@joshuafolkken/kit 1.957.0 → 1.978.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 (102) hide show
  1. package/.claude/skills/workflow-commands/background-commands.md +3 -0
  2. package/.claude/skills/workflow-commands/backlogrun-child.md +75 -120
  3. package/.claude/skills/workflow-commands/backlogrun-lanes.md +78 -115
  4. package/.claude/skills/workflow-commands/backlogrun-progress.md +82 -146
  5. package/.claude/skills/workflow-commands/backlogrun-steps.md +173 -229
  6. package/.claude/skills/workflow-commands/backlogrun.md +3 -1
  7. package/.claude/skills/workflow-commands/followup.md +3 -0
  8. package/.claude/skills/workflow-commands/fullrun.md +4 -4
  9. package/.claude/skills/workflow-commands/halfrun.md +12 -7
  10. package/.claude/skills/workflow-commands/observation-filing.md +31 -25
  11. package/.claude/skills/workflow-commands/pre-gate-cut.md +96 -161
  12. package/.claude/skills/workflow-commands/working-tree-hold.md +16 -1
  13. package/.github/workflows/pr-classification.yml +12 -1
  14. package/CLAUDE.md +3 -3
  15. package/README.md +25 -21
  16. package/dist/CLAUDE.md +3 -3
  17. package/dist/hooks/batch-guard.js +4 -4
  18. package/dist/hooks/{chunk-XSL4SRMA.js → chunk-2K52BRWV.js} +2 -2
  19. package/dist/hooks/{chunk-M6K7EQDO.js → chunk-BNGGEY3Z.js} +38 -32
  20. package/dist/hooks/{chunk-KOCK5FXQ.js → chunk-BVMSPG3M.js} +3 -3
  21. package/dist/hooks/{chunk-3ON7HOYB.js → chunk-CRGJTD6E.js} +1 -1
  22. package/dist/hooks/{chunk-HJXQZ7V4.js → chunk-F6OM6SIG.js} +3 -3
  23. package/dist/hooks/{chunk-LDQDZYHX.js → chunk-KQG4776P.js} +1 -1
  24. package/dist/hooks/{chunk-244WYOEO.js → chunk-NXL3SBXY.js} +2 -2
  25. package/dist/hooks/{chunk-IJPBTDTB.js → chunk-Q7LE77CO.js} +88 -18
  26. package/dist/hooks/{chunk-BELI5XSS.js → chunk-ZPLXLJ7O.js} +15 -5
  27. package/dist/hooks/{chunk-GNOITLRR.js → chunk-ZYYSBNR6.js} +4 -4
  28. package/dist/hooks/codex-hook-adapter.js +10 -10
  29. package/dist/hooks/duplicate-read-guard.js +5 -5
  30. package/dist/hooks/format-edited.js +2 -2
  31. package/dist/hooks/investigation-guard.js +5 -5
  32. package/dist/hooks/pretool-guard.js +9 -9
  33. package/dist/hooks/stop-guard.js +206 -176
  34. package/dist/josh.js +8 -4
  35. package/package.json +4 -4
  36. package/prompts/collaboration-workflow/wip-cap.md +1 -1
  37. package/scripts/backlog/backlog-arrival.ts +11 -1
  38. package/scripts/backlog/backlog-budget.ts +25 -8
  39. package/scripts/backlog/backlog-next.ts +45 -2
  40. package/scripts/backlog/backlog-offer.ts +1 -0
  41. package/scripts/backlog/backlog-plan-cli.ts +66 -27
  42. package/scripts/backlog/backlog-plan.ts +30 -2
  43. package/scripts/backlog/backlog-stalled.ts +9 -0
  44. package/scripts/backlog/backlog-waves.ts +169 -0
  45. package/scripts/ci/pr-classification.ts +13 -12
  46. package/scripts/document/document-byte-budget.ts +1 -1
  47. package/scripts/document/entry-read-budget.ts +7 -5
  48. package/scripts/epic/epic-lane-offer.ts +69 -4
  49. package/scripts/epic/epic-next.ts +2 -2
  50. package/scripts/epic/epic-solo.ts +147 -0
  51. package/scripts/epic/epic-triage.ts +47 -0
  52. package/scripts/gate/hook-gate-reuse.ts +7 -8
  53. package/scripts/git/git-closes-keyword.ts +15 -0
  54. package/scripts/git/git-followup-flush.ts +5 -6
  55. package/scripts/git/git-followup-issue-close.ts +75 -0
  56. package/scripts/git/git-pr-followup-wrapup.ts +36 -6
  57. package/scripts/git/git-pr-followup.ts +9 -16
  58. package/scripts/git/git-staging.ts +33 -20
  59. package/scripts/git/issue-labels.ts +75 -5
  60. package/scripts/hooks/stop-guard.ts +1 -0
  61. package/scripts/init/init-bootstrap.ts +92 -0
  62. package/scripts/init/init-install.ts +67 -0
  63. package/scripts/init/init-logic-workspace.ts +1 -1
  64. package/scripts/init/init.ts +58 -13
  65. package/scripts/init/start-plan.ts +1 -1
  66. package/scripts/init/start-steps.ts +5 -28
  67. package/scripts/issue/issue-closing-pr.ts +93 -0
  68. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +2 -1
  69. package/scripts/josh/josh-commands-project.ts +5 -1
  70. package/scripts/josh/josh-commands-workflow.ts +3 -3
  71. package/scripts/observations/observation-ledger-prepare.ts +31 -0
  72. package/scripts/observations/observation-ledger.ts +5 -5
  73. package/scripts/observations/observations-flush.ts +11 -30
  74. package/scripts/repo/repository-labels.ts +91 -0
  75. package/scripts/rules/implementation-cut.ts +49 -16
  76. package/scripts/rules/lane-background.ts +25 -5
  77. package/scripts/rules/stop-rules.ts +16 -4
  78. package/scripts/run/run-carry-cli.ts +21 -0
  79. package/scripts/run/run-cut-cli.ts +92 -110
  80. package/scripts/run/run-cut-report.ts +167 -0
  81. package/scripts/run/run-cut.ts +23 -7
  82. package/scripts/run/run-entry-cli.ts +70 -3
  83. package/scripts/run/run-halfrun-resume.ts +104 -0
  84. package/scripts/run/run-headless.ts +1 -0
  85. package/scripts/run/run-hold-cli.ts +57 -13
  86. package/scripts/run/run-hold.ts +26 -4
  87. package/scripts/run/run-merge-cli.ts +28 -14
  88. package/scripts/run/run-merge-steps.ts +38 -2
  89. package/scripts/run/run-merge.ts +41 -3
  90. package/scripts/run/run-prep-cli.ts +8 -2
  91. package/scripts/run/run-prep-locate.ts +165 -0
  92. package/scripts/run/run-prep.ts +12 -1
  93. package/scripts/run/run-step-cli.ts +16 -0
  94. package/scripts/run/run-step.ts +28 -4
  95. package/scripts/run/run-tail-cli.ts +4 -2
  96. package/scripts/run/run-wake-work.ts +4 -0
  97. package/scripts/safe-chain/ci-installer-pin.ts +8 -4
  98. package/scripts/security/security-audit.ts +12 -1
  99. package/scripts/sync/sync.ts +6 -0
  100. package/scripts/time-runtime/time-background.ts +11 -0
  101. package/scripts/time-runtime/time-transcript-line.ts +4 -0
  102. package/templates/pnpm-workspace.static.yaml +2 -0
