@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.
Files changed (61) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-lanes.md +3 -3
  2. package/.claude/skills/workflow-commands/backlogrun-park.md +6 -7
  3. package/.claude/skills/workflow-commands/backlogrun-steps.md +3 -2
  4. package/.claude/skills/workflow-commands/pre-gate-cut.md +4 -4
  5. package/.github/workflows/ci.yml +15 -2
  6. package/dist/hooks/batch-guard.js +4 -4
  7. package/dist/hooks/{chunk-5KTCMCOT.js → chunk-32GQTL4F.js} +1 -1
  8. package/dist/hooks/{chunk-URJA6XY4.js → chunk-DO6NWWHU.js} +4 -4
  9. package/dist/hooks/{chunk-GU5MATVE.js → chunk-G6KRCKEC.js} +11 -19
  10. package/dist/hooks/{chunk-ZDZ555YH.js → chunk-GDS5N2KB.js} +88 -291
  11. package/dist/hooks/{chunk-GYNGP7HI.js → chunk-I4XSL4TT.js} +1 -1
  12. package/dist/hooks/{chunk-DYAVAM6S.js → chunk-S6X6WYKR.js} +324 -49
  13. package/dist/hooks/{chunk-ANJ2WGFD.js → chunk-UAGLLIGZ.js} +1 -1
  14. package/dist/hooks/{chunk-ENA7P5FB.js → chunk-VESKRQOF.js} +3 -3
  15. package/dist/hooks/{chunk-V2BTOZHT.js → chunk-VQLLNFNT.js} +3 -3
  16. package/dist/hooks/{chunk-A6P7PN7P.js → chunk-ZAL3NRGL.js} +2 -2
  17. package/dist/hooks/codex-hook-adapter.js +10 -10
  18. package/dist/hooks/duplicate-read-guard.js +5 -5
  19. package/dist/hooks/format-edited.js +2 -2
  20. package/dist/hooks/inputs.json +192 -192
  21. package/dist/hooks/investigation-guard.js +5 -5
  22. package/dist/hooks/pretool-guard.js +9 -9
  23. package/dist/hooks/stop-guard.js +18 -4
  24. package/dist/josh.js +4 -1
  25. package/package.json +1 -1
  26. package/scripts/backlog/backlog-next.ts +15 -4
  27. package/scripts/backlog/backlog-plan-cli.ts +6 -5
  28. package/scripts/backlog/backlog-rank.ts +18 -9
  29. package/scripts/backlog/backlog-restructure.ts +152 -0
  30. package/scripts/backlog/backlog-waves.ts +15 -17
  31. package/scripts/cost-runtime/context-cut-threshold.ts +1 -9
  32. package/scripts/cost-runtime/cost-verdict.ts +15 -28
  33. package/scripts/followup/git-followup-stages.ts +9 -0
  34. package/scripts/followup/git-pr-followup.ts +1 -1
  35. package/scripts/gh/git-gh-issue.ts +5 -3
  36. package/scripts/gh/git-gh-pr-read.ts +4 -0
  37. package/scripts/gh/git-pr-checks-eval.ts +8 -0
  38. package/scripts/gh/git-pr-checks.ts +11 -1
  39. package/scripts/git/constants.ts +10 -0
  40. package/scripts/git/main-merge.ts +57 -21
  41. package/scripts/git/schemas.ts +2 -0
  42. package/scripts/josh/josh-commands-development.ts +1 -1
  43. package/scripts/lane/lane-launch-cli.ts +21 -3
  44. package/scripts/notify/telegram-notify.ts +26 -1
  45. package/scripts/rules/pre-gate-cut.ts +2 -2
  46. package/scripts/run/run-carry.ts +11 -5
  47. package/scripts/run/run-cut-cli.ts +1 -1
  48. package/scripts/run/run-cut-report.ts +2 -2
  49. package/scripts/run/run-ship-cli.ts +27 -6
  50. package/scripts/run/run-ship-next.ts +13 -0
  51. package/scripts/run/run-ship-pre-detach.ts +66 -0
  52. package/scripts/run/run-ship-return.ts +7 -4
  53. package/scripts/run/run-ship-stage.ts +13 -0
  54. package/scripts/run/run-ship-steps.ts +10 -2
  55. package/scripts/run/run-ship-sync.ts +114 -0
  56. package/scripts/run/run-ship.ts +6 -0
  57. package/scripts/run/run-wake-cli.ts +8 -1
  58. package/scripts/run/run-wake-driver.ts +42 -11
  59. package/scripts/test/test-red-logic.ts +17 -2
  60. package/scripts/test/test-red.ts +8 -7
  61. 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
