jackherd 1.15.1 → 1.16.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 (54) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +22 -19
  3. package/dist/{chunk-V4HOLGFP.js → chunk-4LUFR7RC.js} +19 -1
  4. package/dist/config.d.ts +47 -2
  5. package/dist/config.js +65 -4
  6. package/dist/plan-format.js +3 -19
  7. package/herdr-plugin/herdr-plugin.toml +1 -1
  8. package/install.sh +49 -4
  9. package/kit/hooks/hooks/merge-guard/decision.ts +17 -2
  10. package/kit/workflow/skills/follow/SKILL.md +18 -10
  11. package/kit/workflow/skills/orchestrate/SKILL.md +9 -6
  12. package/package.json +2 -1
  13. package/recipes/README.md +3 -2
  14. package/recipes/google-calendar.md +30 -0
  15. package/recipes/sources/google-calendar-today.ts +32 -0
  16. package/recipes/sources/google-calendar.ts +368 -0
  17. package/recipes/sources.md +15 -3
  18. package/schemas/jackherd.schema.json +12 -0
  19. package/schemas/sources.schema.json +141 -0
  20. package/src/check/skill-budget-defaults.ts +11 -4
  21. package/src/cli.ts +1 -0
  22. package/src/config/json-schema.ts +7 -3
  23. package/src/config/location.ts +7 -0
  24. package/src/config/personal-sources.ts +83 -0
  25. package/src/config/schema.ts +29 -0
  26. package/src/plans/herd-claim.ts +9 -31
  27. package/src/plans/layout.ts +135 -18
  28. package/src/plans/live.ts +54 -7
  29. package/src/plans/row-actions.ts +17 -10
  30. package/src/plans/settings.ts +20 -7
  31. package/src/plans/snapshot.ts +34 -22
  32. package/src/plans/source-rows.ts +35 -2
  33. package/src/secrets/command.ts +163 -0
  34. package/src/secrets/environment.ts +45 -0
  35. package/src/secrets/hidden-input.ts +51 -0
  36. package/src/secrets/listing.ts +63 -0
  37. package/src/secrets/options.ts +45 -0
  38. package/src/secrets/store.ts +74 -0
  39. package/src/shared/process-info.ts +89 -0
  40. package/src/sync/command-context.ts +26 -4
  41. package/src/sync/render.ts +9 -4
  42. package/src/watch/command.ts +44 -12
  43. package/src/watch/lane-report.ts +2 -1
  44. package/src/watch/plan-badges-read.ts +28 -10
  45. package/src/watch/source-dry-run-command.ts +23 -12
  46. package/src/watch/source-dry-run.ts +20 -3
  47. package/src/watch/source-reload.ts +100 -0
  48. package/src/watch/source-run.ts +63 -7
  49. package/src/watch/source-tickets.ts +15 -0
  50. package/src/watch/sources.ts +21 -0
  51. package/src/watch/texts.ts +1 -1
  52. package/src/watch/watch-events.ts +9 -0
  53. package/src/watch/watched-source.ts +39 -0
  54. package/src/watch/watcher-lock.ts +8 -1
