jackherd 1.15.2 → 1.17.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 (161) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/INSTALL.md +2 -1
  3. package/README.md +26 -22
  4. package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
  5. package/dist/config.d.ts +33 -1
  6. package/dist/config.js +57 -5
  7. package/dist/plan-format.d.ts +18 -6
  8. package/dist/plan-format.js +8 -1
  9. package/docs/plan-contract.md +1 -0
  10. package/herdr-plugin/herdr-plugin.toml +4 -1
  11. package/herdr-plugin/restore-plans.ts +60 -26
  12. package/herdr-plugin/resume.ts +18 -0
  13. package/install.sh +1 -1
  14. package/kit/hooks/ask-rules.json +11 -0
  15. package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
  16. package/kit/hooks/hooks/approval-gate/index.ts +36 -7
  17. package/kit/hooks/hooks/approval-gate/run.ts +72 -16
  18. package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
  19. package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
  20. package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
  21. package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
  22. package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
  23. package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
  24. package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
  25. package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
  26. package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
  27. package/kit/hooks/hooks/merge-guard/decision.ts +438 -35
  28. package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
  29. package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
  30. package/kit/hooks/hooks/merge-guard/run.ts +110 -28
  31. package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
  32. package/kit/hooks/hooks/plain-question/form.ts +89 -0
  33. package/kit/hooks/hooks/plain-question/index.ts +44 -4
  34. package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
  35. package/kit/hooks/hooks/plain-question/run.ts +14 -7
  36. package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
  37. package/kit/hooks/hooks.json +11 -2
  38. package/kit/review/agents/skill-review.md +3 -2
  39. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  40. package/kit/rules/rules/kit/code-style.md +6 -6
  41. package/kit/rules/rules/kit/writing-for-agents.md +3 -0
  42. package/kit/workflow/skills/follow/SKILL.md +18 -11
  43. package/kit/workflow/skills/implement/SKILL.md +1 -1
  44. package/kit/workflow/skills/orchestrate/SKILL.md +124 -300
  45. package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
  46. package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
  47. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
  48. package/kit/workflow/skills/orchestrate/references/events.md +46 -1
  49. package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
  50. package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
  51. package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
  52. package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
  53. package/kit/workflow/skills/orchestrate/references/plan-commit.md +9 -2
  54. package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
  55. package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
  56. package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
  57. package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
  58. package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
  59. package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
  60. package/kit/workflow/skills/plan/SKILL.md +3 -2
  61. package/package.json +3 -1
  62. package/recipes/README.md +4 -2
  63. package/recipes/google-calendar.md +30 -0
  64. package/recipes/jira-cli.md +1 -1
  65. package/recipes/sources/google-calendar-today.ts +38 -0
  66. package/recipes/sources/google-calendar.ts +368 -0
  67. package/recipes/sources/jira.ts +10 -2
  68. package/recipes/sources/linear.ts +9 -3
  69. package/recipes/sources/sentry.ts +8 -2
  70. package/recipes/sources.md +17 -3
  71. package/schemas/jackherd.schema.json +67 -0
  72. package/schemas/sources.schema.json +9 -0
  73. package/src/approval/checked.ts +60 -0
  74. package/src/approval/command.ts +16 -0
  75. package/src/check/command.ts +51 -6
  76. package/src/check/lint-pointers.ts +79 -0
  77. package/src/check/lint-shaped.ts +196 -0
  78. package/src/check/plan-structure-check.ts +116 -4
  79. package/src/check/report.ts +9 -3
  80. package/src/check/schema.ts +37 -0
  81. package/src/check/settings.ts +4 -0
  82. package/src/check/skill-budget-defaults.ts +81 -23
  83. package/src/check/skill-budgets.ts +58 -10
  84. package/src/cli.ts +2 -0
  85. package/src/config/personal-sources.ts +46 -10
  86. package/src/config/repository-identity.ts +40 -0
  87. package/src/config/schema.ts +53 -1
  88. package/src/context/command.ts +33 -2
  89. package/src/context/options.ts +33 -8
  90. package/src/context/usage-logs.ts +53 -0
  91. package/src/context/usage.ts +110 -0
  92. package/src/lane/base-command.ts +35 -0
  93. package/src/lane/base.ts +43 -0
  94. package/src/lane/close-check.ts +49 -15
  95. package/src/lane/close-command.ts +71 -11
  96. package/src/lane/close-options.ts +29 -8
  97. package/src/lane/close-survey.ts +13 -1
  98. package/src/lane/close.ts +51 -12
  99. package/src/lane/command.ts +21 -3
  100. package/src/lane/send-command.ts +32 -0
  101. package/src/lane/send-herdr.ts +77 -0
  102. package/src/lane/send-options.ts +23 -0
  103. package/src/lane/send.ts +90 -0
  104. package/src/lane/skill-hint.ts +27 -0
  105. package/src/lane/space-command.ts +30 -0
  106. package/src/lane/space.ts +49 -0
  107. package/src/lane/start-lane-hint.ts +18 -0
  108. package/src/plan-contract/base-check-git.ts +33 -0
  109. package/src/plan-contract/base-check.ts +17 -0
  110. package/src/plan-contract/command.ts +25 -4
  111. package/src/plan-format/plan-structure.ts +17 -5
  112. package/src/plans/ask-coordinator.ts +23 -13
  113. package/src/plans/board-rows.ts +9 -4
  114. package/src/plans/click-hint.ts +4 -0
  115. package/src/plans/command.ts +6 -5
  116. package/src/plans/ensure-panes.ts +14 -3
  117. package/src/plans/git.ts +14 -0
  118. package/src/plans/github.ts +19 -10
  119. package/src/plans/layout.ts +191 -25
  120. package/src/plans/live.ts +57 -14
  121. package/src/plans/plan-panes.ts +21 -4
  122. package/src/plans/row-actions.ts +17 -10
  123. package/src/plans/selection.ts +29 -0
  124. package/src/plans/settings.ts +4 -0
  125. package/src/plans/snapshot.ts +8 -3
  126. package/src/plans/source-rows.ts +35 -2
  127. package/src/resume/command.ts +213 -0
  128. package/src/resume/decision.ts +102 -0
  129. package/src/resume/keeper.ts +65 -0
  130. package/src/resume/location.ts +22 -0
  131. package/src/resume/log.ts +27 -0
  132. package/src/resume/ports.ts +102 -0
  133. package/src/resume/record.ts +116 -0
  134. package/src/resume/targets.ts +84 -0
  135. package/src/resume/texts.ts +27 -0
  136. package/src/secrets/command.ts +15 -5
  137. package/src/sync/herdr-plugin.ts +2 -11
  138. package/src/sync/plan-ask-rules.ts +11 -0
  139. package/src/sync/render.ts +3 -1
  140. package/src/vocabulary/index.ts +13 -0
  141. package/src/waiting/command.ts +13 -4
  142. package/src/watch/command.ts +177 -78
  143. package/src/watch/config-reload.ts +119 -0
  144. package/src/watch/lane-model.ts +11 -1
  145. package/src/watch/lane-progress.ts +70 -8
  146. package/src/watch/lane-report.ts +68 -12
  147. package/src/watch/main-checkout.ts +5 -1
  148. package/src/watch/merge-duties.ts +33 -0
  149. package/src/watch/merge-overlap-read.ts +69 -0
  150. package/src/watch/plan-views.ts +16 -4
  151. package/src/watch/reference-paths.ts +46 -5
  152. package/src/watch/report-questions.ts +5 -2
  153. package/src/watch/single-report.ts +3 -2
  154. package/src/watch/source-dry-run-command.ts +22 -4
  155. package/src/watch/source-dry-run.ts +1 -1
  156. package/src/watch/source-reason.ts +58 -0
  157. package/src/watch/source-reload.ts +21 -17
  158. package/src/watch/source-run.ts +24 -7
  159. package/src/watch/sources.ts +9 -2
  160. package/src/watch/texts.ts +15 -4
  161. package/src/watch/waiting-line.ts +4 -3
