@joshuafolkken/kit 1.1094.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 (177) hide show
  1. package/.claude/agents/investigator.md +17 -0
  2. package/.claude/settings.json +1 -0
  3. package/.claude/skills/workflow-commands/SKILL.md +1 -1
  4. package/.claude/skills/workflow-commands/background-commands.md +36 -85
  5. package/.claude/skills/workflow-commands/backlogrun-progress.md +31 -34
  6. package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -2
  7. package/.claude/skills/workflow-commands/delegation.md +5 -1
  8. package/.claude/skills/workflow-commands/fullrun.md +5 -4
  9. package/.claude/skills/workflow-commands/halfrun.md +5 -4
  10. package/.claude/skills/workflow-commands/latest-gate.md +7 -15
  11. package/.claude/skills/workflow-commands/pre-gate-cut.md +4 -8
  12. package/.claude/skills/workflow-commands/split-assessment.md +4 -18
  13. package/.github/actions/setup-node/action.yml +33 -0
  14. package/.github/actions/setup-pnpm/action.yml +4 -4
  15. package/.github/workflows/auto-tag.yml +2 -3
  16. package/.github/workflows/ci.yml +7 -8
  17. package/.github/workflows/dependabot-auto-merge.yml +1 -0
  18. package/.github/workflows/pr-classification.yml +1 -3
  19. package/.github/workflows/production.yml +1 -0
  20. package/.github/workflows/publish.yml +10 -9
  21. package/.github/workflows/sonar-qube.yml +17 -13
  22. package/.pnpmfile.mjs +29 -0
  23. package/AGENTS.md +4 -11
  24. package/CLAUDE.md +24 -17
  25. package/GEMINI.md +4 -13
  26. package/README.md +8 -3
  27. package/SECURITY.md +2 -0
  28. package/cspell/index.yaml +4 -0
  29. package/dist/CLAUDE.basic.md +6 -6
  30. package/dist/CLAUDE.md +24 -17
  31. package/dist/CLAUDE.static.md +6 -6
  32. package/dist/config-merge/index.js +33 -9
  33. package/dist/hooks/batch-guard.js +4 -4
  34. package/dist/hooks/{chunk-OIZUHPJI.js → chunk-FO2I7UT7.js} +1 -1
  35. package/dist/hooks/{chunk-NKOG3XAV.js → chunk-GDU26K3J.js} +11 -3
  36. package/dist/hooks/{chunk-O4LL4HTI.js → chunk-HANSKHES.js} +2 -2
  37. package/dist/hooks/{chunk-3C4YCCEF.js → chunk-HBKZLITF.js} +4 -4
  38. package/dist/hooks/{chunk-UD24EAIL.js → chunk-JKKTVDAG.js} +1984 -146
  39. package/dist/hooks/{chunk-M2XDCUKD.js → chunk-KBXZLMRH.js} +89 -1803
  40. package/dist/hooks/{chunk-IL27YTPH.js → chunk-KOKOAV6D.js} +2 -2
  41. package/dist/hooks/{chunk-7ZPFAVR7.js → chunk-LJBLGHEH.js} +59 -19
  42. package/dist/hooks/{chunk-E54KGFEQ.js → chunk-NFZGFGOR.js} +1 -1
  43. package/dist/hooks/{chunk-KHWHKJOM.js → chunk-RFWWUI5F.js} +4 -4
  44. package/dist/hooks/codex-hook-adapter.js +12 -47
  45. package/dist/hooks/duplicate-read-guard.js +5 -5
  46. package/dist/hooks/format-edited.js +2 -2
  47. package/dist/hooks/inputs.json +175 -173
  48. package/dist/hooks/investigation-guard.js +5 -5
  49. package/dist/hooks/pretool-guard.js +9 -9
  50. package/dist/hooks/stop-guard.js +17 -12
  51. package/dist/josh.js +87 -31
  52. package/dist/version/index.js +36 -20
  53. package/eslint/rules/unicorn.js +3 -2
  54. package/package.json +23 -5
  55. package/pnpm-workspace.yaml +5 -7
  56. package/prompts/collaboration-workflow/glossary.md +17 -0
  57. package/prompts/collaboration-workflow/issue-template.md +1 -1
  58. package/prompts/collaboration-workflow/operating-rules.md +1 -1
  59. package/prompts/collaboration-workflow/principles.md +9 -0
  60. package/prompts/collaboration-workflow.md +1 -0
  61. package/scripts/backlog/backlog-arrival.ts +30 -10
  62. package/scripts/backlog/backlog-budget-cli.ts +15 -16
  63. package/scripts/backlog/backlog-offer-cli.ts +6 -14
  64. package/scripts/backlog/backlog-plan.ts +29 -18
  65. package/scripts/backlog/backlog-ready.ts +20 -7
  66. package/scripts/config-merge/parse-jsonc.ts +33 -3
  67. package/scripts/config-merge/patch-json-key.ts +13 -6
  68. package/scripts/cost-runtime/cost-cli.ts +3 -7
  69. package/scripts/delegation/delegation-policy.ts +9 -1
  70. package/scripts/delegation/investigation-guard.ts +1 -1
  71. package/scripts/delegation/investigation-reads.ts +105 -19
  72. package/scripts/doctor/doctor-consumer.ts +3 -5
  73. package/scripts/doctor/doctor-io.ts +9 -13
  74. package/scripts/document/backlogrun-parent-read-set.ts +12 -1
  75. package/scripts/document/capped-print.ts +74 -0
  76. package/scripts/document/document-byte-budget.ts +1 -0
  77. package/scripts/document/document-section-cli.ts +4 -3
  78. package/scripts/document/entry-read-budget.ts +13 -7
  79. package/scripts/document/entry-read-set.ts +32 -8
  80. package/scripts/document/lane-child-read-set.ts +7 -0
  81. package/scripts/document/read-set-point-of-use.ts +8 -3
  82. package/scripts/document/read-set-trim.ts +3 -1
  83. package/scripts/epic/epic-audit.ts +3 -18
  84. package/scripts/gate/scoped-green.ts +25 -14
  85. package/scripts/gh/git-gh-issue-list.ts +12 -11
  86. package/scripts/gh/git-gh-issue-write.ts +4 -1
  87. package/scripts/gh/git-gh-pr-auto-merge.ts +4 -1
  88. package/scripts/git/git-command.ts +6 -1
  89. package/scripts/git/git-spawn-sync.ts +65 -0
  90. package/scripts/git/git-spawn.ts +5 -4
  91. package/scripts/git/stash/stash-pop-args.ts +1 -6
  92. package/scripts/init/claude-kit-only-settings.ts +29 -0
  93. package/scripts/init/init-copy-content.ts +8 -2
  94. package/scripts/init/init-file-action.ts +54 -0
  95. package/scripts/init/init-logic-migrate.ts +35 -17
  96. package/scripts/init/init-logic-secretlint.ts +1 -1
  97. package/scripts/init/init-logic.ts +6 -22
  98. package/scripts/init/init.ts +11 -68
  99. package/scripts/init/initial-commit.ts +24 -0
  100. package/scripts/init/kit-written-paths.ts +2 -0
  101. package/scripts/init/project-profile.ts +3 -6
  102. package/scripts/init/start-exec.ts +16 -3
  103. package/scripts/init/start-steps.ts +10 -13
  104. package/scripts/issue/defect-rate-cli.ts +61 -20
  105. package/scripts/issue/issue-backlinks.ts +7 -14
  106. package/scripts/issue/issue-cite.ts +12 -6
  107. package/scripts/issue/issue-file.ts +5 -12
  108. package/scripts/issue/issue-read-cli.ts +1 -0
  109. package/scripts/issue/issue-report-failures.ts +6 -2
  110. package/scripts/issue/issue-state-cli.ts +1 -1
  111. package/scripts/issue/markdown-section.ts +21 -6
  112. package/scripts/issue/measure-rerun-cli.ts +73 -14
  113. package/scripts/josh/josh-commands-lane.ts +1 -1
  114. package/scripts/josh/josh-commands-project.ts +8 -1
  115. package/scripts/josh/josh-commands-workflow.ts +1 -1
  116. package/scripts/josh/run-github-prerequisite.ts +3 -6
  117. package/scripts/lane/lane-await.ts +89 -15
  118. package/scripts/lane/lane-cli.ts +11 -13
  119. package/scripts/lane/lane-close.ts +4 -1
  120. package/scripts/lane/lane-launch-cli.ts +10 -16
  121. package/scripts/lane/openai-review-broker.ts +4 -1
  122. package/scripts/lib/cli-flags.ts +31 -2
  123. package/scripts/lib/error-message.ts +12 -1
  124. package/scripts/lines/line-targets.ts +0 -11
  125. package/scripts/lines/lines-command.ts +5 -3
  126. package/scripts/propagate/propagate-git.ts +3 -8
  127. package/scripts/refactor/refactor-scan.ts +2 -1
  128. package/scripts/registry-migration/migrate-logic.ts +28 -19
  129. package/scripts/registry-migration/migrate.ts +6 -5
  130. package/scripts/release/github-release-cli.ts +14 -3
  131. package/scripts/release/github-release-environment.ts +1 -0
  132. package/scripts/release/github-release.ts +103 -17
  133. package/scripts/release/release-publish.ts +36 -2
  134. package/scripts/release/release-worktree.ts +97 -2
  135. package/scripts/review/review-checkout.ts +3 -2
  136. package/scripts/review/review-record-cli.ts +9 -16
  137. package/scripts/rules/early-heartbeat.ts +4 -3
  138. package/scripts/rules/lane-park.ts +9 -0
  139. package/scripts/rules/stop-rules.ts +7 -4
  140. package/scripts/run/run-carry-args.ts +8 -12
  141. package/scripts/run/run-carry-cli.ts +3 -1
  142. package/scripts/run/run-carry-conversation.ts +94 -0
  143. package/scripts/run/run-carry-reclaim.ts +37 -0
  144. package/scripts/run/run-carry.ts +21 -5
  145. package/scripts/run/run-cut-args.ts +1 -6
  146. package/scripts/run/run-ending-cli.ts +1 -5
  147. package/scripts/run/run-entry-cli.ts +40 -9
  148. package/scripts/run/run-entry-stop.ts +104 -0
  149. package/scripts/run/run-event-stream-emit.ts +7 -2
  150. package/scripts/run/run-hold.ts +4 -1
  151. package/scripts/run/run-liveness-cli.ts +1 -5
  152. package/scripts/run/run-merge-cli.ts +2 -10
  153. package/scripts/run/run-merge-steps.ts +4 -1
  154. package/scripts/run/run-parent-cut-hook.ts +4 -1
  155. package/scripts/run/run-prep-cli.ts +4 -1
  156. package/scripts/run/run-progress-args.ts +20 -4
  157. package/scripts/run/run-progress-cli.ts +31 -29
  158. package/scripts/run/run-progress-clock.ts +4 -1
  159. package/scripts/run/run-ship-cli.ts +8 -16
  160. package/scripts/run/run-ship-probe.ts +4 -1
  161. package/scripts/run/run-status-cli.ts +2 -15
  162. package/scripts/run/run-tidy-lanes.ts +4 -1
  163. package/scripts/run/run-wake-cli.ts +2 -10
  164. package/scripts/run/run-watcher-guard.ts +9 -5
  165. package/scripts/safe-chain/pack-hook.ts +92 -0
  166. package/scripts/sync/sync-ai-files.ts +4 -0
  167. package/scripts/test/test-declared-changed.ts +13 -16
  168. package/scripts/test/test-declared.ts +3 -7
  169. package/scripts/test/test-related.ts +1 -6
  170. package/scripts/test/test-unit-guard.ts +21 -7
  171. package/scripts/time-runtime/time-spans.ts +4 -0
  172. package/scripts/version/version-commands.ts +11 -1
  173. package/templates/CLAUDE.basic.md +6 -6
  174. package/templates/workflows/ci.yml +16 -82
  175. package/templates/workflows/dependabot-auto-merge.yml +1 -0
  176. package/templates/workflows/github-release.yml +14 -28
  177. package/tsconfig.sonar.json +4 -22
