@joshuafolkken/kit 1.957.0 → 1.978.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 (102) 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 +4 -4
  9. package/.claude/skills/workflow-commands/halfrun.md +12 -7
  10. package/.claude/skills/workflow-commands/observation-filing.md +31 -25
  11. package/.claude/skills/workflow-commands/pre-gate-cut.md +96 -161
  12. package/.claude/skills/workflow-commands/working-tree-hold.md +16 -1
  13. package/.github/workflows/pr-classification.yml +12 -1
  14. package/CLAUDE.md +3 -3
  15. package/README.md +25 -21
  16. package/dist/CLAUDE.md +3 -3
  17. package/dist/hooks/batch-guard.js +4 -4
  18. package/dist/hooks/{chunk-XSL4SRMA.js → chunk-2K52BRWV.js} +2 -2
  19. package/dist/hooks/{chunk-M6K7EQDO.js → chunk-BNGGEY3Z.js} +38 -32
  20. package/dist/hooks/{chunk-KOCK5FXQ.js → chunk-BVMSPG3M.js} +3 -3
  21. package/dist/hooks/{chunk-3ON7HOYB.js → chunk-CRGJTD6E.js} +1 -1
  22. package/dist/hooks/{chunk-HJXQZ7V4.js → chunk-F6OM6SIG.js} +3 -3
  23. package/dist/hooks/{chunk-LDQDZYHX.js → chunk-KQG4776P.js} +1 -1
  24. package/dist/hooks/{chunk-244WYOEO.js → chunk-NXL3SBXY.js} +2 -2
  25. package/dist/hooks/{chunk-IJPBTDTB.js → chunk-Q7LE77CO.js} +88 -18
  26. package/dist/hooks/{chunk-BELI5XSS.js → chunk-ZPLXLJ7O.js} +15 -5
  27. package/dist/hooks/{chunk-GNOITLRR.js → chunk-ZYYSBNR6.js} +4 -4
  28. package/dist/hooks/codex-hook-adapter.js +10 -10
  29. package/dist/hooks/duplicate-read-guard.js +5 -5
  30. package/dist/hooks/format-edited.js +2 -2
  31. package/dist/hooks/investigation-guard.js +5 -5
  32. package/dist/hooks/pretool-guard.js +9 -9
  33. package/dist/hooks/stop-guard.js +206 -176
  34. package/dist/josh.js +8 -4
  35. package/package.json +4 -4
  36. package/prompts/collaboration-workflow/wip-cap.md +1 -1
  37. package/scripts/backlog/backlog-arrival.ts +11 -1
  38. package/scripts/backlog/backlog-budget.ts +25 -8
  39. package/scripts/backlog/backlog-next.ts +45 -2
  40. package/scripts/backlog/backlog-offer.ts +1 -0
  41. package/scripts/backlog/backlog-plan-cli.ts +66 -27
  42. package/scripts/backlog/backlog-plan.ts +30 -2
  43. package/scripts/backlog/backlog-stalled.ts +9 -0
  44. package/scripts/backlog/backlog-waves.ts +169 -0
  45. package/scripts/ci/pr-classification.ts +13 -12
  46. package/scripts/document/document-byte-budget.ts +1 -1
  47. package/scripts/document/entry-read-budget.ts +7 -5
  48. package/scripts/epic/epic-lane-offer.ts +69 -4
  49. package/scripts/epic/epic-next.ts +2 -2
  50. package/scripts/epic/epic-solo.ts +147 -0
  51. package/scripts/epic/epic-triage.ts +47 -0
  52. package/scripts/gate/hook-gate-reuse.ts +7 -8
  53. package/scripts/git/git-closes-keyword.ts +15 -0
  54. package/scripts/git/git-followup-flush.ts +5 -6
  55. package/scripts/git/git-followup-issue-close.ts +75 -0
  56. package/scripts/git/git-pr-followup-wrapup.ts +36 -6
  57. package/scripts/git/git-pr-followup.ts +9 -16
  58. package/scripts/git/git-staging.ts +33 -20
  59. package/scripts/git/issue-labels.ts +75 -5
  60. package/scripts/hooks/stop-guard.ts +1 -0
  61. package/scripts/init/init-bootstrap.ts +92 -0
  62. package/scripts/init/init-install.ts +67 -0
  63. package/scripts/init/init-logic-workspace.ts +1 -1
  64. package/scripts/init/init.ts +58 -13
  65. package/scripts/init/start-plan.ts +1 -1
  66. package/scripts/init/start-steps.ts +5 -28
  67. package/scripts/issue/issue-closing-pr.ts +93 -0
  68. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +2 -1
  69. package/scripts/josh/josh-commands-project.ts +5 -1
  70. package/scripts/josh/josh-commands-workflow.ts +3 -3
  71. package/scripts/observations/observation-ledger-prepare.ts +31 -0
  72. package/scripts/observations/observation-ledger.ts +5 -5
  73. package/scripts/observations/observations-flush.ts +11 -30
  74. package/scripts/repo/repository-labels.ts +91 -0
  75. package/scripts/rules/implementation-cut.ts +49 -16
  76. package/scripts/rules/lane-background.ts +25 -5
  77. package/scripts/rules/stop-rules.ts +16 -4
  78. package/scripts/run/run-carry-cli.ts +21 -0
  79. package/scripts/run/run-cut-cli.ts +92 -110
  80. package/scripts/run/run-cut-report.ts +167 -0
  81. package/scripts/run/run-cut.ts +23 -7
  82. package/scripts/run/run-entry-cli.ts +70 -3
  83. package/scripts/run/run-halfrun-resume.ts +104 -0
  84. package/scripts/run/run-headless.ts +1 -0
  85. package/scripts/run/run-hold-cli.ts +57 -13
  86. package/scripts/run/run-hold.ts +26 -4
  87. package/scripts/run/run-merge-cli.ts +28 -14
  88. package/scripts/run/run-merge-steps.ts +38 -2
  89. package/scripts/run/run-merge.ts +41 -3
  90. package/scripts/run/run-prep-cli.ts +8 -2
  91. package/scripts/run/run-prep-locate.ts +165 -0
  92. package/scripts/run/run-prep.ts +12 -1
  93. package/scripts/run/run-step-cli.ts +16 -0
  94. package/scripts/run/run-step.ts +28 -4
  95. package/scripts/run/run-tail-cli.ts +4 -2
  96. package/scripts/run/run-wake-work.ts +4 -0
  97. package/scripts/safe-chain/ci-installer-pin.ts +8 -4
  98. package/scripts/security/security-audit.ts +12 -1
  99. package/scripts/sync/sync.ts +6 -0
  100. package/scripts/time-runtime/time-background.ts +11 -0
  101. package/scripts/time-runtime/time-transcript-line.ts +4 -0
  102. package/templates/pnpm-workspace.static.yaml +2 -0
