@joshuafolkken/kit 1.1435.0 → 1.1455.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 (105) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-handoff-report.md +1 -1
  2. package/.claude/skills/workflow-commands/backlogrun-park.md +2 -0
  3. package/.claude/skills/workflow-commands/backlogrun-steps.md +9 -7
  4. package/.claude/skills/workflow-commands/backlogrun.md +2 -2
  5. package/.claude/skills/workflow-commands/fullrun-steps.md +1 -1
  6. package/.claude/skills/workflow-commands/halfrun.md +3 -2
  7. package/.claude/skills/workflow-commands/kickoff.md +12 -13
  8. package/.claude/skills/workflow-commands/observation-filing.md +1 -1
  9. package/.claude/skills/workflow-commands/prerequisite.md +6 -0
  10. package/.claude/skills/workflow-commands/prrun.md +2 -1
  11. package/.claude/skills/workflow-commands/retrospective.md +5 -5
  12. package/.claude/skills/workflow-commands/split-assessment.md +2 -2
  13. package/CLAUDE.md +1 -1
  14. package/README.md +10 -19
  15. package/dist/CLAUDE.md +1 -1
  16. package/dist/hooks/batch-guard.js +6 -6
  17. package/dist/hooks/{chunk-3LE6VLSR.js → chunk-3C4XRYG6.js} +18 -5
  18. package/dist/hooks/{chunk-7KMN5QB5.js → chunk-5H4SSDSD.js} +3 -3
  19. package/dist/hooks/{chunk-YZXYOAZ3.js → chunk-7WQBPLUN.js} +11 -5
  20. package/dist/hooks/{chunk-2G5F2YCD.js → chunk-D5QBTOCZ.js} +3 -3
  21. package/dist/hooks/{chunk-MU4LJFIV.js → chunk-GPLGZ6IC.js} +3 -3
  22. package/dist/hooks/{chunk-MSYSUSY6.js → chunk-HFGWYGUV.js} +5 -5
  23. package/dist/hooks/{chunk-JXQBK323.js → chunk-HHI6FO3R.js} +132 -105
  24. package/dist/hooks/{chunk-XER6SSIB.js → chunk-LIAFCXL7.js} +17 -7
  25. package/dist/hooks/{chunk-4BHUPFKF.js → chunk-OLMO72ON.js} +1 -1
  26. package/dist/hooks/{chunk-EMB55BDF.js → chunk-TBJFTSUY.js} +2 -2
  27. package/dist/hooks/{chunk-5SJSSCJG.js → chunk-UT7YC3N2.js} +1 -1
  28. package/dist/hooks/{chunk-QL6QTPWN.js → chunk-WNLVGT7E.js} +3 -3
  29. package/dist/hooks/{chunk-YW452ZAX.js → chunk-Z4XI7CZS.js} +2 -2
  30. package/dist/hooks/codex-hook-adapter.js +13 -13
  31. package/dist/hooks/duplicate-read-guard.js +7 -7
  32. package/dist/hooks/format-edited.js +3 -3
  33. package/dist/hooks/inputs.json +229 -226
  34. package/dist/hooks/investigation-guard.js +7 -7
  35. package/dist/hooks/pretool-guard.js +12 -12
  36. package/dist/hooks/session-lang.js +3 -3
  37. package/dist/hooks/stop-guard.js +64 -11
  38. package/dist/josh.js +34 -11
  39. package/package.json +1 -1
  40. package/prompts/collaboration-workflow/issue-template.md +10 -8
  41. package/prompts/collaboration-workflow/shell-body.md +1 -1
  42. package/prompts/review.md +8 -4
  43. package/scripts/auto-ok/auto-ok-judge-cli.ts +125 -0
  44. package/scripts/auto-ok/auto-ok-judge-read.ts +95 -0
  45. package/scripts/auto-ok/auto-ok-judge-write.ts +109 -0
  46. package/scripts/auto-ok/auto-ok-judge.ts +67 -0
  47. package/scripts/backlog/backlog-drive-cli.ts +14 -0
  48. package/scripts/backlog/backlog-drive.ts +22 -3
  49. package/scripts/document/document-byte-budget.ts +1 -1
  50. package/scripts/fresh-machine/fresh-machine-patterns.ts +60 -15
  51. package/scripts/gate/project-checks.ts +5 -2
  52. package/scripts/gh/git-gh-issue-rest.ts +1 -0
  53. package/scripts/gh/git-gh-issue.ts +9 -0
  54. package/scripts/gh/git-pr-ai-review.ts +12 -1
  55. package/scripts/gh/git-pr-confirmation.ts +11 -7
  56. package/scripts/init/init-actions.ts +3 -3
  57. package/scripts/init/init-ai-copy.ts +1 -1
  58. package/scripts/init/init-basic.ts +1 -1
  59. package/scripts/init/init-bootstrap.ts +6 -2
  60. package/scripts/init/init-dry-run-report.ts +150 -0
  61. package/scripts/init/init-dry-run.ts +189 -0
  62. package/scripts/init/init-install.ts +8 -1
  63. package/scripts/init/init.ts +57 -19
  64. package/scripts/init/kit-setup-state.ts +11 -17
  65. package/scripts/init/own-formatter.ts +21 -0
  66. package/scripts/init/project-profile.ts +25 -6
  67. package/scripts/init/start-dry-run.ts +70 -0
  68. package/scripts/init/start-plan.ts +8 -3
  69. package/scripts/init/start-setup-pr.ts +13 -24
  70. package/scripts/init/start-steps.ts +16 -1
  71. package/scripts/init/start.ts +38 -18
  72. package/scripts/init/working-tree.ts +116 -0
  73. package/scripts/issue/issue-auto-ok.ts +68 -99
  74. package/scripts/issue/issue-file-cli.ts +17 -17
  75. package/scripts/issue/issue-file.ts +1 -6
  76. package/scripts/issue/issue-labels.ts +6 -2
  77. package/scripts/issue/issue-release-cli.ts +69 -23
  78. package/scripts/issue/issue-release.ts +3 -1
  79. package/scripts/josh/josh-commands-ai.ts +11 -0
  80. package/scripts/josh/josh-commands-issue.ts +3 -3
  81. package/scripts/josh/josh-commands-project.ts +2 -2
  82. package/scripts/josh/josh-commands-workflow.ts +1 -1
  83. package/scripts/josh/process-identity.ts +32 -6
  84. package/scripts/lane/lane-broker-server.ts +69 -0
  85. package/scripts/lane/lane-session-broker.ts +37 -14
  86. package/scripts/notify/confirmation-options.ts +59 -0
  87. package/scripts/notify/notify-opt-in.ts +33 -0
  88. package/scripts/notify/telegram-notify.ts +25 -8
  89. package/scripts/notify/telegram-test-logic.ts +33 -1
  90. package/scripts/notify/telegram-test.ts +4 -2
  91. package/scripts/observations/observation-ledger-migrate.ts +3 -15
  92. package/scripts/release/release-issue-close.ts +133 -0
  93. package/scripts/release/release-publish.ts +12 -1
  94. package/scripts/repo/repository-labels.ts +11 -1
  95. package/scripts/review/comment-ranges.ts +199 -0
  96. package/scripts/review/review-brief-cli.ts +22 -5
  97. package/scripts/review/review-code-tree.ts +119 -0
  98. package/scripts/review/review-round2-cli.ts +16 -5
  99. package/scripts/review/review-round2.ts +30 -10
  100. package/scripts/review/review-stamps.ts +22 -1
  101. package/scripts/rules/shell-body-trigger.ts +3 -2
  102. package/scripts/rules/stop-rules.ts +2 -1
  103. package/scripts/run/entry/run-entry-stop.ts +6 -3
  104. package/scripts/run/run-stop-notify.ts +2 -0
  105. package/scripts/uninstall/distributed-files.ts +1 -1
