@joshuafolkken/kit 1.1471.0 → 1.1514.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 (267) hide show
  1. package/.claude/settings.json +6 -6
  2. package/.claude/skills/epic-commands/SKILL.md +2 -14
  3. package/.claude/skills/epic-commands/epic-bundle.md +9 -5
  4. package/.claude/skills/epic-commands/execution-waves.md +1 -2
  5. package/.claude/skills/verify-ui/SKILL.md +1 -2
  6. package/.claude/skills/workflow-commands/SKILL.md +7 -9
  7. package/.claude/skills/workflow-commands/background-commands.md +3 -7
  8. package/.claude/skills/workflow-commands/backlogrun-child.md +3 -10
  9. package/.claude/skills/workflow-commands/backlogrun-lanes.md +6 -15
  10. package/.claude/skills/workflow-commands/backlogrun-park.md +2 -8
  11. package/.claude/skills/workflow-commands/backlogrun-progress.md +10 -19
  12. package/.claude/skills/workflow-commands/backlogrun-recovery.md +8 -8
  13. package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -10
  14. package/.claude/skills/workflow-commands/backlogrun.md +31 -50
  15. package/.claude/skills/workflow-commands/chain-rule.md +7 -6
  16. package/.claude/skills/workflow-commands/delegation.md +12 -8
  17. package/.claude/skills/workflow-commands/entry-sequence.md +1 -3
  18. package/.claude/skills/workflow-commands/followup.md +3 -6
  19. package/.claude/skills/workflow-commands/fullrun-steps.md +3 -5
  20. package/.claude/skills/workflow-commands/fullrun.md +4 -6
  21. package/.claude/skills/workflow-commands/halfrun.md +3 -7
  22. package/.claude/skills/workflow-commands/into-target.md +1 -2
  23. package/.claude/skills/workflow-commands/issue-comments.md +3 -3
  24. package/.claude/skills/workflow-commands/issue-scout.md +3 -2
  25. package/.claude/skills/workflow-commands/latest-gate.md +2 -6
  26. package/.claude/skills/workflow-commands/needs-human-review.md +2 -5
  27. package/.claude/skills/workflow-commands/observation-filing.md +0 -14
  28. package/.claude/skills/workflow-commands/observation-ledger.md +4 -9
  29. package/.claude/skills/workflow-commands/pre-gate-cut.md +11 -16
  30. package/.claude/skills/workflow-commands/prerequisite.md +1 -2
  31. package/.claude/skills/workflow-commands/progress-watcher.md +2 -7
  32. package/.claude/skills/workflow-commands/prrun.md +3 -5
  33. package/.claude/skills/workflow-commands/retrospective.md +4 -4
  34. package/.claude/skills/workflow-commands/split-assessment.md +1 -3
  35. package/.claude/skills/workflow-commands/target-repository.md +1 -2
  36. package/.claude/skills/workflow-commands/working-tree-hold.md +2 -4
  37. package/.codex/hooks.json +6 -6
  38. package/.github/actions/setup-node/action.yml +1 -1
  39. package/.github/workflows/ci.yml +1 -1
  40. package/.github/workflows/publish.yml +2 -2
  41. package/CLAUDE.md +1 -1
  42. package/cspell/index.yaml +7 -0
  43. package/dist/CLAUDE.md +1 -1
  44. package/dist/commands/document-section-cli.js +67 -34
  45. package/dist/hooks/batch-guard.js +6 -6
  46. package/dist/hooks/chunk-4KKD2UTK.js +66 -0
  47. package/dist/hooks/{chunk-J47HU5H5.js → chunk-6TS2JD6D.js} +3 -3
  48. package/dist/hooks/chunk-7DSBJVFH.js +50 -0
  49. package/dist/hooks/{chunk-YHBZAJSV.js → chunk-A65Y4YOR.js} +3 -3
  50. package/dist/hooks/{chunk-E7OIKX5Z.js → chunk-APUO6U4J.js} +85 -7
  51. package/dist/hooks/{chunk-IX6TTTI6.js → chunk-EFXE6C37.js} +8 -22
  52. package/dist/hooks/chunk-GP4IEC4O.js +20 -0
  53. package/dist/hooks/{chunk-SHGPANFB.js → chunk-JP45SOCJ.js} +3 -3
  54. package/dist/hooks/{chunk-K4EOONYM.js → chunk-LAOV7NLM.js} +1 -1
  55. package/dist/hooks/chunk-M4HGZV7U.js +83 -0
  56. package/dist/hooks/{chunk-KC5AK2GF.js → chunk-O6GDRNJB.js} +3 -3
  57. package/dist/hooks/{chunk-YYPI7SJZ.js → chunk-PF2NZL2A.js} +5 -5
  58. package/dist/hooks/{chunk-H5HVWYXK.js → chunk-PTICX4OL.js} +3 -4
  59. package/dist/hooks/{chunk-IIGFYXKC.js → chunk-RT27QRJW.js} +581 -1438
  60. package/dist/hooks/{chunk-43ZHT22P.js → chunk-SYOTDYCI.js} +1127 -1089
  61. package/dist/hooks/{chunk-AS74OU3D.js → chunk-TNUMY6NL.js} +1507 -1336
  62. package/dist/hooks/chunk-UPUZF3AN.js +279 -0
  63. package/dist/hooks/{chunk-EUMACUK2.js → chunk-WK6SMBIC.js} +1948 -806
  64. package/dist/hooks/codex-hook-adapter.js +17 -14
  65. package/dist/hooks/duplicate-read-guard.js +7 -7
  66. package/dist/hooks/format-edited.js +5 -4
  67. package/dist/hooks/inputs.json +317 -264
  68. package/dist/hooks/investigation-guard.js +8 -11
  69. package/dist/hooks/pretool-guard.js +16 -13
  70. package/dist/hooks/session-lang.js +370 -25
  71. package/dist/hooks/stop-guard.js +2576 -176
  72. package/dist/josh.js +2124 -277
  73. package/lefthook/base.yml +5 -3
  74. package/package.json +12 -10
  75. package/prompts/collaboration-workflow/file-edits.md +1 -3
  76. package/prompts/collaboration-workflow/gh-rest.md +1 -2
  77. package/prompts/collaboration-workflow/issue-citation.md +2 -4
  78. package/prompts/collaboration-workflow/issue-template.md +10 -2
  79. package/prompts/collaboration-workflow/lane-instructions.md +35 -0
  80. package/prompts/collaboration-workflow/operating-rules.md +2 -6
  81. package/prompts/collaboration-workflow/output-bounds.md +3 -3
  82. package/prompts/collaboration-workflow/overview.md +1 -1
  83. package/prompts/collaboration-workflow/principles.md +14 -8
  84. package/prompts/collaboration-workflow/report-format.md +9 -0
  85. package/prompts/collaboration-workflow/residency.md +12 -8
  86. package/prompts/collaboration-workflow/rule-delivery.md +3 -11
  87. package/prompts/collaboration-workflow/shell-body.md +6 -6
  88. package/prompts/collaboration-workflow/turn-batching.md +5 -5
  89. package/prompts/collaboration-workflow/upstream-interrupt.md +2 -2
  90. package/prompts/collaboration-workflow/wip-cap.md +6 -6
  91. package/prompts/collaboration-workflow.md +1 -0
  92. package/prompts/review.md +4 -5
  93. package/prompts/testing-guide.md +2 -2
  94. package/scripts/agent/agent-argv.ts +20 -4
  95. package/scripts/agent/agent-role-profile.ts +101 -45
  96. package/scripts/agent/agent-safe-text.ts +60 -0
  97. package/scripts/agent/claude-agent-argv.ts +14 -0
  98. package/scripts/agent/codex-agent-argv.ts +2 -0
  99. package/scripts/agent/lane-instructions.ts +144 -0
  100. package/scripts/backlog/backlog-budget.ts +4 -4
  101. package/scripts/backlog/backlog-drive-named.ts +1 -1
  102. package/scripts/backlog/backlog-drive-restore.ts +9 -1
  103. package/scripts/backlog/backlog-occupied.ts +56 -0
  104. package/scripts/backlog/backlog-ready.ts +47 -10
  105. package/scripts/delegation/duplicate-read-guard.ts +2 -2
  106. package/scripts/delegation/investigation-guard.ts +15 -65
  107. package/scripts/delegation/investigation-reads.ts +0 -1
  108. package/scripts/document/document-byte-budget.ts +6 -5
  109. package/scripts/document/document-section-cli.ts +17 -5
  110. package/scripts/document/entry-read-budget.ts +2 -2
  111. package/scripts/document/read-set-cli.ts +4 -1
  112. package/scripts/epic/epic-audit-orphans.ts +5 -8
  113. package/scripts/epic/epic-audit.ts +52 -1
  114. package/scripts/epic/epic-bundle-evidence.ts +49 -26
  115. package/scripts/epic/epic-bundle-referenced.ts +14 -3
  116. package/scripts/epic/epic-bundle.ts +17 -9
  117. package/scripts/followup/git-followup-workflow.ts +19 -1
  118. package/scripts/gate/check-inputs.ts +129 -0
  119. package/scripts/gate/check-reuse.ts +162 -0
  120. package/scripts/gate/core-budget.ts +93 -44
  121. package/scripts/gate/core-wait.ts +130 -0
  122. package/scripts/gate/free-cores.ts +49 -0
  123. package/scripts/gate/gate-ledger.ts +17 -3
  124. package/scripts/gate/gate-plan.ts +23 -1
  125. package/scripts/gate/gate-report.ts +15 -2
  126. package/scripts/gate/gate-run-steps.ts +150 -0
  127. package/scripts/gate/held-mark.ts +55 -0
  128. package/scripts/gate/machine-capacity.ts +70 -11
  129. package/scripts/gate/pre-commit-type-check.ts +14 -1
  130. package/scripts/gate/verification-gate.ts +58 -125
  131. package/scripts/gh/git-gh-issue-rest.ts +1 -0
  132. package/scripts/gh/git-gh-issue-write.ts +24 -2
  133. package/scripts/gh/git-pr.ts +13 -1
  134. package/scripts/git/main-merge-regenerate.ts +78 -0
  135. package/scripts/git/main-merge.ts +14 -5
  136. package/scripts/hooks/batch-guard.ts +5 -8
  137. package/scripts/hooks/pre-commit-lint.ts +91 -0
  138. package/scripts/hooks/pre-push-unit.ts +1 -1
  139. package/scripts/hooks/pretool-guard.ts +27 -52
  140. package/scripts/hooks/reserved-run.ts +1 -0
  141. package/scripts/hooks/run-hook.sh +10 -12
  142. package/scripts/hooks/stop-actions.ts +204 -0
  143. package/scripts/hooks/stop-guard.ts +62 -21
  144. package/scripts/init/hook-launch.ts +12 -7
  145. package/scripts/issue/expected-effect-lint.ts +59 -0
  146. package/scripts/issue/issue-cite-cli.ts +35 -15
  147. package/scripts/issue/issue-classification.ts +1 -1
  148. package/scripts/issue/issue-lint-cli.ts +5 -3
  149. package/scripts/issue/issue-scout-cli.ts +19 -14
  150. package/scripts/issue/markdown-section.ts +19 -5
  151. package/scripts/josh/command-usage.ts +110 -0
  152. package/scripts/josh/josh-command-map.ts +2 -0
  153. package/scripts/josh/josh-command-types.ts +55 -9
  154. package/scripts/josh/josh-commands-ai.ts +13 -0
  155. package/scripts/josh/josh-commands-development.ts +1 -1
  156. package/scripts/josh/josh-commands-hooks.ts +6 -0
  157. package/scripts/josh/josh-commands-lane.ts +10 -1
  158. package/scripts/josh/josh-commands-workflow.ts +5 -0
  159. package/scripts/josh/josh-logic.ts +6 -15
  160. package/scripts/josh/josh-usage-automation.ts +121 -0
  161. package/scripts/josh/josh-usage-backlog.ts +180 -0
  162. package/scripts/josh/josh-usage-developer.ts +230 -0
  163. package/scripts/josh/josh-usage-hooks.ts +38 -0
  164. package/scripts/josh/josh-usage-issue.ts +205 -0
  165. package/scripts/josh/josh-usage-review.ts +101 -0
  166. package/scripts/josh/josh-usage-run.ts +252 -0
  167. package/scripts/josh/josh-usage-session.ts +238 -0
  168. package/scripts/josh/josh-usage-workflow.ts +212 -0
  169. package/scripts/josh/josh.ts +14 -5
  170. package/scripts/josh/package-directory.ts +20 -0
  171. package/scripts/lane/lane-capacity.ts +8 -2
  172. package/scripts/lane/lane-check-stats.ts +56 -0
  173. package/scripts/lane/lane-child-invocation.ts +35 -4
  174. package/scripts/lane/lane-dispatch.ts +3 -3
  175. package/scripts/lane/lane-guard-policy.ts +2 -2
  176. package/scripts/lane/lane-handoff.ts +8 -1
  177. package/scripts/lane/lane-install.ts +12 -4
  178. package/scripts/lane/lane-ledger.ts +43 -1
  179. package/scripts/lane/lane-limit-cli.ts +8 -9
  180. package/scripts/lane/lane-measure-cli.ts +2 -0
  181. package/scripts/lane/lane-preserve.ts +29 -1
  182. package/scripts/lane/lane-registry.ts +9 -7
  183. package/scripts/lane/lane-restart.ts +5 -26
  184. package/scripts/lane/lane-session-action.ts +146 -0
  185. package/scripts/lane/lane-session-broker.ts +57 -138
  186. package/scripts/lane/lane-withdraw-cli.ts +71 -0
  187. package/scripts/lane/lane-withdraw.ts +182 -0
  188. package/scripts/lint/format.ts +4 -4
  189. package/scripts/lint/lint-parallel.ts +10 -3
  190. package/scripts/lint/lint-related-scope.ts +4 -1
  191. package/scripts/notify/telegram-test.ts +10 -5
  192. package/scripts/report/report-format-reference.ts +2 -0
  193. package/scripts/review/acceptance-map-check.ts +61 -0
  194. package/scripts/review/acceptance-map.ts +205 -0
  195. package/scripts/review/review-brief-cli.ts +1 -1
  196. package/scripts/rules/agent-model.ts +59 -0
  197. package/scripts/rules/delivered-rules.ts +11 -2
  198. package/scripts/rules/filing-cap.ts +1 -0
  199. package/scripts/rules/firing-point.ts +6 -5
  200. package/scripts/rules/guard-remedy.ts +117 -0
  201. package/scripts/rules/issue-comments.ts +67 -6
  202. package/scripts/rules/last-prompt.ts +50 -5
  203. package/scripts/rules/oracle-list-cli.ts +7 -0
  204. package/scripts/rules/piped-verification.ts +3 -1
  205. package/scripts/rules/rule-list.ts +4 -1
  206. package/scripts/rules/rule-registry-rows.ts +10 -0
  207. package/scripts/rules/rule-registry.ts +7 -4
  208. package/scripts/rules/rule-rewrite.ts +114 -0
  209. package/scripts/rules/rule-value.ts +7 -1
  210. package/scripts/rules/shell-segments.ts +15 -0
  211. package/scripts/rules/stop-decision.ts +77 -0
  212. package/scripts/rules/stop-rules.ts +147 -47
  213. package/scripts/rules/tail-commands.ts +15 -1
  214. package/scripts/run/board/run-board-cli.ts +2 -0
  215. package/scripts/run/board/run-board-closed.ts +8 -2
  216. package/scripts/run/board/run-board-github.ts +9 -1
  217. package/scripts/run/board/run-board-header.ts +4 -1
  218. package/scripts/run/board/run-board-labels.ts +2 -0
  219. package/scripts/run/board/run-board-layout.ts +1 -1
  220. package/scripts/run/board/run-board-read.ts +16 -0
  221. package/scripts/run/board/run-board-reason.ts +10 -1
  222. package/scripts/run/board/run-board-render-legend.ts +1 -0
  223. package/scripts/run/board/run-board-render-slot.ts +1 -1
  224. package/scripts/run/board/run-board-render-waits.ts +41 -0
  225. package/scripts/run/board/run-board-render.ts +6 -1
  226. package/scripts/run/board/run-board-row-yield.ts +1 -0
  227. package/scripts/run/board/run-board-state.ts +5 -0
  228. package/scripts/run/board/run-board-status.ts +72 -21
  229. package/scripts/run/board/run-board-tick.ts +5 -1
  230. package/scripts/run/cut/run-cut-cli.ts +3 -2
  231. package/scripts/run/cut/run-cut-report.ts +16 -0
  232. package/scripts/run/detached-launch.ts +23 -4
  233. package/scripts/run/entry/run-entry-cli.ts +8 -1
  234. package/scripts/run/event/run-event-render.ts +1 -0
  235. package/scripts/run/event/run-event-scope.ts +19 -1
  236. package/scripts/run/event/run-event-stream-emit.ts +13 -6
  237. package/scripts/run/event/run-event-stream.ts +4 -0
  238. package/scripts/run/hold/run-hold-session.ts +46 -0
  239. package/scripts/run/hold/run-hold.ts +7 -1
  240. package/scripts/run/launch-priority.ts +32 -0
  241. package/scripts/run/merge/run-merge-cli.ts +16 -65
  242. package/scripts/run/merge/run-merge-signals.ts +99 -0
  243. package/scripts/run/merge/run-merge-steps.ts +11 -1
  244. package/scripts/run/merge/run-merge.ts +21 -9
  245. package/scripts/run/run-liveness.ts +1 -1
  246. package/scripts/run/run-poll-hook.ts +157 -0
  247. package/scripts/run/run-step-cli.ts +22 -15
  248. package/scripts/run/run-step-section.ts +85 -0
  249. package/scripts/run/ship/run-ship-cli.ts +5 -12
  250. package/scripts/run/ship/run-ship-detach.ts +9 -0
  251. package/scripts/run/ship/run-ship-next.ts +10 -2
  252. package/scripts/run/ship/run-ship-overlap.ts +195 -0
  253. package/scripts/run/ship/run-ship-pre-detach.ts +33 -15
  254. package/scripts/run/ship/run-ship-preflight.ts +28 -2
  255. package/scripts/run/ship/run-ship-return.ts +31 -7
  256. package/scripts/run/ship/run-ship-review-steps.ts +12 -10
  257. package/scripts/run/ship/run-ship-steps.ts +1 -1
  258. package/scripts/run/ship/run-ship-stop-context.ts +206 -0
  259. package/scripts/run/ship/run-ship-sync.ts +14 -6
  260. package/scripts/test/test-e2e-guard.ts +30 -5
  261. package/scripts/test/test-unit-guard.ts +16 -1
  262. package/scripts/test/unit-worker-share.ts +26 -4
  263. package/scripts/time-runtime/time-shell.ts +29 -4
  264. package/scripts/time-runtime/time-spans.ts +1 -0
  265. package/scripts/time-runtime/time-tool-call.ts +7 -0
  266. package/templates/gitignore +6 -2
  267. package/dist/hooks/chunk-K4LMBQC2.js +0 -68
