jackherd 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/INSTALL.md +176 -0
  3. package/README.md +260 -315
  4. package/assets/branding/README.md +7 -3
  5. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  6. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  7. package/assets/branding/jackherd-logo-dark.svg +1 -1
  8. package/assets/icons/bark-dark.svg +1 -0
  9. package/assets/icons/bark.svg +1 -0
  10. package/assets/icons/bowl-dark.svg +1 -0
  11. package/assets/icons/bowl.svg +1 -0
  12. package/assets/icons/decide-dark.svg +1 -0
  13. package/assets/icons/decide.svg +1 -0
  14. package/assets/icons/ear-dark.svg +1 -0
  15. package/assets/icons/ear.svg +1 -0
  16. package/assets/icons/lanes-dark.svg +1 -0
  17. package/assets/icons/lanes.svg +1 -0
  18. package/assets/icons/plan-dark.svg +1 -0
  19. package/assets/icons/plan.svg +1 -0
  20. package/assets/icons/space.svg +1 -0
  21. package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
  22. package/dist/config.d.ts +14 -3
  23. package/dist/config.js +111 -10
  24. package/dist/plan-format.d.ts +180 -77
  25. package/dist/plan-format.js +292 -93
  26. package/docs/lanes-dark.svg +50 -0
  27. package/docs/lanes.svg +50 -1
  28. package/docs/skills-dark.svg +65 -0
  29. package/docs/skills.svg +65 -1
  30. package/docs/workflow-dark.svg +138 -0
  31. package/docs/workflow.svg +135 -169
  32. package/herdr-plugin/herdr-plugin.toml +6 -6
  33. package/herdr-plugin/open-plans.ts +1 -1
  34. package/herdr-plugin/restore-plans.ts +7 -7
  35. package/install.sh +1 -1
  36. package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
  37. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
  38. package/kit/hooks/hooks/approval-gate/index.ts +69 -0
  39. package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
  40. package/kit/hooks/hooks/approval-gate/request.ts +111 -0
  41. package/kit/hooks/hooks/approval-gate/run.ts +94 -0
  42. package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
  43. package/kit/hooks/hooks/approval-gate/store.ts +70 -0
  44. package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
  45. package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
  46. package/kit/hooks/hooks/merge-guard/run.ts +2 -1
  47. package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
  48. package/kit/hooks/hooks/model-gate/index.ts +58 -0
  49. package/kit/hooks/hooks/model-gate/run.ts +63 -0
  50. package/kit/hooks/hooks/model-log/index.ts +60 -0
  51. package/kit/hooks/hooks/model-log/run.ts +57 -0
  52. package/kit/hooks/hooks/model-log/store.ts +40 -0
  53. package/kit/hooks/hooks/model-log/transcript.ts +37 -0
  54. package/kit/hooks/hooks/shared/checkout.ts +22 -0
  55. package/kit/hooks/hooks/shared/command.ts +6 -0
  56. package/kit/hooks/hooks/shared/config.ts +21 -0
  57. package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
  58. package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
  59. package/kit/hooks/hooks/shared/files.ts +19 -0
  60. package/kit/hooks/hooks/shared/fresh.ts +4 -0
  61. package/kit/hooks/hooks/shared/json.ts +4 -0
  62. package/kit/hooks/hooks/shared/model-family.ts +48 -0
  63. package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
  64. package/kit/hooks/hooks/shared/private-directory.ts +29 -0
  65. package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
  66. package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
  67. package/kit/hooks/hooks.json +25 -0
  68. package/kit/workflow/skills/follow/SKILL.md +4 -3
  69. package/kit/workflow/skills/implement/SKILL.md +6 -6
  70. package/kit/workflow/skills/orchestrate/SKILL.md +113 -30
  71. package/kit/workflow/skills/plan/SKILL.md +5 -5
  72. package/kit/workflow/skills/start/SKILL.md +2 -2
  73. package/package.json +4 -1
  74. package/schemas/jackherd.schema.json +43 -5
  75. package/src/check/command.ts +31 -4
  76. package/src/check/deferral-arrows.ts +1 -1
  77. package/src/check/parallel-report.ts +23 -0
  78. package/src/check/plan-structure-check.ts +120 -53
  79. package/src/cli.ts +8 -2
  80. package/src/config/nested-quantifier.ts +73 -0
  81. package/src/config/repository-directory.ts +11 -0
  82. package/src/config/schema.ts +63 -8
  83. package/src/plan-format/board-prose.ts +36 -0
  84. package/src/plan-format/dependencies.ts +227 -0
  85. package/src/plan-format/documentation-gate.ts +16 -0
  86. package/src/plan-format/index.ts +23 -1
  87. package/src/plan-format/lanes.ts +35 -1
  88. package/src/plan-format/model-column.ts +52 -0
  89. package/src/plan-format/phases.ts +16 -1
  90. package/src/plan-format/plan-structure.ts +10 -28
  91. package/src/plan-format/progress.ts +7 -29
  92. package/src/plans/command.ts +3 -3
  93. package/src/plans/current-phase.ts +71 -0
  94. package/src/plans/ensure-panes.ts +2 -2
  95. package/src/plans/herd-command.ts +4 -0
  96. package/src/plans/lane-discovery.ts +14 -2
  97. package/src/plans/lane-tickets.ts +69 -0
  98. package/src/plans/layout.ts +182 -88
  99. package/src/plans/live.ts +5 -1
  100. package/src/plans/phase-models-read.ts +37 -0
  101. package/src/plans/phase-models.ts +70 -0
  102. package/src/plans/plan-panes.ts +8 -7
  103. package/src/plans/pull-requests.ts +78 -4
  104. package/src/plans/selection.ts +1 -40
  105. package/src/plans/settings.ts +9 -3
  106. package/src/plans/shown-with-more.ts +5 -0
  107. package/src/plans/snapshot.ts +82 -8
  108. package/src/plans/sprint-html.ts +2 -2
  109. package/src/plans/supervisor.ts +5 -4
  110. package/src/plans/symbols.ts +9 -6
  111. package/src/plans/texts.ts +30 -13
  112. package/src/plans/view.ts +1 -0
  113. package/src/sync/herdr-plugin.ts +1 -1
  114. package/src/sync/next-steps.ts +1 -1
  115. package/src/sync/render.ts +2 -1
  116. package/src/sync/setup-questions.ts +2 -2
  117. package/src/terminal/text.ts +4 -0
  118. package/src/terminal/types.ts +1 -1
  119. package/src/vocabulary/index.ts +39 -0
  120. package/src/watch/approvals.ts +96 -0
  121. package/src/watch/command.ts +154 -50
  122. package/src/watch/failed-tests.ts +52 -0
  123. package/src/watch/failure-read.ts +56 -0
  124. package/src/watch/failures.ts +107 -0
  125. package/src/watch/feedback-item.ts +21 -0
  126. package/src/watch/feedback-read.ts +2 -1
  127. package/src/watch/feedback.ts +37 -28
  128. package/src/watch/gh-process.ts +53 -0
  129. package/src/watch/gh.ts +10 -20
  130. package/src/watch/heartbeat-progress.ts +1 -1
  131. package/src/watch/lane-model.ts +54 -0
  132. package/src/watch/lane-parallel.ts +46 -0
  133. package/src/watch/lane-progress.ts +28 -4
  134. package/src/watch/lane-report.ts +62 -9
  135. package/src/watch/lane-sources.ts +1 -1
  136. package/src/watch/no-finding.ts +47 -0
  137. package/src/watch/plan-badges-read.ts +19 -7
  138. package/src/watch/plan-badges.ts +59 -7
  139. package/src/watch/plan-views.ts +5 -5
  140. package/src/watch/previews.ts +3 -9
  141. package/src/watch/pull-requests.ts +12 -2
  142. package/src/watch/recorded-reads.ts +25 -0
  143. package/src/watch/source-fields.ts +4 -0
  144. package/src/watch/source-tickets.ts +99 -0
  145. package/src/watch/sources.ts +62 -15
  146. package/src/watch/texts.ts +21 -1
  147. package/src/watch/watch-defaults.ts +8 -0
  148. package/src/watch/watch-events.ts +64 -4
  149. package/src/watch/watcher-lock.ts +1 -1
  150. package/src/watch/workflow-runs.ts +5 -7
  151. package/docs/lanes.mmd +0 -9
  152. package/docs/skills.mmd +0 -10