@@ -0,0 +1,150 @@
1
+ import { json_value } from '#scripts/lib/json-value'
2
+
3
+ type ChangeKind = 'create' | 'merge' | 'overwrite' | 'delete'
4
+
5
+ interface FileChange {
6
+ path: string
7
+ before: string | undefined
8
+ after: string | undefined
9
+ }
10
+
11
+ type JsonRecord = Record<string, unknown>
12
+
13
+ const PACKAGE_JSON = 'package.json'
14
+ const JSON_EXTENSION = '.json'
15
+ const KIND_WIDTH = 'overwrite'.length
16
+ const NO_CHANGES = ' (no file changes)'
17
+
18
+ // Every path whose content differs between the two snapshots, sorted, so a run reads the same twice.
19
+ function changed_files(
20
+ before: ReadonlyMap<string, string>,
21
+ after: ReadonlyMap<string, string>,
22
+ ): Array<FileChange> {
23
+ const paths = [...new Set([...before.keys(), ...after.keys()])].toSorted((left, right) =>
24
+ left.localeCompare(right),
25
+ )
26
+
27
+ return paths
28
+ .filter((file) => before.get(file) !== after.get(file))
29
+ .map((file) => ({ path: file, before: before.get(file), after: after.get(file) }))
30
+ }
31
+
32
+ // A changed file is a merge when every line it had survives — the project's own content is kept —
33
+ // and an overwrite when any of its lines is replaced or removed.
34
+ function keeps_every_line(before: string, after: string): boolean {
35
+ const remaining = new Set(after.split('\n'))
36
+
37
+ return before.split('\n').every((line) => remaining.has(line))
38
+ }
39
+
40
+ function is_kept_array(before: ReadonlyArray<unknown>, after: unknown): boolean {
41
+ if (!Array.isArray(after)) return false
42
+ const remaining = new Set(after.map((value) => JSON.stringify(value)))
43
+
44
+ return before.every((value) => remaining.has(JSON.stringify(value)))
45
+ }
46
+
47
+ // A JSON file is rewritten whole when kit adds to it — `package.json` written out again, a list grown — so
48
+ // it is compared by value: a merge when every value it had is still there, wherever the lines moved.
49
+ function is_kept(before: unknown, after: unknown): boolean {
50
+ if (Array.isArray(before)) return is_kept_array(before, after)
51
+ if (!json_value.is_record(before) || !json_value.is_record(after)) return before === after
52
+
53
+ return Object.keys(before).every((key) => is_kept(before[key], after[key]))
54
+ }
55
+
56
+ // Undefined when either side is not plain JSON (a `tsconfig.json` with comments), which falls back
57
+ // to the line test.
58
+ function keeps_every_value(before: string, after: string): boolean | undefined {
59
+ const old_value = json_value.parse_or_undefined(before)
60
+ const new_value = json_value.parse_or_undefined(after)
61
+ if (old_value === undefined || new_value === undefined) return undefined
62
+
63
+ return is_kept(old_value, new_value)
64
+ }
65
+
66
+ function keeps_content(file: string, before: string, after: string): boolean {
67
+ const is_kept_by_value = file.endsWith(JSON_EXTENSION)
68
+ ? keeps_every_value(before, after)
69
+ : undefined
70
+
71
+ return is_kept_by_value ?? keeps_every_line(before, after)
72
+ }
73
+
74
+ function change_kind(change: FileChange): ChangeKind {
75
+ if (change.before === undefined) return 'create'
76
+ if (change.after === undefined) return 'delete'
77
+
78
+ return keeps_content(change.path, change.before, change.after) ? 'merge' : 'overwrite'
79
+ }
80
+
81
+ function parse_record(content: string | undefined): JsonRecord {
82
+ const parsed = content === undefined ? undefined : json_value.parse_or_undefined(content)
83
+
84
+ return json_value.is_record(parsed) ? parsed : {}
85
+ }
86
+
87
+ function changed_keys(before: JsonRecord, after: JsonRecord): Array<string> {
88
+ const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])]
89
+
90
+ return keys.filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key]))
91
+ }
92
+
93
+ function entry_line(name: string, before: unknown, after: unknown): string {
94
+ if (before === undefined) return `+ ${name} ${JSON.stringify(after)}`
95
+ if (after === undefined) return `- ${name}`
96
+
97
+ return `~ ${name} ${JSON.stringify(before)} → ${JSON.stringify(after)}`
98
+ }
99
+
100
+ // A missing field reads as an empty object, so a block added or removed whole is listed too.
101
+ function as_entries(value: unknown): JsonRecord | undefined {
102
+ if (value === undefined) return {}
103
+
104
+ return json_value.is_record(value) ? value : undefined
105
+ }
106
+
107
+ // A field that is an object (`scripts`, `devDependencies`) is listed entry by entry, so the plan
108
+ // names each script and dependency rather than the whole block.
109
+ function field_lines(key: string, before: unknown, after: unknown): Array<string> {
110
+ const old_entries = as_entries(before)
111
+ const new_entries = as_entries(after)
112
+
113
+ if (old_entries === undefined || new_entries === undefined) {
114
+ return [entry_line(key, before, after)]
115
+ }
116
+
117
+ return changed_keys(old_entries, new_entries).map((name) =>
118
+ entry_line(`${key}.${name}`, old_entries[name], new_entries[name]),
119
+ )
120
+ }
121
+
122
+ function package_json_lines(change: FileChange): Array<string> {
123
+ const before = parse_record(change.before)
124
+ const after = parse_record(change.after)
125
+
126
+ return changed_keys(before, after).flatMap((key) => field_lines(key, before[key], after[key]))
127
+ }
128
+
129
+ function change_lines(change: FileChange): Array<string> {
130
+ const head = ` ${change_kind(change).padEnd(KIND_WIDTH)} ${change.path}`
131
+ if (change.path !== PACKAGE_JSON || change.after === undefined) return [head]
132
+
133
+ return [head, ...package_json_lines(change).map((line) => ` ${line}`)]
134
+ }
135
+
136
+ function format_report(
137
+ title: string,
138
+ changes: ReadonlyArray<FileChange>,
139
+ dependency_lines: ReadonlyArray<string>,
140
+ ): string {
141
+ const files =
142
+ changes.length === 0 ? [NO_CHANGES] : changes.flatMap((change) => change_lines(change))
143
+ const dependencies = dependency_lines.map((line) => ` ${line}`)
144
+
145
+ return [title, '', 'Files:', ...files, '', 'Dependencies:', ...dependencies].join('\n')
146
+ }
147
+
148
+ const init_dry_run_report = { changed_files, change_kind, package_json_lines, format_report }
149
+ export { init_dry_run_report }
150
+ export type { ChangeKind, FileChange }
@@ -0,0 +1,189 @@
1
+ import { createHash } from 'node:crypto'
2
+ import {
3
+ constants,
4
+ cpSync,
5
+ existsSync,
6
+ lstatSync,
7
+ mkdtempSync,
8
+ readFileSync,
9
+ rmSync,
10
+ statSync,
11
+ } from 'node:fs'
12
+ import os from 'node:os'
13
+ import path from 'node:path'
14
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
15
+ import { resolve_tsx_runner } from '#scripts/josh/josh-logic'
16
+ import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
17
+ import { execaSync } from 'execa'
18
+ import { init_bootstrap } from './init-bootstrap'
19
+ import { init_dry_run_report, type FileChange } from './init-dry-run-report'
20
+ import { init_install } from './init-install'
21
+ import { PACKAGE_DIR, package_path } from './init-paths'
22
+ import { project_profile, type ProjectProfile } from './project-profile'
23
+ import { start_exec } from './start-exec'
24
+
25
+ // `josh init --dry-run` and `josh start --dry-run` answer "what would the setup change?" without
26
+ // a second copy of the setup's decisions: the real `josh init` runs on a scratch copy of the project,
27
+ // and the plan is the difference it left there. Every create, merge and skip is decided by the code
28
+ // that decides it for real, so the plan cannot drift from the run (#3830).
29
+
30
+ interface DryRunRequest {
31
+ root: string
32
+ profile: ProjectProfile | undefined
33
+ // Whether the setup meets a repository — `josh start` creates one before its setup runs.
34
+ has_git: boolean
35
+ is_install: boolean
36
+ }
37
+
38
+ interface DryRunSplit {
39
+ is_dry_run: boolean
40
+ rest: ReadonlyArray<string>
41
+ }
42
+
43
+ const DRY_RUN_FLAG = '--dry-run'
44
+ const INIT_SCRIPT = 'scripts/init/init.ts'
45
+ const SCRATCH_PREFIX = 'josh-init-dry-run-'
46
+ const DEFAULT_BRANCH = 'main'
47
+ const TITLE = 'josh init --dry-run — nothing was written. The setup would:'
48
+
49
+ function split_dry_run_flag(args: ReadonlyArray<string>): DryRunSplit {
50
+ return {
51
+ is_dry_run: args.includes(DRY_RUN_FLAG),
52
+ rest: args.filter((argument) => argument !== DRY_RUN_FLAG),
53
+ }
54
+ }
55
+
56
+ // A directory is copied unless the project scan skips it: an ignored one, or a link — followed, a
57
+ // link to the project or an ancestor would copy it into itself until the path is too long.
58
+ function is_copied_directory(source: string): boolean {
59
+ return (
60
+ !lstatSync(source).isSymbolicLink() &&
61
+ !project_profile.is_ignored_directory(path.basename(source))
62
+ )
63
+ }
64
+
65
+ // A link to a file is copied as the file it points to, so no write in the scratch copy can follow a
66
+ // link back into the project; a dangling one is left out, as the project scan leaves out what it
67
+ // cannot read. The root itself is always copied: `cpSync` filters it too, so a project in a
68
+ // directory named `build` or `dist` would otherwise be planned as an empty one.
69
+ function is_copied(root: string, source: string): boolean {
70
+ if (source === root) return true
71
+ if (!existsSync(source)) return false
72
+
73
+ return !statSync(source).isDirectory() || is_copied_directory(source)
74
+ }
75
+
76
+ // The scratch repository answers the setup's git questions as the project's does: one exists, and
77
+ // its origin is the same URL, so a GitHub project is planned as one.
78
+ function mirror_git(root: string, scratch: string): void {
79
+ start_exec.git_succeeds(['init', '--quiet', `--initial-branch=${DEFAULT_BRANCH}`], scratch)
80
+ const origin = git_spawn_sync.origin_url(root)
81
+
82
+ if (origin !== undefined) start_exec.git_succeeds(['remote', 'add', 'origin', origin], scratch)
83
+ }
84
+
85
+ function prepare_scratch(root: string, has_git: boolean): string {
86
+ const scratch = mkdtempSync(path.join(os.tmpdir(), SCRATCH_PREFIX))
87
+
88
+ // A copy-on-write clone where the file system supports one, so mirroring a large project costs
89
+ // next to nothing there; a plain copy elsewhere.
90
+ cpSync(root, scratch, {
91
+ recursive: true,
92
+ dereference: true,
93
+ mode: constants.COPYFILE_FICLONE,
94
+ filter: (source) => is_copied(root, source),
95
+ })
96
+ if (has_git) mirror_git(root, scratch)
97
+
98
+ return scratch
99
+ }
100
+
101
+ function digest_of(file: string): string {
102
+ return createHash('sha256').update(readFileSync(file)).digest('hex')
103
+ }
104
+
105
+ // A snapshot holds each file's digest, not its content, so a project with large files is compared
106
+ // without holding them in memory; only the files the setup changed are read in full.
107
+ function snapshot(root: string): Map<string, string> {
108
+ const files = project_profile.list_files(root)
109
+
110
+ return new Map(files.map((file) => [path.relative(root, file), digest_of(file)]))
111
+ }
112
+
113
+ function content_of(
114
+ root: string,
115
+ file: string,
116
+ digests: ReadonlyMap<string, string>,
117
+ ): string | undefined {
118
+ return digests.has(file) ? readFileSync(path.join(root, file), 'utf8') : undefined
119
+ }
120
+
121
+ // The project itself still holds what the scratch copy held before the setup ran.
122
+ function changes_since(
123
+ request: DryRunRequest,
124
+ scratch: string,
125
+ before: ReadonlyMap<string, string>,
126
+ ): Array<FileChange> {
127
+ const after = snapshot(scratch)
128
+
129
+ return init_dry_run_report.changed_files(before, after).map((change) => ({
130
+ path: change.path,
131
+ before: content_of(request.root, change.path, before),
132
+ after: content_of(scratch, change.path, after),
133
+ }))
134
+ }
135
+
136
+ // `--no-install` keeps the install out of the scratch copy, and the hand-off variable keeps this
137
+ // kit doing the setup itself — the scratch copy has no kit of its own to hand it to.
138
+ function run_init(scratch: string, profile: ProjectProfile | undefined): void {
139
+ const runner = resolve_tsx_runner()
140
+ const profile_args = profile === undefined ? [] : ['--profile', profile]
141
+ const args = [package_path(INIT_SCRIPT), init_install.NO_INSTALL_FLAG, ...profile_args]
142
+ const result = execaSync(runner.executable, [...runner.leading_arguments, ...args], {
143
+ cwd: scratch,
144
+ env: { [init_bootstrap.HANDOFF_ENV]: '1' },
145
+ all: true,
146
+ reject: false,
147
+ })
148
+
149
+ if (result.exitCode !== 0) throw new Error(`the simulated josh init failed:\n${result.all}`)
150
+ }
151
+
152
+ function simulate(request: DryRunRequest): Array<FileChange> {
153
+ const scratch = prepare_scratch(request.root, request.has_git)
154
+
155
+ try {
156
+ const before = snapshot(scratch)
157
+
158
+ run_init(scratch, request.profile)
159
+
160
+ return changes_since(request, scratch, before)
161
+ } finally {
162
+ rmSync(scratch, { recursive: true, force: true })
163
+ }
164
+ }
165
+
166
+ // The same two questions the real run asks: whether to install at all, and whether this kit is the
167
+ // project's own or hands the setup to the one it installs.
168
+ function dependency_lines(root: string, is_install: boolean): Array<string> {
169
+ if (!is_install) return [`skip the install and the format (${init_install.NO_INSTALL_FLAG})`]
170
+ const steps = `run ${init_install.INSTALL_STEP.label}, then ${init_install.FORMAT_STEP.label}`
171
+
172
+ if (init_bootstrap.is_project_kit(PACKAGE_DIR, root)) return [steps]
173
+
174
+ return [`install ${KIT_PACKAGE_NAME} into the project and hand the setup to its josh init`, steps]
175
+ }
176
+
177
+ function plan_report(request: DryRunRequest, title: string = TITLE): string {
178
+ const changes = simulate(request)
179
+
180
+ return init_dry_run_report.format_report(
181
+ title,
182
+ changes,
183
+ dependency_lines(request.root, request.is_install),
184
+ )
185
+ }
186
+
187
+ const init_dry_run = { DRY_RUN_FLAG, split_dry_run_flag, plan_report }
188
+ export { init_dry_run }
189
+ export type { DryRunRequest }
@@ -62,6 +62,13 @@ function run_post_init_steps(project_root: string): string | undefined {
62
62
  return undefined
63
63
  }
