@joshuafolkken/kit 1.3092.0 → 1.3101.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 (125) hide show
  1. package/.github/actions/setup-pnpm/action.yml +12 -4
  2. package/dist/.inputs-verified.json +1 -1
  3. package/dist/commands/backlog-drive-cli.js +17 -17
  4. package/dist/commands/backlogrun-cli.js +21 -20
  5. package/dist/commands/check-related.js +18 -18
  6. package/dist/commands/{chunk-VXE3V3OC.js → chunk-2CNVIA35.js} +2 -2
  7. package/dist/commands/{chunk-374XGAWD.js → chunk-36XHHYNU.js} +1 -1
  8. package/dist/commands/{chunk-W3MDLQZF.js → chunk-3LUPGBW4.js} +1 -1
  9. package/dist/commands/{chunk-T6OUNP7E.js → chunk-3VLDFM5I.js} +6 -6
  10. package/dist/commands/{chunk-4K7KLR7L.js → chunk-6AHAY5VZ.js} +1 -1
  11. package/dist/commands/{chunk-ET6IRQ3L.js → chunk-6FN37DER.js} +3 -3
  12. package/dist/commands/{chunk-LBMAJDGN.js → chunk-6JD4VNO4.js} +2 -2
  13. package/dist/commands/{chunk-KD3SEAMV.js → chunk-7HYYHCTN.js} +2 -2
  14. package/dist/commands/{chunk-4GUGC4IW.js → chunk-7K5ITPHR.js} +2 -2
  15. package/dist/commands/{chunk-XTJA34ND.js → chunk-7ULYLGUK.js} +10 -10
  16. package/dist/commands/{chunk-IEKW3ON6.js → chunk-ABM2MZBF.js} +1 -1
  17. package/dist/commands/{chunk-IOKOHB7P.js → chunk-BBLEC4YQ.js} +1 -1
  18. package/dist/commands/{chunk-V2T5DP3B.js → chunk-C7FGDZWP.js} +1 -1
  19. package/dist/commands/{chunk-MHXIJKGP.js → chunk-EHPTXSAT.js} +20 -9
  20. package/dist/commands/{chunk-PF3PMT2Q.js → chunk-EPICTV2P.js} +1 -1
  21. package/dist/commands/{chunk-GVDRRBHZ.js → chunk-EVVQJJFV.js} +2 -2
  22. package/dist/commands/{chunk-NOSG3RUP.js → chunk-F57LACNC.js} +2 -2
  23. package/dist/commands/{chunk-BAEDHPXW.js → chunk-FA7CN75F.js} +4 -4
  24. package/dist/commands/{chunk-62NYXZCC.js → chunk-FDASVHZD.js} +1 -1
  25. package/dist/commands/{chunk-YMAEYY5R.js → chunk-FEYZ575X.js} +1 -1
  26. package/dist/commands/{chunk-PILH72AV.js → chunk-FTAWHAI7.js} +1 -1
  27. package/dist/commands/{chunk-MK4CARBI.js → chunk-G3BBCAAH.js} +1 -1
  28. package/dist/commands/{chunk-Z6QLFPFB.js → chunk-GABDPTDK.js} +4 -4
  29. package/dist/commands/{chunk-VHLDQ3PH.js → chunk-HHS3LYLV.js} +2 -2
  30. package/dist/commands/{chunk-EE4VOZJP.js → chunk-HQZ65PJE.js} +3 -3
  31. package/dist/commands/{chunk-KHGDZQRG.js → chunk-HSZECERZ.js} +2 -2
  32. package/dist/commands/{chunk-BLUAO4QW.js → chunk-IY3DSDRW.js} +1 -1
  33. package/dist/commands/{chunk-2XZF3YFY.js → chunk-J2RUI4KN.js} +1 -1
  34. package/dist/commands/{chunk-2Q7MGFYB.js → chunk-J3PCRUVY.js} +27 -12
  35. package/dist/commands/{chunk-RDUFVQVJ.js → chunk-JU2MAYQ3.js} +7 -7
  36. package/dist/commands/{chunk-ZEUKX3RZ.js → chunk-KJPZPTS4.js} +1 -1
  37. package/dist/commands/{chunk-7E43YW53.js → chunk-MHF6BDQI.js} +1 -1
  38. package/dist/commands/{chunk-BE6F4YK2.js → chunk-OHN3QARP.js} +10 -10
  39. package/dist/commands/{chunk-AT46EY7H.js → chunk-OKBZGLD5.js} +1 -1
  40. package/dist/commands/{chunk-XCCT6GKA.js → chunk-OZN4HOZT.js} +2 -2
  41. package/dist/commands/{chunk-ZJ5S4VFD.js → chunk-RAQ3E6OT.js} +1 -1
  42. package/dist/commands/{chunk-XSKJAG72.js → chunk-S5GX3H4P.js} +1 -1
  43. package/dist/commands/{chunk-HESWKELD.js → chunk-SC57VV6A.js} +1 -1
  44. package/dist/commands/{chunk-EZYW54OQ.js → chunk-SOETJZWH.js} +221 -118
  45. package/dist/commands/{chunk-HNFBEB4L.js → chunk-UUA63LCI.js} +1 -1
  46. package/dist/commands/{chunk-ZGBXUY7R.js → chunk-UZDKCGDC.js} +1 -1
  47. package/dist/commands/{chunk-B5XYAWNN.js → chunk-VPRQFM2T.js} +1 -1
  48. package/dist/commands/{chunk-M4WG74Q7.js → chunk-VUCDGQ6Y.js} +1 -1
  49. package/dist/commands/{chunk-UX3HCPLX.js → chunk-W7X7MZZN.js} +1 -1
  50. package/dist/commands/{chunk-EN65ZS3S.js → chunk-WORHUJ6T.js} +1 -1
  51. package/dist/commands/chunk-WRHMYSW7.js +36 -0
  52. package/dist/commands/{chunk-K5H3CLZZ.js → chunk-YANDBVAT.js} +1 -1
  53. package/dist/commands/{chunk-GWTBIGYC.js → chunk-YD7XK2TX.js} +1 -1
  54. package/dist/commands/{chunk-M5HA2ZCG.js → chunk-YT5JR2CB.js} +2 -2
  55. package/dist/commands/{chunk-EWCUT7U2.js → chunk-Z2NUK47W.js} +1 -1
  56. package/dist/commands/{chunk-4MGTNY2L.js → chunk-ZI3SABMH.js} +1 -1
  57. package/dist/commands/{chunk-TTTZZN7F.js → chunk-ZJJLROJY.js} +3 -3
  58. package/dist/commands/disposition-cli.js +15 -15
  59. package/dist/commands/git-followup-workflow.js +18 -7
  60. package/dist/commands/inputs.json +1668 -1663
  61. package/dist/commands/issue-already-done-cli.js +8 -8
  62. package/dist/commands/issue-measured-cli.js +9 -9
  63. package/dist/commands/issue-plan-cli.js +16 -16
  64. package/dist/commands/issue-premise-unmet-cli.js +8 -8
  65. package/dist/commands/lane-breakdown-cli.js +17 -17
  66. package/dist/commands/lane-cli.js +20 -20
  67. package/dist/commands/lane-measure-cli.js +23 -23
  68. package/dist/commands/lane-plan-cli.js +15 -15
  69. package/dist/commands/lane-restart-cli.js +20 -20
  70. package/dist/commands/lane-why-cli.js +4 -4
  71. package/dist/commands/lint-parallel-main.js +2 -2
  72. package/dist/commands/lint-related.js +6 -6
  73. package/dist/commands/lint-rule.js +2 -2
  74. package/dist/commands/plan-scaffold-cli.js +20 -20
  75. package/dist/commands/pre-commit-type-check.js +7 -7
  76. package/dist/commands/pre-push-unit.js +9 -9
  77. package/dist/commands/prose-stale-cli.js +5 -5
  78. package/dist/commands/review-brief-main.js +23 -23
  79. package/dist/commands/review-pair-cli.js +19 -19
  80. package/dist/commands/review-record-main.js +19 -19
  81. package/dist/commands/review-round2-cli.js +4 -4
  82. package/dist/commands/review-submit-cli.js +23 -23
  83. package/dist/commands/run-board-main.js +2 -1
  84. package/dist/commands/run-chain-cli.js +19 -19
  85. package/dist/commands/run-cut-cli.js +19 -19
  86. package/dist/commands/run-entry-cli.js +27 -27
  87. package/dist/commands/run-hold-main.js +7 -7
  88. package/dist/commands/run-next-cli.js +20 -20
  89. package/dist/commands/run-park-cli.js +8 -8
  90. package/dist/commands/run-pause-cli.js +8 -8
  91. package/dist/commands/run-prep-main.js +20 -20
  92. package/dist/commands/run-prime-cli.js +10 -10
  93. package/dist/commands/run-review-cli.js +26 -26
  94. package/dist/commands/run-ship-cli.js +29 -29
  95. package/dist/commands/run-ship-evidence-cli.js +4 -4
  96. package/dist/commands/run-ship-wait-main.js +5 -5
  97. package/dist/commands/run-split-cli.js +8 -8
  98. package/dist/commands/run-step-cli.js +27 -27
  99. package/dist/commands/run-wake-main.js +19 -19
  100. package/dist/commands/selfcheck-cli.js +8 -8
  101. package/dist/commands/stop-guard.js +7 -7
  102. package/dist/commands/test-e2e-guard.js +6 -6
  103. package/dist/commands/test-pinned-main.js +8 -8
  104. package/dist/commands/test-reach-cli.js +4 -4
  105. package/dist/commands/{test-related-Z6YY2GFH.js → test-related-XBDH2IOH.js} +11 -11
  106. package/dist/commands/test-related-main.js +13 -13
  107. package/dist/commands/verification-gate.js +17 -17
  108. package/dist/hooks/inputs.json +511 -510
  109. package/dist/hooks/stop-guard.js +68 -59
  110. package/dist/inputs.json +64 -64
  111. package/package.json +1 -1
  112. package/scripts/followup/git-followup-finish.ts +16 -1
  113. package/scripts/gate/verification-inputs.ts +2 -1
  114. package/scripts/run/board/run-board-layout.ts +9 -4
  115. package/scripts/run/board/run-board-read.ts +29 -69
  116. package/scripts/run/board/run-board-render-lanes.ts +26 -9
  117. package/scripts/run/board/run-board-render-lead.ts +9 -0
  118. package/scripts/run/board/run-board-root-layout.ts +69 -0
  119. package/scripts/run/board/run-board-single.ts +69 -4
  120. package/scripts/run/board/run-board-span-read.ts +85 -0
  121. package/scripts/run/board/run-board-tick.ts +12 -4
  122. package/scripts/run/hold/run-hold-ended.ts +50 -0
  123. package/scripts/test/test-pinned-cache-logic.ts +13 -7
  124. package/scripts/test/test-pinned-cache.ts +33 -11
  125. package/scripts/test/test-runner-config.ts +19 -0
