jackherd 1.8.1 → 1.9.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 (111) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/INSTALL.md +2 -0
  3. package/README.md +37 -44
  4. package/dist/{chunk-OU2KALXP.js → chunk-MCP4XJQH.js} +15 -0
  5. package/dist/config.d.ts +6 -0
  6. package/dist/config.js +19 -3
  7. package/dist/plan-format.d.ts +19 -2
  8. package/dist/plan-format.js +21 -7
  9. package/herdr-plugin/herdr-plugin.toml +1 -1
  10. package/install.sh +1 -1
  11. package/kit/hooks/hooks/approval-gate/index.ts +22 -4
  12. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  13. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  14. package/kit/hooks/hooks/merge-guard/decision.ts +67 -20
  15. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  16. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  17. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  18. package/kit/hooks/hooks/sent-file/store.ts +18 -0
  19. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  20. package/kit/hooks/hooks.json +9 -0
  21. package/kit/hooks/statusline.sh +1 -1
  22. package/kit/workflow/skills/implement/SKILL.md +2 -0
  23. package/kit/workflow/skills/orchestrate/SKILL.md +30 -6
  24. package/package.json +2 -1
  25. package/schemas/jackherd.schema.json +26 -2
  26. package/src/cli.ts +3 -0
  27. package/src/config/schema.ts +22 -2
  28. package/src/context/fill.ts +12 -0
  29. package/src/context/session-log.ts +43 -1
  30. package/src/plan-format/clock.ts +4 -0
  31. package/src/plan-format/dependencies.ts +2 -5
  32. package/src/plan-format/lanes.ts +30 -5
  33. package/src/plan-format/terminal-text.ts +4 -0
  34. package/src/plan-format/waiting.ts +48 -0
  35. package/src/plans/active-tasks.ts +60 -0
  36. package/src/plans/current-phase.ts +17 -7
  37. package/src/plans/current-task.ts +63 -5
  38. package/src/plans/lane-standing.ts +9 -4
  39. package/src/plans/lane-summary.ts +141 -74
  40. package/src/plans/lane-tickets.ts +15 -0
  41. package/src/plans/layout-phase.ts +57 -18
  42. package/src/plans/layout-summary.ts +85 -67
  43. package/src/plans/layout.ts +253 -89
  44. package/src/plans/live.ts +22 -0
  45. package/src/plans/phase-models.ts +1 -1
  46. package/src/plans/phase-ranges.ts +45 -0
  47. package/src/plans/pull-requests.ts +6 -0
  48. package/src/plans/selection.ts +19 -16
  49. package/src/plans/sent-files-read.ts +22 -0
  50. package/src/plans/sent-files.ts +121 -0
  51. package/src/plans/signals-read.ts +54 -0
  52. package/src/plans/snapshot.ts +251 -55
  53. package/src/plans/subagents-read.ts +82 -0
  54. package/src/plans/subagents.ts +132 -0
  55. package/src/plans/texts.ts +16 -11
  56. package/src/plans/unlisted-lanes.ts +10 -5
  57. package/src/plans/view.ts +3 -3
  58. package/src/plans/waiting-click.ts +76 -0
  59. package/src/preview/command.ts +83 -0
  60. package/src/preview/embed.ts +209 -0
  61. package/src/preview/images.ts +89 -0
  62. package/src/preview/open.ts +12 -0
  63. package/src/preview/options.ts +32 -0
  64. package/src/preview/page.ts +89 -0
  65. package/src/preview/render.ts +41 -0
  66. package/src/settled/board-row.ts +1 -2
  67. package/src/shared/is-record.ts +2 -0
  68. package/src/shared/private-records.ts +48 -0
  69. package/src/task-signals/agent.ts +15 -0
  70. package/src/task-signals/claude-read.ts +72 -0
  71. package/src/task-signals/claude-signals.ts +112 -0
  72. package/src/task-signals/command.ts +71 -0
  73. package/src/task-signals/fixtures/session.jsonl +7 -0
  74. package/src/task-signals/matcher.ts +96 -0
  75. package/src/task-signals/record-store.ts +40 -0
  76. package/src/task-signals/record.ts +68 -0
  77. package/src/task-signals/text.ts +13 -0
  78. package/src/task-signals/types.ts +14 -0
  79. package/src/terminal/open.ts +13 -6
  80. package/src/terminal/text.ts +8 -1
  81. package/src/terminal/width.ts +2 -1
  82. package/src/vocabulary/index.ts +28 -0
  83. package/src/waiting/command.ts +35 -0
  84. package/src/watch/appointments.ts +2 -3
  85. package/src/watch/approvals.ts +2 -2
  86. package/src/watch/command.ts +91 -5
  87. package/src/watch/failed-tests.ts +56 -10
  88. package/src/watch/failure-read.ts +18 -6
  89. package/src/watch/failures.ts +29 -5
  90. package/src/watch/feedback.ts +1 -1
  91. package/src/watch/gh-process.ts +9 -3
  92. package/src/watch/json-guards.ts +0 -3
  93. package/src/watch/lane-fill.ts +1 -1
  94. package/src/watch/lane-model.ts +1 -1
  95. package/src/watch/lane-parallel.ts +1 -1
  96. package/src/watch/lane-progress.ts +19 -7
  97. package/src/watch/lane-report.ts +5 -1
  98. package/src/watch/lane-rest.ts +28 -0
  99. package/src/watch/plan-badges.ts +9 -2
  100. package/src/watch/previews.ts +1 -1
  101. package/src/watch/pull-requests.ts +2 -1
  102. package/src/watch/source-path.ts +1 -1
  103. package/src/watch/sources.ts +1 -1
  104. package/src/watch/stream-output.ts +12 -7
  105. package/src/watch/task-parallel-input.ts +118 -0
  106. package/src/watch/task-parallel-read.ts +149 -0
  107. package/src/watch/task-parallel-touched.ts +39 -0
  108. package/src/watch/task-parallel.ts +206 -0
  109. package/src/watch/waiting-line.ts +19 -0
  110. package/src/watch/watch-events.ts +13 -1
  111. package/src/watch/workflow-runs.ts +2 -1