- // The refusal is read after the fetch, so the incoming paths are the ones the merge would bring in
48
- // (joshuafolkken/kit#2445).
49
- async function merge_refusal(default_branch: string): Promise<string | undefined> {
50
- const status = await git_command.status()
51
- const incoming = await main_merge_guard.incoming_paths(default_branch)
52
-
53
- return main_merge_guard.refusal(status, incoming, default_branch)
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 merge that stops on a conflict keeps git's report and adds how to finish it, so the next step is
57
- // the sanctioned commit rather than a request for `git add`.
58
- async function merge_with_hint(default_branch: string, message: string | undefined): Promise<void> {
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, message)
70
+ await git_command.merge_branch(default_branch, merge_message(default_branch, current_branch))
61
71
  } catch (error) {
62
- console.error(main_merge_guard.CONFLICT_HINT)
63
- throw error
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
- async function merge_default_branch(): Promise<number> {
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 refusal = await merge_refusal(default_branch)
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 (refusal !== undefined) {
78
- console.error(refusal)
113
+ if (failure !== undefined) {
114
+ console.error(failure)
79
115
 
80
116
  return FAILURE_EXIT_CODE
81
117
  }
82
118
 
83
- await merge_with_hint(default_branch, merge_message(default_branch, current_branch))
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 }
@@ -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 opened = await josh_command.josh_run(['lane:open', context.issue], should_forward_stderr)
110
+ const directory = await lane_directory(context.issue)
93
111
 
94
- if (opened.code !== SUCCESS_EXIT_CODE) return UNOPENED
112
+ if (directory === undefined) return UNOPENED
95
113
 
96
- if (!(await prepare(context.stash, opened.out))) return FAILED
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 build_text(input: TelegramSendInput): string {
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 recent-window context verdict, read lazily** (joshuafolkken/kit#2312). It is a thunk, like
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 recent-window context is worth its resume** (joshuafolkken/kit#2312).
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
@@ -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 \`${RESUME_COMMAND} "${carry.invocation}" ${OWNER_ARGUMENT}\`, or discard it with \`${END_COMMAND}\` and begin again.`
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 recent `RECENT_REQUEST_WINDOW`
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 recent-window context is under the shared cut threshold, so the pre-gate cut was not worth its
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 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.',
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 { header: step.header, body: result.out, code: result.code }
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
- await run_ship_return.return_control(args.number, stage, run_ship_next.resume_of(args))
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 section = await run_stage(run_ship_steps.PREFLIGHT_STEP, args, await open_context(args))
348
+ const sections = await pre_detach_sections(args)
349
+ const code = run_ship.exit_code(sections)
329
350
 
330
- if (section.code === SUCCESS_EXIT_CODE) return await detach(args)
351
+ if (code === SUCCESS_EXIT_CODE) return await detach(args)
331
352
 
332
- console.info(run_ship.format_report([section]))
353
+ console.info(run_ship.format_report(sections))
333
354
 
334
- return run_ship.exit_code([section])
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 { Stage } from './run-ship-stage'
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(run_event_stream.EVENT_KIND.SHIP_STOP, stop_text(issue, stage))
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<JoshResult>
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 josh(['followup', args.title, ...args.notify]),
80
+ run: async (args) => await run_ship_sync.followup_stage(args.title, args.notify),
73
81
  },
74
82
  {
75
83
  stage: STAGE.REPORT,