@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.
- 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 +3 -4
- package/.claude/skills/workflow-commands/observation-filing.md +31 -25
- package/.claude/skills/workflow-commands/pre-gate-cut.md +96 -161
- package/.github/workflows/pr-classification.yml +12 -1
- package/CLAUDE.md +3 -3
- package/README.md +5 -3
- package/dist/CLAUDE.md +3 -3
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-OM5VDM6F.js → chunk-2K52BRWV.js} +2 -2
- package/dist/hooks/{chunk-Z5TVZ4AP.js → chunk-BNWIOJFU.js} +29 -16
- package/dist/hooks/{chunk-NKVLBB65.js → chunk-BVMSPG3M.js} +3 -3
- package/dist/hooks/{chunk-IA7WC4NS.js → chunk-CRGJTD6E.js} +1 -1
- package/dist/hooks/{chunk-M4U4YNRE.js → chunk-F6OM6SIG.js} +3 -3
- package/dist/hooks/{chunk-O3PEDF3M.js → chunk-KQG4776P.js} +1 -1
- package/dist/hooks/{chunk-V7RH56HU.js → chunk-NXL3SBXY.js} +2 -2
- package/dist/hooks/{chunk-452OKRQX.js → chunk-WWTMHHZU.js} +120 -18
- package/dist/hooks/{chunk-EYFJ74MJ.js → chunk-ZPLXLJ7O.js} +21 -7
- package/dist/hooks/{chunk-IBFC25IX.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 +14 -6
- package/package.json +3 -3
- 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-drive-cli.ts +5 -1
- package/scripts/backlog/backlog-drive-retrospective.ts +20 -0
- package/scripts/backlog/backlog-drive.ts +7 -3
- 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/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 +42 -0
- package/scripts/hooks/stop-guard.ts +1 -0
- package/scripts/init/init-install.ts +64 -0
- package/scripts/init/init.ts +38 -11
- package/scripts/init/project-profile.ts +9 -5
- package/scripts/init/start-plan.ts +145 -0
- package/scripts/init/start-prompt.ts +55 -0
- package/scripts/init/start-steps.ts +167 -0
- package/scripts/init/start.ts +92 -16
- package/scripts/issue/issue-closing-pr.ts +93 -0
- package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +4 -2
- package/scripts/josh/josh-commands-project.ts +11 -3
- 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/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 +36 -2
- package/scripts/run/run-headless.ts +1 -0
- package/scripts/run/run-hold-cli.ts +36 -15
- package/scripts/run/run-hold.ts +15 -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-retrospective.ts +34 -0
- package/scripts/run/run-step-cli.ts +19 -5
- package/scripts/run/run-step.ts +31 -19
- 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/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
|
@@ -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
|
-
|
|
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.
|
|
56
|
-
//
|
|
57
|
-
type BudgetVerdict =
|
|
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
|
|
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,
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|