@@ -40,14 +40,14 @@ const apply = (action: PaneAction): string => {
40
40
  ? splitPaneOf(runHerdr(['pane', 'split', action.pane, '--direction', 'right', '--no-focus']))
41
41
  : action.pane
42
42
  runHerdr(['pane', 'run', target, action.command])
43
- return `${action.kind === 'split' ? `split ${action.pane}, ` : ''}started the plans view in ${target}`
43
+ return `${action.kind === 'split' ? `split ${action.pane}, ` : ''}started the herd in ${target}`
44
44
  } catch (error) {
45
45
  return `${action.pane}: ${errorText(error)}`
46
46
  }
47
47
  }
48
48
 
49
49
  /**
50
- * `plans --ensure`: brings back every missing plans view beside the coordinator and each lane of the board, and
50
+ * `herd --ensure`: brings back every missing herd pane beside the coordinator and each lane of the board, and
51
51
  * leaves running ones alone, so it can run after every start. A lane gets its view once its agent runs. One line per
52
52
  * action or skipped target, none when every view runs.
53
53
  */
@@ -0,0 +1,4 @@
1
+ /** The CLI word of the herd and the alias it had up to 1.6, which running panes of that version still carry. */
2
+ export const HERD_COMMAND = { name: 'herd', alias: 'plans' } as const
3
+
4
+ export const HERD_COMMAND_WORDS: readonly string[] = Object.values(HERD_COMMAND)
@@ -9,14 +9,26 @@ export type LaneSources = {
9
9
 
10
10
  export type LaneSetChange = { added: string[]; removed: string[] }
11
11
 
12
+ /**
13
+ * The helper agents of the lanes: an agent named `<lane>-<purpose>` that a lane started in a pane of its own, such as
14
+ * for a measurement. A name that is a worktree of its own is that lane, not a helper. The prefix only decides whether
15
+ * the agent is watched, not whose it is: under lanes `a` and `a-b` the agent `a-b-c` matches both and is watched once,
16
+ * and any agent named after a lane with a suffix is watched, whoever started it.
17
+ */
18
+ const helpersOf = (lanes: readonly string[], worktrees: readonly string[], agentNames: readonly string[]): string[] =>
19
+ [...new Set(agentNames)].filter(
20
+ (name) => !worktrees.includes(name) && lanes.some((lane) => name.startsWith(`${lane}-`)),
21
+ )
22
+
12
23
  /**
13
24
  * The lanes to watch when none are named: a worktree directory whose name a herdr agent carries or
14
25
  * the board's lanes table lists. A worktree with neither (a branch parked in its own checkout) is no
15
- * lane, and an agent or a row without a worktree is none either.
26
+ * lane, and an agent or a row without a worktree is none either, except a lane's helper agent (`helpersOf`).
16
27
  */
17
28
  export const discoverLanes = ({ worktrees, agentNames, sprintLanes }: LaneSources): string[] => {
18
29
  const claimed = new Set([...agentNames, ...sprintLanes])
19
- return [...new Set(worktrees)].filter((worktree) => claimed.has(worktree)).sort()
30
+ const lanes = [...new Set(worktrees)].filter((worktree) => claimed.has(worktree))
31
+ return [...lanes, ...helpersOf(lanes, worktrees, agentNames)].sort()
20
32
  }
21
33
 
22
34
  /** The lanes that joined and left between two discoveries, each sorted. */
@@ -0,0 +1,69 @@
1
+ import type { SprintLane } from '../plan-format'
2
+ import type { SourceTicket, TicketBadge } from '../watch/plan-badges'
3
+ import type { LinkChip } from './link-chips'
4
+ import { shownWithMore } from './shown-with-more'
5
+
6
+ /** The tickets of one lane as drawn: the first `SHOWN_TICKETS`, how many more, and how many of all are new. */
7
+ export type LaneTickets = { shown: TicketBadge[]; more: number; newCount: number }
8
+
9
+ export const SHOWN_TICKETS = 3
10
+
11
+ const WORD_CHARACTER = /[\p{L}\p{N}_]/u
12
+
13
+ const isWordCharacter = (character: string | undefined): boolean =>
14
+ character !== undefined && WORD_CHARACTER.test(character)
15
+
16
+ /**
17
+ * Whether `text` names `id` as a whole word: no letter, digit or `_` right before or after it, so `T-12` is not
18
+ * found in `T-123`. A plain search per occurrence, no regular expression: a lookbehind over a long plan cost
19
+ * seconds per refresh. An empty id is found nowhere.
20
+ */
21
+ const namesWord = (text: string, id: string): boolean => {
22
+ if (id === '') return false
23
+ for (let at = text.indexOf(id); at !== -1; at = text.indexOf(id, at + 1)) {
24
+ if (!isWordCharacter(text[at - 1]) && !isWordCharacter(text[at + id.length])) return true
25
+ }
26
+ return false
27
+ }
28
+
29
+ const belongsTo = (ticket: SourceTicket, lane: string, texts: readonly string[]): boolean =>
30
+ ticket.lane === lane || [lane, ...texts].some((text) => namesWord(text, ticket.id))
31
+
32
+ const onceById = (tickets: readonly SourceTicket[]): SourceTicket[] =>
33
+ tickets.filter((ticket, index) => tickets.findIndex(({ id }) => id === ticket.id) === index)
34
+
35
+ const badgeOf = ({ lane: _lane, ...badge }: SourceTicket): TicketBadge => badge
36
+
37
+ /** What the lane's board rows say of it beside its name, for finding ticket ids: the text of each row. */
38
+ export const boardRowTexts = (lane: SprintLane): string[] => lane.steps.map(({ label }) => label)
39
+
40
+ /**
41
+ * The tickets of `lane`: those of a source that names it, and those whose id the lane's name or `texts` (its board
42
+ * rows and its plans) name as a whole word, as written. A ticket listed twice counts once, from its first source.
43
+ * New tickets come first, the rest keep their order; null when the lane has none, so the view draws nothing.
44
+ */
45
+ export const laneTicketsOf = (
46
+ tickets: readonly SourceTicket[],
47
+ lane: string,
48
+ texts: readonly string[],
49
+ ): LaneTickets | null => {
50
+ const own = onceById(tickets.filter((ticket) => belongsTo(ticket, lane, texts))).map(badgeOf)
51
+ if (own.length === 0) return null
52
+ const fresh = own.filter(({ isNew }) => isNew)
53
+ const ordered = [...fresh, ...own.filter(({ isNew }) => !isNew)]
54
+ return {
55
+ ...shownWithMore(ordered, SHOWN_TICKETS),
56
+ newCount: fresh.length,
57
+ }
58
+ }
59
+
60
+ /** The lane's link chips without those whose URL a shown ticket badge opens: the badge takes the chip's place. */
61
+ export const withoutTicketLinks = (
62
+ chips: Record<string, LinkChip[]>,
63
+ tickets: LaneTickets,
64
+ ): Record<string, LinkChip[]> => {
65
+ const shown = new Set(tickets.shown.flatMap(({ url }) => (url ? [url] : [])))
66
+ return Object.fromEntries(
67
+ Object.entries(chips).map(([href, list]) => [href, list.filter(({ url }) => !shown.has(url))]),
68
+ )
69
+ }
@@ -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])] : []),