@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
@@ -25,7 +25,7 @@ import { run_merge_token, type MergeToken } from './run-merge-token'
25
25
  // The contract is `run:carry`'s: **standard output carries one token** (or the newline-separated
26
26
  // offer `epic:next --lanes` prints), and everything else is a captured subprocess or a quiet
27
27
  // in-process call. The verdicts this command adds to the offer are `over` (hand off), `human-review`
28
- // (stop — the child's own ending), `stop` (the consecutive-failure guard tripped) and `retry` (the
28
+ // (stop — the child's own ending), `stop` (the consecutive-failure guard tripped, or a failed child could not be parked) and `retry` (the
29
29
  // child's state could not be read; re-read before deciding).
30
30
 
31
31
  const ARGV_OFFSET = 2
@@ -163,13 +163,16 @@ function parse(argv: ReadonlyArray<string>): MergeContext | undefined {
163
163
  interface ControlVerdict {
164
164
  token: MergeToken
165
165
  code: number
166
+ is_offer: false
166
167
  }
167
168
 
168
169
  // The next child offered in the collected one's place: the text `backlog:next` or `epic:next --lanes`
169
- // printed, passed through as it was read, so no narrower type holds it.
170
+ // printed, passed through as it was read, so no narrower type holds it. `is_offer` keeps its `stop`
171
+ // apart from the control one (`run_merge_token.is_control_stop`).
170
172
  interface OfferVerdict {
171
173
  token: string
172
174
  code: number
175
+ is_offer: true
173
176
  }
174
177
 
175
178
  type MergeVerdict = ControlVerdict | OfferVerdict
@@ -179,11 +182,11 @@ type MergeVerdict = ControlVerdict | OfferVerdict
179
182
  type MergeResult = MergeVerdict & { outcome: ChildOutcome }
180
183
 
181
184
  function emit(token: MergeToken, code: number): ControlVerdict {
182
- return { token, code }
185
+ return { token, code, is_offer: false }
183
186
  }
184
187
 
185
188
  async function offered(ctx: MergeContext): Promise<OfferVerdict> {
186
- return { token: await run_merge_steps.ask_next(ctx), code: SUCCESS_EXIT_CODE }
189
+ return { token: await run_merge_steps.ask_next(ctx), code: SUCCESS_EXIT_CODE, is_offer: true }
187
190
  }
188
191
 
189
192
  // Whether the child's exit record shows it could not reach the API — read only when `--output` named
@@ -0,0 +1,61 @@
1
+ import { git_common_directory } from '#scripts/git/git-common-directory'
2
+ import { run_event_scope } from '#scripts/run/event/run-event-scope'
3
+ import { run_event_stream, type RunEvent } from '#scripts/run/event/run-event-stream'
4
+ import { run_ship_detach } from '#scripts/run/ship/run-ship-detach'
5
+
6
+ // Why `run:merge` parks a child whose detached ship has already ended (joshuafolkken/kit#3808).
7
+ //
8
+ // **A child that handed its region to `josh ship --detach` is parked for what its ship did.** A running
9
+ // ship is never parked (`merge_child` answers `shipping` first), so a park after a hand-off means the
10
+ // ship ended unmerged — and the session the ship relaunched to fix it ended too. The generic "session
11
+ // ended with its issue open" then hides the one fact a person needs: the stage the ship stopped at. The
12
+ // cause is that stage's `ship-stop` line, written by the supervisor since its own launch; a ship that
13
+ // ended without one (killed, or failed before any stage) is named by its result and its log instead.
14
+
15
+ type ShipEnding = ReturnType<typeof run_ship_detach.read_result>
16
+
17
+ const ENDED_RESULTS: ReadonlySet<string> = new Set(['failed', 'abnormal'])
18
+
19
+ type Events = ReadonlyArray<RunEvent>
20
+
21
+ // `child`'s events after its newest `ship-launch`, so an earlier launch's stop the relaunched session
22
+ // already fixed is never blamed for this ending. The record and the stream outlive a dispatch, so only a
23
+ // ship launched since the child's own newest dispatch is this attempt's: an earlier attempt's ended ship
24
+ // answers `undefined`, never this park.
25
+ function since_ship_launch(events: Events, child: string): Events | undefined {
26
+ const attempt = run_event_scope.since_own_launch(events, child)
27
+ const own = attempt.filter((event) => run_event_scope.issue_named(event) === child)
28
+ const launched_at = own.findLastIndex(
29
+ (event) => event.kind === run_event_stream.EVENT_KIND.SHIP_LAUNCH,
30
+ )
31
+
32
+ return launched_at === -1 ? undefined : own.slice(launched_at + 1)
33
+ }
34
+
35
+ function cause_of(ending: ShipEnding, events: Events, child: string): string | undefined {
36
+ if (ending === undefined || !ENDED_RESULTS.has(ending.result)) return undefined
37
+
38
+ const attempt = since_ship_launch(events, child)
39
+ if (attempt === undefined) return undefined
40
+
41
+ const stop = attempt.findLast((event) => event.kind === run_event_stream.EVENT_KIND.SHIP_STOP)
42
+
43
+ if (stop !== undefined) return `its detached ship stopped and was not fixed: ${stop.text}.`
44
+
45
+ return `its detached ship ended ${ending.result} before recording a stopped stage — pnpm josh ship --log ${child}.`
46
+ }
47
+
48
+ // The ship's cause for `child`'s park, read from the record and the stream this checkout's repository
49
+ // keys on, or `undefined` when the child handed nothing to a ship or its ship succeeded.
50
+ function cause(child: string, directory: string = process.cwd()): string | undefined {
51
+ const repository = git_common_directory.repository(directory)
52
+ if (repository === undefined) return undefined
53
+
54
+ const events = run_event_stream.read_events(run_event_stream.target_of(repository))
55
+
56
+ return cause_of(run_ship_detach.read_result(repository, child), events, child)
57
+ }
58
+
59
+ const run_merge_ship = { cause, cause_of }
60
+
61
+ export { run_merge_ship }
@@ -9,6 +9,7 @@ import { NEEDS_DECISION_LABEL } from '#scripts/issue/issue-labels'
9
9
  import { session_cite } from '#scripts/issue/session-cite'
10
10
  import { josh_command, type JoshResult } from '#scripts/josh/josh-run'
11
11
  import { lane_close } from '#scripts/lane/lane-close'
12
+ import { lane_preserve } from '#scripts/lane/lane-preserve'
12
13
  import { lane_reap } from '#scripts/lane/lane-reap'
13
14
  import { lane_registry, type LaneInfo } from '#scripts/lane/lane-registry'
14
15
  import { lane_relaunch } from '#scripts/lane/lane-relaunch'
@@ -24,6 +25,7 @@ import { run_carry_conversation } from '#scripts/run/carry/run-carry-conversatio
24
25
  import { run_cut, type RunCut } from '#scripts/run/cut/run-cut'
25
26
  import { run_label } from '#scripts/run/run-label'
26
27
  import { run_merge } from './run-merge'
28
+ import { run_merge_ship } from './run-merge-ship'
27
29
 
28
30
  // The result of attempting to apply a carry change. Distinguishing `refused` from `applied` lets
29
31
  // callers surface the refusal as a hard failure rather than silently continuing on a carry that was
@@ -167,18 +169,8 @@ async function close_lane(child: string): Promise<void> {
167
169
  await josh(['lane:close', child])
168
170
  }
169
171
 
170
- function preserved_comment(message: string): string {
171
- return [
172
- 'The lane held uncommitted work when `run:merge` closed it, so it was stashed before the close:',
173
- `- recover with \`pnpm josh stash:pop "${message}"\``,
174
- ].join('\n')
175
- }
176
-
177
172
  async function stash_work(child: string, directory: string): Promise<void> {
178
- const message = git_stash.work_message(child, LANE_CLOSE_OCCASION)
179
-
180
- await git_stash.push(message, directory)
181
- await git_gh_issue_write.issue_try_comment(child, preserved_comment(message))
173
+ await lane_preserve.stash_work(child, directory, LANE_CLOSE_OCCASION, '`run:merge` closed it')
182
174
  }
183
175
 
184
176
  // Stash whatever the lane still holds uncommitted before its tree is removed: `lane:close` deletes by
@@ -344,13 +336,14 @@ async function park_failed(ctx: MergeContext, cause: string): Promise<FailedResu
344
336
  }
