@joshuafolkken/kit 1.957.0 → 1.972.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 (91) hide show
  1. package/.claude/skills/workflow-commands/background-commands.md +3 -0
  2. package/.claude/skills/workflow-commands/backlogrun-child.md +75 -120
  3. package/.claude/skills/workflow-commands/backlogrun-lanes.md +78 -115
  4. package/.claude/skills/workflow-commands/backlogrun-progress.md +82 -146
  5. package/.claude/skills/workflow-commands/backlogrun-steps.md +173 -229
  6. package/.claude/skills/workflow-commands/backlogrun.md +3 -1
  7. package/.claude/skills/workflow-commands/followup.md +3 -0
  8. package/.claude/skills/workflow-commands/fullrun.md +3 -4
  9. package/.claude/skills/workflow-commands/observation-filing.md +31 -25
  10. package/.claude/skills/workflow-commands/pre-gate-cut.md +96 -161
  11. package/.github/workflows/pr-classification.yml +12 -1
  12. package/CLAUDE.md +3 -3
  13. package/README.md +3 -3
  14. package/dist/CLAUDE.md +3 -3
  15. package/dist/hooks/batch-guard.js +4 -4
  16. package/dist/hooks/{chunk-XSL4SRMA.js → chunk-2K52BRWV.js} +2 -2
  17. package/dist/hooks/{chunk-M6K7EQDO.js → chunk-BNWIOJFU.js} +29 -16
  18. package/dist/hooks/{chunk-KOCK5FXQ.js → chunk-BVMSPG3M.js} +3 -3
  19. package/dist/hooks/{chunk-3ON7HOYB.js → chunk-CRGJTD6E.js} +1 -1
  20. package/dist/hooks/{chunk-HJXQZ7V4.js → chunk-F6OM6SIG.js} +3 -3
  21. package/dist/hooks/{chunk-LDQDZYHX.js → chunk-KQG4776P.js} +1 -1
  22. package/dist/hooks/{chunk-244WYOEO.js → chunk-NXL3SBXY.js} +2 -2
  23. package/dist/hooks/{chunk-IJPBTDTB.js → chunk-WWTMHHZU.js} +59 -18
  24. package/dist/hooks/{chunk-BELI5XSS.js → chunk-ZPLXLJ7O.js} +15 -5
  25. package/dist/hooks/{chunk-GNOITLRR.js → chunk-ZYYSBNR6.js} +4 -4
  26. package/dist/hooks/codex-hook-adapter.js +10 -10
  27. package/dist/hooks/duplicate-read-guard.js +5 -5
  28. package/dist/hooks/format-edited.js +2 -2
  29. package/dist/hooks/investigation-guard.js +5 -5
  30. package/dist/hooks/pretool-guard.js +9 -9
  31. package/dist/hooks/stop-guard.js +206 -176
  32. package/dist/josh.js +8 -4
  33. package/package.json +3 -3
  34. package/prompts/collaboration-workflow/wip-cap.md +1 -1
  35. package/scripts/backlog/backlog-arrival.ts +11 -1
  36. package/scripts/backlog/backlog-budget.ts +25 -8
  37. package/scripts/backlog/backlog-next.ts +45 -2
  38. package/scripts/backlog/backlog-offer.ts +1 -0
  39. package/scripts/backlog/backlog-plan-cli.ts +66 -27
  40. package/scripts/backlog/backlog-plan.ts +30 -2
  41. package/scripts/backlog/backlog-stalled.ts +9 -0
  42. package/scripts/backlog/backlog-waves.ts +169 -0
  43. package/scripts/document/document-byte-budget.ts +1 -1
  44. package/scripts/document/entry-read-budget.ts +7 -5
  45. package/scripts/epic/epic-lane-offer.ts +69 -4
  46. package/scripts/epic/epic-next.ts +2 -2
  47. package/scripts/epic/epic-solo.ts +147 -0
  48. package/scripts/epic/epic-triage.ts +47 -0
  49. package/scripts/gate/hook-gate-reuse.ts +7 -8
  50. package/scripts/git/git-closes-keyword.ts +15 -0
  51. package/scripts/git/git-followup-flush.ts +5 -6
  52. package/scripts/git/git-followup-issue-close.ts +75 -0
  53. package/scripts/git/git-pr-followup-wrapup.ts +36 -6
  54. package/scripts/git/git-pr-followup.ts +9 -16
  55. package/scripts/git/git-staging.ts +33 -20
  56. package/scripts/git/issue-labels.ts +23 -0
  57. package/scripts/hooks/stop-guard.ts +1 -0
  58. package/scripts/init/init-install.ts +64 -0
  59. package/scripts/init/init.ts +38 -11
  60. package/scripts/issue/issue-closing-pr.ts +93 -0
  61. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +2 -1
  62. package/scripts/josh/josh-commands-project.ts +5 -1
  63. package/scripts/josh/josh-commands-workflow.ts +3 -3
  64. package/scripts/observations/observation-ledger-prepare.ts +31 -0
  65. package/scripts/observations/observation-ledger.ts +5 -5
  66. package/scripts/observations/observations-flush.ts +11 -30
  67. package/scripts/rules/implementation-cut.ts +49 -16
  68. package/scripts/rules/lane-background.ts +25 -5
  69. package/scripts/rules/stop-rules.ts +16 -4
  70. package/scripts/run/run-carry-cli.ts +21 -0
  71. package/scripts/run/run-cut-cli.ts +92 -110
  72. package/scripts/run/run-cut-report.ts +167 -0
  73. package/scripts/run/run-cut.ts +23 -7
  74. package/scripts/run/run-entry-cli.ts +36 -2
  75. package/scripts/run/run-headless.ts +1 -0
  76. package/scripts/run/run-hold-cli.ts +36 -15
  77. package/scripts/run/run-hold.ts +15 -4
  78. package/scripts/run/run-merge-cli.ts +28 -14
  79. package/scripts/run/run-merge-steps.ts +38 -2
  80. package/scripts/run/run-merge.ts +41 -3
  81. package/scripts/run/run-prep-cli.ts +8 -2
  82. package/scripts/run/run-prep-locate.ts +165 -0
  83. package/scripts/run/run-prep.ts +12 -1
  84. package/scripts/run/run-step-cli.ts +16 -0
  85. package/scripts/run/run-step.ts +28 -4
  86. package/scripts/run/run-tail-cli.ts +4 -2
  87. package/scripts/run/run-wake-work.ts +4 -0
  88. package/scripts/safe-chain/ci-installer-pin.ts +8 -4
  89. package/scripts/time-runtime/time-background.ts +11 -0
  90. package/scripts/time-runtime/time-transcript-line.ts +4 -0
  91. package/templates/pnpm-workspace.static.yaml +2 -0
