@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
@@ -2,11 +2,15 @@ import { fileURLToPath } from 'node:url'
2
2
  import { auto_ok_cli, type OptedInRead, type TrackingRead } from '#scripts/auto-ok/auto-ok-cli'
3
3
  import { repo_discovery } from '#scripts/discovery/repo-discovery'
4
4
  import { epic_bundle_gaps } from '#scripts/epic/epic-bundle-gaps'
5
+ import { epic_busy } from '#scripts/epic/epic-busy'
6
+ import type { EpicChild } from '#scripts/epic/epic-graph'
5
7
  import { epic_index } from '#scripts/epic/epic-index'
6
8
  import { epic_next } from '#scripts/epic/epic-next'
7
9
  import { epic_next_read, type EpicRead } from '#scripts/epic/epic-next-read'
8
10
  import type { EpicView } from '#scripts/epic/epic-next-views'
9
11
  import { epic_report, type EpicNextResult, type EpicVerdict } from '#scripts/epic/epic-report'
12
+ import { epic_solo } from '#scripts/epic/epic-solo'
13
+ import { epic_triage, type TriageVerdict } from '#scripts/epic/epic-triage'
10
14
  import { git_gh_command } from '#scripts/git/git-gh-command'
11
15
  import { PROJECT_ROOT } from '#scripts/init/init-paths'
12
16
  import { issue_citation } from '#scripts/rules/issue-citation'
@@ -61,7 +65,10 @@ const RETRY_MESSAGE =
61
65
  // no failure path that could emit it, so widening the shared union would give every consumer of it a
62
66
  // member none of them can produce — and `VERDICT_LINES` a line nothing ever prints. The token is a
63
67
  // backlog-level answer about the transport, not a reading of one epic's graph.
64
- type BacklogVerdict = EpicVerdict | 'retry'
68
+ //
69
+ // `triage` is `epic:next`'s word as well as this command's (joshuafolkken/kit#2779), but it is decided
70
+ // after the graph is read rather than from it, so it stays `epic_triage`'s type too.
71
+ type BacklogVerdict = EpicVerdict | 'retry' | TriageVerdict
65
72
 
