@joshuafolkken/kit 1.1387.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 (236) 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-IYVOLN6A.js → chunk-2G5F2YCD.js} +3 -3
  14. package/dist/hooks/{chunk-2YSW3CXH.js → chunk-3LE6VLSR.js} +17 -6
  15. package/dist/hooks/{chunk-J3KSS7II.js → chunk-4BHUPFKF.js} +1 -1
  16. package/dist/hooks/{chunk-EAQEGME7.js → chunk-5SJSSCJG.js} +1 -1
  17. package/dist/hooks/{chunk-FZ6FUSBM.js → chunk-723HKZO5.js} +1 -1
  18. package/dist/hooks/{chunk-LMWBRJIP.js → chunk-7KMN5QB5.js} +3 -3
  19. package/dist/hooks/{chunk-NEC4AT4Z.js → chunk-DGT54IH5.js} +4 -0
  20. package/dist/hooks/{chunk-STDSEPWU.js → chunk-EMB55BDF.js} +4 -4
  21. package/dist/hooks/{chunk-JBBMAPC4.js → chunk-IX6TTTI6.js} +4 -0
  22. package/dist/hooks/{chunk-GJ7JPIHN.js → chunk-JXQBK323.js} +2674 -187
  23. package/dist/hooks/{chunk-DCY2GWHC.js → chunk-MSYSUSY6.js} +5 -5
  24. package/dist/hooks/{chunk-EBSHN25W.js → chunk-MU4LJFIV.js} +4 -4
  25. package/dist/hooks/{chunk-WNFJLBXI.js → chunk-QL6QTPWN.js} +3 -3
  26. package/dist/hooks/{chunk-SL4GQ2XP.js → chunk-XER6SSIB.js} +91 -18
  27. package/dist/hooks/{chunk-U32VSRNG.js → chunk-YW452ZAX.js} +89 -34
  28. package/dist/hooks/{chunk-GNDJ7NJ7.js → chunk-YZXYOAZ3.js} +202 -55
  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 +288 -229
  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 +69 -16
  36. package/dist/hooks/stop-guard.js +13 -38
  37. package/dist/josh.js +62 -9
  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-epic.ts +24 -8
  45. package/scripts/backlog/backlog-drive-launch.ts +3 -0
  46. package/scripts/backlog/backlog-drive-named-offer.ts +7 -2
  47. package/scripts/backlog/backlog-drive-named.ts +4 -2
  48. package/scripts/backlog/backlog-drive-unread.ts +56 -0
  49. package/scripts/backlog/backlog-drive.ts +16 -8
  50. package/scripts/backlog/backlog-overlap.ts +22 -5
  51. package/scripts/backlog/backlog-ready.ts +1 -3
  52. package/scripts/backlog/backlogrun-args.ts +13 -2
  53. package/scripts/backlog/backlogrun-cli.ts +91 -19
  54. package/scripts/backlog/backlogrun-launch.ts +24 -0
  55. package/scripts/backlog/backlogrun-replace.ts +166 -0
  56. package/scripts/backlog/backlogrun-stop.ts +178 -0
  57. package/scripts/behavior/behavior-cli.ts +6 -1
  58. package/scripts/doctor/doctor-io.ts +60 -9
  59. package/scripts/doctor/doctor.ts +20 -1
  60. package/scripts/document/document-byte-budget.ts +3 -3
  61. package/scripts/epic/epic-bundle-cli.ts +8 -8
  62. package/scripts/epic/epic-bundle-referenced.ts +2 -5
  63. package/scripts/epic/epic-bundle.ts +39 -4
  64. package/scripts/exports/unused-members-cli.ts +2 -1
  65. package/scripts/followup/followup-checks-event.ts +82 -0
  66. package/scripts/followup/git-followup-finish.ts +16 -2
  67. package/scripts/followup/git-followup-workflow.ts +5 -2
  68. package/scripts/followup/git-pr-followup-checks.ts +18 -4
  69. package/scripts/followup/git-pr-followup-wrapup.ts +5 -0
  70. package/scripts/followup/git-pr-followup.ts +4 -1
  71. package/scripts/fresh-machine/fresh-machine-cli.ts +204 -0
  72. package/scripts/fresh-machine/fresh-machine-docker.ts +65 -0
  73. package/scripts/fresh-machine/fresh-machine-image.ts +62 -0
  74. package/scripts/fresh-machine/fresh-machine-log.ts +61 -0
  75. package/scripts/fresh-machine/fresh-machine-patterns.ts +130 -0
  76. package/scripts/fresh-machine/fresh-machine-run.ts +215 -0
  77. package/scripts/fresh-machine/fresh-machine-script.ts +45 -0
  78. package/scripts/fresh-machine/fresh-machine-spec.ts +57 -0
  79. package/scripts/fresh-machine/specs/kit-test-backlogrun-python.json +34 -0
  80. package/scripts/fresh-machine/specs/kit-test-backlogrun.json +31 -0
  81. package/scripts/gate/gate-report.ts +70 -15
  82. package/scripts/gate/project-checks.ts +13 -2
  83. package/scripts/gate/scoped-green.ts +2 -1
  84. package/scripts/gate/verification-gate.ts +4 -3
  85. package/scripts/gh/gh-author-association.ts +14 -0
  86. package/scripts/gh/git-gh-conditional.ts +66 -0
  87. package/scripts/gh/git-gh-exec.ts +1 -1
  88. package/scripts/gh/git-gh-issue-read.ts +6 -3
  89. package/scripts/gh/git-gh-issue-write.ts +5 -1
  90. package/scripts/gh/git-gh-issue.ts +4 -1
  91. package/scripts/gh/git-pr-checks-eval.ts +13 -0
  92. package/scripts/gh/git-pr-checks-parse.ts +8 -1
  93. package/scripts/gh/git-pr-checks-watch.ts +11 -4
  94. package/scripts/gh/git-pr-checks.ts +36 -2
  95. package/scripts/git/git-command.ts +16 -4
  96. package/scripts/git/git-schemas.ts +1 -0
  97. package/scripts/git/git-spawn-sync.ts +1 -1
  98. package/scripts/hooks/pretool-guard.ts +3 -1
  99. package/scripts/init/hook-command-rewrite.ts +31 -0
  100. package/scripts/init/init-actions.ts +4 -1
  101. package/scripts/init/init-ai-copy.ts +33 -10
  102. package/scripts/init/init-basic.ts +8 -3
  103. package/scripts/init/init-logic-toolchain.ts +76 -0
  104. package/scripts/init/init-logic.ts +2 -0
  105. package/scripts/init/init.ts +5 -23
  106. package/scripts/init/project-profile.ts +36 -7
  107. package/scripts/init/start-exec.ts +27 -1
  108. package/scripts/init/start-plan.ts +15 -0
  109. package/scripts/init/start-steps.ts +46 -0
  110. package/scripts/init/start.ts +1 -0
  111. package/scripts/init/workflow-mark.ts +35 -0
  112. package/scripts/issue/issue-ack-cli.ts +84 -0
  113. package/scripts/issue/issue-labels.ts +40 -8
  114. package/scripts/issue/issue-state-cli.ts +14 -4
  115. package/scripts/issue/issue-watch-diff.ts +152 -0
  116. package/scripts/issue/issue-watch-gate.ts +81 -0
  117. package/scripts/issue/issue-watch-hook.ts +54 -0
  118. package/scripts/issue/issue-watch.ts +240 -0
  119. package/scripts/issue/measure-rerun-cli.ts +3 -4
  120. package/scripts/josh/josh-command-map.ts +4 -0
  121. package/scripts/josh/josh-commands-backlog.ts +2 -2
  122. package/scripts/josh/josh-commands-development.ts +1 -1
  123. package/scripts/josh/josh-commands-issue.ts +7 -0
  124. package/scripts/josh/josh-commands-lane.ts +11 -2
  125. package/scripts/josh/josh-commands-project.ts +24 -0
  126. package/scripts/josh/josh-verdict.ts +33 -4
  127. package/scripts/lane/claude-lane-broker.ts +142 -0
  128. package/scripts/lane/lane-await.ts +6 -2
  129. package/scripts/lane/lane-cache.ts +37 -7
  130. package/scripts/lane/lane-capacity.ts +8 -6
  131. package/scripts/lane/lane-child-invocation.ts +1 -0
  132. package/scripts/lane/lane-cli.ts +15 -1
  133. package/scripts/lane/lane-close.ts +15 -6
  134. package/scripts/lane/lane-dispatch.ts +36 -10
  135. package/scripts/lane/lane-environment.ts +1 -1
  136. package/scripts/lane/lane-launch-cli.ts +13 -4
  137. package/scripts/lane/lane-ledger.ts +21 -3
  138. package/scripts/lane/lane-measure-cli.ts +10 -2
  139. package/scripts/lane/lane-open.ts +9 -1
  140. package/scripts/lane/lane-preserve.ts +49 -0
  141. package/scripts/lane/lane-reap.ts +20 -5
  142. package/scripts/lane/lane-registry.ts +6 -0
  143. package/scripts/lane/lane-restart-cli.ts +51 -0
  144. package/scripts/lane/lane-restart.ts +175 -0
  145. package/scripts/lane/{openai-review-broker.ts → lane-session-broker.ts} +106 -28
  146. package/scripts/lane/lane-spent.ts +195 -0
  147. package/scripts/lane/lane-stage-stats.ts +27 -3
  148. package/scripts/lane/lane-stop-stats.ts +96 -0
  149. package/scripts/lane/openai-lane-supervisor.ts +2 -2
  150. package/scripts/lane/process-tools.ts +31 -0
  151. package/scripts/lib/buffered-process.ts +36 -2
  152. package/scripts/lib/pnpm-reach.ts +27 -0
  153. package/scripts/lib/status-icons.ts +5 -1
  154. package/scripts/lint/lint-parallel.ts +19 -2
  155. package/scripts/observations/observation-ledger-home.ts +1 -0
  156. package/scripts/review/review-attest-cli.ts +21 -6
  157. package/scripts/review/review-brief-cli.ts +2 -2
  158. package/scripts/review/review-comment-round.ts +114 -0
  159. package/scripts/review/review-record-cli.ts +39 -17
  160. package/scripts/rules/implementation-cut.ts +3 -3
  161. package/scripts/rules/pre-gate-cut.ts +1 -1
  162. package/scripts/run/board/run-board-checks.ts +42 -0
  163. package/scripts/run/board/run-board-cli.ts +2 -0
  164. package/scripts/run/board/run-board-every.ts +3 -1
  165. package/scripts/run/board/run-board-fit.ts +16 -5
  166. package/scripts/run/board/run-board-fresh.ts +64 -0
  167. package/scripts/run/board/run-board-header.ts +129 -53
  168. package/scripts/run/board/run-board-labels.ts +47 -4
  169. package/scripts/run/board/run-board-lane-event.ts +66 -0
  170. package/scripts/run/board/run-board-layout.ts +57 -9
  171. package/scripts/run/board/run-board-now.ts +234 -0
  172. package/scripts/run/board/run-board-operation-read.ts +75 -0
  173. package/scripts/run/board/run-board-operation.ts +150 -0
  174. package/scripts/run/board/run-board-phase.ts +26 -1
  175. package/scripts/run/board/run-board-plan.ts +4 -6
  176. package/scripts/run/board/run-board-read.ts +49 -3
  177. package/scripts/run/board/run-board-reason.ts +124 -0
  178. package/scripts/run/board/run-board-render-checks.ts +55 -0
  179. package/scripts/run/board/run-board-render-legend.ts +12 -3
  180. package/scripts/run/board/run-board-render-slot.ts +2 -2
  181. package/scripts/run/board/run-board-render-tail.ts +128 -0
  182. package/scripts/run/board/run-board-render-thanks.ts +50 -0
  183. package/scripts/run/board/run-board-render-title.ts +58 -0
  184. package/scripts/run/board/run-board-render.ts +96 -80
  185. package/scripts/run/board/run-board-row-yield.ts +32 -0
  186. package/scripts/run/board/run-board-setup.ts +61 -13
  187. package/scripts/run/board/run-board-state.ts +10 -0
  188. package/scripts/run/board/run-board-status.ts +66 -7
  189. package/scripts/run/board/run-board-thanks-csv.ts +71 -0
  190. package/scripts/run/board/run-board-thanks-read.ts +112 -0
  191. package/scripts/run/board/run-board-thanks.ts +53 -0
  192. package/scripts/run/board/run-board-tick.ts +50 -21
  193. package/scripts/run/board/run-board-usual.ts +44 -0
  194. package/scripts/run/carry/run-carry-args.ts +9 -1
  195. package/scripts/run/carry/run-carry-cli.ts +2 -45
  196. package/scripts/run/carry/run-carry-end.ts +73 -0
  197. package/scripts/run/carry/run-carry.ts +8 -1
  198. package/scripts/run/entry/run-entry-cli.ts +5 -0
  199. package/scripts/run/event/run-event-checks.ts +54 -0
  200. package/scripts/run/event/run-event-render.ts +2 -0
  201. package/scripts/run/event/run-event-scope.ts +1 -0
  202. package/scripts/run/event/run-event-setup.ts +55 -11
  203. package/scripts/run/event/run-event-stream.ts +27 -11
  204. package/scripts/run/merge/run-merge-cli.ts +7 -4
  205. package/scripts/run/merge/run-merge-ship.ts +61 -0
  206. package/scripts/run/merge/run-merge-steps.ts +7 -14
  207. package/scripts/run/merge/run-merge-token.ts +16 -2
  208. package/scripts/run/run-headless.ts +1 -0
  209. package/scripts/run/run-step.ts +2 -0
  210. package/scripts/run/ship/run-ship-cli.ts +22 -2
  211. package/scripts/run/ship/run-ship-return.ts +31 -13
  212. package/scripts/run/ship/run-ship-review-steps.ts +17 -4
  213. package/scripts/run/ship/run-ship-stage.ts +1 -0
  214. package/scripts/run/wake/run-wake-await.ts +130 -0
  215. package/scripts/run/wake/run-wake-cli.ts +60 -71
  216. package/scripts/run/wake/run-wake-describe.ts +34 -2
  217. package/scripts/run/wake/run-wake-driver.ts +11 -4
  218. package/scripts/run/wake/run-wake-session.ts +20 -3
  219. package/scripts/run/wake/run-wake-stop.ts +37 -0
  220. package/scripts/run/wake/run-wake-supervise.ts +44 -0
  221. package/scripts/sync/claude-settings-merge.ts +144 -0
  222. package/scripts/sync/sync-ai-files.ts +28 -3
  223. package/scripts/sync/sync.ts +15 -0
  224. package/scripts/test/project-test-script.ts +73 -0
  225. package/scripts/test/skip-marker.ts +14 -1
  226. package/scripts/test/test-related.ts +3 -2
  227. package/scripts/test/test-unit-guard.ts +78 -14
  228. package/scripts/time-runtime/time-reported-failure.ts +11 -3
  229. package/scripts/uninstall/distributed-files.ts +92 -0
  230. package/scripts/uninstall/uninstall-plan.ts +119 -0
  231. package/scripts/uninstall/uninstall-report.ts +61 -0
  232. package/scripts/uninstall/uninstall.ts +206 -0
  233. package/templates/AGENTS.basic.md +4 -0
  234. package/templates/GEMINI.basic.md +6 -0
  235. package/dist/hooks/chunk-6V5OPIUU.js +0 -13
  236. package/scripts/run/board/run-board-track.ts +0 -22