@@ -81,8 +81,10 @@ import {
81
81
  import {
82
82
  BACKLOG_KEY,
83
83
  type ClickTarget,
84
+ entryBlockHeadKey,
84
85
  isBlockFolded,
85
86
  isBlockHeadKey,
87
+ isEntryBlockFolded,
86
88
  isLaneOpen,
87
89
  isLanesOpen,
88
90
  LANES_KEY,
@@ -98,7 +100,17 @@ import {
98
100
  import type { SentChip } from './sent-files'
99
101
  import { shownWithMore } from './shown-with-more'
100
102
  import { askedCheckOf } from './source-asked'
101
- import { type SourceBlock, type SourceSummary, sourceItems, sourceSummaries } from './source-rows'
103
+ import {
104
+ entryBlockItems,
105
+ entryBlockSources,
106
+ type HerdSource,
107
+ type SourceBlock,
108
+ type SourceSummary,
109
+ sourceItems,
110
+ sourceMoreKey,
111
+ sourceSummaries,
112
+ statusOnly,
113
+ } from './source-rows'
102
114
  import { chevronOf, LESS_MARK, NO_AGENT_SYMBOL, statusSymbol, statusWord, TURN_MARK } from './symbols'
103
115
  import { BADGE_TEXTS, type BadgeTexts, checksText, foldedChecksText, laneMarkText, TEXTS } from './texts'
104
116
  import { paintOfRole, paintOfTone, toneOfRole } from './tones'
@@ -177,14 +189,16 @@ export type OverviewView = {
177
189
 
178
190
  /**
179
191
  * 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.
192
+ * top. `yourTurnBlock`, `entryBlocks` and `sourceBlock` are what a click on these blocks is matched against, null (or
193
+ * no entry block) while the block is not drawn.
182
194
  */
183
195
  export type Screen = {
184
196
  header: string[]
185
197
  body: Frame
186
198
  footer: string[]
187
199
  yourTurnBlock: BlockHit | null
200
+ /** The blocks of the sources that show their entries, in their order under "Your turn". */
201
+ entryBlocks: BlockHit[]
188
202
  sourceBlock: BlockHit | null
189
203
  /** 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
204
  focusHits: FocusHit[]
@@ -236,7 +250,14 @@ type BlockHeadParts = Pick<TopBlock, 'name' | 'count' | 'balance' | 'countPaint'
236
250
  type BlockStack = { lines: string[]; hits: (BlockHit | null)[] }
237
251
 
238
252
  /** What every lane of the lanes block is drawn with. */
239
- type LanesContext = { state: Selection; width: number; badgeTexts: BadgeTexts; taskStyle: TaskStyle }
253
+ type LanesContext = {
254
+ state: Selection
255
+ width: number
256
+ badgeTexts: BadgeTexts
257
+ taskStyle: TaskStyle
258
+ /** How a lane's `↳` activity line is drawn: shimmering while the herd does, so its task rests, dim otherwise. */
259
+ activityStyle: TaskStyle
260
+ }
240
261
 
241
262
  /** In front of every line of the lanes block under its head. */
242
263
  const LANE_INDENT = ' '
@@ -768,6 +789,14 @@ const detailPlacement = (
768
789
  return { beside, room: beside ? besideRoom : width - visibleLength(indent) }
769
790
  }
770
791
 
792
+ /**
793
+ * Whether the whole head and a detail still stand on one line with `right`. A lane that rests has no bar, only the
794
+ * padding that aligns its badges with the others; `laneLines` gives that back before it cuts the name.
795
+ */
796
+ const detailFitsBeside = (head: string, right: string, width: number, indent: string): boolean =>
797
+ visibleLength(head) + COLUMN_SEPARATOR.length + visibleLength(right) < width &&
798
+ detailPlacement(head, right, width, indent).beside
799
+
771
800
  /** The summary the snapshot read for the lane, or the one its own steps give. */
772
801
  const summaryOf = (view: LaneView): LaneSummary =>
773
802
  view.summary ?? laneSummary({ progress: view.progress, phases: {}, activity: {} }, 0)
@@ -791,16 +820,23 @@ const laneRight = (view: LaneView, tallyWidth: number): string => {
791
820
  /** The cells of the phase count column, 0 when the pane leaves it out. */
792
821
  const tallyWidthOf = ({ phases, tally }: LaneColumns): number => (phases ? tally : 0)
793
822
 
794
- /** A folded lane's marks in the column of `columns.marks`, right before context and bar. */
795
- const markedRight = (view: LaneView, columns: LaneColumns): string => {
823
+ /** The marks in the column of `columns.marks`, `padOf(right)` cells before the lane's own context and bar. */
824
+ const marksBeforeRight = (view: LaneView, columns: LaneColumns, padOf: (right: string) => number): string => {
796
825
  const right = laneRight(view, tallyWidthOf(columns))
797
826
  const marks = laneMarks(view)
798
827
  if (!marks) return right
799
828
  if (columns.right === 0) return padVisible(marks, columns.marks)
800
- const pad = ' '.repeat(Math.max(columns.right - visibleLength(right), 0))
801
- return `${padVisible(marks, columns.marks)}${COLUMN_SEPARATOR}${pad}${right}`
829
+ return `${padVisible(marks, columns.marks)}${COLUMN_SEPARATOR}${' '.repeat(padOf(right))}${right}`
802
830
  }
803
831
 
832
+ /** A folded lane's marks right before context and bar, padded to the widest right column of the pane. */
833
+ const markedRight = (view: LaneView, columns: LaneColumns): string =>
834
+ marksBeforeRight(view, columns, (right) => Math.max(columns.right - visibleLength(right), 0))
835
+
836
+ /** `markedRight` without the padding a lane without a bar only has to align its marks with the other lanes'. */
837
+ const markedRightTight = (view: LaneView, columns: LaneColumns): string =>
838
+ marksBeforeRight(view, columns, (right) => (right ? Math.max(columns.right - visibleLength(right), 0) : 0))
839
+
804
840
  /** The task row below a folded lane's phase row, under the lane's name; none while the lane reports no task. */
805
841
  const foldedTaskRow = (view: LaneView, indent: string, width: number, style: TaskStyle): string[] => {
806
842
  const phase = headPhase(view)
@@ -825,8 +861,9 @@ const laneLines = (
825
861
  const nameStyle = restsFolded(view, open) ? paint.dim : paint.bold
826
862
  const cells = padVisible(nameStyle(fit(progress.lane.name, columns.name)), columns.name)
827
863
  const head = `${paint.dim(chevronOf(open))} ${symbolCell(status.agent)} ${cells}`
828
- const right = open ? laneProgressColumn(view, tallyWidthOf(columns)) : markedRight(view, columns)
829
864
  const nameIndent = ' '.repeat(visibleLength(head) - visibleLength(cells))
865
+ const aligned = open ? laneProgressColumn(view, tallyWidthOf(columns)) : markedRight(view, columns)
866
+ const right = open || detailFitsBeside(head, aligned, width, nameIndent) ? aligned : markedRightTight(view, columns)
830
867
  const { room } = detailPlacement(head, right, width, nameIndent)
831
868
  const lines = withDetail(head, laneDetail(view, open, reason, room), right, width, nameIndent)
832
869
  return open ? lines : [...lines, ...foldedTaskRow(view, nameIndent, width, taskStyle)]
@@ -903,39 +940,97 @@ const SUBAGENT_ACTIVITY_INDENT = ' '
903
940
  /** How long a subagent's name may grow before its action, so a narrow pane cuts the name and keeps the action. */
904
941
  const SUBAGENT_NAME_CELLS = 28
905
942
 
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))
943
+ /** The key of a lane's activity line, flipped in `expanded`. */
944
+ const laneActivityKey = (lane: string): string => `activity>${lane}`
945
+
946
+ /**
947
+ * The key of a subagent's activity line, by its label: a subagent that moves up when another ends keeps its own state,
948
+ * and the key is no child of the lane's, so folding one leaves the other.
949
+ */
950
+ const subagentActivityKey = (lane: string, label: string): string => `subagent-activity>${lane}>${label}`
951
+
952
+ /**
953
+ * One dim line of activity behind `indent`, cut to the pane on plain text before the paint. A cut line is a row: a
954
+ * click unfolds it over several lines, the text hanging under its first word, and a second click folds it; a line
955
+ * that fits is no click target.
956
+ */
957
+ const activityFrame = (
958
+ key: string,
959
+ indent: string,
960
+ text: string,
961
+ { state, width, style }: { state: Selection; width: number; style: TaskStyle },
962
+ ): Frame => {
963
+ const head = `${indent}${ACTIVITY_MARK} `
964
+ const cut = textWidth(`${head}${text}`) > width
965
+ const lines =
966
+ cut && state.expanded.has(key)
967
+ ? wrap(text, Math.max(width - textWidth(head), 1)).map((line, index) =>
968
+ index === 0 ? `${head}${line}` : `${' '.repeat(textWidth(head))}${line}`,
969
+ )
970
+ : [fit(`${head}${text}`, width)]
971
+ const styled = lines.map(style)
972
+ return cut ? rowFrame(key, styled, [], { state, width }) : textFrame(styled)
973
+ }
909
974
 
910
975
  /**
911
976
  * The last action of a working lane's agent and of each of its running subagents, one dim line each, under the lane
912
- * line in the column of its children. None while the lane does not work, or without a source for it.
977
+ * line in the column of its children. None while the lane does not work, or without a source for it. The first
978
+ * subagent line shimmers when nothing else of the lane does (`leadsSubagent`).
913
979
  */
914
- const activityLines = ({ activity, status }: LaneView, width: number): string[] => {
980
+ const activityFrames = (
981
+ { activity, status, progress }: LaneView,
982
+ {
983
+ activityStyle,
984
+ leadsSubagent,
985
+ ...context
986
+ }: Pick<LanesContext, 'state' | 'width' | 'activityStyle'> & {
987
+ leadsSubagent: boolean
988
+ },
989
+ ): Frame[] => {
915
990
  if (!activity || status.agent !== AGENT_STATUS.working) return []
991
+ const lane = progress.lane.name
916
992
  const indent = ' '.repeat(LANE_HEAD_CELLS)
993
+ const still = { ...context, style: paint.dim }
917
994
  return [
918
- ...(activity.lane === null ? [] : [activityLine(indent, activity.lane, width)]),
919
- ...activity.subagents.map(({ label, description, line }) =>
920
- activityLine(
995
+ ...(activity.lane === null
996
+ ? []
997
+ : [activityFrame(laneActivityKey(lane), indent, activity.lane, { ...context, style: activityStyle })]),
998
+ ...activity.subagents.map(({ label, description, line }, index) =>
999
+ activityFrame(
1000
+ subagentActivityKey(lane, label),
921
1001
  `${indent}${SUBAGENT_ACTIVITY_INDENT}`,
922
1002
  `${ellipsize(description ?? label, SUBAGENT_NAME_CELLS)}: ${line}`,
923
- width,
1003
+ leadsSubagent && index === 0 ? { ...context, style: activityStyle } : still,
924
1004
  ),
925
1005
  ),
926
1006
  ]
927
1007
  }
928
1008
 
1009
+ /** True when the lane shows its own `↳` activity line: the one line of the lane that shimmers, its task rests. */
1010
+ const showsLaneActivity = ({ activity, status }: LaneView): boolean =>
1011
+ typeof activity?.lane === 'string' && status.agent === AGENT_STATUS.working
1012
+
1013
+ /** True when a task line of the lane shimmers: the pane shimmers its tasks and the lane reports one to show. */
1014
+ const showsShimmeringTask = (
1015
+ view: LaneView,
1016
+ { width, taskStyle }: Pick<LanesContext, 'width' | 'taskStyle'>,
1017
+ ): boolean => taskStyle === SHIMMERING_TASK && foldedTaskRow(view, '', width, taskStyle).length > 0
1018
+
1019
+ /** True when no line of the lane shimmers yet, so its first subagent line does: neither its activity nor its task. */
1020
+ const subagentLeads = (view: LaneView, context: Pick<LanesContext, 'width' | 'taskStyle'>): boolean =>
1021
+ !showsLaneActivity(view) && !showsShimmeringTask(view, context)
1022
+
929
1023
  /**
930
1024
  * The lane line is a row of its own; an open lane continues after a blank line with its session, links, pull requests
931
1025
  * and phases (`openLaneFrame`), indented to the column of the lane's name.
932
1026
  */
933
1027
  const laneBlock = (
934
1028
  view: LaneView,
935
- { state, width, badgeTexts, taskStyle }: LanesContext,
1029
+ { state, width, badgeTexts, taskStyle: lanesTaskStyle, activityStyle }: LanesContext,
936
1030
  columns: LaneColumns,
937
1031
  reason: LaneReason | null,
938
1032
  ): Frame => {
1033
+ const taskStyle = showsLaneActivity(view) ? STILL_TASK : lanesTaskStyle
939
1034
  const context: LaneContext = {
940
1035
  progress: view.progress,
941
1036
  state,
@@ -950,8 +1045,8 @@ const laneBlock = (
950
1045
  const open = isLaneOpen(state, view.progress.lane.name, view.status.agent)
951
1046
  const lines = laneLines(view, open, { width, taskStyle }, columns, reason)
952
1047
  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
1048
+ const leadsSubagent = subagentLeads(view, { width, taskStyle: lanesTaskStyle })
1049
+ const row = joinFrames([lane, ...activityFrames(view, { state, width, activityStyle, leadsSubagent })])
955
1050
  if (!open) return row
956
1051
  const inner = narrower(context, LANE_HEAD_CELLS)
957
1052
  const head = {
@@ -1226,11 +1321,12 @@ const sourceSummaryText = (summaries: readonly SourceSummary[]): string =>
1226
1321
  * fails, and a source's entries under it once opened.
1227
1322
  */
1228
1323
  const sourceLines = (
1229
- { sources, now }: SourceBlock,
1324
+ { sources: listed, now }: SourceBlock,
1230
1325
  awaiting: readonly WaitingItem[],
1231
1326
  state: Selection,
1232
1327
  width: number,
1233
1328
  ): BlockLines => {
1329
+ const sources = listed.map(statusOnly)
1234
1330
  const shown = state.expanded.has(SOURCE_MORE_KEY) ? sources : sources.slice(0, SHOWN_BLOCK_ROWS)
1235
1331
  const asked = askedCheckOf(awaiting)
1236
1332
  const newTone = toneOfRole(TONE_ROLE.new)
@@ -1258,6 +1354,70 @@ const sourceLinesOf = (
1258
1354
  ): BlockLines | null =>
1259
1355
  sourceBlock && sourceBlock.sources.length > 0 ? sourceLines(sourceBlock, awaiting, state, width) : null
1260
1356
 
1357
+ /**
1358
+ * How many entry rows all blocks of entries show together while folded: one calendar keeps the five of the other
1359
+ * blocks, a second one takes from the same five, so the head of the herd, which does not scroll, leaves the lanes room.
1360
+ */
1361
+ const ENTRY_BLOCK_ROW_BUDGET = SHOWN_BLOCK_ROWS
1362
+
1363
+ /**
1364
+ * The rows each block of entries shows while folded, in the order of the sources: the shared budget, with one row kept
1365
+ * for each block after it, and at least one row per block, so a block past the budget still shows its first entry; a
1366
+ * block with fewer entries than its share leaves the rest to the blocks after it. More blocks than the budget take one
1367
+ * row each, past the budget.
1368
+ */
1369
+ const entryRowShares = (counts: readonly number[], budget: number): number[] => {
1370
+ let left = budget
1371
+ return counts.map((count, index) => {
1372
+ const share = Math.min(count, Math.max(left - (counts.length - index - 1), 1))
1373
+ left -= share
1374
+ return share
1375
+ })
1376
+ }
1377
+
1378
+ /** A source with a block of its own, the clock its times are counted against and the rows it shows while folded. */
1379
+ type EntryBlock = { source: HerdSource; now: number; share: number }
1380
+
1381
+ /**
1382
+ * The block of a source that shows its entries: open at the start, headed by its label (else its name) and the number
1383
+ * of entries, folded and opened again by its head as in the other blocks, its `share` of entries and `+<n>` for the
1384
+ * rest, which opens all of them and folds them again. An entry with an address opens it.
1385
+ */
1386
+ const entryBlockLines = ({ source, now, share }: EntryBlock, state: Selection, width: number): BlockLines => {
1387
+ const moreKey = sourceMoreKey(source.name)
1388
+ const expanded = state.expanded.has(moreKey)
1389
+ const hidden = source.entries.length - share
1390
+ const block: TopBlock = {
1391
+ name: source.label ?? source.name,
1392
+ count: source.entries.length,
1393
+ balance: '',
1394
+ markPaint: paint.dim,
1395
+ focused: (key) => isFocused(state, key),
1396
+ allBold: (key) => buttonsAllBold(state, key),
1397
+ countPaint: (text) => text,
1398
+ moreKey: hidden > 0 ? moreKey : null,
1399
+ hidden,
1400
+ expanded,
1401
+ rows: allBlockRows(entryBlockItems(source, now, expanded ? source.entries.length : share), new Set(), width),
1402
+ }
1403
+ const headKey = entryBlockHeadKey(source.name)
1404
+ return isEntryBlockFolded(state, source.name)
1405
+ ? foldedBlockLines(block, headKey, width)
1406
+ : openBlockLines(block, headKey, width)
1407
+ }
1408
+
1409
+ const entryBlocksOf = (sourceBlock: SourceBlock | undefined, state: Selection, width: number): BlockLines[] => {
1410
+ if (!sourceBlock) return []
1411
+ const sources = entryBlockSources(sourceBlock.sources)
1412
+ const shares = entryRowShares(
1413
+ sources.map(({ entries }) => entries.length),
1414
+ ENTRY_BLOCK_ROW_BUDGET,
1415
+ )
1416
+ return sources.map((source, index) =>
1417
+ entryBlockLines({ source, now: sourceBlock.now, share: shares[index] ?? 0 }, state, width),
1418
+ )
1419
+ }
1420
+
1261
1421
  /**
1262
1422
  * The blocks one under the other, `offset` lines below the top of the screen, a blank line after each, so one stands
1263
1423
  * between neighbours and one between the last and the lanes. An absent block (null) takes no line and has no hit.
@@ -1419,6 +1579,7 @@ export const renderScreen = (
1419
1579
  width: number,
1420
1580
  ): Screen => {
1421
1581
  const taskStyle = shimmer ? SHIMMERING_TASK : STILL_TASK
1582
+ const activityStyle = shimmer ? SHIMMERING_TASK : paint.dim
1422
1583
  const { active, waiting, noAgent } = partitionLanes(lanes)
1423
1584
  const badgeTexts = BADGE_TEXTS
1424
1585
  const turnRowsOf = yourTurn ? [...yourTurn.turn.now, ...yourTurn.turn.planned] : []
@@ -1429,9 +1590,13 @@ export const renderScreen = (
1429
1590
  const nowLines = unplaced.map((task) => nowLine(task, width, taskStyle))
1430
1591
  const above = nowLines.length > 0 ? [titleLine, rule(width), ...nowLines] : [titleLine]
1431
1592
  const listed = sourceLinesOf(sourceBlock, awaiting, state, width)
1432
- const stack = stackBlocks([turn, listed], above.length)
1593
+ const entryBlocks = entryBlocksOf(sourceBlock, state, width)
1594
+ const stack = stackBlocks([turn, ...entryBlocks, listed], above.length)
1595
+ const sourcesSlot = 1 + entryBlocks.length
1433
1596
  const sections = [
1434
- ...(active.length > 0 ? [lanesFrame(active, turnRowsOf, { state, width, badgeTexts, taskStyle })] : []),
1597
+ ...(active.length > 0
1598
+ ? [lanesFrame(active, turnRowsOf, { state, width, badgeTexts, taskStyle, activityStyle })]
1599
+ : []),
1435
1600
  ...(waiting.length > 0 ? [waitingFrame(waiting, state, width)] : []),
1436
1601
  textFrame(parkedBlock(noAgent, width)),
1437
1602
  ...(planned.length > 0 ? [backlogFrame(planned, state, width)] : []),
@@ -1448,7 +1613,8 @@ export const renderScreen = (
1448
1613
  const body = { ...plainBody, lines: emphasisedAt(plainBody.lines, plainHeader.length, focusHits, state, width) }
1449
1614
  return {
1450
1615
  yourTurnBlock: stack.hits[0],
1451
- sourceBlock: stack.hits[1],
1616
+ entryBlocks: stack.hits.slice(1, sourcesSlot).filter((hit) => hit !== null),
1617
+ sourceBlock: stack.hits[sourcesSlot],
1452
1618
  focusHits: state.showLegend ? focusHits.filter(({ line }) => line < plainHeader.length) : focusHits,
1453
1619
  rowKeys: [...blockHits.map(({ key }) => key), ...targetKeys(body.targets)],
1454
1620
  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,
@@ -46,10 +47,7 @@ import {
46
47
  parseMouse,
47
48
  splitKeys,
48
49
  syncLaneFolds,
49
- TOP_BLOCK,
50
- type TopBlockId,
51
50
  toggledExpansion,
52
- topBlockHeadKey,
53
51
  type View,
54
52
  windowTop,
55
53
  withoutStaleMotions,
@@ -76,6 +74,7 @@ import {
76
74
  startSelection,
77
75
  } from './view'
78
76
  import { type BlockClick, type BlockHit, blockClick, blockHeadClick, mainClickOf } from './waiting-click'
77
+ import type { TurnRow, YourTurn } from './your-turn'
79
78
 
80
79
  /**
81
80
  * Where the herd's reads come from: its own reader, or the read the watcher publishes for every herd of the board
@@ -97,6 +96,8 @@ type Live = {
97
96
  errors: string[]
98
97
  /** One footer line about the last action of the person, such as where the sprint page was written; cleared by the next key. */
99
98
  notice: string | null
99
+ /** What a `[done]` closed, left out of "Your turn" from the click until a read no longer holds its row. */
100
+ closing: DoneTarget[]
100
101
  reported: Map<string, string | null>
101
102
  /** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of the page. */
102
103
  read: Snapshot | null
@@ -123,6 +124,39 @@ type Live = {
123
124
 
124
125
  const QUIT_KEYS = new Set(['\x03', 'q'])
125
126
 
127
+ /** Whether `row` of "Your turn" is what `target` closes: a record by its id, a board row or an appointment by its cells. */
128
+ const isClosedBy = (target: DoneTarget, { waiting, appointment }: TurnRow): boolean => {
129
+ switch (target.kind) {
130
+ case DONE_KIND.record:
131
+ return waiting?.id === target.id
132
+ case DONE_KIND.boardRow:
133
+ return waiting?.id === undefined && waiting?.since === target.since && waiting.item === target.item
134
+ case DONE_KIND.appointment:
135
+ return appointment?.when === target.when && appointment.action === target.action
136
+ }
137
+ }
138
+
139
+ const isClosing = (closing: readonly DoneTarget[], row: TurnRow): boolean =>
140
+ closing.some((target) => isClosedBy(target, row))
141
+
142
+ /** "Your turn" without the rows `closing` names, so a closed row leaves at the click, not at the next read. */
143
+ export const withoutClosing = (turn: YourTurn, closing: readonly DoneTarget[]): YourTurn => {
144
+ if (closing.length === 0) return turn
145
+ const open = (rows: readonly TurnRow[]) => rows.filter((row) => !isClosing(closing, row))
146
+ return { now: open(turn.now), planned: open(turn.planned) }
147
+ }
148
+
149
+ /** The closings a read still holds a row of; one whose row the read no longer holds is closed on the board as well. */
150
+ export const pendingClosings = (turn: YourTurn | undefined, closing: readonly DoneTarget[]): DoneTarget[] => {
151
+ const rows = turn ? [...turn.now, ...turn.planned] : []
152
+ return closing.filter((target) => rows.some((row) => isClosedBy(target, row)))
153
+ }
154
+
155
+ const withClosingLeftOut = (snapshot: Snapshot, closing: readonly DoneTarget[]): Snapshot =>
156
+ snapshot.yourTurn && closing.length > 0
157
+ ? { ...snapshot, yourTurn: { ...snapshot.yourTurn, turn: withoutClosing(snapshot.yourTurn.turn, closing) } }
158
+ : snapshot
159
+
126
160
  /** The sidebar titles that differ from the ones last reported, in the order of `titles`; null clears one. */
127
161
  export const changedTitles = (
128
162
  titles: ReadonlyMap<string, string | null>,
@@ -171,7 +205,7 @@ const renderContextOf = (live: Live): RenderContext => ({
171
205
 
172
206
  /** The screen for the current state, with the window scrolled to keep the cursor in view. */
173
207
  const renderLive = (live: Live, snapshot: Snapshot): { screen: Screen; top: number } => {
174
- const screen = renderSnapshot(snapshot, renderContextOf(live))
208
+ const screen = renderSnapshot(withClosingLeftOut(snapshot, live.closing), renderContextOf(live))
175
209
  return { screen, top: windowTop(live.view, screen.body.cursorLine, screen.body.lines.length, bodyRows(screen)) }
176
210
  }
177
211
 
@@ -236,7 +270,7 @@ const followShimmer = (live: Live, prepared: PreparedShimmer) => {
236
270
  const drawHover = (live: Live) => {
237
271
  live.frame = null
238
272
  if (!live.snapshot) return
239
- live.plain ??= renderUnfocused(live.snapshot, renderContextOf(live))
273
+ live.plain ??= renderUnfocused(withClosingLeftOut(live.snapshot, live.closing), renderContextOf(live))
240
274
  const laid = focusedScreen(live.plain, live.view.selection, paneWidth(), agentOfSnapshot(live.snapshot))
241
275
  if (laid === null) redraw(live)
242
276
  else write(live, laid, live.view.top)
@@ -311,6 +345,7 @@ const takeRead = (live: Live, outcome: ReadOutcome, notes: readonly string[]): b
311
345
  )
312
346
  live.view = { ...live.view, selection: folded }
313
347
  live.snapshot = next
348
+ live.closing = pendingClosings(next.yourTurn?.turn, live.closing)
314
349
  live.errors = [...next.errors, ...notes, ...reportTitles(live.source, next.titles, live.reported)]
315
350
  return redraw(live)
316
351
  }
@@ -474,12 +509,14 @@ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
474
509
  return
475
510
  }
476
511
  if ('done' in outcome) {
477
- live.notice = closeRow(outcome.done, {
512
+ const { closed, notice } = closeRow(outcome.done, {
478
513
  sprintFile: live.source.sprintFile,
479
514
  language: live.source.settings.planLanguage,
480
515
  now: new Date(),
481
516
  closeRecords: (ids) => closeWaiting(live.source.root, ids, Date.now()),
482
517
  })
518
+ if (closed) live.closing = [...live.closing, outcome.done]
519
+ live.notice = notice
483
520
  return
484
521
  }
485
522
  const reason = openInBrowser(outcome.open)
@@ -487,17 +524,18 @@ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
487
524
  }
488
525
 
489
526
  /**
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
527
+ * A plain click on a block on top of the herd, matched against `hit` as it was drawn: the head folds or
528
+ * opens its block, a
491
529
  * cut-off row expands, a file or Artifact opens, a button asks the coordinator or closes the row; the failure or the
492
530
  * result shows in the footer.
493
531
  */
494
- const clickBlock = (live: Live, block: TopBlockId, hit: BlockHit | null, input: string): boolean => {
532
+ const clickBlock = (live: Live, hit: BlockHit | null, input: string): boolean => {
495
533
  const mouse = parseMouse(input)
496
534
  if (!mouse || !hit) return false
497
- if (blockHeadClick(hit.firstLine - 1, mouse)) {
535
+ if (hit.headKey && blockHeadClick(hit.firstLine - 1, mouse)) {
498
536
  live.view = {
499
537
  ...live.view,
500
- selection: applyClick(live.view.selection, topBlockHeadKey(block)),
538
+ selection: applyClick(live.view.selection, hit.headKey),
501
539
  }
502
540
  return true
503
541
  }
@@ -533,7 +571,11 @@ const activateBlockRow = (live: Live, screen: Screen, input: string): boolean =>
533
571
  return true
534
572
  }
535
573
 
536
- const blocksOf = ({ yourTurnBlock, sourceBlock }: Screen): (BlockHit | null)[] => [yourTurnBlock, sourceBlock]
574
+ const blocksOf = ({ yourTurnBlock, entryBlocks, sourceBlock }: Screen): (BlockHit | null)[] => [
575
+ yourTurnBlock,
576
+ ...entryBlocks,
577
+ sourceBlock,
578
+ ]
537
579
 
538
580
  const handleInput = (live: Live, input: string) => {
539
581
  const { snapshot, screen } = live
@@ -543,7 +585,6 @@ const handleInput = (live: Live, input: string) => {
543
585
  hover(live, screen, mouse)
544
586
  return
545
587
  }
546
- live.notice = null
547
588
  const settled = settledSelection(live.view.selection, screen)
548
589
  live.view = { ...live.view, selection: mouse ? settled : withoutHover(settled) }
549
590
  if (activateBlockRow(live, screen, input)) {
@@ -555,8 +596,9 @@ const handleInput = (live: Live, input: string) => {
555
596
  return
556
597
  }
557
598
  if (
558
- clickBlock(live, TOP_BLOCK.yourTurn, screen.yourTurnBlock, input) ||
559
- clickBlock(live, TOP_BLOCK.sources, screen.sourceBlock, input)
599
+ clickBlock(live, screen.yourTurnBlock, input) ||
600
+ screen.entryBlocks.some((hit) => clickBlock(live, hit, input)) ||
601
+ clickBlock(live, screen.sourceBlock, input)
560
602
  ) {
561
603
  redraw(live)
562
604
  return
@@ -594,6 +636,7 @@ export const runLive = (source: Source, key: string | null = null): void => {
594
636
  view: { selection: overviewSelection(), top: 0, follow: true },
595
637
  errors: [],
596
638
  notice: null,
639
+ closing: [],
597
640
  reported: new Map(),
598
641
  read: null,
599
642
  whole: null,
@@ -35,6 +35,14 @@ export type PlanPaneResult = { actions: PaneAction[]; notes: string[] }
35
35
 
36
36
  type ListedPane = { pane_id: string; tab_id: string; cwd: string; agent?: unknown; label?: unknown }
37
37
 
38
+ /**
39
+ * The exit codes of `herd --ensure`: `noCoordinator` when the coordinator's pane is missing or ambiguous, so a
40
+ * caller that runs it after a restart knows to wait for the coordinator's session; 1 stays the error of the CLI.
41
+ */
42
+ export const ENSURE_EXIT = { done: 0, noCoordinator: 3 } as const
43
+
44
+ export type EnsureExit = (typeof ENSURE_EXIT)[keyof typeof ENSURE_EXIT]
45
+
38
46
  const isListedPane = (value: unknown): value is ListedPane => {
39
47
  if (typeof value !== 'object' || value === null) return false
40
48
  const candidate = value as Record<string, unknown>
@@ -124,9 +132,13 @@ export const isIdleShell = ({ agent, foreground }: PaneState): boolean =>
124
132
  const isStartingShell = ({ agent, foreground }: PaneState): boolean =>
125
133
  !agent && (foreground.length === 0 || SHELLS.has(programOf(foreground[0])))
126
134
 
135
+ const hasHerdBeside = (panes: readonly PaneState[], agent: PaneState): boolean =>
136
+ panes.some((each) => each.tab === agent.tab && each.label === herdLabel(null))
137
+
127
138
  /**
128
139
  * The coordinator's pane: the agent herdr lists under the coordinator's name, else the calling pane when an agent
129
- * in the main checkout runs there, else the only agent in the main checkout. Two agents there and no name: none.
140
+ * in the main checkout runs there, else the only agent in the main checkout, else the only one there whose tab holds
141
+ * a pane labelled `herd` (herdr keeps labels across a restart, agent names not always). Otherwise none.
130
142
  */
131
143
  export const coordinatorPaneOf = (
132
144
  panes: readonly PaneState[],
@@ -136,15 +148,20 @@ export const coordinatorPaneOf = (
136
148
  if (hints.named) return hints.named
137
149
  const inRoot = panes.filter((each) => each.agent && each.cwd === root)
138
150
  if (hints.caller && inRoot.some((each) => each.pane === hints.caller)) return hints.caller
139
- return inRoot.length === 1 ? (inRoot[0]?.pane ?? null) : null
151
+ const candidates = inRoot.length === 1 ? inRoot : inRoot.filter((agent) => hasHerdBeside(panes, agent))
152
+ return candidates.length === 1 ? (candidates[0]?.pane ?? null) : null
140
153
  }
141
154
 
142
- /** Why no coordinator pane was found: no agent in the main checkout, or several and none of them named. */
155
+ /** The exit code of `herd --ensure` for the coordinator pane it found among `panes`, or none. */
156
+ export const ensureExitOf = (panes: readonly PaneState[], coordinator: string | null): EnsureExit =>
157
+ panes.some((each) => each.pane === coordinator) ? ENSURE_EXIT.done : ENSURE_EXIT.noCoordinator
158
+
159
+ /** Why no coordinator pane was found: no agent in the main checkout, or several, none named and none alone beside a herd pane. */
143
160
  export const missingCoordinatorNote = (panes: readonly PaneState[], root: string, name: string): string => {
144
161
  const inRoot = panes.filter((each) => each.agent && each.cwd === root).length
145
162
  return inRoot === 0
146
163
  ? 'coordinator: no agent in the main checkout'
147
- : `coordinator: ${inRoot} agents in the main checkout and none named ${name}; run herd --ensure in its pane, or herdr agent rename <pane> ${name}`
164
+ : `coordinator: ${inRoot} agents in the main checkout, none named ${name} and none alone beside a herd pane; run herd --ensure in its pane, or herdr agent rename <pane> ${name}`
148
165
  }
149
166
 
150
167
  const quoted = (arg: string): string => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`)
@@ -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)