@joshuafolkken/kit 1.1387.0 → 1.1453.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (278) hide show
  1. package/.claude/skills/epic-commands/SKILL.md +1 -1
  2. package/.claude/skills/epic-commands/epic-bundle.md +5 -0
  3. package/.claude/skills/workflow-commands/backlogrun-lanes.md +5 -3
  4. package/.claude/skills/workflow-commands/backlogrun-park.md +2 -0
  5. package/.claude/skills/workflow-commands/backlogrun-steps.md +13 -10
  6. package/.claude/skills/workflow-commands/backlogrun.md +3 -2
  7. package/.claude/skills/workflow-commands/chain-rule.md +4 -2
  8. package/.claude/skills/workflow-commands/fullrun-steps.md +1 -1
  9. package/.claude/skills/workflow-commands/halfrun.md +1 -1
  10. package/.claude/skills/workflow-commands/issue-comments.md +6 -0
  11. package/.claude/skills/workflow-commands/kickoff.md +12 -13
  12. package/.claude/skills/workflow-commands/observation-filing.md +1 -1
  13. package/.claude/skills/workflow-commands/observation-ledger.md +3 -2
  14. package/.claude/skills/workflow-commands/pre-gate-cut.md +5 -3
  15. package/.claude/skills/workflow-commands/prerequisite.md +6 -0
  16. package/.claude/skills/workflow-commands/retrospective.md +5 -5
  17. package/.claude/skills/workflow-commands/split-assessment.md +2 -2
  18. package/README.md +27 -4
  19. package/dist/hooks/audit-provision.js +2 -2
  20. package/dist/hooks/batch-guard.js +7 -7
  21. package/dist/hooks/{chunk-2YSW3CXH.js → chunk-3C4XRYG6.js} +35 -11
  22. package/dist/hooks/{chunk-GJ7JPIHN.js → chunk-5C7L6FRC.js} +2704 -190
  23. package/dist/hooks/{chunk-FZ6FUSBM.js → chunk-723HKZO5.js} +1 -1
  24. package/dist/hooks/{chunk-STDSEPWU.js → chunk-D3NJXN5M.js} +4 -4
  25. package/dist/hooks/{chunk-NEC4AT4Z.js → chunk-DGT54IH5.js} +4 -0
  26. package/dist/hooks/{chunk-LMWBRJIP.js → chunk-EF5GQ6WS.js} +3 -3
  27. package/dist/hooks/{chunk-JBBMAPC4.js → chunk-IX6TTTI6.js} +4 -0
  28. package/dist/hooks/{chunk-IYVOLN6A.js → chunk-LBFYEOXJ.js} +3 -3
  29. package/dist/hooks/{chunk-SL4GQ2XP.js → chunk-MBJYQPEK.js} +106 -23
  30. package/dist/hooks/{chunk-WNFJLBXI.js → chunk-NO4AALP5.js} +3 -3
  31. package/dist/hooks/{chunk-J3KSS7II.js → chunk-OLMO72ON.js} +1 -1
  32. package/dist/hooks/{chunk-DCY2GWHC.js → chunk-SHYUQOIS.js} +5 -5
  33. package/dist/hooks/{chunk-EAQEGME7.js → chunk-UT7YC3N2.js} +1 -1
  34. package/dist/hooks/{chunk-EBSHN25W.js → chunk-W5ZKGTSY.js} +4 -4
  35. package/dist/hooks/{chunk-GNDJ7NJ7.js → chunk-Y3GKIKGW.js} +209 -56
  36. package/dist/hooks/{chunk-U32VSRNG.js → chunk-Z4XI7CZS.js} +89 -34
  37. package/dist/hooks/codex-hook-adapter.js +15 -15
  38. package/dist/hooks/duplicate-read-guard.js +8 -8
  39. package/dist/hooks/format-edited.js +5 -5
  40. package/dist/hooks/inputs.json +261 -201
  41. package/dist/hooks/investigation-guard.js +8 -8
  42. package/dist/hooks/pretool-guard.js +14 -14
  43. package/dist/hooks/session-lang.js +69 -16
  44. package/dist/hooks/stop-guard.js +13 -38
  45. package/dist/josh.js +95 -19
  46. package/package.json +4 -3
  47. package/prompts/collaboration-workflow/issue-template.md +10 -8
  48. package/prompts/review.md +30 -4
  49. package/scripts/agent/claude-agent-argv.ts +13 -1
  50. package/scripts/auth/auth-setup-cli.ts +101 -0
  51. package/scripts/auth/claude-token-output.ts +78 -0
  52. package/scripts/auth/claude-token.ts +68 -0
  53. package/scripts/auto-ok/auto-ok-judge-cli.ts +125 -0
  54. package/scripts/auto-ok/auto-ok-judge-read.ts +95 -0
  55. package/scripts/auto-ok/auto-ok-judge-write.ts +109 -0
  56. package/scripts/auto-ok/auto-ok-judge.ts +67 -0
  57. package/scripts/backlog/backlog-drive-cli.ts +14 -0
  58. package/scripts/backlog/backlog-drive-epic.ts +24 -8
  59. package/scripts/backlog/backlog-drive-launch.ts +3 -0
  60. package/scripts/backlog/backlog-drive-named-offer.ts +7 -2
  61. package/scripts/backlog/backlog-drive-named.ts +4 -2
  62. package/scripts/backlog/backlog-drive-unread.ts +56 -0
  63. package/scripts/backlog/backlog-drive.ts +38 -11
  64. package/scripts/backlog/backlog-overlap.ts +22 -5
  65. package/scripts/backlog/backlog-ready.ts +1 -3
  66. package/scripts/backlog/backlogrun-args.ts +13 -2
  67. package/scripts/backlog/backlogrun-cli.ts +91 -19
  68. package/scripts/backlog/backlogrun-launch.ts +24 -0
  69. package/scripts/backlog/backlogrun-replace.ts +166 -0
  70. package/scripts/backlog/backlogrun-stop.ts +178 -0
  71. package/scripts/behavior/behavior-cli.ts +6 -1
  72. package/scripts/doctor/doctor-io.ts +60 -9
  73. package/scripts/doctor/doctor.ts +20 -1
  74. package/scripts/document/document-byte-budget.ts +3 -3
  75. package/scripts/epic/epic-bundle-cli.ts +8 -8
  76. package/scripts/epic/epic-bundle-referenced.ts +2 -5
  77. package/scripts/epic/epic-bundle.ts +39 -4
  78. package/scripts/exports/unused-members-cli.ts +2 -1
  79. package/scripts/followup/followup-checks-event.ts +82 -0
  80. package/scripts/followup/git-followup-finish.ts +16 -2
  81. package/scripts/followup/git-followup-workflow.ts +5 -2
  82. package/scripts/followup/git-pr-followup-checks.ts +18 -4
  83. package/scripts/followup/git-pr-followup-wrapup.ts +5 -0
  84. package/scripts/followup/git-pr-followup.ts +4 -1
  85. package/scripts/fresh-machine/fresh-machine-cli.ts +204 -0
  86. package/scripts/fresh-machine/fresh-machine-docker.ts +65 -0
  87. package/scripts/fresh-machine/fresh-machine-image.ts +62 -0
  88. package/scripts/fresh-machine/fresh-machine-log.ts +61 -0
  89. package/scripts/fresh-machine/fresh-machine-patterns.ts +175 -0
  90. package/scripts/fresh-machine/fresh-machine-run.ts +215 -0
  91. package/scripts/fresh-machine/fresh-machine-script.ts +45 -0
  92. package/scripts/fresh-machine/fresh-machine-spec.ts +57 -0
  93. package/scripts/fresh-machine/specs/kit-test-backlogrun-python.json +34 -0
  94. package/scripts/fresh-machine/specs/kit-test-backlogrun.json +31 -0
  95. package/scripts/gate/gate-report.ts +70 -15
  96. package/scripts/gate/project-checks.ts +18 -4
  97. package/scripts/gate/scoped-green.ts +2 -1
  98. package/scripts/gate/verification-gate.ts +4 -3
  99. package/scripts/gh/gh-author-association.ts +14 -0
  100. package/scripts/gh/git-gh-conditional.ts +66 -0
  101. package/scripts/gh/git-gh-exec.ts +1 -1
  102. package/scripts/gh/git-gh-issue-read.ts +6 -3
  103. package/scripts/gh/git-gh-issue-rest.ts +1 -0
  104. package/scripts/gh/git-gh-issue-write.ts +5 -1
  105. package/scripts/gh/git-gh-issue.ts +13 -1
  106. package/scripts/gh/git-pr-checks-eval.ts +13 -0
  107. package/scripts/gh/git-pr-checks-parse.ts +8 -1
  108. package/scripts/gh/git-pr-checks-watch.ts +11 -4
  109. package/scripts/gh/git-pr-checks.ts +36 -2
  110. package/scripts/git/git-command.ts +16 -4
  111. package/scripts/git/git-schemas.ts +1 -0
  112. package/scripts/git/git-spawn-sync.ts +1 -1
  113. package/scripts/hooks/pretool-guard.ts +3 -1
  114. package/scripts/init/hook-command-rewrite.ts +31 -0
  115. package/scripts/init/init-actions.ts +7 -4
  116. package/scripts/init/init-ai-copy.ts +34 -11
  117. package/scripts/init/init-basic.ts +9 -4
  118. package/scripts/init/init-bootstrap.ts +6 -2
  119. package/scripts/init/init-dry-run-report.ts +150 -0
  120. package/scripts/init/init-dry-run.ts +189 -0
  121. package/scripts/init/init-install.ts +8 -1
  122. package/scripts/init/init-logic-toolchain.ts +76 -0
  123. package/scripts/init/init-logic.ts +2 -0
  124. package/scripts/init/init.ts +62 -42
  125. package/scripts/init/kit-setup-state.ts +11 -17
  126. package/scripts/init/own-formatter.ts +21 -0
  127. package/scripts/init/project-profile.ts +60 -12
  128. package/scripts/init/start-dry-run.ts +70 -0
  129. package/scripts/init/start-exec.ts +27 -1
  130. package/scripts/init/start-plan.ts +23 -3
  131. package/scripts/init/start-setup-pr.ts +13 -24
  132. package/scripts/init/start-steps.ts +62 -1
  133. package/scripts/init/start.ts +38 -17
  134. package/scripts/init/workflow-mark.ts +35 -0
  135. package/scripts/init/working-tree.ts +116 -0
  136. package/scripts/issue/issue-ack-cli.ts +84 -0
  137. package/scripts/issue/issue-auto-ok.ts +68 -99
  138. package/scripts/issue/issue-file-cli.ts +17 -17
  139. package/scripts/issue/issue-file.ts +1 -6
  140. package/scripts/issue/issue-labels.ts +46 -10
  141. package/scripts/issue/issue-release-cli.ts +69 -23
  142. package/scripts/issue/issue-release.ts +3 -1
  143. package/scripts/issue/issue-state-cli.ts +14 -4
  144. package/scripts/issue/issue-watch-diff.ts +152 -0
  145. package/scripts/issue/issue-watch-gate.ts +81 -0
  146. package/scripts/issue/issue-watch-hook.ts +54 -0
  147. package/scripts/issue/issue-watch.ts +240 -0
  148. package/scripts/issue/measure-rerun-cli.ts +3 -4
  149. package/scripts/josh/josh-command-map.ts +4 -0
  150. package/scripts/josh/josh-commands-ai.ts +11 -0
  151. package/scripts/josh/josh-commands-backlog.ts +2 -2
  152. package/scripts/josh/josh-commands-development.ts +1 -1
  153. package/scripts/josh/josh-commands-issue.ts +10 -3
  154. package/scripts/josh/josh-commands-lane.ts +11 -2
  155. package/scripts/josh/josh-commands-project.ts +26 -2
  156. package/scripts/josh/josh-verdict.ts +33 -4
  157. package/scripts/josh/process-identity.ts +32 -6
  158. package/scripts/lane/claude-lane-broker.ts +142 -0
  159. package/scripts/lane/lane-await.ts +6 -2
  160. package/scripts/lane/lane-broker-server.ts +69 -0
  161. package/scripts/lane/lane-cache.ts +37 -7
  162. package/scripts/lane/lane-capacity.ts +8 -6
  163. package/scripts/lane/lane-child-invocation.ts +1 -0
  164. package/scripts/lane/lane-cli.ts +15 -1
  165. package/scripts/lane/lane-close.ts +15 -6
  166. package/scripts/lane/lane-dispatch.ts +36 -10
  167. package/scripts/lane/lane-environment.ts +1 -1
  168. package/scripts/lane/lane-launch-cli.ts +13 -4
  169. package/scripts/lane/lane-ledger.ts +21 -3
  170. package/scripts/lane/lane-measure-cli.ts +10 -2
  171. package/scripts/lane/lane-open.ts +9 -1
  172. package/scripts/lane/lane-preserve.ts +49 -0
  173. package/scripts/lane/lane-reap.ts +20 -5
  174. package/scripts/lane/lane-registry.ts +6 -0
  175. package/scripts/lane/lane-restart-cli.ts +51 -0
  176. package/scripts/lane/lane-restart.ts +175 -0
  177. package/scripts/lane/lane-session-broker.ts +363 -0
  178. package/scripts/lane/lane-spent.ts +195 -0
  179. package/scripts/lane/lane-stage-stats.ts +27 -3
  180. package/scripts/lane/lane-stop-stats.ts +96 -0
  181. package/scripts/lane/openai-lane-supervisor.ts +2 -2
  182. package/scripts/lane/process-tools.ts +31 -0
  183. package/scripts/lib/buffered-process.ts +36 -2
  184. package/scripts/lib/pnpm-reach.ts +27 -0
  185. package/scripts/lib/status-icons.ts +5 -1
  186. package/scripts/lint/lint-parallel.ts +19 -2
  187. package/scripts/observations/observation-ledger-home.ts +1 -0
  188. package/scripts/observations/observation-ledger-migrate.ts +3 -15
  189. package/scripts/release/release-issue-close.ts +133 -0
  190. package/scripts/release/release-publish.ts +12 -1
  191. package/scripts/repo/repository-labels.ts +11 -1
  192. package/scripts/review/comment-ranges.ts +199 -0
  193. package/scripts/review/review-attest-cli.ts +21 -6
  194. package/scripts/review/review-brief-cli.ts +24 -7
  195. package/scripts/review/review-code-tree.ts +119 -0
  196. package/scripts/review/review-comment-round.ts +114 -0
  197. package/scripts/review/review-record-cli.ts +39 -17
  198. package/scripts/review/review-round2-cli.ts +16 -5
  199. package/scripts/review/review-round2.ts +30 -10
  200. package/scripts/review/review-stamps.ts +22 -1
  201. package/scripts/rules/implementation-cut.ts +3 -3
  202. package/scripts/rules/pre-gate-cut.ts +1 -1
  203. package/scripts/run/board/run-board-checks.ts +42 -0
  204. package/scripts/run/board/run-board-cli.ts +2 -0
  205. package/scripts/run/board/run-board-every.ts +3 -1
  206. package/scripts/run/board/run-board-fit.ts +16 -5
  207. package/scripts/run/board/run-board-fresh.ts +64 -0
  208. package/scripts/run/board/run-board-header.ts +129 -53
  209. package/scripts/run/board/run-board-labels.ts +47 -4
  210. package/scripts/run/board/run-board-lane-event.ts +66 -0
  211. package/scripts/run/board/run-board-layout.ts +57 -9
  212. package/scripts/run/board/run-board-now.ts +234 -0
  213. package/scripts/run/board/run-board-operation-read.ts +75 -0
  214. package/scripts/run/board/run-board-operation.ts +150 -0
  215. package/scripts/run/board/run-board-phase.ts +26 -1
  216. package/scripts/run/board/run-board-plan.ts +4 -6
  217. package/scripts/run/board/run-board-read.ts +49 -3
  218. package/scripts/run/board/run-board-reason.ts +124 -0
  219. package/scripts/run/board/run-board-render-checks.ts +55 -0
  220. package/scripts/run/board/run-board-render-legend.ts +12 -3
  221. package/scripts/run/board/run-board-render-slot.ts +2 -2
  222. package/scripts/run/board/run-board-render-tail.ts +128 -0
  223. package/scripts/run/board/run-board-render-thanks.ts +50 -0
  224. package/scripts/run/board/run-board-render-title.ts +58 -0
  225. package/scripts/run/board/run-board-render.ts +96 -80
  226. package/scripts/run/board/run-board-row-yield.ts +32 -0
  227. package/scripts/run/board/run-board-setup.ts +61 -13
  228. package/scripts/run/board/run-board-state.ts +10 -0
  229. package/scripts/run/board/run-board-status.ts +66 -7
  230. package/scripts/run/board/run-board-thanks-csv.ts +71 -0
  231. package/scripts/run/board/run-board-thanks-read.ts +112 -0
  232. package/scripts/run/board/run-board-thanks.ts +53 -0
  233. package/scripts/run/board/run-board-tick.ts +50 -21
  234. package/scripts/run/board/run-board-usual.ts +44 -0
  235. package/scripts/run/carry/run-carry-args.ts +9 -1
  236. package/scripts/run/carry/run-carry-cli.ts +2 -45
  237. package/scripts/run/carry/run-carry-end.ts +73 -0
  238. package/scripts/run/carry/run-carry.ts +8 -1
  239. package/scripts/run/entry/run-entry-cli.ts +5 -0
  240. package/scripts/run/event/run-event-checks.ts +54 -0
  241. package/scripts/run/event/run-event-render.ts +2 -0
  242. package/scripts/run/event/run-event-scope.ts +1 -0
  243. package/scripts/run/event/run-event-setup.ts +55 -11
  244. package/scripts/run/event/run-event-stream.ts +27 -11
  245. package/scripts/run/merge/run-merge-cli.ts +7 -4
  246. package/scripts/run/merge/run-merge-ship.ts +61 -0
  247. package/scripts/run/merge/run-merge-steps.ts +7 -14
  248. package/scripts/run/merge/run-merge-token.ts +16 -2
  249. package/scripts/run/run-headless.ts +1 -0
  250. package/scripts/run/run-step.ts +2 -0
  251. package/scripts/run/ship/run-ship-cli.ts +22 -2
  252. package/scripts/run/ship/run-ship-return.ts +31 -13
  253. package/scripts/run/ship/run-ship-review-steps.ts +17 -4
  254. package/scripts/run/ship/run-ship-stage.ts +1 -0
  255. package/scripts/run/wake/run-wake-await.ts +130 -0
  256. package/scripts/run/wake/run-wake-cli.ts +60 -71
  257. package/scripts/run/wake/run-wake-describe.ts +34 -2
  258. package/scripts/run/wake/run-wake-driver.ts +11 -4
  259. package/scripts/run/wake/run-wake-session.ts +20 -3
  260. package/scripts/run/wake/run-wake-stop.ts +37 -0
  261. package/scripts/run/wake/run-wake-supervise.ts +44 -0
  262. package/scripts/sync/claude-settings-merge.ts +144 -0
  263. package/scripts/sync/sync-ai-files.ts +28 -3
  264. package/scripts/sync/sync.ts +15 -0
  265. package/scripts/test/project-test-script.ts +73 -0
  266. package/scripts/test/skip-marker.ts +14 -1
  267. package/scripts/test/test-related.ts +3 -2
  268. package/scripts/test/test-unit-guard.ts +78 -14
  269. package/scripts/time-runtime/time-reported-failure.ts +11 -3
  270. package/scripts/uninstall/distributed-files.ts +92 -0
  271. package/scripts/uninstall/uninstall-plan.ts +119 -0
  272. package/scripts/uninstall/uninstall-report.ts +61 -0
  273. package/scripts/uninstall/uninstall.ts +206 -0
  274. package/templates/AGENTS.basic.md +4 -0
  275. package/templates/GEMINI.basic.md +6 -0
  276. package/dist/hooks/chunk-6V5OPIUU.js +0 -13
  277. package/scripts/lane/openai-review-broker.ts +0 -262
  278. package/scripts/run/board/run-board-track.ts +0 -22
