jackherd 1.16.0 → 1.17.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 (155) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/INSTALL.md +2 -1
  3. package/README.md +22 -21
  4. package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
  5. package/dist/config.d.ts +33 -1
  6. package/dist/config.js +57 -5
  7. package/dist/plan-format.d.ts +18 -6
  8. package/dist/plan-format.js +8 -1
  9. package/docs/plan-contract.md +1 -0
  10. package/herdr-plugin/herdr-plugin.toml +4 -1
  11. package/herdr-plugin/restore-plans.ts +60 -26
  12. package/herdr-plugin/resume.ts +18 -0
  13. package/install.sh +1 -1
  14. package/kit/hooks/ask-rules.json +11 -0
  15. package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
  16. package/kit/hooks/hooks/approval-gate/index.ts +36 -7
  17. package/kit/hooks/hooks/approval-gate/run.ts +72 -16
  18. package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
  19. package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
  20. package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
  21. package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
  22. package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
  23. package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
  24. package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
  25. package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
  26. package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
  27. package/kit/hooks/hooks/merge-guard/decision.ts +423 -35
  28. package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
  29. package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
  30. package/kit/hooks/hooks/merge-guard/run.ts +110 -28
  31. package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
  32. package/kit/hooks/hooks/plain-question/form.ts +89 -0
  33. package/kit/hooks/hooks/plain-question/index.ts +44 -4
  34. package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
  35. package/kit/hooks/hooks/plain-question/run.ts +14 -7
  36. package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
  37. package/kit/hooks/hooks.json +11 -2
  38. package/kit/review/agents/skill-review.md +3 -2
  39. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  40. package/kit/rules/rules/kit/code-style.md +6 -6
  41. package/kit/rules/rules/kit/writing-for-agents.md +3 -0
  42. package/kit/workflow/skills/follow/SKILL.md +2 -3
  43. package/kit/workflow/skills/implement/SKILL.md +1 -1
  44. package/kit/workflow/skills/orchestrate/SKILL.md +120 -299
  45. package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
  46. package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
  47. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
  48. package/kit/workflow/skills/orchestrate/references/events.md +46 -1
  49. package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
  50. package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
  51. package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
  52. package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
  53. package/kit/workflow/skills/orchestrate/references/plan-commit.md +9 -2
  54. package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
  55. package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
  56. package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
  57. package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
  58. package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
  59. package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
  60. package/kit/workflow/skills/plan/SKILL.md +3 -2
  61. package/package.json +2 -1
  62. package/recipes/README.md +1 -0
  63. package/recipes/jira-cli.md +1 -1
  64. package/recipes/sources/google-calendar-today.ts +8 -2
  65. package/recipes/sources/jira.ts +10 -2
  66. package/recipes/sources/linear.ts +9 -3
  67. package/recipes/sources/sentry.ts +8 -2
  68. package/recipes/sources.md +4 -2
  69. package/schemas/jackherd.schema.json +67 -0
  70. package/schemas/sources.schema.json +9 -0
  71. package/src/approval/checked.ts +60 -0
  72. package/src/approval/command.ts +16 -0
  73. package/src/check/command.ts +51 -6
  74. package/src/check/lint-pointers.ts +79 -0
  75. package/src/check/lint-shaped.ts +196 -0
  76. package/src/check/plan-structure-check.ts +116 -4
  77. package/src/check/report.ts +9 -3
  78. package/src/check/schema.ts +37 -0
  79. package/src/check/settings.ts +4 -0
  80. package/src/check/skill-budget-defaults.ts +77 -26
  81. package/src/check/skill-budgets.ts +58 -10
  82. package/src/cli.ts +2 -0
  83. package/src/config/personal-sources.ts +46 -10
  84. package/src/config/repository-identity.ts +40 -0
  85. package/src/config/schema.ts +53 -1
  86. package/src/context/command.ts +33 -2
  87. package/src/context/options.ts +33 -8
  88. package/src/context/usage-logs.ts +53 -0
  89. package/src/context/usage.ts +110 -0
  90. package/src/lane/base-command.ts +35 -0
  91. package/src/lane/base.ts +43 -0
  92. package/src/lane/close-check.ts +49 -15
  93. package/src/lane/close-command.ts +71 -11
  94. package/src/lane/close-options.ts +29 -8
  95. package/src/lane/close-survey.ts +13 -1
  96. package/src/lane/close.ts +51 -12
  97. package/src/lane/command.ts +21 -3
  98. package/src/lane/send-command.ts +32 -0
  99. package/src/lane/send-herdr.ts +77 -0
  100. package/src/lane/send-options.ts +23 -0
  101. package/src/lane/send.ts +90 -0
  102. package/src/lane/skill-hint.ts +27 -0
  103. package/src/lane/space-command.ts +30 -0
  104. package/src/lane/space.ts +49 -0
  105. package/src/lane/start-lane-hint.ts +18 -0
  106. package/src/plan-contract/base-check-git.ts +33 -0
  107. package/src/plan-contract/base-check.ts +17 -0
  108. package/src/plan-contract/command.ts +25 -4
  109. package/src/plan-format/plan-structure.ts +17 -5
  110. package/src/plans/ask-coordinator.ts +23 -13
  111. package/src/plans/board-rows.ts +9 -4
  112. package/src/plans/click-hint.ts +4 -0
  113. package/src/plans/command.ts +6 -5
  114. package/src/plans/ensure-panes.ts +14 -3
  115. package/src/plans/git.ts +14 -0
  116. package/src/plans/github.ts +19 -10
  117. package/src/plans/layout.ts +82 -33
  118. package/src/plans/live.ts +8 -12
  119. package/src/plans/plan-panes.ts +21 -4
  120. package/src/plans/selection.ts +29 -0
  121. package/src/plans/settings.ts +4 -0
  122. package/src/resume/command.ts +213 -0
  123. package/src/resume/decision.ts +102 -0
  124. package/src/resume/keeper.ts +65 -0
  125. package/src/resume/location.ts +22 -0
  126. package/src/resume/log.ts +27 -0
  127. package/src/resume/ports.ts +102 -0
  128. package/src/resume/record.ts +116 -0
  129. package/src/resume/targets.ts +84 -0
  130. package/src/resume/texts.ts +27 -0
  131. package/src/secrets/command.ts +15 -5
  132. package/src/sync/herdr-plugin.ts +2 -11
  133. package/src/sync/plan-ask-rules.ts +11 -0
  134. package/src/sync/render.ts +3 -1
  135. package/src/vocabulary/index.ts +13 -0
  136. package/src/waiting/command.ts +13 -4
  137. package/src/watch/command.ts +177 -78
  138. package/src/watch/config-reload.ts +119 -0
  139. package/src/watch/lane-model.ts +11 -1
  140. package/src/watch/lane-progress.ts +70 -8
  141. package/src/watch/lane-report.ts +66 -11
  142. package/src/watch/main-checkout.ts +5 -1
  143. package/src/watch/merge-duties.ts +33 -0
  144. package/src/watch/merge-overlap-read.ts +69 -0
  145. package/src/watch/plan-views.ts +16 -4
  146. package/src/watch/reference-paths.ts +46 -5
  147. package/src/watch/report-questions.ts +5 -2
  148. package/src/watch/single-report.ts +3 -2
  149. package/src/watch/source-dry-run-command.ts +22 -4
  150. package/src/watch/source-reason.ts +58 -0
  151. package/src/watch/source-reload.ts +21 -17
  152. package/src/watch/source-run.ts +24 -7
  153. package/src/watch/sources.ts +9 -2
  154. package/src/watch/texts.ts +14 -3
  155. package/src/watch/waiting-line.ts +4 -3
