jackherd 1.7.0 → 1.8.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 (157) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/INSTALL.md +4 -2
  3. package/{NOTICE → NOTICE.txt} +1 -1
  4. package/README.md +94 -1033
  5. package/assets/icons/bark-dark.svg +1 -1
  6. package/assets/icons/bark.svg +1 -1
  7. package/assets/icons/bowl-dark.svg +1 -1
  8. package/assets/icons/bowl.svg +1 -1
  9. package/assets/icons/decide-dark.svg +1 -1
  10. package/assets/icons/decide.svg +1 -1
  11. package/assets/icons/ear-dark.svg +1 -1
  12. package/assets/icons/ear.svg +1 -1
  13. package/assets/icons/lanes-dark.svg +1 -1
  14. package/assets/icons/lanes.svg +1 -1
  15. package/assets/icons/plan-dark.svg +1 -1
  16. package/assets/icons/plan.svg +1 -1
  17. package/dist/{chunk-OBUWTQ6Z.js → chunk-M6DDZEW4.js} +24 -10
  18. package/dist/config.d.ts +11 -5
  19. package/dist/config.js +53 -21
  20. package/dist/plan-format.d.ts +73 -7
  21. package/dist/plan-format.js +180 -100
  22. package/docs/workflow-dark.svg +1 -1
  23. package/docs/workflow.svg +1 -1
  24. package/herdr-plugin/herdr-plugin.toml +1 -1
  25. package/install.sh +1 -1
  26. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +2 -12
  27. package/kit/hooks/hooks/approval-gate/index.ts +10 -8
  28. package/kit/hooks/hooks/approval-gate/run.ts +21 -12
  29. package/kit/hooks/hooks/ask-hint/decision.ts +3 -3
  30. package/kit/hooks/hooks/git-guard/decision.ts +189 -15
  31. package/kit/hooks/hooks/git-guard/index.ts +13 -3
  32. package/kit/hooks/hooks/git-guard/push-target.ts +85 -0
  33. package/kit/hooks/hooks/git-guard/repository.ts +63 -0
  34. package/kit/hooks/hooks/merge-guard/check-free.ts +1 -13
  35. package/kit/hooks/hooks/merge-guard/decision.ts +342 -102
  36. package/kit/hooks/hooks/merge-guard/index.ts +6 -4
  37. package/kit/hooks/hooks/merge-guard/plain-path.ts +15 -0
  38. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +23 -0
  39. package/kit/hooks/hooks/merge-guard/plan-approval-settings.ts +142 -0
  40. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +196 -0
  41. package/kit/hooks/hooks/merge-guard/plan-approval.ts +168 -0
  42. package/kit/hooks/hooks/merge-guard/plan-growth.ts +40 -0
  43. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +194 -0
  44. package/kit/hooks/hooks/merge-guard/pull-request.ts +67 -21
  45. package/kit/hooks/hooks/merge-guard/run.ts +118 -29
  46. package/kit/hooks/hooks/merge-guard/settings.ts +8 -8
  47. package/kit/hooks/hooks/merge-guard/worker.ts +3 -3
  48. package/kit/hooks/hooks/shared/base-branch.ts +23 -0
  49. package/kit/hooks/hooks/shared/board-lanes.ts +93 -0
  50. package/kit/hooks/hooks/shared/command-text-runners.ts +71 -0
  51. package/kit/hooks/hooks/shared/command.ts +14 -2
  52. package/kit/hooks/hooks/shared/commit.ts +2 -0
  53. package/kit/hooks/hooks/shared/dialog-answers.ts +4 -1
  54. package/kit/hooks/hooks/shared/json.ts +10 -0
  55. package/kit/hooks/hooks/shared/lane.ts +57 -0
  56. package/kit/hooks/hooks/shared/once.ts +8 -0
  57. package/kit/hooks/hooks/shared/own-pane.ts +31 -0
  58. package/kit/hooks/hooks/shared/pane-agent.ts +24 -0
  59. package/kit/hooks/hooks/shared/plan-open-phases.ts +114 -0
  60. package/kit/hooks/hooks/shared/program-position.ts +148 -5
  61. package/kit/hooks/hooks/shared/shell-aliases.ts +258 -0
  62. package/kit/hooks/hooks/shared/shell-snapshots.ts +68 -0
  63. package/kit/hooks/hooks/shared/shell.ts +167 -20
  64. package/kit/hooks/hooks.json +11 -2
  65. package/kit/hooks/statusline.sh +29 -16
  66. package/kit/review/skills/diagnose/SKILL.md +1 -1
  67. package/kit/review/skills/review/SKILL.md +2 -0
  68. package/kit/rules/rules/kit/testing.md +2 -5
  69. package/kit/rules/rules/kit/writing-for-agents.md +3 -1
  70. package/kit/workflow/agents/spec-review.md +1 -1
  71. package/kit/workflow/skills/follow/SKILL.md +3 -3
  72. package/kit/workflow/skills/grill/SKILL.md +1 -1
  73. package/kit/workflow/skills/implement/SKILL.md +10 -11
  74. package/kit/workflow/skills/orchestrate/SKILL.md +155 -117
  75. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +17 -0
  76. package/kit/workflow/skills/orchestrate/references/herd.md +27 -0
  77. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -0
  78. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +23 -0
  79. package/kit/workflow/skills/plan/SKILL.md +5 -2
  80. package/kit/workflow/skills/plan/references/template-English.md +6 -0
  81. package/kit/workflow/skills/plan/references/template-German.md +6 -0
  82. package/package.json +3 -2
  83. package/recipes/README.md +21 -0
  84. package/recipes/github-issues.md +23 -0
  85. package/recipes/github-projects.md +22 -0
  86. package/recipes/jira-cli.md +26 -0
  87. package/recipes/jira-rest.md +24 -0
  88. package/recipes/linear.md +25 -0
  89. package/recipes/sentry.md +18 -0
  90. package/recipes/sources/jira.ts +51 -0
  91. package/recipes/sources/linear.ts +19 -0
  92. package/recipes/sources/sentry.ts +12 -0
  93. package/recipes/sources.md +80 -0
  94. package/recipes/status-page.md +19 -0
  95. package/schemas/jackherd.schema.json +42 -18
  96. package/src/check/command.ts +2 -2
  97. package/src/check/parallel-report.ts +19 -9
  98. package/src/cli.ts +1 -0
  99. package/src/config/context-shares.ts +130 -0
  100. package/src/config/legacy-names.ts +8 -0
  101. package/src/config/read-config.ts +12 -4
  102. package/src/config/schema.ts +75 -20
  103. package/src/context/auto-compact.ts +68 -0
  104. package/src/context/command.ts +68 -17
  105. package/src/context/coordinator-name.ts +27 -9
  106. package/src/context/index.ts +0 -1
  107. package/src/context/level.ts +18 -11
  108. package/src/context/report.ts +6 -3
  109. package/src/plan-format/dependencies.ts +144 -32
  110. package/src/plan-format/index.ts +6 -0
  111. package/src/plan-format/lanes.ts +6 -0
  112. package/src/plan-format/progress.ts +19 -8
  113. package/src/plans/command.ts +14 -5
  114. package/src/plans/current-phase.ts +63 -7
  115. package/src/plans/current-task.ts +51 -0
  116. package/src/plans/ensure-panes.ts +25 -8
  117. package/src/plans/frame.ts +99 -0
  118. package/src/plans/git.ts +8 -0
  119. package/src/plans/herd-memory.ts +107 -0
  120. package/src/plans/lane-plans.ts +5 -3
  121. package/src/plans/lane-summary.ts +222 -0
  122. package/src/plans/layout-phase.ts +148 -0
  123. package/src/plans/layout-summary.ts +140 -0
  124. package/src/plans/layout.ts +164 -420
  125. package/src/plans/live.ts +11 -6
  126. package/src/plans/plan-panes.ts +15 -0
  127. package/src/plans/planned.ts +9 -42
  128. package/src/plans/selection.ts +2 -6
  129. package/src/plans/settings.ts +2 -7
  130. package/src/plans/snapshot.ts +38 -39
  131. package/src/plans/symbols.ts +2 -2
  132. package/src/plans/texts.ts +32 -31
  133. package/src/plans/view.ts +0 -1
  134. package/src/plans/worked-phases.ts +2 -2
  135. package/src/settled/board-row.ts +133 -0
  136. package/src/settled/check.ts +129 -0
  137. package/src/settled/command.ts +65 -0
  138. package/src/settled/evidence-version.ts +14 -0
  139. package/src/settled/options.ts +69 -0
  140. package/src/settled/send.ts +27 -0
  141. package/src/settled/settle.ts +87 -0
  142. package/src/settled/topics.ts +56 -0
  143. package/src/sync/command.ts +16 -1
  144. package/src/sync/render.ts +13 -4
  145. package/src/sync/sync.ts +25 -5
  146. package/src/terminal/herdr.ts +46 -10
  147. package/src/terminal/none.ts +1 -0
  148. package/src/terminal/types.ts +15 -2
  149. package/src/update/command.ts +4 -2
  150. package/src/update/update.ts +33 -9
  151. package/src/vocabulary/index.ts +47 -11
  152. package/src/watch/appointments.ts +205 -0
  153. package/src/watch/command.ts +62 -19
  154. package/src/watch/lane-report.ts +11 -3
  155. package/src/watch/texts.ts +35 -59
  156. package/src/watch/watch-events.ts +21 -28
  157. package/kit/hooks/hooks/approval-gate/lane.ts +0 -14