@@ -0,0 +1,104 @@
1
+ import { run_hold, type HoldRead, type RunHold } from './run-hold'
2
+
3
+ // joshuafolkken/kit#2796: a `halfrun` stops before its commit and **keeps** its hold, because the
4
+ // uncommitted, hand-verified work is exactly what a second run would trample. The person then types
5
+ // `fullrun #N` to ship it — and `run:entry`'s claim answered `busy` against that very hold, or, once the
6
+ // hold had been cleared, the preflight advised stashing the verified diff away. The hand-off the
7
+ // `halfrun` stop promised could not land, and each run improvised its own way past the guard.
8
+ //
9
+ // **The stop is recorded, never inferred.** A hold naming #N over a dirty tree is also what a `halfrun`
10
+ // still implementing and a `backlogrun` child leave, and adopting either hands a half-written diff to
11
+ // the gate and the merge. So the `halfrun` stop re-writes its own record with a stop mark
12
+ // (`run:hold <N> --halfrun-stop`) — re-keying a `halfrun new`'s unnumbered record to the issue it filed —
13
+ // and only a marked record is adopted. An expired one qualifies too, since a person may verify the next
14
+ // day. The adopted record carries the `fullrun` mark instead, so the implementation-cut guard and
15
+ // `run:cut --resume` read the tree as this `fullrun`'s from here on.
16
+
17
+ const MARKED = 'marked'
18
+ const FOREIGN = 'foreign'
19
+ const UNREADABLE = 'unreadable'
20
+
21
+ type StopMark = typeof FOREIGN | typeof MARKED | typeof UNREADABLE
22
+
23
+ function hold_of(read: HoldRead): RunHold | undefined {
24
+ return read.kind === 'held' || read.kind === 'stale' ? read.hold : undefined
25
+ }
26
+
27
+ function is_halfrun_stop(read: HoldRead, issue: string, is_dirty: boolean): boolean {
28
+ const hold = hold_of(read)
29
+
30
+ return is_dirty && hold?.issue === issue && hold.is_halfrun_stop === true
31
+ }
32
+
33
+ // The stopping run's own record names its issue, or `new` when it entered as `halfrun new`; a free tree
34
+ // is marked as well, since the work to protect is sitting in it either way.
35
+ function is_own_record(read: HoldRead, issue: string): boolean {
36
+ const hold = hold_of(read)
37
+
38
+ return hold === undefined || hold.issue === issue || hold.issue === run_hold.UNNUMBERED_ISSUE
39
+ }
40
+
41
+ function mark_stop_at(target: string, issue: string): StopMark {
42
+ const read = run_hold.read_hold(target)
43
+
44
+ if (read.kind === 'unreadable') return UNREADABLE
45
+
46
+ if (!is_own_record(read, issue)) return FOREIGN
47
+
48
+ run_hold.release_hold(target)
49
+
50
+ return run_hold.create_halfrun_stop_hold(target, issue) ? MARKED : FOREIGN
51
+ }
52
+
53
+ // `false` leaves the record untouched, so the ordinary claim that follows decides the tree exactly as
54
+ // it did before this resume existed.
55
+ function adopt_at(target: string, issue: string, is_dirty: boolean): boolean {
56
+ if (!is_halfrun_stop(run_hold.read_hold(target), issue, is_dirty)) return false
57
+
58
+ run_hold.release_hold(target)
59
+
60
+ return run_hold.create_hold(target, issue, new Date(), true)
61
+ }
62
+
63
+ // **An unreadable git directory is no resume**, never a crash: `run:entry` must still print its one
64
+ // summary line, and the ordinary claim that follows answers `unknown` for the same tree.
65
+ async function hold_target(): Promise<string | undefined> {
66
+ try {
67
+ const directory = await run_hold.worktree_directory()
68
+
69
+ return directory === undefined ? undefined : run_hold.hold_path(directory)
70
+ } catch {
71
+ return undefined
72
+ }
73
+ }
74
+
75
+ // Read-only, so `run:entry` can ask the session budget before it takes anything over.
76
+ async function is_pending(issue: string): Promise<boolean> {
77
+ const target = await hold_target()
78
+
79
+ if (target === undefined) return false
80
+
81
+ return is_halfrun_stop(run_hold.read_hold(target), issue, await run_hold.is_tree_dirty())
82
+ }
83
+
84
+ async function adopt(issue: string): Promise<boolean> {
85
+ const target = await hold_target()
86
+
87
+ if (target === undefined) return false
88
+
89
+ return adopt_at(target, issue, await run_hold.is_tree_dirty())
90
+ }
91
+
92
+ const run_halfrun_resume = {
93
+ FOREIGN,
94
+ MARKED,
95
+ UNREADABLE,
96
+ adopt,
97
+ adopt_at,
98
+ is_halfrun_stop,
99
+ is_pending,
100
+ mark_stop_at,
101
+ }
102
+
103
+ export type { StopMark }
104
+ export { run_halfrun_resume }
@@ -99,6 +99,7 @@ const run_headless = {
99
99
  environment,
100
100
  is_backlog_parent,
101
101
  is_headless,
102
+ is_owned_here,
102
103
  must_keep_waiting,
103
104
  }
