@joshuafolkken/kit 1.1035.0 → 1.1050.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/verify-ui/SKILL.md +1 -1
- package/.claude/skills/workflow-commands/SKILL.md +2 -2
- package/.claude/skills/workflow-commands/background-commands.md +1 -1
- package/.claude/skills/workflow-commands/backlogrun-child.md +2 -2
- package/.claude/skills/workflow-commands/backlogrun-lanes.md +1 -1
- package/.claude/skills/workflow-commands/backlogrun-park.md +6 -0
- package/.claude/skills/workflow-commands/backlogrun-progress.md +30 -36
- package/.claude/skills/workflow-commands/backlogrun-steps.md +16 -16
- package/.claude/skills/workflow-commands/chain-rule.md +2 -2
- package/.claude/skills/workflow-commands/followup-reference.md +2 -2
- package/.claude/skills/workflow-commands/issue-comments.md +1 -1
- package/.claude/skills/workflow-commands/observation-filing.md +41 -86
- package/.claude/skills/workflow-commands/pre-gate-cut.md +33 -36
- package/.claude/skills/workflow-commands/working-tree-hold.md +2 -2
- package/.github/workflows/auto-tag.yml +21 -0
- package/.github/workflows/publish.yml +9 -10
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-HU66PWWL.js → chunk-5BK3K7V4.js} +89 -41
- package/dist/hooks/{chunk-GLBDBJOK.js → chunk-7VPDUBRN.js} +49 -151
- package/dist/hooks/{chunk-GJT4BNVE.js → chunk-DMQN45FN.js} +3 -3
- package/dist/hooks/{chunk-D67KVGGY.js → chunk-EJDX2TOB.js} +13 -3
- package/dist/hooks/{chunk-KG6PZD72.js → chunk-GQ7KP45M.js} +2 -2
- package/dist/hooks/{chunk-Y27M6A7X.js → chunk-HJIISEAC.js} +4 -4
- package/dist/hooks/{chunk-XPLP27WA.js → chunk-NAS4C7GP.js} +4 -26
- package/dist/hooks/{chunk-ZXNE6NY3.js → chunk-NJQOBE6L.js} +2 -2
- package/dist/hooks/{chunk-EIFCAJRB.js → chunk-RLDX5SOC.js} +1184 -946
- package/dist/hooks/{chunk-HOYJU5RM.js → chunk-WQ4OSBSS.js} +10 -3
- package/dist/hooks/codex-hook-adapter.js +10 -10
- package/dist/hooks/duplicate-read-guard.js +5 -5
- package/dist/hooks/format-edited.js +15 -34
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +23 -16
- package/dist/hooks/stop-guard.js +13 -188
- package/dist/josh.js +4 -106
- package/dist/version/index.js +381 -9
- package/package.json +6 -6
- package/prompts/collaboration-workflow/file-edits.md +6 -53
- package/prompts/collaboration-workflow/residency.md +1 -1
- package/prompts/collaboration-workflow/rule-delivery.md +4 -35
- package/prompts/collaboration-workflow/shell-body.md +7 -9
- package/prompts/collaboration-workflow/turn-batching.md +21 -37
- package/prompts/collaboration-workflow/wip-cap.md +15 -18
- package/prompts/sonar-hotspot-handling.md +1 -1
- package/scripts/adopt/adopt-toolkits.ts +2 -0
- package/scripts/auto-ok/auto-ok-cli.ts +9 -9
- package/scripts/backlog/backlog-next.ts +24 -8
- package/scripts/backlog/backlog-plan-cli.ts +5 -1
- package/scripts/backlog/backlog-pool.ts +31 -11
- package/scripts/backlog/backlog-rank.ts +106 -0
- package/scripts/backlog/backlog-waves.ts +42 -29
- package/scripts/build/build-hooks.ts +7 -2
- package/scripts/delegation/delegation-policy.ts +11 -0
- package/scripts/document/document-byte-budget.ts +5 -5
- package/scripts/document/entry-read-budget.ts +5 -4
- package/scripts/epic/epic-lane-offer.ts +11 -22
- package/scripts/epic/epic-rank.ts +37 -0
- package/scripts/epic/epic-solo.ts +29 -28
- package/scripts/git/git-command.ts +1 -1
- package/scripts/git/git-execa-error.ts +6 -1
- package/scripts/git/git-next-issues.ts +34 -5
- package/scripts/git/git-preflight.ts +22 -4
- package/scripts/git/git-push-transport.ts +12 -69
- package/scripts/git/git-spawn.ts +79 -3
- package/scripts/git/git-ssh-keepalive.ts +74 -0
- package/scripts/git/gone-branch.ts +3 -2
- package/scripts/git/issue-labels.ts +12 -0
- package/scripts/git/issue-rank.ts +89 -0
- package/scripts/hooks/format-edited-cli.ts +15 -0
- package/scripts/hooks/format-edited-file.ts +2 -9
- package/scripts/hooks/pretool-guard-cli.ts +21 -0
- package/scripts/hooks/pretool-guard.ts +14 -15
- package/scripts/init/start-exec.ts +7 -1
- package/scripts/init/start-plan.ts +18 -5
- package/scripts/init/start-setup-pr.ts +46 -19
- package/scripts/init/start-steps.ts +41 -9
- package/scripts/init/start.ts +40 -10
- package/scripts/issue/issue-cite.ts +19 -1
- package/scripts/josh/josh-command-map.ts +0 -102
- package/scripts/josh/josh-commands-development.ts +2 -2
- package/scripts/josh/josh-commands-project.ts +1 -1
- package/scripts/lane/lane-child-marker.ts +16 -2
- package/scripts/lane/lane-cli.ts +32 -14
- package/scripts/lane/lane-report.ts +10 -4
- package/scripts/propagate/propagate-plan.ts +185 -0
- package/scripts/propagate/propagate-run.ts +5 -1
- package/scripts/propagate/propagate-steps.ts +16 -1
- package/scripts/propagate/propagate-targets.ts +13 -2
- package/scripts/propagate/propagate.ts +60 -42
- package/scripts/registry-migration/migrate-logic.ts +1 -0
- package/scripts/release/github-release.ts +1 -1
- package/scripts/review/review-attest.ts +44 -0
- package/scripts/review/review-brief-cli.ts +49 -2
- package/scripts/review/review-brief.ts +46 -1
- package/scripts/review/review-stamps.ts +7 -0
- package/scripts/rules/implementation-cut.ts +1 -1
- package/scripts/rules/issue-fold.ts +1 -2
- package/scripts/rules/lane-carry-conflict.ts +1 -2
- package/scripts/rules/lane-park.ts +1 -2
- package/scripts/rules/pre-gate-cut.ts +2 -2
- package/scripts/run/run-headless.ts +22 -6
- package/scripts/run/run-parent-cut-hook.ts +144 -0
- package/scripts/run/run-progress-read.ts +2 -1
- package/scripts/run/run-progress.ts +21 -8
- package/scripts/run/run-ship-cli.ts +6 -74
- package/scripts/run/run-ship-preflight.ts +87 -0
- package/scripts/run/run-ship-review-steps.ts +5 -26
- package/scripts/run/run-ship-review.ts +9 -0
- package/scripts/run/run-ship-scoped.ts +44 -0
- package/scripts/run/run-ship-stage.ts +5 -1
- package/scripts/run/run-ship-steps.ts +105 -0
- package/scripts/run/run-ship.ts +2 -0
- package/scripts/run/run-wake-cli.ts +2 -2
- package/scripts/run/run-wake-describe.ts +19 -1
- package/scripts/run/run-watcher-hook.ts +1 -1
- package/scripts/time-runtime/time-bundle-call.ts +2 -2
- package/scripts/version/npm-registry.ts +97 -0
- package/scripts/version/version-commands.ts +5 -4
- package/scripts/version/version-remote.ts +26 -12
|
@@ -6,8 +6,13 @@ import { scoped_green } from '#scripts/gate/scoped-green'
|
|
|
6
6
|
import { change_base } from '#scripts/git/change-base'
|
|
7
7
|
import { changed_paths } from '#scripts/git/changed-paths'
|
|
8
8
|
import { git_command } from '#scripts/git/git-command'
|
|
9
|
+
import { PROJECT_ROOT } from '#scripts/init/init-paths'
|
|
10
|
+
import type { FileMapStamp } from '#scripts/josh/file-map-stamp'
|
|
9
11
|
import { find_package_directory } from '#scripts/josh/josh-logic'
|
|
10
12
|
import { path_decision } from '#scripts/josh/path-decision'
|
|
13
|
+
import { stamp_file } from '#scripts/josh/stamp-file'
|
|
14
|
+
import { openai_review_broker } from '#scripts/lane/openai-review-broker'
|
|
15
|
+
import { run_ship_review } from '#scripts/run/run-ship-review'
|
|
11
16
|
import { review_attest } from './review-attest'
|
|
12
17
|
import { review_brief } from './review-brief'
|
|
13
18
|
import { review_checkout, type ReviewCheckout } from './review-checkout'
|
|
@@ -134,13 +139,54 @@ function record_round_one(
|
|
|
134
139
|
// failed to record would hand the review a command that cannot succeed and the run a check that
|
|
135
140
|
// answers `not-required` — the guard gone, silently, which is the shape this whole record exists to
|
|
136
141
|
// remove.
|
|
137
|
-
|
|
142
|
+
//
|
|
143
|
+
// **Whether this is a resume is read first** (joshuafolkken/kit#2945): `record_target` drops the
|
|
144
|
+
// previous nonce's records, so asked afterwards the finished review would always read as absent. The
|
|
145
|
+
// findings file is the ship's, keyed on `PROJECT_ROOT` as the ship keys it; a review run outside the
|
|
146
|
+
// ship writes none, so it never reads as a resume and keeps the whole change.
|
|
147
|
+
//
|
|
148
|
+
// **The narrowing is measured from the tree that review was briefed on**, and only when that record
|
|
149
|
+
// was written by the brief the pointer names: one left by an earlier brief is not the tree the
|
|
150
|
+
// finished review read, so the resume reads the whole change instead.
|
|
151
|
+
function attested_tree(checkout: ReviewCheckout): FileMapStamp | undefined {
|
|
152
|
+
const { findings } = openai_review_broker.review_paths(PROJECT_ROOT)
|
|
153
|
+
const briefed = review_stamps.briefed_stamp.read()
|
|
154
|
+
|
|
155
|
+
if (briefed === undefined || !review_attest.is_briefed_since(briefed.taken_at, checkout.root)) {
|
|
156
|
+
return undefined
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const is_clean =
|
|
160
|
+
review_attest.is_reviewed_on(checkout, findings, checkout.root) &&
|
|
161
|
+
run_ship_review.is_clean_round_one(stamp_file.read_stamp_text(findings))
|
|
162
|
+
|
|
163
|
+
return is_clean ? briefed : undefined
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function open_contract(): Promise<{
|
|
167
|
+
checkout: ReviewCheckout
|
|
168
|
+
nonce: string
|
|
169
|
+
resumed_from: FileMapStamp | undefined
|
|
170
|
+
}> {
|
|
138
171
|
const checkout = await review_checkout.read_checkout()
|
|
172
|
+
const resumed_from = attested_tree(checkout)
|
|
139
173
|
|
|
140
174
|
// Keyed on the root git just answered with, never on `process.cwd()`: the check made before the
|
|
141
175
|
// merge asks git the same question, and the two hash different keys the moment one of them runs
|
|
142
176
|
// from a subdirectory — a mismatch that would drop the guard with nothing printed.
|
|
143
|
-
return { checkout, nonce: review_attest.record_target(checkout, checkout.root) }
|
|
177
|
+
return { checkout, nonce: review_attest.record_target(checkout, checkout.root), resumed_from }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Retaken on every round-1 brief, after the pointer it pairs with (joshuafolkken/kit#2945). Swallowed:
|
|
181
|
+
// a record that failed to write predates the next pointer, so the resume widens rather than narrows.
|
|
182
|
+
function record_briefed(round: number, tree: Record<string, string>, base?: string): void {
|
|
183
|
+
if (round !== FIRST_ROUND) return
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
review_stamps.briefed_stamp.write(tree, undefined, base)
|
|
187
|
+
} catch {
|
|
188
|
+
/* no record widens the resume rather than narrowing it */
|
|
189
|
+
}
|
|
144
190
|
}
|
|
145
191
|
|
|
146
192
|
interface ComposeRequest {
|
|
@@ -252,6 +298,7 @@ async function run_review(round: number, reading: ChangeReading): Promise<number
|
|
|
252
298
|
if (resolved.kind === 'rejected') return report_error(resolved.note)
|
|
253
299
|
console.info(await compose_brief({ round, paths, tree, base, profile: resolved.profile }))
|
|
254
300
|
record_round_one(round, tree, commit)
|
|
301
|
+
record_briefed(round, tree, commit)
|
|
255
302
|
|
|
256
303
|
return 0
|
|
257
304
|
}
|
|
@@ -357,6 +357,39 @@ function round_two_block(
|
|
|
357
357
|
return narrow_block(scope.recorded_at, scope, root, base)
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
+
// **A resumed round 1 reads what changed since the attested review, reusing round 2's reconciliation**
|
|
361
|
+
// (joshuafolkken/kit#2945). A ship that stopped and was re-issued briefed the whole change again, so
|
|
362
|
+
// the reviewer re-read every file the attested review had just read. The scope is the comparison round
|
|
363
|
+
// 2 makes, measured from the tree that review was briefed on. Every reading that cannot narrow falls back to the whole
|
|
364
|
+
// change: no record, a moved base, or an empty target. An empty target is included because a resumed
|
|
365
|
+
// review that read nothing would report a clean verdict over the one already recorded.
|
|
366
|
+
const RESUMED_HEADING =
|
|
367
|
+
'Resumed round 1 — a review of this branch was already attested, so this pass reads only what changed since the tree it read.'
|
|
368
|
+
|
|
369
|
+
const RESUMED_QUESTION =
|
|
370
|
+
'Review these files in full against the rubric — they are the only code this pass has not already read.'
|
|
371
|
+
|
|
372
|
+
function resumed_target(
|
|
373
|
+
snapshot: FileMapStamp | undefined,
|
|
374
|
+
tree: Record<string, string>,
|
|
375
|
+
root: string,
|
|
376
|
+
base: string,
|
|
377
|
+
): string {
|
|
378
|
+
const scope = round_two_scope(snapshot, tree, base)
|
|
379
|
+
|
|
380
|
+
if (scope.recorded_at === undefined || scope.target.length === 0) {
|
|
381
|
+
return whole_change_target(root, base)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return [
|
|
385
|
+
RESUMED_HEADING,
|
|
386
|
+
`The attested review read the tree recorded at ${scope.recorded_at}.`,
|
|
387
|
+
`Target: only these files, which changed since that tree:\n${format_paths(root, scope.target)}`,
|
|
388
|
+
...reconciliation(root, base, scope),
|
|
389
|
+
RESUMED_QUESTION,
|
|
390
|
+
].join('\n')
|
|
391
|
+
}
|
|
392
|
+
|
|
360
393
|
interface BriefStamps {
|
|
361
394
|
gate: FileMapStamp | undefined
|
|
362
395
|
in_flight: FileMapStamp | undefined
|
|
@@ -377,12 +410,23 @@ interface BriefInput {
|
|
|
377
410
|
// The rubric's absolute path, resolved by the caller against the kit package rather than the review
|
|
378
411
|
// checkout, so it resolves in a consumer that ships no `prompts/` of its own (joshuafolkken/kit#2402).
|
|
379
412
|
rubric_path: string
|
|
413
|
+
// The tree a finished, clean review of this branch was briefed on, so round 1 is a resume
|
|
414
|
+
// (joshuafolkken/kit#2945). Absent, round 1 reads the whole change.
|
|
415
|
+
resumed_from?: FileMapStamp | undefined
|
|
380
416
|
}
|
|
381
417
|
|
|
382
418
|
const SECOND_ROUND = 2
|
|
383
419
|
|
|
420
|
+
function round_one_target(input: BriefInput): string {
|
|
421
|
+
const { root } = input.checkout
|
|
422
|
+
|
|
423
|
+
if (input.resumed_from === undefined) return whole_change_target(root, input.base)
|
|
424
|
+
|
|
425
|
+
return resumed_target(input.resumed_from, input.tree, root, input.base)
|
|
426
|
+
}
|
|
427
|
+
|
|
384
428
|
function target_block(input: BriefInput): string {
|
|
385
|
-
if (input.round < SECOND_ROUND) return
|
|
429
|
+
if (input.round < SECOND_ROUND) return round_one_target(input)
|
|
386
430
|
|
|
387
431
|
return round_two_block(input.stamps.round_one, input.tree, input.checkout.root, input.base)
|
|
388
432
|
}
|
|
@@ -422,6 +466,7 @@ const review_brief = {
|
|
|
422
466
|
green_stamp,
|
|
423
467
|
in_flight_line,
|
|
424
468
|
live_marker,
|
|
469
|
+
RESUMED_HEADING,
|
|
425
470
|
matching_stamp,
|
|
426
471
|
no_snapshot_line,
|
|
427
472
|
NOTHING_LEFT_LINE,
|
|
@@ -59,10 +59,15 @@ const TEST_RELATED_PREFIX = 'josh-test-related-stamp-'
|
|
|
59
59
|
const GATE_PREFIX = 'josh-gate-stamp-'
|
|
60
60
|
const IN_FLIGHT_PREFIX = 'josh-gate-running-'
|
|
61
61
|
const ROUND_ONE_PREFIX = 'josh-review-round1-'
|
|
62
|
+
const BRIEFED_PREFIX = 'josh-review-briefed-'
|
|
62
63
|
|
|
63
64
|
const gate_stamp: FileMapStampAccess = file_map_stamp.create(GATE_PREFIX, PROJECT_ROOT)
|
|
64
65
|
const in_flight_stamp: FileMapStampAccess = file_map_stamp.create(IN_FLIGHT_PREFIX, PROJECT_ROOT)
|
|
65
66
|
const round_one_stamp: FileMapStampAccess = file_map_stamp.create(ROUND_ONE_PREFIX, PROJECT_ROOT)
|
|
67
|
+
// **The tree the latest round-1 brief handed out, retaken on every brief** (joshuafolkken/kit#2945).
|
|
68
|
+
// The round-1 snapshot is kept from the first brief of a run, so it is not the tree a later review
|
|
69
|
+
// read — a resume narrowed against it would skip a file put back to that older content.
|
|
70
|
+
const briefed_stamp: FileMapStampAccess = file_map_stamp.create(BRIEFED_PREFIX, PROJECT_ROOT)
|
|
66
71
|
const lint_related_stamp: FileMapStampAccess = file_map_stamp.create(
|
|
67
72
|
LINT_RELATED_PREFIX,
|
|
68
73
|
PROJECT_ROOT,
|
|
@@ -97,6 +102,8 @@ function clear_round_one(target?: string): void {
|
|
|
97
102
|
}
|
|
98
103
|
|
|
99
104
|
const review_stamps = {
|
|
105
|
+
BRIEFED_PREFIX,
|
|
106
|
+
briefed_stamp,
|
|
100
107
|
clear_round_one,
|
|
101
108
|
GATE_PREFIX,
|
|
102
109
|
gate_stamp,
|
|
@@ -50,7 +50,7 @@ function is_impl_cut_segment(segment: string): boolean {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
// Whether this command takes the implementation-phase cut, segment-wise so a spelling quoted inside a
|
|
53
|
-
// body is not read as the call
|
|
53
|
+
// body is not read as the call.
|
|
54
54
|
function takes_the_impl_cut(command: string): boolean {
|
|
55
55
|
return shell_segments.segments_of(command).some((segment) => is_impl_cut_segment(segment))
|
|
56
56
|
}
|
|
@@ -21,8 +21,7 @@ import { tail_commands } from './tail-commands'
|
|
|
21
21
|
// "fold first". **No new predicate**: the filing trigger is `bash_triggers.is_issue_filing`, and the
|
|
22
22
|
// prior-filing count is `filing_cap.prior_filing_count`, both reused verbatim.
|
|
23
23
|
|
|
24
|
-
// `pnpm josh issue:fold`, the one act the rule asks for.
|
|
25
|
-
// is read, so the canonical name matches both spellings.
|
|
24
|
+
// `pnpm josh issue:fold`, the one act the rule asks for.
|
|
26
25
|
const FOLD_NAMES: ReadonlySet<string> = new Set(['issue:fold'])
|
|
27
26
|
|
|
28
27
|
// Keeping the rule: a call that runs the fold. Segment-wise, so a spelling quoted inside a filing's
|
|
@@ -29,8 +29,7 @@ import { shell_segments } from './shell-segments'
|
|
|
29
29
|
|
|
30
30
|
// The parent backlogrun budget/ownership commands. A child never runs either: `run:carry` begins and
|
|
31
31
|
// advances the run budget the parent owns, and `run:merge` counts a returned child into it. Named in
|
|
32
|
-
// canonical form only — `shell_segments.is_josh_command` canonicalizes an alias
|
|
33
|
-
// the match, so `pnpm josh rmg` arrives as `run:merge`.
|
|
32
|
+
// canonical form only — `shell_segments.is_josh_command` canonicalizes an alias before the match.
|
|
34
33
|
const RUN_BUDGET_COMMANDS: ReadonlySet<string> = new Set(['run:merge', 'run:carry'])
|
|
35
34
|
|
|
36
35
|
// A segment that invokes one of the parent's budget commands. Segment-wise and alias-expanded for the
|
|
@@ -30,8 +30,7 @@ import { shell_segments } from './shell-segments'
|
|
|
30
30
|
// the stop, the reason is a checklist rather than an accusation, and a child that already parked simply
|
|
31
31
|
// reissues. One wasted round trip on the compliant path is the safe direction; a missed park is not.
|
|
32
32
|
|
|
33
|
-
// `josh notify`,
|
|
34
|
-
// same way `pre-gate-cut.ts` matches `gate` through `ga`.
|
|
33
|
+
// `josh notify`, named canonically — `shell_segments.is_josh_command` expands an alias before the match.
|
|
35
34
|
const NOTIFY_COMMANDS: ReadonlySet<string> = new Set(['notify'])
|
|
36
35
|
// The task type that marks a stop waiting on a person, taken quoted or bare so `--task-type
|
|
37
36
|
// confirmation` and `--task-type=confirmation` read alike. A `progress` or `pr` notification is not a
|
|
@@ -44,8 +44,8 @@ const HOLD_COMMANDS: ReadonlySet<string> = new Set(['run:hold'])
|
|
|
44
44
|
// predicate exists to keep it out of.
|
|
45
45
|
const HOLD_MODE_FLAG = /(?:^|\s)--release(?:[=\s]|$)/u
|
|
46
46
|
|
|
47
|
-
// **Alias-expanded rather than matched as text**, so `pnpm josh ga`
|
|
48
|
-
//
|
|
47
|
+
// **Alias-expanded rather than matched as text**, so `pnpm josh ga` is the same call as `pnpm josh
|
|
48
|
+
// gate`. Segment-wise for the reason every other row here is: one shell
|
|
49
49
|
// line carries several commands, and a name quoted inside a body is not the command being invoked.
|
|
50
50
|
//
|
|
51
51
|
// **One reading of that question rather than one per name set.** Four predicates here differ only in
|
|
@@ -73,10 +73,27 @@ function is_owned_here(carry: RunCarry, ancestry: () => ReadonlySet<number>): bo
|
|
|
73
73
|
return carry.owner_pid !== undefined && ancestry().has(carry.owner_pid)
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
function
|
|
77
|
-
|
|
76
|
+
function driven_here(
|
|
77
|
+
read: CarryRead | undefined,
|
|
78
|
+
ancestry: () => ReadonlySet<number>,
|
|
79
|
+
): RunCarry | undefined {
|
|
80
|
+
if (read?.kind !== 'carried' || !is_driving(read)) return undefined
|
|
78
81
|
|
|
79
|
-
return is_owned_here(read.carry, ancestry)
|
|
82
|
+
return is_owned_here(read.carry, ancestry) ? read.carry : undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The live, un-handed-off carry record this session drives as the `backlogrun` parent, or `undefined`
|
|
87
|
+
* for a lane child or any session that does not own one. Returned rather than tested so a caller that
|
|
88
|
+
* also needs the record's counters (the parent cut's cap, joshuafolkken/kit#2947) reads it once.
|
|
89
|
+
*/
|
|
90
|
+
async function driving_carry(
|
|
91
|
+
source: EnvironmentSource = process.env,
|
|
92
|
+
ancestry: () => ReadonlySet<number> = lane_reap.own_ancestry,
|
|
93
|
+
): Promise<RunCarry | undefined> {
|
|
94
|
+
if (lane_child_marker.marked_issue(source) !== undefined) return undefined
|
|
95
|
+
|
|
96
|
+
return driven_here(await read_carry_here(), ancestry)
|
|
80
97
|
}
|
|
81
98
|
|
|
82
99
|
/**
|
|
@@ -88,14 +105,13 @@ async function is_backlog_parent(
|
|
|
88
105
|
source: EnvironmentSource = process.env,
|
|
89
106
|
ancestry: () => ReadonlySet<number> = lane_reap.own_ancestry,
|
|
90
107
|
): Promise<boolean> {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return is_driven_here(await read_carry_here(), ancestry)
|
|
108
|
+
return (await driving_carry(source, ancestry)) !== undefined
|
|
94
109
|
}
|
|
95
110
|
|
|
96
111
|
const run_headless = {
|
|
97
112
|
HEADLESS_ENV_KEY,
|
|
98
113
|
current_carry,
|
|
114
|
+
driving_carry,
|
|
99
115
|
environment,
|
|
100
116
|
is_backlog_parent,
|
|
101
117
|
is_headless,
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { CONTEXT_CUT_THRESHOLD } from '#scripts/cost-runtime/context-cut-threshold'
|
|
2
|
+
import { cost_cli, type CostVerdict } from '#scripts/cost-runtime/cost-cli'
|
|
3
|
+
import { cost_format } from '#scripts/cost-runtime/cost-format'
|
|
4
|
+
import { cost_verdict } from '#scripts/cost-runtime/cost-verdict'
|
|
5
|
+
import { hook_decision } from '#scripts/josh/hook-decision'
|
|
6
|
+
import { implementation_cut_verdict } from '#scripts/rules/implementation-cut-verdict'
|
|
7
|
+
import { run_carry, type RunCarry } from './run-carry'
|
|
8
|
+
import { run_headless } from './run-headless'
|
|
9
|
+
import { run_watcher_hook } from './run-watcher-hook'
|
|
10
|
+
|
|
11
|
+
// The `backlogrun` parent's hand-off, delivered at the call it binds on (joshuafolkken/kit#2947).
|
|
12
|
+
//
|
|
13
|
+
// **The parent's hand-off was asked only at a merge, and the context grew between merges.** The
|
|
14
|
+
// orchestrator never implements, yet on 2026-10-02 parent 259fb6d5 sent 111 of its 170 requests past
|
|
15
|
+
// the shared threshold, peaking at 207,081 tokens: `pnpm josh cost --cut` is read after each child's
|
|
16
|
+
// merge (`backlogrun-progress.md` → "The hand-off"), and a long wait or a lane-failure investigation
|
|
17
|
+
// between two merges is exactly where it went unread. This is the lesson `implementation-cut.ts` records
|
|
18
|
+
// for a lane child, one session up — a check carried as a step is taken late, a `PreToolUse` refusal is
|
|
19
|
+
// taken at the next call.
|
|
20
|
+
//
|
|
21
|
+
// **The same statistic and the same threshold, never a second measurement.** The verdict is
|
|
22
|
+
// `cost_cli.session_verdict` — what `pnpm josh cost --cut` prints — read through the implementation cut's
|
|
23
|
+
// short reuse window so a burst of calls is priced once.
|
|
24
|
+
//
|
|
25
|
+
// **It is async and composed after the synchronous guards, like the watcher guard.** Telling the parent
|
|
26
|
+
// apart reads the carry record through `run_headless.driving_carry`, which is asynchronous; reusing it is
|
|
27
|
+
// what keeps a second, synchronous spelling of the parent test from existing.
|
|
28
|
+
|
|
29
|
+
const SWITCH_ENV_KEY = 'JOSH_PARENT_CUT_GUARD'
|
|
30
|
+
const STAMP_PREFIX = 'josh-parent-cut-guard-'
|
|
31
|
+
|
|
32
|
+
// **One refusal, then a quiet window long enough to finish the hand-off in.** The hand-off is several
|
|
33
|
+
// calls — `lane:list`, `lane:output`, `run:carry --cut`, `run:wake`, the progress comment — and a guard
|
|
34
|
+
// refusing each of them would wedge the very procedure it asks for. A parent that could not cut (a lane
|
|
35
|
+
// nobody can poll) is asked again once the window has passed, which is the "next opportunity" the merge
|
|
36
|
+
// seam used to be.
|
|
37
|
+
const QUIET_WINDOW_MS = 600_000
|
|
38
|
+
|
|
39
|
+
// What the decision has to know about the world, passed in so it is testable without a carry record or
|
|
40
|
+
// a session transcript on disk.
|
|
41
|
+
interface ParentCutState {
|
|
42
|
+
carry: () => Promise<RunCarry | undefined>
|
|
43
|
+
verdict: () => CostVerdict
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function current_state(): ParentCutState {
|
|
47
|
+
return {
|
|
48
|
+
carry: async (): Promise<RunCarry | undefined> => await run_headless.driving_carry(),
|
|
49
|
+
verdict: (): CostVerdict =>
|
|
50
|
+
implementation_cut_verdict.reused_verdict(() => cost_cli.session_verdict()),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const THRESHOLD_TEXT = `${cost_format.format_tokens(CONTEXT_CUT_THRESHOLD)}-token`
|
|
55
|
+
|
|
56
|
+
const PARENT_CUT_REASON =
|
|
57
|
+
'⛔ backlogrun parent hand-off: this session drives a `backlogrun` (it owns the live carry record) and ' +
|
|
58
|
+
`its recent-context cost has crossed the shared ${THRESHOLD_TEXT} threshold between merges, so every ` +
|
|
59
|
+
'later request re-reads the accumulated context (joshuafolkken/kit#2947). Take the hand-off now rather ' +
|
|
60
|
+
'than at the next merge: follow `.claude/skills/workflow-commands/backlogrun-progress.md` → "The ' +
|
|
61
|
+
'hand-off" from its `over` branch — open no new lane and take no new child, read `pnpm josh lane:list`, ' +
|
|
62
|
+
'make sure every lane in flight records an output path, then run `pnpm josh run:carry --cut --owner ' +
|
|
63
|
+
'"$PPID"` and, unless it answers `capped`, `pnpm josh run:wake`, post the progress comment and end the ' +
|
|
64
|
+
'turn. A lane nobody can poll means the cut does not happen; carry on and this asks again later. ' +
|
|
65
|
+
'Reissue this call when the hand-off is done or cannot be taken — the guard stays quiet for a while ' +
|
|
66
|
+
'after it refuses, so the hand-off calls themselves are not refused.'
|
|
67
|
+
|
|
68
|
+
// Neither an uncapped parent's own record nor a measured `over` is optional: a capped record has no cut
|
|
69
|
+
// left to take (`run:carry --cut` answers `capped`), and an unmeasurable session is left alone here —
|
|
70
|
+
// refusing every call of a session that cannot be priced would wedge it, while the merge-seam check
|
|
71
|
+
// still takes `over`'s branch for it.
|
|
72
|
+
async function is_due(state: ParentCutState): Promise<boolean> {
|
|
73
|
+
const carry = await state.carry()
|
|
74
|
+
|
|
75
|
+
if (carry === undefined || run_carry.is_at_cut_cap(carry)) return false
|
|
76
|
+
|
|
77
|
+
return state.verdict() === cost_verdict.OVER_VERDICT
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function is_quiet(target: string, now_ms: number): boolean {
|
|
81
|
+
const last_ms = hook_decision.create_refusal_stamp(STAMP_PREFIX).last_ms(target)
|
|
82
|
+
|
|
83
|
+
return last_ms !== hook_decision.NEVER_MS && now_ms - last_ms < QUIET_WINDOW_MS
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The quiet window is read before the carry record and the transcript price, so a refused parent's
|
|
87
|
+
// following calls cost one small file read each.
|
|
88
|
+
async function refusal_for(
|
|
89
|
+
transcript: string,
|
|
90
|
+
now_ms: number,
|
|
91
|
+
state: ParentCutState,
|
|
92
|
+
): Promise<string | undefined> {
|
|
93
|
+
const stamp = hook_decision.create_refusal_stamp(STAMP_PREFIX)
|
|
94
|
+
const target = stamp.path(transcript)
|
|
95
|
+
|
|
96
|
+
if (is_quiet(target, now_ms) || !(await is_due(state))) return undefined
|
|
97
|
+
|
|
98
|
+
return stamp.record(target, now_ms) ? PARENT_CUT_REASON : undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A delegated unit's call names the *parent's* transcript and runs under the parent's process, so the
|
|
102
|
+
// carry record reads as owned here; only `agent_id` tells it apart (`hook-decision.ts`). Refusing it
|
|
103
|
+
// would hand the unit a hand-off it cannot take and spend the parent's quiet window on it.
|
|
104
|
+
function is_unit_call(raw_payload: string): boolean {
|
|
105
|
+
try {
|
|
106
|
+
return (hook_decision.parse_hook_payload(raw_payload)?.agent_id ?? '') !== ''
|
|
107
|
+
} catch {
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function is_exempt(raw_payload: string): boolean {
|
|
113
|
+
return !hook_decision.is_switch_enabled(SWITCH_ENV_KEY) || is_unit_call(raw_payload)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The deny reason for a call a `backlogrun` parent makes past the shared threshold, or `undefined` when
|
|
118
|
+
* the call proceeds — a lane child, a delegated unit, a person's session, a handed-off or capped
|
|
119
|
+
* record, an `under` or unmeasurable verdict, or a call inside the quiet window after a refusal.
|
|
120
|
+
*/
|
|
121
|
+
async function parent_cut_reason(
|
|
122
|
+
raw_payload: string,
|
|
123
|
+
now_ms: number = Date.now(),
|
|
124
|
+
state: ParentCutState = current_state(),
|
|
125
|
+
): Promise<string | undefined> {
|
|
126
|
+
if (is_exempt(raw_payload)) return undefined
|
|
127
|
+
|
|
128
|
+
const transcript = run_watcher_hook.transcript_of(raw_payload)
|
|
129
|
+
|
|
130
|
+
if (transcript === undefined) return undefined
|
|
131
|
+
|
|
132
|
+
return await refusal_for(transcript, now_ms, state)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const run_parent_cut_hook = {
|
|
136
|
+
PARENT_CUT_REASON,
|
|
137
|
+
QUIET_WINDOW_MS,
|
|
138
|
+
STAMP_PREFIX,
|
|
139
|
+
SWITCH_ENV_KEY,
|
|
140
|
+
parent_cut_reason,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export type { ParentCutState }
|
|
144
|
+
export { run_parent_cut_hook }
|
|
@@ -78,7 +78,7 @@ async function to_child(issue: OpenIssueData): Promise<ChildObservation> {
|
|
|
78
78
|
const number = String(issue.number)
|
|
79
79
|
const child = await run_preflight.read_child_state(number)
|
|
80
80
|
|
|
81
|
-
return { issue: number, labels: to_labels(issue), pr_state: child.pr_state }
|
|
81
|
+
return { issue: number, title: issue.title, labels: to_labels(issue), pr_state: child.pr_state }
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
async function read_children(repo: string): Promise<ReadonlyArray<ChildObservation> | undefined> {
|
|
@@ -171,6 +171,7 @@ async function read_observations(request: ObservationRequest): Promise<Observati
|
|
|
171
171
|
if (children.length === 0 && !(await has_run_started(lanes))) return IDLE_READ
|
|
172
172
|
|
|
173
173
|
const observations: Observations = {
|
|
174
|
+
repo: request.repo,
|
|
174
175
|
children,
|
|
175
176
|
lanes,
|
|
176
177
|
load_average: loadavg()[FIRST_LOAD_AVERAGE] ?? 0,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { issue_cite, type IssueCiter } from '#scripts/issue/issue-cite'
|
|
2
|
+
|
|
1
3
|
// `josh run:progress` — the one line an unattended run prints when it has gone quiet, and the clock
|
|
2
4
|
// that decides when that is (joshuafolkken/kit#1520).
|
|
3
5
|
//
|
|
@@ -56,6 +58,9 @@ const DEFAULT_INTERVAL_MS = DEFAULT_INTERVAL_MINUTES * MS_PER_MINUTE
|
|
|
56
58
|
// a second spelling of it — `none` / `open` / `merged` / `closed`.
|
|
57
59
|
interface ChildObservation {
|
|
58
60
|
issue: string
|
|
61
|
+
// Kept from the `in-progress` listing the child was read from, so the line cites it in full
|
|
62
|
+
// (joshuafolkken/kit#2943) without a read per child.
|
|
63
|
+
title: string
|
|
59
64
|
labels: ReadonlyArray<string>
|
|
60
65
|
pr_state: string
|
|
61
66
|
}
|
|
@@ -66,6 +71,8 @@ interface LaneObservation {
|
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
interface Observations {
|
|
74
|
+
// `owner/repo`, the repository the listing was read from — what every printed issue links into.
|
|
75
|
+
repo: string
|
|
69
76
|
children: ReadonlyArray<ChildObservation>
|
|
70
77
|
lanes: ReadonlyArray<LaneObservation>
|
|
71
78
|
load_average: number
|
|
@@ -195,26 +202,32 @@ function next_state(
|
|
|
195
202
|
return { key, unchanged_since_ms: now_ms }
|
|
196
203
|
}
|
|
197
204
|
|
|
198
|
-
|
|
205
|
+
// The issue leads as a citation rather than a bare `#N` (joshuafolkken/kit#2943): the parent relays these
|
|
206
|
+
// lines verbatim, and a bare number copied into a reply is what the Stop guard sends back.
|
|
207
|
+
function format_child(child: ChildObservation, cite: IssueCiter): string {
|
|
199
208
|
const labels = child.labels.length > 0 ? child.labels.join(',') : '(no labels)'
|
|
200
209
|
|
|
201
|
-
return
|
|
210
|
+
return `${cite(child.issue)} ${labels} PR:${child.pr_state}`
|
|
202
211
|
}
|
|
203
212
|
|
|
204
213
|
// An empty set is the pre-label stage, said as such rather than left as a blank slot between the
|
|
205
214
|
// separators (joshuafolkken/kit#1900). After that change a `read_observations` result carries no
|
|
206
215
|
// children exactly when a run has started and none has reached `in-progress` yet, so this is the one
|
|
207
216
|
// place the two cases are told apart on the line.
|
|
208
|
-
function format_children(children: ReadonlyArray<ChildObservation
|
|
217
|
+
function format_children(children: ReadonlyArray<ChildObservation>, repo: string): string {
|
|
209
218
|
if (children.length === 0) return NO_CHILD_YET
|
|
219
|
+
const cite = issue_cite.citer(repo, new Map(children.map((child) => [child.issue, child.title])))
|
|
210
220
|
|
|
211
|
-
return children.map((child) => format_child(child)).join(' · ')
|
|
221
|
+
return children.map((child) => format_child(child, cite)).join(' · ')
|
|
212
222
|
}
|
|
213
223
|
|
|
214
|
-
|
|
224
|
+
// A lane is linked without its title: a lane in flight is a child the line above already cited in full,
|
|
225
|
+
// so repeating the title here would only lengthen the line.
|
|
226
|
+
function format_lanes(lanes: ReadonlyArray<LaneObservation>, repo: string): string {
|
|
215
227
|
if (lanes.length === 0) return NO_LANES
|
|
228
|
+
const cite = issue_cite.citer(repo, new Map())
|
|
216
229
|
|
|
217
|
-
return lanes.map((lane) => `${lane.issue}:${lane.state}`).join(' ')
|
|
230
|
+
return lanes.map((lane) => `${cite(lane.issue)}:${lane.state}`).join(' ')
|
|
218
231
|
}
|
|
219
232
|
|
|
220
233
|
// Absent is said, never filled in. A run started without a transcript path prints `unread` here, which
|
|
@@ -303,8 +316,8 @@ function format_line(observations: Observations, timing: LineTiming): string {
|
|
|
303
316
|
observed_at: format_observed_at(timing.now_ms),
|
|
304
317
|
quiet: format_minutes(timing.now_ms - timing.quiet_since_ms),
|
|
305
318
|
unchanged: format_minutes(timing.now_ms - timing.unchanged_since_ms),
|
|
306
|
-
children: format_children(observations.children),
|
|
307
|
-
lanes: format_lanes(observations.lanes),
|
|
319
|
+
children: format_children(observations.children, observations.repo),
|
|
320
|
+
lanes: format_lanes(observations.lanes, observations.repo),
|
|
308
321
|
load: observations.load_average.toFixed(1),
|
|
309
322
|
record: format_record(observations.record_age_ms),
|
|
310
323
|
next: format_next_report(timing.now_ms, timing.interval_ms),
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
3
|
import { parseArgs } from 'node:util'
|
|
4
|
-
import { josh_command, type JoshResult } from '#scripts/josh/josh-run'
|
|
5
4
|
import { lane_child_marker } from '#scripts/lane/lane-child-marker'
|
|
6
5
|
import { run_event_stream } from './run-event-stream'
|
|
7
6
|
import { run_event_stream_emit } from './run-event-stream-emit'
|
|
@@ -9,8 +8,8 @@ import { run_ship, type ShipSection } from './run-ship'
|
|
|
9
8
|
import { run_ship_detach } from './run-ship-detach'
|
|
10
9
|
import { run_ship_probe } from './run-ship-probe'
|
|
11
10
|
import { run_ship_return } from './run-ship-return'
|
|
12
|
-
import { run_ship_review_steps } from './run-ship-review-steps'
|
|
13
11
|
import { run_ship_stage, type Phase, type ShipState, type Stage } from './run-ship-stage'
|
|
12
|
+
import { run_ship_steps, type ShipArguments, type Step } from './run-ship-steps'
|
|
14
13
|
|
|
15
14
|
// `josh ship "<title> #<N>"` — one call for the fixed commit-to-report region a run ships a change on
|
|
16
15
|
// (joshuafolkken/kit#2398). The loop used to spend a round trip each on `gate`, `git -y`, `followup`
|
|
@@ -67,7 +66,6 @@ const USAGE =
|
|
|
67
66
|
const NO_LOG_NOTE = 'no detached ship supervisor log for this issue'
|
|
68
67
|
const LANE_CHILD_DETACH_NOTE =
|
|
69
68
|
'lane child: shipping through the detached supervisor (--detach implied), so the end of this turn cannot kill it'
|
|
70
|
-
const should_forward_stderr = true
|
|
71
69
|
|
|
72
70
|
type NotifyValues = Partial<Record<(typeof NOTIFY_OPTIONS)[number], string>>
|
|
73
71
|
|
|
@@ -84,24 +82,8 @@ const OPTIONS = {
|
|
|
84
82
|
cite: { type: 'string', multiple: true },
|
|
85
83
|
} as const
|
|
86
84
|
|
|
87
|
-
interface ShipArguments {
|
|
88
|
-
title: string
|
|
89
|
-
number: string
|
|
90
|
-
notify: ReadonlyArray<string>
|
|
91
|
-
body: ReadonlyArray<string>
|
|
92
|
-
cites: ReadonlyArray<string>
|
|
93
|
-
is_review: boolean
|
|
94
|
-
is_detach: boolean
|
|
95
|
-
}
|
|
96
|
-
|
|
97
85
|
type ShipCommand = { kind: 'ship'; args: ShipArguments } | { kind: 'log'; number: string }
|
|
98
86
|
|
|
99
|
-
interface Step {
|
|
100
|
-
stage: Stage
|
|
101
|
-
header: string
|
|
102
|
-
run: (args: ShipArguments, state: ShipState) => Promise<JoshResult>
|
|
103
|
-
}
|
|
104
|
-
|
|
105
87
|
// What a resumed ship knows before its first stage: the record's path (absent outside a repository),
|
|
106
88
|
// the stages it says completed, and the repository's actual state (`run-ship-stage.ts` decides from it).
|
|
107
89
|
interface ShipContext {
|
|
@@ -124,58 +106,7 @@ function body_arguments(path: string | undefined): ReadonlyArray<string> {
|
|
|
124
106
|
return path === undefined ? [] : [`--${BODY_FILE_OPTION}`, path]
|
|
125
107
|
}
|
|
126
108
|
|
|
127
|
-
const {
|
|
128
|
-
|
|
129
|
-
// The four steps in the order a change ships: the gate before the commit, the commit/push/PR before
|
|
130
|
-
// the merge, the merge before the report bookkeeping. The commit step carries the `--skip-*` flags a
|
|
131
|
-
// resumed ship needs, so an existing commit or push is never made twice.
|
|
132
|
-
async function josh(argv: ReadonlyArray<string>): Promise<JoshResult> {
|
|
133
|
-
return await josh_command.josh_run(argv, should_forward_stderr)
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const COMMIT_STEPS: ReadonlyArray<Step> = [
|
|
137
|
-
{ stage: STAGE.GATE, header: run_ship.GATE_HEADER, run: async () => await josh(['gate']) },
|
|
138
|
-
{
|
|
139
|
-
stage: STAGE.COMMIT,
|
|
140
|
-
header: run_ship.COMMIT_HEADER,
|
|
141
|
-
run: async (args, state) =>
|
|
142
|
-
await josh(['git', '-y', ...run_ship_stage.commit_flags(state), ...args.body, args.title]),
|
|
143
|
-
},
|
|
144
|
-
]
|
|
145
|
-
|
|
146
|
-
const MERGE_STEPS: ReadonlyArray<Step> = [
|
|
147
|
-
{
|
|
148
|
-
stage: STAGE.FOLLOWUP,
|
|
149
|
-
header: run_ship.FOLLOWUP_HEADER,
|
|
150
|
-
run: async (args) => await josh(['followup', args.title, ...args.notify]),
|
|
151
|
-
},
|
|
152
|
-
{
|
|
153
|
-
stage: STAGE.REPORT,
|
|
154
|
-
header: run_ship.REPORT_HEADER,
|
|
155
|
-
run: async (args) => await josh(['run:tail', args.number, ...args.cites]),
|
|
156
|
-
},
|
|
157
|
-
]
|
|
158
|
-
|
|
159
|
-
// `--review` (joshuafolkken/kit#2427) puts the supervised round-1 review in front of the gate: it
|
|
160
|
-
// launches the gate itself, so the gate stage that follows reuses that tree's green record. It also
|
|
161
|
-
// puts the round-2 pass between the commit and the followup (joshuafolkken/kit#2489), so the PR opens
|
|
162
|
-
// between the rounds and round 2 runs beside CI — a no-op when round 1 left no fix delta.
|
|
163
|
-
const REVIEW_STEP: Step = {
|
|
164
|
-
stage: STAGE.REVIEW,
|
|
165
|
-
header: run_ship.REVIEW_HEADER,
|
|
166
|
-
run: async (args) => await run_ship_review_steps.review_stage(args.number),
|
|
167
|
-
}
|
|
168
|
-
const ROUND_TWO_STEP: Step = {
|
|
169
|
-
stage: STAGE.ROUND_TWO,
|
|
170
|
-
header: run_ship.ROUND_TWO_HEADER,
|
|
171
|
-
run: async (args) => await run_ship_review_steps.round_two_stage(args.number),
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function steps(args: ShipArguments): ReadonlyArray<Step> {
|
|
175
|
-
if (!args.is_review) return [...COMMIT_STEPS, ...MERGE_STEPS]
|
|
176
|
-
|
|
177
|
-
return [REVIEW_STEP, ...COMMIT_STEPS, ROUND_TWO_STEP, ...MERGE_STEPS]
|
|
178
|
-
}
|
|
109
|
+
const { PHASE } = run_ship_stage
|
|
179
110
|
|
|
180
111
|
function issue_number(title: string): string | undefined {
|
|
181
112
|
return TRAILING_ISSUE_PATTERN.exec(title)?.[NUMBER_GROUP]
|
|
@@ -211,6 +142,7 @@ function ship_args(
|
|
|
211
142
|
number,
|
|
212
143
|
notify: notify_arguments(values),
|
|
213
144
|
body: body_arguments(values[BODY_FILE_OPTION]),
|
|
145
|
+
body_path: values[BODY_FILE_OPTION],
|
|
214
146
|
cites,
|
|
215
147
|
is_review: values.review === true,
|
|
216
148
|
is_detach: values.detach === true,
|
|
@@ -328,14 +260,14 @@ async function stopped(
|
|
|
328
260
|
return sections
|
|
329
261
|
}
|
|
330
262
|
|
|
331
|
-
// Run the
|
|
263
|
+
// Run the stages in order, stopping at the first that failed: a red gate never reaches the commit, so
|
|
332
264
|
// the returned sections end at the failure the report names. A ship that reached the end clears its
|
|
333
|
-
// record, so the next ship of the same issue starts from the
|
|
265
|
+
// record, so the next ship of the same issue starts from the preflight.
|
|
334
266
|
async function ship(args: ShipArguments): Promise<ReadonlyArray<ShipSection>> {
|
|
335
267
|
const context = await open_context(args)
|
|
336
268
|
const sections: Array<ShipSection> = []
|
|
337
269
|
|
|
338
|
-
for (const step of steps(args)) {
|
|
270
|
+
for (const step of run_ship_steps.steps(args)) {
|
|
339
271
|
const section = await run_stage(step, args, context)
|
|
340
272
|
|
|
341
273
|
sections.push(section)
|