@joshuafolkken/kit 1.1166.0 → 1.1178.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/backlogrun-lanes.md +3 -3
- package/.claude/skills/workflow-commands/backlogrun-park.md +6 -7
- package/.claude/skills/workflow-commands/backlogrun-steps.md +3 -2
- package/.claude/skills/workflow-commands/pre-gate-cut.md +4 -4
- package/.github/workflows/ci.yml +15 -2
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-5KTCMCOT.js → chunk-32GQTL4F.js} +1 -1
- package/dist/hooks/{chunk-URJA6XY4.js → chunk-DO6NWWHU.js} +4 -4
- package/dist/hooks/{chunk-GU5MATVE.js → chunk-G6KRCKEC.js} +11 -19
- package/dist/hooks/{chunk-ZDZ555YH.js → chunk-GDS5N2KB.js} +88 -291
- package/dist/hooks/{chunk-GYNGP7HI.js → chunk-I4XSL4TT.js} +1 -1
- package/dist/hooks/{chunk-DYAVAM6S.js → chunk-S6X6WYKR.js} +324 -49
- package/dist/hooks/{chunk-ANJ2WGFD.js → chunk-UAGLLIGZ.js} +1 -1
- package/dist/hooks/{chunk-ENA7P5FB.js → chunk-VESKRQOF.js} +3 -3
- package/dist/hooks/{chunk-V2BTOZHT.js → chunk-VQLLNFNT.js} +3 -3
- package/dist/hooks/{chunk-A6P7PN7P.js → chunk-ZAL3NRGL.js} +2 -2
- 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/inputs.json +192 -192
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +9 -9
- package/dist/hooks/stop-guard.js +18 -4
- package/dist/josh.js +4 -1
- package/package.json +1 -1
- package/scripts/backlog/backlog-next.ts +15 -4
- package/scripts/backlog/backlog-plan-cli.ts +6 -5
- package/scripts/backlog/backlog-rank.ts +18 -9
- package/scripts/backlog/backlog-restructure.ts +152 -0
- package/scripts/backlog/backlog-waves.ts +15 -17
- package/scripts/cost-runtime/context-cut-threshold.ts +1 -9
- package/scripts/cost-runtime/cost-verdict.ts +15 -28
- package/scripts/followup/git-followup-stages.ts +9 -0
- package/scripts/followup/git-pr-followup.ts +1 -1
- package/scripts/gh/git-gh-issue.ts +5 -3
- package/scripts/gh/git-gh-pr-read.ts +4 -0
- package/scripts/gh/git-pr-checks-eval.ts +8 -0
- package/scripts/gh/git-pr-checks.ts +11 -1
- package/scripts/git/constants.ts +10 -0
- package/scripts/git/main-merge.ts +57 -21
- package/scripts/git/schemas.ts +2 -0
- package/scripts/josh/josh-commands-development.ts +1 -1
- package/scripts/lane/lane-launch-cli.ts +21 -3
- package/scripts/notify/telegram-notify.ts +26 -1
- package/scripts/rules/pre-gate-cut.ts +2 -2
- package/scripts/run/run-carry.ts +11 -5
- package/scripts/run/run-cut-cli.ts +1 -1
- package/scripts/run/run-cut-report.ts +2 -2
- package/scripts/run/run-ship-cli.ts +27 -6
- package/scripts/run/run-ship-next.ts +13 -0
- package/scripts/run/run-ship-pre-detach.ts +66 -0
- package/scripts/run/run-ship-return.ts +7 -4
- package/scripts/run/run-ship-stage.ts +13 -0
- package/scripts/run/run-ship-steps.ts +10 -2
- package/scripts/run/run-ship-sync.ts +114 -0
- package/scripts/run/run-ship.ts +6 -0
- package/scripts/run/run-wake-cli.ts +8 -1
- package/scripts/run/run-wake-driver.ts +42 -11
- package/scripts/test/test-red-logic.ts +17 -2
- package/scripts/test/test-red.ts +8 -7
- package/scripts/time-runtime/time-transcript-line.ts +27 -1
|
@@ -44,44 +44,79 @@ function merge_message(default_branch: string, current_branch: string): string |
|
|
|
44
44
|
return `Merge ${default_branch} into ${current_branch} #${issue_number}`
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
47
|
+
// What one merge of the default branch came to (joshuafolkken/kit#3221). `josh ship` merges it again
|
|
48
|
+
// before the commit and on a conflicting pull request, and branches on the answer rather than on an
|
|
49
|
+
// exit code: `current` brought nothing in, so the tree is unchanged; `merged` changed it; `refused`
|
|
50
|
+
// is the guard's answer, nothing attempted; `conflict` names the paths git left unmerged, so a resumed
|
|
51
|
+
// session goes straight to them instead of asking git again.
|
|
52
|
+
type MergeOutcome =
|
|
53
|
+
| { kind: 'current'; branch: string }
|
|
54
|
+
| { kind: 'merged'; branch: string }
|
|
55
|
+
| { kind: 'refused'; message: string }
|
|
56
|
+
| { kind: 'conflict'; files: ReadonlyArray<string> }
|
|
57
|
+
|
|
58
|
+
interface MergeTarget {
|
|
59
|
+
default_branch: string
|
|
60
|
+
current_branch: string
|
|
61
|
+
is_current: boolean
|
|
54
62
|
}
|
|
55
63
|
|
|
56
|
-
// A
|
|
57
|
-
//
|
|
58
|
-
async function
|
|
64
|
+
// A failure that left no unmerged path — a remote that cannot be reached, a hook that refused — is not
|
|
65
|
+
// a conflict, so it is rethrown rather than reported as one with nothing to resolve.
|
|
66
|
+
async function merge_into(target: MergeTarget): Promise<MergeOutcome> {
|
|
67
|
+
const { default_branch, current_branch } = target
|
|
68
|
+
|
|
59
69
|
try {
|
|
60
|
-
await git_command.merge_branch(default_branch,
|
|
70
|
+
await git_command.merge_branch(default_branch, merge_message(default_branch, current_branch))
|
|
61
71
|
} catch (error) {
|
|
62
|
-
|
|
63
|
-
|
|
72
|
+
const files = main_merge_guard.unmerged_paths(await git_command.status())
|
|
73
|
+
|
|
74
|
+
if (files.length === 0) throw error
|
|
75
|
+
|
|
76
|
+
return { kind: 'conflict', files }
|
|
64
77
|
}
|
|
78
|
+
|
|
79
|
+
return { kind: target.is_current ? 'current' : 'merged', branch: default_branch }
|
|
65
80
|
}
|
|
66
81
|
|
|
67
82
|
// The fetch is what makes `origin/<default>` current before the merge reads it. `git pull` did the
|
|
68
|
-
// two together, which is the only thing it was doing here that is worth keeping.
|
|
69
|
-
|
|
83
|
+
// two together, which is the only thing it was doing here that is worth keeping. The refusal is read
|
|
84
|
+
// after the fetch, so the incoming paths are the ones the merge would bring in (joshuafolkken/kit#2445).
|
|
85
|
+
async function merge(): Promise<MergeOutcome> {
|
|
70
86
|
const default_branch = await git_command.get_default_branch()
|
|
71
87
|
const current_branch = await git_command.branch()
|
|
72
88
|
|
|
73
89
|
await git_command.fetch_branch(default_branch)
|
|
74
90
|
|
|
75
|
-
const
|
|
91
|
+
const status = await git_command.status()
|
|
92
|
+
const incoming = await main_merge_guard.incoming_paths(default_branch)
|
|
93
|
+
const message = main_merge_guard.refusal(status, incoming, default_branch)
|
|
94
|
+
|
|
95
|
+
if (message !== undefined) return { kind: 'refused', message }
|
|
96
|
+
|
|
97
|
+
return await merge_into({ default_branch, current_branch, is_current: incoming.length === 0 })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A merge that stops on a conflict keeps git's report and adds how to finish it, so the next step is
|
|
101
|
+
// the sanctioned commit rather than a request for `git add`.
|
|
102
|
+
function failure_text(outcome: MergeOutcome): string | undefined {
|
|
103
|
+
if (outcome.kind === 'refused') return outcome.message
|
|
104
|
+
if (outcome.kind === 'conflict') return main_merge_guard.CONFLICT_HINT
|
|
105
|
+
|
|
106
|
+
return undefined
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function merge_default_branch(): Promise<number> {
|
|
110
|
+
const outcome = await merge()
|
|
111
|
+
const failure = failure_text(outcome)
|
|
76
112
|
|
|
77
|
-
if (
|
|
78
|
-
console.error(
|
|
113
|
+
if (failure !== undefined) {
|
|
114
|
+
console.error(failure)
|
|
79
115
|
|
|
80
116
|
return FAILURE_EXIT_CODE
|
|
81
117
|
}
|
|
82
118
|
|
|
83
|
-
|
|
84
|
-
console.info(default_branch)
|
|
119
|
+
if ('branch' in outcome) console.info(outcome.branch)
|
|
85
120
|
|
|
86
121
|
return SUCCESS_EXIT_CODE
|
|
87
122
|
}
|
|
@@ -114,8 +149,9 @@ async function main(argv: ReadonlyArray<string>): Promise<void> {
|
|
|
114
149
|
process.exitCode = await run(argv)
|
|
115
150
|
}
|
|
116
151
|
|
|
117
|
-
const main_merge = { run }
|
|
152
|
+
const main_merge = { merge, run }
|
|
118
153
|
|
|
119
154
|
if (process.argv[1] === fileURLToPath(import.meta.url)) await main(process.argv.slice(ARGV_OFFSET))
|
|
120
155
|
|
|
121
156
|
export { main_merge }
|
|
157
|
+
export type { MergeOutcome }
|
package/scripts/git/schemas.ts
CHANGED
|
@@ -140,6 +140,8 @@ const open_issue_schema = z.object({
|
|
|
140
140
|
labels: z.array(issue_label_schema).optional(),
|
|
141
141
|
createdAt: z.string(),
|
|
142
142
|
blockedBy: blocked_by_schema,
|
|
143
|
+
// Present only on the pickup listing, which reads it for the restructures each issue declares.
|
|
144
|
+
body: z.string().optional(),
|
|
143
145
|
})
|
|
144
146
|
|
|
145
147
|
type RollupItemData = z.infer<typeof rollup_item_schema>
|
|
@@ -168,7 +168,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
|
|
|
168
168
|
'test:red': {
|
|
169
169
|
script: 'scripts/test/test-red.ts',
|
|
170
170
|
description:
|
|
171
|
-
'Run the changed unit tests against the pre-fix tree (merge-base worktree) and report red/green/no-test',
|
|
171
|
+
'Run the changed unit tests against the pre-fix tree (merge-base worktree) and report red/green/no-test/test-only',
|
|
172
172
|
category: 'Development',
|
|
173
173
|
reference: ['', 'developer', ['git', 'processes']],
|
|
174
174
|
},
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
3
|
import { josh_command } from '#scripts/josh/josh-run'
|
|
4
|
+
import { lane_registry } from '#scripts/lane/lane-registry'
|
|
4
5
|
import { cli_flags } from '#scripts/lib/cli-flags'
|
|
5
6
|
import { INSTALL_TIMEOUT_MS } from '#scripts/lib/timeouts'
|
|
6
7
|
import { execa } from 'execa'
|
|
@@ -15,6 +16,7 @@ import { execa } from 'execa'
|
|
|
15
16
|
// and messages are reused rather than cloned — `lane:open`'s cut-session guard and its `full` /
|
|
16
17
|
// `already-open` refusals, `stash:pop`'s message-matched pop, `lane:dispatch`'s in-progress claim and
|
|
17
18
|
// warning. Their explanations stream through to stderr; the child's pid is the one thing on stdout.
|
|
19
|
+
// A lane a park kept is the one `already-open` it steps past — `lane_directory` below.
|
|
18
20
|
|
|
19
21
|
const ARGV_OFFSET = 2
|
|
20
22
|
const SUCCESS_EXIT_CODE = 0
|
|
@@ -86,14 +88,30 @@ type LaunchOutcome = { kind: 'launched'; pid: string } | { kind: 'unopened' } |
|
|
|
86
88
|
const UNOPENED: LaunchOutcome = { kind: 'unopened' }
|
|
87
89
|
const FAILED: LaunchOutcome = { kind: 'failed' }
|
|
88
90
|
|
|
91
|
+
// **A lane a park kept open is resumed, not reopened** (joshuafolkken/kit#3228). A child parked before
|
|
92
|
+
// its commit leaves its uncommitted work in its lane, and `lane:open` refuses that lane as
|
|
93
|
+
// `already-open` — so the released child is dispatched into the kept tree instead. **Only a lane a
|
|
94
|
+
// child was already dispatched into counts as kept**: `lane:dispatch` records the child's output in the
|
|
95
|
+
// lane's `.env`, and a lane whose install failed never got that far, so it still goes through
|
|
96
|
+
// `lane:open` and its refusal. So does a stranded lane, whose `.env` is gone with its tree.
|
|
97
|
+
async function lane_directory(issue: string): Promise<string | undefined> {
|
|
98
|
+
const kept = await lane_registry.find_open_lane(issue)
|
|
99
|
+
|
|
100
|
+
if (kept?.output !== undefined) return kept.directory
|
|
101
|
+
|
|
102
|
+
const opened = await josh_command.josh_run(['lane:open', issue], should_forward_stderr)
|
|
103
|
+
|
|
104
|
+
return opened.code === SUCCESS_EXIT_CODE ? opened.out : undefined
|
|
105
|
+
}
|
|
106
|
+
|
|
89
107
|
// The launch chain in-process. The CLI prints the pid; `backlog:drive` launches through this same
|
|
90
108
|
// chain (joshuafolkken/kit#2508).
|
|
91
109
|
async function launch_lane(context: LaunchContext): Promise<LaunchOutcome> {
|
|
92
|
-
const
|
|
110
|
+
const directory = await lane_directory(context.issue)
|
|
93
111
|
|
|
94
|
-
if (
|
|
112
|
+
if (directory === undefined) return UNOPENED
|
|
95
113
|
|
|
96
|
-
if (!(await prepare(context.stash,
|
|
114
|
+
if (!(await prepare(context.stash, directory))) return FAILED
|
|
97
115
|
|
|
98
116
|
const dispatched = await josh_command.josh_run(
|
|
99
117
|
['lane:dispatch', context.issue],
|
|
@@ -3,6 +3,10 @@ import { timed_fetch } from '#scripts/lib/timed-fetch'
|
|
|
3
3
|
import { z } from 'zod'
|
|
4
4
|
|
|
5
5
|
const TELEGRAM_API_BASE = 'https://api.telegram.org'
|
|
6
|
+
// `sendMessage` refuses a text longer than this.
|
|
7
|
+
const TELEGRAM_TEXT_LIMIT = 4096
|
|
8
|
+
const BODY_TRUNCATION_MARK = '\n...'
|
|
9
|
+
const TRAILING_HIGH_SURROGATE = /[\uD800-\uDBFF]$/u
|
|
6
10
|
|
|
7
11
|
const telegram_environment_schema = z.object({
|
|
8
12
|
telegram_bot_token: z.string().min(1, { message: 'TELEGRAM_BOT_TOKEN is required' }),
|
|
@@ -188,10 +192,31 @@ function build_blocks(input: TelegramSendInput): Array<string> {
|
|
|
188
192
|
return blocks
|
|
189
193
|
}
|
|
190
194
|
|
|
191
|
-
function
|
|
195
|
+
function join_blocks(input: TelegramSendInput): string {
|
|
192
196
|
return build_blocks(input).join('\n\n')
|
|
193
197
|
}
|
|
194
198
|
|
|
199
|
+
// A cut between the two halves of a surrogate pair leaves a lone surrogate, which is not valid
|
|
200
|
+
// UTF-8 once encoded, so the half is dropped with the rest of the tail.
|
|
201
|
+
function head_of(body: string, kept: number): string {
|
|
202
|
+
const head = body.slice(0, kept)
|
|
203
|
+
|
|
204
|
+
return TRAILING_HIGH_SURROGATE.test(head) ? head.slice(0, -1) : head
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// **The body is cut to fit Telegram's length limit, keeping its head** (joshuafolkken/kit#3242). A
|
|
208
|
+
// warning carrying a whole driver stderr ran past 4096 characters and was refused with `400 Bad
|
|
209
|
+
// Request`, so it reached nobody. A caller puts the cause first, so the tail is what is dropped, and the
|
|
210
|
+
// header and the URLs around the body survive.
|
|
211
|
+
function build_text(input: TelegramSendInput): string {
|
|
212
|
+
const text = join_blocks(input)
|
|
213
|
+
const overflow = text.length - TELEGRAM_TEXT_LIMIT
|
|
214
|
+
if (overflow <= 0 || input.body === undefined) return text
|
|
215
|
+
const kept = Math.max(0, input.body.length - overflow - BODY_TRUNCATION_MARK.length)
|
|
216
|
+
|
|
217
|
+
return join_blocks({ ...input, body: `${head_of(input.body, kept)}${BODY_TRUNCATION_MARK}` })
|
|
218
|
+
}
|
|
219
|
+
|
|
195
220
|
async function post_message(config: TelegramConfig, text: string): Promise<void> {
|
|
196
221
|
const url = `${TELEGRAM_API_BASE}/bot${config.bot_token}/sendMessage`
|
|
197
222
|
const response = await timed_fetch(url, {
|
|
@@ -100,7 +100,7 @@ interface LaneCutState {
|
|
|
100
100
|
directory: string
|
|
101
101
|
carried: (now?: Date) => RunCut | undefined
|
|
102
102
|
marked_issue: string | undefined
|
|
103
|
-
// **The
|
|
103
|
+
// **The context verdict, read lazily** (joshuafolkken/kit#2312). It is a thunk, like
|
|
104
104
|
// `carried`, so `current_state` builds the object without pricing a session — the read happens only
|
|
105
105
|
// after the command and lane checks pass, off the handful of calls that actually run a lane's gate.
|
|
106
106
|
context_verdict: () => CostVerdict
|
|
@@ -136,7 +136,7 @@ function uncut_lane_issue(state: LaneCutState): string | undefined {
|
|
|
136
136
|
return state.carried()?.issue === issue ? undefined : issue
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
// **The cut is taken only when the
|
|
139
|
+
// **The cut is taken only when the current context is worth its resume** (joshuafolkken/kit#2312).
|
|
140
140
|
// Below the shared `CONTEXT_CUT_THRESHOLD` a short lane has no accumulation a cut would drop, so the
|
|
141
141
|
// gate runs uncut rather than paying for a relaunch; an unmeasurable session keeps the old
|
|
142
142
|
// unconditional cut as the safety net joshuafolkken/kit#1933 relies on. The verdict is
|
package/scripts/run/run-carry.ts
CHANGED
|
@@ -480,15 +480,20 @@ function busy_message(carry: RunCarry): string {
|
|
|
480
480
|
return `A live run holds this budget — ${describe_carry(carry)}, owner pid ${String(carry.owner_pid)} still running. Nothing was established. Wait for that run, or end its record with \`${END_COMMAND}\` once you know it is over.`
|
|
481
481
|
}
|
|
482
482
|
|
|
483
|
+
// The adoption command, shared by the standing refusal and the judgment wake's hand-off.
|
|
484
|
+
// **`--owner "$PPID"` is part of the command, not an optional extra.** An adoption made without it
|
|
485
|
+
// leaves the record declaring no owner, and from then on every second parent reads it as not provably
|
|
486
|
+
// live and is answered `standing` rather than `busy` — the ownership defense degrading silently
|
|
487
|
+
// through the very message that recommends the command.
|
|
488
|
+
function claim_command(invocation: string): string {
|
|
489
|
+
return `${RESUME_COMMAND} "${invocation}" ${OWNER_ARGUMENT}`
|
|
490
|
+
}
|
|
491
|
+
|
|
483
492
|
// The record's owner is gone and no cut handed it off — the crashed run, and the same command
|
|
484
493
|
// retyped over it. Both ways out are named, because which one is right is the reader's to say and
|
|
485
494
|
// not this command's to guess.
|
|
486
|
-
// **`--owner "$PPID"` is part of the command it prints, not an optional extra.** An adoption made
|
|
487
|
-
// without it leaves the record declaring no owner, and from then on every second parent reads it as
|
|
488
|
-
// not provably live and is answered `standing` rather than `busy` — the ownership defense degrading
|
|
489
|
-
// silently through the very message that recommends the command.
|
|
490
495
|
function standing_message(carry: RunCarry): string {
|
|
491
|
-
return `A run record is standing here that no cut handed off — ${describe_carry(carry)}. Nothing was established. Carry that budget with \`${
|
|
496
|
+
return `A run record is standing here that no cut handed off — ${describe_carry(carry)}. Nothing was established. Carry that budget with \`${claim_command(carry.invocation)}\`, or discard it with \`${END_COMMAND}\` and begin again.`
|
|
492
497
|
}
|
|
493
498
|
|
|
494
499
|
// The count refusal reads differently by cause: a record awaiting its successor, or one a successor
|
|
@@ -535,6 +540,7 @@ const run_carry = {
|
|
|
535
540
|
begin_carry,
|
|
536
541
|
busy_message,
|
|
537
542
|
carry_path,
|
|
543
|
+
claim_command,
|
|
538
544
|
classify,
|
|
539
545
|
classify_claim,
|
|
540
546
|
count_refused_message,
|
|
@@ -60,7 +60,7 @@ const {
|
|
|
60
60
|
} = run_cut_report
|
|
61
61
|
|
|
62
62
|
// **The pre-gate cut is conditional on the same statistic the implementation-phase cut reads**
|
|
63
|
-
// (joshuafolkken/kit#2312). `cost_cli.session_verdict` prices the
|
|
63
|
+
// (joshuafolkken/kit#2312). `cost_cli.session_verdict` prices the newest request
|
|
64
64
|
// against `CONTEXT_CUT_THRESHOLD`, so no second threshold is introduced — an `under` session skips the
|
|
65
65
|
// cut, and an unmeasurable one keeps the old unconditional cut as the safety net joshuafolkken/kit#1933
|
|
66
66
|
// relies on. Only the pre-gate phase is checked here; the implementation-phase caller gates its own
|
|
@@ -14,7 +14,7 @@ const RESUME_VERDICT = 'resume'
|
|
|
14
14
|
// re-reading the record.
|
|
15
15
|
const RESUME_IMPL_VERDICT = 'resume-impl'
|
|
16
16
|
const FRESH_VERDICT = 'fresh'
|
|
17
|
-
// The
|
|
17
|
+
// The current context is under the shared cut threshold, so the pre-gate cut was not worth its
|
|
18
18
|
// resume and nothing was cut (joshuafolkken/kit#2312). A benign, non-failing answer: the run carries
|
|
19
19
|
// on to the gate itself, exactly as `not-a-lane` does.
|
|
20
20
|
const UNDER_THRESHOLD_VERDICT = 'under-threshold'
|
|
@@ -111,7 +111,7 @@ function refuse_unready(state: CutState): number {
|
|
|
111
111
|
// relaunch costs more than it saves; the current process carries the run on to the gate uncut.
|
|
112
112
|
function report_under_threshold(): number {
|
|
113
113
|
console.error(
|
|
114
|
-
'The
|
|
114
|
+
'The current 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
115
|
)
|
|
116
116
|
|
|
117
117
|
return report(UNDER_THRESHOLD_VERDICT, SUCCESS_EXIT_CODE)
|
|
@@ -7,6 +7,7 @@ import { run_event_stream_emit } from './run-event-stream-emit'
|
|
|
7
7
|
import { run_ship, type ShipSection } from './run-ship'
|
|
8
8
|
import { run_ship_detach } from './run-ship-detach'
|
|
9
9
|
import { run_ship_next } from './run-ship-next'
|
|
10
|
+
import { run_ship_pre_detach } from './run-ship-pre-detach'
|
|
10
11
|
import { run_ship_probe } from './run-ship-probe'
|
|
11
12
|
import { run_ship_return } from './run-ship-return'
|
|
12
13
|
import { run_ship_stage, type Phase, type ShipState, type Stage } from './run-ship-stage'
|
|
@@ -171,8 +172,9 @@ function parse(argv: ReadonlyArray<string>): ShipCommand | undefined {
|
|
|
171
172
|
|
|
172
173
|
async function run_step(step: Step, args: ShipArguments, state: ShipState): Promise<ShipSection> {
|
|
173
174
|
const result = await step.run(args, state)
|
|
175
|
+
const section = { header: step.header, body: result.out, code: result.code }
|
|
174
176
|
|
|
175
|
-
return
|
|
177
|
+
return result.conflicts === undefined ? section : { ...section, conflicts: result.conflicts }
|
|
176
178
|
}
|
|
177
179
|
|
|
178
180
|
async function emit_phase(args: ShipArguments, stage: Stage, phase: Phase): Promise<void> {
|
|
@@ -249,7 +251,14 @@ async function stopped(
|
|
|
249
251
|
stage: Stage,
|
|
250
252
|
): Promise<ReadonlyArray<ShipSection>> {
|
|
251
253
|
if (run_ship_detach.is_supervised()) {
|
|
252
|
-
|
|
254
|
+
const conflicts = sections.at(-1)?.conflicts
|
|
255
|
+
const resume = run_ship_next.resume_of(args)
|
|
256
|
+
|
|
257
|
+
await run_ship_return.return_control(
|
|
258
|
+
args.number,
|
|
259
|
+
stage,
|
|
260
|
+
conflicts === undefined ? resume : { ...resume, conflicts },
|
|
261
|
+
)
|
|
253
262
|
}
|
|
254
263
|
|
|
255
264
|
return sections
|
|
@@ -324,14 +333,26 @@ function should_detach(args: ShipArguments): boolean {
|
|
|
324
333
|
// joshuafolkken/kit#2966: the preflight is deterministic and fast, so it runs here, in the agent's own
|
|
325
334
|
// turn, before the hand-off — a stop it finds is fixed by the same session instead of relaunching one
|
|
326
335
|
// from the supervisor. Only the slow stages go to the supervisor, which re-asks the preflight itself.
|
|
336
|
+
// joshuafolkken/kit#3222: the type check and the document tests follow it here for the same reason; they
|
|
337
|
+
// are not a stage, so the supervisor's gate runs them again rather than passing over them.
|
|
338
|
+
async function pre_detach_sections(args: ShipArguments): Promise<ReadonlyArray<ShipSection>> {
|
|
339
|
+
const preflight = await run_stage(run_ship_steps.PREFLIGHT_STEP, args, await open_context(args))
|
|
340
|
+
if (preflight.code !== SUCCESS_EXIT_CODE) return [preflight]
|
|
341
|
+
|
|
342
|
+
const result = await run_ship_pre_detach.checks()
|
|
343
|
+
|
|
344
|
+
return [preflight, { header: run_ship.PRE_DETACH_HEADER, body: result.out, code: result.code }]
|
|
345
|
+
}
|
|
346
|
+
|
|
327
347
|
async function detach_after_preflight(args: ShipArguments): Promise<number> {
|
|
328
|
-
const
|
|
348
|
+
const sections = await pre_detach_sections(args)
|
|
349
|
+
const code = run_ship.exit_code(sections)
|
|
329
350
|
|
|
330
|
-
if (
|
|
351
|
+
if (code === SUCCESS_EXIT_CODE) return await detach(args)
|
|
331
352
|
|
|
332
|
-
console.info(run_ship.format_report(
|
|
353
|
+
console.info(run_ship.format_report(sections))
|
|
333
354
|
|
|
334
|
-
return
|
|
355
|
+
return code
|
|
335
356
|
}
|
|
336
357
|
|
|
337
358
|
async function supervised_repository(): Promise<string | undefined> {
|
|
@@ -42,6 +42,8 @@ interface ShipResume {
|
|
|
42
42
|
title: string
|
|
43
43
|
flags: ReadonlyArray<string>
|
|
44
44
|
is_review: boolean
|
|
45
|
+
// The paths a merge of the default branch left unmerged (joshuafolkken/kit#3221).
|
|
46
|
+
conflicts?: ReadonlyArray<string>
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
// Single quotes, so a `$` or a backtick in a title is never expanded by the shell that runs it.
|
|
@@ -90,14 +92,25 @@ const AFTER_FIX: Record<Stage, (resume: ShipResume) => string> = {
|
|
|
90
92
|
[STAGE.PREFLIGHT]: detach_as_started,
|
|
91
93
|
[STAGE.REVIEW]: detach_as_started,
|
|
92
94
|
[STAGE.GATE]: detach_as_started,
|
|
95
|
+
[STAGE.SYNC]: detach_as_started,
|
|
93
96
|
[STAGE.COMMIT]: detach_as_started,
|
|
94
97
|
[STAGE.ROUND_TWO]: detach_again,
|
|
95
98
|
[STAGE.FOLLOWUP]: detach_again,
|
|
96
99
|
[STAGE.REPORT]: detach_again,
|
|
97
100
|
}
|
|
98
101
|
|
|
102
|
+
// A conflict names its paths (joshuafolkken/kit#3221), so the resumed session resolves them without
|
|
103
|
+
// reading the report or asking git which files are unmerged.
|
|
104
|
+
function conflict_step(conflicts: ReadonlyArray<string>): string {
|
|
105
|
+
return `Resolve the conflict with origin/main in ${conflicts.join(', ')} (the merge is left in progress: remove the conflict markers in those files), then`
|
|
106
|
+
}
|
|
107
|
+
|
|
99
108
|
// Two lines' worth: read the stopped report, then the one command that resumes after the fix.
|
|
100
109
|
function next_step(issue: string, stage: Stage, resume: ShipResume): string {
|
|
110
|
+
const conflicts = resume.conflicts ?? []
|
|
111
|
+
|
|
112
|
+
if (conflicts.length > 0) return `${conflict_step(conflicts)} ${AFTER_FIX[stage](resume)}`
|
|
113
|
+
|
|
101
114
|
return `Read the stopped report with \`${COMMAND.LOG} ${issue}\`, fix the ${stage} failure, then ${AFTER_FIX[stage](resume)}`
|
|
102
115
|
}
|
|
103
116
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { type_check_step } from '#scripts/gate/type-check-step'
|
|
4
|
+
import type { JoshResult } from '#scripts/josh/josh-run'
|
|
5
|
+
import { buffered_process, type BufferedProcessResult } from '#scripts/lib/buffered-process'
|
|
6
|
+
|
|
7
|
+
// The fast checks a detached `josh ship` runs in the agent's own turn, after the preflight and before
|
|
8
|
+
// the hand-off (joshuafolkken/kit#3222). In one measured backlogrun about 7 of 31 lane resumes were a
|
|
9
|
+
// gate failure first seen after the detach — a type error in a file the related-only checks never
|
|
10
|
+
// touched, or a document test (a SKILL.md pointer, a byte budget, the command list) that only the full
|
|
11
|
+
// suite runs — each paid for with a relaunched session. These two answer in seconds, so the same
|
|
12
|
+
// session fixes what they find and ships again. **Nothing moves out of the gate**: the supervised gate
|
|
13
|
+
// stage still runs every check, these included.
|
|
14
|
+
//
|
|
15
|
+
// The test targets are the directories those resumes failed in. A consumer has none of them, so only
|
|
16
|
+
// the ones present run, and a consumer meets the type check alone.
|
|
17
|
+
|
|
18
|
+
const SUCCESS_EXIT_CODE = 0
|
|
19
|
+
const FAILURE_EXIT_CODE = 1
|
|
20
|
+
const SECTION_SEPARATOR = '\n\n'
|
|
21
|
+
const PASSED = 'type check and document tests passed'
|
|
22
|
+
const DOCUMENT_TEST_TARGETS: ReadonlyArray<string> = [
|
|
23
|
+
'scripts/document',
|
|
24
|
+
'scripts/claude',
|
|
25
|
+
'scripts/rules',
|
|
26
|
+
'scripts/josh/josh-command-map.test.ts',
|
|
27
|
+
]
|
|
28
|
+
// A present directory need not hold a test the project's Vitest includes — a consumer's own
|
|
29
|
+
// `scripts/claude` of shell helpers, say — and a filter that matches nothing would stop every ship.
|
|
30
|
+
const DOCUMENT_TEST_COMMAND: ReadonlyArray<string> = ['josh', 'test:unit', '--passWithNoTests']
|
|
31
|
+
|
|
32
|
+
function present_targets(directory: string): ReadonlyArray<string> {
|
|
33
|
+
return DOCUMENT_TEST_TARGETS.filter((target) => existsSync(path.join(directory, target)))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function command_lists(directory: string): Promise<Array<ReadonlyArray<string>>> {
|
|
37
|
+
const type_check = await type_check_step.resolve_type_check_args(directory)
|
|
38
|
+
const targets = present_targets(directory)
|
|
39
|
+
|
|
40
|
+
return targets.length === 0 ? [type_check] : [type_check, [...DOCUMENT_TEST_COMMAND, ...targets]]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function run_in(
|
|
44
|
+
directory: string,
|
|
45
|
+
list: ReadonlyArray<string>,
|
|
46
|
+
): Promise<BufferedProcessResult> {
|
|
47
|
+
return await buffered_process.run_buffered_process(list, { cwd: directory })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Both run at once, buffered, and every failure is reported, so one ship shows all there is to fix.
|
|
51
|
+
async function checks(directory: string = process.cwd()): Promise<JoshResult> {
|
|
52
|
+
const lists = await command_lists(directory)
|
|
53
|
+
const results = await Promise.all(lists.map(async (list) => await run_in(directory, list)))
|
|
54
|
+
const failed = results.filter((result) => buffered_process.is_process_failed(result))
|
|
55
|
+
|
|
56
|
+
if (failed.length === 0) return { code: SUCCESS_EXIT_CODE, out: PASSED }
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
code: FAILURE_EXIT_CODE,
|
|
60
|
+
out: failed.map((result) => result.output.trim()).join(SECTION_SEPARATOR),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const run_ship_pre_detach = { DOCUMENT_TEST_TARGETS, PASSED, checks }
|
|
65
|
+
|
|
66
|
+
export { run_ship_pre_detach }
|
|
@@ -6,7 +6,7 @@ import { lane_relaunch } from '#scripts/lane/lane-relaunch'
|
|
|
6
6
|
import { run_event_stream } from './run-event-stream'
|
|
7
7
|
import { run_event_stream_emit } from './run-event-stream-emit'
|
|
8
8
|
import type { ShipResume } from './run-ship-next'
|
|
9
|
-
import type
|
|
9
|
+
import { run_ship_stage, type Stage } from './run-ship-stage'
|
|
10
10
|
|
|
11
11
|
// How a detached `josh ship` supervisor hands a stopped stage back (joshuafolkken/kit#2428). The agent
|
|
12
12
|
// ended when it handed the region over, so a red gate, a High/Medium review, a failed push or red CI has
|
|
@@ -28,8 +28,8 @@ const OPENAI_PROVIDER = 'openai'
|
|
|
28
28
|
|
|
29
29
|
type ReturnOutcome = 'relaunched' | 'recorded'
|
|
30
30
|
|
|
31
|
-
function stop_text(issue: string, stage: Stage): string {
|
|
32
|
-
return `#${issue} ${stage} failed — pnpm josh ship --log ${issue}`
|
|
31
|
+
function stop_text(issue: string, stage: Stage, resume: ShipResume): string {
|
|
32
|
+
return `#${issue} ${run_ship_stage.stop_reason(stage, resume.conflicts)} failed — pnpm josh ship --log ${issue}`
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function can_relaunch(lane: LaneInfo | undefined): lane is LaneInfo {
|
|
@@ -53,7 +53,10 @@ async function return_control(
|
|
|
53
53
|
stage: Stage,
|
|
54
54
|
resume: ShipResume,
|
|
55
55
|
): Promise<ReturnOutcome> {
|
|
56
|
-
await run_event_stream_emit.emit(
|
|
56
|
+
await run_event_stream_emit.emit(
|
|
57
|
+
run_event_stream.EVENT_KIND.SHIP_STOP,
|
|
58
|
+
stop_text(issue, stage, resume),
|
|
59
|
+
)
|
|
57
60
|
|
|
58
61
|
const lane = await lane_to_relaunch(issue)
|
|
59
62
|
|
|
@@ -20,6 +20,7 @@ const STAGE = {
|
|
|
20
20
|
PREFLIGHT: 'preflight',
|
|
21
21
|
REVIEW: 'review',
|
|
22
22
|
GATE: 'gate',
|
|
23
|
+
SYNC: 'sync',
|
|
23
24
|
COMMIT: 'commit',
|
|
24
25
|
ROUND_TWO: 'round-2',
|
|
25
26
|
FOLLOWUP: 'followup',
|
|
@@ -99,6 +100,9 @@ const DONE_BY: Record<Stage, (done: ReadonlySet<string>, state: ShipState) => bo
|
|
|
99
100
|
// the recorded round, and only a commit pins the tree that round read.
|
|
100
101
|
[STAGE.REVIEW]: (_done, state) => state.is_merged || state.is_committed,
|
|
101
102
|
[STAGE.GATE]: (_done, state) => state.is_merged || state.is_committed,
|
|
103
|
+
// The merge of the default branch before the commit (joshuafolkken/kit#3221): once committed, a
|
|
104
|
+
// conflicting pull request is the followup's to merge again.
|
|
105
|
+
[STAGE.SYNC]: (_done, state) => state.is_merged || state.is_committed,
|
|
102
106
|
[STAGE.COMMIT]: (done, state) => state.is_merged || (done.has(STAGE.COMMIT) && is_shipped(state)),
|
|
103
107
|
// The round-2 pass (joshuafolkken/kit#2489) reads the pushed fix delta, so its record is honored only
|
|
104
108
|
// while that push still stands — new work pushed on top reruns it.
|
|
@@ -138,6 +142,14 @@ function event_text(issue: string, stage: Stage, phase: Phase): string {
|
|
|
138
142
|
return `#${issue} ${stage} ${phase}`
|
|
139
143
|
}
|
|
140
144
|
|
|
145
|
+
// A stop on a merge git could not finish is named `conflict` rather than by the stage it happened in
|
|
146
|
+
// (joshuafolkken/kit#3221), so the event stream reads the cause without anyone opening the report.
|
|
147
|
+
const CONFLICT = 'conflict'
|
|
148
|
+
|
|
149
|
+
function stop_reason(stage: Stage, conflicts: ReadonlyArray<string> = []): string {
|
|
150
|
+
return conflicts.length > 0 ? CONFLICT : stage
|
|
151
|
+
}
|
|
152
|
+
|
|
141
153
|
const run_ship_stage = {
|
|
142
154
|
PHASE,
|
|
143
155
|
STAGE,
|
|
@@ -148,6 +160,7 @@ const run_ship_stage = {
|
|
|
148
160
|
mark_done,
|
|
149
161
|
read_done,
|
|
150
162
|
record_path,
|
|
163
|
+
stop_reason,
|
|
151
164
|
}
|
|
152
165
|
|
|
153
166
|
export type { Phase, ShipState, Stage }
|
|
@@ -4,6 +4,7 @@ import { run_ship_preflight } from './run-ship-preflight'
|
|
|
4
4
|
import { run_ship_review_steps } from './run-ship-review-steps'
|
|
5
5
|
import { run_ship_scoped } from './run-ship-scoped'
|
|
6
6
|
import { run_ship_stage, type ShipState, type Stage } from './run-ship-stage'
|
|
7
|
+
import { run_ship_sync, type StepResult } from './run-ship-sync'
|
|
7
8
|
|
|
8
9
|
// The stages `josh ship` runs, in the order a change ships (joshuafolkken/kit#2398), kept apart from the
|
|
9
10
|
// chaining CLI so the order is read in one place.
|
|
@@ -24,7 +25,7 @@ interface ShipArguments {
|
|
|
24
25
|
interface Step {
|
|
25
26
|
stage: Stage
|
|
26
27
|
header: string
|
|
27
|
-
run: (args: ShipArguments, state: ShipState) => Promise<
|
|
28
|
+
run: (args: ShipArguments, state: ShipState) => Promise<StepResult>
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
const { STAGE } = run_ship_stage
|
|
@@ -56,6 +57,13 @@ const COMMIT_STEPS: ReadonlyArray<Step> = [
|
|
|
56
57
|
async () => await josh(['gate']),
|
|
57
58
|
]),
|
|
58
59
|
},
|
|
60
|
+
// joshuafolkken/kit#3221: the default branch merged again right before the commit, so the pull
|
|
61
|
+
// request opens on a branch that is current; a clean merge goes on without waking anyone.
|
|
62
|
+
{
|
|
63
|
+
stage: STAGE.SYNC,
|
|
64
|
+
header: run_ship.SYNC_HEADER,
|
|
65
|
+
run: async () => await run_ship_sync.sync_stage(),
|
|
66
|
+
},
|
|
59
67
|
{
|
|
60
68
|
stage: STAGE.COMMIT,
|
|
61
69
|
header: run_ship.COMMIT_HEADER,
|
|
@@ -69,7 +77,7 @@ const MERGE_STEPS: ReadonlyArray<Step> = [
|
|
|
69
77
|
{
|
|
70
78
|
stage: STAGE.FOLLOWUP,
|
|
71
79
|
header: run_ship.FOLLOWUP_HEADER,
|
|
72
|
-
run: async (args) => await
|
|
80
|
+
run: async (args) => await run_ship_sync.followup_stage(args.title, args.notify),
|
|
73
81
|
},
|
|
74
82
|
{
|
|
75
83
|
stage: STAGE.REPORT,
|