jackherd 0.0.0-stage → 1.0.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 (250) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +38 -0
  4. package/README.md +1123 -2
  5. package/assets/branding/README.md +62 -0
  6. package/assets/branding/jackherd-icon-256.png +0 -0
  7. package/assets/branding/jackherd-icon-512.png +0 -0
  8. package/assets/branding/jackherd-icon-64.png +0 -0
  9. package/assets/branding/jackherd-icon-small-32.png +0 -0
  10. package/assets/branding/jackherd-icon-small-64.png +0 -0
  11. package/assets/branding/jackherd-icon-small.svg +1 -0
  12. package/assets/branding/jackherd-icon.svg +1 -0
  13. package/assets/branding/jackherd-logo-1024.png +0 -0
  14. package/assets/branding/jackherd-logo-512.png +0 -0
  15. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  16. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  17. package/assets/branding/jackherd-logo-dark.svg +1 -0
  18. package/assets/branding/jackherd-logo-mark-1024.png +0 -0
  19. package/assets/branding/jackherd-logo-mark-512.png +0 -0
  20. package/assets/branding/jackherd-logo-mark.svg +1 -0
  21. package/assets/branding/jackherd-logo.svg +1 -0
  22. package/dist/chunk-R6MGHCD7.js +93 -0
  23. package/dist/config.d.ts +167 -0
  24. package/dist/config.js +401 -0
  25. package/dist/plan-format.d.ts +325 -0
  26. package/dist/plan-format.js +636 -0
  27. package/docs/lanes.mmd +9 -0
  28. package/docs/lanes.svg +1 -0
  29. package/docs/skills.mmd +10 -0
  30. package/docs/skills.svg +1 -0
  31. package/docs/workflow.svg +172 -0
  32. package/herdr-plugin/herdr-plugin.toml +22 -0
  33. package/herdr-plugin/open-plans.ts +43 -0
  34. package/herdr-plugin/restore-plans.ts +107 -0
  35. package/install.sh +157 -0
  36. package/kit/hooks/hooks/git-guard/decision.ts +438 -0
  37. package/kit/hooks/hooks/git-guard/index.ts +38 -0
  38. package/kit/hooks/hooks/git-guard/shell.ts +407 -0
  39. package/kit/hooks/hooks/watch-guard/background.ts +36 -0
  40. package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
  41. package/kit/hooks/hooks/watch-guard/index.ts +11 -0
  42. package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
  43. package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
  44. package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
  45. package/kit/hooks/hooks.json +30 -0
  46. package/kit/hooks/statusline.sh +72 -0
  47. package/kit/review/agents/devops.md +11 -0
  48. package/kit/review/agents/public-content.md +39 -0
  49. package/kit/review/agents/security-audit.md +11 -0
  50. package/kit/review/agents/uncle-bob.md +11 -0
  51. package/kit/review/skills/devops/SKILL.md +50 -0
  52. package/kit/review/skills/diagnose/SKILL.md +140 -0
  53. package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
  54. package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
  55. package/kit/review/skills/pre-commit/SKILL.md +100 -0
  56. package/kit/review/skills/review/SKILL.md +43 -0
  57. package/kit/review/skills/security-audit/SKILL.md +65 -0
  58. package/kit/review/skills/uncle-bob/SKILL.md +43 -0
  59. package/kit/rules/rules/kit/arc42.md +89 -0
  60. package/kit/rules/rules/kit/architecture-files.md +87 -0
  61. package/kit/rules/rules/kit/code-style.md +159 -0
  62. package/kit/rules/rules/kit/pull-requests.md +59 -0
  63. package/kit/rules/rules/kit/testing.md +91 -0
  64. package/kit/rules/rules/kit/writing-for-agents.md +110 -0
  65. package/kit/workflow/agents/explore.md +25 -0
  66. package/kit/workflow/agents/implement.md +16 -0
  67. package/kit/workflow/agents/plan.md +13 -0
  68. package/kit/workflow/agents/spec-review.md +96 -0
  69. package/kit/workflow/skills/follow/SKILL.md +48 -0
  70. package/kit/workflow/skills/grill/SKILL.md +60 -0
  71. package/kit/workflow/skills/implement/SKILL.md +107 -0
  72. package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
  73. package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
  74. package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
  75. package/kit/workflow/skills/plan/SKILL.md +118 -0
  76. package/kit/workflow/skills/plan/references/template-English.md +92 -0
  77. package/kit/workflow/skills/plan/references/template-German.md +92 -0
  78. package/kit/workflow/skills/start/SKILL.md +47 -0
  79. package/package.json +65 -4
  80. package/schemas/jackherd.schema.json +601 -0
  81. package/src/check/changes.ts +45 -0
  82. package/src/check/command-line.ts +12 -0
  83. package/src/check/command.ts +136 -0
  84. package/src/check/deferral-arrows.ts +41 -0
  85. package/src/check/execute.ts +29 -0
  86. package/src/check/glob.ts +15 -0
  87. package/src/check/plan-structure-check.ts +135 -0
  88. package/src/check/plan.ts +106 -0
  89. package/src/check/public-content-check.ts +32 -0
  90. package/src/check/public-content-facts.ts +177 -0
  91. package/src/check/public-content.ts +216 -0
  92. package/src/check/report.ts +55 -0
  93. package/src/check/result.ts +42 -0
  94. package/src/check/roots.ts +36 -0
  95. package/src/check/routing.ts +22 -0
  96. package/src/check/schema.ts +83 -0
  97. package/src/check/settings.ts +44 -0
  98. package/src/claude/paths.ts +14 -0
  99. package/src/cli.ts +46 -0
  100. package/src/config/command.ts +45 -0
  101. package/src/config/detect.ts +196 -0
  102. package/src/config/index.ts +2 -0
  103. package/src/config/json-schema.ts +5 -0
  104. package/src/config/legacy-names.ts +56 -0
  105. package/src/config/link-kinds.ts +25 -0
  106. package/src/config/location.ts +46 -0
  107. package/src/config/read-config.ts +42 -0
  108. package/src/config/repository-facts.ts +19 -0
  109. package/src/config/repository-values.ts +46 -0
  110. package/src/config/schema.ts +302 -0
  111. package/src/context/command.ts +39 -0
  112. package/src/context/coordinator-name.ts +16 -0
  113. package/src/context/fill.ts +69 -0
  114. package/src/context/index.ts +10 -0
  115. package/src/context/level.ts +22 -0
  116. package/src/context/options.ts +29 -0
  117. package/src/context/report.ts +42 -0
  118. package/src/context/session-log.ts +91 -0
  119. package/src/plan-format/drift.ts +69 -0
  120. package/src/plan-format/header.ts +27 -0
  121. package/src/plan-format/index.ts +9 -0
  122. package/src/plan-format/lanes.ts +128 -0
  123. package/src/plan-format/markdown.ts +98 -0
  124. package/src/plan-format/phase-tally.ts +55 -0
  125. package/src/plan-format/phases.ts +224 -0
  126. package/src/plan-format/plan-structure.ts +150 -0
  127. package/src/plan-format/progress.ts +202 -0
  128. package/src/plan-format/sprint-document.ts +69 -0
  129. package/src/plan-format/terminal-text.ts +28 -0
  130. package/src/plans/board-choice.ts +60 -0
  131. package/src/plans/board-pick.ts +72 -0
  132. package/src/plans/board-rows.ts +62 -0
  133. package/src/plans/command.ts +60 -0
  134. package/src/plans/ensure-panes.ts +71 -0
  135. package/src/plans/error-text.ts +5 -0
  136. package/src/plans/fetch.ts +96 -0
  137. package/src/plans/git.ts +145 -0
  138. package/src/plans/github.ts +35 -0
  139. package/src/plans/lane-discovery.ts +34 -0
  140. package/src/plans/lane-plans.ts +80 -0
  141. package/src/plans/layout.ts +881 -0
  142. package/src/plans/link-chips.ts +74 -0
  143. package/src/plans/live.ts +336 -0
  144. package/src/plans/markdown-html.ts +140 -0
  145. package/src/plans/options.ts +15 -0
  146. package/src/plans/phase-source.ts +71 -0
  147. package/src/plans/plan-chips.ts +75 -0
  148. package/src/plans/plan-panes.ts +206 -0
  149. package/src/plans/planned.ts +117 -0
  150. package/src/plans/pull-requests.ts +107 -0
  151. package/src/plans/restart-decision.ts +42 -0
  152. package/src/plans/selection.ts +352 -0
  153. package/src/plans/settings.ts +68 -0
  154. package/src/plans/single-flight.ts +25 -0
  155. package/src/plans/snapshot.ts +300 -0
  156. package/src/plans/sprint-export.ts +103 -0
  157. package/src/plans/sprint-html.ts +301 -0
  158. package/src/plans/sprint-page.ts +287 -0
  159. package/src/plans/supervisor.ts +124 -0
  160. package/src/plans/symbols.ts +54 -0
  161. package/src/plans/texts.ts +103 -0
  162. package/src/plans/unlisted-lanes.ts +45 -0
  163. package/src/plans/view.ts +68 -0
  164. package/src/plans/worked-phases.ts +53 -0
  165. package/src/sync/checksum.ts +13 -0
  166. package/src/sync/choice.ts +14 -0
  167. package/src/sync/command-context.ts +113 -0
  168. package/src/sync/command.ts +94 -0
  169. package/src/sync/error-message.ts +1 -0
  170. package/src/sync/gitignore.ts +45 -0
  171. package/src/sync/global-kit.ts +176 -0
  172. package/src/sync/herdr-plugin.ts +175 -0
  173. package/src/sync/if-changed.ts +109 -0
  174. package/src/sync/init-command.ts +239 -0
  175. package/src/sync/init.ts +136 -0
  176. package/src/sync/kit-hooks.ts +18 -0
  177. package/src/sync/kit-path.ts +77 -0
  178. package/src/sync/kit-source.ts +169 -0
  179. package/src/sync/kit.ts +9 -0
  180. package/src/sync/legacy-files.ts +116 -0
  181. package/src/sync/manifest.ts +44 -0
  182. package/src/sync/next-steps.ts +42 -0
  183. package/src/sync/plan-file.ts +154 -0
  184. package/src/sync/plan-hooks.ts +165 -0
  185. package/src/sync/project-fs.ts +132 -0
  186. package/src/sync/render.ts +60 -0
  187. package/src/sync/report.ts +96 -0
  188. package/src/sync/settings-hooks.ts +90 -0
  189. package/src/sync/settings-migration.ts +94 -0
  190. package/src/sync/settings-offer.ts +168 -0
  191. package/src/sync/setup-questions.ts +70 -0
  192. package/src/sync/setup.ts +52 -0
  193. package/src/sync/sync-plan.ts +120 -0
  194. package/src/sync/sync-result.ts +9 -0
  195. package/src/sync/sync.ts +307 -0
  196. package/src/sync/tools.ts +52 -0
  197. package/src/terminal/herdr.ts +91 -0
  198. package/src/terminal/index.ts +13 -0
  199. package/src/terminal/none.ts +7 -0
  200. package/src/terminal/open.ts +39 -0
  201. package/src/terminal/text.ts +159 -0
  202. package/src/terminal/types.ts +30 -0
  203. package/src/terminal/width.ts +90 -0
  204. package/src/update/command.ts +65 -0
  205. package/src/update/package-manager.ts +56 -0
  206. package/src/update/update.ts +105 -0
  207. package/src/version/installed-version.ts +32 -0
  208. package/src/version/release.ts +22 -0
  209. package/src/version/skill-diff.ts +63 -0
  210. package/src/version/started-kit.ts +14 -0
  211. package/src/vocabulary/index.ts +132 -0
  212. package/src/watch/base-tip.ts +18 -0
  213. package/src/watch/command.ts +919 -0
  214. package/src/watch/coordinator-fill.ts +76 -0
  215. package/src/watch/feedback-read.ts +73 -0
  216. package/src/watch/feedback.ts +308 -0
  217. package/src/watch/gh.ts +22 -0
  218. package/src/watch/github-read.ts +175 -0
  219. package/src/watch/herdr.ts +11 -0
  220. package/src/watch/json-guards.ts +9 -0
  221. package/src/watch/kit-update.ts +18 -0
  222. package/src/watch/lane-fill.ts +36 -0
  223. package/src/watch/lane-message.ts +99 -0
  224. package/src/watch/lane-progress.ts +44 -0
  225. package/src/watch/lane-report-file.ts +53 -0
  226. package/src/watch/lane-report.ts +270 -0
  227. package/src/watch/lane-search.ts +41 -0
  228. package/src/watch/lane-sources.ts +46 -0
  229. package/src/watch/lane-watch.ts +121 -0
  230. package/src/watch/monitor-tool.ts +19 -0
  231. package/src/watch/options.ts +79 -0
  232. package/src/watch/plan-badges-read.ts +30 -0
  233. package/src/watch/plan-badges.ts +66 -0
  234. package/src/watch/plan-views.ts +59 -0
  235. package/src/watch/preview-read.ts +60 -0
  236. package/src/watch/previews.ts +234 -0
  237. package/src/watch/pull-requests.ts +117 -0
  238. package/src/watch/single-report.ts +15 -0
  239. package/src/watch/skill-change.ts +61 -0
  240. package/src/watch/source-dry-run-command.ts +35 -0
  241. package/src/watch/source-dry-run.ts +124 -0
  242. package/src/watch/source-fields.ts +9 -0
  243. package/src/watch/source-path.ts +40 -0
  244. package/src/watch/source-run.ts +90 -0
  245. package/src/watch/sources.ts +344 -0
  246. package/src/watch/stream-output.ts +14 -0
  247. package/src/watch/texts.ts +76 -0
  248. package/src/watch/watch-events.ts +399 -0
  249. package/src/watch/watcher-lock.ts +145 -0
  250. package/src/watch/workflow-runs.ts +96 -0
