tickmarkr 1.80.0 → 1.83.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 (50) hide show
  1. package/README.md +4 -4
  2. package/dist/brand.d.ts +4 -1
  3. package/dist/brand.js +46 -7
  4. package/dist/cli/commands/approve.js +53 -8
  5. package/dist/cli/commands/plan.js +17 -2
  6. package/dist/cli/commands/report.js +15 -6
  7. package/dist/cli/commands/ui.d.ts +1 -1
  8. package/dist/cli/commands/ui.js +47 -4
  9. package/dist/cli/index.d.ts +1 -1
  10. package/dist/cli/index.js +1 -1
  11. package/dist/compile/collateral.d.ts +19 -0
  12. package/dist/compile/collateral.js +90 -0
  13. package/dist/compile/index.js +18 -4
  14. package/dist/drivers/herdr.d.ts +1 -0
  15. package/dist/drivers/herdr.js +19 -0
  16. package/dist/drivers/types.d.ts +1 -0
  17. package/dist/gates/acceptance.js +50 -8
  18. package/dist/gates/llm.js +34 -19
  19. package/dist/gates/review.d.ts +9 -1
  20. package/dist/gates/review.js +173 -7
  21. package/dist/gates/run-gates.d.ts +1 -0
  22. package/dist/gates/run-gates.js +18 -1
  23. package/dist/report/bundle.d.ts +6 -1
  24. package/dist/report/bundle.js +13 -3
  25. package/dist/run/daemon.d.ts +5 -0
  26. package/dist/run/daemon.js +188 -27
  27. package/dist/run/journal.d.ts +5 -0
  28. package/dist/run/journal.js +71 -1
  29. package/dist/tui/cockpit/capture.d.ts +107 -1
  30. package/dist/tui/cockpit/capture.js +287 -10
  31. package/dist/tui/cockpit/components.d.ts +15 -86
  32. package/dist/tui/cockpit/components.js +49 -55
  33. package/dist/tui/cockpit/derive.d.ts +73 -1
  34. package/dist/tui/cockpit/derive.js +211 -15
  35. package/dist/tui/cockpit/keys.d.ts +244 -0
  36. package/dist/tui/cockpit/keys.js +634 -0
  37. package/dist/tui/cockpit/layout.d.ts +162 -0
  38. package/dist/tui/cockpit/layout.js +229 -0
  39. package/dist/tui/cockpit/live.d.ts +80 -0
  40. package/dist/tui/cockpit/live.js +311 -0
  41. package/dist/tui/cockpit/run-cockpit.d.ts +65 -2
  42. package/dist/tui/cockpit/run-cockpit.js +705 -128
  43. package/dist/tui/cockpit/setup-cockpit.d.ts +149 -1
  44. package/dist/tui/cockpit/setup-cockpit.js +432 -56
  45. package/dist/tui/cockpit/views.d.ts +72 -0
  46. package/dist/tui/cockpit/views.js +56 -0
  47. package/dist/tui/cockpit/width.d.ts +105 -0
  48. package/dist/tui/cockpit/width.js +276 -0
  49. package/fixtures/speckit-sample/tasks.md +2 -2
  50. package/package.json +3 -1
@@ -1,46 +1,31 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { Children } from "react";
4
- import stringWidth from "string-width";
5
4
  import { GLYPHS } from "../../brand.js";
6
5
  import { COCKPIT_DATA_RAMP, COCKPIT_INKS, TEXT_EMPHASIS_TOKENS, } from "./theme.js";
7
6
  import { SPARKLINE_BUCKET_WINDOW } from "./derive.js";
8
7
  export { SPARKLINE_BUCKET_WINDOW } from "./derive.js";