@@ -1,13 +1,15 @@
1
1
  import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
2
- import { PHASE_ID_PREFIX } from './lanes'
2
+ import { PHASE_ID_PREFIX, phaseLabels } from './lanes'
3
3
  import { escapeRegExp, firstTableWithHeader, subsectionLines } from './markdown'
4
4
  import { type PhaseHeading, parsePlanPhases, phaseHeadings } from './phases'
5
+ import { isPhaseFinished } from './progress'
5
6
 
6
7
  /**
7
8
  * A row of the dependency table. `after` holds the phases it waits for, `external` the entries that name no phase
8
9
  * (`User: Zuruf`) or a range that cannot be expanded; `writes` are the paths of the writes cell (`''` is the whole
9
10
  * 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
+ * entries of the marker cell that keep two phases apart, `releases` the packages its release markers name
12
+ * (`Release jackherd 1.8.0` is `jackherd`).
11
13
  */
12
14
  export type PhaseDependency = {
13
15
  id: string
@@ -16,12 +18,16 @@ export type PhaseDependency = {
16
18
  writes: string[]
17
19
  writesLike: string[]
18
20
  serialMarkers: string[]
21
+ releases: string[]
19
22
  }
20
23
 
24
+ /** The open phases in waves (each starts once the ones before it are done), the longest chain, and the open phases no wave holds. */
25
+ export type PhaseSchedule = { waves: string[][]; criticalPath: string[]; unplaced: string[] }
26
+
21
27
  /** A phase heading (1-based line) the dependency table has no row for. */
22
28
  export type PhaseWithoutRow = PhaseHeading
23
29
 
24
- type Candidate = { id: string; writes: string[]; serialMarkers: string[] }
30
+ type Candidate = { id: string; writes: string[]; serialMarkers: string[]; releases: string[] }
25
31
 
26
32
  const ID = '(\\d+[a-z]?)'
27
33
  const PHASE_ID = new RegExp(`^\\s*${PHASE_ID_PREFIX}?${ID}\\b`, 'i')
@@ -30,6 +36,10 @@ const BACKTICKED = /`([^`]+)`/g
30
36
  const NO_VALUE = /^[—–-]?$/
31
37
  const TRAILING_NOTE = /\s*\([^)]*\)\s*$/
32
38
  const GLOB_CHARACTER = /[*?{[]/
39
+ const VERSION = /^v?\d+(?:[.\-+][\w.-]*)?$/i
40
+ const PACKAGE_SCOPE = /^@[^/]+\//
41
+ /** The files a release of the package in their directory changes: the changelog and the version in the manifest. */
42
+ const RELEASE_FILE = /(?:^|\/)(?:CHANGELOG\.md|package\.json)$/
33
43
  const MAX_RANGE = 50
34
44
 
35
45
  const phaseIdOf = (cell: string): string | null => PHASE_ID.exec(cell)?.[1]?.toLowerCase() ?? null
@@ -109,18 +119,51 @@ const writesOf = (cell: string, language: PlanLanguage): Pick<PhaseDependency, '
109
119
  writesLike: writesLikeOf(cell, language),
110
120
  })
111
121
 
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
122
+ const releasePattern = (language: PlanLanguage): RegExp =>
123
+ new RegExp(`^${escapeRegExp(PLAN_VOCABULARY[language].releaseMarker)}(?:\\s|$)`, 'i')
124
+
125
+ const markerEntries = (cell: string): string[] =>
126
+ cell
119
127
  .split(';')
120
128
  .map((entry) => entry.replace(TRAILING_NOTE, '').trim())
121
- .filter((entry) => !NO_VALUE.test(entry) && !release.test(entry))
129
+ .filter((entry) => !NO_VALUE.test(entry))
130
+
131
+ /** A package as releases compare it: without its scope, in lower case (`@x/UI` and `packages/ui` are both `ui`). */
132
+ const packageKey = (name: string): string => name.replace(PACKAGE_SCOPE, '').toLowerCase()
133
+
134
+ /** The package a release entry names: the words after the release word that are no version (`Release @x/ui 2.0` is `ui`). */
135
+ const releasedPackageOf = (entry: string): string[] => {
136
+ const name = entry
137
+ .split(/\s+/)
138
+ .slice(1)
139
+ .filter((word) => !VERSION.test(word))
140
+ .join(' ')
141
+ return name ? [packageKey(name)] : []
122
142
  }
123
143
 
144
+ /**
145
+ * The marker cell split once into its entries at `;`, each without a trailing note in parentheses: the release
146
+ * entries (the release word followed by a space or the end, in any case; `Release-Pipeline` is a marker of its own)
147
+ * give the packages they name, a release entry with a version alone (`Release 1.7.0`) none; every other entry is a
148
+ * serial marker.
149
+ */
150
+ const markerCellOf = (cell: string, language: PlanLanguage): Pick<PhaseDependency, 'serialMarkers' | 'releases'> => {
151
+ const release = releasePattern(language)
152
+ const entries = markerEntries(cell)
153
+ return {
154
+ serialMarkers: entries.filter((entry) => !release.test(entry)),
155
+ releases: entries.filter((entry) => release.test(entry)).flatMap(releasedPackageOf),
156
+ }
157
+ }
158
+
159
+ /** The packages whose release files a write set touches, by the last segment of their directory (`.` for the root). */
160
+ const releasedByFiles = (writes: readonly string[]): string[] =>
161
+ writes.flatMap((path) => {
162
+ if (!RELEASE_FILE.test(path)) return []
163
+ const directory = path.split('/').slice(0, -1)
164
+ return [packageKey(directory.at(-1) ?? '.')]
165
+ })
166
+
124
167
  /**
125
168
  * The rows of the plan's dependency table (`### Abhängigkeiten und Parallelisierung`); null when the plan has no
