@joshuafolkken/kit 1.1387.0 → 1.1453.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 (278) 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-park.md +2 -0
  5. package/.claude/skills/workflow-commands/backlogrun-steps.md +13 -10
  6. package/.claude/skills/workflow-commands/backlogrun.md +3 -2
  7. package/.claude/skills/workflow-commands/chain-rule.md +4 -2
  8. package/.claude/skills/workflow-commands/fullrun-steps.md +1 -1
  9. package/.claude/skills/workflow-commands/halfrun.md +1 -1
  10. package/.claude/skills/workflow-commands/issue-comments.md +6 -0
  11. package/.claude/skills/workflow-commands/kickoff.md +12 -13
  12. package/.claude/skills/workflow-commands/observation-filing.md +1 -1
  13. package/.claude/skills/workflow-commands/observation-ledger.md +3 -2
  14. package/.claude/skills/workflow-commands/pre-gate-cut.md +5 -3
  15. package/.claude/skills/workflow-commands/prerequisite.md +6 -0
  16. package/.claude/skills/workflow-commands/retrospective.md +5 -5
  17. package/.claude/skills/workflow-commands/split-assessment.md +2 -2
  18. package/README.md +27 -4
  19. package/dist/hooks/audit-provision.js +2 -2
  20. package/dist/hooks/batch-guard.js +7 -7
  21. package/dist/hooks/{chunk-2YSW3CXH.js → chunk-3C4XRYG6.js} +35 -11
  22. package/dist/hooks/{chunk-GJ7JPIHN.js → chunk-5C7L6FRC.js} +2704 -190
  23. package/dist/hooks/{chunk-FZ6FUSBM.js → chunk-723HKZO5.js} +1 -1
  24. package/dist/hooks/{chunk-STDSEPWU.js → chunk-D3NJXN5M.js} +4 -4
  25. package/dist/hooks/{chunk-NEC4AT4Z.js → chunk-DGT54IH5.js} +4 -0
  26. package/dist/hooks/{chunk-LMWBRJIP.js → chunk-EF5GQ6WS.js} +3 -3
  27. package/dist/hooks/{chunk-JBBMAPC4.js → chunk-IX6TTTI6.js} +4 -0
  28. package/dist/hooks/{chunk-IYVOLN6A.js → chunk-LBFYEOXJ.js} +3 -3
  29. package/dist/hooks/{chunk-SL4GQ2XP.js → chunk-MBJYQPEK.js} +106 -23
  30. package/dist/hooks/{chunk-WNFJLBXI.js → chunk-NO4AALP5.js} +3 -3
  31. package/dist/hooks/{chunk-J3KSS7II.js → chunk-OLMO72ON.js} +1 -1
  32. package/dist/hooks/{chunk-DCY2GWHC.js → chunk-SHYUQOIS.js} +5 -5
  33. package/dist/hooks/{chunk-EAQEGME7.js → chunk-UT7YC3N2.js} +1 -1
  34. package/dist/hooks/{chunk-EBSHN25W.js → chunk-W5ZKGTSY.js} +4 -4
  35. package/dist/hooks/{chunk-GNDJ7NJ7.js → chunk-Y3GKIKGW.js} +209 -56
  36. package/dist/hooks/{chunk-U32VSRNG.js → chunk-Z4XI7CZS.js} +89 -34
  37. package/dist/hooks/codex-hook-adapter.js +15 -15
  38. package/dist/hooks/duplicate-read-guard.js +8 -8
  39. package/dist/hooks/format-edited.js +5 -5
  40. package/dist/hooks/inputs.json +261 -201
  41. package/dist/hooks/investigation-guard.js +8 -8
  42. package/dist/hooks/pretool-guard.js +14 -14
  43. package/dist/hooks/session-lang.js +69 -16
  44. package/dist/hooks/stop-guard.js +13 -38
  45. package/dist/josh.js +95 -19
  46. package/package.json +4 -3
  47. package/prompts/collaboration-workflow/issue-template.md +10 -8
  48. package/prompts/review.md +30 -4
  49. package/scripts/agent/claude-agent-argv.ts +13 -1
  50. package/scripts/auth/auth-setup-cli.ts +101 -0
  51. package/scripts/auth/claude-token-output.ts +78 -0
  52. package/scripts/auth/claude-token.ts +68 -0
  53. package/scripts/auto-ok/auto-ok-judge-cli.ts +125 -0
  54. package/scripts/auto-ok/auto-ok-judge-read.ts +95 -0
  55. package/scripts/auto-ok/auto-ok-judge-write.ts +109 -0
  56. package/scripts/auto-ok/auto-ok-judge.ts +67 -0
  57. package/scripts/backlog/backlog-drive-cli.ts +14 -0
  58. package/scripts/backlog/backlog-drive-epic.ts +24 -8
  59. package/scripts/backlog/backlog-drive-launch.ts +3 -0
  60. package/scripts/backlog/backlog-drive-named-offer.ts +7 -2
  61. package/scripts/backlog/backlog-drive-named.ts +4 -2
  62. package/scripts/backlog/backlog-drive-unread.ts +56 -0
  63. package/scripts/backlog/backlog-drive.ts +38 -11
  64. package/scripts/backlog/backlog-overlap.ts +22 -5
  65. package/scripts/backlog/backlog-ready.ts +1 -3
  66. package/scripts/backlog/backlogrun-args.ts +13 -2
  67. package/scripts/backlog/backlogrun-cli.ts +91 -19
  68. package/scripts/backlog/backlogrun-launch.ts +24 -0
  69. package/scripts/backlog/backlogrun-replace.ts +166 -0
  70. package/scripts/backlog/backlogrun-stop.ts +178 -0
  71. package/scripts/behavior/behavior-cli.ts +6 -1
  72. package/scripts/doctor/doctor-io.ts +60 -9
  73. package/scripts/doctor/doctor.ts +20 -1
  74. package/scripts/document/document-byte-budget.ts +3 -3
  75. package/scripts/epic/epic-bundle-cli.ts +8 -8
  76. package/scripts/epic/epic-bundle-referenced.ts +2 -5
  77. package/scripts/epic/epic-bundle.ts +39 -4
  78. package/scripts/exports/unused-members-cli.ts +2 -1
  79. package/scripts/followup/followup-checks-event.ts +82 -0
  80. package/scripts/followup/git-followup-finish.ts +16 -2
  81. package/scripts/followup/git-followup-workflow.ts +5 -2
  82. package/scripts/followup/git-pr-followup-checks.ts +18 -4
  83. package/scripts/followup/git-pr-followup-wrapup.ts +5 -0
  84. package/scripts/followup/git-pr-followup.ts +4 -1
  85. package/scripts/fresh-machine/fresh-machine-cli.ts +204 -0
  86. package/scripts/fresh-machine/fresh-machine-docker.ts +65 -0
  87. package/scripts/fresh-machine/fresh-machine-image.ts +62 -0
  88. package/scripts/fresh-machine/fresh-machine-log.ts +61 -0
  89. package/scripts/fresh-machine/fresh-machine-patterns.ts +175 -0
  90. package/scripts/fresh-machine/fresh-machine-run.ts +215 -0
  91. package/scripts/fresh-machine/fresh-machine-script.ts +45 -0
  92. package/scripts/fresh-machine/fresh-machine-spec.ts +57 -0
  93. package/scripts/fresh-machine/specs/kit-test-backlogrun-python.json +34 -0
  94. package/scripts/fresh-machine/specs/kit-test-backlogrun.json +31 -0
  95. package/scripts/gate/gate-report.ts +70 -15
  96. package/scripts/gate/project-checks.ts +18 -4
  97. package/scripts/gate/scoped-green.ts +2 -1
  98. package/scripts/gate/verification-gate.ts +4 -3
  99. package/scripts/gh/gh-author-association.ts +14 -0
  100. package/scripts/gh/git-gh-conditional.ts +66 -0
  101. package/scripts/gh/git-gh-exec.ts +1 -1
  102. package/scripts/gh/git-gh-issue-read.ts +6 -3
  103. package/scripts/gh/git-gh-issue-rest.ts +1 -0
  104. package/scripts/gh/git-gh-issue-write.ts +5 -1
  105. package/scripts/gh/git-gh-issue.ts +13 -1
  106. package/scripts/gh/git-pr-checks-eval.ts +13 -0
  107. package/scripts/gh/git-pr-checks-parse.ts +8 -1
  108. package/scripts/gh/git-pr-checks-watch.ts +11 -4
  109. package/scripts/gh/git-pr-checks.ts +36 -2
  110. package/scripts/git/git-command.ts +16 -4
  111. package/scripts/git/git-schemas.ts +1 -0
  112. package/scripts/git/git-spawn-sync.ts +1 -1
  113. package/scripts/hooks/pretool-guard.ts +3 -1
  114. package/scripts/init/hook-command-rewrite.ts +31 -0
  115. package/scripts/init/init-actions.ts +7 -4
  116. package/scripts/init/init-ai-copy.ts +34 -11
  117. package/scripts/init/init-basic.ts +9 -4
  118. package/scripts/init/init-bootstrap.ts +6 -2
  119. package/scripts/init/init-dry-run-report.ts +150 -0
  120. package/scripts/init/init-dry-run.ts +189 -0
  121. package/scripts/init/init-install.ts +8 -1
  122. package/scripts/init/init-logic-toolchain.ts +76 -0
  123. package/scripts/init/init-logic.ts +2 -0
  124. package/scripts/init/init.ts +62 -42
  125. package/scripts/init/kit-setup-state.ts +11 -17
  126. package/scripts/init/own-formatter.ts +21 -0
  127. package/scripts/init/project-profile.ts +60 -12
  128. package/scripts/init/start-dry-run.ts +70 -0
  129. package/scripts/init/start-exec.ts +27 -1
  130. package/scripts/init/start-plan.ts +23 -3
  131. package/scripts/init/start-setup-pr.ts +13 -24
  132. package/scripts/init/start-steps.ts +62 -1
  133. package/scripts/init/start.ts +38 -17
  134. package/scripts/init/workflow-mark.ts +35 -0
  135. package/scripts/init/working-tree.ts +116 -0
  136. package/scripts/issue/issue-ack-cli.ts +84 -0
  137. package/scripts/issue/issue-auto-ok.ts +68 -99
  138. package/scripts/issue/issue-file-cli.ts +17 -17
  139. package/scripts/issue/issue-file.ts +1 -6
  140. package/scripts/issue/issue-labels.ts +46 -10
  141. package/scripts/issue/issue-release-cli.ts +69 -23
  142. package/scripts/issue/issue-release.ts +3 -1
  143. package/scripts/issue/issue-state-cli.ts +14 -4
  144. package/scripts/issue/issue-watch-diff.ts +152 -0
  145. package/scripts/issue/issue-watch-gate.ts +81 -0
  146. package/scripts/issue/issue-watch-hook.ts +54 -0
  147. package/scripts/issue/issue-watch.ts +240 -0
  148. package/scripts/issue/measure-rerun-cli.ts +3 -4
  149. package/scripts/josh/josh-command-map.ts +4 -0
  150. package/scripts/josh/josh-commands-ai.ts +11 -0
  151. package/scripts/josh/josh-commands-backlog.ts +2 -2
  152. package/scripts/josh/josh-commands-development.ts +1 -1
  153. package/scripts/josh/josh-commands-issue.ts +10 -3
  154. package/scripts/josh/josh-commands-lane.ts +11 -2
  155. package/scripts/josh/josh-commands-project.ts +26 -2
  156. package/scripts/josh/josh-verdict.ts +33 -4
  157. package/scripts/josh/process-identity.ts +32 -6
  158. package/scripts/lane/claude-lane-broker.ts +142 -0
  159. package/scripts/lane/lane-await.ts +6 -2
  160. package/scripts/lane/lane-broker-server.ts +69 -0
  161. package/scripts/lane/lane-cache.ts +37 -7
  162. package/scripts/lane/lane-capacity.ts +8 -6
  163. package/scripts/lane/lane-child-invocation.ts +1 -0
  164. package/scripts/lane/lane-cli.ts +15 -1
  165. package/scripts/lane/lane-close.ts +15 -6
  166. package/scripts/lane/lane-dispatch.ts +36 -10
  167. package/scripts/lane/lane-environment.ts +1 -1
  168. package/scripts/lane/lane-launch-cli.ts +13 -4
  169. package/scripts/lane/lane-ledger.ts +21 -3
  170. package/scripts/lane/lane-measure-cli.ts +10 -2
  171. package/scripts/lane/lane-open.ts +9 -1
  172. package/scripts/lane/lane-preserve.ts +49 -0
  173. package/scripts/lane/lane-reap.ts +20 -5
  174. package/scripts/lane/lane-registry.ts +6 -0
  175. package/scripts/lane/lane-restart-cli.ts +51 -0
  176. package/scripts/lane/lane-restart.ts +175 -0
  177. package/scripts/lane/lane-session-broker.ts +363 -0
  178. package/scripts/lane/lane-spent.ts +195 -0
  179. package/scripts/lane/lane-stage-stats.ts +27 -3
  180. package/scripts/lane/lane-stop-stats.ts +96 -0
  181. package/scripts/lane/openai-lane-supervisor.ts +2 -2
  182. package/scripts/lane/process-tools.ts +31 -0
  183. package/scripts/lib/buffered-process.ts +36 -2
  184. package/scripts/lib/pnpm-reach.ts +27 -0
  185. package/scripts/lib/status-icons.ts +5 -1
  186. package/scripts/lint/lint-parallel.ts +19 -2
  187. package/scripts/observations/observation-ledger-home.ts +1 -0
  188. package/scripts/observations/observation-ledger-migrate.ts +3 -15
  189. package/scripts/release/release-issue-close.ts +133 -0
  190. package/scripts/release/release-publish.ts +12 -1
  191. package/scripts/repo/repository-labels.ts +11 -1
  192. package/scripts/review/comment-ranges.ts +199 -0
  193. package/scripts/review/review-attest-cli.ts +21 -6
  194. package/scripts/review/review-brief-cli.ts +24 -7
  195. package/scripts/review/review-code-tree.ts +119 -0
  196. package/scripts/review/review-comment-round.ts +114 -0
  197. package/scripts/review/review-record-cli.ts +39 -17
  198. package/scripts/review/review-round2-cli.ts +16 -5
  199. package/scripts/review/review-round2.ts +30 -10
  200. package/scripts/review/review-stamps.ts +22 -1
  201. package/scripts/rules/implementation-cut.ts +3 -3
  202. package/scripts/rules/pre-gate-cut.ts +1 -1
  203. package/scripts/run/board/run-board-checks.ts +42 -0
  204. package/scripts/run/board/run-board-cli.ts +2 -0
  205. package/scripts/run/board/run-board-every.ts +3 -1
  206. package/scripts/run/board/run-board-fit.ts +16 -5
  207. package/scripts/run/board/run-board-fresh.ts +64 -0
  208. package/scripts/run/board/run-board-header.ts +129 -53
  209. package/scripts/run/board/run-board-labels.ts +47 -4
  210. package/scripts/run/board/run-board-lane-event.ts +66 -0
  211. package/scripts/run/board/run-board-layout.ts +57 -9
  212. package/scripts/run/board/run-board-now.ts +234 -0
  213. package/scripts/run/board/run-board-operation-read.ts +75 -0
  214. package/scripts/run/board/run-board-operation.ts +150 -0
  215. package/scripts/run/board/run-board-phase.ts +26 -1
  216. package/scripts/run/board/run-board-plan.ts +4 -6
  217. package/scripts/run/board/run-board-read.ts +49 -3
  218. package/scripts/run/board/run-board-reason.ts +124 -0
  219. package/scripts/run/board/run-board-render-checks.ts +55 -0
  220. package/scripts/run/board/run-board-render-legend.ts +12 -3
  221. package/scripts/run/board/run-board-render-slot.ts +2 -2
  222. package/scripts/run/board/run-board-render-tail.ts +128 -0
  223. package/scripts/run/board/run-board-render-thanks.ts +50 -0
  224. package/scripts/run/board/run-board-render-title.ts +58 -0
  225. package/scripts/run/board/run-board-render.ts +96 -80
  226. package/scripts/run/board/run-board-row-yield.ts +32 -0
  227. package/scripts/run/board/run-board-setup.ts +61 -13
  228. package/scripts/run/board/run-board-state.ts +10 -0
  229. package/scripts/run/board/run-board-status.ts +66 -7
  230. package/scripts/run/board/run-board-thanks-csv.ts +71 -0
  231. package/scripts/run/board/run-board-thanks-read.ts +112 -0
  232. package/scripts/run/board/run-board-thanks.ts +53 -0
  233. package/scripts/run/board/run-board-tick.ts +50 -21
  234. package/scripts/run/board/run-board-usual.ts +44 -0
  235. package/scripts/run/carry/run-carry-args.ts +9 -1
  236. package/scripts/run/carry/run-carry-cli.ts +2 -45
  237. package/scripts/run/carry/run-carry-end.ts +73 -0
  238. package/scripts/run/carry/run-carry.ts +8 -1
  239. package/scripts/run/entry/run-entry-cli.ts +5 -0
  240. package/scripts/run/event/run-event-checks.ts +54 -0
  241. package/scripts/run/event/run-event-render.ts +2 -0
  242. package/scripts/run/event/run-event-scope.ts +1 -0
  243. package/scripts/run/event/run-event-setup.ts +55 -11
  244. package/scripts/run/event/run-event-stream.ts +27 -11
  245. package/scripts/run/merge/run-merge-cli.ts +7 -4
  246. package/scripts/run/merge/run-merge-ship.ts +61 -0
  247. package/scripts/run/merge/run-merge-steps.ts +7 -14
  248. package/scripts/run/merge/run-merge-token.ts +16 -2
  249. package/scripts/run/run-headless.ts +1 -0
  250. package/scripts/run/run-step.ts +2 -0
  251. package/scripts/run/ship/run-ship-cli.ts +22 -2
  252. package/scripts/run/ship/run-ship-return.ts +31 -13
  253. package/scripts/run/ship/run-ship-review-steps.ts +17 -4
  254. package/scripts/run/ship/run-ship-stage.ts +1 -0
  255. package/scripts/run/wake/run-wake-await.ts +130 -0
  256. package/scripts/run/wake/run-wake-cli.ts +60 -71
  257. package/scripts/run/wake/run-wake-describe.ts +34 -2
  258. package/scripts/run/wake/run-wake-driver.ts +11 -4
  259. package/scripts/run/wake/run-wake-session.ts +20 -3
  260. package/scripts/run/wake/run-wake-stop.ts +37 -0
  261. package/scripts/run/wake/run-wake-supervise.ts +44 -0
  262. package/scripts/sync/claude-settings-merge.ts +144 -0
  263. package/scripts/sync/sync-ai-files.ts +28 -3
  264. package/scripts/sync/sync.ts +15 -0
  265. package/scripts/test/project-test-script.ts +73 -0
  266. package/scripts/test/skip-marker.ts +14 -1
  267. package/scripts/test/test-related.ts +3 -2
  268. package/scripts/test/test-unit-guard.ts +78 -14
  269. package/scripts/time-runtime/time-reported-failure.ts +11 -3
  270. package/scripts/uninstall/distributed-files.ts +92 -0
  271. package/scripts/uninstall/uninstall-plan.ts +119 -0
  272. package/scripts/uninstall/uninstall-report.ts +61 -0
  273. package/scripts/uninstall/uninstall.ts +206 -0
  274. package/templates/AGENTS.basic.md +4 -0
  275. package/templates/GEMINI.basic.md +6 -0
  276. package/dist/hooks/chunk-6V5OPIUU.js +0 -13
  277. package/scripts/lane/openai-review-broker.ts +0 -262
  278. package/scripts/run/board/run-board-track.ts +0 -22
