jackherd 1.12.7 → 1.14.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 (60) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +17 -17
  4. package/dist/config.d.ts +35 -11
  5. package/dist/config.js +49 -83
  6. package/herdr-plugin/herdr-plugin.toml +1 -1
  7. package/install.sh +1 -1
  8. package/kit/hooks/hooks/merge-guard/board-settings.ts +7 -32
  9. package/kit/hooks/hooks/merge-guard/lane-plan.ts +2 -29
  10. package/kit/hooks/hooks/plain-question/code-cache.ts +70 -0
  11. package/kit/hooks/hooks/plain-question/index.ts +80 -0
  12. package/kit/hooks/hooks/plain-question/run.ts +50 -0
  13. package/kit/hooks/hooks/shared/board-lanes.ts +19 -0
  14. package/kit/hooks/hooks/shared/boards.ts +96 -0
  15. package/kit/hooks/hooks/shared/plan-codes.ts +68 -0
  16. package/kit/hooks/hooks.json +9 -0
  17. package/kit/review/skills/diagnose/SKILL.md +2 -2
  18. package/kit/review/skills/diagnose/references/feedback-loops.md +4 -13
  19. package/kit/rules/rules/kit/code-style.md +15 -18
  20. package/kit/rules/rules/kit/testing.md +7 -28
  21. package/kit/workflow/skills/implement/SKILL.md +3 -3
  22. package/kit/workflow/skills/orchestrate/SKILL.md +4 -1
  23. package/kit/workflow/skills/orchestrate/references/events.md +6 -1
  24. package/kit/workflow/skills/orchestrate/references/plan-commit.md +2 -1
  25. package/kit/workflow/skills/start/SKILL.md +9 -6
  26. package/kit/workflow/skills/start/references/missing-commands.md +17 -0
  27. package/package.json +1 -1
  28. package/schemas/jackherd.schema.json +38 -11
  29. package/src/check/command.ts +167 -35
  30. package/src/check/file-kinds.ts +142 -0
  31. package/src/check/plan-structure-check.ts +34 -1
  32. package/src/check/plan.ts +230 -45
  33. package/src/check/report.ts +111 -3
  34. package/src/check/result.ts +13 -3
  35. package/src/check/roots.ts +47 -10
  36. package/src/check/schema.ts +20 -3
  37. package/src/check/settings.ts +25 -4
  38. package/src/check/skill-budget-defaults.ts +7 -4
  39. package/src/config/base-branch.ts +2 -0
  40. package/src/config/command.ts +32 -13
  41. package/src/config/detect.ts +242 -74
  42. package/src/config/location.ts +6 -0
  43. package/src/config/repository-facts.ts +32 -7
  44. package/src/config/repository-values.ts +162 -5
  45. package/src/config/schema.ts +34 -16
  46. package/src/lane-prompt/texts.ts +2 -2
  47. package/src/plan-format/bare-codes.ts +28 -0
  48. package/src/plans/lane-plans.ts +2 -1
  49. package/src/sync/command-context.ts +4 -2
  50. package/src/sync/herdr-plugin.ts +30 -6
  51. package/src/sync/init.ts +17 -16
  52. package/src/sync/render.ts +4 -6
  53. package/src/watch/command.ts +32 -1
  54. package/src/watch/lane-progress.ts +6 -1
  55. package/src/watch/lane-report.ts +10 -0
  56. package/src/watch/plan-missing-read.ts +47 -0
  57. package/src/watch/plan-missing.ts +43 -0
  58. package/src/watch/reference-paths.ts +1 -0
  59. package/src/watch/texts.ts +4 -0
  60. package/src/watch/watch-events.ts +7 -0
@@ -1,55 +1,80 @@
1
1
  import { CLI_NAME } from '../version/release'
2
+ import { DEFAULT_BASE_BRANCH } from './base-branch'
2
3
  import { PROJECT_CONFIG_PATH } from './location'
4
+ import { COMMAND_KEYS, type CommandKey } from './schema'
3
5
 
