jackherd 1.8.1 → 1.10.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 (261) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/INSTALL.md +3 -1
  3. package/README.md +75 -64
  4. package/dist/{chunk-OU2KALXP.js → chunk-5MQBCUQ5.js} +24 -4
  5. package/dist/config.d.ts +45 -4
  6. package/dist/config.js +84 -27
  7. package/dist/plan-format.d.ts +63 -7
  8. package/dist/plan-format.js +66 -12
  9. package/docs/workflow-dark.svg +67 -63
  10. package/docs/workflow.svg +67 -63
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/ask-rules.json +15 -0
  14. package/kit/hooks/hooks/approval-gate/index.ts +22 -4
  15. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  16. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  17. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  18. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  19. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  20. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  21. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  22. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  23. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  24. package/kit/hooks/hooks/merge-guard/decision.ts +127 -140
  25. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  26. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  27. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  28. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  29. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  30. package/kit/hooks/hooks/open-question/index.ts +26 -0
  31. package/kit/hooks/hooks/open-question/record.ts +135 -0
  32. package/kit/hooks/hooks/open-question/run.ts +52 -0
  33. package/kit/hooks/hooks/open-question/store.ts +39 -0
  34. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  35. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  36. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  37. package/kit/hooks/hooks/sent-file/store.ts +9 -0
  38. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  39. package/kit/hooks/hooks/shared/config.ts +7 -0
  40. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  41. package/kit/hooks/hooks/shared/files.ts +7 -0
  42. package/kit/hooks/hooks/shared/lane.ts +1 -1
  43. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  44. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  45. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  46. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  47. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  48. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  49. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  50. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  51. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  52. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  53. package/kit/hooks/hooks.json +21 -4
  54. package/kit/hooks/statusline.sh +1 -1
  55. package/kit/review/agents/skill-review.md +34 -0
  56. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  57. package/kit/workflow/skills/follow/SKILL.md +1 -8
  58. package/kit/workflow/skills/implement/SKILL.md +10 -6
  59. package/kit/workflow/skills/orchestrate/SKILL.md +213 -412
  60. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  61. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  62. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  63. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  64. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  65. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  66. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  67. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  68. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  69. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  70. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  71. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  72. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  73. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  74. package/kit/workflow/skills/plan/SKILL.md +6 -5
  75. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  76. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  77. package/package.json +4 -1
  78. package/recipes/README.md +2 -1
  79. package/recipes/sources.md +1 -1
  80. package/schemas/jackherd.schema.json +85 -7
  81. package/src/check/agent-text-reviewers.ts +23 -0
  82. package/src/check/command.ts +2 -0
  83. package/src/check/plan-structure-check.ts +49 -2
  84. package/src/check/plan.ts +8 -1
  85. package/src/check/schema.ts +9 -0
  86. package/src/check/skill-budget-defaults.ts +55 -0
  87. package/src/check/skill-budgets.ts +185 -0
  88. package/src/cli.ts +4 -0
  89. package/src/config/auto-window.ts +8 -0
  90. package/src/config/link-kinds.ts +7 -1
  91. package/src/config/schema.ts +80 -26
  92. package/src/context/fill.ts +12 -0
  93. package/src/context/session-log.ts +43 -1
  94. package/src/lane/close-board.ts +106 -0
  95. package/src/lane/close-check.ts +183 -0
  96. package/src/lane/close-command-line.ts +11 -0
  97. package/src/lane/close-command.ts +217 -0
  98. package/src/lane/close-options.ts +43 -0
  99. package/src/lane/close-survey.ts +83 -0
  100. package/src/lane/close.ts +141 -0
  101. package/src/lane/command.ts +13 -0
  102. package/src/lane-prompt/build.ts +74 -0
  103. package/src/lane-prompt/command.ts +87 -0
  104. package/src/lane-prompt/input.ts +116 -0
  105. package/src/lane-prompt/options.ts +68 -0
  106. package/src/lane-prompt/texts.ts +101 -0
  107. package/src/plan-format/clock.ts +12 -0
  108. package/src/plan-format/dependencies.ts +9 -9
  109. package/src/plan-format/index.ts +1 -0
  110. package/src/plan-format/lanes.ts +30 -5
  111. package/src/plan-format/short-names.ts +71 -0
  112. package/src/plan-format/terminal-text.ts +4 -0
  113. package/src/plan-format/waiting.ts +53 -0
  114. package/src/plans/active-tasks.ts +60 -0
  115. package/src/plans/appointment-rows.ts +59 -0
  116. package/src/plans/ask-coordinator.ts +77 -0
  117. package/src/plans/ask-target.ts +25 -0
  118. package/src/plans/child-stop.ts +51 -0
  119. package/src/plans/closable-records.ts +34 -0
  120. package/src/plans/command.ts +3 -2
  121. package/src/plans/current-phase.ts +17 -7
  122. package/src/plans/current-task.ts +63 -5
  123. package/src/plans/fetch.ts +26 -3
  124. package/src/plans/git.ts +19 -5
  125. package/src/plans/herd-claim.ts +173 -0
  126. package/src/plans/herd-memory.ts +36 -25
  127. package/src/plans/lane-plans.ts +44 -13
  128. package/src/plans/lane-standing.ts +9 -4
  129. package/src/plans/lane-summary.ts +367 -92
  130. package/src/plans/lane-tickets.ts +15 -0
  131. package/src/plans/layout-phase.ts +80 -19
  132. package/src/plans/layout-summary.ts +163 -74
  133. package/src/plans/layout.ts +472 -114
  134. package/src/plans/link-chips.ts +1 -0
  135. package/src/plans/live.ts +119 -10
  136. package/src/plans/phase-ids.ts +8 -0
  137. package/src/plans/phase-models.ts +1 -1
  138. package/src/plans/phase-ranges.ts +45 -0
  139. package/src/plans/phase-source.ts +15 -0
  140. package/src/plans/plan-cache.ts +28 -0
  141. package/src/plans/plan-heads.ts +54 -0
  142. package/src/plans/pull-requests.ts +23 -2
  143. package/src/plans/question-records.ts +48 -0
  144. package/src/plans/questions-read.ts +13 -0
  145. package/src/plans/read-cache.ts +8 -0
  146. package/src/plans/record-id.ts +4 -0
  147. package/src/plans/restart-decision.ts +13 -4
  148. package/src/plans/selection.ts +117 -34
  149. package/src/plans/sent-files-read.ts +13 -0
  150. package/src/plans/sent-files.ts +114 -0
  151. package/src/plans/settings.ts +2 -1
  152. package/src/plans/signalled-phase.ts +42 -0
  153. package/src/plans/signals-read.ts +62 -0
  154. package/src/plans/snapshot.ts +279 -60
  155. package/src/plans/sprint-export.ts +11 -11
  156. package/src/plans/sprint-page.ts +5 -5
  157. package/src/plans/subagent-phases.ts +95 -0
  158. package/src/plans/subagent-tasks.ts +51 -0
  159. package/src/plans/subagents-read.ts +131 -0
  160. package/src/plans/subagents.ts +190 -0
  161. package/src/plans/supervisor.ts +14 -4
  162. package/src/plans/symbols.ts +3 -0
  163. package/src/plans/task-name.ts +34 -0
  164. package/src/plans/texts.ts +36 -14
  165. package/src/plans/unlisted-lanes.ts +10 -5
  166. package/src/plans/view.ts +20 -12
  167. package/src/plans/waiting-click.ts +231 -0
  168. package/src/plans/waiting-records.ts +39 -0
  169. package/src/plans/waiting-window.ts +8 -0
  170. package/src/preview/command.ts +105 -0
  171. package/src/preview/embed.ts +209 -0
  172. package/src/preview/images.ts +89 -0
  173. package/src/preview/open.ts +12 -0
  174. package/src/preview/options.ts +32 -0
  175. package/src/preview/page.ts +89 -0
  176. package/src/preview/render.ts +41 -0
  177. package/src/settled/board-row.ts +1 -2
  178. package/src/shared/is-record.ts +2 -0
  179. package/src/shared/private-location.ts +89 -0
  180. package/src/shared/private-records.ts +48 -0
  181. package/src/shared/stat-cache.ts +48 -0
  182. package/src/sync/kit-source.ts +29 -10
  183. package/src/sync/kit.ts +2 -0
  184. package/src/sync/manifest.ts +2 -0
  185. package/src/sync/plan-ask-rules.ts +94 -0
  186. package/src/sync/plan-hooks.ts +2 -0
  187. package/src/sync/render.ts +3 -3
  188. package/src/sync/settings-hooks.ts +4 -1
  189. package/src/sync/sync-plan.ts +1 -0
  190. package/src/sync/sync.ts +24 -3
  191. package/src/task-signals/agent.ts +15 -0
  192. package/src/task-signals/claude-read.ts +89 -0
  193. package/src/task-signals/claude-signals.ts +112 -0
  194. package/src/task-signals/command.ts +71 -0
  195. package/src/task-signals/fixtures/session.jsonl +7 -0
  196. package/src/task-signals/matcher.ts +96 -0
  197. package/src/task-signals/record-store.ts +40 -0
  198. package/src/task-signals/record.ts +68 -0
  199. package/src/task-signals/text.ts +13 -0
  200. package/src/task-signals/types.ts +14 -0
  201. package/src/terminal/open.ts +13 -6
  202. package/src/terminal/text.ts +16 -4
  203. package/src/terminal/width.ts +2 -1
  204. package/src/version/skill-diff.ts +46 -0
  205. package/src/vocabulary/index.ts +44 -6
  206. package/src/waiting/command.ts +50 -0
  207. package/src/watch/appointments.ts +2 -4
  208. package/src/watch/approvals.ts +2 -2
  209. package/src/watch/asking-lanes.ts +40 -0
  210. package/src/watch/base-tip.ts +11 -0
  211. package/src/watch/command.ts +401 -90
  212. package/src/watch/failed-tests.ts +56 -10
  213. package/src/watch/failure-read.ts +18 -6
  214. package/src/watch/failures.ts +29 -5
  215. package/src/watch/feedback.ts +1 -1
  216. package/src/watch/gh-process.ts +9 -3
  217. package/src/watch/heartbeat-progress.ts +6 -18
  218. package/src/watch/json-guards.ts +0 -3
  219. package/src/watch/kit-update.ts +28 -9
  220. package/src/watch/lane-fill.ts +1 -1
  221. package/src/watch/lane-model.ts +1 -1
  222. package/src/watch/lane-name.ts +6 -0
  223. package/src/watch/lane-parallel.ts +1 -1
  224. package/src/watch/lane-progress.ts +58 -14
  225. package/src/watch/lane-report-file.ts +20 -5
  226. package/src/watch/lane-report.ts +37 -5
  227. package/src/watch/lane-rest.ts +28 -0
  228. package/src/watch/lane-sources.ts +31 -3
  229. package/src/watch/main-checkout.ts +34 -0
  230. package/src/watch/plan-badges-read.ts +13 -7
  231. package/src/watch/plan-badges.ts +9 -2
  232. package/src/watch/previews.ts +1 -1
  233. package/src/watch/pull-requests.ts +8 -3
  234. package/src/watch/reference-paths.ts +87 -0
  235. package/src/watch/running-subagents.ts +43 -0
  236. package/src/watch/skill-change.ts +124 -32
  237. package/src/watch/source-change.ts +48 -0
  238. package/src/watch/source-path.ts +1 -1
  239. package/src/watch/sources.ts +7 -4
  240. package/src/watch/stream-output.ts +14 -8
  241. package/src/watch/task-parallel-auto.ts +208 -0
  242. package/src/watch/task-parallel-data-paths.ts +46 -0
  243. package/src/watch/task-parallel-input.ts +108 -0
  244. package/src/watch/task-parallel-read.ts +151 -0
  245. package/src/watch/task-parallel-touched.ts +39 -0
  246. package/src/watch/task-parallel.ts +235 -0
  247. package/src/watch/task-signal-missing.ts +69 -0
  248. package/src/watch/task-signal-read.ts +29 -0
  249. package/src/watch/texts.ts +10 -0
  250. package/src/watch/update-available.ts +6 -3
  251. package/src/watch/waiting-line.ts +19 -0
  252. package/src/watch/watch-events.ts +36 -1
  253. package/src/watch/watcher-lock.ts +25 -29
  254. package/src/watch/workflow-runs.ts +2 -1
  255. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  256. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  257. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  258. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  259. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  260. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  261. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -1,39 +1,51 @@
