@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
@@ -3,15 +3,27 @@ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from '
3
3
  import path from 'node:path'
4
4
  import { setTimeout as sleep } from 'node:timers/promises'
5
5
  import { agent_argv } from '#scripts/agent/agent-argv'
6
- import { agent_role_profile } from '#scripts/agent/agent-role-profile'
6
+ import { agent_role_profile, type AgentProvider } from '#scripts/agent/agent-role-profile'
7
7
  import { git_common_directory } from '#scripts/git/git-common-directory'
8
8
  import { stamp_file } from '#scripts/josh/stamp-file'
9
9
  import { error_text } from '#scripts/lib/error-message'
10
10
  import { json_value } from '#scripts/lib/json-value'
11
11
  import { detached_launch } from '#scripts/run/detached-launch'
12
12
  import { run_ship_review } from '#scripts/run/ship/run-ship-review'
13
+ import { run_ship_stage } from '#scripts/run/ship/run-ship-stage'
13
14
  import { z } from 'zod'
15
+ import { lane_child_invocation, type ShipStop } from './lane-child-invocation'
16
+ import { lane_child_marker } from './lane-child-marker'
14
17
  import type { LaneInfo } from './lane-registry'
18
+ import { lane_relaunch } from './lane-relaunch'
19
+
20
+ // The sessions a lane child's detached `josh ship` cannot start itself, started for it by a process
21
+ // that can: the round-1 and round-2 reviewer, and the fresh child a stopped ship hands back to. An
22
+ // OpenAI lane's own supervisor serves it, because a nested Codex cannot write outside its sandbox; a
23
+ // Claude lane is served by the `run:wake` supervisor (`claude-lane-broker.ts`), because Claude Code removes its
24
+ // token from what its Bash tool starts, so a token-only credential never reaches the ship the child
25
+ // launched (joshuafolkken/kit#3817). **The request names an action, never a command**: the server
26
+ // rebuilds the argv from the registered lane, so a child can ask for nothing but these two sessions.
15
27
 
16
28
  const REQUEST_PREFIX = '.josh-review-request-'
17
29
  const RESPONSE_PREFIX = '.josh-review-response-'
@@ -25,8 +37,22 @@ const HEARTBEAT_LIMIT_MS = 3000
25
37
  const MINUTE_MS = 60_000
26
38
  const REQUEST_LIMIT_MINUTES = 30
27
39
  const REQUEST_LIMIT_MS = REQUEST_LIMIT_MINUTES * MINUTE_MS
40
+ const ANTHROPIC_PROVIDER: AgentProvider = 'anthropic'
41
+ const stop_schema = z
42
+ .object({
43
+ stage: z.enum(run_ship_stage.STAGE),
44
+ title: z.string(),
45
+ flags: z.array(z.string()).readonly(),
46
+ is_review: z.boolean(),
47
+ conflicts: z.array(z.string()).readonly().default([]),
48
+ })
49
+ .strict()
50
+ const action_schema = z.discriminatedUnion('kind', [
51
+ z.object({ kind: z.literal('review'), round: z.enum(['1', '2']) }).strict(),
52
+ z.object({ kind: z.literal('relaunch'), stop: stop_schema }).strict(),
53
+ ])
28
54
  const request_schema = z
29
- .object({ session: z.uuid(), nonce: z.uuid(), round: z.enum(['1', '2']) })
55
+ .object({ session: z.uuid(), nonce: z.uuid(), action: action_schema })
30
56
  .strict()
31
57
  const response_schema = z
32
58
  .object({
@@ -36,9 +62,10 @@ const response_schema = z
36
62
  heartbeat_at: z.number().int().positive(),
37
63
  })
38
64
  .strict()
39
- type ReviewRequest = z.infer<typeof request_schema>
40
- type ReviewResponse = z.infer<typeof response_schema>
41
- type ReviewRound = ReviewRequest['round']
65
+ type BrokerRequest = z.infer<typeof request_schema>
66
+ type BrokerResponse = z.infer<typeof response_schema>
67
+ type ReviewRound = Extract<z.infer<typeof action_schema>, { kind: 'review' }>['round']
68
+ type BrokerAction = { kind: 'review'; round: ReviewRound } | { kind: 'relaunch'; stop: ShipStop }
42
69
 