@@ -43,8 +43,14 @@ const RELEASE_FLAG = '--release'
43
43
  // cannot — clearing a record somebody else left behind — is spelled out rather than reached by
44
44
  // typing the ordinary command.
45
45
  const FORCE_FLAG = '--force'
46
+ // What `fullrun #N`'s entry claims with, so the record says the run is a `fullrun` — the one run whose
47
+ // implementation cut outside a lane resumes as `fullrun #N` (joshuafolkken/kit#2760).
48
+ const FULLRUN_FLAG = '--fullrun'
49
+ // The most a claim reads: the issue number and the `--fullrun` flag after it.
50
+ const MAX_CLAIM_ARGUMENTS = 2
46
51
  const ISSUE_NUMBER_PATTERN = /^[1-9]\d*$/u
47
- const USAGE = 'Usage: josh run:hold [<issue-number>] | josh run:release [<issue-number> | --force]'
52
+ const USAGE =
53
+ 'Usage: josh run:hold [<issue-number> [--fullrun]] | josh run:release [<issue-number> | --force]'
48
54
 
49
55
  const HOLD_VERDICT = 'hold'
50
56
  const BUSY_VERDICT = 'busy'
@@ -57,10 +63,14 @@ const HELD_VERDICT = 'held'
57
63
 
58
64
  const FORCE_RELEASE_KIND = 'force-release'
59
65
 
66
+ interface ClaimRequest {
67
+ kind: 'claim'
68
+ issue: string
69
+ is_fullrun?: boolean
70
+ }
71
+
60
72
  type HoldRequest =
61
- | { kind: 'release'; claimant: string }
62
- | { kind: typeof FORCE_RELEASE_KIND }
63
- | { kind: 'claim'; issue: string }
73
+ ClaimRequest | { kind: 'release'; claimant: string } | { kind: typeof FORCE_RELEASE_KIND }
64
74
 
65
75
  const FORCE_RELEASE_REQUEST: HoldRequest = { kind: FORCE_RELEASE_KIND }
66
76
 
@@ -68,14 +78,21 @@ function release_request(claimant: string): HoldRequest {
68
78
  return { kind: 'release', claimant }
69
79
  }
70
80
 
81
+ // The flag after a numbered claim: absent, or `--fullrun`. Anything else is a usage error.
82
+ function numbered_claim(issue: string, flag: string | undefined): HoldRequest | undefined {
83
+ if (flag === undefined) return { kind: 'claim', issue }
84
+
85
+ return flag === FULLRUN_FLAG ? { kind: 'claim', issue, is_fullrun: true } : undefined
86
+ }
87
+
71
88
  function parse_claim(argv: ReadonlyArray<string>): HoldRequest | undefined {
72
- const [first] = argv
89
+ const [first, flag] = argv
73
90
 
74
91
  if (first === undefined) return { kind: 'claim', issue: run_hold.UNNUMBERED_ISSUE }
75
92
 
76
- if (argv.length > 1 || !ISSUE_NUMBER_PATTERN.test(first)) return undefined
93
+ if (argv.length > MAX_CLAIM_ARGUMENTS || !ISSUE_NUMBER_PATTERN.test(first)) return undefined
77
94
 
78
- return { kind: 'claim', issue: first }
95
+ return numbered_claim(first, flag)
79
96
  }
80
97
 