1
1
  import {
2
2
  isPhaseFinished,
3
3
  isPhaseOpen,
4
- isTickedUnmerged,
5
4
  type LaneProgress,
6
5
  type PhaseProgress,
7
6
  type PlanPhase,
8
7
  type PlansByHref,
9
- parseDependencyTable,
10
8
  phaseProgress,
11
- phaseWaves,
12
9
  } from '../plan-format'
13
- import type { PlanLanguage } from '../vocabulary'
10
+ import { MS_PER_MINUTE } from '../plan-format/clock'
11
+ import type { TaskSignal } from '../task-signals/types'
12
+ import { type ActiveTask, assignSignals, freshSignals, type UnplannedTask } from './active-tasks'
14
13
  import { type ActivityTimes, activityKey } from './current-phase'
15
- import { currentTasks, type TaskSignal } from './current-task'
14
+ import { currentTasks, type TaskActivity } from './current-task'
15
+ import { isNamedBy } from './phase-ids'
16
+ import { signalledPhase } from './signalled-phase'
17
+ import {
18
+ type PhaselessReview,
19
+ type ReviewType,
20
+ type SubagentPhase,
21
+ type SubagentPhaseRun,
22
+ subagentPhases,
23
+ } from './subagent-phases'
24
+ import { type DescribedSubagent, subagentTaskPlacements } from './subagent-tasks'
16
25
 