@@ -1,36 +1,47 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { dirname, join, posix } from 'node:path'
3
3
  import { boardPlansOf, isInsideRepository } from '../../kit/hooks/hooks/shared/board-lanes'
4
- import { planCopiesIn } from '../../kit/hooks/hooks/shared/boards'
4
+ import { boardsIn, planCopiesIn } from '../../kit/hooks/hooks/shared/boards'
5
+ import { readTextOrNull } from '../../kit/hooks/hooks/shared/files'
5
6
  import { codesOfPlans } from '../../kit/hooks/hooks/shared/plan-codes'
6
7
  import {
7
8
  boardRowsWithoutPhase,
8
9
  boardRowsWithProse,
10
+ currentPhase,
9
11
  hasLanesTable,
10
12
  type LongPhaseTitle,
13
+ laneProgress,
11
14
  longPhaseTitles,
12
- type MergedPhaseWithOpenBoxes,
13
15
  mergedPhasesWithOpenBoxes,
14
16
  newPhasesWithoutReviewGate,
17
+ openBoxesOfPhase,
15
18
  PHASE_CELL_LIMIT,
16
19
  PHASE_TITLE_LIMIT,
20
+ type PhaseWithOpenBoxes,
17
21
  type PhaseWithoutGate,
18
22
  type PhaseWithoutRow,
19
23
  PLAN_VOCABULARY,
20
24
  type PlanLanguage,
25
+ type PlanPhase,
21
26
  PROSE_REASON,
27
+ parsePlanPhases,
28
+ parseSprintLanes,
22
29
  phaseIdsOfPlan,
23
30
  phasesWithoutDependencyRow,
24
31
  type RowWithoutPhase,
25
32
  type RowWithProse,
33
+ resolvePlanPath,
26
34
  SHORT_NAME_LIMIT,
27
35
  SHORT_NAME_PROBLEM,
28
36
  type ShortNameFinding,
37
+ type SprintLane,
29
38
  shortNameFindings,
30
39
  } from '../plan-format'
31
40
  import { boardRowsWithBareCodes, type RowWithBareCode } from '../plan-format/bare-codes'
41
+ import { mergePlanPhases } from '../plans/phase-source'
32
42
  import { changedPlansOf } from './deferral-arrows'
33
43
  import { CHECK_STATUS, type CheckResult } from './result'
44
+ import { OPEN_BOXES_LEVEL, type OpenBoxesLevel } from './schema'
34
45
 
35
46
  /** The content of a file at a ref; null where the ref lacks the file. */
36
47
  export type ContentAt = (path: string) => string | null
@@ -72,6 +83,22 @@ type Report<T> = {
72
83
  clean: string
73
84
  }
74
85
 
86
+ /** The lane a checkout is for and where its boards live, as the check reads them from the checkout. */
87
+ export type LaneBoxesInput = {
88
+ root: string
89
+ lane: string
90
+ planDirectory: string
91
+ board: { pattern: string; file: string }
92
+ language: PlanLanguage
93
+ /** A plan as the base has it (the pull request's parent in the watcher); null where it lacks the file. */
94
+ baseOf: ContentAt
95
+ }
96
+
97
+ type BuiltPhase = { planPath: string; phaseId: string; partial: boolean }
98
+
99
+ /** The phase a lane builds with the plan it is in and its open boxes; `open` is null for a phase without any. */
100
+ export type LaneBuild = { path: string; open: PhaseWithOpenBoxes | null }
101
+
75
102
  const changedCount = (count: number, noun: string): string => `${count} changed ${count === 1 ? noun : `${noun}s`}`
76
103
 
