jackherd 1.11.2 → 1.12.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 (94) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +69 -51
  3. package/dist/{chunk-RMMXD4G5.js → chunk-V4HOLGFP.js} +2 -2
  4. package/dist/config.d.ts +10 -0
  5. package/dist/config.js +15 -5
  6. package/dist/plan-format.d.ts +11 -6
  7. package/dist/plan-format.js +8 -2
  8. package/docs/follow-dark.svg +2 -2
  9. package/docs/follow.svg +2 -2
  10. package/docs/workflow-dark.svg +56 -64
  11. package/docs/workflow.svg +56 -64
  12. package/herdr-plugin/herdr-plugin.toml +1 -1
  13. package/install.sh +1 -1
  14. package/kit/workflow/skills/implement/SKILL.md +1 -1
  15. package/kit/workflow/skills/orchestrate/SKILL.md +1 -1
  16. package/kit/workflow/skills/orchestrate/references/draft-review.md +4 -0
  17. package/kit/workflow/skills/orchestrate/references/events.md +8 -1
  18. package/kit/workflow/skills/plan/SKILL.md +2 -0
  19. package/package.json +2 -2
  20. package/schemas/jackherd.schema.json +18 -1
  21. package/src/config/link-kinds.ts +1 -1
  22. package/src/config/schema.ts +5 -2
  23. package/src/config/tones.ts +9 -2
  24. package/src/context/fill.ts +4 -10
  25. package/src/context/session-log.ts +108 -2
  26. package/src/plan-format/lanes.ts +10 -1
  27. package/src/plan-format/phases.ts +11 -3
  28. package/src/plan-format/progress.ts +4 -2
  29. package/src/plan-format/waiting.ts +12 -0
  30. package/src/plans/active-tasks.ts +31 -8
  31. package/src/plans/appointment-rows.ts +42 -53
  32. package/src/plans/board-rows.ts +14 -0
  33. package/src/plans/calendar-days.ts +29 -0
  34. package/src/plans/click-hint.ts +94 -0
  35. package/src/plans/command.ts +8 -9
  36. package/src/plans/current-phase.ts +22 -10
  37. package/src/plans/current-task.ts +18 -3
  38. package/src/plans/frame.ts +18 -26
  39. package/src/plans/git.ts +90 -31
  40. package/src/plans/groomed.ts +13 -7
  41. package/src/plans/lane-reason.ts +61 -0
  42. package/src/plans/lane-summary.ts +75 -24
  43. package/src/plans/layout-phase.ts +123 -76
  44. package/src/plans/layout-summary.ts +133 -83
  45. package/src/plans/layout.ts +451 -345
  46. package/src/plans/legend.ts +29 -0
  47. package/src/plans/link-chips.ts +26 -8
  48. package/src/plans/live.ts +238 -84
  49. package/src/plans/next-rows.ts +5 -37
  50. package/src/plans/phase-source.ts +8 -3
  51. package/src/plans/plan-chips.ts +6 -3
  52. package/src/plans/plan-heads.ts +1 -1
  53. package/src/plans/planned.ts +4 -1
  54. package/src/plans/process-slots.ts +36 -0
  55. package/src/plans/pull-requests.ts +9 -1
  56. package/src/plans/read-cache.ts +18 -3
  57. package/src/plans/refresh-pace.ts +13 -0
  58. package/src/plans/row-focus.ts +86 -15
  59. package/src/plans/selection.ts +124 -89
  60. package/src/plans/sent-files.ts +1 -1
  61. package/src/plans/settings.ts +5 -2
  62. package/src/plans/shimmer.ts +124 -0
  63. package/src/plans/signals-read.ts +38 -10
  64. package/src/plans/slice-gate.ts +51 -0
  65. package/src/plans/snapshot.ts +185 -84
  66. package/src/plans/source-asked.ts +1 -1
  67. package/src/plans/source-rows.ts +60 -18
  68. package/src/plans/sprint-html.ts +1 -1
  69. package/src/plans/status-pace.ts +45 -0
  70. package/src/plans/subagent-tasks.ts +1 -1
  71. package/src/plans/subagents-read.ts +61 -24
  72. package/src/plans/subagents.ts +2 -2
  73. package/src/plans/symbols.ts +4 -1
  74. package/src/plans/task-brief.ts +143 -0
  75. package/src/plans/texts.ts +181 -39
  76. package/src/plans/view.ts +198 -32
  77. package/src/plans/waiting-click.ts +54 -110
  78. package/src/plans/watch-burst.ts +25 -0
  79. package/src/plans/worktree-stamp.ts +35 -0
  80. package/src/plans/your-turn-rows.ts +102 -0
  81. package/src/plans/your-turn.ts +226 -0
  82. package/src/terminal/herdr.ts +25 -5
  83. package/src/terminal/restore.ts +44 -0
  84. package/src/terminal/text.ts +2 -2
  85. package/src/terminal/width.ts +9 -0
  86. package/src/vocabulary/index.ts +6 -5
  87. package/src/watch/command.ts +28 -2
  88. package/src/watch/lane-report.ts +2 -0
  89. package/src/watch/lane-rest.ts +7 -1
  90. package/src/watch/reference-paths.ts +1 -0
  91. package/src/watch/unlinked-artifacts-read.ts +43 -0
  92. package/src/watch/unlinked-artifacts.ts +76 -0
  93. package/src/watch/watch-events.ts +12 -0
  94. package/src/watch/watcher-lock.ts +47 -4
