tickmarkr 2.5.6 → 2.5.8

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 (59) hide show
  1. package/dist/adapters/prompt.js +21 -1
  2. package/dist/cli/commands/approve.js +13 -6
  3. package/dist/cli/commands/compile.js +7 -0
  4. package/dist/cli/commands/plan.d.ts +5 -0
  5. package/dist/cli/commands/plan.js +28 -23
  6. package/dist/cli/commands/resume.js +1 -1
  7. package/dist/cli/commands/run.js +1 -1
  8. package/dist/cli/commands/status.js +95 -34
  9. package/dist/cli/commands/verify.js +6 -4
  10. package/dist/compile/native.js +68 -7
  11. package/dist/compile/retired-literals.d.ts +22 -0
  12. package/dist/compile/retired-literals.js +271 -0
  13. package/dist/drivers/index.d.ts +4 -2
  14. package/dist/drivers/index.js +54 -6
  15. package/dist/gates/baseline.d.ts +8 -3
  16. package/dist/gates/baseline.js +6 -3
  17. package/dist/gates/review.js +12 -1
  18. package/dist/gates/run-gates.d.ts +6 -1
  19. package/dist/gates/run-gates.js +67 -9
  20. package/dist/gates/test-manifest.d.ts +12 -0
  21. package/dist/gates/test-manifest.js +29 -3
  22. package/dist/gates/test-reporter.js +28 -2
  23. package/dist/graph/graph.d.ts +2 -0
  24. package/dist/graph/graph.js +5 -0
  25. package/dist/graph/schema.d.ts +28 -0
  26. package/dist/graph/schema.js +13 -1
  27. package/dist/run/activity.d.ts +28 -0
  28. package/dist/run/activity.js +194 -0
  29. package/dist/run/daemon.d.ts +28 -0
  30. package/dist/run/daemon.js +466 -110
  31. package/dist/run/git.d.ts +6 -1
  32. package/dist/run/git.js +63 -11
  33. package/dist/run/journal.d.ts +57 -5
  34. package/dist/run/journal.js +103 -9
  35. package/dist/run/merge.d.ts +2 -0
  36. package/dist/run/merge.js +1 -0
  37. package/dist/run/operator-page-summary.d.ts +56 -0
  38. package/dist/run/operator-page-summary.js +68 -0
  39. package/dist/run/operator-summary.d.ts +69 -0
  40. package/dist/run/operator-summary.js +77 -0
  41. package/dist/run/protocol.d.ts +71 -0
  42. package/dist/run/protocol.js +32 -0
  43. package/dist/run/repair-disposition.d.ts +41 -0
  44. package/dist/run/repair-disposition.js +77 -0
  45. package/dist/tui/cockpit/board.d.ts +9 -0
  46. package/dist/tui/cockpit/board.js +10 -0
  47. package/dist/tui/cockpit/derive.d.ts +35 -0
  48. package/dist/tui/cockpit/derive.js +152 -10
  49. package/dist/tui/cockpit/evidence-view.d.ts +2 -0
  50. package/dist/tui/cockpit/evidence-view.js +42 -12
  51. package/dist/tui/cockpit/home-view.js +45 -30
  52. package/dist/tui/cockpit/live-store.d.ts +18 -0
  53. package/dist/tui/cockpit/run-cockpit.d.ts +8 -1
  54. package/dist/tui/cockpit/run-cockpit.js +95 -1
  55. package/dist/tui/cockpit/run-view.d.ts +37 -0
  56. package/dist/tui/cockpit/run-view.js +189 -2
  57. package/package.json +1 -1
  58. package/schema/rungraph.schema.json +54 -0
  59. package/skills/tickmarkr-overseer/SKILL.md +162 -3
@@ -2,10 +2,12 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
2
2
  import { existsSync, renameSync, statSync, unlinkSync } from "node:fs";
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import { Box, useInput } from "ink";
5
- import { useEffect, useState } from "react";
5
+ import { useEffect, useRef, useState } from "react";
6
6
  import { buildOperatorRecord, formatOperatorRecordRow } from "../../report/operator-record.js";
7
+ import { EMPTY_OPERATOR_PAGE_SUMMARY, foldOperatorPages, operatorPageRow } from "../../run/operator-page-summary.js";
7
8
  import { formatJournalNarration } from "../../run/journal.js";
8
9
  import { BodyText, JournalRowPanel, Panel } from "./components.js";
10
+ import { cellWidth, sliceCells } from "./width.js";
9
11
  // review.ts:485 emits `; raw saved: <path>) — failing closed`, the path immediately followed by