@@ -6,32 +6,31 @@ import {
6
6
  type PhaseProgress,
7
7
  type PlanPhase,
8
8
  type PlansByHref,
9
- parseDependencyTable,
10
9
  phaseProgress,
11
- phaseWaves,
12
10
  } from '../plan-format'
13
- import type { PlanLanguage } from '../vocabulary'
11
+ import type { TaskSignal } from '../task-signals/types'
12
+ import { type ActiveTask, assignSignals, freshSignals, type UnplannedTask } from './active-tasks'
14
13
  import { type ActivityTimes, activityKey } from './current-phase'
15
- import { currentTasks, type TaskSignal } from './current-task'
14
+ import { currentTasks, type TaskActivity } from './current-task'
16
15
 
17
16
  /**
18
- * A phase the lane works on now: an open one whose ticks or task lines moved less than `STALE_ACTIVITY_MINUTES` ago.
19
- * `stepIndex` is the newest step of the lane that shows it, null for a phase no step shows; `minutesAgo` the whole
20
- * minutes since that movement.
17
+ * A phase the lane works on now: an open one ticked less than `STALE_ACTIVITY_MINUTES` ago, one an agent reports, or
18
+ * one with a ticked task. `stepIndex` is the newest step of the lane that shows it, null for a phase no step shows;
19
+ * `minutesAgo` the whole minutes since its last tick or report, null when the herd saw neither.
21
20
  */
22
21
  export type ActivePhase = {
23
22
  phase: PhaseProgress
24
23
  stepIndex: number | null
25
- minutesAgo: number
26
- /** The index of the task the phase is on in its checkboxes (`currentTasks`); null when none is open. */
27
- taskIndex: number | null
24
+ minutesAgo: number | null
25
+ /**
26
+ * The tasks the phase is on: the ones agents reported, else the one `currentTasks` guesses (marked `guessed`); empty
27
+ * when no task is open.
28
+ */
29
+ tasks: ActiveTask[]
28
30
  }
29
31
 
