@joshuafolkken/kit 1.983.0 → 1.998.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 (94) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-child.md +1 -1
  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-762BDXDU.js} +220 -68
  11. package/dist/hooks/{chunk-IM3KM25Y.js → chunk-7WAOP42A.js} +5 -3
  12. package/dist/hooks/{chunk-P3UX7SFN.js → chunk-A2H5I2VD.js} +2 -2
  13. package/dist/hooks/{chunk-ERXMJJOV.js → chunk-AWTFKKFW.js} +1 -1
  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/document/document-byte-budget.ts +1 -1
  37. package/scripts/gate/gate-steps.ts +1 -1
  38. package/scripts/gate/project-checks.ts +12 -11
  39. package/scripts/gate/type-check-command.ts +1 -1
  40. package/scripts/git/git-branch.ts +14 -3
  41. package/scripts/git/git-issue.ts +13 -5
  42. package/scripts/git/git-pr.ts +16 -10
  43. package/scripts/git/git-preflight.ts +138 -0
  44. package/scripts/git/telegram-notify.ts +21 -0
  45. package/scripts/hooks/stop-guard.ts +2 -0
  46. package/scripts/init/basic-path-migration.ts +24 -0
  47. package/scripts/init/distributed-paths.ts +22 -0
  48. package/scripts/init/init-actions.ts +27 -19
  49. package/scripts/init/init-ai-copy.ts +86 -28
  50. package/scripts/init/{init-static.ts → init-basic.ts} +6 -6
  51. package/scripts/init/init-bootstrap.ts +4 -3
  52. package/scripts/init/init-install.ts +1 -1
  53. package/scripts/init/init-logic-templates.ts +1 -1
  54. package/scripts/init/init-logic.ts +1 -1
  55. package/scripts/init/init.ts +41 -19
  56. package/scripts/init/kit-setup-state.ts +30 -0
  57. package/scripts/init/kit-written-paths.ts +33 -0
  58. package/scripts/init/project-profile-cli.ts +15 -1
  59. package/scripts/init/project-profile.ts +41 -12
  60. package/scripts/init/session-language-environment.ts +83 -0
  61. package/scripts/init/start-exec.ts +28 -0
  62. package/scripts/init/start-plan.ts +35 -13
  63. package/scripts/init/start-prompt.ts +22 -8
  64. package/scripts/init/start-setup-pr.ts +138 -0
  65. package/scripts/init/start-steps.ts +30 -30
  66. package/scripts/init/start.ts +7 -3
  67. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +3 -3
  68. package/scripts/josh/josh-commands-development.ts +3 -3
  69. package/scripts/josh/josh-commands-project.ts +2 -2
  70. package/scripts/lint/cspell-cached.ts +3 -3
  71. package/scripts/lint/format.ts +1 -1
  72. package/scripts/lint/lint-parallel.ts +2 -2
  73. package/scripts/rules/filing-cap.ts +1 -0
  74. package/scripts/rules/issue-citation.ts +30 -1
  75. package/scripts/rules/last-prompt.ts +79 -0
  76. package/scripts/rules/stop-rules.ts +5 -3
  77. package/scripts/safe-chain/ci-installer-pin.ts +19 -12
  78. package/scripts/safe-chain/project-config.ts +1 -1
  79. package/scripts/sync/sync-ai-files.ts +204 -0
  80. package/scripts/sync/sync.ts +48 -184
  81. package/scripts/test/josh-harness-environment.ts +1 -1
  82. package/scripts/test/pilot-files.ts +0 -1
  83. package/scripts/test/test-declared-changed.ts +1 -1
  84. package/scripts/test/test-declared-logic.ts +55 -14
  85. package/scripts/test/test-declared.ts +23 -11
  86. package/scripts/test/test-unit-guard.ts +6 -6
  87. package/scripts/time-runtime/time-shell.ts +36 -2
  88. package/scripts-ai/git-workflow.ts +10 -0
  89. package/templates/CLAUDE.basic.md +8 -0
  90. package/templates/workflows/ci.yml +27 -0
  91. /package/{templates/CLAUDE.static.md → dist/CLAUDE.basic.md} +0 -0
  92. /package/templates/{cursorrules.static → cursorrules.basic} +0 -0
  93. /package/templates/{pnpm-workspace.static.yaml → pnpm-workspace.basic.yaml} +0 -0
  94. /package/templates/{prettierignore.static → prettierignore.basic} +0 -0
