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.
- package/CHANGELOG.md +9 -0
- package/INSTALL.md +1 -1
- package/README.md +22 -22
- package/dist/{chunk-JDO6O3UO.js → chunk-O365KHHP.js} +139 -3
- package/dist/config.d.ts +4 -0
- package/dist/config.js +4 -1
- package/dist/plan-format.d.ts +121 -3
- package/dist/plan-format.js +176 -35
- package/docs/plan-contract.md +52 -5
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/index.ts +2 -2
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +1 -1
- package/kit/hooks/hooks/open-question/record.ts +81 -1
- package/kit/hooks/hooks/open-question/run.ts +17 -3
- package/kit/hooks/hooks/question-session-end/index.ts +18 -0
- package/kit/hooks/hooks/question-session-end/run.ts +37 -0
- package/kit/hooks/hooks/sent-file/record.ts +4 -1
- package/kit/hooks/hooks/shared/board-lanes.ts +33 -12
- package/kit/hooks/hooks/shared/boards.ts +22 -6
- package/kit/hooks/hooks.json +8 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +3 -3
- package/kit/workflow/skills/orchestrate/references/waiting.md +15 -1
- package/kit/workflow/skills/plan/SKILL.md +3 -1
- package/kit/workflow/skills/plan/references/template-English.md +2 -0
- package/kit/workflow/skills/plan/references/template-German.md +2 -0
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +7 -0
- package/src/brief/command.ts +1 -1
- package/src/brief/lane-facts.ts +2 -2
- package/src/check/command.ts +2 -1
- package/src/check/plan-structure-check.ts +68 -14
- package/src/check/settings.ts +6 -1
- package/src/check/skill-budget-defaults.ts +9 -4
- package/src/config/schema.ts +3 -0
- package/src/lane/close-board.ts +4 -4
- package/src/lane/close-command.ts +2 -2
- package/src/lane/dev-command.ts +1 -1
- package/src/lane/port-command.ts +1 -1
- package/src/plan-contract/command.ts +2 -1
- package/src/plan-contract/contract.ts +30 -9
- package/src/plan-format/bare-codes.ts +4 -4
- package/src/plan-format/dependencies.ts +5 -5
- package/src/plan-format/lanes.ts +3 -4
- package/src/plan-format/markdown.ts +125 -10
- package/src/plan-format/open-status.ts +15 -0
- package/src/plan-format/phases.ts +45 -4
- package/src/plan-format/plan-head.ts +31 -9
- package/src/plan-format/plan-structure.ts +43 -7
- package/src/plan-format/progress.ts +3 -0
- package/src/plan-format/sprint-document.ts +4 -4
- package/src/plan-format/unrecognized-sections.ts +21 -0
- package/src/plan-format/waiting.ts +13 -13
- package/src/plans/board-close.ts +25 -16
- package/src/plans/board-pick.ts +1 -1
- package/src/plans/closable-records.ts +11 -10
- package/src/plans/close-reason.ts +19 -0
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/layout-summary.ts +8 -1
- package/src/plans/layout.ts +14 -5
- package/src/plans/live.ts +3 -2
- package/src/plans/question-records.ts +23 -2
- package/src/plans/questions-read.ts +2 -2
- package/src/plans/sent-files-read.ts +2 -2
- package/src/plans/sent-files.ts +12 -4
- package/src/plans/settings.ts +3 -0
- package/src/plans/snapshot.ts +9 -5
- package/src/plans/sprint-export.ts +1 -1
- package/src/plans/texts.ts +3 -0
- package/src/plans/view.ts +1 -0
- package/src/resume/coordinator-read.ts +2 -2
- package/src/settled/command.ts +1 -1
- package/src/settled/settle.ts +5 -5
- package/src/sync/render.ts +20 -5
- package/src/sync/sync-plan.ts +6 -2
- package/src/sync/sync.ts +3 -2
- package/src/vocabulary/index.ts +192 -4
- package/src/waiting/command.ts +82 -26
- package/src/watch/appointments.ts +13 -20
- package/src/watch/command.ts +100 -6
- package/src/watch/lane-progress.ts +1 -1
- package/src/watch/lane-report.ts +4 -0
- package/src/watch/merge-overlap-read.ts +2 -2
- package/src/watch/obsolete-proposals.ts +175 -0
- package/src/watch/obsolete-records.ts +77 -0
- package/src/watch/plan-missing-read.ts +3 -1
- package/src/watch/reference-paths.ts +2 -0
- 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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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 {
|
|
2
|
-
|
|
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
|
|
45
|
-
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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) ||
|
|
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
|
|
60
|
+
const { sections } = PLAN_VOCABULARY[language]
|
|
61
61
|
const { header, rows } = firstTableWithHeader(subsectionLines(planMarkdown, sections.dependencies))
|
|
62
|
-
const column = header.
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
49
|
+
const { heading } = WAITING_VOCABULARY[language]
|
|
51
50
|
const { header, rows } = firstTableWithHeader(sectionLines(board, heading))
|
|
52
|
-
const
|
|
53
|
-
|
|
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.
|
|
57
|
-
const unblocksPosition = header.
|
|
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] ?? ''
|
|
68
|
+
open: isOpen(cells[status] ?? ''),
|
|
69
69
|
...(origin === '' ? {} : { origin }),
|
|
70
70
|
...(unblocks.length === 0 ? {} : { unblocks }),
|
|
71
71
|
},
|
package/src/plans/board-close.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { twoDigits } from '../plan-format/clock'
|
|
2
|
-
import { firstTableRows, sectionLines, sectionStart } from '../plan-format/markdown'
|
|
3
|
-
import {
|
|
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
|
|
8
|
-
keys: readonly [
|
|
9
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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:
|
|
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 =
|
|
59
|
-
const statusAt = names.
|
|
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])),
|
package/src/plans/board-pick.ts
CHANGED
|
@@ -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.
|
|
23
|
+
lanes: parseSprintLanes(readFileSync(file, 'utf8'), settings.boardLanguage).map((lane) => lane.name),
|
|
24
24
|
changedAt: changedAtOf(root, settings, file),
|
|
25
25
|
})
|
|
26
26
|
|