jackherd 1.6.0 → 1.7.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 (152) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/INSTALL.md +176 -0
  3. package/README.md +260 -315
  4. package/assets/branding/README.md +7 -3
  5. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  6. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  7. package/assets/branding/jackherd-logo-dark.svg +1 -1
  8. package/assets/icons/bark-dark.svg +1 -0
  9. package/assets/icons/bark.svg +1 -0
  10. package/assets/icons/bowl-dark.svg +1 -0
  11. package/assets/icons/bowl.svg +1 -0
  12. package/assets/icons/decide-dark.svg +1 -0
  13. package/assets/icons/decide.svg +1 -0
  14. package/assets/icons/ear-dark.svg +1 -0
  15. package/assets/icons/ear.svg +1 -0
  16. package/assets/icons/lanes-dark.svg +1 -0
  17. package/assets/icons/lanes.svg +1 -0
  18. package/assets/icons/plan-dark.svg +1 -0
  19. package/assets/icons/plan.svg +1 -0
  20. package/assets/icons/space.svg +1 -0
  21. package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
  22. package/dist/config.d.ts +14 -3
  23. package/dist/config.js +111 -10
  24. package/dist/plan-format.d.ts +180 -77
  25. package/dist/plan-format.js +292 -93
  26. package/docs/lanes-dark.svg +50 -0
  27. package/docs/lanes.svg +50 -1
  28. package/docs/skills-dark.svg +65 -0
  29. package/docs/skills.svg +65 -1
  30. package/docs/workflow-dark.svg +138 -0
  31. package/docs/workflow.svg +135 -169
  32. package/herdr-plugin/herdr-plugin.toml +6 -6
  33. package/herdr-plugin/open-plans.ts +1 -1
  34. package/herdr-plugin/restore-plans.ts +7 -7
  35. package/install.sh +1 -1
  36. package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
  37. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
  38. package/kit/hooks/hooks/approval-gate/index.ts +69 -0
  39. package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
  40. package/kit/hooks/hooks/approval-gate/request.ts +111 -0
  41. package/kit/hooks/hooks/approval-gate/run.ts +94 -0
  42. package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
  43. package/kit/hooks/hooks/approval-gate/store.ts +70 -0
  44. package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
  45. package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
  46. package/kit/hooks/hooks/merge-guard/run.ts +2 -1
  47. package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
  48. package/kit/hooks/hooks/model-gate/index.ts +58 -0
  49. package/kit/hooks/hooks/model-gate/run.ts +63 -0
  50. package/kit/hooks/hooks/model-log/index.ts +60 -0
  51. package/kit/hooks/hooks/model-log/run.ts +57 -0
  52. package/kit/hooks/hooks/model-log/store.ts +40 -0
  53. package/kit/hooks/hooks/model-log/transcript.ts +37 -0
  54. package/kit/hooks/hooks/shared/checkout.ts +22 -0
  55. package/kit/hooks/hooks/shared/command.ts +6 -0
  56. package/kit/hooks/hooks/shared/config.ts +21 -0
  57. package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
  58. package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
  59. package/kit/hooks/hooks/shared/files.ts +19 -0
  60. package/kit/hooks/hooks/shared/fresh.ts +4 -0
  61. package/kit/hooks/hooks/shared/json.ts +4 -0
  62. package/kit/hooks/hooks/shared/model-family.ts +48 -0
  63. package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
  64. package/kit/hooks/hooks/shared/private-directory.ts +29 -0
  65. package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
  66. package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
  67. package/kit/hooks/hooks.json +25 -0
  68. package/kit/workflow/skills/follow/SKILL.md +4 -3
  69. package/kit/workflow/skills/implement/SKILL.md +6 -6
  70. package/kit/workflow/skills/orchestrate/SKILL.md +113 -30
  71. package/kit/workflow/skills/plan/SKILL.md +5 -5
  72. package/kit/workflow/skills/start/SKILL.md +2 -2
  73. package/package.json +4 -1
  74. package/schemas/jackherd.schema.json +43 -5
  75. package/src/check/command.ts +31 -4
  76. package/src/check/deferral-arrows.ts +1 -1
  77. package/src/check/parallel-report.ts +23 -0
  78. package/src/check/plan-structure-check.ts +120 -53
  79. package/src/cli.ts +8 -2
  80. package/src/config/nested-quantifier.ts +73 -0
  81. package/src/config/repository-directory.ts +11 -0
  82. package/src/config/schema.ts +63 -8
  83. package/src/plan-format/board-prose.ts +36 -0
  84. package/src/plan-format/dependencies.ts +227 -0
  85. package/src/plan-format/documentation-gate.ts +16 -0
  86. package/src/plan-format/index.ts +23 -1
  87. package/src/plan-format/lanes.ts +35 -1
  88. package/src/plan-format/model-column.ts +52 -0
  89. package/src/plan-format/phases.ts +16 -1
  90. package/src/plan-format/plan-structure.ts +10 -28
  91. package/src/plan-format/progress.ts +7 -29
  92. package/src/plans/command.ts +3 -3
  93. package/src/plans/current-phase.ts +71 -0
  94. package/src/plans/ensure-panes.ts +2 -2
  95. package/src/plans/herd-command.ts +4 -0
  96. package/src/plans/lane-discovery.ts +14 -2
  97. package/src/plans/lane-tickets.ts +69 -0
  98. package/src/plans/layout.ts +182 -88
  99. package/src/plans/live.ts +5 -1
  100. package/src/plans/phase-models-read.ts +37 -0
  101. package/src/plans/phase-models.ts +70 -0
  102. package/src/plans/plan-panes.ts +8 -7
  103. package/src/plans/pull-requests.ts +78 -4
  104. package/src/plans/selection.ts +1 -40
  105. package/src/plans/settings.ts +9 -3
  106. package/src/plans/shown-with-more.ts +5 -0
  107. package/src/plans/snapshot.ts +82 -8
  108. package/src/plans/sprint-html.ts +2 -2
  109. package/src/plans/supervisor.ts +5 -4
  110. package/src/plans/symbols.ts +9 -6
  111. package/src/plans/texts.ts +30 -13
  112. package/src/plans/view.ts +1 -0
  113. package/src/sync/herdr-plugin.ts +1 -1
  114. package/src/sync/next-steps.ts +1 -1
  115. package/src/sync/render.ts +2 -1
  116. package/src/sync/setup-questions.ts +2 -2
  117. package/src/terminal/text.ts +4 -0
  118. package/src/terminal/types.ts +1 -1
  119. package/src/vocabulary/index.ts +39 -0
  120. package/src/watch/approvals.ts +96 -0
  121. package/src/watch/command.ts +154 -50
  122. package/src/watch/failed-tests.ts +52 -0
  123. package/src/watch/failure-read.ts +56 -0
  124. package/src/watch/failures.ts +107 -0
  125. package/src/watch/feedback-item.ts +21 -0
  126. package/src/watch/feedback-read.ts +2 -1
  127. package/src/watch/feedback.ts +37 -28
  128. package/src/watch/gh-process.ts +53 -0
  129. package/src/watch/gh.ts +10 -20
  130. package/src/watch/heartbeat-progress.ts +1 -1
  131. package/src/watch/lane-model.ts +54 -0
  132. package/src/watch/lane-parallel.ts +46 -0
  133. package/src/watch/lane-progress.ts +28 -4
  134. package/src/watch/lane-report.ts +62 -9
  135. package/src/watch/lane-sources.ts +1 -1
  136. package/src/watch/no-finding.ts +47 -0
  137. package/src/watch/plan-badges-read.ts +19 -7
  138. package/src/watch/plan-badges.ts +59 -7
  139. package/src/watch/plan-views.ts +5 -5
  140. package/src/watch/previews.ts +3 -9
  141. package/src/watch/pull-requests.ts +12 -2
  142. package/src/watch/recorded-reads.ts +25 -0
  143. package/src/watch/source-fields.ts +4 -0
  144. package/src/watch/source-tickets.ts +99 -0
  145. package/src/watch/sources.ts +62 -15
  146. package/src/watch/texts.ts +21 -1
  147. package/src/watch/watch-defaults.ts +8 -0
  148. package/src/watch/watch-events.ts +64 -4
  149. package/src/watch/watcher-lock.ts +1 -1
  150. package/src/watch/workflow-runs.ts +5 -7
  151. package/docs/lanes.mmd +0 -9
  152. package/docs/skills.mmd +0 -10
