@joshuafolkken/kit 1.1379.0 → 1.1387.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 (84) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-progress.md +2 -3
  2. package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -4
  3. package/.claude/skills/workflow-commands/retrospective.md +1 -2
  4. package/dist/hooks/batch-guard.js +6 -6
  5. package/dist/hooks/{chunk-RW4XMF47.js → chunk-2YSW3CXH.js} +133 -234
  6. package/dist/hooks/{chunk-4RWRP3FF.js → chunk-DCY2GWHC.js} +5 -5
  7. package/dist/hooks/{chunk-DHSKBAQ2.js → chunk-EAQEGME7.js} +1 -1
  8. package/dist/hooks/{chunk-E7LZL5AN.js → chunk-EBSHN25W.js} +3 -3
  9. package/dist/hooks/{chunk-HGA4EG5S.js → chunk-GJ7JPIHN.js} +15 -15
  10. package/dist/hooks/{chunk-TG2AB3W6.js → chunk-GNDJ7NJ7.js} +88 -501
  11. package/dist/hooks/{chunk-546X4PX3.js → chunk-IYVOLN6A.js} +3 -3
  12. package/dist/hooks/{chunk-NBEAGPS4.js → chunk-J3KSS7II.js} +1 -1
  13. package/dist/hooks/{chunk-7ZM464OW.js → chunk-LMWBRJIP.js} +3 -3
  14. package/dist/hooks/{chunk-TORRSNDJ.js → chunk-SL4GQ2XP.js} +3 -3
  15. package/dist/hooks/{chunk-PV3FWDSQ.js → chunk-STDSEPWU.js} +2 -2
  16. package/dist/hooks/{chunk-YSDAH73S.js → chunk-U32VSRNG.js} +420 -4
  17. package/dist/hooks/{chunk-QARPP36N.js → chunk-WNFJLBXI.js} +3 -3
  18. package/dist/hooks/codex-hook-adapter.js +13 -13
  19. package/dist/hooks/duplicate-read-guard.js +7 -7
  20. package/dist/hooks/format-edited.js +3 -3
  21. package/dist/hooks/inputs.json +213 -212
  22. package/dist/hooks/investigation-guard.js +7 -7
  23. package/dist/hooks/pretool-guard.js +12 -12
  24. package/dist/hooks/session-lang.js +65 -9
  25. package/dist/hooks/stop-guard.js +104 -32
  26. package/dist/josh.js +2 -2
  27. package/package.json +1 -1
  28. package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
  29. package/scripts/backlog/backlog-budget-cli.ts +2 -20
  30. package/scripts/backlog/backlog-budget.ts +72 -109
  31. package/scripts/backlog/backlog-drive-cli.ts +12 -7
  32. package/scripts/backlog/backlog-drive-launch.ts +2 -0
  33. package/scripts/backlog/backlog-drive-owner.ts +72 -5
  34. package/scripts/backlog/backlog-drive.ts +5 -2
  35. package/scripts/backlog/backlog-offer-cli.ts +1 -15
  36. package/scripts/backlog/backlogrun-launch.ts +56 -5
  37. package/scripts/document/document-byte-budget.ts +1 -1
  38. package/scripts/epic/epic-candidate-confirm.ts +1 -1
  39. package/scripts/epic/epic-cross-repo.ts +4 -4
  40. package/scripts/epic/epic-next.ts +1 -1
  41. package/scripts/hooks/stop-guard.ts +20 -9
  42. package/scripts/init/start-plan.ts +15 -4
  43. package/scripts/init/start-steps.ts +31 -3
  44. package/scripts/init/start.ts +20 -13
  45. package/scripts/josh/josh-commands-ai.ts +1 -1
  46. package/scripts/josh/josh-commands-project.ts +1 -1
  47. package/scripts/lane/lane-cli.ts +1 -3
  48. package/scripts/run/board/run-board-header.ts +137 -88
  49. package/scripts/run/board/run-board-labels.ts +18 -17
  50. package/scripts/run/board/run-board-machine.ts +21 -8
  51. package/scripts/run/board/run-board-read.ts +9 -2
  52. package/scripts/run/board/run-board-render.ts +1 -5
  53. package/scripts/run/board/run-board-setup.ts +73 -0
  54. package/scripts/run/board/run-board-span.ts +49 -8
  55. package/scripts/run/board/run-board-status.ts +10 -23
  56. package/scripts/run/board/run-board-tick.ts +11 -2
  57. package/scripts/run/carry/run-carry-added.ts +1 -1
  58. package/scripts/run/carry/run-carry-change.ts +1 -1
  59. package/scripts/run/carry/run-carry-cli.ts +36 -60
  60. package/scripts/run/carry/run-carry-ended.ts +10 -2
  61. package/scripts/run/carry/run-carry.ts +38 -68
  62. package/scripts/run/cut/run-cut.ts +4 -4
  63. package/scripts/run/event/run-event-render.ts +1 -1
  64. package/scripts/run/event/run-event-scope.ts +4 -8
  65. package/scripts/run/event/run-event-setup.ts +47 -0
  66. package/scripts/run/event/run-event-stream-emit.ts +0 -25
  67. package/scripts/run/event/run-event-stream.ts +41 -28
  68. package/scripts/run/merge/run-merge-collect.ts +1 -1
  69. package/scripts/run/merge/run-merge-steps.ts +1 -1
  70. package/scripts/run/progress/run-progress-read.ts +1 -1
  71. package/scripts/run/run-headless.ts +2 -2
  72. package/scripts/run/run-status-cli.ts +1 -5
  73. package/scripts/run/run-step.ts +2 -9
  74. package/scripts/run/run-stop-notify.ts +1 -1
  75. package/scripts/run/run-stranded-detect.ts +61 -15
  76. package/scripts/run/run-stranded.ts +25 -17
  77. package/scripts/run/wake/run-wake-cli.ts +58 -15
  78. package/scripts/run/wake/run-wake-describe.ts +5 -5
  79. package/scripts/run/wake/run-wake-driver.ts +13 -4
  80. package/scripts/run/wake/run-wake-handoff.ts +1 -1
  81. package/scripts/run/wake/run-wake-loop.ts +4 -21
  82. package/scripts/run/wake/run-wake-restart.ts +1 -1
  83. package/scripts/run/wake/run-wake.ts +6 -7
  84. package/scripts/backlog/backlog-idle.ts +0 -88
