jackherd 1.18.1 → 1.19.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 (78) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +31 -26
  4. package/assets/icons/model.svg +1 -0
  5. package/dist/{chunk-N5FAOQZL.js → chunk-JDO6O3UO.js} +52 -2
  6. package/dist/config.d.ts +15 -2
  7. package/dist/config.js +22 -3
  8. package/dist/plan-format.d.ts +84 -1
  9. package/dist/plan-format.js +85 -1
  10. package/docs/plan-contract.md +8 -2
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/ask-rules.json +1 -7
  14. package/kit/hooks/hooks/approval-gate/decision.ts +10 -11
  15. package/kit/hooks/hooks/approval-gate/run.ts +23 -3
  16. package/kit/hooks/hooks/browser-guard/decision.ts +7 -0
  17. package/kit/hooks/hooks/browser-guard/index.ts +50 -0
  18. package/kit/hooks/hooks/plain-question/arguments.ts +14 -1
  19. package/kit/hooks/hooks/plain-question/card.ts +102 -0
  20. package/kit/hooks/hooks/plain-question/form.ts +25 -9
  21. package/kit/hooks/hooks/plain-question/index.ts +20 -25
  22. package/kit/hooks/hooks/plain-question/recommendation.ts +6 -6
  23. package/kit/hooks/hooks/return-brief/decision.ts +29 -0
  24. package/kit/hooks/hooks/return-brief/index.ts +37 -0
  25. package/kit/hooks/hooks/return-brief/settings.ts +11 -0
  26. package/kit/hooks/hooks/shared/coordinator-call.ts +25 -0
  27. package/kit/hooks/hooks/shared/transcript.ts +9 -0
  28. package/kit/hooks/hooks.json +18 -1
  29. package/kit/review/agents/security-audit.md +1 -1
  30. package/kit/workflow/agents/browser-check.md +29 -0
  31. package/kit/workflow/skills/implement/SKILL.md +5 -3
  32. package/kit/workflow/skills/orchestrate/SKILL.md +36 -14
  33. package/kit/workflow/skills/orchestrate/references/acceptance.md +10 -9
  34. package/kit/workflow/skills/orchestrate/references/close-lane.md +1 -1
  35. package/kit/workflow/skills/orchestrate/references/coordinator-clear.md +45 -0
  36. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +15 -12
  37. package/kit/workflow/skills/plan/SKILL.md +2 -0
  38. package/kit/workflow/skills/plan/references/template-English.md +8 -0
  39. package/kit/workflow/skills/plan/references/template-German.md +8 -0
  40. package/package.json +1 -1
  41. package/schemas/jackherd.schema.json +16 -0
  42. package/src/brief/command.ts +45 -0
  43. package/src/brief/lane-brief.ts +51 -0
  44. package/src/brief/lane-facts.ts +64 -0
  45. package/src/check/skill-budget-defaults.ts +15 -4
  46. package/src/cli.ts +1 -0
  47. package/src/config/schema.ts +22 -0
  48. package/src/lane/command.ts +1 -2
  49. package/src/lane-prompt/texts.ts +2 -2
  50. package/src/plan-contract/command.ts +1 -0
  51. package/src/plan-contract/contract.ts +28 -5
  52. package/src/plan-format/index.ts +1 -0
  53. package/src/plan-format/plan-head.ts +131 -0
  54. package/src/plans/active-tasks.ts +8 -1
  55. package/src/plans/click-hint.ts +2 -1
  56. package/src/plans/focus-overlay.ts +5 -2
  57. package/src/plans/folded-detail.ts +47 -0
  58. package/src/plans/lane-line.ts +86 -0
  59. package/src/plans/lane-reason.ts +24 -4
  60. package/src/plans/layout-phase.ts +4 -20
  61. package/src/plans/layout-summary.ts +56 -45
  62. package/src/plans/layout.ts +178 -44
  63. package/src/plans/port-listening.ts +25 -0
  64. package/src/plans/selection.ts +11 -0
  65. package/src/plans/snapshot.ts +27 -9
  66. package/src/plans/task-checklist.ts +57 -0
  67. package/src/plans/texts.ts +34 -9
  68. package/src/resume/command.ts +40 -6
  69. package/src/resume/coordinator-read.ts +136 -0
  70. package/src/resume/state.ts +163 -0
  71. package/src/resume/texts.ts +6 -0
  72. package/src/sync/render.ts +5 -1
  73. package/src/vocabulary/index.ts +74 -0
  74. package/src/watch/clear-moment.ts +228 -0
  75. package/src/watch/command.ts +92 -8
  76. package/src/watch/lane-report.ts +5 -1
  77. package/src/watch/reference-paths.ts +8 -4
  78. package/src/watch/watch-events.ts +13 -0
