@joshuafolkken/kit 1.1229.0 → 1.1240.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-lanes.md +1 -1
  2. package/.claude/skills/workflow-commands/backlogrun-progress.md +2 -2
  3. package/.claude/skills/workflow-commands/followup.md +5 -0
  4. package/.claude/skills/workflow-commands/kickoff.md +1 -0
  5. package/.claude/skills/workflow-commands/latest-gate.md +1 -1
  6. package/.github/workflows/publish.yml +2 -0
  7. package/README.md +30 -31
  8. package/dist/hooks/batch-guard.js +4 -4
  9. package/dist/hooks/{chunk-JUDKDCDP.js → chunk-4TZVB44A.js} +8 -8
  10. package/dist/hooks/{chunk-RNWHN5BP.js → chunk-F3ELQ2AF.js} +3 -3
  11. package/dist/hooks/{chunk-VC3PZPZO.js → chunk-GPTFMK5E.js} +1 -1
  12. package/dist/hooks/{chunk-FIVDHQGT.js → chunk-KHGB5S3Y.js} +1 -1
  13. package/dist/hooks/{chunk-NV2JRZZO.js → chunk-KHX4U3BG.js} +17 -2
  14. package/dist/hooks/{chunk-F56MFS4B.js → chunk-KRBERBBI.js} +3 -3
  15. package/dist/hooks/{chunk-TQNPDZNH.js → chunk-QVSWU24O.js} +4 -4
  16. package/dist/hooks/{chunk-55HHG5PX.js → chunk-WK2XSHSI.js} +284 -63
  17. package/dist/hooks/{chunk-5VMKCCC4.js → chunk-XZYKEM6J.js} +2 -2
  18. package/dist/hooks/{chunk-6HJJXRBX.js → chunk-ZQGD4GBY.js} +1 -1
  19. package/dist/hooks/codex-hook-adapter.js +10 -10
  20. package/dist/hooks/duplicate-read-guard.js +5 -5
  21. package/dist/hooks/format-edited.js +2 -2
  22. package/dist/hooks/inputs.json +172 -170
  23. package/dist/hooks/investigation-guard.js +5 -5
  24. package/dist/hooks/pretool-guard.js +9 -9
  25. package/dist/hooks/stop-guard.js +3 -17
  26. package/dist/josh.js +279 -63
  27. package/package.json +1 -1
  28. package/prompts/collaboration-workflow/issue-template.md +1 -0
  29. package/scripts/gate/core-admission.ts +162 -0
  30. package/scripts/gate/core-budget.ts +121 -67
  31. package/scripts/gate/gate-plan.ts +35 -2
  32. package/scripts/gate/gate-report.ts +28 -9
  33. package/scripts/gate/machine-capacity.ts +175 -0
  34. package/scripts/gate/verification-gate.ts +7 -2
  35. package/scripts/init/init.ts +5 -1
  36. package/scripts/init/package-manager-pin.ts +35 -0
  37. package/scripts/issue/issue-auto-ok.ts +31 -6
  38. package/scripts/issue/issue-file-cli.ts +31 -8
  39. package/scripts/issue/issue-file.ts +5 -0
  40. package/scripts/issue/issue-labels.ts +13 -0
  41. package/scripts/issue/issue-release-cli.ts +155 -0
  42. package/scripts/issue/issue-release.ts +58 -0
  43. package/scripts/josh/josh-command-types.ts +3 -0
  44. package/scripts/josh/josh-commands-development.ts +8 -0
  45. package/scripts/josh/josh-commands-issue.ts +8 -1
  46. package/scripts/josh/josh-commands-lane.ts +2 -1
  47. package/scripts/josh/josh-commands-maintenance.ts +2 -2
  48. package/scripts/josh/josh-logic.ts +4 -3
  49. package/scripts/lane/lane-cli.ts +9 -1
  50. package/scripts/lane/lane-leftover.ts +43 -29
  51. package/scripts/lane/lane-open.ts +5 -5
  52. package/scripts/lane/lane-paths.ts +13 -0
  53. package/scripts/lane/lane-registry.ts +11 -0
  54. package/scripts/lane/lane-stray.ts +140 -0
  55. package/scripts/release/github-release-environment.ts +1 -0
  56. package/scripts/release/github-release-runs.ts +115 -0
  57. package/scripts/release/github-release.ts +13 -67
  58. package/scripts/run/cut/run-cut-cli.ts +16 -3
  59. package/scripts/run/event/run-event-render.ts +1 -0
  60. package/scripts/run/event/run-event-stream.ts +4 -0
  61. package/scripts/run/run-step.ts +5 -2
  62. package/scripts/run/wake/run-wake-cli.ts +2 -3
  63. package/scripts/run/wake/run-wake-describe.ts +56 -5
  64. package/scripts/run/wake/run-wake-loop.ts +5 -13
  65. package/scripts/run/wake/run-wake.ts +15 -64
  66. package/scripts/version/latest-corepack.ts +109 -78
  67. package/templates/workflows/github-release.yml +4 -0
