jackherd 1.12.7 → 1.14.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 (60) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +17 -17
  4. package/dist/config.d.ts +35 -11
  5. package/dist/config.js +49 -83
  6. package/herdr-plugin/herdr-plugin.toml +1 -1
  7. package/install.sh +1 -1
  8. package/kit/hooks/hooks/merge-guard/board-settings.ts +7 -32
  9. package/kit/hooks/hooks/merge-guard/lane-plan.ts +2 -29
  10. package/kit/hooks/hooks/plain-question/code-cache.ts +70 -0
  11. package/kit/hooks/hooks/plain-question/index.ts +80 -0
  12. package/kit/hooks/hooks/plain-question/run.ts +50 -0
  13. package/kit/hooks/hooks/shared/board-lanes.ts +19 -0
  14. package/kit/hooks/hooks/shared/boards.ts +96 -0
  15. package/kit/hooks/hooks/shared/plan-codes.ts +68 -0
  16. package/kit/hooks/hooks.json +9 -0
  17. package/kit/review/skills/diagnose/SKILL.md +2 -2
  18. package/kit/review/skills/diagnose/references/feedback-loops.md +4 -13
  19. package/kit/rules/rules/kit/code-style.md +15 -18
  20. package/kit/rules/rules/kit/testing.md +7 -28
  21. package/kit/workflow/skills/implement/SKILL.md +3 -3
  22. package/kit/workflow/skills/orchestrate/SKILL.md +4 -1
  23. package/kit/workflow/skills/orchestrate/references/events.md +6 -1
  24. package/kit/workflow/skills/orchestrate/references/plan-commit.md +2 -1
  25. package/kit/workflow/skills/start/SKILL.md +9 -6
  26. package/kit/workflow/skills/start/references/missing-commands.md +17 -0
  27. package/package.json +1 -1
  28. package/schemas/jackherd.schema.json +38 -11
  29. package/src/check/command.ts +167 -35
  30. package/src/check/file-kinds.ts +142 -0
  31. package/src/check/plan-structure-check.ts +34 -1
  32. package/src/check/plan.ts +230 -45
  33. package/src/check/report.ts +111 -3
  34. package/src/check/result.ts +13 -3
  35. package/src/check/roots.ts +47 -10
  36. package/src/check/schema.ts +20 -3
  37. package/src/check/settings.ts +25 -4
  38. package/src/check/skill-budget-defaults.ts +7 -4
  39. package/src/config/base-branch.ts +2 -0
  40. package/src/config/command.ts +32 -13
  41. package/src/config/detect.ts +242 -74
  42. package/src/config/location.ts +6 -0
  43. package/src/config/repository-facts.ts +32 -7
  44. package/src/config/repository-values.ts +162 -5
  45. package/src/config/schema.ts +34 -16
  46. package/src/lane-prompt/texts.ts +2 -2
  47. package/src/plan-format/bare-codes.ts +28 -0
  48. package/src/plans/lane-plans.ts +2 -1
  49. package/src/sync/command-context.ts +4 -2
  50. package/src/sync/herdr-plugin.ts +30 -6
  51. package/src/sync/init.ts +17 -16
  52. package/src/sync/render.ts +4 -6
  53. package/src/watch/command.ts +32 -1
  54. package/src/watch/lane-progress.ts +6 -1
  55. package/src/watch/lane-report.ts +10 -0
  56. package/src/watch/plan-missing-read.ts +47 -0
  57. package/src/watch/plan-missing.ts +43 -0
  58. package/src/watch/reference-paths.ts +1 -0
  59. package/src/watch/texts.ts +4 -0
  60. package/src/watch/watch-events.ts +7 -0
@@ -1,26 +1,63 @@
1
1
  import { dirname } from 'node:path'
2
+ import { CLAUDE_DIRECTORY } from '../claude/paths'
3
+ import { parseManifest } from '../sync/manifest'
4
+
5
+ /** The manifest whose `workspaces` field gathers workspaces (npm, Yarn, Bun). */
6
+ export const WORKSPACE_MANIFEST = 'package.json'
7
+
8
+ /** pnpm lists its workspaces in a file of its own instead of `WORKSPACE_MANIFEST`. */
9
+ export const WORKSPACE_LIST_FILE = 'pnpm-workspace.yaml'
2
10
 