@@ -172,6 +172,29 @@ function started_for(git_directory: string, owner_pid: number | undefined): Date
172
172
  return Number.isNaN(started_ms) ? undefined : new Date(started_ms)
173
173
  }
174
174
 
175
+ function signal_parent(pid: number): void {
176
+ try {
177
+ process.kill(pid, 'SIGTERM')
178
+ } catch {
179
+ note_to_stderr(`process ${String(pid)} could not be signalled; its launch record is removed.`)
180
+ }
181
+ }
182
+
183
+ // Stops the parent this launcher started and forgets its launch — the pid stopped, `undefined` where
184
+ // no launched parent was live. **Only the process the record names, and only while it is still that
185
+ // process**: a session a person typed `backlogrun` into has no launch record and is never signalled.
186
+ function stop_parent(git_directory: string): number | undefined {
187
+ const record = live_launch(git_directory)
188
+
189
+ stamp_file.remove_stamp(launch_path(git_directory))
190
+
191
+ if (record === undefined) return undefined
192
+
193
+ signal_parent(record.pid)
194
+
195
+ return record.pid
196
+ }
197
+
175
198
  // The headless mark holds the parent's turn open while its lanes run, as `run:wake`'s does. The launch
176
199
  // strips only Claude's session keys, so a Codex session this was typed in is cleared here too — inherited,