17
26
  /**
18
- * A phase the lane works on now: an open one whose ticks or task lines moved less than `STALE_ACTIVITY_MINUTES` ago.
19
- * `stepIndex` is the newest step of the lane that shows it, null for a phase no step shows; `minutesAgo` the whole
20
- * minutes since that movement.
27
+ * A phase the lane works on now: an open one ticked less than `STALE_ACTIVITY_MINUTES` ago, one an agent reports, or
28
+ * one with a ticked task. `stepIndex` is the newest step of the lane that shows it, null for a phase no step shows;
29
+ * `minutesAgo` the whole minutes since its last tick or report, null when the herd saw neither.
21
30
  */
22
31
  export type ActivePhase = {
23
32
  phase: PhaseProgress
24
33
  stepIndex: number | null
25
- minutesAgo: number
26
- /** The index of the task the phase is on in its checkboxes (`currentTasks`); null when none is open. */
27
- taskIndex: number | null
34
+ minutesAgo: number | null
35
+ /**
36
+ * The tasks the phase is on: the ones agents reported, else the one `currentTasks` guesses (marked `guessed`); empty
37
+ * when no task is open.
38
+ */
39
+ tasks: ActiveTask[]
28
40
  }
29
41
 
30
- /** The phases that come next: the open ones of the next wave, `wave` its 1-based number, null without a dependency table. */
31
- export type NextPhases = { wave: number | null; phaseIds: string[] }
42
+ /** A phase with the step that shows it (null for none), which keys its row (`phaseKey`). */
43
+ export type PlacedPhase = { phase: PhaseProgress; stepIndex: number | null }
32
44
 
33
- /** Open phases after the next ones; `planTitle` is null for the lane's current plan, the board's title for another. */
34
- export type LaterPhases = { planTitle: string | null; phaseIds: string[] }
45
+ /** Open phases that do not run, by number; `planTitle` is null for the lane's current plan, the board's title for another. */
46
+ export type WaitingPhases = { planTitle: string | null; phaseIds: string[] }
35
47
 
36
- export type FinishedPhase = { id: string; unmerged: boolean }
48
+ export type FinishedPhase = { id: string }
37
49
 
38
50
  /** A phase the lane's board rows name that has nothing to run: `missing` when the plan lacks it, else it has no checkbox. */
39
51
  export type UnwrittenPhase = { id: string; missing: boolean }
@@ -41,39 +53,56 @@ export type UnwrittenPhase = { id: string; missing: boolean }
41
53
  /** Checkboxes and phases over every plan of the lane, a phase counted when it has a checkbox. */
42
54
  export type PlanTally = { done: number; total: number; phasesDone: number; phases: number }
43
55
 
44
- /** A lane as the herd sums it up: what runs now, what comes next and after, what is finished, and how far it is. */
56
+ /** A lane as the herd sums it up: what runs now, what waits, what is finished, and how far it is. */
45
57
  export type LaneSummary = {
46
- /** The running phases, the one moved last first. */
58
+ /** The running phases by number, never by recency, so a new movement reorders nothing. */
47
59
  active: ActivePhase[]
48
60
  /** The open phase of the current plan moved last, at any age; null while none has a time. */
49
61
  latest: PhaseProgress | null
50
- next: NextPhases | null
51
- later: LaterPhases[]
62
+ /** The open phase the lane's newest report or its branch names (`signalledPhase`); it runs and the lane line names it. */
63
+ signalled: PhaseProgress | null
64
+ /** The open phases of the current plan that do not run, then the open phases of the lane's other plans. */
65
+ waiting: WaitingPhases[]
66
+ /** The phases behind `waiting`, in its order, for the rows `next` opens to. */
67
+ waitingPhases: PlacedPhase[]
52
68
  finished: FinishedPhase[]
69
+ /** The phases behind `finished`, for the rows `✓` opens to. */
70
+ finishedPhases: PlacedPhase[]
53
71
  unwritten: UnwrittenPhase[]
54
72
  tally: PlanTally
73
+ /** What agents report doing that no open task of the plan names. */
74
+ unplanned: UnplannedTask[]
75
+ /** What the running subagents without a phase in their description work on, as they described it. */
76
+ subagentTasks: string[]
55
77
  }