126
169
  * such table or the table lacks one of its three columns, so a plan that never had one is not asked for rows.
@@ -140,7 +183,7 @@ export const parseDependencyTable = (markdown: string, language: PlanLanguage):
140
183
  id,
141
184
  ...dependsOf(cells[after] ?? '', language),
142
185
  ...writesOf(cells[writes] ?? '', language),
143
- serialMarkers: markersOf(cells[marker] ?? '', language),
186
+ ...markerCellOf(cells[marker] ?? '', language),
144
187
  },
145
188
  ]
146
189
  })
@@ -154,6 +197,9 @@ export const phasesWithoutDependencyRow = (markdown: string, language: PlanLangu
154
197
  return phaseHeadings(markdown, language).filter(({ id }) => !listed.has(id))
155
198
  }
156
199
 
200
+ const longestChain = (chains: readonly string[][]): string[] =>
201
+ chains.reduce<string[]>((longest, chain) => (chain.length > longest.length ? chain : longest), [])
202
+
157
203
  const mergeSets = (sets: readonly (string[] | null)[]): string[] | null =>
158
204
  sets.some((set) => set === null) ? null : sets.flatMap((set) => set ?? [])
159
205
 
@@ -172,30 +218,26 @@ const writeSetOf = (
172
218
  const overlaps = (left: string, right: string): boolean =>
173
219
  left === '' || right === '' || left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)
174
220
 
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()))
221
+ const sharesEntry = (first: readonly string[], second: readonly string[]): boolean => {
222
+ const entries = new Set(second.map((entry) => entry.trim().toLowerCase()))
223
+ return first.some((entry) => entries.has(entry.trim().toLowerCase()))
178
224
  }
179
225
 
