@joshuafolkken/kit 1.957.0 → 1.978.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 (102) hide show
  1. package/.claude/skills/workflow-commands/background-commands.md +3 -0
  2. package/.claude/skills/workflow-commands/backlogrun-child.md +75 -120
  3. package/.claude/skills/workflow-commands/backlogrun-lanes.md +78 -115
  4. package/.claude/skills/workflow-commands/backlogrun-progress.md +82 -146
  5. package/.claude/skills/workflow-commands/backlogrun-steps.md +173 -229
  6. package/.claude/skills/workflow-commands/backlogrun.md +3 -1
  7. package/.claude/skills/workflow-commands/followup.md +3 -0
  8. package/.claude/skills/workflow-commands/fullrun.md +4 -4
  9. package/.claude/skills/workflow-commands/halfrun.md +12 -7
  10. package/.claude/skills/workflow-commands/observation-filing.md +31 -25
  11. package/.claude/skills/workflow-commands/pre-gate-cut.md +96 -161
  12. package/.claude/skills/workflow-commands/working-tree-hold.md +16 -1
  13. package/.github/workflows/pr-classification.yml +12 -1
  14. package/CLAUDE.md +3 -3
  15. package/README.md +25 -21
  16. package/dist/CLAUDE.md +3 -3
  17. package/dist/hooks/batch-guard.js +4 -4
  18. package/dist/hooks/{chunk-XSL4SRMA.js → chunk-2K52BRWV.js} +2 -2
  19. package/dist/hooks/{chunk-M6K7EQDO.js → chunk-BNGGEY3Z.js} +38 -32
  20. package/dist/hooks/{chunk-KOCK5FXQ.js → chunk-BVMSPG3M.js} +3 -3
  21. package/dist/hooks/{chunk-3ON7HOYB.js → chunk-CRGJTD6E.js} +1 -1
  22. package/dist/hooks/{chunk-HJXQZ7V4.js → chunk-F6OM6SIG.js} +3 -3
  23. package/dist/hooks/{chunk-LDQDZYHX.js → chunk-KQG4776P.js} +1 -1
  24. package/dist/hooks/{chunk-244WYOEO.js → chunk-NXL3SBXY.js} +2 -2
  25. package/dist/hooks/{chunk-IJPBTDTB.js → chunk-Q7LE77CO.js} +88 -18
  26. package/dist/hooks/{chunk-BELI5XSS.js → chunk-ZPLXLJ7O.js} +15 -5
  27. package/dist/hooks/{chunk-GNOITLRR.js → chunk-ZYYSBNR6.js} +4 -4
  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 +2 -2
  31. package/dist/hooks/investigation-guard.js +5 -5
  32. package/dist/hooks/pretool-guard.js +9 -9
  33. package/dist/hooks/stop-guard.js +206 -176
  34. package/dist/josh.js +8 -4
  35. package/package.json +4 -4
  36. package/prompts/collaboration-workflow/wip-cap.md +1 -1
  37. package/scripts/backlog/backlog-arrival.ts +11 -1
  38. package/scripts/backlog/backlog-budget.ts +25 -8
  39. package/scripts/backlog/backlog-next.ts +45 -2
  40. package/scripts/backlog/backlog-offer.ts +1 -0
  41. package/scripts/backlog/backlog-plan-cli.ts +66 -27
  42. package/scripts/backlog/backlog-plan.ts +30 -2
  43. package/scripts/backlog/backlog-stalled.ts +9 -0
  44. package/scripts/backlog/backlog-waves.ts +169 -0
  45. package/scripts/ci/pr-classification.ts +13 -12
  46. package/scripts/document/document-byte-budget.ts +1 -1
  47. package/scripts/document/entry-read-budget.ts +7 -5
  48. package/scripts/epic/epic-lane-offer.ts +69 -4
  49. package/scripts/epic/epic-next.ts +2 -2
  50. package/scripts/epic/epic-solo.ts +147 -0
  51. package/scripts/epic/epic-triage.ts +47 -0
  52. package/scripts/gate/hook-gate-reuse.ts +7 -8
  53. package/scripts/git/git-closes-keyword.ts +15 -0
  54. package/scripts/git/git-followup-flush.ts +5 -6
  55. package/scripts/git/git-followup-issue-close.ts +75 -0
  56. package/scripts/git/git-pr-followup-wrapup.ts +36 -6
  57. package/scripts/git/git-pr-followup.ts +9 -16
  58. package/scripts/git/git-staging.ts +33 -20
  59. package/scripts/git/issue-labels.ts +75 -5
  60. package/scripts/hooks/stop-guard.ts +1 -0
  61. package/scripts/init/init-bootstrap.ts +92 -0
  62. package/scripts/init/init-install.ts +67 -0
  63. package/scripts/init/init-logic-workspace.ts +1 -1
  64. package/scripts/init/init.ts +58 -13
  65. package/scripts/init/start-plan.ts +1 -1
  66. package/scripts/init/start-steps.ts +5 -28
  67. package/scripts/issue/issue-closing-pr.ts +93 -0
  68. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +2 -1
  69. package/scripts/josh/josh-commands-project.ts +5 -1
  70. package/scripts/josh/josh-commands-workflow.ts +3 -3
  71. package/scripts/observations/observation-ledger-prepare.ts +31 -0
  72. package/scripts/observations/observation-ledger.ts +5 -5
  73. package/scripts/observations/observations-flush.ts +11 -30
  74. package/scripts/repo/repository-labels.ts +91 -0
  75. package/scripts/rules/implementation-cut.ts +49 -16
  76. package/scripts/rules/lane-background.ts +25 -5
  77. package/scripts/rules/stop-rules.ts +16 -4
  78. package/scripts/run/run-carry-cli.ts +21 -0
  79. package/scripts/run/run-cut-cli.ts +92 -110
  80. package/scripts/run/run-cut-report.ts +167 -0
  81. package/scripts/run/run-cut.ts +23 -7
  82. package/scripts/run/run-entry-cli.ts +70 -3
  83. package/scripts/run/run-halfrun-resume.ts +104 -0
  84. package/scripts/run/run-headless.ts +1 -0
  85. package/scripts/run/run-hold-cli.ts +57 -13
  86. package/scripts/run/run-hold.ts +26 -4
  87. package/scripts/run/run-merge-cli.ts +28 -14
  88. package/scripts/run/run-merge-steps.ts +38 -2
  89. package/scripts/run/run-merge.ts +41 -3
  90. package/scripts/run/run-prep-cli.ts +8 -2
  91. package/scripts/run/run-prep-locate.ts +165 -0
  92. package/scripts/run/run-prep.ts +12 -1
  93. package/scripts/run/run-step-cli.ts +16 -0
  94. package/scripts/run/run-step.ts +28 -4
  95. package/scripts/run/run-tail-cli.ts +4 -2
  96. package/scripts/run/run-wake-work.ts +4 -0
  97. package/scripts/safe-chain/ci-installer-pin.ts +8 -4
  98. package/scripts/security/security-audit.ts +12 -1
  99. package/scripts/sync/sync.ts +6 -0
  100. package/scripts/time-runtime/time-background.ts +11 -0
  101. package/scripts/time-runtime/time-transcript-line.ts +4 -0
  102. package/templates/pnpm-workspace.static.yaml +2 -0
