jackherd 1.12.7 → 1.13.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 (47) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +35 -17
  4. package/dist/config.d.ts +32 -11
  5. package/dist/config.js +38 -82
  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 +2 -0
  23. package/kit/workflow/skills/start/SKILL.md +4 -5
  24. package/package.json +1 -1
  25. package/schemas/jackherd.schema.json +14 -11
  26. package/src/check/command.ts +121 -35
  27. package/src/check/plan-structure-check.ts +34 -1
  28. package/src/check/plan.ts +127 -44
  29. package/src/check/report.ts +67 -2
  30. package/src/check/result.ts +13 -3
  31. package/src/check/roots.ts +38 -10
  32. package/src/check/schema.ts +8 -3
  33. package/src/check/settings.ts +18 -1
  34. package/src/check/skill-budget-defaults.ts +3 -2
  35. package/src/config/base-branch.ts +2 -0
  36. package/src/config/command.ts +32 -13
  37. package/src/config/detect.ts +242 -74
  38. package/src/config/location.ts +6 -0
  39. package/src/config/repository-facts.ts +32 -7
  40. package/src/config/repository-values.ts +162 -5
  41. package/src/config/schema.ts +34 -16
  42. package/src/lane-prompt/texts.ts +2 -2
  43. package/src/plan-format/bare-codes.ts +28 -0
  44. package/src/sync/command-context.ts +4 -2
  45. package/src/sync/herdr-plugin.ts +30 -6
  46. package/src/sync/init.ts +17 -16
  47. package/src/sync/render.ts +4 -6
@@ -1,4 +1,14 @@
1
- import type { JackherdConfig } from './schema'
1
+ import { CLI_NAME } from '../version/release'
2
+ import {
3
+ type DetectedValues,
4
+ detectedValues,
5
+ frozenInstallCommand,
6
+ type RepositoryFacts,
7
+ STACKS,
8
+ stackOfCommand,
9
+ } from './detect'
10
+ import { PROJECT_CONFIG_PATH, unsetValueText } from './location'
11
+ import { COMMAND_KEYS, type JackherdConfig } from './schema'
2
12
 
3
13
  /** The values each repository decides for itself, by the short name `jh config` and global skills use. */