4
- export const PACKAGE_MANAGER = { bun: 'bun', yarn: 'yarn', pnpm: 'pnpm', npm: 'npm' } as const
6
+ export const STACK = { bun: 'bun', yarn: 'yarn', pnpm: 'pnpm', npm: 'npm', composer: 'composer' } as const
5
7
 
6
- export type PackageManager = (typeof PACKAGE_MANAGER)[keyof typeof PACKAGE_MANAGER]
8
+ export type Stack = (typeof STACK)[keyof typeof STACK]
9
+
10
+ /** A tool choice: the command of the first package the manifest requires. */
11
+ type ToolChoice = readonly (readonly [packageName: string, command: string])[]
12
+
13
+ type ConfigObject = Record<string, unknown>
14
+
15
+ /** The blocks of a config that detection fills. */
16
+ type ConfigParts = { commands: ConfigObject; lanes: ConfigObject; check: ConfigObject | undefined }
7
17
 
8
18
  /** What the kit reads from a repository to fill the values its config leaves out. */
9
19
  export type RepositoryFacts = {
10
- /** The lock files of `LOCK_FILES` present in the repository root. */
20
+ /** The lock files of `STACKS` present in the repository root. */
11
21
  lockFiles: string[]
12
22
  /** Branch names on `origin`, without the remote prefix. */
13
23
  remoteBranches: string[]
24
+ /** The package names the root manifests of the present stacks declare; empty without a readable manifest. */
25
+ manifestPackages: string[]
26
+ /** The root markers of `STACKS` present in the repository root (`composer.json`, `package.json`). */
27
+ manifests: string[]
14
28
  }
15
29
 
16
- export type PackageManagerDetection =
17
- | { kind: 'found'; manager: PackageManager; lockFile: string }
30
+ /** `manifest`: no lock file yet, but a manifest only one stack has. */
31
+ export type StackDetection =
32
+ | { kind: 'found'; stack: Stack; lockFile: string }
33
+ | { kind: 'manifest'; stack: Stack; manifest: string }
18
34
  | { kind: 'none' }
19
35
  | { kind: 'ambiguous'; lockFiles: string[] }
20
36
 
21
- type DerivedCommands = {
22
- test: string
23
- typecheck: string
24
- lint: string
25
- format: string
26
- install: string
27
- dev: string
28
- lintFiles: string
29
- }
37
+ /** A detection that names one stack, by its lock file or its manifest. */
38
+ export const isDecided = (detection: StackDetection): detection is Extract<StackDetection, { stack: Stack }> =>
39
+ detection.kind === 'found' || detection.kind === 'manifest'
30
40
 
31
- /** In order of preference within one manager: Bun's text lock before its binary one. */
32
- export const LOCK_FILES: Record<string, PackageManager> = {
33
- 'bun.lock': PACKAGE_MANAGER.bun,
34
- 'bun.lockb': PACKAGE_MANAGER.bun,
35
- 'yarn.lock': PACKAGE_MANAGER.yarn,
36
- 'pnpm-lock.yaml': PACKAGE_MANAGER.pnpm,
37
- 'package-lock.json': PACKAGE_MANAGER.npm,
38
- }
41
+ type DerivedCommands = Record<CommandKey | 'lintFiles', string>
39
42
 
