@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.
- package/.claude/agents/investigator.md +17 -0
- package/.claude/settings.json +1 -0
- package/.claude/skills/workflow-commands/SKILL.md +1 -1
- package/.claude/skills/workflow-commands/background-commands.md +36 -85
- package/.claude/skills/workflow-commands/backlogrun-progress.md +31 -34
- package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -2
- package/.claude/skills/workflow-commands/delegation.md +5 -1
- package/.claude/skills/workflow-commands/fullrun.md +5 -4
- package/.claude/skills/workflow-commands/halfrun.md +5 -4
- package/.claude/skills/workflow-commands/latest-gate.md +7 -15
- package/.claude/skills/workflow-commands/pre-gate-cut.md +4 -8
- package/.claude/skills/workflow-commands/split-assessment.md +4 -18
- package/.github/actions/setup-node/action.yml +33 -0
- package/.github/actions/setup-pnpm/action.yml +4 -4
- package/.github/workflows/auto-tag.yml +2 -3
- package/.github/workflows/ci.yml +7 -8
- package/.github/workflows/dependabot-auto-merge.yml +1 -0
- package/.github/workflows/pr-classification.yml +1 -3
- package/.github/workflows/production.yml +1 -0
- package/.github/workflows/publish.yml +10 -9
- package/.github/workflows/sonar-qube.yml +17 -13
- package/.pnpmfile.mjs +29 -0
- package/AGENTS.md +4 -11
- package/CLAUDE.md +24 -17
- package/GEMINI.md +4 -13
- package/README.md +8 -3
- package/SECURITY.md +2 -0
- package/cspell/index.yaml +4 -0
- package/dist/CLAUDE.basic.md +6 -6
- package/dist/CLAUDE.md +24 -17
- package/dist/CLAUDE.static.md +6 -6
- package/dist/config-merge/index.js +33 -9
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-OIZUHPJI.js → chunk-FO2I7UT7.js} +1 -1
- package/dist/hooks/{chunk-NKOG3XAV.js → chunk-GDU26K3J.js} +11 -3
- package/dist/hooks/{chunk-O4LL4HTI.js → chunk-HANSKHES.js} +2 -2
- package/dist/hooks/{chunk-3C4YCCEF.js → chunk-HBKZLITF.js} +4 -4
- package/dist/hooks/{chunk-UD24EAIL.js → chunk-JKKTVDAG.js} +1984 -146
- package/dist/hooks/{chunk-M2XDCUKD.js → chunk-KBXZLMRH.js} +89 -1803
- package/dist/hooks/{chunk-IL27YTPH.js → chunk-KOKOAV6D.js} +2 -2
- package/dist/hooks/{chunk-7ZPFAVR7.js → chunk-LJBLGHEH.js} +59 -19
- package/dist/hooks/{chunk-E54KGFEQ.js → chunk-NFZGFGOR.js} +1 -1
- package/dist/hooks/{chunk-KHWHKJOM.js → chunk-RFWWUI5F.js} +4 -4
- package/dist/hooks/codex-hook-adapter.js +12 -47
- package/dist/hooks/duplicate-read-guard.js +5 -5
- package/dist/hooks/format-edited.js +2 -2
- package/dist/hooks/inputs.json +175 -173
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +9 -9
- package/dist/hooks/stop-guard.js +17 -12
- package/dist/josh.js +87 -31
- package/dist/version/index.js +36 -20
- package/eslint/rules/unicorn.js +3 -2
- package/package.json +23 -5
- package/pnpm-workspace.yaml +5 -7
- package/prompts/collaboration-workflow/glossary.md +17 -0
- package/prompts/collaboration-workflow/issue-template.md +1 -1
- package/prompts/collaboration-workflow/operating-rules.md +1 -1
- package/prompts/collaboration-workflow/principles.md +9 -0
- package/prompts/collaboration-workflow.md +1 -0
- package/scripts/backlog/backlog-arrival.ts +30 -10
- package/scripts/backlog/backlog-budget-cli.ts +15 -16
- package/scripts/backlog/backlog-offer-cli.ts +6 -14
- package/scripts/backlog/backlog-plan.ts +29 -18
- package/scripts/backlog/backlog-ready.ts +20 -7
- package/scripts/config-merge/parse-jsonc.ts +33 -3
- package/scripts/config-merge/patch-json-key.ts +13 -6
- package/scripts/cost-runtime/cost-cli.ts +3 -7
- package/scripts/delegation/delegation-policy.ts +9 -1
- package/scripts/delegation/investigation-guard.ts +1 -1
- package/scripts/delegation/investigation-reads.ts +105 -19
- package/scripts/doctor/doctor-consumer.ts +3 -5
- package/scripts/doctor/doctor-io.ts +9 -13
- package/scripts/document/backlogrun-parent-read-set.ts +12 -1
- package/scripts/document/capped-print.ts +74 -0
- package/scripts/document/document-byte-budget.ts +1 -0
- package/scripts/document/document-section-cli.ts +4 -3
- package/scripts/document/entry-read-budget.ts +13 -7
- package/scripts/document/entry-read-set.ts +32 -8
- package/scripts/document/lane-child-read-set.ts +7 -0
- package/scripts/document/read-set-point-of-use.ts +8 -3
- package/scripts/document/read-set-trim.ts +3 -1
- package/scripts/epic/epic-audit.ts +3 -18
- package/scripts/gate/scoped-green.ts +25 -14
- package/scripts/gh/git-gh-issue-list.ts +12 -11
- package/scripts/gh/git-gh-issue-write.ts +4 -1
- package/scripts/gh/git-gh-pr-auto-merge.ts +4 -1
- package/scripts/git/git-command.ts +6 -1
- package/scripts/git/git-spawn-sync.ts +65 -0
- package/scripts/git/git-spawn.ts +5 -4
- package/scripts/git/stash/stash-pop-args.ts +1 -6
- package/scripts/init/claude-kit-only-settings.ts +29 -0
- package/scripts/init/init-copy-content.ts +8 -2
- package/scripts/init/init-file-action.ts +54 -0
- package/scripts/init/init-logic-migrate.ts +35 -17
- package/scripts/init/init-logic-secretlint.ts +1 -1
- package/scripts/init/init-logic.ts +6 -22
- package/scripts/init/init.ts +11 -68
- package/scripts/init/initial-commit.ts +24 -0
- package/scripts/init/kit-written-paths.ts +2 -0
- package/scripts/init/project-profile.ts +3 -6
- package/scripts/init/start-exec.ts +16 -3
- package/scripts/init/start-steps.ts +10 -13
- package/scripts/issue/defect-rate-cli.ts +61 -20
- package/scripts/issue/issue-backlinks.ts +7 -14
- package/scripts/issue/issue-cite.ts +12 -6
- package/scripts/issue/issue-file.ts +5 -12
- package/scripts/issue/issue-read-cli.ts +1 -0
- package/scripts/issue/issue-report-failures.ts +6 -2
- package/scripts/issue/issue-state-cli.ts +1 -1
- package/scripts/issue/markdown-section.ts +21 -6
- package/scripts/issue/measure-rerun-cli.ts +73 -14
- package/scripts/josh/josh-commands-lane.ts +1 -1
- package/scripts/josh/josh-commands-project.ts +8 -1
- package/scripts/josh/josh-commands-workflow.ts +1 -1
- package/scripts/josh/run-github-prerequisite.ts +3 -6
- package/scripts/lane/lane-await.ts +89 -15
- package/scripts/lane/lane-cli.ts +11 -13
- package/scripts/lane/lane-close.ts +4 -1
- package/scripts/lane/lane-launch-cli.ts +10 -16
- package/scripts/lane/openai-review-broker.ts +4 -1
- package/scripts/lib/cli-flags.ts +31 -2
- package/scripts/lib/error-message.ts +12 -1
- package/scripts/lines/line-targets.ts +0 -11
- package/scripts/lines/lines-command.ts +5 -3
- package/scripts/propagate/propagate-git.ts +3 -8
- package/scripts/refactor/refactor-scan.ts +2 -1
- package/scripts/registry-migration/migrate-logic.ts +28 -19
- package/scripts/registry-migration/migrate.ts +6 -5
- package/scripts/release/github-release-cli.ts +14 -3
- package/scripts/release/github-release-environment.ts +1 -0
- package/scripts/release/github-release.ts +103 -17
- package/scripts/release/release-publish.ts +36 -2
- package/scripts/release/release-worktree.ts +97 -2
- package/scripts/review/review-checkout.ts +3 -2
- package/scripts/review/review-record-cli.ts +9 -16
- package/scripts/rules/early-heartbeat.ts +4 -3
- package/scripts/rules/lane-park.ts +9 -0
- package/scripts/rules/stop-rules.ts +7 -4
- package/scripts/run/run-carry-args.ts +8 -12
- package/scripts/run/run-carry-cli.ts +3 -1
- package/scripts/run/run-carry-conversation.ts +94 -0
- package/scripts/run/run-carry-reclaim.ts +37 -0
- package/scripts/run/run-carry.ts +21 -5
- package/scripts/run/run-cut-args.ts +1 -6
- package/scripts/run/run-ending-cli.ts +1 -5
- package/scripts/run/run-entry-cli.ts +40 -9
- package/scripts/run/run-entry-stop.ts +104 -0
- package/scripts/run/run-event-stream-emit.ts +7 -2
- package/scripts/run/run-hold.ts +4 -1
- package/scripts/run/run-liveness-cli.ts +1 -5
- package/scripts/run/run-merge-cli.ts +2 -10
- package/scripts/run/run-merge-steps.ts +4 -1
- package/scripts/run/run-parent-cut-hook.ts +4 -1
- package/scripts/run/run-prep-cli.ts +4 -1
- package/scripts/run/run-progress-args.ts +20 -4
- package/scripts/run/run-progress-cli.ts +31 -29
- package/scripts/run/run-progress-clock.ts +4 -1
- package/scripts/run/run-ship-cli.ts +8 -16
- package/scripts/run/run-ship-probe.ts +4 -1
- package/scripts/run/run-status-cli.ts +2 -15
- package/scripts/run/run-tidy-lanes.ts +4 -1
- package/scripts/run/run-wake-cli.ts +2 -10
- package/scripts/run/run-watcher-guard.ts +9 -5
- package/scripts/safe-chain/pack-hook.ts +92 -0
- package/scripts/sync/sync-ai-files.ts +4 -0
- package/scripts/test/test-declared-changed.ts +13 -16
- package/scripts/test/test-declared.ts +3 -7
- package/scripts/test/test-related.ts +1 -6
- package/scripts/test/test-unit-guard.ts +21 -7
- package/scripts/time-runtime/time-spans.ts +4 -0
- package/scripts/version/version-commands.ts +11 -1
- package/templates/CLAUDE.basic.md +6 -6
- package/templates/workflows/ci.yml +16 -82
- package/templates/workflows/dependabot-auto-merge.yml +1 -0
- package/templates/workflows/github-release.yml +14 -28
- 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
|
-
|
|
119
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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({
|
|
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
|
|
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<
|
|
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
|
-
|
|
160
|
-
|
|
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
|
|
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
|
|
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.
|
|
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(
|
|
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,
|
|
280
|
+
return { tags, budget, floor: start_tag, fallback: async () => await previous_tag(client) }
|
|
205
281
|
}
|
|
206
282
|
|
|
207
|
-
return { tags,
|
|
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
|
|
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
|
|
136
|
+
async function publish_locked(plan: ReleasePlan): Promise<number> {
|
|
125
137
|
const branch_name = branch_name_for(plan.next_version)
|
|
126
138
|
|
|
127
|
-
await
|
|
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
|
|
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
|
|
25
|
-
//
|
|
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 {
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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`
|
|
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)
|
|
191
|
-
|
|
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
|
-
|
|
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
|
|
173
|
-
'
|
|
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
|
|