@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.
- package/.claude/skills/workflow-commands/backlogrun-child.md +4 -5
- package/.claude/skills/workflow-commands/backlogrun-steps.md +1 -1
- package/.github/workflows/sonar-qube.yml +12 -2
- package/CLAUDE.md +2 -2
- package/README.md +30 -21
- package/dist/CLAUDE.md +2 -2
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-ZT65LR3U.js → chunk-4GDR2YTV.js} +4 -4
- package/dist/hooks/{chunk-ASUZGZFT.js → chunk-56LGMJJV.js} +2 -2
- package/dist/hooks/{chunk-LBXQHXMC.js → chunk-6PSTVOLH.js} +263 -74
- package/dist/hooks/{chunk-P3UX7SFN.js → chunk-A2H5I2VD.js} +2 -2
- package/dist/hooks/{chunk-ERXMJJOV.js → chunk-AWTFKKFW.js} +1 -1
- package/dist/hooks/{chunk-IM3KM25Y.js → chunk-EZLHXKJ6.js} +10 -7
- package/dist/hooks/{chunk-APVOACVT.js → chunk-GBJUERYK.js} +1 -1
- package/dist/hooks/{chunk-5JX6HXDX.js → chunk-KPZN42RT.js} +3 -3
- package/dist/hooks/{chunk-47SPMGOD.js → chunk-PAXMLGSS.js} +26 -7
- package/dist/hooks/{chunk-RFPHKU7C.js → chunk-WMIR5SNX.js} +3 -3
- package/dist/hooks/codex-hook-adapter.js +10 -10
- package/dist/hooks/duplicate-read-guard.js +5 -5
- package/dist/hooks/format-edited.js +2 -2
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +9 -9
- package/dist/hooks/stop-guard.js +98 -26
- package/dist/josh.js +5 -5
- package/package.json +5 -1
- package/prettier/basic.d.ts +3 -0
- package/prettier/basic.js +10 -0
- package/prettier/index.js +2 -2
- package/prettier/static.js +3 -10
- package/prompts/sonar-hotspot-handling.md +6 -0
- package/prompts/testing-guide.md +1 -1
- package/scripts/agent/agent-diagnostics.ts +1 -0
- package/scripts/agent/agent-role-profile.ts +1 -1
- package/scripts/behavior/behavior-rules.ts +8 -1
- package/scripts/build/build-claude-md.ts +13 -4
- package/scripts/ci/pr-classification.ts +11 -2
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/gate/gate-steps.ts +1 -1
- package/scripts/gate/project-checks.ts +12 -11
- package/scripts/gate/type-check-command.ts +1 -1
- package/scripts/git/git-branch.ts +14 -3
- package/scripts/git/git-command.ts +5 -2
- package/scripts/git/git-issue.ts +13 -5
- package/scripts/git/git-pr.ts +16 -10
- package/scripts/git/git-preflight.ts +138 -0
- package/scripts/git/telegram-notify.ts +21 -0
- package/scripts/hooks/stop-guard.ts +2 -0
- package/scripts/init/basic-path-migration.ts +24 -0
- package/scripts/init/distributed-paths.ts +22 -0
- package/scripts/init/init-actions.ts +27 -19
- package/scripts/init/init-ai-copy.ts +86 -28
- package/scripts/init/{init-static.ts → init-basic.ts} +6 -6
- package/scripts/init/init-bootstrap.ts +4 -3
- package/scripts/init/init-install.ts +1 -1
- package/scripts/init/init-logic-templates.ts +1 -1
- package/scripts/init/init-logic.ts +1 -1
- package/scripts/init/init.ts +41 -19
- package/scripts/init/kit-setup-state.ts +30 -0
- package/scripts/init/kit-written-paths.ts +33 -0
- package/scripts/init/project-profile-cli.ts +15 -1
- package/scripts/init/project-profile.ts +41 -12
- package/scripts/init/session-language-environment.ts +83 -0
- package/scripts/init/start-exec.ts +28 -0
- package/scripts/init/start-plan.ts +35 -13
- package/scripts/init/start-prompt.ts +22 -8
- package/scripts/init/start-setup-pr.ts +138 -0
- package/scripts/init/start-steps.ts +30 -30
- package/scripts/init/start.ts +7 -3
- package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +3 -3
- package/scripts/josh/josh-commands-development.ts +3 -3
- package/scripts/josh/josh-commands-project.ts +2 -2
- package/scripts/lane/lane-leftover.ts +143 -0
- package/scripts/lane/lane-open.ts +14 -1
- package/scripts/lint/cspell-cached.ts +3 -3
- package/scripts/lint/format.ts +1 -1
- package/scripts/lint/lint-parallel.ts +2 -2
- package/scripts/propagate/propagate-steps.ts +14 -1
- package/scripts/rules/filing-cap.ts +1 -0
- package/scripts/rules/issue-citation.ts +30 -1
- package/scripts/rules/last-prompt.ts +79 -0
- package/scripts/rules/stop-rules.ts +5 -3
- package/scripts/run/run-hold.ts +3 -3
- package/scripts/run/run-preflight.ts +75 -5
- package/scripts/run/run-progress-cli.ts +14 -2
- package/scripts/safe-chain/ci-installer-pin.ts +19 -12
- package/scripts/safe-chain/project-config.ts +1 -1
- package/scripts/sync/sync-ai-files.ts +204 -0
- package/scripts/sync/sync.ts +48 -184
- package/scripts/test/josh-harness-environment.ts +1 -1
- package/scripts/test/pilot-files.ts +0 -1
- package/scripts/test/test-declared-changed.ts +1 -1
- package/scripts/test/test-declared-logic.ts +55 -14
- package/scripts/test/test-declared.ts +23 -11
- package/scripts/test/test-unit-guard.ts +6 -6
- package/scripts/time-runtime/time-shell.ts +36 -2
- package/scripts-ai/git-workflow.ts +10 -0
- package/templates/CLAUDE.basic.md +8 -0
- package/templates/workflows/ci.yml +27 -0
- /package/{templates/CLAUDE.static.md → dist/CLAUDE.basic.md} +0 -0
- /package/templates/{cursorrules.static → cursorrules.basic} +0 -0
- /package/templates/{pnpm-workspace.static.yaml → pnpm-workspace.basic.yaml} +0 -0
- /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
|
|
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
|
-
|
|
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
|
|
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
|
|
300
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
6
|
-
// SHA-256 as workflow-level env, so one pin per file moves every "Setup safe-chain" step
|
|
7
|
-
// (joshuafolkken/kit#2711, joshuafolkken/kit#2765).
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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> =
|
|
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
|
-
|
|
92
|
+
list_workflows,
|
|
86
93
|
installer_url,
|
|
87
94
|
extract_pinned_version,
|
|
88
95
|
rewrite_pin,
|
|
@@ -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 }
|