jackherd 1.6.1 → 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 +26 -0
  2. package/INSTALL.md +176 -0
  3. package/README.md +226 -310
  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 +109 -27
  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
@@ -1,12 +1,13 @@
1
1
  import { CONTEXT_LEVEL, type ContextLevel, type ContextReading, contextLabel } from '../context'
2
- import type { ChecklistItem, DeferredItem, LaneProgress, PhaseProgress, PlanStep, StepProgress } from '../plan-format'
3
- import { activeItem, currentPhase, isTickedUnmerged, PHASE_ID_PREFIX } from '../plan-format'
2
+ import type { ChecklistItem, DeferredItem, LaneProgress, PhaseProgress, StepProgress } from '../plan-format'
3
+ import { activeItem, currentPhase, isTickedUnmerged, onlyDocumentationOpen, PHASE_ID_PREFIX } from '../plan-format'
4
4
  import {
5
5
  BADGE_TONE,
6
6
  BAR_SEGMENT,
7
7
  type BadgeTone,
8
8
  badge,
9
9
  bar,
10
+ box,
10
11
  fit,
11
12
  INVERSE_ON,
12
13
  packGroups,
@@ -24,12 +25,15 @@ import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
24
25
  import {
25
26
  NO_BADGES,
26
27
  type PullRequestBadges,
28
+ type TicketBadge,
27
29
  WATCH_HEALTH,
28
30
  type WatchBadges,
29
31
  type WatchHealth,
30
32
  } from '../watch/plan-badges'
31
33
  import { LANE_STANDING, type LaneStanding, laneStanding, waitingNote } from './lane-standing'
32
- import { chipKey, type LinkChip } from './link-chips'
34
+ import type { LaneTickets } from './lane-tickets'
35
+ import { chipKey, type LinkChip, planUrlOf } from './link-chips'
36
+ import { type PhaseModel, phaseModelKey } from './phase-models'
33
37
  import { type PlannedPlan, phaseList, plannedPhaseCount } from './planned'
34
38
  import { CHECKS, type ChecksState, type PullRequest, pullRequestKey } from './pull-requests'
35
39
  import {
@@ -38,7 +42,6 @@ import {
38
42
  isLaneOpen,
39
43
  itemKey,
40
44
  laneKey,
41
- notesKey,
42
45
  PLANNED_KEY,
43
46
  phaseKey,
44
47
  type Selection,
@@ -46,8 +49,10 @@ import {
46
49
  targetKeys,
47
50
  tickedKey,
48
51
  } from './selection'
52
+ import { shownWithMore } from './shown-with-more'
49
53
  import {
50
54
  chevronOf,
55
+ MODEL_MARK,
51
56
  NO_AGENT_SYMBOL,
52
57
  NO_AGENT_WORD,
53
58
  PHASE_COUNT,
@@ -57,7 +62,7 @@ import {
57
62
  statusSymbol,
58
63
  statusWord,
59
64
  } from './symbols'
60
- import { BADGE_TEXTS, type BadgeTexts, laneMarkText, TEXTS } from './texts'
65
+ import { BADGE_TEXTS, type BadgeTexts, checksText, laneMarkText, TEXTS } from './texts'
61
66
 
62
67
  /**
63
68
  * Where a lane stands outside its plan. `agent` is the terminal adapter's status, null when no agent is listed;
@@ -82,6 +87,10 @@ export type LaneView = {
82
87
  unlisted?: boolean
83
88
  /** Preview and feedback of the lane's pull requests from the running watcher's state; none without a watcher. */
84
89
  badges?: WatchBadges
90
+ /** The tickets of the lane from the running watcher's state; absent when it has none or no watcher runs. */
91
+ tickets?: LaneTickets
92
+ /** The model each phase's implementing subagent last ran on, by `phaseModelKey`; absent without a record. */
93
+ models?: Record<string, PhaseModel>
85
94
  }
86
95
 
87
96
  /**
@@ -94,6 +103,8 @@ export type OverviewView = {
94
103
  lanes: LaneView[]
95
104
  planned: PlannedPlan[]
96
105
  watch?: WatchHealth
106
+ /** True when the config marks a ticket source, so a missing watcher is said without a pull request too. */
107
+ watchesTickets?: boolean
97
108
  language?: PlanLanguage
98
109
  }
99
110
 
@@ -114,6 +125,10 @@ type LaneContext = {
114
125
  chips: Record<string, LinkChip[]>
115
126
  badges: WatchBadges
116
127
  badgeTexts: BadgeTexts
128
+ /** The lane's tickets for the plan block that shows them, the first; null in every other block. */
129
+ tickets: LaneTickets | null
130
+ /** The model each phase ran on, by `phaseModelKey`. */
131
+ models: Record<string, PhaseModel>
117
132
  }
118
133
 
119
134
  /**
@@ -143,20 +158,24 @@ const TREE_GUTTER = TREE.branch.length
143
158
 
144
159
  /** Places on each side of the slash of a count: ` 9/9 `, ` 15/15 `, `123/456`. */
145
160
  const COUNT_PLACES = 3
146
- /** The cells before a count: `✅`, `✓`, `?`, `–` or nothing, padded so every slash on screen is in one column. */
161
+ /** The cells before a count: `◐`, `✓`, `?`, `–` or nothing, padded so every slash on screen is in one column. */
147
162
  const COUNT_MARK_WIDTH = 2
148
163
  const BLANK_COUNT = ' '.repeat(2 * COUNT_PLACES + 1)
149
164
  const MIN_TITLE_WIDTH = 8
150
165
  const MIN_BESIDE_WIDTH = 24
151
166
  const CONTEXT_WIDTH = 5
152
- const CONTEXT_SYMBOL = '🧠'
153
- const PROGRESS_SYMBOL = '✅'
167
+ const CONTEXT_SYMBOL = '🥣'
168
+ const PROGRESS_SYMBOL = '◐'
169
+ const HEADER_MARK = '🐾'
154
170
  const COLUMN_RULE = '│'
155
171
  /** The number badge of a three-digit pull request (`↗ #` and the digits, a cell of padding either side); a longer one widens it. */
156
172
  const PULL_REQUEST_BADGE_WIDTH = 8
157
173
  /** Under the lane line's status symbol, where the pull request row and its badge line start (D96). */
158
174
  const PULL_REQUEST_INDENT = ' '
159
175
  const CHECKS_WIDTH = 10
176
+ /** The most failed tests listed under a pull request; the rest is counted. */
177
+ const SHOWN_FAILED_TESTS = 3
178
+ const FAILED_TEST_INDENT = ' '
160
179
  const MARKDOWN_MARKS = /\*\*|`/g
161
180
 
162
181
  /** Lower ranks need the coordinator sooner; a status an adapter adds later ranks after `working`. */
@@ -169,10 +188,11 @@ const URGENCY: Record<AgentStatus, number> = {
169
188
  const UNKNOWN_URGENCY = 3
170
189
  const NO_AGENT_URGENCY = 4
171
190
 
172
- const AGENT_PAINT: Record<AgentStatus, (text: string) => string> = {
173
- [AGENT_STATUS.working]: paint.yellow,
174
- [AGENT_STATUS.blocked]: paint.red,
175
- [AGENT_STATUS.done]: paint.green,
191
+ /** The colour of a lane state, word and symbol alike: teal while working or done, bold amber when it waits for you, grey when idle. */
192
+ const STATUS_PAINT: Record<AgentStatus, (text: string) => string> = {
193
+ [AGENT_STATUS.working]: paint.teal,
194
+ [AGENT_STATUS.blocked]: (text) => paint.bold(paint.yellow(text)),
195
+ [AGENT_STATUS.done]: paint.teal,
176
196
  [AGENT_STATUS.idle]: paint.dim,
177
197
  }
178
198
 
@@ -207,19 +227,28 @@ const PHASE_PAINT: Record<PhaseState, (count: string) => string> = {
207
227
  const countText = (done: number, total: number): string =>
208
228
  `${String(done).padStart(COUNT_PLACES)}/${String(total).padEnd(COUNT_PLACES)}`
209
229
 
230
+ const COUNT_CELL_WIDTH = COUNT_MARK_WIDTH + 1 + BLANK_COUNT.length
231
+
210
232
  /** Mark and count in one fixed-width cell at the right edge, the same for the lane, phase and planned rows. */
211
233
  const countCell = (mark: string, count: string): string => `${padVisible(mark, COUNT_MARK_WIDTH)} ${count}`
212
234
 
235
+ /** A word in place of a count, right-aligned in the width of the count cell like `n/m`. */
236
+ const wordCell = (word: string): string => `${' '.repeat(Math.max(COUNT_CELL_WIDTH - textWidth(word), 0))}${word}`
237
+
213
238
  const PHASE_MARK: Record<PhaseState, string> = {
214
239
  [PHASE_STATE.missing]: PHASE_COUNT.missing,
215
240
  [PHASE_STATE.empty]: PHASE_COUNT.empty,
216
- [PHASE_STATE.all]: PHASE_COUNT.allTicked,
241
+ [PHASE_STATE.all]: '',
217
242
  [PHASE_STATE.open]: '',
218
243
  }
219
244
 
220
- /** The count cell of a phase row at the right edge; the current phase is bold yellow. */
245
+ /** The count cell of a phase row at the right edge; the current phase is bold yellow. A phase lacking only the documentation box says so instead of a count. */
221
246
  const phaseCountCell = (phase: PhaseProgress, current: boolean): string => {
222
247
  const state = phaseStateOf(phase)
248
+ if (state === PHASE_STATE.open && onlyDocumentationOpen(phase)) {
249
+ const cell = wordCell(TEXTS.allButDocs)
250
+ return current ? paint.bold(paint.yellow(cell)) : paint.green(cell)
251
+ }
223
252
  const hasCount = state === PHASE_STATE.open || state === PHASE_STATE.all
224
253
  const cell = countCell(PHASE_MARK[state], hasCount ? countText(phase.done, phase.total) : BLANK_COUNT)
225
254
  return current ? paint.bold(paint.yellow(cell)) : PHASE_PAINT[state](cell)
@@ -227,13 +256,16 @@ const phaseCountCell = (phase: PhaseProgress, current: boolean): string => {
227
256
 
228
257
  const agentLabel = (agent: string | null): string => {
229
258
  if (agent === null) return paint.dim(statusWord(agent))
230
- return isAgentStatus(agent) ? AGENT_PAINT[agent](statusWord(agent)) : statusWord(agent)
259
+ return isAgentStatus(agent) ? STATUS_PAINT[agent](statusWord(agent)) : statusWord(agent)
231
260
  }
232
261
 
233
262
  /** The symbol of a status padded to `SYMBOL_WIDTH` cells, so the names after it start in one column. */
234
263
  const symbolCell = (agent: string | null): string => {
235
264
  const symbol = statusSymbol(agent)
236
- return padVisible(agent === null ? paint.dim(symbol) : symbol, SYMBOL_WIDTH)
265
+ return padVisible(
266
+ agent !== null && isAgentStatus(agent) ? STATUS_PAINT[agent](symbol) : paint.dim(symbol),
267
+ SYMBOL_WIDTH,
268
+ )
237
269
  }
238
270
 
239
271
  const aheadLabel = ({ branch, ahead }: LaneStatus): string => {
@@ -431,11 +463,39 @@ export const rowWithCount = (
431
463
  const unmergedMark = (phase: PhaseProgress): string =>
432
464
  isTickedUnmerged(phase) ? ` ${paint.yellow(TEXTS.tickedUnmerged)}` : ''
433
465
 
434
- /** The phase title cut before the count, which stays at the right edge, or the note that the plan lacks the phase. */
435
- const phaseRow = (phase: PhaseProgress, open: boolean, current: boolean, width: number): string => {
466
+ /** The chevron of a phase row; a finished phase that is closed shows a green ✓ in its place. */
467
+ const phaseMark = (phase: PhaseProgress, open: boolean): string =>
468
+ !open && phaseStateOf(phase) === PHASE_STATE.all ? paint.green(PHASE_COUNT.allTicked) : paint.dim(chevronOf(open))
469
+
470
+ const familyLabel = (family: string): string => `${family.charAt(0).toUpperCase()}${family.slice(1)}`
471
+
472
+ /**
473
+ * The model the phase's subagent last ran on: `Opus ✓` when it fits the plan's recommendation, `Sonnet ≠ Opus` in
474
+ * amber when it does not, the family alone, dim, when the plan recommends none; empty without a record.
475
+ */
476
+ const modelMark = (model: PhaseModel | undefined): string => {
477
+ if (!model) return ''
478
+ const used = familyLabel(model.used)
479
+ if (model.recommended === null) return paint.dim(used)
480
+ return model.fits
481
+ ? `${paint.dim(used)} ${paint.green(MODEL_MARK.fits)}`
482
+ : paint.yellow(`${used} ${MODEL_MARK.differs} ${familyLabel(model.recommended)}`)
483
+ }
484
+
485
+ /**
486
+ * The phase title cut before the model and the count, which stay at the right edge, or the note that the plan lacks
487
+ * the phase. The model gives way in a pane too narrow to leave the title `MIN_TITLE_WIDTH` cells.
488
+ */
489
+ const phaseRow = (phase: PhaseProgress, open: boolean, current: boolean, width: number, model?: PhaseModel): string => {
436
490
  const title = phase.title === null ? paint.dim(TEXTS.notInPlan) : plain(phase.title)
437
- const head = `${paint.dim(chevronOf(open))} P${phase.id} ${title}${current ? unmergedMark(phase) : ''}`
438
- return spread(head, phaseCountCell(phase, current), width)
491
+ const label = `${phaseMark(phase, open)} P${phase.id} `
492
+ const head = `${label}${title}${current ? unmergedMark(phase) : ''}`
493
+ const count = phaseCountCell(phase, current)
494
+ const withModel = `${modelMark(model)}${COLUMN_SEPARATOR}${count}`
495
+ const titleRoom = width - visibleLength(label) - visibleLength(withModel) - 1
496
+ const fitsModel =
497
+ model !== undefined && (titleRoom >= MIN_TITLE_WIDTH || titleRoom >= visibleLength(head) - visibleLength(label))
498
+ return spread(head, fitsModel ? withModel : count, width)
439
499
  }
440
500
 
441
501
  /** A closed phase shows only `active` under it, when given, closed whatever the task's fold. */
@@ -459,7 +519,8 @@ const phaseFrame = (context: StepContext, phase: PhaseProgress): Frame => {
459
519
  const inner = deeper(context)
460
520
  const active = current ? activeItem(phase) : null
461
521
  const children = open ? phaseChildren(inner, key, phase, active) : [activeTaskFrame(inner, key, phase, active)]
462
- return joinFrames([rowFrame(key, [phaseRow(phase, open, current, width)], [], context), nest(children)])
522
+ const model = context.models[phaseModelKey(stepIndex, phase.id)]
523
+ return joinFrames([rowFrame(key, [phaseRow(phase, open, current, width, model)], [], context), nest(children)])
463
524
  }
464
525
 
465
526
  /** True when number, checks and the badges leave room for a title beside them on one line. */
@@ -471,9 +532,12 @@ const fitsBeside = (head: string, cells: BadgeCell[], width: number): boolean =>
471
532
  * columns, the watcher's badges after them and the title last, cut with `…` on its one line. When the badges leave no
472
533
  * room for a title, they go on lines of their own below, starting in the column of the lane's status symbol (D96).
473
534
  */
474
- const pullRequestFrame = (pullRequest: PullRequest, context: LaneContext): Frame => {
535
+ const pullRequestRow = (pullRequest: PullRequest, context: LaneContext): Frame => {
475
536
  const number = padVisible(badge(pullRequest.url, `↗ #${pullRequest.number}`), PULL_REQUEST_BADGE_WIDTH)
476
- const checks = padVisible(CHECKS_PAINT[pullRequest.checks](TEXTS.checks[pullRequest.checks]), CHECKS_WIDTH)
537
+ const checksWords = checksText(pullRequest)
538
+ const checksColumn =
539
+ textWidth(checksWords) < CHECKS_WIDTH ? CHECKS_WIDTH : textWidth(checksWords) + COLUMN_SEPARATOR.length
540
+ const checks = padVisible(CHECKS_PAINT[pullRequest.checks](checksWords), checksColumn)
477
541
  const head = `${PULL_REQUEST_INDENT}${number}${COLUMN_SEPARATOR}${checks}`
478
542
  const prefix = pullRequest.draft ? TEXTS.draft : ''
479
543
  const dimPrefix = (line: string, index: number) =>
@@ -495,6 +559,23 @@ const pullRequestFrame = (pullRequest: PullRequest, context: LaneContext): Frame
495
559
  return { ...row, targets: [...cellTargets(cells, 0, visibleLength(head), context.width), ...row.targets] }
496
560
  }
497
561
 
562
+ /** The failed tests of a red pull request, at most `SHOWN_FAILED_TESTS` of them one per line, then `+<n>`; none otherwise. */
563
+ const failedTestLines = (pullRequest: PullRequest, badges: PullRequestBadges | undefined, width: number): string[] => {
564
+ const tests = pullRequest.checks === CHECKS.failed ? (badges?.failedTests ?? []) : []
565
+ const { shown, more } = shownWithMore(tests, SHOWN_FAILED_TESTS)
566
+ const lines = shown.map((name) => `${FAILED_TEST_INDENT}${paint.red(`✗ ${plain(name)}`)}`)
567
+ return [...lines, ...(more > 0 ? [`${FAILED_TEST_INDENT}${paint.dim(`+${more}`)}`] : [])].map((line) =>
568
+ fit(line, width),
569
+ )
570
+ }
571
+
572
+ /** The pull request row, then the tests its red checks failed, which only the watcher knows. */
573
+ const pullRequestFrame = (pullRequest: PullRequest, context: LaneContext): Frame =>
574
+ joinFrames([
575
+ pullRequestRow(pullRequest, context),
576
+ textFrame(failedTestLines(pullRequest, context.badges[String(pullRequest.number)], context.width)),
577
+ ])
578
+
498
579
  const CONTEXT_PAINT: Record<ContextLevel, (text: string) => string> = {
499
580
  [CONTEXT_LEVEL.ok]: paint.dim,
500
581
  [CONTEXT_LEVEL.warn]: paint.yellow,
@@ -521,26 +602,17 @@ const groupSteps = (steps: readonly StepProgress[]): number[][] =>
521
602
  return groups
522
603
  }, [])
