@joshuafolkken/kit 1.953.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 (100) 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 +5 -3
  14. package/dist/CLAUDE.md +3 -3
  15. package/dist/hooks/batch-guard.js +4 -4
  16. package/dist/hooks/{chunk-OM5VDM6F.js → chunk-2K52BRWV.js} +2 -2
  17. package/dist/hooks/{chunk-Z5TVZ4AP.js → chunk-BNWIOJFU.js} +29 -16
  18. package/dist/hooks/{chunk-NKVLBB65.js → chunk-BVMSPG3M.js} +3 -3
  19. package/dist/hooks/{chunk-IA7WC4NS.js → chunk-CRGJTD6E.js} +1 -1
  20. package/dist/hooks/{chunk-M4U4YNRE.js → chunk-F6OM6SIG.js} +3 -3
  21. package/dist/hooks/{chunk-O3PEDF3M.js → chunk-KQG4776P.js} +1 -1
  22. package/dist/hooks/{chunk-V7RH56HU.js → chunk-NXL3SBXY.js} +2 -2
  23. package/dist/hooks/{chunk-452OKRQX.js → chunk-WWTMHHZU.js} +120 -18
  24. package/dist/hooks/{chunk-EYFJ74MJ.js → chunk-ZPLXLJ7O.js} +21 -7
  25. package/dist/hooks/{chunk-IBFC25IX.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 +14 -6
  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-drive-cli.ts +5 -1
  38. package/scripts/backlog/backlog-drive-retrospective.ts +20 -0
  39. package/scripts/backlog/backlog-drive.ts +7 -3
  40. package/scripts/backlog/backlog-next.ts +45 -2
  41. package/scripts/backlog/backlog-offer.ts +1 -0
  42. package/scripts/backlog/backlog-plan-cli.ts +66 -27
  43. package/scripts/backlog/backlog-plan.ts +30 -2
  44. package/scripts/backlog/backlog-stalled.ts +9 -0
  45. package/scripts/backlog/backlog-waves.ts +169 -0
  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 +42 -0
  60. package/scripts/hooks/stop-guard.ts +1 -0
  61. package/scripts/init/init-install.ts +64 -0
  62. package/scripts/init/init.ts +38 -11
  63. package/scripts/init/project-profile.ts +9 -5
  64. package/scripts/init/start-plan.ts +145 -0
  65. package/scripts/init/start-prompt.ts +55 -0
  66. package/scripts/init/start-steps.ts +167 -0
  67. package/scripts/init/start.ts +92 -16
  68. package/scripts/issue/issue-closing-pr.ts +93 -0
  69. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +4 -2
  70. package/scripts/josh/josh-commands-project.ts +11 -3
  71. package/scripts/josh/josh-commands-workflow.ts +3 -3
  72. package/scripts/observations/observation-ledger-prepare.ts +31 -0
  73. package/scripts/observations/observation-ledger.ts +5 -5
  74. package/scripts/observations/observations-flush.ts +11 -30
  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 +36 -2
  83. package/scripts/run/run-headless.ts +1 -0
  84. package/scripts/run/run-hold-cli.ts +36 -15
  85. package/scripts/run/run-hold.ts +15 -4
  86. package/scripts/run/run-merge-cli.ts +28 -14
  87. package/scripts/run/run-merge-steps.ts +38 -2
  88. package/scripts/run/run-merge.ts +41 -3
  89. package/scripts/run/run-prep-cli.ts +8 -2
  90. package/scripts/run/run-prep-locate.ts +165 -0
  91. package/scripts/run/run-prep.ts +12 -1
  92. package/scripts/run/run-retrospective.ts +34 -0
  93. package/scripts/run/run-step-cli.ts +19 -5
  94. package/scripts/run/run-step.ts +31 -19
  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/time-runtime/time-background.ts +11 -0
  99. package/scripts/time-runtime/time-transcript-line.ts +4 -0
  100. package/templates/pnpm-workspace.static.yaml +2 -0
@@ -0,0 +1,169 @@
1
+ import type { BusyRead } from '#scripts/epic/epic-busy'
2
+ import { epic_classify } from '#scripts/epic/epic-classify'
3
+ import { epic_cross_repo } from '#scripts/epic/epic-cross-repo'
4
+ import { epic_graph, type EpicChild } from '#scripts/epic/epic-graph'
5
+ import { epic_outside_blocker } from '#scripts/epic/epic-outside-blocker'
6
+ import { epic_report, type EpicNextResult } from '#scripts/epic/epic-report'
7
+ import { epic_solo } from '#scripts/epic/epic-solo'
8
+ import { has_label_name, IN_PROGRESS_LABEL, NEEDS_DECISION_LABEL } from '#scripts/git/issue-labels'
9
+ import { backlog_plan, type PlanContext } from './backlog-plan'
10
+
11
+ // The order a `backlogrun` takes, wave by wave, before it starts (joshuafolkken/kit#2778).
12
+ //
13
+ // `backlog:next` answers one ask — what may start now — and `backlog:plan` shows the pool that ask is
14
+ // made from, so the order past the first answer was left for a person to work out by hand. This plays
15
+ // the asks forward under one assumption: **every issue of a wave merges before the next wave starts**.
16
+ //
17
+ // **No rule is written twice.** The first wave is `epic_solo.select` over the very candidates
18
+ // `backlog:next` resolves, asked of an idle repository — so it is what `backlog:next` prints when
19
+ // nothing is running. Each later wave marks the earlier waves closed and re-classifies the same pool
20
+ // through `epic_classify.classify_children` with the resolver `epic:next` sorted it with the first
21
+ // time — `epic_cross_repo.resolve_cross_repo`, so a blocker in another repository still waits for its
22
+ // release rather than counting as done on close — then puts the runnable set through
23
+ // `epic_solo.select` again.
24
+
25
+ const IDLE: BusyRead = { kind: 'idle' }
26
+ const CLOSED = 'CLOSED'
27
+
28
+ const HEADING_ASSUMPTION =
29
+ 'Assumes every issue in a wave merges before the next wave starts. Issues a run already has are left out, and only issues of this repository are planned.'
30
+ const UNREACHED_HEADING = 'Not reached — each names what it is waiting on:'
31
+ const PARALLEL_NOTE = '(parallel)'
32
+ const NEEDS_DECISION_NOTE = `waiting on a person (\`${NEEDS_DECISION_LABEL}\`)`
33
+ const NOT_REACHED_NOTE = 'not reached by the waves above'
34
+ const WAVE_GAP_WIDTH = 2
35
+ const PARALLEL_GAP_WIDTH = 3
36
+ const WAVE_GAP = ' '.repeat(WAVE_GAP_WIDTH)
37
+ const PARALLEL_GAP = ' '.repeat(PARALLEL_GAP_WIDTH)
38
+
39
+ interface WavePlan {
40
+ waves: ReadonlyArray<ReadonlyArray<EpicChild>>
41
+ unreached: ReadonlyArray<EpicChild>
42
+ }
43
+
44
+ function is_running(child: EpicChild): boolean {
45
+ return has_label_name(child.labels, IN_PROGRESS_LABEL)
46
+ }
47
+
48
+ // Every child the classification placed, in the order it placed them: the ranked candidates first,
49
+ // then the waiting ones, then the ones waiting on a person.
50
+ function everything_in(result: EpicNextResult): ReadonlyArray<EpicChild> {
51
+ const candidates = result.candidates.flatMap((bundle) => bundle.children)
52
+
53
+ return [...candidates, ...result.waiting, ...result.blocked_on_people]
54
+ }
55
+
56
+ function as_closed(child: EpicChild, done: ReadonlySet<string>): EpicChild {
57
+ return done.has(epic_graph.key_of(child)) ? { ...child, state: CLOSED } : child
58
+ }
59
+
60
+ // The pool a wave is chosen from. A running issue is left out of it but kept in `running`, so a child
61
+ // behind it waits on it rather than being handed to a person.
62
+ interface WavePool {
63
+ children: ReadonlyArray<EpicChild>
64
+ running: ReadonlySet<string>
65
+ repo: string
66
+ }
67
+
68
+ function pool_of(result: EpicNextResult, repo: string): WavePool {
69
+ const all = everything_in(result)
70
+
71
+ return {
72
+ children: all.filter((child) => !is_running(child)),
73
+ running: epic_outside_blocker.running_keys(all),
74
+ repo,
75
+ }
76
+ }
77
+
78
+ function offer(runnable: ReadonlyArray<EpicChild>, repo: string): ReadonlyArray<EpicChild> {
79
+ return epic_solo.select(runnable, IDLE, repo).offered
80
+ }
81
+
82
+ function first_wave(result: EpicNextResult, repo: string): ReadonlyArray<EpicChild> {
83
+ return offer(epic_report.candidates_for_repo(result, repo), repo)
84
+ }
85
+
86
+ function next_wave(pool: WavePool, done: ReadonlySet<string>): ReadonlyArray<EpicChild> {
87
+ const children = pool.children.map((child) => as_closed(child, done))
88
+ const { runnable } = epic_classify.classify_children(
89
+ children,
90
+ epic_cross_repo.resolve_cross_repo,
91
+ pool.running,
92
+ )
93
+
94
+ return offer(
95
+ runnable.filter((child) => child.repo === pool.repo),
96
+ pool.repo,
97
+ )
98
+ }
99
+
100
+ // Each wave is closed before the next is asked for; a wave that offers nothing ends the plan, which
101
+ // the pool's size bounds because every wave closes at least one child.
102
+ function build(result: EpicNextResult, repo: string): WavePlan {
103
+ const pool = pool_of(result, repo)
104
+ const done = new Set<string>()
105
+ const waves: Array<ReadonlyArray<EpicChild>> = []
106
+
107
+ for (let wave = first_wave(result, repo); wave.length > 0; wave = next_wave(pool, done)) {
108
+ waves.push(wave)
109
+ for (const child of wave) done.add(epic_graph.key_of(child))
110
+ }
111
+
112
+ const unreached = everything_in(result).filter(
113
+ (child) => child.repo === repo && !done.has(epic_graph.key_of(child)),
114
+ )
115
+
116
+ return { waves, unreached }
117
+ }
118
+
119
+ // The plan's own "waiting on" wording is reused, so an unreached issue names its blocker the way the
120
+ // plan's waiting section does. Its "past the offer" fallback means nothing across waves, so it reads
121
+ // as not reached instead.
122
+ function unreached_note(child: EpicChild, context: PlanContext): string {
123
+ if (has_label_name(child.labels, NEEDS_DECISION_LABEL)) return NEEDS_DECISION_NOTE
124
+ const note = backlog_plan.waiting_note(child, context)
125
+
126
+ return note === backlog_plan.PAST_OFFER_NOTE ? NOT_REACHED_NOTE : note
127
+ }
128
+
129
+ function wave_line(wave: ReadonlyArray<EpicChild>, index: number, repo: string): string {
130
+ const references = wave.map((child) => backlog_plan.marked_reference(child, repo)).join(' ')
131
+ const suffix = wave.length > 1 ? `${PARALLEL_GAP}${PARALLEL_NOTE}` : ''
132
+
133
+ return `Wave ${String(index + 1)}${WAVE_GAP}${references}${suffix}`
134
+ }
135
+
136
+ function unreached_lines(plan: WavePlan, context: PlanContext): Array<string> {
137
+ return plan.unreached.map((child) =>
138
+ backlog_plan.row_of(child, context, unreached_note(child, context)),
139
+ )
140
+ }
141
+
142
+ function format_waves(result: EpicNextResult, context: PlanContext): string {
143
+ if (result.verdict === 'error') return backlog_plan.format_unusable(result)
144
+
145
+ const plan = build(result, context.repo)
146
+ const waves = plan.waves.map((wave, index) => wave_line(wave, index, context.repo))
147
+
148
+ return [
149
+ `Backlog waves — ${context.repo}`,
150
+ HEADING_ASSUMPTION,
151
+ '',
152
+ ...(waves.length === 0 ? [backlog_plan.NOTHING] : waves),
153
+ '',
154
+ backlog_plan.section(UNREACHED_HEADING, unreached_lines(plan, context)),
155
+ ].join('\n')
156
+ }
157
+
158
+ const backlog_waves = {
159
+ HEADING_ASSUMPTION,
160
+ NEEDS_DECISION_NOTE,
161
+ NOT_REACHED_NOTE,
162
+ PARALLEL_NOTE,
163
+ UNREACHED_HEADING,
164
+ build,
165
+ format_waves,
166
+ }
167
+
168
+ export { backlog_waves }
169
+ export type { WavePlan }
@@ -82,7 +82,7 @@ const DOCUMENT_BYTE_BUDGET: ReadonlyArray<DocumentBudget> = [
82
82
  { path: '.claude/skills/workflow-commands/rule-residency.md', bytes: 24_576 },
83
83
  { path: '.claude/skills/workflow-commands/target-repository.md', bytes: 8192 },
84
84
  { path: '.claude/skills/workflow-commands/working-tree-hold.md', bytes: 8192 },
85
- { path: 'docs/josh-commands.md', bytes: 167_936 },
85
+ { path: 'docs/josh-commands.md', bytes: 172_032 },
86
86
  { path: 'prompts/agent-rules.md', bytes: 4096 },
87
87
  { path: 'prompts/coding-standards.md', bytes: 16_384 },
88
88
  { path: 'prompts/collaboration-workflow.md', bytes: 8192 },
@@ -76,11 +76,13 @@ const ENTRY_READ_BUDGET: ReadonlyArray<EntryBudget> = [
76
76
  // joshuafolkken/kit#2353 wired the watcher-guard note into `backlogrun-progress.md` (read by the three
77
77
  // planning entries): kickoff has no headroom, so the addition was offset by tightening that file's
78
78
  // heartbeat prose, keeping every entry within its existing block.
79
- { entry: 'kickoff', bytes: 258_048 },
80
- { entry: 'fullrun', bytes: 258_048 },
81
- { entry: 'halfrun', bytes: 258_048 },
82
- { entry: 'backlogrun', bytes: 262_144 },
83
- { entry: LANE_CHILD, bytes: 86_016 },
79
+ // joshuafolkken/kit#2762 moved the rationale of five point-of-use documents to
80
+ // `docs/maintainers/*-rationale.md`, and the ratchet holds the reduction.
81
+ { entry: 'kickoff', bytes: 229_376 },
82
+ { entry: 'fullrun', bytes: 229_376 },
83
+ { entry: 'halfrun', bytes: 229_376 },
84
+ { entry: 'backlogrun', bytes: 233_472 },
85
+ { entry: LANE_CHILD, bytes: 81_920 },
84
86
  ]
85
87
 
86
88
  function byte_size(root: string, relative_path: string): number {
@@ -7,6 +7,8 @@ import {
7
7
  } from './epic-candidate-confirm'
8
8
  import { epic_graph, type EpicChild } from './epic-graph'
9
9
  import type { EpicVerdict } from './epic-report'
10
+ import { epic_solo } from './epic-solo'
11
+ import { epic_triage, type TriageVerdict } from './epic-triage'
10
12
 
11
13
  // Which children a repository has room to start right now (joshuafolkken/kit#1491).
12
14
  //
@@ -50,7 +52,7 @@ interface LaneRequest {
50
52
  // output carries issue numbers or a verdict and nothing else.
51
53
  interface LaneOffer {
52
54
  children: ReadonlyArray<EpicChild>
53
- verdict: EpicVerdict
55
+ verdict: EpicVerdict | TriageVerdict
54
56
  notice: string
55
57
  }
56
58
 
@@ -165,14 +167,75 @@ async function collect(
165
167
  return { children, verdict: children.length > NO_LANES ? RUN_VERDICT : verdict }
166
168
  }
167
169
 
170
+ // The `run:solo` gate `backlog:next` applies, applied to a named epic's lanes too
171
+ // (joshuafolkken/kit#2776): the confirmed children are cut where `epic_solo.select` says, so a
172
+ // `run:solo` child never opens a lane beside another one on either path.
173
+ function solo_offer(
174
+ answer: { children: ReadonlyArray<EpicChild>; verdict: EpicVerdict },
175
+ read: BusyRead,
176
+ request: LaneRequest,
177
+ ): LaneOffer {
178
+ const { offered, notice } = epic_solo.select(answer.children, read, request.repo)
179
+
180
+ if (offered.length === NO_LANES && answer.children.length > NO_LANES) {
181
+ return { children: [], verdict: WAIT_VERDICT, notice: notice ?? '' }
182
+ }
183
+
184
+ return { ...answer, children: offered, notice: offered_notice(offered, read, request) }
185
+ }
186
+
187
+ // The confirmation walk stops at the free-lane count, so a `run:solo` candidate ranked past it — or
188
+ // in a pool past it — would never reach `solo_offer` to be preferred. Into an idle repository the
189
+ // first pool holding one is asked first, with it at its head through the same `epic_solo.prefer` the
190
+ // offer applies (joshuafolkken/kit#2778). The candidate stays in its own pool, because a pool carries
191
+ // the graph it is confirmed against.
192
+ function solo_first(pools: ReadonlyArray<RepoPool>, read: BusyRead): ReadonlyArray<RepoPool> {
193
+ if (read.kind !== 'idle') return pools
194
+
195
+ const index = pools.findIndex((pool) => pool.candidates.some((child) => epic_solo.is_solo(child)))
196
+ const pool = pools[index]
197
+
198
+ if (pool === undefined) return pools
199
+
200
+ const lifted = { ...pool, candidates: epic_solo.prefer(pool.candidates, read) }
201
+
202
+ return [lifted, ...pools.filter((other) => other !== pool)]
203
+ }
204
+
168
205
  // The repository is asked how full it is **before** any candidate is confirmed, for the reason
169
206
  // joshuafolkken/kit#1121 records: a repository with no free lane is handed nothing, so the relations
170
207
  // request that would confirm a candidate there buys an answer nobody reads — and a polling `epicrun`
171
208
  // would pay it every round.
209
+ //
210
+ // **Triage is asked before even that** (joshuafolkken/kit#2779): an untriaged candidate withholds every
211
+ // candidate whatever the occupancy, and it needs only the labels already read. Only a `--lanes` ask
212
+ // is gated — it is the one that opens children beside each other; the single-child form starts one.
213
+ function triage_offer(pools: ReadonlyArray<RepoPool>, request: LaneRequest): LaneOffer | undefined {
214
+ if (!request.is_all_lanes) return undefined
215
+
216
+ const untriaged = epic_triage.untriaged(
217
+ dedupe_pools(pools.map((pool) => candidates_in(pool, request.repo))).flatMap(
218
+ (pool) => pool.candidates,
219
+ ),
220
+ )
221
+
222
+ if (untriaged.length === NO_LANES) return undefined
223
+
224
+ return {
225
+ children: [],
226
+ verdict: epic_triage.TRIAGE_VERDICT,
227
+ notice: epic_triage.message(untriaged, request.repo),
228
+ }
229
+ }
230
+
172
231
  async function offer_for_repo(
173
232
  pools: ReadonlyArray<RepoPool>,
174
233
  request: LaneRequest,
175
234
  ): Promise<LaneOffer> {
235
+ const triage = triage_offer(pools, request)
236
+
237
+ if (triage !== undefined) return triage
238
+
176
239
  const read = await epic_busy.read_repository(request.repo)
177
240
  const free = free_lanes(read, request.limit)
178
241
 
@@ -184,10 +247,12 @@ async function offer_for_repo(
184
247
  }
185
248
  }
186
249
 
187
- const for_repo = dedupe_pools(pools.map((pool) => candidates_in(pool, request.repo)))
188
- const answer = await collect(for_repo, wanted_of(request, free))
250
+ const for_repo = solo_first(
251
+ dedupe_pools(pools.map((pool) => candidates_in(pool, request.repo))),
252
+ read,
253
+ )
189
254
 
190
- return { ...answer, notice: offered_notice(answer.children, read, request) }
255
+ return solo_offer(await collect(for_repo, wanted_of(request, free)), read, request)
191
256
  }
192
257
 
193
258
  const epic_lane_offer = {
@@ -16,7 +16,7 @@ import { epic_lane_offer, type LaneOffer, type LaneRequest } from './epic-lane-o
16
16
  import { epic_next_read, type EpicRead, type SnapshotReads } from './epic-next-read'
17
17
  import { epic_next_views, type EpicView } from './epic-next-views'
18
18
  import { epic_outside_blocker } from './epic-outside-blocker'
19
- import { epic_report, type EpicNextResult, type EpicVerdict } from './epic-report'
19
+ import { epic_report, type EpicNextResult } from './epic-report'
20
20
 
21
21
  // `josh epic:next <E…>` — which of the named epics' children can be started right now, bundled per
22
22
  // repository, and what the rest are waiting on (joshuafolkken/kit#860).
@@ -210,7 +210,7 @@ function refuse(reason: string): number {
210
210
  // The verdict as it applies to *this* repository. `run` never reaches a caller here: it means some
211
211
  // other repository has work, which for this session is something to wait on rather than a state its
212
212
  // loop has a branch for. The whole-run timeout is what bounds that wait.
213
- function repo_verdict(verdict: EpicVerdict): EpicVerdict {
213
+ function repo_verdict(verdict: LaneOffer['verdict']): LaneOffer['verdict'] {
214
214
  return verdict === 'run' ? 'wait' : verdict
215
215
  }
216
216
 
@@ -0,0 +1,147 @@
1
+ import { has_any_label, has_label_name, RUN_SOLO_LABEL } from '#scripts/git/issue-labels'
2
+ import { issue_citation } from '#scripts/rules/issue-citation'
3
+ import { epic_busy, type BusyRead } from './epic-busy'
4
+ import type { EpicChild } from './epic-graph'
5
+ import { epic_report, type EpicNextResult } from './epic-report'
6
+
7
+ // The `run:solo` gate on what a run is offered (joshuafolkken/kit#2776).
8
+ //
9
+ // `wip-cap.md`'s run-alone section says an issue whose defect reaches the verification path runs
10
+ // alone, and until this gate that was a judgement made at dispatch — the offer handed such an issue
11
+ // out beside others and `backlog:drive` launched every number it was given. The label records the
12
+ // judgement once; this applies it to every ask, so it holds without an agent in the loop.
13
+ //
14
+ // **Both offer paths call `select`**: `backlog:next` (the pool drain, through `gate`) and
15
+ // `epic:next --lanes` (a named epic's children, `epic-lane-offer.ts`). One rule, so the two cannot
16
+ // disagree about what may start beside what.
17
+ //
18
+ // Three rules, all against the repository's `in-progress` holders (`epic_busy.read_repository`):
19
+ // a running `run:solo` issue lets nothing new start; a `run:solo` candidate starts only into an idle
20
+ // repository and then alone; and nothing ranked below a waiting `run:solo` candidate is offered past
21
+ // it, so it is not starved by the lanes it is waiting on. A listing that could not be read is not an
22
+ // idle repository — the same fail-safe answer `epic_busy` gives.
23
+ //
24
+ // **An idle repository takes its first `run:solo` candidate ahead of the ranking**
25
+ // (joshuafolkken/kit#2778). An issue carries the label because its defect reaches the verification
26
+ // path, and `wip-cap.md` resumes a batch only once that has merged — so an ordinary candidate ranked
27
+ // ahead of it would spend a whole wave running alone, then hold the fix back behind its own merge.
28
+ // While lanes are running the ranking stands: nothing overtakes a waiting `run:solo` candidate, and
29
+ // the ones ahead of it keep their lanes.
30
+
31
+ const SOLO_LABELS: ReadonlySet<string> = new Set([RUN_SOLO_LABEL])
32
+
33
+ // Which of the ordered candidates may start, which are held back, and why when a reason is worth
34
+ // saying. `offered` keeps the candidates' own order.
35
+ interface SoloSelection {
36
+ offered: ReadonlyArray<EpicChild>
37
+ withheld: ReadonlyArray<EpicChild>
38
+ notice?: string
39
+ }
40
+
41
+ interface SoloGate {
42
+ result: EpicNextResult
43
+ notice?: string
44
+ }
45
+
46
+ function is_solo(child: EpicChild): boolean {
47
+ return has_label_name(child.labels, RUN_SOLO_LABEL)
48
+ }
49
+
50
+ function solo_holder(read: BusyRead): number | undefined {
51
+ if (read.kind !== 'busy') return undefined
52
+
53
+ return read.issues.find((issue) => has_any_label(issue.labels, SOLO_LABELS))?.number
54
+ }
55
+
56
+ function held_message(holder: number, repo: string): string {
57
+ return issue_citation.linkify(
58
+ `#${String(holder)} carries \`${RUN_SOLO_LABEL}\` and is running in ${repo}; nothing else starts until it merges or is parked.`,
59
+ repo,
60
+ )
61
+ }
62
+
63
+ function waiting_message(solo: number, repo: string): string {
64
+ return issue_citation.linkify(
65
+ `#${String(solo)} carries \`${RUN_SOLO_LABEL}\` and waits for ${repo}'s running lanes to finish; nothing ranked below it is offered past it.`,
66
+ repo,
67
+ )
68
+ }
69
+
70
+ function first_of(children: ReadonlyArray<EpicChild>, keep: number): SoloSelection {
71
+ return { offered: children.slice(0, keep), withheld: children.slice(keep) }
72
+ }
73
+
74
+ function none_of(children: ReadonlyArray<EpicChild>, notice: string): SoloSelection {
75
+ return { offered: [], withheld: [...children], notice }
76
+ }
77
+
78
+ // The ranking with the first `run:solo` candidate moved to the head — but only into an idle
79
+ // repository, the one place it may start. Anywhere else the ranking is returned as it was.
80
+ function prefer(children: ReadonlyArray<EpicChild>, read: BusyRead): ReadonlyArray<EpicChild> {
81
+ const solo = children.find((child) => is_solo(child))
82
+
83
+ if (solo === undefined || read.kind !== 'idle') return children
84
+
85
+ return [solo, ...children.filter((child) => child !== solo)]
86
+ }
87
+
88
+ // Where the first `run:solo` candidate sits decides what starts: everything when there is none, the
89
+ // candidates ahead of it when lanes are running, and it alone into an idle repository.
90
+ function select_candidates(
91
+ children: ReadonlyArray<EpicChild>,
92
+ read: BusyRead,
93
+ repo: string,
94
+ ): SoloSelection {
95
+ const ordered = prefer(children, read)
96
+ const index = ordered.findIndex((child) => is_solo(child))
97
+ const solo = ordered[index]
98
+
99
+ if (solo === undefined) return first_of(ordered, ordered.length)
100
+ if (index > 0) return first_of(ordered, index)
101
+ if (read.kind === 'idle') return first_of(ordered, 1)
102
+
103
+ return none_of(ordered, waiting_message(solo.number, repo))
104
+ }
105
+
106
+ // An unreadable or truncated listing keeps nothing: without it a running `run:solo` issue cannot be
107
+ // ruled out.
108
+ function select(children: ReadonlyArray<EpicChild>, read: BusyRead, repo: string): SoloSelection {
109
+ if (read.kind === 'unreadable' || read.kind === 'truncated') {
110
+ return none_of(children, epic_busy.busy_reason(read, repo, 0))
111
+ }
112
+
113
+ const holder = solo_holder(read)
114
+
115
+ if (holder !== undefined) return none_of(children, held_message(holder, repo))
116
+
117
+ return select_candidates(children, read, repo)
118
+ }
119
+
120
+ // The offered candidates of `repo` stay; the withheld ones move to `waiting`, which is where the
121
+ // report already lists children that time alone will release.
122
+ function withhold(result: EpicNextResult, repo: string, selection: SoloSelection): EpicNextResult {
123
+ return {
124
+ ...result,
125
+ candidates: result.candidates.map((bundle) =>
126
+ bundle.repo === repo ? { ...bundle, children: selection.offered } : bundle,
127
+ ),
128
+ waiting: [...result.waiting, ...selection.withheld],
129
+ }
130
+ }
131
+
132
+ // Only a `run` answer offers anything, so every other verdict passes through untouched.
133
+ function gate(result: EpicNextResult, read: BusyRead, repo: string): SoloGate {
134
+ if (result.verdict !== 'run') return { result }
135
+
136
+ const selection = select(epic_report.candidates_for_repo(result, repo), read, repo)
137
+ const gated = withhold(result, repo, selection)
138
+
139
+ return selection.notice === undefined
140
+ ? { result: gated }
141
+ : { result: gated, notice: selection.notice }
142
+ }
143
+
144
+ const epic_solo = { gate, is_solo, prefer, select }
145
+
146
+ export type { SoloGate, SoloSelection }
147
+ export { epic_solo }
@@ -0,0 +1,47 @@
1
+ import { has_label_name, RUN_LANE_LABEL, RUN_SOLO_LABEL } from '#scripts/git/issue-labels'
2
+ import { issue_citation } from '#scripts/rules/issue-citation'
3
+ import type { EpicChild } from './epic-graph'
4
+
5
+ // The triage gate on what a run is offered (joshuafolkken/kit#2779).
6
+ //
7
+ // `run:solo` (joshuafolkken/kit#2776) records that an issue runs alone, but its absence said nothing:
8
+ // "judged safe beside others" and "never judged" looked the same, and an issue opted in mid-run reached
9
+ // `backlog:drive` without any agent reading it. `run:lane` records the other answer, so an issue with
10
+ // neither label is one nobody has judged — and such an issue may be one that must run alone.
11
+ //
12
+ // **One untriaged candidate withholds every candidate**, not just itself: whether the others may start
13
+ // depends on whether it is `run:solo`, which is exactly what is not known yet. The answer is the
14
+ // `triage` verdict, which `backlog:drive` hands back to the parent rather than acting on.
15
+ //
16
+ // **Both offer paths call `untriaged`**: `backlog:next` (the pool drain) and `epic:next --lanes`
17
+ // (a named epic's children, `epic-lane-offer.ts`) — the same pairing `epic_solo` keeps.
18
+
19
+ type TriageVerdict = 'triage'
20
+
21
+ const TRIAGE_VERDICT: TriageVerdict = 'triage'
22
+
23
+ function is_triaged(child: EpicChild): boolean {
24
+ const { labels } = child
25
+
26
+ return has_label_name(labels, RUN_SOLO_LABEL) || has_label_name(labels, RUN_LANE_LABEL)
27
+ }
28
+
29
+ // The candidates nobody has judged, in the order they were given.
30
+ function untriaged(children: ReadonlyArray<EpicChild>): ReadonlyArray<EpicChild> {
31
+ return children.filter((child) => !is_triaged(child))
32
+ }
33
+
34
+ // Names every untriaged number, so the parent reading standard error knows which issues to judge.
35
+ function message(children: ReadonlyArray<EpicChild>, repo: string): string {
36
+ const numbers = children.map((child) => `#${String(child.number)}`).join(', ')
37
+
38
+ return issue_citation.linkify(
39
+ `Untriaged: ${numbers} carry neither \`${RUN_SOLO_LABEL}\` nor \`${RUN_LANE_LABEL}\` in ${repo}, so nothing is offered until each is judged. Read each one, record its blocked-by order and one of the two labels with a comment saying why, then ask again.`,
40
+ repo,
41
+ )
42
+ }
43
+
44
+ const epic_triage = { TRIAGE_VERDICT, is_triaged, message, untriaged }
45
+
46
+ export type { TriageVerdict }
47
+ export { epic_triage }
@@ -53,14 +53,13 @@ function is_force_requested(force_environment: string): boolean {
53
53
  // nothing" for "git could not be asked". Every predicate below treats the two differently.
54
54
  //
55
55
  // **The observation ledger is dropped from the reading, and it is the one line that may be**
56
- // (joshuafolkken/kit#1756). Since that issue `pnpm josh git` excludes the ledger from
57
- // what it stages, so a parent's appended line sits modified-but-never-staged for the whole interval
58
- // between the append and the next `pnpm josh observations:flush` — and every condition below reads a
59
- // non-empty status as "this operation carries a tree no check has read". Left in, one ledger line
60
- // would send every commit and every push in the primary checkout back to the full gate for days,
61
- // which is the reuse this module exists to grant. **Dropping it is sound because nothing can carry
62
- // it**: the exclusion means the index and the push are byte-for-byte the recorded tree whatever the
63
- // ledger says, and the ledger's own content is checked by the CI of the pull request the flush opens.
56
+ // (joshuafolkken/kit#1756). Lines are appended throughout a run — a review's record lands after its
57
+ // gate — and every condition below reads a non-empty status as "this operation carries a tree no
58
+ // check has read". Left in, one ledger line would send every commit and every push in the primary
59
+ // checkout back to the full gate, which is the reuse this module exists to grant. **Dropping it is
60
+ // sound because the ledger is no code any check runs**: `pnpm josh git` stages it with the run's
61
+ // commit only after its grammar has been parsed (joshuafolkken/kit#2763), and the rest of its content
62
+ // is checked by the CI of the pull request that carries it.
64
63
  async function read_status_lines(): Promise<ReadonlyArray<string> | undefined> {
65
64
  try {
66
65
  const status = await git_command.status()
@@ -0,0 +1,15 @@
1
+ // Captures the number, so the one pattern answers every question asked of a PR body: whether the
2
+ // linked issue will auto-close, which issue that is when the invocation did not say
3
+ // (joshuafolkken/kit#1539), and whether a merged pull request closes a child a `backlogrun` judged
4
+ // unfinished (joshuafolkken/kit#2769). A second pattern would be two readings of "closes #N" to disagree.
5
+ const CLOSES_PATTERN = /closes\s+#(\d+)/iu
6
+
7
+ function parse_closes_issue_number(body: string | undefined): string | undefined {
8
+ if (body === undefined) return undefined
9
+
10
+ return CLOSES_PATTERN.exec(body)?.[1]
11
+ }
12
+
13
+ const git_closes_keyword = { parse_closes_issue_number }
14
+
15
+ export { git_closes_keyword }
@@ -5,12 +5,11 @@ import { git_command } from './git-command'
5
5
  import { git_followup_cleanup } from './git-followup-cleanup'
6
6
  import { main_sync } from './main-sync'
7
7
 
8
- // The observation ledger's only commit path is `pnpm josh observations:flush`, and until now no
9
- // command's procedure called it (joshuafolkken/kit#1810). The ledger is excluded from ordinary
10
- // staging (joshuafolkken/kit#1756), so an appended line stayed in the working tree until a person
11
- // remembered to flush it — the same "a run that had to remember is the run that forgets" defect the
12
- // staging side was built to avoid, left standing on the commit side. This wires the flush into
13
- // `pnpm josh followup`, the one step that knows the merge has landed.
8
+ // The residual commit path for the observation ledger (joshuafolkken/kit#1810). A run's appended lines
9
+ // ride its own commit (joshuafolkken/kit#2763), so after the merge the ledger is usually clean and this
10
+ // short-circuits; what it still catches is a line appended after the commit — a second review round's
11
+ // record — which would otherwise stay in the working tree until a person remembered to flush it. This
12
+ // wires the flush into `pnpm josh followup`, the one step that knows the merge has landed.
14
13
  //
15
14
  // **It runs before the working-tree hold is released** (`scripts-ai/git-followup-finish.ts`, whose
16
15
  // `finish()` runs after `git_pr_followup.run()` returns), because a flush switches branches, and a
@@ -0,0 +1,75 @@
1
+ import { poll, type PollOptions } from '#scripts/lib/poll'
2
+ import { z } from 'zod'
3
+ import { git_epic_parse } from './git-epic-parse'
4
+ import { git_gh_command } from './git-gh-command'
5
+ import { parse_json_object_safe } from './parse-json-array'
6
+
7
+ // **A merged pull request whose `closes #N` GitHub did not apply** (joshuafolkken/kit#2770). From
8
+ // 2026-09-30 06:34Z every merge `followup` made left its issue open — seven in a row, where every
9
+ // earlier one was closed by the pull request within a second — although the merge request, the
10
+ // merging account and the `closes #N` first line were all the same as before. The run then reported
11
+ // success, and a `backlogrun` read the still-open child as a failure and stopped.
12
+ //
13
+ // **The issue's state is what is checked, not `closingIssuesReferences`.** That field is GraphQL-only
14
+ // (a cloud session is answered 403 there, joshuafolkken/kit#1022) and it is not the fact that failed:
15
+ // PR #2768 answered it empty at the time and names #2761 now, while #2761 was never closed by it. The
16
+ // observable defect is an issue left open, so that is what is read, and what is repaired.
17
+ //
18
+ // **A short wait first**, because GitHub applies the keyword asynchronously — the closes it did apply
19
+ // landed within a second of the merge. Closing on the first read would take the close away from the
20
+ // pull request on every run that merely raced it.
21
+ const CLOSE_WAIT: PollOptions = { attempts: 3, interval_ms: 2000 }
22
+
23
+ const CLOSE_RECOVERY = 'gh api -X PATCH repos/{owner}/{repo}/issues/<N> -f state=closed'
24
+
25
+ const state_read_schema = z.object({ state: z.string().optional() })
26
+
27
+ async function is_issue_closed(issue_number: string): Promise<boolean> {
28
+ const raw = await git_gh_command.issue_get_state_and_relations(issue_number)
29
+ const parsed = raw === undefined ? undefined : parse_json_object_safe(raw, state_read_schema)
30
+
31
+ return git_epic_parse.is_state_closed(parsed?.state)
32
+ }
33
+
34
+ const FOLLOWUP_CLOSER = 'pnpm josh followup'
35
+
36
+ // English, like every other string a script posts: the comment says why the close came from this
37
+ // command rather than from the pull request, so a reader of the issue is not left guessing. `closer`
38
+ // names the command that closed it — `followup` at the merge, or `run:merge` when a `backlogrun`
39
+ // finds the child still open afterwards (joshuafolkken/kit#2769).
40
+ function build_close_comment(pr_url: string | undefined, closer: string = FOLLOWUP_CLOSER): string {
41
+ return (
42
+ `Closed by \`${closer}\`: ${pr_url ?? 'the merged pull request'} merged, ` +
43
+ 'but GitHub did not apply its ' +
44
+ '`closes #N` keyword (joshuafolkken/kit#2770).'
45
+ )
46
+ }
47
+
48
+ // **Throws when the issue is still open**, so the guard around it reports the failure with the
49
+ // command that finishes it by hand — an issue left open after a merge is the one thing this step
50
+ // exists to stop going unnoticed.
51
+ async function ensure_issue_closed(
52
+ input: { issue_number: string | undefined; pr_url: string | undefined; closer?: string },
53
+ wait: PollOptions = CLOSE_WAIT,
54
+ ): Promise<void> {
55
+ const { issue_number } = input
56
+ const is_done =
57
+ issue_number === undefined ||
58
+ (await poll.poll_until(async () => await is_issue_closed(issue_number), wait))
59
+ if (is_done) return
60
+
61
+ console.warn(`⚠️ Issue #${issue_number} is still open after the merge — closing it.`)
62
+ const comment = build_close_comment(input.pr_url, input.closer)
63
+
64
+ if (!(await git_gh_command.issue_close(issue_number, comment))) {
65
+ throw new Error(`gh api could not close issue #${issue_number}`)
66
+ }
67
+ }
68
+
69
+ const git_followup_issue_close = {
70
+ ensure_issue_closed,
71
+ build_close_comment,
72
+ CLOSE_RECOVERY,
73
+ }
74
+
75
+ export { git_followup_issue_close }