@joshuafolkken/kit 1.1383.0 → 1.1387.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/hooks/batch-guard.js +6 -6
  2. package/dist/hooks/{chunk-4MGHBTOM.js → chunk-2YSW3CXH.js} +8 -2
  3. package/dist/hooks/{chunk-KCF2YE7O.js → chunk-DCY2GWHC.js} +5 -5
  4. package/dist/hooks/{chunk-KC6LUZZ2.js → chunk-EAQEGME7.js} +1 -1
  5. package/dist/hooks/{chunk-LC56R53A.js → chunk-EBSHN25W.js} +3 -3
  6. package/dist/hooks/{chunk-6MAW7BCM.js → chunk-GJ7JPIHN.js} +14 -14
  7. package/dist/hooks/{chunk-6AC4C2IO.js → chunk-GNDJ7NJ7.js} +59 -462
  8. package/dist/hooks/{chunk-LOJTBU5V.js → chunk-IYVOLN6A.js} +3 -3
  9. package/dist/hooks/{chunk-Y5R47QHU.js → chunk-J3KSS7II.js} +1 -1
  10. package/dist/hooks/{chunk-RYP3TSUJ.js → chunk-LMWBRJIP.js} +3 -3
  11. package/dist/hooks/{chunk-TT742SIO.js → chunk-SL4GQ2XP.js} +2 -2
  12. package/dist/hooks/{chunk-YKUITFT7.js → chunk-STDSEPWU.js} +2 -2
  13. package/dist/hooks/{chunk-2X2ART5G.js → chunk-U32VSRNG.js} +420 -4
  14. package/dist/hooks/{chunk-7VSZ2N4B.js → chunk-WNFJLBXI.js} +3 -3
  15. package/dist/hooks/codex-hook-adapter.js +13 -13
  16. package/dist/hooks/duplicate-read-guard.js +7 -7
  17. package/dist/hooks/format-edited.js +3 -3
  18. package/dist/hooks/inputs.json +208 -206
  19. package/dist/hooks/investigation-guard.js +7 -7
  20. package/dist/hooks/pretool-guard.js +12 -12
  21. package/dist/hooks/session-lang.js +65 -9
  22. package/dist/hooks/stop-guard.js +91 -23
  23. package/dist/josh.js +1 -1
  24. package/package.json +1 -1
  25. package/scripts/backlog/backlog-drive-cli.ts +2 -4
  26. package/scripts/backlog/backlog-drive-launch.ts +2 -0
  27. package/scripts/backlog/backlog-drive-owner.ts +72 -5
  28. package/scripts/backlog/backlogrun-launch.ts +56 -5
  29. package/scripts/document/document-byte-budget.ts +1 -1
  30. package/scripts/init/start-plan.ts +15 -4
  31. package/scripts/init/start-steps.ts +31 -3
  32. package/scripts/init/start.ts +20 -13
  33. package/scripts/josh/josh-commands-project.ts +1 -1
  34. package/scripts/run/board/run-board-header.ts +39 -13
  35. package/scripts/run/board/run-board-read.ts +9 -2
  36. package/scripts/run/board/run-board-setup.ts +73 -0
  37. package/scripts/run/board/run-board-span.ts +49 -7
  38. package/scripts/run/board/run-board-status.ts +10 -6
  39. package/scripts/run/board/run-board-tick.ts +10 -2
  40. package/scripts/run/carry/run-carry-added.ts +1 -1
  41. package/scripts/run/carry/run-carry-change.ts +1 -1
  42. package/scripts/run/carry/run-carry-cli.ts +37 -7
  43. package/scripts/run/carry/run-carry-ended.ts +9 -1
  44. package/scripts/run/carry/run-carry.ts +16 -0
  45. package/scripts/run/event/run-event-render.ts +1 -0
  46. package/scripts/run/event/run-event-setup.ts +47 -0
  47. package/scripts/run/event/run-event-stream.ts +26 -5
  48. package/scripts/run/run-stranded-detect.ts +58 -12
  49. package/scripts/run/wake/run-wake-cli.ts +51 -6
  50. package/scripts/run/wake/run-wake-describe.ts +2 -0
  51. package/scripts/run/wake/run-wake-driver.ts +10 -1
  52. package/scripts/run/wake/run-wake-handoff.ts +1 -1