@@ -29,6 +29,24 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
29
29
  reference: ['<dir>', 'automation', ['files', 'git']],
30
30
  is_kit_only: true,
31
31
  },
32
+ 'fresh:run': {
33
+ script: 'scripts/fresh-machine/fresh-machine-cli.ts',
34
+ description:
35
+ 'Run an install scenario in a Docker container of a machine with nothing installed, and keep the log',
36
+ category: 'Project',
37
+ reference: [
38
+ '<pattern> | --spec <file.json> [--package-version <version>] [--log-dir <dir>] [--rebuild] | --list',
39
+ 'maintainer',
40
+ ['files', 'network', 'processes'],
41
+ ],
42
+ },
43
+ 'auth:setup': {
44
+ script: 'scripts/auth/auth-setup-cli.ts',
45
+ description:
46
+ 'Save the long-lived Claude Code token `claude setup-token` prints in the macOS keychain, for josh fresh:run',
47
+ category: 'Project',
48
+ reference: ['[--check]', 'maintainer', ['network', 'processes']],
49
+ },
32
50
  init: {
33
51
  script: 'scripts/init/init.ts',
34
52
  description: 'Initialize config in a new project',
@@ -45,6 +63,12 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
45
63
  category: 'Project',
46
64
  reference: ['', 'developer', ['files']],
47
65
  },