@@ -9,6 +9,7 @@ import { openai_lane_supervisor } from '#scripts/lane/openai-lane-supervisor'
9
9
  import { run_cut, type CutState, type RunCut } from './run-cut'
10
10
  import { run_cut_args, type Request } from './run-cut-args'
11
11
  import { run_cut_handoff } from './run-cut-handoff'
12
+ import { run_cut_report } from './run-cut-report'
12
13
  import { run_event_stream } from './run-event-stream'
13
14
  import { run_event_stream_emit } from './run-event-stream-emit'
14
15
 
@@ -24,115 +25,41 @@ import { run_event_stream_emit } from './run-event-stream-emit'
24
25
  // session.
25
26
 
26
27
  const ARGV_OFFSET = 2
27
- const SUCCESS_EXIT_CODE = 0
28
- const FAILURE_EXIT_CODE = 1
29
-
30
- const CUT_VERDICT = 'cut'
31
- const RESUME_VERDICT = 'resume'
32
- // An implementation-phase resume: the fresh process continues implementing rather than going to the
33
- // gate (joshuafolkken/kit#1933). It is a distinct token so the resuming child branches on it without
34
- // re-reading the record.
35
- const RESUME_IMPL_VERDICT = 'resume-impl'
36
- const FRESH_VERDICT = 'fresh'
37
- // The recent-window context is under the shared cut threshold, so the pre-gate cut was not worth its
38
- // resume and nothing was cut (joshuafolkken/kit#2312). A benign, non-failing answer: the run carries
39
- // on to the gate itself, exactly as `not-a-lane` does.
40
- const UNDER_THRESHOLD_VERDICT = 'under-threshold'
41
- const STALE_VERDICT = 'stale'
42
- // The record matched the tree but carried no instruction to resume on, so the run was refused rather
43
- // than continued blind to what it was told to do (joshuafolkken/kit#2354).
44
- const INCOMPLETE_VERDICT = 'incomplete'
45
- // The `--handoff` path was given but could not be read as a handoff, or was too large for the record;
46
- // the cut is refused rather than taken without the instruction it was meant to carry.
47
- const BAD_HANDOFF_VERDICT = 'bad-handoff'
48
- const HANDED_OFF_VERDICT = 'handed-off'
49
- const BUSY_VERDICT = 'busy'
50
- const NOT_A_LANE_VERDICT = 'not-a-lane'
51
- const UNREADY_VERDICT = 'unready'
52
- const FAILED_VERDICT = 'failed'
53
- const ENDED_VERDICT = 'ended'
54
- const UNREADABLE_VERDICT = 'unreadable'
55
- const UNKNOWN_VERDICT = 'unknown'
56
-
57
- function report(verdict: string, code: number): number {
58
- console.info(verdict)
59
-
60
- return code
61
- }
62
-
63
- function report_stale(cut_record: RunCut): number {
64
- console.error(run_cut.stale_message(cut_record))
65
-
66
- return report(STALE_VERDICT, FAILURE_EXIT_CODE)
67
- }
68
-
69
- function report_busy(cut_record: RunCut): number {
70
- console.error(run_cut.busy_message(cut_record))
71
28
 
72
- return report(BUSY_VERDICT, FAILURE_EXIT_CODE)
73
- }
74
-
75
- // The record matched the tree but carried no instruction; the run is refused rather than continued
76
- // without what it was told to do (joshuafolkken/kit#2354).
77
- function report_incomplete(cut_record: RunCut): number {
78
- console.error(run_cut.incomplete_message(cut_record))
79
-
80
- return report(INCOMPLETE_VERDICT, FAILURE_EXIT_CODE)
81
- }
82
-
83
- // The `--handoff` file parsed but the assembled record would not fit the byte bound — the scalar fields
84
- // carry a handoff that fit alone past the cap (joshuafolkken/kit#2354).
85
- const HANDOFF_OVERFLOW_NOTE = '--handoff <path> would grow the cut record past its byte bound'
86
- // An implementation cut resumes into implementation, and that resume refuses a record with no
87
- // instruction (`incomplete`) — so the cut is refused here instead of relaunching a successor that can
88
- // only stop (joshuafolkken/kit#2484).
89
- const HANDOFF_MISSING_NOTE = 'this cut resumes into implementation and needs --handoff <path>'
90
-
91
- function report_bad_handoff(note: string): number {
92
- console.error(`${note}. Nothing was cut; write the handoff file and reissue.`)
93
-
94
- return report(BAD_HANDOFF_VERDICT, FAILURE_EXIT_CODE)
95
- }
96
-
97
- // A successor already resumed this cut; a process woken after its own hand-off is told to stop rather
98
- // than to investigate (joshuafolkken/kit#1935). It is a benign, non-failing stop.
99
- function report_handed_off(cut_record: RunCut): number {
100
- console.error(run_cut.handed_off_message(cut_record))
101
-
102
- return report(HANDED_OFF_VERDICT, SUCCESS_EXIT_CODE)
103
- }
104
-
105
- function report_unreadable(): number {
106
- console.error(run_cut.unreadable_message())
107
-
108
- return report(UNREADABLE_VERDICT, FAILURE_EXIT_CODE)
109
- }
110
-
111
- function report_unknown(): number {
112
- console.error(run_cut.unknown_message())
113
-
114
- return report(UNKNOWN_VERDICT, FAILURE_EXIT_CODE)
115
- }
116
-
117
- // A cut is only meaningful on a lane branch whose implementation is uncommitted. On the default
118
- // branch, or with a clean tree, there is nothing to carry across the boundary.
119
- function refuse_unready(state: CutState): number {
120
- console.error(
121
- `Not ready to cut on ${state.branch} (dirty: ${String(state.is_dirty)}); a cut needs an uncommitted lane branch. Nothing was cut.`,
122
- )
123
-
124
- return report(UNREADY_VERDICT, FAILURE_EXIT_CODE)
125
- }
126
-
127
- // Below the shared context threshold there is no accumulation a cut would drop, so the resume it would
128
- // relaunch costs more than it saves; the current process carries the run on to the gate uncut.
129
- function report_under_threshold(): number {
130
- console.error(
131
- 'The recent-window context is under the shared cut threshold, so nothing was cut; a resume would cost more than the accumulation it would drop. Continue to the gate.',
132
- )
133
-
134
- return report(UNDER_THRESHOLD_VERDICT, SUCCESS_EXIT_CODE)
135
- }
29
+ const {
30
+ BAD_HANDOFF_VERDICT,
31
+ BUSY_VERDICT,
32
+ CUT_VERDICT,
33
+ ENDED_VERDICT,
34
+ FAILED_VERDICT,
35
+ FAILURE_EXIT_CODE,
36
+ FRESH_VERDICT,
37
+ HANDED_OFF_VERDICT,
38
+ HANDOFF_MISSING_NOTE,
39
+ HANDOFF_OVERFLOW_NOTE,
40
+ INCOMPLETE_VERDICT,
41
+ NOT_A_LANE_VERDICT,
42
+ OVER_VERDICT,
43
+ RESUME_IMPL_VERDICT,
44
+ RESUME_VERDICT,
45
+ STALE_VERDICT,
46
+ SUCCESS_EXIT_CODE,
47
+ UNDER_THRESHOLD_VERDICT,
48
+ UNKNOWN_VERDICT,
49
+ UNREADABLE_VERDICT,
50
+ UNREADY_VERDICT,
51
+ refuse_unready,
52
+ report,
53
+ report_bad_handoff,
54
+ report_busy,
55
+ report_handed_off,
56
+ report_incomplete,
57
+ report_over,
58
+ report_stale,
59
+ report_under_threshold,
60
+ report_unknown,
61
+ report_unreadable,
62
+ } = run_cut_report
136
63
 