64
64
 
65
- const init_install = { NO_INSTALL_FLAG, split_install_flag, did_step_succeed, run_post_init_steps }
65
+ const init_install = {
66
+ NO_INSTALL_FLAG,
67
+ INSTALL_STEP,
68
+ FORMAT_STEP,
69
+ split_install_flag,
70
+ did_step_succeed,
71
+ run_post_init_steps,
72
+ }
66
73
  export { init_install }
67
74
  export type { InstallStep }
@@ -19,20 +19,27 @@ import { init_actions, PRETTIER_CONFIG_JS } from './init-actions'
19
19
  import { init_ai_copy } from './init-ai-copy'
20
20
  import { init_basic } from './init-basic'
21
21
  import { init_bootstrap } from './init-bootstrap'
22
+ import { init_dry_run } from './init-dry-run'
22
23
  import { init_file_action } from './init-file-action'
23
24
  import { init_install } from './init-install'
24
25
  import { init_logic } from './init-logic'
25
26
  import { PACKAGE_DIR, PROJECT_ROOT } from './init-paths'
26
- import { kit_setup_state } from './kit-setup-state'
27
27
  import { package_manager_pin } from './package-manager-pin'
28
28
  import { project_profile, type ProjectProfile, type ProjectShape } from './project-profile'
