jackherd 1.8.1 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/INSTALL.md +3 -1
  3. package/README.md +75 -64
  4. package/dist/{chunk-OU2KALXP.js → chunk-5MQBCUQ5.js} +24 -4
  5. package/dist/config.d.ts +45 -4
  6. package/dist/config.js +84 -27
  7. package/dist/plan-format.d.ts +63 -7
  8. package/dist/plan-format.js +66 -12
  9. package/docs/workflow-dark.svg +67 -63
  10. package/docs/workflow.svg +67 -63
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/ask-rules.json +15 -0
  14. package/kit/hooks/hooks/approval-gate/index.ts +22 -4
  15. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  16. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  17. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  18. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  19. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  20. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  21. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  22. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  23. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  24. package/kit/hooks/hooks/merge-guard/decision.ts +127 -140
  25. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  26. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  27. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  28. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  29. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  30. package/kit/hooks/hooks/open-question/index.ts +26 -0
  31. package/kit/hooks/hooks/open-question/record.ts +135 -0
  32. package/kit/hooks/hooks/open-question/run.ts +52 -0
  33. package/kit/hooks/hooks/open-question/store.ts +39 -0
  34. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  35. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  36. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  37. package/kit/hooks/hooks/sent-file/store.ts +9 -0
  38. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  39. package/kit/hooks/hooks/shared/config.ts +7 -0
  40. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  41. package/kit/hooks/hooks/shared/files.ts +7 -0
  42. package/kit/hooks/hooks/shared/lane.ts +1 -1
  43. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  44. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  45. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  46. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  47. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  48. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  49. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  50. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  51. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  52. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  53. package/kit/hooks/hooks.json +21 -4
  54. package/kit/hooks/statusline.sh +1 -1
  55. package/kit/review/agents/skill-review.md +34 -0
  56. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  57. package/kit/workflow/skills/follow/SKILL.md +1 -8
  58. package/kit/workflow/skills/implement/SKILL.md +10 -6
  59. package/kit/workflow/skills/orchestrate/SKILL.md +213 -412
  60. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  61. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  62. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  63. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  64. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  65. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  66. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  67. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  68. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  69. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  70. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  71. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  72. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  73. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  74. package/kit/workflow/skills/plan/SKILL.md +6 -5
  75. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  76. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  77. package/package.json +4 -1
  78. package/recipes/README.md +2 -1
  79. package/recipes/sources.md +1 -1
  80. package/schemas/jackherd.schema.json +85 -7
  81. package/src/check/agent-text-reviewers.ts +23 -0
  82. package/src/check/command.ts +2 -0
  83. package/src/check/plan-structure-check.ts +49 -2
  84. package/src/check/plan.ts +8 -1
  85. package/src/check/schema.ts +9 -0
  86. package/src/check/skill-budget-defaults.ts +55 -0
  87. package/src/check/skill-budgets.ts +185 -0
  88. package/src/cli.ts +4 -0
  89. package/src/config/auto-window.ts +8 -0
  90. package/src/config/link-kinds.ts +7 -1
  91. package/src/config/schema.ts +80 -26
  92. package/src/context/fill.ts +12 -0
  93. package/src/context/session-log.ts +43 -1
  94. package/src/lane/close-board.ts +106 -0
  95. package/src/lane/close-check.ts +183 -0
  96. package/src/lane/close-command-line.ts +11 -0
  97. package/src/lane/close-command.ts +217 -0
  98. package/src/lane/close-options.ts +43 -0
  99. package/src/lane/close-survey.ts +83 -0
  100. package/src/lane/close.ts +141 -0
  101. package/src/lane/command.ts +13 -0
  102. package/src/lane-prompt/build.ts +74 -0
  103. package/src/lane-prompt/command.ts +87 -0
  104. package/src/lane-prompt/input.ts +116 -0
  105. package/src/lane-prompt/options.ts +68 -0
  106. package/src/lane-prompt/texts.ts +101 -0
  107. package/src/plan-format/clock.ts +12 -0
  108. package/src/plan-format/dependencies.ts +9 -9
  109. package/src/plan-format/index.ts +1 -0
  110. package/src/plan-format/lanes.ts +30 -5
  111. package/src/plan-format/short-names.ts +71 -0
  112. package/src/plan-format/terminal-text.ts +4 -0
  113. package/src/plan-format/waiting.ts +53 -0
  114. package/src/plans/active-tasks.ts +60 -0
  115. package/src/plans/appointment-rows.ts +59 -0
  116. package/src/plans/ask-coordinator.ts +77 -0
  117. package/src/plans/ask-target.ts +25 -0
  118. package/src/plans/child-stop.ts +51 -0
  119. package/src/plans/closable-records.ts +34 -0
  120. package/src/plans/command.ts +3 -2
  121. package/src/plans/current-phase.ts +17 -7
  122. package/src/plans/current-task.ts +63 -5
  123. package/src/plans/fetch.ts +26 -3
  124. package/src/plans/git.ts +19 -5
  125. package/src/plans/herd-claim.ts +173 -0
  126. package/src/plans/herd-memory.ts +36 -25
  127. package/src/plans/lane-plans.ts +44 -13
  128. package/src/plans/lane-standing.ts +9 -4
  129. package/src/plans/lane-summary.ts +367 -92
  130. package/src/plans/lane-tickets.ts +15 -0
  131. package/src/plans/layout-phase.ts +80 -19
  132. package/src/plans/layout-summary.ts +163 -74
  133. package/src/plans/layout.ts +472 -114
  134. package/src/plans/link-chips.ts +1 -0
  135. package/src/plans/live.ts +119 -10
  136. package/src/plans/phase-ids.ts +8 -0
  137. package/src/plans/phase-models.ts +1 -1
  138. package/src/plans/phase-ranges.ts +45 -0
  139. package/src/plans/phase-source.ts +15 -0
  140. package/src/plans/plan-cache.ts +28 -0
  141. package/src/plans/plan-heads.ts +54 -0
  142. package/src/plans/pull-requests.ts +23 -2
  143. package/src/plans/question-records.ts +48 -0
  144. package/src/plans/questions-read.ts +13 -0
  145. package/src/plans/read-cache.ts +8 -0
  146. package/src/plans/record-id.ts +4 -0
  147. package/src/plans/restart-decision.ts +13 -4
  148. package/src/plans/selection.ts +117 -34
  149. package/src/plans/sent-files-read.ts +13 -0
  150. package/src/plans/sent-files.ts +114 -0
  151. package/src/plans/settings.ts +2 -1
  152. package/src/plans/signalled-phase.ts +42 -0
  153. package/src/plans/signals-read.ts +62 -0
  154. package/src/plans/snapshot.ts +279 -60
  155. package/src/plans/sprint-export.ts +11 -11
  156. package/src/plans/sprint-page.ts +5 -5
  157. package/src/plans/subagent-phases.ts +95 -0
  158. package/src/plans/subagent-tasks.ts +51 -0
  159. package/src/plans/subagents-read.ts +131 -0
  160. package/src/plans/subagents.ts +190 -0
  161. package/src/plans/supervisor.ts +14 -4
  162. package/src/plans/symbols.ts +3 -0
  163. package/src/plans/task-name.ts +34 -0
  164. package/src/plans/texts.ts +36 -14
  165. package/src/plans/unlisted-lanes.ts +10 -5
  166. package/src/plans/view.ts +20 -12
  167. package/src/plans/waiting-click.ts +231 -0
  168. package/src/plans/waiting-records.ts +39 -0
  169. package/src/plans/waiting-window.ts +8 -0
  170. package/src/preview/command.ts +105 -0
  171. package/src/preview/embed.ts +209 -0
  172. package/src/preview/images.ts +89 -0
  173. package/src/preview/open.ts +12 -0
  174. package/src/preview/options.ts +32 -0
  175. package/src/preview/page.ts +89 -0
  176. package/src/preview/render.ts +41 -0
  177. package/src/settled/board-row.ts +1 -2
  178. package/src/shared/is-record.ts +2 -0
  179. package/src/shared/private-location.ts +89 -0
  180. package/src/shared/private-records.ts +48 -0
  181. package/src/shared/stat-cache.ts +48 -0
  182. package/src/sync/kit-source.ts +29 -10
  183. package/src/sync/kit.ts +2 -0
  184. package/src/sync/manifest.ts +2 -0
  185. package/src/sync/plan-ask-rules.ts +94 -0
  186. package/src/sync/plan-hooks.ts +2 -0
  187. package/src/sync/render.ts +3 -3
  188. package/src/sync/settings-hooks.ts +4 -1
  189. package/src/sync/sync-plan.ts +1 -0
  190. package/src/sync/sync.ts +24 -3
  191. package/src/task-signals/agent.ts +15 -0
  192. package/src/task-signals/claude-read.ts +89 -0
  193. package/src/task-signals/claude-signals.ts +112 -0
  194. package/src/task-signals/command.ts +71 -0
  195. package/src/task-signals/fixtures/session.jsonl +7 -0
  196. package/src/task-signals/matcher.ts +96 -0
  197. package/src/task-signals/record-store.ts +40 -0
  198. package/src/task-signals/record.ts +68 -0
  199. package/src/task-signals/text.ts +13 -0
  200. package/src/task-signals/types.ts +14 -0
  201. package/src/terminal/open.ts +13 -6
  202. package/src/terminal/text.ts +16 -4
  203. package/src/terminal/width.ts +2 -1
  204. package/src/version/skill-diff.ts +46 -0
  205. package/src/vocabulary/index.ts +44 -6
  206. package/src/waiting/command.ts +50 -0
  207. package/src/watch/appointments.ts +2 -4
  208. package/src/watch/approvals.ts +2 -2
  209. package/src/watch/asking-lanes.ts +40 -0
  210. package/src/watch/base-tip.ts +11 -0
  211. package/src/watch/command.ts +401 -90
  212. package/src/watch/failed-tests.ts +56 -10
  213. package/src/watch/failure-read.ts +18 -6
  214. package/src/watch/failures.ts +29 -5
  215. package/src/watch/feedback.ts +1 -1
  216. package/src/watch/gh-process.ts +9 -3
  217. package/src/watch/heartbeat-progress.ts +6 -18
  218. package/src/watch/json-guards.ts +0 -3
  219. package/src/watch/kit-update.ts +28 -9
  220. package/src/watch/lane-fill.ts +1 -1
  221. package/src/watch/lane-model.ts +1 -1
  222. package/src/watch/lane-name.ts +6 -0
  223. package/src/watch/lane-parallel.ts +1 -1
  224. package/src/watch/lane-progress.ts +58 -14
  225. package/src/watch/lane-report-file.ts +20 -5
  226. package/src/watch/lane-report.ts +37 -5
  227. package/src/watch/lane-rest.ts +28 -0
  228. package/src/watch/lane-sources.ts +31 -3
  229. package/src/watch/main-checkout.ts +34 -0
  230. package/src/watch/plan-badges-read.ts +13 -7
  231. package/src/watch/plan-badges.ts +9 -2
  232. package/src/watch/previews.ts +1 -1
  233. package/src/watch/pull-requests.ts +8 -3
  234. package/src/watch/reference-paths.ts +87 -0
  235. package/src/watch/running-subagents.ts +43 -0
  236. package/src/watch/skill-change.ts +124 -32
  237. package/src/watch/source-change.ts +48 -0
  238. package/src/watch/source-path.ts +1 -1
  239. package/src/watch/sources.ts +7 -4
  240. package/src/watch/stream-output.ts +14 -8
  241. package/src/watch/task-parallel-auto.ts +208 -0
  242. package/src/watch/task-parallel-data-paths.ts +46 -0
  243. package/src/watch/task-parallel-input.ts +108 -0
  244. package/src/watch/task-parallel-read.ts +151 -0
  245. package/src/watch/task-parallel-touched.ts +39 -0
  246. package/src/watch/task-parallel.ts +235 -0
  247. package/src/watch/task-signal-missing.ts +69 -0
  248. package/src/watch/task-signal-read.ts +29 -0
  249. package/src/watch/texts.ts +10 -0
  250. package/src/watch/update-available.ts +6 -3
  251. package/src/watch/waiting-line.ts +19 -0
  252. package/src/watch/watch-events.ts +36 -1
  253. package/src/watch/watcher-lock.ts +25 -29
  254. package/src/watch/workflow-runs.ts +2 -1
  255. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  256. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  257. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  258. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  259. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  260. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  261. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -1,14 +1,17 @@
