@joshuafolkken/kit 1.957.0 → 1.978.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/workflow-commands/background-commands.md +3 -0
- package/.claude/skills/workflow-commands/backlogrun-child.md +75 -120
- package/.claude/skills/workflow-commands/backlogrun-lanes.md +78 -115
- package/.claude/skills/workflow-commands/backlogrun-progress.md +82 -146
- package/.claude/skills/workflow-commands/backlogrun-steps.md +173 -229
- package/.claude/skills/workflow-commands/backlogrun.md +3 -1
- package/.claude/skills/workflow-commands/followup.md +3 -0
- package/.claude/skills/workflow-commands/fullrun.md +4 -4
- package/.claude/skills/workflow-commands/halfrun.md +12 -7
- package/.claude/skills/workflow-commands/observation-filing.md +31 -25
- package/.claude/skills/workflow-commands/pre-gate-cut.md +96 -161
- package/.claude/skills/workflow-commands/working-tree-hold.md +16 -1
- package/.github/workflows/pr-classification.yml +12 -1
- package/CLAUDE.md +3 -3
- package/README.md +25 -21
- package/dist/CLAUDE.md +3 -3
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-XSL4SRMA.js → chunk-2K52BRWV.js} +2 -2
- package/dist/hooks/{chunk-M6K7EQDO.js → chunk-BNGGEY3Z.js} +38 -32
- package/dist/hooks/{chunk-KOCK5FXQ.js → chunk-BVMSPG3M.js} +3 -3
- package/dist/hooks/{chunk-3ON7HOYB.js → chunk-CRGJTD6E.js} +1 -1
- package/dist/hooks/{chunk-HJXQZ7V4.js → chunk-F6OM6SIG.js} +3 -3
- package/dist/hooks/{chunk-LDQDZYHX.js → chunk-KQG4776P.js} +1 -1
- package/dist/hooks/{chunk-244WYOEO.js → chunk-NXL3SBXY.js} +2 -2
- package/dist/hooks/{chunk-IJPBTDTB.js → chunk-Q7LE77CO.js} +88 -18
- package/dist/hooks/{chunk-BELI5XSS.js → chunk-ZPLXLJ7O.js} +15 -5
- package/dist/hooks/{chunk-GNOITLRR.js → chunk-ZYYSBNR6.js} +4 -4
- package/dist/hooks/codex-hook-adapter.js +10 -10
- package/dist/hooks/duplicate-read-guard.js +5 -5
- package/dist/hooks/format-edited.js +2 -2
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +9 -9
- package/dist/hooks/stop-guard.js +206 -176
- package/dist/josh.js +8 -4
- package/package.json +4 -4
- package/prompts/collaboration-workflow/wip-cap.md +1 -1
- package/scripts/backlog/backlog-arrival.ts +11 -1
- package/scripts/backlog/backlog-budget.ts +25 -8
- package/scripts/backlog/backlog-next.ts +45 -2
- package/scripts/backlog/backlog-offer.ts +1 -0
- package/scripts/backlog/backlog-plan-cli.ts +66 -27
- package/scripts/backlog/backlog-plan.ts +30 -2
- package/scripts/backlog/backlog-stalled.ts +9 -0
- package/scripts/backlog/backlog-waves.ts +169 -0
- package/scripts/ci/pr-classification.ts +13 -12
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/document/entry-read-budget.ts +7 -5
- package/scripts/epic/epic-lane-offer.ts +69 -4
- package/scripts/epic/epic-next.ts +2 -2
- package/scripts/epic/epic-solo.ts +147 -0
- package/scripts/epic/epic-triage.ts +47 -0
- package/scripts/gate/hook-gate-reuse.ts +7 -8
- package/scripts/git/git-closes-keyword.ts +15 -0
- package/scripts/git/git-followup-flush.ts +5 -6
- package/scripts/git/git-followup-issue-close.ts +75 -0
- package/scripts/git/git-pr-followup-wrapup.ts +36 -6
- package/scripts/git/git-pr-followup.ts +9 -16
- package/scripts/git/git-staging.ts +33 -20
- package/scripts/git/issue-labels.ts +75 -5
- package/scripts/hooks/stop-guard.ts +1 -0
- package/scripts/init/init-bootstrap.ts +92 -0
- package/scripts/init/init-install.ts +67 -0
- package/scripts/init/init-logic-workspace.ts +1 -1
- package/scripts/init/init.ts +58 -13
- package/scripts/init/start-plan.ts +1 -1
- package/scripts/init/start-steps.ts +5 -28
- package/scripts/issue/issue-closing-pr.ts +93 -0
- package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +2 -1
- package/scripts/josh/josh-commands-project.ts +5 -1
- package/scripts/josh/josh-commands-workflow.ts +3 -3
- package/scripts/observations/observation-ledger-prepare.ts +31 -0
- package/scripts/observations/observation-ledger.ts +5 -5
- package/scripts/observations/observations-flush.ts +11 -30
- package/scripts/repo/repository-labels.ts +91 -0
- package/scripts/rules/implementation-cut.ts +49 -16
- package/scripts/rules/lane-background.ts +25 -5
- package/scripts/rules/stop-rules.ts +16 -4
- package/scripts/run/run-carry-cli.ts +21 -0
- package/scripts/run/run-cut-cli.ts +92 -110
- package/scripts/run/run-cut-report.ts +167 -0
- package/scripts/run/run-cut.ts +23 -7
- package/scripts/run/run-entry-cli.ts +70 -3
- package/scripts/run/run-halfrun-resume.ts +104 -0
- package/scripts/run/run-headless.ts +1 -0
- package/scripts/run/run-hold-cli.ts +57 -13
- package/scripts/run/run-hold.ts +26 -4
- package/scripts/run/run-merge-cli.ts +28 -14
- package/scripts/run/run-merge-steps.ts +38 -2
- package/scripts/run/run-merge.ts +41 -3
- package/scripts/run/run-prep-cli.ts +8 -2
- package/scripts/run/run-prep-locate.ts +165 -0
- package/scripts/run/run-prep.ts +12 -1
- package/scripts/run/run-step-cli.ts +16 -0
- package/scripts/run/run-step.ts +28 -4
- package/scripts/run/run-tail-cli.ts +4 -2
- package/scripts/run/run-wake-work.ts +4 -0
- package/scripts/safe-chain/ci-installer-pin.ts +8 -4
- package/scripts/security/security-audit.ts +12 -1
- package/scripts/sync/sync.ts +6 -0
- package/scripts/time-runtime/time-background.ts +11 -0
- package/scripts/time-runtime/time-transcript-line.ts +4 -0
- 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
|
-
|
|
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
|
-
|
|
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`
|
|
60
|
-
// positional or an unknown option; whether
|
|
61
|
-
function
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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(
|
|
154
|
-
open_numbers: is_capped ? undefined : backlog_scope.open_numbers_of(
|
|
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
|
-
|
|
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,
|
|
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
|
|
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>,
|
|
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,
|
|
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
|
|
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 =
|
|
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(
|
|
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 }
|
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import {
|
|
4
|
+
BREAKING_CHANGE_LABEL as BREAKING,
|
|
5
|
+
BUGFIX_LABEL as BUGFIX,
|
|
6
|
+
RELEASE_CLASSIFICATION_NAMES as CLASSIFICATION_LABELS,
|
|
7
|
+
ENHANCEMENT_LABEL,
|
|
8
|
+
type ReleaseClassification,
|
|
9
|
+
} from '#scripts/git/issue-labels'
|
|
3
10
|
import { execaSync } from 'execa'
|
|
4
11
|
import { z } from 'zod'
|
|
5
12
|
|
|
6
|
-
const BUGFIX = 'bugfix'
|
|
7
|
-
const BREAKING = 'breaking-change'
|
|
8
|
-
const CLASSIFICATION_LABELS = [
|
|
9
|
-
BREAKING,
|
|
10
|
-
'enhancement',
|
|
11
|
-
BUGFIX,
|
|
12
|
-
'other-change',
|
|
13
|
-
'ignore-for-release',
|
|
14
|
-
] as const
|
|
15
|
-
type ReleaseClassification = (typeof CLASSIFICATION_LABELS)[number]
|
|
16
13
|
const CLASSIFICATION_SET: ReadonlySet<string> = new Set(CLASSIFICATION_LABELS)
|
|
17
14
|
const ISSUE_BUG_LABEL = 'bug'
|
|
18
15
|
const DECLARATION = /^- リリース分類: (.+)$/gmu
|
|
@@ -50,7 +47,7 @@ function release_candidates(
|
|
|
50
47
|
): ReadonlyArray<ReleaseClassification> {
|
|
51
48
|
if (!candidates.includes(BREAKING)) return candidates
|
|
52
49
|
|
|
53
|
-
return candidates.filter((candidate) => candidate !==
|
|
50
|
+
return candidates.filter((candidate) => candidate !== ENHANCEMENT_LABEL)
|
|
54
51
|
}
|
|
55
52
|
|
|
56
53
|
function issue_candidates(issue_json: string): ReadonlyArray<ReleaseClassification> {
|
|
@@ -132,4 +129,8 @@ const pr_classification = {
|
|
|
132
129
|
select_issue_classification,
|
|
133
130
|
}
|
|
134
131
|
|
|
135
|
-
export { pr_classification
|
|
132
|
+
export { pr_classification }
|
|
133
|
+
export {
|
|
134
|
+
RELEASE_CLASSIFICATION_NAMES as CLASSIFICATION_LABELS,
|
|
135
|
+
type ReleaseClassification,
|
|
136
|
+
} from '#scripts/git/issue-labels'
|
|
@@ -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:
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
{ entry: '
|
|
82
|
-
{ entry: '
|
|
83
|
-
{ entry:
|
|
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 {
|