523
604
 
524
- const isTicked = ({ phase }: PhaseEntry): boolean => phase.total > 0 && phase.done === phase.total
525
-
526
- /** A ticked phase folds into the ticked line, unless it is the current one: that one stays a row of its own. */
527
- const foldsAway = (progress: LaneProgress, entry: PhaseEntry): boolean =>
528
- isTicked(entry) && !isCurrent(progress, entry.stepIndex, entry.phase)
529
-
530
- const tickedLine = (entries: PhaseEntry[]): string[] =>
531
- entries.length === 0
532
- ? []
533
- : [`${paint.green('✓')} ${paint.dim(`${entries.map(({ phase }) => `P${phase.id}`).join(' ')} ${TEXTS.ticked}`)}`]
534
-
535
605
  const CHIP_GAP = ' '
536
606
  const CHIP_MARK = '📎 '
607
+ const TICKET_MARK = '📡'
608
+ const STATUS_SEPARATOR = ' · '
537
609
 
538
610
  /** `✦ label` as a badge linked to the chip's address, `private` faint after it for a link its plan says is private. */
539
611
  const chipCell = ({ url, icon, label, private: hidden }: LinkChip): string =>
540
612
  badge(url, `${icon} ${plain(label)}`, hidden ? TEXTS.privateLink : '')
