jackherd 1.24.0 → 1.25.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 (68) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +21 -21
  3. package/dist/{chunk-JDO6O3UO.js → chunk-O365KHHP.js} +139 -3
  4. package/dist/config.d.ts +4 -0
  5. package/dist/config.js +4 -1
  6. package/dist/plan-format.d.ts +121 -3
  7. package/dist/plan-format.js +176 -35
  8. package/docs/plan-contract.md +52 -5
  9. package/herdr-plugin/herdr-plugin.toml +1 -1
  10. package/install.sh +1 -1
  11. package/kit/hooks/hooks/approval-gate/index.ts +2 -2
  12. package/kit/hooks/hooks/merge-guard/lane-plan.ts +1 -1
  13. package/kit/hooks/hooks/shared/board-lanes.ts +33 -12
  14. package/kit/hooks/hooks/shared/boards.ts +22 -6
  15. package/kit/workflow/skills/orchestrate/references/herd.md +3 -3
  16. package/kit/workflow/skills/plan/SKILL.md +3 -1
  17. package/kit/workflow/skills/plan/references/template-English.md +2 -0
  18. package/kit/workflow/skills/plan/references/template-German.md +2 -0
  19. package/package.json +1 -1
  20. package/schemas/jackherd.schema.json +7 -0
  21. package/src/brief/command.ts +1 -1
  22. package/src/brief/lane-facts.ts +2 -2
  23. package/src/check/command.ts +2 -1
  24. package/src/check/plan-structure-check.ts +68 -14
  25. package/src/check/settings.ts +6 -1
  26. package/src/check/skill-budget-defaults.ts +6 -3
  27. package/src/config/schema.ts +3 -0
  28. package/src/lane/close-board.ts +4 -4
  29. package/src/lane/close-command.ts +2 -2
  30. package/src/lane/dev-command.ts +1 -1
  31. package/src/lane/port-command.ts +1 -1
  32. package/src/plan-contract/command.ts +2 -1
  33. package/src/plan-contract/contract.ts +30 -9
  34. package/src/plan-format/bare-codes.ts +4 -4
  35. package/src/plan-format/dependencies.ts +5 -5
  36. package/src/plan-format/lanes.ts +3 -4
  37. package/src/plan-format/markdown.ts +125 -10
  38. package/src/plan-format/open-status.ts +15 -0
  39. package/src/plan-format/phases.ts +45 -4
  40. package/src/plan-format/plan-head.ts +31 -9
  41. package/src/plan-format/plan-structure.ts +43 -7
  42. package/src/plan-format/progress.ts +3 -0
  43. package/src/plan-format/sprint-document.ts +4 -4
  44. package/src/plan-format/unrecognized-sections.ts +21 -0
  45. package/src/plan-format/waiting.ts +13 -13
  46. package/src/plans/board-close.ts +25 -16
  47. package/src/plans/board-pick.ts +1 -1
  48. package/src/plans/ensure-panes.ts +2 -2
  49. package/src/plans/layout-summary.ts +8 -1
  50. package/src/plans/layout.ts +14 -5
  51. package/src/plans/live.ts +1 -1
  52. package/src/plans/settings.ts +3 -0
  53. package/src/plans/snapshot.ts +9 -5
  54. package/src/plans/sprint-export.ts +1 -1
  55. package/src/plans/texts.ts +3 -0
  56. package/src/plans/view.ts +1 -0
  57. package/src/resume/coordinator-read.ts +2 -2
  58. package/src/settled/command.ts +1 -1
  59. package/src/settled/settle.ts +5 -5
  60. package/src/sync/render.ts +20 -5
  61. package/src/sync/sync-plan.ts +6 -2
  62. package/src/sync/sync.ts +3 -2
  63. package/src/vocabulary/index.ts +192 -4
  64. package/src/watch/appointments.ts +13 -20
  65. package/src/watch/command.ts +5 -5
  66. package/src/watch/lane-progress.ts +1 -1
  67. package/src/watch/merge-overlap-read.ts +2 -2
  68. package/src/watch/plan-missing-read.ts +3 -1
@@ -27,16 +27,38 @@ const PLAN_COLUMN = 2
27
27
 
28
28
  const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
29
29
 
