@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.
- package/.claude/skills/workflow-commands/backlogrun-progress.md +2 -3
- package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -4
- package/.claude/skills/workflow-commands/retrospective.md +1 -2
- package/dist/hooks/batch-guard.js +6 -6
- package/dist/hooks/{chunk-YSDAH73S.js → chunk-2X2ART5G.js} +2 -2
- package/dist/hooks/{chunk-RW4XMF47.js → chunk-4MGHBTOM.js} +125 -232
- package/dist/hooks/{chunk-TG2AB3W6.js → chunk-6AC4C2IO.js} +45 -55
- package/dist/hooks/{chunk-HGA4EG5S.js → chunk-6MAW7BCM.js} +12 -12
- package/dist/hooks/{chunk-QARPP36N.js → chunk-7VSZ2N4B.js} +3 -3
- package/dist/hooks/{chunk-DHSKBAQ2.js → chunk-KC6LUZZ2.js} +1 -1
- package/dist/hooks/{chunk-4RWRP3FF.js → chunk-KCF2YE7O.js} +5 -5
- package/dist/hooks/{chunk-E7LZL5AN.js → chunk-LC56R53A.js} +3 -3
- package/dist/hooks/{chunk-546X4PX3.js → chunk-LOJTBU5V.js} +3 -3
- package/dist/hooks/{chunk-7ZM464OW.js → chunk-RYP3TSUJ.js} +3 -3
- package/dist/hooks/{chunk-TORRSNDJ.js → chunk-TT742SIO.js} +2 -2
- package/dist/hooks/{chunk-NBEAGPS4.js → chunk-Y5R47QHU.js} +1 -1
- package/dist/hooks/{chunk-PV3FWDSQ.js → chunk-YKUITFT7.js} +2 -2
- package/dist/hooks/codex-hook-adapter.js +13 -13
- package/dist/hooks/duplicate-read-guard.js +7 -7
- package/dist/hooks/format-edited.js +3 -3
- package/dist/hooks/inputs.json +199 -200
- package/dist/hooks/investigation-guard.js +7 -7
- package/dist/hooks/pretool-guard.js +12 -12
- package/dist/hooks/session-lang.js +3 -3
- package/dist/hooks/stop-guard.js +19 -15
- package/dist/josh.js +1 -1
- package/package.json +1 -1
- package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
- package/scripts/backlog/backlog-budget-cli.ts +2 -20
- package/scripts/backlog/backlog-budget.ts +72 -109
- package/scripts/backlog/backlog-drive-cli.ts +10 -3
- package/scripts/backlog/backlog-drive.ts +5 -2
- package/scripts/backlog/backlog-offer-cli.ts +1 -15
- package/scripts/epic/epic-candidate-confirm.ts +1 -1
- package/scripts/epic/epic-cross-repo.ts +4 -4
- package/scripts/epic/epic-next.ts +1 -1
- package/scripts/hooks/stop-guard.ts +20 -9
- package/scripts/josh/josh-commands-ai.ts +1 -1
- package/scripts/lane/lane-cli.ts +1 -3
- package/scripts/run/board/run-board-header.ts +107 -84
- package/scripts/run/board/run-board-labels.ts +18 -17
- package/scripts/run/board/run-board-machine.ts +21 -8
- package/scripts/run/board/run-board-render.ts +1 -5
- package/scripts/run/board/run-board-span.ts +0 -1
- package/scripts/run/board/run-board-status.ts +0 -17
- package/scripts/run/board/run-board-tick.ts +1 -0
- package/scripts/run/carry/run-carry-cli.ts +8 -62
- package/scripts/run/carry/run-carry-ended.ts +1 -1
- package/scripts/run/carry/run-carry.ts +22 -68
- package/scripts/run/cut/run-cut.ts +4 -4
- package/scripts/run/event/run-event-render.ts +0 -1
- package/scripts/run/event/run-event-scope.ts +4 -8
- package/scripts/run/event/run-event-stream-emit.ts +0 -25
- package/scripts/run/event/run-event-stream.ts +23 -31
- package/scripts/run/merge/run-merge-collect.ts +1 -1
- package/scripts/run/merge/run-merge-steps.ts +1 -1
- package/scripts/run/progress/run-progress-read.ts +1 -1
- package/scripts/run/run-headless.ts +2 -2
- package/scripts/run/run-status-cli.ts +1 -5
- package/scripts/run/run-step.ts +2 -9
- package/scripts/run/run-stop-notify.ts +1 -1
- package/scripts/run/run-stranded-detect.ts +3 -3
- package/scripts/run/run-stranded.ts +25 -17
- package/scripts/run/wake/run-wake-cli.ts +7 -9
- package/scripts/run/wake/run-wake-describe.ts +3 -5
- package/scripts/run/wake/run-wake-driver.ts +3 -3
- package/scripts/run/wake/run-wake-loop.ts +4 -21
- package/scripts/run/wake/run-wake-restart.ts +1 -1
- package/scripts/run/wake/run-wake.ts +6 -7
- 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
|
|
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
|
|
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
|
-
// **
|
|
185
|
-
//
|
|
186
|
-
//
|
|
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
|
-
|
|
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
|
|
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'
|
|
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
|
|
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
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
254
|
-
const read = stamp_record.classify(raw, CARRY_SPEC, now)
|
|
231
|
+
const carry = parse_carry(raw)
|
|
255
232
|
|
|
256
|
-
if (
|
|
233
|
+
if (carry === undefined || Number.isNaN(Date.parse(carry.started_at))) {
|
|
234
|
+
return { kind: 'unreadable' }
|
|
235
|
+
}
|
|
257
236
|
|
|
258
|
-
return { kind:
|
|
237
|
+
return { kind: 'carried', carry }
|
|
259
238
|
}
|
|
260
239
|
|
|
261
|
-
function read_carry(target: string
|
|
262
|
-
return classify(stamp_file.read_stamp_text(target)
|
|
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
|
-
|
|
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
|
|
54
|
-
//
|
|
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.
|
|
58
|
-
const CUT_MAX_AGE_HOURS = backlog_budget.
|
|
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,
|
|
59
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
155
|
-
//
|
|
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
|
|
204
|
-
// fail-quiet direction `stamp_file.read_stamp_text` takes for a planted or
|
|
205
|
-
|
|
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) => !
|
|
260
|
-
...disposable.filter((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.
|
|
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
|
|
299
|
-
const
|
|
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'
|
|
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'
|
|
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'
|
|
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`)
|
|
48
|
-
//
|
|
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'
|
|
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
|
|
package/scripts/run/run-step.ts
CHANGED
|
@@ -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
|
|
292
|
-
// run is its successor's
|
|
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,
|