@@ -0,0 +1,58 @@
1
+ import { DEPTH_0_LABEL, RELEASE_LABEL } from './issue-labels'
2
+
3
+ // The decisions behind `josh issue:release` (joshuafolkken/kit#3360), kept apart from the command
4
+ // that reads the network so each is a pure answer a test can pin. An Issue whose change reaches a
5
+ // consumer only once published used to surface the release only at the end of `followup`'s report;
6
+ // it is now linked to the repository's release Issue, which it blocks until it merges.
7
+
8
+ interface ListingRow {
9
+ number: number
10
+ }
11
+
12
+ function is_listing_row(value: unknown): value is ListingRow {
13
+ if (typeof value !== 'object' || value === null) return false
14
+
15
+ return Number.isSafeInteger(Reflect.get(value, 'number'))
16
+ }
17
+
18
+ // The open release Issue a listing of `release`-labelled Issues names, or `undefined` when it names
19
+ // none or is unreadable. One release publishes every merge since the last, so a repository holds one
20
+ // release Issue at a time; were a person to open a second, the oldest is the one the next release
21
+ // closes, so it is the one every new blocker joins.
22
+ function open_release_of(json: string): number | undefined {
23
+ try {
24
+ const parsed: unknown = JSON.parse(json)
25
+ const rows = Array.isArray(parsed) ? parsed.filter((row) => is_listing_row(row)) : []
26
+
27
+ return rows.length === 0 ? undefined : Math.min(...rows.map((row) => row.number))
28
+ } catch {
29
+ return undefined
30
+ }
31
+ }
32
+
33
+ function title_of(repo: string): string {
34
+ return `Release ${repo} (next version)`
35
+ }
36
+
37
+ // Plain English with no classification declaration: the Issue is not filed through `issue:file`, and
38
+ // its labels are `RELEASE_LABELS` rather than read off the body.
39
+ function body_of(repo: string): string {
40
+ return [
41
+ `Tracks the next release of ${repo}.`,
42
+ '',
43
+ 'Every Issue this one is blocked by changes something a consumer can use only once it is published. Once they have merged, a person runs `pnpm josh release` in the primary checkout.',
44
+ '',
45
+ 'Close this Issue once that release is published: the next blocker files a new one, so an open release Issue names only unreleased work.',
46
+ '',
47
+ '`auto-ok` is never applied to this Issue by a run; a person may apply it.',
48
+ '',
49
+ ].join('\n')
50
+ }
51
+
52
+ // The release label and a depth: the subject of a release is what a consumer touches.
53
+ // Never `auto-ok`.
54
+ const RELEASE_LABELS: ReadonlyArray<string> = [RELEASE_LABEL, DEPTH_0_LABEL]
55
+
56
+ const issue_release = { RELEASE_LABELS, open_release_of, title_of, body_of }
57
+
58
+ export { issue_release }
@@ -71,6 +71,9 @@ interface CommandEntry {
71
71
  // under a second, so a wait for room on the machine would stall every edit for the length of
72
72
  // another lane's check.
73
73
  core_weight?: number | (() => number)
74
+ // The memory the command's tools hold, claimed with its cores (joshuafolkken/kit#3371); read like
75
+ // `core_weight`, and none declared is none waited for.
76
+ memory_mb?: number | (() => number)
74
77
  }
75
78
 
76
79
  // The name `josh gate` registers under. It lives here rather than in `verification-gate.ts` so a
@@ -28,6 +28,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
28
28
  category: 'Development',
29
29
  reference: ['', 'developer', ['processes']],
30
30
  core_weight: core_budget.CORE_WEIGHTS.lint,
31
+ memory_mb: core_budget.MEMORY_MB.lint,
31
32
  },
32
33
  'lint:related': {
33
34
  script: 'scripts/lint/lint-related.ts',
@@ -35,6 +36,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
35
36
  category: 'Development',
36
37
  reference: [FILE_ARGUMENTS, 'developer', ['processes']],
37
38
  core_weight: core_budget.CORE_WEIGHTS.lint,
39
+ memory_mb: core_budget.MEMORY_MB.lint,
38
40
  },
39
41
  lines: {
40
42
  script: 'scripts/lines/lines-command.ts',
@@ -42,6 +44,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
42
44
  category: 'Development',
43
45
  reference: [REQUIRED_FILE_ARGUMENTS, 'developer', ['none']],
44
46
  core_weight: core_budget.CORE_WEIGHTS.eslint_scan,
47
+ memory_mb: core_budget.MEMORY_MB.eslint_scan,
45
48
  },
