@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
@@ -10,6 +10,7 @@ import { run_invocation } from '#scripts/run/run-invocation'
10
10
  import { backlog_named } from './backlog-named'
11
11
  import { backlog_next, type OptedIn } from './backlog-next'
12
12
  import { backlog_plan, type NamedPlan, type PlanContext } from './backlog-plan'
13
+ import type { GateScope } from './backlog-rank'
13
14
  import { backlog_scope } from './backlog-scope'
14
15
  import { backlog_waves } from './backlog-waves'
15
16
 
@@ -104,9 +105,9 @@ interface Plan {
104
105
  // past the listing cap. Carrying the *whole* index here instead would move that misreport rather
105
106
  // than remove it: the sentence would name an epic that is withholding nothing.
106
107
  tracked: ReadonlyMap<number, number>
107
- // The rows the offer's cap bounds, so `--waves` cuts each wave where `backlog:next` does
108
- // (joshuafolkken/kit#2928).
109
- standalone: ReadonlySet<string>
108
+ // The rows the offer's cap bounds and the paths each issue restructures, so `--waves` cuts each
109
+ // wave where `backlog:next` does (joshuafolkken/kit#2928, joshuafolkken/kit#3221).
110
+ scope: GateScope
110
111
  }
111
112
 
112
113
  // The listing plus whether it was cut. Carried together because the rows alone cannot say whether
@@ -141,7 +142,7 @@ async function classify(
141
142
  repo: context.repo,
142
143
  exclude,
143
144
  tracked: epic_index.withheld_children(context.tracking.index, context.opted_in.issues),
144
- standalone: backlog_next.standalone_keys(context),
145
+ scope: backlog_next.gate_scope(context),
145
146
  }
146
147
  }
147
148
 