40
- /** The base branches detection chooses between; any other base is set in the config. */
41
- const BASE_BRANCH_CANDIDATES = ['main', 'master'] as const
43
+ /** The row of one stack. A command the stack has no tool for is absent from `commands`. */
44
+ export type StackProfile = {
45
+ /** In order of preference: Bun's text lock before its binary one. */
46
+ lockFiles: readonly string[]
47
+ /** The manifest in the repository root. */
48
+ rootMarker: string
49
+ /** Whether the stack installs dependencies, jackherd among them, into `node_modules` beside the config. */
50
+ installsIntoNodeModules: boolean
51
+ /** Files whose presence says the code is typed or statically analysed. */
52
+ typeMarkers: readonly string[]
53
+ testFilePattern: RegExp
54
+ /** The files a test of this stack covers: its code, never its documents or manifests. */
55
+ sourceFilePattern: RegExp
56
+ /** Globs the lint covers; none means every file. */
57
+ lintGlobs: readonly string[]
58
+ commands: (manifestPackages: readonly string[]) => Partial<DerivedCommands>
59
+ /** The words a command of this stack starts with. */
60
+ commandWords: readonly string[]
61
+ /** The directory whose binaries a command of this stack starts with, as `vendor/bin/`; absent for none. */
62
+ binaryDirectory?: string
63
+ /** The install command that stops at the lock file instead of changing it; absent where the plain install does. */
64
+ frozenInstall?: string
65
+ /** The package names a parsed root manifest declares; absent for a stack whose tools are not named by packages. */
66
+ manifestPackages?: (manifest: unknown) => string[]
67
+ }
42
68
 
43
- /** The base branch when the repository does not decide; the schema's default too. */
44
- export const DEFAULT_BASE_BRANCH = 'main'
69
+ const NODE_TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/
45
70
 
46
- const COMMAND_KEYS = ['test', 'typecheck', 'lint', 'format', 'install', 'dev'] as const
71
+ const NODE_SOURCE_FILE = /\.[cm]?[jt]sx?$/
47
72
 
48
73
  /**
49
74
  * Bun fetches a tool it lacks, so its commands name the package; the others run the repository's own binaries.
50
75
  * Biome runs as `bunx @biomejs/biome <command>`: its package's only binary, started without naming it twice.
51
76
  */
