@joshuafolkken/kit 1.957.0 → 1.972.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 (91) 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 +3 -4
  9. package/.claude/skills/workflow-commands/observation-filing.md +31 -25
  10. package/.claude/skills/workflow-commands/pre-gate-cut.md +96 -161
  11. package/.github/workflows/pr-classification.yml +12 -1
  12. package/CLAUDE.md +3 -3
  13. package/README.md +3 -3
  14. package/dist/CLAUDE.md +3 -3
  15. package/dist/hooks/batch-guard.js +4 -4
  16. package/dist/hooks/{chunk-XSL4SRMA.js → chunk-2K52BRWV.js} +2 -2
  17. package/dist/hooks/{chunk-M6K7EQDO.js → chunk-BNWIOJFU.js} +29 -16
  18. package/dist/hooks/{chunk-KOCK5FXQ.js → chunk-BVMSPG3M.js} +3 -3
  19. package/dist/hooks/{chunk-3ON7HOYB.js → chunk-CRGJTD6E.js} +1 -1
  20. package/dist/hooks/{chunk-HJXQZ7V4.js → chunk-F6OM6SIG.js} +3 -3
  21. package/dist/hooks/{chunk-LDQDZYHX.js → chunk-KQG4776P.js} +1 -1
  22. package/dist/hooks/{chunk-244WYOEO.js → chunk-NXL3SBXY.js} +2 -2
  23. package/dist/hooks/{chunk-IJPBTDTB.js → chunk-WWTMHHZU.js} +59 -18
  24. package/dist/hooks/{chunk-BELI5XSS.js → chunk-ZPLXLJ7O.js} +15 -5
  25. package/dist/hooks/{chunk-GNOITLRR.js → chunk-ZYYSBNR6.js} +4 -4
  26. package/dist/hooks/codex-hook-adapter.js +10 -10
  27. package/dist/hooks/duplicate-read-guard.js +5 -5
  28. package/dist/hooks/format-edited.js +2 -2
  29. package/dist/hooks/investigation-guard.js +5 -5
  30. package/dist/hooks/pretool-guard.js +9 -9
  31. package/dist/hooks/stop-guard.js +206 -176
  32. package/dist/josh.js +8 -4
  33. package/package.json +3 -3
  34. package/prompts/collaboration-workflow/wip-cap.md +1 -1
  35. package/scripts/backlog/backlog-arrival.ts +11 -1
  36. package/scripts/backlog/backlog-budget.ts +25 -8
  37. package/scripts/backlog/backlog-next.ts +45 -2
  38. package/scripts/backlog/backlog-offer.ts +1 -0
  39. package/scripts/backlog/backlog-plan-cli.ts +66 -27
  40. package/scripts/backlog/backlog-plan.ts +30 -2
  41. package/scripts/backlog/backlog-stalled.ts +9 -0
  42. package/scripts/backlog/backlog-waves.ts +169 -0
  43. package/scripts/document/document-byte-budget.ts +1 -1
  44. package/scripts/document/entry-read-budget.ts +7 -5
  45. package/scripts/epic/epic-lane-offer.ts +69 -4
  46. package/scripts/epic/epic-next.ts +2 -2
  47. package/scripts/epic/epic-solo.ts +147 -0
  48. package/scripts/epic/epic-triage.ts +47 -0
  49. package/scripts/gate/hook-gate-reuse.ts +7 -8
  50. package/scripts/git/git-closes-keyword.ts +15 -0
  51. package/scripts/git/git-followup-flush.ts +5 -6
  52. package/scripts/git/git-followup-issue-close.ts +75 -0
  53. package/scripts/git/git-pr-followup-wrapup.ts +36 -6
  54. package/scripts/git/git-pr-followup.ts +9 -16
  55. package/scripts/git/git-staging.ts +33 -20
  56. package/scripts/git/issue-labels.ts +23 -0
  57. package/scripts/hooks/stop-guard.ts +1 -0
  58. package/scripts/init/init-install.ts +64 -0
  59. package/scripts/init/init.ts +38 -11
  60. package/scripts/issue/issue-closing-pr.ts +93 -0
  61. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +2 -1
  62. package/scripts/josh/josh-commands-project.ts +5 -1
  63. package/scripts/josh/josh-commands-workflow.ts +3 -3
  64. package/scripts/observations/observation-ledger-prepare.ts +31 -0
  65. package/scripts/observations/observation-ledger.ts +5 -5
  66. package/scripts/observations/observations-flush.ts +11 -30
  67. package/scripts/rules/implementation-cut.ts +49 -16
  68. package/scripts/rules/lane-background.ts +25 -5
  69. package/scripts/rules/stop-rules.ts +16 -4
  70. package/scripts/run/run-carry-cli.ts +21 -0
  71. package/scripts/run/run-cut-cli.ts +92 -110
  72. package/scripts/run/run-cut-report.ts +167 -0
  73. package/scripts/run/run-cut.ts +23 -7
  74. package/scripts/run/run-entry-cli.ts +36 -2
  75. package/scripts/run/run-headless.ts +1 -0
  76. package/scripts/run/run-hold-cli.ts +36 -15
  77. package/scripts/run/run-hold.ts +15 -4
  78. package/scripts/run/run-merge-cli.ts +28 -14
  79. package/scripts/run/run-merge-steps.ts +38 -2
  80. package/scripts/run/run-merge.ts +41 -3
  81. package/scripts/run/run-prep-cli.ts +8 -2
  82. package/scripts/run/run-prep-locate.ts +165 -0
  83. package/scripts/run/run-prep.ts +12 -1
  84. package/scripts/run/run-step-cli.ts +16 -0
  85. package/scripts/run/run-step.ts +28 -4
  86. package/scripts/run/run-tail-cli.ts +4 -2
  87. package/scripts/run/run-wake-work.ts +4 -0
  88. package/scripts/safe-chain/ci-installer-pin.ts +8 -4
  89. package/scripts/time-runtime/time-background.ts +11 -0
  90. package/scripts/time-runtime/time-transcript-line.ts +4 -0
  91. package/templates/pnpm-workspace.static.yaml +2 -0