@@ -172,7 +173,7 @@ function print_plan(plan: Plan, listing: OpenListing, view: PlanView): void {
172
173
  const context = context_of(plan, listing)
173
174
 
174
175
  if (view.is_waves) {
175
- console.info(backlog_waves.format_waves(plan.result, context, plan.standalone))
176
+ console.info(backlog_waves.format_waves(plan.result, context, plan.scope))
176
177
 
177
178
  return
178
179
  }
@@ -4,6 +4,7 @@ import { epic_rank } from '#scripts/epic/epic-rank'
4
4
  import type { EpicNextResult } from '#scripts/epic/epic-report'
5
5
  import { epic_solo, type SoloGate, type SoloSelection } from '#scripts/epic/epic-solo'
6
6
  import { git_next_issues } from '#scripts/issue/git-next-issues'
7
+ import { backlog_restructure, type Declared } from './backlog-restructure'
7
8
 
8
9
  // What the backlog offers, in the order it is decided (joshuafolkken/kit#2928): rank, then the
9
10
  // `run:solo` gate, then the cap. `backlog:next` and `backlog:plan --waves` both call `select`, so the
@@ -78,29 +79,37 @@ interface OfferInput {
78
79
  repo: string
79
80
  // The keys of the standalone rows — the ones the cap bounds.
80
81
  standalone: ReadonlySet<string>
82
+ // The paths each issue restructures; two candidates claiming one are not offered together
83
+ // (joshuafolkken/kit#3221). Absent, nothing is separated.
84
+ declared?: Declared
81
85
  }
82
86
 
87
+ // The restructure separation runs before the cap, for the reason the gate does: the cap only ever
88
+ // removes what would otherwise be offered.
83
89
  function select(input: OfferInput): SoloSelection {
84
90
  const ranked = rank(input.candidates, input.pool)
91
+ const { read, repo } = input
92
+ const gated = epic_solo.select(ranked, read, repo)
93
+ const separated = backlog_restructure.separate(gated, input.declared ?? new Map(), { read, repo })
85
94
 
86
- return cap(epic_solo.select(ranked, input.read, input.repo), input.standalone)
95
+ return cap(separated, input.standalone)
96
+ }
97
+
98
+ interface GateScope {
99
+ standalone: ReadonlySet<string>
100
+ declared: Declared
87
101
  }
88
102
 
89
103
  // `backlog:next`'s gate: `epic_solo.gate` with this selection in place of its bare one.
90
- function gate(
91
- result: EpicNextResult,
92
- read: BusyRead,
93
- repo: string,
94
- standalone: ReadonlySet<string>,
95
- ): SoloGate {
104
+ function gate(result: EpicNextResult, read: BusyRead, repo: string, scope: GateScope): SoloGate {
96
105
  const pool = everything_in(result)
97
106
 
98
107
  return epic_solo.gate(result, read, repo, (candidates) =>
99
- select({ candidates, pool, read, repo, standalone }),
108
+ select({ candidates, pool, read, repo, ...scope }),
100
109
  )
101
110
  }
102
111
 
103
112
  const backlog_rank = { everything_in, gate, rank_result, select }
104
113
 
105
114
  export { backlog_rank }
106
- export type { OfferInput }
115
+ export type { GateScope, OfferInput }
@@ -0,0 +1,152 @@
1
+ import type { BusyRead } from '#scripts/epic/epic-busy'
2
+ import type { EpicChild } from '#scripts/epic/epic-graph'
3
+ import type { SoloSelection } from '#scripts/epic/epic-solo'
4
+
5
+ // Which candidates may not run side by side because both restructure the same file
6
+ // (joshuafolkken/kit#3221). The condition is deliberately narrow: plain overlap is not serialized,
7
+ // because every resume record of the 2026-10-05 backlog run was resolved mechanically by merging
8
+ // main. What git cannot resolve on its own is a rename or modify/delete conflict — two lanes that
9
+ // each move, split, rename or delete the same file — so only a path both bodies name on a line with
10
+ // such a verb holds the second one back. `remove` is left out: issue bodies use it for lines and
11
+ // options far more often than for files.
12
+ const RESTRUCTURE_WORDS: ReadonlySet<string> = new Set([
13
+ 'move',
14
+ 'moved',
15
+ 'moves',
16
+ 'moving',
17
+ 'rename',
18
+ 'renamed',
19
+ 'renames',
20
+ 'renaming',
21
+ 'split',
22
+ 'splits',
23
+ 'splitting',
24
+ 'delete',
25
+ 'deleted',
26
+ 'deletes',
27
+ 'deleting',
28
+ ])
29
+ const RESTRUCTURE_PHRASES: ReadonlyArray<string> = ['移動', '分割', '削除', 'リネーム', '改名']
30
+ const WORD = /[a-z]+/giu
31
+ const CODE_SPAN = /`([^`\s]+)`/gu
32
+ const LINE_SUFFIX = /:\d+(?::\d+)?$/u
33
+ const FILE_EXTENSION = /\.[a-z]\w*$/iu
34
+
35
+ // The restructured paths each issue declares, by issue number.
36
+ type Declared = ReadonlyMap<number, ReadonlySet<string>>
37
+
38
+ interface DeclaringIssue {
39
+ number: number
40
+ body?: string | undefined
41
+ }
42
+
43
+ function is_path(token: string): boolean {
44
+ return token.includes('/') || FILE_EXTENSION.test(token)
45
+ }
46
+
47
+ function paths_on(line: string): Array<string> {
48
+ return [...line.matchAll(CODE_SPAN)]
49
+ .map((match) => (match[1] ?? '').replace(LINE_SUFFIX, ''))
50
+ .filter((token) => is_path(token))
51
+ }
52
+
53
+ function is_restructuring(line: string): boolean {
54
+ const words = line.match(WORD) ?? []
55
+
56
+ if (words.some((word) => RESTRUCTURE_WORDS.has(word.toLowerCase()))) return true
57
+
58
+ return RESTRUCTURE_PHRASES.some((phrase) => line.includes(phrase))
59
+ }
60
+
61
+ // The backticked paths on the lines of `body` that carry a restructuring verb.
62
+ function restructured_paths(body: string): ReadonlySet<string> {
63
+ const lines = body.split('\n').filter((line) => is_restructuring(line))
64
+
65
+ return new Set(lines.flatMap((line) => paths_on(line)))
66
+ }
67
+
68
+ function declared_of(issues: ReadonlyArray<DeclaringIssue>): Declared {
69
+ return new Map(issues.map((issue) => [issue.number, restructured_paths(issue.body ?? '')]))
70
+ }
71
+
72
+ interface SeparateContext {
73
+ read: BusyRead
74
+ repo: string
75
+ }
76
+
77
+ // The path each running lane restructures, mapped to the issue that holds it.
78
+ function running_claims(declared: Declared, read: BusyRead): Map<string, number> {
79
+ const holders = read.kind === 'busy' ? read.issues.map((issue) => issue.number) : []
80
+ const pairs = holders.flatMap((number) =>
81
+ [...(declared.get(number) ?? [])].map((path): [string, number] => [path, number]),
82
+ )
83
+
84
+ return new Map(pairs)
85
+ }
86
+
87
+ function paths_of(child: EpicChild, declared: Declared, repo: string): ReadonlySet<string> {
88
+ if (child.repo !== repo) return new Set()
89
+
90
+ return declared.get(child.number) ?? new Set()
91
+ }
92
+
93
+ function clash_note(child: EpicChild, path: string, holder: number): string {
94
+ return `#${String(child.number)} waits: it and #${String(holder)} both restructure \`${path}\`.`
95
+ }
96
+
97
+ interface Walk {
98
+ claims: Map<string, number>
99
+ offered: Array<EpicChild>
100
+ withheld: Array<EpicChild>
101
+ notes: Array<string>
102
+ }
103
+
104
+ function visit(walk: Walk, child: EpicChild, paths: ReadonlySet<string>): void {
105
+ const clash = [...paths].find((path) => walk.claims.has(path))
106
+
107
+ if (clash === undefined) {
108
+ walk.offered.push(child)
109
+ for (const path of paths) walk.claims.set(path, child.number)
110
+
111
+ return
112
+ }
113
+
114
+ walk.withheld.push(child)
115
+ walk.notes.push(clash_note(child, clash, walk.claims.get(clash) ?? child.number))
116
+ }
117
+
118
+ function merged_notice(
119
+ notice: string | undefined,
120
+ notes: ReadonlyArray<string>,
121
+ ): string | undefined {
122
+ const parts = notice === undefined ? notes : [notice, ...notes]
123
+
124
+ return parts.length === 0 ? undefined : parts.join('\n')
125
+ }
126
+
127
+ // `selection` with every offered candidate that restructures a path a running lane or an earlier
128
+ // candidate already restructures moved to `withheld`, in offer order.
129
+ function separate(
130
+ selection: SoloSelection,
131
+ declared: Declared,
132
+ context: SeparateContext,
133
+ ): SoloSelection {
134
+ const walk: Walk = {
135
+ claims: running_claims(declared, context.read),
136
+ offered: [],
137
+ withheld: [],
138
+ notes: [],
139
+ }
140
+
141
+ for (const child of selection.offered) visit(walk, child, paths_of(child, declared, context.repo))
142
+
143
+ const notice = merged_notice(selection.notice, walk.notes)
144
+ const withheld = [...walk.withheld, ...selection.withheld]
145
+
146
+ return { offered: walk.offered, withheld, ...(notice !== undefined && { notice }) }
147
+ }
148
+
149
+ const backlog_restructure = { declared_of, restructured_paths, separate }
150
+
151
+ export { backlog_restructure }
152
+ export type { Declared }
@@ -10,7 +10,7 @@ import {
10
10
  NEEDS_DECISION_LABEL,
11
11
  } from '#scripts/issue/issue-labels'