43
70
  function review_paths(directory: string): { brief: string; findings: string; log: string } {
44
71
  return {
@@ -67,6 +94,16 @@ function read_record<T>(target: string, schema: z.ZodType<T>): T | undefined {
67
94
  }
68
95
  }
69
96
 
97
+ // The reviewer runs on the lane's own provider, so a Claude lane's server never starts a Codex reviewer
98
+ // and an OpenAI lane's never starts a Claude one.
99
+ function provider_of(lane: LaneInfo): AgentProvider {
100
+ return lane.profile?.provider ?? ANTHROPIC_PROVIDER
101
+ }
102
+
103
+ // **The reviewer carries the lane's mark explicitly**: the supervisor serving a Claude lane is no lane child,
104
+ // so a reviewer inheriting its environment would descend from the carry record's owner unmarked and be
105
+ // read as the `backlogrun` parent by every hook.
106
+
70
107
  async function launch_review(lane: LaneInfo, round: ReviewRound): Promise<boolean> {
71
108
  const target = review_paths(lane.directory)
72
109
  const prompt_of =
@@ -76,7 +113,7 @@ async function launch_review(lane: LaneInfo, round: ReviewRound): Promise<boolea
76
113
  agent_role_profile.REVIEWER,
77
114
  lane.directory,
78
115
  )
79
- if (built.kind === 'rejected' || built.profile.provider !== 'openai') return false
116
+ if (built.kind === 'rejected' || built.profile.provider !== provider_of(lane)) return false
80
117
 
81
118
  const result = await detached_launch.launch_attached(
82
119
  {
@@ -84,6 +121,7 @@ async function launch_review(lane: LaneInfo, round: ReviewRound): Promise<boolea
84
121
  cwd: lane.directory,
85
122
  log_path: target.log,
86
123
  profile: built.profile,
124
+ env: lane_child_marker.env_for(lane.issue),
87
125
  },
88
126
  console.error,
89
127
  )
@@ -91,18 +129,35 @@ async function launch_review(lane: LaneInfo, round: ReviewRound): Promise<boolea
91
129
  return result.kind === 'completed' && result.exit_code === 0
92
130
  }
93
131
 