@@ -13,7 +13,7 @@ import { start_steps } from './start-steps'
13
13
 
14
14
  const ARGUMENT_START_INDEX = 2
15
15
  const READY_MESSAGE = 'GitHub workflow ready. Use kickoff new in your assistant to plan an Issue.'
16
- const START_USAGE = 'josh start [--profile static|node] [--yes] [--github] [--public]'
16
+ const START_USAGE = 'josh start [--profile basic|full] [--yes] [--github] [--public]'
17
17
  const YES_FLAG = '--yes'
18
18
  const GITHUB_FLAG = '--github'
19
19
  const PUBLIC_FLAG = '--public'
@@ -72,7 +72,11 @@ function prepare(args: ReadonlyArray<string>, is_tty: boolean, root: string): Pr
72
72
 
73
73
  stop_on(start_steps.github_cli_refusal(root))
74
74
 
75
- return { steps: plan.steps, interaction, choices: { options, shape, consent, root } }
75
+ return {
76
+ steps: plan.steps,
77
+ interaction,
78
+ choices: { options, shape, consent, root, steps: plan.steps },
79
+ }
76
80
  }
77
81
 
78
82
  async function main(args: ReadonlyArray<string>, is_tty: boolean): Promise<void> {
@@ -82,7 +86,7 @@ async function main(args: ReadonlyArray<string>, is_tty: boolean): Promise<void>
82
86
  const profile = await resolve_profile(choices, interaction)
83
87
  const { root, options } = choices
84
88
 
85
- start_steps.run_steps(steps, { root, profile, visibility: options.visibility })
89
+ await start_steps.run_steps(steps, { root, profile, visibility: options.visibility })
86
90
  console.info(`\n${READY_MESSAGE}`)
87
91
  }
88
92
 
@@ -450,7 +450,7 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
450
450
  ],
451
451
  ],
452
452
  "init": [
453
- "[--profile static|node] [--no-install]",
453
+ "[--profile basic|full] [--no-install]",
454
454
  "developer",
455
455
  [
456
456
  "files",
@@ -766,7 +766,7 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
766
766
  ],
767
767
  ],
768
768
  "profile": [
769
- "[--profile static|node]",
769
+ "[--profile basic|full]",
770
770
  "developer",
771
771
  [
772
772
  "files",
@@ -1112,7 +1112,7 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
1112
1112
  ],
1113
1113
  ],
1114
1114
  "start": [
1115
- "[--profile static|node] [--yes] [--github] [--public]",
1115
+ "[--profile basic|full] [--yes] [--github] [--public]",
1116
1116
  "developer",
1117
1117
  [
1118
1118
  "files",
@@ -22,7 +22,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
22
22
  },
23
23
  lint: {
24
24
  script: 'scripts/lint/lint-parallel.ts',
25
- description: 'Check code with prettier and eslint (skips a tool a static project lacks)',
25
+ description: 'Check code with prettier and eslint (skips a tool a basic project lacks)',
26
26
  category: 'Development',
27
27
  reference: ['', 'developer', ['processes']],
28
28
  },
@@ -61,7 +61,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
61
61
  },
62
62
  format: {
63
63
  script: 'scripts/lint/format.ts',
64
- description: 'Format code with prettier and eslint (skips a tool a static project lacks)',
64
+ description: 'Format code with prettier and eslint (skips a tool a basic project lacks)',
65
65
  category: 'Development',
66
66
  reference: ['', 'developer', ['files', 'processes']],
67
67
  },
@@ -183,7 +183,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
183
183
  },
