@joshuafolkken/kit 1.1383.0 → 1.1435.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 (244) hide show
  1. package/.claude/skills/epic-commands/SKILL.md +1 -1
  2. package/.claude/skills/epic-commands/epic-bundle.md +5 -0
  3. package/.claude/skills/workflow-commands/backlogrun-lanes.md +5 -3
  4. package/.claude/skills/workflow-commands/backlogrun-steps.md +4 -3
  5. package/.claude/skills/workflow-commands/backlogrun.md +1 -0
  6. package/.claude/skills/workflow-commands/chain-rule.md +4 -2
  7. package/.claude/skills/workflow-commands/issue-comments.md +6 -0
  8. package/.claude/skills/workflow-commands/observation-ledger.md +3 -2
  9. package/.claude/skills/workflow-commands/pre-gate-cut.md +5 -3
  10. package/README.md +25 -0
  11. package/dist/hooks/audit-provision.js +2 -2
  12. package/dist/hooks/batch-guard.js +7 -7
  13. package/dist/hooks/{chunk-LOJTBU5V.js → chunk-2G5F2YCD.js} +3 -3
  14. package/dist/hooks/{chunk-4MGHBTOM.js → chunk-3LE6VLSR.js} +25 -8
  15. package/dist/hooks/{chunk-Y5R47QHU.js → chunk-4BHUPFKF.js} +1 -1
  16. package/dist/hooks/{chunk-KC6LUZZ2.js → chunk-5SJSSCJG.js} +1 -1
  17. package/dist/hooks/{chunk-FZ6FUSBM.js → chunk-723HKZO5.js} +1 -1
  18. package/dist/hooks/{chunk-RYP3TSUJ.js → chunk-7KMN5QB5.js} +3 -3
  19. package/dist/hooks/{chunk-NEC4AT4Z.js → chunk-DGT54IH5.js} +4 -0
  20. package/dist/hooks/{chunk-YKUITFT7.js → chunk-EMB55BDF.js} +4 -4
  21. package/dist/hooks/{chunk-JBBMAPC4.js → chunk-IX6TTTI6.js} +4 -0
  22. package/dist/hooks/{chunk-6MAW7BCM.js → chunk-JXQBK323.js} +2677 -190
  23. package/dist/hooks/{chunk-KCF2YE7O.js → chunk-MSYSUSY6.js} +5 -5
  24. package/dist/hooks/{chunk-LC56R53A.js → chunk-MU4LJFIV.js} +4 -4
  25. package/dist/hooks/{chunk-7VSZ2N4B.js → chunk-QL6QTPWN.js} +3 -3
  26. package/dist/hooks/{chunk-TT742SIO.js → chunk-XER6SSIB.js} +92 -19
  27. package/dist/hooks/{chunk-2X2ART5G.js → chunk-YW452ZAX.js} +500 -29
  28. package/dist/hooks/{chunk-6AC4C2IO.js → chunk-YZXYOAZ3.js} +187 -443
  29. package/dist/hooks/codex-hook-adapter.js +15 -15
  30. package/dist/hooks/duplicate-read-guard.js +8 -8
  31. package/dist/hooks/format-edited.js +5 -5
  32. package/dist/hooks/inputs.json +262 -201
  33. package/dist/hooks/investigation-guard.js +8 -8
  34. package/dist/hooks/pretool-guard.js +14 -14
  35. package/dist/hooks/session-lang.js +124 -15
  36. package/dist/hooks/stop-guard.js +98 -55
  37. package/dist/josh.js +63 -10
  38. package/package.json +4 -3
  39. package/prompts/review.md +22 -0
  40. package/scripts/agent/claude-agent-argv.ts +13 -1
  41. package/scripts/auth/auth-setup-cli.ts +101 -0
  42. package/scripts/auth/claude-token-output.ts +78 -0
  43. package/scripts/auth/claude-token.ts +68 -0
  44. package/scripts/backlog/backlog-drive-cli.ts +2 -4
  45. package/scripts/backlog/backlog-drive-epic.ts +24 -8
  46. package/scripts/backlog/backlog-drive-launch.ts +5 -0
  47. package/scripts/backlog/backlog-drive-named-offer.ts +7 -2
  48. package/scripts/backlog/backlog-drive-named.ts +4 -2
  49. package/scripts/backlog/backlog-drive-owner.ts +72 -5
  50. package/scripts/backlog/backlog-drive-unread.ts +56 -0
  51. package/scripts/backlog/backlog-drive.ts +16 -8
  52. package/scripts/backlog/backlog-overlap.ts +22 -5
  53. package/scripts/backlog/backlog-ready.ts +1 -3
  54. package/scripts/backlog/backlogrun-args.ts +13 -2
  55. package/scripts/backlog/backlogrun-cli.ts +91 -19
  56. package/scripts/backlog/backlogrun-launch.ts +80 -5
  57. package/scripts/backlog/backlogrun-replace.ts +166 -0
  58. package/scripts/backlog/backlogrun-stop.ts +178 -0
  59. package/scripts/behavior/behavior-cli.ts +6 -1
  60. package/scripts/doctor/doctor-io.ts +60 -9
  61. package/scripts/doctor/doctor.ts +20 -1
  62. package/scripts/document/document-byte-budget.ts +3 -3
  63. package/scripts/epic/epic-bundle-cli.ts +8 -8
  64. package/scripts/epic/epic-bundle-referenced.ts +2 -5
  65. package/scripts/epic/epic-bundle.ts +39 -4
  66. package/scripts/exports/unused-members-cli.ts +2 -1
  67. package/scripts/followup/followup-checks-event.ts +82 -0
  68. package/scripts/followup/git-followup-finish.ts +16 -2
  69. package/scripts/followup/git-followup-workflow.ts +5 -2
  70. package/scripts/followup/git-pr-followup-checks.ts +18 -4
  71. package/scripts/followup/git-pr-followup-wrapup.ts +5 -0
  72. package/scripts/followup/git-pr-followup.ts +4 -1
  73. package/scripts/fresh-machine/fresh-machine-cli.ts +204 -0
  74. package/scripts/fresh-machine/fresh-machine-docker.ts +65 -0
  75. package/scripts/fresh-machine/fresh-machine-image.ts +62 -0
  76. package/scripts/fresh-machine/fresh-machine-log.ts +61 -0
  77. package/scripts/fresh-machine/fresh-machine-patterns.ts +130 -0
  78. package/scripts/fresh-machine/fresh-machine-run.ts +215 -0
  79. package/scripts/fresh-machine/fresh-machine-script.ts +45 -0
  80. package/scripts/fresh-machine/fresh-machine-spec.ts +57 -0
  81. package/scripts/fresh-machine/specs/kit-test-backlogrun-python.json +34 -0
  82. package/scripts/fresh-machine/specs/kit-test-backlogrun.json +31 -0
  83. package/scripts/gate/gate-report.ts +70 -15
  84. package/scripts/gate/project-checks.ts +13 -2
  85. package/scripts/gate/scoped-green.ts +2 -1
  86. package/scripts/gate/verification-gate.ts +4 -3
  87. package/scripts/gh/gh-author-association.ts +14 -0
  88. package/scripts/gh/git-gh-conditional.ts +66 -0
  89. package/scripts/gh/git-gh-exec.ts +1 -1
  90. package/scripts/gh/git-gh-issue-read.ts +6 -3
  91. package/scripts/gh/git-gh-issue-write.ts +5 -1
  92. package/scripts/gh/git-gh-issue.ts +4 -1
  93. package/scripts/gh/git-pr-checks-eval.ts +13 -0
  94. package/scripts/gh/git-pr-checks-parse.ts +8 -1
  95. package/scripts/gh/git-pr-checks-watch.ts +11 -4
  96. package/scripts/gh/git-pr-checks.ts +36 -2
  97. package/scripts/git/git-command.ts +16 -4
  98. package/scripts/git/git-schemas.ts +1 -0
  99. package/scripts/git/git-spawn-sync.ts +1 -1
  100. package/scripts/hooks/pretool-guard.ts +3 -1
  101. package/scripts/init/hook-command-rewrite.ts +31 -0
  102. package/scripts/init/init-actions.ts +4 -1
  103. package/scripts/init/init-ai-copy.ts +33 -10
  104. package/scripts/init/init-basic.ts +8 -3
  105. package/scripts/init/init-logic-toolchain.ts +76 -0
  106. package/scripts/init/init-logic.ts +2 -0
  107. package/scripts/init/init.ts +5 -23
  108. package/scripts/init/project-profile.ts +36 -7
  109. package/scripts/init/start-exec.ts +27 -1
  110. package/scripts/init/start-plan.ts +30 -4
  111. package/scripts/init/start-steps.ts +77 -3
  112. package/scripts/init/start.ts +21 -13
  113. package/scripts/init/workflow-mark.ts +35 -0
  114. package/scripts/issue/issue-ack-cli.ts +84 -0
  115. package/scripts/issue/issue-labels.ts +40 -8
  116. package/scripts/issue/issue-state-cli.ts +14 -4
  117. package/scripts/issue/issue-watch-diff.ts +152 -0
  118. package/scripts/issue/issue-watch-gate.ts +81 -0
  119. package/scripts/issue/issue-watch-hook.ts +54 -0
  120. package/scripts/issue/issue-watch.ts +240 -0
  121. package/scripts/issue/measure-rerun-cli.ts +3 -4
  122. package/scripts/josh/josh-command-map.ts +4 -0
  123. package/scripts/josh/josh-commands-backlog.ts +2 -2
  124. package/scripts/josh/josh-commands-development.ts +1 -1
  125. package/scripts/josh/josh-commands-issue.ts +7 -0
  126. package/scripts/josh/josh-commands-lane.ts +11 -2
  127. package/scripts/josh/josh-commands-project.ts +25 -1
  128. package/scripts/josh/josh-verdict.ts +33 -4
  129. package/scripts/lane/claude-lane-broker.ts +142 -0
  130. package/scripts/lane/lane-await.ts +6 -2
  131. package/scripts/lane/lane-cache.ts +37 -7
  132. package/scripts/lane/lane-capacity.ts +8 -6
  133. package/scripts/lane/lane-child-invocation.ts +1 -0
  134. package/scripts/lane/lane-cli.ts +15 -1
  135. package/scripts/lane/lane-close.ts +15 -6
  136. package/scripts/lane/lane-dispatch.ts +36 -10
  137. package/scripts/lane/lane-environment.ts +1 -1
  138. package/scripts/lane/lane-launch-cli.ts +13 -4
  139. package/scripts/lane/lane-ledger.ts +21 -3
  140. package/scripts/lane/lane-measure-cli.ts +10 -2
  141. package/scripts/lane/lane-open.ts +9 -1
  142. package/scripts/lane/lane-preserve.ts +49 -0
  143. package/scripts/lane/lane-reap.ts +20 -5
  144. package/scripts/lane/lane-registry.ts +6 -0
  145. package/scripts/lane/lane-restart-cli.ts +51 -0
  146. package/scripts/lane/lane-restart.ts +175 -0
  147. package/scripts/lane/{openai-review-broker.ts → lane-session-broker.ts} +106 -28
  148. package/scripts/lane/lane-spent.ts +195 -0
  149. package/scripts/lane/lane-stage-stats.ts +27 -3
  150. package/scripts/lane/lane-stop-stats.ts +96 -0
  151. package/scripts/lane/openai-lane-supervisor.ts +2 -2
  152. package/scripts/lane/process-tools.ts +31 -0
  153. package/scripts/lib/buffered-process.ts +36 -2
  154. package/scripts/lib/pnpm-reach.ts +27 -0
  155. package/scripts/lib/status-icons.ts +5 -1
  156. package/scripts/lint/lint-parallel.ts +19 -2
  157. package/scripts/observations/observation-ledger-home.ts +1 -0
  158. package/scripts/review/review-attest-cli.ts +21 -6
  159. package/scripts/review/review-brief-cli.ts +2 -2
  160. package/scripts/review/review-comment-round.ts +114 -0
  161. package/scripts/review/review-record-cli.ts +39 -17
  162. package/scripts/rules/implementation-cut.ts +3 -3
  163. package/scripts/rules/pre-gate-cut.ts +1 -1
  164. package/scripts/run/board/run-board-checks.ts +42 -0
  165. package/scripts/run/board/run-board-cli.ts +2 -0
  166. package/scripts/run/board/run-board-every.ts +3 -1
  167. package/scripts/run/board/run-board-fit.ts +16 -5
  168. package/scripts/run/board/run-board-fresh.ts +64 -0
  169. package/scripts/run/board/run-board-header.ts +139 -37
  170. package/scripts/run/board/run-board-labels.ts +47 -4
  171. package/scripts/run/board/run-board-lane-event.ts +66 -0
  172. package/scripts/run/board/run-board-layout.ts +57 -9
  173. package/scripts/run/board/run-board-now.ts +234 -0
  174. package/scripts/run/board/run-board-operation-read.ts +75 -0
  175. package/scripts/run/board/run-board-operation.ts +150 -0
  176. package/scripts/run/board/run-board-phase.ts +26 -1
  177. package/scripts/run/board/run-board-plan.ts +4 -6
  178. package/scripts/run/board/run-board-read.ts +58 -5
  179. package/scripts/run/board/run-board-reason.ts +124 -0
  180. package/scripts/run/board/run-board-render-checks.ts +55 -0
  181. package/scripts/run/board/run-board-render-legend.ts +12 -3
  182. package/scripts/run/board/run-board-render-slot.ts +2 -2
  183. package/scripts/run/board/run-board-render-tail.ts +128 -0
  184. package/scripts/run/board/run-board-render-thanks.ts +50 -0
  185. package/scripts/run/board/run-board-render-title.ts +58 -0
  186. package/scripts/run/board/run-board-render.ts +96 -80
  187. package/scripts/run/board/run-board-row-yield.ts +32 -0
  188. package/scripts/run/board/run-board-setup.ts +121 -0
  189. package/scripts/run/board/run-board-span.ts +49 -7
  190. package/scripts/run/board/run-board-state.ts +10 -0
  191. package/scripts/run/board/run-board-status.ts +76 -13
  192. package/scripts/run/board/run-board-thanks-csv.ts +71 -0
  193. package/scripts/run/board/run-board-thanks-read.ts +112 -0
  194. package/scripts/run/board/run-board-thanks.ts +53 -0
  195. package/scripts/run/board/run-board-tick.ts +53 -16
  196. package/scripts/run/board/run-board-usual.ts +44 -0
  197. package/scripts/run/carry/run-carry-added.ts +1 -1
  198. package/scripts/run/carry/run-carry-args.ts +9 -1
  199. package/scripts/run/carry/run-carry-change.ts +1 -1
  200. package/scripts/run/carry/run-carry-cli.ts +39 -52
  201. package/scripts/run/carry/run-carry-end.ts +73 -0
  202. package/scripts/run/carry/run-carry-ended.ts +9 -1
  203. package/scripts/run/carry/run-carry.ts +24 -1
  204. package/scripts/run/entry/run-entry-cli.ts +5 -0
  205. package/scripts/run/event/run-event-checks.ts +54 -0
  206. package/scripts/run/event/run-event-render.ts +3 -0
  207. package/scripts/run/event/run-event-scope.ts +1 -0
  208. package/scripts/run/event/run-event-setup.ts +91 -0
  209. package/scripts/run/event/run-event-stream.ts +46 -9
  210. package/scripts/run/merge/run-merge-cli.ts +7 -4
  211. package/scripts/run/merge/run-merge-ship.ts +61 -0
  212. package/scripts/run/merge/run-merge-steps.ts +7 -14
  213. package/scripts/run/merge/run-merge-token.ts +16 -2
  214. package/scripts/run/run-headless.ts +1 -0
  215. package/scripts/run/run-step.ts +2 -0
  216. package/scripts/run/run-stranded-detect.ts +58 -12
  217. package/scripts/run/ship/run-ship-cli.ts +22 -2
  218. package/scripts/run/ship/run-ship-return.ts +31 -13
  219. package/scripts/run/ship/run-ship-review-steps.ts +17 -4
  220. package/scripts/run/ship/run-ship-stage.ts +1 -0
  221. package/scripts/run/wake/run-wake-await.ts +130 -0
  222. package/scripts/run/wake/run-wake-cli.ts +108 -74
  223. package/scripts/run/wake/run-wake-describe.ts +36 -2
  224. package/scripts/run/wake/run-wake-driver.ts +21 -5
  225. package/scripts/run/wake/run-wake-handoff.ts +1 -1
  226. package/scripts/run/wake/run-wake-session.ts +20 -3
  227. package/scripts/run/wake/run-wake-stop.ts +37 -0
  228. package/scripts/run/wake/run-wake-supervise.ts +44 -0
  229. package/scripts/sync/claude-settings-merge.ts +144 -0
  230. package/scripts/sync/sync-ai-files.ts +28 -3
  231. package/scripts/sync/sync.ts +15 -0
  232. package/scripts/test/project-test-script.ts +73 -0
  233. package/scripts/test/skip-marker.ts +14 -1
  234. package/scripts/test/test-related.ts +3 -2
  235. package/scripts/test/test-unit-guard.ts +78 -14
  236. package/scripts/time-runtime/time-reported-failure.ts +11 -3
  237. package/scripts/uninstall/distributed-files.ts +92 -0
  238. package/scripts/uninstall/uninstall-plan.ts +119 -0
  239. package/scripts/uninstall/uninstall-report.ts +61 -0
  240. package/scripts/uninstall/uninstall.ts +206 -0
  241. package/templates/AGENTS.basic.md +4 -0
  242. package/templates/GEMINI.basic.md +6 -0
  243. package/dist/hooks/chunk-6V5OPIUU.js +0 -13
  244. package/scripts/run/board/run-board-track.ts +0 -22
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
+ import { backlogrun_launch } from '#scripts/backlog/backlogrun-launch'
3
4
  import { cost_cli } from '#scripts/cost-runtime/cost-cli'