@@ -1,22 +1,18 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
- import { api_outage } from '#scripts/agent/api-outage'
4
3
  import { CONTEXT_CUT_THRESHOLD } from '#scripts/cost-runtime/context-cut-threshold'
5
4
  import { issue_cite } from '#scripts/issue/issue-cite'
6
- import { issue_closing_pr } from '#scripts/issue/issue-closing-pr'
7
5
  import { issue_number_shape } from '#scripts/issue/issue-number-shape'
8
6
  import { issue_state_cli } from '#scripts/issue/issue-state-cli'
9
7
  import { lane_handoff } from '#scripts/lane/lane-handoff'
10
8
  import { lane_ledger } from '#scripts/lane/lane-ledger'
11
9
  import { cli_flags } from '#scripts/lib/cli-flags'
12
10
  import { run_carry, type CarryOwner, type RunCarry } from '#scripts/run/carry/run-carry'
13
- import { run_event_scope } from '#scripts/run/event/run-event-scope'
14
11
  import { run_event_stream } from '#scripts/run/event/run-event-stream'
15
12
  import { run_event_stream_emit } from '#scripts/run/event/run-event-stream-emit'
16
- import { run_ending } from '#scripts/run/run-ending'
17
13
  import { run_label } from '#scripts/run/run-label'
