@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
@@ -50,12 +50,12 @@ const { PRE_GATE_PHASE, IMPLEMENTATION_PHASE } = agent_role_profile
50
50
  // `pnpm josh cost --cut` hand-off uses (`backlogrun-progress.md` → "The hand-off"). The shared
51
51
  // threshold prevents the scheduler and worker boundaries from drifting apart.
52
52
  const IMPLEMENTATION_CONTEXT_THRESHOLD = CONTEXT_CUT_THRESHOLD
53
- // **The whole-run bound is `backlog-budget.ts`'s, imported rather than restated** — the same reuse
54
- // `run-carry.ts` makes. A resume is expected within seconds of the cut, so this age is only a backstop
53
+ // **The bound is `backlog-budget.ts`'s idle wait, imported rather than restated**, so the eight hours
54
+ // stay written once. A resume is expected within seconds of the cut, so this age is only a backstop
55
55
  // against a fresh process that never started; an expired record is cleared and replaced by the next
56
56
  // cut rather than blocking it.
57
- const CUT_MAX_AGE_MS = backlog_budget.WHOLE_RUN_BUDGET_MS
58
- const CUT_MAX_AGE_HOURS = backlog_budget.WHOLE_RUN_BUDGET_HOURS
57
+ const CUT_MAX_AGE_MS = backlog_budget.DEFAULT_IDLE_MS
58
+ const CUT_MAX_AGE_HOURS = backlog_budget.IDLE_WAIT_HOURS
59
59
 
60
60
  const END_COMMAND = 'pnpm josh run:cut --end'
61
61
  const READ_COMMAND = 'pnpm josh run:cut --json'
@@ -39,12 +39,12 @@ const KIND_LABELS: Readonly<Record<EventKind, KindLabel>> = {
39
39
  heartbeat: { ja: '生存確認', en: 'heartbeat' },
40
40
  'ship-launch': { ja: '出荷を開始', en: 'ship launched' },
41
41
  'ship-stop': { ja: '出荷が停止', en: 'ship stopped' },
42
- idle: { ja: '新着待ち', en: 'idle watch' },
43
42
  filed: { ja: '起票', en: 'filed' },
44
43
  note: { ja: '意見', en: 'note' },
45
44
  'lane-phase': { ja: 'レーンの段階', en: 'lane phase' },
46
45
  add: { ja: '割り込み', en: 'added' },
47
46
  'lane-limit': { ja: 'レーン上限', en: 'lane limit' },
47
+ setup: { ja: '準備', en: 'setup' },
48
48
  }
49
49
 
50
50
  const LABELS_BY_KIND: ReadonlyMap<string, KindLabel> = new Map(Object.entries(KIND_LABELS))
@@ -55,16 +55,12 @@ function scoped_events(
55
55
  return events.filter((event) => is_within(event, floor))
56
56
  }
57
57
 
58
- // The scope the run's own carry record implies. A record that reads at all names the invocation's start, and
59
- // **an expired one names it just as well**: expiry is the budget's verdict on a run, not a gap in the record,
60
- // so rounding it to unknown would withhold the scope from exactly the long runs that most need one. Only an
61
- // absent or unreadable record leaves the scope undetermined.
58
+ // The scope the run's own carry record implies. A record that reads at all names the invocation's start,
59
+ // however long ago that was; only an absent or unreadable record leaves the scope undetermined.
62
60
  function scope_of(read: CarryRead): EventScope {
63
- if (read.kind === 'carried' || read.kind === 'expired') {
64
- return { kind: SINCE_SCOPE, started_at: read.carry.started_at }
65
- }
61
+ if (read.kind !== 'carried') return UNKNOWN_EVENT_SCOPE
66
62
 
67
- return UNKNOWN_EVENT_SCOPE
63
+ return { kind: SINCE_SCOPE, started_at: read.carry.started_at }
68
64
  }
69
65
 
70
66
  // The newest event within scope, or `undefined` when the scope could not be determined or nothing falls