226
+ /** A shared serial marker, or a release of the same package: two releases of one package never run side by side. */
227
+ const sharesMarker = (first: Candidate, second: Candidate): boolean =>
228
+ sharesEntry(first.serialMarkers, second.serialMarkers) || sharesEntry(first.releases, second.releases)
229
+
180
230
  const collides = (first: Candidate, second: Candidate): boolean =>
181
231
  sharesMarker(first, second) || first.writes.some((path) => second.writes.some((other) => overlaps(path, other)))
182
232
 
183
233
  const candidateOf = (row: PhaseDependency, byId: ReadonlyMap<string, PhaseDependency>): Candidate[] => {
184
234
  const writes = writeSetOf(row, byId, [])
185
- return writes === null || writes.length === 0 ? [] : [{ id: row.id, writes, serialMarkers: row.serialMarkers }]
235
+ if (writes === null || writes.length === 0) return []
236
+ const releases = [...new Set([...row.releases, ...releasedByFiles(writes)])]
237
+ return [{ id: row.id, writes, serialMarkers: row.serialMarkers, releases }]
186
238
  }
187
239
 
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[][] => {
240
+ const candidateGroups = (rows: readonly PhaseDependency[], doneIds: ReadonlySet<string>): Candidate[][] => {
199
241
  const byId = new Map(rows.map((row) => [row.id, row]))
200
242
  const ready = rows
201
243
  .filter((row) => !doneIds.has(row.id) && row.external.length === 0 && row.after.every((id) => doneIds.has(id)))
@@ -206,22 +248,92 @@ export const parallelGroups = (rows: readonly PhaseDependency[], doneIds: Readon
206
248
  if (group) group.push(candidate)
207
249
  else groups.push([candidate])
208
250
  }
209
- return groups.map((group) => group.map(({ id }) => id))
251
+ return groups
252
+ }
253
+
254
+ /**
255
+ * The phases that can run side by side, as groups in table order: the first group can start now, a later one holds
256
+ * the ready phases that collide with an earlier group's members. A phase is offered when it is not in `doneIds`,
257
+ * every phase in its `Depends on` cell is, and the cell names no external gate: nothing in the plan says such a gate
258
+ * holds, so the phase stays out and the caller asks. Two phases share a group only when no path of one lies at or
259
+ * below a path of the other (a root path lies below nothing and above everything) and they share no serial marker,
260
+ * compared without case, and release no package the other releases: a release marker naming it (`Release jackherd
261
+ * 1.8.0`) or a write of its release files (`CHANGELOG.md`, `package.json` in its directory), the package compared by
262
+ * its name without scope and the last segment of its directory. A release marker without a name keeps nobody apart.
263
+ * A `wie N` writes cell takes the set of phase N; a phase whose set is empty, whose `wie` target is unknown, or whose
264
+ * `wie` chain is a cycle, cannot be shown disjoint and stays out. Whether two phases collide in content is not decided here.
265
+ */
266
+ export const parallelGroups = (rows: readonly PhaseDependency[], doneIds: ReadonlySet<string>): string[][] =>
267
+ candidateGroups(rows, doneIds).map((group) => group.map(({ id }) => id))
268
+
269
+ /**
270
+ * The whole sequence of parallel groups, computed ahead: the first wave is the first group of `parallelGroups`, each
271
+ * later wave the first group once the waves before it are done. A phase `parallelGroups` never offers (an external
272
+ * gate, an empty or unresolvable writes set) is in no wave, and neither is a phase waiting for it.
273
+ */
274
+ export const phaseWaves = (rows: readonly PhaseDependency[], doneIds: ReadonlySet<string>): string[][] => {
275
+ const waves: string[][] = []
276
+ const done = new Set(doneIds)
277
+ let wave = parallelGroups(rows, done)[0]
278
+ while (wave) {
279
+ waves.push(wave)
280
+ for (const id of wave) done.add(id)
281
+ wave = parallelGroups(rows, done)[0]
282
+ }
283
+ return waves
284
+ }
285
+
286
+ /**
287
+ * The longest chain of open phases in `waves`, counted in phases: a phase follows another that it depends on, or one
288
+ * of an earlier wave it shares a serial marker or a package release with. A shared path alone does not chain, so a
289
+ * plan with more waves than its critical path is long loses the difference to overlapping writes. The first longest
290
+ * chain in wave order; empty without waves.
291
+ */
292
+ export const criticalPath = (rows: readonly PhaseDependency[], waves: readonly string[][]): string[] => {
293
+ const byId = new Map(rows.map((row) => [row.id, row]))
294
+ const candidates = new Map(rows.flatMap((row) => candidateOf(row, byId)).map((each) => [each.id, each]))
295
+ const chains = new Map<string, string[]>()
296
+ const follows = (later: string, earlier: string): boolean => {
297
+ const [first, second] = [candidates.get(earlier), candidates.get(later)]
298
+ const chained = first !== undefined && second !== undefined && sharesMarker(first, second)
299
+ return chained || (byId.get(later)?.after.includes(earlier) ?? false)
300
+ }
301
+ waves.forEach((wave, index) => {
302
+ const earlier = waves.slice(0, index).flat()
303
+ for (const id of wave) {
304
+ const before = longestChain(earlier.filter((other) => follows(id, other)).map((other) => chains.get(other) ?? []))
305
+ chains.set(id, [...before, id])
306
+ }
307
+ })
308
+ return longestChain([...chains.values()])
210
309
  }
211
310
 
212
311
  /** 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(' ∥ ')
312
+ export const phasesText = (phases: readonly string[]): string => phaseLabels(phases, ' ∥ ')
215
313
 
216
314
  const donePhaseIds = (markdown: string, language: PlanLanguage): Set<string> =>
217
315
  new Set(
218
316
  parsePlanPhases(markdown, language)
219
- .filter(({ items }) => items.length > 0 && items.every((item) => item.done))
317
+ .filter(isPhaseFinished)
220
318
  .map(({ id }) => id),
221
319
  )
222
320
 
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. */
321
+ /** `parallelGroups` of the plan's own dependency table and ticks (a phase is done once `isPhaseFinished`); null for a plan without a table. */
224
322
  export const parallelGroupsOfPlan = (markdown: string, language: PlanLanguage): string[][] | null => {
225
323
  const table = parseDependencyTable(markdown, language)
226
324
  return table === null ? null : parallelGroups(table, donePhaseIds(markdown, language))
227
325
  }
326
+
327
+ /** The waves, the critical path and the open phases in no wave, of the plan's own table and ticks; null for a plan without a table. */
328
+ export const wavesOfPlan = (markdown: string, language: PlanLanguage): PhaseSchedule | null => {
329
+ const table = parseDependencyTable(markdown, language)
330
+ if (table === null) return null
331
+ const done = donePhaseIds(markdown, language)
332
+ const waves = phaseWaves(table, done)
333
+ const placed = new Set(waves.flat())
334
+ return {
335
+ waves,
336
+ criticalPath: criticalPath(table, waves),
337
+ unplaced: table.filter(({ id }) => !done.has(id) && !placed.has(id)).map(({ id }) => id),
338
+ }
339
+ }
@@ -1,13 +1,17 @@
1
1
  export * from '../vocabulary'
2
2
  export * from './board-prose'
3
3
  export {
4
+ criticalPath,
4
5
  type PhaseDependency,
6
+ type PhaseSchedule,
5
7
  type PhaseWithoutRow,
6
8
  parallelGroups,
7
9
  parallelGroupsOfPlan,
8
10
  parseDependencyTable,
9
11
  phasesText,
10
12
  phasesWithoutDependencyRow,
13
+ phaseWaves,
14
+ wavesOfPlan,
11
15
  } from './dependencies'
12
16
  export * from './documentation-gate'
13
17
  export * from './drift'
@@ -18,6 +22,8 @@ export {
18
22
  type PlanStep,
19
23
  parseSprintLanes,
20
24
  phaseIdsOfCell,
25
+ phaseLabel,
26
+ phaseLabels,
21
27
  resolvePlanPath,
22
28
  type SprintLane,
23
29
  sprintTitleOf,
@@ -29,6 +29,12 @@ const PLAN_LINK = /\[([^\]]+)\]\(([^)]+)\)/
29
29
  /** Sprint tables name a phase `P4`; the sidebar title prints it the same way. */