@@ -0,0 +1,227 @@
1
+ import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
2
+ import { PHASE_ID_PREFIX } from './lanes'
3
+ import { escapeRegExp, firstTableWithHeader, subsectionLines } from './markdown'
4
+ import { type PhaseHeading, parsePlanPhases, phaseHeadings } from './phases'
5
+
6
+ /**
7
+ * A row of the dependency table. `after` holds the phases it waits for, `external` the entries that name no phase
8
+ * (`User: Zuruf`) or a range that cannot be expanded; `writes` are the paths of the writes cell (`''` is the whole
9
+ * repository), `writesLike` the phases whose sets the cell takes over (`wie 15 und 16`); `serialMarkers` are the
10
+ * entries of the marker cell that keep two phases apart.
11
+ */
12
+ export type PhaseDependency = {
13
+ id: string
14
+ after: string[]
15
+ external: string[]
16
+ writes: string[]
17
+ writesLike: string[]
18
+ serialMarkers: string[]
19
+ }
20
+
21
+ /** A phase heading (1-based line) the dependency table has no row for. */
22
+ export type PhaseWithoutRow = PhaseHeading
23
+
24
+ type Candidate = { id: string; writes: string[]; serialMarkers: string[] }
25
+
26
+ const ID = '(\\d+[a-z]?)'
27
+ const PHASE_ID = new RegExp(`^\\s*${PHASE_ID_PREFIX}?${ID}\\b`, 'i')
28
+ const PHASE_RANGE = new RegExp(`^${PHASE_ID_PREFIX}?(\\d+)\\s*[–-]\\s*${PHASE_ID_PREFIX}?(\\d+)$`, 'i')
29
+ const BACKTICKED = /`([^`]+)`/g
30
+ const NO_VALUE = /^[—–-]?$/
31
+ const TRAILING_NOTE = /\s*\([^)]*\)\s*$/
32
+ const GLOB_CHARACTER = /[*?{[]/
33
+ const MAX_RANGE = 50
34
+
35
+ const phaseIdOf = (cell: string): string | null => PHASE_ID.exec(cell)?.[1]?.toLowerCase() ?? null
36
+
37
+ const splitOutsideParentheses = (cell: string, separator: string): string[] => {
38
+ const entries: string[] = []
39
+ let depth = 0
40
+ let start = 0
41
+ for (const [index, character] of [...cell].entries()) {
42
+ if (character === '(') depth += 1
43
+ else if (character === ')') depth = Math.max(depth - 1, 0)
44
+ else if (character === separator && depth === 0) {
45
+ entries.push(cell.slice(start, index))
46
+ start = index + 1
47
+ }
48
+ }
49
+ return [...entries, cell.slice(start)]
50
+ }
51
+
52
+ /** The ids a range entry (`P15–P20`) spans; null when the entry is no range, empty when it cannot be expanded. */
53
+ const rangeOf = (entry: string): string[] | null => {
54
+ const match = PHASE_RANGE.exec(entry)
55
+ if (!match) return null
56
+ const [first, last] = [Number(match[1]), Number(match[2])]
57
+ if (first > last || last - first >= MAX_RANGE) return []
58
+ return Array.from({ length: last - first + 1 }, (_, offset) => String(first + offset))
59
+ }
60
+
61
+ const dependsOf = (cell: string, language: PlanLanguage): Pick<PhaseDependency, 'after' | 'external'> => {
62
+ const noDependency = PLAN_VOCABULARY[language].noDependency
63
+ const entries = splitOutsideParentheses(cell, ',')
64
+ .map((entry) => entry.trim())
65
+ .filter((entry) => !NO_VALUE.test(entry) && !noDependency.includes(entry.toLowerCase()))
66
+ const after = entries.flatMap((entry) => rangeOf(entry) ?? phaseIdOf(entry) ?? [])
67
+ const external = entries.filter((entry) => {
68
+ const spanned = rangeOf(entry)
69
+ return spanned === null ? phaseIdOf(entry) === null : spanned.length === 0
70
+ })
71
+ return { after, external }
72
+ }
73
+
74
+ /**
75
+ * A path as the cell writes it, cut to the directory a glob character starts in (`scripts/*.ts` is `scripts`,
76
+ * `.claude/**` is `.claude`, `*.md` is the root `''`) and without a trailing slash; null for a backticked entry with a
77
+ * space in it, which is a command.
78
+ */
79
+ const pathOf = (entry: string): string | null => {
80
+ if (/\s/.test(entry)) return null
81
+ const glob = entry.search(GLOB_CHARACTER)
82
+ const literal = glob === -1 ? entry : entry.slice(0, Math.max(entry.lastIndexOf('/', glob), 0))
83
+ const path = literal.replace(/^\.\//, '').replace(/\/+$/, '')
84
+ return path === '.' ? '' : path
85
+ }
86
+
87
+ const likePattern = (language: PlanLanguage): { start: RegExp; id: RegExp } => {
88
+ const { writesLike, writesLikeJoiners } = PLAN_VOCABULARY[language]
89
+ const words = (list: readonly string[]) => list.map(escapeRegExp).join('|')
90
+ const target = `${PHASE_ID_PREFIX}?${ID}`
91
+ const joiner = `(?:\\s*,\\s*|\\s+(?:${words(writesLikeJoiners)})\\s+|\\s*,\\s*(?:${words(writesLikeJoiners)})\\s+)`
92
+ return {
93
+ start: new RegExp(`(?<![\\p{L}\\d])(?:${words(writesLike)})\\s+${target}\\b(?:${joiner}${target}\\b)*`, 'iu'),
94
+ id: new RegExp(`${PHASE_ID_PREFIX}?${ID}\\b`, 'gi'),
95
+ }
96
+ }
97
+
98
+ /** The phases a writes cell takes over (`wie 15`, `wie P15 und P16`, `like 15, 16`), found anywhere in the cell. */
99
+ const writesLikeOf = (cell: string, language: PlanLanguage): string[] => {
100
+ const { start, id } = likePattern(language)
101
+ const found = start.exec(cell)?.[0]
102
+ if (found === undefined) return []
103
+ const afterWord = found.replace(/^\S+\s+/, '')
104
+ return [...afterWord.matchAll(id)].map((match) => (match[1] ?? '').toLowerCase())
105
+ }
106
+
107
+ const writesOf = (cell: string, language: PlanLanguage): Pick<PhaseDependency, 'writes' | 'writesLike'> => ({
108
+ writes: [...cell.matchAll(BACKTICKED)].flatMap((match) => pathOf(match[1] ?? '') ?? []),
109
+ writesLike: writesLikeOf(cell, language),
110
+ })
111
+
112
+ /**
113
+ * The marker cell's entries, split at `;`, without a trailing note in parentheses and without the release entry: the
114
+ * release word followed by a space or the end, in any case (`Release-Pipeline` is a marker of its own).
115
+ */
116
+ const markersOf = (cell: string, language: PlanLanguage): string[] => {
117
+ const release = new RegExp(`^${escapeRegExp(PLAN_VOCABULARY[language].releaseMarker)}(?:\\s|$)`, 'i')
118
+ return cell
119
+ .split(';')
120
+ .map((entry) => entry.replace(TRAILING_NOTE, '').trim())
121
+ .filter((entry) => !NO_VALUE.test(entry) && !release.test(entry))
122
+ }
123
+
124
+ /**
125
+ * The rows of the plan's dependency table (`### Abhängigkeiten und Parallelisierung`); null when the plan has no
126
+ * such table or the table lacks one of its three columns, so a plan that never had one is not asked for rows.
127
+ */
128
+ export const parseDependencyTable = (markdown: string, language: PlanLanguage): PhaseDependency[] | null => {
129
+ const { sections, columns } = PLAN_VOCABULARY[language]
130
+ const { header, rows } = firstTableWithHeader(subsectionLines(markdown, sections.dependencies))
131
+ const [after, writes, marker] = [columns.dependsOn, columns.writes, columns.serialMarker].map((name) =>
132
+ header.indexOf(name),
133
+ )
134
+ if (after === -1 || writes === -1 || marker === -1) return null
135
+ return rows.flatMap((cells) => {
136
+ const id = phaseIdOf(cells[0] ?? '')
137
+ if (id === null) return []
138
+ return [
139
+ {
140
+ id,
141
+ ...dependsOf(cells[after] ?? '', language),
142
+ ...writesOf(cells[writes] ?? '', language),
143
+ serialMarkers: markersOf(cells[marker] ?? '', language),
144
+ },
145
+ ]
146
+ })
147
+ }
148
+
149
+ /** The phase headings of a plan with a dependency table that the table has no row for; null for a plan without one. */
150
+ export const phasesWithoutDependencyRow = (markdown: string, language: PlanLanguage): PhaseWithoutRow[] | null => {
151
+ const table = parseDependencyTable(markdown, language)
152
+ if (table === null) return null
153
+ const listed = new Set(table.map(({ id }) => id))
154
+ return phaseHeadings(markdown, language).filter(({ id }) => !listed.has(id))
155
+ }
156
+
157
+ const mergeSets = (sets: readonly (string[] | null)[]): string[] | null =>
158
+ sets.some((set) => set === null) ? null : sets.flatMap((set) => set ?? [])
159
+
160
+ /** The paths a phase writes, those of every phase its `like` cell names included; null for an unknown target or a cycle. */
161
+ const writeSetOf = (
162
+ row: PhaseDependency,
163
+ byId: ReadonlyMap<string, PhaseDependency>,
164
+ chain: readonly string[],
165
+ ): string[] | null => {
166
+ if (chain.includes(row.id)) return null
167
+ const targets = row.writesLike.map((id) => byId.get(id))
168
+ const like = mergeSets(targets.map((target) => (target ? writeSetOf(target, byId, [...chain, row.id]) : null)))
169
+ return like === null ? null : [...row.writes, ...like]
170
+ }
171
+
172
+ const overlaps = (left: string, right: string): boolean =>
173
+ left === '' || right === '' || left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)
174
+
175
+ const sharesMarker = (first: Candidate, second: Candidate): boolean => {
176
+ const markers = new Set(second.serialMarkers.map((marker) => marker.trim().toLowerCase()))
177
+ return first.serialMarkers.some((marker) => markers.has(marker.trim().toLowerCase()))
178
+ }
179
+
180
+ const collides = (first: Candidate, second: Candidate): boolean =>
181
+ sharesMarker(first, second) || first.writes.some((path) => second.writes.some((other) => overlaps(path, other)))
182
+
183
+ const candidateOf = (row: PhaseDependency, byId: ReadonlyMap<string, PhaseDependency>): Candidate[] => {
184
+ const writes = writeSetOf(row, byId, [])
185
+ return writes === null || writes.length === 0 ? [] : [{ id: row.id, writes, serialMarkers: row.serialMarkers }]
186
+ }
187
+
188
+ /**
189
+ * The phases that can run side by side, as groups in table order: the first group can start now, a later one holds
190
+ * the ready phases that collide with an earlier group's members. A phase is offered when it is not in `doneIds`,
191
+ * every phase in its `Depends on` cell is, and the cell names no external gate: nothing in the plan says such a gate
192
+ * holds, so the phase stays out and the caller asks. Two phases share a group only when no path of one lies at or
193
+ * below a path of the other (a root path lies below nothing and above everything) and they share no serial marker,
194
+ * compared without case (a release marker does not count). A `wie N` writes cell takes the set of phase N; a phase
195
+ * whose set is empty, whose `wie` target is unknown, or whose `wie` chain is a cycle, cannot be shown disjoint and
196
+ * stays out. Whether two phases collide in content, or in the release files, is not decided here.
197
+ */
198
+ export const parallelGroups = (rows: readonly PhaseDependency[], doneIds: ReadonlySet<string>): string[][] => {
199
+ const byId = new Map(rows.map((row) => [row.id, row]))
200
+ const ready = rows
201
+ .filter((row) => !doneIds.has(row.id) && row.external.length === 0 && row.after.every((id) => doneIds.has(id)))
202
+ .flatMap((row) => candidateOf(row, byId))
203
+ const groups: Candidate[][] = []
204
+ for (const candidate of ready) {
205
+ const group = groups.find((members) => members.every((member) => !collides(member, candidate)))
206
+ if (group) group.push(candidate)
207
+ else groups.push([candidate])
208
+ }
209
+ return groups.map((group) => group.map(({ id }) => id))
210
+ }
211
+
212
+ /** The phases as a report names them, side by side: `P19 ∥ P20`. */
213
+ export const phasesText = (phases: readonly string[]): string =>
214
+ phases.map((id) => `${PHASE_ID_PREFIX}${id}`).join(' ∥ ')
215
+
216
+ const donePhaseIds = (markdown: string, language: PlanLanguage): Set<string> =>
217
+ new Set(
218
+ parsePlanPhases(markdown, language)
219
+ .filter(({ items }) => items.length > 0 && items.every((item) => item.done))
220
+ .map(({ id }) => id),
221
+ )
222
+
223
+ /** `parallelGroups` of the plan's own dependency table and ticks (a phase is done when every box is ticked); null for a plan without a table. */
224
+ export const parallelGroupsOfPlan = (markdown: string, language: PlanLanguage): string[][] | null => {
225
+ const table = parseDependencyTable(markdown, language)
226
+ return table === null ? null : parallelGroups(table, donePhaseIds(markdown, language))
227
+ }
@@ -0,0 +1,16 @@
1
+ import { PLAN_VOCABULARY } from '../vocabulary'
2
+ import type { ChecklistItem } from './phases'
3
+
4
+ const DOCUMENTATION_WORDS = Object.values(PLAN_VOCABULARY).map((vocabulary) =>
5
+ vocabulary.sections.documentation.toLowerCase(),
6
+ )
7
+
8
+ /** The review-gate box `Dokumentation aktualisiert …` (`Documentation updated …`): a gate item starting with the documentation word. */
9
+ export const isDocumentationGateItem = ({ gate, text }: ChecklistItem): boolean =>
10
+ gate === true && DOCUMENTATION_WORDS.some((word) => text.toLowerCase().startsWith(word))
11
+
12
+ /** The documentation gate box is the only unticked box of the phase: everything else is done. */
13
+ export const onlyDocumentationOpen = ({ items }: { items: readonly ChecklistItem[] }): boolean => {
14
+ const open = items.filter((item) => !item.done)
15
+ return open.length === 1 && isDocumentationGateItem(open[0])
16
+ }
@@ -1,7 +1,29 @@
1
1
  export * from '../vocabulary'
