@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
@@ -5,8 +5,8 @@ import { run_carry } from '#scripts/run/carry/run-carry'
5
5
  import { z } from 'zod'
6
6
 
7
7
  // The measurement ledger the lane limit is tuned against: one JSON line per
8
- // lane merge, per finished `josh gate`, per machine-load sample, per lane dispatch and per finished
9
- // `josh ship` stage, read back by `josh lane:stats`.
8
+ // lane merge, per finished `josh gate`, per machine-load sample, per lane dispatch, per finished
9
+ // `josh ship` stage and per stopped `josh ship`, read back by `josh lane:stats`.
10
10
  //
11
11
  // **Its own file rather than the run's event stream.** The stream is capped at 500 events and is
12
12
  // rewritten whole on every append, which is right for "where is the run" and wrong for a measurement
@@ -35,6 +35,7 @@ const KIND = {
35
35
  LOAD: 'load',
36
36
  DISPATCH: 'dispatch',
37
37
  STAGE: 'stage',
38
+ STOP: 'stop',
38
39
  } as const
39
40
 
40
41
  const merge_schema = z.object({ kind: z.literal(KIND.MERGE), at: z.string(), issue: z.number() })
@@ -79,12 +80,22 @@ const stage_schema = z.object({
79
80
  elapsed_ms: z.number(),
80
81
  issue: z.number().optional(),
81
82
  })
83
+ // A `josh ship` that stopped on `issue`: `reason` is the stage it stopped at, or `conflict` — the
84
+ // retrospective's stop reason. How long it stayed stopped is derived from the next ship stage, not
85
+ // recorded, so a resumed ship never has to know the stop it ends.
86
+ const stop_schema = z.object({
87
+ kind: z.literal(KIND.STOP),
88
+ at: z.string(),
89
+ issue: z.number(),
90
+ reason: z.string(),
91
+ })
82
92
  const entry_schema = z.discriminatedUnion('kind', [
83
93
  merge_schema,
84
94
  gate_schema,
85
95
  load_schema,
86
96
  dispatch_schema,
87
97
  stage_schema,
98
+ stop_schema,
88
99
  ])
89
100
 
90
101
  type LedgerEntry = z.infer<typeof entry_schema>
@@ -93,9 +104,11 @@ type GateEntry = z.infer<typeof gate_schema>
93
104
  type LoadEntry = z.infer<typeof load_schema>
94
105
  type DispatchEntry = z.infer<typeof dispatch_schema>
95
106
  type StageEntry = z.infer<typeof stage_schema>
107
+ type StopEntry = z.infer<typeof stop_schema>
96
108
  // What a finished gate reports; the ledger stamps the kind and the time.
97
109
  type GateRecord = Omit<GateEntry, 'kind' | 'at'>
98
110
  type StageRecord = Omit<StageEntry, 'kind' | 'at'>
111
+ type StopRecord = Omit<StopEntry, 'kind' | 'at'>
99
112
 
