jackherd 1.6.0 → 1.7.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 +31 -0
- package/INSTALL.md +176 -0
- package/README.md +260 -315
- package/assets/branding/README.md +7 -3
- package/assets/branding/jackherd-logo-dark-1024.png +0 -0
- package/assets/branding/jackherd-logo-dark-512.png +0 -0
- package/assets/branding/jackherd-logo-dark.svg +1 -1
- package/assets/icons/bark-dark.svg +1 -0
- package/assets/icons/bark.svg +1 -0
- package/assets/icons/bowl-dark.svg +1 -0
- package/assets/icons/bowl.svg +1 -0
- package/assets/icons/decide-dark.svg +1 -0
- package/assets/icons/decide.svg +1 -0
- package/assets/icons/ear-dark.svg +1 -0
- package/assets/icons/ear.svg +1 -0
- package/assets/icons/lanes-dark.svg +1 -0
- package/assets/icons/lanes.svg +1 -0
- package/assets/icons/plan-dark.svg +1 -0
- package/assets/icons/plan.svg +1 -0
- package/assets/icons/space.svg +1 -0
- package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
- package/dist/config.d.ts +14 -3
- package/dist/config.js +111 -10
- package/dist/plan-format.d.ts +180 -77
- package/dist/plan-format.js +292 -93
- package/docs/lanes-dark.svg +50 -0
- package/docs/lanes.svg +50 -1
- package/docs/skills-dark.svg +65 -0
- package/docs/skills.svg +65 -1
- package/docs/workflow-dark.svg +138 -0
- package/docs/workflow.svg +135 -169
- package/herdr-plugin/herdr-plugin.toml +6 -6
- package/herdr-plugin/open-plans.ts +1 -1
- package/herdr-plugin/restore-plans.ts +7 -7
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
- package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
- package/kit/hooks/hooks/approval-gate/index.ts +69 -0
- package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
- package/kit/hooks/hooks/approval-gate/request.ts +111 -0
- package/kit/hooks/hooks/approval-gate/run.ts +94 -0
- package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
- package/kit/hooks/hooks/approval-gate/store.ts +70 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
- package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
- package/kit/hooks/hooks/merge-guard/run.ts +2 -1
- package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
- package/kit/hooks/hooks/model-gate/index.ts +58 -0
- package/kit/hooks/hooks/model-gate/run.ts +63 -0
- package/kit/hooks/hooks/model-log/index.ts +60 -0
- package/kit/hooks/hooks/model-log/run.ts +57 -0
- package/kit/hooks/hooks/model-log/store.ts +40 -0
- package/kit/hooks/hooks/model-log/transcript.ts +37 -0
- package/kit/hooks/hooks/shared/checkout.ts +22 -0
- package/kit/hooks/hooks/shared/command.ts +6 -0
- package/kit/hooks/hooks/shared/config.ts +21 -0
- package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
- package/kit/hooks/hooks/shared/files.ts +19 -0
- package/kit/hooks/hooks/shared/fresh.ts +4 -0
- package/kit/hooks/hooks/shared/json.ts +4 -0
- package/kit/hooks/hooks/shared/model-family.ts +48 -0
- package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
- package/kit/hooks/hooks/shared/private-directory.ts +29 -0
- package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
- package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
- package/kit/hooks/hooks.json +25 -0
- package/kit/workflow/skills/follow/SKILL.md +4 -3
- package/kit/workflow/skills/implement/SKILL.md +6 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +113 -30
- package/kit/workflow/skills/plan/SKILL.md +5 -5
- package/kit/workflow/skills/start/SKILL.md +2 -2
- package/package.json +4 -1
- package/schemas/jackherd.schema.json +43 -5
- package/src/check/command.ts +31 -4
- package/src/check/deferral-arrows.ts +1 -1
- package/src/check/parallel-report.ts +23 -0
- package/src/check/plan-structure-check.ts +120 -53
- package/src/cli.ts +8 -2
- package/src/config/nested-quantifier.ts +73 -0
- package/src/config/repository-directory.ts +11 -0
- package/src/config/schema.ts +63 -8
- package/src/plan-format/board-prose.ts +36 -0
- package/src/plan-format/dependencies.ts +227 -0
- package/src/plan-format/documentation-gate.ts +16 -0
- package/src/plan-format/index.ts +23 -1
- package/src/plan-format/lanes.ts +35 -1
- package/src/plan-format/model-column.ts +52 -0
- package/src/plan-format/phases.ts +16 -1
- package/src/plan-format/plan-structure.ts +10 -28
- package/src/plan-format/progress.ts +7 -29
- package/src/plans/command.ts +3 -3
- package/src/plans/current-phase.ts +71 -0
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/herd-command.ts +4 -0
- package/src/plans/lane-discovery.ts +14 -2
- package/src/plans/lane-tickets.ts +69 -0
- package/src/plans/layout.ts +182 -88
- package/src/plans/live.ts +5 -1
- package/src/plans/phase-models-read.ts +37 -0
- package/src/plans/phase-models.ts +70 -0
- package/src/plans/plan-panes.ts +8 -7
- package/src/plans/pull-requests.ts +78 -4
- package/src/plans/selection.ts +1 -40
- package/src/plans/settings.ts +9 -3
- package/src/plans/shown-with-more.ts +5 -0
- package/src/plans/snapshot.ts +82 -8
- package/src/plans/sprint-html.ts +2 -2
- package/src/plans/supervisor.ts +5 -4
- package/src/plans/symbols.ts +9 -6
- package/src/plans/texts.ts +30 -13
- package/src/plans/view.ts +1 -0
- package/src/sync/herdr-plugin.ts +1 -1
- package/src/sync/next-steps.ts +1 -1
- package/src/sync/render.ts +2 -1
- package/src/sync/setup-questions.ts +2 -2
- package/src/terminal/text.ts +4 -0
- package/src/terminal/types.ts +1 -1
- package/src/vocabulary/index.ts +39 -0
- package/src/watch/approvals.ts +96 -0
- package/src/watch/command.ts +154 -50
- package/src/watch/failed-tests.ts +52 -0
- package/src/watch/failure-read.ts +56 -0
- package/src/watch/failures.ts +107 -0
- package/src/watch/feedback-item.ts +21 -0
- package/src/watch/feedback-read.ts +2 -1
- package/src/watch/feedback.ts +37 -28
- package/src/watch/gh-process.ts +53 -0
- package/src/watch/gh.ts +10 -20
- package/src/watch/heartbeat-progress.ts +1 -1
- package/src/watch/lane-model.ts +54 -0
- package/src/watch/lane-parallel.ts +46 -0
- package/src/watch/lane-progress.ts +28 -4
- package/src/watch/lane-report.ts +62 -9
- package/src/watch/lane-sources.ts +1 -1
- package/src/watch/no-finding.ts +47 -0
- package/src/watch/plan-badges-read.ts +19 -7
- package/src/watch/plan-badges.ts +59 -7
- package/src/watch/plan-views.ts +5 -5
- package/src/watch/previews.ts +3 -9
- package/src/watch/pull-requests.ts +12 -2
- package/src/watch/recorded-reads.ts +25 -0
- package/src/watch/source-fields.ts +4 -0
- package/src/watch/source-tickets.ts +99 -0
- package/src/watch/sources.ts +62 -15
- package/src/watch/texts.ts +21 -1
- package/src/watch/watch-defaults.ts +8 -0
- package/src/watch/watch-events.ts +64 -4
- package/src/watch/watcher-lock.ts +1 -1
- package/src/watch/workflow-runs.ts +5 -7
- package/docs/lanes.mmd +0 -9
- package/docs/skills.mmd +0 -10
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** The model families a subagent can be started on, by the word their aliases and full ids carry. */
|
|
2
|
+
export const MODEL_FAMILY = { haiku: 'haiku', sonnet: 'sonnet', opus: 'opus', fable: 'fable' } as const
|
|
3
|
+
|
|
4
|
+
export type ModelFamily = (typeof MODEL_FAMILY)[keyof typeof MODEL_FAMILY]
|
|
5
|
+
|
|
6
|
+
/** The families from the weakest to the strongest. */
|
|
7
|
+
const MODEL_RANK: readonly ModelFamily[] = [MODEL_FAMILY.haiku, MODEL_FAMILY.sonnet, MODEL_FAMILY.opus, MODEL_FAMILY.fable]
|
|
8
|
+
|
|
9
|
+
export const MODEL_COMPARISON = { fits: 'fits', weaker: 'weaker', stronger: 'stronger', unknown: 'unknown' } as const
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How a model compares with a plan's recommendation, which may name a range (`Fable 5 / Opus 4.8`). `recommended` is
|
|
13
|
+
* the family to start on instead: the strongest of the range for a stronger model, else the weakest.
|
|
14
|
+
*/
|
|
15
|
+
export type ModelComparison =
|
|
16
|
+
| { kind: typeof MODEL_COMPARISON.fits | typeof MODEL_COMPARISON.weaker | typeof MODEL_COMPARISON.stronger; recommended: ModelFamily }
|
|
17
|
+
| { kind: typeof MODEL_COMPARISON.unknown }
|
|
18
|
+
|
|
19
|
+
const TOKEN_SEPARATOR = /[^a-z0-9]+/
|
|
20
|
+
|
|
21
|
+
const isModelFamily = (token: string): token is ModelFamily => (MODEL_RANK as readonly string[]).includes(token)
|
|
22
|
+
|
|
23
|
+
const rankOf = (family: ModelFamily): number => MODEL_RANK.indexOf(family)
|
|
24
|
+
|
|
25
|
+
/** Every family a text names as a whole word, once each, in its order: `claude-opus-5-5`, `Opus 5.5`, `opus`. */
|
|
26
|
+
export const familiesIn = (text: string): ModelFamily[] => [
|
|
27
|
+
...new Set(text.toLowerCase().split(TOKEN_SEPARATOR).filter(isModelFamily)),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
/** The family of a model alias or id; null for `inherit`, an unknown vendor or an empty value. */
|
|
31
|
+
export const familyOf = (model: string): ModelFamily | null => familiesIn(model)[0] ?? null
|
|
32
|
+
|
|
33
|
+
const byRank = (first: ModelFamily, second: ModelFamily): number => rankOf(first) - rankOf(second)
|
|
34
|
+
|
|
35
|
+
/** The families a text names, from the weakest to the strongest. */
|
|
36
|
+
export const rankedFamiliesIn = (text: string): ModelFamily[] => familiesIn(text).sort(byRank)
|
|
37
|
+
|
|
38
|
+
/** How `model` compares with the families the recommendation names, from its weakest to its strongest. */
|
|
39
|
+
export const compareModels = (recommendation: string, model: string): ModelComparison => {
|
|
40
|
+
const named = rankedFamiliesIn(recommendation)
|
|
41
|
+
const family = familyOf(model)
|
|
42
|
+
const [weakest] = named
|
|
43
|
+
const strongest = named.at(-1)
|
|
44
|
+
if (!family || !weakest || !strongest) return { kind: MODEL_COMPARISON.unknown }
|
|
45
|
+
if (rankOf(family) < rankOf(weakest)) return { kind: MODEL_COMPARISON.weaker, recommended: weakest }
|
|
46
|
+
if (rankOf(family) > rankOf(strongest)) return { kind: MODEL_COMPARISON.stronger, recommended: strongest }
|
|
47
|
+
return { kind: MODEL_COMPARISON.fits, recommended: weakest }
|
|
48
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const ROW_PHASE = /^(?:phase\s+)?p?(\d+[a-z]?)\b/i
|
|
2
|
+
const SEPARATOR_ROW = /^[\s|:-]+$/
|
|
3
|
+
/** The header of a plan's model column in the plan languages of the kit's templates. */
|
|
4
|
+
const MODEL_COLUMN_HEADERS = ['model', 'modell']
|
|
5
|
+
const PHASE_COLUMN_HEADER = 'phase'
|
|
6
|
+
|
|
7
|
+
const cellsOf = (line: string): string[] =>
|
|
8
|
+
line
|
|
9
|
+
.trim()
|
|
10
|
+
.replace(/^\|/, '')
|
|
11
|
+
.replace(/\|$/, '')
|
|
12
|
+
.split('|')
|
|
13
|
+
.map((cell) => cell.trim())
|
|
14
|
+
|
|
15
|
+
/** The index of the model column when the row is the header of a phase table with one, else -1. */
|
|
16
|
+
const modelColumnOf = (cells: readonly string[]): number =>
|
|
17
|
+
cells[0]?.toLowerCase() === PHASE_COLUMN_HEADER
|
|
18
|
+
? cells.findIndex((cell) => MODEL_COLUMN_HEADERS.includes(cell.toLowerCase()))
|
|
19
|
+
: -1
|
|
20
|
+
|
|
21
|
+
const rowPhase = (cell: string): string | null => ROW_PHASE.exec(cell.replaceAll('*', ''))?.[1]?.toLowerCase() ?? null
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The model cell of the phase's row in a table whose header starts with `Phase` and has a `Model`/`Modell` column;
|
|
25
|
+
* null when the plan has no such table or no row for the phase.
|
|
26
|
+
*/
|
|
27
|
+
export const recommendationOf = (plan: string, phase: string): string | null => {
|
|
28
|
+
let modelColumn = -1
|
|
29
|
+
for (const line of plan.split('\n')) {
|
|
30
|
+
if (!line.trim().startsWith('|')) {
|
|
31
|
+
modelColumn = -1
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
const cells = cellsOf(line)
|
|
35
|
+
const header = modelColumnOf(cells)
|
|
36
|
+
if (header !== -1) modelColumn = header
|
|
37
|
+
else if (modelColumn !== -1 && !SEPARATOR_ROW.test(line) && rowPhase(cells[0] ?? '') === phase)
|
|
38
|
+
return cells[modelColumn] ?? null
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, type Stats, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/** The mode of a file in a private directory: readable by its owner only. */
|
|
5
|
+
export const PRIVATE_FILE = 0o600
|
|
6
|
+
const PRIVATE_DIRECTORY = 0o700
|
|
7
|
+
|
|
8
|
+
/** What sits at the path, a dangling link too, without following a link; null for nothing. */
|
|
9
|
+
const entryAt = (path: string): Stats | null => {
|
|
10
|
+
try {
|
|
11
|
+
return lstatSync(path)
|
|
12
|
+
} catch {
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A directory only its owner reads, ignored by git: created `0700` when missing; when present it must be a directory
|
|
19
|
+
* and not a link (else this throws, so nothing is written through it), is narrowed to `0700` and gets the `.gitignore`
|
|
20
|
+
* it lacks. An existing `.gitignore` is kept.
|
|
21
|
+
*/
|
|
22
|
+
export const ensurePrivateDirectory = (directory: string): void => {
|
|
23
|
+
const entry = entryAt(directory)
|
|
24
|
+
if (entry && (entry.isSymbolicLink() || !entry.isDirectory())) throw new Error(`${directory} is no directory`)
|
|
25
|
+
if (!entry) mkdirSync(directory, { recursive: true, mode: PRIVATE_DIRECTORY })
|
|
26
|
+
chmodSync(directory, PRIVATE_DIRECTORY)
|
|
27
|
+
const ignore = join(directory, '.gitignore')
|
|
28
|
+
if (!existsSync(ignore)) writeFileSync(ignore, '*\n')
|
|
29
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { isAbsolute } from 'node:path'
|
|
2
|
+
|
|
3
|
+
const segmentsOf = (path: string): string[] => path.split(/[\\/]/).filter((segment) => segment !== '' && segment !== '.')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The same rule as `isRepositoryDirectory` in the package's config schema, which a hook cannot import.
|
|
7
|
+
* A directory below the repository root: relative, not the root itself and without a `..` segment, so a config cannot
|
|
8
|
+
* point a hook elsewhere or make the whole repository the directory.
|
|
9
|
+
*/
|
|
10
|
+
export const isRepositoryDirectory = (value: unknown): value is string =>
|
|
11
|
+
typeof value === 'string' &&
|
|
12
|
+
!isAbsolute(value) &&
|
|
13
|
+
segmentsOf(value).length > 0 &&
|
|
14
|
+
!segmentsOf(value).includes('..')
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
|
2
|
+
import { recommendationOf } from './plan-model-table'
|
|
3
|
+
import { isRepositoryDirectory } from './repository-directory'
|
|
4
|
+
|
|
5
|
+
/** Where a subagent's phase is looked up, from the config of the project and of the home directory. */
|
|
6
|
+
export type SubagentSettings = { planDirectory: string; agentNames: string[] }
|
|
7
|
+
|
|
8
|
+
/** The phase a prompt names, the plan it found it in (relative to the root, `/`-separated) and the row's model cell. */
|
|
9
|
+
export type PhaseRecommendation = { phase: string; plan: string; cell: string | null }
|
|
10
|
+
|
|
11
|
+
/** `realPath` resolves every link and answers null for a path that does not exist. */
|
|
12
|
+
export type PlanFiles = { realPath: (path: string) => string | null; readText: (path: string) => string | null }
|
|
13
|
+
|
|
14
|
+
const IMPLEMENT_AGENT = 'implement'
|
|
15
|
+
const DEFAULT_PLAN_DIRECTORY = 'docs/plans'
|
|
16
|
+
/** A prompt is read up to here: the phase and the plan stand at its start, and a giant prompt costs no time. */
|
|
17
|
+
const PROMPT_LIMIT = 64 * 1024
|
|
18
|
+
/** The config schema's `skillPrefix`: lower-case letters and digits ending in a dash. */
|
|
19
|
+
const SKILL_PREFIX = /^[a-z0-9]+-$/
|
|
20
|
+
/** The convention of a dispatching prompt (`Phase P3`), preferred over the bare form (`phase 3`). */
|
|
21
|
+
const PREFIXED_PHASE = /\bphase\s+p(\d+[a-z]?)\b/gi
|
|
22
|
+
const BARE_PHASE = /\bphase\s+(\d+[a-z]?)\b/gi
|
|
23
|
+
const PATH_SEPARATOR = /[\s`'"()<>[\]{}„“”«»,;]+/
|
|
24
|
+
const SENTENCE_END = /[.:!?]+$/
|
|
25
|
+
const MARKDOWN_FILE = /\.md$/
|
|
26
|
+
|
|
27
|
+
/** The one phase a pattern names, lower-case; null for none or for several. */
|
|
28
|
+
const singlePhase = (prompt: string, pattern: RegExp): string | null => {
|
|
29
|
+
const phases = new Set([...prompt.matchAll(pattern)].map(([, phase]) => phase.toLowerCase()))
|
|
30
|
+
return phases.size === 1 ? [...phases][0] : null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The phase a prompt is dispatched for: the one `Phase P<n>` it names, else the one `phase <n>`; null otherwise. */
|
|
34
|
+
export const phaseOfPrompt = (prompt: string): string | null =>
|
|
35
|
+
singlePhase(prompt, PREFIXED_PHASE) ?? singlePhase(prompt, BARE_PHASE)
|
|
36
|
+
|
|
37
|
+
/** Every Markdown path a prompt names, in its order; a split on separators, linear in the prompt's length. */
|
|
38
|
+
export const planCandidatesOfPrompt = (prompt: string): string[] =>
|
|
39
|
+
prompt
|
|
40
|
+
.split(PATH_SEPARATOR)
|
|
41
|
+
.map((word) => word.replace(SENTENCE_END, ''))
|
|
42
|
+
.filter((word) => MARKDOWN_FILE.test(word))
|
|
43
|
+
|
|
44
|
+
const isInside = (directory: string, path: string): boolean => {
|
|
45
|
+
const inner = relative(directory, path)
|
|
46
|
+
return inner !== '' && !inner.startsWith(`..${sep}`) && inner !== '..' && !isAbsolute(inner)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The first candidate that exists and lies in the plan directory below `root`, both compared where their links
|
|
51
|
+
* resolve, so a link under the plan directory to a file elsewhere never counts; null when none does.
|
|
52
|
+
*/
|
|
53
|
+
export const planFileOf = (
|
|
54
|
+
root: string,
|
|
55
|
+
planDirectory: string,
|
|
56
|
+
candidates: readonly string[],
|
|
57
|
+
realPath: (path: string) => string | null,
|
|
58
|
+
): string | null => {
|
|
59
|
+
const directory = realPath(resolve(root, planDirectory))
|
|
60
|
+
if (directory === null) return null
|
|
61
|
+
const files = candidates.map((candidate) => realPath(resolve(root, candidate)))
|
|
62
|
+
return files.find((file): file is string => file !== null && isInside(directory, file)) ?? null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `implement` and its name under each usable prefix, once each. */
|
|
66
|
+
export const implementAgentNames = (prefixes: readonly string[]): string[] => [
|
|
67
|
+
...new Set([
|
|
68
|
+
IMPLEMENT_AGENT,
|
|
69
|
+
...prefixes.filter((prefix) => SKILL_PREFIX.test(prefix)).map((prefix) => `${prefix}${IMPLEMENT_AGENT}`),
|
|
70
|
+
]),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
/** The settings from the configs, project first: its plan directory if usable, every prefix. */
|
|
74
|
+
export const subagentSettingsOf = (configs: readonly Record<string, unknown>[]): SubagentSettings => ({
|
|
75
|
+
planDirectory: configs.map((config) => config.planDirectory).find(isRepositoryDirectory) ?? DEFAULT_PLAN_DIRECTORY,
|
|
76
|
+
agentNames: implementAgentNames(
|
|
77
|
+
configs.flatMap((config) => (typeof config.skillPrefix === 'string' ? [config.skillPrefix] : [])),
|
|
78
|
+
),
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The phase the first 64 KB of a prompt name, with the plan they name in the plan directory and that plan's model cell
|
|
83
|
+
* for the phase (null without a row). The plan's path is taken from the resolved root. Null without a phase or an
|
|
84
|
+
* existing plan.
|
|
85
|
+
*/
|
|
86
|
+
export const phaseRecommendation = (
|
|
87
|
+
root: string,
|
|
88
|
+
prompt: string,
|
|
89
|
+
planDirectory: string,
|
|
90
|
+
files: PlanFiles,
|
|
91
|
+
): PhaseRecommendation | null => {
|
|
92
|
+
const head = prompt.slice(0, PROMPT_LIMIT)
|
|
93
|
+
const phase = phaseOfPrompt(head)
|
|
94
|
+
const file = phase ? planFileOf(root, planDirectory, planCandidatesOfPrompt(head), files.realPath) : null
|
|
95
|
+
const text = file ? files.readText(file) : null
|
|
96
|
+
if (!phase || !file || text === null) return null
|
|
97
|
+
const base = files.realPath(root) ?? root
|
|
98
|
+
return { phase, plan: relative(base, file).split(sep).join('/'), cell: recommendationOf(text, phase) }
|
|
99
|
+
}
|
package/kit/hooks/hooks.json
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
+
"approval-gate": {
|
|
3
|
+
"event": "PermissionRequest",
|
|
4
|
+
"handler": {
|
|
5
|
+
"type": "command",
|
|
6
|
+
"command": "cd \"$CLAUDE_PROJECT_DIR\" && bun .claude/hooks/approval-gate/index.ts '{{kit.approval.allow}}' '{{kit.approval.deny}}' '{{kit.approval.pending}}' '{{kit.approval.denied}}'",
|
|
7
|
+
"timeout": 600
|
|
8
|
+
}
|
|
9
|
+
},
|
|
2
10
|
"ask-hint": {
|
|
3
11
|
"event": "Stop",
|
|
4
12
|
"handler": {
|
|
@@ -36,6 +44,23 @@
|
|
|
36
44
|
"onFailure": "block"
|
|
37
45
|
}
|
|
38
46
|
},
|
|
47
|
+
"model-gate": {
|
|
48
|
+
"event": "PreToolUse",
|
|
49
|
+
"matcher": "Agent|Task",
|
|
50
|
+
"handler": {
|
|
51
|
+
"type": "command",
|
|
52
|
+
"command": "cd \"$CLAUDE_PROJECT_DIR\" && bun .claude/hooks/model-gate/index.ts",
|
|
53
|
+
"timeout": 10
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"model-log": {
|
|
57
|
+
"event": "SubagentStop",
|
|
58
|
+
"handler": {
|
|
59
|
+
"type": "command",
|
|
60
|
+
"command": "cd \"$CLAUDE_PROJECT_DIR\" && bun .claude/hooks/model-log/index.ts",
|
|
61
|
+
"timeout": 10
|
|
62
|
+
}
|
|
63
|
+
},
|
|
39
64
|
"watch-guard": {
|
|
40
65
|
"event": "PreToolUse",
|
|
41
66
|
"matcher": "Bash",
|
|
@@ -10,11 +10,12 @@ You add one entry to `watch.sources`, so that `jh watch` reports a new or change
|
|
|
10
10
|
|
|
11
11
|
## 1. Ask
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
`AskUserQuestion` in {{kit.responseLanguage}}, only for what the argument and the conversation leave open, each with the recommended option first; at most four questions per call, so more open points go into a second call:
|
|
14
14
|
|
|
15
|
-
- **Service:** the recipes the README lists, and "another command that prints JSON".
|
|
15
|
+
- **Service:** the recipes the README lists (Jira, Linear, GitHub issues, GitHub Projects, Sentry, a status page), and "another command that prints JSON".
|
|
16
16
|
- **What to follow:** the item the argument names (its comments, recommended) or a query over several items.
|
|
17
17
|
- **Lane:** the lane the reports name, from the board's lanes table, or none.
|
|
18
|
+
- **Ticket:** whether the entries are tickets (`ticket: true`, recommended for an issue tracker or a project board), which the herd shows as a badge beside the plan of their lane: the lane above, or the lane whose name, board row or plan names the ticket's id. A source of comments or messages is no ticket. The path of the status you find yourself in step 3.
|
|
18
19
|
- **What reports:** a change of what the user reads (title, body, the latest comment; recommended), or a change of named fields such as a status (`reportOn` with those fields, mapped in `map`).
|
|
19
20
|
- **Own changes:** ignore the user's own account on that service (`ignoreAuthors`, needs an author in the map), or report everything.
|
|
20
21
|
|
|
@@ -29,7 +30,7 @@ Anything missing goes to the user as the command to run themselves (`! <command>
|
|
|
29
30
|
|
|
30
31
|
Before filling, read the help of the installed CLI version for every command the recipe calls (`acli jira workitem search --help` and `workitem view --help`, `gh issue list --help`) and confirm that the fields and options the entry needs exist there. Read the provider's web documentation only when the help leaves it open or no recipe fits. A recipe is a starting point; the installed version decides.
|
|
31
32
|
|
|
32
|
-
Fill the recipe with the item or query and the answers from step 1: a `name` of letters, digits, `.`, `_` and `-` that says what is followed, `lane` and `
|
|
33
|
+
Fill the recipe with the item or query and the answers from step 1: a `name` of letters, digits, `.`, `_` and `-` that says what is followed, `lane`, `ignoreAuthors` and `ticket` where chosen. For a ticket source, find the path of the status in the output (from the CLI's help, or the dry run with `--raw`) and set it as `map.status`; the entry you show for approval in step 4 carries it, and without a status in the output the entry has none and the badge shows only the key. A recipe that needs a script (a token from the environment, or one call per item) gets TypeScript run by Bun: write it to the scratchpad first as `<name>.ts`, never into the repository before step 4, and let the entry's `command` be `["bun", "scripts/sources/<name>.ts"]`. The script:
|
|
33
34
|
|
|
34
35
|
- bounds every request and every call of a tool with `AbortSignal.timeout(20_000)`, where a shell recipe would use `--max-time`;
|
|
35
36
|
- reads a token only from `process.env`, and never prints it, passes it as an argument or writes it into an output;
|
|
@@ -19,22 +19,22 @@ You are the **Implementer**. Your job is to carry tasks through completely: expl
|
|
|
19
19
|
1. Read the model recommended for the phase from the table.
|
|
20
20
|
2. Compare it with the model your current run is on:
|
|
21
21
|
- **Same model** → continue with step 1.
|
|
22
|
-
- **Any other model, stronger or weaker** → delegate the phase to a subagent with the recommended model: `Agent(subagent_type: 'implement', model: <recommended>, prompt: …)`. Hand it what you already know (files located, decisions made, the phase's review gate) so it does not repeat the exploration. It ticks its phase's tasks itself as it finishes them (see Plan Hygiene). You stay the orchestrator: the review gate, gate verification, the arc42 and architecture checks and `/pre-commit` stay with you.
|
|
22
|
+
- **Any other model, stronger or weaker** → delegate the phase to a subagent with the recommended model: `Agent(subagent_type: 'implement', model: <recommended>, prompt: …)`. Hand it what you already know (files located, decisions made, the phase's review gate) so it does not repeat the exploration. The prompt names the phase as `Phase P<n>` and the plan by its path from the repository root, the one form the kit's hooks read: `model-gate` stops a start on a weaker model than the table's, `model-log` records the model the subagent ran on for the herd. It ticks its phase's tasks itself as it finishes them (see Plan Hygiene). You stay the orchestrator: the review gate, gate verification, the arc42 and architecture checks and `/pre-commit` stay with you.
|
|
23
23
|
3. **The recommendation is a choice, not a floor.** The table records the tier the plan's author judged right for the error cost; a model cannot switch itself, so spawning the subagent is the way to apply it.
|
|
24
24
|
|
|
25
25
|
## Step 0b: Parallel Gate (plans with a `{{kit.heading.dependencies}}` table)
|
|
26
26
|
|
|
27
27
|
When the user asks for more than one phase:
|
|
28
28
|
|
|
29
|
-
1. **
|
|
30
|
-
2. **Batch:**
|
|
31
|
-
3. **Dispatch the batch in one message**, one `Agent(subagent_type: 'implement', model: <from the model table>, prompt: …)` per phase, in the same checkout: the disjoint `{{kit.heading.writes}}` sets are the isolation. Each prompt names its phase and its `{{kit.heading.writes}}` set as the **only** paths it may edit, besides ticking its own phase's task lines in the plan, and states that it ticks the task checkboxes of its own phase as it finishes each one (Plan Hygiene), and leaves the review gate, commits and `/pre-commit` to you — it reports changed files, test results and the gate items it ran.
|
|
29
|
+
1. **Read the computed groups:** run `jh check --parallel <plan>`. It reads the plan's `{{kit.heading.dependencies}}` table and the plan's ticks and prints one line per group of phases that can run side by side, `group 1: P19 ∥ P20`; group 1 can start now, a later group waits for the phases before it. A phase is ready when every `{{kit.heading.dependsOn}}` entry is done (ticked off with its review gate); a phase with an external gate (`User: …`) is never ready, since the plan cannot say the gate holds. Two phases share a group only with no shared path under `{{kit.heading.writes}}` and no shared `{{kit.heading.serialMarker}}` (compared without case). Only backticked paths count, written from the repository root; a directory overlaps every file below it, a glob counts as the directory before its first wildcard, and a wildcard at the root (`*.md`) overlaps everything. A `like N` entry (`wie N`, the plan's own word) in the cell, with several phases (`like 15 and 16`) as their union, adds the sets of those phases. `no dependency table` means the plan has none: the phases run one after the other. `no phase is ready` means every open phase waits, is done, or is in no group.
|
|
30
|
+
2. **Batch:** the requested phases in group 1. A requested phase in a later group waits, naming the phase it collides with. One in no group is not ready for one of these reasons: it is done, it has an external gate (ask whether the gate holds), its `like N` entry names an unknown phase or runs in a cycle, or its `{{kit.heading.writes}}` cell has no backticked path; say which. The command checks paths and markers only: two phases that collide in content, or both edit a shared release file (changelog, version bump), stay your judgement, so serialise them yourself.
|
|
31
|
+
3. **Dispatch the batch in one message**, one `Agent(subagent_type: 'implement', model: <from the model table>, prompt: …)` per phase, in the same checkout: the disjoint `{{kit.heading.writes}}` sets are the isolation. Each prompt names its phase (`Phase P<n>` and the plan's path, as in Step 0) and its `{{kit.heading.writes}}` set as the **only** paths it may edit, besides ticking its own phase's task lines in the plan, and states that it ticks the task checkboxes of its own phase as it finishes each one (Plan Hygiene), and leaves the review gate, commits and `/pre-commit` to you — it reports changed files, test results and the gate items it ran.
|
|
32
32
|
4. **When the batch returns:** check each report against its set (`git diff --stat`; an edit outside the set is a finding; a ticked line outside its own phase is one too), tick what it left open, walk each review gate. Gate items that write outside a phase's set (documentation, plan synchronisation) are yours. Then recompute the ready set and dispatch the next batch.
|
|
33
33
|
5. **Commits stay serial and with you:** one `/pre-commit` per finished phase, in phase order.
|
|
34
34
|
|
|
35
35
|
## Plan Hygiene (when implementing from a plan in `{{kit.planDirectory}}/`)
|
|
36
36
|
|
|
37
|
-
- **Tick off tasks as you go.** As soon as a task is done, set `- [ ]` → `- [x]` in the plan document, right then, so the plan reflects real progress and the
|
|
37
|
+
- **Tick off tasks as you go.** As soon as a task is done, set `- [ ]` → `- [x]` in the plan document, right then, so the plan reflects real progress and the herd shows it while the phase runs.
|
|
38
38
|
- **A subagent ticks only its own phase,** and only with `Edit` on the exact task line (enough of it to be unique in the file). Never `Write` the plan or rewrite it with a script: parallel subagents share the file, and `Edit` applies its replacement to the file as it is on disk, so ticks in other phases survive, while a whole-file write from an older read erases them. A line that is not unique, and every review-gate item, stays with the caller.
|
|
39
39
|
- **The review gate is binding.** A phase is finished once its review gate has been walked through completely and ticked off; only then comes the next phase or `/pre-commit`. Gate items that name a skill (`/uncle-bob`, `/security-audit`, …) are ticked after the skill has actually run.
|
|
40
40
|
|
|
@@ -42,7 +42,7 @@ When the user asks for more than one phase:
|
|
|
42
42
|
|
|
43
43
|
Before you write a single line:
|
|
44
44
|
|
|
45
|
-
0. A phase not yet in the plan gets its skeleton there first (heading, origin, tasks, review gate), before any research, so the
|
|
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
47
|
2. Check `package.json` for `gen` / `generate` / `codegen` scripts and run the generator for a generated file instead of editing it.
|
|
48
48
|
|