177
200
  // it would make the parent's own `josh` commands resolve the wrong provider.
@@ -223,6 +246,7 @@ const backlogrun_launch = {
223
246
  pending_launch,
224
247
  start,
225
248
  started_for,
249
+ stop_parent,
226
250
  }
227
251
 
228
252
  export type { Launched, LaunchTarget, PendingLaunch, StartResult }
@@ -0,0 +1,166 @@
1
+ import { lane_spent, type LaneUse } from '#scripts/lane/lane-spent'
2
+ import { run_carry, type CarryRead, type RunCarry } from '#scripts/run/carry/run-carry'
3
+ import { run_event_scope } from '#scripts/run/event/run-event-scope'
4
+ import { run_event_stream, type RunEvent } from '#scripts/run/event/run-event-stream'
5
+ import { backlog_drive_retrospective } from './backlog-drive-retrospective'
6
+ import { backlogrun_launch } from './backlogrun-launch'
7
+
8
+ // Whether `josh backlogrun` may replace the run already going with the one just typed. A run that
9
+ // drained its backlog keeps watching for hours with nothing in flight, and starting another meant typing
10
+ // `--stop` first; this is the test that lets the start do both.
11
+ //
12
+ // **A different invocation, a watching run, no lane in use — all three.** The same invocation is how a
13
+ // person reopens the board, so it never rebuilds the run. A run still starting is opening a lane, and a
14
+ // child killed mid-change leaves a tree nobody can resume, so only a run past its drain is replaced.
15
+ //
16
+ // **Only a lane a child is working in keeps the run.** A lane a past run left behind has nobody in it;
17
+ // counted as open, it kept every later invocation from replacing the run (`lane-spent.ts`).
18
+ //
19
+ // **A drain that still owes the retrospective is not the watch.** The drain is marked before the
20
+ // end-of-run retrospective runs, so a run owing one has a session at work on it; stopping that session
21
+ // loses the retrospective with the record, and the run is kept until it has run.
22
+ //
23
+ // **An undetermined run is not replaced.** A record or a scope that cannot be read answers `keep`, the
24
+ // side that keeps the run.
25
+ //
26
+ // **A launch its parent has not claimed yet is still starting, not unreadable.** No carry record stands
27
+ // then, so the invocation is read from the launch record: the same one is the board reopened, and any
28
+ // other is kept as a run that has not written its record yet.
29
+ //
30
+ // **A kept run says why.** Every `keep` carries the one condition that held it, because a board shown
31
+ // with no word looked the same whichever of them it was.
32
+
33
+ const KIND = run_event_stream.EVENT_KIND
34
+ const NOT_FOUND = -1
35
+ const ISSUE_PREFIX = '#'
36
+ const UNREADABLE_REASON = "the run's record could not be read"
37
+ const UNCLAIMED_REASON = 'the run is still starting: it has not written its record yet'
38
+ const WORKING_LANE = 'a child is still working in the lane for'
39
+ const UNREAD_LANE = 'whether a child is working could not be read in the lane for'
40
+ const LANE_WAY_OUT = '(`pnpm josh lane:close <N>` discards a lane)'
41
+ const RETROSPECTIVE_REASON = 'the run still owes its retrospective'
42
+ const WORKING_REASON = 'the run is not watching: it is still starting or has a child at work'
43
+
44
+ // `same` is the board reopened, which is kept without a word; `keep` is a refusal, and says why.
45
+ type ReplaceVerdict = { kind: 'replace' } | { kind: 'same' } | { kind: 'keep'; reason: string }
46
+
47
+ const REPLACE: ReplaceVerdict = { kind: 'replace' }
48
+ const SAME: ReplaceVerdict = { kind: 'same' }
49
+
50
+ interface ReplacePorts {
51
+ read_carry: (git_directory: string) => CarryRead
52
+ events: (git_directory: string) => ReadonlyArray<RunEvent>
53
+ is_retrospective_owed: (carry: RunCarry) => boolean
54
+ // The invocation of a launch whose parent has not claimed the record yet.
55
+ pending_invocation: (git_directory: string) => string | undefined
56
+ // The lanes in use: a child working in one, or a probe that did not answer.
57
+ lanes: () => Promise<LaneUse>
58
+ }
59
+
60
+ const LIVE_PORTS: ReplacePorts = {
61
+ read_carry: (git_directory) => run_carry.read_carry(run_carry.carry_path(git_directory)),
62
+ events: (git_directory) =>
63
+ run_event_stream.read_events(run_event_stream.target_of(git_directory)),
64
+ is_retrospective_owed: backlog_drive_retrospective.is_owed,
65
+ pending_invocation: (git_directory) =>
66
+ backlogrun_launch.pending_launch(git_directory)?.invocation,
67
+ lanes: async () => await lane_spent.lanes_in_use(),
68
+ }
69
+
70
+ function keep(reason: string): ReplaceVerdict {
71
+ return { kind: 'keep', reason }
72
+ }
73
+
74
+ // What the record alone settles, or `undefined` for a different invocation that owes nothing.
75
+ function record_verdict(
76
+ invocation: string,
77
+ read: CarryRead,
78
+ ports: ReplacePorts,
79
+ ): ReplaceVerdict | undefined {
80
+ if (read.kind !== 'carried') return keep(UNREADABLE_REASON)
81
+ if (read.carry.invocation === invocation) return SAME
82
+
83
+ return ports.is_retrospective_owed(read.carry) ? keep(RETROSPECTIVE_REASON) : undefined
84
+ }
85
+
86
+ // A launch with no carry record yet, answered from the launch record.
87
+ function unclaimed_verdict(pending: string | undefined, invocation: string): ReplaceVerdict {
88
+ return pending === invocation ? SAME : keep(UNCLAIMED_REASON)
89
+ }
90
+
91
+ // Watching is the run's newest drain with no launch after it: a child the watch woke for ends it.
92
+ function is_watching(scoped: ReadonlyArray<RunEvent>): boolean {
93
+ const drained_at = scoped.findLastIndex((event) => event.kind === KIND.DRAIN)
94
+
95
+ if (drained_at === NOT_FOUND) return false
96
+
97
+ return scoped.slice(drained_at).every((event) => event.kind !== KIND.CHILD_LAUNCH)
98
+ }
99
+
100
+ function watch_verdict(
101
+ git_directory: string,
102
+ read: CarryRead,
103
+ ports: ReplacePorts,
104
+ ): ReplaceVerdict {
105
+ const events = ports.events(git_directory)
106
+ const scoped = run_event_scope.scoped_events(events, run_event_scope.scope_of(read))
107
+
108
+ if (scoped === undefined) return keep(UNREADABLE_REASON)
109
+
110
+ return is_watching(scoped) ? REPLACE : keep(WORKING_REASON)
111
+ }
112
+
113
+ function named(lead: string, issues: ReadonlyArray<string>): Array<string> {
114
+ if (issues.length === 0) return []
115
+
116
+ const cited = issues.map((issue) => `${ISSUE_PREFIX}${issue}`).join(' ')
117
+
118
+ return [`${lead} ${cited}`]
119
+ }
120
+
121
+ // The lanes are named with the command that discards one, the only way past a child that will not end —
122
+ // and a probe that did not answer is said as that, because the close takes a lane's uncommitted work.
123
+ function lane_reason(use: LaneUse): string {
124
+ const parts = [...named(WORKING_LANE, use.working), ...named(UNREAD_LANE, use.unread)]
125
+
126
+ return `${parts.join('; ')} ${LANE_WAY_OUT}`
127
+ }
128
+
129
+ function is_free(use: LaneUse): boolean {
130
+ return use.working.length === 0 && use.unread.length === 0
131
+ }
132
+
133
+ async function claimed_verdict(
134
+ invocation: string,
135
+ git_directory: string,
136
+ read: CarryRead,
137
+ ports: ReplacePorts,
138
+ ): Promise<ReplaceVerdict> {
139
+ const settled =
140
+ record_verdict(invocation, read, ports) ?? watch_verdict(git_directory, read, ports)
141
+
142
+ if (settled.kind !== 'replace') return settled
143
+
144
+ const use = await ports.lanes()
145
+
146
+ return is_free(use) ? REPLACE : keep(lane_reason(use))
147
+ }
148
+
149
+ async function decide(
150
+ invocation: string,
151
+ git_directory: string,
152
+ ports: ReplacePorts = LIVE_PORTS,
153
+ ): Promise<ReplaceVerdict> {
154
+ const read = ports.read_carry(git_directory)
155
+
156
+ if (read.kind === 'none') {
157
+ return unclaimed_verdict(ports.pending_invocation(git_directory), invocation)
158
+ }
159
+
160
+ return await claimed_verdict(invocation, git_directory, read, ports)
161
+ }
162
+
163
+ const backlogrun_replace = { decide }
164
+
165
+ export type { ReplacePorts, ReplaceVerdict }
166
+ export { backlogrun_replace }
@@ -0,0 +1,178 @@
1
+ import { process_identity } from '#scripts/josh/process-identity'
2
+ import { lane_registry } from '#scripts/lane/lane-registry'
3
+ import { run_carry, type CarryRead, type RunCarry } from '#scripts/run/carry/run-carry'
4
+ import { run_carry_end } from '#scripts/run/carry/run-carry-end'
5
+ import { run_wake } from '#scripts/run/wake/run-wake'
6
+ import { run_wake_stop } from '#scripts/run/wake/run-wake-stop'
7
+ import { backlogrun_launch } from './backlogrun-launch'
8
+
9
+ // `josh backlogrun --stop` — the order a `backlogrun` is stopped in, and what is said of it. Each stop
10
+ // is the one its own command already makes (`run:wake --stop`, `run:carry --end --stopped`); only the
11
+ // order is here.
12
+ //
13
+ // **Supervisor, then parent, then record.** A record ended first is one a supervisor or a parent still
14
+ // alive can write back, and a supervisor still watching would wake a session over the run just ended.
15
+ //
16
+ // **Only what `josh backlogrun` started is stopped.** A run a person typed into a session of their own
17
+ // is owned by that session; its record is kept and the owner is named, because ending a record under a
18
+ // live owner leaves a session running lanes nothing counts.
19
+ //
20
+ // **A headless session is the run's own.** Nobody can end the run from it, so when it holds the record
21
+ // it is stopped too. The record says so itself (`is_owner_headless`): the supervisor's record is gone
22
+ // after a `run:wake --stop` or a supervisor that gave up, and the session it woke outlives both.
23
+ //
24
+ // **Lanes are counted, never stopped**: a child killed mid-change leaves a tree nobody can resume.
25
+
26
+ const STOPPED_REASON = 'stopped by a person with `josh backlogrun --stop`'
27
+ const PREFIX = 'backlogrun:'
28
+ const ISSUE_PREFIX = '#'
29
+
30
+ type RecordOutcome = { kind: 'none' } | { kind: 'ended' } | { kind: 'held'; owner: number }
31
+
32
+ interface StopReport {
33
+ // The pids stopped, `undefined` where there was nothing to stop.
34
+ supervisor: number | undefined
35
+ parent: number | undefined
36
+ // The headless session that held the record.
37
+ session: number | undefined
38
+ record: RecordOutcome
39
+ // The issues whose lanes are still open, left running.
40
+ lanes: ReadonlyArray<string>
41
+ }
42
+
43
+ interface StopPorts {
44
+ stop_supervisor: (git_directory: string) => number | undefined
45
+ stop_parent: (git_directory: string) => number | undefined
46
+ stop_session: (pid: number) => void
47
+ read_carry: (git_directory: string) => CarryRead
48
+ is_live: (pid: number, start: string | undefined) => boolean
49
+ end_record: (git_directory: string) => Promise<CarryRead>
50
+ lanes: () => Promise<Array<string>>
51
+ }
52
+
53
+ // The issues whose lanes are open — every one a stop leaves on disk, a child in it or not.
54
+ async function open_lanes(): Promise<Array<string>> {
55
+ const lanes = await lane_registry.list_lanes()
56
+
57
+ return lanes.filter((lane) => !lane.is_stranded).map((lane) => lane.issue)
58
+ }
59
+
60
+ // A person who typed the stop is at the terminal, so the ⏸️ confirmation a stop pushes is not sent.
61
+ const LIVE_PORTS: StopPorts = {
62
+ stop_supervisor: (git_directory) => run_wake_stop.stop(run_wake.wake_path(git_directory))?.pid,
63
+ stop_parent: backlogrun_launch.stop_parent,
64
+ stop_session: run_wake_stop.signal,
65
+ read_carry: (git_directory) => run_carry.read_carry(run_carry.carry_path(git_directory)),
66
+ is_live: (pid, start) => process_identity.is_same_process(pid, start) !== false,
67
+ end_record: async (git_directory) =>
68
+ await run_carry_end.end_run(git_directory, { stopped: STOPPED_REASON, is_announced: false }),
69
+ lanes: open_lanes,
70
+ }
71
+
72
+ function live_owner(carry: RunCarry, ports: StopPorts): number | undefined {
73
+ const { owner_pid, owner_start } = carry
74
+
75
+ if (owner_pid === undefined) return undefined
76
+
77
+ return ports.is_live(owner_pid, owner_start) ? owner_pid : undefined
78
+ }
79
+
80
+ // The live owner this command did not just stop.
81
+ function standing_owner(
82
+ read: CarryRead,
83
+ stopped: ReadonlyArray<number | undefined>,
84
+ ports: StopPorts,
85
+ ): number | undefined {
86
+ const owner = read.kind === 'carried' ? live_owner(read.carry, ports) : undefined
87
+
88
+ return stopped.includes(owner) ? undefined : owner
89
+ }
90
+
91
+ function is_headless_owner(read: CarryRead): boolean {
92
+ return read.kind === 'carried' && read.carry.is_owner_headless === true
93
+ }
94
+
95
+ // Stops the owner where the record marks it headless — the pid stopped, `undefined` where the owner
96
+ // is any other process, which this command has no business signalling.
97
+ function stop_headless(
98
+ owner: number | undefined,
99
+ read: CarryRead,
100
+ ports: StopPorts,
101
+ ): number | undefined {
102
+ if (owner === undefined || !is_headless_owner(read)) return undefined
103
+
104
+ ports.stop_session(owner)
105
+
106
+ return owner
107
+ }
108
+
109
+ async function close_record(
110
+ git_directory: string,
111
+ read: CarryRead,
112
+ holder: number | undefined,
113
+ ports: StopPorts,
114
+ ): Promise<RecordOutcome> {
115
+ if (read.kind === 'none') return { kind: 'none' }
116
+ if (holder !== undefined) return { kind: 'held', owner: holder }
117
+
118
+ await ports.end_record(git_directory)
119
+
120
+ return { kind: 'ended' }
121
+ }
122
+
123
+ async function stop(git_directory: string, ports: StopPorts = LIVE_PORTS): Promise<StopReport> {
124
+ const supervisor = ports.stop_supervisor(git_directory)
125
+ const parent = ports.stop_parent(git_directory)
126
+ const read = ports.read_carry(git_directory)
127
+ const owner = standing_owner(read, [supervisor, parent], ports)
128
+ const session = stop_headless(owner, read, ports)
129
+ const holder = session === undefined ? owner : undefined
130
+ const record = await close_record(git_directory, read, holder, ports)
131
+ const lanes = await ports.lanes()
132
+
133
+ return { supervisor, parent, session, record, lanes }
134
+ }
135
+
136
+ function process_line(what: string, pid: number | undefined): Array<string> {
137
+ return pid === undefined ? [] : [`${PREFIX} stopped the ${what} (process ${String(pid)})`]
138
+ }
139
+
140
+ function record_lines(outcome: RecordOutcome): Array<string> {
141
+ if (outcome.kind === 'none') return []
142
+ if (outcome.kind === 'ended') return [`${PREFIX} ended the run's record`]
143
+
144
+ return [
145
+ `${PREFIX} process ${String(outcome.owner)} owns the run and was not started by \`josh backlogrun\`; its record is kept — end the run from that session`,
146
+ ]
147
+ }
148
+
149
+ function lane_lines(lanes: ReadonlyArray<string>): Array<string> {
150
+ if (lanes.length === 0) return []
151
+
152
+ const issues = lanes.map((issue) => `${ISSUE_PREFIX}${issue}`).join(' ')
153
+
154
+ return [`${PREFIX} ${String(lanes.length)} lane(s) still running, not stopped: ${issues}`]
155
+ }
156
+
157
+ // What a person reads back: one line per thing done, or the one line that says nothing was running.
158
+ function lines_of(report: StopReport): Array<string> {
159
+ const done = [
160
+ ...process_line('supervisor', report.supervisor),
161
+ ...process_line('parent session', report.parent),
162
+ ...process_line('headless session', report.session),
163
+ ...record_lines(report.record),
164
+ ]
165
+ const stopped = done.length === 0 ? [`${PREFIX} nothing is running`] : done
166
+
167
+ return [...stopped, ...lane_lines(report.lanes)]
168
+ }
169
+
170
+ // A record left with its owner is a run this command did not stop.
171
+ function is_stopped(report: StopReport): boolean {
172
+ return report.record.kind !== 'held'
173
+ }
174
+
175
+ const backlogrun_stop = { is_stopped, lines_of, stop }
176
+
177
+ export type { StopPorts, StopReport }
178
+ export { backlogrun_stop }
@@ -2,6 +2,7 @@
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { cost_transcript, type SessionFile } from '#scripts/cost-runtime/cost-transcript'
4
4
  import { status_icons } from '#scripts/lib/status-icons'