81
98
  function parse_release_argument(first: string): HoldRequest | undefined {
@@ -135,8 +152,10 @@ async function blocking_message(read: HoldRead): Promise<string | undefined> {
135
152
 
136
153
  // A record already here is never overwritten by a claim: overwriting is what the guard exists to
137
154
  // prevent, and the person who knows the other run has ended clears it with `run:release --force`.
138
- function take_free_tree(target: string, issue: string): number {
139
- if (run_hold.create_hold(target, issue)) return report_hold()
155
+ function take_free_tree(target: string, request: ClaimRequest): number {
156
+ const is_taken = run_hold.create_hold(target, request.issue, new Date(), request.is_fullrun)
157
+
158
+ if (is_taken) return report_hold()
140
159
 
141
160
  return report_busy(run_hold.race_message(run_hold.read_hold(target), target))
142
161
  }
@@ -145,11 +164,11 @@ function take_free_tree(target: string, issue: string): number {
145
164
  // record with a plain write would tell two sessions that both found it expired that they both won —
146
165
  // the race the free path was just fixed for, reintroduced one branch over. Removing it first is what
147
166
  // turns the expired record into a free tree; the create is what decides between the two claimants.
148
- function replace_stale(target: string, issue: string, hold: RunHold): number {
167
+ function replace_stale(target: string, request: ClaimRequest, hold: RunHold): number {
149
168
  console.error(run_hold.stale_message(hold))
150
169
  run_hold.release_hold(target)
151
170
 
152
- return take_free_tree(target, issue)
171
+ return take_free_tree(target, request)
153
172
  }
154
173
 
155
174
  // A non-clean preflight verdict is not an error: it is an answer a loop branches on, exactly as
@@ -176,8 +195,8 @@ async function preflight_gate(issue: string, is_linked: boolean): Promise<number
176
195
  return report_preflight(decision)
177
196
  }
178
197
 
179
- async function claim(target: string, issue: string, is_linked: boolean): Promise<number> {
180
- const gate = await preflight_gate(issue, is_linked)
198
+ async function claim(target: string, request: ClaimRequest, is_linked: boolean): Promise<number> {
199
+ const gate = await preflight_gate(request.issue, is_linked)
181
200
 
182
201
  if (gate !== undefined) return gate
183
202
 
@@ -187,7 +206,9 @@ async function claim(target: string, issue: string, is_linked: boolean): Promise
187
206
  if (blocked !== undefined) return report_busy(blocked)
188
207
 
189
208
  const code =
190
- read.kind === 'stale' ? replace_stale(target, issue, read.hold) : take_free_tree(target, issue)
209
+ read.kind === 'stale'
210
+ ? replace_stale(target, request, read.hold)
211
+ : take_free_tree(target, request)
191
212
 
192
213
  if (code === SUCCESS_EXIT_CODE) await run_tidy_cli.sweep()
193
214
 
@@ -262,7 +283,7 @@ async function read_worktree(): Promise<string | undefined> {
262
283
  }
263
284
 
264
285
  async function dispatch(request: HoldRequest, target: string, is_linked: boolean): Promise<number> {
265
- if (request.kind === 'claim') return await claim(target, request.issue, is_linked)
286
+ if (request.kind === 'claim') return await claim(target, request, is_linked)
266
287
 
267
288
  if (request.kind === FORCE_RELEASE_KIND) return force_release(target)
268
289
 
@@ -43,6 +43,11 @@ interface RunHold {
43
43
  issue: string
44
44
  taken_at: string
45
45
  pid: number
46
+ // Set only by `fullrun #N`'s entry (`run:entry` claims with `--fullrun`, joshuafolkken/kit#2760).
47
+ // The implementation cut outside a lane resumes as `fullrun #N`, so a `halfrun` or an in-session
48
+ // `backlogrun` child — which hold the tree just the same — must not be read as one. Optional and
49
+ // `| undefined` because a record written before this field existed is simply not a `fullrun`'s.
50
+ is_fullrun?: boolean | undefined
46
51
  }
47
52
 
48
53
  type HoldRead =
@@ -81,6 +86,7 @@ const run_hold_schema = z.object({
81
86
  issue: z.string(),
82
87
  taken_at: z.string(),
83
88
  pid: z.number(),
89
+ is_fullrun: z.boolean().optional(),
84
90
  })
85
91
 
86
92
  // `undefined` for anything that is not a well-formed record, so the caller decides what a broken one
@@ -125,8 +131,8 @@ function read_hold(target: string, now: Date = new Date()): HoldRead {
125
131
  return classify(stamp_file.read_stamp_text(target), now)
126
132
  }
127
133
 
128
- function build_hold(issue: string, now: Date): RunHold {
129
- return { issue, taken_at: now.toISOString(), pid: process.pid }
134
+ function build_hold(issue: string, now: Date, is_fullrun?: boolean): RunHold {
135
+ return { issue, taken_at: now.toISOString(), pid: process.pid, is_fullrun }
130
136
  }
131
137
 
132
138
  function write_hold(target: string, issue: string, now: Date = new Date()): RunHold {
@@ -141,8 +147,13 @@ function write_hold(target: string, issue: string, now: Date = new Date()): RunH
141
147
  // an entry point in the same second both read `free`, and a write would tell both of them they won —
142
148
  // which is the incident this guard was written after, reproduced by the guard itself. `false` means
143
149
  // the other one got there first.
144
- function create_hold(target: string, issue: string, now: Date = new Date()): boolean {
145
- return stamp_file.create_stamp(target, build_hold(issue, now))
150
+ function create_hold(
151
+ target: string,
152
+ issue: string,
153
+ now: Date = new Date(),
154
+ is_fullrun?: boolean,
155
+ ): boolean {
156
+ return stamp_file.create_stamp(target, build_hold(issue, now, is_fullrun))
146
157
  }
147
158
 
148
159
  // **A tree with uncommitted work in it is never handed over, however old its record is.** No age can
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'
3
3
  import { parseArgs } from 'node:util'
4
4
  import { api_outage } from '#scripts/agent/api-outage'
5
5
  import { CONTEXT_CUT_THRESHOLD } from '#scripts/cost-runtime/context-cut-threshold'
6
+ import { issue_closing_pr } from '#scripts/issue/issue-closing-pr'
6
7
  import { issue_state_cli } from '#scripts/issue/issue-state-cli'
7
8
  import { run_carry, type CarryOwner, type RunCarry } from './run-carry'
8
9
  import { run_ending } from './run-ending'
@@ -202,17 +203,27 @@ function read_is_outage(output: string | undefined): boolean {
202
203
  return api_outage.is_outage(run_ending.read_exit(output))
203
204
  }
204
205
 
205
- async function read_signals(ctx: MergeContext): Promise<EndingSignals> {
206
- return {
207
- is_outage: read_is_outage(ctx.output),
208
- is_cut: await run_merge_steps.has_resumable_cut(ctx.child),
209
- }
206
+ type IssueRead = Awaited<ReturnType<typeof issue_state_cli.read_issue>>
207
+
208
+ // The timeline is read only for an OPEN, unparked child — the one classification a merged closing pull
209
+ // request changes. A CLOSED child was closed by GitHub, so it must carry no `merged_pr` for
210
+ // `do_merged` to close again (joshuafolkken/kit#2769).
211
+ async function read_merged_pr(child: string, read: IssueRead): Promise<string | undefined> {
212
+ if (read.kind !== 'state' || run_merge.classify_child(read.state) !== 'failed') return undefined
213
+
214
+ return await issue_closing_pr.read_closing_pr(child)
210
215
  }
211
216
 
212
- function outcome_of(
213
- read: Awaited<ReturnType<typeof issue_state_cli.read_issue>>,
214
- signals: EndingSignals,
215
- ): ChildOutcome {
217
+ async function read_signals(ctx: MergeContext, read: IssueRead): Promise<EndingSignals> {
218
+ const [is_cut, merged_pr] = await Promise.all([
219
+ run_merge_steps.has_resumable_cut(ctx.child),
220
+ read_merged_pr(ctx.child, read),
221
+ ])
222
+
223
+ return { is_outage: read_is_outage(ctx.output), is_cut, merged_pr }
224
+ }
225
+
226
+ function outcome_of(read: IssueRead, signals: EndingSignals): ChildOutcome {
216
227
  return read.kind === 'state' ? run_merge.classify_child(read.state, signals) : 'unresolved'
217
228
  }
218
229
 
@@ -238,8 +249,8 @@ async function on_merged(ctx: MergeContext): Promise<MergeVerdict> {
238
249
  return emit(await run_merge_steps.ask_next(ctx), SUCCESS_EXIT_CODE)
239
250
  }
240
251
 
241
- async function on_failed(ctx: MergeContext): Promise<MergeVerdict> {
242
- const result = await run_merge_steps.do_failed(ctx)
252
+ async function on_failed(ctx: MergeContext, cause?: string): Promise<MergeVerdict> {
253
+ const result = await run_merge_steps.do_failed(ctx, cause)
243
254
 
244
255
  if (result.is_refused) return report_count_refused(result.carry)
245
256
 
@@ -291,7 +302,9 @@ async function on_cut(ctx: MergeContext): Promise<MergeVerdict> {
291
302
 
292
303
  if (refused !== undefined) return report_count_refused(refused)
293
304
 
294
- if (!(await run_merge_steps.resume_cut(ctx.child))) return await on_failed(ctx)
305
+ if (!(await run_merge_steps.resume_cut(ctx.child))) {
306
+ return await on_failed(ctx, run_merge_steps.CUT_RELAUNCH_CAUSE)
307
+ }
295
308
 
296
309
  await run_event_stream_emit.emit(
297
310
  run_event_stream.EVENT_KIND.CHILD_LAUNCH,
@@ -358,9 +371,10 @@ async function merge_child(ctx: MergeContext): Promise<MergeResult> {
358
371
  }
359
372
 
360
373
  const read = await issue_state_cli.read_issue(ctx.child, ctx.repo)
361
- const outcome = outcome_of(read, await read_signals(ctx))
374
+ const signals = await read_signals(ctx, read)
375
+ const outcome = outcome_of(read, signals)
362
376
 
363
- return { outcome, ...(await HANDLERS[outcome](ctx)) }
377
+ return { outcome, ...(await HANDLERS[outcome]({ ...ctx, merged_pr: signals.merged_pr })) }
364
378
  }
365
379
 
366
380
  async function run(argv: ReadonlyArray<string>): Promise<number> {
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { git_followup_issue_close } from '#scripts/git/git-followup-issue-close'
3
4
  import { git_gh_issue_write } from '#scripts/git/git-gh-issue-write'
4
5
  import { git_stash } from '#scripts/git/git-stash'
5
6
  import { IN_PROGRESS_LABEL, NEEDS_DECISION_LABEL } from '#scripts/git/issue-labels'
@@ -34,6 +35,12 @@ const LANES = '--lanes'
34
35
  const REPO_FLAG = '--repo'
35
36
  const LANE_CLOSE_OCCASION = 'lane close'
36
37
  const GIT_ENTRY = '.git'
38
+ const MERGE_CLOSER = 'pnpm josh run:merge'
39
+ const SINGLE_READ = { attempts: 1, interval_ms: 0 }
40
+ // What the driver judged when it parks a child, stated on the issue (joshuafolkken/kit#2769).
41
+ const FAILED_CAUSE = 'the child’s session ended with its issue still open and unfinished.'
42
+ const CUT_RELAUNCH_CAUSE =
43
+ 'its lane held a declared cut, but no successor could be relaunched from it (or it already was once).'
37
44
 
38
45
  // One returned child's context, gathered from the command line by the CLI.
39
46
  interface MergeContext {
@@ -46,6 +53,9 @@ interface MergeContext {
46
53
  // ending apart from a genuine child failure (joshuafolkken/kit#2240); absent leaves outage detection
47
54
  // off, so the child is classified from its GitHub state alone as before.
48
55
  output?: string | undefined
56
+ // The merged pull request that closes the child, when GitHub left the child OPEN after the merge —
57
+ // the one `merged` child whose issue this command still has to close (joshuafolkken/kit#2769).
58
+ merged_pr?: string | undefined
49
59
  }
50
60
 
51
61
  interface FailedResult {
@@ -164,6 +174,22 @@ async function post_counters(ctx: MergeContext, carry: RunCarry | undefined): Pr
164
174
  await git_gh_issue_write.issue_try_comment(ctx.epic, run_merge.counters_comment(carry))
165
175
  }
166
176
 
177
+ // A child merged while GitHub left it OPEN is closed here, through the one close `followup` uses
178
+ // (joshuafolkken/kit#2769). The state was just read OPEN, so it is read once rather than waited on. A
179
+ // close that fails is reported with its recovery and does not undo the merge being counted.
180
+ async function close_merged_open(ctx: MergeContext): Promise<void> {
181
+ if (ctx.merged_pr === undefined) return
182
+
183
+ try {
184
+ await git_followup_issue_close.ensure_issue_closed(
185
+ { issue_number: ctx.child, pr_url: ctx.merged_pr, closer: MERGE_CLOSER },
186
+ SINGLE_READ,
187
+ )
188
+ } catch {
189
+ console.error(`#${ctx.child}: could not be closed — ${git_followup_issue_close.CLOSE_RECOVERY}`)
190
+ }
191
+ }
192
+
167
193
  // A merged child: count the merge (which resets the failure streak), return to the default branch,
168
194
  // stash any uncommitted work the lane still holds and close it, and mirror the counters onto the epic. Returns the carry when the ownership check
169
195
  // fails — the caller treats a non-undefined return as a hard refusal and must not offer a next child
@@ -173,6 +199,7 @@ async function do_merged(ctx: MergeContext): Promise<RunCarry | undefined> {
173
199
 
174
200
  if (refused !== undefined) return refused
175
201
 
202
+ await close_merged_open(ctx)
176
203
  await sync_main()
177
204
  const result = await apply_carry(ctx, {
178
205
  ...run_merge.change_of('merged'),
@@ -202,8 +229,10 @@ async function remove_in_progress(child: string): Promise<void> {
202
229
  // issue number forever, joshuafolkken/kit#2421), drop the stale `in-progress`, and park it with
203
230
  // `needs-decision` so the next offer does not hand the same child straight back. Returns the record
204
231
  // so the caller can read the streak against the guard. Returns `is_refused: true` without touching labels when the
205
- // carry record rejected the count (joshuafolkken/kit#2114).
206
- async function do_failed(ctx: MergeContext): Promise<FailedResult> {
232
+ // carry record rejected the count (joshuafolkken/kit#2114). A park is always explained on the issue —
233
+ // `cause` says what was judged, and the comment what was read and what a person does next
234
+ // (joshuafolkken/kit#2769).
235
+ async function do_failed(ctx: MergeContext, cause: string = FAILED_CAUSE): Promise<FailedResult> {
207
236
  const result = await apply_carry(ctx, run_merge.change_of('failed'))
208
237
 
209
238
  if (result.kind === 'refused') return { carry: result.carry, is_parked: false, is_refused: true }
@@ -214,6 +243,12 @@ async function do_failed(ctx: MergeContext): Promise<FailedResult> {
214
243
  await remove_in_progress(ctx.child)
215
244
  const is_parked = await git_gh_issue_write.issue_add_label(ctx.child, NEEDS_DECISION_LABEL)
216
245
 
246
+ if (is_parked) {
247
+ const reason = { child: ctx.child, cause, carry, output: ctx.output }
248
+
249
+ await git_gh_issue_write.issue_try_comment(ctx.child, run_merge.park_comment(reason))
250
+ }
251
+
217
252
  return { carry, is_parked, is_refused: false }
218
253
  }
219
254
 
@@ -311,6 +346,7 @@ async function ask_next(ctx: MergeContext): Promise<string> {
311
346
  }
312
347
 
313
348
  const run_merge_steps = {
349
+ CUT_RELAUNCH_CAUSE,
314
350
  apply_carry,
315
351
  ask_next,
316
352
  do_failed,
@@ -32,7 +32,9 @@ const ONE = 1
32
32
 
33
33
  // What a returned child turned out to be, decided from its GitHub state and — for the failed case
34
34
  // alone — the exit record.
35
- // - `merged` — CLOSED; the child finished and its pull request merged.
35
+ // - `merged` — CLOSED; the child finished and its pull request merged. Also an OPEN, unparked
36
+ // child a merged pull request's `closes #N` names — GitHub merged it without closing
37
+ // the issue (joshuafolkken/kit#2769).
36
38
  // - `human-review` — OPEN and carrying `needs-human-review`; the run's own ending (SKILL.md → §2z).
37
39
  // - `parked` — OPEN and carrying `needs-decision` or `already-done`; a person still owns it.
38
40
  // - `split` — OPEN and carrying `epic`; its work was divided into new children.
@@ -60,6 +62,9 @@ type ChildOutcome =
60
62
  interface EndingSignals {
61
63
  is_outage: boolean
62
64
  is_cut: boolean
65
+ // The merged pull request whose `closes #N` names the child, when GitHub left the child OPEN after
66
+ // merging it (joshuafolkken/kit#2769).
67
+ merged_pr?: string | undefined
63
68
  }
64
69
 
65
70
  const NO_SIGNALS: EndingSignals = { is_outage: false, is_cut: false }
@@ -82,8 +87,12 @@ function is_parked(state: IssueState): boolean {
82
87
  // An OPEN, unparked child that is not waiting on a person: a declared cut is resumed
83
88
  // (joshuafolkken/kit#2484), and it is read before the outage so a cut whose process then lost the API is
84
89
  // still resumed from its record; an OPEN, unparked child that could not reach the API is an `outage`,
85
- // not a `failed` (joshuafolkken/kit#2240).
90
+ // not a `failed` (joshuafolkken/kit#2240). **A merged pull request that closes it is read first**: the
91
+ // work landed and only GitHub's close did not, so it is `merged` — never a failure counted against the
92
+ // guard (joshuafolkken/kit#2769).
86
93
  function unfinished_outcome(signals: EndingSignals): ChildOutcome {
94
+ if (signals.merged_pr !== undefined) return 'merged'
95
+
87
96
  if (signals.is_cut) return 'cut'
88
97
 
89
98
  return signals.is_outage ? 'outage' : 'failed'
@@ -152,15 +161,44 @@ function counters_comment(carry: RunCarry): string {
152
161
  ].join('\n')
153
162
  }
154
163
 
164
+ // Why the run parked a child it judged failed (joshuafolkken/kit#2769). A child that stops itself
165
+ // leaves its own comment; one the driver stops left nothing, so a person opening the issue could not
166
+ // tell why it carried `needs-decision`. Script-emitted, so English.
167
+ interface ParkReason {
168
+ child: string
169
+ cause: string
170
+ carry: RunCarry | undefined
171
+ output: string | undefined
172
+ }
173
+
174
+ function streak_line(carry: RunCarry | undefined): string {
175
+ if (carry === undefined) return '- Consecutive failures: not recorded (no carry record).'
176
+
177
+ return `- Consecutive failures: ${String(carry.failures)} of ${String(CONSECUTIVE_FAILURE_LIMIT)} (the run stops at ${String(CONSECUTIVE_FAILURE_LIMIT)}).`
178
+ }
179
+
180
+ function park_comment(reason: ParkReason): string {
181
+ const transcript = reason.output === undefined ? '' : ` (\`${reason.output}\`)`
182
+
183
+ return [
184
+ 'Parked with `needs-decision` by `pnpm josh run:merge`, the `backlogrun` driver — not by the child itself.',
185
+ `- Why: ${reason.cause}`,
186
+ '- Read: the issue is OPEN, carries none of `needs-decision`, `already-done`, `needs-human-review` or `epic`, and no merged pull request’s `closes #N` names it.',
187
+ streak_line(reason.carry),
188
+ `- Next: read the child’s transcript${transcript}, then either resume it with \`fullrun #${reason.child}\`, or record the decision here and remove \`needs-decision\`.`,
189
+ ].join('\n')
190
+ }
191
+
155
192
  const run_merge = {
156
193
  CONSECUTIVE_FAILURE_LIMIT,
157
194
  CONSECUTIVE_OUTAGE_LIMIT,
158
195
  change_of,
159
196
  classify_child,
160
197
  counters_comment,
198
+ park_comment,
161
199
  is_guard_tripped,
162
200
  is_outage_guard_tripped,
163
201
  }
164
202
 
165
- export type { ChildOutcome, EndingSignals }
203
+ export type { ChildOutcome, EndingSignals, ParkReason }
166
204
  export { run_merge }
@@ -6,10 +6,12 @@ import { issue_state_cli, type StateRead } from '#scripts/issue/issue-state-cli'
6
6
  import { lane_child_marker } from '#scripts/lane/lane-child-marker'
7
7
  import { latest_scope_cli } from '#scripts/version/latest-scope-cli'
8
8
  import { run_prep, type PrepParts } from './run-prep'
9
+ import { run_prep_locate } from './run-prep-locate'
9
10
 
10
11
  // `josh run:prep <N>` — one call for the reads a `fullrun` makes before its first edit
11
12
  // (joshuafolkken/kit#1978): the issue body and comments (`issue:read`), the state, labels and
12
- // `human_review` line (`issue:state`), and the dependency-update scope (`latest:scope`). Each is
13
+ // `human_review` line (`issue:state`), the dependency-update scope (`latest:scope`), and where the
14
+ // paths and identifiers the body names occur in code (`run-prep-locate.ts`). Each is
13
15
  // reused rather than reproduced, and the two body reads run concurrently, so three round trips become
14
16
  // one.
15
17
  //
@@ -37,6 +39,7 @@ interface PrepReads {
37
39
  state: StateRead
38
40
  latest: LatestDecision
39
41
  has_changes: boolean
42
+ locations: string
40
43
  }
41
44
 
42
45
  // Exactly one issue number, or the call is refused: `run:prep` prepares one run, and a second number
@@ -90,8 +93,10 @@ async function gather(issue_number: string): Promise<PrepReads> {
90
93
  issue_state_cli.read_issue(issue_number),
91
94
  lane_has_changes(),
92
95
  ])
96
+ // Located from the body just read, so it waits for that read rather than joining the batch above.
97
+ const locations = await run_prep_locate.locate(content.kind === 'ok' ? content.block : '')
93
98
 
94
- return { content, state, latest: latest_decision(), has_changes }
99
+ return { content, state, latest: latest_decision(), has_changes, locations }
95
100
  }
96
101
 
97
102
  function content_body(issue_number: string, content: BlockRead): string {
@@ -110,6 +115,7 @@ function to_parts(issue_number: string, reads: PrepReads): PrepParts {
110
115
  latest_scope: reads.latest.scope,
111
116
  latest_reason: reads.latest.reason,
112
117
  has_changes: reads.has_changes,
118
+ locations: reads.locations,
113
119
  }
114
120
  }
115
121
 
@@ -0,0 +1,165 @@
1
+ import { get_exit_code } from '#scripts/git/git-execa-error'
2
+ import { git_spawn } from '#scripts/git/git-spawn'
3
+
4
+ // The code locations an issue names, bundled into `run:prep` (joshuafolkken/kit#2761). Across the 90
5
+ // runs `.time-history.jsonl` split by contributor, 53% of round trips were investigation, and most of
6
+ // it opened the files an issue names one read at a time. This finds each path, identifier and command
7
+ // the body puts in backticks and hands back where it occurs in code — `file:line` and a short excerpt,
8
+ // never a whole file — so the first read lands on the right place.
9
+ //
10
+ // **Only a backticked span is a target.** A prose word is not a name, and a span holding a space is a
11
+ // command line or a sentence rather than one thing to look up; the one exception is a `josh` command,
12
+ // whose subcommand is the name.
13
+
14
+ const BACKTICKED = /`([^`\n]+)`/gu
15
+ const JOSH_PREFIX = /^(?:pnpm\s+)?josh\s+(\S+)/u
16
+ // A `file:line` citation names the file; the suffix would make both the file lookup and the grep miss.
17
+ const LINE_SUFFIX = /(?::\d+)+$/u
18
+ // A name carries a separator a prose word does not — `run:prep`, `run_prep`, `run-prep.ts`, `a/b`.
19
+ const NAME_CHARACTERS = /^[\w@./:-]+$/u
20
+ const NAME_SEPARATOR = /[./:_-]/u
21
+ const GREP_LINE = /^(?<path>[^:]+):(?<line>\d+):(?<text>.*)$/u
22
+ const MIN_TARGET_LENGTH = 3
23
+ const MAX_TARGETS = 10
24
+ const MAX_FILES_PER_TARGET = 3
25
+ const MAX_EXCERPT_LENGTH = 80
26
+ const ELLIPSIS = '…'
27
+ const INDENT = ' '
28
+ const HIT_SEPARATOR = ' '
29
+ const NO_MATCH = ' — no match in code'
30
+ const SEARCH_FAILED = ' — git grep failed; not searched'
31
+ // `git grep` exits 1 for no match; any other exit is a search that never ran.
32
+ const GREP_NO_MATCH_EXIT = 1
33
+ const FILE_PATHSPEC = ':(glob)**/'
34
+ const FILE_HIT = { line: '1', excerpt: '(file named in the issue)' }
35
+ const NO_TARGETS = '(no backticked path or identifier in the issue)'
36
+ // Code only, tests and snapshots excluded: a document mentions a name far more often than code
37
+ // defines it, and the three-file cap would fill with prose before reaching the implementation.
38
+ const CODE_PATHS = [
39
+ '*.ts',
40
+ '*.js',
41
+ '*.svelte',
42
+ ':(exclude)*.test.ts',
43
+ ':(exclude)*.e2e.ts',
44
+ ':(exclude)*.snap',
45
+ ]
46
+
47
+ interface Hit {
48
+ path: string
49
+ line: string
50
+ excerpt: string
51
+ }
52
+
53
+ interface Located {
54
+ target: string
55
+ hits: ReadonlyArray<Hit>
56
+ has_failed: boolean
57
+ }
58
+
59
+ function to_target(span: string): string {
60
+ const josh = JOSH_PREFIX.exec(span)
61
+
62
+ return josh?.[1] ?? span.trim().replace(LINE_SUFFIX, '')
63
+ }
64
+
65
+ function is_name(target: string): boolean {
66
+ if (target.length < MIN_TARGET_LENGTH || target.includes('://')) return false
67
+
68
+ return NAME_CHARACTERS.test(target) && NAME_SEPARATOR.test(target)
69
+ }
70
+
71
+ function extract_targets(text: string): Array<string> {
72
+ const targets = [...text.matchAll(BACKTICKED)].map((match) => to_target(match[1] ?? ''))
73
+
74
+ return [...new Set(targets.filter((target) => is_name(target)))].slice(0, MAX_TARGETS)
75
+ }
76
+
77
+ function shorten(text: string): string {
78
+ const trimmed = text.trim()
79
+
80
+ if (trimmed.length <= MAX_EXCERPT_LENGTH) return trimmed
81
+
82
+ return `${trimmed.slice(0, MAX_EXCERPT_LENGTH)}${ELLIPSIS}`
83
+ }
84
+
85
+ // `git grep -n` prints `path:line:text`; the text may itself hold colons, so only the first two split.
86
+ function to_hit(output_line: string): Hit | undefined {
87
+ const groups = GREP_LINE.exec(output_line)?.groups
88
+
89
+ if (groups === undefined) return undefined
90
+
91
+ // All three groups are non-optional in `GREP_LINE`, so a match always carries each of them.
92
+ const { path, line, text } = groups as Record<'line' | 'path' | 'text', string>
93
+
94
+ return { path, line, excerpt: shorten(text) }
95
+ }
96
+
97
+ function parse_hits(stdout: string): Array<Hit> {
98
+ const hits = stdout.split('\n').map((line) => to_hit(line))
99
+
100
+ return hits.filter((hit): hit is Hit => hit !== undefined).slice(0, MAX_FILES_PER_TARGET)
101
+ }
102
+
103
+ function format_target(located: Located): string {
104
+ if (located.has_failed) return `${located.target}${SEARCH_FAILED}`
105
+ if (located.hits.length === 0) return `${located.target}${NO_MATCH}`
106
+
107
+ const lines = located.hits.map(
108
+ (hit) => `${INDENT}${hit.path}:${hit.line}${HIT_SEPARATOR}${hit.excerpt}`,
109
+ )
110
+
111
+ return [located.target, ...lines].join('\n')
112
+ }
113
+
114
+ function format_locations(located: ReadonlyArray<Located>): string {
115
+ if (located.length === 0) return NO_TARGETS
116
+
117
+ return located.map((entry) => format_target(entry)).join('\n')
118
+ }
119
+
120
+ // `git grep` exits 1 when nothing matches, which `git_spawn.read` raises. Neither that nor a search
121
+ // that could not run fails the bundle — the issue body is still worth printing without it — but only
122
+ // the first is reported as "no match": a git without `grep -m` (older than 2.38) exits 129, and naming
123
+ // every target absent then would send the reader away from code that is there.
124
+ async function grep(target: string): Promise<Located> {
125
+ const grep_arguments = ['grep', '-n', '-I', '-F', '-m', '1', '-e', target, '--', ...CODE_PATHS]
126
+
127
+ try {
128
+ return { target, hits: parse_hits(await git_spawn.read(grep_arguments)), has_failed: false }
129
+ } catch (error) {
130
+ return { target, hits: [], has_failed: get_exit_code(error) !== GREP_NO_MATCH_EXIT }
131
+ }
132
+ }
133
+
134
+ // A path is rarely spelled out inside code — imports drop the extension and resolve relatively — so
135
+ // grepping `scripts/run/run-prep.ts` misses the very file an issue most often names. A tracked file
136
+ // whose path ends in the target is the location itself; `**/` lets a bare file name match too.
137
+ async function find_files(target: string): Promise<Array<Hit>> {
138
+ try {
139
+ const stdout = await git_spawn.read(['ls-files', '--', `${FILE_PATHSPEC}${target}`])
140
+ const paths = stdout.split('\n').filter((path) => path !== '')
141
+
142
+ return paths.slice(0, MAX_FILES_PER_TARGET).map((path) => ({ path, ...FILE_HIT }))
143
+ } catch {
144
+ return []
145
+ }
146
+ }
147
+
148
+ async function find(target: string): Promise<Located> {
149
+ const files = await find_files(target)
150
+
151
+ if (files.length > 0) return { target, hits: files, has_failed: false }
152
+
153
+ return await grep(target)
154
+ }
155
+
156
+ async function locate(text: string): Promise<string> {
157
+ const located = await Promise.all(extract_targets(text).map(async (target) => await find(target)))
158
+
159
+ return format_locations(located)
160
+ }
161
+
162
+ const run_prep_locate = { extract_targets, format_locations, locate, parse_hits }
163
+
164
+ export type { Hit, Located }
165
+ export { run_prep_locate }
@@ -23,6 +23,7 @@ const NO = 'no'
23
23
  const CONTENT_HEADER = '=== issue ==='
24
24
  const STATE_HEADER = '=== state ==='
25
25
  const LATEST_HEADER = '=== dependency update ==='
26
+ const LOCATIONS_HEADER = '=== code locations ==='
26
27
  const SECTION_SEPARATOR = '\n\n'
27
28
  const LATEST_JOINER = ' — '
28
29
 
@@ -35,6 +36,8 @@ interface PrepParts {
35
36
  state_failure: string
36
37
  latest_scope: string
37
38
  latest_reason: string
39
+ // Where the paths and identifiers the issue names occur in code (joshuafolkken/kit#2761).
40
+ locations: string
38
41
  // Whether a lane child's tree holds uncommitted work — read for the verdict, not printed in the
39
42
  // report (joshuafolkken/kit#2476).
40
43
  has_changes: boolean
@@ -75,10 +78,18 @@ function format_report(parts: PrepParts): string {
75
78
  section(CONTENT_HEADER, parts.content_body),
76
79
  section(STATE_HEADER, state_body(parts)),
77
80
  section(LATEST_HEADER, `${parts.latest_scope}${LATEST_JOINER}${parts.latest_reason}`),
81
+ section(LOCATIONS_HEADER, parts.locations),
78
82
  ].join(SECTION_SEPARATOR)
79
83
  }
80
84
 
81
- const run_prep = { CONTENT_HEADER, LATEST_HEADER, STATE_HEADER, SUMMARY_PREFIX, format_report }
85
+ const run_prep = {
86
+ CONTENT_HEADER,
87
+ LATEST_HEADER,
88
+ LOCATIONS_HEADER,
89
+ STATE_HEADER,
90
+ SUMMARY_PREFIX,
91
+ format_report,
92
+ }
82
93
 
83
94
  export type { PrepParts }
84
95
  export { run_prep }