4
5
  import { cost_verdict } from '#scripts/cost-runtime/cost-verdict'
6
+ import { run_event_setup } from '#scripts/run/event/run-event-setup'
5
7
  import { run_event_stream } from '#scripts/run/event/run-event-stream'
6
8
  import { run_event_stream_emit } from '#scripts/run/event/run-event-stream-emit'
7
- import { run_merge_collect } from '#scripts/run/merge/run-merge-collect'
8
9
  import { run_cli_fault } from '#scripts/run/run-cli-fault'
9
10
  import { run_headless } from '#scripts/run/run-headless'
10
- import { run_stop_notify } from '#scripts/run/run-stop-notify'
11
11
  import {
12
12
  run_carry,
13
13
  type CarryClaim,
@@ -17,8 +17,7 @@ import {
17
17
  } from './run-carry'
18
18
  import { run_carry_args, type CountRequest, type Request } from './run-carry-args'
19
19
  import { run_carry_conversation } from './run-carry-conversation'
20
- import { run_carry_ended } from './run-carry-ended'
21
- import { run_carry_stash } from './run-carry-stash'
20
+ import { run_carry_end } from './run-carry-end'
22
21
 
23
22
  // `josh run:carry` — the record that carries one invocation's budget across its own session cuts.
24
23
  // A `backlogrun` begins it, counts a merge and a filing into it, marks each
@@ -151,25 +150,47 @@ function report_claim(
151
150
  return report(CLAIM_VERDICTS[claim], carry, is_json, FAILURE_EXIT_CODE)
152
151
  }
153
152
 
154
- function start_fresh(target: string, request: CarryClaimRequest, is_json: boolean): number {
155
- const carry = run_carry.begin_carry(target, request.invocation, request.owner)
153
+ // When the run began: the moment `josh backlogrun` launched this owner, so the clock `run:board` was
154
+ // already counting from does not restart at the claim; `undefined` — now — for every other run.
155
+ async function launch_start(request: CarryClaimRequest): Promise<Date | undefined> {
156
+ const directory = await run_carry.repository_directory()
157
+
158
+ if (directory === undefined) return undefined
159
+
160
+ return backlogrun_launch.started_for(directory, request.owner.pid)
161
+ }
162
+
163
+ // A run that began is planning, which is what its board says until the supervisor takes it over.
164
+ async function start_fresh(
165
+ target: string,
166
+ request: CarryClaimRequest,
167
+ is_json: boolean,
168
+ ): Promise<number> {
169
+ const started = await launch_start(request)
170
+ const carry = run_carry.begin_carry(target, request.invocation, request.owner, started)
156
171
 
157
172
  if (carry === undefined) return report_lost(is_json)
158
173
 
174
+ await run_event_setup.emit(run_event_setup.STAGE.PLANNING)
175
+
159
176
  return report_carry(BEGAN_VERDICT, carry, is_json)
160
177
  }
161
178
 
162
179
  // **A standing record is never replaced, and never resumed into by something else.** Replacing one
163
180
  // would restart the budget the cut exists to carry, and resuming into one would put two parents on a
164
181
  // single budget. Its age changes none of that: a record is replaced by nothing but a person's `--end`.
165
- function begin(target: string, request: CarryClaimRequest, is_json: boolean): number {
182
+ async function begin(
183
+ target: string,
184
+ request: CarryClaimRequest,
185
+ is_json: boolean,
186
+ ): Promise<number> {
166
187
  const read = run_carry.read_carry(target)
167
188
 
168
189
  if (read.kind === 'unreadable') return report_unreadable(is_json)
169
190
 
170
191
  if (read.kind === 'carried') return report_claim(target, read.carry, request, is_json)
171
192
 
172
- return start_fresh(target, request, is_json)
193
+ return await start_fresh(target, request, is_json)
173
194
  }
174
195
 
175
196
  function report_nothing_to_resume(is_json: boolean): number {
@@ -203,10 +224,16 @@ function report_over(is_json: boolean): number {
203
224
  return report(OVER_VERDICT, undefined, is_json, FAILURE_EXIT_CODE)
204
225
  }
205
226
 
206
- function claim_record(target: string, request: CarryClaimRequest, is_json: boolean): number {
227
+ async function claim_record(
228
+ target: string,
229
+ request: CarryClaimRequest,
230
+ is_json: boolean,
231
+ ): Promise<number> {
207
232
  if (cost_cli.session_verdict() === cost_verdict.OVER_VERDICT) return report_over(is_json)
208
233
 
209
- return request.is_adoption ? adopt(target, request, is_json) : begin(target, request, is_json)
234
+ if (request.is_adoption) return adopt(target, request, is_json)
235
+
236
+ return await begin(target, request, is_json)
210
237
  }
211
238
 
212
239
  // The record is no longer this session's to advance — handed off at a cut, or taken over by a live
@@ -286,52 +313,12 @@ async function count(
286
313
  return report_carry(COUNTED_VERDICT, carry, is_json)
287
314
  }
288
315
 
289
- // **A stop over a record that is still there pushes one ⏸️ confirmation before the record goes**.
290
- // `plan` is read from the record `--end` is about to remove, so the second `--end` reads `none` and
291
- // plans nothing — one stop never notifies twice. A clean `--end` passes no reason and stays silent,
292
- // because a completed run has its own notification. The closed-issue stash report
293
- // (`run-carry-stash.ts`) is once per invocation on the same read.
294
- async function report_end(read: CarryRead): Promise<void> {
295
- if (read.kind === 'none') return
296
-
297
- await run_carry_stash.report_orphans()
298
- }
299
-
300
- // Best-effort: the board's record of the ended run never stands between `--end` and the carry record it
301
- // clears, so a failed write goes to stderr rather than leaving the run carried.
302
- function keep_ended(directory: string, read: CarryRead, stopped: string | undefined): void {
303
- try {
304
- run_carry_ended.record_ended(run_carry_ended.ended_path(directory), read, new Date(), stopped)
305
- } catch (error) {
306
- console.error(`run:carry: the ended run was not recorded for run:board: ${String(error)}`)
307
- }
308
- }
309
-
310
- // The record goes, and the run it held stays readable as the last ended run, so `run:board` keeps the
311
- // finished run on screen until the next one begins — a stopped one with the session to resume it from.
312
- function close_record(directory: string, stopped: string | undefined): CarryRead {
313
- const target = run_carry.carry_path(directory)
314
- const read = run_carry.read_carry(target)
315
-
316
- keep_ended(directory, read, stopped)
317
- run_carry.end_carry(target)
318
-
319
- return read
320
- }
321
-
322
- // The merges the run ended with are recorded while its record can still count them.
323
316
  async function finish(
324
317
  directory: string,
325
318
  stopped: string | undefined,
326
319
  is_json: boolean,
327
320
  ): Promise<number> {
328
- await run_merge_collect.collect_merged(directory)
329
- const read = close_record(directory, stopped)
330
- const notice = run_stop_notify.plan(read, stopped)
331
-
332
- if (notice !== undefined) await run_stop_notify.announce(notice)
333
-
334
- await report_end(read)
321
+ const read = await run_carry_end.end_run(directory, { stopped, is_announced: true })
335
322
 
336
323
  if (read.kind === 'none') return report(NONE_VERDICT, undefined, is_json, SUCCESS_EXIT_CODE)
337
324
 
@@ -347,7 +334,7 @@ async function act(
347
334
  request: Exclude<Request, { kind: 'end' }>,
348
335
  is_json: boolean,
349
336
  ): Promise<number> {
350
- if (request.kind === 'claim') return claim_record(target, request.claim, is_json)
337
+ if (request.kind === 'claim') return await claim_record(target, request.claim, is_json)
351
338
 
352
339
  const read = run_carry.read_carry(target)
353
340
 
@@ -0,0 +1,73 @@
1
+ import { run_merge_collect } from '#scripts/run/merge/run-merge-collect'
2
+ import { run_stop_notify } from '#scripts/run/run-stop-notify'
3
+ import { run_carry, type CarryRead } from './run-carry'
4
+ import { run_carry_ended } from './run-carry-ended'
5
+ import { run_carry_stash } from './run-carry-stash'
6
+
7
+ // Ending a run's record, apart from the command that prints the verdict: `run:carry --end` and
8
+ // `josh backlogrun --stop` both end it here, so the one end is never written twice.
9
+
10
+ interface EndRequest {
11
+ // Why the run stopped; `undefined` is a clean finish.
12
+ stopped: string | undefined
13
+ // Whether a stop pushes its ⏸️ confirmation. A person who typed the stop is already at the terminal.
14
+ is_announced: boolean
15
+ }
16
+
17
+ // **A stop over a record that is still there pushes one ⏸️ confirmation before the record goes**.
18
+ // `plan` is read from the record the end is about to remove, so the second end reads `none` and
19
+ // plans nothing — one stop never notifies twice. A clean end passes no reason and stays silent,
20
+ // because a completed run has its own notification.
21
+ async function announce_stop(read: CarryRead, request: EndRequest): Promise<void> {
22
+ if (!request.is_announced) return
23
+
24
+ const notice = run_stop_notify.plan(read, request.stopped)
25
+
26
+ if (notice !== undefined) await run_stop_notify.announce(notice)
27
+ }
28
+
29
+ // The closed-issue stash report (`run-carry-stash.ts`) is once per invocation on the same read.
30
+ async function report_end(read: CarryRead): Promise<void> {
31
+ if (read.kind === 'none') return
32
+
33
+ await run_carry_stash.report_orphans()
34
+ }
35
+
36
+ // Best-effort: the board's record of the ended run never stands between the end and the carry record it
37
+ // clears, so a failed write goes to stderr rather than leaving the run carried.
38
+ function keep_ended(directory: string, read: CarryRead, stopped: string | undefined): void {
39
+ try {
40
+ run_carry_ended.record_ended(run_carry_ended.ended_path(directory), read, new Date(), stopped)
41
+ } catch (error) {
42
+ console.error(`run:carry: the ended run was not recorded for run:board: ${String(error)}`)
43
+ }
44
+ }
45
+
46
+ // The record goes, and the run it held stays readable as the last ended run, so `run:board` keeps the
47
+ // finished run on screen until the next one begins — a stopped one with the session to resume it from.
48
+ function close_record(directory: string, stopped: string | undefined): CarryRead {
49
+ const target = run_carry.carry_path(directory)
50
+ const read = run_carry.read_carry(target)
51
+
52
+ keep_ended(directory, read, stopped)
53
+ run_carry.end_carry(target)
54
+
55
+ return read
56
+ }
57
+
58
+ // The merges the run ended with are recorded while its record can still count them. The read handed
59
+ // back is the record as it stood before it was removed.
60
+ async function end_run(directory: string, request: EndRequest): Promise<CarryRead> {
61
+ await run_merge_collect.collect_merged(directory)
62
+ const read = close_record(directory, request.stopped)
63
+
64
+ await announce_stop(read, request)
65
+ await report_end(read)
66
+
67
+ return read
68
+ }
69
+
70
+ const run_carry_end = { end_run }
71
+
72
+ export type { EndRequest }
73
+ export { run_carry_end }
@@ -79,7 +79,15 @@ function read_ended(target: string): EndedRun | undefined {
79
79
  return raw === undefined ? undefined : parse_ended(raw)
80
80
  }
81
81
 
82
- const run_carry_ended = { ended_path, read_ended, record_ended }
82
+ // Whether the run that began at `started_at` is the one `--end` last ended — what tells a record that is
83
+ // gone because its run ended from one that only failed to read.
84
+ function has_ended(target: string, started_at: string | undefined): boolean {
85
+ const ended = read_ended(target)
86
+
87
+ return ended !== undefined && ended.started_at === started_at
88
+ }
89
+
90
+ const run_carry_ended = { ended_path, has_ended, read_ended, record_ended }
83
91
 
84
92
  export { run_carry_ended }
85
93
  export type { EndedRun }
@@ -102,6 +102,9 @@ interface RunCarry {
102
102
  // The owner conversation's transcript, which outlives a restart of its process
103
103
  // (`run-carry-conversation.ts`). Optional for the same reason as the pid.
104
104
  owner_transcript?: string | undefined
105
+ // The owner is a session the run launched headless (`run-headless.ts`), so nobody can end the run
106
+ // from it. Kept here, beside the owner it describes, so it outlives the supervisor's own record.
107
+ is_owner_headless?: boolean | undefined
105
108
  // Set by `--cut`, and by nothing else. A crash never reaches `--cut`, which is what makes a
106
109
  // declared cut the only standing record carried without a person deciding.
107
110
  is_handed_off?: boolean | undefined
@@ -138,6 +141,7 @@ interface CarryOwner {
138
141
  pid?: number | undefined
139
142
  start?: string | undefined
140
143
  transcript?: string | undefined
144
+ is_headless?: boolean | undefined
141
145
  }
142
146
 
143
147
  // **`resume` is the only one of the four that establishes anything.** The other three are refusals,
@@ -199,6 +203,7 @@ const run_carry_schema = z.object({
199
203
  owner_pid: z.number().optional(),
200
204
  owner_start: z.string().optional(),
201
205
  owner_transcript: z.string().optional(),
206
+ is_owner_headless: z.boolean().optional(),
202
207
  is_handed_off: z.boolean().optional(),
203
208
  done: z.array(z.number()).optional(),
204
209
  retrospective: z.boolean().optional(),
@@ -265,6 +270,7 @@ function fresh_carry(invocation: string, owner: CarryOwner, now: Date): RunCarry
265
270
  owner_pid: owner.pid,
266
271
  owner_start: owner.start,
267
272
  owner_transcript: owner.transcript,
273
+ is_owner_headless: owner.is_headless,
268
274
  }
269
275
  }
270
276
 
@@ -297,7 +303,7 @@ function adopt_carry(
297
303
  owner: CarryOwner = NO_OWNER,
298
304
  ): RunCarry | undefined {
299
305
  // The run's own fields carry across untouched — the counters, the outage fold timestamp, `done` so the successor does not re-run what the cut session finished,
300
- // and `retrospective` so a run resumed after a cut does not repeat its retrospective. Only the three
306
+ // and `retrospective` so a run resumed after a cut does not repeat its retrospective. Only the
301
307
  // ownership fields are overridden: the new owner replaces the old — a caller that declared none
302
308
  // writes `undefined`, so the previous owner cannot survive — and the declared hand-off is spent.
303
309
  const next: RunCarry = {
@@ -305,6 +311,7 @@ function adopt_carry(
305
311
  owner_pid: owner.pid,
306
312
  owner_start: owner.start,
307
313
  owner_transcript: owner.transcript,
314
+ is_owner_headless: owner.is_headless,
308
315
  is_handed_off: false,
309
316
  }
310
317
 
@@ -336,6 +343,21 @@ function is_foreign_live_owner(carry: RunCarry, owner: CarryOwner): boolean {
336
343
  return is_owner_live(carry) && !is_owned_by(carry, owner)
337
344
  }
338
345
 
346
+ // Whether a process other than the caller still holds the record. **By process alone, never by
347
+ // conversation**: the wake supervisor inherits the environment of the session that started it, so a
348
+ // conversation match would read that live session's record as the supervisor's own. The driver adopts
349
+ // the record for the supervisor's process (`run-wake-driver.ts` → `claim_record`), so without this a
350
+ // supervisor reads itself as a session at work.
351
+ function is_held_by_another(
352
+ carry: RunCarry,
353
+ is_live: (carry: RunCarry) => boolean = is_owner_live,
354
+ ): boolean {
355
+ const caller = owner_of(process.pid)
356
+ const is_own = carry.owner_pid === caller.pid && carry.owner_start === caller.start
357
+
358
+ return !is_own && is_live(carry)
359
+ }
360
+
339
361
  function is_handed_off_to(carry: RunCarry, invocation: string): boolean {
340
362
  return carry.is_handed_off === true && carry.invocation === invocation
341
363
  }
@@ -504,6 +526,7 @@ const run_carry = {
504
526
  fresh_carry,
505
527
  is_count_refused,
506
528
  is_foreign_live_owner,
529
+ is_held_by_another,
507
530
  is_owner_live,
508
531
  lost_message,
509
532
  nothing_to_resume_message,
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { issue_number_shape } from '#scripts/issue/issue-number-shape'
4
+ import { issue_watch } from '#scripts/issue/issue-watch'
4
5
  import { session_cite } from '#scripts/issue/session-cite'
5
6
  import { josh_command } from '#scripts/josh/josh-run'
6
7
  import { lane_child_marker } from '#scripts/lane/lane-child-marker'
@@ -254,6 +255,10 @@ async function proceed(issue_number: string, hold: string, cost: string): Promis
254
255
  await run_label.mark(issue_number)
255
256
  const reads = await gather_reads(issue_number)
256
257
 
258
+ // The issue the report prints is the one the run has read, so it is the baseline a person's later
259
+ // update is measured against (`issue-watch.ts`).
260
+ await issue_watch.record_baseline_here(issue_number)
261
+
257
262
  return emit({ issue_number, hold, cost, verdict: reads.verdict, report: reads.report })
258
263
  }
259
264
 
@@ -0,0 +1,54 @@
1
+ import { issue_cite } from '#scripts/issue/issue-cite'
2
+
3
+ // The words of a `pr-checks` event (joshuafolkken/kit#3770): `josh followup` writes them and `run:board`
4
+ // reads them back, so the one spelling lives here. A line reads
5
+ // `#3754 PR 3765 pass 6 pending 2 fail 0 coderabbit running` — the issue first, as every event names
6
+ // it, so the stream's readers key it to the child as they key any other.
7
+
8
+ const CODERABBIT_STATES = ['running', 'done', 'rate-limited', 'failed', 'none'] as const
9
+
10
+ type CodeRabbitState = (typeof CODERABBIT_STATES)[number]
11
+
12
+ interface PrChecks {
13
+ pr: number
14
+ pass: number
15
+ pending: number
16
+ fail: number
17
+ coderabbit: CodeRabbitState
18
+ }
19
+
20
+ const CHECKS_PATTERN =
21
+ /^#\d+ PR (?<pr>\d+) pass (?<pass>\d+) pending (?<pending>\d+) fail (?<fail>\d+) coderabbit (?<coderabbit>\S+)$/u
22
+
23
+ function text_of(issue: string, checks: PrChecks): string {
24
+ const { pr, pass, pending, fail, coderabbit } = checks
25
+
26
+ return `${issue_cite.plain(issue)} PR ${String(pr)} pass ${String(pass)} pending ${String(pending)} fail ${String(fail)} coderabbit ${coderabbit}`
27
+ }
28
+
29
+ const STATE_NAMES: ReadonlySet<string> = new Set(CODERABBIT_STATES)
30
+
31
+ function is_coderabbit_state(value = ''): value is CodeRabbitState {
32
+ return STATE_NAMES.has(value)
33
+ }
34
+
35
+ // The checks a line carries, `undefined` for a line written in some other shape.
36
+ function checks_of(text: string): PrChecks | undefined {
37
+ const groups = CHECKS_PATTERN.exec(text)?.groups
38
+ const coderabbit = groups?.['coderabbit']
39
+
40
+ if (groups === undefined || !is_coderabbit_state(coderabbit)) return undefined
41
+
42
+ return {
43
+ pr: Number(groups['pr']),
44
+ pass: Number(groups['pass']),
45
+ pending: Number(groups['pending']),
46
+ fail: Number(groups['fail']),
47
+ coderabbit,
48
+ }
49
+ }
50
+
51
+ const run_event_checks = { checks_of, text_of }
52
+
53
+ export { run_event_checks }
54
+ export type { CodeRabbitState, PrChecks }
@@ -28,6 +28,7 @@ const KIND_LABELS: Readonly<Record<EventKind, KindLabel>> = {
28
28
  outage: { ja: '障害', en: 'outage' },
29
29
  cut: { ja: 'セッション交代', en: 'session cut' },
30
30
  resume: { ja: '実装を再開', en: 'implementation resumed' },
31
+ restart: { ja: '止めて出し直し', en: 'restarted' },
31
32
  drain: { ja: 'バックログが空', en: 'backlog drained' },
32
33
  stop: { ja: '停止', en: 'stopped' },
33
34
  'pr-opened': { ja: 'PR 作成', en: 'PR opened' },
@@ -44,6 +45,8 @@ const KIND_LABELS: Readonly<Record<EventKind, KindLabel>> = {
44
45
  'lane-phase': { ja: 'レーンの段階', en: 'lane phase' },
45
46
  add: { ja: '割り込み', en: 'added' },
46
47
  'lane-limit': { ja: 'レーン上限', en: 'lane limit' },
48
+ setup: { ja: '準備', en: 'setup' },
49
+ 'pr-checks': { ja: 'PR のチェック', en: 'PR checks' },
47
50
  }
48
51
 
49
52
  const LABELS_BY_KIND: ReadonlyMap<string, KindLabel> = new Map(Object.entries(KIND_LABELS))
@@ -93,6 +93,7 @@ const KIND = run_event_stream.EVENT_KIND
93
93
  // so a lane child never reads one as its own.
94
94
  const BATCH_RECORD_KINDS: ReadonlySet<string> = new Set([
95
95
  KIND.CHILD_LAUNCH,
96
+ KIND.RESTART,
96
97
  KIND.PARK,
97
98
  KIND.SPLIT,
98
99
  KIND.RETROSPECTIVE,
@@ -0,0 +1,91 @@
1
+ import { issue_cite } from '#scripts/issue/issue-cite'
2
+ import { error_text } from '#scripts/lib/error-message'
3
+ import { run_event_stream } from './run-event-stream'
4
+ import { run_event_stream_emit } from './run-event-stream-emit'
5
+
6
+ // The stages a run passes between `josh backlogrun` and its first child, and those of a later hand-back
7
+ // to a session, each written to the stream as one `setup` event by the step that enters it. **The text
8
+ // is the stage's own words**: `run:board` draws it as written, so the message a person reads and the
9
+ // `💓` beside it come from one event and cannot disagree. Only the stages of a lane's launch — opening
10
+ // it, installing into it, dispatching its child — name an issue, which is how a reader tells them from
11
+ // the stages before them.
12
+
13
+ const STAGE = {
14
+ // `josh backlogrun` started the parent session, which is reading its procedure.
15
+ STARTING: 'starting session',
16
+ // `run:carry --begin` claimed the run, and the parent is planning it.
17
+ PLANNING: 'planning',
18
+ // The supervisor took the run over from the parent and is asking for the first offer.
19
+ HANDING_OFF: 'handing off',
20
+ // The driver answered something only a session can act on — an untriaged child, an epic left to a
21
+ // person — and the supervisor is waking one. The next hand-off replaces it.
22
+ JUDGING: 'waiting on judgment',
23
+ // The supervisor started that session, which is now acting on the driver's answer. It is detached, so
24
+ // its end is the supervisor's next hand-off, which replaces this.
25
+ SESSION: 'judgment session running',
26
+ // The driver's launch of a lane returned without a child: no lane was free to open, or a step after
27
+ // the open failed. It names no issue, so the lane's own stage ends here whatever the driver does next.
28
+ NO_CHILD: 'no child launched',
29
+ } as const
30
+
31
+ const OPENING = 'opening lane'
32
+ const INSTALLING = 'installing dependencies'
33
+ const DISPATCHING = 'dispatching'
34
+ // The stages of one lane's launch, in the order a lane passes them.
35
+ const LANE_STAGE = { OPENING, INSTALLING, DISPATCHING } as const
36
+
37
+ // The driver is about to open a lane for `issue` — for every child, not only the first.
38
+ function opening_lane(issue: string): string {
39
+ return `${OPENING} ${issue_cite.plain(issue)}`
40
+ }
41
+
42
+ // The lane's work tree exists and its dependencies are about to be installed into it.
43
+ function installing(issue: string): string {
44
+ return `${INSTALLING} ${issue_cite.plain(issue)}`
45
+ }
46
+
47
+ // The lane is ready and its child is about to be dispatched; the child's own `child-launch` follows.
48
+ function dispatching(issue: string): string {
49
+ return `${DISPATCHING} ${issue_cite.plain(issue)}`
50
+ }
51
+
52
+ // Best-effort, as every append to this stream is: a stage that could not be written is a board that
53
+ // says less, never a run that failed.
54
+ async function emit(text: string): Promise<void> {
55
+ await run_event_stream_emit.emit(run_event_stream.EVENT_KIND.SETUP, text)
56
+ }
57
+
58
+ // The same append to a stream the caller resolves, dated the moment it hands over.
59
+ function append_stage(resolve_target: () => string, text: string, at: Date): void {
60
+ try {
61
+ const { SETUP } = run_event_stream.EVENT_KIND
62
+
63
+ run_event_stream.append(resolve_target(), SETUP, text, at.toISOString())
64
+ } catch (error) {
65
+ error_text.trace_swallowed('run_event_setup.append_stage', error)
66
+ }
67
+ }
68
+
69
+ // For a caller that already holds the repository's common git directory and the moment it wants the
70
+ // stage dated — the launcher, whose record and first stage carry one time.
71
+ function emit_in(repository: string, text: string, at: Date): void {
72
+ append_stage(() => run_event_stream.target_of(repository), text, at)
73
+ }
74
+
75
+ // For a caller that already holds the stream's own path — the supervisor, which resolved it once.
76
+ function emit_to(target: string, text: string, at: Date): void {
77
+ append_stage(() => target, text, at)
78
+ }
79
+
80
+ const run_event_setup = {
81
+ LANE_STAGE,
82
+ STAGE,
83
+ dispatching,
84
+ emit,
85
+ emit_in,
86
+ emit_to,
87
+ installing,
88
+ opening_lane,
89
+ }
90
+
91
+ export { run_event_setup }
@@ -60,6 +60,10 @@ const EVENT_KIND = {
60
60
  // resume clears the cut record, so without this the `cut` would stay the newest position and
61
61
  // `run:step` would keep answering the resume command it had just run.
62
62
  RESUME: 'resume',
63
+ // A person stopped a running lane child and re-dispatched its issue in a fresh session
64
+ // (`lane:restart`). It follows the fresh dispatch's `CHILD_LAUNCH`, so it is a launch to the parent and
65
+ // the board, and — like that launch — a batch record a lane child never reads as its own position.
66
+ RESTART: 'restart',
63
67
  // The backlog emptied while nothing of the run's own was in flight — the drain. It marks the position
64
68
  // at which the end-of-run retrospective is owed, *before* the idle watch opens, so `run:step` fires
65
69
  // the retrospective at the drain rather than after the watch runs its course. It is not itself a
@@ -83,7 +87,8 @@ const EVENT_KIND = {
83
87
  // stalled is pointed straight at dispatching the work rather than left waiting.
84
88
  STALL: 'stall',
85
89
  // The run's own driver is gone: the cut handed the budget off, the owner has died, and no supervisor
86
- // is watching. Emitted once per strand episode by the stop-time detector,
90
+ // is watching. Emitted once per strand episode by the stop-time detector — or by the `run:wake`
91
+ // supervisor as it fails, leaves that state behind and has warned of it —
87
92
  // exactly as `STALL` is — the newest-event dedup keeps a run polled every stop from notifying more
88
93
  // than once, and a terminal reader following the stream sees the line. The strand is the step before
89
94
  // the stall: a stalled run still has a driver that could dispatch, a stranded one has none.
@@ -124,6 +129,16 @@ const EVENT_KIND = {
124
129
  // arrival probe reads it as a wake: the lanes a raise frees are new room for a pool its baseline
125
130
  // already holds. A trace: it says the run gained room, not where the run is.
126
131
  LANE_LIMIT: 'lane-limit',
132
+ // One stage of the run's start — the session starting, the plan, the hand-off to the supervisor, a
133
+ // lane opening (`run-event-setup.ts`). `run:board` draws it while the run stands at it, so the
134
+ // minutes before one read as work rather than as a run that has stopped. A trace: it says the run is
135
+ // getting ready, not where the run is.
136
+ SETUP: 'setup',
137
+ // A child's pull request checks as `josh followup` last polled them — the CI counts and CodeRabbit's
138
+ // state (`run-event-checks.ts`), written only when they change, so `run:board` draws a child waiting
139
+ // in followup without asking GitHub (joshuafolkken/kit#3770). A trace: it says how the checks stand,
140
+ // not where the run is.
141
+ PR_CHECKS: 'pr-checks',
127
142
  } as const
128
143
 
129
144
  type EventKind = (typeof EVENT_KIND)[keyof typeof EVENT_KIND]
@@ -141,13 +156,21 @@ const TRACE_KINDS: ReadonlySet<string> = new Set([
141
156
  EVENT_KIND.LANE_PHASE,
142
157
  EVENT_KIND.ADD,
143
158
  EVENT_KIND.LANE_LIMIT,
159
+ EVENT_KIND.SETUP,
160
+ EVENT_KIND.PR_CHECKS,
144
161
  ])
145
162
 
146
163
  // The trace kinds that say only that the run was alive, so the bound rolls them off first. A `filed` or
147
164
  // `note` line is a trace to the position readers but is the board's record of what the run found, so
148
165
  // it rolls off only with the positions. A ship-stage `start` is disposable but goes last of them:
149
- // `run:board` draws from that line.
150
- const DISPOSABLE_KINDS: ReadonlySet<string> = new Set([EVENT_KIND.SHIP_STAGE, EVENT_KIND.HEARTBEAT])
166
+ // `run:board` draws from that line. Only the newest `setup` line is the stage the run stands at,
167
+ // so every one but the newest goes; likewise only each child's newest `pr-checks` is drawn.
168
+ const DISPOSABLE_KINDS: ReadonlySet<string> = new Set([
169
+ EVENT_KIND.SHIP_STAGE,
170
+ EVENT_KIND.HEARTBEAT,
171
+ EVENT_KIND.SETUP,
172
+ EVENT_KIND.PR_CHECKS,
173
+ ])
151
174
 
152
175
  const event_schema = z.object({
153
176
  pos: z.number(),
@@ -217,17 +240,18 @@ function last_position(events: ReadonlyArray<RunEvent>): number {
217
240
  return events[events.length - POSITION_INCREMENT]?.pos ?? FIRST_POSITION
218
241
  }
219
242
 
220
- // The issue a ship-stage names — `#<N> <stage> <phase>` carries it first.
243
+ // The issue a ship-stage or a `pr-checks` line names — `#<N> …` carries it first.
221
244
  function issue_of(event: RunEvent): string {
222
245
  return event.text.split(WORD_SEPARATOR)[FIRST_POSITION] ?? ''
223
246
  }
224
247
 
225
- // Each issue's newest ship-stage, the one the board reads its furthest phase from.
226
- function newest_stages(events: ReadonlyArray<RunEvent>): ReadonlySet<RunEvent> {
248
+ // Each issue's newest line of one kind: the ship-stage the board reads its furthest phase from, and the
249
+ // `pr-checks` it draws a followup row's end from.
250
+ function newest_by_issue(events: ReadonlyArray<RunEvent>, kind: string): ReadonlySet<RunEvent> {
227
251
  const newest = new Map<string, RunEvent>()
228
252
 
229
253
  for (const event of events) {
230
- if (event.kind === EVENT_KIND.SHIP_STAGE) newest.set(issue_of(event), event)
254
+ if (event.kind === kind) newest.set(issue_of(event), event)
231
255
  }
232
256
 
233
257
  return new Set(newest.values())
@@ -241,10 +265,22 @@ function is_stage_start(event: RunEvent): boolean {
241
265
  return event.text.split(WORD_SEPARATOR)[STAGE_PHASE_INDEX] === run_ship_stage.PHASE.START
242
266
  }
243
267
 
268
+ // The disposable lines the bound never drops: each issue's newest ship-stage and `pr-checks` and the
269
+ // run's newest `setup` stage, the ones `run:board` draws from now.
270
+ function drawn_lines(events: ReadonlyArray<RunEvent>): ReadonlySet<RunEvent> {
271
+ const setup = events.findLast((event) => event.kind === EVENT_KIND.SETUP)
272
+
273
+ return new Set([
274
+ ...newest_by_issue(events, EVENT_KIND.SHIP_STAGE),
275
+ ...newest_by_issue(events, EVENT_KIND.PR_CHECKS),
276
+ ...(setup === undefined ? [] : [setup]),
277
+ ])
278
+ }
279
+
244
280
  // The lines the bound may drop, in the order it drops them: the ones that draw nothing first, then the
245
281
  // ones `run:board` draws from, each group oldest first.
246
282
  function droppable(events: ReadonlyArray<RunEvent>): ReadonlyArray<RunEvent> {
247
- const kept = newest_stages(events)
283
+ const kept = drawn_lines(events)
248
284
  const disposable = events.filter((event) => DISPOSABLE_KINDS.has(event.kind) && !kept.has(event))
249
285
 
250
286
  return [
@@ -261,7 +297,8 @@ function droppable(events: ReadonlyArray<RunEvent>): ReadonlyArray<RunEvent> {
261
297
  //
262
298
  // **Each issue's newest ship-stage is not disposable**: it is the only record `run:board` draws a lane's
263
299
  // review-and-later phase from, and on a stream full of positions it would otherwise roll off in its own
264
- // append.
300
+ // append. **The newest `setup` stage is kept for the same reason**: it is the stage `run:board` draws
301
+ // while the run stands at it, and a run that starts on a full stream would otherwise show none.
265
302
  //
266
303
  // **A ship-stage `start` outlasts the other disposable lines**: each one is a phase on the lane's track,
267
304
  // so it is kept past the `done` lines and the heartbeats.