@joshuafolkken/kit 1.1453.0 → 1.1471.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 (224) hide show
  1. package/.claude/skills/dependency-update/SKILL.md +26 -55
  2. package/.claude/skills/epic-commands/SKILL.md +87 -148
  3. package/.claude/skills/epic-commands/epic-bundle.md +45 -77
  4. package/.claude/skills/epic-commands/execution-waves.md +44 -81
  5. package/.claude/skills/verify-ui/SKILL.md +25 -50
  6. package/.claude/skills/workflow-commands/background-commands.md +36 -47
  7. package/.claude/skills/workflow-commands/backlogrun-child.md +72 -85
  8. package/.claude/skills/workflow-commands/backlogrun-handoff-report.md +1 -1
  9. package/.claude/skills/workflow-commands/backlogrun-lanes.md +91 -121
  10. package/.claude/skills/workflow-commands/backlogrun-park.md +73 -117
  11. package/.claude/skills/workflow-commands/backlogrun-progress.md +7 -9
  12. package/.claude/skills/workflow-commands/backlogrun-recovery.md +11 -20
  13. package/.claude/skills/workflow-commands/backlogrun-steps.md +43 -52
  14. package/.claude/skills/workflow-commands/chain-rule.md +24 -34
  15. package/.claude/skills/workflow-commands/followup.md +57 -76
  16. package/.claude/skills/workflow-commands/halfrun.md +2 -1
  17. package/.claude/skills/workflow-commands/issue-comments.md +43 -60
  18. package/.claude/skills/workflow-commands/kickoff.md +65 -74
  19. package/.claude/skills/workflow-commands/observation-filing.md +62 -107
  20. package/.claude/skills/workflow-commands/observation-ledger.md +56 -111
  21. package/.claude/skills/workflow-commands/pre-gate-cut.md +89 -138
  22. package/.claude/skills/workflow-commands/prerequisite.md +10 -15
  23. package/.claude/skills/workflow-commands/progress-watcher.md +21 -26
  24. package/.claude/skills/workflow-commands/prrun.md +2 -1
  25. package/.claude/skills/workflow-commands/retrospective.md +31 -50
  26. package/.claude/skills/workflow-commands/split-assessment.md +34 -58
  27. package/.claude/skills/workflow-commands/working-tree-hold.md +54 -71
  28. package/CLAUDE.md +7 -6
  29. package/README.md +2 -9
  30. package/dist/CLAUDE.md +7 -6
  31. package/dist/hooks/batch-guard.js +6 -6
  32. package/dist/hooks/{chunk-Y3GKIKGW.js → chunk-43ZHT22P.js} +31 -15
  33. package/dist/hooks/{chunk-MBJYQPEK.js → chunk-AS74OU3D.js} +24 -8
  34. package/dist/hooks/{chunk-3C4XRYG6.js → chunk-E7OIKX5Z.js} +17 -12
  35. package/dist/hooks/{chunk-Z4XI7CZS.js → chunk-EUMACUK2.js} +18 -12
  36. package/dist/hooks/{chunk-NO4AALP5.js → chunk-H5HVWYXK.js} +4 -4
  37. package/dist/hooks/{chunk-5C7L6FRC.js → chunk-IIGFYXKC.js} +346 -241
  38. package/dist/hooks/{chunk-W5ZKGTSY.js → chunk-J47HU5H5.js} +14 -3
  39. package/dist/hooks/{chunk-OLMO72ON.js → chunk-K4EOONYM.js} +1 -1
  40. package/dist/hooks/{chunk-LBFYEOXJ.js → chunk-K4LMBQC2.js} +4 -4
  41. package/dist/hooks/{chunk-D3NJXN5M.js → chunk-KC5AK2GF.js} +8 -6
  42. package/dist/hooks/chunk-SHGPANFB.js +56 -0
  43. package/dist/hooks/{chunk-EF5GQ6WS.js → chunk-YHBZAJSV.js} +3 -3
  44. package/dist/hooks/{chunk-SHYUQOIS.js → chunk-YYPI7SJZ.js} +5 -5
  45. package/dist/hooks/codex-hook-adapter.js +13 -13
  46. package/dist/hooks/duplicate-read-guard.js +7 -7
  47. package/dist/hooks/format-edited.js +3 -3
  48. package/dist/hooks/inputs.json +251 -247
  49. package/dist/hooks/investigation-guard.js +7 -7
  50. package/dist/hooks/pretool-guard.js +12 -12
  51. package/dist/hooks/session-lang.js +6 -5
  52. package/dist/hooks/stop-guard.js +75 -18
  53. package/dist/josh.js +6 -7
  54. package/dist/managed-marker/index.js +1 -2
  55. package/dist/version/index.js +15 -12
  56. package/eslint/base.js +2 -1
  57. package/eslint/rules/unicorn.js +16 -0
  58. package/package.json +3 -3
  59. package/prompts/coding-standards.md +12 -22
  60. package/prompts/collaboration-workflow/file-edits.md +26 -44
  61. package/prompts/collaboration-workflow/gh-rest.md +20 -36
  62. package/prompts/collaboration-workflow/glossary.md +5 -7
  63. package/prompts/collaboration-workflow/issue-citation.md +7 -13
  64. package/prompts/collaboration-workflow/issue-template.md +29 -29
  65. package/prompts/collaboration-workflow/operating-rules.md +35 -44
  66. package/prompts/collaboration-workflow/output-bounds.md +15 -15
  67. package/prompts/collaboration-workflow/overview.md +14 -19
  68. package/prompts/collaboration-workflow/plan-comment.md +13 -13
  69. package/prompts/collaboration-workflow/principles.md +50 -53
  70. package/prompts/collaboration-workflow/proposal-request.md +1 -9
  71. package/prompts/collaboration-workflow/report-format.md +32 -49
  72. package/prompts/collaboration-workflow/residency.md +10 -10
  73. package/prompts/collaboration-workflow/rule-delivery.md +9 -9
  74. package/prompts/collaboration-workflow/shell-body.md +11 -21
  75. package/prompts/collaboration-workflow/turn-batching.md +8 -14
  76. package/prompts/collaboration-workflow/upstream-interrupt.md +26 -34
  77. package/prompts/collaboration-workflow/wip-cap.md +24 -30
  78. package/prompts/collaboration-workflow.md +24 -25
  79. package/prompts/refactoring.md +33 -43
  80. package/prompts/review-rubric.md +84 -129
  81. package/prompts/review.md +111 -141
  82. package/prompts/sonar-hotspot-handling.md +22 -50
  83. package/prompts/testing-guide.md +48 -99
  84. package/scripts/adopt/adopt-toolkits.ts +2 -4
  85. package/scripts/agent/agent-exit-record.ts +23 -1
  86. package/scripts/agent/codex-agent-argv.ts +7 -2
  87. package/scripts/backlog/backlog-budget-cli.ts +1 -2
  88. package/scripts/backlog/backlog-drive-carried.ts +26 -0
  89. package/scripts/backlog/backlog-drive-cli.ts +10 -5
  90. package/scripts/backlog/backlog-drive-named.ts +2 -4
  91. package/scripts/backlog/backlog-drive-restore.ts +19 -1
  92. package/scripts/backlog/backlog-drive-seats.ts +27 -0
  93. package/scripts/backlog/backlog-drive.ts +11 -6
  94. package/scripts/backlog/backlog-ready.ts +13 -6
  95. package/scripts/behavior/behavior-rules.ts +3 -2
  96. package/scripts/cost-runtime/codex-usage.ts +4 -2
  97. package/scripts/cost-runtime/cost-cli.ts +1 -2
  98. package/scripts/delegation/investigation-guard.ts +1 -1
  99. package/scripts/delegation/investigation-reads.ts +1 -1
  100. package/scripts/discovery/repo-discovery.ts +4 -2
  101. package/scripts/discovery/repo-origin.ts +2 -4
  102. package/scripts/doctor/doctor-logic.ts +6 -3
  103. package/scripts/document/document-byte-budget.ts +27 -27
  104. package/scripts/document/document-byte-check.ts +1 -2
  105. package/scripts/document/entry-read-budget.ts +2 -2
  106. package/scripts/epic/epic-audit-rationale.ts +8 -2
  107. package/scripts/epic/epic-bundle-cli.ts +1 -2
  108. package/scripts/epic/epic-chains.ts +6 -2
  109. package/scripts/epic/epic-close-comment.ts +1 -2
  110. package/scripts/gate/verification-gate.ts +7 -2
  111. package/scripts/gh/git-pr-ai-review.ts +12 -1
  112. package/scripts/gh/git-pr-confirmation.ts +11 -7
  113. package/scripts/gh/git-pr.ts +6 -2
  114. package/scripts/git/git-common-directory.ts +3 -3
  115. package/scripts/git/git-diff-reads.ts +16 -9
  116. package/scripts/git/git-execa-error.ts +1 -2
  117. package/scripts/hooks/format-edited-file.ts +10 -4
  118. package/scripts/hooks/pre-push-unit.ts +15 -4
  119. package/scripts/hooks/pretool-guard.ts +23 -29
  120. package/scripts/hooks/step-zero-notice.ts +3 -2
  121. package/scripts/init/hook-command-rewrite.ts +6 -2
  122. package/scripts/init/init-logic-deploy-vps.ts +4 -2
  123. package/scripts/issue/behavior-change-lint.ts +12 -6
  124. package/scripts/issue/issue-scout-cli.ts +3 -2
  125. package/scripts/josh/file-map-stamp.ts +1 -3
  126. package/scripts/josh/hook-decision.ts +17 -2
  127. package/scripts/josh/josh-commands-workflow.ts +1 -1
  128. package/scripts/lane/lane-await.ts +3 -2
  129. package/scripts/lane/lane-child-invocation.ts +14 -1
  130. package/scripts/lane/lane-guard-policy.ts +4 -4
  131. package/scripts/lane/lane-ledger.ts +9 -0
  132. package/scripts/lane/lane-load.ts +9 -2
  133. package/scripts/lane/lane-measure-cli.ts +2 -0
  134. package/scripts/lane/lane-parked.ts +18 -0
  135. package/scripts/lane/lane-process-load.ts +125 -0
  136. package/scripts/lane/lane-process-stats.ts +93 -0
  137. package/scripts/lane/lane-registry.ts +8 -3
  138. package/scripts/lane/lane-session-broker.ts +29 -5
  139. package/scripts/lane/lane-spent.ts +14 -3
  140. package/scripts/lane/lane-stats.ts +1 -0
  141. package/scripts/managed-marker/managed-marker-logic.ts +1 -2
  142. package/scripts/notify/confirmation-options.ts +59 -0
  143. package/scripts/notify/notify-opt-in.ts +33 -0
  144. package/scripts/notify/telegram-notify.ts +25 -8
  145. package/scripts/notify/telegram-test-logic.ts +33 -1
  146. package/scripts/notify/telegram-test.ts +4 -2
  147. package/scripts/overrides/lockfile-overrides.ts +1 -3
  148. package/scripts/propagate/propagate.ts +1 -2
  149. package/scripts/release/release-progress.ts +3 -2
  150. package/scripts/report/report-lint.ts +7 -4
  151. package/scripts/review/review-record-cli.ts +6 -2
  152. package/scripts/rules/destructive-command.ts +6 -9
  153. package/scripts/rules/direct-filing.ts +3 -7
  154. package/scripts/rules/early-heartbeat.ts +7 -18
  155. package/scripts/rules/file-body.ts +6 -13
  156. package/scripts/rules/filing-cap.ts +3 -6
  157. package/scripts/rules/git-force.ts +5 -9
  158. package/scripts/rules/implementation-cut.ts +37 -35
  159. package/scripts/rules/index-guard.ts +4 -7
  160. package/scripts/rules/issue-comments.ts +5 -15
  161. package/scripts/rules/josh-git-bare.ts +3 -6
  162. package/scripts/rules/lane-background.ts +10 -21
  163. package/scripts/rules/lane-carry-conflict.ts +7 -17
  164. package/scripts/rules/lane-interactive-ask.ts +6 -11
  165. package/scripts/rules/lane-park.ts +5 -13
  166. package/scripts/rules/lane-split-park.ts +2 -7
  167. package/scripts/rules/lane-switch-main.ts +9 -12
  168. package/scripts/rules/oracle-consulted.ts +4 -5
  169. package/scripts/rules/piped-verification.ts +81 -31
  170. package/scripts/rules/poll-loop.ts +7 -17
  171. package/scripts/rules/pre-gate-cut.ts +7 -21
  172. package/scripts/rules/prior-comment-read.ts +7 -5
  173. package/scripts/rules/protected-files.ts +5 -9
  174. package/scripts/rules/raw-field-body.ts +3 -7
  175. package/scripts/rules/rule-body-guard.ts +4 -11
  176. package/scripts/rules/run-tail-rule.ts +6 -22
  177. package/scripts/rules/shell-body-trigger.ts +7 -8
  178. package/scripts/rules/shell-segments.ts +7 -0
  179. package/scripts/rules/stop-rules.ts +17 -29
  180. package/scripts/rules/test-declared-commit.ts +4 -10
  181. package/scripts/rules/third-party-write.ts +5 -10
  182. package/scripts/rules/worktree-guard.ts +5 -12
  183. package/scripts/run/board/run-board-github.ts +11 -3
  184. package/scripts/run/board/run-board-idle.ts +31 -0
  185. package/scripts/run/board/run-board-read.ts +9 -12
  186. package/scripts/run/board/run-board-reason.ts +7 -3
  187. package/scripts/run/board/run-board-state.ts +4 -0
  188. package/scripts/run/board/run-board-status.ts +18 -3
  189. package/scripts/run/board/run-board-thanks.ts +7 -2
  190. package/scripts/run/board/run-board-tick.ts +32 -7
  191. package/scripts/run/carry/run-carry-args.ts +1 -3
  192. package/scripts/run/carry/run-carry-conversation.ts +7 -2
  193. package/scripts/run/cut/run-cut-cli.ts +15 -20
  194. package/scripts/run/cut/run-cut-decline.ts +72 -0
  195. package/scripts/run/cut/run-cut-ready.ts +74 -0
  196. package/scripts/run/cut/run-cut-report.ts +13 -0
  197. package/scripts/run/cut/run-cut.ts +10 -1
  198. package/scripts/run/entry/run-entry-stop.ts +6 -3
  199. package/scripts/run/hold/run-hold-cli.ts +14 -3
  200. package/scripts/run/hold/run-hold.ts +6 -3
  201. package/scripts/run/merge/run-merge-cli.ts +30 -7
  202. package/scripts/run/merge/run-merge-ship.ts +59 -1
  203. package/scripts/run/merge/run-merge.ts +3 -2
  204. package/scripts/run/run-ending.ts +31 -3
  205. package/scripts/run/run-parent-cut-hook.ts +5 -10
  206. package/scripts/run/run-preflight.ts +1 -2
  207. package/scripts/run/run-stop-notify.ts +3 -2
  208. package/scripts/run/run-watcher-guard.ts +6 -3
  209. package/scripts/run/ship/run-ship-review-steps.ts +13 -4
  210. package/scripts/run/ship/run-ship-review.ts +15 -2
  211. package/scripts/run/tidy/run-tidy-cli.ts +3 -3
  212. package/scripts/run/tidy/run-tidy-lanes.ts +3 -2
  213. package/scripts/safe-chain/project-config.ts +7 -2
  214. package/scripts/sync/sync-hook-safety.ts +8 -2
  215. package/scripts/test/unit-worker-share.ts +3 -2
  216. package/scripts/time-runtime/time-batch-guard.ts +3 -3
  217. package/scripts/time-runtime/time-hook-transcript.ts +3 -2
  218. package/scripts/time-runtime/time-shell.ts +10 -0
  219. package/scripts/version/effective-upstream.ts +3 -4
  220. package/scripts/version/kit-version-config.ts +8 -2
  221. package/scripts/version/latest-corepack.ts +1 -2
  222. package/scripts/version/release-hold.ts +11 -4
  223. package/scripts/version/version-commands.ts +12 -4
  224. package/dist/hooks/chunk-UT7YC3N2.js +0 -56
