jackherd 1.24.0 → 1.26.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 (88) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +22 -22
  4. package/dist/{chunk-JDO6O3UO.js → chunk-O365KHHP.js} +139 -3
  5. package/dist/config.d.ts +4 -0
  6. package/dist/config.js +4 -1
  7. package/dist/plan-format.d.ts +121 -3
  8. package/dist/plan-format.js +176 -35
  9. package/docs/plan-contract.md +52 -5
  10. package/herdr-plugin/herdr-plugin.toml +1 -1
  11. package/install.sh +1 -1
  12. package/kit/hooks/hooks/approval-gate/index.ts +2 -2
  13. package/kit/hooks/hooks/merge-guard/lane-plan.ts +1 -1
  14. package/kit/hooks/hooks/open-question/record.ts +81 -1
  15. package/kit/hooks/hooks/open-question/run.ts +17 -3
  16. package/kit/hooks/hooks/question-session-end/index.ts +18 -0
  17. package/kit/hooks/hooks/question-session-end/run.ts +37 -0
  18. package/kit/hooks/hooks/sent-file/record.ts +4 -1
  19. package/kit/hooks/hooks/shared/board-lanes.ts +33 -12
  20. package/kit/hooks/hooks/shared/boards.ts +22 -6
  21. package/kit/hooks/hooks.json +8 -0
  22. package/kit/workflow/skills/orchestrate/references/herd.md +3 -3
  23. package/kit/workflow/skills/orchestrate/references/waiting.md +15 -1
  24. package/kit/workflow/skills/plan/SKILL.md +3 -1
  25. package/kit/workflow/skills/plan/references/template-English.md +2 -0
  26. package/kit/workflow/skills/plan/references/template-German.md +2 -0
  27. package/package.json +1 -1
  28. package/schemas/jackherd.schema.json +7 -0
  29. package/src/brief/command.ts +1 -1
  30. package/src/brief/lane-facts.ts +2 -2
  31. package/src/check/command.ts +2 -1
  32. package/src/check/plan-structure-check.ts +68 -14
  33. package/src/check/settings.ts +6 -1
  34. package/src/check/skill-budget-defaults.ts +9 -4
  35. package/src/config/schema.ts +3 -0
  36. package/src/lane/close-board.ts +4 -4
  37. package/src/lane/close-command.ts +2 -2
  38. package/src/lane/dev-command.ts +1 -1
  39. package/src/lane/port-command.ts +1 -1
  40. package/src/plan-contract/command.ts +2 -1
  41. package/src/plan-contract/contract.ts +30 -9
  42. package/src/plan-format/bare-codes.ts +4 -4
  43. package/src/plan-format/dependencies.ts +5 -5
  44. package/src/plan-format/lanes.ts +3 -4
  45. package/src/plan-format/markdown.ts +125 -10
  46. package/src/plan-format/open-status.ts +15 -0
  47. package/src/plan-format/phases.ts +45 -4
  48. package/src/plan-format/plan-head.ts +31 -9
  49. package/src/plan-format/plan-structure.ts +43 -7
  50. package/src/plan-format/progress.ts +3 -0
  51. package/src/plan-format/sprint-document.ts +4 -4
  52. package/src/plan-format/unrecognized-sections.ts +21 -0
  53. package/src/plan-format/waiting.ts +13 -13
  54. package/src/plans/board-close.ts +25 -16
  55. package/src/plans/board-pick.ts +1 -1
  56. package/src/plans/closable-records.ts +11 -10
  57. package/src/plans/close-reason.ts +19 -0
  58. package/src/plans/ensure-panes.ts +2 -2
  59. package/src/plans/layout-summary.ts +8 -1
  60. package/src/plans/layout.ts +14 -5
  61. package/src/plans/live.ts +3 -2
  62. package/src/plans/question-records.ts +23 -2
  63. package/src/plans/questions-read.ts +2 -2
  64. package/src/plans/sent-files-read.ts +2 -2
  65. package/src/plans/sent-files.ts +12 -4
  66. package/src/plans/settings.ts +3 -0
  67. package/src/plans/snapshot.ts +9 -5
  68. package/src/plans/sprint-export.ts +1 -1
  69. package/src/plans/texts.ts +3 -0
  70. package/src/plans/view.ts +1 -0
  71. package/src/resume/coordinator-read.ts +2 -2
  72. package/src/settled/command.ts +1 -1
  73. package/src/settled/settle.ts +5 -5
  74. package/src/sync/render.ts +20 -5
  75. package/src/sync/sync-plan.ts +6 -2
  76. package/src/sync/sync.ts +3 -2
  77. package/src/vocabulary/index.ts +192 -4
  78. package/src/waiting/command.ts +82 -26
  79. package/src/watch/appointments.ts +13 -20
  80. package/src/watch/command.ts +100 -6
  81. package/src/watch/lane-progress.ts +1 -1
  82. package/src/watch/lane-report.ts +4 -0
  83. package/src/watch/merge-overlap-read.ts +2 -2
  84. package/src/watch/obsolete-proposals.ts +175 -0
  85. package/src/watch/obsolete-records.ts +77 -0
  86. package/src/watch/plan-missing-read.ts +3 -1
  87. package/src/watch/reference-paths.ts +2 -0
  88. package/src/watch/watch-events.ts +20 -0
