jackherd 1.9.0 → 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 (204) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +50 -32
  4. package/dist/{chunk-MCP4XJQH.js → chunk-5MQBCUQ5.js} +9 -4
  5. package/dist/config.d.ts +39 -4
  6. package/dist/config.js +67 -26
  7. package/dist/plan-format.d.ts +45 -6
  8. package/dist/plan-format.js +46 -6
  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/ask-hint/decision.ts +82 -14
  15. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  16. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  17. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  18. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  19. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  20. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  21. package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
  22. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  23. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  24. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  25. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  26. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  27. package/kit/hooks/hooks/open-question/index.ts +26 -0
  28. package/kit/hooks/hooks/open-question/record.ts +135 -0
  29. package/kit/hooks/hooks/open-question/run.ts +52 -0
  30. package/kit/hooks/hooks/open-question/store.ts +39 -0
  31. package/kit/hooks/hooks/sent-file/store.ts +3 -12
  32. package/kit/hooks/hooks/shared/config.ts +7 -0
  33. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  34. package/kit/hooks/hooks/shared/files.ts +7 -0
  35. package/kit/hooks/hooks/shared/lane.ts +1 -1
  36. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  37. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  38. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  39. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  40. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  41. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  42. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  43. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  44. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  45. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  46. package/kit/hooks/hooks.json +13 -5
  47. package/kit/review/agents/skill-review.md +34 -0
  48. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  49. package/kit/workflow/skills/follow/SKILL.md +1 -8
  50. package/kit/workflow/skills/implement/SKILL.md +8 -6
  51. package/kit/workflow/skills/orchestrate/SKILL.md +210 -433
  52. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  53. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  54. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  55. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  56. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  57. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  58. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  59. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  60. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  61. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  62. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  63. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  64. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  65. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  66. package/kit/workflow/skills/plan/SKILL.md +6 -5
  67. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  68. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  69. package/package.json +3 -1
  70. package/recipes/README.md +2 -1
  71. package/recipes/sources.md +1 -1
  72. package/schemas/jackherd.schema.json +59 -5
  73. package/src/check/agent-text-reviewers.ts +23 -0
  74. package/src/check/command.ts +2 -0
  75. package/src/check/plan-structure-check.ts +49 -2
  76. package/src/check/plan.ts +8 -1
  77. package/src/check/schema.ts +9 -0
  78. package/src/check/skill-budget-defaults.ts +55 -0
  79. package/src/check/skill-budgets.ts +185 -0
  80. package/src/cli.ts +1 -0
  81. package/src/config/auto-window.ts +8 -0
  82. package/src/config/link-kinds.ts +7 -1
  83. package/src/config/schema.ts +58 -24
  84. package/src/lane/close-board.ts +106 -0
  85. package/src/lane/close-check.ts +183 -0
  86. package/src/lane/close-command-line.ts +11 -0
  87. package/src/lane/close-command.ts +217 -0
  88. package/src/lane/close-options.ts +43 -0
  89. package/src/lane/close-survey.ts +83 -0
  90. package/src/lane/close.ts +141 -0
  91. package/src/lane/command.ts +13 -0
  92. package/src/lane-prompt/build.ts +74 -0
  93. package/src/lane-prompt/command.ts +87 -0
  94. package/src/lane-prompt/input.ts +116 -0
  95. package/src/lane-prompt/options.ts +68 -0
  96. package/src/lane-prompt/texts.ts +101 -0
  97. package/src/plan-format/clock.ts +8 -0
  98. package/src/plan-format/dependencies.ts +7 -4
  99. package/src/plan-format/index.ts +1 -0
  100. package/src/plan-format/short-names.ts +71 -0
  101. package/src/plan-format/waiting.ts +7 -2
  102. package/src/plans/appointment-rows.ts +59 -0
  103. package/src/plans/ask-coordinator.ts +77 -0
  104. package/src/plans/ask-target.ts +25 -0
  105. package/src/plans/child-stop.ts +51 -0
  106. package/src/plans/closable-records.ts +34 -0
  107. package/src/plans/command.ts +3 -2
  108. package/src/plans/fetch.ts +26 -3
  109. package/src/plans/git.ts +19 -5
  110. package/src/plans/herd-claim.ts +173 -0
  111. package/src/plans/herd-memory.ts +36 -25
  112. package/src/plans/lane-plans.ts +44 -13
  113. package/src/plans/lane-summary.ts +253 -45
  114. package/src/plans/layout-phase.ts +29 -7
  115. package/src/plans/layout-summary.ts +89 -18
  116. package/src/plans/layout.ts +262 -68
  117. package/src/plans/link-chips.ts +1 -0
  118. package/src/plans/live.ts +107 -20
  119. package/src/plans/phase-ids.ts +8 -0
  120. package/src/plans/phase-source.ts +15 -0
  121. package/src/plans/plan-cache.ts +28 -0
  122. package/src/plans/plan-heads.ts +54 -0
  123. package/src/plans/pull-requests.ts +17 -2
  124. package/src/plans/question-records.ts +48 -0
  125. package/src/plans/questions-read.ts +13 -0
  126. package/src/plans/read-cache.ts +8 -0
  127. package/src/plans/record-id.ts +4 -0
  128. package/src/plans/restart-decision.ts +13 -4
  129. package/src/plans/selection.ts +98 -18
  130. package/src/plans/sent-files-read.ts +6 -15
  131. package/src/plans/sent-files.ts +7 -14
  132. package/src/plans/settings.ts +2 -1
  133. package/src/plans/signalled-phase.ts +42 -0
  134. package/src/plans/signals-read.ts +12 -4
  135. package/src/plans/snapshot.ts +85 -62
  136. package/src/plans/sprint-export.ts +11 -11
  137. package/src/plans/sprint-page.ts +5 -5
  138. package/src/plans/subagent-phases.ts +95 -0
  139. package/src/plans/subagent-tasks.ts +51 -0
  140. package/src/plans/subagents-read.ts +66 -17
  141. package/src/plans/subagents.ts +64 -6
  142. package/src/plans/supervisor.ts +14 -4
  143. package/src/plans/symbols.ts +3 -0
  144. package/src/plans/task-name.ts +34 -0
  145. package/src/plans/texts.ts +23 -6
  146. package/src/plans/view.ts +17 -9
  147. package/src/plans/waiting-click.ts +192 -37
  148. package/src/plans/waiting-records.ts +39 -0
  149. package/src/plans/waiting-window.ts +8 -0
  150. package/src/preview/command.ts +25 -3
  151. package/src/shared/private-location.ts +89 -0
  152. package/src/shared/stat-cache.ts +48 -0
  153. package/src/sync/kit-source.ts +29 -10
  154. package/src/sync/kit.ts +2 -0
  155. package/src/sync/manifest.ts +2 -0
  156. package/src/sync/plan-ask-rules.ts +94 -0
  157. package/src/sync/plan-hooks.ts +2 -0
  158. package/src/sync/render.ts +3 -3
  159. package/src/sync/settings-hooks.ts +4 -1
  160. package/src/sync/sync-plan.ts +1 -0
  161. package/src/sync/sync.ts +24 -3
  162. package/src/task-signals/claude-read.ts +27 -10
  163. package/src/terminal/text.ts +8 -3
  164. package/src/version/skill-diff.ts +46 -0
  165. package/src/vocabulary/index.ts +16 -6
  166. package/src/waiting/command.ts +22 -7
  167. package/src/watch/appointments.ts +1 -2
  168. package/src/watch/asking-lanes.ts +40 -0
  169. package/src/watch/base-tip.ts +11 -0
  170. package/src/watch/command.ts +330 -105
  171. package/src/watch/heartbeat-progress.ts +6 -18
  172. package/src/watch/kit-update.ts +28 -9
  173. package/src/watch/lane-name.ts +6 -0
  174. package/src/watch/lane-progress.ts +40 -8
  175. package/src/watch/lane-report-file.ts +20 -5
  176. package/src/watch/lane-report.ts +32 -4
  177. package/src/watch/lane-sources.ts +31 -3
  178. package/src/watch/main-checkout.ts +34 -0
  179. package/src/watch/plan-badges-read.ts +13 -7
  180. package/src/watch/pull-requests.ts +6 -2
  181. package/src/watch/reference-paths.ts +87 -0
  182. package/src/watch/running-subagents.ts +43 -0
  183. package/src/watch/skill-change.ts +124 -32
  184. package/src/watch/source-change.ts +48 -0
  185. package/src/watch/sources.ts +6 -3
  186. package/src/watch/stream-output.ts +4 -3
  187. package/src/watch/task-parallel-auto.ts +208 -0
  188. package/src/watch/task-parallel-data-paths.ts +46 -0
  189. package/src/watch/task-parallel-input.ts +5 -15
  190. package/src/watch/task-parallel-read.ts +3 -1
  191. package/src/watch/task-parallel.ts +36 -7
  192. package/src/watch/task-signal-missing.ts +69 -0
  193. package/src/watch/task-signal-read.ts +29 -0
  194. package/src/watch/texts.ts +10 -0
  195. package/src/watch/update-available.ts +6 -3
  196. package/src/watch/watch-events.ts +23 -0
  197. package/src/watch/watcher-lock.ts +25 -29
  198. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  199. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  200. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  201. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  202. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  203. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  204. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -28,6 +28,7 @@ import {
28
28
  type WatchHealth,
29
29
  } from '../watch/plan-badges'
