@joshuafolkken/kit 1.1229.0 → 1.1240.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 (67) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-lanes.md +1 -1
  2. package/.claude/skills/workflow-commands/backlogrun-progress.md +2 -2
  3. package/.claude/skills/workflow-commands/followup.md +5 -0
  4. package/.claude/skills/workflow-commands/kickoff.md +1 -0
  5. package/.claude/skills/workflow-commands/latest-gate.md +1 -1
  6. package/.github/workflows/publish.yml +2 -0
  7. package/README.md +30 -31
  8. package/dist/hooks/batch-guard.js +4 -4
  9. package/dist/hooks/{chunk-JUDKDCDP.js → chunk-4TZVB44A.js} +8 -8
  10. package/dist/hooks/{chunk-RNWHN5BP.js → chunk-F3ELQ2AF.js} +3 -3
  11. package/dist/hooks/{chunk-VC3PZPZO.js → chunk-GPTFMK5E.js} +1 -1
  12. package/dist/hooks/{chunk-FIVDHQGT.js → chunk-KHGB5S3Y.js} +1 -1
  13. package/dist/hooks/{chunk-NV2JRZZO.js → chunk-KHX4U3BG.js} +17 -2
  14. package/dist/hooks/{chunk-F56MFS4B.js → chunk-KRBERBBI.js} +3 -3
  15. package/dist/hooks/{chunk-TQNPDZNH.js → chunk-QVSWU24O.js} +4 -4
  16. package/dist/hooks/{chunk-55HHG5PX.js → chunk-WK2XSHSI.js} +284 -63
  17. package/dist/hooks/{chunk-5VMKCCC4.js → chunk-XZYKEM6J.js} +2 -2
  18. package/dist/hooks/{chunk-6HJJXRBX.js → chunk-ZQGD4GBY.js} +1 -1
  19. package/dist/hooks/codex-hook-adapter.js +10 -10
  20. package/dist/hooks/duplicate-read-guard.js +5 -5
  21. package/dist/hooks/format-edited.js +2 -2
  22. package/dist/hooks/inputs.json +172 -170
  23. package/dist/hooks/investigation-guard.js +5 -5
  24. package/dist/hooks/pretool-guard.js +9 -9
  25. package/dist/hooks/stop-guard.js +3 -17
  26. package/dist/josh.js +279 -63
  27. package/package.json +1 -1
  28. package/prompts/collaboration-workflow/issue-template.md +1 -0
  29. package/scripts/gate/core-admission.ts +162 -0
  30. package/scripts/gate/core-budget.ts +121 -67
  31. package/scripts/gate/gate-plan.ts +35 -2
  32. package/scripts/gate/gate-report.ts +28 -9
  33. package/scripts/gate/machine-capacity.ts +175 -0
  34. package/scripts/gate/verification-gate.ts +7 -2
  35. package/scripts/init/init.ts +5 -1
  36. package/scripts/init/package-manager-pin.ts +35 -0
  37. package/scripts/issue/issue-auto-ok.ts +31 -6
  38. package/scripts/issue/issue-file-cli.ts +31 -8
  39. package/scripts/issue/issue-file.ts +5 -0
  40. package/scripts/issue/issue-labels.ts +13 -0
  41. package/scripts/issue/issue-release-cli.ts +155 -0
  42. package/scripts/issue/issue-release.ts +58 -0
  43. package/scripts/josh/josh-command-types.ts +3 -0
  44. package/scripts/josh/josh-commands-development.ts +8 -0
  45. package/scripts/josh/josh-commands-issue.ts +8 -1
  46. package/scripts/josh/josh-commands-lane.ts +2 -1
  47. package/scripts/josh/josh-commands-maintenance.ts +2 -2
  48. package/scripts/josh/josh-logic.ts +4 -3
  49. package/scripts/lane/lane-cli.ts +9 -1
  50. package/scripts/lane/lane-leftover.ts +43 -29
  51. package/scripts/lane/lane-open.ts +5 -5
  52. package/scripts/lane/lane-paths.ts +13 -0
  53. package/scripts/lane/lane-registry.ts +11 -0
  54. package/scripts/lane/lane-stray.ts +140 -0
  55. package/scripts/release/github-release-environment.ts +1 -0
  56. package/scripts/release/github-release-runs.ts +115 -0
  57. package/scripts/release/github-release.ts +13 -67
  58. package/scripts/run/cut/run-cut-cli.ts +16 -3
  59. package/scripts/run/event/run-event-render.ts +1 -0
  60. package/scripts/run/event/run-event-stream.ts +4 -0
  61. package/scripts/run/run-step.ts +5 -2
  62. package/scripts/run/wake/run-wake-cli.ts +2 -3
  63. package/scripts/run/wake/run-wake-describe.ts +56 -5
  64. package/scripts/run/wake/run-wake-loop.ts +5 -13
  65. package/scripts/run/wake/run-wake.ts +15 -64
  66. package/scripts/version/latest-corepack.ts +109 -78
  67. package/templates/workflows/github-release.yml +4 -0