@@ -98,7 +98,17 @@ import {
98
98
  import type { SentChip } from './sent-files'
99
99
  import { shownWithMore } from './shown-with-more'
100
100
  import { askedCheckOf } from './source-asked'
101
- import { type SourceBlock, type SourceSummary, sourceItems, sourceSummaries } from './source-rows'
101
+ import {
102
+ entryBlockItems,
103
+ entryBlockSources,
104
+ type HerdSource,
105
+ type SourceBlock,
106
+ type SourceSummary,
107
+ sourceItems,
108
+ sourceMoreKey,
109
+ sourceSummaries,
110
+ statusOnly,
111
+ } from './source-rows'
102
112
  import { chevronOf, LESS_MARK, NO_AGENT_SYMBOL, statusSymbol, statusWord, TURN_MARK } from './symbols'
103
113
  import { BADGE_TEXTS, type BadgeTexts, checksText, foldedChecksText, laneMarkText, TEXTS } from './texts'
104
114
  import { paintOfRole, paintOfTone, toneOfRole } from './tones'
@@ -177,14 +187,16 @@ export type OverviewView = {
177
187
 
178
188
  /**
179
189
  * The drawn screen: a fixed header and footer around a body that scrolls; body lines and targets count from the body's
180
- * top. `yourTurnBlock` and `sourceBlock` are what a click on these blocks is matched against, null while the block is
181
- * not drawn.
190
+ * top. `yourTurnBlock`, `entryBlocks` and `sourceBlock` are what a click on these blocks is matched against, null (or
191
+ * no entry block) while the block is not drawn.
182
192
  */
183
193
  export type Screen = {
184
194
  header: string[]
185
195
  body: Frame
186
196
  footer: string[]
187
197
  yourTurnBlock: BlockHit | null
198
+ /** The blocks of the sources that show their entries, in their order under "Your turn". */
199
+ entryBlocks: BlockHit[]
188
200
  sourceBlock: BlockHit | null
189
201
  /** Every clickable row by the screen line it starts on (header lines first, then the body unscrolled): what the mouse and the keys land on. */
190
202
  focusHits: FocusHit[]
@@ -227,10 +239,12 @@ type TopBlock = {
227
239
  hidden: number
228
240
  expanded: boolean
229
241
  rows: BlockRow[]
242
+ /** The mark in front of the name in place of the chevron, for a block that does not fold; absent for the chevron. */
243
+ headMark?: string
230
244
  }
231
245
 
232
246
  /** What the head of a block says: its name, its count, its balance and the paint of the count. */
233
- type BlockHeadParts = Pick<TopBlock, 'name' | 'count' | 'balance' | 'countPaint'>
247
+ type BlockHeadParts = Pick<TopBlock, 'name' | 'count' | 'balance' | 'countPaint' | 'headMark'>
234
248
 
235
249
  /** The blocks on top of the herd one under the other: their lines below the title, and the hit of each drawn block. */
236
250
  type BlockStack = { lines: string[]; hits: (BlockHit | null)[] }
@@ -903,24 +917,57 @@ const SUBAGENT_ACTIVITY_INDENT = ' '
903
917
  /** How long a subagent's name may grow before its action, so a narrow pane cuts the name and keeps the action. */
904
918
  const SUBAGENT_NAME_CELLS = 28
905
919
 
906
- /** One dim line of activity behind `indent`, cut to `width`; the cut is made before the paint, on plain text. */
907
- const activityLine = (indent: string, text: string, width: number): string =>
908
- paint.dim(fit(`${indent}${ACTIVITY_MARK} ${text}`, width))
920
+ /** The key of a lane's activity line, flipped in `expanded`. */
921
+ const laneActivityKey = (lane: string): string => `activity>${lane}`
922
+
923
+ /**
924
+ * The key of a subagent's activity line, by its label: a subagent that moves up when another ends keeps its own state,
925
+ * and the key is no child of the lane's, so folding one leaves the other.
926
+ */
927
+ const subagentActivityKey = (lane: string, label: string): string => `subagent-activity>${lane}>${label}`
928
+
929
+ /**
930
+ * One dim line of activity behind `indent`, cut to the pane on plain text before the paint. A cut line is a row: a
931
+ * click unfolds it over several lines, the text hanging under its first word, and a second click folds it; a line
932
+ * that fits is no click target.
933
+ */
934
+ const activityFrame = (
935
+ key: string,
936
+ indent: string,
937
+ text: string,
938
+ { state, width }: { state: Selection; width: number },
939
+ ): Frame => {
940
+ const head = `${indent}${ACTIVITY_MARK} `
941
+ const cut = textWidth(`${head}${text}`) > width
942
+ const lines =
943
+ cut && state.expanded.has(key)
944
+ ? wrap(text, Math.max(width - textWidth(head), 1)).map((line, index) =>
945
+ index === 0 ? `${head}${line}` : `${' '.repeat(textWidth(head))}${line}`,
946
+ )
947
+ : [fit(`${head}${text}`, width)]
948
+ const dimmed = lines.map((line) => paint.dim(line))
949
+ return cut ? rowFrame(key, dimmed, [], { state, width }) : textFrame(dimmed)
950
+ }
909
951
 
910
952
  /**
911
953
  * The last action of a working lane's agent and of each of its running subagents, one dim line each, under the lane
912
954
  * line in the column of its children. None while the lane does not work, or without a source for it.
913
955
  */
914
- const activityLines = ({ activity, status }: LaneView, width: number): string[] => {
956
+ const activityFrames = (
957
+ { activity, status, progress }: LaneView,
958
+ context: { state: Selection; width: number },
959
+ ): Frame[] => {
915
960
  if (!activity || status.agent !== AGENT_STATUS.working) return []
961
+ const lane = progress.lane.name
916
962
  const indent = ' '.repeat(LANE_HEAD_CELLS)
917
963
  return [
918
- ...(activity.lane === null ? [] : [activityLine(indent, activity.lane, width)]),
964
+ ...(activity.lane === null ? [] : [activityFrame(laneActivityKey(lane), indent, activity.lane, context)]),
919
965
  ...activity.subagents.map(({ label, description, line }) =>
920
- activityLine(
966
+ activityFrame(
967
+ subagentActivityKey(lane, label),
921
968
  `${indent}${SUBAGENT_ACTIVITY_INDENT}`,
922
969
  `${ellipsize(description ?? label, SUBAGENT_NAME_CELLS)}: ${line}`,
923
- width,
970
+ context,
924
971
  ),
925
972
  ),
926
973
  ]
@@ -950,8 +997,7 @@ const laneBlock = (
950
997
  const open = isLaneOpen(state, view.progress.lane.name, view.status.agent)
951
998
  const lines = laneLines(view, open, { width, taskStyle }, columns, reason)
952
999
  const lane = rowFrame(laneKey(view.progress.lane.name), lines, [], context)
953
- const activity = activityLines(view, width)
954
- const row = activity.length > 0 ? joinFrames([lane, textFrame(activity)]) : lane
1000
+ const row = joinFrames([lane, ...activityFrames(view, { state, width })])
955
1001
  if (!open) return row
956
1002
  const inner = narrower(context, LANE_HEAD_CELLS)
957
1003
  const head = {
@@ -1085,9 +1131,13 @@ const blockBar = (): string => ' '
1085
1131
  * above the block; `▸ <name> · <n> <balance>` while folded. The count (in `countPaint` while it is more than 0) is
1086
1132
  * left out for a block without one; the balance comes painted.
1087
1133
  */
1088
- const blockHead = ({ name, count, balance, countPaint }: BlockHeadParts, open: boolean, width: number): string => {
1134
+ const blockHead = (
1135
+ { name, count, balance, countPaint, headMark }: BlockHeadParts,
1136
+ open: boolean,
1137
+ width: number,
1138
+ ): string => {
1089
1139
  const counted = count === null ? '' : ` ${ITEM_SEPARATOR} ${count > 0 ? countPaint(String(count)) : count}`
1090
- const lead = `${paint.dim(chevronOf(open))} ${name}${counted}${balance ? ` ${balance}` : ''}`
1140
+ const lead = `${paint.dim(headMark ?? chevronOf(open))} ${name}${counted}${balance ? ` ${balance}` : ''}`
1091
1141
  if (!open) return fit(lead, width)
1092
1142
  const cut = fit(lead, Math.max(width - 2, 1))
1093
1143
  return `${cut} ${paint.dim(RULE.repeat(Math.max(width - visibleLength(cut) - 1, 0)))}`
@@ -1226,11 +1276,12 @@ const sourceSummaryText = (summaries: readonly SourceSummary[]): string =>
1226
1276
  * fails, and a source's entries under it once opened.
1227
1277
  */
1228
1278
  const sourceLines = (
1229
- { sources, now }: SourceBlock,
1279
+ { sources: listed, now }: SourceBlock,
1230
1280
  awaiting: readonly WaitingItem[],
1231
1281
  state: Selection,
1232
1282
  width: number,
1233
1283
  ): BlockLines => {
1284
+ const sources = listed.map(statusOnly)
1234
1285
  const shown = state.expanded.has(SOURCE_MORE_KEY) ? sources : sources.slice(0, SHOWN_BLOCK_ROWS)
1235
1286
  const asked = askedCheckOf(awaiting)
1236
1287
  const newTone = toneOfRole(TONE_ROLE.new)
@@ -1258,6 +1309,69 @@ const sourceLinesOf = (
1258
1309
  ): BlockLines | null =>
1259
1310
  sourceBlock && sourceBlock.sources.length > 0 ? sourceLines(sourceBlock, awaiting, state, width) : null
1260
1311
 
1312
+ /**
1313
+ * How many entry rows all blocks of entries show together while folded: one calendar keeps the five of the other
1314
+ * blocks, a second one takes from the same five, so the head of the herd, which does not scroll, leaves the lanes room.
1315
+ */
1316
+ const ENTRY_BLOCK_ROW_BUDGET = SHOWN_BLOCK_ROWS
1317
+
1318
+ /**
1319
+ * The rows each block of entries shows while folded, in the order of the sources: the shared budget, with one row kept
1320
+ * for each block after it, and at least one row per block, so a block past the budget still shows its first entry; a
1321
+ * block with fewer entries than its share leaves the rest to the blocks after it. More blocks than the budget take one
1322
+ * row each, past the budget.
1323
+ */
1324
+ const entryRowShares = (counts: readonly number[], budget: number): number[] => {
1325
+ let left = budget
1326
+ return counts.map((count, index) => {
1327
+ const share = Math.min(count, Math.max(left - (counts.length - index - 1), 1))
1328
+ left -= share
1329
+ return share
1330
+ })
1331
+ }
1332
+
1333
+ /** A source with a block of its own, the clock its times are counted against and the rows it shows while folded. */
1334
+ type EntryBlock = { source: HerdSource; now: number; share: number }
1335
+
1336
+ /**
1337
+ * The block of a source that shows its entries: always open, headed by its label (else its name) without a chevron,
1338
+ * its `share` of entries and `+<n>` for the rest, which opens all of them and folds them again as in the other blocks.
1339
+ * The head is no click target; an entry with an address opens it.
1340
+ */
1341
+ const entryBlockLines = ({ source, now, share }: EntryBlock, state: Selection, width: number): BlockLines => {
1342
+ const moreKey = sourceMoreKey(source.name)
1343
+ const expanded = state.expanded.has(moreKey)
1344
+ const hidden = source.entries.length - share
1345
+ const block: TopBlock = {
1346
+ name: source.label ?? source.name,
1347
+ count: null,
1348
+ balance: '',
1349
+ markPaint: paint.dim,
1350
+ focused: (key) => isFocused(state, key),
1351
+ allBold: (key) => buttonsAllBold(state, key),
1352
+ countPaint: (text) => text,
1353
+ moreKey: hidden > 0 ? moreKey : null,
1354
+ hidden,
1355
+ expanded,
1356
+ rows: allBlockRows(entryBlockItems(source, now, expanded ? source.entries.length : share), new Set(), width),
1357
+ headMark: ' ',
1358
+ }
1359
+ const { lines, hit } = openBlockLines(block, '', width)
1360
+ return { open: true, lines, hit: { rows: hit.rows, moreKey: hit.moreKey } }
1361
+ }
1362
+
1363
+ const entryBlocksOf = (sourceBlock: SourceBlock | undefined, state: Selection, width: number): BlockLines[] => {
1364
+ if (!sourceBlock) return []
1365
+ const sources = entryBlockSources(sourceBlock.sources)
1366
+ const shares = entryRowShares(
1367
+ sources.map(({ entries }) => entries.length),
1368
+ ENTRY_BLOCK_ROW_BUDGET,
1369
+ )
1370
+ return sources.map((source, index) =>
1371
+ entryBlockLines({ source, now: sourceBlock.now, share: shares[index] ?? 0 }, state, width),
1372
+ )
1373
+ }
1374
+
1261
1375
  /**
1262
1376
  * The blocks one under the other, `offset` lines below the top of the screen, a blank line after each, so one stands
1263
1377
  * between neighbours and one between the last and the lanes. An absent block (null) takes no line and has no hit.
@@ -1429,7 +1543,9 @@ export const renderScreen = (
1429
1543
  const nowLines = unplaced.map((task) => nowLine(task, width, taskStyle))
1430
1544
  const above = nowLines.length > 0 ? [titleLine, rule(width), ...nowLines] : [titleLine]
1431
1545
  const listed = sourceLinesOf(sourceBlock, awaiting, state, width)
1432
- const stack = stackBlocks([turn, listed], above.length)
1546
+ const entryBlocks = entryBlocksOf(sourceBlock, state, width)
1547
+ const stack = stackBlocks([turn, ...entryBlocks, listed], above.length)
1548
+ const sourcesSlot = 1 + entryBlocks.length
1433
1549
  const sections = [
1434
1550
  ...(active.length > 0 ? [lanesFrame(active, turnRowsOf, { state, width, badgeTexts, taskStyle })] : []),
1435
1551
  ...(waiting.length > 0 ? [waitingFrame(waiting, state, width)] : []),
@@ -1448,7 +1564,8 @@ export const renderScreen = (
1448
1564
  const body = { ...plainBody, lines: emphasisedAt(plainBody.lines, plainHeader.length, focusHits, state, width) }
1449
1565
  return {
1450
1566
  yourTurnBlock: stack.hits[0],
1451
- sourceBlock: stack.hits[1],
1567
+ entryBlocks: stack.hits.slice(1, sourcesSlot).filter((hit) => hit !== null),
1568
+ sourceBlock: stack.hits[sourcesSlot],
1452
1569
  focusHits: state.showLegend ? focusHits.filter(({ line }) => line < plainHeader.length) : focusHits,
1453
1570
  rowKeys: [...blockHits.map(({ key }) => key), ...targetKeys(body.targets)],
1454
1571
  header,
package/src/plans/live.ts CHANGED
@@ -35,6 +35,7 @@ import { REFRESH_PACE } from './refresh-pace'
35
35
  import { TAKEN_OVER_CODE, TAKEOVER_SIGNAL } from './restart-decision'
36
36
  import { closeRow } from './row-actions'
37
37
  import { blockRowByKey, type HoverScreen, hoveredSelection, rehovered, withoutHover } from './row-focus'
38
+ import { DONE_KIND, type DoneTarget } from './row-targets'
38
39
  import {
39
40
  applyClick,
40
41
  inputOutcome,
@@ -76,6 +77,7 @@ import {
76
77
  startSelection,
77
78
  } from './view'
78
79
  import { type BlockClick, type BlockHit, blockClick, blockHeadClick, mainClickOf } from './waiting-click'
80
+ import type { TurnRow, YourTurn } from './your-turn'
79
81
 
80
82
  /**
81
83
  * Where the herd's reads come from: its own reader, or the read the watcher publishes for every herd of the board
@@ -97,6 +99,8 @@ type Live = {
97
99
  errors: string[]
98
100
  /** One footer line about the last action of the person, such as where the sprint page was written; cleared by the next key. */
99
101
  notice: string | null
102
+ /** What a `[done]` closed, left out of "Your turn" from the click until a read no longer holds its row. */
103
+ closing: DoneTarget[]
100
104
  reported: Map<string, string | null>
101
105
  /** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of the page. */
102
106
  read: Snapshot | null
@@ -123,6 +127,39 @@ type Live = {
123
127
 
124
128
  const QUIT_KEYS = new Set(['\x03', 'q'])
125
129
 
130
+ /** Whether `row` of "Your turn" is what `target` closes: a record by its id, a board row or an appointment by its cells. */
131
+ const isClosedBy = (target: DoneTarget, { waiting, appointment }: TurnRow): boolean => {
132
+ switch (target.kind) {
133
+ case DONE_KIND.record:
134
+ return waiting?.id === target.id
135
+ case DONE_KIND.boardRow:
136
+ return waiting?.id === undefined && waiting?.since === target.since && waiting.item === target.item
137
+ case DONE_KIND.appointment:
138
+ return appointment?.when === target.when && appointment.action === target.action
139
+ }
140
+ }
141
+
142
+ const isClosing = (closing: readonly DoneTarget[], row: TurnRow): boolean =>
143
+ closing.some((target) => isClosedBy(target, row))
144
+
145
+ /** "Your turn" without the rows `closing` names, so a closed row leaves at the click, not at the next read. */
146
+ export const withoutClosing = (turn: YourTurn, closing: readonly DoneTarget[]): YourTurn => {
147
+ if (closing.length === 0) return turn
148
+ const open = (rows: readonly TurnRow[]) => rows.filter((row) => !isClosing(closing, row))
149
+ return { now: open(turn.now), planned: open(turn.planned) }
150
+ }
151
+
152
+ /** The closings a read still holds a row of; one whose row the read no longer holds is closed on the board as well. */
153
+ export const pendingClosings = (turn: YourTurn | undefined, closing: readonly DoneTarget[]): DoneTarget[] => {
154
+ const rows = turn ? [...turn.now, ...turn.planned] : []
155
+ return closing.filter((target) => rows.some((row) => isClosedBy(target, row)))
156
+ }
157
+
158
+ const withClosingLeftOut = (snapshot: Snapshot, closing: readonly DoneTarget[]): Snapshot =>
159
+ snapshot.yourTurn && closing.length > 0
160
+ ? { ...snapshot, yourTurn: { ...snapshot.yourTurn, turn: withoutClosing(snapshot.yourTurn.turn, closing) } }
161
+ : snapshot
162
+
126
163
  /** The sidebar titles that differ from the ones last reported, in the order of `titles`; null clears one. */
127
164
  export const changedTitles = (
128
165
  titles: ReadonlyMap<string, string | null>,
@@ -171,7 +208,7 @@ const renderContextOf = (live: Live): RenderContext => ({
171
208
 
172
209
  /** The screen for the current state, with the window scrolled to keep the cursor in view. */
173
210
  const renderLive = (live: Live, snapshot: Snapshot): { screen: Screen; top: number } => {
174
- const screen = renderSnapshot(snapshot, renderContextOf(live))
211
+ const screen = renderSnapshot(withClosingLeftOut(snapshot, live.closing), renderContextOf(live))
175
212
  return { screen, top: windowTop(live.view, screen.body.cursorLine, screen.body.lines.length, bodyRows(screen)) }
176
213
  }
177
214
 
@@ -236,7 +273,7 @@ const followShimmer = (live: Live, prepared: PreparedShimmer) => {
236
273
  const drawHover = (live: Live) => {
237
274
  live.frame = null
238
275
  if (!live.snapshot) return
239
- live.plain ??= renderUnfocused(live.snapshot, renderContextOf(live))
276
+ live.plain ??= renderUnfocused(withClosingLeftOut(live.snapshot, live.closing), renderContextOf(live))
240
277
  const laid = focusedScreen(live.plain, live.view.selection, paneWidth(), agentOfSnapshot(live.snapshot))
241
278
  if (laid === null) redraw(live)
242
279
  else write(live, laid, live.view.top)
@@ -311,6 +348,7 @@ const takeRead = (live: Live, outcome: ReadOutcome, notes: readonly string[]): b
311
348
  )
312
349
  live.view = { ...live.view, selection: folded }
313
350
  live.snapshot = next
351
+ live.closing = pendingClosings(next.yourTurn?.turn, live.closing)
314
352
  live.errors = [...next.errors, ...notes, ...reportTitles(live.source, next.titles, live.reported)]
315
353
  return redraw(live)
316
354
  }
@@ -474,12 +512,14 @@ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
474
512
  return
475
513
  }
476
514
  if ('done' in outcome) {
477
- live.notice = closeRow(outcome.done, {
515
+ const { closed, notice } = closeRow(outcome.done, {
478
516
  sprintFile: live.source.sprintFile,
479
517
  language: live.source.settings.planLanguage,
480
518
  now: new Date(),
481
519
  closeRecords: (ids) => closeWaiting(live.source.root, ids, Date.now()),
482
520
  })
521
+ if (closed) live.closing = [...live.closing, outcome.done]
522
+ live.notice = notice
483
523
  return
484
524
  }
485
525
  const reason = openInBrowser(outcome.open)
@@ -487,14 +527,15 @@ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
487
527
  }
488
528
 
489
529
  /**
490
- * A plain click on a block on top of the herd, matched against `hit` as it was drawn: the head folds or opens it, a
530
+ * A plain click on a block on top of the herd, matched against `hit` as it was drawn: the head of `block` folds or
531
+ * opens it (a block of entries, `block` null, has no such head), a
491
532
  * cut-off row expands, a file or Artifact opens, a button asks the coordinator or closes the row; the failure or the
492
533
  * result shows in the footer.
493
534
  */
494
- const clickBlock = (live: Live, block: TopBlockId, hit: BlockHit | null, input: string): boolean => {
535
+ const clickBlock = (live: Live, block: TopBlockId | null, hit: BlockHit | null, input: string): boolean => {
495
536
  const mouse = parseMouse(input)
496
537
  if (!mouse || !hit) return false
497
- if (blockHeadClick(hit.firstLine - 1, mouse)) {
538
+ if (block !== null && blockHeadClick(hit.firstLine - 1, mouse)) {
498
539
  live.view = {
499
540
  ...live.view,
500
541
  selection: applyClick(live.view.selection, topBlockHeadKey(block)),
@@ -533,7 +574,11 @@ const activateBlockRow = (live: Live, screen: Screen, input: string): boolean =>
533
574
  return true
534
575
  }
535
576
 
536
- const blocksOf = ({ yourTurnBlock, sourceBlock }: Screen): (BlockHit | null)[] => [yourTurnBlock, sourceBlock]
577
+ const blocksOf = ({ yourTurnBlock, entryBlocks, sourceBlock }: Screen): (BlockHit | null)[] => [
578
+ yourTurnBlock,
579
+ ...entryBlocks,
580
+ sourceBlock,
581
+ ]
537
582
 
538
583
  const handleInput = (live: Live, input: string) => {
539
584
  const { snapshot, screen } = live
@@ -556,6 +601,7 @@ const handleInput = (live: Live, input: string) => {
556
601
  }
557
602
  if (
558
603
  clickBlock(live, TOP_BLOCK.yourTurn, screen.yourTurnBlock, input) ||
604
+ screen.entryBlocks.some((hit) => clickBlock(live, null, hit, input)) ||
559
605
  clickBlock(live, TOP_BLOCK.sources, screen.sourceBlock, input)
560
606
  ) {
561
607
  redraw(live)
@@ -594,6 +640,7 @@ export const runLive = (source: Source, key: string | null = null): void => {
594
640
  view: { selection: overviewSelection(), top: 0, follow: true },
595
641
  errors: [],
596
642
  notice: null,
643
+ closing: [],
597
644
  reported: new Map(),
598
645
  read: null,
599
646
  whole: null,
@@ -16,9 +16,16 @@ export type CloseDependencies = {
16
16
  closeRecords: (ids: readonly string[]) => number
17
17
  }
18
18
 
19
- const closeRecord = (id: string, { closeRecords }: CloseDependencies): string => {
20
- if (!isQuestionId(id)) return TEXTS.notClosed('not a record id')
21
- return closeRecords([id]) > 0 ? TEXTS.closed(`record ${id}`) : TEXTS.notClosed('record not found or closed already')
19
+ /** Whether the row was closed, and the footer line that says so or why not. */
20
+ export type CloseResult = { closed: boolean; notice: string }
21
+
22
+ const closedAs = (subject: string): CloseResult => ({ closed: true, notice: TEXTS.closed(subject) })
23
+
24
+ const notClosed = (reason: string): CloseResult => ({ closed: false, notice: TEXTS.notClosed(reason) })
25
+
26
+ const closeRecord = (id: string, { closeRecords }: CloseDependencies): CloseResult => {
27
+ if (!isQuestionId(id)) return notClosed('not a record id')
28
+ return closeRecords([id]) > 0 ? closedAs(`record ${id}`) : notClosed('record not found or closed already')
22
29
  }
23
30
 
24
31
  const closedBoard = (target: DoneTarget, board: string, { language, now }: CloseDependencies): string | null => {
@@ -31,22 +38,22 @@ const subjectOfRow = (target: DoneTarget): string => {
31
38
  return target.kind === DONE_KIND.appointment ? `appointment ${boardRowKey(target.when, target.action)}` : ''
32
39
  }
33
40
 
34
- const closeBoardRow = (target: DoneTarget, dependencies: CloseDependencies): string => {
41
+ const closeBoardRow = (target: DoneTarget, dependencies: CloseDependencies): CloseResult => {
35
42
  try {
36
43
  const board = readFileSync(dependencies.sprintFile, 'utf8')
37
44
  const closed = closedBoard(target, board, dependencies)
38
- if (closed === null) return TEXTS.notClosed('row not found in the board')
45
+ if (closed === null) return notClosed('row not found in the board')
39
46
  writeFileSync(dependencies.sprintFile, closed)
40
- return TEXTS.closed(subjectOfRow(target))
47
+ return closedAs(subjectOfRow(target))
41
48
  } catch (error) {
42
- return TEXTS.notClosed(errorText(error))
49
+ return notClosed(errorText(error))
43
50
  }
44
51
  }
45
52
 
46
53
  /**
47
54
  * Closes what a `[done]` button names: a log record by its id, or a row of the board's file by writing the stamp into its
48
- * status cell. Returns the footer line. The board is read again just before it is written, so a row the coordinator
49
- * changed since the herd drew it is found by its text or not at all.
55
+ * status cell. Returns whether it closed and the footer line. The board is read again just before it is written, so a
56
+ * row the coordinator changed since the herd drew it is found by its text or not at all.
50
57
  */
51
- export const closeRow = (target: DoneTarget, dependencies: CloseDependencies): string =>
58
+ export const closeRow = (target: DoneTarget, dependencies: CloseDependencies): CloseResult =>
52
59
  target.kind === DONE_KIND.record ? closeRecord(target.id, dependencies) : closeBoardRow(target, dependencies)
@@ -5,6 +5,8 @@ import { PROJECT_CONFIG_PATH } from '../config/location'
5
5
  import { readConfig } from '../config/read-config'
6
6
  import type { ToneName } from '../config/tones'
7
7
  import type { TicketSource } from '../watch/plan-badges'
8
+ import type { SourceShow } from '../watch/sources'
9
+ import { type WatchedSource, watchedSourceOf } from '../watch/watched-source'
8
10
 
9
11
  export const CONFIG_PATH = PROJECT_CONFIG_PATH
10
12
 
@@ -20,14 +22,29 @@ export type PlansSettings = Pick<
20
22
  context: JackherdConfig['plans']['context']
21
23
  /** The `watch.sources` marked `ticket`, whose entries the view shows as badges. */
22
24
  ticketSources: TicketSource[]
23
- /** The other `watch.sources` by name, with the tone each names for a new report; the herd lists them in a block of their own. */
24
- herdSources: { name: string; tone: ToneName | null }[]
25
+ /**
26
+ * The other `watch.sources` by name, with the tone each names for a new report and, when set, how it shows its
27
+ * entries; the herd lists them in a block of their own.
28
+ */
29
+ herdSources: { name: string; tone: ToneName | null; show?: SourceShow }[]
25
30
  /** By source name: what the herd puts before a row of "Your turn" that came from the source, its `label` or else its name. */
26
31
  sourceLabels: Record<string, string>
27
32
  /** The tone of each role in the herd. */
28
33
  tones: JackherdConfig['plans']['tones']
29
34
  }
30
35
 
36
+ /** What the herd draws of the watcher's sources: from the watcher's state when it keeps them, else from the config. */
37
+ export type HerdSourceSettings = Pick<PlansSettings, 'ticketSources' | 'herdSources' | 'sourceLabels'>
38
+
39
+ /** The herd's view of `sources`, in their order: the tickets, the others with their tone and `show`, every label. */
40
+ export const herdSourceSettingsOf = (sources: readonly WatchedSource[]): HerdSourceSettings => ({
41
+ ticketSources: sources.flatMap(({ name, lane, ticket }) => (ticket ? [{ name, lane }] : [])),
42
+ herdSources: sources.flatMap(({ name, ticket, tone, show }) =>
43
+ ticket ? [] : [{ name, tone, ...(show === null ? {} : { show }) }],
44
+ ),
45
+ sourceLabels: Object.fromEntries(sources.map(({ name, label }) => [name, label ?? name])),
46
+ })
47
+
31
48
  /** The locale of the times the herd writes (the refresh error, the last fetch): English, with a 24-hour clock. */
32
49
  export const CLOCK_LOCALE = 'en-GB'
33
50
 
@@ -45,11 +62,7 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
45
62
  shimmer: config.plans.shimmer,
46
63
  linkKinds: config.plans.linkKinds,
47
64
  context: config.plans.context,
48
- ticketSources: config.watch.sources.flatMap(({ name, lane, ticket }) =>
49
- ticket ? [{ name, lane: lane ?? null }] : [],
50
- ),
51
- herdSources: config.watch.sources.flatMap(({ name, ticket, tone }) => (ticket ? [] : [{ name, tone: tone ?? null }])),
52
- sourceLabels: Object.fromEntries(config.watch.sources.map(({ name, label }) => [name, label ?? name])),
65
+ ...herdSourceSettingsOf(config.watch.sources.map(watchedSourceOf)),
53
66
  tones: config.plans.tones,
54
67
  })
55
68
 
@@ -23,7 +23,7 @@ import { isRestingAgent } from '../terminal/types'
23
23
  import { laneReportPath, readLaneReport } from '../watch/lane-report-file'
24
24
  import { restNote } from '../watch/lane-rest'
25
25
  import type { SourceTicket, WatchBadges, WatchHealth } from '../watch/plan-badges'
26
- import { readWatchReading } from '../watch/plan-badges-read'
26
+ import { readWatchReading, type SourcedWatchReading } from '../watch/plan-badges-read'
27
27
  import type { SourceStatus } from '../watch/source-statuses'
28
28
  import { assignSignals, laneOfDirectory, type UnplannedTask } from './active-tasks'
29
29
  import { activityLineOf, type LaneSubagents } from './activity-read'
@@ -65,7 +65,7 @@ import { markStale, type PlannedPlan, plannedPlans } from './planned'
65
65
  import type { PullRequest } from './pull-requests'
66
66
  import type { ReadCache } from './read-cache'
67
67
  import { type SentChip, sentChipsByLane } from './sent-files'
68
- import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from './settings'
68
+ import { baseRefOf, boardRepoPathOf, type HerdSourceSettings, type PlansSettings, worktreeOf } from './settings'
69
69
  import { signalledTitle } from './signalled-phase'
70
70
  import { readAllSignalsSliced } from './signals-read'
71
71
  import { createSliceGate, type SliceGate } from './slice-gate'
@@ -194,7 +194,7 @@ type LaneSet = { lanes: SprintLane[]; unlisted: UnlistedLane[]; sources: LaneSou
194
194
 
195
195
  /** What a lane's view is decorated with after it was read. */
196
196
  type Decorations = {
197
- reading: ReturnType<typeof readWatchReading> | null
197
+ reading: SourcedWatchReading | null
198
198
  phaseModels: readonly PhaseModelRecord[]
199
199
  sprintDirectory: string
200
200
  sentChips: ReadonlyMap<string | null, SentChip[]>
@@ -635,17 +635,40 @@ const decoratedViews = (
635
635
  ),
636
636
  )
637
637
 
638
- /** Each source with the tone and the label its config gives it. */
638
+ /** Each source with the tone, the label and the `show` its config gives it. */
639
639
  const withSourceStyle = (
640
640
  statuses: readonly SourceStatus[],
641
- { herdSources, sourceLabels }: Pick<PlansSettings, 'herdSources' | 'sourceLabels'>,
641
+ { herdSources, sourceLabels }: HerdSourceSettings,
642
642
  ): HerdSource[] =>
643
643
  statuses.map((status) => {
644
644
  const label = sourceLabels[status.name]
645
- const tone = herdSources.find(({ name }) => name === status.name)?.tone ?? null
646
- return { ...status, tone, ...(label === undefined ? {} : { label }) }
645
+ const config = herdSources.find(({ name }) => name === status.name)
646
+ return {
647
+ ...status,
648
+ tone: config?.tone ?? null,
649
+ ...(label === undefined ? {} : { label }),
650
+ ...(config?.show === undefined ? {} : { show: config.show }),
651
+ }
647
652
  })
648
653
 
654
+ /**
655
+ * The watcher's reading and the sources the view draws: those the watcher's state names, else the config's. The
656
+ * state is read every time, so a source the watcher took up since the herd started shows; the reading is null while
657
+ * the view follows no source and no pull request.
658
+ */
659
+ const watchReadingOf = (
660
+ root: string,
661
+ now: number,
662
+ fallback: HerdSourceSettings,
663
+ results: readonly LaneResult[],
664
+ ): { reading: SourcedWatchReading | null; sourceSettings: HerdSourceSettings } => {
665
+ const read = readWatchReading(root, now, fallback)
666
+ const { ticketSources, herdSources } = read.sourceSettings
667
+ const watched =
668
+ ticketSources.length > 0 || herdSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
669
+ return { reading: watched ? read : null, sourceSettings: read.sourceSettings }
670
+ }
671
+
649
672
  /**
650
673
  * The open phases no lane with an agent leads, a plan marked stale by its last change at `baseCommit`; none while the
651
674
  * view shows one lane.
@@ -712,25 +735,14 @@ export const loadSnapshot = async (
712
735
  const { parsed } = laneSet.sources
713
736
  const listedPlans = await readListedPlans(source, base, sprintDirectory, markdown, fallbacks)
714
737
  const documentsPlans = Object.assign({}, listedPlans, ...results.map((result) => result.texts))
715
- const { ticketSources, herdSources } = settings
716
- const watched =
717
- ticketSources.length > 0 || herdSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
718
738
  await gate.pass()
719
- const reading = watched
720
- ? readWatchReading(
721
- root,
722
- now,
723
- ticketSources,
724
- undefined,
725
- undefined,
726
- herdSources.map(({ name }) => name),
727
- )
728
- : null
739
+ const { reading, sourceSettings } = watchReadingOf(root, now, settings, results)
740
+ const { ticketSources } = sourceSettings
729
741
  await gate.pass()
730
742
  const waitingRecords = readWaitingRecords(root)
731
743
  const waiting = withSourcePrefix(
732
744
  waitingItemsOf(liveBoard, settings.planLanguage, waitingRecords, now),
733
- settings.sourceLabels,
745
+ sourceSettings.sourceLabels,
734
746
  )
735
747
  const sentChips = sentChipsByLane(waitingRecords.sent, worktrees, now)
736
748
  const notice = fallbacks.length > 0 ? fallbackNotice(fallbacks, baseRef, refExists(root, baseRef)) : null
@@ -782,7 +794,7 @@ export const loadSnapshot = async (
782
794
  waiting,
783
795
  groomedDays: groomedDaysAgo(liveBoard, settings.planLanguage, new Date(now)),
784
796
  yourTurn: yourTurnOfBoard(liveBoard, settings.planLanguage, waiting, now),
785
- ...(reading?.sources ? { sourceBlock: { sources: withSourceStyle(reading.sources, settings), now } } : {}),
797
+ ...(reading?.sources ? { sourceBlock: { sources: withSourceStyle(reading.sources, sourceSettings), now } } : {}),
786
798
  tones: settings.tones,
787
799
  ...(coordinator ? { coordinator: coordinator.view } : {}),
788
800
  unplaced: unplacedOf(unplacedSources, laneSet.lanes, Boolean(options.lane)),