@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
|
@@ -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
|
|
108
|
-
// (joshuafolkken/kit#2928).
|
|
109
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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,
|
|
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,
|
|
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
|
-
|
|
64
|
+
scope: GateScope
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
function pool_of(result: EpicNextResult, repo: string,
|
|
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
|
-
|
|
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,
|
|
83
|
+
const { repo, scope } = pool
|
|
82
84
|
|
|
83
|
-
return backlog_rank.select({ candidates, pool: children, read: IDLE, repo,
|
|
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. `
|
|
110
|
-
// offer's cap bounds; left empty, no wave is capped.
|
|
111
|
-
function build(
|
|
112
|
-
result
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
|
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
|
|
33
|
-
//
|
|
34
|
-
//
|
|
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
|
-
|
|
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
|
|
55
|
-
// from
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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')
|
|
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
|
|
package/scripts/git/constants.ts
CHANGED
|
@@ -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,
|