@joshuafolkken/kit 1.1455.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 (211) 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-lanes.md +91 -121
  9. package/.claude/skills/workflow-commands/backlogrun-park.md +73 -117
  10. package/.claude/skills/workflow-commands/backlogrun-progress.md +7 -9
  11. package/.claude/skills/workflow-commands/backlogrun-recovery.md +11 -20
  12. package/.claude/skills/workflow-commands/backlogrun-steps.md +43 -52
  13. package/.claude/skills/workflow-commands/chain-rule.md +24 -34
  14. package/.claude/skills/workflow-commands/followup.md +57 -76
  15. package/.claude/skills/workflow-commands/issue-comments.md +43 -60
  16. package/.claude/skills/workflow-commands/kickoff.md +65 -74
  17. package/.claude/skills/workflow-commands/observation-filing.md +62 -107
  18. package/.claude/skills/workflow-commands/observation-ledger.md +56 -111
  19. package/.claude/skills/workflow-commands/pre-gate-cut.md +89 -138
  20. package/.claude/skills/workflow-commands/prerequisite.md +10 -15
  21. package/.claude/skills/workflow-commands/progress-watcher.md +21 -26
  22. package/.claude/skills/workflow-commands/retrospective.md +31 -50
  23. package/.claude/skills/workflow-commands/split-assessment.md +34 -58
  24. package/.claude/skills/workflow-commands/working-tree-hold.md +54 -71
  25. package/CLAUDE.md +6 -5
  26. package/dist/CLAUDE.md +6 -5
  27. package/dist/hooks/batch-guard.js +6 -6
  28. package/dist/hooks/{chunk-7WQBPLUN.js → chunk-43ZHT22P.js} +31 -15
  29. package/dist/hooks/{chunk-LIAFCXL7.js → chunk-AS74OU3D.js} +23 -7
  30. package/dist/hooks/{chunk-3C4XRYG6.js → chunk-E7OIKX5Z.js} +17 -12
  31. package/dist/hooks/{chunk-Z4XI7CZS.js → chunk-EUMACUK2.js} +18 -12
  32. package/dist/hooks/{chunk-WNLVGT7E.js → chunk-H5HVWYXK.js} +4 -4
  33. package/dist/hooks/{chunk-HHI6FO3R.js → chunk-IIGFYXKC.js} +345 -240
  34. package/dist/hooks/{chunk-GPLGZ6IC.js → chunk-J47HU5H5.js} +14 -3
  35. package/dist/hooks/{chunk-OLMO72ON.js → chunk-K4EOONYM.js} +1 -1
  36. package/dist/hooks/{chunk-D5QBTOCZ.js → chunk-K4LMBQC2.js} +4 -4
  37. package/dist/hooks/{chunk-TBJFTSUY.js → chunk-KC5AK2GF.js} +8 -6
  38. package/dist/hooks/chunk-SHGPANFB.js +56 -0
  39. package/dist/hooks/{chunk-5H4SSDSD.js → chunk-YHBZAJSV.js} +3 -3
  40. package/dist/hooks/{chunk-HFGWYGUV.js → chunk-YYPI7SJZ.js} +5 -5
  41. package/dist/hooks/codex-hook-adapter.js +13 -13
  42. package/dist/hooks/duplicate-read-guard.js +7 -7
  43. package/dist/hooks/format-edited.js +3 -3
  44. package/dist/hooks/inputs.json +216 -214
  45. package/dist/hooks/investigation-guard.js +7 -7
  46. package/dist/hooks/pretool-guard.js +12 -12
  47. package/dist/hooks/session-lang.js +6 -5
  48. package/dist/hooks/stop-guard.js +18 -14
  49. package/dist/josh.js +5 -6
  50. package/dist/managed-marker/index.js +1 -2
  51. package/dist/version/index.js +15 -12
  52. package/eslint/base.js +2 -1
  53. package/eslint/rules/unicorn.js +16 -0
  54. package/package.json +3 -3
  55. package/prompts/coding-standards.md +12 -22
  56. package/prompts/collaboration-workflow/file-edits.md +26 -44
  57. package/prompts/collaboration-workflow/gh-rest.md +20 -36
  58. package/prompts/collaboration-workflow/glossary.md +5 -7
  59. package/prompts/collaboration-workflow/issue-citation.md +7 -13
  60. package/prompts/collaboration-workflow/issue-template.md +29 -29
  61. package/prompts/collaboration-workflow/operating-rules.md +35 -44
  62. package/prompts/collaboration-workflow/output-bounds.md +15 -15
  63. package/prompts/collaboration-workflow/overview.md +14 -19
  64. package/prompts/collaboration-workflow/plan-comment.md +13 -13
  65. package/prompts/collaboration-workflow/principles.md +50 -53
  66. package/prompts/collaboration-workflow/proposal-request.md +1 -9
  67. package/prompts/collaboration-workflow/report-format.md +32 -49
  68. package/prompts/collaboration-workflow/residency.md +10 -10
  69. package/prompts/collaboration-workflow/rule-delivery.md +9 -9
  70. package/prompts/collaboration-workflow/shell-body.md +10 -20
  71. package/prompts/collaboration-workflow/turn-batching.md +8 -14
  72. package/prompts/collaboration-workflow/upstream-interrupt.md +26 -34
  73. package/prompts/collaboration-workflow/wip-cap.md +24 -30
  74. package/prompts/collaboration-workflow.md +24 -25
  75. package/prompts/refactoring.md +33 -43
  76. package/prompts/review-rubric.md +84 -129
  77. package/prompts/review.md +111 -141
  78. package/prompts/sonar-hotspot-handling.md +22 -50
  79. package/prompts/testing-guide.md +48 -99
  80. package/scripts/adopt/adopt-toolkits.ts +2 -4
  81. package/scripts/agent/agent-exit-record.ts +23 -1
  82. package/scripts/agent/codex-agent-argv.ts +7 -2
  83. package/scripts/backlog/backlog-budget-cli.ts +1 -2
  84. package/scripts/backlog/backlog-drive-carried.ts +26 -0
  85. package/scripts/backlog/backlog-drive-cli.ts +10 -5
  86. package/scripts/backlog/backlog-drive-named.ts +2 -4
  87. package/scripts/backlog/backlog-drive-restore.ts +19 -1
  88. package/scripts/backlog/backlog-drive-seats.ts +27 -0
  89. package/scripts/backlog/backlog-drive.ts +11 -6
  90. package/scripts/backlog/backlog-ready.ts +13 -6
  91. package/scripts/behavior/behavior-rules.ts +3 -2
  92. package/scripts/cost-runtime/codex-usage.ts +4 -2
  93. package/scripts/cost-runtime/cost-cli.ts +1 -2
  94. package/scripts/delegation/investigation-guard.ts +1 -1
  95. package/scripts/delegation/investigation-reads.ts +1 -1
  96. package/scripts/discovery/repo-discovery.ts +4 -2
  97. package/scripts/discovery/repo-origin.ts +2 -4
  98. package/scripts/doctor/doctor-logic.ts +6 -3
  99. package/scripts/document/document-byte-budget.ts +27 -27
  100. package/scripts/document/document-byte-check.ts +1 -2
  101. package/scripts/document/entry-read-budget.ts +2 -2
  102. package/scripts/epic/epic-audit-rationale.ts +8 -2
  103. package/scripts/epic/epic-bundle-cli.ts +1 -2
  104. package/scripts/epic/epic-chains.ts +6 -2
  105. package/scripts/epic/epic-close-comment.ts +1 -2
  106. package/scripts/gate/verification-gate.ts +7 -2
  107. package/scripts/gh/git-pr.ts +6 -2
  108. package/scripts/git/git-common-directory.ts +3 -3
  109. package/scripts/git/git-diff-reads.ts +16 -9
  110. package/scripts/git/git-execa-error.ts +1 -2
  111. package/scripts/hooks/format-edited-file.ts +10 -4
  112. package/scripts/hooks/pre-push-unit.ts +15 -4
  113. package/scripts/hooks/pretool-guard.ts +23 -29
  114. package/scripts/hooks/step-zero-notice.ts +3 -2
  115. package/scripts/init/hook-command-rewrite.ts +6 -2
  116. package/scripts/init/init-logic-deploy-vps.ts +4 -2
  117. package/scripts/issue/behavior-change-lint.ts +12 -6
  118. package/scripts/issue/issue-scout-cli.ts +3 -2
  119. package/scripts/josh/file-map-stamp.ts +1 -3
  120. package/scripts/josh/hook-decision.ts +17 -2
  121. package/scripts/lane/lane-await.ts +3 -2
  122. package/scripts/lane/lane-child-invocation.ts +14 -1
  123. package/scripts/lane/lane-guard-policy.ts +4 -4
  124. package/scripts/lane/lane-ledger.ts +9 -0
  125. package/scripts/lane/lane-load.ts +9 -2
  126. package/scripts/lane/lane-measure-cli.ts +2 -0
  127. package/scripts/lane/lane-parked.ts +18 -0
  128. package/scripts/lane/lane-process-load.ts +125 -0
  129. package/scripts/lane/lane-process-stats.ts +93 -0
  130. package/scripts/lane/lane-registry.ts +8 -3
  131. package/scripts/lane/lane-session-broker.ts +29 -5
  132. package/scripts/lane/lane-spent.ts +14 -3
  133. package/scripts/lane/lane-stats.ts +1 -0
  134. package/scripts/managed-marker/managed-marker-logic.ts +1 -2
  135. package/scripts/overrides/lockfile-overrides.ts +1 -3
  136. package/scripts/propagate/propagate.ts +1 -2
  137. package/scripts/release/release-progress.ts +3 -2
  138. package/scripts/report/report-lint.ts +7 -4
  139. package/scripts/review/review-record-cli.ts +6 -2
  140. package/scripts/rules/destructive-command.ts +6 -9
  141. package/scripts/rules/direct-filing.ts +3 -7
  142. package/scripts/rules/early-heartbeat.ts +7 -18
  143. package/scripts/rules/file-body.ts +6 -13
  144. package/scripts/rules/filing-cap.ts +3 -6
  145. package/scripts/rules/git-force.ts +5 -9
  146. package/scripts/rules/implementation-cut.ts +37 -35
  147. package/scripts/rules/index-guard.ts +4 -7
  148. package/scripts/rules/issue-comments.ts +5 -15
  149. package/scripts/rules/josh-git-bare.ts +3 -6
  150. package/scripts/rules/lane-background.ts +10 -21
  151. package/scripts/rules/lane-carry-conflict.ts +7 -17
  152. package/scripts/rules/lane-interactive-ask.ts +6 -11
  153. package/scripts/rules/lane-park.ts +5 -13
  154. package/scripts/rules/lane-split-park.ts +2 -7
  155. package/scripts/rules/lane-switch-main.ts +9 -12
  156. package/scripts/rules/oracle-consulted.ts +4 -5
  157. package/scripts/rules/piped-verification.ts +81 -31
  158. package/scripts/rules/poll-loop.ts +7 -17
  159. package/scripts/rules/pre-gate-cut.ts +7 -21
  160. package/scripts/rules/prior-comment-read.ts +7 -5
  161. package/scripts/rules/protected-files.ts +5 -9
  162. package/scripts/rules/raw-field-body.ts +3 -7
  163. package/scripts/rules/rule-body-guard.ts +4 -11
  164. package/scripts/rules/run-tail-rule.ts +6 -22
  165. package/scripts/rules/shell-body-trigger.ts +4 -6
  166. package/scripts/rules/shell-segments.ts +7 -0
  167. package/scripts/rules/stop-rules.ts +16 -29
  168. package/scripts/rules/test-declared-commit.ts +4 -10
  169. package/scripts/rules/third-party-write.ts +5 -10
  170. package/scripts/rules/worktree-guard.ts +5 -12
  171. package/scripts/run/board/run-board-github.ts +11 -3
  172. package/scripts/run/board/run-board-idle.ts +31 -0
  173. package/scripts/run/board/run-board-read.ts +9 -12
  174. package/scripts/run/board/run-board-reason.ts +7 -3
  175. package/scripts/run/board/run-board-state.ts +4 -0
  176. package/scripts/run/board/run-board-status.ts +18 -3
  177. package/scripts/run/board/run-board-thanks.ts +7 -2
  178. package/scripts/run/board/run-board-tick.ts +32 -7
  179. package/scripts/run/carry/run-carry-args.ts +1 -3
  180. package/scripts/run/carry/run-carry-conversation.ts +7 -2
  181. package/scripts/run/cut/run-cut-cli.ts +15 -20
  182. package/scripts/run/cut/run-cut-decline.ts +72 -0
  183. package/scripts/run/cut/run-cut-ready.ts +74 -0
  184. package/scripts/run/cut/run-cut-report.ts +13 -0
  185. package/scripts/run/cut/run-cut.ts +10 -1
  186. package/scripts/run/hold/run-hold-cli.ts +14 -3
  187. package/scripts/run/hold/run-hold.ts +6 -3
  188. package/scripts/run/merge/run-merge-cli.ts +30 -7
  189. package/scripts/run/merge/run-merge-ship.ts +59 -1
  190. package/scripts/run/merge/run-merge.ts +3 -2
  191. package/scripts/run/run-ending.ts +31 -3
  192. package/scripts/run/run-parent-cut-hook.ts +5 -10
  193. package/scripts/run/run-preflight.ts +1 -2
  194. package/scripts/run/run-stop-notify.ts +1 -2
  195. package/scripts/run/run-watcher-guard.ts +6 -3
  196. package/scripts/run/ship/run-ship-review-steps.ts +13 -4
  197. package/scripts/run/ship/run-ship-review.ts +15 -2
  198. package/scripts/run/tidy/run-tidy-cli.ts +3 -3
  199. package/scripts/run/tidy/run-tidy-lanes.ts +3 -2
  200. package/scripts/safe-chain/project-config.ts +7 -2
  201. package/scripts/sync/sync-hook-safety.ts +8 -2
  202. package/scripts/test/unit-worker-share.ts +3 -2
  203. package/scripts/time-runtime/time-batch-guard.ts +3 -3
  204. package/scripts/time-runtime/time-hook-transcript.ts +3 -2
  205. package/scripts/time-runtime/time-shell.ts +10 -0
  206. package/scripts/version/effective-upstream.ts +3 -4
  207. package/scripts/version/kit-version-config.ts +8 -2
  208. package/scripts/version/latest-corepack.ts +1 -2
  209. package/scripts/version/release-hold.ts +11 -4
  210. package/scripts/version/version-commands.ts +12 -4
  211. package/dist/hooks/chunk-UT7YC3N2.js +0 -56