1
1
  import type { ChecklistItem, DeferredItem, LaneProgress, PhaseProgress } from '../plan-format'
2
2
  import { onlyDocumentationOpen, phaseLabel } from '../plan-format'
3
- import { fit, padVisible, paint, wrap } from '../terminal/text'
3
+ import { terminalSafe } from '../plan-format/terminal-text'
4
+ import { fit, padVisible, paint, spread, wrap } from '../terminal/text'
4
5
  import { textWidth } from '../terminal/width'
5
6
  import type { WatchBadges } from '../watch/plan-badges'
7
+ import type { ActiveTask, UnplannedTask } from './active-tasks'
6
8
  import { type Frame, rowFrame, textFrame } from './frame'
7
9
  import type { LaneTickets } from './lane-tickets'
8
10
  import type { LinkChip } from './link-chips'
9
11
  import type { PhaseModel } from './phase-models'
10
12
  import { itemKey, type Selection, tickedKey } from './selection'
11
13
  import { chevronOf, MODEL_MARK, PHASE_COUNT, PHASE_STATE, type PhaseState, phaseStateOf } from './symbols'
14
+ import { taskBodyOf, taskNameOf } from './task-name'
12
15
  import { type BadgeTexts, TEXTS } from './texts'
13
16
 
14
17
  /** What every block of one lane is drawn with. */
