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.
- package/CHANGELOG.md +20 -0
- package/README.md +28 -38
- package/assets/icons/skills.svg +1 -0
- package/dist/config.d.ts +12 -1
- package/dist/config.js +35 -2
- package/dist/plan-format.d.ts +24 -1
- package/dist/plan-format.js +25 -5
- package/docs/plan-contract.md +84 -0
- package/docs/skills-dark.svg +15 -2
- package/docs/skills.svg +15 -2
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/herdr-plugin/restore-plans.ts +56 -6
- package/install.sh +1 -1
- package/kit/hooks/hooks/merge-guard/board-settings.ts +5 -2
- package/kit/hooks/hooks/merge-guard/pull-request.ts +28 -15
- package/kit/hooks/hooks/merge-guard/run.ts +6 -3
- package/kit/hooks/hooks/merge-guard/settings.ts +12 -1
- package/kit/hooks/hooks/model-log/transcript.ts +4 -7
- package/kit/hooks/hooks/open-question/record.ts +4 -3
- package/kit/hooks/hooks/shared/dialog-answers.ts +7 -3
- package/kit/hooks/hooks/shared/log-vocabulary.ts +13 -0
- package/kit/hooks/hooks/shared/transcript.ts +7 -4
- package/kit/hooks/hooks/task-hint/decision.ts +3 -2
- package/kit/hooks/hooks/task-hint/run.ts +2 -4
- package/kit/rules/rules/kit/pull-requests.md +1 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +24 -10
- package/kit/workflow/skills/orchestrate/handover/index.ts +8 -2
- package/kit/workflow/skills/orchestrate/references/adopt-plan.md +51 -0
- package/kit/workflow/skills/orchestrate/references/events.md +6 -1
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +7 -6
- package/kit/workflow/skills/start/SKILL.md +6 -2
- package/kit/workflow/skills/start/references/missing-commands.md +17 -0
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +43 -0
- package/src/check/command.ts +53 -7
- package/src/check/file-kinds.ts +144 -0
- package/src/check/parallel-report.ts +10 -3
- package/src/check/plan.ts +118 -16
- package/src/check/report.ts +44 -1
- package/src/check/roots.ts +9 -0
- package/src/check/schema.ts +12 -0
- package/src/check/settings.ts +11 -3
- package/src/check/skill-budget-defaults.ts +21 -7
- package/src/check/skill-budgets.ts +49 -19
- package/src/claude/paths.ts +12 -0
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +27 -0
- package/src/context/fill.ts +2 -1
- package/src/context/log-vocabulary.ts +22 -0
- package/src/context/session-log.ts +2 -1
- package/src/lane-prompt/build.ts +10 -5
- package/src/lane-prompt/command.ts +6 -1
- package/src/lane-prompt/input.ts +4 -4
- package/src/lane-prompt/texts.ts +18 -10
- package/src/plan-contract/command.ts +69 -0
- package/src/plan-contract/contract.ts +144 -0
- package/src/plan-format/dependencies.ts +1 -4
- package/src/plan-format/index.ts +4 -0
- package/src/plan-format/lanes.ts +11 -0
- package/src/plan-format/markdown.ts +6 -5
- package/src/plan-format/model-table.ts +27 -0
- package/src/plans/activity-read.ts +98 -0
- package/src/plans/ask-coordinator.ts +20 -11
- package/src/plans/lane-plans.ts +2 -1
- package/src/plans/layout.ts +49 -4
- package/src/plans/live.ts +17 -4
- package/src/plans/plan-panes.ts +25 -5
- package/src/plans/snapshot.ts +18 -8
- package/src/plans/subagents-read.ts +4 -3
- package/src/plans/subagents.ts +2 -1
- package/src/plans/texts.ts +27 -1
- package/src/settled/send.ts +80 -4
- package/src/shared/parse-json.ts +8 -0
- package/src/sync/command.ts +17 -10
- package/src/sync/foreign-skills.ts +40 -0
- package/src/sync/global-kit.ts +31 -21
- package/src/sync/init.ts +2 -2
- package/src/sync/kit-exclude.ts +39 -0
- package/src/sync/plan-file.ts +1 -1
- package/src/sync/render.ts +2 -1
- package/src/sync/report.ts +37 -2
- package/src/sync/sync-result.ts +5 -2
- package/src/sync/sync.ts +13 -5
- package/src/task-signals/claude-signals.ts +6 -5
- package/src/watch/command.ts +88 -18
- package/src/watch/lane-message.ts +1 -1
- package/src/watch/lane-progress.ts +57 -3
- package/src/watch/lane-report.ts +67 -2
- package/src/watch/lane-watch.ts +2 -8
- package/src/watch/plan-missing-read.ts +47 -0
- package/src/watch/plan-missing.ts +43 -0
- package/src/watch/reference-paths.ts +1 -0
- package/src/watch/task-parallel-touched.ts +9 -2
- package/src/watch/texts.ts +8 -0
- package/src/watch/watch-events.ts +7 -0
package/src/settled/send.ts
CHANGED
|
@@ -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
|
|
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[] => [
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/sync/command.ts
CHANGED
|
@@ -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
|
|
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
|
+
})
|
package/src/sync/global-kit.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
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) ?
|
|
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
|
-
/**
|
|
73
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|
package/src/sync/plan-file.ts
CHANGED
|
@@ -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,
|
|
83
|
+
return plan(input, FILE_OUTCOME.untracked, cleanUpKitNew(input), undefined)
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
const planConflict = (input: FileInput, rendered: RenderedFile): FilePlan => {
|
package/src/sync/render.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/sync/report.ts
CHANGED
|
@@ -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 [
|
|
144
|
+
return [
|
|
145
|
+
...(result.notes ?? []),
|
|
146
|
+
summary,
|
|
147
|
+
...excludedLines(result.excluded),
|
|
148
|
+
...fileLines,
|
|
149
|
+
...hookLines,
|
|
150
|
+
...hint,
|
|
151
|
+
].join('\n')
|
|
117
152
|
}
|
package/src/sync/sync-result.ts
CHANGED
|
@@ -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
|
-
/**
|
|
9
|
-
|
|
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 {
|
|
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 {
|
|
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 ===
|
|
80
|
-
if (block.name ===
|
|
81
|
-
if (block.name ===
|
|
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 ===
|
|
90
|
-
if (block.type ===
|
|
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
|
|