@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
@@ -4,6 +4,7 @@ import { filing_offer } from './filing-offer'
4
4
  import { issue_citation } from './issue-citation'
5
5
  import { lane_background } from './lane-background'
6
6
  import { reply_language } from './reply-language'
7
+ import { stop_decision, type StopDecision } from './stop-decision'
7
8
 
8
9
  // The stop-time rules, delivered on the `Stop` hook.
9
10
  //
@@ -19,11 +20,14 @@ import { reply_language } from './reply-language'
19
20
  // permission envelope. The stop guard is a second *entry* on the one foundation, exactly as
20
21
  // `pretool-guard` is one — not a second copy of the plumbing.
21
22
  //
22
- // **Every rule refuses**. Missing the stop notification or leaving a hold
23
- // on a clean tree costs a person a silent wait or a trampled tree; a bare `#N` in the reply reaches
24
- // the person watching but not the model that could fix it, so it too blocks — `{"decision":"block"}`
25
- // is the one channel a `Stop` hook has to the model, and `stop_hook_active` caps a false positive at a
26
- // single wasted turn. The detection is tightened to match (`issue-citation.ts`, `filing-offer.ts`).
23
+ // **A rule whose answer is fixed acts; only a rule that needs judgement refuses** (joshuafolkken/kit#3919).
24
+ // The stop notification, the hold release and the citation lines are each one mechanical operation the
25
+ // hook can run itself, so they come back as `actions` and the stop goes through: a refusal is a whole
26
+ // model turn spent re-reading the context to run a command the hook already knew. `stop-actions.ts`
27
+ // runs them, and only an action that fails falls back to its refusal. The rest — the reply's language,
28
+ // an unattended filing offer, a lane's index request or running task, a headless parent's wait — need
29
+ // the model, so they still block with `{"decision":"block"}`, the one channel a `Stop` hook has to it;
30
+ // `stop_hook_active` caps a false positive at a single wasted turn.
27
31
 
28
32
  const SWITCH_ENV_KEY = 'JOSH_STOP_GUARD'
29
33
  const BLOCK_DECISION = 'block'
@@ -40,14 +44,22 @@ type StopPayload = z.infer<typeof stop_payload_schema>
40
44
 
41
45
  // What one stop looks like to the rules, computed once by the CLI from the payload and the world.
