@joshuafolkken/kit 1.1383.0 → 1.1435.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/.claude/skills/epic-commands/SKILL.md +1 -1
  2. package/.claude/skills/epic-commands/epic-bundle.md +5 -0
  3. package/.claude/skills/workflow-commands/backlogrun-lanes.md +5 -3
  4. package/.claude/skills/workflow-commands/backlogrun-steps.md +4 -3
  5. package/.claude/skills/workflow-commands/backlogrun.md +1 -0
  6. package/.claude/skills/workflow-commands/chain-rule.md +4 -2
  7. package/.claude/skills/workflow-commands/issue-comments.md +6 -0
  8. package/.claude/skills/workflow-commands/observation-ledger.md +3 -2
  9. package/.claude/skills/workflow-commands/pre-gate-cut.md +5 -3
  10. package/README.md +25 -0
  11. package/dist/hooks/audit-provision.js +2 -2
  12. package/dist/hooks/batch-guard.js +7 -7
  13. package/dist/hooks/{chunk-LOJTBU5V.js → chunk-2G5F2YCD.js} +3 -3
  14. package/dist/hooks/{chunk-4MGHBTOM.js → chunk-3LE6VLSR.js} +25 -8
  15. package/dist/hooks/{chunk-Y5R47QHU.js → chunk-4BHUPFKF.js} +1 -1
  16. package/dist/hooks/{chunk-KC6LUZZ2.js → chunk-5SJSSCJG.js} +1 -1
  17. package/dist/hooks/{chunk-FZ6FUSBM.js → chunk-723HKZO5.js} +1 -1
  18. package/dist/hooks/{chunk-RYP3TSUJ.js → chunk-7KMN5QB5.js} +3 -3
  19. package/dist/hooks/{chunk-NEC4AT4Z.js → chunk-DGT54IH5.js} +4 -0
  20. package/dist/hooks/{chunk-YKUITFT7.js → chunk-EMB55BDF.js} +4 -4
  21. package/dist/hooks/{chunk-JBBMAPC4.js → chunk-IX6TTTI6.js} +4 -0
  22. package/dist/hooks/{chunk-6MAW7BCM.js → chunk-JXQBK323.js} +2677 -190
  23. package/dist/hooks/{chunk-KCF2YE7O.js → chunk-MSYSUSY6.js} +5 -5
  24. package/dist/hooks/{chunk-LC56R53A.js → chunk-MU4LJFIV.js} +4 -4
  25. package/dist/hooks/{chunk-7VSZ2N4B.js → chunk-QL6QTPWN.js} +3 -3
  26. package/dist/hooks/{chunk-TT742SIO.js → chunk-XER6SSIB.js} +92 -19
  27. package/dist/hooks/{chunk-2X2ART5G.js → chunk-YW452ZAX.js} +500 -29
  28. package/dist/hooks/{chunk-6AC4C2IO.js → chunk-YZXYOAZ3.js} +187 -443
  29. package/dist/hooks/codex-hook-adapter.js +15 -15
  30. package/dist/hooks/duplicate-read-guard.js +8 -8
  31. package/dist/hooks/format-edited.js +5 -5
  32. package/dist/hooks/inputs.json +262 -201
  33. package/dist/hooks/investigation-guard.js +8 -8
  34. package/dist/hooks/pretool-guard.js +14 -14
  35. package/dist/hooks/session-lang.js +124 -15
  36. package/dist/hooks/stop-guard.js +98 -55
  37. package/dist/josh.js +63 -10
  38. package/package.json +4 -3
  39. package/prompts/review.md +22 -0
  40. package/scripts/agent/claude-agent-argv.ts +13 -1
  41. package/scripts/auth/auth-setup-cli.ts +101 -0
  42. package/scripts/auth/claude-token-output.ts +78 -0
  43. package/scripts/auth/claude-token.ts +68 -0
  44. package/scripts/backlog/backlog-drive-cli.ts +2 -4
  45. package/scripts/backlog/backlog-drive-epic.ts +24 -8
  46. package/scripts/backlog/backlog-drive-launch.ts +5 -0
  47. package/scripts/backlog/backlog-drive-named-offer.ts +7 -2
  48. package/scripts/backlog/backlog-drive-named.ts +4 -2
  49. package/scripts/backlog/backlog-drive-owner.ts +72 -5
  50. package/scripts/backlog/backlog-drive-unread.ts +56 -0
  51. package/scripts/backlog/backlog-drive.ts +16 -8
  52. package/scripts/backlog/backlog-overlap.ts +22 -5
  53. package/scripts/backlog/backlog-ready.ts +1 -3
  54. package/scripts/backlog/backlogrun-args.ts +13 -2
  55. package/scripts/backlog/backlogrun-cli.ts +91 -19
  56. package/scripts/backlog/backlogrun-launch.ts +80 -5
  57. package/scripts/backlog/backlogrun-replace.ts +166 -0
  58. package/scripts/backlog/backlogrun-stop.ts +178 -0
  59. package/scripts/behavior/behavior-cli.ts +6 -1
  60. package/scripts/doctor/doctor-io.ts +60 -9
  61. package/scripts/doctor/doctor.ts +20 -1
  62. package/scripts/document/document-byte-budget.ts +3 -3
  63. package/scripts/epic/epic-bundle-cli.ts +8 -8
  64. package/scripts/epic/epic-bundle-referenced.ts +2 -5
  65. package/scripts/epic/epic-bundle.ts +39 -4
  66. package/scripts/exports/unused-members-cli.ts +2 -1
  67. package/scripts/followup/followup-checks-event.ts +82 -0
  68. package/scripts/followup/git-followup-finish.ts +16 -2
  69. package/scripts/followup/git-followup-workflow.ts +5 -2
  70. package/scripts/followup/git-pr-followup-checks.ts +18 -4
  71. package/scripts/followup/git-pr-followup-wrapup.ts +5 -0
  72. package/scripts/followup/git-pr-followup.ts +4 -1
  73. package/scripts/fresh-machine/fresh-machine-cli.ts +204 -0
  74. package/scripts/fresh-machine/fresh-machine-docker.ts +65 -0
  75. package/scripts/fresh-machine/fresh-machine-image.ts +62 -0
  76. package/scripts/fresh-machine/fresh-machine-log.ts +61 -0
  77. package/scripts/fresh-machine/fresh-machine-patterns.ts +130 -0
  78. package/scripts/fresh-machine/fresh-machine-run.ts +215 -0
  79. package/scripts/fresh-machine/fresh-machine-script.ts +45 -0
  80. package/scripts/fresh-machine/fresh-machine-spec.ts +57 -0
  81. package/scripts/fresh-machine/specs/kit-test-backlogrun-python.json +34 -0
  82. package/scripts/fresh-machine/specs/kit-test-backlogrun.json +31 -0
  83. package/scripts/gate/gate-report.ts +70 -15
  84. package/scripts/gate/project-checks.ts +13 -2
  85. package/scripts/gate/scoped-green.ts +2 -1
  86. package/scripts/gate/verification-gate.ts +4 -3
  87. package/scripts/gh/gh-author-association.ts +14 -0
  88. package/scripts/gh/git-gh-conditional.ts +66 -0
  89. package/scripts/gh/git-gh-exec.ts +1 -1
  90. package/scripts/gh/git-gh-issue-read.ts +6 -3
  91. package/scripts/gh/git-gh-issue-write.ts +5 -1
  92. package/scripts/gh/git-gh-issue.ts +4 -1
  93. package/scripts/gh/git-pr-checks-eval.ts +13 -0
  94. package/scripts/gh/git-pr-checks-parse.ts +8 -1
  95. package/scripts/gh/git-pr-checks-watch.ts +11 -4
  96. package/scripts/gh/git-pr-checks.ts +36 -2
  97. package/scripts/git/git-command.ts +16 -4
  98. package/scripts/git/git-schemas.ts +1 -0
  99. package/scripts/git/git-spawn-sync.ts +1 -1
  100. package/scripts/hooks/pretool-guard.ts +3 -1
  101. package/scripts/init/hook-command-rewrite.ts +31 -0
  102. package/scripts/init/init-actions.ts +4 -1
  103. package/scripts/init/init-ai-copy.ts +33 -10
  104. package/scripts/init/init-basic.ts +8 -3
  105. package/scripts/init/init-logic-toolchain.ts +76 -0
  106. package/scripts/init/init-logic.ts +2 -0
  107. package/scripts/init/init.ts +5 -23
  108. package/scripts/init/project-profile.ts +36 -7
  109. package/scripts/init/start-exec.ts +27 -1
  110. package/scripts/init/start-plan.ts +30 -4
  111. package/scripts/init/start-steps.ts +77 -3
  112. package/scripts/init/start.ts +21 -13
  113. package/scripts/init/workflow-mark.ts +35 -0
  114. package/scripts/issue/issue-ack-cli.ts +84 -0
  115. package/scripts/issue/issue-labels.ts +40 -8
  116. package/scripts/issue/issue-state-cli.ts +14 -4
  117. package/scripts/issue/issue-watch-diff.ts +152 -0
  118. package/scripts/issue/issue-watch-gate.ts +81 -0
  119. package/scripts/issue/issue-watch-hook.ts +54 -0
  120. package/scripts/issue/issue-watch.ts +240 -0
  121. package/scripts/issue/measure-rerun-cli.ts +3 -4
  122. package/scripts/josh/josh-command-map.ts +4 -0
  123. package/scripts/josh/josh-commands-backlog.ts +2 -2
  124. package/scripts/josh/josh-commands-development.ts +1 -1
  125. package/scripts/josh/josh-commands-issue.ts +7 -0
  126. package/scripts/josh/josh-commands-lane.ts +11 -2
  127. package/scripts/josh/josh-commands-project.ts +25 -1
  128. package/scripts/josh/josh-verdict.ts +33 -4
  129. package/scripts/lane/claude-lane-broker.ts +142 -0
  130. package/scripts/lane/lane-await.ts +6 -2
  131. package/scripts/lane/lane-cache.ts +37 -7
  132. package/scripts/lane/lane-capacity.ts +8 -6
  133. package/scripts/lane/lane-child-invocation.ts +1 -0
  134. package/scripts/lane/lane-cli.ts +15 -1
  135. package/scripts/lane/lane-close.ts +15 -6
  136. package/scripts/lane/lane-dispatch.ts +36 -10
  137. package/scripts/lane/lane-environment.ts +1 -1
  138. package/scripts/lane/lane-launch-cli.ts +13 -4
  139. package/scripts/lane/lane-ledger.ts +21 -3
  140. package/scripts/lane/lane-measure-cli.ts +10 -2
  141. package/scripts/lane/lane-open.ts +9 -1
  142. package/scripts/lane/lane-preserve.ts +49 -0
  143. package/scripts/lane/lane-reap.ts +20 -5
  144. package/scripts/lane/lane-registry.ts +6 -0
  145. package/scripts/lane/lane-restart-cli.ts +51 -0
  146. package/scripts/lane/lane-restart.ts +175 -0
  147. package/scripts/lane/{openai-review-broker.ts → lane-session-broker.ts} +106 -28
  148. package/scripts/lane/lane-spent.ts +195 -0
  149. package/scripts/lane/lane-stage-stats.ts +27 -3
  150. package/scripts/lane/lane-stop-stats.ts +96 -0
  151. package/scripts/lane/openai-lane-supervisor.ts +2 -2
  152. package/scripts/lane/process-tools.ts +31 -0
  153. package/scripts/lib/buffered-process.ts +36 -2
  154. package/scripts/lib/pnpm-reach.ts +27 -0
  155. package/scripts/lib/status-icons.ts +5 -1
  156. package/scripts/lint/lint-parallel.ts +19 -2
  157. package/scripts/observations/observation-ledger-home.ts +1 -0
  158. package/scripts/review/review-attest-cli.ts +21 -6
  159. package/scripts/review/review-brief-cli.ts +2 -2
  160. package/scripts/review/review-comment-round.ts +114 -0
  161. package/scripts/review/review-record-cli.ts +39 -17
  162. package/scripts/rules/implementation-cut.ts +3 -3
  163. package/scripts/rules/pre-gate-cut.ts +1 -1
  164. package/scripts/run/board/run-board-checks.ts +42 -0
  165. package/scripts/run/board/run-board-cli.ts +2 -0
  166. package/scripts/run/board/run-board-every.ts +3 -1
  167. package/scripts/run/board/run-board-fit.ts +16 -5
  168. package/scripts/run/board/run-board-fresh.ts +64 -0
  169. package/scripts/run/board/run-board-header.ts +139 -37
  170. package/scripts/run/board/run-board-labels.ts +47 -4
  171. package/scripts/run/board/run-board-lane-event.ts +66 -0
  172. package/scripts/run/board/run-board-layout.ts +57 -9
  173. package/scripts/run/board/run-board-now.ts +234 -0
  174. package/scripts/run/board/run-board-operation-read.ts +75 -0
  175. package/scripts/run/board/run-board-operation.ts +150 -0
  176. package/scripts/run/board/run-board-phase.ts +26 -1
  177. package/scripts/run/board/run-board-plan.ts +4 -6
  178. package/scripts/run/board/run-board-read.ts +58 -5
  179. package/scripts/run/board/run-board-reason.ts +124 -0
  180. package/scripts/run/board/run-board-render-checks.ts +55 -0
  181. package/scripts/run/board/run-board-render-legend.ts +12 -3
  182. package/scripts/run/board/run-board-render-slot.ts +2 -2
  183. package/scripts/run/board/run-board-render-tail.ts +128 -0
  184. package/scripts/run/board/run-board-render-thanks.ts +50 -0
  185. package/scripts/run/board/run-board-render-title.ts +58 -0
  186. package/scripts/run/board/run-board-render.ts +96 -80
  187. package/scripts/run/board/run-board-row-yield.ts +32 -0
  188. package/scripts/run/board/run-board-setup.ts +121 -0
  189. package/scripts/run/board/run-board-span.ts +49 -7
  190. package/scripts/run/board/run-board-state.ts +10 -0
  191. package/scripts/run/board/run-board-status.ts +76 -13
  192. package/scripts/run/board/run-board-thanks-csv.ts +71 -0
  193. package/scripts/run/board/run-board-thanks-read.ts +112 -0
  194. package/scripts/run/board/run-board-thanks.ts +53 -0
  195. package/scripts/run/board/run-board-tick.ts +53 -16
  196. package/scripts/run/board/run-board-usual.ts +44 -0
  197. package/scripts/run/carry/run-carry-added.ts +1 -1
  198. package/scripts/run/carry/run-carry-args.ts +9 -1
  199. package/scripts/run/carry/run-carry-change.ts +1 -1
  200. package/scripts/run/carry/run-carry-cli.ts +39 -52
  201. package/scripts/run/carry/run-carry-end.ts +73 -0
  202. package/scripts/run/carry/run-carry-ended.ts +9 -1
  203. package/scripts/run/carry/run-carry.ts +24 -1
  204. package/scripts/run/entry/run-entry-cli.ts +5 -0
  205. package/scripts/run/event/run-event-checks.ts +54 -0
  206. package/scripts/run/event/run-event-render.ts +3 -0
  207. package/scripts/run/event/run-event-scope.ts +1 -0
  208. package/scripts/run/event/run-event-setup.ts +91 -0
  209. package/scripts/run/event/run-event-stream.ts +46 -9
  210. package/scripts/run/merge/run-merge-cli.ts +7 -4
  211. package/scripts/run/merge/run-merge-ship.ts +61 -0
  212. package/scripts/run/merge/run-merge-steps.ts +7 -14
  213. package/scripts/run/merge/run-merge-token.ts +16 -2
  214. package/scripts/run/run-headless.ts +1 -0
  215. package/scripts/run/run-step.ts +2 -0
  216. package/scripts/run/run-stranded-detect.ts +58 -12
  217. package/scripts/run/ship/run-ship-cli.ts +22 -2
  218. package/scripts/run/ship/run-ship-return.ts +31 -13
  219. package/scripts/run/ship/run-ship-review-steps.ts +17 -4
  220. package/scripts/run/ship/run-ship-stage.ts +1 -0
  221. package/scripts/run/wake/run-wake-await.ts +130 -0
  222. package/scripts/run/wake/run-wake-cli.ts +108 -74
  223. package/scripts/run/wake/run-wake-describe.ts +36 -2
  224. package/scripts/run/wake/run-wake-driver.ts +21 -5
  225. package/scripts/run/wake/run-wake-handoff.ts +1 -1
  226. package/scripts/run/wake/run-wake-session.ts +20 -3
  227. package/scripts/run/wake/run-wake-stop.ts +37 -0
  228. package/scripts/run/wake/run-wake-supervise.ts +44 -0
  229. package/scripts/sync/claude-settings-merge.ts +144 -0
  230. package/scripts/sync/sync-ai-files.ts +28 -3
  231. package/scripts/sync/sync.ts +15 -0
  232. package/scripts/test/project-test-script.ts +73 -0
  233. package/scripts/test/skip-marker.ts +14 -1
  234. package/scripts/test/test-related.ts +3 -2
  235. package/scripts/test/test-unit-guard.ts +78 -14
  236. package/scripts/time-runtime/time-reported-failure.ts +11 -3
  237. package/scripts/uninstall/distributed-files.ts +92 -0
  238. package/scripts/uninstall/uninstall-plan.ts +119 -0
  239. package/scripts/uninstall/uninstall-report.ts +61 -0
  240. package/scripts/uninstall/uninstall.ts +206 -0
  241. package/templates/AGENTS.basic.md +4 -0
  242. package/templates/GEMINI.basic.md +6 -0
  243. package/dist/hooks/chunk-6V5OPIUU.js +0 -13
  244. package/scripts/run/board/run-board-track.ts +0 -22