@@ -0,0 +1,115 @@
1
+ import { z } from 'zod'
2
+
3
+ // A job skipped by its `if:` (or neutral) belongs to a run that succeeded; counting it would skip a
4
+ // lower tag whose release is still on its way.
5
+ const FAILED_CONCLUSIONS = new Set(['failure', 'cancelled', 'timed_out', 'startup_failure'])
6
+ const RELEASE_RUN_TITLE = 'GitHub Release '
7
+ const RUN_SCHEMA = z.object({
8
+ id: z.number(),
9
+ display_title: z.string(),
10
+ status: z.string().optional(),
11
+ conclusion: z.string().nullable().optional(),
12
+ })
13
+ const JOB_SCHEMA = z.object({
14
+ name: z.string(),
15
+ status: z.string(),
16
+ conclusion: z.string().nullable(),
17
+ })
18
+ const RUNS_SCHEMA = z.object({ workflow_runs: z.array(RUN_SCHEMA) })
19
+ const JOBS_SCHEMA = z.object({ jobs: z.array(JOB_SCHEMA) })
20
+
21
+ type Run = z.infer<typeof RUN_SCHEMA>
22
+ type PollState = 'ready' | 'failed' | 'pending'
23
+
24
+ // `read` answers a GET on a repository API path. `workflow` is the publish workflow and `jobs` the
25
+ // jobs in it whose failure counts (empty: every job). `release_run_workflow` is the workflow that
26
+ // creates a tag's release — the publish workflow itself when its run creates the release.
27
+ interface RunSource {
28
+ read: (path: string) => Promise<unknown>
29
+ workflow: string | undefined
30
+ jobs: ReadonlyArray<string>
31
+ release_run_workflow: string | undefined
32
+ }
33
+
34
+ function is_failed_job(job: z.infer<typeof JOB_SCHEMA>, names: ReadonlyArray<string>): boolean {
35
+ if (job.status !== 'completed' || !FAILED_CONCLUSIONS.has(job.conclusion ?? '')) return false
36
+ if (names.length === 0) return true
37
+
38
+ return names.some((name) => job.name === name || job.name.startsWith(`${name} /`))
39
+ }
40
+
41
+ async function list_runs(source: RunSource, workflow_name: string): Promise<ReadonlyArray<Run>> {
42
+ const workflow = encodeURIComponent(workflow_name)
43
+
44
+ return RUNS_SCHEMA.parse(await source.read(`/actions/workflows/${workflow}/runs?per_page=100`))
45
+ .workflow_runs
46
+ }
47
+
48
+ async function find_run(
49
+ source: RunSource,
50
+ workflow_name: string,
51
+ title: string,
52
+ ): Promise<Run | undefined> {
53
+ const runs = await list_runs(source, workflow_name)
54
+
55
+ return runs.find((item) => item.display_title === title)
56
+ }
57
+
58
+ async function has_failed_job(source: RunSource, run_id: number): Promise<boolean> {
59
+ const { jobs } = JOBS_SCHEMA.parse(
60
+ await source.read(`/actions/runs/${String(run_id)}/jobs?per_page=100`),
61
+ )
62
+
63
+ return jobs.some((job) => is_failed_job(job, source.jobs))
64
+ }
65
+
66
+ // A run started before its title named the tag may be at work on any tag.
67
+ function may_release(run: Run, tag: string): boolean {
68
+ if (run.status === 'completed') return false
69
+
70
+ return (
71
+ run.display_title === `${RELEASE_RUN_TITLE}${tag}` ||
72
+ !run.display_title.startsWith(RELEASE_RUN_TITLE)
73
+ )
74
+ }
75
+
76
+ // Unset, nothing tells that a release is over, so the lower tag is waited for.
77
+ async function is_release_over(source: RunSource, tag: string): Promise<boolean> {
78
+ const workflow = source.release_run_workflow
79
+ if (workflow === undefined) return false
80
+ if (workflow === source.workflow) return true
81
+
82
+ const runs = await list_runs(source, workflow)
83
+
84
+ return runs.every((run) => !may_release(run, tag))
85
+ }
86
+
87
+ // A lower tag whose publication failed, or finished while no release run is left at work on it,
88
+ // never gains a release: a tag cut before joshuafolkken/kit#3138 was fixed would hold every later one.
89
+ async function is_release_lost(source: RunSource, tag: string): Promise<boolean> {
90
+ if (source.workflow === undefined) return false
91
+
92
+ const run = await find_run(source, source.workflow, `Publish ${tag}`)
93
+ if (!run) return false
94
+ if (await has_failed_job(source, run.id)) return true
95
+
96
+ return run.status === 'completed' && (await is_release_over(source, tag))
97
+ }
98
+
99
+ // A run not listed yet is pending too: the announcement that starts this release also starts the
100
+ // publication, so the release can ask before GitHub has queued that run.
101
+ async function publication_state(
102
+ source: RunSource,
103
+ workflow: string,
104
+ tag: string,
105
+ ): Promise<PollState> {
106
+ const run = await find_run(source, workflow, `Publish ${tag}`)
107
+ if (run?.status !== 'completed') return 'pending'
108
+
109
+ return run.conclusion === 'success' ? 'ready' : 'failed'
110
+ }
111
+
112
+ const github_release_runs = { is_release_lost, publication_state }
113
+
114
+ export type { PollState, RunSource }
115
+ export { github_release_runs }
@@ -1,6 +1,7 @@
1
1
  import { setTimeout } from 'node:timers/promises'
