@joshuafolkken/kit 1.1128.0 → 1.1166.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 (192) hide show
  1. package/.claude/agents/investigator.md +17 -0
  2. package/.claude/settings.json +5 -4
  3. package/.claude/skills/epic-commands/SKILL.md +27 -239
  4. package/.claude/skills/epic-commands/epic-bundle.md +115 -0
  5. package/.claude/skills/epic-commands/execution-waves.md +126 -0
  6. package/.claude/skills/workflow-commands/SKILL.md +48 -194
  7. package/.claude/skills/workflow-commands/background-commands.md +1 -1
  8. package/.claude/skills/workflow-commands/backlogrun-child.md +12 -123
  9. package/.claude/skills/workflow-commands/backlogrun-lanes.md +11 -60
  10. package/.claude/skills/workflow-commands/backlogrun-park.md +10 -8
  11. package/.claude/skills/workflow-commands/backlogrun-progress.md +19 -175
  12. package/.claude/skills/workflow-commands/backlogrun-recovery.md +155 -0
  13. package/.claude/skills/workflow-commands/backlogrun-steps.md +64 -144
  14. package/.claude/skills/workflow-commands/backlogrun.md +5 -4
  15. package/.claude/skills/workflow-commands/chain-rule.md +3 -2
  16. package/.claude/skills/workflow-commands/delegation.md +7 -3
  17. package/.claude/skills/workflow-commands/entry-sequence.md +62 -0
  18. package/.claude/skills/workflow-commands/followup.md +104 -181
  19. package/.claude/skills/workflow-commands/fullrun-steps.md +8 -9
  20. package/.claude/skills/workflow-commands/fullrun.md +16 -73
  21. package/.claude/skills/workflow-commands/halfrun.md +10 -36
  22. package/.claude/skills/workflow-commands/into-target.md +4 -4
  23. package/.claude/skills/workflow-commands/issue-comments.md +7 -8
  24. package/.claude/skills/workflow-commands/issue-scout.md +6 -6
  25. package/.claude/skills/workflow-commands/kickoff.md +6 -6
  26. package/.claude/skills/workflow-commands/needs-human-review.md +6 -6
  27. package/.claude/skills/workflow-commands/observation-filing.md +21 -172
  28. package/.claude/skills/workflow-commands/observation-ledger.md +150 -0
  29. package/.claude/skills/workflow-commands/pre-gate-cut.md +33 -93
  30. package/.claude/skills/workflow-commands/prerequisite.md +6 -6
  31. package/.claude/skills/workflow-commands/progress-watcher.md +90 -0
  32. package/.claude/skills/workflow-commands/prrun.md +5 -5
  33. package/.claude/skills/workflow-commands/retrospective.md +9 -9
  34. package/.claude/skills/workflow-commands/split-assessment.md +2 -2
  35. package/.claude/skills/workflow-commands/target-repository.md +2 -2
  36. package/.claude/skills/workflow-commands/working-tree-hold.md +7 -5
  37. package/.codex/hooks.json +4 -4
  38. package/AGENTS.md +1 -6
  39. package/CLAUDE.md +58 -120
  40. package/GEMINI.md +1 -6
  41. package/SECURITY.md +1 -0
  42. package/dist/CLAUDE.md +58 -120
  43. package/dist/hooks/batch-guard.js +4 -4
  44. package/dist/hooks/{chunk-MKVGBQPA.js → chunk-5KTCMCOT.js} +14 -1
  45. package/dist/hooks/{chunk-NXYMNPUQ.js → chunk-A6P7PN7P.js} +59 -21
  46. package/dist/hooks/{chunk-77J7IEQM.js → chunk-ANJ2WGFD.js} +1 -1
  47. package/dist/hooks/{chunk-4JZDP5X5.js → chunk-DYAVAM6S.js} +572 -127
  48. package/dist/hooks/{chunk-7HVB7SZG.js → chunk-ENA7P5FB.js} +4 -4
  49. package/dist/hooks/{chunk-M7QKEKWO.js → chunk-GU5MATVE.js} +214 -85
  50. package/dist/hooks/chunk-GYNGP7HI.js +56 -0
  51. package/dist/hooks/{chunk-5SD2YM7Z.js → chunk-URJA6XY4.js} +4 -4
  52. package/dist/hooks/{chunk-XYUIQOW3.js → chunk-V2BTOZHT.js} +6 -4
  53. package/dist/hooks/{chunk-AIOIOMPX.js → chunk-ZDZ555YH.js} +225 -78
  54. package/dist/hooks/codex-hook-adapter.js +10 -10
  55. package/dist/hooks/duplicate-read-guard.js +5 -5
  56. package/dist/hooks/format-edited.js +2 -2
  57. package/dist/hooks/inputs.json +186 -184
  58. package/dist/hooks/investigation-guard.js +5 -5
  59. package/dist/hooks/pretool-guard.js +11 -10
  60. package/dist/hooks/stop-guard.js +5 -5
  61. package/dist/josh.js +33 -3
  62. package/dist/version/index.js +5 -1
  63. package/package.json +7 -4
  64. package/prompts/collaboration-workflow/file-edits.md +11 -10
  65. package/prompts/collaboration-workflow/gh-rest.md +2 -4
  66. package/prompts/collaboration-workflow/glossary.md +1 -1
  67. package/prompts/collaboration-workflow/issue-citation.md +5 -9
  68. package/prompts/collaboration-workflow/issue-template.md +40 -115
  69. package/prompts/collaboration-workflow/operating-rules.md +4 -5
  70. package/prompts/collaboration-workflow/output-bounds.md +12 -78
  71. package/prompts/collaboration-workflow/overview.md +7 -13
  72. package/prompts/collaboration-workflow/plan-comment.md +11 -39
  73. package/prompts/collaboration-workflow/principles.md +3 -4
  74. package/prompts/collaboration-workflow/report-format.md +28 -74
  75. package/prompts/collaboration-workflow/residency.md +12 -109
  76. package/prompts/collaboration-workflow/rule-delivery.md +98 -97
  77. package/prompts/collaboration-workflow/shell-body.md +3 -13
  78. package/prompts/collaboration-workflow/turn-batching.md +14 -88
  79. package/prompts/collaboration-workflow/upstream-interrupt.md +12 -14
  80. package/prompts/collaboration-workflow/wip-cap.md +6 -45
  81. package/prompts/collaboration-workflow.md +1 -13
  82. package/prompts/review-rubric.md +8 -71
  83. package/prompts/review.md +25 -55
  84. package/prompts/testing-guide.md +18 -67
  85. package/scripts/agent/agent-role-profile.ts +1 -1
  86. package/scripts/agent/codex-hooks.ts +3 -12
  87. package/scripts/backlog/backlog-budget.ts +1 -1
  88. package/scripts/backlog/backlog-drive-cli.ts +4 -3
  89. package/scripts/backlog/backlog-drive-offer-argv.ts +10 -1
  90. package/scripts/backlog/backlog-offer.ts +6 -6
  91. package/scripts/behavior/behavior-rules.ts +11 -4
  92. package/scripts/delegation/delegation-policy.ts +9 -1
  93. package/scripts/delegation/investigation-guard.ts +1 -1
  94. package/scripts/delegation/investigation-reads.ts +109 -23
  95. package/scripts/discovery/repo-discovery.ts +21 -7
  96. package/scripts/discovery/repo-origin.ts +26 -0
  97. package/scripts/discovery/repo-party.ts +42 -1
  98. package/scripts/document/backlogrun-parent-read-set.ts +14 -15
  99. package/scripts/document/capped-print.ts +74 -0
  100. package/scripts/document/document-byte-budget.ts +34 -20
  101. package/scripts/document/document-section-cli.ts +4 -3
  102. package/scripts/document/entry-read-budget.ts +21 -9
  103. package/scripts/document/lane-child-read-set.ts +12 -14
  104. package/scripts/document/read-set-cli.ts +4 -4
  105. package/scripts/document/read-set-point-of-use.ts +10 -2
  106. package/scripts/document/resident-budget.ts +47 -0
  107. package/scripts/epic/epic-shape.ts +10 -13
  108. package/scripts/epic/epic-solo.ts +1 -1
  109. package/scripts/exports/unused-members-cli.ts +7 -3
  110. package/scripts/gate/scoped-green.ts +25 -14
  111. package/scripts/gh/git-gh-api-path.ts +7 -0
  112. package/scripts/hooks/pretool-guard-cli.ts +2 -2
  113. package/scripts/hooks/pretool-guard.ts +45 -2
  114. package/scripts/hooks/run-hook.sh +41 -0
  115. package/scripts/init/claude-kit-only-settings.ts +29 -0
  116. package/scripts/init/hook-command-rewrite.ts +18 -35
  117. package/scripts/init/hook-launch.ts +21 -32
  118. package/scripts/init/init-copy-content.ts +8 -2
  119. package/scripts/init/initial-commit.ts +24 -0
  120. package/scripts/init/start-exec.ts +16 -3
  121. package/scripts/init/start-steps.ts +8 -12
  122. package/scripts/issue/issue-auto-ok.ts +95 -0
  123. package/scripts/issue/issue-file-cli.ts +53 -7
  124. package/scripts/issue/issue-file.ts +28 -5
  125. package/scripts/issue/issue-labels.ts +12 -12
  126. package/scripts/issue/issue-rank.ts +1 -1
  127. package/scripts/issue/issue-read-cli.ts +4 -3
  128. package/scripts/issue/issue-read.ts +2 -2
  129. package/scripts/issue/issue-report-failures.ts +6 -2
  130. package/scripts/issue/issue-scout-cli.ts +2 -2
  131. package/scripts/issue/issue-state-cli.ts +2 -2
  132. package/scripts/issue/issue-state.ts +2 -2
  133. package/scripts/issue/issue-wip.ts +70 -0
  134. package/scripts/josh/hook-decision.ts +17 -0
  135. package/scripts/josh/josh-commands-development.ts +10 -0
  136. package/scripts/josh/josh-commands-issue.ts +2 -2
  137. package/scripts/josh/josh-commands-lane.ts +1 -1
  138. package/scripts/josh/josh-commands-project.ts +7 -0
  139. package/scripts/lane/lane-await.ts +46 -2
  140. package/scripts/lane/lane-cli.ts +11 -13
  141. package/scripts/lines/bytes-command.ts +29 -6
  142. package/scripts/observations/observation-ledger-line.ts +1 -1
  143. package/scripts/release/github-release-environment.ts +1 -0
  144. package/scripts/release/github-release.ts +103 -17
  145. package/scripts/release/release-progress.ts +167 -0
  146. package/scripts/release/release-publish.ts +19 -8
  147. package/scripts/release/release-tag.ts +22 -8
  148. package/scripts/rules/decision-oracle-stage.ts +1 -1
  149. package/scripts/rules/decision-oracle.ts +6 -6
  150. package/scripts/rules/delivered-rules.ts +11 -11
  151. package/scripts/rules/direct-pr-create.ts +110 -0
  152. package/scripts/rules/file-body.ts +98 -42
  153. package/scripts/rules/filing-cap.ts +2 -2
  154. package/scripts/rules/filing-offer.ts +1 -1
  155. package/scripts/rules/gh-api.ts +18 -1
  156. package/scripts/rules/implementation-cut.ts +3 -1
  157. package/scripts/rules/lane-background.ts +67 -10
  158. package/scripts/rules/permission-guards.ts +10 -2
  159. package/scripts/rules/stop-rules.ts +4 -4
  160. package/scripts/rules/test-declared-commit.ts +1 -1
  161. package/scripts/rules/third-party-write.ts +56 -12
  162. package/scripts/run/run-carry-cli.ts +3 -1
  163. package/scripts/run/run-carry-conversation.ts +94 -0
  164. package/scripts/run/run-carry-reclaim.ts +37 -0
  165. package/scripts/run/run-carry.ts +21 -5
  166. package/scripts/run/run-cut-handoff.ts +21 -1
  167. package/scripts/run/run-entry-cli.ts +11 -3
  168. package/scripts/run/run-hold-cli.ts +16 -2
  169. package/scripts/run/run-label.ts +50 -0
  170. package/scripts/run/run-merge-steps.ts +12 -4
  171. package/scripts/run/run-merge.ts +1 -1
  172. package/scripts/run/run-prep-cli.ts +6 -2
  173. package/scripts/run/run-prep.ts +17 -0
  174. package/scripts/run/run-ship-preflight.ts +34 -3
  175. package/scripts/run/run-ship-review-steps.ts +1 -1
  176. package/scripts/run/run-ship-review.ts +26 -5
  177. package/scripts/run/run-step-cli.ts +20 -7
  178. package/scripts/run/run-wake-driver.ts +24 -1
  179. package/scripts/run/run-wake-session.ts +2 -1
  180. package/scripts/test/test-declared-logic.ts +1 -1
  181. package/scripts/test/test-related.ts +1 -6
  182. package/scripts/test/test-unit-guard.ts +21 -7
  183. package/scripts/time-runtime/time-batch-guard.ts +52 -16
  184. package/scripts/time-runtime/time-density-hook.ts +20 -2
  185. package/scripts/time-runtime/time-density.ts +4 -3
  186. package/scripts/time-runtime/time-markers.ts +15 -4
  187. package/scripts/time-runtime/time-spans.ts +4 -0
  188. package/scripts/version/npm-registry.ts +11 -1
  189. package/templates/workflows/ci.yml +9 -1
  190. package/templates/workflows/github-release.yml +13 -25
  191. package/.claude/skills/workflow-commands/followup-reference.md +0 -118
  192. package/dist/hooks/chunk-QVLT52OV.js +0 -464