@@ -67,9 +67,12 @@ interface StopContext {
67
67
  headless_refusals: number
68
68
  // This session is a dispatched lane child for this checkout — `lane_child_marker.is_child_of`.
69
69
  lane_child: boolean
70
- // A background task this run launched has not finished — `lane_background.pending_background_ids`
71
- // over the transcript tail (joshuafolkken/kit#2704).
70
+ // A background task this run launched — a command or a subagent — has not finished —
71
+ // `lane_background.pending_background_ids` over the transcript tail (joshuafolkken/kit#2704).
72
72
  background_pending: boolean
73
+ // A backgrounded subagent this run launched has not finished — `lane_background.pending_agent_ids`
74
+ // (joshuafolkken/kit#2774). Apart from `background_pending`, since only a subagent is sure to end.
75
+ agent_pending: boolean
73
76
  // The resolved `JOSH_SESSION_LANG` — `session_language.resolve_session_lang` (joshuafolkken/kit#2470).
74
77
  session_lang: string
75
78
  }
@@ -221,12 +224,21 @@ function needs_filing(context: StopContext): boolean {
221
224
  return filing_offer.is_first_party_target(context.message, context.session_owner)
222
225
  }
223
226
 
227
+ // **A running background subagent stands both hold rules down** (joshuafolkken/kit#2774). Its completion
228
+ // notice re-invokes the session, so the turn-end is a wait on the run's own work, not on a person — a
229
+ // notify there says "nothing needed" and dilutes the ones that do. A backgrounded command does not
230
+ // count: a dev server never ends, and a stop behind it would leave a person silently waiting. A lane
231
+ // child never reaches this with a task running: `needs_background_wait` answers it first.
232
+ function is_held_idle(context: StopContext): boolean {
233
+ return context.hold_present && !context.agent_pending
234
+ }
235
+
224
236
  function needs_notify(context: StopContext): boolean {
225
- return context.hold_present && !context.notified
237
+ return is_held_idle(context) && !context.notified
226
238
  }
227
239
 
228
240
  function needs_release(context: StopContext): boolean {
229
- return context.hold_present && context.tree_clean
241
+ return is_held_idle(context) && context.tree_clean
230
242
  }
231
243
 
