tickmarkr 2.5.5 → 2.5.7

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 (69) hide show
  1. package/README.md +3 -1
  2. package/dist/adapters/prompt.js +21 -1
  3. package/dist/cli/commands/approve.js +69 -8
  4. package/dist/cli/commands/doctor.d.ts +10 -0
  5. package/dist/cli/commands/fleet.js +4 -0
  6. package/dist/cli/commands/plan.js +20 -4
  7. package/dist/cli/commands/status.js +95 -34
  8. package/dist/cli/commands/verify.js +108 -85
  9. package/dist/config/config.d.ts +11 -0
  10. package/dist/config/config.js +21 -12
  11. package/dist/config/fleet-overlay.js +55 -17
  12. package/dist/drivers/index.js +2 -1
  13. package/dist/drivers/orca.d.ts +21 -1
  14. package/dist/drivers/orca.js +209 -27
  15. package/dist/gates/baseline.d.ts +21 -5
  16. package/dist/gates/baseline.js +67 -17
  17. package/dist/gates/cache.d.ts +14 -13
  18. package/dist/gates/cache.js +17 -5
  19. package/dist/gates/llm.d.ts +3 -0
  20. package/dist/gates/llm.js +11 -0
  21. package/dist/gates/review.d.ts +28 -3
  22. package/dist/gates/review.js +118 -15
  23. package/dist/gates/run-gates.d.ts +10 -2
  24. package/dist/gates/run-gates.js +362 -108
  25. package/dist/gates/test-manifest.d.ts +33 -1
  26. package/dist/gates/test-manifest.js +132 -40
  27. package/dist/gates/test-reporter.js +20 -7
  28. package/dist/graph/graph.d.ts +4 -0
  29. package/dist/graph/graph.js +50 -1
  30. package/dist/run/activity.d.ts +28 -0
  31. package/dist/run/activity.js +194 -0
  32. package/dist/run/consult.js +5 -4
  33. package/dist/run/daemon.d.ts +11 -0
  34. package/dist/run/daemon.js +663 -128
  35. package/dist/run/execution-budget.d.ts +25 -0
  36. package/dist/run/execution-budget.js +142 -0
  37. package/dist/run/git.d.ts +46 -1
  38. package/dist/run/git.js +149 -12
  39. package/dist/run/journal.d.ts +23 -5
  40. package/dist/run/journal.js +98 -25
  41. package/dist/run/lease.d.ts +44 -0
  42. package/dist/run/lease.js +226 -3
  43. package/dist/run/operator-page-summary.d.ts +56 -0
  44. package/dist/run/operator-page-summary.js +68 -0
  45. package/dist/run/operator-summary.d.ts +69 -0
  46. package/dist/run/operator-summary.js +77 -0
  47. package/dist/run/protocol.d.ts +71 -0
  48. package/dist/run/protocol.js +32 -0
  49. package/dist/run/recovery.d.ts +8 -0
  50. package/dist/run/recovery.js +25 -0
  51. package/dist/run/repair-selection.d.ts +12 -0
  52. package/dist/run/repair-selection.js +56 -0
  53. package/dist/run/stall.d.ts +6 -1
  54. package/dist/run/stall.js +60 -3
  55. package/dist/tui/cockpit/board.d.ts +9 -0
  56. package/dist/tui/cockpit/board.js +10 -0
  57. package/dist/tui/cockpit/derive.d.ts +35 -0
  58. package/dist/tui/cockpit/derive.js +152 -10
  59. package/dist/tui/cockpit/evidence-view.d.ts +2 -0
  60. package/dist/tui/cockpit/evidence-view.js +42 -12
  61. package/dist/tui/cockpit/run-cockpit.d.ts +8 -1
  62. package/dist/tui/cockpit/run-cockpit.js +95 -1
  63. package/dist/tui/cockpit/run-view.d.ts +37 -0
  64. package/dist/tui/cockpit/run-view.js +189 -2
  65. package/dist/tui/ink/fleet-app.d.ts +10 -2
  66. package/dist/tui/ink/fleet-app.js +33 -15
  67. package/package.json +2 -2
  68. package/skills/tickmarkr-overseer/SKILL.md +55 -3
  69. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +29 -2
@@ -1,7 +1,13 @@
1
1
  // Consolidated C1/C6 readers use the incremental lifecycle contract; legacy captures keep their API.
2
2
  export { readOperatorState as deriveOperatorState } from "../../run/operator-state.js";
3
3
  import { channelKey } from "../../adapters/types.js";