104
105
 
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
+ import { run_halfrun_resume } from './run-halfrun-resume'
3
4
  import { run_hold, type HoldRead, type RunHold } from './run-hold'
4
5
  import { run_preflight, type PreflightDecision } from './run-preflight'
5
6
  import { run_tidy_cli } from './run-tidy-cli'
@@ -43,8 +44,15 @@ const RELEASE_FLAG = '--release'
43
44
  // cannot — clearing a record somebody else left behind — is spelled out rather than reached by
44
45
  // typing the ordinary command.
45
46
  const FORCE_FLAG = '--force'
47
+ // What `fullrun #N`'s entry claims with, so the record says the run is a `fullrun` — the one run whose
48
+ // implementation cut outside a lane resumes as `fullrun #N` (joshuafolkken/kit#2760).
49
+ const FULLRUN_FLAG = '--fullrun'
50
+ const HALFRUN_STOP_FLAG = '--halfrun-stop'
51
+ // The most a claim reads: the issue number and the one flag after it.
52
+ const MAX_CLAIM_ARGUMENTS = 2
46
53
  const ISSUE_NUMBER_PATTERN = /^[1-9]\d*$/u
47
- const USAGE = 'Usage: josh run:hold [<issue-number>] | josh run:release [<issue-number> | --force]'
54
+ const USAGE =
55
+ 'Usage: josh run:hold [<issue-number> [--fullrun | --halfrun-stop]] | josh run:release [<issue-number> | --force]'
48
56
 
49
57
  const HOLD_VERDICT = 'hold'
50
58
  const BUSY_VERDICT = 'busy'