30
- /** The phases that come next: the open ones of the next wave, `wave` its 1-based number, null without a dependency table. */
31
- export type NextPhases = { wave: number | null; phaseIds: string[] }
32
-
33
- /** Open phases after the next ones; `planTitle` is null for the lane's current plan, the board's title for another. */
34
- export type LaterPhases = { planTitle: string | null; phaseIds: string[] }
32
+ /** Open phases that do not run, by number; `planTitle` is null for the lane's current plan, the board's title for another. */
33
+ export type WaitingPhases = { planTitle: string | null; phaseIds: string[] }
35
34
 
36
35
  export type FinishedPhase = { id: string; unmerged: boolean }
37
36
 
@@ -41,35 +40,36 @@ export type UnwrittenPhase = { id: string; missing: boolean }
41
40
  /** Checkboxes and phases over every plan of the lane, a phase counted when it has a checkbox. */
42
41
  export type PlanTally = { done: number; total: number; phasesDone: number; phases: number }
43
42
 
44
- /** A lane as the herd sums it up: what runs now, what comes next and after, what is finished, and how far it is. */
43
+ /** A lane as the herd sums it up: what runs now, what waits, what is finished, and how far it is. */
45
44
  export type LaneSummary = {
46
- /** The running phases, the one moved last first. */
45
+ /** The running phases by number, never by recency, so a new movement reorders nothing. */
47
46
  active: ActivePhase[]
48
47
  /** The open phase of the current plan moved last, at any age; null while none has a time. */
49
48
  latest: PhaseProgress | null
50
- next: NextPhases | null
51
- later: LaterPhases[]
49
+ /** The open phases of the current plan that do not run, then the open phases of the lane's other plans. */
50
+ waiting: WaitingPhases[]
52
51
  finished: FinishedPhase[]
53
52
  unwritten: UnwrittenPhase[]
54
53
  tally: PlanTally
54
+ /** What agents report doing that no open task of the plan names. */
55
+ unplanned: UnplannedTask[]
55
56
  }
56
57
 
57
58
  /**
58
- * `phases` are the lane's plans as it reads them (the base merged with its worktree), `texts` their texts for the
59
- * dependency tables; a plan missing from `phases` falls back to the phases the lane's steps show.
59
+ * `phases` are the lane's plans as it reads them (the base merged with its worktree); a plan missing from `phases`
60
+ * falls back to the phases the lane's steps show.
60
61
  */
61
62
  export type SummaryInput = {
62
63
  progress: LaneProgress
63
64
  phases: Readonly<PlansByHref>
64
- texts: Readonly<Record<string, string>>
65
65
  activity: ActivityTimes
66
- /** The language the plans were written in, for reading their dependency tables; the herd's words are English. */
67
- language: PlanLanguage
68
- /** Hints at the task a phase is on, by phase id; none when absent, and no source writes one yet. */
69
- signals?: readonly TaskSignal[]
66
+ /** Hints at the task a phase is on, guessed from the words of the lane's agents; the fallback while nothing is reported. */
67
+ guesses?: readonly TaskActivity[]
68
+ /** What the lane's agents report doing (task lists, `jh task`), matched to open tasks of the current plan. */
69
+ reported?: readonly TaskSignal[]
70
70
  }
71
71
 
72
- /** Minutes without a tick or a new task line after which a phase no longer runs. */
72
+ /** Minutes without a tick after which a phase no longer runs, unless an agent reports it. */
73
73
  export const STALE_ACTIVITY_MINUTES = 20
74
74
 
75
75
  const MINUTE = 60_000
@@ -92,9 +92,27 @@ const stepPhases = ({ steps }: LaneProgress, href: string): PhaseProgress[] => {
92
92
  return shown.filter((phase, index) => shown.findIndex(({ id }) => id === phase.id) === index)
93
93
  }
94
94
 
