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.
- package/CHANGELOG.md +10 -0
- package/INSTALL.md +1 -1
- package/README.md +35 -17
- package/dist/config.d.ts +32 -11
- package/dist/config.js +38 -82
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/merge-guard/board-settings.ts +7 -32
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +2 -29
- package/kit/hooks/hooks/plain-question/code-cache.ts +70 -0
- package/kit/hooks/hooks/plain-question/index.ts +80 -0
- package/kit/hooks/hooks/plain-question/run.ts +50 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +19 -0
- package/kit/hooks/hooks/shared/boards.ts +96 -0
- package/kit/hooks/hooks/shared/plan-codes.ts +68 -0
- package/kit/hooks/hooks.json +9 -0
- package/kit/review/skills/diagnose/SKILL.md +2 -2
- package/kit/review/skills/diagnose/references/feedback-loops.md +4 -13
- package/kit/rules/rules/kit/code-style.md +15 -18
- package/kit/rules/rules/kit/testing.md +7 -28
- package/kit/workflow/skills/implement/SKILL.md +3 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +2 -0
- package/kit/workflow/skills/start/SKILL.md +4 -5
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +14 -11
- package/src/check/command.ts +121 -35
- package/src/check/plan-structure-check.ts +34 -1
- package/src/check/plan.ts +127 -44
- package/src/check/report.ts +67 -2
- package/src/check/result.ts +13 -3
- package/src/check/roots.ts +38 -10
- package/src/check/schema.ts +8 -3
- package/src/check/settings.ts +18 -1
- package/src/check/skill-budget-defaults.ts +3 -2
- package/src/config/base-branch.ts +2 -0
- package/src/config/command.ts +32 -13
- package/src/config/detect.ts +242 -74
- package/src/config/location.ts +6 -0
- package/src/config/repository-facts.ts +32 -7
- package/src/config/repository-values.ts +162 -5
- package/src/config/schema.ts +34 -16
- package/src/lane-prompt/texts.ts +2 -2
- package/src/plan-format/bare-codes.ts +28 -0
- package/src/sync/command-context.ts +4 -2
- package/src/sync/herdr-plugin.ts +30 -6
- package/src/sync/init.ts +17 -16
- 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)))
|
package/kit/hooks/hooks.json
CHANGED
|
@@ -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)** —
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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;
|
|
7
|
+
The formatter and linter config is the authority; where it is silent, match the surrounding code.
|
|
8
8
|
|
|
9
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
-
-
|
|
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;
|
|
112
|
-
-
|
|
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
|
|
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
|
-
|
|
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 (`
|
|
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 |
|
|
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
|
-
###
|
|
15
|
+
### Run the tests where the runner can see the project's config
|
|
16
16
|
|
|
17
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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`.
|
|
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
|
|
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
|
|
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
|
|
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):
|