232
244
  function citation_reason(message: string): string | undefined {
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
+ import { cost_cli } from '#scripts/cost-runtime/cost-cli'
4
+ import { cost_verdict } from '#scripts/cost-runtime/cost-verdict'
3
5
  import {
4
6
  run_carry,
5
7
  type CarryClaim,
@@ -44,6 +46,9 @@ const UNKNOWN_VERDICT = 'unknown'
44
46
  // the run carries on uncut rather than paying a cold preamble the accumulation it would shed no longer
45
47
  // covers, exactly as an under-threshold pre-gate cut carries on to the gate.
46
48
  const CAPPED_VERDICT = 'capped'
49
+ // The asking session is over the shared context-cut threshold, so nothing was claimed
50
+ // (joshuafolkken/kit#2760).
51
+ const OVER_VERDICT = 'over'
47
52
  const NO_CUTS = 0
48
53
 
49
54
  const CLAIM_VERDICTS: Record<CarryClaim, string> = {
@@ -235,7 +240,22 @@ function adopt(target: string, request: CarryClaimRequest, is_json: boolean): nu
235
240
  return report_claim(target, read.carry, request, is_json)
236
241
  }
237
242
 
243
+ // **A session already over the shared cut threshold claims nothing** (joshuafolkken/kit#2760). A claim
244
+ // assumed the asking session was fresh, so a `backlogrun` parent that had taken its cut at 233k could
245
+ // retype the keyword in the same conversation and be answered `resumed` by its own hand-off — measured
246
+ // twice, at 232k and 258k. The verdict is `pnpm josh cost --cut`'s, so no second threshold exists; only a
247
+ // measured `over` refuses, because a provider with no transcript cannot be priced at all.
248
+ function report_over(is_json: boolean): number {
249
+ console.error(
250
+ 'This session is over the shared context-cut threshold (`pnpm josh cost --cut` answers `over`), so no run was begun or resumed here. End this conversation and type the invocation again in a fresh session.',
251
+ )
252
+
253
+ return report(OVER_VERDICT, undefined, is_json, FAILURE_EXIT_CODE)
254
+ }
255
+
238
256
  function claim_record(target: string, request: CarryClaimRequest, is_json: boolean): number {
257
+ if (cost_cli.session_verdict() === cost_verdict.OVER_VERDICT) return report_over(is_json)
258
+
239
259
  return request.is_adoption ? adopt(target, request, is_json) : begin(target, request, is_json)
240
260
  }
241
261
 
@@ -425,6 +445,7 @@ const run_carry_cli = {
425
445
  EXPIRED_VERDICT,
426
446
  MISMATCH_VERDICT,
427
447
  NONE_VERDICT,
448
+ OVER_VERDICT,
428
449
  RESUMED_VERDICT,
429
450
  STANDING_VERDICT,
430
451
  UNKNOWN_VERDICT,
@@ -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,6 +2,7 @@
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'
6
7
  import { run_hold_cli } from './run-hold-cli'
7
8
  import { run_next } from './run-next'
@@ -50,9 +51,13 @@ function first_line(out: string): string {
50
51
  }
51
52
 
52
53
  // `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.
54
+ // stderr, so the composite branches on the token and the reader still sees why. `--fullrun` marks the
55
+ // hold as this entry's, since `run:entry` is `fullrun #N`'s alone (joshuafolkken/kit#2760).
54
56
  async function claim_hold(issue_number: string): Promise<string> {
55
- const held = await josh_command.josh_run(['run:hold', issue_number], should_forward_stderr)
57
+ const held = await josh_command.josh_run(
58
+ ['run:hold', issue_number, '--fullrun'],
59
+ should_forward_stderr,
60
+ )
56
61
 
57
62
  return first_line(held.out)
58
63
  }
@@ -96,7 +101,36 @@ function stop_report(issue_number: string, hold: string, cost: string, report: s
96
101
  return emit({ issue_number, hold, cost, verdict: run_entry.NO_VERDICT, report })
97
102
  }
98
103
 
104
+ interface Resume {
105
+ token: string
106
+ code: number
107
+ }
108
+
109
+ // **A resume is asked before the hold is claimed** (joshuafolkken/kit#2760). An implementation cut
110
+ // outside a lane keeps its hold, so the fresh session's `fullrun #N` would be refused `busy` by its own
111
+ // run's hold before it ever reached `run:cut --resume` — the hand-off the cut promised could not land.
112
+ async function ask_resume(issue_number: string): Promise<Resume> {
113
+ const asked = await josh_command.josh_run(
114
+ ['run:cut', '--resume', issue_number],
115
+ should_forward_stderr,
116
+ )
117
+
118
+ return { token: first_line(asked.out), code: asked.code }
119
+ }
120
+
121
+ // Anything but `fresh` is `run:cut --resume`'s answer to act on (`pre-gate-cut.md`), not a new run: the
122
+ // token and its exit code are passed through, and nothing is claimed or read.
123
+ function resume_report(issue_number: string, resume: Resume): number {
124
+ console.info(`entry #${issue_number} — resume: ${resume.token}`)
125
+
126
+ return resume.code
127
+ }
128
+
99
129
  async function open_run(issue_number: string): Promise<number> {
130
+ const resume = await ask_resume(issue_number)
131
+
132
+ if (resume.token !== run_cut_report.FRESH_VERDICT) return resume_report(issue_number, resume)
133
+
100
134
  const hold = await claim_hold(issue_number)
101
135
 
102
136
  if (hold !== run_hold_cli.HOLD_VERDICT) {
@@ -99,6 +99,7 @@ const run_headless = {
99
99
  environment,
100
100
  is_backlog_parent,
101
101
  is_headless,
102
+ is_owned_here,
102
103
  must_keep_waiting,
103
104
  }
104
105