@@ -0,0 +1,196 @@
1
+ import { CLI_NAME } from '../version/release'
2
+ import { PROJECT_CONFIG_PATH } from './location'
3
+
4
+ export const PACKAGE_MANAGER = { bun: 'bun', yarn: 'yarn', pnpm: 'pnpm', npm: 'npm' } as const
5
+
6
+ export type PackageManager = (typeof PACKAGE_MANAGER)[keyof typeof PACKAGE_MANAGER]
7
+
8
+ /** What the kit reads from a repository to fill the values its config leaves out. */
9
+ export type RepositoryFacts = {
10
+ /** The lock files of `LOCK_FILES` present in the repository root. */
11
+ lockFiles: string[]
12
+ /** Branch names on `origin`, without the remote prefix. */
13
+ remoteBranches: string[]
14
+ }
15
+
16
+ export type PackageManagerDetection =
17
+ | { kind: 'found'; manager: PackageManager; lockFile: string }
18
+ | { kind: 'none' }
19
+ | { kind: 'ambiguous'; lockFiles: string[] }
20
+
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
+ }
30
+
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
+ }
39
+
40
+ /** The base branches detection chooses between; any other base is set in the config. */
41
+ const BASE_BRANCH_CANDIDATES = ['main', 'master'] as const
42
+
43
+ /** The base branch when the repository does not decide; the schema's default too. */
44
+ export const DEFAULT_BASE_BRANCH = 'main'
45
+
46
+ const COMMAND_KEYS = ['test', 'typecheck', 'lint', 'format', 'install', 'dev'] as const
47
+
48
+ /**
49
+ * Bun fetches a tool it lacks, so its commands name the package; the others run the repository's own binaries.
50
+ * Biome runs as `bunx @biomejs/biome <command>`: its package's only binary, started without naming it twice.
51
+ */
52
+ const DERIVED_COMMANDS: Record<PackageManager, DerivedCommands> = {
53
+ bun: {
54
+ test: 'bun test',
55
+ typecheck: 'bunx -p typescript tsc --noEmit',
56
+ lint: 'bunx @biomejs/biome check .',
57
+ format: 'bunx @biomejs/biome format --write .',
58
+ install: 'bun install',
59
+ dev: 'bun run dev',
60
+ lintFiles: 'bunx @biomejs/biome check --no-errors-on-unmatched',
61
+ },
62
+ yarn: {
63
+ test: 'yarn test',
64
+ typecheck: 'yarn tsc --noEmit',
65
+ lint: 'yarn biome check .',
66
+ format: 'yarn biome format --write .',
67
+ install: 'yarn install',
68
+ dev: 'yarn dev',
69
+ lintFiles: 'yarn biome check --no-errors-on-unmatched',
70
+ },
71
+ pnpm: {
72
+ test: 'pnpm test',
73
+ typecheck: 'pnpm exec tsc --noEmit',
74
+ lint: 'pnpm exec biome check .',
75
+ format: 'pnpm exec biome format --write .',
76
+ install: 'pnpm install',
77
+ dev: 'pnpm dev',
78
+ lintFiles: 'pnpm exec biome check --no-errors-on-unmatched',
79
+ },
80
+ npm: {
81
+ test: 'npm test',
82
+ typecheck: 'npx tsc --noEmit',
83
+ lint: 'npx @biomejs/biome check .',
84
+ format: 'npx @biomejs/biome format --write .',
85
+ install: 'npm install',
86
+ dev: 'npm run dev',
87
+ lintFiles: 'npx @biomejs/biome check --no-errors-on-unmatched',
88
+ },
89
+ }
90
+
91
+ /** The bun defaults of the schema, so a repository Bun runs and one detection skips agree. */
92
+ export const BUN_COMMANDS = DERIVED_COMMANDS.bun
93
+
94
+ type ConfigObject = Record<string, unknown>
95
+
96
+ /** The blocks of a config that detection fills. */
97
+ type ConfigParts = { commands: ConfigObject; lanes: ConfigObject; check: ConfigObject | undefined }
98
+
99
+ const isObject = (value: unknown): value is ConfigObject =>
100
+ typeof value === 'object' && value !== null && !Array.isArray(value)
101
+
102
+ const has = (object: ConfigObject, key: string): boolean => Object.hasOwn(object, key)
103
+
104
+ export const detectPackageManager = (lockFiles: readonly string[]): PackageManagerDetection => {
105
+ const known = Object.keys(LOCK_FILES).filter((file) => lockFiles.includes(file))
106
+ const managers = new Set(known.map((file) => LOCK_FILES[file]))
107
+ if (managers.size === 0) return { kind: 'none' }
108
+ if (managers.size > 1) return { kind: 'ambiguous', lockFiles: known }
109
+ return { kind: 'found', manager: LOCK_FILES[known[0]], lockFile: known[0] }
110
+ }
111
+
112
+ /** `main` or `master` when origin has exactly one of them; `origin/HEAD` is not asked, since it can point at a branch long deleted. */
113
+ export const detectBaseBranch = (remoteBranches: readonly string[]): string | null => {
114
+ const present = BASE_BRANCH_CANDIDATES.filter((branch) => remoteBranches.includes(branch))
115
+ return present.length === 1 ? present[0] : null
116
+ }
117
+
118
+ const ambiguousNote = (lockFiles: readonly string[]): string =>
119
+ `${CLI_NAME}: lock files of several package managers (${lockFiles.join(', ')}); using the bun defaults. Set \`commands\` and \`lanes.lockFile\` in ${PROJECT_CONFIG_PATH}.`
120
+
121
+ const baseBranchNote = (remoteBranches: readonly string[]): string => {
122
+ const reason = BASE_BRANCH_CANDIDATES.every((branch) => remoteBranches.includes(branch))
123
+ ? 'origin has both main and master'
124
+ : 'origin has neither main nor master'
125
+ return `${CLI_NAME}: ${reason}; using ${DEFAULT_BASE_BRANCH}. Set \`baseBranch\` in ${PROJECT_CONFIG_PATH}.`
126
+ }
127
+
128
+ const missingCommandKeys = (commands: ConfigObject): string[] => COMMAND_KEYS.filter((key) => !has(commands, key))
129
+
130
+ const withManager = (
131
+ raw: ConfigObject,
132
+ parts: ConfigParts,
133
+ found: { manager: PackageManager; lockFile: string },
134
+ ): ConfigObject => {
135
+ const derived = DERIVED_COMMANDS[found.manager]
136
+ const commands = Object.fromEntries(
137
+ missingCommandKeys(parts.commands).map((key) => [key, derived[key as keyof DerivedCommands]]),
138
+ )
139
+ const check = parts.check ?? {}
140
+ return {
141
+ ...raw,
142
+ commands: { ...commands, ...parts.commands },
143
+ lanes: { lockFile: found.lockFile, ...parts.lanes },
144
+ check: { lintFilesCommand: derived.lintFiles, ...check },
145
+ }
146
+ }
147
+
148
+ const needsManager = (parts: ConfigParts) =>
149
+ missingCommandKeys(parts.commands).length > 0 ||
150
+ !has(parts.lanes, 'lockFile') ||
151
+ !(parts.check && has(parts.check, 'lintFilesCommand'))
152
+
153
+ const fillManager = (
154
+ raw: ConfigObject,
155
+ parts: ConfigParts,
156
+ lockFiles: readonly string[],
157
+ ): { raw: ConfigObject; notes: string[] } => {
158
+ if (!needsManager(parts)) return { raw, notes: [] }
159
+ const detection = detectPackageManager(lockFiles)
160
+ if (detection.kind === 'found') return { raw: withManager(raw, parts, detection), notes: [] }
161
+ return { raw, notes: detection.kind === 'ambiguous' ? [ambiguousNote(detection.lockFiles)] : [] }
162
+ }
163
+
164
+ const fillBaseBranch = (
165
+ raw: ConfigObject,
166
+ remoteBranches: readonly string[],
167
+ ): { raw: ConfigObject; notes: string[] } => {
168
+ if (has(raw, 'baseBranch')) return { raw, notes: [] }
169
+ const detected = detectBaseBranch(remoteBranches)
170
+ return detected
171
+ ? { raw: { ...raw, baseBranch: detected }, notes: [] }
172
+ : { raw, notes: [baseBranchNote(remoteBranches)] }
173
+ }
174
+
175
+ /** The config's own `commands`, `lanes` and `check` blocks, or null when one is there but no object. */
176
+ const configParts = (raw: ConfigObject): ConfigParts | null => {
177
+ const { commands = {}, lanes = {}, check } = raw
178
+ if (!isObject(commands) || !isObject(lanes) || (check !== undefined && !isObject(check))) return null
179
+ return { commands, lanes, check }
180
+ }
181
+
182
+ /**
183
+ * The raw config with every value it leaves out of `commands`, `lanes.lockFile`, `check.lintFilesCommand` and
184
+ * `baseBranch` filled from the repository: the package manager from the lock file, the base branch from origin.
185
+ * A value the config sets always wins. Where the repository does not decide (lock files of several managers,
186
+ * both or neither of `main` and `master`), the schema default holds and a note says so; without a lock file the
187
+ * defaults hold silently. A config of the wrong shape is passed through untouched for the schema to refuse.
188
+ */
189
+ export const withDetected = (raw: unknown, facts: RepositoryFacts): { raw: unknown; notes: string[] } => {
190
+ if (!isObject(raw)) return { raw, notes: [] }
191
+ const parts = configParts(raw)
192
+ if (!parts) return { raw, notes: [] }
193
+ const manager = fillManager(raw, parts, facts.lockFiles)
194
+ const base = fillBaseBranch(manager.raw, facts.remoteBranches)
195
+ return { raw: base.raw, notes: [...manager.notes, ...base.notes] }
196
+ }
@@ -0,0 +1,2 @@
1
+ export * from './json-schema'
2
+ export * from './schema'
@@ -0,0 +1,5 @@
1
+ import { z } from 'zod'
2
+ import { jackherdConfigSchema } from './schema'
3
+
4
+ export const renderConfigJsonSchema = (): string =>
5
+ `${JSON.stringify(z.toJSONSchema(jackherdConfigSchema, { io: 'input' }), null, 2)}\n`
@@ -0,0 +1,56 @@
1
+ import { join } from 'node:path'
2
+ import { CLI_NAME } from '../version/release'
3
+ import { CONFIG_FILE, CONFIG_SCHEMA_REFERENCE, LEGACY_CONFIG_PATH, MANIFEST_FILE, TOOL_DIRECTORY } from './location'
4
+
5
+ /** An old file name and the one it moved to, both relative to a project root or the home directory. */
6
+ export type Rename = { from: string; to: string }
7
+
8
+ /** What moving the old files takes: the renames whose target is free, and the old files a new one shadows. */
9
+ export type RenamePlan = { renames: Rename[]; shadowed: Rename[] }
10
+
11
+ /** The line a run prints when it moved files of an install before the rename, so the session learns the new command. */
12
+ export const RENAMED_NOTICE = `agent-kit is now jackherd: the command is \`${CLI_NAME}\`.`
13
+
14
+ /** Read and moved until 2.0.0, never written. */
15
+ export const LEGACY_MANIFEST_PATH = '.claude/agent-kit.lock.json'
16
+ export const LEGACY_UPDATE_STATE_PATH = '.claude/agent-kit-update-check.json'
17
+ export const LEGACY_SCHEMA_REFERENCE = '../node_modules/@crossdelta/agent-kit/schemas/agent-kit.schema.json'
18
+
19
+ export const PROJECT_LEGACY_FILES: readonly Rename[] = [
20
+ { from: LEGACY_CONFIG_PATH, to: `${TOOL_DIRECTORY}/${CONFIG_FILE}` },
21
+ { from: LEGACY_MANIFEST_PATH, to: `${TOOL_DIRECTORY}/${MANIFEST_FILE}` },
22
+ ]
23
+
24
+ export const PUBLIC_CONTENT_PATH = `${TOOL_DIRECTORY}/public-content.json`
25
+
26
+ /** Where the public-content facts lay before the rename: below `XDG_CONFIG_HOME`, else `~/.config`. */
27
+ export const legacyPublicContentPath = (home: string, configHome: string | undefined): string =>
28
+ join(configHome || join(home, '.config'), 'agent-kit', 'public-content.json')
29
+
30
+ /**
31
+ * The renames `pairs` take in a directory holding `present`: only pairs whose old file is there; one whose new
32
+ * file is there too is shadowed, since the new file wins and the old one is the user's to delete.
33
+ */
34
+ export const legacyRenames = (pairs: readonly Rename[], present: readonly string[]): RenamePlan => {
35
+ const there = new Set(present)
36
+ const old = pairs.filter(({ from }) => there.has(from))
37
+ return {
38
+ renames: old.filter(({ to }) => !there.has(to)),
39
+ shadowed: old.filter(({ to }) => there.has(to)),
40
+ }
41
+ }
42
+
43
+ /** A moved config's `$schema` at the old package points at the new one; any other text stays byte for byte. */
44
+ export const withCurrentSchemaReference = (text: string): string =>
45
+ text.replace(`"$schema": "${LEGACY_SCHEMA_REFERENCE}"`, `"$schema": "${CONFIG_SCHEMA_REFERENCE}"`)
46
+
47
+ /**
48
+ * The line a command prints when it read the config under its old name; null for any other path. The home
49
+ * directory's file moves with `sync --global`, a project's with `sync`.
50
+ */
51
+ export const legacyConfigNote = (configPath: string, home: string): string | null => {
52
+ if (!configPath.endsWith(`/${LEGACY_CONFIG_PATH}`)) return null
53
+ const directory = configPath.slice(0, -LEGACY_CONFIG_PATH.length - 1)
54
+ const command = directory === home ? `${CLI_NAME} sync --global` : `${CLI_NAME} sync`
55
+ return `${configPath} becomes ${join(directory, TOOL_DIRECTORY, CONFIG_FILE)} at the next \`${command}\`.`
56
+ }
@@ -0,0 +1,25 @@
1
+ /** A kind of link a plan can carry, shown as a chip on the sprint page: the URLs it matches, its label and its icon. */
2
+ export type LinkKind = {
3
+ /** A regular expression source matched against the whole URL. */
4
+ pattern: string
5
+ /** The chip's text when the plan gives the link no text of its own. */
6
+ label: string
7
+ /** A short text or emoji shown before the label; no external asset. */
8
+ icon: string
9
+ }
10
+
11
+ /** The kinds known without configuration: plain URL patterns, no service is called. */
12
+ export const DEFAULT_LINK_KINDS: readonly LinkKind[] = [
13
+ { pattern: '^https://github\\.com/[^/]+/[^/]+/(pull|issues)/\\d+', label: 'GitHub', icon: '⑂' },
14
+ { pattern: '^https://(www\\.)?figma\\.com/', label: 'Figma', icon: '◈' },
15
+ { pattern: '^https://claude\\.ai/(code/)?artifact/', label: 'Artifact', icon: '✦' },
16
+ ]
17
+
18
+ export const isRegularExpression = (source: string): boolean => {
19
+ try {
20
+ new RegExp(source)
21
+ return true
22
+ } catch {
23
+ return false
24
+ }
25
+ }
@@ -0,0 +1,46 @@
1
+ import { dirname, join, resolve } from 'node:path'
2
+ import { PACKAGE_NAME } from '../version/release'
3
+
4
+ /** The directory holding the tool's own config and lock, in a project and in the home directory alike. */
5
+ export const TOOL_DIRECTORY = '.jackherd'
6
+ export const CONFIG_FILE = 'config.json'
7
+ export const MANIFEST_FILE = 'lock.json'
8
+
9
+ /** The `$schema` a new project config gets: from `.jackherd/`, as deep as `node_modules` is from `.claude/`. */
10
+ export const CONFIG_SCHEMA_REFERENCE = `../node_modules/${PACKAGE_NAME}/schemas/${PACKAGE_NAME}.schema.json`
11
+
12
+ /** The project's config, relative to its root; the same path below the home directory is the global config. */
13
+ export const PROJECT_CONFIG_PATH = `${TOOL_DIRECTORY}/${CONFIG_FILE}`
14
+
15
+ /** Where the config lay before 0.29, read until 2.0.0 when the new one is missing; never written. */
16
+ export const LEGACY_CONFIG_PATH = '.claude/agent-kit.json'
17
+
18
+ /** The config files of `root`, the one that wins first. */
19
+ export const configCandidates = (root: string): string[] => [
20
+ join(root, PROJECT_CONFIG_PATH),
21
+ join(root, LEGACY_CONFIG_PATH),
22
+ ]
23
+
24
+ export const globalConfigPath = (home: string): string => join(home, PROJECT_CONFIG_PATH)
25
+
26
+ /**
27
+ * The nearest ancestor of `start` holding a project config. The home directory is passed over: the file there
28
+ * is the global config of `jh sync --global`, and `~` is no project.
29
+ */
30
+ export const findProjectRoot = (start: string, home: string, exists: (path: string) => boolean): string | null => {
31
+ const homeDirectory = resolve(home)
32
+ let directory = resolve(start)
33
+ while (directory === homeDirectory || !configCandidates(directory).some(exists)) {
34
+ const parent = dirname(directory)
35
+ if (parent === directory) return null
36
+ directory = parent
37
+ }
38
+ return directory
39
+ }
40
+
41
+ /**
42
+ * The project config of the first of `roots` that has one, else `~/.jackherd/config.json`, else null; in each
43
+ * directory the old `.claude/agent-kit.json` stands in for a missing new file.
44
+ */
45
+ export const locateConfigFile = (roots: string[], home: string, exists: (path: string) => boolean): string | null =>
46
+ [...roots, home].flatMap(configCandidates).find(exists) ?? null
@@ -0,0 +1,42 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { type RepositoryFacts, withDetected } from './detect'
4
+ import { legacyConfigNote } from './legacy-names'
5
+ import { globalConfigPath, locateConfigFile, PROJECT_CONFIG_PATH } from './location'
6
+ import { readRepositoryFacts } from './repository-facts'
7
+ import { type JackherdConfig, parseConfigFile } from './schema'
8
+
9
+ /** A config with the values detection filled in, and a line for each value the repository did not decide. */
10
+ export type ResolvedConfig = { config: JackherdConfig; notes: string[] }
11
+
12
+ /**
13
+ * Parses a config file's raw JSON, filling what it leaves out from the repository (see `withDetected`); an invalid
14
+ * file throws with its path and the issues in words.
15
+ */
16
+ const resolveConfig = (raw: unknown, facts: RepositoryFacts, path: string): ResolvedConfig => {
17
+ const detected = withDetected(raw ?? {}, facts)
18
+ return { config: parseConfigFile(detected.raw, path), notes: detected.notes }
19
+ }
20
+
21
+ /**
22
+ * The project's config from `.jackherd/config.json` of the first of `roots` that has one, else from
23
+ * `~/.jackherd/config.json`; throws when there is none or the file is invalid. The checkout a command runs in
24
+ * comes before the main checkout, so a lane reads the config it is working on, not the one `main` had before
25
+ * the lane changed it. Values the file leaves out are detected in the first of `roots`.
26
+ */
27
+ export const readResolvedConfig = (
28
+ roots: string[],
29
+ home: string = homedir(),
30
+ facts: RepositoryFacts = readRepositoryFacts(roots[0]),
31
+ ): ResolvedConfig => {
32
+ const configPath = locateConfigFile(roots, home, existsSync)
33
+ if (!configPath)
34
+ throw new Error(`${PROJECT_CONFIG_PATH} not found in ${roots.join(' or ')}, nor ${globalConfigPath(home)}`)
35
+ const resolved = resolveConfig(JSON.parse(readFileSync(configPath, 'utf8')), facts, configPath)
36
+ const legacy = legacyConfigNote(configPath, home)
37
+ return legacy ? { ...resolved, notes: [legacy, ...resolved.notes] } : resolved
38
+ }
39
+
40
+ /** `readResolvedConfig` without the notes. */
41
+ export const readConfig = (roots: string[], home: string = homedir()): JackherdConfig =>
42
+ readResolvedConfig(roots, home).config
@@ -0,0 +1,19 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { LOCK_FILES, type RepositoryFacts } from './detect'
5
+
6
+ const REMOTE_BRANCHES = ['for-each-ref', '--format=%(refname:lstrip=3)', 'refs/remotes/origin/']
7
+
8
+ /** Branch names on origin as the last fetch left them; none outside a git repository or without origin. */
9
+ const remoteBranchesOf = (root: string): string[] => {
10
+ const result = spawnSync('git', REMOTE_BRANCHES, { cwd: root, encoding: 'utf8' })
11
+ if (result.status !== 0) return []
12
+ return result.stdout.split('\n').filter((branch) => branch !== '' && branch !== 'HEAD')
13
+ }
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
+ })
@@ -0,0 +1,46 @@
1
+ import type { JackherdConfig } from './schema'
2
+
3
+ /** The values each repository decides for itself, by the short name `jh config` and global skills use. */
4
+ export const REPOSITORY_VALUE = {
5
+ test: 'test',
6
+ typecheck: 'typecheck',
7
+ lint: 'lint',
8
+ format: 'format',
9
+ install: 'install',
10
+ dev: 'dev',
11
+ lockFile: 'lockFile',
12
+ baseBranch: 'baseBranch',
13
+ } as const
14
+
15
+ export type RepositoryValue = (typeof REPOSITORY_VALUE)[keyof typeof REPOSITORY_VALUE]
16
+
17
+ const PLACEHOLDER: Record<RepositoryValue, string> = {
18
+ test: 'kit.command.test',
19
+ typecheck: 'kit.command.typecheck',
20
+ lint: 'kit.command.lint',
21
+ format: 'kit.command.format',
22
+ install: 'kit.command.install',
23
+ dev: 'kit.command.dev',
24
+ lockFile: 'kit.lanes.lockFile',
25
+ baseBranch: 'kit.baseBranch',
26
+ }
27
+
28
+ const NAMES = Object.values(REPOSITORY_VALUE)
29
+
30
+ export const isRepositoryValue = (value: string): value is RepositoryValue => (NAMES as string[]).includes(value)
31
+
32
+ /** How a global kit renders the placeholders a repository decides: `<install>`, `<lockFile>`, `<baseBranch>`, … */
33
+ export const REPOSITORY_TOKENS: ReadonlyMap<string, string> = new Map(
34
+ NAMES.map((name) => [PLACEHOLDER[name], `<${name}>`]),
35
+ )
36
+
37
+ export const repositoryValuesOf = (config: JackherdConfig): Record<RepositoryValue, string> => ({
38
+ ...config.commands,
39
+ lockFile: config.lanes.lockFile,
40
+ baseBranch: config.baseBranch,
41
+ })
42
+
43
+ export const formatRepositoryValues = (values: Record<RepositoryValue, string>): string[] => {
44
+ const width = Math.max(...NAMES.map((name) => name.length)) + 2
45
+ return NAMES.map((name) => `${name.padEnd(width)}${values[name]}`)
46
+ }