@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
@@ -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
  }
@@ -38,6 +37,8 @@ async function announce(notice: StopNotice): Promise<boolean> {
38
37
  issue_title: STOP_TITLE,
39
38
  body: notice.reason,
40
39
  recovery: STOP_RECOVERY,
40
+ recommended: 'Resolve the reason above, then run `backlogrun` again',
41
+ alternatives: ['Leave the backlog stopped until a later session'],
41
42
  })
42
43
  }
43
44
 
@@ -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
  }
@@ -13,8 +13,14 @@ const SELF_DIRECTORY = path.dirname(fileURLToPath(import.meta.url))
13
13
  function read_shadow_warning(): string | undefined {
14
14
  const path_josh = doctor_io.resolve_path_josh()
15
15
  const global_josh = doctor_io.resolve_pnpm_global_josh()
16
- if (path_josh === undefined || global_josh === undefined) return undefined
17
- if (!doctor_logic.is_shadowed(path_josh, global_josh)) return undefined
16
+
17
+ if (
18
+ path_josh === undefined ||
19
+ global_josh === undefined ||
20
+ !doctor_logic.is_shadowed(path_josh, global_josh)
21
+ ) {
22
+ return undefined
23
+ }
18
24
 
19
25
  return doctor_logic.format_shadow_warning(path_josh, global_josh)
20
26
  }
@@ -259,8 +259,7 @@ function restore_package_json(
259
259
 
260
260
  function decode_integrity(encoded: string): string | undefined {
261
261
  const bytes = Buffer.from(encoded, 'base64')
262
- if (bytes.length !== SHA512_BYTES) return undefined
263
- if (bytes.toString('base64') !== encoded) return undefined
262
+ if (bytes.length !== SHA512_BYTES || bytes.toString('base64') !== encoded) return undefined
264
263
 
265
264
  return `sha512.${bytes.toString('hex')}`
266
265
  }
@@ -51,8 +51,10 @@ function resolve_installable(
51
51
  minimum_age_minutes: number,
52
52
  now_ms: number,
53
53
  ): string | undefined {
54
- if (times === undefined) return undefined
55
- if (!is_latest_withheld(times, latest, minimum_age_minutes, now_ms)) return undefined
54
+ if (times === undefined || !is_latest_withheld(times, latest, minimum_age_minutes, now_ms)) {
55
+ return undefined
56
+ }
57
+
56
58
  const installable = release_age.select_aged_version(times, undefined, minimum_age_minutes, now_ms)
57
59
 
58
60
  return installable === latest ? undefined : installable
@@ -122,8 +124,13 @@ function build_release_hold_notes(
122
124
  installable: string | undefined,
123
125
  minimum_age_minutes: number,
124
126
  ): Array<string> {
125
- if (version === undefined || installable === undefined) return []
126
- if (!is_release_age_hold(version, latest, installable)) return []
127
+ if (
128
+ version === undefined ||
129
+ installable === undefined ||
130
+ !is_release_age_hold(version, latest, installable)
131
+ ) {
132
+ return []
133
+ }
127
134
 
128
135
  return [format_release_hold_note(version, latest, installable, minimum_age_minutes)]
129
136
  }
@@ -101,8 +101,10 @@ function build_upstream_effective(
101
101
  context: UpstreamHookContext,
102
102
  ): UpstreamEffective | undefined {
103
103
  const { resolve_effective_version, resolve_global_upgrade_command } = upstream
104
- if (resolve_effective_version === undefined) return undefined
105
- if (resolve_global_upgrade_command === undefined) return undefined
104
+
105
+ if (resolve_effective_version === undefined || resolve_global_upgrade_command === undefined) {
106
+ return undefined
107
+ }
106
108
 
107
109
  return {
108
110
  version: resolve_effective_version(context),
@@ -264,8 +266,14 @@ function describe_effective_outcome(
264
266
  snapshot: VersionSnapshot,
265
267
  ): Array<string> {
266
268
  const { resolve_effective_version } = upstream
267
- if (resolve_effective_version === undefined || report === undefined) return []
268
- if (version_check_logic.build_effective_upgrade_commands(report).length === 0) return []
269
+
270
+ if (
271
+ resolve_effective_version === undefined ||
272
+ report === undefined ||
273
+ version_check_logic.build_effective_upgrade_commands(report).length === 0
274
+ ) {
275
+ return []
276
+ }
269
277
 
270
278
  const after = resolve_effective_version({
271
279
  latest: snapshot.latest,