30
30
  export const PHASE_ID_PREFIX = 'P'
31
31
 
32
+ /** A phase as plans and reports name it: `P4`. */
33
+ export const phaseLabel = (id: string): string => `${PHASE_ID_PREFIX}${id}`
34
+
35
+ /** Phases as plans and reports name them, joined by `separator`: `P3 P4`, `P19 ∥ P20`. */
36
+ export const phaseLabels = (ids: readonly string[], separator: string): string => ids.map(phaseLabel).join(separator)
37
+
32
38
  const PHASES_COLUMN = 3
33
39
  const CLAUSE_SEPARATOR = /([;,]|\.(?=\s))\s*/
34
40
  const ENTRY_END = new Set([';', '.'])
@@ -1,4 +1,4 @@
1
- import { PHASE_ID_PREFIX, type PlanStep, type SprintLane } from './lanes'
1
+ import { type PlanStep, phaseLabel, type SprintLane } from './lanes'
2
2
  import type { ChecklistItem, DeferredItem, PlanPhase } from './phases'
3
3
 
4
4
  /**
@@ -37,7 +37,8 @@ export const sumPhases = (phases: readonly PhaseProgress[]): { done: number; tot
37
37
  const phaseById = <T extends { id: string }>(phases: readonly T[], id: string): T | undefined =>
38
38
  phases.find((phase) => phase.id === id)
39
39
 
40
- const phaseProgress = (phases: PlanPhase[], id: string): PhaseProgress => {
40
+ /** The progress of the phase `id` of a plan; a phase the plan lacks has a null title and no checkboxes. */
41
+ export const phaseProgress = (phases: readonly PlanPhase[], id: string): PhaseProgress => {
41
42
  const phase = phaseById(phases, id)
42
43
  const items = phase?.items ?? []
43
44
  return {
@@ -51,7 +52,17 @@ const phaseProgress = (phases: PlanPhase[], id: string): PhaseProgress => {
51
52
  }
52
53
  }
53
54
 
54
- const isOpen = ({ done, total }: PhaseProgress): boolean => done < total
55
+ /**
56
+ * A phase is finished once every checkbox it has is ticked, its tasks and its review gate alike: `/implement` and
57
+ * `jh check --parallel` read it so, and an open documentation box keeps the phase open. A phase without a checkbox is
58
+ * never finished.
59
+ */
60
+ export const isPhaseFinished = ({ items }: Pick<PlanPhase, 'items'>): boolean =>
61
+ items.length > 0 && items.every(({ done }) => done)
62
+
63
+ /** A phase with a checkbox that is not finished; a phase without a checkbox is neither. */
64
+ export const isPhaseOpen = (phase: Pick<PlanPhase, 'items'>): boolean =>
65
+ phase.items.length > 0 && !isPhaseFinished(phase)
55
66
 
56
67
  /** A phase the plan has written: one with at least one checkbox, not one missing from the plan or without any. */
57
68
  const isWritten = ({ total }: PhaseProgress): boolean => total > 0
@@ -68,7 +79,7 @@ const isTouched = (phase: PhaseProgress): boolean => phase.touched === true && i
68
79
  * A phase the lane ticked to the end in its worktree while the base branch does not carry those ticks yet: still
69
80
  * the lane's work until it is merged, not a finished phase.
70
81
  */
71
- export const isTickedUnmerged = (phase: PhaseProgress): boolean => isTouched(phase) && !isOpen(phase)
82
+ export const isTickedUnmerged = (phase: PhaseProgress): boolean => isTouched(phase) && isPhaseFinished(phase)
72
83
 
73
84
  /** The first phase `pick` finds in a step, from the newest step back. */
74
85
  const newestStepWith = (
@@ -112,7 +123,7 @@ const rowPhases = (steps: readonly StepProgress[], stepIndex: number): LocatedPh
112
123
  }
113
124
 
114
125
  const touchedOpenPhaseOf = (phases: readonly PhaseProgress[]): PhaseProgress | undefined =>
115
- phases.find((phase) => isTouched(phase) && isOpen(phase))
126
+ phases.find((phase) => isTouched(phase) && isPhaseOpen(phase))
116
127
 
117
128
  const lastTickedUnmergedOf = (phases: readonly PhaseProgress[]): PhaseProgress | undefined =>
118
129
  phases.filter(isTickedUnmerged).at(-1)
@@ -126,7 +137,7 @@ const lastTickedUnmergedOf = (phases: readonly PhaseProgress[]): PhaseProgress |
126
137
  const newestRowPhase = (steps: readonly StepProgress[]): LaneProgress['current'] => {
127
138
  const located = rowPhases(steps, newestRowIndex(steps)).filter(({ phase }) => isWritten(phase))
128
139
  const phases = located.map(({ phase }) => phase)
129
- const phase = touchedOpenPhaseOf(phases) ?? lastTickedUnmergedOf(phases) ?? phases.find(isOpen)
140
+ const phase = touchedOpenPhaseOf(phases) ?? lastTickedUnmergedOf(phases) ?? phases.find(isPhaseOpen)
130
141
  return located.find((entry) => entry.phase === phase)?.current ?? null
131
142
  }
132
143
 
@@ -141,7 +152,7 @@ const phaseFromTicks = (steps: readonly StepProgress[]): LaneProgress['current']
141
152
  const touched = newestStepWith(steps, touchedOpenPhaseOf) ?? newestStepWith(steps, lastTickedUnmergedOf)
142
153
  if (touched) return touched
143
154
  const lastIndex = newestWrittenStepIndex(steps)
144
- const phase = steps[lastIndex]?.phases.find(isOpen)
155
+ const phase = steps[lastIndex]?.phases.find(isPhaseOpen)
145
156
  return phase ? { stepIndex: lastIndex, phaseId: phase.id } : null
146
157
  }