12
12
  import { backlog_plan, type PlanContext } from './backlog-plan'
13
- import { backlog_rank } from './backlog-rank'
13
+ import { backlog_rank, type GateScope } from './backlog-rank'
14
14
 
15
15
  // The order a `backlogrun` takes, wave by wave, before it starts (joshuafolkken/kit#2778).
16
16
  //
@@ -18,7 +18,8 @@ import { backlog_rank } from './backlog-rank'
18
18
  // made from, so the order past the first answer was left for a person to work out by hand. This plays
19
19
  // the asks forward under one assumption: **every issue of a wave merges before the next wave starts**.
20
20
  //
21
- // **No rule is written twice.** The first wave is `backlog_rank.select` — rank, `run:solo` gate, cap —
21
+ // **No rule is written twice.** The first wave is `backlog_rank.select` — rank, `run:solo` gate,
22
+ // restructure separation, cap —
22
23
  // over the very candidates `backlog:next` resolves, asked of an idle repository — so it is what
23
24
  // `backlog:next` prints when nothing is running (joshuafolkken/kit#2928). Each later wave marks the earlier waves closed and re-classifies the same pool
24
25
  // through `epic_classify.classify_children` with the resolver `epic:next` sorted it with the first
@@ -28,6 +29,7 @@ import { backlog_rank } from './backlog-rank'
28
29
 
