@joshuafolkken/kit 1.1128.0 → 1.1136.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 (54) hide show
  1. package/.claude/agents/investigator.md +17 -0
  2. package/.claude/settings.json +1 -0
  3. package/.claude/skills/workflow-commands/backlogrun-progress.md +2 -2
  4. package/.claude/skills/workflow-commands/delegation.md +5 -1
  5. package/SECURITY.md +1 -0
  6. package/dist/hooks/batch-guard.js +4 -4
  7. package/dist/hooks/{chunk-77J7IEQM.js → chunk-FO2I7UT7.js} +1 -1
  8. package/dist/hooks/{chunk-4JZDP5X5.js → chunk-GDU26K3J.js} +9 -1
  9. package/dist/hooks/{chunk-QVLT52OV.js → chunk-HANSKHES.js} +2 -2
  10. package/dist/hooks/{chunk-5SD2YM7Z.js → chunk-HBKZLITF.js} +4 -4
  11. package/dist/hooks/{chunk-AIOIOMPX.js → chunk-JKKTVDAG.js} +128 -61
  12. package/dist/hooks/{chunk-M7QKEKWO.js → chunk-KBXZLMRH.js} +9 -28
  13. package/dist/hooks/{chunk-XYUIQOW3.js → chunk-KOKOAV6D.js} +2 -2
  14. package/dist/hooks/{chunk-NXYMNPUQ.js → chunk-LJBLGHEH.js} +59 -19
  15. package/dist/hooks/{chunk-MKVGBQPA.js → chunk-NFZGFGOR.js} +1 -1
  16. package/dist/hooks/{chunk-7HVB7SZG.js → chunk-RFWWUI5F.js} +4 -4
  17. package/dist/hooks/codex-hook-adapter.js +10 -10
  18. package/dist/hooks/duplicate-read-guard.js +5 -5
  19. package/dist/hooks/format-edited.js +2 -2
  20. package/dist/hooks/inputs.json +199 -198
  21. package/dist/hooks/investigation-guard.js +5 -5
  22. package/dist/hooks/pretool-guard.js +9 -9
  23. package/dist/hooks/stop-guard.js +3 -3
  24. package/dist/josh.js +8 -1
  25. package/package.json +6 -4
  26. package/scripts/delegation/delegation-policy.ts +9 -1
  27. package/scripts/delegation/investigation-guard.ts +1 -1
  28. package/scripts/delegation/investigation-reads.ts +105 -19
  29. package/scripts/document/capped-print.ts +74 -0
  30. package/scripts/document/document-section-cli.ts +4 -3
  31. package/scripts/gate/scoped-green.ts +25 -14
  32. package/scripts/init/claude-kit-only-settings.ts +29 -0
  33. package/scripts/init/init-copy-content.ts +8 -2
  34. package/scripts/init/initial-commit.ts +24 -0
  35. package/scripts/init/start-exec.ts +16 -3
  36. package/scripts/init/start-steps.ts +8 -12
  37. package/scripts/issue/issue-read-cli.ts +1 -0
  38. package/scripts/issue/issue-report-failures.ts +6 -2
  39. package/scripts/issue/issue-state-cli.ts +1 -1
  40. package/scripts/josh/josh-commands-lane.ts +1 -1
  41. package/scripts/josh/josh-commands-project.ts +7 -0
  42. package/scripts/lane/lane-await.ts +46 -2
  43. package/scripts/lane/lane-cli.ts +11 -13
  44. package/scripts/release/github-release-environment.ts +1 -0
  45. package/scripts/release/github-release.ts +103 -17
  46. package/scripts/run/run-carry-cli.ts +3 -1
  47. package/scripts/run/run-carry-conversation.ts +94 -0
  48. package/scripts/run/run-carry-reclaim.ts +37 -0
  49. package/scripts/run/run-carry.ts +21 -5
  50. package/scripts/run/run-merge-steps.ts +4 -1
  51. package/scripts/test/test-related.ts +1 -6
  52. package/scripts/test/test-unit-guard.ts +21 -7
  53. package/scripts/time-runtime/time-spans.ts +4 -0
  54. package/templates/workflows/github-release.yml +13 -25
@@ -188,7 +188,7 @@ function print_states(reports: ReadonlyArray<IssueReport>): void {
188
188
  const should_attribute = reports.length > 1
189
189
  const blocks = reports.flatMap((report) => state_blocks(report, should_attribute))
190
190
 
191
- issue_report_failures.print_blocks(blocks, BLOCK_SEPARATOR)
191
+ issue_report_failures.print_blocks(blocks, BLOCK_SEPARATOR, 'issue:state')
192
192
  }
193
193
 