18
- import { run_merge, type ChildOutcome, type EndingSignals } from './run-merge'
19
- import { run_merge_ship } from './run-merge-ship'
14
+ import { run_merge, type ChildOutcome } from './run-merge'
15
+ import { run_merge_signals } from './run-merge-signals'
20
16
  import { run_merge_steps, type FailedResult, type MergeContext } from './run-merge-steps'
21
17
  import { run_merge_token, type MergeToken } from './run-merge-token'
22
18
 
@@ -191,61 +187,6 @@ async function offered(ctx: MergeContext): Promise<OfferVerdict> {
191
187
  return { token: await run_merge_steps.ask_next(ctx), code: SUCCESS_EXIT_CODE, is_offer: true }
192
188
  }
193
189
 
194
- // Whether the child's session ended through its environment — read only when `--output` named the
195
- // transcript, off otherwise: its exit record shows it could not reach the API, or the session was
196
- // killed inside a turn. The path is validated inside `run_ending`, so a missing output is "no".
197
- function read_is_outage(output: string | undefined): boolean {
198
- if (output === undefined) return false
199
-
200
- return api_outage.is_outage(run_ending.read_exit(output)) || run_ending.ended_mid_turn(output)
201
- }
202
-
203
- async function child_outages(child: string): Promise<number> {
204
- const events = await run_event_stream_emit.current_events()
205
-
206
- return events.filter(
207
- (event) =>
208
- event.kind === run_event_stream.EVENT_KIND.OUTAGE &&
209
- run_event_scope.issue_named(event) === child,
210
- ).length
211
- }
212
-
213
- // An outage child is re-dispatched only so many times: one that ends the same way on every dispatch is
214
- // parked, since merges in other lanes reset the run-wide outage streak and would never stop it.
215
- async function read_is_retryable(child: string, output: string | undefined): Promise<boolean> {
216
- const is_interrupted =
217
- read_is_outage(output) || (await run_merge_ship.is_unanswered(child, output))
218
-
219
- if (!is_interrupted) return false
220
-
221
- return (await child_outages(child)) < run_merge.CONSECUTIVE_OUTAGE_LIMIT
222
- }
223
-
224
- type IssueRead = Awaited<ReturnType<typeof issue_state_cli.read_issue>>
225
-
226
- // The timeline is read only for an OPEN, unparked child — the one classification a merged closing pull
227
- // request changes. A CLOSED child was closed by GitHub, so it must carry no `merged_pr` for
228
- // `do_merged` to close again.
229
- async function read_merged_pr(child: string, read: IssueRead): Promise<string | undefined> {
230
- if (read.kind !== 'state' || run_merge.classify_child(read.state) !== 'failed') return undefined
231
-
232
- return await issue_closing_pr.read_closing_pr(child)
233
- }
234
-
235
- async function read_signals(ctx: MergeContext, read: IssueRead): Promise<EndingSignals> {
236
- const [is_cut, merged_pr, is_outage] = await Promise.all([
237
- run_merge_steps.has_resumable_cut(ctx.child),
238
- read_merged_pr(ctx.child, read),
239
- read_is_retryable(ctx.child, ctx.output),
240
- ])
241
-
242
- return { is_outage, is_cut, merged_pr }
243
- }
244
-
245
- function outcome_of(read: IssueRead, signals: EndingSignals): ChildOutcome {
246
- return read.kind === 'state' ? run_merge.classify_child(read.state, signals) : 'unresolved'
247
- }
248
-
249
190
  // A refused count means this session is not the carry record's owner, so the whole operation is
