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,120 @@
1
+ import { posix } from 'node:path'
2
+ import { CLAUDE_DIRECTORY, SETTINGS_FILE } from '../claude/paths'
3
+ import { type JackherdConfig, parseConfigFile } from '../config'
4
+ import { type RepositoryFacts, withDetected } from '../config/detect'
5
+ import { CONFIG_FILE, TOOL_DIRECTORY } from '../config/location'
6
+ import { checksum } from './checksum'
7
+ import { errorMessage } from './error-message'
8
+ import type { KitSource } from './kit'
9
+ import { parseKitHooks } from './kit-hooks'
10
+ import { parseKitPath } from './kit-path'
11
+ import type { Manifest } from './manifest'
12
+ import { FILE_MODE, FILE_OUTCOME, type FilePlan, type RenderedFile } from './plan-file'
13
+ import type { HooksPlan } from './plan-hooks'
14
+ import type { HookDefinition } from './settings-hooks'
15
+
16
+ export type AcceptRequest = { files: Set<string>; settings: boolean }
17
+
18
+ const SETTINGS_ACCEPT_PATHS: readonly string[] = [SETTINGS_FILE, `${CLAUDE_DIRECTORY}/${SETTINGS_FILE}`]
19
+
20
+ export const parseJson = (text: string, file: string, location: string = TOOL_DIRECTORY): unknown => {
21
+ try {
22
+ return JSON.parse(text)
23
+ } catch (err) {
24
+ throw new Error(`${location}/${file} is not valid JSON: ${errorMessage(err)}`)
25
+ }
26
+ }
27
+
28
+ /**
29
+ * `location` names the config's directory in messages: `.jackherd` in a project, `~/.jackherd` for a global install.
30
+ * With `facts`, the values the file leaves out are detected from that repository (see `withDetected`).
31
+ */
32
+ export const parseConfig = (
33
+ text: string,
34
+ location: string = TOOL_DIRECTORY,
35
+ facts?: RepositoryFacts,
36
+ ): { config: JackherdConfig; notes: string[] } => {
37
+ const raw = parseJson(text, CONFIG_FILE, location)
38
+ const detected = facts ? withDetected(raw, facts) : { raw, notes: [] }
39
+ return { config: parseConfigFile(detected.raw, `${location}/${CONFIG_FILE}`), notes: detected.notes }
40
+ }
41
+
42
+ export const parseAcceptPaths = (raw: string[]): AcceptRequest => {
43
+ const files = new Set<string>()
44
+ let settings = false
45
+ for (const path of raw) {
46
+ if (SETTINGS_ACCEPT_PATHS.includes(path)) {
47
+ settings = true
48
+ continue
49
+ }
50
+ const parsed = parseKitPath(path)
51
+ if (!parsed.ok) throw new Error(`--accept ${path}: ${parsed.reason}`)
52
+ files.add(parsed.path)
53
+ }
54
+ return { files, settings }
55
+ }
56
+
57
+ /** Closed whatever the config says, so a tampered `.jackherd/config.json` cannot reopen it. */
58
+ const DEFAULT_WORKTREE_PREFIX = 'worktrees/'
59
+
60
+ /** Lower-case prefixes relative to `.claude/` that hold worktrees: the default and the configured one. */
61
+ const worktreePrefixes = (config: JackherdConfig): string[] => {
62
+ const normalized = posix.normalize(config.worktreeDirectory)
63
+ const parsed = parseKitPath(normalized)
64
+ const configured = parsed.ok && normalized.startsWith(`${CLAUDE_DIRECTORY}/`) ? [`${parsed.path.toLowerCase()}/`] : []
65
+ return [DEFAULT_WORKTREE_PREFIX, ...configured]
66
+ }
67
+
68
+ /**
69
+ * Worktrees under `.claude/` hold other checkouts; no kit path, manifest entry or `--accept` may
70
+ * reach into them, whatever checksum a manifest entry claims.
71
+ */
72
+ export const assertOutsideWorktrees = (paths: Iterable<string>, config: JackherdConfig): void => {
73
+ const prefixes = worktreePrefixes(config)
74
+ const inside = [...paths].filter((path) => prefixes.some((prefix) => path.toLowerCase().startsWith(prefix)))
75
+ if (inside.length > 0) {
76
+ throw new Error(`Refusing paths inside the worktree directory ${config.worktreeDirectory}: ${inside.join(', ')}`)
77
+ }
78
+ }
79
+
80
+ export const renderKitFiles = (kit: KitSource, render: (template: string) => string): Map<string, RenderedFile> => {
81
+ const rendered = new Map<string, RenderedFile>()
82
+ for (const file of kit.files) {
83
+ const parsed = parseKitPath(file.path)
84
+ if (!parsed.ok) throw new Error(`Kit file ${file.path} ${parsed.reason}`)
85
+ if (rendered.has(parsed.path)) throw new Error(`Kit file ${parsed.path} appears twice`)
86
+ const content = render(file.template)
87
+ const mode = file.executable ? FILE_MODE.executable : FILE_MODE.regular
88
+ rendered.set(parsed.path, { content, checksum: checksum(content), mode })
89
+ }
90
+ return rendered
91
+ }
92
+
93
+ export const renderKitHooks = (
94
+ kit: KitSource,
95
+ render: (template: string) => string,
96
+ ): Record<string, HookDefinition> => {
97
+ if (kit.hooksTemplate === undefined) return {}
98
+ const parsed = parseKitHooks(render(kit.hooksTemplate))
99
+ if (!parsed.ok) throw new Error(`Kit hooks are invalid: ${parsed.reason}`)
100
+ return parsed.hooks
101
+ }
102
+
103
+ /** Every `--accept` must land on something to accept; otherwise the run stops before any write. */
104
+ export const assertAcceptsApply = (accept: AcceptRequest, files: FilePlan[], hooks: HooksPlan): void => {
105
+ const accepted = new Set(files.filter(({ outcome }) => outcome === FILE_OUTCOME.accepted).map(({ path }) => path))
106
+ const refused = [...accept.files].filter((path) => !accepted.has(path))
107
+ if (accept.settings && hooks.notAcceptable) refused.push(`${CLAUDE_DIRECTORY}/${SETTINGS_FILE}`)
108
+ if (refused.length > 0) {
109
+ throw new Error(`Nothing to accept for: ${refused.join(', ')} (no conflict, no deselected kit file or hook there)`)
110
+ }
111
+ }
112
+
113
+ export const nextManifest = (kitVersion: string, files: FilePlan[], hooks: HooksPlan): Manifest => {
114
+ const manifest: Manifest = {
115
+ kitVersion,
116
+ files: Object.fromEntries(files.flatMap(({ path, entry }) => (entry ? [[path, entry]] : []))),
117
+ hooks: hooks.entries,
118
+ }
119
+ return hooks.kitNewRecorded === undefined ? manifest : { ...manifest, settingsKitNew: hooks.kitNewRecorded }
120
+ }
@@ -0,0 +1,9 @@
1
+ import type { FileOutcome } from './plan-file'
2
+ import type { HookOutcome } from './plan-hooks'
3
+
4
+ export type FileReport = { path: string; outcome: FileOutcome }
5
+
6
+ export type HookReport = { name: string; outcome: HookOutcome }
7
+
8
+ /** `notes` name the config values the repository did not decide, printed before the summary. */
9
+ export type SyncResult = { files: FileReport[]; hooks: HookReport[]; notes?: string[] }
@@ -0,0 +1,307 @@
1
+ import { homedir } from 'node:os'
2
+ import { join } from 'node:path'
3
+ import { CLAUDE_DIRECTORY, SETTINGS_FILE } from '../claude/paths'
4
+ import type { JackherdConfig } from '../config'
5
+ import type { RepositoryFacts } from '../config/detect'
6
+ import { CONFIG_FILE, MANIFEST_FILE, TOOL_DIRECTORY } from '../config/location'
7
+ import { readRepositoryFacts } from '../config/repository-facts'
8
+ import { REPOSITORY_TOKENS } from '../config/repository-values'
9
+ import { CLI_NAME } from '../version/release'
10
+ import { ensureGitignoreLines } from './gitignore'
11
+ import { toGlobalKit, toProjectKit } from './global-kit'
12
+ import type { KitSource } from './kit'
13
+ import { kitNewPath, toolRootPath } from './kit-path'
14
+ import { moveGlobalLegacyFiles, moveProjectLegacyFiles, removeEmptyLegacyDirectories } from './legacy-files'
15
+ import { EMPTY_MANIFEST, type Manifest, parseManifest, serializeManifest } from './manifest'
16
+ import { FILE_MODE, type FileEffect, type FilePlan, planFile, type RenderedFile } from './plan-file'
17
+ import { type HooksPlan, planHooks } from './plan-hooks'
18
+ import {
19
+ assertNotHomeDirectory,
20
+ assertOwnDirectoriesInProject,
21
+ readLocalState,
22
+ readRegularFile,
23
+ removeFileSafely,
24
+ writeFileSafely,
25
+ } from './project-fs'
26
+ import { renderTemplate } from './render'
27
+ import { parseSettings, type Settings } from './settings-hooks'
28
+ import {
29
+ type AcceptRequest,
30
+ assertAcceptsApply,
31
+ assertOutsideWorktrees,
32
+ nextManifest,
33
+ parseAcceptPaths,
34
+ parseConfig,
35
+ parseJson,
36
+ renderKitFiles,
37
+ renderKitHooks,
38
+ } from './sync-plan'
39
+ import type { SyncResult } from './sync-result'
40
+
41
+ /** A sync's result with the config it was read from, so a caller can name what the config decided. */
42
+ export type ConfiguredSync = { result: SyncResult; config: JackherdConfig }
43
+
44
+ export type SyncRequest = {
45
+ projectRoot: string
46
+ /** The user's home directory, which is never a project; defaults to the real one. */
47
+ home?: string
48
+ kit: KitSource
49
+ /** Paths from `--accept`, relative to `.claude/` or to the project root. */
50
+ accept: string[]
51
+ /** What fills the values the config leaves out; read from the project root when absent. */
52
+ facts?: RepositoryFacts
53
+ }
54
+
55
+ export type GlobalSyncRequest = {
56
+ /** The directory holding the user's `.claude/`, normally the home directory. */
57
+ home: string
58
+ kit: KitSource
59
+ /** Paths from `--accept`, relative to `~/.claude/`. */
60
+ accept: string[]
61
+ /** `XDG_CONFIG_HOME` as the command read it, where the old public-content facts lie; undefined for `~/.config`. */
62
+ configHome?: string
63
+ }
64
+
65
+ /** Where a lock path lies on disk: the directory to read and write it in, and the path below it. */
66
+ type Location = { directory: string; path: string }
67
+
68
+ type Locate = (path: string) => Location
69
+
70
+ /** What the plan is built from once the target decided how to read its config and settings. */
71
+ type PlanInput = {
72
+ claudeDirectory: string
73
+ toolDirectory: string
74
+ locate: Locate
75
+ kit: KitSource
76
+ config: JackherdConfig
77
+ accept: string[]
78
+ readHooks: (manifest: Manifest, accept: AcceptRequest) => Promise<HooksPlan>
79
+ /** Placeholders rendered as something other than their config value (see `renderTemplate`). */
80
+ overrides?: ReadonlyMap<string, string>
81
+ }
82
+
83
+ type SyncPlan = { files: FilePlan[]; hooks: HooksPlan; manifest: Manifest; manifestText: string | undefined }
84
+
85
+ const SETTINGS_KIT_NEW = kitNewPath(SETTINGS_FILE)
86
+
87
+ const GLOBAL_TOOL_LOCATION = `~/${TOOL_DIRECTORY}`
88
+
89
+ /** `location` names the directory in messages; `initCommand` is what creates the config there. */
90
+ const readConfig = async (
91
+ toolDirectory: string,
92
+ location: string,
93
+ initCommand: string,
94
+ facts?: RepositoryFacts,
95
+ ): Promise<{ config: JackherdConfig; notes: string[] }> => {
96
+ const text = await readRegularFile(toolDirectory, CONFIG_FILE)
97
+ if (text === undefined) throw new Error(`No ${location}/${CONFIG_FILE} found. Run \`${initCommand}\` first.`)
98
+ return parseConfig(text, location, facts)
99
+ }
100
+
101
+ const readManifest = async (toolDirectory: string): Promise<{ manifest: Manifest; text: string | undefined }> => {
102
+ const text = await readRegularFile(toolDirectory, MANIFEST_FILE)
103
+ if (text === undefined) return { manifest: EMPTY_MANIFEST, text }
104
+ const parsed = parseManifest(parseJson(text, MANIFEST_FILE))
105
+ if (!parsed.ok) throw new Error(`${TOOL_DIRECTORY}/${MANIFEST_FILE} is invalid:\n${parsed.reason}`)
106
+ return { manifest: parsed.manifest, text }
107
+ }
108
+
109
+ const readSettings = async (claudeDirectory: string): Promise<Settings> => {
110
+ const text = await readRegularFile(claudeDirectory, SETTINGS_FILE)
111
+ if (text === undefined) return {}
112
+ const parsed = parseSettings(parseJson(text, SETTINGS_FILE, CLAUDE_DIRECTORY))
113
+ if (!parsed.ok) throw new Error(`.claude/${SETTINGS_FILE} has hooks sync cannot read:\n${parsed.reason}`)
114
+ return parsed.settings
115
+ }
116
+
117
+ /** A project keeps every kit file in `.claude/`. */
118
+ const inClaudeDirectory =
119
+ (claudeDirectory: string): Locate =>
120
+ (path) => ({ directory: claudeDirectory, path })
121
+
122
+ /** A global install keeps the tool root's files in `~/.jackherd/`, everything else in `~/.claude/`. */
123
+ const inGlobalDirectories =
124
+ (claudeDirectory: string, toolDirectory: string): Locate =>
125
+ (path) => {
126
+ const inTool = toolRootPath(path)
127
+ return inTool === null ? { directory: claudeDirectory, path } : { directory: toolDirectory, path: inTool }
128
+ }
129
+
130
+ const localStateAt = ({ directory, path }: Location) => readLocalState(directory, path)
131
+
132
+ const planFiles = async (
133
+ locate: Locate,
134
+ rendered: Map<string, RenderedFile>,
135
+ manifest: Manifest,
136
+ accept: AcceptRequest,
137
+ ): Promise<FilePlan[]> => {
138
+ const paths = [...new Set([...rendered.keys(), ...Object.keys(manifest.files)])].sort()
139
+ return Promise.all(
140
+ paths.map(async (path) =>
141
+ planFile({
142
+ path,
143
+ rendered: rendered.get(path) ?? null,
144
+ entry: manifest.files[path],
145
+ local: await localStateAt(locate(path)),
146
+ kitNew: await localStateAt(locate(kitNewPath(path))),
147
+ accept: accept.files.has(path),
148
+ }),
149
+ ),
150
+ )
151
+ }
152
+
153
+ const buildPlan = async ({
154
+ toolDirectory,
155
+ locate,
156
+ kit,
157
+ config,
158
+ accept: acceptPaths,
159
+ readHooks,
160
+ overrides,
161
+ }: PlanInput): Promise<SyncPlan> => {
162
+ const { manifest, text: manifestText } = await readManifest(toolDirectory)
163
+ const accept = parseAcceptPaths(acceptPaths)
164
+ const render = (template: string) => renderTemplate(template, config, overrides)
165
+ const rendered = renderKitFiles(kit, render)
166
+ assertOutsideWorktrees([...rendered.keys(), ...Object.keys(manifest.files), ...accept.files], config)
167
+ const files = await planFiles(locate, rendered, manifest, accept)
168
+ const hooks = await readHooks(manifest, accept)
169
+ assertAcceptsApply(accept, files, hooks)
170
+ return { files, hooks, manifest: nextManifest(kit.version, files, hooks), manifestText }
171
+ }
172
+
173
+ /** Merges the kit's hooks into the project's `settings.json`. */
174
+ const projectHooks =
175
+ (claudeDirectory: string, kit: KitSource, config: JackherdConfig) =>
176
+ async (manifest: Manifest, accept: AcceptRequest): Promise<HooksPlan> =>
177
+ planHooks({
178
+ kit: renderKitHooks(kit, (template) => renderTemplate(template, config)),
179
+ entries: manifest.hooks,
180
+ settings: await readSettings(claudeDirectory),
181
+ kitNew: await readLocalState(claudeDirectory, SETTINGS_KIT_NEW),
182
+ kitNewRecorded: manifest.settingsKitNew,
183
+ accept: accept.settings,
184
+ })
185
+
186
+ /** A global install ships no hooks and never reads the user's `settings.json`. */
187
+ const noHooks = async (_manifest: Manifest, accept: AcceptRequest): Promise<HooksPlan> =>
188
+ planHooks({
189
+ kit: {},
190
+ entries: {},
191
+ settings: {},
192
+ kitNew: { kind: 'missing' },
193
+ kitNewRecorded: undefined,
194
+ accept: accept.settings,
195
+ })
196
+
197
+ const applyFileEffect = (locate: Locate, path: string, effect: FileEffect): Promise<void> => {
198
+ const { directory, path: target } = locate(effect.target === 'file' ? path : kitNewPath(path))
199
+ if (effect.kind === 'remove') return removeFileSafely(directory, target)
200
+ return writeFileSafely(directory, target, effect.content, effect.mode)
201
+ }
202
+
203
+ /** Writes before removals, so a file that moved (an old global path to the tool root) is never missing in between. */
204
+ const effectsInOrder = (files: FilePlan[]): { path: string; effect: FileEffect }[] => {
205
+ const all = files.flatMap(({ path, effects }) => effects.map((effect) => ({ path, effect })))
206
+ return [
207
+ ...all.filter(({ effect }) => effect.kind !== 'remove'),
208
+ ...all.filter(({ effect }) => effect.kind === 'remove'),
209
+ ]
210
+ }
211
+
212
+ const applyHooks = async (claudeDirectory: string, hooks: HooksPlan): Promise<void> => {
213
+ if (hooks.changed) {
214
+ await writeFileSafely(
215
+ claudeDirectory,
216
+ SETTINGS_FILE,
217
+ `${JSON.stringify(hooks.settings, null, 2)}\n`,
218
+ FILE_MODE.regular,
219
+ )
220
+ }
221
+ if (hooks.kitNew.kind === 'write')
222
+ await writeFileSafely(claudeDirectory, SETTINGS_KIT_NEW, hooks.kitNew.content, FILE_MODE.regular)
223
+ if (hooks.kitNew.kind === 'remove') await removeFileSafely(claudeDirectory, SETTINGS_KIT_NEW)
224
+ }
225
+
226
+ /**
227
+ * The manifest is written last: a run that dies halfway leaves files whose content equals the
228
+ * kit version, which the next run adopts as unchanged instead of mistaking them for local edits.
229
+ */
230
+ const applyPlan = async (
231
+ { claudeDirectory, toolDirectory, locate }: Pick<PlanInput, 'claudeDirectory' | 'toolDirectory' | 'locate'>,
232
+ plan: SyncPlan,
233
+ ): Promise<void> => {
234
+ for (const { path, effect } of effectsInOrder(plan.files)) await applyFileEffect(locate, path, effect)
235
+ await applyHooks(claudeDirectory, plan.hooks)
236
+ const manifestText = serializeManifest(plan.manifest)
237
+ if (manifestText !== plan.manifestText)
238
+ await writeFileSafely(toolDirectory, MANIFEST_FILE, manifestText, FILE_MODE.regular)
239
+ }
240
+
241
+ const syncResultOf = (plan: SyncPlan): SyncResult => ({
242
+ files: plan.files.map(({ path, outcome }) => ({ path, outcome })),
243
+ hooks: plan.hooks.outcomes,
244
+ })
245
+
246
+ /**
247
+ * Brings the project's `.claude/` in line with the kit and records it in `.jackherd/lock.json`. Everything is read, validated and planned
248
+ * before the first write, so a refusal (invalid config, manifest or settings, a bad path, an
249
+ * `--accept` with nothing to accept) throws and leaves the project exactly as it was.
250
+ */
251
+ export const syncProjectWithConfig = async (request: SyncRequest): Promise<ConfiguredSync> => {
252
+ await assertNotHomeDirectory(request.projectRoot, request.home ?? homedir())
253
+ await assertOwnDirectoriesInProject(request.projectRoot)
254
+ const claudeDirectory = join(request.projectRoot, CLAUDE_DIRECTORY)
255
+ const toolDirectory = join(request.projectRoot, TOOL_DIRECTORY)
256
+ const moved = await moveProjectLegacyFiles(request.projectRoot)
257
+ const facts = request.facts ?? readRepositoryFacts(request.projectRoot)
258
+ const { config, notes } = await readConfig(toolDirectory, TOOL_DIRECTORY, `${CLI_NAME} init`, facts)
259
+ const directories = { claudeDirectory, toolDirectory, locate: inClaudeDirectory(claudeDirectory) }
260
+ const plan = await buildPlan({
261
+ ...directories,
262
+ kit: toProjectKit(request.kit, config.skillPrefix),
263
+ config,
264
+ accept: request.accept,
265
+ readHooks: projectHooks(claudeDirectory, request.kit, config),
266
+ })
267
+ await applyPlan(directories, plan)
268
+ await ensureGitignoreLines(request.projectRoot)
269
+ return { result: { ...syncResultOf(plan), notes: [...moved, ...notes] }, config }
270
+ }
271
+
272
+ export const runSync = async (request: SyncRequest): Promise<SyncResult> =>
273
+ (await syncProjectWithConfig(request)).result
274
+
275
+ /**
276
+ * Installs the kit into the user's `~/.claude/` with every skill and agent renamed by `skillPrefix`
277
+ * (see `toGlobalKit`). Same plan-then-write and manifest rules as a project sync, against
278
+ * `~/.jackherd/config.json` and `~/.jackherd/lock.json`; no hooks, no `settings.json`, no
279
+ * `.gitignore`. Files the kit never wrote stay untouched; one at a kit path becomes a conflict.
280
+ */
281
+ export const syncGlobalWithConfig = async (request: GlobalSyncRequest): Promise<ConfiguredSync> => {
282
+ await assertOwnDirectoriesInProject(request.home)
283
+ const claudeDirectory = join(request.home, CLAUDE_DIRECTORY)
284
+ const toolDirectory = join(request.home, TOOL_DIRECTORY)
285
+ const moved = await moveGlobalLegacyFiles(request.home, request.configHome)
286
+ const { config } = await readConfig(toolDirectory, GLOBAL_TOOL_LOCATION, `${CLI_NAME} init --global`)
287
+ if (config.skillPrefix === '') {
288
+ throw new Error(
289
+ `${GLOBAL_TOOL_LOCATION}/${CONFIG_FILE} has no skillPrefix; a global install needs one (e.g. "kit-") so it cannot shadow project skills`,
290
+ )
291
+ }
292
+ const directories = { claudeDirectory, toolDirectory, locate: inGlobalDirectories(claudeDirectory, toolDirectory) }
293
+ const plan = await buildPlan({
294
+ ...directories,
295
+ kit: toGlobalKit(request.kit, config.skillPrefix),
296
+ config,
297
+ accept: request.accept,
298
+ readHooks: noHooks,
299
+ overrides: REPOSITORY_TOKENS,
300
+ })
301
+ await applyPlan(directories, plan)
302
+ await removeEmptyLegacyDirectories(claudeDirectory)
303
+ return { result: { ...syncResultOf(plan), notes: moved }, config }
304
+ }
305
+
306
+ export const runGlobalSync = async (request: GlobalSyncRequest): Promise<SyncResult> =>
307
+ (await syncGlobalWithConfig(request)).result
@@ -0,0 +1,52 @@
1
+ import { spawnSync } from 'node:child_process'
2
+
3
+ /** What a run of an external program answered; `missing` is true when there is no such program to run at all. */
4
+ export type ToolRun = { exitCode: number; output: string; missing: boolean }
5
+
6
+ /** Runs one external program with arguments, never through a shell. */
7
+ export type RunTool = (binary: string, args: string[]) => ToolRun
8
+
9
+ /**
10
+ * The external programs the kit works with, as far as they are installed; `herdrInstallable` is true where the
11
+ * official herdr installer applies (Linux and macOS).
12
+ */
13
+ export type ToolPresence = { herdr: boolean; gh: boolean; herdrInstallable: boolean }
14
+
15
+ type Environment = Record<string, string | undefined>
16
+
17
+ /**
18
+ * The installers the kit may run after a yes: each tool's official script, HTTPS-only on every redirect. The script
19
+ * is downloaded whole into a temporary file before `sh` runs it, so a download cut off midway runs nothing and a
20
+ * failed download ends with curl's exit code. Wrapped in `sh -c`, the command is also safe to paste into any
21
+ * interactive shell: its `exit` ends only the inner shell. herdr's applies to Linux and macOS.
22
+ */
23
+ export const INSTALLERS = {
24
+ herdr:
25
+ 'sh -c \'installer=$(mktemp) && curl --proto =https --tlsv1.2 -fsSL https://herdr.dev/install.sh -o "$installer" && sh "$installer"; code=$?; rm -f "$installer"; exit $code\'',
26
+ } as const
27
+
28
+ export type InstallerName = keyof typeof INSTALLERS
29
+
30
+ /** The binary herdr names in `HERDR_BIN_PATH`, else `herdr` from the PATH. */
31
+ export const herdrBinary = (env: Environment): string => env.HERDR_BIN_PATH || 'herdr'
32
+
33
+ const INSTALLABLE_PLATFORMS: readonly NodeJS.Platform[] = ['linux', 'darwin']
34
+
35
+ const isPresent = (run: RunTool, binary: string): boolean => !run(binary, ['--version']).missing
36
+
37
+ /** Which tools are installed, asked by running them; a program that runs at all counts, whatever it answers. */
38
+ export const detectTools = (env: Environment, platform: NodeJS.Platform, run: RunTool): ToolPresence => ({
39
+ herdr: isPresent(run, herdrBinary(env)),
40
+ gh: isPresent(run, 'gh'),
41
+ herdrInstallable: INSTALLABLE_PLATFORMS.includes(platform),
42
+ })
43
+
44
+ /** `env` is passed on because Bun looks the binary up in it. */
45
+ export const spawnTool =
46
+ (env: Environment): RunTool =>
47
+ (binary, args) => {
48
+ const result = spawnSync(binary, args, { encoding: 'utf-8', env })
49
+ const output = `${result.error?.message ?? ''} ${result.stdout ?? ''}${result.stderr ?? ''}`.trim()
50
+ const missing = (result.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT'
51
+ return { exitCode: result.status ?? 1, output, missing }
52
+ }
@@ -0,0 +1,91 @@
1
+ import { herdrBinary } from '../sync/tools'
2
+ import type { AdoptedName, LaneAgent, LaneStatusSource } from './types'
3
+
4
+ type HerdrAgent = { name?: string; pane_id: string; agent_status: string; agent_session?: unknown; cwd?: unknown }
5
+
6
+ const METADATA_SOURCE = 'pf-plans'
7
+
8
+ const isHerdrAgent = (value: unknown): value is HerdrAgent => {
9
+ if (typeof value !== 'object' || value === null) return false
10
+ const candidate = value as Record<string, unknown>
11
+ return (
12
+ typeof candidate.pane_id === 'string' &&
13
+ typeof candidate.agent_status === 'string' &&
14
+ (candidate.name === undefined || typeof candidate.name === 'string')
15
+ )
16
+ }
17
+
18
+ const sessionOf = ({ agent_session }: HerdrAgent): string | null => {
19
+ if (typeof agent_session !== 'object' || agent_session === null) return null
20
+ const { value } = agent_session as Record<string, unknown>
21
+ return typeof value === 'string' && value !== '' ? value : null
22
+ }
23
+
24
+ const laneAgentOf = (agent: HerdrAgent, name: string): LaneAgent => ({
25
+ name,
26
+ pane: agent.pane_id,
27
+ status: agent.agent_status,
28
+ session: sessionOf(agent),
29
+ cwd: typeof agent.cwd === 'string' ? agent.cwd : null,
30
+ })
31
+
32
+ /**
33
+ * Reads the JSON of `herdr agent list`; throws on anything else, so a changed format is seen, not guessed.
34
+ * Agents without a name are left out, except the one in `adopt.pane`, which takes `adopt.name`.
35
+ */
36
+ export const parseAgentList = (json: string, adopt?: AdoptedName): LaneAgent[] => {
37
+ const agents = (JSON.parse(json) as { result?: { agents?: unknown } }).result?.agents
38
+ if (!Array.isArray(agents) || !agents.every(isHerdrAgent)) throw new Error('herdr agent list: unexpected answer')
39
+ return agents.flatMap((agent) => {
40
+ const name = agent.name || (agent.pane_id === adopt?.pane ? adopt.name : '')
41
+ return name ? [laneAgentOf(agent, name)] : []
42
+ })
43
+ }
44
+
45
+ /**
46
+ * Sets or, with null, clears the sidebar title of a pane. The pane id goes before the options:
47
+ * herdr 0.9.0 rejects it after them, despite `--help`.
48
+ */
49
+ export const titleArguments = (pane: string, title: string | null): string[] => [
50
+ 'pane',
51
+ 'report-metadata',
52
+ pane,
53
+ '--source',
54
+ METADATA_SOURCE,
55
+ ...(title ? ['--title', title] : ['--clear-title']),
56
+ ]
57
+
58
+ /** Runs the herdr CLI with these arguments and returns its stdout; throws when it fails. */
59
+ export type HerdrRunner = (args: string[]) => string
60
+
61
+ /**
62
+ * Runs the herdr binary of `herdrBinary` (`HERDR_BIN_PATH`, else `herdr` from the PATH) and returns its stdout;
63
+ * throws with herdr's own error text when it fails or is not running.
64
+ */
65
+ export const herdrRunner =
66
+ (env: Record<string, string | undefined>): HerdrRunner =>
67
+ (args) => {
68
+ const result = Bun.spawnSync([herdrBinary(env), ...args], { stdout: 'pipe', stderr: 'pipe', env })
69
+ if (result.exitCode !== 0) {
70
+ throw new Error(
71
+ `herdr ${args.slice(0, 2).join(' ')}: ${result.stderr.toString().trim() || `exit ${result.exitCode}`}`,
72
+ )
73
+ }
74
+ return result.stdout.toString()
75
+ }
76
+
77
+ export const runHerdr: HerdrRunner = herdrRunner(process.env)
78
+
79
+ export const createHerdrLaneStatusSource = (run: HerdrRunner): LaneStatusSource => ({
80
+ listAgents: (adopt) => parseAgentList(run(['agent', 'list']), adopt),
81
+ setTitle: (pane, title) => {
82
+ try {
83
+ run(titleArguments(pane, title))
84
+ return null
85
+ } catch (error) {
86
+ return `${pane}: ${error instanceof Error ? error.message : String(error)}`
87
+ }
88
+ },
89
+ })
90
+
91
+ export const herdrLaneStatusSource = createHerdrLaneStatusSource(runHerdr)
@@ -0,0 +1,13 @@
1
+ import { TERMINAL_ADAPTER, type TerminalAdapter } from '../config'
2
+ import { herdrLaneStatusSource } from './herdr'
3
+ import { noneLaneStatusSource } from './none'
4
+ import type { LaneStatusSource } from './types'
5
+
6
+ export type { AdoptedName, LaneAgent, LaneStatusSource } from './types'
7
+
8
+ const SOURCES: Record<TerminalAdapter, LaneStatusSource> = {
9
+ [TERMINAL_ADAPTER.none]: noneLaneStatusSource,
10
+ [TERMINAL_ADAPTER.herdr]: herdrLaneStatusSource,
11
+ }
12
+
13
+ export const laneStatusSourceFor = (adapter: TerminalAdapter): LaneStatusSource => SOURCES[adapter]
@@ -0,0 +1,7 @@
1
+ import type { LaneStatusSource } from './types'
2
+
3
+ /** No terminal adapter: no agents are known, nothing is titled, and nothing is reported as an error. */
4
+ export const noneLaneStatusSource: LaneStatusSource = {
5
+ listAgents: () => [],
6
+ setTitle: () => null,
7
+ }
@@ -0,0 +1,39 @@
1
+ /** What opening something in the default application needs from the machine; tests pass their own. */
2
+ export type OpenEnvironment = {
3
+ platform: NodeJS.Platform
4
+ env: Record<string, string | undefined>
5
+ start: (command: string, args: string[]) => void
6
+ }
7
+
8
+ const OPENERS: Partial<Record<NodeJS.Platform, string>> = { darwin: 'open', linux: 'xdg-open' }
9
+
10
+ /** The command that opens a file or a link in the default application of the platform, null when there is none. */
11
+ export const openerFor = (platform: NodeJS.Platform): string | null => OPENERS[platform] ?? null
12
+
13
+ /** Linux opens things in a graphical session only; elsewhere the platform's opener is assumed to work. */
14
+ export const hasDisplay = ({ platform, env }: Pick<OpenEnvironment, 'platform' | 'env'>): boolean =>
15
+ platform !== 'linux' || Boolean(env.DISPLAY || env.WAYLAND_DISPLAY)
16
+
17
+ const systemEnvironment = (): OpenEnvironment => ({
18
+ platform: process.platform,
19
+ env: process.env,
20
+ start: (command, args) => {
21
+ Bun.spawn([command, ...args], { stdout: 'ignore', stderr: 'ignore' })
22
+ },
23
+ })
24
+
25
+ /**
26
+ * Opens a file or a link in the default application. Returns the reason when that was not possible (no
27
+ * opener for the platform, no display, the opener could not be started), null when it was started.
28
+ */
29
+ export const openInBrowser = (target: string, environment: OpenEnvironment = systemEnvironment()): string | null => {
30
+ const opener = openerFor(environment.platform)
31
+ if (!opener) return `no way to open ${target} on ${environment.platform}`
32
+ if (!hasDisplay(environment)) return 'no display'
33
+ try {
34
+ environment.start(opener, [target])
35
+ return null
36
+ } catch (error) {
37
+ return `${opener} ${target}: ${error instanceof Error ? error.message : String(error)}`
38
+ }
39
+ }