jackherd 1.11.2 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +69 -51
  3. package/dist/{chunk-RMMXD4G5.js → chunk-V4HOLGFP.js} +2 -2
  4. package/dist/config.d.ts +10 -0
  5. package/dist/config.js +15 -5
  6. package/dist/plan-format.d.ts +11 -6
  7. package/dist/plan-format.js +8 -2
  8. package/docs/follow-dark.svg +2 -2
  9. package/docs/follow.svg +2 -2
  10. package/docs/workflow-dark.svg +56 -64
  11. package/docs/workflow.svg +56 -64
  12. package/herdr-plugin/herdr-plugin.toml +1 -1
  13. package/install.sh +1 -1
  14. package/kit/workflow/skills/implement/SKILL.md +1 -1
  15. package/kit/workflow/skills/orchestrate/SKILL.md +1 -1
  16. package/kit/workflow/skills/orchestrate/references/draft-review.md +4 -0
  17. package/kit/workflow/skills/orchestrate/references/events.md +8 -1
  18. package/kit/workflow/skills/plan/SKILL.md +2 -0
  19. package/package.json +2 -2
  20. package/schemas/jackherd.schema.json +18 -1
  21. package/src/config/link-kinds.ts +1 -1
  22. package/src/config/schema.ts +5 -2
  23. package/src/config/tones.ts +9 -2
  24. package/src/context/fill.ts +4 -10
  25. package/src/context/session-log.ts +108 -2
  26. package/src/plan-format/lanes.ts +10 -1
  27. package/src/plan-format/phases.ts +11 -3
  28. package/src/plan-format/progress.ts +4 -2
  29. package/src/plan-format/waiting.ts +12 -0
  30. package/src/plans/active-tasks.ts +31 -8
  31. package/src/plans/appointment-rows.ts +42 -53
  32. package/src/plans/board-rows.ts +14 -0
  33. package/src/plans/calendar-days.ts +29 -0
  34. package/src/plans/click-hint.ts +94 -0
  35. package/src/plans/command.ts +8 -9
  36. package/src/plans/current-phase.ts +22 -10
  37. package/src/plans/current-task.ts +18 -3
  38. package/src/plans/frame.ts +18 -26
  39. package/src/plans/git.ts +90 -31
  40. package/src/plans/groomed.ts +13 -7
  41. package/src/plans/lane-reason.ts +61 -0
  42. package/src/plans/lane-summary.ts +75 -24
  43. package/src/plans/layout-phase.ts +123 -76
  44. package/src/plans/layout-summary.ts +133 -83
  45. package/src/plans/layout.ts +451 -345
  46. package/src/plans/legend.ts +29 -0
  47. package/src/plans/link-chips.ts +26 -8
  48. package/src/plans/live.ts +238 -84
  49. package/src/plans/next-rows.ts +5 -37
  50. package/src/plans/phase-source.ts +8 -3
  51. package/src/plans/plan-chips.ts +6 -3
  52. package/src/plans/plan-heads.ts +1 -1
  53. package/src/plans/planned.ts +4 -1
  54. package/src/plans/process-slots.ts +36 -0
  55. package/src/plans/pull-requests.ts +9 -1
  56. package/src/plans/read-cache.ts +18 -3
  57. package/src/plans/refresh-pace.ts +13 -0
  58. package/src/plans/row-focus.ts +86 -15
  59. package/src/plans/selection.ts +124 -89
  60. package/src/plans/sent-files.ts +1 -1
  61. package/src/plans/settings.ts +5 -2
  62. package/src/plans/shimmer.ts +124 -0
  63. package/src/plans/signals-read.ts +38 -10
  64. package/src/plans/slice-gate.ts +51 -0
  65. package/src/plans/snapshot.ts +185 -84
  66. package/src/plans/source-asked.ts +1 -1
  67. package/src/plans/source-rows.ts +60 -18
  68. package/src/plans/sprint-html.ts +1 -1
  69. package/src/plans/status-pace.ts +45 -0
  70. package/src/plans/subagent-tasks.ts +1 -1
  71. package/src/plans/subagents-read.ts +61 -24
  72. package/src/plans/subagents.ts +2 -2
  73. package/src/plans/symbols.ts +4 -1
  74. package/src/plans/task-brief.ts +143 -0
  75. package/src/plans/texts.ts +181 -39
  76. package/src/plans/view.ts +198 -32
  77. package/src/plans/waiting-click.ts +54 -110
  78. package/src/plans/watch-burst.ts +25 -0
  79. package/src/plans/worktree-stamp.ts +35 -0
  80. package/src/plans/your-turn-rows.ts +102 -0
  81. package/src/plans/your-turn.ts +226 -0
  82. package/src/terminal/herdr.ts +25 -5
  83. package/src/terminal/restore.ts +44 -0
  84. package/src/terminal/text.ts +2 -2
  85. package/src/terminal/width.ts +9 -0
  86. package/src/vocabulary/index.ts +6 -5
  87. package/src/watch/command.ts +28 -2
  88. package/src/watch/lane-report.ts +2 -0
  89. package/src/watch/lane-rest.ts +7 -1
  90. package/src/watch/reference-paths.ts +1 -0
  91. package/src/watch/unlinked-artifacts-read.ts +43 -0
  92. package/src/watch/unlinked-artifacts.ts +76 -0
  93. package/src/watch/watch-events.ts +12 -0
  94. package/src/watch/watcher-lock.ts +47 -4
