jackherd 1.11.2 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +69 -51
- 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
|
@@ -1,83 +1,72 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { padVisible } from '../terminal/text'
|
|
3
|
-
import { textWidth } from '../terminal/width'
|
|
1
|
+
import { twoDigits } from '../plan-format/clock'
|
|
4
2
|
import type { PlanLanguage } from '../vocabulary'
|
|
5
|
-
import { type Appointment, readAppointmentTable } from '../watch/appointments'
|
|
3
|
+
import { type Appointment, readAppointmentTable, type UnreadableAppointment } from '../watch/appointments'
|
|
6
4
|
import { ASK_KIND } from './ask-target'
|
|
5
|
+
import { isSameWeek } from './calendar-days'
|
|
7
6
|
import { DONE_KIND } from './row-targets'
|
|
8
|
-
import {
|
|
9
|
-
import { type BlockButton, type BlockItem, DONE_BUTTON, MOVE_BUTTON, RUN_BUTTON } from './waiting-click'
|
|
7
|
+
import { type BlockButton, DONE_BUTTON, MOVE_BUTTON, RUN_BUTTON } from './waiting-click'
|
|
10
8
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
9
|
+
/**
|
|
10
|
+
* An open appointment as the herd shows it: `label` is the weekday and the time, `Mon 09:03` (the weekday is English
|
|
11
|
+
* whatever the board's language), or `time open` for a row whose time cell cannot be read; `at` is its time in epoch
|
|
12
|
+
* milliseconds, null for such a row; `key` is the watcher's, or the cell and the action for a row without a time.
|
|
13
|
+
*/
|
|
14
|
+
export type AppointmentRow = { key: string; when: string; at: number | null; label: string; action: string }
|
|
13
15
|
|
|
14
|
-
/** The
|
|
15
|
-
export
|
|
16
|
+
/** The label of an open row whose time cell cannot be read. */
|
|
17
|
+
export const TIME_OPEN = 'time open'
|
|
16
18
|
|
|
17
|
-
const
|
|
19
|
+
const TABLE_CELL_SEPARATOR = '|'
|
|
18
20
|
|
|
19
21
|
const WEEKDAY_LOCALE = 'en-US'
|
|
20
22
|
|
|
21
23
|
const weekdayOf = (when: Date): string => new Intl.DateTimeFormat(WEEKDAY_LOCALE, { weekday: 'short' }).format(when)
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const daysAhead = (when: Date, now: Date): number => Math.round((startOfDay(when) - startOfDay(now)) / DAY_MS)
|
|
28
|
-
|
|
29
|
-
/** The weekday names a day within the week; from a week on it is the date, `13.01.`, since the weekday would repeat. */
|
|
25
|
+
/**
|
|
26
|
+
* The weekday names a day of the running week, Monday to Sunday, ahead or back; outside it the date follows, `Mon 26.10.`,
|
|
27
|
+
* since the weekday alone would name a day of this week.
|
|
28
|
+
*/
|
|
30
29
|
const dayOf = (when: Date, now: Date): string =>
|
|
31
|
-
|
|
32
|
-
?
|
|
33
|
-
: weekdayOf(when)
|
|
30
|
+
isSameWeek(when, now)
|
|
31
|
+
? weekdayOf(when)
|
|
32
|
+
: `${weekdayOf(when)} ${twoDigits(when.getDate())}.${twoDigits(when.getMonth() + 1)}.`
|
|
34
33
|
|
|
35
34
|
const rowOf =
|
|
36
35
|
(now: Date) =>
|
|
37
36
|
({ when, whenText, action, key }: Appointment): AppointmentRow => ({
|
|
38
37
|
key,
|
|
39
38
|
when: whenText,
|
|
39
|
+
at: when.getTime(),
|
|
40
40
|
label: `${dayOf(when, now)} ${twoDigits(when.getHours())}:${twoDigits(when.getMinutes())}`,
|
|
41
41
|
action,
|
|
42
42
|
})
|
|
43
43
|
|
|
44
|
+
const timeOpenRowOf = ({ whenText, action }: UnreadableAppointment): AppointmentRow => ({
|
|
45
|
+
key: `${whenText} ${action}`,
|
|
46
|
+
when: whenText,
|
|
47
|
+
at: null,
|
|
48
|
+
label: TIME_OPEN,
|
|
49
|
+
action,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
/** A row of the table, not the table itself read as unreadable for a missing column: a cell holds no `|`. */
|
|
53
|
+
const isTableRow = ({ whenText }: UnreadableAppointment): boolean => !whenText.includes(TABLE_CELL_SEPARATOR)
|
|
54
|
+
|
|
44
55
|
/**
|
|
45
|
-
* The open appointments of the board's section, read as the watcher reads them
|
|
46
|
-
*
|
|
56
|
+
* The open appointments of the board's section, read as the watcher reads them: those whose time cannot be read first,
|
|
57
|
+
* as `time open`, then the others, the earliest first. A done row and a board without the section give no row.
|
|
47
58
|
*/
|
|
48
|
-
export const appointmentRows = (board: string, language: PlanLanguage, now: Date): AppointmentRow[] =>
|
|
49
|
-
readAppointmentTable(board, language, now)
|
|
50
|
-
|
|
59
|
+
export const appointmentRows = (board: string, language: PlanLanguage, now: Date): AppointmentRow[] => {
|
|
60
|
+
const { appointments, unreadable } = readAppointmentTable(board, language, now)
|
|
61
|
+
const timed = appointments
|
|
62
|
+
.filter(({ open }) => open)
|
|
51
63
|
.sort((left, right) => left.when.getTime() - right.when.getTime())
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
/** The block of the board's open appointments, as the herd shows them. */
|
|
55
|
-
export const appointmentBlock = (board: string, language: PlanLanguage, now: Date): AppointmentBlock => ({
|
|
56
|
-
rows: appointmentRows(board, language, now),
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
const appointmentKey = ({ key }: AppointmentRow): string => `appointment>${key}`
|
|
64
|
+
return [...unreadable.filter(isTableRow).map(timeOpenRowOf), ...timed.map(rowOf(now))]
|
|
65
|
+
}
|
|
60
66
|
|
|
61
|
-
|
|
67
|
+
/** `[done]` closes the row, `[run]` asks the coordinator to run it now, `[move]` to ask the user for a new time. */
|
|
68
|
+
export const appointmentButtons = ({ when, action }: AppointmentRow): BlockButton[] => [
|
|
62
69
|
{ label: DONE_BUTTON, click: { done: { kind: DONE_KIND.appointment, when, action } } },
|
|
63
70
|
{ label: RUN_BUTTON, click: { ask: { kind: ASK_KIND.appointmentRun, when, action } } },
|
|
64
71
|
{ label: MOVE_BUTTON, click: { ask: { kind: ASK_KIND.appointmentMove, when, action } } },
|
|
65
72
|
]
|
|
66
|
-
|
|
67
|
-
/** The rows as the block draws them; a click on the row only expands it, the buttons close it, run it or move it. */
|
|
68
|
-
export const appointmentItems = (rows: readonly AppointmentRow[]): BlockItem[] => {
|
|
69
|
-
const timeWidth = Math.max(0, ...rows.map(({ label }) => textWidth(label)))
|
|
70
|
-
return rows.map((row) => ({
|
|
71
|
-
key: appointmentKey(row),
|
|
72
|
-
mark: APPOINTMENT_MARK,
|
|
73
|
-
lead: padVisible(row.label, timeWidth),
|
|
74
|
-
text: row.action,
|
|
75
|
-
title: shortTitle(row.action),
|
|
76
|
-
url: null,
|
|
77
|
-
ask: null,
|
|
78
|
-
buttons: buttonsOf(row),
|
|
79
|
-
}))
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** The keys of all rows, the shown ones and the `+<n>` rest alike: what the fold of the block remembers. */
|
|
83
|
-
export const appointmentKeys = (rows: readonly AppointmentRow[]): string[] => rows.map(appointmentKey)
|
package/src/plans/board-rows.ts
CHANGED
|
@@ -60,3 +60,17 @@ export const boardRowsOf = (baseText: string, checkoutText: string, language: Pl
|
|
|
60
60
|
if (checkoutText === baseText) return { lanes, rowsFromCheckout: 0 }
|
|
61
61
|
return withCheckoutRows(lanes, parseSprintLanes(checkoutText, language))
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
/** `boardRowsOf` that keeps its last answer: the board seldom changes between two reads, and parsing it holds the event loop. */
|
|
65
|
+
export type BoardRowsReader = typeof boardRowsOf
|
|
66
|
+
|
|
67
|
+
/** A `BoardRowsReader` that parses again only when a text or the language differs from its last call. */
|
|
68
|
+
export const boardRowsReader = (): BoardRowsReader => {
|
|
69
|
+
let last: { baseText: string; checkoutText: string; language: PlanLanguage; rows: BoardRows } | null = null
|
|
70
|
+
return (baseText, checkoutText, language) => {
|
|
71
|
+
if (last?.baseText !== baseText || last.checkoutText !== checkoutText || last.language !== language) {
|
|
72
|
+
last = { baseText, checkoutText, language, rows: boardRowsOf(baseText, checkoutText, language) }
|
|
73
|
+
}
|
|
74
|
+
return last.rows
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { DAY_MS } from '../plan-format/clock'
|
|
2
|
+
|
|
3
|
+
const DAYS_PER_WEEK = 7
|
|
4
|
+
|
|
5
|
+
const startOfDay = (when: Date): number => new Date(when.getFullYear(), when.getMonth(), when.getDate()).getTime()
|
|
6
|
+
|
|
7
|
+
/** Days since the last Monday: 0 on a Monday, 6 on a Sunday. */
|
|
8
|
+
const daysSinceMonday = (when: Date): number => (when.getDay() + DAYS_PER_WEEK - 1) % DAYS_PER_WEEK
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Calendar days from `from` to `to` in the clock's time zone, negative when `to` is earlier: 23:59 to 00:01 is one
|
|
12
|
+
* day, 00:01 to 23:59 of the same day none. Rounded, so a day of 23 or 25 hours at a clock change still counts as one.
|
|
13
|
+
*/
|
|
14
|
+
export const calendarDaysBetween = (from: Date, to: Date): number =>
|
|
15
|
+
Math.round((startOfDay(to) - startOfDay(from)) / DAY_MS)
|
|
16
|
+
|
|
17
|
+
/** The same wall-clock time `days` calendar days later (earlier for a negative count), across a clock change too. */
|
|
18
|
+
export const addCalendarDays = (when: Date, days: number): Date => {
|
|
19
|
+
const moved = new Date(when)
|
|
20
|
+
moved.setDate(moved.getDate() + days)
|
|
21
|
+
return moved
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether both times fall in the same calendar week, Monday to Sunday, in the clock's time zone. */
|
|
25
|
+
export const isSameWeek = (left: Date, right: Date): boolean =>
|
|
26
|
+
calendarDaysBetween(
|
|
27
|
+
addCalendarDays(left, -daysSinceMonday(left)),
|
|
28
|
+
addCalendarDays(right, -daysSinceMonday(right)),
|
|
29
|
+
) === 0
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { phaseLabel } from '../plan-format'
|
|
2
|
+
import { pullRequestNumberOfKey } from './pull-requests'
|
|
3
|
+
import { LINKS_KEY_PREFIX } from './row-focus'
|
|
4
|
+
import {
|
|
5
|
+
BACKLOG_KEY,
|
|
6
|
+
finishedKey,
|
|
7
|
+
isBlockFolded,
|
|
8
|
+
isLaneOpen,
|
|
9
|
+
isLanesOpen,
|
|
10
|
+
isTaskRestKey,
|
|
11
|
+
itemPhaseOfKey,
|
|
12
|
+
LANES_KEY,
|
|
13
|
+
laneOfKey,
|
|
14
|
+
nextKey,
|
|
15
|
+
phaseIdOfKey,
|
|
16
|
+
type Selection,
|
|
17
|
+
TOP_BLOCK,
|
|
18
|
+
type TopBlockId,
|
|
19
|
+
topBlockOfKey,
|
|
20
|
+
WAITING_KEY,
|
|
21
|
+
} from './selection'
|
|
22
|
+
import { TEXTS } from './texts'
|
|
23
|
+
import { type BlockRow, mainClickOf } from './waiting-click'
|
|
24
|
+
|
|
25
|
+
/** The status a lane's agent reports, by the lane's name; null without an agent. */
|
|
26
|
+
export type AgentOf = (lane: string) => string | null
|
|
27
|
+
|
|
28
|
+
const NEXT_PREFIX = nextKey('')
|
|
29
|
+
const DONE_PREFIX = finishedKey('')
|
|
30
|
+
|
|
31
|
+
/** The heads of the folded blocks of the body by their names; the lanes block opens and folds by its own flag. */
|
|
32
|
+
const BLOCK_NAMES: Record<string, string> = {
|
|
33
|
+
[WAITING_KEY]: TEXTS.waitingHeading,
|
|
34
|
+
[BACKLOG_KEY]: TEXTS.backlogHeading,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The blocks on top by their names, whose heads open and fold them. */
|
|
38
|
+
const TOP_BLOCK_NAMES: Record<TopBlockId, string> = {
|
|
39
|
+
[TOP_BLOCK.yourTurn]: TEXTS.yourTurnHeading,
|
|
40
|
+
[TOP_BLOCK.sources]: TEXTS.sourceHeading,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `opens <name>` or `folds <name>` for a row that opens and folds, by whether it is open now. */
|
|
44
|
+
const foldHint = (open: boolean, name: string): string => (open ? TEXTS.hintFolds(name) : TEXTS.hintOpens(name))
|
|
45
|
+
|
|
46
|
+
/** What a click on a row of a block on top does, which enter does too: ask the coordinator, or open it. */
|
|
47
|
+
const blockHint = (row: BlockRow): string | null => {
|
|
48
|
+
const click = mainClickOf(row)
|
|
49
|
+
if (!click) return null
|
|
50
|
+
return 'ask' in click ? TEXTS.hintBlockAsks : TEXTS.hintBlockOpens
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What a click does on a row of the body that opens and folds, by the shape of its key; null for another key. */
|
|
54
|
+
const foldingHint = (key: string, state: Selection, agentOf: AgentOf): string | null => {
|
|
55
|
+
const lane = laneOfKey(key)
|
|
56
|
+
if (lane !== null) return foldHint(isLaneOpen(state, lane, agentOf(lane)), lane)
|
|
57
|
+
if (key === LANES_KEY) return foldHint(isLanesOpen(state), TEXTS.lanesHeading)
|
|
58
|
+
const top = topBlockOfKey(key)
|
|
59
|
+
if (top !== null) return foldHint(!isBlockFolded(state, top), TOP_BLOCK_NAMES[top])
|
|
60
|
+
const open = state.expanded.has(key)
|
|
61
|
+
const block = BLOCK_NAMES[key]
|
|
62
|
+
if (block) return foldHint(open, block)
|
|
63
|
+
if (itemPhaseOfKey(key) !== null) return foldHint(open, TEXTS.hintTask)
|
|
64
|
+
if (isTaskRestKey(key)) return foldHint(open, TEXTS.hintTaskRest)
|
|
65
|
+
const phase = phaseIdOfKey(key)
|
|
66
|
+
if (phase !== null) return open ? TEXTS.hintFoldsTasks(phaseLabel(phase)) : TEXTS.hintListsTasks(phaseLabel(phase))
|
|
67
|
+
if (key.startsWith(NEXT_PREFIX)) return foldHint(open, TEXTS.hintNextPhases)
|
|
68
|
+
if (key.startsWith(DONE_PREFIX)) return foldHint(open, TEXTS.hintDonePhases)
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What a click does on a row of the body: open or fold it, or open the pull request it names. */
|
|
73
|
+
const bodyHint = (key: string, state: Selection, agentOf: AgentOf): string | null => {
|
|
74
|
+
const number = pullRequestNumberOfKey(key)
|
|
75
|
+
return number === null ? foldingHint(key, state, agentOf) : TEXTS.hintOpens(`#${number}`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The footer line that says what a click or a key does on the row under the mouse, else the selected one:
|
|
80
|
+
* `click or → opens search-index · ? help`. `blockRow` finds a row of the blocks on top by its key, `agentOf` the
|
|
81
|
+
* status of a lane's agent, which decides whether a lane without a fold of its own is drawn open. Null while no row
|
|
82
|
+
* has the focus, the mouse is on a line of links, or the row has nothing a click does.
|
|
83
|
+
*/
|
|
84
|
+
export const clickHint = (
|
|
85
|
+
state: Selection,
|
|
86
|
+
blockRow: (key: string) => BlockRow | null,
|
|
87
|
+
agentOf: AgentOf = () => null,
|
|
88
|
+
): string | null => {
|
|
89
|
+
const key = state.hovered ?? state.selected
|
|
90
|
+
if (!key || key.startsWith(LINKS_KEY_PREFIX)) return null
|
|
91
|
+
const row = blockRow(key)
|
|
92
|
+
const hint = row ? blockHint(row) : bodyHint(key, state, agentOf)
|
|
93
|
+
return hint ? TEXTS.clickHint(hint) : null
|
|
94
|
+
}
|
package/src/plans/command.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { homedir } from 'node:os'
|
|
2
2
|
import { TERMINAL_ADAPTER } from '../config/schema'
|
|
3
3
|
import { laneStatusSourceFor } from '../terminal'
|
|
4
|
+
import { listHerdrAgents } from '../terminal/herdr'
|
|
4
5
|
import { startedKit } from '../version/started-kit'
|
|
5
6
|
import { boardFor, foundLanesOf } from './board-pick'
|
|
6
7
|
import { ensurePlanPanes } from './ensure-panes'
|
|
@@ -11,22 +12,20 @@ import { recallLanes, rememberLanes } from './herd-memory'
|
|
|
11
12
|
import { runLive } from './live'
|
|
12
13
|
import { parseOptions } from './options'
|
|
13
14
|
import { isSupervisedChild } from './restart-decision'
|
|
14
|
-
import {
|
|
15
|
+
import { type PlansSettings, readSettings } from './settings'
|
|
15
16
|
import { loadSnapshot, type Source } from './snapshot'
|
|
16
17
|
import { superviseView } from './supervisor'
|
|
17
18
|
import { frameText, paneWidth, renderSnapshot, startSelection } from './view'
|
|
18
19
|
|
|
20
|
+
/** herdr's agents read without holding the event loop, so the live view keeps answering keys during a refresh. */
|
|
21
|
+
const agentListOf = (settings: PlansSettings): Pick<Source, 'listAgents'> =>
|
|
22
|
+
settings.adapter === TERMINAL_ADAPTER.herdr ? { listAgents: () => listHerdrAgents() } : {}
|
|
23
|
+
|
|
19
24
|
const printOnce = async (source: Source): Promise<void> => {
|
|
20
25
|
const memories = recallLanes(source.root)
|
|
21
26
|
const snapshot = await loadSnapshot(source, await fetchPullRequests(source.root), memories)
|
|
22
27
|
rememberLanes(source.root, memories, snapshot.documents.laneOrder)
|
|
23
|
-
const screen = renderSnapshot(
|
|
24
|
-
snapshot,
|
|
25
|
-
startSelection(snapshot, source.options),
|
|
26
|
-
snapshot.errors,
|
|
27
|
-
paneWidth(),
|
|
28
|
-
CLOCK_LOCALE,
|
|
29
|
-
)
|
|
28
|
+
const screen = renderSnapshot(snapshot, startSelection(snapshot, source.options), snapshot.errors, paneWidth())
|
|
30
29
|
console.log(frameText(screen, Boolean(process.stdout.isTTY)))
|
|
31
30
|
}
|
|
32
31
|
|
|
@@ -58,7 +57,7 @@ export const run = async (args: string[], home: string = homedir()): Promise<num
|
|
|
58
57
|
for (const line of lines.length > 0 ? lines : ['every herd pane is running']) console.log(line)
|
|
59
58
|
return 0
|
|
60
59
|
}
|
|
61
|
-
const source = { root, sprintFile: picked.file, options, settings, terminal }
|
|
60
|
+
const source: Source = { root, sprintFile: picked.file, options, settings, terminal, ...agentListOf(settings) }
|
|
62
61
|
if (options.once || !process.stdout.isTTY) await printOnceWithNotice(source, picked.notice)
|
|
63
62
|
else if (isSupervisedChild(process.env)) await startExclusive(claimOwnTerminal, () => runLive(source))
|
|
64
63
|
else process.exit(await superviseView(args, await startedKit()))
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
isTickedUnmerged,
|
|
3
|
+
type LaneProgress,
|
|
4
|
+
type PhaseProgress,
|
|
5
|
+
type PlanPhase,
|
|
6
|
+
type PlansByHref,
|
|
7
|
+
} from '../plan-format'
|
|
2
8
|
|
|
3
9
|
/** A phase of a plan, as the sprint table spells the plan's link. */
|
|
4
10
|
export type PhaseRef = { planHref: string; phaseId: string }
|
|
@@ -21,6 +27,20 @@ export const activityKey = (planHref: string, phaseId: string): string => `${pla
|
|
|
21
27
|
/** Marks a tick string of the tasks alone; one remembered before, with the review gate in it, has no mark. */
|
|
22
28
|
const TASK_TICKS = 't:'
|
|
23
29
|
|
|
30
|
+
const keptTicks = new WeakMap<PlanPhase, string>()
|
|
31
|
+
|
|
32
|
+
/** The tick string of a phase's tasks, kept per phase object: a plan that did not change hands back the same phases. */
|
|
33
|
+
const taskTicksOf = (phase: PlanPhase): string => {
|
|
34
|
+
const kept = keptTicks.get(phase)
|
|
35
|
+
if (kept !== undefined) return kept
|
|
36
|
+
const ticks = `${TASK_TICKS}${phase.items
|
|
37
|
+
.filter((item) => !item.gate)
|
|
38
|
+
.map((item) => (item.done ? '1' : '0'))
|
|
39
|
+
.join('')}`
|
|
40
|
+
keptTicks.set(phase, ticks)
|
|
41
|
+
return ticks
|
|
42
|
+
}
|
|
43
|
+
|
|
24
44
|
/**
|
|
25
45
|
* The ticks of each phase's tasks outside the review gate (D67): ticking the gate is bookkeeping, not work on the
|
|
26
46
|
* phase, so it moves nothing in the herd.
|
|
@@ -29,15 +49,7 @@ export const tickSnapshotOf = (plans: PlansByHref): TickSnapshot =>
|
|
|
29
49
|
Object.fromEntries(
|
|
30
50
|
Object.entries(plans).map(([href, phases]) => [
|
|
31
51
|
href,
|
|
32
|
-
Object.fromEntries(
|
|
33
|
-
phases.map((phase) => [
|
|
34
|
-
phase.id,
|
|
35
|
-
`${TASK_TICKS}${phase.items
|
|
36
|
-
.filter((item) => !item.gate)
|
|
37
|
-
.map((item) => (item.done ? '1' : '0'))
|
|
38
|
-
.join('')}`,
|
|
39
|
-
]),
|
|
40
|
-
),
|
|
52
|
+
Object.fromEntries(phases.map((phase) => [phase.id, taskTicksOf(phase)])),
|
|
41
53
|
]),
|
|
42
54
|
)
|
|
43
55
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { keptLogEntry } from '../context/session-log'
|
|
2
2
|
import type { PlanPhase } from '../plan-format'
|
|
3
3
|
import { isRecord } from '../shared/is-record'
|
|
4
4
|
|
|
@@ -64,6 +64,21 @@ const TASK_TEXT_LENGTH = 200
|
|
|
64
64
|
|
|
65
65
|
const wordsOf = (text: string): Set<string> => new Set(text.toLowerCase().match(WORD) ?? [])
|
|
66
66
|
|
|
67
|
+
/** How many task texts keep their words: every open task of the plans a herd shows, a few thousand at most. */
|
|
68
|
+
const KEPT_TASK_WORDS = 8192
|
|
69
|
+
|
|
70
|
+
const taskWords = new Map<string, string[]>()
|
|
71
|
+
|
|
72
|
+
/** The distinctive words of a task's text (its first `TASK_TEXT_LENGTH` characters), kept per text across refreshes. */
|
|
73
|
+
const taskWordsOf = (text: string): string[] => {
|
|
74
|
+
const kept = taskWords.get(text)
|
|
75
|
+
if (kept) return kept
|
|
76
|
+
const words = [...wordsOf(text.slice(0, TASK_TEXT_LENGTH))]
|
|
77
|
+
if (taskWords.size >= KEPT_TASK_WORDS) taskWords.clear()
|
|
78
|
+
taskWords.set(text, words)
|
|
79
|
+
return words
|
|
80
|
+
}
|
|
81
|
+
|
|
67
82
|
/**
|
|
68
83
|
* The signal each phase gets from what an agent of the lane did lately (`activity`, see `activityTextOf`): the open
|
|
69
84
|
* task of which the agent used at least `SIGNAL_SHARE` of the distinctive words (six letters or more, from the first
|
|
@@ -77,7 +92,7 @@ export const taskSignalsOf = (plan: readonly TaskPhase[], activity: string, at:
|
|
|
77
92
|
return plan.flatMap((phase) => {
|
|
78
93
|
const scored = phase.items.flatMap((item, taskIndex) => {
|
|
79
94
|
if (item.done) return []
|
|
80
|
-
const words =
|
|
95
|
+
const words = taskWordsOf(item.text)
|
|
81
96
|
const hits = words.filter((word) => used.has(word)).length
|
|
82
97
|
return words.length > 0 && hits >= SIGNAL_MIN_WORDS && hits / words.length >= SIGNAL_SHARE
|
|
83
98
|
? [{ taskIndex, hits }]
|
|
@@ -103,7 +118,7 @@ export const activityTextOf = (lines: readonly string[], entries: number): strin
|
|
|
103
118
|
lines
|
|
104
119
|
.slice(-entries)
|
|
105
120
|
.flatMap((line) => {
|
|
106
|
-
const message =
|
|
121
|
+
const message = keptLogEntry(line)?.message
|
|
107
122
|
return isRecord(message) ? contentTexts(message.content) : []
|
|
108
123
|
})
|
|
109
124
|
.join(' ')
|
package/src/plans/frame.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { visibleLength } from '../terminal/text'
|
|
2
2
|
import { focusLine, isFocused } from './row-focus'
|
|
3
3
|
import type { ClickTarget, Selection } from './selection'
|
|
4
4
|
|
|
@@ -11,10 +11,6 @@ export type Frame = { lines: string[]; cursorLine: number; targets: ClickTarget[
|
|
|
11
11
|
/** What goes in front of a frame's lines: `first` on its first line, `rest` on the others, both drawn in `style`. */
|
|
12
12
|
export type Lead = { first: string; rest: string; style: (text: string) => string }
|
|
13
13
|
|
|
14
|
-
/** The tree lines: a branch to a sibling that has more below it, the last branch, and what carries on beside them. */
|
|
15
|
-
export const TREE = { branch: '├─ ', last: '└─ ', pipe: '│ ', gap: ' ', trunk: '│' } as const
|
|
16
|
-
const TREE_GUTTER = TREE.branch.length
|
|
17
|
-
|
|
18
14
|
export const textFrame = (lines: string[]): Frame => ({ lines, cursorLine: -1, targets: [] })
|
|
19
15
|
|
|
20
16
|
/** Stacks frames, moving each one's cursor line and click targets below the frames before it. */
|
|
@@ -48,30 +44,26 @@ export const prefixed = (frame: Frame, { first, rest, style }: Lead): Frame => {
|
|
|
48
44
|
}
|
|
49
45
|
}
|
|
50
46
|
|
|
51
|
-
/** The
|
|
52
|
-
export const
|
|
47
|
+
/** The text as it is: the style of a lead of blanks. */
|
|
48
|
+
export const unpainted = (text: string): string => text
|
|
49
|
+
|
|
50
|
+
/** The frame moved right by `lead` on every line; the caller has drawn it that much narrower (`narrower`). */
|
|
51
|
+
export const indented = (frame: Frame, lead: string): Frame =>
|
|
52
|
+
prefixed(frame, { first: lead, rest: lead, style: unpainted })
|
|
53
|
+
|
|
54
|
+
/** The context `cells` narrower, one cell at the least. */
|
|
55
|
+
export const narrower = <Context extends { width: number }>(context: Context, cells: number): Context => ({
|
|
53
56
|
...context,
|
|
54
|
-
width: Math.max(context.width -
|
|
57
|
+
width: Math.max(context.width - cells, 1),
|
|
55
58
|
})
|
|
56
59
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
style: paint.dim,
|
|
65
|
-
})
|
|
66
|
-
})
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* The children of a node as a tree: each child's first line behind a branch, the last behind the closing one, the
|
|
71
|
-
* lines of a child after its first beside the trunk. The caller has drawn the children `TREE_GUTTER` narrower
|
|
72
|
-
* (`deeper`); a child without lines leaves no branch.
|
|
73
|
-
*/
|
|
74
|
-
export const nest = (children: Frame[]): Frame => joinFrames(branches(children))
|
|
60
|
+
/** The frames with a blank line between neighbours; a frame without lines takes no line and no blank. */
|
|
61
|
+
export const spaced = (frames: Frame[]): Frame =>
|
|
62
|
+
joinFrames(
|
|
63
|
+
frames
|
|
64
|
+
.filter((frame) => frame.lines.length > 0)
|
|
65
|
+
.flatMap((frame, index) => (index === 0 ? [frame] : [textFrame(['']), frame])),
|
|
66
|
+
)
|
|
75
67
|
|
|
76
68
|
/**
|
|
77
69
|
* A selectable row: drawn with the focus background and mark (`focusLine`) when selected or under the mouse, clickable
|
package/src/plans/git.ts
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
2
2
|
import { dirname } from 'node:path'
|
|
3
3
|
import type { LaneStatus } from './layout'
|
|
4
|
+
import { createProcessSlots } from './process-slots'
|
|
4
5
|
import type { BaseRefInfo } from './sprint-page'
|
|
6
|
+
import type { BranchStatus } from './status-pace'
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
/** How many git processes the herd's reads keep in flight; more end together and hold the event loop as one block. */
|
|
9
|
+
const GIT_PROCESSES = 6
|
|
10
|
+
|
|
11
|
+
const gitSlots = createProcessSlots(GIT_PROCESSES)
|
|
12
|
+
|
|
13
|
+
const passAtOnce = async () => {}
|
|
14
|
+
|
|
15
|
+
/** `pass` is awaited in the slot right before the process starts: starting one holds the event loop as well. */
|
|
16
|
+
const gitAsync = (cwd: string, args: string[], pass: () => Promise<void> = passAtOnce): Promise<string | null> =>
|
|
17
|
+
gitSlots.run(async () => {
|
|
18
|
+
await pass()
|
|
19
|
+
const child = Bun.spawn(['git', '-C', cwd, ...args], { stdout: 'pipe', stderr: 'ignore' })
|
|
20
|
+
const [output, exitCode] = await Promise.all([new Response(child.stdout).text(), child.exited])
|
|
21
|
+
return exitCode === 0 ? output.trim() : null
|
|
22
|
+
})
|
|
11
23
|
|
|
12
24
|
const git = (cwd: string, args: string[]): string | null => {
|
|
13
25
|
const result = Bun.spawnSync(['git', '-C', cwd, ...args], { stdout: 'pipe', stderr: 'pipe' })
|
|
@@ -32,8 +44,25 @@ export const showAtRef = (root: string, ref: string, path: string): string | nul
|
|
|
32
44
|
export const commitOf = (root: string, ref: string): string | null =>
|
|
33
45
|
git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
|
|
34
46
|
|
|
35
|
-
|
|
36
|
-
|
|
47
|
+
/** The commit `ref` points at, read without holding the event loop; null when it does not exist. */
|
|
48
|
+
export const resolveCommit = async (root: string, ref: string): Promise<string | null> =>
|
|
49
|
+
(await gitAsync(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])) || null
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The commit of every ref under `namespace` (e.g. `refs/jackherd/pr`), by full ref name, read in one git call and
|
|
53
|
+
* without holding the event loop; empty when there is none or no git.
|
|
54
|
+
*/
|
|
55
|
+
export const refCommits = async (root: string, namespace: string): Promise<Map<string, string>> => {
|
|
56
|
+
const output = await gitAsync(root, ['for-each-ref', '--format=%(refname) %(objectname)', namespace])
|
|
57
|
+
const pairs = (output ?? '').split('\n').flatMap((line) => {
|
|
58
|
+
const [ref, commit] = line.split(' ')
|
|
59
|
+
return ref && commit ? [[ref, commit] as const] : []
|
|
60
|
+
})
|
|
61
|
+
return new Map(pairs)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const lastChangeAt = async (root: string, commit: string, path: string): Promise<number | null> => {
|
|
65
|
+
const seconds = Number(await gitAsync(root, ['log', '-1', '--format=%ct', commit, '--', path]))
|
|
37
66
|
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null
|
|
38
67
|
}
|
|
39
68
|
|
|
@@ -51,20 +80,21 @@ const changesOf = (root: string, commit: string): Map<string, number | null> =>
|
|
|
51
80
|
}
|
|
52
81
|
|
|
53
82
|
/**
|
|
54
|
-
* A reader of the commit time, in epoch milliseconds, of the last commit at `
|
|
55
|
-
*
|
|
56
|
-
*
|
|
83
|
+
* A reader of the commit time, in epoch milliseconds, of the last commit at `commit` that changed each of `paths`;
|
|
84
|
+
* null for a path not asked, a file unknown there or no commit. The times not kept yet are read side by side without
|
|
85
|
+
* holding the event loop, and each answer is kept per commit, so a refresh while the ref stands asks git nothing and
|
|
86
|
+
* a moved ref reads afresh.
|
|
57
87
|
*/
|
|
58
|
-
export const
|
|
59
|
-
|
|
88
|
+
export const lastChangesAt = async (
|
|
89
|
+
root: string,
|
|
90
|
+
commit: string | null,
|
|
91
|
+
paths: readonly string[],
|
|
92
|
+
): Promise<(path: string) => number | null> => {
|
|
60
93
|
if (commit === null) return () => null
|
|
61
94
|
const changes = changesOf(root, commit)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
changes.set(path, at)
|
|
66
|
-
return at
|
|
67
|
-
}
|
|
95
|
+
const missing = paths.filter((path) => !changes.has(path))
|
|
96
|
+
await Promise.all(missing.map(async (path) => changes.set(path, await lastChangeAt(root, commit, path))))
|
|
97
|
+
return (path) => changes.get(path) ?? null
|
|
68
98
|
}
|
|
69
99
|
|
|
70
100
|
/** Reads files as they are at the base ref. */
|
|
@@ -92,8 +122,10 @@ const textsOf = (root: string, commit: string): Map<string, string> => {
|
|
|
92
122
|
* one `git rev-parse` per ref instead of one `git show` per plan, and the base and the pull request heads read in
|
|
93
123
|
* the same round do not evict each other.
|
|
94
124
|
*/
|
|
95
|
-
export const baseReaderOf = (root: string, ref: string): BaseReader =>
|
|
96
|
-
|
|
125
|
+
export const baseReaderOf = (root: string, ref: string): BaseReader => baseReaderAt(root, commitOf(root, ref))
|
|
126
|
+
|
|
127
|
+
/** `baseReaderOf` for a commit resolved beforehand (`resolveCommit`); reads nothing without one. */
|
|
128
|
+
export const baseReaderAt = (root: string, commit: string | null): BaseReader => {
|
|
97
129
|
if (commit === null) return () => null
|
|
98
130
|
const texts = textsOf(root, commit)
|
|
99
131
|
return (path) => {
|
|
@@ -127,26 +159,53 @@ export const worktreeRoot = (cwd: string): string => {
|
|
|
127
159
|
return top
|
|
128
160
|
}
|
|
129
161
|
|
|
162
|
+
const BRANCH_HEAD = '# branch.head '
|
|
163
|
+
const DETACHED_HEAD = '(detached)'
|
|
164
|
+
const HEADER_MARK = '#'
|
|
165
|
+
|
|
130
166
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
167
|
+
* The branch and the number of changed files, untracked ones included, in the output of `git status --porcelain=v2
|
|
168
|
+
* --branch`; a detached head and a missing output have no branch.
|
|
169
|
+
*/
|
|
170
|
+
export const branchStatusOf = (output: string | null): BranchStatus => {
|
|
171
|
+
const lines = (output ?? '').split('\n').filter(Boolean)
|
|
172
|
+
const head = lines.find((line) => line.startsWith(BRANCH_HEAD))?.slice(BRANCH_HEAD.length)
|
|
173
|
+
return {
|
|
174
|
+
branch: head && head !== DETACHED_HEAD ? head : null,
|
|
175
|
+
dirty: lines.filter((line) => !line.startsWith(HEADER_MARK)).length,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Reads branch and changed files of a worktree; its git process starts after `pass`. */
|
|
180
|
+
export type BranchStatusReader = (worktree: string, pass: () => Promise<void>) => Promise<BranchStatus>
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Branch and changed files of a worktree from `git status`, the dearest of the herd's git calls in a large checkout.
|
|
184
|
+
* Without optional locks: the herd never rewrites the index, which would take the lock an agent's git needs and move
|
|
185
|
+
* the index the worktree's stamp watches (`worktreeStamp`).
|
|
186
|
+
*/
|
|
187
|
+
export const branchStatusAt: BranchStatusReader = async (worktree, pass) =>
|
|
188
|
+
branchStatusOf(await gitAsync(worktree, ['--no-optional-locks', 'status', '--porcelain=v2', '--branch'], pass))
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Branch, commits ahead of `baseRef` and changed files of a worktree; null and 0 when it does not exist. The two
|
|
192
|
+
* git calls run side by side and without holding the event loop, so a key pressed meanwhile is answered at once;
|
|
193
|
+
* each starts after `pass` (a `SliceGate`), so the starts of many worktrees' calls do not add up to one block.
|
|
194
|
+
* `readBranchStatus` may answer from what it kept instead of asking git (`pacedStatus`).
|
|
133
195
|
*/
|
|
134
196
|
export const gitStatusOf = async (
|
|
135
197
|
worktree: string,
|
|
136
198
|
baseRef: string,
|
|
199
|
+
pass: () => Promise<void> = passAtOnce,
|
|
200
|
+
readBranchStatus: BranchStatusReader = branchStatusAt,
|
|
137
201
|
): Promise<Pick<LaneStatus, 'branch' | 'ahead' | 'dirty'>> => {
|
|
138
202
|
if (!existsSync(worktree)) return { branch: null, ahead: null, dirty: 0 }
|
|
139
|
-
const [count,
|
|
140
|
-
gitAsync(worktree, ['rev-list', '--count', `${baseRef}..HEAD`]),
|
|
141
|
-
|
|
142
|
-
gitAsync(worktree, ['branch', '--show-current']),
|
|
203
|
+
const [count, status] = await Promise.all([
|
|
204
|
+
gitAsync(worktree, ['rev-list', '--count', `${baseRef}..HEAD`], pass),
|
|
205
|
+
readBranchStatus(worktree, pass),
|
|
143
206
|
])
|
|
144
207
|
const ahead = Number.parseInt(count ?? '', 10)
|
|
145
|
-
return {
|
|
146
|
-
branch: branch || null,
|
|
147
|
-
ahead: Number.isNaN(ahead) ? null : ahead,
|
|
148
|
-
dirty: (porcelain ?? '').split('\n').filter(Boolean).length,
|
|
149
|
-
}
|
|
208
|
+
return { ...status, ahead: Number.isNaN(ahead) ? null : ahead }
|
|
150
209
|
}
|
|
151
210
|
|
|
152
211
|
/** Where this clone keeps `FETCH_HEAD`, whether it exists yet or not; null outside a git checkout. */
|