@@ -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
 
@@ -41,6 +41,22 @@ const UNTOUCHED_LABEL = 'Deliberately untouched'
41
41
  const INSTRUCTION_LABEL = 'Instruction (verbatim)'
42
42
  const EMPTY_LIST_NOTE = '(none)'
43
43
 
44
+ type HandoffKey = keyof Handoff
45
+
46
+ function describe_key(key: HandoffKey): string {
47
+ const value = handoff_schema.shape[key] instanceof z.ZodArray ? '["…"]' : '"…"'
48
+
49
+ return `"${key}": ${value}`
50
+ }
51
+
52
+ // The shape a handoff file must have, derived from `handoff_schema` so the refusal that asks for the file
53
+ // and the error that rejects it can never name a key the parser does not read (joshuafolkken/kit#3195).
54
+ // A refusal that listed the four items in prose was answered with Markdown, which `parse_handoff` rejects.
55
+ const HANDOFF_FORMAT = `a JSON object {${handoff_schema
56
+ .keyof()
57
+ .options.map((key) => describe_key(key))
58
+ .join(', ')}}`
59
+
44
60
  function parse_handoff(raw: string): Handoff | undefined {
45
61
  try {
46
62
  const parsed = handoff_schema.safeParse(JSON.parse(raw))
@@ -84,7 +100,10 @@ function load_handoff(handoff_path: string | undefined, max_bytes: number): Hand
84
100
  const handoff = read_handoff_file(handoff_path)
85
101
 
86
102
  if (handoff === undefined) {
87
- return { kind: 'bad', note: `--handoff ${handoff_path} is not a readable handoff file` }
103
+ return {
104
+ kind: 'bad',
105
+ note: `--handoff ${handoff_path} is not a readable handoff file — write ${HANDOFF_FORMAT}`,
106
+ }
88
107
  }
89
108
 
90
109
  if (!is_within_bound(handoff, max_bytes)) {
@@ -114,6 +133,7 @@ function describe_handoff(handoff: Handoff): string {
114
133
  }
115
134
 
116
135
  const run_cut_handoff = {
136
+ HANDOFF_FORMAT,
117
137
  describe_handoff,
118
138
  handoff_schema,
119
139
  is_complete_handoff,
@@ -7,6 +7,7 @@ import { run_entry, type EntryParts } from './run-entry'
7
7
  import { run_entry_stop } from './run-entry-stop'
8
8
  import { run_halfrun_resume } from './run-halfrun-resume'
9
9
  import { run_hold_cli } from './run-hold-cli'
10
+ import { run_label } from './run-label'
10
11
  import { run_next } from './run-next'
11
12
  import { run_prep } from './run-prep'
12
13
  import { run_prep_cli } from './run-prep-cli'
@@ -231,6 +232,15 @@ function is_settled(decision: StageDecision): boolean {
231
232
  return decision.is_reached && decision.state !== run_stage.MERGED
232
233
  }
233
234
 
235
+ // The tree is held and the budget allows the run, so the issue is marked as running before its reads
236
+ // (joshuafolkken/kit#3182) — the label then shows in the `labels:` line the report below carries.
237
+ async function proceed(issue_number: string, hold: string, cost: string): Promise<number> {
238
+ await run_label.mark(issue_number)
239
+ const reads = await gather_reads(issue_number)
240
+
241
+ return emit({ issue_number, hold, cost, verdict: reads.verdict, report: reads.report })
242
+ }
243
+
234
244
  async function claim_run({ issue_number, command }: EntryRequest): Promise<number> {
235
245
  const hold = await claim_hold(issue_number, command)
236
246
 
@@ -248,9 +258,7 @@ async function claim_run({ issue_number, command }: EntryRequest): Promise<numbe
248
258
  return await stop_run({ parts, command, reason: BUDGET_REASON, should_release: true })
249
259
  }
250
260
 
251
- const reads = await gather_reads(issue_number)
252
-
253
- return emit({ issue_number, hold, cost, verdict: reads.verdict, report: reads.report })
261
+ return await proceed(issue_number, hold, cost)
254
262
  }
255
263
 
256
264
  // A carried cut is asked first, before anything is read (`run:cut --resume`); `kickoff` claims and cuts
@@ -2,6 +2,7 @@
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { run_halfrun_resume } from './run-halfrun-resume'
4
4
  import { run_hold, type HoldRead, type RunHold } from './run-hold'
5
+ import { run_label } from './run-label'
5
6
  import { run_preflight, type PreflightDecision } from './run-preflight'
6
7
  import { run_prrun_resume } from './run-prrun-resume'
7
8
  import { run_tidy_cli } from './run-tidy-cli'
@@ -267,7 +268,7 @@ function report_unreadable_release(): number {
267
268
  // Until then this removed whatever was there, and the `busy` stop message sent a person here to clear
268
269
  // a record they had judged stale from outside the run that wrote it — so the guard's own recovery
269
270
  // instruction was a way to free a live run's tree, which is the incident it was written after.
270
- function release(target: string, claimant: string): number {
271
+ function release_record(target: string, claimant: string): number {
271
272
  const read = run_hold.read_hold(target)
272
273
 
273
274
  if (read.kind === 'free') return remove_record(target, false)
@@ -279,6 +280,19 @@ function release(target: string, claimant: string): number {
279
280
  return remove_record(target, true)
280
281
  }
281
282
 
283
+ // **A numbered run's own release takes its `in-progress` marker back off** (joshuafolkken/kit#3182),
284
+ // whether or not a record was still there — the stop that releases is the run ending its hold on the
285
+ // issue. A refused release (another run's record, an unreadable one) touches no label, and an
286
+ // unnumbered run names no issue to touch.
287
+ async function release(target: string, claimant: string): Promise<number> {
288
+ const code = release_record(target, claimant)
289
+ const is_own_end = code === SUCCESS_EXIT_CODE && claimant !== run_hold.UNNUMBERED_ISSUE
290
+
291
+ if (is_own_end) await run_label.unmark(claimant)
292
+
293
+ return code
294
+ }
295
+
282
296
  // The one path that removes a record without matching it, which is why it says what it removed: a
283
297
  // record genuinely abandoned by a crashed session has no run left to release it, and the eight-hour
284
298
  // expiry alone leaves a dirty tree held until a person acts.
@@ -335,7 +349,7 @@ async function dispatch(request: HoldRequest, target: string, is_linked: boolean
335
349
 
336
350
  if (request.kind === FORCE_RELEASE_KIND) return force_release(target)
337
351
 
338
- return release(target, request.claimant)
352
+ return await release(target, request.claimant)
339
353
  }
340
354
 
341
355
  async function answer(request: HoldRequest): Promise<number> {
@@ -0,0 +1,50 @@
1
+ import { git_followup_label } from '#scripts/followup/git-followup-label'
2
+ import { git_gh_command } from '#scripts/gh/git-gh-command'
3
+ import { IN_PROGRESS_LABEL } from '#scripts/issue/issue-labels'
4
+
5
+ // **The `in-progress` marker belongs to the commands that open and release a run**
6
+ // (joshuafolkken/kit#3182). Every manifest used to spell the label's create, apply and removal as raw
7
+ // `gh api` calls, so each copy was a place the marker could be forgotten; `run:entry` applies it the
8
+ // moment the tree is held and the budget allows the run, and `run:release` takes it back off.
9
+ //
10
+ // **Neither half ever fails the command it rides on.** The hold is the guard and the label only
11
+ // reports it, so a refused write is warned about by name — with the call that finishes it by hand —
12
+ // and the run's own verdict stands.
13
+
14
+ const MARK_RECOVERY = "gh api repos/{owner}/{repo}/issues/<N>/labels -f 'labels[]=in-progress'"
15
+ const UNMARK_RECOVERY = 'gh api -X DELETE repos/{owner}/{repo}/issues/<N>/labels/in-progress'
16
+
17
+ function warn(action: string, issue: string, recovery: string): void {
18
+ console.warn(`⚠ could not ${action} \`in-progress\` on #${issue} — run by hand: ${recovery}`)
19
+ }
20
+
21
+ // `issue_add_label` creates the label when the repository lacks it (`lane-dispatch.ts`), so no
22
+ // separate provisioning call is made here.
23
+ async function mark(issue: string): Promise<boolean> {
24
+ const is_marked = await git_gh_command.issue_add_label(issue, IN_PROGRESS_LABEL)
25
+
26
+ if (!is_marked) warn('apply', issue, MARK_RECOVERY)
27
+
28
+ return is_marked
29
+ }
30
+
31
+ // The removal is `followup`'s own — read first, removed only when present, under the stored
32
+ // casing — so an issue that never carried the label is never written to.
33
+ async function unmark(issue: string): Promise<boolean> {
34
+ try {
35
+ await git_followup_label.strip_in_progress(issue)
36
+
37
+ return true
38
+ } catch {
39
+ warn('remove', issue, UNMARK_RECOVERY)
40
+
41
+ return false
42
+ }
43
+ }
44
+
45
+ const run_label = {
46
+ mark,
47
+ unmark,
48
+ }
49
+
50
+ export { run_label }
@@ -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
 
@@ -47,7 +48,10 @@ interface MergeContext {
47
48
  child: string
48
49
  epic: string | undefined
49
50
  repo: string | undefined
50
- over: number
51
+ // The hand-off threshold, or `undefined` where no AI session owns the loop — the supervisor's
52
+ // `backlog:drive` has no context to cut, so a merge there is never handed back as `over`
53
+ // (joshuafolkken/kit#3156).
54
+ over: number | undefined
51
55
  owner: CarryOwner
52
56
  // The child's transcript output path, when the caller passed `--output`. Read to tell an API-outage
53
57
  // ending apart from a genuine child failure (joshuafolkken/kit#2240); absent leaves outage detection
@@ -105,7 +109,9 @@ async function apply_carry(
105
109
  return { kind: 'refused', carry: record.carry }
106
110
  }
107
111
 
108
- return { kind: 'applied', carry: run_carry.apply_change(record.target, record.carry, change) }
112
+ const owned = run_carry_conversation.reclaim_owner(record.carry, ctx.owner)
113
+
114
+ return { kind: 'applied', carry: run_carry.apply_change(record.target, owned, change) }
109
115
  }
110
116
 
111
117
  // The carry record when this session may not act on it — the same ownership guard `apply_carry` asks —
@@ -324,8 +330,10 @@ async function resume_cut(child: string): Promise<boolean> {
324
330
  }
325
331
 
326
332
  // The hand-off check, asked at a merge alone. A subprocess that cannot measure exits non-zero, which
327
- // is read as `over` — "could not measure" is never "still cheap".
328
- async function is_over_budget(over: number): Promise<boolean> {
333
+ // is read as `over` — "could not measure" is never "still cheap". No threshold is no session to measure.
334
+ async function is_over_budget(over: number | undefined): Promise<boolean> {
335
+ if (over === undefined) return false
336
+
329
337
  const result = await josh(['cost', '--over', String(over)])
330
338
 
331
339
  return result.code !== 0 || result.out === OVER
@@ -35,7 +35,7 @@ const ONE = 1
35
35
  // - `merged` — CLOSED; the child finished and its pull request merged. Also an OPEN, unparked
36
36
  // child a merged pull request's `closes #N` names — GitHub merged it without closing
37
37
  // the issue (joshuafolkken/kit#2769).
38
- // - `human-review` — OPEN and carrying `needs-human-review`; the run's own ending (SKILL.md → §2z).
38
+ // - `human-review` — OPEN and carrying `needs-human-review`; the run's own ending (needs-human-review.md).
39
39
  // - `parked` — OPEN and carrying `needs-decision` or `already-done`; a person still owns it.
40
40
  // - `split` — OPEN and carrying `epic`; its work was divided into new children.
41
41
  // - `outage` — OPEN and carrying neither, but the exit record shows it could not reach the API; not
@@ -8,6 +8,7 @@ import { error_text } from '#scripts/lib/error-message'
8
8
  import { latest_scope_cli } from '#scripts/version/latest-scope-cli'
9
9
  import { run_prep, type PrepParts } from './run-prep'
10
10
  import { run_prep_locate } from './run-prep-locate'
11
+ import { run_ship_preflight } from './run-ship-preflight'
11
12
 
12
13
  // `josh run:prep <N>` — one call for the reads a `fullrun` makes before its first edit
13
14
  // (joshuafolkken/kit#1978): the issue body and comments (`issue:read`), the state, labels and
@@ -41,6 +42,7 @@ interface PrepReads {
41
42
  latest: LatestDecision
42
43
  has_changes: boolean
43
44
  locations: string
45
+ ship_problems: ReadonlyArray<string>
44
46
  }
45
47
 
46
48
  // Exactly one issue number, or the call is refused: `run:prep` prepares one run, and a second number
@@ -91,15 +93,16 @@ async function lane_has_changes(): Promise<boolean> {
91
93
  }
92
94
 
93
95
  async function gather(issue_number: string): Promise<PrepReads> {
94
- const [content, state, has_changes] = await Promise.all([
96
+ const [content, state, has_changes, ship_problems] = await Promise.all([
95
97
  issue_read_cli.read_block(issue_number),
96
98
  issue_state_cli.read_issue(issue_number),
97
99
  lane_has_changes(),
100
+ run_ship_preflight.ahead(issue_number),
98
101
  ])
99
102
  // Located from the body just read, so it waits for that read rather than joining the batch above.
100
103
  const locations = await run_prep_locate.locate(content.kind === 'ok' ? content.block : '')
101
104
 
102
- return { content, state, latest: latest_decision(), has_changes, locations }
105
+ return { content, state, latest: latest_decision(), has_changes, locations, ship_problems }
103
106
  }
104
107
 
105
108
  function content_body(issue_number: string, content: BlockRead): string {
@@ -119,6 +122,7 @@ function to_parts(issue_number: string, reads: PrepReads): PrepParts {
119
122
  latest_reason: reads.latest.reason,
120
123
  has_changes: reads.has_changes,
121
124
  locations: reads.locations,
125
+ ship_problems: reads.ship_problems,
122
126
  }
123
127
  }
124
128
 
@@ -24,6 +24,10 @@ const CONTENT_HEADER = '=== issue ==='
24
24
  const STATE_HEADER = '=== state ==='
25
25
  const LATEST_HEADER = '=== dependency update ==='
26
26
  const LOCATIONS_HEADER = '=== code locations ==='
27
+ const SHIP_HEADER = '=== ship preconditions ==='
28
+ const SHIP_MET = 'met — nothing `josh ship` refuses on as the tree stands'
29
+ const SHIP_UNMET =
30
+ 'unmet — `josh ship` will refuse until these are fixed; fix them before the gate:'
27
31
  const SECTION_SEPARATOR = '\n\n'
28
32
  const LATEST_JOINER = ' — '
29
33
 
@@ -41,6 +45,9 @@ interface PrepParts {
41
45
  // Whether a lane child's tree holds uncommitted work — read for the verdict, not printed in the
42
46
  // report (joshuafolkken/kit#2476).
43
47
  has_changes: boolean
48
+ // What `josh ship` would refuse on as the tree stands — `run_ship_preflight.ahead`
49
+ // (joshuafolkken/kit#3154).
50
+ ship_problems: ReadonlyArray<string>
44
51
  }
45
52
 
46
53
  function human_review_value(state: IssueState | undefined): string {
@@ -72,6 +79,12 @@ function section(header: string, body: string): string {
72
79
  return `${header}\n${body}`
73
80
  }
74
81
 
82
+ function ship_body(problems: ReadonlyArray<string>): string {
83
+ if (problems.length === 0) return SHIP_MET
84
+
85
+ return [SHIP_UNMET, ...problems.map((problem) => ` - ${problem}`)].join('\n')
86
+ }
87
+
75
88
  function format_report(parts: PrepParts): string {
76
89
  return [
77
90
  summary(parts),
@@ -79,6 +92,7 @@ function format_report(parts: PrepParts): string {
79
92
  section(STATE_HEADER, state_body(parts)),
80
93
  section(LATEST_HEADER, `${parts.latest_scope}${LATEST_JOINER}${parts.latest_reason}`),
81
94
  section(LOCATIONS_HEADER, parts.locations),
95
+ section(SHIP_HEADER, ship_body(parts.ship_problems)),
82
96
  ].join(SECTION_SEPARATOR)
83
97
  }
84
98
 
@@ -86,9 +100,12 @@ const run_prep = {
86
100
  CONTENT_HEADER,
87
101
  LATEST_HEADER,
88
102
  LOCATIONS_HEADER,
103
+ SHIP_HEADER,
104
+ SHIP_UNMET,
89
105
  STATE_HEADER,
90
106
  SUMMARY_PREFIX,
91
107
  format_report,
108
+ ship_body,
92
109
  }
93
110
 
94
111
  export type { PrepParts }
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { git_gh_command } from '#scripts/gh/git-gh-command'
3
+ import { git_pr } from '#scripts/gh/git-pr'
3
4
  import { changed_paths } from '#scripts/git/changed-paths'
4
5
  import { git_branch } from '#scripts/git/git-branch'
5
6
  import { git_preflight } from '#scripts/git/git-preflight'
@@ -22,6 +23,7 @@ import { run_ship_scoped } from './run-ship-scoped'
22
23
  const SUCCESS_EXIT_CODE = 0
23
24
  const FAILURE_EXIT_CODE = 1
24
25
  const EVIDENCE_PROBLEM = `This change touches runtime code, but the PR body has no ${live_evidence.EVIDENCE_HEADING} section: run the acceptance criteria for real and pass the body with --body-file <path>.`
26
+ const EVIDENCE_PENDING = `This change touches runtime code and no PR is open yet: write the ${live_evidence.EVIDENCE_HEADING} section to a file and hand it to josh ship with --body-file <path>, or the ship refuses.`
25
27
 
26
28
  interface PreflightRequest {
27
29
  title: string
@@ -34,13 +36,20 @@ async function body_text(body_path: string | undefined): Promise<string | undefi
34
36
  return await git_gh_command.pr_get_body(await git_branch.current())
35
37
  }
36
38
 
37
- async function evidence_problems(body_path: string | undefined): Promise<Array<string>> {
39
+ // `no_body_problem` answers a runtime change with no body to read at all — no `--body-file` and no open
40
+ // PR, which is every run before its ship opens one.
41
+ async function evidence_problems(
42
+ body_path: string | undefined,
43
+ no_body_problem: string = EVIDENCE_PROBLEM,
44
+ ): Promise<Array<string>> {
38
45
  const [paths, body] = await Promise.all([
39
46
  changed_paths.read_changed_paths(false),
40
47
  body_text(body_path),
41
48
  ])
42
49
 
43
- return live_evidence.verdict_for(paths, body) === 'required' ? [EVIDENCE_PROBLEM] : []
50
+ if (live_evidence.verdict_for(paths, body) !== 'required') return []
51
+
52
+ return [body === undefined ? no_body_problem : EVIDENCE_PROBLEM]
44
53
  }
45
54
 
46
55
  function refusal(problems: ReadonlyArray<string>): JoshResult {
@@ -81,7 +90,29 @@ async function stage(request: PreflightRequest): Promise<JoshResult> {
81
90
  return scoped.code === SUCCESS_EXIT_CODE ? { code: SUCCESS_EXIT_CODE, out: 'ready' } : scoped
82
91
  }
83
92
 
84
- const run_ship_preflight = { EVIDENCE_PROBLEM, stage }
93
+ async function classification_problems(issue_number: string): Promise<Array<string>> {
94
+ await git_pr.release_classification(issue_number, await git_branch.current())
95
+
96
+ return []
97
+ }
98
+
99
+ // **The same two refusals, asked before the ship rather than by it** (joshuafolkken/kit#3154). In one
100
+ // measured backlogrun 20 of 41 lane `ship` refusals were a missing classification or missing evidence —
101
+ // facts fixed long before the ship, refused at the run's largest context. `run:prep` (and so `run:entry`)
102
+ // and `run:step` print this, so the Issue body and the evidence are met while they are cheap. Nothing
103
+ // here replaces `stage`: the ship and `followup` still refuse on both. Before the PR exists the evidence
104
+ // can only live in the file the ship will be handed, so it is asked for as that file, not reported as a
105
+ // body that already failed.
106
+ async function ahead(issue_number: string): Promise<Array<string>> {
107
+ const [classification, evidence] = await Promise.all([
108
+ caught(classification_problems(issue_number)),
109
+ caught(evidence_problems(undefined, EVIDENCE_PENDING)),
110
+ ])
111
+
112
+ return [...classification, ...evidence]
113
+ }
114
+
115
+ const run_ship_preflight = { EVIDENCE_PENDING, EVIDENCE_PROBLEM, ahead, stage }
85
116
 
86
117
  export type { PreflightRequest }
87
118
  export { run_ship_preflight }
@@ -155,7 +155,7 @@ async function record_and_route(
155
155
  if (recorded.code !== SUCCESS_EXIT_CODE) return recorded
156
156
 
157
157
  const outcome = outcome_of(verdict)
158
- const out = [...verdict.specs, outcome.note].join('\n')
158
+ const out = [...verdict.report, outcome.note].join('\n')
159
159
 
160
160
  return outcome.is_passing ? { code: SUCCESS_EXIT_CODE, out } : failure(out)
161
161
  }