8
+ import { formatKeybarEntries, keybarEntries, SETUP_KEY_BINDINGS, } from "./keys.js";
9
+ /**
10
+ * Measurement and cutting come from the cockpit's one width authority; this
11
+ * file owns no width table and counts no code points of its own. The local
12
+ * name stays `stringWidth` because `keys.test.ts`'s branch ledger proves the
13
+ * roster's fit branch by naming that line of this file verbatim and watching
14
+ * the named test fail without it — renaming the call would leave the ledger
15
+ * reporting a missing anchor instead of a covered branch. The name is the
16
+ * ledger's; the measurement is `cellWidth`, and `string-width` the package is
17
+ * no longer imported here.
18
+ */
19
+ import { cellWidth as stringWidth, sliceCells } from "./width.js";
9
20
  const SPARKLINE_GLYPHS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
10
21
  const BAND_GAP_COLUMNS = 1;
11
22
  const PANEL_HORIZONTAL_CHROME = 4;
12
23
  const READABLE_PANEL_COLUMNS = 20;
13
24
  export const PANEL_CHROME_ROWS = 3;
14
25
  export const BAND_CONTINUATION_PREFIX = "↳ ";
15
- const RUN_KEYS = [
16
- { key: "↑↓", label: "Move" },
17
- { key: "⏎", label: "Open" },
18
- { key: "←", label: "Back" },
19
- { key: "Tab", label: "Panel" },
20
- { key: "?", label: "Help" },
21
- { key: "q", label: "Quit" },
22
- { key: "f", label: "Follow" },
23
- { key: "/", label: "Filter" },
24
- ];
25
- const SETUP_ADDITIONAL_KEYS = [
26
- { key: "␣", label: "Toggle" },
27
- { key: "a", label: "All" },
28
- { key: "r", label: "Re-probe" },
29
- { key: "s", label: "Save" },
30
- { key: "n", label: "Next" },
31
- { key: "p", label: "Prev" },
32
- ];
33
- const SETUP_KEYS = [
34
- RUN_KEYS[0],
35
- { key: RUN_KEYS[1].key, label: "Next" },
36
- RUN_KEYS[2],
37
- RUN_KEYS[3],
38
- RUN_KEYS[4],
39
- RUN_KEYS[5],
40
- ...SETUP_ADDITIONAL_KEYS,
41
- ];
26
+ const SETUP_KEYS = keybarEntries(SETUP_KEY_BINDINGS);
27
+ const SETUP_ADDITIONAL_KEYS = SETUP_KEYS.slice(6);
42
28
  export const KEYBAR_KEYS = {
43
- run: RUN_KEYS,
44
29
  setup: SETUP_KEYS,
45
30
  setupAdditional: SETUP_ADDITIONAL_KEYS,
46
31
  };
@@ -108,27 +93,6 @@ export function allocateBandColumns(columns, content, options = {}) {
108
93
  const shares = apportion(usable - base.reduce((sum, value) => sum + value, 0), preferred.map((value, index) => value - base[index]));
109
94
  return base.map((value, index) => value + shares[index]);
110
95
  }