@@ -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,
@@ -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,
@@ -10,11 +10,13 @@ import { lane_handoff } from '#scripts/lane/lane-handoff'
10
10
  import { lane_ledger } from '#scripts/lane/lane-ledger'
11
11
  import { cli_flags } from '#scripts/lib/cli-flags'
12
12
  import { run_carry, type CarryOwner, type RunCarry } from '#scripts/run/carry/run-carry'
13
+ import { run_event_scope } from '#scripts/run/event/run-event-scope'
13
14
  import { run_event_stream } from '#scripts/run/event/run-event-stream'
14
15
  import { run_event_stream_emit } from '#scripts/run/event/run-event-stream-emit'
15
16
  import { run_ending } from '#scripts/run/run-ending'
16
17
  import { run_label } from '#scripts/run/run-label'
17
18
  import { run_merge, type ChildOutcome, type EndingSignals } from './run-merge'
19
+ import { run_merge_ship } from './run-merge-ship'
18
20
  import { run_merge_steps, type FailedResult, type MergeContext } from './run-merge-steps'
19
21
  import { run_merge_token, type MergeToken } from './run-merge-token'
20
22
 
@@ -189,14 +191,34 @@ async function offered(ctx: MergeContext): Promise<OfferVerdict> {
189
191
  return { token: await run_merge_steps.ask_next(ctx), code: SUCCESS_EXIT_CODE, is_offer: true }
190
192
  }
