@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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { run_hold, type HoldRead, type RunHold } from './run-hold'
|
|
2
|
+
|
|
3
|
+
// joshuafolkken/kit#2796: a `halfrun` stops before its commit and **keeps** its hold, because the
|
|
4
|
+
// uncommitted, hand-verified work is exactly what a second run would trample. The person then types
|
|
5
|
+
// `fullrun #N` to ship it — and `run:entry`'s claim answered `busy` against that very hold, or, once the
|
|
6
|
+
// hold had been cleared, the preflight advised stashing the verified diff away. The hand-off the
|
|
7
|
+
// `halfrun` stop promised could not land, and each run improvised its own way past the guard.
|
|
8
|
+
//
|
|
9
|
+
// **The stop is recorded, never inferred.** A hold naming #N over a dirty tree is also what a `halfrun`
|
|
10
|
+
// still implementing and a `backlogrun` child leave, and adopting either hands a half-written diff to
|
|
11
|
+
// the gate and the merge. So the `halfrun` stop re-writes its own record with a stop mark
|
|
12
|
+
// (`run:hold <N> --halfrun-stop`) — re-keying a `halfrun new`'s unnumbered record to the issue it filed —
|
|
13
|
+
// and only a marked record is adopted. An expired one qualifies too, since a person may verify the next
|
|
14
|
+
// day. The adopted record carries the `fullrun` mark instead, so the implementation-cut guard and
|
|
15
|
+
// `run:cut --resume` read the tree as this `fullrun`'s from here on.
|
|
16
|
+
|
|
17
|
+
const MARKED = 'marked'
|
|
18
|
+
const FOREIGN = 'foreign'
|
|
19
|
+
const UNREADABLE = 'unreadable'
|
|
20
|
+
|
|
21
|
+
type StopMark = typeof FOREIGN | typeof MARKED | typeof UNREADABLE
|
|
22
|
+
|
|
23
|
+
function hold_of(read: HoldRead): RunHold | undefined {
|
|
24
|
+
return read.kind === 'held' || read.kind === 'stale' ? read.hold : undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function is_halfrun_stop(read: HoldRead, issue: string, is_dirty: boolean): boolean {
|
|
28
|
+
const hold = hold_of(read)
|
|
29
|
+
|
|
30
|
+
return is_dirty && hold?.issue === issue && hold.is_halfrun_stop === true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The stopping run's own record names its issue, or `new` when it entered as `halfrun new`; a free tree
|
|
34
|
+
// is marked as well, since the work to protect is sitting in it either way.
|
|
35
|
+
function is_own_record(read: HoldRead, issue: string): boolean {
|
|
36
|
+
const hold = hold_of(read)
|
|
37
|
+
|
|
38
|
+
return hold === undefined || hold.issue === issue || hold.issue === run_hold.UNNUMBERED_ISSUE
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mark_stop_at(target: string, issue: string): StopMark {
|
|
42
|
+
const read = run_hold.read_hold(target)
|
|
43
|
+
|
|
44
|
+
if (read.kind === 'unreadable') return UNREADABLE
|
|
45
|
+
|
|
46
|
+
if (!is_own_record(read, issue)) return FOREIGN
|
|
47
|
+
|
|
48
|
+
run_hold.release_hold(target)
|
|
49
|
+
|
|
50
|
+
return run_hold.create_halfrun_stop_hold(target, issue) ? MARKED : FOREIGN
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// `false` leaves the record untouched, so the ordinary claim that follows decides the tree exactly as
|
|
54
|
+
// it did before this resume existed.
|
|
55
|
+
function adopt_at(target: string, issue: string, is_dirty: boolean): boolean {
|
|
56
|
+
if (!is_halfrun_stop(run_hold.read_hold(target), issue, is_dirty)) return false
|
|
57
|
+
|
|
58
|
+
run_hold.release_hold(target)
|
|
59
|
+
|
|
60
|
+
return run_hold.create_hold(target, issue, new Date(), true)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// **An unreadable git directory is no resume**, never a crash: `run:entry` must still print its one
|
|
64
|
+
// summary line, and the ordinary claim that follows answers `unknown` for the same tree.
|
|
65
|
+
async function hold_target(): Promise<string | undefined> {
|
|
66
|
+
try {
|
|
67
|
+
const directory = await run_hold.worktree_directory()
|
|
68
|
+
|
|
69
|
+
return directory === undefined ? undefined : run_hold.hold_path(directory)
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Read-only, so `run:entry` can ask the session budget before it takes anything over.
|
|
76
|
+
async function is_pending(issue: string): Promise<boolean> {
|
|
77
|
+
const target = await hold_target()
|
|
78
|
+
|
|
79
|
+
if (target === undefined) return false
|
|
80
|
+
|
|
81
|
+
return is_halfrun_stop(run_hold.read_hold(target), issue, await run_hold.is_tree_dirty())
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function adopt(issue: string): Promise<boolean> {
|
|
85
|
+
const target = await hold_target()
|
|
86
|
+
|
|
87
|
+
if (target === undefined) return false
|
|
88
|
+
|
|
89
|
+
return adopt_at(target, issue, await run_hold.is_tree_dirty())
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const run_halfrun_resume = {
|
|
93
|
+
FOREIGN,
|
|
94
|
+
MARKED,
|
|
95
|
+
UNREADABLE,
|
|
96
|
+
adopt,
|
|
97
|
+
adopt_at,
|
|
98
|
+
is_halfrun_stop,
|
|
99
|
+
is_pending,
|
|
100
|
+
mark_stop_at,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type { StopMark }
|
|
104
|
+
export { run_halfrun_resume }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { run_halfrun_resume } from './run-halfrun-resume'
|
|
3
4
|
import { run_hold, type HoldRead, type RunHold } from './run-hold'
|
|
4
5
|
import { run_preflight, type PreflightDecision } from './run-preflight'
|
|
5
6
|
import { run_tidy_cli } from './run-tidy-cli'
|
|
@@ -43,8 +44,15 @@ const RELEASE_FLAG = '--release'
|
|
|
43
44
|
// cannot — clearing a record somebody else left behind — is spelled out rather than reached by
|
|
44
45
|
// typing the ordinary command.
|
|
45
46
|
const FORCE_FLAG = '--force'
|
|
47
|
+
// What `fullrun #N`'s entry claims with, so the record says the run is a `fullrun` — the one run whose
|
|
48
|
+
// implementation cut outside a lane resumes as `fullrun #N` (joshuafolkken/kit#2760).
|
|
49
|
+
const FULLRUN_FLAG = '--fullrun'
|
|
50
|
+
const HALFRUN_STOP_FLAG = '--halfrun-stop'
|
|
51
|
+
// The most a claim reads: the issue number and the one flag after it.
|
|
52
|
+
const MAX_CLAIM_ARGUMENTS = 2
|
|
46
53
|
const ISSUE_NUMBER_PATTERN = /^[1-9]\d*$/u
|
|
47
|
-
const USAGE =
|
|
54
|
+
const USAGE =
|
|
55
|
+
'Usage: josh run:hold [<issue-number> [--fullrun | --halfrun-stop]] | josh run:release [<issue-number> | --force]'
|
|
48
56
|
|
|
49
57
|
const HOLD_VERDICT = 'hold'
|
|
50
58
|
const BUSY_VERDICT = 'busy'
|
|
@@ -56,11 +64,19 @@ const NONE_VERDICT = 'none'
|
|
|
56
64
|
const HELD_VERDICT = 'held'
|
|
57
65
|
|
|
58
66
|
const FORCE_RELEASE_KIND = 'force-release'
|
|
67
|
+
const HALFRUN_STOP_KIND = 'halfrun-stop'
|
|
68
|
+
|
|
69
|
+
interface ClaimRequest {
|
|
70
|
+
kind: 'claim'
|
|
71
|
+
issue: string
|
|
72
|
+
is_fullrun?: boolean
|
|
73
|
+
}
|
|
59
74
|
|
|
60
75
|
type HoldRequest =
|
|
76
|
+
| ClaimRequest
|
|
61
77
|
| { kind: 'release'; claimant: string }
|
|
62
78
|
| { kind: typeof FORCE_RELEASE_KIND }
|
|
63
|
-
| { kind:
|
|
79
|
+
| { kind: typeof HALFRUN_STOP_KIND; issue: string }
|
|
64
80
|
|
|
65
81
|
const FORCE_RELEASE_REQUEST: HoldRequest = { kind: FORCE_RELEASE_KIND }
|
|
66
82
|
|
|
@@ -68,14 +84,24 @@ function release_request(claimant: string): HoldRequest {
|
|
|
68
84
|
return { kind: 'release', claimant }
|
|
69
85
|
}
|
|
70
86
|
|
|
87
|
+
// The flag after a numbered claim: absent, `--fullrun` or `--halfrun-stop`. Anything else is a usage
|
|
88
|
+
// error.
|
|
89
|
+
function numbered_claim(issue: string, flag: string | undefined): HoldRequest | undefined {
|
|
90
|
+
if (flag === undefined) return { kind: 'claim', issue }
|
|
91
|
+
|
|
92
|
+
if (flag === HALFRUN_STOP_FLAG) return { kind: HALFRUN_STOP_KIND, issue }
|
|
93
|
+
|
|
94
|
+
return flag === FULLRUN_FLAG ? { kind: 'claim', issue, is_fullrun: true } : undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
71
97
|
function parse_claim(argv: ReadonlyArray<string>): HoldRequest | undefined {
|
|
72
|
-
const [first] = argv
|
|
98
|
+
const [first, flag] = argv
|
|
73
99
|
|
|
74
100
|
if (first === undefined) return { kind: 'claim', issue: run_hold.UNNUMBERED_ISSUE }
|
|
75
101
|
|
|
76
|
-
if (argv.length >
|
|
102
|
+
if (argv.length > MAX_CLAIM_ARGUMENTS || !ISSUE_NUMBER_PATTERN.test(first)) return undefined
|
|
77
103
|
|
|
78
|
-
return
|
|
104
|
+
return numbered_claim(first, flag)
|
|
79
105
|
}
|
|
80
106
|
|
|
81
107
|
function parse_release_argument(first: string): HoldRequest | undefined {
|
|
@@ -135,8 +161,10 @@ async function blocking_message(read: HoldRead): Promise<string | undefined> {
|
|
|
135
161
|
|
|
136
162
|
// A record already here is never overwritten by a claim: overwriting is what the guard exists to
|
|
137
163
|
// prevent, and the person who knows the other run has ended clears it with `run:release --force`.
|
|
138
|
-
function take_free_tree(target: string,
|
|
139
|
-
|
|
164
|
+
function take_free_tree(target: string, request: ClaimRequest): number {
|
|
165
|
+
const is_taken = run_hold.create_hold(target, request.issue, new Date(), request.is_fullrun)
|
|
166
|
+
|
|
167
|
+
if (is_taken) return report_hold()
|
|
140
168
|
|
|
141
169
|
return report_busy(run_hold.race_message(run_hold.read_hold(target), target))
|
|
142
170
|
}
|
|
@@ -145,11 +173,11 @@ function take_free_tree(target: string, issue: string): number {
|
|
|
145
173
|
// record with a plain write would tell two sessions that both found it expired that they both won —
|
|
146
174
|
// the race the free path was just fixed for, reintroduced one branch over. Removing it first is what
|
|
147
175
|
// turns the expired record into a free tree; the create is what decides between the two claimants.
|
|
148
|
-
function replace_stale(target: string,
|
|
176
|
+
function replace_stale(target: string, request: ClaimRequest, hold: RunHold): number {
|
|
149
177
|
console.error(run_hold.stale_message(hold))
|
|
150
178
|
run_hold.release_hold(target)
|
|
151
179
|
|
|
152
|
-
return take_free_tree(target,
|
|
180
|
+
return take_free_tree(target, request)
|
|
153
181
|
}
|
|
154
182
|
|
|
155
183
|
// A non-clean preflight verdict is not an error: it is an answer a loop branches on, exactly as
|
|
@@ -176,8 +204,8 @@ async function preflight_gate(issue: string, is_linked: boolean): Promise<number
|
|
|
176
204
|
return report_preflight(decision)
|
|
177
205
|
}
|
|
178
206
|
|
|
179
|
-
async function claim(target: string,
|
|
180
|
-
const gate = await preflight_gate(issue, is_linked)
|
|
207
|
+
async function claim(target: string, request: ClaimRequest, is_linked: boolean): Promise<number> {
|
|
208
|
+
const gate = await preflight_gate(request.issue, is_linked)
|
|
181
209
|
|
|
182
210
|
if (gate !== undefined) return gate
|
|
183
211
|
|
|
@@ -187,7 +215,9 @@ async function claim(target: string, issue: string, is_linked: boolean): Promise
|
|
|
187
215
|
if (blocked !== undefined) return report_busy(blocked)
|
|
188
216
|
|
|
189
217
|
const code =
|
|
190
|
-
read.kind === 'stale'
|
|
218
|
+
read.kind === 'stale'
|
|
219
|
+
? replace_stale(target, request, read.hold)
|
|
220
|
+
: take_free_tree(target, request)
|
|
191
221
|
|
|
192
222
|
if (code === SUCCESS_EXIT_CODE) await run_tidy_cli.sweep()
|
|
193
223
|
|
|
@@ -261,8 +291,22 @@ async function read_worktree(): Promise<string | undefined> {
|
|
|
261
291
|
}
|
|
262
292
|
}
|
|
263
293
|
|
|
294
|
+
// **A `halfrun` stop marks its own record** (joshuafolkken/kit#2796) — the positive "this run has ended
|
|
295
|
+
// over its verified diff" that `run:entry` adopts on `fullrun #N`. Another run's record is left alone.
|
|
296
|
+
function mark_halfrun_stop(target: string, issue: string): number {
|
|
297
|
+
const mark = run_halfrun_resume.mark_stop_at(target, issue)
|
|
298
|
+
|
|
299
|
+
if (mark === run_halfrun_resume.MARKED) return report_hold()
|
|
300
|
+
|
|
301
|
+
if (mark === run_halfrun_resume.UNREADABLE) return report_unknown()
|
|
302
|
+
|
|
303
|
+
return report_busy(run_hold.race_message(run_hold.read_hold(target), target))
|
|
304
|
+
}
|
|
305
|
+
|
|
264
306
|
async function dispatch(request: HoldRequest, target: string, is_linked: boolean): Promise<number> {
|
|
265
|
-
if (request.kind === 'claim') return await claim(target, request
|
|
307
|
+
if (request.kind === 'claim') return await claim(target, request, is_linked)
|
|
308
|
+
|
|
309
|
+
if (request.kind === HALFRUN_STOP_KIND) return mark_halfrun_stop(target, request.issue)
|
|
266
310
|
|
|
267
311
|
if (request.kind === FORCE_RELEASE_KIND) return force_release(target)
|
|
268
312
|
|
package/scripts/run/run-hold.ts
CHANGED
|
@@ -43,6 +43,15 @@ interface RunHold {
|
|
|
43
43
|
issue: string
|
|
44
44
|
taken_at: string
|
|
45
45
|
pid: number
|
|
46
|
+
// Set only by `fullrun #N`'s entry (`run:entry` claims with `--fullrun`, joshuafolkken/kit#2760).
|
|
47
|
+
// The implementation cut outside a lane resumes as `fullrun #N`, so a `halfrun` or an in-session
|
|
48
|
+
// `backlogrun` child — which hold the tree just the same — must not be read as one. Optional and
|
|
49
|
+
// `| undefined` because a record written before this field existed is simply not a `fullrun`'s.
|
|
50
|
+
is_fullrun?: boolean | undefined
|
|
51
|
+
// Set only at a `halfrun`'s stop before commit (`run:hold <N> --halfrun-stop`, joshuafolkken/kit#2796):
|
|
52
|
+
// the positive record that the run has *ended* over its verified diff, which is what lets
|
|
53
|
+
// `fullrun #N` adopt the hold. A running `halfrun` or a `backlogrun` child never carries it.
|
|
54
|
+
is_halfrun_stop?: boolean | undefined
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
type HoldRead =
|
|
@@ -81,6 +90,8 @@ const run_hold_schema = z.object({
|
|
|
81
90
|
issue: z.string(),
|
|
82
91
|
taken_at: z.string(),
|
|
83
92
|
pid: z.number(),
|
|
93
|
+
is_fullrun: z.boolean().optional(),
|
|
94
|
+
is_halfrun_stop: z.boolean().optional(),
|
|
84
95
|
})
|
|
85
96
|
|
|
86
97
|
// `undefined` for anything that is not a well-formed record, so the caller decides what a broken one
|
|
@@ -125,8 +136,8 @@ function read_hold(target: string, now: Date = new Date()): HoldRead {
|
|
|
125
136
|
return classify(stamp_file.read_stamp_text(target), now)
|
|
126
137
|
}
|
|
127
138
|
|
|
128
|
-
function build_hold(issue: string, now: Date): RunHold {
|
|
129
|
-
return { issue, taken_at: now.toISOString(), pid: process.pid }
|
|
139
|
+
function build_hold(issue: string, now: Date, is_fullrun?: boolean): RunHold {
|
|
140
|
+
return { issue, taken_at: now.toISOString(), pid: process.pid, is_fullrun }
|
|
130
141
|
}
|
|
131
142
|
|
|
132
143
|
function write_hold(target: string, issue: string, now: Date = new Date()): RunHold {
|
|
@@ -141,8 +152,18 @@ function write_hold(target: string, issue: string, now: Date = new Date()): RunH
|
|
|
141
152
|
// an entry point in the same second both read `free`, and a write would tell both of them they won —
|
|
142
153
|
// which is the incident this guard was written after, reproduced by the guard itself. `false` means
|
|
143
154
|
// the other one got there first.
|
|
144
|
-
function create_hold(
|
|
145
|
-
|
|
155
|
+
function create_hold(
|
|
156
|
+
target: string,
|
|
157
|
+
issue: string,
|
|
158
|
+
now: Date = new Date(),
|
|
159
|
+
is_fullrun?: boolean,
|
|
160
|
+
): boolean {
|
|
161
|
+
return stamp_file.create_stamp(target, build_hold(issue, now, is_fullrun))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The claim a `halfrun` stop leaves behind: the same exclusive create, carrying the stop mark.
|
|
165
|
+
function create_halfrun_stop_hold(target: string, issue: string, now: Date = new Date()): boolean {
|
|
166
|
+
return stamp_file.create_stamp(target, { ...build_hold(issue, now), is_halfrun_stop: true })
|
|
146
167
|
}
|
|
147
168
|
|
|
148
169
|
// **A tree with uncommitted work in it is never handed over, however old its record is.** No age can
|
|
@@ -238,6 +259,7 @@ const run_hold = {
|
|
|
238
259
|
RELEASE_COMMAND,
|
|
239
260
|
UNNUMBERED_ISSUE,
|
|
240
261
|
classify,
|
|
262
|
+
create_halfrun_stop_hold,
|
|
241
263
|
create_hold,
|
|
242
264
|
describe_holder,
|
|
243
265
|
foreign_release_message,
|
|
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
3
3
|
import { parseArgs } from 'node:util'
|
|
4
4
|
import { api_outage } from '#scripts/agent/api-outage'
|
|
5
5
|
import { CONTEXT_CUT_THRESHOLD } from '#scripts/cost-runtime/context-cut-threshold'
|
|
6
|
+
import { issue_closing_pr } from '#scripts/issue/issue-closing-pr'
|
|
6
7
|
import { issue_state_cli } from '#scripts/issue/issue-state-cli'
|
|
7
8
|
import { run_carry, type CarryOwner, type RunCarry } from './run-carry'
|
|
8
9
|
import { run_ending } from './run-ending'
|
|
@@ -202,17 +203,27 @@ function read_is_outage(output: string | undefined): boolean {
|
|
|
202
203
|
return api_outage.is_outage(run_ending.read_exit(output))
|
|
203
204
|
}
|
|
204
205
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
206
|
+
type IssueRead = Awaited<ReturnType<typeof issue_state_cli.read_issue>>
|
|
207
|
+
|
|
208
|
+
// The timeline is read only for an OPEN, unparked child — the one classification a merged closing pull
|
|
209
|
+
// request changes. A CLOSED child was closed by GitHub, so it must carry no `merged_pr` for
|
|
210
|
+
// `do_merged` to close again (joshuafolkken/kit#2769).
|
|
211
|
+
async function read_merged_pr(child: string, read: IssueRead): Promise<string | undefined> {
|
|
212
|
+
if (read.kind !== 'state' || run_merge.classify_child(read.state) !== 'failed') return undefined
|
|
213
|
+
|
|
214
|
+
return await issue_closing_pr.read_closing_pr(child)
|
|
210
215
|
}
|
|
211
216
|
|
|
212
|
-
function
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
)
|
|
217
|
+
async function read_signals(ctx: MergeContext, read: IssueRead): Promise<EndingSignals> {
|
|
218
|
+
const [is_cut, merged_pr] = await Promise.all([
|
|
219
|
+
run_merge_steps.has_resumable_cut(ctx.child),
|
|
220
|
+
read_merged_pr(ctx.child, read),
|
|
221
|
+
])
|
|
222
|
+
|
|
223
|
+
return { is_outage: read_is_outage(ctx.output), is_cut, merged_pr }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function outcome_of(read: IssueRead, signals: EndingSignals): ChildOutcome {
|
|
216
227
|
return read.kind === 'state' ? run_merge.classify_child(read.state, signals) : 'unresolved'
|
|
217
228
|
}
|
|
218
229
|
|
|
@@ -238,8 +249,8 @@ async function on_merged(ctx: MergeContext): Promise<MergeVerdict> {
|
|
|
238
249
|
return emit(await run_merge_steps.ask_next(ctx), SUCCESS_EXIT_CODE)
|
|
239
250
|
}
|
|
240
251
|
|
|
241
|
-
async function on_failed(ctx: MergeContext): Promise<MergeVerdict> {
|
|
242
|
-
const result = await run_merge_steps.do_failed(ctx)
|
|
252
|
+
async function on_failed(ctx: MergeContext, cause?: string): Promise<MergeVerdict> {
|
|
253
|
+
const result = await run_merge_steps.do_failed(ctx, cause)
|
|
243
254
|
|
|
244
255
|
if (result.is_refused) return report_count_refused(result.carry)
|
|
245
256
|
|
|
@@ -291,7 +302,9 @@ async function on_cut(ctx: MergeContext): Promise<MergeVerdict> {
|
|
|
291
302
|
|
|
292
303
|
if (refused !== undefined) return report_count_refused(refused)
|
|
293
304
|
|
|
294
|
-
if (!(await run_merge_steps.resume_cut(ctx.child)))
|
|
305
|
+
if (!(await run_merge_steps.resume_cut(ctx.child))) {
|
|
306
|
+
return await on_failed(ctx, run_merge_steps.CUT_RELAUNCH_CAUSE)
|
|
307
|
+
}
|
|
295
308
|
|
|
296
309
|
await run_event_stream_emit.emit(
|
|
297
310
|
run_event_stream.EVENT_KIND.CHILD_LAUNCH,
|
|
@@ -358,9 +371,10 @@ async function merge_child(ctx: MergeContext): Promise<MergeResult> {
|
|
|
358
371
|
}
|
|
359
372
|
|
|
360
373
|
const read = await issue_state_cli.read_issue(ctx.child, ctx.repo)
|
|
361
|
-
const
|
|
374
|
+
const signals = await read_signals(ctx, read)
|
|
375
|
+
const outcome = outcome_of(read, signals)
|
|
362
376
|
|
|
363
|
-
return { outcome, ...(await HANDLERS[outcome](ctx)) }
|
|
377
|
+
return { outcome, ...(await HANDLERS[outcome]({ ...ctx, merged_pr: signals.merged_pr })) }
|
|
364
378
|
}
|
|
365
379
|
|
|
366
380
|
async function run(argv: ReadonlyArray<string>): Promise<number> {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
+
import { git_followup_issue_close } from '#scripts/git/git-followup-issue-close'
|
|
3
4
|
import { git_gh_issue_write } from '#scripts/git/git-gh-issue-write'
|
|
4
5
|
import { git_stash } from '#scripts/git/git-stash'
|
|
5
6
|
import { IN_PROGRESS_LABEL, NEEDS_DECISION_LABEL } from '#scripts/git/issue-labels'
|
|
@@ -34,6 +35,12 @@ const LANES = '--lanes'
|
|
|
34
35
|
const REPO_FLAG = '--repo'
|
|
35
36
|
const LANE_CLOSE_OCCASION = 'lane close'
|
|
36
37
|
const GIT_ENTRY = '.git'
|
|
38
|
+
const MERGE_CLOSER = 'pnpm josh run:merge'
|
|
39
|
+
const SINGLE_READ = { attempts: 1, interval_ms: 0 }
|
|
40
|
+
// What the driver judged when it parks a child, stated on the issue (joshuafolkken/kit#2769).
|
|
41
|
+
const FAILED_CAUSE = 'the child’s session ended with its issue still open and unfinished.'
|
|
42
|
+
const CUT_RELAUNCH_CAUSE =
|
|
43
|
+
'its lane held a declared cut, but no successor could be relaunched from it (or it already was once).'
|
|
37
44
|
|
|
38
45
|
// One returned child's context, gathered from the command line by the CLI.
|
|
39
46
|
interface MergeContext {
|
|
@@ -46,6 +53,9 @@ interface MergeContext {
|
|
|
46
53
|
// ending apart from a genuine child failure (joshuafolkken/kit#2240); absent leaves outage detection
|
|
47
54
|
// off, so the child is classified from its GitHub state alone as before.
|
|
48
55
|
output?: string | undefined
|
|
56
|
+
// The merged pull request that closes the child, when GitHub left the child OPEN after the merge —
|
|
57
|
+
// the one `merged` child whose issue this command still has to close (joshuafolkken/kit#2769).
|
|
58
|
+
merged_pr?: string | undefined
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
interface FailedResult {
|
|
@@ -164,6 +174,22 @@ async function post_counters(ctx: MergeContext, carry: RunCarry | undefined): Pr
|
|
|
164
174
|
await git_gh_issue_write.issue_try_comment(ctx.epic, run_merge.counters_comment(carry))
|
|
165
175
|
}
|
|
166
176
|
|
|
177
|
+
// A child merged while GitHub left it OPEN is closed here, through the one close `followup` uses
|
|
178
|
+
// (joshuafolkken/kit#2769). The state was just read OPEN, so it is read once rather than waited on. A
|
|
179
|
+
// close that fails is reported with its recovery and does not undo the merge being counted.
|
|
180
|
+
async function close_merged_open(ctx: MergeContext): Promise<void> {
|
|
181
|
+
if (ctx.merged_pr === undefined) return
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
await git_followup_issue_close.ensure_issue_closed(
|
|
185
|
+
{ issue_number: ctx.child, pr_url: ctx.merged_pr, closer: MERGE_CLOSER },
|
|
186
|
+
SINGLE_READ,
|
|
187
|
+
)
|
|
188
|
+
} catch {
|
|
189
|
+
console.error(`#${ctx.child}: could not be closed — ${git_followup_issue_close.CLOSE_RECOVERY}`)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
167
193
|
// A merged child: count the merge (which resets the failure streak), return to the default branch,
|
|
168
194
|
// stash any uncommitted work the lane still holds and close it, and mirror the counters onto the epic. Returns the carry when the ownership check
|
|
169
195
|
// fails — the caller treats a non-undefined return as a hard refusal and must not offer a next child
|
|
@@ -173,6 +199,7 @@ async function do_merged(ctx: MergeContext): Promise<RunCarry | undefined> {
|
|
|
173
199
|
|
|
174
200
|
if (refused !== undefined) return refused
|
|
175
201
|
|
|
202
|
+
await close_merged_open(ctx)
|
|
176
203
|
await sync_main()
|
|
177
204
|
const result = await apply_carry(ctx, {
|
|
178
205
|
...run_merge.change_of('merged'),
|
|
@@ -202,8 +229,10 @@ async function remove_in_progress(child: string): Promise<void> {
|
|
|
202
229
|
// issue number forever, joshuafolkken/kit#2421), drop the stale `in-progress`, and park it with
|
|
203
230
|
// `needs-decision` so the next offer does not hand the same child straight back. Returns the record
|
|
204
231
|
// so the caller can read the streak against the guard. Returns `is_refused: true` without touching labels when the
|
|
205
|
-
// carry record rejected the count (joshuafolkken/kit#2114).
|
|
206
|
-
|
|
232
|
+
// carry record rejected the count (joshuafolkken/kit#2114). A park is always explained on the issue —
|
|
233
|
+
// `cause` says what was judged, and the comment what was read and what a person does next
|
|
234
|
+
// (joshuafolkken/kit#2769).
|
|
235
|
+
async function do_failed(ctx: MergeContext, cause: string = FAILED_CAUSE): Promise<FailedResult> {
|
|
207
236
|
const result = await apply_carry(ctx, run_merge.change_of('failed'))
|
|
208
237
|
|
|
209
238
|
if (result.kind === 'refused') return { carry: result.carry, is_parked: false, is_refused: true }
|
|
@@ -214,6 +243,12 @@ async function do_failed(ctx: MergeContext): Promise<FailedResult> {
|
|
|
214
243
|
await remove_in_progress(ctx.child)
|
|
215
244
|
const is_parked = await git_gh_issue_write.issue_add_label(ctx.child, NEEDS_DECISION_LABEL)
|
|
216
245
|
|
|
246
|
+
if (is_parked) {
|
|
247
|
+
const reason = { child: ctx.child, cause, carry, output: ctx.output }
|
|
248
|
+
|
|
249
|
+
await git_gh_issue_write.issue_try_comment(ctx.child, run_merge.park_comment(reason))
|
|
250
|
+
}
|
|
251
|
+
|
|
217
252
|
return { carry, is_parked, is_refused: false }
|
|
218
253
|
}
|
|
219
254
|
|
|
@@ -311,6 +346,7 @@ async function ask_next(ctx: MergeContext): Promise<string> {
|
|
|
311
346
|
}
|
|
312
347
|
|
|
313
348
|
const run_merge_steps = {
|
|
349
|
+
CUT_RELAUNCH_CAUSE,
|
|
314
350
|
apply_carry,
|
|
315
351
|
ask_next,
|
|
316
352
|
do_failed,
|
package/scripts/run/run-merge.ts
CHANGED
|
@@ -32,7 +32,9 @@ const ONE = 1
|
|
|
32
32
|
|
|
33
33
|
// What a returned child turned out to be, decided from its GitHub state and — for the failed case
|
|
34
34
|
// alone — the exit record.
|
|
35
|
-
// - `merged` — CLOSED; the child finished and its pull request merged.
|
|
35
|
+
// - `merged` — CLOSED; the child finished and its pull request merged. Also an OPEN, unparked
|
|
36
|
+
// child a merged pull request's `closes #N` names — GitHub merged it without closing
|
|
37
|
+
// the issue (joshuafolkken/kit#2769).
|
|
36
38
|
// - `human-review` — OPEN and carrying `needs-human-review`; the run's own ending (SKILL.md → §2z).
|
|
37
39
|
// - `parked` — OPEN and carrying `needs-decision` or `already-done`; a person still owns it.
|
|
38
40
|
// - `split` — OPEN and carrying `epic`; its work was divided into new children.
|
|
@@ -60,6 +62,9 @@ type ChildOutcome =
|
|
|
60
62
|
interface EndingSignals {
|
|
61
63
|
is_outage: boolean
|
|
62
64
|
is_cut: boolean
|
|
65
|
+
// The merged pull request whose `closes #N` names the child, when GitHub left the child OPEN after
|
|
66
|
+
// merging it (joshuafolkken/kit#2769).
|
|
67
|
+
merged_pr?: string | undefined
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
const NO_SIGNALS: EndingSignals = { is_outage: false, is_cut: false }
|
|
@@ -82,8 +87,12 @@ function is_parked(state: IssueState): boolean {
|
|
|
82
87
|
// An OPEN, unparked child that is not waiting on a person: a declared cut is resumed
|
|
83
88
|
// (joshuafolkken/kit#2484), and it is read before the outage so a cut whose process then lost the API is
|
|
84
89
|
// still resumed from its record; an OPEN, unparked child that could not reach the API is an `outage`,
|
|
85
|
-
// not a `failed` (joshuafolkken/kit#2240).
|
|
90
|
+
// not a `failed` (joshuafolkken/kit#2240). **A merged pull request that closes it is read first**: the
|
|
91
|
+
// work landed and only GitHub's close did not, so it is `merged` — never a failure counted against the
|
|
92
|
+
// guard (joshuafolkken/kit#2769).
|
|
86
93
|
function unfinished_outcome(signals: EndingSignals): ChildOutcome {
|
|
94
|
+
if (signals.merged_pr !== undefined) return 'merged'
|
|
95
|
+
|
|
87
96
|
if (signals.is_cut) return 'cut'
|
|
88
97
|
|
|
89
98
|
return signals.is_outage ? 'outage' : 'failed'
|
|
@@ -152,15 +161,44 @@ function counters_comment(carry: RunCarry): string {
|
|
|
152
161
|
].join('\n')
|
|
153
162
|
}
|
|
154
163
|
|
|
164
|
+
// Why the run parked a child it judged failed (joshuafolkken/kit#2769). A child that stops itself
|
|
165
|
+
// leaves its own comment; one the driver stops left nothing, so a person opening the issue could not
|
|
166
|
+
// tell why it carried `needs-decision`. Script-emitted, so English.
|
|
167
|
+
interface ParkReason {
|
|
168
|
+
child: string
|
|
169
|
+
cause: string
|
|
170
|
+
carry: RunCarry | undefined
|
|
171
|
+
output: string | undefined
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function streak_line(carry: RunCarry | undefined): string {
|
|
175
|
+
if (carry === undefined) return '- Consecutive failures: not recorded (no carry record).'
|
|
176
|
+
|
|
177
|
+
return `- Consecutive failures: ${String(carry.failures)} of ${String(CONSECUTIVE_FAILURE_LIMIT)} (the run stops at ${String(CONSECUTIVE_FAILURE_LIMIT)}).`
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function park_comment(reason: ParkReason): string {
|
|
181
|
+
const transcript = reason.output === undefined ? '' : ` (\`${reason.output}\`)`
|
|
182
|
+
|
|
183
|
+
return [
|
|
184
|
+
'Parked with `needs-decision` by `pnpm josh run:merge`, the `backlogrun` driver — not by the child itself.',
|
|
185
|
+
`- Why: ${reason.cause}`,
|
|
186
|
+
'- Read: the issue is OPEN, carries none of `needs-decision`, `already-done`, `needs-human-review` or `epic`, and no merged pull request’s `closes #N` names it.',
|
|
187
|
+
streak_line(reason.carry),
|
|
188
|
+
`- Next: read the child’s transcript${transcript}, then either resume it with \`fullrun #${reason.child}\`, or record the decision here and remove \`needs-decision\`.`,
|
|
189
|
+
].join('\n')
|
|
190
|
+
}
|
|
191
|
+
|
|
155
192
|
const run_merge = {
|
|
156
193
|
CONSECUTIVE_FAILURE_LIMIT,
|
|
157
194
|
CONSECUTIVE_OUTAGE_LIMIT,
|
|
158
195
|
change_of,
|
|
159
196
|
classify_child,
|
|
160
197
|
counters_comment,
|
|
198
|
+
park_comment,
|
|
161
199
|
is_guard_tripped,
|
|
162
200
|
is_outage_guard_tripped,
|
|
163
201
|
}
|
|
164
202
|
|
|
165
|
-
export type { ChildOutcome, EndingSignals }
|
|
203
|
+
export type { ChildOutcome, EndingSignals, ParkReason }
|
|
166
204
|
export { run_merge }
|
|
@@ -6,10 +6,12 @@ import { issue_state_cli, type StateRead } from '#scripts/issue/issue-state-cli'
|
|
|
6
6
|
import { lane_child_marker } from '#scripts/lane/lane-child-marker'
|
|
7
7
|
import { latest_scope_cli } from '#scripts/version/latest-scope-cli'
|
|
8
8
|
import { run_prep, type PrepParts } from './run-prep'
|
|
9
|
+
import { run_prep_locate } from './run-prep-locate'
|
|
9
10
|
|
|
10
11
|
// `josh run:prep <N>` — one call for the reads a `fullrun` makes before its first edit
|
|
11
12
|
// (joshuafolkken/kit#1978): the issue body and comments (`issue:read`), the state, labels and
|
|
12
|
-
// `human_review` line (`issue:state`),
|
|
13
|
+
// `human_review` line (`issue:state`), the dependency-update scope (`latest:scope`), and where the
|
|
14
|
+
// paths and identifiers the body names occur in code (`run-prep-locate.ts`). Each is
|
|
13
15
|
// reused rather than reproduced, and the two body reads run concurrently, so three round trips become
|
|
14
16
|
// one.
|
|
15
17
|
//
|
|
@@ -37,6 +39,7 @@ interface PrepReads {
|
|
|
37
39
|
state: StateRead
|
|
38
40
|
latest: LatestDecision
|
|
39
41
|
has_changes: boolean
|
|
42
|
+
locations: string
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
// Exactly one issue number, or the call is refused: `run:prep` prepares one run, and a second number
|
|
@@ -90,8 +93,10 @@ async function gather(issue_number: string): Promise<PrepReads> {
|
|
|
90
93
|
issue_state_cli.read_issue(issue_number),
|
|
91
94
|
lane_has_changes(),
|
|
92
95
|
])
|
|
96
|
+
// Located from the body just read, so it waits for that read rather than joining the batch above.
|
|
97
|
+
const locations = await run_prep_locate.locate(content.kind === 'ok' ? content.block : '')
|
|
93
98
|
|
|
94
|
-
return { content, state, latest: latest_decision(), has_changes }
|
|
99
|
+
return { content, state, latest: latest_decision(), has_changes, locations }
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
function content_body(issue_number: string, content: BlockRead): string {
|
|
@@ -110,6 +115,7 @@ function to_parts(issue_number: string, reads: PrepReads): PrepParts {
|
|
|
110
115
|
latest_scope: reads.latest.scope,
|
|
111
116
|
latest_reason: reads.latest.reason,
|
|
112
117
|
has_changes: reads.has_changes,
|
|
118
|
+
locations: reads.locations,
|
|
113
119
|
}
|
|
114
120
|
}
|
|
115
121
|
|