46
49
  'refactor:scan': {
47
50
  script: 'scripts/refactor/refactor-scan-cli.ts',
@@ -50,6 +53,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
50
53
  category: 'Development',
51
54
  reference: ['', 'automation', ['processes']],
52
55
  core_weight: core_budget.CORE_WEIGHTS.eslint_scan,
56
+ memory_mb: core_budget.MEMORY_MB.eslint_scan,
53
57
  },
54
58
  bytes: {
55
59
  script: 'scripts/lines/bytes-command.ts',
@@ -137,6 +141,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
137
141
  category: 'Development',
138
142
  reference: ['', 'developer', ['processes']],
139
143
  core_weight: core_budget.CORE_WEIGHTS.spell_check,
144
+ memory_mb: core_budget.MEMORY_MB.spell_check,
140
145
  },
141
146
  behavior: {
142
147
  script: 'scripts/behavior/behavior-cli.ts',
@@ -159,6 +164,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
159
164
  category: 'Development',
160
165
  reference: [FILTER_ARGUMENTS, 'developer', ['processes']],
161
166
  core_weight: gate_plan.direct_unit_weight,
167
+ memory_mb: gate_plan.direct_unit_memory,
162
168
  },
163
169
  'test:related': {
164
170
  script: 'scripts/test/test-related.ts',
@@ -166,6 +172,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
166
172
  category: 'Development',
167
173
  reference: [FILE_ARGUMENTS, 'developer', ['processes']],
168
174
  core_weight: gate_plan.direct_unit_weight,
175
+ memory_mb: gate_plan.direct_unit_memory,
169
176
  },
170
177
  'test:declared': {
171
178
  script: 'scripts/test/test-declared.ts',
@@ -213,6 +220,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
213
220
  category: 'Development',
214
221
  reference: ['[arguments...]', 'developer', ['processes']],
215
222
  core_weight: core_budget.CORE_WEIGHTS.type_check,
223
+ memory_mb: core_budget.MEMORY_MB.type_check,
216
224
  },
217
225
  port: {
218
226
  script: 'scripts/ports/port-command.ts',
@@ -69,11 +69,18 @@ const ISSUE_COMMANDS: Record<string, CommandEntry> = {
69
69
  'File an Issue with every filing step: lint, Origin, WIP cap, duplicate scout, labels, then epic:bundle',
70
70
  category: 'AI tools',
71
71
  reference: [
72
- '<title> --body-file <path> --depth <0|1|2> [--route <route>] [--label <name>] [--repo <owner/repo>] [--distinct <N,…>] [--over-cap] [--no-auto-ok]',
72
+ '<title> --body-file <path> --depth <0|1|2> [--route <route>] [--label <name>] [--repo <owner/repo>] [--distinct <N,…>] [--over-cap] [--no-auto-ok] [--release]',
73
73
  'automation',
74
74
  ['network'],
75
75
  ],
76
76
  },
77
+ 'issue:release': {
78
+ script: 'scripts/issue/issue-release-cli.ts',
79
+ description:
80
+ "Link an Issue to the repository's open release Issue as its blocker, filing one when none is open",
81
+ category: 'AI tools',
82
+ reference: ['<issue>', 'automation', ['network']],
83
+ },
77
84
  }
78
85
  /* eslint-enable @typescript-eslint/naming-convention */
79
86
 
@@ -39,7 +39,8 @@ const LANE_COMMANDS: Record<string, CommandEntry> = {
39
39
  },
