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/live.ts CHANGED
@@ -1,34 +1,36 @@
1
- import { existsSync, type FSWatcher, statSync, watch, writeSync } from 'node:fs'
1
+ import { existsSync, type FSWatcher, watch } from 'node:fs'
2
2
  import { basename, dirname } from 'node:path'
3
3
  import { pathToFileURL } from 'node:url'
4
4
  import { terminalSafe } from '../plan-format/terminal-text'
5
5
  import { runHerdr } from '../terminal/herdr'
6
+ import { enterTerminal, reportCrash, terminalRestore } from '../terminal/lifetime'
6
7
  import { openInBrowser } from '../terminal/open'
7
- import { crashEnd, onceRestore } from '../terminal/restore'
8
+ import { crashEnd } from '../terminal/restore'
8
9
  import { closeWaiting } from '../waiting/command'
10
+ import { isWatcherRunning } from '../watch/watcher-lock'
9
11
  import { askCoordinator } from './ask-coordinator'
10
- import type { LaneMemory } from './current-phase'
11
12
  import { errorText } from './error-text'
13
+ import { focusedScreen } from './focus-overlay'
14
+ import { noPullRequests } from './github'
12
15
  import {
13
- type FetchState,
14
- fetchBase,
15
- fetchNotice,
16
- fetchPlanHeads,
17
- goneLanePullRequests,
18
- initialFetchState,
19
- shouldFetch,
20
- watchedFiles,
21
- } from './fetch'
22
- import { fetchHeadPath } from './git'
23
- import { fetchPullRequests, noPullRequests, type PullRequestState } from './github'
24
- import { recallLanes, rememberLanes } from './herd-memory'
16
+ HERD_FILES,
17
+ type HerdPlans,
18
+ type HerdRead,
19
+ herdFilesOf,
20
+ isPublishedLive,
21
+ laneRead,
22
+ publishedRead,
23
+ readHerdBeat,
24
+ readHerdPlans,
25
+ readHerdRead,
26
+ } from './herd-state'
25
27
  import { type Screen, settledSelection } from './layout'
26
- import { type KnownPage, pageAfterWrite, pageKey, pageNeedsWriting, planAnchorOn, withPlanChips } from './plan-chips'
27
- import { planPullRequests } from './plan-heads'
28
+ import { initialPageState, type PageState, syncSprintPage } from './page-sync'
29
+ import { pageKey, pageNeedsWriting, planAnchorOn, withPlanChips } from './plan-chips'
28
30
  import { herdLabel } from './plan-panes'
29
31
  import { pullRequestUrl } from './pull-requests'
30
- import { newReadCache, type ReadCache } from './read-cache'
31
- import { quietRoundsAfter, REFRESH_PACE, refreshDelay } from './refresh-pace'
32
+ import { type Reader, type ReadOutcome, startReader } from './reader'
33
+ import { REFRESH_PACE } from './refresh-pace'
32
34
  import { TAKEN_OVER_CODE, TAKEOVER_SIGNAL } from './restart-decision'
33
35
  import { closeRow } from './row-actions'
34
36
  import { blockRowByKey, type HoverScreen, hoveredSelection, rehovered, withoutHover } from './row-focus'
@@ -51,64 +53,56 @@ import {
51
53
  windowTop,
52
54
  withoutStaleMotions,
53
55
  } from './selection'
54
- import { BASE_REMOTE, baseRefOf, boardRepoPathOf, CLOCK_LOCALE } from './settings'
56
+ import { boardRepoPathOf, CLOCK_LOCALE } from './settings'
55
57
  import { SHIMMER, shimmerEnabled, shimmerRows } from './shimmer'
56
- import { singleFlight } from './single-flight'
57
58
  import { loadSnapshot, type Snapshot, type Source } from './snapshot'
58
- import { exportSprintPage, writeSprintPageInSteps } from './sprint-export'
59
+ import { exportSprintPage } from './sprint-export'
59
60
  import { boardTexts } from './texts'
