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
@@ -0,0 +1,70 @@
1
+ import { isRecord } from '../shared/json'
2
+
3
+ /** A plan file as `stat` sees it: its absolute path, modification time and size, the cache's key. */
4
+ export type PlanFileStat = { path: string; mtimeMs: number; size: number }
5
+
6
+ /** The codes of each plan file the last dialog read, by absolute path, with the time and size they were read at. */
7
+ export type CodeCache = Record<string, { mtimeMs: number; size: number; codes: string[] }>
8
+
9
+ /** The codes the files define together (null when none could be read), the cache to keep, and whether it changed. */
10
+ export type CodeLookup = { codes: Set<string> | null; cache: CodeCache; changed: boolean }
11
+
12
+ const CACHE_VERSION = 1
13
+
14
+ const isEntry = (value: unknown): value is CodeCache[string] =>
15
+ isRecord(value) &&
16
+ typeof value.mtimeMs === 'number' &&
17
+ typeof value.size === 'number' &&
18
+ Array.isArray(value.codes) &&
19
+ value.codes.every((code) => typeof code === 'string')
20
+
21
+ /** The cache file's text as a cache; a missing, broken or foreign file, and each entry of the wrong shape, read as empty. */
22
+ export const parseCodeCache = (text: string | null): CodeCache => {
23
+ try {
24
+ const parsed: unknown = text === null ? null : JSON.parse(text)
25
+ if (!isRecord(parsed) || parsed.version !== CACHE_VERSION || !isRecord(parsed.entries)) return {}
26
+ return Object.fromEntries(
27
+ Object.entries(parsed.entries).flatMap(([path, entry]): [string, CodeCache[string]][] =>
28
+ isEntry(entry) ? [[path, entry]] : [],
29
+ ),
30
+ )
31
+ } catch {
32
+ return {}
33
+ }
34
+ }
35
+
36
+ export const serializeCodeCache = (cache: CodeCache): string =>
37
+ `${JSON.stringify({ version: CACHE_VERSION, entries: cache })}\n`
38
+
39
+ const isFresh = (entry: CodeCache[string] | undefined, { mtimeMs, size }: PlanFileStat): entry is CodeCache[string] =>
40
+ entry !== undefined && entry.mtimeMs === mtimeMs && entry.size === size
41
+
42
+ /**
43
+ * The codes of the plan files: a file whose time and size match its cache entry is not read, the others are read and
44
+ * parsed, copies with the same text once. The cache keeps exactly the files read or reused now, so an unlinked plan
45
+ * leaves it.
46
+ */
47
+ export const lookUpCodes = (
48
+ stats: readonly PlanFileStat[],
49
+ cache: CodeCache,
50
+ readText: (path: string) => string | null,
51
+ codesOf: (text: string) => string[],
52
+ ): CodeLookup => {
53
+ const parsed = new Map<string, string[]>()
54
+ const parse = (text: string): string[] => parsed.get(text) ?? parsed.set(text, codesOf(text)).get(text) ?? []
55
+ let missed = false
56
+ const entries = stats.flatMap((stat): [string, CodeCache[string]][] => {
57
+ const entry = cache[stat.path]
58
+ if (isFresh(entry, stat)) return [[stat.path, entry]]
59
+ missed = true
60
+ const text = readText(stat.path)
61
+ return text === null ? [] : [[stat.path, { mtimeMs: stat.mtimeMs, size: stat.size, codes: parse(text) }]]
62
+ })
63
+ const next = Object.fromEntries(entries)
64
+ const dropped = Object.keys(cache).some((path) => !(path in next))
65
+ return {
66
+ codes: entries.length === 0 ? null : new Set(entries.flatMap(([, entry]) => entry.codes)),
67
+ cache: next,
68
+ changed: missed || dropped,
69
+ }
70
+ }
@@ -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