jackherd 0.0.1 → 1.0.1

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 +65 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +38 -0
  4. package/README.md +1122 -3
  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 +63 -5
  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,69 @@
1
+ import type { PlanLanguage } from '../vocabulary'
2
+ import { PLAN_VOCABULARY } from '../vocabulary'
3
+ import { escapeRegExp } from './markdown'
4
+
5
+ export type DriftFinding = { file: string; reason: string }
6
+
7
+ type DriftWords = {
8
+ /** `Status:` lines starting with one of these describe a plan in flight, where open items are expected. */
9
+ inFlight: RegExp
10
+ completion: RegExp
11
+ open: RegExp
12
+ architecture: RegExp
13
+ /** Level 2-4 heading words that mark the architecture documentation closing phase. */
14
+ architectureHeading: string
15
+ selfReported: (statusText: string) => string
16
+ silent: string
17
+ }
18
+
19
+ const OPEN_TABLE_CELL = /⬜/
20
+
21
+ const DRIFT_WORDS: Record<PlanLanguage, DriftWords> = {
22
+ de: {
23
+ inFlight: /^(Draft|In Progress|Proposed|Open|Planned|Review-Ready|Aktiv)\b/i,
24
+ completion: /\b(done|abgeschlossen|umgesetzt|implementiert|implemented|fertig)\b/i,
25
+ open: /\b(offen|ausstehend|deferred|zurückgestellt)\b/i,
26
+ architecture: /\b(arc42|ADR|Architekturdokumentation|Dokumentation)\b/i,
27
+ architectureHeading: 'Architekturdokumentation',
28
+ selfReported: (statusText) => `Status nennt offene Doku-Arbeit: "${statusText.trim()}"`,
29
+ silent: 'arc42-Abschlussphase hat offene ⬜-Zeilen trotz abgeschlossenem Status',
30
+ },
31
+ en: {
32
+ inFlight: /^(Draft|In Progress|Proposed|Open|Planned|Review-Ready|Active)\b/i,
33
+ completion: /\b(done|completed|implemented|finished)\b/i,
34
+ open: /\b(open|outstanding|pending|deferred)\b/i,
35
+ architecture: /\b(arc42|ADR|architecture documentation|documentation)\b/i,
36
+ architectureHeading: 'Architecture documentation',
37
+ selfReported: (statusText) => `Status names open documentation work: "${statusText.trim()}"`,
38
+ silent: 'The arc42 closing phase has open ⬜ rows despite a completed status',
39
+ },
40
+ }
41
+
42
+ // Single line on purpose: the drift check always read only the first line of the field, and the
43
+ // 270-plan parity run depends on it. `headerField` follows soft wraps and would classify some plans differently.
44
+ const statusLine = (content: string, name: string): string | undefined =>
45
+ content.match(new RegExp(`^\\*\\*${escapeRegExp(name)}:\\*\\*\\s*(.+)$`, 'm'))?.[1]
46
+
47
+ const architectureSection = (content: string, heading: string): string | undefined =>
48
+ content.match(new RegExp(`#{2,4}[^\\n]*${escapeRegExp(heading)}[^\\n]*\\n([\\s\\S]*?)(?=\\n#{1,4} |$)`))?.[1]
49
+
50
+ /**
51
+ * Plans whose own status text or closing documentation phase admits unfinished architecture work.
52
+ * Plans in flight are skipped: open items there are expected, not drift.
53
+ */
54
+ export const findDrift = (file: string, content: string, language: PlanLanguage): DriftFinding[] => {
55
+ const statusText = statusLine(content, PLAN_VOCABULARY[language].header.status)
56
+ const words = DRIFT_WORDS[language]
57
+ if (!statusText || words.inFlight.test(statusText.trim())) return []
58
+
59
+ const completed = words.completion.test(statusText)
60
+ const caveat = words.open.test(statusText)
61
+ const selfReported =
62
+ completed && caveat && words.architecture.test(statusText) ? words.selfReported(statusText) : null
63
+ const section = completed && !caveat ? architectureSection(content, words.architectureHeading) : undefined
64
+ const silent = section && OPEN_TABLE_CELL.test(section) ? words.silent : null
65
+
66
+ return [selfReported, silent]
67
+ .filter((reason): reason is string => reason !== null)
68
+ .map((reason) => ({ file, reason }))
69
+ }
@@ -0,0 +1,27 @@
1
+ import { classifyStatus, PLAN_VOCABULARY, type PlanLanguage, type PlanStatus } from '../vocabulary'
2
+ import { firstHeading, headerField } from './markdown'
3
+
4
+ export type PlanHeader = {
5
+ title: string
6
+ date: string
7
+ status: string
8
+ statusWord: PlanStatus
9
+ scope: string
10
+ }
11
+
12
+ const DECISION_ROW = /^\|\s*\*?\*?D\d+/gm
13
+
14
+ export const readPlanHeader = (markdown: string, language: PlanLanguage, fallbackTitle: string): PlanHeader => {
15
+ const { header } = PLAN_VOCABULARY[language]
16
+ const status = headerField(markdown, header.status)
17
+ return {
18
+ title: firstHeading(markdown, fallbackTitle),
19
+ date: headerField(markdown, header.date),
20
+ status,
21
+ statusWord: classifyStatus(language, status),
22
+ scope: headerField(markdown, header.scope),
23
+ }
24
+ }
25
+
26
+ /** Rows of the decision table, counted by their `D<number>` first cell. */
27
+ export const countDecisions = (markdown: string): number => (markdown.match(DECISION_ROW) ?? []).length
@@ -0,0 +1,9 @@
1
+ export * from '../vocabulary'
2
+ export * from './drift'
3
+ export * from './header'
4
+ export * from './lanes'
5
+ export * from './phase-tally'
6
+ export * from './phases'
7
+ export * from './plan-structure'
8
+ export * from './progress'
9
+ export * from './sprint-document'
@@ -0,0 +1,128 @@
1
+ import { posix } from 'node:path'
2
+ import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
3
+ import { firstHeading, firstTable, sectionLines } from './markdown'
4
+
5
+ /**
6
+ * `namedPhaseIds` holds every id the row names when `withoutRepeats` dropped some because an earlier row of the lane
7
+ * named them; `withoutRow` marks a step no board row wrote: the phases a lane worked on without a row of their own.
8
+ */
9
+ export type PlanStep = {
10
+ planTitle: string
11
+ planHref: string | null
12
+ phaseIds: string[]
13
+ label: string
14
+ startsAfter: string
15
+ model: string
16
+ namedPhaseIds?: string[]
17
+ withoutRow?: true
18
+ }
19
+
20
+ /** A lane of the sprint table; `closed` when its name cell says the lane was cleaned up. */
21
+ export type SprintLane = { name: string; port: number | null; steps: PlanStep[]; closed: boolean }
22
+
23
+ const LANE_NAME = /`([^`]+)`/
24
+ const CONTINUATION = '↳'
25
+ const PLAN_LINK = /\[([^\]]+)\]\(([^)]+)\)/
26
+ /** Sprint tables name a phase `P4`; the sidebar title prints it the same way. */
27
+ export const PHASE_ID_PREFIX = 'P'
28
+
29
+ const CLAUSE_SEPARATOR = /([;,]|\.(?=\s))\s*/
30
+ const ENTRY_END = new Set([';', '.'])
31
+
32
+ type PhaseIdPatterns = { leading: RegExp; afterConnector: RegExp; connector: RegExp }
33
+
34
+ const phaseIdPatterns = (language: PlanLanguage): PhaseIdPatterns => {
35
+ const connectors = PLAN_VOCABULARY[language].phaseConnectors.join('|')
36
+ const id = `(?:\\*\\*)?${PHASE_ID_PREFIX}(\\d+[a-z]?)\\b`
37
+ return {
38
+ leading: new RegExp(`^\\s*(?:(?:${connectors})\\s+)*${id}`),
39
+ afterConnector: new RegExp(`^\\s*(?:${connectors})\\s`),
40
+ connector: new RegExp(`\\b(?:${connectors})\\s+${id}`, 'g'),
41
+ }
42
+ }
43
+
44
+ /**
45
+ * The phase ids of a phases cell. An id counts where an entry starts: at the start of the cell or of a
46
+ * sentence, after `;`, after a connector word (`P1 Index und P2 Suche`), or after `,` while a list that began
47
+ * with an id is running (`P1 (#<number>), P2 (#<number>)`). A `P6` inside a sentence (`geht in P6 auf`, `abgeräumt,
48
+ * P6 startet neu`) is prose.
49
+ */
50
+ const phaseIdsOf = (cell: string, patterns: PhaseIdPatterns): string[] => {
51
+ const parts = cell.split(CLAUSE_SEPARATOR)
52
+ const ids: string[] = []
53
+ let listRunning = false
54
+ for (let index = 0; index < parts.length; index += 2) {
55
+ const separator = index > 0 ? parts[index - 1] : null
56
+ const clause = parts[index]
57
+ const entryStart = separator === null || ENTRY_END.has(separator)
58
+ if (entryStart) listRunning = false
59
+ const leading = clause.match(patterns.leading)
60
+ const startsEntry: boolean = entryStart || listRunning || patterns.afterConnector.test(clause)
61
+ const counts: boolean = leading !== null && startsEntry
62
+ if (leading && counts) ids.push(leading[1])
63
+ ids.push(...[...clause.matchAll(patterns.connector)].map((match) => match[1]))
64
+ listRunning ||= counts
65
+ }
66
+ return [...new Set(ids)]
67
+ }
68
+
69
+ /** The phase ids a phases cell of the lanes table names, read as `parseSprintLanes` reads them. */
70
+ export const phaseIdsOfCell = (cell: string, language: PlanLanguage): string[] =>
71
+ phaseIdsOf(cell, phaseIdPatterns(language))
72
+
73
+ export const laneNameOf = (cell: string, previous: string | null): string | null =>
74
+ cell.match(LANE_NAME)?.[1] ?? (cell.startsWith(CONTINUATION) ? previous : null)
75
+
76
+ const toStep = (
77
+ [, , plan = '', phases = '', startsAfter = '', model = '']: string[],
78
+ phaseId: PhaseIdPatterns,
79
+ ): PlanStep => {
80
+ const link = plan.match(PLAN_LINK)
81
+ return {
82
+ planTitle: link ? link[1] : plan,
83
+ planHref: link ? link[2] : null,
84
+ phaseIds: phaseIdsOf(phases, phaseId),
85
+ label: phases,
86
+ startsAfter,
87
+ model,
88
+ }
89
+ }
90
+
91
+ /** The step without the phases an earlier step of the lane already named from the same plan, which `namedPhaseIds` keeps. */
92
+ export const withoutRepeats = (lane: SprintLane, step: PlanStep): PlanStep => {
93
+ const named = new Set(
94
+ lane.steps.filter((earlier) => earlier.planHref === step.planHref).flatMap((earlier) => earlier.phaseIds),
95
+ )
96
+ const phaseIds = step.phaseIds.filter((id) => !named.has(id))
97
+ if (phaseIds.length === step.phaseIds.length) return step
98
+ return { ...step, phaseIds, namedPhaseIds: step.namedPhaseIds ?? step.phaseIds }
99
+ }
100
+
101
+ /**
102
+ * The lanes of the sprint document's lanes table (`## Spuren` in `de`) in table order. A row whose
103
+ * first cell names a lane in backticks belongs to that lane; a `↳` row without a name continues
104
+ * the lane above it.
105
+ */
106
+ export const parseSprintLanes = (markdown: string, language: PlanLanguage): SprintLane[] => {
107
+ const lanes = new Map<string, SprintLane>()
108
+ const phaseId = phaseIdPatterns(language)
109
+ const { laneClosed } = PLAN_VOCABULARY[language]
110
+ let previous: string | null = null
111
+ for (const cells of firstTable(sectionLines(markdown, PLAN_VOCABULARY[language].sections.lanes))) {
112
+ const name = laneNameOf(cells[0] ?? '', previous)
113
+ if (!name) continue
114
+ const port = Number.parseInt(cells[1] ?? '', 10)
115
+ const lane = lanes.get(name) ?? { name, port: Number.isNaN(port) ? null : port, steps: [], closed: false }
116
+ lane.closed ||= (cells[0] ?? '').includes(laneClosed)
117
+ lane.steps.push(withoutRepeats(lane, toStep(cells, phaseId)))
118
+ lanes.set(name, lane)
119
+ previous = name
120
+ }
121
+ return [...lanes.values()]
122
+ }
123
+
124
+ export const sprintTitleOf = (markdown: string, fallback: string): string => firstHeading(markdown, fallback)
125
+
126
+ /** The repository path of a plan link, relative to the sprint document's directory. */
127
+ export const resolvePlanPath = (sprintDirectory: string, href: string): string =>
128
+ posix.normalize(posix.join(sprintDirectory, href.split('#')[0]))
@@ -0,0 +1,98 @@
1
+ import { terminalSafe } from './terminal-text'
2
+
3
+ const SECTION_END = /^##\s/
4
+ const TABLE_SEPARATOR = /^\|[\s|:-]+\|$/
5
+
6
+ export const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
7
+
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*$`)
11
+ const start = markdown.split('\n').findIndex((line) => heading.test(line))
12
+ return start === -1 ? -1 : start + 1
13
+ }
14
+
15
+ /** The lines under a level-2 heading with exactly this text, up to the next level-2 heading. */
16
+ export const sectionLines = (markdown: string, headingText: string): string[] => {
17
+ 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)
22
+ }
23
+
24
+ const tableCells = (line: string): string[] =>
25
+ line
26
+ .slice(1, line.endsWith('|') ? -1 : undefined)
27
+ .split('|')
28
+ .map((cell) => terminalSafe(cell).trim())
29
+
30
+ /** A body row of a table: its index among the lines it was read from, the line as written, and its cells. */
31
+ export type TableRow = { index: number; line: string; cells: string[] }
32
+
33
+ /** Body rows of the first table among `lines` with their index, header and separator dropped, cells trimmed and terminal-safe. */
34
+ export const firstTableRows = (lines: string[]): TableRow[] => {
35
+ const start = lines.findIndex((line) => line.startsWith('|'))
36
+ if (start === -1) return []
37
+ const rest = lines.slice(start)
38
+ const end = rest.findIndex((line) => !line.startsWith('|'))
39
+ return (end === -1 ? rest : rest.slice(0, end))
40
+ .map((line, offset) => ({ index: start + offset, line, cells: tableCells(line) }))
41
+ .slice(1)
42
+ .filter(({ line }) => !TABLE_SEPARATOR.test(line))
43
+ }
44
+
45
+ /** Body rows of the first table among `lines`, header and separator dropped, cells trimmed and terminal-safe. */
46
+ export const firstTable = (lines: string[]): string[][] => firstTableRows(lines).map(({ cells }) => cells)
47
+
48
+ /** Value of a `**Name:** value` header line; continues over soft-wrapped lines until the next field or blank line. */
49
+ export const headerField = (markdown: string, name: string): string => {
50
+ const match = new RegExp(`^\\*\\*${escapeRegExp(name)}:\\*\\*\\s*(.+?)(?=\\n\\*\\*|\\n\\n)`, 'ms').exec(markdown)
51
+ return match ? match[1].replace(/\s+/g, ' ').trim() : ''
52
+ }
53
+
54
+ export const firstHeading = (markdown: string, fallback: string): string =>
55
+ terminalSafe(markdown.match(/^#\s+(.+)$/m)?.[1] ?? fallback).trim()
56
+
57
+ const HEADING = /^(#{2,4})\s+(.*?)\s*$/
58
+
59
+ /**
60
+ * The lines under the first level 2 to 4 heading whose text starts with `headingText` (a suffix such as
61
+ * `(PFLICHT ab 3 Phasen)` is allowed), up to the next heading of the same or a higher level.
62
+ */
63
+ export const subsectionLines = (markdown: string, headingText: string): string[] => {
64
+ const lines = markdown.split('\n')
65
+ const start = lines.findIndex((line) => HEADING.exec(line)?.[2].startsWith(headingText))
66
+ if (start === -1) return []
67
+ const level = HEADING.exec(lines[start])?.[1].length ?? 2
68
+ const rest = lines.slice(start + 1)
69
+ const end = rest.findIndex((line) => {
70
+ const heading = HEADING.exec(line)
71
+ return heading !== null && heading[1].length <= level
72
+ })
73
+ return end === -1 ? rest : rest.slice(0, end)
74
+ }
75
+
76
+ /** The header cells and the body rows of the first table among `lines`. */
77
+ export const firstTableWithHeader = (lines: string[]): { header: string[]; rows: string[][] } => {
78
+ const start = lines.findIndex((line) => line.startsWith('|'))
79
+ if (start === -1) return { header: [], rows: [] }
80
+ return { header: tableCells(lines[start]), rows: firstTable(lines) }
81
+ }
82
+
83
+ export type TableWithCaption = { caption: string | null; header: string[]; rows: string[][] }
84
+
85
+ /** Every table among `lines` with its header cells and body rows, and the paragraph line right above it as its caption. */
86
+ export const allTables = (lines: string[]): TableWithCaption[] => {
87
+ const tables: TableWithCaption[] = []
88
+ for (let index = 0; index < lines.length; index += 1) {
89
+ if (!lines[index].startsWith('|') || (index > 0 && lines[index - 1].startsWith('|'))) continue
90
+ const above = lines
91
+ .slice(0, index)
92
+ .reverse()
93
+ .find((line) => line.trim() !== '')
94
+ const caption = above && !above.startsWith('|') ? above.trim() : null
95
+ tables.push({ caption, ...firstTableWithHeader(lines.slice(index)) })
96
+ }
97
+ return tables
98
+ }
@@ -0,0 +1,55 @@
1
+ import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
2
+ import { escapeRegExp } from './markdown'
3
+ import { deferralTarget } from './phases'
4
+
5
+ /** Per heading that carries checkboxes or table status cells: how many are done and open. */
6
+ export type PhaseTally = { title: string; done: number; open: number; model: string | undefined }
7
+
8
+ /** A tally with the id of its phase and the targets of the checkboxes it defers. */
9
+ type Section = PhaseTally & { phaseId: string; targets: string[] }
10
+
11
+ const DONE_MARK = /^\s*[-*]\s\[x\]|\|\s*✅\s*\|/gim
12
+ const OPEN_MARK = /^\s*[-*]\s\[ \]|\|\s*⬜\s*\|/gm
13
+ const TRAILING_SEPARATOR = /[:—–-]\s*$/
14
+
15
+ const PHASE_ID = /^(\d+[a-z]?)/
16
+
17
+ const modelFor = (markdown: string, phaseWord: string, phaseTitle: string): string | undefined => {
18
+ const prefix = new RegExp(`^${escapeRegExp(phaseWord)}\\s*`, 'i')
19
+ const number = /^(\d+)/.exec(phaseTitle.replace(prefix, ''))?.[1]
20
+ if (!number) return undefined
21
+ const row = new RegExp(`^\\|\\s*${number}\\s*[—–-][^|]*\\|\\s*([^|]+?)\\s*\\|`, 'm').exec(markdown)
22
+ return row?.[1].trim()
23
+ }
24
+
25
+ const sectionOf = (section: string, heading: RegExp, markdown: string, phaseWord: string): Section | null => {
26
+ const match = heading.exec(section)
27
+ if (!match) return null
28
+ const title = match[1].replace(TRAILING_SEPARATOR, '').trim()
29
+ const phaseId = PHASE_ID.exec(title)?.[1]?.toLowerCase() ?? ''
30
+ const targets = section.split('\n').flatMap((line) => deferralTarget(line, phaseId) ?? [])
31
+ const done = (section.match(DONE_MARK) ?? []).length
32
+ const open = (section.match(OPEN_MARK) ?? []).length - targets.length
33
+ return { title, done, open, model: modelFor(markdown, phaseWord, title), phaseId, targets }
34
+ }
35
+
36
+ /**
37
+ * Every level-2 or level-3 section whose heading starts with a number (optionally after the phase
38
+ * word) and that holds at least one checkbox or ✅/⬜ table cell, with the model its recommendation
39
+ * table names for that phase. An open checkbox moved on with `→ P<n>` counts as open in the section
40
+ * of phase `<n>` instead of its own; with no such section it stays open where it is, as in `parsePlanPhases`.
41
+ */
42
+ export const tallyPhases = (markdown: string, language: PlanLanguage): PhaseTally[] => {
43
+ const phaseWord = PLAN_VOCABULARY[language].phase
44
+ const heading = new RegExp(`^###?\\s+(?:${escapeRegExp(phaseWord)}\\s*)?(\\d+[^\\n]*)`)
45
+ const sections = markdown
46
+ .split(/\n(?=###?\s)/)
47
+ .flatMap((section) => sectionOf(section, heading, markdown, phaseWord) ?? [])
48
+ const ids = new Set(sections.map(({ phaseId }) => phaseId))
49
+ return sections.flatMap(({ phaseId, targets, ...tally }) => {
50
+ const stranded = targets.filter((target) => !ids.has(target)).length
51
+ const arriving = sections.flatMap((each) => each.targets).filter((target) => target === phaseId).length
52
+ const open = tally.open + stranded + arriving
53
+ return tally.done + open === 0 ? [] : [{ ...tally, open }]
54
+ })
55
+ }
@@ -0,0 +1,224 @@
1
+ import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
2
+ import { PHASE_ID_PREFIX } from './lanes'
3
+ import { escapeRegExp } from './markdown'
4
+ import { terminalSafe } from './terminal-text'
5
+
6
+ /** A checkbox of a plan. `gate` is set on the ones listed under the phase's review gate, and only on those. */
7
+ export type ChecklistItem = { done: boolean; text: string; gate?: true }
8
+
9
+ /** An open checkbox that ends in `→ P<n>`: moved on purpose to phase `target`, its text without the marker. */
10
+ export type DeferredItem = { text: string; target: string }
11
+
12
+ /** An open checkbox whose phase arrow is not at the end of its line (1-based), with the form the parser reads. */
13
+ export type MisplacedDeferral = { line: number; target: string; corrected: string }
14
+
15
+ /**
16
+ * `touched` is set by the phase source when the worktree's ticks differ from the base branch's. `deferred` holds
17
+ * the checkboxes moved to another phase; they are not among `items`, so they count neither as open nor as done.
18
+ */
19
+ export type PlanPhase = { id: string; title: string; items: ChecklistItem[]; deferred?: DeferredItem[]; touched?: true }
20
+
21
+ const REVIEW_GATE = /^\s*\*\*review[- ]gate\b/i
22
+ const PHASE_END = /^#{1,3}\s/
23
+ const CHECKBOX = /^\s*[-*]\s+\[([ xX])\]\s+(.*)$/
24
+ const SECTION_HEADING = /^##\s+(.+?)\s*$/
25
+ const TOP_HEADING = /^#\s/
26
+ const TABLE_STATUS = { done: '✅', open: '⬜' } as const
27
+ const ROW_NUMBER = /^\d+(\.\d+)*[a-z]?$/
28
+ const NUMBERED_SECTION = /^##\s+(\d+[a-z]?)\s*[—–:-]\s*(.*)$/
29
+ const SECTION_POINTER = /`##\s+([^`]+?)\s*`/
30
+ const DEFERRED_TO = new RegExp(`\\s→\\s*${PHASE_ID_PREFIX}(\\d+[a-z]?)\\s*$`, 'i')
31
+ /** A phase arrow anywhere in a line, with the whitespace before it. */
32
+ const PHASE_ARROW = new RegExp(`\\s*→\\s*${PHASE_ID_PREFIX}(\\d+[a-z]?)\\b`, 'i')
33
+
34
+ /** The line that opens a phase's review gate: `**Review-Gate:**`, `**Review gate** (…)`. */
35
+ export const isReviewGateLine = (line: string): boolean => REVIEW_GATE.test(line)
36
+
37
+ /** A heading of level three or higher, which ends the section of a phase. */
38
+ export const endsPhase = (line: string): boolean => PHASE_END.test(line)
39
+
40
+ export const isCheckboxLine = (line: string): boolean => CHECKBOX.test(line)
41
+
42
+ export const isOpenCheckboxLine = (line: string): boolean => line.match(CHECKBOX)?.[1] === ' '
43
+
44
+ export const phaseHeadingPattern = (language: PlanLanguage): RegExp =>
45
+ new RegExp(`^###\\s+${escapeRegExp(PLAN_VOCABULARY[language].phase)}\\s+(\\d+[a-z]?)\\b\\s*[:—–-]?\\s*(.*)$`, 'i')
46
+
47
+ const tableCells = (line: string): string[] | null => {
48
+ const trimmed = line.trim()
49
+ if (!trimmed.startsWith('|')) return null
50
+ return trimmed
51
+ .replace(/^\|/, '')
52
+ .replace(/\|$/, '')
53
+ .split('|')
54
+ .map((cell) => cell.trim())
55
+ }
56
+
57
+ /** A table row with a cell that is exactly ✅ or ⬜: its other cells, minus a leading row number, are the text. */
58
+ const tableItemOf = (line: string): ChecklistItem | null => {
59
+ const cells = tableCells(line)
60
+ const statusIndex = cells?.findIndex((cell) => cell === TABLE_STATUS.done || cell === TABLE_STATUS.open) ?? -1
61
+ if (!cells || statusIndex < 0) return null
62
+ const rest = cells.filter((_, index) => index !== statusIndex)
63
+ const text = (ROW_NUMBER.test(rest[0] ?? '') ? rest.slice(1) : rest).filter((cell) => cell !== '').join(' · ')
64
+ return { done: cells[statusIndex] === TABLE_STATUS.done, text: terminalSafe(text).trim() }
65
+ }
66
+
67
+ const checkboxItemOf = (line: string): ChecklistItem | null => {
68
+ const checkbox = line.match(CHECKBOX)
69
+ return checkbox ? { done: checkbox[1] !== ' ', text: terminalSafe(checkbox[2] ?? '').trim() } : null
70
+ }
71
+
72
+ const checklistItemOf = (line: string, gate = false): ChecklistItem | null => {
73
+ const item = checkboxItemOf(line) ?? tableItemOf(line)
74
+ if (!item) return null
75
+ return gate ? { ...item, gate: true } : item
76
+ }
77
+
78
+ /** The items (checkboxes and ✅/⬜ table rows) of every `## ` section by its heading, sub-headings included. */
79
+ const sectionItems = (markdown: string): Map<string, ChecklistItem[]> => {
80
+ const sections = new Map<string, ChecklistItem[]>()
81
+ let open: ChecklistItem[] | null = null
82
+ for (const line of markdown.split('\n')) {
83
+ const heading = line.match(SECTION_HEADING)
84
+ if (heading?.[1]) {
85
+ open = []
86
+ sections.set(heading[1], open)
87
+ } else if (TOP_HEADING.test(line)) {
88
+ open = null
89
+ } else {
90
+ const item = checklistItemOf(line)
91
+ if (item) open?.push(item)
92
+ }
93
+ }
94
+ return sections
95
+ }
96
+
97
+ /**
98
+ * Gives a phase without checkboxes of its own the ones of the section it points at. Sections are
99
+ * cut like the orchestrate script does (to the next `# ` or `## ` heading, the last of equal headings
100
+ * wins), so the counts of both agree.
101
+ */
102
+ const withPointedSections = (
103
+ phases: PlanPhase[],
104
+ pointers: ReadonlyMap<PlanPhase, string>,
105
+ markdown: string,
106
+ ): PlanPhase[] => {
107
+ if (pointers.size === 0) return phases
108
+ const sections = sectionItems(markdown)
109
+ return phases.map((phase) => {
110
+ const pointer = pointers.get(phase)
111
+ return phase.items.length === 0 && pointer ? { ...phase, items: sections.get(pointer) ?? [] } : phase
112
+ })
113
+ }
114
+
115
+ /**
116
+ * The phase an open checkbox line is moved to with `→ P<n>` at its end; null for any other line, a ticked box,
117
+ * a ✅/⬜ table row, and an arrow to the box's own phase `phaseId`. The one rule the parser and the tally share.
118
+ */
119
+ export const deferralTarget = (line: string, phaseId: string): string | null => {
120
+ const checkbox = line.match(CHECKBOX)
121
+ if (checkbox?.[1] !== ' ') return null
122
+ const target = checkbox[2]?.match(DEFERRED_TO)?.[1]?.toLowerCase()
123
+ return target && target !== phaseId ? target : null
124
+ }
125
+
126
+ const misplacedDeferralOf = (line: string, index: number): MisplacedDeferral[] => {
127
+ const checkbox = line.match(CHECKBOX)
128
+ if (checkbox?.[1] !== ' ' || DEFERRED_TO.test(checkbox[2])) return []
129
+ const arrow = checkbox[2].match(PHASE_ARROW)
130
+ if (!arrow) return []
131
+ const rest = checkbox[2].replace(PHASE_ARROW, '').trim()
132
+ const marker = line.slice(0, line.length - checkbox[2].length)
133
+ return [{ line: index + 1, target: arrow[1], corrected: `${marker}${rest} → ${PHASE_ID_PREFIX}${arrow[1]}` }]
134
+ }
135
+
136
+ /**
137
+ * Open checkboxes that carry `→ P<n>` somewhere other than at the end of the line. `deferralTarget` reads the arrow
138
+ * only there, so such a box counts as open in its own phase without a word; `jh check` refuses it. In an
139
+ * open checkbox the phase arrow stands only at the end, also where it would be meant as prose (`→ P2 prüfen`).
140
+ */
141
+ export const misplacedDeferrals = (markdown: string): MisplacedDeferral[] =>
142
+ markdown.split('\n').flatMap(misplacedDeferralOf)
143
+
144
+ const deferredItemOf = (line: string, phaseId: string): DeferredItem | null => {
145
+ const target = deferralTarget(line, phaseId)
146
+ const text = line.match(CHECKBOX)?.[2] ?? ''
147
+ return target ? { text: terminalSafe(text.replace(DEFERRED_TO, '')).trim(), target } : null
148
+ }
149
+
150
+ /** A deferred checkbox as its target phase lists it: an open, plain task with its origin. */
151
+ const arrivedItem = ({ text }: DeferredItem, origin: PlanPhase): ChecklistItem => ({
152
+ done: false,
153
+ text: `${text} ← ${PHASE_ID_PREFIX}${origin.id}`,
154
+ })
155
+
156
+ /**
157
+ * Appends every deferred checkbox to its target phase as an open task. One whose target the plan lacks goes back
158
+ * among its own phase's items, open and with its marker, so a mistyped target never drops a task from the count.
159
+ */
160
+ const withArrivedItems = (phases: PlanPhase[]): PlanPhase[] => {
161
+ const ids = new Set(phases.map(({ id }) => id))
162
+ const moved = phases.flatMap((origin) => (origin.deferred ?? []).map((item) => ({ origin, item })))
163
+ if (moved.length === 0) return phases
164
+ return phases.map((phase) => {
165
+ const arriving = moved
166
+ .filter(({ item }) => item.target === phase.id)
167
+ .map(({ item, origin }) => arrivedItem(item, origin))
168
+ const stranded = (phase.deferred ?? []).filter(({ target }) => !ids.has(target))
169
+ const deferred = (phase.deferred ?? []).filter(({ target }) => ids.has(target))
170
+ const returned = stranded.map(({ text, target }) => ({
171
+ done: false,
172
+ text: `${text} → ${PHASE_ID_PREFIX}${target}`,
173
+ }))
174
+ const { deferred: _, ...rest } = phase
175
+ return { ...rest, items: [...phase.items, ...returned, ...arriving], ...(deferred.length > 0 ? { deferred } : {}) }
176
+ })
177
+ }
178
+
179
+ /** Files one line of an open phase: a deferred checkbox under `deferred`, any other checkbox or table row under `items`. */
180
+ const fileLine = (phase: PlanPhase, line: string, gate: boolean): void => {
181
+ const deferred = deferredItemOf(line, phase.id)
182
+ if (deferred) {
183
+ phase.deferred = [...(phase.deferred ?? []), deferred]
184
+ return
185
+ }
186
+ const item = checklistItemOf(line, gate)
187
+ if (item) phase.items.push(item)
188
+ }
189
+
190
+ const notePointer = (pointers: Map<PlanPhase, string>, phase: PlanPhase | null, line: string): void => {
191
+ const pointer = phase && !pointers.has(phase) ? line.match(SECTION_POINTER)?.[1] : undefined
192
+ if (phase && pointer) pointers.set(phase, pointer)
193
+ }
194
+
195
+ const headingOf = (line: string, phaseHeading: RegExp): RegExpMatchArray | null =>
196
+ line.match(phaseHeading) ?? line.match(NUMBERED_SECTION)
197
+
198
+ /**
199
+ * Every `### Phase <id>` section, and every `## <id> — title` section, with its checkboxes. A
200
+ * section runs to the next heading of level three or higher, so a review-gate list or a `####`
201
+ * sub-heading inside the phase still counts. A phase without checkboxes of its own that points at
202
+ * a section (``Siehe `## Dokumentation` ``) counts that section's items, so it is done when the
203
+ * section is. An open checkbox ending in `→ P<n>` is kept under `deferred` and listed in phase `<n>` as open.
204
+ */
205
+ export const parsePlanPhases = (markdown: string, language: PlanLanguage): PlanPhase[] => {
206
+ const phaseHeading = phaseHeadingPattern(language)
207
+ const pointers = new Map<PlanPhase, string>()
208
+ const { phases } = markdown.split('\n').reduce<{ phases: PlanPhase[]; open: PlanPhase | null; gate: boolean }>(
209
+ ({ phases, open, gate }, line) => {
210
+ const heading = headingOf(line, phaseHeading)
211
+ if (heading) {
212
+ const phase = { id: (heading[1] ?? '').toLowerCase(), title: terminalSafe(heading[2] ?? '').trim(), items: [] }
213
+ return { phases: [...phases, phase], open: phase, gate: false }
214
+ }
215
+ if (PHASE_END.test(line)) return { phases, open: null, gate: false }
216
+ const inGate = gate || REVIEW_GATE.test(line)
217
+ if (open) fileLine(open, line, inGate)
218
+ notePointer(pointers, open, line)
219
+ return { phases, open, gate: inGate }
220
+ },
221
+ { phases: [], open: null, gate: false },
222
+ )
223
+ return withArrivedItems(withPointedSections(phases, pointers, markdown))
224
+ }