66
+ uninstall: {
67
+ script: 'scripts/uninstall/uninstall.ts',
68
+ description: 'Remove what kit added to a project and list what is left to review by hand',
69
+ category: 'Project',
70
+ reference: [`${PROFILE_ARGUMENTS} [--yes]`, 'developer', ['files', 'processes']],
71
+ },
48
72
  'registry:migrate': {
49
73
  script: 'scripts/registry-migration/migrate.ts',
50
74
  description: 'Migrate a project from GitHub Packages to public npm',
@@ -35,7 +35,7 @@ import { status_icons } from '#scripts/lib/status-icons'
35
35
  // gate's plan line is what says "the gate starts here", and it is single-sourced for the reason the
36
36
  // verdict is: the printer builds it and the detector matches it from one place.
37
37
 
38
- const { FAIL_ICON, PASS_ICON } = status_icons
38
+ const { FAIL_ICON, PASS_ICON, WARN_ICON } = status_icons
39
39
  const GATE_SUBJECT = 'verification gate'
40
40
 
41
41
  // The prefixes are what the detector matches **and** what the printer builds its line from, so a
@@ -43,16 +43,41 @@ const GATE_SUBJECT = 'verification gate'
43
43
  // this module exists to prevent.
44
44
  const GATE_PASSED_PREFIX = `${PASS_ICON} ${GATE_SUBJECT} passed`
45
45
  const GATE_FAILED_PREFIX = `${FAIL_ICON} ${GATE_SUBJECT} failed`
46
+ // **A gate that ran no check has not passed, and it has not failed either.** Every check of a `basic`
47
+ // project with nothing to check exits 0 with a skip notice, which is a legitimate state — the exit code
48
+ // stays 0, or such a project could never commit — but a line reading `passed` made it indistinguishable
49
+ // from a gate that ran every check. It opens with neither icon and neither word, so no reader of the
50
+ // other two prefixes can take it for one of them.
51
+ const GATE_UNVERIFIED_PREFIX = `${WARN_ICON} ${GATE_SUBJECT} not verified`
46
52
 
47
- type JoshVerdict = 'passed' | 'failed'
53
+ type JoshVerdict = 'passed' | 'failed' | 'unverified'
48
54
 
49
55
  // Typed rather than left to inference: the namespace object widens a bare literal to `string`, and a
50
56
  // caller comparing `read_verdict(line)` against it would then be comparing two unrelated types.
51
57
  const PASSED_VERDICT: JoshVerdict = 'passed'
52
58
  const FAILED_VERDICT: JoshVerdict = 'failed'
59
+ const UNVERIFIED_VERDICT: JoshVerdict = 'unverified'
53
60
 
54
- function format_gate_passed(step_count: string, total: string): string {
55
- return `${GATE_PASSED_PREFIX} (${step_count} checks) in ${total}.`
61
+ // How many of a gate's checks ran, and how many passed without running.
62
+ interface GateCounts {
63
+ ran: number
64
+ skipped: number
65
+ }
66
+
67
+ // The split is printed only when there is one. A gate that skipped nothing — kit's own, and an ordinary
68
+ // consumer's — keeps the line it always printed.
69
+ function format_counts(counts: GateCounts): string {
70
+ if (counts.skipped === 0) return `${String(counts.ran)} checks`
71
+
72
+ return `${String(counts.ran)} ran, ${String(counts.skipped)} skipped`
73
+ }
74
+
75
+ function format_gate_passed(counts: GateCounts, total: string): string {
76
+ return `${GATE_PASSED_PREFIX} (${format_counts(counts)}) in ${total}.`
77
+ }
78
+
79
+ function format_gate_unverified(skipped: number, total: string): string {
80
+ return `${GATE_UNVERIFIED_PREFIX} (0 ran, ${String(skipped)} skipped) in ${total}.`
56
81
  }
57
82
 
58
83
  function format_gate_failed(failed_labels: string, total: string): string {
@@ -91,14 +116,18 @@ function read_verdict(line: string): JoshVerdict | undefined {
91
116
 
92
117
  if (trimmed.startsWith(GATE_PASSED_PREFIX)) return PASSED_VERDICT
93
118
 
119
+ if (trimmed.startsWith(GATE_UNVERIFIED_PREFIX)) return UNVERIFIED_VERDICT
120
+
94
121
  return undefined
95
122
  }
96
123
 
97
124
  const josh_verdict = {
98
125
  PASSED_VERDICT,
99
126
  FAILED_VERDICT,
127
+ UNVERIFIED_VERDICT,
100
128
  format_gate_passed,
101
129
  format_gate_failed,
130
+ format_gate_unverified,
102
131
  GATE_OPENING_MARK,
103
132
  GATE_OPENING_PREFIX,
104
133
  is_gate_opening,
@@ -0,0 +1,142 @@
1
+ import { error_text } from '#scripts/lib/error-message'
2
+ import { lane_registry, type LaneInfo } from './lane-registry'
3
+ import { lane_relaunch } from './lane-relaunch'
4
+ import { lane_session_broker } from './lane-session-broker'
5
+
6
+ // The lane broker for every open Claude lane, run inside the `run:wake` supervisor for as long as the
7
+ // supervisor runs, so nobody has to start or stop it.
8
+ //
9
+ // **It lives in the supervisor because the supervisor is what holds the token.** `josh backlogrun`
10
+ // starts it from the terminal (joshuafolkken/kit#3804), so every session it starts can sign in with a
11
+ // token-only credential. A lane child's detached `josh ship` cannot: it descends from the child's Bash
12
+ // tool, which Claude Code starts without the token (joshuafolkken/kit#3817). Its reviewer and its relaunch
13
+ // are asked of this broker instead (`lane-session-broker.ts`).
14
+ //
15
+ // **Not inside one `backlog:drive`**: the supervisor runs a drive per pass, and a lane's ship reaches its
16
+ // review whenever it does — while the drive has handed back to a session too, when no drive runs.
17
+ //
18
+ // An OpenAI lane is left to its own supervisor, which serves the same broker for that lane's lifetime;
19
+ // two servers for one lane would overwrite each other's response.
20
+
21
+ const SCAN_INTERVAL_MS = 5000
22
+
23
+ type ListLanes = () => Promise<ReadonlyArray<LaneInfo>>
24
+
25
+ interface Served {
26
+ stop: () => void
27
+ done: Promise<void>
28
+ }
29
+
30
+ function is_served(lane: LaneInfo): boolean {
31
+ return !lane.is_stranded && !lane_relaunch.is_openai_lane(lane)
32
+ }
33
+
34
+ // One lane's server, held open until `stop` — a lane that could not be served is traced, never thrown,
35
+ // so it costs the supervisor nothing but that lane's broker.
36
+ async function hold_server(lane: LaneInfo, held: Promise<void>): Promise<void> {
37
+ try {
38
+ await lane_session_broker.with_server(lane, async () => {
39
+ await held
40
+ })
41
+ } catch (error) {
42
+ error_text.trace_swallowed('claude_lane_broker.hold_server', error)
43
+ }
44
+ }
45
+
46
+ function serve_lane(lane: LaneInfo): Served {
47
+ const release = { stop: (): void => undefined }
48
+ const held = new Promise<void>((resolve) => {
49
+ release.stop = resolve
50
+ })
51
+
52
+ return { stop: release.stop, done: hold_server(lane, held) }
53
+ }
54
+
55
+ // **A closed lane's entry stays until its server has finished.** A server still answering a reviewer
56
+ // keeps writing its response, so a server opened beside it for a re-listed lane would have the two
57
+ // overwrite each other — the lane is reopened only by the scan after the old one is gone.
58
+ async function forget_when_done(
59
+ served: Map<string, Served>,
60
+ issue: string,
61
+ entry: Served,
62
+ ): Promise<void> {
63
+ await entry.done
64
+ if (served.get(issue) === entry) served.delete(issue)
65
+ }
66
+
67
+ function close_gone(served: Map<string, Served>, open: ReadonlySet<string>): void {
68
+ for (const [issue, entry] of served) {
69
+ if (open.has(issue)) {
70
+ continue
71
+ }
72
+
73
+ entry.stop()
74
+ void forget_when_done(served, issue, entry)
75
+ }
76
+ }
77
+
78
+ function open_new(served: Map<string, Served>, lanes: ReadonlyArray<LaneInfo>): void {
79
+ for (const lane of lanes) {
80
+ if (!served.has(lane.issue)) served.set(lane.issue, serve_lane(lane))
81
+ }
82
+ }
83
+
84
+ async function scan(served: Map<string, Served>, list: ListLanes): Promise<void> {
85
+ try {
86
+ const listed = await list()
87
+ const lanes = listed.filter((lane) => is_served(lane))
88
+
89
+ close_gone(served, new Set(lanes.map((lane) => lane.issue)))
90
+ open_new(served, lanes)
91
+ } catch (error) {
92
+ error_text.trace_swallowed('claude_lane_broker.scan', error)
93
+ }
94
+ }
95
+
96
+ // Each scan starts only once the previous one has finished, so two never race over the same map.
97
+ async function scan_after(
98
+ previous: Promise<void>,
99
+ served: Map<string, Served>,
100
+ list: ListLanes,
101
+ ): Promise<void> {
102
+ await previous
103
+ await scan(served, list)
104
+ }
105
+
106
+ // Starts serving now and on every interval; the returned stop closes every server and waits for them.
107
+ function start(list: ListLanes = lane_registry.list_lanes): () => Promise<void> {
108
+ const served = new Map<string, Served>()
109
+ const state = { scanning: scan(served, list) }
110
+ const timer = setInterval(() => {
111
+ state.scanning = scan_after(state.scanning, served, list)
112
+ }, SCAN_INTERVAL_MS).unref()
113
+
114
+ return async () => {
115
+ clearInterval(timer)
116
+ await state.scanning
117
+ close_gone(served, new Set())
118
+ await Promise.all(
119
+ [...served.values()].map(async (entry) => {
120
+ await entry.done
121
+ }),
122
+ )
123
+ }
124
+ }
125
+
126
+ // Serves every open Claude lane for exactly as long as `run` runs.
127
+ async function serve_during<T>(
128
+ run: () => Promise<T>,
129
+ list: ListLanes = lane_registry.list_lanes,
130
+ ): Promise<T> {
131
+ const stop = start(list)
132
+
133
+ try {
134
+ return await run()
135
+ } finally {
136
+ await stop()
137
+ }
138
+ }
139
+
140
+ const claude_lane_broker = { SCAN_INTERVAL_MS, serve_during, start }
141
+
142
+ export { claude_lane_broker }
@@ -12,6 +12,7 @@ import {
12
12
  } from '#scripts/run/run-liveness'
13
13
  import { lane_child_invocation } from './lane-child-invocation'
14
14
  import { lane_handoff } from './lane-handoff'
15
+ import { process_tools } from './process-tools'
15
16
 
16
17
  // Detached lane children are not harness-tracked processes, so their completion fires no
17
18
  // re-invocation event in the parent's session. Without a blocking wait the parent notices only
@@ -30,6 +31,7 @@ const NEVER_APPEARED_TIMEOUT_MS = 600_000
30
31
  const MS_PER_SECOND = 1000
31
32
  const PROCESS_FOUND = 0
32
33
  const PROCESS_NOT_FOUND = 1
34
+ const PGREP_COMMAND = 'pgrep'
33
35
 
34
36
  // `is_settled` is read once, when the wait starts: a child that ended between two `lane:await` calls
35
37
  // never appears in the second one, and only its settled issue tells it apart from one not yet
@@ -77,14 +79,16 @@ interface RunConfig extends CheckConfig {
77
79
 
78
80
  // Three answers, not two: only pgrep's own "no match" exit is `none`. A pgrep that failed or hit its
79
81
  // timeout never looked, and `run:liveness` answers `undetermined` for it rather than booking a live
80
- // child as stopped.
82
+ // child as stopped. A pgrep that is not installed could never look, so it is refused by name.
81
83
  function process_trace_default(issue: string): ProcessTrace {
82
84
  const pattern = lane_child_invocation.process_pattern(issue)
83
- const result = spawnSync('pgrep', ['-f', pattern], {
85
+ const result = spawnSync(PGREP_COMMAND, ['-f', pattern], {
84
86
  encoding: 'utf8',
85
87
  timeout: PROBE_TIMEOUT_MS,
86
88
  })
87
89
 
90
+ process_tools.throw_if_missing(PGREP_COMMAND, result.error)
91
+
88
92
  if (result.status === PROCESS_FOUND) return PROCESS_ALIVE
89
93
  if (lane_handoff.is_ship_running(issue, process.cwd())) return PROCESS_ALIVE
90
94
 
@@ -1,5 +1,13 @@
1
1
  import { randomUUID } from 'node:crypto'
2
- import { cpSync, existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
2
+ import {
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ renameSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
3
11
  import path from 'node:path'
4
12
  import { hook_launch } from '#scripts/init/hook-launch'
5
13
  import {
@@ -30,6 +38,8 @@ import {
30
38
  // checkout never produced is skipped, and a copy that fails is skipped rather than thrown — so
31
39
  // seeding can never be the reason an open fails, unlike the `.env` write the lane cannot run without.
32
40
 
41
+ const STAGING_SEGMENTS = ['node_modules', '.cache', 'josh-lane-cache'] as const
42
+
33
43
  function replace_root(value: unknown, source_prefix: string, destination_root: string): unknown {
34
44
  if (typeof value !== 'string') return value
35
45
 
@@ -61,19 +71,39 @@ function portable_content(
61
71
  return content
62
72
  }
63
73
 
64
- function write_atomically(destination_path: string, content: string): void {
65
- const temporary_path = `${destination_path}.${String(process.pid)}.${randomUUID()}.tmp`
74
+ // The staging file lives under `node_modules/.cache`, never beside the cache it replaces: prettier
75
+ // runs in parallel with the tool whose cache is being copied, and a temporary name in the checkout
76
+ // root is listed by its tree walk and gone by the time it is read (#3763). `node_modules` is skipped
77
+ // by every tree scan and sits on the checkout's own filesystem, so the rename stays atomic.
78
+ function stage_path(destination_root: string, cache_file: string): string {
79
+ const staging_directory = path.join(destination_root, ...STAGING_SEGMENTS)
80
+
81
+ mkdirSync(staging_directory, { recursive: true })
82
+
83
+ return path.join(staging_directory, `${cache_file}.${String(process.pid)}.${randomUUID()}.tmp`)
84
+ }
66
85
 
86
+ function publish_staged(temporary_path: string, destination_path: string, content: string): void {
67
87
  try {
68
88
  writeFileSync(temporary_path, content, { flag: 'wx' })
69
89
  renameSync(temporary_path, destination_path)
70
- } catch {
71
- // Best-effort: a cache that could not be published simply leaves the destination unchanged.
72
90
  } finally {
73
91
  rmSync(temporary_path, { force: true })
74
92
  }
75
93
  }
76
94
 
95
+ function write_atomically(destination_root: string, cache_file: string, content: string): void {
96
+ if (!existsSync(destination_root)) return
97
+
98
+ try {
99
+ const temporary_path = stage_path(destination_root, cache_file)
100
+
101
+ publish_staged(temporary_path, path.join(destination_root, cache_file), content)
102
+ } catch {
103
+ // Best-effort: a cache that could not be published simply leaves the destination unchanged.
104
+ }
105
+ }
106
+
77
107
  function copy_if_present(source_root: string, destination: string, spec: GateCacheSpec): void {
78
108
  const source_path = path.join(source_root, spec.cache_file)
79
109
  if (!existsSync(source_path)) return
@@ -86,7 +116,7 @@ function copy_if_present(source_root: string, destination: string, spec: GateCac
86
116
  destination,
87
117
  )
88
118
 
89
- write_atomically(path.join(destination, spec.cache_file), content)
119
+ write_atomically(destination, spec.cache_file, content)
90
120
  } catch {
91
121
  // A missing, changing, or malformed cache is a miss, never a failed lane or gate.
92
122
  }
@@ -130,6 +160,6 @@ function sync_cache(source_root: string, destination: string, cache_file: string
130
160
  copy_if_present(source_root, destination, spec)
131
161
  }
132
162
 
133
- const lane_cache = { seed_caches, seed_hook_bundles, sync_cache }
163
+ const lane_cache = { STAGING_SEGMENTS, seed_caches, seed_hook_bundles, sync_cache }
134
164
 
135
165
  export { lane_cache }
@@ -10,14 +10,16 @@ import { lane_seed_policy } from './lane-seed'
10
10
  // is what would have to be undone the first time two epics run together, which is why the scheduler
11
11
  // is epic-free.
12
12
  //
13
- // **It is a ceiling, not a prediction.** Six lanes does not say six children will run at once; it
14
- // says a seventh will not start. What actually runs is bounded by what is runnable, by the port
13
+ // **It is a ceiling, not a prediction.** Four lanes does not say four children will run at once; it
14
+ // says a fifth will not start. What actually runs is bounded by what is runnable, by the port
15
15
  // seats `lane-seed.ts` can allocate, and by the machine.
16
16
 
17
- // Six rather than the nine seats `lane_seed_policy` can allocate: the seats are the hard structural
17
+ // Four rather than the nine seats `lane_seed_policy` can allocate: the seats are the hard structural
18
18
  // bound, this is the one a person tunes, and leaving headroom between them means raising the limit
19
- // never lands on "the ports ran out" as its first symptom.
20
- const DEFAULT_LANE_LIMIT = 6
19
+ // never lands on "the ports ran out" as its first symptom. joshuafolkken/kit#3788 lowered it from
20
+ // six: a week of `lane:stats` showed 1.5 effective lanes, while the hours at five or six lanes held
21
+ // the swap peak, the slowest gates and most of the gate and commit failures.
22
+ const DEFAULT_LANE_LIMIT = 4
21
23
  const LANE_LIMIT_KEY = 'JOSH_LANE_LIMIT'
22
24
  const POSITIVE_INTEGER = /^[1-9]\d*$/u
23
25
  const NO_LANES = 0
@@ -28,7 +30,7 @@ const NO_LANES = 0
28
30
  type LimitChoice = { kind: 'limit'; limit: number } | { kind: 'problem'; problem: string }
29
31
 
30
32
  // **An invalid value is a hard error, never a silent fall back to the default** — the rule
31
- // `PORT_SEED` already holds to. A typo that quietly becomes 6 is a limit nobody set, and the whole
33
+ // `PORT_SEED` already holds to. A typo that quietly becomes 4 is a limit nobody set, and the whole
32
34
  // point of the setting is that the number is the one a person chose.
33
35
  //
34
36
  // Blank is unset, not invalid: an `.env` line left as `JOSH_LANE_LIMIT=` is a variable nobody
@@ -85,4 +85,5 @@ const lane_child_invocation = {
85
85
  ship_stop_invocation,
86
86
  }
87
87
 
88
+ export type { ShipStop }
88
89
  export { lane_child_invocation }
@@ -18,6 +18,7 @@ import { lane_output, type ReadOutcome, type RecordOutcome } from './lane-output
18
18
  import { lane_registry, type LaneInfo } from './lane-registry'
19
19
  import { lane_report } from './lane-report'
20
20
  import { lane_seed_policy } from './lane-seed'
21
+ import type { KeptLane } from './lane-spent'
21
22
  import { lane_stray } from './lane-stray'
22
23
 
23
24
  // `josh lane:open` / `lane:close` / `lane:list` / `lane:prune` — the lane's whole lifecycle.
@@ -263,10 +264,23 @@ async function list_command(rest: ReadonlyArray<string>): Promise<number> {
263
264
  return code
264
265
  }
265
266
 
267
+ // A lane left open is named with its reason, so a person can tell a lane somebody is in from one that
268
+ // only waits on a push, a commit or an issue — and knows the command that discards it anyway.
269
+ function report_kept(kept: ReadonlyArray<KeptLane>): void {
270
+ for (const lane of kept) {
271
+ console.error(
272
+ `Kept the lane for ${session_cite.issue(lane.issue)}: ${lane.reason}. \`pnpm josh lane:close ${lane.issue}\` discards it.`,
273
+ )
274
+ }
275
+ }
276
+
266
277
  async function prune_command(rest: ReadonlyArray<string>): Promise<number> {
267
278
  if (rest.length > 0) return report_usage()
268
279
 
269
- const code = report_sweep(await lane_close.prune_lanes())
280
+ const outcome = await lane_close.prune_lanes()
281
+ const code = report_sweep(outcome)
282
+
283
+ report_kept(outcome.kept)
270
284
 
271
285
  // After the closes, so a lane whose close left its directory behind is swept in the same run. A
272
286
  // kept stray is reported, never a failure: it waits on a person, and a non-zero exit would stop
@@ -5,6 +5,7 @@ import { error_text } from '#scripts/lib/error-message'
5
5
  import { lane_paths } from './lane-paths'
6
6
  import { lane_reap } from './lane-reap'
7
7
  import { lane_registry, type LaneInfo } from './lane-registry'
8
+ import { lane_spent, type KeptLane } from './lane-spent'
8
9
 
9
10
  // Closing a lane: no work tree, no branch, no directory.
10
11
  //
@@ -42,6 +43,11 @@ interface SweepOutcome {
42
43
  failed: Array<string>
43
44
  }
44
45
 
46
+ interface PruneOutcome extends SweepOutcome {
47
+ // The live lanes left open, each with the reason it was not discarded.
48
+ kept: Array<KeptLane>
49
+ }
50
+
45
51
  async function ignore_failure(action: () => Promise<string>): Promise<void> {
46
52
  try {
47
53
  await action()
@@ -171,14 +177,17 @@ async function close_each(issues: ReadonlyArray<string>): Promise<SweepOutcome>
171
177
  }
172
178
 
173
179
  /**
174
- * Close every lane whose work tree is no longer on disk — the route out of an interruption that took
175
- * the directory with it and left git's registration behind.
180
+ * Close every lane nothing needs any more: one whose work tree is no longer on disk — the route out of
181
+ * an interruption that took the directory with it and left git's registration behind — and one a past
182
+ * run left spent (`lane-spent.ts`). A live lane that is not spent is kept, and `kept` says why.
176
183
  */
177
- async function prune_lanes(): Promise<SweepOutcome> {
184
+ async function prune_lanes(): Promise<PruneOutcome> {
178
185
  const lanes = await lane_registry.list_lanes()
179
- const stranded = lanes.filter((lane) => lane.is_stranded)
186
+ const { spent, kept } = await lane_spent.sort_lanes(lanes)
187
+ const doomed = lanes.filter((lane) => lane.is_stranded || spent.includes(lane))
188
+ const sweep = await close_each(doomed.map((lane) => lane.issue))
180
189
 
181
- return await close_each(stranded.map((lane) => lane.issue))
190
+ return { ...sweep, kept }
182
191
  }
183
192
 
184
193
  async function close_all_lanes(): Promise<SweepOutcome> {
@@ -194,5 +203,5 @@ const lane_close = {
194
203
  resolve_lane,
195
204
  }
196
205
 
197
- export type { CloseKind, CloseOutcome, ResolvedLane, SweepOutcome }
206
+ export type { CloseKind, CloseOutcome, PruneOutcome, ResolvedLane, SweepOutcome }
198
207
  export { lane_close }
@@ -49,6 +49,7 @@ const WARNING_RECOVERY =
49
49
  // The discriminant for a dispatch refused because the `in-progress` marker could not be applied, named
50
50
  // once rather than inlined at each use.
51
51
  const LABEL_UNSET_KIND = 'label-unset'
52
+ const RESTART_START = 'restart'
52
53
  // The role a lane child runs under, resolved once so the fresh and resume builders read the same one.
53
54
  const RESOLVED_ROLE = agent_role_profile.WORKER
54
55
 
@@ -281,13 +282,15 @@ function built_for(plan: ResumePlan, profile: AgentProfile, lane: LaneInfo): Age
281
282
  // **The re-dispatch decides resume-or-fresh from the lane's own exit record**.
282
283
  // The record is read from the log the previous dispatch of this lane wrote — a first dispatch finds
283
284
  // none and starts fresh, an `outage` ending with a session id resumes it. The provider is resolved
284
- // first because the resume mechanism is Claude Code's, and an OpenAI lane falls back to fresh.
285
- function prepared(lane: LaneInfo): Prepared {
285
+ // first because the resume mechanism is Claude Code's, and an OpenAI lane falls back to fresh. **A
286
+ // fresh dispatch never reads the record**: a person restarting a child (`lane:restart`) wants a new
287
+ // session that reads the Issue from the start, whatever the previous one ended on.
288
+ function prepared(lane: LaneInfo, is_fresh: boolean): Prepared {
286
289
  const resolved = agent_role_profile.resolve(RESOLVED_ROLE)
287
290
 
288
291
  if (resolved.kind === 'rejected') return resolved
289
292
 
290
- const record = run_ending.read_exit(default_log_path(lane))
293
+ const record = is_fresh ? undefined : run_ending.read_exit(default_log_path(lane))
291
294
  const plan = lane_resume.plan(lane.issue, record, resolved.profile.provider)
292
295
  const built = built_for(plan, resolved.profile, lane)
293
296
 
@@ -333,8 +336,23 @@ async function record_started(issue: string): Promise<void> {
333
336
  await lane_ledger.record_dispatch(Number(issue))
334
337
  }
335
338
 
336
- async function finish_dispatch(issue: string, request: StartRequest): Promise<DispatchOutcome> {
337
- const mark = await mark_in_progress(issue)
339
+ // How a dispatch begins. A `resumable` one claims the marker itself and resumes an `outage` session; a
340
+ // `restart` is a person's (`lane:restart`): its caller claimed the marker before stopping the old child,
341
+ // so no GitHub round-trip sits between that stop and this launch, and the session starts fresh.
342
+ type DispatchStart = 'resumable' | typeof RESTART_START
343
+
344
+ async function claimed(issue: string, start: DispatchStart): Promise<LabelWrite> {
345
+ if (start === RESTART_START) return { is_applied: true }
346
+
347
+ return await mark_in_progress(issue)
348
+ }
349
+
350
+ async function finish_dispatch(
351
+ issue: string,
352
+ request: StartRequest,
353
+ start: DispatchStart,
354
+ ): Promise<DispatchOutcome> {
355
+ const mark = await claimed(issue, start)
338
356
 
339
357
  if (!mark.is_applied) return { kind: LABEL_UNSET_KIND, reason: mark.reason }
340
358
  const outcome = await started(request)
@@ -346,14 +364,21 @@ async function finish_dispatch(issue: string, request: StartRequest): Promise<Di
346
364
  return outcome
347
365
  }
348
366
 
349
- /** Start `fullrun #<N>` detached in the lane for `#<N>`, recording where it writes as it does so. */
350
- async function dispatch_child(issue: string): Promise<DispatchOutcome> {
367
+ /**
368
+ * Start `fullrun #<N>` detached in the lane for `#<N>`, recording where it writes as it does so.
369
+ * A `restart` start expects the marker already claimed and starts a new session even where the lane's
370
+ * last ending would resume one.
371
+ */
372
+ async function dispatch_child(
373
+ issue: string,
374
+ start: DispatchStart = 'resumable',
375
+ ): Promise<DispatchOutcome> {
351
376
  issue_number_shape.require_issue_number(issue)
352
377
 
353
378
  const lane = await lane_registry.find_open_lane(issue)
354
379
 
355
380
  if (lane === undefined) return { kind: 'no-lane' }
356
- const built = prepared(lane)
381
+ const built = prepared(lane, start === RESTART_START)
357
382
  const log_path = default_log_path(lane)
358
383
 
359
384
  if (built.kind === 'rejected') return { kind: 'failed', lane, log_path, note: built.note }
@@ -362,7 +387,7 @@ async function dispatch_child(issue: string): Promise<DispatchOutcome> {
362
387
 
363
388
  if (log.kind !== 'ready') return log
364
389
 
365
- return await finish_dispatch(issue, { lane, log_path: log.path, ...built })
390
+ return await finish_dispatch(issue, { lane, log_path: log.path, ...built }, start)
366
391
  }
367
392
 
368
393
  // **A launch whose log could not be opened is still a launch, and the reader has to hear both halves.**
@@ -438,6 +463,7 @@ async function warn_of_problem(outcome: DispatchOutcome, issue: string): Promise
438
463
 
439
464
  const lane_dispatch = {
440
465
  child_invocation,
466
+ claim_marker: mark_in_progress,
441
467
  default_log_path,
442
468
  describe,
443
469
  dispatch_child,
@@ -446,5 +472,5 @@ const lane_dispatch = {
446
472
  warn_of_problem,
447
473
  }
448
474
 
449
- export type { DispatchOutcome }
475
+ export type { DispatchOutcome, DispatchStart }
450
476
  export { lane_dispatch }
@@ -7,7 +7,7 @@ import { agent_role_profile, type AgentProfile } from '#scripts/agent/agent-role
7
7
  // the nearest ancestor holding a `package.json` — which, inside a linked work tree, is the lane's
8
8
  // own directory — so a lane with no `.env` runs on seed 0 and a lane handed a verbatim copy of the
9
9
  // root's runs on the root's exact offset; either way every lane lands on one pair of ports, and a
10
- // busy port fails without retrying on another. At the default six lanes that turns E2E from "fails
10
+ // busy port fails without retrying on another. At six lanes that turns E2E from "fails
11
11
  // sometimes" into "fails nearly always". So the lane's `.env` keeps the project's `PORT_SEED`
12
12
  // unchanged and adds `JOSH_LANE_SEAT`, and `ports/index.js` combines them as `seed × 10 + seat`:
13
13
  // the file plainly shows which number is the project's and which is the
@@ -6,6 +6,7 @@ import { lane_registry, type LaneInfo } from '#scripts/lane/lane-registry'
6
6
  import { lane_vacant } from '#scripts/lane/lane-vacant'
7
7
  import { cli_flags } from '#scripts/lib/cli-flags'
8
8
  import { INSTALL_TIMEOUT_MS } from '#scripts/lib/timeouts'
9
+ import { run_event_setup } from '#scripts/run/event/run-event-setup'
9
10
  import { execa } from 'execa'
10
11
 
11
12
  // `josh lane:launch <issue> [--stash <message>]` — one composite command for a `backlogrun` lane-start
@@ -73,8 +74,11 @@ async function install(directory: string): Promise<boolean> {
73
74
 
74
75
  // The first lane pops the shared stash and re-installs against the lock it brought in; a pop that
75
76
  // refused (`no-match` / `ambiguous`) stops the lane rather than dispatching onto an unprepared tree.
76
- // A caller without `--stash` skips both, which is every lane after the first.
77
- async function prepare(stash: string | undefined, directory: string): Promise<boolean> {
77
+ // A caller without `--stash` skips both, which is every lane after the first. The re-install is a
78
+ // stage of its own on the run's stream, as the install `lane:open` ran was.
79
+ async function prepare(context: LaunchContext, directory: string): Promise<boolean> {
80
+ const { issue, stash } = context
81
+
78
82
  if (stash === undefined) return true
79
83
 
80
84
  const popped = await josh_command.josh_run(
@@ -84,6 +88,8 @@ async function prepare(stash: string | undefined, directory: string): Promise<bo
84
88
 
85
89
  if (popped.code !== SUCCESS_EXIT_CODE) return false
86
90
 
91
+ await run_event_setup.emit(run_event_setup.installing(issue))
92
+
87
93
  return await install(directory)
88
94
  }
89
95
 
@@ -124,13 +130,16 @@ async function lane_directory(issue: string): Promise<string | undefined> {
124
130
  }
125
131
 
126
132
  // The launch chain in-process. The CLI prints the pid; `backlog:drive` launches through this same
127
- // chain.
133
+ // chain. The dispatch is said on the run's stream before it starts, so the board draws a lane that is
134
+ // ready apart from one still being filled.
128
135
  async function launch_lane(context: LaunchContext): Promise<LaunchOutcome> {
129
136
  const directory = await lane_directory(context.issue)
130
137
 
131
138
  if (directory === undefined) return UNOPENED
132
139
 
133
- if (!(await prepare(context.stash, directory))) return FAILED
140
+ if (!(await prepare(context, directory))) return FAILED
141
+
142
+ await run_event_setup.emit(run_event_setup.dispatching(context.issue))
134
143
 
135
144
  const dispatched = await josh_command.josh_run(
136
145
  ['lane:dispatch', context.issue],