@@ -56,11 +64,19 @@ const NONE_VERDICT = 'none'
56
64
  const HELD_VERDICT = 'held'
57
65
 
58
66
  const FORCE_RELEASE_KIND = 'force-release'
67
+ const HALFRUN_STOP_KIND = 'halfrun-stop'
68
+
69
+ interface ClaimRequest {
70
+ kind: 'claim'
71
+ issue: string
72
+ is_fullrun?: boolean
73
+ }
59
74
 
60
75
  type HoldRequest =
76
+ | ClaimRequest
61
77
  | { kind: 'release'; claimant: string }
62
78
  | { kind: typeof FORCE_RELEASE_KIND }
63
- | { kind: 'claim'; issue: string }
79
+ | { kind: typeof HALFRUN_STOP_KIND; issue: string }
64
80
 
65
81
  const FORCE_RELEASE_REQUEST: HoldRequest = { kind: FORCE_RELEASE_KIND }
66
82
 
@@ -68,14 +84,24 @@ function release_request(claimant: string): HoldRequest {
68
84
  return { kind: 'release', claimant }
69
85
  }
70
86
 
87
+ // The flag after a numbered claim: absent, `--fullrun` or `--halfrun-stop`. Anything else is a usage
88
+ // error.
89
+ function numbered_claim(issue: string, flag: string | undefined): HoldRequest | undefined {
90
+ if (flag === undefined) return { kind: 'claim', issue }
91
+
92
+ if (flag === HALFRUN_STOP_FLAG) return { kind: HALFRUN_STOP_KIND, issue }
93
+
94
+ return flag === FULLRUN_FLAG ? { kind: 'claim', issue, is_fullrun: true } : undefined
95
+ }
96
+
71
97
  function parse_claim(argv: ReadonlyArray<string>): HoldRequest | undefined {
72
- const [first] = argv
98
+ const [first, flag] = argv
73
99
 
74
100
  if (first === undefined) return { kind: 'claim', issue: run_hold.UNNUMBERED_ISSUE }
75
101
 
76
- if (argv.length > 1 || !ISSUE_NUMBER_PATTERN.test(first)) return undefined
102
+ if (argv.length > MAX_CLAIM_ARGUMENTS || !ISSUE_NUMBER_PATTERN.test(first)) return undefined
77
103
 
78
- return { kind: 'claim', issue: first }
104
+ return numbered_claim(first, flag)
79
105
  }
80
106
 
