@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
@@ -22,6 +22,13 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
22
22
  ['files', 'git', 'network', 'processes'],
23
23
  ],
24
24
  },
25
+ 'dogfood:commit': {
26
+ script: 'scripts/dogfood/dogfood-commit-cli.ts',
27
+ description: "Make the first commit of a dogfood run's own kit-test-* project",
28
+ category: 'Project',
29
+ reference: ['<dir>', 'automation', ['files', 'git']],
30
+ is_kit_only: true,
31
+ },
25
32
  init: {
26
33
  script: 'scripts/init/init.ts',
27
34
  description: 'Initialize config in a new project',
@@ -40,7 +47,7 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
40
47
  },
41
48
  'registry:migrate': {
42
49
  script: 'scripts/registry-migration/migrate.ts',
43
- description: 'Migrate a kit-only project from GitHub Packages to public npm',
50
+ description: 'Migrate a project from GitHub Packages to public npm',
44
51
  category: 'Project',
45
52
  reference: ['', 'developer', ['files', 'network', 'processes']],
46
53
  },
@@ -89,7 +89,7 @@ const WORKFLOW_COMMANDS: Record<string, CommandEntry> = {
89
89
  script: 'scripts/issue/measure-rerun-cli.ts',
90
90
  description: 'Re-run a merged issue’s baseline command and print the before/after pair',
91
91
  category: 'Workflow',
92
- reference: ['<path>', 'automation', ['processes', 'files']],
92
+ reference: ['<issue-number>', 'automation', ['network', 'processes', 'files']],
93
93
  },
94
94
  // A script rather than an `sh -c` chain, because it has a precondition to enforce: run inside a
95
95
  // linked work tree it would hijack the default branch from every other one (joshuafolkken/kit#1535).
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { repo_origin } from '#scripts/discovery/repo-origin'
4
- import { execaSync } from 'execa'
4
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
5
5
 