100
113
  function target_of(repository: string): string {
101
114
  return stamp_file.stamp_path(LEDGER_PREFIX, repository, LEDGER_SUFFIX)
@@ -168,6 +181,10 @@ async function record_stage(stage: StageRecord, path?: string): Promise<void> {
168
181
  await record({ kind: KIND.STAGE, at: now_iso(), ...stage }, path)
169
182
  }
170
183
 
184
+ async function record_stop(stop: StopRecord, path?: string): Promise<void> {
185
+ await record({ kind: KIND.STOP, at: now_iso(), ...stop }, path)
186
+ }
187
+
171
188
  function parse_line(line: string): LedgerEntry | undefined {
172
189
  return entry_schema.safeParse(json_value.parse_or_undefined(line)).data
173
190
  }
@@ -199,9 +216,10 @@ const lane_ledger = {
199
216
  record_gate,
200
217
  record_merge,
201
218
  record_stage,
219
+ record_stop,
202
220
  target,
203
221
  target_of,
204
222
  }
205
223
 
206
- export type { DispatchEntry, GateEntry, LedgerEntry, LoadEntry, MergeEntry, StageEntry }
224
+ export type { DispatchEntry, GateEntry, LedgerEntry, LoadEntry, MergeEntry, StageEntry, StopEntry }
207
225
  export { lane_ledger }
@@ -7,12 +7,14 @@ import { lane_ledger } from './lane-ledger'
7
7
  import { lane_sampler, type MeasureContext } from './lane-sampler'
8
8
  import { lane_stage_stats } from './lane-stage-stats'
9
9
  import { lane_stats } from './lane-stats'
10
+ import { lane_stop_stats } from './lane-stop-stats'
10
11
 
11
12
  // `josh lane:sample` / `josh lane:stats` — the lane-limit measurement.
12
13
  //
13
14
  // `lane:sample` appends one machine-load sample to the ledger, or one every `--every <seconds>` until it
14
15
  // is stopped; `lane:stats` reduces a period of the ledger to the one table row #3347 records per lane
15
- // limit, and under it the per-stage durations of the period's lanes. Every entry writes itself during
16
+ // limit, under it the per-stage durations of the period's lanes, and under those how long a stopped
17
+ // ship waited for each stop reason. Every entry writes itself during
16
18
  // a `backlogrun` — the merges, the gates, the stages, and the load `backlog:drive` samples while it
17
19
  // runs — so a person only closes the period with `lane:stats`; `lane:sample` is for a load reading
18
20
  // outside one. The procedure is `docs/maintainers/lane-limit-measurement.md`.
@@ -112,7 +114,13 @@ async function stats_command(
112
114
  const entries = await read_ledger(context)
113
115
  const row = lane_stats.row(entries, window, label)
114
116
 
115
- console.info(`${lane_stats.header()}\n${row}\n\n${lane_stage_stats.table(entries, window)}`)
117
+ console.info(
118
+ [
119
+ `${lane_stats.header()}\n${row}`,
120
+ lane_stage_stats.table(entries, window),
121
+ lane_stop_stats.table(entries, window),
122
+ ].join('\n\n'),
123
+ )
116
124
 
117
125
  return 0
118
126
  }
@@ -4,6 +4,7 @@ import { ENV_FILE_NAME } from '#ports'
4
4
  import { git_worktree } from '#scripts/git/git-worktree'
5
5
  import { session_cite } from '#scripts/issue/session-cite'
6
6
  import { file_reader } from '#scripts/lib/read-file'
7
+ import { run_event_setup } from '#scripts/run/event/run-event-setup'
7
8
  import { lane_cache } from './lane-cache'
8
9
  import { lane_environment } from './lane-environment'
9
10
  import { lane_install, type InstallResult } from './lane-install'
@@ -172,6 +173,13 @@ function guard_install(lane: LaneInfo, result: InstallResult): void {
172
173
  )
173
174
  }
174
175
 