81
107
  function parse_release_argument(first: string): HoldRequest | undefined {
@@ -135,8 +161,10 @@ async function blocking_message(read: HoldRead): Promise<string | undefined> {
135
161
 
136
162
  // A record already here is never overwritten by a claim: overwriting is what the guard exists to
137
163
  // 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()
164
+ function take_free_tree(target: string, request: ClaimRequest): number {
165
+ const is_taken = run_hold.create_hold(target, request.issue, new Date(), request.is_fullrun)
166
+
167
+ if (is_taken) return report_hold()
140
168
 
141
169
  return report_busy(run_hold.race_message(run_hold.read_hold(target), target))
142
170
  }
@@ -145,11 +173,11 @@ function take_free_tree(target: string, issue: string): number {
145
173
  // record with a plain write would tell two sessions that both found it expired that they both won —
146
174
  // the race the free path was just fixed for, reintroduced one branch over. Removing it first is what
147
175
  // 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 {
176
+ function replace_stale(target: string, request: ClaimRequest, hold: RunHold): number {
149
177
  console.error(run_hold.stale_message(hold))
150
178
  run_hold.release_hold(target)
151
179
 
152
- return take_free_tree(target, issue)
180
+ return take_free_tree(target, request)
153
181
  }
154
182
 
155
183
  // A non-clean preflight verdict is not an error: it is an answer a loop branches on, exactly as
@@ -176,8 +204,8 @@ async function preflight_gate(issue: string, is_linked: boolean): Promise<number
176
204
  return report_preflight(decision)
177
205
  }
178
206
 
179
- async function claim(target: string, issue: string, is_linked: boolean): Promise<number> {
180
- const gate = await preflight_gate(issue, is_linked)
207
+ async function claim(target: string, request: ClaimRequest, is_linked: boolean): Promise<number> {
208
+ const gate = await preflight_gate(request.issue, is_linked)
181
209
 
182
210
  if (gate !== undefined) return gate
183
211
 
@@ -187,7 +215,9 @@ async function claim(target: string, issue: string, is_linked: boolean): Promise
187
215
  if (blocked !== undefined) return report_busy(blocked)
188
216
 
189
217
  const code =
190
- read.kind === 'stale' ? replace_stale(target, issue, read.hold) : take_free_tree(target, issue)
218
+ read.kind === 'stale'
219
+ ? replace_stale(target, request, read.hold)
220
+ : take_free_tree(target, request)
191
221
 
192
222
  if (code === SUCCESS_EXIT_CODE) await run_tidy_cli.sweep()
193
223
 
@@ -261,8 +291,22 @@ async function read_worktree(): Promise<string | undefined> {
261
291
  }
262
292
  }
263
293
 
294
+ // **A `halfrun` stop marks its own record** (joshuafolkken/kit#2796) — the positive "this run has ended
295
+ // over its verified diff" that `run:entry` adopts on `fullrun #N`. Another run's record is left alone.
296
+ function mark_halfrun_stop(target: string, issue: string): number {
297
+ const mark = run_halfrun_resume.mark_stop_at(target, issue)
298
+
299
+ if (mark === run_halfrun_resume.MARKED) return report_hold()
300
+
301
+ if (mark === run_halfrun_resume.UNREADABLE) return report_unknown()
302
+
303
+ return report_busy(run_hold.race_message(run_hold.read_hold(target), target))
304
+ }
305
+
264
306
  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)
307
+ if (request.kind === 'claim') return await claim(target, request, is_linked)
308
+
309
+ if (request.kind === HALFRUN_STOP_KIND) return mark_halfrun_stop(target, request.issue)
266
310
 
267
311
  if (request.kind === FORCE_RELEASE_KIND) return force_release(target)
268
312
 
@@ -43,6 +43,15 @@ 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
51
+ // Set only at a `halfrun`'s stop before commit (`run:hold <N> --halfrun-stop`, joshuafolkken/kit#2796):
52
+ // the positive record that the run has *ended* over its verified diff, which is what lets
53
+ // `fullrun #N` adopt the hold. A running `halfrun` or a `backlogrun` child never carries it.
54
+ is_halfrun_stop?: boolean | undefined
46
55
  }
47
56
 
48
57
  type HoldRead =
@@ -81,6 +90,8 @@ const run_hold_schema = z.object({
81
90
  issue: z.string(),
82
91
  taken_at: z.string(),
83
92
  pid: z.number(),
93
+ is_fullrun: z.boolean().optional(),
94
+ is_halfrun_stop: z.boolean().optional(),
84
95
  })
85
96
 
86
97
  // `undefined` for anything that is not a well-formed record, so the caller decides what a broken one
@@ -125,8 +136,8 @@ function read_hold(target: string, now: Date = new Date()): HoldRead {
125
136
  return classify(stamp_file.read_stamp_text(target), now)
126
137
  }
127
138
 
128
- function build_hold(issue: string, now: Date): RunHold {
129
- return { issue, taken_at: now.toISOString(), pid: process.pid }
139
+ function build_hold(issue: string, now: Date, is_fullrun?: boolean): RunHold {
140
+ return { issue, taken_at: now.toISOString(), pid: process.pid, is_fullrun }
130
141
  }
131
142
 
132
143
  function write_hold(target: string, issue: string, now: Date = new Date()): RunHold {
@@ -141,8 +152,18 @@ function write_hold(target: string, issue: string, now: Date = new Date()): RunH
141
152
  // an entry point in the same second both read `free`, and a write would tell both of them they won —
142
153
  // which is the incident this guard was written after, reproduced by the guard itself. `false` means
143
154
  // 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))
155
+ function create_hold(
156
+ target: string,
157
+ issue: string,
158
+ now: Date = new Date(),
159
+ is_fullrun?: boolean,
160
+ ): boolean {
161
+ return stamp_file.create_stamp(target, build_hold(issue, now, is_fullrun))
162
+ }
163
+
164
+ // The claim a `halfrun` stop leaves behind: the same exclusive create, carrying the stop mark.
165
+ function create_halfrun_stop_hold(target: string, issue: string, now: Date = new Date()): boolean {
166
+ return stamp_file.create_stamp(target, { ...build_hold(issue, now), is_halfrun_stop: true })
146
167
  }
147
168
 
148
169
  // **A tree with uncommitted work in it is never handed over, however old its record is.** No age can
@@ -238,6 +259,7 @@ const run_hold = {
238
259
  RELEASE_COMMAND,
239
260
  UNNUMBERED_ISSUE,
240
261
  classify,
262
+ create_halfrun_stop_hold,
241
263
  create_hold,
242
264
  describe_holder,
243
265
  foreign_release_message,
@@ -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