42
46
  interface StopContext {
43
- // The `run:hold` record for this working tree is still in place (`held` or `stale`).
44
- hold_present: boolean
47
+ // The Issue the `run:hold` record for this working tree names (`held` or `stale`) — `new` for an
48
+ // unnumbered entry — or `undefined` when no record is in place.
49
+ hold_issue: string | undefined
50
+ // That record is this session's own run's, or its owner cannot be told (`run-hold-session.ts`). A
51
+ // hold another run took is that run's pause to announce and release, never this session's.
52
+ hold_owned: boolean
53
+ // The person opened this turn with a question: the reply answers someone at the session, not a
54
+ // pause waiting on them (`stop-decision.ts` → `is_question`).
55
+ answers_question: boolean
45
56
  // `git status --porcelain` came back empty.
46
57
  tree_clean: boolean
47
58
  // That record carries a `prrun` stop mark: the run stopped at a green pull
48
59
  // request and keeps the hold over a clean tree on purpose, so a person can look before it merges.
49
60
  prrun_stopped: boolean
50
- // A `confirmation` notify is on this run's transcript tail.
61
+ // A `confirmation` notify is on this run's transcript tail, or the hook's own send is recorded on the
62
+ // hold since the pause began (`run-hold-session.ts` → `is_notified_since`).
51
63
  notified: boolean
52
64
  // The turn's session-facing reply text.
53
65
  message: string
@@ -56,7 +68,8 @@ interface StopContext {
56
68
  // This stop already forced a continuation earlier in the prompt — the loop-breaker Claude Code
57
69
  // documents, so a run that will not comply is not blocked forever.
58
70
  stop_hook_active: boolean
59
- // A pre-gate cut record is in place for this work tree. A dispatched lane child ends its turn at
71
+ // This turn ended at a cut — its record is in place, or this turn relaunched the successor that may
72
+ // already have cleared it (`stop-guard.ts` → `is_cut_stop`). A dispatched lane child ends its turn at
60
73
  // the cut holding the tree by design and hands off to a fresh process (`pre-gate-cut.md`), so that
61
74
  // turn-end is an automatic continuation, not the person-waiting pause the block rules are for.
62
75
  cut_pending: boolean
@@ -90,15 +103,26 @@ interface StopContext {
90
103
  backlog_parent: boolean
91
104
  }
92
105
 
106
+ // The operations the hook runs itself instead of refusing — each carries what it acts on.
107
+ type StopAction =
108
+ | { kind: 'notify'; issue: string; message: string; decision: StopDecision }
109
+ | { kind: 'release'; issue: string }
110
+ | { kind: 'cite'; references: ReadonlyArray<string> }
111
+
112
+ // `reason` refuses the stop; otherwise `actions` run, and `blocks_on_failure` says whether a failed one
113
+ // falls back to its refusal — never past the loop-breaker.
93
114
  interface StopOutcome {
94
115
  reason: string | undefined
116
+ actions: ReadonlyArray<StopAction>
117
+ blocks_on_failure: boolean
95
118
  }
96
119
 
97
- const NO_OUTCOME: StopOutcome = { reason: undefined }
120
+ const NO_OUTCOME: StopOutcome = { reason: undefined, actions: [], blocks_on_failure: false }
98
121
 
99
122
  // **A mid-workflow stop is announced off-screen before it happens.** `CLAUDE.md` → "Mid-workflow stop
100
123
  // notification" requires the `confirmation` Telegram before any pause; the hold is what says a run is
101
- // mid-workflow, and the transcript is what says the notify was sent.
124
+ // mid-workflow, and the transcript is what says the notify was sent. The hook sends it; this reason is
125
+ // only the fallback for a send that failed.
102
126
  const STOP_NOTIFY_REASON =
103
127
  '⛔ mid-workflow stop notification: this working tree is still held by a run and no `confirmation` ' +
104
128
  'Telegram was sent this turn. Send ' +
@@ -109,13 +133,14 @@ const STOP_NOTIFY_REASON =
109
133
  // **A clean tree that still holds is a tree the next run will trample.** `working-tree-hold.md`: a stop that
110
134
  // leaves the tree clean releases the hold; a `halfrun` pre-commit stop and a `needs-human-review` stop
111
135
  // keep it because their tree is dirty, which is why this row is silent whenever the tree is not clean.
136
+ // The hook releases it; this reason is only the fallback for a release that failed.
112
137
  const HOLD_RELEASE_REASON =
113
138
  '⛔ working-tree hold not released: this working tree is clean but its `run:hold` record is still in ' +
114
139
  'place. Release it with `pnpm josh run:release <N>` (bare for a `new` entry; ' +
115
140
  '`working-tree-hold.md`), then confirm in one line that it was released — do not repeat your ' +
116
141
  'previous reply.'
117
142
 
118
- // **A refusal that corrects rather than advises**. The bare `#N` is already
143
+ // **The fallback when the hook could not print the citation lines itself**. The bare `#N` is already
119
144
  // on screen and the hook cannot unsay it, so the reason does the one thing that helps the *next* reply:
120
145
  // it names the numbers it detected and hands over the exact `issue:cite` call that prints the
121
146
  // paste-ready lines, then asks for the corrected citation lines alone — not the whole reply reissued.
@@ -246,42 +271,52 @@ function needs_filing(context: StopContext): boolean {
246
271
  //
247
272
  // **A lane handed to the detached ship stands them down too**: the hold now
248
273
  // belongs to the supervisor driving the merge, and the person hears from that supervisor's own notify.
274
+ //
275
+ // **Another run's hold stands them down as well**: the pause and the claim are that run's.
249
276
  function is_held_idle(context: StopContext): boolean {
250
- return context.hold_present && !context.agent_pending && !context.handed_off
277
+ if (context.hold_issue === undefined || !context.hold_owned) return false
278
+
279
+ return !context.agent_pending && !context.handed_off
251
280
  }
252
281
 
282
+ // **A stop that answers the person's question is no pause** (joshuafolkken/kit#3961): they are reading
283
+ // the reply where it was written, whatever the tree's hold says.
253
284
  function needs_notify(context: StopContext): boolean {
254
- return is_held_idle(context) && !context.notified
285
+ return is_held_idle(context) && !context.notified && !context.answers_question
255
286
  }
256
287
 
257
- function needs_release(context: StopContext): boolean {
258
- return is_held_idle(context) && context.tree_clean && !context.prrun_stopped
288
+ // The notify a stop owes but cannot send: its reply names no decision to announce.
289
+ function needs_decision(context: StopContext): boolean {
290
+ return needs_notify(context) && stop_decision.decision_of(context.message) === undefined
259
291
  }
260
292
 
261
- function citation_reason(context: StopContext): string | undefined {
262
- const references = issue_citation.unquoted_references(context.message, context.prompt)
263
- if (references.length === 0) return undefined
264
-
265
- return build_citation_reason(references)
293
+ function needs_release(context: StopContext): boolean {
294
+ return is_held_idle(context) && context.tree_clean && !context.prrun_stopped
266
295
  }
267
296
 
268
- // The two rules on how a reply is written. **A headless agent's reply has no reader**:
269
- // its text lands in a stream-json log, so correcting its citations or its
270
- // language costs a turn and reaches nobody. The artifacts a person does read — a Telegram, an Issue, a
271
- // pull request — are held to the same rules by their own guards, not here.
272
- function wording_reason(context: StopContext): string | undefined {
273
- if (context.headless_agent) return undefined
297
+ // The bare references the citation lines are printed for. **A headless agent's reply has no reader**:
298
+ // its text lands in a stream-json log, so correcting its citations or its language costs a turn and
299
+ // reaches nobody. The artifacts a person does read — a Telegram, an Issue, a pull request — are held
300
+ // to the same rules by their own guards, not here.
301
+ function bare_references(context: StopContext): ReadonlyArray<string> {
302
+ if (context.headless_agent) return []
274
303
 
275
- return citation_reason(context) ?? language_reason(context)
304
+ return issue_citation.unquoted_references(context.message, context.prompt)
276
305
  }
277
306
 
278
- // The three rules that read the reply itself, filing offer first and the language last. A drifted reply
307
+ // The two rules that read the reply and need the model, the filing offer first. A drifted reply
279
308
  // refused for another reason is not rewritten, but the drift still ends: `block_envelope` appends the
280
309
  // session-language note to every refusal, so the turn it continues is written in the session language.
281
310
  function reply_reason(context: StopContext): string | undefined {
282
311
  if (needs_filing(context)) return FILING_OFFER_REASON
312
+ if (context.headless_agent) return undefined
283
313
 
284
- return wording_reason(context)
314
+ return language_reason(context)
315
+ }
316
+
317
+ // Sent back once; past the loop-breaker the run has said nothing is awaited, and nothing is sent.
318
+ function decision_reason(context: StopContext): string | undefined {
319
+ return needs_decision(context) ? stop_decision.DECISION_REASON : undefined
285
320
  }
286
321
 
287
322
  // The headless wait holds past the loop-breaker until the spin bound is reached.
@@ -306,24 +341,56 @@ function needs_background_wait(context: StopContext): boolean {
306
341
  return context.lane_child && context.background_pending
307
342
  }
308
343
 
309
- // The two hold rules, notify ahead of release — behind the lane index route
310
- // and the lane background wait, since the notify they would ask for announces
311
- // a pause the run has no reason to take.
312
- function hold_reason(context: StopContext): string | undefined {
344
+ // The two lane rules that mean the run is not pausing at all — ahead of every action, since the notify
345
+ // would announce a pause the run has no reason to take.
346
+ function lane_reason(context: StopContext): string | undefined {
313
347
  if (needs_index_route(context)) return LANE_INDEX_REASON
314
348
  if (needs_background_wait(context)) return lane_background.LANE_BACKGROUND_STOP_REASON
315
- if (needs_notify(context)) return STOP_NOTIFY_REASON
316
- if (needs_release(context)) return HOLD_RELEASE_REASON
317
349
 
318
350
  return undefined
319
351
  }
320
352
 
321
- // First-wins across every rule. Two things stand every rule down: `stop_hook_active` (the
322
- // loop-breaker, so a run that already got one continuation this prompt may stop) and a pre-gate cut in
323
- // flight (a lane child's automatic turn-end, not a person-waiting pause). The two hold rules keep their
324
- // order ahead of the reply rules, so a stop that both still owes its notify and holds a bare `#N` still
325
- // reports the notify first; the filing offer goes ahead of the citation, since the filing it forces
326
- // produces the citation the reply then needs.
353
+ // The refusals that need the model: the lane rules alone, else the reply's own and the decision
354
+ // together. **Joined, since the loop-breaker grants one continuation**: a decision refusal queued behind
355
+ // a language or filing refusal would never be sent, and its stop would pass with no notify.
356
+ function model_reason(context: StopContext): string | undefined {
357
+ const lane = lane_reason(context)
358
+ if (lane !== undefined) return lane
359
+
360
+ const reasons = [reply_reason(context), decision_reason(context)].filter(
361
+ (reason) => reason !== undefined,
362
+ )
363
+
364
+ return reasons.length === 0 ? undefined : reasons.join('\n\n')
365
+ }
366
+
367
+ // The notify carries the reply's own decision; a reply with none sends nothing (`needs_decision`).
368
+ function notify_actions(context: StopContext, issue: string): Array<StopAction> {
369
+ const decision = stop_decision.decision_of(context.message)
370
+ if (decision === undefined || !needs_notify(context)) return []
371
+
372
+ return [{ kind: 'notify', issue, message: context.message, decision }]
373
+ }
374
+
375
+ function hold_actions(context: StopContext): Array<StopAction> {
376
+ const issue = context.hold_issue
377
+ if (issue === undefined) return []
378
+
379
+ const notify = notify_actions(context, issue)
380
+
381
+ return needs_release(context) ? [...notify, { kind: 'release', issue }] : notify
382
+ }
383
+
384
+ // The actions in the order they run: the notify ahead of the release that ends the hold it reports.
385
+ function stop_actions(context: StopContext): ReadonlyArray<StopAction> {
386
+ const references = bare_references(context)
387
+ const cite: Array<StopAction> = references.length > 0 ? [{ kind: 'cite', references }] : []
388
+
389
+ return [...hold_actions(context), ...cite]
390
+ }
391
+
392
+ // First-wins across the refusals. `stop_hook_active` is the loop-breaker, so a run that already got one
393
+ // continuation this prompt may stop; the lane rules go first, then the filing offer, then the language.
327
394
  //
328
395
  // **The headless wait comes first and outlasts `stop_hook_active` up to its spin bound**.
329
396
  // The loop-breaker exists so a person is not wedged behind a rule the run will not satisfy; a headless
@@ -334,18 +401,49 @@ function block_reason(context: StopContext): string | undefined {
334
401
  if (is_headless_held(context)) return HEADLESS_WAIT_REASON
335
402
  if (context.stop_hook_active || context.cut_pending) return undefined
336
403
 
337
- return hold_reason(context) ?? reply_reason(context)
404
+ return model_reason(context)
405
+ }
406
+
407
+ // **The actions run only on the stop that goes through**, so a refused stop never notifies a pause its
408
+ // continuation then cancels, while the continuation's own stop, past the loop-breaker, still runs them
409
+ // and a failure there no longer refuses. A lane child still waiting on its own work runs none, and a
410
+ // pre-gate cut in flight stands everything but the headless wait down: it is a lane child's automatic
411
+ // turn-end, not a person-waiting pause.
412
+ function runs_actions(context: StopContext, reason: string | undefined): boolean {
413
+ return reason === undefined && !context.cut_pending && lane_reason(context) === undefined
338
414
  }
339
415
 
340
416
  function stop_outcome(context: StopContext): StopOutcome {
341
- return { reason: block_reason(context) }
417
+ const reason = block_reason(context)
418
+ if (!runs_actions(context, reason)) return { ...NO_OUTCOME, reason }
419
+
420
+ const actions = stop_actions(context)
421
+ if (actions.length === 0) return NO_OUTCOME
422
+
423
+ return { reason, actions, blocks_on_failure: !context.stop_hook_active }
424
+ }
425
+
426
+ // The refusal an action falls back to when the hook could not run it.
427
+ function fallback_reason(action: StopAction): string {
428
+ if (action.kind === 'notify') return STOP_NOTIFY_REASON
429
+ if (action.kind === 'release') return HOLD_RELEASE_REASON
430
+
431
+ return build_citation_reason(action.references)
342
432
  }
343
433
 
344
434
  // The documented shape a `Stop` hook blocks with: `reason` is fed back to Claude, which then continues
345
435
  // instead of stopping. Plain stdout is not it — only this envelope holds the stop. Every reason leaves
346
436
  // with the session-language note, so no rule can hand the model an English-only turn.
347
- function block_envelope(reason: string, lang: string): string {
348
- return JSON.stringify({ decision: BLOCK_DECISION, reason: `${reason} ${language_note(lang)}` })
437
+ // `notice` is what the hook already did on this stop, shown to the person alongside.
438
+ function block_envelope(reason: string, lang: string, notice?: string): string {
439
+ const refusal = { decision: BLOCK_DECISION, reason: `${reason} ${language_note(lang)}` }
440
+
441
+ return JSON.stringify(notice === undefined ? refusal : { ...refusal, systemMessage: notice })
442
+ }
443
+
444
+ // The shape a `Stop` hook shows the person a line in without holding the stop: `systemMessage` alone.
445
+ function notice_envelope(notice: string): string {
446
+ return JSON.stringify({ systemMessage: notice })
349
447
  }
350
448
 
351
449
  function parse_stop_payload(raw_payload: string): StopPayload | undefined {
@@ -371,10 +469,12 @@ const stop_rules = {
371
469
  block_envelope,
372
470
  build_language_reason,
373
471
  count_headless_refusals,
472
+ fallback_reason,
374
473
  is_enabled,
474
+ notice_envelope,
375
475
  parse_stop_payload,
376
476
  stop_outcome,
377
477
  }
378
478
 
379
- export type { StopContext, StopOutcome }
479
+ export type { StopAction, StopContext, StopOutcome }
380
480
  export { stop_rules }
@@ -30,6 +30,20 @@ function prior_bash_commands(tail: string): ReadonlyArray<string> {
30
30
  return tail.split('\n').flatMap((line) => bash_commands_of(line))
31
31
  }
32
32
 
33
- const tail_commands = { is_bash_use, prior_bash_commands }
33
+ // **Whether a call `is_call` names answered with the `token` line** — the call and its own result
34
+ // paired off parsed blocks, so a transcript that merely quotes the command or the token does not
35
+ // count. An unreadable text reads as no answer.
36
+ function answered(text: string, is_call: (command: string) => boolean, token: string): boolean {
37
+ const blocks = time_transcript_line.parse_text(text).flatMap((line) => line.blocks)
38
+ const call_ids = new Set(
39
+ blocks
40
+ .filter((block) => is_call(time_shell.bash_command(block.input)))
41
+ .map((block) => block.id),
42
+ )
43
+
44
+ return blocks.some((block) => call_ids.has(block.result_id) && block.token_lines.includes(token))
45
+ }
46
+
47
+ const tail_commands = { answered, is_bash_use, prior_bash_commands }
34
48
 
35
49
  export { tail_commands }
@@ -2,6 +2,7 @@
2
2
  import { setTimeout as sleep } from 'node:timers/promises'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { styleText } from 'node:util'
5
+ import { core_budget } from '#scripts/gate/core-budget'
5
6
  import { machine_capacity } from '#scripts/gate/machine-capacity'
6
7
  import { telegram_notify } from '#scripts/notify/telegram-notify'
7
8
  import { run_progress } from '#scripts/run/progress/run-progress'
@@ -51,6 +52,7 @@ const LIVE_PORTS: BoardPorts = {
51
52
  read_plan: async (scope) => await run_board_plan.read_plan(scope),
52
53
  read_local: run_board_read.read_local,
53
54
  read_machine: machine_capacity.read_sample,
55
+ read_waits: () => core_budget.budget_waits(),
54
56
  read_usage: run_board_usage_read.usage_reader(),
55
57
  read_closed: async (issues) => await run_board_closed.read_all(issues),
56
58
  read_thanks: async () => await run_board_thanks_read.read_thanks(),
@@ -18,10 +18,13 @@ interface ClosedIssue {
18
18
  closed_ms: number | undefined
19
19
  // Closed by a merged pull request, rather than closed by hand.
20
20
  is_merged: boolean
21
+ // Closed as not planned — withdrawn rather than finished (joshuafolkken/kit#3958).
22
+ is_not_planned: boolean
21
23
  }
22
24
 
23
- const CLOSED_FIELDS = 'title,state,closedAt,labels'
25
+ const CLOSED_FIELDS = 'title,state,stateReason,closedAt,labels'
24
26
  const CLOSED_STATE = 'CLOSED'
27
+ const NOT_PLANNED_REASON = 'not_planned'
25
28
  const STILL_OPEN = 'open' as const
26
29
 
27
30
  // What one issue answered: closed, still open, or `undefined` when it could not be read.
@@ -41,6 +44,7 @@ const READ_CONCURRENCY = 4
41
44
  const closed_schema = z.looseObject({
42
45
  title: z.string(),
43
46
  state: z.string(),
47
+ stateReason: z.string().nullish(),
44
48
  closedAt: z.string().nullish(),
45
49
  labels: z.array(issue_label_schema).default([]),
46
50
  })
@@ -69,7 +73,9 @@ async function read_closed(issue: number): Promise<ClosedRead> {
69
73
 
70
74
  const labels = view.labels.map((label) => label.name)
71
75
 
72
- return { title: view.title, labels, closed_ms: closed_ms_of(view), is_merged }
76
+ const is_not_planned = view.stateReason === NOT_PLANNED_REASON
77
+
78
+ return { title: view.title, labels, closed_ms: closed_ms_of(view), is_merged, is_not_planned }
73
79
  }
74
80
 
75
81
  async function read_all(
@@ -73,12 +73,20 @@ function fold_plan(state: BoardState): BoardState {
73
73
  }
74
74
  }
75
75
 
76
+ // joshuafolkken/kit#3969: the planned issues whose open lane may be this run's though the stream never
77
+ // saw it launched. An earlier run's lane is not, so its issue waits until this run launches it.
78
+ function lanes_in_run(plan: BoardPlan, local: LocalRead): ReadonlySet<number> {
79
+ const earlier = new Set(local.earlier_lanes?.map(Number))
80
+
81
+ return new Set([...run_board_layout.numbers_of(plan)].filter((issue) => !earlier.has(issue)))
82
+ }
83
+
76
84
  function statuses_for(
77
85
  state: BoardState,
78
86
  plan: BoardPlan,
79
87
  local: LocalRead,
80
88
  ): ReadonlyMap<number, ItemStatus> {
81
- const in_run = run_board_layout.numbers_of(plan)
89
+ const in_run = lanes_in_run(plan, local)
82
90
  const stopped = run_board_idle.stopped_of(state.idle_since, state.read_ms)
83
91
 
84
92
  return run_board_status.statuses_of(local.events, local.lanes, in_run, stopped)
@@ -1,5 +1,6 @@
1
1
  import { styleText } from 'node:util'
2
2
  import { backlog_budget } from '#scripts/backlog/backlog-budget'
3
+ import type { BudgetWait } from '#scripts/gate/core-wait'
3
4
  import { run_board_fit } from './run-board-fit'
4
5
  import { run_board_fresh } from './run-board-fresh'
5
6
  import { run_board_labels } from './run-board-labels'
@@ -65,6 +66,8 @@ interface BoardHeader {
65
66
  machine: MachineGauges | undefined
66
67
  // Each running lane's CPU and memory, `undefined` where none could be read.
67
68
  usages?: LaneUsages | undefined
69
+ // The claims waiting in the machine-wide core budget (`run-board-render-waits.ts`).
70
+ budget_waits?: ReadonlyArray<BudgetWait> | undefined
68
71
  // How long each phase usually takes; a running row's phase time is colored against it.
69
72
  usual?: UsualTimes | undefined
70
73
  // The spinner's frame a running run and a running row turn, `undefined` where the output is not a
@@ -107,7 +110,7 @@ function count_state(rows: ReadonlyArray<BoardRow>, state: BoardRow['state']): n
107
110
  function counts_of(layout: BoardLayout): BoardCounts {
108
111
  const total = run_board_layout.rows_of(layout).length
109
112
  const parked = count_state(layout.active, 'parked')
110
- const settled = count_state(layout.active, 'merged') + parked + count_state(layout.active, 'done')
113
+ const settled = layout.active.filter((row) => run_board_layout.is_settled(row.state)).length
111
114
  const running = count_state(layout.active, 'running')
112
115
 
113
116
  return { total, settled, parked, running, remaining: total - settled - running }
@@ -43,6 +43,7 @@ const WORDS = {
43
43
  merged: 'merged',
44
44
  parked: 'parked',
45
45
  done: 'done',
46
+ withdrawn: 'withdrawn',
46
47
  in_progress: 'running',
47
48
  stopped: 'stopped',
48
49
  waiting: 'waiting',
@@ -104,6 +105,7 @@ const STATE_ICONS: Readonly<Record<ItemState, string>> = {
104
105
  waiting: '⏳',
105
106
  human: '🙋',
106
107
  stopped: '🛑',
108
+ withdrawn: '🚫',
107
109
  }
108
110
 
109
111
  // The blocking edge, chosen on the same rule: ⛓ is a text symbol.
@@ -82,7 +82,7 @@ interface Drawn extends Overlay {
82
82
  reason_of: ReasonOf | undefined
83
83
  }
84
84
 
85
- const SETTLED_STATES: ReadonlySet<ItemState> = new Set(['merged', 'parked', 'done'])
85
+ const SETTLED_STATES: ReadonlySet<ItemState> = new Set(['merged', 'parked', 'done', 'withdrawn'])
86
86
 
87
87
  function is_settled(state: ItemState): boolean {
88
88
  return SETTLED_STATES.has(state)
@@ -37,9 +37,15 @@ interface LocalRead {
37
37
  // The open lanes no child is working in at this read (`lane-spent.ts`); one is drawn stopped once it
38
38
  // has stayed so (`run-board-idle.ts`).
39
39
  idle_lanes?: ReadonlyArray<string> | undefined
40
+ // The open lanes that last took a child before the run started — an earlier run's, until this run
41
+ // dispatches into one again.
42
+ earlier_lanes?: ReadonlyArray<string> | undefined
40
43
  // How many more lanes the limit allows beside the live ones, as `lane:limit` counts them;
41
44
  // `undefined` where the limit could not be read, so nothing is said about it.
42
45
  free_lanes?: number | undefined
46
+ // The issues whose lanes hold a place under the limit (`backlog-occupied.ts`), named beside a row
47
+ // that waits for a free lane.
48
+ occupied_lanes?: ReadonlyArray<string> | undefined
43
49
  // How long each phase usually takes, from the repository's lane ledger.
44
50
  usual?: UsualTimes | undefined
45
51
  }
@@ -81,6 +87,13 @@ function sources_of(
81
87
  }
82
88
  }
83
89
 
90
+ // joshuafolkken/kit#3969: a lane whose last dispatch is unknown is not called earlier.
91
+ function earlier_lanes(lanes: ReadonlyArray<LaneInfo>, started_ms: number): Array<string> {
92
+ return lanes
93
+ .filter((lane) => lane.dispatched_ms !== undefined && lane.dispatched_ms < started_ms)
94
+ .map((lane) => lane.issue)
95
+ }
96
+
84
97
  // `undefined` when no run has started here.
85
98
  async function read_local(): Promise<LocalRead | undefined> {
86
99
  const repository = await run_carry.repository_directory()
@@ -90,6 +103,7 @@ async function read_local(): Promise<LocalRead | undefined> {
90
103
 
91
104
  const events = run_event_stream.read_events(run_event_stream.target_of(repository))
92
105
  const lanes = await lane_registry.list_lanes()
106
+ const occupied = await backlog_ready.occupied_lanes(lanes)
93
107
 
94
108
  return {
95
109
  started_ms: span.started_ms,
@@ -101,7 +115,9 @@ async function read_local(): Promise<LocalRead | undefined> {
101
115
  is_stranded: run_stranded_detect.is_stranded_at(repository),
102
116
  operations: run_board_operation_read.read(sources_of(repository, lanes, transcript)),
103
117
  idle_lanes: lane_spent.idle_lanes(lanes),
118
+ earlier_lanes: earlier_lanes(lanes, span.started_ms),
104
119
  free_lanes: await backlog_ready.free_lanes_beside(lanes),
120
+ occupied_lanes: occupied.map((lane) => lane.issue),
105
121
  usual: run_board_usual.read(repository),
106
122
  }
107
123
  }
@@ -36,6 +36,9 @@ interface ReasonRead extends Overlay {
36
36
  events: ReadonlyArray<RunEvent>
37
37
  // How many more lanes the limit allows; `undefined` where it could not be read.
38
38
  free_lanes: number | undefined
39
+ // The issues whose lanes fill the limit, named after `no free lane` so a lane an earlier run left
40
+ // behind shows on the row it holds back.
41
+ occupied_lanes?: ReadonlyArray<string> | undefined
39
42
  // A stopped run opens no lane, so a stage its stream ends on is not one any lane is at.
40
43
  is_stopped: boolean
41
44
  }
@@ -92,6 +95,12 @@ function overlap_of(child: EpicChild, read: ReasonRead, pool: Pool): string | un
92
95
  : `${WORDS.overlaps} ${String(clash.holder)} (${clash.path})`
93
96
  }
94
97
 
98
+ function no_lane_of(occupied: ReadonlyArray<string> = []): string {
99
+ if (occupied.length === 0) return WORDS.no_lane
100
+
101
+ return `${WORDS.no_lane} — ${occupied.join(' ')}`
102
+ }
103
+
95
104
  // A candidate nothing blocks, in the order `backlog:next` answers: the pool held for a judgment, then
96
105
  // a path a running issue declares, then a pool with no lane left.
97
106
  function offer_wait(child: EpicChild, read: ReasonRead, pool: Pool): string | undefined {
@@ -101,7 +110,7 @@ function offer_wait(child: EpicChild, read: ReasonRead, pool: Pool): string | un
101
110
 
102
111
  if (overlap !== undefined) return overlap
103
112
 
104
- return read.free_lanes === NO_FREE_LANES ? WORDS.no_lane : undefined
113
+ return read.free_lanes === NO_FREE_LANES ? no_lane_of(read.occupied_lanes) : undefined
105
114
  }
106
115
 
107
116
  function wait_of(child: EpicChild, read: ReasonRead, pool: Pool): RowReason | undefined {
@@ -32,6 +32,7 @@ const STATE_LEGEND: ReadonlyArray<readonly [ItemState, keyof Words]> = [
32
32
  ['merged', 'merged'],
33
33
  ['parked', 'parked'],
34
34
  ['done', 'done'],
35
+ ['withdrawn', 'withdrawn'],
35
36
  ['running', 'in_progress'],
36
37
  ['stopped', 'stopped'],
37
38
  ['waiting', 'waiting'],
@@ -9,7 +9,7 @@ import { run_board_usage_text } from './run-board-usage-text'
9
9
  // blanks on any other row.
10
10
 
11
11
  const { finish_of, text_of } = run_board_usage_text
12
- const FINISHED: ReadonlySet<ItemState> = new Set(['merged', 'done'])
12
+ const FINISHED: ReadonlySet<ItemState> = new Set(['merged', 'done', 'withdrawn'])
13
13
  const BLANK = ' '
14
14
 
15
15
  // What one frame's column is drawn from: each lane's usage, and the running rows the frame draws —
@@ -0,0 +1,41 @@
1
+ import { core_wait, type BudgetWait } from '#scripts/gate/core-wait'
2
+
3
+ // The claims waiting in the machine-wide core budget, under the header (joshuafolkken/kit#3943): a lane
4
+ // held back by memory pressure otherwise reads the same as one that is stuck. Each line names the
5
+ // command, the lane it runs in — the main checkout where none, and another repository's checkout by
6
+ // name, unlinked — how long it has waited, and why.
7
+
8
+ const MS_PER_SECOND = 1000
9
+ // 🚧 rather than ⏳, which a waiting row already draws, or 🚦, the gate stage's; an emoji a terminal
10
+ // draws two columns wide.
11
+ const BUDGET_ICON = '🚧'
12
+ const GAP = ' '
13
+ const MAIN_CHECKOUT = 'main'
14
+
15
+ function place_text(wait: BudgetWait, link: (reference: string) => string): string {
16
+ if (wait.elsewhere !== undefined) return wait.elsewhere
17
+
18
+ return wait.lane === undefined ? MAIN_CHECKOUT : link(wait.lane)
19
+ }
20
+
21
+ function wait_text(wait: BudgetWait, link: (reference: string) => string): string {
22
+ const lane = place_text(wait, link)
23
+ const seconds = `${String(Math.round(wait.waited_ms / MS_PER_SECOND))}s`
24
+ const parts = [wait.command ?? 'a step', lane, seconds, core_wait.REASON_TEXT[wait.reason]]
25
+
26
+ return `${BUDGET_ICON} ${parts.join(GAP)}`
27
+ }
28
+
29
+ // Nothing is drawn while no claim waits.
30
+ function wait_lines(
31
+ waits: ReadonlyArray<BudgetWait> | undefined,
32
+ link: (reference: string) => string,
33
+ ): Array<string> {
34
+ if (waits === undefined || waits.length === 0) return []
35
+
36
+ return ['', ...waits.map((wait) => wait_text(wait, link))]
37
+ }
38
+
39
+ const run_board_render_waits = { BUDGET_ICON, wait_lines }
40
+
41
+ export { run_board_render_waits }
@@ -24,6 +24,7 @@ import { run_board_render_slot, type UsageSlot } from './run-board-render-slot'
24
24
  import { run_board_render_tail } from './run-board-render-tail'
25
25
  import { run_board_render_thanks } from './run-board-render-thanks'
26
26
  import { run_board_render_title } from './run-board-render-title'
27
+ import { run_board_render_waits } from './run-board-render-waits'
27
28
  import { run_board_row_yield } from './run-board-row-yield'
28
29
  import type { Thanks } from './run-board-thanks-csv'
29
30
 
@@ -388,7 +389,11 @@ function bounded(parts: FrameParts, bounds: FrameBounds): Array<string> {
388
389
 
389
390
  function render(view: BoardView): Array<string> {
390
391
  const { header, notes } = view
391
- const above = [...run_board_header.header_lines(header), ...resume_lines(view.resume)]
392
+ const above = [
393
+ ...run_board_header.header_lines(header),
394
+ ...run_board_render_waits.wait_lines(header.budget_waits, header.link),
395
+ ...resume_lines(view.resume),
396
+ ]
392
397
  const parts = {
393
398
  above,
394
399
  plan: plan_sections(header, view.size),
@@ -9,6 +9,7 @@ import type { ItemState } from './run-board-status'
9
9
  const YIELD_STAGES: Readonly<Record<ItemState, YieldStage>> = {
10
10
  merged: 'finished',
11
11
  done: 'finished',
12
+ withdrawn: 'finished',
12
13
  waiting: 'pending',
13
14
  human: 'pending',
14
15
  parked: 'parked',