@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
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
} from './epic-candidate-confirm'
|
|
8
8
|
import { epic_graph, type EpicChild } from './epic-graph'
|
|
9
9
|
import type { EpicVerdict } from './epic-report'
|
|
10
|
+
import { epic_solo } from './epic-solo'
|
|
11
|
+
import { epic_triage, type TriageVerdict } from './epic-triage'
|
|
10
12
|
|
|
11
13
|
// Which children a repository has room to start right now (joshuafolkken/kit#1491).
|
|
12
14
|
//
|
|
@@ -50,7 +52,7 @@ interface LaneRequest {
|
|
|
50
52
|
// output carries issue numbers or a verdict and nothing else.
|
|
51
53
|
interface LaneOffer {
|
|
52
54
|
children: ReadonlyArray<EpicChild>
|
|
53
|
-
verdict: EpicVerdict
|
|
55
|
+
verdict: EpicVerdict | TriageVerdict
|
|
54
56
|
notice: string
|
|
55
57
|
}
|
|
56
58
|
|
|
@@ -165,14 +167,75 @@ async function collect(
|
|
|
165
167
|
return { children, verdict: children.length > NO_LANES ? RUN_VERDICT : verdict }
|
|
166
168
|
}
|
|
167
169
|
|
|
170
|
+
// The `run:solo` gate `backlog:next` applies, applied to a named epic's lanes too
|
|
171
|
+
// (joshuafolkken/kit#2776): the confirmed children are cut where `epic_solo.select` says, so a
|
|
172
|
+
// `run:solo` child never opens a lane beside another one on either path.
|
|
173
|
+
function solo_offer(
|
|
174
|
+
answer: { children: ReadonlyArray<EpicChild>; verdict: EpicVerdict },
|
|
175
|
+
read: BusyRead,
|
|
176
|
+
request: LaneRequest,
|
|
177
|
+
): LaneOffer {
|
|
178
|
+
const { offered, notice } = epic_solo.select(answer.children, read, request.repo)
|
|
179
|
+
|
|
180
|
+
if (offered.length === NO_LANES && answer.children.length > NO_LANES) {
|
|
181
|
+
return { children: [], verdict: WAIT_VERDICT, notice: notice ?? '' }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { ...answer, children: offered, notice: offered_notice(offered, read, request) }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The confirmation walk stops at the free-lane count, so a `run:solo` candidate ranked past it — or
|
|
188
|
+
// in a pool past it — would never reach `solo_offer` to be preferred. Into an idle repository the
|
|
189
|
+
// first pool holding one is asked first, with it at its head through the same `epic_solo.prefer` the
|
|
190
|
+
// offer applies (joshuafolkken/kit#2778). The candidate stays in its own pool, because a pool carries
|
|
191
|
+
// the graph it is confirmed against.
|
|
192
|
+
function solo_first(pools: ReadonlyArray<RepoPool>, read: BusyRead): ReadonlyArray<RepoPool> {
|
|
193
|
+
if (read.kind !== 'idle') return pools
|
|
194
|
+
|
|
195
|
+
const index = pools.findIndex((pool) => pool.candidates.some((child) => epic_solo.is_solo(child)))
|
|
196
|
+
const pool = pools[index]
|
|
197
|
+
|
|
198
|
+
if (pool === undefined) return pools
|
|
199
|
+
|
|
200
|
+
const lifted = { ...pool, candidates: epic_solo.prefer(pool.candidates, read) }
|
|
201
|
+
|
|
202
|
+
return [lifted, ...pools.filter((other) => other !== pool)]
|
|
203
|
+
}
|
|
204
|
+
|
|
168
205
|
// The repository is asked how full it is **before** any candidate is confirmed, for the reason
|
|
169
206
|
// joshuafolkken/kit#1121 records: a repository with no free lane is handed nothing, so the relations
|
|
170
207
|
// request that would confirm a candidate there buys an answer nobody reads — and a polling `epicrun`
|
|
171
208
|
// would pay it every round.
|
|
209
|
+
//
|
|
210
|
+
// **Triage is asked before even that** (joshuafolkken/kit#2779): an untriaged candidate withholds every
|
|
211
|
+
// candidate whatever the occupancy, and it needs only the labels already read. Only a `--lanes` ask
|
|
212
|
+
// is gated — it is the one that opens children beside each other; the single-child form starts one.
|
|
213
|
+
function triage_offer(pools: ReadonlyArray<RepoPool>, request: LaneRequest): LaneOffer | undefined {
|
|
214
|
+
if (!request.is_all_lanes) return undefined
|
|
215
|
+
|
|
216
|
+
const untriaged = epic_triage.untriaged(
|
|
217
|
+
dedupe_pools(pools.map((pool) => candidates_in(pool, request.repo))).flatMap(
|
|
218
|
+
(pool) => pool.candidates,
|
|
219
|
+
),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
if (untriaged.length === NO_LANES) return undefined
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
children: [],
|
|
226
|
+
verdict: epic_triage.TRIAGE_VERDICT,
|
|
227
|
+
notice: epic_triage.message(untriaged, request.repo),
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
172
231
|
async function offer_for_repo(
|
|
173
232
|
pools: ReadonlyArray<RepoPool>,
|
|
174
233
|
request: LaneRequest,
|
|
175
234
|
): Promise<LaneOffer> {
|
|
235
|
+
const triage = triage_offer(pools, request)
|
|
236
|
+
|
|
237
|
+
if (triage !== undefined) return triage
|
|
238
|
+
|
|
176
239
|
const read = await epic_busy.read_repository(request.repo)
|
|
177
240
|
const free = free_lanes(read, request.limit)
|
|
178
241
|
|
|
@@ -184,10 +247,12 @@ async function offer_for_repo(
|
|
|
184
247
|
}
|
|
185
248
|
}
|
|
186
249
|
|
|
187
|
-
const for_repo =
|
|
188
|
-
|
|
250
|
+
const for_repo = solo_first(
|
|
251
|
+
dedupe_pools(pools.map((pool) => candidates_in(pool, request.repo))),
|
|
252
|
+
read,
|
|
253
|
+
)
|
|
189
254
|
|
|
190
|
-
return
|
|
255
|
+
return solo_offer(await collect(for_repo, wanted_of(request, free)), read, request)
|
|
191
256
|
}
|
|
192
257
|
|
|
193
258
|
const epic_lane_offer = {
|
|
@@ -16,7 +16,7 @@ import { epic_lane_offer, type LaneOffer, type LaneRequest } from './epic-lane-o
|
|
|
16
16
|
import { epic_next_read, type EpicRead, type SnapshotReads } from './epic-next-read'
|
|
17
17
|
import { epic_next_views, type EpicView } from './epic-next-views'
|
|
18
18
|
import { epic_outside_blocker } from './epic-outside-blocker'
|
|
19
|
-
import { epic_report, type EpicNextResult
|
|
19
|
+
import { epic_report, type EpicNextResult } from './epic-report'
|
|
20
20
|
|
|
21
21
|
// `josh epic:next <E…>` — which of the named epics' children can be started right now, bundled per
|
|
22
22
|
// repository, and what the rest are waiting on (joshuafolkken/kit#860).
|
|
@@ -210,7 +210,7 @@ function refuse(reason: string): number {
|
|
|
210
210
|
// The verdict as it applies to *this* repository. `run` never reaches a caller here: it means some
|
|
211
211
|
// other repository has work, which for this session is something to wait on rather than a state its
|
|
212
212
|
// loop has a branch for. The whole-run timeout is what bounds that wait.
|
|
213
|
-
function repo_verdict(verdict:
|
|
213
|
+
function repo_verdict(verdict: LaneOffer['verdict']): LaneOffer['verdict'] {
|
|
214
214
|
return verdict === 'run' ? 'wait' : verdict
|
|
215
215
|
}
|
|
216
216
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { has_any_label, has_label_name, RUN_SOLO_LABEL } from '#scripts/git/issue-labels'
|
|
2
|
+
import { issue_citation } from '#scripts/rules/issue-citation'
|
|
3
|
+
import { epic_busy, type BusyRead } from './epic-busy'
|
|
4
|
+
import type { EpicChild } from './epic-graph'
|
|
5
|
+
import { epic_report, type EpicNextResult } from './epic-report'
|
|
6
|
+
|
|
7
|
+
// The `run:solo` gate on what a run is offered (joshuafolkken/kit#2776).
|
|
8
|
+
//
|
|
9
|
+
// `wip-cap.md`'s run-alone section says an issue whose defect reaches the verification path runs
|
|
10
|
+
// alone, and until this gate that was a judgement made at dispatch — the offer handed such an issue
|
|
11
|
+
// out beside others and `backlog:drive` launched every number it was given. The label records the
|
|
12
|
+
// judgement once; this applies it to every ask, so it holds without an agent in the loop.
|
|
13
|
+
//
|
|
14
|
+
// **Both offer paths call `select`**: `backlog:next` (the pool drain, through `gate`) and
|
|
15
|
+
// `epic:next --lanes` (a named epic's children, `epic-lane-offer.ts`). One rule, so the two cannot
|
|
16
|
+
// disagree about what may start beside what.
|
|
17
|
+
//
|
|
18
|
+
// Three rules, all against the repository's `in-progress` holders (`epic_busy.read_repository`):
|
|
19
|
+
// a running `run:solo` issue lets nothing new start; a `run:solo` candidate starts only into an idle
|
|
20
|
+
// repository and then alone; and nothing ranked below a waiting `run:solo` candidate is offered past
|
|
21
|
+
// it, so it is not starved by the lanes it is waiting on. A listing that could not be read is not an
|
|
22
|
+
// idle repository — the same fail-safe answer `epic_busy` gives.
|
|
23
|
+
//
|
|
24
|
+
// **An idle repository takes its first `run:solo` candidate ahead of the ranking**
|
|
25
|
+
// (joshuafolkken/kit#2778). An issue carries the label because its defect reaches the verification
|
|
26
|
+
// path, and `wip-cap.md` resumes a batch only once that has merged — so an ordinary candidate ranked
|
|
27
|
+
// ahead of it would spend a whole wave running alone, then hold the fix back behind its own merge.
|
|
28
|
+
// While lanes are running the ranking stands: nothing overtakes a waiting `run:solo` candidate, and
|
|
29
|
+
// the ones ahead of it keep their lanes.
|
|
30
|
+
|
|
31
|
+
const SOLO_LABELS: ReadonlySet<string> = new Set([RUN_SOLO_LABEL])
|
|
32
|
+
|
|
33
|
+
// Which of the ordered candidates may start, which are held back, and why when a reason is worth
|
|
34
|
+
// saying. `offered` keeps the candidates' own order.
|
|
35
|
+
interface SoloSelection {
|
|
36
|
+
offered: ReadonlyArray<EpicChild>
|
|
37
|
+
withheld: ReadonlyArray<EpicChild>
|
|
38
|
+
notice?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface SoloGate {
|
|
42
|
+
result: EpicNextResult
|
|
43
|
+
notice?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function is_solo(child: EpicChild): boolean {
|
|
47
|
+
return has_label_name(child.labels, RUN_SOLO_LABEL)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function solo_holder(read: BusyRead): number | undefined {
|
|
51
|
+
if (read.kind !== 'busy') return undefined
|
|
52
|
+
|
|
53
|
+
return read.issues.find((issue) => has_any_label(issue.labels, SOLO_LABELS))?.number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function held_message(holder: number, repo: string): string {
|
|
57
|
+
return issue_citation.linkify(
|
|
58
|
+
`#${String(holder)} carries \`${RUN_SOLO_LABEL}\` and is running in ${repo}; nothing else starts until it merges or is parked.`,
|
|
59
|
+
repo,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function waiting_message(solo: number, repo: string): string {
|
|
64
|
+
return issue_citation.linkify(
|
|
65
|
+
`#${String(solo)} carries \`${RUN_SOLO_LABEL}\` and waits for ${repo}'s running lanes to finish; nothing ranked below it is offered past it.`,
|
|
66
|
+
repo,
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function first_of(children: ReadonlyArray<EpicChild>, keep: number): SoloSelection {
|
|
71
|
+
return { offered: children.slice(0, keep), withheld: children.slice(keep) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function none_of(children: ReadonlyArray<EpicChild>, notice: string): SoloSelection {
|
|
75
|
+
return { offered: [], withheld: [...children], notice }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The ranking with the first `run:solo` candidate moved to the head — but only into an idle
|
|
79
|
+
// repository, the one place it may start. Anywhere else the ranking is returned as it was.
|
|
80
|
+
function prefer(children: ReadonlyArray<EpicChild>, read: BusyRead): ReadonlyArray<EpicChild> {
|
|
81
|
+
const solo = children.find((child) => is_solo(child))
|
|
82
|
+
|
|
83
|
+
if (solo === undefined || read.kind !== 'idle') return children
|
|
84
|
+
|
|
85
|
+
return [solo, ...children.filter((child) => child !== solo)]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Where the first `run:solo` candidate sits decides what starts: everything when there is none, the
|
|
89
|
+
// candidates ahead of it when lanes are running, and it alone into an idle repository.
|
|
90
|
+
function select_candidates(
|
|
91
|
+
children: ReadonlyArray<EpicChild>,
|
|
92
|
+
read: BusyRead,
|
|
93
|
+
repo: string,
|
|
94
|
+
): SoloSelection {
|
|
95
|
+
const ordered = prefer(children, read)
|
|
96
|
+
const index = ordered.findIndex((child) => is_solo(child))
|
|
97
|
+
const solo = ordered[index]
|
|
98
|
+
|
|
99
|
+
if (solo === undefined) return first_of(ordered, ordered.length)
|
|
100
|
+
if (index > 0) return first_of(ordered, index)
|
|
101
|
+
if (read.kind === 'idle') return first_of(ordered, 1)
|
|
102
|
+
|
|
103
|
+
return none_of(ordered, waiting_message(solo.number, repo))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// An unreadable or truncated listing keeps nothing: without it a running `run:solo` issue cannot be
|
|
107
|
+
// ruled out.
|
|
108
|
+
function select(children: ReadonlyArray<EpicChild>, read: BusyRead, repo: string): SoloSelection {
|
|
109
|
+
if (read.kind === 'unreadable' || read.kind === 'truncated') {
|
|
110
|
+
return none_of(children, epic_busy.busy_reason(read, repo, 0))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const holder = solo_holder(read)
|
|
114
|
+
|
|
115
|
+
if (holder !== undefined) return none_of(children, held_message(holder, repo))
|
|
116
|
+
|
|
117
|
+
return select_candidates(children, read, repo)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The offered candidates of `repo` stay; the withheld ones move to `waiting`, which is where the
|
|
121
|
+
// report already lists children that time alone will release.
|
|
122
|
+
function withhold(result: EpicNextResult, repo: string, selection: SoloSelection): EpicNextResult {
|
|
123
|
+
return {
|
|
124
|
+
...result,
|
|
125
|
+
candidates: result.candidates.map((bundle) =>
|
|
126
|
+
bundle.repo === repo ? { ...bundle, children: selection.offered } : bundle,
|
|
127
|
+
),
|
|
128
|
+
waiting: [...result.waiting, ...selection.withheld],
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Only a `run` answer offers anything, so every other verdict passes through untouched.
|
|
133
|
+
function gate(result: EpicNextResult, read: BusyRead, repo: string): SoloGate {
|
|
134
|
+
if (result.verdict !== 'run') return { result }
|
|
135
|
+
|
|
136
|
+
const selection = select(epic_report.candidates_for_repo(result, repo), read, repo)
|
|
137
|
+
const gated = withhold(result, repo, selection)
|
|
138
|
+
|
|
139
|
+
return selection.notice === undefined
|
|
140
|
+
? { result: gated }
|
|
141
|
+
: { result: gated, notice: selection.notice }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const epic_solo = { gate, is_solo, prefer, select }
|
|
145
|
+
|
|
146
|
+
export type { SoloGate, SoloSelection }
|
|
147
|
+
export { epic_solo }
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { has_label_name, RUN_LANE_LABEL, RUN_SOLO_LABEL } from '#scripts/git/issue-labels'
|
|
2
|
+
import { issue_citation } from '#scripts/rules/issue-citation'
|
|
3
|
+
import type { EpicChild } from './epic-graph'
|
|
4
|
+
|
|
5
|
+
// The triage gate on what a run is offered (joshuafolkken/kit#2779).
|
|
6
|
+
//
|
|
7
|
+
// `run:solo` (joshuafolkken/kit#2776) records that an issue runs alone, but its absence said nothing:
|
|
8
|
+
// "judged safe beside others" and "never judged" looked the same, and an issue opted in mid-run reached
|
|
9
|
+
// `backlog:drive` without any agent reading it. `run:lane` records the other answer, so an issue with
|
|
10
|
+
// neither label is one nobody has judged — and such an issue may be one that must run alone.
|
|
11
|
+
//
|
|
12
|
+
// **One untriaged candidate withholds every candidate**, not just itself: whether the others may start
|
|
13
|
+
// depends on whether it is `run:solo`, which is exactly what is not known yet. The answer is the
|
|
14
|
+
// `triage` verdict, which `backlog:drive` hands back to the parent rather than acting on.
|
|
15
|
+
//
|
|
16
|
+
// **Both offer paths call `untriaged`**: `backlog:next` (the pool drain) and `epic:next --lanes`
|
|
17
|
+
// (a named epic's children, `epic-lane-offer.ts`) — the same pairing `epic_solo` keeps.
|
|
18
|
+
|
|
19
|
+
type TriageVerdict = 'triage'
|
|
20
|
+
|
|
21
|
+
const TRIAGE_VERDICT: TriageVerdict = 'triage'
|
|
22
|
+
|
|
23
|
+
function is_triaged(child: EpicChild): boolean {
|
|
24
|
+
const { labels } = child
|
|
25
|
+
|
|
26
|
+
return has_label_name(labels, RUN_SOLO_LABEL) || has_label_name(labels, RUN_LANE_LABEL)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The candidates nobody has judged, in the order they were given.
|
|
30
|
+
function untriaged(children: ReadonlyArray<EpicChild>): ReadonlyArray<EpicChild> {
|
|
31
|
+
return children.filter((child) => !is_triaged(child))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Names every untriaged number, so the parent reading standard error knows which issues to judge.
|
|
35
|
+
function message(children: ReadonlyArray<EpicChild>, repo: string): string {
|
|
36
|
+
const numbers = children.map((child) => `#${String(child.number)}`).join(', ')
|
|
37
|
+
|
|
38
|
+
return issue_citation.linkify(
|
|
39
|
+
`Untriaged: ${numbers} carry neither \`${RUN_SOLO_LABEL}\` nor \`${RUN_LANE_LABEL}\` in ${repo}, so nothing is offered until each is judged. Read each one, record its blocked-by order and one of the two labels with a comment saying why, then ask again.`,
|
|
40
|
+
repo,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const epic_triage = { TRIAGE_VERDICT, is_triaged, message, untriaged }
|
|
45
|
+
|
|
46
|
+
export type { TriageVerdict }
|
|
47
|
+
export { epic_triage }
|
|
@@ -53,14 +53,13 @@ function is_force_requested(force_environment: string): boolean {
|
|
|
53
53
|
// nothing" for "git could not be asked". Every predicate below treats the two differently.
|
|
54
54
|
//
|
|
55
55
|
// **The observation ledger is dropped from the reading, and it is the one line that may be**
|
|
56
|
-
// (joshuafolkken/kit#1756).
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// ledger says, and the ledger's own content is checked by the CI of the pull request the flush opens.
|
|
56
|
+
// (joshuafolkken/kit#1756). Lines are appended throughout a run — a review's record lands after its
|
|
57
|
+
// gate — and every condition below reads a non-empty status as "this operation carries a tree no
|
|
58
|
+
// check has read". Left in, one ledger line would send every commit and every push in the primary
|
|
59
|
+
// checkout back to the full gate, which is the reuse this module exists to grant. **Dropping it is
|
|
60
|
+
// sound because the ledger is no code any check runs**: `pnpm josh git` stages it with the run's
|
|
61
|
+
// commit only after its grammar has been parsed (joshuafolkken/kit#2763), and the rest of its content
|
|
62
|
+
// is checked by the CI of the pull request that carries it.
|
|
64
63
|
async function read_status_lines(): Promise<ReadonlyArray<string> | undefined> {
|
|
65
64
|
try {
|
|
66
65
|
const status = await git_command.status()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Captures the number, so the one pattern answers every question asked of a PR body: whether the
|
|
2
|
+
// linked issue will auto-close, which issue that is when the invocation did not say
|
|
3
|
+
// (joshuafolkken/kit#1539), and whether a merged pull request closes a child a `backlogrun` judged
|
|
4
|
+
// unfinished (joshuafolkken/kit#2769). A second pattern would be two readings of "closes #N" to disagree.
|
|
5
|
+
const CLOSES_PATTERN = /closes\s+#(\d+)/iu
|
|
6
|
+
|
|
7
|
+
function parse_closes_issue_number(body: string | undefined): string | undefined {
|
|
8
|
+
if (body === undefined) return undefined
|
|
9
|
+
|
|
10
|
+
return CLOSES_PATTERN.exec(body)?.[1]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const git_closes_keyword = { parse_closes_issue_number }
|
|
14
|
+
|
|
15
|
+
export { git_closes_keyword }
|
|
@@ -5,12 +5,11 @@ import { git_command } from './git-command'
|
|
|
5
5
|
import { git_followup_cleanup } from './git-followup-cleanup'
|
|
6
6
|
import { main_sync } from './main-sync'
|
|
7
7
|
|
|
8
|
-
// The
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// `pnpm josh followup`, the one step that knows the merge has landed.
|
|
8
|
+
// The residual commit path for the observation ledger (joshuafolkken/kit#1810). A run's appended lines
|
|
9
|
+
// ride its own commit (joshuafolkken/kit#2763), so after the merge the ledger is usually clean and this
|
|
10
|
+
// short-circuits; what it still catches is a line appended after the commit — a second review round's
|
|
11
|
+
// record — which would otherwise stay in the working tree until a person remembered to flush it. This
|
|
12
|
+
// wires the flush into `pnpm josh followup`, the one step that knows the merge has landed.
|
|
14
13
|
//
|
|
15
14
|
// **It runs before the working-tree hold is released** (`scripts-ai/git-followup-finish.ts`, whose
|
|
16
15
|
// `finish()` runs after `git_pr_followup.run()` returns), because a flush switches branches, and a
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { poll, type PollOptions } from '#scripts/lib/poll'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { git_epic_parse } from './git-epic-parse'
|
|
4
|
+
import { git_gh_command } from './git-gh-command'
|
|
5
|
+
import { parse_json_object_safe } from './parse-json-array'
|
|
6
|
+
|
|
7
|
+
// **A merged pull request whose `closes #N` GitHub did not apply** (joshuafolkken/kit#2770). From
|
|
8
|
+
// 2026-09-30 06:34Z every merge `followup` made left its issue open — seven in a row, where every
|
|
9
|
+
// earlier one was closed by the pull request within a second — although the merge request, the
|
|
10
|
+
// merging account and the `closes #N` first line were all the same as before. The run then reported
|
|
11
|
+
// success, and a `backlogrun` read the still-open child as a failure and stopped.
|
|
12
|
+
//
|
|
13
|
+
// **The issue's state is what is checked, not `closingIssuesReferences`.** That field is GraphQL-only
|
|
14
|
+
// (a cloud session is answered 403 there, joshuafolkken/kit#1022) and it is not the fact that failed:
|
|
15
|
+
// PR #2768 answered it empty at the time and names #2761 now, while #2761 was never closed by it. The
|
|
16
|
+
// observable defect is an issue left open, so that is what is read, and what is repaired.
|
|
17
|
+
//
|
|
18
|
+
// **A short wait first**, because GitHub applies the keyword asynchronously — the closes it did apply
|
|
19
|
+
// landed within a second of the merge. Closing on the first read would take the close away from the
|
|
20
|
+
// pull request on every run that merely raced it.
|
|
21
|
+
const CLOSE_WAIT: PollOptions = { attempts: 3, interval_ms: 2000 }
|
|
22
|
+
|
|
23
|
+
const CLOSE_RECOVERY = 'gh api -X PATCH repos/{owner}/{repo}/issues/<N> -f state=closed'
|
|
24
|
+
|
|
25
|
+
const state_read_schema = z.object({ state: z.string().optional() })
|
|
26
|
+
|
|
27
|
+
async function is_issue_closed(issue_number: string): Promise<boolean> {
|
|
28
|
+
const raw = await git_gh_command.issue_get_state_and_relations(issue_number)
|
|
29
|
+
const parsed = raw === undefined ? undefined : parse_json_object_safe(raw, state_read_schema)
|
|
30
|
+
|
|
31
|
+
return git_epic_parse.is_state_closed(parsed?.state)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const FOLLOWUP_CLOSER = 'pnpm josh followup'
|
|
35
|
+
|
|
36
|
+
// English, like every other string a script posts: the comment says why the close came from this
|
|
37
|
+
// command rather than from the pull request, so a reader of the issue is not left guessing. `closer`
|
|
38
|
+
// names the command that closed it — `followup` at the merge, or `run:merge` when a `backlogrun`
|
|
39
|
+
// finds the child still open afterwards (joshuafolkken/kit#2769).
|
|
40
|
+
function build_close_comment(pr_url: string | undefined, closer: string = FOLLOWUP_CLOSER): string {
|
|
41
|
+
return (
|
|
42
|
+
`Closed by \`${closer}\`: ${pr_url ?? 'the merged pull request'} merged, ` +
|
|
43
|
+
'but GitHub did not apply its ' +
|
|
44
|
+
'`closes #N` keyword (joshuafolkken/kit#2770).'
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// **Throws when the issue is still open**, so the guard around it reports the failure with the
|
|
49
|
+
// command that finishes it by hand — an issue left open after a merge is the one thing this step
|
|
50
|
+
// exists to stop going unnoticed.
|
|
51
|
+
async function ensure_issue_closed(
|
|
52
|
+
input: { issue_number: string | undefined; pr_url: string | undefined; closer?: string },
|
|
53
|
+
wait: PollOptions = CLOSE_WAIT,
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
const { issue_number } = input
|
|
56
|
+
const is_done =
|
|
57
|
+
issue_number === undefined ||
|
|
58
|
+
(await poll.poll_until(async () => await is_issue_closed(issue_number), wait))
|
|
59
|
+
if (is_done) return
|
|
60
|
+
|
|
61
|
+
console.warn(`⚠️ Issue #${issue_number} is still open after the merge — closing it.`)
|
|
62
|
+
const comment = build_close_comment(input.pr_url, input.closer)
|
|
63
|
+
|
|
64
|
+
if (!(await git_gh_command.issue_close(issue_number, comment))) {
|
|
65
|
+
throw new Error(`gh api could not close issue #${issue_number}`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const git_followup_issue_close = {
|
|
70
|
+
ensure_issue_closed,
|
|
71
|
+
build_close_comment,
|
|
72
|
+
CLOSE_RECOVERY,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { git_followup_issue_close }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { git_epic_close } from './git-epic-close'
|
|
2
2
|
import { git_followup_cleanup } from './git-followup-cleanup'
|
|
3
3
|
import { git_followup_flush } from './git-followup-flush'
|
|
4
|
+
import { git_followup_issue_close } from './git-followup-issue-close'
|
|
4
5
|
import { git_followup_label } from './git-followup-label'
|
|
5
6
|
import { git_followup_stages, type StageLog } from './git-followup-stages'
|
|
6
7
|
import { git_gh_command } from './git-gh-command'
|
|
@@ -25,6 +26,9 @@ const IN_PROGRESS_RECOVERY = 'gh api -X DELETE repos/{owner}/{repo}/issues/<N>/l
|
|
|
25
26
|
interface WrapupInput {
|
|
26
27
|
branch_name: string
|
|
27
28
|
issue_number: string | undefined
|
|
29
|
+
// The number the pull request body's `closes #N` names — the only issue the merge claimed to
|
|
30
|
+
// close, so the only one the merged-issue close may write to (joshuafolkken/kit#2770).
|
|
31
|
+
closes_number: string | undefined
|
|
28
32
|
notify_config: GitNotifyConfig | undefined
|
|
29
33
|
pr_url: string | undefined
|
|
30
34
|
should_merge: boolean
|
|
@@ -164,9 +168,30 @@ async function in_progress_step(input: WrapupInput): Promise<void> {
|
|
|
164
168
|
})
|
|
165
169
|
}
|
|
166
170
|
|
|
171
|
+
// **The merged issue's close, confirmed rather than assumed** (joshuafolkken/kit#2770): GitHub stopped
|
|
172
|
+
// applying `closes #N` on some merges, and nothing noticed. The epic auto-close does not wait on it —
|
|
173
|
+
// it already excludes the merged issue from its reads — so it runs beside the other steps. It acts on
|
|
174
|
+
// the body's `closes #N` rather than the run's issue number: a pull request that closes nothing, or
|
|
175
|
+
// another issue, never closes the one the invocation named.
|
|
176
|
+
async function issue_close_step(input: WrapupInput): Promise<void> {
|
|
177
|
+
if (!input.should_merge) return
|
|
178
|
+
|
|
179
|
+
await git_followup_cleanup.run_guarded_step(input.should_merge, {
|
|
180
|
+
label: 'The merged issue close',
|
|
181
|
+
recovery: git_followup_issue_close.CLOSE_RECOVERY,
|
|
182
|
+
run: async () => {
|
|
183
|
+
await git_followup_issue_close.ensure_issue_closed({
|
|
184
|
+
issue_number: input.closes_number,
|
|
185
|
+
pr_url: input.pr_url,
|
|
186
|
+
})
|
|
187
|
+
},
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
167
191
|
// **The steps after the merge are issued together** (joshuafolkken/kit#1446). The completion
|
|
168
|
-
// comment writes to the issue or the pull request, the auto-close reads the open epics,
|
|
169
|
-
//
|
|
192
|
+
// comment writes to the issue or the pull request, the auto-close reads the open epics, the label
|
|
193
|
+
// removal reads and writes the issue's labels, and the merged-issue close reads and may close the
|
|
194
|
+
// issue; none needs another's answer, and measured
|
|
170
195
|
// serially the first two were 1.9 s and 3.1 s of `followup`'s own clock. Past the merge none can
|
|
171
196
|
// reject another — `run_guarded_step` reports the failure and answers instead — so they settle
|
|
172
197
|
// independently and the stage costs the longest of them rather than their sum.
|
|
@@ -178,9 +203,9 @@ async function in_progress_step(input: WrapupInput): Promise<void> {
|
|
|
178
203
|
// (`git-followup-cleanup.ts`), which is easier to read as one sequence than as a settled set.
|
|
179
204
|
//
|
|
180
205
|
// **What the overlap costs, stated rather than glossed**: mutating requests now go out at once
|
|
181
|
-
// against one repository — a comment write, an auto-close that comments on and closes epics,
|
|
182
|
-
//
|
|
183
|
-
// of them are guarded, so the worst case is a cleanup reported as unfinished rather than a failed
|
|
206
|
+
// against one repository — a comment write, an auto-close that comments on and closes epics, a label
|
|
207
|
+
// delete, and the merged issue's close when GitHub did not apply it — which is what GitHub's
|
|
208
|
+
// secondary rate limit guidance is about. Past the merge all of them are guarded, so the worst case is a cleanup reported as unfinished rather than a failed
|
|
184
209
|
// run, and this is a handful of requests rather than a fan-out. **Their console output can
|
|
185
210
|
// interleave too**: the guard's warning and its recovery line are printed as they happen, so an
|
|
186
211
|
// auto-close progress line can land between them. The recovery line names its own command and stands
|
|
@@ -194,7 +219,12 @@ async function run_tail_steps(input: WrapupInput): Promise<void> {
|
|
|
194
219
|
return
|
|
195
220
|
}
|
|
196
221
|
|
|
197
|
-
await Promise.all([
|
|
222
|
+
await Promise.all([
|
|
223
|
+
notify_step(input),
|
|
224
|
+
epic_close_step(input),
|
|
225
|
+
in_progress_step(input),
|
|
226
|
+
issue_close_step(input),
|
|
227
|
+
])
|
|
198
228
|
}
|
|
199
229
|
|
|
200
230
|
async function run_wrapup(input: WrapupInput, log: StageLog): Promise<void> {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { git_closes_keyword } from './git-closes-keyword'
|
|
1
2
|
import { git_followup_pending } from './git-followup-pending'
|
|
2
3
|
import { git_followup_stages, type StageLog } from './git-followup-stages'
|
|
3
4
|
import { git_gh_command } from './git-gh-command'
|
|
@@ -15,10 +16,6 @@ import { telegram_notify, type TelegramSendInput, type TelegramTaskType } from '
|
|
|
15
16
|
|
|
16
17
|
const { STAGE, lap } = git_followup_stages
|
|
17
18
|
|
|
18
|
-
// Captures the number, so the one pattern answers both questions the run asks of a PR body: whether
|
|
19
|
-
// the linked issue will auto-close, and — since joshuafolkken/kit#1539 — which issue that is when the
|
|
20
|
-
// invocation did not say. A second pattern here would be two readings of "closes #N" to disagree.
|
|
21
|
-
const CLOSES_PATTERN = /closes\s+#(\d+)/iu
|
|
22
19
|
// Named so a test can pin the note without restating it (joshuafolkken/kit#999).
|
|
23
20
|
const WATCH_FAILED_NOTE = 'pr checks --watch failed; falling through to polling'
|
|
24
21
|
const REPO_NAME_SEPARATOR = '/'
|
|
@@ -69,11 +66,7 @@ interface FollowupInput {
|
|
|
69
66
|
should_merge: boolean
|
|
70
67
|
}
|
|
71
68
|
|
|
72
|
-
|
|
73
|
-
if (body === undefined) return undefined
|
|
74
|
-
|
|
75
|
-
return CLOSES_PATTERN.exec(body)?.[1]
|
|
76
|
-
}
|
|
69
|
+
const { parse_closes_issue_number } = git_closes_keyword
|
|
77
70
|
|
|
78
71
|
function has_closes_keyword(body: string | undefined): boolean {
|
|
79
72
|
return parse_closes_issue_number(body) !== undefined
|
|
@@ -234,6 +227,7 @@ async function read_issue_title(
|
|
|
234
227
|
|
|
235
228
|
interface RunContext {
|
|
236
229
|
issue_number: string | undefined
|
|
230
|
+
closes_number: string | undefined
|
|
237
231
|
context: TelegramContext
|
|
238
232
|
}
|
|
239
233
|
|
|
@@ -256,19 +250,17 @@ async function read_run_context(input: {
|
|
|
256
250
|
issue_number: string | undefined
|
|
257
251
|
}): Promise<RunContext> {
|
|
258
252
|
const closes = warn_if_missing_closes(input.branch_name)
|
|
259
|
-
const owner = git_gh_command.repo_get_name_with_owner()
|
|
260
|
-
const url = git_gh_command.pr_get_url(input.branch_name)
|
|
261
|
-
const title = read_issue_title(input.issue_number, closes)
|
|
262
253
|
const [closes_number, name_with_owner, pr_url, issue_title] = await Promise.all([
|
|
263
254
|
closes,
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
255
|
+
git_gh_command.repo_get_name_with_owner(),
|
|
256
|
+
git_gh_command.pr_get_url(input.branch_name),
|
|
257
|
+
read_issue_title(input.issue_number, closes),
|
|
267
258
|
])
|
|
268
259
|
const issue_number = input.issue_number ?? closes_number
|
|
269
260
|
|
|
270
261
|
return {
|
|
271
262
|
issue_number,
|
|
263
|
+
closes_number,
|
|
272
264
|
context: {
|
|
273
265
|
repo_name: parse_repo_name(name_with_owner),
|
|
274
266
|
issue_title,
|
|
@@ -373,7 +365,7 @@ async function run_review_checks(
|
|
|
373
365
|
// the epic close all name the same issue — and so the caller's own tail can record a run whose number
|
|
374
366
|
// only the pull request knew.
|
|
375
367
|
async function run_stages(input: FollowupInput, log: StageLog): Promise<string | undefined> {
|
|
376
|
-
const { issue_number, context } = await read_run_context(input)
|
|
368
|
+
const { issue_number, closes_number, context } = await read_run_context(input)
|
|
377
369
|
|
|
378
370
|
lap(log, STAGE.closes_and_context)
|
|
379
371
|
const checks = await run_review_checks(input, context, log)
|
|
@@ -385,6 +377,7 @@ async function run_stages(input: FollowupInput, log: StageLog): Promise<string |
|
|
|
385
377
|
{
|
|
386
378
|
branch_name: input.branch_name,
|
|
387
379
|
issue_number,
|
|
380
|
+
closes_number,
|
|
388
381
|
notify_config: input.notify_config,
|
|
389
382
|
pr_url: context.pr_url,
|
|
390
383
|
should_merge: input.should_merge,
|