94
- function write_response(target: string, body: z.infer<typeof response_schema>): void {
132
+ // An OpenAI lane's successor is its supervisor's to start, never a relaunch's.
133
+ function relaunch(lane: LaneInfo, stop: ShipStop): boolean {
134
+ if (lane_relaunch.is_openai_lane(lane)) return false
135
+
136
+ const invocation = lane_child_invocation.ship_stop_invocation(lane.issue, stop)
137
+ const phase = agent_role_profile.IMPLEMENTATION_PHASE
138
+ const result = lane_relaunch.relaunch(lane, invocation, phase, console.error)
139
+
140
+ return result.kind === 'launched'
141
+ }
142
+
143
+ async function perform(lane: LaneInfo, action: BrokerAction): Promise<boolean> {
144
+ if (action.kind === 'relaunch') return relaunch(lane, action.stop)
145
+
146
+ return await launch_review(lane, action.round)
147
+ }
148
+
149
+ function write_response(target: string, body: BrokerResponse): void {
95
150
  const temporary = `${target}.${String(process.pid)}.tmp`
96
151
 
97
152
  writeFileSync(temporary, JSON.stringify(body))
98
153
  renameSync(temporary, target)
99
154
  }
100
155
 
101
- async function launched(lane: LaneInfo, round: ReviewRound): Promise<boolean> {
156
+ async function launched(lane: LaneInfo, action: BrokerAction): Promise<boolean> {
102
157
  try {
103
- return await launch_review(lane, round)
158
+ return await perform(lane, action)
104
159
  } catch (error) {
105
- error_text.trace_swallowed('openai_review_broker.launched', error)
160
+ error_text.trace_swallowed('lane_session_broker.launched', error)
106
161
 
107
162
  return false
108
163
  }
@@ -111,10 +166,10 @@ async function launched(lane: LaneInfo, round: ReviewRound): Promise<boolean> {
111
166
  async function process_request(
112
167
  lane: LaneInfo,
113
168
  response: string,
114
- received: ReviewRequest,
115
- state: { current: ReviewResponse },
169
+ received: BrokerRequest,
170
+ state: { current: BrokerResponse },
116
171
  ): Promise<void> {
117
- const is_success = await launched(lane, received.round)
172
+ const is_success = await launched(lane, received.action)
118
173
 
119
174
  state.current = {
120
175
  session: received.session,
@@ -129,7 +184,7 @@ async function serve(
129
184
  lane: LaneInfo,
130
185
  session: string,
131
186
  response: string,
132
- state: { is_running: boolean; current: ReviewResponse },
187
+ state: { is_running: boolean; current: BrokerResponse },
133
188
  ): Promise<void> {
134
189
  let last_nonce = ''
135
190
  const target = request_path(lane.directory, lane.issue)
@@ -148,7 +203,7 @@ async function serve(
148
203
  }
149
204
  }
150
205
 
151
- function prepare(lane: LaneInfo): { response: string; current: ReviewResponse } {
206
+ function prepare(lane: LaneInfo): { response: string; current: BrokerResponse } {
152
207
  const response = response_path(lane.directory, lane.issue)
153
208
  if (response === undefined) throw new Error('review broker has no trusted response location')
154
209
 
@@ -156,7 +211,7 @@ function prepare(lane: LaneInfo): { response: string; current: ReviewResponse }
156
211
 
157
212
  mkdirSync(path.dirname(response), { recursive: true })
158
213
  mkdirSync(path.dirname(request_path(lane.directory, lane.issue)), { recursive: true })
159
- const current: ReviewResponse = {
214
+ const current: BrokerResponse = {
160
215
  session,
161
216
  nonce: '',
162
217
  is_success: false,
@@ -168,7 +223,7 @@ function prepare(lane: LaneInfo): { response: string; current: ReviewResponse }
168
223
  return { response, current }
169
224
  }
170
225
 
171
- function start_heartbeat(state: { current: ReviewResponse }, response: string): NodeJS.Timeout {
226
+ function start_heartbeat(state: { current: BrokerResponse }, response: string): NodeJS.Timeout {
172
227
  const heartbeat = setInterval(() => {
173
228
  state.current = { ...state.current, heartbeat_at: Date.now() }
174
229
  write_response(response, state.current)
@@ -179,15 +234,17 @@ function start_heartbeat(state: { current: ReviewResponse }, response: string):
179
234
  return heartbeat
180
235
  }
181
236
 
237
+ // **The heartbeat outlives the request being served.** A ship waiting on a reviewer reads a stale
238
+ // heartbeat as a dead server, so stopping it first would fail a review the server is still running.
182
239
  async function stop_server(
183
240
  state: { is_running: boolean },
184
241
  heartbeat: NodeJS.Timeout,
185
242
  serving: Promise<void>,
186
243
  response: string,
187
244
  ): Promise<void> {
188
- clearInterval(heartbeat)
189
245
  state.is_running = false
190
246
  await serving
247
+ clearInterval(heartbeat)
191
248
  unlinkSync(response)
192
249
  }
193
250
 
@@ -205,9 +262,9 @@ async function with_server<T>(lane: LaneInfo, run: () => Promise<T>): Promise<T>
205
262
  }
206
263
 
207
264
  function is_live_response(
208
- response: ReviewResponse | undefined,
265
+ response: BrokerResponse | undefined,
209
266
  session: string,
210
- ): response is ReviewResponse {
267
+ ): response is BrokerResponse {
211
268
  const now = Date.now()
212
269
 
213
270
  return (
@@ -218,7 +275,7 @@ function is_live_response(
218
275
  }
219
276
 
220
277
  function response_verdict(
221
- response: ReviewResponse | undefined,
278
+ response: BrokerResponse | undefined,
222
279
  session: string,
223
280
  nonce: string,
224
281
  ): 'wait' | 'success' | 'failure' {
@@ -242,21 +299,42 @@ async function wait_response(target: string, session: string, nonce: string): Pr
242
299
  return false
243
300
  }
244
301
 
245
- async function request(directory: string, issue: string, round: ReviewRound): Promise<boolean> {
246
- const target = response_path(directory, issue)
247
- if (target === undefined) return false
302
+ function live_response(target: string): BrokerResponse | undefined {
248
303
  const response = read_record(target, response_schema)
249
304
 
250
- if (response === undefined || !is_live_response(response, response.session)) return false
305
+ return is_live_response(response, response?.session ?? '') ? response : undefined
306
+ }
307
+
308
+ // Whether a server answers for this lane now — what a Claude lane's ship asks before it hands a session
309
+ // over rather than starting the session itself.
310
+ function is_serving(directory: string, issue: string): boolean {
311
+ const target = response_path(directory, issue)
312
+
313
+ return target !== undefined && live_response(target) !== undefined
314
+ }
315
+
316
+ async function request(directory: string, issue: string, action: BrokerAction): Promise<boolean> {
317
+ const target = response_path(directory, issue)
318
+ const response = target === undefined ? undefined : live_response(target)
319
+
320
+ if (target === undefined || response === undefined) return false
251
321
 
252
322
  const nonce = randomUUID()
253
- const body: ReviewRequest = { session: response.session, nonce, round }
323
+ const body = { session: response.session, nonce, action }
254
324
 
255
325
  stamp_file.replace_stamp(request_path(directory, issue), body)
256
326
 
257
327
  return await wait_response(target, response.session, nonce)
258
328
  }
259
329
 
260
- const openai_review_broker = { request, request_path, response_path, review_paths, with_server }
330
+ const lane_session_broker = {
331
+ is_serving,
332
+ request,
333
+ request_path,
334
+ response_path,
335
+ review_paths,
336
+ with_server,
337
+ }
261
338
 
262
- export { openai_review_broker }
339
+ export type { BrokerAction, ReviewRound }
340
+ 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
 
@@ -0,0 +1,96 @@
1
+ import { run_ship_stage } from '#scripts/run/ship/run-ship-stage'
2
+ import { lane_ledger, type LedgerEntry, type StopEntry } from './lane-ledger'
3
+ import { lane_stage_stats } from './lane-stage-stats'
4
+ import { lane_stats, type StatsWindow } from './lane-stats'
5
+
6
+ // How long a lane stood stopped: the ledger's `stop` entries of one period reduced to one row per stop
7
+ // reason — median, maximum, how many stops were resumed and how many never were — printed by
8
+ // `josh lane:stats` under the per-stage table, so the stop reason worth removing next is a number.
9
+ //
10
+ // **The wait is derived, not recorded**: from a stop to the start of the same issue's next ship stage,
11
+ // the way `implement` is. A stop that no ship stage followed — the lane parked, or left stopped — is
12
+ // counted under `not resumed` and kept out of the median and the maximum, whose time has no end.
13
+ //
14
+ // **A re-dispatch of the issue ends the search**: the new child's ship is a fresh lane's work, not the
15
+ // resumption of a stop that may be days old.
16
+
17
+ const COLUMNS = ['stop', 'median (min)', 'max (min)', 'runs', 'not resumed'] as const
18
+ const { STAGE } = run_ship_stage
19
+ const { is_dispatch, is_number, is_stage } = lane_stage_stats
20
+ const REASONS = [
21
+ STAGE.PREFLIGHT,
22
+ STAGE.REVIEW,
23
+ STAGE.SYNC,
24
+ run_ship_stage.CONFLICT,
25
+ STAGE.GATE,
26
+ STAGE.COMMIT,
27
+ STAGE.ROUND_TWO,
28
+ STAGE.FOLLOWUP,
29
+ STAGE.REPORT,
30
+ ] as const
31
+
32
+ interface StopWait {
33
+ reason: string
34
+ wait_ms: number | undefined
35
+ }
36
+
37
+ function is_stop(entry: LedgerEntry): entry is StopEntry {
38
+ return entry.kind === lane_ledger.KIND.STOP
39
+ }
40
+
41
+ function redispatch_ms(stop: StopEntry, entries: ReadonlyArray<LedgerEntry>): number {
42
+ const from_ms = Date.parse(stop.at)
43
+ const later = entries
44
+ .filter((entry) => is_dispatch(entry))
45
+ .filter((entry) => entry.issue === stop.issue)
46
+ .map((entry) => Date.parse(entry.at))
47
+ .filter((at) => at > from_ms)
48
+
49
+ return later.length === 0 ? Infinity : Math.min(...later)
50
+ }
51
+
52
+ function wait_ms(stop: StopEntry, entries: ReadonlyArray<LedgerEntry>): number | undefined {
53
+ const from_ms = Date.parse(stop.at)
54
+ const until_ms = redispatch_ms(stop, entries)
55
+ const starts = entries
56
+ .filter((entry) => is_stage(entry))
57
+ .filter((stage) => stage.issue === stop.issue)
58
+ .map((stage) => lane_stage_stats.started_ms(stage))
59
+ .filter((start) => start >= from_ms && start < until_ms)
60
+
61
+ return starts.length === 0 ? undefined : Math.min(...starts) - from_ms
62
+ }
63
+
64
+ function waits_of(entries: ReadonlyArray<LedgerEntry>): Array<StopWait> {
65
+ return entries
66
+ .filter(is_stop)
67
+ .map((stop) => ({ reason: stop.reason, wait_ms: wait_ms(stop, entries) }))
68
+ }
69
+
70
+ function reason_row(reason: string, waits: ReadonlyArray<StopWait>): string {
71
+ const of_reason = waits.filter((wait) => wait.reason === reason)
72
+ const resumed = of_reason.map((wait) => wait.wait_ms).filter((value) => is_number(value))
73
+
74
+ return lane_stats.to_row([
75
+ reason,
76
+ lane_stage_stats.cell(lane_stats.median(resumed)),
77
+ lane_stage_stats.cell(lane_stats.peak(resumed)),
78
+ String(resumed.length),
79
+ String(of_reason.length - resumed.length),
80
+ ])
81
+ }
82
+
83
+ // The period's table: `entries` is the whole ledger, filtered here to `window`.
84
+ function table(entries: ReadonlyArray<LedgerEntry>, window: StatsWindow): string {
85
+ const waits = waits_of(lane_stats.in_window(entries, window))
86
+
87
+ return [
88
+ lane_stats.to_row(COLUMNS),
89
+ lane_stats.to_row(COLUMNS.map(() => '---')),
90
+ ...REASONS.map((reason) => reason_row(reason, waits)),
91
+ ].join('\n')
92
+ }
93
+
94
+ const lane_stop_stats = { REASONS, table }
95
+
96
+ export { lane_stop_stats }
@@ -9,10 +9,10 @@ import { lane_child_invocation } from './lane-child-invocation'
9
9
  import { lane_child_marker } from './lane-child-marker'
10
10
  import { lane_dispatch_log } from './lane-dispatch-log'
11
11
  import type { LaneInfo } from './lane-registry'
12
+ import { lane_session_broker } from './lane-session-broker'
12
13
  import { openai_lane_ship } from './openai-lane-ship'
13
14
  import { openai_lane_supervisor_decision } from './openai-lane-supervisor-decision'
14
15
  import { openai_lane_supervisor_owner, type SupervisorOwner } from './openai-lane-supervisor-owner'
15
- import { openai_review_broker } from './openai-review-broker'
16
16
 
17
17
  const { claim, read_state, release, update } = openai_lane_supervisor_owner
18
18
  const CHILD_POLL_MS = 250
@@ -178,7 +178,7 @@ async function supervise(lane: LaneInfo, profile: AgentProfile, nonce: string):
178
178
  try {
179
179
  if (!(await openai_lane_supervisor_decision.is_approved(lane.directory, nonce))) return 0
180
180
 
181
- return await openai_review_broker.with_server(
181
+ return await lane_session_broker.with_server(
182
182
  lane,
183
183
  async () => await run_claimed(lane, profile, owner),
184
184
  )
@@ -0,0 +1,31 @@
1
+ import path from 'node:path'
2
+
3
+ // One definition of "the process tools a lane reads are not installed".
4
+ //
5
+ // **`ps` and `pgrep` are a prerequisite, not an optional probe.** The owner check walks the caller's
6
+ // ancestry with `ps`, and lane liveness and reaping match children with `pgrep`. A machine without
7
+ // them (a slim Debian image ships no `procps`) used to read every probe as "nothing there": the
8
+ // ancestry stopped at the caller, `run:step` answered `unknown`, and the parent stopped before its
9
+ // first lane with no word of why. A missing tool is refused by name instead.
10
+
11
+ // What Node reports when the executable to spawn does not exist.
12
+ const NOT_FOUND_CODE = 'ENOENT'
13
+ const PACKAGE_HINT =
14
+ 'install the package that provides it (procps on Debian/Ubuntu: `apt-get install procps`)'
15
+
16
+ function is_not_found(error: unknown): boolean {
17
+ return error instanceof Error && 'code' in error && error.code === NOT_FOUND_CODE
18
+ }
19
+
20
+ function missing_notice(command: string): string {
21
+ return `\`${path.basename(command)}\` is not installed — kit's lane commands read processes with ps and pgrep; ${PACKAGE_HINT}`
22
+ }
23
+
24
+ /** Throw a named refusal when `error` says `command` could not be spawned because it does not exist. */
25
+ function throw_if_missing(command: string, error: unknown): void {
26
+ if (is_not_found(error)) throw new Error(missing_notice(command), { cause: error })
27
+ }
28
+
29
+ const process_tools = { missing_notice, throw_if_missing }
30
+
31
+ export { process_tools }