541
613
 
542
- /** A badge and the key a click on it opens. */
543
- type BadgeCell = { cell: string; key: string }
614
+ /** A badge and the key a click on it opens; null for a cell no click opens. */
615
+ type BadgeCell = { cell: string; key: string | null }
544
616
 
545
617
  const packedWidth = (cells: BadgeCell[]): number =>
546
618
  cells.reduce((sum, { cell }, index) => sum + visibleLength(cell) + (index > 0 ? CHIP_GAP.length : 0), 0)
@@ -548,8 +620,9 @@ const packedWidth = (cells: BadgeCell[]): number =>
548
620
  /** One click target per badge of a line, from `column` on, each over exactly the columns it covers up to `edge`. */
549
621
  const cellTargets = (cells: BadgeCell[], line: number, column: number, edge: number): ClickTarget[] => {
550
622
  let from = column
551
- return cells.map(({ cell, key }) => {
552
- const target = { line, from, to: Math.min(from + visibleLength(cell), edge), key }
623
+ return cells.flatMap(({ cell, key }) => {
624
+ const to = Math.min(from + visibleLength(cell), edge)
625
+ const target = key === null ? [] : [{ line, from, to, key }]
553
626
  from += visibleLength(cell) + CHIP_GAP.length
554
627
  return target
555
628
  })
@@ -578,13 +651,36 @@ const packedFrame = (cells: BadgeCell[], lead: string, width: number): Frame =>
578
651
  return { lines, cursorLine: -1, targets }
579
652
  }
580
653
 
581
- /** The chips of a plan behind a 📎, then a blank line before the phases. */
582
- const chipFrame = (chips: LinkChip[], width: number): Frame => {
583
- const frame = packedFrame(
584
- chips.map((chip) => ({ cell: chipCell(chip), key: chipKey(chip.url) })),
585
- CHIP_MARK,
586
- width,
587
- )
654
+ const ticketTone = (isNew: boolean): BadgeTone => (isNew ? BADGE_TONE.amber : BADGE_TONE.grey)
655
+
656
+ /** A badge linked to `url`, or a box no click opens when there is none. */
657
+ const linkedBox = (url: string | null, text: string, tone: BadgeTone): string =>
658
+ url ? badge(url, text, '', tone) : box(text, tone)
659
+
660
+ /** `📡 T-1 · In Progress` linked to the ticket, amber while new; a ticket without URL is a box no click opens. */
661
+ const ticketCell = ({ id, url, status, isNew }: TicketBadge): BadgeCell => {
662
+ const text = `${TICKET_MARK} ${id}${status ? `${STATUS_SEPARATOR}${status}` : ''}`
663
+ return { cell: linkedBox(url, text, ticketTone(isNew)), key: url ? chipKey(url) : null }
664
+ }
665
+
666
+ /** The lane's ticket badges, then `+<n>` as plain text for the ones not shown. */
667
+ const ticketCells = (tickets: LaneTickets | null): BadgeCell[] => {
668
+ if (!tickets) return []
669
+ const more = tickets.more > 0 ? [{ cell: paint.dim(`+${tickets.more}`), key: null }] : []
670
+ return [...tickets.shown.map(ticketCell), ...more]
671
+ }
672
+
673
+ const chipCellOf = (chip: LinkChip): BadgeCell => ({ cell: chipCell(chip), key: chipKey(chip.url) })
674
+
675
+ /** The chips of a plan behind a 📎, the plan chip first and the tickets beside it, then a blank line before the phases. */
676
+ const chipFrame = (chips: LinkChip[], tickets: LaneTickets | null, width: number): Frame => {
677
+ const isPlanChip = (chip: LinkChip) => planUrlOf(chipKey(chip.url)) !== null
678
+ const cells = [
679
+ ...chips.filter(isPlanChip).map(chipCellOf),
680
+ ...ticketCells(tickets),
681
+ ...chips.filter((chip) => !isPlanChip(chip)).map(chipCellOf),
682
+ ]
683
+ const frame = packedFrame(cells, CHIP_MARK, width)
588
684
  return frame.lines.length > 0 ? { ...frame, lines: [...frame.lines, ''] } : frame
589
685
  }
590
686
 
@@ -612,43 +708,22 @@ const badgeCells = (
612
708
  const planHeading = (title: string, width: number): string[] => wrap(plain(title), width).map(paint.bold)
613
709
 
614
710
  /**
615
- * A plan as a node: its title in bold, and below it the links, the open phases, and the ticked ones summed up in
616
- * one line unless `showDone` lists them.
711
+ * A plan as a node: its title in bold, and below it the links and the phases in plan order, each ticked one as a
712
+ * collapsed row of its own.
617
713
  */
618
714
  const planBlock = (context: LaneContext, stepIndexes: number[], entries: PhaseEntry[]): Frame => {
619
- const { progress, state, width, chips } = context
715
+ const { progress, width, chips, tickets } = context
620
716
  const { planTitle, planHref } = progress.steps[stepIndexes[0]].step
621
- const shown = state.showDone ? entries : entries.filter((entry) => !foldsAway(progress, entry))
622
- const ticked = state.showDone ? [] : entries.filter((entry) => foldsAway(progress, entry))
623
717
  const inner = deeper(context)
624
718
  return joinFrames([
625
719
  textFrame(planHeading(planTitle, width)),
626
720
  nest([
627
- chipFrame(planHref ? (chips[planHref] ?? []) : [], inner.width),
628
- ...shown.map(({ stepIndex, phase }) => phaseFrame({ ...inner, stepIndex }, phase)),
629
- textFrame(tickedLine(ticked)),
721
+ chipFrame(planHref ? (chips[planHref] ?? []) : [], tickets, inner.width),
722
+ ...entries.map(({ stepIndex, phase }) => phaseFrame({ ...inner, stepIndex }, phase)),
630
723
  ]),
631
724
  ])
632
725
  }
633
726
 
634
- /**
635
- * The summary line of a lane's notes is a row of its own, clickable like the key `n`; shown, the notes follow it
636
- * unselectable.
637
- */
638
- const noteFrame = (context: LaneContext, steps: PlanStep[]): Frame => {
639
- if (steps.length === 0) return textFrame([])
640
- const { progress, state, width } = context
641
- const lane = progress.lane.name
642
- const shown = state.notesShown.has(lane)
643
- const summary = paint.dim(
644
- `${chevronOf(shown)} ${shown ? TEXTS.notesShown(steps.length) : TEXTS.notesHidden(steps.length)}`,
645
- )
646
- const notes = shown
647
- ? steps.flatMap((step) => [...planHeading(step.planTitle, width), ...wrap(plain(step.label), width).map(paint.dim)])
648
- : []
649
- return rowFrame(notesKey(lane), [fit(summary, width)], notes, context)
650
- }
651
-
652
727
  /** The columns lane lines share, so names, statuses and bars of all of them line up. */
653
728
  const STATUS_WIDTH = Math.max(...[...Object.values(AGENT_STATUS), NO_AGENT_WORD].map(textWidth))
654
729
 
@@ -674,8 +749,16 @@ const pullRequestMark = (pullRequest: PullRequest, badges: WatchBadges): string
674
749
  return badge(pullRequest.url, text, '', markTone(found))
675
750
  }
676
751
 
677
- const pullRequestMarks = ({ pullRequests, badges }: LaneView): string =>
678
- pullRequests.map((pullRequest) => pullRequestMark(pullRequest, badges ?? {})).join(' ')
752
+ /** `📡 <n>` in amber while tickets of the lane are new, linked to the first of them; nothing otherwise. */
753
+ const ticketMark = (tickets: LaneTickets | undefined): string[] => {
754
+ const first = tickets?.shown.find(({ isNew }) => isNew)
755
+ if (!tickets || !first) return []
756
+ return [linkedBox(first.url, `${TICKET_MARK} ${tickets.newCount}`, BADGE_TONE.amber)]
757
+ }
758
+
759
+ /** A folded lane's marks: its pull request badges, and the ticket mark while a ticket is new. */
760
+ const laneMarks = ({ pullRequests, badges, tickets }: LaneView): string =>
761
+ [...pullRequests.map((pullRequest) => pullRequestMark(pullRequest, badges ?? {})), ...ticketMark(tickets)].join(' ')
679
762
 
680
763
  /** What follows the status on a lane line: git state while the lane is open, the next phase when folded. */
681
764
  const laneDetail = (view: LaneView, open: boolean): string => {
@@ -702,10 +785,10 @@ const laneRight = (view: LaneView): string => {
702
785
  return counts ? `${contextCell(view.context)}${counts}` : contextCell(view.context).trimEnd()
703
786
  }
704
787
 
705
- /** A folded lane's pull request badges in the column of `columns.marks`, right before context and bar (D96). */
788
+ /** A folded lane's marks in the column of `columns.marks`, right before context and bar (D96). */
706
789
  const markedRight = (view: LaneView, columns: LaneColumns): string => {
707
790
  const right = laneRight(view)
708
- const marks = pullRequestMarks(view)
791
+ const marks = laneMarks(view)
709
792
  if (!marks) return right
710
793
  if (columns.right === 0) return padVisible(marks, columns.marks)
711
794
  const pad = ' '.repeat(Math.max(columns.right - visibleLength(right), 0))
@@ -731,7 +814,16 @@ const laneBlock = (
731
814
  columns: LaneColumns,
732
815
  badgeTexts: BadgeTexts,
733
816
  ): Frame => {
734
- const context = { progress: view.progress, state, width, chips: view.chips, badges: view.badges ?? {}, badgeTexts }
817
+ const context = {
818
+ progress: view.progress,
819
+ state,
820
+ width,
821
+ chips: view.chips,
822
+ badges: view.badges ?? {},
823
+ badgeTexts,
824
+ tickets: view.tickets ?? null,
825
+ models: view.models ?? {},
826
+ }
735
827
  const open = isLaneOpen(state, view.progress.lane.name, view.status.agent)
736
828
  const row = rowFrame(laneKey(view.progress.lane.name), laneLines(view, open, width, columns), [], context)
737
829
  if (!open) return row
@@ -742,13 +834,12 @@ const laneBlock = (
742
834
  ),
743
835
  }))
744
836
  const planned = groups.filter(({ entries }) => entries.length > 0)
745
- const notes = groups
746
- .filter(({ entries }) => entries.length === 0)
747
- .map(({ stepIndexes }) => view.progress.steps[stepIndexes[0]].step)
748
837
  const inner = deeper(context)
749
838
  const content = nestSpaced([
750
- ...planned.map(({ stepIndexes, entries }) => planBlock(inner, stepIndexes, entries)),
751
- noteFrame(inner, notes),
839
+ ...(planned.length === 0 ? [chipFrame([], inner.tickets, inner.width)] : []),
840
+ ...planned.map(({ stepIndexes, entries }, index) =>
841
+ planBlock(index === 0 ? inner : { ...inner, tickets: null }, stepIndexes, entries),
842
+ ),
752
843
  followingFrame(view, state, inner.width),
753
844
  ])
754
845
  return joinFrames([row, ...view.pullRequests.map((pullRequest) => pullRequestFrame(pullRequest, context)), content])
@@ -833,17 +924,20 @@ const separate = (blocks: Frame[], width: number): Frame[] =>
833
924
 
834
925
  /**
835
926
  * Why badges may be missing, said beside the clock: no watcher runs, or its state is stale. Nothing while it is live,
836
- * or when no lane on screen has a pull request a badge could belong to.
927
+ * or when no lane on screen has a pull request a badge could belong to and no source is marked `ticket`.
837
928
  */
838
- const watchNotice = (lanes: LaneView[], watch: WatchHealth | undefined, texts: BadgeTexts): string => {
929
+ const watchNotice = (
930
+ { lanes, watch, watchesTickets }: Pick<OverviewView, 'lanes' | 'watch' | 'watchesTickets'>,
931
+ texts: BadgeTexts,
932
+ ): string => {
839
933
  if (!watch || watch.kind === WATCH_HEALTH.live) return ''
840
- if (!lanes.some(({ pullRequests }) => pullRequests.length > 0)) return ''
934
+ if (!watchesTickets && !lanes.some(({ pullRequests }) => pullRequests.length > 0)) return ''
841
935
  return watch.kind === WATCH_HEALTH.off ? texts.watcherOff : texts.stale(watch.minutes)
842
936
  }
843
937
 
844
938
  const laneColumnsOf = (lanes: LaneView[]): LaneColumns => ({
845
939
  name: Math.min(Math.max(0, ...lanes.map(({ progress }) => textWidth(progress.lane.name))), NAME_WIDTH_MAX),
846
- marks: Math.max(0, ...lanes.map((lane) => visibleLength(pullRequestMarks(lane)))),
940
+ marks: Math.max(0, ...lanes.map((lane) => visibleLength(laneMarks(lane)))),
847
941
  right: Math.max(0, ...lanes.map((lane) => visibleLength(laneRight(lane)))),
848
942
  })
849
943
 
@@ -864,7 +958,7 @@ const partitionLanes = (lanes: LaneView[]): Record<'active' | 'waiting' | 'noAge
864
958
  * lanes with a worktree but no agent each share a closing block; finished lanes are not drawn.
865
959
  */
866
960
  export const renderScreen = (
867
- { title, time, lanes, planned, watch, language = PLAN_LANGUAGE.en }: OverviewView,
961
+ { title, time, lanes, planned, watch, watchesTickets, language = PLAN_LANGUAGE.en }: OverviewView,
868
962
  state: Selection,
869
963
  width: number,
870
964
  ): Screen => {
@@ -873,10 +967,10 @@ export const renderScreen = (
873
967
  const columns = laneColumnsOf(active)
874
968
  const badgeTexts = BADGE_TEXTS[language]
875
969
  const blocks = active.map((lane) => laneBlock(lane, state, width, columns, badgeTexts))
876
- const notice = watchNotice(active, watch, badgeTexts)
970
+ const notice = watchNotice({ lanes: active, watch, watchesTickets }, badgeTexts)
877
971
  const clock = notice ? `${paint.yellow(notice)}${COLUMN_SEPARATOR}${paint.dim(time)}` : paint.dim(time)
878
972
  return {
879
- header: [spread(paint.bold(title), clock, width), rule(width)],
973
+ header: [spread(`${HEADER_MARK} ${paint.bold(title)}`, clock, width), rule(width)],
880
974
  body: joinFrames([
881
975
  ...separate(blocks, width),
882
976
  ...(parked.length > 0 ? [textFrame([rule(width), ...parked])] : []),
package/src/plans/live.ts CHANGED
@@ -3,6 +3,7 @@ import { dirname } from 'node:path'
3
3
  import { pathToFileURL } from 'node:url'
4
4
  import { terminalSafe } from '../plan-format/terminal-text'
5
5
  import { openInBrowser } from '../terminal/open'
6
+ import type { LaneMemory } from './current-phase'
6
7
  import { errorText } from './error-text'
7
8
  import {
8
9
  type FetchState,
@@ -50,6 +51,8 @@ type Live = {
50
51
  /** This clone's `FETCH_HEAD`, watched beside the plan files; null when git names none. */
51
52
  fetchHead: string | null
52
53
  reported: Map<string, string | null>
54
+ /** Per lane, the ticks seen at the last read and the phase that moved last; the herd's start empties it. */
55
+ memories: Map<string, LaneMemory>
53
56
  pending: Timer | null
54
57
  /** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of `page`. */
55
58
  read: Snapshot | null
@@ -185,7 +188,7 @@ const refresh = (live: Live, onChange: () => void): Promise<void> => {
185
188
 
186
189
  const readOnce = async (live: Live, onChange: () => void) => {
187
190
  try {
188
- const read = await loadSnapshot(live.source, live.pullRequests)
191
+ const read = await loadSnapshot(live.source, live.pullRequests, live.memories)
189
192
  const next = withKnownPage(live, read)
190
193
  live.read = read
191
194
  if (!live.snapshot) live.view = { ...live.view, selection: startSelection(next, live.source.options) }
@@ -277,6 +280,7 @@ export const runLive = (source: Source): void => {
277
280
  watchers: new Map(),
278
281
  fetchHead: fetchHeadPath(source.root),
279
282
  reported: new Map(),
283
+ memories: new Map(),
280
284
  pending: null,
281
285
  read: null,
282
286
  page: null,
@@ -0,0 +1,37 @@
1
+ import { lstatSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { isPhaseModelRecord, type PhaseModelRecord } from './phase-models'
4
+
5
+ /** The file name the hook gives a record: 16 hex characters of a hash over plan and phase. */
6
+ const RECORD_FILE = /^[0-9a-f]{16}\.json$/
7
+
8
+ /** A record is a line of JSON; anything larger was not written by the hook. */
9
+ const MAX_RECORD_BYTES = 4 * 1024
10
+
11
+ /** A plain file of record size: a link could read elsewhere and a pipe would block the herd, so neither is read. */
12
+ const isRecordFile = (path: string): boolean => {
13
+ const entry = lstatSync(path)
14
+ return entry.isFile() && entry.size < MAX_RECORD_BYTES
15
+ }
16
+
17
+ const recordOf = (path: string): PhaseModelRecord[] => {
18
+ try {
19
+ if (!isRecordFile(path)) return []
20
+ const record: unknown = JSON.parse(readFileSync(path, 'utf8'))
21
+ return isPhaseModelRecord(record) ? [record] : []
22
+ } catch {
23
+ return []
24
+ }
25
+ }
26
+
27
+ /** Every record of the model log in the main checkout; nothing when the directory is missing or unreadable. */
28
+ export const readPhaseModels = (root: string): PhaseModelRecord[] => {
29
+ const directory = join(root, '.jackherd', 'models')
30
+ try {
31
+ return readdirSync(directory)
32
+ .filter((name) => RECORD_FILE.test(name))
33
+ .flatMap((name) => recordOf(join(directory, name)))
34
+ } catch {
35
+ return []
36
+ }
37
+ }
@@ -0,0 +1,70 @@
1
+ import { type LaneProgress, resolvePlanPath } from '../plan-format'
2
+ import { isRecord } from '../watch/json-guards'
3
+
4
+ /** The model families a phase ran on and was recommended, as lower-case words; `fits` null without a recommendation. */
5
+ export type PhaseModel = { used: string; recommended: string | null; fits: boolean | null }
6
+
7
+ /**
8
+ * A record as the kit's model log hook writes it to `.jackherd/models/<key>.json` in the main checkout: the plan from
9
+ * the repository root, the phase, the model id of the subagent's last answer and when it ended. Same shape as
10
+ * `PhaseModelRecord` in the hook, which this package does not import at run time.
11
+ */
12
+ export type PhaseModelRecord = PhaseModel & { plan: string; phase: string; model: string; at: number }
13
+
14
+ /** A family as a word of at most 16 lower-case letters, so a record cannot widen the phase row. */
15
+ const FAMILY = /^[a-z]{1,16}$/
16
+
17
+ const isFamily = (value: unknown): value is string => typeof value === 'string' && FAMILY.test(value)
18
+
19
+ const isVerdict = (recommended: unknown, fits: unknown): boolean =>
20
+ (recommended === null && fits === null) || (isFamily(recommended) && typeof fits === 'boolean')
21
+
22
+ export const isPhaseModelRecord = (value: unknown): value is PhaseModelRecord =>
23
+ isRecord(value) &&
24
+ typeof value.plan === 'string' &&
25
+ typeof value.phase === 'string' &&
26
+ typeof value.model === 'string' &&
27
+ isFamily(value.used) &&
28
+ isVerdict(value.recommended, value.fits) &&
29
+ typeof value.at === 'number'
30
+
31
+ /** The key of a phase of a lane's step in `LaneView.models`. */
32
+ export const phaseModelKey = (stepIndex: number, phaseId: string): string => `${stepIndex}:${phaseId}`
33
+
34
+ const recordKey = (plan: string, phase: string): string => `${plan}\n${phase}`
35
+
36
+ /** The newest record per plan and phase. */
37
+ const newestByPhase = (records: readonly PhaseModelRecord[]): Map<string, PhaseModelRecord> => {
38
+ const newest = new Map<string, PhaseModelRecord>()
39
+ for (const record of records) {
40
+ const key = recordKey(record.plan, record.phase)
41
+ const known = newest.get(key)
42
+ if (!known || record.at > known.at) newest.set(key, record)
43
+ }
44
+ return newest
45
+ }
46
+
47
+ /** The model of every phase of the lane's steps that has a record, by `phaseModelKey`. */
48
+ export const laneModelsOf = (
49
+ progress: LaneProgress,
50
+ records: readonly PhaseModelRecord[],
51
+ sprintDirectory: string,
52
+ ): Record<string, PhaseModel> => {
53
+ const newest = newestByPhase(records)
54
+ return Object.fromEntries(
55
+ progress.steps.flatMap(({ step, phases }, stepIndex) => {
56
+ const plan = step.planHref ? resolvePlanPath(sprintDirectory, step.planHref) : null
57
+ return phases.flatMap((phase) => {
58
+ const record = plan === null ? undefined : newest.get(recordKey(plan, phase.id))
59
+ return record
60
+ ? [
61
+ [
62
+ phaseModelKey(stepIndex, phase.id),
63
+ { used: record.used, recommended: record.recommended, fits: record.fits },
64
+ ],
65
+ ]
66
+ : []
67
+ })
68
+ }),
69
+ )
70
+ }