30
30
  import type { UnplannedTask } from './active-tasks'
31
+ import { type AppointmentBlock, appointmentItems, appointmentKeys } from './appointment-rows'
31
32
  import { deeper, type Frame, joinFrames, nest, prefixed, rowFrame, TREE, textFrame } from './frame'
32
33
  import { LANE_STANDING, type LaneStanding, laneStanding, waitingNote } from './lane-standing'
33
34
  import { type LaneSummary, laneSummary } from './lane-summary'
@@ -41,25 +42,40 @@ import {
41
42
  nowLine,
42
43
  plain,
43
44
  } from './layout-phase'
44
- import { summaryChildren } from './layout-summary'
45
+ import { foldedTaskLine, summaryChildren } from './layout-summary'
45
46
  import { chipKey, type LinkChip, planUrlOf } from './link-chips'
46
47
  import type { PhaseModel } from './phase-models'
47
48
  import { type PlannedPlan, phaseList, plannedPhaseCount } from './planned'
48
49
  import { CHECKS, type ChecksState, failedTestKey, type PullRequest, pullRequestKey } from './pull-requests'
49
50
  import {
50
51
  type ClickTarget,
52
+ isBlockFolded,
51
53
  isLaneOpen,
52
54
  laneKey,
53
55
  PLANNED_KEY,
54
56
  type Selection,
55
57
  selectedKey,
58
+ TOP_BLOCK,
59
+ type TopBlockId,
56
60
  targetKeys,
57
61
  } from './selection'