@@ -0,0 +1,47 @@
1
+ import { fit, paint, visibleLength } from '../terminal/text'
2
+
3
+ /**
4
+ * The words of a folded lane line behind its name; an empty one is left out. `phaseCount` is `P2/5`, `phaseMore` the
5
+ * other running phases (`+1 phase`), which stands whole behind the phase name or not at all.
6
+ */
7
+ export type FoldedDetailParts = {
8
+ lead: string
9
+ plan: string
10
+ phaseCount: string
11
+ phaseName: string
12
+ phaseMore: string
13
+ reason: string
14
+ }
15
+
16
+ const SEPARATOR = ' · '
17
+ const PHASE_GAP = ' '
18
+ const MIN_PLAN_WIDTH = 8
19
+ const MIN_NAME_WIDTH = 8
20
+
21
+ const joined = ({ lead, plan, phaseCount, phaseName, phaseMore, reason }: FoldedDetailParts): string => {
22
+ const phase = [phaseCount, phaseName, phaseName && phaseMore].filter(Boolean).join(PHASE_GAP)
23
+ const rest = [plan, phase, reason].filter(Boolean).join(paint.dim(SEPARATOR))
24
+ return [lead, rest].filter(Boolean).join(' ')
25
+ }
26
+
27
+ /** The part cut to `room` cells, or dropped when `room` is under `minimum`. */
28
+ const cutTo = (part: string, room: number, minimum: number): string => (room >= minimum ? fit(part, room) : '')
29
+
30
+ /**
31
+ * `Plan · P2/5 Phase name · reason` within `room` cells. A pane too narrow gives up the plan title first (cut to what
32
+ * is left, dropped under 8 cells), then the phase name (cut to what is left, dropped under 8 cells); the phase count
33
+ * and the reason stay whole, and only a reason wider than the pane itself is cut.
34
+ */
35
+ export const foldedDetail = (parts: FoldedDetailParts, room: number): string => {
36
+ const fits = (candidate: FoldedDetailParts) => visibleLength(joined(candidate)) <= room
37
+ if (fits(parts)) return joined(parts)
38
+ const withoutPlan = { ...parts, plan: '' }
39
+ const planRoom = room - visibleLength(joined(withoutPlan)) - SEPARATOR.length
40
+ const cutPlan = { ...parts, plan: cutTo(parts.plan, planRoom, MIN_PLAN_WIDTH) }
41
+ if (cutPlan.plan && fits(cutPlan)) return joined(cutPlan)
42
+ if (fits(withoutPlan)) return joined(withoutPlan)
43
+ const moreWidth = parts.phaseMore ? PHASE_GAP.length + visibleLength(parts.phaseMore) : 0
44
+ const nameRoom = room - visibleLength(joined({ ...withoutPlan, phaseName: '' })) - PHASE_GAP.length - moreWidth
45
+ const cutName = { ...withoutPlan, phaseName: cutTo(parts.phaseName, nameRoom, MIN_NAME_WIDTH) }
46
+ return fit(joined(cutName), room)
47
+ }
@@ -0,0 +1,86 @@
1
+ import { TONE_ROLE } from '../config/tones'
2
+ import { ellipsize } from '../plan-format/terminal-text'
3
+ import { badge, paint } from '../terminal/text'
4
+ import { AGENT_STATUS, isRestingAgent } from '../terminal/types'
5
+ import { LANE_REASON, type LaneReason } from './lane-reason'
6
+ import { TEXTS } from './texts'
7
+ import { paintOfRole } from './tones'
8
+
9
+ /** What a folded lane line says first: the lane needs the user, is blocked on something, or runs. */
10
+ export const LANE_STATE = { needsYou: 'needs-you', blocked: 'blocked', running: 'running' } as const
11
+ export type LaneState = (typeof LANE_STATE)[keyof typeof LANE_STATE]
12
+
13
+ /** A lane as its line needs it: the agent's status, why a resting lane rests, the age of its last activity and its step. */
14
+ export type LaneLineInput = {
15
+ agent: string | null
16
+ reason: LaneReason | null
17
+ minutesAgo: number | null
18
+ step: string
19
+ /** The most cells the text behind the status may take; under `MIN_TEXT_WIDTH` the lead is the status alone. */
20
+ maxText?: number
21
+ }
22
+
23
+ const LIST_MARKER = /^·\s*/
24
+
25
+ /**
26
+ * The state of a lane: a blocked agent or a resting lane with an open question needs you; a resting lane that waits
27
+ * on a row of "Your turn" or on its starts-after cell is blocked; a working lane runs. Null for the rest (a resting
28
+ * lane without a known reason, a lane without an agent), which the line leaves to its older words.
29
+ */
30
+ export const laneStateOf = (agent: string | null, reason: LaneReason | null): LaneState | null => {
31
+ if (agent === AGENT_STATUS.blocked) return LANE_STATE.needsYou
32
+ if (agent === AGENT_STATUS.working) return LANE_STATE.running
33
+ if (!isRestingAgent(agent) || !reason) return null
34
+ if (reason.kind === LANE_REASON.you) return LANE_STATE.needsYou
35
+ return reason.kind === LANE_REASON.none ? null : LANE_STATE.blocked
36
+ }
37
+
38
+ const STATE_WORD: Record<LaneState, () => string> = {
39
+ [LANE_STATE.needsYou]: () => TEXTS.laneNeedsYou,
40
+ [LANE_STATE.blocked]: () => TEXTS.laneBlocked,
41
+ [LANE_STATE.running]: () => TEXTS.laneRunning,
42
+ }
43
+
44
+ const STATE_PAINT: Record<LaneState, (text: string) => string> = {
45
+ [LANE_STATE.needsYou]: (text) => paint.bold(paintOfRole(TONE_ROLE.waiting)(text)),
46
+ [LANE_STATE.blocked]: paintOfRole(TONE_ROLE.waiting),
47
+ [LANE_STATE.running]: paintOfRole(TONE_ROLE.running),
48
+ }
49
+
50
+ /** The most of a question, wait reason or step the lead names, so the phase count behind it stays in view. */
51
+ const TEXT_WIDTH = 44
52
+
53
+ const MIN_TEXT_WIDTH = 8
54
+
55
+ const rawText = (state: LaneState, { reason, step }: LaneLineInput): string => {
56
+ if (state === LANE_STATE.running) return step
57
+ if (!reason) return ''
58
+ return state === LANE_STATE.needsYou
59
+ ? reason.text.replace(LIST_MARKER, '')
60
+ : [reason.label, reason.text].filter(Boolean).join(' ')
61
+ }
62
+
63
+ /** The question to the user is never cut for room; a wait reason or a step gives way to the phase count behind it. */
64
+ const stateText = (state: LaneState, input: LaneLineInput): string => {
65
+ if (state === LANE_STATE.needsYou) return rawText(state, input)
66
+ const width = Math.min(TEXT_WIDTH, input.maxText ?? TEXT_WIDTH)
67
+ return width < MIN_TEXT_WIDTH ? '' : ellipsize(rawText(state, input), width)
68
+ }
69
+
70
+ /**
71
+ * The words that open a folded lane line: `needs you 3 min · <question>`, `blocked 3 min · waits on <what>` or
72
+ * `running 3 min · <step>`. The age is that of the lane's last activity, whole minutes, so a line changes once a
73
+ * minute; the step is the lane's reported task, which changes with the work and not with every action. Empty
74
+ * for a lane without a state.
75
+ */
76
+ export const laneLead = (input: LaneLineInput): string => {
77
+ const state = laneStateOf(input.agent, input.reason)
78
+ if (!state) return ''
79
+ const age = input.minutesAgo === null ? '' : ` ${TEXTS.laneAge(input.minutesAgo)}`
80
+ const text = stateText(state, input)
81
+ return `${STATE_PAINT[state](`${STATE_WORD[state]()}${age}`)}${text ? `${paint.dim(' ·')} ${text}` : ''}`
82
+ }
83
+
84
+ /** `:3100 ↗` as a badge linked to the lane's dev server; empty while the lane has no port or nothing listens on it. */
85
+ export const portBadge = (port: number | null, listening: boolean): string =>
86
+ port === null || !listening ? '' : badge(`http://localhost:${port}`, `:${port} ↗`)
@@ -1,6 +1,7 @@
1
1
  import type { SprintLane } from '../plan-format'