60
61
  import {
61
62
  agentOfSnapshot,
62
63
  bodyRows,
63
64
  changedLinesOutput,
64
65
  clockOf,
65
- focusedScreen,
66
66
  frameLinesOf,
67
67
  nextFrameDelay,
68
68
  type PreparedShimmer,
69
69
  paneWidth,
70
70
  preparedShimmer,
71
+ type RenderContext,
71
72
  renderSnapshot,
72
73
  renderUnfocused,
73
74
  shimmerOutput,
74
75
  startSelection,
75
- TERMINAL_OFF,
76
- TERMINAL_ON,
77
76
  } from './view'
78
77
  import { type BlockClick, type BlockHit, blockClick, blockHeadClick, mainClickOf } from './waiting-click'
79
- import { isOwnFetchWrite, watchRefreshDelay } from './watch-burst'
78
+
79
+ /**
80
+ * Where the herd's reads come from: its own reader, or the read the watcher publishes for every herd of the board
81
+ * (`herd-state.ts`), with the watcher on the directory it writes to and what was taken from it last.
82
+ */
83
+ type Feed =
84
+ | { kind: 'own'; reader: Reader }
85
+ | { kind: 'published'; directory: FSWatcher | null; taken: { read: HerdRead; plans: HerdPlans } | null }
80
86
 
81
87
  /** The state of the running view; only the functions below `runLive` change it. */