111
- function takeDisplayColumns(value, columns) {
112
- const characters = [...value];
113
- let used = 0;
114
- let taken = 0;
115
- while (taken < characters.length) {
116
- const width = stringWidth(characters[taken]);
117
- if (taken > 0 && used + width > columns)
118
- break;
119
- if (taken === 0 && width > columns) {
120
- taken = 1;
121
- break;
122
- }
123
- used += width;
124
- taken += 1;
125
- }
126
- const concatenate = (items) => items.reduce((result, character) => result + character, "");
127
- return [
128
- concatenate(characters.slice(0, taken)),
129
- concatenate(characters.slice(taken)),
130
- ];
131
- }
132
96
  /**
133
97
  * Compose one data line into visible rows. A fitting line is byte-for-byte
134
98
  * untouched. Every wrapped row carries a continuation marker before any
@@ -164,7 +128,7 @@ export function composeBandLine(text, columns) {
164
128
  continue;
165
129
  }
166
130
  const available = Math.max(1, width - stringWidth(current));
167
- const [head, tail] = takeDisplayColumns(word, available);
131
+ const { head, tail } = sliceCells(word, available);
168
132
  current += head;
169
133
  word = tail;
170
134
  if (word.length === 0)
@@ -269,12 +233,42 @@ export function ProgressMeter({ value, width = 24, }) {
269
233
  function OneLineItems({ children, width, label, }) {
270
234
  return (_jsx(Box, { flexDirection: "row", flexWrap: "nowrap", width: width, height: 1, overflow: "hidden", "aria-label": label, children: children }));
271
235
  }
272
- export function Keybar({ surface, width, }) {
273
- return (_jsx(OneLineItems, { width: width, label: `${surface} cockpit keys`, children: KEYBAR_KEYS[surface].map((item, index) => (_jsxs(Box, { flexShrink: 0, children: [index > 0 && _jsx(BodyText, { emphasis: "dim", children: " \u00B7 " }), _jsx(BodyText, { emphasis: "strong", children: item.key }), _jsxs(BodyText, { emphasis: "dim", children: [" ", item.label] })] }, item.key))) }));
236
+ /**
237
+ * Wrap a projected roster without losing a byte. Unlike prose wrapping, these
238
+ * rows are exact consecutive slices: stripping continuation markers and
239
+ * joining the slices reconstructs the registry projection byte for byte.
240
+ */
241
+ export function keyRosterLines(entries, columns) {
242
+ const width = Math.max(1, Math.floor(columns));
243
+ const formatted = formatKeybarEntries(entries);
244
+ if (stringWidth(formatted) <= width)
245
+ return [formatted];
246
+ const rendered = [];
247
+ let remaining = formatted;
248
+ let first = true;
249
+ while (remaining.length > 0) {
250
+ const prefix = first ? "" : BAND_CONTINUATION_PREFIX;
251
+ const available = Math.max(1, width - stringWidth(prefix));
252
+ const { head: slice, tail } = sliceCells(remaining, available);
253
+ // Ink trims terminal-row trailing spaces. Carry them to the next slice so
254
+ // the painted representation still reconstructs the formatted roster.
255
+ const trailingSpace = slice.match(/\s+$/u)?.[0] ?? "";
256
+ const head = trailingSpace.length === 0
257
+ ? slice
258
+ : slice.slice(0, -trailingSpace.length);
259
+ rendered.push(`${prefix}${head}`);
260
+ remaining = trailingSpace + tail;
261
+ first = false;
262
+ }
263
+ return rendered;
264
+ }
265
+ /** The lossless, width-safe rendering of one projected key roster. */
266
+ export function KeyRoster({ entries, width, }) {
267
+ return (_jsx(Box, { flexDirection: "column", width: width, "aria-label": "run cockpit keys", children: keyRosterLines(entries, width).map((line, index) => (_jsx(BodyText, { emphasis: "dim", children: line }, `${index}:${line}`))) }));
274
268
  }