@@ -109,6 +109,8 @@ interface BoardLayout {
109
109
  // The earlier run's lanes that still hold a place under the limit, and the lanes with no record —
110
110
  // drawn after the run's own lanes, and no row the header counts.
111
111
  earlier?: ReadonlyArray<BoardRow> | undefined
112
+ // The root checkout's single run beside the run, drawn above its lanes and no row the header counts.
113
+ root?: { name: string; row: BoardRow } | undefined
112
114
  }
113
115
 
114
116
  // What an active row is named and classified by — each issue's title and labels.
@@ -382,9 +384,10 @@ function rows_of(layout: BoardLayout): Array<BoardRow> {
382
384
  return [...layout.active, ...queued_rows(layout), ...layout.people, ...layout.unreached]
383
385
  }
384
386
 
385
- // Every row the frame draws — the counted ones and the earlier run's lanes — what the legend reads.
387
+ // Every row the frame draws — the root checkout's single run's, the counted ones and the earlier run's
388
+ // lanes — what the legend reads.
386
389
  function drawn_rows(layout: BoardLayout): Array<BoardRow> {
387
- return [...rows_of(layout), ...(layout.earlier ?? [])]
390
+ return [layout.root?.row ?? [], rows_of(layout), layout.earlier ?? []].flat()
388
391
  }
