jackherd 1.16.0 → 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 (155) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/INSTALL.md +2 -1
  3. package/README.md +22 -21
  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 +423 -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 +2 -3
  43. package/kit/workflow/skills/implement/SKILL.md +1 -1
  44. package/kit/workflow/skills/orchestrate/SKILL.md +120 -299
  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 +2 -1
  62. package/recipes/README.md +1 -0
  63. package/recipes/jira-cli.md +1 -1
  64. package/recipes/sources/google-calendar-today.ts +8 -2
  65. package/recipes/sources/jira.ts +10 -2
  66. package/recipes/sources/linear.ts +9 -3
  67. package/recipes/sources/sentry.ts +8 -2
  68. package/recipes/sources.md +4 -2
  69. package/schemas/jackherd.schema.json +67 -0
  70. package/schemas/sources.schema.json +9 -0
  71. package/src/approval/checked.ts +60 -0
  72. package/src/approval/command.ts +16 -0
  73. package/src/check/command.ts +51 -6
  74. package/src/check/lint-pointers.ts +79 -0
  75. package/src/check/lint-shaped.ts +196 -0
  76. package/src/check/plan-structure-check.ts +116 -4
  77. package/src/check/report.ts +9 -3
  78. package/src/check/schema.ts +37 -0
  79. package/src/check/settings.ts +4 -0
  80. package/src/check/skill-budget-defaults.ts +77 -26
  81. package/src/check/skill-budgets.ts +58 -10
  82. package/src/cli.ts +2 -0
  83. package/src/config/personal-sources.ts +46 -10
  84. package/src/config/repository-identity.ts +40 -0
  85. package/src/config/schema.ts +53 -1
  86. package/src/context/command.ts +33 -2
  87. package/src/context/options.ts +33 -8
  88. package/src/context/usage-logs.ts +53 -0
  89. package/src/context/usage.ts +110 -0
  90. package/src/lane/base-command.ts +35 -0
  91. package/src/lane/base.ts +43 -0
  92. package/src/lane/close-check.ts +49 -15
  93. package/src/lane/close-command.ts +71 -11
  94. package/src/lane/close-options.ts +29 -8
  95. package/src/lane/close-survey.ts +13 -1
  96. package/src/lane/close.ts +51 -12
  97. package/src/lane/command.ts +21 -3
  98. package/src/lane/send-command.ts +32 -0
  99. package/src/lane/send-herdr.ts +77 -0
  100. package/src/lane/send-options.ts +23 -0
  101. package/src/lane/send.ts +90 -0
  102. package/src/lane/skill-hint.ts +27 -0
  103. package/src/lane/space-command.ts +30 -0
  104. package/src/lane/space.ts +49 -0
  105. package/src/lane/start-lane-hint.ts +18 -0
  106. package/src/plan-contract/base-check-git.ts +33 -0
  107. package/src/plan-contract/base-check.ts +17 -0
  108. package/src/plan-contract/command.ts +25 -4
  109. package/src/plan-format/plan-structure.ts +17 -5
  110. package/src/plans/ask-coordinator.ts +23 -13
  111. package/src/plans/board-rows.ts +9 -4
  112. package/src/plans/click-hint.ts +4 -0
  113. package/src/plans/command.ts +6 -5
  114. package/src/plans/ensure-panes.ts +14 -3
  115. package/src/plans/git.ts +14 -0
  116. package/src/plans/github.ts +19 -10
  117. package/src/plans/layout.ts +82 -33
  118. package/src/plans/live.ts +8 -12
  119. package/src/plans/plan-panes.ts +21 -4
  120. package/src/plans/selection.ts +29 -0
  121. package/src/plans/settings.ts +4 -0
  122. package/src/resume/command.ts +213 -0
  123. package/src/resume/decision.ts +102 -0
  124. package/src/resume/keeper.ts +65 -0
  125. package/src/resume/location.ts +22 -0
  126. package/src/resume/log.ts +27 -0
  127. package/src/resume/ports.ts +102 -0
  128. package/src/resume/record.ts +116 -0
  129. package/src/resume/targets.ts +84 -0
  130. package/src/resume/texts.ts +27 -0
  131. package/src/secrets/command.ts +15 -5
  132. package/src/sync/herdr-plugin.ts +2 -11
  133. package/src/sync/plan-ask-rules.ts +11 -0
  134. package/src/sync/render.ts +3 -1
  135. package/src/vocabulary/index.ts +13 -0
  136. package/src/waiting/command.ts +13 -4
  137. package/src/watch/command.ts +177 -78
  138. package/src/watch/config-reload.ts +119 -0
  139. package/src/watch/lane-model.ts +11 -1
  140. package/src/watch/lane-progress.ts +70 -8
  141. package/src/watch/lane-report.ts +66 -11
  142. package/src/watch/main-checkout.ts +5 -1
  143. package/src/watch/merge-duties.ts +33 -0
  144. package/src/watch/merge-overlap-read.ts +69 -0
  145. package/src/watch/plan-views.ts +16 -4
  146. package/src/watch/reference-paths.ts +46 -5
  147. package/src/watch/report-questions.ts +5 -2
  148. package/src/watch/single-report.ts +3 -2
  149. package/src/watch/source-dry-run-command.ts +22 -4
  150. package/src/watch/source-reason.ts +58 -0
  151. package/src/watch/source-reload.ts +21 -17
  152. package/src/watch/source-run.ts +24 -7
  153. package/src/watch/sources.ts +9 -2
  154. package/src/watch/texts.ts +14 -3
  155. package/src/watch/waiting-line.ts +4 -3