191
193
 
192
- // Whether the child's exit record shows it could not reach the API — read only when `--output` named
193
- // the transcript, off otherwise. The path is validated inside
194
- // `run_ending.read_exit`, which returns `undefined` for an unreadable or missing record, so a missing
195
- // output is simply "not an outage".
194
+ // Whether the child's session ended through its environment — read only when `--output` named the
195
+ // transcript, off otherwise: its exit record shows it could not reach the API, or the session was
196
+ // killed inside a turn. The path is validated inside `run_ending`, so a missing output is "no".
196
197
  function read_is_outage(output: string | undefined): boolean {
197
198
  if (output === undefined) return false
198
199
 
199
- return api_outage.is_outage(run_ending.read_exit(output))
200
+ return api_outage.is_outage(run_ending.read_exit(output)) || run_ending.ended_mid_turn(output)
201
+ }
202
+
203
+ async function child_outages(child: string): Promise<number> {
204
+ const events = await run_event_stream_emit.current_events()
205
+
206
+ return events.filter(
207
+ (event) =>
208
+ event.kind === run_event_stream.EVENT_KIND.OUTAGE &&
209
+ run_event_scope.issue_named(event) === child,
210
+ ).length
211
+ }
212
+
213
+ // An outage child is re-dispatched only so many times: one that ends the same way on every dispatch is
214
+ // parked, since merges in other lanes reset the run-wide outage streak and would never stop it.
215
+ async function read_is_retryable(child: string, output: string | undefined): Promise<boolean> {
216
+ const is_interrupted =
217
+ read_is_outage(output) || (await run_merge_ship.is_unanswered(child, output))
218
+
219
+ if (!is_interrupted) return false
220
+
221
+ return (await child_outages(child)) < run_merge.CONSECUTIVE_OUTAGE_LIMIT
200
222
  }
