jackherd 1.13.0 → 1.15.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 (95) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +28 -38
  3. package/assets/icons/skills.svg +1 -0
  4. package/dist/config.d.ts +12 -1
  5. package/dist/config.js +35 -2
  6. package/dist/plan-format.d.ts +24 -1
  7. package/dist/plan-format.js +25 -5
  8. package/docs/plan-contract.md +84 -0
  9. package/docs/skills-dark.svg +15 -2
  10. package/docs/skills.svg +15 -2
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/herdr-plugin/restore-plans.ts +56 -6
  13. package/install.sh +1 -1
  14. package/kit/hooks/hooks/merge-guard/board-settings.ts +5 -2
  15. package/kit/hooks/hooks/merge-guard/pull-request.ts +28 -15
  16. package/kit/hooks/hooks/merge-guard/run.ts +6 -3
  17. package/kit/hooks/hooks/merge-guard/settings.ts +12 -1
  18. package/kit/hooks/hooks/model-log/transcript.ts +4 -7
  19. package/kit/hooks/hooks/open-question/record.ts +4 -3
  20. package/kit/hooks/hooks/shared/dialog-answers.ts +7 -3
  21. package/kit/hooks/hooks/shared/log-vocabulary.ts +13 -0
  22. package/kit/hooks/hooks/shared/transcript.ts +7 -4
  23. package/kit/hooks/hooks/task-hint/decision.ts +3 -2
  24. package/kit/hooks/hooks/task-hint/run.ts +2 -4
  25. package/kit/rules/rules/kit/pull-requests.md +1 -0
  26. package/kit/workflow/skills/orchestrate/SKILL.md +24 -10
  27. package/kit/workflow/skills/orchestrate/handover/index.ts +8 -2
  28. package/kit/workflow/skills/orchestrate/references/adopt-plan.md +51 -0
  29. package/kit/workflow/skills/orchestrate/references/events.md +6 -1
  30. package/kit/workflow/skills/orchestrate/references/plan-commit.md +7 -6
  31. package/kit/workflow/skills/start/SKILL.md +6 -2
  32. package/kit/workflow/skills/start/references/missing-commands.md +17 -0
  33. package/package.json +1 -1
  34. package/schemas/jackherd.schema.json +43 -0
  35. package/src/check/command.ts +53 -7
  36. package/src/check/file-kinds.ts +144 -0
  37. package/src/check/parallel-report.ts +10 -3
  38. package/src/check/plan.ts +118 -16
  39. package/src/check/report.ts +44 -1
  40. package/src/check/roots.ts +9 -0
  41. package/src/check/schema.ts +12 -0
  42. package/src/check/settings.ts +11 -3
  43. package/src/check/skill-budget-defaults.ts +21 -7
  44. package/src/check/skill-budgets.ts +49 -19
  45. package/src/claude/paths.ts +12 -0
  46. package/src/cli.ts +1 -0
  47. package/src/config/schema.ts +27 -0
  48. package/src/context/fill.ts +2 -1
  49. package/src/context/log-vocabulary.ts +22 -0
  50. package/src/context/session-log.ts +2 -1
  51. package/src/lane-prompt/build.ts +10 -5
  52. package/src/lane-prompt/command.ts +6 -1
  53. package/src/lane-prompt/input.ts +4 -4
  54. package/src/lane-prompt/texts.ts +18 -10
  55. package/src/plan-contract/command.ts +69 -0
  56. package/src/plan-contract/contract.ts +144 -0
  57. package/src/plan-format/dependencies.ts +1 -4
  58. package/src/plan-format/index.ts +4 -0
  59. package/src/plan-format/lanes.ts +11 -0
  60. package/src/plan-format/markdown.ts +6 -5
  61. package/src/plan-format/model-table.ts +27 -0
  62. package/src/plans/activity-read.ts +98 -0
  63. package/src/plans/ask-coordinator.ts +20 -11
  64. package/src/plans/lane-plans.ts +2 -1
  65. package/src/plans/layout.ts +49 -4
  66. package/src/plans/live.ts +17 -4
  67. package/src/plans/plan-panes.ts +25 -5
  68. package/src/plans/snapshot.ts +18 -8
  69. package/src/plans/subagents-read.ts +4 -3
  70. package/src/plans/subagents.ts +2 -1
  71. package/src/plans/texts.ts +27 -1
  72. package/src/settled/send.ts +80 -4
  73. package/src/shared/parse-json.ts +8 -0
  74. package/src/sync/command.ts +17 -10
  75. package/src/sync/foreign-skills.ts +40 -0
  76. package/src/sync/global-kit.ts +31 -21
  77. package/src/sync/init.ts +2 -2
  78. package/src/sync/kit-exclude.ts +39 -0
  79. package/src/sync/plan-file.ts +1 -1
  80. package/src/sync/render.ts +2 -1
  81. package/src/sync/report.ts +37 -2
  82. package/src/sync/sync-result.ts +5 -2
  83. package/src/sync/sync.ts +13 -5
  84. package/src/task-signals/claude-signals.ts +6 -5
  85. package/src/watch/command.ts +88 -18
  86. package/src/watch/lane-message.ts +1 -1
  87. package/src/watch/lane-progress.ts +57 -3
  88. package/src/watch/lane-report.ts +67 -2
  89. package/src/watch/lane-watch.ts +2 -8
  90. package/src/watch/plan-missing-read.ts +47 -0
  91. package/src/watch/plan-missing.ts +43 -0
  92. package/src/watch/reference-paths.ts +1 -0
  93. package/src/watch/task-parallel-touched.ts +9 -2
  94. package/src/watch/texts.ts +8 -0
  95. package/src/watch/watch-events.ts +7 -0