@@ -4,31 +4,40 @@ import { PULL_REQUEST_FIELDS, type PullRequest, parsePullRequests, pullRequestsB
4
4
  /** Open pull requests by head branch, or none with the reason when `gh` fails. */
5
5
  export type PullRequestState = { byBranch: Map<string, PullRequest[]>; errors: string[] }
6
6
 
7
+ /** What `gh` answered: stdout on success, else the reason. */
8
+ export type GhAnswer = { ok: true; stdout: string } | { ok: false; reason: string }
9
+
7
10
  export const noPullRequests = (): PullRequestState => ({ byBranch: new Map(), errors: [] })
8
11
 
9
12
  /** The GitHub host whose pull request links are trusted; `GH_HOST` names an Enterprise server. */
10
13
  export const GITHUB_HOST = process.env.GH_HOST ?? 'github.com'
11
14
 
12
- const runGh = async (root: string): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
13
- const child = Bun.spawn(['gh', 'pr', 'list', '--state', 'open', '--limit', '100', '--json', PULL_REQUEST_FIELDS], {
14
- cwd: root,
15
- stdout: 'pipe',
16
- stderr: 'pipe',
17
- })
15
+ const runGh = async (args: readonly string[], root: string): Promise<GhAnswer> => {
16
+ const child = Bun.spawn(['gh', ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
18
17
  const [stdout, stderr, exitCode] = await Promise.all([
19
18
  new Response(child.stdout).text(),
20
19
  new Response(child.stderr).text(),
21
20
  child.exited,
22
21
  ])
23
- return { stdout, stderr, exitCode }
22
+ return exitCode === 0 ? { ok: true, stdout } : { ok: false, reason: stderr.trim() || `exit ${exitCode}` }
23
+ }
24
+
25
+ /** Runs `gh`; never rejects, also not when `gh` is not installed, and the reason is terminal-safe. */
26
+ const askGh = async (args: readonly string[], root: string): Promise<GhAnswer> => {
27
+ try {
28
+ const answer = await runGh(args, root)
29
+ return answer.ok ? answer : { ok: false, reason: errorText(answer.reason) }
30
+ } catch (error) {
31
+ return { ok: false, reason: errorText(error) }
32
+ }
24
33
  }
25
34
 
26
35
  /** Open pull requests, or none with the reason; never rejects, also not when `gh` is not installed. */
27
36
  export const fetchPullRequests = async (root: string): Promise<PullRequestState> => {
37
+ const answer = await askGh(['pr', 'list', '--state', 'open', '--limit', '100', '--json', PULL_REQUEST_FIELDS], root)
38
+ if (!answer.ok) return { byBranch: new Map(), errors: [`gh pr list: ${answer.reason}`] }
28
39
  try {
29
- const { stdout, stderr, exitCode } = await runGh(root)
30
- if (exitCode !== 0) throw new Error(`gh pr list: ${stderr.trim() || `exit ${exitCode}`}`)
31
- return { byBranch: pullRequestsByBranch(parsePullRequests(stdout, GITHUB_HOST)), errors: [] }
40
+ return { byBranch: pullRequestsByBranch(parsePullRequests(answer.stdout, GITHUB_HOST)), errors: [] }
32
41
  } catch (error) {
33
42
  return { byBranch: new Map(), errors: [errorText(error)] }
34
43
  }
@@ -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,
@@ -239,18 +241,23 @@ type TopBlock = {
239
241
  hidden: number
240
242
  expanded: boolean
241
243
  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
244
244
  }
245
245
 
246
246
  /** What the head of a block says: its name, its count, its balance and the paint of the count. */
247
- type BlockHeadParts = Pick<TopBlock, 'name' | 'count' | 'balance' | 'countPaint' | 'headMark'>
247
+ type BlockHeadParts = Pick<TopBlock, 'name' | 'count' | 'balance' | 'countPaint'>
248
248
 
249
249
  /** The blocks on top of the herd one under the other: their lines below the title, and the hit of each drawn block. */
250
250
  type BlockStack = { lines: string[]; hits: (BlockHit | null)[] }
251
251
 
252
252
  /** What every lane of the lanes block is drawn with. */
253
- 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
+ }
254
261
 
255
262
  /** In front of every line of the lanes block under its head. */
256
263
  const LANE_INDENT = ' '
@@ -782,6 +789,14 @@ const detailPlacement = (
782
789
  return { beside, room: beside ? besideRoom : width - visibleLength(indent) }
783
790
  }
784
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
+
785
800
  /** The summary the snapshot read for the lane, or the one its own steps give. */
786
801
  const summaryOf = (view: LaneView): LaneSummary =>
787
802
  view.summary ?? laneSummary({ progress: view.progress, phases: {}, activity: {} }, 0)
@@ -805,16 +820,23 @@ const laneRight = (view: LaneView, tallyWidth: number): string => {
805
820
  /** The cells of the phase count column, 0 when the pane leaves it out. */
806
821
  const tallyWidthOf = ({ phases, tally }: LaneColumns): number => (phases ? tally : 0)
807
822
 
808
- /** A folded lane's marks in the column of `columns.marks`, right before context and bar. */
809
- 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 => {
810
825
  const right = laneRight(view, tallyWidthOf(columns))
811
826
  const marks = laneMarks(view)
812
827
  if (!marks) return right
813
828
  if (columns.right === 0) return padVisible(marks, columns.marks)
814
- const pad = ' '.repeat(Math.max(columns.right - visibleLength(right), 0))
815
- return `${padVisible(marks, columns.marks)}${COLUMN_SEPARATOR}${pad}${right}`
829
+ return `${padVisible(marks, columns.marks)}${COLUMN_SEPARATOR}${' '.repeat(padOf(right))}${right}`
816
830
  }
817
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
+
818
840
  /** The task row below a folded lane's phase row, under the lane's name; none while the lane reports no task. */
819
841
  const foldedTaskRow = (view: LaneView, indent: string, width: number, style: TaskStyle): string[] => {
820
842
  const phase = headPhase(view)
@@ -839,8 +861,9 @@ const laneLines = (
839
861
  const nameStyle = restsFolded(view, open) ? paint.dim : paint.bold
840
862
  const cells = padVisible(nameStyle(fit(progress.lane.name, columns.name)), columns.name)
841
863
  const head = `${paint.dim(chevronOf(open))} ${symbolCell(status.agent)} ${cells}`
842
- const right = open ? laneProgressColumn(view, tallyWidthOf(columns)) : markedRight(view, columns)
843
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)
844
867
  const { room } = detailPlacement(head, right, width, nameIndent)
845
868
  const lines = withDetail(head, laneDetail(view, open, reason, room), right, width, nameIndent)
846
869
  return open ? lines : [...lines, ...foldedTaskRow(view, nameIndent, width, taskStyle)]
@@ -935,7 +958,7 @@ const activityFrame = (
935
958
  key: string,
936
959
  indent: string,
937
960
  text: string,
938
- { state, width }: { state: Selection; width: number },
961
+ { state, width, style }: { state: Selection; width: number; style: TaskStyle },
939
962
  ): Frame => {
940
963
  const head = `${indent}${ACTIVITY_MARK} `
941
964
  const cut = textWidth(`${head}${text}`) > width
@@ -945,44 +968,69 @@ const activityFrame = (
945
968
  index === 0 ? `${head}${line}` : `${' '.repeat(textWidth(head))}${line}`,
946
969
  )
947
970
  : [fit(`${head}${text}`, width)]
948
- const dimmed = lines.map((line) => paint.dim(line))
949
- return cut ? rowFrame(key, dimmed, [], { state, width }) : textFrame(dimmed)
971
+ const styled = lines.map(style)
972
+ return cut ? rowFrame(key, styled, [], { state, width }) : textFrame(styled)
950
973
  }
951
974
 
952
975
  /**
953
976
  * The last action of a working lane's agent and of each of its running subagents, one dim line each, under the lane
954
- * 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`).
955
979
  */
956
980
  const activityFrames = (
957
981
  { activity, status, progress }: LaneView,
958
- context: { state: Selection; width: number },
982
+ {
983
+ activityStyle,
984
+ leadsSubagent,
985
+ ...context
986
+ }: Pick<LanesContext, 'state' | 'width' | 'activityStyle'> & {
987
+ leadsSubagent: boolean
988
+ },
959
989
  ): Frame[] => {
960
990
  if (!activity || status.agent !== AGENT_STATUS.working) return []
961
991
  const lane = progress.lane.name
962
992
  const indent = ' '.repeat(LANE_HEAD_CELLS)
993
+ const still = { ...context, style: paint.dim }
963
994
  return [
964
- ...(activity.lane === null ? [] : [activityFrame(laneActivityKey(lane), indent, activity.lane, context)]),
965
- ...activity.subagents.map(({ label, description, line }) =>
995
+ ...(activity.lane === null
996
+ ? []
997
+ : [activityFrame(laneActivityKey(lane), indent, activity.lane, { ...context, style: activityStyle })]),
998
+ ...activity.subagents.map(({ label, description, line }, index) =>
966
999
  activityFrame(
967
1000
  subagentActivityKey(lane, label),
968
1001
  `${indent}${SUBAGENT_ACTIVITY_INDENT}`,
969
1002
  `${ellipsize(description ?? label, SUBAGENT_NAME_CELLS)}: ${line}`,
970
- context,
1003
+ leadsSubagent && index === 0 ? { ...context, style: activityStyle } : still,
971
1004
  ),
972
1005
  ),
973
1006
  ]
974
1007
  }
975
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
+
976
1023
  /**
977
1024
  * The lane line is a row of its own; an open lane continues after a blank line with its session, links, pull requests
978
1025
  * and phases (`openLaneFrame`), indented to the column of the lane's name.
979
1026
  */
980
1027
  const laneBlock = (
981
1028
  view: LaneView,
982
- { state, width, badgeTexts, taskStyle }: LanesContext,
1029
+ { state, width, badgeTexts, taskStyle: lanesTaskStyle, activityStyle }: LanesContext,
983
1030
  columns: LaneColumns,
984
1031
  reason: LaneReason | null,
985
1032
  ): Frame => {
1033
+ const taskStyle = showsLaneActivity(view) ? STILL_TASK : lanesTaskStyle
986
1034
  const context: LaneContext = {
987
1035
  progress: view.progress,
988
1036
  state,
@@ -997,7 +1045,8 @@ const laneBlock = (
997
1045
  const open = isLaneOpen(state, view.progress.lane.name, view.status.agent)
998
1046
  const lines = laneLines(view, open, { width, taskStyle }, columns, reason)
999
1047
  const lane = rowFrame(laneKey(view.progress.lane.name), lines, [], context)
1000
- const row = joinFrames([lane, ...activityFrames(view, { state, width })])
1048
+ const leadsSubagent = subagentLeads(view, { width, taskStyle: lanesTaskStyle })
1049
+ const row = joinFrames([lane, ...activityFrames(view, { state, width, activityStyle, leadsSubagent })])
1001
1050
  if (!open) return row
1002
1051
  const inner = narrower(context, LANE_HEAD_CELLS)
1003
1052
  const head = {
@@ -1131,13 +1180,9 @@ const blockBar = (): string => ' '
1131
1180
  * above the block; `▸ <name> · <n> <balance>` while folded. The count (in `countPaint` while it is more than 0) is
1132
1181
  * left out for a block without one; the balance comes painted.
1133
1182
  */
1134
- const blockHead = (
1135
- { name, count, balance, countPaint, headMark }: BlockHeadParts,
1136
- open: boolean,
1137
- width: number,
1138
- ): string => {
1183
+ const blockHead = ({ name, count, balance, countPaint }: BlockHeadParts, open: boolean, width: number): string => {
1139
1184
  const counted = count === null ? '' : ` ${ITEM_SEPARATOR} ${count > 0 ? countPaint(String(count)) : count}`
1140
- const lead = `${paint.dim(headMark ?? chevronOf(open))} ${name}${counted}${balance ? ` ${balance}` : ''}`
1185
+ const lead = `${paint.dim(chevronOf(open))} ${name}${counted}${balance ? ` ${balance}` : ''}`
1141
1186
  if (!open) return fit(lead, width)
1142
1187
  const cut = fit(lead, Math.max(width - 2, 1))
1143
1188
  return `${cut} ${paint.dim(RULE.repeat(Math.max(width - visibleLength(cut) - 1, 0)))}`
@@ -1334,9 +1379,9 @@ const entryRowShares = (counts: readonly number[], budget: number): number[] =>
1334
1379
  type EntryBlock = { source: HerdSource; now: number; share: number }
1335
1380
 
1336
1381
  /**
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.
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.
1340
1385
  */
1341
1386
  const entryBlockLines = ({ source, now, share }: EntryBlock, state: Selection, width: number): BlockLines => {
1342
1387
  const moreKey = sourceMoreKey(source.name)
@@ -1344,7 +1389,7 @@ const entryBlockLines = ({ source, now, share }: EntryBlock, state: Selection, w
1344
1389
  const hidden = source.entries.length - share
1345
1390
  const block: TopBlock = {
1346
1391
  name: source.label ?? source.name,
1347
- count: null,
1392
+ count: source.entries.length,
1348
1393
  balance: '',
1349
1394
  markPaint: paint.dim,
1350
1395
  focused: (key) => isFocused(state, key),
@@ -1354,10 +1399,11 @@ const entryBlockLines = ({ source, now, share }: EntryBlock, state: Selection, w
1354
1399
  hidden,
1355
1400
  expanded,
1356
1401
  rows: allBlockRows(entryBlockItems(source, now, expanded ? source.entries.length : share), new Set(), width),
1357
- headMark: ' ',
1358
1402
  }
1359
- const { lines, hit } = openBlockLines(block, '', width)
1360
- return { open: true, lines, hit: { rows: hit.rows, moreKey: hit.moreKey } }
1403
+ const headKey = entryBlockHeadKey(source.name)
1404
+ return isEntryBlockFolded(state, source.name)
1405
+ ? foldedBlockLines(block, headKey, width)
1406
+ : openBlockLines(block, headKey, width)
1361
1407
  }
1362
1408
 
1363
1409
  const entryBlocksOf = (sourceBlock: SourceBlock | undefined, state: Selection, width: number): BlockLines[] => {
@@ -1533,6 +1579,7 @@ export const renderScreen = (
1533
1579
  width: number,
1534
1580
  ): Screen => {
1535
1581
  const taskStyle = shimmer ? SHIMMERING_TASK : STILL_TASK
1582
+ const activityStyle = shimmer ? SHIMMERING_TASK : paint.dim
1536
1583
  const { active, waiting, noAgent } = partitionLanes(lanes)
1537
1584
  const badgeTexts = BADGE_TEXTS
1538
1585
  const turnRowsOf = yourTurn ? [...yourTurn.turn.now, ...yourTurn.turn.planned] : []
@@ -1547,7 +1594,9 @@ export const renderScreen = (
1547
1594
  const stack = stackBlocks([turn, ...entryBlocks, listed], above.length)
1548
1595
  const sourcesSlot = 1 + entryBlocks.length
1549
1596
  const sections = [
1550
- ...(active.length > 0 ? [lanesFrame(active, turnRowsOf, { state, width, badgeTexts, taskStyle })] : []),
1597
+ ...(active.length > 0
1598
+ ? [lanesFrame(active, turnRowsOf, { state, width, badgeTexts, taskStyle, activityStyle })]
1599
+ : []),
1551
1600
  ...(waiting.length > 0 ? [waitingFrame(waiting, state, width)] : []),
1552
1601
  textFrame(parkedBlock(noAgent, width)),
1553
1602
  ...(planned.length > 0 ? [backlogFrame(planned, state, width)] : []),
package/src/plans/live.ts CHANGED
@@ -47,10 +47,7 @@ import {
47
47
  parseMouse,
48
48
  splitKeys,
49
49
  syncLaneFolds,
50
- TOP_BLOCK,
51
- type TopBlockId,
52
50
  toggledExpansion,
53
- topBlockHeadKey,
54
51
  type View,
55
52
  windowTop,
56
53
  withoutStaleMotions,
@@ -527,18 +524,18 @@ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
527
524
  }
528
525
 
529
526
  /**
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
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
532
529
  * cut-off row expands, a file or Artifact opens, a button asks the coordinator or closes the row; the failure or the
533
530
  * result shows in the footer.
534
531
  */
535
- const clickBlock = (live: Live, block: TopBlockId | null, hit: BlockHit | null, input: string): boolean => {
532
+ const clickBlock = (live: Live, hit: BlockHit | null, input: string): boolean => {
536
533
  const mouse = parseMouse(input)
537
534
  if (!mouse || !hit) return false
538
- if (block !== null && blockHeadClick(hit.firstLine - 1, mouse)) {
535
+ if (hit.headKey && blockHeadClick(hit.firstLine - 1, mouse)) {
539
536
  live.view = {
540
537
  ...live.view,
541
- selection: applyClick(live.view.selection, topBlockHeadKey(block)),
538
+ selection: applyClick(live.view.selection, hit.headKey),
542
539
  }
543
540
  return true
544
541
  }
@@ -588,7 +585,6 @@ const handleInput = (live: Live, input: string) => {
588
585
  hover(live, screen, mouse)
589
586
  return
590
587
  }
591
- live.notice = null
592
588
  const settled = settledSelection(live.view.selection, screen)
593
589
  live.view = { ...live.view, selection: mouse ? settled : withoutHover(settled) }
594
590
  if (activateBlockRow(live, screen, input)) {
@@ -600,9 +596,9 @@ const handleInput = (live: Live, input: string) => {
600
596
  return
601
597
  }
602
598
  if (
603
- clickBlock(live, TOP_BLOCK.yourTurn, screen.yourTurnBlock, input) ||
604
- screen.entryBlocks.some((hit) => clickBlock(live, null, hit, input)) ||
605
- 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)
606
602
  ) {
607
603
  redraw(live)
608
604
  return
@@ -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("'", `'\\''`)}'`)
@@ -27,6 +27,8 @@ export type Selection = {
27
27
  laneFolds: ReadonlyMap<string, LaneFold>
28
28
  /** The top blocks the user opened; every other one is folded, at the start of the herd all of them. */
29
29
  openBlocks?: ReadonlySet<TopBlockId>
30
+ /** The sources whose block of entries the user folded; absent or without the source while it is open, as at the start. */
31
+ foldedEntryBlocks?: ReadonlySet<string>
30
32
  /** True once the user folded the lanes block, which is open at the start; absent or false while it is open. */
31
33
  lanesFolded?: boolean
32
34
  }
@@ -110,6 +112,26 @@ export const topBlockHeadKey = (block: TopBlockId): string => `${TOP_HEAD_KEY_PR
110
112
  export const topBlockOfKey = (key: string): TopBlockId | null =>
111
113
  Object.values(TOP_BLOCK).find((block) => topBlockHeadKey(block) === key) ?? null
112
114
 
115
+ const ENTRY_HEAD_KEY_PREFIX = `${TOP_HEAD_KEY_PREFIX}entries>`
116
+
117
+ /** The key of the head of the block of entries of a source, which the arrows reach like every other clickable row. */
118
+ export const entryBlockHeadKey = (source: string): string => `${ENTRY_HEAD_KEY_PREFIX}${source}`
119
+
120
+ /** The source whose block of entries a head key names, null for any other key. */
121
+ export const entryBlockOfKey = (key: string): string | null =>
122
+ key.startsWith(ENTRY_HEAD_KEY_PREFIX) ? key.slice(ENTRY_HEAD_KEY_PREFIX.length) : null
123
+
124
+ /** Whether the block of entries of `source` is drawn folded: it is open at the start, until the user folds it. */
125
+ export const isEntryBlockFolded = ({ foldedEntryBlocks }: Selection, source: string): boolean =>
126
+ foldedEntryBlocks?.has(source) === true
127
+
128
+ const withEntryBlockOpen = (selection: Selection, source: string, open: boolean): Selection => {
129
+ const folded = new Set(selection.foldedEntryBlocks)
130
+ if (open) folded.delete(source)
131
+ else folded.add(source)
132
+ return { ...selection, foldedEntryBlocks: folded }
133
+ }
134
+
113
135
  /** Whether the lanes block is drawn open: it is at the start of the herd, until the user folds it. */
114
136
  export const isLanesOpen = ({ lanesFolded }: Selection): boolean => !lanesFolded
115
137
 
@@ -274,6 +296,13 @@ const foldedHead = (selection: Selection, key: string, next: (open: boolean) =>
274
296
  const lane = laneOfKey(key)
275
297
  if (lane !== null) return { ...foldLane(selection, lane, next), selected: key }
276
298
  if (key === LANES_KEY) return { ...withLanesOpen(selection, next(isLanesOpen(selection))), selected: key }
299
+ const entrySource = entryBlockOfKey(key)
300
+ if (entrySource !== null) {
301
+ return {
302
+ ...withEntryBlockOpen(selection, entrySource, next(!isEntryBlockFolded(selection, entrySource))),
303
+ selected: key,
304
+ }
305
+ }
277
306
  const block = topBlockOfKey(key)
278
307
  if (block === null) return null
279
308
  return { ...withBlockOpen(selection, block, next(!isBlockFolded(selection, block))), selected: key }
@@ -4,6 +4,7 @@ import { type JackherdConfig, parseJackherdConfig } from '../config'
4
4
  import { PROJECT_CONFIG_PATH } from '../config/location'
5
5
  import { readConfig } from '../config/read-config'
6
6
  import type { ToneName } from '../config/tones'
7
+ import { DEFAULT_MIN_FREE_GB } from '../lane/space'
7
8
  import type { TicketSource } from '../watch/plan-badges'
8
9
  import type { SourceShow } from '../watch/sources'
9
10
  import { type WatchedSource, watchedSourceOf } from '../watch/watched-source'
@@ -15,6 +16,8 @@ export type PlansSettings = Pick<
15
16
  'planDirectory' | 'board' | 'worktreeDirectory' | 'baseBranch' | 'planLanguage' | 'responseLanguage' | 'skillPrefix'
16
17
  > & {
17
18
  adapter: JackherdConfig['terminal']['adapter']
19
+ /** Free gigabytes a new lane needs on the disk (`lanes.minFreeGb`). */
20
+ minFreeGb: number
18
21
  fetchIntervalMinutes: number
19
22
  /** Whether the running task of a folded lane shimmers; `NO_COLOR` keeps it still whatever this says. */
20
23
  shimmer: boolean
@@ -58,6 +61,7 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
58
61
  responseLanguage: config.responseLanguage,
59
62
  skillPrefix: config.skillPrefix,
60
63
  adapter: config.terminal.adapter,
64
+ minFreeGb: config.lanes.minFreeGb ?? DEFAULT_MIN_FREE_GB,
61
65
  fetchIntervalMinutes: config.plans.fetchIntervalMinutes,
62
66
  shimmer: config.plans.shimmer,
63
67
  linkKinds: config.plans.linkKinds,