201
223
 
202
224
  type IssueRead = Awaited<ReturnType<typeof issue_state_cli.read_issue>>
@@ -211,12 +233,13 @@ async function read_merged_pr(child: string, read: IssueRead): Promise<string |
211
233
  }
212
234
 
213
235
  async function read_signals(ctx: MergeContext, read: IssueRead): Promise<EndingSignals> {
214
- const [is_cut, merged_pr] = await Promise.all([
236
+ const [is_cut, merged_pr, is_outage] = await Promise.all([
215
237
  run_merge_steps.has_resumable_cut(ctx.child),
216
238
  read_merged_pr(ctx.child, read),
239
+ read_is_retryable(ctx.child, ctx.output),
217
240
  ])
218
241
 
219
- return { is_outage: read_is_outage(ctx.output), is_cut, merged_pr }
242
+ return { is_outage, is_cut, merged_pr }
220
243
  }
221
244
 
222
245
  function outcome_of(read: IssueRead, signals: EndingSignals): ChildOutcome {
@@ -1,6 +1,9 @@
1
1
  import { git_common_directory } from '#scripts/git/git-common-directory'
2
+ import { lane_registry } from '#scripts/lane/lane-registry'
3
+ import { lane_relaunch } from '#scripts/lane/lane-relaunch'
2
4
  import { run_event_scope } from '#scripts/run/event/run-event-scope'
3
5
  import { run_event_stream, type RunEvent } from '#scripts/run/event/run-event-stream'
6
+ import { run_ending } from '#scripts/run/run-ending'
4
7
  import { run_ship_detach } from '#scripts/run/ship/run-ship-detach'
5
8
 
6
9
  // Why `run:merge` parks a child whose detached ship has already ended (joshuafolkken/kit#3808).
@@ -56,6 +59,61 @@ function cause(child: string, directory: string = process.cwd()): string | undef
56
59
  return cause_of(run_ship_detach.read_result(repository, child), events, child)
57
60
  }
58
61
 
59
- const run_merge_ship = { cause, cause_of }
62
+ // Whether the ended ship was never answered: a session relaunched to fix a stop writes to the
63
+ // transcript after it, so a transcript last written before the newest stop means no fix-up ran — its
64
+ // broker or relaunch could not start. A ship with no stop at all ended abnormally only when its supervisor
65
+ // died before reaching a stage.
66
+ function is_stop_unanswered(
67
+ ending: ShipEnding,
68
+ attempt: Events,
69
+ written_at: number | undefined,
70
+ ): boolean {
71
+ const stop = attempt.findLast((event) => event.kind === run_event_stream.EVENT_KIND.SHIP_STOP)
72
+
73
+ if (stop === undefined) return ending?.result === 'abnormal'
74
+
75
+ return written_at !== undefined && written_at < Date.parse(stop.at)
76
+ }
77
+
78
+ // Whether the child's ship ended through an interruption rather than its work, so the child is
79
+ // re-dispatched instead of parked.
80
+ function interrupted_of(
81
+ ending: ShipEnding,
82
+ events: Events,
83
+ child: string,
84
+ written_at: number | undefined,
85
+ ): boolean {
86
+ if (ending === undefined || !ENDED_RESULTS.has(ending.result)) return false
87
+
88
+ const attempt = since_ship_launch(events, child)
89
+
90
+ return attempt !== undefined && is_stop_unanswered(ending, attempt, written_at)
91
+ }
92
+
93
+ function is_interrupted(
94
+ child: string,
95
+ written_at: number | undefined,
96
+ directory: string = process.cwd(),
97
+ ): boolean {
98
+ const repository = git_common_directory.repository(directory)
99
+ if (repository === undefined) return false
100
+
101
+ const events = run_event_stream.read_events(run_event_stream.target_of(repository))
102
+
103
+ return interrupted_of(run_ship_detach.read_result(repository, child), events, child, written_at)
104
+ }
105
+
106
+ // Whether the child's ship stopped and the relaunch meant to fix it never ran, read against its
107
+ // transcript at `output`. An OpenAI lane is never relaunched — its parent books every stop as an ended
108
+ // child — and nothing relaunches a child whose lane is gone, so only an open non-OpenAI lane reads as one.
109
+ async function is_unanswered(child: string, output: string | undefined): Promise<boolean> {
110
+ if (output === undefined || !is_interrupted(child, run_ending.written_at(output))) return false
111
+
112
+ const lane = await lane_registry.find_open_lane(child)
113
+
114
+ return lane !== undefined && !lane_relaunch.is_openai_lane(lane)
115
+ }
116
+
117
+ const run_merge_ship = { cause, cause_of, interrupted_of, is_unanswered }
60
118
 
61
119
  export { run_merge_ship }
@@ -23,8 +23,9 @@ const CLOSED_STATE = 'CLOSED'
23
23
  // resets the streak (`run-carry.ts` → `next_failures`), so the guard trips only on an environment
24
24
  // failing every child it is handed rather than on failures scattered across a long run.
25
25
  const CONSECUTIVE_FAILURE_LIMIT = 3
26
- // Three API outages in a row stop the run too — the same shape as the failure
27
- // guard, over its own streak. An outage is a child that could not reach the API, so a run of them is
26
+ // Three outages in a row stop the run too — the same shape as the failure
27
+ // guard, over its own streak. An outage is a child its environment ended — it could not reach the API,
28
+ // its session was killed inside a turn, or its ship stopped with no relaunch — so a run of them is
28
29
  // the environment being down; stopping is what keeps the run from re-dispatching into a dead API
29
30
  // forever. A merge or a genuine child failure resets it (`run-carry.ts` → `next_outages`).
30
31
  const CONSECUTIVE_OUTAGE_LIMIT = 3
@@ -1,4 +1,4 @@
1
- import { readFileSync } from 'node:fs'
1
+ import { readFileSync, statSync } from 'node:fs'
2
2
  import { agent_exit_record } from '#scripts/agent/agent-exit-record'
3
3
  import { api_outage } from '#scripts/agent/api-outage'
4
4
  import type { ClaudeResultEvent } from '#scripts/agent/claude-result-event'
@@ -179,7 +179,7 @@ async function read_cut_taken(issue: string): Promise<boolean> {
179
179
  return read.kind === 'carried' && read.cut.invocation === run_cut.invocation_for(issue)
180
180
  }
181
181
 
182
- function read_exit(output_path: string): ClaudeResultEvent | undefined {
182
+ function read_transcript(output_path: string): string | undefined {
183
183
  const safe_path = run_liveness.to_safe_path(output_path)
184
184
 
185
185
  if (safe_path === undefined) return undefined
@@ -188,12 +188,38 @@ function read_exit(output_path: string): ClaudeResultEvent | undefined {
188
188
  // Validated by `run_liveness.to_safe_path` — normalized, required absolute, confined to the
189
189
  // allowed roots — the same check `josh lane:output` gates a transcript path with, reused rather
190
190
  // than re-spelled so the two cannot disagree.
191
- return agent_exit_record.read_exit_record(readFileSync(safe_path, 'utf8')) // NOSONAR
191
+ return readFileSync(safe_path, 'utf8') // NOSONAR
192
+ } catch {
193
+ return undefined
194
+ }
195
+ }
196
+
197
+ function read_exit(output_path: string): ClaudeResultEvent | undefined {
198
+ const transcript = read_transcript(output_path)
199
+
200
+ return transcript === undefined ? undefined : agent_exit_record.read_exit_record(transcript)
201
+ }
202
+
203
+ // When the child's transcript was last written, in epoch milliseconds — `undefined` when unreadable.
204
+ function written_at(output_path: string): number | undefined {
205
+ const safe_path = run_liveness.to_safe_path(output_path)
206
+
207
+ if (safe_path === undefined) return undefined
208
+
209
+ try {
210
+ return statSync(safe_path).mtimeMs // NOSONAR — validated as `read_transcript` validates it
192
211
  } catch {
193
212
  return undefined
194
213
  }
195
214
  }
196
215
 
216
+ // The child's session was killed inside a turn rather than finishing it.
217
+ function ended_mid_turn(output_path: string): boolean {
218
+ const transcript = read_transcript(output_path)
219
+
220
+ return transcript !== undefined && agent_exit_record.is_mid_turn(transcript)
221
+ }
222
+
197
223
  async function read_traces(request: EndingRequest): Promise<EndingTraces> {
198
224
  const [is_child_closed, is_cut_taken, is_tree_dirty] = await Promise.all([
199
225
  read_child_closed(request.issue, request.repo),
@@ -218,7 +244,9 @@ async function check(request: EndingRequest): Promise<EndingDecision> {
218
244
  const run_ending = {
219
245
  check,
220
246
  decide,
247
+ ended_mid_turn,
221
248
  read_exit,
249
+ written_at,
222
250
  }
223
251
 
224
252
  export type { EndingDecision, EndingRequest, EndingTraces, EndingVerdict }
@@ -53,16 +53,11 @@ function current_state(): ParentCutState {
53
53
  const THRESHOLD_TEXT = `${cost_format.format_tokens(CONTEXT_CUT_THRESHOLD)}-token`
54
54
 
55
55
  const PARENT_CUT_REASON =
56
- '⛔ backlogrun parent hand-off: this session drives a `backlogrun` (it owns the live carry record) and ' +
57
- `its recent-context cost has crossed the shared ${THRESHOLD_TEXT} threshold between merges, so every ` +
58
- 'later request re-reads the accumulated context (joshuafolkken/kit#2947). Take the hand-off now rather ' +
59
- 'than at the next merge: follow `.claude/skills/workflow-commands/backlogrun-progress.md` → "The ' +
60
- 'hand-off" from its `over` branch — open no new lane and take no new child, read `pnpm josh lane:list`, ' +
61
- 'make sure every lane in flight records an output path, then run `pnpm josh run:carry --cut --owner ' +
62
- '"$PPID"` and, unless it answers `capped`, `pnpm josh run:wake`, post the progress comment and end the ' +
63
- 'turn. A lane nobody can poll means the cut does not happen; carry on and this asks again later. ' +
64
- 'Reissue this call when the hand-off is done or cannot be taken — the guard stays quiet for a while ' +
65
- 'after it refuses, so the hand-off calls themselves are not refused.'
56
+ '⛔ backlogrun parent hand-off: this session drives a `backlogrun` and its recent-context cost ' +
57
+ `crossed the ${THRESHOLD_TEXT} threshold between merges. Take the hand-off now: open no new lane, ` +
58
+ 'follow `backlogrun-progress.md` → "The hand-off" (`pnpm josh run:carry --cut --owner "$PPID"`, then ' +
59
+ '`pnpm josh run:wake` unless `capped`) and end the turn. Reissue this call once done or if it ' +
60
+ 'cannot be taken.'
66
61
 
67
62
  // Neither an uncapped parent's own record nor a measured `over` is optional: a capped record has no cut
68
63
  // left to take (`run:carry --cut` answers `capped`), and an unmeasurable session is left alone here —
@@ -343,8 +343,7 @@ async function read_candidate(
343
343
  ): Promise<ChildState | undefined> {
344
344
  const state = await read_pr_of_branch(name)
345
345
 
346
- if (state.pr_state !== NO_PR) return state
347
- if (await has_commits_beyond(context.base, references)) return state
346
+ if (state.pr_state !== NO_PR || (await has_commits_beyond(context.base, references))) return state
348
347
 
349
348
  return (await is_held_dirty(name, context.lane)) ? state : undefined
350
349
  }
@@ -27,8 +27,7 @@ const STOP_RECOVERY =
27
27
  // this type is for the run that halted needing a person. An `unreadable` record is not announced: with
28
28
  // nothing readable there is no run this can honestly say stopped.
29
29
  function plan(read: CarryRead, reason: string | undefined): StopNotice | undefined {
30
- if (reason === undefined || reason.length === 0) return undefined
31
- if (read.kind !== 'carried') return undefined
30
+ if (reason === undefined || reason.length === 0 || read.kind !== 'carried') return undefined
32
31
 
33
32
  return { reason }
34
33
  }
@@ -49,9 +49,12 @@ async function owes_watcher(): Promise<boolean> {
49
49
  // `run_progress_clock.life_target_of(git_directory)`. Passed in rather than resolved here so the
50
50
  // caller can use whichever resolution fits its sync/async context.
51
51
  async function check(life_target: string): Promise<GuardResult> {
52
- if (!(await owes_watcher())) return OK_RESULT
53
-
54
- if (run_progress_clock.is_life_fresh(life_target, STALE_THRESHOLD_MS)) return OK_RESULT
52
+ if (
53
+ !(await owes_watcher()) ||
54
+ run_progress_clock.is_life_fresh(life_target, STALE_THRESHOLD_MS)
55
+ ) {
56
+ return OK_RESULT
57
+ }
55
58
 
56
59
  return { kind: 'stale', note: STALE_NOTE }
57
60
  }
@@ -59,7 +59,7 @@ const ROUND_ONE_RECORDED_NOTE =
59
59
  // single-sourced with the preflight and gate stages.
60
60
  const { run_phases, scoped_pair } = run_ship_scoped
61
61
 
62
- type PromptOf = (brief_path: string, findings_path: string) => string
62
+ type PromptOf = (brief_path: string, findings_path: string, issue: string) => string
63
63
 
64
64
  function failure(out: string): JoshResult {
65
65
  return { code: FAILURE_EXIT_CODE, out }
@@ -138,7 +138,12 @@ async function launch_reviewer(
138
138
  stamp_file.write_text_stamp(target.brief, brief)
139
139
  stamp_file.remove_stamp(target.findings)
140
140
 
141
- return await run_session(prompt_of(target.brief, target.findings), target.log, issue, round)
141
+ return await run_session(
142
+ prompt_of(target.brief, target.findings, issue),
143
+ target.log,
144
+ issue,
145
+ round,
146
+ )
142
147
  }
143
148
 
144
149
  function current_verdict(): ReturnType<typeof run_ship_review.read_verdict> {
@@ -153,8 +158,12 @@ function current_verdict(): ReturnType<typeof run_ship_review.read_verdict> {
153
158
  async function join_gate(): Promise<JoshResult> {
154
159
  const joined = await josh([RUN_REVIEW, '--join'])
155
160
 
156
- if (joined.code === SUCCESS_EXIT_CODE) return joined
157
- if (!run_ship_review.has_fixes(stamp_file.read_stamp_text(paths().findings))) return joined
161
+ if (
162
+ joined.code === SUCCESS_EXIT_CODE ||
163
+ !run_ship_review.has_fixes(stamp_file.read_stamp_text(paths().findings))
164
+ ) {
165
+ return joined
166
+ }
158
167
 
159
168
  note(FIXED_JOIN_NOTE)
160
169
 
@@ -1,3 +1,4 @@
1
+ import { issue_cite } from '#scripts/issue/issue-cite'
1
2
  import { review_finding_ledger } from '#scripts/review/review-finding-ledger'
2
3
  import { review_record_cli } from '#scripts/review/review-record-cli'
3
4
 
@@ -83,12 +84,23 @@ function findings_instruction(findings_path: string, fixed_clause: string): stri
83
84
  ].join(' ')
84
85
  }
85
86
 
87
+ // The rubric's requirement check sends the reviewer to the Issue, and a body-only read there is refused
88
+ // by the issue-comments rule — so the prompt names the read that carries the comments with the body
89
+ // (joshuafolkken/kit#3875).
90
+ function issue_read_instruction(issue: string): string {
91
+ return [
92
+ `Read Issue ${issue_cite.plain(issue)} with \`pnpm josh issue:read ${issue}\`, which prints the body and every`,
93
+ `comment together — never a body-only \`gh api …/issues/${issue}\` read.`,
94
+ ].join(' ')
95
+ }
96
+
86
97
  // One line, because `detached_launch` refuses an argument carrying a control character: the multi-line
87
98
  // brief travels by path, never inline.
88
- function reviewer_prompt(brief_path: string, findings_path: string): string {
99
+ function reviewer_prompt(brief_path: string, findings_path: string, issue: string): string {
89
100
  return [
90
101
  `Run a /code-review of this checkout: read the review brief at ${brief_path} and follow it exactly —`,
91
102
  'the level on its first line, the rubric it names, the checkout it pins and its review:attest step.',
103
+ issue_read_instruction(issue),
92
104
  'Apply a fix only for a Medium finding whose fix is small and local — a one-function refactor, an',
93
105
  'added test, an added guard; never fix a High or a change that reaches beyond one place, and never',
94
106
  'commit, stage or push. After any fix, run `pnpm josh lint:related && pnpm josh test:related` and',
@@ -98,11 +110,12 @@ function reviewer_prompt(brief_path: string, findings_path: string): string {
98
110
  }
99
111
 
100
112
  // The round-2 verification pass: a fresh session over the round-1 fix delta, reading only.
101
- function verification_prompt(brief_path: string, findings_path: string): string {
113
+ function verification_prompt(brief_path: string, findings_path: string, issue: string): string {
102
114
  return [
103
115
  `Run a /code-review of this checkout — the round-2 verification pass over the round-1 fixes: read the`,
104
116
  `review brief at ${brief_path} and follow it exactly — the level on its first line, the rubric it`,
105
117
  'names, the checkout it pins and its review:attest step. Do not edit any file.',
118
+ issue_read_instruction(issue),
106
119
  findings_instruction(findings_path, ''),
107
120
  ].join(' ')
108
121
  }
@@ -33,12 +33,12 @@ function memoized(read: IsMerged): IsMerged {
33
33
 
34
34
  async function sweep(): Promise<void> {
35
35
  try {
36
- const read_merged = memoized(issue_merged.read_merged)
36
+ const is_merged = memoized(issue_merged.read_merged)
37
37
  const in_flight = backlog_drive_restore.active_issues(
38
38
  await run_event_stream_emit.current_events(),
39
39
  )
40
- const lanes = await run_tidy_lanes.tidy_lanes(read_merged, in_flight)
41
- const stashes = await run_tidy_stashes.tidy_stashes(read_merged)
40
+ const lanes = await run_tidy_lanes.tidy_lanes(is_merged, in_flight)
41
+ const stashes = await run_tidy_stashes.tidy_stashes(is_merged)
42
42
  const report = run_tidy.format_report([...lanes, ...stashes])
43
43
 
44
44
  if (report !== undefined) console.error(report)
@@ -95,8 +95,9 @@ async function tidy_lane(
95
95
  is_merged: IsMerged,
96
96
  in_flight: ReadonlySet<string>,
97
97
  ): Promise<Outcome | undefined> {
98
- if (in_flight.has(lane.issue)) return undefined
99
- if (lane.is_stranded || !(await is_merged(lane.issue))) return undefined
98
+ if (in_flight.has(lane.issue) || lane.is_stranded || !(await is_merged(lane.issue))) {
99
+ return undefined
100
+ }
100
101
 
101
102
  return { target: `lane ${session_cite.issue(lane.issue)}`, verdict: await settle(lane) }
102
103
  }
@@ -126,8 +126,13 @@ function merge_project_config(existing: string, workspace: string): string {
126
126
  const aikido = yaml_document.parse_yaml(existing)
127
127
  const safe_chain = read_mapping(aikido['safe-chain'], 'safe-chain')
128
128
  const merged = merge_age(merge_exclusions(safe_chain, exclusions), workspace)
129
- if (JSON.stringify(merged) === JSON.stringify(safe_chain)) return existing
130
- if (has_nothing_to_write(existing, exclusions, workspace)) return existing
129
+
130
+ if (
131
+ JSON.stringify(merged) === JSON.stringify(safe_chain) ||
132
+ has_nothing_to_write(existing, exclusions, workspace)
133
+ ) {
134
+ return existing
135
+ }
131
136
 
132
137
  return replace_safe_chain_block(existing, merged)
133
138
  }
@@ -65,8 +65,14 @@ function hook_write_warning(
65
65
  filename: string,
66
66
  ): string | undefined {
67
67
  const installed_version = installed_consumer_version(project_root)
68
- if (writing_version === undefined || installed_version === undefined) return undefined
69
- if (is_safe_to_write_hooks(writing_version, installed_version)) return undefined
68
+
69
+ if (
70
+ writing_version === undefined ||
71
+ installed_version === undefined ||
72
+ is_safe_to_write_hooks(writing_version, installed_version)
73
+ ) {
74
+ return undefined
75
+ }
70
76
 
71
77
  return outdated_install_warning(writing_version, installed_version, filename)
72
78
  }
@@ -186,8 +186,9 @@ function worker_arguments(
186
186
  vitest_arguments: ReadonlyArray<string>,
187
187
  share: number | undefined,
188
188
  ): Array<string> {
189
- if (share === undefined) return []
190
- if (vitest_arguments.some((argument) => is_worker_flag(argument))) return []
189
+ if (share === undefined || vitest_arguments.some((argument) => is_worker_flag(argument))) {
190
+ return []
191
+ }
191
192
 
192
193
  return [`${WORKER_FLAG}=${String(share)}`]
193
194
  }
@@ -51,12 +51,12 @@ type NamedCall = Pick<Span, 'is_bundleable' | 'is_writing' | 'label' | 'targets'
51
51
  const RULE_POINTER = '`prompts/collaboration-workflow/turn-batching.md`'
52
52
  const STREAK = `${String(CONSECUTIVE_LIMIT)} single-call turns in a row`
53
53
 
54
- // Includes what to do when the turn was already batching — the guard cannot see the turn it interrupted.
54
+ // Includes what to do when there is nothing to pair — the guard cannot see the turn it interrupted.
55
55
  const REASON =
56
56
  `⛔ batching: ${STREAK}. Reissue this with the calls that do not need its result — the test is ` +
57
57
  `whether a call's input needs another call's result, not what kind of call it is (edits are covered ` +
58
- `exactly as reads), and it never authorizes weakening a verification gate or a review. Already ` +
59
- `batching, or nothing to pair? Reissue it unchanged; this fires once per streak. ${RULE_POINTER}`
58
+ `exactly as reads); it never authorizes weakening a verification gate or a review. Nothing to ` +
59
+ `pair? Reissue it unchanged. ${RULE_POINTER}`
60
60
 
61
61
  // A whole-file write earns a notice, not a refusal: a `Write` cannot be refused safely.
62
62
  const NOTICE =
@@ -68,8 +68,9 @@ function fork_path(transcript_path: string, agent_id: string): string {
68
68
  // **A path that is not a transcript is answered with itself**, since nothing can be derived from it and
69
69
  // guessing would name a file in some other layout entirely.
70
70
  function transcript_of(transcript_path: string, agent_id: string | null | undefined): string {
71
- if (!is_agent_id(agent_id)) return transcript_path
72
- if (!transcript_path.endsWith(TRANSCRIPT_EXTENSION)) return transcript_path
71
+ if (!is_agent_id(agent_id) || !transcript_path.endsWith(TRANSCRIPT_EXTENSION)) {
72
+ return transcript_path
73
+ }
73
74
 
74
75
  return fork_path(transcript_path, agent_id)
75
76
  }
@@ -189,6 +189,15 @@ function unquoted(command: string): string {
189
189
  return without_heredoc_bodies(command).replaceAll(QUOTED_SPAN_PATTERN, ' ')
190
190
  }
191
191
 
192
+ // Quoted text replaced by spaces of its own length, so an offset found here is the same offset in the
193
+ // command — what a caller cutting the command where the unquoted reading says to needs. `undefined`
194
+ // for a line carrying a heredoc, whose body lines a cut would read as commands of their own.
195
+ function blanked(command: string): string | undefined {
196
+ if (HEREDOC_MARKER.test(command)) return undefined
197
+
198
+ return command.replaceAll(QUOTED_SPAN_PATTERN, (span) => ' '.repeat(span.length))
199
+ }
200
+
192
201
  function discarded_commands(command: string): Array<string> {
193
202
  const plain = unquoted(command)
194
203
 
@@ -308,6 +317,7 @@ const time_shell = {
308
317
  JOSH_PREFIX,
309
318
  bash_command,
310
319
  bash_label,
320
+ blanked,
311
321
  command_segment,
312
322
  discarded_commands,
313
323
  josh_arguments,
@@ -23,8 +23,7 @@ function read_matching_version(manifest_path: string, package_name: string): str
23
23
  const parsed = package_named_version_schema.safeParse(
24
24
  json_value.parse_or_undefined(readFileSync(manifest_path, 'utf8')),
25
25
  )
26
- if (!parsed.success) return undefined
27
- if (parsed.data.name !== package_name) return undefined
26
+ if (!parsed.success || parsed.data.name !== package_name) return undefined
28
27
 
29
28
  return parsed.data.version
30
29
  }
@@ -45,10 +44,10 @@ function walk_up_for_version(start_directory: string, package_name: string): str
45
44
  function resolve_from_base(
46
45
  base_url: string,
47
46
  package_name: string,
48
- resolve_marker: string | undefined,
47
+ resolve_marker = package_name,
49
48
  ): string | undefined {
50
49
  try {
51
- return createRequire(base_url).resolve(resolve_marker ?? package_name)
50
+ return createRequire(base_url).resolve(resolve_marker)
52
51
  } catch {
53
52
  return undefined
54
53
  }