@@ -4,6 +4,7 @@ import type { ClosedAnswer, ClosedIssue } from './run-board-closed'
4
4
  import type { Fetch } from './run-board-fetch'
5
5
  import type { TerminalSize } from './run-board-fit'
6
6
  import type { BoardForm } from './run-board-header'
7
+ import { run_board_idle, type IdleSince } from './run-board-idle'
7
8
  import type { BoardPlan } from './run-board-layout'
8
9
  import type { Link } from './run-board-link'
9
10
  import type { MachineGauges, MachineMark } from './run-board-machine'
@@ -56,6 +57,8 @@ interface BoardState {
56
57
  // The last local read, drawn until the next is due; `undefined` when no run had started.
57
58
  local: LocalRead | undefined
58
59
  read_ms: number | undefined
60
+ // When each lane the reads found idle was first read so (`run-board-idle.ts`).
61
+ idle_since: IdleSince
59
62
  // The start of the run the plan state was built for, so a run that begins draws nothing of the last.
60
63
  run_started_ms: number | undefined
61
64
  plan: BoardPlan | undefined
@@ -91,6 +94,7 @@ interface BoardState {
91
94
  const FRESH_STATE: BoardState = {
92
95
  local: undefined,
93
96
  read_ms: undefined,
97
+ idle_since: run_board_idle.NO_IDLE,
94
98
  run_started_ms: undefined,
95
99
  plan: undefined,
96
100
  plan_fetch: undefined,
@@ -66,6 +66,9 @@ const ENDING_STATES: Readonly<Partial<Record<string, ItemState>>> = {
66
66
  const CONTINUING_KINDS: ReadonlySet<string> = new Set([KIND.CUT, KIND.RESUME])
67
67
  // A person's restart starts the child over in a fresh session, so its track starts over too.
68
68
  const LAUNCH_KINDS: ReadonlySet<string> = new Set([KIND.CHILD_LAUNCH, KIND.RESTART])
69
+ // What the GitHub read may still settle: a running child, and one whose lane has no child left in it —
70
+ // a child that merged and then died is drawn merged, not stopped.
71
+ const UNSETTLED_STATES: ReadonlySet<ItemState> = new Set(['running', 'stopped'])
69
72
 
70
73
  function moment(event: RunEvent): number {
71
74
  return Date.parse(event.at)
@@ -175,13 +178,25 @@ function is_running_lane(
175
178
  return in_run.has(issue) && !run_board_setup.is_lane_pending(events, issue)
176
179
  }
177
180
 
181
+ // A lane no child is working in is stopped whatever the stream last said: a child that died writes no
182
+ // ending, so its row would otherwise run — and its phase time grow — for as long as the lane stays.
183
+ function lane_status(status: ItemStatus | undefined, lane: string, is_idle: boolean): ItemStatus {
184
+ const held = { lane: `lane ${lane}` }
185
+
186
+ return is_idle
187
+ ? { ...settled_as(status, 'stopped'), ...held }
188
+ : { state: 'running', ...status, ...held }
189
+ }
190
+
178
191
  // Every child the run has touched, with the lane each running child holds. A lane the stream never saw
179
192
  // launched — a run restored after the stream rolled — still reads as running while its issue is in the
180
- // run's plan (`in_run`); one the run is still setting up for stays waiting until its launch.
193
+ // run's plan (`in_run`); one the run is still setting up for stays waiting until its launch. A lane in
194
+ // `idle` has no child working in it (`lane-spent.ts`).
181
195
  function statuses_of(
182
196
  events: ReadonlyArray<RunEvent>,
183
197
  lanes: ReadonlyArray<string>,
184
198
  in_run: ReadonlySet<number>,
199
+ idle: ReadonlyArray<string> = [],
185
200
  ): ReadonlyMap<number, ItemStatus> {
186
201
  const statuses = fold_events(events)
187
202
 
@@ -190,7 +205,7 @@ function statuses_of(
190
205
  const status = statuses.get(issue)
191
206
 
192
207
  if (is_running_lane(issue, status, events, in_run)) {
193
- statuses.set(issue, { state: 'running', ...status, lane: `lane ${lane}` })
208
+ statuses.set(issue, lane_status(status, lane, idle.includes(lane)))
194
209
  }
195
210
  }
196
211
 
@@ -260,7 +275,7 @@ function closed_status(
260
275
  }
261
276
 
262
277
  function settled(issue: number, status: ItemStatus, read: OpenRead): ItemStatus {
263
- if (status.state !== 'running') return status
278
+ if (!UNSETTLED_STATES.has(status.state)) return status
264
279
 
265
280
  const from_closed = closed_status(status, read.closed.get(issue))
266
281
 
@@ -17,8 +17,13 @@ type ThanksState = Pick<BoardState, 'thanks' | 'thanks_fetch' | 'thanks_attempte
17
17
  function launch_thanks(state: BoardState, ports: BoardPorts, now_ms: number): BoardState {
18
18
  const { read_thanks } = ports
19
19
 
20
- if (read_thanks === undefined || state.thanks_fetch !== undefined) return state
21
- if (!is_due(state.thanks_attempted_ms, TTL_MS, now_ms)) return state
20
+ if (
21
+ read_thanks === undefined ||
22
+ state.thanks_fetch !== undefined ||
23
+ !is_due(state.thanks_attempted_ms, TTL_MS, now_ms)
24
+ ) {
25
+ return state
26
+ }
22
27
 
23
28
  const thanks_fetch: Fetch<Thanks> = run_board_fetch.launch(read_thanks, state.thanks)
24
29
 
@@ -1,7 +1,9 @@
1
+ import { lane_await } from '#scripts/lane/lane-await'
1
2
  import { run_progress } from '#scripts/run/progress/run-progress'
2
3
  import type { ClosedIssue } from './run-board-closed'
3
4
  import { run_board_github } from './run-board-github'
4
5
  import { run_board_header, type BoardHeader } from './run-board-header'
6
+ import { run_board_idle } from './run-board-idle'
5
7
  import { run_board_labels } from './run-board-labels'
6
8
  import { run_board_layout, type BoardLayout, type BoardPlan } from './run-board-layout'
7
9
  import { run_board_link } from './run-board-link'
@@ -34,10 +36,30 @@ const { FRESH_STATE, is_due } = run_board_state
34
36
  const { PLAN_RETRY_MS, gather_github, statuses_for } = run_board_github
35
37
  const { gather_thanks } = run_board_thanks
36
38
 
37
- async function reread(state: BoardState, ports: BoardPorts, now_ms: number): Promise<BoardState> {
39
+ async function read_at(state: BoardState, ports: BoardPorts, now_ms: number): Promise<BoardState> {
40
+ const local = await ports.read_local()
41
+ const idle_since = run_board_idle.since_of(state.idle_since, local?.idle_lanes ?? [], now_ms)
42
+
43
+ return { ...state, local, read_ms: now_ms, idle_since }
44
+ }
45
+
46
+ // A single frame has no earlier read to confirm an idle lane against (`run-board-idle.ts`).
47
+ function is_unconfirmed(before: BoardState, read: BoardState, pace: Pace): boolean {
48
+ return pace === 'settled' && before.read_ms === undefined && read.idle_since.size > 0
49
+ }
50
+
51
+ async function reread(state: BoardState, ports: BoardPorts, pace: Pace): Promise<BoardState> {
52
+ const now_ms = ports.now()
53
+
38
54
  if (!is_due(state.read_ms, LOCAL_READ_MS, now_ms)) return state
39
55
 
40
- return { ...state, local: await ports.read_local(), read_ms: now_ms }
56
+ const read = await read_at(state, ports, now_ms)
57
+
58
+ if (!is_unconfirmed(state, read, pace)) return read
59
+
60
+ await ports.sleep(lane_await.RECONFIRM_MS)
61
+
62
+ return await read_at(read, ports, ports.now())
41
63
  }
42
64
 
43
65
  // Each lane's usage, read on the machine's second; a reading that failed draws no column.
@@ -69,9 +91,9 @@ async function resample(state: BoardState, ports: BoardPorts, now_ms: number): P
69
91
  function state_for(state: BoardState, local: LocalRead): BoardState {
70
92
  if (state.run_started_ms === local.started_ms) return state
71
93
 
72
- const { local: kept, read_ms } = state
94
+ const { local: kept, read_ms, idle_since } = state
73
95
 
74
- return { ...FRESH_STATE, local: kept, read_ms, run_started_ms: local.started_ms }
96
+ return { ...FRESH_STATE, local: kept, read_ms, idle_since, run_started_ms: local.started_ms }
75
97
  }
76
98
 
77
99
  // The plan with a closed child's title and labels under the listing's own.
@@ -108,7 +130,7 @@ function planned_layout(state: BoardState, plan: BoardPlan, local: LocalRead): B
108
130
  closed,
109
131
  labels: plan.labels,
110
132
  }
111
- const statuses = run_board_status.settle_closed(statuses_for(plan, local), read)
133
+ const statuses = run_board_status.settle_closed(statuses_for(state, plan, local), read)
112
134
  const reason_of = run_board_reason.reader({
113
135
  plan,
114
136
  statuses,
@@ -125,8 +147,10 @@ function planned_layout(state: BoardState, plan: BoardPlan, local: LocalRead): B
125
147
  function layout_for(state: BoardState, local: LocalRead): BoardLayout {
126
148
  if (state.plan !== undefined) return planned_layout(state, state.plan, local)
127
149
 
150
+ const stopped = run_board_idle.stopped_of(state.idle_since, state.read_ms)
151
+
128
152
  return run_board_layout.local_layout_of(
129
- run_board_status.statuses_of(local.events, local.lanes, new Set()),
153
+ run_board_status.statuses_of(local.events, local.lanes, new Set(), stopped),
130
154
  )
131
155
  }
132
156
 
@@ -220,8 +244,9 @@ async function tick(
220
244
  ports: BoardPorts,
221
245
  pace: Pace = 'settled',
222
246
  ): Promise<BoardState> {
247
+ const read = await reread(state, ports, pace)
248
+ // Taken after the read, which a single frame may have waited on to confirm an idle lane.
223
249
  const now_ms = ports.now()
224
- const read = await reread(state, ports, now_ms)
225
250
  const { local } = read
226
251
 
227
252
  if (local === undefined) {
@@ -269,9 +269,7 @@ function to_named_request(values: ParsedValues, owner: CarryOwner): Request | un
269
269
  }
270
270
 
271
271
  function to_request(values: ParsedValues): Request | undefined {
272
- if (group_count(values) > ONE_GROUP) return undefined
273
-
274
- if (!is_summary_paired(values)) return undefined
272
+ if (group_count(values) > ONE_GROUP || !is_summary_paired(values)) return undefined
275
273
 
276
274
  const owner = to_owner(values.owner)
277
275
 
@@ -76,8 +76,13 @@ function is_same_conversation(carry: RunCarry, owner: CarryOwner): boolean {
76
76
  // a new one, so the record goes on naming a live process once the conversation falls quiet. A record a
77
77
  // cut handed off is the successor's to take, never the cutting conversation's to take back.
78
78
  function reclaim_owner(carry: RunCarry, owner: CarryOwner): RunCarry {
79
- if (carry.is_handed_off === true || !is_same_conversation(carry, owner)) return carry
80
- if (owner.pid === carry.owner_pid) return carry
79
+ if (
80
+ carry.is_handed_off === true ||
81
+ !is_same_conversation(carry, owner) ||
82
+ owner.pid === carry.owner_pid
83
+ ) {
84
+ return carry
85
+ }
81
86
 
82
87
  return { ...carry, owner_pid: owner.pid, owner_start: owner.start }
83
88
  }
@@ -3,7 +3,6 @@ import { fileURLToPath } from 'node:url'
3
3
  import { agent_session_role } from '#scripts/agent/agent-session-role'
4
4
  import { cost_cli } from '#scripts/cost-runtime/cost-cli'
5
5
  import { cost_verdict } from '#scripts/cost-runtime/cost-verdict'
6
- import { git_command } from '#scripts/git/git-command'
7
6
  import { issue_cite } from '#scripts/issue/issue-cite'
8
7
  import { session_cite } from '#scripts/issue/session-cite'
9
8
  import { lane_registry, type LaneInfo } from '#scripts/lane/lane-registry'
@@ -12,9 +11,11 @@ import { openai_lane_supervisor } from '#scripts/lane/openai-lane-supervisor'
12
11
  import { run_event_stream } from '#scripts/run/event/run-event-stream'
13
12
  import { run_event_stream_emit } from '#scripts/run/event/run-event-stream-emit'
14
13
  import { run_cli_fault } from '#scripts/run/run-cli-fault'
15
- import { run_cut, type CutState, type RunCut } from './run-cut'
14
+ import { run_cut, type RunCut } from './run-cut'
16
15
  import { run_cut_args, type Request } from './run-cut-args'
16
+ import { run_cut_decline } from './run-cut-decline'
17
17
  import { run_cut_handoff } from './run-cut-handoff'
18
+ import { run_cut_ready } from './run-cut-ready'
18
19
  import { run_cut_report } from './run-cut-report'
19
20
 
20
21
  // `josh run:cut` — the record that lets a lane child end its process before the gate and a fresh one
@@ -57,6 +58,7 @@ const {
57
58
  report_busy,
58
59
  report_fault,
59
60
  report_handed_off,
61
+ report_held_cut,
60
62
  report_incomplete,
61
63
  report_over,
62
64
  report_reviewer,
@@ -125,11 +127,8 @@ function relaunch(target: string, lane: LaneInfo, phase: string): number {
125
127
  return report(CUT_VERDICT, SUCCESS_EXIT_CODE)
126
128
  }
127
129
 
128
- async function is_lane_branch(state: CutState): Promise<boolean> {
129
- const default_branch = await git_command.get_default_branch()
130
-
131
- return state.branch !== default_branch && state.is_dirty
132
- }
130
+ const { is_lane_branch } = run_cut_ready
131
+ const { declined } = run_cut_decline
133
132
 
134
133
  // An expired record is a fresh process that never started; it is cleared so the next cut can begin
135
134
  // rather than being wedged behind a stale file the exclusive create would refuse forever.
@@ -204,16 +203,6 @@ async function finish_cut(target: string, lane: LaneInfo, request: CutRequest):
204
203
  : relaunch(target, lane, request.phase)
205
204
  }
206
205
 
207
- // The cut outside a lane is taken, and nothing is relaunched: the session that took it is the one a
208
- // person is watching, so the run is handed on the way `fullrun`'s entry `over` stop hands it on.
209
- function report_held_cut(issue: string): number {
210
- console.error(
211
- `The cut is recorded for ${session_cite.issue(issue)} outside a lane, so nothing was relaunched. Keep the hold, send a \`confirmation\` Telegram whose body names the resume command \`fullrun ${issue_cite.plain(issue)}\`, and end the turn; the fresh session's \`pnpm josh run:cut --resume ${issue}\` answers \`resume-impl\` with the handoff.`,
212
- )
213
-
214
- return report(CUT_VERDICT, SUCCESS_EXIT_CODE)
215
- }
216
-
217
206
  // **A `fullrun` held in its own checkout takes the implementation cut too**, so a run a person started
218
207
  // has a bound on its context mid-implementation. The hold naming this issue is what marks the run — a
219
208
  // tree nobody holds for it, and the pre-gate phase (which a held run's gate reaches in the same
@@ -228,8 +217,11 @@ async function cut_outside_lane(
228
217
 
229
218
  const state = await run_cut.current_state()
230
219
 
231
- if (state.held_issue !== request.issue) return report(NOT_A_LANE_VERDICT, SUCCESS_EXIT_CODE)
232
- if (!(await is_lane_branch(state))) return refuse_unready(state)
220
+ if (state.held_issue !== request.issue) {
221
+ return await declined(request, state, report(NOT_A_LANE_VERDICT, SUCCESS_EXIT_CODE))
222
+ }
223
+
224
+ if (!(await is_lane_branch(state))) return await declined(request, state, refuse_unready(state))
233
225
 
234
226
  clear_expired(target)
235
227
 
@@ -250,7 +242,10 @@ async function cut(
250
242
 
251
243
  const state = await run_cut.current_state()
252
244
 
253
- if (!(await is_lane_branch(state))) return refuse_unready(state)
245
+ if (!(await is_lane_branch(state))) {
246
+ return await declined({ issue, phase }, state, refuse_unready(state))
247
+ }
248
+
254
249
  if (skips_pre_gate_cut(phase)) return report_under_threshold()
255
250
 
256
251
  clear_expired(target)
@@ -0,0 +1,72 @@
1
+ import { stamp_file } from '#scripts/josh/stamp-file'
2
+ import { json_value } from '#scripts/lib/json-value'
3
+ import { z } from 'zod'
4
+ import { run_cut, type CutState } from './run-cut'
5
+
6
+ // The implementation cut a run asked for and was refused — `not-a-lane` or `unready` — recorded so the
7
+ // implementation-cut guard stops asking for it (joshuafolkken/kit#3881).
8
+ //
9
+ // **The reissue window alone re-asked every 90 seconds.** A run whose `run:cut --impl` can only answer
10
+ // `unready` was refused edit after edit, each refusal a round trip that cut nothing. Neither answer
11
+ // changes while the same hold stands over the tree, so the record names the issue and the hold it
12
+ // answered, and the guard stays silent while both still match; a fresh hold — a new run — asks again.
13
+
14
+ const DECLINE_PREFIX = 'josh-run-cut-decline-'
15
+
16
+ const decline_schema = z.object({ issue: z.string(), hold_taken_at: z.string().optional() })
17
+
18
+ type CutDecline = z.infer<typeof decline_schema>
19
+
20
+ function decline_path(git_directory: string): string {
21
+ return stamp_file.stamp_path(DECLINE_PREFIX, git_directory)
22
+ }
23
+
24
+ function record(git_directory: string, decline: CutDecline): void {
25
+ stamp_file.replace_stamp(decline_path(git_directory), decline)
26
+ }
27
+
28
+ function read(git_directory: string): CutDecline | undefined {
29
+ const raw = stamp_file.read_stamp_text(decline_path(git_directory))
30
+
31
+ return raw === undefined ? undefined : json_value.parse_with(raw, decline_schema)
32
+ }
33
+
34
+ function matches(recorded: CutDecline | undefined, current: CutDecline): boolean {
35
+ return recorded?.issue === current.issue && recorded.hold_taken_at === current.hold_taken_at
36
+ }
37
+
38
+ // The same read taken synchronously, for the `PreToolUse` guard. A checkout git cannot describe has no
39
+ // record, so the guard keeps speaking there.
40
+ function is_declined_sync(
41
+ issue: string,
42
+ directory: string | undefined = run_cut.worktree_git_directory_sync(),
43
+ ): boolean {
44
+ if (directory === undefined) return false
45
+
46
+ const hold_taken_at = run_cut.hold_of(directory)?.taken_at
47
+
48
+ return matches(read(directory), { issue, hold_taken_at })
49
+ }
50
+
51
+ // **An implementation cut `run:cut` refuses is recorded for the guard that asked for it**, then its
52
+ // exit code is passed back unchanged. The pre-gate phase has its own guard and records nothing.
53
+ async function declined(
54
+ request: { issue: string; phase: string },
55
+ state: CutState,
56
+ code: number,
57
+ ): Promise<number> {
58
+ if (request.phase !== run_cut.IMPLEMENTATION_PHASE) return code
59
+
60
+ const directory = await run_cut.worktree_directory()
61
+
62
+ if (directory !== undefined) {
63
+ record(directory, { issue: request.issue, hold_taken_at: state.hold_taken_at })
64
+ }
65
+
66
+ return code
67
+ }
68
+
69
+ const run_cut_decline = { decline_path, declined, is_declined_sync, read, record }
70
+
71
+ export type { CutDecline }
72
+ export { run_cut_decline }
@@ -0,0 +1,74 @@
1
+ import { UNTRACKED_FILES_FLAG } from '#scripts/git/constants'
2
+ import { git_command } from '#scripts/git/git-command'
3
+ import { git_diff_reads } from '#scripts/git/git-diff-reads'
4
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
5
+ import type { CutState } from './run-cut'
6
+
7
+ // Whether a cut can be taken on this tree — the one predicate `run:cut` refuses `unready` on and the
8
+ // implementation-cut guard asks before it refuses an edit (joshuafolkken/kit#3881).
9
+ //
10
+ // **The guard and the command must not disagree.** The guard once fired on every over-threshold edit
11
+ // of a run held on the default branch, where `run:cut --impl` can only ever answer `unready`; each
12
+ // refusal cost a round trip that cut nothing. Reading the same predicate keeps the guard silent
13
+ // exactly where the command would refuse.
14
+
15
+ interface CutTree {
16
+ branch: string
17
+ default_branch: string
18
+ is_dirty: boolean
19
+ }
20
+
21
+ // A cut needs an uncommitted implementation on a branch other than the default one: the resume
22
+ // verifies both, so a tree missing either has nothing a fresh process could resume.
23
+ function is_ready(tree: CutTree): boolean {
24
+ return tree.branch !== tree.default_branch && tree.is_dirty
25
+ }
26
+
27
+ // The same predicate over the state `run:cut` reads, against the default branch asked asynchronously.
28
+ async function is_lane_branch(state: CutState): Promise<boolean> {
29
+ const default_branch = await git_command.get_default_branch()
30
+
31
+ return is_ready({ branch: state.branch, default_branch, is_dirty: state.is_dirty })
32
+ }
33
+
34
+ // `--branch` puts the branch on a `# branch.head` header line, so one call answers both halves; every
35
+ // other line is a change.
36
+ const STATUS_ARGUMENTS = ['status', '--porcelain=v2', '--branch', UNTRACKED_FILES_FLAG] as const
37
+ const HEADER_PREFIX = '#'
38
+ const BRANCH_HEADER = '# branch.head '
39
+
40
+ function tree_of(status: string, default_branch: string): CutTree | undefined {
41
+ const lines = status.split('\n').filter((line) => line !== '')
42
+ const header = lines.find((line) => line.startsWith(BRANCH_HEADER))
43
+
44
+ if (header === undefined) return undefined
45
+
46
+ const branch = header.slice(BRANCH_HEADER.length)
47
+ const is_dirty = lines.some((line) => !line.startsWith(HEADER_PREFIX))
48
+
49
+ return { branch, default_branch, is_dirty }
50
+ }
51
+
52
+ // The tree read synchronously, for the `PreToolUse` guard — `undefined` where git cannot answer.
53
+ function read_tree_sync(): CutTree | undefined {
54
+ const status = git_spawn_sync.read(STATUS_ARGUMENTS)
55
+
56
+ if (status === undefined) return undefined
57
+
58
+ const reference = git_spawn_sync.read(git_diff_reads.DEFAULT_BRANCH_ARGUMENTS)
59
+
60
+ return tree_of(status, git_diff_reads.default_branch_of(reference))
61
+ }
62
+
63
+ // **A tree git cannot describe reads as ready**, so the guard keeps speaking rather than going quiet
64
+ // on a read fault — the same direction `run_cut.carried_cut_sync` takes for an unreadable record.
65
+ function is_ready_sync(read: () => CutTree | undefined = read_tree_sync): boolean {
66
+ const tree = read()
67
+
68
+ return tree === undefined || is_ready(tree)
69
+ }
70
+
71
+ const run_cut_ready = { is_lane_branch, is_ready, is_ready_sync, tree_of }
72
+
73
+ export type { CutTree }
74
+ export { run_cut_ready }
@@ -1,3 +1,5 @@
1
+ import { issue_cite } from '#scripts/issue/issue-cite'
2
+ import { session_cite } from '#scripts/issue/session-cite'
1
3
  import { run_cut, type CutState, type RunCut } from './run-cut'
2
4
  import { run_cut_handoff } from './run-cut-handoff'
3
5
 
@@ -136,6 +138,16 @@ function report_reviewer(): number {
136
138
  return report(NOT_A_LANE_VERDICT, SUCCESS_EXIT_CODE)
137
139
  }
138
140
 
141
+ // The cut outside a lane is taken, and nothing is relaunched: the session that took it is the one a
142
+ // person is watching, so the run is handed on the way `fullrun`'s entry `over` stop hands it on.
143
+ function report_held_cut(issue: string): number {
144
+ console.error(
145
+ `The cut is recorded for ${session_cite.issue(issue)} outside a lane, so nothing was relaunched. Keep the hold, send a \`confirmation\` Telegram whose body names the resume command \`fullrun ${issue_cite.plain(issue)}\`, and end the turn; the fresh session's \`pnpm josh run:cut --resume ${issue}\` answers \`resume-impl\` with the handoff.`,
146
+ )
147
+
148
+ return report(CUT_VERDICT, SUCCESS_EXIT_CODE)
149
+ }
150
+
139
151
  // **An implementation cut is never resumed by a session over the threshold**.
140
152
  // The cut outside a lane leaves the session that took it open, so a person retyping `fullrun #N` there
141
153
  // would be answered `resume-impl`, keep implementing over the threshold, and be cut again on the next
@@ -176,6 +188,7 @@ const run_cut_report = {
176
188
  report_busy,
177
189
  report_fault,
178
190
  report_handed_off,
191
+ report_held_cut,
179
192
  report_incomplete,
180
193
  report_over,
181
194
  report_reviewer,
@@ -106,6 +106,8 @@ interface CutState {
106
106
  is_held: boolean
107
107
  // The issue a `fullrun` hold names, absent when the tree is not held by one.
108
108
  held_issue?: string | undefined
109
+ // When the hold standing over the tree was taken — what keys a declined cut to the run it answered.
110
+ hold_taken_at?: string | undefined
109
111
  }
110
112
 
111
113
  // The fields a cut is written from, bundled so `begin_cut` stays within the parameter limit. `phase`
@@ -402,7 +404,13 @@ async function current_state(): Promise<CutState> {
402
404
  const is_dirty = await run_hold.is_tree_dirty()
403
405
  const hold = hold_of(await worktree_directory())
404
406
 
405
- return { branch, is_dirty, is_held: hold !== undefined, held_issue: fullrun_issue(hold) }
407
+ return {
408
+ branch,
409
+ is_dirty,
410
+ is_held: hold !== undefined,
411
+ held_issue: fullrun_issue(hold),
412
+ hold_taken_at: hold?.taken_at,
413
+ }
406
414
  }
407
415
 
408
416
  function describe_cut(cut: RunCut): string {
@@ -466,6 +474,7 @@ const run_cut = {
466
474
  handed_off_message,
467
475
  has_required_handoff,
468
476
  held_issue_sync,
477
+ hold_of,
469
478
  incomplete_message,
470
479
  invocation_for,
471
480
  is_expired,
@@ -1,6 +1,6 @@
1
1
  import { issue_cite } from '#scripts/issue/issue-cite'
2
2
  import { josh_command } from '#scripts/josh/josh-run'
3
- import { telegram_notify } from '#scripts/notify/telegram-notify'
3
+ import { telegram_notify, type ConfirmInput } from '#scripts/notify/telegram-notify'
4
4
  import { lane_park } from '#scripts/rules/lane-park'
5
5
 
6
6
  // What `run:entry` does on the way out of a stop it decided itself.
@@ -26,14 +26,15 @@ interface StopNotice {
26
26
  }
27
27
 
28
28
  interface StopPorts {
29
- notify: (input: { issue_title: string; body: string; recovery: string }) => Promise<boolean>
29
+ notify: (input: ConfirmInput) => Promise<boolean>
30
30
  release: (issue_number: string) => Promise<boolean>
31
31
  is_parking_child: () => boolean
32
32
  }
33
33
 
34
34
  const RECOVERY = 'If this did not arrive, the run:entry output in the session names the stop.'
35
+ const LATER_ALTERNATIVE = 'Leave the Issue for a later session — nothing was changed'
35
36
  const NOTIFY_FAILED_NOTE =
36
- '(the confirmation Telegram could not be sent — send it with `pnpm josh notify --task-type confirmation` before stopping)'
37
+ '(the confirmation Telegram could not be sent — send it with `pnpm josh notify --task-type confirmation --recommended "<choice>"` before stopping)'
37
38
  const RELEASED_NOTE = '(hold released by run:entry — the tree is free for the resume)'
38
39
  const RELEASE_FAILED_NOTE =
39
40
  '(the hold could not be released — run `pnpm josh run:release` before stopping)'
@@ -78,6 +79,8 @@ async function notify_stop(notice: StopNotice, ports: StopPorts): Promise<void>
78
79
  issue_title: `${notice.command} ${issue_cite.plain(notice.issue_number)} stopped`,
79
80
  body: body_of(notice),
80
81
  recovery: RECOVERY,
82
+ recommended: `Run \`${notice.command} ${issue_cite.plain(notice.issue_number)}\` again in a fresh session`,
83
+ alternatives: [LATER_ALTERNATIVE],
81
84
  })
82
85
 
83
86
  console.info(is_sent ? lane_park.COMMAND_NOTIFY_MARKER : NOTIFY_FAILED_NOTE)
@@ -176,14 +176,25 @@ function held_block(hold: RunHold, issue: string): string | undefined {
176
176
  return run_hold.is_reentry(hold, issue) ? undefined : run_hold.held_message(hold)
177
177
  }
178
178
 
179
+ // An expired record of the claimant's own issue that no stop marked is the earlier run of the same work —
180
+ // a child killed and re-dispatched into its lane — so the claim adopts it with the work it left. A
181
+ // stopped `halfrun` or `prrun` keeps its own adopt path.
182
+ function is_own_unstopped(hold: RunHold, issue: string): boolean {
183
+ return run_hold.is_reentry(hold, issue) && !run_hold.is_stop_marked(hold)
184
+ }
185
+
186
+ async function stale_block(hold: RunHold, issue: string): Promise<string | undefined> {
187
+ if (is_own_unstopped(hold, issue)) return undefined
188
+
189
+ return (await run_hold.is_tree_dirty()) ? run_hold.uncommitted_message(hold) : undefined
190
+ }
191
+
179
192
  async function blocking_message(read: HoldRead, issue: string): Promise<string | undefined> {
180
193
  if (read.kind === 'held') return held_block(read.hold, issue)
181
194
 
182
195
  if (read.kind === 'unreadable') return run_hold.unreadable_message()
183
196
 
184
- const is_expired_over_work = read.kind === 'stale' && (await run_hold.is_tree_dirty())
185
-
186
- return is_expired_over_work ? run_hold.uncommitted_message(read.hold) : undefined
197
+ return read.kind === 'stale' ? await stale_block(read.hold, issue) : undefined
187
198
  }
188
199
 
189
200
  // A record already here is never overwritten by a claim: overwriting is what the guard exists to
@@ -129,10 +129,12 @@ function is_expired(hold: RunHold, now: Date): boolean {
129
129
  // nothing. Only a recorded session that is provably gone counts — no owner, or a live pid whose start
130
130
  // cannot be read, leaves the age as the whole test. **A stop-marked record is exempt**: a `halfrun` or
131
131
  // `prrun` stop is held across a person's latency on purpose, long after its session ends.
132
- function is_owner_gone(hold: RunHold): boolean {
133
- const is_stop_mark = hold.is_halfrun_stop === true || hold.prrun_stop_head !== undefined
132
+ function is_stop_marked(hold: RunHold): boolean {
133
+ return hold.is_halfrun_stop === true || hold.prrun_stop_head !== undefined
134
+ }
134
135
 
135
- if (is_stop_mark || hold.owner_pid === undefined) return false
136
+ function is_owner_gone(hold: RunHold): boolean {
137
+ if (is_stop_marked(hold) || hold.owner_pid === undefined) return false
136
138
 
137
139
  return process_identity.is_same_process(hold.owner_pid, hold.owner_start) === false
138
140
  }
@@ -317,6 +319,7 @@ function unknown_message(): string {
317
319
  }
318
320
 
319
321
  const run_hold = {
322
+ is_stop_marked,
320
323
  FORCE_RELEASE_COMMAND,
321
324
  HOLD_MAX_AGE_HOURS,
322
325
  HOLD_MAX_AGE_MS,