2
2
  import semver from 'semver'
3
3
  import { z } from 'zod'
4
+ import { github_release_runs, type PollState, type RunSource } from './github-release-runs'
4
5
 
5
6
  const API_ROOT = 'https://api.github.com/repos'
6
7
  const LATEST_PATH = '/releases/latest'
@@ -8,42 +9,26 @@ const REPOSITORY_PATTERN = /^[\w.-]+\/[\w.-]+$/u
8
9
  const TAG_PATTERN = /^v\d+\.\d+\.\d+(?:[.-][0-9A-Za-z.-]+)?$/u
9
10
  const REQUEST_TIMEOUT_MS = 30_000
10
11
  const NOT_FOUND_STATUS = 404
11
- // A job skipped by its `if:` (or neutral) belongs to a run that succeeded; counting it would skip a
12
- // lower tag whose release is still on its way.
13
- const FAILED_CONCLUSIONS = new Set(['failure', 'cancelled', 'timed_out', 'startup_failure'])
14
12
  const POLL_INTERVAL_MS = 30_000
15
13
  const MAX_WAIT_ATTEMPTS = 120
16
14
  const RELEASE_SCHEMA = z.object({ tag_name: z.string().min(1) })
17
15
  const NOTES_SCHEMA = z.object({ name: z.string().min(1), body: z.string().min(1) })
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
- })
24
- const JOB_SCHEMA = z.object({
25
- name: z.string(),
26
- status: z.string(),
27
- conclusion: z.string().nullable(),
28
- })
29
- const RUNS_SCHEMA = z.object({ workflow_runs: z.array(RUN_SCHEMA) })
30
- const JOBS_SCHEMA = z.object({ jobs: z.array(JOB_SCHEMA) })
31
-
32
16
  type ReleaseRequest = (url: string, init: RequestInit) => Promise<Response>