@@ -1,14 +1,14 @@
1
- import { DAY_MS } from '../plan-format/clock'
2
1
  import { APPOINTMENT_VOCABULARY, type PlanLanguage } from '../vocabulary'
3
2
  import { readAppointmentTable } from '../watch/appointments'
3
+ import { addCalendarDays, calendarDaysBetween } from './calendar-days'
4
4
 
5
5
  const GROOMING_INTERVAL_DAYS = 7
6
6
  const STALE_FROM_DAYS = 8
7
7
 
8
8
  /**
9
- * Whole days since the board was last groomed, or null when the board has no open grooming appointment. The
10
- * coordinator moves that appointment a week on after each grooming, so the last one was a week before it. A time
11
- * before that (a board never groomed on the clock) counts as 0.
9
+ * Calendar days since the board was last groomed, in the clock's time zone, or null when the board has no open
10
+ * grooming appointment. The coordinator moves that appointment a week on after each grooming, so the last one was a
11
+ * week before it. A time before that (a board never groomed on the clock) counts as 0.
12
12
  */
13
13
  export const groomedDaysAgo = (board: string, language: PlanLanguage, now: Date): number | null => {
14
14
  const word = APPOINTMENT_VOCABULARY[language].grooming
@@ -16,12 +16,18 @@ export const groomedDaysAgo = (board: string, language: PlanLanguage, now: Date)
16
16
  .appointments.filter(({ open, action }) => open && action.toLowerCase().startsWith(word))
17
17
  .sort((left, right) => left.when.getTime() - right.when.getTime())[0]
18
18
  if (!next) return null
19
- const groomedAt = next.when.getTime() - GROOMING_INTERVAL_DAYS * DAY_MS
20
- return Math.max(Math.floor((now.getTime() - groomedAt) / DAY_MS), 0)
19
+ const groomedAt = addCalendarDays(next.when, -GROOMING_INTERVAL_DAYS)
20
+ return Math.max(calendarDaysBetween(groomedAt, now), 0)
21
+ }
22
+
23
+ const daysText = (days: number): string => {
24
+ if (days === 0) return 'today'
25
+ if (days === 1) return 'yesterday'
26
+ return `${days}d ago`
21
27
  }
22
28
 
23
29
  /** The header text; `stale` from eight days on, when the herd paints it amber. */
24
30
  export const groomedText = (days: number): { text: string; stale: boolean } => ({
25
- text: days === 0 ? 'groomed today' : `groomed ${days}d ago`,
31
+ text: `groomed ${daysText(days)}`,
26
32
  stale: days >= STALE_FROM_DAYS,
27
33
  })