@@ -0,0 +1,47 @@
1
+ import { issue_cite } from '#scripts/issue/issue-cite'
2
+ import { error_text } from '#scripts/lib/error-message'
3
+ import { run_event_stream } from './run-event-stream'
4
+ import { run_event_stream_emit } from './run-event-stream-emit'
5
+
6
+ // The stages a run passes between `josh backlogrun` and its first child, each written to the stream as
7
+ // one `setup` event by the step that enters it. **The text is the stage's own words**: `run:board` draws
8
+ // it as written, so the message a person reads and the `💓` beside it come from one event and cannot
9
+ // disagree. Only a lane opening names an issue, which is how a reader tells it from the stages before it.
10
+
11
+ const STAGE = {
12
+ // `josh backlogrun` started the parent session, which is reading its procedure.
13
+ STARTING: 'starting session',
14
+ // `run:carry --begin` claimed the run, and the parent is planning it.
15
+ PLANNING: 'planning',
16
+ // The supervisor took the run over from the parent and is asking for the first offer.
17
+ HANDING_OFF: 'handing off',
18
+ } as const
19
+
20
+ const OPENING = 'opening lane'
21
+
22
+ // The driver is about to open a lane for `issue` — for every child, not only the first.
23
+ function opening_lane(issue: string): string {
24
+ return `${OPENING} ${issue_cite.plain(issue)}`
25
+ }
26
+
27
+ // Best-effort, as every append to this stream is: a stage that could not be written is a board that
28
+ // says less, never a run that failed.
29
+ async function emit(text: string): Promise<void> {
30
+ await run_event_stream_emit.emit(run_event_stream.EVENT_KIND.SETUP, text)
31
+ }
32
+
33
+ // The same append for a caller that already holds the repository's common git directory and the moment
34
+ // it wants the stage dated — the launcher, whose record and first stage carry one time.
35
+ function emit_in(repository: string, text: string, at: Date): void {
36
+ try {
37
+ const target = run_event_stream.target_of(repository)
38
+
39
+ run_event_stream.append(target, run_event_stream.EVENT_KIND.SETUP, text, at.toISOString())
40
+ } catch (error) {
41
+ error_text.trace_swallowed('run_event_setup.emit_in', error)
42
+ }
43
+ }
44
+
45
+ const run_event_setup = { STAGE, emit, emit_in, opening_lane }
46
+
47
+ export { run_event_setup }
@@ -70,30 +70,6 @@ async function emit_once(kind: string, text: string): Promise<boolean> {
70
70
  }
71
71
  }
72
72
 
73
- /**
74
- * Append one event unless the newest event of this kind already carries this text — for a marker whose
75
- * value, not its presence, is the news. The idle window is re-derived on every
76
- * offer of a watching loop; only a window that moved is worth a line. Returns whether it appended.
77
- */
78
- async function emit_changed(kind: string, text: string): Promise<boolean> {
79
- try {
80
- const target = await stream_target()
81
-
82
- if (target === undefined) return false
83
-
84
- const newest = run_event_stream.read_events(target).findLast((event) => event.kind === kind)
85
-
86
- if (newest?.text === text) return false
87
-
88
- return run_event_stream.append(target, kind, text, now_iso()).appended
89
- } catch (error) {
90
- // Best-effort: a failed append is dropped rather than raised into the loop's work.
91
- error_text.trace_swallowed('run_event_stream_emit.emit_changed', error)
92
-
93
- return false
94
- }
95
- }
96
-
97
73
  // The events of the invocation now running: the stream outlives every invocation, so a marker a previous
98
74
  // run left — a stall that ended with no dispatch after it — must not hold back this run's first one. An
99
75
  // undetermined scope (no carry record) keeps the whole stream, the side that under-notifies rather than
