@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,4 +1,5 @@
1
1
  import type { NamedPlan } from '#scripts/backlog/backlog-plan'
2
+ import type { BudgetWait } from '#scripts/gate/core-wait'
2
3
  import type { MachineSample } from '#scripts/gate/machine-capacity'
3
4
  import type { ClosedAnswer, ClosedIssue } from './run-board-closed'
4
5
  import type { Fetch } from './run-board-fetch'
@@ -21,6 +22,8 @@ interface BoardPorts {
21
22
  // `undefined` when no run has started here.
22
23
  read_local: () => Promise<LocalRead | undefined>
23
24
  read_machine: () => Promise<MachineSample>
25
+ // The claims waiting in the core budget, read on the machine's second; none where it is not given.
26
+ read_waits?: (() => Array<BudgetWait>) | undefined
24
27
  // Every process and its lane, the last reading's lanes reused (`run-board-usage-read.ts`).
25
28
  read_usage: (before: UsageMark | undefined) => Promise<UsageMark | undefined>
26
29
  // The issues that answered as closed, and whether every issue answered (`run-board-closed.ts`).
@@ -85,6 +88,7 @@ interface BoardState {
85
88
  // them.
86
89
  usage: UsageMark | undefined
87
90
  usages: LaneUsages | undefined
91
+ budget_waits: ReadonlyArray<BudgetWait>
88
92
  // The reviewers drawn above the legend, the read of them in flight, and when it was last launched.
89
93
  thanks: Thanks
90
94
  thanks_fetch: Fetch<Thanks> | undefined
@@ -112,6 +116,7 @@ const FRESH_STATE: BoardState = {
112
116
  spots: [],
113
117
  usage: undefined,
114
118
  usages: undefined,
119
+ budget_waits: [],
115
120
  thanks: [],
116
121
  thanks_fetch: undefined,
117
122
  thanks_attempted_ms: undefined,
@@ -17,11 +17,17 @@ import { run_board_setup, type SetupRead } from './run-board-setup'
17
17
  // statuses are laid over is the slow GitHub half, and so is what a child that left the open listing
18
18
  // answers when read closed.
19
19
 
20
- type ItemState = 'running' | 'merged' | 'parked' | 'done' | 'waiting' | 'human' | 'stopped'
20
+ type ItemState =
21
+ 'running' | 'merged' | 'parked' | 'done' | 'waiting' | 'human' | 'stopped' | 'withdrawn'
21
22
 
22
23
  interface ItemStatus {
23
24
  state: ItemState
25
+ // When the child first launched in this run — a re-dispatch or a restart does not move it, so the
26
+ // row's time is the lane's whole time.
24
27
  started_ms?: number | undefined
28
+ // The child's newest launch, present only where it launched again after `started_ms` — what the
29
+ // GitHub read is weighed against.
30
+ launched_ms?: number | undefined
25
31
  ended_ms?: number | undefined
26
32
  // The seat-free lane label (`lane <N>`) of a running child, when it holds one — the running test reads
27
33
  // it; the board does not draw it.
@@ -61,25 +67,40 @@ const ENDING_STATES: Readonly<Partial<Record<string, ItemState>>> = {
61
67
  [KIND.PARK]: 'parked',
62
68
  [KIND.SPLIT]: 'done',
63
69
  [KIND.STOP]: 'done',
70
+ [KIND.WITHDRAW]: 'withdrawn',
64
71
  }
65
72
  // A cut or a resume says the child is still running, on whatever phase it had reached.
66
73
  const CONTINUING_KINDS: ReadonlySet<string> = new Set([KIND.CUT, KIND.RESUME])
67
- // A person's restart starts the child over in a fresh session, so its track starts over too.
74
+ // A person's restart, and a re-dispatch after an outage, start the child over in a fresh session, so
75
+ // its track starts over too; its time does not (`launched`).
68
76
  const LAUNCH_KINDS: ReadonlySet<string> = new Set([KIND.CHILD_LAUNCH, KIND.RESTART])
69
77
  // What the GitHub read may still settle: a running child, and one whose lane has no child left in it —
70
78
  // a child that merged and then died is drawn merged, not stopped.
71
79
  const UNSETTLED_STATES: ReadonlySet<ItemState> = new Set(['running', 'stopped'])
80
+ // A parked child waits on a person, who may close its issue after the park; only a closed read settles
81
+ // it again, so one still open stays parked whatever its labels say (joshuafolkken/kit#3958).
82
+ const PARKED_STATE: ItemState = 'parked'
72
83
 
73
84
  function moment(event: RunEvent): number {
74
85
  return Date.parse(event.at)
75
86
  }
76
87
 
77
- function launched(event: RunEvent): ItemStatus {
88
+ // The newest launch carried on, where the child launched more than once.
89
+ function relaunch_of(status: ItemStatus | undefined): Pick<ItemStatus, 'launched_ms'> {
90
+ const launched_ms = status?.launched_ms
91
+
92
+ return launched_ms === undefined ? {} : { launched_ms }
93
+ }
94
+
95
+ // joshuafolkken/kit#3971: a launch keeps the start of the child's first one in this run.
96
+ function launched(previous: ItemStatus | undefined, event: RunEvent): ItemStatus {
78
97
  const at_ms = moment(event)
98
+ const started_ms = previous?.started_ms ?? at_ms
79
99
 
80
100
  return {
81
101
  state: 'running',
82
- started_ms: at_ms,
102
+ started_ms,
103
+ ...(started_ms !== at_ms && { launched_ms: at_ms }),
83
104
  track: [run_board_phase.LAUNCHED_PHASE],
84
105
  phase_ms: at_ms,
85
106
  }
@@ -98,7 +119,12 @@ function moved(previous: ItemStatus, track: ReadonlyArray<Phase>, event: RunEven
98
119
  function settled_as(previous: ItemStatus | undefined, state: ItemState): ItemStatus {
99
120
  const track = previous?.track
100
121
 
101
- return { state, started_ms: previous?.started_ms, ...(track !== undefined && { track }) }
122
+ return {
123
+ state,
124
+ started_ms: previous?.started_ms,
125
+ ...relaunch_of(previous),
126
+ ...(track !== undefined && { track }),
127
+ }
102
128
  }
103
129
 
104
130
  function ended(previous: ItemStatus | undefined, state: ItemState, event: RunEvent): ItemStatus {
@@ -108,7 +134,7 @@ function ended(previous: ItemStatus | undefined, state: ItemState, event: RunEve
108
134
  function continued(previous: ItemStatus | undefined): ItemStatus {
109
135
  const { started_ms, track, phase_ms } = previous ?? {}
110
136
 
111
- return { state: 'running', started_ms, track, phase_ms }
137
+ return { state: 'running', started_ms, ...relaunch_of(previous), track, phase_ms }
112
138
  }
113
139
 
114
140
  // A stage line carrying its attempt redraws the track of any child launched on this stream — a settled
@@ -143,7 +169,7 @@ function advanced(previous: ItemStatus | undefined, event: RunEvent): ItemStatus
143
169
 
144
170
  // One event folded into the status of the child it names; an event the board does not track keeps it.
145
171
  function next_status(previous: ItemStatus | undefined, event: RunEvent): ItemStatus | undefined {
146
- if (LAUNCH_KINDS.has(event.kind)) return launched(event)
172
+ if (LAUNCH_KINDS.has(event.kind)) return launched(previous, event)
147
173
 
148
174
  const ending = ENDING_STATES[event.kind]
149
175
 
@@ -165,8 +191,9 @@ function fold_events(events: ReadonlyArray<RunEvent>): Map<number, ItemStatus> {
165
191
  return statuses
166
192
  }
167
193
 
168
- // A lane is this run's only when its issue is on the run's stream or in the run's plan, so a lane left
169
- // over from an earlier day is not drawn as this run's child.
194
+ // A lane is this run's only when its issue is on the run's stream or in `in_run`, so a lane left over
195
+ // from an earlier day is not drawn as this run's child. `in_run` leaves out a planned issue whose lane
196
+ // an earlier run dispatched (`run-board-github.ts`).
170
197
  function is_running_lane(
171
198
  issue: number,
172
199
  status: ItemStatus | undefined,
@@ -227,9 +254,14 @@ function is_listed_closed(issue: number, read: OpenRead): boolean {
227
254
  return read.open_numbers !== undefined && !read.open_numbers.has(issue)
228
255
  }
229
256
 
230
- // The listing was read after the child launched, so what it says about the child is this run's.
257
+ // When the child last launched — what a GitHub read is weighed against.
258
+ function launch_ms(status: ItemStatus): number | undefined {
259
+ return status.launched_ms ?? status.started_ms
260
+ }
261
+
262
+ // The listing was read after the child last launched, so what it says about the child is current.
231
263
  function is_read_after_launch(status: ItemStatus, read: OpenRead): boolean {
232
- return read.read_ms !== undefined && (status.started_ms ?? 0) <= read.read_ms
264
+ return read.read_ms !== undefined && (launch_ms(status) ?? 0) <= read.read_ms
233
265
  }
234
266
 
235
267
  // A child launched before the listing was read and missing from it has closed since.
@@ -257,26 +289,30 @@ function labelled(issue: number, status: ItemStatus, read: OpenRead): ItemStatus
257
289
 
258
290
  // A close dated before the launch is an earlier life of the issue, not how this run's child ended.
259
291
  function is_closed_before_launch(status: ItemStatus, closed: ClosedIssue): boolean {
260
- if (closed.closed_ms === undefined || status.started_ms === undefined) return false
292
+ const launch = launch_ms(status)
293
+
294
+ if (launch === undefined || closed.closed_ms === undefined) return false
295
+
296
+ return closed.closed_ms < launch
297
+ }
298
+
299
+ function closed_state(closed: ClosedIssue): ItemState {
300
+ if (closed.is_merged) return 'merged'
261
301
 
262
- return closed.closed_ms < status.started_ms
302
+ return closed.is_not_planned ? 'withdrawn' : 'done'
263
303
  }
264
304
 
265
- // A running child read closed ends when GitHub says it closed, merged or not.
305
+ // A child read closed ends when GitHub says it closed — merged, withdrawn as not planned, or done.
266
306
  function closed_status(
267
307
  status: ItemStatus,
268
308
  closed: ClosedIssue | undefined,
269
309
  ): ItemStatus | undefined {
270
310
  if (closed === undefined || is_closed_before_launch(status, closed)) return undefined
271
311
 
272
- const state = closed.is_merged ? 'merged' : 'done'
273
-
274
- return { ...settled_as(status, state), ended_ms: closed.closed_ms }
312
+ return { ...settled_as(status, closed_state(closed)), ended_ms: closed.closed_ms }
275
313
  }
276
314
 
277
- function settled(issue: number, status: ItemStatus, read: OpenRead): ItemStatus {
278
- if (!UNSETTLED_STATES.has(status.state)) return status
279
-
315
+ function unsettled(issue: number, status: ItemStatus, read: OpenRead): ItemStatus {
280
316
  const from_closed = closed_status(status, read.closed.get(issue))
281
317
 
282
318
  if (from_closed !== undefined) return from_closed
@@ -286,11 +322,26 @@ function settled(issue: number, status: ItemStatus, read: OpenRead): ItemStatus
286
322
  return labelled(issue, status, read)
287
323
  }
288
324
 
325
+ // The closed read is kept for the run, so a parked child a person reopened since is told apart by the
326
+ // listing holding it again.
327
+ function parked_settled(issue: number, status: ItemStatus, read: OpenRead): ItemStatus {
328
+ if (read.open_numbers?.has(issue) === true) return status
329
+
330
+ return closed_status(status, read.closed.get(issue)) ?? status
331
+ }
332
+
333
+ function settled(issue: number, status: ItemStatus, read: OpenRead): ItemStatus {
334
+ if (status.state === PARKED_STATE) return parked_settled(issue, status, read)
335
+
336
+ return UNSETTLED_STATES.has(status.state) ? unsettled(issue, status, read) : status
337
+ }
338
+
289
339
  // **The stream stays the source of truth; the listing only unsticks it.** A child whose merge never
290
340
  // reached the stream would read as running forever, so a running child the plan's open listing no
291
341
  // longer holds is drawn finished instead — merged and timed once GitHub was read for it, finished
292
342
  // without a time while that read has not answered. One the listing still holds is drawn as its labels
293
- // say, so a child that stopped with no parent to record it does not read as running.
343
+ // say, so a child that stopped with no parent to record it does not read as running. A parked child is
344
+ // settled again only by a closed read.
294
345
  function settle_closed(
295
346
  statuses: ReadonlyMap<number, ItemStatus>,
296
347
  read: OpenRead,
@@ -82,8 +82,10 @@ async function resample(state: BoardState, ports: BoardPorts, now_ms: number): P
82
82
  const at_ms = ports.now()
83
83
  const machine = { sample: await ports.read_machine(), at_ms }
84
84
  const gauges = run_board_machine.gauges_of(state.machine, machine)
85
+ const budget_waits = ports.read_waits?.() ?? []
86
+ const usages = await read_usages(state, ports)
85
87
 
86
- return { ...state, ...(await read_usages(state, ports)), machine, gauges, sampled_ms: now_ms }
88
+ return { ...state, ...usages, machine, gauges, budget_waits, sampled_ms: now_ms }
87
89
  }
88
90
 
89
91
  // The state of the run on screen; a different run starts its plan state from nothing, so no row, count
@@ -136,6 +138,7 @@ function planned_layout(state: BoardState, plan: BoardPlan, local: LocalRead): B
136
138
  statuses,
137
139
  events: local.events,
138
140
  free_lanes: local.free_lanes,
141
+ occupied_lanes: local.occupied_lanes,
139
142
  is_stopped: activity_here(local).is_stopped,
140
143
  })
141
144
 
@@ -192,6 +195,7 @@ function header_of(
192
195
  is_plan_loading: state.plan_fetch !== undefined,
193
196
  machine: state.gauges,
194
197
  usages: state.usages,
198
+ budget_waits: state.budget_waits,
195
199
  form: redraw.ports.form,
196
200
  repo: state.plan?.context.repo,
197
201
  spinner: redraw.ports.is_tty ? SPOT : undefined,
@@ -61,6 +61,7 @@ const {
61
61
  report_held_cut,
62
62
  report_incomplete,
63
63
  report_over,
64
+ report_relaunched_cut,
64
65
  report_reviewer,
65
66
  report_stale,
66
67
  report_under_threshold,
@@ -124,7 +125,7 @@ function relaunch(target: string, lane: LaneInfo, phase: string): number {
124
125
 
125
126
  if (notes.length > 0) console.error(notes.join('\n'))
126
127
 
127
- return report(CUT_VERDICT, SUCCESS_EXIT_CODE)
128
+ return report_relaunched_cut()
128
129
  }
129
130
 
130
131
  const { is_lane_branch } = run_cut_ready
@@ -199,7 +200,7 @@ async function finish_cut(target: string, lane: LaneInfo, request: CutRequest):
199
200
  if (refused !== undefined) return refused
200
201
 
201
202
  return lane_relaunch.is_openai_lane(lane)
202
- ? report(CUT_VERDICT, SUCCESS_EXIT_CODE)
203
+ ? report_relaunched_cut()
203
204
  : relaunch(target, lane, request.phase)
204
205
  }
205
206
 
@@ -148,6 +148,20 @@ function report_held_cut(issue: string): number {
148
148
  return report(CUT_VERDICT, SUCCESS_EXIT_CODE)
149
149
  }
150
150
 
151
+ // **A cut that relaunched its successor says so on this session's own transcript** (joshuafolkken/kit#3936).
152
+ // The successor's `run:cut --resume` can clear the record before this session's turn ends, so the
153
+ // `Stop` hook cannot tell a cut stop by the record alone; it reads this line off the turn instead, a
154
+ // fact no other process can remove. The held cut never prints it — a person resumes that one. **A bare
155
+ // token line**, so it is read off the `run:cut` call's own result (`cost-run-cut.ts`), and a turn that
156
+ // merely quotes it is not read as a relaunch.
157
+ const RELAUNCHED_CUT_MARKER = 'cut-relaunched'
158
+
159
+ function report_relaunched_cut(): number {
160
+ console.error(RELAUNCHED_CUT_MARKER)
161
+
162
+ return report(CUT_VERDICT, SUCCESS_EXIT_CODE)
163
+ }
164
+
151
165
  // **An implementation cut is never resumed by a session over the threshold**.
152
166
  // The cut outside a lane leaves the session that took it open, so a person retyping `fullrun #N` there
153
167
  // would be answered `resume-impl`, keep implementing over the threshold, and be cut again on the next
@@ -174,6 +188,7 @@ const run_cut_report = {
174
188
  INCOMPLETE_VERDICT,
175
189
  NOT_A_LANE_VERDICT,
176
190
  OVER_VERDICT,
191
+ RELAUNCHED_CUT_MARKER,
177
192
  RESUME_IMPL_VERDICT,
178
193
  RESUME_VERDICT,
179
194
  STALE_VERDICT,
@@ -191,6 +206,7 @@ const run_cut_report = {
191
206
  report_held_cut,
192
207
  report_incomplete,
193
208
  report_over,
209
+ report_relaunched_cut,
194
210
  report_reviewer,
195
211
  report_stale,
196
212
  report_under_threshold,
@@ -4,6 +4,7 @@ import { agent_launch_environment } from '#scripts/agent/agent-launch-environmen
4
4
  import { agent_role_profile, type AgentProfile } from '#scripts/agent/agent-role-profile'
5
5
  import { agent_session_environment } from '#scripts/josh/agent-session-environment'
6
6
  import { stamp_file } from '#scripts/josh/stamp-file'
7
+ import { launch_priority } from '#scripts/run/launch-priority'
7
8
 
8
9
  // Starting a process that outlives this one, and keeping what it writes.
9
10
  //
@@ -34,13 +35,18 @@ const UNSAFE_NOTE =
34
35
  //
35
36
  // A NUL byte is what `execve` treats as the end of a string, so a value carrying one executes as a
36
37
  // prefix of itself; the other control characters have no business in a command line and their presence
37
- // means the value did not come from where it was supposed to.
38
+ // means the value did not come from where it was supposed to. **An agent's prompt is the one element a
39
+ // line break belongs in** — a ship-stop prompt carries the failed output's lines — so a vector marked
40
+ // `ends_with_prompt` lets it into its last element alone (joshuafolkken/kit#3964).
38
41
  //
39
42
  // The shared value validation lives with the role policy so environment overrides and argv elements
40
43
  // cannot disagree about which control characters or lengths are safe.
41
44
  interface LaunchArgv {
42
45
  command: string
43
46
  args: ReadonlyArray<string>
47
+ // Set by an agent argv builder, whose prompt is the last element: that one element may carry line
48
+ // breaks (`is_safe_prompt`), and every other element is checked as before.
49
+ ends_with_prompt?: boolean
44
50
  }
45
51
 
46
52
  type LaunchResult = { kind: 'launched'; pid: number } | { kind: 'failed'; note: string }
@@ -65,12 +71,23 @@ interface LaunchRequest {
65
71
  profile?: AgentProfile | undefined
66
72
  }
67
73
 
68
- const { is_safe_value } = agent_role_profile
74
+ const { is_safe_prompt, is_safe_value } = agent_role_profile
75
+ const LAST = -1
76
+
77
+ // An agent vector's prompt, the last element, may break lines; every other element is a value.
78
+ function is_safe_element(argv: LaunchArgv, value: string, index: number): boolean {
79
+ const is_prompt = argv.ends_with_prompt === true && index === argv.args.length + LAST
80
+
81
+ return is_prompt ? is_safe_prompt(value) : is_safe_value(value)
82
+ }
69
83
 
70
84
  // The single gate every argument vector passes before it reaches the operating system, whoever
71
85
  // composed it.
72
86
  function is_safe_argv(argv: LaunchArgv): boolean {
73
- return is_safe_value(argv.command) && argv.args.every((value) => is_safe_value(value))
87
+ return (
88
+ is_safe_value(argv.command) &&
89
+ argv.args.every((value, index) => is_safe_element(argv, value, index))
90
+ )
74
91
  }
75
92
 
76
93
  function note_of(error: unknown): string {
@@ -251,6 +268,7 @@ async function attached_spawn(
251
268
  child.once('exit', (exit_code) => {
252
269
  resolve({ kind: 'completed', pid: child.pid ?? 0, exit_code })
253
270
  })
271
+ launch_priority.lower(child.pid)
254
272
  notify_spawn(child.pid, on_spawn, on_error)
255
273
  })
256
274
  }
@@ -287,6 +305,7 @@ function spawned(
287
305
  on_error(note_of(error))
288
306
  })
289
307
  child.unref()
308
+ launch_priority.lower(child.pid)
290
309
 
291
310
  return child.pid === undefined ? { kind: 'failed', note: NO_PID_NOTE } : launched(child.pid)
292
311
  }
@@ -341,5 +360,5 @@ const detached_launch = {
341
360
  note_of,
342
361
  }
343
362
 
344
- export type { LaunchArgv, LaunchRequest, LaunchResult }
363
+ export type { AttachedLaunchResult, LaunchArgv, LaunchRequest, LaunchResult }
345
364
  export { detached_launch }
@@ -22,6 +22,7 @@ import {
22
22
  } from '#scripts/run/run-stage'
23
23
  import { run_stage_read, type StageRead } from '#scripts/run/run-stage-read'
24
24
  import { run_step } from '#scripts/run/run-step'
25
+ import { run_step_section } from '#scripts/run/run-step-section'
25
26
  import { run_entry, type EntryParts } from './run-entry'
26
27
  import { run_entry_stop } from './run-entry-stop'
27
28
 
@@ -259,7 +260,13 @@ async function proceed(issue_number: string, hold: string, cost: string): Promis
259
260
  // update is measured against (`issue-watch.ts`).
260
261
  await issue_watch.record_baseline_here(issue_number)
261
262
 
262
- return emit({ issue_number, hold, cost, verdict: reads.verdict, report: reads.report })
263
+ const code = emit({ issue_number, hold, cost, verdict: reads.verdict, report: reads.report })
264
+
265
+ // The entry's own read — the split assessment every run answers before its work — printed here so
266
+ // the reader does not spend a turn fetching it.
267
+ if (code !== SUCCESS_EXIT_CODE) return code
268
+
269
+ return run_step_section.print_section(run_step_section.ENTRY)
263
270
  }
264
271
 
265
272
  async function claim_run(
@@ -29,6 +29,7 @@ const KIND_LABELS: Readonly<Record<EventKind, KindLabel>> = {
29
29
  cut: { ja: 'セッション交代', en: 'session cut' },
30
30
  resume: { ja: '実装を再開', en: 'implementation resumed' },
31
31
  restart: { ja: '止めて出し直し', en: 'restarted' },
32
+ withdraw: { ja: '取り下げ', en: 'withdrawn' },
32
33
  drain: { ja: 'バックログが空', en: 'backlog drained' },
33
34
  stop: { ja: '停止', en: 'stopped' },
34
35
  'pr-opened': { ja: 'PR 作成', en: 'PR opened' },
@@ -94,6 +94,7 @@ const KIND = run_event_stream.EVENT_KIND
94
94
  const BATCH_RECORD_KINDS: ReadonlySet<string> = new Set([
95
95
  KIND.CHILD_LAUNCH,
96
96
  KIND.RESTART,
97
+ KIND.WITHDRAW,
97
98
  KIND.PARK,
98
99
  KIND.SPLIT,
99
100
  KIND.RETROSPECTIVE,
@@ -167,12 +168,14 @@ function last_issue_event(
167
168
  }
168
169
 
169
170
  // A child's launch and the endings `run:merge` settles it with — the ones `run:board` stops drawing it
170
- // running on. A park or a split already ran `run:merge`, so a child closed after one owes nothing.
171
+ // running on. A park or a split already ran `run:merge`, so a child closed after one owes nothing; a
172
+ // withdrawal settles the child itself, its issue closed by the person who withdrew it.
171
173
  const LAUNCH_OR_SETTLE_KINDS: ReadonlySet<string> = new Set([
172
174
  KIND.CHILD_LAUNCH,
173
175
  KIND.MERGE,
174
176
  KIND.PARK,
175
177
  KIND.SPLIT,
178
+ KIND.WITHDRAW,
176
179
  ])
177
180
 
178
181
  // Whether this invocation's newest launch of the issue has not yet been settled.
@@ -186,9 +189,24 @@ function is_merge_owed(events: ReadonlyArray<RunEvent>, scope: EventScope, issue
186
189
  return newest?.kind === KIND.CHILD_LAUNCH
187
190
  }
188
191
 
192
+ function is_launch_or_withdrawal(event: RunEvent, issue: string): boolean {
193
+ const is_kind = event.kind === KIND.CHILD_LAUNCH || event.kind === KIND.WITHDRAW
194
+
195
+ return is_kind && names_issue(event, issue)
196
+ }
197
+
198
+ // Whether a person withdrew the issue's newest launch (`lane:withdraw`): its newest launch or withdrawal
199
+ // is a withdrawal. A launch after it is a fresh attempt, which the old withdrawal does not settle.
200
+ function is_withdrawn(events: ReadonlyArray<RunEvent>, issue: string): boolean {
201
+ const newest = events.findLast((event) => is_launch_or_withdrawal(event, issue))
202
+
203
+ return newest?.kind === KIND.WITHDRAW
204
+ }
205
+
189
206
  const run_event_scope = {
190
207
  UNKNOWN_EVENT_SCOPE,
191
208
  is_merge_owed,
209
+ is_withdrawn,
192
210
  issue_named,
193
211
  last_issue_event,
194
212
  last_scoped_event,
@@ -113,10 +113,9 @@ async function emit_heartbeat(line: string): Promise<void> {
113
113
  await emit(run_event_stream.EVENT_KIND.HEARTBEAT, line)
114
114
  }
115
115
 
116
- // The events of the invocation now running, for a reader restoring the run from its stream
117
- // (`backlog:drive`). Fail-quiet like the writers: no repository or an unreadable stream is an empty
118
- // invocation.
119
- async function current_events(): Promise<ReadonlyArray<RunEvent>> {
116
+ // The events of the invocation now running, `undefined` where the invocation's scope could not be told
117
+ // — for a reader that must not take "no events" for "nothing launched" (`backlog-occupied.ts`).
118
+ async function scoped_current_events(): Promise<ReadonlyArray<RunEvent> | undefined> {
120
119
  try {
121
120
  const repository = await run_carry.repository_directory()
122
121
 
@@ -126,12 +125,19 @@ async function current_events(): Promise<ReadonlyArray<RunEvent>> {
126
125
  const scope = run_event_scope.scope_of(read)
127
126
  const events = run_event_stream.read_events(run_event_stream.target_of(repository))
128
127
 
129
- return run_event_scope.scoped_events(events, scope) ?? []
128
+ return run_event_scope.scoped_events(events, scope)
130
129
  } catch {
131
- return []
130
+ return undefined
132
131
  }
133
132
  }
134
133
 
134
+ // The events of the invocation now running, for a reader restoring the run from its stream
135
+ // (`backlog:drive`). Fail-quiet like the writers: no repository, an unreadable stream or an
136
+ // undetermined scope is an empty invocation.
137
+ async function current_events(): Promise<ReadonlyArray<RunEvent>> {
138
+ return (await scoped_current_events()) ?? []
139
+ }
140
+
135
141
  // The invocation's events for a writer deciding whether a marker is already there: unlike
136
142
  // `current_events`, an undetermined scope keeps the whole stream, so a run with no carry record skips a
137
143
  // marker it already wrote rather than writing it twice.
@@ -167,6 +173,7 @@ const run_event_stream_emit = {
167
173
  emit_once,
168
174
  emit_once_since,
169
175
  invocation_or_all_events,
176
+ scoped_current_events,
170
177
  stream_target,
171
178
  }
172
179
 
@@ -64,6 +64,10 @@ const EVENT_KIND = {
64
64
  // (`lane:restart`). It follows the fresh dispatch's `CHILD_LAUNCH`, so it is a launch to the parent and
65
65
  // the board, and — like that launch — a batch record a lane child never reads as its own position.
66
66
  RESTART: 'restart',
67
+ // A person withdrew a lane child — stopped it and closed its issue as not planned (`lane:withdraw`).
68
+ // It is written before the stop, so `run:merge` settles the child as withdrawn whatever GitHub says
69
+ // by the time the parent collects it, and counts it as neither a merge, a failure nor an outage.
70
+ WITHDRAW: 'withdraw',
67
71
  // The backlog emptied while nothing of the run's own was in flight — the drain. It marks the position
68
72
  // at which the end-of-run retrospective is owed, *before* the idle watch opens, so `run:step` fires
69
73
  // the retrospective at the drain rather than after the watch runs its course. It is not itself a
@@ -0,0 +1,46 @@
1
+ import { stamp_file } from '#scripts/josh/stamp-file'
2
+ import { run_halfrun_resume } from '#scripts/run/run-halfrun-resume'
3
+ import { run_hold, type RunHold, type SessionOwner } from './run-hold'
4
+
5
+ // What the `Stop` hook asks of a hold beyond "is one in place" (joshuafolkken/kit#3961): whether it is
6
+ // this session's own run's, and whether its pause was already announced.
7
+ //
8
+ // **Another run's hold is not this session's pause.** A `kickoff`, or a question answered while a
9
+ // `backlogrun` parent holds the same tree, stops in a session that holds nothing — announcing that stop
10
+ // as the held run's "confirmation required" reports a decision nobody is waiting on.
11
+ //
12
+ // **Unknown is this session's own.** A record written outside an agent session, or a hook that cannot
13
+ // read its own session, keeps the behavior the hold had before ownership was recorded: a missed notify
14
+ // costs a person a silent pause, a wrong one only a needless Telegram.
15
+
16
+ function is_same_start(recorded: string | undefined, own: string | undefined): boolean {
17
+ return recorded === undefined || own === undefined || recorded === own
18
+ }
19
+
20
+ function is_session_hold(hold: RunHold, owner: SessionOwner = run_hold.session_owner()): boolean {
21
+ if (hold.owner_pid === undefined || owner.owner_pid === undefined) return true
22
+
23
+ return hold.owner_pid === owner.owner_pid && is_same_start(hold.owner_start, owner.owner_start)
24
+ }
25
+
26
+ // **A pause is announced once, and a person's prompt ends it.** The hook's own send is recorded on the
27
+ // hold, so a later stop of the same pause — a background notice that woke the session — finds it; a send before `pause_start` belongs to an earlier pause. An unknown start
28
+ // announces again: a needless Telegram costs less than a silent wait.
29
+ function is_notified_since(hold: RunHold, pause_start: string | undefined): boolean {
30
+ if (pause_start === undefined || hold.notified_at === undefined) return false
31
+
32
+ return Date.parse(hold.notified_at) >= Date.parse(pause_start)
33
+ }
34
+
35
+ // Stamps the send on the record in place; a tree no longer held has nothing to stamp. A stale record is
36
+ // stamped too: the hook reads it as the hold (`hold_of`), so a run past the expiry age still finds its send.
37
+ function mark_notified(target: string, now: Date = new Date()): void {
38
+ const hold = run_halfrun_resume.hold_of(run_hold.read_hold(target, now))
39
+ if (hold === undefined) return
40
+
41
+ stamp_file.write_stamp(target, { ...hold, notified_at: now.toISOString() })
42
+ }
43
+
44
+ const run_hold_session = { is_notified_since, is_session_hold, mark_notified }
45
+
46
+ export { run_hold_session }
@@ -65,6 +65,10 @@ interface RunHold {
65
65
  // fields, or by a claim made outside an agent session.
66
66
  owner_pid?: number | undefined
67
67
  owner_start?: string | undefined
68
+ // When the `Stop` hook last sent this run's `confirmation` Telegram — its own send leaves no command
69
+ // on the transcript, so the record is what keeps one pause from being announced twice
70
+ // (`run-hold-session.ts`).
71
+ notified_at?: string | undefined
68
72
  }
69
73
 
70
74
  type HoldRead =
@@ -107,6 +111,7 @@ const run_hold_schema = z.object({
107
111
  prrun_stop_head: z.string().optional(),
108
112
  owner_pid: z.number().optional(),
109
113
  owner_start: z.string().optional(),
114
+ notified_at: z.string().optional(),
110
115
  })
111
116
 
112
117
  // `undefined` for anything that is not a well-formed record, so the caller decides what a broken one
@@ -342,6 +347,7 @@ const run_hold = {
342
347
  race_message,
343
348
  read_hold,
344
349
  release_hold,
350
+ session_owner,
345
351
  stale_message,
346
352
  uncommitted_message,
347
353
  unknown_message,
@@ -350,5 +356,5 @@ const run_hold = {
350
356
  write_hold,
351
357
  }
352
358
 
353
- export type { HoldRead, RunHold, StopMarkFields }
359
+ export type { HoldRead, RunHold, SessionOwner, StopMarkFields }
354
360
  export { run_hold }
@@ -0,0 +1,32 @@
1
+ import { setPriority } from 'node:os'
2
+
3
+ // **A launched session runs below the interactive one, so a saturated machine still answers the
4
+ // user.** Every headless session and everything it starts — gates, tests, builds — used to share the
5
+ // interactive session's priority, and at a load of ~30 the lanes took the terminal's responsiveness
6
+ // with them (#3947). The niceness is set on the child right after `spawn` returns: a process inherits
7
+ // its parent's niceness at fork, and an agent CLI boots for far longer than this takes before it forks
8
+ // anything, so every descendant inherits it. Efficiency-core confinement (`taskpolicy -b`) is not
9
+ // used — it throttles a lane far beyond yielding to the user.
10
+ const LANE_NICENESS = 10
11
+
12
+ type SetPriority = (pid: number, priority: number) => void
13
+
14
+ // **A priority that cannot be lowered starts the session as before.** A platform without the call, or
15
+ // a child that already runs at a lower priority than this, is not a reason to fail the launch.
16
+ function lower(pid: number | undefined, set_priority: SetPriority = setPriority): void {
17
+ if (pid === undefined) return
18
+
19
+ try {
20
+ set_priority(pid, LANE_NICENESS)
21
+ } catch {
22
+ // the session keeps the priority it was started with
23
+ }
24
+ }
25
+
26
+ const launch_priority = {
27
+ LANE_NICENESS,
28
+ lower,
29
+ }
30
+
31
+ export type { SetPriority }
32
+ export { launch_priority }