250
191
  // rejected — the lane is not closed and no next child is offered.
251
192
  function report_count_refused(carry: RunCarry | undefined): MergeVerdict {
@@ -397,6 +338,13 @@ async function on_skipped(ctx: MergeContext): Promise<MergeVerdict> {
397
338
  return await offered(ctx)
398
339
  }
399
340
 
341
+ // A child a person withdrew with `lane:withdraw`: the withdrawal already stopped it, closed its issue
342
+ // and lane and put itself on the stream, so nothing is counted or written here and its seat goes to the
343
+ // next child.
344
+ async function on_withdrawn(ctx: MergeContext): Promise<MergeVerdict> {
345
+ return await offered(ctx)
346
+ }
347
+
400
348
  // The child's own ending: it stopped before its commit for a person to look at, so nothing is counted
401
349
  // and no next child is offered.
402
350
  async function on_human_review(): Promise<MergeVerdict> {
@@ -422,6 +370,7 @@ const HANDLERS: Readonly<Record<ChildOutcome, (ctx: MergeContext) => Promise<Mer
422
370
  split: on_skipped,
423
371
  unresolved: on_unresolved,
424
372
  shipping: on_shipping,
373
+ withdrawn: on_withdrawn,
425
374
  }
426
375
 
427
376
  // The whole merge event for one returned child, in-process: read its state, classify it, and run the
@@ -432,10 +381,12 @@ async function merge_child(ctx: MergeContext): Promise<MergeResult> {
432
381
  }
433
382
 
434
383
  const read = await issue_state_cli.read_issue(ctx.child, ctx.repo)
435
- const signals = await read_signals(ctx, read)
436
- const outcome = outcome_of(read, signals)
384
+ const signals = await run_merge_signals.read_signals(ctx, read)
385
+ const outcome = run_merge_signals.outcome_of(read, signals)
386
+
387
+ const { merged_pr, is_unanswered } = signals
437
388
 
438
- return { outcome, ...(await HANDLERS[outcome]({ ...ctx, merged_pr: signals.merged_pr })) }
389
+ return { outcome, ...(await HANDLERS[outcome]({ ...ctx, merged_pr, is_unanswered })) }
439
390
  }
440
391
 