184
184
  check: {
185
185
  script: 'scripts/gate/type-check-command.ts',
186
- description: 'Type-check with tsc (skips a static project with no TypeScript to check)',
186
+ description: 'Type-check with tsc (skips a basic project with no TypeScript to check)',
187
187
  category: 'Development',
188
188
  reference: ['[arguments...]', 'developer', ['processes']],
189
189
  },
@@ -4,7 +4,7 @@ import type { CommandEntry } from './josh-command-types'
4
4
  // no identifier format allows. The same disable sits at the top of `josh-commands-maintenance.ts`
5
5
  // for the same reason.
6
6
  /* eslint-disable @typescript-eslint/naming-convention */
7
- const PROFILE_ARGUMENTS = '[--profile static|node]'
7
+ const PROFILE_ARGUMENTS = '[--profile basic|full]'
8
8
  const PROJECT_COMMANDS: Record<string, CommandEntry> = {
9
9
  profile: {
10
10
  script: 'scripts/init/project-profile-cli.ts',
@@ -14,7 +14,7 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
14
14
  },
15
15
  start: {
16
16
  script: 'scripts/init/start.ts',
17
- description: 'Create a new project for the GitHub Issue workflow, from git init to labels',
17
+ description: 'Set a project up for the GitHub Issue workflow, from git init to the setup PR',
18
18
  category: 'Project',
19
19
  reference: [
20
20
  `${PROFILE_ARGUMENTS} [--yes] [--github] [--public]`,
@@ -23,8 +23,8 @@ const CSPELL_ARGS = [
23
23
 
24
24
  // The wrapper puts cache transfer directly around cspell rather than around the whole gate (#2060),
25
25
  // so a later failing check cannot prevent a completed cache from reaching the primary checkout.
26
- function static_skip_reason(directory: string): string | undefined {
27
- if (!project_checks.is_static(directory)) return undefined
26
+ function basic_skip_reason(directory: string): string | undefined {
27
+ if (!project_checks.is_basic(directory)) return undefined
28
28
 
29
29
  if (!project_checks.has_config(directory, project_checks.CSPELL_CONFIGS)) {
30
30
  return 'no cspell configuration was found'
@@ -36,7 +36,7 @@ function static_skip_reason(directory: string): string | undefined {
36
36
  }
37
37
 
38
38
  async function run(extra_arguments: ReadonlyArray<string> = []): Promise<number> {
39
- const reason = static_skip_reason(process.cwd())
39
+ const reason = basic_skip_reason(process.cwd())
40
40
 
41
41
  if (reason !== undefined) {
42
42
  console.info(project_checks.skip_notice('cspell', reason))
@@ -19,7 +19,7 @@ async function run_step(args: ReadonlyArray<string>, directory: string): Promise
19
19
 
20
20
  // prettier first, deliberately: `eslint --fix` exits 1 whenever a non-autofixable error remains, so
21
21
  // putting it first would mean one unused variable anywhere in the tree stops prettier from running.
22
- // A static project without Prettier or ESLint is skipped with the same reason `josh lint` prints
22
+ // A basic project without Prettier or ESLint is skipped with the same reason `josh lint` prints
23
23
  // (joshuafolkken/kit#2693, joshuafolkken/kit#2606).
24
24
  async function run_unless_skipped(
25
25
  check: string,
@@ -64,8 +64,8 @@ function lint_exit_code(prettier: BufferedProcessResult, eslint: BufferedProcess
64
64
  // The two checks are run from here whether they were pointed at the whole tree or at one change's
65
65
  // files, so `josh lint:related` reaches prettier and eslint through the same buffering, the same
66
66
  // "one failure does not abort the other" reading, and the same exit code (joshuafolkken/kit#1298) —
67
- // and through the same static-project skip, so a narrowed run never reaches a tool the whole-tree
68
- // run would have skipped (joshuafolkken/kit#2709). The skip reasons answer nothing for a node
67
+ // and through the same basic-project skip, so a narrowed run never reaches a tool the whole-tree
68
+ // run would have skipped (joshuafolkken/kit#2709). The skip reasons answer nothing for a full
69
69
  // project, so the one path serves both profiles.
70
70
  async function run_lint_checks(
71
71
  prettier_args: ReadonlyArray<string>,
@@ -129,6 +129,7 @@ const filing_cap = {
129
129
  ROW,
130
130
  current_turn,
131
131
  decide,
132
+ is_prompt_line,
132
133
  prior_filing_count,
133
134
  turn_filing_count,
134
135
  }
@@ -171,6 +171,28 @@ function bare_references(message: string): ReadonlyArray<string> {
171
171
  return [...new Set(found)]
172
172
  }
173
173
 
174
+ // Every reference the prompt carries, each as written — `#N` or `owner/repo#N` — with none of the
175
+ // citation exclusions applied: a number pasted inside a fence or a quote is still the person's.
176
+ function prompt_references(prompt: string): ReadonlySet<string> {
177
+ const found = new Set<string>()
178
+
179
+ for (const match of prompt.matchAll(ISSUE_NUMBER)) {
180
+ found.add(repo_prefix(prompt, match.index) + match[0])
181
+ }
182
+
183
+ return found
184
+ }
185
+
186
+ // **A number the person's prompt already carried is quoted, not cited** (joshuafolkken/kit#2819). A
187
+ // pasted log from another repository holds its own `#1`…`#7`; `issue:cite` would link them to this
188
+ // repository's unrelated Issues, so following the refusal would make the reply wrong. Only the exact
189
+ // reference the prompt wrote is exempt, so a bare `#N` the run brought in itself is still refused.
190
+ function unquoted_references(message: string, prompt: string): ReadonlyArray<string> {
191
+ const quoted = prompt_references(prompt)
192
+
193
+ return bare_references(message).filter((reference) => !quoted.has(reference))
194
+ }
195
+
174
196
  function has_bare_reference(message: string): boolean {
175
197
  return bare_references(message).length > 0
176
198
  }
@@ -237,6 +259,13 @@ function linkify(message: string, slug: string): string {
237
259
  return out.join('\n')
238
260
  }
239
261
 
240
- const issue_citation = { bare_references, cite_arguments, has_bare_reference, linkify, prose_lines }
262
+ const issue_citation = {
263
+ bare_references,
264
+ cite_arguments,
265
+ has_bare_reference,
266
+ linkify,
267
+ prose_lines,
268
+ unquoted_references,
269
+ }
241
270
 
242
271
  export { issue_citation }
@@ -0,0 +1,79 @@
1
+ import { json_value } from '#scripts/lib/json-value'
2
+ import { time_reported_failure } from '#scripts/time-runtime/time-reported-failure'
3
+ import { z } from 'zod'
4
+ import { filing_cap } from './filing-cap'
5
+
6
+ // The text of the person's last prompt on a transcript tail — what the stop guard's citation row
7
+ // reads to tell a `#N` the person quoted from one the run brought in itself (joshuafolkken/kit#2819).
8
+ //
9
+ // **A prompt is `filing_cap`'s prompt line, minus what the harness wrote there.** A skill's body is a
10
+ // `user` line with no tool result too, but it carries `isMeta`; a background task's completion notice
11
+ // and a subagent's hand-back carry an `origin` whose `kind` is not `human`. Each is the harness
12
+ // speaking, not the person — and a notice can name the very Issue the run filed — so only a line with
13
+ // no `origin` (an SDK prompt) or a `human` one is read.
14
+ //
15
+ // **A workflow invocation quotes nothing.** `fullrun #2819` names this repository's own Issue, so a
16
+ // bare `#2819` in that run's report is a citation slip like any other, and the prompt yields no text —
17
+ // a resumed lane child's prompt included, since it ends on the same invocation.
18
+
19
+ const WORKFLOW_KEYWORDS: ReadonlySet<string> = new Set([
20
+ 'kickoff',
21
+ 'fullrun',
22
+ 'halfrun',
23
+ 'backlogrun',
24
+ ])
25
+ const NO_PROMPT = ''
26
+ const HUMAN_ORIGIN = 'human'
27
+ // The word before the prompt's last one — where a trailing `fullrun #N` puts its keyword.
28
+ const KEYWORD_BEFORE_ARGUMENT = -2
29
+
30
+ const PROMPT_LINE_SCHEMA = z.object({
31
+ isMeta: z.boolean().nullish(),
32
+ origin: z.object({ kind: z.string().nullish() }).nullish(),
33
+ message: z.object({ content: z.unknown() }),
34
+ })
35
+
36
+ type PromptLine = z.infer<typeof PROMPT_LINE_SCHEMA>
37
+
38
+ function is_person(line: PromptLine): boolean {
39
+ if (line.isMeta === true) return false
40
+
41
+ return line.origin === null || line.origin === undefined || line.origin.kind === HUMAN_ORIGIN
42
+ }
43
+
44
+ // The prompt line's text, or `undefined` when the harness wrote it or it does not read as one.
45
+ function person_text(line: string): string | undefined {
46
+ const parsed = PROMPT_LINE_SCHEMA.safeParse(json_value.parse_or_undefined(line))
47
+
48
+ if (!parsed.success || !is_person(parsed.data)) return undefined
49
+
50
+ return time_reported_failure.result_text(parsed.data.message.content)
51
+ }
52
+
53
+ // A keyword opening the prompt is what a person types; a keyword before its one trailing argument is a
54
+ // lane child's resume prompt, whose preamble ends on `fullrun #N` (`lane-child-invocation.ts`).
55
+ function is_invocation(prompt: string): boolean {
56
+ const words = prompt.trim().split(/\s+/u)
57
+
58
+ return [words.at(0), words.at(KEYWORD_BEFORE_ARGUMENT)].some((word) =>
59
+ WORKFLOW_KEYWORDS.has(word ?? NO_PROMPT),
60
+ )
61
+ }
62
+
63
+ function prompt_line_text(line: string): string | undefined {
64
+ return filing_cap.is_prompt_line(line) ? person_text(line) : undefined
65
+ }
66
+
67
+ // The last person prompt on the tail; '' when none is on it or it is an invocation.
68
+ function prompt_text(tail: string): string {
69
+ const text = tail
70
+ .split('\n')
71
+ .map((line) => prompt_line_text(line))
72
+ .findLast((one) => one !== undefined)
73
+
74
+ return text === undefined || is_invocation(text) ? NO_PROMPT : text
75
+ }
76
+
77
+ const last_prompt = { prompt_text }
78
+
79
+ export { last_prompt }
@@ -49,6 +49,8 @@ interface StopContext {
49
49
  notified: boolean
50
50
  // The turn's session-facing reply text.
51
51
  message: string
52
+ // The person's last prompt — a `#N` it already carried is quoted, not cited (joshuafolkken/kit#2819).
53
+ prompt: string
52
54
  // This stop already forced a continuation earlier in the prompt — the loop-breaker Claude Code
53
55
  // documents, so a run that will not comply is not blocked forever.
54
56
  stop_hook_active: boolean
@@ -242,8 +244,8 @@ function needs_release(context: StopContext): boolean {
242
244
  return is_held_idle(context) && context.tree_clean
243
245
  }
244
246
 
245
- function citation_reason(message: string): string | undefined {
246
- const references = issue_citation.bare_references(message)
247
+ function citation_reason(context: StopContext): string | undefined {
248
+ const references = issue_citation.unquoted_references(context.message, context.prompt)
247
249
  if (references.length === 0) return undefined
248
250
 
249
251
  return build_citation_reason(references)
@@ -255,7 +257,7 @@ function citation_reason(message: string): string | undefined {
255
257
  function reply_reason(context: StopContext): string | undefined {
256
258
  if (needs_filing(context)) return FILING_OFFER_REASON
257
259
 
258
- return citation_reason(context.message) ?? language_reason(context)
260
+ return citation_reason(context) ?? language_reason(context)
259
261
  }
260
262
 
261
263
  // The headless wait holds past the loop-breaker until the spin bound is reached.
@@ -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 }