jackherd 1.12.2 → 1.12.4
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 +16 -0
- package/README.md +16 -16
- package/dist/plan-format.d.ts +23 -11
- package/dist/plan-format.js +6 -3
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/workflow/skills/orchestrate/SKILL.md +7 -7
- package/package.json +1 -1
- package/src/context/session-log.ts +4 -6
- package/src/plan-format/phases.ts +22 -6
- package/src/plan-format/progress.ts +5 -8
- package/src/plans/command.ts +10 -3
- package/src/plans/current-task.ts +3 -7
- package/src/plans/focus-overlay.ts +80 -0
- package/src/plans/git.ts +6 -11
- package/src/plans/herd-state.ts +201 -0
- package/src/plans/lane-reason.ts +1 -1
- package/src/plans/lane-summary.ts +6 -2
- package/src/plans/layout-phase.ts +1 -3
- package/src/plans/layout-summary.ts +1 -1
- package/src/plans/layout.ts +32 -31
- package/src/plans/link-chips.ts +7 -16
- package/src/plans/live.ts +215 -267
- package/src/plans/markdown-text.ts +4 -0
- package/src/plans/page-sync.ts +39 -0
- package/src/plans/phase-source.ts +5 -2
- package/src/plans/reader.ts +217 -0
- package/src/plans/refresh-schedule.ts +53 -0
- package/src/plans/snapshot.ts +32 -31
- package/src/plans/status-pace.ts +7 -4
- package/src/plans/subagents-read.ts +28 -12
- package/src/plans/supervisor.ts +1 -1
- package/src/plans/view.ts +9 -101
- package/src/plans/waiting-click.ts +27 -17
- package/src/plans/worked-phases.ts +1 -1
- package/src/plans/worktree-stamp.ts +12 -9
- package/src/plans/your-turn-rows.ts +33 -32
- package/src/plans/your-turn.ts +6 -4
- package/src/shared/bounded-memo.ts +19 -0
- package/src/shared/stat-cache.ts +2 -1
- package/src/terminal/lifetime.ts +52 -0
- package/src/watch/command.ts +21 -10
- package/src/watch/herd-publisher.ts +84 -0
- package/src/watch/lane-report.ts +17 -1
- package/src/watch/source-entries.ts +7 -0
- package/src/watch/source-statuses.ts +2 -2
- package/src/watch/sources.ts +5 -2
- package/src/watch/stream-output.ts +6 -0
- package/src/watch/watch-events.ts +8 -0
- package/src/watch/watcher-lock.ts +5 -2
- package/src/plans/next-rows.ts +0 -12
package/src/plans/view.ts
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { ARTIFACT_LINK_KIND } from '../config/link-kinds'
|
|
2
2
|
import { DEFAULT_TONES } from '../config/tones'
|
|
3
|
-
import { fit,
|
|
4
|
-
import {
|
|
3
|
+
import { fit, plainText, visibleLength } from '../terminal/text'
|
|
4
|
+
import type { AgentOf } from './click-hint'
|
|
5
|
+
import { unfocused } from './focus-overlay'
|
|
5
6
|
import { type OverviewView, renderScreen, renderSelected, type Screen } from './layout'
|
|
6
7
|
import type { Options } from './options'
|
|
7
|
-
import { blockRowByKey, boldBracketAt, type FocusHit, focusLine, isFocused, LINKS_KEY_PREFIX } from './row-focus'
|
|
8
8
|
import { laneSelection, overviewSelection, type Selection } from './selection'
|
|
9
|
-
import { longestShimmer, type ShimmerRow, shimmerAt
|
|
9
|
+
import { longestShimmer, type ShimmerRow, shimmerAt } from './shimmer'
|
|
10
10
|
import type { Snapshot } from './snapshot'
|
|
11
|
-
import { TEXTS } from './texts'
|
|
12
11
|
import { setTones } from './tones'
|
|
13
12
|
|
|
14
13
|
const DEFAULT_WIDTH = 100
|
|
@@ -25,9 +24,6 @@ export const FRAME_MS = 16
|
|
|
25
24
|
export const nextFrameDelay = (lastFrameAt: number | null, now: number): number =>
|
|
26
25
|
lastFrameAt === null ? 0 : Math.max(FRAME_MS - (now - lastFrameAt), 0)
|
|
27
26
|
|
|
28
|
-
export const TERMINAL_ON = '\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1003h\x1b[?1006h'
|
|
29
|
-
export const TERMINAL_OFF = '\x1b[?1006l\x1b[?1003l\x1b[?1000l\x1b[?25h\x1b[?1049l'
|
|
30
|
-
|
|
31
27
|
/** One column short of the pane: a line filling the last column makes some terminals wrap it. */
|
|
32
28
|
export const paneWidth = (): number => (process.stdout.columns ?? (Number(process.env.COLUMNS) || DEFAULT_WIDTH)) - 1
|
|
33
29
|
|
|
@@ -62,21 +58,18 @@ const withErrors = (screen: Screen, errors: string[], width: number): Screen =>
|
|
|
62
58
|
footer: [...errors.map((error) => fit(error, width)), ...screen.footer],
|
|
63
59
|
})
|
|
64
60
|
|
|
61
|
+
/** What a drawing of a snapshot needs besides the snapshot: the selection, the error lines over the footer, the pane width and whether the shimmer runs. */
|
|
62
|
+
export type RenderContext = { selection: Selection; errors: string[]; width: number; shimmer?: boolean }
|
|
63
|
+
|
|
65
64
|
export const renderSnapshot = (
|
|
66
65
|
snapshot: Snapshot,
|
|
67
|
-
selection:
|
|
68
|
-
errors: string[],
|
|
69
|
-
width: number,
|
|
70
|
-
shimmer = false,
|
|
66
|
+
{ selection, errors, width, shimmer = false }: RenderContext,
|
|
71
67
|
): Screen => withErrors(renderSelected(overviewOf(snapshot, shimmer), selection, width), errors, width)
|
|
72
68
|
|
|
73
69
|
/** The snapshot drawn with no row focused (`unfocused`), the base a hover lays its focus on (`focusedScreen`). */
|
|
74
70
|
export const renderUnfocused = (
|
|
75
71
|
snapshot: Snapshot,
|
|
76
|
-
selection:
|
|
77
|
-
errors: string[],
|
|
78
|
-
width: number,
|
|
79
|
-
shimmer = false,
|
|
72
|
+
{ selection, errors, width, shimmer = false }: RenderContext,
|
|
80
73
|
): Screen => withErrors(renderScreen(overviewOf(snapshot, shimmer), unfocused(selection), width), errors, width)
|
|
81
74
|
|
|
82
75
|
/** The status of a lane's agent by the lane's name, for the footer's click hint. */
|
|
@@ -106,13 +99,6 @@ const wholeFrame = (lines: readonly string[], width: number, now: number): strin
|
|
|
106
99
|
return `${SYNC_BEGIN}${CURSOR_HOME}${drawn}${CLEAR_BELOW}${SYNC_END}`
|
|
107
100
|
}
|
|
108
101
|
|
|
109
|
-
/**
|
|
110
|
-
* The frame of `frameLinesOf` as written, a shimmering text with its stripe at `now` (epoch milliseconds). It stands
|
|
111
|
-
* between the marks of synchronized output, so a terminal that knows them never shows half of it.
|
|
112
|
-
*/
|
|
113
|
-
export const frameOutput = (screen: Screen, top: number, width: number, rows: number, now: number): string =>
|
|
114
|
-
wholeFrame(frameLinesOf(screen, top, rows), width, now)
|
|
115
|
-
|
|
116
102
|
/**
|
|
117
103
|
* What to write to turn the frame `previous` on the terminal into `lines`: the frame whole when the terminal content
|
|
118
104
|
* is unknown (null) or its height changed, else each changed line at its cursor address, nothing when none changed.
|
|
@@ -132,10 +118,6 @@ export const changedLinesOutput = (
|
|
|
132
118
|
return changed.length === 0 ? '' : `${SYNC_BEGIN}${changed.join('')}${SYNC_END}`
|
|
133
119
|
}
|
|
134
120
|
|
|
135
|
-
/** The lines of the frame `frameOutput` writes that shimmer, by their row on the terminal. */
|
|
136
|
-
export const frameShimmerRows = (screen: Screen, top: number, rows: number): ShimmerRow[] =>
|
|
137
|
-
shimmerRows(frameLinesOf(screen, top, rows))
|
|
138
|
-
|
|
139
121
|
/** The rows that shimmer, prepared once per frame: the pass they share, and whether a row is wider than the pane. */
|
|
140
122
|
export type PreparedShimmer = {
|
|
141
123
|
rows: readonly (ShimmerRow & { wide: boolean })[]
|
|
@@ -162,80 +144,6 @@ export const shimmerOutput = ({ rows, longest, width }: PreparedShimmer, now: nu
|
|
|
162
144
|
return `${SYNC_BEGIN}${drawn.join('')}${SYNC_END}`
|
|
163
145
|
}
|
|
164
146
|
|
|
165
|
-
/** A selection that focuses no row: the screen drawn with it is the base `focusedScreen` lays the focus on. */
|
|
166
|
-
export const unfocused = (selection: Selection): Selection => ({
|
|
167
|
-
...selection,
|
|
168
|
-
selected: NO_ROW,
|
|
169
|
-
hovered: null,
|
|
170
|
-
hoveredFrom: null,
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
const NO_ROW = '\0'
|
|
174
|
-
const LEAD = /^ */
|
|
175
|
-
|
|
176
|
-
/** The blanks a block puts before a row it holds (`indented`): the focus lies right of them, as narrow as the row. */
|
|
177
|
-
const leadOf = (line: string): number => LEAD.exec(line)?.[0].length ?? 0
|
|
178
|
-
|
|
179
|
-
/** `lines` with the lines of `hit` (counted from `offset`) changed by `change`, the first line told by its index. */
|
|
180
|
-
const changedHit = (
|
|
181
|
-
lines: readonly string[],
|
|
182
|
-
offset: number,
|
|
183
|
-
hit: FocusHit | undefined,
|
|
184
|
-
change: (line: string, index: number) => string,
|
|
185
|
-
): string[] =>
|
|
186
|
-
lines.map((line, at) => {
|
|
187
|
-
const index = hit ? at + offset - hit.line : -1
|
|
188
|
-
return hit && index >= 0 && index < hit.lines ? change(line, index) : line
|
|
189
|
-
})
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* `screen` with the focus `state` gives laid on one region of `lines`: the focused row on the focus background and
|
|
193
|
-
* mark, a line of links under the mouse marked alone, and the square-bracket button under the mouse bold.
|
|
194
|
-
*/
|
|
195
|
-
const focusedLines = (lines: readonly string[], offset: number, screen: Screen, state: Selection, width: number) => {
|
|
196
|
-
const hitOf = (key: string | null | undefined) => (key ? screen.focusHits.find((hit) => hit.key === key) : undefined)
|
|
197
|
-
const hovered = hitOf(state.hovered)
|
|
198
|
-
const focusedKey = state.selected !== null && isFocused(state, state.selected) ? state.selected : null
|
|
199
|
-
const focusedHit = hitOf(focusedKey)
|
|
200
|
-
const gutter = focusedHit ? leadOf(lines[focusedHit.line - offset] ?? '') : 0
|
|
201
|
-
const focused = changedHit(
|
|
202
|
-
lines,
|
|
203
|
-
offset,
|
|
204
|
-
focusedHit,
|
|
205
|
-
(line, index) => `${line.slice(0, gutter)}${focusLine(line.slice(gutter), width - gutter, index === 0)}`,
|
|
206
|
-
)
|
|
207
|
-
const links = state.hovered?.startsWith(LINKS_KEY_PREFIX)
|
|
208
|
-
? changedHit(focused, offset, hovered, (line, index) => (index === 0 ? focusLine(line, width, true) : line))
|
|
209
|
-
: focused
|
|
210
|
-
const from = state.hoveredFrom
|
|
211
|
-
return from == null
|
|
212
|
-
? links
|
|
213
|
-
: changedHit(links, offset, hovered, (line, index) => (index === 0 ? boldBracketAt(line, from) : line))
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* The screen a full drawing with `state` gives, laid on `plain` (drawn with `unfocused`) without a new layout: a hover
|
|
218
|
-
* only moves the focus. Null where it needs the layout: with the help open, or for a row of the blocks on top, whose
|
|
219
|
-
* buttons stand only at the selection. `agentOf` says a lane's agent, for the footer's click hint.
|
|
220
|
-
*/
|
|
221
|
-
export const focusedScreen = (plain: Screen, state: Selection, width: number, agentOf: AgentOf): Screen | null => {
|
|
222
|
-
const blocks = [plain.yourTurnBlock, plain.sourceBlock]
|
|
223
|
-
const isBlockRow = (key: string | null | undefined) => !!key && blockRowByKey(blocks, key) !== null
|
|
224
|
-
if (state.showLegend || isBlockRow(state.selected) || isBlockRow(state.hovered)) return null
|
|
225
|
-
const header = focusedLines(plain.header, 0, plain, state, width)
|
|
226
|
-
const lines = focusedLines(plain.body.lines, plain.header.length, plain, state, width)
|
|
227
|
-
const selectedHit = plain.focusHits.find(({ key }) => key === state.selected)
|
|
228
|
-
const cursorLine =
|
|
229
|
-
selectedHit && selectedHit.line >= plain.header.length ? selectedHit.line - plain.header.length : -1
|
|
230
|
-
const hint = clickHint(state, (key) => blockRowByKey(blocks, key), agentOf) ?? TEXTS.hint
|
|
231
|
-
return {
|
|
232
|
-
...plain,
|
|
233
|
-
header,
|
|
234
|
-
body: { ...plain.body, lines, cursorLine },
|
|
235
|
-
footer: [...plain.footer.slice(0, -1), fit(paint.dim(hint), width)],
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
147
|
const ARTIFACT_ADDRESS = new RegExp(ARTIFACT_LINK_KIND.pattern)
|
|
240
148
|
|
|
241
149
|
/**
|
|
@@ -8,7 +8,7 @@ import { MOUSE_BUTTON, type MouseEvent } from './selection'
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* One row of a block of the herd as drawn: its plain text lines (more than one while it is expanded; the first starts
|
|
11
|
-
* with `mark` and a blank and
|
|
11
|
+
* with `mark` and a blank and takes the whole pane; the buttons lie over its end only while the row is selected, `hot`), whether its text is wider than the pane and the row may expand (`cut`), whether it is expanded, and the address a
|
|
12
12
|
* click opens.
|
|
13
13
|
*/
|
|
14
14
|
export type BlockRow = {
|
|
@@ -28,6 +28,11 @@ export type BlockRow = {
|
|
|
28
28
|
toggle?: string
|
|
29
29
|
/** The row is dim from its text on; absent for most rows. */
|
|
30
30
|
dim?: boolean
|
|
31
|
+
/**
|
|
32
|
+
* The first line while the row shows its buttons (`buttonsOver`): the mark and the text cut to end a blank before the
|
|
33
|
+
* first button, which stands flush right at the edge; absent for a row without buttons.
|
|
34
|
+
*/
|
|
35
|
+
hot?: string
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
/**
|
|
@@ -98,8 +103,6 @@ export const waitingKey = ({ id, item }: WaitingItem): string => `waiting>${id ?
|
|
|
98
103
|
/** The cells the two blanks take that start every line of a block row (`blockBar` and the gap after it). */
|
|
99
104
|
export const BAR_WIDTH = 2
|
|
100
105
|
|
|
101
|
-
const roomOf = (width: number): number => Math.max(width - BAR_WIDTH, 1)
|
|
102
|
-
|
|
103
106
|
/** `[ask]` for what the coordinator can put, none for what it cannot. */
|
|
104
107
|
const askButtons = (ask: AskTarget | null): BlockButton[] => (ask ? [{ label: ASK_BUTTON, click: { ask } }] : [])
|
|
105
108
|
|
|
@@ -117,14 +120,22 @@ export const placedButtons = (buttons: readonly BlockButton[], room: number): Pl
|
|
|
117
120
|
})
|
|
118
121
|
}
|
|
119
122
|
|
|
120
|
-
/** The cells
|
|
121
|
-
export const
|
|
122
|
-
buttonCells === 0 ? roomOf(width) : Math.max(roomOf(width) - (buttonCells + ASK_GAP), 1)
|
|
123
|
+
/** The cells of text a block row has: the whole pane but the two blanks that start it. */
|
|
124
|
+
export const rowRoom = (width: number): number => Math.max(width - BAR_WIDTH, 1)
|
|
123
125
|
|
|
124
|
-
/**
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
/**
|
|
127
|
+
* The buttons flush right at the edge of a first line of `room` cells, laid over whatever stands there: the text keeps
|
|
128
|
+
* the whole line while the row is not chosen, and gives up its end (in "Your turn" the time too) to the buttons while
|
|
129
|
+
* it is. No buttons, no hot line.
|
|
130
|
+
*/
|
|
131
|
+
export const buttonsOver = (
|
|
132
|
+
text: string,
|
|
133
|
+
buttons: readonly BlockButton[],
|
|
134
|
+
room: number,
|
|
135
|
+
): { hot?: string; placed: PlacedButton[] } => {
|
|
136
|
+
if (buttons.length === 0) return { placed: [] }
|
|
137
|
+
const textCells = Math.max(room - buttonsWidth(buttons) - ASK_GAP, 1)
|
|
138
|
+
return { hot: padVisible(fit(text, textCells), textCells), placed: placedButtons(buttons, textCells) }
|
|
128
139
|
}
|
|
129
140
|
|
|
130
141
|
/** The labels of a row's buttons as drawn after its text, one blank apart, each painted with `paintLabel`. */
|
|
@@ -147,13 +158,12 @@ const MIN_WRAP_WIDTH = 12
|
|
|
147
158
|
* column of the text; one line cut at `room` when the lead leaves fewer than `MIN_WRAP_WIDTH` cells, so a wide lead never
|
|
148
159
|
* wraps the text one cell per line.
|
|
149
160
|
*/
|
|
150
|
-
const hangingLines = (start: string, text: string, room: number): string[] => {
|
|
161
|
+
export const hangingLines = (start: string, text: string, room: number): string[] => {
|
|
151
162
|
const hang = textWidth(start)
|
|
152
163
|
if (room - hang < MIN_WRAP_WIDTH) return [fit(`${start}${text}`, room)]
|
|
153
164
|
const [first, ...rest] = wrap(text, room - hang)
|
|
154
165
|
return [`${start}${first}`, ...rest.map((line) => `${' '.repeat(hang)}${line}`)]
|
|
155
166
|
}
|
|
156
|
-
|
|
157
167
|
/** What `[done]` closes: the record of a file or a question, else the row of the board; none for a record-less question. */
|
|
158
168
|
const doneOf = (item: WaitingItem): DoneTarget | null => {
|
|
159
169
|
if (item.id) return { kind: DONE_KIND.record, id: item.id }
|
|
@@ -174,15 +184,14 @@ export const waitingUrl = (item: WaitingItem): string | null =>
|
|
|
174
184
|
|
|
175
185
|
const rowOf = (item: BlockItem, expandedKeys: ReadonlySet<string>, width: number): BlockRow => {
|
|
176
186
|
const { key, mark, lead, text, url, ask, tone, toggle, dim, expands } = item
|
|
177
|
-
const
|
|
187
|
+
const room = rowRoom(width)
|
|
178
188
|
const leadText = lead === undefined ? '' : `${lead} `
|
|
179
189
|
const start = `${mark} ${leadText}`
|
|
180
190
|
const cut = expands !== false && textWidth(`${start}${text}`) > room
|
|
181
191
|
const expanded = cut && expandedKeys.has(key)
|
|
182
|
-
const
|
|
183
|
-
const lines
|
|
184
|
-
|
|
185
|
-
const dimFrom = parts.map((line, index) => {
|
|
192
|
+
const lines = expanded ? hangingLines(start, text, room) : [fit(`${start}${text}`, room)]
|
|
193
|
+
const { hot, placed } = buttonsOver(lines[0], askButtons(ask), room)
|
|
194
|
+
const dimFrom = lines.map((line, index) => {
|
|
186
195
|
if (!dim) return line.length
|
|
187
196
|
return index === 0 ? mark.length + 1 : 0
|
|
188
197
|
})
|
|
@@ -195,6 +204,7 @@ const rowOf = (item: BlockItem, expandedKeys: ReadonlySet<string>, width: number
|
|
|
195
204
|
url,
|
|
196
205
|
buttons: placed,
|
|
197
206
|
dimFrom,
|
|
207
|
+
...(hot && { hot }),
|
|
198
208
|
...(tone && { tone }),
|
|
199
209
|
...(toggle && { toggle }),
|
|
200
210
|
...(dim && { dim }),
|
|
@@ -15,7 +15,7 @@ import { belongsToLane } from './phase-source'
|
|
|
15
15
|
const workedIds = (lane: SprintLane, plans: PlansByHref, href: string): string[] => {
|
|
16
16
|
const named = lane.steps.filter((step) => step.planHref === href).flatMap((step) => step.phaseIds)
|
|
17
17
|
return (plans[href] ?? [])
|
|
18
|
-
.filter((phase) => phase.
|
|
18
|
+
.filter((phase) => phase.work && phase.items.some((item) => item.done) && !belongsToLane(phase.id, named))
|
|
19
19
|
.map((phase) => phase.id)
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync, statSync } from 'node:fs'
|
|
2
2
|
import { isAbsolute, join, resolve } from 'node:path'
|
|
3
|
+
import { stampOf } from '../shared/stat-cache'
|
|
3
4
|
|
|
4
5
|
const GIT_DIRECTORY_LINE = /^gitdir:\s*(.+)$/m
|
|
5
6
|
|
|
@@ -17,19 +18,21 @@ const gitDirectoryOf = (worktree: string): string => {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
const fileMark = (path: string): string => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return `${mtimeMs}:${size}`
|
|
23
|
-
} catch {
|
|
24
|
-
return '-'
|
|
25
|
-
}
|
|
21
|
+
const stamp = stampOf(path)
|
|
22
|
+
return stamp ? `${stamp.mtimeMs}:${stamp.size}` : '-'
|
|
26
23
|
}
|
|
27
24
|
|
|
28
25
|
/**
|
|
29
|
-
* What tells whether a worktree's `git status` may have changed without asking git: its index
|
|
30
|
-
* and
|
|
26
|
+
* What tells whether a worktree's `git status` and its commits may have changed without asking git: its index, HEAD
|
|
27
|
+
* and the reflog of HEAD (which every commit, reset and rebase grows) as stat-ed, and `activity` (`logMark` of its
|
|
28
|
+
* agent, its subagents' work, the base's commit). Stable for a worktree that does not exist.
|
|
31
29
|
*/
|
|
32
30
|
export const worktreeStamp = (worktree: string, activity: string): string => {
|
|
33
31
|
const gitDirectory = gitDirectoryOf(worktree)
|
|
34
|
-
return [
|
|
32
|
+
return [
|
|
33
|
+
fileMark(join(gitDirectory, 'index')),
|
|
34
|
+
fileMark(join(gitDirectory, 'HEAD')),
|
|
35
|
+
fileMark(join(gitDirectory, 'logs', 'HEAD')),
|
|
36
|
+
activity,
|
|
37
|
+
].join('|')
|
|
35
38
|
}
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { WAITING_KIND } from '../plan-format/waiting'
|
|
2
|
-
import { fit,
|
|
2
|
+
import { fit, spread } from '../terminal/text'
|
|
3
3
|
import { textWidth } from '../terminal/width'
|
|
4
4
|
import { appointmentButtons } from './appointment-rows'
|
|
5
5
|
import { chevronOf, TURN_MARK } from './symbols'
|
|
6
6
|
import {
|
|
7
|
-
ASK_GAP,
|
|
8
7
|
type BlockButton,
|
|
9
8
|
type BlockRow,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
buttonsOver,
|
|
10
|
+
hangingLines,
|
|
11
|
+
rowRoom,
|
|
13
12
|
waitingButtons,
|
|
14
13
|
waitingUrl,
|
|
15
14
|
} from './waiting-click'
|
|
@@ -33,31 +32,35 @@ const buttonsOf = (row: TurnRow): BlockButton[] => {
|
|
|
33
32
|
return row.waiting ? waitingButtons(row.waiting) : []
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
/**
|
|
37
|
-
const
|
|
38
|
-
const textCells = Math.max(room - textWidth(row.time) - TIME_GAP, 1)
|
|
39
|
-
return spread(fit(`${mark} ${row.text}`, textCells), row.time, room)
|
|
40
|
-
}
|
|
35
|
+
/** What stands right of the text: the gap and the time, flush right; nothing for a row without a time. */
|
|
36
|
+
const timeColumn = (time: string): string => (time ? `${' '.repeat(TIME_GAP)}${time}` : '')
|
|
41
37
|
|
|
42
38
|
/**
|
|
43
|
-
* A topic as a block row: the mark
|
|
44
|
-
*
|
|
45
|
-
*
|
|
39
|
+
* A topic as a block row: the mark and the text over the whole pane but the time, which stands flush right at its
|
|
40
|
+
* edge (`room`), so the times end in one column; under a row that frees lanes the dim line `unblocks …`. A text cut
|
|
41
|
+
* off at the time is `cut`, and once its key is in `expandedKeys` it hangs under its first line, the time staying on
|
|
42
|
+
* that one. The chosen row lays its buttons flush right at the edge, over the end of the text and the time (`buttonsOver`).
|
|
46
43
|
*/
|
|
47
|
-
const rowOf = (row: TurnRow, room: number): BlockRow => {
|
|
44
|
+
const rowOf = (row: TurnRow, room: number, expandedKeys: ReadonlySet<string>): BlockRow => {
|
|
48
45
|
const mark = markOf(row)
|
|
49
|
-
const
|
|
46
|
+
const start = `${mark} `
|
|
47
|
+
const textCells = Math.max(room - textWidth(timeColumn(row.time)), 1)
|
|
48
|
+
const cut = textWidth(`${start}${row.text}`) > textCells
|
|
49
|
+
const expanded = cut && expandedKeys.has(row.key)
|
|
50
|
+
const [head = '', ...hanging] = expanded ? hangingLines(start, row.text, textCells) : [`${start}${row.text}`]
|
|
51
|
+
const first = spread(fit(head, textCells), row.time, room)
|
|
50
52
|
const unblocks = row.unblocks.length > 0 ? [fit(`${UNBLOCKS_INDENT}unblocks ${row.unblocks.join(', ')}`, room)] : []
|
|
51
|
-
const
|
|
53
|
+
const { hot, placed } = buttonsOver(head, buttonsOf(row), room)
|
|
52
54
|
return {
|
|
53
55
|
key: row.key,
|
|
54
56
|
mark,
|
|
55
|
-
lines: [
|
|
56
|
-
cut
|
|
57
|
-
expanded
|
|
57
|
+
lines: [first, ...hanging, ...unblocks],
|
|
58
|
+
cut,
|
|
59
|
+
expanded,
|
|
58
60
|
url: row.waiting ? waitingUrl(row.waiting) : null,
|
|
59
|
-
buttons:
|
|
60
|
-
dimFrom: [first.length - row.time.length, ...unblocks.map(() => 0)],
|
|
61
|
+
buttons: placed,
|
|
62
|
+
dimFrom: [first.length - row.time.length, ...hanging.map((line) => line.length), ...unblocks.map(() => 0)],
|
|
63
|
+
...(hot && { hot }),
|
|
61
64
|
}
|
|
62
65
|
}
|
|
63
66
|
|
|
@@ -80,23 +83,21 @@ const toggleRow = (key: string, text: string, open: boolean, room: number): Bloc
|
|
|
80
83
|
const moreText = ({ count, older }: { count: number; older: number }): string =>
|
|
81
84
|
older > 0 ? `${count} more · ${older} older than ${OLD_DAYS} days` : `${count} more`
|
|
82
85
|
|
|
83
|
-
/** The cells of text every row of the block may take: what the widest set of buttons leaves of the pane. */
|
|
84
|
-
const sharedRoom = (rows: readonly TurnRow[], width: number): number =>
|
|
85
|
-
textRoom(width, Math.max(0, ...rows.map((row) => buttonsWidth(buttonsOf(row)))))
|
|
86
|
-
|
|
87
86
|
/**
|
|
88
87
|
* The rows of the open "Your turn" block: the now rows, the line for the rest of them, the planned rows and the line
|
|
89
|
-
* for the later ones; an opened line points down at the rest of its rows, which stand below it.
|
|
88
|
+
* for the later ones; an opened line points down at the rest of its rows, which stand below it. `expandedKeys` names
|
|
89
|
+
* the rows cut off at the edge that show their whole text.
|
|
90
90
|
*/
|
|
91
|
-
export const turnRows = (page: TurnPage, width: number): BlockRow[] => {
|
|
91
|
+
export const turnRows = (page: TurnPage, width: number, expandedKeys: ReadonlySet<string> = new Set()): BlockRow[] => {
|
|
92
92
|
const { opened } = page
|
|
93
|
-
const room =
|
|
93
|
+
const room = rowRoom(width)
|
|
94
|
+
const rowsOf = (rows: readonly TurnRow[]) => rows.map((row) => rowOf(row, room, expandedKeys))
|
|
94
95
|
return [
|
|
95
|
-
...page.now
|
|
96
|
+
...rowsOf(page.now),
|
|
96
97
|
...(page.nowMore ? [toggleRow(YOUR_TURN_MORE_KEY, moreText(page.nowMore), opened.allNow, room)] : []),
|
|
97
|
-
...page.nowRest
|
|
98
|
-
...page.planned
|
|
98
|
+
...rowsOf(page.nowRest),
|
|
99
|
+
...rowsOf(page.planned),
|
|
99
100
|
...(page.later > 0 ? [toggleRow(YOUR_TURN_LATER_KEY, `${page.later} later`, opened.allPlanned, room)] : []),
|
|
100
|
-
...page.plannedRest
|
|
101
|
+
...rowsOf(page.plannedRest),
|
|
101
102
|
]
|
|
102
103
|
}
|
package/src/plans/your-turn.ts
CHANGED
|
@@ -4,8 +4,7 @@ import type { PlanLanguage } from '../vocabulary'
|
|
|
4
4
|
import { parseAppointmentTime } from '../watch/appointments'
|
|
5
5
|
import { type AppointmentRow, appointmentRows, TIME_OPEN } from './appointment-rows'
|
|
6
6
|
import { calendarDaysBetween } from './calendar-days'
|
|
7
|
-
import { plain } from './
|
|
8
|
-
import { nextLines } from './next-rows'
|
|
7
|
+
import { plain } from './markdown-text'
|
|
9
8
|
import { shortTitle } from './task-name'
|
|
10
9
|
import { waitingKey } from './waiting-click'
|
|
11
10
|
|
|
@@ -173,7 +172,10 @@ export const yourTurnOf = ({ waiting, appointments, next, now }: TurnSources): Y
|
|
|
173
172
|
return { now: [...nowRows].sort(byBlockingThenAge), planned: [...planned, ...nextRows].sort(byTime) }
|
|
174
173
|
}
|
|
175
174
|
|
|
176
|
-
/**
|
|
175
|
+
/**
|
|
176
|
+
* "Your turn" of a board: `waiting` (its open rows, the questions and the files) and its open appointments, only what
|
|
177
|
+
* waits on the user. The board's next lines stay out: they carry no action and repeat what waits.
|
|
178
|
+
*/
|
|
177
179
|
export const yourTurnOfBoard = (
|
|
178
180
|
board: string,
|
|
179
181
|
language: PlanLanguage,
|
|
@@ -182,7 +184,7 @@ export const yourTurnOfBoard = (
|
|
|
182
184
|
): YourTurnBlock => {
|
|
183
185
|
const clock = new Date(now)
|
|
184
186
|
const appointments = appointmentRows(board, language, clock)
|
|
185
|
-
return { turn: yourTurnOf({ waiting, appointments, next:
|
|
187
|
+
return { turn: yourTurnOf({ waiting, appointments, next: [], now: clock }), now }
|
|
186
188
|
}
|
|
187
189
|
|
|
188
190
|
const isOld = (row: TurnRow, now: Date): boolean =>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** What was computed per key, kept while a herd runs; a set while it holds `limit` keys, a known one too, starts it over. */
|
|
2
|
+
export type BoundedMemo<K, V> = {
|
|
3
|
+
/** The value kept for `key`, undefined when none. */
|
|
4
|
+
get: (key: K) => V | undefined
|
|
5
|
+
/** Keeps `value` for `key` and answers it. */
|
|
6
|
+
set: (key: K, value: V) => V
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const boundedMemo = <K, V>(limit: number): BoundedMemo<K, V> => {
|
|
10
|
+
const kept = new Map<K, V>()
|
|
11
|
+
return {
|
|
12
|
+
get: (key) => kept.get(key),
|
|
13
|
+
set: (key, value) => {
|
|
14
|
+
if (kept.size >= limit) kept.clear()
|
|
15
|
+
kept.set(key, value)
|
|
16
|
+
return value
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/shared/stat-cache.ts
CHANGED
|
@@ -22,7 +22,8 @@ export const dropUnused = (cache: Map<string, unknown>, used: ReadonlySet<string
|
|
|
22
22
|
for (const key of [...cache.keys()]) if (!used.has(key)) cache.delete(key)
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
/** The stamp of the file at `path`, null when it cannot be stat-ed. */
|
|
26
|
+
export const stampOf = (path: string): FileStamp | null => {
|
|
26
27
|
try {
|
|
27
28
|
const { mtimeMs, size } = statSync(path)
|
|
28
29
|
return { mtimeMs, size }
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { writeSync } from 'node:fs'
|
|
2
|
+
import { onceRestore } from './restore'
|
|
3
|
+
|
|
4
|
+
export const TERMINAL_ON = '\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1003h\x1b[?1006h'
|
|
5
|
+
export const TERMINAL_OFF = '\x1b[?1006l\x1b[?1003l\x1b[?1000l\x1b[?25h\x1b[?1049l'
|
|
6
|
+
|
|
7
|
+
const STDOUT_FD = 1
|
|
8
|
+
const STDERR_FD = 2
|
|
9
|
+
|
|
10
|
+
/** How the view ends: `quit` and `crash` leave, `restore` gives the terminal back and runs on `exit` as well. */
|
|
11
|
+
export type TerminalEnds = { quit: () => void; crash: (error: unknown) => void; restore: () => void }
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The terminal given back on every end, written synchronously because `exit` waits for nothing: mouse reports and
|
|
15
|
+
* alternate screen off, cursor shown, raw mode released, once (`onceRestore`).
|
|
16
|
+
*/
|
|
17
|
+
export const terminalRestore = (): (() => void) =>
|
|
18
|
+
onceRestore(TERMINAL_OFF, {
|
|
19
|
+
write: (text) => writeSync(STDOUT_FD, text),
|
|
20
|
+
releaseRaw: () => {
|
|
21
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Alternate screen, hidden cursor, mouse reports and raw keys, each chunk read handed to `onData`. A signal quits, an
|
|
27
|
+
* error nobody caught crashes (a closed terminal's `EIO` on stdin among them), and `exit` restores the terminal
|
|
28
|
+
* whatever ended the process.
|
|
29
|
+
*/
|
|
30
|
+
export const enterTerminal = (onData: (chunk: string) => void, ends: TerminalEnds, onResize: () => void) => {
|
|
31
|
+
process.stdout.write(TERMINAL_ON)
|
|
32
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) process.on(signal, ends.quit)
|
|
33
|
+
process.on('exit', ends.restore)
|
|
34
|
+
process.on('uncaughtException', ends.crash)
|
|
35
|
+
process.on('unhandledRejection', ends.crash)
|
|
36
|
+
process.stdout.on('resize', onResize)
|
|
37
|
+
if (!process.stdin.isTTY) return
|
|
38
|
+
process.stdin.setRawMode(true)
|
|
39
|
+
process.stdin.on('error', ends.crash)
|
|
40
|
+
process.stdin.on('data', (chunk) => onData(chunk.toString()))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The terminal restored first, so the report lands in the shell and not on the alternate screen; null reports nothing. */
|
|
44
|
+
export const reportCrash = (report: string | null, restore: () => void) => {
|
|
45
|
+
restore()
|
|
46
|
+
if (report === null) return
|
|
47
|
+
try {
|
|
48
|
+
writeSync(STDERR_FD, `${report}\n`)
|
|
49
|
+
} catch {
|
|
50
|
+
// stderr went away with the terminal; the exit code still tells.
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/watch/command.ts
CHANGED
|
@@ -88,6 +88,7 @@ import { type FeedbackItem } from './feedback-item'
|
|
|
88
88
|
import { type FeedbackItemsRead, readCommentItems, readFeedbackItems, readOwnLogin } from './feedback-read'
|
|
89
89
|
import { answered, deployTargetOf, type GithubRead, readGithub, targetText } from './github-read'
|
|
90
90
|
import { currentPhaseCountsOf } from './heartbeat-progress'
|
|
91
|
+
import { startHerdPublisher } from './herd-publisher'
|
|
91
92
|
import { agentScreen, listAgents } from './herdr'
|
|
92
93
|
import { endedByKitUpdate } from './kit-update'
|
|
93
94
|
import { type LaneReadings, laneFillEvents, recordLaneLevels } from './lane-fill'
|
|
@@ -98,7 +99,7 @@ import { boardCheckoutRows, watchedLaneParallel, watchedLaneProgress, watchedPla
|
|
|
98
99
|
import {
|
|
99
100
|
appointmentEventLine,
|
|
100
101
|
approvalDutyLines,
|
|
101
|
-
|
|
102
|
+
armNotice,
|
|
102
103
|
blockedDutyLines,
|
|
103
104
|
coordinatorStatusLine,
|
|
104
105
|
feedbackEventLines,
|
|
@@ -171,7 +172,7 @@ import { sourceDryRun } from './source-dry-run-command'
|
|
|
171
172
|
import { runSource, SOURCE_LIMITS } from './source-run'
|
|
172
173
|
import { recordWatchedSources } from './source-tickets'
|
|
173
174
|
import { isSourceDue, type SourceRun, type SourceSettings, type SourceState, sourceEvents } from './sources'
|
|
174
|
-
import { streamHeartbeatLine } from './stream-output'
|
|
175
|
+
import { heartbeatStateOf, isHeartbeatNews, streamHeartbeatLine } from './stream-output'
|
|
175
176
|
import {
|
|
176
177
|
formatTaskParallelLine,
|
|
177
178
|
TASK_PARALLEL_EVENT,
|
|
@@ -1161,10 +1162,11 @@ const subagentCountsOf = (watch: Watch, now: number): Record<string, number> =>
|
|
|
1161
1162
|
|
|
1162
1163
|
/**
|
|
1163
1164
|
* The heartbeat of a stream: one line, a working lane with its phase count at every heartbeat and the subagents running
|
|
1164
|
-
* in its worktree
|
|
1165
|
-
* count, and the next report names the failure.
|
|
1165
|
+
* in its worktree, printed only when its state changed since the last one sent. The plans are read only when a lane
|
|
1166
|
+
* works; a board that cannot be read leaves the lines without a count, and the next report names the failure. Returns
|
|
1167
|
+
* the state of the heartbeat it printed, null when it printed none.
|
|
1166
1168
|
*/
|
|
1167
|
-
const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: number): Promise<
|
|
1169
|
+
const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: number): Promise<string | null> => {
|
|
1168
1170
|
const working = workingLanes(watching)
|
|
1169
1171
|
const progress = await workingProgressOf(watch, watching)
|
|
1170
1172
|
const counts = currentPhaseCountsOf(progress)
|
|
@@ -1176,13 +1178,16 @@ const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: numbe
|
|
|
1176
1178
|
}),
|
|
1177
1179
|
)
|
|
1178
1180
|
const standing = [waitingLineOf(watch), mainCheckoutLineOf(watch)].filter((line) => line !== null)
|
|
1179
|
-
|
|
1181
|
+
const line = streamHeartbeatLine(clock(now), watching.lanes, watching.current, notes, rests, standing)
|
|
1182
|
+
const sent = isHeartbeatNews(watching.state.heartbeat, line) ? heartbeatStateOf(line) : null
|
|
1183
|
+
if (sent !== null) console.log(line)
|
|
1180
1184
|
const events = [
|
|
1181
1185
|
...taskParallelEventsOf(watch, watching, now),
|
|
1182
1186
|
...noTaskSignalEventsOf(watch, watching, progress, now),
|
|
1183
1187
|
...unlinkedArtifactEventsOf(watch, watching),
|
|
1184
1188
|
]
|
|
1185
1189
|
for (const event of events) console.log(`${watchEventLine(event, null)}${hintFor(watch, eventReference(event))}`)
|
|
1190
|
+
return sent
|
|
1186
1191
|
}
|
|
1187
1192
|
|
|
1188
1193
|
/**
|
|
@@ -1235,8 +1240,8 @@ const pollOnce = async (watch: Watch, watching: Watching): Promise<boolean> => {
|
|
|
1235
1240
|
})
|
|
1236
1241
|
} else {
|
|
1237
1242
|
if (heartbeat) {
|
|
1238
|
-
await printStreamHeartbeat(watch, watching, now)
|
|
1239
|
-
next = recordedOf(watch, watching)
|
|
1243
|
+
const sent = await printStreamHeartbeat(watch, watching, now)
|
|
1244
|
+
next = { ...recordedOf(watch, watching), ...(sent !== null && { heartbeat: sent }) }
|
|
1240
1245
|
}
|
|
1241
1246
|
if (due) writePrivateFile(watch.files.state, JSON.stringify(next))
|
|
1242
1247
|
watching.baseline = advanceBaseline(watching.baseline, watching.current)
|
|
@@ -1299,10 +1304,11 @@ const watchLanes = async (watch: Watch) => {
|
|
|
1299
1304
|
const sprintLanes = watch.options.lanes ? [] : sprintLaneNames(watch, [])
|
|
1300
1305
|
const lanes = lanesFor(watch, agents, sprintLanes)
|
|
1301
1306
|
const armed = toSnapshot(agents, lanes)
|
|
1302
|
-
|
|
1307
|
+
const arm = armNotice(state.armed, lanes, armed, takeOver, relative(watch.root, watch.sprintFile))
|
|
1308
|
+
if (arm.line !== null) console.log(arm.line)
|
|
1303
1309
|
if (watch.boardNotice) console.log(watch.boardNotice)
|
|
1304
1310
|
|
|
1305
|
-
const watching = initialWatching(watch, { lanes, sprintLanes, state, armed, agents })
|
|
1311
|
+
const watching = initialWatching(watch, { lanes, sprintLanes, state: { ...state, armed: arm.state }, armed, agents })
|
|
1306
1312
|
keepViews(watch, watching, { kind: ENSURE_TRIGGER.start })
|
|
1307
1313
|
process.on('SIGTERM', () => {
|
|
1308
1314
|
saveState(watch, watching)
|
|
@@ -1311,6 +1317,10 @@ const watchLanes = async (watch: Watch) => {
|
|
|
1311
1317
|
})
|
|
1312
1318
|
|
|
1313
1319
|
const kit = await startedKit()
|
|
1320
|
+
const stopPublisher =
|
|
1321
|
+
kit && watch.options.stream
|
|
1322
|
+
? startHerdPublisher({ root: watch.root, sprintFile: watch.sprintFile, settings: watch.settings }, kit)
|
|
1323
|
+
: null
|
|
1314
1324
|
const skillSnapshot = kit ? readOrchestrateSnapshot(kit.packageRoot) : null
|
|
1315
1325
|
const skillLine = () => skillLineFor(kit, skillSnapshot, dirname(watch.files.state))
|
|
1316
1326
|
const announceUpdate = kit
|
|
@@ -1330,6 +1340,7 @@ const watchLanes = async (watch: Watch) => {
|
|
|
1330
1340
|
}
|
|
1331
1341
|
} finally {
|
|
1332
1342
|
sourceChanges?.close()
|
|
1343
|
+
stopPublisher?.()
|
|
1333
1344
|
}
|
|
1334
1345
|
}
|
|
1335
1346
|
|