194
194
  async function run(argv: ReadonlyArray<string>): Promise<number> {
@@ -59,7 +59,7 @@ const LANE_COMMANDS: Record<string, CommandEntry> = {
59
59
  description:
60
60
  'Block until any of the named in-flight lane children completes; prints which one finished',
61
61
  category: 'AI tools',
62
- reference: ['<issue> [<issue>...]', 'automation', ['processes']],
62
+ reference: ['<issue> [<issue>...] [--owner <pid>]', 'automation', ['files', 'processes']],
63
63
  default_script_arguments: ['await'],
64
64
  },
65
65
  'lane:launch': {
@@ -22,6 +22,13 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
22
22
  ['files', 'git', 'network', 'processes'],
23
23
  ],
24
24
  },
25
+ 'dogfood:commit': {
26
+ script: 'scripts/dogfood/dogfood-commit-cli.ts',
27
+ description: "Make the first commit of a dogfood run's own kit-test-* project",
28
+ category: 'Project',
29
+ reference: ['<dir>', 'automation', ['files', 'git']],
30
+ is_kit_only: true,
31
+ },
25
32
  init: {
26
33
  script: 'scripts/init/init.ts',
27
34
  description: 'Initialize config in a new project',
@@ -1,5 +1,6 @@
1
1
  import { spawnSync } from 'node:child_process'
2
2
  import { setTimeout as sleep } from 'node:timers/promises'
3
+ import { parseArgs } from 'node:util'
3
4
  import { run_liveness } from '#scripts/run/run-liveness'
4
5
  import { lane_child_invocation } from './lane-child-invocation'
5
6
  import { lane_handoff } from './lane-handoff'
@@ -33,6 +34,16 @@ interface AwaitState {
33
34
  is_settled?: boolean
34
35
  }
35
36
 
37
+ interface AwaitArguments {
38
+ issues: ReadonlyArray<string>
39
+ owner: number | undefined
40
+ }
41
+
42
+ interface RawAwaitArguments {
43
+ positionals: Array<string>
44
+ values: { owner?: string | undefined }
45
+ }
46
+
36
47
  // Injectable for testing -- the CLI never overrides these.
37
48
  interface AwaitOptions {
38
49
  is_running?: (issue: string) => boolean
@@ -67,6 +78,39 @@ async function is_settled_default(issue: string): Promise<boolean> {
67
78
  return (await run_liveness.read_child_settled(issue)) === true
68
79
  }
69
80
 
81
+ // An unknown flag or a flag without its value is `undefined`, which is usage.
82
+ function read_await_args(rest: ReadonlyArray<string>): RawAwaitArguments | undefined {
83
+ try {
84
+ return parseArgs({
85
+ args: [...rest],
86
+ allowPositionals: true,
87
+ strict: true,
88
+ options: { owner: { type: 'string' } },
89
+ })
90
+ } catch {
91
+ return undefined
92
+ }
93
+ }
94
+
95
+ // Every positional is an issue number, at least one is given, and `--owner` is a pid.
96
+ function is_valid(raw: RawAwaitArguments): boolean {
97
+ const { owner } = raw.values
98
+ const numbers = owner === undefined ? raw.positionals : [...raw.positionals, owner]
99
+
100
+ return raw.positionals.length > 0 && numbers.every((value) => ISSUE_PATTERN.test(value))
101
+ }
102
+
103
+ // The issues to wait on and the waiting session's pid, or `undefined` for anything else.
104
+ function parse_arguments(rest: ReadonlyArray<string>): AwaitArguments | undefined {
105
+ const raw = read_await_args(rest)
106
+
107
+ if (raw === undefined || !is_valid(raw)) return undefined
108
+
109
+ const { owner } = raw.values
110
+
111
+ return { issues: raw.positionals, owner: owner === undefined ? undefined : Number(owner) }
112
+ }
113
+
70
114
  function make_initial_state(is_settled: boolean): AwaitState {
71
115
  return { appeared: false, disappeared_at: undefined, first_polled_at: undefined, is_settled }
72
116
  }
@@ -187,13 +231,13 @@ async function wait_for_any(
187
231
  }
188
232
 
189
233
  const lane_await = {
190
- ISSUE_PATTERN,
191
234
  NEVER_APPEARED_TIMEOUT_MS,
192
235
  RECONFIRM_MS,
193
236
  check_issue,
194
237
  is_process_running_default,
238
+ parse_arguments,
195
239
  wait_for_any,
196
240
  }
197
241
 
198
- export type { AwaitState, CheckConfig }
242
+ export type { AwaitArguments, AwaitState, CheckConfig }
199
243
  export { lane_await }
@@ -6,6 +6,7 @@ import type { OpenIssueData } from '#scripts/git/schemas'
6
6
  import { issue_cite, type IssueCiter } from '#scripts/issue/issue-cite'
7
7
  import { error_text } from '#scripts/lib/error-message'
8
8
  import { run_carry } from '#scripts/run/run-carry'
9
+ import { run_carry_reclaim } from '#scripts/run/run-carry-reclaim'
9
10
  import { lane_await } from './lane-await'
10
11
  import { lane_close, type CloseOutcome, type SweepOutcome } from './lane-close'
11
12
  import { lane_dispatch, type DispatchOutcome } from './lane-dispatch'
@@ -40,7 +41,7 @@ const USAGE = [
40
41
  ' josh lane:prune',
41
42
  ' josh lane:output <issue-number> [<path>]',
42
43
  ' josh lane:dispatch <issue-number>',
43
- ' josh lane:await <issue-number> ...',
44
+ ' josh lane:await <issue-number> ... [--owner <pid>]',
44
45
  ].join('\n')
45
46
 
46
47
  type Handler = (rest: ReadonlyArray<string>) => Promise<number>
@@ -356,22 +357,19 @@ async function dispatch_command(rest: ReadonlyArray<string>): Promise<number> {
356
357
  return await report_dispatch(await lane_dispatch.dispatch_child(issue), issue)
357
358
  }
358
359
 
359
- // Each valid argument is an issue number; any non-number stops parsing and triggers usage.
360
- function parse_issues(rest: ReadonlyArray<string>): ReadonlyArray<string> | undefined {
361
- if (rest.length === 0) return undefined
362
- const issues = rest.filter((value) => lane_await.ISSUE_PATTERN.test(value))
363
-
364
- return issues.length === rest.length ? issues : undefined
365
- }
366
-
367
360
  // The completed issue number goes to standard output so `N=$(pnpm josh lane:await ...)` captures
368
- // which child finished without parsing stderr.
361
+ // which child finished without parsing stderr. `--owner` reclaims the carry record for a resumed
362
+ // conversation before the wait (`run-carry-reclaim.ts`).
369
363
  async function await_command(rest: ReadonlyArray<string>): Promise<number> {
370
- const issues = parse_issues(rest)
364
+ const parsed = lane_await.parse_arguments(rest)
371
365
 
372
- if (issues === undefined) return report_usage()
366
+ if (parsed === undefined) return report_usage()
367
+
368
+ if (parsed.owner !== undefined) {
369
+ await run_carry_reclaim.reclaim_carry(run_carry.owner_of(parsed.owner))
370
+ }
373
371
 
374
- const completed = await lane_await.wait_for_any(issues)
372
+ const completed = await lane_await.wait_for_any(parsed.issues)
375
373
 
376
374
  console.info(completed)
377
375
 
@@ -37,6 +37,7 @@ function read_release_input(environment: Environment): ReleaseInput {
37
37
  start_tag: optional(environment['RELEASE_START_TAG']),
38
38
  workflow: optional(environment['RELEASE_WORKFLOW']),
39
39
  jobs: job_names(environment['RELEASE_JOBS']),
40
+ await_publish: environment['RELEASE_AWAIT_PUBLISH'] === 'true',
40
41
  },
41
42
  }
42
43
  }
@@ -15,7 +15,12 @@ const POLL_INTERVAL_MS = 30_000
15
15
  const MAX_WAIT_ATTEMPTS = 120
16
16
  const RELEASE_SCHEMA = z.object({ tag_name: z.string().min(1) })
17
17
  const NOTES_SCHEMA = z.object({ name: z.string().min(1), body: z.string().min(1) })
18
- const RUN_SCHEMA = z.object({ id: z.number(), display_title: z.string() })
18
+ const RUN_SCHEMA = z.object({
19
+ id: z.number(),
20
+ display_title: z.string(),
21
+ status: z.string().optional(),
22
+ conclusion: z.string().nullable().optional(),
23
+ })
19
24
  const JOB_SCHEMA = z.object({
20
25
  name: z.string(),
21
26
  status: z.string(),
@@ -26,15 +31,19 @@ const JOBS_SCHEMA = z.object({ jobs: z.array(JOB_SCHEMA) })
26
31
 
27
32
  type ReleaseRequest = (url: string, init: RequestInit) => Promise<Response>
28
33
  type Wait = () => Promise<void>
34
+ type PollState = 'ready' | 'failed' | 'pending'
29
35
 
30
36
  // What differs between repositories. `start_tag` is the last tag that predates automatic releases;
31
37
  // without it the latest existing release is the floor. `workflow` names the publish workflow whose
32
38
  // failed run lets a lower tag be skipped, and `jobs` the jobs in it that count (empty: every job).
39
+ // `await_publish` holds the tag's own release until its run in that workflow has succeeded, for a
40
+ // release started beside the publication rather than from inside it.
33
41
  interface ReleaseSettings {
34
42
  repository: string
35
43
  start_tag?: string | undefined
36
44
  workflow?: string | undefined
37
45
  jobs?: ReadonlyArray<string> | undefined
46
+ await_publish?: boolean | undefined
38
47
  }
39
48
 
40
49
  interface PublishOptions extends ReleaseSettings {
@@ -56,7 +65,14 @@ interface Ladder {
56
65
  tags: ReadonlyArray<string>
57
66
  floor: string | undefined
58
67
  fallback?: () => Promise<string>
68
+ budget: Budget
69
+ }
70
+
71
+ // One allowance shared by every wait a release makes: waiting for its own publication and then for
72
+ // lower tags' releases must end inside the job's `timeout-minutes` together, not each on its own.
73
+ interface Budget {
59
74
  wait: Wait
75
+ remaining: number
60
76
  }
61
77
 
62
78
  function request_options(token: string, method: string, body?: object): RequestInit {
@@ -130,13 +146,22 @@ function is_failed_job(job: z.infer<typeof JOB_SCHEMA>, names: ReadonlyArray<str
130
146
  return names.some((name) => job.name === name || job.name.startsWith(`${name} /`))
131
147
  }
132
148
 
149
+ async function publish_run(
150
+ client: Client,
151
+ workflow_name: string,
152
+ tag: string,
153
+ ): Promise<z.infer<typeof RUN_SCHEMA> | undefined> {
154
+ const workflow = encodeURIComponent(workflow_name)
155
+ const runs_response = await api_get(client, `/actions/workflows/${workflow}/runs?per_page=100`)
156
+ const runs = RUNS_SCHEMA.parse(await read_response(runs_response)).workflow_runs
157
+
158
+ return runs.find((item) => item.display_title === `Publish ${tag}`)
159
+ }
160
+
133
161
  async function failed_publication(client: Client, tag: string): Promise<boolean> {
134
162
  if (client.workflow === undefined) return false
135
163
 
136
- const workflow = encodeURIComponent(client.workflow)
137
- const runs_response = await api_get(client, `/actions/workflows/${workflow}/runs?per_page=100`)
138
- const runs = RUNS_SCHEMA.parse(await read_response(runs_response)).workflow_runs
139
- const run = runs.find((item) => item.display_title === `Publish ${tag}`)
164
+ const run = await publish_run(client, client.workflow, tag)
140
165
  if (!run) return false
141
166
 
142
167
  const jobs_response = await api_get(client, `/actions/runs/${String(run.id)}/jobs?per_page=100`)
@@ -149,24 +174,72 @@ async function default_wait(): Promise<void> {
149
174
  await setTimeout(POLL_INTERVAL_MS)
150
175
  }
151
176
 
152
- async function release_state(client: Client, tag: string): Promise<'ready' | 'failed' | 'pending'> {
177
+ async function release_state(client: Client, tag: string): Promise<PollState> {
153
178
  if (await existing_release(client, tag)) return 'ready'
154
179
  if (await failed_publication(client, tag)) return 'failed'
155
180
 
156
181
  return 'pending'
157
182
  }
158
183
 
159
- async function wait_for_release(client: Client, tag: string, wait: Wait): Promise<boolean> {
160
- for (let attempt = 0; attempt < MAX_WAIT_ATTEMPTS; attempt += 1) {
184
+ // A run not listed yet is pending too: the announcement that starts this release also starts the
185
+ // publication, so the release can ask before GitHub has queued that run.
186
+ async function publication_state(
187
+ client: Client,
188
+ workflow: string,
189
+ tag: string,
190
+ ): Promise<PollState> {
191
+ const run = await publish_run(client, workflow, tag)
192
+ if (run?.status !== 'completed') return 'pending'
193
+
194
+ return run.conclusion === 'success' ? 'ready' : 'failed'
195
+ }
196
+
197
+ async function poll(
198
+ read: () => Promise<PollState>,
199
+ budget: Budget,
200
+ subject: string,
201
+ ): Promise<boolean> {
202
+ while (budget.remaining > 0) {
161
203
  // eslint-disable-next-line no-await-in-loop -- polling: each read waits on the state the previous one saw
162
- const state = await release_state(client, tag)
204
+ const state = await read()
163
205
  if (state !== 'pending') return state === 'ready'
164
206
 
207
+ budget.remaining -= 1
165
208
  // eslint-disable-next-line no-await-in-loop -- polling: each read waits on the state the previous one saw
166
- await wait()
209
+ await budget.wait()
167
210
  }
168
211
 
169
- throw new Error(`Timed out waiting for previous GitHub Release ${tag}`)
212
+ throw new Error(`Timed out waiting for ${subject}`)
213
+ }
214
+
215
+ async function wait_for_release(client: Client, tag: string, budget: Budget): Promise<boolean> {
216
+ return await poll(
217
+ async () => await release_state(client, tag),
218
+ budget,
219
+ `previous GitHub Release ${tag}`,
220
+ )
221
+ }
222
+
223
+ async function wait_for_publication(client: Client, tag: string, budget: Budget): Promise<boolean> {
224
+ const { workflow } = client
225
+ if (workflow === undefined) throw new Error('Waiting for the publication needs RELEASE_WORKFLOW')
226
+
227
+ return await poll(
228
+ async () => await publication_state(client, workflow, tag),
229
+ budget,
230
+ `Publish ${tag}`,
231
+ )
232
+ }
233
+
234
+ async function is_publication_ready(
235
+ client: Client,
236
+ tag: string,
237
+ options: PublishOptions,
238
+ budget: Budget,
239
+ ): Promise<boolean> {
240
+ if (options.await_publish !== true) return true
241
+
242
+ return await wait_for_publication(client, tag, budget)
170
243
  }
171
244
 
172
245
  async function ladder_end(ladder: Ladder): Promise<string | undefined> {
@@ -184,7 +257,7 @@ async function baseline_tag(
184
257
  if (ladder.floor === undefined) return prior
185
258
  if (!prior) return await ladder_end(ladder)
186
259
 
187
- return (await wait_for_release(client, prior, ladder.wait))
260
+ return (await wait_for_release(client, prior, ladder.budget))
188
261
  ? prior
189
262
  : await baseline_tag(client, prior, ladder)
190
263
  }
@@ -195,16 +268,19 @@ function assert_older(previous: string | undefined, tag: string): void {
195
268
  }
196
269
  }
197
270
 
198
- async function build_ladder(client: Client, options: PublishOptions): Promise<Ladder> {
271
+ async function build_ladder(
272
+ client: Client,
273
+ options: PublishOptions,
274
+ budget: Budget,
275
+ ): Promise<Ladder> {
199
276
  const tags = options.tags ?? []
200
- const wait = options.wait ?? default_wait
201
277
  const { start_tag } = options
202
278
 
203
279
  if (start_tag !== undefined) {
204
- return { tags, wait, floor: start_tag, fallback: async () => await previous_tag(client) }
280
+ return { tags, budget, floor: start_tag, fallback: async () => await previous_tag(client) }
205
281
  }
206
282
 
207
- return { tags, wait, floor: await latest_release(client) }
283
+ return { tags, budget, floor: await latest_release(client) }
208
284
  }
209
285
 
210
286
  async function generate_notes(
@@ -252,6 +328,10 @@ function create_client(request: ReleaseRequest, token: string, settings: Release
252
328
  }
253
329
  }
254
330
 
331
+ function create_budget(options: PublishOptions): Budget {
332
+ return { wait: options.wait ?? default_wait, remaining: MAX_WAIT_ATTEMPTS }
333
+ }
334
+
255
335
  async function publish(
256
336
  request: ReleaseRequest,
257
337
  token: string,
@@ -263,7 +343,13 @@ async function publish(
263
343
  const client = create_client(request, token, options)
264
344
  if (await existing_release(client, tag)) return 'already-published'
265
345
 
266
- const previous = await baseline_tag(client, tag, await build_ladder(client, options))
346
+ const budget = create_budget(options)
347
+
348
+ if (!(await is_publication_ready(client, tag, options, budget))) {
349
+ return `skipped ${tag}: Publish ${tag} did not succeed`
350
+ }
351
+
352
+ const previous = await baseline_tag(client, tag, await build_ladder(client, options, budget))
267
353
 
268
354
  assert_older(previous, tag)
269
355
  await create_release(client, tag, previous)
@@ -10,6 +10,7 @@ import {
10
10
  type RunCarry,
11
11
  } from './run-carry'
12
12
  import { run_carry_args, type CountRequest, type Request } from './run-carry-args'
13
+ import { run_carry_conversation } from './run-carry-conversation'
13
14
  import { run_carry_stash } from './run-carry-stash'
14
15
  import { run_event_stream } from './run-event-stream'
15
16
  import { run_event_stream_emit } from './run-event-stream-emit'
@@ -331,7 +332,8 @@ async function count(
331
332
 
332
333
  if (blocked !== undefined) return blocked
333
334
 
334
- const carry = run_carry.apply_change(target, read.carry, request.change)
335
+ const owned = run_carry_conversation.reclaim_owner(read.carry, request.owner)
336
+ const carry = run_carry.apply_change(target, owned, request.change)
335
337
 
336
338
  await record_retrospective(request)
337
339
 
@@ -0,0 +1,94 @@
1
+ import { existsSync, statSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { cost_transcript } from '#scripts/cost-runtime/cost-transcript'
4
+ import { own_session } from '#scripts/cost-runtime/own-session'
5
+ import type { CarryOwner, RunCarry } from './run-carry'
6
+
7
+ // **A conversation outlives its process, so an owner named by a pid alone dies too early**
8
+ // (joshuafolkken/kit#3137). The carry record names the agent process spending the budget, and that is
9
+ // right while the process lives — but an interactive conversation is not its process. On 2026-10-04 a
10
+ // VS Code extension-host restart ended the session's process at 15:17:34 UTC, and the same
11
+ // conversation resumed eighteen seconds later in a new process (`--resume=<id>`), writing on into the
12
+ // same transcript. `run:wake` read the old pid as dead, recovered the record as a crash, and dispatched
13
+ // a second lane child for the issue the conversation was still running.
14
+ //
15
+ // **The transcript is what persists across the restart, so it is the second half of the identity.**
16
+ // Every resume path — the extension's `--resume=<id>`, a person's `claude -c` — appends to the one
17
+ // file named by the session id, and nothing else writes it. So a record also names its owner's
18
+ // transcript, and a dead pid whose transcript was written inside the quiet window below is a
19
+ // conversation still being continued rather than a crash. Matching the transcript is also how the
20
+ // resumed conversation is recognized as the record's own owner, so it can keep counting.
21
+ //
22
+ // **A hand-off ends the conversation's claim.** A record `--cut` handed off is waited on by the pid
23
+ // alone, because the cutting conversation is meant to end there — counted by its transcript, the
24
+ // supervisor would hold every cut for the whole window after the cutting process had already exited.
25
+
26
+ // How long after its last write a conversation whose process has gone still counts as continuing. It
27
+ // bridges a restart and a resume — measured at eighteen seconds — with room for a person reopening a
28
+ // window, and it is the order of the wake grace window, so a genuine crash is recovered no later than
29
+ // a lost wake would be retried.
30
+ const CONVERSATION_QUIET_MS = 600_000
31
+
32
+ type Environment = Readonly<Record<string, string | undefined>>
33
+
34
+ // The calling session's own transcript, or `undefined` outside a session or when its file cannot be
35
+ // found. **The id comes from the environment, which a detached supervisor never inherits**
36
+ // (`agent-session-environment.ts` strips it), so a record the supervisor adopts names no transcript.
37
+ function own_transcript(
38
+ environment: Environment = process.env,
39
+ directories: ReadonlyArray<string> = cost_transcript.transcript_directories(process.cwd()),
40
+ ): string | undefined {
41
+ const session_id = own_session.session_id_of(environment)
42
+
43
+ if (session_id === undefined) return undefined
44
+
45
+ const name = `${session_id}${cost_transcript.TRANSCRIPT_EXTENSION}`
46
+
47
+ return directories.map((directory) => path.join(directory, name)).find((file) => existsSync(file))
48
+ }
49
+
50
+ function modified_ms(transcript: string): number | undefined {
51
+ try {
52
+ return statSync(transcript).mtimeMs
53
+ } catch {
54
+ return undefined
55
+ }
56
+ }
57
+
58
+ function is_recent(modified: number | undefined, now: Date): boolean {
59
+ return modified !== undefined && now.getTime() - modified <= CONVERSATION_QUIET_MS
60
+ }
61
+
62
+ function is_conversation_live(
63
+ carry: RunCarry,
64
+ now: Date = new Date(),
65
+ read_modified: (transcript: string) => number | undefined = modified_ms,
66
+ ): boolean {
67
+ if (carry.is_handed_off === true || carry.owner_transcript === undefined) return false
68
+
69
+ return is_recent(read_modified(carry.owner_transcript), now)
70
+ }
71
+
72
+ function is_same_conversation(carry: RunCarry, owner: CarryOwner): boolean {
73
+ return owner.transcript !== undefined && owner.transcript === carry.owner_transcript
74
+ }
75
+
76
+ // The record with its owner moved to the caller's process when the caller is the same conversation in
77
+ // a new one, so the record goes on naming a live process once the conversation falls quiet. A record a
78
+ // cut handed off is the successor's to take, never the cutting conversation's to take back.
79
+ function reclaim_owner(carry: RunCarry, owner: CarryOwner): RunCarry {
80
+ if (carry.is_handed_off === true || !is_same_conversation(carry, owner)) return carry
81
+ if (owner.pid === carry.owner_pid) return carry
82
+
83
+ return { ...carry, owner_pid: owner.pid, owner_start: owner.start }
84
+ }
85
+
86
+ const run_carry_conversation = {
87
+ CONVERSATION_QUIET_MS,
88
+ own_transcript,
89
+ is_conversation_live,
90
+ is_same_conversation,
91
+ reclaim_owner,
92
+ }
93
+
94
+ export { run_carry_conversation }
@@ -0,0 +1,37 @@
1
+ import { stamp_file } from '#scripts/josh/stamp-file'
2
+ import { run_carry, type CarryOwner } from './run-carry'
3
+ import { run_carry_conversation } from './run-carry-conversation'
4
+
5
+ // **A resumed conversation has to take its record back before it falls quiet** (joshuafolkken/kit#3137).
6
+ // A count moves the owner to the conversation's new process, but a parent waiting on a lane child
7
+ // counts nothing — `lane:await` blocks in the background and the transcript stays unwritten — so a
8
+ // restart followed by a long wait outlasts the quiet window with the record still naming the dead
9
+ // process, and `run:wake` recovers it as a crash. The wait itself is therefore where the parent
10
+ // declares its owner: `lane:await --owner "$PPID"` reclaims the record before it starts waiting.
11
+
12
+ // Writes the record back under the caller's process when the caller is the record's own conversation
13
+ // in a new one; answers whether it did.
14
+ function reclaim_at(target: string, owner: CarryOwner): boolean {
15
+ const read = run_carry.read_carry(target)
16
+
17
+ if (read.kind !== 'carried') return false
18
+
19
+ const reclaimed = run_carry_conversation.reclaim_owner(read.carry, owner)
20
+
21
+ if (reclaimed === read.carry) return false
22
+
23
+ // Replaced atomically: a `run:wake` reading between an unlink and a write would see no record.
24
+ stamp_file.replace_stamp(target, reclaimed)
25
+
26
+ return true
27
+ }
28
+
29
+ async function reclaim_carry(owner: CarryOwner): Promise<boolean> {
30
+ const directory = await run_carry.repository_directory()
31
+
32
+ return directory !== undefined && reclaim_at(run_carry.carry_path(directory), owner)
33
+ }
34
+
35
+ const run_carry_reclaim = { reclaim_at, reclaim_carry }
36
+
37
+ export { run_carry_reclaim }
@@ -4,6 +4,7 @@ import { process_identity } from '#scripts/josh/process-identity'
4
4
  import { stamp_file } from '#scripts/josh/stamp-file'
5
5
  import { z } from 'zod'
6
6
  import { run_carry_change } from './run-carry-change'
7
+ import { run_carry_conversation } from './run-carry-conversation'
7
8
  import { run_invocation } from './run-invocation'
8
9
 
9
10
  // joshuafolkken/kit#1714: a `backlogrun` declares a budget — `--max`, `--idle` and the 8-hour
@@ -106,6 +107,9 @@ interface RunCarry {
106
107
  // them apart would be asserting a distinction nothing here can observe.
107
108
  owner_pid?: number | undefined
108
109
  owner_start?: string | undefined
110
+ // The owner conversation's transcript, which outlives a restart of its process
111
+ // (`run-carry-conversation.ts`, joshuafolkken/kit#3137). Optional for the same reason as the pid.
112
+ owner_transcript?: string | undefined
109
113
  // Set by `--cut`, and by nothing else. A crash never reaches `--cut`, which is what makes a
110
114
  // declared cut the only standing record carried without a person deciding.
111
115
  is_handed_off?: boolean | undefined
@@ -129,10 +133,12 @@ interface RunCarry {
129
133
  }
130
134
 
131
135
  // The identity of a process, as `process-identity.ts` keeps it: the pid plus an opaque start-time
132
- // token, so a reissued pid is never mistaken for the process that wrote the record.
136
+ // token, so a reissued pid is never mistaken for the process that wrote the record — and the
137
+ // transcript of the conversation that process runs, which outlives it (`run-carry-conversation.ts`).
133
138
  interface CarryOwner {
134
139
  pid?: number | undefined
135
140
  start?: string | undefined
141
+ transcript?: string | undefined
136
142
  }
137
143
 
138
144
  // **`resume` is the only one of the four that establishes anything.** The other three are refusals,
@@ -201,6 +207,7 @@ const run_carry_schema = z.object({
201
207
  // which is the not-provably-live answer rather than a live one.
202
208
  owner_pid: z.number().optional(),
203
209
  owner_start: z.string().optional(),
210
+ owner_transcript: z.string().optional(),
204
211
  is_handed_off: z.boolean().optional(),
205
212
  done: z.array(z.number()).optional(),
206
213
  // Optional, so a record written before the retrospective existed still parses (joshuafolkken/kit#2328).
@@ -256,12 +263,14 @@ function read_carry(target: string, now: Date = new Date()): CarryRead {
256
263
 
257
264
  // The identity to record for a pid the caller declared. `read_start` is what makes the pair an
258
265
  // identity rather than a number; injected in tests so the sandbox branch is exercised without
259
- // changing the machine's process capabilities.
266
+ // changing the machine's process capabilities. The transcript is the calling session's own — the
267
+ // declared pid is that session's process (`--owner "$PPID"`).
260
268
  function owner_of(
261
269
  pid: number,
262
270
  read: (owner_pid: number) => string | undefined = process_identity.read_start,
271
+ transcript: string | undefined = run_carry_conversation.own_transcript(),
263
272
  ): CarryOwner {
264
- return { pid, start: read(pid) }
273
+ return { pid, start: read(pid), transcript }
265
274
  }
266
275
 
267
276
  function fresh_carry(invocation: string, owner: CarryOwner, now: Date): RunCarry {
@@ -275,6 +284,7 @@ function fresh_carry(invocation: string, owner: CarryOwner, now: Date): RunCarry
275
284
  outages: NO_INCREMENT,
276
285
  owner_pid: owner.pid,
277
286
  owner_start: owner.start,
287
+ owner_transcript: owner.transcript,
278
288
  }
279
289
  }
280
290
 
@@ -329,6 +339,7 @@ function adopt_carry(
329
339
  ...carry,
330
340
  owner_pid: owner.pid,
331
341
  owner_start: owner.start,
342
+ owner_transcript: owner.transcript,
332
343
  is_handed_off: false,
333
344
  }
334
345
 
@@ -340,11 +351,16 @@ function adopt_carry(
340
351
  // A live pid with no readable start token is conservatively held rather than replaced. The command
341
352
  // cannot prove its generation inside that sandbox, but a false `busy` stops for a person while a
342
353
  // false `standing` can put two parents on one budget. A record with no pid still resolves false.
343
- function is_owner_live(carry: RunCarry): boolean {
344
- return process_identity.is_same_process(carry.owner_pid, carry.owner_start) !== false
354
+ // A gone process whose conversation is still being continued is live (`run-carry-conversation.ts`).
355
+ function is_owner_live(carry: RunCarry, now: Date = new Date()): boolean {
356
+ if (process_identity.is_same_process(carry.owner_pid, carry.owner_start) !== false) return true
357
+
358
+ return run_carry_conversation.is_conversation_live(carry, now)
345
359
  }
346
360
 
347
361
  function is_owned_by(carry: RunCarry, owner: CarryOwner): boolean {
362
+ if (run_carry_conversation.is_same_conversation(carry, owner)) return true
363
+
348
364
  return carry.owner_pid === owner.pid && carry.owner_start === owner.start
349
365
  }
350
366
 
@@ -10,6 +10,7 @@ import { lane_reap } from '#scripts/lane/lane-reap'
10
10
  import { lane_registry, type LaneInfo } from '#scripts/lane/lane-registry'
11
11
  import { lane_relaunch } from '#scripts/lane/lane-relaunch'
12
12
  import { run_carry, type CarryChange, type CarryOwner, type RunCarry } from './run-carry'
13
+ import { run_carry_conversation } from './run-carry-conversation'
13
14
  import { run_cut, type RunCut } from './run-cut'
14
15
  import { run_merge } from './run-merge'
15
16
 
@@ -105,7 +106,9 @@ async function apply_carry(
105
106
  return { kind: 'refused', carry: record.carry }
106
107
  }
107
108
 
108
- return { kind: 'applied', carry: run_carry.apply_change(record.target, record.carry, change) }
109
+ const owned = run_carry_conversation.reclaim_owner(record.carry, ctx.owner)
110
+
111
+ return { kind: 'applied', carry: run_carry.apply_change(record.target, owned, change) }
109
112
  }
110
113
 
111
114
  // The carry record when this session may not act on it — the same ownership guard `apply_carry` asks —
@@ -26,12 +26,7 @@ import { test_unit_guard } from './test-unit-guard'
26
26
  function conclusive_tree(before: GateTree | undefined): GateTree | undefined {
27
27
  if (before === undefined) return undefined
28
28
 
29
- const action = test_unit_guard.resolve_guard_action(
30
- test_unit_guard.is_vitest_installed(process.cwd()),
31
- test_unit_guard.has_unit_tests(process.cwd()),
32
- )
33
-
34
- return action === 'run' ? before : undefined
29
+ return test_unit_guard.guard_action(process.cwd()) === 'run' ? before : undefined
35
30
  }
36
31
 
37
32
  async function run_related_tests(command_arguments: ReadonlyArray<string>): Promise<number> {