58
62
  import type { SentChip } from './sent-files'
59
63
  import { shownWithMore } from './shown-with-more'
60
- import { chevronOf, NO_AGENT_SYMBOL, statusSymbol, statusWord } from './symbols'
64
+ import { chevronOf, LESS_MARK, NO_AGENT_SYMBOL, statusSymbol, statusWord } from './symbols'
61
65
  import { BADGE_TEXTS, type BadgeTexts, checksText, laneMarkText, TEXTS } from './texts'
62
- import { SHOWN_WAITING, WAITING_MARK, type WaitingRow, waitingRows } from './waiting-click'
66
+ import {
67
+ APPOINTMENT_MORE_KEY,
68
+ ASK_BUTTON,
69
+ type BlockHit,
70
+ type BlockRow,
71
+ blockRows,
72
+ moreKeyOf,
73
+ SHOWN_BLOCK_ROWS,
74
+ WAITING_MARK,
75
+ WAITING_MORE_KEY,
76
+ waitingKeys,
77
+ waitingRows,
78
+ } from './waiting-click'
63
79
 
64
80
  /**
65
81
  * Where a lane stands outside its plan. `agent` is the terminal adapter's status, null when no agent is listed;
@@ -105,13 +121,24 @@ export type OverviewView = {
105
121
  watchesTickets?: boolean
106
122
  /** The open items of the board's section of what waits on the user, shown under the title. */
107
123
  awaiting?: WaitingItem[]
124
+ /** The open appointments of the board, in a block of their own under the for-you block. */
125
+ appointments?: AppointmentBlock
108
126
  /** What agents report doing that no lane takes, one `now:` line each under the title. */
109
127
  unplaced?: UnplannedTask[]
110
128
  }
111
129
 
112
- /** A fixed header and footer around a body that scrolls; body lines and targets count from the body's top. */
113
- /** The drawn screen; `waitingLine` is the screen line (0-based) of the first for-you row, where a click is matched. */
114
- export type Screen = { header: string[]; body: Frame; footer: string[]; waitingLine: number }
130
+ /**
131
+ * The drawn screen: a fixed header and footer around a body that scrolls; body lines and targets count from the body's
132
+ * top. `waitingBlock` and `appointmentBlock` are what a click on the for-you and the appointment block is matched
133
+ * against, null while the block is not drawn.
134
+ */
135
+ export type Screen = {
136
+ header: string[]
137
+ body: Frame
138
+ footer: string[]
139
+ waitingBlock: BlockHit | null
140
+ appointmentBlock: BlockHit | null
141
+ }
115
142
 
116
143
  /**
117
144
  * Widths shared by every lane line: the name column, the folded pull request badges, and bar with context. They are
@@ -121,8 +148,32 @@ export type Screen = { header: string[]; body: Frame; footer: string[]; waitingL
121
148
  */
122
149
  type LaneColumns = { name: number; marks: number; right: number; phases: boolean; tally: number }
123
150
 
151
+ /**
152
+ * The lines of a block on top of the herd; the head of an open one is a rule of its own, a folded one is a plain line.
153
+ * `hit` is what a click on it is matched against, without the screen line the stack gives it.
154
+ */
155
+ type BlockLines = { open: boolean; lines: string[]; hit: Omit<BlockHit, 'firstLine'> }
156
+
157
+ /**
158
+ * What a block of the top of the herd is drawn from: its name, the bar of its lines, the paint of the mark that starts
159
+ * a row, the key of its `+<n>` line, its points and their rows.
160
+ */
161
+ type TopBlock = {
162
+ name: string
163
+ bar: () => string
164
+ markPaint: (text: string) => string
165
+ /** The paint of the count in the head while the block has points: amber for what waits on the user. */
166
+ countPaint: (text: string) => string
167
+ moreKey: string
168
+ expanded: boolean
169
+ keys: readonly string[]
170
+ rows: BlockRow[]
171
+ }
172
+
173
+ /** The blocks on top of the herd one under the other: their lines below the title, and the hit of each drawn block. */
174
+ type BlockStack = { lines: string[]; hits: (BlockHit | null)[] }
175
+
124
176
  const BRANCH_MARK = '⎇'
125
- const WOOF = 'woof'
126
177
  const SENT_MARK = '📎'
127
178
  const SYMBOL_WIDTH = 2
128
179
  const NAME_WIDTH_MAX = 24
@@ -140,7 +191,7 @@ const COLUMN_RULE = '│'
140
191
  /** The number badge of a three-digit pull request (`↗ #` and the digits, a cell of padding either side); a longer one widens it. */
141
192
  const PULL_REQUEST_BADGE_WIDTH = 8
