@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.
Files changed (118) hide show
  1. package/.claude/skills/verify-ui/SKILL.md +1 -1
  2. package/.claude/skills/workflow-commands/SKILL.md +2 -2
  3. package/.claude/skills/workflow-commands/background-commands.md +1 -1
  4. package/.claude/skills/workflow-commands/backlogrun-child.md +2 -2
  5. package/.claude/skills/workflow-commands/backlogrun-lanes.md +1 -1
  6. package/.claude/skills/workflow-commands/backlogrun-park.md +6 -0
  7. package/.claude/skills/workflow-commands/backlogrun-progress.md +30 -36
  8. package/.claude/skills/workflow-commands/backlogrun-steps.md +16 -16
  9. package/.claude/skills/workflow-commands/chain-rule.md +2 -2
  10. package/.claude/skills/workflow-commands/followup-reference.md +2 -2
  11. package/.claude/skills/workflow-commands/issue-comments.md +1 -1
  12. package/.claude/skills/workflow-commands/observation-filing.md +41 -86
  13. package/.claude/skills/workflow-commands/pre-gate-cut.md +33 -36
  14. package/.claude/skills/workflow-commands/working-tree-hold.md +2 -2
  15. package/.github/workflows/auto-tag.yml +21 -0
  16. package/.github/workflows/publish.yml +9 -10
  17. package/dist/hooks/batch-guard.js +4 -4
  18. package/dist/hooks/{chunk-HU66PWWL.js → chunk-5BK3K7V4.js} +89 -41
  19. package/dist/hooks/{chunk-GLBDBJOK.js → chunk-7VPDUBRN.js} +49 -151
  20. package/dist/hooks/{chunk-GJT4BNVE.js → chunk-DMQN45FN.js} +3 -3
  21. package/dist/hooks/{chunk-D67KVGGY.js → chunk-EJDX2TOB.js} +13 -3
  22. package/dist/hooks/{chunk-KG6PZD72.js → chunk-GQ7KP45M.js} +2 -2
  23. package/dist/hooks/{chunk-Y27M6A7X.js → chunk-HJIISEAC.js} +4 -4
  24. package/dist/hooks/{chunk-XPLP27WA.js → chunk-NAS4C7GP.js} +4 -26
  25. package/dist/hooks/{chunk-ZXNE6NY3.js → chunk-NJQOBE6L.js} +2 -2
  26. package/dist/hooks/{chunk-EIFCAJRB.js → chunk-RLDX5SOC.js} +1184 -946
  27. package/dist/hooks/{chunk-HOYJU5RM.js → chunk-WQ4OSBSS.js} +10 -3
  28. package/dist/hooks/codex-hook-adapter.js +10 -10
  29. package/dist/hooks/duplicate-read-guard.js +5 -5
  30. package/dist/hooks/format-edited.js +15 -34
  31. package/dist/hooks/investigation-guard.js +5 -5
  32. package/dist/hooks/pretool-guard.js +23 -16
  33. package/dist/hooks/stop-guard.js +13 -188
  34. package/dist/josh.js +4 -106
  35. package/dist/version/index.js +381 -9
  36. package/package.json +6 -6
  37. package/prompts/collaboration-workflow/file-edits.md +6 -53
  38. package/prompts/collaboration-workflow/residency.md +1 -1
  39. package/prompts/collaboration-workflow/rule-delivery.md +4 -35
  40. package/prompts/collaboration-workflow/shell-body.md +7 -9
  41. package/prompts/collaboration-workflow/turn-batching.md +21 -37
  42. package/prompts/collaboration-workflow/wip-cap.md +15 -18
  43. package/prompts/sonar-hotspot-handling.md +1 -1
  44. package/scripts/adopt/adopt-toolkits.ts +2 -0
  45. package/scripts/auto-ok/auto-ok-cli.ts +9 -9
  46. package/scripts/backlog/backlog-next.ts +24 -8
  47. package/scripts/backlog/backlog-plan-cli.ts +5 -1
  48. package/scripts/backlog/backlog-pool.ts +31 -11
  49. package/scripts/backlog/backlog-rank.ts +106 -0
  50. package/scripts/backlog/backlog-waves.ts +42 -29
  51. package/scripts/build/build-hooks.ts +7 -2
  52. package/scripts/delegation/delegation-policy.ts +11 -0
  53. package/scripts/document/document-byte-budget.ts +5 -5
  54. package/scripts/document/entry-read-budget.ts +5 -4
  55. package/scripts/epic/epic-lane-offer.ts +11 -22
  56. package/scripts/epic/epic-rank.ts +37 -0
  57. package/scripts/epic/epic-solo.ts +29 -28
  58. package/scripts/git/git-command.ts +1 -1
  59. package/scripts/git/git-execa-error.ts +6 -1
  60. package/scripts/git/git-next-issues.ts +34 -5
  61. package/scripts/git/git-preflight.ts +22 -4
  62. package/scripts/git/git-push-transport.ts +12 -69
  63. package/scripts/git/git-spawn.ts +79 -3
  64. package/scripts/git/git-ssh-keepalive.ts +74 -0
  65. package/scripts/git/gone-branch.ts +3 -2
  66. package/scripts/git/issue-labels.ts +12 -0
  67. package/scripts/git/issue-rank.ts +89 -0
  68. package/scripts/hooks/format-edited-cli.ts +15 -0
  69. package/scripts/hooks/format-edited-file.ts +2 -9
  70. package/scripts/hooks/pretool-guard-cli.ts +21 -0
  71. package/scripts/hooks/pretool-guard.ts +14 -15
  72. package/scripts/init/start-exec.ts +7 -1
  73. package/scripts/init/start-plan.ts +18 -5
  74. package/scripts/init/start-setup-pr.ts +46 -19
  75. package/scripts/init/start-steps.ts +41 -9
  76. package/scripts/init/start.ts +40 -10
  77. package/scripts/issue/issue-cite.ts +19 -1
  78. package/scripts/josh/josh-command-map.ts +0 -102
  79. package/scripts/josh/josh-commands-development.ts +2 -2
  80. package/scripts/josh/josh-commands-project.ts +1 -1
  81. package/scripts/lane/lane-child-marker.ts +16 -2
  82. package/scripts/lane/lane-cli.ts +32 -14
  83. package/scripts/lane/lane-report.ts +10 -4
  84. package/scripts/propagate/propagate-plan.ts +185 -0
  85. package/scripts/propagate/propagate-run.ts +5 -1
  86. package/scripts/propagate/propagate-steps.ts +16 -1
  87. package/scripts/propagate/propagate-targets.ts +13 -2
  88. package/scripts/propagate/propagate.ts +60 -42
  89. package/scripts/registry-migration/migrate-logic.ts +1 -0
  90. package/scripts/release/github-release.ts +1 -1
  91. package/scripts/review/review-attest.ts +44 -0
  92. package/scripts/review/review-brief-cli.ts +49 -2
  93. package/scripts/review/review-brief.ts +46 -1
  94. package/scripts/review/review-stamps.ts +7 -0
  95. package/scripts/rules/implementation-cut.ts +1 -1
  96. package/scripts/rules/issue-fold.ts +1 -2
  97. package/scripts/rules/lane-carry-conflict.ts +1 -2
  98. package/scripts/rules/lane-park.ts +1 -2
  99. package/scripts/rules/pre-gate-cut.ts +2 -2
  100. package/scripts/run/run-headless.ts +22 -6
  101. package/scripts/run/run-parent-cut-hook.ts +144 -0
  102. package/scripts/run/run-progress-read.ts +2 -1
  103. package/scripts/run/run-progress.ts +21 -8
  104. package/scripts/run/run-ship-cli.ts +6 -74
  105. package/scripts/run/run-ship-preflight.ts +87 -0
  106. package/scripts/run/run-ship-review-steps.ts +5 -26
  107. package/scripts/run/run-ship-review.ts +9 -0
  108. package/scripts/run/run-ship-scoped.ts +44 -0
  109. package/scripts/run/run-ship-stage.ts +5 -1
  110. package/scripts/run/run-ship-steps.ts +105 -0
  111. package/scripts/run/run-ship.ts +2 -0
  112. package/scripts/run/run-wake-cli.ts +2 -2
  113. package/scripts/run/run-wake-describe.ts +19 -1
  114. package/scripts/run/run-watcher-hook.ts +1 -1
  115. package/scripts/time-runtime/time-bundle-call.ts +2 -2
  116. package/scripts/version/npm-registry.ts +97 -0
  117. package/scripts/version/version-commands.ts +5 -4
  118. 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
- async function open_contract(): Promise<{ checkout: ReviewCheckout; nonce: string }> {
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 whole_change_target(input.checkout.root, input.base)
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, and alias-expanded so `pnpm josh rct --impl` is the same 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. The alias `isf` is expanded where the command
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 (`rc`, `rmg`) before
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`, in either spelling — the alias `nf` is expanded to `notify` before this sees it, the
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` and `pnpm josh rct` are the same
48
- // calls as their canonical spellings. Segment-wise for the reason every other row here is: one shell
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 is_driven_here(read: CarryRead | undefined, ancestry: () => ReadonlySet<number>): boolean {
77
- if (read?.kind !== 'carried' || !is_driving(read)) return false
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
- if (lane_child_marker.marked_issue(source) !== undefined) return false
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
- function format_child(child: ChildObservation): string {
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 `#${child.issue} ${labels} PR:${child.pr_state}`
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>): string {
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
- function format_lanes(lanes: ReadonlyArray<LaneObservation>): string {
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 { STAGE, PHASE } = run_ship_stage
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 four in order, stopping at the first that failed: a red gate never reaches the commit, so
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 gate.
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)