345
337
 
346
338
  // A child that ended unfinished: waiting when it records an open blocker (`do_waiting`), parked
347
- // otherwise (`park_failed`) — an order already recorded is never a decision for a person.
348
- async function do_failed(ctx: MergeContext, cause: string = FAILED_CAUSE): Promise<FailedResult> {
339
+ // otherwise (`park_failed`) — an order already recorded is never a decision for a person. Without a
340
+ // cause of the caller's, a child whose detached ship ended is parked for the ship's stop.
341
+ async function do_failed(ctx: MergeContext, cause?: string): Promise<FailedResult> {
349
342
  const blockers = await open_blockers(ctx.child)
350
343
 
351
344
  if (blockers.length > 0) return await do_waiting(ctx, blockers)
352
345
 
353
- return await park_failed(ctx, cause)
346
+ return await park_failed(ctx, cause ?? run_merge_ship.cause(ctx.child) ?? FAILED_CAUSE)
354
347
  }
355
348
 
356
349
  // The outcome of counting an outage: the record to read the streak against, and whether the count was
@@ -24,7 +24,21 @@ const MERGE_TOKEN = {
24
24
 
25
25
  type MergeToken = (typeof MERGE_TOKEN)[keyof typeof MERGE_TOKEN]
26
26
 
27
- const run_merge_token = { MERGE_TOKEN }
27
+ // Where the token came from: `run:merge`'s own control verdict, or the next offer (`backlog:next` /
28
+ // `epic:next`) passed through as it was read. The offer has a `stop` of its own — nothing ready to start
29
+ // is left — spelled like the control `stop`, so the source travels beside the token.
30
+ interface MergeTokenRead {
31
+ token: string
32
+ is_offer: boolean
33
+ }
28
34
 
29
- export type { MergeToken }
35
+ // The single reading of "this merge stopped the run": the control `stop` alone. An offer's `stop` is a
36
+ // parked backlog, which `backlog:budget` decides — a watch while the idle budget lasts — never the merge.
37
+ function is_control_stop(read: MergeTokenRead): boolean {
38
+ return !read.is_offer && read.token === MERGE_TOKEN.STOP
39
+ }
40
+
41
+ const run_merge_token = { MERGE_TOKEN, is_control_stop }
42
+
43
+ export type { MergeToken, MergeTokenRead }
30
44
  export { run_merge_token }
@@ -129,6 +129,7 @@ const run_headless = {
129
129
  is_backlog_parent,
130
130
  is_cut_capped,
131
131
  is_headless,
132
+ is_headless_parent,
132
133
  is_owned_here,
133
134
  must_keep_waiting,
134
135
  }
@@ -222,6 +222,8 @@ const FULL_INPUT_ACTIONS: Record<string, (input: StepInput) => StepAction> = {
222
222
  [KIND.STOP]: stop_action,
223
223
  [KIND.DRAIN]: drain_action,
224
224
  [KIND.CHILD_LAUNCH]: child_launch_action,
225
+ // A person's restart re-dispatched the child, so the parent's position is a launch's.
226
+ [KIND.RESTART]: child_launch_action,
225
227
  }
226
228
 
227
229
  // An event the table does not name leaves the position unknown rather than guessing a next step. A
@@ -1,5 +1,6 @@
1
1
  import { telegram_notify } from '#scripts/notify/telegram-notify'
2
2
  import { run_carry, type CarryRead, type RunCarry } from '#scripts/run/carry/run-carry'
3
+ import { run_carry_ended } from '#scripts/run/carry/run-carry-ended'
3
4
  import { run_event_stream } from '#scripts/run/event/run-event-stream'
4
5
  import { run_event_stream_emit } from '#scripts/run/event/run-event-stream-emit'
5
6
  import { run_wake, type RunWake } from '#scripts/run/wake/run-wake'
@@ -27,13 +28,14 @@ import {
27
28
  // so recovery is another actor's.
28
29
  const STRAND_RECOVERY = `Restart supervision with \`${run_stranded.RECOVERY_COMMAND}\` — it wakes a fresh successor to claim the budget; the cutting session must not resume it itself.`
29
30
 
30
- // The seams a test replaces: the two record targets, the two record reads, the owner and supervisor
31
- // liveness probes, and the two side effects. The defaults are the real readers; a test hands fakes so
31
+ // The seams a test replaces: the two record targets, the two record reads, the ended-run read, the owner
32
+ // and supervisor liveness probes, and the two side effects. The defaults are the real readers; a test hands fakes so
32
33
  // the gather and report logic run without a git tree, a process table or a network.
33
34
  interface DetectPorts {
34
35
  resolve_targets: () => Promise<RecordTargets | undefined>
35
36
  read_carry: (target: string) => CarryRead
36
37
  is_owner_live: (carry: RunCarry) => boolean
38
+ has_ended: (target: string, started_at: string) => boolean
37
39
  read_wake: (target: string) => RunWake | undefined
38
40
  supervisor_liveness: (wake: RunWake | undefined) => SupervisorLiveness
39
41
  emit_stranded: (text: string) => Promise<boolean>
@@ -45,15 +47,25 @@ interface RecordTargets {
45
47
  wake: string
46
48
  }
47
49
 
50
+ // The run a supervisor was watching: its carry record, where `run:carry --end` records the run it
51
+ // removes, and the `started_at` the supervisor read when it took the run on.
52
+ interface WatchedRun {
53
+ carry_target: string
54
+ ended_target: string
55
+ started_at: string
56
+ }
57
+
58
+ function targets_of(directory: string): RecordTargets {
59
+ return { carry: run_carry.carry_path(directory), wake: run_wake.wake_path(directory) }
60
+ }
61
+
48
62
  // Both records key on the common git directory, the same one `run-carry.ts` and `run-wake.ts` resolve,
49
63
  // so a strand read from any lane reads the one run's records. `undefined` outside a repository: no git
50
64
  // directory to key on, so no run to be stranded.
51
65
  async function real_resolve_targets(): Promise<RecordTargets | undefined> {
52
66
  const directory = await run_carry.repository_directory()
53
67
 
54
- if (directory === undefined) return undefined
55
-
56
- return { carry: run_carry.carry_path(directory), wake: run_wake.wake_path(directory) }
68
+ return directory === undefined ? undefined : targets_of(directory)
57
69
  }
58
70
 
59
71
  async function real_emit_stranded(text: string): Promise<boolean> {
@@ -68,6 +80,7 @@ const DEFAULT_PORTS: DetectPorts = {
68
80
  resolve_targets: real_resolve_targets,
69
81
  read_carry: (target) => run_carry.read_carry(target),
70
82
  is_owner_live: run_carry.is_owner_live,
83
+ has_ended: run_carry_ended.has_ended,
71
84
  read_wake: (target) => run_wake.read_wake(target),
72
85
  supervisor_liveness: (wake) => run_stranded.supervisor_liveness(wake),
73
86
  emit_stranded: real_emit_stranded,
@@ -77,11 +90,7 @@ const DEFAULT_PORTS: DetectPorts = {
77
90
  // The three facts, assembled behind the carry-record gate. A non-`carried` read — nothing carried, or
78
91
  // an unreadable record — is `undefined`: there is no live budget to be stranded, so the
79
92
  // wake record is never read and the judge is never asked.
80
- async function gather(ports: DetectPorts): Promise<StrandedInput | undefined> {
81
- const targets = await ports.resolve_targets()
82
-
83
- if (targets === undefined) return undefined
84
-
93
+ function input_at(ports: DetectPorts, targets: RecordTargets): StrandedInput | undefined {
85
94
  const read = ports.read_carry(targets.carry)
86
95
 
87
96
  if (read.kind !== 'carried') return undefined
@@ -93,6 +102,12 @@ async function gather(ports: DetectPorts): Promise<StrandedInput | undefined> {
93
102
  }
94
103
  }
95
104
 
105
+ async function gather(ports: DetectPorts): Promise<StrandedInput | undefined> {
106
+ const targets = await ports.resolve_targets()
107
+
108
+ return targets === undefined ? undefined : input_at(ports, targets)
109
+ }
110
+
96
111
  // The marker and the notification, both once per strand episode. `emit_stranded` refuses a second while
97
112
  // the strand event is still the newest, and its `false` is what holds the notification back too — so a
98
113
  // strand polled every stop reaches the person once, not once a turn.
@@ -128,7 +143,38 @@ async function run_stranded_check(): Promise<void> {
128
143
  }
129
144
  }
130
145
 
131
- const run_stranded_detect = { detect_and_report, run_stranded_check }
146
+ // The same judgement for a reader that draws the run rather than reports on it: `run:board` asks it
147
+ // every local read, so a run nobody is driving is not drawn as one still waiting for work.
148
+ function is_stranded_at(directory: string, ports: DetectPorts = DEFAULT_PORTS): boolean {
149
+ const input = input_at(ports, targets_of(directory))
150
+
151
+ return input !== undefined && run_stranded.is_stranded(input)
152
+ }
153
+
154
+ // The judgement a supervisor asks of the run it is about to stop watching. The asker is that
155
+ // supervisor on its way out, so it is taken as gone, and so is an owner that is the asker itself
156
+ // (`run_carry.is_held_by_another`) — that owner is about to be gone. A
157
+ // record another session holds is not left stranded. **A record that does not read is left stranded
158
+ // unless `run:carry --end` ended this very run**: the read that failed the driver fails here too, and
159
+ // that run still has its budget and nobody watching it.
160
+ function is_left_stranded(run: WatchedRun, ports: DetectPorts = DEFAULT_PORTS): boolean {
161
+ const read = ports.read_carry(run.carry_target)
162
+
163
+ if (read.kind !== 'carried') return !ports.has_ended(run.ended_target, run.started_at)
164
+
165
+ return run_stranded.is_stranded({
166
+ read,
167
+ is_owner_live: run_carry.is_held_by_another(read.carry, ports.is_owner_live),
168
+ supervisor: run_stranded.GONE,
169
+ })
170
+ }
171
+
172
+ const run_stranded_detect = {
173
+ detect_and_report,
174
+ is_left_stranded,
175
+ is_stranded_at,
176
+ run_stranded_check,
177
+ }
132
178
 
133
179
  export { run_stranded_detect }
134
- export type { DetectPorts, RecordTargets }
180
+ export type { DetectPorts, RecordTargets, WatchedRun }
@@ -274,6 +274,18 @@ async function open_context(args: ShipArguments): Promise<ShipContext> {
274
274
  return { target, done, state, started }
275
275
  }
276
276
 
277
+ // Every stop goes to the lane ledger — supervised, in an agent's own turn, or a lane child's preflight
278
+ // before the hand-off — so `lane:stats` can say how long a ship stayed stopped for each reason.
279
+ async function record_stop(
280
+ args: ShipArguments,
281
+ stage: Stage,
282
+ conflicts?: ReadonlyArray<string>,
283
+ ): Promise<void> {
284
+ const reason = run_ship_stage.stop_reason(stage, conflicts)
285
+
286
+ await lane_ledger.record_stop({ issue: Number(args.number), reason })
287
+ }
288
+
277
289
  // A detached supervisor hands the stopped stage back (`run-ship-return.ts`); a
278
290
  // ship in an agent's own turn has its report in front of that agent already. The `ship-stop` ends the
279
291
  // attempt, so the record's started stages go with it.
@@ -283,8 +295,11 @@ async function stopped(
283
295
  step: Step,
284
296
  context: ShipContext,
285
297
  ): Promise<ReadonlyArray<ShipSection>> {
298
+ const conflicts = sections.at(-1)?.conflicts
299
+
300
+ await record_stop(args, step.stage, conflicts)
301
+
286
302
  if (run_ship_detach.is_supervised()) {
287
- const conflicts = sections.at(-1)?.conflicts
288
303
  const resume = run_ship_next.resume_of(args)
289
304
 
290
305
  record(context.target, run_ship_stage.end_attempt)
@@ -371,7 +386,12 @@ function should_detach(args: ShipArguments): boolean {
371
386
  // supervisor's gate runs them again rather than passing over them.
372
387
  async function pre_detach_sections(args: ShipArguments): Promise<ReadonlyArray<ShipSection>> {
373
388
  const preflight = await run_stage(run_ship_steps.PREFLIGHT_STEP, args, await open_context(args))
374
- if (preflight.code !== SUCCESS_EXIT_CODE) return [preflight]
389
+
390
+ if (preflight.code !== SUCCESS_EXIT_CODE) {
391
+ await record_stop(args, run_ship_steps.PREFLIGHT_STEP.stage)
392
+
393
+ return [preflight]
394
+ }
375
395
 
376
396
  const result = await run_ship_pre_detach.checks()
377
397
 
@@ -1,8 +1,9 @@
1
1
  import { agent_role_profile } from '#scripts/agent/agent-role-profile'
2
- import { lane_child_invocation } from '#scripts/lane/lane-child-invocation'
2
+ import { lane_child_invocation, type ShipStop } from '#scripts/lane/lane-child-invocation'
3
3
  import { lane_child_marker } from '#scripts/lane/lane-child-marker'
4
4
  import { lane_registry, type LaneInfo } from '#scripts/lane/lane-registry'
5
5
  import { lane_relaunch } from '#scripts/lane/lane-relaunch'
6
+ import { lane_session_broker } from '#scripts/lane/lane-session-broker'
6
7
  import { run_event_stream } from '#scripts/run/event/run-event-stream'
7
8
  import { run_event_stream_emit } from '#scripts/run/event/run-event-stream-emit'
8
9
  import type { ShipResume } from './run-ship-next'
@@ -49,6 +50,31 @@ function note(text: string): void {
49
50
  process.stderr.write(`${text}\n`)
50
51
  }
51
52
 
53
+ function relaunched(lane: LaneInfo, stop: ShipStop): ReturnOutcome {
54
+ const invocation = lane_child_invocation.ship_stop_invocation(lane.issue, stop)
55
+ const phase = agent_role_profile.IMPLEMENTATION_PHASE
56
+ const result = lane_relaunch.relaunch(lane, invocation, phase, note)
57
+
58
+ if (result.kind === 'launched') return 'relaunched'
59
+
60
+ note(`the lane child could not be relaunched: ${result.note}`)
61
+
62
+ return 'recorded'
63
+ }
64
+
65
+ // **A served lane's child is relaunched by its broker, not from here**: this supervisor descends from the
66
+ // child's Bash tool, which Claude Code starts without the token, so a child started here could not sign
67
+ // in where the token is the only credential (joshuafolkken/kit#3817).
68
+ async function brokered(lane: LaneInfo, stop: ShipStop): Promise<ReturnOutcome> {
69
+ if (await lane_session_broker.request(lane.directory, lane.issue, { kind: 'relaunch', stop })) {
70
+ return 'relaunched'
71
+ }
72
+
73
+ note('the lane child could not be relaunched: the lane broker did not start it')
74
+
75
+ return 'recorded'
76
+ }
77
+
52
78
  async function return_control(
53
79
  issue: string,
54
80
  stage: Stage,
@@ -63,19 +89,11 @@ async function return_control(
63
89
 
64
90
  if (lane === undefined) return 'recorded'
65
91
 
66
- const invocation = lane_child_invocation.ship_stop_invocation(issue, { stage, ...resume })
67
- const result = lane_relaunch.relaunch(
68
- lane,
69
- invocation,
70
- agent_role_profile.IMPLEMENTATION_PHASE,
71
- note,
72
- )
92
+ const stop = { stage, ...resume }
73
93
 
74
- if (result.kind === 'launched') return 'relaunched'
75
-
76
- note(`the lane child could not be relaunched: ${result.note}`)
77
-
78
- return 'recorded'
94
+ return lane_session_broker.is_serving(lane.directory, issue)
95
+ ? await brokered(lane, stop)
96
+ : relaunched(lane, stop)
79
97
  }
80
98
 
81
99
  const run_ship_return = { return_control }
@@ -4,7 +4,7 @@ import { PROJECT_ROOT } from '#scripts/init/init-paths'
4
4
  import { josh_command, type JoshResult } from '#scripts/josh/josh-run'
5
5
  import { stamp_file } from '#scripts/josh/stamp-file'
6
6
  import { lane_child_marker } from '#scripts/lane/lane-child-marker'
7
- import { openai_review_broker } from '#scripts/lane/openai-review-broker'
7
+ import { lane_session_broker } from '#scripts/lane/lane-session-broker'
8
8
  import { review_record } from '#scripts/review/review-record'
9
9
  import { detached_launch } from '#scripts/run/detached-launch'
10
10
  import { run_ship_review, type RoundOutcome, type ScoredVerdict } from './run-ship-review'
@@ -66,7 +66,7 @@ function failure(out: string): JoshResult {
66
66
  }
67
67
 
68
68
  function paths(): { brief: string; findings: string; log: string } {
69
- return openai_review_broker.review_paths(PROJECT_ROOT)
69
+ return lane_session_broker.review_paths(PROJECT_ROOT)
70
70
  }
71
71
 
72
72
  async function josh(argv: ReadonlyArray<string>): Promise<JoshResult> {
@@ -94,6 +94,16 @@ async function run_direct_session(
94
94
  return { code: SUCCESS_EXIT_CODE, out: '' }
95
95
  }
96
96
 
97
+ // A lane child's reviewer is started by the lane's broker: an OpenAI lane's always, since a nested Codex
98
+ // cannot write outside its sandbox, and a Claude lane's whenever the `run:wake` supervisor serves it, since this
99
+ // ship was started from the child's Bash tool and so never received a token-only credential
100
+ // (joshuafolkken/kit#3817). A Claude lane nobody serves starts the reviewer itself, as before.
101
+ function is_brokered(profile: AgentProfile, issue: string): boolean {
102
+ if (!lane_child_marker.is_child_of(PROJECT_ROOT)) return false
103
+
104
+ return profile.provider === 'openai' || lane_session_broker.is_serving(PROJECT_ROOT, issue)
105
+ }
106
+
97
107
  async function run_session(
98
108
  prompt: string,
99
109
  log_path: string,
@@ -103,11 +113,14 @@ async function run_session(
103
113
  const built = agent_argv.resolve_in(prompt, agent_role_profile.REVIEWER, PROJECT_ROOT)
104
114
  if (built.kind === 'rejected') return failure(`reviewer profile rejected: ${built.note}`)
105
115
 
106
- if (built.profile.provider !== 'openai' || !lane_child_marker.is_child_of(PROJECT_ROOT)) {
116
+ if (!is_brokered(built.profile, issue)) {
107
117
  return await run_direct_session(built.argv, built.profile, log_path)
108
118
  }
109
119
 
110
- const is_success = await openai_review_broker.request(PROJECT_ROOT, issue, round)
120
+ const is_success = await lane_session_broker.request(PROJECT_ROOT, issue, {
121
+ kind: 'review',
122
+ round,
123
+ })
111
124
 
112
125
  return is_success ? { code: SUCCESS_EXIT_CODE, out: '' } : failure('isolated reviewer failed')
113
126
  }
@@ -201,6 +201,7 @@ function stop_reason(stage: Stage, conflicts: ReadonlyArray<string> = []): strin
201
201
  }
202
202
 
203
203
  const run_ship_stage = {
204
+ CONFLICT,
204
205
  PHASE,
205
206
  STAGE,
206
207
  clear,
@@ -0,0 +1,130 @@
1
+ import type { AgentProfile } from '#scripts/agent/agent-role-profile'
2
+ import { backlogrun_launch } from '#scripts/backlog/backlogrun-launch'
3
+ import { poll } from '#scripts/lib/poll'
4
+ import { run_carry, type CarryRead, type RunCarry } from '#scripts/run/carry/run-carry'
5
+ import { run_wake, type RunWake } from './run-wake'
6
+ import type { WakeContext } from './run-wake-describe'
7
+
8
+ // How a supervisor claims the run it watches — including the one `josh backlogrun` starts beside the
9
+ // parent it launched, before that parent has claimed the carry record (joshuafolkken/kit#3804).
10
+ //
11
+ // **That supervisor is started by the command, not by the parent's own shell.** Claude Code removes its
12
+ // token from every process its Bash tool starts, so a supervisor the parent started there could not
13
+ // authenticate the sessions it wakes wherever the token is the only credential. Started by the command,
14
+ // it inherits the token — but before the parent has run `run:carry --begin`, so it waits for that record
15
+ // while the launch that will write it is alive, and gives up once that launch is gone.
16
+ //
17
+ // **While a launch is pending, only a live owner's record is its parent's**: a dead owner's record is one
18
+ // an earlier run left, and supervising it would wake that run beside the parent just launched.
19
+
20
+ interface AwaitPorts {
21
+ read_carry: () => CarryRead
22
+ // The invocation of the launched parent that has not claimed the carry record yet, `undefined` once
23
+ // no such launch is alive.
24
+ pending_invocation: () => string | undefined
25
+ is_owner_live: (carry: RunCarry) => boolean
26
+ sleep: (milliseconds: number) => Promise<void>
27
+ }
28
+
29
+ type ClaimResult =
30
+ | { kind: 'claimed'; wake: RunWake; carry: RunCarry }
31
+ | { kind: 'running' }
32
+ | { kind: 'refused'; carry_kind: CarryRead['kind'] }
33
+
34
+ function is_live_carry(read: CarryRead, ports: AwaitPorts): boolean {
35
+ return read.kind === 'carried' && ports.is_owner_live(read.carry)
36
+ }
37
+
38
+ // What the supervisor claims its record for: the live carried run, else the launch about to carry one,
39
+ // else the carried run a supervisor started with no launch pending wakes.
40
+ function supervised_invocation(ports: AwaitPorts): string | undefined {
41
+ const read = ports.read_carry()
42
+
43
+ if (read.kind !== 'carried') return ports.pending_invocation()
44
+ if (ports.is_owner_live(read.carry)) return read.carry.invocation
45
+
46
+ return ports.pending_invocation() ?? read.carry.invocation
47
+ }
48
+
49
+ // **The carry record is read once more after the launch is gone**, because a parent that wrote the
50
+ // record and ended between the two reads still left a run to supervise.
51
+ async function await_carry(ports: AwaitPorts, interval_ms: number): Promise<CarryRead> {
52
+ const read = ports.read_carry()
53
+
54
+ if (is_live_carry(read, ports)) return read
55
+ if (ports.pending_invocation() === undefined) return ports.read_carry()
56
+
57
+ await ports.sleep(interval_ms)
58
+
59
+ return await await_carry(ports, interval_ms)
60
+ }
61
+
62
+ function ports_of(context: WakeContext): AwaitPorts {
63
+ return {
64
+ read_carry: () => run_carry.read_carry(context.carry_target),
65
+ pending_invocation: () => backlogrun_launch.pending_launch(context.git_directory)?.invocation,
66
+ is_owner_live: (carry) => run_carry.is_owner_live(carry),
67
+ sleep: poll.sleep,
68
+ }
69
+ }
70
+
71
+ // The run the claim is held for: the one claimed for, or **a live run carried under another
72
+ // invocation** — a person's own `run:carry --begin` landing before the launched parent's. That run's
73
+ // `run:wake --start` found this supervisor live and answered `running`, so letting the claim go here
74
+ // would leave it with nobody watching it.
75
+ function supervised_carry(
76
+ read: CarryRead,
77
+ invocation: string,
78
+ ports: AwaitPorts,
79
+ ): RunCarry | undefined {
80
+ if (read.kind !== 'carried') return undefined
81
+ if (read.carry.invocation === invocation || ports.is_owner_live(read.carry)) return read.carry
82
+
83
+ return undefined
84
+ }
85
+
86
+ // **The record is re-pointed at the run it now supervises**, because the loop wakes sessions for the
87
+ // record's invocation. **It carries the state a claim for that run would**, not the state the claim
88
+ // inherited for the pending launch's invocation — that run's wakes are not this one's. A refused write
89
+ // means the record is no longer this process's, which the loop's first pass reads as the stop it is.
90
+ function held_for(
91
+ target: string,
92
+ wake: RunWake,
93
+ carry: RunCarry,
94
+ profile: AgentProfile,
95
+ ): ClaimResult {
96
+ if (carry.invocation === wake.invocation) return { kind: 'claimed', wake, carry }
97
+
98
+ const supervised = run_wake.fresh_wake(carry.invocation, new Date(), profile)
99
+
100
+ run_wake.update_wake(target, supervised)
101
+
102
+ return { kind: 'claimed', wake: supervised, carry }
103
+ }
104
+
105
+ // **The record is claimed before the carry record is awaited**, so the parent's own `run:wake --start`
106
+ // finds this supervisor live and answers `running` rather than starting a second one beside it.
107
+ // A parent that ends without carrying a run leaves nothing to supervise, and the claim is let go.
108
+ async function claim_run(
109
+ context: WakeContext,
110
+ profile: AgentProfile,
111
+ interval_ms: number,
112
+ ports: AwaitPorts = ports_of(context),
113
+ ): Promise<ClaimResult> {
114
+ const invocation = supervised_invocation(ports)
115
+ if (invocation === undefined) return { kind: 'refused', carry_kind: ports.read_carry().kind }
116
+ const wake = run_wake.claim(context.wake_target, invocation, new Date(), profile)
117
+ if (wake === undefined) return { kind: 'running' }
118
+ const read = await await_carry(ports, interval_ms)
119
+ const carry = supervised_carry(read, invocation, ports)
120
+ if (carry !== undefined) return held_for(context.wake_target, wake, carry, profile)
121
+
122
+ run_wake.tidy_own_wake(context.wake_target)
123
+
124
+ return { kind: 'refused', carry_kind: read.kind }
125
+ }
126
+
127
+ const run_wake_await = { await_carry, claim_run, supervised_invocation }
128
+
129
+ export type { AwaitPorts, ClaimResult }
130
+ export { run_wake_await }