10
12
  // the message's closing paren — so the capture must stop before a trailing `)`, never swallow it.
11
13
  const ARTIFACT_PATTERN = /raw saved:\s*([^\s)]+)/gi;
@@ -64,7 +66,7 @@ export function deriveEvidenceJournal(eventsOrRows, source = "journal.jsonl") {
64
66
  time: e.ts,
65
67
  state: stateFor(e),
66
68
  text: formatJournalNarration(e),
67
- fullText: details,
69
+ fullText: details || JSON.stringify(e.data, null, 2),
68
70
  artifacts,
69
71
  ...(e.taskId ? { taskId: e.taskId } : {}),
70
72
  ...(gate ? { gate } : {}),
@@ -89,6 +91,15 @@ export function deriveEvidenceView(input) {
89
91
  ? input.rows.flatMap((r) => (r.event ? [r.event] : []))
90
92
  : (input.events ?? []);
91
93
  return {
94
+ operatorPages: foldOperatorPages(EMPTY_OPERATOR_PAGE_SUMMARY, items.flatMap((item, index) => {
95
+ const tracked = "line" in item;
96
+ const event = tracked ? item.event : item;
97
+ if (!event)
98
+ return [];
99
+ // The source identifies the owning run; preserve physical lines across malformed rows.
100
+ const row = operatorPageRow(event, tracked ? item.line : index + 1, tracked ? item.source ?? source : source);
101
+ return row ? [row] : [];
102
+ })).groups,
92
103
  journal: deriveEvidenceJournal(items, source),
93
104
  channels: buildOperatorRecord(events, input.costs ?? []),
94
105
  reportLines: input.reportLines ?? [],
@@ -171,6 +182,18 @@ export function EvidenceView({ model, width, focused = true, focusEvidence, onSe
171
182
  }, [focusEvidence?.source, focusEvidence?.line]);
172
183
  const selectedIndex = model.journal.findIndex((row) => row.evidence.line === selectedLine);
173
184
  const selectedRow = selectedIndex >= 0 ? model.journal[selectedIndex] : undefined;
185
+ // The key handler reads the selection from this ref, never from the render it closed over: ink
186
+ // re-subscribes the handler in an effect AFTER the frame is drawn, so a key arriving in that
187
+ // window would act on the previous row while the screen shows the new one (Enter opened the row
188
+ // above the highlighted one; a second fast arrow lost its step). Render resyncs it; a move
189
+ // writes it before the state update, so the next key sees the move even with no render between.
190
+ const lineRef = useRef(selectedLine);
191
+ lineRef.current = selectedLine;
192
+ const moveTo = (index) => {
193
+ const line = model.journal[index].evidence.line;
194
+ lineRef.current = line;
195
+ setSelectedLine(line);
196
+ };
174
197
  useInput((input, key) => {
175
198
  if (key.leftArrow)
176
199
  setTab((t) => TABS[(TABS.indexOf(t) - 1 + TABS.length) % TABS.length]);
@@ -182,23 +205,30 @@ export function EvidenceView({ model, width, focused = true, focusEvidence, onSe
182
205
  return;
183
206
  if (input === "f")
184
207
  setFollow((f) => !f);
208
+ const current = model.journal.findIndex((row) => row.evidence.line === lineRef.current);
185
209
  if (key.upArrow) {
186
210
  setFollow(false);
187
- const i = Math.max(0, (selectedIndex < 0 ? model.journal.length - 1 : selectedIndex) - 1);
188
- setSelectedLine(model.journal[i].evidence.line);
211
+ moveTo(Math.max(0, (current < 0 ? model.journal.length - 1 : current) - 1));
189
212
  }
190
213
  if (key.downArrow) {
191
214
  setFollow(false);
192
- const i = Math.min(model.journal.length - 1, (selectedIndex < 0 ? 0 : selectedIndex) + 1);
193
- setSelectedLine(model.journal[i].evidence.line);
215
+ moveTo(Math.min(model.journal.length - 1, (current < 0 ? 0 : current) + 1));
194
216
  }
195
- if (key.return && selectedRow && onSelect)
196
- onSelect(selectedRow.evidence);
217
+ if (key.return && current >= 0 && onSelect)
218
+ onSelect(model.journal[current].evidence);
197
219
  }, { isActive: focused });
198
- const journalRows = model.journal.map((row) => ({
199
- id: row.evidence.id, time: row.time, state: row.state, text: row.text,
200
- }));
201
- return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsxs(BodyText, { emphasis: "strong", children: ["EVIDENCE ", TABS.map((t) => (t === tab ? `[${TAB_TITLE[t]}]` : TAB_TITLE[t])).join(" "), tab === "journal" ? ` | Follow ${follow ? "on" : "off"}` : "", selectedRow ? ` | selected ${selectedRow.evidence.id}` : ""] }), tab === "journal" && (_jsxs(_Fragment, { children: [_jsx(JournalRowPanel, { rows: journalRows, selection: selectedIndex >= 0 ? selectedIndex : undefined }), _jsx(Panel, { title: "SELECTED EVIDENCE", children: selectedRow
220
+ const journalRows = model.journal.map((row) => {
221
+ // Leave room for physical source references and complete state labels in the narrow body.
222
+ // The raw row opened with Enter retains the full recorded timestamp.
223
+ const time = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(row.time) ? row.time.slice(11, 19) : row.time;
224
+ const text = `#L${row.evidence.line} ${row.text}`;
225
+ // Reserve the widest state label (18 cells), two separators and the selection pointer.
226
+ // Bound narration before Yoga lays out the row so long task IDs cannot squeeze state words.
227
+ const budget = typeof width === "number" ? Math.max(1, width - cellWidth(time) - 22) : undefined;
228
+ const clipped = budget === undefined || cellWidth(text) <= budget ? text : `${sliceCells(text, budget - 1).head}…`;
229
+ return { id: row.evidence.id, time, state: row.state, text: clipped };
230
+ });
231
+ return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsxs(BodyText, { emphasis: "strong", children: ["EVIDENCE ", TABS.map((t) => (t === tab ? `[${TAB_TITLE[t]}]` : TAB_TITLE[t])).join(" "), tab === "journal" ? ` | Follow ${follow ? "on" : "off"}` : "", selectedRow ? ` | selected ${selectedRow.evidence.id}` : ""] }), tab === "journal" && (_jsxs(_Fragment, { children: [_jsx(JournalRowPanel, { title: "RAW JOURNAL", rows: journalRows, selection: selectedIndex >= 0 ? selectedIndex : undefined }), _jsx(Panel, { title: "OPERATOR PAGE GROUPS", children: model.operatorPages.length ? model.operatorPages.map((group, index) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(BodyText, { emphasis: "strong", children: [group.taskId, " \u00B7 ", group.park, " \u00B7 ", group.status] }), _jsxs(BodyText, { children: ["first ", group.firstEvidenceAt] }), _jsxs(BodyText, { children: ["last ", group.lastEvidenceAt] }), _jsxs(BodyText, { children: ["visible ", group.observedCount, " \u00B7 suppressed ", group.suppressedCount, group.rawOnly ? " · raw-only (suppression unrecorded)" : ""] }), _jsx(BodyText, { children: "Raw source lines (\u2191\u2193 select, Enter open):" }), group.lines.map(line => _jsxs(BodyText, { children: ["#L", line] }, line))] }, `${group.key}:${index}`))) : _jsx(BodyText, { emphasis: "dim", children: "no operator pages" }) }), _jsx(Panel, { title: "SELECTED EVIDENCE", children: selectedRow
202
232
  ? (_jsxs(_Fragment, { children: [_jsxs(BodyText, { emphasis: "dim", children: ["Original journal ", selectedRow.evidence.id] }), selectedRow.fullText.split("\n").map((line, i) => _jsx(BodyText, { children: line }, i)), _jsxs(BodyText, { emphasis: "dim", children: ["Artifacts: ", selectedRow.artifacts.length ? selectedRow.artifacts.join(", ") : "missing"] })] }))
203
233
  : _jsx(BodyText, { emphasis: "dim", children: "no history" }) })] })), tab === "report" && (_jsx(Panel, { title: "REPORT", children: model.reportLines.length
204
234
  ? model.reportLines.map((line, i) => _jsx(BodyText, { children: line }, i))
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, useInput } from "ink";
3
- import { useLayoutEffect, useState } from "react";
3
+ import { useLayoutEffect, useRef, useState } from "react";
4
4
  import { GLYPHS } from "../../brand.js";
5
5
  import { BodyText, CockpitGrid, Panel, ProgressMeter, Sparkline, StateGlyph, } from "./components.js";
6
6
  import { ABSENT_FIELD, fieldReading } from "./derive.js";
@@ -146,75 +146,90 @@ export function HomeView({ model, width, focused = true, onOpenPark, onOpenDiagn
146
146
  const maxOffset = Math.max(0, model.activity.length - 1);
147
147
  const maxSelection = Math.max(0, model.activity.length - 1);
148
148
  const safeOffset = Math.min(offset, maxOffset);
149
+ // Keep input ahead of the next render/effect subscription, as in EvidenceView.
150
+ // Render reconciles the refs with model updates; each move writes before setting state.
151
+ const sectionRef = useRef(effectiveSection);
152
+ const needsYouIndexRef = useRef(effectiveNeedsYouIndex);
153
+ const activityIndexRef = useRef(effectiveActivityIndex);
154
+ sectionRef.current = effectiveSection;
155
+ needsYouIndexRef.current = effectiveNeedsYouIndex;
156
+ activityIndexRef.current = effectiveActivityIndex;
157
+ const moveSection = (next) => {
158
+ sectionRef.current = next;
159
+ setSection(next);
160
+ };
161
+ const moveNeedsYou = (index) => {
162
+ needsYouIndexRef.current = index;
163
+ setNeedsYouIndex(index);
164
+ setSelectedNeedsYouId(model.needsYou[index]?.id);
165
+ };
166
+ const moveActivity = (index) => {
167
+ activityIndexRef.current = index;
168
+ setActivityIndex(index);
169
+ setSelectedEvidenceId(model.activity[index]?.evidence.id);
170
+ };
149
171
  useInput((_input, key) => {
150
172
  if (key.leftArrow && model.needsYou.length > 0) {
151
- if (effectiveSection === "needsYou") {
152
- const nextIndex = (effectiveNeedsYouIndex + 1) % model.needsYou.length;
153
- setNeedsYouIndex(nextIndex);
154
- setSelectedNeedsYouId(model.needsYou[nextIndex]?.id);
173
+ if (sectionRef.current === "needsYou") {
174
+ const nextIndex = (needsYouIndexRef.current + 1) % model.needsYou.length;
175
+ moveNeedsYou(nextIndex);
155
176
  }
156
177
  else {
157
- setSection("needsYou");
178
+ moveSection("needsYou");
158
179
  }
159
180
  }
160
181
  if (key.rightArrow)
161
- setSection("activity");
182
+ moveSection("activity");
162
183
  if (key.upArrow) {
163
- if (effectiveSection === "needsYou") {
184
+ if (sectionRef.current === "needsYou") {
164
185
  if (model.needsYou.length > 0) {
165
- const nextIndex = (effectiveNeedsYouIndex - 1 + model.needsYou.length) % model.needsYou.length;
166
- setNeedsYouIndex(nextIndex);
167
- setSelectedNeedsYouId(model.needsYou[nextIndex]?.id);
186
+ const nextIndex = (needsYouIndexRef.current - 1 + model.needsYou.length) % model.needsYou.length;
187
+ moveNeedsYou(nextIndex);
168
188
  }
169
189
  }
170
190
  else {
171
191
  if (model.activity.length > 0) {
172
- const nextIndex = Math.max(0, effectiveActivityIndex - 1);
173
- setActivityIndex(nextIndex);
174
- setSelectedEvidenceId(model.activity[nextIndex]?.evidence.id);
192
+ const nextIndex = Math.max(0, activityIndexRef.current - 1);
193
+ moveActivity(nextIndex);
175
194
  }
176
195
  }
177
196
  }
178
197
  if (key.downArrow) {
179
- if (effectiveSection === "needsYou") {
198
+ if (sectionRef.current === "needsYou") {
180
199
  if (model.needsYou.length > 0) {
181
- const nextIndex = (effectiveNeedsYouIndex + 1) % model.needsYou.length;
182
- setNeedsYouIndex(nextIndex);
183
- setSelectedNeedsYouId(model.needsYou[nextIndex]?.id);
200
+ const nextIndex = (needsYouIndexRef.current + 1) % model.needsYou.length;
201
+ moveNeedsYou(nextIndex);
184
202
  }
185
203
  }
186
204
  else {
187
205
  if (model.activity.length > 0) {
188
- const nextIndex = Math.min(maxSelection, effectiveActivityIndex + 1);
189
- setActivityIndex(nextIndex);
190
- setSelectedEvidenceId(model.activity[nextIndex]?.evidence.id);
206
+ const nextIndex = Math.min(maxSelection, activityIndexRef.current + 1);
207
+ moveActivity(nextIndex);
191
208
  }
192
209
  }
193
210
  }
194
211
  if (key.pageDown) {
195
212
  setOffset((o) => Math.max(0, o - HOME_ACTIVITY_VISIBLE_ROWS));
196
213
  if (model.activity.length > 0) {
197
- const nextIndex = Math.max(0, effectiveActivityIndex - HOME_ACTIVITY_VISIBLE_ROWS);
198
- setActivityIndex(nextIndex);
199
- setSelectedEvidenceId(model.activity[nextIndex]?.evidence.id);
214
+ const nextIndex = Math.max(0, activityIndexRef.current - HOME_ACTIVITY_VISIBLE_ROWS);
215
+ moveActivity(nextIndex);
200
216
  }
201
217
  }
202
218
  if (key.pageUp) {
203
219
  setOffset((o) => Math.min(maxOffset, o + HOME_ACTIVITY_VISIBLE_ROWS));
204
220
  if (model.activity.length > 0) {
205
- const nextIndex = Math.min(maxSelection, effectiveActivityIndex + HOME_ACTIVITY_VISIBLE_ROWS);
206
- setActivityIndex(nextIndex);
207
- setSelectedEvidenceId(model.activity[nextIndex]?.evidence.id);
221
+ const nextIndex = Math.min(maxSelection, activityIndexRef.current + HOME_ACTIVITY_VISIBLE_ROWS);
222
+ moveActivity(nextIndex);
208
223
  }
209
224
  }
210
225
  if (key.return) {
211
- if (effectiveSection === "needsYou") {
212
- const target = selectedNeedsYou;
226
+ if (sectionRef.current === "needsYou") {
227
+ const target = selectNeedsYouTarget(model, needsYouIndexRef.current);
213
228
  if (target)
214
229
  (target.kind === "park" ? onOpenPark : onOpenDiagnostic)(target.id);
215
230
  }
216
231
  else {
217
- const row = model.activity[effectiveActivityIndex];
232
+ const row = model.activity[activityIndexRef.current];
218
233
  if (row)
219
234
  onOpenEvidence(row.evidence);
220
235
  }
@@ -121,6 +121,7 @@ export declare function createLiveStore(options: LiveStoreOptions): {
121
121
  oracle: "test";
122
122
  test: string;
123
123
  text?: string | undefined;
124
+ landing?: string | undefined;
124
125
  } | {
125
126
  oracle: "judge";
126
127
  text: string;
@@ -138,6 +139,14 @@ export declare function createLiveStore(options: LiveStoreOptions): {
138
139
  confidence?: number | undefined;
139
140
  reason?: string | undefined;
140
141
  }[] | undefined;
142
+ pins?: ({
143
+ kind: "literal";
144
+ text: string;
145
+ glob: string;
146
+ } | {
147
+ kind: "fixture";
148
+ paths: string[];
149
+ })[] | undefined;
141
150
  routingHints?: {
142
151
  pin?: {
143
152
  via: string;
@@ -213,6 +222,7 @@ export declare function createLiveStore(options: LiveStoreOptions): {
213
222
  oracle: "test";
214
223
  test: string;
215
224
  text?: string | undefined;
225
+ landing?: string | undefined;
216
226
  } | {
217
227
  oracle: "judge";
218
228
  text: string;
@@ -230,6 +240,14 @@ export declare function createLiveStore(options: LiveStoreOptions): {
230
240
  confidence?: number | undefined;
231
241
  reason?: string | undefined;
232
242
  }[] | undefined;
243
+ pins?: ({
244
+ kind: "literal";
245
+ text: string;
246
+ glob: string;
247
+ } | {
248
+ kind: "fixture";
249
+ paths: string[];
250
+ })[] | undefined;
233
251
  routingHints?: {
234
252
  pin?: {
235
253
  via: string;
@@ -1,6 +1,6 @@
1
1
  import { type ReactElement } from "react";
2
2
  import { type JournalRow } from "./components.js";
3
- import { type RunCockpitData } from "./derive.js";
3
+ import { type RunCockpitData, type TaskRow } from "./derive.js";
4
4
  import { type KeybarEntry, type RunInteractionState, type RunKeyProjection } from "./keys.js";
5
5
  import { type RunViewId } from "./views.js";
6
6
  import { type FramePlan, type FrameRegion, type FrameState, type FrameCockpitLayout, type FrameView, type MeasuredSize, type PlannedFrame } from "./layout.js";
@@ -29,6 +29,13 @@ export declare function panelRows(lines: readonly string[], columns: number): nu
29
29
  * identities assigned by the production journal derivation.
30
30
  */
31
31
  export declare function deriveRunViewRows(data: RunCockpitData, viewId: RunViewId, filterQuery?: string): readonly JournalRow[];
32
+ /**
33
+ * T9: the shared projection (derive.ts TaskRow.summary/activity/identities/harvests) read onto the
34
+ * task row — identity, phase, build evidence, blocker, next action — each unrecorded field saying so,
35
+ * each recorded one citing the journal line it was read from, and the OBS-1048 stall marker when the
36
+ * newest attempt's harvest is suspect. No attempt label is printed here: the ruler is the row's own.
37
+ */
38
+ export declare function taskProjectionText(row: TaskRow, journal?: readonly JournalRow[]): string;
32
39
  /**
33
40
  * The identities the body's rows carry in the order the body draws them — the
34
41
  * same window `SizedJournalPanel` paints, so a hit on a body row resolves to
@@ -4,6 +4,7 @@ import { cloneElement, createContext, useContext, useEffect, useInsertionEffect,
4
4
  import { GLYPHS, PLAIN_COMPACT_LOCKUP, } from "../../brand.js";
5
5
  import { allocateBandColumns, BandLines, BodyText, CockpitGrid, composeBandLine, JournalRowPanel, keyRosterLines, Panel, PANEL_CHROME_ROWS, ProgressMeter, Sparkline, StatTile, StatusStrip, } from "./components.js";
6
6
  import { fieldReading } from "./derive.js";
7
+ import { STALL_MARKER } from "./run-view.js";
7
8
  import { initialRunInteractionState, projectRunKeyEntries, reconcileRunInteraction, RUN_SIDE_RAIL_COLUMN_FLOOR, runPanelFocusOrder, runSideRailVisible, selectableRunViewRowIds, } from "./keys.js";
8
9
  import { DECISIONS_TAB_TITLE, RUN_VIEWS, runViewById, setupSectionAt, SETUP_SECTIONS, } from "./views.js";
9
10
  import { FRAME_HEADER_GAP, FRAME_NESTED_ROW_BANDS, FULL_JOURNAL_ROWS, planFrame, RUN_PANEL_HORIZONTAL_CHROME, SIDEBAR_COLUMN_FLOOR, } from "./layout.js";
@@ -279,7 +280,7 @@ function promotedViewRows(data, viewId) {
279
280
  time: fieldReading(row.lastEventTime),
280
281
  ...(row.lastEventTimestamp === undefined ? {} : { timestamp: row.lastEventTimestamp }),
281
282
  state: taskRowState(row),
282
- text: `${row.taskId} · ${fieldReading(row.state)} · ${runAttemptLabel(row.attempts)} · ${fieldReading(row.actor)}${row.title === undefined ? "" : ` · ${row.title}`}`,
283
+ text: `${row.taskId} · ${fieldReading(row.state)} · ${runAttemptLabel(row.attempts)} · ${fieldReading(row.actor)}${row.title === undefined ? "" : ` · ${row.title}`} · ${taskProjectionText(row, data.journalRows)}`,
283
284
  }));
284
285
  }
285
286
  if (viewId === "gates") {
@@ -302,6 +303,99 @@ function promotedViewRows(data, viewId) {
302
303
  }
303
304
  return [];
304
305
  }
306
+ /**
307
+ * T9: the shared projection (derive.ts TaskRow.summary/activity/identities/harvests) read onto the
308
+ * task row — identity, phase, build evidence, blocker, next action — each unrecorded field saying so,
309
+ * each recorded one citing the journal line it was read from, and the OBS-1048 stall marker when the
310
+ * newest attempt's harvest is suspect. No attempt label is printed here: the ruler is the row's own.
311
+ */
312
+ export function taskProjectionText(row, journal = []) {
313
+ const identity = row.identities?.at(-1);
314
+ const blocker = row.summary?.blocker;
315
+ const harvest = row.harvests?.at(-1);
316
+ const evidence = projectionEvidence(row, journal);
317
+ const at = (line) => line === undefined ? "(no journal row)" : `#L${line}`;
318
+ const responsible = row.summary?.responsible;
319
+ const identityLabel = identity
320
+ ? `${identity.role} ${fieldReading(identity.name ?? responsible?.agent)}`
321
+ : responsible?.role !== undefined || responsible?.agent !== undefined
322
+ ? `${fieldReading(responsible.role)} ${fieldReading(responsible.agent)}`
323
+ : fieldReading(undefined);
324
+ return [
325
+ `identity ${identityLabel}${evidence.identity === undefined ? identityLabel === fieldReading(undefined) ? "" : ` ${at(undefined)}` : ` ${at(evidence.identity)}`}`,
326
+ `phase ${fieldReading(row.summary?.phase ?? row.activity?.state)}${row.summary?.phase === undefined && row.activity === undefined ? "" : ` ${at(evidence.phase)}`}`,
327
+ `build ${fieldReading(row.activity?.build.state)}${row.activity !== undefined && "receipt" in row.activity.build ? ` ${at(evidence.build)}` : ""}`,
328
+ `blocker ${blocker ? `${blocker.kind} ${at(evidence.blocker)}` : "none"}`,
329
+ `next ${blocker?.nextAction == null ? fieldReading(undefined) : `${blocker.nextAction} ${at(evidence.nextAction)}`}`,
330
+ ...(harvest?.suspectedStalledHarvest ? [`${STALL_MARKER} · launch ${at(evidence.launch)} · ${harvest.nudgeFailures} nudge failed ${at(evidence.nudge)} · ${harvest.pageCount} paged ${at(evidence.page)} · no worker-result`] : []),
331
+ ].reduce((text, part) => `${text} · ${part}`);
332
+ }
333
+ /**
334
+ * Correlate each already-derived field with the row that can have produced that value. This is
335
+ * intentionally not a "newest row with a plausible name" lookup: launch identity is paired by
336
+ * recorded seat, phase starts at the current launch boundary, a receipt is taken from the current
337
+ * verification battery, and harvest counts select only the number of rows retained by that fold.
338
+ * The newest presentation row omits its event name; it is used only for a terminal field whose
339
+ * derived last-evidence timestamp proves that exact row is the transition.
340
+ */
341
+ function projectionEvidence(row, journal) {
342
+ const facts = journal.flatMap((entry) => {
343
+ const match = /^event:(\d+)$/u.exec(entry.id);
344
+ if (!match || !entry.text.startsWith(`${row.taskId} `))
345
+ return [];
346
+ const separator = entry.text.lastIndexOf(" · ");
347
+ return [{ line: Number(match[1]), timestamp: entry.timestamp, event: separator < 0 ? undefined : entry.text.slice(separator + 3) }];
348
+ }).sort((a, b) => a.line - b.line);
349
+ const lines = (event) => facts.filter((fact) => fact.event === event).map((fact) => fact.line);
350
+ // JournalRow is deliberately presentation-only: it carries neither the raw payload nor its
351
+ // attempt. Consequently two plausible rows cannot be correlated to the accepted projection
352
+ // without guessing. Fail closed in that case; the evidence remains reachable in the journal.
353
+ const only = (candidates) => candidates.length === 1 ? candidates[0] : undefined;
354
+ const unknownAfter = (line) => facts
355
+ .filter((fact) => fact.event === undefined && (line === undefined || fact.line > line))
356
+ .map((fact) => fact.line);
357
+ const newestTaskLine = [...facts].reverse().find((fact) => fact.timestamp === row.lastEventTimestamp)?.line;
358
+ const namedIdentities = row.identities?.filter((seat) => seat.name !== undefined) ?? [];
359
+ const currentIdentity = row.identities?.at(-1);
360
+ const launches = lines("worker-launch");
361
+ const identityIndex = currentIdentity === undefined ? -1 : namedIdentities.findIndex((seat) => seat.key === currentIdentity.key);
362
+ // A newest worker-launch has no printed event suffix. It is nevertheless the only missing launch
363
+ // when the identity fold gained one more named seat on the row carrying lastEventTimestamp.
364
+ if (launches.length < namedIdentities.length && newestTaskLine !== undefined && !launches.includes(newestTaskLine))
365
+ launches.push(newestTaskLine);
366
+ const identity = identityIndex >= 0 && launches.length === namedIdentities.length
367
+ ? launches[identityIndex]
368
+ : currentIdentity === undefined ? only(lines("worker-result")) : undefined;
369
+ const launch = identityIndex >= 0 && launches.length === namedIdentities.length ? launches[identityIndex] : undefined;
370
+ const afterLaunch = (candidates) => candidates.filter((line) => launch === undefined || line > launch);
371
+ const state = row.summary?.phase ?? row.activity?.state;
372
+ let phase;
373
+ if (state === "terminal") {
374
+ phase = only([...lines("task-human"), ...lines("task-failed"), ...lines("task-done"), ...lines("task-approved"), ...lines("merge")].sort((a, b) => a - b));
375
+ }
376
+ else if (state === "implementing")
377
+ phase = launch;
378
+ else if (state === "preparing")
379
+ phase = only([...lines("task-dispatch"), ...unknownAfter(undefined)]);
380
+ else if (state === "returned-for-verification")
381
+ phase = only([...afterLaunch(lines("worker-result")), ...unknownAfter(launch)]);
382
+ else if (state === "validating" || state === "reviewing")
383
+ phase = only([...afterLaunch([...lines("phase-start"), ...lines("gate-phase-start")].sort((a, b) => a - b)), ...unknownAfter(launch)]);
384
+ else
385
+ phase = only([...afterLaunch([...lines("gate-result"), ...lines("phase-start"), ...lines("gate-phase-start")].sort((a, b) => a - b)), ...unknownAfter(launch)]);
386
+ const battery = lines("phase-start").at(-1) ?? launch;
387
+ const receiptLines = [...lines("build-receipt"), ...lines("build-result")].sort((a, b) => a - b).filter((line) => battery === undefined || line > battery);
388
+ const build = row.activity !== undefined && "receipt" in row.activity.build ? only([...receiptLines, ...unknownAfter(battery)]) : undefined;
389
+ const parkCandidates = [...lines("task-human"), ...lines("task-failed")].sort((a, b) => a - b);
390
+ const park = only(parkCandidates) ?? (parkCandidates.length === 0 && row.state === "human" ? newestTaskLine : undefined);
391
+ const nudgeCandidates = afterLaunch(lines("worker-nudge-failed"));
392
+ const pageCandidates = afterLaunch(lines("operator-page"));
393
+ const nudgeCount = row.harvests?.at(-1)?.nudgeFailures ?? 0;
394
+ const pageCount = row.harvests?.at(-1)?.pageCount ?? 0;
395
+ const nudge = nudgeCount > 0 && nudgeCandidates.length === nudgeCount ? nudgeCandidates.at(-1) : undefined;
396
+ const page = pageCount > 0 && pageCandidates.length === pageCount ? pageCandidates.at(-1) : undefined;
397
+ return { identity, phase, build, ...(row.summary?.blocker ? { blocker: park, nextAction: park } : {}), launch, nudge, page };
398
+ }
305
399
  /** A task row exists on an operator surface only when the journal fold recorded a task fact. */
306
400
  function recordedTaskRow(row) {
307
401
  return row.state !== undefined
@@ -83,6 +83,43 @@ export declare function applyRunViewKey(session: RunViewSession, event: RunViewK
83
83
  * a closed run whose record lacks a bucket says so rather than reading absence as empty.
84
84
  */
85
85
  export declare function runSummaryLine(s: OperatorSnapshot): string;
86
+ /** One projected reading and the physical journal line it came from; no line means no row says it. */
87
+ export interface ProjectionField {
88
+ readonly label: string;
89
+ readonly line?: number;
90
+ }
91
+ export interface TaskProjection {
92
+ readonly taskId: string;
93
+ readonly identity: ProjectionField;
94
+ readonly phase: ProjectionField;
95
+ readonly build: ProjectionField;
96
+ readonly blocker: ProjectionField;
97
+ readonly nextAction: ProjectionField;
98
+ /** OBS-1048: present only when the newest launched attempt has failed nudges, pages and no worker-result. */
99
+ readonly stalled?: ProjectionField;
100
+ }
101
+ export declare const STALL_MARKER = "\u26A0 stalled harvest suspected";
102
+ /**
103
+ * The evidence-only projection status renders (run/activity.ts, run/operator-summary.ts), folded
104
+ * over the journal rows this view was handed, with the physical line each reading was taken from.
105
+ * Nothing is inferred from a title, a provider or a task id: an unrecorded field says so.
106
+ */
107
+ export declare function projectRunTasks(snapshot: OperatorSnapshot, rows: readonly RunEvidenceRow[], graph: RunGraph | undefined, decisions: readonly RunDecision[], runId: string): readonly TaskProjection[];
108
+ /** One wrapped line per task: every field with its line, and the stall marker when the harvest is suspect. */
109
+ export declare const projectionLine: (p: TaskProjection) => string;
110
+ export interface PaneLocator {
111
+ readonly status: "recorded" | "unavailable";
112
+ readonly reason: string;
113
+ /** The worker-launch row the reading stands on, when one exists. */
114
+ readonly line?: number;
115
+ }
116
+ /**
117
+ * Where the current attempt's pane is recorded — and only that. The reading is exact-name: the
118
+ * launch row's slot name must be the owned name of THIS task, attempt and run. No terminal is ever
119
+ * chosen by title, provider or task id; a missing, foreign, stale or ambiguous record reads
120
+ * unavailable with the rows it was read from, and reading never touches a host.
121
+ */
122
+ export declare function paneLocator(task: OperatorTask, rows: readonly RunEvidenceRow[], runId: string): PaneLocator;
86
123
  /** The board's footer keys merged with the cockpit's own — every one of them handled by this view or the shell. */
87
124
  export declare const RUN_VIEW_KEYS: readonly string[];
88
125
  export interface RunViewProps {