@joshuafolkken/kit 1.883.0 → 1.921.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 (112) hide show
  1. package/.claude/skills/workflow-commands/fullrun-steps.md +4 -2
  2. package/.claude/skills/workflow-commands/halfrun.md +3 -1
  3. package/.claude/skills/workflow-commands/kickoff.md +5 -3
  4. package/.claude/skills/workflow-commands/observation-filing.md +3 -1
  5. package/.claude/skills/workflow-commands/prerequisite.md +3 -1
  6. package/.claude/skills/workflow-commands/target-repository.md +1 -1
  7. package/.github/pull_request_template.md +12 -0
  8. package/.github/release.yml +3 -0
  9. package/.github/workflows/ci.yml +8 -0
  10. package/.github/workflows/production.yml +10 -1
  11. package/.github/workflows/publish.yml +72 -2
  12. package/CLAUDE.md +2 -0
  13. package/dist/CLAUDE.md +2 -0
  14. package/dist/CLAUDE.static.md +8 -0
  15. package/dist/hooks/batch-guard.js +4 -4
  16. package/dist/hooks/{chunk-36UE53LS.js → chunk-3HZ5MJIW.js} +2 -2
  17. package/dist/hooks/chunk-3V37IID6.js +82 -0
  18. package/dist/hooks/{chunk-FRJKVSK3.js → chunk-77RLKZ5V.js} +2 -2
  19. package/dist/hooks/{chunk-VGYSA2NT.js → chunk-LC765SUA.js} +16 -2
  20. package/dist/hooks/{chunk-ZBK4XRAS.js → chunk-NWD47MW3.js} +3 -3
  21. package/dist/hooks/{chunk-4OXFU3MK.js → chunk-O2FBZPSW.js} +619 -156
  22. package/dist/hooks/{chunk-6MQCXEEA.js → chunk-OLTAGGHH.js} +25 -88
  23. package/dist/hooks/{chunk-FNFOLFFV.js → chunk-OYU6KPRA.js} +1 -1
  24. package/dist/hooks/{chunk-PRDCNGA6.js → chunk-UANRLZV7.js} +4 -4
  25. package/dist/hooks/{chunk-264QY6KS.js → chunk-UULARQ5Q.js} +23 -1
  26. package/dist/hooks/{chunk-Y5OYCU2F.js → chunk-XMR3BS35.js} +3 -3
  27. package/dist/hooks/codex-hook-adapter.js +11 -10
  28. package/dist/hooks/duplicate-read-guard.js +5 -5
  29. package/dist/hooks/format-edited.js +3 -2
  30. package/dist/hooks/investigation-guard.js +5 -5
  31. package/dist/hooks/pretool-guard.js +10 -9
  32. package/dist/hooks/stop-guard.js +3 -3
  33. package/dist/josh.js +155 -12
  34. package/package.json +70 -19
  35. package/prettier/index.js +3 -8
  36. package/prettier/static.d.ts +3 -0
  37. package/prettier/static.js +10 -0
  38. package/prompts/collaboration-workflow/issue-template.md +11 -4
  39. package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
  40. package/prompts/collaboration-workflow/wip-cap.md +2 -2
  41. package/prompts/review.md +2 -2
  42. package/prompts/testing-guide.md +1 -0
  43. package/scripts/build/build-claude-md.ts +6 -1
  44. package/scripts/ci/pr-classification.ts +114 -0
  45. package/scripts/document/document-byte-budget.ts +3 -2
  46. package/scripts/gate/gate-report.ts +2 -0
  47. package/scripts/gate/gate-steps.ts +50 -0
  48. package/scripts/gate/project-checks.ts +121 -0
  49. package/scripts/gate/verification-gate.ts +14 -43
  50. package/scripts/git/git-common-directory.ts +9 -1
  51. package/scripts/git/git-gh-pr-rollup.ts +29 -1
  52. package/scripts/git/git-gh-pr.ts +61 -1
  53. package/scripts/git/git-pr.ts +62 -13
  54. package/scripts/git/issue-labels.ts +9 -0
  55. package/scripts/init/distributed-paths.ts +9 -5
  56. package/scripts/init/hook-command-bootstrap.ts +40 -8
  57. package/scripts/init/hook-command-rewrite.ts +7 -3
  58. package/scripts/init/init-actions.ts +94 -7
  59. package/scripts/init/init-ai-copy.ts +104 -19
  60. package/scripts/init/init-static.ts +52 -0
  61. package/scripts/init/init.ts +97 -30
  62. package/scripts/init/project-profile-cli.ts +14 -0
  63. package/scripts/init/project-profile.ts +149 -0
  64. package/scripts/init/start.ts +30 -0
  65. package/scripts/issue/issue-bug-label.ts +13 -0
  66. package/scripts/issue/issue-classification.ts +74 -0
  67. package/scripts/issue/issue-lint-cli.ts +10 -3
  68. package/scripts/issue/markdown-section.ts +38 -4
  69. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +27 -1
  70. package/scripts/josh/josh-command-map.ts +3 -0
  71. package/scripts/josh/josh-commands-development.ts +6 -0
  72. package/scripts/josh/josh-commands-project.ts +14 -1
  73. package/scripts/josh/josh-logic.ts +13 -0
  74. package/scripts/josh/run-github-prerequisite.ts +32 -0
  75. package/scripts/lane/lane-await.ts +8 -1
  76. package/scripts/lane/openai-lane-ship.ts +30 -0
  77. package/scripts/lane/openai-lane-supervisor.ts +35 -8
  78. package/scripts/lane/openai-review-broker.ts +260 -0
  79. package/scripts/lint/cspell-cached.ts +21 -0
  80. package/scripts/lint/lint-parallel.ts +44 -1
  81. package/scripts/observations/observations-flush.ts +1 -1
  82. package/scripts/package/production-dependency-graph.ts +68 -0
  83. package/scripts/package/verify-optional-eslint-install.ts +62 -0
  84. package/scripts/propagate/propagate-run.ts +6 -0
  85. package/scripts/propagate/propagate-steps.ts +2 -0
  86. package/scripts/release/github-release-cli.ts +11 -0
  87. package/scripts/release/github-release.ts +220 -0
  88. package/scripts/release/publish-tag-cli.ts +8 -0
  89. package/scripts/release/publish-tag.ts +11 -0
  90. package/scripts/release/release-publish.ts +7 -2
  91. package/scripts/rules/delivered-rules.ts +2 -0
  92. package/scripts/rules/issue-bug-label.ts +85 -0
  93. package/scripts/rules/issue-filing-args.ts +50 -0
  94. package/scripts/run/run-merge-cli.ts +19 -0
  95. package/scripts/run/run-merge.ts +9 -1
  96. package/scripts/run/run-ship-cli.ts +20 -0
  97. package/scripts/run/run-ship-detach.ts +180 -14
  98. package/scripts/run/run-ship-review-steps.ts +45 -25
  99. package/scripts/run/run-wake-session.ts +3 -1
  100. package/scripts/test/pilot-files.ts +0 -2
  101. package/scripts/test/skip-marker.ts +3 -0
  102. package/scripts/test/test-declared-changed.ts +2 -1
  103. package/scripts/test/test-declared-logic.ts +15 -10
  104. package/scripts/test/test-declared.ts +20 -8
  105. package/scripts/test/test-red-logic.ts +3 -3
  106. package/scripts/test/test-unit-guard.ts +24 -12
  107. package/scripts/version/version-check-arguments.ts +14 -0
  108. package/scripts/version/version-check.ts +10 -3
  109. package/templates/CLAUDE.static.md +8 -0
  110. package/templates/cursorrules.static +1 -0
  111. package/templates/prettierignore.static +12 -0
  112. package/templates/workflows/ci.yml +5 -0