@@ -6,17 +6,23 @@ import {
6
6
  agent_role_profile,
7
7
  detached_launch,
8
8
  git_common_directory,
9
+ issue_cite,
10
+ run_event_stream,
11
+ run_event_stream_emit,
9
12
  run_headless
10
- } from "./chunk-2X2ART5G.js";
13
+ } from "./chunk-U32VSRNG.js";
14
+ import {
15
+ error_text
16
+ } from "./chunk-CDY4NFQG.js";
11
17
  import {
12
18
  josh_environment_file
13
- } from "./chunk-Y5R47QHU.js";
19
+ } from "./chunk-J3KSS7II.js";
14
20
  import {
15
21
  agent_session_environment,
16
22
  json_value,
17
23
  process_identity,
18
24
  run_carry
19
- } from "./chunk-4MGHBTOM.js";
25
+ } from "./chunk-2YSW3CXH.js";
20
26
  import {
21
27
  stamp_file
22
28
  } from "./chunk-NEC4AT4Z.js";
@@ -298,6 +304,32 @@ var agent_argv = {
298
304
  with_resume_in
299
305
  };
300
306
 
307
+ // scripts/run/event/run-event-setup.ts
308
+ var STAGE = {
309
+ // `josh backlogrun` started the parent session, which is reading its procedure.
310
+ STARTING: "starting session",
311
+ // `run:carry --begin` claimed the run, and the parent is planning it.
312
+ PLANNING: "planning",
313
+ // The supervisor took the run over from the parent and is asking for the first offer.
314
+ HANDING_OFF: "handing off"
315
+ };
316
+ var OPENING = "opening lane";
317
+ function opening_lane(issue) {
318
+ return `${OPENING} ${issue_cite.plain(issue)}`;
319
+ }
320
+ async function emit(text) {
321
+ await run_event_stream_emit.emit(run_event_stream.EVENT_KIND.SETUP, text);
322
+ }
323
+ function emit_in(repository, text, at) {
324
+ try {
325
+ const target = run_event_stream.target_of(repository);
326
+ run_event_stream.append(target, run_event_stream.EVENT_KIND.SETUP, text, at.toISOString());
327
+ } catch (error) {
328
+ error_text.trace_swallowed("run_event_setup.emit_in", error);
329
+ }
330
+ }
331
+ var run_event_setup = { STAGE, emit, emit_in, opening_lane };
332
+
301
333
  // scripts/backlog/backlogrun-launch.ts
302
334
  import { z } from "zod";
303
335
  var LAUNCH_PREFIX = "josh-backlogrun-launch-";
@@ -306,7 +338,11 @@ var LOG_SUFFIX = ".log";
306
338
  var launch_schema = z.object({
307
339
  pid: z.number(),
308
340
  start: z.string().optional(),
309
- session: z.string().optional()
341
+ session: z.string().optional(),
342
+ // What the parent was started to run and when, so `run:board` has a run to draw and a clock to count
343
+ // before the parent claims the carry record. Absent on a record an earlier version wrote.
344
+ invocation: z.string().optional(),
345
+ started_at: z.string().optional()
310
346
  });
