@joshuafolkken/kit 1.983.0 → 1.1003.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/backlogrun-child.md +4 -5
  2. package/.claude/skills/workflow-commands/backlogrun-steps.md +1 -1
  3. package/.github/workflows/sonar-qube.yml +12 -2
  4. package/CLAUDE.md +2 -2
  5. package/README.md +30 -21
  6. package/dist/CLAUDE.md +2 -2
  7. package/dist/hooks/batch-guard.js +4 -4
  8. package/dist/hooks/{chunk-ZT65LR3U.js → chunk-4GDR2YTV.js} +4 -4
  9. package/dist/hooks/{chunk-ASUZGZFT.js → chunk-56LGMJJV.js} +2 -2
  10. package/dist/hooks/{chunk-LBXQHXMC.js → chunk-6PSTVOLH.js} +263 -74
  11. package/dist/hooks/{chunk-P3UX7SFN.js → chunk-A2H5I2VD.js} +2 -2
  12. package/dist/hooks/{chunk-ERXMJJOV.js → chunk-AWTFKKFW.js} +1 -1
  13. package/dist/hooks/{chunk-IM3KM25Y.js → chunk-EZLHXKJ6.js} +10 -7
  14. package/dist/hooks/{chunk-APVOACVT.js → chunk-GBJUERYK.js} +1 -1
  15. package/dist/hooks/{chunk-5JX6HXDX.js → chunk-KPZN42RT.js} +3 -3
  16. package/dist/hooks/{chunk-47SPMGOD.js → chunk-PAXMLGSS.js} +26 -7
  17. package/dist/hooks/{chunk-RFPHKU7C.js → chunk-WMIR5SNX.js} +3 -3
  18. package/dist/hooks/codex-hook-adapter.js +10 -10
  19. package/dist/hooks/duplicate-read-guard.js +5 -5
  20. package/dist/hooks/format-edited.js +2 -2
  21. package/dist/hooks/investigation-guard.js +5 -5
  22. package/dist/hooks/pretool-guard.js +9 -9
  23. package/dist/hooks/stop-guard.js +98 -26
  24. package/dist/josh.js +5 -5
  25. package/package.json +5 -1
  26. package/prettier/basic.d.ts +3 -0
  27. package/prettier/basic.js +10 -0
  28. package/prettier/index.js +2 -2
  29. package/prettier/static.js +3 -10
  30. package/prompts/sonar-hotspot-handling.md +6 -0
  31. package/prompts/testing-guide.md +1 -1
  32. package/scripts/agent/agent-diagnostics.ts +1 -0
  33. package/scripts/agent/agent-role-profile.ts +1 -1
  34. package/scripts/behavior/behavior-rules.ts +8 -1
  35. package/scripts/build/build-claude-md.ts +13 -4
  36. package/scripts/ci/pr-classification.ts +11 -2
  37. package/scripts/document/document-byte-budget.ts +1 -1
  38. package/scripts/gate/gate-steps.ts +1 -1
  39. package/scripts/gate/project-checks.ts +12 -11
  40. package/scripts/gate/type-check-command.ts +1 -1
  41. package/scripts/git/git-branch.ts +14 -3
  42. package/scripts/git/git-command.ts +5 -2
  43. package/scripts/git/git-issue.ts +13 -5
  44. package/scripts/git/git-pr.ts +16 -10
  45. package/scripts/git/git-preflight.ts +138 -0
  46. package/scripts/git/telegram-notify.ts +21 -0
  47. package/scripts/hooks/stop-guard.ts +2 -0
  48. package/scripts/init/basic-path-migration.ts +24 -0
  49. package/scripts/init/distributed-paths.ts +22 -0
  50. package/scripts/init/init-actions.ts +27 -19
  51. package/scripts/init/init-ai-copy.ts +86 -28
  52. package/scripts/init/{init-static.ts → init-basic.ts} +6 -6
  53. package/scripts/init/init-bootstrap.ts +4 -3
  54. package/scripts/init/init-install.ts +1 -1
  55. package/scripts/init/init-logic-templates.ts +1 -1
  56. package/scripts/init/init-logic.ts +1 -1
  57. package/scripts/init/init.ts +41 -19
  58. package/scripts/init/kit-setup-state.ts +30 -0
  59. package/scripts/init/kit-written-paths.ts +33 -0
  60. package/scripts/init/project-profile-cli.ts +15 -1
  61. package/scripts/init/project-profile.ts +41 -12
  62. package/scripts/init/session-language-environment.ts +83 -0
  63. package/scripts/init/start-exec.ts +28 -0
  64. package/scripts/init/start-plan.ts +35 -13
  65. package/scripts/init/start-prompt.ts +22 -8
  66. package/scripts/init/start-setup-pr.ts +138 -0
  67. package/scripts/init/start-steps.ts +30 -30
  68. package/scripts/init/start.ts +7 -3
  69. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +3 -3
  70. package/scripts/josh/josh-commands-development.ts +3 -3
  71. package/scripts/josh/josh-commands-project.ts +2 -2
  72. package/scripts/lane/lane-leftover.ts +143 -0
  73. package/scripts/lane/lane-open.ts +14 -1
  74. package/scripts/lint/cspell-cached.ts +3 -3
  75. package/scripts/lint/format.ts +1 -1
  76. package/scripts/lint/lint-parallel.ts +2 -2
  77. package/scripts/propagate/propagate-steps.ts +14 -1
  78. package/scripts/rules/filing-cap.ts +1 -0
  79. package/scripts/rules/issue-citation.ts +30 -1
  80. package/scripts/rules/last-prompt.ts +79 -0
  81. package/scripts/rules/stop-rules.ts +5 -3
  82. package/scripts/run/run-hold.ts +3 -3
  83. package/scripts/run/run-preflight.ts +75 -5
  84. package/scripts/run/run-progress-cli.ts +14 -2
  85. package/scripts/safe-chain/ci-installer-pin.ts +19 -12
  86. package/scripts/safe-chain/project-config.ts +1 -1
  87. package/scripts/sync/sync-ai-files.ts +204 -0
  88. package/scripts/sync/sync.ts +48 -184
  89. package/scripts/test/josh-harness-environment.ts +1 -1
  90. package/scripts/test/pilot-files.ts +0 -1
  91. package/scripts/test/test-declared-changed.ts +1 -1
  92. package/scripts/test/test-declared-logic.ts +55 -14
  93. package/scripts/test/test-declared.ts +23 -11
  94. package/scripts/test/test-unit-guard.ts +6 -6
  95. package/scripts/time-runtime/time-shell.ts +36 -2
  96. package/scripts-ai/git-workflow.ts +10 -0
  97. package/templates/CLAUDE.basic.md +8 -0
  98. package/templates/workflows/ci.yml +27 -0
  99. /package/{templates/CLAUDE.static.md → dist/CLAUDE.basic.md} +0 -0
  100. /package/templates/{cursorrules.static → cursorrules.basic} +0 -0
  101. /package/templates/{pnpm-workspace.static.yaml → pnpm-workspace.basic.yaml} +0 -0
  102. /package/templates/{prettierignore.static → prettierignore.basic} +0 -0
