jackherd 1.12.7 → 1.13.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 (47) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +35 -17
  4. package/dist/config.d.ts +32 -11
  5. package/dist/config.js +38 -82
  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 +2 -0
  23. package/kit/workflow/skills/start/SKILL.md +4 -5
  24. package/package.json +1 -1
  25. package/schemas/jackherd.schema.json +14 -11
  26. package/src/check/command.ts +121 -35
  27. package/src/check/plan-structure-check.ts +34 -1
  28. package/src/check/plan.ts +127 -44
  29. package/src/check/report.ts +67 -2
  30. package/src/check/result.ts +13 -3
  31. package/src/check/roots.ts +38 -10
  32. package/src/check/schema.ts +8 -3
  33. package/src/check/settings.ts +18 -1
  34. package/src/check/skill-budget-defaults.ts +3 -2
  35. package/src/config/base-branch.ts +2 -0
  36. package/src/config/command.ts +32 -13
  37. package/src/config/detect.ts +242 -74
  38. package/src/config/location.ts +6 -0
  39. package/src/config/repository-facts.ts +32 -7
  40. package/src/config/repository-values.ts +162 -5
  41. package/src/config/schema.ts +34 -16
  42. package/src/lane-prompt/texts.ts +2 -2
  43. package/src/plan-format/bare-codes.ts +28 -0
  44. package/src/sync/command-context.ts +4 -2
  45. package/src/sync/herdr-plugin.ts +30 -6
  46. package/src/sync/init.ts +17 -16
  47. package/src/sync/render.ts +4 -6