311
347
  function launch_path(git_directory) {
312
348
  return stamp_file.stamp_path(LAUNCH_PREFIX, git_directory);
@@ -348,9 +384,27 @@ function argv_of(invocation, provider, worktree, session) {
348
384
  function note_to_stderr(note) {
349
385
  console.error(`backlogrun: ${note}`);
350
386
  }
351
- function record_launch(git_directory, pid, session) {
352
- const started = process_identity.read_start(pid);
353
- stamp_file.replace_stamp(launch_path(git_directory), { pid, start: started, session });
387
+ function record_launch(git_directory, launch) {
388
+ const now = /* @__PURE__ */ new Date();
389
+ const identity = process_identity.read_start(launch.pid);
390
+ const record = { ...launch, start: identity, started_at: now.toISOString() };
391
+ stamp_file.replace_stamp(launch_path(git_directory), record);
392
+ run_event_setup.emit_in(git_directory, run_event_setup.STAGE.STARTING, now);
393
+ }
394
+ function live_launch(git_directory) {
395
+ const record = read_launch(launch_path(git_directory));
396
+ return is_launch_live(record) ? record : void 0;
397
+ }
398
+ function pending_launch(git_directory) {
399
+ const { invocation, started_at } = live_launch(git_directory) ?? {};
400
+ if (invocation === void 0 || started_at === void 0) return void 0;
401
+ return { invocation, started_at };
402
+ }
403
+ function started_for(git_directory, owner_pid) {
404
+ const record = live_launch(git_directory);
405
+ if (record?.started_at === void 0 || record.pid !== owner_pid) return void 0;
406
+ const started_ms = Date.parse(record.started_at);
407
+ return Number.isNaN(started_ms) ? void 0 : new Date(started_ms);
354
408
  }
355
409
  function spawn_parent(argv, provider, target) {
356
410
  const environment = {
@@ -376,7 +430,7 @@ function start(invocation, provider, target) {
376
430
  if (built.kind === "rejected") return { kind: "failed", note: built.note };
377
431
  const result = spawn_parent(built, provider, target);
378
432
  if (result.kind === "failed") return result;
379
- record_launch(target.git_directory, result.pid, session);
433
+ record_launch(target.git_directory, { pid: result.pid, session, invocation });
380
434
  const launched = { pid: result.pid, session, log_path: log_path(target.git_directory) };
381
435
  return { kind: "launched", launched };
382
436
  }
@@ -384,7 +438,9 @@ var backlogrun_launch = {
384
438
  argv_of,
385
439
  chosen_environment,
386
440
  is_running,
387
- start
441
+ pending_launch,
442
+ start,
443
+ started_for
388
444
  };
389
445
 
390
446
  // scripts/josh/session-language-cli.ts
@@ -13,10 +13,8 @@ import {
13
13
  lane_park,
14
14
  repo_party,
15
15
  run_cut,
16
- run_event_stream,
17
- run_event_stream_emit,
18
16
  run_hold
19
- } from "./chunk-6AC4C2IO.js";
17
+ } from "./chunk-GNDJ7NJ7.js";
20
18
  import {
21
19
  session_language
22
20
  } from "./chunk-6V5OPIUU.js";
@@ -25,23 +23,25 @@ import {
25
23
  agent_role_profile,
26
24
  issue_cite,
27
25
  lane_registry,
26
+ run_event_stream,
27
+ run_event_stream_emit,
28
28
  run_headless
29
- } from "./chunk-2X2ART5G.js";
29
+ } from "./chunk-U32VSRNG.js";
30
30
  import {
31
31
  timed_fetch
32
32
  } from "./chunk-FZ6FUSBM.js";
33
33
  import "./chunk-CDY4NFQG.js";
34
34
  import {
35
35
  hook_decision
36
- } from "./chunk-LC56R53A.js";
36
+ } from "./chunk-EBSHN25W.js";
37
37
  import {
38
38
  time_density_hook,
39
39
  time_hook_transcript,
40
40
  time_reported_failure
41
- } from "./chunk-TT742SIO.js";
41
+ } from "./chunk-SL4GQ2XP.js";
42
42
  import {
43
43
  lane_child_marker
44
- } from "./chunk-Y5R47QHU.js";
44
+ } from "./chunk-J3KSS7II.js";
45
45
  import {
46
46
  backlog_budget,
47
47
  issue_number_shape,
@@ -50,7 +50,7 @@ import {
50
50
  process_owner_schema,
51
51
  run_carry,
52
52
  run_invocation
53
- } from "./chunk-4MGHBTOM.js";
53
+ } from "./chunk-2YSW3CXH.js";
54
54
  import {
55
55
  PROJECT_ROOT,
56
56
  stamp_file
@@ -964,8 +964,53 @@ var run_halfrun_resume = {
964
964
  take_over
965
965
  };
966
966
 
967
- // scripts/run/wake/run-wake.ts
967
+ // scripts/run/carry/run-carry-ended.ts
968
+ import path from "node:path";
968
969
  import { z as z4 } from "zod";
970
+ var ENDED_PREFIX = "josh-run-ended-";
971
+ var TRANSCRIPT_SUFFIX = ".jsonl";
972
+ var ended_run_schema = z4.object({
973
+ invocation: z4.string(),
974
+ started_at: z4.string(),
975
+ ended_at: z4.string(),
976
+ stopped: z4.string().optional(),
977
+ session: z4.string().optional()
978
+ });
979
+ function ended_path(git_directory) {
980
+ return stamp_file.stamp_path(ENDED_PREFIX, git_directory);
981
+ }
982
+ function session_of(transcript) {
983
+ return transcript === void 0 ? void 0 : path.basename(transcript, TRANSCRIPT_SUFFIX);
984
+ }
985
+ function stop_of(transcript, stopped) {
986
+ return stopped === void 0 ? {} : { stopped, session: session_of(transcript) };
987
+ }
988
+ function record_ended(target, read, now = /* @__PURE__ */ new Date(), stopped) {
989
+ if (read.kind !== "carried") return;
990
+ const { invocation, started_at, owner_transcript } = read.carry;
991
+ const ended_at = now.toISOString();
992
+ stamp_file.replace_stamp(target, {
993
+ invocation,
994
+ started_at,
995
+ ended_at,
996
+ ...stop_of(owner_transcript, stopped)
997
+ });
998
+ }
999
+ function parse_ended(raw) {
1000
+ return json_value.parse_with(raw, ended_run_schema);
1001
+ }
1002
+ function read_ended(target) {
1003
+ const raw = stamp_file.read_stamp_text(target);
1004
+ return raw === void 0 ? void 0 : parse_ended(raw);
1005
+ }
1006
+ function has_ended(target, started_at) {
1007
+ const ended = read_ended(target);
1008
+ return ended !== void 0 && ended.started_at === started_at;
1009
+ }
1010
+ var run_carry_ended = { ended_path, has_ended, read_ended, record_ended };
1011
+
1012
+ // scripts/run/wake/run-wake.ts
1013
+ import { z as z5 } from "zod";
969
1014
  var WAKE_PREFIX = "josh-run-wake-";
970
1015
  var WAKE_LOG_PREFIX = "josh-run-wake-log-";
971
1016
  var WAKE_LOG_SUFFIX = ".log";
@@ -984,15 +1029,15 @@ var STOP_REASONS = {
984
1029
  unreadable: "unreadable"
985
1030
  };
986
1031
  var run_wake_schema = process_owner_schema.extend({
987
- invocation: z4.string(),
988
- started_at: z4.string(),
989
- woke: z4.number(),
990
- woke_at: z4.string().optional(),
1032
+ invocation: z5.string(),
1033
+ started_at: z5.string(),
1034
+ woke: z5.number(),
1035
+ woke_at: z5.string().optional(),
991
1036
  // Every optional `RunWake` field must be listed: `z.object` strips undeclared ones on the round trip.
992
- attempts: z4.number().optional(),
993
- woke_pid: z4.number().optional(),
994
- spawned: z4.array(z4.string()).optional(),
995
- idle_since: z4.string().optional(),
1037
+ attempts: z5.number().optional(),
1038
+ woke_pid: z5.number().optional(),
1039
+ spawned: z5.array(z5.string()).optional(),
1040
+ idle_since: z5.string().optional(),
996
1041
  profile: agent_role_profile.PROFILE_SCHEMA.optional()
997
1042
  });
998
1043
  function is_overdue(marked_at, now, limit_ms = WAKE_GRACE_MS) {
@@ -1186,10 +1231,12 @@ var run_stranded = {
1186
1231
 
1187
1232
  // scripts/run/run-stranded-detect.ts
1188
1233
  var STRAND_RECOVERY = `Restart supervision with \`${run_stranded.RECOVERY_COMMAND}\` \u2014 it wakes a fresh successor to claim the budget; the cutting session must not resume it itself.`;
1234
+ function targets_of(directory) {
1235
+ return { carry: run_carry.carry_path(directory), wake: run_wake.wake_path(directory) };
1236
+ }
1189
1237
  async function real_resolve_targets() {
1190
1238
  const directory = await run_carry.repository_directory();
1191
- if (directory === void 0) return void 0;
1192
- return { carry: run_carry.carry_path(directory), wake: run_wake.wake_path(directory) };
1239
+ return directory === void 0 ? void 0 : targets_of(directory);
1193
1240
  }
1194
1241
  async function real_emit_stranded(text2) {
1195
1242
  return await run_event_stream_emit.emit_once(run_event_stream.EVENT_KIND.STRANDED, text2);
@@ -1201,14 +1248,13 @@ var DEFAULT_PORTS3 = {
1201
1248
  resolve_targets: real_resolve_targets,
1202
1249
  read_carry: (target) => run_carry.read_carry(target),
1203
1250
  is_owner_live: run_carry.is_owner_live,
1251
+ has_ended: run_carry_ended.has_ended,
1204
1252
  read_wake: (target) => run_wake.read_wake(target),
1205
1253
  supervisor_liveness: (wake) => run_stranded.supervisor_liveness(wake),
1206
1254
  emit_stranded: real_emit_stranded,
1207
1255
  notify_stranded: real_notify_stranded
1208
1256
  };
1209
- async function gather2(ports) {
1210
- const targets = await ports.resolve_targets();
1211
- if (targets === void 0) return void 0;
1257
+ function input_at(ports, targets) {
1212
1258
  const read = ports.read_carry(targets.carry);
1213
1259
  if (read.kind !== "carried") return void 0;
1214
1260
  return {
@@ -1217,6 +1263,10 @@ async function gather2(ports) {
1217
1263
  supervisor: ports.supervisor_liveness(ports.read_wake(targets.wake))
1218
1264
  };
1219
1265
  }
1266
+ async function gather2(ports) {
1267
+ const targets = await ports.resolve_targets();
1268
+ return targets === void 0 ? void 0 : input_at(ports, targets);
1269
+ }
1220
1270
  async function report2(ports, input) {
1221
1271
  if (input.read.kind !== "carried") return;
1222
1272
  const text2 = run_stranded.describe(input.read.carry);
@@ -1235,7 +1285,25 @@ async function run_stranded_check() {
1235
1285
  } catch {
1236
1286
  }
1237
1287
  }
1238
- var run_stranded_detect = { detect_and_report: detect_and_report2, run_stranded_check };
1288
+ function is_stranded_at(directory, ports = DEFAULT_PORTS3) {
1289
+ const input = input_at(ports, targets_of(directory));
1290
+ return input !== void 0 && run_stranded.is_stranded(input);
1291
+ }
1292
+ function is_left_stranded(run, ports = DEFAULT_PORTS3) {
1293
+ const read = ports.read_carry(run.carry_target);
1294
+ if (read.kind !== "carried") return !ports.has_ended(run.ended_target, run.started_at);
1295
+ return run_stranded.is_stranded({
1296
+ read,
1297
+ is_owner_live: run_carry.is_held_by_another(read.carry, ports.is_owner_live),
1298
+ supervisor: run_stranded.GONE
1299
+ });
1300
+ }
1301
+ var run_stranded_detect = {
1302
+ detect_and_report: detect_and_report2,
1303
+ is_left_stranded,
1304
+ is_stranded_at,
1305
+ run_stranded_check
1306
+ };
1239
1307
 
1240
1308
  // scripts/hooks/stop-guard.ts
1241
1309
  async function hold_facts() {
package/dist/josh.js CHANGED
@@ -2501,7 +2501,7 @@ var PROJECT_COMMANDS = {
2501
2501
  description: "Set a project up for the GitHub Issue workflow, from git init to the setup PR",
2502
2502
  category: "Project",
2503
2503
  reference: [
2504
- `${PROFILE_ARGUMENTS} [--yes] [--github] [--public] [--init-command <command>]`,
2504
+ `${PROFILE_ARGUMENTS} [--yes] [--github] [--public] [--init-command <command>] [--push-preflight-command <command>]`,
2505
2505
  "developer",
2506
2506
  ["files", "git", "network", "processes"]
2507
2507
  ]
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.1383.0",
2
+ "version": "1.1387.0",
3
3
  "name": "@joshuafolkken/kit",
4
4
  "description": "Development toolkit for AI-assisted projects: formatting, AI assistant rules and a Git/GitHub Issue workflow for any repository, plus lint, type check, tests and hooks for Node projects",
5
5
  "keywords": [
@@ -79,10 +79,7 @@ async function read_offer(
79
79
  state: DriveState,
80
80
  context: DriveContext,
81
81
  ): Promise<OfferRead | undefined> {
82
- await backlog_drive_owner.assert_current(context.owner)
83
- const carry = await read_record()
84
-
85
- if (carry === undefined) return undefined
82
+ const carry = await backlog_drive_owner.assert_current(context.owner)
86
83
  const named = await backlog_drive_named_offer.read(carry, state, context)
87
84
  if (named !== undefined) return named
88
85
 
@@ -269,6 +266,7 @@ const backlog_drive_cli = {
269
266
  merge_token,
270
267
  offer_argv: backlog_drive_offer_argv.offer_argv,
271
268
  parse: backlog_drive_args.parse,
269
+ read_offer,
272
270
  resume_line: backlog_drive_args.resume_line,
273
271
  run,
274
272
  to_offer,
@@ -1,5 +1,6 @@
1
1
  import { git_stash } from '#scripts/git/stash/git-stash'
2
2
  import { lane_launch_cli, type LaunchOutcome } from '#scripts/lane/lane-launch-cli'
3
+ import { run_event_setup } from '#scripts/run/event/run-event-setup'
3
4
  import { backlog_drive_owner } from './backlog-drive-owner'
4
5
 
5
6
  // The driver's lane launch. The parent that ran `josh latest` in the primary
@@ -28,6 +29,7 @@ async function latest_stash(): Promise<string | undefined> {
28
29
 
29
30
  async function launch(issue: string, owner: string): Promise<LaunchOutcome['kind']> {
30
31
  await backlog_drive_owner.assert_current(owner)
32
+ await run_event_setup.emit(run_event_setup.opening_lane(issue))
31
33
 
32
34
  const outcome = await lane_launch_cli.launch_lane({ issue, stash: await latest_stash() })
33
35
 
@@ -1,4 +1,18 @@
1
- import { run_carry, type RunCarry } from '#scripts/run/carry/run-carry'
1
+ import { existsSync } from 'node:fs'
2
+ import { poll } from '#scripts/lib/poll'
3
+ import { run_carry, type CarryRead, type RunCarry } from '#scripts/run/carry/run-carry'
4
+ import { run_carry_ended } from '#scripts/run/carry/run-carry-ended'
5
+ import { backlog_budget } from './backlog-budget'
6
+
7
+ // **A record that does not read is asked again before the driver gives up on it.** The driver is the
8
+ // only thing looking for work during the idle watch, so ending on one failed read throws away whatever
9
+ // is left of the watch. The read is repeated at the offer's own interval, and
10
+ // the count bounds it: a record that stays unreadable still ends the driver, with the reason said.
11
+ const READ_ATTEMPTS = 6
12
+ const ENDED_MESSAGE = 'The carry record is no longer active.'
13
+
14
+ // The run this driver last read, so a record that is gone can be told from one `run:carry --end` ended.
15
+ const seen: { started_at?: string } = {}
2
16
 
3
17
  function owns_record(carry: RunCarry, owner: string): boolean {
4
18
  const current = run_carry.owner_of(Number(owner))
@@ -7,16 +21,69 @@ function owns_record(carry: RunCarry, owner: string): boolean {
7
21
  return has_owner && run_carry.is_owner_live(carry) && !carry.is_handed_off
8
22
  }
9
23
 
10
- async function assert_current(owner: string): Promise<void> {
24
+ // `--end` records the run it removes, so an ended run answers at once rather than after the re-reads.
25
+ function is_ended(directory: string): boolean {
26
+ return run_carry_ended.has_ended(run_carry_ended.ended_path(directory), seen.started_at)
27
+ }
28
+
29
+ // Whether the file is there is said beside the read, because `none` is the answer both to a record
30
+ // that is gone and to one that is present and could not be opened.
31
+ function unread_note(read: CarryRead, directory: string): string {
32
+ const presence = existsSync(run_carry.carry_path(directory)) ? 'present' : 'absent'
33
+
34
+ return `The carry record read \`${read.kind}\` with its file ${presence}`
35
+ }
36
+
37
+ function is_settled(read: CarryRead, directory: string): boolean {
38
+ if (read.kind === 'carried' || is_ended(directory)) return true
39
+
40
+ console.error(`${unread_note(read, directory)}; reading it again.`)
41
+
42
+ return false
43
+ }
44
+
45
+ async function read_record(directory: string): Promise<CarryRead> {
46
+ const target = run_carry.carry_path(directory)
47
+ let read: CarryRead = { kind: 'none' }
48
+
49
+ await poll.poll_until(
50
+ async () => {
51
+ read = run_carry.read_carry(target)
52
+
53
+ return is_settled(read, directory)
54
+ },
55
+ {
56
+ attempts: READ_ATTEMPTS,
57
+ interval_ms: backlog_budget.OFFER_INTERVAL_MS,
58
+ sleeper: async (duration_ms) => {
59
+ await poll.sleep(duration_ms)
60
+ },
61
+ },
62
+ )
63
+
64
+ return read
65
+ }
66
+
67
+ function unread_message(read: CarryRead, directory: string): string {
68
+ if (is_ended(directory)) return ENDED_MESSAGE
69
+
70
+ return `${unread_note(read, directory)}, ${String(READ_ATTEMPTS)} times in a row.`
71
+ }
72
+
73
+ async function assert_current(owner: string): Promise<RunCarry> {
11
74
  const directory = await run_carry.repository_directory()
12
75
  if (directory === undefined) throw new Error('The carry record cannot be located.')
13
76
 
14
- const read = run_carry.read_carry(run_carry.carry_path(directory))
15
- if (read.kind !== 'carried') throw new Error('The carry record is no longer active.')
77
+ const read = await read_record(directory)
78
+ if (read.kind !== 'carried') throw new Error(unread_message(read, directory))
16
79
 
17
80
  if (!owns_record(read.carry, owner)) {
18
81
  throw new Error('The driver no longer owns the carry record.')
19
82
  }
83
+
84
+ seen.started_at = read.carry.started_at
85
+
86
+ return read.carry
20
87
  }
21
88
 
22
- export const backlog_drive_owner = { assert_current }
89
+ export const backlog_drive_owner = { READ_ATTEMPTS, assert_current }
@@ -11,6 +11,7 @@ import { stamp_file } from '#scripts/josh/stamp-file'
11
11
  import { json_value } from '#scripts/lib/json-value'
12
12
  import { run_carry, type CarryRead } from '#scripts/run/carry/run-carry'
13
13
  import { detached_launch, type LaunchResult } from '#scripts/run/detached-launch'
14
+ import { run_event_setup } from '#scripts/run/event/run-event-setup'
14
15
  import { run_headless } from '#scripts/run/run-headless'
15
16
  import { z } from 'zod'
16
17
 
@@ -34,10 +35,20 @@ const launch_schema = z.object({
34
35
  pid: z.number(),
35
36
  start: z.string().optional(),
36
37
  session: z.string().optional(),
38
+ // What the parent was started to run and when, so `run:board` has a run to draw and a clock to count
39
+ // before the parent claims the carry record. Absent on a record an earlier version wrote.
40
+ invocation: z.string().optional(),
41
+ started_at: z.string().optional(),
37
42
  })
38
43
 
39
44
  type LaunchRecord = z.infer<typeof launch_schema>
40
45
 
46
+ // A launched run the parent has not claimed yet, as far as its record says.
47
+ interface PendingLaunch {
48
+ invocation: string
49
+ started_at: string
50
+ }
51
+
41
52
  interface LaunchTarget {
42
53
  // The checkout the parent runs in, and its common git directory the records are keyed on.
43
54
  worktree: string
@@ -117,10 +128,48 @@ function note_to_stderr(note: string): void {
117
128
  console.error(`backlogrun: ${note}`)
118
129
  }
119
130
 
120
- function record_launch(git_directory: string, pid: number, session: string | undefined): void {
121
- const started = process_identity.read_start(pid)
131
+ // **The record and the run's first stage carry one moment**, so the `starting session` event is never
132
+ // dated before the start the board measures from.
133
+ function record_launch(
134
+ git_directory: string,
135
+ launch: Pick<LaunchRecord, 'pid' | 'session' | 'invocation'>,
136
+ ): void {
137
+ const now = new Date()
138
+ const identity = process_identity.read_start(launch.pid)
139
+ const record: LaunchRecord = { ...launch, start: identity, started_at: now.toISOString() }
140
+
141
+ stamp_file.replace_stamp(launch_path(git_directory), record)
142
+ run_event_setup.emit_in(git_directory, run_event_setup.STAGE.STARTING, now)
143
+ }
144
+
145
+ // The live launch record here, `undefined` once the parent it names is gone.
146
+ function live_launch(git_directory: string): LaunchRecord | undefined {
147
+ const record = read_launch(launch_path(git_directory))
148
+
149
+ return is_launch_live(record) ? record : undefined
150
+ }
151
+
152
+ // The launch `run:board` draws while no carry record stands: one whose record says what it runs and
153
+ // since when.
154
+ function pending_launch(git_directory: string): PendingLaunch | undefined {
155
+ const { invocation, started_at } = live_launch(git_directory) ?? {}
156
+
157
+ if (invocation === undefined || started_at === undefined) return undefined
158
+
159
+ return { invocation, started_at }
160
+ }
161
+
162
+ // When the launch that started `owner_pid` began, for `run:carry --begin` to start the run's clock
163
+ // from: **only the parent this launch started inherits it**, so a run typed inside a session — which has
164
+ // no launch record, or another parent's — still starts at its own `--begin`.
165
+ function started_for(git_directory: string, owner_pid: number | undefined): Date | undefined {
166
+ const record = live_launch(git_directory)
167
+
168
+ if (record?.started_at === undefined || record.pid !== owner_pid) return undefined
169
+
170
+ const started_ms = Date.parse(record.started_at)
122
171
 
123
- stamp_file.replace_stamp(launch_path(git_directory), { pid, start: started, session })
172
+ return Number.isNaN(started_ms) ? undefined : new Date(started_ms)
124
173
  }
125
174
 
126
175
  // The headless mark holds the parent's turn open while its lanes run, as `run:wake`'s does. The launch
@@ -160,7 +209,7 @@ function start(invocation: string, provider: AgentProvider, target: LaunchTarget
160
209
 
161
210
  if (result.kind === 'failed') return result
162
211
 
163
- record_launch(target.git_directory, result.pid, session)
212
+ record_launch(target.git_directory, { pid: result.pid, session, invocation })
164
213
 
165
214
  const launched = { pid: result.pid, session, log_path: log_path(target.git_directory) }
166
215
 
@@ -171,8 +220,10 @@ const backlogrun_launch = {
171
220
  argv_of,
172
221
  chosen_environment,
173
222
  is_running,
223
+ pending_launch,
174
224
  start,
225
+ started_for,
175
226
  }
176
227
 
177
- export type { Launched, LaunchTarget, StartResult }
228
+ export type { Launched, LaunchTarget, PendingLaunch, StartResult }
178
229
  export { backlogrun_launch }
@@ -74,7 +74,7 @@ const DOCUMENT_BYTE_BUDGET: ReadonlyArray<DocumentBudget> = [
74
74
  { path: '.claude/skills/workflow-commands/target-repository.md', bytes: 4096 },
75
75
  { path: '.claude/skills/workflow-commands/working-tree-hold.md', bytes: 8192 },
76
76
  { path: 'docs/josh-commands-automation.md', bytes: 53_248 },
77
- { path: 'docs/josh-commands-run.md', bytes: 65_536 },
77
+ { path: 'docs/josh-commands-run.md', bytes: 69_632 },
78
78
  { path: 'docs/josh-commands-backlog.md', bytes: 57_344 },
79
79
  { path: 'docs/josh-commands.md', bytes: 49_152 },
80
80
  { path: 'docs/labels-and-run-states.md', bytes: 8192 },
@@ -9,6 +9,7 @@ interface StartOptions {
9
9
  is_github: boolean
10
10
  visibility: Visibility
11
11
  init_command: string | undefined
12
+ push_preflight_command: string | undefined
12
13
  }
13
14
 
14
15
  type StartStep = 'git_init' | 'initialize' | 'commit' | 'repository' | 'labels' | 'setup_pr'
@@ -53,7 +54,8 @@ const STEP_LABELS: Readonly<Record<StartStep, string>> = {
53
54
  setup_pr: 'Open an Issue and a pull request that add the kit setup to main',
54
55
  }
55
56
 
56
- // The GitHub write steps `--yes` alone never consents to.
57
+ // The GitHub write steps `--yes` alone never consents to. Each one pushes — main, or the setup branch —
58
+ // so they are also the steps a caller's push preflight guards (#3702).
57
59
  const GITHUB_WRITE_STEPS: ReadonlySet<StartStep> = new Set(['repository', 'setup_pr'])
58
60
 
59
61
  function refused(refusal: string): StepPlan {
@@ -107,6 +109,10 @@ function plan_steps(state: GitState): StepPlan {
107
109
  return { steps: FULL_STEPS, refusal: undefined }
108
110
  }
109
111
 
112
+ function has_push(steps: ReadonlyArray<StartStep>): boolean {
113
+ return steps.some((step) => GITHUB_WRITE_STEPS.has(step))
114
+ }
115
+
110
116
  function interaction_of(options: StartOptions, is_tty: boolean): Verdict<Interaction> {
111
117
  if (options.is_yes) return { value: 'unattended', refusal: undefined }
112
118
  if (is_tty) return { value: 'interactive', refusal: undefined }
@@ -126,9 +132,7 @@ function github_consent(
126
132
  options: StartOptions,
127
133
  interaction: Interaction,
128
134
  ): Verdict<Consent> {
129
- const is_github_write = steps.some((step) => GITHUB_WRITE_STEPS.has(step))
130
-
131
- if (!is_github_write || options.is_github) return { value: 'granted', refusal: undefined }
135
+ if (!has_push(steps) || options.is_github) return { value: 'granted', refusal: undefined }
132
136
  if (interaction === 'interactive') return { value: 'ask', refusal: undefined }
133
137
 
134
138
  return {
@@ -158,6 +162,11 @@ function progress_report(
158
162
  return `josh start stopped at: ${labels[failed]}\nCompleted: ${done}\nCause: ${cause}`
159
163
  }
160
164
 
165
+ // What a run prints when the caller's push preflight fails: no step has run yet, so nothing changed.
166
+ function preflight_report(command: string, cause: string): string {
167
+ return `josh start stopped before its first step: the push preflight (${command}) failed. Nothing was changed.\nCause: ${cause}`
168
+ }
169
+
161
170
  function plan_summary(
162
171
  steps: ReadonlyArray<StartStep>,
163
172
  labels: Readonly<Record<StartStep, string>> = STEP_LABELS,
@@ -169,9 +178,11 @@ function plan_summary(
169
178
 
170
179
  const start_plan = {
171
180
  plan_steps,
181
+ has_push,
172
182
  interaction_of,
173
183
  github_consent,
174
184
  progress_report,
185
+ preflight_report,
175
186
  plan_summary,
176
187
  step_labels,
177
188
  STEP_LABELS,