jackherd 1.10.0 → 1.11.1

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 (66) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +24 -16
  3. package/dist/{chunk-5MQBCUQ5.js → chunk-RMMXD4G5.js} +9 -2
  4. package/dist/config.d.ts +60 -0
  5. package/dist/config.js +39 -4
  6. package/dist/plan-format.d.ts +7 -1
  7. package/dist/plan-format.js +4 -2
  8. package/docs/follow-dark.svg +48 -0
  9. package/docs/follow.svg +48 -0
  10. package/herdr-plugin/herdr-plugin.toml +1 -1
  11. package/install.sh +1 -1
  12. package/kit/hooks/hooks/open-question/run.ts +8 -3
  13. package/kit/rules/rules/kit/pull-requests.md +2 -0
  14. package/kit/workflow/skills/follow/SKILL.md +3 -3
  15. package/kit/workflow/skills/orchestrate/SKILL.md +11 -3
  16. package/package.json +2 -2
  17. package/recipes/sources.md +4 -0
  18. package/schemas/jackherd.schema.json +80 -0
  19. package/src/cli.ts +1 -0
  20. package/src/config/schema.ts +24 -1
  21. package/src/config/tones.ts +27 -0
  22. package/src/lane-prompt/additions.ts +14 -0
  23. package/src/lane-prompt/command.ts +8 -2
  24. package/src/lane-prompt/input.ts +44 -3
  25. package/src/lane-prompt/options.ts +2 -0
  26. package/src/mermaid/command.ts +36 -0
  27. package/src/mermaid/problems.ts +48 -0
  28. package/src/plan-format/dependencies.ts +2 -2
  29. package/src/plan-format/waiting.ts +15 -2
  30. package/src/plans/appointment-rows.ts +34 -10
  31. package/src/plans/ask-coordinator.ts +46 -12
  32. package/src/plans/ask-target.ts +13 -2
  33. package/src/plans/board-close.ts +85 -0
  34. package/src/plans/frame.ts +5 -12
  35. package/src/plans/git.ts +35 -0
  36. package/src/plans/groomed.ts +27 -0
  37. package/src/plans/layout-phase.ts +7 -4
  38. package/src/plans/layout.ts +258 -48
  39. package/src/plans/link-chips.ts +22 -4
  40. package/src/plans/live.ts +73 -5
  41. package/src/plans/next-rows.ts +44 -0
  42. package/src/plans/planned.ts +22 -0
  43. package/src/plans/row-actions.ts +52 -0
  44. package/src/plans/row-focus.ts +148 -0
  45. package/src/plans/row-targets.ts +7 -0
  46. package/src/plans/selection.ts +14 -1
  47. package/src/plans/settings.ts +10 -0
  48. package/src/plans/snapshot.ts +79 -21
  49. package/src/plans/source-asked.ts +19 -0
  50. package/src/plans/source-prefix.ts +17 -0
  51. package/src/plans/source-rows.ts +188 -0
  52. package/src/plans/texts.ts +28 -3
  53. package/src/plans/tones.ts +30 -0
  54. package/src/plans/view.ts +8 -2
  55. package/src/plans/waiting-click.ts +129 -24
  56. package/src/terminal/text.ts +8 -0
  57. package/src/terminal/types.ts +4 -0
  58. package/src/vocabulary/index.ts +10 -0
  59. package/src/watch/plan-badges-read.ts +9 -2
  60. package/src/watch/plan-badges.ts +10 -2
  61. package/src/watch/report-questions.ts +4 -3
  62. package/src/watch/source-entries.ts +55 -0
  63. package/src/watch/source-fields.ts +1 -0
  64. package/src/watch/source-statuses.ts +60 -0
  65. package/src/watch/source-tickets.ts +21 -1
  66. package/src/watch/sources.ts +41 -1
@@ -17,8 +17,12 @@ import type { WaitingItem } from '../plan-format/waiting'
17
17
  import { dropUnused } from '../shared/stat-cache'
18
18
  import type { TaskSignal } from '../task-signals/types'
19
19
  import type { LaneAgent, LaneStatusSource } from '../terminal'
20
+ import { isRestingAgent } from '../terminal/types'
21
+ import { laneReportPath, readLaneReport } from '../watch/lane-report-file'
22
+ import { restNote } from '../watch/lane-rest'
20
23
  import type { SourceTicket, WatchBadges, WatchHealth } from '../watch/plan-badges'
21
24
  import { readWatchReading } from '../watch/plan-badges-read'
25
+ import type { SourceStatus } from '../watch/source-statuses'
22
26
  import { assignSignals, laneOfDirectory, type UnplannedTask } from './active-tasks'
23
27
  import { type AppointmentBlock, appointmentBlock } from './appointment-rows'
24
28
  import { boardRowsOf } from './board-rows'
@@ -33,8 +37,9 @@ import {
33
37
  } from './current-phase'
34
38
  import { activityTextOf, taskSignalsOf } from './current-task'
35
39
  import { errorText } from './error-text'
36
- import { type BaseReader, baseReaderOf, gitStatusOf, refExists } from './git'
40
+ import { type BaseReader, baseReaderOf, gitStatusOf, lastChangeReaderOf, refExists } from './git'
37
41
  import type { PullRequestState } from './github'