441
392
  async function run(argv: ReadonlyArray<string>): Promise<number> {
@@ -461,7 +412,7 @@ async function main(argv: ReadonlyArray<string>): Promise<void> {
461
412
  const run_merge_cli = {
462
413
  merge_child,
463
414
  parse,
464
- read_merged_pr,
415
+ read_merged_pr: run_merge_signals.read_merged_pr,
465
416
  record_merged,
466
417
  run,
467
418
  }
@@ -0,0 +1,99 @@
1
+ import { api_outage } from '#scripts/agent/api-outage'
2
+ import { issue_closing_pr } from '#scripts/issue/issue-closing-pr'
3
+ import { issue_merged } from '#scripts/issue/issue-merged'
4
+ import type { issue_state_cli } from '#scripts/issue/issue-state-cli'
5
+ import { run_event_scope } from '#scripts/run/event/run-event-scope'
6
+ import { run_event_stream } from '#scripts/run/event/run-event-stream'
7
+ import { run_event_stream_emit } from '#scripts/run/event/run-event-stream-emit'
8
+ import { run_ending } from '#scripts/run/run-ending'
9
+ import { run_merge, type ChildOutcome, type EndingSignals } from './run-merge'
10
+ import { run_merge_ship } from './run-merge-ship'
11
+ import { run_merge_steps, type MergeContext } from './run-merge-steps'
12
+
13
+ // What `run:merge` reads beside a returned child's GitHub state before classifying it — the exit
14
+ // record, the lane's cut, a merged closing pull request and a person's withdrawal — and the
15
+ // classification those readings and the state decide (`run_merge.classify_child`).
16
+
17
+ type IssueRead = Awaited<ReturnType<typeof issue_state_cli.read_issue>>
18
+
19
+ // Whether the child's session ended through its environment — read only when `--output` named the
20
+ // transcript, off otherwise: its exit record shows it could not reach the API, or the session was
21
+ // killed inside a turn. The path is validated inside `run_ending`, so a missing output is "no".
22
+ function read_is_outage(output: string | undefined): boolean {
23
+ if (output === undefined) return false
24
+
25
+ return api_outage.is_outage(run_ending.read_exit(output)) || run_ending.ended_mid_turn(output)
26
+ }
27
+
28
+ async function child_outages(child: string): Promise<number> {
29
+ const events = await run_event_stream_emit.current_events()
30
+
31
+ return events.filter(
32
+ (event) =>
33
+ event.kind === run_event_stream.EVENT_KIND.OUTAGE &&
34
+ run_event_scope.issue_named(event) === child,
35
+ ).length
36
+ }
37
+
38
+ // An outage child is re-dispatched only so many times: one that ends the same way on every dispatch is
39
+ // parked, since merges in other lanes reset the run-wide outage streak and would never stop it. **An
40
+ // unanswered ship stop is told apart from an API outage**: the child reached the API, so it is
41
+ // re-dispatched without adding to the run-wide streak (joshuafolkken/kit#3974).
42
+ async function read_retryable(
43
+ child: string,
44
+ output: string | undefined,
45
+ ): Promise<{ is_outage: boolean; is_unanswered: boolean }> {
46
+ const is_api_outage = read_is_outage(output)
47
+ const is_unanswered = !is_api_outage && (await run_merge_ship.is_unanswered(child, output))
48
+
49
+ if (!is_api_outage && !is_unanswered) return { is_outage: false, is_unanswered: false }
50
+
51
+ const is_outage = (await child_outages(child)) < run_merge.CONSECUTIVE_OUTAGE_LIMIT
52
+
53
+ return { is_outage, is_unanswered: is_outage && is_unanswered }
54
+ }
55
+
56
+ // The timeline is read only for an OPEN, unparked child — the one classification a merged closing pull
57
+ // request changes. A CLOSED child was closed by GitHub, so it must carry no `merged_pr` for
58
+ // `do_merged` to close again.
59
+ async function read_merged_pr(child: string, read: IssueRead): Promise<string | undefined> {
60
+ if (read.kind !== 'state' || run_merge.classify_child(read.state) !== 'failed') return undefined
61
+
62
+ return await issue_closing_pr.read_closing_pr(child)
63
+ }
64
+
65
+ // A child that merged before `lane:withdraw` stopped it is merged, not withdrawn: the withdrawal's
66
+ // event is already written, so only the timeline tells its merged close from the not-planned one. An
67
+ // unread timeline answers `undefined`, and `outcome_of` asks again rather than guessing.
68
+ async function read_is_withdrawn(child: string, read: IssueRead): Promise<boolean | undefined> {
69
+ const events = await run_event_stream_emit.current_events()
70
+
71
+ if (!run_event_scope.is_withdrawn(events, child)) return false
72
+ if (read.kind !== 'state' || run_merge.classify_child(read.state) !== 'merged') return true
73
+ const is_merged = await issue_merged.read_merge_state(child)
74
+
75
+ return is_merged === undefined ? undefined : !is_merged
76
+ }
77
+
78
+ async function read_signals(ctx: MergeContext, read: IssueRead): Promise<EndingSignals> {
79
+ const [is_cut, merged_pr, retryable, is_withdrawn] = await Promise.all([
80
+ run_merge_steps.has_resumable_cut(ctx.child),
81
+ read_merged_pr(ctx.child, read),
82
+ read_retryable(ctx.child, ctx.output),
83
+ read_is_withdrawn(ctx.child, read),
84
+ ])
85
+
86
+ return { ...retryable, is_cut, merged_pr, is_withdrawn }
87
+ }
88
+
89
+ // An unread state is `unresolved` unless a withdrawal already settled the child, which is read locally;
90
+ // a withdrawn child whose timeline could not tell its merge (`is_withdrawn` unset) is `unresolved` too.
91
+ function outcome_of(read: IssueRead, signals: EndingSignals): ChildOutcome {
92
+ if (signals.is_withdrawn === undefined) return 'unresolved'
93
+
94
+ return run_merge.classify_child(read.kind === 'state' ? read.state : undefined, signals)
95
+ }
96
+
97
+ const run_merge_signals = { outcome_of, read_merged_pr, read_signals }
98
+
99
+ export { run_merge_signals }
@@ -73,6 +73,8 @@ interface MergeContext {
73
73
  // The merged pull request that closes the child, when GitHub left the child OPEN after the merge —
74
74
  // the one `merged` child whose issue this command still has to close.
75
75
  merged_pr?: string | undefined
76
+ // The outage is a ship stop no relaunch answered: re-dispatched, but not added to the streak.
77
+ is_unanswered?: boolean | undefined
76
78
  }
77
79
 
78
80
  interface FailedResult {
@@ -359,8 +361,16 @@ interface OutageResult {
359
361
  // the child, so it is re-dispatchable in the same run. Returns the record so the caller can read the
360
362
  // outage streak against its guard. Returns `is_refused: true` without touching labels when the carry
361
363
  // record rejected the count.
364
+ async function count_outage(ctx: MergeContext): Promise<ApplyCarryResult> {
365
+ if (ctx.is_unanswered !== true) return await apply_carry(ctx, run_merge.change_of('outage'))
366
+
367
+ const refused = await refused_carry(ctx)
368
+
369
+ return refused === undefined ? { kind: 'none' } : { kind: 'refused', carry: refused }
370
+ }
371
+
362
372
  async function do_outage(ctx: MergeContext): Promise<OutageResult> {
363
- const result = await apply_carry(ctx, run_merge.change_of('outage'))
373
+ const result = await count_outage(ctx)
364
374
 
365
375
  if (result.kind === 'refused') return { carry: result.carry, is_refused: true }
366
376
 
@@ -24,10 +24,11 @@ const CLOSED_STATE = 'CLOSED'
24
24
  // failing every child it is handed rather than on failures scattered across a long run.
25
25
  const CONSECUTIVE_FAILURE_LIMIT = 3
26
26
  // Three outages in a row stop the run too — the same shape as the failure
27
- // guard, over its own streak. An outage is a child its environment ended — it could not reach the API,
28
- // its session was killed inside a turn, or its ship stopped with no relaunch — so a run of them is
29
- // the environment being down; stopping is what keeps the run from re-dispatching into a dead API
30
- // forever. A merge or a genuine child failure resets it (`run-carry.ts` → `next_outages`).
27
+ // guard, over its own streak. An outage is a child its environment ended — it could not reach the API
28
+ // or its session was killed inside a turn — so a run of them is the environment being down; stopping is
29
+ // what keeps the run from re-dispatching into a dead API forever. A ship that stopped with no relaunch
30
+ // is re-dispatched too but not counted: its child reached the API (joshuafolkken/kit#3974). A merge or
31
+ // a genuine child failure resets it (`run-carry.ts` → `next_outages`).
31
32
  const CONSECUTIVE_OUTAGE_LIMIT = 3
32
33
  const ONE = 1
33
34
 
@@ -45,9 +46,12 @@ const ONE = 1
45
46
  // the child ended its session on purpose, so it is resumed rather than parked and
46
47
  // counts nothing.
47
48
  // - `failed` — OPEN and carrying neither, and not an outage; the child did not finish.
49
+ // - `withdrawn` — a person withdrew it with `lane:withdraw`, read from the event stream before the
50
+ // GitHub state: closed as not planned, or about to be, it is neither merged nor failed.
48
51
  // - `unresolved` — the state could not be read; re-read before deciding.
49
52
  type ChildOutcome =
50
53
  | 'merged'
54
+ | 'withdrawn'
51
55
  | 'human-review'
52
56
  | 'parked'
53
57
  | 'split'
@@ -65,6 +69,10 @@ interface EndingSignals {
65
69
  // The merged pull request whose `closes #N` names the child, when GitHub left the child OPEN after
66
70
  // merging it.
67
71
  merged_pr?: string | undefined
72
+ // The newest launch of the child was withdrawn by a person (`run_event_scope.is_withdrawn`).
73
+ is_withdrawn?: boolean | undefined
74
+ // The outage is a ship stop no relaunch answered, not an API the child could not reach.
75
+ is_unanswered?: boolean | undefined
68
76
  }
69
77
 
70
78
  const NO_SIGNALS: EndingSignals = { is_outage: false, is_cut: false }
@@ -107,12 +115,16 @@ function open_outcome(state: IssueState, signals: EndingSignals): ChildOutcome {
107
115
  return unfinished_outcome(signals)
108
116
  }
109
117
 
110
- // The signals are read by the CLI and matter only for an OPEN child — a CLOSED child is merged
111
- // regardless of how the process exited.
118
+ // The signals are read by the CLI and, but for a withdrawal, matter only for an OPEN child — a CLOSED
119
+ // child is merged regardless of how the process exited. **A withdrawal is read first**: `lane:withdraw`
120
+ // records it before stopping the child, so the parent may collect it while the issue is still OPEN, and
121
+ // once it is CLOSED as not planned the closed state would otherwise read as a merge.
112
122
  function classify_child(
113
123
  state: IssueState | undefined,
114
124
  signals: EndingSignals = NO_SIGNALS,
115
125
  ): ChildOutcome {
126
+ if (signals.is_withdrawn === true) return 'withdrawn'
127
+
116
128
  if (state === undefined) return 'unresolved'
117
129
 
118
130
  if (is_closed(state)) return 'merged'
@@ -121,9 +133,9 @@ function classify_child(
121
133
  }
122
134
 
123
135
  // The counter change a child's outcome implies. A merge counts a merge (which resets the failure
124
- // streak); a failure counts a failure; a split, parked or human-review child counts nothing — a decision
125
- // waiting on a person is not the environment failing — and `undefined` says the carry record is left
126
- // untouched.
136
+ // streak); a failure counts a failure; a split, parked, withdrawn or human-review child counts nothing —
137
+ // a decision a person made or still owes is not the environment failing — and `undefined` says the
138
+ // carry record is left untouched.
127
139
  function change_of(outcome: ChildOutcome): CarryChange | undefined {
128
140
  if (outcome === 'merged') return { merged: ONE }
129
141
 
@@ -103,7 +103,7 @@ const REASONS: Record<LivenessVerdict, string> = {
103
103
  'A trace could not be read, so this says nothing about the unit. A trace that could not be read answers `undetermined`, never `stopped`.',
104
104
  }
105
105
 
106
- const ALIVE_ADVICE = 'Keep polling; do not touch the child.'
106
+ const ALIVE_ADVICE = 'Keep waiting on `pnpm josh lane:await`; do not touch the child.'
107
107
  const SETTLED_ADVICE =
108
108
  'Read the child with `pnpm josh issue:state <N>` and take the branch its state says.'
109
109
  // The unit stopped, but *why* it stopped — abandoned mid-implementation, or dead because it could not
@@ -0,0 +1,157 @@
1
+ import { hook_decision } from '#scripts/josh/hook-decision'
2
+ import { error_text } from '#scripts/lib/error-message'
3
+ import { time_density_hook } from '#scripts/time-runtime/time-density-hook'
4
+ import { time_shell } from '#scripts/time-runtime/time-shell'
5
+ import { time_spans, type Span } from '#scripts/time-runtime/time-spans'
6
+ import { run_headless } from './run-headless'
7
+ import { run_watcher_hook } from './run-watcher-hook'
8
+
9
+ // The `backlogrun` parent's and the wake session's poll, refused at its first repeat.
10
+ //
11
+ // **The parent waits on a blocking command, never on a re-read.** `backlog:drive` and `lane:await` hold
12
+ // the turn until something the model has to read happens; a state read issued again with nothing
13
+ // decided in between is a poll, and each one is a model turn spent on an answer that has not changed.
14
+ //
15
+ // **"Nothing decided in between" is read off the transcript**: the last completed call before this one
16
+ // ran the same state read on the same operands, with no person's prompt after it. A different command, a person's prompt or
17
+ // any other tool call in between breaks the streak, so a progress question answered twice is not one.
18
+ //
19
+ // **The parent test is `run_headless.driving_carry`'s**, the one the parent hand-off guard reads, so a
20
+ // lane child, a person's session and a handed-off record are left alone. A wake session adopts the
21
+ // record, so it reads as the parent here.
22
+
23
+ const SWITCH_ENV_KEY = 'JOSH_POLL_GUARD'
24
+ const BASH_TOOL = 'Bash'
25
+ const STATE_READS: ReadonlySet<string> = new Set([
26
+ 'josh lane:list',
27
+ 'josh lane:output',
28
+ 'josh run:board',
29
+ 'josh run:carry',
30
+ 'josh run:liveness',
31
+ 'josh run:progress',
32
+ 'josh run:wake',
33
+ ])
34
+
35
+ const POLL_REASON =
36
+ '⛔ poll: this `backlogrun` session repeats a state read with nothing decided in between, on every ' +
37
+ 'occurrence. Wait on `pnpm josh backlog:drive`, or `pnpm josh lane:await <N...> --owner "$PPID"` in ' +
38
+ 'the background, and end the turn; its exit is the wake.'
39
+
40
+ // The flags that make a state-read command write: `run:carry --filed` twice is two filings, and
41
+ // `run:carry --cut` after `run:carry --json` is the hand-off, so neither is a re-read.
42
+ const WRITING_FLAGS: ReadonlySet<string> = new Set([
43
+ '--begin',
44
+ '--cut',
45
+ '--done',
46
+ '--end',
47
+ '--every',
48
+ '--filed',
49
+ '--loop',
50
+ '--mark',
51
+ '--merged',
52
+ '--resume',
53
+ '--retrospective',
54
+ '--start',
55
+ '--stop',
56
+ '--wait',
57
+ ])
58
+
59
+ const FLAG_PREFIX = '-'
60
+ const NAME_WORDS = 2
61
+ const SIGNATURE_SEPARATOR = ' '
62
+
63
+ // A state read keyed by the command and its operands, or `undefined` for a call that is none.
64
+ // **Operands, not flags**: `lane:output 1 <path>` then `lane:output 2 <path>` read two children, while
65
+ // `run:board` and `run:board --chat` print one answer.
66
+ function read_key(signature: string): string | undefined {
67
+ const words = signature.split(SIGNATURE_SEPARATOR)
68
+ const name = words.slice(0, NAME_WORDS).join(SIGNATURE_SEPARATOR)
69
+
70
+ if (!STATE_READS.has(name) || words.some((word) => WRITING_FLAGS.has(word))) return undefined
71
+
72
+ return words.filter((word) => !word.startsWith(FLAG_PREFIX)).join(SIGNATURE_SEPARATOR)
73
+ }
74
+
75
+ function read_keys(signatures: ReadonlyArray<string>): Array<string> {
76
+ return signatures.map((signature) => read_key(signature)).filter((key) => key !== undefined)
77
+ }
78
+
79
+ function state_reads_of(command: string): ReadonlyArray<string> {
80
+ return read_keys(time_shell.josh_signatures_of(command))
81
+ }
82
+
83
+ function command_of(input: unknown): string {
84
+ if (typeof input !== 'object' || input === null || !('command' in input)) return ''
85
+
86
+ return typeof input.command === 'string' ? input.command : ''
87
+ }
88
+
89
+ // The state reads the call runs, or none for a call this guard never judges: not a shell call, or a
90
+ // delegated unit's (it names the parent's transcript, so only `agent_id` tells it apart).
91
+ function candidate_reads(raw_payload: string): ReadonlyArray<string> {
92
+ const payload = hook_decision.parse_hook_payload(raw_payload)
93
+
94
+ if (payload?.tool_name !== BASH_TOOL || (payload.agent_id ?? '') !== '') return []
95
+
96
+ return state_reads_of(command_of(payload.tool_input))
97
+ }
98
+
99
+ // The last completed call or person's prompt; a model span is the turn itself, never a decision.
100
+ function last_step(tail: string): Span | undefined {
101
+ return time_spans
102
+ .parse_timeline(tail)
103
+ .spans.findLast((span) => span.category !== time_spans.MODEL_CATEGORY)
104
+ }
105
+
106
+ function repeats_last_call(tail: string, reads: ReadonlyArray<string>): boolean {
107
+ const last = last_step(tail)
108
+
109
+ if (last?.category !== time_spans.TOOL_CATEGORY) return false
110
+
111
+ const last_reads = read_keys(last.josh_signatures)
112
+
113
+ return reads.some((key) => last_reads.includes(key))
114
+ }
115
+
116
+ // Every cheap test first: the carry record is read only for a call that already repeats a state read.
117
+ function is_repeat(raw_payload: string): boolean {
118
+ const reads = hook_decision.is_switch_enabled(SWITCH_ENV_KEY) ? candidate_reads(raw_payload) : []
119
+ const transcript = reads.length === 0 ? undefined : run_watcher_hook.transcript_of(raw_payload)
120
+
121
+ if (transcript === undefined) return false
122
+
123
+ return repeats_last_call(time_density_hook.read_tail(transcript), reads)
124
+ }
125
+
126
+ /**
127
+ * The deny reason for a state read the driving `backlogrun` parent (or its wake session) issues again
128
+ * with nothing decided since the last one, or `undefined` when the call proceeds.
129
+ */
130
+ async function poll_reason(
131
+ raw_payload: string,
132
+ is_parent: () => Promise<boolean> = run_headless.is_backlog_parent,
133
+ ): Promise<string | undefined> {
134
+ if (!is_repeat(raw_payload)) return undefined
135
+
136
+ return (await is_parent()) ? POLL_REASON : undefined
137
+ }
138
+
139
+ // It fails open: an unreadable transcript or carry record allows the call rather than wedging the run.
140
+ async function safe_poll_reason(raw_payload: string): Promise<string | undefined> {
141
+ try {
142
+ return await poll_reason(raw_payload)
143
+ } catch (error) {
144
+ error_text.trace_swallowed('run_poll_hook.poll_reason', error)
145
+
146
+ return undefined
147
+ }
148
+ }
149
+
150
+ const run_poll_hook = {
151
+ POLL_REASON,
152
+ SWITCH_ENV_KEY,
153
+ poll_reason,
154
+ safe_poll_reason,
155
+ }
156
+
157
+ export { run_poll_hook }
@@ -14,15 +14,16 @@ import { run_prep, type PrepParts } from './run-prep'
14
14
  import { run_prep_cli } from './run-prep-cli'
15
15
  import { run_retrospective } from './run-retrospective'
16
16
  import { run_step, type ParentInput, type StepInput } from './run-step'
17
+ import { run_step_section } from './run-step-section'
17
18
 
18
- // `josh run:step [<N>]` — print the run's next single action, computed from the event stream, the carry
19
+ // `josh run:step [<N>]` — print the run's next single action, followed by the section that governs it
20
+ // (`run-step-section.ts`), computed from the event stream, the carry
19
21
  // record and the issue state. It reads exactly those three, never the
20
22
  // conversation: the issue facts through `run:prep`'s own gather (so this and `run:next` never answer
21
23
  // from different reads), the newest event off the stream, and the carry record's kind. The reader runs
22
24
  // the printed line, returns the result through `run:event`, and asks again — the loop `run:step`
23
25
  // exists to close.
24
26
 
25
- const SUCCESS_EXIT_CODE = 0
26
27
  const FAILURE_EXIT_CODE = 1
27
28
  const ARGV_OFFSET = 2
28
29
  const USAGE =
@@ -154,16 +155,31 @@ function parent_input(directory: string | undefined): ParentInput {
154
155
  // `unknown` is the one answer with nothing to run — no record this session drives — so it exits non-zero
155
156
  // with the usage, exactly as a position missing its issue state does.
156
157
  async function run_parent(): Promise<number> {
157
- const { line } = run_step.parent_action(parent_input(await run_carry.repository_directory()))
158
+ const action = run_step.parent_action(parent_input(await run_carry.repository_directory()))
158
159
 
159
- console.info(line)
160
- if (line !== run_step.UNKNOWN) return SUCCESS_EXIT_CODE
160
+ console.info(action.line)
161
+ if (action.line !== run_step.UNKNOWN) return run_step_section.print_for(action)
161
162
 
162
163
  console.error(USAGE)
163
164
 
164
165
  return FAILURE_EXIT_CODE
165
166
  }
166
167
 
168
+ function report({ input, ship_problems }: Gathered): number {
169
+ const action = run_step.next_action(input)
170
+
171
+ console.info(action.line)
172
+ // On stderr, so stdout stays the action line and its rule: what `josh ship` would
173
+ // refuse on is said at every step, while it is still cheap to meet.
174
+ if (ship_problems.length > 0) console.error(run_prep.ship_body(ship_problems))
175
+
176
+ // A state that could not be read exits non-zero, exactly as `run:prep` and `run:next` exit on the
177
+ // same failure, so a position missing its issue state is never read as a confident next step.
178
+ if (input.state === undefined) return FAILURE_EXIT_CODE
179
+
180
+ return run_step_section.print_for(action)
181
+ }
182
+
167
183
  async function run(argv: ReadonlyArray<string>): Promise<number> {
168
184
  if (argv.length === 0) return await run_parent()
169
185
 
@@ -175,16 +191,7 @@ async function run(argv: ReadonlyArray<string>): Promise<number> {
175
191
  return FAILURE_EXIT_CODE
176
192
  }
177
193
 
178
- const { input, ship_problems } = await gather(issue_number)
179
-
180
- console.info(run_step.next_action(input).line)
181
- // On stderr, so stdout stays the one action line: what `josh ship` would
182
- // refuse on is said at every step, while it is still cheap to meet.
183
- if (ship_problems.length > 0) console.error(run_prep.ship_body(ship_problems))
184
-
185
- // A state that could not be read exits non-zero, exactly as `run:prep` and `run:next` exit on the
186
- // same failure, so a position missing its issue state is never read as a confident next step.
187
- return input.state === undefined ? FAILURE_EXIT_CODE : SUCCESS_EXIT_CODE
194
+ return report(await gather(issue_number))
188
195
  }
189
196
 
190
197
  async function main(argv: ReadonlyArray<string>): Promise<void> {
@@ -0,0 +1,85 @@
1
+ import { document_section_cli } from '#scripts/document/document-section-cli'
2
+ import { run_step, type StepAction } from './run-step'
3
+
4
+ // The section that governs each action `run:step` and `run:entry` print — the one table the action →
5
+ // rule mapping lives in, so the manifests no longer carry a route table the reader walks to find it.
6
+ // The command prints the section after its action line, which saves the `doc:section` turn the reader
7
+ // would otherwise spend fetching a section the action already determined.
8
+ //
9
+ // **A heading that does not resolve fails the command**, exactly as `doc:section` refuses it: a pointer
10
+ // renamed away would otherwise print the action without its rule, and the reader would act unread.
11
+
12
+ const JOSH_PREFIX = 'pnpm josh '
13
+ const COMMAND_SEPARATOR = ' '
14
+ const SUCCESS_EXIT_CODE = 0
15
+
16
+ // The key `run:entry` prints under — not a `run:step` action, but the entry's own read, kept in the
17
+ // same table so every mechanically-chosen section has one home.
18
+ const ENTRY = 'entry'
19
+
20
+ interface SectionReference {
21
+ document: string
22
+ heading: string
23
+ }
24
+
25
+ function reference(document: string, heading: string): SectionReference {
26
+ return { document, heading }
27
+ }
28
+
29
+ // The gate's chain governs both the implementation it follows and a stopped ship's report.
30
+ const CHAIN = reference('chain-rule.md', 'Run the review-to-merge chain')
31
+ const PROGRESS = 'backlogrun-progress.md'
32
+
33
+ // Keyed on a verdict token, or on the josh subcommand a command action runs. An action with no row —
34
+ // `wait`, `stop`, a `decide:` line — has no rule to read beyond its own line.
35
+ const GOVERNING_SECTIONS: ReadonlyMap<string, SectionReference> = new Map([
36
+ [ENTRY, reference('split-assessment.md', 'The question')],
37
+ [run_step.IMPLEMENT, CHAIN],
38
+ [run_step.HUMAN_REVIEW, reference('needs-human-review.md', '`needs-human-review`')],
39
+ [run_step.UPDATE_DEPS, reference('latest-gate.md', 'The dependency update')],
40
+ ['followup', reference('followup.md', 'Run `pnpm josh followup`')],
41
+ ['review:round2', reference('prompts/review.md', 'Review round cap')],
42
+ ['run:merge', reference(PROGRESS, "Running a named epic's children")],
43
+ ['backlog:next', reference('backlogrun-steps.md', 'The loop')],
44
+ ['run:cut', reference('pre-gate-cut.md', 'Resuming')],
45
+ ['ship', CHAIN],
46
+ ['retrospective', reference('retrospective.md', 'The end-of-run retrospective')],
47
+ ['run:carry', reference(PROGRESS, 'The hand-off')],
48
+ ])
49
+
50
+ function key_of(action: StepAction): string {
51
+ if (action.kind !== 'command') return action.line
52
+
53
+ const [subcommand = ''] = action.line.slice(JOSH_PREFIX.length).split(COMMAND_SEPARATOR)
54
+
55
+ return subcommand
56
+ }
57
+
58
+ function section_for(key: string): SectionReference | undefined {
59
+ return GOVERNING_SECTIONS.get(key)
60
+ }
61
+
62
+ // Printed through `doc:section` itself, so the extent, the Bash-cap split and the refusal of an
63
+ // unresolved heading are that command's, never a second copy of them.
64
+ function print_section(key: string, root: string = process.cwd()): number {
65
+ const found = section_for(key)
66
+
67
+ if (found === undefined) return SUCCESS_EXIT_CODE
68
+
69
+ return document_section_cli.run([found.document, found.heading], root)
70
+ }
71
+
72
+ function print_for(action: StepAction, root: string = process.cwd()): number {
73
+ return print_section(key_of(action), root)
74
+ }
75
+
76
+ const run_step_section = {
77
+ ENTRY,
78
+ GOVERNING_SECTIONS,
79
+ key_of,
80
+ print_for,
81
+ print_section,
82
+ }
83
+
84
+ export type { SectionReference }
85
+ export { run_step_section }