29
30
  const IDLE: BusyRead = { kind: 'idle' }
30
31
  const CLOSED = 'CLOSED'
32
+ const EMPTY_SCOPE: GateScope = { standalone: new Set(), declared: new Map() }
31
33
 
32
34
  const HEADING_ASSUMPTION =
33
35
  'Assumes every issue in a wave merges before the next wave starts. Issues a run already has are left out, and only issues of this repository are planned.'
@@ -59,17 +61,17 @@ interface WavePool {
59
61
  children: ReadonlyArray<EpicChild>
60
62
  running: ReadonlySet<string>
61
63
  repo: string
62
- standalone: ReadonlySet<string>
64
+ scope: GateScope
63
65
  }
64
66
 
65
- function pool_of(result: EpicNextResult, repo: string, standalone: ReadonlySet<string>): WavePool {
67
+ function pool_of(result: EpicNextResult, repo: string, scope: GateScope): WavePool {
66
68
  const all = backlog_rank.everything_in(result)
67
69
 
68
70
  return {
69
71
  children: all.filter((child) => !is_running(child)),
70
72
  running: epic_outside_blocker.running_keys(all),
71
73
  repo,
72
- standalone,
74
+ scope,
73
75
  }
74
76
  }
75
77
 
@@ -78,9 +80,9 @@ function offer(
78
80
  children: ReadonlyArray<EpicChild>,
79
81
  pool: WavePool,
80
82
  ): ReadonlyArray<EpicChild> {
81
- const { repo, standalone } = pool
83
+ const { repo, scope } = pool
82
84
 
83
- return backlog_rank.select({ candidates, pool: children, read: IDLE, repo, standalone }).offered
85
+ return backlog_rank.select({ candidates, pool: children, read: IDLE, repo, ...scope }).offered
84
86
  }
85
87
 
86
88
  // Ranked against the whole result, exactly as `backlog:next` ranks it, so the two agree.
@@ -106,14 +108,10 @@ function next_wave(pool: WavePool, done: ReadonlySet<string>): ReadonlyArray<Epi
106
108
  }
107
109
 
108
110
  // Each wave is closed before the next is asked for; a wave that offers nothing ends the plan, which
