@joshuafolkken/kit 1.1379.0 → 1.1383.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 (70) 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-YSDAH73S.js → chunk-2X2ART5G.js} +2 -2
  6. package/dist/hooks/{chunk-RW4XMF47.js → chunk-4MGHBTOM.js} +125 -232
  7. package/dist/hooks/{chunk-TG2AB3W6.js → chunk-6AC4C2IO.js} +45 -55
  8. package/dist/hooks/{chunk-HGA4EG5S.js → chunk-6MAW7BCM.js} +12 -12
  9. package/dist/hooks/{chunk-QARPP36N.js → chunk-7VSZ2N4B.js} +3 -3
  10. package/dist/hooks/{chunk-DHSKBAQ2.js → chunk-KC6LUZZ2.js} +1 -1
  11. package/dist/hooks/{chunk-4RWRP3FF.js → chunk-KCF2YE7O.js} +5 -5
  12. package/dist/hooks/{chunk-E7LZL5AN.js → chunk-LC56R53A.js} +3 -3
  13. package/dist/hooks/{chunk-546X4PX3.js → chunk-LOJTBU5V.js} +3 -3
  14. package/dist/hooks/{chunk-7ZM464OW.js → chunk-RYP3TSUJ.js} +3 -3
  15. package/dist/hooks/{chunk-TORRSNDJ.js → chunk-TT742SIO.js} +2 -2
  16. package/dist/hooks/{chunk-NBEAGPS4.js → chunk-Y5R47QHU.js} +1 -1
  17. package/dist/hooks/{chunk-PV3FWDSQ.js → chunk-YKUITFT7.js} +2 -2
  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 +199 -200
  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 +3 -3
  25. package/dist/hooks/stop-guard.js +19 -15
  26. package/dist/josh.js +1 -1
  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 +10 -3
  32. package/scripts/backlog/backlog-drive.ts +5 -2
  33. package/scripts/backlog/backlog-offer-cli.ts +1 -15
  34. package/scripts/epic/epic-candidate-confirm.ts +1 -1
  35. package/scripts/epic/epic-cross-repo.ts +4 -4
  36. package/scripts/epic/epic-next.ts +1 -1
  37. package/scripts/hooks/stop-guard.ts +20 -9
  38. package/scripts/josh/josh-commands-ai.ts +1 -1
  39. package/scripts/lane/lane-cli.ts +1 -3
  40. package/scripts/run/board/run-board-header.ts +107 -84
  41. package/scripts/run/board/run-board-labels.ts +18 -17
  42. package/scripts/run/board/run-board-machine.ts +21 -8
  43. package/scripts/run/board/run-board-render.ts +1 -5
  44. package/scripts/run/board/run-board-span.ts +0 -1
  45. package/scripts/run/board/run-board-status.ts +0 -17
  46. package/scripts/run/board/run-board-tick.ts +1 -0
  47. package/scripts/run/carry/run-carry-cli.ts +8 -62
  48. package/scripts/run/carry/run-carry-ended.ts +1 -1
  49. package/scripts/run/carry/run-carry.ts +22 -68
  50. package/scripts/run/cut/run-cut.ts +4 -4
  51. package/scripts/run/event/run-event-render.ts +0 -1
  52. package/scripts/run/event/run-event-scope.ts +4 -8
  53. package/scripts/run/event/run-event-stream-emit.ts +0 -25
  54. package/scripts/run/event/run-event-stream.ts +23 -31
  55. package/scripts/run/merge/run-merge-collect.ts +1 -1
  56. package/scripts/run/merge/run-merge-steps.ts +1 -1
  57. package/scripts/run/progress/run-progress-read.ts +1 -1
  58. package/scripts/run/run-headless.ts +2 -2
  59. package/scripts/run/run-status-cli.ts +1 -5
  60. package/scripts/run/run-step.ts +2 -9
  61. package/scripts/run/run-stop-notify.ts +1 -1
  62. package/scripts/run/run-stranded-detect.ts +3 -3
  63. package/scripts/run/run-stranded.ts +25 -17
  64. package/scripts/run/wake/run-wake-cli.ts +7 -9
  65. package/scripts/run/wake/run-wake-describe.ts +3 -5
  66. package/scripts/run/wake/run-wake-driver.ts +3 -3
  67. package/scripts/run/wake/run-wake-loop.ts +4 -21
  68. package/scripts/run/wake/run-wake-restart.ts +1 -1
  69. package/scripts/run/wake/run-wake.ts +6 -7
  70. package/scripts/backlog/backlog-idle.ts +0 -88