@@ -1,17 +1,80 @@
1
+ import { z } from 'zod'
1
2
  import { TERMINAL_ADAPTER, type TerminalAdapter } from '../config'
2
3
  import { errorText } from '../plans/error-text'
3
- import type { HerdrRunner } from '../terminal/herdr'
4
+ import { parseJsonOrNull } from '../shared/parse-json'
5
+ import type { HerdrAsyncRunner, HerdrRunner } from '../terminal/herdr'
6
+ import { AGENT_STATUS } from '../terminal/types'
4
7
 
5
8
  /** Hands a text to a lane's agent; returns the error text, or null when the agent took it. */
6
9
  export type LaneSender = (lane: string, message: string) => string | null
7
10
 
11
+ /** As `LaneSender`, for a caller that must not hold the event loop while herdr answers. */
12
+ export type AsyncLaneSender = (lane: string, message: string) => Promise<string | null>
13
+
14
+ /** How long herdr itself waits for a working state before it reports `agent_prompt_stalled`. */
15
+ export const HERDR_STALL_WINDOW_MS = 5_000
16
+
17
+ /** Longer than herdr's own stall window, so a stalled paste is reported as stalled and not as a timeout. */
18
+ export const PROMPT_CONFIRM_TIMEOUT_MS = 10_000
19
+
20
+ /** The error codes of `herdr agent prompt --wait` that mean the agent never started working. */
21
+ const HERDR_PROMPT_ERROR = { stalled: 'agent_prompt_stalled', timeout: 'timeout' } as const
22
+
23
+ const herdrErrorSchema = z
24
+ .object({ error: z.object({ code: z.string(), message: z.string().optional() }).loose() })
25
+ .loose()
26
+
27
+ type HerdrError = z.infer<typeof herdrErrorSchema>['error']
28
+
29
+ /** What came before herdr's JSON (the command's name), and the error in it. */
30
+ type ParsedHerdrError = { prefix: string; error: HerdrError }
31
+
32
+ const notWorking = (reason: string): string => `the agent did not start working after the prompt (${reason})`
33
+
34
+ const PROMPT_FAILURE_TEXT: Record<string, string> = {
35
+ [HERDR_PROMPT_ERROR.stalled]: notWorking(
36
+ `herdr: the text sits as a paste, nothing happened within ${HERDR_STALL_WINDOW_MS / 1000} s`,
37
+ ),
38
+ [HERDR_PROMPT_ERROR.timeout]: notWorking(`herdr: no working state within ${PROMPT_CONFIRM_TIMEOUT_MS / 1000} s`),
39
+ }
40
+
41
+ const NO_TERMINAL = `no terminal to reach the lane (terminal.adapter is ${TERMINAL_ADAPTER.none})`
42
+
8
43
  /**
9
44
  * `herdr agent prompt <lane> <text>`, the way the coordinator prompts a lane: the text as one argument of the
10
45
  * process, so no shell expands its backticks or `$(…)`.
11
46
  */
12
- export const promptArguments = (lane: string, message: string): string[] => ['agent', 'prompt', lane, message]
47
+ export const promptArguments = (lane: string, message: string): string[] => [
48
+ 'agent',
49
+ 'prompt',
50
+ lane,
51
+ message,
52
+ '--wait',
53
+ '--until',
54
+ AGENT_STATUS.working,
55
+ '--timeout',
56
+ String(PROMPT_CONFIRM_TIMEOUT_MS),
57
+ ]
13
58
 
14
- const NO_TERMINAL = `no terminal to reach the lane (terminal.adapter is ${TERMINAL_ADAPTER.none})`
59
+ /** The `{"error": …}` herdr prints after the command's name, or null for any other text. */
60
+ const herdrErrorOf = (text: string): ParsedHerdrError | null => {
61
+ const start = text.indexOf('{')
62
+ const parsed = start < 0 ? null : herdrErrorSchema.safeParse(parseJsonOrNull(text.slice(start)))
63
+ if (!parsed?.success) return null
64
+ return { prefix: text.slice(0, start).replace(/:\s*$/, ''), error: parsed.data.error }
65
+ }
66
+
67
+ const unknownFailureText = ({ prefix, error }: ParsedHerdrError): string =>
68
+ [prefix, error.code, error.message].filter((part) => part !== undefined && part !== '').join(': ')
69
+
70
+ /** Turns the text of a failed `herdr agent prompt` into one that says what happened; unknown text stays as it is. */
71
+ export const promptFailureText = (text: string): string => {
72
+ const parsed = herdrErrorOf(text)
73
+ if (parsed === null) return text
74
+ return PROMPT_FAILURE_TEXT[parsed.error.code] ?? unknownFailureText(parsed)
75
+ }
76
+
77
+ const sendFailureText = (error: unknown): string => promptFailureText(errorText(error))
15
78
 