2
+ export * from './board-prose'
3
+ export {
4
+ type PhaseDependency,
5
+ type PhaseWithoutRow,
6
+ parallelGroups,
7
+ parallelGroupsOfPlan,
8
+ parseDependencyTable,
9
+ phasesText,
10
+ phasesWithoutDependencyRow,
11
+ } from './dependencies'
12
+ export * from './documentation-gate'
2
13
  export * from './drift'
3
14
  export * from './header'
4
- export * from './lanes'
15
+ export {
16
+ laneNameOf,
17
+ PHASE_ID_PREFIX,
18
+ type PlanStep,
19
+ parseSprintLanes,
20
+ phaseIdsOfCell,
21
+ resolvePlanPath,
22
+ type SprintLane,
23
+ sprintTitleOf,
24
+ withoutRepeats,
25
+ } from './lanes'
26
+ export * from './model-column'
5
27
  export * from './phase-tally'
6
28
  export * from './phases'
7
29
  export * from './plan-structure'
@@ -1,6 +1,6 @@
1
1
  import { posix } from 'node:path'
2
2
  import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
3
- import { firstHeading, firstTable, sectionLines } from './markdown'
3
+ import { firstHeading, firstTable, firstTableRows, sectionLines, sectionStart } from './markdown'
4
4
 