142
193
  /**
143
- * Under the lane line's status symbol: where the pull request row and its badge line start (D96), and where the trunk
194
+ * Under the lane line's status symbol: where the pull request row and its badge line start, and where the trunk
144
195
  * of an open lane hangs, not under the fold arrow.
145
196
  */
146
197
  const UNDER_STATUS = ' '
@@ -189,12 +240,11 @@ const aheadLabel = ({ branch, ahead }: LaneStatus): string => {
189
240
  return ahead > 0 ? paint.teal(`↑${ahead}`) : ''
190
241
  }
191
242
 
243
+ const branchLabel = (status: LaneStatus): string =>
244
+ status.branch ? paint.dim(`${BRANCH_MARK} ${status.branch}`) : paint.dim(TEXTS.noWorktree)
245
+
192
246
  const gitLabel = (status: LaneStatus): string =>
193
- [
194
- status.branch ? paint.dim(`${BRANCH_MARK} ${status.branch}`) : paint.dim(TEXTS.noWorktree),
195
- aheadLabel(status),
196
- status.dirty ? paint.magenta(`±${status.dirty}`) : '',
197
- ]
247
+ [branchLabel(status), aheadLabel(status), status.dirty ? paint.magenta(`±${status.dirty}`) : '']
198
248
  .filter(Boolean)
199
249
  .join(' ')
200
250
 
@@ -247,7 +297,7 @@ const checksColumnOf = (pullRequest: PullRequest): number =>
247
297
  /**
248
298
  * `⟦↗ #<number>⟧ ✓ checks ⟦🌐 preview⟧ ⟦💬 3 new⟧ title`: the number as a badge and the check state in fixed
249
299
  * columns, the watcher's badges after them and the title last, cut with `…` on its one line. When the badges leave no
250
- * room for a title, they go on lines of their own below, starting in the column of the lane's status symbol (D96).
300
+ * room for a title, they go on lines of their own below, starting in the column of the lane's status symbol.
251
301
  */