389
392
 
390
393
  // Every row waiting on a person's decision: the touched children parked on one, then the untouched
@@ -395,11 +398,12 @@ function people_of(layout: BoardLayout): Array<BoardRow> {
395
398
  return [...asked, ...layout.people]
396
399
  }
397
400
 
398
- // The run's own rows `apart` does not name, then the earlier run's lanes still holding a place.
401
+ // The root checkout's single run's row, the run's own rows `apart` does not name, then the earlier
402
+ // run's lanes still holding a place.
399
403
  function lane_rows(layout: BoardLayout, apart: ReadonlyArray<BoardRow>): Array<BoardRow> {
400
404
  const own = layout.active.filter((row) => !apart.includes(row))
401
405
 
402
- return [...own, ...(layout.earlier ?? [])]
406
+ return [layout.root?.row ?? [], own, layout.earlier ?? []].flat()
403
407
  }
404
408
 
405
409
  // The lanes section's rows: those neither finished nor waiting on a person's decision.
@@ -420,6 +424,7 @@ function listed_parks(layout: BoardLayout): ReadonlySet<number> {
420
424
 
421
425
  const run_board_layout = {
422
426
  EMPTY_LAYOUT,
427
+ active_order,
423
428
  drawn_rows,
424
429
  is_blocked,
425
430
  is_decision: run_board_human.is_decision,
@@ -1,20 +1,17 @@
1
1
  import { backlog_drive_memory } from '#scripts/backlog/backlog-drive-memory'
2
2
  import type { NamedPlan } from '#scripts/backlog/backlog-plan'
3
3
  import { backlog_ready } from '#scripts/backlog/backlog-ready'
4
- import { backlogrun_launch } from '#scripts/backlog/backlogrun-launch'
5
4
  import { lane_held_stats } from '#scripts/lane/lane-held-stats'
6
5
  import { lane_ledger } from '#scripts/lane/lane-ledger'
7
6
  import type { TableRead } from '#scripts/lane/lane-process-table'
8
7
  import { lane_registry, type LaneInfo } from '#scripts/lane/lane-registry'
9
8
  import { lane_state, type LaneStateRecord } from '#scripts/lane/lane-state'
10
9
  import { stamped_memo } from '#scripts/lib/stamped-memo'
11
- import { run_carry, type CarryRead, type RunCarry } from '#scripts/run/carry/run-carry'
12
- import { run_carry_ended } from '#scripts/run/carry/run-carry-ended'
10
+ import { run_carry, type RunCarry } from '#scripts/run/carry/run-carry'
13
11
  import { run_event_scope } from '#scripts/run/event/run-event-scope'
14
12
  import { run_event_stream, type RunEvent } from '#scripts/run/event/run-event-stream'
15
13
  import { run_event_tail } from '#scripts/run/event/run-event-tail'
16
14
  import { run_merge_usage_limit } from '#scripts/run/merge/run-merge-usage-limit'
17
- import { run_stranded_detect } from '#scripts/run/run-stranded-detect'
18
15
  import { run_wake } from '#scripts/run/wake/run-wake'
19
16
  import { run_board_found, type FoundOf } from './run-board-found'
20
17
  import { run_board_lane_result } from './run-board-lane-result'
@@ -26,10 +23,10 @@ import {
26
23
  } from './run-board-operation-read'
27
24
  import type { ProcessRoots } from './run-board-process-groups'
28
25
  import type { QuotaProvider } from './run-board-quota-read'
29
- import { run_board_roots } from './run-board-roots'
30
26
  import { run_board_scope } from './run-board-scope'
31
- import { run_board_single } from './run-board-single'
27
+ import { run_board_single, type EndedRoot, type RootRun } from './run-board-single'
32
28
  import { run_board_span, type BoardSpan } from './run-board-span'
29
+ import { run_board_span_read } from './run-board-span-read'
33
30
  import { run_board_throughput, type ThroughputRow } from './run-board-throughput'
34
31
  import { run_board_usual, type UsualTimes } from './run-board-usual'
35
32
 
@@ -42,6 +39,10 @@ interface LocalRead {
42
39
  ended_ms: number | undefined
43
40
  // The single run's name the header draws; `undefined` for a `backlogrun` (`run-board-single.ts`).
44
41
  run_name?: string | undefined
42
+ // The root checkout's single run beside a carried run, drawn above its lanes.
43
+ root?: RootRun | undefined
44
+ // The root checkout's single runs a merge ended inside the run, drawn in `✅ done`.
45
+ ended_roots?: ReadonlyArray<EndedRoot> | undefined
45
46
  // What the run was asked to do, from its invocation.
46
47
  scope: NamedPlan
47
48
  events: ReadonlyArray<RunEvent>
@@ -103,27 +104,6 @@ interface LocalRead {
103
104
  found?: FoundOf | undefined
104
105
  }
105
106
 
106
- const NO_SPAN: SpanHere = {
107
- span: undefined,
108
- resume: undefined,
109
- transcript: undefined,
110
- roots: {},
111
- carry: undefined,
112
- is_stranded: false,
113
- is_session_active: false,
114
- }
115
-
116
- interface SpanHere {
117
- span: BoardSpan | undefined
118
- resume: string | undefined
119
- // The parent session's transcript, where the run's record names one.
120
- transcript: string | undefined
121
- roots: ProcessRoots
122
- carry: RunCarry | undefined
123
- is_stranded: boolean
124
- is_session_active: boolean
125
- }
126
-
127
107
  // The main work tree, resolved once: it does not move while the board is open, and resolving it runs a
128
108
  // `git` subprocess the redraw would wait on (joshuafolkken/kit#4263). A failed resolve is forgotten,
129
109
  // so a `git` that timed out once is asked again on the next redraw rather than failing every one.
@@ -142,44 +122,6 @@ async function main_root(): Promise<string> {
142
122
  }
143
123
  }
144
124
 
145
- // Every process the run's record names is asked of `table`, the redraw's own `ps` snapshot — only once
146
- // a run is found, so a repository with no run waits on no `ps`.
147
- async function recorded_span_here(
148
- repository: string,
149
- table: TableRead | undefined,
150
- read: CarryRead,
151
- ): Promise<SpanHere> {
152
- const ended = run_carry_ended.read_ended(run_carry_ended.ended_path(repository))
153
- const launch = backlogrun_launch.pending_launch(repository)
154
- const span = run_board_span.span_of(read, ended, launch)
155
-
156
- if (span === undefined) return NO_SPAN
157
-
158
- const same_process = await run_board_roots.same_in(table)
159
- const stranded_ports = run_stranded_detect.ports_with(same_process)
160
-
161
- return {
162
- span,
163
- resume: run_board_span.resume_of(read, ended, launch),
164
- transcript: read.kind === 'carried' ? read.carry.owner_transcript : undefined,
165
- roots: run_board_roots.roots_of(repository, read, same_process),
166
- is_session_active: run_board_roots.session_is_active(repository, read, same_process),
167
- carry: read.kind === 'carried' ? read.carry : undefined,
168
- is_stranded: run_stranded_detect.is_stranded_at(repository, stranded_ports),
169
- }
170
- }
171
-
172
- // A single run this checkout holds is the one drawn (`run-board-single.ts`), before the ended run and,
173
- // outside a lane, before the carried one; it names no process.
174
- async function span_here(repository: string, table: TableRead | undefined): Promise<SpanHere> {
175
- const read = run_carry.read_carry(run_carry.carry_path(repository))
176
- const single = await run_board_single.span_here(read.kind !== 'none')
177
-
178
- return single === undefined
179
- ? await recorded_span_here(repository, table, read)
180
- : { ...NO_SPAN, span: single }
181
- }
182
-
183
125
  // Where each session of the run writes what it does: a lane's child to its dispatch log, in the lane's
184
126
  // own checkout; the parent and a judgment session to theirs, in this one.
185
127
  function sources_of(
@@ -327,11 +269,16 @@ async function span_events_here(
327
269
  }
328
270
  }
329
271
 
272
+ type RootFields = Pick<LocalRead, 'root' | 'ended_roots'>
273
+
274
+ // The span's own fields, with the root checkout's single runs beside it.
330
275
  function span_fields(
331
276
  span: BoardSpan,
332
- ): Pick<LocalRead, 'started_ms' | 'ended_ms' | 'scheduler_provider' | 'run_name'> {
277
+ root: RootFields,
278
+ ): Pick<LocalRead, 'started_ms' | 'ended_ms' | 'scheduler_provider' | 'run_name'> & RootFields {
333
279
  const run_name = run_board_single.name_of(span.invocation)
334
280
  const times = {
281
+ ...root,
335
282
  started_ms: span.started_ms,
336
283
  ended_ms: span.ended_ms,
337
284
  ...(run_name !== undefined && { run_name }),
@@ -342,12 +289,25 @@ function span_fields(
342
289
  return { ...times, scheduler_provider: span.scheduler_provider === 'openai' ? 'codex' : 'claude' }
343
290
  }
344
291
 
292
+ // The root checkout's single run beside the span, and its runs a merge ended inside it.
293
+ async function roots_here(
294
+ span: BoardSpan,
295
+ root: BoardSpan | undefined,
296
+ events: ReadonlyArray<RunEvent>,
297
+ ): Promise<RootFields> {
298
+ const ended_roots = await run_board_single.ended_here(span)
299
+
300
+ return root === undefined
301
+ ? { ended_roots }
302
+ : { root: run_board_single.root_of(root, events), ended_roots }
303
+ }
304
+
345
305
  // `undefined` when no run has started here. The lane count reads `table`, the redraw's process
346
306
  // snapshot, so it agrees with the usage columns.
347
307
  async function read_local(table?: TableRead): Promise<LocalRead | undefined> {
348
308
  const repository = await run_carry.repository_directory()
349
- const { span, resume, transcript, roots, carry, is_stranded, is_session_active } =
350
- repository === undefined ? NO_SPAN : await span_here(repository, table)
309
+ const { span, root, resume, transcript, roots, carry, is_stranded, is_session_active } =
310
+ await run_board_span_read.span_here(repository, table)
351
311
 
352
312
  if (repository === undefined || span === undefined) return undefined
353
313
 
@@ -356,7 +316,7 @@ async function read_local(table?: TableRead): Promise<LocalRead | undefined> {
356
316
  const lane_read = await lanes_here(lanes, repository, events, span.started_ms)
357
317
 
358
318
  return {
359
- ...span_fields(span),
319
+ ...span_fields(span, await roots_here(span, root, events)),
360
320
  scope: run_board_scope.scope_of(span.invocation, carry?.added),
361
321
  ...(await span_events_here(events, span, table, carry)),
362
322
  throughput: run_board_throughput.rows_of(events, Date.now()),
@@ -20,7 +20,8 @@ import { run_board_usage_text, type CellLimits } from './run-board-usage-text'
20
20
  const { HEADER_ICONS } = run_board_labels
21
21
  const { cell_widths, cells_of, lane_limits } = run_board_usage_text
22
22
  const { columns_rule } = run_board_render_section
23
- const { ELAPSED_START, LANES_LABEL, USAGE_START, column_offset, lane_label } = run_board_render_lead
23
+ const { ELAPSED_START, LANES_LABEL, USAGE_START, column_offset, lane_label, root_label } =
24
+ run_board_render_lead
24
25
  const { width_of } = run_board_fit
25
26
  const { columns_cut } = run_board_render_title
26
27
  const { reasons_of } = run_board_seat
@@ -150,20 +151,17 @@ function seat_lines(header: BoardHeader, columns: number | undefined): Array<Pla
150
151
  }))
151
152
  }
152
153
 
153
- // The run's own rows under the lanes rule, ⏰ over the run's time and the usage labels over their
154
- // cells where they are drawn, then the empty seats' reasons.
155
- function lanes_section(
156
- rows: ReadonlyArray<PlanLine>,
154
+ // A section of lane rows under `label`'s rule, ⏰ over the run's time and the usage labels over their
155
+ // cells where they are drawn; none without a line.
156
+ function rows_section(
157
+ lines: Array<PlanLine>,
158
+ label: string,
157
159
  cells: LaneCells | undefined,
158
160
  header: BoardHeader,
159
- columns?: number,
160
161
  ): Array<PlanSection> {
161
- const lines = [...rows, ...seat_lines(header, columns)]
162
-
163
162
  if (lines.length === 0) return []
164
163
 
165
164
  const offset = column_offset(header)
166
- const label = lane_label(header)
167
165
  const elapsed = { label: HEADER_ICONS.elapsed, start: ELAPSED_START + offset }
168
166
  const usage = cells === undefined ? [] : label_columns(USAGE_START + offset, cells.gauge)
169
167
  const rule = columns_rule(label, [elapsed, ...usage])
@@ -171,6 +169,25 @@ function lanes_section(
171
169
  return [{ head: ['', rule], lines, label, key: 'lanes' }]
172
170
  }
173
171
 
172
+ // The root checkout's single run's row, the first of `rows` (`run_board_layout.lane_rows`), under its
173
+ // own `🏠` rule above the run's lanes (joshuafolkken/kit#7524); then the run's own rows under the lanes
174
+ // rule and the empty seats' reasons.
175
+ function lanes_section(
176
+ rows: ReadonlyArray<PlanLine>,
177
+ cells: LaneCells | undefined,
178
+ header: BoardHeader,
179
+ columns?: number,
180
+ ): Array<PlanSection> {
181
+ const root = header.layout?.root
182
+ const root_count = root === undefined ? 0 : 1
183
+ const lanes = [...rows.slice(root_count), ...seat_lines(header, columns)]
184
+
185
+ return [
186
+ ...rows_section(rows.slice(0, root_count), root_label(root?.name ?? ''), cells, header),
187
+ ...rows_section(lanes, lane_label(header), cells, header),
188
+ ]
189
+ }
190
+
174
191
  const run_board_render_lanes = {
175
192
  COLUMN_LABELS,
176
193
  LANES_LABEL,
@@ -33,10 +33,18 @@ const ELAPSED_START =
33
33
  // The column a lane row's usage cells start at, after the lead and one blank; the processes rows' too.
34
34
  const USAGE_START = ELAPSED_START + TIME_WIDTH + SPACE
35
35
  const LANES_LABEL = `${run_board_labels.LANES_ICON} lanes`
36
+ // A single run's rule (joshuafolkken/kit#7523): it runs in the root checkout, not in a lane, so its
37
+ // row stands under the command it runs rather than under the lanes.
38
+ const SINGLE_RUN_ICON = '🏠'
36
39
  // Two leading rules, a space, and the separator before the first column.
37
40
  const RULE_ROOM = 6
38
41
 
42
+ function root_label(run_name: string): string {
43
+ return `${SINGLE_RUN_ICON} ${run_name}`
44
+ }
45
+
39
46
  function lane_label(header: BoardHeader): string {
47
+ if (header.run_name !== undefined) return root_label(header.run_name)
40
48
  if (header.occupied_count === undefined) return LANES_LABEL
41
49
 
42
50
  return `${LANES_LABEL} ${String(header.occupied_count)}/${String(header.lane_limit ?? '—')}`
@@ -102,6 +110,7 @@ const run_board_render_lead = {
102
110
  indent_of,
103
111
  lead_of,
104
112
  lane_label,
113
+ root_label,
105
114
  running_fresh,
106
115
  }
107
116
 
@@ -0,0 +1,69 @@
1
+ import { run_board_layout, type BoardLayout, type BoardRow, type RowRead } from './run-board-layout'
2
+ import type { LocalRead } from './run-board-read'
3
+ import type { EndedRoot } from './run-board-single'
4
+ import { run_board_status, type ItemStatus } from './run-board-status'
5
+
6
+ // The root checkout's single runs beside a carried run. A running one is drawn under 🏠 above the lanes
7
+ // (joshuafolkken/kit#7524), its row read off its own span's events and kept out of `active`, so the
8
+ // header counts no lane and no 🔄 for it. One a merge ended inside the run leaves 🏠 and is listed in
9
+ // `✅ done` as a lane's finished row is, in its place by its end (joshuafolkken/kit#7525).
10
+
11
+ type RootRead = Pick<LocalRead, 'root' | 'ended_roots' | 'lane_states'>
12
+
13
+ function live_of(local: RootRead, read: RowRead): Pick<BoardLayout, 'root'> {
14
+ const { root } = local
15
+
16
+ if (root === undefined) return {}
17
+ const status = run_board_status.statuses_of(root.events, local.lane_states).get(root.issue)
18
+
19
+ if (status === undefined) return {}
20
+ const statuses = new Map([[root.issue, status]])
21
+ const [row] = run_board_layout.local_layout_of(statuses, undefined, read).active
22
+
23
+ return row === undefined ? {} : { root: { name: root.command, row } }
24
+ }
25
+
26
+ // A merge released the run's hold, so it ended merged.
27
+ function ended_status(run: EndedRoot): ItemStatus {
28
+ return { state: 'merged', started_ms: run.started_ms, ended_ms: run.ended_ms }
29
+ }
30
+
31
+ // The ended runs' rows among the run's own, any row of the same issue replaced.
32
+ function active_of(
33
+ active: ReadonlyArray<BoardRow>,
34
+ ended: ReadonlyArray<EndedRoot>,
35
+ read: RowRead,
36
+ ): ReadonlyArray<BoardRow> {
37
+ if (ended.length === 0) return active
38
+
39
+ const statuses = new Map(ended.map((run) => [run.issue, ended_status(run)]))
40
+ const rows = run_board_layout.local_layout_of(statuses, undefined, read).active
41
+ const kept = active.filter((row) => !statuses.has(row.number))
42
+
43
+ return [...kept, ...rows].toSorted(run_board_layout.active_order)
44
+ }
45
+
46
+ function layout_of(layout: BoardLayout, local: RootRead, read: RowRead): BoardLayout {
47
+ return {
48
+ ...layout,
49
+ active: active_of(layout.active, local.ended_roots ?? [], read),
50
+ ...live_of(local, read),
51
+ }
52
+ }
53
+
54
+ // The baseline the header counts arrivals against: the rows the ended runs add to the run's are no
55
+ // arrival to its plan.
56
+ function baseline_of(
57
+ baseline_total: number | undefined,
58
+ run_layout: BoardLayout,
59
+ layout: BoardLayout,
60
+ ): number | undefined {
61
+ if (baseline_total === undefined) return undefined
62
+ const { rows_of } = run_board_layout
63
+
64
+ return baseline_total + rows_of(layout).length - rows_of(run_layout).length
65
+ }
66
+
67
+ const run_board_root_layout = { baseline_of, layout_of }
68
+
69
+ export { run_board_root_layout }
@@ -6,7 +6,8 @@ import { stamped_memo } from '#scripts/lib/stamped-memo'
6
6
  import { run_event_scope } from '#scripts/run/event/run-event-scope'
7
7
  import { run_event_stream, type RunEvent } from '#scripts/run/event/run-event-stream'
8
8
  import { run_hold, type HoldRead } from '#scripts/run/hold/run-hold'
9
- import type { BoardSpan } from './run-board-span'
9
+ import { run_hold_ended, type EndedHold } from '#scripts/run/hold/run-hold-ended'
10
+ import { run_board_span, type BoardSpan } from './run-board-span'
10
11
 
11
12
  // A single `fullrun #N` or `halfrun #N` has no carry record; the only record that says it is running
12
13
  // here is this checkout's hold (`run-hold.ts`), so the board draws it from there — no store of its own
@@ -24,6 +25,15 @@ interface SingleRun {
24
25
  issue: number
25
26
  }
26
27
 
28
+ interface RootRun extends SingleRun {
29
+ events: ReadonlyArray<RunEvent>
30
+ }
31
+
32
+ interface EndedRoot extends SingleRun {
33
+ started_ms: number
34
+ ended_ms: number
35
+ }
36
+
27
37
  // The checkout's own git directory, resolved once per working directory: resolving it runs a `git`
28
38
  // subprocess the redraw would wait on, as `run-board-read.ts`'s main work tree does.
29
39
  const directories = stamped_memo.create<Promise<string | undefined>>()
@@ -68,8 +78,8 @@ async function directory_here(): Promise<string | undefined> {
68
78
  }
69
79
 
70
80
  // The single run held in this checkout; `undefined` where none is. A lane's hold is its `backlogrun`
71
- // child's, drawn as that run's row, so only outside a lane does a hold outrank the carry record — a
72
- // `fullrun` in another checkout beside a running `backlogrun`.
81
+ // child's, drawn as that run's row, so only outside a lane is a hold read beside the carry record — a
82
+ // `fullrun` in the root checkout beside a running `backlogrun`.
73
83
  async function span_here(is_carried: boolean): Promise<BoardSpan | undefined> {
74
84
  if (is_carried && lane_paths.lane_issue_of(process.cwd()) !== undefined) return undefined
75
85
 
@@ -80,6 +90,41 @@ async function span_here(is_carried: boolean): Promise<BoardSpan | undefined> {
80
90
  return span_of(run_hold.read_hold(run_hold.hold_path(directory)))
81
91
  }
82
92
 
93
+ // A run the root checkout's merge released, as `run-hold-ended.ts` kept it; `undefined` where its
94
+ // record names no issue or no moment.
95
+ function ended_of(ended: EndedHold): EndedRoot | undefined {
96
+ if (!issue_number_shape.is_issue_number(ended.issue)) return undefined
97
+
98
+ const started_ms = run_event_scope.moment_of(ended.taken_at)
99
+ const ended_ms = run_event_scope.moment_of(ended.ended_at)
100
+
101
+ if (started_ms === undefined || ended_ms === undefined) return undefined
102
+
103
+ const command = ended.is_fullrun === true ? FULLRUN : HALFRUN
104
+
105
+ return { command, issue: Number(ended.issue), started_ms, ended_ms }
106
+ }
107
+
108
+ function is_inside(run: EndedRoot, span: BoardSpan): boolean {
109
+ return run.ended_ms >= span.started_ms && run.ended_ms <= (span.ended_ms ?? Infinity)
110
+ }
111
+
112
+ // The released runs that ended inside `span`, which the board lists in `✅ done` (joshuafolkken/kit#7525).
113
+ function ended_in(records: ReadonlyArray<EndedHold>, span: BoardSpan): Array<EndedRoot> {
114
+ return records
115
+ .map((record) => ended_of(record))
116
+ .filter((run): run is EndedRoot => run !== undefined && is_inside(run, span))
117
+ }
118
+
119
+ // This checkout's released single runs inside `span`; none in a lane, where no merge records one.
120
+ async function ended_here(span: BoardSpan): Promise<Array<EndedRoot>> {
121
+ const directory = await directory_here()
122
+
123
+ if (directory === undefined) return []
124
+
125
+ return ended_in(run_hold_ended.read_ended(run_hold_ended.ended_path(directory)), span)
126
+ }
127
+
83
128
  // The command and issue of a single run's invocation; `undefined` for any other run's.
84
129
  function single_of(invocation: string): SingleRun | undefined {
85
130
  const { command, issue } = SINGLE_INVOCATION.exec(invocation)?.groups ?? {}
@@ -118,6 +163,26 @@ function name_of(invocation: string): string | undefined {
118
163
  return single_of(invocation)?.command
119
164
  }
120
165
 
121
- const run_board_single = { events_of, name_of, scope_of, span_here, span_of }
166
+ // The root checkout's single run beside a carried run, with its own span's events its row's phase is
167
+ // read from (joshuafolkken/kit#7524).
168
+ function root_of(span: BoardSpan, events: ReadonlyArray<RunEvent>): RootRun | undefined {
169
+ const single = single_of(span.invocation)
170
+
171
+ if (single === undefined) return undefined
172
+
173
+ return { ...single, events: events_of(span, run_board_span.events_of(events, span)) }
174
+ }
175
+
176
+ const run_board_single = {
177
+ ended_here,
178
+ ended_in,
179
+ events_of,
180
+ name_of,
181
+ root_of,
182
+ scope_of,
183
+ span_here,
184
+ span_of,
185
+ }
122
186
 
123
187
  export { run_board_single }
188
+ export type { EndedRoot, RootRun }
@@ -0,0 +1,85 @@
1
+ import { backlogrun_launch } from '#scripts/backlog/backlogrun-launch'
2
+ import type { TableRead } from '#scripts/lane/lane-process-table'
3
+ import { run_carry, type CarryRead, type RunCarry } from '#scripts/run/carry/run-carry'
4
+ import { run_carry_ended } from '#scripts/run/carry/run-carry-ended'
5
+ import { run_stranded_detect } from '#scripts/run/run-stranded-detect'
6
+ import type { ProcessRoots } from './run-board-process-groups'
7
+ import { run_board_roots } from './run-board-roots'
8
+ import { run_board_single } from './run-board-single'
9
+ import { run_board_span, type BoardSpan } from './run-board-span'
10
+
11
+ // What `run:board` reads of the run drawn here: its span, the session it resumes from and the
12
+ // processes its record names.
13
+ interface SpanHere {
14
+ span: BoardSpan | undefined
15
+ resume: string | undefined
16
+ // The parent session's transcript, where the run's record names one.
17
+ transcript: string | undefined
18
+ roots: ProcessRoots
19
+ carry: RunCarry | undefined
20
+ is_stranded: boolean
21
+ is_session_active: boolean
22
+ // The root checkout's single run beside the carried one.
23
+ root?: BoardSpan | undefined
24
+ }
25
+
26
+ const NO_SPAN: SpanHere = {
27
+ span: undefined,
28
+ resume: undefined,
29
+ transcript: undefined,
30
+ roots: {},
31
+ carry: undefined,
32
+ is_stranded: false,
33
+ is_session_active: false,
34
+ }
35
+
36
+ // Every process the run's record names is asked of `table`, the redraw's own `ps` snapshot — only once
37
+ // a run is found, so a repository with no run waits on no `ps`.
38
+ async function recorded_span_here(
39
+ repository: string,
40
+ table: TableRead | undefined,
41
+ read: CarryRead,
42
+ ): Promise<SpanHere> {
43
+ const ended = run_carry_ended.read_ended(run_carry_ended.ended_path(repository))
44
+ const launch = backlogrun_launch.pending_launch(repository)
45
+ const span = run_board_span.span_of(read, ended, launch)
46
+
47
+ if (span === undefined) return NO_SPAN
48
+
49
+ const same_process = await run_board_roots.same_in(table)
50
+ const stranded_ports = run_stranded_detect.ports_with(same_process)
51
+
52
+ return {
53
+ span,
54
+ resume: run_board_span.resume_of(read, ended, launch),
55
+ transcript: read.kind === 'carried' ? read.carry.owner_transcript : undefined,
56
+ roots: run_board_roots.roots_of(repository, read, same_process),
57
+ is_session_active: run_board_roots.session_is_active(repository, read, same_process),
58
+ carry: read.kind === 'carried' ? read.carry : undefined,
59
+ is_stranded: run_stranded_detect.is_stranded_at(repository, stranded_ports),
60
+ }
61
+ }
62
+
63
+ // A single run this checkout holds is the one drawn (`run-board-single.ts`) before the ended run; it
64
+ // names no process. Beside a carried run it is the root checkout's, drawn as a row above the carried
65
+ // run's lanes, which stays the frame (joshuafolkken/kit#7524). No repository, no run.
66
+ async function span_here(
67
+ repository: string | undefined,
68
+ table: TableRead | undefined,
69
+ ): Promise<SpanHere> {
70
+ if (repository === undefined) return NO_SPAN
71
+ const read = run_carry.read_carry(run_carry.carry_path(repository))
72
+ const is_carried = read.kind !== 'none'
73
+ const single = await run_board_single.span_here(is_carried)
74
+
75
+ if (single === undefined) return await recorded_span_here(repository, table, read)
76
+ const recorded = is_carried ? await recorded_span_here(repository, table, read) : NO_SPAN
77
+
78
+ // A carried record that reads no span leaves the single run the one drawn.
79
+ return recorded.span === undefined ? { ...NO_SPAN, span: single } : { ...recorded, root: single }
80
+ }
81
+
82
+ const run_board_span_read = { span_here }
83
+
84
+ export { run_board_span_read }
85
+ export type { SpanHere }
@@ -13,6 +13,7 @@ import type { LocalRead } from './run-board-read'
13
13
  import { run_board_reason, type ReasonRead } from './run-board-reason'
14
14
  import { run_board_released } from './run-board-released'
15
15
  import { run_board_render } from './run-board-render'
16
+ import { run_board_root_layout } from './run-board-root-layout'
16
17
  import { run_board_seat, type SeatRead } from './run-board-seat'
17
18
  import { run_board_setup } from './run-board-setup'
18
19
  import type { Known } from './run-board-snapshot'
@@ -321,13 +322,20 @@ function draw(ports: BoardPorts, lines: ReadonlyArray<string>): void {
321
322
  }
322
323
 
323
324
  // The first total the plan gives is the baseline every later arrival is counted against; the run's own
324
- // children before it are no total.
325
+ // children before it are no total. The root checkout's single runs are laid over the run's.
325
326
  function draw_run(state: BoardState, local: LocalRead, redraw: Redraw): BoardState {
326
- const layout = layout_for(state, local, redraw.now_ms)
327
+ const read = closed_under(known_of(state), state.closed)
328
+ const run_layout = layout_for(state, local, redraw.now_ms)
329
+ const layout = run_board_root_layout.layout_of(run_layout, local, read)
327
330
  const first_total =
328
- state.plan === undefined ? undefined : run_board_header.counts_of(layout).total
331
+ state.plan === undefined ? undefined : run_board_header.counts_of(run_layout).total
329
332
  const settled = { ...state, baseline_total: state.baseline_total ?? first_total }
330
- const { lines, spots } = frame_of(settled, local, layout, redraw)
333
+ const baseline_total = run_board_root_layout.baseline_of(
334
+ settled.baseline_total,
335
+ run_layout,
336
+ layout,
337
+ )
338
+ const { lines, spots } = frame_of({ ...settled, baseline_total }, local, layout, redraw)
331
339
 
332
340
  draw(redraw.ports, lines)
333
341
 
@@ -0,0 +1,50 @@
1
+ import { stamp_file } from '#scripts/josh/stamp-file'
2
+ import { json_value } from '#scripts/lib/json-value'
3
+ import { z } from 'zod'
4
+ import type { HoldRead } from './run-hold'
5
+
6
+ // The runs whose hold a merge released here. A single `fullrun` or `halfrun` in the root checkout has
7
+ // no record but its hold (`run-board-single.ts`), and the release removes it, so without this
8
+ // `run:board` would lose the run the moment it merged; this keeps when each began and ended for the
9
+ // board's `✅ done` (joshuafolkken/kit#7525). The newest few only: the board reads the ones that ended
10
+ // inside the run on screen.
11
+
12
+ const ENDED_PREFIX = 'josh-run-hold-ended-'
13
+ const KEPT_RUNS = 20
14
+
15
+ const ended_hold_schema = z.object({
16
+ issue: z.string(),
17
+ is_fullrun: z.boolean().optional(),
18
+ taken_at: z.string(),
19
+ ended_at: z.string(),
20
+ })
21
+
22
+ type EndedHold = z.infer<typeof ended_hold_schema>
23
+
24
+ // Keyed like the hold, on the work tree's own git directory.
25
+ function ended_path(git_directory: string): string {
26
+ return stamp_file.stamp_path(ENDED_PREFIX, git_directory)
27
+ }
28
+
29
+ // None where no run has ended here, or the record does not read.
30
+ function read_ended(target: string): ReadonlyArray<EndedHold> {
31
+ const raw = stamp_file.read_stamp_text(target)
32
+
33
+ return raw === undefined ? [] : (json_value.parse_with(raw, z.array(ended_hold_schema)) ?? [])
34
+ }
35
+
36
+ // Records the hold a merge is about to release; a read with no live hold names no run.
37
+ function record_ended(target: string, read: HoldRead, now: Date = new Date()): void {
38
+ if (read.kind !== 'held') return
39
+
40
+ const { issue, is_fullrun, taken_at } = read.hold
41
+ const ended: EndedHold = { issue, taken_at, ended_at: now.toISOString() }
42
+
43
+ if (is_fullrun === true) ended.is_fullrun = true
44
+ stamp_file.replace_stamp(target, [...read_ended(target), ended].slice(-KEPT_RUNS))
45
+ }
46
+
47
+ const run_hold_ended = { KEPT_RUNS, ended_path, read_ended, record_ended }
48
+
49
+ export { run_hold_ended }
50
+ export type { EndedHold }