jackherd 1.23.3 → 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 (90) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/INSTALL.md +16 -0
  3. package/README.md +23 -22
  4. package/dist/{chunk-JDO6O3UO.js → chunk-O365KHHP.js} +139 -3
  5. package/dist/config.d.ts +13 -1
  6. package/dist/config.js +178 -132
  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/shared/board-lanes.ts +33 -12
  15. package/kit/hooks/hooks/shared/boards.ts +22 -6
  16. package/kit/workflow/skills/orchestrate/SKILL.md +2 -1
  17. package/kit/workflow/skills/orchestrate/references/backend.md +30 -0
  18. package/kit/workflow/skills/orchestrate/references/close-lane.md +1 -0
  19. package/kit/workflow/skills/orchestrate/references/herd.md +3 -3
  20. package/kit/workflow/skills/orchestrate/references/start-lane.md +2 -2
  21. package/kit/workflow/skills/plan/SKILL.md +3 -1
  22. package/kit/workflow/skills/plan/references/template-English.md +2 -0
  23. package/kit/workflow/skills/plan/references/template-German.md +2 -0
  24. package/package.json +1 -1
  25. package/schemas/jackherd.schema.json +49 -0
  26. package/src/backend/command-line.ts +116 -0
  27. package/src/backend/command.ts +14 -0
  28. package/src/backend/control.ts +194 -0
  29. package/src/backend/handover.ts +28 -0
  30. package/src/backend/herdr.ts +147 -0
  31. package/src/backend/lock.ts +84 -0
  32. package/src/backend/process-cwd.ts +32 -0
  33. package/src/backend/schema.ts +47 -0
  34. package/src/backend/state.ts +57 -0
  35. package/src/backend/step.ts +93 -0
  36. package/src/backend/system.ts +150 -0
  37. package/src/backend/watch.ts +75 -0
  38. package/src/brief/command.ts +1 -1
  39. package/src/brief/lane-facts.ts +2 -2
  40. package/src/check/command.ts +2 -1
  41. package/src/check/plan-structure-check.ts +68 -14
  42. package/src/check/settings.ts +6 -1
  43. package/src/check/skill-budget-defaults.ts +6 -3
  44. package/src/cli.ts +1 -0
  45. package/src/config/schema.ts +6 -0
  46. package/src/lane/close-board.ts +4 -4
  47. package/src/lane/close-command.ts +2 -2
  48. package/src/lane/dev-command.ts +1 -1
  49. package/src/lane/dev.ts +2 -2
  50. package/src/lane/port-command.ts +1 -1
  51. package/src/plan-contract/command.ts +2 -1
  52. package/src/plan-contract/contract.ts +30 -9
  53. package/src/plan-format/bare-codes.ts +4 -4
  54. package/src/plan-format/dependencies.ts +5 -5
  55. package/src/plan-format/lanes.ts +3 -4
  56. package/src/plan-format/markdown.ts +125 -10
  57. package/src/plan-format/open-status.ts +15 -0
  58. package/src/plan-format/phases.ts +45 -4
  59. package/src/plan-format/plan-head.ts +31 -9
  60. package/src/plan-format/plan-structure.ts +43 -7
  61. package/src/plan-format/progress.ts +3 -0
  62. package/src/plan-format/sprint-document.ts +4 -4
  63. package/src/plan-format/unrecognized-sections.ts +21 -0
  64. package/src/plan-format/waiting.ts +13 -13
  65. package/src/plans/board-close.ts +25 -16
  66. package/src/plans/board-pick.ts +1 -1
  67. package/src/plans/ensure-panes.ts +2 -2
  68. package/src/plans/layout-summary.ts +8 -1
  69. package/src/plans/layout.ts +14 -5
  70. package/src/plans/live.ts +1 -1
  71. package/src/plans/plan-panes.ts +1 -1
  72. package/src/plans/settings.ts +6 -0
  73. package/src/plans/snapshot.ts +9 -5
  74. package/src/plans/sprint-export.ts +1 -1
  75. package/src/plans/texts.ts +3 -0
  76. package/src/plans/view.ts +1 -0
  77. package/src/resume/coordinator-read.ts +2 -2
  78. package/src/settled/command.ts +1 -1
  79. package/src/settled/settle.ts +5 -5
  80. package/src/shared/shell-word.ts +2 -0
  81. package/src/sync/render.ts +20 -5
  82. package/src/sync/sync-plan.ts +6 -2
  83. package/src/sync/sync.ts +3 -2
  84. package/src/vocabulary/index.ts +192 -4
  85. package/src/watch/appointments.ts +13 -20
  86. package/src/watch/command.ts +32 -5
  87. package/src/watch/lane-progress.ts +1 -1
  88. package/src/watch/merge-overlap-read.ts +2 -2
  89. package/src/watch/plan-missing-read.ts +3 -1
  90. package/src/watch/reference-paths.ts +4 -0