56
78
 
57
79
  /**
58
- * `phases` are the lane's plans as it reads them (the base merged with its worktree), `texts` their texts for the
59
- * dependency tables; a plan missing from `phases` falls back to the phases the lane's steps show.
80
+ * `phases` are the lane's plans as it reads them (the base merged with its worktree); a plan missing from `phases`
81
+ * falls back to the phases the lane's steps show.
60
82
  */
61
83
  export type SummaryInput = {
62
84
  progress: LaneProgress
63
85
  phases: Readonly<PlansByHref>
64
- texts: Readonly<Record<string, string>>
65
86
  activity: ActivityTimes
66
- /** The language the plans were written in, for reading their dependency tables; the herd's words are English. */
67
- language: PlanLanguage
68
- /** Hints at the task a phase is on, by phase id; none when absent, and no source writes one yet. */
69
- signals?: readonly TaskSignal[]
87
+ /** Hints at the task a phase is on, guessed from the words of the lane's agents; the fallback while nothing is reported. */
88
+ guesses?: readonly TaskActivity[]
89
+ /** What the lane's agents report doing (task lists, `jh task`), matched to open tasks of the current plan. */
90
+ reported?: readonly TaskSignal[]
91
+ /** The phases the descriptions of the lane's running subagents name; a signal like a report, never a tick. */
92
+ subagents?: readonly SubagentPhaseRun[]
93
+ /** The descriptions of the running subagents that name no phase; the folded lane shows them as tasks. */
94
+ subagentTasks?: readonly string[]
95
+ /** The running reviews that name no phase; they sit on the `signalled` phase, else show as tasks like `subagentTasks`. */
96
+ phaselessReviews?: readonly PhaselessReview[]
97
+ /** The running subagents that are no review; one whose description starts with a task's short name sits on it (`subagentTaskPlacements`). */
98
+ describedSubagents?: readonly DescribedSubagent[]
99
+ /** The branch checked out in the lane's worktree; a phase it names (`branchPhaseId`) is current while no report names one. */
100
+ branch?: string | null
70
101
  }
71
102
 
72
- /** Minutes without a tick or a new task line after which a phase no longer runs. */
103
+ /** Minutes without a tick after which a phase no longer runs, unless an agent reports it. */
73
104
  export const STALE_ACTIVITY_MINUTES = 20
74
105
 
75
- const MINUTE = 60_000
76
-
77
106
  const isWritten = ({ total }: PhaseProgress): boolean => total > 0
78
107
 
