@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
@@ -1,27 +1,41 @@
1
1
  #!/usr/bin/env tsx
2
2
  import path from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
+ import type { AgentProvider } from '#scripts/agent/agent-role-profile'
4
5
  import { josh_environment_file } from '#scripts/josh/josh-environment-file'
5
6
  import { run_board_cli } from '#scripts/run/board/run-board-cli'
6
7
  import { run_carry } from '#scripts/run/carry/run-carry'
8
+ import type { LaunchResult } from '#scripts/run/detached-launch'
9
+ import { run_wake_cli } from '#scripts/run/wake/run-wake-cli'
7
10
  import { backlogrun_args, type BacklogrunArguments } from './backlogrun-args'
8
11
  import { backlogrun_launch, type LaunchTarget, type StartResult } from './backlogrun-launch'
12
+ import { backlogrun_replace, type ReplaceVerdict } from './backlogrun-replace'
13
+ import { backlogrun_stop, type StopReport } from './backlogrun-stop'
9
14
 
10
- // `josh backlogrun` — start a `backlogrun` from the terminal and watch it. A
15
+ // `josh backlogrun` — start a `backlogrun` from the terminal and watch it, or stop it with `--stop`. A
11
16
  // run already going is never joined by a second one: the board is shown, and any issue named is pointed
12
17
  // at `run:add` rather than added behind the person's back. Otherwise the agent is started in the
13
18
  // background, its output to a log file, and the board takes over the terminal at once. Closing the
14
19
  // board closes only the board.
20
+ //
21
+ // **An idle run gives way to a different invocation.** A run watching with no child at work in a lane
22
+ // is stopped as `--stop` stops it and the typed one is started in its place (`backlogrun-replace.ts`);
23
+ // a run that is kept says why in one line above the board. The same invocation only shows the board,
24
+ // so looking at a run never rebuilds it.
15
25
 
16
26
  const ARGV_OFFSET = 2
27
+ const SUCCESS_EXIT_CODE = 0
17
28
  const FAILURE_EXIT_CODE = 1
18
29
  const USAGE =
19
- 'Usage: josh backlogrun [#<n>...] [--only] [--max <n>] [--idle <minutes>] [--agent claude|codex]'
30
+ 'Usage: josh backlogrun [<n>...] [--only] [--max <n>] [--idle <minutes>] [--agent claude|codex] | --stop'
20
31
 
21
32
  interface BacklogrunPorts {
22
33
  repository_directory: () => Promise<string | undefined>
23
34
  is_running: (git_directory: string) => boolean
35
+ replacement: (invocation: string, git_directory: string) => Promise<ReplaceVerdict>
24
36
  start: (args: BacklogrunArguments, target: LaunchTarget) => StartResult
37
+ start_supervisor: (git_directory: string, provider: AgentProvider) => LaunchResult
38
+ stop: (git_directory: string) => Promise<StopReport>
25
39
  show_board: () => Promise<number>
26
40
  write: (line: string) => void
27
41
  }