@@ -2,6 +2,7 @@ import {
2
2
  observation_ledger,
3
3
  OBSERVATION_LEDGER_PATHS,
4
4
  } from '#scripts/observations/observation-ledger'
5
+ import { observation_ledger_prepare } from '#scripts/observations/observation-ledger-prepare'
5
6
  import { git_command } from './git-command'
6
7
  import { git_prompt } from './git-prompt'
7
8
  import { git_status } from './git-status'
@@ -67,18 +68,26 @@ async function stage_untracked_files(files: ReadonlyArray<string>): Promise<void
67
68
  for (const file of files) console.info(` + ${file}`)
68
69
  }
69
70
 
70
- // **The observation ledger is never staged by an ordinary commit** (joshuafolkken/kit#1756). A
71
- // parent session appends to it in the primary checkout and nothing there commits it, so the next
72
- // `pnpm josh git` run in that checkout swept the line into a pull request about something else
73
- // entirely — a diff a reviewer has no reason to question. Excluding it here covers every entry
74
- // point, because this is the one staging step all of them go through;
75
- // `pnpm josh observations:flush` is what commits it, on a branch of its own.
76
- // Both of its paths are excluded (joshuafolkken/kit#2724): a run on the old code still appends at
77
- // the old one until `observation-ledger-migrate.ts` moves it.
78
- const PATHS_EXCLUDED_FROM_STAGING: ReadonlyArray<string> = OBSERVATION_LEDGER_PATHS
79
-
80
- function is_stageable(file_path: string): boolean {
81
- return !observation_ledger.is_ledger_path(file_path)
71
+ // **The observation ledger rides the run's own commit** (joshuafolkken/kit#2763). It used to be
72
+ // excluded here (joshuafolkken/kit#1756) and committed afterwards by `pnpm josh observations:flush` as
73
+ // a pull request of its own — a second branch, CI wait and merge behind every run that appended a
74
+ // line, 144 of them in one month. Staged with the run instead, the lines are reviewed and merged with
75
+ // the pull request whose run recorded them, while CI for that pull request is the only wait.
76
+ //
77
+ // **A line that breaks the grammar keeps the old exclusion** (joshuafolkken/kit#2123): the commit
78
+ // goes ahead without the ledger, and `pnpm josh observations:flush` — which refuses the same line and
79
+ // names it — is where it is repaired. The migration is run first, as the flush runs it, so a line
80
+ // still on the old path (joshuafolkken/kit#2724) is committed at the new one. A lane has no ledger
81
+ // line of its own to carry: its writers append to the primary checkout's.
82
+ async function ledger_exclusions(): Promise<ReadonlyArray<string>> {
83
+ const broken = await observation_ledger_prepare.prepare(await git_command.repository_root())
84
+
85
+ return broken.length > 0 ? OBSERVATION_LEDGER_PATHS : []
86
+ }
87
+
88
+ // A migration claim is a copy mid-move, never the ledger itself, so it is never staged.
89
+ function is_stageable(file_path: string, excluded: ReadonlyArray<string>): boolean {
90
+ return !observation_ledger.is_migration_claim(file_path) && !excluded.includes(file_path)
82
91
  }
83
92
 
84
93
  // **Said out loud, because the alternative is an unexplained failure.** With the ledger the only
@@ -89,27 +98,31 @@ function is_stageable(file_path: string): boolean {
89
98
  // **The paths are parsed rather than matched as substrings**: `docs/maintainers/observations.md.bak` contains the
90
99
  // ledger's path, and a hint naming a file the exclusion never touched is a hint that teaches the
91
100
  // reader to ignore it.
92
- function report_excluded_paths(status_output: string): void {
93
- const excluded = status_output
101
+ function report_excluded_paths(status_output: string, excluded: ReadonlyArray<string>): void {
102
+ const left_out = status_output
94
103
  .split('\n')
95
104
  .filter((line) => line.trim().length > 0)
96
105
  .map((line) => observation_ledger.status_path(line))
97
- .filter((file_path) => !is_stageable(file_path))
106
+ .filter((file_path) => excluded.includes(file_path))
98
107
 
99
- for (const file_path of excluded) {
100
- console.info(`💡 ${file_path} is not staged; commit it with \`pnpm josh observations:flush\`.`)
108
+ for (const file_path of left_out) {
109
+ console.info(
110
+ `💡 ${file_path} is not staged: a ledger line breaks the grammar; \`pnpm josh observations:flush\` names it.`,
111
+ )
101
112
  }
102
113
  }
103
114
 
115
+ // The status is read after the migration, so an old-path ledger it moved is not staged by its old name.
104
116
  async function stage_tracked_files(): Promise<void> {
117
+ const excluded = await ledger_exclusions()
105
118
  const status_output = await git_command.status()
106
119
  const untracked = git_status
107
120
  .list_untracked_files(status_output)
108
- .filter((file_path) => is_stageable(file_path))
121
+ .filter((file_path) => is_stageable(file_path, excluded))
109
122
 
110
- await git_command.add_tracked(PATHS_EXCLUDED_FROM_STAGING)
123
+ await git_command.add_tracked(excluded)
111
124
  console.info('💡 Auto-staged tracked modified files (git add -u).')
112
- report_excluded_paths(status_output)
125
+ report_excluded_paths(status_output, excluded)
113
126
  await stage_untracked_files(untracked)
114
127
  }
115
128
 
@@ -10,6 +10,15 @@ const IN_PROGRESS_LABEL = 'in-progress'
10
10
  // it: a parked child is why a run reports "nothing left that time will fix" rather than waiting
11
11
  // forever (joshuafolkken/kit#860).
12
12
  const NEEDS_DECISION_LABEL = 'needs-decision'
13
+ // Marks an issue whose defect reaches the verification path, so it runs with nothing beside it
14
+ // (joshuafolkken/kit#2776). A run applies it from the verification-path enumeration in `wip-cap.md`;
15
+ // `backlog:next` enforces it (`backlog-solo.ts`), so the rule no longer rests on a judgement at
16
+ // dispatch time.
17
+ const RUN_SOLO_LABEL = 'run:solo'
18
+ // The other half of the same judgement: an issue a run read and found safe to run beside others
19
+ // (joshuafolkken/kit#2779). Without it an issue lacking `run:solo` could mean "judged parallel" or
20
+ // "never judged", and `backlog:next` withholds the second (`epic-triage.ts`).
21
+ const RUN_LANE_LABEL = 'run:lane'
13
22
  // Opts one issue outside any epic into unattended execution (joshuafolkken/kit#906). **Only a
14
23
  // person applies it.** `epicrun #<E>` approves the merges inside `#<E>`; this label is the only way
15
24
  // a person extends that approval past the epic's edge, so a label an AI could apply to itself would
@@ -177,24 +186,71 @@ const DEPTH_LABELS: ReadonlyArray<{
177
186
  },
178
187
  ]
179
188
 
180
- // What `josh start` provisions on a new repository so the first `kickoff` finds every label a run
181
- // applies with its intended color (joshuafolkken/kit#2197). The `epic` and `in-progress` metadata is
182
- // the one the workflow documents create them with; the labels only a person applies are left out.
183
- const WORKFLOW_LABELS: ReadonlyArray<{
189
+ interface LabelDefinition {
184
190
  name: string
185
191
  color: string
186
192
  description: string
187
- }> = [
193
+ }
194
+
195
+ // What `josh start` provisions on a new repository so the first `kickoff` finds every label a run
196
+ // applies with its intended color (joshuafolkken/kit#2197). The `epic` and `in-progress` metadata is
197
+ // the one the workflow documents create them with; the labels only a person applies are left out.
198
+ const WORKFLOW_LABELS: ReadonlyArray<LabelDefinition> = [
188
199
  {
189
200
  name: EPIC_LABEL,
190
201
  color: '5319e7',
191
202
  description: 'Tracks a batch of child issues from one split',
192
203
  },
193
204
  { name: IN_PROGRESS_LABEL, color: '0075ca', description: 'Work is actively in progress' },
205
+ {
206
+ name: RUN_SOLO_LABEL,
207
+ color: 'b60205',
208
+ description: 'Runs alone in a backlogrun: nothing starts beside it',
209
+ },
210
+ {
211
+ name: RUN_LANE_LABEL,
212
+ color: '0e8a16',
213
+ description: 'Judged safe to run beside others in a backlogrun',
214
+ },
194
215
  ...FILING_ROUTE_LABELS,
195
216
  ...DEPTH_LABELS,
196
217
  ]
197
218
 
219
+ // The pull request release classifications `pr-classification.yml` requires exactly one of
220
+ // (joshuafolkken/kit#2797). The check is distributed by `josh sync`, so the labels it asks for are
221
+ // provisioned from this same list — a repository that never had them would otherwise fail the check
222
+ // on every pull request that is not an `enhancement`.
223
+ const BUGFIX_LABEL = 'bugfix'
224
+ const OTHER_CHANGE_LABEL = 'other-change'
225
+ const IGNORE_FOR_RELEASE_LABEL = 'ignore-for-release'
226
+ const RELEASE_CLASSIFICATION_NAMES = [
227
+ BREAKING_CHANGE_LABEL,
228
+ ENHANCEMENT_LABEL,
229
+ BUGFIX_LABEL,
230
+ OTHER_CHANGE_LABEL,
231
+ IGNORE_FOR_RELEASE_LABEL,
232
+ ] as const
233
+ type ReleaseClassification = (typeof RELEASE_CLASSIFICATION_NAMES)[number]
234
+
235
+ const RELEASE_CLASSIFICATION_METADATA: Readonly<
236
+ Record<ReleaseClassification, Omit<LabelDefinition, 'name'>>
237
+ > = {
238
+ [BREAKING_CHANGE_LABEL]: { color: 'd93f0b', description: 'Release: a breaking change (major)' },
239
+ [ENHANCEMENT_LABEL]: { color: '84b6eb', description: 'Release: a new feature (minor)' },
240
+ [BUGFIX_LABEL]: { color: 'd73a4a', description: 'Release: a bug fix (patch)' },
241
+ [OTHER_CHANGE_LABEL]: { color: 'c2e0c6', description: 'Release: any other change (patch)' },
242
+ [IGNORE_FOR_RELEASE_LABEL]: { color: 'cfd3d7', description: 'Release: left out of the notes' },
243
+ }
244
+
245
+ const RELEASE_CLASSIFICATION_LABELS: ReadonlyArray<LabelDefinition> =
246
+ RELEASE_CLASSIFICATION_NAMES.map((name) => ({ name, ...RELEASE_CLASSIFICATION_METADATA[name] }))
247
+
248
+ // Every label kit provisions on a repository — `josh start` on a new one, `josh sync` on every run.
249
+ const REPOSITORY_LABELS: ReadonlyArray<LabelDefinition> = [
250
+ ...WORKFLOW_LABELS,
251
+ ...RELEASE_CLASSIFICATION_LABELS,
252
+ ]
253
+
198
254
  // The shape `gh issue list --json labels` returns; narrowed here so the predicate below takes any
199
255
  // listing row without importing a schema.
200
256
  interface LabelReference {
@@ -269,18 +325,25 @@ const ALL_LABELS: ReadonlySet<string> = new Set([
269
325
  INTERRUPT_ROUTE_LABEL,
270
326
  REVIEW_CAP_ROUTE_LABEL,
271
327
  REVIEW_ROUND2_SKIPPED_LABEL,
328
+ RUN_LANE_LABEL,
329
+ RUN_SOLO_LABEL,
272
330
  SPLIT_ROUTE_LABEL,
273
331
  TIER_A_ROUTE_LABEL,
274
332
  DEPTH_0_LABEL,
275
333
  DEPTH_1_LABEL,
276
334
  DEPTH_2_LABEL,
335
+ BUGFIX_LABEL,
336
+ OTHER_CHANGE_LABEL,
337
+ IGNORE_FOR_RELEASE_LABEL,
277
338
  ])
278
339
 
340
+ export type { LabelDefinition, ReleaseClassification }
279
341
  export {
280
342
  ALL_LABELS,
281
343
  ALREADY_DONE_LABEL,
282
344
  AUTO_OK_LABEL,
283
345
  BUG_LABEL,
346
+ BUGFIX_LABEL,
284
347
  BREAKING_CHANGE_LABEL,
285
348
  depth_label_of,
286
349
  DEPTH_0_LABEL,
@@ -293,14 +356,21 @@ export {
293
356
  FILING_ROUTE_LABELS,
294
357
  has_any_label,
295
358
  has_label_name,
359
+ IGNORE_FOR_RELEASE_LABEL,
296
360
  IN_PROGRESS_LABEL,
297
361
  INTERRUPT_ROUTE_LABEL,
298
362
  label_name_of,
299
363
  NEEDS_DECISION_LABEL,
300
364
  NEEDS_HUMAN_REVIEW_LABEL,
301
365
  NOT_DIRECTLY_RUNNABLE_LABELS,
366
+ OTHER_CHANGE_LABEL,
367
+ RELEASE_CLASSIFICATION_LABELS,
368
+ RELEASE_CLASSIFICATION_NAMES,
369
+ REPOSITORY_LABELS,
302
370
  REVIEW_CAP_ROUTE_LABEL,
303
371
  REVIEW_ROUND2_SKIPPED_LABEL,
372
+ RUN_LANE_LABEL,
373
+ RUN_SOLO_LABEL,
304
374
  SPLIT_ROUTE_LABEL,
305
375
  TIER_A_ROUTE_LABEL,
306
376
  WORKFLOW_LABELS,
@@ -78,6 +78,7 @@ async function build_context(
78
78
  headless_refusals: stop_rules.count_headless_refusals(tail),
79
79
  lane_child: lane_child_marker.is_child_of(process.cwd()),
80
80
  background_pending: lane_background.pending_background_ids(tail).length > 0,
81
+ agent_pending: lane_background.pending_agent_ids(tail).length > 0,
81
82
  session_lang: session_lang(),
82
83
  }
83
84
  }
@@ -0,0 +1,92 @@
1
+ import { existsSync, readFileSync, realpathSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { z } from 'zod'
4
+ import { init_install, type InstallStep } from './init-install'
5
+ import { init_logic_workspace } from './init-logic-workspace'
6
+
7
+ const KIT_PACKAGE_NAME = '@joshuafolkken/kit'
8
+ const NODE_MODULES = 'node_modules'
9
+ const PACKAGE_JSON = 'package.json'
10
+ // The static template lists exactly the build scripts kit's own dependency tree carries, which is
11
+ // what installing kit alone needs approved (joshuafolkken/kit#2785).
12
+ const KIT_BUILDS_TEMPLATE = 'templates/pnpm-workspace.static.yaml'
13
+ const HANDOFF_FAILURE = 'josh init from the project-installed kit failed — see the output above'
14
+ // Set on the handed-off run, so a kit that still does not find itself installed in the project — pnpm
15
+ // put it somewhere else — stops instead of installing and handing off again without end.
16
+ const HANDOFF_ENV = 'JOSH_INIT_HANDED_OFF'
17
+ const REPEATED_HANDOFF = `the kit this project installed is not at ${NODE_MODULES}/${KIT_PACKAGE_NAME} — run \`pnpm exec josh init\` where it is`
18
+
19
+ const dependency_fields_schema = z.looseObject({
20
+ dependencies: z.record(z.string(), z.string()).optional(),
21
+ devDependencies: z.record(z.string(), z.string()).optional(),
22
+ })
23
+
24
+ function real_directory(directory: string): string | undefined {
25
+ return existsSync(directory) ? realpathSync(directory) : undefined
26
+ }
27
+
28
+ // A `pnpm dlx` (or global) run executes whatever kit its cache held, which may be days behind the
29
+ // registry — and `init` pins the kit it runs as, so a stale cache used to leave its version and its
30
+ // templates in the project for good (joshuafolkken/kit#2794). Only the kit the project itself installs
31
+ // does the setup. Compared by real path, so a global virtual store shared with the project still
32
+ // reads as the project's own kit.
33
+ function is_project_kit(package_directory: string, project_root: string): boolean {
34
+ const installed = real_directory(path.join(project_root, NODE_MODULES, KIT_PACKAGE_NAME))
35
+
36
+ return installed !== undefined && installed === real_directory(package_directory)
37
+ }
38
+
39
+ function kit_build_flags(template: string): Array<string> {
40
+ return [...init_logic_workspace.template_build_values(template)]
41
+ .filter(([, value]) => value === 'true')
42
+ .map(([name]) => `--allow-build=${name}`)
43
+ }
44
+
45
+ function has_kit_dependency(project_root: string): boolean {
46
+ const manifest_path = path.join(project_root, PACKAGE_JSON)
47
+ if (!existsSync(manifest_path)) return false
48
+ const parsed: unknown = JSON.parse(readFileSync(manifest_path, 'utf8'))
49
+ const manifest = dependency_fields_schema.parse(parsed)
50
+ const names = Object.keys({ ...manifest.dependencies, ...manifest.devDependencies })
51
+
52
+ return names.includes(KIT_PACKAGE_NAME)
53
+ }
54
+
55
+ // A project that already lists kit keeps the version it chose; otherwise pnpm picks it, under the
56
+ // project's own `minimumReleaseAge`, so the version installed is never the cache's decision.
57
+ function kit_install_step(has_kit: boolean, template: string): InstallStep {
58
+ if (has_kit) return { label: 'pnpm install', command: 'pnpm', args: ['install'] }
59
+ const args = ['add', '-D', ...kit_build_flags(template), KIT_PACKAGE_NAME]
60
+
61
+ return { label: `pnpm ${args.join(' ')}`, command: 'pnpm', args }
62
+ }
63
+
64
+ function handoff_step(args: ReadonlyArray<string>): InstallStep {
65
+ const handoff_args = ['exec', 'josh', 'init', ...args]
66
+
67
+ return {
68
+ label: `pnpm ${handoff_args.join(' ')}`,
69
+ command: 'pnpm',
70
+ args: handoff_args,
71
+ env: { [HANDOFF_ENV]: '1' },
72
+ }
73
+ }
74
+
75
+ // Returns the failure to report, or undefined once the project-installed kit finished the setup.
76
+ function hand_off(
77
+ package_directory: string,
78
+ project_root: string,
79
+ args: ReadonlyArray<string>,
80
+ ): string | undefined {
81
+ if (process.env[HANDOFF_ENV] !== undefined) return REPEATED_HANDOFF
82
+ console.info('\n🚀 Installing @joshuafolkken/kit into the project, then running its josh init')
83
+ const template = readFileSync(path.join(package_directory, KIT_BUILDS_TEMPLATE), 'utf8')
84
+ const install = kit_install_step(has_kit_dependency(project_root), template)
85
+ if (!init_install.did_step_succeed(install, project_root)) return `${install.label} failed`
86
+ if (!init_install.did_step_succeed(handoff_step(args), project_root)) return HANDOFF_FAILURE
87
+
88
+ return undefined
89
+ }
90
+
91
+ const init_bootstrap = { is_project_kit, kit_build_flags, hand_off }
92
+ export { init_bootstrap }
@@ -0,0 +1,67 @@
1
+ import { execaSync } from 'execa'
2
+
3
+ interface InstallStep {
4
+ label: string
5
+ command: string
6
+ args: ReadonlyArray<string>
7
+ env?: Readonly<Record<string, string>>
8
+ }
9
+
10
+ interface InstallRequest {
11
+ is_install: boolean
12
+ rest: ReadonlyArray<string>
13
+ }
14
+
15
+ const NO_INSTALL_FLAG = '--no-install'
16
+ const INSTALL_STEP: InstallStep = { label: 'pnpm install', command: 'pnpm', args: ['install'] }
17
+ const FORMAT_STEP: InstallStep = {
18
+ label: 'pnpm josh format',
19
+ command: 'pnpm',
20
+ args: ['exec', 'josh', 'format'],
21
+ }
22
+ const INSTALL_FAILURE = [
23
+ `${INSTALL_STEP.label} failed — fix the cause above,`,
24
+ `then run it manually: ${INSTALL_STEP.label} && ${FORMAT_STEP.label}`,
25
+ ].join(' ')
26
+ const FORMAT_WARNING = [
27
+ ` ⚠ ${FORMAT_STEP.label} left problems it could not fix in the project's own files —`,
28
+ `the setup itself succeeded; fix them, then run: ${FORMAT_STEP.label}`,
29
+ ].join(' ')
30
+
31
+ // The flag is peeled off before the `--profile` pair is parsed, so the profile parser stays the one
32
+ // `josh start` shares.
33
+ function split_install_flag(args: ReadonlyArray<string>): InstallRequest {
34
+ return {
35
+ is_install: !args.includes(NO_INSTALL_FLAG),
36
+ rest: args.filter((argument) => argument !== NO_INSTALL_FLAG),
37
+ }
38
+ }
39
+
40
+ function did_step_succeed(step: InstallStep, project_root: string): boolean {
41
+ console.info(`\n$ ${step.label}`)
42
+ const result = execaSync(step.command, step.args, {
43
+ cwd: project_root,
44
+ env: step.env ?? {},
45
+ stdio: 'inherit',
46
+ reject: false,
47
+ })
48
+
49
+ return result.exitCode === 0
50
+ }
51
+
52
+ // Returns the failure to report, or undefined once the install passed. A failed install stops
53
+ // there: `josh format` runs prettier and the kit itself, both of which the install provides. A failed
54
+ // format does not fail `init` — in a node project it also runs `eslint --fix`, which exits non-zero on
55
+ // the project's own unfixable errors, and those are the project's result, not the setup's (the
56
+ // reason `josh gate` is left out of `init` too).
57
+ function run_post_init_steps(project_root: string): string | undefined {
58
+ console.info('\nDependencies:')
59
+ if (!did_step_succeed(INSTALL_STEP, project_root)) return INSTALL_FAILURE
60
+ if (!did_step_succeed(FORMAT_STEP, project_root)) console.warn(FORMAT_WARNING)
61
+
62
+ return undefined
63
+ }
64
+
65
+ const init_install = { NO_INSTALL_FLAG, split_install_flag, did_step_succeed, run_post_init_steps }
66
+ export { init_install }
67
+ export type { InstallStep }
@@ -138,6 +138,6 @@ function merge_workspace_yaml(existing: string, template: string): string {
138
138
  return append_user_blocks(cleaned, new_keys, template)
139
139
  }
140
140
 
141
- const init_logic_workspace = { merge_workspace_yaml }
141
+ const init_logic_workspace = { merge_workspace_yaml, template_build_values }
142
142
 
143
143
  export { init_logic_workspace }
@@ -15,11 +15,13 @@ import { execaSync } from 'execa'
15
15
  import { z } from 'zod'
16
16
  import { init_actions, PRETTIER_CONFIG_JS, type FileAction } from './init-actions'
17
17
  import { init_ai_copy } from './init-ai-copy'
18
+ import { init_bootstrap } from './init-bootstrap'
19
+ import { init_install } from './init-install'
18
20
  import { init_logic } from './init-logic'
19
21
  import { PACKAGE_DIR, PROJECT_ROOT } from './init-paths'
20
22
  import { init_static } from './init-static'
21
23
  import { plugin_install_hint_module } from './plugin-install-hint'
22
- import { project_profile, type ProjectShape } from './project-profile'
24
+ import { project_profile, type ProjectProfile, type ProjectShape } from './project-profile'
23
25
 
24
26
  const PACKAGE_JSON = 'package.json'
25
27
  const KIT_PACKAGE_NAME = '@joshuafolkken/kit'
@@ -28,6 +30,7 @@ const SAMPLE_INDENT_WIDTH = 4
28
30
  const ARGUMENT_START_INDEX = 2
29
31
  const SAMPLE_INDENT = ' '.repeat(SAMPLE_INDENT_WIDTH)
30
32
  const INSTALL_HINT = '→ run `pnpm install` to install what package.json lists'
33
+ const INIT_USAGE = 'josh init [--profile static|node] [--no-install]'
31
34
 
32
35
  function write_new_file(action: FileAction, destination_path: string): void {
33
36
  mkdirSync(path.dirname(destination_path), { recursive: true })
@@ -218,14 +221,10 @@ function merge_project_package_json(shape: ProjectShape): void {
218
221
 
219
222
  writeFileSync(package_json_path, merged)
220
223
  console.info(` ✔ ${is_existing ? 'updated' : 'created'} package.json`)
221
- // `init` writes the manifest but installs nothing, so a tool it just listed is absent until the
222
- // user installs it — and `josh lint` then fails on it rather than skipping it
223
- // (joshuafolkken/kit#2709). Saying so here is what tells the user the next step.
224
- console.info(` ${INSTALL_HINT}`)
225
224
  }
226
225
 
227
- // A fresh project runs `init` before its first `pnpm install`, so lefthook — added to devDependencies
228
- // above — is not there yet; the `prepare` script installs the hooks once it is.
226
+ // Under `--no-install` a fresh project has no lefthook yet — added to devDependencies above — so the
227
+ // `prepare` script of the user's own `pnpm install` installs the hooks once it is.
229
228
  function install_lefthook(project_root: string = PROJECT_ROOT): void {
230
229
  console.info('\nLefthook:')
231
230
  const bin = resolve_local_bin(project_root, LEFTHOOK_BIN)
@@ -285,8 +284,6 @@ function run_ai_file_actions(shape: ProjectShape): void {
285
284
  // resolved, so `gh repo view` runs once; the position is the pre-existing one.
286
285
  const name_with_owner = init_ai_copy.run_ai_copies(shape)
287
286
 
288
- if (shape.has_git && shape.profile === 'node') install_lefthook()
289
-
290
287
  if (shape.has_github) report_repository_settings(name_with_owner)
291
288
 
292
289
  if (shape.profile === 'node') plugin_install_hint_module.report_plugin_install_hint()
@@ -302,19 +299,67 @@ function initialize_project(shape: ProjectShape): void {
302
299
 
303
300
  run_ai_file_actions(shape)
304
301
  if (shape.profile === 'node') project_config.sync_project_config(PROJECT_ROOT)
302
+ }
303
+
304
+ // A tool `init` just listed is absent until the user installs it — and `josh lint` then fails on it
305
+ // rather than skipping it (joshuafolkken/kit#2709). Saying so is what tells the user the next step.
306
+ function report_manual_install(shape: ProjectShape): void {
307
+ console.info(`\nDependencies:\n ${INSTALL_HINT}`)
308
+ if (shape.has_git && shape.profile === 'node') install_lefthook()
309
+ }
310
+
311
+ // The install runs the `prepare` script, which installs the git hooks. A failure is thrown rather
312
+ // than printed, so `josh start` — which calls this entry point — stops before its first commit.
313
+ function finish_dependencies(shape: ProjectShape, is_install: boolean): void {
314
+ if (!is_install) {
315
+ report_manual_install(shape)
316
+
317
+ return
318
+ }
319
+
320
+ const failure = init_install.run_post_init_steps(PROJECT_ROOT)
321
+ if (failure !== undefined) throw new Error(failure)
322
+ }
323
+
324
+ // A kit run from outside the project — `pnpm dlx`, a global install — sets nothing up itself: it
325
+ // installs the project's kit and hands the whole run to that one (joshuafolkken/kit#2794). Under
326
+ // `--no-install` nothing is installed, so the running kit does the setup as before.
327
+ function did_hand_off(args: ReadonlyArray<string>): boolean {
328
+ if (init_bootstrap.is_project_kit(PACKAGE_DIR, PROJECT_ROOT)) return false
329
+ const failure = init_bootstrap.hand_off(PACKAGE_DIR, PROJECT_ROOT, args)
330
+ if (failure !== undefined) throw new Error(failure)
331
+
332
+ return true
333
+ }
334
+
335
+ function set_up_project(requested: ProjectProfile | undefined, is_install: boolean): void {
336
+ const shape = project_profile.inspect_project(PROJECT_ROOT, requested)
337
+
338
+ initialize_project(shape)
339
+ finish_dependencies(shape, is_install)
305
340
  console.info('\n✅ Done.\n')
306
341
  }
307
342
 
308
343
  function main(args: ReadonlyArray<string> = []): void {
309
344
  if (did_refuse_self_run(PACKAGE_DIR, PROJECT_ROOT)) return
310
- const requested = project_profile.requested_profile(args)
311
- const shape = project_profile.inspect_project(PROJECT_ROOT, requested)
345
+ const { is_install, rest } = init_install.split_install_flag(args)
346
+ // Validated before the hand-off, so a malformed invocation is refused before kit is installed.
347
+ const requested = project_profile.requested_profile(rest, INIT_USAGE)
312
348
 
313
- initialize_project(shape)
349
+ if (!is_install || !did_hand_off(args)) set_up_project(requested, is_install)
350
+ }
351
+
352
+ function run_cli(args: ReadonlyArray<string>): void {
353
+ try {
354
+ main(args)
355
+ } catch (error) {
356
+ console.error(`\n✖ ${error instanceof Error ? error.message : String(error)}\n`)
357
+ process.exitCode = 1
358
+ }
314
359
  }
315
360
 
316
361
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
317
- main(process.argv.slice(ARGUMENT_START_INDEX))
362
+ run_cli(process.argv.slice(ARGUMENT_START_INDEX))
318
363
  }
319
364
 
320
365
  const init = {
@@ -46,7 +46,7 @@ const STEP_LABELS: Readonly<Record<StartStep, string>> = {
46
46
  initialize: 'Initialize kit (the same setup as josh init)',
47
47
  commit: 'Commit every file as the initial commit',
48
48
  repository: 'Create the GitHub repository and push main',
49
- labels: 'Add the workflow labels the repository is missing',
49
+ labels: 'Add the workflow and release-classification labels the repository is missing',
50
50
  }
51
51
 
52
52
  function refused(refusal: string): StepPlan {
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path'
2
2
  import { git_gh_exec } from '#scripts/git/git-gh-exec'
3
- import { WORKFLOW_LABELS } from '#scripts/git/issue-labels'
3
+ import { repository_labels } from '#scripts/repo/repository-labels'
4
4
  import { self_sync_guard } from '#scripts/self-sync-guard/self-sync-guard-logic'
5
5
  import { execaSync } from 'execa'
6
6
  import { main as init_main } from './init'
@@ -14,10 +14,7 @@ interface StepContext {
14
14
  visibility: Visibility
15
15
  }
16
16
 
17
- type WorkflowLabel = (typeof WORKFLOW_LABELS)[number]
18
-
19
17
  const DEFAULT_BRANCH = 'main'
20
- const LABELS_ENDPOINT = 'repos/{owner}/{repo}/labels'
21
18
  const GH_INSTALL_HINT =
22
19
  'GitHub CLI (gh) is not installed. Install it from https://cli.github.com/, run gh auth login, then run josh start again. Nothing was changed.'
23
20
  const GH_AUTH_HINT =
@@ -100,29 +97,10 @@ function create_github_repository(context: StepContext): void {
100
97
  run('gh', ['repo', 'create', name, visibility, ...source], context.root)
101
98
  }
102
99
 
103
- // Labels already present are left as they are — only the missing ones are created, so a repository
104
- // that recolored a label keeps its choice. GitHub compares label names case-insensitively.
105
- function missing_workflow_labels(existing: ReadonlyArray<string>): ReadonlyArray<WorkflowLabel> {
106
- const names = new Set(existing.map((name) => name.toLowerCase()))
107
-
108
- return WORKFLOW_LABELS.filter((label) => !names.has(label.name))
109
- }
110
-
111
- function create_label(label: WorkflowLabel, root: string): void {
112
- const fields = [`name=${label.name}`, `color=${label.color}`, `description=${label.description}`]
113
-
114
- run('gh', ['api', LABELS_ENDPOINT, ...fields.flatMap((field) => ['-f', field]), '--silent'], root)
115
- console.info(` ✔ label ${label.name}`)
116
- }
117
-
118
- function add_missing_labels(context: StepContext): void {
119
- const listing = execaSync('gh', ['api', '--paginate', LABELS_ENDPOINT, '--jq', '.[].name'], {
120
- ...git_gh_exec.direct_environment(),
121
- cwd: context.root,
122
- })
123
- const existing = listing.stdout.split('\n').filter((name) => name.length > 0)
124
-
125
- for (const label of missing_workflow_labels(existing)) create_label(label, context.root)
100
+ // The repository was created by the step before, so `gh` resolves the placeholder from the
101
+ // directory `josh start` runs in, which is the root.
102
+ function add_missing_labels(): void {
103
+ repository_labels.ensure_labels('{owner}/{repo}')
126
104
  }
127
105
 
128
106
  const STEP_ACTIONS: Readonly<Record<StartStep, (context: StepContext) => void>> = {
@@ -160,7 +138,6 @@ const start_steps = {
160
138
  read_git_state,
161
139
  github_cli_refusal,
162
140
  self_run_refusal,
163
- missing_workflow_labels,
164
141
  run_steps,
165
142
  }
166
143
  export { start_steps }