@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
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { run_cut, type CutState, type RunCut } from './run-cut'
|
|
2
|
+
|
|
3
|
+
// The verdict tokens `josh run:cut` answers with and the reports that print them, split out of
|
|
4
|
+
// `run-cut-cli.ts` so the CLI keeps to acting on the record. The contract is unchanged: **standard
|
|
5
|
+
// output carries exactly one token** and every explanation goes to standard error.
|
|
6
|
+
|
|
7
|
+
const SUCCESS_EXIT_CODE = 0
|
|
8
|
+
const FAILURE_EXIT_CODE = 1
|
|
9
|
+
|
|
10
|
+
const CUT_VERDICT = 'cut'
|
|
11
|
+
const RESUME_VERDICT = 'resume'
|
|
12
|
+
// An implementation-phase resume: the fresh process continues implementing rather than going to the
|
|
13
|
+
// gate (joshuafolkken/kit#1933). It is a distinct token so the resuming child branches on it without
|
|
14
|
+
// re-reading the record.
|
|
15
|
+
const RESUME_IMPL_VERDICT = 'resume-impl'
|
|
16
|
+
const FRESH_VERDICT = 'fresh'
|
|
17
|
+
// The recent-window context is under the shared cut threshold, so the pre-gate cut was not worth its
|
|
18
|
+
// resume and nothing was cut (joshuafolkken/kit#2312). A benign, non-failing answer: the run carries
|
|
19
|
+
// on to the gate itself, exactly as `not-a-lane` does.
|
|
20
|
+
const UNDER_THRESHOLD_VERDICT = 'under-threshold'
|
|
21
|
+
const STALE_VERDICT = 'stale'
|
|
22
|
+
// The record matched the tree but carried no instruction to resume on, so the run was refused rather
|
|
23
|
+
// than continued blind to what it was told to do (joshuafolkken/kit#2354).
|
|
24
|
+
const INCOMPLETE_VERDICT = 'incomplete'
|
|
25
|
+
// The `--handoff` path was given but could not be read as a handoff, or was too large for the record;
|
|
26
|
+
// the cut is refused rather than taken without the instruction it was meant to carry.
|
|
27
|
+
const BAD_HANDOFF_VERDICT = 'bad-handoff'
|
|
28
|
+
const HANDED_OFF_VERDICT = 'handed-off'
|
|
29
|
+
const BUSY_VERDICT = 'busy'
|
|
30
|
+
const NOT_A_LANE_VERDICT = 'not-a-lane'
|
|
31
|
+
const UNREADY_VERDICT = 'unready'
|
|
32
|
+
const FAILED_VERDICT = 'failed'
|
|
33
|
+
const ENDED_VERDICT = 'ended'
|
|
34
|
+
const UNREADABLE_VERDICT = 'unreadable'
|
|
35
|
+
const UNKNOWN_VERDICT = 'unknown'
|
|
36
|
+
// The asking session is over the shared context-cut threshold, so an implementation cut was not
|
|
37
|
+
// resumed into it (joshuafolkken/kit#2760) — the same refusal `run:carry` answers `over` with.
|
|
38
|
+
const OVER_VERDICT = 'over'
|
|
39
|
+
|
|
40
|
+
function report(verdict: string, code: number): number {
|
|
41
|
+
console.info(verdict)
|
|
42
|
+
|
|
43
|
+
return code
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function report_stale(cut_record: RunCut): number {
|
|
47
|
+
console.error(run_cut.stale_message(cut_record))
|
|
48
|
+
|
|
49
|
+
return report(STALE_VERDICT, FAILURE_EXIT_CODE)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function report_busy(cut_record: RunCut): number {
|
|
53
|
+
console.error(run_cut.busy_message(cut_record))
|
|
54
|
+
|
|
55
|
+
return report(BUSY_VERDICT, FAILURE_EXIT_CODE)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The record matched the tree but carried no instruction; the run is refused rather than continued
|
|
59
|
+
// without what it was told to do (joshuafolkken/kit#2354).
|
|
60
|
+
function report_incomplete(cut_record: RunCut): number {
|
|
61
|
+
console.error(run_cut.incomplete_message(cut_record))
|
|
62
|
+
|
|
63
|
+
return report(INCOMPLETE_VERDICT, FAILURE_EXIT_CODE)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The `--handoff` file parsed but the assembled record would not fit the byte bound — the scalar fields
|
|
67
|
+
// carry a handoff that fit alone past the cap (joshuafolkken/kit#2354).
|
|
68
|
+
const HANDOFF_OVERFLOW_NOTE = '--handoff <path> would grow the cut record past its byte bound'
|
|
69
|
+
// An implementation cut resumes into implementation, and that resume refuses a record with no
|
|
70
|
+
// instruction (`incomplete`) — so the cut is refused here instead of relaunching a successor that can
|
|
71
|
+
// only stop (joshuafolkken/kit#2484).
|
|
72
|
+
const HANDOFF_MISSING_NOTE = 'this cut resumes into implementation and needs --handoff <path>'
|
|
73
|
+
|
|
74
|
+
function report_bad_handoff(note: string): number {
|
|
75
|
+
console.error(`${note}. Nothing was cut; write the handoff file and reissue.`)
|
|
76
|
+
|
|
77
|
+
return report(BAD_HANDOFF_VERDICT, FAILURE_EXIT_CODE)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A successor already resumed this cut; a process woken after its own hand-off is told to stop rather
|
|
81
|
+
// than to investigate (joshuafolkken/kit#1935). It is a benign, non-failing stop.
|
|
82
|
+
function report_handed_off(cut_record: RunCut): number {
|
|
83
|
+
console.error(run_cut.handed_off_message(cut_record))
|
|
84
|
+
|
|
85
|
+
return report(HANDED_OFF_VERDICT, SUCCESS_EXIT_CODE)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function report_unreadable(): number {
|
|
89
|
+
console.error(run_cut.unreadable_message())
|
|
90
|
+
|
|
91
|
+
return report(UNREADABLE_VERDICT, FAILURE_EXIT_CODE)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function report_unknown(): number {
|
|
95
|
+
console.error(run_cut.unknown_message())
|
|
96
|
+
|
|
97
|
+
return report(UNKNOWN_VERDICT, FAILURE_EXIT_CODE)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A cut is only meaningful on a lane branch whose implementation is uncommitted. On the default
|
|
101
|
+
// branch, or with a clean tree, there is nothing to carry across the boundary.
|
|
102
|
+
function refuse_unready(state: CutState): number {
|
|
103
|
+
console.error(
|
|
104
|
+
`Not ready to cut on ${state.branch} (dirty: ${String(state.is_dirty)}); a cut needs an uncommitted lane branch. Nothing was cut.`,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return report(UNREADY_VERDICT, FAILURE_EXIT_CODE)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Below the shared context threshold there is no accumulation a cut would drop, so the resume it would
|
|
111
|
+
// relaunch costs more than it saves; the current process carries the run on to the gate uncut.
|
|
112
|
+
function report_under_threshold(): number {
|
|
113
|
+
console.error(
|
|
114
|
+
'The recent-window context is under the shared cut threshold, so nothing was cut; a resume would cost more than the accumulation it would drop. Continue to the gate.',
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return report(UNDER_THRESHOLD_VERDICT, SUCCESS_EXIT_CODE)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// **An implementation cut is never resumed by a session over the threshold** (joshuafolkken/kit#2760).
|
|
121
|
+
// The cut outside a lane leaves the session that took it open, so a person retyping `fullrun #N` there
|
|
122
|
+
// would be answered `resume-impl`, keep implementing over the threshold, and be cut again on the next
|
|
123
|
+
// edit — a loop that sheds no context. The record is left intact for a fresh session.
|
|
124
|
+
function report_over(): number {
|
|
125
|
+
console.error(
|
|
126
|
+
'This session is over the shared context-cut threshold (`pnpm josh cost --cut` answers `over`), so the implementation cut was not resumed here and its record is kept. End this conversation and type the invocation again in a fresh session.',
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return report(OVER_VERDICT, FAILURE_EXIT_CODE)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const run_cut_report = {
|
|
133
|
+
BAD_HANDOFF_VERDICT,
|
|
134
|
+
BUSY_VERDICT,
|
|
135
|
+
CUT_VERDICT,
|
|
136
|
+
ENDED_VERDICT,
|
|
137
|
+
FAILED_VERDICT,
|
|
138
|
+
FAILURE_EXIT_CODE,
|
|
139
|
+
FRESH_VERDICT,
|
|
140
|
+
HANDED_OFF_VERDICT,
|
|
141
|
+
HANDOFF_MISSING_NOTE,
|
|
142
|
+
HANDOFF_OVERFLOW_NOTE,
|
|
143
|
+
INCOMPLETE_VERDICT,
|
|
144
|
+
NOT_A_LANE_VERDICT,
|
|
145
|
+
OVER_VERDICT,
|
|
146
|
+
RESUME_IMPL_VERDICT,
|
|
147
|
+
RESUME_VERDICT,
|
|
148
|
+
STALE_VERDICT,
|
|
149
|
+
SUCCESS_EXIT_CODE,
|
|
150
|
+
UNDER_THRESHOLD_VERDICT,
|
|
151
|
+
UNKNOWN_VERDICT,
|
|
152
|
+
UNREADABLE_VERDICT,
|
|
153
|
+
UNREADY_VERDICT,
|
|
154
|
+
refuse_unready,
|
|
155
|
+
report,
|
|
156
|
+
report_bad_handoff,
|
|
157
|
+
report_busy,
|
|
158
|
+
report_handed_off,
|
|
159
|
+
report_incomplete,
|
|
160
|
+
report_over,
|
|
161
|
+
report_stale,
|
|
162
|
+
report_under_threshold,
|
|
163
|
+
report_unknown,
|
|
164
|
+
report_unreadable,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export { run_cut_report }
|
package/scripts/run/run-cut.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { stamp_file } from '#scripts/josh/stamp-file'
|
|
|
9
9
|
import { lane_child_invocation } from '#scripts/lane/lane-child-invocation'
|
|
10
10
|
import { z } from 'zod'
|
|
11
11
|
import { run_cut_handoff, type Handoff } from './run-cut-handoff'
|
|
12
|
-
import { run_hold } from './run-hold'
|
|
12
|
+
import { run_hold, type RunHold } from './run-hold'
|
|
13
13
|
|
|
14
14
|
// joshuafolkken/kit#1839: a lane child is a detached `fullrun #<N>` process, and the thinking it
|
|
15
15
|
// accumulates while implementing rides on every later API call in the same session — measured at 176K
|
|
@@ -118,6 +118,8 @@ interface CutState {
|
|
|
118
118
|
branch: string
|
|
119
119
|
is_dirty: boolean
|
|
120
120
|
is_held: boolean
|
|
121
|
+
// The issue a `fullrun` hold names, absent when the tree is not held by one (joshuafolkken/kit#2760).
|
|
122
|
+
held_issue?: string | undefined
|
|
121
123
|
}
|
|
122
124
|
|
|
123
125
|
// The fields a cut is written from, bundled so `begin_cut` stays within the parameter limit. `phase`
|
|
@@ -432,22 +434,34 @@ function end_cut(target: string): void {
|
|
|
432
434
|
stamp_file.remove_stamp(target)
|
|
433
435
|
}
|
|
434
436
|
|
|
435
|
-
//
|
|
437
|
+
// The run hold still standing over this tree — present as `held` or as an aged-out `stale`, but
|
|
436
438
|
// present. A tree with no hold at all is not the one a cut was taken in, so the resume refuses it.
|
|
437
|
-
function
|
|
438
|
-
if (directory === undefined) return
|
|
439
|
+
function hold_of(directory: string | undefined): RunHold | undefined {
|
|
440
|
+
if (directory === undefined) return undefined
|
|
439
441
|
|
|
440
442
|
const read = run_hold.read_hold(run_hold.hold_path(directory))
|
|
441
443
|
|
|
442
|
-
return read.kind === 'held' || read.kind === 'stale'
|
|
444
|
+
return read.kind === 'held' || read.kind === 'stale' ? read.hold : undefined
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// The issue a `fullrun` holds this tree for — what tells a `fullrun` held in its own checkout apart from
|
|
448
|
+
// a person's tree (joshuafolkken/kit#2760). A `halfrun` or an in-session `backlogrun` child holds it
|
|
449
|
+
// too, but its cut would be resumed as `fullrun #N`, so only a hold `run:entry` marked names an issue.
|
|
450
|
+
function fullrun_issue(hold: RunHold | undefined): string | undefined {
|
|
451
|
+
return hold?.is_fullrun === true ? hold.issue : undefined
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// The same read taken synchronously, for the `PreToolUse` implementation-cut guard.
|
|
455
|
+
function held_issue_sync(): string | undefined {
|
|
456
|
+
return fullrun_issue(hold_of(worktree_git_directory_sync()))
|
|
443
457
|
}
|
|
444
458
|
|
|
445
459
|
async function current_state(): Promise<CutState> {
|
|
446
460
|
const branch = await git_command.branch()
|
|
447
461
|
const is_dirty = await run_hold.is_tree_dirty()
|
|
448
|
-
const
|
|
462
|
+
const hold = hold_of(await worktree_directory())
|
|
449
463
|
|
|
450
|
-
return { branch, is_dirty, is_held:
|
|
464
|
+
return { branch, is_dirty, is_held: hold !== undefined, held_issue: fullrun_issue(hold) }
|
|
451
465
|
}
|
|
452
466
|
|
|
453
467
|
function describe_cut(cut: RunCut): string {
|
|
@@ -508,8 +522,10 @@ const run_cut = {
|
|
|
508
522
|
describe_cut,
|
|
509
523
|
end_cut,
|
|
510
524
|
fresh_cut,
|
|
525
|
+
fullrun_issue,
|
|
511
526
|
handed_off_message,
|
|
512
527
|
has_required_handoff,
|
|
528
|
+
held_issue_sync,
|
|
513
529
|
incomplete_message,
|
|
514
530
|
invocation_for,
|
|
515
531
|
is_expired,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
3
|
import { josh_command } from '#scripts/josh/josh-run'
|
|
4
4
|
import { lane_child_marker } from '#scripts/lane/lane-child-marker'
|
|
5
|
+
import { run_cut_report } from './run-cut-report'
|
|
5
6
|
import { run_entry, type EntryParts } from './run-entry'
|
|
6
7
|
import { run_hold_cli } from './run-hold-cli'
|
|
7
8
|
import { run_next } from './run-next'
|
|
@@ -50,9 +51,13 @@ function first_line(out: string): string {
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
// `run:hold <N>` prints one token — `hold`, `busy` or `unknown` — and forwards its explanation to
|
|
53
|
-
// stderr, so the composite branches on the token and the reader still sees why.
|
|
54
|
+
// stderr, so the composite branches on the token and the reader still sees why. `--fullrun` marks the
|
|
55
|
+
// hold as this entry's, since `run:entry` is `fullrun #N`'s alone (joshuafolkken/kit#2760).
|
|
54
56
|
async function claim_hold(issue_number: string): Promise<string> {
|
|
55
|
-
const held = await josh_command.josh_run(
|
|
57
|
+
const held = await josh_command.josh_run(
|
|
58
|
+
['run:hold', issue_number, '--fullrun'],
|
|
59
|
+
should_forward_stderr,
|
|
60
|
+
)
|
|
56
61
|
|
|
57
62
|
return first_line(held.out)
|
|
58
63
|
}
|
|
@@ -96,7 +101,36 @@ function stop_report(issue_number: string, hold: string, cost: string, report: s
|
|
|
96
101
|
return emit({ issue_number, hold, cost, verdict: run_entry.NO_VERDICT, report })
|
|
97
102
|
}
|
|
98
103
|
|
|
104
|
+
interface Resume {
|
|
105
|
+
token: string
|
|
106
|
+
code: number
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// **A resume is asked before the hold is claimed** (joshuafolkken/kit#2760). An implementation cut
|
|
110
|
+
// outside a lane keeps its hold, so the fresh session's `fullrun #N` would be refused `busy` by its own
|
|
111
|
+
// run's hold before it ever reached `run:cut --resume` — the hand-off the cut promised could not land.
|
|
112
|
+
async function ask_resume(issue_number: string): Promise<Resume> {
|
|
113
|
+
const asked = await josh_command.josh_run(
|
|
114
|
+
['run:cut', '--resume', issue_number],
|
|
115
|
+
should_forward_stderr,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return { token: first_line(asked.out), code: asked.code }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Anything but `fresh` is `run:cut --resume`'s answer to act on (`pre-gate-cut.md`), not a new run: the
|
|
122
|
+
// token and its exit code are passed through, and nothing is claimed or read.
|
|
123
|
+
function resume_report(issue_number: string, resume: Resume): number {
|
|
124
|
+
console.info(`entry #${issue_number} — resume: ${resume.token}`)
|
|
125
|
+
|
|
126
|
+
return resume.code
|
|
127
|
+
}
|
|
128
|
+
|
|
99
129
|
async function open_run(issue_number: string): Promise<number> {
|
|
130
|
+
const resume = await ask_resume(issue_number)
|
|
131
|
+
|
|
132
|
+
if (resume.token !== run_cut_report.FRESH_VERDICT) return resume_report(issue_number, resume)
|
|
133
|
+
|
|
100
134
|
const hold = await claim_hold(issue_number)
|
|
101
135
|
|
|
102
136
|
if (hold !== run_hold_cli.HOLD_VERDICT) {
|
|
@@ -43,8 +43,14 @@ const RELEASE_FLAG = '--release'
|
|
|
43
43
|
// cannot — clearing a record somebody else left behind — is spelled out rather than reached by
|
|
44
44
|
// typing the ordinary command.
|
|
45
45
|
const FORCE_FLAG = '--force'
|
|
46
|
+
// What `fullrun #N`'s entry claims with, so the record says the run is a `fullrun` — the one run whose
|
|
47
|
+
// implementation cut outside a lane resumes as `fullrun #N` (joshuafolkken/kit#2760).
|
|
48
|
+
const FULLRUN_FLAG = '--fullrun'
|
|
49
|
+
// The most a claim reads: the issue number and the `--fullrun` flag after it.
|
|
50
|
+
const MAX_CLAIM_ARGUMENTS = 2
|
|
46
51
|
const ISSUE_NUMBER_PATTERN = /^[1-9]\d*$/u
|
|
47
|
-
const USAGE =
|
|
52
|
+
const USAGE =
|
|
53
|
+
'Usage: josh run:hold [<issue-number> [--fullrun]] | josh run:release [<issue-number> | --force]'
|
|
48
54
|
|
|
49
55
|
const HOLD_VERDICT = 'hold'
|
|
50
56
|
const BUSY_VERDICT = 'busy'
|
|
@@ -57,10 +63,14 @@ const HELD_VERDICT = 'held'
|
|
|
57
63
|
|
|
58
64
|
const FORCE_RELEASE_KIND = 'force-release'
|
|
59
65
|
|
|
66
|
+
interface ClaimRequest {
|
|
67
|
+
kind: 'claim'
|
|
68
|
+
issue: string
|
|
69
|
+
is_fullrun?: boolean
|
|
70
|
+
}
|
|
71
|
+
|
|
60
72
|
type HoldRequest =
|
|
61
|
-
| { kind: 'release'; claimant: string }
|
|
62
|
-
| { kind: typeof FORCE_RELEASE_KIND }
|
|
63
|
-
| { kind: 'claim'; issue: string }
|
|
73
|
+
ClaimRequest | { kind: 'release'; claimant: string } | { kind: typeof FORCE_RELEASE_KIND }
|
|
64
74
|
|
|
65
75
|
const FORCE_RELEASE_REQUEST: HoldRequest = { kind: FORCE_RELEASE_KIND }
|
|
66
76
|
|
|
@@ -68,14 +78,21 @@ function release_request(claimant: string): HoldRequest {
|
|
|
68
78
|
return { kind: 'release', claimant }
|
|
69
79
|
}
|
|
70
80
|
|
|
81
|
+
// The flag after a numbered claim: absent, or `--fullrun`. Anything else is a usage error.
|
|
82
|
+
function numbered_claim(issue: string, flag: string | undefined): HoldRequest | undefined {
|
|
83
|
+
if (flag === undefined) return { kind: 'claim', issue }
|
|
84
|
+
|
|
85
|
+
return flag === FULLRUN_FLAG ? { kind: 'claim', issue, is_fullrun: true } : undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
71
88
|
function parse_claim(argv: ReadonlyArray<string>): HoldRequest | undefined {
|
|
72
|
-
const [first] = argv
|
|
89
|
+
const [first, flag] = argv
|
|
73
90
|
|
|
74
91
|
if (first === undefined) return { kind: 'claim', issue: run_hold.UNNUMBERED_ISSUE }
|
|
75
92
|
|
|
76
|
-
if (argv.length >
|
|
93
|
+
if (argv.length > MAX_CLAIM_ARGUMENTS || !ISSUE_NUMBER_PATTERN.test(first)) return undefined
|
|
77
94
|
|
|
78
|
-
return
|
|
95
|
+
return numbered_claim(first, flag)
|
|
79
96
|
}
|
|
80
97
|
|
|
81
98
|
function parse_release_argument(first: string): HoldRequest | undefined {
|
|
@@ -135,8 +152,10 @@ async function blocking_message(read: HoldRead): Promise<string | undefined> {
|
|
|
135
152
|
|
|
136
153
|
// A record already here is never overwritten by a claim: overwriting is what the guard exists to
|
|
137
154
|
// 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
|
-
|
|
155
|
+
function take_free_tree(target: string, request: ClaimRequest): number {
|
|
156
|
+
const is_taken = run_hold.create_hold(target, request.issue, new Date(), request.is_fullrun)
|
|
157
|
+
|
|
158
|
+
if (is_taken) return report_hold()
|
|
140
159
|
|
|
141
160
|
return report_busy(run_hold.race_message(run_hold.read_hold(target), target))
|
|
142
161
|
}
|
|
@@ -145,11 +164,11 @@ function take_free_tree(target: string, issue: string): number {
|
|
|
145
164
|
// record with a plain write would tell two sessions that both found it expired that they both won —
|
|
146
165
|
// the race the free path was just fixed for, reintroduced one branch over. Removing it first is what
|
|
147
166
|
// turns the expired record into a free tree; the create is what decides between the two claimants.
|
|
148
|
-
function replace_stale(target: string,
|
|
167
|
+
function replace_stale(target: string, request: ClaimRequest, hold: RunHold): number {
|
|
149
168
|
console.error(run_hold.stale_message(hold))
|
|
150
169
|
run_hold.release_hold(target)
|
|
151
170
|
|
|
152
|
-
return take_free_tree(target,
|
|
171
|
+
return take_free_tree(target, request)
|
|
153
172
|
}
|
|
154
173
|
|
|
155
174
|
// A non-clean preflight verdict is not an error: it is an answer a loop branches on, exactly as
|
|
@@ -176,8 +195,8 @@ async function preflight_gate(issue: string, is_linked: boolean): Promise<number
|
|
|
176
195
|
return report_preflight(decision)
|
|
177
196
|
}
|
|
178
197
|
|
|
179
|
-
async function claim(target: string,
|
|
180
|
-
const gate = await preflight_gate(issue, is_linked)
|
|
198
|
+
async function claim(target: string, request: ClaimRequest, is_linked: boolean): Promise<number> {
|
|
199
|
+
const gate = await preflight_gate(request.issue, is_linked)
|
|
181
200
|
|
|
182
201
|
if (gate !== undefined) return gate
|
|
183
202
|
|
|
@@ -187,7 +206,9 @@ async function claim(target: string, issue: string, is_linked: boolean): Promise
|
|
|
187
206
|
if (blocked !== undefined) return report_busy(blocked)
|
|
188
207
|
|
|
189
208
|
const code =
|
|
190
|
-
read.kind === 'stale'
|
|
209
|
+
read.kind === 'stale'
|
|
210
|
+
? replace_stale(target, request, read.hold)
|
|
211
|
+
: take_free_tree(target, request)
|
|
191
212
|
|
|
192
213
|
if (code === SUCCESS_EXIT_CODE) await run_tidy_cli.sweep()
|
|
193
214
|
|
|
@@ -262,7 +283,7 @@ async function read_worktree(): Promise<string | undefined> {
|
|
|
262
283
|
}
|
|
263
284
|
|
|
264
285
|
async function dispatch(request: HoldRequest, target: string, is_linked: boolean): Promise<number> {
|
|
265
|
-
if (request.kind === 'claim') return await claim(target, request
|
|
286
|
+
if (request.kind === 'claim') return await claim(target, request, is_linked)
|
|
266
287
|
|
|
267
288
|
if (request.kind === FORCE_RELEASE_KIND) return force_release(target)
|
|
268
289
|
|
package/scripts/run/run-hold.ts
CHANGED
|
@@ -43,6 +43,11 @@ 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
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
type HoldRead =
|
|
@@ -81,6 +86,7 @@ const run_hold_schema = z.object({
|
|
|
81
86
|
issue: z.string(),
|
|
82
87
|
taken_at: z.string(),
|
|
83
88
|
pid: z.number(),
|
|
89
|
+
is_fullrun: z.boolean().optional(),
|
|
84
90
|
})
|
|
85
91
|
|
|
86
92
|
// `undefined` for anything that is not a well-formed record, so the caller decides what a broken one
|
|
@@ -125,8 +131,8 @@ function read_hold(target: string, now: Date = new Date()): HoldRead {
|
|
|
125
131
|
return classify(stamp_file.read_stamp_text(target), now)
|
|
126
132
|
}
|
|
127
133
|
|
|
128
|
-
function build_hold(issue: string, now: Date): RunHold {
|
|
129
|
-
return { issue, taken_at: now.toISOString(), pid: process.pid }
|
|
134
|
+
function build_hold(issue: string, now: Date, is_fullrun?: boolean): RunHold {
|
|
135
|
+
return { issue, taken_at: now.toISOString(), pid: process.pid, is_fullrun }
|
|
130
136
|
}
|
|
131
137
|
|
|
132
138
|
function write_hold(target: string, issue: string, now: Date = new Date()): RunHold {
|
|
@@ -141,8 +147,13 @@ function write_hold(target: string, issue: string, now: Date = new Date()): RunH
|
|
|
141
147
|
// an entry point in the same second both read `free`, and a write would tell both of them they won —
|
|
142
148
|
// which is the incident this guard was written after, reproduced by the guard itself. `false` means
|
|
143
149
|
// the other one got there first.
|
|
144
|
-
function create_hold(
|
|
145
|
-
|
|
150
|
+
function create_hold(
|
|
151
|
+
target: string,
|
|
152
|
+
issue: string,
|
|
153
|
+
now: Date = new Date(),
|
|
154
|
+
is_fullrun?: boolean,
|
|
155
|
+
): boolean {
|
|
156
|
+
return stamp_file.create_stamp(target, build_hold(issue, now, is_fullrun))
|
|
146
157
|
}
|
|
147
158
|
|
|
148
159
|
// **A tree with uncommitted work in it is never handed over, however old its record is.** No age can
|
|
@@ -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,
|