33
17
  type Wait = () => Promise<void>
34
- type PollState = 'ready' | 'failed' | 'pending'
35
18
 
36
19
  // What differs between repositories. `start_tag` is the last tag that predates automatic releases;
37
20
  // without it the latest existing release is the floor. `workflow` names the publish workflow whose
38
21
  // failed run lets a lower tag be skipped, and `jobs` the jobs in it that count (empty: every job).
39
22
  // `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.
23
+ // release started beside the publication rather than from inside it. `release_run_workflow` names
24
+ // the workflow whose run creates a tag's release, so a lower tag left without one can be told lost.
41
25
  interface ReleaseSettings {
42
26
  repository: string
43
27
  start_tag?: string | undefined
44
28
  workflow?: string | undefined
45
29
  jobs?: ReadonlyArray<string> | undefined
46
30
  await_publish?: boolean | undefined
31
+ release_run_workflow?: string | undefined
47
32
  }
48
33
 
49
34
  interface PublishOptions extends ReleaseSettings {
@@ -51,12 +36,10 @@ interface PublishOptions extends ReleaseSettings {
51
36
  wait?: Wait
52
37
  }
53
38
 
54
- interface Client {
39
+ interface Client extends Omit<RunSource, 'read'> {
55
40
  request: ReleaseRequest
56
41
  token: string
57
42
  url: string
58
- workflow: string | undefined
59
- jobs: ReadonlyArray<string>
60
43
  }
61
44
 
62
45
  // `fallback` answers when no tag sits between the floor and the target; without one the floor itself
@@ -139,59 +122,21 @@ function predecessor(
139
122
  .toSorted((left, right) => semver.compare(right, left))[0]
140
123
  }
141
124
 
142
- function is_failed_job(job: z.infer<typeof JOB_SCHEMA>, names: ReadonlyArray<string>): boolean {
143
- if (job.status !== 'completed' || !FAILED_CONCLUSIONS.has(job.conclusion ?? '')) return false
144
- if (names.length === 0) return true
145
-
146
- return names.some((name) => job.name === name || job.name.startsWith(`${name} /`))
147
- }
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
-
161
- async function failed_publication(client: Client, tag: string): Promise<boolean> {
162
- if (client.workflow === undefined) return false
163
-
164
- const run = await publish_run(client, client.workflow, tag)
165
- if (!run) return false
166
-
167
- const jobs_response = await api_get(client, `/actions/runs/${String(run.id)}/jobs?per_page=100`)
168
- const { jobs } = JOBS_SCHEMA.parse(await read_response(jobs_response))
169
-
170
- return jobs.some((job) => is_failed_job(job, client.jobs))
125
+ function run_source(client: Client): RunSource {
126
+ return { ...client, read: async (path) => await read_response(await api_get(client, path)) }
171
127
  }
172
128
 
173
129
  async function default_wait(): Promise<void> {
174
130
  await setTimeout(POLL_INTERVAL_MS)
175
131
  }
176
132
 
133
+ // The lost state is read before the release: a run completes only after creating the release, so a
134
+ // run that completes between the two reads still shows its release to the second one.
177
135
  async function release_state(client: Client, tag: string): Promise<PollState> {
136
+ const is_lost = await github_release_runs.is_release_lost(run_source(client), tag)
178
137
  if (await existing_release(client, tag)) return 'ready'
179
- if (await failed_publication(client, tag)) return 'failed'
180
-
181
- return 'pending'
182
- }
183
-
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
138
 
194
- return run.conclusion === 'success' ? 'ready' : 'failed'
139
+ return is_lost ? 'failed' : 'pending'
195
140
  }
196
141
 
197
142
  async function poll(
@@ -225,7 +170,7 @@ async function wait_for_publication(client: Client, tag: string, budget: Budget)
225
170
  if (workflow === undefined) throw new Error('Waiting for the publication needs RELEASE_WORKFLOW')
226
171
 
227
172
  return await poll(
228
- async () => await publication_state(client, workflow, tag),
173
+ async () => await github_release_runs.publication_state(run_source(client), workflow, tag),
229
174
  budget,
230
175
  `Publish ${tag}`,
231
176
  )
@@ -325,6 +270,7 @@ function create_client(request: ReleaseRequest, token: string, settings: Release
325
270
  url: `${API_ROOT}/${settings.repository}`,
326
271
  workflow: settings.workflow,
327
272
  jobs: settings.jobs ?? [],
273
+ release_run_workflow: settings.release_run_workflow,
328
274
  }
329
275
  }
330
276
 
@@ -290,14 +290,27 @@ function announce_handoff(cut_record: RunCut): void {
290
290
  }
291
291
  }
292
292
 
293
- function adopt(target: string, cut_record: RunCut): number {
293
+ // **An implementation resume appends a `resume` event** (joshuafolkken/kit#3375). It clears the record
294
+ // the `cut` event stood for, so the stream is what moves `run:step` back to implementation; a pre-gate
295
+ // resume keeps its record and appends nothing. Best-effort, as the cut's own append is.
296
+ async function emit_resume_event(issue: string, verdict: string): Promise<void> {
297
+ if (verdict !== RESUME_IMPL_VERDICT) return
298
+
299
+ await run_event_stream_emit.emit(run_event_stream.EVENT_KIND.RESUME, `#${issue} resumed`)
300
+ }
301
+
302
+ async function adopt(target: string, cut_record: RunCut): Promise<number> {
294
303
  const taken = take_over(target, cut_record)
295
304
 
296
305
  if (taken === undefined) return report_busy(cut_record)
297
306
 
298
307
  announce_handoff(cut_record)
299
308
 
300
- return report(resume_verdict_for(cut_record), SUCCESS_EXIT_CODE)
309
+ const verdict = resume_verdict_for(cut_record)
310
+
311
+ await emit_resume_event(cut_record.issue, verdict)
312
+
313
+ return report(verdict, SUCCESS_EXIT_CODE)
301
314
  }
302
315
 
303
316
  // A relaunched lane child and a fresh session both measure `under`, so only the session that took an
@@ -329,7 +342,7 @@ async function verify_and_adopt(
329
342
 
330
343
  if (is_over_threshold_resume(cut_record)) return report_over()
331
344
 
332
- return adopt(target, cut_record)
345
+ return await adopt(target, cut_record)
333
346
  }
334
347
 
335
348
  async function resume(target: string, issue: string): Promise<number> {
@@ -28,6 +28,7 @@ const KIND_LABELS: Readonly<Record<EventKind, KindLabel>> = {
28
28
  split: { ja: '分割', en: 'split' },
29
29
  outage: { ja: '障害', en: 'outage' },
30
30
  cut: { ja: 'セッション交代', en: 'session cut' },
31
+ resume: { ja: '実装を再開', en: 'implementation resumed' },
31
32
  drain: { ja: 'バックログが空', en: 'backlog drained' },
32
33
  stop: { ja: '停止', en: 'stopped' },
33
34
  'pr-opened': { ja: 'PR 作成', en: 'PR opened' },
@@ -48,6 +48,10 @@ const EVENT_KIND = {
48
48
  SPLIT: 'split',
49
49
  OUTAGE: 'outage',
50
50
  CUT: 'cut',
51
+ // A fresh session adopted an implementation cut — `run:cut --resume` answered `resume-impl`
52
+ // (joshuafolkken/kit#3375). The resume clears the cut record, so without this the `cut` stayed the
53
+ // newest position and `run:step` kept answering the resume command it had just run.
54
+ RESUME: 'resume',
51
55
  // The backlog emptied while nothing of the run's own was in flight — the drain
52
56
  // (joshuafolkken/kit#2335). It marks the position at which the end-of-run retrospective is owed,
53
57
  // *before* the idle watch opens, so `run:step` fires the retrospective at the drain rather than after
@@ -236,9 +236,12 @@ function event_action(event: string, issue_number: string, is_lane_child: boolea
236
236
  }
237
237
 
238
238
  // A run that has planned but emitted nothing else is still at its pre-implementation position, so a
239
- // `plan` reads the same as an empty stream.
239
+ // `plan` reads the same as an empty stream. A resume from an implementation cut is back at the same
240
+ // position (joshuafolkken/kit#3375): read as the `cut` it follows, it pointed at the spent resume again.
241
+ const IMPLEMENTATION_POSITIONS: ReadonlySet<string> = new Set([KIND.PLAN, KIND.RESUME])
242
+
240
243
  function is_pre_implementation(last_event: string | undefined): boolean {
241
- return last_event === undefined || last_event === run_event_stream.EVENT_KIND.PLAN
244
+ return last_event === undefined || IMPLEMENTATION_POSITIONS.has(last_event)
242
245
  }
243
246
 
244
247
  // **No cut at the setup→implementation boundary** (joshuafolkken/kit#2489). A lane child's context is
@@ -74,9 +74,8 @@ const WARNING_RECOVERY = `Check the wake command, then restart with \`pnpm josh
74
74
  //
75
75
  // **`expired` and `unreadable` were silent until joshuafolkken/kit#1746, and that was the defect
76
76
  // rather than the design.** Both end the supervisor while a carry record is still sitting there handed
77
- // off, so the run is left asleep with nothing anywhere saying so — `run-wake.ts` →
78
- // `is_predecessor_exiting` names the `expired` one as a silent overnight failure in as many words, and
79
- // then only the `failed` reason was wired to the notification.
77
+ // off, so the run is left asleep with nothing anywhere saying so — a silent overnight failure, while
78
+ // only the `failed` reason was wired to the notification.
80
79
  // **Every reason is listed, and the silent two are listed as `undefined`.** A partial map would let a
81
80
  // sixth stop reason compile straight past this and reach nobody, which is the defect class this
82
81
  // section exists to close.
@@ -1,10 +1,10 @@
1
1
  import { agent_role_profile } from '#scripts/agent/agent-role-profile'
2
2
  import { gh_spawn } from '#scripts/gh/gh-spawn'
3
3
  import { issue_citation } from '#scripts/rules/issue-citation'
4
- import { run_carry, type CarryRead } from '#scripts/run/carry/run-carry'
4
+ import { run_carry, type CarryRead, type RunCarry } from '#scripts/run/carry/run-carry'
5
5
  import { run_event_stream } from '#scripts/run/event/run-event-stream'
6
6
  import { run_liveness } from '#scripts/run/run-liveness'
7
- import { run_wake, type RunWake } from './run-wake'
7
+ import { run_wake, type RunWake, type WakeDecision, type WakeDecisionInput } from './run-wake'
8
8
 
9
9
  // The `--list` presentation of a wake record (joshuafolkken/kit#2407 split it out of `run-wake-cli.ts`,
10
10
  // which had reached its line limit). What the command *does* stays in the CLI; how a supervisor's
@@ -13,6 +13,7 @@ import { run_wake, type RunWake } from './run-wake'
13
13
  const UNKNOWN_CUTS = 'an unreadable number of'
14
14
  const REPO_LOOKUP_TIMEOUT_MS = 10_000
15
15
  const STOP_COMMAND = 'pnpm josh run:wake --stop'
16
+ const UNREADABLE_TIME = 'an unreadable time'
16
17
 
17
18
  // The three paths every verb needs: the carry record it reads, its own record, and the work tree a
18
19
  // woken session runs in. The work tree is the common git directory's parent, which is the *primary*
@@ -49,6 +50,55 @@ function outstanding_line(wake: RunWake): string | undefined {
49
50
  return `${String(wake.attempts)} launch(es) outstanding for the current cut, none claimed yet`
50
51
  }
51
52
 
53
+ // **Why the supervisor is not launching, and until when** (joshuafolkken/kit#3363). A supervisor that
54
+ // waits writes nothing to its log, so before this `--list` showed a stalled run and a waiting one
55
+ // alike. The state is `run_wake.decide`'s own answer rather than a second reading of the record: an
56
+ // `idle_since` mark is the last pass having found no work, so it is fed back in as `has_work: false`.
57
+ function wait_input(wake: RunWake, read: CarryRead, now: Date): WakeDecisionInput {
58
+ return {
59
+ read,
60
+ woke_at: wake.woke_at,
61
+ attempts: wake.attempts ?? 0,
62
+ is_owner_live: read.kind === 'carried' && run_carry.is_owner_live(read.carry, now),
63
+ has_work: wake.idle_since === undefined ? undefined : false,
64
+ idle_since: wake.idle_since,
65
+ now,
66
+ }
67
+ }
68
+
69
+ function deadline(marked_at: string | undefined, limit_ms: number): string {
70
+ const marked = Date.parse(marked_at ?? '')
71
+
72
+ return Number.isNaN(marked) ? UNREADABLE_TIME : new Date(marked + limit_ms).toISOString()
73
+ }
74
+
75
+ // A live owner that is this supervisor is its own driver at work; any other is a session spending the
76
+ // budget, which has no deadline of its own short of the carry record's whole-run expiry.
77
+ function owner_wait(wake: RunWake, carry: RunCarry): string {
78
+ if (carry.owner_pid === wake.pid) {
79
+ return 'waiting: the driver holds the carry record and is running'
80
+ }
81
+
82
+ const expiry = deadline(carry.started_at, run_carry.CARRY_MAX_AGE_MS)
83
+
84
+ 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}`
85
+ }
86
+
87
+ const WAIT_LINES: Partial<Record<WakeDecision['kind'], (wake: RunWake) => string>> = {
88
+ pending: (wake) =>
89
+ `waiting: the woken session has until ${deadline(wake.woke_at, run_wake.WAKE_GRACE_MS)} to claim the carry record`,
90
+ idle: (wake) =>
91
+ `waiting: no runnable work; the driver starts anyway at ${deadline(wake.idle_since, run_wake.IDLE_CEILING_MS)}`,
92
+ }
93
+
94
+ function wait_line(wake: RunWake, read: CarryRead, now: Date): string | undefined {
95
+ const { kind } = run_wake.decide(wait_input(wake, read, now))
96
+
97
+ if (kind === 'wait' && read.kind === 'carried') return owner_wait(wake, read.carry)
98
+
99
+ return WAIT_LINES[kind]?.(wake)
100
+ }
101
+
52
102
  // The run's stream, read here because a headless parent's progress reaches its own transcript alone —
53
103
  // after a cut, `--list` is the person's one window onto it (joshuafolkken/kit#1910). Two lines: the
54
104
  // newest event (the degenerate last-event read of the stream the watch pane follows, so `--list` and
@@ -73,16 +123,17 @@ function stream_lines(context: WakeContext): Array<string> {
73
123
  // **The outstanding line beside it is what makes a shortfall readable**: the count is observed at a
74
124
  // poll, so it lags a claim by up to one interval, and launches still outstanding are what say whether
75
125
  // a missing wake is one not yet seen or one that never arrived.
76
- function describe_wake(wake: RunWake, context: WakeContext): string {
126
+ function describe_wake(wake: RunWake, context: WakeContext, now: Date = new Date()): string {
77
127
  const live = run_wake.is_supervisor_live(wake) ? 'running' : 'not running'
78
- const cuts = carry_cuts(run_carry.read_carry(context.carry_target))
128
+ const read = run_carry.read_carry(context.carry_target)
79
129
 
80
130
  return [
81
131
  `invocation: ${wake.invocation}`,
82
132
  `profile: ${wake.profile === undefined ? 'unrecorded' : agent_role_profile.describe(wake.profile)}`,
83
133
  `supervisor: process ${String(wake.pid)} (${live}), watching since ${wake.started_at}`,
84
- `woke ${String(wake.woke)} session(s) across ${cuts} cut(s)`,
134
+ `woke ${String(wake.woke)} session(s) across ${carry_cuts(read)} cut(s)`,
85
135
  outstanding_line(wake),
136
+ wait_line(wake, read, now),
86
137
  run_liveness.describe_agent_state(context.log_target),
87
138
  `output: ${context.log_target}`,
88
139
  // How progress is seen without asking after the cut: the run's stream, followed from here
@@ -35,9 +35,8 @@ interface LoopPorts {
35
35
  // The carry record, re-read every pass rather than cached: it is written by the parent loop of a
36
36
  // different session, which is the whole point of reading it here.
37
37
  read_carry: () => CarryRead
38
- // Whether the process named on the carry record is still running. The cutting session writes
39
- // `--cut` and then exits, so the two moments are not the same one and the supervisor has to wait
40
- // out the second — `run-wake.ts` → `WakeDecisionInput.is_owner_live`.
38
+ // Whether the process named on the carry record is still running, which tells a session at work
39
+ // from a crashed one — `run-wake.ts` → `WakeDecisionInput.is_owner_live`.
41
40
  is_owner_live: (read: CarryRead) => boolean
42
41
  // A compatibility probe for the wake decision. Production lets the resident driver inspect work,
43
42
  // including an empty idle watch, without starting an AI session.
@@ -110,17 +109,11 @@ async function wake_step(wake: RunWake, ports: LoopPorts): Promise<StepOutcome>
110
109
  // A `wait` means the woken session has claimed the carry record, so the wake mark is cleared and the
111
110
  // next cut starts the grace window afresh — **and it is the only pass on which `woke` may grow**
112
111
  // (joshuafolkken/kit#1746), because arriving here is the supervisor observing that a record it was
113
- // waiting on has been taken over. A `hold` starts the ceiling on a wait without launching
114
- // anything — the bounded wait on a predecessor that has cut but not yet exited, which ends the moment
115
- // that predecessor does. A `pending` keeps what is there, because the window it is measured from is
116
- // still open.
112
+ // waiting on has been taken over. A `pending` keeps what is there, because the window it is measured
113
+ // from is still open.
117
114
  function continue_step(wake: RunWake, decision: WakeDecision, ports: LoopPorts): StepOutcome {
118
115
  if (decision.kind === 'wait') return { kind: 'continue', wake: run_wake.count_claim(wake) }
119
116
 
120
- if (decision.kind === 'hold') {
121
- return { kind: 'continue', wake: run_wake.mark_wait(wake, ports.now()) }
122
- }
123
-
124
117
  if (decision.kind === 'idle') {
125
118
  return { kind: 'continue', wake: run_wake.mark_idle(wake, ports.now()) }
126
119
  }
@@ -146,7 +139,6 @@ function decision_input(wake: RunWake, ports: LoopPorts): WakeDecisionInput {
146
139
  woke_at: wake.woke_at,
147
140
  attempts: wake.attempts ?? NO_ATTEMPTS,
148
141
  is_owner_live: ports.is_owner_live(read),
149
- held_at: wake.held_at,
150
142
  has_work: undefined,
151
143
  idle_since: wake.idle_since,
152
144
  now: ports.now(),
@@ -155,7 +147,7 @@ function decision_input(wake: RunWake, ports: LoopPorts): WakeDecisionInput {
155
147
 
156
148
  // **Work is asked about only where the answer could change the decision** (joshuafolkken/kit#2417):
157
149
  // on a pass the record alone would launch from. Every other pass — the in-flight wait, the pending
158
- // grace window, the hold — decides from the record as before and costs no backlog read.
150
+ // grace window — decides from the record as before and costs no backlog read.
159
151
  async function decide_pass(wake: RunWake, ports: LoopPorts): Promise<WakeDecision> {
160
152
  const input = decision_input(wake, ports)
161
153
  const decision = run_wake.decide(input)