5
+ import { NOTHING_TO_CHECK_MARKER } from '#scripts/test/skip-marker'
5
6
  import { behavior_assertion, type Violation } from './behavior-assertion'
6
7
  import { behavior_rules } from './behavior-rules'
7
8
 
@@ -32,8 +33,12 @@ function current_session(cwd: string = process.cwd()): SessionFile | undefined {
32
33
  // **A run with no transcript passes, and passes without the skip marker.** The gate reads `—
33
34
  // skipping` to withhold its green record; a behavior check that found no transcript — a CI runner has
34
35
  // none — has nothing to say about the tree and must not withhold the record the other checks earned.
36
+ // It carries the nothing-to-check marker instead, so the gate's verdict does not count it as a check
37
+ // that ran.
35
38
  function report_no_transcript(): number {
36
- console.info(`${LABEL}: no recorded transcript to check ${status_icons.PASS_ICON}`)
39
+ console.info(
40
+ `${LABEL}: no recorded transcript — ${NOTHING_TO_CHECK_MARKER} ${status_icons.PASS_ICON}`,
41
+ )
37
42
 
38
43
  return PASS_EXIT_CODE
39
44
  }
@@ -1,8 +1,9 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
4
+ import { pnpm_reach } from '#scripts/lib/pnpm-reach'
4
5
  import { read_spawn_stdout } from '#scripts/lib/spawn-exit'
5
- import { PROBE_TIMEOUT_MS } from '#scripts/lib/timeouts'
6
+ import { COMMAND_TIMEOUT_MS, PROBE_TIMEOUT_MS } from '#scripts/lib/timeouts'
6
7
  import { execaSync } from 'execa'
7
8
  import { doctor_logic } from './doctor-logic'
8
9
 
@@ -19,22 +20,45 @@ const AUTO_MERGE_WORKFLOW_PATH = '.github/workflows/dependabot-auto-merge.yml'
19
20
  // consumer's own auto-merge workflow which does creates the same one kit's does.
20
21
  const AUTO_MERGE_COMMAND = 'gh pr merge --auto'
21
22
  const YAML_COMMENT_PREFIX = '#'
23
+ const UNKNOWN_PNPM: PnpmProbe = { state: 'unknown' }
22
24
 
23
- // Resolve the `josh` that the shell would run — the first match on PATH, via the platform's
24
- // lookup command (`where` on Windows, `which` elsewhere). Undefined when the lookup fails or
25
- // prints nothing (no `josh` on PATH, e.g. inside the kit repo where only `pnpm josh` runs).
26
- function resolve_path_josh(): string | undefined {
25
+ // What the platform's lookup command said about a name: whether the command itself could be
26
+ // started, and the first match it printed.
27
+ interface PathLookup {
28
+ did_run: boolean
29
+ first_path: string | undefined
30
+ }
31
+
32
+ // Ask the platform's lookup command (`where` on Windows, `which` elsewhere) for `bin` on PATH.
33
+ function look_up_on_path(bin: string): PathLookup {
27
34
  const lookup = doctor_logic.path_lookup_command(process.platform)
28
- const result = execaSync(lookup, [JOSH_BIN], { reject: false })
35
+ const result = execaSync(lookup, [bin], { reject: false })
36
+
37
+ return {
38
+ did_run: !pnpm_reach.is_not_found(result),
39
+ first_path: doctor_logic.first_path_line(read_spawn_stdout(result)),
40
+ }
41
+ }
42
+
43
+ // Resolve the `josh` that the shell would run — the first match on PATH. Undefined when the lookup
44
+ // fails or prints nothing (no `josh` on PATH, e.g. inside the kit repo where only `pnpm josh` runs).
45
+ function resolve_path_josh(): string | undefined {
46
+ return look_up_on_path(JOSH_BIN).first_path
47
+ }
48
+
49
+ // Whether the lookup command ran and found no `bin`. A lookup command that is itself absent proves
50
+ // nothing about `bin`, so it is not an answer.
51
+ function is_off_path(bin: string): boolean {
52
+ const { did_run, first_path } = look_up_on_path(bin)
29
53
 
30
- return doctor_logic.first_path_line(read_spawn_stdout(result))
54
+ return did_run && first_path === undefined
31
55
  }
32
56
 
33
57
  // Resolve the pnpm-global `josh` bin path. Undefined when pnpm is missing or the global bin does
34
58
  // not contain a `josh` (kit not installed globally). `pnpm bin -g` prepends a `[WARN]` line to
35
59
  // stdout inside a project, so the bin directory is extracted via first_path_line.
36
60
  function resolve_pnpm_global_josh(): string | undefined {
37
- const result = execaSync('pnpm', ['bin', '-g'], { reject: false })
61
+ const result = execaSync(pnpm_reach.PNPM, ['bin', '-g'], { reject: false })
38
62
  const bin_directory = doctor_logic.first_path_line(read_spawn_stdout(result))
39
63
  if (bin_directory === undefined) return undefined
40
64
  const candidate = path.join(bin_directory, JOSH_BIN)
@@ -42,6 +66,29 @@ function resolve_pnpm_global_josh(): string | undefined {
42
66
  return existsSync(candidate) ? candidate : undefined
43
67
  }
44
68
 
69
+ // A spawn that found no executable proves pnpm is off PATH, but Windows never reports one: an
70
+ // unresolved `pnpm` is handed to `cmd.exe`, which starts, prints "'pnpm' is not recognized" and exits
71
+ // 1. So a pnpm that did not answer is also looked up by name, which says the same thing everywhere.
72
+ function is_pnpm_missing(result: { code?: string | undefined }): boolean {
73
+ return pnpm_reach.is_not_found(result) || is_off_path(pnpm_reach.PNPM)
74
+ }
75
+
76
+ // Whether this process can start `pnpm`, and which one answers. `josh` runs its checks as
77
+ // `pnpm josh <check>` children, so a `pnpm` this process cannot see fails every one of them before
78
+ // it starts; asking here is what lets a user learn it before a gate does. `missing` is
79
+ // kept apart from `unknown`: a pnpm that is on PATH and failed is a different fault with a
80
+ // different fix.
81
+ function probe_pnpm(): PnpmProbe {
82
+ const result = execaSync(pnpm_reach.PNPM, ['--version'], {
83
+ reject: false,
84
+ timeout: COMMAND_TIMEOUT_MS,
85
+ })
86
+ const version = read_spawn_stdout(result).trim()
87
+ if (version !== '' && result.exitCode === 0) return { state: 'found', version }
88
+
89
+ return is_pnpm_missing(result) ? { state: 'missing' } : UNKNOWN_PNPM
90
+ }
91
+
45
92
  // Whether the working directory is provably *outside* a git work tree. Purely local — no network,
46
93
  // and it does not depend on `gh` being installed or authenticated, which is what makes it usable to
47
94
  // decide whether a repository-scoped check applies at all.
@@ -192,7 +239,11 @@ function has_auto_merge_workflow(start: string, boundary?: string): boolean {
192
239
  type GitTopLevel =
193
240
  { state: 'inside'; top_level: string } | { state: 'outside' } | { state: 'undetermined' }
194
241
 
242
+ // What asking `pnpm --version` from this process established.
243
+ type PnpmProbe = { state: 'found'; version: string } | { state: 'missing' } | { state: 'unknown' }
244
+
195
245
  const doctor_io = {
246
+ probe_pnpm,
196
247
  resolve_path_josh,
197
248
  resolve_pnpm_global_josh,
198
249
  has_distributed_dependabot_config,
@@ -200,5 +251,5 @@ const doctor_io = {
200
251
  resolve_git_top_level,
201
252
  }
202
253
 
203
- export type { GitTopLevel }
254
+ export type { GitTopLevel, PnpmProbe }
204
255
  export { doctor_io }
@@ -5,12 +5,13 @@ import { fileURLToPath } from 'node:url'
5
5
  import { repo_discovery } from '#scripts/discovery/repo-discovery'
6
6
  import { gh_spawn } from '#scripts/gh/gh-spawn'
7
7
  import { PROJECT_ROOT } from '#scripts/init/init-paths'
8
+ import { pnpm_reach } from '#scripts/lib/pnpm-reach'
8
9
  import { auto_merge_setting } from '#scripts/repo/auto-merge-setting'
9
10
  import { ruleset_check } from '#scripts/repo/ruleset-check'
10
11
  import { security_updates } from '#scripts/security/security-updates'
11
12
  import { running_binary } from '#scripts/version/running-binary'
12
13
  import { doctor_consumer } from './doctor-consumer'
13
- import { doctor_io, type GitTopLevel } from './doctor-io'
14
+ import { doctor_io, type GitTopLevel, type PnpmProbe } from './doctor-io'
14
15
  import { doctor_logic } from './doctor-logic'
15
16
  import { doctor_ports } from './doctor-ports'
16
17
 
@@ -43,10 +44,12 @@ interface DoctorContext {
43
44
  running_path: string
44
45
  path_josh: string | undefined
45
46
  global_josh: string | undefined
47
+ pnpm: PnpmProbe
46
48
  }
47
49
 
48
50
  function gather_context(): DoctorContext {
49
51
  return {
52
+ pnpm: doctor_io.probe_pnpm(),
50
53
  running_version: running_binary.read_running_version(SELF_DIR),
51
54
  running_path: running_binary.running_package_directory(SELF_DIR),
52
55
  path_josh: doctor_io.resolve_path_josh(),
@@ -54,13 +57,28 @@ function gather_context(): DoctorContext {
54
57
  }
55
58
  }
56
59
 
60
+ function describe_pnpm(probe: PnpmProbe): string {
61
+ if (probe.state === 'found') return probe.version
62
+
63
+ return probe.state === 'missing' ? NOT_ON_PATH : UNKNOWN
64
+ }
65
+
57
66
  function print_report(ctx: DoctorContext): void {
58
67
  console.info('josh doctor')
59
68
  console.info(` Running: ${ctx.running_version ?? UNKNOWN} (${ctx.running_path})`)
60
69
  console.info(` On PATH: ${ctx.path_josh ?? NOT_ON_PATH}`)
70
+ console.info(` pnpm: ${describe_pnpm(ctx.pnpm)}`)
61
71
  console.info(` pnpm global: ${ctx.global_josh ?? UNKNOWN}`)
62
72
  }
63
73
 
74
+ // Said here rather than left to the gate: every check `josh` runs is a `pnpm` child, so without
75
+ // pnpm on this process's PATH each of them fails before it starts.
76
+ function report_pnpm_reach(ctx: DoctorContext): void {
77
+ if (ctx.pnpm.state !== 'missing') return
78
+ console.info('')
79
+ console.info(`⚠ ${pnpm_reach.NOT_ON_PATH_NOTICE}`)
80
+ }
81
+
64
82
  // Remove a confirmed stale kit shim so the pnpm-global josh reclaims PATH precedence. Any other
65
83
  // shadowing binary is left in place and reported for manual review.
66
84
  function reclaim_shim(path_josh: string, global_josh: string): void {
@@ -207,6 +225,7 @@ function consumer_root(git: GitTopLevel): string {
207
225
  function run_reports(ctx: DoctorContext, git: GitTopLevel, options: DoctorOptions): void {
208
226
  print_report(ctx)
209
227
  report_path_diagnosis(ctx, options.is_fix)
228
+ report_pnpm_reach(ctx)
210
229
  doctor_consumer.report_consumer_setup(consumer_root(git))
211
230
  report_repository_map(git)
212
231
  if (options.show_ports) report_port_seeds(git)
@@ -74,9 +74,9 @@ const DOCUMENT_BYTE_BUDGET: ReadonlyArray<DocumentBudget> = [
74
74
  { path: '.claude/skills/workflow-commands/target-repository.md', bytes: 4096 },
75
75
  { path: '.claude/skills/workflow-commands/working-tree-hold.md', bytes: 8192 },
76
76
  { path: 'docs/josh-commands-automation.md', bytes: 53_248 },
77
- { path: 'docs/josh-commands-run.md', bytes: 69_632 },
78
- { path: 'docs/josh-commands-backlog.md', bytes: 57_344 },
79
- { path: 'docs/josh-commands.md', bytes: 49_152 },
77
+ { path: 'docs/josh-commands-run.md', bytes: 77_824 },
78
+ { path: 'docs/josh-commands-backlog.md', bytes: 61_440 },
79
+ { path: 'docs/josh-commands.md', bytes: 57_344 },
80
80
  { path: 'docs/labels-and-run-states.md', bytes: 8192 },
81
81
  { path: 'prompts/coding-standards.md', bytes: 8192 },
82
82
  { path: 'prompts/collaboration-workflow.md', bytes: 8192 },
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { git_gh_command } from '#scripts/gh/git-gh-command'
4
+ import { issue_label_schema } from '#scripts/git/git-schemas'
4
5
  import { cutoff_of, type ScanCutoff } from '#scripts/git/listing-cutoff'
5
6
  import { parse_json_array_or_undefined } from '#scripts/git/parse-json-array'
6
7
  import { issue_cite } from '#scripts/issue/issue-cite'
@@ -14,6 +15,7 @@ import {
14
15
  type BacklogIssue,
15
16
  type BundleAction,
16
17
  type BundleDecision,
18
+ type MembershipContext,
17
19
  } from './epic-bundle'
18
20
  import { epic_bundle_evidence } from './epic-bundle-evidence'
19
21
  import { epic_bundle_gaps } from './epic-bundle-gaps'
@@ -53,6 +55,7 @@ const UNKNOWN_REPO_MESSAGE =
53
55
  // describing one it does.
54
56
  const backlog_schema = epic_schema.extend({
55
57
  title: z.string().nullable(),
58
+ labels: z.array(issue_label_schema).default([]),
56
59
  blocked_by_count: z.number().nullish(),
57
60
  })
58
61
 
@@ -62,10 +65,6 @@ type BacklogRow = z.infer<typeof backlog_schema>
62
65
  // task-list read would answer differently the first time that shape moved.
63
66
  const { build_epic_index } = epic_index
64
67
 
65
- function to_epic_field(epic: number | undefined): { epic?: number } {
66
- return epic === undefined ? {} : { epic }
67
- }
68
-
69
68
  // Undefined when the read failed, which the caller reports — as opposed to an issue that genuinely
70
69
  // declares no relations, which is an empty array.
71
70
  async function epic_issue_relations(
@@ -139,18 +138,19 @@ interface FetchedBacklog {
139
138
  // One listing row as the bundler reads it. Split from the fetch so that function stays a list of
140
139
  // requests rather than a request list plus a row mapping.
141
140
  function to_backlog_issue(
142
- row: { number: number; title?: string | null; body?: string | null },
143
- context: { repo: string; epics: Map<number, number>; epic_numbers: ReadonlySet<number> },
141
+ row: Pick<BacklogRow, 'number' | 'title' | 'body' | 'labels'>,
142
+ context: MembershipContext & { repo: string },
144
143
  blocked_by: ReadonlyArray<IssueReference>,
145
144
  ): BacklogIssue {
145
+ const labels = row.labels.map((label) => label.name)
146
+
146
147
  return {
147
148
  number: row.number,
148
149
  repo: context.repo,
149
150
  title: row.title ?? '',
150
151
  body: row.body ?? '',
151
152
  blocked_by,
152
- is_epic: context.epic_numbers.has(row.number),
153
- ...to_epic_field(context.epics.get(row.number)),
153
+ ...epic_bundle.membership_of(row.number, labels, context),
154
154
  }
155
155
  }
156
156