79
108
  const planHrefs = ({ steps }: LaneProgress): string[] => [
@@ -92,9 +121,21 @@ const stepPhases = ({ steps }: LaneProgress, href: string): PhaseProgress[] => {
92
121
  return shown.filter((phase, index) => shown.findIndex(({ id }) => id === phase.id) === index)
93
122
  }
94
123
 
124
+ /** The ids the lane's steps on `href` name, a row's repeats and the phases it worked on without a row included. */
125
+ const namedIds = ({ steps }: LaneProgress, href: string): Set<string> =>
126
+ new Set(
127
+ steps.flatMap(({ step }) => (step.planHref === href ? [...step.phaseIds, ...(step.namedPhaseIds ?? [])] : [])),
128
+ )
129
+
130
+ /**
131
+ * The phases of `href` the lane names, in plan order: another lane's phases of the same plan are not its own, however
132
+ * lately they moved.
133
+ */
95
134
  const phasesOf = (input: SummaryInput, href: string): PhaseProgress[] => {
96
135
  const plan: readonly PlanPhase[] | undefined = input.phases[href]
97
- return plan ? plan.map(({ id }) => phaseProgress(plan, id)) : stepPhases(input.progress, href)
136
+ if (!plan) return stepPhases(input.progress, href)
137
+ const named = namedIds(input.progress, href)
138
+ return plan.filter(({ id }) => isNamedBy(id, named)).map(({ id }) => phaseProgress(plan, id))
98
139
  }
99
140
 
100
141
  const newestStepShowing = ({ steps }: LaneProgress, href: string, id: string): number | null => {
@@ -106,53 +147,209 @@ const newestStepShowing = ({ steps }: LaneProgress, href: string, id: string): n
106
147
 
107
148
  type TimedPhase = { phase: PhaseProgress; minutesAgo: number }
108
149
 
150
+ type RunningPhase = { phase: PhaseProgress; minutesAgo: number | null }
151
+
152
+ /**
153
+ * A phase whose tasks outside the review gate are partly ticked: work on it has begun and goes on. One with only its
154
+ * review gate open waits for its review, as in `jh check --parallel`.
155
+ */
156
+ const isStarted = ({ items }: PhaseProgress): boolean => {
157
+ const tasks = items.filter(({ gate }) => !gate)
158
+ return tasks.some(({ done }) => done) && tasks.some(({ done }) => !done)
159
+ }
160
+
161
+ const minutesSince = (at: number, now: number): number => Math.max(Math.floor((now - at) / MS_PER_MINUTE), 0)
162
+
109
163
  /** The open phases with a time of their last movement, the one moved last first; a phase without a time is absent. */
110
164
  const timedPhases = (input: SummaryInput, href: string, phases: PhaseProgress[], now: number): TimedPhase[] =>
111
165
  phases
112
166
  .filter(isPhaseOpen)
113
167
  .flatMap((phase) => {
114
168
  const at = input.activity[activityKey(href, phase.id)]
115
- return at === undefined ? [] : [{ phase, minutesAgo: Math.max(Math.floor((now - at) / MINUTE), 0) }]
169
+ return at === undefined ? [] : [{ phase, minutesAgo: minutesSince(at, now) }]
116
170
  })
117
171
  .sort((first, second) => first.minutesAgo - second.minutesAgo)
118
172
 
119
- /** The timed phases that moved less than `STALE_ACTIVITY_MINUTES` ago, each with the task it is on. */
120
- const activeOf = (input: SummaryInput, href: string, timed: TimedPhase[], now: number): ActivePhase[] => {
121
- const running = timed.filter(({ minutesAgo }) => minutesAgo < STALE_ACTIVITY_MINUTES)
122
- const tasks = currentTasks(
123
- running.map(({ phase }) => phase),
124
- input.signals ?? [],
125
- now,
126
- STALE_ACTIVITY_MINUTES * MINUTE,
127
- )
128
- return running.map(({ phase, minutesAgo }) => ({
129
- phase,
130
- stepIndex: newestStepShowing(input.progress, href, phase.id),
131
- minutesAgo,
132
- taskIndex: tasks.get(phase.id) ?? null,
133
- }))
173
+ const newest = (times: readonly number[]): number | null => (times.length > 0 ? Math.max(...times) : null)
174
+
175
+ const isListed = (listed: readonly RunningPhase[], phase: PhaseProgress): boolean =>
176
+ listed.some((each) => each.phase.id === phase.id)
177
+
178
+ /** What names a phase as running besides its ticks, by phase id: the tasks agents reported and the subagents at work. */
179
+ type PhaseSignals = {
180
+ reported: ReadonlyMap<string, ActiveTask[]>
181
+ subagents: ReadonlyMap<string, SubagentPhase>
134
182
  }
135
183
 
184
+ /** When the newest report or running subagent of a phase began, in milliseconds; null for none. */
185
+ const reportedAt = (id: string, { reported, subagents }: PhaseSignals): number | null => {
186
+ const subagent = subagents.get(id)
187
+ return newest([
188
+ ...(reported.get(id) ?? []).flatMap((task) => task.startedAt ?? []),
189
+ ...(subagent === undefined ? [] : [subagent.at]),
190
+ ])
191
+ }
192
+
193
+ /** The open phases a reported task or a running subagent names that are not `listed` yet, aged from the newest of them. */
194
+ const reportedPhases = (
195
+ phases: PhaseProgress[],
196
+ signals: PhaseSignals,
197
+ listed: readonly RunningPhase[],
198
+ now: number,
199
+ ): RunningPhase[] =>
200
+ phases
201
+ .filter(
202
+ (phase) =>
203
+ isPhaseOpen(phase) &&
204
+ (signals.reported.has(phase.id) || signals.subagents.has(phase.id)) &&
205
+ !isListed(listed, phase),
206
+ )
207
+ .map((phase) => {
208
+ const startedAt = reportedAt(phase.id, signals)
209
+ return { phase, minutesAgo: startedAt === null ? 0 : minutesSince(startedAt, now) }
210
+ })
211
+
136
212
  /**
137
- * The open phases of the first wave that holds one not running; without a dependency table, phases run one after the
138
- * other and the next is the first open one not running. Null when nothing is left, or no wave holds what is left.
213
+ * Whether a text names the review type as a word (`/security-audit`, `` `uncle-bob` ``): `review` is not named by
214
+ * `skill-review` or `Review-Gate`. The types hold no character a pattern reads specially.
139
215
  */
140
- const nextOf = (
141
- input: SummaryInput,
142
- href: string,
216
+ const namesReview = (text: string, review: ReviewType): boolean =>
217
+ new RegExp(`(?<![\\w-])${review}(?![\\w-])`).test(text)
218
+
219
+ /**
220
+ * The review gate box a running review sits on, which gets its `▶` like a reported task: the open one that names the
221
+ * review, else the first open one. A phase whose gate is ticked or that has none shows no arrow.
222
+ */
223
+ const reviewTask = ({ items }: PhaseProgress, review: ReviewType, at: number): ActiveTask[] => {
224
+ const isOpenGate = (item: PhaseProgress['items'][number]): boolean => item.gate === true && !item.done
225
+ const named = items.findIndex((item) => isOpenGate(item) && namesReview(item.text, review))
226
+ const index = named === -1 ? items.findIndex(isOpenGate) : named
227
+ return index === -1 ? [] : [{ index, agent: null, guessed: false, startedAt: at }]
228
+ }
229
+
230
+ /** Adds `tasks` to the phase's tasks in `merged`, each task once. */
231
+ const addTasks = (merged: Map<string, ActiveTask[]>, phaseId: string, tasks: readonly ActiveTask[]): void => {
232
+ const known = merged.get(phaseId) ?? []
233
+ merged.set(phaseId, [...known, ...tasks.filter(({ index }) => !known.some((task) => task.index === index))])
234
+ }
235
+
236
+ /** The reported tasks with the gate box each running review of a phase sits on added, unless a report names it already. */
237
+ const withReviewTasks = (
238
+ reported: ReadonlyMap<string, ActiveTask[]>,
143
239
  phases: PhaseProgress[],
144
- running: ReadonlySet<string>,
145
- ): NextPhases | null => {
146
- const waiting = phases.filter((phase) => isPhaseOpen(phase) && !running.has(phase.id)).map(({ id }) => id)
147
- const text = input.texts[href]
148
- const rows = text === undefined ? null : parseDependencyTable(text, input.language)
149
- if (rows === null) return waiting.length > 0 ? { wave: null, phaseIds: [waiting[0]] } : null
150
- const done = new Set(phases.filter(isPhaseFinished).map(({ id }) => id))
151
- const waves = phaseWaves(rows, done)
152
- const index = waves.findIndex((wave) => wave.some((id) => waiting.includes(id)))
153
- return index === -1 ? null : { wave: index + 1, phaseIds: waves[index].filter((id) => waiting.includes(id)) }
240
+ subagents: ReadonlyMap<string, SubagentPhase>,
241
+ ): Map<string, ActiveTask[]> => {
242
+ const merged = new Map(reported)
243
+ for (const phase of phases) {
244
+ const found = subagents.get(phase.id)
245
+ if (!found) continue
246
+ for (const review of found.reviews) addTasks(merged, phase.id, reviewTask(phase, review, found.at))
247
+ }
248
+ return merged
249
+ }
250
+
251
+ /** The reported tasks with the tasks the subagents sit on added (`subagentTaskPlacements`), unless a report names them already. */
252
+ const withTasks = (
253
+ reported: ReadonlyMap<string, ActiveTask[]>,
254
+ added: ReadonlyMap<string, ActiveTask[]>,
255
+ ): Map<string, ActiveTask[]> => {
256
+ const merged = new Map(reported)
257
+ for (const [phaseId, tasks] of added) addTasks(merged, phaseId, tasks)
258
+ return merged
259
+ }
260
+
261
+ /** The signalled phase when a review can sit on it: one with an open gate box, else none. */
262
+ const reviewPhaseOf = (signalled: PhaseProgress | null): PhaseProgress | null =>
263
+ signalled?.items.some((item) => item.gate && !item.done) ? signalled : null
264
+
265
+ /**
266
+ * The runs of the lane's subagents, its reviews that name no phase placed on the `signalled` phase while its gate has
267
+ * an open box; otherwise they stay unplaced and show as tasks (`unplacedTasks`), so a running review never vanishes.
268
+ */
269
+ const placedRuns = (input: SummaryInput, signalled: PhaseProgress | null): SubagentPhaseRun[] => {
270
+ const phase = reviewPhaseOf(signalled)
271
+ return [
272
+ ...(input.subagents ?? []),
273
+ ...(phase === null
274
+ ? []
275
+ : (input.phaselessReviews ?? []).map(({ review, at }) => ({ ids: [phase.id], review, at }))),
276
+ ]
277
+ }
278
+
279
+ /**
280
+ * The descriptions of the subagents that name no phase and sit on no task (`placed`), then those of the reviews no
281
+ * signalled phase takes.
282
+ */
283
+ const unplacedTasks = (input: SummaryInput, signalled: PhaseProgress | null, placed: readonly string[]): string[] => [
284
+ ...(input.subagentTasks ?? []).filter((description) => !placed.includes(description)),
285
+ ...(reviewPhaseOf(signalled) === null
286
+ ? (input.phaselessReviews ?? []).flatMap(({ description }) => (description === null ? [] : [description]))
287
+ : []),
288
+ ]
289
+
290
+ /** The open phases with a ticked task (`isStarted`) that are not `listed` yet, aged from their last tick when one is known. */
291
+ const startedPhases = (phases: PhaseProgress[], timed: TimedPhase[], listed: readonly RunningPhase[]): RunningPhase[] =>
292
+ phases
293
+ .filter((phase) => isPhaseOpen(phase) && isStarted(phase) && !isListed(listed, phase))
294
+ .map((phase) => ({ phase, minutesAgo: timed.find((each) => each.phase.id === phase.id)?.minutesAgo ?? null }))
295
+
296
+ /** The signalled phase when no other source lists it yet, aged from its last tick when one is known. */
297
+ const signalledRunning = (
298
+ signalled: PhaseProgress | null,
299
+ timed: TimedPhase[],
300
+ listed: readonly RunningPhase[],
301
+ ): RunningPhase[] =>
302
+ signalled && !isListed(listed, signalled)
303
+ ? [{ phase: signalled, minutesAgo: timed.find((each) => each.phase.id === signalled.id)?.minutesAgo ?? null }]
304
+ : []
305
+
306
+ /**
307
+ * The phases that run: the timed ones ticked less than `STALE_ACTIVITY_MINUTES` ago, the phases a reported task or a
308
+ * running subagent names, the signalled phase (`signalledPhase`), then every other open phase with a ticked task
309
+ * (`isStarted`), with the age of its last tick when one is known, else none. A phase only written down, without a
310
+ * tick, a report or the lane's branch naming it, waits.
311
+ */
312
+ const runningPhases = (
313
+ timed: TimedPhase[],
314
+ phases: PhaseProgress[],
315
+ signals: PhaseSignals & { signalled: PhaseProgress | null },
316
+ now: number,
317
+ ): RunningPhase[] => {
318
+ const moved = timed.filter(({ minutesAgo }) => minutesAgo < STALE_ACTIVITY_MINUTES)
319
+ const reported = [...moved, ...reportedPhases(phases, signals, moved, now)]
320
+ const listed = [...reported, ...signalledRunning(signals.signalled, timed, reported)]
321
+ return [...listed, ...startedPhases(phases, timed, listed)]
154
322
  }
155
323
 
324
+ /**
325
+ * The task the herd guesses for each of the `phases` from the words of the lane's agents (`currentTasks`); the caller
326
+ * passes no phase while an agent reports, so a lane with a report guesses nothing.
327
+ */
328
+ const guessedTasks = (input: SummaryInput, phases: PhaseProgress[], now: number): ReadonlyMap<string, number> =>
329
+ currentTasks(phases, input.guesses ?? [], now, STALE_ACTIVITY_MINUTES * MS_PER_MINUTE)
330
+
331
+ /** The running phases, each with the tasks agents reported on it, else its task in `guessed`, marked as guessed. */
332
+ const activeOf = (
333
+ input: SummaryInput,
334
+ href: string,
335
+ running: RunningPhase[],
336
+ reported: ReadonlyMap<string, ActiveTask[]>,
337
+ guessed: ReadonlyMap<string, number>,
338
+ ): ActivePhase[] =>
339
+ running
340
+ .map(({ phase, minutesAgo }) => {
341
+ const guess = guessed.get(phase.id)
342
+ return {
343
+ phase,
344
+ stepIndex: newestStepShowing(input.progress, href, phase.id),
345
+ minutesAgo,
346
+ tasks:
347
+ reported.get(phase.id) ??
348
+ (guess === undefined ? [] : [{ index: guess, agent: null, guessed: true, startedAt: null }]),
349
+ }
350
+ })
351
+ .sort((first, second) => first.phase.id.localeCompare(second.phase.id, undefined, { numeric: true }))
352
+
156
353
  const tallyOf = (phases: PhaseProgress[]): PlanTally => ({
157
354
  done: phases.reduce((sum, phase) => sum + phase.done, 0),
158
355
  total: phases.reduce((sum, phase) => sum + phase.total, 0),
@@ -164,59 +361,137 @@ const titleOf = ({ steps }: LaneProgress, href: string): string =>
164
361
  steps.find(({ step }) => step.planHref === href)?.step.planTitle ?? href
165
362
 
166
363
  /** The open phases of the lane's other plans, each plan by its title. */
167
- const otherPlans = (input: SummaryInput, hrefs: string[]): LaterPhases[] =>
364
+ const otherPlans = (input: SummaryInput, hrefs: string[]): WaitingPhases[] =>
168
365
  hrefs.flatMap((href) => {
169
366
  const open = phasesOf(input, href).filter(isPhaseOpen)
170
367
  return open.length > 0 ? [{ planTitle: titleOf(input.progress, href), phaseIds: open.map(({ id }) => id) }] : []
171
368
  })
172
369
 
173
- /** The open phases of the current plan that neither run nor come next, then the open phases of the lane's other plans. */
174
- const laterOf = (
370
+ const byPhaseNumber = (first: PhaseProgress, second: PhaseProgress): number =>
371
+ first.id.localeCompare(second.id, undefined, { numeric: true })
372
+
373
+ /** The open phases of the current plan that do not run, by number. */
374
+ const waitingIn = (phases: PhaseProgress[], running: ReadonlySet<string>): PhaseProgress[] =>
375
+ phases.filter((phase) => isPhaseOpen(phase) && !running.has(phase.id)).sort(byPhaseNumber)
376
+
377
+ const otherHrefs = (input: SummaryInput, href: string): string[] =>
378
+ planHrefs(input.progress).filter((each) => each !== href)
379
+
380
+ /** The open phases of the current plan that do not run, by number, then the open phases of the lane's other plans. */
381
+ const waitingOf = (
175
382
  input: SummaryInput,
176
383
  href: string,
177
384
  phases: PhaseProgress[],
178
- upcoming: ReadonlySet<string>,
179
- ): LaterPhases[] => {
180
- const waiting = phases
181
- .filter((phase) => isPhaseOpen(phase) && !upcoming.has(phase.id))
182
- .map(({ id }) => id)
183
- .sort((first, second) => first.localeCompare(second, undefined, { numeric: true }))
184
- const others = otherPlans(
185
- input,
186
- planHrefs(input.progress).filter((each) => each !== href),
187
- )
188
- return [...(waiting.length > 0 ? [{ planTitle: null, phaseIds: waiting }] : []), ...others]
385
+ running: ReadonlySet<string>,
386
+ ): WaitingPhases[] => {
387
+ const waiting = waitingIn(phases, running).map(({ id }) => id)
388
+ return [
389
+ ...(waiting.length > 0 ? [{ planTitle: null, phaseIds: waiting }] : []),
390
+ ...otherPlans(input, otherHrefs(input, href)),
391
+ ]
189
392
  }
190
393
 
191
- /** The finished phases of the current plan, each marked while its ticks are not merged. */
394
+ const placedIn =
395
+ (progress: LaneProgress, href: string) =>
396
+ (phase: PhaseProgress): PlacedPhase => ({ phase, stepIndex: newestStepShowing(progress, href, phase.id) })
397
+
398
+ /** The phases behind `waitingOf`, in its order, each with the step that shows it. */
399
+ const waitingPlaced = (
400
+ input: SummaryInput,
401
+ href: string,
402
+ phases: PhaseProgress[],
403
+ running: ReadonlySet<string>,
404
+ ): PlacedPhase[] => [
405
+ ...waitingIn(phases, running).map(placedIn(input.progress, href)),
406
+ ...otherHrefs(input, href).flatMap((other) =>
407
+ phasesOf(input, other).filter(isPhaseOpen).map(placedIn(input.progress, other)),
408
+ ),
409
+ ]
410
+
411
+ /** The finished phases of the current plan. */
192
412
  const finishedOf = (phases: PhaseProgress[]): FinishedPhase[] =>
193
- phases.filter(isPhaseFinished).map((phase) => ({ id: phase.id, unmerged: isTickedUnmerged(phase) }))
413
+ phases.filter(isPhaseFinished).map((phase) => ({ id: phase.id }))
414
+
415
+ type Resting = Pick<LaneSummary, 'waiting' | 'waitingPhases' | 'finished' | 'finishedPhases' | 'unwritten'>
416
+
417
+ /** What does not run: the phases that wait or are finished, as ids and as phases to open, and those without a checkbox. */
418
+ const restingOf = (input: SummaryInput, href: string, phases: PhaseProgress[], active: ActivePhase[]): Resting => {
419
+ const running = new Set(active.map(({ phase }) => phase.id))
420
+ return {
421
+ waiting: waitingOf(input, href, phases, running),
422
+ waitingPhases: waitingPlaced(input, href, phases, running),
423
+ finished: finishedOf(phases),
424
+ finishedPhases: phases.filter(isPhaseFinished).map(placedIn(input.progress, href)),
425
+ unwritten: stepPhases(input.progress, href)
426
+ .filter((phase) => !isWritten(phase))
427
+ .map(({ id, title }) => ({ id, missing: title === null })),
428
+ }
429
+ }
430
+
431
+ type ActiveSources = {
432
+ phases: PhaseProgress[]
433
+ timed: TimedPhase[]
434
+ /** The tasks agents reported, by phase id. */
435
+ reported: ReadonlyMap<string, ActiveTask[]>
436
+ /** Whether any report is fresh; the herd guesses no task then. */
437
+ anyFresh: boolean
438
+ signalled: PhaseProgress | null
439
+ /** The runs of the lane's subagents, the reviews that name no phase placed (`placedRuns`). */
440
+ subagents: readonly SubagentPhaseRun[]
441
+ }
442
+
443
+ /** The running phases of the plan on `href`, each with the tasks reported on it, the review gate or the guessed one. */
444
+ const activePhases = (input: SummaryInput, href: string, sources: ActiveSources, now: number): ActivePhase[] => {
445
+ const { phases, timed, reported, anyFresh, signalled } = sources
446
+ const named = subagentPhases(phases, sources.subagents)
447
+ const running = runningPhases(timed, phases, { reported, subagents: named, signalled }, now)
448
+ const tasks = withReviewTasks(reported, phases, named)
449
+ const guessable = anyFresh ? [] : running.map(({ phase }) => phase).filter(({ id }) => !tasks.has(id))
450
+ return activeOf(input, href, running, tasks, guessedTasks(input, guessable, now))
451
+ }
194
452
 
195
453
  /**
196
- * The lane summed up over its plans at `now`: the open phases running in its current plan (moved less than
197
- * `STALE_ACTIVITY_MINUTES` ago; a phase without a time of its last movement never runs), the open phase moved last,
198
- * the next wave's open phases, the other open phases, the finished ones (marked when not merged yet), the phases its
199
- * rows name with nothing to run, and the tally over every plan of the lane. A phase ticked to the end, review gate
454
+ * The lane summed up over its plans at `now`: the open phases running in its current plan (`runningPhases`), the open
455
+ * phase moved last, the open phases that wait, the finished ones, the phases its rows
456
+ * name with nothing to run, and the tally over every plan of the lane. A phase ticked to the end, review gate
200
457
  * included, is finished, never running.
201
458
  */
202
459
  export const laneSummary = (input: SummaryInput, now: number): LaneSummary => {
203
460
  const href = focusHref(input.progress)
204
461
  const tally = tallyOf(planHrefs(input.progress).flatMap((each) => phasesOf(input, each)))
205
- if (href === null) return { active: [], latest: null, next: null, later: [], finished: [], unwritten: [], tally }
462
+ if (href === null) {
463
+ const subagentTasks = unplacedTasks(input, null, [])
464
+ return {
465
+ active: [],
466
+ latest: null,
467
+ signalled: null,
468
+ waiting: [],
469
+ waitingPhases: [],
470
+ finished: [],
471
+ finishedPhases: [],
472
+ unwritten: [],
473
+ tally,
474
+ unplanned: [],
475
+ subagentTasks,
476
+ }
477
+ }
206
478
  const phases = phasesOf(input, href)
207
479
  const timed = timedPhases(input, href, phases, now)
208
- const active = activeOf(input, href, timed, now)
209
- const running = new Set(active.map(({ phase }) => phase.id))
210
- const next = nextOf(input, href, phases, running)
480
+ const fresh = freshSignals(input.reported ?? [], newest(Object.values(input.activity)))
481
+ const { byPhase, unplanned } = assignSignals(phases, fresh)
482
+ const signalled = signalledPhase(phases, byPhase, input.branch ?? null)
483
+ const subagents = placedRuns(input, signalled)
484
+ const onTasks = subagentTaskPlacements(phases, input.describedSubagents ?? [])
485
+ const reported = withTasks(byPhase, onTasks.byPhase)
486
+ const sources = { phases, timed, reported, anyFresh: fresh.length > 0, signalled, subagents }
487
+ const active = activePhases(input, href, sources, now)
211
488
  return {
212
489
  active,
213
490
  latest: timed[0]?.phase ?? null,
214
- next,
215
- later: laterOf(input, href, phases, new Set([...running, ...(next?.phaseIds ?? [])])),
216
- finished: finishedOf(phases),
217
- unwritten: stepPhases(input.progress, href)
218
- .filter((phase) => !isWritten(phase))
219
- .map(({ id, title }) => ({ id, missing: title === null })),
491
+ signalled,
492
+ ...restingOf(input, href, phases, active),
220
493
  tally,
494
+ unplanned,
495
+ subagentTasks: unplacedTasks(input, signalled, onTasks.placed),
221
496
  }
222
497
  }
@@ -34,6 +34,21 @@ const onceById = (tickets: readonly SourceTicket[]): SourceTicket[] =>
34
34
 
35
35
  const badgeOf = ({ lane: _lane, ...badge }: SourceTicket): TicketBadge => badge
36
36
 
37
+ /**
38
+ * A pull request title without the lane ticket it starts with (`T-1 …`, `[T-1] …`, `T-1: …`, `T-1 - …`): the ticket
39
+ * stands under the lane's links already. A key inside the title, a longer key or a title that is the key alone stays.
40
+ */
41
+ export const withoutTicketKey = (title: string, ids: readonly string[]): string => {
42
+ for (const id of ids) {
43
+ const lead = title.startsWith(`[${id}]`) ? `[${id}]` : title.startsWith(id) ? id : ''
44
+ const rest = title.slice(lead.length)
45
+ if (lead === '' || isWordCharacter(rest[0])) continue
46
+ const stripped = rest.replace(/^\s*[:-]?\s+|^\s*:\s*/, '')
47
+ if (stripped !== '' && stripped !== rest) return stripped
48
+ }
49
+ return title
50
+ }
51
+
37
52
  /** What the lane's board rows say of it beside its name, for finding ticket ids: the text of each row. */
38
53
  export const boardRowTexts = (lane: SprintLane): string[] => lane.steps.map(({ label }) => label)
39
54