16
79
  /** The sender for the configured terminal; without one (`none`) every send fails with the reason. */
17
80
  export const laneSenderFor =
@@ -22,6 +85,19 @@ export const laneSenderFor =
22
85
  run(promptArguments(lane, message))
23
86
  return null
24
87
  } catch (error) {
25
- return errorText(error)
88
+ return sendFailureText(error)
89
+ }
90
+ }
91
+
92
+ /** As `laneSenderFor`, awaiting herdr instead of holding the event loop for up to `PROMPT_CONFIRM_TIMEOUT_MS`. */
93
+ export const laneSenderForAsync =
94
+ (adapter: TerminalAdapter, run: HerdrAsyncRunner): AsyncLaneSender =>
95
+ async (lane, message) => {
96
+ if (adapter === TERMINAL_ADAPTER.none) return NO_TERMINAL
97
+ try {
98
+ await run(promptArguments(lane, message))
99
+ return null
100
+ } catch (error) {
101
+ return sendFailureText(error)
26
102
  }
27
103
  }
@@ -0,0 +1,8 @@
1
+ /** The parsed value of `text`, or null when it is not JSON. */
2
+ export const parseJsonOrNull = (text: string): unknown => {
3
+ try {
4
+ return JSON.parse(text)
5
+ } catch {
6
+ return null
7
+ }
8
+ }
@@ -11,8 +11,9 @@ import {
11
11
  usageError,
12
12
  } from './command-context'
13
13
  import { errorMessage } from './error-message'
14
+ import { readForeignSkills } from './foreign-skills'
14
15
  import { dailyUpdateNotice, defaultUpdateCheck, lockedVersion, syncNeeded, type UpdateCheck } from './if-changed'
15
- import { SYNC_EXIT } from './report'
16
+ import { foreignSkillLines, SYNC_EXIT } from './report'
16
17
  import { migrateGlobalSettings } from './settings-migration'
17
18
  import { runGlobalSync, runSync } from './sync'
18
19
 
@@ -101,6 +102,20 @@ const syncGlobalIfChanged = async (context: CommandContext, accept: string[], ch
101
102
  return exitCode
102
103
  }
103
104
 