@@ -0,0 +1,80 @@
1
+ // PreToolUse hook of Claude Code for AskUserQuestion, shipped by jackherd: the client adapter of the plain-question
2
+ // guard. A dialog that shows a plan code bare (`F9` instead of "the release check (F9)") is denied with one line of
3
+ // reason, so the question is asked again in the user's words. The codes are the ones the plans linked from the boards
4
+ // of the main checkout define, in the main checkout and in the worktrees of the lanes the boards name, else a fallback
5
+ // pattern; a cache keyed by each file's time and size spares reading the unchanged ones. Anything else, and any
6
+ // failure, answers nothing, so the dialog opens.
7
+ import { statSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+ import { boardLocationOf, boardsIn } from '../shared/boards'
10
+ import { mainCheckoutOf } from '../shared/checkout'
11
+ import { readConfig } from '../shared/config'
12
+ import { readTextOrNull } from '../shared/files'
13
+ import { isRecord } from '../shared/json'
14
+ import { planCodesOf } from '../shared/plan-codes'
15
+ import { ensurePrivateDirectory, writePrivateFile } from '../shared/private-directory'
16
+ import { type CodeCache, lookUpCodes, type PlanFileStat, parseCodeCache, serializeCodeCache } from './code-cache'
17
+ import { denyReasonOf, dialogTextsOf, planPathsOfBoards, planRootsOfBoards } from './run'
18
+
19
+ const HOOK_EVENT = 'PreToolUse'
20
+
21
+ /** Where the cache lies: in the main checkout beside `.jackherd/approvals`, ignored by git. */
22
+ const cacheDirectory = (main: string): string => join(main, '.jackherd', 'plan-codes')
23
+ const CACHE_FILE = 'codes.json'
24
+
25
+ const statOf = (path: string): PlanFileStat | null => {
26
+ const stat = statSync(path, { throwIfNoEntry: false })
27
+ return stat?.isFile() ? { path, mtimeMs: stat.mtimeMs, size: stat.size } : null
28
+ }
29
+
30
+ /** Keeps the cache for the next dialog; a cache that cannot be written only costs that dialog a full read. */
31
+ const saveCache = (main: string, cache: CodeCache): void => {
32
+ try {
33
+ ensurePrivateDirectory(cacheDirectory(main))
34
+ writePrivateFile(join(cacheDirectory(main), CACHE_FILE), serializeCodeCache(cache))
35
+ } catch {
36
+ // the next dialog reads the plans again
37
+ }
38
+ }
39
+
40
+ /**
41
+ * The codes of the plans the main checkout's boards link, in the main checkout and in the worktrees of the lanes the
42
+ * boards name; null outside a repository, without a board location or a readable plan.
43
+ */
44
+ const planCodesFor = (cwd: string): Set<string> | null => {
45
+ try {
46
+ const main = mainCheckoutOf(cwd)
47
+ const location = boardLocationOf(readConfig([main]))
48
+ if (location === null) return null
49
+ const { planLanguage: language, worktreeDirectory } = location
50
+ const boards = boardsIn(main, location)
51
+ const roots = planRootsOfBoards(main, worktreeDirectory, boards, language)
52
+ const stats = planPathsOfBoards(boards, language).flatMap((path) => roots.flatMap((root) => statOf(join(root, path)) ?? []))
53
+ const cache = parseCodeCache(readTextOrNull(join(cacheDirectory(main), CACHE_FILE)))
54
+ const lookup = lookUpCodes(stats, cache, readTextOrNull, (text) => [...planCodesOf(text, language)])
55
+ if (lookup.changed) saveCache(main, lookup.cache)
56
+ return lookup.codes
57
+ } catch {
58
+ return null
59
+ }
60
+ }
61
+
62
+ const decide = async (): Promise<string | null> => {
63
+ try {
64
+ const input: unknown = JSON.parse(await Bun.stdin.text())
65
+ if (!isRecord(input) || typeof input.cwd !== 'string') return null
66
+ const texts = dialogTextsOf(input.tool_input)
67
+ return texts.length === 0 ? null : denyReasonOf(texts, planCodesFor(input.cwd))
68
+ } catch {
69
+ return null
70
+ }
71
+ }
72
+
73
+ const reason = await decide()
74
+ if (reason !== null)
75
+ console.log(
76
+ JSON.stringify({
77
+ hookSpecificOutput: { hookEventName: HOOK_EVENT, permissionDecision: 'deny', permissionDecisionReason: reason },
78
+ }),
79
+ )
80
+ process.exit(0)
@@ -0,0 +1,50 @@
1
+ import { join } from 'node:path'
2
+ import { type BoardLanguage, boardPlansOf, isInsideRepository, openLaneNamesOf } from '../shared/board-lanes'
3
+ import type { Board } from '../shared/boards'
4
+ import { isRecord } from '../shared/json'
5
+ import { bareCodesOf } from '../shared/plan-codes'
6
+
7
+ /** A lane name that is one plain directory name, never `.`, `..` or a path. */
8
+ const LANE_DIRECTORY = /^(?!\.{1,2}$)[A-Za-z0-9._-]+$/
9
+
10
+ const strings = (values: unknown[]): string[] => values.filter((value): value is string => typeof value === 'string')
11
+
12
+ const optionTexts = (options: unknown): string[] =>
13
+ Array.isArray(options) ? options.filter(isRecord).flatMap((option) => strings([option.label, option.description])) : []
14
+
15
+ /** The texts a dialog shows the user: each question, its header chip, and every option's label and description. */
16
+ export const dialogTextsOf = (toolInput: unknown): string[] => {
17
+ if (!isRecord(toolInput) || !Array.isArray(toolInput.questions)) return []
18
+ return toolInput.questions
19
+ .filter(isRecord)
20
+ .flatMap((question) => [...strings([question.question, question.header]), ...optionTexts(question.options)])
21
+ }
22
+
23
+ /** Every plan the boards link, relative to the repository, once; a path that leaves the repository is dropped. */
24
+ export const planPathsOfBoards = (boards: readonly Board[], language: BoardLanguage): string[] => [
25
+ ...new Set(boards.flatMap(({ text, directory }) => boardPlansOf(text, directory, language)).filter(isInsideRepository)),
26
+ ]
27
+
28
+ /**
29
+ * Where a plan the boards link may lie: the main checkout, and the worktree of each open lane a board names, where a
30
+ * lane's new plan may exist before it reaches the main checkout. Other worktrees are not read.
31
+ */
32
+ export const planRootsOfBoards = (
33
+ main: string,
34
+ worktreeDirectory: string,
35
+ boards: readonly Board[],
36
+ language: BoardLanguage,
37
+ ): string[] => {
38
+ const lanes = new Set(
39
+ boards.flatMap(({ text }) => openLaneNamesOf(text, language)).filter((lane) => LANE_DIRECTORY.test(lane)),
40
+ )
41
+ return [main, ...[...lanes].map((lane) => join(main, worktreeDirectory, lane))]
42
+ }
43
+
44
+ /** The reason to send a dialog back: it names the first code it shows bare; null when it shows none. */
45
+ export const denyReasonOf = (texts: readonly string[], codes: ReadonlySet<string> | null): string | null => {
46
+ const [code] = texts.flatMap((text) => bareCodesOf(text, codes))
47
+ return code === undefined
48
+ ? null
49
+ : `Plan code ${code} without its words: say in everyday words what it is, the code at most in brackets after them, as in "the architecture docs (${code})".`
50
+ }
@@ -89,6 +89,25 @@ export const laneNamesOf = (markdown: string, language: BoardLanguage): string[]
89
89
  ...new Set(laneRows(markdown, language).map(({ lane }) => lane)),
90
90
  ]
91
91
 
92
+ /** Whether a plan path as `planPathOf` normalises it stays inside the repository: relative, no step out of it. */
93
+ export const isInsideRepository = (path: string): boolean =>
94
+ !path.startsWith('/') && path !== '..' && !path.startsWith('../')
95
+
96
+ /** Every plan the lanes table links, cleaned-up lanes included, relative to the repository, in table order. */
97
+ export const boardPlansOf = (markdown: string, boardDirectory: string, language: BoardLanguage): string[] => [
98
+ ...new Set(laneRows(markdown, language).flatMap(({ cells }) => planPathOf(boardDirectory, cells[PLAN_COLUMN] ?? ''))),
99
+ ]
100
+
101
+ /** Every lane the lanes table names whose current rows do not say it was cleaned up, in table order, read in one pass. */
102
+ export const openLaneNamesOf = (markdown: string, language: BoardLanguage): string[] => {
103
+ const { laneClosed } = BOARD_WORDS[language]
104
+ const rowsByLane = new Map<string, Row[]>()
105
+ for (const row of laneRows(markdown, language)) rowsByLane.set(row.lane, [...(rowsByLane.get(row.lane) ?? []), row])
106
+ return [...rowsByLane].flatMap(([lane, rows]) =>
107
+ currentRows(rows, laneClosed).some(({ cells }) => (cells[0] ?? '').includes(laneClosed)) ? [] : [lane],
108
+ )
109
+ }
110
+
92
111
  /** The rows of `lane` in the board's lanes table; undefined when the table has no row for it. */