5
5
  /**
6
6
  * `namedPhaseIds` holds every id the row names when `withoutRepeats` dropped some because an earlier row of the lane
@@ -20,12 +20,16 @@ export type PlanStep = {
20
20
  /** A lane of the sprint table; `closed` when its name cell says the lane was cleaned up. */
21
21
  export type SprintLane = { name: string; port: number | null; steps: PlanStep[]; closed: boolean }
22
22
 
23
+ /** A row of the lanes table (1-based line) of an open lane with the text of its phases cell. */
24
+ export type OpenLaneRow = { line: number; lane: string; phases: string }
25
+
23
26
  const LANE_NAME = /`([^`]+)`/
24
27
  const CONTINUATION = '↳'
25
28
  const PLAN_LINK = /\[([^\]]+)\]\(([^)]+)\)/
26
29
  /** Sprint tables name a phase `P4`; the sidebar title prints it the same way. */
27
30
  export const PHASE_ID_PREFIX = 'P'
28
31
 
32
+ const PHASES_COLUMN = 3
29
33
  const CLAUSE_SEPARATOR = /([;,]|\.(?=\s))\s*/
30
34
  const ENTRY_END = new Set([';', '.'])
31
35
 
@@ -126,3 +130,33 @@ export const sprintTitleOf = (markdown: string, fallback: string): string => fir
126
130
  /** The repository path of a plan link, relative to the sprint document's directory. */
127
131
  export const resolvePlanPath = (sprintDirectory: string, href: string): string =>
128
132
  posix.normalize(posix.join(sprintDirectory, href.split('#')[0]))
133
+
134
+ /**
135
+ * The rows of the lanes table (`## Spuren`) that are not in `previousLines` word for word. The table is read as
136
+ * `parseSprintLanes` reads it (its first table, a lane closed when any of its rows says so); rows of a closed lane
137
+ * are history and stay out.
138
+ */
139
+ export const changedOpenLaneRows = (
140
+ markdown: string,
141
+ language: PlanLanguage,
142
+ previousLines: ReadonlySet<string>,
143
+ ): OpenLaneRow[] => {
144
+ const start = sectionStart(markdown, PLAN_VOCABULARY[language].sections.lanes)
145
+ if (start === -1) return []
146
+ const closed = new Set(
147
+ parseSprintLanes(markdown, language)
148
+ .filter((lane) => lane.closed)
149
+ .map((lane) => lane.name),
150
+ )
151
+ const rows: OpenLaneRow[] = []
152
+ let previous: string | null = null
153
+ for (const { index, line, cells } of firstTableRows(
154
+ sectionLines(markdown, PLAN_VOCABULARY[language].sections.lanes),
155
+ )) {
156
+ const lane = laneNameOf(cells[0] ?? '', previous)
157
+ previous = lane ?? previous
158
+ if (lane !== null && !closed.has(lane) && !previousLines.has(line))
159
+ rows.push({ line: start + index + 1, lane, phases: cells[PHASES_COLUMN] ?? '' })
160
+ }
161
+ return rows
162
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * A model as a board's model column names it: its words (`Opus 5.5` names `opus`; `claude-sonnet-5-5` names
3
+ * `claude` and `sonnet`, one of which is the family) and its version (`['5', '5']`).
4
+ */
5
+ export type ColumnModel = { names: string[]; version: string[] }
6
+
7
+ const NOTE = /\([^)]*\)/g
8
+ const ENTRY_SEPARATOR = /[/,+→]/
9
+ const TOKEN_SEPARATOR = /[^a-z0-9]+/
10
+ const NAME = /^[a-z]+$/
11
+ const NUMBER = /^\d+$/
12
+
13
+ const tokensOf = (text: string): string[] => text.toLowerCase().split(TOKEN_SEPARATOR)
14
+
15
+ /** Words and numbers of an entry; a mixed token (`1m`, a window mark) is neither. */
16
+ const modelOf = (entry: string): ColumnModel | null => {
17
+ const tokens = tokensOf(entry)
18
+ const names = tokens.filter((token) => NAME.test(token))
19
+ return names.length > 0 ? { names, version: tokens.filter((token) => NUMBER.test(token)) } : null
20
+ }
21
+
22
+ /** The models a model column names, in its order; a note in brackets (`(Plan)`) and a dash name none. */
23
+ export const columnModels = (column: string): ColumnModel[] =>
24
+ column
25
+ .replace(NOTE, '')
26
+ .split(ENTRY_SEPARATOR)
27
+ .flatMap((entry) => modelOf(entry) ?? [])
28
+
29
+ const startsWith = (tokens: readonly string[], prefix: readonly string[]): boolean =>
30
+ prefix.every((part, index) => tokens[index] === part)
31
+
32
+ /** Whether the id has the name as a whole word with the version right after it or right before it. */
33
+ const fitsName = (tokens: readonly string[], name: string, version: readonly string[]): boolean =>
34
+ tokens.some(
35
+ (token, index) =>
36
+ token === name &&
37
+ (startsWith(tokens.slice(index + 1), version) ||
38
+ startsWith(tokens.slice(index - version.length, index), version)),
39
+ )
40
+
41
+ const fits = (tokens: readonly string[], { names, version }: ColumnModel): boolean =>
42
+ names.some((name) => fitsName(tokens, name, version))
43
+
44
+ /**
45
+ * Whether a running model's id fits none of the models the column names, by family and version (`Opus 5.5` fits
46
+ * `claude-opus-5-5`, also with a date or window suffix). Knows no vendor. A column that names no model never differs.
47
+ */
48
+ export const differsFromColumn = (column: string, modelId: string): boolean => {
49
+ const models = columnModels(column)
50
+ const tokens = tokensOf(modelId)
51
+ return models.length > 0 && !models.some((model) => fits(tokens, model))
52
+ }
@@ -18,6 +18,9 @@ export type MisplacedDeferral = { line: number; target: string; corrected: strin
18
18
  */
19
19
  export type PlanPhase = { id: string; title: string; items: ChecklistItem[]; deferred?: DeferredItem[]; touched?: true }
20
20
 
21
+ /** A phase heading with its 1-based line, the lower-case id and the title. */
22
+ export type PhaseHeading = { line: number; id: string; title: string }
23
+
21
24
  const REVIEW_GATE = /^\s*\*\*review[- ]gate\b/i
22
25
  const PHASE_END = /^#{1,3}\s/
23
26
  const CHECKBOX = /^\s*[-*]\s+\[([ xX])\]\s+(.*)$/
@@ -25,7 +28,8 @@ const SECTION_HEADING = /^##\s+(.+?)\s*$/
25
28
  const TOP_HEADING = /^#\s/
26
29
  const TABLE_STATUS = { done: '✅', open: '⬜' } as const
27
30
  const ROW_NUMBER = /^\d+(\.\d+)*[a-z]?$/
28
- const NUMBERED_SECTION = /^##\s+(\d+[a-z]?)\s*[—–:-]\s*(.*)$/
31
+ /** `## 10 — title`, and `## P15 — title` as a board names the phase. */
32
+ const NUMBERED_SECTION = new RegExp(`^##\\s+${PHASE_ID_PREFIX}?(\\d+[a-z]?)\\s*[—–:-]\\s*(.*)$`)
29
33
  const SECTION_POINTER = /`##\s+([^`]+?)\s*`/
30
34
  const DEFERRED_TO = new RegExp(`\\s→\\s*${PHASE_ID_PREFIX}(\\d+[a-z]?)\\s*$`, 'i')
31
35
  /** A phase arrow anywhere in a line, with the whitespace before it. */
@@ -195,6 +199,17 @@ const notePointer = (pointers: Map<PlanPhase, string>, phase: PlanPhase | null,
195
199
  const headingOf = (line: string, phaseHeading: RegExp): RegExpMatchArray | null =>
196
200
  line.match(phaseHeading) ?? line.match(NUMBERED_SECTION)
197
201
 
202
+ /** Every phase heading of a plan, `### Phase <id>` and `## P<id> — title` alike, with its 1-based line. */
203
+ export const phaseHeadings = (markdown: string, language: PlanLanguage): PhaseHeading[] => {
204
+ const phaseHeading = phaseHeadingPattern(language)
205
+ return markdown.split('\n').flatMap((text, index) => {
206
+ const heading = headingOf(text, phaseHeading)
207
+ return heading
208
+ ? [{ line: index + 1, id: (heading[1] ?? '').toLowerCase(), title: terminalSafe(heading[2] ?? '').trim() }]
209
+ : []
210
+ })
211
+ }
212
+
198
213
  /**
199
214
  * Every `### Phase <id>` section, and every `## <id> — title` section, with its checkboxes. A
200
215
  * section runs to the next heading of level three or higher, so a review-gate list or a `####`
@@ -1,12 +1,13 @@
1
1
  import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
2
- import { laneNameOf, parseSprintLanes, phaseIdsOfCell } from './lanes'
3
- import { escapeRegExp, firstTableRows, sectionLines, sectionStart } from './markdown'
2
+ import { changedOpenLaneRows, type OpenLaneRow, phaseIdsOfCell } from './lanes'
3
+ import { escapeRegExp, sectionLines } from './markdown'
4
4
  import {
5
5
  deferralTarget,
6
6
  endsPhase,
7
7
  isCheckboxLine,
8
8
  isOpenCheckboxLine,
9
9
  isReviewGateLine,
10
+ type PhaseHeading,
10
11
  type PlanPhase,
11
12
  parsePlanPhases,
12
13
  phaseHeadingPattern,
@@ -14,10 +15,10 @@ import {
14
15
  import { terminalSafe } from './terminal-text'
15
16
 
16
17
  /** 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
+ export type PhaseWithoutGate = PhaseHeading
18
19
 
19
20
  /** 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
+ export type RowWithoutPhase = OpenLaneRow
21
22
 
22
23
  /**
23
24
  * A `### Phase` heading (1-based line) whose tasks are all ticked on the base branch while its own section still
@@ -118,33 +119,14 @@ export const mergedPhasesWithOpenBoxes = (
118
119
  }
119
120
 
120
121
  /**
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.
122
+ * The changed rows of open lanes whose phases cell names no phase id (`P10c`), so neither the view nor the watcher
123
+ * can tie that step to a phase.
125
124
  */