109
- // the pool's size bounds because every wave closes at least one child. `standalone` names the rows the
110
- // offer's cap bounds; left empty, no wave is capped.
111
- function build(
112
- result: EpicNextResult,
113
- repo: string,
114
- standalone: ReadonlySet<string> = new Set(),
115
- ): WavePlan {
116
- const pool = pool_of(result, repo, standalone)
111
+ // the pool's size bounds because every wave closes at least one child. `scope` names the rows the
112
+ // offer's cap bounds and the paths each issue restructures; left empty, no wave is capped or separated.
113
+ function build(result: EpicNextResult, repo: string, scope: GateScope = EMPTY_SCOPE): WavePlan {
114
+ const pool = pool_of(result, repo, scope)
117
115
  const done = new Set<string>()
118
116
  const waves: Array<ReadonlyArray<EpicChild>> = []
119
117
 
@@ -155,11 +153,11 @@ function unreached_lines(plan: WavePlan, context: PlanContext): Array<string> {
155
153
  function format_waves(
156
154
  result: EpicNextResult,
157
155
  context: PlanContext,
158
- standalone: ReadonlySet<string> = new Set(),
156
+ scope: GateScope = EMPTY_SCOPE,
159
157
  ): string {
160
158
  if (result.verdict === 'error') return backlog_plan.format_unusable(result)
161
159
 
162
- const plan = build(result, context.repo, standalone)
160
+ const plan = build(result, context.repo, scope)
163
161
  const waves = plan.waves.map((wave, index) => wave_line(wave, index, context.repo))
164
162
 
165
163
  return [
@@ -10,12 +10,4 @@ import { context_cut_payback } from './context-cut-payback'
10
10
  // from the cache-price multipliers and the horizon rather than restated, so it moves with the model.
11
11
  const CONTEXT_CUT_THRESHOLD = context_cut_payback.break_even_context()
12
12
 
13
- // The hand-off prices the average billed input of the most recent requests, not the whole session
14
- // (joshuafolkken/kit#2295). A long parent session's whole-session average lags its current context by
15
- // dozens of requests — measured at 85 requests (~4 hours) behind on the 2026-09-21 parent — so it
16
- // keeps supervising an already-oversized context long after a cut was due. Ten requests is enough to
17
- // smooth a single outlier without reaching back into the cheap early context; it sits beside the
18
- // threshold so the window and the limit are read from one place and cannot drift.
19
- const RECENT_REQUEST_WINDOW = 10
20
-
21
- export { CONTEXT_CUT_THRESHOLD, RECENT_REQUEST_WINDOW }
13
+ export { CONTEXT_CUT_THRESHOLD }
@@ -3,42 +3,31 @@
3
3
  // of a session will cost, and takes only the figures its verdict needs — an over measurement — never
4
4
  // a whole report. The `--cap` counterfactual it once sat beside was retired in #2016 as readerless.
5
5
 
6
- import { RECENT_REQUEST_WINDOW } from './context-cut-threshold'
7
-
8
6
  const FAILURE_EXIT_CODE = 1
9
7
  const OVER_VERDICT = 'over'
10
8
  const UNDER_VERDICT = 'under'
11
9
 
12
- // What `--over` reads of a session: the billed input each request paid, oldest first. The whole
13
- // per-request sequence is carried rather than a single sum because the hand-off prices the recent
14
- // tail alone (joshuafolkken/kit#2295) — a long session's whole-session average lags its current
15
- // context by dozens of requests, so a sum could not answer what the recent window actually cost.
10
+ // What `--over` reads of a session: the billed input each request paid, oldest first. The sequence
11
+ // is carried rather than a single sum because the hand-off prices the newest request alone
12
+ // (joshuafolkken/kit#3224) — a sum could not answer what the session's current context costs.
16
13
  interface OverMeasurement {
17
14
  billed_input_per_request: ReadonlyArray<number>
18
15
  }
19
16
 
20
- // The most recent requests the hand-off prices, oldest first — the whole sequence when it is shorter
21
- // than the window, so a session with fewer than `RECENT_REQUEST_WINDOW` requests is priced on what it
22
- // has rather than padded.
23
- function recent_requests(measurement: OverMeasurement): ReadonlyArray<number> {
24
- return measurement.billed_input_per_request.slice(-RECENT_REQUEST_WINDOW)
25
- }
26
-
27
17
  // What a turn costs is decided by the accumulated preamble, not by what the turn does, so the
28
- // marginal cost of a session is the billed input its recent requests paid. Measured across one
18
+ // marginal cost of a session is the billed input its newest request paid. Measured across one
29
19
  // `epicrun` that ran six children in one context: 222k per request during the first child, 645k
30
20
  // during the sixth — the same work at 2.9x the price (joshuafolkken/kit#968).
31
21
  //
32
- // The recent window rather than the whole session, because a long parent's whole-session average
33
- // lags its current context by dozens of requests (joshuafolkken/kit#2295); the recent average says
34
- // what the *next* turn will cost, which is the thing a hand-off decision turns on.
22
+ // **The newest request, not an average.** joshuafolkken/kit#2295 replaced the whole-session average,
23
+ // which lagged a long parent by dozens of requests, with the average of the last ten; that still
24
+ // lagged. A session's billed input only grows between compactions (a unit writes its own transcript),
25
+ // so an average smooths no outlier — it only trails the context it averages. On 2026-10-05 wake
26
+ // session 03367124 crossed the threshold at request 65 and its ten-request average at request 70
27
+ // (joshuafolkken/kit#3224). The threshold is a break-even on the *current* context
28
+ // (`context-cut-payback.ts`), so the current context is what it is compared against.
35
29
  function per_request_cost(measurement: OverMeasurement): number {
36
- const window = recent_requests(measurement)
37
- if (window.length === 0) return 0
38
-
39
- const total = window.reduce((sum, billed) => sum + billed, 0)
40
-
41
- return Math.round(total / window.length)
30
+ return measurement.billed_input_per_request.at(-1) ?? 0
42
31
  }
43
32
 
44
33
  // The bare over/under decision, without a report: `run:status` reads this same verdict read-only
@@ -51,14 +40,12 @@ function classify(
51
40
  return per_request_cost(measurement) > limit ? OVER_VERDICT : UNDER_VERDICT
52
41
  }
53
42
 
54
- // The stderr line names the window the average covers, so the reader can tell a recent-tail average
55
- // from a whole-session one (joshuafolkken/kit#2295): "the last W of T request(s)" reads W when the
56
- // session is longer than the window and the whole count when it is shorter.
43
+ // The stderr line names which request was priced, so the reader can tell the newest request's cost
44
+ // from an average over the session (joshuafolkken/kit#3224).
57
45
  function over_summary(measurement: OverMeasurement, limit: number): string {
58
46
  const total = measurement.billed_input_per_request.length
59
- const window = recent_requests(measurement).length
60
47
 
61
- return `${String(per_request_cost(measurement))} billed input tokens per request over the last ${String(window)} of ${String(total)} request(s); limit ${String(limit)}`
48
+ return `${String(per_request_cost(measurement))} billed input tokens per request, on the newest of ${String(total)} request(s); limit ${String(limit)}`
62
49
  }
63
50
 
64
51
  // A verdict, not a table: the point of the flag is that the hand-off is decided by a number rather
@@ -1,3 +1,4 @@
1
+ import { PrConflictError } from '#scripts/gh/git-pr-checks-eval'
1
2
  import { time_format } from '#scripts/time-runtime/time-format'
2
3
 
3
4
  // Where `pnpm josh followup` spends its own wall clock (joshuafolkken/kit#1349).
@@ -73,8 +74,15 @@ const STAGE = {
73
74
  // blocker and on a red check, and those are the runs whose wait is longest — reporting nothing for
74
75
  // them would leave the measurement blind to exactly the invocations worth measuring.
75
76
  interrupted: 'interrupted',
77
+ // The interrupted lap when the wait failed on a merge conflict (joshuafolkken/kit#3221), so the
78
+ // stage block names the cause a resumed session used to look up with `gh pr view`.
79
+ conflict: 'conflict',
76
80
  } as const
77
81
 
82
+ function stopped_lap(error: unknown): string {
83
+ return error instanceof PrConflictError ? STAGE.conflict : STAGE.interrupted
84
+ }
85
+
78
86
  interface FollowupStage {
79
87
  name: string
80
88
  duration_ms: number
@@ -143,6 +151,7 @@ const git_followup_stages = {
143
151
  total_ms,
144
152
  format_stages,
145
153
  print_stages,
154
+ stopped_lap,
146
155
  }
147
156
 
148
157
  export type { FollowupStage, StageLog }
@@ -437,7 +437,7 @@ async function run(input: FollowupInput): Promise<string | undefined> {
437
437
  try {
438
438
  return await run_stages(input, log)
439
439
  } catch (error) {
440
- lap(log, STAGE.interrupted)
440
+ lap(log, git_followup_stages.stopped_lap(error))
441
441
 
442
442
  throw error
443
443
  } finally {
@@ -26,8 +26,10 @@ const SUMMARY_FIELDS = 'number,title,labels,createdAt'
26
26
  // `SUMMARY_FIELDS` on purpose: a listing carries no `blockedBy`, so the field is answered from each
27
27
  // row's own dependencies endpoint, and asking for it on the shared list would put that request
28
28
  // behind the next-issues display too — where a refused one becomes a failure with no message at all,
29
- // since the listing swallows the error (joshuafolkken/kit#996, joshuafolkken/kit#1025).
30
- const PICKUP_FIELDS = `${SUMMARY_FIELDS},blockedBy`
29
+ // since the listing swallows the error (joshuafolkken/kit#996, joshuafolkken/kit#1025). `body` costs
30
+ // no request — the REST listing row already carries it — and is what the backlog offer reads for
31
+ // the restructures each issue declares (joshuafolkken/kit#3221).
32
+ const PICKUP_FIELDS = `${SUMMARY_FIELDS},blockedBy,body`
31
33
 
32
34
  // Every listing below goes through the one REST invocation in `git-gh-issue-list.ts`. It kept the
33
35
  // `json_fields` / `limit` contract it had as a `gh issue list` wrapper, so these callers changed
@@ -111,7 +113,7 @@ async function issue_list_by_label(label: string, limit: number): Promise<IssueL
111
113
  }
112
114
 
113
115
  // The same filter with the fields the `auto-ok` pickup needs (joshuafolkken/kit#906): it orders by
114
- // `createdAt` and re-checks `labels` client-side, so bodies would be fetched and thrown away. A
116
+ // `createdAt`, re-checks `labels` client-side and reads each body for its declared restructures. A
115
117
  // label that does not exist in the repository is not an error — the listing comes back empty, which
116
118
  // is exactly "nobody has opted anything in".
117
119
  // `json_fields` overrides the pickup's list. Its only caller is the probe that runs *after* a failed
@@ -2,6 +2,7 @@ import { FULL_PAGE_QUERY, git_gh_api_path } from './git-gh-api-path'
2
2
  import { git_gh_exec } from './git-gh-exec'
3
3
  import { git_gh_helpers } from './git-gh-helpers'
4
4
  import { git_gh_pr_rest, type RestPull } from './git-gh-pr-rest'
5
+ import { to_gh_state } from './git-gh-rest-state'
5
6
 
6
7
  // Reading pull requests through REST, in the answers `gh pr view` used to give.
7
8
  //
@@ -81,6 +82,8 @@ interface PullMergeState {
81
82
  is_merged: boolean
82
83
  merged_at: string | undefined
83
84
  head_sha: string | undefined
85
+ // `josh ship` reads `DIRTY` here to re-merge a conflicting pull request (joshuafolkken/kit#3221).
86
+ merge_state_status: string | undefined
84
87
  }
85
88
 
86
89
  type PullNumberRead =
@@ -234,6 +237,7 @@ async function pr_get_merge_state(branch_name: string): Promise<PullMergeState |
234
237
  is_merged: git_gh_pr_rest.is_merged(pull),
235
238
  merged_at: pull.merged_at ?? undefined,
236
239
  head_sha: pull.head?.sha,
240
+ merge_state_status: to_gh_state(pull.mergeable_state),
237
241
  }
238
242
  }
239
243
 
@@ -240,7 +240,14 @@ function is_auto_merge_blocked(snapshot: PrStateSnapshot): boolean {
240
240
  )
241
241
  }
242
242
 
243
+ // A failed wait whose cause is a merge conflict (joshuafolkken/kit#3221), so `followup` names its last
244
+ // lap `conflict` rather than `interrupted` and the cause is read from the stage block, not looked up.
245
+ class PrConflictError extends Error {
246
+ override name = 'PrConflictError'
247
+ }
248
+
243
249
  export {
250
+ PrConflictError,
244
251
  evaluate_pr_state,
245
252
  is_auto_merge_blocked,
246
253
  is_review_decision_decisive,
@@ -251,5 +258,6 @@ export {
251
258
  REQUIRED_CHECKS,
252
259
  CODERABBIT_CHECK_NAME,
253
260
  is_required_check,
261
+ is_merge_conflict,
254
262
  }
255
263
  export type { PrEvaluation }
@@ -6,7 +6,9 @@ import {
6
6
  describe_pr_failure,
7
7
  evaluate_pr_state,
8
8
  is_auto_merge_blocked,
9
+ is_merge_conflict,
9
10
  is_review_decision_decisive,
11
+ PrConflictError,
10
12
  type PrEvaluation,
11
13
  } from './git-pr-checks-eval'
12
14
  import { parse_pr_state_snapshot, read_string, type PrStateSnapshot } from './git-pr-checks-parse'
@@ -142,6 +144,12 @@ function advance_stable_count(previous: number, state: PrEvaluation): number {
142
144
  return state === 'success' ? previous + 1 : 0
143
145
  }
144
146
 
147
+ function failure_error(message: string, snapshot: PrStateSnapshot): Error {
148
+ return is_merge_conflict(snapshot.merge_state_status)
149
+ ? new PrConflictError(message)
150
+ : new Error(message)
151
+ }
152
+
145
153
  function classify_poll_result(input: {
146
154
  snapshot: PrStateSnapshot
147
155
  stable_count: number
@@ -150,7 +158,9 @@ function classify_poll_result(input: {
150
158
  }): { is_done: boolean; next_stable_count: number } {
151
159
  const state = input.evaluator.evaluate(input.snapshot)
152
160
 
153
- if (state === 'failure') throw new Error(input.evaluator.describe(input.snapshot))
161
+ if (state === 'failure') {
162
+ throw failure_error(input.evaluator.describe(input.snapshot), input.snapshot)
163
+ }
154
164
 
155
165
  const next_stable_count = advance_stable_count(input.stable_count, state)
156
166
 
@@ -12,6 +12,11 @@ const PORCELAIN_FLAG = '--porcelain'
12
12
  // The untracked-files mode every porcelain status reading passes (`git-command.ts` carries why it is
13
13
  // passed and why it is `all`), shared so another module's own reading cannot drift from it.
14
14
  const UNTRACKED_FILES_FLAG = '--untracked-files=all'
15
+ // The git binary a spawn runs in place of the platform's own (joshuafolkken/kit#3234). The spawn path
16
+ // is absolute, so a `git` placed in front on `PATH` never stands in for it: the unit suite's network
17
+ // guard shimmed `git` that way, and `josh ship`'s tests fetched and merged the default branch into the
18
+ // checkout they ran in without the guard ever seeing the call. The guard names its shim here instead.
19
+ const GIT_BINARY_KEY = 'JOSH_GIT_BINARY'
15
20
 
16
21
  function get_git_command(): string {
17
22
  if (platform() === 'win32') {
@@ -22,6 +27,10 @@ function get_git_command(): string {
22
27
  }
23
28
 
24
29
  function get_git_command_for_spawn(): string {
30
+ const binary = process.env[GIT_BINARY_KEY]
31
+
32
+ if (binary !== undefined && binary !== '') return binary
33
+
25
34
  if (platform() === 'win32') {
26
35
  return String.raw`C:\Program Files\Git\cmd\git.exe`
27
36
  }
@@ -35,6 +44,7 @@ const git_utilities = {
35
44
  }
36
45
 
37
46
  export {
47
+ GIT_BINARY_KEY,
38
48
  REQUIRED_STATUS_LENGTH,
39
49
  STAGED_STATUS_INDEX,
40
50
  UNTRACKED_FILE_PREFIX,