@@ -78,9 +78,8 @@ interface SeparateContext {
78
78
  repo: string
79
79
  }
80
80
 
81
- // The path each running lane declares, mapped to the issue that holds it.
82
- function running_claims(declared: Declared, read: BusyRead): Map<string, number> {
83
- const holders = read.kind === 'busy' ? read.issues.map((issue) => issue.number) : []
81
+ // The path each of `holders` declares, mapped to the issue that holds it.
82
+ function claims_of(declared: Declared, holders: ReadonlyArray<number>): Map<string, number> {
84
83
  const pairs = holders.flatMap((number) =>
85
84
  [...(declared.get(number) ?? [])].map((path): [string, number] => [path, number]),
86
85
  )
@@ -88,6 +87,10 @@ function running_claims(declared: Declared, read: BusyRead): Map<string, number>
88
87
  return new Map(pairs)
89
88
  }
90
89
 
90
+ function running_claims(declared: Declared, read: BusyRead): Map<string, number> {
91
+ return claims_of(declared, read.kind === 'busy' ? read.issues.map((issue) => issue.number) : [])
92
+ }
93
+
91
94
  function paths_of(child: EpicChild, declared: Declared, repo: string): ReadonlySet<string> {
92
95
  if (child.repo !== repo) return new Set()
93
96
 
@@ -113,6 +116,20 @@ function clash_of(
113
116
  return clashes[0]
114
117
  }
115
118
 
119
+ // The running issues a candidate is weighed against, and the repository they are in.
120
+ interface Holding {
121
+ holders: ReadonlyArray<number>
122
+ repo: string
123
+ }
124
+
125
+ // The path `child` declares that one of the running `holders` already declares — the clash `separate`
126
+ // withholds a candidate on, for a reader that names the running issues itself (`run:board`).
127
+ function running_clash(child: EpicChild, declared: Declared, holding: Holding): Clash | undefined {
128
+ const claims = claims_of(declared, holding.holders)
129
+
130
+ return clash_of(claims, paths_of(child, declared, holding.repo))
131
+ }
132
+
116
133
  function clash_note(child: EpicChild, clash: Clash): string {
117
134
  const waiting = session_cite.issue(child.number, undefined, child.repo)
118
135
  const holder = session_cite.issue(clash.holder, undefined, child.repo)
@@ -172,7 +189,7 @@ function separate(
172
189
  return { offered: walk.offered, withheld, ...(notice !== undefined && { notice }) }
173
190
  }
174
191
 
175
- const backlog_overlap = { declared_of, declared_paths, separate }
192
+ const backlog_overlap = { declared_of, declared_paths, running_clash, separate }
176
193
 
177
194
  export { backlog_overlap }
178
- export type { Declared }
195
+ export type { Clash, Declared }
@@ -25,9 +25,7 @@ interface ReadyPorts {
25
25
 
26
26
  // The lanes in use: the open worktrees an interruption has not stranded. `josh lane:limit` prints it.
27
27
  async function live_lane_count(): Promise<number> {
28
- const lanes = await lane_registry.list_lanes()
29
-
30
- return lanes.filter((lane) => !lane.is_stranded).length
28
+ return lane_registry.live_count(await lane_registry.list_lanes())
31
29
  }
32
30
 
33
31
  async function free_under(limit: number): Promise<number> {
@@ -9,8 +9,12 @@ import { run_invocation } from '#scripts/run/run-invocation'
9
9
  //
10
10
  // **A bare number is an issue.** A shell reads an unquoted `#3437` as a comment, so `3437` in the named
11
11
  // list is taken as `#3437`; a number after the first flag is that flag's value and is left alone.
12
+ //
13
+ // **`--stop` stands alone.** It stops the run rather than starting one, so beside an issue or any other
14
+ // flag it is refused before anything is stopped — a mistyped start must never end a run.
12
15
 
13
16
  const AGENT_FLAG = '--agent'
17
+ const STOP_FLAG = '--stop'
14
18
  const BACKLOG_COMMAND = 'backlogrun'
15
19
  const ISSUE_PREFIX = '#'
16
20
  const FLAG_PREFIX = '-'
@@ -26,7 +30,8 @@ interface BacklogrunArguments {
26
30
  issues: ReadonlyArray<number>
27
31
  }
28
32
 
29
- type ArgumentsResult = { kind: 'args'; args: BacklogrunArguments } | { kind: 'refused' }
33
+ type ArgumentsResult =
34
+ { kind: 'args'; args: BacklogrunArguments } | { kind: 'stop' } | { kind: 'refused' }
30
35
 
31
36
  interface AgentSplit {
32
37
  agent: string | undefined
@@ -56,7 +61,7 @@ function normalized(rest: ReadonlyArray<string>): Array<string> {
56
61
  return [...rest.slice(0, split_at).map((token) => issue_token(token)), ...rest.slice(split_at)]
57
62
  }
58
63
 
59
- function parse(argv: ReadonlyArray<string>): ArgumentsResult {
64
+ function parse_start(argv: ReadonlyArray<string>): ArgumentsResult {
60
65
  const { agent, rest } = split_agent(argv)
61
66
  const provider = agent === undefined ? undefined : AGENTS[agent]
62
67
  const invocation = run_invocation.rebuild([BACKLOG_COMMAND, ...normalized(rest)].join(' '))
@@ -68,6 +73,12 @@ function parse(argv: ReadonlyArray<string>): ArgumentsResult {
68
73
  return { kind: 'args', args: { provider, invocation, issues } }
69
74
  }
70
75
 
76
+ function parse(argv: ReadonlyArray<string>): ArgumentsResult {
77
+ if (!argv.includes(STOP_FLAG)) return parse_start(argv)
78
+
79
+ return { kind: argv.length === 1 ? 'stop' : 'refused' }
80
+ }
81
+
71
82
  const backlogrun_args = { parse }
72
83
 
73
84
  export type { BacklogrunArguments }
@@ -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
  }
@@ -11,6 +11,7 @@ import { stamp_file } from '#scripts/josh/stamp-file'
11
11
  import { json_value } from '#scripts/lib/json-value'
12
12
  import { run_carry, type CarryRead } from '#scripts/run/carry/run-carry'
13
13
  import { detached_launch, type LaunchResult } from '#scripts/run/detached-launch'
14
+ import { run_event_setup } from '#scripts/run/event/run-event-setup'
14
15
  import { run_headless } from '#scripts/run/run-headless'
15
16
  import { z } from 'zod'
16
17
 
@@ -34,10 +35,20 @@ const launch_schema = z.object({
34
35
  pid: z.number(),
35
36
  start: z.string().optional(),
36
37
  session: z.string().optional(),
38
+ // What the parent was started to run and when, so `run:board` has a run to draw and a clock to count
39
+ // before the parent claims the carry record. Absent on a record an earlier version wrote.
40
+ invocation: z.string().optional(),
41
+ started_at: z.string().optional(),
37
42
  })
38
43
 
39
44
  type LaunchRecord = z.infer<typeof launch_schema>
40
45
 
46
+ // A launched run the parent has not claimed yet, as far as its record says.
47
+ interface PendingLaunch {
48
+ invocation: string
49
+ started_at: string
50
+ }
51
+
41
52
  interface LaunchTarget {
42
53
  // The checkout the parent runs in, and its common git directory the records are keyed on.
43
54
  worktree: string
@@ -117,10 +128,71 @@ function note_to_stderr(note: string): void {
117
128
  console.error(`backlogrun: ${note}`)
118
129
  }
119
130
 
120
- function record_launch(git_directory: string, pid: number, session: string | undefined): void {
121
- const started = process_identity.read_start(pid)
131
+ // **The record and the run's first stage carry one moment**, so the `starting session` event is never
132
+ // dated before the start the board measures from.
133
+ function record_launch(
134
+ git_directory: string,
135
+ launch: Pick<LaunchRecord, 'pid' | 'session' | 'invocation'>,
136
+ ): void {
137
+ const now = new Date()
138
+ const identity = process_identity.read_start(launch.pid)
139
+ const record: LaunchRecord = { ...launch, start: identity, started_at: now.toISOString() }
140
+
141
+ stamp_file.replace_stamp(launch_path(git_directory), record)
142
+ run_event_setup.emit_in(git_directory, run_event_setup.STAGE.STARTING, now)
143
+ }
144
+
145
+ // The live launch record here, `undefined` once the parent it names is gone.
146
+ function live_launch(git_directory: string): LaunchRecord | undefined {
147
+ const record = read_launch(launch_path(git_directory))
148
+
149
+ return is_launch_live(record) ? record : undefined
150
+ }
151
+
152
+ // The launch `run:board` draws while no carry record stands: one whose record says what it runs and
153
+ // since when.
154
+ function pending_launch(git_directory: string): PendingLaunch | undefined {
155
+ const { invocation, started_at } = live_launch(git_directory) ?? {}
156
+
157
+ if (invocation === undefined || started_at === undefined) return undefined
158
+
159
+ return { invocation, started_at }
160
+ }
161
+
162
+ // When the launch that started `owner_pid` began, for `run:carry --begin` to start the run's clock
163
+ // from: **only the parent this launch started inherits it**, so a run typed inside a session — which has
164
+ // no launch record, or another parent's — still starts at its own `--begin`.
165
+ function started_for(git_directory: string, owner_pid: number | undefined): Date | undefined {
166
+ const record = live_launch(git_directory)
167
+
168
+ if (record?.started_at === undefined || record.pid !== owner_pid) return undefined
169
+
170
+ const started_ms = Date.parse(record.started_at)
171
+
172
+ return Number.isNaN(started_ms) ? undefined : new Date(started_ms)
173
+ }
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)
122
194
 
123
- stamp_file.replace_stamp(launch_path(git_directory), { pid, start: started, session })
195
+ return record.pid
124
196
  }
125
197
 
126
198
  // The headless mark holds the parent's turn open while its lanes run, as `run:wake`'s does. The launch
@@ -160,7 +232,7 @@ function start(invocation: string, provider: AgentProvider, target: LaunchTarget
160
232
 
161
233
  if (result.kind === 'failed') return result
162
234
 
163
- record_launch(target.git_directory, result.pid, session)
235
+ record_launch(target.git_directory, { pid: result.pid, session, invocation })
164
236
 
165
237
  const launched = { pid: result.pid, session, log_path: log_path(target.git_directory) }
166
238
 
@@ -171,8 +243,11 @@ const backlogrun_launch = {
171
243
  argv_of,
172
244
  chosen_environment,
173
245
  is_running,
246
+ pending_launch,
174
247
  start,
248
+ started_for,
249
+ stop_parent,
175
250
  }
176
251
 
177
- export type { Launched, LaunchTarget, StartResult }
252
+ export type { Launched, LaunchTarget, PendingLaunch, StartResult }
178
253
  export { backlogrun_launch }
@@ -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 }