@@ -0,0 +1,61 @@
1
+ import type { SprintLane } from '../plan-format'
2
+ import { AGENT_STATUS, isRestingAgent } from '../terminal/types'
3
+ import { REST_QUESTION_COUNT } from '../watch/lane-rest'
4
+ import { waitingNote } from './lane-standing'
5
+ import { plain } from './layout-phase'
6
+ import { shortTitle } from './task-name'
7
+ import { TEXTS } from './texts'
8
+
9
+ /** What a resting lane waits on: a question to you, a row of "Your turn", its starts-after cell, or nothing known. */
10
+ export const LANE_REASON = { you: 'you', on: 'on', after: 'after', none: 'none' } as const
11
+ export type LaneReasonKind = (typeof LANE_REASON)[keyof typeof LANE_REASON]
12
+
13
+ /** The reason as the lane line shows it: the label dim, the short text bright. */
14
+ export type LaneReason = { kind: LaneReasonKind; label: string; text: string }
15
+
16
+ /** A lane as its reason needs it: the board's lane, the status of its agent and the open question of its report. */
17
+ export type ReasonInput = { lane: SprintLane; agent: string | null; restNote?: string | null }
18
+
19
+ /** A row of "Your turn" as the reason needs it: its text and the lanes it frees. */
20
+ export type UnblockingRow = { text: string; unblocks: readonly string[] }
21
+
22
+ /** The head of the lanes block counts what waits on you and what idles otherwise. */
23
+ export type LanesBalance = { waitsOnYou: number; idle: number }
24
+
25
+ /** Cut before the markdown goes: a colon inside backticks does not end the short title. */
26
+ const shortOf = (text: string): string => plain(shortTitle(text)).trim()
27
+
28
+ /** How many open questions the watcher counted in the report; a note without its count is one. */
29
+ const noteQuestionCount = (note: string): number => Number(note.match(REST_QUESTION_COUNT)?.[1] ?? 1)
30
+
31
+ /**
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
34
+ * to their short title, else nothing known, which is meant to stand out. Null for a lane that works or whose agent is
35
+ * blocked: the blocked word says it waits on you.
36
+ */
37
+ export const laneReason = (
38
+ { lane, agent, restNote }: ReasonInput,
39
+ rows: readonly UnblockingRow[],
40
+ ): LaneReason | null => {
41
+ if (!isRestingAgent(agent)) return null
42
+ if (restNote)
43
+ return {
44
+ kind: LANE_REASON.you,
45
+ label: TEXTS.restWaitsOnYou,
46
+ text: TEXTS.restQuestions(noteQuestionCount(restNote)),
47
+ }
48
+ const row = rows.find(({ unblocks }) => unblocks.includes(lane.name))
49
+ if (row) return { kind: LANE_REASON.on, label: TEXTS.restWaitsOn, text: shortOf(row.text) }
50
+ const after = waitingNote(lane)
51
+ if (after !== null) return { kind: LANE_REASON.after, label: TEXTS.restAfter, text: shortOf(after) }
52
+ return { kind: LANE_REASON.none, label: '', text: TEXTS.restNoReason }
53
+ }
54
+
55
+ const waitsOnYou = ({ agent, restNote }: ReasonInput): boolean =>
56
+ agent === AGENT_STATUS.blocked || (isRestingAgent(agent) && Boolean(restNote))
57
+
58
+ export const lanesBalance = (lanes: readonly ReasonInput[]): LanesBalance => ({
59
+ waitsOnYou: lanes.filter(waitsOnYou).length,
60
+ idle: lanes.filter((lane) => isRestingAgent(lane.agent) && !waitsOnYou(lane)).length,
61
+ })
@@ -103,6 +103,9 @@ export type SummaryInput = {
103
103
  /** Minutes without a tick after which a phase no longer runs, unless an agent reports it. */
104
104
  export const STALE_ACTIVITY_MINUTES = 20
105
105
 
106
+ /** How many open tasks an open lane shows under a phase it works on before `▸ <n> more open`. */
107
+ export const SHOWN_OPEN_TASKS = 4
108
+
106
109
  const isWritten = ({ total }: PhaseProgress): boolean => total > 0
107
110
 
108
111
  const planHrefs = ({ steps }: LaneProgress): string[] => [
@@ -127,6 +130,19 @@ const namedIds = ({ steps }: LaneProgress, href: string): Set<string> =>
127
130
  steps.flatMap(({ step }) => (step.planHref === href ? [...step.phaseIds, ...(step.namedPhaseIds ?? [])] : [])),
128
131
  )
129
132
 
133
+ /**
134
+ * Whether a phase is a merged leftover: an older board row named it, the newest row does not, and the base branch
135
+ * carries its ticks as they stand (not `touched`), so its open boxes were left open on purpose, or carries ticks of
136
+ * it at all (`merged`), so whatever the worktree ticked since is no run of its own. Never one while the lane has no
137
+ * row.
138
+ */
139
+ const mergedLeftoverOf = ({ steps }: LaneProgress): IsLeftover => {
140
+ const row = [...steps].reverse().find(({ step }) => !step.withoutRow)?.step
141
+ if (!row) return () => false
142
+ const rowIds = new Set(row.namedPhaseIds ?? row.phaseIds)
143
+ return (phase) => (phase.touched !== true || phase.merged === true) && !isNamedBy(phase.id, rowIds)
144
+ }
145
+
130
146
  /**
131
147
  * The phases of `href` the lane names, in plan order: another lane's phases of the same plan are not its own, however
132
148
  * lately they moved.
@@ -149,6 +165,19 @@ type TimedPhase = { phase: PhaseProgress; minutesAgo: number }
149
165
 
150
166
  type RunningPhase = { phase: PhaseProgress; minutesAgo: number | null }
151
167
 
168
+ /** Whether a phase is a merged leftover (`mergedLeftoverOf`): it never runs and waits behind the others. */
169
+ type IsLeftover = (phase: PhaseProgress) => boolean
170
+
171
+ /**
172
+ * A merged leftover whose merge the base branch shows (`merged`): its recent ticks are no run of its own, so neither
173
+ * its movement nor the latest movement makes it run. A leftover without that evidence still runs once it moves.
174
+ */
175
+ const isMergedRun = (phase: PhaseProgress, isLeftover: IsLeftover): boolean =>
176
+ phase.merged === true && isLeftover(phase)
177
+
178
+ /** What decides which open phases wait and in which order: the ids that run, and the merged leftovers. */
179
+ type WaitingContext = { running: ReadonlySet<string>; isLeftover: IsLeftover }
180
+
152
181
  /**
153
182
  * A phase whose tasks outside the review gate are partly ticked: work on it has begun and goes on. One with only its
154
183
  * review gate open waits for its review, as in `jh check --parallel`.
@@ -217,7 +246,7 @@ const namesReview = (text: string, review: ReviewType): boolean =>
217
246
  new RegExp(`(?<![\\w-])${review}(?![\\w-])`).test(text)
218
247
 
219
248
  /**
220
- * The review gate box a running review sits on, which gets its `▶` like a reported task: the open one that names the
249
+ * The review gate box a running review sits on, which gets its `●` like a reported task: the open one that names the
221
250
  * review, else the first open one. A phase whose gate is ticked or that has none shows no arrow.
222
251
  */
223
252
  const reviewTask = ({ items }: PhaseProgress, review: ReviewType, at: number): ActiveTask[] => {
@@ -287,10 +316,18 @@ const unplacedTasks = (input: SummaryInput, signalled: PhaseProgress | null, pla
287
316
  : []),
288
317
  ]
289
318
 
290
- /** The open phases with a ticked task (`isStarted`) that are not `listed` yet, aged from their last tick when one is known. */
291
- const startedPhases = (phases: PhaseProgress[], timed: TimedPhase[], listed: readonly RunningPhase[]): RunningPhase[] =>
319
+ /**
320
+ * The open phases with a ticked task (`isStarted`) that are not `listed` yet, aged from their last tick when one is
321
+ * known; a merged leftover (`mergedLeftoverOf`) is none, it waits behind the others.
322
+ */
323
+ const startedPhases = (
324
+ phases: PhaseProgress[],
325
+ timed: TimedPhase[],
326
+ listed: readonly RunningPhase[],
327
+ isLeftover: IsLeftover,
328
+ ): RunningPhase[] =>
292
329
  phases
293
- .filter((phase) => isPhaseOpen(phase) && isStarted(phase) && !isListed(listed, phase))
330
+ .filter((phase) => isPhaseOpen(phase) && isStarted(phase) && !isListed(listed, phase) && !isLeftover(phase))
294
331
  .map((phase) => ({ phase, minutesAgo: timed.find((each) => each.phase.id === phase.id)?.minutesAgo ?? null }))
295
332
 
296
333
  /** The signalled phase when no other source lists it yet, aged from its last tick when one is known. */
@@ -304,7 +341,8 @@ const signalledRunning = (
304
341
  : []
305
342
 
306
343
  /**
307
- * The phases that run: the timed ones ticked less than `STALE_ACTIVITY_MINUTES` ago, the phases a reported task or a
344
+ * The phases that run: the timed ones ticked less than `STALE_ACTIVITY_MINUTES` ago, merged runs (`isMergedRun`)
345
+ * aside, the phases a reported task or a
308
346
  * running subagent names, the signalled phase (`signalledPhase`), then every other open phase with a ticked task
309
347
  * (`isStarted`), with the age of its last tick when one is known, else none. A phase only written down, without a
310
348
  * tick, a report or the lane's branch naming it, waits.
@@ -314,11 +352,14 @@ const runningPhases = (
314
352
  phases: PhaseProgress[],
315
353
  signals: PhaseSignals & { signalled: PhaseProgress | null },
316
354
  now: number,
355
+ isLeftover: IsLeftover,
317
356
  ): RunningPhase[] => {
318
- const moved = timed.filter(({ minutesAgo }) => minutesAgo < STALE_ACTIVITY_MINUTES)
357
+ const moved = timed.filter(
358
+ ({ phase, minutesAgo }) => minutesAgo < STALE_ACTIVITY_MINUTES && !isMergedRun(phase, isLeftover),
359
+ )
319
360
  const reported = [...moved, ...reportedPhases(phases, signals, moved, now)]
320
361
  const listed = [...reported, ...signalledRunning(signals.signalled, timed, reported)]
321
- return [...listed, ...startedPhases(phases, timed, listed)]
362
+ return [...listed, ...startedPhases(phases, timed, listed, isLeftover)]
322
363
  }
323
364
 
324
365
  /**
@@ -370,9 +411,11 @@ const otherPlans = (input: SummaryInput, hrefs: string[]): WaitingPhases[] =>
370
411
  const byPhaseNumber = (first: PhaseProgress, second: PhaseProgress): number =>
371
412
  first.id.localeCompare(second.id, undefined, { numeric: true })
372
413
 
373
- /** The open phases of the current plan that do not run, by number. */
374
- const waitingIn = (phases: PhaseProgress[], running: ReadonlySet<string>): PhaseProgress[] =>
375
- phases.filter((phase) => isPhaseOpen(phase) && !running.has(phase.id)).sort(byPhaseNumber)
414
+ /** The open phases of the current plan that do not run, by number, the merged leftovers (`mergedLeftoverOf`) last. */
415
+ const waitingIn = (phases: PhaseProgress[], { running, isLeftover }: WaitingContext): PhaseProgress[] => {
416
+ const waiting = phases.filter((phase) => isPhaseOpen(phase) && !running.has(phase.id)).sort(byPhaseNumber)
417
+ return [...waiting.filter((phase) => !isLeftover(phase)), ...waiting.filter(isLeftover)]
418
+ }
376
419
 
377
420
  const otherHrefs = (input: SummaryInput, href: string): string[] =>
378
421
  planHrefs(input.progress).filter((each) => each !== href)
@@ -382,9 +425,9 @@ const waitingOf = (
382
425
  input: SummaryInput,
383
426
  href: string,
384
427
  phases: PhaseProgress[],
385
- running: ReadonlySet<string>,
428
+ context: WaitingContext,
386
429
  ): WaitingPhases[] => {
387
- const waiting = waitingIn(phases, running).map(({ id }) => id)
430
+ const waiting = waitingIn(phases, context).map(({ id }) => id)
388
431
  return [
389
432
  ...(waiting.length > 0 ? [{ planTitle: null, phaseIds: waiting }] : []),
390
433
  ...otherPlans(input, otherHrefs(input, href)),
@@ -400,9 +443,9 @@ const waitingPlaced = (
400
443
  input: SummaryInput,
401
444
  href: string,
402
445
  phases: PhaseProgress[],
403
- running: ReadonlySet<string>,
446
+ context: WaitingContext,
404
447
  ): PlacedPhase[] => [
405
- ...waitingIn(phases, running).map(placedIn(input.progress, href)),
448
+ ...waitingIn(phases, context).map(placedIn(input.progress, href)),
406
449
  ...otherHrefs(input, href).flatMap((other) =>
407
450
  phasesOf(input, other).filter(isPhaseOpen).map(placedIn(input.progress, other)),
408
451
  ),
@@ -415,11 +458,16 @@ const finishedOf = (phases: PhaseProgress[]): FinishedPhase[] =>
415
458
  type Resting = Pick<LaneSummary, 'waiting' | 'waitingPhases' | 'finished' | 'finishedPhases' | 'unwritten'>
416
459
 
417
460
  /** What does not run: the phases that wait or are finished, as ids and as phases to open, and those without a checkbox. */
418
- const restingOf = (input: SummaryInput, href: string, phases: PhaseProgress[], active: ActivePhase[]): Resting => {
419
- const running = new Set(active.map(({ phase }) => phase.id))
461
+ const restingOf = (
462
+ input: SummaryInput,
463
+ href: string,
464
+ phases: PhaseProgress[],
465
+ { active, isLeftover }: { active: ActivePhase[]; isLeftover: IsLeftover },
466
+ ): Resting => {
467
+ const context = { running: new Set(active.map(({ phase }) => phase.id)), isLeftover }
420
468
  return {
421
- waiting: waitingOf(input, href, phases, running),
422
- waitingPhases: waitingPlaced(input, href, phases, running),
469
+ waiting: waitingOf(input, href, phases, context),
470
+ waitingPhases: waitingPlaced(input, href, phases, context),
423
471
  finished: finishedOf(phases),
424
472
  finishedPhases: phases.filter(isPhaseFinished).map(placedIn(input.progress, href)),
425
473
  unwritten: stepPhases(input.progress, href)
@@ -438,13 +486,14 @@ type ActiveSources = {
438
486
  signalled: PhaseProgress | null
439
487
  /** The runs of the lane's subagents, the reviews that name no phase placed (`placedRuns`). */
440
488
  subagents: readonly SubagentPhaseRun[]
489
+ isLeftover: IsLeftover
441
490
  }
442
491
 
443
492
  /** The running phases of the plan on `href`, each with the tasks reported on it, the review gate or the guessed one. */
444
493
  const activePhases = (input: SummaryInput, href: string, sources: ActiveSources, now: number): ActivePhase[] => {
445
- const { phases, timed, reported, anyFresh, signalled } = sources
494
+ const { phases, timed, reported, anyFresh, signalled, isLeftover } = sources
446
495
  const named = subagentPhases(phases, sources.subagents)
447
- const running = runningPhases(timed, phases, { reported, subagents: named, signalled }, now)
496
+ const running = runningPhases(timed, phases, { reported, subagents: named, signalled }, now, isLeftover)
448
497
  const tasks = withReviewTasks(reported, phases, named)
449
498
  const guessable = anyFresh ? [] : running.map(({ phase }) => phase).filter(({ id }) => !tasks.has(id))
450
499
  return activeOf(input, href, running, tasks, guessedTasks(input, guessable, now))
@@ -477,19 +526,21 @@ export const laneSummary = (input: SummaryInput, now: number): LaneSummary => {
477
526
  }
478
527
  const phases = phasesOf(input, href)
479
528
  const timed = timedPhases(input, href, phases, now)
529
+ const isLeftover = mergedLeftoverOf(input.progress)
530
+ const latest = timed.find(({ phase }) => !isMergedRun(phase, isLeftover))?.phase ?? null
480
531
  const fresh = freshSignals(input.reported ?? [], newest(Object.values(input.activity)))
481
- const { byPhase, unplanned } = assignSignals(phases, fresh)
532
+ const { byPhase, unplanned } = assignSignals(phases, fresh, latest?.id ?? null)
482
533
  const signalled = signalledPhase(phases, byPhase, input.branch ?? null)
483
534
  const subagents = placedRuns(input, signalled)
484
535
  const onTasks = subagentTaskPlacements(phases, input.describedSubagents ?? [])
485
536
  const reported = withTasks(byPhase, onTasks.byPhase)
486
- const sources = { phases, timed, reported, anyFresh: fresh.length > 0, signalled, subagents }
537
+ const sources = { phases, timed, reported, anyFresh: fresh.length > 0, signalled, subagents, isLeftover }
487
538
  const active = activePhases(input, href, sources, now)
488
539
  return {
489
540
  active,
490
- latest: timed[0]?.phase ?? null,
541
+ latest,
491
542
  signalled,
492
- ...restingOf(input, href, phases, active),
543
+ ...restingOf(input, href, phases, { active, isLeftover }),
493
544
  tally,
494
545
  unplanned,
495
546
  subagentTasks: unplacedTasks(input, signalled, onTasks.placed),
@@ -1,16 +1,18 @@
1
1
  import type { ChecklistItem, DeferredItem, LaneProgress, PhaseProgress } from '../plan-format'
2
2
  import { onlyDocumentationOpen, phaseLabel } from '../plan-format'
3
3
  import { terminalSafe } from '../plan-format/terminal-text'
4
- import { fit, padVisible, paint, spread, wrap } from '../terminal/text'
4
+ import { fit, padVisible, paint, spread, visibleLength } from '../terminal/text'
5
5
  import { textWidth } from '../terminal/width'
6
6
  import type { WatchBadges } from '../watch/plan-badges'
7
7
  import type { ActiveTask, UnplannedTask } from './active-tasks'
8
- import { type Frame, rowFrame, textFrame } from './frame'
8
+ import { type Frame, joinFrames, rowFrame, textFrame } from './frame'
9
9
  import type { LaneTickets } from './lane-tickets'
10
10
  import type { LinkChip } from './link-chips'
11
11
  import type { PhaseModel } from './phase-models'
12
- import { itemKey, type Selection, tickedKey } from './selection'
12
+ import { itemKey, type Selection, taskRestKey } from './selection'
13
+ import { shimmering, stillTeal } from './shimmer'
13
14
  import { chevronOf, MODEL_MARK, PHASE_COUNT, PHASE_STATE, type PhaseState, phaseStateOf } from './symbols'
15
+ import { taskBrief } from './task-brief'
14
16
  import { taskBodyOf, taskNameOf } from './task-name'
15
17
  import { type BadgeTexts, TEXTS } from './texts'
16
18
 
@@ -26,14 +28,33 @@ export type LaneContext = {
26
28
  tickets: LaneTickets | null
27
29
  /** The model each phase ran on, by `phaseModelKey`. */
28
30
  models: Record<string, PhaseModel>
31
+ /** How the text of a task an agent reports is drawn. */
32
+ taskStyle: TaskStyle
29
33
  }
30
34
 
31
- export const ACTIVE_MARK = '▶'
32
- export const GUESSED_MARK = '▷'
35
+ /** How the text of a task an agent reports is drawn, behind its `●`. */
36
+ export type TaskStyle = (text: string) => string
37
+
38
+ /** A task of a phase: its checkbox, its index in `phase.items`, and what an agent reports of it, absent for none. */
39
+ export type PhaseTask = { item: ChecklistItem; index: number; active: ActiveTask | undefined }
40
+
41
+ type TaskLook = { mark: string; style: (line: string) => string; suffix: string }
42
+
43
+ const ACTIVE_MARK = '●'
44
+ const GUESSED_MARK = '◌'
33
45
  export const DEFERRED_MARK = '→'
34
46
  export const COLUMN_SEPARATOR = ' '
35
47
  export const MIN_TITLE_WIDTH = 8
36
48
 
49
+ /** The text as it is: a task the herd only guesses from words. */
50
+ const PLAIN_TASK: TaskStyle = (text) => text
51
+
52
+ /** The text still in the teal the shimmer rests in (lighter than the brand teal of its `●`), while the herd does not shimmer. */
53
+ export const STILL_TASK: TaskStyle = stillTeal
54
+
55
+ /** The text marked to shimmer (`shimmerAt` draws it). */
56
+ export const SHIMMERING_TASK: TaskStyle = shimmering
57
+
37
58
  /** Places on each side of the slash of a count: ` 9/9 `, ` 15/15 `, `123/456`. */
38
59
  const COUNT_PLACES = 3
39
60
  /** The cells before a count: `✓`, `?`, `–` or nothing, padded so every slash on screen is in one column. */
@@ -41,6 +62,7 @@ const COUNT_MARK_WIDTH = 2
41
62
  const BLANK_COUNT = ' '.repeat(2 * COUNT_PLACES + 1)
42
63
  const COUNT_CELL_WIDTH = COUNT_MARK_WIDTH + 1 + BLANK_COUNT.length
43
64
  const MARKDOWN_MARKS = /\*\*|`/g
65
+ const CODE_SPAN = /`([^`]+)`/g
44
66
 
45
67
  const PHASE_PAINT: Record<PhaseState, (count: string) => string> = {
46
68
  [PHASE_STATE.missing]: paint.dim,
@@ -93,120 +115,145 @@ export const modelMark = (model: PhaseModel | undefined): string =>
93
115
  ? paint.yellow(`${familyLabel(model.used)} ${MODEL_MARK.differs} ${familyLabel(model.recommended)}`)
94
116
  : ''
95
117
 
96
- /** `text` wrapped beside a one-cell `mark`, each line in `style`; follow-up lines indent under the text. */
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
118
  /**
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.
119
+ * The mark of a phase or task being worked on: a teal `●` where an agent reported it, a dim teal `◌` where the herd
120
+ * only guesses it from words. No triangle: in the herd a triangle means a row that opens. Yellow is the herd's colour
121
+ * for what waits on the user.
108
122
  */
109
123
  export const activeArrow = (guessed: boolean): string =>
110
124
  guessed ? paint.dim(paint.teal(GUESSED_MARK)) : paint.teal(ACTIVE_MARK)
111
125
 
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
126
  /** The agent's name after a title, dim; nothing while only one agent is active. */
116
127
  export const agentSuffix = (agent: string | null): string => (agent ? ` ${paint.dim(terminalSafe(agent))}` : '')
117
128
 
118
129
  /** 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)) : '')
130
+ const agentCell = (agent: string | null): string => (agent ? paint.dim(terminalSafe(agent)) : '')
120
131
 
121
132
  /** Two cells: a task row hangs under its phase's label, which an arrow would otherwise push right. */
122
133
  export const TASK_INDENT = ' '
123
134
 
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)
135
+ /** The thin line before each line of an opened task's text, under the task's mark, and the cells it takes with its gap. */
136
+ const RAIL_PREFIX = `${TASK_INDENT}${paint.dim('│')} `
137
+ const RAIL_CELLS = visibleLength(RAIL_PREFIX)
127
138
 
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)
139
+ /** `● <label> <text>`, the text plain like every task and drawn in `style`, the agent dim at the right edge. */
140
+ const labelledLine = (label: string, { text, agent }: UnplannedTask, width: number, style: TaskStyle): string =>
141
+ spread(`${activeArrow(false)} ${label} ${style(plain(terminalSafe(text)))}`, agentCell(agent), width)
130
142
 
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)
143
+ /** `● now: <text>`: what an agent reports that no task of the plan names; the agent dim at the right edge. */
144
+ export const nowLine = (task: UnplannedTask, width: number, style: TaskStyle): string =>
145
+ labelledLine(TEXTS.now, task, width, style)
134
146
 
135
- type TaskLook = { mark: string; style: (line: string) => string; suffix: string }
147
+ /** `● Subagent: <description>`: a running subagent that names no phase and sits on none. */
148
+ export const subagentLine = (description: string, width: number, style: TaskStyle): string =>
149
+ labelledLine(TEXTS.subagent, { text: description, agent: null }, width, style)
136
150
 
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 => {
151
+ /**
152
+ * The mark and the text style of a task: a ticked one green and dim, the one being worked on with its arrow and
153
+ * otherwise plain like every open task; bold is for headings (lane name, phase title).
154
+ */
155
+ const taskLook = ({ done }: ChecklistItem, active: ActiveTask | undefined, taskStyle: TaskStyle): TaskLook => {
139
156
  if (active) {
140
- return {
141
- mark: activeArrow(active.guessed),
142
- style: (line) => activeTitle(line, active.guessed),
143
- suffix: agentSuffix(active.agent),
144
- }
157
+ const style = active.guessed ? PLAIN_TASK : taskStyle
158
+ return { mark: activeArrow(active.guessed), style, suffix: agentSuffix(active.agent) }
145
159
  }
146
160
  return done
147
161
  ? { mark: paint.green('☑'), style: paint.dim, suffix: '' }
148
162
  : { mark: '☐', style: (line) => line, suffix: '' }
149
163
  }
150
164
 
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[] => {
153
- const { mark, style } = taskLook(item, active)
154
- return [spread(`${mark} ${style(taskTitle(item.text))}`, agentCell(active?.agent ?? null), width)]
155
- }
165
+ /** What an open task reads as on one row, like `taskTitle`, but a path in backticks stays as a dim addition. */
166
+ const openTaskTitle = (text: string): string =>
167
+ plain((taskNameOf(text) ?? text).replace(CODE_SPAN, (_, code: string) => paint.dim(code)))
156
168
 
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]
169
+ /** A closed task as one row, its short name or its text cut at the pane's edge with `…`; an open one's paths dim. */
170
+ const closedTaskRow = (item: ChecklistItem, look: TaskLook, agent: string | null, width: number): string[] => {
171
+ const title = item.done ? taskTitle(item.text) : openTaskTitle(item.text)
172
+ return [spread(`${look.mark} ${look.style(title)}`, agentCell(agent), width)]
163
173
  }
164
174
 
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)
170
- }
175
+ /** A line of an opened task's text behind a thin dim rail, two cells in, under the task's mark; dim when ticked. */
176
+ const railLine = (line: string, done: boolean): string => `${RAIL_PREFIX}${done ? paint.dim(line) : line}`
171
177
 
172
178
  /** A task as a selectable row under its `key`: clickable across every line it takes, the cursor on its first. */
173
179
  export const taskFrame = (key: string, rows: string[], context: Pick<LaneContext, 'state' | 'width'>): Frame =>
174
180
  rowFrame(key, rows, [], context)
175
181
 
182
+ /**
183
+ * An opened task set as a brief (`taskBrief`): a named one its name bold beside the mark, an unnamed one the brief's
184
+ * first line; below it the brief behind a rail, then `▸ n more lines`, a row of its own that opens the rest below it.
185
+ */
186
+ const openedTaskFrame = (
187
+ key: string,
188
+ item: ChecklistItem,
189
+ look: TaskLook,
190
+ context: Pick<LaneContext, 'state' | 'width'>,
191
+ ): Frame => {
192
+ const { width } = context
193
+ const name = taskNameOf(item.text)
194
+ const brief = taskBrief(taskBodyOf(item.text), width - RAIL_CELLS)
195
+ const head = name ? paint.bold(plain(name)) : look.style(brief.shown[0] ?? '')
196
+ const below = name ? brief.shown : brief.shown.slice(1)
197
+ const task = taskFrame(
198
+ key,
199
+ [fit(`${look.mark} ${head}${look.suffix}`, width), ...below.map((line) => railLine(line, item.done))],
200
+ context,
201
+ )
202
+ return brief.rest.length === 0 ? task : joinFrames([task, taskRestFrame(key, brief.rest, item.done, context)])
203
+ }
204
+
205
+ /**
206
+ * The rest of an opened task's text once opened, right under its brief as one text, then `▸ n more lines` below the
207
+ * whole text, a row of its own under `taskRestKey`.
208
+ */
209
+ const taskRestFrame = (
210
+ key: string,
211
+ rest: readonly string[],
212
+ done: boolean,
213
+ context: Pick<LaneContext, 'state' | 'width'>,
214
+ ): Frame => {
215
+ const restKey = taskRestKey(key)
216
+ const open = context.state.expanded.has(restKey)
217
+ const more = `${TASK_INDENT}${paint.dim(`${chevronOf(open)} ${TEXTS.moreLines(rest.length)}`)}`
218
+ return joinFrames([
219
+ textFrame(open ? rest.map((line) => railLine(line, done)) : []),
220
+ rowFrame(restKey, [fit(more, context.width)], [], context),
221
+ ])
222
+ }
223
+
224
+ /** A task as its row under the phase's `phaseRowKey`: one line while closed, set as a brief once a click opened it. */
225
+ export const taskRowFrame = (
226
+ context: Pick<LaneContext, 'state' | 'width' | 'taskStyle'>,
227
+ phaseRowKey: string,
228
+ { item, index, active }: PhaseTask,
229
+ ): Frame => {
230
+ const key = itemKey(phaseRowKey, index)
231
+ const look = taskLook(item, active, context.taskStyle)
232
+ return context.state.expanded.has(key)
233
+ ? openedTaskFrame(key, item, look, context)
234
+ : taskFrame(key, closedTaskRow(item, look, active?.agent ?? null, context.width), context)
235
+ }
236
+
176
237
  /** A checkbox moved to another phase, dim: `→ P9 <text>`. */
177
238
  const deferredRow = ({ text, target }: DeferredItem, width: number): string =>
178
239
  fit(paint.dim(`${DEFERRED_MARK} ${phaseLabel(target)} ${plain(text)}`), width)
179
240
 
180
- /** The clickable line of a phase's ticked tasks: `▸ <n> ticked · …lists them`, or `▾ …folds them` above the listed ones. */
181
- const tickedSummaryFrame = (count: number, phaseRowKey: string, width: number, listed: boolean): Frame => ({
182
- lines: [
183
- fit(paint.dim(`${chevronOf(listed)} ${listed ? TEXTS.tickedFold(count) : TEXTS.tickedSummary(count)}`), width),
184
- ],
185
- cursorLine: -1,
186
- targets: [{ line: 0, from: 0, to: width, key: tickedKey(phaseRowKey), wholeLine: true }],
187
- })
188
-
189
241
  /**
190
- * The children of an open phase, one frame per task: open ones first, the `active` ones (by task index) marked, then the ones
191
- * deferred to another phase, then the ticked ones listed or summed up.
242
+ * The children of an open phase below its fold row, one frame per task: the open ones first, the `active` ones (by
243
+ * task index) marked, then the ones deferred to another phase, then the ticked ones, all listed: an opened phase has
244
+ * no second fold row. The tasks in `shown` stand above already and are left out.
192
245
  */
193
246
  export const phaseChildren = (
194
247
  context: LaneContext,
195
248
  phaseRowKey: string,
196
249
  phase: PhaseProgress,
197
250
  active: ReadonlyMap<number, ActiveTask>,
251
+ shown: ReadonlySet<number>,
198
252
  ): Frame[] => {
199
- const { state, width } = context
200
- const frameOf = (item: ChecklistItem, index: number): Frame => {
201
- const key = itemKey(phaseRowKey, index)
202
- const mark = active.get(index)
203
- const rows = state.expanded.has(key) ? openTaskRows(item, mark, width) : closedTaskRow(item, mark, width)
204
- return taskFrame(key, rows, context)
205
- }
253
+ const frameOf = (item: ChecklistItem, index: number): Frame =>
254
+ taskRowFrame(context, phaseRowKey, { item, index, active: active.get(index) })
206
255
  const framesWhere = (done: boolean): Frame[] =>
207
- phase.items.flatMap((item, index) => (item.done === done ? [frameOf(item, index)] : []))
208
- const tickedCount = phase.items.filter((item) => item.done).length
209
- const summary = tickedCount > 0 ? [tickedSummaryFrame(tickedCount, phaseRowKey, width, state.showDone)] : []
210
- const deferred = (phase.deferred ?? []).map((item) => textFrame([deferredRow(item, width)]))
211
- return [...framesWhere(false), ...deferred, ...summary, ...(state.showDone ? framesWhere(true) : [])]
256
+ phase.items.flatMap((item, index) => (item.done === done && !shown.has(index) ? [frameOf(item, index)] : []))
257
+ const deferred = (phase.deferred ?? []).map((item) => textFrame([deferredRow(item, context.width)]))
258
+ return [...framesWhere(false), ...deferred, ...framesWhere(true)]
212
259
  }