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/check/plan.ts
CHANGED
|
@@ -3,9 +3,10 @@ import { STACKS, type Stack, type StackProfile } from '../config/detect'
|
|
|
3
3
|
import { agentTextReviewers } from './agent-text-reviewers'
|
|
4
4
|
import { type ChangedFile, generatorOwnedFiles, stagedBuildArtifacts } from './changes'
|
|
5
5
|
import { tokenize } from './command-line'
|
|
6
|
+
import { hasNamedTest, isSourceFile, isTestFile, testStemsOf } from './file-kinds'
|
|
6
7
|
import { matchesAnyGlob } from './glob'
|
|
7
8
|
import { CHECK_KIND, type CheckKind } from './result'
|
|
8
|
-
import { isUnder, looseDirectories, packageRootsFor } from './roots'
|
|
9
|
+
import { isUnder, looseDirectories, nearestRootOf, packageRootOf, packageRootsFor } from './roots'
|
|
9
10
|
import { projectChecksFor, publicContentReviewers, reviewersFor } from './routing'
|
|
10
11
|
import type { CheckConfig, ProjectCheck } from './schema'
|
|
11
12
|
|
|
@@ -21,7 +22,7 @@ export type PlannedCheck = {
|
|
|
21
22
|
kind: CheckKind
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
export type PackageCommands = Pick<JackherdConfig['commands'], 'test' | 'typecheck'>
|
|
25
|
+
export type PackageCommands = Pick<JackherdConfig['commands'], 'test' | 'typecheck' | 'lint'>
|
|
25
26
|
|
|
26
27
|
/** What the plan asks of the disk, by the markers of the stack (`checkStackOf`). */
|
|
27
28
|
export type Filesystem = {
|
|
@@ -32,9 +33,11 @@ export type Filesystem = {
|
|
|
32
33
|
/** Whether the directory holds a type marker of the stack (`tsconfig.json`, `phpstan.neon`). */
|
|
33
34
|
hasTypeConfig: (dir: string) => boolean
|
|
34
35
|
hasTests: (dir: string) => boolean
|
|
36
|
+
/** Every file of the repository that git tracks or does not ignore; read only for the name rule. */
|
|
37
|
+
repositoryFiles: () => readonly string[]
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
/** The stack as the check reads it;
|
|
40
|
+
/** The stack as the check reads it; with none or several detected, every stack's markers and patterns together. */
|
|
38
41
|
export type CheckStack = {
|
|
39
42
|
rootMarkers: string[]
|
|
40
43
|
typeMarkers: string[]
|
|
@@ -44,6 +47,14 @@ export type CheckStack = {
|
|
|
44
47
|
lintGlobs: string[]
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
/** What the check reads only where no stack is detected at all (not where several are). */
|
|
51
|
+
export type NoStackFallback = {
|
|
52
|
+
/** `check.roots` and the repository root `.`: a file no root marker holds belongs to the nearest one above it. */
|
|
53
|
+
roots: readonly string[]
|
|
54
|
+
/** `commands.lint`, run once from `.` where no `check.lintFilesCommand` is set. */
|
|
55
|
+
lintCommand?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
47
58
|
/** What the plan reads from the settings: the test and type commands, the `check` block and the stack's patterns. */
|
|
48
59
|
export type PlanInputs = {
|
|
49
60
|
commands: PackageCommands
|
|
@@ -51,6 +62,8 @@ export type PlanInputs = {
|
|
|
51
62
|
stack: Pick<CheckStack, 'lintGlobs' | 'sourceFile'>
|
|
52
63
|
/** Repository paths of the kit's copies (`.jackherd/lock.json`); their tests run where the kit keeps the source. */
|
|
53
64
|
kitCopies: readonly string[]
|
|
65
|
+
/** Null where a stack is detected, or several. */
|
|
66
|
+
noStackFallback: NoStackFallback | null
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
export type CheckPlan = {
|
|
@@ -63,8 +76,22 @@ export type CheckPlan = {
|
|
|
63
76
|
checks: PlannedCheck[]
|
|
64
77
|
/** The changed source files of the stack no planned test reaches, kit copies aside; a check without a test is no green. */
|
|
65
78
|
untestedSources: string[]
|
|
79
|
+
/**
|
|
80
|
+
* Without a detected stack, the changed source files under a root found by `NoStackFallback.roots` that no test file
|
|
81
|
+
* of their name covers: a run from such a root reaches every file, so the name decides, and only as a warning.
|
|
82
|
+
*/
|
|
83
|
+
sourcesWithoutNamedTest: string[]
|
|
66
84
|
}
|
|
67
85
|
|
|
86
|
+
/** The roots of a change: by root marker, and without a detected stack by `NoStackFallback.roots` for the rest. */
|
|
87
|
+
type ChangeRoots = { roots: string[]; fallbackRoots: string[]; fallbackPaths: string[] }
|
|
88
|
+
|
|
89
|
+
/** What reaches a changed file: a test run from a directory above it, or a fired project check of kind tests whose globs (or no globs) match it. */
|
|
90
|
+
type TestReach = { dirs: readonly string[]; checks: readonly ProjectCheck[] }
|
|
91
|
+
|
|
92
|
+
/** Which changed source files no test covers: the paths under marker roots by reach, the fallback paths by name. */
|
|
93
|
+
type Coverage = Pick<CheckPlan, 'untestedSources' | 'sourcesWithoutNamedTest'>
|
|
94
|
+
|
|
68
95
|
export const isSlow = (check: PlannedCheck): boolean =>
|
|
69
96
|
check.kind === CHECK_KIND.types || check.kind === CHECK_KIND.tests
|
|
70
97
|
|
|
@@ -92,6 +119,36 @@ export const checkStackOf = (stack: Stack | null): CheckStack => {
|
|
|
92
119
|
const lintedPaths = (paths: readonly string[], lintGlobs: readonly string[]): string[] =>
|
|
93
120
|
lintGlobs.length === 0 ? [...paths] : paths.filter((path) => matchesAnyGlob(path, lintGlobs))
|
|
94
121
|
|
|
122
|
+
/** A predicate from the configured globs, or the given one where none are configured. */
|
|
123
|
+
const globsOr =
|
|
124
|
+
(globs: readonly string[] | undefined, fallback: (path: string) => boolean) =>
|
|
125
|
+
(path: string): boolean =>
|
|
126
|
+
globs === undefined ? fallback(path) : matchesAnyGlob(path, globs)
|
|
127
|
+
|
|
128
|
+
/** Each path no root marker holds, with the nearest fallback root above it; paths without one are left out. */
|
|
129
|
+
const fallbackRootsOf = (
|
|
130
|
+
paths: readonly string[],
|
|
131
|
+
fallback: readonly string[],
|
|
132
|
+
isProjectRoot: Filesystem['isProjectRoot'],
|
|
133
|
+
): { path: string; root: string }[] =>
|
|
134
|
+
paths.flatMap((path) => {
|
|
135
|
+
const root = packageRootOf(path, isProjectRoot) === null ? nearestRootOf(path, fallback) : null
|
|
136
|
+
return root === null ? [] : [{ path, root }]
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
const changeRootsOf = (
|
|
140
|
+
paths: readonly string[],
|
|
141
|
+
fallback: readonly string[],
|
|
142
|
+
{ isProjectRoot, isWorkspaceRoot }: Pick<Filesystem, 'isProjectRoot' | 'isWorkspaceRoot'>,
|
|
143
|
+
): ChangeRoots => {
|
|
144
|
+
const isTestable = (root: string) => !isWorkspaceRoot(root)
|
|
145
|
+
const fallbackPairs = fallbackRootsOf(paths, fallback, isProjectRoot)
|
|
146
|
+
const fallbackPaths = fallbackPairs.map(({ path }) => path)
|
|
147
|
+
const fallbackRoots = unique(fallbackPairs.map(({ root }) => root)).filter(isTestable)
|
|
148
|
+
const markerRoots = packageRootsFor(paths, isProjectRoot).filter(isTestable)
|
|
149
|
+
return { roots: unique([...markerRoots, ...fallbackRoots]), fallbackRoots, fallbackPaths }
|
|
150
|
+
}
|
|
151
|
+
|
|
95
152
|
/** No lint without a `lintFilesCommand`, none is guessed; none either when no changed file is the stack's to lint. */
|
|
96
153
|
const lintChecks = (command: string | undefined, paths: readonly string[]): PlannedCheck[] =>
|
|
97
154
|
command === undefined || paths.length === 0
|
|
@@ -106,6 +163,17 @@ const lintChecks = (command: string | undefined, paths: readonly string[]): Plan
|
|
|
106
163
|
},
|
|
107
164
|
]
|
|
108
165
|
|
|
166
|
+
/** Without a detected stack and a `lintFilesCommand`, the project's lint once from the repository root. */
|
|
167
|
+
const projectLint = (command: string | undefined): PlannedCheck[] =>
|
|
168
|
+
command === undefined ? [] : [{ name: 'Lint', command, argv: tokenize(command), cwd: '.', kind: CHECK_KIND.lint }]
|
|
169
|
+
|
|
170
|
+
const lintOf = (
|
|
171
|
+
lintFilesCommand: string | undefined,
|
|
172
|
+
noStackFallback: NoStackFallback | null,
|
|
173
|
+
paths: readonly string[],
|
|
174
|
+
): PlannedCheck[] =>
|
|
175
|
+
lintFilesCommand === undefined ? projectLint(noStackFallback?.lintCommand) : lintChecks(lintFilesCommand, paths)
|
|
176
|
+
|
|
109
177
|
const projectChecks = (fired: readonly ProjectCheck[]): PlannedCheck[] =>
|
|
110
178
|
fired.map(({ name, command, kind }) => ({
|
|
111
179
|
name,
|
|
@@ -127,18 +195,15 @@ const inDirectory = (kind: CheckKind, label: string, command: string, dir: strin
|
|
|
127
195
|
const typeChecks = (
|
|
128
196
|
roots: readonly string[],
|
|
129
197
|
command: string | undefined,
|
|
130
|
-
|
|
198
|
+
isTypeRoot: (root: string) => boolean,
|
|
131
199
|
): PlannedCheck[] =>
|
|
132
200
|
command === undefined
|
|
133
201
|
? []
|
|
134
|
-
: roots.filter(
|
|
202
|
+
: roots.filter(isTypeRoot).map((root) => inDirectory(CHECK_KIND.types, 'Types', command, root))
|
|
135
203
|
|
|
136
204
|
const testChecks = (dirs: readonly string[], command: string | undefined): PlannedCheck[] =>
|
|
137
205
|
command === undefined ? [] : dirs.map((dir) => inDirectory(CHECK_KIND.tests, 'Tests', command, dir))
|
|
138
206
|
|
|
139
|
-
/** What reaches a changed file: a test run from a directory above it, or a fired project check of kind tests whose globs (or no globs) match it. */
|
|
140
|
-
type TestReach = { dirs: readonly string[]; checks: readonly ProjectCheck[] }
|
|
141
|
-
|
|
142
207
|
const isReached = (path: string, { dirs, checks }: TestReach): boolean =>
|
|
143
208
|
dirs.some((dir) => isUnder(path, dir)) ||
|
|
144
209
|
checks.some(({ globs }) => globs === undefined || matchesAnyGlob(path, globs))
|
|
@@ -147,8 +212,43 @@ const isReached = (path: string, { dirs, checks }: TestReach): boolean =>
|
|
|
147
212
|
const untestedSourcesOf = (
|
|
148
213
|
paths: readonly string[],
|
|
149
214
|
reach: TestReach,
|
|
150
|
-
{
|
|
151
|
-
): string[] => paths.filter((path) =>
|
|
215
|
+
{ isSource, kitCopies }: { isSource: (path: string) => boolean; kitCopies: readonly string[] },
|
|
216
|
+
): string[] => paths.filter((path) => isSource(path) && !kitCopies.includes(path) && !isReached(path, reach))
|
|
217
|
+
|
|
218
|
+
/** The source files among `paths` with no test file of their name in the repository, read only when there are any. */
|
|
219
|
+
const sourcesWithoutNamedTestOf = (
|
|
220
|
+
paths: readonly string[],
|
|
221
|
+
testChecks: readonly ProjectCheck[],
|
|
222
|
+
{ check, kitCopies }: Pick<PlanInputs, 'check' | 'kitCopies'>,
|
|
223
|
+
repositoryFiles: Filesystem['repositoryFiles'],
|
|
224
|
+
): string[] => {
|
|
225
|
+
const isTest = globsOr(check.testFiles, isTestFile)
|
|
226
|
+
const isNamedSource = globsOr(check.sourceFiles, isSourceFile)
|
|
227
|
+
const isSource = (path: string) => !isTest(path) && isNamedSource(path)
|
|
228
|
+
const candidates = untestedSourcesOf(paths, { dirs: [], checks: testChecks }, { isSource, kitCopies })
|
|
229
|
+
if (candidates.length === 0) return []
|
|
230
|
+
const testStems = testStemsOf(repositoryFiles(), isTest)
|
|
231
|
+
return candidates.filter((path) => !hasNamedTest(path, testStems))
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const coverageOf = (
|
|
235
|
+
{ paths, fallbackPaths }: { paths: readonly string[]; fallbackPaths: readonly string[] },
|
|
236
|
+
reach: TestReach,
|
|
237
|
+
{ check, stack, kitCopies }: Pick<PlanInputs, 'check' | 'stack' | 'kitCopies'>,
|
|
238
|
+
repositoryFiles: Filesystem['repositoryFiles'],
|
|
239
|
+
): Coverage => {
|
|
240
|
+
const markerPaths = paths.filter((path) => !fallbackPaths.includes(path))
|
|
241
|
+
const isSource = globsOr(check.sourceFiles, (path) => stack.sourceFile.test(path))
|
|
242
|
+
return {
|
|
243
|
+
untestedSources: untestedSourcesOf(markerPaths, reach, { isSource, kitCopies }),
|
|
244
|
+
sourcesWithoutNamedTest: sourcesWithoutNamedTestOf(
|
|
245
|
+
fallbackPaths,
|
|
246
|
+
reach.checks,
|
|
247
|
+
{ check, kitCopies },
|
|
248
|
+
repositoryFiles,
|
|
249
|
+
),
|
|
250
|
+
}
|
|
251
|
+
}
|
|
152
252
|
|
|
153
253
|
/** The reviewers a set of changed paths calls for, each named once: routed, public content and agent texts. */
|
|
154
254
|
export const reviewersOf = (paths: readonly string[], config: CheckConfig): string[] => [
|
|
@@ -162,15 +262,17 @@ export const reviewersOf = (paths: readonly string[], config: CheckConfig): stri
|
|
|
162
262
|
/**
|
|
163
263
|
* Decides, without touching the disk or running anything, what a set of changed files calls for:
|
|
164
264
|
* which package roots to type-check and test, which project checks fire, which reviewers to start,
|
|
165
|
-
* and which changed source files no test covers. A root that gathers workspaces is never tested.
|
|
265
|
+
* and which changed source files no test covers. A root that gathers workspaces is never tested. Without a
|
|
266
|
+
* detected stack, a file no root marker holds is tested from its `NoStackFallback.roots` entry and judged by its name.
|
|
166
267
|
*/
|
|
167
268
|
export const planChecks = (
|
|
168
269
|
files: readonly ChangedFile[],
|
|
169
|
-
{ commands, check: config, stack, kitCopies }: PlanInputs,
|
|
270
|
+
{ commands, check: config, stack, kitCopies, noStackFallback }: PlanInputs,
|
|
170
271
|
filesystem: Filesystem,
|
|
171
272
|
): CheckPlan => {
|
|
172
273
|
const paths = files.map((file) => file.path)
|
|
173
|
-
const roots =
|
|
274
|
+
const { roots, fallbackRoots, fallbackPaths } = changeRootsOf(paths, noStackFallback?.roots ?? [], filesystem)
|
|
275
|
+
const isTypeRoot = (root: string) => fallbackRoots.includes(root) || filesystem.hasTypeConfig(root)
|
|
174
276
|
const loose = config.looseTests ? looseDirectories(paths, roots, filesystem.hasTests) : []
|
|
175
277
|
const fired = projectChecksFor(paths, config.checks)
|
|
176
278
|
const testDirs = commands.test === undefined ? [] : [...roots, ...loose]
|
|
@@ -178,9 +280,9 @@ export const planChecks = (
|
|
|
178
280
|
paths.length === 0
|
|
179
281
|
? []
|
|
180
282
|
: [
|
|
181
|
-
...
|
|
283
|
+
...lintOf(config.lintFilesCommand, noStackFallback, lintedPaths(paths, stack.lintGlobs)),
|
|
182
284
|
...projectChecks(fired),
|
|
183
|
-
...typeChecks(roots, commands.typecheck,
|
|
285
|
+
...typeChecks(roots, commands.typecheck, isTypeRoot),
|
|
184
286
|
...testChecks(testDirs, commands.test),
|
|
185
287
|
]
|
|
186
288
|
const reach: TestReach = { dirs: testDirs, checks: fired.filter((check) => check.kind === CHECK_KIND.tests) }
|
|
@@ -191,6 +293,6 @@ export const planChecks = (
|
|
|
191
293
|
generated: generatorOwnedFiles(paths, config.generatorOwned),
|
|
192
294
|
artifacts: stagedBuildArtifacts(files, config.buildArtifacts),
|
|
193
295
|
checks,
|
|
194
|
-
|
|
296
|
+
...coverageOf({ paths, fallbackPaths }, reach, { check: config, stack, kitCopies }, filesystem.repositoryFiles),
|
|
195
297
|
}
|
|
196
298
|
}
|
package/src/check/report.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { STACKS, type StackDetection, stackOfCommand } from '../config/detect'
|
|
2
|
+
import { PROJECT_CONFIG_PATH } from '../config/location'
|
|
2
3
|
import { CLI_NAME } from '../version/release'
|
|
3
4
|
import { type ChangedFile, STATUS_COMMAND } from './changes'
|
|
4
|
-
import type { CheckPlan } from './plan'
|
|
5
|
+
import type { CheckPlan, PackageCommands } from './plan'
|
|
5
6
|
import { CHECK_STATUS, type CheckResult, type CheckStatus, RUN_MODE, type RunMode, SKIP_SLOW_FLAG } from './result'
|
|
6
7
|
|
|
7
8
|
const STATUS_ICON = { pass: '✅', fail: '❌', warn: '⚠️ ', skipped: '⏭️ ' } as const
|
|
@@ -45,6 +46,48 @@ export const untestedResult = (sources: readonly string[], mode: RunMode): Check
|
|
|
45
46
|
durationMs: 0,
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/** Without a detected stack, the changed source files no test file of their name covers: a warning in every run mode. */
|
|
50
|
+
export const namedTestResult = (sources: readonly string[]): CheckResult | null =>
|
|
51
|
+
sources.length === 0
|
|
52
|
+
? null
|
|
53
|
+
: {
|
|
54
|
+
name: 'Tests by name',
|
|
55
|
+
command: 'check.testFiles',
|
|
56
|
+
status: CHECK_STATUS.warn,
|
|
57
|
+
detail: [
|
|
58
|
+
`no test file named like ${plural(sources.length, 'changed source file')}; add one, or name the test files in \`check.testFiles\``,
|
|
59
|
+
...sources,
|
|
60
|
+
].join('\n'),
|
|
61
|
+
durationMs: 0,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Without a detected stack nothing derives `commands.test`, and a check without it tests nothing, so it fails. */
|
|
65
|
+
export const missingTestCommandResult = (command: string | undefined, detection: StackDetection): CheckResult | null =>
|
|
66
|
+
command !== undefined || detection.kind !== 'none'
|
|
67
|
+
? null
|
|
68
|
+
: {
|
|
69
|
+
name: 'Test command',
|
|
70
|
+
command: 'commands.test',
|
|
71
|
+
status: CHECK_STATUS.fail,
|
|
72
|
+
detail: `no stack detected and no \`commands.test\` set; set it in ${PROJECT_CONFIG_PATH} (\`${CLI_NAME} config test\` names the line, \`/jh-start\` proposes it from the CI)`,
|
|
73
|
+
durationMs: 0,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Without a detected stack a missing type or lint command is a note, not a result: not every language has the step. */
|
|
77
|
+
export const missingCommandNotes = (
|
|
78
|
+
commands: PackageCommands,
|
|
79
|
+
lintFilesCommand: string | undefined,
|
|
80
|
+
detection: StackDetection,
|
|
81
|
+
): string[] => {
|
|
82
|
+
if (detection.kind !== 'none') return []
|
|
83
|
+
return [
|
|
84
|
+
...(commands.typecheck === undefined ? [`${CLI_NAME}: no \`commands.typecheck\` set; no type check runs.`] : []),
|
|
85
|
+
...(commands.lint === undefined && lintFilesCommand === undefined
|
|
86
|
+
? [`${CLI_NAME}: neither \`check.lintFilesCommand\` nor \`commands.lint\` set; no lint runs.`]
|
|
87
|
+
: []),
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
|
|
48
91
|
/** The lock files of known stacks the repository holds; none means it has not installed yet. */
|
|
49
92
|
const presentLockFiles = (detection: StackDetection): readonly string[] => {
|
|
50
93
|
if (detection.kind === 'found') return [detection.lockFile]
|
package/src/check/roots.ts
CHANGED
|
@@ -46,6 +46,15 @@ export const kitCopyPaths = (manifest: unknown): string[] => {
|
|
|
46
46
|
/** Whether a test run from `root` reaches `path`; from `.` it reaches every path. */
|
|
47
47
|
export const isUnder = (path: string, root: string): boolean => root === '.' || path.startsWith(`${root}/`)
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The deepest of `roots` above `path`; null when none is. Without a detected stack, a file no root marker holds
|
|
51
|
+
* belongs to the nearest `check.roots` entry, with the repository root `.` among them.
|
|
52
|
+
*/
|
|
53
|
+
export const nearestRootOf = (path: string, roots: readonly string[]): string | null =>
|
|
54
|
+
roots
|
|
55
|
+
.filter((root) => isUnder(path, root))
|
|
56
|
+
.reduce<string | null>((nearest, root) => (nearest === null || root.length > nearest.length ? root : nearest), null)
|
|
57
|
+
|
|
49
58
|
/**
|
|
50
59
|
* Directories with test files that belong to no package root, which would otherwise be tested by
|
|
51
60
|
* nobody. The repository root `.` is excluded: a test run from there walks the whole repository.
|
package/src/check/schema.ts
CHANGED
|
@@ -20,6 +20,9 @@ const glob = nonEmpty.refine((value) => !/[{}[\]]/.test(value), 'Braces and brac
|
|
|
20
20
|
|
|
21
21
|
const globs = z.array(glob).min(1)
|
|
22
22
|
|
|
23
|
+
/** A directory from the repository root as the check compares it: `./crates/core/` reads as `crates/core`. */
|
|
24
|
+
const directory = nonEmpty.transform((value) => value.replace(/^(\.\/)+/, '').replace(/\/+$/, '') || '.')
|
|
25
|
+
|
|
23
26
|
/** Staged output that belongs to a build, never to a commit. */
|
|
24
27
|
export const DEFAULT_BUILD_ARTIFACT_GLOBS = [
|
|
25
28
|
'**/tsup.config.bundled_*',
|
|
@@ -88,6 +91,15 @@ export const checkConfigSchema = z.strictObject({
|
|
|
88
91
|
skillBudgets: skillBudgets.optional(),
|
|
89
92
|
/** Also run tests in directories that hold test files but belong to no package. */
|
|
90
93
|
looseTests: z.boolean().default(false),
|
|
94
|
+
/**
|
|
95
|
+
* Package roots, as directories from the repository root, read only where no stack is detected: a changed file
|
|
96
|
+
* belongs to the nearest one above it, else to the repository root `.`.
|
|
97
|
+
*/
|
|
98
|
+
roots: z.array(directory).min(1).optional(),
|
|
99
|
+
/** The files a test covers; replaces the stack's pattern, or the rule of thumb where no stack is detected. */
|
|
100
|
+
sourceFiles: globs.optional(),
|
|
101
|
+
/** The test files; replaces the stack's pattern, or the rule of thumb where no stack is detected. */
|
|
102
|
+
testFiles: globs.optional(),
|
|
91
103
|
})
|
|
92
104
|
|
|
93
105
|
export type CheckConfig = z.infer<typeof checkConfigSchema>
|
package/src/check/settings.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from '../config/detect'
|
|
11
11
|
import { PROJECT_CONFIG_PATH } from '../config/location'
|
|
12
12
|
import type { PlanLanguage } from '../vocabulary'
|
|
13
|
-
import type { PackageCommands } from './plan'
|
|
13
|
+
import type { NoStackFallback, PackageCommands } from './plan'
|
|
14
14
|
import { type CheckConfig, checkConfigSchema } from './schema'
|
|
15
15
|
|
|
16
16
|
export const CONFIG_PATH = PROJECT_CONFIG_PATH
|
|
@@ -25,6 +25,8 @@ export type CheckSettings = {
|
|
|
25
25
|
baseBranch: string
|
|
26
26
|
/** Where the lane worktrees live, below the main checkout: a board's plans are read there too. */
|
|
27
27
|
worktreeDirectory: string
|
|
28
|
+
/** Put before the kit's skill and agent names in this project; empty without one. */
|
|
29
|
+
skillPrefix: string
|
|
28
30
|
/** What the lock files (without one, the manifests) decide: one stack, none, or several (ambiguous); `stackOf` reads the stack from it. */
|
|
29
31
|
stack: StackDetection
|
|
30
32
|
notes: string[]
|
|
@@ -33,11 +35,16 @@ export type CheckSettings = {
|
|
|
33
35
|
/** The stack a detection decided, by lock file or manifest; null for none or several, where the check reads every stack's markers. */
|
|
34
36
|
export const stackOf = (detection: StackDetection): Stack | null => (isDecided(detection) ? detection.stack : null)
|
|
35
37
|
|
|
38
|
+
/** Without any detected stack: `check.roots` and the repository root `.` as fallback roots, `commands.lint` from the root. */
|
|
39
|
+
export const noStackFallbackOf = ({ stack, check, commands }: CheckSettings): NoStackFallback | null =>
|
|
40
|
+
stack.kind === 'none' ? { roots: [...(check.roots ?? []), '.'], lintCommand: commands.lint } : null
|
|
41
|
+
|
|
36
42
|
const commandsSchema = jackherdConfigSchema.shape.commands
|
|
37
43
|
const planDirectorySchema = jackherdConfigSchema.shape.planDirectory
|
|
38
44
|
const planLanguageSchema = jackherdConfigSchema.shape.planLanguage
|
|
39
45
|
const baseBranchSchema = jackherdConfigSchema.shape.baseBranch
|
|
40
46
|
const worktreeDirectorySchema = jackherdConfigSchema.shape.worktreeDirectory
|
|
47
|
+
const skillPrefixSchema = jackherdConfigSchema.shape.skillPrefix
|
|
41
48
|
|
|
42
49
|
/**
|
|
43
50
|
* The check runner's view of `.jackherd/config.json`: the shared `commands` block, `planDirectory`, `planLanguage` and `baseBranch` plus its own
|
|
@@ -47,14 +54,15 @@ const worktreeDirectorySchema = jackherdConfigSchema.shape.worktreeDirectory
|
|
|
47
54
|
export const parseCheckSettings = (raw: unknown, facts: RepositoryFacts): CheckSettings => {
|
|
48
55
|
const detected = withDetected(z.record(z.string(), z.unknown()).parse(raw), facts)
|
|
49
56
|
const object = z.record(z.string(), z.unknown()).parse(detected.raw)
|
|
50
|
-
const { test, typecheck } = commandsSchema.parse(object.commands)
|
|
57
|
+
const { test, typecheck, lint } = commandsSchema.parse(object.commands)
|
|
51
58
|
return {
|
|
52
|
-
commands: { test, typecheck },
|
|
59
|
+
commands: { test, typecheck, lint },
|
|
53
60
|
check: checkConfigSchema.parse(object.check ?? {}),
|
|
54
61
|
planDirectory: planDirectorySchema.parse(object.planDirectory),
|
|
55
62
|
planLanguage: planLanguageSchema.parse(object.planLanguage),
|
|
56
63
|
baseBranch: baseBranchSchema.parse(object.baseBranch),
|
|
57
64
|
worktreeDirectory: worktreeDirectorySchema.parse(object.worktreeDirectory),
|
|
65
|
+
skillPrefix: skillPrefixSchema.parse(object.skillPrefix),
|
|
58
66
|
stack: detectStack(facts.lockFiles, facts.manifests),
|
|
59
67
|
notes: detected.notes,
|
|
60
68
|
}
|
|
@@ -21,18 +21,30 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
|
|
|
21
21
|
'.claude/skills/implement/SKILL.md': 2190,
|
|
22
22
|
// P33: the paw in the header chip, panes by tab and title, the lane in the context line of a passed-on question.
|
|
23
23
|
// Plain words: no plan code without its words in a short question.
|
|
24
|
-
|
|
24
|
+
// Any language: a pointer in § 0 and a section in events.md for `plan missing`.
|
|
25
|
+
// Open up: a pointer in § 0 to adopt-plan.md, and § 2 step 8 waits until the lane works on its prompt (+113);
|
|
26
|
+
// step 0 lets the adoption diff stand for its question, step 2 bases on origin only for an unchanged plan.
|
|
27
|
+
// Cross-check: measured in a repository whose stack nothing detects, every unset command renders as a note (+7).
|
|
28
|
+
// Ready phase: the `done` / `idle` row names the watcher's mandatory `ready:` line (+20).
|
|
29
|
+
// Drafts until the merge: § 5 step 5 opens a draft and readies it, Self-merge step 3 waits for the runs after it (+100).
|
|
30
|
+
'.claude/skills/orchestrate/SKILL.md': 8230,
|
|
31
|
+
// skill-review: the copy is taken after a mirror, and rejecting together with a remark is a remark (+36).
|
|
32
|
+
// Cross-check: an adopted plan also gets short names and a no-review-gate line, which `jh check` asks of a new
|
|
33
|
+
// plan; the proof became a word diff, since a short name goes into an existing line (+74); skill-review: the
|
|
34
|
+
// done check names `jh check`, the gate and short-name rules as the code reads them (+24).
|
|
35
|
+
'.claude/skills/orchestrate/references/adopt-plan.md': 680,
|
|
25
36
|
'.claude/skills/orchestrate/references/acceptance.md': 200,
|
|
26
37
|
'.claude/skills/orchestrate/references/close-lane.md': 250,
|
|
27
38
|
'.claude/skills/orchestrate/references/coordinator-handover.md': 551,
|
|
28
39
|
'.claude/skills/orchestrate/references/draft-review.md': 300,
|
|
29
40
|
// P33: a source entry the user must read or decide becomes a row of what waits on them, and when it closes.
|
|
30
|
-
'.claude/skills/orchestrate/references/events.md':
|
|
41
|
+
'.claude/skills/orchestrate/references/events.md': 1166,
|
|
31
42
|
'.claude/skills/orchestrate/references/herd.md': 299,
|
|
32
43
|
'.claude/skills/orchestrate/references/lane-handover.md': 280,
|
|
33
44
|
'.claude/skills/orchestrate/references/lane-session.md': 280,
|
|
34
45
|
'.claude/skills/orchestrate/references/lane-setup.md': 380,
|
|
35
|
-
|
|
46
|
+
// Open up: an adopted plan is committed like a missing one, and a tracked copy is restored after the merge.
|
|
47
|
+
'.claude/skills/orchestrate/references/plan-commit.md': 350,
|
|
36
48
|
'.claude/skills/orchestrate/references/sensitive-input.md': 269,
|
|
37
49
|
'.claude/skills/plan/SKILL.md': 1920,
|
|
38
50
|
'.claude/skills/plan/references/template-English.md': 536,
|
|
@@ -40,10 +52,11 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
|
|
|
40
52
|
'.claude/skills/pre-commit/SKILL.md': 1003,
|
|
41
53
|
'.claude/skills/review/SKILL.md': 319,
|
|
42
54
|
'.claude/skills/security-audit/SKILL.md': 519,
|
|
43
|
-
|
|
55
|
+
// Any language: step 3 points to proposing the missing commands from the CI; the proposal itself is a reference.
|
|
56
|
+
'.claude/skills/start/SKILL.md': 630,
|
|
44
57
|
'.claude/skills/uncle-bob/SKILL.md': 466,
|
|
45
|
-
// P33: grows with the skill above.
|
|
46
|
-
'.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md':
|
|
58
|
+
// P33 and open up: grows with the skill above.
|
|
59
|
+
'.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 8880,
|
|
47
60
|
'.claude/agents/devops.md': 104,
|
|
48
61
|
'.claude/agents/explore.md': 132,
|
|
49
62
|
'.claude/agents/implement.md': 303,
|
|
@@ -54,6 +67,7 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
|
|
|
54
67
|
'.claude/agents/spec-review.md': 1137,
|
|
55
68
|
'.claude/agents/uncle-bob.md': 111,
|
|
56
69
|
'.claude/rules/kit/code-style.md': 1664,
|
|
57
|
-
|
|
70
|
+
// Drafts until the merge: a precondition opens a draft when `merge.openAsDraft` says so (+39).
|
|
71
|
+
'.claude/rules/kit/pull-requests.md': 640,
|
|
58
72
|
'.claude/rules/kit/testing.md': 741,
|
|
59
73
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import { CLAUDE_DIRECTORY, claudePath, SKILLS_DIRECTORY } from '../claude/paths'
|
|
3
|
+
import { CLAUDE_DIRECTORY, claudePath, prefixedKitPath, SKILLS_DIRECTORY } from '../claude/paths'
|
|
4
4
|
import { projectLockCandidates } from '../config/legacy-names'
|
|
5
|
+
import { parseJsonOrNull } from '../shared/parse-json'
|
|
6
|
+
import { readForeignSkills, SKILLS_LOCK_FILE, skillEntryPath, skillNameOfEntry } from '../sync/foreign-skills'
|
|
5
7
|
import { parseManifest } from '../sync/manifest'
|
|
6
8
|
import { matchesGlob } from './glob'
|
|
7
9
|
import { CHECK_STATUS, type CheckResult, type CheckStatus } from './result'
|
|
@@ -17,9 +19,10 @@ import {
|
|
|
17
19
|
export type BudgetTable = Readonly<SkillBudgets>
|
|
18
20
|
|
|
19
21
|
/** A file with the budget it falls back on when neither the project nor the kit names one. */
|
|
20
|
-
export type FileWords = { path: string; words: number; fallback: number }
|
|
22
|
+
export type FileWords = { path: string; words: number; fallback: number; foreign?: boolean }
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
/** A `foreign` measurement belongs to a skill `skills-lock.json` lists: it warns, it never fails. */
|
|
25
|
+
export type Measurement = { label: string; words: number; budget: number; foreign?: boolean }
|
|
23
26
|
|
|
24
27
|
type Overrun = typeof CHECK_STATUS.warn | typeof CHECK_STATUS.fail
|
|
25
28
|
|
|
@@ -31,6 +34,7 @@ const KIT_RULE = /^rules\//
|
|
|
31
34
|
const PATHS_IN_FRONTMATTER = /^---\r?\n(?:(?!---)[^\n]*\n)*?paths:/
|
|
32
35
|
const SUM_SEPARATOR = ' + '
|
|
33
36
|
const FAIL_FACTOR = 1.1
|
|
37
|
+
const FOREIGN_LABEL = `(foreign, ${SKILLS_LOCK_FILE})`
|
|
34
38
|
|
|
35
39
|
const OVERRUN_LABEL: Record<Overrun, string> = { warn: 'warning', fail: 'error' }
|
|
36
40
|
|
|
@@ -65,6 +69,23 @@ export const budgetOf = (path: string, project: BudgetTable, defaults: BudgetTab
|
|
|
65
69
|
return project[path] ?? (glob === undefined ? undefined : project[glob]) ?? defaults[path] ?? fallback
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
/** Every budget path starts with `.claude/` (the kit-content test holds them to shipped files). */
|
|
73
|
+
const prefixedPath = (path: string, prefix: string): string =>
|
|
74
|
+
claudePath(prefixedKitPath(path.slice(CLAUDE_DIRECTORY.length + 1), prefix))
|
|
75
|
+
|
|
76
|
+
/** The kit's budgets under the paths a project with `skillPrefix` installs its skills and agents at; supplements keep theirs. */
|
|
77
|
+
export const prefixedBudgets = (budgets: BudgetTable, prefix: string): BudgetTable =>
|
|
78
|
+
prefix === ''
|
|
79
|
+
? budgets
|
|
80
|
+
: Object.fromEntries(
|
|
81
|
+
Object.entries(budgets).map(([key, words]) => [
|
|
82
|
+
budgetKeyPaths(key)
|
|
83
|
+
.map((path) => prefixedPath(path, prefix))
|
|
84
|
+
.join(SUM_SEPARATOR),
|
|
85
|
+
words,
|
|
86
|
+
]),
|
|
87
|
+
)
|
|
88
|
+
|
|
68
89
|
const sumEntry = (key: string, files: readonly FileWords[]): number | null => {
|
|
69
90
|
const parts = budgetKeyPaths(key).map((part) => files.find((file) => file.path === part))
|
|
70
91
|
return parts.every((part) => part !== undefined) ? parts.reduce((total, part) => total + part.words, 0) : null
|
|
@@ -76,10 +97,11 @@ export const measureFiles = (
|
|
|
76
97
|
project: BudgetTable,
|
|
77
98
|
defaults: BudgetTable,
|
|
78
99
|
): Measurement[] => {
|
|
79
|
-
const single = files.map(({ path, words, fallback }) => ({
|
|
80
|
-
label: path,
|
|
100
|
+
const single = files.map(({ path, words, fallback, foreign }) => ({
|
|
101
|
+
label: foreign ? `${path} ${FOREIGN_LABEL}` : path,
|
|
81
102
|
words,
|
|
82
103
|
budget: budgetOf(path, project, defaults, fallback),
|
|
104
|
+
foreign,
|
|
83
105
|
}))
|
|
84
106
|
const sums = [...new Set([...Object.keys(project), ...Object.keys(defaults)])]
|
|
85
107
|
.filter((key) => key.includes(SUM_SEPARATOR))
|
|
@@ -90,6 +112,11 @@ export const measureFiles = (
|
|
|
90
112
|
return [...single, ...sums]
|
|
91
113
|
}
|
|
92
114
|
|
|
115
|
+
const overrunOfMeasurement = ({ words, budget, foreign }: Measurement): Overrun | null => {
|
|
116
|
+
const overrun = overrunOf(words, budget)
|
|
117
|
+
return foreign && overrun === CHECK_STATUS.fail ? CHECK_STATUS.warn : overrun
|
|
118
|
+
}
|
|
119
|
+
|
|
93
120
|
const overrunLine = ({ entry: { label, words, budget }, overrun }: MeasuredOverrun): string =>
|
|
94
121
|
`${OVERRUN_LABEL[overrun]}: ${label} has ${words} words, budget ${budget}`
|
|
95
122
|
|
|
@@ -104,7 +131,7 @@ const statusOf = (overruns: readonly MeasuredOverrun[]): CheckStatus => {
|
|
|
104
131
|
/** One line per overrun; one line in total when none is over. Warns above a budget, fails above the budget plus 10 %. */
|
|
105
132
|
export const budgetResult = (measured: readonly Measurement[], durationMs: number): CheckResult => {
|
|
106
133
|
const overruns = measured.flatMap((entry) => {
|
|
107
|
-
const overrun =
|
|
134
|
+
const overrun = overrunOfMeasurement(entry)
|
|
108
135
|
return overrun === null ? [] : [{ entry, overrun }]
|
|
109
136
|
})
|
|
110
137
|
return {
|
|
@@ -124,19 +151,11 @@ const readText = (path: string): string | null => {
|
|
|
124
151
|
}
|
|
125
152
|
}
|
|
126
153
|
|
|
127
|
-
const parseJson = (text: string): unknown => {
|
|
128
|
-
try {
|
|
129
|
-
return JSON.parse(text)
|
|
130
|
-
} catch {
|
|
131
|
-
return null
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
154
|
/** The kit documents the first valid lock file lists, the lock under its old name counting while sync has not moved it. */
|
|
136
155
|
const kitPathsOf = (root: string): string[] => {
|
|
137
156
|
for (const candidate of projectLockCandidates(root)) {
|
|
138
157
|
const text = readText(candidate)
|
|
139
|
-
const parsed = text === null ? null : parseManifest(
|
|
158
|
+
const parsed = text === null ? null : parseManifest(parseJsonOrNull(text))
|
|
140
159
|
if (parsed?.ok) return Object.keys(parsed.manifest.files).filter((key) => KIT_DOCUMENT.test(key))
|
|
141
160
|
}
|
|
142
161
|
return []
|
|
@@ -159,9 +178,17 @@ const kitFileWordsOf = (root: string, kitPath: string): FileWords[] => {
|
|
|
159
178
|
|
|
160
179
|
const ownSkillPaths = (root: string, kit: ReadonlySet<string>): string[] =>
|
|
161
180
|
listDirectory(join(root, CLAUDE_DIRECTORY, SKILLS_DIRECTORY))
|
|
162
|
-
.map((name) => claudePath(
|
|
181
|
+
.map((name) => claudePath(skillEntryPath(name)))
|
|
163
182
|
.filter((path) => !kit.has(path))
|
|
164
183
|
|
|
184
|
+
/** Marks the entry file of a skill `skills-lock.json` lists; any other file is left as it is. */
|
|
185
|
+
const markForeign =
|
|
186
|
+
(foreign: ReadonlySet<string>) =>
|
|
187
|
+
(file: FileWords): FileWords => {
|
|
188
|
+
const name = skillNameOfEntry(file.path)
|
|
189
|
+
return name !== undefined && foreign.has(name) ? { ...file, foreign: true } : file
|
|
190
|
+
}
|
|
191
|
+
|
|
165
192
|
const supplementPaths = (root: string): string[] =>
|
|
166
193
|
listDirectory(join(root, CLAUDE_DIRECTORY, SUPPLEMENTS_DIRECTORY))
|
|
167
194
|
.filter((name) => name.endsWith('.md'))
|
|
@@ -170,16 +197,19 @@ const supplementPaths = (root: string): string[] =>
|
|
|
170
197
|
/**
|
|
171
198
|
* The installed kit skills with their references, kit agents and kit rules that load every session (the lock file
|
|
172
199
|
* says which files the kit installed), the project's supplements and its own skills, measured against the budgets.
|
|
200
|
+
* `skillPrefix` is the project's: the kit's budgets follow its skills and agents to their prefixed paths.
|
|
173
201
|
* Null when there is nothing to measure.
|
|
174
202
|
*/
|
|
175
|
-
export const skillBudgetCheck = (root: string, project: BudgetTable = {}): CheckResult | null => {
|
|
203
|
+
export const skillBudgetCheck = (root: string, project: BudgetTable = {}, skillPrefix = ''): CheckResult | null => {
|
|
176
204
|
const started = Date.now()
|
|
177
205
|
const kitPaths = kitPathsOf(root)
|
|
178
206
|
const kit = new Set(kitPaths.map((key) => claudePath(key)))
|
|
207
|
+
const foreign = readForeignSkills(root)
|
|
179
208
|
const files = [
|
|
180
209
|
...kitPaths.flatMap((key) => kitFileWordsOf(root, key)),
|
|
181
210
|
...supplementPaths(root).flatMap((path) => fileWordsOf(root, path, SUPPLEMENT_BUDGET)),
|
|
182
211
|
...ownSkillPaths(root, kit).flatMap((path) => fileWordsOf(root, path, OWN_SKILL_BUDGET)),
|
|
183
|
-
]
|
|
184
|
-
|
|
212
|
+
].map(markForeign(foreign))
|
|
213
|
+
const defaults = prefixedBudgets(KIT_SKILL_BUDGETS, skillPrefix)
|
|
214
|
+
return files.length === 0 ? null : budgetResult(measureFiles(files, project, defaults), Date.now() - started)
|
|
185
215
|
}
|
package/src/claude/paths.ts
CHANGED
|
@@ -10,5 +10,17 @@ export const HANDOVER_DIRECTORY = 'handover'
|
|
|
10
10
|
export const WORKTREES_DIRECTORY = 'worktrees'
|
|
11
11
|
export const SESSION_LOGS_DIRECTORY = 'projects'
|
|
12
12
|
|
|
13
|
+
/** A skill's file and an agent below `.claude/`, by name: `skills/<name>/<file>`, `agents/<name>.md`. */
|
|
14
|
+
export const SKILL_PATH = new RegExp(`^${SKILLS_DIRECTORY}/([^/]+)/(.+)$`)
|
|
15
|
+
export const AGENT_PATH = new RegExp(`^${AGENTS_DIRECTORY}/([^/]+)\\.md$`)
|
|
16
|
+
|
|
13
17
|
/** `.claude/<directory>` as a path in a project and in a text that names it. */
|
|
14
18
|
export const claudePath = (...segments: string[]): string => [CLAUDE_DIRECTORY, ...segments].join('/')
|
|
19
|
+
|
|
20
|
+
/** A kit path below `.claude/` as a project with `skillPrefix` installs it: skills and agents prefixed, the rest as it is. */
|
|
21
|
+
export const prefixedKitPath = (path: string, prefix: string): string => {
|
|
22
|
+
const skill = SKILL_PATH.exec(path)
|
|
23
|
+
if (skill) return `${SKILLS_DIRECTORY}/${prefix}${skill[1]}/${skill[2]}`
|
|
24
|
+
const agent = AGENT_PATH.exec(path)
|
|
25
|
+
return agent ? `${AGENTS_DIRECTORY}/${prefix}${agent[1]}.md` : path
|
|
26
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -26,6 +26,7 @@ export const COMMANDS: CommandTable = {
|
|
|
26
26
|
[HERD_COMMAND.alias]: () => import('./plans/command'),
|
|
27
27
|
lane: () => import('./lane/command'),
|
|
28
28
|
mermaid: () => import('./mermaid/command'),
|
|
29
|
+
plan: () => import('./plan-contract/command'),
|
|
29
30
|
preview: () => import('./preview/command'),
|
|
30
31
|
settled: () => import('./settled/command'),
|
|
31
32
|
sync: () => import('./sync/command'),
|