126
125
  export const boardRowsWithoutPhase = (
127
126
  markdown: string,
128
127
  language: PlanLanguage,
129
128
  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),
129
+ ): RowWithoutPhase[] =>
130
+ changedOpenLaneRows(markdown, language, previousLines).filter(
131
+ ({ phases }) => phaseIdsOfCell(phases, language).length === 0,
137
132
  )
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
- }
@@ -33,30 +33,12 @@ export const sumPhases = (phases: readonly PhaseProgress[]): { done: number; tot
33
33
  total: phases.reduce((sum, phase) => sum + phase.total, 0),
34
34
  })
35
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
- }
36
+ /** The phase with exactly this id: a board row's `3c` never falls back to phase 3. */
37
+ const phaseById = <T extends { id: string }>(phases: readonly T[], id: string): T | undefined =>
38
+ phases.find((phase) => phase.id === id)
57
39
 
58
40
  const phaseProgress = (phases: PlanPhase[], id: string): PhaseProgress => {
59
- const phase = findPhase(phases, id)
41
+ const phase = phaseById(phases, id)
60
42
  const items = phase?.items ?? []
61
43
  return {
62
44
  id,
@@ -102,16 +84,12 @@ const newestStepWith = (
102
84
 
103
85
  type LocatedPhase = { phase: PhaseProgress; current: NonNullable<LaneProgress['current']> }
104
86
 
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
87
  /** The phase `id` as the newest step before `before` that shows it from the same plan has it. */
110
88
  const earlierPhase = (steps: readonly StepProgress[], before: number, id: string): LocatedPhase | null => {
111
89
  const planHref = steps[before].step.planHref
112
90
  for (let stepIndex = before - 1; stepIndex >= 0; stepIndex--) {
113
91
  if (steps[stepIndex].step.planHref !== planHref) continue
114
- const phase = shownPhase(steps[stepIndex].phases, id)
92
+ const phase = phaseById(steps[stepIndex].phases, id)
115
93
  if (phase) return { phase, current: { stepIndex, phaseId: phase.id } }
116
94
  }
117
95
  return null
@@ -179,7 +157,7 @@ export const currentPhaseOf = (steps: readonly StepProgress[]): LaneProgress['cu
179
157
  export const laneProgress = (lane: SprintLane, plans: PlansByHref): LaneProgress => {
180
158
  const steps = lane.steps.map((step) => {
181
159
  const phases = step.planHref ? plans[step.planHref] : undefined
182
- return { step, phases: phases ? keptPhaseIds(phases, step.phaseIds).map((id) => phaseProgress(phases, id)) : [] }
160
+ return { step, phases: phases ? step.phaseIds.map((id) => phaseProgress(phases, id)) : [] }
183
161
  })
184
162
  return { lane, steps, current: currentPhaseOf(steps), ...sumPhases(steps.flatMap((step) => step.phases)) }
185
163
  }
@@ -187,7 +165,7 @@ export const laneProgress = (lane: SprintLane, plans: PlansByHref): LaneProgress
187
165
  export const currentPhase = (progress: LaneProgress): PhaseProgress | null => {
188
166
  if (!progress.current) return null
189
167
  const { stepIndex, phaseId } = progress.current
190
- return progress.steps[stepIndex].phases.find((phase) => phase.id === phaseId) ?? null
168
+ return phaseById(progress.steps[stepIndex].phases, phaseId) ?? null
191
169
  }
192
170
 
193
171
  /** 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. */
@@ -27,8 +27,8 @@ const printOnce = async (source: Source): Promise<void> => {
27
27
  }
28
28
 
29
29
  /**
30
- * `jh plans [<board.md>] [--lane <name>] [--once | --ensure]`: a live view of a board's lanes and their plan
31
- * progress; `--ensure` instead brings back the missing views beside the coordinator and each lane.
30
+ * `jh herd [<board.md>] [--lane <name>] [--once | --ensure]`: a live view of a board's lanes and their plan
31
+ * progress; `--ensure` instead brings back the missing herd panes beside the coordinator and each lane.
32
32
  * `home` is where the global config is looked up when the project has none.
33
33
  */
34
34
  export const run = async (args: string[], home: string = homedir()): Promise<number> => {
@@ -45,7 +45,7 @@ export const run = async (args: string[], home: string = homedir()): Promise<num
45
45
  settings.adapter === TERMINAL_ADAPTER.herdr
46
46
  ? ensurePlanPanes(root, settings, picked.file)
47
47
  : ['the terminal adapter is not herdr: no panes to bring back']
48
- for (const line of lines.length > 0 ? lines : ['every plans view is running']) console.log(line)
48
+ for (const line of lines.length > 0 ? lines : ['every herd pane is running']) console.log(line)
49
49
  return 0
50
50
  }
51
51
  const source = { root, sprintFile: picked.file, options, settings, terminal, boardNotice: picked.notice }
@@ -0,0 +1,71 @@
1
+ import { isTickedUnmerged, type LaneProgress, type PhaseProgress, type PlansByHref } from '../plan-format'
2
+
3
+ /** A phase of a plan, as the sprint table spells the plan's link. */
4
+ export type PhaseRef = { planHref: string; phaseId: string }
5
+
6
+ /** Per plan link and phase id, the state of its checkboxes as `1` (ticked) and `0` (open) in plan order. */
7
+ export type TickSnapshot = Record<string, Record<string, string>>
8
+
9
+ /**
10
+ * What the herd remembers of a lane between two reads: the ticks it saw, the phase whose ticks moved last and the
11
+ * identity of the newest board row (`newestRowIdentity`), which a new row changes.
12
+ */
13
+ export type LaneMemory = { ticks: TickSnapshot; moved: PhaseRef | null; row: string }
14
+
15
+ export const tickSnapshotOf = (plans: PlansByHref): TickSnapshot =>
16
+ Object.fromEntries(
17
+ Object.entries(plans).map(([href, phases]) => [
18
+ href,
19
+ Object.fromEntries(
20
+ phases.map((phase) => [phase.id, phase.items.map((item) => (item.done ? '1' : '0')).join('')]),
21
+ ),
22
+ ]),
23
+ )
24
+
25
+ /**
26
+ * The phases both reads have whose ticks differ, in plan order; a plan or phase new in `next` has no state to move
27
+ * from. A task added to or removed from a phase changes its tick string and counts as movement.
28
+ */
29
+ export const movedPhases = (previous: TickSnapshot, next: TickSnapshot): PhaseRef[] =>
30
+ Object.entries(next).flatMap(([planHref, phases]) =>
31
+ Object.entries(phases).flatMap(([phaseId, ticks]) => {
32
+ const before = previous[planHref]?.[phaseId]
33
+ return before !== undefined && before !== ticks ? [{ planHref, phaseId }] : []
34
+ }),
35
+ )
36
+
37
+ /** The newest board row of a lane as its step index and the phase ids it shows, a step `withWorkedPhases` added not counted. */
38
+ export const newestRowIdentity = ({ steps }: LaneProgress): string => {
39
+ const index = steps.reduce((last, { step }, stepIndex) => (step.withoutRow ? last : stepIndex), -1)
40
+ return `${index}:${(steps[index]?.phases ?? []).map((phase) => phase.id).join(',')}`
41
+ }
42
+
43
+ /**
44
+ * The memory after a read of the lane's plans. The first read has no earlier ticks and no moved phase; exactly one
45
+ * moved phase becomes the remembered one; none keeps the one moved last; several at once decide nothing, so the
46
+ * newest board row rules again until a single phase moves. A changed newest board row (`row`) is an explicit
47
+ * signal and clears the memory.
48
+ */
49
+ export const nextMemory = (previous: LaneMemory | undefined, plans: PlansByHref, row: string): LaneMemory => {
50
+ const ticks = tickSnapshotOf(plans)
51
+ if (!previous || previous.row !== row) return { ticks, moved: null, row }
52
+ const moved = movedPhases(previous.ticks, ticks)
53
+ if (moved.length === 0) return { ticks, moved: previous.moved, row }
54
+ return { ticks, moved: moved.length === 1 ? moved[0] : null, row }
55
+ }
56
+
57
+ /**
58
+ * The progress with `moved` as the current phase, in the newest step that shows it with checkboxes; unchanged
59
+ * when there is no moved phase, the lane does not show it or it is done and merged, so the newest board row decides
60
+ * as before.
61
+ */
62
+ export const withMovedPhase = (progress: LaneProgress, moved: PhaseRef | null): LaneProgress => {
63
+ if (!moved) return progress
64
+ const isCurrentWork = (phase: PhaseProgress): boolean =>
65
+ phase.id === moved.phaseId && phase.total > 0 && (phase.done < phase.total || isTickedUnmerged(phase))
66
+ const showsIt = progress.steps.map(
67
+ ({ step, phases }) => step.planHref === moved.planHref && phases.some(isCurrentWork),
68
+ )
69
+ const stepIndex = showsIt.lastIndexOf(true)
70
+ return stepIndex < 0 ? progress : { ...progress, current: { stepIndex, phaseId: moved.phaseId } }
71
+ }