@@ -1,3 +1,4 @@
1
+ import { pr_classification, type ReleaseClassification } from '#scripts/ci/pr-classification'
1
2
  import { animation_helpers, type AnimationOptions } from './animation-helpers'
2
3
  import { git_gh_command } from './git-gh-command'
3
4
  import type { IssueInfo } from './git-issue'
@@ -12,7 +13,11 @@ import { pr_info_schema } from './schemas'
12
13
  // in the same instant the pull request was created it can answer nothing, and `pr_get_url` folds
13
14
  // "not there yet" and "the read failed" into the same `undefined`. The five-second sleep used to
14
15
  // cover that gap incidentally.
15
- async function create_pr(title: string, body: string): Promise<string | undefined> {
16
+ async function create_pr(
17
+ title: string,
18
+ body: string,
19
+ label: ReleaseClassification,
20
+ ): Promise<string | undefined> {
16
21
  const config: AnimationOptions<string> = {
17
22
  icon_selector: () => '✅',
18
23
  error_message: 'Failed to create PR',
@@ -22,7 +27,7 @@ async function create_pr(title: string, body: string): Promise<string | undefine
22
27
  try {
23
28
  return await animation_helpers.execute_with_animation(
24
29
  'Creating pull request...',
25
- async () => await git_gh_command.pr_create(title, body),
30
+ async () => await git_gh_command.pr_create(title, body, label),
26
31
  config,
27
32
  )
28
33
  } catch (error) {
@@ -72,8 +77,13 @@ async function report_open_pr(branch_name: string, created_url?: string): Promis
72
77
 
73
78
  const PR_STATE_MERGED = 'MERGED'
74
79
 
75
- async function create_and_report(title: string, body: string, branch_name: string): Promise<void> {
76
- const created_url = await create_pr(title, body)
80
+ async function create_and_report(
81
+ title: string,
82
+ body: string,
83
+ branch_name: string,
84
+ label: ReleaseClassification,
85
+ ): Promise<void> {
86
+ const created_url = await create_pr(title, body, label)
77
87
 
78
88
  await report_open_pr(branch_name, created_url)
79
89
  }
@@ -113,7 +123,9 @@ async function report_existing_pr(
113
123
  body: string,
114
124
  branch_name: string,
115
125
  should_replace_body: boolean,
126
+ label: ReleaseClassification,
116
127
  ): Promise<void> {
128
+ await git_gh_command.pr_ensure_classification(branch_name, label)
117
129
  if (should_replace_body) await git_gh_command.pr_update_body(branch_name, body)
118
130
 
119
131
  await report_open_pr(branch_name)
@@ -123,35 +135,36 @@ async function handle_existing_pr(
123
135
  title: string,
124
136
  body: string,
125
137
  branch_name: string,
126
- should_replace_body: boolean,
138
+ options: { label: ReleaseClassification; should_replace_body: boolean },
127
139
  ): Promise<void> {
128
140
  const pr_state_result = await get_pr_state_safe(branch_name)
129
141
 
130
142
  if (is_pr_state_merged(pr_state_result)) {
131
143
  git_pr_messages.display_merged_pr_message()
132
- await create_and_report(title, body, branch_name)
144
+ await create_and_report(title, body, branch_name, options.label)
133
145
 
134
146
  return
135
147
  }
136
148
 
137
- await report_existing_pr(body, branch_name, should_replace_body)
149
+ await report_existing_pr(body, branch_name, options.should_replace_body, options.label)
138
150
  }
139
151
 
140
152
  async function create(
141
153
  title: string,
142
154
  body: string,
143
155
  branch_name: string,
144
- should_replace_body = false,
156
+ options: { label: ReleaseClassification; should_replace_body?: boolean },
145
157
  ): Promise<void> {
158
+ const { label, should_replace_body = false } = options
146
159
  const has_pr = await git_gh_command.pr_exists(branch_name)
147
160
 
148
161
  if (!has_pr) {
149
- await create_and_report(title, body, branch_name)
162
+ await create_and_report(title, body, branch_name, label)
150
163
 
151
164
  return
152
165
  }
153
166
 
154
- await handle_existing_pr(title, body, branch_name, should_replace_body)
167
+ await handle_existing_pr(title, body, branch_name, { label, should_replace_body })
155
168
  }
156
169
 
157
170
  function build_title(issue_info: IssueInfo): string {
@@ -166,11 +179,47 @@ function build_body(issue_info: IssueInfo, extra_body?: string): string {
166
179
  return `${closes}\n\n${extra_body}`
167
180
  }
168
181
 
182
+ async function existing_pr_label(branch_name: string): Promise<ReleaseClassification | undefined> {
183
+ if (!(await git_gh_command.pr_exists(branch_name))) return undefined
184
+ if (is_pr_state_merged(await get_pr_state_safe(branch_name))) return undefined
185
+
186
+ return await git_gh_command.pr_get_classification(branch_name)
187
+ }
188
+
189
+ async function create_for_issue(
190
+ issue_info: IssueInfo,
191
+ extra_body: string | undefined,
192
+ label: ReleaseClassification,
193
+ ): Promise<void> {
194
+ await create(
195
+ build_title(issue_info),
196
+ build_body(issue_info, extra_body),
197
+ issue_info.branch_name,
198
+ {
199
+ label,
200
+ should_replace_body: extra_body !== undefined,
201
+ },
202
+ )
203
+ }
204
+
169
205
  async function create_with_issue_info(issue_info: IssueInfo, extra_body?: string): Promise<void> {
170
- const title = build_title(issue_info)
171
- const body = build_body(issue_info, extra_body)
206
+ const existing_label = await existing_pr_label(issue_info.branch_name)
207
+
208
+ if (existing_label !== undefined) {
209
+ await create_for_issue(issue_info, extra_body, existing_label)
210
+
211
+ return
212
+ }
213
+
214
+ const issue_json = await git_gh_command.issue_view_json(issue_info.number, 'labels,body')
215
+
216
+ if (issue_json === undefined) {
217
+ throw new Error(`Could not read issue #${issue_info.number} for release classification`)
218
+ }
219
+
220
+ const label = pr_classification.select_issue_classification(issue_json)
172
221
 
173
- await create(title, body, issue_info.branch_name, extra_body !== undefined)
222
+ await create_for_issue(issue_info, extra_body, label)
174
223
  }
175
224
 
176
225
  const git_pr = {
@@ -2,6 +2,9 @@
2
2
  // labeler and the next-issues display all key on these exact strings, and a drifted copy would
3
3
  // fail silently — an epic filtered on the wrong name is simply never closed or never excluded.
4
4
  const EPIC_LABEL = 'epic'
5
+ const BUG_LABEL = 'bug'
6
+ const ENHANCEMENT_LABEL = 'enhancement'
7
+ const BREAKING_CHANGE_LABEL = 'breaking-change'
5
8
  const IN_PROGRESS_LABEL = 'in-progress'
6
9
  // Parks a child that cannot advance without a person deciding something. `epic:next` is what reads
7
10
  // it: a parked child is why a run reports "nothing left that time will fix" rather than waiting
@@ -237,6 +240,9 @@ const REVIEW_ROUND2_SKIPPED_LABEL = 'review-round2-skipped'
237
240
 
238
241
  const ALL_LABELS: ReadonlySet<string> = new Set([
239
242
  EPIC_LABEL,
243
+ BUG_LABEL,
244
+ ENHANCEMENT_LABEL,
245
+ BREAKING_CHANGE_LABEL,
240
246
  IN_PROGRESS_LABEL,
241
247
  NEEDS_DECISION_LABEL,
242
248
  AUTO_OK_LABEL,
@@ -256,12 +262,15 @@ export {
256
262
  ALL_LABELS,
257
263
  ALREADY_DONE_LABEL,
258
264
  AUTO_OK_LABEL,
265
+ BUG_LABEL,
266
+ BREAKING_CHANGE_LABEL,
259
267
  depth_label_of,
260
268
  DEPTH_0_LABEL,
261
269
  DEPTH_1_LABEL,
262
270
  DEPTH_2_LABEL,
263
271
  DEPTH_LABEL_ORDER,
264
272
  DEPTH_LABELS,
273
+ ENHANCEMENT_LABEL,
265
274
  EPIC_LABEL,
266
275
  FILING_ROUTE_LABELS,
267
276
  has_any_label,
@@ -20,8 +20,10 @@ const GITHUB_REPO_TREE_BASE = 'https://github.com/joshuafolkken/kit/tree/main/'
20
20
  // The consumer imports kit's published, already path-transformed rules. A tracked bootstrap note
21
21
  // remains readable before installation, while the rule body stays in the package (kit#1878).
22
22
  const CLAUDE_MD_IMPORT_LINE = '@node_modules/@joshuafolkken/kit/dist/CLAUDE.md'
23
- const CLAUDE_MD_BOOTSTRAP =
23
+ const OLD_CLAUDE_MD_BOOTSTRAP =
24
24
  '> Fresh checkout: if kit is not installed, run `pnpm install` first, then reread this file before doing any other work.'
25
+ const CLAUDE_MD_BOOTSTRAP =
26
+ '> Fresh checkout: before `pnpm install`, run `pnpm config get "@joshuafolkken:registry"` from the project root to check the effective registry, including user-level settings. If it points to GitHub Packages, [set up authentication](https://github.com/joshuafolkken/kit/blob/main/docs/authentication.md#1-get-a-token-from-the-gh-cli) first. Then run `pnpm install` and reread this file before doing any other work.'
25
27
 
26
28
  // A span containing `*` is excluded: it is a **glob**, not a reference to a file a consumer can
27
29
  // open. A distributed document that writes a directory set as `prompts/**` means "anything beneath
@@ -84,13 +86,15 @@ function transform_distributed_paths(content: string): string {
84
86
  function ensure_claude_md_import(existing: string | undefined): string {
85
87
  if (existing === undefined) return `${CLAUDE_MD_BOOTSTRAP}\n\n${CLAUDE_MD_IMPORT_LINE}\n`
86
88
 
87
- if (existing.includes(CLAUDE_MD_IMPORT_LINE)) {
88
- if (existing.includes(CLAUDE_MD_BOOTSTRAP)) return existing
89
+ const updated = existing.replace(OLD_CLAUDE_MD_BOOTSTRAP, () => CLAUDE_MD_BOOTSTRAP)
90
+
91
+ if (updated.includes(CLAUDE_MD_IMPORT_LINE)) {
92
+ if (updated.includes(CLAUDE_MD_BOOTSTRAP)) return updated
89
93
 
90
- return `${CLAUDE_MD_BOOTSTRAP}\n\n${existing}`
94
+ return `${CLAUDE_MD_BOOTSTRAP}\n\n${updated}`
91
95
  }
92
96
 
93
- return `${CLAUDE_MD_BOOTSTRAP}\n\n${CLAUDE_MD_IMPORT_LINE}\n\n${existing}`
97
+ return `${CLAUDE_MD_BOOTSTRAP}\n\n${CLAUDE_MD_IMPORT_LINE}\n\n${updated}`
94
98
  }
95
99
 
96
100
  const distributed_paths = {
@@ -10,6 +10,11 @@ interface HookResult {
10
10
  stderr: string
11
11
  }
12
12
 
13
+ interface CheckoutOptions {
14
+ git_overrides?: Record<string, string>
15
+ is_external_git?: boolean
16
+ }
17
+
13
18
  function create_partial_install(temporary_root: string): void {
14
19
  const package_root = path.join(temporary_root, 'node_modules/@joshuafolkken/kit')
15
20
  const hook_path = path.join(package_root, 'dist/hooks/pretool-guard.js')
@@ -19,25 +24,52 @@ function create_partial_install(temporary_root: string): void {
19
24
  writeFileSync(hook_path, "process.stdout.write('guard ran')")
20
25
  }
21
26
 
22
- function run_in_temporary_checkout(command: string, is_partial_install = false): HookResult {
23
- const temporary_root = mkdtempSync(path.join(tmpdir(), 'kit-hook-bootstrap-'))
27
+ function execution_directory(temporary_root: string, is_nested: boolean): string {
28
+ if (!is_nested) return temporary_root
29
+
30
+ const nested_root = path.join(temporary_root, 'src')
31
+
32
+ mkdirSync(nested_root)
33
+
34
+ return nested_root
35
+ }
36
+
37
+ function checkout_environment(temporary_root: string, options: CheckoutOptions): NodeJS.ProcessEnv {
38
+ const metadata_root = path.join(temporary_root, 'metadata')
24
39
  const git_environment = {
25
40
  ...process.env,
26
41
  ...git_location_environment.location_free_environment(),
27
42
  }
43
+ const git_arguments = options.is_external_git
44
+ ? ['init', '--bare', '-q', metadata_root]
45
+ : ['init', '-q']
46
+
47
+ spawnSync('git', git_arguments, { cwd: temporary_root, env: git_environment })
48
+
49
+ if (options.is_external_git) {
50
+ return { ...git_environment, GIT_DIR: metadata_root, GIT_WORK_TREE: temporary_root }
51
+ }
52
+
53
+ return { ...git_environment, ...options.git_overrides }
54
+ }
55
+
56
+ function run_in_temporary_checkout(
57
+ command: string,
58
+ is_partial_install = false,
59
+ is_nested = false,
60
+ options: CheckoutOptions = {},
61
+ ): HookResult {
62
+ const temporary_root = mkdtempSync(path.join(tmpdir(), 'kit-hook-bootstrap-'))
28
63
 
29
64
  try {
30
- spawnSync('git', ['init', '-q'], {
31
- cwd: temporary_root,
32
- env: git_environment,
33
- })
65
+ const hook_environment = checkout_environment(temporary_root, options)
34
66
 
35
67
  if (is_partial_install) create_partial_install(temporary_root)
36
68
 
37
69
  const { status, stdout, stderr } = spawnSync('sh', ['-c', command], {
38
- cwd: temporary_root,
70
+ cwd: execution_directory(temporary_root, is_nested),
39
71
  encoding: 'utf8',
40
- env: git_environment,
72
+ env: hook_environment,
41
73
  })
42
74
 
43
75
  return { status, stdout, stderr }
@@ -1,3 +1,4 @@
1
+ import { GIT_LOCATION_VARIABLES } from '#scripts/git/git-location-environment'
1
2
  import { claude_plugin_config } from './claude-plugin-config'
2
3
  import { hook_launch } from './hook-launch'
3
4
 
@@ -15,7 +16,7 @@ import { hook_launch } from './hook-launch'
15
16
 
16
17
  const PNPM_JOSH_PREFIX = 'pnpm josh '
17
18
  const CODEX_ADAPTER_SOURCE = 'pnpm exec tsx scripts/hooks/codex-hook-adapter.ts'
18
- // Relative to the consumer's project root, which is the working directory a Claude Code hook runs in.
19
+ // Relative to the consumer's project root, which hook commands select before using this path.
19
20
  // The path is the plugin marketplace's `node_modules` location plus the built entry `bin.josh` names.
20
21
  const BUNDLE_INVOCATION = `node ${claude_plugin_config.MARKETPLACE_PATH}/dist/josh.js `
21
22
  // The per-hook bundle directory a fresh clone builds, and where it lives inside the installed package.
@@ -26,7 +27,8 @@ const MISSING_INSTALL_NOTICE = 'kit hooks inactive: run pnpm install, then rerea
26
27
  const MISSING_INSTALL_GUARD = `if [ ! -f ${CONSUMER_PACKAGE_MANIFEST} ]; then echo '${MISSING_INSTALL_NOTICE}' >&2; exit 0; fi; `
27
28
  const CODEX_ADAPTER_BUNDLE = `node ${CONSUMER_HOOK_BUNDLE_DIR}codex-hook-adapter.js`
28
29
  const CODEX_HOOKS_DESTINATION = '.codex/hooks.json'
29
- const CODEX_ROOT_PREFIX = String.raw`cd \"$(git rev-parse --show-toplevel)\" && `
30
+ const GIT_UNSET_OPTIONS = GIT_LOCATION_VARIABLES.map((name) => `-u ${name}`).join(' ')
31
+ const PROJECT_ROOT_PREFIX = String.raw`if project_root=\"$(env ${GIT_UNSET_OPTIONS} git rev-parse --show-toplevel 2>/dev/null)\"; then unset ${GIT_LOCATION_VARIABLES.join(' ')}; else project_root=\"$(git rev-parse --show-toplevel)\" || exit 1; fi; cd \"$project_root\" && `
30
32
  // Capture the value of every `"command"` field, escapes and all, so the rewrites below touch command
31
33
  // values alone — never an echo reminder's prose or a `"description"` that merely mentions the string.
32
34
  const COMMAND_FIELD = /("command":\s*")((?:[^"\\]|\\.)*)(")/gu
@@ -61,7 +63,9 @@ function rewrite_command_value(value: string): string {
61
63
  function rewrite_hook_commands(content: string, is_codex = false): string {
62
64
  return content.replaceAll(COMMAND_FIELD, (_match, open: string, value: string, close: string) => {
63
65
  const rewritten = rewrite_command_value(value)
64
- const prefix = is_codex && !rewritten.startsWith(CODEX_ROOT_PREFIX) ? CODEX_ROOT_PREFIX : ''
66
+ const should_select_root = is_codex || rewritten.includes(claude_plugin_config.MARKETPLACE_PATH)
67
+ const prefix =
68
+ should_select_root && !rewritten.startsWith(PROJECT_ROOT_PREFIX) ? PROJECT_ROOT_PREFIX : ''
65
69
 
66
70
  return `${open}${prefix}${rewritten}${close}`
67
71
  })
@@ -1,10 +1,17 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { json_format } from '#scripts/config-merge/json-format'
3
4
  import { string_array_schema, vscode_settings_schema } from '#scripts/lib/schemas'
4
5
  import { init_logic } from './init-logic'
5
6
  import { package_path, PROJECT_ROOT } from './init-paths'
7
+ import type { ProjectShape } from './project-profile'
6
8
 
7
9
  const PRETTIER_CONFIG_JS = 'prettier.config.js'
10
+ const STATIC_PRETTIER_CONFIG = 'prettier.config.mjs'
11
+ const VSCODE_EXTENSIONS_PATH = '.vscode/extensions.json'
12
+ const VSCODE_SETTINGS_PATH = '.vscode/settings.json'
13
+ const TSCONFIG_PATH = 'tsconfig.json'
14
+ const LEFTHOOK_PATH = 'lefthook.yml'
8
15
 
9
16
  interface FileAction {
10
17
  dest: string
@@ -38,13 +45,13 @@ function build_vscode_actions(): ReadonlyArray<FileAction> {
38
45
 
39
46
  return [
40
47
  {
41
- dest: '.vscode/extensions.json',
48
+ dest: VSCODE_EXTENSIONS_PATH,
42
49
  create: () => read_package_file(extensions_path),
43
50
  merge: (existing) =>
44
51
  init_logic.merge_json_array_field(existing, 'recommendations', recommendations),
45
52
  },
46
53
  {
47
- dest: '.vscode/settings.json',
54
+ dest: VSCODE_SETTINGS_PATH,
48
55
  create: () =>
49
56
  init_logic.strip_kit_only_vscode_settings_content(read_package_file(settings_path)),
50
57
  merge: (existing) => init_logic.merge_json_object(existing, settings_data),
@@ -52,6 +59,64 @@ function build_vscode_actions(): ReadonlyArray<FileAction> {
52
59
  ]
53
60
  }
54
61
 
62
+ const STATIC_EXTENSIONS = [
63
+ 'streetsidesoftware.code-spell-checker',
64
+ 'pkief.material-icon-theme',
65
+ 'usernamehw.errorlens',
66
+ 'anthropic.claude-code',
67
+ 'github.github-vscode-theme',
68
+ ]
69
+ const PRETTIER_EXTENSION = 'esbenp.prettier-vscode'
70
+ const DEFAULT_FORMATTER = 'editor.defaultFormatter'
71
+ const FORMAT_ON_SAVE = 'editor.formatOnSave'
72
+ const STATIC_LANGUAGES = ['[html]', '[css]', '[javascript]']
73
+
74
+ function static_settings(): Record<string, Record<string, unknown>> {
75
+ const formatter = { [DEFAULT_FORMATTER]: PRETTIER_EXTENSION, [FORMAT_ON_SAVE]: true }
76
+
77
+ return Object.fromEntries(STATIC_LANGUAGES.map((language) => [language, formatter]))
78
+ }
79
+
80
+ function build_static_settings_action(): FileAction {
81
+ const settings = static_settings()
82
+
83
+ return build_action(
84
+ VSCODE_SETTINGS_PATH,
85
+ () => json_format.format_json(settings),
86
+ (existing) => init_logic.merge_json_object(existing, settings),
87
+ )
88
+ }
89
+
90
+ function build_static_vscode_actions(shape: ProjectShape): ReadonlyArray<FileAction> {
91
+ const recommendations = shape.has_web
92
+ ? [...STATIC_EXTENSIONS, PRETTIER_EXTENSION]
93
+ : STATIC_EXTENSIONS
94
+ const actions: Array<FileAction> = [
95
+ build_action(
96
+ VSCODE_EXTENSIONS_PATH,
97
+ () => json_format.format_json({ recommendations }),
98
+ (existing) => init_logic.merge_json_array_field(existing, 'recommendations', recommendations),
99
+ ),
100
+ ]
101
+
102
+ if (shape.has_web) actions.push(build_static_settings_action())
103
+
104
+ return actions
105
+ }
106
+
107
+ function build_static_tsconfig_action(): FileAction {
108
+ return build_action(
109
+ TSCONFIG_PATH,
110
+ () => json_format.format_json({ extends: init_logic.get_tsconfig_extends_entry() }),
111
+ (existing) =>
112
+ init_logic.merge_tsconfig_extends(
113
+ existing,
114
+ init_logic.get_tsconfig_extends_entry(),
115
+ PROJECT_ROOT,
116
+ ),
117
+ )
118
+ }
119
+
55
120
  // Create-only: the rule list is a project-owned allowlist once it exists, so a consumer
56
121
  // that added custom rules or disabled a noisy one must not have that overwritten.
57
122
  function build_secretlint_action(): FileAction {
@@ -65,7 +130,7 @@ function build_secretlint_action(): FileAction {
65
130
  // migrate a retired `*.jsonc` preset path only once its renamed target is installed (#681).
66
131
  function build_tsconfig_action(): FileAction {
67
132
  return build_action(
68
- 'tsconfig.json',
133
+ TSCONFIG_PATH,
69
134
  () => init_logic.generate_tsconfig(),
70
135
  (existing) =>
71
136
  init_logic.merge_tsconfig_exclude(
@@ -89,7 +154,7 @@ function build_config_file_actions(): ReadonlyArray<FileAction> {
89
154
  (existing) => init_logic.merge_cspell_import(existing, init_logic.get_cspell_import_value()),
90
155
  ),
91
156
  build_action(
92
- 'lefthook.yml',
157
+ LEFTHOOK_PATH,
93
158
  () => init_logic.generate_lefthook_config(),
94
159
  (existing) => init_logic.merge_lefthook_extends(existing, lefthook_extends),
95
160
  ),
@@ -122,9 +187,29 @@ function build_gitignore_action(): FileAction {
122
187
  )
123
188
  }
124
189
 
125
- function build_file_actions(): ReadonlyArray<FileAction> {
190
+ function build_static_actions(shape: ProjectShape): ReadonlyArray<FileAction> {
191
+ const actions: Array<FileAction> = []
192
+ if (shape.has_git) actions.push(build_gitignore_action())
193
+ if (shape.has_typescript) actions.push(build_static_tsconfig_action())
194
+
195
+ if (shape.has_web) {
196
+ actions.push({
197
+ dest: STATIC_PRETTIER_CONFIG,
198
+ create: () =>
199
+ "import { config } from '@joshuafolkken/kit/prettier/static'\n\nexport default config\n",
200
+ })
201
+ }
202
+
203
+ actions.push(...build_static_vscode_actions(shape))
204
+
205
+ return actions
206
+ }
207
+
208
+ function build_file_actions(shape?: ProjectShape): ReadonlyArray<FileAction> {
209
+ if (shape?.profile === 'static') return build_static_actions(shape)
210
+
126
211
  return [
127
- build_gitignore_action(),
212
+ ...(shape?.has_git === false ? [] : [build_gitignore_action()]),
128
213
  build_action(
129
214
  '.npmrc',
130
215
  () => init_logic.generate_npmrc(),
@@ -137,7 +222,9 @@ function build_file_actions(): ReadonlyArray<FileAction> {
137
222
  (existing) => init_logic.merge_prettier_config(existing),
138
223
  ),
139
224
  build_playwright_action(),
140
- ...build_config_file_actions(),
225
+ ...build_config_file_actions().filter(
226
+ (action) => shape?.has_git !== false || action.dest !== LEFTHOOK_PATH,
227
+ ),
141
228
  ]
142
229
  }
143
230
 
@@ -12,9 +12,31 @@ import { transform_copied_content } from './init-copy-content'
12
12
  import { init_logic } from './init-logic'
13
13
  import { package_path, PROJECT_ROOT } from './init-paths'
14
14
  import { init_sonar } from './init-sonar'
15
+ import type { ProjectShape } from './project-profile'
15
16
 
16
17
  const WORKSPACE_YAML = 'pnpm-workspace.yaml'
17
18
  const CLAUDE_MD_FILENAME = 'CLAUDE.md'
19
+ const STATIC_CLAUDE_IMPORT = '@node_modules/@joshuafolkken/kit/dist/CLAUDE.static.md'
20
+ const PRETTIER_IGNORE = '.prettierignore'
21
+ const GIT_ATTRIBUTES = '.gitattributes'
22
+ const CODE_OF_CONDUCT = 'CODE_OF_CONDUCT.md'
23
+ const SECURITY_MD = 'SECURITY.md'
24
+ const STATIC_AI_FILES = [
25
+ 'AGENTS.md',
26
+ 'GEMINI.md',
27
+ PRETTIER_IGNORE,
28
+ GIT_ATTRIBUTES,
29
+ CODE_OF_CONDUCT,
30
+ SECURITY_MD,
31
+ ]
32
+ const GITHUB_FILES = new Set([CODE_OF_CONDUCT, SECURITY_MD])
33
+ const STATIC_POINTER_MAPPING = { src: 'templates/cursorrules.static', dest: '.cursorrules' }
34
+ const PULL_REQUEST_TEMPLATE = '.github/pull_request_template.md'
35
+ const RELEASE_CONFIG = '.github/release.yml'
36
+ const STATIC_GITHUB_MAPPINGS = [
37
+ { src: PULL_REQUEST_TEMPLATE, dest: PULL_REQUEST_TEMPLATE },
38
+ { src: RELEASE_CONFIG, dest: RELEASE_CONFIG },
39
+ ]
18
40
 
19
41
  // Whether the existing file is a workflow that carries no header. A read failure answers "no": the
20
42
  // point is to warn, and a warning is not worth failing a command that used to open nothing.
@@ -59,7 +81,7 @@ function did_skip_copy_if_absent(
59
81
  label: string,
60
82
  ): boolean {
61
83
  if (existsSync(destination_path)) {
62
- console.info(` ⏭ skipped ${label} (already exists — run josh sync to update)`)
84
+ console.info(` ⏭ skipped ${label} (already exists)`)
63
85
  did_warn_unstamped_workflow(destination_path, label)
64
86
 
65
87
  return true
@@ -89,8 +111,12 @@ function did_skip_workspace_yaml_copy(source_path: string, destination_path: str
89
111
  return false
90
112
  }
91
113
 
92
- function did_skip_ai_file_copy(filename: string): boolean {
93
- const source_path = package_path(filename)
114
+ function did_skip_ai_file_copy(filename: string, shape?: ProjectShape): boolean {
115
+ const source_path = package_path(
116
+ filename === PRETTIER_IGNORE && shape?.profile === 'static'
117
+ ? 'templates/prettierignore.static'
118
+ : filename,
119
+ )
94
120
  const destination_path = path.join(PROJECT_ROOT, filename)
95
121
 
96
122
  if (filename === WORKSPACE_YAML) {
@@ -183,16 +209,21 @@ function did_skip_ai_directory_copy(directory_name: string): boolean {
183
209
  // AI file — leaves an existing one untouched so a consumer's additions are never disturbed. `josh sync`
184
210
  // is what ensures the import line on an existing file (sync.ts). CLAUDE.md is no longer byte-copied
185
211
  // (joshuafolkken/kit#1878), so it is handled here rather than through AI_COPY_FILES.
186
- function did_skip_claude_md_import(): boolean {
212
+ function did_skip_claude_md_import(shape?: ProjectShape): boolean {
187
213
  const destination_path = path.join(PROJECT_ROOT, CLAUDE_MD_FILENAME)
188
214
 
189
215
  if (existsSync(destination_path)) {
190
- console.info(` ⏭ skipped ${CLAUDE_MD_FILENAME} (already exists — run josh sync to update)`)
216
+ console.info(` ⏭ skipped ${CLAUDE_MD_FILENAME} (already exists)`)
191
217
 
192
218
  return true
193
219
  }
194
220
 
195
- writeFileSync(destination_path, init_logic.ensure_claude_md_import(undefined))
221
+ const content =
222
+ shape?.profile === 'static'
223
+ ? `> If kit is not installed, run \`pnpm install\` first.\n\n${STATIC_CLAUDE_IMPORT}\n`
224
+ : init_logic.ensure_claude_md_import(undefined)
225
+
226
+ writeFileSync(destination_path, content)
196
227
  console.info(` ✔ created ${CLAUDE_MD_FILENAME}`)
197
228
 
198
229
  return false
@@ -201,14 +232,69 @@ function did_skip_claude_md_import(): boolean {
201
232
  // Returns the repository name resolved for the Sonar config, so `josh init` can reuse it for the
202
233
  // security-updates report instead of spawning a second `gh repo view` (joshuafolkken/kit#805).
203
234
  // Resolving it here rather than in the caller keeps every AI-file write ahead of the network call.
204
- function run_ai_copies(): string | undefined {
205
- const did_skip_claude_md = did_skip_claude_md_import()
206
- const file_skips = init_logic
207
- .get_ai_copy_files()
208
- .map((filename) => did_skip_ai_file_copy(filename))
209
- const mapping_skips = init_logic
210
- .get_ai_copy_file_mappings()
211
- .map(({ src: source, dest: destination }) => did_skip_ai_file_mapping(source, destination))
235
+ function should_copy_git_file(filename: string, shape?: ProjectShape): boolean {
236
+ if (shape?.has_git !== false) return true
237
+
238
+ return (
239
+ filename !== GIT_ATTRIBUTES &&
240
+ !filename.startsWith('.claude/') &&
241
+ !filename.startsWith('.codex/')
242
+ )
243
+ }
244
+
245
+ function should_copy_github_file(filename: string, shape?: ProjectShape): boolean {
246
+ if (shape?.has_github !== false) return true
247
+
248
+ return !GITHUB_FILES.has(filename) && !filename.startsWith('.github/')
249
+ }
250
+
251
+ function should_copy_static_file(filename: string, shape?: ProjectShape): boolean {
252
+ if (filename !== PRETTIER_IGNORE || shape?.profile !== 'static') return true
253
+
254
+ return shape.has_web
255
+ }
256
+
257
+ function should_copy_ai_file(filename: string, shape?: ProjectShape): boolean {
258
+ return (
259
+ should_copy_git_file(filename, shape) &&
260
+ should_copy_github_file(filename, shape) &&
261
+ should_copy_static_file(filename, shape)
262
+ )
263
+ }
264
+
265
+ function ai_files(shape?: ProjectShape): ReadonlyArray<string> {
266
+ const files = shape?.profile === 'static' ? STATIC_AI_FILES : init_logic.get_ai_copy_files()
267
+
268
+ return files.filter((filename) => should_copy_ai_file(filename, shape))
269
+ }
270
+
271
+ function ai_mapping_skips(shape?: ProjectShape): ReadonlyArray<boolean> {
272
+ const mappings =
273
+ shape?.profile === 'static'
274
+ ? [STATIC_POINTER_MAPPING, ...(shape.has_github ? STATIC_GITHUB_MAPPINGS : [])]
275
+ : init_logic.get_ai_copy_file_mappings()
276
+
277
+ return mappings
278
+ .filter(({ dest }) => shape?.has_github !== false || !dest.startsWith('.github/'))
279
+ .map(({ src, dest }) => did_skip_ai_file_mapping(src, dest))
280
+ }
281
+
282
+ function repository_name(shape?: ProjectShape): string | undefined {
283
+ if (shape?.has_github === false || shape?.profile === 'static') return undefined
284
+
285
+ return gh_spawn.get_repo_name_with_owner()
286
+ }
287
+
288
+ function report_sync_hint(has_skips: boolean, shape?: ProjectShape): void {
289
+ if (!has_skips || shape?.profile === 'static') return
290
+
291
+ console.info('\n 💡 Run `josh sync` to overwrite skipped AI files with the latest version.')
292
+ }
293
+
294
+ function run_ai_copies(shape?: ProjectShape): string | undefined {
295
+ const did_skip_claude_md = did_skip_claude_md_import(shape)
296
+ const file_skips = ai_files(shape).map((filename) => did_skip_ai_file_copy(filename, shape))
297
+ const mapping_skips = ai_mapping_skips(shape)
212
298
  const directory_skips = init_logic
213
299
  .get_ai_copy_directories()
214
300
  .map((directory_name) => did_skip_ai_directory_copy(directory_name))
@@ -216,13 +302,11 @@ function run_ai_copies(): string | undefined {
216
302
  Boolean,
217
303
  )
218
304
 
219
- const name_with_owner = gh_spawn.get_repo_name_with_owner()
305
+ const name_with_owner = repository_name(shape)
220
306
 
221
- init_sonar.copy_sonar_with_template(name_with_owner)
307
+ if (shape?.profile !== 'static') init_sonar.copy_sonar_with_template(name_with_owner)
222
308
 
223
- if (has_skips) {
224
- console.info('\n 💡 Run `josh sync` to overwrite skipped AI files with the latest version.')
225
- }
309
+ report_sync_hint(has_skips, shape)
226
310
 
227
311
  return name_with_owner
228
312
  }
@@ -230,6 +314,7 @@ function run_ai_copies(): string | undefined {
230
314
  const init_ai_copy = {
231
315
  copy_ai_file,
232
316
  run_ai_copies,
317
+ ai_files,
233
318
  }
234
319
 
235
320
  export { init_ai_copy }