147
158
 
@@ -175,6 +186,6 @@ export const activeItem = (phase: Pick<PhaseProgress, 'items'>): ChecklistItem |
175
186
  /** 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. */
176
187
  export const shortTitle = (progress: LaneProgress): string | null => {
177
188
  const phase = currentPhase(progress)
178
- if (phase) return `${PHASE_ID_PREFIX}${phase.id} · ${phase.done}/${phase.total}`
189
+ if (phase) return `${phaseLabel(phase.id)} · ${phase.done}/${phase.total}`
179
190
  return progress.total > 0 ? `✓ ${progress.done}/${progress.total}` : null
180
191
  }
@@ -6,26 +6,35 @@ import { boardFor, foundLanesOf } from './board-pick'
6
6
  import { ensurePlanPanes } from './ensure-panes'
7
7
  import { mainCheckout, worktreeRoot } from './git'
8
8
  import { fetchPullRequests } from './github'
9
+ import { recallLanes, rememberLanes } from './herd-memory'
9
10
  import { runLive } from './live'
10
11
  import { parseOptions } from './options'
11
12
  import { isSupervisedChild } from './restart-decision'
12
- import { clockLocaleOf, readSettings } from './settings'
13
+ import { CLOCK_LOCALE, readSettings } from './settings'
13
14
  import { loadSnapshot, type Source } from './snapshot'
14
15
  import { superviseView } from './supervisor'
15
16
  import { frameText, paneWidth, renderSnapshot, startSelection } from './view'
16
17
 
17
18
  const printOnce = async (source: Source): Promise<void> => {
18
- const snapshot = await loadSnapshot(source, await fetchPullRequests(source.root))
19
+ const memories = recallLanes(source.root)
20
+ const snapshot = await loadSnapshot(source, await fetchPullRequests(source.root), memories)
21
+ rememberLanes(source.root, memories, snapshot.documents.laneOrder)
19
22
  const screen = renderSnapshot(
20
23
  snapshot,
21
24
  startSelection(snapshot, source.options),
22
25
  snapshot.errors,
23
26
  paneWidth(),
24
- clockLocaleOf(source.settings),
27
+ CLOCK_LOCALE,
25
28
  )
26
29
  console.log(frameText(screen, Boolean(process.stdout.isTTY)))
27
30
  }
28
31
 
32
+ /** One print, with why this board was chosen once on stderr: never in the frame (the watcher's `armed:` keeps it). */
33
+ const printOnceWithNotice = async (source: Source, notice: string | null): Promise<void> => {
34
+ if (notice) console.error(notice)
35
+ await printOnce(source)
36
+ }
37
+
29
38
  /**
30
39
  * `jh herd [<board.md>] [--lane <name>] [--once | --ensure]`: a live view of a board's lanes and their plan
31
40
  * progress; `--ensure` instead brings back the missing herd panes beside the coordinator and each lane.
@@ -48,8 +57,8 @@ export const run = async (args: string[], home: string = homedir()): Promise<num
48
57
  for (const line of lines.length > 0 ? lines : ['every herd pane is running']) console.log(line)
49
58
  return 0
50
59
  }
51
- const source = { root, sprintFile: picked.file, options, settings, terminal, boardNotice: picked.notice }
52
- if (options.once || !process.stdout.isTTY) await printOnce(source)
60
+ const source = { root, sprintFile: picked.file, options, settings, terminal }
61
+ if (options.once || !process.stdout.isTTY) await printOnceWithNotice(source, picked.notice)
53
62
  else if (isSupervisedChild(process.env)) runLive(source)
54
63
  else return await superviseView(args, await startedKit())
55
64
  return 0
@@ -6,34 +6,90 @@ export type PhaseRef = { planHref: string; phaseId: string }
6
6
  /** Per plan link and phase id, the state of its checkboxes as `1` (ticked) and `0` (open) in plan order. */