@@ -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),
@@ -71,10 +71,13 @@ export const KIT_SKILL_TARGETS: Readonly<Record<string, number>> = {
71
71
  // The dev server restart by PID and `herdr pane run`, the form the merge guard passes (+54).
72
72
  '.claude/skills/orchestrate/references/start-lane.md': 766,
73
73
  // Core cut: § 4 waiting rows beyond the basic one, moved out of the core.
74
- '.claude/skills/orchestrate/references/waiting.md': 216,
74
+ // What the watcher closes by itself, how to work through a `waiting obsolete?` proposal, and the rule for a
75
+ // decision the hooks cannot see: the coordinator's only text for the closing flow.
76
+ '.claude/skills/orchestrate/references/waiting.md': 450,
75
77
  '.claude/skills/orchestrate/references/watch-without-monitor.md': 154,
76
78
  // Prose cut, round 2: cut to its mandatory rules, measured size plus 5 %.
77
- '.claude/skills/plan/SKILL.md': 1717,
79
+ // 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).
80
+ '.claude/skills/plan/SKILL.md': 1744,
78
81
  '.claude/skills/plan/references/template-English.md': 536,
79
82
  '.claude/skills/plan/references/template-German.md': 501,
80
83
  // Prose cut, round 2: cut to its mandatory rules, measured size plus 5 %.
@@ -111,9 +114,11 @@ export const KIT_SKILL_CEILINGS: Readonly<Record<string, number>> = {
111
114
  '.claude/agents/skill-review.md': 513,
112
115
  // Plan head: every plan opens with a `For you` head of five short lines a person reads first, which
113
116
  // `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,
117
+ // Phase purpose: the line under every phase heading a template shows, one sentence of what is different afterwards and for whom (+17).
118
+ '.claude/skills/plan/references/template-English.md': 595,
115
119
  // 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,
120
+ // Phase purpose: the same line under every phase heading as the English template (+15).
121
+ '.claude/skills/plan/references/template-German.md': 557,
117
122
  // P33: the paw in the header chip, panes by tab and title, the lane in the context line of a passed-on question.
118
123
  // Plain words: no plan code without its words in a short question.
119
124
  // 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.`
@@ -10,16 +10,19 @@ import {
10
10
  phaseCount,
11
11
  phaseHeadings,
12
12
  phaseLabels,
13
+ phasesWithoutPurpose,
13
14
  } from '../plan-format'
14
- import type { PlanLanguage } from '../vocabulary'
15
+ import { lookedForSection } from '../plan-format/markdown'
16
+ import { type PlanLanguage, SECTION_KEY } from '../vocabulary'
15
17
 
16
- /** The five parts of the plan contract; the head is a note, never a gap. */
18
+ /** The six parts of the plan contract; the head and the purpose lines are notes, never a gap. */
17
19
  export const CONTRACT_PART = {
18
20
  phases: 'phases',
19
21
  tasks: 'tasks',
20
22
  model: 'model',
21
23
  dependencies: 'dependencies',
22
24
  head: 'head',
25
+ purpose: 'purpose',
23
26
  } as const
24
27
 
25
28
  export type ContractPart = (typeof CONTRACT_PART)[keyof typeof CONTRACT_PART]
@@ -37,7 +40,7 @@ export type PartStatus = (typeof PART_STATUS)[keyof typeof PART_STATUS]
37
40
  /** `line` is the 1-based line of the first problem, or of the part's first sign; null when the plan shows none. */
38
41
  export type PartFinding = { part: ContractPart; status: PartStatus; line: number | null; message: string }
39
42
 
40
- /** `met` is false when a part is malformed, or phases, tasks or the model table are missing; a missing dependency table or a missing or stale head is a note. */
43
+ /** `met` is false when a part is malformed, or phases, tasks or the model table are missing; a missing dependency table, a missing or stale head or a missing purpose line is a note. */
41
44
  export type PlanContract = { parts: PartFinding[]; met: boolean }
42
45
 
43
46
  /** Fewer phases than this need no dependency table: there is nothing to put side by side. */
@@ -64,18 +67,19 @@ const phasesPart = (headings: readonly PhaseHeading[]): PartFinding =>
64
67
  ? finding(CONTRACT_PART.phases, PART_STATUS.missing, null, 'no phase heading')
65
68
  : finding(CONTRACT_PART.phases, PART_STATUS.present, headings[0].line, phaseCount(headings.length))
66
69
 
67
- /** Present when no phase lacks the part (`ok`), else malformed at the first phase that does, naming them all. */
70
+ /** Present when no phase lacks the part (`ok`), else `unmet` (malformed by default) at the first phase that does, naming them all. */
68
71
  const coverage = (
69
72
  part: ContractPart,
70
73
  without: readonly PhaseHeading[],
71
74
  ok: { line: number | null; message: string },
72
75
  problem: string,
76
+ unmet: PartStatus = PART_STATUS.malformed,
73
77
  ): PartFinding =>
74
78
  without.length === 0
75
79
  ? finding(part, PART_STATUS.present, ok.line, ok.message)
76
80
  : finding(
77
81
  part,
78
- PART_STATUS.malformed,
82
+ unmet,
79
83
  without[0].line,
80
84
  `${problem} ${phaseLabels(
81
85
  without.map(({ id }) => id),
@@ -108,7 +112,10 @@ const lacks = (headings: readonly PhaseHeading[], listed: ReadonlySet<string>):
108
112
 
109
113
  const modelPart = (markdown: string, language: PlanLanguage, headings: readonly PhaseHeading[]): PartFinding => {
110
114
  const rows = parseModelTable(markdown, language)
111
- if (rows === null) return finding(CONTRACT_PART.model, PART_STATUS.missing, null, 'no model table')
115
+ if (rows === null) {
116
+ const looked = lookedForSection(SECTION_KEY.model, '###', language)
117
+ return finding(CONTRACT_PART.model, PART_STATUS.missing, null, `no model table ${looked}`)
118
+ }
112
119
  const modelled = new Set(rows.filter(isDecided).map(({ id }) => id))
113
120
  return coverage(
114
121
  CONTRACT_PART.model,
@@ -122,7 +129,10 @@ const dependenciesPart = (markdown: string, language: PlanLanguage, headings: re
122
129
  if (headings.length < PHASES_NEEDING_DEPENDENCIES)
123
130
  return finding(CONTRACT_PART.dependencies, PART_STATUS.present, null, 'not needed with fewer than two phases')
124
131
  const table = parseDependencyTable(markdown, language)
125
- if (table === null) return finding(CONTRACT_PART.dependencies, PART_STATUS.missing, null, NO_DEPENDENCY_TABLE)
132
+ if (table === null) {
133
+ const looked = lookedForSection(SECTION_KEY.dependencies, '###', language)
134
+ return finding(CONTRACT_PART.dependencies, PART_STATUS.missing, null, `${NO_DEPENDENCY_TABLE} ${looked}`)
135
+ }
126
136
  return coverage(
127
137
  CONTRACT_PART.dependencies,
128
138
  lacks(headings, new Set(table.map(({ id }) => id))),
@@ -131,8 +141,18 @@ const dependenciesPart = (markdown: string, language: PlanLanguage, headings: re
131
141
  )
132
142
  }
133
143
 
134
- /** Notes, not gaps: a missing dependency table (the phases then run one after another) and the head, which old plans lack. */
135
- const NOTE_PARTS: readonly ContractPart[] = [CONTRACT_PART.dependencies, CONTRACT_PART.head]
144
+ /** A note: present when every `### Phase` with tasks opens with its purpose line, else missing at the first phase without, naming them all. */
145
+ const purposePart = (markdown: string, language: PlanLanguage): PartFinding =>
146
+ coverage(
147
+ CONTRACT_PART.purpose,
148
+ phasesWithoutPurpose(markdown, language),
149
+ { line: null, message: 'every phase says what it is about' },
150
+ 'no purpose line in',
151
+ PART_STATUS.missing,
152
+ )
153
+
154
+ /** Notes, not gaps: a missing dependency table (the phases then run one after another), and the head and the purpose lines, which old plans lack. */
155
+ const NOTE_PARTS: readonly ContractPart[] = [CONTRACT_PART.dependencies, CONTRACT_PART.head, CONTRACT_PART.purpose]
136
156
 
137
157
  export const isGap = ({ part, status }: PartFinding): boolean =>
138
158
  status === PART_STATUS.malformed || (status === PART_STATUS.missing && !NOTE_PARTS.includes(part))
@@ -162,6 +182,7 @@ export const contractOf = (markdown: string, language: PlanLanguage): PlanContra
162
182
  modelPart(markdown, language, headings),
163
183
  dependenciesPart(markdown, language, headings),
164
184
  headPart(markdown, language),
185
+ purposePart(markdown, language),
165
186
  ]
166
187
  return { parts, met: !parts.some(isGap) }
167
188
  }
@@ -1,6 +1,6 @@
1
1
  import { bareCodesOf } from '../../kit/hooks/hooks/shared/plan-codes'
2
- import { type PlanLanguage, WAITING_VOCABULARY } from '../vocabulary'
3
- import { firstTableRows, firstTableWithHeader, sectionLines, sectionStart } from './markdown'
2
+ import { COLUMN_KEY, type PlanLanguage, WAITING_VOCABULARY } from '../vocabulary'
3
+ import { columnIndex, firstTableRows, firstTableWithHeader, sectionLines, sectionStart } from './markdown'
4
4
 
5
5
  /** A changed row of the waiting section (1-based line) whose item cell names `code` bare, outside brackets. */
6
6
  export type RowWithBareCode = { line: number; code: string }
@@ -16,10 +16,10 @@ export const boardRowsWithBareCodes = (
16
16
  previousLines: ReadonlySet<string>,
17
17
  codes: ReadonlySet<string> | null,
18
18
  ): RowWithBareCode[] => {
19
- const { heading, columns } = WAITING_VOCABULARY[language]
19
+ const { heading } = WAITING_VOCABULARY[language]
20
20
  const start = sectionStart(markdown, heading)
21
21
  const lines = sectionLines(markdown, heading)
22
- const item = firstTableWithHeader(lines).header.indexOf(columns.item)
22
+ const item = columnIndex(firstTableWithHeader(lines).header, COLUMN_KEY.item, language)
23
23
  if (item === -1) return []
24
24
  return firstTableRows(lines).flatMap(({ index, line, cells }) => {
25
25
  const code = previousLines.has(line) ? undefined : bareCodesOf(cells[item] ?? '', codes)[0]
@@ -1,6 +1,6 @@
1
- import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
1
+ import { COLUMN_KEY, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
2
2
  import { PHASE_ID_PREFIX, phaseIdOf, phaseLabels, spannedPhaseIds } from './lanes'
3
- import { escapeRegExp, firstTableWithHeader, subsectionLines } from './markdown'
3
+ import { columnIndex, escapeRegExp, firstTableWithHeader, subsectionLines } from './markdown'
4
4
  import { type PhaseHeading, parsePlanPhases, phaseHeadings } from './phases'
5
5
  import { isPhaseFinished } from './progress'
6
6
 
@@ -163,10 +163,10 @@ const releasedByFiles = (writes: readonly string[]): string[] =>
163
163
  * such table or the table lacks one of its three columns, so a plan that never had one is not asked for rows.
164
164
  */
165
165
  export const parseDependencyTable = (markdown: string, language: PlanLanguage): PhaseDependency[] | null => {
166
- const { sections, columns } = PLAN_VOCABULARY[language]
166
+ const { sections } = PLAN_VOCABULARY[language]
167
167
  const { header, rows } = firstTableWithHeader(subsectionLines(markdown, sections.dependencies))
168
- const [after, writes, marker] = [columns.dependsOn, columns.writes, columns.serialMarker].map((name) =>
169
- header.indexOf(name),
168
+ const [after, writes, marker] = [COLUMN_KEY.dependsOn, COLUMN_KEY.writes, COLUMN_KEY.serialMarker].map((key) =>
169
+ columnIndex(header, key, language),
170
170
  )
171
171
  if (after === -1 || writes === -1 || marker === -1) return null
172
172
  return rows.flatMap((cells) => {
@@ -1,6 +1,6 @@
1
1
  import { posix } from 'node:path'
2
- import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
3
- import { firstHeading, firstTable, firstTableRows, sectionLines, sectionStart } from './markdown'
2
+ import { CELL_KEY, LANE_CLOSED_WORDS, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
3
+ import { firstHeading, firstTable, firstTableRows, namesState, sectionLines, sectionStart } from './markdown'
4
4
 
5
5
  /**
6
6
  * `namedPhaseIds` holds every id the row names when `withoutRepeats` dropped some because an earlier row of the lane
@@ -156,14 +156,13 @@ export const withoutRepeats = (lane: SprintLane, step: PlanStep): PlanStep => {
156
156
  export const parseSprintLanes = (markdown: string, language: PlanLanguage): SprintLane[] => {
157
157
  const lanes = new Map<string, SprintLane>()
158
158
  const phaseId = phaseIdPatterns(language)
159
- const { laneClosed } = PLAN_VOCABULARY[language]
160
159
  let previous: string | null = null
161
160
  for (const cells of firstTable(sectionLines(markdown, PLAN_VOCABULARY[language].sections.lanes))) {
162
161
  const name = laneNameOf(cells[0] ?? '', previous)
163
162
  if (!name) continue
164
163
  const port = Number.parseInt(cells[1] ?? '', 10)
165
164
  const lane = lanes.get(name) ?? { name, port: Number.isNaN(port) ? null : port, steps: [], closed: false }
166
- const marked = (cells[0] ?? '').includes(laneClosed)
165
+ const marked = namesState(cells[0] ?? '', CELL_KEY.laneClosed, LANE_CLOSED_WORDS)
167
166
  if (lane.closed && !marked && !(cells[0] ?? '').startsWith(CONTINUATION)) {
168
167
  lane.closed = false
169
168
  lane.steps = []