jackherd 0.0.0-stage → 1.0.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 (250) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +38 -0
  4. package/README.md +1123 -2
  5. package/assets/branding/README.md +62 -0
  6. package/assets/branding/jackherd-icon-256.png +0 -0
  7. package/assets/branding/jackherd-icon-512.png +0 -0
  8. package/assets/branding/jackherd-icon-64.png +0 -0
  9. package/assets/branding/jackherd-icon-small-32.png +0 -0
  10. package/assets/branding/jackherd-icon-small-64.png +0 -0
  11. package/assets/branding/jackherd-icon-small.svg +1 -0
  12. package/assets/branding/jackherd-icon.svg +1 -0
  13. package/assets/branding/jackherd-logo-1024.png +0 -0
  14. package/assets/branding/jackherd-logo-512.png +0 -0
  15. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  16. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  17. package/assets/branding/jackherd-logo-dark.svg +1 -0
  18. package/assets/branding/jackherd-logo-mark-1024.png +0 -0
  19. package/assets/branding/jackherd-logo-mark-512.png +0 -0
  20. package/assets/branding/jackherd-logo-mark.svg +1 -0
  21. package/assets/branding/jackherd-logo.svg +1 -0
  22. package/dist/chunk-R6MGHCD7.js +93 -0
  23. package/dist/config.d.ts +167 -0
  24. package/dist/config.js +401 -0
  25. package/dist/plan-format.d.ts +325 -0
  26. package/dist/plan-format.js +636 -0
  27. package/docs/lanes.mmd +9 -0
  28. package/docs/lanes.svg +1 -0
  29. package/docs/skills.mmd +10 -0
  30. package/docs/skills.svg +1 -0
  31. package/docs/workflow.svg +172 -0
  32. package/herdr-plugin/herdr-plugin.toml +22 -0
  33. package/herdr-plugin/open-plans.ts +43 -0
  34. package/herdr-plugin/restore-plans.ts +107 -0
  35. package/install.sh +157 -0
  36. package/kit/hooks/hooks/git-guard/decision.ts +438 -0
  37. package/kit/hooks/hooks/git-guard/index.ts +38 -0
  38. package/kit/hooks/hooks/git-guard/shell.ts +407 -0
  39. package/kit/hooks/hooks/watch-guard/background.ts +36 -0
  40. package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
  41. package/kit/hooks/hooks/watch-guard/index.ts +11 -0
  42. package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
  43. package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
  44. package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
  45. package/kit/hooks/hooks.json +30 -0
  46. package/kit/hooks/statusline.sh +72 -0
  47. package/kit/review/agents/devops.md +11 -0
  48. package/kit/review/agents/public-content.md +39 -0
  49. package/kit/review/agents/security-audit.md +11 -0
  50. package/kit/review/agents/uncle-bob.md +11 -0
  51. package/kit/review/skills/devops/SKILL.md +50 -0
  52. package/kit/review/skills/diagnose/SKILL.md +140 -0
  53. package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
  54. package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
  55. package/kit/review/skills/pre-commit/SKILL.md +100 -0
  56. package/kit/review/skills/review/SKILL.md +43 -0
  57. package/kit/review/skills/security-audit/SKILL.md +65 -0
  58. package/kit/review/skills/uncle-bob/SKILL.md +43 -0
  59. package/kit/rules/rules/kit/arc42.md +89 -0
  60. package/kit/rules/rules/kit/architecture-files.md +87 -0
  61. package/kit/rules/rules/kit/code-style.md +159 -0
  62. package/kit/rules/rules/kit/pull-requests.md +59 -0
  63. package/kit/rules/rules/kit/testing.md +91 -0
  64. package/kit/rules/rules/kit/writing-for-agents.md +110 -0
  65. package/kit/workflow/agents/explore.md +25 -0
  66. package/kit/workflow/agents/implement.md +16 -0
  67. package/kit/workflow/agents/plan.md +13 -0
  68. package/kit/workflow/agents/spec-review.md +96 -0
  69. package/kit/workflow/skills/follow/SKILL.md +48 -0
  70. package/kit/workflow/skills/grill/SKILL.md +60 -0
  71. package/kit/workflow/skills/implement/SKILL.md +107 -0
  72. package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
  73. package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
  74. package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
  75. package/kit/workflow/skills/plan/SKILL.md +118 -0
  76. package/kit/workflow/skills/plan/references/template-English.md +92 -0
  77. package/kit/workflow/skills/plan/references/template-German.md +92 -0
  78. package/kit/workflow/skills/start/SKILL.md +47 -0
  79. package/package.json +65 -4
  80. package/schemas/jackherd.schema.json +601 -0
  81. package/src/check/changes.ts +45 -0
  82. package/src/check/command-line.ts +12 -0
  83. package/src/check/command.ts +136 -0
  84. package/src/check/deferral-arrows.ts +41 -0
  85. package/src/check/execute.ts +29 -0
  86. package/src/check/glob.ts +15 -0
  87. package/src/check/plan-structure-check.ts +135 -0
  88. package/src/check/plan.ts +106 -0
  89. package/src/check/public-content-check.ts +32 -0
  90. package/src/check/public-content-facts.ts +177 -0
  91. package/src/check/public-content.ts +216 -0
  92. package/src/check/report.ts +55 -0
  93. package/src/check/result.ts +42 -0
  94. package/src/check/roots.ts +36 -0
  95. package/src/check/routing.ts +22 -0
  96. package/src/check/schema.ts +83 -0
  97. package/src/check/settings.ts +44 -0
  98. package/src/claude/paths.ts +14 -0
  99. package/src/cli.ts +46 -0
  100. package/src/config/command.ts +45 -0
  101. package/src/config/detect.ts +196 -0
  102. package/src/config/index.ts +2 -0
  103. package/src/config/json-schema.ts +5 -0
  104. package/src/config/legacy-names.ts +56 -0
  105. package/src/config/link-kinds.ts +25 -0
  106. package/src/config/location.ts +46 -0
  107. package/src/config/read-config.ts +42 -0
  108. package/src/config/repository-facts.ts +19 -0
  109. package/src/config/repository-values.ts +46 -0
  110. package/src/config/schema.ts +302 -0
  111. package/src/context/command.ts +39 -0
  112. package/src/context/coordinator-name.ts +16 -0
  113. package/src/context/fill.ts +69 -0
  114. package/src/context/index.ts +10 -0
  115. package/src/context/level.ts +22 -0
  116. package/src/context/options.ts +29 -0
  117. package/src/context/report.ts +42 -0
  118. package/src/context/session-log.ts +91 -0
  119. package/src/plan-format/drift.ts +69 -0
  120. package/src/plan-format/header.ts +27 -0
  121. package/src/plan-format/index.ts +9 -0
  122. package/src/plan-format/lanes.ts +128 -0
  123. package/src/plan-format/markdown.ts +98 -0
  124. package/src/plan-format/phase-tally.ts +55 -0
  125. package/src/plan-format/phases.ts +224 -0
  126. package/src/plan-format/plan-structure.ts +150 -0
  127. package/src/plan-format/progress.ts +202 -0
  128. package/src/plan-format/sprint-document.ts +69 -0
  129. package/src/plan-format/terminal-text.ts +28 -0
  130. package/src/plans/board-choice.ts +60 -0
  131. package/src/plans/board-pick.ts +72 -0
  132. package/src/plans/board-rows.ts +62 -0
  133. package/src/plans/command.ts +60 -0
  134. package/src/plans/ensure-panes.ts +71 -0
  135. package/src/plans/error-text.ts +5 -0
  136. package/src/plans/fetch.ts +96 -0
  137. package/src/plans/git.ts +145 -0
  138. package/src/plans/github.ts +35 -0
  139. package/src/plans/lane-discovery.ts +34 -0
  140. package/src/plans/lane-plans.ts +80 -0
  141. package/src/plans/layout.ts +881 -0
  142. package/src/plans/link-chips.ts +74 -0
  143. package/src/plans/live.ts +336 -0
  144. package/src/plans/markdown-html.ts +140 -0
  145. package/src/plans/options.ts +15 -0
  146. package/src/plans/phase-source.ts +71 -0
  147. package/src/plans/plan-chips.ts +75 -0
  148. package/src/plans/plan-panes.ts +206 -0
  149. package/src/plans/planned.ts +117 -0
  150. package/src/plans/pull-requests.ts +107 -0
  151. package/src/plans/restart-decision.ts +42 -0
  152. package/src/plans/selection.ts +352 -0
  153. package/src/plans/settings.ts +68 -0
  154. package/src/plans/single-flight.ts +25 -0
  155. package/src/plans/snapshot.ts +300 -0
  156. package/src/plans/sprint-export.ts +103 -0
  157. package/src/plans/sprint-html.ts +301 -0
  158. package/src/plans/sprint-page.ts +287 -0
  159. package/src/plans/supervisor.ts +124 -0
  160. package/src/plans/symbols.ts +54 -0
  161. package/src/plans/texts.ts +103 -0
  162. package/src/plans/unlisted-lanes.ts +45 -0
  163. package/src/plans/view.ts +68 -0
  164. package/src/plans/worked-phases.ts +53 -0
  165. package/src/sync/checksum.ts +13 -0
  166. package/src/sync/choice.ts +14 -0
  167. package/src/sync/command-context.ts +113 -0
  168. package/src/sync/command.ts +94 -0
  169. package/src/sync/error-message.ts +1 -0
  170. package/src/sync/gitignore.ts +45 -0
  171. package/src/sync/global-kit.ts +176 -0
  172. package/src/sync/herdr-plugin.ts +175 -0
  173. package/src/sync/if-changed.ts +109 -0
  174. package/src/sync/init-command.ts +239 -0
  175. package/src/sync/init.ts +136 -0
  176. package/src/sync/kit-hooks.ts +18 -0
  177. package/src/sync/kit-path.ts +77 -0
  178. package/src/sync/kit-source.ts +169 -0
  179. package/src/sync/kit.ts +9 -0
  180. package/src/sync/legacy-files.ts +116 -0
  181. package/src/sync/manifest.ts +44 -0
  182. package/src/sync/next-steps.ts +42 -0
  183. package/src/sync/plan-file.ts +154 -0
  184. package/src/sync/plan-hooks.ts +165 -0
  185. package/src/sync/project-fs.ts +132 -0
  186. package/src/sync/render.ts +60 -0
  187. package/src/sync/report.ts +96 -0
  188. package/src/sync/settings-hooks.ts +90 -0
  189. package/src/sync/settings-migration.ts +94 -0
  190. package/src/sync/settings-offer.ts +168 -0
  191. package/src/sync/setup-questions.ts +70 -0
  192. package/src/sync/setup.ts +52 -0
  193. package/src/sync/sync-plan.ts +120 -0
  194. package/src/sync/sync-result.ts +9 -0
  195. package/src/sync/sync.ts +307 -0
  196. package/src/sync/tools.ts +52 -0
  197. package/src/terminal/herdr.ts +91 -0
  198. package/src/terminal/index.ts +13 -0
  199. package/src/terminal/none.ts +7 -0
  200. package/src/terminal/open.ts +39 -0
  201. package/src/terminal/text.ts +159 -0
  202. package/src/terminal/types.ts +30 -0
  203. package/src/terminal/width.ts +90 -0
  204. package/src/update/command.ts +65 -0
  205. package/src/update/package-manager.ts +56 -0
  206. package/src/update/update.ts +105 -0
  207. package/src/version/installed-version.ts +32 -0
  208. package/src/version/release.ts +22 -0
  209. package/src/version/skill-diff.ts +63 -0
  210. package/src/version/started-kit.ts +14 -0
  211. package/src/vocabulary/index.ts +132 -0
  212. package/src/watch/base-tip.ts +18 -0
  213. package/src/watch/command.ts +919 -0
  214. package/src/watch/coordinator-fill.ts +76 -0
  215. package/src/watch/feedback-read.ts +73 -0
  216. package/src/watch/feedback.ts +308 -0
  217. package/src/watch/gh.ts +22 -0
  218. package/src/watch/github-read.ts +175 -0
  219. package/src/watch/herdr.ts +11 -0
  220. package/src/watch/json-guards.ts +9 -0
  221. package/src/watch/kit-update.ts +18 -0
  222. package/src/watch/lane-fill.ts +36 -0
  223. package/src/watch/lane-message.ts +99 -0
  224. package/src/watch/lane-progress.ts +44 -0
  225. package/src/watch/lane-report-file.ts +53 -0
  226. package/src/watch/lane-report.ts +270 -0
  227. package/src/watch/lane-search.ts +41 -0
  228. package/src/watch/lane-sources.ts +46 -0
  229. package/src/watch/lane-watch.ts +121 -0
  230. package/src/watch/monitor-tool.ts +19 -0
  231. package/src/watch/options.ts +79 -0
  232. package/src/watch/plan-badges-read.ts +30 -0
  233. package/src/watch/plan-badges.ts +66 -0
  234. package/src/watch/plan-views.ts +59 -0
  235. package/src/watch/preview-read.ts +60 -0
  236. package/src/watch/previews.ts +234 -0
  237. package/src/watch/pull-requests.ts +117 -0
  238. package/src/watch/single-report.ts +15 -0
  239. package/src/watch/skill-change.ts +61 -0
  240. package/src/watch/source-dry-run-command.ts +35 -0
  241. package/src/watch/source-dry-run.ts +124 -0
  242. package/src/watch/source-fields.ts +9 -0
  243. package/src/watch/source-path.ts +40 -0
  244. package/src/watch/source-run.ts +90 -0
  245. package/src/watch/sources.ts +344 -0
  246. package/src/watch/stream-output.ts +14 -0
  247. package/src/watch/texts.ts +76 -0
  248. package/src/watch/watch-events.ts +399 -0
  249. package/src/watch/watcher-lock.ts +145 -0
  250. package/src/watch/workflow-runs.ts +96 -0