2
+ import { ellipsize } from '../plan-format/terminal-text'
2
3
  import { AGENT_STATUS, isRestingAgent } from '../terminal/types'
3
- import { REST_QUESTION_COUNT } from '../watch/lane-rest'
4
+ import { REST_NOTE_LABEL, REST_QUESTION_COUNT } from '../watch/lane-rest'
4
5
  import { waitingNote } from './lane-standing'
5
6
  import { plain } from './markdown-text'
6
7
  import { shortTitle } from './task-name'
@@ -28,9 +29,28 @@ const shortOf = (text: string): string => plain(shortTitle(text)).trim()
28
29
  /** How many open questions the watcher counted in the report; a note without its count is one. */
29
30
  const noteQuestionCount = (note: string): number => Number(note.match(REST_QUESTION_COUNT)?.[1] ?? 1)
30
31
 
32
+ const LIST_MARKER = /^[*\-\s]*(?:\d+\.\s+)?/
33
+
34
+ /** The first question of a rest note in its words, markdown and list markers out; empty when only its label or count remains. */
35
+ const noteQuestion = (note: string): string =>
36
+ plain((note.startsWith(REST_NOTE_LABEL) ? note.slice(REST_NOTE_LABEL.length) : note).replace(REST_QUESTION_COUNT, ''))
37
+ .trim()
38
+ .replace(LIST_MARKER, '')
39
+
40
+ /** The most of a question the line shows: a longer one would push the plan title out of a wide pane. */
41
+ const QUESTION_WIDTH = 30
42
+
43
+ /** What a question to you says: the first question in its words and `+n` for the others, else the count alone. */
44
+ const questionText = (note: string): string => {
45
+ const question = noteQuestion(note)
46
+ return question
47
+ ? TEXTS.restAsks(ellipsize(question, QUESTION_WIDTH), noteQuestionCount(note) - 1)
48
+ : TEXTS.restQuestions(noteQuestionCount(note))
49
+ }
50
+
31
51
  /**
32
- * Why a resting lane (idle or done) rests: the open questions of its report wait on you, by their count alone (their
33
- * text stands as rows of "Your turn"), else a row of "Your turn" that unblocks it, else its starts-after cell, both cut
52
+ * Why a resting lane (idle or done) rests: the open questions of its report wait on you, the first in its words and
53
+ * `+n` for the others, else a row of "Your turn" that unblocks it, else its starts-after cell, both cut
34
54
  * to their short title, else nothing known, which is meant to stand out. Null for a lane that works or whose agent is
35
55
  * blocked: the blocked word says it waits on you.
36
56
  */