@@ -1,3 +1,4 @@
1
+ import type { PendingLaunch } from '#scripts/backlog/backlogrun-launch'
1
2
  import type { CarryRead } from '#scripts/run/carry/run-carry'
2
3
  import type { EndedRun } from '#scripts/run/carry/run-carry-ended'
3
4
  import { run_event_scope } from '#scripts/run/event/run-event-scope'
@@ -27,10 +28,40 @@ function ended_span(ended: EndedRun | undefined): BoardSpan | undefined {
27
28
  return { invocation: ended.invocation, started_ms, ended_ms }
28
29
  }
29
30
 
31
+ // A run `josh backlogrun` launched and its parent has not claimed yet: it has no carry record, so the
32
+ // launch record is the only one that says a run is here and since when.
33
+ function launch_span(launch: PendingLaunch | undefined): BoardSpan | undefined {
34
+ const started_ms = launch === undefined ? undefined : moment_of(launch.started_at)
35
+
36
+ if (launch === undefined || started_ms === undefined) return undefined
37
+
38
+ return { invocation: launch.invocation, started_ms, ended_ms: undefined }
39
+ }
40
+
41
+ // With no carry record: the run on its way, else the one that last ended. **A launch older than that
42
+ // end is the ended run's own** — its parent outlives `run:carry --end` for a moment — so it never
43
+ // redraws a finished run as one starting.
44
+ function idle_span(
45
+ ended: EndedRun | undefined,
46
+ launch: PendingLaunch | undefined,
47
+ ): BoardSpan | undefined {
48
+ const last = ended_span(ended)
49
+ const coming = launch_span(launch)
50
+
51
+ if (coming === undefined) return last
52
+
53
+ return (last?.ended_ms ?? 0) >= coming.started_ms ? last : coming
54
+ }
55
+
30
56
  // The running run wins over the ended one, so a run that begins replaces the finished board at once.