52
- const DERIVED_COMMANDS: Record<PackageManager, DerivedCommands> = {
77
+ const NODE_COMMANDS = {
53
78
  bun: {
54
79
  test: 'bun test',
55
80
  typecheck: 'bunx -p typescript tsc --noEmit',
@@ -86,16 +111,115 @@ const DERIVED_COMMANDS: Record<PackageManager, DerivedCommands> = {
86
111
  dev: 'npm run dev',
87
112
  lintFiles: 'npx @biomejs/biome check --no-errors-on-unmatched',
88
113
  },
114
+ } satisfies Record<Exclude<Stack, 'composer'>, DerivedCommands>
115
+
116
+ const firstTool = (choice: ToolChoice, manifestPackages: readonly string[]): string | undefined =>
117
+ choice.find(([packageName]) => manifestPackages.includes(packageName))?.[1]
118
+
119
+ const COMPOSER_TOOLS = {
120
+ test: [
121
+ ['pestphp/pest', 'vendor/bin/pest'],
122
+ ['phpunit/phpunit', 'vendor/bin/phpunit'],
123
+ ],
124
+ typecheck: [
125
+ ['phpstan/phpstan', 'vendor/bin/phpstan analyse --no-progress'],
126
+ ['vimeo/psalm', 'vendor/bin/psalm --no-progress'],
127
+ ],
128
+ lint: [
129
+ ['laravel/pint', 'vendor/bin/pint --test'],
130
+ ['friendsofphp/php-cs-fixer', 'vendor/bin/php-cs-fixer check'],
131
+ ],
132
+ format: [
133
+ ['laravel/pint', 'vendor/bin/pint'],
134
+ ['friendsofphp/php-cs-fixer', 'vendor/bin/php-cs-fixer fix'],
135
+ ],
136
+ lintFiles: [
137
+ ['laravel/pint', 'vendor/bin/pint --test'],
138
+ ['friendsofphp/php-cs-fixer', 'vendor/bin/php-cs-fixer check --path-mode=intersection'],
139
+ ],
140
+ } satisfies Partial<Record<keyof DerivedCommands, ToolChoice>>
141
+
142
+ const COMPOSER_MANIFEST_SECTIONS = ['require', 'require-dev'] as const
143
+
144
+ const packageNamesOf = (manifest: unknown, section: string): string[] => {
145
+ const packages =
146
+ typeof manifest === 'object' && manifest !== null ? (manifest as Record<string, unknown>)[section] : null
147
+ return typeof packages === 'object' && packages !== null && !Array.isArray(packages) ? Object.keys(packages) : []
89
148
  }
90
149
 
91
- /** The install command of each manager that stops at the lock file instead of changing it. */
92
- const FROZEN_INSTALL: Record<string, string> = {
93
- [DERIVED_COMMANDS.bun.install]: 'bun install --frozen-lockfile',
94
- [DERIVED_COMMANDS.yarn.install]: 'yarn install --immutable',
95
- [DERIVED_COMMANDS.pnpm.install]: 'pnpm install --frozen-lockfile',
96
- [DERIVED_COMMANDS.npm.install]: 'npm ci',
150
+ const composerPackages = (manifest: unknown): string[] =>
151
+ COMPOSER_MANIFEST_SECTIONS.flatMap((section) => packageNamesOf(manifest, section))
152
+
153
+ const composerCommands = (manifestPackages: readonly string[]): Partial<DerivedCommands> => {
154
+ const tools = Object.fromEntries(
155
+ Object.entries(COMPOSER_TOOLS).map(([key, choice]) => [key, firstTool(choice, manifestPackages)]),
156
+ )
157
+ return Object.fromEntries(
158
+ Object.entries({ ...tools, install: 'composer install', dev: 'composer run dev' }).filter(
159
+ ([, command]) => command !== undefined,
160
+ ),
161
+ )
162
+ }
163
+
164
+ const nodeProfile = (
165
+ lockFiles: readonly string[],
166
+ commandWords: readonly string[],
167
+ commands: DerivedCommands,
168
+ frozenInstall: string,
169
+ ): StackProfile => ({
170
+ lockFiles,
171
+ commandWords,
172
+ frozenInstall,
173
+ rootMarker: 'package.json',
174
+ installsIntoNodeModules: true,
175
+ typeMarkers: ['tsconfig.json'],
176
+ testFilePattern: NODE_TEST_FILE,
177
+ sourceFilePattern: NODE_SOURCE_FILE,
178
+ lintGlobs: [],
179
+ commands: () => commands,
180
+ })
181
+
182
+ /** One row per stack, in the order detection reports them. */
183
+ export const STACKS: Record<Stack, StackProfile> = {
184
+ bun: nodeProfile(['bun.lock', 'bun.lockb'], ['bun', 'bunx'], NODE_COMMANDS.bun, 'bun install --frozen-lockfile'),
185
+ yarn: nodeProfile(['yarn.lock'], ['yarn'], NODE_COMMANDS.yarn, 'yarn install --immutable'),
186
+ pnpm: nodeProfile(['pnpm-lock.yaml'], ['pnpm'], NODE_COMMANDS.pnpm, 'pnpm install --frozen-lockfile'),
187
+ npm: nodeProfile(['package-lock.json'], ['npm', 'npx'], NODE_COMMANDS.npm, 'npm ci'),
188
+ composer: {
189
+ lockFiles: ['composer.lock'],
190
+ commandWords: ['composer'],
191
+ binaryDirectory: 'vendor/bin/',
192
+ rootMarker: 'composer.json',
193
+ installsIntoNodeModules: false,
194
+ typeMarkers: ['phpstan.neon', 'phpstan.neon.dist', 'phpstan.dist.neon', 'psalm.xml'],
195
+ testFilePattern: /Test\.php$/,
196
+ sourceFilePattern: /\.php$/,
197
+ lintGlobs: ['**/*.php'],
198
+ commands: composerCommands,
199
+ manifestPackages: composerPackages,
200
+ },
97
201
  }
98
202
 
203
+ const STACK_NAMES = Object.keys(STACKS) as Stack[]
204
+
205
+ /** The stack whose tool a command starts with (`bunx`, `composer`, `vendor/bin/pest`); undefined for a command of the project's own. */
206
+ export const stackOfCommand = (command: string): Stack | undefined => {
207
+ const [word = ''] = command.trim().split(/\s+/)
208
+ return STACK_NAMES.find(
209
+ (stack) =>
210
+ STACKS[stack].commandWords.includes(word) ||
211
+ (STACKS[stack].binaryDirectory !== undefined && word.startsWith(STACKS[stack].binaryDirectory)),
212
+ )
213
+ }
214
+
215
+ /** The install command of each manager that stops at the lock file instead of changing it; Composer's already does. */
216
+ const FROZEN_INSTALL: Record<string, string> = Object.fromEntries(
217
+ Object.values(STACKS).flatMap(({ commands, frozenInstall }) => {
218
+ const install = commands([]).install
219
+ return install === undefined || frozenInstall === undefined ? [] : [[install, frozenInstall]]
220
+ }),
221
+ )
222
+
99
223
  /**
100
224
  * The check that a worktree's dependencies are all there: the manager's install with the lock file frozen, fast when
101
225
  * nothing is missing, fetching what is, failing when the lock file and `package.json` disagree. An install command the
@@ -103,35 +227,51 @@ const FROZEN_INSTALL: Record<string, string> = {
103
227
  */
104
228
  export const frozenInstallCommand = (install: string): string => FROZEN_INSTALL[install] ?? install
105
229
 
106
- /** The bun defaults of the schema, so a repository Bun runs and one detection skips agree. */
107
- export const BUN_COMMANDS = DERIVED_COMMANDS.bun
108
-
109
- type ConfigObject = Record<string, unknown>
110
-
111
- /** The blocks of a config that detection fills. */
112
- type ConfigParts = { commands: ConfigObject; lanes: ConfigObject; check: ConfigObject | undefined }
113
-
114
230
  const isObject = (value: unknown): value is ConfigObject =>
115
231
  typeof value === 'object' && value !== null && !Array.isArray(value)
116
232
 
117
233
  const has = (object: ConfigObject, key: string): boolean => Object.hasOwn(object, key)
118
234
 
119
- export const detectPackageManager = (lockFiles: readonly string[]): PackageManagerDetection => {
120
- const known = Object.keys(LOCK_FILES).filter((file) => lockFiles.includes(file))
121
- const managers = new Set(known.map((file) => LOCK_FILES[file]))
122
- if (managers.size === 0) return { kind: 'none' }
123
- if (managers.size > 1) return { kind: 'ambiguous', lockFiles: known }
124
- return { kind: 'found', manager: LOCK_FILES[known[0]], lockFile: known[0] }
235
+ /** Without a lock file, the stack whose manifest is the only one there and is no other stack's; none where managers share it. */
236
+ const detectByManifest = (manifests: readonly string[]): StackDetection => {
237
+ const stacks = STACK_NAMES.filter((stack) => manifests.includes(STACKS[stack].rootMarker))
238
+ return stacks.length === 1
239
+ ? { kind: 'manifest', stack: stacks[0], manifest: STACKS[stacks[0]].rootMarker }
240
+ : { kind: 'none' }
241
+ }
242
+
243
+ /** The stack by its lock files; only without any lock file by its manifest, which never names a manager it shares. */
244
+ export const detectStack = (lockFiles: readonly string[], manifests: readonly string[]): StackDetection => {
245
+ const known = STACK_NAMES.flatMap((stack) => STACKS[stack].lockFiles.filter((file) => lockFiles.includes(file)))
246
+ const stacks = new Set(STACK_NAMES.filter((stack) => STACKS[stack].lockFiles.some((file) => known.includes(file))))
247
+ if (stacks.size === 0) return detectByManifest(manifests)
248
+ if (stacks.size > 1) return { kind: 'ambiguous', lockFiles: known }
249
+ return { kind: 'found', stack: [...stacks][0], lockFile: known[0] }
125
250
  }
126
251
 
252
+ /** The base branches detection chooses between; any other base is set in the config. */
253
+ const BASE_BRANCH_CANDIDATES = ['main', 'master'] as const
254
+
127
255
  /** `main` or `master` when origin has exactly one of them; `origin/HEAD` is not asked, since it can point at a branch long deleted. */
128
256
  export const detectBaseBranch = (remoteBranches: readonly string[]): string | null => {
129
257
  const present = BASE_BRANCH_CANDIDATES.filter((branch) => remoteBranches.includes(branch))
130
258
  return present.length === 1 ? present[0] : null
131
259
  }
132
260
 
261
+ const UNSET_VALUES = `\`commands\`, \`lanes.lockFile\` and \`check.lintFilesCommand\` in ${PROJECT_CONFIG_PATH}`
262
+
133
263
  const ambiguousNote = (lockFiles: readonly string[]): string =>
134
- `${CLI_NAME}: lock files of several package managers (${lockFiles.join(', ')}); using the bun defaults. Set \`commands\` and \`lanes.lockFile\` in ${PROJECT_CONFIG_PATH}.`
264
+ `${CLI_NAME}: lock files of several stacks (${lockFiles.join(', ')}). Set ${UNSET_VALUES}.`
265
+
266
+ /** What manifests without a lock file fail to name: one shared by several managers, or several stacks at once. */
267
+ const unnamedBy = (manifests: readonly string[]): string =>
268
+ manifests.length === 1 ? 'package manager' : 'single stack'
269
+
270
+ /** Without a stack: either nothing known is there, or only manifests that name no single stack. */
271
+ const unknownStackNote = (manifests: readonly string[]): string =>
272
+ manifests.length === 0
273
+ ? `${CLI_NAME}: no known stack (lock file) in this repository; set ${UNSET_VALUES}.`
274
+ : `${CLI_NAME}: ${manifests.join(', ')} without a lock file names no ${unnamedBy(manifests)}; set ${UNSET_VALUES}.`
135
275
 
136
276
  const baseBranchNote = (remoteBranches: readonly string[]): string => {
137
277
  const reason = BASE_BRANCH_CANDIDATES.every((branch) => remoteBranches.includes(branch))
@@ -140,40 +280,67 @@ const baseBranchNote = (remoteBranches: readonly string[]): string => {
140
280
  return `${CLI_NAME}: ${reason}; using ${DEFAULT_BASE_BRANCH}. Set \`baseBranch\` in ${PROJECT_CONFIG_PATH}.`
141
281
  }
142
282
 
143
- const missingCommandKeys = (commands: ConfigObject): string[] => COMMAND_KEYS.filter((key) => !has(commands, key))
283
+ /** What the stack of a repository sets for the values a config leaves out; empty where no stack is found. */
284
+ export type DetectedValues = {
285
+ commands: Partial<Record<CommandKey, string>>
286
+ lanes: { lockFile?: string }
287
+ check: { lintFilesCommand?: string }
288
+ }
289
+
290
+ /** A stack found by its manifest alone sets no `lanes.lockFile`: the lock file does not exist yet. */
291
+ const detectedValuesOf = (detection: StackDetection, manifestPackages: readonly string[]): DetectedValues => {
292
+ if (!isDecided(detection)) return { commands: {}, lanes: {}, check: {} }
293
+ const { lintFiles, ...derived } = STACKS[detection.stack].commands(manifestPackages)
294
+ const commands = Object.fromEntries(
295
+ COMMAND_KEYS.flatMap((key) => (derived[key] === undefined ? [] : [[key, derived[key]]])),
296
+ )
297
+ return {
298
+ commands,
299
+ lanes: detection.kind === 'found' ? { lockFile: detection.lockFile } : {},
300
+ check: lintFiles === undefined ? {} : { lintFilesCommand: lintFiles },
301
+ }
302
+ }
144
303
 
145
- const withManager = (
146
- raw: ConfigObject,
147
- parts: ConfigParts,
148
- found: { manager: PackageManager; lockFile: string },
149
- ): ConfigObject => {
150
- const derived = DERIVED_COMMANDS[found.manager]
304
+ /** The values the repository's stack (from its lock file or manifest) would set in a config. */
305
+ export const detectedValues = (facts: RepositoryFacts): DetectedValues =>
306
+ detectedValuesOf(detectStack(facts.lockFiles, facts.manifests), facts.manifestPackages)
307
+
308
+ const missingCommandKeys = (commands: ConfigObject): CommandKey[] => COMMAND_KEYS.filter((key) => !has(commands, key))
309
+
310
+ const withStack = (raw: ConfigObject, parts: ConfigParts, detected: DetectedValues): ConfigObject => {
151
311
  const commands = Object.fromEntries(
152
- missingCommandKeys(parts.commands).map((key) => [key, derived[key as keyof DerivedCommands]]),
312
+ missingCommandKeys(parts.commands).flatMap((key) => {
313
+ const command = detected.commands[key]
314
+ return command === undefined ? [] : [[key, command]]
315
+ }),
153
316
  )
154
- const check = parts.check ?? {}
155
317
  return {
156
318
  ...raw,
157
319
  commands: { ...commands, ...parts.commands },
158
- lanes: { lockFile: found.lockFile, ...parts.lanes },
159
- check: { lintFilesCommand: derived.lintFiles, ...check },
320
+ lanes: { ...detected.lanes, ...parts.lanes },
321
+ check: { ...detected.check, ...parts.check },
160
322
  }
161
323
  }
162
324
 
163
- const needsManager = (parts: ConfigParts) =>
325
+ const needsStack = (parts: ConfigParts) =>
164
326
  missingCommandKeys(parts.commands).length > 0 ||
165
327
  !has(parts.lanes, 'lockFile') ||
166
328
  !(parts.check && has(parts.check, 'lintFilesCommand'))
167
329
 
168
- const fillManager = (
330
+ const fillStack = (
169
331
  raw: ConfigObject,
170
332
  parts: ConfigParts,
171
- lockFiles: readonly string[],
333
+ facts: RepositoryFacts,
172
334
  ): { raw: ConfigObject; notes: string[] } => {
173
- if (!needsManager(parts)) return { raw, notes: [] }
174
- const detection = detectPackageManager(lockFiles)
175
- if (detection.kind === 'found') return { raw: withManager(raw, parts, detection), notes: [] }
176
- return { raw, notes: detection.kind === 'ambiguous' ? [ambiguousNote(detection.lockFiles)] : [] }
335
+ if (!needsStack(parts)) return { raw, notes: [] }
336
+ const detection = detectStack(facts.lockFiles, facts.manifests)
337
+ if (isDecided(detection)) {
338
+ return { raw: withStack(raw, parts, detectedValuesOf(detection, facts.manifestPackages)), notes: [] }
339
+ }
340
+ return {
341
+ raw,
342
+ notes: [detection.kind === 'ambiguous' ? ambiguousNote(detection.lockFiles) : unknownStackNote(facts.manifests)],
343
+ }
177
344
  }
178
345
 
179
346
  const fillBaseBranch = (
@@ -196,16 +363,17 @@ const configParts = (raw: ConfigObject): ConfigParts | null => {
196
363
 
197
364
  /**
198
365
  * The raw config with every value it leaves out of `commands`, `lanes.lockFile`, `check.lintFilesCommand` and
199
- * `baseBranch` filled from the repository: the package manager from the lock file, the base branch from origin.
200
- * A value the config sets always wins. Where the repository does not decide (lock files of several managers,
201
- * both or neither of `main` and `master`), the schema default holds and a note says so; without a lock file the
202
- * defaults hold silently. A config of the wrong shape is passed through untouched for the schema to refuse.
366
+ * `baseBranch` filled from the repository: the stack from the lock file (without one, from a manifest only one stack
367
+ * has), the base branch from origin. A value the config sets always wins, and a command the stack has no tool for
368
+ * stays out. Where the repository does not decide (no lock file and no such manifest, lock files of several stacks,
369
+ * both or neither of `main` and `master`) nothing is guessed and a note
370
+ * says so. A config of the wrong shape is passed through untouched for the schema to refuse.
203
371
  */
204
372
  export const withDetected = (raw: unknown, facts: RepositoryFacts): { raw: unknown; notes: string[] } => {
205
373
  if (!isObject(raw)) return { raw, notes: [] }
206
374
  const parts = configParts(raw)
207
375
  if (!parts) return { raw, notes: [] }
208
- const manager = fillManager(raw, parts, facts.lockFiles)
209
- const base = fillBaseBranch(manager.raw, facts.remoteBranches)
210
- return { raw: base.raw, notes: [...manager.notes, ...base.notes] }
376
+ const stack = fillStack(raw, parts, facts)
377
+ const base = fillBaseBranch(stack.raw, facts.remoteBranches)
378
+ return { raw: base.raw, notes: [...stack.notes, ...base.notes] }
211
379
  }
@@ -12,6 +12,12 @@ export const CONFIG_SCHEMA_REFERENCE = `../node_modules/${PACKAGE_NAME}/schemas/
12
12
  /** The project's config, relative to its root; the same path below the home directory is the global config. */
13
13
  export const PROJECT_CONFIG_PATH = `${TOOL_DIRECTORY}/${CONFIG_FILE}`
14
14
 
15
+ /**
16
+ * What stands in for a value the project config leaves unset, such as `commands.test`: where to set it. Plain text
17
+ * without backticks, since a kit text puts the value inside a code span.
18
+ */
19
+ export const unsetValueText = (key: string): string => `not set: ${key} in ${PROJECT_CONFIG_PATH}`
20
+
15
21
  /** Where the config lay before 0.29, read until 2.0.0 when the new one is missing; never written. */
16
22
  export const LEGACY_CONFIG_PATH = '.claude/agent-kit.json'
17
23
 
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process'
2
- import { existsSync } from 'node:fs'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
- import { LOCK_FILES, type RepositoryFacts } from './detect'
4
+ import { detectStack, isDecided, type RepositoryFacts, STACKS } from './detect'
5
5
 
6
6
  const REMOTE_BRANCHES = ['for-each-ref', '--format=%(refname:lstrip=3)', 'refs/remotes/origin/']
7
7
 
@@ -12,8 +12,33 @@ const remoteBranchesOf = (root: string): string[] => {
12
12
  return result.stdout.split('\n').filter((branch) => branch !== '' && branch !== 'HEAD')
13
13
  }
14
14
 
15
- /** The lock files and origin branches of the repository at `root`; reads only, never fetches. */
16
- export const readRepositoryFacts = (root: string): RepositoryFacts => ({
17
- lockFiles: Object.keys(LOCK_FILES).filter((file) => existsSync(join(root, file))),
18
- remoteBranches: remoteBranchesOf(root),
19
- })
15
+ const readJson = (path: string): unknown => {
16
+ try {
17
+ return JSON.parse(readFileSync(path, 'utf8'))
18
+ } catch {
19
+ return null
20
+ }
21
+ }
22
+
23
+ /** Package names the root manifest of the detected stack declares; none without one stack, or for a missing or broken manifest. */
24
+ const manifestPackagesOf = (root: string, lockFiles: readonly string[], manifests: readonly string[]): string[] => {
25
+ const detection = detectStack(lockFiles, manifests)
26
+ if (!isDecided(detection)) return []
27
+ const { rootMarker, manifestPackages } = STACKS[detection.stack]
28
+ return manifestPackages ? manifestPackages(readJson(join(root, rootMarker))) : []
29
+ }
30
+
31
+ /** The lock files, manifests, manifest packages and origin branches of the repository at `root`; reads only, never fetches. */
32
+ export const readRepositoryFacts = (root: string): RepositoryFacts => {
33
+ const present = (file: string) => existsSync(join(root, file))
34
+ const lockFiles = Object.values(STACKS)
35
+ .flatMap((stack) => stack.lockFiles)
36
+ .filter(present)
37
+ const manifests = [...new Set(Object.values(STACKS).map((stack) => stack.rootMarker))].filter(present)
38
+ return {
39
+ lockFiles,
40
+ manifests,
41
+ manifestPackages: manifestPackagesOf(root, lockFiles, manifests),
42
+ remoteBranches: remoteBranchesOf(root),
43
+ }
44
+ }