@@ -1,5 +1,6 @@
1
1
  import { git_command } from '#scripts/git/git-command'
2
2
  import { git_gh_pr_read } from '#scripts/git/git-gh-pr-read'
3
+ import { lane_registry, type LaneInfo } from '#scripts/lane/lane-registry'
3
4
  import { z } from 'zod'
4
5
  import { run_hold } from './run-hold'
5
6
  import { run_issue_number } from './run-issue-number'
@@ -72,7 +73,7 @@ const GH_PR_STATES: ReadonlyMap<string, PrState> = new Map([
72
73
  ])
73
74
 
74
75
  const CLEAN_REASON =
75
- 'Nothing was left behind: the tree is clean, HEAD is on the default branch, and this issue has no branch or pull request.'
76
+ 'Nothing was left behind: the tree is clean, HEAD is on the default branch, and this issue has no pull request and no branch holding work.'
76
77
 
77
78
  const CLEAN_ADVICE = 'Start the child.'
78
79
 
@@ -283,22 +284,91 @@ function to_head_branch(name: string): string {
283
284
  return separator_index === NOT_FOUND_INDEX ? name : name.slice(separator_index + 1)
284
285
  }
285
286
 
286
- async function read_branch_candidates(issue: string): Promise<Array<string>> {
287
+ function group_by_head(pairs: ReadonlyArray<[string, string]>): Map<string, Array<string>> {
288
+ const groups = new Map<string, Array<string>>()
289
+
290
+ for (const [name, reference] of pairs) groups.set(name, [...(groups.get(name) ?? []), reference])
291
+
292
+ return groups
293
+ }
294
+
295
+ // Each candidate keeps every ref it was found as — the local branch and each remote-tracking ref —
296
+ // because either may be the one holding the commits: a local branch left at the default branch can
297
+ // sit beside a remote one another machine pushed work to, and the stripped head name may not resolve.
298
+ async function read_branch_candidates(issue: string): Promise<Map<string, Array<string>>> {
287
299
  const pattern = `${issue}${ISSUE_BRANCH_SUFFIX}`
288
300
  const [local, remote] = await Promise.all([
289
301
  git_command.branch_names(pattern),
290
302
  git_command.branch_names_remote(`${ANY_REMOTE_PREFIX}${pattern}`),
291
303
  ])
292
304
 
293
- return [...new Set([...local, ...remote.map((name) => to_head_branch(name))])]
305
+ return group_by_head([
306
+ ...local.map((name): [string, string] => [name, name]),
307
+ ...remote.map((reference): [string, string] => [to_head_branch(reference), reference]),
308
+ ])
309
+ }
310
+
311
+ async function has_commits_beyond(
312
+ base: string,
313
+ references: ReadonlyArray<string>,
314
+ ): Promise<boolean> {
315
+ const counts = await Promise.all(
316
+ references.map(async (reference) => await git_command.commit_count_beyond(base, reference)),
317
+ )
318
+
319
+ return counts.some((count) => count > 0)
320
+ }
321
+
322
+ // **A lane stopped mid-implementation holds its work uncommitted**, on a branch with no commit yet —
323
+ // so the lane checked out on the candidate is asked whether its tree is dirty, read the way
324
+ // `run_hold` reads this tree (an unreadable status is dirty). A lane on another branch, or whose
325
+ // directory is gone, holds nothing for this candidate.
326
+ async function is_held_dirty(name: string, lane: LaneInfo | undefined): Promise<boolean> {
327
+ if (lane?.branch !== name || lane.is_stranded) return false
328
+
329
+ return await run_hold.is_tree_dirty(lane.directory)
330
+ }
331
+
332
+ interface CandidateContext {
333
+ base: string
334
+ lane: LaneInfo | undefined
335
+ }
336
+
337
+ // **A branch with no pull request, no commit beyond the default branch and no uncommitted change in
338
+ // its lane holds no work** (joshuafolkken/kit#2855). A lane that ended before implementing — an
339
+ // operational issue with no code change — leaves exactly that behind, and reading its mere existence
340
+ // as a partial implementation sent the next `fullrun #N` to `resume` over nothing. A branch with a
341
+ // pull request is never counted: whatever its commits, the pull request is the state the verdict is
342
+ // decided on.
343
+ async function read_candidate(
344
+ name: string,
345
+ references: ReadonlyArray<string>,
346
+ context: CandidateContext,
347
+ ): Promise<ChildState | undefined> {
348
+ const state = await read_pr_of_branch(name)
349
+
350
+ if (state.pr_state !== NO_PR) return state
351
+ if (await has_commits_beyond(context.base, references)) return state
352
+
353
+ return (await is_held_dirty(name, context.lane)) ? state : undefined
294
354
  }
295
355
 
296
356
  async function read_child_state(issue: string): Promise<ChildState> {
297
357
  const candidates = await read_branch_candidates(issue)
298
358
 
299
- return pick_child_state(
300
- await Promise.all(candidates.map(async (name) => await read_pr_of_branch(name))),
359
+ if (candidates.size === 0) return NO_CHILD_WORK
360
+
361
+ const [base, lane] = await Promise.all([
362
+ git_command.default_branch_reference(),
363
+ lane_registry.find_open_lane(issue),
364
+ ])
365
+ const reads = await Promise.all(
366
+ [...candidates].map(
367
+ async ([name, references]) => await read_candidate(name, references, { base, lane }),
368
+ ),
301
369
  )
370
+
371
+ return pick_child_state(reads.filter((read): read is ChildState => read !== undefined))
302
372
  }
303
373
 
304
374
  // The child read is skipped where the tree already answers `reclaim`: a `gh` round trip buys nothing
@@ -77,7 +77,12 @@ const WAIT_EXPIRED_NOTICE = `The watch bound (\`--hours\`, ${String(DEFAULT_MAX_
77
77
  // The repository name is only ever printed, never written against, so the bounded lookup is the right
78
78
  // one: a `gh` call that hangs would otherwise block the synchronous read at startup and leave the
79
79
  // watcher neither running nor saying so.
80
- const REPO_LOOKUP_TIMEOUT_MS = 10_000
80
+ const REPO_LOOKUP_TIMEOUT_SECONDS = 10
81
+ const REPO_LOOKUP_TIMEOUT_MS = REPO_LOOKUP_TIMEOUT_SECONDS * run_progress.MS_PER_SECOND
82
+ // A lookup that timed out or failed is not an argument the caller got wrong, so it is told apart from
83
+ // the usage error — read as one, it sent a person to fix arguments that were correct
84
+ // (joshuafolkken/kit#2859).
85
+ const REPO_UNRESOLVED_NOTICE = `The repository could not be resolved (\`gh\` did not answer within ${String(REPO_LOOKUP_TIMEOUT_SECONDS)}s or failed), so the progress watcher did not start. Pass \`--repo <owner/repo>\` and start it again.`
81
86
 
82
87
  const DECLINE_NOTICES: Readonly<Record<'idle' | 'unreadable', string>> = {
83
88
  idle: IDLE_NOTICE,
@@ -141,6 +146,12 @@ function report_usage(): number {
141
146
  return FAILURE_EXIT_CODE
142
147
  }
143
148
 
149
+ function report_repo_unresolved(): number {
150
+ console.error(REPO_UNRESOLVED_NOTICE)
151
+
152
+ return FAILURE_EXIT_CODE
153
+ }
154
+
144
155
  function report_disabled(): number {
145
156
  console.error(DISABLED_NOTICE)
146
157
 
@@ -443,7 +454,7 @@ function to_options(values: ParsedValues): WatchOptions | undefined {
443
454
  async function run_watch(values: ParsedValues): Promise<number> {
444
455
  const options = to_options(values)
445
456
 
446
- if (options === undefined) return report_usage()
457
+ if (options === undefined) return report_repo_unresolved()
447
458
  if (values.once === true) return await once(options)
448
459
  if (values.wait === true) return await wait_once(options)
449
460
 
@@ -487,6 +498,7 @@ const run_progress_cli = {
487
498
  IDLE_NOTICE,
488
499
  LANE_CHILD_NOTICE,
489
500
  MARKED_NOTICE,
501
+ REPO_UNRESOLVED_NOTICE,
490
502
  TICK_SECONDS,
491
503
  UNREADABLE_NOTICE,
492
504
  USAGE,
@@ -1,16 +1,14 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
3
3
  import { execaSync } from 'execa'
4
4
 
5
- // The workflows that install safe-chain on the runner. Each carries the release and the installer's
6
- // SHA-256 as workflow-level env, so one pin per file moves every "Setup safe-chain" step at once
7
- // (joshuafolkken/kit#2711, joshuafolkken/kit#2765). A consumer has the first and the last, and
8
- // `josh sync` rewrites both from kit, so moving them anywhere but kit is harmless and short-lived.
9
- const WORKFLOW_PATHS = [
10
- '.github/workflows/ci.yml',
11
- 'templates/workflows/ci.yml',
12
- '.github/workflows/pr-classification.yml',
13
- ]
5
+ // Where the workflows that install safe-chain on the runner live. Each carries the release and the
6
+ // installer's SHA-256 as workflow-level env, so one pin per file moves every "Setup safe-chain" step
7
+ // at once (joshuafolkken/kit#2711, joshuafolkken/kit#2765). The directories are scanned rather than
8
+ // a file list kept, so a workflow another package distributes — app-kit's `dast.yml` and `load.yml`
9
+ // — moves with the rest (joshuafolkken/kit#2830); a file without the pin is never a target.
10
+ const WORKFLOW_DIRECTORIES = ['.github/workflows', 'templates/workflows']
11
+ const WORKFLOW_FILE_RE = /\.ya?ml$/u
14
12
  // `[ \t]` rather than `\s`: under the `m` flag `\s*` would run across line ends and swallow the
15
13
  // blank line that follows the env block.
16
14
  const VERSION_RE = /^(?<key>[ \t]*SAFE_CHAIN_INSTALLER_VERSION:[ \t]*)(?<value>\S+)[ \t]*$/mu
@@ -27,6 +25,15 @@ function installer_url(version: string): string {
27
25
  return `https://github.com/AikidoSec/safe-chain/releases/download/${version}/install-safe-chain.sh`
28
26
  }
29
27
 
28
+ function list_workflows(): Array<string> {
29
+ return WORKFLOW_DIRECTORIES.filter((directory) => existsSync(directory)).flatMap((directory) =>
30
+ readdirSync(directory)
31
+ .filter((file_name) => WORKFLOW_FILE_RE.test(file_name))
32
+ .toSorted((left, right) => left.localeCompare(right))
33
+ .map((file_name) => `${directory}/${file_name}`),
34
+ )
35
+ }
36
+
30
37
  function extract_pinned_version(content: string): string | undefined {
31
38
  return VERSION_RE.exec(content)?.groups?.[VALUE_GROUP]
32
39
  }
@@ -66,7 +73,7 @@ function write_pin(workflow_path: string, pin: InstallerPin): void {
66
73
  writeFileSync(workflow_path, rewrite_pin(readFileSync(workflow_path, 'utf8'), pin), 'utf8')
67
74
  }
68
75
 
69
- function sync(latest: string, workflow_paths: ReadonlyArray<string> = WORKFLOW_PATHS): void {
76
+ function sync(latest: string, workflow_paths: ReadonlyArray<string> = list_workflows()): void {
70
77
  const targets = stale_workflows(latest, workflow_paths)
71
78
  if (targets.length === 0) return
72
79
 
@@ -82,7 +89,7 @@ function sync(latest: string, workflow_paths: ReadonlyArray<string> = WORKFLOW_P
82
89
  }
83
90
 
84
91
  const ci_installer_pin = {
85
- WORKFLOW_PATHS,
92
+ list_workflows,
86
93
  installer_url,
87
94
  extract_pinned_version,
88
95
  rewrite_pin,
@@ -146,4 +146,4 @@ function sync_project_config(root: string): boolean {
146
146
 
147
147
  const project_config = { merge_project_config, sync_project_config }
148
148
 
149
- export { project_config }
149
+ export { project_config, PROJECT_CONFIG_FILE }
@@ -0,0 +1,204 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { init_ai_copy } from '#scripts/init/init-ai-copy'
4
+ import { transform_copied_content } from '#scripts/init/init-copy-content'
5
+ import { init_logic } from '#scripts/init/init-logic'
6
+ import { PACKAGE_DIR, PROJECT_ROOT } from '#scripts/init/init-paths'
7
+ import type { ProjectShape } from '#scripts/init/project-profile'
8
+ import { copy_directory_failure, directory_copy_blocker } from './directory-copy-guard'
9
+ import { file_content } from './file-content'
10
+ import { REMOVED_SKILL_MANIFEST } from './removed-skill-manifest'
11
+ import { skill_migration, type MigrationResult } from './skill-migration'
12
+ import { sync_hook_safety } from './sync-hook-safety'
13
+
14
+ const WORKSPACE_YAML = 'pnpm-workspace.yaml'
15
+ const PACKAGE_JSON = 'package.json'
16
+ const CLAUDE_MD_FILENAME = 'CLAUDE.md'
17
+ const CLAUDE_SETTINGS_FILE = '.claude/settings.json'
18
+ const CODEX_HOOKS_FILE = '.codex/hooks.json'
19
+
20
+ function sync_ai_file(source_path: string, destination_path: string): boolean {
21
+ const content = readFileSync(source_path, 'utf8')
22
+
23
+ return file_content.write_text_if_changed(
24
+ destination_path,
25
+ transform_copied_content(destination_path, content),
26
+ )
27
+ }
28
+
29
+ // Both copied hook files must run against the consumer's installed bundle. When sync is run from a
30
+ // newer source than that install, writing either file would leave hooks
31
+ // that fail every prompt (joshuafolkken/kit#1930); skip it and print how to update instead.
32
+ function should_skip_hook_file(filename: string): boolean {
33
+ if (filename !== CLAUDE_SETTINGS_FILE && filename !== CODEX_HOOKS_FILE) return false
34
+ const warning = sync_hook_safety.hook_write_warning(
35
+ PROJECT_ROOT,
36
+ sync_hook_safety.read_version_at(path.join(PACKAGE_DIR, PACKAGE_JSON)),
37
+ filename,
38
+ )
39
+ if (warning === undefined) return false
40
+ console.warn(warning)
41
+
42
+ return true
43
+ }
44
+
45
+ function sync_file(source: string, filename: string): void {
46
+ if (should_skip_hook_file(filename)) return
47
+ const did_change = sync_ai_file(path.join(PACKAGE_DIR, source), path.join(PROJECT_ROOT, filename))
48
+
49
+ console.info(` ✔ ${did_change ? 'synced ' : 'unchanged'} ${filename}`)
50
+ }
51
+
52
+ function sync_file_mapping(source_path: string, destination_path: string): void {
53
+ if (!existsSync(source_path)) {
54
+ console.warn(` ⚠ skipped ${path.basename(destination_path)} (not found in package)`)
55
+
56
+ return
57
+ }
58
+
59
+ // Routed through sync_ai_file rather than cpSync: templates/workflows/ci.yml is a mapped
60
+ // file, so a byte copy would hand the consumer the template's own action pins. The pins
61
+ // have to be resolved from .github/workflows at write time (joshuafolkken/kit#747).
62
+ const did_change = sync_ai_file(source_path, destination_path)
63
+
64
+ console.info(` ✔ ${did_change ? 'synced ' : 'unchanged'} ${path.basename(destination_path)}`)
65
+ }
66
+
67
+ function sync_workspace_yaml(
68
+ template_path: string,
69
+ destination_path: string,
70
+ is_force = false,
71
+ ): boolean {
72
+ const template = readFileSync(template_path, 'utf8')
73
+ const existing =
74
+ !is_force && existsSync(destination_path) ? readFileSync(destination_path, 'utf8') : ''
75
+ const merged = init_logic.merge_workspace_yaml(existing, template)
76
+
77
+ return file_content.write_text_if_changed(destination_path, merged)
78
+ }
79
+
80
+ // The source is the profile's: a basic project syncs from its own templates, as `josh init` wrote it.
81
+ function sync_ai_copy_file(filename: string, is_force: boolean, shape?: ProjectShape): void {
82
+ const source = init_ai_copy.ai_file_source(filename, shape)
83
+
84
+ if (filename === WORKSPACE_YAML) {
85
+ const did_change = sync_workspace_yaml(
86
+ path.join(PACKAGE_DIR, source),
87
+ path.join(PROJECT_ROOT, WORKSPACE_YAML),
88
+ is_force,
89
+ )
90
+
91
+ console.info(` ✔ ${did_change ? 'synced ' : 'unchanged'} ${WORKSPACE_YAML}`)
92
+
93
+ return
94
+ }
95
+
96
+ sync_file(source, filename)
97
+ }
98
+
99
+ // The reason this directory was not synced, or nothing when it was. Both halves answer the same
100
+ // question — what stops the copy — so the caller has one line to print either way.
101
+ function directory_sync_failure(
102
+ directory_name: string,
103
+ on_copy: (did_change: boolean) => void,
104
+ ): string | undefined {
105
+ const source_path = path.join(PACKAGE_DIR, directory_name)
106
+ const destination_path = path.join(PROJECT_ROOT, directory_name)
107
+
108
+ return (
109
+ directory_copy_blocker(source_path, destination_path) ??
110
+ copy_directory_failure(source_path, destination_path, on_copy)
111
+ )
112
+ }
113
+
114
+ function sync_directory(directory_name: string): void {
115
+ const failure = directory_sync_failure(directory_name, (did_change) => {
116
+ console.info(` ✔ ${did_change ? 'synced ' : 'unchanged'} ${directory_name}/`)
117
+ })
118
+
119
+ if (failure !== undefined) console.warn(` ⚠ skipped ${directory_name}/ (${failure})`)
120
+ }
121
+
122
+ // A consumer's stale copy of a distributed skill is removed once it still matches the shipment and
123
+ // kept with a warning once it does not (joshuafolkken/kit#1879). Plugin skills compare against the
124
+ // package source; a retired skill compares against the frozen manifest (joshuafolkken/kit#1990). The
125
+ // note distinguishes the two, and `absent` — the consumer never had the copy — prints nothing.
126
+ function report_migration_result(result: MigrationResult): void {
127
+ if (result.action === 'absent') return
128
+
129
+ if (result.action === 'removed') {
130
+ console.info(
131
+ ` ✔ removed ${result.directory}/ (${skill_migration.removed_note(result.source)})`,
132
+ )
133
+
134
+ return
135
+ }
136
+
137
+ console.warn(` ⚠ kept ${result.directory}/ (${skill_migration.kept_note(result.source)})`)
138
+ }
139
+
140
+ function migrate_removed_skills(): void {
141
+ const plugin = skill_migration.migrate_removed_skill_directories(PACKAGE_DIR, PROJECT_ROOT)
142
+ const retired = skill_migration.migrate_manifest_skills(PROJECT_ROOT, REMOVED_SKILL_MANIFEST)
143
+
144
+ for (const result of [...plugin, ...retired]) report_migration_result(result)
145
+ }
146
+
147
+ function ensured_claude_md(existing: string | undefined, shape?: ProjectShape): string {
148
+ return shape?.profile === 'basic'
149
+ ? init_ai_copy.ensure_basic_claude_md(existing)
150
+ : init_logic.ensure_claude_md_import(existing)
151
+ }
152
+
153
+ // CLAUDE.md is not byte-copied (joshuafolkken/kit#1878): a consumer's file is one @import of kit's
154
+ // published rules plus the project's own additions below it. Ensure the import line is present
155
+ // without ever disturbing those additions — so this ignores --force, which would otherwise mean
156
+ // discarding a consumer's content.
157
+ function sync_claude_md(destination_path: string, shape?: ProjectShape): void {
158
+ const existing = existsSync(destination_path) ? readFileSync(destination_path, 'utf8') : undefined
159
+ const ensured = ensured_claude_md(existing, shape)
160
+
161
+ if (ensured === existing) {
162
+ console.info(` ✔ unchanged ${CLAUDE_MD_FILENAME}`)
163
+
164
+ return
165
+ }
166
+
167
+ mkdirSync(path.dirname(destination_path), { recursive: true })
168
+ writeFileSync(destination_path, ensured)
169
+ console.info(` ✔ synced ${CLAUDE_MD_FILENAME}`)
170
+ }
171
+
172
+ // A basic shape syncs the file set `josh init` writes for that profile; no shape is the full set,
173
+ // unfiltered, as sync has always written it (joshuafolkken/kit#2827).
174
+ function sync_ai_copy_all(is_force: boolean, shape?: ProjectShape): void {
175
+ console.info('AI files:')
176
+
177
+ sync_claude_md(path.join(PROJECT_ROOT, CLAUDE_MD_FILENAME), shape)
178
+
179
+ for (const filename of init_ai_copy.ai_files(shape)) {
180
+ sync_ai_copy_file(filename, is_force, shape)
181
+ }
182
+
183
+ for (const { src, dest } of init_ai_copy.ai_file_mappings(shape)) {
184
+ sync_file_mapping(path.join(PACKAGE_DIR, src), path.join(PROJECT_ROOT, dest))
185
+ }
186
+
187
+ for (const directory_name of init_logic.get_ai_copy_directories()) {
188
+ sync_directory(directory_name)
189
+ }
190
+
191
+ migrate_removed_skills()
192
+ }
193
+
194
+ const sync_ai_files = {
195
+ should_skip_hook_file,
196
+ sync_file_mapping,
197
+ sync_ai_file,
198
+ sync_workspace_yaml,
199
+ sync_claude_md,
200
+ sync_directory,
201
+ sync_ai_copy_all,
202
+ }
203
+
204
+ export { sync_ai_files }