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
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; without a detected stack, every stack's markers and patterns together. */
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
- hasTypeConfig: Filesystem['hasTypeConfig'],
198
+ isTypeRoot: (root: string) => boolean,
131
199
  ): PlannedCheck[] =>
132
200
  command === undefined
133
201
  ? []
134
- : roots.filter(hasTypeConfig).map((root) => inDirectory(CHECK_KIND.types, 'Types', command, root))
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
- { sourceFile, kitCopies }: Pick<PlanInputs['stack'], 'sourceFile'> & Pick<PlanInputs, 'kitCopies'>,
151
- ): string[] => paths.filter((path) => sourceFile.test(path) && !kitCopies.includes(path) && !isReached(path, reach))
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 = packageRootsFor(paths, filesystem.isProjectRoot).filter((root) => !filesystem.isWorkspaceRoot(root))
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
- ...lintChecks(config.lintFilesCommand, lintedPaths(paths, stack.lintGlobs)),
283
+ ...lintOf(config.lintFilesCommand, noStackFallback, lintedPaths(paths, stack.lintGlobs)),
182
284
  ...projectChecks(fired),
183
- ...typeChecks(roots, commands.typecheck, filesystem.hasTypeConfig),
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
- untestedSources: untestedSourcesOf(paths, reach, { sourceFile: stack.sourceFile, kitCopies }),
296
+ ...coverageOf({ paths, fallbackPaths }, reach, { check: config, stack, kitCopies }, filesystem.repositoryFiles),
195
297
  }
196
298
  }
@@ -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]
@@ -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.
@@ -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>
@@ -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
- '.claude/skills/orchestrate/SKILL.md': 7920,
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': 1110,
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
- '.claude/skills/orchestrate/references/plan-commit.md': 330,
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
- '.claude/skills/start/SKILL.md': 607,
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': 8720,
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
- '.claude/rules/kit/pull-requests.md': 601,
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
- export type Measurement = { label: string; words: number; budget: number }
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 = overrunOf(entry.words, entry.budget)
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(parseJson(text))
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(SKILLS_DIRECTORY, name, 'SKILL.md'))
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
- return files.length === 0 ? null : budgetResult(measureFiles(files, project, KIT_SKILL_BUDGETS), Date.now() - started)
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
  }
@@ -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'),