93
112
  export const lanePlansOf = (
94
113
  markdown: string,
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Where a project keeps its boards, read from its config, and the boards themselves as the main checkout holds them
3
+ * (the coordinator keeps its board there, committed or not). Shared by the merge guard and the plain-question guard.
4
+ */
5
+ import { readdirSync } from 'node:fs'
6
+ import { join, posix } from 'node:path'
7
+ import { BOARD_WORDS, type BoardLanguage } from './board-lanes'
8
+ import { readTextOrNull } from './files'
9
+ import { isRecord } from './json'
10
+ import { isRepositoryDirectory } from './repository-directory'
11
+
12
+ /** A board's directory relative to the repository, and its text. */
13
+ export type Board = { directory: string; text: string }
14
+
15
+ /** The plan language, the plan directory and the board pattern and file below it, and where the lane worktrees live. */
16
+ export type BoardLocation = {
17
+ planLanguage: BoardLanguage
18
+ planDirectory: string
19
+ board: { pattern: string; file: string }
20
+ worktreeDirectory: string
21
+ }
22
+
23
+ const BOARD_PATTERN = /^(?![-.])[A-Za-z0-9._*?-]+$/
24
+ const BOARD_FILE = /^(?![-.])[A-Za-z0-9._-]+\.md$/
25
+
26
+ /** The value at `key`, its default when absent, `undefined` when present but invalid. */
27
+ const valueOr = <T>(
28
+ section: Record<string, unknown>,
29
+ key: string,
30
+ isValid: (value: unknown) => value is T,
31
+ fallback: T,
32
+ ) => (!(key in section) ? fallback : isValid(section[key]) ? section[key] : undefined)
33
+
34
+ const matches =
35
+ (pattern: RegExp) =>
36
+ (value: unknown): value is string =>
37
+ typeof value === 'string' && pattern.test(value)
38
+
39
+ const isPlanLanguage = (value: unknown): value is BoardLanguage => typeof value === 'string' && Object.hasOwn(BOARD_WORDS, value)
40
+
41
+ /**
42
+ * The board location's values of a config, each with its default where absent and `undefined` where present but
43
+ * invalid; `board` is the config's `board` section.
44
+ */
45
+ export const boardLocationValues = (config: Record<string, unknown>, board: Record<string, unknown>) => ({
46
+ planLanguage: valueOr(config, 'planLanguage', isPlanLanguage, 'en'),
47
+ planDirectory: valueOr(config, 'planDirectory', isRepositoryDirectory, 'docs/plans'),
48
+ pattern: valueOr(board, 'pattern', matches(BOARD_PATTERN), 'board-*'),
49
+ file: valueOr(board, 'file', matches(BOARD_FILE), 'board.md'),
50
+ worktreeDirectory: valueOr(config, 'worktreeDirectory', isRepositoryDirectory, '.claude/worktrees'),
51
+ })
52
+
53
+ /** The board location of a config; `null` when its `board` section is no object or a value it reads is invalid. */
54
+ export const boardLocationOf = (config: Record<string, unknown>): BoardLocation | null => {
55
+ const board = config.board ?? {}
56
+ if (!isRecord(board)) return null
57
+ const { planLanguage, planDirectory, pattern, file, worktreeDirectory } = boardLocationValues(config, board)
58
+ if (planLanguage === undefined || planDirectory === undefined || worktreeDirectory === undefined) return null
59
+ if (pattern === undefined || file === undefined) return null
60
+ return { planLanguage, planDirectory, board: { pattern, file }, worktreeDirectory }
61
+ }
62
+
63
+ /** The boards under the plan directory of the main checkout whose directory name matches the board pattern. */
64
+ export const boardsIn = (main: string, location: Pick<BoardLocation, 'planDirectory' | 'board'>): Board[] => {
65
+ const pattern = new Bun.Glob(location.board.pattern)
66
+ try {
67
+ return readdirSync(join(main, location.planDirectory), { withFileTypes: true })
68
+ .filter((entry) => entry.isDirectory() && pattern.match(entry.name))
69
+ .flatMap((entry) => {
70
+ const directory = posix.join(location.planDirectory, entry.name)
71
+ const text = readTextOrNull(join(main, directory, location.board.file))
72
+ return text === null ? [] : [{ directory, text }]
73
+ })
74
+ } catch {
75
+ return []
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Where a plan the boards link can lie: the main checkout, and every directory below its worktree directory, since a
81
+ * lane's plan may exist only in the lane's worktree, at the same path relative to it.
82
+ */
83
+ export const planRootsIn = (main: string, worktreeDirectory: string): string[] => {
84
+ try {
85
+ const worktrees = readdirSync(join(main, worktreeDirectory), { withFileTypes: true })
86
+ .filter((entry) => entry.isDirectory())
87
+ .map((entry) => join(main, worktreeDirectory, entry.name))
88
+ return [main, ...worktrees]
89
+ } catch {
90
+ return [main]
91
+ }
92
+ }
93
+
94
+ /** The texts of a plan, `path` relative to the repository, in every root that holds a readable copy. */
95
+ export const planCopiesIn = (roots: readonly string[], path: string): string[] =>
96
+ roots.flatMap((root) => readTextOrNull(join(root, path)) ?? [])
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The codes plans give their phases, decisions and questions (`P7`, `D16`, `F1`, `L3`), read the way a plan defines
3
+ * them, and the ones a text names bare, outside brackets. The phase headings are read the way `phaseHeadings` in the
4
+ * package reads them, which a hook cannot import; a test compares both on the repository's real plans.
5
+ */
6
+ import type { BoardLanguage } from './board-lanes'
7
+
8
+ /** The plan vocabulary's word for a phase heading, per plan language. */
9
+ export const PHASE_WORDS: Record<BoardLanguage, string> = { de: 'Phase', en: 'Phase' }
10
+
11
+ /** The prefix a phase id carries outside its heading (`PHASE_ID_PREFIX` in the package). */
12
+ export const PHASE_CODE_PREFIX = 'P'
13
+
14
+ const CODE = '[A-Z]{1,2}\\d{1,3}[a-z]?'
15
+ const NUMBERED_SECTION = new RegExp(`^##\\s+${PHASE_CODE_PREFIX}?(\\d+[a-z]?)\\s*[—–:-]\\s*(.*)$`)
16
+ const DEFINITIONS = [
17
+ new RegExp(`^\\|\\s*(?:\\*\\*)?(${CODE})(?:\\*\\*)?\\s*\\|`),
18
+ new RegExp(`^#{2,6}\\s+(?:\\*\\*)?(${CODE})\\b`),
19
+ new RegExp(`^\\s*[-*]\\s+\\*\\*(${CODE})\\b`),
20
+ ]
21
+ /** A code standing as a word of its own: not after a word character, `-`, `/` or `.`, not before a word character. */
22
+ const STANDALONE = new RegExp(`(?<![\\w/.-])(${CODE})(?!\\w)`, 'g')
23
+ /** Without a plan to read: phase, follow-up, decision and question codes, as the plans of this kit write them. */
24
+ const FALLBACK = /^[FPDE]\d{1,3}[a-z]?$/
25
+ const BRACKETED = /\([^()]*\)/g
26
+
27
+ const phaseHeadingOf = (language: BoardLanguage): RegExp =>
28
+ new RegExp(`^###\\s+${PHASE_WORDS[language]}\\s+(\\d+[a-z]?)\\b`, 'i')
29
+
30
+ /** The phase ids of a plan's headings, `### Phase <id>` and `## <id> — title` alike, lower case as the package reads them. */
31
+ export const phaseIdsOf = (markdown: string, language: BoardLanguage): string[] => {
32
+ const heading = phaseHeadingOf(language)
33
+ return markdown.split('\n').flatMap((line) => {
34
+ const id = (line.match(heading) ?? line.match(NUMBERED_SECTION))?.[1]
35
+ return id === undefined ? [] : [id.toLowerCase()]
36
+ })
37
+ }
38
+
39
+ /**
40
+ * Every code a plan defines: its phases as `P<id>`, and each code that opens a table row, a heading or a bold list
41
+ * item (one or two capitals, a number, at most one lower-case letter).
42
+ */
43
+ export const planCodesOf = (markdown: string, language: BoardLanguage): Set<string> => {
44
+ const defined = markdown.split('\n').flatMap((line) => {
45
+ const code = DEFINITIONS.map((definition) => line.match(definition)?.[1]).find((match) => match !== undefined)
46
+ return code === undefined ? [] : [code]
47
+ })
48
+ return new Set([...phaseIdsOf(markdown, language).map((id) => `${PHASE_CODE_PREFIX}${id}`), ...defined])
49
+ }
50
+
51
+ /** The codes the plans define together; null when there is no plan text, so the fallback pattern decides. */
52
+ export const codesOfPlans = (plans: readonly string[], language: BoardLanguage): Set<string> | null =>
53
+ plans.length === 0 ? null : new Set(plans.flatMap((plan) => [...planCodesOf(plan, language)]))
54
+
55
+ /** The text with every bracketed span blanked, innermost first, so a code in brackets is never read as bare. */
56
+ const withoutBrackets = (text: string): string => {
57
+ const blanked = text.replace(BRACKETED, ' ')
58
+ return blanked === text ? text : withoutBrackets(blanked)
59
+ }
60
+
61
+ /**
62
+ * The codes `text` names bare, outside brackets, in order of appearance: those of `codes`, or, when no plan could be
63
+ * read (`null`), those of the fallback pattern.
64
+ */
65
+ export const bareCodesOf = (text: string, codes: ReadonlySet<string> | null): string[] =>
66
+ [...withoutBrackets(text).matchAll(STANDALONE)]
67
+ .map((match) => match[1] ?? '')
68
+ .filter((code) => (codes === null ? FALLBACK.test(code) : codes.has(code)))
@@ -69,6 +69,15 @@
69
69
  "timeout": 10
70
70
  }
71
71
  },
72
+ "plain-question": {
73
+ "event": "PreToolUse",
74
+ "matcher": "AskUserQuestion",
75
+ "handler": {
76
+ "type": "command",
77
+ "command": "cd \"$CLAUDE_PROJECT_DIR\" && bun .claude/hooks/plain-question/index.ts",
78
+ "timeout": 10
79
+ }
80
+ },
72
81
  "sent-file": {
73
82
  "event": "PostToolUse",
74
83
  "matcher": "SendUserFile|Artifact",
@@ -30,7 +30,7 @@ If the redacted output is not enough to diagnose the bug, say so and ask the use
30
30
 
31
31
  Spend disproportionate effort here. Be aggressive, be creative, refuse to give up.
32
32
 
33
- **The loop recipes live in [`references/feedback-loops.md`](references/feedback-loops.md)** — Bun tests, a real browser via Playwright, HTTP via curl, message subjects, differential runs. Read it before you build a loop; it also carries the two Bun invocation traps that make a healthy suite look broken.
33
+ **The loop recipes live in [`references/feedback-loops.md`](references/feedback-loops.md)** — the test runner, a real browser via Playwright, HTTP via curl, message subjects, differential runs. Read it before you build a loop; it also names the invocation traps that make a healthy suite look broken.
34
34
 
35
35
  ### Tighten the loop
36
36
 
@@ -111,7 +111,7 @@ A correct seam exercises the **real bug pattern as it occurs at the call site**.
111
111
 
112
112
  Where a seam exists, follow the decision tree in `.claude/rules/kit/testing.md` (a bug fix always earns a regression test) and:
113
113
 
114
- 1. Turn the minimised repro into a failing `*.test.ts` next to the source.
114
+ 1. Turn the minimised repro into a failing test, named and placed as `.claude/rules/kit/testing.md` → Runner & File Placement says.
115
115
  2. Watch it fail.
116
116
  3. Apply the fix — the smallest change that turns it green, no opportunistic refactoring.
117
117
  4. Watch it pass.
@@ -2,22 +2,13 @@
2
2
 
3
3
  Recipes for Phase 1 of `/diagnose`. Pick the one that reaches the bug's code path, then tighten it. The project supplement in `.claude/project/diagnose.md` adds the recipes only this project has.
4
4
 
5
- Ordered by how tight the loop tends to be. Reach for the first one that can actually go red on the bug — a Bun test that cannot see the bug is worse than a slower loop that can.
5
+ Ordered by how tight the loop tends to be. Reach for the first one that can actually go red on the bug — a test that cannot see the bug is worse than a slower loop that can.
6
6
 
7
- ## 1. Bun test — the default
7
+ ## 1. The project's test runner — the default
8
8
 
9
- The tightest loop available. Milliseconds, deterministic, unattended.
9
+ The tightest loop available. Milliseconds, deterministic, unattended: `{{kit.command.test}}`, narrowed to the one test file or module directory that reaches the bug.
10
10
 
11
- ```bash
12
- cd packages/<package> && bun test src/<module>/<file>.test.ts
13
- ```
14
-
15
- Two invocation traps make a healthy suite look broken:
16
-
17
- - **`EMFILE` / `ProcessFdQuotaExceeded` while dev servers run.** From the workspace root Bun walks the whole monorepo and can exhaust the kernel's descriptor table while watchers hold it. It reports `0 pass / 1 error`, not an assertion failure. Fix: `cd` into the package or module directory first; leave the user's dev servers running.
18
- - **Run from the package root, not below it.** Bun reads `tsconfig.json` — and with it `jsxImportSource` — from the **working directory**. One level too deep and every `.tsx` a test touches compiles against React's runtime: `Cannot find module 'react/jsx-dev-runtime'`. It reads like a missing dependency in untouched code. Re-run from the package root before concluding anything.
19
-
20
- Both failures are environmental. Neither is evidence about the bug.
11
+ Invocation traps make a healthy suite look broken: a runner started from the wrong directory fails on a watcher exhausting file descriptors, or on a config file it resolved from the working directory. Such a failure is environmental, not an assertion, and says nothing about the bug. Re-run from the directory the project's config lives in before concluding anything; the project supplement in `.claude/project/diagnose.md` names the traps of this project's runner.
21
12
 
22
13
  ## 2. The rendered app — Playwright
23
14
 
@@ -4,12 +4,9 @@ The kit's core. Project-specific conventions (UI framework, shared component lib
4
4
 
5
5
  ## Formatting
6
6
 
7
- The formatter and linter config is the authority; these are the conventions it encodes:
7
+ The formatter and linter config is the authority; where it is silent, match the surrounding code.
8
8
 
9
- - Single quotes, no semicolons, 2-space indent, trailing commas.
10
- - Sorted imports; no unused imports.
11
- - Arrow functions over `function` declarations.
12
- - Template literals over string concatenation.
9
+ - No unused imports.
13
10
  - Blank lines separate logical sections; decorative separator comments (`// ─────────`) stay out.
14
11
 
15
12
  ## Language
@@ -29,9 +26,9 @@ The formatter and linter config is the authority; these are the conventions it e
29
26
  - Self-documenting code over comments: clear naming, pure functions, obvious control flow.
30
27
  - **No section-label comments** (`// Types`, `// Pure functions`, `// Imports`). The code order speaks for itself.
31
28
 
32
- ### When to add JSDoc
29
+ ### When to add a doc comment
33
30
 
34
- Add JSDoc to an exported item when **any** of these is true:
31
+ Add a doc comment (JSDoc, PHPDoc …) to an exported item when **any** of these is true:
35
32
 
36
33
  - Parameters aren't self-evident from their types (a `duration` param: what unit? what happens at 0?)
37
34
  - The function has non-obvious constraints or behaviour (serialization limits, a required context, a side-effect contract)
@@ -65,11 +62,11 @@ export const processFile = async (path: string): Promise<void> => {
65
62
 
66
63
  The UI framework is a **replaceable adapter layer**, not where domain logic lives. Keep business logic framework-free so the framework stays swappable and the logic stays unit-testable without a component harness.
67
64
 
68
- - **Domain logic lives in framework-free `.ts` modules** — calculations, validation, state machines, mapping, formatting. These modules import nothing from the UI framework.
65
+ - **Domain logic lives in framework-free modules** — calculations, validation, state machines, mapping, formatting. These modules import nothing from the UI framework.
69
66
  - **Components consume logic, they don't contain it.** A component body wires state to pure functions; a derived value that grows past trivial derivation becomes a pure function the component calls.
70
67
  - **Backend logic never touches the frontend framework.**
71
68
 
72
- **Litmus test:** if swapping the framework would force you to rewrite a `.ts` file that has no UI in it, that file has leaked framework coupling — extract the logic.
69
+ **Litmus test:** if swapping the framework would force you to rewrite a source file that has no UI in it, that file has leaked framework coupling — extract the logic.
73
70
 
74
71
  ## Code Reuse
75
72
 
@@ -101,24 +98,24 @@ Apply these checks to **your own output** before presenting it, and fix violatio
101
98
 
102
99
  Before editing a file that might be generated:
103
100
 
104
- 1. **Check `package.json` scripts** for `gen`, `generate`, `codegen`.
101
+ 1. **Check the project's scripts** (`package.json`, `composer.json`, `Makefile`) for `gen`, `generate`, `codegen`.
105
102
  2. **Run the generator** instead of editing by hand; a generated file is overwritten on the next run.
106
103
 
107
104
  ## Validation & Types
108
105
 
109
- - Zod for schemas, with types inferred via `z.infer`.
106
+ - The project's schema library for schemas, with types derived from the schema.
110
107
  - Validation lives once, at the boundary, not again in every layer.
111
- - Strict typing; `unknown` and a parse over `any`.
112
- - Zod v4 top-level API: `z.uuid()`, `z.email()`, `z.url()`, `z.iso.datetime()`, `.loose()` — not the deprecated v3 chains (`z.string().uuid()`, `.passthrough()`).
108
+ - Strict typing; an unknown value is parsed at the boundary, never typed as "anything".
109
+ - Read the installed version of the schema library before writing against it.
113
110
 
114
111
  ### A fixed vocabulary is a typed constant
115
112
 
116
- Roles, modes, statuses, channels: every closed set of values has **one** `as const` object next to its type, and every comparison, option id and schema literal reads from it.
113
+ Roles, modes, statuses, channels: every closed set of values has **one** constant next to its type (an `as const` object, an enum, a class constant), and every comparison, option id and schema literal reads from it.
117
114
 
118
- TypeScript already checks a literal against a union inside the core. The constant earns its place at the **seams where a `string` arrives**: a query parameter, a form option id, a row read from the database. There a literal comparison is unchecked, and its usual companion — `mode === 'per_km' ? 'per_km' : 'tiers'` — maps every unknown value to a default in silence.
115
+ A type checker already checks a literal against the closed set inside the core. The constant earns its place at the **seams where a `string` arrives**: a query parameter, a form option id, a row read from the database. There a literal comparison is unchecked, and its usual companion — `mode === 'per_km' ? 'per_km' : 'tiers'` — maps every unknown value to a default in silence.
119
116
 
120
117
  ```ts
121
- // ✅ the constant is the vocabulary; a guard narrows the string, an unknown value is refused
118
+ // ✅ (TypeScript shown) the constant is the vocabulary; a guard narrows the string, an unknown value is refused
122
119
  export const FEE_MODE = { tiers: 'tiers', perKm: 'per_km' } as const
123
120
  export type FeeMode = (typeof FEE_MODE)[keyof typeof FEE_MODE]
124
121
  export const isFeeMode = (value: string): value is FeeMode =>
@@ -130,7 +127,7 @@ if (isFeeMode(id)) handleChange(switchMode(form, id))
130
127
  mode: id === 'per_km' ? 'per_km' : 'tiers'
131
128
  ```
132
129
 
133
- Derive the schema from the constant (`z.literal(FEE_MODE.perKm)`), never the other way round, so the constant, the type and the parser cannot disagree. Test fixtures may spell the literal.
130
+ Derive the schema from the constant (a literal built from `FEE_MODE.perKm`), never the other way round, so the constant, the type and the parser cannot disagree. Test fixtures may spell the literal.
134
131
 
135
132
  ## Database Writes
136
133
 
@@ -151,7 +148,7 @@ The lesson behind this rule: order numbers drawn from a truncated clock collided
151
148
 
152
149
  ### Generated identifiers carry entropy, never a clock
153
150
 
154
- A value drawn from the clock is a bucket, not an identifier, and truncating it widens the bucket. Draw from `crypto.getRandomValues`, keep the drawing in a **pure function** that takes the random values as an argument, and unit-test it. A generator whose only seam is a server function with a database call in it is a generator no test reaches.
151
+ A value drawn from the clock is a bucket, not an identifier, and truncating it widens the bucket. Draw from the platform's CSPRNG (`crypto.getRandomValues`, `random_bytes`), keep the drawing in a **pure function** that takes the random values as an argument, and unit-test it. A generator whose only seam is a server function with a database call in it is a generator no test reaches.
155
152
 
156
153
  ## External Measurements
157
154
 
@@ -9,24 +9,12 @@ All new or changed code **must** have tests.
9
9
  | Setting | Value |
10
10
  | ------------ | ---------------------------------------------- |
11
11
  | Runner | `{{kit.command.test}}` |
12
- | File naming | `*.test.ts` co-located next to the source file |
13
- | E2E runner | Playwright |
12
+ | File naming | the name and place the runner finds; follow where the project's existing tests live |
13
+ | E2E runner | Playwright, or the project's own |
14
14
 
15
- ### `EMFILE` / `ProcessFdQuotaExceeded` while dev servers run
15
+ ### Run the tests where the runner can see the project's config
16
16
 
17
- With watch processes active, `bun test` from the **workspace root** can fail before running a single test with `EMFILE` / `ProcessFdQuotaExceeded` (`Cannot read file ".../tsconfig.json": EMFILE`). The watchers exhaust the kernel's global descriptor table, so Bun cannot open its config. **This is an environment condition, not a test failure** — the run reports `0 pass / 1 error`. Raising `ulimit -n` does not help; the exhaustion is system-wide.
18
-
19
- **Workaround: `cd` into the package or module directory and run the tests there.** From a narrow directory Bun scans only that subtree. Leave the user's dev servers running; for a full-suite run that genuinely needs the root, stop them with the user's consent.
20
-
21
- ### Run from the package root, not below it
22
-
23
- Bun resolves `tsconfig.json` from the **working directory**, and that is where it reads `jsxImportSource`. Descend below a package root and any `.tsx` a test pulls in compiles against React's JSX runtime. The failure does not name JSX:
24
-
25
- ```
26
- error: Cannot find module 'react/jsx-dev-runtime' from '.../components/area-chart/index.tsx'
27
- ```
28
-
29
- **Read the failure before calling it a broken test.** It arrives as an "unhandled error between tests" with a module-resolution message, so it reads like a missing dependency in code nobody touched. Re-run from the package root first. Re-running the same wrong invocation with the changes stashed reproduces it identically and proves nothing.
17
+ A runner started from the wrong directory can fail before it runs a test: a watcher exhausting file descriptors, a config file or compiler setting resolved from the working directory and not found. **Read the failure before calling it a broken test** — an environment error reads like a missing dependency in code nobody touched, and re-running the same wrong invocation with the changes stashed proves nothing. Run from the package or module directory the config lives in, and leave the user's dev servers running. Where the project has its own testing rule, it names the failures its runner shows.
30
18
 
31
19
  ## What to Test (decision tree)
32
20
 
@@ -65,20 +53,11 @@ Write tests in this order — stop when coverage expectations are met:
65
53
 
66
54
  1. **Unit tests** — pure functions, validators, mappers
67
55
  2. **Integration tests** — service interactions, database operations
68
- 3. **E2E tests** — user flows via Playwright (only when requested)
69
-
70
- ## `mock.module()` replaces a module for the whole file
56
+ 3. **E2E tests** — user flows via the E2E runner (only when requested)
71
57
 
72
- `mock.module()` replaces the module **globally for the entire test file** — every import, including transitive ones.
58
+ ## Mock only the lowest-level dependency
73
59
 
74
- **Mock only the lowest-level dependency** (adapters, repositories). The use-case logic still runs; only I/O is stubbed. Mocking an intermediate module (a use-case in a handler test) breaks every other test in that file that imports it.
75
-
76
- ```typescript
77
- // ✅ Mock only the adapter
78
- mock.module('../adapters', () => ({
79
- auditRepo: { insert: mock(() => Promise.resolve()) },
80
- }))
81
- ```
60
+ **Mock only the lowest-level dependency** (adapters, repositories). The use-case logic still runs; only I/O is stubbed. Where the runner's mock replaces a whole module, it does so for the entire file, so mocking an intermediate module (a use-case in a handler test) breaks every other test there. Where the project has its own testing rule, it names the mocking call of its runner.
82
61
 
83
62
  ## Before every commit
84
63
 
@@ -44,7 +44,7 @@ Before you write a single line:
44
44
 
45
45
  0. A phase not yet in the plan gets its skeleton there first (heading, origin, tasks, review gate), before any research, so the herd never shows it as missing.
46
46
  1. Search with `Grep` / `Glob` / `Read` for existing utilities, components and domain types that cover 80%+ of the need. For deeper research dispatch an `explore` subagent.
47
- 2. Check `package.json` for `gen` / `generate` / `codegen` scripts and run the generator for a generated file instead of editing it.
47
+ 2. Run the generator for a generated file instead of editing it (`.claude/rules/kit/code-style.md` → Generators).
48
48
 
49
49
  ## Step 2: Implement
50
50
 
@@ -65,11 +65,11 @@ After each changed file:
65
65
 
66
66
  ## Step 4: Write Tests
67
67
 
68
- Follow the decision tree in `.claude/rules/kit/testing.md`. Test files sit next to the source; run them with `{{kit.command.test}}`. For changes in a shared package, also run the tests of the packages that consume it.
68
+ Follow the decision tree in `.claude/rules/kit/testing.md`. Name and place test files as `.claude/rules/kit/testing.md` → Runner & File Placement says; run them with `{{kit.command.test}}`. For changes in a shared package, also run the tests of the packages that consume it.
69
69
 
70
70
  ## Step 4b: Look at It
71
71
 
72
- A change a user sees — a page, a route, a redirect, a printed document — is looked at in the running app before `/pre-commit`. Unit tests prove the pieces; only the running app proves they meet. Use a temporary end-to-end spec (Playwright) or the browser, take a screenshot per state, and report what each shows. Delete the temporary spec before the commit unless the plan asks for an end-to-end test.
72
+ A change a user sees — a page, a route, a redirect, a printed document — is looked at in the running app before `/pre-commit`. Unit tests prove the pieces; only the running app proves they meet. Use a temporary end-to-end spec (Playwright or the project's E2E runner) or the browser, take a screenshot per state, and report what each shows. Delete the temporary spec before the commit unless the plan asks for an end-to-end test.
73
73
 
74
74
  No visible change (a domain package, a migration, a service) → skip, and say so in the report.
75
75
 
@@ -374,6 +374,8 @@ with its `TODO`. Silence is no answer: a collected question stays open until the
374
374
  (``Lane `<lane>` asks: …``, in the language of the replies), not in the question; **Approvals** keeps its own form.
375
375
  - An option label has five words at most. Its description has about twelve and says only what the
376
376
  choice leads to. The self-merge label of § 5 stays as written.
377
+ - Name a plan item by what it is, the code at most in brackets after it (`the architecture docs (P7)`): in
378
+ the dialog, the chat and the item cell of a waiting row, a passed-on lane question included.
377
379
  - Two or three options, mostly.
378
380
 
379
381
  ```text
@@ -20,24 +20,23 @@ Show this to the user, shortened to what fits on one screen:
20
20
 
21
21
  ## 2. Check the setup
22
22
 
23
- Run each command on its own. All of them only read. Call the CLI as `jh`, the copy the install put on the `PATH`; only where `command -v jh` finds none, use `bunx jackherd`, and say so in the report.
23
+ Run each command on its own; all only read. Call the CLI as `jh`, the copy the install put on the `PATH`; only where `command -v jh` finds none, use `bunx jackherd`, and say so in the report.
24
24
 
25
- 1. `jh config`: prints the commands and the base branch this repository uses. It reads one file, this repository's `.jackherd/config.json` if it exists, otherwise `~/.jackherd/config.json`, and fails with "not found" when neither exists. Detection only fills values in a file that exists.
25
+ 1. `jh config`: prints the commands and the base branch this repository uses, and on stderr a line for each setting that is missing or contradicts the repository. It reads one file: this repository's `.jackherd/config.json` if it exists, otherwise `~/.jackherd/config.json`, and fails with "not found" when neither exists. Detection only fills values in a file that exists.
26
26
  2. The terminal adapter: `config` does not print it, so read the same file. Present means `"terminal": { "adapter": "herdr" }`; no `terminal` entry means `none`.
27
27
  3. `command -v herdr`: prints the path when herdr is installed.
28
28
  4. `printenv HERDR_ENV`: prints `1` when this session runs inside herdr.
29
29
  5. `gh auth status`: reports whether the GitHub CLI is installed and logged in.
30
30
 
31
- Report a short list, one line per item, marked present or missing. For each missing item give the command or line that fixes it:
31
+ Report one line per item, present or missing. For each missing item give the fixing command or line:
32
32
 
33
33
  - Config: `jh init --global`, or `bunx jackherd init` to install the kit into this repository.
34
+ - Each stderr line from `jh config`: missing or contradicting, with the line it ends on. An install command or lock file that is missing or contradicted blocks parallel lanes, whose worktree setup runs both.
34
35
  - Terminal adapter: add `"terminal": { "adapter": "herdr" }` to the file from item 2.
35
36
  - herdr (optional, only for parallel lanes). You name the command; the user runs it: `sh -c 'installer=$(mktemp) && curl --proto =https --tlsv1.2 -fsSL https://herdr.dev/install.sh -o "$installer" && sh "$installer"; code=$?; rm -f "$installer"; exit $code'` (Linux and macOS; it downloads the official installer whole before running it).
36
37
  - Session outside herdr: start Claude Code from a herdr pane.
37
38
  - `gh`: install the GitHub CLI (`gh`), then run `gh auth login`.
38
39
 
39
- State plainly that herdr is only needed to run parallel lanes with `/orchestrate`. `/plan`, `/implement` and `jh herd` work without it.
40
-
41
40
  ## 3. Offer the first step
42
41
 
43
42
  Ask through `AskUserQuestion`, in {{kit.responseLanguage}}, with these options (the first one recommended):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.12.7",
3
+ "version": "1.13.0",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",