@@ -39,7 +39,6 @@ const RESUMED_VERDICT = 'resumed'
39
39
  const CARRIED_VERDICT = 'carried'
40
40
  const COUNTED_VERDICT = 'counted'
41
41
  const ENDED_VERDICT = 'ended'
42
- const EXPIRED_VERDICT = 'expired'
43
42
  const NONE_VERDICT = 'none'
44
43
  const UNREADABLE_VERDICT = 'unreadable'
45
44
  const MISMATCH_VERDICT = 'mismatch'
@@ -94,28 +93,14 @@ function report_unreadable(is_json: boolean): number {
94
93
  return report(UNREADABLE_VERDICT, undefined, is_json, FAILURE_EXIT_CODE)
95
94
  }
96
95
 
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
96
  function report_read(read: CarryRead, is_json: boolean): number {
104
97
  if (read.kind === 'none') return report(NONE_VERDICT, undefined, is_json, SUCCESS_EXIT_CODE)
105
98
 
106
99
  if (read.kind === 'unreadable') return report_unreadable(is_json)
107
100
 
108
- if (read.kind === 'expired') return report_expired(read.carry, is_json)
109
-
110
101
  return report_carry(CARRIED_VERDICT, read.carry, is_json)
111
102
  }
112
103
 
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
104
  function refusal_message(claim: CarryClaim, carry: RunCarry, invocation: string): string {
120
105
  if (claim === 'busy') return run_carry.busy_message(carry)
121
106
 
@@ -149,7 +134,7 @@ function report_adoption(
149
134
 
150
135
  // **Whose record it is, is the classifier's answer and not this command's guess.** `--end` is only
151
136
  // 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
137
+ // standing until a person carries or discards it; `resumed` is reached only where the run itself declared the
153
138
  // cut, or where `--resume` says the reader has decided to carry it.
154
139
  function report_claim(
155
140
  target: string,
@@ -166,49 +151,17 @@ function report_claim(
166
151
  return report(CLAIM_VERDICTS[claim], carry, is_json, FAILURE_EXIT_CODE)
167
152
  }
168
153
 
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)
154
+ function start_fresh(target: string, request: CarryClaimRequest, is_json: boolean): number {
155
+ const carry = run_carry.begin_carry(target, request.invocation, request.owner)
178
156
 
179
157
  if (carry === undefined) return report_lost(is_json)
180
158
 
181
159
  return report_carry(BEGAN_VERDICT, carry, is_json)
182
160
  }
183
161
 
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(
188
- target: string,
189
- carry: RunCarry,
190
- request: CarryClaimRequest,
191
- is_json: boolean,
192
- ): number {
193
- if (run_carry.is_foreign_live_owner(carry, request.owner)) return report_busy(carry, is_json)
194
-
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)
201
-
202
- console.error(run_carry.expired_message(carry))
203
-
204
- return start_fresh(target, request, true, is_json)
205
- }
206
-
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.
162
+ // **A standing record is never replaced, and never resumed into by something else.** Replacing one
163
+ // would restart the budget the cut exists to carry, and resuming into one would put two parents on a
164
+ // single budget. Its age changes none of that: a record is replaced by nothing but a person's `--end`.
212
165
  function begin(target: string, request: CarryClaimRequest, is_json: boolean): number {
213
166
  const read = run_carry.read_carry(target)
214
167
 
@@ -216,9 +169,7 @@ function begin(target: string, request: CarryClaimRequest, is_json: boolean): nu
216
169
 
217
170
  if (read.kind === 'carried') return report_claim(target, read.carry, request, is_json)
218
171
 
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)
172
+ return start_fresh(target, request, is_json)
222
173
  }
223
174
 
224
175
  function report_nothing_to_resume(is_json: boolean): number {
@@ -237,8 +188,6 @@ function adopt(target: string, request: CarryClaimRequest, is_json: boolean): nu
237
188
 
238
189
  if (read.kind === 'unreadable') return report_unreadable(is_json)
239
190
 
240
- if (read.kind === 'expired') return report_expired(read.carry, is_json)
241
-
242
191
  return report_claim(target, read.carry, request, is_json)
243
192
  }