6
6
  const GITHUB_RUN_COMMANDS = new Set([
7
7
  'run:entry',
@@ -19,11 +19,8 @@ function explanation(root: string, command: string): string | undefined {
19
19
  return `${command} requires Git and a GitHub origin. Run git init and add a GitHub origin first.`
20
20
  }
21
21
 
22
- const result = execaSync('git', ['config', '--get', 'remote.origin.url'], {
23
- cwd: root,
24
- reject: false,
25
- })
26
- if (result.exitCode === 0 && repo_origin.parse_origin_url(result.stdout)) return undefined
22
+ const url = git_spawn_sync.origin_url(root)
23
+ if (url !== undefined && repo_origin.parse_origin_url(url)) return undefined
27
24
 
28
25
  return `${command} requires a GitHub origin. Add an origin pointing to GitHub first.`
29
26
  }
@@ -1,5 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process'
2
2
  import { setTimeout as sleep } from 'node:timers/promises'
3
+ import { parseArgs } from 'node:util'
4
+ import { run_liveness } from '#scripts/run/run-liveness'
3
5
  import { lane_child_invocation } from './lane-child-invocation'
4
6
  import { lane_handoff } from './lane-handoff'
5
7
 
@@ -22,15 +24,30 @@ const MS_PER_SECOND = 1000
22
24
  const ISSUE_PATTERN = /^[1-9]\d*$/u
23
25
  const PROCESS_FOUND = 0
24
26
 
27
+ // `is_settled` is read once, when the wait starts: a child that ended between two `lane:await` calls
28
+ // never appears in the second one, and only its settled issue tells it apart from one not yet
29
+ // launched (joshuafolkken/kit#3133).
25
30
  interface AwaitState {
26
31
  appeared: boolean
27
32
  disappeared_at: number | undefined
28
33
  first_polled_at: number | undefined
34
+ is_settled?: boolean
35
+ }
36
+
37
+ interface AwaitArguments {
38
+ issues: ReadonlyArray<string>
39
+ owner: number | undefined
40
+ }
41
+
42
+ interface RawAwaitArguments {
43
+ positionals: Array<string>
44
+ values: { owner?: string | undefined }
29
45
  }
30
46
 
31
47
  // Injectable for testing -- the CLI never overrides these.
32
48
  interface AwaitOptions {
33
49
  is_running?: (issue: string) => boolean
50
+ is_settled?: (issue: string) => Promise<boolean>
34
51
  poll_ms?: number
35
52
  reconfirm_ms?: number
36
53
  never_appeared_timeout_ms?: number
@@ -43,6 +60,7 @@ interface CheckConfig {
43
60
  }
44
61
 
45
62
  interface RunConfig extends CheckConfig {
63
+ is_settled: (issue: string) => Promise<boolean>
46
64
  poll_ms: number
47
65
  }
48
66
 
@@ -55,23 +73,75 @@ function is_process_running_default(issue: string): boolean {
55
73
  return lane_handoff.is_ship_running(issue, process.cwd())
56
74
  }
57
75
 
58
- function make_initial_state(): AwaitState {
59
- return { appeared: false, disappeared_at: undefined, first_polled_at: undefined }
76
+ // The same positive evidence `run:liveness` answers `settled` on: the issue closed, or it was parked.
77
+ async function is_settled_default(issue: string): Promise<boolean> {
78
+ return (await run_liveness.read_child_settled(issue)) === true
60
79
  }
61
80
 
62
- function make_states(issues: ReadonlyArray<string>): Map<string, AwaitState> {
63
- return new Map(issues.map((issue) => [issue, make_initial_state()]))
81
+ // An unknown flag or a flag without its value is `undefined`, which is usage.
82
+ function read_await_args(rest: ReadonlyArray<string>): RawAwaitArguments | undefined {
83
+ try {
84
+ return parseArgs({
85
+ args: [...rest],
86
+ allowPositionals: true,
87
+ strict: true,
88
+ options: { owner: { type: 'string' } },
89
+ })
90
+ } catch {
91
+ return undefined
92
+ }
64
93
  }
65
94
 
66
- function resolve_options(options: AwaitOptions): RunConfig {
95
+ // Every positional is an issue number, at least one is given, and `--owner` is a pid.
96
+ function is_valid(raw: RawAwaitArguments): boolean {
97
+ const { owner } = raw.values
98
+ const numbers = owner === undefined ? raw.positionals : [...raw.positionals, owner]
99
+
100
+ return raw.positionals.length > 0 && numbers.every((value) => ISSUE_PATTERN.test(value))
101
+ }
102
+
103
+ // The issues to wait on and the waiting session's pid, or `undefined` for anything else.
104
+ function parse_arguments(rest: ReadonlyArray<string>): AwaitArguments | undefined {
105
+ const raw = read_await_args(rest)
106
+
107
+ if (raw === undefined || !is_valid(raw)) return undefined
108
+
109
+ const { owner } = raw.values
110
+
111
+ return { issues: raw.positionals, owner: owner === undefined ? undefined : Number(owner) }
112
+ }
113
+
114
+ function make_initial_state(is_settled: boolean): AwaitState {
115
+ return { appeared: false, disappeared_at: undefined, first_polled_at: undefined, is_settled }
116
+ }
117
+
118
+ async function make_states(
119
+ issues: ReadonlyArray<string>,
120
+ is_settled: (issue: string) => Promise<boolean>,
121
+ ): Promise<Map<string, AwaitState>> {
122
+ const settled = await Promise.all(issues.map(async (issue) => await is_settled(issue)))
123
+
124
+ return new Map(issues.map((issue, index) => [issue, make_initial_state(settled[index] === true)]))
125
+ }
126
+
127
+ function resolve_timing(
128
+ options: AwaitOptions,
129
+ ): Pick<RunConfig, 'never_appeared_timeout_ms' | 'poll_ms' | 'reconfirm_ms'> {
67
130
  return {
68
- is_running: options.is_running ?? is_process_running_default,
69
131
  poll_ms: options.poll_ms ?? DEFAULT_POLL_MS,
70
132
  reconfirm_ms: options.reconfirm_ms ?? RECONFIRM_MS,
71
133
  never_appeared_timeout_ms: options.never_appeared_timeout_ms ?? NEVER_APPEARED_TIMEOUT_MS,
72
134
  }
73
135
  }
74
136
 
137
+ function resolve_options(options: AwaitOptions): RunConfig {
138
+ return {
139
+ is_running: options.is_running ?? is_process_running_default,
140
+ is_settled: options.is_settled ?? is_settled_default,
141
+ ...resolve_timing(options),
142
+ }
143
+ }
144
+
75
145
  function update_liveness(state: AwaitState, is_live: boolean): void {
76
146
  if (!is_live) return
77
147
 
@@ -79,13 +149,16 @@ function update_liveness(state: AwaitState, is_live: boolean): void {
79
149
  state.disappeared_at = undefined
80
150
  }
81
151
 
82
- // Throws if the issue never appeared within the timeout window; otherwise records the first-seen time.
152
+ // Returns the issue when it had already settled before the wait started — it ended and will never
153
+ // appear. Otherwise throws if it never appeared within the timeout window, and records the first-seen time.
83
154
  function check_not_appeared(
84
155
  issue: string,
85
156
  state: AwaitState,
86
157
  now_ms: number,
87
158
  timeout_ms: number,
88
- ): void {
159
+ ): string | undefined {
160
+ if (state.is_settled === true) return issue
161
+
89
162
  state.first_polled_at ??= now_ms
90
163
  const elapsed_ms = now_ms - state.first_polled_at
91
164
 
@@ -94,10 +167,13 @@ function check_not_appeared(
94
167
  `lane child ${issue} never appeared within ${String(timeout_ms / MS_PER_SECOND)}s`,
95
168
  )
96
169
  }
170
+
171
+ return undefined
97
172
  }
98
173
 
99
174
  // Returns the issue that has confirmed-completed this tick, `undefined` if none yet.
100
- // The `appeared` guard prevents a process that never showed up from being declared done.
175
+ // The `appeared` guard prevents a process that never showed up from being declared done, unless its
176
+ // issue had already settled when the wait started.
101
177
  function check_issue(
102
178
  issue: string,
103
179
  state: AwaitState,
@@ -111,9 +187,7 @@ function check_issue(
111
187
  if (is_live) return undefined
112
188
 
113
189
  if (!state.appeared) {
114
- check_not_appeared(issue, state, now_ms, config.never_appeared_timeout_ms)
115
-
116
- return undefined
190
+ return check_not_appeared(issue, state, now_ms, config.never_appeared_timeout_ms)
117
191
  }
118
192
 
119
193
  if (state.disappeared_at === undefined) {
@@ -144,7 +218,7 @@ async function wait_for_any(
144
218
  options: AwaitOptions = {},
145
219
  ): Promise<string> {
146
220
  const config = resolve_options(options)
147
- const states = make_states(issues)
221
+ const states = await make_states(issues, config.is_settled)
148
222
 
149
223
  for (;;) {
150
224
  const completed = check_any(states, Date.now(), config)
@@ -157,13 +231,13 @@ async function wait_for_any(
157
231
  }
158
232
 
159
233
  const lane_await = {
160
- ISSUE_PATTERN,
161
234
  NEVER_APPEARED_TIMEOUT_MS,
162
235
  RECONFIRM_MS,
163
236
  check_issue,
164
237
  is_process_running_default,
238
+ parse_arguments,
165
239
  wait_for_any,
166
240
  }
167
241
 
168
- export type { AwaitState, CheckConfig }
242
+ export type { AwaitArguments, AwaitState, CheckConfig }
169
243
  export { lane_await }
@@ -6,6 +6,7 @@ import type { OpenIssueData } from '#scripts/git/schemas'
6
6
  import { issue_cite, type IssueCiter } from '#scripts/issue/issue-cite'
7
7
  import { error_text } from '#scripts/lib/error-message'
8
8
  import { run_carry } from '#scripts/run/run-carry'
9
+ import { run_carry_reclaim } from '#scripts/run/run-carry-reclaim'
9
10
  import { lane_await } from './lane-await'
10
11
  import { lane_close, type CloseOutcome, type SweepOutcome } from './lane-close'
11
12
  import { lane_dispatch, type DispatchOutcome } from './lane-dispatch'
@@ -40,7 +41,7 @@ const USAGE = [
40
41
  ' josh lane:prune',
41
42
  ' josh lane:output <issue-number> [<path>]',
42
43
  ' josh lane:dispatch <issue-number>',
43
- ' josh lane:await <issue-number> ...',
44
+ ' josh lane:await <issue-number> ... [--owner <pid>]',
44
45
  ].join('\n')
45
46
 
46
47
  type Handler = (rest: ReadonlyArray<string>) => Promise<number>
@@ -356,22 +357,19 @@ async function dispatch_command(rest: ReadonlyArray<string>): Promise<number> {
356
357
  return await report_dispatch(await lane_dispatch.dispatch_child(issue), issue)
357
358
  }
358
359
 
359
- // Each valid argument is an issue number; any non-number stops parsing and triggers usage.
360
- function parse_issues(rest: ReadonlyArray<string>): ReadonlyArray<string> | undefined {
361
- if (rest.length === 0) return undefined
362
- const issues = rest.filter((value) => lane_await.ISSUE_PATTERN.test(value))
363
-
364
- return issues.length === rest.length ? issues : undefined
365
- }
366
-
367
360
  // The completed issue number goes to standard output so `N=$(pnpm josh lane:await ...)` captures
368
- // which child finished without parsing stderr.
361
+ // which child finished without parsing stderr. `--owner` reclaims the carry record for a resumed
362
+ // conversation before the wait (`run-carry-reclaim.ts`).
369
363
  async function await_command(rest: ReadonlyArray<string>): Promise<number> {
370
- const issues = parse_issues(rest)
364
+ const parsed = lane_await.parse_arguments(rest)
371
365
 
372
- if (issues === undefined) return report_usage()
366
+ if (parsed === undefined) return report_usage()
367
+
368
+ if (parsed.owner !== undefined) {
369
+ await run_carry_reclaim.reclaim_carry(run_carry.owner_of(parsed.owner))
370
+ }
373
371
 
374
- const completed = await lane_await.wait_for_any(issues)
372
+ const completed = await lane_await.wait_for_any(parsed.issues)
375
373
 
376
374
  console.info(completed)
377
375
 
@@ -1,6 +1,7 @@
1
1
  import { existsSync, rmSync } from 'node:fs'
2
2
  import { git_command } from '#scripts/git/git-command'
3
3
  import { git_worktree } from '#scripts/git/git-worktree'
4
+ import { error_text } from '#scripts/lib/error-message'
4
5
  import { lane_paths } from './lane-paths'
5
6
  import { lane_reap } from './lane-reap'
6
7
  import { lane_registry, type LaneInfo } from './lane-registry'
@@ -147,7 +148,9 @@ async function try_close(issue: string): Promise<boolean> {
147
148
  const outcome = await close_lane(issue)
148
149
 
149
150
  return outcome.kind !== 'incomplete'
150
- } catch {
151
+ } catch (error) {
152
+ error_text.trace_swallowed('lane_close.try_close', error)
153
+
151
154
  return false
152
155
  }
153
156
  }
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
- import { parseArgs } from 'node:util'
4
3
  import { josh_command } from '#scripts/josh/josh-run'
4
+ import { cli_flags } from '#scripts/lib/cli-flags'
5
5
  import { INSTALL_TIMEOUT_MS } from '#scripts/lib/timeouts'
6
6
  import { execa } from 'execa'
7
7
 
@@ -35,21 +35,15 @@ interface LaunchContext {
35
35
  }
36
36
 
37
37
  function read_context(argv: ReadonlyArray<string>): LaunchContext | undefined {
38
- try {
39
- const parsed = parseArgs({
40
- args: [...argv],
41
- options: OPTIONS,
42
- strict: true,
43
- allowPositionals: true,
44
- })
45
- const [issue, ...rest] = parsed.positionals
46
-
47
- if (issue === undefined || rest.length > 0 || !ISSUE_PATTERN.test(issue)) return undefined
48
-
49
- return { issue, stash: parsed.values.stash }
50
- } catch {
51
- return undefined
52
- }
38
+ const parsed = cli_flags.arguments_of(argv, OPTIONS)
39
+
40
+ if (parsed === undefined) return undefined
41
+
42
+ const [issue, ...rest] = parsed.positionals
43
+
44
+ if (issue === undefined || rest.length > 0 || !ISSUE_PATTERN.test(issue)) return undefined
45
+
46
+ return { issue, stash: parsed.values.stash }
53
47
  }
54
48
 
55
49
  // The pop brought in the `pnpm-lock.yaml` `josh latest` rewrote, so this one lane installs a second
@@ -6,6 +6,7 @@ import { agent_argv } from '#scripts/agent/agent-argv'
6
6
  import { agent_role_profile } from '#scripts/agent/agent-role-profile'
7
7
  import { git_common_directory } from '#scripts/git/git-common-directory'
8
8
  import { stamp_file } from '#scripts/josh/stamp-file'
9
+ import { error_text } from '#scripts/lib/error-message'
9
10
  import { detached_launch } from '#scripts/run/detached-launch'
10
11
  import { run_ship_review } from '#scripts/run/run-ship-review'
11
12
  import { z } from 'zod'
@@ -102,7 +103,9 @@ function write_response(target: string, body: z.infer<typeof response_schema>):
102
103
  async function launched(lane: LaneInfo, round: ReviewRound): Promise<boolean> {
103
104
  try {
104
105
  return await launch_review(lane, round)
105
- } catch {
106
+ } catch (error) {
107
+ error_text.trace_swallowed('openai_review_broker.launched', error)
108
+
106
109
  return false
107
110
  }
108
111
  }
@@ -1,4 +1,4 @@
1
- import { parseArgs, type ParseArgsConfig } from 'node:util'
1
+ import { parseArgs, type ParseArgsConfig, type ParseArgsOptionsConfig } from 'node:util'
2
2
 
3
3
  // Argument reading for the commands under `scripts/`.
4
4
  //
@@ -32,6 +32,35 @@ function parse_or_undefined<T extends ParseArgsConfig>(
32
32
  }
33
33
  }
34
34
 
35
- const cli_flags = { refuse_unknown_flags, parse_or_undefined }
35
+ // The two shapes every command reads `argv` in: flags alone, or flags beside positionals. Both are
36
+ // strict, so an unknown flag is `undefined` rather than a silently ignored typo; the commands had each
37
+ // carried a one-line wrapper spelling these out (joshuafolkken/kit#3072).
38
+ type FlagsOnly<T extends ParseArgsOptionsConfig> = ReturnType<
39
+ typeof parseArgs<{ args: Array<string>; options: T }>
40
+ >
41
+ type WithPositionals<T extends ParseArgsOptionsConfig> = ReturnType<
42
+ typeof parseArgs<{ args: Array<string>; options: T; allowPositionals: true }>
43
+ >
44
+
45
+ function values_of<T extends ParseArgsOptionsConfig>(
46
+ argv: ReadonlyArray<string>,
47
+ options: T,
48
+ ): FlagsOnly<T>['values'] | undefined {
49
+ return parse_or_undefined({ args: [...argv], options })?.values
50
+ }
51
+
52
+ function arguments_of<T extends ParseArgsOptionsConfig>(
53
+ argv: ReadonlyArray<string>,
54
+ options: T,
55
+ ): WithPositionals<T> | undefined {
56
+ return parse_or_undefined({ args: [...argv], options, allowPositionals: true })
57
+ }
58
+
59
+ // A string option's value, or `undefined` where the flag was absent or parsed as anything else.
60
+ function string_of(value: unknown): string | undefined {
61
+ return typeof value === 'string' ? value : undefined
62
+ }
63
+
64
+ const cli_flags = { refuse_unknown_flags, parse_or_undefined, values_of, arguments_of, string_of }
36
65
 
37
66
  export { cli_flags }
@@ -4,6 +4,17 @@ function message_of(error: unknown): string {
4
4
  return error instanceof Error ? error.message : String(error)
5
5
  }
6
6
 
7
- const error_text = { message_of }
7
+ const DEBUG_ENV_KEY = 'JOSH_DEBUG'
8
+
9
+ // A catch that folds a failure into a state verdict — "dirty", "not merged", "not written" — leaves no
10
+ // trace of why by default, so a wrong verdict cannot be followed back to its cause (joshuafolkken/kit#3067).
11
+ // `JOSH_DEBUG` set to any non-blank value writes the swallowed reason to stderr; unset, nothing changes.
12
+ function trace_swallowed(where: string, error: unknown): void {
13
+ if ((process.env[DEBUG_ENV_KEY] ?? '').trim() === '') return
14
+
15
+ process.stderr.write(`josh debug: ${where}: ${message_of(error)}\n`)
16
+ }
17
+
18
+ const error_text = { message_of, trace_swallowed }
8
19
 
9
20
  export { error_text }
@@ -40,10 +40,6 @@ const LS_FILES_FLAGS: ReadonlyArray<string> = [
40
40
  '-z',
41
41
  ]
42
42
  const NUL = '\0'
43
- // `--show-toplevel` prints the absolute repository (or worktree) root from anywhere inside it, so the
44
- // root-relative names `--full-name` returns resolve to the right absolute paths wherever the command
45
- // was invoked.
46
- const SHOW_TOPLEVEL: ReadonlyArray<string> = ['rev-parse', '--show-toplevel']
47
43
 
48
44
  function is_lint_target(relative_path: string): boolean {
49
45
  return LINT_EXTENSIONS.has(path.extname(relative_path))
@@ -53,12 +49,6 @@ function split_names(output: string): ReadonlyArray<string> {
53
49
  return output.split(NUL).filter((name) => name.length > 0)
54
50
  }
55
51
 
56
- // `git_spawn.read` is the one place ordinary git commands are spawned from, so this reuses it rather
57
- // than growing a second spawn site (joshuafolkken/kit#1640).
58
- async function repo_root(): Promise<string> {
59
- return await git_spawn.read([...SHOW_TOPLEVEL])
60
- }
61
-
62
52
  // The names resolve against the repository `root`, never the process cwd: `--full-name` prints them
63
53
  // relative to the root wherever the command runs, so resolving against cwd would double the prefix
64
54
  // and drop every file when the command is invoked from a subdirectory.
@@ -73,7 +63,6 @@ async function lint_target_files(root: string): Promise<ReadonlyArray<string>> {
73
63
  const line_targets = {
74
64
  is_lint_target,
75
65
  lint_target_files,
76
- repo_root,
77
66
  split_names,
78
67
  }
79
68
 
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env tsx
2
2
  import path from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
+ import { git_command } from '#scripts/git/git-command'
4
5
  import { line_budget, type FileBudget, type LineBudget } from './line-budget'
5
6
  import { line_targets } from './line-targets'
6
7
 
@@ -122,10 +123,11 @@ function render(budgets: ReadonlyArray<FileBudget>, project_root: string): strin
122
123
  return [header(budgets), ...rows_for(budgets, project_root)].join('\n')
123
124
  }
124
125
 
125
- // The scan resolves the repository root first — `budgets_for` and the row display both key off it — so
126
- // a run started from a subdirectory reports the same files as one started from the root.
126
+ // The scan resolves the repository root first — `budgets_for` and the row display both key off it, and
127
+ // `git ls-files --full-name` names files relative to it — so a run started from a subdirectory reports
128
+ // the same files as one started from the root.
127
129
  async function run_scan(): Promise<number> {
128
- const root = await line_targets.repo_root()
130
+ const root = await git_command.repository_root()
129
131
  const budgets = await scan_budgets(root)
130
132
 
131
133
  process.stdout.write(`${render(budgets, root)}\n`)
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { git_location_environment } from '#scripts/git/git-location-environment'
4
4
  import { ls_remote_branch_arguments } from '#scripts/git/git-ls-remote'
5
- import { execaSync } from 'execa'
5
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
6
6
 
7
7
  // The git probes propagation needs before it writes anything into a working tree.
8
8
  //
@@ -16,7 +16,6 @@ const GIT_TIMEOUT_MS = 10_000
16
16
  // `rev-parse` and tight for an ssh handshake, and a probe that times out answers "no" — which for
17
17
  // the two below would report a push that never reached origin as a push nobody attempted.
18
18
  const REMOTE_TIMEOUT_MS = 60_000
19
- const SUCCESS_EXIT_CODE = 0
20
19
  const DEFAULT_BRANCH_REF = 'refs/remotes/origin/HEAD'
21
20
  const FALLBACK_DEFAULT_BRANCH = 'main'
22
21
  const ORIGIN_PREFIX = 'refs/remotes/origin/'
@@ -54,15 +53,11 @@ function run_git(
54
53
  args: ReadonlyArray<string>,
55
54
  timeout_ms: number = GIT_TIMEOUT_MS,
56
55
  ): string | undefined {
57
- const result = execaSync('git', args, {
56
+ return git_spawn_sync.read(args, {
58
57
  cwd: repository_path,
59
- reject: false,
60
58
  timeout: timeout_ms,
61
- env: { LC_ALL: 'C', LANGUAGE: 'C', ...git_location_environment.location_free_environment() },
62
- extendEnv: true,
59
+ env: { ...git_spawn_sync.C_LOCALE, ...git_location_environment.location_free_environment() },
63
60
  })
64
-
65
- return result.exitCode === SUCCESS_EXIT_CODE ? result.stdout.trim() : undefined
66
61
  }
67
62
 
68
63
  // The repository's default branch, from the remote's own HEAD. Falls back to `main` when the ref is
@@ -1,3 +1,4 @@
1
+ import { git_command } from '#scripts/git/git-command'
1
2
  import { line_targets } from '#scripts/lines/line-targets'
2
3
  import { import_graph } from './import-graph'
3
4
  import { refactor_lint, type CategoryResult } from './refactor-lint'
@@ -97,7 +98,7 @@ async function scan(root: string): Promise<ScanResult> {
97
98
  }
98
99
 
99
100
  async function run_scan(): Promise<number> {
100
- const root = await line_targets.repo_root()
101
+ const root = await git_command.repository_root()
101
102
  const result = await scan(root)
102
103
 
103
104
  process.stdout.write(`${render_result(result)}\n`)
@@ -99,25 +99,33 @@ function is_github_tarball_line(line: string): boolean {
99
99
  return line.trimStart().startsWith('tarball: https://npm.pkg.github.com/')
100
100
  }
101
101
 
102
- function clean_kit_line(
102
+ // Integrities are keyed by `name@version`, so two scoped packages sharing a version never collide.
103
+ function package_id(key: string): string {
104
+ const name = fix_gh_packages_logic.package_path_from_key(key)
105
+
106
+ return `${name}@${fix_gh_packages_logic.package_version_from_key(key)}`
107
+ }
108
+
109
+ function clean_scoped_line(
103
110
  line: string,
104
- version: string,
111
+ id: string,
105
112
  integrities: ReadonlyMap<string, IntegrityPair>,
106
113
  ): string | undefined {
107
- if (!version) return line
114
+ if (!id) return line
108
115
  if (is_github_tarball_line(line)) return undefined
109
116
  const cleaned = remove_flow_tarball(line)
110
- const pair = integrities.get(version)
117
+ const pair = integrities.get(id)
111
118
  if (pair === undefined) return cleaned
112
119
 
113
120
  return cleaned.split(`integrity: ${pair[0]}`).join(`integrity: ${pair[1]}`)
114
121
  }
115
122
 
116
- function next_kit_version(line: string, version: string): string {
117
- if (!is_package_entry(line)) return version
118
- const prefix = " '@joshuafolkken/kit@"
123
+ function next_scoped_id(line: string, id: string): string {
124
+ if (!is_package_entry(line)) return id
125
+ const prefix = ` '${SCOPE}/`
126
+ if (!line.startsWith(prefix)) return ''
119
127
 
120
- return line.startsWith(prefix) ? (line.slice(prefix.length).split("'", 1)[0] ?? '') : ''
128
+ return package_id(line.slice(" '".length).split("'", 1)[0] ?? '')
121
129
  }
122
130
 
123
131
  function integrity_pair(
@@ -135,12 +143,12 @@ function lock_integrities(
135
143
  ): Map<string, IntegrityPair> {
136
144
  const packages = fix_gh_packages_logic.parse_lockfile_packages(lockfile)
137
145
  const pairs = new Map<string, IntegrityPair>()
138
- const kit_entries = Object.entries(packages).filter(([key]) => key.startsWith(`${SCOPE}/kit@`))
146
+ const scoped_entries = Object.entries(packages).filter(([key]) => key.startsWith(`${SCOPE}/`))
139
147
 
140
- for (const [key, entry] of kit_entries) {
141
- const version = fix_gh_packages_logic.package_version_from_key(key)
142
- const pair = integrity_pair(entry.resolution?.integrity, npm_integrities.get(version))
143
- if (pair !== undefined) pairs.set(version, pair)
148
+ for (const [key, entry] of scoped_entries) {
149
+ const id = package_id(key)
150
+ const pair = integrity_pair(entry.resolution?.integrity, npm_integrities.get(id))
151
+ if (pair !== undefined) pairs.set(id, pair)
144
152
  }
145
153
 
146
154
  return pairs
@@ -150,17 +158,17 @@ function push_if_defined(lines: Array<string>, cleaned: string | undefined): voi
150
158
  if (cleaned !== undefined) lines.push(cleaned)
151
159
  }
152
160
 
153
- function rewrite_kit_lockfile(
161
+ function rewrite_scoped_lockfile(
154
162
  lockfile: string,
155
163
  npm_integrities: ReadonlyMap<string, string>,
156
164
  ): string {
157
- let kit_version = ''
165
+ let id = ''
158
166
  const lines: Array<string> = []
159
167
  const integrities = lock_integrities(lockfile, npm_integrities)
160
168
 
161
169
  for (const line of lockfile.split('\n')) {
162
- kit_version = next_kit_version(line, kit_version)
163
- const cleaned = clean_kit_line(line, kit_version, integrities)
170
+ id = next_scoped_id(line, id)
171
+ const cleaned = clean_scoped_line(line, id, integrities)
164
172
 
165
173
  push_if_defined(lines, cleaned)
166
174
  }
@@ -206,7 +214,8 @@ function previous_registry(project_npmrc: string, user_npmrc: string): string {
206
214
 
207
215
  function plan(project_npmrc: string, user_npmrc: string, lockfile: string): MigrationPlan {
208
216
  const packages = scoped_packages(lockfile)
209
- const blocked = packages.filter((name) => name !== `${SCOPE}/kit`)
217
+ // Every scoped package may migrate; `migrate` blocks any whose locked version is not on public npm.
218
+ const blocked: Array<string> = []
210
219
  if (has_duplicate_mapping(project_npmrc, user_npmrc)) blocked.push('duplicate registry mappings')
211
220
  const unsupported = unsupported_registry(project_npmrc)
212
221
  if (unsupported !== undefined) blocked.push(unsupported)
@@ -220,7 +229,7 @@ const migrate_logic = {
220
229
  plan,
221
230
  github_tarballs,
222
231
  is_github_tarball,
223
- rewrite_kit_lockfile,
232
+ rewrite_scoped_lockfile,
224
233
  scoped_versions,
225
234
  }
226
235