137
64
  // **The pre-gate cut is conditional on the same statistic the implementation-phase cut reads**
138
65
  // (joshuafolkken/kit#2312). `cost_cli.session_verdict` prices the recent `RECENT_REQUEST_WINDOW`
@@ -240,7 +167,9 @@ function spec_refusal(spec: Parameters<typeof run_cut.begin_cut>[1]): string | u
240
167
  return undefined
241
168
  }
242
169
 
243
- async function finish_cut(target: string, lane: LaneInfo, request: CutRequest): Promise<number> {
170
+ // Writes the record and appends the `cut` event, or answers the refusal that stopped it — the half of a
171
+ // cut every checkout shares; what follows the write (a relaunch or a stop) is the caller's.
172
+ async function write_cut(target: string, request: CutRequest): Promise<number | undefined> {
244
173
  const loaded = run_cut_handoff.load_handoff(request.handoff_path, run_cut.MAX_HANDOFF_BYTES)
245
174
  if (loaded.kind === 'bad') return report_bad_handoff(loaded.note)
246
175
 
@@ -258,11 +187,53 @@ async function finish_cut(target: string, lane: LaneInfo, request: CutRequest):
258
187
 
259
188
  await emit_cut_event(request)
260
189
 
190
+ return undefined
191
+ }
192
+
193
+ async function finish_cut(target: string, lane: LaneInfo, request: CutRequest): Promise<number> {
194
+ const refused = await write_cut(target, request)
195
+
196
+ if (refused !== undefined) return refused
197
+
261
198
  return lane_relaunch.is_openai_lane(lane)
262
199
  ? report(CUT_VERDICT, SUCCESS_EXIT_CODE)
263
200
  : relaunch(target, lane, request.phase)
264
201
  }
265
202
 
203
+ // The cut outside a lane is taken, and nothing is relaunched: the session that took it is the one a
204
+ // person is watching, so the run is handed on the way `fullrun`'s entry `over` stop hands it on.
205
+ function report_held_cut(issue: string): number {
206
+ console.error(
207
+ `The cut is recorded for #${issue} outside a lane, so nothing was relaunched. Keep the hold, send a \`confirmation\` Telegram whose body names the resume command \`fullrun #${issue}\`, and end the turn; the fresh session's \`pnpm josh run:cut --resume ${issue}\` answers \`resume-impl\` with the handoff.`,
208
+ )
209
+
210
+ return report(CUT_VERDICT, SUCCESS_EXIT_CODE)
211
+ }
212
+
213
+ // **A `fullrun` held in its own checkout takes the implementation cut too** (joshuafolkken/kit#2760).
214
+ // The cut was lane-only, so a run a person started had no bound on its context mid-implementation. The
215
+ // hold naming this issue is what marks the run — a tree nobody holds for it, and the pre-gate phase
216
+ // (which a held run's gate reaches in the same session), stay `not-a-lane` exactly as before.
217
+ async function cut_outside_lane(
218
+ target: string,
219
+ request: Omit<CutRequest, 'branch'>,
220
+ ): Promise<number> {
221
+ if (request.phase !== run_cut.IMPLEMENTATION_PHASE) {
222
+ return report(NOT_A_LANE_VERDICT, SUCCESS_EXIT_CODE)
223
+ }
224
+
225
+ const state = await run_cut.current_state()
226
+
227
+ if (state.held_issue !== request.issue) return report(NOT_A_LANE_VERDICT, SUCCESS_EXIT_CODE)
228
+ if (!(await is_lane_branch(state))) return refuse_unready(state)
229
+
230
+ clear_expired(target)
231
+
232
+ const refused = await write_cut(target, { ...request, branch: state.branch })
233
+
234
+ return refused ?? report_held_cut(request.issue)
235
+ }
236
+
266
237
  async function cut(
267
238
  target: string,
268
239
  issue: string,
@@ -271,7 +242,7 @@ async function cut(
271
242
  ): Promise<number> {
272
243
  const lane = await lane_registry.find_open_lane(issue)
273
244
 
274
- if (lane === undefined) return report(NOT_A_LANE_VERDICT, SUCCESS_EXIT_CODE)
245
+ if (lane === undefined) return await cut_outside_lane(target, { issue, phase, handoff_path })
275
246
 
276
247
  const state = await run_cut.current_state()
277
248
 
@@ -331,6 +302,14 @@ function adopt(target: string, cut_record: RunCut): number {
331
302
  return report(resume_verdict_for(cut_record), SUCCESS_EXIT_CODE)
332
303
  }
333
304
 
305
+ // A relaunched lane child and a fresh session both measure `under`, so only the session that took an
306
+ // implementation cut resumes over the threshold (joshuafolkken/kit#2760); unmeasurable is not refused.
307
+ function is_over_threshold_resume(cut_record: RunCut): boolean {
308
+ if (!run_cut.resumes_into_implementation(cut_record.phase)) return false
309
+
310
+ return cost_cli.session_verdict() === cost_verdict.OVER_VERDICT
311
+ }
312
+
334
313
  async function verify_and_adopt(
335
314
  target: string,
336
315
  cut_record: RunCut,
@@ -350,6 +329,8 @@ async function verify_and_adopt(
350
329
 
351
330
  if (verdict === INCOMPLETE_VERDICT) return report_incomplete(cut_record)
352
331
 
332
+ if (is_over_threshold_resume(cut_record)) return report_over()
333
+
353
334
  return adopt(target, cut_record)
354
335
  }
355
336
 
@@ -444,6 +425,7 @@ const run_cut_cli = {
444
425
  HANDED_OFF_VERDICT,
445
426
  INCOMPLETE_VERDICT,
446
427
  NOT_A_LANE_VERDICT,
428
+ OVER_VERDICT,
447
429
  RESUME_IMPL_VERDICT,
448
430
  RESUME_VERDICT,
449
431
  STALE_VERDICT,
@@ -0,0 +1,167 @@
1
+ import { run_cut, type CutState, type RunCut } from './run-cut'
2
+
3
+ // The verdict tokens `josh run:cut` answers with and the reports that print them, split out of
4
+ // `run-cut-cli.ts` so the CLI keeps to acting on the record. The contract is unchanged: **standard
5
+ // output carries exactly one token** and every explanation goes to standard error.
6
+
7
+ const SUCCESS_EXIT_CODE = 0
8
+ const FAILURE_EXIT_CODE = 1
9
+
10
+ const CUT_VERDICT = 'cut'
11
+ const RESUME_VERDICT = 'resume'
12
+ // An implementation-phase resume: the fresh process continues implementing rather than going to the
13
+ // gate (joshuafolkken/kit#1933). It is a distinct token so the resuming child branches on it without
14
+ // re-reading the record.
15
+ const RESUME_IMPL_VERDICT = 'resume-impl'
16
+ const FRESH_VERDICT = 'fresh'
17
+ // The recent-window context is under the shared cut threshold, so the pre-gate cut was not worth its
18
+ // resume and nothing was cut (joshuafolkken/kit#2312). A benign, non-failing answer: the run carries
19
+ // on to the gate itself, exactly as `not-a-lane` does.
20
+ const UNDER_THRESHOLD_VERDICT = 'under-threshold'
21
+ const STALE_VERDICT = 'stale'
22
+ // The record matched the tree but carried no instruction to resume on, so the run was refused rather
23
+ // than continued blind to what it was told to do (joshuafolkken/kit#2354).
24
+ const INCOMPLETE_VERDICT = 'incomplete'
25
+ // The `--handoff` path was given but could not be read as a handoff, or was too large for the record;
26
+ // the cut is refused rather than taken without the instruction it was meant to carry.
27
+ const BAD_HANDOFF_VERDICT = 'bad-handoff'
28
+ const HANDED_OFF_VERDICT = 'handed-off'
29
+ const BUSY_VERDICT = 'busy'
30
+ const NOT_A_LANE_VERDICT = 'not-a-lane'
31
+ const UNREADY_VERDICT = 'unready'
32
+ const FAILED_VERDICT = 'failed'
33
+ const ENDED_VERDICT = 'ended'
34
+ const UNREADABLE_VERDICT = 'unreadable'
35
+ const UNKNOWN_VERDICT = 'unknown'
36
+ // The asking session is over the shared context-cut threshold, so an implementation cut was not
37
+ // resumed into it (joshuafolkken/kit#2760) — the same refusal `run:carry` answers `over` with.
38
+ const OVER_VERDICT = 'over'
39
+
40
+ function report(verdict: string, code: number): number {
41
+ console.info(verdict)
42
+
43
+ return code
44
+ }
45
+
46
+ function report_stale(cut_record: RunCut): number {
47
+ console.error(run_cut.stale_message(cut_record))
48
+
49
+ return report(STALE_VERDICT, FAILURE_EXIT_CODE)
50
+ }
51
+
52
+ function report_busy(cut_record: RunCut): number {
53
+ console.error(run_cut.busy_message(cut_record))
54
+
55
+ return report(BUSY_VERDICT, FAILURE_EXIT_CODE)
56
+ }
57
+
58
+ // The record matched the tree but carried no instruction; the run is refused rather than continued
59
+ // without what it was told to do (joshuafolkken/kit#2354).
60
+ function report_incomplete(cut_record: RunCut): number {
61
+ console.error(run_cut.incomplete_message(cut_record))
62
+
63
+ return report(INCOMPLETE_VERDICT, FAILURE_EXIT_CODE)
64
+ }
65
+
66
+ // The `--handoff` file parsed but the assembled record would not fit the byte bound — the scalar fields
67
+ // carry a handoff that fit alone past the cap (joshuafolkken/kit#2354).
68
+ const HANDOFF_OVERFLOW_NOTE = '--handoff <path> would grow the cut record past its byte bound'
69
+ // An implementation cut resumes into implementation, and that resume refuses a record with no
70
+ // instruction (`incomplete`) — so the cut is refused here instead of relaunching a successor that can
71
+ // only stop (joshuafolkken/kit#2484).
72
+ const HANDOFF_MISSING_NOTE = 'this cut resumes into implementation and needs --handoff <path>'
73
+
74
+ function report_bad_handoff(note: string): number {
75
+ console.error(`${note}. Nothing was cut; write the handoff file and reissue.`)
76
+
77
+ return report(BAD_HANDOFF_VERDICT, FAILURE_EXIT_CODE)
78
+ }
79
+
80
+ // A successor already resumed this cut; a process woken after its own hand-off is told to stop rather
81
+ // than to investigate (joshuafolkken/kit#1935). It is a benign, non-failing stop.
82
+ function report_handed_off(cut_record: RunCut): number {
83
+ console.error(run_cut.handed_off_message(cut_record))
84
+
85
+ return report(HANDED_OFF_VERDICT, SUCCESS_EXIT_CODE)
86
+ }
87
+
88
+ function report_unreadable(): number {
89
+ console.error(run_cut.unreadable_message())
90
+
91
+ return report(UNREADABLE_VERDICT, FAILURE_EXIT_CODE)
92
+ }
93
+
94
+ function report_unknown(): number {
95
+ console.error(run_cut.unknown_message())
96
+
97
+ return report(UNKNOWN_VERDICT, FAILURE_EXIT_CODE)
98
+ }
99
+
100
+ // A cut is only meaningful on a lane branch whose implementation is uncommitted. On the default
101
+ // branch, or with a clean tree, there is nothing to carry across the boundary.
102
+ function refuse_unready(state: CutState): number {
103
+ console.error(
104
+ `Not ready to cut on ${state.branch} (dirty: ${String(state.is_dirty)}); a cut needs an uncommitted lane branch. Nothing was cut.`,
105
+ )
106
+
107
+ return report(UNREADY_VERDICT, FAILURE_EXIT_CODE)
108
+ }
109
+
110
+ // Below the shared context threshold there is no accumulation a cut would drop, so the resume it would
111
+ // relaunch costs more than it saves; the current process carries the run on to the gate uncut.
112
+ function report_under_threshold(): number {
113
+ console.error(
114
+ 'The recent-window context is under the shared cut threshold, so nothing was cut; a resume would cost more than the accumulation it would drop. Continue to the gate.',
115
+ )
116
+
117
+ return report(UNDER_THRESHOLD_VERDICT, SUCCESS_EXIT_CODE)
118
+ }
119
+
120
+ // **An implementation cut is never resumed by a session over the threshold** (joshuafolkken/kit#2760).
121
+ // The cut outside a lane leaves the session that took it open, so a person retyping `fullrun #N` there
122
+ // would be answered `resume-impl`, keep implementing over the threshold, and be cut again on the next
123
+ // edit — a loop that sheds no context. The record is left intact for a fresh session.
124
+ function report_over(): number {
125
+ console.error(
126
+ 'This session is over the shared context-cut threshold (`pnpm josh cost --cut` answers `over`), so the implementation cut was not resumed here and its record is kept. End this conversation and type the invocation again in a fresh session.',
127
+ )
128
+
129
+ return report(OVER_VERDICT, FAILURE_EXIT_CODE)
130
+ }
131
+
132
+ const run_cut_report = {
133
+ BAD_HANDOFF_VERDICT,
134
+ BUSY_VERDICT,
135
+ CUT_VERDICT,
136
+ ENDED_VERDICT,
137
+ FAILED_VERDICT,
138
+ FAILURE_EXIT_CODE,
139
+ FRESH_VERDICT,
140
+ HANDED_OFF_VERDICT,
141
+ HANDOFF_MISSING_NOTE,
142
+ HANDOFF_OVERFLOW_NOTE,
143
+ INCOMPLETE_VERDICT,
144
+ NOT_A_LANE_VERDICT,
145
+ OVER_VERDICT,
146
+ RESUME_IMPL_VERDICT,
147
+ RESUME_VERDICT,
148
+ STALE_VERDICT,
149
+ SUCCESS_EXIT_CODE,
150
+ UNDER_THRESHOLD_VERDICT,
151
+ UNKNOWN_VERDICT,
152
+ UNREADABLE_VERDICT,
153
+ UNREADY_VERDICT,
154
+ refuse_unready,
155
+ report,
156
+ report_bad_handoff,
157
+ report_busy,
158
+ report_handed_off,
159
+ report_incomplete,
160
+ report_over,
161
+ report_stale,
162
+ report_under_threshold,
163
+ report_unknown,
164
+ report_unreadable,
165
+ }
166
+
167
+ export { run_cut_report }
@@ -9,7 +9,7 @@ import { stamp_file } from '#scripts/josh/stamp-file'
9
9
  import { lane_child_invocation } from '#scripts/lane/lane-child-invocation'
10
10
  import { z } from 'zod'
11
11
  import { run_cut_handoff, type Handoff } from './run-cut-handoff'
12
- import { run_hold } from './run-hold'
12
+ import { run_hold, type RunHold } from './run-hold'
13
13
 
14
14
  // joshuafolkken/kit#1839: a lane child is a detached `fullrun #<N>` process, and the thinking it
15
15
  // accumulates while implementing rides on every later API call in the same session — measured at 176K
@@ -118,6 +118,8 @@ interface CutState {
118
118
  branch: string
119
119
  is_dirty: boolean
120
120
  is_held: boolean
121
+ // The issue a `fullrun` hold names, absent when the tree is not held by one (joshuafolkken/kit#2760).
122
+ held_issue?: string | undefined
121
123
  }
122
124
 
123
125
  // The fields a cut is written from, bundled so `begin_cut` stays within the parameter limit. `phase`
@@ -432,22 +434,34 @@ function end_cut(target: string): void {
432
434
  stamp_file.remove_stamp(target)
433
435
  }
434
436
 
435
- // Whether a run hold still stands over this tree — present as `held` or as an aged-out `stale`, but
437
+ // The run hold still standing over this tree — present as `held` or as an aged-out `stale`, but
436
438
  // present. A tree with no hold at all is not the one a cut was taken in, so the resume refuses it.
437
- function tree_is_held(directory: string | undefined): boolean {
438
- if (directory === undefined) return false
439
+ function hold_of(directory: string | undefined): RunHold | undefined {
440
+ if (directory === undefined) return undefined
439
441
 
440
442
  const read = run_hold.read_hold(run_hold.hold_path(directory))
441
443
 
442
- return read.kind === 'held' || read.kind === 'stale'
444
+ return read.kind === 'held' || read.kind === 'stale' ? read.hold : undefined
445
+ }
446
+
447
+ // The issue a `fullrun` holds this tree for — what tells a `fullrun` held in its own checkout apart from
448
+ // a person's tree (joshuafolkken/kit#2760). A `halfrun` or an in-session `backlogrun` child holds it
449
+ // too, but its cut would be resumed as `fullrun #N`, so only a hold `run:entry` marked names an issue.
450
+ function fullrun_issue(hold: RunHold | undefined): string | undefined {
451
+ return hold?.is_fullrun === true ? hold.issue : undefined
452
+ }
453
+
454
+ // The same read taken synchronously, for the `PreToolUse` implementation-cut guard.
455
+ function held_issue_sync(): string | undefined {
456
+ return fullrun_issue(hold_of(worktree_git_directory_sync()))
443
457
  }
444
458
 
445
459
  async function current_state(): Promise<CutState> {
446
460
  const branch = await git_command.branch()
447
461
  const is_dirty = await run_hold.is_tree_dirty()
448
- const directory = await worktree_directory()
462
+ const hold = hold_of(await worktree_directory())
449
463
 
450
- return { branch, is_dirty, is_held: tree_is_held(directory) }
464
+ return { branch, is_dirty, is_held: hold !== undefined, held_issue: fullrun_issue(hold) }
451
465
  }
452
466
 
453
467
  function describe_cut(cut: RunCut): string {
@@ -508,8 +522,10 @@ const run_cut = {
508
522
  describe_cut,
509
523
  end_cut,
510
524
  fresh_cut,
525
+ fullrun_issue,
511
526
  handed_off_message,
512
527
  has_required_handoff,
528
+ held_issue_sync,
513
529
  incomplete_message,
514
530
  invocation_for,
515
531
  is_expired,
@@ -2,7 +2,9 @@
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { josh_command } from '#scripts/josh/josh-run'
4
4
  import { lane_child_marker } from '#scripts/lane/lane-child-marker'
5
+ import { run_cut_report } from './run-cut-report'
5
6
  import { run_entry, type EntryParts } from './run-entry'
7
+ import { run_halfrun_resume } from './run-halfrun-resume'
6
8
  import { run_hold_cli } from './run-hold-cli'
7
9
  import { run_next } from './run-next'
8
10
  import { run_prep } from './run-prep'
@@ -32,6 +34,7 @@ const NEWLINE = '\n'
32
34
  const HOLD_STOP_NOTE =
33
35
  '(tree not held — see the reason above; clean up and retry, or release with `pnpm josh run:release`)'
34
36
  const COST_STOP_NOTE = '(session budget spent — see the figure above; resume in a fresh session)'
37
+ const HALFRUN_RESUME_TOKEN = 'halfrun'
35
38
 
36
39
  // Exactly one issue number, or the call is refused — `run:entry` opens one run, and a second number
37
40
  // would claim and read a second issue a caller reads as this one's.
@@ -50,9 +53,13 @@ function first_line(out: string): string {
50
53
  }
51
54
 
52
55
  // `run:hold <N>` prints one token — `hold`, `busy` or `unknown` — and forwards its explanation to
53
- // stderr, so the composite branches on the token and the reader still sees why.
56
+ // stderr, so the composite branches on the token and the reader still sees why. `--fullrun` marks the
57
+ // hold as this entry's, since `run:entry` is `fullrun #N`'s alone (joshuafolkken/kit#2760).
54
58
  async function claim_hold(issue_number: string): Promise<string> {
55
- const held = await josh_command.josh_run(['run:hold', issue_number], should_forward_stderr)
59
+ const held = await josh_command.josh_run(
60
+ ['run:hold', issue_number, '--fullrun'],
61
+ should_forward_stderr,
62
+ )
56
63
 
57
64
  return first_line(held.out)
58
65
  }
@@ -96,7 +103,58 @@ function stop_report(issue_number: string, hold: string, cost: string, report: s
96
103
  return emit({ issue_number, hold, cost, verdict: run_entry.NO_VERDICT, report })
97
104
  }
98
105
 
106
+ interface Resume {
107
+ token: string
108
+ code: number
109
+ }
110
+
111
+ // **A resume is asked before the hold is claimed** (joshuafolkken/kit#2760). An implementation cut
112
+ // outside a lane keeps its hold, so the fresh session's `fullrun #N` would be refused `busy` by its own
113
+ // run's hold before it ever reached `run:cut --resume` — the hand-off the cut promised could not land.
114
+ async function ask_resume(issue_number: string): Promise<Resume> {
115
+ const asked = await josh_command.josh_run(
116
+ ['run:cut', '--resume', issue_number],
117
+ should_forward_stderr,
118
+ )
119
+
120
+ return { token: first_line(asked.out), code: asked.code }
121
+ }
122
+
123
+ // Anything but `fresh` is `run:cut --resume`'s answer to act on (`pre-gate-cut.md`), not a new run: the
124
+ // token and its exit code are passed through, and nothing is claimed or read.
125
+ function resume_report(issue_number: string, resume: Resume): number {
126
+ console.info(`entry #${issue_number} — resume: ${resume.token}`)
127
+
128
+ return resume.code
129
+ }
130
+
131
+ // **A `halfrun` stopped before its commit is resumed, not claimed** (joshuafolkken/kit#2796): its hold
132
+ // is kept over the verified diff, so the claim below would answer `busy` against it. The budget is asked
133
+ // first, as for any run; then the hold is adopted and the run goes to the gate, skipping everything up
134
+ // to and including implementation.
135
+ async function resume_halfrun(issue_number: string): Promise<number> {
136
+ const cost = await check_cost()
137
+
138
+ if (cost === run_entry.COST_OVER) {
139
+ return stop_report(issue_number, HALFRUN_RESUME_TOKEN, cost, COST_STOP_NOTE)
140
+ }
141
+
142
+ if (!(await run_halfrun_resume.adopt(issue_number))) {
143
+ return stop_report(issue_number, run_hold_cli.BUSY_VERDICT, cost, HOLD_STOP_NOTE)
144
+ }
145
+
146
+ console.info(`entry #${issue_number} — resume: ${HALFRUN_RESUME_TOKEN}`)
147
+
148
+ return SUCCESS_EXIT_CODE
149
+ }
150
+
99
151
  async function open_run(issue_number: string): Promise<number> {
152
+ const resume = await ask_resume(issue_number)
153
+
154
+ if (resume.token !== run_cut_report.FRESH_VERDICT) return resume_report(issue_number, resume)
155
+
156
+ if (await run_halfrun_resume.is_pending(issue_number)) return await resume_halfrun(issue_number)
157
+
100
158
  const hold = await claim_hold(issue_number)
101
159
 
102
160
  if (hold !== run_hold_cli.HOLD_VERDICT) {
@@ -128,7 +186,16 @@ async function main(argv: ReadonlyArray<string>): Promise<void> {
128
186
  process.exitCode = await run(argv)
129
187
  }
130
188
 
131
- const run_entry_cli = { SUCCESS_EXIT_CODE, USAGE, check_cost, claim_hold, main, parse_number, run }
189
+ const run_entry_cli = {
190
+ HALFRUN_RESUME_TOKEN,
191
+ SUCCESS_EXIT_CODE,
192
+ USAGE,
193
+ check_cost,
194
+ claim_hold,
195
+ main,
196
+ parse_number,
197
+ run,
198
+ }
132
199
 
133
200
  if (process.argv[1] === fileURLToPath(import.meta.url)) await main(process.argv.slice(ARGV_OFFSET))
134
201