jackherd 1.11.1 → 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.
- package/CHANGELOG.md +22 -0
- package/README.md +76 -58
- package/dist/{chunk-RMMXD4G5.js → chunk-V4HOLGFP.js} +2 -2
- package/dist/config.d.ts +10 -0
- package/dist/config.js +15 -5
- package/dist/plan-format.d.ts +11 -6
- package/dist/plan-format.js +8 -2
- package/docs/follow-dark.svg +2 -2
- package/docs/follow.svg +2 -2
- package/docs/workflow-dark.svg +56 -64
- package/docs/workflow.svg +56 -64
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/workflow/skills/implement/SKILL.md +1 -1
- package/kit/workflow/skills/orchestrate/SKILL.md +1 -1
- package/kit/workflow/skills/orchestrate/references/draft-review.md +4 -0
- package/kit/workflow/skills/orchestrate/references/events.md +8 -1
- package/kit/workflow/skills/plan/SKILL.md +2 -0
- package/package.json +2 -2
- package/schemas/jackherd.schema.json +18 -1
- package/src/config/link-kinds.ts +1 -1
- package/src/config/schema.ts +5 -2
- package/src/config/tones.ts +9 -2
- package/src/context/fill.ts +4 -10
- package/src/context/session-log.ts +108 -2
- package/src/plan-format/lanes.ts +10 -1
- package/src/plan-format/phases.ts +11 -3
- package/src/plan-format/progress.ts +4 -2
- package/src/plan-format/waiting.ts +12 -0
- package/src/plans/active-tasks.ts +31 -8
- package/src/plans/appointment-rows.ts +42 -53
- package/src/plans/board-rows.ts +14 -0
- package/src/plans/calendar-days.ts +29 -0
- package/src/plans/click-hint.ts +94 -0
- package/src/plans/command.ts +8 -9
- package/src/plans/current-phase.ts +22 -10
- package/src/plans/current-task.ts +18 -3
- package/src/plans/frame.ts +18 -26
- package/src/plans/git.ts +90 -31
- package/src/plans/groomed.ts +13 -7
- package/src/plans/lane-reason.ts +61 -0
- package/src/plans/lane-summary.ts +75 -24
- package/src/plans/layout-phase.ts +123 -76
- package/src/plans/layout-summary.ts +133 -83
- package/src/plans/layout.ts +451 -345
- package/src/plans/legend.ts +29 -0
- package/src/plans/link-chips.ts +26 -8
- package/src/plans/live.ts +238 -84
- package/src/plans/next-rows.ts +5 -37
- package/src/plans/phase-source.ts +8 -3
- package/src/plans/plan-chips.ts +6 -3
- package/src/plans/plan-heads.ts +1 -1
- package/src/plans/planned.ts +4 -1
- package/src/plans/process-slots.ts +36 -0
- package/src/plans/pull-requests.ts +9 -1
- package/src/plans/read-cache.ts +18 -3
- package/src/plans/refresh-pace.ts +13 -0
- package/src/plans/row-focus.ts +86 -15
- package/src/plans/selection.ts +124 -89
- package/src/plans/sent-files.ts +1 -1
- package/src/plans/settings.ts +5 -2
- package/src/plans/shimmer.ts +124 -0
- package/src/plans/signals-read.ts +38 -10
- package/src/plans/slice-gate.ts +51 -0
- package/src/plans/snapshot.ts +185 -84
- package/src/plans/source-asked.ts +1 -1
- package/src/plans/source-rows.ts +60 -18
- package/src/plans/sprint-html.ts +1 -1
- package/src/plans/status-pace.ts +45 -0
- package/src/plans/subagent-tasks.ts +1 -1
- package/src/plans/subagents-read.ts +61 -24
- package/src/plans/subagents.ts +2 -2
- package/src/plans/symbols.ts +4 -1
- package/src/plans/task-brief.ts +143 -0
- package/src/plans/texts.ts +181 -39
- package/src/plans/view.ts +198 -32
- package/src/plans/waiting-click.ts +54 -110
- package/src/plans/watch-burst.ts +25 -0
- package/src/plans/worktree-stamp.ts +35 -0
- package/src/plans/your-turn-rows.ts +102 -0
- package/src/plans/your-turn.ts +226 -0
- package/src/terminal/herdr.ts +25 -5
- package/src/terminal/restore.ts +44 -0
- package/src/terminal/text.ts +2 -2
- package/src/terminal/width.ts +9 -0
- package/src/vocabulary/index.ts +6 -5
- package/src/watch/command.ts +28 -2
- package/src/watch/lane-report.ts +2 -0
- package/src/watch/lane-rest.ts +7 -1
- package/src/watch/reference-paths.ts +1 -0
- package/src/watch/unlinked-artifacts-read.ts +43 -0
- package/src/watch/unlinked-artifacts.ts +76 -0
- package/src/watch/watch-events.ts +12 -0
- package/src/watch/watcher-lock.ts +47 -4
package/src/plans/groomed.ts
CHANGED
|
@@ -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
|
-
*
|
|
10
|
-
* coordinator moves that appointment a week on after each grooming, so the last one was a
|
|
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
|
|
20
|
-
return Math.max(
|
|
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:
|
|
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
|
|
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
|
-
/**
|
|
291
|
-
|
|
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,
|
|
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(
|
|
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:
|
|
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
|
-
|
|
428
|
+
context: WaitingContext,
|
|
386
429
|
): WaitingPhases[] => {
|
|
387
|
-
const waiting = waitingIn(phases,
|
|
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
|
-
|
|
446
|
+
context: WaitingContext,
|
|
404
447
|
): PlacedPhase[] => [
|
|
405
|
-
...waitingIn(phases,
|
|
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 = (
|
|
419
|
-
|
|
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,
|
|
422
|
-
waitingPhases: waitingPlaced(input, href, phases,
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
32
|
-
export
|
|
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
|
|
107
|
-
* only guesses it from words.
|
|
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
|
-
|
|
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
|
-
/**
|
|
125
|
-
const
|
|
126
|
-
|
|
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
|
-
/**
|
|
129
|
-
|
|
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
|
-
/**
|
|
132
|
-
export const
|
|
133
|
-
labelledLine(TEXTS.
|
|
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
|
-
|
|
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
|
-
/**
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
/**
|
|
152
|
-
const
|
|
153
|
-
|
|
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
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
|
|
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
|
-
/**
|
|
166
|
-
const
|
|
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
|
|
191
|
-
* deferred to another phase, then the ticked ones listed
|
|
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
|
|
200
|
-
|
|
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
|
|
209
|
-
|
|
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
|
}
|