@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.
- package/.claude/skills/workflow-commands/backlogrun-child.md +1 -1
- 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-762BDXDU.js} +220 -68
- package/dist/hooks/{chunk-IM3KM25Y.js → chunk-7WAOP42A.js} +5 -3
- 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-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/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-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/lint/cspell-cached.ts +3 -3
- package/scripts/lint/format.ts +1 -1
- package/scripts/lint/lint-parallel.ts +2 -2
- 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/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
package/scripts/init/start.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: '
|
|
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
|
|
27
|
-
if (!project_checks.
|
|
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 =
|
|
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))
|
package/scripts/lint/format.ts
CHANGED
|
@@ -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
|
|
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
|
|
68
|
-
// run would have skipped (joshuafolkken/kit#2709). The skip reasons answer nothing for a
|
|
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>,
|
|
@@ -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 = {
|
|
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(
|
|
246
|
-
const references = issue_citation.
|
|
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
|
|
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
|
-
//
|
|
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 }
|