30
- const sectionLines = (markdown: string, heading: string): string[] => {
30
+ const MARKER = /<!--\s*jh:\s*([a-z-]+)\s*-->/
31
+
32
+ const markerOf = (text: string | undefined): string | undefined => (text === undefined ? undefined : MARKER.exec(text)?.[1])
33
+
34
+ const languagesFrom = (language: BoardLanguage): BoardLanguage[] =>
35
+ [language, ...(Object.keys(BOARD_WORDS) as BoardLanguage[]).filter((other) => other !== language)]
36
+
37
+ /** The index of the lanes section's heading: the marker `<!-- jh: lanes -->` under it, else the heading of `language`, else of the other language. */
38
+ const lanesHeadingIndex = (lines: string[], language: BoardLanguage): number => {
39
+ const marked = lines.findIndex((line, index) => SECTION_END.test(line) && markerOf(lines[index + 1]) === 'lanes')
40
+ if (marked !== -1) return marked
41
+ for (const candidate of languagesFrom(language)) {
42
+ const pattern = new RegExp(`^##\\s+${escapeRegExp(BOARD_WORDS[candidate].lanes)}\\s*$`)
43
+ const found = lines.findIndex((line) => pattern.test(line))
44
+ if (found !== -1) return found
45
+ }
46
+ return -1
47
+ }
48
+
49
+ const lanesSectionLines = (markdown: string, language: BoardLanguage): string[] => {
31
50
  const lines = markdown.split('\n')
32
- const pattern = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`)
33
- const start = lines.findIndex((line) => pattern.test(line))
34
- if (start === -1) return []
35
- const rest = lines.slice(start + 1)
51
+ const heading = lanesHeadingIndex(lines, language)
52
+ if (heading === -1) return []
53
+ const rest = lines.slice(heading + 1)
36
54
  const end = rest.findIndex((line) => SECTION_END.test(line))
37
55
  return end === -1 ? rest : rest.slice(0, end)
38
56
  }
39
57
 
58
+ /** Whether a lane's name cell says the lane was cleaned up: the marker `<!-- jh: lane-closed -->`, or the word of any language. */
59
+ const isClosedCell = (cell: string): boolean =>
60
+ markerOf(cell) === 'lane-closed' || Object.values(BOARD_WORDS).some(({ laneClosed }) => cell.includes(laneClosed))
61
+
40
62
  const cellsOf = (line: string): string[] =>
41
63
  line
42
64
  .slice(1, line.endsWith('|') ? -1 : undefined)
@@ -55,7 +77,7 @@ const firstTableLines = (lines: string[]): string[] => {
55
77
  const laneRows = (markdown: string, language: BoardLanguage): Row[] => {
56
78
  const rows: Row[] = []
57
79
  let previous: string | null = null
58
- for (const line of firstTableLines(sectionLines(markdown, BOARD_WORDS[language].lanes))) {
80
+ for (const line of firstTableLines(lanesSectionLines(markdown, language))) {
59
81
  const cells = cellsOf(line)
60
82
  const first = cells[0] ?? ''
61
83
  const lane: string | null = first.match(LANE_NAME)?.[1] ?? (first.startsWith(CONTINUATION) ? previous : null)
@@ -73,11 +95,11 @@ const planPathOf = (boardDirectory: string, cell: string): string[] => {
73
95
  }
74
96
 
75
97
  /** The rows of one lane since it was last cleaned up: a named row without the mark after a closed one starts it anew. */
76
- const currentRows = <Row extends { cells: string[] }>(rows: Row[], closedWord: string): Row[] =>
98
+ const currentRows = <Row extends { cells: string[] }>(rows: Row[]): Row[] =>
77
99
  rows.reduce<{ closed: boolean; kept: Row[] }>(
78
100
  ({ closed, kept }, row) => {
79
101
  const first = row.cells[0] ?? ''
80
- const marked = first.includes(closedWord)
102
+ const marked = isClosedCell(first)
81
103
  const startsAnew = closed && !marked && !first.startsWith(CONTINUATION)
82
104
  return { closed: marked || (closed && !startsAnew), kept: startsAnew ? [row] : [...kept, row] }
83
105
  },
@@ -100,11 +122,10 @@ export const boardPlansOf = (markdown: string, boardDirectory: string, language:
100
122
 
101
123
  /** Every lane the lanes table names whose current rows do not say it was cleaned up, in table order, read in one pass. */
102
124
  export const openLaneNamesOf = (markdown: string, language: BoardLanguage): string[] => {
103
- const { laneClosed } = BOARD_WORDS[language]
104
125
  const rowsByLane = new Map<string, Row[]>()
105
126
  for (const row of laneRows(markdown, language)) rowsByLane.set(row.lane, [...(rowsByLane.get(row.lane) ?? []), row])
106
127
  return [...rowsByLane].flatMap(([lane, rows]) =>
107
- currentRows(rows, laneClosed).some(({ cells }) => (cells[0] ?? '').includes(laneClosed)) ? [] : [lane],
128
+ currentRows(rows).some(({ cells }) => isClosedCell(cells[0] ?? '')) ? [] : [lane],
108
129
  )
109
130
  }
110
131
 
@@ -117,9 +138,9 @@ export const lanePlansOf = (
117
138
  ): LanePlans | undefined => {
118
139
  const all = laneRows(markdown, language).filter((row) => row.lane === lane)
119
140
  if (all.length === 0) return undefined
120
- const rows = currentRows(all, BOARD_WORDS[language].laneClosed)
141
+ const rows = currentRows(all)
121
142
  return {
122
- closed: rows.some(({ cells }) => (cells[0] ?? '').includes(BOARD_WORDS[language].laneClosed)),
143
+ closed: rows.some(({ cells }) => isClosedCell(cells[0] ?? '')),
123
144
  plans: [...new Set(rows.flatMap(({ cells }) => planPathOf(boardDirectory, cells[PLAN_COLUMN] ?? '')))],
124
145
  }
125
146
  }
@@ -12,9 +12,13 @@ import { isRepositoryDirectory } from './repository-directory'
12
12
  /** A board's directory relative to the repository, and its text. */
13
13
  export type Board = { directory: string; text: string }
14
14
 
15
- /** The plan language, the plan directory and the board pattern and file below it, and where the lane worktrees live. */
15
+ /**
16
+ * The plan language, the language the board is written in (the plan language when the config sets none), the plan
17
+ * directory and the board pattern and file below it, and where the lane worktrees live.
18
+ */
16
19
  export type BoardLocation = {
17
20
  planLanguage: BoardLanguage
21
+ boardLanguage: BoardLanguage
18
22
  planDirectory: string
19
23
  board: { pattern: string; file: string }
20
24
  worktreeDirectory: string
@@ -42,8 +46,16 @@ const isPlanLanguage = (value: unknown): value is BoardLanguage => typeof value
42
46
  * The board location's values of a config, each with its default where absent and `undefined` where present but
43
47
  * invalid; `board` is the config's `board` section.
44
48
  */
45
- export const boardLocationValues = (config: Record<string, unknown>, board: Record<string, unknown>) => ({
46
- planLanguage: valueOr(config, 'planLanguage', isPlanLanguage, 'en'),
49
+ export const boardLocationValues = (config: Record<string, unknown>, board: Record<string, unknown>) => {
50
+ const planLanguage = valueOr(config, 'planLanguage', isPlanLanguage, 'en')
51
+ return {
52
+ planLanguage,
53
+ boardLanguage: valueOr(config, 'boardLanguage', isPlanLanguage, planLanguage ?? 'en'),
54
+ ...directoryValues(config, board),
55
+ }
56
+ }
57
+
58
+ const directoryValues = (config: Record<string, unknown>, board: Record<string, unknown>) => ({
47
59
  planDirectory: valueOr(config, 'planDirectory', isRepositoryDirectory, 'docs/plans'),
48
60
  pattern: valueOr(board, 'pattern', matches(BOARD_PATTERN), 'board-*'),
49
61
  file: valueOr(board, 'file', matches(BOARD_FILE), 'board.md'),
@@ -54,10 +66,14 @@ export const boardLocationValues = (config: Record<string, unknown>, board: Reco
54
66
  export const boardLocationOf = (config: Record<string, unknown>): BoardLocation | null => {
55
67
  const board = config.board ?? {}
56
68
  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
69
+ const { planLanguage, boardLanguage, planDirectory, pattern, file, worktreeDirectory } = boardLocationValues(
70
+ config,
71
+ board,
72
+ )
73
+ if (planLanguage === undefined || boardLanguage === undefined) return null
74
+ if (planDirectory === undefined || worktreeDirectory === undefined) return null
59
75
  if (pattern === undefined || file === undefined) return null
60
- return { planLanguage, planDirectory, board: { pattern, file }, worktreeDirectory }
76
+ return { planLanguage, boardLanguage, planDirectory, board: { pattern, file }, worktreeDirectory }
61
77
  }
62
78
 
63
79
  /** The boards under the plan directory of the main checkout whose directory name matches the board pattern. */
@@ -8,13 +8,13 @@ is missing or comes twice.
8
8
  It reads the plans at `origin/{{kit.baseBranch}}`. From a lane's worktree, once the lane has work
9
9
  there, it takes the phases the lane's rows name and any phase that is new there or ticked further
10
10
  than on the base. It redraws when a plan file changes or a fetch of the base branch lands in the
11
- main checkout (the watcher fetches it as soon as the remote moved). While it runs, it also sets each
12
- lane's sidebar title (`P4 · 7/12`) and clears it on exit.
11
+ main checkout (the watcher fetches it as soon as the remote moved). While it runs it sets each
12
+ lane's sidebar title (`P4 · 7/12`), clearing it on exit. An opened lane adds each phase's purpose line.
13
13
 
14
14
  A phase the lane ticked to the end in its worktree stays its current phase until the base branch
15
15
  carries its ticks (the watcher marks it `abgehakt, nicht gemergt` / `ticked, not merged`; the herd
16
16
  shows the branch's `↑n` instead); an open phase the lane works on goes first. The lane's newest row decides
17
- which phases count: ticks in an older phase (tidying the plan) take over only when that row names no
17
+ which phases count: ticks in an older phase take over only when that row names no
18
18
  phase or only merged ones, which is why each new row starts with its phase number (§ 2 step 5).
19
19
 
20
20
  ## Notices per lane
@@ -11,6 +11,8 @@ You create a structured concept/plan document in `{{kit.planDirectory}}/`.
11
11
  > written in {{kit.planLanguage}}. Keep the section headings of the template **verbatim** — the plan
12
12
  > parser, `/implement` and `spec-review` find `## {{kit.heading.decisions}}`,
13
13
  > `## {{kit.heading.implementation}}` and `## {{kit.heading.documentation}}` by those exact names.
14
+ > Another language: each heading, table head and purpose line carries `<!-- jh: <key> -->` (keys:
15
+ > `docs/plan-contract.md`, "Language and markers"); done when `jh plan check` shows no `missing`.
14
16
 
15
17
  ## Your Workflow
16
18
 
@@ -36,7 +38,7 @@ The closing documentation section reads `## N — …` with rows `N.1` to `N.5`:
36
38
 
37
39
  A checkbox moved on purpose to a later phase stays where it is, unticked, and ends in the target: `- [ ] Update the documentation → P9`. The herd counts it neither as open nor as done in its own phase and lists it as open in phase 9. The arrow stands only at the end of the line. Once a phase's tasks are ticked on the base branch, every box still open in it, gate boxes included, is ticked or moved on this way; `jh check` checks both.
38
40
 
39
- A task starts with its short name: `- [ ] **Name**: …` (a review-gate item keeps its own form). The name has 40 characters at most and names the subject and the result (`Heartbeat shows progress without ticks`); the how, the conditions, the origin and the evidence follow the colon. A phase's title is its name; its context goes into its origin line, a quote under the heading (`> Origin: …`, in the plan's language). A task the user adds later: `- [ ] **Name**: … (User <DD.MM.> <HH:MM>)`.
41
+ A task starts with its short name: `- [ ] **Name**: …` (a review-gate item keeps its own form). The name has 40 characters at most and names the subject and the result (`Heartbeat shows progress without ticks`); the how, the conditions, the origin and the evidence follow the colon. A phase's title is its name; directly under it stands the purpose line of the template, one sentence saying what is different afterwards and for whom, so that a reader outside the project understands the phase from that line alone; `jh check` asks for it on every new phase. The phase's context goes into its origin line, a quote below the purpose line (`> Origin: …`, in the plan's language). A task the user adds later: `- [ ] **Name**: … (User <DD.MM.> <HH:MM>)`.
40
42
 
41
43
  An Artifact that belongs to the plan (a mockup, a draft for review, a findings page) stands as a Markdown link with its title in the phase it concerns, in the same move as its first publish: `**Mockup:** [Title](url)` or in the origin line. The herd shows them as chips; nothing else ties an Artifact published from the main checkout to the lane.
42
44
 
@@ -50,6 +50,8 @@ What changes concretely? Files, modules, flows.
50
50
 
51
51
  ### {{kit.heading.phase}} 1: ...
52
52
 
53
+ **What it is about:** what is different afterwards, for whom; "nothing visible" when that is the case
54
+
53
55
  - [ ] **Name of step 1**: how, conditions, origin, evidence
54
56
  - [ ] **Name of step 2**: how, conditions, origin, evidence
55
57
 
@@ -50,6 +50,8 @@ Was ändert sich konkret? Dateien, Module, Flows.
50
50
 
51
51
  ### {{kit.heading.phase}} 1: ...
52
52
 
53
+ **Worum es geht:** was danach anders ist, für wen; „nichts Sichtbares“, wenn das so ist
54
+
53
55
  - [ ] **Name von Schritt 1**: Wie, Bedingungen, Herkunft, Beleg
54
56
  - [ ] **Name von Schritt 2**: Wie, Bedingungen, Herkunft, Beleg
55
57
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.24.0",
3
+ "version": "1.25.0",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",
@@ -29,6 +29,13 @@
29
29
  "en"
30
30
  ]
31
31
  },