@@ -24,8 +24,7 @@ import type { SourceTicket, WatchBadges, WatchHealth } from '../watch/plan-badge
24
24
  import { readWatchReading } from '../watch/plan-badges-read'
25
25
  import type { SourceStatus } from '../watch/source-statuses'
26
26
  import { assignSignals, laneOfDirectory, type UnplannedTask } from './active-tasks'
27
- import { type AppointmentBlock, appointmentBlock } from './appointment-rows'
28
- import { boardRowsOf } from './board-rows'
27
+ import { type BoardRowsReader, boardRowsOf } from './board-rows'
29
28
  import {
30
29
  type ActivityTimes,
31
30
  type LaneMemory,
@@ -37,7 +36,17 @@ import {
37
36
  } from './current-phase'
38
37
  import { activityTextOf, taskSignalsOf } from './current-task'
39
38
  import { errorText } from './error-text'
40
- import { type BaseReader, baseReaderOf, gitStatusOf, lastChangeReaderOf, refExists } from './git'
39
+ import {
40
+ type BaseReader,
41
+ type BranchStatusReader,
42
+ baseReaderAt,
43
+ branchStatusAt,
44
+ gitStatusOf,
45
+ lastChangesAt,
46
+ refCommits,
47
+ refExists,
48
+ resolveCommit,
49
+ } from './git'
41
50
  import type { PullRequestState } from './github'
42
51
  import { groomedDaysAgo } from './groomed'
43
52
  import {
@@ -53,26 +62,30 @@ import { laneSummary } from './lane-summary'
53
62
  import { boardRowTexts, laneTicketsOf, withoutTicketLinks } from './lane-tickets'
54
63
  import type { LaneView } from './layout'
55
64
  import { extractLinkChips } from './link-chips'
56
- import { type NextBlock, nextBlock } from './next-rows'
57
65
  import type { Options } from './options'
58
66
  import { laneModelsOf, type PhaseModelRecord } from './phase-models'
59
67
  import { readPhaseModels } from './phase-models-read'
60
- import { laneHeads, planPullRequests, readerOncePerRef } from './plan-heads'
68
+ import { parsedPlanPhases } from './plan-cache'
69
+ import { laneHeads, PULL_HEAD_NAMESPACE, planPullRequests, readerOncePerRef } from './plan-heads'
61
70
  import { markStale, type PlannedPlan, plannedPlans } from './planned'
62
71
  import type { PullRequest } from './pull-requests'
63
72
  import type { ReadCache } from './read-cache'
64
73
  import { type SentChip, sentChipsByLane } from './sent-files'
65
74
  import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from './settings'
66
75
  import { signalledTitle } from './signalled-phase'
67
- import { readAllSignals } from './signals-read'
76
+ import { readAllSignalsSliced } from './signals-read'
77
+ import { createSliceGate, type SliceGate } from './slice-gate'
68
78
  import { withSourcePrefix } from './source-prefix'
69
79
  import type { HerdSource, SourceBlock } from './source-rows'
80
+ import { type KeptStatus, logMark, pacedStatus } from './status-pace'
70
81
  import type { SubagentActivity } from './subagents'
71
- import { readSubagentActivity, subagentSourceOf } from './subagents-read'
82
+ import { readSubagentActivitySliced, subagentSourceOf } from './subagents-read'
72
83
  import { boardTexts } from './texts'
73
84
  import { type UnlistedLane, unlistedLanes, unlistedSprintLane } from './unlisted-lanes'
74
85
  import { readWaitingRecords, waitingItemsOf } from './waiting-records'
75
86
  import { withWorkedPhases } from './worked-phases'
87
+ import { worktreeStamp } from './worktree-stamp'
88
+ import { type YourTurnBlock, yourTurnOfBoard } from './your-turn'
76
89
 
77
90
  /** Where the view reads from: the main checkout, the sprint document, the command line and the project settings. */
78
91
  export type Source = {
@@ -81,6 +94,8 @@ export type Source = {
81
94
  options: Options
82
95
  settings: PlansSettings
83
96
  terminal: LaneStatusSource
97
+ /** The terminal's agents without holding the event loop; defaults to `terminal.listAgents`, which does. */
98
+ listAgents?: () => Promise<LaneAgent[]>
84
99
  /** The log lines of an agent's chat, null without one; defaults to reading them from `~/.claude/projects`. */
85
100
  readAgentLog?: AgentLogReader
86
101
  /** The worktrees subagents of the coordinator work in; defaults to reading them from `~/.claude/projects`. */
@@ -125,11 +140,9 @@ export type Snapshot = {
125
140
  waiting?: WaitingItem[]
126
141
  /** Whole days since the board's grooming (`groomedDaysAgo`); null when its appointment is missing. */
127
142
  groomedDays?: number | null
128
- /** The board's next section, read from the same board text as `waiting`, for the block under it. */
129
- next?: NextBlock
130
- /** The board's open appointments, read from the same board text as `waiting`, for the block under the next one. */
131
- appointments?: AppointmentBlock
132
- /** The sources of `watch.sources` not marked `ticket`, from the watcher's state, for the block under the appointments. */
143
+ /** What is the user's turn: `waiting`, the board's open appointments and its next section, one row per topic. */
144
+ yourTurn?: YourTurnBlock
145
+ /** The sources of `watch.sources` not marked `ticket`, from the watcher's state, for the block under "Your turn". */
133
146
  sourceBlock?: SourceBlock
134
147
  /** The tone of each role, from the config, which `renderSnapshot` sets before it draws. */
135
148
  tones?: PlansSettings['tones']
@@ -156,6 +169,12 @@ type LaneSources = {
156
169
  /** The directory the lanes' worktrees lie in, which tells a signal's lane. */
157
170
  worktrees: string
158
171
  parsed?: PlanSources['parsed']
172
+ /** The gate each lane passes before a piece of synchronous work, so the lanes take turns with keys and drawing. */
173
+ gate: SliceGate
174
+ /** Each worktree's last `git status`, kept between the herd's reads; absent for a single read, which asks git. */
175
+ statuses?: Map<string, KeptStatus>
176
+ /** When this read happens, epoch milliseconds. */
177
+ now: number
159
178
  }
160
179
 
161
180
  /** `liveMarkdown` is the checkout's board file (the coordinator edits it before any commit), else `markdown`. */
@@ -163,6 +182,12 @@ type BoardLanes = { markdown: string; liveMarkdown: string; lanes: SprintLane[]
163
182
 
164
183
  const SHOWN_NAMES = 2
165
184
 
185
+ /**
186
+ * How long a read keeps the event loop before it lets keys, mouse and drawing in: a quarter frame at 60 Hz, so a slice
187
+ * and the longest single step after it stay within one frame.
188
+ */
189
+ const SLICE_MS = 4
190
+
166
191
  type LaneResult = { view: LaneView; files: string[]; texts: Record<string, string>; pane: string | null }
167
192
 
168
193
  /** The board's lanes the view shows, the running lanes it does not list, and what the lanes read from. */
@@ -177,9 +202,30 @@ type Decorations = {
177
202
  restNoteOf: (lane: string) => string | null
178
203
  }
179
204
 
205
+ /** What every answer line of a session log holds; the fill is read from answers only. */
206
+ const ANSWER_MARK = '"type":"assistant"'
207
+
208
+ const answerLines = new WeakMap<readonly string[], string[]>()
209
+
210
+ /**
211
+ * The lines of a log that can be answers, kept per log as read: a log that did not change comes back as the same
212
+ * array (`followTailLines`), so its lines are not searched again, and the fill parses a few lines, not the whole tail.
213
+ */
214
+ const answerLinesOf = (lines: readonly string[]): string[] => {
215
+ const kept = answerLines.get(lines)
216
+ if (kept) return kept
217
+ const found = lines.filter((line) => line.includes(ANSWER_MARK))
218
+ answerLines.set(lines, found)
219
+ return found
220
+ }
221
+
180
222
  /** How full the agent's context is, null when the terminal gave no session or the session has no log. */
181
223
  const contextOf = (agent: LaneAgent | undefined, source: Source, readLog: AgentLogReader): ContextReading | null => {
182
- const report = contextOfAgent(agent, readLog, source.settings.context)
224
+ const readAnswers: AgentLogReader = (asked) => {
225
+ const lines = readLog(asked)
226
+ return lines && answerLinesOf(lines)
227
+ }
228
+ const report = contextOfAgent(agent, readAnswers, source.settings.context)
183
229
  return report && { share: report.share, level: report.level }
184
230
  }
185
231
 
@@ -201,17 +247,35 @@ const workText = ({ readLog, subagents }: LaneSources, lane: string, agent: Lane
201
247
  return [lines ? activityTextOf(lines, WORK_ENTRIES) : '', subagents.get(lane)?.activity ?? ''].join(' ')
202
248
  }
203
249
 
250
+ /**
251
+ * `git status` of a lane's worktree asked only when something there may have moved: its index or HEAD, its agent's
252
+ * log or its subagents' work (`worktreeStamp`), or the kept answer is old; git every time without kept statuses.
253
+ */
254
+ const pacedStatusReader = (sources: LaneSources, name: string, agent: LaneAgent | undefined): BranchStatusReader => {
255
+ const { statuses, now } = sources
256
+ if (!statuses) return branchStatusAt
257
+ return (worktree, pass) => {
258
+ const activity = `${logMark(agent ? sources.readLog(agent) : null)}|${sources.subagents.get(name)?.activity ?? ''}`
259
+ const ask = { worktree, stamp: worktreeStamp(worktree, activity), now }
260
+ return pacedStatus(statuses, ask, () => branchStatusAt(worktree, pass))
261
+ }
262
+ }
263
+
204
264
  /** What a lane's worktree and agent say, listed in the sprint or not: git state, pull requests, context. */
205
265
  const runningState = async (sources: LaneSources, name: string, agent: LaneAgent | undefined) => {
206
266
  const { source, pullRequests } = sources
207
- const gitStatus = await gitStatusOf(worktreeOf(source.root, source.settings, name), baseRefOf(source.settings))
267
+ const gitStatus = await gitStatusOf(
268
+ worktreeOf(source.root, source.settings, name),
269
+ baseRefOf(source.settings),
270
+ sources.gate.pass,
271
+ pacedStatusReader(sources, name, agent),
272
+ )
273
+ await sources.gate.pass()
208
274
  const subagent = agent ? undefined : sources.subagents.get(name)
209
- const title = agent?.title ?? subagent?.description
210
275
  return {
211
276
  gitStatus,
212
277
  view: {
213
278
  status: { agent: agent?.status ?? subagent?.status ?? null, ...gitStatus },
214
- ...(title ? { sessionTitle: title } : {}),
215
279
  pullRequests: gitStatus.branch ? (pullRequests.byBranch.get(gitStatus.branch) ?? []) : [],
216
280
  context: contextOf(agent, source, sources.readLog),
217
281
  },
@@ -248,6 +312,7 @@ const laneView = async (
248
312
  ): Promise<LaneResult> => {
249
313
  const { source, agents } = sources
250
314
  const { root, settings } = source
315
+ await sources.gate.pass()
251
316
  const worktree = worktreeOf(root, settings, lane.name)
252
317
  const agent = agents.find((candidate) => candidate.name === lane.name)
253
318
  const { gitStatus, view: running } = await runningState(sources, lane.name, agent)
@@ -269,6 +334,7 @@ const laneView = async (
269
334
  laneWorkOf(worktree, gitStatus),
270
335
  fallbacks,
271
336
  )
337
+ await sources.gate.pass()
272
338
  const shown = withWorkedPhases(lane, phasesByHref)
273
339
  const { progress, activity } = progressWithMemory(shown, phasesByHref, memories, { now, modified })
274
340
  const laneSubagents = sources.subagents.get(lane.name)
@@ -340,19 +406,16 @@ const withTickets = (result: LaneResult, tickets: readonly SourceTicket[]): Lane
340
406
  return found ? { ...view, chips: withoutTicketLinks(view.chips, found), tickets: found } : view
341
407
  }
342
408
 
343
- /** What agents report doing; nothing when it cannot be read. */
344
- const readSignals = (
409
+ /** What agents report doing, one log per slice of `gate`; nothing when it cannot be read. */
410
+ const readSignals = async (
345
411
  { root, readSignals: read }: Source,
346
- worktrees: string,
347
- now: number,
348
- cache?: ReadCache,
349
- ): TaskSignal[] => {
412
+ context: { worktrees: string; now: number; gate: SliceGate; cache?: ReadCache },
413
+ ): Promise<TaskSignal[]> => {
414
+ const { worktrees, now, gate, cache } = context
350
415
  try {
351
- return (
352
- read ??
353
- ((main, directory, at) =>
354
- readAllSignals({ projects: defaultProjectsDirectory(), root: main, worktrees: directory }, at, cache?.signals))
355
- )(root, worktrees, now)
416
+ if (read) return read(root, worktrees, now)
417
+ const source = { projects: defaultProjectsDirectory(), root, worktrees }
418
+ return await readAllSignalsSliced(source, now, cache?.signals ?? new Map(), gate.pass)
356
419
  } catch {
357
420
  return []
358
421
  }
@@ -370,23 +433,23 @@ const unplacedOf = (sources: LaneSources, lanes: readonly SprintLane[], onlyLane
370
433
  }
371
434
 
372
435
  /** The terminal's agents, or none with the reason when it cannot be asked, so the screen says why lanes show "no agent". */
373
- const readAgents = (terminal: LaneStatusSource): { agents: LaneAgent[]; errors: string[] } => {
436
+ const readAgents = async ({ terminal, listAgents }: Source): Promise<{ agents: LaneAgent[]; errors: string[] }> => {
374
437
  try {
375
- return { agents: terminal.listAgents(), errors: [] }
438
+ return { agents: listAgents ? await listAgents() : terminal.listAgents(), errors: [] }
376
439
  } catch (error) {
377
440
  return { agents: [], errors: [errorText(error)] }
378
441
  }
379
442
  }
380
443
 
381
- /** What subagents of the coordinator do in the worktrees; nothing when their logs cannot be read. */
382
- const readSubagents = (
444
+ /** What subagents of the coordinator do in the worktrees, one session per slice of `gate`; nothing when their logs cannot be read. */
445
+ const readSubagents = async (
383
446
  { root, settings, readSubagents: read }: Source,
384
447
  worktrees: string,
385
- ): Map<string, SubagentActivity> => {
448
+ gate: SliceGate,
449
+ ): Promise<Map<string, SubagentActivity>> => {
386
450
  try {
387
- return (
388
- read ?? ((main, directory) => readSubagentActivity(subagentSourceOf(main, directory, settings.skillPrefix)))
389
- )(root, worktrees)
451
+ if (read) return read(root, worktrees)
452
+ return await readSubagentActivitySliced(subagentSourceOf(root, worktrees, settings.skillPrefix), gate.pass)
390
453
  } catch {
391
454
  return new Map()
392
455
  }
@@ -427,12 +490,13 @@ const readBoardLanes = async (
427
490
  base: BaseReader,
428
491
  boardPath: string,
429
492
  fallbacks: Fallbacks,
493
+ rowsOf: BoardRowsReader,
430
494
  ): Promise<BoardLanes> => {
431
495
  const { sprintFile, settings } = source
432
496
  const atBase = textAtBase(base, boardPath)
433
497
  const markdown = atBase ?? (await readFallback(boardPath, sprintFile, fallbacks)) ?? ''
434
498
  const liveMarkdown = atBase !== null && existsSync(sprintFile) ? await Bun.file(sprintFile).text() : markdown
435
- return { markdown, liveMarkdown, lanes: boardRowsOf(markdown, liveMarkdown, settings.planLanguage).lanes }
499
+ return { markdown, liveMarkdown, lanes: rowsOf(markdown, liveMarkdown, settings.planLanguage).lanes }
436
500
  }
437
501
 
438
502
  /** The plans the sprint's plans tables name, read at the base ref, by link; lanes read theirs themselves. */
@@ -463,7 +527,7 @@ const forgetLanes = (memories: Map<string, LaneMemory> | undefined, lanes: reado
463
527
  for (const name of [...(memories?.keys() ?? [])]) if (!names.has(name)) memories?.delete(name)
464
528
  }
465
529
 
466
- const laneSetOf = (
530
+ const laneSetOf = async (
467
531
  source: Source,
468
532
  context: {
469
533
  base: BaseReader
@@ -471,14 +535,19 @@ const laneSetOf = (
471
535
  sprintDirectory: string
472
536
  worktrees: string
473
537
  cache?: ReadCache
538
+ gate: SliceGate
474
539
  },
475
540
  pullRequests: PullRequestState,
476
541
  now: number,
477
- ): LaneSet => {
478
- const { base, board, sprintDirectory, worktrees, cache } = context
479
- const { agents, errors } = readAgents(source.terminal)
542
+ ): Promise<LaneSet> => {
543
+ const { base, board, sprintDirectory, worktrees, cache, gate } = context
544
+ await gate.pass()
545
+ const { agents, errors } = await readAgents(source)
480
546
  const isWanted = ({ name }: { name: string }) => !source.options.lane || name === source.options.lane
481
- const subagents = readSubagents(source, worktrees)
547
+ const subagents = await readSubagents(source, worktrees, gate)
548
+ const signals = await readSignals(source, { worktrees, now, gate, cache })
549
+ const planPulls = planPullRequests([...pullRequests.byBranch.values()].flat(), source.settings.planDirectory)
550
+ const headCommits = planPulls.length > 0 ? await refCommits(source.root, PULL_HEAD_NAMESPACE) : new Map()
482
551
  const listed = new Set(board.lanes.map((lane) => lane.name))
483
552
  return {
484
553
  lanes: board.lanes.filter(isWanted),
@@ -491,11 +560,14 @@ const laneSetOf = (
491
560
  subagents,
492
561
  readLog: onceReader(source.readAgentLog ?? agentLog),
493
562
  pullRequests,
494
- planPullRequests: planPullRequests([...pullRequests.byBranch.values()].flat(), source.settings.planDirectory),
495
- headReaderOf: readerOncePerRef((ref) => baseReaderOf(source.root, ref)),
496
- signals: readSignals(source, worktrees, now, cache),
563
+ planPullRequests: planPulls,
564
+ headReaderOf: readerOncePerRef((ref) => baseReaderAt(source.root, headCommits.get(ref) ?? null)),
565
+ signals,
497
566
  worktrees,
498
567
  parsed: cache && { entries: cache.plans, used: new Set<string>() },
568
+ gate,
569
+ statuses: cache?.statuses,
570
+ now,
499
571
  },
500
572
  errors,
501
573
  }
@@ -535,29 +607,48 @@ const decoratedViews = (
535
607
  ),
536
608
  )
537
609
 
538
- /** The open phases no lane with an agent leads; none while the view shows one lane. */
539
- const withSourceTones = (statuses: readonly SourceStatus[], configured: PlansSettings['herdSources']): HerdSource[] =>
540
- statuses.map((status) => ({ ...status, tone: configured.find(({ name }) => name === status.name)?.tone ?? null }))
610
+ /** Each source with the tone and the label its config gives it. */
611
+ const withSourceStyle = (
612
+ statuses: readonly SourceStatus[],
613
+ { herdSources, sourceLabels }: Pick<PlansSettings, 'herdSources' | 'sourceLabels'>,
614
+ ): HerdSource[] =>
615
+ statuses.map((status) => {
616
+ const label = sourceLabels[status.name]
617
+ const tone = herdSources.find(({ name }) => name === status.name)?.tone ?? null
618
+ return { ...status, tone, ...(label === undefined ? {} : { label }) }
619
+ })
541
620
 
542
- const plannedOf = (
621
+ /**
622
+ * The open phases no lane with an agent leads, a plan marked stale by its last change at `baseCommit`; none while the
623
+ * view shows one lane.
624
+ */
625
+ const plannedOf = async (
543
626
  source: Source,
544
- plans: Record<string, string>,
545
627
  results: readonly LaneResult[],
546
- sprintDirectory: string,
547
- now: number,
548
- ): PlannedPlan[] =>
549
- source.options.lane
550
- ? []
551
- : markStale(
552
- plannedPlans({
553
- plans,
554
- lanes: results.map(({ view }) => ({ lane: view.progress.lane, agent: view.status.agent })),
555
- sprintDirectory,
556
- language: source.settings.planLanguage,
557
- }),
558
- lastChangeReaderOf(source.root, baseRefOf(source.settings)),
559
- now,
560
- )
628
+ context: {
629
+ plans: Record<string, string>
630
+ sprintDirectory: string
631
+ now: number
632
+ parsed: PlanSources['parsed']
633
+ baseCommit: string | null
634
+ },
635
+ ): Promise<PlannedPlan[]> => {
636
+ if (source.options.lane) return []
637
+ const { plans, sprintDirectory, now, parsed, baseCommit } = context
638
+ const planned = plannedPlans({
639
+ plans,
640
+ lanes: results.map(({ view }) => ({ lane: view.progress.lane, agent: view.status.agent })),
641
+ sprintDirectory,
642
+ language: source.settings.planLanguage,
643
+ parse: (markdown, path) => parsedPlanPhases(parsed, path, markdown, source.settings.planLanguage),
644
+ })
645
+ const touchedAt = await lastChangesAt(
646
+ source.root,
647
+ baseCommit,
648
+ planned.map(({ path }) => path),
649
+ )
650
+ return markStale(planned, touchedAt, now)
651
+ }
561
652
 
562
653
  /**
563
654
  * Reads the board, its plans and the lanes' state. `memories` holds, per lane, what the herd saw at the last read; it
@@ -571,25 +662,27 @@ export const loadSnapshot = async (
571
662
  cache?: ReadCache,
572
663
  ): Promise<Snapshot> => {
573
664
  const { root, sprintFile, options, settings } = source
665
+ const gate = createSliceGate(SLICE_MS)
574
666
  const fallbacks: Fallbacks = []
575
667
  const baseRef = baseRefOf(settings)
576
- const base = baseReaderOf(root, baseRef)
668
+ const baseCommit = await resolveCommit(root, baseRef)
669
+ const base = baseReaderAt(root, baseCommit)
577
670
  const boardPath = boardRepoPathOf(root, settings, sprintFile)
578
- const board = await readBoardLanes(source, base, boardPath, fallbacks)
671
+ const board = await readBoardLanes(source, base, boardPath, fallbacks, cache?.boardRows ?? boardRowsOf)
579
672
  const { markdown } = board
580
673
  const sprintDirectory = dirname(boardPath)
581
674
  const worktrees = join(root, settings.worktreeDirectory)
582
675
  const now = Date.now()
583
676
  const liveBoard = board.liveMarkdown
584
- const laneSet = laneSetOf(source, { base, board, sprintDirectory, worktrees, cache }, pullRequests, now)
677
+ const laneSet = await laneSetOf(source, { base, board, sprintDirectory, worktrees, cache, gate }, pullRequests, now)
585
678
  const results = await laneResultsOf(laneSet, fallbacks, now, memories)
586
679
  const { parsed } = laneSet.sources
587
- if (parsed) dropUnused(parsed.entries, parsed.used)
588
680
  const listedPlans = await readListedPlans(source, base, sprintDirectory, markdown, fallbacks)
589
681
  const documentsPlans = Object.assign({}, listedPlans, ...results.map((result) => result.texts))
590
682
  const { ticketSources, herdSources } = settings
591
683
  const watched =
592
684
  ticketSources.length > 0 || herdSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
685
+ await gate.pass()
593
686
  const reading = watched
594
687
  ? readWatchReading(
595
688
  root,
@@ -600,6 +693,7 @@ export const loadSnapshot = async (
600
693
  herdSources.map(({ name }) => name),
601
694
  )
602
695
  : null
696
+ await gate.pass()
603
697
  const waitingRecords = readWaitingRecords(root)
604
698
  const waiting = withSourcePrefix(
605
699
  waitingItemsOf(liveBoard, settings.planLanguage, waitingRecords, now),
@@ -609,23 +703,35 @@ export const loadSnapshot = async (
609
703
  const notice = fallbacks.length > 0 ? fallbackNotice(fallbacks, baseRef, refExists(root, baseRef)) : null
610
704
  const unknownLaneTitle =
611
705
  options.lane && results.length === 0 ? boardTexts(settings.board.term).laneNotListed(options.lane) : null
706
+ await gate.pass()
707
+ const views = decoratedViews(results, {
708
+ reading,
709
+ phaseModels: readPhaseModels(root),
710
+ sprintDirectory,
711
+ sentChips,
712
+ restNoteOf: (lane) =>
713
+ restNote(readLaneReport(laneReportPath(worktreeOf(root, settings, lane), lane))?.text ?? null),
714
+ })
715
+ await gate.pass()
716
+ const planned = await plannedOf(source, results, {
717
+ plans: documentsPlans,
718
+ sprintDirectory,
719
+ now,
720
+ parsed,
721
+ baseCommit,
722
+ })
723
+ if (parsed) dropUnused(parsed.entries, parsed.used)
724
+ await gate.pass()
612
725
  return {
613
726
  title: unknownLaneTitle ?? sprintTitleOf(markdown, sprintDirectory),
614
- views: decoratedViews(results, {
615
- reading,
616
- phaseModels: readPhaseModels(root),
617
- sprintDirectory,
618
- sentChips,
619
- restNoteOf: (lane) =>
620
- restNote(readLaneReport(laneReportPath(worktreeOf(root, settings, lane), lane))?.text ?? null),
621
- }),
727
+ views,
622
728
  documents: {
623
729
  sprint: markdown,
624
730
  plans: documentsPlans,
625
731
  laneOrder: [...laneSet.lanes, ...laneSet.unlisted].map((lane) => lane.name),
626
732
  lanes: board.lanes,
627
733
  },
628
- planned: plannedOf(source, documentsPlans, results, sprintDirectory, now),
734
+ planned,
629
735
  files: [sprintFile, ...results.flatMap((result) => result.files)],
630
736
  titles: new Map(
631
737
  results.flatMap(({ view, pane }) =>
@@ -636,13 +742,8 @@ export const loadSnapshot = async (
636
742
  watch: reading?.health,
637
743
  waiting,
638
744
  groomedDays: groomedDaysAgo(liveBoard, settings.planLanguage, new Date(now)),
639
- next: nextBlock(
640
- liveBoard,
641
- settings.planLanguage,
642
- waiting.map(({ item }) => item),
643
- ),
644
- appointments: appointmentBlock(liveBoard, settings.planLanguage, new Date(now)),
645
- ...(reading?.sources ? { sourceBlock: { sources: withSourceTones(reading.sources, herdSources), now } } : {}),
745
+ yourTurn: yourTurnOfBoard(liveBoard, settings.planLanguage, waiting, now),
746
+ ...(reading?.sources ? { sourceBlock: { sources: withSourceStyle(reading.sources, settings), now } } : {}),
646
747
  tones: settings.tones,
647
748
  unplaced: unplacedOf(laneSet.sources, laneSet.lanes, Boolean(options.lane)),
648
749
  ...(ticketSources.length > 0 ? { watchesTickets: true } : {}),
@@ -1,6 +1,6 @@
1
1
  import type { WaitingItem } from '../plan-format/waiting'
2
2
 
3
- /** Whether the source `source` has an entry titled `title` that an open row of "Waiting for you" asks about. */
3
+ /** Whether the source `source` has an entry titled `title` that an open row of "Your turn" asks about. */
4
4
  export type AskedCheck = (source: string, title: string) => boolean
5
5
 
6
6
  const same = (left: string, right: string): boolean => left.trim().toLowerCase() === right.trim().toLowerCase()
@@ -7,15 +7,24 @@ import type { AskedCheck } from './source-asked'
7
7
  import { chevronOf } from './symbols'
8
8
  import type { BlockItem } from './waiting-click'
9
9
 
10
- /** A source's status with the tone its config names for the mark while its report is new; null where it names none. */
11
- export type HerdSource = SourceStatus & { tone?: ToneName | null }
10
+ /**
11
+ * A source's status with the tone its config names for the mark while its report is new (null where it names none) and
12
+ * the label its config gives it, absent where it gives none.
13
+ */
14
+ export type HerdSource = SourceStatus & { tone?: ToneName | null; label?: string }
15
+
16
+ /** One source in the line of the folded sources block: what it says and the tone it wears, none for dim. */
17
+ export type SourceSummary = { text: string; tone?: ToneName }
12
18
 
13
19
  /** The sources block: the listed sources, and the clock (epoch milliseconds) their ages are counted against. */
14
20
  export type SourceBlock = { sources: HerdSource[]; now: number }
15
21
 
16
- /** What decides how the rows of the block look: the keys the user opened, and which entries a row of "Waiting for you" asks about. */
22
+ /** What decides how the rows of the block look: the keys the user opened, and which entries a row of "Your turn" asks about. */
17
23
  export type SourceView = { expanded: ReadonlySet<string>; asked: AskedCheck }
18
24
 
25
+ /** The widths of the count column and the time column over the entries shown; the count column is 0 without counts. */
26
+ type EntryColumns = { count: number; time: number }
27
+
19
28
  /** How many entries of an opened source show before `+<n> more`. */
20
29
  export const SOURCE_ENTRY_ROWS = 5
21
30
 
@@ -23,7 +32,7 @@ const SOURCE_MARK = '●'
23
32
  const ENTRY_MARK = '•'
24
33
  const MORE_MARK = ' '
25
34
  const SEPARATOR = ' · '
26
- const ASKED_TEXT = '↑ asked'
35
+ const ASKED_TEXT = '↑ in your turn'
27
36
  const MINUTE_MS = 60_000
28
37
  const HOUR_MS = 60 * MINUTE_MS
29
38
  const DAY_MS = 24 * HOUR_MS
@@ -114,10 +123,18 @@ const entryToneOf = (
114
123
  return entry.isNew ? (source.tone ?? newTone) : undefined
115
124
  }
116
125
 
117
- const entryTextOf = (entry: EntryStatus, now: number, isAsked: boolean): string =>
118
- [entry.count === null ? '' : `${entry.count}×`, clockOf(entry.at, now), isAsked ? ASKED_TEXT : '']
119
- .filter((part) => part !== '')
120
- .join(' ')
126
+ const countOf = ({ count }: EntryStatus): string => (count === null ? '' : `${count}×`)
127
+
128
+ const columnsOf = (entries: readonly EntryStatus[], now: number): EntryColumns => ({
129
+ count: Math.max(0, ...entries.map((entry) => textWidth(countOf(entry)))),
130
+ time: Math.max(0, ...entries.map(({ at }) => textWidth(clockOf(at, now)))),
131
+ })
132
+
133
+ /** `113× 11:11` with both columns padded, so the titles behind it start in one column whatever their width. */
134
+ const entryLeadOf = (entry: EntryStatus, now: number, columns: EntryColumns): string => {
135
+ const time = padVisible(clockOf(entry.at, now), columns.time)
136
+ return columns.count === 0 ? time : `${padVisible(countOf(entry), columns.count)} ${time}`
137
+ }
121
138
 
122
139
  const entryItem = (
123
140
  source: HerdSource,
@@ -125,17 +142,18 @@ const entryItem = (
125
142
  view: SourceView,
126
143
  now: number,
127
144
  newTone: ToneName,
128
- titleWidth: number,
145
+ columns: EntryColumns,
129
146
  ): BlockItem => {
130
147
  const isAsked = view.asked(source.name, entry.title)
131
148
  const tone = entryToneOf(isAsked, entry, source, newTone)
132
149
  return {
133
150
  key: entryKey(source.name, entry),
134
151
  mark: ENTRY_MARK,
135
- lead: padVisible(entry.title, titleWidth),
136
- text: entryTextOf(entry, now, isAsked),
152
+ lead: entryLeadOf(entry, now, columns),
153
+ text: isAsked ? `${ASKED_TEXT} ${entry.title}` : entry.title,
137
154
  url: entry.url,
138
155
  ask: { kind: ASK_KIND.sourceEntry, source: source.name, title: entry.title },
156
+ expands: false,
139
157
  ...(tone && { tone }),
140
158
  ...(isAsked && { dim: true }),
141
159
  }
@@ -153,10 +171,10 @@ const moreItem = (name: string, hidden: number, showsAll: boolean): BlockItem =>
153
171
  const entryItems = (source: HerdSource, view: SourceView, now: number, newTone: ToneName): BlockItem[] => {
154
172
  const showsAll = view.expanded.has(sourceMoreKey(source.name))
155
173
  const shown = showsAll ? source.entries : source.entries.slice(0, SOURCE_ENTRY_ROWS)
156
- const titleWidth = Math.max(0, ...shown.map(({ title }) => textWidth(title)))
174
+ const columns = columnsOf(shown, now)
157
175
  const hidden = source.entries.length - SOURCE_ENTRY_ROWS
158
176
  return [
159
- ...shown.map((entry) => entryItem(source, entry, view, now, newTone, titleWidth)),
177
+ ...shown.map((entry) => entryItem(source, entry, view, now, newTone, columns)),
160
178
  ...(hidden > 0 ? [moreItem(source.name, hidden, showsAll)] : []),
161
179
  ]
162
180
  }
@@ -166,9 +184,10 @@ const DEFAULT_VIEW: SourceView = { expanded: new Set(), asked: () => false }
166
184
  /**
167
185
  * The rows of the sources block: per source one row with its name, when it ran, since when it fails and, for a source
168
186
  * without entries, its newest report. A source with entries is folded to that row, and a click on it opens its entries
169
- * (`view.expanded` holds its key): each with title, count and time, at most five and then `+<n> more`, a click on an
170
- * entry opens its address, `[ask]` asks the coordinator to look at exactly that entry. An entry that a row of
171
- * "Waiting for you" already asks about is dim and says `↑ asked`, and it does not make the source amber.
187
+ * (`view.expanded` holds its key): each with count and time as a fixed column, then its title, cut at the pane's edge,
188
+ * at most five and then `+<n> more`. An entry never expands: a click opens its address, `[ask]` asks the coordinator
189
+ * to look at exactly that entry. An entry that a row of "Your turn" already asks about is dim and starts with
190
+ * `↑ in your turn`, and it does not make the source amber.
172
191
  * `newTone` is the tone of the role `new`, which a source without its own tone wears while it has something new.
173
192
  */
174
193
  export const sourceItems = (
@@ -184,5 +203,28 @@ export const sourceItems = (
184
203
  ])
185
204
  }
186
205
 
187
- /** The keys of all rows: what the fold of the block remembers. */
188
- export const sourceKeys = (sources: readonly SourceStatus[]): string[] => sources.map(sourceKey)
206
+ const entriesText = (count: number): string => `${count} ${count === 1 ? 'entry' : 'entries'}`
207
+
208
+ const summaryTextOf = (source: HerdSource, now: number, asked: AskedCheck): string => {
209
+ const name = source.label ?? source.name
210
+ if (source.failingSince !== null) return `${name} failing since ${agoOf(source.failingSince, now)}`
211
+ if (source.entries.length === 0) return `${name} ${source.ranAt === null ? 'not run yet' : agoOf(source.ranAt, now)}`
212
+ const fresh = source.entries.filter((entry) => isUnaskedNew(source.name, entry, asked)).length
213
+ return `${name} ${entriesText(source.entries.length)}${fresh > 0 ? `, ${fresh} new` : ''}`
214
+ }
215
+
216
+ /**
217
+ * What the folded sources block says of each source, `errors 5 entries, 1 new`, `uptime 7 h ago`: its label, then
218
+ * its entries and how many are new and not yet in "Your turn", or when it ran, or since when it fails. A source wears
219
+ * the tone of its mark (red while failing, its own or `newTone` with a new entry not in "Your turn"), else none.
220
+ */
221
+ export const sourceSummaries = (
222
+ sources: readonly HerdSource[],
223
+ now: number,
224
+ newTone: ToneName,
225
+ asked: AskedCheck,
226
+ ): SourceSummary[] =>
227
+ sources.map((source) => {
228
+ const tone = toneOf(source, newTone, asked)
229
+ return { text: summaryTextOf(source, now, asked), ...(tone && { tone }) }
230
+ })
@@ -236,7 +236,7 @@ const plannedRow = ({ title, plan, phaseIds, done, total }: PagePlanned): string
236
236
  const plannedSection = ({ planned }: SprintPage): string => {
237
237
  if (planned.length === 0) return ''
238
238
  const phases = plannedPhaseCount(planned)
239
- const summary = `<summary>○ ${escapeHtml(TEXTS.planned(phases, planned.length))}</summary>`
239
+ const summary = `<summary>○ ${escapeHtml(TEXTS.backlog(phases, planned.length))}</summary>`
240
240
  return `<details class="planned">${summary}<ul class="phases">${planned.map(plannedRow).join('')}</ul></details>`
241
241
  }
242
242