@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
@@ -1,3 +1,4 @@
1
+ import { epic_triage } from '#scripts/epic/epic-triage'
1
2
  import type { JoshResult } from '#scripts/josh/josh-run'
2
3
  import { run_headless } from '#scripts/run/run-headless'
3
4
  import { backlog_next } from './backlog-next'
@@ -51,6 +52,10 @@ function answered_issues(result: JoshResult | undefined): ReadonlyArray<string>
51
52
  throw new Error('`backlog:next` did not answer')
52
53
  }
53
54
 
55
+ // `triage` names no number, yet the parent has work — so it is read as one token, which a reading
56
+ // sees as an arrival (joshuafolkken/kit#2779).
57
+ if (backlog_stalled.needs_triage(result.out)) return [epic_triage.TRIAGE_VERDICT]
58
+
54
59
  return backlog_stalled.ready_tokens(result.out)
55
60
  }
56
61
 
@@ -76,9 +81,14 @@ function arrivals(baseline: ReadonlySet<string>, reading: ReadyReading): Readonl
76
81
 
77
82
  // The baseline is the whole runnable pool, free lane or not: an issue that was waiting for a lane is not
78
83
  // new when one frees, and the child's merge that freed it already wakes the parent.
84
+ //
85
+ // A baseline read during a `triage` answer names none of the issues it withheld, so every one of them
86
+ // would arrive once they were judged — the whole-pool wake. It is read as a baseline that did not answer.
79
87
  async function read_baseline(ports: ReadyPorts): Promise<ReadonlySet<string> | undefined> {
80
88
  try {
81
- return new Set(await ports.ready_issues())
89
+ const issues = await ports.ready_issues()
90
+
91
+ return issues.includes(epic_triage.TRIAGE_VERDICT) ? undefined : new Set(issues)
82
92
  } catch {
83
93
  return undefined
84
94
  }
@@ -47,20 +47,22 @@ const IDLE_POLL_MINUTES = 5
47
47
  const RUN_VERDICT = 'run'
48
48
  const WATCH_VERDICT = 'watch'
49
49
  const STOP_VERDICT = 'stop'
50
+ const TRIAGE_VERDICT = 'triage'
50
51
 
51
52
  // `run` — start what `backlog:next` offered. `watch` — sleep the polling interval and ask again,
52
53
  // except while something of the run's own is in flight, where the wake is the progress watcher's exit
53
54
  // and the interval is only a floor (`backlogrun.md` → "The wake exists only while something is in
54
55
  // flight").
55
- // `stop` — report and finish. There is no fourth: an answer the loop cannot act on is a verdict
56
- // nobody can write a loop against.
57
- type BudgetVerdict = typeof RUN_VERDICT | typeof WATCH_VERDICT | typeof STOP_VERDICT
56
+ // `stop` — report and finish. `triage` — judge the untriaged issues `backlog:next` named, then ask
57
+ // again (joshuafolkken/kit#2779); it is the parent's to act on, so `backlog:drive` hands it back.
58
+ type BudgetVerdict =
59
+ typeof RUN_VERDICT | typeof WATCH_VERDICT | typeof STOP_VERDICT | typeof TRIAGE_VERDICT
58
60
 
59
61
  // What `backlog:next` answered, in the words this decision needs. The mapping is mechanical and is
60
62
  // written once, in `backlogrun-steps.md` → "The loop": numbers are `candidates`, `wait` is `blocked` while
61
63
  // this run has children in flight and `exhausted` when it does not, `none` is `exhausted`, `stop` is
62
- // `parked`, and `error` or a failed listing is `unreadable`.
63
- const ANSWERS = ['blocked', 'candidates', 'exhausted', 'parked', 'unreadable'] as const
64
+ // `parked`, `triage` is `untriaged`, and `error` or a failed listing is `unreadable`.
65
+ const ANSWERS = ['blocked', 'candidates', 'exhausted', 'parked', 'unreadable', 'untriaged'] as const
64
66
 
65
67
  type BacklogAnswer = (typeof ANSWERS)[number]
66
68
 
@@ -102,6 +104,9 @@ const UNREADABLE_REASON =
102
104
  const BLOCKED_REASON =
103
105
  'Everything opted in is blocked or already running, so waiting can still change the answer. Poll again at the polling interval.'
104
106
 
107
+ const TRIAGE_REASON =
108
+ 'An opted-in candidate carries neither `run:solo` nor `run:lane`, so nothing starts until it is judged. Read each issue named above, record its order and one of the two labels with a comment, then ask again.'
109
+
105
110
  const NO_IDLE_WATCH_REASON =
106
111
  'The backlog is empty and the idle watch was turned off with `--idle 0`, so the run finishes here.'
107
112
 
@@ -237,6 +242,18 @@ function is_finish(input: BudgetInput): boolean {
237
242
  return stop_reason(input) === undefined && max_decision(input) === undefined
238
243
  }
239
244
 
245
+ // The answers whose decision no count changes, once the bounds above have been applied.
246
+ const FIXED_DECISIONS: Readonly<Partial<Record<BacklogAnswer, BudgetDecision>>> = {
247
+ blocked: { verdict: WATCH_VERDICT, reason: BLOCKED_REASON },
248
+ untriaged: { verdict: TRIAGE_VERDICT, reason: TRIAGE_REASON },
249
+ }
250
+
251
+ function answer_decision(input: BudgetInput): BudgetDecision {
252
+ if (input.answer === 'candidates') return { verdict: RUN_VERDICT, reason: run_reason(input) }
253
+
254
+ return FIXED_DECISIONS[input.answer] ?? idle_decision(input)
255
+ }
256
+
240
257
  function decide(input: BudgetInput): BudgetDecision {
241
258
  const reason = stop_reason(input)
242
259
 
@@ -245,10 +262,8 @@ function decide(input: BudgetInput): BudgetDecision {
245
262
  const capped = max_decision(input)
246
263
 
247
264
  if (capped !== undefined) return capped
248
- if (input.answer === 'candidates') return { verdict: RUN_VERDICT, reason: run_reason(input) }
249
- if (input.answer === 'blocked') return { verdict: WATCH_VERDICT, reason: BLOCKED_REASON }
250
265
 
251
- return idle_decision(input)
266
+ return answer_decision(input)
252
267
  }
253
268
 
254
269
  const backlog_budget = {
@@ -263,6 +278,8 @@ const backlog_budget = {
263
278
  PARKED_REASON,
264
279
  RUN_VERDICT,
265
280
  STOP_VERDICT,
281
+ TRIAGE_REASON,
282
+ TRIAGE_VERDICT,
266
283
  UNREADABLE_REASON,
267
284
  WATCH_VERDICT,
268
285
  WHOLE_RUN_BUDGET_HOURS,
@@ -3,6 +3,7 @@ import { setTimeout as sleep } from 'node:timers/promises'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { parseArgs } from 'node:util'
5
5
  import { CONTEXT_CUT_THRESHOLD } from '#scripts/cost-runtime/context-cut-threshold'
6
+ import { hook_decision } from '#scripts/josh/hook-decision'
6
7
  import { josh_command } from '#scripts/josh/josh-run'
7
8
  import { lane_await, type AwaitState } from '#scripts/lane/lane-await'
8
9
  import { lane_launch_cli } from '#scripts/lane/lane-launch-cli'
@@ -24,6 +25,7 @@ import { backlog_drive_named_offer } from './backlog-drive-named-offer'
24
25
  import { backlog_drive_offer_argv } from './backlog-drive-offer-argv'
25
26
  import { backlog_drive_owner } from './backlog-drive-owner'
26
27
  import { backlog_drive_restore } from './backlog-drive-restore'
28
+ import { backlog_drive_retrospective } from './backlog-drive-retrospective'
27
29
  import { backlog_offer_cli } from './backlog-offer-cli'
28
30
 
29
31
  // `josh backlog:drive` — the backlogrun parent's loop as one resident wait (joshuafolkken/kit#2499).
@@ -193,7 +195,7 @@ async function read_offer(
193
195
 
194
196
  return offer === undefined
195
197
  ? undefined
196
- : { ...offer, is_retrospective_done: carry.retrospective === true }
198
+ : { ...offer, is_retrospective_owed: backlog_drive_retrospective.is_owed(carry) }
197
199
  }
198
200
 
199
201
  // A non-zero `run:merge` prints its token first only for a hand-off (`busy`, `stop`, `retry`); any
@@ -357,6 +359,8 @@ async function run(argv: ReadonlyArray<string>): Promise<number> {
357
359
  }
358
360
 
359
361
  async function main(argv: ReadonlyArray<string>): Promise<void> {
362
+ // Load `.env` on the real command path only, so `JOSH_RETROSPECTIVE` set there reaches the offer read.
363
+ hook_decision.load_environment_file()
360
364
  process.exitCode = await run(argv)
361
365
  }
362
366
 
@@ -0,0 +1,20 @@
1
+ import { doctor_consumer } from '#scripts/doctor/doctor-consumer'
2
+ import { find_package_directory } from '#scripts/josh/josh-logic'
3
+ import type { RunCarry } from '#scripts/run/run-carry'
4
+ import { run_retrospective } from '#scripts/run/run-retrospective'
5
+
6
+ // Whether a drain owes the retrospective, read through the owed rule `run:step` reads, so a drain it
7
+ // owes nothing at keeps the idle watch rather than waking a session with nothing to run
8
+ // (joshuafolkken/kit#2750). The drive is the parent's loop, never a lane child's.
9
+ function is_owed(carry: RunCarry): boolean {
10
+ const root = find_package_directory(process.cwd())
11
+
12
+ return run_retrospective.is_owed({
13
+ is_retrospective_enabled: run_retrospective.is_enabled(),
14
+ is_lane_child: false,
15
+ is_retrospective_done: carry.retrospective === true,
16
+ is_consumer: doctor_consumer.is_kit_consumer(root),
17
+ })
18
+ }
19
+
20
+ export const backlog_drive_retrospective = { is_owed }
@@ -45,7 +45,9 @@ interface OfferRead {
45
45
  retries: number
46
46
  answer?: string | undefined
47
47
  reason?: string | undefined
48
- is_retrospective_done?: boolean
48
+ // Whether a drain here owes the retrospective: the opt-in switch is on and it has not run yet. Absent
49
+ // reads as not owed, so a drain with the switch off keeps the idle watch (joshuafolkken/kit#2750).
50
+ is_retrospective_owed?: boolean
49
51
  is_finish?: boolean | undefined
50
52
  }
51
53
 
@@ -106,7 +108,9 @@ function ended(reason: string, state: DriveState, token?: string, issue?: string
106
108
 
107
109
  function is_drain(read: OfferRead, state: DriveState): boolean {
108
110
  return (
109
- read.verdict === WATCH_VERDICT && state.in_flight.length === 0 && !read.is_retrospective_done
111
+ read.verdict === WATCH_VERDICT &&
112
+ state.in_flight.length === 0 &&
113
+ read.is_retrospective_owed === true
110
114
  )
111
115
  }
112
116
 
@@ -115,7 +119,7 @@ function needs_retrospective(read: OfferRead, state: DriveState): boolean {
115
119
  read.verdict === STOP_VERDICT &&
116
120
  read.answer === 'exhausted' &&
117
121
  state.in_flight.length === 0 &&
118
- !read.is_retrospective_done
122
+ read.is_retrospective_owed === true
119
123
  )
120
124
  }
121
125
 
@@ -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