275
269
  export function StatusStrip({ items, width, }) {
276
270
  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}`))) }));
277
271
  }
278
- export function JournalRowPanel({ rows, width, focused, title = "JOURNAL", }) {
279
- 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 }), _jsxs(BodyText, { children: [" ", row.text] })] }, row.id ?? `${row.time}:${row.state}:${index}`))) }));
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))) }));
280
274
  }
@@ -1,4 +1,5 @@
1
- import type { JournalRow, StatusStripItem } from "./components.js";
1
+ import type { ComponentState, JournalRow, StatusStripItem } from "./components.js";
2
+ import type { RunViewId } from "./views.js";
2
3
  export declare const SPARKLINE_BUCKET_WINDOW = 12;
3
4
  export declare function selectSparklineBucketWidthMs(elapsedMs: number): number;
4
5
  type MetricSample = number | null;
@@ -26,6 +27,9 @@ export type RunCockpitData = {
26
27
  readonly progress: number;
27
28
  readonly progressCaption: string;
28
29
  readonly journalRows: readonly JournalRow[];
30
+ readonly taskRows: readonly TaskRow[];
31
+ readonly gateRows: readonly GateRow[];
32
+ readonly fleetRows: readonly FleetRow[];
29
33
  readonly statusItems: readonly StatusStripItem[];
30
34
  };
31
35
  export type RunCockpitSource = {
@@ -34,6 +38,74 @@ export type RunCockpitSource = {
34
38
  };
35
39
  export type RunCockpitDeriveOptions = {
36
40
  readonly isDaemonAlive?: (pid: number) => boolean;
41
+ /** The compiled graph, when the engagement has one. Identity only — see CockpitGraph. */
42
+ readonly graph?: CockpitGraph;
37
43
  };
44
+ /**
45
+ * What the compiled graph lends the cockpit: which tasks exist, in what order,
46
+ * and what each is called. `status` is named here only to state that it is
47
+ * deliberately unread — the journal owns state, so a recompile can never
48
+ * repaint a parked task green.
49
+ */
50
+ export type CockpitGraph = {
51
+ readonly tasks: readonly {
52
+ readonly id: string;
53
+ readonly title?: string;
54
+ readonly status?: string;
55
+ }[];
56
+ };
57
+ /** How an unrecorded field draws: the engagement said nothing, so the row says nothing. */
58
+ export declare const ABSENT_FIELD = "-";
59
+ /**
60
+ * One reading of a row field. An absent field is a fact about the engagement —
61
+ * it never recorded this — and never collapses into an empty string or a zero
62
+ * that reads like a measurement.
63
+ */
64
+ export declare function fieldReading(value: string | number | undefined): string;
65
+ /**
66
+ * One task of the compiled graph, wearing only what the journal recorded about
67
+ * it. A task the journal never mentions still draws a row; its state, attempts,
68
+ * actor and last event time are absent rather than invented — including the
69
+ * state, because "pending" is a thing the engagement would have to have
70
+ * recorded, not a thing a row may assume about silence.
71
+ *
72
+ * `attempts` is a count of recorded dispatches, never the newest dispatch's
73
+ * `attempt` label: labels restart at zero on resume, so the label of the last
74
+ * dispatch understates a task the engagement resumed into.
75
+ */
76
+ export type TaskRow = {
77
+ readonly id: string;
78
+ readonly taskId: string;
79
+ readonly state?: TaskState;
80
+ readonly attempts?: number;
81
+ readonly actor?: string;
82
+ readonly lastEventTime?: string;
83
+ readonly title?: string;
84
+ };
85
+ /** One recorded gate result. `details` is the record's own text, verbatim. */
86
+ export type GateRow = {
87
+ readonly id: string;
88
+ readonly time: string;
89
+ readonly state: ComponentState;
90
+ readonly gate?: string;
91
+ readonly taskId?: string;
92
+ readonly pass?: boolean;
93
+ readonly details?: string;
94
+ };
95
+ /** One channel the engagement recorded, dispatched to or merely named. */
96
+ export type FleetRow = {
97
+ readonly id: string;
98
+ readonly adapter: string;
99
+ readonly model: string;
100
+ readonly dispatches?: number;
101
+ readonly lastEventTime?: string;
102
+ };
103
+ export type TaskState = "done" | "failed" | "human" | "pending" | "running" | "interrupted";
104
+ /**
105
+ * The source identities the named view's rows carry, in draw order. Selection
106
+ * is repaired against these, so it follows a row across a refresh instead of
107
+ * following the index the row happened to occupy.
108
+ */
109
+ export declare function runViewRowIdentities(data: RunCockpitData, viewId: RunViewId, filterQuery?: string): readonly string[];
38
110
  export declare function deriveRunCockpitData(source: RunCockpitSource, binaryVersion: string, options?: RunCockpitDeriveOptions): RunCockpitData;
39
111
  export {};
@@ -1,3 +1,4 @@
1
+ import { channelKey } from "../../adapters/types.js";
1
2
  export const SPARKLINE_BUCKET_WINDOW = 12;
2
3
  const MINUTE_MS = 60_000;
3
4
  const SPARKLINE_BUCKET_WIDTH_LADDER_MINUTES = [
@@ -22,6 +23,16 @@ export function selectSparklineBucketWidthMs(elapsedMs) {
22
23
  coarsened *= 2;
23
24
  return coarsened * MINUTE_MS;
24
25
  }
26
+ /** How an unrecorded field draws: the engagement said nothing, so the row says nothing. */
27
+ export const ABSENT_FIELD = "-";
28
+ /**
29
+ * One reading of a row field. An absent field is a fact about the engagement —
30
+ * it never recorded this — and never collapses into an empty string or a zero
31
+ * that reads like a measurement.
32
+ */
33
+ export function fieldReading(value) {
34
+ return value === undefined ? ABSENT_FIELD : String(value);
35
+ }
25
36
  function recordValue(value) {
26
37
  return typeof value === "object" && value !== null && !Array.isArray(value)
27
38
  ? value
@@ -114,7 +125,7 @@ function deriveTasks(events, interrupted) {
114
125
  const created = {
115
126
  id: taskId,
116
127
  dispatches: [],
117
- state: "pending",
128
+ state: undefined,
118
129
  lastIndex: index,
119
130
  lastTs: ts,
120
131
  phase: "pending",
@@ -164,13 +175,36 @@ function deriveTasks(events, interrupted) {
164
175
  }
165
176
  if (interrupted) {
166
177
  for (const fact of tasks.values()) {
167
- if (fact.state === "running" || fact.state === "pending") {
178
+ // Only a task the journal recorded as running. `task-dispatch` recorded
179
+ // that it started; no event records how it ended; the daemon that would
180
+ // have recorded that ending is gone. So the recorded state cannot still
181
+ // be true, and its interruption is a reading of that record — not an
182
+ // invention on top of silence.
183
+ //
184
+ // Silence stays silent. A task the engagement mentioned without ever
185
+ // recording a state keeps none: the run's stopping is a fact about the
186
+ // run, and lending it to a task would be exactly the assumption an absent
187
+ // field exists to refuse.
188
+ //
189
+ // A state the journal did record otherwise is never overwritten —
190
+ // `pending` is what `task-approved` recorded, and it says the task was
191
+ // released back to the pool to await a dispatch, not that a dispatch was
192
+ // cut short. In the real post-run approval sequence (run-end, then the
193
+ // operator approves) that approval is the newest thing the engagement
194
+ // recorded about the task, so reporting it as interrupted would be the
195
+ // run's stopping overruling the journal. The journal wins.
196
+ if (fact.state === "running")
168
197
  fact.state = "interrupted";
169
- }
170
198
  }
171
199
  }
172
200
  return tasks;
173
201
  }
202
+ /**
203
+ * The label the engagement gave the attempt now standing — which is what the
204
+ * spotlight caption is naming ("attempt 3 of 4", the ladder's own numbering).
205
+ * Not a count of attempts: labels restart at zero on resume. Rows that mean
206
+ * "how many times was this tried" count dispatches instead — see TaskRow.
207
+ */
174
208
  function taskAttempt(fact) {
175
209
  return fact.dispatches.at(-1)?.attempt ?? Math.max(1, fact.dispatches.length);
176
210
  }
@@ -227,9 +261,10 @@ function recordsSuccess(event) {
227
261
  }
228
262
  /**
229
263
  * A row depicts the event it names: the event's own recorded outcome decides
230
- * the state, never the state its task reached later. Only a task with no
231
- * terminal event in a run whose daemon is gone lends its interruption, because
232
- * no event records that ending — the interruption is the run's present truth.
264
+ * the state, never the state its task reached later. Only a task the journal
265
+ * recorded as running, in a run whose daemon is gone, lends its interruption:
266
+ * a dispatch was recorded, no event records how it ended, and the process that
267
+ * would have recorded that ending is no longer there.
233
268
  * Every state carries its word, so meaning never rides on hue alone.
234
269
  */
235
270
  function eventPresentation(event, task) {
@@ -269,6 +304,147 @@ function journalRows(events, tasks, defects, runStatus) {
269
304
  const history = [...events].reverse().map((event, index) => historyRow(event, tasks, spotlight, index === 0, runStatus));
270
305
  return [...[...defects].reverse().map(defectRow), ...history];
271
306
  }
307
+ /**
308
+ * The tasks the engagement owns, in the compiled graph's order: the graph
309
+ * supplies identity and nothing else, so a task the journal never mentions
310
+ * still draws a row. Every other field is read off recorded events — the
311
+ * graph's own `status` is never consulted, which is what keeps a recompiled
312
+ * graph from repainting a parked task green.
313
+ */
314
+ function taskRows(events, tasks, graph) {
315
+ const lastSeen = new Map();
316
+ for (const event of events) {
317
+ if (event.taskId !== undefined)
318
+ lastSeen.set(event.taskId, event.ts);
319
+ }
320
+ const identities = graph !== undefined
321
+ ? graph.tasks.map((task) => ({ id: task.id, title: task.title }))
322
+ // Without a graph the engagement's own records are the only identities there are.
323
+ : [...new Set([...tasks.keys(), ...lastSeen.keys()])].map((id) => ({
324
+ id,
325
+ title: undefined,
326
+ }));
327
+ return identities.map(({ id, title }) => {
328
+ const fact = tasks.get(id);
329
+ const assignment = fact?.dispatches.at(-1)?.assignment;
330
+ const lastTs = lastSeen.get(id);
331
+ // Counted, not read off the newest dispatch's label: the label restarts at
332
+ // zero when a run resumes, so a task dispatched 0,1,0 was tried three times
333
+ // and reporting its last label would say one.
334
+ const attempts = fact?.dispatches.length ?? 0;
335
+ return {
336
+ id: `task:${id}`,
337
+ taskId: id,
338
+ ...(fact?.state === undefined ? {} : { state: fact.state }),
339
+ ...(attempts === 0 ? {} : { attempts }),
340
+ ...(assignment === undefined ? {} : { actor: channelKey(assignment) }),
341
+ ...(lastTs === undefined ? {} : { lastEventTime: eventTime(lastTs) }),
342
+ ...(title === undefined ? {} : { title }),
343
+ };
344
+ });
345
+ }
346
+ /**
347
+ * One row per recorded gate result, newest first. The recorded details ride
348
+ * through untouched — the operator reads the reviewer's findings and the
349
+ * failing test names as the gate wrote them, never a rephrasing.
350
+ */
351
+ function gateRows(events) {
352
+ const rows = events.flatMap((event) => {
353
+ if (event.event !== "gate-result")
354
+ return [];
355
+ const pass = typeof event.data.pass === "boolean" ? event.data.pass : undefined;
356
+ return [{
357
+ id: `gate:${event.line}`,
358
+ time: eventTime(event.ts),
359
+ state: pass === true ? "pass" : pass === false ? "fail" : "neutral",
360
+ ...(typeof event.data.gate === "string" ? { gate: event.data.gate } : {}),
361
+ ...(event.taskId === undefined ? {} : { taskId: event.taskId }),
362
+ ...(pass === undefined ? {} : { pass }),
363
+ ...(typeof event.data.details === "string" ? { details: event.data.details } : {}),
364
+ }];
365
+ });
366
+ return rows.reverse();
367
+ }
368
+ /** An `adapter:model` channel key back into its parts, or nothing if it is not one. */
369
+ function splitChannelKey(key) {
370
+ const separator = key.indexOf(":");
371
+ if (separator <= 0 || separator >= key.length - 1)
372
+ return undefined;
373
+ return { adapter: key.slice(0, separator), model: key.slice(separator + 1) };
374
+ }
375
+ /**
376
+ * One row per channel the engagement recorded, in the order it first named
377
+ * them — starting with the roster it recorded at run-start, which is the whole
378
+ * fleet it could have dispatched to, then any channel a later event named that
379
+ * the roster did not. A channel it named without dispatching — a roster seat it
380
+ * never used, or the static choice a failover passed over — carries no dispatch
381
+ * count at all, because zero dispatches is something the engagement never
382
+ * measured, not a measurement of zero.
383
+ */
384
+ function fleetRows(events) {
385
+ const channels = new Map();
386
+ const note = (channel, ts, dispatched) => {
387
+ const key = channelKey(channel);
388
+ const existing = channels.get(key)
389
+ ?? { adapter: channel.adapter, model: channel.model, dispatches: 0, lastTs: ts };
390
+ channels.set(key, {
391
+ ...existing,
392
+ dispatches: existing.dispatches + (dispatched ? 1 : 0),
393
+ lastTs: ts,
394
+ });
395
+ };
396
+ const noteKeys = (recorded, ts) => {
397
+ for (const key of recorded) {
398
+ const channel = typeof key === "string" ? splitChannelKey(key) : undefined;
399
+ if (channel)
400
+ note(channel, ts, false);
401
+ }
402
+ };
403
+ for (const event of events) {
404
+ if (event.event === "run-start" && Array.isArray(event.data.channels)) {
405
+ noteKeys(event.data.channels, event.ts);
406
+ continue;
407
+ }
408
+ if (event.event === "task-dispatch") {
409
+ const assignment = assignmentFrom(event.data.assignment);
410
+ if (assignment)
411
+ note(assignment, event.ts, true);
412
+ continue;
413
+ }
414
+ if (event.event === "failover-deviation") {
415
+ noteKeys([event.data.static, event.data.chosen], event.ts);
416
+ }
417
+ }
418
+ return [...channels.entries()].map(([key, channel]) => ({
419
+ id: `channel:${key}`,
420
+ adapter: channel.adapter,
421
+ model: channel.model,
422
+ ...(channel.dispatches === 0 ? {} : { dispatches: channel.dispatches }),
423
+ lastEventTime: eventTime(channel.lastTs),
424
+ }));
425
+ }
426
+ /**
427
+ * The source identities the named view's rows carry, in draw order. Selection
428
+ * is repaired against these, so it follows a row across a refresh instead of
429
+ * following the index the row happened to occupy.
430
+ */
431
+ export function runViewRowIdentities(data, viewId,
432
+ // ponytail: the filter is the journal's, as it has always been; the promoted
433
+ // views gain one when a criterion asks for it.
434
+ filterQuery = "") {
435
+ if (viewId === "tasks")
436
+ return data.taskRows.map((row) => row.id);
437
+ if (viewId === "gates")
438
+ return data.gateRows.map((row) => row.id);
439
+ if (viewId === "fleet")
440
+ return data.fleetRows.map((row) => row.id);
441
+ if (viewId !== "journal")
442
+ return [];
443
+ const query = filterQuery.trim().toLowerCase();
444
+ return data.journalRows
445
+ .filter((row) => query === "" || row.text.toLowerCase().includes(query))
446
+ .map((row) => row.id);
447
+ }
272
448
  function bucketedMetricSamples(events) {
273
449
  const taskSamples = Array.from({ length: SPARKLINE_BUCKET_WINDOW }, () => null);
274
450
  const gateTotals = Array.from({ length: SPARKLINE_BUCKET_WINDOW }, () => 0);
@@ -302,21 +478,35 @@ function bucketedMetricSamples(events) {
302
478
  passRate: gateTotals.map((total, index) => total === 0 ? null : Math.round((gatePasses[index] / total) * 100)),
303
479
  };
304
480
  }
481
+ // OBS-244: the strip reports the LATEST verification cycle, never the whole journal conjoined.
482
+ // The daemon runs the integration-tip commands and appends `run-end` in one breath (daemon.ts
483
+ // finishRun), so a cycle is the tip-verify events recorded since the previous run-end together
484
+ // with the verdict on the run-end that closes them — a mid-run failure a later cycle recovered is
485
+ // no longer permanent red. `undefined` means nothing was verified: drawn absent, never passed.
486
+ // Fail-closed everywhere else — a cycle with no closing verdict, or a verdict with no events, is
487
+ // a failure, because a claim tickmarkr cannot corroborate is not a pass.
305
488
  function tipVerificationPassed(events) {
306
- const eventVerdicts = events.flatMap((event) => {
489
+ const runEnds = events.flatMap((event, index) => event.event === "run-end" ? [index] : []);
490
+ const lastRunEnd = runEnds.at(-1);
491
+ const cycle = events.slice((runEnds.at(-2) ?? -1) + 1, lastRunEnd === undefined ? events.length : lastRunEnd + 1);
492
+ const cycleVerdicts = cycle.flatMap((event) => {
307
493
  if (event.event === "tip-verify-failed")
308
494
  return [false];
309
495
  if (event.event === "tip-verify")
310
496
  return [event.data.pass === true];
311
497
  return [];
312
498
  });
313
- const endVerdicts = events.flatMap((event) => event.event === "run-end" && typeof event.data.tipVerify === "string"
314
- ? [event.data.tipVerify === "passed"]
315
- : []);
316
- return eventVerdicts.length > 0
317
- && endVerdicts.length > 0
318
- && eventVerdicts.every(Boolean)
319
- && endVerdicts.every(Boolean);
499
+ const closing = lastRunEnd === undefined
500
+ ? undefined
501
+ : events[lastRunEnd].data.tipVerify;
502
+ const endVerdict = typeof closing === "string"
503
+ ? closing === "passed"
504
+ : undefined;
505
+ if (cycleVerdicts.length === 0 && endVerdict === undefined)
506
+ return undefined;
507
+ return endVerdict === true
508
+ && cycleVerdicts.length > 0
509
+ && cycleVerdicts.every(Boolean);
320
510
  }
321
511
  export function deriveRunCockpitData(source, binaryVersion, options = {}) {
322
512
  const { events, defects } = parseSource(source.raw);
@@ -379,8 +569,14 @@ export function deriveRunCockpitData(source, binaryVersion, options = {}) {
379
569
  ? `${spotlight.id} · ${spotlight.phase} · attempt ${taskAttempt(spotlight)} · ${taskActor(spotlight)}`
380
570
  : "- · pending · attempt 1 · unassigned",
381
571
  journalRows: journalRows(events, tasks, defects, runStatus),
572
+ taskRows: taskRows(events, tasks, options.graph),
573
+ gateRows: gateRows(events),
574
+ fleetRows: fleetRows(events),
382
575
  statusItems: [
383
- { state: tipPassed ? "pass" : "fail", text: `tip-verify ${tipPassed ? "passed" : "FAILED"}` },
576
+ {
577
+ state: tipPassed === undefined ? "neutral" : tipPassed ? "pass" : "fail",
578
+ text: `tip-verify ${tipPassed === undefined ? ABSENT_FIELD : tipPassed ? "passed" : "FAILED"}`,
579
+ },
384
580
  { state: "pass", text: `done ${done}` },
385
581
  { state: failed > 0 ? "fail" : "neutral", text: `failed ${failed}` },
386
582
  { state: human > 0 ? "warn" : "neutral", text: `human ${human}` },