7
7
  export type TickSnapshot = Record<string, Record<string, string>>
8
8
 
9
+ /** When a phase's ticks or task lines last moved, in milliseconds, by `activityKey`. */
10
+ export type ActivityTimes = Readonly<Record<string, number>>
11
+
9
12
  /**
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.
13
+ * What the herd remembers of a lane between two reads: the ticks it saw, the phase whose ticks moved last, the
14
+ * identity of the newest board row (`newestRowIdentity`), which a new row changes, and when each phase last moved.
12
15
  */
13
- export type LaneMemory = { ticks: TickSnapshot; moved: PhaseRef | null; row: string }
16
+ export type LaneMemory = { ticks: TickSnapshot; moved: PhaseRef | null; row: string; activity?: ActivityTimes }
17
+
18
+ /** The key of a phase in `ActivityTimes`. */
19
+ export const activityKey = (planHref: string, phaseId: string): string => `${planHref}#${phaseId}`
20
+
21
+ /** Marks a tick string of the tasks alone; one remembered before, with the review gate in it, has no mark. */
22
+ const TASK_TICKS = 't:'
14
23
 
24
+ /**
25
+ * The ticks of each phase's tasks outside the review gate (D67): ticking the gate is bookkeeping, not work on the
26
+ * phase, so it moves nothing in the herd.
27
+ */
15
28
  export const tickSnapshotOf = (plans: PlansByHref): TickSnapshot =>
16
29
  Object.fromEntries(
17
30
  Object.entries(plans).map(([href, phases]) => [
18
31
  href,
19
32
  Object.fromEntries(
20
- phases.map((phase) => [phase.id, phase.items.map((item) => (item.done ? '1' : '0')).join('')]),
33
+ phases.map((phase) => [
34
+ phase.id,
35
+ `${TASK_TICKS}${phase.items
36
+ .filter((item) => !item.gate)
37
+ .map((item) => (item.done ? '1' : '0'))
38
+ .join('')}`,
39
+ ]),
21
40
  ),
22
41
  ]),
23
42
  )
24
43
 
25
44
  /**
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.
45
+ * The phases both reads have whose ticks differ, in plan order; a plan or phase new in `next`, or one remembered in
46
+ * the format before D67, has no state to move from. A task added to or removed from a phase changes its tick string
47
+ * and counts as movement.
28
48
  */
29
49
  export const movedPhases = (previous: TickSnapshot, next: TickSnapshot): PhaseRef[] =>
30
50
  Object.entries(next).flatMap(([planHref, phases]) =>
31
51
  Object.entries(phases).flatMap(([phaseId, ticks]) => {
32
52
  const before = previous[planHref]?.[phaseId]
33
- return before !== undefined && before !== ticks ? [{ planHref, phaseId }] : []
53
+ return before?.startsWith(TASK_TICKS) && before !== ticks ? [{ planHref, phaseId }] : []
34
54
  }),
35
55
  )
36
56
 
57
+ /** The phases `next` has in a plan `previous` read already but without them: task lines a lane wrote into a new phase. */
58
+ const addedPhases = (previous: TickSnapshot, next: TickSnapshot): PhaseRef[] =>
59
+ Object.entries(next).flatMap(([planHref, phases]) => {
60
+ const before = previous[planHref]
61
+ if (before === undefined) return []
62
+ return Object.keys(phases).flatMap((phaseId) => (phaseId in before ? [] : [{ planHref, phaseId }]))
63
+ })
64
+
65
+ /** When each plan file was last written, in milliseconds, by plan link; a plan without an entry is bounded by the clock. */
66
+ export type PlanModified = Readonly<Record<string, number>>
67
+
68
+ /**
69
+ * When each phase last moved, after a read at `now` whose ticks are `ticks`: a phase whose ticks or task lines changed
70
+ * since `previous`, or that is new in a plan `previous` read already, takes the time its plan file was last written
71
+ * (`modified`), bounded by `now`, so a change first seen long after it was made is not taken for a fresh one; the
72
+ * others keep their time. The first read knows no movement, so without a remembered read no phase has a time.
73
+ */
74
+ export const nextActivity = (
75
+ previous: LaneMemory | undefined,
76
+ ticks: TickSnapshot,
77
+ now: number,
78
+ modified: PlanModified = {},
79
+ ): ActivityTimes => {
80
+ if (!previous) return {}
81
+ const changed = [...movedPhases(previous.ticks, ticks), ...addedPhases(previous.ticks, ticks)]
82
+ return {
83
+ ...previous.activity,
84
+ ...Object.fromEntries(
85
+ changed.map(({ planHref, phaseId }) => [
86
+ activityKey(planHref, phaseId),
87
+ Math.min(now, modified[planHref] ?? now),
88
+ ]),
89
+ ),
90
+ }
91
+ }
92
+
37
93
  /** The newest board row of a lane as its step index and the phase ids it shows, a step `withWorkedPhases` added not counted. */