@@ -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)
package/src/lane/dev.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { isRecord } from '../shared/is-record'
2
+ import { SHELL_WORD } from '../shared/shell-word'
2
3
  import { CLI_NAME } from '../version/release'
3
4
  import { MAX_PORT } from './port'
4
5
 
@@ -36,7 +37,6 @@ board, the command is lanes.devCommand of the config ({port} and {filter} stand
36
37
  PORT=<port> <command> in a pane labelled "<lane> dev :<port>". A port something listens on is refused; a pane with
37
38
  that label in the lane's tab is reported and left alone.`
38
39
 
39
- const FILTER_VALUE = /^[\w@./:-]+$/
40
40
  const PORT_PLACEHOLDER = '{port}'
41
41
  const FILTER_PLACEHOLDER = '{filter}'
42
42
 
@@ -83,7 +83,7 @@ export const devLabelOf = (lane: string, port: number): string => `${lane} dev :
83
83
  */
84
84
  export const devCommandOf = (template: string | null | undefined, port: number, filter: string | null): string => {
85
85
  if (!template) throw new Error('no dev command: set lanes.devCommand in .jackherd/config.json')
86
- if (filter !== null && !FILTER_VALUE.test(filter)) throw new Error(`${FILTER} ${filter} is no package name`)
86
+ if (filter !== null && !SHELL_WORD.test(filter)) throw new Error(`${FILTER} ${filter} is no package name`)
87
87
  if (filter === null && template.includes(FILTER_PLACEHOLDER))
88
88
  throw new Error(`lanes.devCommand names {filter}: pass ${FILTER} <package>`)
89
89
  const command = template.replaceAll(PORT_PLACEHOLDER, String(port)).replaceAll(FILTER_PLACEHOLDER, filter ?? '')
@@ -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 = []
@@ -1,3 +1,15 @@
1
+ import {
2
+ type CellKey,
3
+ type ColumnKey,
4
+ columnWords,
5
+ headingOfSection,
6
+ isSectionKey,
7
+ languagesFrom,
8
+ MARKER_IN_TEXT,
9
+ type PlanLanguage,
10
+ type SectionKey,
11
+ sectionOfHeading,
12
+ } from '../vocabulary'
1
13
  import { terminalSafe } from './terminal-text'
2
14
 
3
15
  const SECTION_END = /^##\s/
@@ -5,20 +17,120 @@ const TABLE_SEPARATOR = /^\|[\s|:-]+\|$/
5
17
 
6
18
  export const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
7
19
 
8
- /** The index of the line after a level-2 heading with exactly this text; -1 when there is none. */
9
- export const sectionStart = (markdown: string, headingText: string): number => {
10
- const heading = new RegExp(`^##\\s+${escapeRegExp(headingText)}\\s*$`)
20
+ const MARKER = /^<!--\s*jh:\s*([a-z-]+)\s*-->\s*$/
21
+
22
+ /** The key of the `<!-- jh: <key> -->` marker in `text`, wherever it stands; null when there is none. The key is not checked against any vocabulary. */
23
+ export const markedKeyOf = (text: string): string | null => MARKER_IN_TEXT.exec(text)?.[1] ?? null
24
+
25
+ /** `text` without its marker, trimmed: what a cell or a line says in words. */
26
+ export const withoutMarker = (text: string): string => text.replace(MARKER_IN_TEXT, '').trim()
27
+
28
+ /**
29
+ * What a reader looked for when it found no section `key`: `(looked for "### A", "### B" or <!-- jh: key -->)`, the
30
+ * heading of `language` first. `level` is the heading marks the reader accepts (`##` or `###`).
31
+ */
32
+ export const lookedForSection = (key: SectionKey, level: '##' | '###', language: PlanLanguage): string => {
33
+ const headings = languagesFrom(language).map((candidate) => `"${level} ${headingOfSection(key, candidate)}"`)
34
+ return `(looked for ${headings.join(', ')} or <!-- jh: ${key} -->)`
35
+ }
36
+
37
+ /** Whether `text` carries the marker `key` or contains one of `words`: a state a cell names in any language. */
38
+ export const namesState = (text: string, key: CellKey, words: readonly string[]): boolean =>
39
+ markedKeyOf(text) === key || words.some((word) => text.includes(word))
40
+
41
+ export const SECTION_HEADING = /^##\s+(.*?)\s*$/
42
+
43
+ const markerKey = (line: string | undefined): SectionKey | null => {
44
+ const key = line === undefined ? undefined : MARKER.exec(line)?.[1]
45
+ return key !== undefined && isSectionKey(key) ? key : null
46
+ }
47
+
48
+ type HeadingMatch = { pattern: RegExp; matches: (text: string, heading: string) => boolean }
49
+
50
+ const SECTION_MATCH: HeadingMatch = { pattern: SECTION_HEADING, matches: (text, heading) => text === heading }
51
+ const SUBSECTION_MATCH: HeadingMatch = {
52
+ pattern: /^(#{2,4})\s+(.*?)\s*$/,
53
+ matches: (text, heading) => text.startsWith(heading),
54
+ }
55
+
56
+ const headingText = (line: string, { pattern }: HeadingMatch): string | undefined => pattern.exec(line)?.at(-1)
57
+
58
+ const headingIndexOf = (lines: string[], key: SectionKey, language: PlanLanguage, match: HeadingMatch): number => {
59
+ const marked = lines.findIndex((line, index) => match.pattern.test(line) && markerKey(lines[index + 1]) === key)
60
+ if (marked !== -1) return marked
61
+ for (const candidate of languagesFrom(language)) {
62
+ const heading = headingOfSection(key, candidate)
63
+ const found = lines.findIndex((line) => {
64
+ const text = headingText(line, match)
65
+ return text !== undefined && match.matches(text, heading)
66
+ })
67
+ if (found !== -1) return found
68
+ }
69
+ return -1
70
+ }
71
+
72
+ /**
73
+ * The index of the line after the level-2 heading that names the section `key`; -1 when none does. A heading names it by the
74
+ * marker `<!-- jh: <key> -->` on the line directly under it (any language), else by the text of `language`, else by the
75
+ * text of any other known language. The marker line itself is not part of the section.
76
+ */
77
+ export const findSectionStart = (markdown: string, key: SectionKey, language: PlanLanguage): number => {
78
+ const lines = markdown.split('\n')
79
+ const heading = headingIndexOf(lines, key, language, SECTION_MATCH)
80
+ if (heading === -1) return -1
81
+ return heading + (markerKey(lines[heading + 1]) === key ? 2 : 1)
82
+ }
83
+
84
+ /** The 0-based index of the level-2 heading that names the section `key`, found as `findSectionStart` finds it; -1 when none does. */
85
+ export const findSectionHeading = (markdown: string, key: SectionKey, language: PlanLanguage): number =>
86
+ headingIndexOf(markdown.split('\n'), key, language, SECTION_MATCH)
87
+
88
+ const untilNextSection = (rest: string[]): string[] => {
89
+ const end = rest.findIndex((line) => SECTION_END.test(line))
90
+ return end === -1 ? rest : rest.slice(0, end)
91
+ }
92
+
93
+ /** The lines of the section `key` names (see `findSectionStart`), up to the next level-2 heading; empty when none does. */
94
+ export const findSection = (markdown: string, key: SectionKey, language: PlanLanguage): string[] => {
95
+ const start = findSectionStart(markdown, key, language)
96
+ return start === -1 ? [] : untilNextSection(markdown.split('\n').slice(start))
97
+ }
98
+
99
+ /**
100
+ * The position of the column `key` names among the header cells; -1 when none does. A cell names it by the marker
101
+ * `<!-- jh: <key> -->` (any language), else by a header name of `language`, else by one of any other known language.
102
+ */
103
+ export const columnIndex = (header: string[], key: ColumnKey, language: PlanLanguage): number => {
104
+ const marked = header.findIndex((cell) => markedKeyOf(cell) === key)
105
+ if (marked !== -1) return marked
106
+ const names = header.map(withoutMarker)
107
+ for (const candidate of languagesFrom(language)) {
108
+ for (const word of columnWords(key, candidate)) {
109
+ const found = names.indexOf(word)
110
+ if (found !== -1) return found
111
+ }
112
+ }
113
+ return -1
114
+ }
115
+
116
+ const exactSectionStart = (markdown: string, headingTextValue: string): number => {
117
+ const heading = new RegExp(`^##\\s+${escapeRegExp(headingTextValue)}\\s*$`)
11
118
  const start = markdown.split('\n').findIndex((line) => heading.test(line))
12
119
  return start === -1 ? -1 : start + 1
13
120
  }
14
121
 
15
- /** The lines under a level-2 heading with exactly this text, up to the next level-2 heading. */
122
+ /** The index of the line after a level-2 heading with exactly this text, or after the one that names the same section in another language or by marker; -1 when there is none. */
123
+ export const sectionStart = (markdown: string, headingText: string): number => {
124
+ const section = sectionOfHeading(headingText)
125
+ return section === null
126
+ ? exactSectionStart(markdown, headingText)
127
+ : findSectionStart(markdown, section.key, section.language)
128
+ }
129
+
130
+ /** The lines under that heading (see `sectionStart`), up to the next level-2 heading. */
16
131
  export const sectionLines = (markdown: string, headingText: string): string[] => {
17
132
  const start = sectionStart(markdown, headingText)
18
- if (start === -1) return []
19
- const rest = markdown.split('\n').slice(start)
20
- const end = rest.findIndex((line) => SECTION_END.test(line))
21
- return end === -1 ? rest : rest.slice(0, end)
133
+ return start === -1 ? [] : untilNextSection(markdown.split('\n').slice(start))
22
134
  }
23
135
 
24
136
  const tableCells = (line: string): string[] =>
@@ -57,8 +169,11 @@ export const firstHeading = (markdown: string, fallback: string): string =>
57
169
  const HEADING = /^(#{2,4})\s+(.*?)\s*$/
58
170
 
59
171
  /** The 0-based line index of the first level 2 to 4 heading whose text starts with `headingText` (a suffix such as `(PFLICHT ab 3 Phasen)` is allowed); -1 when there is none. */
60
- export const subsectionStart = (markdown: string, headingText: string): number =>
61
- markdown.split('\n').findIndex((line) => HEADING.exec(line)?.[2].startsWith(headingText))
172
+ export const subsectionStart = (markdown: string, headingText: string): number => {
173
+ const section = sectionOfHeading(headingText)
174
+ if (section !== null) return headingIndexOf(markdown.split('\n'), section.key, section.language, SUBSECTION_MATCH)
175
+ return markdown.split('\n').findIndex((line) => HEADING.exec(line)?.[2].startsWith(headingText))
176
+ }
62
177
 
63
178
  /** The lines under that heading (see `subsectionStart`), up to the next heading of the same or a higher level. */
64
179
  export const subsectionLines = (markdown: string, headingText: string): string[] => {
@@ -0,0 +1,15 @@
1
+ import { isStatusKey, STATUS_KEY } from '../vocabulary'
2
+ import { markedKeyOf, withoutMarker } from './markdown'
3
+
4
+ /**
5
+ * Whether a status cell of a board row says the row is still open. A status marker decides (`<!-- jh: status-open -->`
6
+ * is open, any other status marker is not); else an empty cell is open (a row nobody closed is not dropped in
7
+ * silence), and so is a cell that starts with one of the lower-case `openWords`. The words come from every known
8
+ * language, so a board need not match the configured one.
9
+ */
10
+ export const isOpenStatus = (status: string, openWords: readonly string[]): boolean => {
11
+ const marked = markedKeyOf(status)
12
+ if (marked !== null && isStatusKey(marked)) return marked === STATUS_KEY.open
13
+ const lower = withoutMarker(status).toLowerCase()
14
+ return lower === '' || openWords.some((word) => lower.startsWith(word))
15
+ }
@@ -1,6 +1,6 @@
1
- import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
1
+ import { LINE_KEY, languagesFrom, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
2
2
  import { PHASE_ID_PREFIX } from './lanes'
3
- import { escapeRegExp } from './markdown'
3
+ import { escapeRegExp, markedKeyOf, withoutMarker } from './markdown'
4
4
  import { terminalSafe } from './terminal-text'
5
5
 
6
6
  /** A checkbox of a plan. `gate` is set on the ones listed under the phase's review gate, and only on those. */
@@ -29,11 +29,13 @@ export type PhaseWorkFields = {
29
29
  /**
30
30
  * `work` is set by the phase source and says nothing for a phase the worktree left as the base has it; a merged phase
31
31
  * is touched as well. `deferred` holds the checkboxes moved to another phase; they are not among `items`, so they
32
- * count neither as open nor as done.
32
+ * count neither as open nor as done. `purpose` is the sentence of the purpose line directly under a `### Phase`
33
+ * heading (`phasePurposeOf`); absent when the phase has none.
33
34
  */
34
35
  export type PlanPhase = {
35
36
  id: string
36
37
  title: string
38
+ purpose?: string
37
39
  items: ChecklistItem[]
38
40
  deferred?: DeferredItem[]
39
41
  } & Partial<PhaseWorkFields>
@@ -72,6 +74,30 @@ export const isOpenCheckboxLine = (line: string): boolean => line.match(CHECKBOX
72
74
  export const phaseHeadingPattern = (language: PlanLanguage): RegExp =>
73
75
  new RegExp(`^###\\s+${escapeRegExp(PLAN_VOCABULARY[language].phase)}\\s+(\\d+[a-z]?)\\b\\s*[:—–-]?\\s*(.*)$`, 'i')
74
76
 
77
+ /** The bold opening of a line (`**Label:**` or `**Label**:`): the label and the text after it. */
78
+ export const BOLD_OPENING = /^\s*\*\*([^*]+?)\s*:?\*\*\s*:?\s*(.*)$/
79
+
80
+ /** Whether the bold `label` of `line` names the purpose line: by the marker on the line, else by the word of `language`, else by that of any other known language. */
81
+ const namesPurpose = (line: string, label: string, language: PlanLanguage): boolean => {
82
+ if (markedKeyOf(line) === LINE_KEY.purpose) return true
83
+ const written = label.trim().toLowerCase()
84
+ return languagesFrom(language).some((candidate) => PLAN_VOCABULARY[candidate].phasePurpose.toLowerCase() === written)
85
+ }
86
+
87
+ /**
88
+ * The sentence of a purpose line, `**Worum es geht:** <sentence>`, the colon inside or outside the bold; null for any
89
+ * other line and for a purpose line without text after the colon.
90
+ */
91
+ export const phasePurposeOf = (line: string, language: PlanLanguage): string | null => {
92
+ const opening = BOLD_OPENING.exec(line)
93
+ if (!opening || !namesPurpose(line, opening[1], language)) return null
94
+ const text = terminalSafe(withoutMarker(opening[2])).trim()
95
+ return text === '' ? null : text
96
+ }
97
+
98
+ /** The first non-empty line under a heading decides whether a phase has its purpose line; once `decided`, later lines never do. */
99
+ export const decidesPurpose = (decided: boolean, line: string): boolean => !decided && line.trim() !== ''
100
+
75
101
  const tableCells = (line: string): string[] | null => {
76
102
  const trimmed = line.trim()
77
103
  if (!trimmed.startsWith('|')) return null
@@ -220,6 +246,14 @@ const notePointer = (pointers: Map<PlanPhase, string>, phase: PlanPhase | null,
220
246
  if (phase && pointer) pointers.set(phase, pointer)
221
247
  }
222
248
 
249
+ /** Reads the first non-empty line under a `### Phase` heading as the phase's purpose; later lines and `## N —` sections never are. */
250
+ const notePurpose = (read: Set<PlanPhase>, phase: PlanPhase | null, line: string, language: PlanLanguage): void => {
251
+ if (!phase || !decidesPurpose(read.has(phase), line)) return
252
+ read.add(phase)
253
+ const purpose = phasePurposeOf(line, language)
254
+ if (purpose !== null) phase.purpose = purpose
255
+ }
256
+
223
257
  const headingOf = (line: string, phaseHeading: RegExp): RegExpMatchArray | null =>
224
258
  line.match(phaseHeading) ?? line.match(NUMBERED_SECTION)
225
259
 
@@ -244,15 +278,22 @@ export const phaseHeadings = (markdown: string, language: PlanLanguage): PhaseHe
244
278
  export const parsePlanPhases = (markdown: string, language: PlanLanguage): PlanPhase[] => {
245
279
  const phaseHeading = phaseHeadingPattern(language)
246
280
  const pointers = new Map<PlanPhase, string>()
281
+ const purposeRead = new Set<PlanPhase>()
247
282
  const { phases } = markdown.split('\n').reduce<{ phases: PlanPhase[]; open: PlanPhase | null; gate: boolean }>(
248
283
  ({ phases, open, gate }, line) => {
249
284
  const heading = headingOf(line, phaseHeading)
250
285
  if (heading) {
251
- const phase = { id: (heading[1] ?? '').toLowerCase(), title: terminalSafe(heading[2] ?? '').trim(), items: [] }
286
+ const phase: PlanPhase = {
287
+ id: (heading[1] ?? '').toLowerCase(),
288
+ title: terminalSafe(heading[2] ?? '').trim(),
289
+ items: [],
290
+ }
291
+ if (!phaseHeading.test(line)) purposeRead.add(phase)
252
292
  return { phases: [...phases, phase], open: phase, gate: false }
253
293
  }
254
294
  if (PHASE_END.test(line)) return { phases, open: null, gate: false }
255
295
  const inGate = gate || REVIEW_GATE.test(line)
296
+ notePurpose(purposeRead, open, line, language)
256
297
  if (open) fileLine(open, line, inGate)
257
298
  notePointer(pointers, open, line)
258
299
  return { phases, open, gate: inGate }
@@ -1,5 +1,13 @@
1
- import { HEAD_LINE, type HeadLine, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
2
- import { escapeRegExp } from './markdown'
1
+ import {
2
+ HEAD_KEY,
3
+ HEAD_LINE,
4
+ type HeadLine,
5
+ languagesFrom,
6
+ PLAN_VOCABULARY,
7
+ type PlanLanguage,
8
+ SECTION_KEY,
9
+ } from '../vocabulary'
10
+ import { escapeRegExp, findSectionHeading, lookedForSection, markedKeyOf, withoutMarker } from './markdown'
3
11
  import { parsePlanPhases } from './phases'
4
12
 
5
13
  /** What a plan holds of its head: all of it and current, none of it, or something that no longer matches the plan. */
@@ -38,12 +46,21 @@ const HEAD_LINES = Object.values(HEAD_LINE)
38
46
 
39
47
  const labelOf = (language: PlanLanguage, line: HeadLine): string => PLAN_VOCABULARY[language].head[line].toLowerCase()
40
48
 
49
+ /** The head line a label names: by the marker on the line, else by the label of `language`, else by that of any other known language. */
50
+ const labelNamed = (text: string, written: string, language: PlanLanguage): HeadLine | undefined => {
51
+ const marked = markedKeyOf(text)
52
+ const byMarker = HEAD_LINES.find((each) => HEAD_KEY[each] === marked)
53
+ if (byMarker) return byMarker
54
+ return languagesFrom(language)
55
+ .map((candidate) => HEAD_LINES.find((each) => labelOf(candidate, each) === written))
56
+ .find((each) => each !== undefined)
57
+ }
58
+
41
59
  const entryOf = (text: string, language: PlanLanguage): { label: HeadLine; text: string } | null => {
42
60
  const match = text.match(HEAD_ENTRY)
43
61
  if (!match) return null
44
- const written = match[1].trim().toLowerCase()
45
- const label = HEAD_LINES.find((each) => labelOf(language, each) === written)
46
- return label ? { label, text: match[2].trim() } : null
62
+ const label = labelNamed(text, match[1].trim().toLowerCase(), language)
63
+ return label ? { label, text: withoutMarker(match[2]) } : null
47
64
  }
48
65
 
49
66
  const phaseOf = (statusText: string | undefined, language: PlanLanguage): HeadPhase | null => {
@@ -59,8 +76,7 @@ const phaseOf = (statusText: string | undefined, language: PlanLanguage): HeadPh
59
76
  */
60
77
  export const parsePlanHead = (markdown: string, language: PlanLanguage): PlanHead | null => {
61
78
  const lines = markdown.split('\n')
62
- const heading = new RegExp(`^##\\s+${escapeRegExp(PLAN_VOCABULARY[language].sections.forYou)}\\s*$`)
63
- const start = lines.findIndex((text) => heading.test(text))
79
+ const start = findSectionHeading(markdown, SECTION_KEY.forYou, language)
64
80
  if (start === -1) return null
65
81
  const rest = lines.slice(start + 1)
66
82
  const end = rest.findIndex((text) => SECTION_HEADING.test(text))
@@ -107,8 +123,14 @@ const phaseMismatch = ({ current, total }: HeadPhase, states: readonly PhaseStat
107
123
  */
108
124
  export const checkPlanHead = (markdown: string, language: PlanLanguage): HeadVerdict => {
109
125
  const head = parsePlanHead(markdown, language)
110
- const { sections } = PLAN_VOCABULARY[language]
111
- if (head === null) return verdict(HEAD_STATUS.missing, null, `no \`## ${sections.forYou}\` section`)
126
+ if (head === null) {
127
+ const looked = lookedForSection(SECTION_KEY.forYou, '##', language)
128
+ return verdict(
129
+ HEAD_STATUS.missing,
130
+ null,
131
+ `no \`## ${PLAN_VOCABULARY[language].sections.forYou}\` section ${looked}`,
132
+ )
133
+ }
112
134
  if (head.misplaced)
113
135
  return verdict(
114
136
  HEAD_STATUS.stale,