@@ -0,0 +1,150 @@
1
+ import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
2
+ import { laneNameOf, parseSprintLanes, phaseIdsOfCell } from './lanes'
3
+ import { escapeRegExp, firstTableRows, sectionLines, sectionStart } from './markdown'
4
+ import {
5
+ deferralTarget,
6
+ endsPhase,
7
+ isCheckboxLine,
8
+ isOpenCheckboxLine,
9
+ isReviewGateLine,
10
+ type PlanPhase,
11
+ parsePlanPhases,
12
+ phaseHeadingPattern,
13
+ } from './phases'
14
+ import { terminalSafe } from './terminal-text'
15
+
16
+ /** A `### Phase` heading (1-based line) whose section has tasks and neither a review gate nor the line saying why not. */
17
+ export type PhaseWithoutGate = { line: number; id: string; title: string }
18
+
19
+ /** A row of the lanes table (1-based line) whose phases cell names no phase id. */
20
+ export type RowWithoutPhase = { line: number; lane: string; phases: string }
21
+
22
+ /**
23
+ * A `### Phase` heading (1-based line) whose tasks are all ticked on the base branch while its own section still
24
+ * has open checkboxes not moved on with `→ P<n>` (`openLines`, 1-based).
25
+ */
26
+ export type MergedPhaseWithOpenBoxes = PhaseWithoutGate & { openLines: number[] }
27
+
28
+ type PhaseSection = PhaseWithoutGate & { tasks: boolean; gate: boolean; openLines: number[] }
29
+
30
+ const noGatePattern = (language: PlanLanguage): RegExp =>
31
+ new RegExp(`^\\s*\\*\\*${escapeRegExp(PLAN_VOCABULARY[language].noReviewGate)}\\b`, 'i')
32
+
33
+ const tickCount = (phase: PlanPhase): number => phase.items.filter((item) => item.done).length
34
+
35
+ /** The phase's tasks before its review gate are all ticked: its work is on the branch the plan was read from. */
36
+ const tasksDone = (phase: PlanPhase): boolean => {
37
+ const tasks = phase.items.filter((item) => !item.gate)
38
+ return tasks.length > 0 && tasks.every((item) => item.done)
39
+ }
40
+
41
+ /** Every `### Phase` section with whether it has tasks and whether it has a gate or says why it has none. */
42
+ const phaseSections = (markdown: string, language: PlanLanguage): PhaseSection[] => {
43
+ const heading = phaseHeadingPattern(language)
44
+ const noGate = noGatePattern(language)
45
+ const sections: PhaseSection[] = []
46
+ let open: PhaseSection | null = null
47
+ for (const [index, line] of markdown.split('\n').entries()) {
48
+ const match = line.match(heading)
49
+ if (match) {
50
+ open = {
51
+ line: index + 1,
52
+ id: match[1].toLowerCase(),
53
+ title: terminalSafe(match[2]).trim(),
54
+ tasks: false,
55
+ gate: false,
56
+ openLines: [],
57
+ }
58
+ sections.push(open)
59
+ } else if (endsPhase(line)) {
60
+ open = null
61
+ } else if (open) {
62
+ open.tasks ||= isCheckboxLine(line)
63
+ open.gate ||= isReviewGateLine(line) || noGate.test(line)
64
+ if (isOpenCheckboxLine(line) && deferralTarget(line, open.id) === null) open.openLines.push(index + 1)
65
+ }
66
+ }
67
+ return sections
68
+ }
69
+
70
+ /** The ids of the `### Phase` headings of a plan. */
71
+ export const phaseIdsOfPlan = (markdown: string, language: PlanLanguage): Set<string> =>
72
+ new Set(phaseSections(markdown, language).map(({ id }) => id))
73
+
74
+ /**
75
+ * The phases a plan gained against `previousIds` (its phase ids before the change) that have checkboxes but
76
+ * neither a review gate nor a `**Kein Review-Gate:** <reason>` line. A phase without checkboxes of its own (one
77
+ * that points at `## Dokumentation`) has nothing to gate; phases the plan already had are not read, so an old
78
+ * plan does not turn red when it is touched.
79
+ */
80
+ export const newPhasesWithoutReviewGate = (
81
+ markdown: string,
82
+ language: PlanLanguage,
83
+ previousIds: ReadonlySet<string>,
84
+ ): PhaseWithoutGate[] =>
85
+ phaseSections(markdown, language)
86
+ .filter(({ id, tasks, gate }) => tasks && !gate && !previousIds.has(id))
87
+ .map(({ line, id, title }) => ({ line, id, title }))
88
+
89
+ /** Whether the document has a lanes table (`## Spuren`): a board, not a plan. */
90
+ export const hasLanesTable = (markdown: string, language: PlanLanguage): boolean =>
91
+ sectionLines(markdown, PLAN_VOCABULARY[language].sections.lanes).some((line) => line.startsWith('|'))
92
+
93
+ /**
94
+ * The phases of `markdown` that are merged but not closed: on the base branch (`base`, the plan there) every task
95
+ * before the review gate is ticked, the change does not tick the phase further, and its own section still has open
96
+ * checkboxes, gate boxes included, that no `→ P<n>` at their end moves on. A phase the base branch lacks, or one
97
+ * with an open task there, is still being worked on.
98
+ */
99
+ export const mergedPhasesWithOpenBoxes = (
100
+ markdown: string,
101
+ base: string,
102
+ language: PlanLanguage,
103
+ ): MergedPhaseWithOpenBoxes[] => {
104
+ const baseById = new Map(parsePlanPhases(base, language).map((phase) => [phase.id, phase]))
105
+ const currentById = new Map(parsePlanPhases(markdown, language).map((phase) => [phase.id, phase]))
106
+ const isMerged = (id: string): boolean => {
107
+ const [basePhase, currentPhase] = [baseById.get(id), currentById.get(id)]
108
+ return (
109
+ basePhase !== undefined &&
110
+ currentPhase !== undefined &&
111
+ tasksDone(basePhase) &&
112
+ tickCount(currentPhase) <= tickCount(basePhase)
113
+ )
114
+ }
115
+ return phaseSections(markdown, language)
116
+ .filter(({ id, openLines }) => openLines.length > 0 && isMerged(id))
117
+ .map(({ line, id, title, openLines }) => ({ line, id, title, openLines }))
118
+ }
119
+
120
+ /**
121
+ * The rows of the lanes table (`## Spuren`) that are not in `previousLines` word for word and whose phases cell
122
+ * names no phase id (`P10c`), so neither the view nor the watcher can tie that step to a phase. The table is read
123
+ * as `parseSprintLanes` reads it (its first table, a lane closed when any of its rows says so); rows of a closed
124
+ * lane are history and stay out.
125
+ */
126
+ export const boardRowsWithoutPhase = (
127
+ markdown: string,
128
+ language: PlanLanguage,
129
+ previousLines: ReadonlySet<string>,
130
+ ): RowWithoutPhase[] => {
131
+ const start = sectionStart(markdown, PLAN_VOCABULARY[language].sections.lanes)
132
+ if (start === -1) return []
133
+ const closed = new Set(
134
+ parseSprintLanes(markdown, language)
135
+ .filter((lane) => lane.closed)
136
+ .map((lane) => lane.name),
137
+ )
138
+ const rows: RowWithoutPhase[] = []
139
+ let previous: string | null = null
140
+ for (const { index, line, cells } of firstTableRows(
141
+ sectionLines(markdown, PLAN_VOCABULARY[language].sections.lanes),
142
+ )) {
143
+ const [name = '', , , phases = ''] = cells
144
+ const lane = laneNameOf(name, previous)
145
+ previous = lane ?? previous
146
+ const counts = lane !== null && !closed.has(lane) && !previousLines.has(line)
147
+ if (counts && phaseIdsOfCell(phases, language).length === 0) rows.push({ line: start + index + 1, lane, phases })
148
+ }
149
+ return rows
150
+ }
@@ -0,0 +1,202 @@
1
+ import { PHASE_ID_PREFIX, type PlanStep, type SprintLane } from './lanes'
2
+ import type { ChecklistItem, DeferredItem, PlanPhase } from './phases'
3
+
4
+ /**
5
+ * `title` is null when the sprint names a phase the plan does not have; `touched` and `deferred` carry the plan
6
+ * phase's own, and the deferred checkboxes are in neither `done` nor `total`.
7
+ */
8
+ export type PhaseProgress = {
9
+ id: string
10
+ title: string | null
11
+ done: number
12
+ total: number
13
+ items: ChecklistItem[]
14
+ deferred?: DeferredItem[]
15
+ touched?: true
16
+ }
17
+
18
+ export type StepProgress = { step: PlanStep; phases: PhaseProgress[] }
19
+
20
+ export type LaneProgress = {
21
+ lane: SprintLane
22
+ steps: StepProgress[]
23
+ current: { stepIndex: number; phaseId: string } | null
24
+ done: number
25
+ total: number
26
+ }
27
+
28
+ /** Parsed phases per plan link, as the sprint table spells the link; a missing key is an unread plan. */
29
+ export type PlansByHref = Record<string, PlanPhase[]>
30
+
31
+ export const sumPhases = (phases: readonly PhaseProgress[]): { done: number; total: number } => ({
32
+ done: phases.reduce((sum, phase) => sum + phase.done, 0),
33
+ total: phases.reduce((sum, phase) => sum + phase.total, 0),
34
+ })
35
+
36
+ const baseId = (id: string): string => id.replace(/[a-z]$/, '')
37
+
38
+ const findPhase = (phases: PlanPhase[], id: string): PlanPhase | undefined =>
39
+ phases.find((phase) => phase.id === id) ?? phases.find((phase) => phase.id === baseId(id))
40
+
41
+ /**
42
+ * The ids of a step that show a phase of their own. An id the plan lacks (`5b`) falls back to its
43
+ * base phase (`5`); when the step already names that phase by its own id, or an earlier id fell
44
+ * back to it, the fallback would list the same tasks twice, so it is dropped. A lone fallback
45
+ * stays: the sprint line `P4a` finds phase 4 in the plans that have no `4a`.
46
+ */
47
+ export const keptPhaseIds = (phases: PlanPhase[], ids: readonly string[]): string[] => {
48
+ const shown = new Set(ids.flatMap((id) => phases.filter((phase) => phase.id === id)))
49
+ return ids.filter((id) => {
50
+ const phase = findPhase(phases, id)
51
+ if (!phase || phase.id === id) return true
52
+ if (shown.has(phase)) return false
53
+ shown.add(phase)
54
+ return true
55
+ })
56
+ }
57
+
58
+ const phaseProgress = (phases: PlanPhase[], id: string): PhaseProgress => {
59
+ const phase = findPhase(phases, id)
60
+ const items = phase?.items ?? []
61
+ return {
62
+ id,
63
+ title: phase?.title ?? null,
64
+ done: items.filter((item) => item.done).length,
65
+ total: items.length,
66
+ items,
67
+ ...(phase?.deferred ? { deferred: phase.deferred } : {}),
68
+ ...(phase?.touched ? { touched: true as const } : {}),
69
+ }
70
+ }
71
+
72
+ const isOpen = ({ done, total }: PhaseProgress): boolean => done < total
73
+
74
+ /** A phase the plan has written: one with at least one checkbox, not one missing from the plan or without any. */
75
+ const isWritten = ({ total }: PhaseProgress): boolean => total > 0
76
+
77
+ const newestWrittenStepIndex = (steps: readonly StepProgress[]): number =>
78
+ steps.reduce((last, { phases }, index) => (phases.some(isWritten) ? index : last), -1)
79
+
80
+ const newestRowIndex = (steps: readonly StepProgress[]): number =>
81
+ steps.reduce((last, { step }, index) => (step.withoutRow ? last : index), -1)
82
+
83
+ const isTouched = (phase: PhaseProgress): boolean => phase.touched === true && isWritten(phase)
84
+
85
+ /**
86
+ * A phase the lane ticked to the end in its worktree while the base branch does not carry those ticks yet: still
87
+ * the lane's work until it is merged, not a finished phase.
88
+ */
89
+ export const isTickedUnmerged = (phase: PhaseProgress): boolean => isTouched(phase) && !isOpen(phase)
90
+
91
+ /** The first phase `pick` finds in a step, from the newest step back. */
92
+ const newestStepWith = (
93
+ steps: readonly StepProgress[],
94
+ pick: (phases: readonly PhaseProgress[]) => PhaseProgress | undefined,
95
+ ): LaneProgress['current'] => {
96
+ for (let stepIndex = steps.length - 1; stepIndex >= 0; stepIndex--) {
97
+ const phase = pick(steps[stepIndex].phases)
98
+ if (phase) return { stepIndex, phaseId: phase.id }
99
+ }
100
+ return null
101
+ }
102
+
103
+ type LocatedPhase = { phase: PhaseProgress; current: NonNullable<LaneProgress['current']> }
104
+
105
+ /** The phase a step shows for `id`: by that id, else by its base id, as `keptPhaseIds` keeps a lone fallback. */
106
+ const shownPhase = (phases: readonly PhaseProgress[], id: string): PhaseProgress | undefined =>
107
+ phases.find((phase) => phase.id === id) ?? phases.find((phase) => phase.id === baseId(id))
108
+
109
+ /** The phase `id` as the newest step before `before` that shows it from the same plan has it. */
110
+ const earlierPhase = (steps: readonly StepProgress[], before: number, id: string): LocatedPhase | null => {
111
+ const planHref = steps[before].step.planHref
112
+ for (let stepIndex = before - 1; stepIndex >= 0; stepIndex--) {
113
+ if (steps[stepIndex].step.planHref !== planHref) continue
114
+ const phase = shownPhase(steps[stepIndex].phases, id)
115
+ if (phase) return { phase, current: { stepIndex, phaseId: phase.id } }
116
+ }
117
+ return null
118
+ }
119
+
120
+ /**
121
+ * The phases a board row names, in its order, each located in the step that shows it: its own for a phase it names
122
+ * first, an earlier step's for one it repeats (`namedPhaseIds`, every id the row names once some were repeats).
123
+ */
124
+ const rowPhases = (steps: readonly StepProgress[], stepIndex: number): LocatedPhase[] => {
125
+ const row = steps[stepIndex]
126
+ if (!row) return []
127
+ const own = row.phases.map((phase) => ({ phase, current: { stepIndex, phaseId: phase.id } }))
128
+ const named = row.step.namedPhaseIds
129
+ if (!named) return own
130
+ return named.flatMap((id) => {
131
+ const located = own.find((entry) => entry.phase.id === id) ?? earlierPhase(steps, stepIndex, id)
132
+ return located ? [located] : []
133
+ })
134
+ }
135
+
136
+ const touchedOpenPhaseOf = (phases: readonly PhaseProgress[]): PhaseProgress | undefined =>
137
+ phases.find((phase) => isTouched(phase) && isOpen(phase))
138
+
139
+ const lastTickedUnmergedOf = (phases: readonly PhaseProgress[]): PhaseProgress | undefined =>
140
+ phases.filter(isTickedUnmerged).at(-1)
141
+
142
+ /**
143
+ * The phase the lane's newest board row names, a step `withWorkedPhases` added not counted: an open one the lane
144
+ * ticks in its worktree, else the last one it ticked to the end without a merge, else the first open one. Null when
145
+ * that row names no written phase or every phase it names is merged, i.e. done and carried by the base branch.
146
+ * A phase the row repeats from an earlier row counts too (`rowPhases`).
147
+ */
148
+ const newestRowPhase = (steps: readonly StepProgress[]): LaneProgress['current'] => {
149
+ const located = rowPhases(steps, newestRowIndex(steps)).filter(({ phase }) => isWritten(phase))
150
+ const phases = located.map(({ phase }) => phase)
151
+ const phase = touchedOpenPhaseOf(phases) ?? lastTickedUnmergedOf(phases) ?? phases.find(isOpen)
152
+ return located.find((entry) => entry.phase === phase)?.current ?? null
153
+ }
154
+
155
+ /**
156
+ * The phase from the ticks alone, when the newest board row decides nothing: among the phases whose ticks differ in
157
+ * the worktree from the base branch, an open one from the newest step (the first within it), else one ticked to the
158
+ * end from the newest step (the last within it); without such a phase, the open phase of the lane's newest step with
159
+ * a written phase (`isWritten`); else none. Open items left in an earlier step, once the newest one is done, stay
160
+ * visible but are not what the lane works on (D81).
161
+ */
162
+ const phaseFromTicks = (steps: readonly StepProgress[]): LaneProgress['current'] => {
163
+ const touched = newestStepWith(steps, touchedOpenPhaseOf) ?? newestStepWith(steps, lastTickedUnmergedOf)
164
+ if (touched) return touched
165
+ const lastIndex = newestWrittenStepIndex(steps)
166
+ const phase = steps[lastIndex]?.phases.find(isOpen)
167
+ return phase ? { stepIndex: lastIndex, phaseId: phase.id } : null
168
+ }
169
+
170
+ /**
171
+ * The phase a lane works on: the one its newest board row names (`newestRowPhase`), so ticks the lane set in older
172
+ * phases while tidying the plan do not take over; when that row decides nothing, the ticks do (`phaseFromTicks`).
173
+ * A phase ticked to the end stays current until the base branch carries its ticks.
174
+ */
175
+ export const currentPhaseOf = (steps: readonly StepProgress[]): LaneProgress['current'] =>
176
+ newestRowPhase(steps) ?? phaseFromTicks(steps)
177
+
178
+ /** Progress of a lane over the phases its steps name; `currentPhaseOf` picks the current phase. */
179
+ export const laneProgress = (lane: SprintLane, plans: PlansByHref): LaneProgress => {
180
+ const steps = lane.steps.map((step) => {
181
+ const phases = step.planHref ? plans[step.planHref] : undefined
182
+ return { step, phases: phases ? keptPhaseIds(phases, step.phaseIds).map((id) => phaseProgress(phases, id)) : [] }
183
+ })
184
+ return { lane, steps, current: currentPhaseOf(steps), ...sumPhases(steps.flatMap((step) => step.phases)) }
185
+ }
186
+
187
+ export const currentPhase = (progress: LaneProgress): PhaseProgress | null => {
188
+ if (!progress.current) return null
189
+ const { stepIndex, phaseId } = progress.current
190
+ return progress.steps[stepIndex].phases.find((phase) => phase.id === phaseId) ?? null
191
+ }
192
+
193
+ /** The item being worked on: the first open checkbox of a phase in plan order, a review-gate item included; null when none is open. */
194
+ export const activeItem = (phase: Pick<PhaseProgress, 'items'>): ChecklistItem | null =>
195
+ phase.items.find((item) => !item.done) ?? null
196
+
197
+ /** The sidebar title of a lane, e.g. `P4 · 7/12` or `P4 · 12/12` while ticked and not merged; null when nothing of the lane could be counted. */
198
+ export const shortTitle = (progress: LaneProgress): string | null => {
199
+ const phase = currentPhase(progress)
200
+ if (phase) return `${PHASE_ID_PREFIX}${phase.id} · ${phase.done}/${phase.total}`
201
+ return progress.total > 0 ? `✓ ${progress.done}/${progress.total}` : null
202
+ }
@@ -0,0 +1,69 @@
1
+ import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
2
+ import { allTables, firstTableWithHeader, sectionLines, subsectionLines } from './markdown'
3
+
4
+ const PHASE_ID_IN_CELL = /^(\d+[a-z]?)\b/
5
+
6
+ /** What a sprint document says around its lanes table: its goal and its history. */
7
+ export type SprintPlanRow = { cells: string[]; title: string | null; href: string | null }
8
+
9
+ /** One table of the plans section: the line above it (`**Folgepläne …:**`), its header and its rows. */
10
+ export type SprintPlanTable = { caption: string | null; header: string[]; rows: SprintPlanRow[] }
11
+
12
+ export type SprintDocument = {
13
+ /** The tables of the plans section (core plans, follow-up plans), each row with the plan it names. */
14
+ planTables: SprintPlanTable[]
15
+ /** The paragraphs of the goal section, or of the text before the first section when there is none. */
16
+ goal: string[]
17
+ history: { header: string[]; rows: string[][] }
18
+ }
19
+
20
+ const paragraphs = (lines: string[]): string[] =>
21
+ lines
22
+ .join('\n')
23
+ .split(/\n\s*\n/)
24
+ .map((paragraph) => paragraph.trim())
25
+ .filter(Boolean)
26
+
27
+ const PLAN_LINK = /\[([^\]]+)\]\(([^)\s]+)\)/
28
+
29
+ const planRowOf = (cells: string[]): SprintPlanRow => {
30
+ const link = cells.map((cell) => PLAN_LINK.exec(cell)).find((match) => match !== null)
31
+ return { cells, title: link?.[1] ?? null, href: link?.[2] ?? null }
32
+ }
33
+
34
+ const textBeforeFirstSection = (markdown: string): string[] => {
35
+ const lines = markdown.split('\n')
36
+ const end = lines.findIndex((line) => /^##\s/.test(line))
37
+ const intro = (end === -1 ? lines : lines.slice(0, end)).filter((line) => !/^#\s/.test(line))
38
+ return paragraphs(intro).filter((paragraph) => !/^\*\*[^*]+:\*\*/.test(paragraph))
39
+ }
40
+
41
+ export const readSprintDocument = (markdown: string, language: PlanLanguage): SprintDocument => {
42
+ const { goal, history, plans } = PLAN_VOCABULARY[language].sections
43
+ const goalParagraphs = paragraphs(sectionLines(markdown, goal))
44
+ return {
45
+ planTables: allTables(sectionLines(markdown, plans)).map(({ caption, header, rows }) => ({
46
+ caption,
47
+ header,
48
+ rows: rows.map(planRowOf),
49
+ })),
50
+ goal: goalParagraphs.length > 0 ? goalParagraphs : textBeforeFirstSection(markdown),
51
+ history: firstTableWithHeader(sectionLines(markdown, history)),
52
+ }
53
+ }
54
+
55
+ /**
56
+ * The `Depends on` cell of each phase from the plan's dependency table, by phase id (`4`, `4b`). The
57
+ * first cell of a row starts with the id (`3 — Sync-Engine`); a plan without the table gives an empty map.
58
+ */
59
+ export const readPhaseDependencies = (planMarkdown: string, language: PlanLanguage): Record<string, string> => {
60
+ const { sections, columns } = PLAN_VOCABULARY[language]
61
+ const { header, rows } = firstTableWithHeader(subsectionLines(planMarkdown, sections.dependencies))
62
+ const column = header.indexOf(columns.dependsOn)
63
+ if (column === -1) return {}
64
+ const entries = rows.flatMap((cells) => {
65
+ const id = PHASE_ID_IN_CELL.exec(cells[0] ?? '')?.[1]
66
+ return id ? [[id, cells[column] ?? ''] as const] : []
67
+ })
68
+ return Object.fromEntries(entries)
69
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * C0 and C1 control characters (ESC, BEL, CR, the 8-bit CSI U+009B …), the bidi marks that reorder text on screen
3
+ * and the Unicode line and paragraph separators, which a reader that splits on them would take as a new line. A
4
+ * terminal executes the first instead of printing them.
5
+ */
6
+ const UNSAFE_RANGES: [number, number][] = [
7
+ [0x00, 0x1f],
8
+ [0x7f, 0x9f],
9
+ [0x061c, 0x061c],
10
+ [0x200e, 0x200f],
11
+ [0x2028, 0x2029],
12
+ [0x202a, 0x202e],
13
+ [0x2066, 0x2069],
14
+ ]
15
+
16
+ const escapeCodePoint = (codePoint: number): string => `\\u${codePoint.toString(16).padStart(4, '0')}`
17
+
18
+ const UNSAFE_TERMINAL_TEXT = new RegExp(
19
+ `[${UNSAFE_RANGES.map(([from, to]) => `${escapeCodePoint(from)}-${escapeCodePoint(to)}`).join('')}]`,
20
+ 'g',
21
+ )
22
+
23
+ /**
24
+ * Text from outside the program (a plan file, a pull request title) made safe to print: every control
25
+ * character is dropped, so an embedded escape sequence shows as harmless leftovers instead of acting
26
+ * on the terminal. Apply it where such text enters, before any styling is added.
27
+ */
28
+ export const terminalSafe = (text: string): string => text.replace(UNSAFE_TERMINAL_TEXT, '')
@@ -0,0 +1,60 @@
1
+ /** A board file that came into question: the lane names its table lists and when it last changed (epoch seconds). */
2
+ export type BoardCandidate = { path: string; lanes: readonly string[]; changedAt: number }
3
+
4
+ export type BoardReason =
5
+ | { kind: 'only' }
6
+ | { kind: 'lanes'; count: number }
7
+ | { kind: 'tie'; count: number }
8
+ | { kind: 'newest' }
9
+
10
+ export type BoardChoice = { chosen: BoardCandidate; others: BoardCandidate[]; reason: BoardReason }
11
+
12
+ const foundIn = (candidate: BoardCandidate, found: ReadonlySet<string>): number =>
13
+ candidate.lanes.filter((lane) => found.has(lane)).length
14
+
15
+ const reasonOf = (hits: number, nextHits: number): BoardReason => {
16
+ if (hits === 0) return { kind: 'newest' }
17
+ return hits === nextHits ? { kind: 'tie', count: hits } : { kind: 'lanes', count: hits }
18
+ }
19
+
20
+ /**
21
+ * The board to read when none is named: the one whose table names the most of the lanes that were found (a
22
+ * worktree with a herdr agent), then the most recently changed, then the path that sorts last (the rule before
23
+ * boards were compared), so every run of the same state chooses the same board. A board that lists lanes nobody runs does not outrank one that
24
+ * lists the running ones.
25
+ */
26
+ export const chooseBoard = (
27
+ candidates: readonly BoardCandidate[],
28
+ foundLanes: readonly string[],
29
+ ): BoardChoice | null => {
30
+ const found = new Set(foundLanes)
31
+ const ranked = candidates
32
+ .map((candidate) => ({ candidate, hits: foundIn(candidate, found) }))
33
+ .sort(
34
+ (left, right) =>
35
+ right.hits - left.hits ||
36
+ right.candidate.changedAt - left.candidate.changedAt ||
37
+ right.candidate.path.localeCompare(left.candidate.path),
38
+ )
39
+ const [first, ...rest] = ranked
40
+ if (!first) return null
41
+ const others = rest.map(({ candidate }) => candidate)
42
+ if (rest.length === 0) return { chosen: first.candidate, others, reason: { kind: 'only' } }
43
+ const reason: BoardReason = reasonOf(first.hits, rest[0].hits)
44
+ return { chosen: first.candidate, others, reason }
45
+ }
46
+
47
+ const reasonText = (reason: BoardReason, term: string): string => {
48
+ if (reason.kind === 'lanes') return `names ${reason.count} found ${reason.count === 1 ? 'lane' : 'lanes'}`
49
+ if (reason.kind === 'tie')
50
+ return `tied on ${reason.count} found ${reason.count === 1 ? 'lane' : 'lanes'}, newest taken`
51
+ if (reason.kind === 'newest') return `no found lane in any ${term}, newest taken`
52
+ return ''
53
+ }
54
+
55
+ /** Why this board and not another, with the way out; null when there was nothing to choose between. */
56
+ export const boardNotice = (choice: BoardChoice, term: string): string | null => {
57
+ if (choice.others.length === 0) return null
58
+ const others = choice.others.map((other) => other.path).join(', ')
59
+ return `${choice.chosen.path} chosen of ${choice.others.length + 1}: ${reasonText(choice.reason, term)}; others: ${others}; override with --board <path>`
60
+ }
@@ -0,0 +1,72 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs'
2
+ import { join, relative, resolve } from 'node:path'
3
+ import { parseSprintLanes } from '../plan-format'
4
+ import { type BoardCandidate, boardNotice, chooseBoard } from './board-choice'
5
+ import { lastCommitTime, worktreeNames } from './git'
6
+ import { discoverLanes } from './lane-discovery'
7
+ import { baseRefOf, boardGlobOf, boardRepoPathOf, type PlansSettings } from './settings'
8
+ import { boardTexts } from './texts'
9
+
10
+ /** The board file that was chosen, and the line that says why when there was a choice. */
11
+ export type PickedBoard = { file: string; notice: string | null }
12
+
13
+ const boardFilesIn = (root: string, glob: string): string[] =>
14
+ existsSync(root) ? [...new Bun.Glob(glob).scanSync({ cwd: root })].sort().map((file) => join(root, file)) : []
15
+
16
+ /** Seconds since the epoch of the last commit on the base ref that touched the board; the file's mtime when none did. */
17
+ const changedAtOf = (root: string, settings: PlansSettings, file: string): number =>
18
+ lastCommitTime(root, baseRefOf(settings), boardRepoPathOf(root, settings, file)) ??
19
+ Math.floor(statSync(file).mtimeMs / 1000)
20
+
21
+ const candidateOf = (root: string, settings: PlansSettings, file: string): BoardCandidate => ({
22
+ path: relative(root, file),
23
+ lanes: parseSprintLanes(readFileSync(file, 'utf8'), settings.planLanguage).map((lane) => lane.name),
24
+ changedAt: changedAtOf(root, settings, file),
25
+ })
26
+
27
+ /**
28
+ * The board under the plan directory, by `board.pattern` and `board.file`, in the first of `roots` that has
29
+ * one: the main checkout first, then the checkout the command runs in, whose board may not be on the base yet.
30
+ * Of several boards the one that names the `foundLanes` wins, see `chooseBoard`.
31
+ */
32
+ export const pickBoard = (
33
+ roots: readonly string[],
34
+ settings: PlansSettings,
35
+ foundLanes: readonly string[],
36
+ ): PickedBoard => {
37
+ const glob = boardGlobOf(settings)
38
+ const found = roots.map((root) => ({ root, files: boardFilesIn(root, glob) })).find(({ files }) => files.length > 0)
39
+ const choice =
40
+ found &&
41
+ chooseBoard(
42
+ found.files.map((file) => candidateOf(found.root, settings, file)),
43
+ foundLanes,
44
+ )
45
+ if (!found || !choice) throw new Error(boardTexts(settings.board.term).missing(glob))
46
+ return { file: join(found.root, choice.chosen.path), notice: boardNotice(choice, settings.board.term) }
47
+ }
48
+
49
+ /**
50
+ * The worktrees under the project that carry an agent of their name: the lanes that exist before any board names
51
+ * them. None when anything on the way fails (the terminal cannot be asked, the directory cannot be read): the
52
+ * choice of a board must not fail over the search for lanes.
53
+ */
54
+ export const foundLanesOf = (root: string, settings: PlansSettings, agentNames: () => string[]): string[] => {
55
+ try {
56
+ return discoverLanes({
57
+ worktrees: worktreeNames(join(root, settings.worktreeDirectory)),
58
+ agentNames: agentNames(),
59
+ sprintLanes: [],
60
+ })
61
+ } catch {
62
+ return []
63
+ }
64
+ }
65
+
66
+ /** The board `--board` names, else the one `pickBoard` chooses; the lanes are looked up only in that case. */
67
+ export const boardFor = (
68
+ boardPath: string | null,
69
+ roots: readonly string[],
70
+ settings: PlansSettings,
71
+ foundLanes: () => string[],
72
+ ): PickedBoard => (boardPath ? { file: resolve(boardPath), notice: null } : pickBoard(roots, settings, foundLanes()))
@@ -0,0 +1,62 @@
1
+ import { type PlanStep, parseSprintLanes, type SprintLane, withoutRepeats } from '../plan-format'
2
+ import type { PlanLanguage } from '../vocabulary'
3
+
4
+ /**
5
+ * The board's lanes as the base ref has them, plus the rows only the checkout's board file has; `rowsFromCheckout`
6
+ * counts those rows.
7
+ */
8
+ export type BoardRows = { lanes: SprintLane[]; rowsFromCheckout: number }
9
+
10
+ const repeatsLabel = (lane: SprintLane, step: PlanStep): boolean =>
11
+ lane.steps.some((known) => known.planHref === step.planHref && known.label === step.label)
12
+
13
+ /** A row without phase ids of its own still counts when it has no plan link or repeats phases an earlier row named. */
14
+ const countsWithoutIds = (lane: SprintLane, step: PlanStep): boolean =>
15
+ (step.planHref === null || step.namedPhaseIds !== undefined) && !repeatsLabel(lane, step)
16
+
17
+ /**
18
+ * The step as far as the lane does not have it yet; null when nothing of it is new. A step with a plan link that
19
+ * names no phase carries nothing to show; one the parser emptied as a repeat stays, since its phases tell which
20
+ * phase the lane's newest row names. A step that names only phases the base rows name is dropped: it is mostly a base
21
+ * row the checkout reworded (`PR offen` to `gemergt`), and appending it would make an old row the newest.
22
+ */
23
+ const newPartOf = (lane: SprintLane, step: PlanStep): PlanStep | null => {
24
+ if (step.phaseIds.length === 0) return countsWithoutIds(lane, step) ? step : null
25
+ const rest = withoutRepeats(lane, step)
26
+ return rest.phaseIds.length > 0 ? rest : null
27
+ }
28
+
29
+ /** The lane with the checkout's steps it does not have yet; the same object when nothing is new. */
30
+ const grownBy = (known: SprintLane, checkout: SprintLane | undefined): SprintLane => {
31
+ const grown: SprintLane = { ...known, steps: [...known.steps] }
32
+ for (const step of checkout?.steps ?? []) {
33
+ const part = newPartOf(grown, step)
34
+ if (part !== null) grown.steps.push(part)
35
+ }
36
+ return grown.steps.length > known.steps.length ? grown : known
37
+ }
38
+
39
+ /**
40
+ * The board at the base ref with the rows only the checkout has. Base lanes keep their order and their rows;
41
+ * a lane only the checkout names is appended; a step of a known lane is added when the base does not name its
42
+ * phases for that plan yet (identity: plan link and phase id), a row without a plan link when its label is new.
43
+ * Both sides come parsed, this function reads no text.
44
+ */
45
+ export const withCheckoutRows = (base: readonly SprintLane[], checkout: readonly SprintLane[]): BoardRows => {
46
+ const checkoutByName = new Map(checkout.map((lane) => [lane.name, lane]))
47
+ const knownNames = new Set(base.map((lane) => lane.name))
48
+ const grown = base.map((lane) => grownBy(lane, checkoutByName.get(lane.name)))
49
+ const lanes = [...grown, ...checkout.filter((lane) => !knownNames.has(lane.name))]
50
+ const rowsFromCheckout = lanes.reduce(
51
+ (count, lane, index) => count + lane.steps.length - (base[index]?.steps.length ?? 0),
52
+ 0,
53
+ )
54
+ return { lanes, rowsFromCheckout }
55
+ }
56
+
57
+ /** The board of `baseText` with the rows of `checkoutText` it lacks; a checkout with the same text adds nothing. */
58
+ export const boardRowsOf = (baseText: string, checkoutText: string, language: PlanLanguage): BoardRows => {
59
+ const lanes = parseSprintLanes(baseText, language)
60
+ if (checkoutText === baseText) return { lanes, rowsFromCheckout: 0 }
61
+ return withCheckoutRows(lanes, parseSprintLanes(checkoutText, language))
62
+ }