@joshuafolkken/kit 1.1373.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/dependency-update/SKILL.md +6 -3
- 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-UTHMB4O2.js → chunk-2X2ART5G.js} +4 -33
- package/dist/hooks/{chunk-NZN2UFI6.js → chunk-4MGHBTOM.js} +162 -233
- package/dist/hooks/{chunk-YLBBSSTF.js → chunk-6AC4C2IO.js} +91 -67
- package/dist/hooks/{chunk-AKGCP4LX.js → chunk-6MAW7BCM.js} +12 -12
- package/dist/hooks/{chunk-JCRDBQXB.js → chunk-7VSZ2N4B.js} +3 -3
- package/dist/hooks/{chunk-GG72ANKI.js → chunk-KC6LUZZ2.js} +1 -1
- package/dist/hooks/{chunk-BZMPVFDY.js → chunk-KCF2YE7O.js} +5 -5
- package/dist/hooks/{chunk-7WKYHEGU.js → chunk-LC56R53A.js} +3 -3
- package/dist/hooks/{chunk-V4TKZHER.js → chunk-LOJTBU5V.js} +3 -3
- package/dist/hooks/{chunk-JGW24KKM.js → chunk-RYP3TSUJ.js} +3 -3
- package/dist/hooks/{chunk-SLSFIWTX.js → chunk-TT742SIO.js} +8 -5
- package/dist/hooks/{chunk-Z5TMJIPW.js → chunk-Y5R47QHU.js} +1 -1
- package/dist/hooks/{chunk-7UBFU3CL.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 +190 -191
- 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 +40 -5
- package/package.json +4 -4
- package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
- package/scripts/adopt/adopt-logic.ts +4 -1
- package/scripts/adopt/adopt.ts +7 -4
- 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/cost-runtime/context-cut-payback.ts +9 -4
- package/scripts/cost-runtime/cost-pricing.ts +94 -33
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/document/read-set-pricing.ts +7 -5
- 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/git/git-spawn-sync.ts +9 -1
- package/scripts/hooks/stop-guard.ts +20 -9
- package/scripts/init/init-ai-copy.ts +10 -3
- package/scripts/init/init-copy-content.ts +8 -1
- package/scripts/init/security-policy.ts +79 -0
- package/scripts/josh/josh-commands-ai.ts +1 -1
- package/scripts/josh/josh-commands-maintenance.ts +3 -0
- package/scripts/josh/josh-commands-project.ts +1 -1
- package/scripts/josh/josh-commands-versioning.ts +2 -1
- package/scripts/lane/lane-cli.ts +1 -3
- package/scripts/propagate/propagate-run.ts +17 -1
- package/scripts/propagate/propagate-steps.ts +9 -1
- 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/sync/sync-ai-files.ts +2 -0
- package/scripts/version/latest-update.ts +20 -6
- package/scripts/version/peer-floor.ts +99 -0
- package/scripts/version/playwright-browsers.ts +109 -0
- package/scripts/version/publishable-range-check.ts +41 -4
- package/templates/SECURITY.md +23 -0
- package/scripts/backlog/backlog-idle.ts +0 -88
|
@@ -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,
|
|
@@ -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'
|
|
31
|
+
if (read.kind !== 'carried') return undefined
|
|
32
32
|
|
|
33
33
|
return { reason }
|
|
34
34
|
}
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
//
|
|
19
19
|
// **Cheap and local.** Every read is a file on disk — the carry record, the wake record — and a process
|
|
20
20
|
// 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
|
|
21
|
+
// time. The gate is the carry record itself: nothing carried, or a record that is unreadable,
|
|
22
22
|
// returns before the wake record is even read.
|
|
23
23
|
|
|
24
24
|
// The recovery line names the command that hands the run to a fresh supervisor — the same one the pure
|
|
@@ -74,8 +74,8 @@ const DEFAULT_PORTS: DetectPorts = {
|
|
|
74
74
|
notify_stranded: real_notify_stranded,
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
// The three facts, assembled behind the carry-record gate. A non-`carried` read — nothing carried,
|
|
78
|
-
//
|
|
77
|
+
// The three facts, assembled behind the carry-record gate. A non-`carried` read — nothing carried, or
|
|
78
|
+
// an unreadable record — is `undefined`: there is no live budget to be stranded, so the
|
|
79
79
|
// wake record is never read and the judge is never asked.
|
|
80
80
|
async function gather(ports: DetectPorts): Promise<StrandedInput | undefined> {
|
|
81
81
|
const targets = await ports.resolve_targets()
|
|
@@ -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
|
|
6
|
-
// successor
|
|
7
|
-
// read and never weighed, are what say that state has been
|
|
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
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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 {
|
|
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
import type { AgentProfile } from '#scripts/agent/agent-role-profile'
|
|
6
|
+
import { backlog_budget } from '#scripts/backlog/backlog-budget'
|
|
6
7
|
import { cli_flags } from '#scripts/lib/cli-flags'
|
|
7
8
|
import { poll } from '#scripts/lib/poll'
|
|
8
9
|
import { telegram_notify } from '#scripts/notify/telegram-notify'
|
|
@@ -34,7 +35,6 @@ const FAILURE_EXIT_CODE = 1
|
|
|
34
35
|
const ARGV_OFFSET = 2
|
|
35
36
|
const ONE_GROUP = 1
|
|
36
37
|
const MS_PER_SECOND = 1000
|
|
37
|
-
const DEFAULT_INTERVAL_SECONDS = 60
|
|
38
38
|
// Whole and positive. A zero interval is a busy loop against the temp directory, not a faster
|
|
39
39
|
// supervisor.
|
|
40
40
|
const INTERVAL_PATTERN = /^[1-9]\d*$/u
|
|
@@ -71,17 +71,15 @@ const WARNING_RECOVERY = `Check the wake command, then restart with \`pnpm josh
|
|
|
71
71
|
// `stopped` is a person's own `--stop` or a supervisor being superseded, and a warning channel that
|
|
72
72
|
// fired on those is one that stops being read.
|
|
73
73
|
//
|
|
74
|
-
// **`
|
|
75
|
-
//
|
|
74
|
+
// **`unreadable` warns**, because it ends the supervisor while a carry record is still sitting there
|
|
75
|
+
// handed off, so the run would otherwise be left asleep with nothing anywhere saying so.
|
|
76
76
|
// **Every reason is listed, and the silent two are listed as `undefined`.** A partial map would let a
|
|
77
|
-
//
|
|
77
|
+
// fifth stop reason compile straight past this and reach nobody, which is the defect class this
|
|
78
78
|
// section exists to close.
|
|
79
79
|
const STOP_BODIES: Record<WakeStopReason, string | undefined> = {
|
|
80
80
|
ended: undefined,
|
|
81
81
|
stopped: undefined,
|
|
82
82
|
failed: 'The supervisor could not continue the run and has stopped.',
|
|
83
|
-
expired:
|
|
84
|
-
'The carried run passed its whole-run bound, so the supervisor stopped without continuing it.',
|
|
85
83
|
unreadable:
|
|
86
84
|
'The carry record could not be read, so the supervisor stopped rather than guess at it.',
|
|
87
85
|
}
|
|
@@ -118,7 +116,7 @@ function note_to_stderr(note: string): void {
|
|
|
118
116
|
}
|
|
119
117
|
|
|
120
118
|
function to_interval_ms(value: string | boolean | undefined): number | undefined {
|
|
121
|
-
if (typeof value !== 'string') return
|
|
119
|
+
if (typeof value !== 'string') return backlog_budget.OFFER_INTERVAL_MS
|
|
122
120
|
if (!INTERVAL_PATTERN.test(value)) return undefined
|
|
123
121
|
|
|
124
122
|
const seconds = Number(value)
|
|
@@ -221,9 +219,9 @@ async function warn_of_stop(stop: LoopStop, body: string, context: WakeContext):
|
|
|
221
219
|
})
|
|
222
220
|
}
|
|
223
221
|
|
|
224
|
-
// **Only a failed wake exits non-zero; `
|
|
222
|
+
// **Only a failed wake exits non-zero; `unreadable` warns but exits zero.** The
|
|
225
223
|
// verdict-and-exit-code table in `docs/josh-commands-automation.md` is a contract callers branch on,
|
|
226
|
-
// and what
|
|
224
|
+
// and what that one needs is the notification, not a different exit code.
|
|
227
225
|
function exit_code_of(reason: WakeStopReason): number {
|
|
228
226
|
return reason === run_wake_loop.FAILED_REASON ? FAILURE_EXIT_CODE : SUCCESS_EXIT_CODE
|
|
229
227
|
}
|
|
@@ -32,7 +32,7 @@ interface WakeContext {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
function carry_cuts(read: CarryRead): string {
|
|
35
|
-
if (read.kind === 'carried'
|
|
35
|
+
if (read.kind === 'carried') return String(read.carry.cuts)
|
|
36
36
|
|
|
37
37
|
return UNKNOWN_CUTS
|
|
38
38
|
}
|
|
@@ -70,15 +70,13 @@ function deadline(marked_at: string | undefined, limit_ms: number): string {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
// A live owner that is this supervisor is its own driver at work; any other is a session spending the
|
|
73
|
-
// budget, which has no deadline of its own
|
|
73
|
+
// budget, which has no deadline of its own.
|
|
74
74
|
function owner_wait(wake: RunWake, carry: RunCarry): string {
|
|
75
75
|
if (carry.owner_pid === wake.pid) {
|
|
76
76
|
return 'waiting: the driver holds the carry record and is running'
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return `waiting: process ${String(carry.owner_pid)} holds the carry record; the driver starts at its next cut or once it exits, and the record expires at ${expiry}`
|
|
79
|
+
return `waiting: process ${String(carry.owner_pid)} holds the carry record; the driver starts at its next cut or once it exits`
|
|
82
80
|
}
|
|
83
81
|
|
|
84
82
|
const WAIT_LINES: Partial<Record<WakeDecision['kind'], (wake: RunWake) => string>> = {
|
|
@@ -159,9 +159,9 @@ function is_mechanical(result: JoshResult): boolean {
|
|
|
159
159
|
)
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
// A record that
|
|
163
|
-
//
|
|
164
|
-
//
|
|
162
|
+
// A record that was removed during the pause answers `undefined`: its stale verdict must not reach a
|
|
163
|
+
// session, so the supervisor's own next pass re-reads the record and ends the run as `ended` or
|
|
164
|
+
// `stopped`; a rerun would only fail on it and report the run as `failed`.
|
|
165
165
|
async function run_driver(
|
|
166
166
|
target: string,
|
|
167
167
|
args: ReadonlyArray<string>,
|
|
@@ -24,12 +24,6 @@ const STOPPED_REASON: WakeStopReason = 'stopped'
|
|
|
24
24
|
const FAILED_REASON: WakeStopReason = 'failed'
|
|
25
25
|
const NO_ATTEMPTS = 0
|
|
26
26
|
const STOPPED_BY_PERSON: LoopStop = { reason: STOPPED_REASON, note: undefined }
|
|
27
|
-
// **The back-off after repeated whiffs, and its ceiling.** While a launch is
|
|
28
|
-
// deferred for want of work, each wait is as long as the idle stretch so far — so the polling interval
|
|
29
|
-
// doubles pass by pass — capped at this multiple of the configured one. Eight times the one-minute
|
|
30
|
-
// default is eight minutes: far fewer backlog reads over a long empty stretch, and still well inside
|
|
31
|
-
// the idle ceiling, so work that appears is picked up within one capped wait.
|
|
32
|
-
const MAX_BACKOFF_FACTOR = 8
|
|
33
27
|
|
|
34
28
|
interface LoopPorts {
|
|
35
29
|
// The carry record, re-read every pass rather than cached: it is written by the parent loop of a
|
|
@@ -157,18 +151,6 @@ async function decide_pass(wake: RunWake, ports: LoopPorts): Promise<WakeDecisio
|
|
|
157
151
|
return run_wake.decide({ ...input, has_work: await ports.has_work(input.read) })
|
|
158
152
|
}
|
|
159
153
|
|
|
160
|
-
// The wait before the next pass: the configured interval, stretched while a launch is being deferred
|
|
161
|
-
// to the length of the idle stretch so far and capped at `MAX_BACKOFF_FACTOR` times the interval.
|
|
162
|
-
function next_interval(wake: RunWake | undefined, interval_ms: number, now: Date): number {
|
|
163
|
-
const idle_since = wake?.idle_since === undefined ? NaN : Date.parse(wake.idle_since)
|
|
164
|
-
|
|
165
|
-
if (Number.isNaN(idle_since)) return interval_ms
|
|
166
|
-
|
|
167
|
-
const stretched = Math.max(interval_ms, now.getTime() - idle_since)
|
|
168
|
-
|
|
169
|
-
return Math.min(stretched, interval_ms * MAX_BACKOFF_FACTOR)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
154
|
// The record is re-read at the top of every pass rather than carried in a variable, so a `--stop` that
|
|
173
155
|
// removed it ends the loop at the next interval and a `--list` reads what the loop actually wrote.
|
|
174
156
|
// One pass: decide, act, write the result back. `undefined` means carry on to the next interval.
|
|
@@ -187,6 +169,9 @@ async function run_pass(
|
|
|
187
169
|
// The record is re-read at the top of every pass rather than carried in a variable, so a `--stop` that
|
|
188
170
|
// removed it ends the loop at the next interval and a `--list` reads what the loop actually wrote.
|
|
189
171
|
//
|
|
172
|
+
// **The wait between two passes is the configured interval, however long the run has been idle**: a
|
|
173
|
+
// launch deferred for want of work is asked about again as soon as a running one would be.
|
|
174
|
+
//
|
|
190
175
|
// **`read_own_wake` rather than `read_wake`, and the difference is a whole pass.**
|
|
191
176
|
// A record that has become a *successor's* is not this loop's to act on, and reading it back would
|
|
192
177
|
// have this pass decide from another supervisor's counters — spawning a session for a cut that
|
|
@@ -203,7 +188,7 @@ async function run_loop(target: string, ports: LoopPorts, interval_ms: number):
|
|
|
203
188
|
if (stop !== undefined) return stop
|
|
204
189
|
|
|
205
190
|
// eslint-disable-next-line no-await-in-loop -- polling: each read waits on the state the previous one saw
|
|
206
|
-
await ports.sleep(
|
|
191
|
+
await ports.sleep(interval_ms)
|
|
207
192
|
wake = run_wake.read_own_wake(target)
|
|
208
193
|
}
|
|
209
194
|
|
|
@@ -212,8 +197,6 @@ async function run_loop(target: string, ports: LoopPorts, interval_ms: number):
|
|
|
212
197
|
|
|
213
198
|
const run_wake_loop = {
|
|
214
199
|
FAILED_REASON,
|
|
215
|
-
MAX_BACKOFF_FACTOR,
|
|
216
|
-
next_interval,
|
|
217
200
|
run_loop,
|
|
218
201
|
}
|
|
219
202
|
|
|
@@ -15,7 +15,7 @@ interface RestartPorts {
|
|
|
15
15
|
// One run of the loop over the record the caller claimed once and holds across every restart, so a
|
|
16
16
|
// `--stop` landing in the pause is read by the next pass as the person's stop.
|
|
17
17
|
pass: () => Promise<LoopStop>
|
|
18
|
-
// Whether the carry record can still be continued — `carried`, neither
|
|
18
|
+
// Whether the carry record can still be continued — `carried`, neither unreadable nor gone.
|
|
19
19
|
is_resumable: () => boolean
|
|
20
20
|
pause: () => Promise<void>
|
|
21
21
|
note: (text: string) => void
|
|
@@ -9,7 +9,7 @@ import { z } from 'zod'
|
|
|
9
9
|
|
|
10
10
|
// The process outside the conversation that starts the next session after a `backlogrun` cut.
|
|
11
11
|
// Whether to wake is the carry record's answer, never a judgement: `carried` and handed off wakes;
|
|
12
|
-
// `none
|
|
12
|
+
// `none` and `unreadable` stop. A hand-off is set by `run:carry --cut`,
|
|
13
13
|
// or by this supervisor when it recovers a record whose owner died, so a live session never reads as
|
|
14
14
|
// waiting. The supervisor owns the record while its deterministic driver spends the budget; a judgment
|
|
15
15
|
// branch hands it off so one AI session can adopt it.
|
|
@@ -23,8 +23,8 @@ const WAKE_LOG_SUFFIX = '.log'
|
|
|
23
23
|
const WAKE_GRACE_MS = 600_000
|
|
24
24
|
// Retried before failing: stopping ends a run nobody is watching.
|
|
25
25
|
const MAX_WAKE_ATTEMPTS = 3
|
|
26
|
-
//
|
|
27
|
-
// `backlogrun` idle
|
|
26
|
+
// A run with nothing to start still needs one session to finish it, so a deferral is bounded by the
|
|
27
|
+
// `backlogrun` idle wait itself.
|
|
28
28
|
const IDLE_CEILING_MS = backlog_budget.DEFAULT_IDLE_MS
|
|
29
29
|
const NO_WAKES = 0
|
|
30
30
|
const ONE_WAKE = 1
|
|
@@ -49,11 +49,11 @@ interface RunWake {
|
|
|
49
49
|
woke_pid?: number | undefined
|
|
50
50
|
// Every `--session-id` this forced, so `josh time --run` attributes whiffs only to its own sessions.
|
|
51
51
|
spawned?: ReadonlyArray<string> | undefined
|
|
52
|
-
// Start of one unbroken idle stretch: bounds the deferral
|
|
52
|
+
// Start of one unbroken idle stretch: bounds the deferral.
|
|
53
53
|
idle_since?: string | undefined
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
type WakeStopReason = 'ended' | '
|
|
56
|
+
type WakeStopReason = 'ended' | 'unreadable' | 'failed' | 'stopped'
|
|
57
57
|
|
|
58
58
|
// `superseded`: another supervisor holds the record and this process is the replaced one.
|
|
59
59
|
type WakeTidyResult = 'absent' | 'removed' | 'superseded'
|
|
@@ -87,9 +87,8 @@ const WAIT_DECISION: WakeDecision = { kind: 'wait' }
|
|
|
87
87
|
const PENDING_DECISION: WakeDecision = { kind: 'pending' }
|
|
88
88
|
const FAILED_DECISION: WakeDecision = { kind: 'failed' }
|
|
89
89
|
|
|
90
|
-
const STOP_REASONS: Record<'none' | '
|
|
90
|
+
const STOP_REASONS: Record<'none' | 'unreadable', WakeStopReason> = {
|
|
91
91
|
none: 'ended',
|
|
92
|
-
expired: 'expired',
|
|
93
92
|
unreadable: 'unreadable',
|
|
94
93
|
}
|
|
95
94
|
|
|
@@ -5,6 +5,7 @@ import { transform_copied_content } from '#scripts/init/init-copy-content'
|
|
|
5
5
|
import { init_logic } from '#scripts/init/init-logic'
|
|
6
6
|
import { PACKAGE_DIR, PROJECT_ROOT } from '#scripts/init/init-paths'
|
|
7
7
|
import type { ProjectShape } from '#scripts/init/project-profile'
|
|
8
|
+
import { security_policy } from '#scripts/init/security-policy'
|
|
8
9
|
import { file_reader } from '#scripts/lib/read-file'
|
|
9
10
|
import { pack_hook } from '#scripts/safe-chain/pack-hook'
|
|
10
11
|
import { copy_directory_failure, directory_copy_blocker } from './directory-copy-guard'
|
|
@@ -80,6 +81,7 @@ function sync_workspace_yaml(
|
|
|
80
81
|
|
|
81
82
|
// The source is the profile's: a basic project syncs from its own templates, as `josh init` wrote it.
|
|
82
83
|
function sync_ai_copy_file(filename: string, is_force: boolean, shape?: ProjectShape): void {
|
|
84
|
+
if (security_policy.did_skip_unresolved(filename, PROJECT_ROOT)) return
|
|
83
85
|
const source = init_ai_copy.ai_file_source(filename, shape)
|
|
84
86
|
|
|
85
87
|
if (filename === WORKSPACE_YAML) {
|
|
@@ -13,6 +13,7 @@ import { overrides_check } from '#scripts/overrides/overrides-logic'
|
|
|
13
13
|
import { execaSync } from 'execa'
|
|
14
14
|
import { fresh_metadata_cache } from './fresh-metadata-cache'
|
|
15
15
|
import { latest_regression, type VersionRegression } from './latest-regression'
|
|
16
|
+
import { playwright_browsers } from './playwright-browsers'
|
|
16
17
|
import { safe_chain_version_update } from './safe-chain-version-update'
|
|
17
18
|
|
|
18
19
|
const PACKAGE_JSON_PATH = 'package.json'
|
|
@@ -164,23 +165,36 @@ function report_lockfile_overrides(): void {
|
|
|
164
165
|
process.exitCode = 1
|
|
165
166
|
}
|
|
166
167
|
|
|
168
|
+
// The browsers follow what `node_modules` holds, not how the update ended: a later stage failing
|
|
169
|
+
// after `@playwright/test` moved, and a rollback — which restores the manifest and the lockfile but
|
|
170
|
+
// leaves the newer package installed — both leave a Playwright whose browsers were never downloaded,
|
|
171
|
+
// and the browser tests that run next would fail with "Executable doesn't exist".
|
|
172
|
+
function install_browsers(playwright_version: string | undefined): void {
|
|
173
|
+
if (playwright_browsers.install_when_needed(playwright_version) !== 0) process.exitCode = 1
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The sync acts on the versions the update settled on, so it runs neither after a failed update nor
|
|
177
|
+
// after a rollback — the latter so the files really are left exactly as they were found: it rewrites
|
|
178
|
+
// the workflows' pinned safe-chain installer, which would contradict the notice that nothing changed.
|
|
167
179
|
function main(): void {
|
|
168
180
|
const snapshot = take_snapshot()
|
|
181
|
+
const playwright_version = playwright_browsers.read_installed_version()
|
|
169
182
|
const overrides = overrides_files.read_current_overrides()
|
|
170
183
|
|
|
171
184
|
report_excluded(overrides_check.list_excluded_package_names(overrides))
|
|
172
185
|
|
|
173
186
|
const outcome = update_without_downgrading(snapshot)
|
|
174
187
|
|
|
188
|
+
// A failed `pnpm update` fails the command: the `josh latest` chain and `josh adopt` both read the
|
|
189
|
+
// exit code, and a zero here would let them carry on over dependencies that were never updated.
|
|
190
|
+
if (outcome.status !== 0) process.exitCode = outcome.status
|
|
191
|
+
|
|
175
192
|
report_overrides(overrides)
|
|
176
193
|
report_lockfile_overrides()
|
|
177
194
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (outcome.status === 0 && !outcome.is_rolled_back) {
|
|
182
|
-
safe_chain_version_update.sync()
|
|
183
|
-
}
|
|
195
|
+
if (outcome.status === 0 && !outcome.is_rolled_back) safe_chain_version_update.sync()
|
|
196
|
+
|
|
197
|
+
install_browsers(playwright_version)
|
|
184
198
|
}
|
|
185
199
|
|
|
186
200
|
if (process.argv[1] === fileURLToPath(import.meta.url)) main()
|