@@ -29,7 +43,11 @@ interface BacklogrunPorts {
29
43
  const LIVE_PORTS: BacklogrunPorts = {
30
44
  repository_directory: run_carry.repository_directory,
31
45
  is_running: backlogrun_launch.is_running,
46
+ replacement: async (invocation, git_directory) =>
47
+ await backlogrun_replace.decide(invocation, git_directory),
32
48
  start: (args, target) => backlogrun_launch.start(args.invocation, args.provider, target),
49
+ start_supervisor: run_wake_cli.start_for_launch,
50
+ stop: async (git_directory) => await backlogrun_stop.stop(git_directory),
33
51
  show_board: async () => await run_board_cli.run([]),
34
52
  write: (line) => process.stdout.write(`${line}\n`),
35
53
  }
@@ -41,6 +59,12 @@ function running_notes(issues: ReadonlyArray<number>): Array<string> {
41
59
  return ['backlogrun: a run is already going; showing its board.', ...guidance]
42
60
  }
43
61
 
62
+ // Why a different invocation did not replace the run, ahead of the board. The same invocation is the
63
+ // board reopened, and adds nothing.
64
+ function kept_notes(verdict: ReplaceVerdict): Array<string> {
65
+ return verdict.kind === 'keep' ? [`backlogrun: not replacing the run — ${verdict.reason}`] : []
66
+ }
67
+
44
68
  function launched_note(result: Extract<StartResult, { kind: 'launched' }>): string {
45
69
  const { pid, session, log_path } = result.launched
46
70
  const session_part = session === undefined ? '' : `, session ${session}`
@@ -48,22 +72,26 @@ function launched_note(result: Extract<StartResult, { kind: 'launched' }>): stri
48
72
  return `backlogrun: started (pid ${String(pid)}${session_part}); log ${log_path}`
49
73
  }
50
74
 
75
+ // **The supervisor is started here, beside the parent, never from the parent's own shell**: Claude
76
+ // Code removes its token from what its Bash tool starts, so only this process can hand a token-only
77
+ // credential on to the sessions the supervisor wakes (joshuafolkken/kit#3804). A supervisor that does
78
+ // not start leaves the parent's own `run:wake --start` to try, so the run is still shown.
79
+ function supervisor_note(result: LaunchResult): string {
80
+ if (result.kind === 'failed') return `backlogrun: could not start the supervisor: ${result.note}`
81
+
82
+ return `backlogrun: supervisor started (pid ${String(result.pid)})`
83
+ }
84
+
51
85
  // The agent starts in the main checkout, the parent of the common git directory every record is keyed on.
52
86
  function target_of(git_directory: string): LaunchTarget {
53
87
  return { worktree: path.dirname(git_directory), git_directory }
54
88
  }
55
89
 
56
- async function begin(
90
+ async function launch(
57
91
  args: BacklogrunArguments,
58
92
  git_directory: string,
59
93
  ports: BacklogrunPorts,
60
94
  ): Promise<number> {
61
- if (ports.is_running(git_directory)) {
62
- for (const note of running_notes(args.issues)) ports.write(note)
63
-
64
- return await ports.show_board()
65
- }
66
-
67
95
  const result = ports.start(args, target_of(git_directory))
68
96
 
69
97
  if (result.kind === 'failed') {
@@ -73,29 +101,73 @@ async function begin(
73
101
  }
74
102
 
75
103
  ports.write(launched_note(result))
104
+ ports.write(supervisor_note(ports.start_supervisor(git_directory, args.provider)))
105
+
106
+ return await ports.show_board()
107
+ }
108
+
109
+ // The idle run is stopped as `--stop` stops it, and a run left with an owner this command may not
110
+ // signal is not replaced: nothing is started beside it.
111
+ async function replace(
112
+ args: BacklogrunArguments,
113
+ git_directory: string,
114
+ ports: BacklogrunPorts,
115
+ ): Promise<number> {
116
+ ports.write(`backlogrun: replacing the idle run with \`${args.invocation}\``)
117
+
118
+ const report = await ports.stop(git_directory)
119
+
120
+ for (const line of backlogrun_stop.lines_of(report)) ports.write(line)
121
+
122
+ if (!backlogrun_stop.is_stopped(report)) return FAILURE_EXIT_CODE
123
+
124
+ return await launch(args, git_directory, ports)
125
+ }
126
+
127
+ async function begin(
128
+ args: BacklogrunArguments,
129
+ git_directory: string,
130
+ ports: BacklogrunPorts,
131
+ ): Promise<number> {
132
+ if (!ports.is_running(git_directory)) return await launch(args, git_directory, ports)
133
+
134
+ const verdict = await ports.replacement(args.invocation, git_directory)
135
+
136
+ if (verdict.kind === 'replace') return await replace(args, git_directory, ports)
137
+
138
+ for (const note of [...kept_notes(verdict), ...running_notes(args.issues)]) ports.write(note)
76
139
 
77
140
  return await ports.show_board()
78
141
  }
79
142
 
143
+ // Stopping starts nothing and draws no board: the lines say what was stopped, and a run left with an
144
+ // owner this command may not signal is a failure the exit code carries.
145
+ async function stop(git_directory: string, ports: BacklogrunPorts): Promise<number> {
146
+ const report = await ports.stop(git_directory)
147
+
148
+ for (const line of backlogrun_stop.lines_of(report)) ports.write(line)
149
+
150
+ return backlogrun_stop.is_stopped(report) ? SUCCESS_EXIT_CODE : FAILURE_EXIT_CODE
151
+ }
152
+
153
+ function refuse(line: string, ports: BacklogrunPorts): number {
154
+ ports.write(line)
155
+
156
+ return FAILURE_EXIT_CODE
157
+ }
158
+
80
159
  async function run(
81
160
  argv: ReadonlyArray<string>,
82
161
  ports: BacklogrunPorts = LIVE_PORTS,
83
162
  ): Promise<number> {
84
163
  const parsed = backlogrun_args.parse(argv)
85
164
 
86
- if (parsed.kind === 'refused') {
87
- ports.write(USAGE)
88
-
89
- return FAILURE_EXIT_CODE
90
- }
165
+ if (parsed.kind === 'refused') return refuse(USAGE, ports)
91
166
 
92
167
  const git_directory = await ports.repository_directory()
93
168
 
94
- if (git_directory === undefined) {
95
- ports.write('backlogrun: not inside a git repository')
96
-
97
- return FAILURE_EXIT_CODE
98
- }
169
+ if (git_directory === undefined) return refuse('backlogrun: not inside a git repository', ports)
170
+ if (parsed.kind === 'stop') return await stop(git_directory, ports)
99
171
 
100
172
  return await begin(parsed.args, git_directory, ports)
101
173
  }
@@ -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
  }