42
+ import { groomedDaysAgo } from './groomed'
38
43
  import {
39
44
  type Fallbacks,
40
45
  laneWorkOf,
@@ -48,17 +53,20 @@ import { laneSummary } from './lane-summary'
48
53
  import { boardRowTexts, laneTicketsOf, withoutTicketLinks } from './lane-tickets'
49
54
  import type { LaneView } from './layout'
50
55
  import { extractLinkChips } from './link-chips'
56
+ import { type NextBlock, nextBlock } from './next-rows'
51
57
  import type { Options } from './options'
52
58
  import { laneModelsOf, type PhaseModelRecord } from './phase-models'
53
59
  import { readPhaseModels } from './phase-models-read'
54
60
  import { laneHeads, planPullRequests, readerOncePerRef } from './plan-heads'
55
- import { type PlannedPlan, plannedPlans } from './planned'
61
+ import { markStale, type PlannedPlan, plannedPlans } from './planned'
56
62
  import type { PullRequest } from './pull-requests'
57
63
  import type { ReadCache } from './read-cache'
58
64
  import { type SentChip, sentChipsByLane } from './sent-files'
59
65
  import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from './settings'
60
66
  import { signalledTitle } from './signalled-phase'
61
67
  import { readAllSignals } from './signals-read'
68
+ import { withSourcePrefix } from './source-prefix'
69
+ import type { HerdSource, SourceBlock } from './source-rows'
62
70
  import type { SubagentActivity } from './subagents'
63
71
  import { readSubagentActivity, subagentSourceOf } from './subagents-read'
64
72
  import { boardTexts } from './texts'
@@ -115,8 +123,16 @@ export type Snapshot = {
115
123
  watchesTickets?: boolean
116
124
  /** What waits on the user's answer: the open rows of the board's section for it, from the checkout's board file. */
117
125
  waiting?: WaitingItem[]
118
- /** The board's open appointments, read from the same board text as `waiting`, for the block under it. */
126
+ /** Whole days since the board's grooming (`groomedDaysAgo`); null when its appointment is missing. */
127
+ groomedDays?: number | null
128
+ /** The board's next section, read from the same board text as `waiting`, for the block under it. */
129
+ next?: NextBlock
130
+ /** The board's open appointments, read from the same board text as `waiting`, for the block under the next one. */
119
131
  appointments?: AppointmentBlock
132
+ /** The sources of `watch.sources` not marked `ticket`, from the watcher's state, for the block under the appointments. */
133
+ sourceBlock?: SourceBlock
134
+ /** The tone of each role, from the config, which `renderSnapshot` sets before it draws. */
135
+ tones?: PlansSettings['tones']
120
136
  /** What agents report doing that no lane takes: the coordinator's own, or a lane the board does not list. */
121
137
  unplaced?: UnplannedTask[]
122
138
  }
@@ -158,6 +174,7 @@ type Decorations = {
158
174
  phaseModels: readonly PhaseModelRecord[]
159
175
  sprintDirectory: string
160
176
  sentChips: ReadonlyMap<string | null, SentChip[]>
177
+ restNoteOf: (lane: string) => string | null
161
178
  }
162
179
 
163
180
  /** How full the agent's context is, null when the terminal gave no session or the session has no log. */
@@ -381,6 +398,12 @@ const withSentFiles = (view: LaneView, chips: ReadonlyMap<string | null, SentChi
381
398
  return found ? { ...view, sentFiles: found } : view
382
399
  }
383
400
 
401
+ /** The view with what its resting lane waits on, read from the lane's report as the watcher reads it; as it was while the agent works. */
402
+ const withRestNote = (view: LaneView, restNoteOf: Decorations['restNoteOf']): LaneView => {
403
+ const note = isRestingAgent(view.status.agent) ? restNoteOf(view.progress.lane.name) : null
404
+ return note === null ? view : { ...view, restNote: note }
405
+ }
406
+
384
407
  /** Up to two names, then how many more: `a, b and 3 more`. */
385
408
  const namedWithMore = (names: readonly string[]): string => {
386
409
  const more = names.length - SHOWN_NAMES
@@ -496,34 +519,45 @@ const laneResultsOf = async (
496
519
  /** Each lane's view with its tickets, the watcher's badges, its phases' models and the files sent from it. */
497
520
  const decoratedViews = (
498
521
  results: readonly LaneResult[],
499
- { reading, phaseModels, sprintDirectory, sentChips }: Decorations,
522
+ { reading, phaseModels, sprintDirectory, sentChips, restNoteOf }: Decorations,
500
523
  ): LaneView[] =>
501
524
  results.map((result) =>
502
- withSentFiles(
503
- withModels(
504
- withBadges(withTickets(result, reading?.tickets ?? []), reading?.badges ?? {}),
505
- phaseModels,
506
- sprintDirectory,
525
+ withRestNote(
526
+ withSentFiles(
527
+ withModels(
528
+ withBadges(withTickets(result, reading?.tickets ?? []), reading?.badges ?? {}),
529
+ phaseModels,
530
+ sprintDirectory,
531
+ ),
532
+ sentChips,
507
533
  ),
508
- sentChips,
534
+ restNoteOf,
509
535
  ),
510
536
  )
511
537
 
512
538
  /** The open phases no lane with an agent leads; none while the view shows one lane. */
539
+ const withSourceTones = (statuses: readonly SourceStatus[], configured: PlansSettings['herdSources']): HerdSource[] =>
540
+ statuses.map((status) => ({ ...status, tone: configured.find(({ name }) => name === status.name)?.tone ?? null }))
541
+
513
542
  const plannedOf = (
514
543
  source: Source,
515
544
  plans: Record<string, string>,
516
545
  results: readonly LaneResult[],
517
546
  sprintDirectory: string,
547
+ now: number,
518
548
  ): PlannedPlan[] =>
519
549
  source.options.lane
520
550
  ? []
521
- : plannedPlans({
522
- plans,
523
- lanes: results.map(({ view }) => ({ lane: view.progress.lane, agent: view.status.agent })),
524
- sprintDirectory,
525
- language: source.settings.planLanguage,
526
- })
551
+ : markStale(
552
+ plannedPlans({
553
+ plans,
554
+ lanes: results.map(({ view }) => ({ lane: view.progress.lane, agent: view.status.agent })),
555
+ sprintDirectory,
556
+ language: source.settings.planLanguage,
557
+ }),
558
+ lastChangeReaderOf(source.root, baseRefOf(source.settings)),
559
+ now,
560
+ )
527
561
 
528
562
  /**
529
563
  * Reads the board, its plans and the lanes' state. `memories` holds, per lane, what the herd saw at the last read; it
@@ -553,10 +587,24 @@ export const loadSnapshot = async (
553
587
  if (parsed) dropUnused(parsed.entries, parsed.used)
554
588
  const listedPlans = await readListedPlans(source, base, sprintDirectory, markdown, fallbacks)
555
589
  const documentsPlans = Object.assign({}, listedPlans, ...results.map((result) => result.texts))
556
- const { ticketSources } = settings
557
- const watched = ticketSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
558
- const reading = watched ? readWatchReading(root, now, ticketSources) : null
590
+ const { ticketSources, herdSources } = settings
591
+ const watched =
592
+ ticketSources.length > 0 || herdSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
593
+ const reading = watched
594
+ ? readWatchReading(
595
+ root,
596
+ now,
597
+ ticketSources,
598
+ undefined,
599
+ undefined,
600
+ herdSources.map(({ name }) => name),
601
+ )
602
+ : null
559
603
  const waitingRecords = readWaitingRecords(root)
604
+ const waiting = withSourcePrefix(
605
+ waitingItemsOf(liveBoard, settings.planLanguage, waitingRecords, now),
606
+ settings.sourceLabels,
607
+ )
560
608
  const sentChips = sentChipsByLane(waitingRecords.sent, worktrees, now)
561
609
  const notice = fallbacks.length > 0 ? fallbackNotice(fallbacks, baseRef, refExists(root, baseRef)) : null
562
610
  const unknownLaneTitle =
@@ -568,6 +616,8 @@ export const loadSnapshot = async (
568
616
  phaseModels: readPhaseModels(root),
569
617
  sprintDirectory,
570
618
  sentChips,
619
+ restNoteOf: (lane) =>
620
+ restNote(readLaneReport(laneReportPath(worktreeOf(root, settings, lane), lane))?.text ?? null),
571
621
  }),
572
622
  documents: {
573
623
  sprint: markdown,
@@ -575,7 +625,7 @@ export const loadSnapshot = async (
575
625
  laneOrder: [...laneSet.lanes, ...laneSet.unlisted].map((lane) => lane.name),
576
626
  lanes: board.lanes,
577
627
  },
578
- planned: plannedOf(source, documentsPlans, results, sprintDirectory),
628
+ planned: plannedOf(source, documentsPlans, results, sprintDirectory, now),
579
629
  files: [sprintFile, ...results.flatMap((result) => result.files)],
580
630
  titles: new Map(
581
631
  results.flatMap(({ view, pane }) =>
@@ -584,8 +634,16 @@ export const loadSnapshot = async (
584
634
  ),
585
635
  errors: [...laneSet.errors, ...pullRequests.errors, ...(notice ? [notice] : [])],
586
636
  watch: reading?.health,
587
- waiting: waitingItemsOf(liveBoard, settings.planLanguage, waitingRecords, now),
637
+ waiting,
638
+ groomedDays: groomedDaysAgo(liveBoard, settings.planLanguage, new Date(now)),
639
+ next: nextBlock(
640
+ liveBoard,
641
+ settings.planLanguage,
642
+ waiting.map(({ item }) => item),
643
+ ),
588
644
  appointments: appointmentBlock(liveBoard, settings.planLanguage, new Date(now)),
645
+ ...(reading?.sources ? { sourceBlock: { sources: withSourceTones(reading.sources, herdSources), now } } : {}),
646
+ tones: settings.tones,
589
647
  unplaced: unplacedOf(laneSet.sources, laneSet.lanes, Boolean(options.lane)),
590
648
  ...(ticketSources.length > 0 ? { watchesTickets: true } : {}),
591
649
  }
@@ -0,0 +1,19 @@
1
+ import type { WaitingItem } from '../plan-format/waiting'
2
+
3
+ /** Whether the source `source` has an entry titled `title` that an open row of "Waiting for you" asks about. */
4
+ export type AskedCheck = (source: string, title: string) => boolean
5
+
6
+ const same = (left: string, right: string): boolean => left.trim().toLowerCase() === right.trim().toLowerCase()
7
+
8
+ /**
9
+ * The check against the open rows: a row asks about an entry when its origin cell names the entry's source and its text
10
+ * contains the entry's title, in any case. The rows come from the board, whose text names the entry in words; no id links them.
11
+ */
12
+ export const askedCheckOf =
13
+ (waiting: readonly WaitingItem[]): AskedCheck =>
14
+ (source, title) =>
15
+ title.trim() !== '' &&
16
+ waiting.some(
17
+ ({ origin, item }) =>
18
+ origin !== undefined && same(origin, source) && item.toLowerCase().includes(title.trim().toLowerCase()),
19
+ )
@@ -0,0 +1,17 @@
1
+ import type { WaitingItem } from '../plan-format/waiting'
2
+
3
+ /** By source name in lower case: what the herd puts before a row that came from the source, its `label` or else its name. */
4
+ export type SourceLabels = Readonly<Record<string, string>>
5
+
6
+ const lookup = (labels: SourceLabels, origin: string): string | undefined =>
7
+ Object.entries(labels).find(([name]) => name.toLowerCase() === origin.trim().toLowerCase())?.[1]
8
+
9
+ /**
10
+ * The rows with `prefix` set on those whose origin cell names a source: the board row of a source's report then
11
+ * reads `Error log · Checkout: Resource Not Found` without a look at the plan. Any other row stays as it is.
12
+ */
13
+ export const withSourcePrefix = (items: readonly WaitingItem[], labels: SourceLabels): WaitingItem[] =>
14
+ items.map((item) => {
15
+ const prefix = item.origin === undefined ? undefined : lookup(labels, item.origin)
16
+ return prefix === undefined ? item : { ...item, prefix }
17
+ })
@@ -0,0 +1,188 @@
1
+ import { TONE, type ToneName } from '../config/tones'
2
+ import { padVisible } from '../terminal/text'
3
+ import { textWidth } from '../terminal/width'
4
+ import type { EntryStatus, SourceStatus } from '../watch/source-statuses'
5
+ import { ASK_KIND } from './ask-target'
6
+ import type { AskedCheck } from './source-asked'
7
+ import { chevronOf } from './symbols'
8
+ import type { BlockItem } from './waiting-click'
9
+
10
+ /** A source's status with the tone its config names for the mark while its report is new; null where it names none. */
11
+ export type HerdSource = SourceStatus & { tone?: ToneName | null }
12
+
13
+ /** The sources block: the listed sources, and the clock (epoch milliseconds) their ages are counted against. */
14
+ export type SourceBlock = { sources: HerdSource[]; now: number }
15
+
16
+ /** What decides how the rows of the block look: the keys the user opened, and which entries a row of "Waiting for you" asks about. */
17
+ export type SourceView = { expanded: ReadonlySet<string>; asked: AskedCheck }
18
+
19
+ /** How many entries of an opened source show before `+<n> more`. */
20
+ export const SOURCE_ENTRY_ROWS = 5
21
+
22
+ const SOURCE_MARK = '●'
23
+ const ENTRY_MARK = '•'
24
+ const MORE_MARK = ' '
25
+ const SEPARATOR = ' · '
26
+ const ASKED_TEXT = '↑ asked'
27
+ const MINUTE_MS = 60_000
28
+ const HOUR_MS = 60 * MINUTE_MS
29
+ const DAY_MS = 24 * HOUR_MS
30
+
31
+ /** `just now`, `12 min ago`, `5 h ago` or `2 d ago`: the whole units since `since`, the largest that is at least one. */
32
+ const agoOf = (since: number, now: number): string => {
33
+ const elapsed = Math.max(now - since, 0)
34
+ if (elapsed >= DAY_MS) return `${Math.floor(elapsed / DAY_MS)} d ago`
35
+ if (elapsed >= HOUR_MS) return `${Math.floor(elapsed / HOUR_MS)} h ago`
36
+ return elapsed >= MINUTE_MS ? `${Math.floor(elapsed / MINUTE_MS)} min ago` : 'just now'
37
+ }
38
+
39
+ const ranText = (ranAt: number | null, now: number): string =>
40
+ ranAt === null ? 'not run yet' : `ran ${agoOf(ranAt, now)}`
41
+
42
+ const twoDigits = (value: number): string => String(value).padStart(2, '0')
43
+
44
+ /** `11:11` for a time on the day of `now`, else the day and month before it, in the local time of the terminal. */
45
+ const clockOf = (at: number, now: number): string => {
46
+ const date = new Date(at)
47
+ const time = `${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`
48
+ return date.toDateString() === new Date(now).toDateString()
49
+ ? time
50
+ : `${twoDigits(date.getDate())}.${twoDigits(date.getMonth() + 1)}. ${time}`
51
+ }
52
+
53
+ const sourceKey = ({ name }: SourceStatus): string => `source>${name}`
54
+
55
+ const entryKey = (name: string, { id }: EntryStatus): string => `source>${name}>${id}`
56
+
57
+ /** The key in the herd's expanded set that shows every entry of the source, not the first five. */
58
+ export const sourceMoreKey = (name: string): string => `more>source>${name}`
59
+
60
+ const isUnaskedNew = (name: string, entry: EntryStatus, asked: AskedCheck): boolean =>
61
+ entry.isNew && !asked(name, entry.title)
62
+
63
+ /**
64
+ * Red while the source fails; else the source's own tone or `newTone` while it has a new entry nobody asked about (for a
65
+ * source without entries: while its newest report is new); none otherwise.
66
+ */
67
+ const toneOf = (source: HerdSource, newTone: ToneName, asked: AskedCheck): ToneName | undefined => {
68
+ if (source.failingSince !== null) return TONE.red
69
+ const hasNew =
70
+ source.entries.length > 0
71
+ ? source.entries.some((entry) => isUnaskedNew(source.name, entry, asked))
72
+ : source.latest?.isNew === true
73
+ return hasNew ? (source.tone ?? newTone) : undefined
74
+ }
75
+
76
+ const textOf = (source: HerdSource, now: number, expanded: boolean): string => {
77
+ const chevron = source.entries.length > 0 ? `${chevronOf(expanded)} ` : ''
78
+ const parts = [
79
+ ...(source.failingSince === null ? [] : [`failing since ${agoOf(source.failingSince, now)}`]),
80
+ ranText(source.ranAt, now),
81
+ ...(source.latest && source.entries.length === 0 ? [source.latest.title] : []),
82
+ ]
83
+ return `${chevron}${parts.join(SEPARATOR)}`
84
+ }
85
+
86
+ const headerOf = (
87
+ source: HerdSource,
88
+ view: SourceView,
89
+ now: number,
90
+ newTone: ToneName,
91
+ nameWidth: number,
92
+ ): BlockItem => {
93
+ const key = sourceKey(source)
94
+ const hasEntries = source.entries.length > 0
95
+ return {
96
+ key,
97
+ mark: SOURCE_MARK,
98
+ lead: padVisible(source.name, nameWidth),
99
+ text: textOf(source, now, view.expanded.has(key)),
100
+ url: hasEntries ? null : (source.latest?.url ?? null),
101
+ ask: null,
102
+ tone: toneOf(source, newTone, view.asked),
103
+ ...(hasEntries ? { toggle: key } : {}),
104
+ }
105
+ }
106
+
107
+ const entryToneOf = (
108
+ isAsked: boolean,
109
+ entry: EntryStatus,
110
+ source: HerdSource,
111
+ newTone: ToneName,
112
+ ): ToneName | undefined => {
113
+ if (isAsked) return TONE.grey
114
+ return entry.isNew ? (source.tone ?? newTone) : undefined
115
+ }
116
+
117
+ const entryTextOf = (entry: EntryStatus, now: number, isAsked: boolean): string =>
118
+ [entry.count === null ? '' : `${entry.count}×`, clockOf(entry.at, now), isAsked ? ASKED_TEXT : '']
119
+ .filter((part) => part !== '')
120
+ .join(' ')
121
+
122
+ const entryItem = (
123
+ source: HerdSource,
124
+ entry: EntryStatus,
125
+ view: SourceView,
126
+ now: number,
127
+ newTone: ToneName,
128
+ titleWidth: number,
129
+ ): BlockItem => {
130
+ const isAsked = view.asked(source.name, entry.title)
131
+ const tone = entryToneOf(isAsked, entry, source, newTone)
132
+ return {
133
+ key: entryKey(source.name, entry),
134
+ mark: ENTRY_MARK,
135
+ lead: padVisible(entry.title, titleWidth),
136
+ text: entryTextOf(entry, now, isAsked),
137
+ url: entry.url,
138
+ ask: { kind: ASK_KIND.sourceEntry, source: source.name, title: entry.title },
139
+ ...(tone && { tone }),
140
+ ...(isAsked && { dim: true }),
141
+ }
142
+ }
143
+
144
+ const moreItem = (name: string, hidden: number, showsAll: boolean): BlockItem => ({
145
+ key: sourceMoreKey(name),
146
+ mark: MORE_MARK,
147
+ text: showsAll ? '▴ fewer' : `+${hidden} more`,
148
+ url: null,
149
+ ask: null,
150
+ toggle: sourceMoreKey(name),
151
+ })
152
+
153
+ const entryItems = (source: HerdSource, view: SourceView, now: number, newTone: ToneName): BlockItem[] => {
154
+ const showsAll = view.expanded.has(sourceMoreKey(source.name))
155
+ const shown = showsAll ? source.entries : source.entries.slice(0, SOURCE_ENTRY_ROWS)
156
+ const titleWidth = Math.max(0, ...shown.map(({ title }) => textWidth(title)))
157
+ const hidden = source.entries.length - SOURCE_ENTRY_ROWS
158
+ return [
159
+ ...shown.map((entry) => entryItem(source, entry, view, now, newTone, titleWidth)),
160
+ ...(hidden > 0 ? [moreItem(source.name, hidden, showsAll)] : []),
161
+ ]
162
+ }
163
+
164
+ const DEFAULT_VIEW: SourceView = { expanded: new Set(), asked: () => false }
165
+
166
+ /**
167
+ * The rows of the sources block: per source one row with its name, when it ran, since when it fails and, for a source
168
+ * without entries, its newest report. A source with entries is folded to that row, and a click on it opens its entries
169
+ * (`view.expanded` holds its key): each with title, count and time, at most five and then `+<n> more`, a click on an
170
+ * entry opens its address, `[ask]` asks the coordinator to look at exactly that entry. An entry that a row of
171
+ * "Waiting for you" already asks about is dim and says `↑ asked`, and it does not make the source amber.
172
+ * `newTone` is the tone of the role `new`, which a source without its own tone wears while it has something new.
173
+ */
174
+ export const sourceItems = (
175
+ sources: readonly HerdSource[],
176
+ now: number,
177
+ newTone: ToneName,
178
+ view: SourceView = DEFAULT_VIEW,
179
+ ): BlockItem[] => {
180
+ const nameWidth = Math.max(0, ...sources.map(({ name }) => textWidth(name)))
181
+ return sources.flatMap((source) => [
182
+ headerOf(source, view, now, newTone, nameWidth),
183
+ ...(view.expanded.has(sourceKey(source)) ? entryItems(source, view, now, newTone) : []),
184
+ ])
185
+ }
186
+
187
+ /** The keys of all rows: what the fold of the block remembers. */
188
+ export const sourceKeys = (sources: readonly SourceStatus[]): string[] => sources.map(sourceKey)
@@ -1,4 +1,5 @@
1
1
  import { STALE_ACTIVITY_MINUTES } from './lane-summary'
2
+ import { STALE_PLAN_DAYS } from './planned'
2
3
  import { CHECK_NAME_LENGTH, CHECKS, type ChecksState, type PullRequest } from './pull-requests'
3
4
  import { shownWithMore } from './shown-with-more'
4
5
  import { MODEL_MARK, NO_AGENT_WORD, PHASE_COUNT } from './symbols'
@@ -26,6 +27,13 @@ export type PlanTexts = {
26
27
  /** The heads of the blocks on top of the herd. */
27
28
  waitingHeading: string
28
29
  appointmentHeading: string
30
+ sourceHeading: string
31
+ nextHeading: string
32
+ /** On a resting lane's line: the label before its starts-after cell, and the line for a lane without any reason. */
33
+ restAfter: string
34
+ restNoReason: string
35
+ /** After the head of the next block when the board's section holds no line. */
36
+ nextEmpty: string
29
37
  notInPlan: string
30
38
  /** Before a task an agent reports that no task of the plan names. */
31
39
  now: string
@@ -34,9 +42,13 @@ export type PlanTexts = {
34
42
  /** In place of the count of a phase whose only open box is the documentation box of its review gate. */
35
43
  allButDocs: string
36
44
  tickedSummary: (count: number) => string
45
+ /** The line above the listed ticked tasks of a phase; a click or `a` folds them again. */
46
+ tickedFold: (count: number) => string
37
47
  draft: string
38
48
  privateLink: string
39
49
  planned: (phases: number, plans: number) => string
50
+ /** The head of the planned section in the herd: `planned · 3 plans, next:`. */
51
+ plannedHead: (plans: number) => string
40
52
  phaseTally: (done: number, total: number) => string
41
53
  herd: HerdTexts
42
54
  checks: Record<ChecksState, string>
@@ -48,6 +60,9 @@ export type PlanTexts = {
48
60
  notAsked: (reason: string) => string
49
61
  /** The footer line when the coordinator `name` was asked to put `subject`, `question <id>` or `board row <key>`. */
50
62
  asked: (name: string, subject: string) => string
63
+ /** The footer line after `[done]` closed `subject`, and the one with the reason when it could not. */
64
+ closed: (subject: string) => string
65
+ notClosed: (reason: string) => string
51
66
  }
52
67
 
53
68
  /** The words of the watcher's badges and of the header notice when they are missing. */
@@ -91,11 +106,17 @@ export const TEXTS: PlanTexts = {
91
106
  noRow: 'no row in lanes',
92
107
  waitingHeading: 'Waiting for you',
93
108
  appointmentHeading: 'Appointments',
109
+ sourceHeading: 'Sources',
110
+ nextHeading: 'Next',
111
+ restAfter: 'after:',
112
+ restNoReason: 'idle · no reason',
113
+ nextEmpty: 'nothing queued, groom the board',
94
114
  notInPlan: 'not in the plan',
95
115
  now: 'now:',
96
116
  subagent: 'Subagent:',
97
117
  allButDocs: ALL_BUT_DOCS,
98
118
  tickedSummary: (count) => `${count} ticked · click or a lists them`,
119
+ tickedFold: (count) => `${count} ticked · click or a folds them`,
99
120
  draft: 'draft · ',
100
121
  privateLink: 'private',
101
122
  phaseTally: (done, total) => (total > 0 ? `${done}/${total} ph` : ''),
@@ -108,25 +129,29 @@ export const TEXTS: PlanTexts = {
108
129
  },
109
130
  planned: (phases, plans) =>
110
131
  `planned (${phases} ${phases === 1 ? 'phase' : 'phases'} in ${plans} ${plans === 1 ? 'plan' : 'plans'})`,
132
+ plannedHead: (plans) => `planned · ${plans} ${plans === 1 ? 'plan' : 'plans'}, next:`,
111
133
  checks: {
112
134
  [CHECKS.passed]: '✓ checks',
113
135
  [CHECKS.failed]: '✗ checks',
114
136
  [CHECKS.pending]: '… checks',
115
137
  [CHECKS.none]: 'no checks',
116
138
  },
117
- hint: '↑↓ select →← open a ticked tasks s html q quit ? help',
139
+ hint: '↑↓ select →← open a/click ticked tasks s html q quit ? help',
118
140
  showDoneNeedsPhase: 'a lists the ticked tasks inside an open phase: open one with → or a click',
119
141
  legend: [
120
142
  'enter/space or click toggles a lane or a phase, click or enter on a task opens its whole text, and opens a ↗ pull request · wheel PgUp PgDn scroll · j k h l like the arrows · click or ctrl+click on a link chip opens its link · ✦ artifact · ⑂ pull request or issue · ◈ Figma · private = the plan says so',
121
143
  '● working · ◆ blocked, waits for you · ◉ done = the agent finished its answer, not the lane · ○ idle · (·) no agent = a worktree nobody works in · • state unknown · only blocked wears its word in the lane line, the symbol says the rest · waiting = not started: no worktree yet, something left to do, and what it starts after; finished lanes (cleaned up, or every named phase ticked) are not shown · a working or blocked lane opens by itself only when it first appears, after that → ← and clicks are yours alone',
122
- `▸ ▾ open and close · an open lane's branches, top down: the agent's title and ⎇ its branch, ↗ its pull requests, its link chips, the phases it works on, next, done · a lane shows only the phases its board rows name · a phase row = some of its tasks are ticked, one of its boxes was ticked in the last ${STALE_ACTIVITY_MINUTES} min, or an agent reports working on it, by number · n min ago since the last tick or report, · gate = only the review gate is open · ▶ teal = a task an agent reported (its task list or jh task; its name dim at the right edge when several agents work in the lane), ▷ dim = only guessed from the words of its log · now: <text> = a reported task no plan task names · a running review sits on the gate box that names it, in the phase it names or else the lane's current one · Subagent: <text> = a running subagent that names no phase and sits on none · next = the other open phases, as ranges · done = the finished ones · ${ALL_BUT_DOCS} = only the documentation box is open · a folded lane line names the phase moved last, +n more active ones, and its count · ${PHASE_COUNT.empty} no checkboxes · ${PHASE_COUNT.missing} not in the plan · a lists the ticked checkboxes of an open phase one by one · s opens every lane and plan as one HTML page`,
144
+ `▸ ▾ open and close · an open lane's branches, top down: the agent's title and ⎇ its branch, ↗ its pull requests, its link chips, the phases it works on, next, done · a lane shows only the phases its board rows name · a phase row = some of its tasks are ticked, one of its boxes was ticked in the last ${STALE_ACTIVITY_MINUTES} min, or an agent reports working on it, by number · n min ago since the last tick or report, · gate = only the review gate is open · ▶ teal = a task an agent reported (its task list or jh task; its name dim at the right edge when several agents work in the lane), ▷ dim = only guessed from the words of its log · now: <text> = a reported task no plan task names · a running review sits on the gate box that names it, in the phase it names or else the lane's current one · Subagent: <text> = a running subagent that names no phase and sits on none · next = the other open phases, as ranges · done = the finished ones · ${ALL_BUT_DOCS} = only the documentation box is open · a folded lane line names the phase moved last, +n more active ones, and its count · ${PHASE_COUNT.empty} no checkboxes · ${PHASE_COUNT.missing} not in the plan · a or a click on its ▸ n ticked line lists the ticked checkboxes of an open phase one by one, a or a click on its ▾ line folds them · s opens every lane and plan as one HTML page`,
123
145
  `the bar = progress over the lane's plans · n/m = m checkboxes, n ticked · n/m ph = n of m phases finished · n/n in green = all ticked · 🥣 share of the context window used, on a folded lane only once it warns · Sonnet ${MODEL_MARK.differs} Opus in amber = the phase's subagent ran on another model than the plan's`,
124
146
  '↗ pull request · 💬 new feedback · ✋ changes requested · 🌐 preview · 📡 ticket: chip 📡 T-1 · status among the link chips, 📡 <n> in amber on a folded lane when tickets are new',
125
- '▌ on top = waits for your answer: 📎 a file or an Artifact that was sent (click: a cut-off row expands, then it opens), ? a question of the board or one a session left in its dialog (a click on its text only expands and folds it; [ask] at the right edge of a question asks the coordinator to put it) · it goes when the board row is closed or `jh waiting close <id>` · `Waiting for you` heads the block (click: fold; click on +n: show all, on ▴ n: five) · ◷ the appointments of the board in a grey bar below it (click: fold)',
147
+ `▌ on top = waits for your answer: 📎 a file or an Artifact that was sent (click: a cut-off row expands, then it opens), ? a question of the board or one a session left in its dialog (a click on its text only expands and folds it; [ask] at the right edge of a question asks the coordinator to put it; [done] closes the row: a board row gets its status, a sent file its record) · it goes when the board row is closed or \`jh waiting close <id>\` · \`Waiting for you\` heads the block (click: fold; click on +n: show all, on ▴ n: five) · ◷ the appointments of the board in a grey bar below it (click: fold; [done] closes the row, [run] asks the coordinator to run it now, [move] to ask you for a new time) · 1 2 3 = Next, the board lines to do next · ● Sources = the watcher’s sources that are no tickets: when each ran and its newest report, the mark amber while that report is under a day old and red while the source fails (click on a row: opens the entry) · planned = the open phases no lane works on, the next one per plan; stale? = its plan file was not changed on the base branch for ${STALE_PLAN_DAYS} days`,
126
148
  '⎇ branch · ↑ commits ahead · ± uncommitted · no worktree: plan read from main',
149
+ '❯ and a light background = the row under the mouse or the arrows, on every clickable row · enter on a block row asks, opens or expands it and never presses [done] · [xx] in square brackets = a click does something, bold under the mouse',
127
150
  ],
128
151
  notAsked: (reason) => `no coordinator to ask: ${reason}`,
129
152
  asked: (name, subject) => `asked ${name} for ${subject}`,
153
+ closed: (subject) => `closed ${subject}`,
154
+ notClosed: (reason) => `not closed: ${reason}`,
130
155
  }
131
156
 
132
157
  /** The mark of a running lane the board has no row for; null for a lane it lists. */
@@ -0,0 +1,30 @@
1
+ import { DEFAULT_TONES, TONE, type ToneName, type ToneRole } from '../config/tones'
2
+ import { paint } from '../terminal/text'
3
+
4
+ const TONE_PAINT: Record<ToneName, (text: string) => string> = {
5
+ [TONE.amber]: paint.yellow,
6
+ [TONE.teal]: paint.teal,
7
+ [TONE.grey]: paint.dim,
8
+ [TONE.red]: paint.red,
9
+ [TONE.green]: paint.green,
10
+ [TONE.blue]: paint.blue,
11
+ [TONE.magenta]: paint.magenta,
12
+ }
13
+
14
+ let palette: Record<ToneRole, ToneName> = DEFAULT_TONES
15
+
16
+ /**
17
+ * Sets the tone of each role for the screens drawn from now on. The herd calls it once per frame with the config's
18
+ * tones, so the layout reads one palette and no function of it takes the colours as a parameter.
19
+ */
20
+ export const setTones = (tones: Record<ToneRole, ToneName>): void => {
21
+ palette = tones
22
+ }
23
+
24
+ export const paintOfTone = (name: ToneName): ((text: string) => string) => TONE_PAINT[name]
25
+
26
+ /** The paint of a role in the palette set last. */
27
+ export const paintOfRole = (role: ToneRole): ((text: string) => string) => TONE_PAINT[palette[role]]
28
+
29
+ /** The tone name of a role in the palette set last. */
30
+ export const toneOfRole = (role: ToneRole): ToneName => palette[role]
package/src/plans/view.ts CHANGED
@@ -1,17 +1,19 @@
1
1
  import { ARTIFACT_LINK_KIND } from '../config/link-kinds'
2
+ import { DEFAULT_TONES } from '../config/tones'
2
3
  import { fit, plainText } from '../terminal/text'
3
4
  import { renderSelected, type Screen } from './layout'
4
5
  import type { Options } from './options'
5
6
  import { laneSelection, overviewSelection, type Selection } from './selection'
6
7
  import type { Snapshot } from './snapshot'
8
+ import { setTones } from './tones'
7
9
 
8
10
  const DEFAULT_WIDTH = 100
9
11
  const CURSOR_HOME = '\x1b[H'
10
12
  const CLEAR_LINE_END = '\x1b[K'
11
13
  const CLEAR_BELOW = '\x1b[J'
12
14
 
13
- export const TERMINAL_ON = '\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1006h'
14
- export const TERMINAL_OFF = '\x1b[?1006l\x1b[?1000l\x1b[?25h\x1b[?1049l'
15
+ export const TERMINAL_ON = '\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1003h\x1b[?1006h'
16
+ export const TERMINAL_OFF = '\x1b[?1006l\x1b[?1003l\x1b[?1000l\x1b[?25h\x1b[?1049l'
15
17
 
16
18
  /** One column short of the pane: a line filling the last column makes some terminals wrap it. */
17
19
  export const paneWidth = (): number => (process.stdout.columns ?? (Number(process.env.COLUMNS) || DEFAULT_WIDTH)) - 1
@@ -32,6 +34,7 @@ export const renderSnapshot = (
32
34
  width: number,
33
35
  locale: string,
34
36
  ): Screen => {
37
+ setTones(snapshot.tones ?? DEFAULT_TONES)
35
38
  const screen = renderSelected(
36
39
  {
37
40
  title: snapshot.title,
@@ -41,7 +44,10 @@ export const renderSnapshot = (
41
44
  watch: snapshot.watch,
42
45
  watchesTickets: snapshot.watchesTickets,
43
46
  awaiting: snapshot.waiting,
47
+ groomedDays: snapshot.groomedDays,
48
+ next: snapshot.next,
44
49
  appointments: snapshot.appointments,
50
+ sourceBlock: snapshot.sourceBlock,
45
51
  unplaced: snapshot.unplaced,
46
52
  },
47
53
  selection,