jackherd 1.12.1 → 1.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +16 -16
  3. package/dist/plan-format.d.ts +23 -11
  4. package/dist/plan-format.js +6 -3
  5. package/herdr-plugin/herdr-plugin.toml +1 -1
  6. package/install.sh +1 -1
  7. package/kit/hooks/hooks/merge-guard/decision.ts +35 -2
  8. package/kit/workflow/skills/orchestrate/SKILL.md +7 -1
  9. package/kit/workflow/skills/orchestrate/references/events.md +4 -2
  10. package/package.json +1 -1
  11. package/src/check/skill-budget-defaults.ts +6 -3
  12. package/src/context/session-log.ts +4 -6
  13. package/src/plan-format/phases.ts +22 -6
  14. package/src/plan-format/progress.ts +5 -8
  15. package/src/plans/command.ts +10 -3
  16. package/src/plans/current-task.ts +3 -7
  17. package/src/plans/focus-overlay.ts +80 -0
  18. package/src/plans/git.ts +6 -11
  19. package/src/plans/herd-state.ts +201 -0
  20. package/src/plans/lane-reason.ts +1 -1
  21. package/src/plans/lane-summary.ts +6 -2
  22. package/src/plans/layout-phase.ts +1 -3
  23. package/src/plans/layout-summary.ts +1 -1
  24. package/src/plans/layout.ts +32 -31
  25. package/src/plans/link-chips.ts +7 -16
  26. package/src/plans/live.ts +215 -267
  27. package/src/plans/markdown-text.ts +4 -0
  28. package/src/plans/page-sync.ts +39 -0
  29. package/src/plans/phase-source.ts +5 -2
  30. package/src/plans/reader.ts +217 -0
  31. package/src/plans/refresh-schedule.ts +53 -0
  32. package/src/plans/snapshot.ts +32 -31
  33. package/src/plans/status-pace.ts +7 -4
  34. package/src/plans/subagents-read.ts +28 -12
  35. package/src/plans/supervisor.ts +1 -1
  36. package/src/plans/view.ts +9 -101
  37. package/src/plans/waiting-click.ts +27 -17
  38. package/src/plans/worked-phases.ts +1 -1
  39. package/src/plans/worktree-stamp.ts +12 -9
  40. package/src/plans/your-turn-rows.ts +33 -32
  41. package/src/plans/your-turn.ts +6 -4
  42. package/src/shared/bounded-memo.ts +19 -0
  43. package/src/shared/stat-cache.ts +2 -1
  44. package/src/task-signals/command.ts +12 -3
  45. package/src/terminal/lifetime.ts +52 -0
  46. package/src/watch/command.ts +6 -0
  47. package/src/watch/herd-publisher.ts +84 -0
  48. package/src/watch/source-entries.ts +7 -0
  49. package/src/watch/source-statuses.ts +2 -2
  50. package/src/watch/sources.ts +5 -2
  51. 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, paint, plainText, visibleLength } from '../terminal/text'