66
73
  const VERDICT_TOKENS: Readonly<Record<BacklogVerdict, string>> = {
67
74
  complete: auto_ok_cli.NONE_TOKEN,
@@ -69,6 +76,7 @@ const VERDICT_TOKENS: Readonly<Record<BacklogVerdict, string>> = {
69
76
  retry: 'retry',
70
77
  run: 'run',
71
78
  stop: 'stop',
79
+ triage: epic_triage.TRIAGE_VERDICT,
72
80
  wait: 'wait',
73
81
  }
74
82
 
@@ -266,13 +274,47 @@ function report_retry(context: PoolContext): number {
266
274
  return SUCCESS_EXIT_CODE
267
275
  }
268
276
 
277
+ // The `run:solo` gate is applied here rather than in `resolve`, because `backlog:plan` shares
278
+ // `resolve` and a plan made before the run lists everything (joshuafolkken/kit#2776). The holders are
279
+ // read only on a `run` answer — the one answer the gate can change.
280
+ async function gate_solo(result: EpicNextResult, repo: string): Promise<EpicNextResult> {
281
+ if (result.verdict !== 'run') return result
282
+
283
+ const gated = epic_solo.gate(result, await epic_busy.read_repository(repo), repo)
284
+
285
+ if (gated.notice !== undefined) console.error(gated.notice)
286
+
287
+ return gated.result
288
+ }
289
+
290
+ // An untriaged candidate withholds every candidate (joshuafolkken/kit#2779). Checked before the
291
+ // `run:solo` gate, which cannot be applied to an issue whose label is not recorded yet; the numbers go
292
+ // to standard error, the one token to standard output.
293
+ function report_triage(context: PoolContext, untriaged: ReadonlyArray<EpicChild>): number {
294
+ warn_epic_gap(context)
295
+ console.error(epic_triage.message(untriaged, context.repo))
296
+ console.info(VERDICT_TOKENS.triage)
297
+
298
+ return SUCCESS_EXIT_CODE
299
+ }
300
+
301
+ function untriaged_of(result: EpicNextResult, repo: string): ReadonlyArray<EpicChild> {
302
+ if (result.verdict !== 'run') return []
303
+
304
+ return epic_triage.untriaged(epic_report.candidates_for_repo(result, repo))
305
+ }
306
+
269
307
  async function answer_pool(context: PoolContext): Promise<number> {
270
308
  const result = await resolve(context)
271
309
 
272
310
  if (result === undefined) return FAILURE_EXIT_CODE
273
311
  if (is_transport_failure(result)) return report_retry(context)
274
312
 
275
- return report(result, context)
313
+ const untriaged = untriaged_of(result, context.repo)
314
+
315
+ if (untriaged.length > 0) return report_triage(context, untriaged)
316
+
317
+ return report(await gate_solo(result, context.repo), context)
276
318
  }
277
319
 
278
320
  // A backlog nobody has opted into answers `none` before either listing below is asked for.
@@ -362,6 +404,7 @@ const backlog_next = {
362
404
  resolve,
363
405
  is_transport_failure,
364
406
  report_retry,
407
+ report_triage,
365
408
  context_of,
366
409
  answer_pool,
367
410
  report_none,
@@ -40,6 +40,7 @@ const VERDICT_ANSWERS: Readonly<Record<string, BacklogAnswer>> = {
40
40
  [TOKENS.complete]: 'exhausted',
41
41
  [TOKENS.error]: 'unreadable',
42
42
  [TOKENS.stop]: 'parked',
43
+ [TOKENS.triage]: 'untriaged',
43
44
  }
44
45
 
45
46
  // `wait` is `blocked` while this run has children in flight and `exhausted` when it does not — the
@@ -9,8 +9,9 @@ import { open_issue_schema, type OpenIssueData } from '#scripts/git/schemas'
9
9
  import { run_invocation } from '#scripts/run/run-invocation'
10
10
  import { backlog_named } from './backlog-named'
11
11
  import { backlog_next, type OptedIn } from './backlog-next'
12
- import { backlog_plan, type NamedPlan } from './backlog-plan'
12
+ import { backlog_plan, type NamedPlan, type PlanContext } from './backlog-plan'
13
13
  import { backlog_scope } from './backlog-scope'
14
+ import { backlog_waves } from './backlog-waves'
14
15
 
15
16
  // `josh backlog:plan` — the whole backlog as a plan a person reads before a run starts
16
17
  // (joshuafolkken/kit#1652).
@@ -36,9 +37,18 @@ const NAMED_COMMAND = 'backlogrun'
36
37
  // The flag that runs the named list and stops, mirrored here so the plan the person reads before the
37
38
  // run shows the same scope the run will take (joshuafolkken/kit#1984).
38
39
  const ONLY_FLAG = '--only'
40
+ // The flag that renders the run's order wave by wave instead of the pool's sections
41
+ // (joshuafolkken/kit#2778).
42
+ const WAVES_FLAG = '--waves'
39
43
 
40
44
  const USAGE =
41
- 'Usage: josh backlog:plan [#<issue-number>...] [--only] [--exclude <issue-number>[,<issue-number>...]]...'
45
+ 'Usage: josh backlog:plan [#<issue-number>...] [--only] [--waves] [--exclude <issue-number>[,<issue-number>...]]...'
46
+
47
+ // The waves play the opted-in pool forward, and a named prefix runs ahead of it one issue at a time —
48
+ // an epic among them running its children first — so waves drawn from the pool alone would promise an
49
+ // order that invocation does not take.
50
+ const WAVES_WITH_NAMED_MESSAGE =
51
+ '`--waves` plans the opted-in backlog alone; drop the named issues (and `--only`) to see its waves.'
42
52
 
43
53
  // The leading `#N` tokens are the named prefix a `backlogrun #N1 #N2 …` runs before the pool. They are
44
54
  // read through the invocation grammar so the plan and the run agree on what counts as a named issue.
@@ -56,10 +66,10 @@ function has_only_flag(argv: ReadonlyArray<string>): boolean {
56
66
  return argv.includes(ONLY_FLAG)
57
67
  }
58
68
 
59
- // `--only` is stripped before the named block and the options are read, so it is never taken for a
60
- // positional or an unknown option; whether it was present is carried as a boolean instead.
61
- function without_only(argv: ReadonlyArray<string>): ReadonlyArray<string> {
62
- return argv.filter((token) => token !== ONLY_FLAG)
69
+ // `--only` and `--waves` are stripped before the named block and the options are read, so neither is
70
+ // taken for a positional or an unknown option; whether each was present is carried as a boolean.
71
+ function without_flags(argv: ReadonlyArray<string>): ReadonlyArray<string> {
72
+ return argv.filter((token) => token !== ONLY_FLAG && token !== WAVES_FLAG)
63
73
  }
64
74
 
65
75
  // The one start-time refusal: `--only` with no named issues has nothing to run. `backlog_named` is the
@@ -103,6 +113,12 @@ interface OpenListing {
103
113
  is_capped: boolean
104
114
  }
105
115
 
116
+ // Which rendering was asked for: the named prefix the sections lead with, or the waves.
117
+ interface PlanView {
118
+ named: NamedPlan
119
+ is_waves: boolean
120
+ }
121
+
106
122
  // The same reads and the same classification `backlog:next` makes, through the same two functions.
107
123
  // `undefined` is a failure it has already reported.
108
124
  async function classify(
@@ -138,26 +154,32 @@ async function fetch_open(): Promise<OpenListing | undefined> {
138
154
  return read.kind === 'read' ? { rows: read.rows, is_capped } : undefined
139
155
  }
140
156
 
141
- function print_plan(
142
- plan: Plan,
143
- open_issues: ReadonlyArray<OpenIssueData>,
144
- is_capped: boolean,
145
- named: NamedPlan,
146
- ): void {
147
- const scope = { repo: plan.repo, exclude: plan.exclude, tracked: plan.tracked }
148
- const rows = backlog_scope.out_of_scope(open_issues, plan.result, scope)
149
- // A cut listing cannot say an issue is closed, only that it was not read — so the blocker filter is
150
- // switched off rather than fed a set that would silently report standing blockers as gone.
151
- const context = {
157
+ // A cut listing cannot say an issue is closed, only that it was not read — so the blocker filter is
158
+ // switched off rather than fed a set that would silently report standing blockers as gone.
159
+ function context_of(plan: Plan, listing: OpenListing): PlanContext {
160
+ return {
152
161
  repo: plan.repo,
153
- titles: backlog_scope.titles_of(open_issues),
154
- open_numbers: is_capped ? undefined : backlog_scope.open_numbers_of(open_issues),
162
+ titles: backlog_scope.titles_of(listing.rows),
163
+ open_numbers: listing.is_capped ? undefined : backlog_scope.open_numbers_of(listing.rows),
155
164
  }
165
+ }
166
+
167
+ function print_plan(plan: Plan, listing: OpenListing, view: PlanView): void {
168
+ const context = context_of(plan, listing)
169
+
170
+ if (view.is_waves) {
171
+ console.info(backlog_waves.format_waves(plan.result, context))
156
172
 
157
- console.info(backlog_plan.format_plan(plan.result, rows, context, named))
173
+ return
174
+ }
175
+
176
+ const scope = { repo: plan.repo, exclude: plan.exclude, tracked: plan.tracked }
177
+ const rows = backlog_scope.out_of_scope(listing.rows, plan.result, scope)
178
+
179
+ console.info(backlog_plan.format_plan(plan.result, rows, context, view.named))
158
180
  }
159
181
 
160
- async function report(plan: Plan, named: NamedPlan): Promise<number> {
182
+ async function report(plan: Plan, view: PlanView): Promise<number> {
161
183
  const listing = await fetch_open()
162
184
 
163
185
  if (listing === undefined) {
@@ -167,12 +189,12 @@ async function report(plan: Plan, named: NamedPlan): Promise<number> {
167
189
  }
168
190
 
169
191
  if (listing.is_capped) console.error(OPEN_TRUNCATED_MESSAGE)
170
- print_plan(plan, listing.rows, listing.is_capped, named)
192
+ print_plan(plan, listing, view)
171
193
 
172
194
  return SUCCESS_EXIT_CODE
173
195
  }
174
196
 
175
- async function run_plan(rest: ReadonlyArray<string>, named: NamedPlan): Promise<number> {
197
+ async function run_plan(rest: ReadonlyArray<string>, view: PlanView): Promise<number> {
176
198
  const options = auto_ok_cli.parse_options(without_named(rest), USAGE)
177
199
 
178
200
  if (options.usage !== undefined) {
@@ -191,17 +213,33 @@ async function run_plan(rest: ReadonlyArray<string>, named: NamedPlan): Promise<
191
213
 
192
214
  const plan = await classify(opted_in, options.exclude ?? [])
193
215
 
194
- return plan === undefined ? FAILURE_EXIT_CODE : await report(plan, named)
216
+ return plan === undefined ? FAILURE_EXIT_CODE : await report(plan, view)
217
+ }
218
+
219
+ // The start-time refusals, `--only`'s and `--waves`'s, in one place so `run` stays a straight line.
220
+ //
221
+ // The waves refusal reads the named block from the flag-stripped tokens, so `--waves #5` is refused
222
+ // like `#5 --waves` rather than having its `#5` dropped by the option parser.
223
+ function refusal_of(
224
+ named: ReadonlyArray<number>,
225
+ rest: ReadonlyArray<string>,
226
+ is_only: boolean,
227
+ is_waves: boolean,
228
+ ): string | undefined {
229
+ if (!is_waves) return only_refusal(named, is_only)
230
+
231
+ return is_only || named_of(rest).length > 0 ? WAVES_WITH_NAMED_MESSAGE : undefined
195
232
  }
196
233
 
197
234
  async function run(argv: ReadonlyArray<string>): Promise<number> {
198
235
  const is_only = has_only_flag(argv)
199
- const rest = without_only(argv)
236
+ const is_waves = argv.includes(WAVES_FLAG)
237
+ const rest = without_flags(argv)
200
238
  // Read the named block from the *original* argv, so `--only` placed before the `#N` block breaks the
201
239
  // leading block exactly as the run grammar does — the plan then refuses an ordering the run refuses,
202
240
  // rather than promising a scope the resumed invocation would stop on (joshuafolkken/kit#1984).
203
241
  const named = named_of(argv)
204
- const refusal = only_refusal(named, is_only)
242
+ const refusal = refusal_of(named, rest, is_only, is_waves)
205
243
 
206
244
  if (refusal !== undefined) {
207
245
  console.error(refusal)
@@ -209,7 +247,7 @@ async function run(argv: ReadonlyArray<string>): Promise<number> {
209
247
  return FAILURE_EXIT_CODE
210
248
  }
211
249
 
212
- return await run_plan(rest, { issues: named, only: is_only })
250
+ return await run_plan(rest, { named: { issues: named, only: is_only }, is_waves })
213
251
  }
214
252
 
215
253
  // `process.exitCode` rather than `process.exit()`, the reason `backlog:next` records: the plan is
@@ -222,6 +260,7 @@ const backlog_plan_cli = {
222
260
  OPEN_TRUNCATED_MESSAGE,
223
261
  OPEN_UNREADABLE_MESSAGE,
224
262
  USAGE,
263
+ WAVES_WITH_NAMED_MESSAGE,
225
264
  classify,
226
265
  fetch_open,
227
266
  main,
@@ -1,7 +1,9 @@
1
1
  import type { EpicChild } from '#scripts/epic/epic-graph'
2
2
  import { epic_report, type EpicNextResult } from '#scripts/epic/epic-report'
3
+ import { epic_solo } from '#scripts/epic/epic-solo'
4
+ import { epic_triage } from '#scripts/epic/epic-triage'
3
5
  import type { IssueReference } from '#scripts/git/git-epic-reference'
4
- import { has_label_name, IN_PROGRESS_LABEL } from '#scripts/git/issue-labels'
6
+ import { has_label_name, IN_PROGRESS_LABEL, RUN_SOLO_LABEL } from '#scripts/git/issue-labels'
5
7
  import type { OutOfScopeRow } from './backlog-scope'
6
8
 
7
9
  // The plan a person reads before a `backlogrun` starts (joshuafolkken/kit#1652).
@@ -74,8 +76,28 @@ function join_row(reference: string, title: string, note: string): string {
74
76
  return `${ROW_INDENT}${parts.join(' ')}`
75
77
  }
76
78
 
79
+ // A `run:solo` child is marked beside its number, so a person reading the plan sees which issues
80
+ // `backlog:next` will start alone before the run starts (joshuafolkken/kit#2776).
81
+ const SOLO_MARK = `[${RUN_SOLO_LABEL}]`
82
+ // A child with neither `run:solo` nor `run:lane` is one `backlog:next` withholds everything for until
83
+ // it is judged (joshuafolkken/kit#2779), so the plan says which ones before the run asks.
84
+ const UNTRIAGED_MARK = '[untriaged]'
85
+
86
+ function mark_of(child: EpicChild): string | undefined {
87
+ if (epic_solo.is_solo(child)) return SOLO_MARK
88
+
89
+ return epic_triage.is_triaged(child) ? undefined : UNTRIAGED_MARK
90
+ }
91
+
92
+ function marked_reference(child: EpicChild, repo: string): string {
93
+ const reference = reference_of(child, repo)
94
+ const mark = mark_of(child)
95
+
96
+ return mark === undefined ? reference : `${reference} ${mark}`
97
+ }
98
+
77
99
  function row_of(child: EpicChild, context: PlanContext, note: string): string {
78
- return join_row(reference_of(child, context.repo), title_of(child, context), note)
100
+ return join_row(marked_reference(child, context.repo), title_of(child, context), note)
79
101
  }
80
102
 
81
103
  // Why this one is not offered yet. The blocker numbers are the answer whenever there are any — that
@@ -221,9 +243,15 @@ const backlog_plan = {
221
243
  PAST_OFFER_NOTE,
222
244
  READY_HEADING,
223
245
  SCOPE_HEADING,
246
+ SOLO_MARK,
247
+ UNTRIAGED_MARK,
224
248
  UNUSABLE_HEADING,
225
249
  WAITING_HEADING,
226
250
  format_plan,
251
+ format_unusable,
252
+ marked_reference,
253
+ row_of,
254
+ section,
227
255
  waiting_note,
228
256
  }
229
257
 
@@ -1,3 +1,4 @@
1
+ import { epic_triage } from '#scripts/epic/epic-triage'
1
2
  import { run_event_stream, type RunEvent } from '#scripts/run/run-event-stream'
2
3
 
3
4
  // When ready backlog work sits undispatched while lanes are free, nobody notices until a person asks
@@ -101,6 +102,13 @@ function count_ready_tokens(out: string): number {
101
102
  return ready_tokens(out).length
102
103
  }
103
104
 
105
+ // Whether `backlog:next` answered `triage` (joshuafolkken/kit#2779): no number is runnable, yet a session
106
+ // has work — judging the untriaged issues — so a reader of the numeric lines alone would take it for an
107
+ // empty pool.
108
+ function needs_triage(out: string): boolean {
109
+ return out.split('\n').some((token) => token.trim() === epic_triage.TRIAGE_VERDICT)
110
+ }
111
+
104
112
  const backlog_stalled = {
105
113
  STALLED,
106
114
  OK,
@@ -111,6 +119,7 @@ const backlog_stalled = {
111
119
  describe,
112
120
  dispatch_age_ms,
113
121
  is_stalled,
122
+ needs_triage,
114
123
  ready_tokens,
115
124
  }
116
125
 
@@ -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 = {