jackherd 1.10.0 → 1.11.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.
- package/CHANGELOG.md +19 -0
- package/README.md +17 -2
- package/dist/{chunk-5MQBCUQ5.js → chunk-RMMXD4G5.js} +9 -2
- package/dist/config.d.ts +60 -0
- package/dist/config.js +39 -4
- package/dist/plan-format.d.ts +7 -1
- package/dist/plan-format.js +4 -2
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/open-question/run.ts +8 -3
- package/kit/rules/rules/kit/pull-requests.md +2 -0
- package/kit/workflow/skills/follow/SKILL.md +3 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +10 -2
- package/package.json +1 -1
- package/recipes/sources.md +4 -0
- package/schemas/jackherd.schema.json +80 -0
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +24 -1
- package/src/config/tones.ts +27 -0
- package/src/lane-prompt/additions.ts +14 -0
- package/src/lane-prompt/command.ts +8 -2
- package/src/lane-prompt/input.ts +44 -3
- package/src/lane-prompt/options.ts +2 -0
- package/src/mermaid/command.ts +36 -0
- package/src/mermaid/problems.ts +48 -0
- package/src/plan-format/dependencies.ts +2 -2
- package/src/plan-format/waiting.ts +15 -2
- package/src/plans/appointment-rows.ts +34 -10
- package/src/plans/ask-coordinator.ts +46 -12
- package/src/plans/ask-target.ts +13 -2
- package/src/plans/board-close.ts +85 -0
- package/src/plans/frame.ts +5 -12
- package/src/plans/git.ts +35 -0
- package/src/plans/groomed.ts +27 -0
- package/src/plans/layout-phase.ts +7 -4
- package/src/plans/layout.ts +258 -48
- package/src/plans/link-chips.ts +22 -4
- package/src/plans/live.ts +73 -5
- package/src/plans/next-rows.ts +44 -0
- package/src/plans/planned.ts +22 -0
- package/src/plans/row-actions.ts +52 -0
- package/src/plans/row-focus.ts +148 -0
- package/src/plans/row-targets.ts +7 -0
- package/src/plans/selection.ts +14 -1
- package/src/plans/settings.ts +10 -0
- package/src/plans/snapshot.ts +79 -21
- package/src/plans/source-asked.ts +19 -0
- package/src/plans/source-prefix.ts +17 -0
- package/src/plans/source-rows.ts +188 -0
- package/src/plans/texts.ts +28 -3
- package/src/plans/tones.ts +30 -0
- package/src/plans/view.ts +8 -2
- package/src/plans/waiting-click.ts +129 -24
- package/src/terminal/text.ts +8 -0
- package/src/terminal/types.ts +4 -0
- package/src/vocabulary/index.ts +10 -0
- package/src/watch/plan-badges-read.ts +9 -2
- package/src/watch/plan-badges.ts +10 -2
- package/src/watch/report-questions.ts +4 -3
- package/src/watch/source-entries.ts +55 -0
- package/src/watch/source-fields.ts +1 -0
- package/src/watch/source-statuses.ts +60 -0
- package/src/watch/source-tickets.ts +21 -1
- package/src/watch/sources.ts +41 -1
package/src/plans/snapshot.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
525
|
+
withRestNote(
|
|
526
|
+
withSentFiles(
|
|
527
|
+
withModels(
|
|
528
|
+
withBadges(withTickets(result, reading?.tickets ?? []), reading?.badges ?? {}),
|
|
529
|
+
phaseModels,
|
|
530
|
+
sprintDirectory,
|
|
531
|
+
),
|
|
532
|
+
sentChips,
|
|
507
533
|
),
|
|
508
|
-
|
|
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
|
-
:
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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 =
|
|
558
|
-
|
|
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
|
|
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)
|
package/src/plans/texts.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|