4
14
  export const REPOSITORY_VALUE = {
@@ -9,6 +19,7 @@ export const REPOSITORY_VALUE = {
9
19
  install: 'install',
10
20
  dev: 'dev',
11
21
  lockFile: 'lockFile',
22
+ verifyInstall: 'verifyInstall',
12
23
  baseBranch: 'baseBranch',
13
24
  } as const
14
25
 
@@ -22,6 +33,7 @@ const PLACEHOLDER: Record<RepositoryValue, string> = {
22
33
  install: 'kit.command.install',
23
34
  dev: 'kit.command.dev',
24
35
  lockFile: 'kit.lanes.lockFile',
36
+ verifyInstall: 'kit.lanes.verifyInstall',
25
37
  baseBranch: 'kit.baseBranch',
26
38
  }
27
39
 
@@ -34,13 +46,158 @@ export const REPOSITORY_TOKENS: ReadonlyMap<string, string> = new Map(
34
46
  NAMES.map((name) => [PLACEHOLDER[name], `<${name}>`]),
35
47
  )
36
48
 
37
- export const repositoryValuesOf = (config: JackherdConfig): Record<RepositoryValue, string> => ({
38
- ...config.commands,
39
- lockFile: config.lanes.lockFile,
40
- baseBranch: config.baseBranch,
49
+ const UNSET_PATH: Record<RepositoryValue, string> = {
50
+ test: 'commands.test',
51
+ typecheck: 'commands.typecheck',
52
+ lint: 'commands.lint',
53
+ format: 'commands.format',
54
+ install: 'commands.install',
55
+ dev: 'commands.dev',
56
+ lockFile: 'lanes.lockFile',
57
+ verifyInstall: 'commands.install',
58
+ baseBranch: 'baseBranch',
59
+ }
60
+
61
+ /** What the config (after detection) sets for each value; undefined for one it leaves unset. */
62
+ const rawRepositoryValues = ({
63
+ commands,
64
+ lanes,
65
+ baseBranch,
66
+ }: JackherdConfig): Record<RepositoryValue, string | undefined> => ({
67
+ test: commands.test,
68
+ typecheck: commands.typecheck,
69
+ lint: commands.lint,
70
+ format: commands.format,
71
+ install: commands.install,
72
+ dev: commands.dev,
73
+ lockFile: lanes.lockFile,
74
+ verifyInstall: commands.install === undefined ? undefined : frozenInstallCommand(commands.install),
75
+ baseBranch,
41
76
  })
42
77
 
78
+ /** A value the config leaves unset reads as the text saying where to set it, as in the kit texts. */
79
+ export const repositoryValuesOf = (config: JackherdConfig): Record<RepositoryValue, string> => {
80
+ const raw = rawRepositoryValues(config)
81
+ return Object.fromEntries(NAMES.map((name) => [name, raw[name] ?? unsetValueText(UNSET_PATH[name])])) as Record<
82
+ RepositoryValue,
83
+ string
84
+ >
85
+ }
86
+
87
+ /** Whether the config (after detection) decides `name`; `repositoryValuesOf` words an unset one as where to set it. */
88
+ export const isRepositoryValueSet = (config: JackherdConfig, name: RepositoryValue): boolean =>
89
+ rawRepositoryValues(config)[name] !== undefined
90
+
91
+ /** Every kit placeholder a repository decides, as `[placeholder, value]` with `repositoryValuesOf`'s values. */
92
+ export const repositoryPlaceholderValues = (config: JackherdConfig): [string, string][] => {
93
+ const values = repositoryValuesOf(config)
94
+ return NAMES.map((name) => [PLACEHOLDER[name], values[name]])
95
+ }
96
+
43
97
  export const formatRepositoryValues = (values: Record<RepositoryValue, string>): string[] => {
44
98
  const width = Math.max(...NAMES.map((name) => name.length)) + 2
45
99
  return NAMES.map((name) => `${name.padEnd(width)}${values[name]}`)
46
100
  }
101
+
102
+ const FINDING_SECTION = { commands: 'commands', lanes: 'lanes', check: 'check' } as const
103
+
104
+ type FindingSection = (typeof FINDING_SECTION)[keyof typeof FINDING_SECTION]
105
+
106
+ type FindingSubject = {
107
+ section: FindingSection
108
+ key: string
109
+ value: string | undefined
110
+ /** What the repository's stack would set here; undefined where it has no tool for it. */
111
+ detected: string | undefined
112
+ }
113
+
114
+ const findingSubjects = ({ commands, lanes, check }: JackherdConfig, facts: RepositoryFacts): FindingSubject[] => {
115
+ const detected: DetectedValues = detectedValues(facts)
116
+ return [
117
+ ...COMMAND_KEYS.map((key) => ({
118
+ section: FINDING_SECTION.commands,
119
+ key,
120
+ value: commands[key],
121
+ detected: detected.commands[key],
122
+ })),
123
+ {
124
+ section: FINDING_SECTION.lanes,
125
+ key: 'lockFile',
126
+ value: lanes.lockFile,
127
+ detected: detected.lanes.lockFile,
128
+ },
129
+ {
130
+ section: FINDING_SECTION.check,
131
+ key: 'lintFilesCommand',
132
+ value: check?.lintFilesCommand,
133
+ detected: detected.check.lintFilesCommand,
134
+ },
135
+ ]
136
+ }
137
+
138
+ /** The line to copy into the config: what the stack detected in the repository would set for `subject`. */
139
+ const copyableLine = ({ section, key, detected }: FindingSubject): string => {
140
+ const scope = `under "${section}" in ${PROJECT_CONFIG_PATH}`
141
+ return detected === undefined
142
+ ? `Set ${JSON.stringify(key)} ${scope} yourself.`
143
+ : `Set ${JSON.stringify(key)}: ${JSON.stringify(detected)} ${scope}.`
144
+ }
145
+
146
+ /** A lock file of a known stack that the repository does not have; null for any other. */
147
+ const lockFileContradiction = (lockFile: string, facts: RepositoryFacts): string | null => {
148
+ const known = Object.values(STACKS).some((stack) => stack.lockFiles.includes(lockFile))
149
+ return known && !facts.lockFiles.includes(lockFile) ? `${lockFile} does not exist` : null
150
+ }
151
+
152
+ /** A command of a known stack whose lock files the repository lacks; null for another command and in a repository without any lock file, which has not installed yet. */
153
+ const commandContradiction = (command: string, facts: RepositoryFacts): string | null => {
154
+ const stack = stackOfCommand(command)
155
+ if (stack === undefined || facts.lockFiles.length === 0) return null
156
+ const { lockFiles } = STACKS[stack]
157
+ return lockFiles.some((file) => facts.lockFiles.includes(file))
158
+ ? null
159
+ : `\`${command}\` needs ${lockFiles.join(' or ')}, which is missing`
160
+ }
161
+
162
+ const contradiction = ({ section, value }: FindingSubject, facts: RepositoryFacts): string | null => {
163
+ if (value === undefined) return null
164
+ return section === FINDING_SECTION.lanes ? lockFileContradiction(value, facts) : commandContradiction(value, facts)
165
+ }
166
+
167
+ const findingOf = (subject: FindingSubject, facts: RepositoryFacts): string | null => {
168
+ const path = `${subject.section}.${subject.key}`
169
+ if (subject.value === undefined) return `${CLI_NAME}: ${path} is missing. ${copyableLine(subject)}`
170
+ const reason = contradiction(subject, facts)
171
+ return reason === null ? null : `${CLI_NAME}: ${path} contradicts the repository: ${reason}. ${copyableLine(subject)}`
172
+ }
173
+
174
+ /**
175
+ * One line for each command, `lanes.lockFile` and `check.lintFilesCommand` that is missing or that a package
176
+ * manager's lock file contradicts (a `bun` command beside only `composer.lock`; a known lock file that does not
177
+ * exist), each with the config line the repository's stack would set, or the note to set it by hand. A command of no
178
+ * known manager and a lock file of no known stack are the project's own and stay unchecked; so does every command
179
+ * in a repository without any lock file, which has not installed yet.
180
+ */
181
+ export const commandFindings = (config: JackherdConfig, facts: RepositoryFacts): string[] =>
182
+ findingSubjects(config, facts).flatMap((subject) => findingOf(subject, facts) ?? [])
183
+
184
+ /** The config key of the finding about `name`; `baseBranch` has none. */
185
+ const FINDING_KEY: Record<RepositoryValue, { section: FindingSection; key: string } | null> = {
186
+ test: { section: FINDING_SECTION.commands, key: 'test' },
187
+ typecheck: { section: FINDING_SECTION.commands, key: 'typecheck' },
188
+ lint: { section: FINDING_SECTION.commands, key: 'lint' },
189
+ format: { section: FINDING_SECTION.commands, key: 'format' },
190
+ install: { section: FINDING_SECTION.commands, key: 'install' },
191
+ dev: { section: FINDING_SECTION.commands, key: 'dev' },
192
+ lockFile: { section: FINDING_SECTION.lanes, key: 'lockFile' },
193
+ verifyInstall: { section: FINDING_SECTION.commands, key: 'install' },
194
+ baseBranch: null,
195
+ }
196
+
197
+ /** The findings of `commandFindings` that are about `name` alone. */
198
+ export const valueFindings = (config: JackherdConfig, facts: RepositoryFacts, name: RepositoryValue): string[] => {
199
+ const wanted = FINDING_KEY[name]
200
+ return findingSubjects(config, facts)
201
+ .filter(({ section, key }) => section === wanted?.section && key === wanted.key)
202
+ .flatMap((subject) => findingOf(subject, facts) ?? [])
203
+ }
@@ -6,7 +6,7 @@ import { CONTENT_FIELDS, SOURCE_FIELD } from '../watch/source-fields'
6
6
  import { isSourcePath } from '../watch/source-path'
7
7
  import { BUN_FAILED_TEST_PATTERN, CLEAN_REVIEW_TEXT } from '../watch/watch-defaults'
8
8
  import { MAX_AUTO_WINDOW_DAYS, windowDays } from './auto-window'
9
- import { BUN_COMMANDS, DEFAULT_BASE_BRANCH } from './detect'
9
+ import { DEFAULT_BASE_BRANCH } from './base-branch'
10
10
  import { DEFAULT_LINK_KINDS, isRegularExpression } from './link-kinds'
11
11
  import { hasNestedQuantifier } from './nested-quantifier'
12
12
  import { isRepositoryDirectory } from './repository-directory'
@@ -28,6 +28,15 @@ export const MODEL_ROLE = {
28
28
 
29
29
  export type ModelRole = (typeof MODEL_ROLE)[keyof typeof MODEL_ROLE]
30
30
 
31
+ /** The model of each role when the config names none: a new project's, and one written by hand without `models`. */
32
+ export const DEFAULT_MODELS: Record<ModelRole, string> = {
33
+ [MODEL_ROLE.plan]: 'claude-opus-5-5',
34
+ [MODEL_ROLE.implement]: 'claude-sonnet-5-5',
35
+ [MODEL_ROLE.review]: 'claude-sonnet-5-5',
36
+ [MODEL_ROLE.explore]: 'claude-sonnet-5-5',
37
+ [MODEL_ROLE.orchestrate]: 'claude-opus-5-5',
38
+ }
39
+
31
40
  export const isTerminalAdapter = (value: string): value is TerminalAdapter =>
32
41
  (Object.values(TERMINAL_ADAPTER) as string[]).includes(value)
33
42
 
@@ -49,6 +58,26 @@ const command = z.string().min(1)
49
58
 
50
59
  const tokenCount = z.number().int().positive()
51
60
 
61
+ /**
62
+ * The project's own commands. Each is optional: detection fills what the repository's stack decides, and a command
63
+ * nobody set stays out instead of a guessed one (a kit text then says where to set it).
64
+ */
65
+ const commandsSchema = z.strictObject({
66
+ test: command.optional(),
67
+ typecheck: command.optional(),
68
+ lint: command.optional(),
69
+ format: command.optional(),
70
+ /** Installs a new lane's dependencies. */
71
+ install: command.optional(),
72
+ /** Starts a lane's dev server, run as `PORT=<port> <dev>`; a project supplement may name another. */
73
+ dev: command.optional(),
74
+ })
75
+
76
+ export type CommandKey = keyof z.infer<typeof commandsSchema>
77
+
78
+ /** Every key of the `commands` block, as the schema names them. */
79
+ export const COMMAND_KEYS = Object.keys(commandsSchema.shape) as CommandKey[]
80
+
52
81
  /** The window `plans.context.defaultWindow` assumes when the config leaves it out. */
53
82
  export const DEFAULT_CONTEXT_WINDOW = 200_000
54
83
 
@@ -450,28 +479,17 @@ export const jackherdConfigSchema = z.strictObject({
450
479
  skillPrefix: z
451
480
  .union([z.literal(''), z.string().regex(SKILL_PREFIX, 'lower-case letters and digits ending in a dash, e.g. kit-')])
452
481
  .default(''),
453
- commands: z
454
- .strictObject({
455
- test: command.default(BUN_COMMANDS.test),
456
- typecheck: command.default(BUN_COMMANDS.typecheck),
457
- lint: command.default(BUN_COMMANDS.lint),
458
- format: command.default(BUN_COMMANDS.format),
459
- /** Installs a new lane's dependencies. */
460
- install: command.default(BUN_COMMANDS.install),
461
- /** Starts a lane's dev server, run as `PORT=<port> <dev>`; a project supplement may name another. */
462
- dev: command.default(BUN_COMMANDS.dev),
463
- })
464
- .prefault({}),
482
+ commands: commandsSchema.prefault({}),
465
483
  /** How `/orchestrate` sets up a lane's worktree. */
466
484
  lanes: z
467
485
  .strictObject({
468
486
  /** Untracked files copied into every new worktree; empty means the files `.worktreeinclude` lists. */
469
487
  copy: z.array(laneCopyPath).default([]),
470
- /** The dependency lock file a rebase takes from the base branch and regenerates, never merges by hand. */
471
- lockFile: lockFilePath.default('bun.lock'),
488
+ /** The dependency lock file a rebase takes from the base branch and regenerates, never merges by hand; detected from the repository when left out. */
489
+ lockFile: lockFilePath.optional(),
472
490
  })
473
491
  .prefault({}),
474
- models: z.record(z.enum(Object.values(MODEL_ROLE)), modelId),
492
+ models: z.record(z.enum(Object.values(MODEL_ROLE)), modelId).default(DEFAULT_MODELS),
475
493
  terminal: z
476
494
  .strictObject({ adapter: z.enum(Object.values(TERMINAL_ADAPTER)).default(TERMINAL_ADAPTER.none) })
477
495
  .prefault({}),
@@ -50,7 +50,7 @@ export const LANE_PROMPT_TEXTS: Record<PlanLanguage, LanePromptTexts> = {
50
50
  report: (reportPath) =>
51
51
  `The report at the end of each step goes to \`${reportPath}\` in the worktree, in the short form: one line of diff stat, one line per check with its numbers, open points, questions as a text block under the heading \`## Questions\` (whatever the report’s language: the watcher finds the section by that word and shows it whole); everything else is in the plan, by reference.`,
52
52
  textBlock:
53
- 'Every question to the coordinator is a text block at the end of your turn, never a dialog (`AskUserQuestion`), also where a skill or a project rule asks for one, the commit confirmation of `/pre-commit` included: the question with the situation, the problem and what each option leads to, in everyday words (the user may not know your terms), the options, your recommendation, the evidence at `file:line`.',
53
+ 'Every question to the coordinator is a text block at the end of your turn, never a dialog (`AskUserQuestion`), also where a skill or a project rule asks for one, the commit confirmation of `/pre-commit` included: the question with the situation, the problem and what each option leads to, in everyday words (the user may not know your terms; a plan code like `P7` appears only in brackets after the words), the options, your recommendation, the evidence at `file:line`.',
54
54
  secret:
55
55
  'A value only the user knows (a secret, password, token or one-time code) never passes through you, your arguments, your output or a file you write: ask the coordinator for the step as a question.',
56
56
  askRule:
@@ -84,7 +84,7 @@ export const LANE_PROMPT_TEXTS: Record<PlanLanguage, LanePromptTexts> = {
84
84
  report: (reportPath) =>
85
85
  `Den Bericht am Ende jedes Schritts schreibst du nach \`${reportPath}\` im Worktree, in der Kurzform: eine Zeile Diff-Stat, eine Zeile je Check mit seinen Zahlen, offene Punkte, Fragen als Textblock unter der Überschrift \`## Questions\` (in jeder Berichtssprache: der Watcher findet den Abschnitt an diesem Wort und zeigt ihn ganz); alles andere steht im Plan, per Verweis.`,
86
86
  textBlock:
87
- 'Jede Frage an den Koordinator ist ein Textblock am Ende deines Turns, nie ein Dialog (`AskUserQuestion`), auch wo ein Skill oder eine Projektregel einen verlangt, die Commit-Bestätigung von `/pre-commit` eingeschlossen: die Frage mit der Lage, dem Problem und wohin jede Option führt, in Alltagsworten (der User kennt deine Begriffe vielleicht nicht), die Optionen, deine Empfehlung, der Beleg als `datei:zeile`.',
87
+ 'Jede Frage an den Koordinator ist ein Textblock am Ende deines Turns, nie ein Dialog (`AskUserQuestion`), auch wo ein Skill oder eine Projektregel einen verlangt, die Commit-Bestätigung von `/pre-commit` eingeschlossen: die Frage mit der Lage, dem Problem und wohin jede Option führt, in Alltagsworten (der User kennt deine Begriffe vielleicht nicht; ein Plan-Kürzel wie `P7` nur in Klammern hinter den Worten), die Optionen, deine Empfehlung, der Beleg als `datei:zeile`.',
88
88
  secret:
89
89
  'Ein Wert, den nur der User kennt (ein Geheimnis, Passwort, Token oder Einmalcode), läuft nie über dich, deine Argumente, deine Ausgabe oder eine Datei, die du schreibst: bitte den Koordinator mit einer Frage um diesen Schritt.',
90
90
  askRule:
@@ -0,0 +1,28 @@
1
+ import { bareCodesOf } from '../../kit/hooks/hooks/shared/plan-codes'
2
+ import { type PlanLanguage, WAITING_VOCABULARY } from '../vocabulary'
3
+ import { firstTableRows, firstTableWithHeader, sectionLines, sectionStart } from './markdown'
4
+
5
+ /** A changed row of the waiting section (1-based line) whose item cell names `code` bare, outside brackets. */
6
+ export type RowWithBareCode = { line: number; code: string }
7
+
8
+ /**
9
+ * The changed rows under the board's waiting section whose item cell names a plan code bare (the first one per row):
10
+ * the user reads the words, the code stands at most in brackets after them. Rows the previous version had word for
11
+ * word stay. `codes` are the codes the board's plans define, or null to use the fallback pattern.
12
+ */
13
+ export const boardRowsWithBareCodes = (
14
+ markdown: string,
15
+ language: PlanLanguage,
16
+ previousLines: ReadonlySet<string>,
17
+ codes: ReadonlySet<string> | null,
18
+ ): RowWithBareCode[] => {
19
+ const { heading, columns } = WAITING_VOCABULARY[language]
20
+ const start = sectionStart(markdown, heading)
21
+ const lines = sectionLines(markdown, heading)
22
+ const item = firstTableWithHeader(lines).header.indexOf(columns.item)
23
+ if (item === -1) return []
24
+ return firstTableRows(lines).flatMap(({ index, line, cells }) => {
25
+ const code = previousLines.has(line) ? undefined : bareCodesOf(cells[item] ?? '', codes)[0]
26
+ return code === undefined ? [] : [{ line: start + index + 1, code }]
27
+ })
28
+ }
@@ -20,7 +20,7 @@ export type CommandContext = {
20
20
  locate: () => Promise<KitLocation>
21
21
  log: (line: string) => void
22
22
  /** Hooks the herdr plugin in and returns the line to print, if any. Must not throw: a failure is a line, not an error. */
23
- linkHerdrPlugin: (packageRoot: string) => string | null
23
+ linkHerdrPlugin: (roots: { packageRoot: string; projectRoot: string }) => string | null
24
24
  /** A yes/no question to the user; null when there is no terminal to ask in. */
25
25
  ask: (question: string) => Promise<boolean | null>
26
26
  /** A choice among fixed answers; Enter takes `fallback`; null when there is no terminal to ask in. */
@@ -99,7 +99,9 @@ export const runWithKit = async (
99
99
  try {
100
100
  const location = await context.locate()
101
101
  const { notes, result } = await action(await readKitSource(location))
102
- const herdrNote = dryRun ? null : context.linkHerdrPlugin(location.packageRoot)
102
+ const herdrNote = dryRun
103
+ ? null
104
+ : context.linkHerdrPlugin({ packageRoot: location.packageRoot, projectRoot: context.projectRoot })
103
105
  for (const note of herdrNote ? [...notes, herdrNote] : notes) context.log(note)
104
106
  context.log(formatReport(result))
105
107
  return dryRun ? driftExitCodeOf(result) : exitCodeOf(result)
@@ -1,6 +1,6 @@
1
1
  import { realpathSync } from 'node:fs'
2
2
  import { join, resolve } from 'node:path'
3
- import { isLinkedWorktree } from '../plans/git'
3
+ import { isLinkedWorktree, mainCheckout } from '../plans/git'
4
4
  import { herdrBinary, spawnTool, type ToolRun } from './tools'
5
5
 
6
6
  export const HERDR_PLUGIN_ID = 'crossdelta.jackherd'
@@ -115,7 +115,8 @@ const joinLines = (...lines: (string | null)[]): string | null => {
115
115
  * Hooks the kit's herdr plugin into herdr when `init` or `sync` runs inside herdr (`HERDR_ENV=1`), and links it
116
116
  * again when herdr's link points at another directory or herdr cannot read its manifest (`herdr plugin link`
117
117
  * replaces a plugin of the same id). What each copy may link:
118
- * - a checkout takes over any link, so a repository that develops the kit keeps its link;
118
+ * - a checkout takes over any link while it runs for its own repository (a worktree of it included), so a repository
119
+ * that develops the kit keeps its link; run for another repository it acts like an installed package;
119
120
  * - an installed package only replaces a missing or unreadable link and leaves a working one standing, also one to
120
121
  * another installed package: otherwise the global package and a project's dependency would take it from each other;
121
122
  * - a lane's worktree never links, since its path goes away with the lane, and only names a broken link.
@@ -160,16 +161,39 @@ const realPathOf = (path: string): string => {
160
161
  }
161
162
  }
162
163
 
164
+ /** The repository a path belongs to (its main checkout, also from a linked worktree); null outside git. */
165
+ const repositoryOf = (path: string): string | null => {
166
+ try {
167
+ return mainCheckout(path)
168
+ } catch {
169
+ return null
170
+ }
171
+ }
172
+
173
+ /**
174
+ * A checkout takes over only at home: when the project lies in the checkout's own repository. For another repository
175
+ * (or a project outside git) it acts like an installed package, so `init` there does not move the link.
176
+ */
177
+ export const checkoutCopyFor = (projectRepository: string | null, packageRepository: string | null): PackageCopy =>
178
+ packageRepository === null || projectRepository === packageRepository ? PACKAGE_COPY.checkout : PACKAGE_COPY.installed
179
+
163
180
  /** A lane's worktree first, so a package installed into one never links either; a workspace link resolves to its checkout. */
164
- const packageCopyOf = (packageRoot: string): PackageCopy => {
181
+ const packageCopyOf = (packageRoot: string, projectRoot: string): PackageCopy => {
165
182
  if (isLinkedWorktree(packageRoot)) return PACKAGE_COPY.laneWorktree
166
- return isInstalledCopy(realPathOf(packageRoot)) ? PACKAGE_COPY.installed : PACKAGE_COPY.checkout
183
+ if (isInstalledCopy(realPathOf(packageRoot))) return PACKAGE_COPY.installed
184
+ return checkoutCopyFor(repositoryOf(projectRoot), repositoryOf(packageRoot))
167
185
  }
168
186
 
169
- export const linkHerdrPluginFrom = (packageRoot: string): string | null =>
187
+ export const linkHerdrPluginFrom = ({
188
+ packageRoot,
189
+ projectRoot,
190
+ }: {
191
+ packageRoot: string
192
+ projectRoot: string
193
+ }): string | null =>
170
194
  linkHerdrPlugin({
171
195
  packageRoot,
172
196
  env: process.env,
173
197
  run: spawnHerdr(process.env),
174
- copy: packageCopyOf(packageRoot),
198
+ copy: packageCopyOf(packageRoot, projectRoot),
175
199
  })
package/src/sync/init.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { homedir } from 'node:os'
2
2
  import { join } from 'node:path'
3
3
  import { AGENTS_DIRECTORY, SKILLS_DIRECTORY } from '../claude/paths'
4
- import { type JackherdConfig, MODEL_ROLE, type ModelRole, parseJackherdConfig, type TerminalAdapter } from '../config'
5
- import { type RepositoryFacts, withDetected } from '../config/detect'
4
+ import { type JackherdConfig, parseJackherdConfig, type TerminalAdapter } from '../config'
5
+ import { type RepositoryFacts, STACKS, withDetected } from '../config/detect'
6
6
  import { CONFIG_FILE, CONFIG_SCHEMA_REFERENCE, TOOL_DIRECTORY } from '../config/location'
7
7
  import { readRepositoryFacts } from '../config/repository-facts'
8
8
  import type { KitSource } from './kit'
@@ -35,16 +35,6 @@ export type InitResult = {
35
35
  terminalAdapter: TerminalAdapter
36
36
  }
37
37
 
38
- // TODO: default model per role for a fresh project; the role list
39
- // itself is still a draft in config/schema.ts.
40
- export const DEFAULT_MODELS: Record<ModelRole, string> = {
41
- [MODEL_ROLE.plan]: 'claude-opus-5-5',
42
- [MODEL_ROLE.implement]: 'claude-sonnet-5-5',
43
- [MODEL_ROLE.review]: 'claude-sonnet-5-5',
44
- [MODEL_ROLE.explore]: 'claude-sonnet-5-5',
45
- [MODEL_ROLE.orchestrate]: 'claude-opus-5-5',
46
- }
47
-
48
38
  /** What a global install, and a project that already has skills of kit names, puts before every kit name. */
49
39
  export const DEFAULT_SKILL_PREFIX = 'jh-'
50
40
 
@@ -64,13 +54,24 @@ export const collidingNames = (kit: KitSource, exists: (path: string) => boolean
64
54
  return agent ? [`${agent[1]} agent`] : []
65
55
  })
66
56
 
57
+ /** Whether the repository holds the lock file of a stack that installs into `node_modules`, where `$schema` points. */
58
+ const hasNodeLockFile = (facts: RepositoryFacts): boolean =>
59
+ Object.values(STACKS).some(
60
+ ({ installsIntoNodeModules, lockFiles }) =>
61
+ installsIntoNodeModules && lockFiles.some((file) => facts.lockFiles.includes(file)),
62
+ )
63
+
64
+ /** The editor's `$schema` pointer, only where its target can exist; elsewhere it would point at nothing. */
65
+ const schemaReference = (facts: RepositoryFacts): { $schema?: string } =>
66
+ hasNodeLockFile(facts) ? { $schema: CONFIG_SCHEMA_REFERENCE } : {}
67
+
67
68
  /**
68
69
  * The default config with every default spelled out, so the project sees what it can change; commands, lock file
69
- * and base branch as detected in the project.
70
+ * and base branch as detected in the project. A command detection could not decide stays out, never guessed.
70
71
  */
71
72
  const defaultConfigText = (facts: RepositoryFacts, skillPrefix: string, setup: SetupConfig | undefined): string => {
72
- const config = parseJackherdConfig(withDetected({ models: DEFAULT_MODELS, skillPrefix, ...setup }, facts).raw)
73
- return `${JSON.stringify({ $schema: CONFIG_SCHEMA_REFERENCE, ...config }, null, 2)}\n`
73
+ const config = parseJackherdConfig(withDetected({ skillPrefix, ...setup }, facts).raw)
74
+ return `${JSON.stringify({ ...schemaReference(facts), ...config }, null, 2)}\n`
74
75
  }
75
76
 
76
77
  /** What each repository decides for itself; a global config that spelled them out would set them for all. */
@@ -84,7 +85,7 @@ const withoutRepositoryValues = ({ baseBranch: _base, commands: _commands, lanes
84
85
  * lock file or base branch, so every repository gets its own detected ones.
85
86
  */
86
87
  const defaultGlobalConfigText = (setup: SetupConfig | undefined): string => {
87
- const config = parseJackherdConfig({ models: DEFAULT_MODELS, skillPrefix: DEFAULT_SKILL_PREFIX, ...setup })
88
+ const config = parseJackherdConfig({ skillPrefix: DEFAULT_SKILL_PREFIX, ...setup })
88
89
  return `${JSON.stringify(withoutRepositoryValues(config), null, 2)}\n`
89
90
  }
90
91
 
@@ -1,5 +1,5 @@
1
1
  import type { JackherdConfig } from '../config'
2
- import { frozenInstallCommand } from '../config/detect'
2
+ import { repositoryPlaceholderValues } from '../config/repository-values'
3
3
  import {
4
4
  ADDRESS_FORM,
5
5
  APPROVAL_TEXTS,
@@ -35,13 +35,10 @@ const placeholderValues = (config: JackherdConfig): Map<string, string> => {
35
35
  ['kit.responseAddress', ADDRESS_FORM[config.responseLanguage][config.responseAddress]],
36
36
  ['kit.planLanguage', LANGUAGE_NAME[config.planLanguage]],
37
37
  ['kit.planDirectory', config.planDirectory],
38
- ['kit.baseBranch', config.baseBranch],
39
38
  ['kit.worktreeDirectory', config.worktreeDirectory],
40
39
  ...prefixed('model', config.models),
41
- ...prefixed('command', config.commands),
40
+ ...repositoryPlaceholderValues(config),
42
41
  ['kit.lanes.copyCommand', laneCopyCommand(config.lanes.copy)],
43
- ['kit.lanes.lockFile', config.lanes.lockFile],
44
- ['kit.lanes.verifyInstall', frozenInstallCommand(config.commands.install)],
45
42
  ['kit.word.laneClosed', laneClosed],
46
43
  ['kit.word.openSubagents', openSubagents],
47
44
  ...prefixed('board', config.board),
@@ -62,7 +59,8 @@ export const listPlaceholders = (template: string): string[] =>
62
59
  * Fills the kit placeholders from the project config: `{{kit.model.<role>}}`,
63
60
  * `{{kit.command.<test|typecheck|lint|format|install|dev>}}`, `{{kit.lanes.<copyCommand|lockFile|verifyInstall>}}`, `{{kit.word.<laneClosed|openSubagents>}}`,
64
61
  * `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.guard.standingApproval.<approve|until>}}`, `{{kit.merge.withoutChecks}}`, `{{kit.heading.<key>}}` from the plan
65
- * vocabulary, the two languages, `{{kit.responseAddress}}`, the plan and worktree directory and the base branch. Other
62
+ * vocabulary, the two languages, `{{kit.responseAddress}}`, the plan and worktree directory and the base branch. A
63
+ * command or lock file the config leaves unset renders as `unsetValueText`, never as a guessed default. Other
66
64
  * `{{…}}` text is left alone; an unknown `kit.` placeholder throws rather than reaching a project
67
65
  * file unrendered. `overrides` win over the config: a global kit renders what each repository decides as
68
66
  * `<name>` (`REPOSITORY_TOKENS`).