3
11
  /**
4
- * The nearest ancestor holding a `package.json`. Tests and `tsc` run from there and nowhere else:
5
- * Bun resolves `tsconfig.json` from the working directory, so descending below a package root
6
- * silently compiles `.tsx` against the wrong JSX runtime.
12
+ * Whether a parsed `WORKSPACE_MANIFEST` gathers workspaces. Such a root only holds packages: a test run from
13
+ * there walks the whole monorepo.
7
14
  */
8
- export const packageRootOf = (path: string, hasPackageJson: (dir: string) => boolean): string | null => {
15
+ export const isWorkspaceManifest = (manifest: unknown): boolean =>
16
+ typeof manifest === 'object' && manifest !== null && Object.hasOwn(manifest, 'workspaces')
17
+
18
+ /**
19
+ * The nearest ancestor holding the stack's root marker (`package.json`, `composer.json`), the repository
20
+ * root included. Tests and type checks run from there and nowhere else: Bun resolves `tsconfig.json`
21
+ * from the working directory, so descending below a package root silently compiles `.tsx` against the
22
+ * wrong JSX runtime.
23
+ */
24
+ export const packageRootOf = (path: string, isProjectRoot: (dir: string) => boolean): string | null => {
9
25
  let dir = dirname(path)
10
26
  while (dir !== '.' && dir !== '/' && dir !== '') {
11
- if (hasPackageJson(dir)) return dir
27
+ if (isProjectRoot(dir)) return dir
12
28
  dir = dirname(dir)
13
29
  }
14
- return hasPackageJson('.') ? '.' : null
30
+ return isProjectRoot('.') ? '.' : null
15
31
  }
16
32
 
17
- export const packageRootsFor = (paths: readonly string[], hasPackageJson: (dir: string) => boolean): string[] => [
18
- ...new Set(paths.map((path) => packageRootOf(path, hasPackageJson)).filter((root): root is string => root !== null)),
33
+ export const packageRootsFor = (paths: readonly string[], isProjectRoot: (dir: string) => boolean): string[] => [
34
+ ...new Set(paths.map((path) => packageRootOf(path, isProjectRoot)).filter((root): root is string => root !== null)),
19
35
  ]
20
36
 
37
+ /**
38
+ * The repository paths of the files a parsed `.jackherd/lock.json` lists: copies whose tests run where the kit
39
+ * keeps their source. A missing or invalid lock lists none, so every changed source file needs its own test.
40
+ */
41
+ export const kitCopyPaths = (manifest: unknown): string[] => {
42
+ const parsed = parseManifest(manifest)
43
+ return parsed.ok ? Object.keys(parsed.manifest.files).map((path) => `${CLAUDE_DIRECTORY}/${path}`) : []
44
+ }
45
+
46
+ /** Whether a test run from `root` reaches `path`; from `.` it reaches every path. */
47
+ export const isUnder = (path: string, root: string): boolean => root === '.' || path.startsWith(`${root}/`)
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
+
21
58
  /**
22
59
  * Directories with test files that belong to no package root, which would otherwise be tested by
23
- * nobody. The workspace root is excluded: a test run from there walks the whole monorepo.
60
+ * nobody. The repository root `.` is excluded: a test run from there walks the whole repository.
24
61
  */
25
62
  export const looseDirectories = (
26
63
  paths: readonly string[],
@@ -29,7 +66,7 @@ export const looseDirectories = (
29
66
  ): string[] => [
30
67
  ...new Set(
31
68
  paths
32
- .filter((path) => !roots.some((root) => path.startsWith(`${root}/`)))
69
+ .filter((path) => !roots.some((root) => isUnder(path, root)))
33
70
  .map((path) => dirname(path))
34
71
  .filter((dir) => dir !== '.' && hasTests(dir)),
35
72
  ),
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
- import { BUN_COMMANDS } from '../config/detect'
3
2
  import { tokenize } from './command-line'
3
+ import { CHECK_KIND } from './result'
4
4
 
5
5
  const nonEmpty = z.string().min(1)
6
6
 
@@ -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_*',
@@ -40,6 +43,11 @@ const projectCheck = z.strictObject({
40
43
  command,
41
44
  /** Without globs the check runs on every change; with them, only when a changed path matches. */
42
45
  globs: globs.optional(),
46
+ /**
47
+ * `tests` for a check that runs the project's tests, `types` for its type check; without one, a plain project check.
48
+ * A test run counts towards "no green without a test", and both run with the slow checks.
49
+ */
50
+ kind: z.enum([CHECK_KIND.tests, CHECK_KIND.types]).optional(),
43
51
  })
44
52
 
45
53
  const generatorOwned = z.strictObject({
@@ -67,9 +75,9 @@ const skillBudgets = z.record(glob, z.number().int().positive())
67
75
  export const checkConfigSchema = z.strictObject({
68
76
  /**
69
77
  * Run once over the changed files, which are appended as arguments. Separate from `commands.lint`,
70
- * which lints the whole project.
78
+ * which lints the whole project. Detected from the repository when left out; without one, no lint runs.
71
79
  */
72
- lintFilesCommand: command.default(BUN_COMMANDS.lintFiles),
80
+ lintFilesCommand: command.optional(),
73
81
  /** Project checks, run alongside lint; add repository-specific scripts here. */
74
82
  checks: z.array(projectCheck).default([]),
75
83
  generatorOwned: z.array(generatorOwned).default([]),
@@ -83,6 +91,15 @@ export const checkConfigSchema = z.strictObject({
83
91
  skillBudgets: skillBudgets.optional(),
84
92
  /** Also run tests in directories that hold test files but belong to no package. */
85
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(),
86
103
  })
87
104
 
88
105
  export type CheckConfig = z.infer<typeof checkConfigSchema>
@@ -1,9 +1,16 @@
1
1
  import { z } from 'zod'
2
2
  import { jackherdConfigSchema } from '../config'
3
- import { type RepositoryFacts, withDetected } from '../config/detect'
3
+ import {
4
+ detectStack,
5
+ isDecided,
6
+ type RepositoryFacts,
7
+ type Stack,
8
+ type StackDetection,
9
+ withDetected,
10
+ } from '../config/detect'
4
11
  import { PROJECT_CONFIG_PATH } from '../config/location'
5
12
  import type { PlanLanguage } from '../vocabulary'
6
- import type { PackageCommands } from './plan'
13
+ import type { NoStackFallback, PackageCommands } from './plan'
7
14
  import { type CheckConfig, checkConfigSchema } from './schema'
8
15
 
9
16
  export const CONFIG_PATH = PROJECT_CONFIG_PATH
@@ -16,13 +23,25 @@ export type CheckSettings = {
16
23
  planLanguage: PlanLanguage
17
24
  /** The branch on the remote whose plans count as merged. */
18
25
  baseBranch: string
26
+ /** Where the lane worktrees live, below the main checkout: a board's plans are read there too. */
27
+ worktreeDirectory: string
28
+ /** What the lock files (without one, the manifests) decide: one stack, none, or several (ambiguous); `stackOf` reads the stack from it. */
29
+ stack: StackDetection
19
30
  notes: string[]
20
31
  }
21
32
 
33
+ /** The stack a detection decided, by lock file or manifest; null for none or several, where the check reads every stack's markers. */
34
+ export const stackOf = (detection: StackDetection): Stack | null => (isDecided(detection) ? detection.stack : null)
35
+
36
+ /** Without any detected stack: `check.roots` and the repository root `.` as fallback roots, `commands.lint` from the root. */
37
+ export const noStackFallbackOf = ({ stack, check, commands }: CheckSettings): NoStackFallback | null =>
38
+ stack.kind === 'none' ? { roots: [...(check.roots ?? []), '.'], lintCommand: commands.lint } : null
39
+
22
40
  const commandsSchema = jackherdConfigSchema.shape.commands
23
41
  const planDirectorySchema = jackherdConfigSchema.shape.planDirectory
24
42
  const planLanguageSchema = jackherdConfigSchema.shape.planLanguage
25
43
  const baseBranchSchema = jackherdConfigSchema.shape.baseBranch
44
+ const worktreeDirectorySchema = jackherdConfigSchema.shape.worktreeDirectory
26
45
 
27
46
  /**
28
47
  * The check runner's view of `.jackherd/config.json`: the shared `commands` block, `planDirectory`, `planLanguage` and `baseBranch` plus its own
@@ -32,13 +51,15 @@ const baseBranchSchema = jackherdConfigSchema.shape.baseBranch
32
51
  export const parseCheckSettings = (raw: unknown, facts: RepositoryFacts): CheckSettings => {
33
52
  const detected = withDetected(z.record(z.string(), z.unknown()).parse(raw), facts)
34
53
  const object = z.record(z.string(), z.unknown()).parse(detected.raw)
35
- const { test, typecheck } = commandsSchema.parse(object.commands)
54
+ const { test, typecheck, lint } = commandsSchema.parse(object.commands)
36
55
  return {
37
- commands: { test, typecheck },
56
+ commands: { test, typecheck, lint },
38
57
  check: checkConfigSchema.parse(object.check ?? {}),
39
58
  planDirectory: planDirectorySchema.parse(object.planDirectory),
40
59
  planLanguage: planLanguageSchema.parse(object.planLanguage),
41
60
  baseBranch: baseBranchSchema.parse(object.baseBranch),
61
+ worktreeDirectory: worktreeDirectorySchema.parse(object.worktreeDirectory),
62
+ stack: detectStack(facts.lockFiles, facts.manifests),
42
63
  notes: detected.notes,
43
64
  }
44
65
  }
@@ -20,13 +20,15 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
20
20
  '.claude/skills/grill/SKILL.md': 685,
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
- '.claude/skills/orchestrate/SKILL.md': 7880,
23
+ // Plain words: no plan code without its words in a short question.
24
+ // Any language: a pointer in § 0 and a section in events.md for `plan missing`.
25
+ '.claude/skills/orchestrate/SKILL.md': 7929,
24
26
  '.claude/skills/orchestrate/references/acceptance.md': 200,
25
27
  '.claude/skills/orchestrate/references/close-lane.md': 250,
26
28
  '.claude/skills/orchestrate/references/coordinator-handover.md': 551,
27
29
  '.claude/skills/orchestrate/references/draft-review.md': 300,
28
30
  // P33: a source entry the user must read or decide becomes a row of what waits on them, and when it closes.
29
- '.claude/skills/orchestrate/references/events.md': 1110,
31
+ '.claude/skills/orchestrate/references/events.md': 1166,
30
32
  '.claude/skills/orchestrate/references/herd.md': 299,
31
33
  '.claude/skills/orchestrate/references/lane-handover.md': 280,
32
34
  '.claude/skills/orchestrate/references/lane-session.md': 280,
@@ -39,10 +41,11 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
39
41
  '.claude/skills/pre-commit/SKILL.md': 1003,
40
42
  '.claude/skills/review/SKILL.md': 319,
41
43
  '.claude/skills/security-audit/SKILL.md': 519,
42
- '.claude/skills/start/SKILL.md': 607,
44
+ // Any language: step 3 points to proposing the missing commands from the CI; the proposal itself is a reference.
45
+ '.claude/skills/start/SKILL.md': 630,
43
46
  '.claude/skills/uncle-bob/SKILL.md': 466,
44
47
  // P33: grows with the skill above.
45
- '.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 8680,
48
+ '.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 8729,
46
49
  '.claude/agents/devops.md': 104,
47
50
  '.claude/agents/explore.md': 132,
48
51
  '.claude/agents/implement.md': 303,
@@ -0,0 +1,2 @@
1
+ /** The base branch when the repository does not decide; the schema's default too. */
2
+ export const DEFAULT_BASE_BRANCH = 'main'
@@ -2,35 +2,54 @@ import { homedir } from 'node:os'
2
2
  import { mainCheckout, worktreeRoot } from '../plans/git'
3
3
  import { CLI_NAME } from '../version/release'
4
4
  import { readResolvedConfig } from './read-config'
5
- import { formatRepositoryValues, isRepositoryValue, REPOSITORY_VALUE, repositoryValuesOf } from './repository-values'
5
+ import { readRepositoryFacts } from './repository-facts'
6
+ import {
7
+ commandFindings,
8
+ formatRepositoryValues,
9
+ isRepositoryValue,
10
+ isRepositoryValueSet,
11
+ REPOSITORY_VALUE,
12
+ repositoryValuesOf,
13
+ valueFindings,
14
+ } from './repository-values'
6
15
 
7
- const USAGE = `Usage: ${CLI_NAME} config [${Object.values(REPOSITORY_VALUE).join(' | ')}]`
16
+ export const CONFIG_EXIT = { printed: 0, failed: 1, usage: 2, unset: 3 } as const
8
17
 
9
- const USAGE_EXIT = 2
18
+ const USAGE = `Usage: ${CLI_NAME} config [${Object.values(REPOSITORY_VALUE).join(' | ')}]
19
+ Exit codes: ${CONFIG_EXIT.printed} printed, ${CONFIG_EXIT.failed} failed, ${CONFIG_EXIT.usage} usage, ${CONFIG_EXIT.unset} the named value is not set (its finding goes to stderr).`
10
20
 
11
21
  export type ConfigOutput = { log: (line: string) => void; error: (line: string) => void }
12
22
 
13
23
  /**
14
24
  * Prints the values this repository decides, as the config sets them or detection finds them: all of them, or
15
- * the one `key` names. Notes about values the repository did not decide go to `error`, so a key's output stays
16
- * the bare value.
25
+ * the one `key` names. Notes about values the repository did not decide, and a finding for each command or lock
26
+ * file that is missing or contradicts the repository (`commandFindings`), go to `error`, so a key's output stays
27
+ * the bare value; for a `key`, only the finding about that key (the notes for `baseBranch`). A `key` whose value
28
+ * is unset prints nothing and returns `CONFIG_EXIT.unset`; its missing finding says where to set it.
17
29
  */
18
30
  export const printRepositoryValues = (args: string[], roots: string[], home: string, output: ConfigOutput): number => {
19
31
  const [key, extra] = args
20
32
  if (extra !== undefined) {
21
33
  output.error(USAGE)
22
- return USAGE_EXIT
34
+ return CONFIG_EXIT.usage
23
35
  }
24
36
  if (key !== undefined && !isRepositoryValue(key)) {
25
37
  output.error(`Unknown key: ${key}\n${USAGE}`)
26
- return USAGE_EXIT
38
+ return CONFIG_EXIT.usage
27
39
  }
28
- const { config, notes } = readResolvedConfig(roots, home)
29
- for (const note of notes) output.error(note)
40
+ const facts = readRepositoryFacts(roots[0])
41
+ const { config, notes } = readResolvedConfig(roots, home, facts)
30
42
  const values = repositoryValuesOf(config)
31
- const lines = key === undefined ? formatRepositoryValues(values) : [values[key]]
32
- for (const line of lines) output.log(line)
33
- return 0
43
+ if (key === undefined) {
44
+ for (const note of [...notes, ...commandFindings(config, facts)]) output.error(note)
45
+ for (const line of formatRepositoryValues(values)) output.log(line)
46
+ return CONFIG_EXIT.printed
47
+ }
48
+ const diagnostics = key === REPOSITORY_VALUE.baseBranch ? notes : valueFindings(config, facts, key)
49
+ for (const line of diagnostics) output.error(line)
50
+ if (!isRepositoryValueSet(config, key)) return CONFIG_EXIT.unset
51
+ output.log(values[key])
52
+ return CONFIG_EXIT.printed
34
53
  }
35
54
 
36
55
  export const run = (args: string[]): number => {
@@ -40,6 +59,6 @@ export const run = (args: string[]): number => {
40
59
  return printRepositoryValues(args, [worktreeRoot(cwd), mainCheckout(cwd)], homedir(), output)
41
60
  } catch (error) {
42
61
  output.error(`${CLI_NAME} config failed: ${error instanceof Error ? error.message : String(error)}`)
43
- return 1
62
+ return CONFIG_EXIT.failed
44
63
  }
45
64
  }