82
88
  type Live = {
83
89
  source: Source
90
+ /** What the herd shares with the watcher to take its read (`publishedKey`); null when it never does. */
91
+ key: string | null
92
+ feed: Feed | null
84
93
  snapshot: Snapshot | null
85
94
  screen: Screen | null
86
95
  view: View
87
96
  errors: string[]
88
- pullRequests: PullRequestState
89
- fetch: FetchState
90
97
  /** One footer line about the last action of the person, such as where the sprint page was written; cleared by the next key. */
91
98
  notice: string | null
92
- watchers: Map<string, FSWatcher>
93
- /** This clone's `FETCH_HEAD`, watched beside the plan files; null when git names none. */
94
- fetchHead: string | null
95
99
  reported: Map<string, string | null>
96
- /** Per lane, the ticks seen at the last read, the phase that moved last and when each moved; kept across herds (`rememberLanes`). */
97
- memories: Map<string, LaneMemory>
98
- /** Session logs and plans parsed at the last read, kept while they did not change. */
99
- cache: ReadCache
100
- pending: Timer | null
101
- /** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of `page`. */
100
+ /** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of the page. */
102
101
  read: Snapshot | null
102
+ /** The whole board as the watcher published it, while the herd shows one lane of it; null otherwise. */
103
+ whole: Snapshot | null
103
104
  /** The sprint page last written, and the key of the plans it was built from; a click on a plan chip opens it. */
104
- page: KnownPage | null
105
- pageWriting: boolean
106
- flight: (() => Promise<void>) | null
107
- /** The refreshes in a row that wrote nothing to the terminal; the next one waits `refreshDelay` of it. */
108
- quietRounds: number
109
- /** When the last refresh started (epoch milliseconds), null before the first; a refresh outside the pace waits `watchRefreshDelay` of it. */
110
- lastRefreshAt: number | null
111
- pageError: string | null
105
+ pageState: PageState
112
106
  /**
113
107
  * The lines last written to the terminal, with their shimmer marks, so a stripe that moved on is no change (the
114
108
  * shimmer clock draws that); null while the terminal content is unknown, such as after a resize.
@@ -126,18 +120,8 @@ type Live = {
126
120
  shimmer: { prepared: PreparedShimmer; clock: Timer | null }
127
121
  }
128
122
 
129
- const PULL_REQUEST_REFRESH_MS = 60_000
130
123
  const QUIT_KEYS = new Set(['\x03', 'q'])
131
124
 
132
- /** When `file` was last written (epoch milliseconds); 0 when it cannot be stat-ed, which no fetch of the herd claims. */
133
- const modifiedAt = (file: string): number => {
134
- try {
135
- return statSync(file).mtimeMs
136
- } catch {
137
- return 0
138
- }
139
- }
140
-
141
125
  /** The sidebar titles that differ from the ones last reported, in the order of `titles`; null clears one. */
142
126
  export const changedTitles = (
143
127
  titles: ReadonlyMap<string, string | null>,
@@ -161,76 +145,6 @@ const clearTitles = (source: Source, reported: Map<string, string | null>) => {
161
145
  }
162
146
  }
163
147
 
164
- const syncWatchers = (watchers: Map<string, FSWatcher>, files: string[], onChange: (file: string) => void) => {
165
- for (const [file, watcher] of watchers) {
166
- if (files.includes(file)) continue
167
- watcher.close()
168
- watchers.delete(file)
169
- }
170
- for (const file of files.filter((candidate) => !watchers.has(candidate))) {
171
- try {
172
- const watcher = watch(file, (event) => {
173
- if (event === 'rename') {
174
- watcher.close()
175
- watchers.delete(file)
176
- }
177
- onChange(file)
178
- })
179
- watchers.set(file, watcher)
180
- } catch {
181
- // A file that cannot be watched right now is retried on the next refresh, which also covers it meanwhile.
182
- }
183
- }
184
- }
185
-
186
- const STDOUT_FD = 1
187
- const STDERR_FD = 2
188
-
189
- /**
190
- * The terminal given back on every end, written synchronously because `exit` waits for nothing: mouse reports and
191
- * alternate screen off, cursor shown, raw mode released, once (`onceRestore`).
192
- */
193
- const terminalRestore = (): (() => void) =>
194
- onceRestore(TERMINAL_OFF, {
195
- write: (text) => writeSync(STDOUT_FD, text),
196
- releaseRaw: () => {
197
- if (process.stdin.isTTY) process.stdin.setRawMode(false)
198
- },
199
- })
200
-
201
- /** How the view ends: `quit` and `crash` leave, `restore` gives the terminal back and runs on `exit` as well. */
202
- type TerminalEnds = { quit: () => void; crash: (error: unknown) => void; restore: () => void }
203
-
204
- /**
205
- * Alternate screen, hidden cursor, mouse reports and raw keys. A signal quits, an error nobody caught crashes (a
206
- * closed terminal's `EIO` on stdin among them), and `exit` restores the terminal whatever ended the process.
207
- */
208
- const enterTerminal = (onInput: (input: string) => void, ends: TerminalEnds, onResize: () => void) => {
209
- process.stdout.write(TERMINAL_ON)
210
- for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) process.on(signal, ends.quit)
211
- process.on('exit', ends.restore)
212
- process.on('uncaughtException', ends.crash)
213
- process.on('unhandledRejection', ends.crash)
214
- process.stdout.on('resize', onResize)
215
- if (!process.stdin.isTTY) return
216
- process.stdin.setRawMode(true)
217
- process.stdin.on('error', ends.crash)
218
- process.stdin.on('data', (chunk) => {
219
- for (const input of withoutStaleMotions(splitKeys(chunk.toString()))) onInput(input)
220
- })
221
- }
222
-
223
- /** The terminal restored first, so the report lands in the shell and not on the alternate screen; null reports nothing. */
224
- const reportCrash = (report: string | null, restore: () => void) => {
225
- restore()
226
- if (report === null) return
227
- try {
228
- writeSync(STDERR_FD, `${report}\n`)
229
- } catch {
230
- // stderr went away with the terminal; the exit code still tells.
231
- }
232
- }
233
-
234
148
  const hoverScreenOf = (screen: Screen, top: number): HoverScreen => ({
235
149
  header: screen.header,
236
150
  bodyLines: screen.body.lines,
@@ -241,15 +155,22 @@ const hoverScreenOf = (screen: Screen, top: number): HoverScreen => ({
241
155
  /** The lines over the footer: refresh errors, a failed page, the notice of the last action. */
242
156
  const liveErrors = (live: Live): string[] => [
243
157
  ...live.errors,
244
- ...(live.pageError ? [live.pageError] : []),
158
+ ...(live.pageState.error ? [live.pageState.error] : []),
245
159
  ...(live.notice ? [live.notice] : []),
246
160
  ]
247
161
 
248
162
  const shimmerOn = (live: Live): boolean => shimmerEnabled(live.source.settings.shimmer, process.env)
249
163
 
164
+ const renderContextOf = (live: Live): RenderContext => ({
165
+ selection: live.view.selection,
166
+ errors: liveErrors(live),
167
+ width: paneWidth(),
168
+ shimmer: shimmerOn(live),
169
+ })
170
+
250
171
  /** The screen for the current state, with the window scrolled to keep the cursor in view. */
251
172
  const renderLive = (live: Live, snapshot: Snapshot): { screen: Screen; top: number } => {
252
- const screen = renderSnapshot(snapshot, live.view.selection, liveErrors(live), paneWidth(), shimmerOn(live))
173
+ const screen = renderSnapshot(snapshot, renderContextOf(live))
253
174
  return { screen, top: windowTop(live.view, screen.body.cursorLine, screen.body.lines.length, bodyRows(screen)) }
254
175
  }
255
176
 
@@ -314,7 +235,7 @@ const followShimmer = (live: Live, prepared: PreparedShimmer) => {
314
235
  const drawHover = (live: Live) => {
315
236
  live.frame = null
316
237
  if (!live.snapshot) return
317
- live.plain ??= renderUnfocused(live.snapshot, live.view.selection, liveErrors(live), paneWidth(), shimmerOn(live))
238
+ live.plain ??= renderUnfocused(live.snapshot, renderContextOf(live))
318
239
  const laid = focusedScreen(live.plain, live.view.selection, paneWidth(), agentOfSnapshot(live.snapshot))
319
240
  if (laid === null) redraw(live)
320
241
  else write(live, laid, live.view.top)
@@ -326,11 +247,16 @@ const requestFrame = (live: Live) => {
326
247
  live.frame = setTimeout(() => drawHover(live), nextFrameDelay(live.lastFrameAt, Date.now()))
327
248
  }
328
249
 
329
- /** The whole sprint, also while the view shows one lane: the snapshot is read again without the lane filter. */
330
- const wholeSprint = async (live: Live, snapshot: Snapshot): Promise<Snapshot> =>
331
- live.source.options.lane
332
- ? loadSnapshot({ ...live.source, options: { ...live.source.options, lane: null } }, live.pullRequests)
333
- : snapshot
250
+ /**
251
+ * The whole sprint, also while the view shows one lane: the watcher's read holds it, the herd's own reader reads the
252
+ * snapshot again without the lane filter.
253
+ */
254
+ const wholeSprint = async (live: Live, snapshot: Snapshot): Promise<Snapshot> => {
255
+ if (!live.source.options.lane) return snapshot
256
+ if (live.whole) return live.whole
257
+ const pullRequests = live.feed?.kind === 'own' ? live.feed.reader.pullRequests() : noPullRequests()
258
+ return loadSnapshot({ ...live.source, options: { ...live.source.options, lane: null } }, pullRequests)
259
+ }
334
260
 
335
261
  const boardTextsOf = (live: Live) => boardTexts(live.source.settings.board.term)
336
262
 
@@ -338,74 +264,150 @@ const sprintDirectoryOf = ({ root, sprintFile, settings }: Source): string =>
338
264
  dirname(boardRepoPathOf(root, settings, sprintFile))
339
265
 
340
266
  /** The snapshot as read with the plan chips of the page written so far, none before the first one. */
341
- const withKnownPage = (live: Live, read: Snapshot): Snapshot =>
342
- live.page ? withPlanChips(read, live.page, sprintDirectoryOf(live.source)) : read
267
+ const withKnownPage = (live: Live, read: Snapshot): Snapshot => {
268
+ const { page } = live.pageState
269
+ return page ? withPlanChips(read, page, sprintDirectoryOf(live.source)) : read
270
+ }
271
+
272
+ const isCurrent = (live: Live, read: Snapshot): boolean => {
273
+ const { page } = live.pageState
274
+ return page !== null && !pageNeedsWriting(page, pageKey(read), existsSync(page.path))
275
+ }
343
276
 
344
- const isCurrent = (live: Live, read: Snapshot): boolean =>
345
- live.page !== null && !pageNeedsWriting(live.page, pageKey(read), existsSync(live.page.path))
277
+ /** Writes the sprint page from the herd's own read when its plans changed (`syncSprintPage`), then draws its chips. */
278
+ const syncPage = async (live: Live, read: Snapshot, feed: Feed) => {
279
+ if (live.feed !== feed) return
280
+ if (!(await syncSprintPage(live.pageState, live.source, read, () => wholeSprint(live, read)))) return
281
+ if (live.feed !== feed) return
282
+ if (live.read) live.snapshot = withKnownPage(live, live.read)
283
+ redraw(live)
284
+ }
346
285
 
347
286
  /**
348
- * Writes the sprint page, so a plan chip's link points at a file that exists. Only when the plans it is built
349
- * from changed or the file is gone: building it takes seconds, with the event loop blocked for all of them.
287
+ * Takes a read into the view, from the herd's own reader or from the watcher: the selection follows the lanes, the
288
+ * errors (`notes` after the read's own) and the pane titles are set, and the screen is drawn. True when it wrote
289
+ * anything.
350
290
  */
351
- const syncSprintPage = async (live: Live, read: Snapshot) => {
352
- const key = pageKey(read)
353
- if (live.pageWriting || !pageNeedsWriting(live.page, key, live.page !== null && existsSync(live.page.path))) return
354
- live.pageWriting = true
355
- try {
356
- const written = await writeSprintPageInSteps(live.source, await wholeSprint(live, read))
357
- live.pageError = 'error' in written ? boardTextsOf(live).pageNotWritten(written.error) : null
358
- live.page = pageAfterWrite(live.page, written, key)
359
- } catch (error) {
360
- live.pageError = boardTextsOf(live).pageNotWritten(errorText(error))
361
- } finally {
362
- live.pageWriting = false
291
+ const takeRead = (live: Live, outcome: ReadOutcome, notes: readonly string[]): boolean => {
292
+ if ('error' in outcome) {
293
+ const at = clockOf(CLOCK_LOCALE)
294
+ live.errors = [`refresh failed at ${at}, showing the last good state: ${outcome.error}`]
295
+ return redraw(live)
363
296
  }
364
- if (live.read) live.snapshot = withKnownPage(live, live.read)
365
- redraw(live)
297
+ const next = withKnownPage(live, outcome.read)
298
+ live.read = outcome.read
299
+ if (!live.snapshot)
300
+ live.view = {
301
+ ...live.view,
302
+ selection: startSelection(next, live.source.options),
303
+ }
304
+ const folded = syncLaneFolds(
305
+ live.view.selection,
306
+ next.views.map(({ progress, status }) => ({
307
+ lane: progress.lane.name,
308
+ status: status.agent,
309
+ })),
310
+ )
311
+ live.view = { ...live.view, selection: folded }
312
+ live.snapshot = next
313
+ live.errors = [...next.errors, ...notes, ...reportTitles(live.source, next.titles, live.reported)]
314
+ return redraw(live)
366
315
  }
367
316
 
368
- /** A re-read never overlaps another: one asked for meanwhile runs once after the running one, so the screen cannot go back to an older read. */
369
- const refresh = (live: Live, onChange: (file: string) => void): Promise<void> => {
370
- live.flight ??= singleFlight(() => readOnce(live, onChange))
371
- return live.flight()
317
+ /** The herd's own reader: it takes each read and writes the sprint page from it. */
318
+ const ownFeed = (live: Live): Feed => {
319
+ const reader = startReader(live.source, (outcome) => {
320
+ const fetchLine = reader.fetchLine()
321
+ const changed = takeRead(live, outcome, fetchLine ? [fetchLine] : [])
322
+ const { read } = live
323
+ if (read) setTimeout(() => void syncPage(live, read, feed), 0)
324
+ return changed
325
+ })
326
+ const feed: Feed = { kind: 'own', reader }
327
+ return feed
328
+ }
329
+
330
+ /** Shows what was taken from the watcher, at the herd's own time: the whole board, or the herd's lane of it. */
331
+ const showPublished = (live: Live, taken: { read: HerdRead; plans: HerdPlans }): boolean => {
332
+ const published = publishedRead(taken.read, taken.plans.plans, Date.now())
333
+ const { lane } = live.source.options
334
+ live.whole = lane ? published.snapshot : null
335
+ live.pageState.page = published.page
336
+ const shown = lane ? laneRead(published.snapshot, lane, boardTextsOf(live).laneNotListed) : published.snapshot
337
+ return takeRead(live, { read: shown }, published.fetchLine ? [published.fetchLine] : [])
372
338
  }
373
339
 
374
- const readOnce = async (live: Live, onChange: (file: string) => void) => {
375
- live.lastRefreshAt = Date.now()
340
+ /**
341
+ * Takes the watcher's read when it is for this herd's key; the plan texts are read again only when their key moved.
342
+ * A read the watcher is still writing is never seen half: it replaces the file whole.
343
+ */
344
+ const takePublished = (live: Live, feed: Extract<Feed, { kind: 'published' }>): void => {
345
+ const files = herdFilesOf(live.source.root)
346
+ const read = readHerdRead(files.read)
347
+ const kept = feed.taken?.plans
348
+ const plans = read && (kept?.plansKey === read.plansKey ? kept : readHerdPlans(files.plans))
349
+ if (!read || read.key !== live.key || !plans || plans.plansKey !== read.plansKey) {
350
+ if (feed.taken) showPublished(live, feed.taken)
351
+ return
352
+ }
353
+ feed.taken = { read, plans }
354
+ showPublished(live, feed.taken)
355
+ }
356
+
357
+ /** Follows the watcher's read: woken by its file, without a timer of its own. */
358
+ const publishedFeed = (live: Live): Feed => {
359
+ const feed: Extract<Feed, { kind: 'published' }> = { kind: 'published', directory: null, taken: null }
360
+ const files = herdFilesOf(live.source.root)
361
+ let pending = false
362
+ const onChange = (_event: string, name: string | null) => {
363
+ if (name !== HERD_FILES.read || pending) return
364
+ pending = true
365
+ setTimeout(() => {
366
+ pending = false
367
+ if (live.feed === feed) takePublished(live, feed)
368
+ }, 0)
369
+ }
376
370
  try {
377
- const read = await loadSnapshot(live.source, live.pullRequests, live.memories, live.cache)
378
- rememberLanes(live.source.root, live.memories, read.documents.laneOrder)
379
- const next = withKnownPage(live, read)
380
- live.read = read
381
- if (!live.snapshot)
382
- live.view = {
383
- ...live.view,
384
- selection: startSelection(next, live.source.options),
385
- }
386
- const folded = syncLaneFolds(
387
- live.view.selection,
388
- next.views.map(({ progress, status }) => ({
389
- lane: progress.lane.name,
390
- status: status.agent,
391
- })),
392
- )
393
- live.view = { ...live.view, selection: folded }
394
- live.snapshot = next
395
- const fetchLine = fetchNotice(live.fetch, baseRefOf(live.source.settings), CLOCK_LOCALE)
396
- live.errors = [
397
- ...next.errors,
398
- ...(fetchLine ? [fetchLine] : []),
399
- ...reportTitles(live.source, next.titles, live.reported),
400
- ]
401
- syncWatchers(live.watchers, watchedFiles(next.files, live.fetchHead), onChange)
402
- } catch (error) {
403
- const at = clockOf(CLOCK_LOCALE)
404
- live.errors = [`refresh failed at ${at}, showing the last good state: ${errorText(error)}`]
371
+ feed.directory = watch(dirname(files.read), onChange)
372
+ } catch {
373
+ feed.directory = null
405
374
  }
406
- live.quietRounds = quietRoundsAfter(live.quietRounds, redraw(live))
407
- const { read } = live
408
- if (read) setTimeout(() => void syncSprintPage(live, read), 0)
375
+ takePublished(live, feed)
376
+ return feed
377
+ }
378
+
379
+ /** Whether the watcher publishes a young read for this herd's key (`isPublishedLive`). */
380
+ const isPublishedForHerd = (live: Live): boolean => {
381
+ const files = herdFilesOf(live.source.root)
382
+ return isPublishedLive({
383
+ beat: readHerdBeat(files.beat),
384
+ key: live.key,
385
+ watcherRunning: isWatcherRunning(files.lock),
386
+ now: Date.now(),
387
+ })
388
+ }
389
+
390
+ const stopFeed = (feed: Feed | null): void => {
391
+ if (feed?.kind === 'own') feed.reader.stop()
392
+ else feed?.directory?.close()
393
+ }
394
+
395
+ /**
396
+ * Switches to the watcher's read while it publishes one for this herd, and back to reading itself once it stops or
397
+ * grows old; while it follows the watcher, the sources' ages are drawn again at the herd's time.
398
+ */
399
+ const followFeed = (live: Live): void => {
400
+ const published = isPublishedForHerd(live)
401
+ const { feed } = live
402
+ if (feed?.kind === 'published' && published) {
403
+ takePublished(live, feed)
404
+ return
405
+ }
406
+ if (feed?.kind === 'own' && !published) return
407
+ stopFeed(feed)
408
+ live.whole = null
409
+ live.pageState.error = null
410
+ live.feed = published ? publishedFeed(live) : ownFeed(live)
409
411
  }
410
412
 
411
413
  const writeSprintPage = async (live: Live, snapshot: Snapshot, anchor: string | null = null) => {
@@ -423,15 +425,17 @@ const writeSprintPage = async (live: Live, snapshot: Snapshot, anchor: string |
423
425
  * first. Any other `file:` address is refused.
424
426
  */
425
427
  const openPlan = (live: Live, snapshot: Snapshot, address: string) => {
426
- const anchor = planAnchorOn(live.page?.path ?? null, address)
427
- if (!anchor || !live.page) return
428
- if (!live.read || !isCurrent(live, live.read)) {
428
+ const { page } = live.pageState
429
+ const anchor = planAnchorOn(page?.path ?? null, address)
430
+ if (!anchor || !page) return
431
+ const read = live.whole ?? live.read
432
+ if (!read || !isCurrent(live, read)) {
429
433
  void writeSprintPage(live, snapshot, anchor)
430
434
  return
431
435
  }
432
- const reason = openInBrowser(`${pathToFileURL(live.page.path).href}#${anchor}`)
436
+ const reason = openInBrowser(`${pathToFileURL(page.path).href}#${anchor}`)
433
437
  const texts = boardTextsOf(live)
434
- live.notice = reason === null ? texts.pageOpened(live.page.path) : texts.pageNotOpenedReason(reason)
438
+ live.notice = reason === null ? texts.pageOpened(page.path) : texts.pageNotOpenedReason(reason)
435
439
  }
436
440
 
437
441
  /**
@@ -563,29 +567,24 @@ const handleInput = (live: Live, input: string) => {
563
567
  redraw(live)
564
568
  }
565
569
 
566
- export const runLive = (source: Source): void => {
570
+ /**
571
+ * Runs the live view of `source`. With a `key` (`publishedKey`) it takes the read the watcher publishes for it while
572
+ * there is one, and reads for itself otherwise; without one it always reads for itself.
573
+ */
574
+ export const runLive = (source: Source, key: string | null = null): void => {
567
575
  const live: Live = {
568
576
  source,
577
+ key,
578
+ feed: null,
569
579
  snapshot: null,
570
580
  screen: null,
571
581
  view: { selection: overviewSelection(), top: 0, follow: true },
572
582
  errors: [],
573
- pullRequests: noPullRequests(),
574
- fetch: initialFetchState(),
575
583
  notice: null,
576
- watchers: new Map(),
577
- fetchHead: fetchHeadPath(source.root),
578
584
  reported: new Map(),
579
- memories: recallLanes(source.root),
580
- cache: newReadCache(),
581
- pending: null,
582
585
  read: null,
583
- page: null,
584
- pageWriting: false,
585
- flight: null,
586
- quietRounds: 0,
587
- lastRefreshAt: null,
588
- pageError: null,
586
+ whole: null,
587
+ pageState: initialPageState(),
589
588
  written: null,
590
589
  plain: null,
591
590
  mouse: null,
@@ -593,60 +592,12 @@ export const runLive = (source: Source): void => {
593
592
  frame: null,
594
593
  shimmer: { prepared: preparedShimmer([], 0), clock: null },
595
594
  }
596
- /** A refresh asked for outside the pace, bundled by `watchRefreshDelay`: one already pending covers the ask. */
597
- const schedule = () => {
598
- if (live.pending) return
599
- live.pending = setTimeout(
600
- () => {
601
- live.pending = null
602
- void refresh(live, onFileChange)
603
- },
604
- watchRefreshDelay(live.lastRefreshAt, Date.now()),
605
- )
606
- }
607
- /** A watched file changed; `FETCH_HEAD` written by the herd's own fetch asks nothing, its end refreshes once. */
608
- const onFileChange = (file: string) => {
609
- if (file === live.fetchHead && isOwnFetchWrite(modifiedAt(file), live.fetch)) return
610
- schedule()
611
- }
612
- const fetchBaseBranch = async () => {
613
- live.fetch = { ...live.fetch, lastAttempt: Date.now(), running: true }
614
- const failure = await fetchBase(source.root, BASE_REMOTE, source.settings.baseBranch)
615
- const planHeads = planPullRequests([...live.pullRequests.byBranch.values()].flat(), source.settings.planDirectory)
616
- if (failure === null) await fetchPlanHeads(source.root, BASE_REMOTE, planHeads)
617
- live.fetch = {
618
- ...live.fetch,
619
- running: false,
620
- failure,
621
- lastSuccess: failure === null ? new Date() : live.fetch.lastSuccess,
622
- }
623
- schedule()
624
- }
625
- /** Starts a fetch when one is due; true when it did, whose end then refreshes. */
626
- const fetchWhenDue = (pullRequestGone: boolean): boolean => {
627
- const due = shouldFetch({
628
- now: Date.now(),
629
- state: live.fetch,
630
- intervalMinutes: source.settings.fetchIntervalMinutes,
631
- pullRequestGone,
632
- })
633
- if (due) void fetchBaseBranch()
634
- return due
635
- }
636
- const refreshPullRequests = async () => {
637
- const previous = live.pullRequests
638
- live.pullRequests = await fetchPullRequests(source.root)
639
- const readable = previous.errors.length === 0 && live.pullRequests.errors.length === 0
640
- const laneBranches = new Set(live.snapshot?.views.flatMap(({ status }) => (status.branch ? [status.branch] : [])))
641
- const gone = goneLanePullRequests(previous.byBranch, live.pullRequests.byBranch, laneBranches)
642
- if (!fetchWhenDue(readable && gone.length > 0)) schedule()
643
- }
644
595
  const restore = terminalRestore()
645
596
  const leave = (code: number) => {
646
597
  restore()
647
598
  clearTitles(source, live.reported)
648
599
  source.terminal.labelOwnPane(null)
649
- for (const watcher of live.watchers.values()) watcher.close()
600
+ stopFeed(live.feed)
650
601
  process.exit(code)
651
602
  }
652
603
  const quit = () => leave(0)
@@ -661,16 +612,13 @@ export const runLive = (source: Source): void => {
661
612
  source.terminal.labelOwnPane(herdLabel(source.options.lane))
662
613
  process.removeAllListeners(TAKEOVER_SIGNAL)
663
614
  process.on(TAKEOVER_SIGNAL, takenOver)
664
- enterTerminal(onInput, { quit, crash, restore }, () => {
615
+ const onData = (chunk: string) => {
616
+ for (const input of withoutStaleMotions(splitKeys(chunk))) onInput(input)
617
+ }
618
+ enterTerminal(onData, { quit, crash, restore }, () => {
665
619
  live.written = null
666
620
  redraw(live)
667
621
  })
668
- const poll = () => {
669
- fetchWhenDue(false)
670
- void refresh(live, onFileChange).finally(() => setTimeout(poll, refreshDelay(live.quietRounds)))
671
- }
672
- setTimeout(poll, REFRESH_PACE.baseMs)
673
- setInterval(refreshPullRequests, PULL_REQUEST_REFRESH_MS)
674
- void refresh(live, onFileChange)
675
- void refreshPullRequests()
622
+ followFeed(live)
623
+ setInterval(() => followFeed(live), REFRESH_PACE.maxMs)
676
624
  }
@@ -0,0 +1,4 @@
1
+ const MARKDOWN_MARKS = /\*\*|`/g
2
+
3
+ /** The text without its bold and code marks, as every row but a heading draws it. */
4
+ export const plain = (text: string): string => text.replace(MARKDOWN_MARKS, '')