40
40
  'lane:prune': {
41
41
  ...LANE_ARGUMENTS,
42
- description: 'Close every lane an interruption left without its work tree',
42
+ description:
43
+ 'Close every lane an interruption left without its work tree, then sweep unregistered leftovers under the lanes root',
43
44
  category: 'AI tools',
44
45
  reference: ['', 'automation', ['files', 'git']],
45
46
  default_script_arguments: ['prune'],
@@ -45,9 +45,9 @@ const MAINTENANCE_COMMANDS: Record<string, CommandEntry> = {
45
45
  shell: [
46
46
  'sh',
47
47
  '-c',
48
- 'export NODE_AUTH_TOKEN=$(gh auth token) && pnpm josh latest:guard && pnpm josh latest:corepack && pnpm josh latest:update && pnpm josh ranges && pnpm josh audit && pnpm josh latest:scope --record',
48
+ 'export NODE_AUTH_TOKEN=$(gh auth token) && pnpm josh latest:guard && pnpm josh latest:update && pnpm josh ranges && pnpm josh audit && pnpm josh latest:corepack && pnpm josh latest:scope --record',
49
49
  ],
50
- description: 'Update pnpm, dependencies, and run security audit',
50
+ description: 'Update dependencies, run security audit, then update pnpm',
51
51
  category: 'Maintenance',
52
52
  reference: ['', 'developer', ['files', 'network', 'processes']],
53
53
  argument_targets: ['latest:corepack', 'latest:update', 'audit'],
@@ -279,8 +279,8 @@ async function run_entry(
279
279
  return await run_script_entry(entry, subcommand_arguments)
280
280
  }
281
281
 
282
- function command_weight(entry: CommandEntry): number | undefined {
283
- return typeof entry.core_weight === 'function' ? entry.core_weight() : entry.core_weight
282
+ function declared(value: number | (() => number) | undefined): number | undefined {
283
+ return typeof value === 'function' ? value() : value
284
284
  }
285
285
 
286
286
  // **Every command passes through here, so this is where a declared weight is reserved**
@@ -291,13 +291,14 @@ async function dispatch_entry(
291
291
  entry: CommandEntry,
292
292
  subcommand_arguments: Array<string>,
293
293
  ): Promise<number> {
294
- const weight = command_weight(entry)
294
+ const weight = declared(entry.core_weight)
295
295
 
296
296
  if (weight === undefined) return await run_entry(entry, subcommand_arguments)
297
297
 
298
298
  return await core_budget.with_command_reservation(
299
299
  weight,
300
300
  async () => await run_entry(entry, subcommand_arguments),
301
+ { memory_mb: declared(entry.memory_mb) },
301
302
  )
302
303
  }
303
304
 
@@ -16,6 +16,7 @@ import { lane_output, type ReadOutcome, type RecordOutcome } from './lane-output
16
16
  import { lane_registry, type LaneInfo } from './lane-registry'
17
17
  import { lane_report } from './lane-report'
18
18
  import { lane_seed_policy } from './lane-seed'
19
+ import { lane_stray } from './lane-stray'
19
20
 
20
21
  // `josh lane:open` / `lane:close` / `lane:list` / `lane:prune` — the lane's whole lifecycle
21
22
  // (joshuafolkken/kit#1490).
@@ -268,7 +269,14 @@ async function list_command(rest: ReadonlyArray<string>): Promise<number> {
268
269
  async function prune_command(rest: ReadonlyArray<string>): Promise<number> {
269
270
  if (rest.length > 0) return report_usage()
270
271
 
271
- return report_sweep(await lane_close.prune_lanes())
272
+ const code = report_sweep(await lane_close.prune_lanes())
273
+
274
+ // After the closes, so a lane whose close left its directory behind is swept in the same run. A
275
+ // kept stray is reported, never a failure: it waits on a person, and a non-zero exit would stop
276
+ // every `backlogrun` preparation until one came (joshuafolkken/kit#3370).
277
+ await lane_stray.sweep_and_report()
278
+
279
+ return code
272
280
  }
273
281
 
274
282
  // The recorded path goes to standard output on both arms, so
@@ -1,7 +1,9 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, rmSync } from 'node:fs'
3
3
  import path from 'node:path'
4
+ import { ENV_FILE_NAME } from '#ports'
4
5
  import { git_spawn } from '#scripts/git/git-spawn'
6
+ import { IGNORED_CACHE_FILES } from '#scripts/josh/josh-command-types'
5
7
 
6
8
  // A lane directory git no longer registers as a work tree (joshuafolkken/kit#2857).
7
9
  //
@@ -11,31 +13,38 @@ import { git_spawn } from '#scripts/git/git-spawn'
11
13
  // here before it adds the tree.
12
14
  //
13
15
  // **The test for removing it is that removing it loses nothing**, not a list of names. Every file in
14
- // it has to be one the repository already holds with the same content, so it is a checkout git would
15
- // write again; the one exception is the `.git` file, a pointer to the registration that is gone. A
16
- // list of generated names would break the next time a file is generated, and would delete whatever
17
- // shares one of those names.
18
-
19
- // `git ls-tree -r -z`: `<mode> <type> <object>\t<path>`, entries separated by NUL — the `-z` form
20
- // leaves a path with an unusual character unquoted, so it can be compared to the path on disk.
21
- const TREE_ENTRY_PATTERN = /^\d+ blob (?<object>[\da-f]+)\t(?<path>.+)$/su
16
+ // it has to hold content the repository's history already stores — a blob some ref reaches — so git
17
+ // can write it again; the one exception is the `.git` file, a pointer to the registration that is
18
+ // gone. **Any commit's content, not only `HEAD`'s at the same path** (joshuafolkken/kit#3370): a
19
+ // leftover is a checkout of whatever commit its lane was on, and measured against `HEAD` the
20
+ // leftovers that issue found differed in 167 to 1,431 files each while every one of them was in the
21
+ // history. A list of generated names would break the next time a file is generated, and would delete
22
+ // whatever shares one of those names.
23
+ //
24
+ // **The one list of names is what `lane:open` and the gate regenerate**, and only at the top level:
25
+ // the install's `node_modules`, the build's `dist`, the `.env` `lane:open` writes from the root's, and
26
+ // the gate's caches. A lane carries them on top of its checkout, none is in the history, and walking
27
+ // `node_modules` alone would hash tens of thousands of files.
28
+ const REGENERATED_PATHS: ReadonlySet<string> = new Set([
29
+ 'node_modules',
30
+ 'dist',
31
+ ENV_FILE_NAME,
32
+ ...IGNORED_CACHE_FILES,
33
+ ])
22
34
  const GIT_POINTER = '.git'
23
35
  const GITDIR_PREFIX = 'gitdir: '
24
36
  // The foreign paths a refusal names before it summarizes the rest as a count.
25
37
  const NAMED_PATH_LIMIT = 10
26
38
 
27
- // One blob entry as `[path, object id]`; a tree, a commit (a submodule) or the trailing empty entry is
28
- // none.
29
- function tree_entry(entry: string): Array<[string, string]> {
30
- const { path: entry_path, object } = TREE_ENTRY_PATTERN.exec(entry)?.groups ?? {}
31
-
32
- if (entry_path === undefined || object === undefined) return []
33
-
34
- return [[entry_path, object]]
39
+ // `git rev-list --objects --all`: one `<object>[ <path>]` line per object any ref reaches — commits,
40
+ // trees and blobs alike, which costs nothing here, because only a blob id can equal a file's.
41
+ function parse_objects(listing: string): Set<string> {
42
+ return new Set(listing.split('\n').map((line) => line.split(' ', 1)[0] ?? ''))
35
43
  }
36
44
 
37
- function parse_tree(listing: string): Map<string, string> {
38
- return new Map(listing.split('\0').flatMap((entry) => tree_entry(entry)))
45
+ /** Every object id the repository's refs reach — the content a leftover can be restored from. */
46
+ async function recoverable_objects(): Promise<Set<string>> {
47
+ return parse_objects(await git_spawn.read(['rev-list', '--objects', '--all']))
39
48
  }
40
49
 
41
50
  // The object id git gives this file — a symbolic link's is that of its target string, as git stores it.
@@ -63,9 +72,14 @@ function walk_files(directory: string, relative: string): Array<string> {
63
72
  })
64
73
  }
65
74
 
66
- // Every file under `directory` as a repository path, symbolic links included and never followed.
75
+ // Every file under `directory` as a repository path, symbolic links included and never followed —
76
+ // less the regenerated top-level entries, which are neither walked nor listed.
67
77
  function list_files(directory: string): Array<string> {
68
- return walk_files(directory, '')
78
+ return readdirSync(directory)
79
+ .filter((name) => !REGENERATED_PATHS.has(name))
80
+ .flatMap((name) =>
81
+ lstatSync(path.join(directory, name)).isDirectory() ? walk_files(directory, name) : name,
82
+ )
69
83
  }
70
84
 
71
85
  // The `.git` file is disposable only while the registration it points to is gone: one that still
@@ -85,25 +99,25 @@ function is_dangling_pointer(directory: string): boolean {
85
99
  function is_reclaimable_file(
86
100
  directory: string,
87
101
  relative: string,
88
- tracked: ReadonlyMap<string, string>,
102
+ recoverable: ReadonlySet<string>,
89
103
  ): boolean {
90
104
  if (relative === GIT_POINTER) return is_dangling_pointer(directory)
91
105
 
92
- return tracked.get(relative) === blob_id(path.join(directory, relative))
106
+ return recoverable.has(blob_id(path.join(directory, relative)))
93
107
  }
94
108
 
95
109
  /**
96
110
  * The paths under `directory` whose removal would lose something — empty when it holds nothing but
97
- * the repository's own files and the `.git` pointer. A `.git` directory is a repository of its own,
98
- * so it is foreign as a whole rather than walked.
111
+ * content the history stores, the regenerated entries and the `.git` pointer. A `.git` directory is a
112
+ * repository of its own, so it is foreign as a whole rather than walked.
99
113
  */
100
- function foreign_paths(directory: string, tracked: ReadonlyMap<string, string>): Array<string> {
114
+ function foreign_paths(directory: string, recoverable: ReadonlySet<string>): Array<string> {
101
115
  const pointer = lstatSync(path.join(directory, GIT_POINTER), { throwIfNoEntry: false })
102
116
 
103
117
  if (pointer?.isDirectory() === true) return [GIT_POINTER]
104
118
 
105
119
  return list_files(directory).filter(
106
- (relative) => !is_reclaimable_file(directory, relative, tracked),
120
+ (relative) => !is_reclaimable_file(directory, relative, recoverable),
107
121
  )
108
122
  }
109
123
 
@@ -125,8 +139,7 @@ function refusal(directory: string, foreign: ReadonlyArray<string>): Error {
125
139
  async function reclaim(directory: string): Promise<void> {
126
140
  if (!existsSync(directory)) return
127
141
 
128
- const tracked = parse_tree(await git_spawn.read(['ls-tree', '-r', '-z', '--full-tree', 'HEAD']))
129
- const foreign = foreign_paths(directory, tracked)
142
+ const foreign = foreign_paths(directory, await recoverable_objects())
130
143
 
131
144
  if (foreign.length > 0) throw refusal(directory, foreign)
132
145
 
@@ -136,8 +149,9 @@ async function reclaim(directory: string): Promise<void> {
136
149
  const lane_leftover = {
137
150
  blob_id,
138
151
  foreign_paths,
139
- parse_tree,
152
+ parse_objects,
140
153
  reclaim,
154
+ recoverable_objects,
141
155
  }
142
156
 
143
157
  export { lane_leftover }
@@ -37,14 +37,13 @@ type OpenOutcome =
37
37
 
38
38
  const FULL_OUTCOME: OpenOutcome = { kind: 'full' }
39
39
 
40
- // The seat a lane will take is claimed by exclusively creating its lock directory here, under the
41
- // lanes root and clear of the lanes themselves (which are numbered, never dot-prefixed). The claim
40
+ // The seat a lane will take is claimed by exclusively creating its lock directory under
41
+ // `lane_paths.SEAT_LOCK_DIR`, in the lanes root and clear of the lanes themselves (which are numbered, never dot-prefixed). The claim
42
42
  // is what closes the gap between reading the free seats and writing the lane's `.env`: two
43
43
  // `lane:open` runs that both saw seat 1 free cannot both create the same lock, so the loser steps to
44
44
  // the next free seat (joshuafolkken/kit#1494). The lock is released once the lane's `.env` is on
45
45
  // disk and the seat is discoverable — or on any failure — so only a hard crash mid-open can strand
46
46
  // one, which removing the lanes-root `.seat-locks` directory clears.
47
- const SEAT_LOCK_DIR = '.seat-locks'
48
47
  const SEAT_LOCK_PREFIX = 'seat-'
49
48
 
50
49
  // A root with no `.env` is the normal state of a fresh clone and of CI, and it means seed 0 — the
@@ -79,7 +78,7 @@ function build_lane(
79
78
  }
80
79
 
81
80
  function seat_lock_path(root: string, seat: number): string {
82
- return path.join(root, SEAT_LOCK_DIR, `${SEAT_LOCK_PREFIX}${String(seat)}`)
81
+ return path.join(root, lane_paths.SEAT_LOCK_DIR, `${SEAT_LOCK_PREFIX}${String(seat)}`)
83
82
  }
84
83
 
85
84
  // Release the seat lock — idempotent (`force`), so calling it after the `.env` write and again in the
@@ -93,7 +92,7 @@ function release_seat_lock(lock: string): void {
93
92
  // being created. A missing directory is none held — the normal state when no open is running.
94
93
  function open_in_flight_count(root: string): number {
95
94
  try {
96
- return readdirSync(path.join(root, SEAT_LOCK_DIR)).filter((entry) =>
95
+ return readdirSync(path.join(root, lane_paths.SEAT_LOCK_DIR)).filter((entry) =>
97
96
  entry.startsWith(SEAT_LOCK_PREFIX),
98
97
  ).length
99
98
  } catch {
@@ -279,6 +278,7 @@ async function open_lane(issue: string): Promise<OpenOutcome> {
279
278
  }
280
279
 
281
280
  const lane_open = {
281
+ open_in_flight_count,
282
282
  open_lane,
283
283
  }
284
284
 
@@ -82,10 +82,23 @@ function lane_branch(issue: string): string {
82
82
  return `${issue}${LANE_BRANCH_SUFFIX}`
83
83
  }
84
84
 
85
+ // The lanes root's own bookkeeping, beside the lanes and never one of them: `lane:open` claims a seat
86
+ // by creating a lock directory under it (joshuafolkken/kit#1494).
87
+ const SEAT_LOCK_DIR = '.seat-locks'
88
+
89
+ // A name the lane machinery writes into a lanes root: a lane's issue number, or a lanes root of its
90
+ // own — the nested one a lane opened from inside a lane once put there. Anything else in the root
91
+ // was put there by someone else (joshuafolkken/kit#3370).
92
+ function is_lane_entry(name: string): boolean {
93
+ return LANE_ISSUE_PATTERN.test(name) || DEFAULT_LANE_ROOT_PATTERN.test(name)
94
+ }
95
+
85
96
  const lane_paths = {
86
97
  LANE_BRANCH_SUFFIX,
87
98
  LANE_ROOT_KEY,
99
+ SEAT_LOCK_DIR,
88
100
  default_lane_root,
101
+ is_lane_entry,
89
102
  lane_branch,
90
103
  lane_directory,
91
104
  lane_issue_of,
@@ -217,6 +217,16 @@ async function list_lanes(): Promise<Array<LaneInfo>> {
217
217
  return lanes.toSorted((left, right) => Number(left.issue) - Number(right.issue))
218
218
  }
219
219
 
220
+ /**
221
+ * Every directory git registers as a work tree, lane or not — what a sweep of the lanes root must
222
+ * never touch, including a work tree at a lane's path whose branch does not read as a lane's.
223
+ */
224
+ async function registered_directories(): Promise<Array<string>> {
225
+ const blocks = await worktree_blocks()
226
+
227
+ return blocks.flatMap((block) => line_value(block.split('\n'), WORKTREE_PREFIX) ?? [])
228
+ }
229
+
220
230
  // The lanes a parent is still waiting on. A stranded lane has no child working in it, so it is not
221
231
  // in-flight — the one definition the watcher guard and the headless stop rule both read.
222
232
  async function has_lanes_in_flight(): Promise<boolean> {
@@ -259,6 +269,7 @@ const lane_registry = {
259
269
  main_repository_root,
260
270
  parse_block,
261
271
  read_environment,
272
+ registered_directories,
262
273
  unreadable_lanes,
263
274
  used_seats,
264
275
  }
@@ -0,0 +1,140 @@
1
+ import { existsSync, lstatSync, readdirSync, rmSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { error_text } from '#scripts/lib/error-message'
4
+ import { lane_leftover } from './lane-leftover'
5
+ import { lane_open } from './lane-open'
6
+ import { lane_paths } from './lane-paths'
7
+ import { lane_registry } from './lane-registry'
8
+
9
+ // What the lanes root holds that no registered work tree accounts for (joshuafolkken/kit#3370).
10
+ //
11
+ // **Every other sweep starts from `git worktree list`**, so a directory whose registration is gone —
12
+ // a lane whose `rmSync` stopped half-way, one whose `.git` points at a registration git pruned, an
13
+ // empty lanes root a lane once nested inside itself — is invisible to all of them, and stays on disk
14
+ // for good. This reads the root itself.
15
+ //
16
+ // **A stray is removed only when removing it loses nothing** — `lane_leftover.foreign_paths`, the one
17
+ // test `lane:open`'s reclaim also applies. One that holds anything else is kept and reported with what
18
+ // it holds; so is every entry whose name the lane machinery never writes, because nothing here knows
19
+ // who put it there. The root's own `.seat-locks` and every registered work tree are not strays.
20
+
21
+ type StrayVerdict = 'foreign' | 'not-a-lane' | 'opening' | 'removed' | 'stuck' | 'unreadable'
22
+
23
+ interface Stray {
24
+ path: string
25
+ verdict: StrayVerdict
26
+ // The paths inside it whose removal would lose something — only on `foreign`.
27
+ foreign: Array<string>
28
+ }
29
+
30
+ function stray_names(root: string, registered: ReadonlySet<string>): Array<string> {
31
+ if (!existsSync(root)) return []
32
+
33
+ return readdirSync(root).filter(
34
+ (name) => name !== lane_paths.SEAT_LOCK_DIR && !registered.has(path.join(root, name)),
35
+ )
36
+ }
37
+
38
+ function is_lane_directory(directory: string): boolean {
39
+ return lane_paths.is_lane_entry(path.basename(directory)) && lstatSync(directory).isDirectory()
40
+ }
41
+
42
+ // A removal that stops part-way is judged by what is left, exactly as `lane:close` judges its own —
43
+ // and what is left is a stray the next sweep reads again.
44
+ function remove(directory: string): StrayVerdict {
45
+ try {
46
+ rmSync(directory, { force: true, recursive: true })
47
+ } catch (error) {
48
+ error_text.trace_swallowed('lane_stray.remove', error)
49
+ }
50
+
51
+ return existsSync(directory) ? 'stuck' : 'removed'
52
+ }
53
+
54
+ // **A `lane:open` in flight holds a seat lock from before its `worktree add` until after the
55
+ // registration**, so while any lock is held a numbered directory may be a lane being created, not a
56
+ // leftover — it is kept, and the next sweep reads it again.
57
+ interface SweepContext {
58
+ recoverable: ReadonlySet<string>
59
+ is_opening: boolean
60
+ }
61
+
62
+ function judge(directory: string, context: SweepContext): Stray {
63
+ if (!is_lane_directory(directory)) return { path: directory, verdict: 'not-a-lane', foreign: [] }
64
+ if (context.is_opening) return { path: directory, verdict: 'opening', foreign: [] }
65
+
66
+ const foreign = lane_leftover.foreign_paths(directory, context.recoverable)
67
+
68
+ if (foreign.length > 0) return { path: directory, verdict: 'foreign', foreign }
69
+
70
+ return { path: directory, verdict: remove(directory), foreign: [] }
71
+ }
72
+
73
+ // One stray that cannot be read — a permission, a file vanishing mid-walk — is kept and reported
74
+ // rather than thrown, so it never stops the sweep of the rest.
75
+ function judge_safely(directory: string, context: SweepContext): Stray {
76
+ try {
77
+ return judge(directory, context)
78
+ } catch (error) {
79
+ error_text.trace_swallowed('lane_stray.judge', error)
80
+
81
+ return { path: directory, verdict: 'unreadable', foreign: [] }
82
+ }
83
+ }
84
+
85
+ /** Remove the lanes root's unregistered leftovers that hold nothing worth keeping; report the rest. */
86
+ async function sweep_strays(): Promise<Array<Stray>> {
87
+ const root = lane_paths.lane_root(await lane_registry.main_repository_root())
88
+ const registered = new Set(await lane_registry.registered_directories())
89
+ const names = stray_names(root, registered)
90
+
91
+ if (names.length === 0) return []
92
+
93
+ const context: SweepContext = {
94
+ recoverable: await lane_leftover.recoverable_objects(),
95
+ is_opening: lane_open.open_in_flight_count(root) > 0,
96
+ }
97
+
98
+ return names.map((name) => judge_safely(path.join(root, name), context))
99
+ }
100
+
101
+ // The foreign paths a report names before it summarizes the rest as a count.
102
+ const NAMED_PATH_LIMIT = 5
103
+
104
+ function named_paths(foreign: ReadonlyArray<string>): string {
105
+ const rest = foreign.length - NAMED_PATH_LIMIT
106
+ const more = rest > 0 ? ` and ${String(rest)} more` : ''
107
+
108
+ return `${foreign.slice(0, NAMED_PATH_LIMIT).join(', ')}${more}`
109
+ }
110
+
111
+ const KEPT_REASONS: Record<Exclude<StrayVerdict, 'foreign' | 'removed'>, string> = {
112
+ 'not-a-lane': 'not a name the lane machinery writes, so it was left alone',
113
+ opening:
114
+ 'a `lane:open` holds a seat lock and this may be its lane; the next sweep reads it again — if no `lane:open` is running, the lock outlived a crash: remove the lanes root `.seat-locks` directory',
115
+ stuck: 'the removal did not finish; the next `pnpm josh lane:prune` tries again',
116
+ unreadable: 'it could not be read, so it was left in place',
117
+ }
118
+
119
+ /** One line per stray, for standard error. */
120
+ function describe(stray: Stray): string {
121
+ if (stray.verdict === 'removed') return `Removed unregistered lane leftover ${stray.path}.`
122
+
123
+ if (stray.verdict === 'foreign') {
124
+ return `Kept ${stray.path}: it holds files git cannot restore (${named_paths(stray.foreign)}). Move them out and delete the directory.`
125
+ }
126
+
127
+ return `Kept ${stray.path}: ${KEPT_REASONS[stray.verdict]}.`
128
+ }
129
+
130
+ /** Sweep, and say on standard error what was removed and what was kept. */
131
+ async function sweep_and_report(): Promise<void> {
132
+ const strays = await sweep_strays()
133
+
134
+ for (const stray of strays) console.error(describe(stray))
135
+ }
136
+
137
+ const lane_stray = { describe, sweep_and_report, sweep_strays }
138
+
139
+ export type { Stray, StrayVerdict }
140
+ export { lane_stray }
@@ -38,6 +38,7 @@ function read_release_input(environment: Environment): ReleaseInput {
38
38
  workflow: optional(environment['RELEASE_WORKFLOW']),
39
39
  jobs: job_names(environment['RELEASE_JOBS']),
40
40
  await_publish: environment['RELEASE_AWAIT_PUBLISH'] === 'true',
41
+ release_run_workflow: optional(environment['RELEASE_RUN_WORKFLOW']),
41
42
  },
42
43
  }
43
44
  }