31
- // An expired carry is still the running run's budget spent, not a run that ended, so it draws nothing.
32
- function span_of(read: CarryRead, ended: EndedRun | undefined): BoardSpan | undefined {
33
- if (read.kind !== 'carried') return read.kind === 'none' ? ended_span(ended) : undefined
57
+ // **The launch's start is the carry's**: `run:carry --begin` inherits it for the parent the launch
58
+ // started, so the clock does not restart when the record appears.
59
+ function span_of(
60
+ read: CarryRead,
61
+ ended: EndedRun | undefined,
62
+ launch?: PendingLaunch,
63
+ ): BoardSpan | undefined {
64
+ if (read.kind !== 'carried') return read.kind === 'none' ? idle_span(ended, launch) : undefined
34
65
 
35
66
  const started_ms = moment_of(read.carry.started_at)
36
67
 
@@ -39,12 +70,22 @@ function span_of(read: CarryRead, ended: EndedRun | undefined): BoardSpan | unde
39
70
  return { invocation: read.carry.invocation, started_ms, ended_ms: undefined }
40
71
  }
41
72
 
42
- // The session a person resumes a stopped run from: only while the ended run is
43
- // the one drawn, and only when it stopped rather than finished. A run that begins hides it at once.
44
- function resume_of(read: CarryRead, ended: EndedRun | undefined): string | undefined {
45
- if (read.kind !== 'none' || ended?.stopped === undefined) return undefined
73
+ // Whether the span drawn with no carry record is the ended run's, rather than a launch's.
74
+ function is_ended_drawn(ended: EndedRun | undefined, launch: PendingLaunch | undefined): boolean {
75
+ return idle_span(ended, launch)?.ended_ms !== undefined
76
+ }
46
77
 
47
- return ended.session
78
+ // The session a person resumes a stopped run from: only while the ended run is
79
+ // the one drawn, and only when it stopped rather than finished. A run that begins — or is on its way —
80
+ // hides it at once.
81
+ function resume_of(
82
+ read: CarryRead,
83
+ ended: EndedRun | undefined,
84
+ launch?: PendingLaunch,
85
+ ): string | undefined {
86
+ if (read.kind !== 'none' || !is_ended_drawn(ended, launch)) return undefined
87
+
88
+ return ended?.stopped === undefined ? undefined : ended.session
48
89
  }
49
90
 
50
91
  function is_within(event: RunEvent, span: BoardSpan): boolean {
@@ -1,4 +1,3 @@
1
- import { backlog_idle, type IdleWindow } from '#scripts/backlog/backlog-idle'
2
1
  import {
3
2
  has_label_name,
4
3
  IN_PROGRESS_LABEL,
@@ -8,6 +7,7 @@ import { run_event_scope } from '#scripts/run/event/run-event-scope'
8
7
  import { run_event_stream, type RunEvent } from '#scripts/run/event/run-event-stream'
9
8
  import type { ClosedIssue } from './run-board-closed'
10
9
  import { run_board_phase, type Phase } from './run-board-phase'
10
+ import { run_board_setup, type SetupRead } from './run-board-setup'
11
11
 
12
12
  // What each issue of a run is doing now, read from the run's own event stream and its open lanes.
13
13
  // Local reads only — no `gh` call — so `run:board` can redraw it every few seconds; the plan these
@@ -27,11 +27,10 @@ interface ItemStatus {
27
27
  track?: ReadonlyArray<Phase> | undefined
28
28
  }
29
29
 
30
- // The run as a whole, read off the same stream.
31
- interface RunActivity {
30
+ // The run as a whole, read off the same stream. The setup half is `run-board-setup.ts`'s, absent
31
+ // where a stream carries no stage.
32
+ interface RunActivity extends Partial<SetupRead> {
32
33
  last_event_ms: number | undefined
33
- // The idle watch's window, when the newest one opened after the newest launch.
34
- idle: IdleWindow | undefined
35
34
  // The run's last position is a `stop` that names no child — the run itself ended.
36
35
  is_stopped: boolean
37
36
  }
@@ -128,16 +127,17 @@ function fold_events(events: ReadonlyArray<RunEvent>): Map<number, ItemStatus> {
128
127
  function is_running_lane(
129
128
  issue: number,
130
129
  status: ItemStatus | undefined,
130
+ events: ReadonlyArray<RunEvent>,
131
131
  in_run: ReadonlySet<number>,
132
132
  ): boolean {
133
- if (status === undefined) return in_run.has(issue)
133
+ if (status !== undefined) return status.state === 'running'
134
134
 
135
- return status.state === 'running'
135
+ return in_run.has(issue) && !run_board_setup.is_lane_pending(events, issue)
136
136
  }
137
137
 
138
138
  // Every child the run has touched, with the lane each running child holds. A lane the stream never saw
139
139
  // launched — a run restored after the stream rolled — still reads as running while its issue is in the
140
- // run's plan (`in_run`).
140
+ // run's plan (`in_run`); one the run is still setting up for stays waiting until its launch.
141
141
  function statuses_of(
142
142
  events: ReadonlyArray<RunEvent>,
143
143
  lanes: ReadonlyArray<string>,
@@ -149,7 +149,7 @@ function statuses_of(
149
149
  const issue = Number(lane)
150
150
  const status = statuses.get(issue)
151
151
 
152
- if (is_running_lane(issue, status, in_run)) {
152
+ if (is_running_lane(issue, status, events, in_run)) {
153
153
  statuses.set(issue, { state: 'running', ...status, lane: `lane ${lane}` })
154
154
  }
155
155
  }
@@ -243,19 +243,6 @@ function settle_closed(
243
243
  return new Map([...statuses].map(([issue, status]) => [issue, settled(issue, status, read)]))
244
244
  }
245
245
 
246
- function newest_of(events: ReadonlyArray<RunEvent>, kind: string): RunEvent | undefined {
247
- return events.findLast((event) => event.kind === kind)
248
- }
249
-
250
- function idle_of(events: ReadonlyArray<RunEvent>): IdleWindow | undefined {
251
- const idle = newest_of(events, KIND.IDLE)
252
- const launch = newest_of(events, KIND.CHILD_LAUNCH)
253
-
254
- if (idle === undefined || (launch !== undefined && launch.pos > idle.pos)) return undefined
255
-
256
- return backlog_idle.parse(idle.text)
257
- }
258
-
259
246
  function is_run_stop(event: RunEvent | undefined): boolean {
260
247
  return event?.kind === KIND.STOP && run_event_scope.issue_named(event) === undefined
261
248
  }
@@ -267,8 +254,8 @@ function activity_of(events: ReadonlyArray<RunEvent>, is_ended = false): RunActi
267
254
  const position = events.findLast((event) => !run_event_stream.TRACE_KINDS.has(event.kind))
268
255
 
269
256
  return {
257
+ ...run_board_setup.read(events),
270
258
  last_event_ms: last === undefined ? undefined : moment(last),
271
- idle: idle_of(events),
272
259
  is_stopped: is_ended || is_run_stop(position),
273
260
  }
274
261
  }
@@ -11,7 +11,7 @@ import type { LocalRead } from './run-board-read'
11
11
  import { run_board_render, type FrameBounds } from './run-board-render'
12
12
  import { run_board_spin, type Spun } from './run-board-spin'
13
13
  import { run_board_state, type BoardPorts, type BoardState, type Pace } from './run-board-state'
14
- import { run_board_status } from './run-board-status'
14
+ import { run_board_status, type RunActivity } from './run-board-status'
15
15
  import { run_board_usage } from './run-board-usage'
16
16
 
17
17
  // One `run:board` redraw. **Three speeds**: the frame is redrawn once a second, on the second, so
@@ -116,6 +116,14 @@ interface Redraw {
116
116
  now_ms: number
117
117
  }
118
118
 
119
+ // A run `run:carry --end` closed and one nobody drives any more are both drawn as stopped.
120
+ function activity_here(local: LocalRead): RunActivity {
121
+ return run_board_status.activity_of(
122
+ local.events,
123
+ local.ended_ms !== undefined || local.is_stranded,
124
+ )
125
+ }
126
+
119
127
  function header_of(
120
128
  state: BoardState,
121
129
  local: LocalRead,
@@ -126,7 +134,7 @@ function header_of(
126
134
  now_ms: redraw.now_ms,
127
135
  started_ms: local.started_ms,
128
136
  ended_ms: local.ended_ms,
129
- activity: run_board_status.activity_of(local.events, local.ended_ms !== undefined),
137
+ activity: activity_here(local),
130
138
  layout,
131
139
  baseline_total: state.baseline_total,
132
140
  plan_fetched_ms: state.fetched_ms,
@@ -135,6 +143,7 @@ function header_of(
135
143
  machine: state.gauges,
136
144
  usages: state.usages,
137
145
  form: redraw.ports.form,
146
+ repo: state.plan?.context.repo,
138
147
  spinner: redraw.ports.is_tty ? SPOT : undefined,
139
148
  link: run_board_link.linker(state.plan?.context.repo, redraw.ports.link),
140
149
  }
@@ -51,7 +51,7 @@ function add_issues(
51
51
  ): RunCarry {
52
52
  const next: RunCarry = { ...carry, added: next_added(carry, additions) }
53
53
 
54
- stamp_file.write_stamp(target, next)
54
+ stamp_file.replace_stamp(target, next)
55
55
 
56
56
  return next
57
57
  }
@@ -83,7 +83,7 @@ function apply_change(
83
83
  retrospective: effective.retrospective === true || carry.retrospective,
84
84
  }
85
85
 
86
- stamp_file.write_stamp(target, next)
86
+ stamp_file.replace_stamp(target, next)
87
87
 
88
88
  return next
89
89
  }
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
+ import { backlogrun_launch } from '#scripts/backlog/backlogrun-launch'
3
4
  import { cost_cli } from '#scripts/cost-runtime/cost-cli'
4
5
  import { cost_verdict } from '#scripts/cost-runtime/cost-verdict'
6
+ import { run_event_setup } from '#scripts/run/event/run-event-setup'
5
7
  import { run_event_stream } from '#scripts/run/event/run-event-stream'
6
8
  import { run_event_stream_emit } from '#scripts/run/event/run-event-stream-emit'
7
9
  import { run_merge_collect } from '#scripts/run/merge/run-merge-collect'
@@ -39,7 +41,6 @@ const RESUMED_VERDICT = 'resumed'
39
41
  const CARRIED_VERDICT = 'carried'
40
42
  const COUNTED_VERDICT = 'counted'
41
43
  const ENDED_VERDICT = 'ended'
42
- const EXPIRED_VERDICT = 'expired'
43
44
  const NONE_VERDICT = 'none'
44
45
  const UNREADABLE_VERDICT = 'unreadable'
45
46
  const MISMATCH_VERDICT = 'mismatch'
@@ -94,28 +95,14 @@ function report_unreadable(is_json: boolean): number {
94
95
  return report(UNREADABLE_VERDICT, undefined, is_json, FAILURE_EXIT_CODE)
95
96
  }
96
97
 
97
- function report_expired(carry: RunCarry, is_json: boolean): number {
98
- console.error(run_carry.expired_message(carry))
99
-
100
- return report_carry(EXPIRED_VERDICT, carry, is_json)
101
- }
102
-
103
98
  function report_read(read: CarryRead, is_json: boolean): number {
104
99
  if (read.kind === 'none') return report(NONE_VERDICT, undefined, is_json, SUCCESS_EXIT_CODE)
105
100
 
106
101
  if (read.kind === 'unreadable') return report_unreadable(is_json)
107
102
 
108
- if (read.kind === 'expired') return report_expired(read.carry, is_json)
109
-
110
103
  return report_carry(CARRIED_VERDICT, read.carry, is_json)
111
104
  }
112
105
 
113
- function report_busy(carry: RunCarry, is_json: boolean): number {
114
- console.error(run_carry.busy_message(carry))
115
-
116
- return report(BUSY_VERDICT, carry, is_json, FAILURE_EXIT_CODE)
117
- }
118
-
119
106
  function refusal_message(claim: CarryClaim, carry: RunCarry, invocation: string): string {
120
107
  if (claim === 'busy') return run_carry.busy_message(carry)
121
108
 
@@ -149,7 +136,7 @@ function report_adoption(
149
136
 
150
137
  // **Whose record it is, is the classifier's answer and not this command's guess.** `--end` is only
151
138
  // reached on the clean-finish path, so a run that crashed or stopped on a guard leaves its record
152
- // standing for up to the whole-run bound; `resumed` is reached only where the run itself declared the
139
+ // standing until a person carries or discards it; `resumed` is reached only where the run itself declared the
153
140
  // cut, or where `--resume` says the reader has decided to carry it.
154
141
  function report_claim(
155
142
  target: string,
@@ -166,59 +153,47 @@ function report_claim(
166
153
  return report(CLAIM_VERDICTS[claim], carry, is_json, FAILURE_EXIT_CODE)
167
154
  }
168
155
 
169
- function start_fresh(
170
- target: string,
171
- request: CarryClaimRequest,
172
- is_replacement: boolean,
173
- is_json: boolean,
174
- ): number {
175
- const carry = is_replacement
176
- ? run_carry.replace_carry(target, request.invocation, request.owner)
177
- : run_carry.begin_carry(target, request.invocation, request.owner)
156
+ // When the run began: the moment `josh backlogrun` launched this owner, so the clock `run:board` was
157
+ // already counting from does not restart at the claim; `undefined` — now — for every other run.
158
+ async function launch_start(request: CarryClaimRequest): Promise<Date | undefined> {
159
+ const directory = await run_carry.repository_directory()
178
160
 
179
- if (carry === undefined) return report_lost(is_json)
161
+ if (directory === undefined) return undefined
180
162
 
181
- return report_carry(BEGAN_VERDICT, carry, is_json)
163
+ return backlogrun_launch.started_for(directory, request.owner.pid)
182
164
  }
183
165
 
184
- // **The bound ends *that* run, so a spent record whose owner is still running is not replaced
185
- // either.** Replacing it would delete a live parent's budget and answer `began` — two parents on one
186
- // record. Only that run, or a person who knows it is over, ends it.
187
- function begin_over_expired(
166
+ // A run that began is planning, which is what its board says until the supervisor takes it over.
167
+ async function start_fresh(
188
168
  target: string,
189
- carry: RunCarry,
190
169
  request: CarryClaimRequest,
191
170
  is_json: boolean,
192
- ): number {
193
- if (run_carry.is_foreign_live_owner(carry, request.owner)) return report_busy(carry, is_json)
171
+ ): Promise<number> {
172
+ const started = await launch_start(request)
173
+ const carry = run_carry.begin_carry(target, request.invocation, request.owner, started)
194
174
 
195
- // **A hand-off says the resumption *is* that run, so the spent bound is this session's too.**
196
- // Replaced here, the record would come back with `started_at` set to now — and `backlog:budget
197
- // --started` reads that field, so the 8-hour bound would restart at every cut and never end the
198
- // run. The answer is `expired`, and a person who really is starting a new run clears the record
199
- // with `--end`, which is what the expiry message says.
200
- if (carry.is_handed_off === true) return report_expired(carry, is_json)
175
+ if (carry === undefined) return report_lost(is_json)
201
176
 
202
- console.error(run_carry.expired_message(carry))
177
+ await run_event_setup.emit(run_event_setup.STAGE.PLANNING)
203
178
 
204
- return start_fresh(target, request, true, is_json)
179
+ return report_carry(BEGAN_VERDICT, carry, is_json)
205
180
  }
206
181
 
207
- // **A live record is never replaced, and never resumed into by something else.** Replacing one would
208
- // restart the budget the cut exists to carry, and resuming into one would put two parents on a single
209
- // budget. An expired one is replaced,
210
- // because its run has spent the whole-run bound and a person typing the keyword again is starting a
211
- // new run — unless its owner is still running, which is the case above.
212
- function begin(target: string, request: CarryClaimRequest, is_json: boolean): number {
182
+ // **A standing record is never replaced, and never resumed into by something else.** Replacing one
183
+ // would restart the budget the cut exists to carry, and resuming into one would put two parents on a
184
+ // single budget. Its age changes none of that: a record is replaced by nothing but a person's `--end`.
185
+ async function begin(
186
+ target: string,
187
+ request: CarryClaimRequest,
188
+ is_json: boolean,
189
+ ): Promise<number> {
213
190
  const read = run_carry.read_carry(target)
214
191
 
215
192
  if (read.kind === 'unreadable') return report_unreadable(is_json)
216
193
 
217
194
  if (read.kind === 'carried') return report_claim(target, read.carry, request, is_json)
218
195
 
219
- if (read.kind === 'expired') return begin_over_expired(target, read.carry, request, is_json)
220
-
221
- return start_fresh(target, request, false, is_json)
196
+ return await start_fresh(target, request, is_json)
222
197
  }
223
198
 
224
199
  function report_nothing_to_resume(is_json: boolean): number {
@@ -237,8 +212,6 @@ function adopt(target: string, request: CarryClaimRequest, is_json: boolean): nu
237
212
 
238
213
  if (read.kind === 'unreadable') return report_unreadable(is_json)
239
214
 
240
- if (read.kind === 'expired') return report_expired(read.carry, is_json)
241
-
242
215
  return report_claim(target, read.carry, request, is_json)
243
216
  }
244
217
 
@@ -254,10 +227,16 @@ function report_over(is_json: boolean): number {
254
227
  return report(OVER_VERDICT, undefined, is_json, FAILURE_EXIT_CODE)
255
228
  }
256
229
 
257
- function claim_record(target: string, request: CarryClaimRequest, is_json: boolean): number {
230
+ async function claim_record(
231
+ target: string,
232
+ request: CarryClaimRequest,
233
+ is_json: boolean,
234
+ ): Promise<number> {
258
235
  if (cost_cli.session_verdict() === cost_verdict.OVER_VERDICT) return report_over(is_json)
259
236
 
260
- return request.is_adoption ? adopt(target, request, is_json) : begin(target, request, is_json)
237
+ if (request.is_adoption) return adopt(target, request, is_json)
238
+
239
+ return await begin(target, request, is_json)
261
240
  }
262
241
 
263
242
  // The record is no longer this session's to advance — handed off at a cut, or taken over by a live
@@ -334,9 +313,7 @@ async function count(
334
313
 
335
314
  await record_retrospective(request)
336
315
 
337
- return read.kind === 'expired'
338
- ? report_expired(carry, is_json)
339
- : report_carry(COUNTED_VERDICT, carry, is_json)
316
+ return report_carry(COUNTED_VERDICT, carry, is_json)
340
317
  }
341
318
 
342
319
  // **A stop over a record that is still there pushes one ⏸️ confirmation before the record goes**.
@@ -400,7 +377,7 @@ async function act(
400
377
  request: Exclude<Request, { kind: 'end' }>,
401
378
  is_json: boolean,
402
379
  ): Promise<number> {
403
- if (request.kind === 'claim') return claim_record(target, request.claim, is_json)
380
+ if (request.kind === 'claim') return await claim_record(target, request.claim, is_json)
404
381
 
405
382
  const read = run_carry.read_carry(target)
406
383
 
@@ -463,7 +440,6 @@ const run_carry_cli = {
463
440
  CARRIED_VERDICT,
464
441
  COUNTED_VERDICT,
465
442
  ENDED_VERDICT,
466
- EXPIRED_VERDICT,
467
443
  MISMATCH_VERDICT,
468
444
  NONE_VERDICT,
469
445
  OVER_VERDICT,
@@ -55,7 +55,7 @@ function record_ended(
55
55
  now: Date = new Date(),
56
56
  stopped?: string,
57
57
  ): void {
58
- if (read.kind !== 'carried' && read.kind !== 'expired') return
58
+ if (read.kind !== 'carried') return
59
59
 
60
60
  const { invocation, started_at, owner_transcript } = read.carry
61
61
  const ended_at = now.toISOString()
@@ -79,7 +79,15 @@ function read_ended(target: string): EndedRun | undefined {
79
79
  return raw === undefined ? undefined : parse_ended(raw)
80
80
  }
81
81
 
82
- const run_carry_ended = { ended_path, read_ended, record_ended }
82
+ // Whether the run that began at `started_at` is the one `--end` last ended — what tells a record that is
83
+ // gone because its run ended from one that only failed to read.
84
+ function has_ended(target: string, started_at: string | undefined): boolean {
85
+ const ended = read_ended(target)
86
+
87
+ return ended !== undefined && ended.started_at === started_at
88
+ }
89
+
90
+ const run_carry_ended = { ended_path, has_ended, read_ended, record_ended }
83
91
 
84
92
  export { run_carry_ended }
85
93
  export type { EndedRun }
@@ -1,8 +1,6 @@
1
- import { backlog_budget } from '#scripts/backlog/backlog-budget'
2
1
  import { git_command } from '#scripts/git/git-command'
3
2
  import { process_identity } from '#scripts/josh/process-identity'
4
3
  import { stamp_file } from '#scripts/josh/stamp-file'
5
- import { stamp_record, type StampRecordSpec } from '#scripts/josh/stamp-record'
6
4
  import { json_value } from '#scripts/lib/json-value'
7
5
  import { run_invocation } from '#scripts/run/run-invocation'
8
6
  import { z } from 'zod'
@@ -10,7 +8,7 @@ import { run_carry_added, type CarryAddition } from './run-carry-added'
10
8
  import { run_carry_change } from './run-carry-change'
11
9
  import { run_carry_conversation } from './run-carry-conversation'
12
10
 
13
- // A `backlogrun` declares a budget — `--max`, `--idle` and the 8-hour whole-run bound — and the session
11
+ // A `backlogrun` declares a budget — `--max` and `--idle` — and the session
14
12
  // cut ends the session, so the next one would start from nothing. **The cut is an execution detail of
15
13
  // the same authorization**, so the budget has to survive it: this record carries it across sessions,
16
14
  // where there is no epic to read progress back off when the run began from the backlog.
@@ -20,9 +18,10 @@ import { run_carry_conversation } from './run-carry-conversation'
20
18
  // one *invocation's* budget, and that invocation opens lanes — each a work tree of its own — so the
21
19
  // key is the common git directory, which every lane of one repository shares.
22
20
  //
23
- // **The 8-hour bound is the record's expiry.** Held as an age rather than as prose, the bound is
24
- // decided the same way `backlog:budget` decides the other two: the command answers, and the run
25
- // obeys. A run that reads `expired` has spent the bound whatever any count says.
21
+ // **The record has no expiry of its own.** A run is bounded by its idle wait, which `backlog:budget`
22
+ // counts from when the run last had work; an age counted from `started_at` would end a run that is
23
+ // still working or still inside that wait. So a record a run never ended — a crash — stays `standing`
24
+ // until a person carries it with `--resume` or discards it with `--end`.
26
25
  //
27
26
  // **Whose record it is, is decided by liveness and by a declared hand-off — never by the invocation
28
27
  // text**. Comparing the `invocation` string alone walks straight through the case that happens most: a
@@ -38,21 +37,14 @@ import { run_carry_conversation } from './run-carry-conversation'
38
37
 
39
38
  const CARRY_PREFIX = 'josh-run-carry-'
40
39
  const REPOSITORY_DIRECTORY_INDEX = 1
41
- // **The whole-run bound is `backlog-budget.ts`'s, imported rather than restated.** `backlogrun.md` →
42
- // "Waiting, and never waiting forever" states the figure and that module applies it; a second copy
43
- // here would drift, and the drift is silent in exactly the direction that matters — raised there and
44
- // still 8 here, this record answers `expired`, `--begin` replaces it, and the budget restarts at
45
- // zero — the loss this record exists to prevent.
46
- const CARRY_MAX_AGE_HOURS = backlog_budget.WHOLE_RUN_BUDGET_HOURS
47
- const CARRY_MAX_AGE_MS = backlog_budget.WHOLE_RUN_BUDGET_MS
48
40
  const NO_INCREMENT = 0
49
41
 
50
42
  // The most session cuts one invocation may take. This bounds `cuts`, which
51
43
  // only `run:carry --cut` increments — a `backlogrun`'s session hand-off at a child's merge (~one per
52
- // 50 minutes over the 8-hour whole-run budget), not the `run:cut` phase cuts a lane child takes. Each
44
+ // 50 minutes of work), not the `run:cut` phase cuts a lane child takes. Each
53
45
  // cut drops the context it accumulated but pays a cold preamble to resume, so past a point the
54
- // re-establishment costs more than the accumulation it sheds; six sits well under the ~10 an 8-hour run
55
- // could otherwise take and refuses a run that has begun to churn. `is_at_cut_cap` reads it, and the
46
+ // re-establishment costs more than the accumulation it sheds; six sits well under the ~10 a night of
47
+ // work could otherwise take and refuses a run that has begun to churn. `is_at_cut_cap` reads it, and the
56
48
  // `--cut` count is refused at it rather than incrementing past it — except a headless judgment
57
49
  // session's hand-back to the driver, which `run_headless.is_cut_capped` exempts.
58
50
  const MAX_CUTS = 6
@@ -185,11 +177,7 @@ interface CarryChange {
185
177
  retrospective?: boolean
186
178
  }
187
179
 
188
- type CarryRead =
189
- | { kind: 'none' }
190
- | { kind: 'carried'; carry: RunCarry }
191
- | { kind: 'expired'; carry: RunCarry }
192
- | { kind: 'unreadable' }
180
+ type CarryRead = { kind: 'none' } | { kind: 'carried'; carry: RunCarry } | { kind: 'unreadable' }
193
181
 
194
182
  const NO_OWNER: CarryOwner = {}
195
183
 
@@ -234,32 +222,23 @@ function parse_carry(raw: string): RunCarry | undefined {
234
222
  return json_value.parse_with(raw, run_carry_schema)
235
223
  }
236
224
 
237
- function started_at_of(carry: RunCarry): string {
238
- return carry.started_at
239
- }
240
-
241
- // The expiry and the classification are `stamp-record.ts`'s, shared with `run-cut.ts` and
242
- // `run-hold.ts`; what a `started_at` that is not a date means is decided there.
243
- const CARRY_SPEC: StampRecordSpec<RunCarry> = {
244
- parse: parse_carry,
245
- timestamp_of: started_at_of,
246
- max_age_ms: CARRY_MAX_AGE_MS,
247
- }
225
+ // **A record that cannot be read is `unreadable`, never `none`** — absent is none; present and
226
+ // unparseable is the state a guard must not fall open on. A `started_at` that is not a date is the
227
+ // same state: it passes the schema as a string, and every reader of the record measures from it.
228
+ function classify(raw: string | undefined): CarryRead {
229
+ if (raw === undefined) return { kind: 'none' }
248
230
 
249
- function is_expired(carry: RunCarry, now: Date): boolean {
250
- return stamp_record.is_older_than(started_at_of(carry), CARRY_MAX_AGE_MS, now)
251
- }
252
-
253
- function classify(raw: string | undefined, now: Date): CarryRead {
254
- const read = stamp_record.classify(raw, CARRY_SPEC, now)
231
+ const carry = parse_carry(raw)
255
232
 
256
- if (!('record' in read)) return read
233
+ if (carry === undefined || Number.isNaN(Date.parse(carry.started_at))) {
234
+ return { kind: 'unreadable' }
235
+ }
257
236
 
258
- return { kind: read.kind, carry: read.record }
237
+ return { kind: 'carried', carry }
259
238
  }
260
239
 
261
- function read_carry(target: string, now: Date = new Date()): CarryRead {
262
- return classify(stamp_file.read_stamp_text(target), now)
240
+ function read_carry(target: string): CarryRead {
241
+ return classify(stamp_file.read_stamp_text(target))
263
242
  }
264
243
 
265
244
  // The identity to record for a pid the caller declared. `read_start` is what makes the pair an
@@ -304,20 +283,6 @@ function begin_carry(
304
283
  return stamp_file.create_stamp(target, carry) ? carry : undefined
305
284
  }
306
285
 
307
- // Replacing a record whose bound is spent. It removes and then creates exclusively rather than
308
- // writing over the old bytes, for the reason `run-hold.ts` replaces a stale hold the same way: a
309
- // plain write would put the race back one branch over.
310
- function replace_carry(
311
- target: string,
312
- invocation: string,
313
- owner: CarryOwner = NO_OWNER,
314
- now: Date = new Date(),
315
- ): RunCarry | undefined {
316
- stamp_file.remove_stamp(target)
317
-
318
- return begin_carry(target, invocation, owner, now)
319
- }
320
-
321
286
  // Taking over a standing record: the counters and `started_at` are the run's and stay, the owner
322
287
  // becomes this session, and the hand-off is spent. **`cuts` is not incremented here** — `--cut`
323
288
  // counts the cut, and a record adopted after a crash crossed no cut at all.
@@ -371,6 +336,21 @@ function is_foreign_live_owner(carry: RunCarry, owner: CarryOwner): boolean {
371
336
  return is_owner_live(carry) && !is_owned_by(carry, owner)
372
337
  }
373
338
 
339
+ // Whether a process other than the caller still holds the record. **By process alone, never by
340
+ // conversation**: the wake supervisor inherits the environment of the session that started it, so a
341
+ // conversation match would read that live session's record as the supervisor's own. The driver adopts
342
+ // the record for the supervisor's process (`run-wake-driver.ts` → `claim_record`), so without this a
343
+ // supervisor reads itself as a session at work.
344
+ function is_held_by_another(
345
+ carry: RunCarry,
346
+ is_live: (carry: RunCarry) => boolean = is_owner_live,
347
+ ): boolean {
348
+ const caller = owner_of(process.pid)
349
+ const is_own = carry.owner_pid === caller.pid && carry.owner_start === caller.start
350
+
351
+ return !is_own && is_live(carry)
352
+ }
353
+
374
354
  function is_handed_off_to(carry: RunCarry, invocation: string): boolean {
375
355
  return carry.is_handed_off === true && carry.invocation === invocation
376
356
  }
@@ -424,9 +404,7 @@ function end_carry(target: string): void {
424
404
  // no record — `none` or `unreadable` — is not a run whose retrospective has run, so it answers `false`;
425
405
  // `run:step` reads it to decide whether the stop position still owes a retrospective.
426
406
  function retrospective_done_of(read: CarryRead): boolean {
427
- if (read.kind !== 'carried' && read.kind !== 'expired') return false
428
-
429
- return read.carry.retrospective === true
407
+ return read.kind === 'carried' && read.carry.retrospective === true
430
408
  }
431
409
 
432
410
  // **What the resumed session has to be told, computed rather than stored.** A stored remainder would
@@ -458,10 +436,6 @@ function describe_carry(carry: RunCarry): string {
458
436
  return `${carry.invocation} started ${carry.started_at}; ${String(carry.merged)} merged, ${String(carry.filed)} filed, ${String(carry.failures)} failed in a row, ${String(carry.outages)} outage(s) in a row, ${String(carry.cuts)} cut(s) crossed${done_note(carry)}`
459
437
  }
460
438
 
461
- function expired_message(carry: RunCarry): string {
462
- return `The ${String(CARRY_MAX_AGE_HOURS)}-hour whole-run bound is spent: ${describe_carry(carry)}. Clear it with \`${END_COMMAND}\`.`
463
- }
464
-
465
439
  // A record left standing by a run that never reached `--end`. Naming both invocations is the point:
466
440
  // the reader has to see that the budget in the way belongs to something else before deciding whether
467
441
  // that run is over.
@@ -525,8 +499,6 @@ function unknown_message(): string {
525
499
  }
526
500
 
527
501
  const run_carry = {
528
- CARRY_MAX_AGE_HOURS,
529
- CARRY_MAX_AGE_MS,
530
502
  END_COMMAND,
531
503
  MAX_CUTS,
532
504
  NO_OWNER,
@@ -543,12 +515,11 @@ const run_carry = {
543
515
  is_at_cut_cap,
544
516
  describe_carry,
545
517
  end_carry,
546
- expired_message,
547
518
  mismatch_message,
548
519
  fresh_carry,
549
520
  is_count_refused,
550
- is_expired,
551
521
  is_foreign_live_owner,
522
+ is_held_by_another,
552
523
  is_owner_live,
553
524
  lost_message,
554
525
  nothing_to_resume_message,
@@ -556,7 +527,6 @@ const run_carry = {
556
527
  parse_carry,
557
528
  read_carry,
558
529
  remaining_of,
559
- replace_carry,
560
530
  repository_directory,
561
531
  retrospective_done_of,
562
532
  standing_message,