38
94
  export const newestRowIdentity = ({ steps }: LaneProgress): string => {
39
95
  const index = steps.reduce((last, { step }, stepIndex) => (step.withoutRow ? last : stepIndex), -1)
@@ -0,0 +1,51 @@
1
+ import type { PlanPhase } from '../plan-format'
2
+
3
+ /**
4
+ * A hint that a phase's task is being worked on: `taskIndex` is the task's index in the phase's checkboxes, `at` when
5
+ * it was seen, in milliseconds. No source writes one yet, so the heuristic of `currentTasks` decides alone.
6
+ */
7
+ export type TaskSignal = { phaseId: string; taskIndex: number; at: number }
8
+
9
+ /** A phase as `currentTasks` reads it: its id and checkboxes. */
10
+ export type TaskPhase = Pick<PlanPhase, 'id' | 'items'>
11
+
12
+ const isOpenTask = (phase: TaskPhase, index: number): boolean => phase.items[index]?.done === false
13
+
14
+ /** The task the newest signal younger than `staleAfterMs` points at, when it is still open; null otherwise. */
15
+ const signalledTask = (
16
+ phase: TaskPhase,
17
+ signals: readonly TaskSignal[],
18
+ now: number,
19
+ staleAfterMs: number,
20
+ ): number | null => {
21
+ const fresh = signals
22
+ .filter((signal) => signal.phaseId === phase.id && now - signal.at < staleAfterMs)
23
+ .filter((signal) => isOpenTask(phase, signal.taskIndex))
24
+ const newest = fresh.reduce<TaskSignal | null>((last, signal) => (last && last.at >= signal.at ? last : signal), null)
25
+ return newest?.taskIndex ?? null
26
+ }
27
+
28
+ /** The first open task outside the review gate; with only gate boxes open, the first of them; -1 when none is open. */
29
+ const firstOpenTask = ({ items }: TaskPhase): number => {
30
+ const outsideGate = items.findIndex((item) => !item.done && !item.gate)
31
+ return outsideGate === -1 ? items.findIndex((item) => !item.done) : outsideGate
32
+ }
33
+
34
+ /**
35
+ * The task each phase is on, by phase id, as its index in the phase's checkboxes: the open task a fresh signal names
36
+ * (the newest), else the first open task outside the review gate, else the first open gate box. A phase without an
37
+ * open task is absent. Derived only, never written into the plan.
38
+ */
39
+ export const currentTasks = (
40
+ plan: readonly TaskPhase[],
41
+ signals: readonly TaskSignal[],
42
+ now: number,
43
+ staleAfterMs: number,
44
+ ): ReadonlyMap<string, number> =>
45
+ new Map(
46
+ plan.flatMap((phase) => {
47
+ const first = firstOpenTask(phase)
48
+ if (first === -1) return []
49
+ return [[phase.id, signalledTask(phase, signals, now, staleAfterMs) ?? first] as const]
50
+ }),
51
+ )
@@ -1,11 +1,12 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { basename, join } from 'node:path'
3
- import { defaultCoordinatorName } from '../context/coordinator-name'
3
+ import { resolveCoordinatorName } from '../context/coordinator-name'
4
4
  import { parseSprintLanes } from '../plan-format'
5
- import { parseAgentList, runHerdr } from '../terminal/herdr'
5
+ import { parseAgentList, renameArguments, runHerdr } from '../terminal/herdr'
6
6
  import { errorText } from './error-text'
7
7
  import {
8
8
  agentPanesOf,
9
+ coordinatorLabelOf,
9
10
  coordinatorPaneOf,
10
11
  foregroundOf,
11
12
  missingCoordinatorNote,
@@ -46,21 +47,37 @@ const apply = (action: PaneAction): string => {
46
47
  }
47
48
  }
48
49
 
50
+ /** Labels the coordinator's pane; the label is decoration, so a failure is dropped. */
51
+ const labelCoordinator = (label: { pane: string; label: string } | null) => {
52
+ if (!label) return
53
+ try {
54
+ runHerdr(renameArguments(label.pane, label.label))
55
+ } catch {
56
+ // A pane herdr cannot rename keeps its old label; the views are what counts here.
57
+ }
58
+ }
59
+
49
60
  /**
50
61
  * `herd --ensure`: brings back every missing herd pane beside the coordinator and each lane of the board, and
51
62
  * leaves running ones alone, so it can run after every start. A lane gets its view once its agent runs. One line per
52
- * action or skipped target, none when every view runs.
63
+ * action or skipped target, none when every view runs. The coordinator's pane is labelled `🐾 Jack` on the way.
53
64
  */
54
65
  export const ensurePlanPanes = (root: string, settings: PlansSettings, boardFile: string): string[] => {
55
66
  const agents = runHerdr(['agent', 'list'])
67
+ const listed = parseAgentList(agents)
56
68
  const panes = panesWithForeground(agentPanesOf(agents))
57
- const coordinatorName = defaultCoordinatorName(basename(root))
69
+ const coordinatorName = resolveCoordinatorName(
70
+ basename(root),
71
+ listed.map((agent) => agent.name),
72
+ )
73
+ const coordinator = coordinatorPaneOf(panes, root, {
74
+ named: listed.find((agent) => agent.name === coordinatorName)?.pane ?? null,
75
+ caller: process.env.HERDR_PANE_ID ?? null,
76
+ })
77
+ labelCoordinator(coordinatorLabelOf(panes, coordinator))
58
78
  const { actions, notes } = planPaneActions({
59
79
  panes,
60
- coordinator: coordinatorPaneOf(panes, root, {
61
- named: parseAgentList(agents).find((agent) => agent.name === coordinatorName)?.pane ?? null,
62
- caller: process.env.HERDR_PANE_ID ?? null,
63
- }),
80
+ coordinator,
64
81
  lanes: lanesOf(root, settings, boardFile),
65
82
  root,
66
83
  worktrees: join(root, settings.worktreeDirectory),