176
+ // **The install is said on the run's stream as it starts**: it is the long part of opening a lane, and
177
+ // `run:board` draws it apart from the work tree being made. Best-effort, as every stage is.
178
+ async function fill(lane: LaneInfo): Promise<void> {
179
+ await run_event_setup.emit(run_event_setup.installing(lane.issue))
180
+ guard_install(lane, await lane_install.install_dependencies(lane.directory))
181
+ }
182
+
175
183
  function build_plan(
176
184
  repository_root: string,
177
185
  root: string,
@@ -231,7 +239,7 @@ async function materialize(plan: LanePlan, source_root: string): Promise<void> {
231
239
  release_seat_lock(plan.seat_lock)
232
240
  lane_cache.seed_caches(source_root, plan.lane.directory)
233
241
  lane_cache.seed_hook_bundles(source_root, plan.lane.directory)
234
- guard_install(plan.lane, await lane_install.install_dependencies(plan.lane.directory))
242
+ await fill(plan.lane)
235
243
  } finally {
236
244
  release_seat_lock(plan.seat_lock)
237
245
  }
@@ -0,0 +1,49 @@
1
+ import { git_gh_issue_write } from '#scripts/gh/git-gh-issue-write'
2
+ import { git_stash } from '#scripts/git/stash/git-stash'
3
+
4
+ // Keeping a lane's uncommitted work before something discards or restarts it — `run:merge` closing a
5
+ // lane, `lane:restart` handing it to a fresh session. The stash stack outlives the tree, so the push is
6
+ // the copy, and the comment on the Issue is where a person finds it again. One writer, so the message
7
+ // `stash:pop` matches on and the recovery line the comment names cannot drift between the callers.
8
+
9
+ function preserved_comment(message: string, cause: string): string {
10
+ return [
11
+ `The lane held uncommitted work when ${cause}, so it was stashed:`,
12
+ `- recover with \`pnpm josh stash:pop "${message}"\``,
13
+ ].join('\n')
14
+ }
15
+
16
+ /**
17
+ * Push the lane's uncommitted work under `<issue>: uncommitted work at <occasion>`, and return the
18
+ * message. A push that fails throws, so the caller keeps the tree rather than losing it.
19
+ */
20
+ async function push_work(issue: string, directory: string, occasion: string): Promise<string> {
21
+ const message = git_stash.work_message(issue, occasion)
22
+
23
+ await git_stash.push(message, directory)
24
+
25
+ return message
26
+ }
27
+
28
+ /** Say on the Issue where the stash `message` went, `cause` completing "when …". Never throws. */
29
+ async function announce(issue: string, message: string, cause: string): Promise<void> {
30
+ await git_gh_issue_write.issue_try_comment(issue, preserved_comment(message, cause))
31
+ }
32
+
33
+ /** `push_work`, then `announce` — for a caller with no reason to separate the two. */
34
+ async function stash_work(
35
+ issue: string,
36
+ directory: string,
37
+ occasion: string,
38
+ cause: string,
39
+ ): Promise<string> {
40
+ const message = await push_work(issue, directory, occasion)
41
+
42
+ await announce(issue, message, cause)
43
+
44
+ return message
45
+ }
46
+
47
+ const lane_preserve = { announce, push_work, stash_work }
48
+
49
+ export { lane_preserve }
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from 'node:child_process'
2
2
  import { PROBE_TIMEOUT_MS } from '#scripts/lib/timeouts'
3
3
  import { lane_child_invocation } from './lane-child-invocation'
4
+ import { process_tools } from './process-tools'
4
5
 
5
6
  // Ending a lane child that did not end itself.
6
7
  //
@@ -42,7 +43,9 @@ function parse_pids(printed: string): Array<number> {
42
43
  .filter((pid) => Number.isSafeInteger(pid) && pid >= LOWEST_CHILD_PID)
43
44
  }
44
45
 
45
- // `pgrep` exits 1 when nothing matches, which `execFileSync` throws — the empty list either way.
46
+ // `pgrep` exits 1 when nothing matches, which `execFileSync` throws — the empty list either way. A
47
+ // probe that is not installed is refused by name: reading it as "nothing there" is how a missing `ps`
48
+ // left the owner check answering `unknown`.
46
49
  function run_probe(command: string, args: ReadonlyArray<string>): string {
47
50
  try {
48
51
  return execFileSync(command, [...args], {
@@ -50,7 +53,9 @@ function run_probe(command: string, args: ReadonlyArray<string>): string {
50
53
  timeout: PROBE_TIMEOUT_MS,
51
54
  stdio: ['ignore', 'pipe', 'ignore'],
52
55
  })
53
- } catch {
56
+ } catch (error) {
57
+ process_tools.throw_if_missing(command, error)
58
+
54
59
  return ''
55
60
  }
56
61
  }
@@ -111,6 +116,12 @@ function tree_of(roots: ReadonlyArray<number>, probes: ReapProbes): Array<number
111
116
  return tree
112
117
  }
113
118
 
119
+ function roots_of(issue: string, own: Set<number>, probes: ReapProbes): Array<number> {
120
+ const pattern = lane_child_invocation.process_pattern(issue)
121
+
122
+ return probes.matching(pattern).filter((pid) => !own.has(pid))
123
+ }
124
+
114
125
  /**
115
126
  * Terminate every process of the lane child for `issue` and everything under it, sparing the caller's
116
127
  * own ancestry. Returns the pids that were signalled — the empty list is the healthy case, a child
@@ -118,14 +129,18 @@ function tree_of(roots: ReadonlyArray<number>, probes: ReapProbes): Array<number
118
129
  */
119
130
  function reap_child(issue: string, probes: ReapProbes = SYSTEM_PROBES): Array<number> {
120
131
  const own = own_ancestry(probes)
121
- const pattern = lane_child_invocation.process_pattern(issue)
122
- const roots = probes.matching(pattern).filter((pid) => !own.has(pid))
123
- const doomed = tree_of(roots, probes).filter((pid) => !own.has(pid))
132
+ const doomed = tree_of(roots_of(issue, own, probes), probes).filter((pid) => !own.has(pid))
124
133
 
125
134
  return doomed.filter((pid) => probes.terminate(pid))
126
135
  }
127
136
 
137
+ /** Whether a lane child for `issue` is running, by the same match `reap_child` would end. */
138
+ function is_child_running(issue: string, probes: ReapProbes = SYSTEM_PROBES): boolean {
139
+ return roots_of(issue, own_ancestry(probes), probes).length > 0
140
+ }
141
+
128
142
  const lane_reap = {
143
+ is_child_running,
129
144
  own_ancestry,
130
145
  parse_pids,
131
146
  reap_child,
@@ -236,6 +236,11 @@ async function has_lanes_in_flight(): Promise<boolean> {
236
236
  return lanes.some((lane) => !lane.is_stranded)
237
237
  }
238
238
 
239
+ /** The lanes in use: the open work trees an interruption has not stranded. */
240
+ function live_count(lanes: ReadonlyArray<LaneInfo>): number {
241
+ return lanes.filter((lane) => !lane.is_stranded).length
242
+ }
243
+
239
244
  /** The seats live lanes hold — what a new lane's seat has to avoid. */
240
245
  function used_seats(lanes: ReadonlyArray<LaneInfo>): Array<number> {
241
246
  return lanes.map((lane) => lane.seat).filter((seat): seat is number => seat !== undefined)
@@ -267,6 +272,7 @@ const lane_registry = {
267
272
  find_open_lane,
268
273
  has_lanes_in_flight,
269
274
  list_lanes,
275
+ live_count,
270
276
  main_repository_root,
271
277
  parse_block,
272
278
  read_environment,
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env tsx
2
+ import { fileURLToPath } from 'node:url'
3
+ import { issue_number_shape } from '#scripts/issue/issue-number-shape'
4
+ import { lane_dispatch } from './lane-dispatch'
5
+ import { lane_restart, type RestartOutcome } from './lane-restart'
6
+
7
+ // `josh lane:restart <issue>` — the contract `lane:dispatch` sets: the fresh child's pid is the one
8
+ // token on standard output, every explanation on standard error, and a restart that did not start a
9
+ // fresh child exits non-zero. A failed dispatch is warned off-screen exactly as `lane:dispatch` warns it,
10
+ // since the stopped child is then left for `run:merge` to collect.
11
+
12
+ const ARGV_OFFSET = 2
13
+ const SUCCESS_EXIT_CODE = 0
14
+ const FAILURE_EXIT_CODE = 1
15
+ const USAGE = 'Usage: josh lane:restart <issue-number>'
16
+
17
+ async function report(outcome: RestartOutcome, issue: string): Promise<number> {
18
+ console.error(lane_restart.describe(outcome, issue))
19
+
20
+ if (outcome.kind !== 'restarted') return FAILURE_EXIT_CODE
21
+ const { dispatch } = outcome
22
+
23
+ if (lane_dispatch.is_worth_warning(dispatch)) await lane_dispatch.warn_of_problem(dispatch, issue)
24
+ if (dispatch.kind !== 'dispatched') return FAILURE_EXIT_CODE
25
+
26
+ console.info(String(dispatch.pid))
27
+
28
+ return SUCCESS_EXIT_CODE
29
+ }
30
+
31
+ async function run(argv: ReadonlyArray<string>): Promise<number> {
32
+ const issue = argv.length === 1 ? argv[0] : undefined
33
+
34
+ if (issue === undefined || !issue_number_shape.is_issue_number(issue)) {
35
+ console.error(USAGE)
36
+
37
+ return FAILURE_EXIT_CODE
38
+ }
39
+
40
+ return await report(await lane_restart.restart(issue), issue)
41
+ }
42
+
43
+ async function main(argv: ReadonlyArray<string>): Promise<void> {
44
+ process.exitCode = await run(argv)
45
+ }
46
+
47
+ const lane_restart_cli = { run }
48
+
49
+ if (process.argv[1] === fileURLToPath(import.meta.url)) await main(process.argv.slice(ARGV_OFFSET))
50
+
51
+ export { lane_restart_cli }
@@ -0,0 +1,175 @@
1
+ import { git_stash } from '#scripts/git/stash/git-stash'
2
+ import { issue_cite } from '#scripts/issue/issue-cite'
3
+ import { IN_PROGRESS_LABEL } from '#scripts/issue/issue-labels'
4
+ import { issue_number_shape } from '#scripts/issue/issue-number-shape'
5
+ import { session_cite } from '#scripts/issue/session-cite'
6
+ import { error_text } from '#scripts/lib/error-message'
7
+ import { run_cut } from '#scripts/run/cut/run-cut'
8
+ import { run_event_stream } from '#scripts/run/event/run-event-stream'
9
+ import { run_event_stream_emit } from '#scripts/run/event/run-event-stream-emit'
10
+ import { lane_dispatch, type DispatchOutcome } from './lane-dispatch'
11
+ import { lane_preserve } from './lane-preserve'
12
+ import { lane_reap } from './lane-reap'
13
+ import { lane_registry, type LaneInfo } from './lane-registry'
14
+
15
+ // `josh lane:restart <N>` — a person stops a running lane child and starts its issue over in a fresh
16
+ // session, typically after correcting the Issue's specification mid-run.
17
+ //
18
+ // **The restart is a hand-off inside the parent's re-confirm window, not a recovery.** Killing the child
19
+ // alone would let `lane:await` book it stopped and `run:merge` classify it — an `outage` resumes the old
20
+ // session with the old specification in mind, anything else is parked `needs-decision` and counted as a
21
+ // failure. `run:cut` already hands a lane to a new process inside `lane:await`'s re-confirm window, so a
22
+ // child that disappears and reappears under the same `fullrun #<N>` pattern is never collected: the
23
+ // restart stops, stashes and re-dispatches within that window, and no failure or outage is counted.
24
+ //
25
+ // **No GitHub round-trip sits between the stop and the launch**, since one slow answer would outlast the
26
+ // window. The `in-progress` marker is claimed while the old child still runs — a refused claim stops
27
+ // nothing — and the Issue comment naming the stash is posted after the fresh child is up.
28
+ //
29
+ // The fresh dispatch reads no exit record (an outage record would otherwise resume the stopped session),
30
+ // a carried cut is ended (its successor would adopt the old hand-off), and the work in the tree is
31
+ // stashed with the recovery line on the Issue. The `restart` event follows the dispatch's own
32
+ // `child-launch`, so the stream and `run:board` say a person restarted the child.
33
+
34
+ const RESTART_OCCASION = 'lane restart'
35
+ const RESTART_CAUSE = 'a person restarted its child with `lane:restart`'
36
+ const DISPATCHED_KIND = 'dispatched'
37
+ const NO_LANE_KIND = 'no-lane'
38
+ const NOT_RUNNING_KIND = 'not-running'
39
+ const UNCLAIMED_KIND = 'unclaimed'
40
+
41
+ interface Unsaved {
42
+ kind: 'unsaved'
43
+ directory: string
44
+ reason: string
45
+ }
46
+
47
+ interface Restarted {
48
+ kind: 'restarted'
49
+ dispatch: DispatchOutcome
50
+ stash: string | undefined
51
+ }
52
+
53
+ type Refused =
54
+ | { kind: typeof NO_LANE_KIND }
55
+ | { kind: typeof NOT_RUNNING_KIND }
56
+ | { kind: typeof UNCLAIMED_KIND; reason: string }
57
+ type RestartOutcome = Restarted | Unsaved | Refused
58
+ type Saved = Unsaved | { kind: 'saved'; stash: string | undefined }
59
+
60
+ // A cut the stopped child carried would hand its successor the old session's hand-off at
61
+ // `run:cut --resume`; the fresh session has to start from the Issue instead.
62
+ function end_carried_cut(lane: LaneInfo): void {
63
+ const carried = run_cut.lane_cut_sync(lane.directory)
64
+
65
+ if (carried !== undefined) run_cut.end_cut(carried.target)
66
+ }
67
+
68
+ async function stash_of(lane: LaneInfo): Promise<string | undefined> {
69
+ if (!(await git_stash.has_changes(lane.directory))) return undefined
70
+
71
+ return await lane_preserve.push_work(lane.issue, lane.directory, RESTART_OCCASION)
72
+ }
73
+
74
+ // A tree that could not be read or pushed is left as it is: re-dispatching into it would hand the fresh
75
+ // session the stopped one's half-done work as though it were its own.
76
+ async function save_work(lane: LaneInfo): Promise<Saved> {
77
+ try {
78
+ return { kind: 'saved', stash: await stash_of(lane) }
79
+ } catch (error) {
80
+ return { kind: 'unsaved', directory: lane.directory, reason: error_text.message_of(error) }
81
+ }
82
+ }
83
+
84
+ async function record_restart(issue: string): Promise<void> {
85
+ await run_event_stream_emit.emit(
86
+ run_event_stream.EVENT_KIND.RESTART,
87
+ `${issue_cite.plain(issue)} restarted in a fresh session by a person`,
88
+ )
89
+ }
90
+
91
+ async function relaunch(lane: LaneInfo): Promise<RestartOutcome> {
92
+ end_carried_cut(lane)
93
+ const saved = await save_work(lane)
94
+
95
+ if (saved.kind === 'unsaved') return saved
96
+ const dispatch = await lane_dispatch.dispatch_child(lane.issue, 'restart')
97
+
98
+ if (dispatch.kind === DISPATCHED_KIND) await record_restart(lane.issue)
99
+
100
+ if (saved.stash !== undefined) {
101
+ await lane_preserve.announce(lane.issue, saved.stash, RESTART_CAUSE)
102
+ }
103
+
104
+ return { kind: 'restarted', dispatch, stash: saved.stash }
105
+ }
106
+
107
+ // A child that ended on its own during the claim (say, right after merging its PR) is a finished child
108
+ // for `run:merge` to collect — relaunching it would re-run a done issue inside the re-confirm window.
109
+ async function stop_and_relaunch(lane: LaneInfo): Promise<RestartOutcome> {
110
+ if (lane_reap.reap_child(lane.issue).length === 0) return { kind: NOT_RUNNING_KIND }
111
+
112
+ return await relaunch(lane)
113
+ }
114
+
115
+ /** Stop the running child of `#<N>`'s lane and re-dispatch the issue there in a fresh session. */
116
+ async function restart(issue: string): Promise<RestartOutcome> {
117
+ issue_number_shape.require_issue_number(issue)
118
+ const lane = await lane_registry.find_open_lane(issue)
119
+
120
+ if (lane === undefined) return { kind: NO_LANE_KIND }
121
+ if (!lane_reap.is_child_running(issue)) return { kind: NOT_RUNNING_KIND }
122
+ const claim = await lane_dispatch.claim_marker(issue)
123
+
124
+ if (!claim.is_applied) return { kind: UNCLAIMED_KIND, reason: claim.reason }
125
+
126
+ return await stop_and_relaunch(lane)
127
+ }
128
+
129
+ function stash_sentence(stash: string | undefined): string {
130
+ if (stash === undefined) return 'The lane held no uncommitted work.'
131
+
132
+ return `Its uncommitted work was stashed — recover with \`pnpm josh stash:pop "${stash}"\`.`
133
+ }
134
+
135
+ // A dispatch that failed leaves the stopped child to `run:merge`: nothing re-appeared inside the
136
+ // re-confirm window, so the parent collects it as it would any stopped child.
137
+ function restarted_text(outcome: Restarted, issue: string): string {
138
+ const head = `Stopped the child for ${session_cite.issue(issue)}. ${stash_sentence(outcome.stash)}`
139
+ const dispatch = lane_dispatch.describe(outcome.dispatch, issue)
140
+
141
+ if (outcome.dispatch.kind === DISPATCHED_KIND) return `${head} ${dispatch}`
142
+
143
+ return `${head} The fresh dispatch failed, so \`run:merge\` will collect the stopped child: ${dispatch}`
144
+ }
145
+
146
+ function unsaved_text(outcome: Unsaved, issue: string): string {
147
+ return `The child for ${session_cite.issue(issue)} was stopped, but its uncommitted work could not be stashed (${outcome.reason}), so nothing was re-dispatched and the lane is left at ${outcome.directory}; \`run:merge\` will collect the stopped child.`
148
+ }
149
+
150
+ function refused_text(outcome: Refused, issue: string): string {
151
+ const cited = session_cite.issue(issue)
152
+
153
+ if (outcome.kind === NO_LANE_KIND) {
154
+ return `No lane is open for ${cited}, so there is no child to restart.`
155
+ }
156
+
157
+ if (outcome.kind === UNCLAIMED_KIND) {
158
+ return `The \`${IN_PROGRESS_LABEL}\` label could not be confirmed on ${cited} (${outcome.reason}), so the running child was left as it is — nothing was stopped or re-dispatched.`
159
+ }
160
+
161
+ return `No child is running in the lane for ${cited}, so nothing was stopped or re-dispatched — a child that has already ended is \`run:merge\`'s to collect.`
162
+ }
163
+
164
+ function describe(outcome: RestartOutcome, issue: string): string {
165
+ if (outcome.kind === 'unsaved') return unsaved_text(outcome, issue)
166
+
167
+ return outcome.kind === 'restarted'
168
+ ? restarted_text(outcome, issue)
169
+ : refused_text(outcome, issue)
170
+ }
171
+
172
+ const lane_restart = { describe, restart }
173
+
174
+ export type { RestartOutcome }
175
+ export { lane_restart }