@@ -76,17 +76,13 @@ type Request =
76
76
  const READ_REQUEST: Request = { kind: 'read' }
77
77
 
78
78
  function read_arguments(argv: ReadonlyArray<string>): ParsedValues | undefined {
79
- return cli_flags.parse_or_undefined({ args: [...argv], options: OPTIONS, strict: true })?.values
80
- }
81
-
82
- function text_of(value: OptionValue): string | undefined {
83
- return typeof value === 'string' ? value : undefined
79
+ return cli_flags.values_of(argv, OPTIONS)
84
80
  }
85
81
 
86
82
  // Absent is zero; present but not a count is `undefined`, which invalidates the whole invocation
87
83
  // rather than quietly counting nothing.
88
84
  function to_count(value: OptionValue): number | undefined {
89
- const text = text_of(value)
85
+ const text = cli_flags.string_of(value)
90
86
 
91
87
  if (text === undefined) return NO_INCREMENT
92
88
 
@@ -99,7 +95,7 @@ function to_count(value: OptionValue): number | undefined {
99
95
  // than a zero: there is no issue to record, which is a different fact from recording issue zero. A
100
96
  // present-but-malformed value invalidates the whole invocation, exactly as a bad `--merged` does.
101
97
  function to_done(value: OptionValue): Pick<CarryChange, 'done'> | undefined {
102
- const text = text_of(value)
98
+ const text = cli_flags.string_of(value)
103
99
 
104
100
  if (text === undefined) return {}
105
101
 
@@ -165,7 +161,7 @@ function group_count(values: ParsedValues): number {
165
161
  // the record simply declares no owner and every standing record reads as not provably live, which
166
162
  // refuses rather than resumes.
167
163
  function to_owner(value: OptionValue): CarryOwner | undefined {
168
- const text = text_of(value)
164
+ const text = cli_flags.string_of(value)
169
165
 
170
166
  if (text === undefined) return run_carry.NO_OWNER
171
167
 
@@ -179,7 +175,7 @@ function to_owner(value: OptionValue): CarryOwner | undefined {
179
175
  // A non-empty `--summary`, or nothing. An empty string is nothing here, so it reads as absent and the
180
176
  // pairing check below refuses it against `--retrospective`.
181
177
  function summary_text(values: ParsedValues): string | undefined {
182
- const summary = text_of(values.summary)
178
+ const summary = cli_flags.string_of(values.summary)
183
179
 
184
180
  return summary === undefined || summary === '' ? undefined : summary
185
181
  }
@@ -220,17 +216,17 @@ function to_claim_request(
220
216
  }
221
217
 
222
218
  function to_other_request(values: ParsedValues, owner: CarryOwner): Request | undefined {
223
- if (values.end === true) return { kind: 'end', stopped: text_of(values.stopped) }
219
+ if (values.end === true) return { kind: 'end', stopped: cli_flags.string_of(values.stopped) }
224
220
 
225
221
  return has_count(values) ? to_count_request(values, owner) : READ_REQUEST
226
222
  }
227
223
 
228
224
  function to_named_request(values: ParsedValues, owner: CarryOwner): Request | undefined {
229
- const begun = text_of(values.begin)
225
+ const begun = cli_flags.string_of(values.begin)
230
226
 
231
227
  if (begun !== undefined) return to_claim_request(begun, owner, false)
232
228
 
233
- const adopted = text_of(values.resume)
229
+ const adopted = cli_flags.string_of(values.resume)
234
230
 
235
231
  if (adopted !== undefined) return to_claim_request(adopted, owner, true)
236
232
 
@@ -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
 
@@ -47,12 +47,7 @@ type Request =
47
47
  | { kind: 'end' }
48
48
 
49
49
  function read_arguments(argv: ReadonlyArray<string>): ParsedValues | undefined {
50
- return cli_flags.parse_or_undefined({
51
- args: [...argv],
52
- options: OPTIONS,
53
- strict: true,
54
- allowPositionals: true,
55
- })
50
+ return cli_flags.arguments_of(argv, OPTIONS)
56
51
  }
57
52
 
58
53
  // The issue is the sole positional, and it must be a real issue number: a cut relaunches
@@ -56,10 +56,6 @@ interface ParsedArguments {
56
56
  values: ParsedValues
57
57
  }
58
58
 
59
- function read_arguments(argv: ReadonlyArray<string>): ParsedArguments | undefined {
60
- return cli_flags.parse_or_undefined({ args: [...argv], options: OPTIONS, allowPositionals: true })
61
- }
62
-
63
59
  function is_valid(parsed: ParsedArguments): boolean {
64
60
  return (
65
61
  parsed.positionals.length === 1 &&
@@ -78,7 +74,7 @@ function to_request(parsed: ParsedArguments): EndingRequest {
78
74
  }
79
75
 
80
76
  function parse_request(argv: ReadonlyArray<string>): EndingRequest | undefined {
81
- const parsed = read_arguments(argv)
77
+ const parsed = cli_flags.arguments_of(argv, OPTIONS)
82
78
 
83
79
  if (parsed === undefined || !is_valid(parsed)) return undefined
84
80
 
@@ -4,6 +4,7 @@ import { josh_command } from '#scripts/josh/josh-run'
4
4
  import { lane_child_marker } from '#scripts/lane/lane-child-marker'
5
5
  import { run_cut_report } from './run-cut-report'
6
6
  import { run_entry, type EntryParts } from './run-entry'
7
+ import { run_entry_stop } from './run-entry-stop'
7
8
  import { run_halfrun_resume } from './run-halfrun-resume'
8
9
  import { run_hold_cli } from './run-hold-cli'
9
10
  import { run_next } from './run-next'
@@ -40,6 +41,7 @@ const NEWLINE = '\n'
40
41
  const HOLD_STOP_NOTE =
41
42
  '(tree not held — see the reason above; clean up and retry, or release with `pnpm josh run:release`)'
42
43
  const COST_STOP_NOTE = '(session budget spent — see the figure above; resume in a fresh session)'
44
+ const { BUDGET_REASON, HOLD_REASON } = run_entry_stop
43
45
  const HALFRUN_RESUME_TOKEN = 'halfrun'
44
46
 
45
47
  // Exactly one issue number, or the call is refused — `run:entry` opens one run, and a second number
@@ -135,6 +137,23 @@ function stop_report(issue_number: string, hold: string, cost: string, report: s
135
137
  return emit({ issue_number, hold, cost, verdict: run_entry.NO_VERDICT, report })
136
138
  }
137
139
 
140
+ // A stop this command decided carries its own chores (joshuafolkken/kit#3099): the `confirmation`
141
+ // Telegram always, and the release where this call claimed the hold — `run_entry_stop` says why.
142
+ interface StopCall {
143
+ parts: Pick<EntryParts, 'issue_number' | 'hold' | 'cost' | 'report'>
144
+ command: StageCommand
145
+ reason: string
146
+ should_release: boolean
147
+ }
148
+
149
+ async function stop_run({ parts, command, reason, should_release }: StopCall): Promise<number> {
150
+ const code = stop_report(parts.issue_number, parts.hold, parts.cost, parts.report)
151
+
152
+ await run_entry_stop.stop({ issue_number: parts.issue_number, command, reason, should_release })
153
+
154
+ return code
155
+ }
156
+
138
157
  interface Resume {
139
158
  token: string
140
159
  code: number
@@ -166,16 +185,22 @@ function resume_report(issue_number: string, resume: Resume): number {
166
185
  // to and including implementation. **A `prrun` stopped before its merge is resumed the same way**
167
186
  // (joshuafolkken/kit#3023), under the token that says what is left of it.
168
187
  async function resume_stopped(
169
- issue_number: string,
188
+ { issue_number, command }: EntryRequest,
170
189
  token: string,
171
190
  stopped_run: { adopt: (issue: string) => Promise<boolean> },
172
191
  ): Promise<number> {
173
192
  const cost = await check_cost()
174
193
 
175
- if (cost === run_entry.COST_OVER) return stop_report(issue_number, token, cost, COST_STOP_NOTE)
194
+ if (cost === run_entry.COST_OVER) {
195
+ const parts = { issue_number, hold: token, cost, report: COST_STOP_NOTE }
196
+
197
+ return await stop_run({ parts, command, reason: BUDGET_REASON, should_release: false })
198
+ }
176
199
 
177
200
  if (!(await stopped_run.adopt(issue_number))) {
178
- return stop_report(issue_number, run_hold_cli.BUSY_VERDICT, cost, HOLD_STOP_NOTE)
201
+ const parts = { issue_number, hold: run_hold_cli.BUSY_VERDICT, cost, report: HOLD_STOP_NOTE }
202
+
203
+ return await stop_run({ parts, command, reason: HOLD_REASON, should_release: false })
179
204
  }
180
205
 
181
206
  console.info(`entry #${issue_number} — resume: ${token}`)
@@ -186,17 +211,17 @@ async function resume_stopped(
186
211
  // The stopped run a decision picks up: a `halfrun` stop resumes at the gate, a `prrun` stop at
187
212
  // `followup` under its own token. `undefined` for any other start, so the ordinary claim decides.
188
213
  async function resume_any(
189
- issue_number: string,
214
+ request: EntryRequest,
190
215
  decision: StageDecision,
191
216
  stage: StageRead,
192
217
  ): Promise<number | undefined> {
193
218
  if (decision.state === run_stage.HALFRUN_STOPPED) {
194
- return await resume_stopped(issue_number, HALFRUN_RESUME_TOKEN, run_halfrun_resume)
219
+ return await resume_stopped(request, HALFRUN_RESUME_TOKEN, run_halfrun_resume)
195
220
  }
196
221
 
197
222
  return stage.prrun_token === undefined
198
223
  ? undefined
199
- : await resume_stopped(issue_number, stage.prrun_token, run_prrun_resume)
224
+ : await resume_stopped(request, stage.prrun_token, run_prrun_resume)
200
225
  }
201
226
 
202
227
  // **A command whose stopping point the issue has already reached redoes nothing** (joshuafolkken/kit#3042)
@@ -210,12 +235,18 @@ async function claim_run({ issue_number, command }: EntryRequest): Promise<numbe
210
235
  const hold = await claim_hold(issue_number, command)
211
236
 
212
237
  if (hold !== run_hold_cli.HOLD_VERDICT) {
213
- return stop_report(issue_number, hold, run_entry.COST_SKIPPED, HOLD_STOP_NOTE)
238
+ const parts = { issue_number, hold, cost: run_entry.COST_SKIPPED, report: HOLD_STOP_NOTE }
239
+
240
+ return await stop_run({ parts, command, reason: HOLD_REASON, should_release: false })
214
241
  }
215
242
 
216
243
  const cost = await check_cost()
217
244
 
218
- if (cost === run_entry.COST_OVER) return stop_report(issue_number, hold, cost, COST_STOP_NOTE)
245
+ if (cost === run_entry.COST_OVER) {
246
+ const parts = { issue_number, hold, cost, report: COST_STOP_NOTE }
247
+
248
+ return await stop_run({ parts, command, reason: BUDGET_REASON, should_release: true })
249
+ }
219
250
 
220
251
  const reads = await gather_reads(issue_number)
221
252
 
@@ -248,7 +279,7 @@ async function open_run(request: EntryRequest): Promise<number> {
248
279
 
249
280
  if (command === run_stage.KICKOFF || is_settled(decision)) return SUCCESS_EXIT_CODE
250
281
 
251
- return (await resume_any(issue_number, decision, stage)) ?? (await claim_run(request))
282
+ return (await resume_any(request, decision, stage)) ?? (await claim_run(request))
252
283
  }
253
284
 
254
285
  async function run(argv: ReadonlyArray<string>): Promise<number> {
@@ -0,0 +1,104 @@
1
+ import { josh_command } from '#scripts/josh/josh-run'
2
+ import { telegram_notify } from '#scripts/notify/telegram-notify'
3
+ import { lane_park } from '#scripts/rules/lane-park'
4
+
5
+ // What `run:entry` does on the way out of a stop it decided itself (joshuafolkken/kit#3099).
6
+ //
7
+ // **The stop's chores used to be the agent's to remember, and the `Stop` hook was what reminded it.**
8
+ // `fullrun.md` step 3 asked the agent, on an `over` budget, to send the `confirmation` Telegram and then
9
+ // run `pnpm josh run:release` — two calls after a command had already decided the run was over. Across
10
+ // eleven measured sessions the hook sent a reply back five times for exactly those two calls, each one a
11
+ // further turn re-reading the whole context. The command that decides the stop knows everything the
12
+ // chores need, so it does them, and the hook stays a safety net that a normal run never trips.
13
+ //
14
+ // **The release is the claim's own undo, and only where this call made the claim.** A resume over a
15
+ // stopped `halfrun` / `prrun` stops on the budget before adopting anything, and the hold there is the
16
+ // stopped run's — kept on purpose over its verified diff — so that stop notifies and releases nothing.
17
+ // A `busy` / `unknown` hold is another run's, or none at all, so it is never released here either.
18
+ //
19
+ // **A dispatched lane child sends no Telegram from here.** Its stop must park the Issue before it
20
+ // notifies, and `lane_park`'s guard delivers that rule at the `josh notify` call — a Telegram sent from
21
+ // inside this command would pass that guard by and satisfy the `Stop` hook with no park behind it.
22
+
23
+ interface StopNotice {
24
+ issue_number: string
25
+ command: string
26
+ reason: string
27
+ should_release: boolean
28
+ }
29
+
30
+ interface StopPorts {
31
+ notify: (input: { issue_title: string; body: string; recovery: string }) => Promise<boolean>
32
+ release: (issue_number: string) => Promise<boolean>
33
+ is_parking_child: () => boolean
34
+ }
35
+
36
+ const RECOVERY = 'If this did not arrive, the run:entry output in the session names the stop.'
37
+ const NOTIFY_FAILED_NOTE =
38
+ '(the confirmation Telegram could not be sent — send it with `pnpm josh notify --task-type confirmation` before stopping)'
39
+ const RELEASED_NOTE = '(hold released by run:entry — the tree is free for the resume)'
40
+ const RELEASE_FAILED_NOTE =
41
+ '(the hold could not be released — run `pnpm josh run:release` before stopping)'
42
+ const should_forward_stderr = true
43
+ const SUCCESS_EXIT_CODE = 0
44
+
45
+ const BUDGET_REASON = 'the session budget is spent'
46
+ const HOLD_REASON = 'the working tree is not held for this run — see the run:entry output'
47
+
48
+ async function release(issue_number: string): Promise<boolean> {
49
+ const released = await josh_command.josh_run(['run:release', issue_number], should_forward_stderr)
50
+
51
+ return released.code === SUCCESS_EXIT_CODE
52
+ }
53
+
54
+ function is_parking_child(): boolean {
55
+ return lane_park.is_parking_child()
56
+ }
57
+
58
+ const DEFAULT_PORTS: StopPorts = { notify: telegram_notify.confirm, release, is_parking_child }
59
+
60
+ function body_of(notice: StopNotice): string {
61
+ return `${notice.command} #${notice.issue_number} stopped at entry: ${notice.reason}.\nResume with \`${notice.command} #${notice.issue_number}\`.`
62
+ }
63
+
64
+ // The release runs first, so the resume the Telegram names finds the tree free when a person acts on it.
65
+ // The marker line is printed only for a Telegram that went out: the `Stop` hook reads it as the notify,
66
+ // and a failed send has to leave the hook still asking for one. The released note, likewise, is printed
67
+ // only for a release that succeeded, so a failed one leaves the agent still asked to release.
68
+ async function release_claim(notice: StopNotice, ports: StopPorts): Promise<void> {
69
+ if (!notice.should_release) return
70
+
71
+ const is_released = await ports.release(notice.issue_number)
72
+
73
+ console.info(is_released ? RELEASED_NOTE : RELEASE_FAILED_NOTE)
74
+ }
75
+
76
+ async function notify_stop(notice: StopNotice, ports: StopPorts): Promise<void> {
77
+ if (ports.is_parking_child()) return
78
+
79
+ const is_sent = await ports.notify({
80
+ issue_title: `${notice.command} #${notice.issue_number} stopped`,
81
+ body: body_of(notice),
82
+ recovery: RECOVERY,
83
+ })
84
+
85
+ console.info(is_sent ? lane_park.COMMAND_NOTIFY_MARKER : NOTIFY_FAILED_NOTE)
86
+ }
87
+
88
+ async function stop(notice: StopNotice, ports: StopPorts = DEFAULT_PORTS): Promise<void> {
89
+ await release_claim(notice, ports)
90
+ await notify_stop(notice, ports)
91
+ }
92
+
93
+ const run_entry_stop = {
94
+ BUDGET_REASON,
95
+ HOLD_REASON,
96
+ NOTIFY_FAILED_NOTE,
97
+ RELEASED_NOTE,
98
+ RELEASE_FAILED_NOTE,
99
+ body_of,
100
+ stop,
101
+ }
102
+
103
+ export { run_entry_stop }
104
+ export type { StopNotice, StopPorts }
@@ -1,3 +1,4 @@
1
+ import { error_text } from '#scripts/lib/error-message'
1
2
  import { run_carry } from './run-carry'
2
3
  import { run_event_scope } from './run-event-scope'
3
4
  import { run_event_stream, type RunEvent } from './run-event-stream'
@@ -62,8 +63,10 @@ async function emit_once(kind: string, text: string): Promise<boolean> {
62
63
  if (target === undefined || run_event_stream.read_last(target)?.kind === kind) return false
63
64
 
64
65
  return run_event_stream.append(target, kind, text, now_iso()).appended
65
- } catch {
66
+ } catch (error) {
66
67
  // Best-effort: a failed append is dropped rather than raised into the loop's work.
68
+ error_text.trace_swallowed('run_event_stream_emit.emit_once', error)
69
+
67
70
  return false
68
71
  }
69
72
  }
@@ -98,8 +101,10 @@ async function emit_once_since(kind: string, text: string, reset_kind: string):
98
101
  }
99
102
 
100
103
  return run_event_stream.append(target, kind, text, now_iso()).appended
101
- } catch {
104
+ } catch (error) {
102
105
  // Best-effort: a failed append is dropped rather than raised into the caller's work.
106
+ error_text.trace_swallowed('run_event_stream_emit.emit_once_since', error)
107
+
103
108
  return false
104
109
  }
105
110
  }
@@ -1,5 +1,6 @@
1
1
  import { git_command } from '#scripts/git/git-command'
2
2
  import { stamp_file } from '#scripts/josh/stamp-file'
3
+ import { error_text } from '#scripts/lib/error-message'
3
4
  import { z } from 'zod'
4
5
 
5
6
  // joshuafolkken/kit#1091: `epicrun` asks `epic-busy.ts` whether a *repository* already has a child in
@@ -196,7 +197,9 @@ async function is_tree_dirty(directory?: string): Promise<boolean> {
196
197
  const status = await git_command.status(directory)
197
198
 
198
199
  return status.trim() !== ''
199
- } catch {
200
+ } catch (error) {
201
+ error_text.trace_swallowed('run_hold.is_tree_dirty', error)
202
+
200
203
  return true
201
204
  }
202
205
  }
@@ -89,10 +89,6 @@ interface ParsedArguments {
89
89
  values: ParsedValues
90
90
  }
91
91
 
92
- function read_arguments(argv: ReadonlyArray<string>): ParsedArguments | undefined {
93
- return cli_flags.parse_or_undefined({ args: [...argv], options: OPTIONS, allowPositionals: true })
94
- }
95
-
96
92
  function is_valid_target(parsed: ParsedArguments): boolean {
97
93
  return (
98
94
  parsed.positionals.length === 1 &&
@@ -136,7 +132,7 @@ function to_request(parsed: ParsedArguments): LivenessRequest {
136
132
  }
137
133
 
138
134
  function parse_request(argv: ReadonlyArray<string>): LivenessRequest | undefined {
139
- const parsed = read_arguments(argv)
135
+ const parsed = cli_flags.arguments_of(argv, OPTIONS)
140
136
 
141
137
  if (parsed === undefined || !is_valid(parsed)) return undefined
142
138
 
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
- import { parseArgs } from 'node:util'
4
3
  import { api_outage } from '#scripts/agent/api-outage'
5
4
  import { CONTEXT_CUT_THRESHOLD } from '#scripts/cost-runtime/context-cut-threshold'
6
5
  import { issue_closing_pr } from '#scripts/issue/issue-closing-pr'
7
6
  import { issue_state_cli } from '#scripts/issue/issue-state-cli'
8
7
  import { lane_handoff } from '#scripts/lane/lane-handoff'
8
+ import { cli_flags } from '#scripts/lib/cli-flags'
9
9
  import { run_carry, type CarryOwner, type RunCarry } from './run-carry'
10
10
  import { run_ending } from './run-ending'
11
11
  import { run_event_stream } from './run-event-stream'
@@ -69,14 +69,6 @@ interface ParsedArguments {
69
69
  values: Partial<Record<'epic' | 'output' | 'over' | 'owner' | 'repo', string>>
70
70
  }
71
71
 
72
- function read_args(argv: ReadonlyArray<string>): ParsedArguments | undefined {
73
- try {
74
- return parseArgs({ args: [...argv], options: OPTIONS, allowPositionals: true, strict: true })
75
- } catch {
76
- return undefined
77
- }
78
- }
79
-
80
72
  // Absent owner is "no owner declared", which the carry guard reads as not provably foreign; a present
81
73
  // but malformed pid invalidates the invocation rather than silently counting unguarded.
82
74
  function to_owner(raw: string | undefined): CarryOwner | undefined {
@@ -170,7 +162,7 @@ function to_context(parsed: ParsedArguments): MergeContext | undefined {
170
162
  }
171
163
 
172
164
  function parse(argv: ReadonlyArray<string>): MergeContext | undefined {
173
- const parsed = read_args(argv)
165
+ const parsed = cli_flags.arguments_of(argv, OPTIONS)
174
166
 
175
167
  return parsed === undefined ? undefined : to_context(parsed)
176
168
  }