4
+ import { newestPark, permittedDecisionVerbs } from "../../cli/commands/approve.js";
5
+ import { parseOwnedName } from "../../drivers/types.js";
6
+ import { projectActivity } from "../../run/activity.js";
4
7
  import { isPidLive } from "../../run/lock.js";
8
+ import { normalizeGateOutcome } from "../../run/outcome.js";
9
+ import { projectOperatorSummary } from "../../run/operator-summary.js";
10
+ import { trackJournalRows } from "../../run/protocol.js";
5
11
  export const SPARKLINE_BUCKET_WINDOW = 12;
6
12
  const MINUTE_MS = 60_000;
7
13
  const SPARKLINE_BUCKET_WIDTH_LADDER_MINUTES = [
@@ -470,7 +476,133 @@ function journalRows(events, tasks, defects, runStatus) {
470
476
  * graph's own `status` is never consulted, which is what keeps a recompiled
471
477
  * graph from repainting a parked task green.
472
478
  */
473
- function taskRows(events, tasks, graph) {
479
+ const stringField = (data, key) => typeof data[key] === "string" ? data[key] : undefined;
480
+ const attemptField = (data) => typeof data.attempt === "number" && Number.isInteger(data.attempt) && data.attempt >= 0 ? data.attempt : undefined;
481
+ /** Recorded seats for one task: role and attempt from the row, or from an owned slot name it carries. */
482
+ function recordedIdentities(events, taskId) {
483
+ const seen = new Map();
484
+ for (const event of events) {
485
+ if (event.taskId !== taskId)
486
+ continue;
487
+ const slot = recordValue(event.data.slot);
488
+ const name = stringField(event.data, "slotName") ?? (slot ? stringField(slot, "name") : stringField(event.data, "slot"));
489
+ const owned = name === undefined ? null : parseOwnedName(name);
490
+ const role = stringField(event.data, "role") ?? owned?.role ?? (event.event === "worker-launch" ? "worker" : undefined);
491
+ if (role === undefined)
492
+ continue;
493
+ const attempt = attemptField(event.data) ?? owned?.attempt;
494
+ if (attempt === undefined)
495
+ continue;
496
+ const key = `${taskId}:${attempt}:${role}`;
497
+ if (!seen.has(key))
498
+ seen.set(key, { key, role, attempt, ...(name === undefined ? {} : { name }) });
499
+ }
500
+ return [...seen.values()];
501
+ }
502
+ /**
503
+ * OBS-1048 fold, chronological: each worker-launch opens a fresh occurrence of its attempt label
504
+ * (labels reset on resume), and only a worker-result recorded AFTER that launch retires it — an
505
+ * unnumbered result binds to the newest launch so far, a numbered one to its own attempt's launch.
506
+ */
507
+ function attemptHarvests(events, taskId) {
508
+ const byAttempt = new Map();
509
+ let newest;
510
+ for (const [order, event] of events.entries()) {
511
+ if (event.taskId !== taskId)
512
+ continue;
513
+ const attempt = attemptField(event.data) ?? (event.event === "worker-result" ? newest : undefined);
514
+ if (attempt === undefined)
515
+ continue;
516
+ const existing = byAttempt.get(attempt);
517
+ if (event.event === "worker-launch") {
518
+ // A launch resets its label's evidence: an earlier occurrence's status, nudges, pages and result are history.
519
+ byAttempt.set(attempt, { launched: true, returned: false, nudges: 0, pages: 0, order });
520
+ newest = attempt;
521
+ continue;
522
+ }
523
+ const h = existing ?? { launched: false, returned: false, nudges: 0, pages: 0, order };
524
+ if (!existing)
525
+ byAttempt.set(attempt, h);
526
+ switch (event.event) {
527
+ case "worker-status":
528
+ h.status = stringField(event.data, "status");
529
+ break;
530
+ case "worker-nudge-failed":
531
+ h.nudges += 1;
532
+ h.nudgeAt = event.ts;
533
+ break;
534
+ case "operator-page":
535
+ h.pages += 1;
536
+ break;
537
+ case "worker-result":
538
+ h.returned = true;
539
+ break;
540
+ }
541
+ }
542
+ // Attempt labels reset on resume, so numeric order is not chronology. The board reads the
543
+ // last harvest as newest; retain the order of each label's latest worker-launch occurrence.
544
+ return [...byAttempt].sort(([, a], [, b]) => a.order - b.order).map(([attempt, h]) => ({
545
+ attempt,
546
+ ...(h.status === undefined ? {} : { lastWorkerStatus: h.status }),
547
+ nudgeFailures: h.nudges,
548
+ ...(h.nudgeAt === undefined ? {} : { lastNudgeFailureAt: h.nudgeAt }),
549
+ pageCount: h.pages,
550
+ suspectedStalledHarvest: h.launched && !h.returned && h.nudges > 0 && h.pages > 0,
551
+ }));
552
+ }
553
+ /**
554
+ * The shared, evidence-only projection status renders (status.ts recordProjection), folded here
555
+ * so both surfaces read one fact. Phase/blocker/next-action come from run/activity.ts and
556
+ * run/operator-summary.ts; decisions from the production park oracle.
557
+ */
558
+ function projectSummaries(events, runId, identities, graph, rowState) {
559
+ const journal = events.map((event) => ({
560
+ ts: event.ts, event: event.event, ...(event.taskId === undefined ? {} : { taskId: event.taskId }), data: event.data,
561
+ }));
562
+ const graphTask = (id) => graph?.tasks.find((task) => task.id === id);
563
+ const tasks = identities.map(({ id }) => ({
564
+ id, gates: graphTask(id)?.gates ?? [], deps: graphTask(id)?.deps ?? [], status: rowState(id) ?? graphTask(id)?.status ?? "unknown",
565
+ }));
566
+ const activity = projectActivity(runId, trackJournalRows(runId, journal.map((raw, sourceIndex) => ({ raw, sourceIndex }))), tasks);
567
+ const rehashAt = journal.reduce((at, event, index) => event.event === "graph-rehash" ? index : at, -1);
568
+ const summaryTasks = tasks.map((task) => {
569
+ const evidence = journal.filter((event) => event.taskId === task.id);
570
+ const last = evidence.at(-1);
571
+ const recorded = activity.get(task.id);
572
+ let phase = last ? recorded.state : undefined;
573
+ // A completed battery is evidence of readiness, never evidence that merge started (status.ts).
574
+ const boundary = journal.reduce((index, event, at) => ["run-start", "run-resume", "run-end"].includes(event.event)
575
+ || (event.taskId === task.id && (event.event === "task-dispatch"
576
+ || (event.event === "phase-start" && event.data.phase === "gates"))) ? at : index, -1);
577
+ const latest = new Map();
578
+ journal.forEach((event, index) => {
579
+ if (event.taskId === task.id && event.event === "gate-result" && typeof event.data.gate === "string")
580
+ latest.set(event.data.gate, index);
581
+ });
582
+ const priorGraph = rehashAt >= 0 && [...latest.values()].some((index) => index < rehashAt);
583
+ // Latest outcome including non-verdicts (status.ts): a held screen or skipped gate is not a pass.
584
+ const passedNow = (gate) => {
585
+ const latestResult = journal.slice(boundary + 1).filter((event) => event.taskId === task.id && event.event === "gate-result" && event.data.gate === gate).at(-1);
586
+ return latestResult !== undefined && normalizeGateOutcome(latestResult.data).kind === "passed";
587
+ };
588
+ if (recorded.state === "unconfirmed" && task.gates.length > 0 && !priorGraph && task.gates.every(passedNow)) {
589
+ phase = "awaiting merge phase";
590
+ }
591
+ const responsibility = [...evidence].reverse().find((event) => typeof event.data.role === "string" || typeof event.data.agent === "string");
592
+ return {
593
+ id: task.id, deps: task.deps, status: task.status, phase, lastEvidenceAt: last?.ts,
594
+ responsible: responsibility ? {
595
+ role: stringField(responsibility.data, "role"), agent: stringField(responsibility.data, "agent"),
596
+ } : undefined,
597
+ };
598
+ });
599
+ const decisions = summaryTasks.flatMap((task) => {
600
+ const park = task.status === "human" ? newestPark(journal, task.id) : undefined;
601
+ return park ? [{ taskId: task.id, park, verbs: permittedDecisionVerbs(park) }] : [];
602
+ });
603
+ return { activity, summaries: new Map(projectOperatorSummary(summaryTasks, decisions).map((task) => [task.taskId, task])) };
604
+ }
605
+ function taskRows(events, tasks, graph, runId) {
474
606
  const lastSeen = new Map();
475
607
  for (const event of events) {
476
608
  if (event.taskId !== undefined)
@@ -483,6 +615,15 @@ function taskRows(events, tasks, graph) {
483
615
  id,
484
616
  title: undefined,
485
617
  }));
618
+ // `task-done` records completion; only `merge` records landing. The fixed
619
+ // Tasks-view consumer maps `done` to a check, so the derived row must keep
620
+ // an unmerged completion in the neutral `completed` state and reserve
621
+ // `done` for a fact whose merge the journal actually carries.
622
+ const rowState = (id) => {
623
+ const fact = tasks.get(id);
624
+ return fact?.state === "done" && fact.merged !== true ? "completed" : fact?.state;
625
+ };
626
+ const projection = projectSummaries(events, runId, identities, graph, rowState);
486
627
  return identities.map(({ id, title }) => {
487
628
  const fact = tasks.get(id);
488
629
  const assignment = fact?.dispatches.at(-1)?.assignment;
@@ -491,13 +632,9 @@ function taskRows(events, tasks, graph) {
491
632
  // zero when a run resumes, so a task dispatched 0,1,0 was tried three times
492
633
  // and reporting its last label would say one.
493
634
  const attempts = fact?.dispatches.length ?? 0;
494
- // `task-done` records completion; only `merge` records landing. The fixed
495
- // Tasks-view consumer maps `done` to a check, so the derived row must keep
496
- // an unmerged completion in the neutral `completed` state and reserve
497
- // `done` for a fact whose merge the journal actually carries.
498
- const state = fact?.state === "done" && fact.merged !== true
499
- ? "completed"
500
- : fact?.state;
635
+ const state = rowState(id);
636
+ const recorded = recordedIdentities(events, id);
637
+ const harvests = attemptHarvests(events, id);
501
638
  return {
502
639
  id: `task:${id}`,
503
640
  taskId: id,
@@ -513,6 +650,10 @@ function taskRows(events, tasks, graph) {
513
650
  // Landing is its own fact. `done` is work finished; only a recorded merge
514
651
  // is work landed, and only that earns a check-mark.
515
652
  ...(fact?.merged === true ? { merged: true } : {}),
653
+ activity: projection.activity.get(id),
654
+ summary: projection.summaries.get(id),
655
+ ...(recorded.length === 0 ? {} : { identities: recorded }),
656
+ ...(harvests.length === 0 ? {} : { harvests }),
516
657
  };
517
658
  });
518
659
  }
@@ -737,9 +878,10 @@ export function deriveRunCockpitData(source, binaryVersion, options = {}) {
737
878
  : interrupted
738
879
  ? "interrupted"
739
880
  : "running";
881
+ const runId = source.fileName.replace(/(?:\.interrupted)?\.journal\.jsonl$/, "");
740
882
  return {
741
883
  binaryVersion,
742
- runId: source.fileName.replace(/(?:\.interrupted)?\.journal\.jsonl$/, ""),
884
+ runId,
743
885
  branch: typeof start.data.branch === "string" ? start.data.branch : "unknown",
744
886
  status: runStatus,
745
887
  elapsed: elapsedReading(events[0].ts, events.at(-1).ts),
@@ -762,7 +904,7 @@ export function deriveRunCockpitData(source, binaryVersion, options = {}) {
762
904
  ? `${spotlight.id} · ${spotlight.phase} · attempt ${taskAttempt(spotlight)} · ${taskActor(spotlight)}`
763
905
  : "- · pending · attempt 1 · unassigned",
764
906
  journalRows: journalRows(events, tasks, defects, runStatus),
765
- taskRows: taskRows(events, tasks, options.graph),
907
+ taskRows: taskRows(events, tasks, options.graph, runId),
766
908
  gateRows: gateRows(events),
767
909
  fleetRows: fleetRows(events),
768
910
  statusItems: [
@@ -1,6 +1,7 @@
1
1
  import { type ReactElement } from "react";
2
2
  import type { ChannelCost } from "../../report/cost.js";
3
3
  import { type OperatorRecordRow } from "../../report/operator-record.js";
4
+ import { type OperatorPageGroup } from "../../run/operator-page-summary.js";
4
5
  import type { EvidenceIdentity } from "../../run/operator-state.js";
5
6
  import { type JournalEvent } from "../../run/journal.js";
6
7
  import { type ComponentState } from "./components.js";
@@ -49,6 +50,7 @@ export interface EvidenceViewInput {
49
50
  readonly learningPreview?: readonly string[];
50
51
  }
51
52
  export interface EvidenceViewModel {
53
+ readonly operatorPages: readonly OperatorPageGroup[];
52
54
  readonly journal: readonly EvidenceRow[];
53
55
  readonly channels: readonly OperatorRecordRow[];
54
56
  readonly reportLines: readonly string[];
@@ -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 { 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 {