29
+ import { working_tree } from './working-tree'
29
30
 
30
31
  const PACKAGE_JSON = 'package.json'
31
32
  const KIT_PACKAGE_NAME = '@joshuafolkken/kit'
32
33
  const LEFTHOOK_BIN = 'lefthook'
33
34
  const ARGUMENT_START_INDEX = 2
34
35
  const INSTALL_HINT = '→ run `pnpm install` to install what package.json lists'
35
- const INIT_USAGE = 'josh init [--profile basic|full] [--no-install]'
36
+ const INIT_USAGE = 'josh init [--profile basic|full] [--no-install] [--dry-run]'
37
+
38
+ interface InitRequest {
39
+ is_dry_run: boolean
40
+ is_install: boolean
41
+ requested: ProjectProfile | undefined
42
+ }
36
43
 
37
44
  const engine_pin_schema = z.looseObject({ name: z.string(), version: z.string() })
38
45
 
@@ -265,14 +272,21 @@ function set_up_project(requested: ProjectProfile | undefined, is_install: boole
265
272
  console.info('\n✅ Done.\n')
266
273
  }
267
274
 
268
- // True when this process set the project up — false when it refused, or handed the run to the
269
- // project's own kit, which then speaks for itself.
270
- function main(args: ReadonlyArray<string> = []): boolean {
271
- if (did_refuse_self_run(PACKAGE_DIR, PROJECT_ROOT)) return false
272
- const { is_install, rest } = init_install.split_install_flag(args)
273
- // Validated before the hand-off, so a malformed invocation is refused before kit is installed.
274
- const requested = project_profile.requested_profile(rest, INIT_USAGE)
275
+ // A dry run sets nothing up, so it answers false and points nowhere onward.
276
+ function print_dry_run({ requested, is_install }: InitRequest): false {
277
+ const has_git = project_profile.has_git(PROJECT_ROOT)
275
278
 
279
+ console.info(
280
+ init_dry_run.plan_report({ root: PROJECT_ROOT, profile: requested, has_git, is_install }),
281
+ )
282
+
283
+ return false
284
+ }
285
+
286
+ function did_set_up_here(
287
+ { requested, is_install }: InitRequest,
288
+ args: ReadonlyArray<string>,
289
+ ): boolean {
276
290
  const is_set_up_here = !is_install || !did_hand_off(args)
277
291
 
278
292
  if (is_set_up_here) set_up_project(requested, is_install)
@@ -280,19 +294,43 @@ function main(args: ReadonlyArray<string> = []): boolean {
280
294
  return is_set_up_here
281
295
  }
282
296
 
283
- // Only the command a person typed points onward to `josh start`: `josh start` calls `main` itself and
284
- // is already the next step.
285
- // A setup that failed after writing, such as a failed install, sets the exit code and points nowhere.
286
- function print_start_hint(): void {
287
- if (process.exitCode !== undefined && process.exitCode !== 0) return
288
- const hint = kit_setup_state.start_hint(PROJECT_ROOT)
297
+ // The flags are peeled off first, so what remains is exactly the `--profile` pair.
298
+ // Validated before the hand-off, so a malformed invocation is refused before kit is installed.
299
+ function parse_init_args(args: ReadonlyArray<string>): InitRequest {
300
+ const { is_dry_run, rest: run_args } = init_dry_run.split_dry_run_flag(args)
301
+ const { is_install, rest } = init_install.split_install_flag(run_args)
302
+
303
+ return { is_dry_run, is_install, requested: project_profile.requested_profile(rest, INIT_USAGE) }
304
+ }
305
+
306
+ // True when this process set the project up — false when it refused, or handed the run to the
307
+ // project's own kit, which then speaks for itself.
308
+ function main(args: ReadonlyArray<string> = []): boolean {
309
+ if (did_refuse_self_run(PACKAGE_DIR, PROJECT_ROOT)) return false
310
+ const request = parse_init_args(args)
289
311
 
290
- if (hint !== undefined) console.info(`${hint}\n`)
312
+ return request.is_dry_run ? print_dry_run(request) : did_set_up_here(request, args)
313
+ }
314
+
315
+ // Only the command a person typed is refused, before anything is written or installed. `josh start`
316
+ // calls `main` itself and decides about its own tree (#3835); a handed-off run follows the check
317
+ // its caller already passed, on a tree the kit install it just made has changed.
318
+ function did_refuse_dirty_tree(): boolean {
319
+ const refusal = init_bootstrap.is_handed_off()
320
+ ? undefined
321
+ : working_tree.dirty_tree_refusal(PROJECT_ROOT, 'josh init')
322
+
323
+ if (refusal === undefined) return false
324
+ console.error(`\n${refusal}\n`)
325
+ process.exitCode = 1
326
+
327
+ return true
291
328
  }
292
329
 
293
330
  function run_cli(args: ReadonlyArray<string>): void {
294
331
  try {
295
- if (main(args)) print_start_hint()
332
+ if (did_refuse_dirty_tree()) return
333
+ main(args)
296
334
  } catch (error) {
297
335
  console.error(`\n✖ ${error_text.message_of(error)}\n`)
298
336
  process.exitCode = 1
@@ -312,5 +350,5 @@ const init = {
312
350
  }
313
351
 
314
352
  // `init` is the entry point; `main` is exported for its own unit test, the way `sync.ts` exports the
315
- // `main()` its self-run guard lives in.
316
- export { init, main }
353
+ // `main()` its self-run guard lives in, and `run_cli` for the refusal only the typed command makes.
354
+ export { init, main, run_cli }
@@ -1,30 +1,24 @@
1
+ import { json_value } from '#scripts/lib/json-value'
1
2
  import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
2
3
  import { start_exec } from './start-exec'
4
+ import { workflow_mark } from './workflow-mark'
3
5
 
4
- const START_HINT =
5
- 'To use the GitHub Issue workflow, run pnpm exec josh start next: it puts this setup on GitHub, through a pull request when main already has commits.'
6
-
7
- // Whether the commit checked out records kit in its manifest — the sign that kit's setup has already
8
- // reached the branch, so `josh start` has no setup pull request to open.
6
+ // Whether the commit checked out holds `josh start`'s setup — its manifest lists kit and records the
7
+ // GitHub workflow mark — the sign that the setup has already reached the branch, so `josh start` has
8
+ // no setup pull request to open. Kit alone is not enough: a committed `josh init` setup lacks the mark
9
+ // and, on `basic`, `.claude/settings.json`, which `josh start`'s setup then writes and only a setup
10
+ // pull request carries to `main` (#3835).
9
11
  function is_kit_committed(root: string): boolean {
10
12
  // `./` resolves the path from `root` rather than from the repository's top level.
11
13
  const manifest = start_exec.git_read(['show', 'HEAD:./package.json'], root)
12
14
 
13
- return manifest?.includes(`"${KIT_PACKAGE_NAME}"`) ?? false
14
- }
15
-
16
- function is_git_work_tree(root: string): boolean {
17
- return start_exec.git_succeeds(['rev-parse', '--is-inside-work-tree'], root)
18
- }
15
+ if (manifest?.includes(`"${KIT_PACKAGE_NAME}"`) !== true) return false
19
16
 
20
- // The line `josh init` ends with in a Git repository whose checkout does not hold kit yet: the setup is
21
- // still uncommitted, and `josh start` is what carries it to GitHub.
22
- function start_hint(root: string): string | undefined {
23
- if (!is_git_work_tree(root) || is_kit_committed(root)) return undefined
17
+ const parsed = json_value.parse_or_undefined(manifest)
24
18
 
25
- return START_HINT
19
+ return workflow_mark.is_recorded(json_value.is_record(parsed) ? parsed : undefined)
26
20
  }
27
21
 
28
- const kit_setup_state = { is_kit_committed, start_hint }
22
+ const kit_setup_state = { is_kit_committed }
29
23
 
30
24
  export { kit_setup_state }
@@ -0,0 +1,21 @@
1
+ import { existsSync } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ // The configuration files of a formatter other than Prettier. A `basic` project holding one already
5
+ // formats its own files, so `josh init` gives it no Prettier and `josh format` / `josh gate` skip it
6
+ // (joshuafolkken/kit#3832).
7
+ const OWN_FORMATTER_CONFIGS = [
8
+ 'biome.json',
9
+ 'biome.jsonc',
10
+ 'dprint.json',
11
+ 'dprint.jsonc',
12
+ '.dprint.json',
13
+ '.dprint.jsonc',
14
+ ]
15
+
16
+ function find_config(root: string): string | undefined {
17
+ return OWN_FORMATTER_CONFIGS.find((name) => existsSync(path.join(root, name)))
18
+ }
19
+
20
+ const own_formatter = { find_config }
21
+ export { own_formatter }
@@ -5,6 +5,7 @@ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
5
5
  import { json_value } from '#scripts/lib/json-value'
6
6
  import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
7
7
  import { kit_written_paths } from './kit-written-paths'
8
+ import { own_formatter } from './own-formatter'
8
9
  import { workflow_mark } from './workflow-mark'
9
10
 
10
11
  type ProjectProfile = 'basic' | 'full'
@@ -15,7 +16,8 @@ interface ProfileResult {
15
16
  }
16
17
 
17
18
  interface ProjectShape extends ProfileResult {
18
- has_web: boolean
19
+ // Web files, and no formatter of the project's own (`own-formatter.ts`).
20
+ should_use_prettier: boolean
19
21
  has_typescript: boolean
20
22
  has_git: boolean
21
23
  has_github: boolean
@@ -127,8 +129,12 @@ function resolve_profile(root: string, requested?: ProjectProfile): ProfileResul
127
129
  return inferred_profile(manifest)
128
130
  }
129
131
 
132
+ function is_ignored_directory(name: string): boolean {
133
+ return IGNORED_DIRECTORIES.has(name)
134
+ }
135
+
130
136
  function should_visit(entry: Dirent): boolean {
131
- return entry.isDirectory() && !IGNORED_DIRECTORIES.has(entry.name)
137
+ return entry.isDirectory() && !is_ignored_directory(entry.name)
132
138
  }
133
139
 
134
140
  function scan_directory(directory: string, pending: Array<string>): Array<string> {
@@ -152,7 +158,9 @@ function is_project_file(root: string, file: string): boolean {
152
158
  return !kit_written_paths.is_kit_written(relative_path)
153
159
  }
154
160
 
155
- function file_extensions(root: string): Array<string> {
161
+ // Every file under `root` outside the directories no setup reads — the one walk the project scan and
162
+ // the `josh init --dry-run` snapshot share.
163
+ function list_files(root: string): Array<string> {
156
164
  const pending = [root]
157
165
  const files: Array<string> = []
158
166
 
@@ -160,15 +168,24 @@ function file_extensions(root: string): Array<string> {
160
168
  files.push(...scan_directory(directory, pending))
161
169
  }
162
170
 
163
- return files.filter((file) => is_project_file(root, file)).map((file) => path.extname(file))
171
+ return files
172
+ }
173
+
174
+ function file_extensions(root: string): Array<string> {
175
+ return list_files(root)
176
+ .filter((file) => is_project_file(root, file))
177
+ .map((file) => path.extname(file))
164
178
  }
165
179
 
166
- function scan_files(root: string): { has_web: boolean; has_typescript: boolean } {
180
+ // A project that formats with its own tool gets no Prettier even with Web files: two formatters
181
+ // would rewrite each other's output (joshuafolkken/kit#3832).
182
+ function scan_files(root: string): { should_use_prettier: boolean; has_typescript: boolean } {
167
183
  const extensions = file_extensions(root)
168
184
  const has_web = extensions.some((extension) => WEB_EXTENSIONS.has(extension))
185
+ const should_use_prettier = has_web && own_formatter.find_config(root) === undefined
169
186
  const has_typescript = extensions.some((extension) => TYPESCRIPT_EXTENSIONS.has(extension))
170
187
 
171
- return { has_web, has_typescript }
188
+ return { should_use_prettier, has_typescript }
172
189
  }
173
190
 
174
191
  function has_github_remote(root: string): boolean {
@@ -205,6 +222,8 @@ const project_profile = {
205
222
  resolve_profile,
206
223
  has_git,
207
224
  inspect_project,
225
+ is_ignored_directory,
226
+ list_files,
208
227
  requested_profile,
209
228
  with_legacy_profile_names,
210
229
  }