244
193
 
@@ -334,9 +283,7 @@ async function count(
334
283
 
335
284
  await record_retrospective(request)
336
285
 
337
- return read.kind === 'expired'
338
- ? report_expired(carry, is_json)
339
- : report_carry(COUNTED_VERDICT, carry, is_json)
286
+ return report_carry(COUNTED_VERDICT, carry, is_json)
340
287
  }
341
288
 
342
289
  // **A stop over a record that is still there pushes one ⏸️ confirmation before the record goes**.
@@ -463,7 +410,6 @@ const run_carry_cli = {
463
410
  CARRIED_VERDICT,
464
411
  COUNTED_VERDICT,
465
412
  ENDED_VERDICT,
466
- EXPIRED_VERDICT,
467
413
  MISMATCH_VERDICT,
468
414
  NONE_VERDICT,
469
415
  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()
@@ -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
- }
248
-
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
- }
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' }
252
230
 
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.
@@ -424,9 +389,7 @@ function end_carry(target: string): void {
424
389
  // no record — `none` or `unreadable` — is not a run whose retrospective has run, so it answers `false`;
425
390
  // `run:step` reads it to decide whether the stop position still owes a retrospective.
426
391
  function retrospective_done_of(read: CarryRead): boolean {
427
- if (read.kind !== 'carried' && read.kind !== 'expired') return false
428
-
429
- return read.carry.retrospective === true
392
+ return read.kind === 'carried' && read.carry.retrospective === true
430
393
  }
431
394
 
432
395
  // **What the resumed session has to be told, computed rather than stored.** A stored remainder would
@@ -458,10 +421,6 @@ function describe_carry(carry: RunCarry): string {
458
421
  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
422
  }
460
423
 
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
424
  // A record left standing by a run that never reached `--end`. Naming both invocations is the point:
466
425
  // the reader has to see that the budget in the way belongs to something else before deciding whether
467
426
  // that run is over.
@@ -525,8 +484,6 @@ function unknown_message(): string {
525
484
  }
526
485
 
527
486
  const run_carry = {
528
- CARRY_MAX_AGE_HOURS,
529
- CARRY_MAX_AGE_MS,
530
487
  END_COMMAND,
531
488
  MAX_CUTS,
532
489
  NO_OWNER,
@@ -543,11 +500,9 @@ const run_carry = {
543
500
  is_at_cut_cap,
544
501
  describe_carry,
545
502
  end_carry,
546
- expired_message,
547
503
  mismatch_message,
548
504
  fresh_carry,
549
505
  is_count_refused,
550
- is_expired,
551
506
  is_foreign_live_owner,
552
507
  is_owner_live,
553
508
  lost_message,
@@ -556,7 +511,6 @@ const run_carry = {
556
511
  parse_carry,
557
512
  read_carry,
558
513
  remaining_of,
559
- replace_carry,
560
514
  repository_directory,
561
515
  retrospective_done_of,
562
516
  standing_message,
@@ -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,7 +39,6 @@ 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' },
@@ -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
@@ -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
@@ -106,11 +106,6 @@ const EVENT_KIND = {
106
106
  // The position that hands control back: `run:step` prints the command that
107
107
  // shows the stopped report, so the agent fixes it and relaunches rather than rebuilding the context.
108
108
  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
109
  // An Issue the run filed on its way, written by `issue:file`, so a run started from the command line
115
110
  // still surfaces what it found — `run:board`'s findings section reads it.
116
111
  FILED: 'filed',
@@ -141,7 +136,6 @@ const EVENT_KINDS: ReadonlyArray<string> = Object.values(EVENT_KIND)
141
136
  const TRACE_KINDS: ReadonlySet<string> = new Set([
142
137
  EVENT_KIND.SHIP_STAGE,
143
138
  EVENT_KIND.HEARTBEAT,
144
- EVENT_KIND.IDLE,
145
139
  EVENT_KIND.FILED,
146
140
  EVENT_KIND.NOTE,
147
141
  EVENT_KIND.LANE_PHASE,
@@ -151,13 +145,9 @@ const TRACE_KINDS: ReadonlySet<string> = new Set([
151
145
 
152
146
  // The trace kinds that say only that the run was alive, so the bound rolls them off first. A `filed` or
153
147
  // `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.
156
- const DISPOSABLE_KINDS: ReadonlySet<string> = new Set([
157
- EVENT_KIND.SHIP_STAGE,
158
- EVENT_KIND.HEARTBEAT,
159
- EVENT_KIND.IDLE,
160
- ])
148
+ // it rolls off only with the positions. A ship-stage `start` is disposable but goes last of them:
149
+ // `run:board` draws from that line.
150
+ const DISPOSABLE_KINDS: ReadonlySet<string> = new Set([EVENT_KIND.SHIP_STAGE, EVENT_KIND.HEARTBEAT])
161
151
 
162
152
  const event_schema = z.object({
163
153
  pos: z.number(),
@@ -200,9 +190,10 @@ function parse_line(line: string): RunEvent | undefined {
200
190
  return event_schema.safeParse(json_value.parse_or_undefined(line)).data
201
191
  }
202
192
 
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> {
193
+ // Every line on the stream that parses, oldest first, whatever its kind. An absent or unreadable file
194
+ // is an empty stream, the same fail-quiet direction `stamp_file.read_stamp_text` takes for a planted or
195
+ // unowned file.
196
+ function read_lines(target: string): ReadonlyArray<RunEvent> {
206
197
  const raw = stamp_file.read_stamp_text(target)
207
198
 
208
199
  if (raw === undefined) return []
@@ -215,6 +206,13 @@ function read_events(target: string): ReadonlyArray<RunEvent> {
215
206
  .filter(is_present)
216
207
  }
217
208
 
209
+ // Every event on the stream, oldest first. A line whose kind the enumeration no longer names — one a
210
+ // previous version wrote — reads as no event, so no reader takes it for a position or for the run's
211
+ // newest activity.
212
+ function read_events(target: string): ReadonlyArray<RunEvent> {
213
+ return read_lines(target).filter((event) => is_event_kind(event.kind))
214
+ }
215
+
218
216
  function last_position(events: ReadonlyArray<RunEvent>): number {
219
217
  return events[events.length - POSITION_INCREMENT]?.pos ?? FIRST_POSITION
220
218
  }
@@ -243,12 +241,6 @@ function is_stage_start(event: RunEvent): boolean {
243
241
  return event.text.split(WORD_SEPARATOR)[STAGE_PHASE_INDEX] === run_ship_stage.PHASE.START
244
242
  }
245
243
 
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)
250
- }
251
-
252
244
  // The lines the bound may drop, in the order it drops them: the ones that draw nothing first, then the
253
245
  // ones `run:board` draws from, each group oldest first.
254
246
  function droppable(events: ReadonlyArray<RunEvent>): ReadonlyArray<RunEvent> {
@@ -256,8 +248,8 @@ function droppable(events: ReadonlyArray<RunEvent>): ReadonlyArray<RunEvent> {
256
248
  const disposable = events.filter((event) => DISPOSABLE_KINDS.has(event.kind) && !kept.has(event))
257
249
 
258
250
  return [
259
- ...disposable.filter((event) => !is_drawn(event)),
260
- ...disposable.filter((event) => is_drawn(event)),
251
+ ...disposable.filter((event) => !is_stage_start(event)),
252
+ ...disposable.filter((event) => is_stage_start(event)),
261
253
  ]
262
254
  }
263
255
 
@@ -272,8 +264,7 @@ function droppable(events: ReadonlyArray<RunEvent>): ReadonlyArray<RunEvent> {
272
264
  // append.
273
265
  //
274
266
  // **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.
267
+ // so it is kept past the `done` lines and the heartbeats.
277
268
  //
278
269
  // **The kept line carries the history**: on a stream the positions fill, every append still rolls off
279
270
  // the issue's previous stage line, so the newest one lists every stage its
@@ -293,11 +284,12 @@ function serialize(events: ReadonlyArray<RunEvent>): string {
293
284
  }
294
285
 
295
286
  // 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.
287
+ // rewrite with a rename, so a reader never sees the stream absent or half-written. The position counts
288
+ // on from every line the file holds, so a line of a retired kind — dropped by this rewrite — never
289
+ // hands its position to a new event.
297
290
  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 }])
291
+ const position = last_position(read_lines(target)) + POSITION_INCREMENT
292
+ const events = bounded([...read_events(target), { pos: position, ...event }])
301
293
 
302
294
  stamp_file.replace_text_stamp(target, serialize(events))
303
295
 
@@ -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,