105
+ const syncProject = (context: CommandContext, accept: string[], dryRun: boolean): Promise<number> =>
106
+ runWithKit(
107
+ context,
108
+ 'sync',
109
+ async (kit) => {
110
+ const result = await runSync({ projectRoot: context.projectRoot, home: context.home, kit, accept, check: dryRun })
111
+ const foreignLines = foreignSkillLines(result, readForeignSkills(context.projectRoot))
112
+ const foreign = foreignLines.length > 0 ? [foreignLines.join('\n')] : []
113
+ const dryRunNote = dryRun ? [`${CLI_NAME} sync ${CHECK_FLAG}: nothing is written`] : []
114
+ return { notes: [...dryRunNote, ...foreign], result }
115
+ },
116
+ dryRun,
117
+ )
118
+
104
119
  export const runSyncCommand = async (
105
120
  args: string[],
106
121
  context: CommandContext = defaultContext(),
@@ -113,15 +128,7 @@ export const runSyncCommand = async (
113
128
  if (global) return syncGlobal(context, accept)
114
129
  if (isConfigWithoutInstall(context.projectRoot, existsSync))
115
130
  return usageError(context, notInstalledLine(context.projectRoot))
116
- return runWithKit(
117
- context,
118
- 'sync',
119
- async (kit) => ({
120
- notes: dryRun ? [`${CLI_NAME} sync ${CHECK_FLAG}: nothing is written`] : [],
121
- result: await runSync({ projectRoot: context.projectRoot, home: context.home, kit, accept, check: dryRun }),
122
- }),
123
- dryRun,
124
- )
131
+ return syncProject(context, accept, dryRun)
125
132
  }
126
133
 
127
134
  export const run = (args: string[]): Promise<number> => runSyncCommand(args)
@@ -0,0 +1,40 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { z } from 'zod'
4
+ import { SKILLS_DIRECTORY } from '../claude/paths'
5
+
6
+ /** Written by `npx skills add` into the project root; only the skill names are read. */
7
+ export const SKILLS_LOCK_FILE = 'skills-lock.json'
8
+
9
+ const skillsLockSchema = z.object({ skills: z.record(z.string(), z.unknown()) }).loose()
10
+
11
+ const SKILL_ENTRY_FILE = 'SKILL.md'
12
+ const SKILL_ENTRY = new RegExp(`(?:^|/)${SKILLS_DIRECTORY}/([^/]+)/${SKILL_ENTRY_FILE.replace('.', '\\.')}$`)
13
+
14
+ /** The kit path (relative to `.claude/`) of a skill's entry file. */
15
+ export const skillEntryPath = (name: string): string => `${SKILLS_DIRECTORY}/${name}/${SKILL_ENTRY_FILE}`
16
+
17
+ /** The skill an entry file path belongs to, from a kit path or a project path; undefined for any other file. */
18
+ export const skillNameOfEntry = (path: string): string | undefined => SKILL_ENTRY.exec(path)?.[1]
19
+
20
+ /** The skill names `skills-lock.json` lists; anything that is not that file's shape is the empty set. */
21
+ export const parseForeignSkills = (value: unknown): ReadonlySet<string> => {
22
+ const parsed = skillsLockSchema.safeParse(value)
23
+ return new Set(parsed.success ? Object.keys(parsed.data.skills) : [])
24
+ }
25
+
26
+ /** The foreign skills of a project: the empty set when the lock file is missing, unreadable or invalid. */
27
+ export const readForeignSkills = (root: string): ReadonlySet<string> => {
28
+ try {
29
+ return parseForeignSkills(JSON.parse(readFileSync(join(root, SKILLS_LOCK_FILE), 'utf8')))
30
+ } catch {
31
+ return new Set()
32
+ }
33
+ }
34
+
35
+ /** Of the kit paths (relative to `.claude/`), the names of the skills whose entry file a foreign skill sits on. */
36
+ export const foreignSkillNamesOf = (kitPaths: readonly string[], foreign: ReadonlySet<string>): string[] =>
37
+ kitPaths.flatMap((path) => {
38
+ const name = skillNameOfEntry(path)
39
+ return name !== undefined && foreign.has(name) ? [name] : []
40
+ })
@@ -1,7 +1,8 @@
1
- import { AGENTS_DIRECTORY, claudePath, RULES_DIRECTORY, SKILLS_DIRECTORY } from '../claude/paths'
1
+ import { AGENT_PATH, claudePath, prefixedKitPath, RULES_DIRECTORY, SKILL_PATH, SKILLS_DIRECTORY } from '../claude/paths'
2
2
  import { SKILL_PREFIX } from '../config'
3
3
  import { TOOL_DIRECTORY } from '../config/location'
4
4
  import { REPOSITORY_TOKENS } from '../config/repository-values'
5
+ import { escapeRegExp } from '../plan-format/markdown'
5
6
  import { CLI_NAME, PACKAGE_NAME } from '../version/release'
6
7
  import type { KitFile, KitSource } from './kit'
7
8
  import { TOOL_ROOT } from './kit-path'
@@ -15,8 +16,6 @@ const GLOBAL_RULES_DIRECTORY = `${TOOL_ROOT}/${RULES_DIRECTORY}`
15
16
  /** Put at the top of a global file that names values each repository decides; `sync --global` renders them as `<name>`. */
16
17
  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
 
18
- const SKILL_PATH = new RegExp(`^${SKILLS_DIRECTORY}/([^/]+)/(.+)$`)
19
- const AGENT_PATH = new RegExp(`^${AGENTS_DIRECTORY}/([^/]+)\\.md$`)
20
19
  const SKILL_ENTRY = new RegExp(`^${SKILLS_DIRECTORY}/[^/]+/SKILL\\.md$`)
21
20
  const RULE_PATH = new RegExp(`^${RULES_DIRECTORY}/kit/(.+)$`)
22
21
 
@@ -24,8 +23,6 @@ const FRONTMATTER = /^---\n([\s\S]*?)\n---\n/
24
23
  const NAME_LINE = /^name: (.+)$/m
25
24
  const DESCRIPTION_LINE = /^description: '(.*)'$/m
26
25
 
27
- const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
28
-
29
26
  export const globalNote = (name: string, kind: EntryKind): string =>
30
27
  `Global ${PACKAGE_NAME} copy. In a project that has its own ${name} ${kind}, use that one instead. `
31
28
 
@@ -38,20 +35,12 @@ const assertUsablePrefix = (prefix: string): void => {
38
35
  /** Where the status line lands: `~/.jackherd/statusline.sh`; `init --global` offers it from there. */
39
36
  const GLOBAL_STATUS_LINE_PATH = `${TOOL_ROOT}/statusline.sh`
40
37
 
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
38
  /** The path in `~/.claude`, or null for what stays out of a global install (the hooks). */
50
39
  const globalPath = (path: string, prefix: string): string | null => {
51
40
  if (path === 'statusline.sh') return GLOBAL_STATUS_LINE_PATH
52
41
  const rule = RULE_PATH.exec(path)
53
42
  if (rule) return `${GLOBAL_RULES_DIRECTORY}/${rule[1]}`
54
- return SKILL_PATH.test(path) || AGENT_PATH.test(path) ? projectPath(path, prefix) : null
43
+ return SKILL_PATH.test(path) || AGENT_PATH.test(path) ? prefixedKitPath(path, prefix) : null
55
44
  }
56
45
 
57
46
  const entryKind = (path: string): EntryKind | null => {
@@ -69,16 +58,37 @@ const kitNames = (files: KitFile[]): string[] => {
69
58
  return [...new Set(names)].sort((left, right) => right.length - left.length)
70
59
  }
71
60
 
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 =>
61
+ /**
62
+ * Names as a skill or agent calls another: slash commands, `subagent_type`, backticked names. `rename` gives the new
63
+ * name inside the call, `suffix` follows the whole call, after a closing quote or backtick.
64
+ */
65
+ export const rewriteCalls = (
66
+ text: string,
67
+ alternation: string,
68
+ rename: (name: string) => string,
69
+ suffix = '',
70
+ ): string =>
74
71
  text
75
72
  .replace(
73
+ new RegExp(`(?<![\\w.~/-])/(${alternation})(?![\\w/-])`, 'g'),
74
+ (_call, name: string) => `/${rename(name)}${suffix}`,
75
+ )
76
+ .replace(
77
+ new RegExp(`subagent_type: '(${alternation})'`, 'g'),
78
+ (_call, name: string) => `subagent_type: '${rename(name)}'${suffix}`,
79
+ )
80
+ .replace(new RegExp(`\`(${alternation})\``, 'g'), (_call, name: string) => `\`${rename(name)}\`${suffix}`)
81
+
82
+ /** The calls of `rewriteCalls` and relative links, each with the prefix. */
83
+ const rewriteNames = (text: string, alternation: string, prefix: string): string =>
84
+ rewriteCalls(
85
+ text.replace(
76
86
  new RegExp(`\\.\\./${SKILLS_DIRECTORY}/(${alternation})(?![\\w-])`, 'g'),
77
87
  `../${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\``)
88
+ ),
89
+ alternation,
90
+ (name) => `${prefix}${name}`,
91
+ )
82
92
 
83
93
  const PROJECT_SKILLS = claudePath(SKILLS_DIRECTORY)
84
94
  const GLOBAL_SKILLS = `~/${PROJECT_SKILLS}`
@@ -151,7 +161,7 @@ export const toGlobalKit = (kit: KitSource, prefix: string): KitSource => {
151
161
  }
152
162
 
153
163
  const toProjectFile = (file: KitFile, names: string[], prefix: string): KitFile => {
154
- const path = projectPath(file.path, prefix)
164
+ const path = prefixedKitPath(file.path, prefix)
155
165
  if (!file.path.endsWith('.md') || file.path.startsWith('hooks/')) return { ...file, path }
156
166
  const alternation = names.map(escapeRegExp).join('|')
157
167
  const body = rewriteNames(rewriteSkillPaths(file.template, alternation, prefix, PROJECT_SKILLS), alternation, prefix)
package/src/sync/init.ts CHANGED
@@ -5,6 +5,7 @@ import { type JackherdConfig, parseJackherdConfig, type TerminalAdapter } from '
5
5
  import { type RepositoryFacts, STACKS, withDetected } from '../config/detect'
6
6
  import { CONFIG_FILE, CONFIG_SCHEMA_REFERENCE, TOOL_DIRECTORY } from '../config/location'
7
7
  import { readRepositoryFacts } from '../config/repository-facts'
8
+ import { DEFAULT_SKILL_PREFIX } from '../config/schema'
8
9
  import type { KitSource } from './kit'
9
10
  import { moveGlobalLegacyFiles, moveProjectLegacyFiles } from './legacy-files'
10
11
  import { assertNotHomeDirectory, assertOwnDirectoriesInProject, createFileExclusively } from './project-fs'
@@ -35,8 +36,7 @@ export type InitResult = {
35
36
  terminalAdapter: TerminalAdapter
36
37
  }
37
38
 
38
- /** What a global install, and a project that already has skills of kit names, puts before every kit name. */
39
- export const DEFAULT_SKILL_PREFIX = 'jh-'
39
+ export { DEFAULT_SKILL_PREFIX }
40
40
 
41
41
  const SKILL_ENTRY = new RegExp(`^${SKILLS_DIRECTORY}/([^/]+)/SKILL\\.md$`)
42
42
  const AGENT_ENTRY = new RegExp(`^${AGENTS_DIRECTORY}/([^/]+)\\.md$`)
@@ -0,0 +1,39 @@
1
+ import { AGENT_PATH, SKILL_PATH } from '../claude/paths'
2
+ import { escapeRegExp } from '../plan-format/markdown'
3
+ import { rewriteCalls } from './global-kit'
4
+ import type { KitFile, KitSource } from './kit'
5
+
6
+ /** The skill or agent a kit file belongs to, by its unprefixed name; null for rules, hooks and the status line. */
7
+ const entryNameOf = ({ path }: KitFile): string | null => (SKILL_PATH.exec(path) ?? AGENT_PATH.exec(path))?.[1] ?? null
8
+
9
+ /** Kit texts an agent reads; the hooks' Markdown and every script stay as the kit wrote them. */
10
+ const isAgentText = ({ path }: KitFile): boolean => path.endsWith('.md') && !path.startsWith('hooks/')
11
+
12
+ /**
13
+ * A call of an excluded name reads `/devops (not installed)`, so an agent sees the skill is missing instead of a dead
14
+ * call. Links and `.claude/skills/<name>/` paths stay as they are: a mark inside a path would break the link it is in.
15
+ */
16
+ const markedNotInstalled = (file: KitFile, alternation: string): KitFile =>
17
+ isAgentText(file)
18
+ ? { ...file, template: rewriteCalls(file.template, alternation, (name) => name, ' (not installed)') }
19
+ : file
20
+
21
+ /**
22
+ * The kit without the skills and agents `kit.exclude` names: a name takes its skill directory and its agent alike, and
23
+ * every call of it in the remaining texts is marked as not installed. Applied before `skillPrefix`, so the names are
24
+ * the kit's own. A name the kit has neither of throws, so a typo never reads as excluded.
25
+ */
26
+ export const withoutExcluded = (kit: KitSource, exclude: readonly string[]): KitSource => {
27
+ if (exclude.length === 0) return kit
28
+ const known = new Set(kit.files.map(entryNameOf))
29
+ const unknown = exclude.filter((name) => !known.has(name))
30
+ if (unknown.length > 0) {
31
+ throw new Error(
32
+ `kit.exclude names no kit skill or agent: ${unknown.join(', ')}. Correct the name, or take it off the list if this kit version no longer ships it.`,
33
+ )
34
+ }
35
+ const excluded = new Set(exclude)
36
+ const alternation = exclude.map(escapeRegExp).join('|')
37
+ const files = kit.files.filter((file) => !excluded.has(entryNameOf(file) ?? ''))
38
+ return { ...kit, files: files.map((file) => markedNotInstalled(file, alternation)) }
39
+ }
@@ -80,7 +80,7 @@ const planKitDropped = (input: FileInput): FilePlan => {
80
80
  if (checksumOf(input.local) === input.entry.base) {
81
81
  return plan(input, FILE_OUTCOME.removed, [{ kind: 'remove', target: 'file' }, ...cleanUpKitNew(input)], undefined)
82
82
  }
83
- return plan(input, FILE_OUTCOME.untracked, [], undefined)
83
+ return plan(input, FILE_OUTCOME.untracked, cleanUpKitNew(input), undefined)
84
84
  }
85
85
 
86
86
  const planConflict = (input: FileInput, rendered: RenderedFile): FilePlan => {
@@ -47,6 +47,7 @@ const placeholderValues = (config: JackherdConfig): Map<string, string> => {
47
47
  ...prefixed('guard.standingApproval', STANDING_APPROVAL_TEXTS[config.responseLanguage]),
48
48
  ...prefixed('approval', APPROVAL_TEXTS[config.responseLanguage]),
49
49
  ['kit.merge.withoutChecks', checkFreeList(config.merge.withoutChecks)],
50
+ ['kit.merge.openAsDraft', config.merge.openAsDraft ? 'on' : 'off'],
50
51
  ...prefixed('heading', { ...header, ...sections, ...columns, phase }),
51
52
  ])
52
53
  }
@@ -58,7 +59,7 @@ export const listPlaceholders = (template: string): string[] =>
58
59
  /**
59
60
  * Fills the kit placeholders from the project config: `{{kit.model.<role>}}`,
60
61
  * `{{kit.command.<test|typecheck|lint|format|install|dev>}}`, `{{kit.lanes.<copyCommand|lockFile|verifyInstall>}}`, `{{kit.word.<laneClosed|openSubagents>}}`,
61
- * `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.guard.standingApproval.<approve|until>}}`, `{{kit.merge.withoutChecks}}`, `{{kit.heading.<key>}}` from the plan
62
+ * `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.guard.standingApproval.<approve|until>}}`, `{{kit.merge.<withoutChecks|openAsDraft>}}`, `{{kit.heading.<key>}}` from the plan
62
63
  * vocabulary, the two languages, `{{kit.responseAddress}}`, the plan and worktree directory and the base branch. A
63
64
  * command or lock file the config leaves unset renders as `unsetValueText`, never as a guessed default. Other
64
65
  * `{{…}}` text is left alone; an unknown `kit.` placeholder throws rather than reaching a project
@@ -1,4 +1,7 @@
1
+ import { PROJECT_CONFIG_PATH } from '../config/location'
2
+ import { DEFAULT_SKILL_PREFIX } from '../config/schema'
1
3
  import { CLI_NAME } from '../version/release'
4
+ import { foreignSkillNamesOf, SKILLS_LOCK_FILE, skillEntryPath } from './foreign-skills'
2
5
  import { shownPath } from './kit-path'
3
6
  import { FILE_OUTCOME, type FileOutcome } from './plan-file'
4
7
  import { HOOK_OUTCOME, type HookOutcome } from './plan-hooks'
@@ -61,7 +64,7 @@ const FILE_SECTIONS: { outcome: FileOutcome; heading: string; line: (path: strin
61
64
  },
62
65
  {
63
66
  outcome: FILE_OUTCOME.untracked,
64
- heading: 'No longer in the kit, kept with your changes and no longer tracked',
67
+ heading: 'No longer in the kit or excluded, kept with your changes and no longer tracked',
65
68
  line: (path) => ` ${shownPath(path)}`,
66
69
  },
67
70
  { outcome: FILE_OUTCOME.resolved, heading: 'Conflicts resolved', line: (path) => ` ${shownPath(path)}` },
@@ -78,6 +81,28 @@ const HOOK_SECTIONS: { outcome: HookOutcome; heading: string }[] = [
78
81
 
79
82
  const RESOLVE_HINT = `Resolve a conflict: merge the .kit-new into your file and delete it, or run \`${CLI_NAME} sync --accept <path>\` to take the kit version.`
80
83
 
84
+ const FOREIGN_SKILL_HEADING = `A skill from ${SKILLS_LOCK_FILE} sits on the kit's path, your file was kept`
85
+
86
+ /** A conflict, or a kept file: `npx skills add` overwrites the kit's file, and an unchanged kit reports it as kept. */
87
+ const FOREIGN_SKILL_OUTCOMES: readonly FileOutcome[] = [FILE_OUTCOME.conflict, FILE_OUTCOME.kept]
88
+
89
+ const foreignSkillHint = (names: readonly string[]): string =>
90
+ ` Set "skillPrefix": "${DEFAULT_SKILL_PREFIX}" in ${PROJECT_CONFIG_PATH} and run \`${CLI_NAME} sync\`: the kit moves to ${names.map((name) => `/${DEFAULT_SKILL_PREFIX}${name}`).join(', ')} and the foreign skill stays. Or install the skill as a plugin with its own namespace.`
91
+
92
+ /**
93
+ * The lines for a kit skill that `skills-lock.json` lists and that sync kept or left in conflict, heading and the
94
+ * two ways out; empty when there is none. `foreign` is the set of skill names from that file.
95
+ */
96
+ export const foreignSkillLines = (result: SyncResult, foreign: ReadonlySet<string>): string[] => {
97
+ const occupied = result.files
98
+ .filter(({ outcome }) => FOREIGN_SKILL_OUTCOMES.includes(outcome))
99
+ .map(({ path }) => path)
100
+ const names = foreignSkillNamesOf(occupied, foreign)
101
+ if (names.length === 0) return []
102
+ const lines = names.map((name) => ` ${shownPath(skillEntryPath(name))}`)
103
+ return ['', `${FOREIGN_SKILL_HEADING}:`, ...lines, foreignSkillHint(names)]
104
+ }
105
+
81
106
  export const exitCodeOf = (result: SyncResult): number => {
82
107
  const outcomes = [...result.files, ...result.hooks].map(({ outcome }) => outcome)
83
108
  return outcomes.some((outcome) => NEEDS_ATTENTION.includes(outcome)) ? SYNC_EXIT.conflicts : SYNC_EXIT.clean
@@ -98,6 +123,9 @@ const summaryLine = (outcomes: Outcome[]): string => {
98
123
 
99
124
  const section = (heading: string, lines: string[]): string[] => (lines.length > 0 ? ['', `${heading}:`, ...lines] : [])
100
125
 
126
+ const excludedLines = (excluded: readonly string[] = []): string[] =>
127
+ excluded.length > 0 ? ['', `Left out by kit.exclude: ${excluded.join(', ')}`] : []
128
+
101
129
  export const formatReport = (result: SyncResult): string => {
102
130
  const fileLines = FILE_SECTIONS.flatMap(({ outcome, heading, line }) =>
103
131
  section(
@@ -113,5 +141,12 @@ export const formatReport = (result: SyncResult): string => {
113
141
  )
114
142
  const hint = exitCodeOf(result) === SYNC_EXIT.conflicts ? ['', RESOLVE_HINT] : []
115
143
  const summary = summaryLine([...result.files, ...result.hooks].map(({ outcome }) => outcome))
116
- return [...(result.notes ?? []), summary, ...fileLines, ...hookLines, ...hint].join('\n')
144
+ return [
145
+ ...(result.notes ?? []),
146
+ summary,
147
+ ...excludedLines(result.excluded),
148
+ ...fileLines,
149
+ ...hookLines,
150
+ ...hint,
151
+ ].join('\n')
117
152
  }
@@ -5,5 +5,8 @@ export type FileReport = { path: string; outcome: FileOutcome }
5
5
 
6
6
  export type HookReport = { name: string; outcome: HookOutcome }
7
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[] }
8
+ /**
9
+ * `notes` name the config values the repository did not decide, printed before the summary. `excluded` are the kit
10
+ * skills and agents `kit.exclude` left out, by the kit's name.
11
+ */
12
+ export type SyncResult = { files: FileReport[]; hooks: HookReport[]; notes?: string[]; excluded?: string[] }
package/src/sync/sync.ts CHANGED
@@ -12,6 +12,7 @@ import { emptiedDirectories } from './emptied-directories'
12
12
  import { ensureGitignoreLines } from './gitignore'
13
13
  import { toGlobalKit, toProjectKit } from './global-kit'
14
14
  import type { KitSource } from './kit'
15
+ import { withoutExcluded } from './kit-exclude'
15
16
  import { kitNewPath, toolRootPath } from './kit-path'
16
17
  import { moveGlobalLegacyFiles, moveProjectLegacyFiles, removeEmptyLegacyDirectories } from './legacy-files'
17
18
  import { EMPTY_MANIFEST, type Manifest, parseManifest, serializeManifest } from './manifest'
@@ -297,9 +298,10 @@ const applyPlan = async (
297
298
  return notes
298
299
  }
299
300
 
300
- const syncResultOf = (plan: SyncPlan): SyncResult => ({
301
+ const syncResultOf = (plan: SyncPlan, excluded: readonly string[]): SyncResult => ({
301
302
  files: plan.files.map(({ path, outcome }) => ({ path, outcome })),
302
303
  hooks: plan.hooks.outcomes,
304
+ ...(excluded.length > 0 ? { excluded: [...excluded] } : {}),
303
305
  })
304
306
 
305
307
  /**
@@ -319,7 +321,7 @@ export const syncProjectWithConfig = async (request: SyncRequest): Promise<Confi
319
321
  const directories = { claudeDirectory, toolDirectory, locate: inClaudeDirectory(claudeDirectory) }
320
322
  const plan = await buildPlan({
321
323
  ...directories,
322
- kit: toProjectKit(request.kit, config.skillPrefix),
324
+ kit: toProjectKit(withoutExcluded(request.kit, config.kit.exclude), config.skillPrefix),
323
325
  config,
324
326
  accept: request.accept,
325
327
  readHooks: projectHooks(claudeDirectory, request.kit, config),
@@ -327,7 +329,10 @@ export const syncProjectWithConfig = async (request: SyncRequest): Promise<Confi
327
329
  const keptDirectoryNotes = request.check ? [] : await applyPlan(directories, plan)
328
330
  if (!request.check) await writeConfigRewrite(toolDirectory, read)
329
331
  if (!request.check) await ensureGitignoreLines(request.projectRoot)
330
- return { result: { ...syncResultOf(plan), notes: [...moved, ...notes, ...keptDirectoryNotes] }, config }
332
+ return {
333
+ result: { ...syncResultOf(plan, config.kit.exclude), notes: [...moved, ...notes, ...keptDirectoryNotes] },
334
+ config,
335
+ }
331
336
  }
332
337
 
333
338
  export const runSync = async (request: SyncRequest): Promise<SyncResult> =>
@@ -354,7 +359,7 @@ export const syncGlobalWithConfig = async (request: GlobalSyncRequest): Promise<
354
359
  const directories = { claudeDirectory, toolDirectory, locate: inGlobalDirectories(claudeDirectory, toolDirectory) }
355
360
  const plan = await buildPlan({
356
361
  ...directories,
357
- kit: toGlobalKit(request.kit, config.skillPrefix),
362
+ kit: toGlobalKit(withoutExcluded(request.kit, config.kit.exclude), config.skillPrefix),
358
363
  config,
359
364
  accept: request.accept,
360
365
  readHooks: noHooks,
@@ -363,7 +368,10 @@ export const syncGlobalWithConfig = async (request: GlobalSyncRequest): Promise<
363
368
  const keptDirectoryNotes = await applyPlan(directories, plan)
364
369
  await writeConfigRewrite(toolDirectory, read)
365
370
  await removeEmptyLegacyDirectories(claudeDirectory)
366
- return { result: { ...syncResultOf(plan), notes: [...moved, ...read.notes, ...keptDirectoryNotes] }, config }
371
+ return {
372
+ result: { ...syncResultOf(plan, config.kit.exclude), notes: [...moved, ...read.notes, ...keptDirectoryNotes] },
373
+ config,
374
+ }
367
375
  }
368
376
 
369
377
  export const runGlobalSync = async (request: GlobalSyncRequest): Promise<SyncResult> =>
@@ -1,3 +1,4 @@
1
+ import { CLAUDE_TOOL, CONTENT_BLOCK_TYPE } from '../context/log-vocabulary'
1
2
  import { parseLogEntry } from '../context/session-log'
2
3
  import { isRecord } from '../shared/is-record'
3
4
  import { cleanTaskText } from './text'
@@ -76,9 +77,9 @@ const onTodoWrite = (tracker: Tracker, input: Record<string, unknown>): void =>
76
77
  const onToolUse = (tracker: Tracker, block: Block): void => {
77
78
  const input = block.input
78
79
  if (!isRecord(input)) return
79
- if (block.name === 'TaskCreate' && typeof block.id === 'string') tracker.pending.set(block.id, input)
80
- if (block.name === 'TaskUpdate') onTaskUpdate(tracker, input)
81
- if (block.name === 'TodoWrite') onTodoWrite(tracker, input)
80
+ if (block.name === CLAUDE_TOOL.taskCreate && typeof block.id === 'string') tracker.pending.set(block.id, input)
81
+ if (block.name === CLAUDE_TOOL.taskUpdate) onTaskUpdate(tracker, input)
82
+ if (block.name === CLAUDE_TOOL.todoWrite) onTodoWrite(tracker, input)
82
83
  }
83
84
 
84
85
  const visit = (tracker: Tracker, entry: Record<string, unknown>): void => {
@@ -86,8 +87,8 @@ const visit = (tracker: Tracker, entry: Record<string, unknown>): void => {
86
87
  if (!Number.isNaN(time)) tracker.time = time
87
88
  if (typeof entry.cwd === 'string') tracker.cwd = entry.cwd
88
89
  for (const block of blocksOf(entry)) {
89
- if (block.type === 'tool_use') onToolUse(tracker, block)
90
- if (block.type === 'tool_result') onResult(tracker, entry, block)
90
+ if (block.type === CONTENT_BLOCK_TYPE.toolUse) onToolUse(tracker, block)
91
+ if (block.type === CONTENT_BLOCK_TYPE.toolResult) onResult(tracker, entry, block)
91
92
  }
92
93
  }
93
94