95
+ const SUB_PHASE_LETTERS = /[a-z]+$/i
96
+
97
+ /** The ids the lane's steps on `href` name, a row's repeats and the phases it worked on without a row included. */
98
+ const namedIds = ({ steps }: LaneProgress, href: string): Set<string> =>
99
+ new Set(
100
+ steps.flatMap(({ step }) => (step.planHref === href ? [...step.phaseIds, ...(step.namedPhaseIds ?? [])] : [])),
101
+ )
102
+
103
+ /** A plan phase the lane names: by its own id, or a sub-phase (`10c`) of a phase it names (`P10`). */
104
+ const isNamed = (id: string, named: ReadonlySet<string>): boolean =>
105
+ named.has(id) || named.has(id.replace(SUB_PHASE_LETTERS, ''))
106
+
107
+ /**
108
+ * The phases of `href` the lane names, in plan order: another lane's phases of the same plan are not its own, however
109
+ * lately they moved.
110
+ */
95
111
  const phasesOf = (input: SummaryInput, href: string): PhaseProgress[] => {
96
112
  const plan: readonly PlanPhase[] | undefined = input.phases[href]
97
- return plan ? plan.map(({ id }) => phaseProgress(plan, id)) : stepPhases(input.progress, href)
113
+ if (!plan) return stepPhases(input.progress, href)
114
+ const named = namedIds(input.progress, href)
115
+ return plan.filter(({ id }) => isNamed(id, named)).map(({ id }) => phaseProgress(plan, id))
98
116
  }
99
117
 
100
118
  const newestStepShowing = ({ steps }: LaneProgress, href: string, id: string): number | null => {
@@ -106,6 +124,17 @@ const newestStepShowing = ({ steps }: LaneProgress, href: string, id: string): n
106
124
 
107
125
  type TimedPhase = { phase: PhaseProgress; minutesAgo: number }
108
126
 
127
+ type RunningPhase = { phase: PhaseProgress; minutesAgo: number | null }
128
+
129
+ /**
130
+ * A phase whose tasks outside the review gate are partly ticked: work on it has begun and goes on. One with only its
131
+ * review gate open waits for its review, as in `jh check --parallel`.
132
+ */
133
+ const isStarted = ({ items }: PhaseProgress): boolean => {
134
+ const tasks = items.filter(({ gate }) => !gate)
135
+ return tasks.some(({ done }) => done) && tasks.some(({ done }) => !done)
136
+ }
137
+
109
138
  /** The open phases with a time of their last movement, the one moved last first; a phase without a time is absent. */
110
139
  const timedPhases = (input: SummaryInput, href: string, phases: PhaseProgress[], now: number): TimedPhase[] =>
111
140
  phases
@@ -116,42 +145,77 @@ const timedPhases = (input: SummaryInput, href: string, phases: PhaseProgress[],
116
145
  })
117
146
  .sort((first, second) => first.minutesAgo - second.minutesAgo)
118
147
 