@@ -43,7 +63,7 @@ export const laneReason = (
43
63
  return {
44
64
  kind: LANE_REASON.you,
45
65
  label: TEXTS.restWaitsOnYou,
46
- text: TEXTS.restQuestions(noteQuestionCount(restNote)),
66
+ text: questionText(restNote),
47
67
  }
48
68
  const row = rows.find(({ unblocks }) => unblocks.includes(lane.name))
49
69
  if (row) return { kind: LANE_REASON.on, label: TEXTS.restWaitsOn, text: shortOf(row.text) }
@@ -153,7 +153,7 @@ export const subagentLine = (description: string, width: number, style: TaskStyl
153
153
  const taskLook = ({ done }: ChecklistItem, active: ActiveTask | undefined, taskStyle: TaskStyle): TaskLook => {
154
154
  if (active) {
155
155
  const style = active.guessed ? PLAIN_TASK : taskStyle
156
- return { mark: activeArrow(active.guessed), style, suffix: agentSuffix(active.agent) }
156
+ return { mark: activeArrow(active.guessed || active.fromPlan === true), style, suffix: agentSuffix(active.agent) }
157
157
  }
158
158
  return done
159
159
  ? { mark: paint.green('☑'), style: paint.dim, suffix: '' }
@@ -236,22 +236,6 @@ export const taskRowFrame = (
236
236
  const deferredRow = ({ text, target }: DeferredItem, width: number): string =>
237
237
  fit(paint.dim(`${DEFERRED_MARK} ${phaseLabel(target)} ${plain(text)}`), width)
238
238
 
239
- /**
240
- * The children of an open phase below its fold row, one frame per task: the open ones first, the `active` ones (by
241
- * task index) marked, then the ones deferred to another phase, then the ticked ones, all listed: an opened phase has
242
- * no second fold row. The tasks in `shown` stand above already and are left out.
243
- */
244
- export const phaseChildren = (
245
- context: LaneContext,
246
- phaseRowKey: string,
247
- phase: PhaseProgress,
248
- active: ReadonlyMap<number, ActiveTask>,
249
- shown: ReadonlySet<number>,
250
- ): Frame[] => {
251
- const frameOf = (item: ChecklistItem, index: number): Frame =>
252
- taskRowFrame(context, phaseRowKey, { item, index, active: active.get(index) })
253
- const framesWhere = (done: boolean): Frame[] =>
254
- phase.items.flatMap((item, index) => (item.done === done && !shown.has(index) ? [frameOf(item, index)] : []))
255
- const deferred = (phase.deferred ?? []).map((item) => textFrame([deferredRow(item, context.width)]))
256
- return [...framesWhere(false), ...deferred, ...framesWhere(true)]
257
- }
239
+ /** The checkboxes a phase moved to another phase, one dim row each, listed below the tasks of an opened phase. */
240
+ export const deferredFrames = (phase: PhaseProgress, width: number): Frame[] =>
241
+ (phase.deferred ?? []).map((item) => textFrame([deferredRow(item, width)]))
@@ -1,15 +1,15 @@
1
1
  import { phaseLabel } from '../plan-format'
2
2
  import { bar, fit, paint, spread, visibleLength } from '../terminal/text'
3
3
  import { type Frame, indented, joinFrames, narrower, rowFrame, spaced, textFrame } from './frame'
4
- import { type ActivePhase, type LaneSummary, type PlacedPhase, SHOWN_OPEN_TASKS } from './lane-summary'
4
+ import { type ActivePhase, type LaneSummary, type PlacedPhase } from './lane-summary'
5
5
  import {
6
6
  activeArrow,
7
7
  COLUMN_SEPARATOR,
8
+ deferredFrames,
8
9
  type LaneContext,
9
10
  MIN_TITLE_WIDTH,
10
11
  modelMark,
11
12
  nowLine,
12
- phaseChildren,
13
13
  phaseCountCell,
14
14
  subagentLine,
15
15
  TASK_INDENT,
@@ -20,16 +20,14 @@ import {
20
20
  import { plain } from './markdown-text'
21
21
  import { type PhaseModel, phaseModelKey } from './phase-models'
22
22
  import { phaseRanges } from './phase-ranges'
23
- import { finishedKey, nextKey, phaseKey } from './selection'
23
+ import { finishedKey, moreTasksKey, nextKey, phaseKey } from './selection'
24
24
  import { chevronOf, PHASE_COUNT } from './symbols'
25
+ import { checklistRows, runningTaskIndexes } from './task-checklist'
25
26
  import { type HerdTexts, TEXTS } from './texts'
26
27
 
27
28
  /** The context of a lane's branches: the lane's, with the herd's words. */
28
29
  type SummaryContext = LaneContext & { labels: HerdTexts }
29
30
 
30
- /** What the row under a phase's tasks counts: the open tasks past the first ones and the done ones. */
31
- type TaskTally = { more: number; done: number }
32
-
33
31
  /** What an open lane shows above its phases, drawn at the width of its children: the session, its link chips and its pull requests. */
34
32
  export type LaneHead = { session: Frame; chips: Frame; pullRequests: Frame }
35
33
 
@@ -39,11 +37,6 @@ const onlyGateOpen = ({ items }: ActivePhase['phase']): boolean => {
39
37
  return open.length > 0 && open.every(({ gate }) => gate)
40
38
  }
41
39
 
42
- const taskTally = ({ items }: ActivePhase['phase']): TaskTally => {
43
- const done = items.filter((item) => item.done).length
44
- return { more: Math.max(items.length - done - SHOWN_OPEN_TASKS, 0), done }
45
- }
46
-
47
40
  /**
48
41
  * `· 6 min ago`, dim: when the running phase was last ticked or reported, nothing when the herd saw neither; `· gate`
49
42
  * after it while only the review gate is open. Empty when there is neither.
@@ -93,7 +86,7 @@ const reportedTaskTexts = ({ phase, tasks }: ActivePhase, labels: HerdTexts): st
93
86
  * In the order of the open lane: the tasks agents report on the phase, the reported ones no plan task names as
94
87
  * `now: …`, then the descriptions of the subagents that name no phase as `Subagent: …`.
95
88
  */
96
- const reportedTexts = (
89
+ export const reportedTexts = (
97
90
  active: ActivePhase | undefined,
98
91
  { unplanned, subagentTasks }: Pick<LaneSummary, 'unplanned' | 'subagentTasks'>,
99
92
  labels: HerdTexts,
@@ -123,57 +116,75 @@ export const foldedTaskLine = (
123
116
  return `${indent}${lead}${style(fit(first, room))}${tail}`
124
117
  }
125
118
 
126
- /** The indexes of a phase's open tasks: the ones it is on first, then the others in the order of the plan. */
127
- const openTaskOrder = ({ phase, tasks }: ActivePhase): number[] => {
128
- const isOpen = (index: number) => phase.items[index]?.done === false
129
- const running = tasks.map(({ index }) => index).filter(isOpen)
130
- const others = phase.items.flatMap((item, index) => (!item.done && !running.includes(index) ? [index] : []))
131
- return [...new Set(running), ...others]
132
- }
133
-
134
119
  /**
135
- * The row that opens and folds a phase's tasks, dim: `▸ 2 more open · 12 done` while the phase shows its first open
136
- * ones, the same text with `▾` once it lists them all. None while the first open tasks are all the phase has.
120
+ * The tasks of a phase that run: the ones agents report, else, while the lane runs the phase, its first open task
121
+ * from the plan, which no agent reported: drawn `◌`, but shimmering like a reported task (`fromPlan`).
137
122
  */
138
- const moreTasksFrame = (key: string, tally: TaskTally, open: boolean, context: SummaryContext): Frame[] => {
139
- if (tally.more === 0 && tally.done === 0) return []
140
- const text = TEXTS.moreTasks(tally.more, tally.done)
141
- return [rowFrame(key, [fit(paint.dim(`${chevronOf(open)} ${text}`), context.width)], [], context)]
123
+ const runningTasks = ({ phase, tasks }: ActivePhase, laneRuns: boolean): Map<number, ActivePhase['tasks'][number]> => {
124
+ const indexes = runningTaskIndexes(
125
+ phase.items,
126
+ tasks.map(({ index }) => index),
127
+ laneRuns,
128
+ )
129
+ const reported = new Map(tasks.map((task) => [task.index, task]))
130
+ return new Map(
131
+ indexes.map((index) => [
132
+ index,
133
+ reported.get(index) ?? { index, agent: null, guessed: false, startedAt: null, fromPlan: true as const },
134
+ ]),
135
+ )
142
136
  }
143
137
 
144
- /** The tasks of a phase at the indexes in `shown`, each a row under `key`. */
145
- const shownTasks = (
138
+ /** The dim row for the tasks a window leaves out, a row of its own under `key` when it opens the whole list. */
139
+ const countFrame = (key: string, text: string, open: boolean, context: SummaryContext): Frame =>
140
+ rowFrame(key, [fit(paint.dim(`${chevronOf(open)} ${text}`), context.width)], [], context)
141
+
142
+ /**
143
+ * The checklist of a phase in the plan's order (`checklistRows`): ticked tasks, the running ones marked, the ones to
144
+ * come. The first count row of a window opens the whole list; opened, `▾ <n> tasks` stands first and folds it, and the
145
+ * checkboxes moved to another phase close it.
146
+ */
147
+ const checklistFrames = (
146
148
  context: SummaryContext,
147
149
  key: string,
148
- shown: readonly number[],
149
150
  active: ActivePhase,
150
151
  running: ReadonlyMap<number, ActivePhase['tasks'][number]>,
151
- ): Frame[] =>
152
- shown.flatMap((index) => {
153
- const item = active.phase.items[index]
154
- return item ? [taskRowFrame(context, key, { item, index, active: running.get(index) })] : []
152
+ ): Frame[] => {
153
+ const { items } = active.phase
154
+ const moreKey = moreTasksKey(key)
155
+ const { expanded } = context.state
156
+ const open = expanded.has(key) || expanded.has(moreKey)
157
+ const rows = checklistRows(items, [...running.keys()], open)
158
+ const windowed = checklistRows(items, [...running.keys()], false).some(({ kind }) => kind !== 'task')
159
+ const frames = rows.map((row): Frame => {
160
+ if (row.kind === 'task') {
161
+ const item = items[row.index]
162
+ return item
163
+ ? taskRowFrame(context, key, { item, index: row.index, active: running.get(row.index) })
164
+ : textFrame([])
165
+ }
166
+ return countFrame(row.kind === 'above' ? key : moreKey, TEXTS.tasksMore(row.count), open, context)
155
167
  })
168
+ const foldKey = expanded.has(key) ? key : moreKey
169
+ const folder = open && windowed ? [countFrame(foldKey, TEXTS.tasksListed(items.length), true, context)] : []
170
+ return [...folder, ...frames, ...(open ? deferredFrames(active.phase, context.width) : [])]
171
+ }
156
172
 
157
173
  /**
158
- * A phase the lane works on: its head with the bar, no row of its own; under it the open tasks, the ones it is on
159
- * first, at most `SHOWN_OPEN_TASKS`, then `▸ <n> more open · <k> done`. Opened, that row stays where it stood with
160
- * its text and a `▾`, and the rest of the tasks (`phaseChildren`) stand below it, never above.
174
+ * A phase the lane works on: its head with the bar, no row of its own; under it the checklist of its tasks in the
175
+ * plan's order, the running one marked (`checklistFrames`). `laneRuns` is false for a phase that only waits.
161
176
  */
162
- const activeFrame = (context: SummaryContext, active: ActivePhase): Frame => {
163
- const { progress, state } = context
177
+ const activeFrame = (context: SummaryContext, active: ActivePhase, laneRuns = true): Frame => {
178
+ const { progress } = context
164
179
  const stepIndex = active.stepIndex ?? -1
165
180
  const key = phaseKey(progress.lane.name, stepIndex, active.phase.id)
166
181
  const current = progress.current?.stepIndex === stepIndex && progress.current.phaseId === active.phase.id
167
182
  const model = context.models[phaseModelKey(stepIndex, active.phase.id)]
168
183
  const inner = narrower(context, TASK_INDENT.length)
169
- const running = new Map(active.tasks.map((task) => [task.index, task]))
170
- const open = state.expanded.has(key)
171
- const shown = openTaskOrder(active).slice(0, SHOWN_OPEN_TASKS)
172
- const more = moreTasksFrame(key, taskTally(active.phase), open, inner)
173
- const rest = open ? phaseChildren(inner, key, active.phase, running, new Set(shown)) : []
184
+ const running = runningTasks(active, laneRuns)
174
185
  return joinFrames([
175
186
  textFrame([phaseHead(active, current, context, model)]),
176
- indented(joinFrames([...shownTasks(inner, key, shown, active, running), ...more, ...rest]), TASK_INDENT),
187
+ indented(joinFrames(checklistFrames(inner, key, active, running)), TASK_INDENT),
177
188
  ])
178
189
  }
179
190
 
@@ -219,7 +230,7 @@ const finishedText = (summary: LaneSummary, labels: HerdTexts): string => {
219
230
 
220
231
  /** A phase that does not run, drawn like a running one: its head, its first open tasks and the line that lists them all. */
221
232
  const restingFrame = (context: SummaryContext, { phase, stepIndex }: PlacedPhase): Frame =>
222
- activeFrame(context, { phase, stepIndex, minutesAgo: null, tasks: [] })
233
+ activeFrame(context, { phase, stepIndex, minutesAgo: null, tasks: [] }, false)
223
234
 
224
235
  /**
225
236
  * `▸ <text>`: one line cut at the pane's edge, a row under `key`; no line when the text is empty. Open (`▾`), it