@@ -0,0 +1,363 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { setTimeout as sleep } from 'node:timers/promises'
5
+ import { agent_argv } from '#scripts/agent/agent-argv'
6
+ import { agent_role_profile, type AgentProvider } from '#scripts/agent/agent-role-profile'
7
+ import { git_common_directory } from '#scripts/git/git-common-directory'
8
+ import { stamp_file } from '#scripts/josh/stamp-file'
9
+ import { error_text } from '#scripts/lib/error-message'
10
+ import { json_value } from '#scripts/lib/json-value'
11
+ import { detached_launch } from '#scripts/run/detached-launch'
12
+ import { run_ship_review } from '#scripts/run/ship/run-ship-review'
13
+ import { run_ship_stage } from '#scripts/run/ship/run-ship-stage'
14
+ import { z } from 'zod'
15
+ import { lane_broker_server, type BrokerServer } from './lane-broker-server'
16
+ import { lane_child_invocation, type ShipStop } from './lane-child-invocation'
17
+ import { lane_child_marker } from './lane-child-marker'
18
+ import type { LaneInfo } from './lane-registry'
19
+ import { lane_relaunch } from './lane-relaunch'
20
+
21
+ // The sessions a lane child's detached `josh ship` cannot start itself, started for it by a process
22
+ // that can: the round-1 and round-2 reviewer, and the fresh child a stopped ship hands back to. An
23
+ // OpenAI lane's own supervisor serves it, because a nested Codex cannot write outside its sandbox; a
24
+ // Claude lane is served by the `run:wake` supervisor (`claude-lane-broker.ts`), because Claude Code removes its
25
+ // token from what its Bash tool starts, so a token-only credential never reaches the ship the child
26
+ // launched (joshuafolkken/kit#3817). **The request names an action, never a command**: the server
27
+ // rebuilds the argv from the registered lane, so a child can ask for nothing but these two sessions.
28
+
29
+ const REQUEST_PREFIX = '.josh-review-request-'
30
+ const RESPONSE_PREFIX = '.josh-review-response-'
31
+ const BRIEF_PREFIX = 'josh-ship-review-brief-'
32
+ const FINDINGS_PREFIX = 'josh-ship-review-findings-'
33
+ const LOG_PREFIX = 'josh-ship-review-log-'
34
+ const CACHE_DIRECTORY = ['node_modules', '.cache']
35
+ const POLL_MS = 100
36
+ // The heartbeat is kept for a ship branched before the server record, and read only where a
37
+ // supervisor started before it left no such record (`lane-broker-server.ts`).
38
+ const LEGACY_HEARTBEAT_MS = 500
39
+ const LEGACY_HEARTBEAT_LIMIT_MS = 3000
40
+ const MINUTE_MS = 60_000
41
+ const REQUEST_LIMIT_MINUTES = 30
42
+ const REQUEST_LIMIT_MS = REQUEST_LIMIT_MINUTES * MINUTE_MS
43
+ const ANTHROPIC_PROVIDER: AgentProvider = 'anthropic'
44
+ const stop_schema = z
45
+ .object({
46
+ stage: z.enum(run_ship_stage.STAGE),
47
+ title: z.string(),
48
+ flags: z.array(z.string()).readonly(),
49
+ is_review: z.boolean(),
50
+ conflicts: z.array(z.string()).readonly().default([]),
51
+ })
52
+ .strict()
53
+ const action_schema = z.discriminatedUnion('kind', [
54
+ z.object({ kind: z.literal('review'), round: z.enum(['1', '2']) }).strict(),
55
+ z.object({ kind: z.literal('relaunch'), stop: stop_schema }).strict(),
56
+ ])
57
+ const request_schema = z
58
+ .object({ session: z.uuid(), nonce: z.uuid(), action: action_schema })
59
+ .strict()
60
+ const response_schema = z
61
+ .object({
62
+ session: z.uuid(),
63
+ nonce: z.string(),
64
+ is_success: z.boolean(),
65
+ heartbeat_at: z.number().int().positive(),
66
+ })
67
+ .strict()
68
+ type BrokerRequest = z.infer<typeof request_schema>
69
+ type BrokerResponse = z.infer<typeof response_schema>
70
+ type ReviewRound = Extract<z.infer<typeof action_schema>, { kind: 'review' }>['round']
71
+ type BrokerAction = { kind: 'review'; round: ReviewRound } | { kind: 'relaunch'; stop: ShipStop }
72
+
73
+ function review_paths(directory: string): { brief: string; findings: string; log: string } {
74
+ return {
75
+ brief: stamp_file.stamp_path(BRIEF_PREFIX, directory, '.md'),
76
+ findings: stamp_file.stamp_path(FINDINGS_PREFIX, directory, '.txt'),
77
+ log: stamp_file.stamp_path(LOG_PREFIX, directory, '.log'),
78
+ }
79
+ }
80
+
81
+ function request_path(directory: string, issue: string): string {
82
+ return path.join(directory, ...CACHE_DIRECTORY, `${REQUEST_PREFIX}${issue}.json`)
83
+ }
84
+
85
+ function response_path(directory: string, issue: string): string | undefined {
86
+ const common = git_common_directory.resolve(directory)
87
+ if (common === undefined || path.basename(common) !== '.git') return undefined
88
+
89
+ return path.join(path.dirname(common), ...CACHE_DIRECTORY, `${RESPONSE_PREFIX}${issue}.json`)
90
+ }
91
+
92
+ function read_record<T>(target: string, schema: z.ZodType<T>): T | undefined {
93
+ try {
94
+ return json_value.parse_with(readFileSync(target, 'utf8'), schema)
95
+ } catch {
96
+ return undefined
97
+ }
98
+ }
99
+
100
+ // The reviewer runs on the lane's own provider, so a Claude lane's server never starts a Codex reviewer
101
+ // and an OpenAI lane's never starts a Claude one.
102
+ function provider_of(lane: LaneInfo): AgentProvider {
103
+ return lane.profile?.provider ?? ANTHROPIC_PROVIDER
104
+ }
105
+
106
+ // **The reviewer carries the lane's mark explicitly**: the supervisor serving a Claude lane is no lane child,
107
+ // so a reviewer inheriting its environment would descend from the carry record's owner unmarked and be
108
+ // read as the `backlogrun` parent by every hook.
109
+
110
+ async function launch_review(lane: LaneInfo, round: ReviewRound): Promise<boolean> {
111
+ const target = review_paths(lane.directory)
112
+ const prompt_of =
113
+ round === '1' ? run_ship_review.reviewer_prompt : run_ship_review.verification_prompt
114
+ const built = agent_argv.resolve_in(
115
+ prompt_of(target.brief, target.findings),
116
+ agent_role_profile.REVIEWER,
117
+ lane.directory,
118
+ )
119
+ if (built.kind === 'rejected' || built.profile.provider !== provider_of(lane)) return false
120
+
121
+ const result = await detached_launch.launch_attached(
122
+ {
123
+ argv: built.argv,
124
+ cwd: lane.directory,
125
+ log_path: target.log,
126
+ profile: built.profile,
127
+ env: lane_child_marker.env_for(lane.issue),
128
+ },
129
+ console.error,
130
+ )
131
+
132
+ return result.kind === 'completed' && result.exit_code === 0
133
+ }
134
+
135
+ // An OpenAI lane's successor is its supervisor's to start, never a relaunch's.
136
+ function relaunch(lane: LaneInfo, stop: ShipStop): boolean {
137
+ if (lane_relaunch.is_openai_lane(lane)) return false
138
+
139
+ const invocation = lane_child_invocation.ship_stop_invocation(lane.issue, stop)
140
+ const phase = agent_role_profile.IMPLEMENTATION_PHASE
141
+ const result = lane_relaunch.relaunch(lane, invocation, phase, console.error)
142
+
143
+ return result.kind === 'launched'
144
+ }
145
+
146
+ async function perform(lane: LaneInfo, action: BrokerAction): Promise<boolean> {
147
+ if (action.kind === 'relaunch') return relaunch(lane, action.stop)
148
+
149
+ return await launch_review(lane, action.round)
150
+ }
151
+
152
+ function write_response(target: string, body: BrokerResponse): void {
153
+ const temporary = `${target}.${String(process.pid)}.tmp`
154
+
155
+ writeFileSync(temporary, JSON.stringify(body))
156
+ renameSync(temporary, target)
157
+ }
158
+
159
+ async function launched(lane: LaneInfo, action: BrokerAction): Promise<boolean> {
160
+ try {
161
+ return await perform(lane, action)
162
+ } catch (error) {
163
+ error_text.trace_swallowed('lane_session_broker.launched', error)
164
+
165
+ return false
166
+ }
167
+ }
168
+
169
+ async function process_request(
170
+ lane: LaneInfo,
171
+ response: string,
172
+ received: BrokerRequest,
173
+ state: { current: BrokerResponse },
174
+ ): Promise<void> {
175
+ const is_success = await launched(lane, received.action)
176
+
177
+ state.current = {
178
+ session: received.session,
179
+ nonce: received.nonce,
180
+ is_success,
181
+ heartbeat_at: Date.now(),
182
+ }
183
+ write_response(response, state.current)
184
+ }
185
+
186
+ async function serve(
187
+ lane: LaneInfo,
188
+ session: string,
189
+ response: string,
190
+ state: { is_running: boolean; current: BrokerResponse },
191
+ ): Promise<void> {
192
+ let last_nonce = ''
193
+ const target = request_path(lane.directory, lane.issue)
194
+
195
+ while (state.is_running) {
196
+ const received = read_record(target, request_schema)
197
+
198
+ if (received?.session === session && received.nonce !== last_nonce) {
199
+ last_nonce = received.nonce
200
+ // eslint-disable-next-line no-await-in-loop -- requests are served one at a time, in arrival order
201
+ await process_request(lane, response, received, state)
202
+ }
203
+
204
+ // eslint-disable-next-line no-await-in-loop -- polling: each read waits on the state the previous one saw
205
+ await sleep(POLL_MS)
206
+ }
207
+ }
208
+
209
+ // The server record is written before the response, so a ship that finds the response finds it too.
210
+ function prepare(lane: LaneInfo): { response: string; current: BrokerResponse } {
211
+ const response = response_path(lane.directory, lane.issue)
212
+ if (response === undefined) throw new Error('review broker has no trusted response location')
213
+
214
+ const session = randomUUID()
215
+
216
+ mkdirSync(path.dirname(response), { recursive: true })
217
+ mkdirSync(path.dirname(request_path(lane.directory, lane.issue)), { recursive: true })
218
+ lane_broker_server.write(response, session)
219
+ const current: BrokerResponse = {
220
+ session,
221
+ nonce: '',
222
+ is_success: false,
223
+ heartbeat_at: Date.now(),
224
+ }
225
+
226
+ write_response(response, current)
227
+
228
+ return { response, current }
229
+ }
230
+
231
+ function start_heartbeat(state: { current: BrokerResponse }, response: string): NodeJS.Timeout {
232
+ const heartbeat = setInterval(() => {
233
+ state.current = { ...state.current, heartbeat_at: Date.now() }
234
+ write_response(response, state.current)
235
+ }, LEGACY_HEARTBEAT_MS)
236
+
237
+ heartbeat.unref()
238
+
239
+ return heartbeat
240
+ }
241
+
242
+ // **The heartbeat outlives the request being served.** A ship waiting on a reviewer reads a stale
243
+ // heartbeat as a dead server, so stopping it first would fail a review the server is still running.
244
+ async function stop_server(
245
+ state: { is_running: boolean },
246
+ heartbeat: NodeJS.Timeout,
247
+ serving: Promise<void>,
248
+ response: string,
249
+ ): Promise<void> {
250
+ state.is_running = false
251
+ await serving
252
+ clearInterval(heartbeat)
253
+ unlinkSync(response)
254
+ lane_broker_server.remove(response)
255
+ }
256
+
257
+ async function with_server<T>(lane: LaneInfo, run: () => Promise<T>): Promise<T> {
258
+ const { response, current } = prepare(lane)
259
+ const state = { is_running: true, current }
260
+ const heartbeat = start_heartbeat(state, response)
261
+ const serving = serve(lane, current.session, response, state)
262
+
263
+ try {
264
+ return await run()
265
+ } finally {
266
+ await stop_server(state, heartbeat, serving, response)
267
+ }
268
+ }
269
+
270
+ function is_fresh_heartbeat(heartbeat_at: number): boolean {
271
+ const now = Date.now()
272
+
273
+ return heartbeat_at <= now && heartbeat_at >= now - LEGACY_HEARTBEAT_LIMIT_MS
274
+ }
275
+
276
+ // Live by the server record (`lane-broker-server.ts`); a response with none was written by a
277
+ // supervisor started before it, read by the heartbeat that supervisor still writes.
278
+ function is_live_response(
279
+ target: string,
280
+ response: BrokerResponse | undefined,
281
+ is_live: (server: BrokerServer) => boolean,
282
+ ): response is BrokerResponse {
283
+ if (response === undefined) return false
284
+
285
+ const server = lane_broker_server.read(target, response.session)
286
+
287
+ return server === undefined ? is_fresh_heartbeat(response.heartbeat_at) : is_live(server)
288
+ }
289
+
290
+ function session_response(target: string, session: string): BrokerResponse | undefined {
291
+ const response = read_record(target, response_schema)
292
+
293
+ return response?.session === session ? response : undefined
294
+ }
295
+
296
+ function response_verdict(
297
+ target: string,
298
+ session: string,
299
+ nonce: string,
300
+ ): 'wait' | 'success' | 'failure' {
301
+ const response = session_response(target, session)
302
+
303
+ if (!is_live_response(target, response, lane_broker_server.is_running)) return 'failure'
304
+ if (response.nonce !== nonce) return 'wait'
305
+
306
+ return response.is_success ? 'success' : 'failure'
307
+ }
308
+
309
+ async function wait_response(target: string, session: string, nonce: string): Promise<boolean> {
310
+ const deadline = Date.now() + REQUEST_LIMIT_MS
311
+
312
+ while (Date.now() < deadline) {
313
+ const verdict = response_verdict(target, session, nonce)
314
+ if (verdict !== 'wait') return verdict === 'success'
315
+
316
+ // eslint-disable-next-line no-await-in-loop -- polling: each read waits on the state the previous one saw
317
+ await sleep(POLL_MS)
318
+ }
319
+
320
+ return false
321
+ }
322
+
323
+ function live_response(target: string): BrokerResponse | undefined {
324
+ const response = read_record(target, response_schema)
325
+
326
+ return is_live_response(target, response, lane_broker_server.is_serving_process)
327
+ ? response
328
+ : undefined
329
+ }
330
+
331
+ // Whether a server answers for this lane now — what a Claude lane's ship asks before it hands a session
332
+ // over rather than starting the session itself.
333
+ function is_serving(directory: string, issue: string): boolean {
334
+ const target = response_path(directory, issue)
335
+
336
+ return target !== undefined && live_response(target) !== undefined
337
+ }
338
+
339
+ async function request(directory: string, issue: string, action: BrokerAction): Promise<boolean> {
340
+ const target = response_path(directory, issue)
341
+ const response = target === undefined ? undefined : live_response(target)
342
+
343
+ if (target === undefined || response === undefined) return false
344
+
345
+ const nonce = randomUUID()
346
+ const body = { session: response.session, nonce, action }
347
+
348
+ stamp_file.replace_stamp(request_path(directory, issue), body)
349
+
350
+ return await wait_response(target, response.session, nonce)
351
+ }
352
+
353
+ const lane_session_broker = {
354
+ is_serving,
355
+ request,
356
+ request_path,
357
+ response_path,
358
+ review_paths,
359
+ with_server,
360
+ }
361
+
362
+ export type { BrokerAction, ReviewRound }
363
+ export { lane_session_broker }
@@ -0,0 +1,195 @@
1
+ import { backlog_drive_restore } from '#scripts/backlog/backlog-drive-restore'
2
+ import { git_gh_issue_read } from '#scripts/gh/git-gh-issue-read'
3
+ import { git_command } from '#scripts/git/git-command'
4
+ import { issue_state } from '#scripts/issue/issue-state'
5
+ import { run_event_stream_emit } from '#scripts/run/event/run-event-stream-emit'
6
+ import { run_hold } from '#scripts/run/hold/run-hold'
7
+ import {
8
+ PROCESS_ALIVE,
9
+ PROCESS_NONE,
10
+ PROCESS_UNKNOWN,
11
+ type ProcessTrace,
12
+ } from '#scripts/run/run-liveness'
13
+ import { lane_await } from './lane-await'
14
+ import { lane_registry, type LaneInfo } from './lane-registry'
15
+
16
+ // Whether a lane is in use, and whether it is spent — the one reading `lane:prune` discards a lane
17
+ // on and `josh backlogrun` keeps a watching run on.
18
+ //
19
+ // **In use means a child is working in it, or nobody could tell.** A lane a past run left behind has
20
+ // no process, and counting it as open kept a watching run from being replaced for as long as the lane
21
+ // sat there. An unanswered probe is in use: both callers act on "nobody is here", and the lane a
22
+ // wrong answer costs is one with a child mid-change.
23
+ //
24
+ // **Spent is all five: no child, the issue closed, not in flight, a clean tree, no commit off every
25
+ // remote.** `lane:close` forces the removal, so each condition names something a close cannot give
26
+ // back — and a condition that could not be read keeps the lane, with the reason said.
27
+ //
28
+ // **A lane the running `backlogrun` still has in flight is its `run:merge`'s.** A child that merged
29
+ // while no driver watched leaves a lane that reads spent on every other count; closed here, the run
30
+ // would have no `merge` event, no ledger line and no merged count for it (`run-tidy-lanes.ts`).
31
+
32
+ const STATE_FIELD = 'state'
33
+ const CLOSED_STATE = 'CLOSED'
34
+
35
+ interface SpentProbes {
36
+ process_trace: (issue: string) => ProcessTrace
37
+ issue_state: (issue: string) => Promise<string | undefined>
38
+ // The issues the running `backlogrun` launched and has not settled.
39
+ in_flight: () => Promise<ReadonlySet<string>>
40
+ is_tree_dirty: (directory: string) => Promise<boolean>
41
+ unpushed_commits: (branch: string) => Promise<number | undefined>
42
+ }
43
+
44
+ interface KeptLane {
45
+ issue: string
46
+ reason: string
47
+ }
48
+
49
+ // The lanes that are in use, apart: a probe that did not answer is not a child seen working.
50
+ interface LaneUse {
51
+ working: Array<string>
52
+ unread: Array<string>
53
+ }
54
+
55
+ // One probe answer per lane: a second sample can disagree with the first, and a lane bucketed on
56
+ // two answers can land in neither list.
57
+ type TracedLane = readonly [issue: string, trace: ProcessTrace]
58
+
59
+ interface SpentSort {
60
+ spent: Array<LaneInfo>
61
+ kept: Array<KeptLane>
62
+ }
63
+
64
+ type Check = (lane: LaneInfo, probes: SpentProbes) => Promise<string | undefined>
65
+
66
+ async function read_issue_state(issue: string): Promise<string | undefined> {
67
+ const json = await git_gh_issue_read.issue_view_json(issue, STATE_FIELD)
68
+
69
+ return json === undefined ? undefined : issue_state.parse_issue_state(json)?.state
70
+ }
71
+
72
+ async function read_unpushed_commits(branch: string): Promise<number | undefined> {
73
+ try {
74
+ return await git_command.commit_count_unpushed(branch)
75
+ } catch {
76
+ return undefined
77
+ }
78
+ }
79
+
80
+ async function read_in_flight(): Promise<ReadonlySet<string>> {
81
+ return backlog_drive_restore.active_issues(await run_event_stream_emit.current_events())
82
+ }
83
+
84
+ const LIVE_PROBES: SpentProbes = {
85
+ process_trace: lane_await.process_trace_default,
86
+ issue_state: read_issue_state,
87
+ in_flight: read_in_flight,
88
+ is_tree_dirty: run_hold.is_tree_dirty,
89
+ unpushed_commits: read_unpushed_commits,
90
+ }
91
+
92
+ // A stranded lane has no work tree for a child to be in, so it is never in use.
93
+ function trace_of(lane: LaneInfo, probes: SpentProbes): ProcessTrace {
94
+ return lane.is_stranded ? PROCESS_NONE : probes.process_trace(lane.issue)
95
+ }
96
+
97
+ async function child_reason(lane: LaneInfo, probes: SpentProbes): Promise<string | undefined> {
98
+ const trace = probes.process_trace(lane.issue)
99
+
100
+ if (trace === PROCESS_NONE) return undefined
101
+
102
+ return trace === PROCESS_ALIVE
103
+ ? 'a child is still working in it'
104
+ : 'whether a child is working in it could not be read'
105
+ }
106
+
107
+ async function issue_reason(lane: LaneInfo, probes: SpentProbes): Promise<string | undefined> {
108
+ const state = await probes.issue_state(lane.issue)
109
+
110
+ if (state === CLOSED_STATE) return undefined
111
+
112
+ return state === undefined ? 'its issue could not be read' : 'its issue is still open'
113
+ }
114
+
115
+ async function run_reason(lane: LaneInfo, probes: SpentProbes): Promise<string | undefined> {
116
+ const in_flight = await probes.in_flight()
117
+
118
+ return in_flight.has(lane.issue)
119
+ ? 'the running backlogrun has not collected it yet (`pnpm josh run:merge` does)'
120
+ : undefined
121
+ }
122
+
123
+ // `is_tree_dirty` answers dirty for a status it could not read, which is the side that keeps the lane.
124
+ async function tree_reason(lane: LaneInfo, probes: SpentProbes): Promise<string | undefined> {
125
+ return (await probes.is_tree_dirty(lane.directory)) ? 'it has uncommitted changes' : undefined
126
+ }
127
+
128
+ async function commit_reason(lane: LaneInfo, probes: SpentProbes): Promise<string | undefined> {
129
+ const count = await probes.unpushed_commits(lane.branch)
130
+
131
+ if (count === 0) return undefined
132
+
133
+ return count === undefined
134
+ ? 'its commits could not be counted'
135
+ : `it has ${String(count)} commit(s) that are on no remote`
136
+ }
137
+
138
+ // In the order a person acts on them: a running child outranks everything, and an open issue is why
139
+ // the lane exists at all.
140
+ const CHECKS: ReadonlyArray<Check> = [
141
+ child_reason,
142
+ issue_reason,
143
+ run_reason,
144
+ tree_reason,
145
+ commit_reason,
146
+ ]
147
+
148
+ // Why the lane is kept, or `undefined` for a spent one.
149
+ async function kept_reason(
150
+ lane: LaneInfo,
151
+ probes: SpentProbes = LIVE_PROBES,
152
+ ): Promise<string | undefined> {
153
+ const reasons = await Promise.all(CHECKS.map(async (check) => await check(lane, probes)))
154
+
155
+ return reasons.find((reason) => reason !== undefined)
156
+ }
157
+
158
+ // Every live lane as spent or kept. A stranded lane is neither: `lane:prune` closes it already.
159
+ async function sort_lanes(
160
+ lanes: ReadonlyArray<LaneInfo>,
161
+ probes: SpentProbes = LIVE_PROBES,
162
+ ): Promise<SpentSort> {
163
+ const live = lanes.filter((lane) => !lane.is_stranded)
164
+ const reasons = await Promise.all(live.map(async (lane) => await kept_reason(lane, probes)))
165
+ const sorted: SpentSort = { spent: [], kept: [] }
166
+
167
+ for (const [index, lane] of live.entries()) {
168
+ const reason = reasons[index]
169
+
170
+ if (reason === undefined) sorted.spent.push(lane)
171
+ else sorted.kept.push({ issue: lane.issue, reason })
172
+ }
173
+
174
+ return sorted
175
+ }
176
+
177
+ function issues_traced(traced: ReadonlyArray<TracedLane>, trace: ProcessTrace): Array<string> {
178
+ return traced.filter(([, answer]) => answer === trace).map(([issue]) => issue)
179
+ }
180
+
181
+ /** The lanes in use — what a run being replaced must not be stopped over. */
182
+ async function lanes_in_use(probes: SpentProbes = LIVE_PROBES): Promise<LaneUse> {
183
+ const lanes = await lane_registry.list_lanes()
184
+ const traced = lanes.map((lane): TracedLane => [lane.issue, trace_of(lane, probes)])
185
+
186
+ return {
187
+ working: issues_traced(traced, PROCESS_ALIVE),
188
+ unread: issues_traced(traced, PROCESS_UNKNOWN),
189
+ }
190
+ }
191
+
192
+ const lane_spent = { kept_reason, lanes_in_use, sort_lanes }
193
+
194
+ export type { KeptLane, LaneUse, SpentProbes, SpentSort }
195
+ export { lane_spent }
@@ -112,11 +112,15 @@ function stage_row(stage: string, measured: Measured): string {
112
112
  ])