252
302
  const pullRequestRow = (pullRequest: PullRequest, context: LaneContext): Frame => {
253
303
  const number = numberCell(pullRequest)
@@ -474,7 +524,7 @@ const chipCells = (chips: LinkChip[], tickets: LaneTickets | null, sent: readonl
474
524
 
475
525
  /**
476
526
  * `🌐 preview` opening the preview, `💬 <n> new` in amber opening the pull request, and `✋ changes requested` in dark
477
- * red opening it too, each only when there is one (D96).
527
+ * red opening it too, each only when there is one.
478
528
  */
479
529
  const badgeCells = (
480
530
  pullRequest: PullRequest,
@@ -494,15 +544,17 @@ const badgeCells = (
494
544
  }
495
545
 
496
546
  /**
497
- * The phase the lane line names: the open phase moved last, else the lane's current phase while it is open; never one
498
- * ticked to the end, whose state the `✓` line says (`not merged`). Null when there is neither.
547
+ * The phase the lane line names: the one its report or branch signals, else the open phase moved last, else the
548
+ * lane's current phase while it is open; never one ticked to the end, whose state the `✓` line says. Null when there
549
+ * is none.
499
550
  */
500
551
  const headPhase = (view: LaneView): PhaseProgress | null => {
552
+ const { signalled, latest } = summaryOf(view)
501
553
  const current = currentPhase(view.progress)
502
- return summaryOf(view).latest ?? (current && isPhaseOpen(current) ? current : null)
554
+ return signalled ?? latest ?? (current && isPhaseOpen(current) ? current : null)
503
555
  }
504
556
 
505
- /** `P9 Freigabe 0.x` for the phase the lane line names; empty when there is none. */
557
+ /** `P9 Release 1.0` for the phase the lane line names; empty when there is none. */
506
558
  const nextPhaseLabel = (view: LaneView): string => {
507
559
  const phase = headPhase(view)
508
560
  return phase ? `${phaseLabel(phase.id)}${phase.title ? ` ${plain(phase.title)}` : ''}` : ''
@@ -590,7 +642,7 @@ const laneRight = (view: LaneView, tallyWidth: number): string => {
590
642
  /** The cells of the phase count column, 0 when the pane leaves it out. */
591
643
  const tallyWidthOf = ({ phases, tally }: LaneColumns): number => (phases ? tally : 0)
592
644
 
593
- /** A folded lane's marks in the column of `columns.marks`, right before context and bar (D96). */
645
+ /** A folded lane's marks in the column of `columns.marks`, right before context and bar. */
594
646
  const markedRight = (view: LaneView, columns: LaneColumns): string => {
595
647
  const right = laneRight(view, tallyWidthOf(columns))
596
648
  const marks = laneMarks(view)
@@ -602,7 +654,7 @@ const markedRight = (view: LaneView, columns: LaneColumns): string => {
602
654
 
603
655
  /**
604
656
  * Before a lane's detail on a line of its own: blanks up to the column of the lane's name, so a folded lane's title
605
- * starts where its name does (17:40), as the branches of an open lane do; an open lane keeps its trunk in front.
657
+ * starts where its name does, as the branches of an open lane do; an open lane keeps its trunk in front.
606
658
  */
607
659
  const detailIndent = (open: boolean, nameColumn: number): string => {
608
660
  if (!open) return ' '.repeat(nameColumn)
@@ -610,19 +662,30 @@ const detailIndent = (open: boolean, nameColumn: number): string => {
610
662
  return `${paint.dim(trunk)}${' '.repeat(Math.max(nameColumn - visibleLength(trunk), 0))}`
611
663
  }
612
664
 
665
+ /** The task row below a folded lane's phase row, under the lane's name; none while the phase has no task on it. */
666
+ const foldedTaskRow = (view: LaneView, indent: string, width: number): string[] => {
667
+ const phase = headPhase(view)
668
+ const { active: running, subagentTasks } = summaryOf(view)
669
+ const active = running.find((each) => each.phase.id === phase?.id)
670
+ const line = active && foldedTaskLine(active, subagentTasks, TEXTS.herd, indent, width)
671
+ return line ? [line] : []
672
+ }
673
+
613
674
  const laneLines = (view: LaneView, open: boolean, width: number, columns: LaneColumns): string[] => {
614
675
  const { progress, status } = view
615
676
  const cells = padVisible(paint.bold(fit(progress.lane.name, columns.name)), columns.name)
616
677
  const head = `${paint.dim(chevronOf(open))} ${symbolCell(status.agent)} ${cells}`
617
678
  const right = open ? laneProgressColumn(view, tallyWidthOf(columns)) : markedRight(view, columns)
618
679
  const nameColumn = visibleLength(head) - visibleLength(cells)
619
- return withDetail(head, laneDetail(view, open), right, width, detailIndent(open, nameColumn))
680
+ const lines = withDetail(head, laneDetail(view, open), right, width, detailIndent(open, nameColumn))
681
+ return open ? lines : [...lines, ...foldedTaskRow(view, ' '.repeat(nameColumn), width)]
620
682
  }
621
683
 
622
684
  /** The parts of a session line: the session's title (the branch without one) at the left, the branch and the context value. */
623
- const sessionParts = (view: LaneView): { left: string; git: string; context: string } => {
685
+ const sessionParts = (view: LaneView): { left: string; git: string; branch: string; context: string } => {
624
686
  const git = gitLabel(view.status)
625
- return { left: view.sessionTitle ? plain(view.sessionTitle) : git, git, context: contextValue(view.context) }
687
+ const branch = branchLabel(view.status)
688
+ return { left: view.sessionTitle ? plain(view.sessionTitle) : git, git, branch, context: contextValue(view.context) }
626
689
  }
627
690
 
628
691
  /**
@@ -631,31 +694,36 @@ const sessionParts = (view: LaneView): { left: string; git: string; context: str
631
694
  * title cut. Without a title the branch stands at the left.
632
695
  */
633
696
  const sessionLine = (view: LaneView, width: number): string => {
634
- const { left, git, context } = sessionParts(view)
697
+ const { left, git, branch, context } = sessionParts(view)
635
698
  if (!view.sessionTitle) return spread(fit(git, Math.max(width - CONTEXT_CELL_WIDTH, 1)), context, width)
636
699
  const room = width - (context ? CONTEXT_CELL_WIDTH : 0)
637
- const withGit = visibleLength(left) + COLUMN_SEPARATOR.length + visibleLength(git) <= room
638
- const right = [withGit ? git : '', context].filter(Boolean).join(COLUMN_SEPARATOR)
700
+ const fitting = [git, branch].find(
701
+ (label) => visibleLength(left) + COLUMN_SEPARATOR.length + visibleLength(label) <= room,
702
+ )
703
+ const right = [fitting ?? '', context].filter(Boolean).join(COLUMN_SEPARATOR)
639
704
  return spread(fit(left, Math.max(room, 1)), right, width)
640
705
  }
641
706
 
642
707
  /**
643
708
  * The session line with the lane's one link chip on it, so the lane needs no row of chips: title, chip, branch and
644
- * context, the branch giving way first; without a title the branch at the left and the chip at the right. Null when
645
- * the line has no room for the chip beside the whole title, and the chip keeps its row of chips.
709
+ * context. When the line is short the ahead and dirty counts of the branch go first, then the title is cut with `…`;
710
+ * without a title the branch stands at the left and the chip at the right. Null when even the cut title would be
711
+ * narrower than `MIN_TITLE_WIDTH`, and the chip keeps its row of chips.
646
712
  */
647
713
  const sessionWithChip = (view: LaneView, chip: BadgeCell, width: number): Frame | null => {
648
- const { left, git, context } = sessionParts(view)
714
+ const { left, git, branch, context } = sessionParts(view)
649
715
  const tails = view.sessionTitle
650
716
  ? [
651
717
  [chip.cell, git, context],
652
- [chip.cell, context],
718
+ [chip.cell, branch, context],
653
719
  ]
654
720
  : [[chip.cell, context]]
655
- const right = tails
656
- .map((tail) => tail.filter(Boolean).join(COLUMN_SEPARATOR))
657
- .find((candidate) => visibleLength(left) + COLUMN_SEPARATOR.length + visibleLength(candidate) <= width)
658
- if (right === undefined) return null
721
+ const candidates = tails.map((tail) => tail.filter(Boolean).join(COLUMN_SEPARATOR))
722
+ const roomFor = (candidate: string) => width - COLUMN_SEPARATOR.length - visibleLength(candidate)
723
+ const narrowest = candidates[candidates.length - 1] ?? ''
724
+ const cuts = Boolean(view.sessionTitle) && roomFor(narrowest) >= MIN_TITLE_WIDTH
725
+ const right = candidates.find((candidate) => visibleLength(left) <= roomFor(candidate)) ?? (cuts ? narrowest : null)
726
+ if (right === null) return null
659
727
  const from = width - visibleLength(right)
660
728
  const targets = chip.key === null ? [] : [{ line: 0, from, to: from + visibleLength(chip.cell), key: chip.key }]
661
729
  return { lines: [spread(left, right, width)], cursorLine: -1, targets }
@@ -782,32 +850,153 @@ const plannedRows = (planned: PlannedPlan[], width: number, indent: string): str
782
850
 
783
851
  const rule = (width: number): string => paint.dim(RULE.repeat(width))
784
852
 
785
- /** The rule under the for-you block, labelled woof (Jack barks) so the block reads as a section of its own. */
786
- const barkingRule = (width: number): string => {
787
- const lead = `${RULE.repeat(2)} ${WOOF} `
788
- return paint.dim(`${lead}${RULE.repeat(Math.max(width - textWidth(lead), 0))}`)
853
+ /** The amber bar that starts every line of the for-you block: the one signal that this waits on the user. */
854
+ const waitingBar = (): string => STATUS_PAINT[AGENT_STATUS.blocked](WAITING_MARK)
855
+
856
+ /** The grey bar of the appointment block: it waits on nobody's answer. */
857
+ const appointmentBar = (): string => paint.dim(WAITING_MARK)
858
+
859
+ /**
860
+ * The head of an open block, `▾ <name> · <n> ──`, filled with the rule to the width, so it is the rule above the block.
861
+ * The label and the count wear the normal colour (the count in `countPaint`), only the rule is dim.
862
+ */
863
+ const blockHead = (name: string, count: number, width: number, countPaint: (text: string) => string): string => {
864
+ const lead = `${chevronOf(true)} ${name} ${ITEM_SEPARATOR} `
865
+ const used = textWidth(`${lead}${count} `)
866
+ return `${lead}${countPaint(String(count))} ${paint.dim(RULE.repeat(Math.max(width - used, 0)))}`
789
867
  }
790
868
 
791
- /** One row as lines: a dim bar and the text in bold on each, linked when the row has an address. */
792
- const waitingRowLines = ({ lines, url }: WaitingRow): string[] =>
793
- lines.map((line) => `${paint.dim(WAITING_MARK)} ${paint.bold(url ? link(url, line) : line)}`)
869
+ const markedLine = (line: string, row: BlockRow, markPaint: (text: string) => string): string =>
870
+ line.startsWith(row.mark) ? `${markPaint(row.mark)}${line.slice(row.mark.length)}` : line
871
+
872
+ /** The text from `from` on dimmed, without the blanks that pad it to the right edge. */
873
+ const dimmedRest = (text: string, from: number): string => {
874
+ const end = text.trimEnd().length
875
+ return from >= end ? text : `${text.slice(0, from)}${paint.dim(text.slice(from, end))}${text.slice(end)}`
876
+ }
794
877
 
795
878
  /**
796
- * What waits on the user's answer, on top of the herd until it is closed: `▌ 📎 name` for a sent file or an Artifact
797
- * and `▌ ? question` for a question of the board, up to five and `▌ +<n>` for the rest. A row wider than the pane is
798
- * cut to one line; once expanded it wraps over several.
879
+ * One row as lines: the bar on each, the mark painted on the first line only, the short title in the normal colour
880
+ * and the rest of an expanded row dim, linked when the row has an address; the `[ask]` button at the end of a
881
+ * question's first line wears the mark's paint.
799
882
  */
800
- const waitingLines = (waiting: readonly WaitingItem[], expanded: ReadonlySet<string>, width: number): string[] => {
801
- const more = Math.max(waiting.length - SHOWN_WAITING, 0)
802
- return [
803
- ...waitingRows(waiting, expanded, width).flatMap(waitingRowLines),
804
- ...(more > 0 ? [`${paint.dim(WAITING_MARK)} +${more}`] : []),
805
- ]
883
+ const blockRowLines = ({ bar, markPaint }: TopBlock, row: BlockRow): string[] =>
884
+ row.lines.map((line, index) => {
885
+ const withButton = index === 0 && row.askColumn !== null
886
+ const styled = dimmedRest(line, row.dimFrom[index] ?? line.length)
887
+ const marked = index === 0 ? markedLine(styled, row, markPaint) : styled
888
+ const text = withButton ? `${marked}${markPaint(ASK_BUTTON)}` : marked
889
+ return `${bar()} ${row.url ? link(row.url, text) : text}`
890
+ })
891
+
892
+ /** A folded block: the one line `▸ <name> · <n>` and the first title as a preview; a click matches no row and no `+<n>` line. */
893
+ const foldedBlockLines = ({ name, keys, rows }: TopBlock, width: number): BlockLines => {
894
+ const preview = rows[0]?.preview
895
+ const head = `${chevronOf(false)} ${name} ${ITEM_SEPARATOR} ${keys.length}`
896
+ return {
897
+ open: false,
898
+ lines: [paint.dim(fit(preview === undefined ? head : `${head} ${preview}`, width))],
899
+ hit: { rows: [], moreKey: null, keys },
900
+ }
806
901
  }
807
902
 
808
- /** A dimmed rule before every lane block but the first, folded or open: it shows where one lane ends. */
809
- const separate = (blocks: Frame[], width: number): Frame[] =>
810
- blocks.flatMap((frame, index) => (index === 0 ? [frame] : [textFrame([rule(width)]), frame]))
903
+ /**
904
+ * An open block on top of the herd: the head `▾ <name> · <n> ──`, one barred line per row (the first five, all while the
905
+ * block is expanded) and `▌ +<n>` for the rest, which turns into `▌ ▴ <n>` once all show and folds them again. A row wider than the pane is cut to
906
+ * one line; once expanded it wraps over several. The rule below is the stack's, not the block's.
907
+ */
908
+ const openBlockLines = (block: TopBlock, width: number): BlockLines => {
909
+ const count = block.keys.length
910
+ const more = Math.max(count - SHOWN_BLOCK_ROWS, 0)
911
+ const moreLine = block.expanded ? `${LESS_MARK} ${more}` : `+${more}`
912
+ return {
913
+ open: true,
914
+ lines: [
915
+ blockHead(block.name, count, width, count > 0 ? block.countPaint : (text) => text),
916
+ ...block.rows.flatMap((row) => blockRowLines(block, row)),
917
+ ...(more > 0 ? [`${block.bar()} ${moreLine}`] : []),
918
+ ],
919
+ hit: { rows: block.rows, moreKey: moreKeyOf(count, block.moreKey), keys: block.keys },
920
+ }
921
+ }
922
+
923
+ const blockLinesOf = (block: TopBlock, state: Selection, id: TopBlockId, width: number): BlockLines =>
924
+ isBlockFolded(state, id, block.keys) ? foldedBlockLines(block, width) : openBlockLines(block, width)
925
+
926
+ /**
927
+ * What waits on the user's answer, on top of the herd until it is closed: `▌ 📎 name` for a sent file or an
928
+ * Artifact, `▌ ? question` for a question of the board or of a session's dialog, in the amber of a lane that waits.
929
+ * One line while the user folded it.
930
+ */
931
+ const waitingLines = (waiting: readonly WaitingItem[], state: Selection, width: number): BlockLines => {
932
+ const keys = waitingKeys(waiting)
933
+ const block: TopBlock = {
934
+ name: TEXTS.waitingHeading,
935
+ bar: waitingBar,
936
+ markPaint: STATUS_PAINT[AGENT_STATUS.blocked],
937
+ countPaint: STATUS_PAINT[AGENT_STATUS.blocked],
938
+ moreKey: WAITING_MORE_KEY,
939
+ expanded: state.expanded.has(WAITING_MORE_KEY),
940
+ keys,
941
+ rows: waitingRows(waiting, state.expanded, width),
942
+ }
943
+ return blockLinesOf(block, state, TOP_BLOCK.waiting, width)
944
+ }
945
+
946
+ /** The open appointments of the board under the for-you block: `▌ ◷ Mo 09:03 Release 2.0 rollout`, in a grey bar and with a grey mark. */
947
+ const appointmentLines = ({ rows }: AppointmentBlock, state: Selection, width: number): BlockLines => {
948
+ const keys = appointmentKeys(rows)
949
+ const block: TopBlock = {
950
+ name: TEXTS.appointmentHeading,
951
+ bar: appointmentBar,
952
+ markPaint: paint.dim,
953
+ countPaint: (text) => text,
954
+ moreKey: APPOINTMENT_MORE_KEY,
955
+ expanded: state.expanded.has(APPOINTMENT_MORE_KEY),
956
+ keys,
957
+ rows: blockRows(appointmentItems(rows), state.expanded, width, APPOINTMENT_MORE_KEY),
958
+ }
959
+ return blockLinesOf(block, state, TOP_BLOCK.appointments, width)
960
+ }
961
+
962
+ const appointmentLinesOf = (
963
+ appointments: AppointmentBlock | undefined,
964
+ state: Selection,
965
+ width: number,
966
+ ): BlockLines | null =>
967
+ appointments && appointments.rows.length > 0 ? appointmentLines(appointments, state, width) : null
968
+
969
+ /**
970
+ * The blocks one under the other, `offset` lines below the top of the screen, with one rule between neighbours: the
971
+ * head of an open block is that rule, a folded block gets a plain one above it unless a folded block stands right
972
+ * above (folded blocks share the rule below them), and a plain rule closes the stack. An
973
+ * absent block (null) takes no line and has no hit.
974
+ */
975
+ const stackBlocks = (slots: readonly (BlockLines | null)[], offset: number, width: number): BlockStack => {
976
+ const lines: string[] = []
977
+ const hits: (BlockHit | null)[] = []
978
+ let previousFolded = false
979
+ for (const slot of slots) {
980
+ if (slot && !slot.open && !previousFolded) lines.push(rule(width))
981
+ hits.push(slot && { ...slot.hit, firstLine: offset + lines.length + 1 })
982
+ if (slot) {
983
+ lines.push(...slot.lines)
984
+ previousFolded = !slot.open
985
+ }
986
+ }
987
+ return { lines: lines.length > 0 ? [...lines, rule(width)] : lines, hits }
988
+ }
989
+
990
+ /**
991
+ * A dimmed rule before every section of the body but the first, which shows where one ends; none between two
992
+ * one-line lane blocks (the first `laneCount` sections), so folded lanes stand together as a list.
993
+ */
994
+ const separate = (sections: Frame[], width: number, laneCount: number): Frame[] =>
995
+ sections.flatMap((frame, index) => {
996
+ if (index === 0) return [frame]
997
+ const listed = index < laneCount && frame.lines.length === 1 && sections[index - 1]?.lines.length === 1
998
+ return listed ? [frame] : [textFrame([rule(width)]), frame]
999
+ })
811
1000
 
812
1001
  /**
813
1002
  * Why badges may be missing, said beside the clock: no watcher runs, or its state is stale. Nothing while it is live,
@@ -856,11 +1045,11 @@ const partitionLanes = (lanes: LaneView[]): Record<'active' | 'waiting' | 'noAge
856
1045
  /**
857
1046
  * Title and clock on top, the key hint at the bottom (with the legend after `?`), and in between one
858
1047
  * block per lane: the lane line with its total bar, and for an open lane the phase rows. Folded lanes are
859
- * one line each and every lane block after the first is preceded by a dimmed rule. Lanes that wait and
1048
+ * one line each, and a dimmed rule stands between lane blocks unless both are one line. Lanes that wait and
860
1049
  * lanes with a worktree but no agent each share a closing block; finished lanes are not drawn.
861
1050
  */
862
1051
  export const renderScreen = (
863
- { title, time, lanes, planned, watch, watchesTickets, awaiting = [], unplaced = [] }: OverviewView,
1052
+ { title, time, lanes, planned, watch, watchesTickets, awaiting = [], appointments, unplaced = [] }: OverviewView,
864
1053
  state: Selection,
865
1054
  width: number,
866
1055
  ): Screen => {
@@ -871,19 +1060,24 @@ export const renderScreen = (
871
1060
  const blocks = active.map((lane) => laneBlock(lane, state, width, columns, badgeTexts))
872
1061
  const notice = watchNotice({ lanes: active, watch, watchesTickets }, badgeTexts)
873
1062
  const clock = notice ? `${paint.yellow(notice)}${COLUMN_SEPARATOR}${paint.dim(time)}` : paint.dim(time)
874
- const top = [spread(`${HEADER_MARK} ${paint.bold(title)}`, clock, width), rule(width)]
1063
+ const titleLine = spread(`${HEADER_MARK} ${paint.bold(title)}`, clock, width)
1064
+ const forYou = awaiting.length > 0 ? waitingLines(awaiting, state, width) : null
1065
+ const dated = appointmentLinesOf(appointments, state, width)
1066
+ const nowLines = unplaced.map((task) => nowLine(task, width))
1067
+ const above = nowLines.length > 0 ? [titleLine, rule(width), ...nowLines] : [titleLine]
1068
+ const stack = stackBlocks([forYou, dated], above.length, width)
1069
+ const endsWithRule = stack.lines.length > 0 || nowLines.length === 0
1070
+ const sections = [
1071
+ ...blocks,
1072
+ ...(parked.length > 0 ? [textFrame(parked)] : []),
1073
+ ...(planned.length > 0 ? [plannedFrame(planned, state, width)] : []),
1074
+ ]
1075
+ const lead = !endsWithRule && blocks.length === 0 && sections.length > 0 ? [textFrame([rule(width)])] : []
875
1076
  return {
876
- waitingLine: top.length + unplaced.length,
877
- header: [
878
- ...top,
879
- ...unplaced.map((task) => nowLine(task, width)),
880
- ...(awaiting.length > 0 ? [...waitingLines(awaiting, state.expanded, width), barkingRule(width)] : []),
881
- ],
882
- body: joinFrames([
883
- ...separate(blocks, width),
884
- ...(parked.length > 0 ? [textFrame([rule(width), ...parked])] : []),
885
- ...(planned.length > 0 ? [textFrame([rule(width)]), plannedFrame(planned, state, width)] : []),
886
- ]),
1077
+ waitingBlock: stack.hits[0],
1078
+ appointmentBlock: stack.hits[1],
1079
+ header: [...above, ...(stack.lines.length > 0 || nowLines.length > 0 ? stack.lines : [rule(width)])],
1080
+ body: joinFrames([...lead, ...separate(sections, width, blocks.length)]),
887
1081
  footer: [
888
1082
  rule(width),
889
1083
  fit(paint.dim(TEXTS.hint), width),
@@ -14,6 +14,7 @@ const MARKS = /\*\*|`/g
14
14
  const CONTROL = /\p{Cc}/gu
15
15
 
16
16
  const linksOf = (line: string): Found[] => {
17
+ if (!line.includes('http')) return []
17
18
  const found: Found[] = []
18
19
  const take = (url: string, text: string | null, index: number) =>
19
20
  found.push({ url, text, before: line.slice(0, index), line })