@@ -26,13 +29,14 @@ export type LaneContext = {
26
29
  }
27
30
 
28
31
  export const ACTIVE_MARK = '▶'
32
+ export const GUESSED_MARK = '▷'
29
33
  export const DEFERRED_MARK = '→'
30
34
  export const COLUMN_SEPARATOR = ' '
31
35
  export const MIN_TITLE_WIDTH = 8
32
36
 
33
37
  /** Places on each side of the slash of a count: ` 9/9 `, ` 15/15 `, `123/456`. */
34
38
  const COUNT_PLACES = 3
35
- /** The cells before a count: `◐`, `✓`, `?`, `–` or nothing, padded so every slash on screen is in one column. */
39
+ /** The cells before a count: `✓`, `?`, `–` or nothing, padded so every slash on screen is in one column. */
36
40
  const COUNT_MARK_WIDTH = 2
37
41
  const BLANK_COUNT = ' '.repeat(2 * COUNT_PLACES + 1)
38
42
  const COUNT_CELL_WIDTH = COUNT_MARK_WIDTH + 1 + BLANK_COUNT.length
@@ -54,6 +58,9 @@ const PHASE_MARK: Record<PhaseState, string> = {
54
58
 
55
59
  export const plain = (text: string): string => text.replace(MARKDOWN_MARKS, '')
56
60
 
61
+ /** What a task reads as on one row: its short name where it carries one (`taskNameOf`), else its whole text. */
62
+ export const taskTitle = (text: string): string => plain(taskNameOf(text) ?? text)
63
+
57
64
  export const countText = (done: number, total: number): string =>
58
65
  `${String(done).padStart(COUNT_PLACES)}/${String(total).padEnd(COUNT_PLACES)}`
59
66
 
@@ -87,25 +94,79 @@ export const modelMark = (model: PhaseModel | undefined): string =>
87
94
  : ''
88
95
 
89
96
  /** `text` wrapped beside a one-cell `mark`, each line in `style`; follow-up lines indent under the text. */
90
- const markedLines = (text: string, mark: string, style: (line: string) => string, width: number): string[] =>
91
- wrap(plain(text), Math.max(width - 2, 1)).map((line, index) => `${index > 0 ? ' ' : mark} ${style(line)}`)
97
+ const markedLines = (text: string, look: TaskLook, width: number): string[] => {
98
+ const lines = wrap(plain(text), Math.max(width - 2, 1))
99
+ return lines.map(
100
+ (line, index) =>
101
+ `${index > 0 ? ' ' : look.mark} ${look.style(line)}${index === lines.length - 1 ? look.suffix : ''}`,
102
+ )
103
+ }
104
+
105
+ /**
106
+ * The arrow of a phase or task being worked on: a teal `▶` where an agent reported it, a dim teal `▷` where the herd
107
+ * only guesses it from words. Yellow is the herd's colour for what waits on the user.
108
+ */
109
+ export const activeArrow = (guessed: boolean): string =>
110
+ guessed ? paint.dim(paint.teal(GUESSED_MARK)) : paint.teal(ACTIVE_MARK)
111
+
112
+ /** The title of a phase or task being worked on: bold where reported, plain where guessed. */
113
+ export const activeTitle = (text: string, guessed: boolean): string => (guessed ? text : paint.bold(text))
114
+
115
+ /** The agent's name after a title, dim; nothing while only one agent is active. */
116
+ export const agentSuffix = (agent: string | null): string => (agent ? ` ${paint.dim(terminalSafe(agent))}` : '')
117
+
118
+ /** The agent's name for the right edge of a row, dim; empty while only one agent is active. */
119
+ export const agentCell = (agent: string | null): string => (agent ? paint.dim(terminalSafe(agent)) : '')
120
+
121
+ /** Two cells: a task row hangs under its phase's label, which an arrow would otherwise push right. */
122
+ export const TASK_INDENT = ' '
92
123
 
93
- /** The mark and the text style of a task: a ticked one green and dim, the one being worked on yellow and bold. */
94
- const taskLook = ({ done }: ChecklistItem, active: boolean): { mark: string; style: (line: string) => string } => {
95
- if (active) return { mark: paint.yellow(ACTIVE_MARK), style: paint.bold }
96
- return done ? { mark: paint.green('☑'), style: paint.dim } : { mark: '☐', style: (line) => line }
124
+ /** `▶ <label> <text>`, the text bold, the agent dim at the right edge. */
125
+ const labelledLine = (label: string, { text, agent }: UnplannedTask, width: number): string =>
126
+ spread(`${activeArrow(false)} ${label} ${paint.bold(plain(terminalSafe(text)))}`, agentCell(agent), width)
127
+
128
+ /** `▶ now: <text>`: what an agent reports that no task of the plan names; the agent dim at the right edge. */
129
+ export const nowLine = (task: UnplannedTask, width: number): string => labelledLine(TEXTS.now, task, width)
130
+
131
+ /** `▶ Subagent: <description>`: a running subagent that names no phase and sits on none. */
132
+ export const subagentLine = (description: string, width: number): string =>
133
+ labelledLine(TEXTS.subagent, { text: description, agent: null }, width)
134
+
135
+ type TaskLook = { mark: string; style: (line: string) => string; suffix: string }
136
+
137
+ /** The mark and the text style of a task: a ticked one green and dim, the one being worked on with its arrow. */
138
+ const taskLook = ({ done }: ChecklistItem, active: ActiveTask | undefined): TaskLook => {
139
+ if (active) {
140
+ return {
141
+ mark: activeArrow(active.guessed),
142
+ style: (line) => activeTitle(line, active.guessed),
143
+ suffix: agentSuffix(active.agent),
144
+ }
145
+ }
146
+ return done
147
+ ? { mark: paint.green('☑'), style: paint.dim, suffix: '' }
148
+ : { mark: '☐', style: (line) => line, suffix: '' }
97
149
  }
98
150
 
99
- /** A closed task as one row, cut at the pane's edge with `…`. */
100
- const closedTaskRow = (item: ChecklistItem, active: boolean, width: number): string[] => {
151
+ /** A closed task as one row, its short name or its text cut at the pane's edge with `…`. */
152
+ const closedTaskRow = (item: ChecklistItem, active: ActiveTask | undefined, width: number): string[] => {
101
153
  const { mark, style } = taskLook(item, active)
102
- return [fit(`${mark} ${style(plain(item.text))}`, width)]
154
+ return [spread(`${mark} ${style(taskTitle(item.text))}`, agentCell(active?.agent ?? null), width)]
103
155
  }
104
156
 
105
- /** An open task as rows: its whole text wrapped. */
106
- const openTaskRows = (item: ChecklistItem, active: boolean, width: number): string[] => {
107
- const { mark, style } = taskLook(item, active)
108
- return markedLines(item.text, mark, style, width)
157
+ /** A named task opened: its name bold beside the mark, below it the line after the name (`taskBodyOf`) wrapped. */
158
+ const namedTaskRows = (name: string, item: ChecklistItem, look: TaskLook, width: number): string[] => {
159
+ const body = wrap(plain(taskBodyOf(item.text)), Math.max(width - 2, 1)).map(
160
+ (line) => ` ${item.done ? paint.dim(line) : line}`,
161
+ )
162
+ return [fit(`${look.mark} ${paint.bold(plain(name))}${look.suffix}`, width), ...body]
163
+ }
164
+
165
+ /** An open task as rows: a named one by `namedTaskRows`, else its whole text wrapped. */
166
+ const openTaskRows = (item: ChecklistItem, active: ActiveTask | undefined, width: number): string[] => {
167
+ const name = taskNameOf(item.text)
168
+ const look = taskLook(item, active)
169
+ return name ? namedTaskRows(name, item, look, width) : markedLines(item.text, look, width)
109
170
  }
110
171
 
111
172
  /** A task as a selectable row under its `key`: clickable across every line it takes, the cursor on its first. */
@@ -123,20 +184,20 @@ const tickedSummaryFrame = (count: number, phaseRowKey: string, width: number):
123
184
  })
124
185
 
125
186
  /**
126
- * The children of an open phase, one frame per task: open ones first, `active` marked among them, then the ones
187
+ * The children of an open phase, one frame per task: open ones first, the `active` ones (by task index) marked, then the ones
127
188
  * deferred to another phase, then the ticked ones listed or summed up.
128
189
  */
129
190
  export const phaseChildren = (
130
191
  context: LaneContext,
131
192
  phaseRowKey: string,
132
193
  phase: PhaseProgress,
133
- active: ChecklistItem | null,
194
+ active: ReadonlyMap<number, ActiveTask>,
134
195
  ): Frame[] => {
135
196
  const { state, width } = context
136
197
  const frameOf = (item: ChecklistItem, index: number): Frame => {
137
198
  const key = itemKey(phaseRowKey, index)
138
- const isActive = item === active
139
- const rows = state.expanded.has(key) ? openTaskRows(item, isActive, width) : closedTaskRow(item, isActive, width)
199
+ const mark = active.get(index)
200
+ const rows = state.expanded.has(key) ? openTaskRows(item, mark, width) : closedTaskRow(item, mark, width)
140
201
  return taskFrame(key, rows, context)
141
202
  }
142
203
  const framesWhere = (done: boolean): Frame[] =>
@@ -1,49 +1,61 @@
1
- import { phaseLabel, phaseLabels } from '../plan-format'
2
- import { fit, padVisible, paint, spread, visibleLength } from '../terminal/text'
3
- import { textWidth } from '../terminal/width'
4
- import { deeper, type Frame, joinFrames, nest, prefixed, rowFrame, textFrame } from './frame'
5
- import type { ActivePhase, LaneSummary } from './lane-summary'
1
+ import { phaseLabel } from '../plan-format'
2
+ import { fit, paint, spread, visibleLength } from '../terminal/text'
3
+ import { deeper, type Frame, joinFrames, nest, rowFrame, textFrame } from './frame'
4
+ import type { ActivePhase, LaneSummary, PlacedPhase } from './lane-summary'
6
5
  import {
7
- ACTIVE_MARK,
6
+ activeArrow,
7
+ activeTitle,
8
+ agentCell,
8
9
  COLUMN_SEPARATOR,
9
- DEFERRED_MARK,
10
10
  type LaneContext,
11
11
  MIN_TITLE_WIDTH,
12
12
  modelMark,
13
+ nowLine,
13
14
  phaseChildren,
14
15
  phaseCountCell,
15
16
  plain,
17
+ subagentLine,
18
+ TASK_INDENT,
16
19
  taskFrame,
20
+ taskTitle,
17
21
  } from './layout-phase'
18
22
  import { type PhaseModel, phaseModelKey } from './phase-models'
19
- import { itemKey, phaseKey } from './selection'
23
+ import { phaseRanges } from './phase-ranges'
24
+ import { finishedKey, itemKey, nextKey, phaseKey } from './selection'
20
25
  import { PHASE_COUNT } from './symbols'
21
26
  import { type HerdTexts, TEXTS } from './texts'
22
27
 
23
- /** The context of a lane's sections: the lane's, with the herd's words. */
28
+ /** The context of a lane's branches: the lane's, with the herd's words. */
24
29
  type SummaryContext = LaneContext & { labels: HerdTexts }
25
30
 
26
- /** The label column of the summary's sections, as wide as the widest label and two cells. */
27
- const sectionWidth = ({ sections }: HerdTexts): number =>
28
- Math.max(...Object.values(sections).map(textWidth)) + COLUMN_SEPARATOR.length
29
-
30
- /** A frame behind a section label on its first line, its other lines indented to where the content starts. */
31
- const labelled = (labelWidth: number, label: string, frame: Frame): Frame =>
32
- prefixed(frame, { first: padVisible(label, labelWidth), rest: ' '.repeat(labelWidth), style: paint.dim })
31
+ /** Whether only the boxes of the review gate are open. */
32
+ const onlyGateOpen = ({ items }: ActivePhase['phase']): boolean => {
33
+ const open = items.filter(({ done }) => !done)
34
+ return open.length > 0 && open.every(({ gate }) => gate)
35
+ }
33
36
 
34
- /** `· 6 min ago`, dim: when the running phase last moved. */
35
- const ageCell = ({ minutesAgo }: ActivePhase, labels: HerdTexts): string =>
36
- `${paint.dim(labels.minutesAgo(minutesAgo))}${COLUMN_SEPARATOR}`
37
+ /**
38
+ * `· 6 min ago`, dim: when the running phase was last ticked or reported, nothing when the herd saw neither; `· gate`
39
+ * after it while only the review gate is open. Empty when there is neither.
40
+ */
41
+ const ageCell = ({ minutesAgo, phase }: ActivePhase, labels: HerdTexts): string => {
42
+ const words = [
43
+ ...(minutesAgo === null ? [] : [labels.minutesAgo(minutesAgo)]),
44
+ ...(onlyGateOpen(phase) ? [labels.gate] : []),
45
+ ]
46
+ return words.length > 0 ? `${paint.dim(words.join(' '))}${COLUMN_SEPARATOR}` : ''
47
+ }
37
48
 
38
49
  /**
39
- * `▶ P12 <title> [model] · 6 min ago 16/26`: title at the left, model, age and count at the right edge. In a pane
40
- * too narrow to leave the title `MIN_TITLE_WIDTH` cells, the model gives way first, then the age.
50
+ * `P12 <title> [model] · 6 min ago 16/26`: title at the left, model, age and count at the right edge, no arrow
51
+ * (arrows stand before tasks only). In a pane too narrow to leave the title `MIN_TITLE_WIDTH` cells, the model gives
52
+ * way first, then the age.
41
53
  */
42
54
  const activeRow = (active: ActivePhase, current: boolean, context: SummaryContext, model?: PhaseModel): string => {
43
55
  const { width, labels } = context
44
56
  const { phase } = active
45
57
  const title = phase.title === null ? paint.dim(TEXTS.notInPlan) : plain(phase.title)
46
- const head = `${paint.yellow(ACTIVE_MARK)} ${phaseLabel(phase.id)} ${title}`
58
+ const head = `${phaseLabel(phase.id)} ${title}`
47
59
  const count = phaseCountCell(phase, current)
48
60
  const withAge = `${ageCell(active, labels)}${count}`
49
61
  const mark = modelMark(model)
@@ -52,17 +64,65 @@ const activeRow = (active: ActivePhase, current: boolean, context: SummaryContex
52
64
  return spread(head, candidates.find((right) => titleRoom(right) >= MIN_TITLE_WIDTH) ?? count, width)
53
65
  }
54
66
 
67
+ /** What an active task reads as on one row: `Review gate` for a box of the review gate, else its short name or text. */
68
+ const taskText = (item: ActivePhase['phase']['items'][number], labels: HerdTexts): string =>
69
+ item.gate ? labels.reviewGate : taskTitle(item.text)
70
+
71
+ /** The texts of the tasks agents report on the phase, then the descriptions of the subagents that name no phase as `Subagent: …`. */
72
+ const reportedTexts = (
73
+ { phase, tasks }: ActivePhase,
74
+ subagentTasks: readonly string[],
75
+ labels: HerdTexts,
76
+ ): string[] => [
77
+ ...tasks
78
+ .filter(({ guessed }) => !guessed)
79
+ .flatMap(({ index }) => {
80
+ const item = phase.items[index]
81
+ return item ? [taskText(item, labels)] : []
82
+ }),
83
+ ...subagentTasks.map((description) => `${TEXTS.subagent} ${taskTitle(description)}`),
84
+ ]
85
+
86
+ /**
87
+ * `▶ Name +1` for a folded lane, behind `indent`: the first task the phase is on with the arrow of the open lane, `+n`
88
+ * for the others in parallel at the end; no ticks, the phase row shows them; the name cut to the pane's edge. A task
89
+ * an agent reports comes first, the descriptions of the `subagentTasks` after it. Null while neither exists: a task
90
+ * the herd only guesses from words is no sign that the lane works.
91
+ */
92
+ export const foldedTaskLine = (
93
+ active: ActivePhase,
94
+ subagentTasks: readonly string[],
95
+ labels: HerdTexts,
96
+ indent: string,
97
+ width: number,
98
+ ): string | null => {
99
+ const [first, ...others] = reportedTexts(active, subagentTasks, labels)
100
+ if (first === undefined) return null
101
+ const lead = `${activeArrow(false)} `
102
+ const tail = others.length > 0 ? ` +${others.length}` : ''
103
+ const room = Math.max(width - visibleLength(indent) - visibleLength(lead) - visibleLength(tail), 1)
104
+ return `${indent}${lead}${activeTitle(fit(first, room), false)}${tail}`
105
+ }
106
+
55
107
  /**
56
- * `→ <task>` under a closed running phase: the task it is on, dim, on one line whatever the task's fold, and a row a
57
- * click opens like the task itself; `→ review gate` in place of a box of the review gate; nothing when no task is open.
108
+ * One row per active task under a closed running phase, each with its own arrow: the title bold and the agent dim
109
+ * after it where agents reported it, `▷` and a plain title where the herd guesses. Always on one line whatever the
110
+ * task's fold, and a row a click opens like the task itself; `Review gate` in place of a box of the review gate.
58
111
  */
59
- const taskLineFrame = (context: SummaryContext, phaseRowKey: string, { phase, taskIndex }: ActivePhase): Frame => {
60
- const item = taskIndex === null ? undefined : phase.items[taskIndex]
61
- if (!item || taskIndex === null) return textFrame([])
62
- const text = item.gate ? context.labels.reviewGate : plain(item.text)
63
- const underTitle = ' '.repeat(visibleLength(ACTIVE_MARK) + 1)
64
- const line = fit(`${underTitle}${paint.dim(`${DEFERRED_MARK} ${text}`)}`, context.width)
65
- return taskFrame(itemKey(phaseRowKey, taskIndex), [line], context)
112
+ const taskLinesFrame = (context: SummaryContext, phaseRowKey: string, { phase, tasks }: ActivePhase): Frame => {
113
+ return joinFrames(
114
+ tasks.flatMap((task) => {
115
+ const item = phase.items[task.index]
116
+ if (!item) return []
117
+ const text = taskText(item, context.labels)
118
+ const line = spread(
119
+ `${TASK_INDENT}${activeArrow(task.guessed)} ${activeTitle(text, task.guessed)}`,
120
+ agentCell(task.agent),
121
+ context.width,
122
+ )
123
+ return [taskFrame(itemKey(phaseRowKey, task.index), [line], context)]
124
+ }),
125
+ )
66
126
  }
67
127
 
68
128
  /** A running phase as a row that opens to its tasks; closed, the task it is on below it. */
@@ -71,70 +131,99 @@ const activeFrame = (context: SummaryContext, active: ActivePhase): Frame => {
71
131
  const stepIndex = active.stepIndex ?? -1
72
132
  const key = phaseKey(progress.lane.name, stepIndex, active.phase.id)
73
133
  const current = progress.current?.stepIndex === stepIndex && progress.current.phaseId === active.phase.id
74
- const item = active.taskIndex === null ? null : (active.phase.items[active.taskIndex] ?? null)
75
134
  const model = context.models[phaseModelKey(stepIndex, active.phase.id)]
76
135
  const row = rowFrame(key, [activeRow(active, current, context, model)], [], context)
77
- if (!state.expanded.has(key)) return joinFrames([row, taskLineFrame(context, key, active)])
78
- return joinFrames([row, nest(phaseChildren(deeper(context), key, active.phase, item))])
136
+ if (!state.expanded.has(key)) return joinFrames([row, taskLinesFrame(context, key, active)])
137
+ return joinFrames([
138
+ row,
139
+ nest(phaseChildren(deeper(context), key, active.phase, new Map(active.tasks.map((task) => [task.index, task])))),
140
+ ])
79
141
  }
80
142
 
81
- /** `wave 2: P5 · P10` or, without waves, `P7`. */
82
- const nextText = ({ next }: LaneSummary, labels: HerdTexts): string =>
83
- next ? `${next.wave === null ? '' : `${labels.wave(next.wave)} `}${phaseLabels(next.phaseIds, ' · ')}` : ''
84
-
85
143
  /** `P8 ?` for a named phase the plan lacks, `P9 –` for one without a checkbox. */
86
144
  const unwrittenText = ({ id, missing }: LaneSummary['unwritten'][number]): string =>
87
145
  `${phaseLabel(id)} ${paint.dim(missing ? PHASE_COUNT.missing : PHASE_COUNT.empty)}`
88
146
 
89
- /** `P6 P7 P8 ? · Second plan: P1`: the current plan's waiting phases, then the other plans' by their name. */
90
- const laterText = ({ later, unwritten }: LaneSummary): string => {
91
- const own = [
92
- ...(later[0]?.planTitle === null ? [phaseLabels(later[0].phaseIds, ' ')] : []),
93
- ...unwritten.map(unwrittenText),
94
- ]
95
- const others = later
147
+ /**
148
+ * `next P5–P7, P10 P8 ? · Second plan: P1` on one line: every open phase that does not run, the current plan's as
149
+ * ranges, then the other plans' by their name; empty when none waits.
150
+ */
151
+ const nextLineText = (summary: LaneSummary, labels: HerdTexts): string => {
152
+ const ownIds = summary.waiting.filter(({ planTitle }) => planTitle === null).flatMap(({ phaseIds }) => phaseIds)
153
+ const ownRanges = phaseRanges(ownIds)
154
+ const own = [...(ownRanges ? [ownRanges] : []), ...summary.unwritten.map(unwrittenText)]
155
+ const others = summary.waiting
96
156
  .filter(({ planTitle }) => planTitle !== null)
97
- .map(({ planTitle, phaseIds }) => `${plain((planTitle ?? '').split(':')[0]).trim()}: ${phaseLabels(phaseIds, ' ')}`)
98
- return [...(own.length > 0 ? [own.join(' ')] : []), ...others].join(' · ')
157
+ .map(({ planTitle, phaseIds }) => `${plain((planTitle ?? '').split(':')[0]).trim()}: ${phaseRanges(phaseIds)}`)
158
+ const text = [...(own.length > 0 ? [own.join(' ')] : []), ...others].join(' · ')
159
+ return text ? `${paint.dim(labels.next)} ${text}` : ''
99
160
  }
100
161
 
101
- /** ` · not merged` once for the line when every finished phase waits for its merge, ` · P1 P2 not merged` when some do. */
102
- const unmergedNote = ({ finished }: LaneSummary, labels: HerdTexts): string => {
103
- const unmerged = finished.filter(({ unmerged }) => unmerged).map(({ id }) => id)
104
- if (unmerged.length === 0) return ''
105
- const which = unmerged.length === finished.length ? '' : `${phaseLabels(unmerged, ' ')} `
106
- return ` · ${paint.yellow(`${which}${labels.notMerged}`)}`
162
+ /** `done P1–P7, P10` on one line: the word `done` in green, the finished phases as ranges. */
163
+ const finishedText = (summary: LaneSummary, labels: HerdTexts): string => {
164
+ const phases = phaseRanges(summary.finished.map(({ id }) => id))
165
+ return phases ? `${paint.green(labels.done)} ${phases}` : ''
107
166
  }
108
167
 
109
- /** `✓ P1 ✓ P4 · not merged` on one line. */
110
- const finishedText = (summary: LaneSummary, labels: HerdTexts): string => {
111
- const phases = summary.finished.map(({ id }) => `${paint.green(PHASE_COUNT.allTicked)} ${phaseLabel(id)}`).join(' ')
112
- return phases ? `${phases}${unmergedNote(summary, labels)}` : ''
168
+ /** A phase that does not run as a row, which opens to its tasks like a running one; closed, it has no task line below it. */
169
+ const restingFrame = (context: SummaryContext, { phase, stepIndex }: PlacedPhase): Frame =>
170
+ activeFrame(context, { phase, stepIndex, minutesAgo: null, tasks: [] })
171
+
172
+ /**
173
+ * One line of text cut at the pane's edge, a row under `key`; no line when the text is empty. Open, it continues with
174
+ * a row per phase it names and the `unwritten` lines, each phase opening to its tasks.
175
+ */
176
+ const foldFrame = (
177
+ context: SummaryContext,
178
+ key: string,
179
+ text: string,
180
+ { phases, unwritten }: { phases: readonly PlacedPhase[]; unwritten: string[] },
181
+ ): Frame => {
182
+ if (!text) return textFrame([])
183
+ const row = rowFrame(key, [fit(text, context.width)], [], context)
184
+ if (!context.state.expanded.has(key)) return row
185
+ const inner = deeper(context)
186
+ const children = [
187
+ ...phases.map((placed) => restingFrame(inner, placed)),
188
+ ...unwritten.map((line) => textFrame([fit(line, inner.width)])),
189
+ ]
190
+ return joinFrames([row, nest(children)])
113
191
  }
114
192
 
115
- /** A section with one line of text, cut at the pane's edge; no frame when the text is empty. */
116
- const textSection = (labelWidth: number, label: string, text: string, width: number): Frame =>
117
- text ? labelled(labelWidth, label, textFrame([fit(text, Math.max(width - labelWidth, 1))])) : textFrame([])
193
+ /** What an open lane shows above its phases, drawn at the width of its branches: the session, its pull requests and its link chips. */
194
+ export type LaneHead = { session: Frame; pullRequests: Frame; chips: Frame }
118
195
 
119
- /** What an open lane shows above its sections, each drawn at the width its label column leaves. */
120
- export type LaneHead = { session: (width: number) => string; chips: (width: number) => Frame }
196
+ /** The `next` and `done` rows of an open lane. */
197
+ const restingFrames = (context: SummaryContext, summary: LaneSummary): Frame[] => {
198
+ const laneName = context.progress.lane.name
199
+ return [
200
+ foldFrame(context, nextKey(laneName), nextLineText(summary, context.labels), {
201
+ phases: summary.waitingPhases,
202
+ unwritten: summary.unwritten.map(unwrittenText),
203
+ }),
204
+ foldFrame(context, finishedKey(laneName), finishedText(summary, context.labels), {
205
+ phases: summary.finishedPhases,
206
+ unwritten: [],
207
+ }),
208
+ ]
209
+ }
121
210
 
122
211
  /**
123
- * The children of an open lane, each on a branch with its label in one column: SESSION, the 📎 chips, then NOW, NEXT,
124
- * LATER and DONE, each section only when it has phases.
212
+ * The children of an open lane, one branch each, told apart by their marks rather than by a label: the session
213
+ * (`⎇`), the pull requests (`↗`), the link chips, then one branch per running phase (its tasks `▶`/`▷` under it), the
214
+ * `now:` and `Subagent:` lines, `next` with the open phases that wait, and `done` with the finished ones; a branch without lines is left out.
125
215
  */
126
216
  export const summaryChildren = (context: LaneContext, summary: LaneSummary, head: LaneHead): Frame[] => {
127
217
  const labels = TEXTS.herd
128
- const labelWidth = sectionWidth(labels)
129
- const inner = { ...context, width: Math.max(context.width - labelWidth, 1), labels }
130
- const { sections } = labels
131
- const now = summary.active.map((active) => activeFrame(inner, active))
218
+ const inner = { ...context, labels }
219
+ const { width } = context
132
220
  return [
133
- labelled(labelWidth, sections.session, textFrame([head.session(inner.width)])),
134
- labelled(labelWidth, sections.chips, head.chips(inner.width)),
135
- ...(now.length > 0 ? [labelled(labelWidth, sections.now, joinFrames(now))] : []),
136
- textSection(labelWidth, sections.next, nextText(summary, labels), context.width),
137
- textSection(labelWidth, sections.later, laterText(summary), context.width),
138
- textSection(labelWidth, sections.done, finishedText(summary, labels), context.width),
221
+ head.session,
222
+ head.pullRequests,
223
+ head.chips,
224
+ ...summary.active.map((active) => activeFrame(inner, active)),
225
+ ...summary.unplanned.map((task) => textFrame([nowLine(task, width)])),
226
+ ...summary.subagentTasks.map((description) => textFrame([subagentLine(description, width)])),
227
+ ...restingFrames(inner, summary),
139
228
  ]
140
229
  }