@@ -187,7 +163,6 @@ const run_event_stream_emit = {
187
163
  all_events,
188
164
  current_events,
189
165
  emit,
190
- emit_changed,
191
166
  emit_heartbeat,
192
167
  emit_once,
193
168
  emit_once_since,
@@ -29,7 +29,7 @@ const EVENT_PREFIX = 'josh-run-events-'
29
29
  // every reader — a person, an editor — that it is a line stream rather than a single document, the same
30
30
  // distinction the ambient `.log` draws.
31
31
  const EVENT_SUFFIX = '.jsonl'
32
- // A run's session-facing events over the 8-hour whole-run bound: a plan, a launch/PR/review-round/merge
32
+ // A run's session-facing events over one unattended night: a plan, a launch/PR/review-round/merge
33
33
  // per child across a few dozen children, plus parks, cuts and stops. Five hundred keeps the whole of a
34
34
  // large `backlogrun` and still bounds an unattended run — past it the oldest events roll off.
35
35
  const EVENT_CAP = 500
@@ -83,7 +83,8 @@ const EVENT_KIND = {
83
83
  // stalled is pointed straight at dispatching the work rather than left waiting.
84
84
  STALL: 'stall',
85
85
  // The run's own driver is gone: the cut handed the budget off, the owner has died, and no supervisor
86
- // is watching. Emitted once per strand episode by the stop-time detector,
86
+ // is watching. Emitted once per strand episode by the stop-time detector — or by the `run:wake`
87
+ // supervisor as it fails, leaves that state behind and has warned of it —
87
88
  // exactly as `STALL` is — the newest-event dedup keeps a run polled every stop from notifying more
88
89
  // than once, and a terminal reader following the stream sees the line. The strand is the step before
89
90
  // the stall: a stalled run still has a driver that could dispatch, a stranded one has none.
@@ -106,11 +107,6 @@ const EVENT_KIND = {
106
107
  // The position that hands control back: `run:step` prints the command that
107
108
  // shows the stopped report, so the agent fixes it and relaunches rather than rebuilding the context.
108
109
  SHIP_STOP: 'ship-stop',
109
- // The idle watch's window — when the run began waiting on an empty backlog and when that wait ends
110
- // `backlog:offer` writes it whenever the window changes, so `run:board` reads
111
- // the deadline rather than guessing it. It is a trace: `emit_once`'s drain dedup and `run:step`'s
112
- // position both read past it, so a watching loop never re-marks the drain it is waiting after.
113
- IDLE: 'idle',
114
110
  // An Issue the run filed on its way, written by `issue:file`, so a run started from the command line
115
111
  // still surfaces what it found — `run:board`'s findings section reads it.
116
112
  FILED: 'filed',
@@ -129,6 +125,11 @@ const EVENT_KIND = {
129
125
  // arrival probe reads it as a wake: the lanes a raise frees are new room for a pool its baseline
130
126
  // already holds. A trace: it says the run gained room, not where the run is.
131
127
  LANE_LIMIT: 'lane-limit',
128
+ // One stage of the run's start — the session starting, the plan, the hand-off to the supervisor, a
129
+ // lane opening (`run-event-setup.ts`). `run:board` draws it until the first child launches, so the
130
+ // minutes before one read as work rather than as a run that has stopped. A trace: it says the run is
131
+ // getting ready, not where the run is.
132
+ SETUP: 'setup',
132
133
  } as const
133
134
 
134
135
  type EventKind = (typeof EVENT_KIND)[keyof typeof EVENT_KIND]
@@ -141,22 +142,23 @@ const EVENT_KINDS: ReadonlyArray<string> = Object.values(EVENT_KIND)
141
142
  const TRACE_KINDS: ReadonlySet<string> = new Set([
142
143
  EVENT_KIND.SHIP_STAGE,
143
144
  EVENT_KIND.HEARTBEAT,
144
- EVENT_KIND.IDLE,
145
145
  EVENT_KIND.FILED,
146
146
  EVENT_KIND.NOTE,
147
147
  EVENT_KIND.LANE_PHASE,
148
148
  EVENT_KIND.ADD,
149
149
  EVENT_KIND.LANE_LIMIT,
150
+ EVENT_KIND.SETUP,
150
151
  ])
151
152
 
152
153
  // The trace kinds that say only that the run was alive, so the bound rolls them off first. A `filed` or
153
154
  // `note` line is a trace to the position readers but is the board's record of what the run found, so
154
- // it rolls off only with the positions. A ship-stage `start` and an `idle` line are disposable but go
155
- // last of them: `run:board` draws from those lines.
155
+ // it rolls off only with the positions. A ship-stage `start` is disposable but goes last of them:
156
+ // `run:board` draws from that line. A `setup` line is read only until the child it prepares launches,
157
+ // so every one but the newest goes.
156
158
  const DISPOSABLE_KINDS: ReadonlySet<string> = new Set([
157
159
  EVENT_KIND.SHIP_STAGE,
158
160
  EVENT_KIND.HEARTBEAT,
159
- EVENT_KIND.IDLE,
161
+ EVENT_KIND.SETUP,
160
162
  ])
161
163
 
162
164
  const event_schema = z.object({
@@ -200,9 +202,10 @@ function parse_line(line: string): RunEvent | undefined {
200
202
  return event_schema.safeParse(json_value.parse_or_undefined(line)).data
201
203
  }
202
204
 
203
- // Every event on the stream, oldest first. An absent or unreadable file is an empty stream, the same
204
- // fail-quiet direction `stamp_file.read_stamp_text` takes for a planted or unowned file.
205
- function read_events(target: string): ReadonlyArray<RunEvent> {
205
+ // Every line on the stream that parses, oldest first, whatever its kind. An absent or unreadable file
206
+ // is an empty stream, the same fail-quiet direction `stamp_file.read_stamp_text` takes for a planted or
207
+ // unowned file.
208
+ function read_lines(target: string): ReadonlyArray<RunEvent> {
206
209
  const raw = stamp_file.read_stamp_text(target)
207
210
 
208
211
  if (raw === undefined) return []
@@ -215,6 +218,13 @@ function read_events(target: string): ReadonlyArray<RunEvent> {
215
218
  .filter(is_present)
216
219
  }
217
220
 
221
+ // Every event on the stream, oldest first. A line whose kind the enumeration no longer names — one a
222
+ // previous version wrote — reads as no event, so no reader takes it for a position or for the run's
223
+ // newest activity.
224
+ function read_events(target: string): ReadonlyArray<RunEvent> {
225
+ return read_lines(target).filter((event) => is_event_kind(event.kind))
226
+ }
227
+
218
228
  function last_position(events: ReadonlyArray<RunEvent>): number {
219
229
  return events[events.length - POSITION_INCREMENT]?.pos ?? FIRST_POSITION
220
230
  }
@@ -243,21 +253,23 @@ function is_stage_start(event: RunEvent): boolean {
243
253
  return event.text.split(WORD_SEPARATOR)[STAGE_PHASE_INDEX] === run_ship_stage.PHASE.START
244
254
  }
245
255
 
246
- // A disposable line `run:board` draws from: a ship-stage `start` is a phase on a lane's track, and an
247
- // `idle` line is the deadline of the run's wait.
248
- function is_drawn(event: RunEvent): boolean {
249
- return event.kind === EVENT_KIND.IDLE || is_stage_start(event)
256
+ // The disposable lines the bound never drops: each issue's newest ship-stage and the run's newest
257
+ // `setup` stage, the ones `run:board` draws from now.
258
+ function drawn_lines(events: ReadonlyArray<RunEvent>): ReadonlySet<RunEvent> {
259
+ const setup = events.findLast((event) => event.kind === EVENT_KIND.SETUP)
260
+
261
+ return new Set([...newest_stages(events), ...(setup === undefined ? [] : [setup])])
250
262
  }
251
263
 
252
264
  // The lines the bound may drop, in the order it drops them: the ones that draw nothing first, then the
253
265
  // ones `run:board` draws from, each group oldest first.
254
266
  function droppable(events: ReadonlyArray<RunEvent>): ReadonlyArray<RunEvent> {
255
- const kept = newest_stages(events)
267
+ const kept = drawn_lines(events)
256
268
  const disposable = events.filter((event) => DISPOSABLE_KINDS.has(event.kind) && !kept.has(event))
257
269
 
258
270
  return [
259
- ...disposable.filter((event) => !is_drawn(event)),
260
- ...disposable.filter((event) => is_drawn(event)),
271
+ ...disposable.filter((event) => !is_stage_start(event)),
272
+ ...disposable.filter((event) => is_stage_start(event)),
261
273
  ]
262
274
  }
263
275
 
@@ -269,11 +281,11 @@ function droppable(events: ReadonlyArray<RunEvent>): ReadonlyArray<RunEvent> {
269
281
  //
270
282
  // **Each issue's newest ship-stage is not disposable**: it is the only record `run:board` draws a lane's
271
283
  // review-and-later phase from, and on a stream full of positions it would otherwise roll off in its own
272
- // append.
284
+ // append. **The newest `setup` stage is kept for the same reason**: it is the stage `run:board` draws
285
+ // until the first child launches, and a run that starts on a full stream would otherwise show none.
273
286
  //
274
287
  // **A ship-stage `start` outlasts the other disposable lines**: each one is a phase on the lane's track,
275
- // so it is kept past the `done` lines and the heartbeats. An `idle` line goes with them, oldest first,
276
- // so a run's newest wait is not the first line a stream of starts and positions gives up.
288
+ // so it is kept past the `done` lines and the heartbeats.
277
289
  //
278
290
  // **The kept line carries the history**: on a stream the positions fill, every append still rolls off
279
291
  // the issue's previous stage line, so the newest one lists every stage its
@@ -293,11 +305,12 @@ function serialize(events: ReadonlyArray<RunEvent>): string {
293
305
  }
294
306
 
295
307
  // The read-modify-write, run only while the stream's lock is held. `replace_text_stamp` publishes the
296
- // rewrite with a rename, so a reader never sees the stream absent or half-written.
308
+ // rewrite with a rename, so a reader never sees the stream absent or half-written. The position counts
309
+ // on from every line the file holds, so a line of a retired kind — dropped by this rewrite — never
310
+ // hands its position to a new event.
297
311
  function append_held(target: string, event: Omit<RunEvent, 'pos'>): number {
298
- const existing = read_events(target)
299
- const position = last_position(existing) + POSITION_INCREMENT
300
- const events = bounded([...existing, { pos: position, ...event }])
312
+ const position = last_position(read_lines(target)) + POSITION_INCREMENT
313
+ const events = bounded([...read_events(target), { pos: position, ...event }])
301
314
 
302
315
  stamp_file.replace_text_stamp(target, serialize(events))
303
316
 
@@ -93,7 +93,7 @@ async function collect_carried(carry: RunCarry): Promise<void> {
93
93
  async function collect_merged(directory: string): Promise<void> {
94
94
  const read = run_carry.read_carry(run_carry.carry_path(directory))
95
95
 
96
- if (read.kind !== 'carried' && read.kind !== 'expired') return
96
+ if (read.kind !== 'carried') return
97
97
 
98
98
  try {
99
99
  await collect_carried(read.carry)
@@ -102,7 +102,7 @@ async function read_record(): Promise<{ target: string; carry: RunCarry } | unde
102
102
  const target = run_carry.carry_path(directory)
103
103
  const read = run_carry.read_carry(target)
104
104
 
105
- if (read.kind !== 'carried' && read.kind !== 'expired') return undefined
105
+ if (read.kind !== 'carried') return undefined
106
106
 
107
107
  return { target, carry: read.carry }
108
108
  }
@@ -123,7 +123,7 @@ function is_hold_present(read: HoldRead): boolean {
123
123
  }
124
124
 
125
125
  function is_carry_present(read: CarryRead): boolean {
126
- return read.kind === 'carried' || read.kind === 'expired'
126
+ return read.kind === 'carried'
127
127
  }
128
128
 
129
129
  function hold_present(worktree: string | undefined): boolean {
@@ -44,8 +44,8 @@ function is_cut_capped(carry: RunCarry, source: EnvironmentSource = process.env)
44
44
  }
45
45
 
46
46
  // Only a live, un-handed-off record binds the session to its lanes. After `run:carry --cut` the next
47
- // successor drives them, and after `--end` (`none`) or past the bound (`expired`) there is no run left
48
- // to drive — ending the turn is correct in all three. An unreadable record fails open, as every stop
47
+ // successor drives them, and after `--end` (`none`) there is no run left to drive — ending the turn is
48
+ // correct in both. An unreadable record fails open, as every stop
49
49
  // rule does (`stop-guard.ts`).
50
50
  function is_driving(read: CarryRead | undefined): boolean {
51
51
  return read?.kind === 'carried' && read.carry.is_handed_off !== true
@@ -25,7 +25,6 @@ const USAGE = 'Usage: josh run:status <issue-number> [--repo <owner/repo>]'
25
25
  const CARRY_NONE_NOTE = 'no run recorded here'
26
26
  const CARRY_UNREADABLE_NOTE = '(run record not bundled: unreadable)'
27
27
  const CARRY_SUMMARY_NONE = 'none'
28
- const CARRY_SUMMARY_EXPIRED = 'expired'
29
28
  const CARRY_SUMMARY_UNREADABLE = 'unreadable'
30
29
  const MERGED_SUFFIX = ' merged'
31
30
 
@@ -94,16 +93,13 @@ function failure_note(issue_number: string, kind: string): string {
94
93
 
95
94
  function carry_summary(carry: CarryRead): string {
96
95
  if (carry.kind === 'carried') return `${String(carry.carry.merged)}${MERGED_SUFFIX}`
97
- if (carry.kind === 'expired') return CARRY_SUMMARY_EXPIRED
98
96
  if (carry.kind === 'unreadable') return CARRY_SUMMARY_UNREADABLE
99
97
 
100
98
  return CARRY_SUMMARY_NONE
101
99
  }
102
100
 
103
101
  function carry_detail(carry: CarryRead): string {
104
- if (carry.kind === 'carried' || carry.kind === 'expired') {
105
- return run_carry.describe_carry(carry.carry)
106
- }
102
+ if (carry.kind === 'carried') return run_carry.describe_carry(carry.carry)
107
103
 
108
104
  if (carry.kind === 'unreadable') return CARRY_UNREADABLE_NOTE
109
105
 
@@ -46,11 +46,6 @@ const VOCABULARY: ReadonlyArray<string> = [
46
46
  UNKNOWN,
47
47
  ]
48
48
 
49
- // The whole-run bound is spent, and carrying the budget or discarding it is a person's call — the one
50
- // Tier-B point this machine surfaces rather than choosing.
51
- const EXPIRED_DECISION =
52
- 'decide: whole-run budget spent — resume with `pnpm josh run:carry --resume` | discard with `pnpm josh run:carry --end`'
53
-
54
49
  // The one step a stopped run still owes before it ends: the end-of-run retrospective.
55
50
  // It reads the run's own measurements and files the improvements worth carrying into the next run;
56
51
  // the command is kit-only, so it means nothing — and is never printed — in a consumer project.
@@ -288,12 +283,11 @@ function is_handed_off_parent(input: CarryFacts): boolean {
288
283
  return input.is_handed_off && input.carry_kind === 'carried' && !input.is_lane_child
289
284
  }
290
285
 
291
- // The carry record's terminal answers: an unreadable record leaves the position unknowable, a handed-off
292
- // run is its successor's, and a spent budget is the person's call.
286
+ // The carry record's terminal answers: an unreadable record leaves the position unknowable, and a
287
+ // handed-off run is its successor's.
293
288
  function carry_terminal_action(input: CarryFacts): StepAction | undefined {
294
289
  if (input.carry_kind === 'unreadable') return verdict(UNKNOWN)
295
290
  if (is_handed_off_parent(input)) return verdict(STOP)
296
- if (input.carry_kind === 'expired') return { kind: 'decide', line: EXPIRED_DECISION }
297
291
 
298
292
  return undefined
299
293
  }
@@ -371,7 +365,6 @@ function next_action(input: StepInput): StepAction {
371
365
 
372
366
  const run_step = {
373
367
  ALREADY_DONE,
374
- EXPIRED_DECISION,
375
368
  HAND_OFF_COMMAND,
376
369
  HUMAN_REVIEW,
377
370
  IMPLEMENT,
@@ -28,7 +28,7 @@ const STOP_RECOVERY =
28
28
  // nothing readable there is no run this can honestly say stopped.
29
29
  function plan(read: CarryRead, reason: string | undefined): StopNotice | undefined {
30
30
  if (reason === undefined || reason.length === 0) return undefined
31
- if (read.kind !== 'carried' && read.kind !== 'expired') return undefined
31
+ if (read.kind !== 'carried') return undefined
32
32
 
33
33
  return { reason }
34
34
  }
@@ -1,5 +1,6 @@
1
1
  import { telegram_notify } from '#scripts/notify/telegram-notify'
2
2
  import { run_carry, type CarryRead, type RunCarry } from '#scripts/run/carry/run-carry'
3
+ import { run_carry_ended } from '#scripts/run/carry/run-carry-ended'
3
4
  import { run_event_stream } from '#scripts/run/event/run-event-stream'
4
5
  import { run_event_stream_emit } from '#scripts/run/event/run-event-stream-emit'
5
6
  import { run_wake, type RunWake } from '#scripts/run/wake/run-wake'
@@ -18,7 +19,7 @@ import {
18
19
  //
19
20
  // **Cheap and local.** Every read is a file on disk — the carry record, the wake record — and a process
20
21
  // liveness probe; there is no network call, so unlike the stall detector this makes every read every
21
- // time. The gate is the carry record itself: nothing carried, or a record that is expired or unreadable,
22
+ // time. The gate is the carry record itself: nothing carried, or a record that is unreadable,
22
23
  // returns before the wake record is even read.
23
24
 
24
25
  // The recovery line names the command that hands the run to a fresh supervisor — the same one the pure
@@ -27,13 +28,14 @@ import {
27
28
  // so recovery is another actor's.
28
29
  const STRAND_RECOVERY = `Restart supervision with \`${run_stranded.RECOVERY_COMMAND}\` — it wakes a fresh successor to claim the budget; the cutting session must not resume it itself.`
29
30
 
30
- // The seams a test replaces: the two record targets, the two record reads, the owner and supervisor
31
- // liveness probes, and the two side effects. The defaults are the real readers; a test hands fakes so
31
+ // The seams a test replaces: the two record targets, the two record reads, the ended-run read, the owner
32
+ // and supervisor liveness probes, and the two side effects. The defaults are the real readers; a test hands fakes so
32
33
  // the gather and report logic run without a git tree, a process table or a network.
33
34
  interface DetectPorts {
34
35
  resolve_targets: () => Promise<RecordTargets | undefined>
35
36
  read_carry: (target: string) => CarryRead
36
37
  is_owner_live: (carry: RunCarry) => boolean
38
+ has_ended: (target: string, started_at: string) => boolean
37
39
  read_wake: (target: string) => RunWake | undefined
38
40
  supervisor_liveness: (wake: RunWake | undefined) => SupervisorLiveness
39
41
  emit_stranded: (text: string) => Promise<boolean>
@@ -45,15 +47,25 @@ interface RecordTargets {
45
47
  wake: string
46
48
  }
47
49
 
50
+ // The run a supervisor was watching: its carry record, where `run:carry --end` records the run it
51
+ // removes, and the `started_at` the supervisor read when it took the run on.
52
+ interface WatchedRun {
53
+ carry_target: string
54
+ ended_target: string
55
+ started_at: string
56
+ }
57
+
58
+ function targets_of(directory: string): RecordTargets {
59
+ return { carry: run_carry.carry_path(directory), wake: run_wake.wake_path(directory) }
60
+ }
61
+
48
62
  // Both records key on the common git directory, the same one `run-carry.ts` and `run-wake.ts` resolve,
49
63
  // so a strand read from any lane reads the one run's records. `undefined` outside a repository: no git
50
64
  // directory to key on, so no run to be stranded.
51
65
  async function real_resolve_targets(): Promise<RecordTargets | undefined> {
52
66
  const directory = await run_carry.repository_directory()
53
67
 
54
- if (directory === undefined) return undefined
55
-
56
- return { carry: run_carry.carry_path(directory), wake: run_wake.wake_path(directory) }
68
+ return directory === undefined ? undefined : targets_of(directory)
57
69
  }
58
70
 
59
71
  async function real_emit_stranded(text: string): Promise<boolean> {
@@ -68,20 +80,17 @@ const DEFAULT_PORTS: DetectPorts = {
68
80
  resolve_targets: real_resolve_targets,
69
81
  read_carry: (target) => run_carry.read_carry(target),
70
82
  is_owner_live: run_carry.is_owner_live,
83
+ has_ended: run_carry_ended.has_ended,
71
84
  read_wake: (target) => run_wake.read_wake(target),
72
85
  supervisor_liveness: (wake) => run_stranded.supervisor_liveness(wake),
73
86
  emit_stranded: real_emit_stranded,
74
87
  notify_stranded: real_notify_stranded,
75
88
  }
76
89
 
77
- // The three facts, assembled behind the carry-record gate. A non-`carried` read — nothing carried, a
78
- // spent bound, or an unreadable record — is `undefined`: there is no live budget to be stranded, so the
90
+ // The three facts, assembled behind the carry-record gate. A non-`carried` read — nothing carried, or
91
+ // an unreadable record — is `undefined`: there is no live budget to be stranded, so the
79
92
  // wake record is never read and the judge is never asked.
80
- async function gather(ports: DetectPorts): Promise<StrandedInput | undefined> {
81
- const targets = await ports.resolve_targets()
82
-
83
- if (targets === undefined) return undefined
84
-
93
+ function input_at(ports: DetectPorts, targets: RecordTargets): StrandedInput | undefined {
85
94
  const read = ports.read_carry(targets.carry)
86
95
 
87
96
  if (read.kind !== 'carried') return undefined
@@ -93,6 +102,12 @@ async function gather(ports: DetectPorts): Promise<StrandedInput | undefined> {
93
102
  }
94
103
  }
95
104
 
105
+ async function gather(ports: DetectPorts): Promise<StrandedInput | undefined> {
106
+ const targets = await ports.resolve_targets()
107
+
108
+ return targets === undefined ? undefined : input_at(ports, targets)
109
+ }
110
+
96
111
  // The marker and the notification, both once per strand episode. `emit_stranded` refuses a second while
97
112
  // the strand event is still the newest, and its `false` is what holds the notification back too — so a
98
113
  // strand polled every stop reaches the person once, not once a turn.
@@ -128,7 +143,38 @@ async function run_stranded_check(): Promise<void> {
128
143
  }
129
144
  }
130
145
 
131
- const run_stranded_detect = { detect_and_report, run_stranded_check }
146
+ // The same judgement for a reader that draws the run rather than reports on it: `run:board` asks it
147
+ // every local read, so a run nobody is driving is not drawn as one still waiting for work.
148
+ function is_stranded_at(directory: string, ports: DetectPorts = DEFAULT_PORTS): boolean {
149
+ const input = input_at(ports, targets_of(directory))
150
+
151
+ return input !== undefined && run_stranded.is_stranded(input)
152
+ }
153
+
154
+ // The judgement a supervisor asks of the run it is about to stop watching. The asker is that
155
+ // supervisor on its way out, so it is taken as gone, and so is an owner that is the asker itself
156
+ // (`run_carry.is_held_by_another`) — that owner is about to be gone. A
157
+ // record another session holds is not left stranded. **A record that does not read is left stranded
158
+ // unless `run:carry --end` ended this very run**: the read that failed the driver fails here too, and
159
+ // that run still has its budget and nobody watching it.
160
+ function is_left_stranded(run: WatchedRun, ports: DetectPorts = DEFAULT_PORTS): boolean {
161
+ const read = ports.read_carry(run.carry_target)
162
+
163
+ if (read.kind !== 'carried') return !ports.has_ended(run.ended_target, run.started_at)
164
+
165
+ return run_stranded.is_stranded({
166
+ read,
167
+ is_owner_live: run_carry.is_held_by_another(read.carry, ports.is_owner_live),
168
+ supervisor: run_stranded.GONE,
169
+ })
170
+ }
171
+
172
+ const run_stranded_detect = {
173
+ detect_and_report,
174
+ is_left_stranded,
175
+ is_stranded_at,
176
+ run_stranded_check,
177
+ }
132
178
 
133
179
  export { run_stranded_detect }
134
- export type { DetectPorts, RecordTargets }
180
+ export type { DetectPorts, RecordTargets, WatchedRun }
@@ -2,10 +2,10 @@ import { process_identity } from '#scripts/josh/process-identity'
2
2
  import { run_carry, type CarryRead, type RunCarry } from '#scripts/run/carry/run-carry'
3
3
  import type { RunWake } from '#scripts/run/wake/run-wake'
4
4
 
5
- // The mechanical judge of a *stranded* run: a handed-off budget whose cutting session died, with no
6
- // successor claiming it and no supervisor watching, so lanes run on with nobody driving. Three facts,
7
- // read and never weighed, are what say that state has been reached: the budget was handed off, its
8
- // owner is gone, and no supervisor is alive to wake a successor.
5
+ // The mechanical judge of a *stranded* run: a budget whose owning session died — at a cut it handed
6
+ // off, or with no hand-off at all — with no successor holding it and no supervisor watching, so lanes
7
+ // run on with nobody driving. Three facts, read and never weighed, are what say that state has been
8
+ // reached: the record names a driver, that owner is gone, and no supervisor is alive to wake a successor.
9
9
  //
10
10
  // **Pure by design, exactly as `backlog-stalled.ts` is.** The carry read, the owner's liveness and the
11
11
  // supervisor's are handed in, so the four acceptance cases — all three holding, a live owner, a claimed
@@ -72,19 +72,27 @@ function supervisor_liveness(
72
72
  return is_same ? LIVE : GONE
73
73
  }
74
74
 
75
- // **The whole of the judgement, in four reads.** The carry record must be `carried` — an `expired`,
76
- // `none` or `unreadable` read is never a strand, because there is either no budget to strand or no way
77
- // to trust what the record says. It must be handed off and *not* yet adopted — `adopt_carry` spends
78
- // `is_handed_off` the instant a successor claims it, so a claimed successor reads `false` here. The owner
79
- // must be gone — a live owner is a session still spending the budget, the ordinary in-flight state. And
80
- // the supervisor must be `gone` — `live` or `unknown` both count as something that could still wake a
81
- // successor, so only a proven-absent supervisor completes the strand.
75
+ // Whether the record names a driver whose absence can be proven: a hand-off waiting for its successor,
76
+ // or an owner process. A record with neither was begun by nobody this machine can probe, and an
77
+ // unprovable owner is never a strand.
78
+ function had_driver(carry: RunCarry): boolean {
79
+ return carry.is_handed_off === true || carry.owner_pid !== undefined
80
+ }
81
+
82
+ // **The whole of the judgement, in four reads.** The carry record must be `carried` — a `none` or
83
+ // `unreadable` read is never a strand, because there is either no run to strand or no way to trust
84
+ // what the record says. The owner must be gone — a live owner is a session still spending the budget,
85
+ // the ordinary in-flight state, and a successor that claimed a hand-off is that live owner, since
86
+ // `adopt_carry` rewrites the owner as it spends `is_handed_off`. The record must name the driver that
87
+ // is gone (`had_driver`). And the supervisor must be `gone` — `live` or `unknown` both count as
88
+ // something that could still wake a successor, so only a proven-absent supervisor completes the strand.
89
+ //
90
+ // **A hand-off is not required.** An owner that died between its claim and the next cut — the
91
+ // supervisor killed mid-drive, an interactive parent's terminal closed — leaves a record no cut handed
92
+ // off, and with no supervisor nothing drives it either. `run-wake.ts` → `decide_not_handed_off`
93
+ // recovers exactly that record, so the recovery the notification names holds for it too.
82
94
  function is_stranded(input: StrandedInput): boolean {
83
- if (
84
- input.read.kind !== 'carried' ||
85
- input.read.carry.is_handed_off !== true ||
86
- input.is_owner_live
87
- ) {
95
+ if (input.read.kind !== 'carried' || input.is_owner_live || !had_driver(input.read.carry)) {
88
96
  return false
89
97
  }
90
98
 
@@ -94,7 +102,7 @@ function is_stranded(input: StrandedInput): boolean {
94
102
  // The one line a person reads in the notification and on the stream: which run is stranded, and why. The
95
103
  // carry record's own summary carries the counts; this frames it as the strand it is.
96
104
  function describe(carry: RunCarry): string {
97
- return `Run stranded — ${run_carry.describe_carry(carry)}. The owner is gone, no cut successor claimed the budget, and no supervisor is watching.`
105
+ return `Run stranded — ${run_carry.describe_carry(carry)}. The owner is gone, no successor holds the budget, and no supervisor is watching.`
98
106
  }
99
107
 
100
108
  function verdict_of(is_run_stranded: boolean): StrandedVerdict {