77
104
  const findingsResult = <T>(
@@ -132,7 +159,7 @@ const bareCodeReport: Report<RowWithBareCode> = {
132
159
  clean: 'no changed waiting row names a plan code bare',
133
160
  }
134
161
 
135
- const mergedReport: Report<MergedPhaseWithOpenBoxes> = {
162
+ const mergedReport: Report<PhaseWithOpenBoxes> = {
136
163
  name: 'Merged phases with open boxes',
137
164
  noun: 'plan',
138
165
  rule: 'a merged phase ticks or moves on its open boxes',
@@ -205,7 +232,7 @@ const bareCodesOfBoard = (roots: readonly string[], file: Changed, language: Pla
205
232
  codesOfPlans(planTextsOf(roots, file, language), language),
206
233
  ).map(withPath(file.path))
207
234
 
208
- const mergedOf = ({ path, current, base }: Changed, language: PlanLanguage): Found<MergedPhaseWithOpenBoxes>[] =>
235
+ const mergedOf = ({ path, current, base }: Changed, language: PlanLanguage): Found<PhaseWithOpenBoxes>[] =>
209
236
  base ? mergedPhasesWithOpenBoxes(current, base, language).map(withPath(path)) : []
210
237
 
211
238
  const unlistedOf = ({ path, current }: Changed, language: PlanLanguage): Found<PhaseWithoutRow>[] | null =>
@@ -227,6 +254,91 @@ const changedContents = ({ root, previousOf, baseOf, language }: PlanCheckInput,
227
254
  return { path, current, previous, previousLines, base: baseOf(path), board: hasLanesTable(current, language) }
228
255
  })
229
256
 
257
+ const openBoxesText = (lane: string, partialWord: string, path: string, found: PhaseWithOpenBoxes): string =>
258
+ `${path}:${found.line} Phase ${found.id} ${found.title} is built by lane ${lane} but has open boxes at ${found.openLines.join(', ')}; tick them, end them with → P<n> or mark the row ${partialWord}`
259
+
260
+ /** The open lane `input.lane` of the newest board that lists it, with the directory its plan links are relative to. */
261
+ const laneOnBoards = (input: LaneBoxesInput): { lane: SprintLane; directory: string } | null => {
262
+ const boards = boardsIn(input.root, input).sort((left, right) => right.directory.localeCompare(left.directory))
263
+ for (const { directory, text } of boards) {
264
+ const lane = parseSprintLanes(text, input.language).find(({ name, closed }) => name === input.lane && !closed)
265
+ if (lane) return { lane, directory }
266
+ }
267
+ return null
268
+ }
269
+
270
+ /** The lane has work of its own in the checkout, so a phase it ticked further than the base is marked touched, as the herd reads it. */
271
+ const CHECKOUT_WORK = { ahead: 1, dirty: 0 }
272
+
273
+ /**
274
+ * The phases of the plan at `path` in the checkout; with the plan on the base too, those the checkout ticked further
275
+ * are marked touched, which is how the herd tells the phase a lane builds from the first open phase of its row.
276
+ */
277
+ const phasesInCheckout = (input: LaneBoxesInput, path: string): PlanPhase[] => {
278
+ const text = readTextOrNull(join(input.root, path))
279
+ const base = input.baseOf(path)
280
+ const own = text === null ? [] : parsePlanPhases(text, input.language)
281
+ return base === null ? own : mergePlanPhases(parsePlanPhases(base, input.language), own, [], CHECKOUT_WORK)
282
+ }
283
+
284
+ /** The phase the lane's newest board row names, the plan it is in and whether the row marks it as built in parts. */
285
+ const phaseBuiltBy = (input: LaneBoxesInput): BuiltPhase | null => {
286
+ const found = laneOnBoards(input)
287
+ if (!found) return null
288
+ const pathOf = (href: string): string => resolvePlanPath(found.directory, href)
289
+ const phasesByHref = Object.fromEntries(
290
+ found.lane.steps.flatMap(({ planHref }) =>
291
+ planHref === null ? [] : [[planHref, phasesInCheckout(input, pathOf(planHref))] as const],
292
+ ),
293
+ )
294
+ const progress = laneProgress(found.lane, phasesByHref)
295
+ const phase = currentPhase(progress)
296
+ const step = progress.current === null ? null : progress.steps[progress.current.stepIndex].step
297
+ if (!phase || !step?.planHref) return null
298
+ // with the base known, a phase this checkout did not tick further waits in the row; it is not what the lane builds
299
+ if (phase.work === undefined && input.baseOf(pathOf(step.planHref)) !== null) return null
300
+ const partial = step.label.includes(PLAN_VOCABULARY[input.language].partialPhase)
301
+ return { planPath: pathOf(step.planHref), phaseId: phase.id, partial }
302
+ }
303
+
304
+ /**
305
+ * The phase the lane's board row names and its open boxes, read from the checkout whether or not a commit changes the
306
+ * plan. Null for a lane the checkout's board does not list, for a row marked with the `partialPhase` word (`(Teil)`),
307
+ * which builds part of the phase, and for a phase ticked to the end, which is no longer the lane's current one.
308
+ */
309
+ export const laneBuild = (input: LaneBoxesInput): LaneBuild | null => {
310
+ const built = phaseBuiltBy(input)
311
+ const plan = built && !built.partial ? readTextOrNull(join(input.root, built.planPath)) : null
312
+ return built && plan !== null
313
+ ? { path: built.planPath, open: openBoxesOfPhase(plan, input.language, built.phaseId) }
314
+ : null
315
+ }
316
+
317
+ /** What the level makes of a finding: `fail` fails, `warn` warns; `off` never reaches here. */
318
+ const statusOf = (level: OpenBoxesLevel): typeof CHECK_STATUS.fail | typeof CHECK_STATUS.warn =>
319
+ level === OPEN_BOXES_LEVEL.warn ? CHECK_STATUS.warn : CHECK_STATUS.fail
320
+
321
+ /**
322
+ * The open boxes of the phase the lane builds (`laneBuild`) as a check: `level` decides whether they fail, warn or are
323
+ * not looked at (`off` returns null).
324
+ */
325
+ export const laneOpenBoxesCheck = (input: LaneBoxesInput & { level: OpenBoxesLevel }): CheckResult | null => {
326
+ if (input.level === OPEN_BOXES_LEVEL.off) return null
327
+ const started = Date.now()
328
+ const build = laneBuild(input)
329
+ if (!build) return null
330
+ const { open } = build
331
+ return {
332
+ name: 'Open boxes of the lane phase',
333
+ command: `lane ${input.lane}, the phase its board row names has no open box`,
334
+ status: open === null ? CHECK_STATUS.pass : statusOf(input.level),
335
+ detail: open
336
+ ? openBoxesText(input.lane, PLAN_VOCABULARY[input.language].partialPhase, build.path, open)
337
+ : 'the phase the lane builds has no open box left',
338
+ durationMs: Date.now() - started,
339
+ }
340
+ }
341
+
230
342
  /**
231
343
  * The structure of the changed plans and boards:
232
344
  * - a phase the change added (against `HEAD`) with tasks and without a review gate fails;
@@ -143,12 +143,18 @@ export const formatPlanHeader = (
143
143
  '',
144
144
  ]
145
145
 
146
+ const ADVISORY_LINE = /^(hint|note):/
147
+
148
+ /** A passing check shows only its advisory lines (`hint:`, `note:`); any other status shows its whole detail. */
149
+ const shownDetail = ({ status, detail }: CheckResult): string[] => {
150
+ const lines = detail ? detail.split('\n') : []
151
+ return status === CHECK_STATUS.pass ? lines.filter((line) => ADVISORY_LINE.test(line)) : lines
152
+ }
153
+
146
154
  export const formatResults = (results: readonly CheckResult[]): string[] =>
147
155
  results.flatMap((result) => [
148
156
  `${STATUS_ICON[result.status]} ${result.name} (${result.command}, ${result.durationMs}ms)`,
149
- ...(result.status !== CHECK_STATUS.pass && result.detail
150
- ? result.detail.split('\n').map((line) => ` ${line}`)
151
- : []),
157
+ ...shownDetail(result).map((line) => ` ${line}`),
152
158
  ])
153
159
 
154
160
  const warnings = (count: number): string => (count > 0 ? `${count} ${count === 1 ? 'warning' : 'warnings'}, ` : '')
@@ -68,6 +68,30 @@ const publicContent = z.strictObject({
68
68
  /** A budget in words per path or glob from the project root; a key joining two paths with ` + ` budgets them together. */
69
69
  const skillBudgets = z.record(glob, z.number().int().positive())
70
70
 
71
+ /** What `jh check` does with open boxes in the phase a lane builds: fail, warn or leave the check out. */
72
+ export const OPEN_BOXES_LEVEL = { fail: 'fail', warn: 'warn', off: 'off' } as const
73
+
74
+ export type OpenBoxesLevel = (typeof OPEN_BOXES_LEVEL)[keyof typeof OPEN_BOXES_LEVEL]
75
+
76
+ /** What `jh check` does with a lint-shaped rule block that names no lint rule: warn (the default), fail or leave the check out. */
77
+ export const LINT_POINTERS_LEVEL = { warn: 'warn', fail: 'fail', off: 'off' } as const
78
+
79
+ export type LintPointersLevel = (typeof LINT_POINTERS_LEVEL)[keyof typeof LINT_POINTERS_LEVEL]
80
+
81
+ const lintPointers = z.strictObject({
82
+ /** `warn` reports a block and lets the commit through; `fail` stops it; `off` skips the check. */
83
+ level: z
84
+ .enum([LINT_POINTERS_LEVEL.warn, LINT_POINTERS_LEVEL.fail, LINT_POINTERS_LEVEL.off])
85
+ .default(LINT_POINTERS_LEVEL.warn),
86
+ /** Rule files left unread, as globs from the project root; the kit copies in the lock file are always left out. */
87
+ exclude: z.array(glob).default([]),
88
+ })
89
+
90
+ export type LintPointersConfig = z.infer<typeof lintPointers>
91
+
92
+ /** The check without a `lintPointers` block: warn, nothing excluded. */
93
+ export const DEFAULT_LINT_POINTERS: LintPointersConfig = { level: LINT_POINTERS_LEVEL.warn, exclude: [] }
94
+
71
95
  /**
72
96
  * The `check` block of `.jackherd/config.json`. Test and type commands come from the top-level
73
97
  * `commands` block; this block holds what only the check runner reads.
@@ -89,6 +113,19 @@ export const checkConfigSchema = z.strictObject({
89
113
  * path and sets one for the project's own skills; a file with none is measured against the kit's default.
90
114
  */
91
115
  skillBudgets: skillBudgets.optional(),
116
+ /**
117
+ * What a lane's check does when the phase its board row names still has open boxes without `→ P<n>`: `fail` (the
118
+ * default) stops the commit, `warn` reports, `off` skips. A row carrying the `partialPhase` word (`(Teil)` in German) builds part of the phase and is
119
+ * left alone; a branch whose directory is no lane's is not checked.
120
+ */
121
+ openBoxes: z
122
+ .enum([OPEN_BOXES_LEVEL.fail, OPEN_BOXES_LEVEL.warn, OPEN_BOXES_LEVEL.off])
123
+ .default(OPEN_BOXES_LEVEL.fail),
124
+ /**
125
+ * Whether a new or changed block of a rule file (or `CLAUDE.md`, `AGENTS.md`) that a linter could check, such
126
+ * as a forbidden import path or call, must name its lint rule with `` `lint:<rule id>` `` (or `` `lint:none` `` and a reason).
127
+ */
128
+ lintPointers: lintPointers.optional(),
92
129
  /** Also run tests in directories that hold test files but belong to no package. */
93
130
  looseTests: z.boolean().default(false),
94
131
  /**
@@ -25,6 +25,8 @@ export type CheckSettings = {
25
25
  baseBranch: string
26
26
  /** Where the lane worktrees live, below the main checkout: a board's plans are read there too. */
27
27
  worktreeDirectory: string
28
+ /** Where the boards live: the directories under `planDirectory` and the file in each. */
29
+ board: { pattern: string; file: string }
28
30
  /** Put before the kit's skill and agent names in this project; empty without one. */
29
31
  skillPrefix: string
30
32
  /** What the lock files (without one, the manifests) decide: one stack, none, or several (ambiguous); `stackOf` reads the stack from it. */
@@ -45,6 +47,7 @@ const planLanguageSchema = jackherdConfigSchema.shape.planLanguage
45
47
  const baseBranchSchema = jackherdConfigSchema.shape.baseBranch
46
48
  const worktreeDirectorySchema = jackherdConfigSchema.shape.worktreeDirectory
47
49
  const skillPrefixSchema = jackherdConfigSchema.shape.skillPrefix
50
+ const boardSchema = jackherdConfigSchema.shape.board
48
51
 
49
52
  /**
50
53
  * The check runner's view of `.jackherd/config.json`: the shared `commands` block, `planDirectory`, `planLanguage` and `baseBranch` plus its own
@@ -62,6 +65,7 @@ export const parseCheckSettings = (raw: unknown, facts: RepositoryFacts): CheckS
62
65
  planLanguage: planLanguageSchema.parse(object.planLanguage),
63
66
  baseBranch: baseBranchSchema.parse(object.baseBranch),
64
67
  worktreeDirectory: worktreeDirectorySchema.parse(object.worktreeDirectory),
68
+ board: boardSchema.parse(object.board),
65
69
  skillPrefix: skillPrefixSchema.parse(object.skillPrefix),
66
70
  stack: detectStack(facts.lockFiles, facts.manifests),
67
71
  notes: detected.notes,
@@ -8,11 +8,11 @@ export const KIT_FILE_FALLBACK_BUDGET = 2000
8
8
  export const SUPPLEMENT_BUDGET = 800
9
9
 
10
10
  /**
11
- * The kit's word budgets, by path from the project root. A key joining two paths with ` + ` budgets what is
12
- * loaded together. A budget is the measured size plus at most 10 %, so a file has room for a sentence, not for a
13
- * section; a file that needs more justifies the growth where it raises the number.
11
+ * The kit's word targets, by path from the project root: the size a file should have, not the size it has. A key
12
+ * joining two paths with ` + ` targets what is loaded together. A file above its target carries a ceiling in
13
+ * `KIT_SKILL_CEILINGS`; growth never moves a target.
14
14
  */
15
- export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
15
+ export const KIT_SKILL_TARGETS: Readonly<Record<string, number>> = {
16
16
  '.claude/skills/devops/SKILL.md': 321,
17
17
  '.claude/skills/diagnose/SKILL.md': 1521,
18
18
  '.claude/skills/diagnose/references/feedback-loops.md': 636,
@@ -22,36 +22,47 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
22
22
  '.claude/skills/follow/SKILL.md': 1498,
23
23
  '.claude/skills/grill/SKILL.md': 685,
24
24
  '.claude/skills/implement/SKILL.md': 2190,
25
- // P33: the paw in the header chip, panes by tab and title, the lane in the context line of a passed-on question.
26
- // Plain words: no plan code without its words in a short question.
27
- // Any language: a pointer in § 0 and a section in events.md for `plan missing`.
28
- // Open up: a pointer in § 0 to adopt-plan.md, and § 2 step 8 waits until the lane works on its prompt (+113);
29
- // step 0 lets the adoption diff stand for its question, step 2 bases on origin only for an unchanged plan.
30
- // Cross-check: measured in a repository whose stack nothing detects, every unset command renders as a note (+7).
31
- // Ready phase: the `done` / `idle` row names the watcher's mandatory `ready:` line (+20).
32
- // Drafts until the merge: § 5 step 5 opens a draft and readies it, Self-merge step 3 waits for the runs after it (+100).
33
- // Ready round: a PR of the lane waiting for approval or merge holds only the commit; the self-merge command runs
34
- // alone, so the guard can check it (+132, the longest rendering of the kit's renderings); skill-review: the commit
35
- // of a PR awaiting approval stages only its files, a branch over a squash-merged PR moves with `--onto` (+51).
36
- '.claude/skills/orchestrate/SKILL.md': 8413,
25
+ '.claude/skills/orchestrate/SKILL.md': 4500,
37
26
  // skill-review: the copy is taken after a mirror, and rejecting together with a remark is a remark (+36).
38
27
  // Cross-check: an adopted plan also gets short names and a no-review-gate line, which `jh check` asks of a new
39
28
  // plan; the proof became a word diff, since a short name goes into an existing line (+74); skill-review: the
40
29
  // done check names `jh check`, the gate and short-name rules as the code reads them (+24).
41
30
  '.claude/skills/orchestrate/references/adopt-plan.md': 680,
42
31
  '.claude/skills/orchestrate/references/acceptance.md': 200,
43
- '.claude/skills/orchestrate/references/close-lane.md': 250,
44
- '.claude/skills/orchestrate/references/coordinator-handover.md': 551,
32
+ // Core cut: the `due` row of the wake-up table moved here (+33).
33
+ '.claude/skills/orchestrate/references/appointments.md': 290,
34
+ // Resume: `--branch` finishes a lane whose worktree is gone, a detached worktree on the base closes, a closed lane
35
+ // is reported (+41). The ask rule is the one question: the coordinator asks no dialog before it (+15).
36
+ '.claude/skills/orchestrate/references/close-lane.md': 309,
37
+ '.claude/skills/orchestrate/references/coordinator-handover.md': 567,
45
38
  '.claude/skills/orchestrate/references/draft-review.md': 300,
46
39
  // P33: a source entry the user must read or decide becomes a row of what waits on them, and when it closes.
47
- '.claude/skills/orchestrate/references/events.md': 1166,
40
+ // Core cut: the `deploy`, `stalled` and lane `warn` rows of the wake-up table moved here (+101), and the checks
41
+ // after `Nothing changed.` from `jh check` (+70 as rendered), and the board patch when `main checkout behind`
42
+ // cannot fast-forward (+60). skill-review: `lane removed`, `no longer listed`, `did not stop` and
43
+ // `herd --ensure failed`, whose lines the watcher already points here (+86).
44
+ '.claude/skills/orchestrate/references/events.md': 1491,
48
45
  '.claude/skills/orchestrate/references/herd.md': 299,
49
- '.claude/skills/orchestrate/references/lane-handover.md': 280,
50
- '.claude/skills/orchestrate/references/lane-session.md': 280,
46
+ '.claude/skills/orchestrate/references/herd-ask.md': 152,
47
+ // Core cut: the request on `lane alert` moved here (+56); ending a lane names `herdr agent prompt <lane> /exit`,
48
+ // the form the merge guard passes (+23).
49
+ '.claude/skills/orchestrate/references/lane-handover.md': 360,
50
+ // Ending the agent names `herdr agent prompt <lane> /exit`, the form the merge guard passes (+25).
51
+ '.claude/skills/orchestrate/references/lane-session.md': 285,
51
52
  '.claude/skills/orchestrate/references/lane-setup.md': 380,
52
53
  // Open up: an adopted plan is committed like a missing one, and a tracked copy is restored after the merge.
53
- '.claude/skills/orchestrate/references/plan-commit.md': 350,
54
+ // Core cut: the question of § 2 step 0 (the adoption's diff round, the approval it is, `<topic>`, a no) (+60).
55
+ '.claude/skills/orchestrate/references/plan-commit.md': 412,
56
+ // Core cut: § 5 self-merge steps 1 to 6, moved out of the core.
57
+ '.claude/skills/orchestrate/references/self-merge.md': 379,
54
58
  '.claude/skills/orchestrate/references/sensitive-input.md': 269,
59
+ '.claude/skills/orchestrate/references/stage-approval.md': 634,
60
+ '.claude/skills/orchestrate/references/standing-approval.md': 458,
61
+ // Core cut: § 2 steps 0 to 8 and § 3 dev server, moved out of the core.
62
+ '.claude/skills/orchestrate/references/start-lane.md': 712,
63
+ // Core cut: § 4 waiting rows beyond the basic one, moved out of the core.
64
+ '.claude/skills/orchestrate/references/waiting.md': 216,
65
+ '.claude/skills/orchestrate/references/watch-without-monitor.md': 154,
55
66
  '.claude/skills/plan/SKILL.md': 1920,
56
67
  '.claude/skills/plan/references/template-English.md': 536,
57
68
  '.claude/skills/plan/references/template-German.md': 501,
@@ -61,9 +72,7 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
61
72
  // Any language: step 3 points to proposing the missing commands from the CI; the proposal itself is a reference.
62
73
  '.claude/skills/start/SKILL.md': 630,
63
74
  '.claude/skills/uncle-bob/SKILL.md': 466,
64
- // P33 and open up: grows with the skill above (+132 for the ready round and the lone merge call); measured with
65
- // this repository's supplement after the skill-review fixes (+153).
66
- '.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 9165,
75
+ '.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 5100,
67
76
  '.claude/agents/devops.md': 104,
68
77
  '.claude/agents/explore.md': 132,
69
78
  '.claude/agents/implement.md': 303,
@@ -73,8 +82,50 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
73
82
  '.claude/agents/skill-review.md': 468,
74
83
  '.claude/agents/spec-review.md': 1137,
75
84
  '.claude/agents/uncle-bob.md': 111,
76
- '.claude/rules/kit/code-style.md': 1664,
85
+ // Lint before prose: the vocabulary section and two one-liners cut, a sentence on `lint:` pointers added (-79).
86
+ '.claude/rules/kit/code-style.md': 1585,
77
87
  // Drafts until the merge: a precondition opens a draft when `merge.openAsDraft` says so (+39).
78
88
  '.claude/rules/kit/pull-requests.md': 640,
79
89
  '.claude/rules/kit/testing.md': 741,
80
90
  }
91
+
92
+ /**
93
+ * The most a file above its target may have, today's size. A ceiling only falls: the plan that cuts the file lowers
94
+ * or deletes it, and a rise needs a reason in the comment above the entry (a test holds every entry to one).
95
+ */
96
+ export const KIT_SKILL_CEILINGS: Readonly<Record<string, number>> = {
97
+ // Orchestrate core plan P1: the reviewer also judges the whole length of a file against its target (+12).
98
+ // Lint before prose: lens 7 for lint-shaped prose and the sentence on the `Lint pointers` result (+33).
99
+ '.claude/agents/skill-review.md': 513,
100
+ // Lint before prose: the quality check that asks of every written rule whether the linter can check it (+44).
101
+ '.claude/skills/plan/SKILL.md': 1964,
102
+ // P33: the paw in the header chip, panes by tab and title, the lane in the context line of a passed-on question.
103
+ // Plain words: no plan code without its words in a short question.
104
+ // Any language: a pointer in § 0 and a section in events.md for `plan missing`.
105
+ // Open up: a pointer in § 0 to adopt-plan.md, and § 2 step 8 waits until the lane works on its prompt (+113);
106
+ // step 0 lets the adoption diff stand for its question, step 2 bases on origin only for an unchanged plan.
107
+ // Cross-check: measured in a repository whose stack nothing detects, every unset command renders as a note (+7).
108
+ // Ready phase: the `done` / `idle` row names the watcher's mandatory `ready:` line (+20).
109
+ // Drafts until the merge: § 5 step 5 opens a draft and readies it, Self-merge step 3 waits for the runs after it (+100).
110
+ // Ready round: a PR of the lane waiting for approval or merge holds only the commit; the self-merge command runs
111
+ // alone, so the guard can check it (+132, the longest rendering of the kit's renderings); skill-review: the commit
112
+ // of a PR awaiting approval stages only its files, a branch over a squash-merged PR moves with `--onto` (+51).
113
+ // Change id: the PR question names the staged change's patch id, which the merge guard compares; skill-review:
114
+ // staging first and the worktree named (+55, to cut again).
115
+ // Bundled board lines: written into the main checkout at once, carried to the base bundled, and the fast-forward
116
+ // that keeps them (+115).
117
+ // Audit P6: commands and hooks carry rules the prose repeated; monitorless watching, appointments, the standing
118
+ // approval and the herd's ask line moved to references. Cut to the measured size plus 5 %.
119
+ // Orchestrate core plan P1: the target is 4500. P5 cut the core to its longest rendering; the rest of the cut is
120
+ // a follow-up, decided by the user after the P6 runs.
121
+ '.claude/skills/orchestrate/SKILL.md': 5938,
122
+ // P33 and open up: grows with the skill above (+132 for the ready round and the lone merge call); measured with
123
+ // this repository's supplement after the skill-review fixes (+153); click merge grows with the skill (+30); change
124
+ // id grows with the skill, staging first and the worktree named after skill-review (+55). Audit P6: cut with the
125
+ // skill, the supplement down to its deviations; measured size plus 5 %.
126
+ // Orchestrate core plan P1: the target is 5100; P5 lowered the ceiling with the skill's to the rendered sum.
127
+ '.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 6635,
128
+ }
129
+
130
+ /** What `jh check` measures against: a file's ceiling where it has one, else its target. */
131
+ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = { ...KIT_SKILL_TARGETS, ...KIT_SKILL_CEILINGS }
@@ -11,6 +11,7 @@ import type { SkillBudgets } from './schema'
11
11
  import {
12
12
  KIT_FILE_FALLBACK_BUDGET,
13
13
  KIT_SKILL_BUDGETS,
14
+ KIT_SKILL_TARGETS,
14
15
  OWN_SKILL_BUDGET,
15
16
  SUPPLEMENT_BUDGET,
16
17
  } from './skill-budget-defaults'
@@ -21,8 +22,18 @@ export type BudgetTable = Readonly<SkillBudgets>
21
22
  /** A file with the budget it falls back on when neither the project nor the kit names one. */
22
23
  export type FileWords = { path: string; words: number; fallback: number; foreign?: boolean }
23
24
 
24
- /** A `foreign` measurement belongs to a skill `skills-lock.json` lists: it warns, it never fails. */
25
- export type Measurement = { label: string; words: number; budget: number; foreign?: boolean }
25
+ /**
26
+ * A `foreign` measurement belongs to a skill `skills-lock.json` lists: it warns, it never fails. `target` is the
27
+ * kit's target for the path, `projectBudget` is the project's own entry for the path.
28
+ */
29
+ export type Measurement = {
30
+ label: string
31
+ words: number
32
+ budget: number
33
+ foreign?: boolean
34
+ target?: number
35
+ projectBudget?: number
36
+ }
26
37
 
27
38
  type Overrun = typeof CHECK_STATUS.warn | typeof CHECK_STATUS.fail
28
39
 
@@ -63,12 +74,16 @@ export const overrunOf = (words: number, budget: number): Overrun | null => {
63
74
  return words > budget ? CHECK_STATUS.warn : null
64
75
  }
65
76
 
66
- /** The project's entry for the exact path, else its first glob that matches, else the kit's default, else `fallback`. */
67
- export const budgetOf = (path: string, project: BudgetTable, defaults: BudgetTable, fallback: number): number => {
77
+ /** The project's entry for the exact path, else its first glob that matches; undefined when it names neither. */
78
+ const projectBudgetOf = (path: string, project: BudgetTable): number | undefined => {
68
79
  const glob = Object.keys(project).find((key) => isGlob(key) && matchesGlob(path, key))
69
- return project[path] ?? (glob === undefined ? undefined : project[glob]) ?? defaults[path] ?? fallback
80
+ return project[path] ?? (glob === undefined ? undefined : project[glob])
70
81
  }
71
82
 
83
+ /** The project's entry for the exact path, else its first glob that matches, else the kit's default, else `fallback`. */
84
+ export const budgetOf = (path: string, project: BudgetTable, defaults: BudgetTable, fallback: number): number =>
85
+ projectBudgetOf(path, project) ?? defaults[path] ?? fallback
86
+
72
87
  /** Every budget path starts with `.claude/` (the kit-content test holds them to shipped files). */
73
88
  const prefixedPath = (path: string, prefix: string): string =>
74
89
  claudePath(prefixedKitPath(path.slice(CLAUDE_DIRECTORY.length + 1), prefix))
@@ -91,23 +106,39 @@ const sumEntry = (key: string, files: readonly FileWords[]): number | null => {
91
106
  return parts.every((part) => part !== undefined) ? parts.reduce((total, part) => total + part.words, 0) : null
92
107
  }
93
108
 
94
- /** Every file against its budget, then every sum entry of the project and the kit whose files all exist. */
109
+ /**
110
+ * Every file against its budget, then every sum entry of the project and the kit whose files all exist. `targets`
111
+ * are the kit's targets by path; a measurement carries its own when the kit names one.
112
+ */
95
113
  export const measureFiles = (
96
114
  files: readonly FileWords[],
97
115
  project: BudgetTable,
98
116
  defaults: BudgetTable,
117
+ targets: BudgetTable = {},
99
118
  ): Measurement[] => {
100
119
  const single = files.map(({ path, words, fallback, foreign }) => ({
101
120
  label: foreign ? `${path} ${FOREIGN_LABEL}` : path,
102
121
  words,
103
122
  budget: budgetOf(path, project, defaults, fallback),
104
123
  foreign,
124
+ target: targets[path],
125
+ projectBudget: projectBudgetOf(path, project),
105
126
  }))
106
127
  const sums = [...new Set([...Object.keys(project), ...Object.keys(defaults)])]
107
128
  .filter((key) => key.includes(SUM_SEPARATOR))
108
129
  .flatMap((key) => {
109
130
  const words = sumEntry(key, files)
110
- return words === null ? [] : [{ label: key, words, budget: budgetOf(key, project, defaults, 0) }]
131
+ return words === null
132
+ ? []
133
+ : [
134
+ {
135
+ label: key,
136
+ words,
137
+ budget: budgetOf(key, project, defaults, 0),
138
+ target: targets[key],
139
+ projectBudget: projectBudgetOf(key, project),
140
+ },
141
+ ]
111
142
  })
112
143
  return [...single, ...sums]
113
144
  }
@@ -128,17 +159,33 @@ const statusOf = (overruns: readonly MeasuredOverrun[]): CheckStatus => {
128
159
  return overruns.length > 0 ? CHECK_STATUS.warn : CHECK_STATUS.pass
129
160
  }
130
161
 
131
- /** One line per overrun; one line in total when none is over. Warns above a budget, fails above the budget plus 10 %. */
162
+ /** A file within the kit's ceiling but above the kit's target for it: a hint, never a failure. A project budget has its note instead. */
163
+ const targetHint = ({ label, words, budget, target, projectBudget }: Measurement): string[] =>
164
+ target !== undefined && projectBudget === undefined && words > target && budget > target
165
+ ? [`hint: ${label} is over target by ${words - target} words (target ${target}, ceiling ${budget})`]
166
+ : []
167
+
168
+ /** A budget the project set above the kit's target for the path: shown, so the deviation stays visible. */
169
+ const overrideNote = ({ label, target, projectBudget }: Measurement): string[] =>
170
+ target !== undefined && projectBudget !== undefined && projectBudget > target
171
+ ? [`note: ${label} budget ${projectBudget} comes from the project, above the kit target ${target}`]
172
+ : []
173
+
174
+ /**
175
+ * One line per overrun; one line in total when none is over. Warns above a budget, fails above the budget plus 10 %.
176
+ * Lines for a file over its target and for a project budget above the kit target follow, and change no status.
177
+ */
132
178
  export const budgetResult = (measured: readonly Measurement[], durationMs: number): CheckResult => {
133
179
  const overruns = measured.flatMap((entry) => {
134
180
  const overrun = overrunOfMeasurement(entry)
135
181
  return overrun === null ? [] : [{ entry, overrun }]
136
182
  })
183
+ const lines = overruns.length > 0 ? overruns.map(overrunLine) : [withinLine(measured.length)]
137
184
  return {
138
185
  name: 'Skill budgets',
139
186
  command: `${measured.length} measured, words per budget`,
140
187
  status: statusOf(overruns),
141
- detail: overruns.map(overrunLine).join('\n') || withinLine(measured.length),
188
+ detail: [...lines, ...measured.flatMap(targetHint), ...measured.flatMap(overrideNote)].join('\n'),
142
189
  durationMs,
143
190
  }
144
191
  }
@@ -211,5 +258,6 @@ export const skillBudgetCheck = (root: string, project: BudgetTable = {}, skillP
211
258
  ...ownSkillPaths(root, kit).flatMap((path) => fileWordsOf(root, path, OWN_SKILL_BUDGET)),
212
259
  ].map(markForeign(foreign))
213
260
  const defaults = prefixedBudgets(KIT_SKILL_BUDGETS, skillPrefix)
214
- return files.length === 0 ? null : budgetResult(measureFiles(files, project, defaults), Date.now() - started)
261
+ const targets = prefixedBudgets(KIT_SKILL_TARGETS, skillPrefix)
262
+ return files.length === 0 ? null : budgetResult(measureFiles(files, project, defaults, targets), Date.now() - started)
215
263
  }
package/src/cli.ts CHANGED
@@ -18,6 +18,7 @@ const ALIASES = new Set<string>([HERD_COMMAND.alias])
18
18
 
19
19
  /** One line per command; each entry lazily imports the module that exports its `run`. */
20
20
  export const COMMANDS: CommandTable = {
21
+ approval: () => import('./approval/command'),
21
22
  check: () => import('./check/command'),
22
23
  config: () => import('./config/command'),
23
24
  context: () => import('./context/command'),
@@ -28,6 +29,7 @@ export const COMMANDS: CommandTable = {
28
29
  mermaid: () => import('./mermaid/command'),
29
30
  plan: () => import('./plan-contract/command'),
30
31
  preview: () => import('./preview/command'),
32
+ resume: () => import('./resume/command'),
31
33
  secret: () => import('./secrets/command'),
32
34
  settled: () => import('./settled/command'),
33
35
  sync: () => import('./sync/command'),