113
113
  }
114
114
 
115
+ function measured_of(entries: ReadonlyArray<LedgerEntry>): Measured {
116
+ const stages = entries.filter(is_stage)
117
+
118
+ return { implement: implement_durations(entries.filter(is_dispatch), stages), stages }
119
+ }
120
+
115
121
  // The period's table: `entries` is the whole ledger, filtered here to `window`.
116
122
  function table(entries: ReadonlyArray<LedgerEntry>, window: StatsWindow): string {
117
- const period = lane_stats.in_window(entries, window)
118
- const stages = period.filter(is_stage)
119
- const measured = { implement: implement_durations(period.filter(is_dispatch), stages), stages }
123
+ const measured = measured_of(lane_stats.in_window(entries, window))
120
124
 
121
125
  return [
122
126
  lane_stats.to_row(COLUMNS),
@@ -125,10 +129,30 @@ function table(entries: ReadonlyArray<LedgerEntry>, window: StatsWindow): string
125
129
  ].join('\n')
126
130
  }
127
131
 
132
+ // Each stage's median over every entry given, a stage nothing recorded left out rather than read as
133
+ // `0` — how long a stage usually takes, which `run:board` measures a running child's phase against.
134
+ function medians(entries: ReadonlyArray<LedgerEntry>): ReadonlyMap<string, number> {
135
+ const measured = measured_of(entries)
136
+
137
+ return new Map(
138
+ STAGES.flatMap((stage) => {
139
+ const median = lane_stats.median(durations_of(stage, measured))
140
+
141
+ return median === undefined ? [] : [[stage, median] as const]
142
+ }),
143
+ )
144
+ }
145
+
128
146
  const lane_stage_stats = {
129
147
  IMPLEMENT,
130
148
  NOT_MEASURED,
131
149
  STAGES,
150
+ cell,
151
+ is_dispatch,
152
+ is_number,
153
+ is_stage,
154
+ medians,
155
+ started_ms,
132
156
  table,
133
157
  }
134
158