jackherd 0.0.1 → 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 +1121 -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 +63 -5
  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,94 @@
1
+ import { CLI_NAME } from '../version/release'
2
+ import {
3
+ type CommandContext,
4
+ defaultContext,
5
+ GLOBAL_FLAG,
6
+ globalTargetNote,
7
+ runWithKit,
8
+ usageError,
9
+ } from './command-context'
10
+ import { errorMessage } from './error-message'
11
+ import { dailyUpdateNotice, defaultUpdateCheck, lockedVersion, syncNeeded, type UpdateCheck } from './if-changed'
12
+ import { SYNC_EXIT } from './report'
13
+ import { migrateGlobalSettings } from './settings-migration'
14
+ import { runGlobalSync, runSync } from './sync'
15
+
16
+ export type SyncArgs =
17
+ | { ok: true; accept: string[]; global: boolean; ifChanged: boolean }
18
+ | { ok: false; message: string }
19
+
20
+ const ACCEPT_FLAG = '--accept'
21
+ const IF_CHANGED_FLAG = '--if-changed'
22
+
23
+ const SYNC_USAGE = `Usage: ${CLI_NAME} sync [--global [${IF_CHANGED_FLAG}]] [--accept <path>]...`
24
+
25
+ export const parseSyncArgs = (args: string[]): SyncArgs => {
26
+ const accept: string[] = []
27
+ let global = false
28
+ let ifChanged = false
29
+ for (let i = 0; i < args.length; i++) {
30
+ const arg = args[i]
31
+ if (arg === GLOBAL_FLAG) {
32
+ global = true
33
+ continue
34
+ }
35
+ if (arg === IF_CHANGED_FLAG) {
36
+ ifChanged = true
37
+ continue
38
+ }
39
+ if (arg.startsWith(`${ACCEPT_FLAG}=`)) {
40
+ accept.push(arg.slice(ACCEPT_FLAG.length + 1))
41
+ continue
42
+ }
43
+ if (arg !== ACCEPT_FLAG) return { ok: false, message: `Unknown argument: ${arg}\n${SYNC_USAGE}` }
44
+ const path = args[++i]
45
+ if (path === undefined) return { ok: false, message: `${ACCEPT_FLAG} needs a path\n${SYNC_USAGE}` }
46
+ accept.push(path)
47
+ }
48
+ if (ifChanged && !global) return { ok: false, message: `${IF_CHANGED_FLAG} goes with ${GLOBAL_FLAG}\n${SYNC_USAGE}` }
49
+ return { ok: true, accept, global, ifChanged }
50
+ }
51
+
52
+ const syncGlobal = (context: CommandContext, accept: string[]): Promise<number> =>
53
+ runWithKit(context, 'sync', async (kit) => {
54
+ const result = await runGlobalSync({ home: context.home, kit, accept, configHome: context.configHome })
55
+ const settings = await migrateGlobalSettings(context.home, context.cliOnPath())
56
+ return { notes: [globalTargetNote(context.home), ...settings], result }
57
+ })
58
+
59
+ /**
60
+ * `sync --global --if-changed`, meant for a `SessionStart` hook: syncs `~/.claude` only when the installed package
61
+ * is not the version the global lock was written by, and once a day names a newer release on npm in one line.
62
+ * Prints nothing when there is nothing to do, since a hook's output reaches the session. Installs nothing.
63
+ */
64
+ const syncGlobalIfChanged = async (context: CommandContext, accept: string[], check: UpdateCheck): Promise<number> => {
65
+ let installed: string
66
+ try {
67
+ installed = (await context.locate()).version
68
+ } catch (err) {
69
+ context.log(`${CLI_NAME} sync failed: ${errorMessage(err)}`)
70
+ return SYNC_EXIT.error
71
+ }
72
+ const exitCode = syncNeeded(installed, await lockedVersion(context.home)) ? await syncGlobal(context, accept) : 0
73
+ const notice = await dailyUpdateNotice(context.home, installed, check)
74
+ if (notice) context.log(notice)
75
+ return exitCode
76
+ }
77
+
78
+ export const runSyncCommand = async (
79
+ args: string[],
80
+ context: CommandContext = defaultContext(),
81
+ check: UpdateCheck = defaultUpdateCheck(),
82
+ ): Promise<number> => {
83
+ const parsed = parseSyncArgs(args)
84
+ if (!parsed.ok) return usageError(context, parsed.message)
85
+ const { accept, global, ifChanged } = parsed
86
+ if (ifChanged) return syncGlobalIfChanged(context, accept, check)
87
+ if (global) return syncGlobal(context, accept)
88
+ return runWithKit(context, 'sync', async (kit) => ({
89
+ notes: [],
90
+ result: await runSync({ projectRoot: context.projectRoot, home: context.home, kit, accept }),
91
+ }))
92
+ }
93
+
94
+ export const run = (args: string[]): Promise<number> => runSyncCommand(args)
@@ -0,0 +1 @@
1
+ export const errorMessage = (err: unknown): string => (err instanceof Error ? err.message : String(err))
@@ -0,0 +1,45 @@
1
+ import { lstat, readFile, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { claudePath, HANDOVER_DIRECTORY } from '../claude/paths'
4
+
5
+ /** What the kit's skills write per lane and nobody commits: the hand-over notes of a lane that changes chat. */
6
+ export const KIT_IGNORED_PATHS: readonly string[] = [`${claudePath(HANDOVER_DIRECTORY)}/`]
7
+
8
+ const GITIGNORE = '.gitignore'
9
+
10
+ const isMissing = (err: unknown): boolean =>
11
+ typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT'
12
+
13
+ const readExisting = async (path: string): Promise<string | undefined> => {
14
+ try {
15
+ const entry = await lstat(path)
16
+ if (!entry.isFile()) throw new Error(`${GITIGNORE} is not a regular file`)
17
+ return await readFile(path, 'utf8')
18
+ } catch (err) {
19
+ if (isMissing(err)) return undefined
20
+ throw err
21
+ }
22
+ }
23
+
24
+ const missingLines = (text: string, lines: readonly string[]): string[] => {
25
+ const present = new Set(text.split('\n').map((line) => line.trim()))
26
+ return lines.filter((line) => !present.has(line))
27
+ }
28
+
29
+ /**
30
+ * Adds the lines the project's `.gitignore` lacks, at its end; a file that does not exist is created. A line
31
+ * that is already there (whatever surrounds it) is left alone, so a second run changes nothing. Returns the
32
+ * lines it added. Throws when `.gitignore` is a symlink or a directory: nothing is written through it.
33
+ */
34
+ export const ensureGitignoreLines = async (
35
+ projectRoot: string,
36
+ lines: readonly string[] = KIT_IGNORED_PATHS,
37
+ ): Promise<string[]> => {
38
+ const path = join(projectRoot, GITIGNORE)
39
+ const existing = await readExisting(path)
40
+ const missing = missingLines(existing ?? '', lines)
41
+ if (missing.length === 0) return []
42
+ const separator = existing === undefined || existing === '' || existing.endsWith('\n') ? '' : '\n'
43
+ await writeFile(path, `${existing ?? ''}${separator}${missing.join('\n')}\n`)
44
+ return missing
45
+ }
@@ -0,0 +1,176 @@
1
+ import { AGENTS_DIRECTORY, claudePath, RULES_DIRECTORY, SKILLS_DIRECTORY } from '../claude/paths'
2
+ import { SKILL_PREFIX } from '../config'
3
+ import { TOOL_DIRECTORY } from '../config/location'
4
+ import { REPOSITORY_TOKENS } from '../config/repository-values'
5
+ import { CLI_NAME, PACKAGE_NAME } from '../version/release'
6
+ import type { KitFile, KitSource } from './kit'
7
+ import { TOOL_ROOT } from './kit-path'
8
+ import { listPlaceholders } from './render'
9
+
10
+ export type EntryKind = 'skill' | 'agent'
11
+
12
+ /** Where the kit's rules land: in `~/.jackherd/rules/`, outside `~/.claude/`, so Claude Code does not load them in every project. */
13
+ const GLOBAL_RULES_DIRECTORY = `${TOOL_ROOT}/${RULES_DIRECTORY}`
14
+
15
+ /** Put at the top of a global file that names values each repository decides; `sync --global` renders them as `<name>`. */
16
+ export const REPOSITORY_VALUES_NOTE = `Values in angle brackets such as \`<test>\`, \`<install>\` or \`<baseBranch>\` are the current repository's own: run \`${CLI_NAME} config\` once in it to read them all, or \`${CLI_NAME} config <name>\` for one.`
17
+
18
+ const SKILL_PATH = new RegExp(`^${SKILLS_DIRECTORY}/([^/]+)/(.+)$`)
19
+ const AGENT_PATH = new RegExp(`^${AGENTS_DIRECTORY}/([^/]+)\\.md$`)
20
+ const SKILL_ENTRY = new RegExp(`^${SKILLS_DIRECTORY}/[^/]+/SKILL\\.md$`)
21
+ const RULE_PATH = new RegExp(`^${RULES_DIRECTORY}/kit/(.+)$`)
22
+
23
+ const FRONTMATTER = /^---\n([\s\S]*?)\n---\n/
24
+ const NAME_LINE = /^name: (.+)$/m
25
+ const DESCRIPTION_LINE = /^description: '(.*)'$/m
26
+
27
+ const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
28
+
29
+ export const globalNote = (name: string, kind: EntryKind): string =>
30
+ `Global ${PACKAGE_NAME} copy. In a project that has its own ${name} ${kind}, use that one instead. `
31
+
32
+ const assertUsablePrefix = (prefix: string): void => {
33
+ if (!SKILL_PREFIX.test(prefix)) {
34
+ throw new Error(`skillPrefix "${prefix}" is not usable: lower-case letters and digits ending in a dash, e.g. jh-`)
35
+ }
36
+ }
37
+
38
+ /** Where the status line lands: `~/.jackherd/statusline.sh`; `init --global` offers it from there. */
39
+ const GLOBAL_STATUS_LINE_PATH = `${TOOL_ROOT}/statusline.sh`
40
+
41
+ /** The path in a project's `.claude/`: skills and agents prefixed, everything else where the kit puts it. */
42
+ const projectPath = (path: string, prefix: string): string => {
43
+ const skill = SKILL_PATH.exec(path)
44
+ if (skill) return `${SKILLS_DIRECTORY}/${prefix}${skill[1]}/${skill[2]}`
45
+ const agent = AGENT_PATH.exec(path)
46
+ return agent ? `${AGENTS_DIRECTORY}/${prefix}${agent[1]}.md` : path
47
+ }
48
+
49
+ /** The path in `~/.claude`, or null for what stays out of a global install (the hooks). */
50
+ const globalPath = (path: string, prefix: string): string | null => {
51
+ if (path === 'statusline.sh') return GLOBAL_STATUS_LINE_PATH
52
+ const rule = RULE_PATH.exec(path)
53
+ if (rule) return `${GLOBAL_RULES_DIRECTORY}/${rule[1]}`
54
+ return SKILL_PATH.test(path) || AGENT_PATH.test(path) ? projectPath(path, prefix) : null
55
+ }
56
+
57
+ const entryKind = (path: string): EntryKind | null => {
58
+ if (SKILL_ENTRY.test(path)) return 'skill'
59
+ if (AGENT_PATH.test(path)) return 'agent'
60
+ return null
61
+ }
62
+
63
+ /** Skill and agent names the kit ships, longest first so `pre-commit` wins over a shorter name inside it. */
64
+ const kitNames = (files: KitFile[]): string[] => {
65
+ const names = files.flatMap(({ path }) => {
66
+ const match = SKILL_PATH.exec(path) ?? AGENT_PATH.exec(path)
67
+ return match ? [match[1]] : []
68
+ })
69
+ return [...new Set(names)].sort((left, right) => right.length - left.length)
70
+ }
71
+
72
+ /** Names as a skill or agent calls another: slash commands, `subagent_type`, backticked names, relative links. */
73
+ const rewriteNames = (text: string, alternation: string, prefix: string): string =>
74
+ text
75
+ .replace(
76
+ new RegExp(`\\.\\./${SKILLS_DIRECTORY}/(${alternation})(?![\\w-])`, 'g'),
77
+ `../${SKILLS_DIRECTORY}/${prefix}$1`,
78
+ )
79
+ .replace(new RegExp(`(?<![\\w.~/-])/(${alternation})(?![\\w/-])`, 'g'), `/${prefix}$1`)
80
+ .replace(new RegExp(`subagent_type: '(${alternation})'`, 'g'), `subagent_type: '${prefix}$1'`)
81
+ .replace(new RegExp(`\`(${alternation})\``, 'g'), `\`${prefix}$1\``)
82
+
83
+ const PROJECT_SKILLS = claudePath(SKILLS_DIRECTORY)
84
+ const GLOBAL_SKILLS = `~/${PROJECT_SKILLS}`
85
+
86
+ /** `.claude/skills/<name>/` as the copy in `skillsDirectory` (`~/.claude/skills` or the project's own). */
87
+ const rewriteSkillPaths = (text: string, alternation: string, prefix: string, skillsDirectory: string): string =>
88
+ text.replace(
89
+ new RegExp(`(?<![~\\w/])${escapeRegExp(PROJECT_SKILLS)}/(${alternation})/`, 'g'),
90
+ `${skillsDirectory}/${prefix}$1/`,
91
+ )
92
+
93
+ const rewriteReferences = (text: string, names: string[], prefix: string): string => {
94
+ const alternation = names.map(escapeRegExp).join('|')
95
+ return rewriteNames(rewriteSkillPaths(text, alternation, prefix, GLOBAL_SKILLS), alternation, prefix).replace(
96
+ new RegExp(`(?<![~\\w/])${escapeRegExp(claudePath(RULES_DIRECTORY))}/kit/(?=[\\w-]+\\.md)`, 'g'),
97
+ `~/${TOOL_DIRECTORY}/${RULES_DIRECTORY}/`,
98
+ )
99
+ }
100
+
101
+ const rewriteFrontmatter = (file: KitFile, prefix: string, note: (name: string) => string): string => {
102
+ const block = FRONTMATTER.exec(file.template)
103
+ if (!block) throw new Error(`Kit file ${file.path} has no frontmatter`)
104
+ const name = NAME_LINE.exec(block[1])?.[1].trim()
105
+ if (!name || !DESCRIPTION_LINE.test(block[1])) {
106
+ throw new Error(`Kit file ${file.path} needs a name line and a single-quoted description line`)
107
+ }
108
+ const head = block[1]
109
+ .replace(NAME_LINE, `name: ${prefix}${name}`)
110
+ .replace(DESCRIPTION_LINE, (_line, text: string) => `description: '${note(name)}${text}'`)
111
+ return `---\n${head}\n---\n${file.template.slice(block[0].length)}`
112
+ }
113
+
114
+ const usesRepositoryValues = (template: string): boolean =>
115
+ listPlaceholders(template).some((name) => REPOSITORY_TOKENS.has(name))
116
+
117
+ /** The note right after the frontmatter, or first when there is none. */
118
+ const withRepositoryNote = (template: string): string => {
119
+ const block = FRONTMATTER.exec(template)
120
+ const head = block ? block[0] : ''
121
+ const rest = template.slice(head.length).replace(/^\n+/, '')
122
+ return `${head}${head ? '\n' : ''}${REPOSITORY_VALUES_NOTE}\n\n${rest}`
123
+ }
124
+
125
+ const toGlobalFile = (file: KitFile, path: string, names: string[], prefix: string): KitFile => {
126
+ if (!file.path.endsWith('.md')) return { ...file, path }
127
+ const kind = entryKind(file.path)
128
+ const noted =
129
+ kind === 'skill' || usesRepositoryValues(file.template) ? withRepositoryNote(file.template) : file.template
130
+ const body = rewriteReferences(noted, names, prefix)
131
+ const template = kind
132
+ ? rewriteFrontmatter({ ...file, template: body }, prefix, (name) => globalNote(name, kind))
133
+ : body
134
+ return { ...file, path, template }
135
+ }
136
+
137
+ /**
138
+ * The kit as `~/.claude` receives it: skills and agents renamed with the prefix, rules and the status line
139
+ * moved into `~/.jackherd/`, hooks left out. Inside Markdown files, every reference
140
+ * follows: slash commands, `subagent_type`, backticked names, skill and rule paths. A global copy
141
+ * needs the prefix because a personal skill shadows a project skill of the same name.
142
+ */
143
+ export const toGlobalKit = (kit: KitSource, prefix: string): KitSource => {
144
+ assertUsablePrefix(prefix)
145
+ const names = kitNames(kit.files)
146
+ const files = kit.files.flatMap((file) => {
147
+ const path = globalPath(file.path, prefix)
148
+ return path ? [toGlobalFile(file, path, names, prefix)] : []
149
+ })
150
+ return { version: kit.version, files, hooksTemplate: undefined }
151
+ }
152
+
153
+ const toProjectFile = (file: KitFile, names: string[], prefix: string): KitFile => {
154
+ const path = projectPath(file.path, prefix)
155
+ if (!file.path.endsWith('.md') || file.path.startsWith('hooks/')) return { ...file, path }
156
+ const alternation = names.map(escapeRegExp).join('|')
157
+ const body = rewriteNames(rewriteSkillPaths(file.template, alternation, prefix, PROJECT_SKILLS), alternation, prefix)
158
+ const kind = entryKind(file.path)
159
+ return {
160
+ ...file,
161
+ path,
162
+ template: kind ? rewriteFrontmatter({ ...file, template: body }, prefix, () => '') : body,
163
+ }
164
+ }
165
+
166
+ /**
167
+ * The kit as a project with `skillPrefix` receives it: skills and agents renamed, every reference to them
168
+ * following (as in `toGlobalKit`), rules, hooks and the status line in place. A project skill of the same name
169
+ * then stays the project's, and the kit skills call each other, not it. Without a prefix the kit is returned as it is.
170
+ */
171
+ export const toProjectKit = (kit: KitSource, prefix: string): KitSource => {
172
+ if (prefix === '') return kit
173
+ assertUsablePrefix(prefix)
174
+ const names = kitNames(kit.files)
175
+ return { ...kit, files: kit.files.map((file) => toProjectFile(file, names, prefix)) }
176
+ }
@@ -0,0 +1,175 @@
1
+ import { realpathSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import { isLinkedWorktree } from '../plans/git'
4
+ import { herdrBinary, spawnTool, type ToolRun } from './tools'
5
+
6
+ export const HERDR_PLUGIN_ID = 'crossdelta.jackherd'
7
+
8
+ /** The plugin id before the rename; `sync` unlinks it once the current id is linked, so it is replaced, not doubled. */
9
+ export const LEGACY_HERDR_PLUGIN_ID = 'crossdelta.agent-kit'
10
+
11
+ /** The plugin's directory below the package root; `package.json` ships it through `files`. */
12
+ export const HERDR_PLUGIN_DIRECTORY = 'herdr-plugin'
13
+
14
+ /** Why the plugin is linked again: herdr does not list it, it points at another directory, or its manifest is unreadable. */
15
+ export const RELINK_REASON = { missing: 'missing', elsewhere: 'elsewhere', manifest: 'manifest' } as const
16
+
17
+ export type RelinkReason = (typeof RELINK_REASON)[keyof typeof RELINK_REASON]
18
+
19
+ /**
20
+ * Where the running package lies: a checkout of the kit's source, a lane's worktree (whose path goes away with the
21
+ * lane), or an installed package (global, `bunx` cache, a project's dependency).
22
+ */
23
+ export const PACKAGE_COPY = { checkout: 'checkout', laneWorktree: 'laneWorktree', installed: 'installed' } as const
24
+
25
+ export type PackageCopy = (typeof PACKAGE_COPY)[keyof typeof PACKAGE_COPY]
26
+
27
+ /** One herdr command; `missing` is true when there is no `herdr` to run at all. */
28
+ export type HerdrRun = (args: string[]) => ToolRun
29
+
30
+ export type LinkRequest = {
31
+ packageRoot: string
32
+ env: Record<string, string | undefined>
33
+ run: HerdrRun
34
+ /** What this copy may link: see `linkHerdrPlugin`. */
35
+ copy: PackageCopy
36
+ /** The id an install before the rename linked; `LEGACY_HERDR_PLUGIN_ID` unless a test names another. */
37
+ legacyPluginId?: string
38
+ }
39
+
40
+ /** The kit plugin as herdr has it linked. */
41
+ export type LinkedPlugin = { root: string; warnings: string[] }
42
+
43
+ type ListedPlugin = { plugin_id?: unknown; plugin_root?: unknown; warnings?: unknown }
44
+
45
+ /** The kit plugin in a `herdr plugin list --json` answer, null when it is not there; throws on another shape. */
46
+ export const linkedPluginOf = (json: string, id: string = HERDR_PLUGIN_ID): LinkedPlugin | null => {
47
+ const plugins = (JSON.parse(json) as { result?: { plugins?: unknown } }).result?.plugins
48
+ if (!Array.isArray(plugins)) throw new Error('herdr plugin list: unexpected answer')
49
+ const listed = (plugins as ListedPlugin[]).find((plugin) => plugin?.plugin_id === id)
50
+ if (!listed) return null
51
+ const warnings = Array.isArray(listed.warnings) ? listed.warnings.filter((each) => typeof each === 'string') : []
52
+ return { root: String(listed.plugin_root), warnings }
53
+ }
54
+
55
+ const manifestWarningOf = (linked: LinkedPlugin): string | undefined =>
56
+ linked.warnings.find((warning) => warning.startsWith('manifest'))
57
+
58
+ /** Why the link has to be made again, null when it points at `expected` and herdr can read its manifest. */
59
+ export const relinkReasonOf = (linked: LinkedPlugin | null, expected: string): RelinkReason | null => {
60
+ if (!linked) return RELINK_REASON.missing
61
+ if (manifestWarningOf(linked)) return RELINK_REASON.manifest
62
+ return resolve(linked.root) === resolve(expected) ? null : RELINK_REASON.elsewhere
63
+ }
64
+
65
+ /** The reason as the output line says it: herdr's own warning for the manifest, else what made the link wrong. */
66
+ const reasonText = (reason: RelinkReason, linked: LinkedPlugin): string =>
67
+ reason === RELINK_REASON.manifest ? (manifestWarningOf(linked) ?? 'manifest') : 'it pointed at another package'
68
+
69
+ /** A lane's worktree never links or unlinks: it names a broken link to this copy's directory, nothing else. */
70
+ const laneNote = (linked: LinkedPlugin | null, packageRoot: string): string | null =>
71
+ relinkReasonOf(linked, join(packageRoot, HERDR_PLUGIN_DIRECTORY)) === null ? null : staleLinkNote(linked)
72
+
73
+ /** What a lane's worktree says about the link: only a broken one is worth a line, since it never relinks from there. */
74
+ const staleLinkNote = (linked: LinkedPlugin | null): string | null => {
75
+ const warning = linked && manifestWarningOf(linked)
76
+ return linked && warning
77
+ ? `The herdr plugin link to ${linked.root} is stale (${warning}); not relinked from a lane worktree, run sync outside one.`
78
+ : null
79
+ }
80
+
81
+ /** Whether the package root lies below a `node_modules` directory, i.e. was installed rather than checked out. */
82
+ export const isInstalledCopy = (packageRoot: string): boolean => packageRoot.split(/[\\/]/).includes('node_modules')
83
+
84
+ type Listing = { missing: boolean; linked: LinkedPlugin | null; legacyLinked: boolean }
85
+
86
+ /**
87
+ * The plugin herdr lists and whether the old id is linked beside it; null and false when the list failed or has
88
+ * another shape: then the link is simply made and nothing is unlinked.
89
+ */
90
+ const listedPlugin = (run: HerdrRun, legacyId: string): Listing => {
91
+ const listing = run(['plugin', 'list', '--json'])
92
+ if (listing.missing || listing.exitCode !== 0) return { missing: listing.missing, linked: null, legacyLinked: false }
93
+ try {
94
+ const legacyLinked = legacyId !== HERDR_PLUGIN_ID && linkedPluginOf(listing.output, legacyId) !== null
95
+ return { missing: false, linked: linkedPluginOf(listing.output), legacyLinked }
96
+ } catch {
97
+ return { missing: false, linked: null, legacyLinked: false }
98
+ }
99
+ }
100
+
101
+ /** Unlinks the old id once the current one is linked; the line to print. */
102
+ const unlinkLegacy = (run: HerdrRun, legacyId: string): string => {
103
+ const result = run(['plugin', 'unlink', legacyId])
104
+ return result.exitCode === 0
105
+ ? `Unlinked the herdr plugin ${legacyId}, which the new one replaces.`
106
+ : `Could not unlink the old herdr plugin ${legacyId}, herdr said: ${result.output || `exit ${result.exitCode}`}`
107
+ }
108
+
109
+ const joinLines = (...lines: (string | null)[]): string | null => {
110
+ const present = lines.filter((line): line is string => line !== null)
111
+ return present.length > 0 ? present.join('\n') : null
112
+ }
113
+
114
+ /**
115
+ * Hooks the kit's herdr plugin into herdr when `init` or `sync` runs inside herdr (`HERDR_ENV=1`), and links it
116
+ * again when herdr's link points at another directory or herdr cannot read its manifest (`herdr plugin link`
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;
119
+ * - an installed package only replaces a missing or unreadable link and leaves a working one standing, also one to
120
+ * another installed package: otherwise the global package and a project's dependency would take it from each other;
121
+ * - a lane's worktree never links, since its path goes away with the lane, and only names a broken link.
122
+ * Returns the line to print, or null when there is nothing to say. A failed link is a hint in the returned line,
123
+ * never an error: herdr is optional.
124
+ */
125
+ export const linkHerdrPlugin = ({
126
+ packageRoot,
127
+ env,
128
+ run,
129
+ copy,
130
+ legacyPluginId = LEGACY_HERDR_PLUGIN_ID,
131
+ }: LinkRequest): string | null => {
132
+ if (env.HERDR_ENV !== '1') return null
133
+ const { missing, linked, legacyLinked } = listedPlugin(run, legacyPluginId)
134
+ if (missing || copy === PACKAGE_COPY.laneWorktree) return missing ? null : laneNote(linked, packageRoot)
135
+ const unlink = () => (legacyLinked ? unlinkLegacy(run, legacyPluginId) : null)
136
+ const directory = join(packageRoot, HERDR_PLUGIN_DIRECTORY)
137
+ const reason = relinkReasonOf(linked, directory)
138
+ if (reason === null) return unlink()
139
+ if (copy === PACKAGE_COPY.installed && reason === RELINK_REASON.elsewhere) return null
140
+ const result = run(['plugin', 'link', directory])
141
+ if (result.exitCode !== 0)
142
+ return `Could not link the herdr plugin, herdr said: ${result.output || `exit ${result.exitCode}`}`
143
+ const linkedLine = linked
144
+ ? `Relinked the herdr plugin from ${linked.root} to ${directory} (${reasonText(reason, linked)}).`
145
+ : 'Linked the herdr plugin (the pane and the action for the plans view).'
146
+ return joinLines(linkedLine, unlink())
147
+ }
148
+
149
+ /** herdr as `HerdrRun`: the binary of `herdrBinary`, run without a shell. */
150
+ const spawnHerdr =
151
+ (env: Record<string, string | undefined>): HerdrRun =>
152
+ (args) =>
153
+ spawnTool(env)(herdrBinary(env), args)
154
+
155
+ const realPathOf = (path: string): string => {
156
+ try {
157
+ return realpathSync(path)
158
+ } catch {
159
+ return path
160
+ }
161
+ }
162
+
163
+ /** 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 => {
165
+ if (isLinkedWorktree(packageRoot)) return PACKAGE_COPY.laneWorktree
166
+ return isInstalledCopy(realPathOf(packageRoot)) ? PACKAGE_COPY.installed : PACKAGE_COPY.checkout
167
+ }
168
+
169
+ export const linkHerdrPluginFrom = (packageRoot: string): string | null =>
170
+ linkHerdrPlugin({
171
+ packageRoot,
172
+ env: process.env,
173
+ run: spawnHerdr(process.env),
174
+ copy: packageCopyOf(packageRoot),
175
+ })
@@ -0,0 +1,109 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { LEGACY_MANIFEST_PATH } from '../config/legacy-names'
4
+ import { MANIFEST_FILE, TOOL_DIRECTORY } from '../config/location'
5
+ import { CLI_NAME, isNewerVersion, PACKAGE_NAME } from '../version/release'
6
+ import { parseManifest } from './manifest'
7
+ import { FILE_MODE } from './plan-file'
8
+ import { assertOwnDirectoriesInProject, writeFileSafely } from './project-fs'
9
+
10
+ /** When the npm registry was last asked for a newer version, in epoch milliseconds. */
11
+ export type UpdateState = { checkedAt: number }
12
+
13
+ /** How `--if-changed` learns the time and the newest version on npm; null when npm gave no answer. */
14
+ export type UpdateCheck = { now: () => number; latestVersion: () => Promise<string | null> }
15
+
16
+ export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
17
+
18
+ const REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`
19
+ const REGISTRY_TIMEOUT_MS = 3_000
20
+ const UPDATE_STATE_FILE = 'update-check.json'
21
+
22
+ /**
23
+ * A sync is due when the installed package is not the version the global lock was written by. Without a lock
24
+ * there is no global install to keep current: that takes `init --global`, never a hook.
25
+ */
26
+ export const syncNeeded = (installed: string, locked: string | null): boolean => locked !== null && installed !== locked
27
+
28
+ /** npm is asked at most once a day; a check stamped in the future (a clock set back) does not block it. */
29
+ export const updateCheckDue = (state: UpdateState | null, now: number): boolean =>
30
+ state === null || state.checkedAt > now || now - state.checkedAt >= UPDATE_CHECK_INTERVAL_MS
31
+
32
+ /**
33
+ * The line a `SessionStart` hook hands the session as context when npm has a newer release. It tells the session
34
+ * to ask the user through a dialog before running `jh update`; the hook installs nothing itself. Only
35
+ * a plain release version is interpolated, since this text becomes part of the session's prompt.
36
+ */
37
+ export const updateNotice = (latest: string | null, installed: string): string | null =>
38
+ latest && isNewerVersion(latest, installed)
39
+ ? `${CLI_NAME} ${latest} is on npm, ${installed} is installed. Ask the user with AskUserQuestion whether to run \`${CLI_NAME} update\` now (update now / not now), and run it only after a yes.`
40
+ : null
41
+
42
+ const readJson = async (path: string): Promise<unknown> => {
43
+ try {
44
+ return JSON.parse(await readFile(path, 'utf8'))
45
+ } catch {
46
+ return null
47
+ }
48
+ }
49
+
50
+ const versionOfLock = async (path: string): Promise<string | null> => {
51
+ const parsed = parseManifest(await readJson(path))
52
+ return parsed.ok ? parsed.manifest.kitVersion : null
53
+ }
54
+
55
+ /** The kit version the global lock was written by, under its old name while the global sync has not moved it. */
56
+ export const lockedVersion = async (home: string): Promise<string | null> =>
57
+ (await versionOfLock(join(home, TOOL_DIRECTORY, MANIFEST_FILE))) ?? versionOfLock(join(home, LEGACY_MANIFEST_PATH))
58
+
59
+ const readUpdateState = async (path: string): Promise<UpdateState | null> => {
60
+ const raw = await readJson(path)
61
+ const checkedAt = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>).checkedAt : undefined
62
+ return typeof checkedAt === 'number' ? { checkedAt } : null
63
+ }
64
+
65
+ /** Stamps the check without following a link at `~/.jackherd` or the file; false when it was refused. */
66
+ const stampUpdateCheck = async (home: string, now: number): Promise<boolean> => {
67
+ try {
68
+ await assertOwnDirectoriesInProject(home)
69
+ await writeFileSafely(
70
+ join(home, TOOL_DIRECTORY),
71
+ UPDATE_STATE_FILE,
72
+ `${JSON.stringify({ checkedAt: now })}\n`,
73
+ FILE_MODE.regular,
74
+ )
75
+ return true
76
+ } catch {
77
+ return false
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The update line, when the daily check is due and npm has a newer release. The check is stamped before npm is
83
+ * asked, so a registry that does not answer is asked again tomorrow, not in every session.
84
+ */
85
+ export const dailyUpdateNotice = async (
86
+ home: string,
87
+ installed: string,
88
+ check: UpdateCheck,
89
+ ): Promise<string | null> => {
90
+ const path = join(home, TOOL_DIRECTORY, UPDATE_STATE_FILE)
91
+ const now = check.now()
92
+ if (!updateCheckDue(await readUpdateState(path), now)) return null
93
+ if (!(await stampUpdateCheck(home, now))) return null
94
+ return updateNotice(await check.latestVersion(), installed)
95
+ }
96
+
97
+ /** Asks the npm registry for the `latest` tag; null on any failure, after a few seconds at most. */
98
+ const registryLatestVersion = async (): Promise<string | null> => {
99
+ try {
100
+ const response = await fetch(REGISTRY_LATEST, { signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS) })
101
+ if (!response.ok) return null
102
+ const { version } = (await response.json()) as { version?: unknown }
103
+ return typeof version === 'string' ? version : null
104
+ } catch {
105
+ return null
106
+ }
107
+ }
108
+
109
+ export const defaultUpdateCheck = (): UpdateCheck => ({ now: () => Date.now(), latestVersion: registryLatestVersion })