@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
@@ -115,8 +115,9 @@ async function verify_public_versions(
115
115
 
116
116
  for (const [index, [name, version]] of versions.entries()) {
117
117
  const integrity = probed[index]
118
- if (integrity === undefined) missing.push(`${name}@${version}`)
119
- else integrities.set(version, integrity)
118
+ const id = `${name}@${version}`
119
+ if (integrity === undefined) missing.push(id)
120
+ else integrities.set(id, integrity)
120
121
  }
121
122
 
122
123
  return { missing, integrities }
@@ -183,7 +184,7 @@ async function verify_resolution(
183
184
  const old_tarballs = migrate_logic.github_tarballs(lockfile)
184
185
  if (old_tarballs.length > 0) throw new Error(`GitHub tarballs remain: ${old_tarballs.join(', ')}`)
185
186
 
186
- if (migrate_logic.rewrite_kit_lockfile(lockfile, integrities) !== lockfile) {
187
+ if (migrate_logic.rewrite_scoped_lockfile(lockfile, integrities) !== lockfile) {
187
188
  throw new Error('lockfile integrity does not match public npm')
188
189
  }
189
190
  }
@@ -197,7 +198,7 @@ async function apply(
197
198
  writeFileSync(context.npmrc_path, context.plan.content)
198
199
  writeFileSync(
199
200
  context.lockfile_path,
200
- migrate_logic.rewrite_kit_lockfile(context.original_lockfile, integrities),
201
+ migrate_logic.rewrite_scoped_lockfile(context.original_lockfile, integrities),
201
202
  )
202
203
  await verify_resolution(context, dependencies, integrities)
203
204
  } catch (error) {
@@ -215,7 +216,7 @@ function is_already_migrated(
215
216
  ): boolean {
216
217
  return (
217
218
  context.plan.content === context.original_npmrc &&
218
- migrate_logic.rewrite_kit_lockfile(context.original_lockfile, integrities) ===
219
+ migrate_logic.rewrite_scoped_lockfile(context.original_lockfile, integrities) ===
219
220
  context.original_lockfile
220
221
  )
221
222
  }
@@ -1,13 +1,24 @@
1
- import { execFileSync } from 'node:child_process'
2
1
  import { fileURLToPath } from 'node:url'
2
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
3
3
  import { github_release } from './github-release'
4
4
  import { github_release_environment } from './github-release-environment'
5
5
 
6
+ // A release published without the tag list would pick its previous tag from nothing, so a failed
7
+ // `git tag` stops the run rather than reading as "no tags".
8
+ function local_tags(): Array<string> {
9
+ const output = git_spawn_sync.read(['tag', '--list'])
10
+ if (output === undefined) throw new Error('git tag --list failed')
11
+
12
+ return output.split('\n')
13
+ }
14
+
6
15
  async function main(): Promise<void> {
7
16
  const { token, tag, settings } = github_release_environment.read_release_input(process.env)
8
- const tags = execFileSync('git', ['tag', '--list'], { encoding: 'utf8' }).trim().split('\n')
9
17
 
10
- console.info(await github_release.publish(fetch, token, tag, { ...settings, tags }))
18
+ console.info(await github_release.publish(fetch, token, tag, { ...settings, tags: local_tags() }))
11
19
  }
12
20
 
13
21
  if (process.argv[1] === fileURLToPath(import.meta.url)) await main()
22
+
23
+ const github_release_cli = { local_tags }
24
+ export { github_release_cli }
@@ -37,6 +37,7 @@ function read_release_input(environment: Environment): ReleaseInput {
37
37
  start_tag: optional(environment['RELEASE_START_TAG']),
38
38
  workflow: optional(environment['RELEASE_WORKFLOW']),
39
39
  jobs: job_names(environment['RELEASE_JOBS']),
40
+ await_publish: environment['RELEASE_AWAIT_PUBLISH'] === 'true',
40
41
  },
41
42
  }
42
43
  }
@@ -15,7 +15,12 @@ const POLL_INTERVAL_MS = 30_000
15
15
  const MAX_WAIT_ATTEMPTS = 120
16
16
  const RELEASE_SCHEMA = z.object({ tag_name: z.string().min(1) })
17
17
  const NOTES_SCHEMA = z.object({ name: z.string().min(1), body: z.string().min(1) })
18
- const RUN_SCHEMA = z.object({ id: z.number(), display_title: z.string() })
18
+ const RUN_SCHEMA = z.object({
19
+ id: z.number(),
20
+ display_title: z.string(),
21
+ status: z.string().optional(),
22
+ conclusion: z.string().nullable().optional(),
23
+ })
19
24
  const JOB_SCHEMA = z.object({
20
25
  name: z.string(),
21
26
  status: z.string(),
@@ -26,15 +31,19 @@ const JOBS_SCHEMA = z.object({ jobs: z.array(JOB_SCHEMA) })
26
31
 
27
32
  type ReleaseRequest = (url: string, init: RequestInit) => Promise<Response>
28
33
  type Wait = () => Promise<void>
34
+ type PollState = 'ready' | 'failed' | 'pending'
29
35
 
30
36
  // What differs between repositories. `start_tag` is the last tag that predates automatic releases;
31
37
  // without it the latest existing release is the floor. `workflow` names the publish workflow whose
32
38
  // failed run lets a lower tag be skipped, and `jobs` the jobs in it that count (empty: every job).
39
+ // `await_publish` holds the tag's own release until its run in that workflow has succeeded, for a
40
+ // release started beside the publication rather than from inside it.
33
41
  interface ReleaseSettings {
34
42
  repository: string
35
43
  start_tag?: string | undefined
36
44
  workflow?: string | undefined
37
45
  jobs?: ReadonlyArray<string> | undefined
46
+ await_publish?: boolean | undefined
38
47
  }
39
48
 
40
49
  interface PublishOptions extends ReleaseSettings {
@@ -56,7 +65,14 @@ interface Ladder {
56
65
  tags: ReadonlyArray<string>
57
66
  floor: string | undefined
58
67
  fallback?: () => Promise<string>
68
+ budget: Budget
69
+ }
70
+
71
+ // One allowance shared by every wait a release makes: waiting for its own publication and then for
72
+ // lower tags' releases must end inside the job's `timeout-minutes` together, not each on its own.
73
+ interface Budget {
59
74
  wait: Wait
75
+ remaining: number
60
76
  }
61
77
 
62
78
  function request_options(token: string, method: string, body?: object): RequestInit {
@@ -130,13 +146,22 @@ function is_failed_job(job: z.infer<typeof JOB_SCHEMA>, names: ReadonlyArray<str
130
146
  return names.some((name) => job.name === name || job.name.startsWith(`${name} /`))
131
147
  }
132
148
 
149
+ async function publish_run(
150
+ client: Client,
151
+ workflow_name: string,
152
+ tag: string,
153
+ ): Promise<z.infer<typeof RUN_SCHEMA> | undefined> {
154
+ const workflow = encodeURIComponent(workflow_name)
155
+ const runs_response = await api_get(client, `/actions/workflows/${workflow}/runs?per_page=100`)
156
+ const runs = RUNS_SCHEMA.parse(await read_response(runs_response)).workflow_runs
157
+
158
+ return runs.find((item) => item.display_title === `Publish ${tag}`)
159
+ }
160
+
133
161
  async function failed_publication(client: Client, tag: string): Promise<boolean> {
134
162
  if (client.workflow === undefined) return false
135
163
 
136
- const workflow = encodeURIComponent(client.workflow)
137
- const runs_response = await api_get(client, `/actions/workflows/${workflow}/runs?per_page=100`)
138
- const runs = RUNS_SCHEMA.parse(await read_response(runs_response)).workflow_runs
139
- const run = runs.find((item) => item.display_title === `Publish ${tag}`)
164
+ const run = await publish_run(client, client.workflow, tag)
140
165
  if (!run) return false
141
166
 
142
167
  const jobs_response = await api_get(client, `/actions/runs/${String(run.id)}/jobs?per_page=100`)
@@ -149,24 +174,72 @@ async function default_wait(): Promise<void> {
149
174
  await setTimeout(POLL_INTERVAL_MS)
150
175
  }
151
176
 
152
- async function release_state(client: Client, tag: string): Promise<'ready' | 'failed' | 'pending'> {
177
+ async function release_state(client: Client, tag: string): Promise<PollState> {
153
178
  if (await existing_release(client, tag)) return 'ready'
154
179
  if (await failed_publication(client, tag)) return 'failed'
155
180
 
156
181
  return 'pending'
157
182
  }
158
183
 
159
- async function wait_for_release(client: Client, tag: string, wait: Wait): Promise<boolean> {
160
- for (let attempt = 0; attempt < MAX_WAIT_ATTEMPTS; attempt += 1) {
184
+ // A run not listed yet is pending too: the announcement that starts this release also starts the
185
+ // publication, so the release can ask before GitHub has queued that run.
186
+ async function publication_state(
187
+ client: Client,
188
+ workflow: string,
189
+ tag: string,
190
+ ): Promise<PollState> {
191
+ const run = await publish_run(client, workflow, tag)
192
+ if (run?.status !== 'completed') return 'pending'
193
+
194
+ return run.conclusion === 'success' ? 'ready' : 'failed'
195
+ }
196
+
197
+ async function poll(
198
+ read: () => Promise<PollState>,
199
+ budget: Budget,
200
+ subject: string,
201
+ ): Promise<boolean> {
202
+ while (budget.remaining > 0) {
161
203
  // eslint-disable-next-line no-await-in-loop -- polling: each read waits on the state the previous one saw
162
- const state = await release_state(client, tag)
204
+ const state = await read()
163
205
  if (state !== 'pending') return state === 'ready'
164
206
 
207
+ budget.remaining -= 1
165
208
  // eslint-disable-next-line no-await-in-loop -- polling: each read waits on the state the previous one saw
166
- await wait()
209
+ await budget.wait()
167
210
  }
168
211
 
169
- throw new Error(`Timed out waiting for previous GitHub Release ${tag}`)
212
+ throw new Error(`Timed out waiting for ${subject}`)
213
+ }
214
+
215
+ async function wait_for_release(client: Client, tag: string, budget: Budget): Promise<boolean> {
216
+ return await poll(
217
+ async () => await release_state(client, tag),
218
+ budget,
219
+ `previous GitHub Release ${tag}`,
220
+ )
221
+ }
222
+
223
+ async function wait_for_publication(client: Client, tag: string, budget: Budget): Promise<boolean> {
224
+ const { workflow } = client
225
+ if (workflow === undefined) throw new Error('Waiting for the publication needs RELEASE_WORKFLOW')
226
+
227
+ return await poll(
228
+ async () => await publication_state(client, workflow, tag),
229
+ budget,
230
+ `Publish ${tag}`,
231
+ )
232
+ }
233
+
234
+ async function is_publication_ready(
235
+ client: Client,
236
+ tag: string,
237
+ options: PublishOptions,
238
+ budget: Budget,
239
+ ): Promise<boolean> {
240
+ if (options.await_publish !== true) return true
241
+
242
+ return await wait_for_publication(client, tag, budget)
170
243
  }
171
244
 
172
245
  async function ladder_end(ladder: Ladder): Promise<string | undefined> {
@@ -184,7 +257,7 @@ async function baseline_tag(
184
257
  if (ladder.floor === undefined) return prior
185
258
  if (!prior) return await ladder_end(ladder)
186
259
 
187
- return (await wait_for_release(client, prior, ladder.wait))
260
+ return (await wait_for_release(client, prior, ladder.budget))
188
261
  ? prior
189
262
  : await baseline_tag(client, prior, ladder)
190
263
  }
@@ -195,16 +268,19 @@ function assert_older(previous: string | undefined, tag: string): void {
195
268
  }
196
269
  }
197
270
 
198
- async function build_ladder(client: Client, options: PublishOptions): Promise<Ladder> {
271
+ async function build_ladder(
272
+ client: Client,
273
+ options: PublishOptions,
274
+ budget: Budget,
275
+ ): Promise<Ladder> {
199
276
  const tags = options.tags ?? []
200
- const wait = options.wait ?? default_wait
201
277
  const { start_tag } = options
202
278
 
203
279
  if (start_tag !== undefined) {
204
- return { tags, wait, floor: start_tag, fallback: async () => await previous_tag(client) }
280
+ return { tags, budget, floor: start_tag, fallback: async () => await previous_tag(client) }
205
281
  }
206
282
 
207
- return { tags, wait, floor: await latest_release(client) }
283
+ return { tags, budget, floor: await latest_release(client) }
208
284
  }
209
285
 
210
286
  async function generate_notes(
@@ -252,6 +328,10 @@ function create_client(request: ReleaseRequest, token: string, settings: Release
252
328
  }
253
329
  }
254
330
 
331
+ function create_budget(options: PublishOptions): Budget {
332
+ return { wait: options.wait ?? default_wait, remaining: MAX_WAIT_ATTEMPTS }
333
+ }
334
+
255
335
  async function publish(
256
336
  request: ReleaseRequest,
257
337
  token: string,
@@ -263,7 +343,13 @@ async function publish(
263
343
  const client = create_client(request, token, options)
264
344
  if (await existing_release(client, tag)) return 'already-published'
265
345
 
266
- const previous = await baseline_tag(client, tag, await build_ladder(client, options))
346
+ const budget = create_budget(options)
347
+
348
+ if (!(await is_publication_ready(client, tag, options, budget))) {
349
+ return `skipped ${tag}: Publish ${tag} did not succeed`
350
+ }
351
+
352
+ const previous = await baseline_tag(client, tag, await build_ladder(client, options, budget))
267
353
 
268
354
  assert_older(previous, tag)
269
355
  await create_release(client, tag, previous)
@@ -2,6 +2,7 @@ import { git_gh_command } from '#scripts/gh/git-gh-command'
2
2
  import { git_pr_checks } from '#scripts/gh/git-pr-checks'
3
3
  import { git_command } from '#scripts/git/git-command'
4
4
  import { git_remote_branch } from '#scripts/git/git-remote-branch'
5
+ import { repository_lock } from '#scripts/git/repository-lock'
5
6
  import { write_version } from '#scripts/version/bump-version'
6
7
  import { version_targets } from '#scripts/version/version-targets'
7
8
  import type { ReleasePlan } from './release-plan'
@@ -20,6 +21,9 @@ const { PACKAGE_JSON } = version_targets
20
21
  const SUCCESS_EXIT_CODE = 0
21
22
  const FAILURE_EXIT_CODE = 1
22
23
  const IGNORE_FOR_RELEASE_LABEL = 'ignore-for-release'
24
+ const RELEASE_LOCK_PREFIX = 'josh-release-lock-'
25
+ // A release waits on CI for minutes, so a second run refuses rather than queueing behind it.
26
+ const RELEASE_LOCK_MAX_WAIT_MS = 0
23
27
 
24
28
  function branch_name_for(version: string): string {
25
29
  return `release/v${version}`
@@ -87,6 +91,14 @@ async function refuse_existing_branch(branch_name: string): Promise<void> {
87
91
  throw new Error(existing_branch_message(branch_name))
88
92
  }
89
93
 
94
+ // **The leftovers go before the guard asks** (joshuafolkken/kit#3058). A run cut off before its
95
+ // `finally`, or a `worktree add` that failed after creating its branch, leaves a local
96
+ // `release/v<version>` that the guard would otherwise refuse as a release already opened.
97
+ async function clear_the_way(branch_name: string): Promise<void> {
98
+ await release_worktree.clear_leftover()
99
+ await refuse_existing_branch(branch_name)
100
+ }
101
+
90
102
  // **No `checkout -b` and no branch guard here** (joshuafolkken/kit#2411). The work tree is already
91
103
  // sitting on the release branch — `release_worktree.create` cut it that way — and the guard ran
92
104
  // before that creation, because `worktree_add` makes the branch local and `branch_exists` would then
@@ -121,10 +133,10 @@ async function merge_and_tag(plan: ReleasePlan, branch_name: string): Promise<nu
121
133
  // branch guard runs before the tree exists, `process.chdir` points the version write and the git
122
134
  // commands at the tree, and the `finally` returns to the root and removes the tree — whichever way
123
135
  // the wait ended, so a failed tag watch cleans up rather than leaving debris behind.
124
- async function publish(plan: ReleasePlan): Promise<number> {
136
+ async function publish_locked(plan: ReleasePlan): Promise<number> {
125
137
  const branch_name = branch_name_for(plan.next_version)
126
138
 
127
- await refuse_existing_branch(branch_name)
139
+ await clear_the_way(branch_name)
128
140
 
129
141
  const directory = await release_worktree.create(branch_name)
130
142
  const previous_cwd = process.cwd()
@@ -140,6 +152,28 @@ async function publish(plan: ReleasePlan): Promise<number> {
140
152
  }
141
153
  }
142
154
 
155
+ function release_running_message(target: string): string {
156
+ return `Another \`pnpm josh release\` is running in this repository (lock \`${target}\`); wait for it to finish, then run it again.`
157
+ }
158
+
159
+ // **One release at a time per repository, held for the whole run** (joshuafolkken/kit#3058).
160
+ // `clear_leftover` removes an unpushed, commit-less release branch, which is also what a live run
161
+ // looks like while it installs dependencies — so it may only run once no other release is alive. The
162
+ // lock answers that: a run cut off before its `finally` leaves a record whose owner is gone, which
163
+ // `repository_lock` clears on the next claim, while a live owner makes this run refuse at once.
164
+ async function publish(plan: ReleasePlan): Promise<number> {
165
+ const target = repository_lock.lock_path(RELEASE_LOCK_PREFIX)
166
+ const exit_code = await repository_lock.with_lock(
167
+ async () => await publish_locked(plan),
168
+ target,
169
+ RELEASE_LOCK_MAX_WAIT_MS,
170
+ )
171
+
172
+ if (exit_code === undefined) throw new Error(release_running_message(target))
173
+
174
+ return exit_code
175
+ }
176
+
143
177
  const release_publish = {
144
178
  branch_name_for,
145
179
  commit_message,
@@ -1,7 +1,9 @@
1
1
  import path from 'node:path'
2
2
  import { git_command } from '#scripts/git/git-command'
3
+ import { git_remote_branch } from '#scripts/git/git-remote-branch'
3
4
  import { git_worktree } from '#scripts/git/git-worktree'
4
5
  import { lane_install } from '#scripts/lane/lane-install'
6
+ import { lane_leftover } from '#scripts/lane/lane-leftover'
5
7
  import { lane_paths } from '#scripts/lane/lane-paths'
6
8
 
7
9
  // The dedicated linked work tree `pnpm josh release` runs inside (joshuafolkken/kit#2411). The whole
@@ -51,18 +53,111 @@ async function install_or_clean(directory: string, branch_name: string): Promise
51
53
  // tree on the default branch, so the tree is created directly on the release branch — `git worktree
52
54
  // add --no-track -b release/v<version> <dir> origin/<default>`, which `worktree_add` already spells
53
55
  // when it is given a start point.
56
+ //
57
+ // **A failed add takes back the branch it made** (joshuafolkken/kit#3058). `git worktree add -b`
58
+ // creates the branch before it looks at the path, so an add refused for a path that already exists
59
+ // still leaves `release/v<version>` behind — and the next run's branch guard then refuses that name
60
+ // as a release already opened. The guard ran before this call and found no such branch, so one
61
+ // present now is this call's own.
62
+ async function add_or_take_back(directory: string, branch_name: string): Promise<void> {
63
+ const default_branch = await git_command.get_default_branch()
64
+
65
+ try {
66
+ await git_worktree.worktree_add(directory, branch_name, `origin/${default_branch}`)
67
+ } catch (error) {
68
+ if (await git_command.branch_exists(branch_name)) await git_worktree.branch_delete(branch_name)
69
+
70
+ throw error
71
+ }
72
+ }
73
+
54
74
  async function create(branch_name: string): Promise<string> {
55
75
  const directory = directory_for(process.cwd())
56
- const default_branch = await git_command.get_default_branch()
57
76
 
58
- await git_worktree.worktree_add(directory, branch_name, `origin/${default_branch}`)
77
+ await add_or_take_back(directory, branch_name)
59
78
  await install_or_clean(directory, branch_name)
60
79
 
61
80
  return directory
62
81
  }
63
82
 
83
+ // **A run that never reached its `finally` leaves its tree and branch behind** (joshuafolkken/kit#3058)
84
+ // — a Ctrl-C, a closed terminal — and every later release then fails on them until a person removes
85
+ // both by hand. They are cleared here only when clearing loses nothing: a release branch origin does
86
+ // not have and that carries no commit beyond `origin/<default>`. Uncommitted edits in the tree are
87
+ // the version bump the next run writes again, so they are not counted as a loss. A branch that was
88
+ // pushed, or that holds a commit, may be a release still in flight, so it is reported and kept. A
89
+ // live run with neither yet is excluded by the caller: `release_publish.publish` holds the release
90
+ // lock, so no other release is alive when this runs.
91
+ const RELEASE_BRANCH_PATTERN = 'release/v*'
92
+
93
+ async function leftover_reason(branch_name: string, base: string): Promise<string | undefined> {
94
+ const answer = await git_remote_branch.ask(branch_name)
95
+
96
+ if (answer !== 'absent') return `origin answers \`${answer}\` for it`
97
+
98
+ const ahead = await git_command.commit_count_beyond(base, branch_name)
99
+
100
+ return ahead === 0 ? undefined : `it holds ${String(ahead)} commit(s) not on \`${base}\``
101
+ }
102
+
103
+ function kept_message(directory: string, kept: ReadonlyArray<[string, string]>): string {
104
+ const lines = kept.map(([branch_name, reason]) => `- \`${branch_name}\`: ${reason}`)
105
+ const commands = [
106
+ `git worktree remove --force ${directory}`,
107
+ ...kept.map(([branch_name]) => `git branch -D ${branch_name}`),
108
+ ].join('; ')
109
+
110
+ return [
111
+ 'A previous `pnpm josh release` left release branches that may still be in use, so nothing was removed:',
112
+ ...lines,
113
+ `If no release is running, finish or close its pull request, delete the remote branch if it was pushed, then run \`${commands}\` and \`pnpm josh release\` again.`,
114
+ ].join('\n')
115
+ }
116
+
117
+ async function kept_branches(branches: ReadonlyArray<string>): Promise<Array<[string, string]>> {
118
+ const base = `origin/${await git_command.get_default_branch()}`
119
+ const reasons = await Promise.all(branches.map(async (name) => await leftover_reason(name, base)))
120
+
121
+ return branches.flatMap((name, index) => {
122
+ const reason = reasons[index]
123
+
124
+ return reason === undefined ? [] : [[name, reason] as [string, string]]
125
+ })
126
+ }
127
+
128
+ async function is_registered(directory: string): Promise<boolean> {
129
+ const listing = await git_worktree.worktree_list()
130
+
131
+ return listing.split('\n').includes(`worktree ${directory}`)
132
+ }
133
+
134
+ // The tree goes before the branches, for the reason `remove` gives. A directory git does not register
135
+ // is `lane_leftover.reclaim`'s to remove or refuse — the same test a lane's leftover path gets.
136
+ // Registration is asked before existence: a directory deleted by hand stays registered, and git
137
+ // refuses to delete a branch a registered tree holds until `worktree remove` drops the registration.
138
+ async function remove_leftover_tree(directory: string): Promise<void> {
139
+ if (await is_registered(directory)) await git_worktree.worktree_remove(directory)
140
+ else await lane_leftover.reclaim(directory)
141
+ }
142
+
143
+ async function clear_leftover(): Promise<void> {
144
+ const directory = directory_for(process.cwd())
145
+ const branches = await git_command.branch_names(RELEASE_BRANCH_PATTERN)
146
+ const kept = await kept_branches(branches)
147
+
148
+ if (kept.length > 0) throw new Error(kept_message(directory, kept))
149
+
150
+ await remove_leftover_tree(directory)
151
+
152
+ for (const branch_name of branches) {
153
+ // eslint-disable-next-line no-await-in-loop -- each `branch -D` takes the same ref lock
154
+ await git_worktree.branch_delete(branch_name)
155
+ }
156
+ }
157
+
64
158
  const release_worktree = {
65
159
  directory_for,
160
+ clear_leftover,
66
161
  create,
67
162
  remove,
68
163
  RELEASE_WORKTREE_NAME,
@@ -21,8 +21,9 @@ interface ReviewCheckout {
21
21
  head: string
22
22
  }
23
23
 
24
- // Three git spawns that do not depend on each other, so they are issued together rather than one
25
- // after another: the brief is printed in front of a review the run is waiting on.
24
+ // Three git reads that do not depend on each other, so they are issued together rather than one after
25
+ // another: the brief is printed in front of a review the run is waiting on. The root is a near-instant
26
+ // synchronous `rev-parse` (joshuafolkken/kit#3065); the branch and head still overlap.
26
27
  async function read_checkout(): Promise<ReviewCheckout> {
27
28
  const [root, branch, head] = await Promise.all([
28
29
  git_command.repository_root(),
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
- import { parseArgs } from 'node:util'
3
+ import { cli_flags } from '#scripts/lib/cli-flags'
4
4
  import { observation_ledger_home } from '#scripts/observations/observation-ledger-home'
5
5
  import { review_finding_ledger, type Finding } from './review-finding-ledger'
6
6
  import { review_record, type RecordVerdict } from './review-record'
@@ -71,21 +71,14 @@ function parse_findings(specs: ReadonlyArray<string>): ReadonlyArray<Finding> |
71
71
  }
72
72
 
73
73
  function parse_argv(argv: ReadonlyArray<string>): Parsed | undefined {
74
- try {
75
- const parsed = parseArgs({
76
- args: [...argv],
77
- options: OPTIONS,
78
- strict: true,
79
- allowPositionals: true,
80
- })
81
-
82
- return {
83
- check: parsed.values.check === true,
84
- issue: parsed.values.issue,
85
- positionals: parsed.positionals,
86
- }
87
- } catch {
88
- return undefined
74
+ const parsed = cli_flags.arguments_of(argv, OPTIONS)
75
+
76
+ if (parsed === undefined) return undefined
77
+
78
+ return {
79
+ check: parsed.values.check === true,
80
+ issue: parsed.values.issue,
81
+ positionals: parsed.positionals,
89
82
  }
90
83
  }
91
84
 
@@ -185,10 +185,11 @@ function decide(call: GuardedCall, run: GuardRun, can_record: boolean): boolean
185
185
  // is the half that makes the rule believable — the promise was kept and the interval was not.
186
186
  const EARLY_HEARTBEAT_REASON =
187
187
  '⛔ early heartbeat: this call arms a wait timer of its own, and the progress clock is not yours ' +
188
- 'to keep. `pnpm josh run:progress --wait` waits the interval out for you ' +
188
+ 'to keep. `pnpm josh run:progress --wait` keeps it for you ' +
189
189
  '(`JOSH_PROGRESS_INTERVAL_MINUTES`, or `josh.progress_interval_minutes` in `package.json`, ' +
190
- 'default 20 minutes), prints the five labelled lines and exits — start it in the background, relay ' +
191
- 'the lines it printed when it exits, and start the next one. Two timers armed at ' +
190
+ 'default 20 minutes): start it once in the background and it reports the five labelled lines to ' +
191
+ "the run's event stream on its own, without waking you — relay nothing, and start it again only " +
192
+ 'after it exits, which it does when newly runnable work arrives. Two timers armed at ' +
192
193
  'once is what produced reports 3–5 minutes apart on a 15-minute setting ' +
193
194
  '(joshuafolkken/kit#1570): a new one was armed on the turn a timer fired and again on the turn a ' +
194
195
  "child's completion woke the run, and `--mark` recorded each report without anything refusing the " +
@@ -62,7 +62,15 @@ const NOTIFY_INVOCATION = /josh\s+(?:notify|nf)\b/u
62
62
  // notify appears and a `--task-type confirmation` appears. That is looser than `is_confirmation_notify`
63
63
  // on purpose — the guard's safe direction is to read a real stop notification as present, so a run
64
64
  // that sent one is never told it did not.
65
+ //
66
+ // **A command that sent the Telegram itself prints this line instead** (joshuafolkken/kit#3099):
67
+ // `run:entry` notifies from inside its own stop, so no `josh notify` invocation reaches the transcript,
68
+ // and the line it prints is what the tail carries. One constant, printed there and matched here.
69
+ const COMMAND_NOTIFY_MARKER = 'stop notified: a confirmation Telegram was sent by this command'
70
+
65
71
  function mentions_confirmation_notify(text: string): boolean {
72
+ if (text.includes(COMMAND_NOTIFY_MARKER)) return true
73
+
66
74
  return NOTIFY_INVOCATION.test(text) && CONFIRMATION_TASK_TYPE.test(text)
67
75
  }
68
76
 
@@ -117,6 +125,7 @@ const LANE_PARK_REASON =
117
125
  'before it stops", and the park itself is `backlogrun-park.md` → "park and continue".'
118
126
 
119
127
  const lane_park = {
128
+ COMMAND_NOTIFY_MARKER,
120
129
  LANE_PARK_REASON,
121
130
  is_confirmation_notify,
122
131
  is_parking_child,
@@ -162,15 +162,18 @@ const HEADLESS_WAIT_MARKER = '⛔ headless parent:'
162
162
  const HEADLESS_REFUSAL_CAP = 3
163
163
 
164
164
  // The foreground waits as they appear in a transcript's tool-call input — the JSON `command` field, so
165
- // the refusal's own prose, which names the same commands, never matches.
166
- const HEADLESS_WAIT_CALLS = ['"command":"pnpm josh lane:await', '"command":"pnpm josh run:progress']
165
+ // the refusal's own prose, which names the same commands, never matches. `run:progress --wait` is not
166
+ // one (joshuafolkken/kit#3102): it streams its reports and runs until work arrives, so it is started in
167
+ // the background, where it lives as long as the turn the foreground wait keeps open.
168
+ const HEADLESS_WAIT_CALLS = ['"command":"pnpm josh lane:await']
167
169
 
168
170
  const HEADLESS_WAIT_BODY =
169
171
  ' this is a `claude -p` `backlogrun` parent (`JOSH_RUN_HEADLESS`) with lanes still ' +
170
172
  'in flight, and ending this turn ends the process — its background waits are killed with it and the ' +
171
173
  'lanes are left without a parent. Wait in the foreground instead: run `pnpm josh lane:await <N...>` ' +
172
- 'for the in-flight lanes (and `pnpm josh run:progress --wait` for the heartbeat) as foreground ' +
173
- 'commands within the tool timeout, act on what they report, and continue the loop. End the turn only ' +
174
+ 'for the in-flight lanes as a foreground command within the tool timeout (with ' +
175
+ '`pnpm josh run:progress --wait` in the background for the heartbeat, which streams its reports ' +
176
+ 'itself and lives while this turn does), act on what it reports, and continue the loop. End the turn only ' +
174
177
  'after `pnpm josh run:carry --cut` or `--end` (`backlogrun-progress.md` → "The parent keeps no clock ' +
175
178
  'of its own").'
176
179