32
+ "boardLanguage": {
33
+ "type": "string",
34
+ "enum": [
35
+ "de",
36
+ "en"
37
+ ]
38
+ },
32
39
  "planDirectory": {
33
40
  "default": "docs/plans",
34
41
  "type": "string",
@@ -35,7 +35,7 @@ export const run = async (args: string[]): Promise<number> => {
35
35
  const here = worktreeRoot(process.cwd())
36
36
  const settings = readSettings([here, root])
37
37
  const picked = boardFor(boardPath, [root, here], settings, () => foundLanesOf(root, settings, () => []))
38
- for (const line of briefLines(laneFactsOf(root, settings, picked.file, Date.now()), settings.planLanguage))
38
+ for (const line of briefLines(laneFactsOf(root, settings, picked.file, Date.now()), settings.boardLanguage))
39
39
  console.log(line)
40
40
  return 0
41
41
  } catch (error) {
@@ -3,7 +3,7 @@ import { dirname, join, relative } from 'node:path'
3
3
  import { parseSprintLanes, resolvePlanPath, type SprintLane } from '../plan-format'
4
4
  import { type QuestionRecord } from '../plans/question-records'
5
5
  import { type SentFileRecord, sentLaneOf } from '../plans/sent-files'
6
- import type { PlansSettings } from '../plans/settings'
6
+ import { type PlansSettings } from '../plans/settings'
7
7
  import { laneDirectoryOf } from '../plans/unlisted-lanes'
8
8
  import { readWaitingRecords } from '../plans/waiting-records'
9
9
  import { isStillWaiting } from '../plans/waiting-window'
@@ -49,7 +49,7 @@ export const laneFactsOf = (root: string, settings: PlansSettings, boardFile: st
49
49
  const sprintDirectory = dirname(relative(root, boardFile))
50
50
  const worktreeDirectory = join(root, settings.worktreeDirectory)
51
51
  const records = readWaitingRecords(root)
52
- return parseSprintLanes(readFileSync(boardFile, 'utf8'), settings.planLanguage)
52
+ return parseSprintLanes(readFileSync(boardFile, 'utf8'), settings.boardLanguage)
53
53
  .filter(({ closed }) => !closed)
54
54
  .map((lane) => {
55
55
  const href = planHrefOf(lane)
@@ -223,7 +223,7 @@ const openBoxesCheckOf = (root: string, settings: CheckSettings): CheckResult |
223
223
  lane,
224
224
  planDirectory: settings.planDirectory,
225
225
  board: settings.board,
226
- language: settings.planLanguage,
226
+ language: settings.boardLanguage,
227
227
  level: settings.check.openBoxes,
228
228
  baseOf: contentAt(root, baseRefOf(settings)),
229
229
  })
@@ -253,6 +253,7 @@ const ownChecksOf = (
253
253
  paths,
254
254
  planDirectory: settings.planDirectory,
255
255
  language: settings.planLanguage,
256
+ boardLanguage: settings.boardLanguage,
256
257
  previousOf: contentAt(root, 'HEAD'),
257
258
  baseOf: contentAt(root, baseRefOf(settings)),
258
259
  planRoots: planRootsOf(root, settings.worktreeDirectory),
@@ -13,12 +13,14 @@ import {
13
13
  laneProgress,
14
14
  longPhaseTitles,
15
15
  mergedPhasesWithOpenBoxes,
16
+ newPhasesWithoutPurpose,
16
17
  newPhasesWithoutReviewGate,
17
18
  openBoxesOfPhase,
18
19
  PHASE_CELL_LIMIT,
19
20
  PHASE_TITLE_LIMIT,
20
21
  type PhaseWithOpenBoxes,
21
22
  type PhaseWithoutGate,
23
+ type PhaseWithoutPurpose,
22
24
  type PhaseWithoutRow,
23
25
  PLAN_VOCABULARY,
24
26
  type PlanLanguage,
@@ -38,7 +40,10 @@ import {
38
40
  shortNameFindings,
39
41
  } from '../plan-format'
40
42
  import { boardRowsWithBareCodes, type RowWithBareCode } from '../plan-format/bare-codes'
43
+ import { namesState } from '../plan-format/markdown'
44
+ import { firstUnrecognizedSectionLine } from '../plan-format/unrecognized-sections'
41
45
  import { mergePlanPhases } from '../plans/phase-source'
46
+ import { CELL_KEY, PARTIAL_PHASE_WORDS, SECTION_KEY } from '../vocabulary'
42
47
  import { changedPlansOf } from './deferral-arrows'
43
48
  import { CHECK_STATUS, type CheckResult } from './result'
44
49
  import { OPEN_BOXES_LEVEL, type OpenBoxesLevel } from './schema'
@@ -55,6 +60,8 @@ export type PlanCheckInput = {
55
60
  paths: readonly string[]
56
61
  planDirectory: string
57
62
  language: PlanLanguage
63
+ /** The language the boards are read in first; `language` when absent. */
64
+ boardLanguage?: PlanLanguage
58
65
  previousOf: ContentAt
59
66
  baseOf: ContentAt
60
67
  /** Where a board's plans are read, `root` when absent: the checkout, the main checkout and the lane worktrees. */
@@ -124,6 +131,16 @@ const gateReport = (language: PlanLanguage): Report<PhaseWithoutGate> => ({
124
131
  clean: 'every new phase has a review gate',
125
132
  })
126
133
 
134
+ const purposeReport = (language: PlanLanguage): Report<PhaseWithoutPurpose> => ({
135
+ name: 'Plan phase purposes',
136
+ noun: 'plan',
137
+ rule: 'new phases say what they are about',
138
+ unmet: CHECK_STATUS.fail,
139
+ text: ({ path, line, id, title }) =>
140
+ `${path}:${line} Phase ${id} ${title} has tasks but no **${PLAN_VOCABULARY[language].phasePurpose}:** line; say in one sentence what is different afterwards, for whom, directly under the heading`,
141
+ clean: 'every new phase says what it is about',
142
+ })
143
+
127
144
  const rowReport: Report<RowWithoutPhase> = {
128
145
  name: 'Board rows without a phase',
129
146
  noun: 'board',
@@ -179,6 +196,21 @@ const unlistedReport: Report<PhaseWithoutRow> = {
179
196
  clean: 'every phase has a row in the dependency table',
180
197
  }
181
198
 
199
+ const sectionReport: Report<{ line: number }> = {
200
+ name: 'Plan sections',
201
+ noun: 'plan',
202
+ rule: 'jh reads at least one section',
203
+ unmet: CHECK_STATUS.warn,
204
+ text: ({ path, line }) =>
205
+ `${path}:${line} none of the ## headings is a section jh reads, so every reader finds the document empty; write one in a known language or put <!-- jh: <key> --> on the line directly under it (keys: ${Object.values(SECTION_KEY).join(', ')})`,
206
+ clean: 'jh reads a section of every changed plan and board',
207
+ }
208
+
209
+ const sectionsOf = ({ path, current }: Changed): Found<{ line: number }>[] => {
210
+ const line = firstUnrecognizedSectionLine(current)
211
+ return line === null ? [] : [{ path, line }]
212
+ }
213
+
182
214
  const shortNameText = (found: Found<ShortNameFinding>): string =>
183
215
  found.problem === SHORT_NAME_PROBLEM.long
184
216
  ? `${found.path}:${found.line} Phase ${found.phase}: the short name has ${found.length} characters (limit ${SHORT_NAME_LIMIT})`
@@ -207,10 +239,15 @@ const withPath =
207
239
  <T>(path: string) =>
208
240
  (found: T): Found<T> => ({ ...found, path })
209
241
 
210
- const gatesOf = ({ path, current, previous }: Changed, language: PlanLanguage): Found<PhaseWithoutGate>[] =>
211
- newPhasesWithoutReviewGate(current, language, previous ? phaseIdsOfPlan(previous, language) : new Set()).map(
212
- withPath(path),
213
- )
242
+ /** The ids of the phases the plan had before the change; none for a plan the change creates. */
243
+ const previousPhaseIds = ({ previous }: Changed, language: PlanLanguage): Set<string> =>
244
+ previous ? phaseIdsOfPlan(previous, language) : new Set()
245
+
246
+ const gatesOf = (file: Changed, language: PlanLanguage): Found<PhaseWithoutGate>[] =>
247
+ newPhasesWithoutReviewGate(file.current, language, previousPhaseIds(file, language)).map(withPath(file.path))
248
+
249
+ const purposesOf = (file: Changed, language: PlanLanguage): Found<PhaseWithoutPurpose>[] =>
250
+ newPhasesWithoutPurpose(file.current, language, previousPhaseIds(file, language)).map(withPath(file.path))
214
251
 
215
252
  const rowsOf = ({ path, current, previousLines }: Changed, language: PlanLanguage): Found<RowWithoutPhase>[] =>
216
253
  boardRowsWithoutPhase(current, language, previousLines).map(withPath(path))
@@ -219,17 +256,26 @@ const proseOf = ({ path, current, previousLines }: Changed, language: PlanLangua
219
256
  boardRowsWithProse(current, language, previousLines).map(withPath(path))
220
257
 
221
258
  /** Every readable copy of the plans the board links, in each plan root; a path that leaves the repository is skipped. */
222
- const planTextsOf = (roots: readonly string[], { path, current }: Changed, language: PlanLanguage): string[] =>
223
- boardPlansOf(current, posix.normalize(dirname(path)), language)
259
+ const planTextsOf = (
260
+ roots: readonly string[],
261
+ { path, current }: Changed,
262
+ languages: { board: PlanLanguage; plan: PlanLanguage },
263
+ ): string[] =>
264
+ boardPlansOf(current, posix.normalize(dirname(path)), languages.board)
224
265
  .filter(isInsideRepository)
225
266
  .flatMap((plan) => planCopiesIn(roots, plan))
226
267
 
227
- const bareCodesOfBoard = (roots: readonly string[], file: Changed, language: PlanLanguage): Found<RowWithBareCode>[] =>
268
+ /** The board is read in the board language, the plans it links in the plan language. */
269
+ const bareCodesOfBoard = (
270
+ roots: readonly string[],
271
+ file: Changed,
272
+ languages: { board: PlanLanguage; plan: PlanLanguage },
273
+ ): Found<RowWithBareCode>[] =>
228
274
  boardRowsWithBareCodes(
229
275
  file.current,
230
- language,
276
+ languages.board,
231
277
  file.previousLines,
232
- codesOfPlans(planTextsOf(roots, file, language), language),
278
+ codesOfPlans(planTextsOf(roots, file, languages), languages.plan),
233
279
  ).map(withPath(file.path))
234
280
 
235
281
  const mergedOf = ({ path, current, base }: Changed, language: PlanLanguage): Found<PhaseWithOpenBoxes>[] =>
@@ -297,7 +343,7 @@ const phaseBuiltBy = (input: LaneBoxesInput): BuiltPhase | null => {
297
343
  if (!phase || !step?.planHref) return null
298
344
  // with the base known, a phase this checkout did not tick further waits in the row; it is not what the lane builds
299
345
  if (phase.work === undefined && input.baseOf(pathOf(step.planHref)) !== null) return null
300
- const partial = step.label.includes(PLAN_VOCABULARY[input.language].partialPhase)
346
+ const partial = namesState(step.label, CELL_KEY.partialPhase, PARTIAL_PHASE_WORDS)
301
347
  return { planPath: pathOf(step.planHref), phaseId: phase.id, partial }
302
348
  }
303
349
 
@@ -341,7 +387,8 @@ export const laneOpenBoxesCheck = (input: LaneBoxesInput & { level: OpenBoxesLev
341
387
 
342
388
  /**
343
389
  * The structure of the changed plans and boards:
344
- * - a phase the change added (against `HEAD`) with tasks and without a review gate fails;
390
+ * - a phase the change added (against `HEAD`) with tasks and without a review gate fails, and so does one without a
391
+ * purpose line directly under its heading;
345
392
  * - a lane row the change added or edited that names no phase warns, and so does one whose phases cell is longer
346
393
  * than `PHASE_CELL_LIMIT` or records a side order of the user;
347
394
  * - a waiting row the change added or edited whose item cell names a plan code of the board's plans bare warns;
@@ -357,15 +404,20 @@ export const planStructureChecks = (input: PlanCheckInput): CheckResult[] => {
357
404
  const files = changedPlansOf(input.paths, input.planDirectory)
358
405
  if (files.length === 0) return []
359
406
  const { language } = input
407
+ const boardLanguage = input.boardLanguage ?? language
360
408
  const started = Date.now()
361
409
  const changed = changedContents(input, files)
362
410
  const boards = changed.filter(({ board }) => board)
363
411
  const plans = changed.filter(({ board }) => !board)
364
412
  const unlistedPerPlan = plans.map((file) => unlistedOf(file, language)).filter((found) => found !== null)
365
413
  const gates = changed.flatMap((file) => gatesOf(file, language))
366
- const rows = boards.flatMap((file) => rowsOf(file, language))
367
- const prose = boards.flatMap((file) => proseOf(file, language))
368
- const bareCodes = boards.flatMap((file) => bareCodesOfBoard(input.planRoots ?? [input.root], file, language))
414
+ const purposes = changed.flatMap((file) => purposesOf(file, language))
415
+ const sections = changed.flatMap(sectionsOf)
416
+ const rows = boards.flatMap((file) => rowsOf(file, boardLanguage))
417
+ const prose = boards.flatMap((file) => proseOf(file, boardLanguage))
418
+ const bareCodes = boards.flatMap((file) =>
419
+ bareCodesOfBoard(input.planRoots ?? [input.root], file, { board: boardLanguage, plan: language }),
420
+ )
369
421
  const merged = plans.flatMap((file) => mergedOf(file, language))
370
422
  const unlisted = unlistedPerPlan.flat()
371
423
  const shortNames = plans.flatMap((file) => shortNamesOf(file, language))
@@ -373,6 +425,7 @@ export const planStructureChecks = (input: PlanCheckInput): CheckResult[] => {
373
425
  const durationMs = Date.now() - started
374
426
  return [
375
427
  findingsResult(gateReport(language), gates, files.length, durationMs),
428
+ findingsResult(purposeReport(language), purposes, files.length, durationMs),
376
429
  ...(boards.length > 0
377
430
  ? [
378
431
  findingsResult(rowReport, rows, boards.length, durationMs),
@@ -390,5 +443,6 @@ export const planStructureChecks = (input: PlanCheckInput): CheckResult[] => {
390
443
  findingsResult(titleReport, titles, plans.length, durationMs),
391
444
  ]
392
445
  : []),
446
+ findingsResult(sectionReport, sections, files.length, durationMs),
393
447
  ]
394
448
  }
@@ -21,6 +21,8 @@ export type CheckSettings = {
21
21
  check: CheckConfig
22
22
  planDirectory: string
23
23
  planLanguage: PlanLanguage
24
+ /** The language the boards are written in: the config's `boardLanguage`, or `planLanguage` when it sets none. */
25
+ boardLanguage: PlanLanguage
24
26
  /** The branch on the remote whose plans count as merged. */
25
27
  baseBranch: string
26
28
  /** Where the lane worktrees live, below the main checkout: a board's plans are read there too. */
@@ -44,6 +46,7 @@ export const noStackFallbackOf = ({ stack, check, commands }: CheckSettings): No
44
46
  const commandsSchema = jackherdConfigSchema.shape.commands
45
47
  const planDirectorySchema = jackherdConfigSchema.shape.planDirectory
46
48
  const planLanguageSchema = jackherdConfigSchema.shape.planLanguage
49
+ const boardLanguageSchema = jackherdConfigSchema.shape.boardLanguage
47
50
  const baseBranchSchema = jackherdConfigSchema.shape.baseBranch
48
51
  const worktreeDirectorySchema = jackherdConfigSchema.shape.worktreeDirectory
49
52
  const skillPrefixSchema = jackherdConfigSchema.shape.skillPrefix
@@ -58,11 +61,13 @@ export const parseCheckSettings = (raw: unknown, facts: RepositoryFacts): CheckS
58
61
  const detected = withDetected(z.record(z.string(), z.unknown()).parse(raw), facts)
59
62
  const object = z.record(z.string(), z.unknown()).parse(detected.raw)
60
63
  const { test, typecheck, lint } = commandsSchema.parse(object.commands)
64
+ const planLanguage = planLanguageSchema.parse(object.planLanguage)
61
65
  return {
62
66
  commands: { test, typecheck, lint },
63
67
  check: checkConfigSchema.parse(object.check ?? {}),
64
68
  planDirectory: planDirectorySchema.parse(object.planDirectory),
65
- planLanguage: planLanguageSchema.parse(object.planLanguage),
69
+ planLanguage,
70
+ boardLanguage: boardLanguageSchema.parse(object.boardLanguage) ?? planLanguage,
66
71
  baseBranch: baseBranchSchema.parse(object.baseBranch),
67
72
  worktreeDirectory: worktreeDirectorySchema.parse(object.worktreeDirectory),
68
73
  board: boardSchema.parse(object.board),
@@ -74,7 +74,8 @@ export const KIT_SKILL_TARGETS: Readonly<Record<string, number>> = {
74
74
  '.claude/skills/orchestrate/references/waiting.md': 216,
75
75
  '.claude/skills/orchestrate/references/watch-without-monitor.md': 154,
76
76
  // Prose cut, round 2: cut to its mandatory rules, measured size plus 5 %.
77
- '.claude/skills/plan/SKILL.md': 1717,
77
+ // Language split: the note that a template renders in the language of its file name and that a heading in any other language carries a marker (+27).
78
+ '.claude/skills/plan/SKILL.md': 1744,
78
79
  '.claude/skills/plan/references/template-English.md': 536,
79
80
  '.claude/skills/plan/references/template-German.md': 501,
80
81
  // Prose cut, round 2: cut to its mandatory rules, measured size plus 5 %.
@@ -111,9 +112,11 @@ export const KIT_SKILL_CEILINGS: Readonly<Record<string, number>> = {
111
112
  '.claude/agents/skill-review.md': 513,
112
113
  // Plan head: every plan opens with a `For you` head of five short lines a person reads first, which
113
114
  // `jh plan check` reports; cut to its labels; skill-review: the status line says what `N` counts. Measured size (+42).
114
- '.claude/skills/plan/references/template-English.md': 578,
115
+ // Phase purpose: the line under every phase heading a template shows, one sentence of what is different afterwards and for whom (+17).
116
+ '.claude/skills/plan/references/template-English.md': 595,
115
117
  // Plan head: the same `Für dich` head as the English template, with what `N` counts; measured size (+41).
116
- '.claude/skills/plan/references/template-German.md': 542,
118
+ // Phase purpose: the same line under every phase heading as the English template (+15).
119
+ '.claude/skills/plan/references/template-German.md': 557,
117
120
  // P33: the paw in the header chip, panes by tab and title, the lane in the context line of a passed-on question.
118
121
  // Plain words: no plan code without its words in a short question.
119
122
  // Any language: a pointer in § 0 and a section in events.md for `plan missing`.
@@ -567,7 +567,10 @@ export const jackherdConfigSchema = z.strictObject({
567
567
  responseLanguage: language.default(PLAN_LANGUAGE.en),
568
568
  /** Whether replies address the user with the familiar or the polite form where the language has both (German: du or Sie). */
569
569
  responseAddress: z.enum(Object.values(RESPONSE_ADDRESS)).default(RESPONSE_ADDRESS.informal),
570
+ /** The language new plans and the plan templates are written in. Boards and plans are read in every known language whatever this says. */
570
571
  planLanguage: language.default(PLAN_LANGUAGE.en),
572
+ /** The language jh writes the board in (history lines, lane rows, waiting rows); `planLanguage` when left out. */
573
+ boardLanguage: language.optional(),
571
574
  planDirectory: z.string().min(1).default('docs/plans'),
572
575
  /** Where the boards live and what the project calls them. */
573
576
  board: boardSchema.prefault({}),
@@ -1,8 +1,8 @@
1
1
  import { twoDigits } from '../plan-format/clock'
2
2
  import { laneNameOf, parseSprintLanes } from '../plan-format/lanes'
3
- import { firstTableRows, sectionLines, sectionStart } from '../plan-format/markdown'
3
+ import { firstTableRows, namesState, sectionLines, sectionStart } from '../plan-format/markdown'
4
4
  import { terminalSafe } from '../plan-format/terminal-text'
5
- import { PLAN_LANGUAGE, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
5
+ import { CELL_KEY, LANE_CLOSED_WORDS, PLAN_LANGUAGE, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
6
6
  import { BOARD_LANE, type BoardLane } from './close-check'
7
7
 
8
8
  const CONTINUATION = '↳'
@@ -38,7 +38,7 @@ export const boardLaneOf = (board: string, lane: string, language: PlanLanguage)
38
38
  * a row that names the lane again without the mark starting it anew. Empty when the lane is closed or missing.
39
39
  */
40
40
  const currentRowIndexes = (board: string, lane: string, language: PlanLanguage): number[] => {
41
- const { sections, laneClosed } = PLAN_VOCABULARY[language]
41
+ const { sections } = PLAN_VOCABULARY[language]
42
42
  const start = sectionStart(board, sections.lanes)
43
43
  if (start === -1) return []
44
44
  let previous: string | null = null
@@ -49,7 +49,7 @@ const currentRowIndexes = (board: string, lane: string, language: PlanLanguage):
49
49
  const name = laneNameOf(first, previous)
50
50
  previous = name ?? previous
51
51
  if (name !== lane) continue
52
- const marked = first.includes(laneClosed)
52
+ const marked = namesState(first, CELL_KEY.laneClosed, LANE_CLOSED_WORDS)
53
53
  if (closed && !marked && !first.startsWith(CONTINUATION)) {
54
54
  closed = false
55
55
  indexes = []
@@ -185,7 +185,7 @@ const surveyOf = async (lane: string, branch: string | null, context: CloseConte
185
185
  const present = entry !== null && existsSync(context.worktree)
186
186
  const facts: CloseFacts = {
187
187
  lane,
188
- boardLane: boardLaneOf(readFileSync(context.boardFile, 'utf8'), lane, context.settings.planLanguage),
188
+ boardLane: boardLaneOf(readFileSync(context.boardFile, 'utf8'), lane, context.settings.boardLanguage),
189
189
  setupFiles: setupFilesOf(context.root),
190
190
  ...agentFactsOf(laneStatusSourceFor(context.settings.adapter).listAgents(), lane, context.worktree),
191
191
  runsInside: realPath(worktreeRoot(context.cwd)) === realPath(context.worktree),
@@ -267,7 +267,7 @@ export const run = async (args: string[], cwd: string = process.cwd()): Promise<
267
267
  boardPath: relative(context.root, context.boardFile),
268
268
  dryRun: options.dryRun,
269
269
  now: new Date(),
270
- language: context.settings.planLanguage,
270
+ language: context.settings.boardLanguage,
271
271
  }
272
272
  return await closeLane(request, portsFor(options.lane, options.branch, context))
273
273
  } catch (error) {
@@ -26,7 +26,7 @@ export const run = async (args: string[], cwd: string = process.cwd()): Promise<
26
26
  const settings = readSettings([root, worktreeRoot(cwd)])
27
27
  const board = boardFor(boardPath === null ? null : resolve(cwd, boardPath), [root], settings, () => [])
28
28
  if (board.notice) console.error(terminalSafe(board.notice))
29
- const row = parseSprintLanes(readFileSync(board.file, 'utf8'), settings.planLanguage).find(
29
+ const row = parseSprintLanes(readFileSync(board.file, 'utf8'), settings.boardLanguage).find(
30
30
  ({ name, closed }) => name === lane && !closed,
31
31
  )
32
32
  const port = devPortOf(portOption, row?.port ?? null)
@@ -25,7 +25,7 @@ export const run = async (args: string[], cwd: string = process.cwd()): Promise<
25
25
  const settings = readSettings([root, worktreeRoot(cwd)])
26
26
  const board = boardFor(boardPath === null ? null : resolve(cwd, boardPath), [root], settings, () => [])
27
27
  if (board.notice) console.error(terminalSafe(board.notice))
28
- const reserved = portsOfRunningLanes(parseSprintLanes(readFileSync(board.file, 'utf8'), settings.planLanguage))
28
+ const reserved = portsOfRunningLanes(parseSprintLanes(readFileSync(board.file, 'utf8'), settings.boardLanguage))
29
29
  const port = await freePortNotListening(settings.basePort, reserved, MAX_PORT, isPortListening)
30
30
  if (port === null) throw new Error(`no free port from ${settings.basePort} to ${MAX_PORT}`)
31
31
  console.log(port)
@@ -23,7 +23,8 @@ export const USAGE = `usage: ${CLI_NAME} plan ${CHECK_SUBCOMMAND} <plan.md>
23
23
 
24
24
  Checks one plan against the plan contract: phases, tasks in every phase, a model for every phase, a dependency
25
25
  row for every phase (from two phases on). One line per part, \`file:line\` where a part is incomplete.
26
- A fifth line, \`head:\` present, missing or stale, reports the plan's "For you" head; it is a note and never changes the exit code.
26
+ A fifth line, \`head:\` present, missing or stale, reports the plan's "For you" head; a sixth, \`purpose:\` present or missing,
27
+ reports whether every phase opens with its one-sentence purpose line. Both are notes and never change the exit code.
27
28
  Where git has the base branch, a plan, or a board that links it, that the base branch lacks gets a \`base:\` line with its path.
28
29
  The plan language comes from .jackherd/config.json.
29
30
  Exit 0 met, 1 with gaps, 2 for a wrong command line or an unreadable file, 3 met but not on the base branch.`