119
- /** The timed phases that moved less than `STALE_ACTIVITY_MINUTES` ago, each with the task it is on. */
120
- const activeOf = (input: SummaryInput, href: string, timed: TimedPhase[], now: number): ActivePhase[] => {
121
- const running = timed.filter(({ minutesAgo }) => minutesAgo < STALE_ACTIVITY_MINUTES)
122
- const tasks = currentTasks(
123
- running.map(({ phase }) => phase),
124
- input.signals ?? [],
125
- now,
126
- STALE_ACTIVITY_MINUTES * MINUTE,
127
- )
128
- return running.map(({ phase, minutesAgo }) => ({
129
- phase,
130
- stepIndex: newestStepShowing(input.progress, href, phase.id),
131
- minutesAgo,
132
- taskIndex: tasks.get(phase.id) ?? null,
133
- }))
148
+ const newest = (times: readonly number[]): number | null => (times.length > 0 ? Math.max(...times) : null)
149
+
150
+ const minutesSince = (at: number, now: number): number => Math.max(Math.floor((now - at) / MINUTE), 0)
151
+
152
+ const isListed = (listed: readonly RunningPhase[], phase: PhaseProgress): boolean =>
153
+ listed.some((each) => each.phase.id === phase.id)
154
+
155
+ /** The open phases a reported task names that are not `listed` yet, aged from the newest report. */
156
+ const reportedPhases = (
157
+ phases: PhaseProgress[],
158
+ reported: ReadonlyMap<string, ActiveTask[]>,
159
+ listed: readonly RunningPhase[],
160
+ now: number,
161
+ ): RunningPhase[] =>
162
+ phases
163
+ .filter((phase) => isPhaseOpen(phase) && reported.has(phase.id) && !isListed(listed, phase))
164
+ .map((phase) => {
165
+ const startedAt = newest((reported.get(phase.id) ?? []).flatMap((task) => task.startedAt ?? []))
166
+ return { phase, minutesAgo: startedAt === null ? 0 : minutesSince(startedAt, now) }
167
+ })
168
+
169
+ /** The open phases with a ticked task (`isStarted`) that are not `listed` yet, aged from their last tick when one is known. */
170
+ const startedPhases = (phases: PhaseProgress[], timed: TimedPhase[], listed: readonly RunningPhase[]): RunningPhase[] =>
171
+ phases
172
+ .filter((phase) => isPhaseOpen(phase) && isStarted(phase) && !isListed(listed, phase))
173
+ .map((phase) => ({ phase, minutesAgo: timed.find((each) => each.phase.id === phase.id)?.minutesAgo ?? null }))
174
+
175
+ /**
176
+ * The phases that run: the timed ones ticked less than `STALE_ACTIVITY_MINUTES` ago, the phases a reported task
177
+ * names, then every other open phase with a ticked task (`isStarted`), with the age of its last tick when one is
178
+ * known, else none (17:38). A phase only written down, without a tick or a report, waits.
179
+ */
180
+ const runningPhases = (
181
+ timed: TimedPhase[],
182
+ phases: PhaseProgress[],
183
+ reported: ReadonlyMap<string, ActiveTask[]>,
184
+ now: number,
185
+ ): RunningPhase[] => {
186
+ const moved = timed.filter(({ minutesAgo }) => minutesAgo < STALE_ACTIVITY_MINUTES)
187
+ const listed = [...moved, ...reportedPhases(phases, reported, moved, now)]
188
+ return [...listed, ...startedPhases(phases, timed, listed)]
134
189
  }
135
190
 
136
191
  /**
137
- * The open phases of the first wave that holds one not running; without a dependency table, phases run one after the
138
- * other and the next is the first open one not running. Null when nothing is left, or no wave holds what is left.
192
+ * The task the herd guesses for each of the `phases` from the words of the lane's agents (`currentTasks`); the caller
193
+ * passes no phase while an agent reports, so a lane with a report guesses nothing.
139
194
  */
140
- const nextOf = (
195
+ const guessedTasks = (input: SummaryInput, phases: PhaseProgress[], now: number): ReadonlyMap<string, number> =>
196
+ currentTasks(phases, input.guesses ?? [], now, STALE_ACTIVITY_MINUTES * MINUTE)
197
+
198
+ /** The running phases, each with the tasks agents reported on it, else its task in `guessed`, marked as guessed. */
199
+ const activeOf = (
141
200
  input: SummaryInput,
142
201
  href: string,
143
- phases: PhaseProgress[],
144
- running: ReadonlySet<string>,
145
- ): NextPhases | null => {
146
- const waiting = phases.filter((phase) => isPhaseOpen(phase) && !running.has(phase.id)).map(({ id }) => id)
147
- const text = input.texts[href]
148
- const rows = text === undefined ? null : parseDependencyTable(text, input.language)
149
- if (rows === null) return waiting.length > 0 ? { wave: null, phaseIds: [waiting[0]] } : null
150
- const done = new Set(phases.filter(isPhaseFinished).map(({ id }) => id))
151
- const waves = phaseWaves(rows, done)
152
- const index = waves.findIndex((wave) => wave.some((id) => waiting.includes(id)))
153
- return index === -1 ? null : { wave: index + 1, phaseIds: waves[index].filter((id) => waiting.includes(id)) }
154
- }
202
+ running: RunningPhase[],
203
+ reported: ReadonlyMap<string, ActiveTask[]>,
204
+ guessed: ReadonlyMap<string, number>,
205
+ ): ActivePhase[] =>
206
+ running
207
+ .map(({ phase, minutesAgo }) => {
208
+ const guess = guessed.get(phase.id)
209
+ return {
210
+ phase,
211
+ stepIndex: newestStepShowing(input.progress, href, phase.id),
212
+ minutesAgo,
213
+ tasks:
214
+ reported.get(phase.id) ??
215
+ (guess === undefined ? [] : [{ index: guess, agent: null, guessed: true, startedAt: null }]),
216
+ }
217
+ })
218
+ .sort((first, second) => first.phase.id.localeCompare(second.phase.id, undefined, { numeric: true }))
155
219
 
156
220
  const tallyOf = (phases: PhaseProgress[]): PlanTally => ({
157
221
  done: phases.reduce((sum, phase) => sum + phase.done, 0),
@@ -164,21 +228,21 @@ const titleOf = ({ steps }: LaneProgress, href: string): string =>
164
228
  steps.find(({ step }) => step.planHref === href)?.step.planTitle ?? href
165
229
 
166
230
  /** The open phases of the lane's other plans, each plan by its title. */
167
- const otherPlans = (input: SummaryInput, hrefs: string[]): LaterPhases[] =>
231
+ const otherPlans = (input: SummaryInput, hrefs: string[]): WaitingPhases[] =>
168
232
  hrefs.flatMap((href) => {
169
233
  const open = phasesOf(input, href).filter(isPhaseOpen)
170
234
  return open.length > 0 ? [{ planTitle: titleOf(input.progress, href), phaseIds: open.map(({ id }) => id) }] : []
171
235
  })
172
236
 
173
- /** The open phases of the current plan that neither run nor come next, then the open phases of the lane's other plans. */
174
- const laterOf = (
237
+ /** The open phases of the current plan that do not run, by number, then the open phases of the lane's other plans. */
238
+ const waitingOf = (
175
239
  input: SummaryInput,
176
240
  href: string,
177
241
  phases: PhaseProgress[],
178
- upcoming: ReadonlySet<string>,
179
- ): LaterPhases[] => {
242
+ running: ReadonlySet<string>,
243
+ ): WaitingPhases[] => {
180
244
  const waiting = phases
181
- .filter((phase) => isPhaseOpen(phase) && !upcoming.has(phase.id))
245
+ .filter((phase) => isPhaseOpen(phase) && !running.has(phase.id))
182
246
  .map(({ id }) => id)
183
247
  .sort((first, second) => first.localeCompare(second, undefined, { numeric: true }))
184
248
  const others = otherPlans(
@@ -193,30 +257,33 @@ const finishedOf = (phases: PhaseProgress[]): FinishedPhase[] =>
193
257
  phases.filter(isPhaseFinished).map((phase) => ({ id: phase.id, unmerged: isTickedUnmerged(phase) }))
194
258
 
195
259
  /**
196
- * The lane summed up over its plans at `now`: the open phases running in its current plan (moved less than
197
- * `STALE_ACTIVITY_MINUTES` ago; a phase without a time of its last movement never runs), the open phase moved last,
198
- * the next wave's open phases, the other open phases, the finished ones (marked when not merged yet), the phases its
199
- * rows name with nothing to run, and the tally over every plan of the lane. A phase ticked to the end, review gate
260
+ * The lane summed up over its plans at `now`: the open phases running in its current plan (`runningPhases`), the open
261
+ * phase moved last, the open phases that wait, the finished ones (marked when not merged yet), the phases its rows
262
+ * name with nothing to run, and the tally over every plan of the lane. A phase ticked to the end, review gate
200
263
  * included, is finished, never running.
201
264
  */
202
265
  export const laneSummary = (input: SummaryInput, now: number): LaneSummary => {
203
266
  const href = focusHref(input.progress)
204
267
  const tally = tallyOf(planHrefs(input.progress).flatMap((each) => phasesOf(input, each)))
205
- if (href === null) return { active: [], latest: null, next: null, later: [], finished: [], unwritten: [], tally }
268
+ if (href === null) {
269
+ return { active: [], latest: null, waiting: [], finished: [], unwritten: [], tally, unplanned: [] }
270
+ }
206
271
  const phases = phasesOf(input, href)
207
272
  const timed = timedPhases(input, href, phases, now)
208
- const active = activeOf(input, href, timed, now)
209
- const running = new Set(active.map(({ phase }) => phase.id))
210
- const next = nextOf(input, href, phases, running)
273
+ const fresh = freshSignals(input.reported ?? [], newest(Object.values(input.activity)))
274
+ const { byPhase, unplanned } = assignSignals(phases, fresh)
275
+ const running = runningPhases(timed, phases, byPhase, now)
276
+ const guessable = fresh.length > 0 ? [] : running.map(({ phase }) => phase).filter(({ id }) => !byPhase.has(id))
277
+ const active = activeOf(input, href, running, byPhase, guessedTasks(input, guessable, now))
211
278
  return {
212
279
  active,
213
280
  latest: timed[0]?.phase ?? null,
214
- next,
215
- later: laterOf(input, href, phases, new Set([...running, ...(next?.phaseIds ?? [])])),
281
+ waiting: waitingOf(input, href, phases, new Set(active.map(({ phase }) => phase.id))),
216
282
  finished: finishedOf(phases),
217
283
  unwritten: stepPhases(input.progress, href)
218
284
  .filter((phase) => !isWritten(phase))
219
285
  .map(({ id, title }) => ({ id, missing: title === null })),
220
286
  tally,
287
+ unplanned,
221
288
  }
222
289
  }
@@ -34,6 +34,21 @@ const onceById = (tickets: readonly SourceTicket[]): SourceTicket[] =>
34
34
 
35
35
  const badgeOf = ({ lane: _lane, ...badge }: SourceTicket): TicketBadge => badge
36
36
 
37
+ /**
38
+ * A pull request title without the lane ticket it starts with (`T-1 …`, `[T-1] …`, `T-1: …`, `T-1 - …`): the ticket
39
+ * stands under the lane's links already. A key inside the title, a longer key or a title that is the key alone stays.
40
+ */
41
+ export const withoutTicketKey = (title: string, ids: readonly string[]): string => {
42
+ for (const id of ids) {
43
+ const lead = title.startsWith(`[${id}]`) ? `[${id}]` : title.startsWith(id) ? id : ''
44
+ const rest = title.slice(lead.length)
45
+ if (lead === '' || isWordCharacter(rest[0])) continue
46
+ const stripped = rest.replace(/^\s*[:-]?\s+|^\s*:\s*/, '')
47
+ if (stripped !== '' && stripped !== rest) return stripped
48
+ }
49
+ return title
50
+ }
51
+
37
52
  /** What the lane's board rows say of it beside its name, for finding ticket ids: the text of each row. */
38
53
  export const boardRowTexts = (lane: SprintLane): string[] => lane.steps.map(({ label }) => label)
39
54
 
@@ -1,8 +1,10 @@
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'
@@ -26,13 +28,14 @@ export type LaneContext = {
26
28
  }
27
29
 
28
30
  export const ACTIVE_MARK = '▶'
31
+ export const GUESSED_MARK = '▷'
29
32
  export const DEFERRED_MARK = '→'
30
33
  export const COLUMN_SEPARATOR = ' '
31
34
  export const MIN_TITLE_WIDTH = 8
32
35
 
33
36
  /** Places on each side of the slash of a count: ` 9/9 `, ` 15/15 `, `123/456`. */
34
37
  const COUNT_PLACES = 3
35
- /** The cells before a count: `◐`, `✓`, `?`, `–` or nothing, padded so every slash on screen is in one column. */
38
+ /** The cells before a count: `✓`, `?`, `–` or nothing, padded so every slash on screen is in one column. */
36
39
  const COUNT_MARK_WIDTH = 2
37
40
  const BLANK_COUNT = ' '.repeat(2 * COUNT_PLACES + 1)
38
41
  const COUNT_CELL_WIDTH = COUNT_MARK_WIDTH + 1 + BLANK_COUNT.length
@@ -87,26 +90,62 @@ export const modelMark = (model: PhaseModel | undefined): string =>
87
90
  : ''
88
91
 
89
92
  /** `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)}`)
93
+ const markedLines = (text: string, look: TaskLook, width: number): string[] => {
94
+ const lines = wrap(plain(text), Math.max(width - 2, 1))
95
+ return lines.map(
96
+ (line, index) =>
97
+ `${index > 0 ? ' ' : look.mark} ${look.style(line)}${index === lines.length - 1 ? look.suffix : ''}`,
98
+ )
99
+ }
100
+
101
+ /**
102
+ * The arrow of a phase or task being worked on: a teal `▶` where an agent reported it, a dim teal `▷` where the herd
103
+ * only guesses it from words. Yellow is the herd's colour for what waits on the user.
104
+ */
105
+ export const activeArrow = (guessed: boolean): string =>
106
+ guessed ? paint.dim(paint.teal(GUESSED_MARK)) : paint.teal(ACTIVE_MARK)
107
+
108
+ /** The title of a phase or task being worked on: bold where reported, plain where guessed. */
109
+ export const activeTitle = (text: string, guessed: boolean): string => (guessed ? text : paint.bold(text))
110
+
111
+ /** The agent's name after a title, dim; nothing while only one agent is active. */
112
+ export const agentSuffix = (agent: string | null): string => (agent ? ` ${paint.dim(terminalSafe(agent))}` : '')
113
+
114
+ /** The agent's name for the right edge of a row, dim; empty while only one agent is active. */
115
+ export const agentCell = (agent: string | null): string => (agent ? paint.dim(terminalSafe(agent)) : '')
92
116
 
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 }
117
+ /** Two cells: a task row hangs under its phase's label, which an arrow would otherwise push right. */
118
+ export const TASK_INDENT = ' '
119
+
120
+ /** `▶ now: <text>`: what an agent reports that no task of the plan names; the agent dim at the right edge. */
121
+ export const nowLine = ({ text, agent }: UnplannedTask, width: number): string =>
122
+ spread(`${activeArrow(false)} ${TEXTS.now} ${paint.bold(plain(terminalSafe(text)))}`, agentCell(agent), width)
123
+
124
+ type TaskLook = { mark: string; style: (line: string) => string; suffix: string }
125
+
126
+ /** The mark and the text style of a task: a ticked one green and dim, the one being worked on with its arrow. */
127
+ const taskLook = ({ done }: ChecklistItem, active: ActiveTask | undefined): TaskLook => {
128
+ if (active) {
129
+ return {
130
+ mark: activeArrow(active.guessed),
131
+ style: (line) => activeTitle(line, active.guessed),
132
+ suffix: agentSuffix(active.agent),
133
+ }
134
+ }
135
+ return done
136
+ ? { mark: paint.green('☑'), style: paint.dim, suffix: '' }
137
+ : { mark: '☐', style: (line) => line, suffix: '' }
97
138
  }
98
139
 
99
140
  /** A closed task as one row, cut at the pane's edge with `…`. */
100
- const closedTaskRow = (item: ChecklistItem, active: boolean, width: number): string[] => {
141
+ const closedTaskRow = (item: ChecklistItem, active: ActiveTask | undefined, width: number): string[] => {
101
142
  const { mark, style } = taskLook(item, active)
102
- return [fit(`${mark} ${style(plain(item.text))}`, width)]
143
+ return [spread(`${mark} ${style(plain(item.text))}`, agentCell(active?.agent ?? null), width)]
103
144
  }
104
145
 
105
146
  /** 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)
109
- }
147
+ const openTaskRows = (item: ChecklistItem, active: ActiveTask | undefined, width: number): string[] =>
148
+ markedLines(item.text, taskLook(item, active), width)
110
149
 
111
150
  /** A task as a selectable row under its `key`: clickable across every line it takes, the cursor on its first. */
112
151
  export const taskFrame = (key: string, rows: string[], context: Pick<LaneContext, 'state' | 'width'>): Frame =>
@@ -123,20 +162,20 @@ const tickedSummaryFrame = (count: number, phaseRowKey: string, width: number):
123
162
  })
124
163
 
125
164
  /**
126
- * The children of an open phase, one frame per task: open ones first, `active` marked among them, then the ones
165
+ * The children of an open phase, one frame per task: open ones first, the `active` ones (by task index) marked, then the ones
127
166
  * deferred to another phase, then the ticked ones listed or summed up.
128
167
  */
129
168
  export const phaseChildren = (
130
169
  context: LaneContext,
131
170
  phaseRowKey: string,
132
171
  phase: PhaseProgress,
133
- active: ChecklistItem | null,
172
+ active: ReadonlyMap<number, ActiveTask>,
134
173
  ): Frame[] => {
135
174
  const { state, width } = context
136
175
  const frameOf = (item: ChecklistItem, index: number): Frame => {
137
176
  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)
177
+ const mark = active.get(index)
178
+ const rows = state.expanded.has(key) ? openTaskRows(item, mark, width) : closedTaskRow(item, mark, width)
140
179
  return taskFrame(key, rows, context)
141
180
  }
142
181
  const framesWhere = (done: boolean): Frame[] =>