4
- import { type AgentOf, clickHint } from './click-hint'
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, shimmerRows } from './shimmer'
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: 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: 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, for a row with `[ask]`, is padded to leave the button its cells), whether its text is wider than the pane and the row may expand (`cut`), whether it is expanded, and the address a
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 a row's text may take beside `buttonCells` of buttons (0 for none) and the blank before them. */
121
- export const textRoom = (width: number, buttonCells: number): number =>
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
- /** The cells a row's text may take and the buttons with the screen columns they start in; none for a row without one. */
125
- const buttonLayout = (items: readonly BlockButton[], width: number): { room: number; placed: PlacedButton[] } => {
126
- const room = textRoom(width, items.length === 0 ? 0 : buttonsWidth(items))
127
- return { room, placed: placedButtons(items, room) }
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 { room, placed } = buttonLayout(askButtons(ask), width)
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 parts = expanded ? hangingLines(start, text, room) : [fit(`${start}${text}`, room)]
183
- const lines =
184
- placed.length > 0 ? parts.map((line, index) => (index === 0 ? padVisible(line, room + ASK_GAP) : line)) : parts
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.touched && phase.items.some((item) => item.done) && !belongsToLane(phase.id, named))
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
- try {
21
- const { mtimeMs, size } = statSync(path)
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 and HEAD as stat-ed,
30
- * and `activity` (`logMark` of its agent, its subagents' work). Stable for a worktree that does not exist.
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 [fileMark(join(gitDirectory, 'index')), fileMark(join(gitDirectory, 'HEAD')), activity].join('|')
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, padVisible, spread } from '../terminal/text'
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
- buttonsWidth,
11
- placedButtons,
12
- textRoom,
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
- /** The text cut to leave the time its column, the time flush right at `room`. */
37
- const firstLineOf = (mark: string, row: TurnRow, room: number): string => {
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, the text and the time flush right at the edge of the text room, which every row
44
- * of the block shares (`room`), so the times end in one column before the buttons; under a row that frees lanes the
45
- * dim line `unblocks …`. The buttons keep their columns whether drawn or not; the block draws them at the focused row.
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 first = firstLineOf(mark, row, room)
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 buttons = buttonsOf(row)
53
+ const { hot, placed } = buttonsOver(head, buttonsOf(row), room)
52
54
  return {
53
55
  key: row.key,
54
56
  mark,
55
- lines: [buttons.length > 0 ? padVisible(first, room + ASK_GAP) : first, ...unblocks],
56
- cut: false,
57
- expanded: false,
57
+ lines: [first, ...hanging, ...unblocks],
58
+ cut,
59
+ expanded,
58
60
  url: row.waiting ? waitingUrl(row.waiting) : null,
59
- buttons: placedButtons(buttons, room),
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 = sharedRoom([...page.now, ...page.nowRest, ...page.planned, ...page.plannedRest], width)
93
+ const room = rowRoom(width)
94
+ const rowsOf = (rows: readonly TurnRow[]) => rows.map((row) => rowOf(row, room, expandedKeys))
94
95
  return [
95
- ...page.now.map((row) => rowOf(row, room)),
96
+ ...rowsOf(page.now),
96
97
  ...(page.nowMore ? [toggleRow(YOUR_TURN_MORE_KEY, moreText(page.nowMore), opened.allNow, room)] : []),
97
- ...page.nowRest.map((row) => rowOf(row, room)),
98
- ...page.planned.map((row) => rowOf(row, room)),
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.map((row) => rowOf(row, room)),
101
+ ...rowsOf(page.plannedRest),
101
102
  ]
102
103
  }
@@ -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 './layout-phase'
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
- /** "Your turn" of a board: `waiting` (its open rows, the questions and the files), its open appointments and next lines. */
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: nextLines(board, language), now: clock }), now }
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
+ }
@@ -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
- const stampOf = (path: string): FileStamp | null => {
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 }
@@ -19,13 +19,22 @@ export type TaskCommand =
19
19
  | { kind: 'help' }
20
20
  | { kind: 'usage' }
21
21
 
22
+ const TASK_VERB = { start: 'start', done: 'done' } as const
23
+ const HELP_OPTIONS: ReadonlySet<string | undefined> = new Set(['--help', '-h'])
24
+
25
+ const isTaskVerb = (name: string | undefined): boolean => name === TASK_VERB.start || name === TASK_VERB.done
26
+
27
+ /** `--help` or `-h` alone, or as the only word after `start` or `done`; among other words it is task text. */
28
+ const asksForHelp = (name: string | undefined, words: readonly string[]): boolean =>
29
+ HELP_OPTIONS.has(name) || (isTaskVerb(name) && words.length === 1 && HELP_OPTIONS.has(words[0]))
30
+
22
31
  /** The command line as a command: the words after `start` or `done` are the task text, whether quoted or not. */
23
32
  export const parseTaskCommand = (args: readonly string[]): TaskCommand => {
24
33
  const [name, ...words] = args
25
- if (name === '--help' || name === '-h') return { kind: 'help' }
34
+ if (asksForHelp(name, words)) return { kind: 'help' }
26
35
  const text = cleanTaskText(words.join(' '))
27
- if (name === 'start' && text !== '') return { kind: 'start', text }
28
- if (name === 'done') return { kind: 'done', text }
36
+ if (name === TASK_VERB.start && text !== '') return { kind: 'start', text }
37
+ if (name === TASK_VERB.done) return { kind: 'done', text }
29
38
  return { kind: 'usage' }
30
39
  }
31
40
 
@@ -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
+ }
@@ -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'
@@ -1311,6 +1312,10 @@ const watchLanes = async (watch: Watch) => {
1311
1312
  })
1312
1313
 
1313
1314
  const kit = await startedKit()
1315
+ const stopPublisher =
1316
+ kit && watch.options.stream
1317
+ ? startHerdPublisher({ root: watch.root, sprintFile: watch.sprintFile, settings: watch.settings }, kit)
1318
+ : null
1314
1319
  const skillSnapshot = kit ? readOrchestrateSnapshot(kit.packageRoot) : null
1315
1320
  const skillLine = () => skillLineFor(kit, skillSnapshot, dirname(watch.files.state))
1316
1321
  const announceUpdate = kit
@@ -1330,6 +1335,7 @@ const watchLanes = async (watch: Watch) => {
1330
1335
  }
1331
1336
  } finally {
1332
1337
  sourceChanges?.close()
1338
+ stopPublisher?.()
1333
1339
  }
1334
1340
  }
1335
1341