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
@@ -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,
@@ -1,7 +1,9 @@
1
- import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
1
+ import { LINE_KEY, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
2
2
  import { changedOpenLaneRows, type OpenLaneRow, phaseIdsOfCell } from './lanes'
3
- import { escapeRegExp, sectionLines } from './markdown'
3
+ import { escapeRegExp, markedKeyOf, sectionLines } from './markdown'
4
4
  import {
5
+ BOLD_OPENING,
6
+ decidesPurpose,
5
7
  deferralTarget,
6
8
  endsPhase,
7
9
  isCheckboxLine,
@@ -11,6 +13,7 @@ import {
11
13
  type PlanPhase,
12
14
  parsePlanPhases,
13
15
  phaseHeadingPattern,
16
+ phasePurposeOf,
14
17
  } from './phases'
15
18
  import { terminalSafe } from './terminal-text'
16
19
 
@@ -26,10 +29,21 @@ export type PhaseWithOpenBoxes = PhaseWithoutGate & { openLines: number[] }
26
29
  /** A `PhaseWithOpenBoxes` whose tasks are all ticked on the base branch. */
27
30
  export type MergedPhaseWithOpenBoxes = PhaseWithOpenBoxes
28
31
 
29
- type PhaseSection = PhaseWithoutGate & { tasks: boolean; gate: boolean; openLines: number[] }
32
+ /** A `### Phase` heading (1-based line) whose section has tasks and no purpose line directly under the heading. */
33
+ export type PhaseWithoutPurpose = PhaseHeading
30
34
 
31
- const noGatePattern = (language: PlanLanguage): RegExp =>
32
- new RegExp(`^\\s*\\*\\*${escapeRegExp(PLAN_VOCABULARY[language].noReviewGate)}\\b`, 'i')
35
+ type PhaseSection = PhaseWithoutGate & { tasks: boolean; gate: boolean; purpose: boolean | null; openLines: number[] }
36
+
37
+ const NO_GATE_WORDS = new RegExp(
38
+ `^\\s*\\*\\*(?:${Object.values(PLAN_VOCABULARY)
39
+ .map(({ noReviewGate }) => escapeRegExp(noReviewGate))
40
+ .join('|')})\\b`,
41
+ 'i',
42
+ )
43
+
44
+ /** The line by which a phase says it has no review gate on purpose: a bold opening with the words of any known language, or with the marker. */
45
+ const isNoGateLine = (line: string): boolean =>
46
+ NO_GATE_WORDS.test(line) || (BOLD_OPENING.test(line) && markedKeyOf(line) === LINE_KEY.noReviewGate)
33
47
 
34
48
  const tickCount = (phase: PlanPhase): number => phase.items.filter((item) => item.done).length
35
49
 
@@ -39,10 +53,13 @@ const tasksDone = (phase: PlanPhase): boolean => {
39
53
  return tasks.length > 0 && tasks.every((item) => item.done)
40
54
  }
41
55
 
56
+ const settlePurpose = (section: PhaseSection, line: string, language: PlanLanguage): void => {
57
+ if (decidesPurpose(section.purpose !== null, line)) section.purpose = phasePurposeOf(line, language) !== null
58
+ }
59
+
42
60
  /** Every `### Phase` section with whether it has tasks and whether it has a gate or says why it has none. */
43
61
  const phaseSections = (markdown: string, language: PlanLanguage): PhaseSection[] => {
44
62
  const heading = phaseHeadingPattern(language)
45
- const noGate = noGatePattern(language)
46
63
  const sections: PhaseSection[] = []
47
64
  let open: PhaseSection | null = null
48
65
  for (const [index, line] of markdown.split('\n').entries()) {
@@ -54,14 +71,16 @@ const phaseSections = (markdown: string, language: PlanLanguage): PhaseSection[]
54
71
  title: terminalSafe(match[2]).trim(),
55
72
  tasks: false,
56
73
  gate: false,
74
+ purpose: null,
57
75
  openLines: [],
58
76
  }
59
77
  sections.push(open)
60
78
  } else if (endsPhase(line)) {
61
79
  open = null
62
80
  } else if (open) {
81
+ settlePurpose(open, line, language)
63
82
  open.tasks ||= isCheckboxLine(line)
64
- open.gate ||= isReviewGateLine(line) || noGate.test(line)
83
+ open.gate ||= isReviewGateLine(line) || isNoGateLine(line)
65
84
  if (isOpenCheckboxLine(line) && deferralTarget(line, open.id) === null) open.openLines.push(index + 1)
66
85
  }
67
86
  }
@@ -87,6 +106,23 @@ export const newPhasesWithoutReviewGate = (
87
106
  .filter(({ id, tasks, gate }) => tasks && !gate && !previousIds.has(id))
88
107
  .map(({ line, id, title }) => ({ line, id, title }))
89
108
 
109
+ /**
110
+ * The phases a plan gained against `previousIds` that have checkboxes and no purpose line as the first non-empty line
111
+ * under the heading (`phasePurposeOf`). Phases the plan already had are not read, like in `newPhasesWithoutReviewGate`.
112
+ */
113
+ export const newPhasesWithoutPurpose = (
114
+ markdown: string,
115
+ language: PlanLanguage,
116
+ previousIds: ReadonlySet<string>,
117
+ ): PhaseWithoutPurpose[] =>
118
+ phaseSections(markdown, language)
119
+ .filter(({ id, tasks, purpose }) => tasks && !purpose && !previousIds.has(id))
120
+ .map(({ line, id, title }) => ({ line, id, title }))
121
+
122
+ /** Every phase of the plan with tasks and without a purpose line: for a plan read whole, where no phase is older than the change. */
123
+ export const phasesWithoutPurpose = (markdown: string, language: PlanLanguage): PhaseWithoutPurpose[] =>
124
+ newPhasesWithoutPurpose(markdown, language, new Set())
125
+
90
126
  /** Whether the document has a lanes table (`## Spuren`): a board, not a plan. */
91
127
  export const hasLanesTable = (markdown: string, language: PlanLanguage): boolean =>
92
128
  sectionLines(markdown, PLAN_VOCABULARY[language].sections.lanes).some((line) => line.startsWith('|'))
@@ -8,6 +8,8 @@ import { type ChecklistItem, type DeferredItem, type PhaseWorkFields, type PlanP
8
8
  export type PhaseProgress = {
9
9
  id: string
10
10
  title: string | null
11
+ /** The phase's purpose sentence; absent when the plan has none or lacks the phase. */
12
+ purpose?: string
11
13
  done: number
12
14
  total: number
13
15
  items: ChecklistItem[]
@@ -46,6 +48,7 @@ export const phaseProgress = (phases: readonly PlanPhase[], id: string): PhasePr
46
48
  done: items.filter((item) => item.done).length,
47
49
  total: items.length,
48
50
  items,
51
+ ...(phase?.purpose ? { purpose: phase.purpose } : {}),
49
52
  ...(phase?.deferred ? { deferred: phase.deferred } : {}),
50
53
  ...(phase?.work ? phaseWorkFields(phase.work) : {}),
51
54
  }
@@ -1,5 +1,5 @@
1
- import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
2
- import { allTables, firstTableWithHeader, sectionLines, subsectionLines } from './markdown'
1
+ import { COLUMN_KEY, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
2
+ import { allTables, columnIndex, firstTableWithHeader, sectionLines, subsectionLines } from './markdown'
3
3
 
4
4
  const PHASE_ID_IN_CELL = /^(\d+[a-z]?)\b/
5
5
 
@@ -57,9 +57,9 @@ export const readSprintDocument = (markdown: string, language: PlanLanguage): Sp
57
57
  * first cell of a row starts with the id (`3 — Sync-Engine`); a plan without the table gives an empty map.
58
58
  */
59
59
  export const readPhaseDependencies = (planMarkdown: string, language: PlanLanguage): Record<string, string> => {
60
- const { sections, columns } = PLAN_VOCABULARY[language]
60
+ const { sections } = PLAN_VOCABULARY[language]
61
61
  const { header, rows } = firstTableWithHeader(subsectionLines(planMarkdown, sections.dependencies))
62
- const column = header.indexOf(columns.dependsOn)
62
+ const column = columnIndex(header, COLUMN_KEY.dependsOn, language)
63
63
  if (column === -1) return {}
64
64
  const entries = rows.flatMap((cells) => {
65
65
  const id = PHASE_ID_IN_CELL.exec(cells[0] ?? '')?.[1]
@@ -0,0 +1,21 @@
1
+ import { isSectionKey, sectionOfHeading } from '../vocabulary'
2
+ import { markedKeyOf, SECTION_HEADING } from './markdown'
3
+
4
+ const isRecognized = (lines: readonly string[], index: number): boolean => {
5
+ const text = SECTION_HEADING.exec(lines[index])?.[1] ?? ''
6
+ const marked = markedKeyOf(lines[index + 1] ?? '')
7
+ return sectionOfHeading(text) !== null || (marked !== null && isSectionKey(marked))
8
+ }
9
+
10
+ /**
11
+ * The 1-based line of the first `##` heading of a board or plan when it has such headings and jh reads none of them,
12
+ * neither by the heading of a known language nor by a marker `<!-- jh: <key> -->` directly under it: a document in a
13
+ * language without vocabulary and without markers, which every reader would take for empty. Null when the document has
14
+ * no `##` heading or reads at least one.
15
+ */
16
+ export const firstUnrecognizedSectionLine = (markdown: string): number | null => {
17
+ const lines = markdown.split('\n')
18
+ const headings = lines.flatMap((line, index) => (SECTION_HEADING.test(line) ? [index] : []))
19
+ if (headings.length === 0 || headings.some((index) => isRecognized(lines, index))) return null
20
+ return headings[0] + 1
21
+ }
@@ -1,5 +1,6 @@
1
- import { type PlanLanguage, WAITING_VOCABULARY } from '../vocabulary'
2
- import { firstTableWithHeader, sectionLines } from './markdown'
1
+ import { COLUMN_KEY, type PlanLanguage, WAITING_VOCABULARY } from '../vocabulary'
2
+ import { columnIndex, firstTableWithHeader, sectionLines } from './markdown'
3
+ import { isOpenStatus } from './open-status'
3
4
  import { terminalSafe } from './terminal-text'
4
5
 
5
6
  /** `file` for a file or an Artifact that was sent, `question` for a dialog that ended unanswered. */
@@ -35,11 +36,9 @@ const namesOf = (cell: string): string[] =>
35
36
  .map((name) => name.replaceAll('`', '').trim())
36
37
  .filter(Boolean)
37
38
 
38
- /** An empty status counts as open: a row nobody closed is not dropped in silence. */
39
- const isOpen = (status: string, language: PlanLanguage): boolean => {
40
- const lower = status.toLowerCase()
41
- return lower === '' || WAITING_VOCABULARY[language].open.some((word) => lower.startsWith(word))
42
- }
39
+ const OPEN_WORDS = Object.values(WAITING_VOCABULARY).flatMap(({ open }) => open)
40
+
41
+ const isOpen = (status: string): boolean => isOpenStatus(status, OPEN_WORDS)
43
42
 
44
43
  /**
45
44
  * The rows of the board's waiting section in `language` (`## Wartet auf dich`, `## Waiting for you`), its columns
@@ -47,14 +46,15 @@ const isOpen = (status: string, language: PlanLanguage): boolean => {
47
46
  * column, a section without a table and a row without an item give nothing.
48
47
  */
49
48
  export const readWaitingItems = (board: string, language: PlanLanguage): WaitingItem[] => {
50
- const { heading, columns, statusAlso } = WAITING_VOCABULARY[language]
49
+ const { heading } = WAITING_VOCABULARY[language]
51
50
  const { header, rows } = firstTableWithHeader(sectionLines(board, heading))
52
- const statusName = [columns.status, ...statusAlso].find((name) => header.includes(name)) ?? columns.status
53
- const positions = [columns.since, columns.item, statusName].map((name) => header.indexOf(name))
51
+ const positions = [COLUMN_KEY.since, COLUMN_KEY.item, COLUMN_KEY.status].map((key) =>
52
+ columnIndex(header, key, language),
53
+ )
54
54
  if (positions.includes(-1)) return []
55
55
  const [since, item, status] = positions
56
- const originPosition = header.indexOf(columns.origin)
57
- const unblocksPosition = header.indexOf(columns.unblocks)
56
+ const originPosition = columnIndex(header, COLUMN_KEY.origin, language)
57
+ const unblocksPosition = columnIndex(header, COLUMN_KEY.unblocks, language)
58
58
  return rows.flatMap((cells) => {
59
59
  const text = oneLine(cells[item] ?? '')
60
60
  const origin = oneLine(cells[originPosition] ?? '')
@@ -65,7 +65,7 @@ export const readWaitingItems = (board: string, language: PlanLanguage): Waiting
65
65
  {
66
66
  since: oneLine(cells[since] ?? ''),
67
67
  item: text,
68
- open: isOpen(cells[status] ?? '', language),
68
+ open: isOpen(cells[status] ?? ''),
69
69
  ...(origin === '' ? {} : { origin }),
70
70
  ...(unblocks.length === 0 ? {} : { unblocks }),
71
71
  },
@@ -1,12 +1,19 @@
1
1
  import { twoDigits } from '../plan-format/clock'
2
- import { firstTableRows, sectionLines, sectionStart } from '../plan-format/markdown'
3
- import { APPOINTMENT_VOCABULARY, PLAN_LANGUAGE, type PlanLanguage, WAITING_VOCABULARY } from '../vocabulary'
2
+ import { columnIndex, firstTableRows, sectionLines, sectionStart } from '../plan-format/markdown'
3
+ import {
4
+ APPOINTMENT_VOCABULARY,
5
+ COLUMN_KEY,
6
+ type ColumnKey,
7
+ PLAN_LANGUAGE,
8
+ type PlanLanguage,
9
+ WAITING_VOCABULARY,
10
+ } from '../vocabulary'
4
11
 
5
12
  type SectionColumns = {
6
13
  heading: string
7
- /** The header names of the two cells that identify a row, then the names that count as the status column. */
8
- keys: readonly [string, string]
9
- status: readonly string[]
14
+ /** The columns of the two cells that identify a row; the status column is found beside them. */
15
+ keys: readonly [ColumnKey, ColumnKey]
16
+ language: PlanLanguage
10
17
  }
11
18
 
12
19
  const DONE_WORD: Record<PlanLanguage, string> = { [PLAN_LANGUAGE.de]: 'erledigt', [PLAN_LANGUAGE.en]: 'done' }
@@ -17,15 +24,17 @@ export const doneStatus = (language: PlanLanguage, now: Date): string =>
17
24
 
18
25
  const squeezed = (text: string): string => text.replace(/\s+/g, ' ').trim()
19
26
 
20
- const waitingColumns = (language: PlanLanguage): SectionColumns => {
21
- const { heading, columns, statusAlso } = WAITING_VOCABULARY[language]
22
- return { heading, keys: [columns.since, columns.item], status: [columns.status, ...statusAlso] }
23
- }
27
+ const waitingColumns = (language: PlanLanguage): SectionColumns => ({
28
+ heading: WAITING_VOCABULARY[language].heading,
29
+ keys: [COLUMN_KEY.since, COLUMN_KEY.item],
30
+ language,
31
+ })
24
32
 
25
- const appointmentColumns = (language: PlanLanguage): SectionColumns => {
26
- const { heading, columns, statusAlso } = APPOINTMENT_VOCABULARY[language]
27
- return { heading, keys: [columns.when, columns.action], status: [columns.status, ...statusAlso] }
28
- }
33
+ const appointmentColumns = (language: PlanLanguage): SectionColumns => ({
34
+ heading: APPOINTMENT_VOCABULARY[language].heading,
35
+ keys: [COLUMN_KEY.when, COLUMN_KEY.action],
36
+ language,
37
+ })
29
38
 
30
39
  /** The line with the status cell (the `index`th between the bars) replaced; the other cells keep their text as written. */
31
40
  const withStatus = (line: string, index: number, status: string): string => {
@@ -42,7 +51,7 @@ const withStatus = (line: string, index: number, status: string): string => {
42
51
  */
43
52
  const closeRow = (
44
53
  board: string,
45
- { heading, keys: keyNames, status: statusNames }: SectionColumns,
54
+ { heading, keys: keyColumns, language }: SectionColumns,
46
55
  keys: readonly [string, string],
47
56
  status: string,
48
57
  ): string | null => {
@@ -55,8 +64,8 @@ const closeRow = (
55
64
  .slice(1, header.endsWith('|') ? -1 : undefined)
56
65
  .split('|')
57
66
  .map((cell) => cell.trim())
58
- const positions = keyNames.map((name) => names.indexOf(name))
59
- const statusAt = names.findIndex((name) => statusNames.includes(name))
67
+ const positions = keyColumns.map((key) => columnIndex(names, key, language))
68
+ const statusAt = columnIndex(names, COLUMN_KEY.status, language)
60
69
  if (positions.includes(-1) || statusAt === -1) return null
61
70
  const row = firstTableRows(lines).find(({ cells }) =>
62
71
  positions.every((position, index) => squeezed(cells[position] ?? '') === squeezed(keys[index])),
@@ -20,7 +20,7 @@ const changedAtOf = (root: string, settings: PlansSettings, file: string): numbe
20
20
 
21
21
  const candidateOf = (root: string, settings: PlansSettings, file: string): BoardCandidate => ({
22
22
  path: relative(root, file),
23
- lanes: parseSprintLanes(readFileSync(file, 'utf8'), settings.planLanguage).map((lane) => lane.name),
23
+ lanes: parseSprintLanes(readFileSync(file, 'utf8'), settings.boardLanguage).map((lane) => lane.name),
24
24
  changedAt: changedAtOf(root, settings, file),
25
25
  })
26
26