jackherd 1.9.0 → 1.11.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 (228) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +65 -32
  4. package/dist/{chunk-MCP4XJQH.js → chunk-RMMXD4G5.js} +18 -6
  5. package/dist/config.d.ts +99 -4
  6. package/dist/config.js +105 -29
  7. package/dist/plan-format.d.ts +51 -6
  8. package/dist/plan-format.js +49 -7
  9. package/docs/workflow-dark.svg +67 -63
  10. package/docs/workflow.svg +67 -63
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/ask-rules.json +15 -0
  14. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  15. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  16. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  17. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  18. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  19. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  20. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  21. package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
  22. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  23. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  24. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  25. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  26. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  27. package/kit/hooks/hooks/open-question/index.ts +26 -0
  28. package/kit/hooks/hooks/open-question/record.ts +135 -0
  29. package/kit/hooks/hooks/open-question/run.ts +57 -0
  30. package/kit/hooks/hooks/open-question/store.ts +39 -0
  31. package/kit/hooks/hooks/sent-file/store.ts +3 -12
  32. package/kit/hooks/hooks/shared/config.ts +7 -0
  33. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  34. package/kit/hooks/hooks/shared/files.ts +7 -0
  35. package/kit/hooks/hooks/shared/lane.ts +1 -1
  36. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  37. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  38. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  39. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  40. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  41. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  42. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  43. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  44. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  45. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  46. package/kit/hooks/hooks.json +13 -5
  47. package/kit/review/agents/skill-review.md +34 -0
  48. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  49. package/kit/rules/rules/kit/pull-requests.md +2 -0
  50. package/kit/workflow/skills/follow/SKILL.md +3 -10
  51. package/kit/workflow/skills/implement/SKILL.md +8 -6
  52. package/kit/workflow/skills/orchestrate/SKILL.md +218 -433
  53. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  54. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  55. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  56. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  57. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  58. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  59. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  60. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  61. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  62. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  63. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  64. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  65. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  66. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  67. package/kit/workflow/skills/plan/SKILL.md +6 -5
  68. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  69. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  70. package/package.json +3 -1
  71. package/recipes/README.md +2 -1
  72. package/recipes/sources.md +5 -1
  73. package/schemas/jackherd.schema.json +139 -5
  74. package/src/check/agent-text-reviewers.ts +23 -0
  75. package/src/check/command.ts +2 -0
  76. package/src/check/plan-structure-check.ts +49 -2
  77. package/src/check/plan.ts +8 -1
  78. package/src/check/schema.ts +9 -0
  79. package/src/check/skill-budget-defaults.ts +55 -0
  80. package/src/check/skill-budgets.ts +185 -0
  81. package/src/cli.ts +2 -0
  82. package/src/config/auto-window.ts +8 -0
  83. package/src/config/link-kinds.ts +7 -1
  84. package/src/config/schema.ts +82 -25
  85. package/src/config/tones.ts +27 -0
  86. package/src/lane/close-board.ts +106 -0
  87. package/src/lane/close-check.ts +183 -0
  88. package/src/lane/close-command-line.ts +11 -0
  89. package/src/lane/close-command.ts +217 -0
  90. package/src/lane/close-options.ts +43 -0
  91. package/src/lane/close-survey.ts +83 -0
  92. package/src/lane/close.ts +141 -0
  93. package/src/lane/command.ts +13 -0
  94. package/src/lane-prompt/additions.ts +14 -0
  95. package/src/lane-prompt/build.ts +74 -0
  96. package/src/lane-prompt/command.ts +93 -0
  97. package/src/lane-prompt/input.ts +157 -0
  98. package/src/lane-prompt/options.ts +70 -0
  99. package/src/lane-prompt/texts.ts +101 -0
  100. package/src/mermaid/command.ts +36 -0
  101. package/src/mermaid/problems.ts +48 -0
  102. package/src/plan-format/clock.ts +8 -0
  103. package/src/plan-format/dependencies.ts +9 -6
  104. package/src/plan-format/index.ts +1 -0
  105. package/src/plan-format/short-names.ts +71 -0
  106. package/src/plan-format/waiting.ts +22 -4
  107. package/src/plans/appointment-rows.ts +83 -0
  108. package/src/plans/ask-coordinator.ts +111 -0
  109. package/src/plans/ask-target.ts +36 -0
  110. package/src/plans/board-close.ts +85 -0
  111. package/src/plans/child-stop.ts +51 -0
  112. package/src/plans/closable-records.ts +34 -0
  113. package/src/plans/command.ts +3 -2
  114. package/src/plans/fetch.ts +26 -3
  115. package/src/plans/frame.ts +5 -12
  116. package/src/plans/git.ts +54 -5
  117. package/src/plans/groomed.ts +27 -0
  118. package/src/plans/herd-claim.ts +173 -0
  119. package/src/plans/herd-memory.ts +36 -25
  120. package/src/plans/lane-plans.ts +44 -13
  121. package/src/plans/lane-summary.ts +253 -45
  122. package/src/plans/layout-phase.ts +36 -11
  123. package/src/plans/layout-summary.ts +89 -18
  124. package/src/plans/layout.ts +502 -98
  125. package/src/plans/link-chips.ts +23 -4
  126. package/src/plans/live.ts +178 -23
  127. package/src/plans/next-rows.ts +44 -0
  128. package/src/plans/phase-ids.ts +8 -0
  129. package/src/plans/phase-source.ts +15 -0
  130. package/src/plans/plan-cache.ts +28 -0
  131. package/src/plans/plan-heads.ts +54 -0
  132. package/src/plans/planned.ts +22 -0
  133. package/src/plans/pull-requests.ts +17 -2
  134. package/src/plans/question-records.ts +48 -0
  135. package/src/plans/questions-read.ts +13 -0
  136. package/src/plans/read-cache.ts +8 -0
  137. package/src/plans/record-id.ts +4 -0
  138. package/src/plans/restart-decision.ts +13 -4
  139. package/src/plans/row-actions.ts +52 -0
  140. package/src/plans/row-focus.ts +148 -0
  141. package/src/plans/row-targets.ts +7 -0
  142. package/src/plans/selection.ts +111 -18
  143. package/src/plans/sent-files-read.ts +6 -15
  144. package/src/plans/sent-files.ts +7 -14
  145. package/src/plans/settings.ts +12 -1
  146. package/src/plans/signalled-phase.ts +42 -0
  147. package/src/plans/signals-read.ts +12 -4
  148. package/src/plans/snapshot.ts +162 -81
  149. package/src/plans/source-asked.ts +19 -0
  150. package/src/plans/source-prefix.ts +17 -0
  151. package/src/plans/source-rows.ts +188 -0
  152. package/src/plans/sprint-export.ts +11 -11
  153. package/src/plans/sprint-page.ts +5 -5
  154. package/src/plans/subagent-phases.ts +95 -0
  155. package/src/plans/subagent-tasks.ts +51 -0
  156. package/src/plans/subagents-read.ts +66 -17
  157. package/src/plans/subagents.ts +64 -6
  158. package/src/plans/supervisor.ts +14 -4
  159. package/src/plans/symbols.ts +3 -0
  160. package/src/plans/task-name.ts +34 -0
  161. package/src/plans/texts.ts +48 -6
  162. package/src/plans/tones.ts +30 -0
  163. package/src/plans/view.ts +25 -11
  164. package/src/plans/waiting-click.ts +295 -35
  165. package/src/plans/waiting-records.ts +39 -0
  166. package/src/plans/waiting-window.ts +8 -0
  167. package/src/preview/command.ts +25 -3
  168. package/src/shared/private-location.ts +89 -0
  169. package/src/shared/stat-cache.ts +48 -0
  170. package/src/sync/kit-source.ts +29 -10
  171. package/src/sync/kit.ts +2 -0
  172. package/src/sync/manifest.ts +2 -0
  173. package/src/sync/plan-ask-rules.ts +94 -0
  174. package/src/sync/plan-hooks.ts +2 -0
  175. package/src/sync/render.ts +3 -3
  176. package/src/sync/settings-hooks.ts +4 -1
  177. package/src/sync/sync-plan.ts +1 -0
  178. package/src/sync/sync.ts +24 -3
  179. package/src/task-signals/claude-read.ts +27 -10
  180. package/src/terminal/text.ts +16 -3
  181. package/src/terminal/types.ts +4 -0
  182. package/src/version/skill-diff.ts +46 -0
  183. package/src/vocabulary/index.ts +26 -6
  184. package/src/waiting/command.ts +22 -7
  185. package/src/watch/appointments.ts +1 -2
  186. package/src/watch/asking-lanes.ts +40 -0
  187. package/src/watch/base-tip.ts +11 -0
  188. package/src/watch/command.ts +330 -105
  189. package/src/watch/heartbeat-progress.ts +6 -18
  190. package/src/watch/kit-update.ts +28 -9
  191. package/src/watch/lane-name.ts +6 -0
  192. package/src/watch/lane-progress.ts +40 -8
  193. package/src/watch/lane-report-file.ts +20 -5
  194. package/src/watch/lane-report.ts +32 -4
  195. package/src/watch/lane-sources.ts +31 -3
  196. package/src/watch/main-checkout.ts +34 -0
  197. package/src/watch/plan-badges-read.ts +22 -9
  198. package/src/watch/plan-badges.ts +10 -2
  199. package/src/watch/pull-requests.ts +6 -2
  200. package/src/watch/reference-paths.ts +87 -0
  201. package/src/watch/report-questions.ts +4 -3
  202. package/src/watch/running-subagents.ts +43 -0
  203. package/src/watch/skill-change.ts +124 -32
  204. package/src/watch/source-change.ts +48 -0
  205. package/src/watch/source-entries.ts +55 -0
  206. package/src/watch/source-fields.ts +1 -0
  207. package/src/watch/source-statuses.ts +60 -0
  208. package/src/watch/source-tickets.ts +21 -1
  209. package/src/watch/sources.ts +47 -4
  210. package/src/watch/stream-output.ts +4 -3
  211. package/src/watch/task-parallel-auto.ts +208 -0
  212. package/src/watch/task-parallel-data-paths.ts +46 -0
  213. package/src/watch/task-parallel-input.ts +5 -15
  214. package/src/watch/task-parallel-read.ts +3 -1
  215. package/src/watch/task-parallel.ts +36 -7
  216. package/src/watch/task-signal-missing.ts +69 -0
  217. package/src/watch/task-signal-read.ts +29 -0
  218. package/src/watch/texts.ts +10 -0
  219. package/src/watch/update-available.ts +6 -3
  220. package/src/watch/watch-events.ts +23 -0
  221. package/src/watch/watcher-lock.ts +25 -29
  222. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  223. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  224. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  225. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  226. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  227. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  228. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -1,4 +1,4 @@
1
- import type { LinkKind } from '../config/link-kinds'
1
+ import { ARTIFACT_LINK_KIND, type LinkKind } from '../config/link-kinds'
2
2
 
3
3
  /** A link of a plan that matches a link kind, as the sprint page shows it. */
4
4
  export type LinkChip = { url: string; label: string; icon: string; private: boolean }
@@ -14,6 +14,7 @@ const MARKS = /\*\*|`/g
14
14
  const CONTROL = /\p{Cc}/gu
15
15
 
16
16
  const linksOf = (line: string): Found[] => {
17
+ if (!line.includes('http')) return []
17
18
  const found: Found[] = []
18
19
  const take = (url: string, text: string | null, index: number) =>
19
20
  found.push({ url, text, before: line.slice(0, index), line })
@@ -26,9 +27,27 @@ const linksOf = (line: string): Found[] => {
26
27
  return found.sort((left, right) => line.indexOf(left.url) - line.indexOf(right.url))
27
28
  }
28
29
 
29
- const labelOf = ({ text, before }: Found, kind: LinkKind): string => {
30
- const written = text ?? LABEL_BEFORE.exec(before)?.[1]
31
- return written ? written.replace(MARKS, '').replace(CONTROL, '').trim() || kind.label : kind.label
30
+ const LIST_LEAD = /^\s*(?:[-*>]\s*)*(?:\[[ xX]\]\s*)?/
31
+ const LEFTOVER_LINK = /\bhttps?:\/\/\S+/g
32
+ const TITLE_END = /[\s:,;.\-–>]+$/
33
+
34
+ /** What a line says besides its links, without list marks, checkbox and emphasis: the title of a bare artifact link. */
35
+ const lineTitleOf = ({ line }: Found): string =>
36
+ line
37
+ .replace(MARKDOWN_LINK, '$1')
38
+ .replace(AUTOLINK, '')
39
+ .replace(LEFTOVER_LINK, '')
40
+ .replace(LIST_LEAD, '')
41
+ .replace(MARKS, '')
42
+ .replace(CONTROL, '')
43
+ .replace(TITLE_END, '')
44
+ .trim()
45
+
46
+ const labelOf = (found: Found, kind: LinkKind): string => {
47
+ const written = found.text ?? LABEL_BEFORE.exec(found.before)?.[1]
48
+ const own = written?.replace(MARKS, '').replace(CONTROL, '').trim()
49
+ if (own) return own
50
+ return (kind.pattern === ARTIFACT_LINK_KIND.pattern && lineTitleOf(found)) || kind.label
32
51
  }
33
52
 
34
53
  /**
package/src/plans/live.ts CHANGED
@@ -1,14 +1,20 @@
1
1
  import { existsSync, type FSWatcher, watch } from 'node:fs'
2
- import { dirname } from 'node:path'
2
+ import { basename, dirname } from 'node:path'
3
3
  import { pathToFileURL } from 'node:url'
4
4
  import { terminalSafe } from '../plan-format/terminal-text'
5
+ import { runHerdr } from '../terminal/herdr'
5
6
  import { openInBrowser } from '../terminal/open'
7
+ import { stripAnsi } from '../terminal/text'
8
+ import { closeWaiting } from '../waiting/command'
9
+ import { appointmentKeys } from './appointment-rows'
10
+ import { askCoordinator } from './ask-coordinator'
6
11
  import type { LaneMemory } from './current-phase'
7
12
  import { errorText } from './error-text'
8
13
  import {
9
14
  type FetchState,
10
15
  fetchBase,
11
16
  fetchNotice,
17
+ fetchPlanHeads,
12
18
  goneLanePullRequests,
13
19
  initialFetchState,
14
20
  shouldFetch,
@@ -17,18 +23,31 @@ import {
17
23
  import { fetchHeadPath } from './git'
18
24
  import { fetchPullRequests, noPullRequests, type PullRequestState } from './github'
19
25
  import { recallLanes, rememberLanes } from './herd-memory'
20
- import type { Screen } from './layout'
26
+ import { type Screen, settledSelection } from './layout'
27
+ import { nextKeys } from './next-rows'
21
28
  import { type KnownPage, pageAfterWrite, pageKey, pageNeedsWriting, planAnchorOn, withPlanChips } from './plan-chips'
29
+ import { planPullRequests } from './plan-heads'
22
30
  import { herdLabel } from './plan-panes'
23
31
  import { pullRequestUrl } from './pull-requests'
32
+ import { newReadCache, type ReadCache } from './read-cache'
33
+ import { TAKEN_OVER_CODE, TAKEOVER_SIGNAL } from './restart-decision'
34
+ import { closeRow } from './row-actions'
35
+ import { blockRowByKey, bracketAt, hoveredKeyAt, lineIndexAt, withHovered } from './row-focus'
24
36
  import {
25
37
  inputOutcome,
38
+ isActivateKey,
39
+ isMotion,
26
40
  isSprintPageKey,
41
+ type MouseEvent,
27
42
  overviewSelection,
28
43
  parseMouse,
44
+ settledBlockFolds,
45
+ showDoneNotice,
29
46
  splitKeys,
30
47
  syncLaneFolds,
31
- targetKeys,
48
+ TOP_BLOCK,
49
+ type TopBlockId,
50
+ toggledBlockFold,
32
51
  toggledExpansion,
33
52
  type View,
34
53
  windowTop,
@@ -36,10 +55,21 @@ import {
36
55
  import { BASE_REMOTE, baseRefOf, boardRepoPathOf, CLOCK_LOCALE } from './settings'
37
56
  import { singleFlight } from './single-flight'
38
57
  import { loadSnapshot, type Snapshot, type Source } from './snapshot'
58
+ import { sourceKeys } from './source-rows'
39
59
  import { exportSprintPage, writeSprintPageInSteps } from './sprint-export'
40
60
  import { boardTexts } from './texts'
41
- import { bodyRows, clockOf, draw, paneWidth, renderSnapshot, startSelection, TERMINAL_OFF, TERMINAL_ON } from './view'
42
- import { waitingClick, waitingRows } from './waiting-click'
61
+ import {
62
+ bodyRows,
63
+ clockOf,
64
+ frameOutput,
65
+ frameToWrite,
66
+ paneWidth,
67
+ renderSnapshot,
68
+ startSelection,
69
+ TERMINAL_OFF,
70
+ TERMINAL_ON,
71
+ } from './view'
72
+ import { type BlockClick, type BlockHit, blockClick, blockHeadClick, mainClickOf, waitingKeys } from './waiting-click'
43
73
 
44
74
  /** The state of the running view; only the functions below `runLive` change it. */
45
75
  type Live = {
@@ -58,6 +88,8 @@ type Live = {
58
88
  reported: Map<string, string | null>
59
89
  /** Per lane, the ticks seen at the last read, the phase that moved last and when each moved; kept across herds (`rememberLanes`). */
60
90
  memories: Map<string, LaneMemory>
91
+ /** Session logs and plans parsed at the last read, kept while they did not change. */
92
+ cache: ReadCache
61
93
  pending: Timer | null
62
94
  /** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of `page`. */
63
95
  read: Snapshot | null
@@ -66,6 +98,8 @@ type Live = {
66
98
  pageWriting: boolean
67
99
  flight: (() => Promise<void>) | null
68
100
  pageError: string | null
101
+ /** The frame last written to the terminal; null while its content is unknown, such as after a resize. */
102
+ written: string | null
69
103
  }
70
104
 
71
105
  const REFRESH_MS = 5_000
@@ -143,7 +177,10 @@ const redraw = (live: Live) => {
143
177
  const top = windowTop(live.view, screen.body.cursorLine, screen.body.lines.length, bodyRows(screen))
144
178
  live.screen = screen
145
179
  live.view = { ...live.view, top }
146
- draw(screen, top)
180
+ const output = frameToWrite(live.written, frameOutput(screen, top, paneWidth(), bodyRows(screen)))
181
+ if (output === null) return
182
+ live.written = output
183
+ process.stdout.write(output)
147
184
  }
148
185
 
149
186
  /** The whole sprint, also while the view shows one lane: the snapshot is read again without the lane filter. */
@@ -193,16 +230,29 @@ const refresh = (live: Live, onChange: () => void): Promise<void> => {
193
230
 
194
231
  const readOnce = async (live: Live, onChange: () => void) => {
195
232
  try {
196
- const read = await loadSnapshot(live.source, live.pullRequests, live.memories)
233
+ const read = await loadSnapshot(live.source, live.pullRequests, live.memories, live.cache)
197
234
  rememberLanes(live.source.root, live.memories, read.documents.laneOrder)
198
235
  const next = withKnownPage(live, read)
199
236
  live.read = read
200
- if (!live.snapshot) live.view = { ...live.view, selection: startSelection(next, live.source.options) }
237
+ if (!live.snapshot)
238
+ live.view = {
239
+ ...live.view,
240
+ selection: startSelection(next, live.source.options),
241
+ }
201
242
  const folded = syncLaneFolds(
202
243
  live.view.selection,
203
- next.views.map(({ progress, status }) => ({ lane: progress.lane.name, status: status.agent })),
244
+ next.views.map(({ progress, status }) => ({
245
+ lane: progress.lane.name,
246
+ status: status.agent,
247
+ })),
204
248
  )
205
- live.view = { ...live.view, selection: folded }
249
+ const settled = settledBlockFolds(folded, {
250
+ waiting: waitingKeys(next.waiting ?? []),
251
+ next: nextKeys(next.next?.rows ?? []),
252
+ appointments: appointmentKeys(next.appointments?.rows ?? []),
253
+ sources: sourceKeys(next.sourceBlock?.sources ?? []),
254
+ })
255
+ live.view = { ...live.view, selection: settled }
206
256
  live.snapshot = next
207
257
  const fetchLine = fetchNotice(live.fetch, baseRefOf(live.source.settings), CLOCK_LOCALE)
208
258
  live.errors = [
@@ -246,39 +296,133 @@ const openPlan = (live: Live, snapshot: Snapshot, address: string) => {
246
296
  live.notice = reason === null ? texts.pageOpened(live.page.path) : texts.pageNotOpenedReason(reason)
247
297
  }
248
298
 
249
- /** A plain click on the for-you block expands or folds a cut-off row, or opens its file or Artifact; the opener's failure shows in the footer. */
250
- const clickWaiting = (live: Live, snapshot: Snapshot, screen: Screen, input: string): boolean => {
251
- const mouse = parseMouse(input)
252
- const rows = waitingRows(snapshot.waiting ?? [], live.view.selection.expanded, paneWidth())
253
- const outcome = mouse ? waitingClick(rows, screen.waitingLine, mouse) : null
254
- if (!outcome) return false
299
+ /**
300
+ * What a hit on a row of a block of the herd does: expands or folds it, asks the coordinator to put a question or a board row
301
+ * (the result shows in the footer) or opens its address (a failure shows there). An appointment row only ever expands.
302
+ */
303
+ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
255
304
  if ('toggle' in outcome) {
256
- live.view = { ...live.view, selection: toggledExpansion(live.view.selection, outcome.toggle) }
257
- return true
305
+ live.view = {
306
+ ...live.view,
307
+ selection: toggledExpansion(live.view.selection, outcome.toggle),
308
+ }
309
+ return
310
+ }
311
+ if ('ask' in outcome) {
312
+ const { root, settings } = live.source
313
+ live.notice = askCoordinator(outcome.ask, {
314
+ language: settings.responseLanguage,
315
+ adapter: settings.adapter,
316
+ repository: basename(root),
317
+ run: runHerdr,
318
+ })
319
+ return
320
+ }
321
+ if ('done' in outcome) {
322
+ live.notice = closeRow(outcome.done, {
323
+ sprintFile: live.source.sprintFile,
324
+ language: live.source.settings.planLanguage,
325
+ now: new Date(),
326
+ closeRecords: (ids) => closeWaiting(live.source.root, ids, Date.now()),
327
+ })
328
+ return
258
329
  }
259
330
  const reason = openInBrowser(outcome.open)
260
331
  if (reason) live.notice = boardTextsOf(live).notOpened(terminalSafe(reason))
332
+ }
333
+
334
+ /**
335
+ * 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
336
+ * cut-off row expands, a file or Artifact opens, a question or a board row asks the coordinator; the failure or the result
337
+ * shows in the footer. An appointment row has nothing to open or ask.
338
+ */
339
+ const clickBlock = (live: Live, block: TopBlockId, hit: BlockHit | null, input: string): boolean => {
340
+ const mouse = parseMouse(input)
341
+ if (!mouse || !hit) return false
342
+ if (blockHeadClick(hit.firstLine - 1, mouse)) {
343
+ live.view = {
344
+ ...live.view,
345
+ selection: toggledBlockFold(live.view.selection, block, hit.keys),
346
+ }
347
+ return true
348
+ }
349
+ const outcome = blockClick(hit.rows, hit.firstLine, mouse, hit.moreKey)
350
+ if (!outcome) return false
351
+ applyBlockClick(live, outcome)
261
352
  return true
262
353
  }
263
354
 
355
+ /** The mouse moved: the row under it is drawn like the selected one, and the screen is redrawn only when that row changes. */
356
+ const hover = (live: Live, screen: Screen, mouse: MouseEvent): void => {
357
+ const frame = { headerRows: screen.header.length, bodyRows: bodyRows(screen), top: live.view.top }
358
+ const key = hoveredKeyAt(screen.focusHits, mouse, frame)
359
+ const line = lineIndexAt(mouse, frame)
360
+ const text = line === null ? '' : (screen.header[line] ?? screen.body.lines[line - frame.headerRows] ?? '')
361
+ const selection = withHovered(
362
+ live.view.selection,
363
+ key,
364
+ key === null ? null : bracketAt(stripAnsi(text), mouse.column - 1),
365
+ )
366
+ if (selection === live.view.selection) return
367
+ live.view = { ...live.view, selection }
368
+ redraw(live)
369
+ }
370
+
371
+ /**
372
+ * Enter or space on the selected row of a block on top does its main action (`mainClickOf`: ask, open, expand); true
373
+ * when the input was for such a row, whether or not it had anything to do.
374
+ */
375
+ const activateBlockRow = (live: Live, screen: Screen, input: string): boolean => {
376
+ if (!isActivateKey(input)) return false
377
+ const key = live.view.selection.selected
378
+ const row = key === null ? null : blockRowByKey(blocksOf(screen), key)
379
+ if (!row) return false
380
+ const click = mainClickOf(row)
381
+ if (click) applyBlockClick(live, click)
382
+ return true
383
+ }
384
+
385
+ const blocksOf = ({ waitingBlock, nextBlock, appointmentBlock, sourceBlock }: Screen): (BlockHit | null)[] => [
386
+ waitingBlock,
387
+ nextBlock,
388
+ appointmentBlock,
389
+ sourceBlock,
390
+ ]
391
+
264
392
  const handleInput = (live: Live, input: string) => {
265
393
  const { snapshot, screen } = live
266
394
  if (!snapshot || !screen) return
395
+ const mouse = parseMouse(input)
396
+ if (mouse && isMotion(mouse)) {
397
+ hover(live, screen, mouse)
398
+ return
399
+ }
267
400
  live.notice = null
401
+ live.view = { ...live.view, selection: settledSelection(live.view.selection, screen) }
402
+ if (activateBlockRow(live, screen, input)) {
403
+ redraw(live)
404
+ return
405
+ }
268
406
  if (isSprintPageKey(input)) {
269
407
  void writeSprintPage(live, snapshot)
270
408
  return
271
409
  }
272
- if (clickWaiting(live, snapshot, screen, input)) {
410
+ if (
411
+ clickBlock(live, TOP_BLOCK.waiting, screen.waitingBlock, input) ||
412
+ clickBlock(live, TOP_BLOCK.next, screen.nextBlock, input) ||
413
+ clickBlock(live, TOP_BLOCK.appointments, screen.appointmentBlock, input) ||
414
+ clickBlock(live, TOP_BLOCK.sources, screen.sourceBlock, input)
415
+ ) {
273
416
  redraw(live)
274
417
  return
275
418
  }
276
419
  const context = {
277
- keys: targetKeys(screen.body.targets),
420
+ keys: screen.rowKeys,
278
421
  targets: screen.body.targets,
279
422
  headerRows: screen.header.length,
280
423
  bodyRows: bodyRows(screen),
281
424
  }
425
+ live.notice = showDoneNotice(live.view.selection, input, context.keys)
282
426
  const pullRequests = snapshot.views.flatMap((view) => view.pullRequests)
283
427
  const outcome = inputOutcome(live.view, input, context, (key) => pullRequestUrl(pullRequests, key))
284
428
  live.view = outcome.view
@@ -306,12 +450,14 @@ export const runLive = (source: Source): void => {
306
450
  fetchHead: fetchHeadPath(source.root),
307
451
  reported: new Map(),
308
452
  memories: recallLanes(source.root),
453
+ cache: newReadCache(),
309
454
  pending: null,
310
455
  read: null,
311
456
  page: null,
312
457
  pageWriting: false,
313
458
  flight: null,
314
459
  pageError: null,
460
+ written: null,
315
461
  }
316
462
  const schedule = () => {
317
463
  if (live.pending) clearTimeout(live.pending)
@@ -320,6 +466,8 @@ export const runLive = (source: Source): void => {
320
466
  const fetchBaseBranch = async () => {
321
467
  live.fetch = { ...live.fetch, lastAttempt: Date.now(), running: true }
322
468
  const failure = await fetchBase(source.root, BASE_REMOTE, source.settings.baseBranch)
469
+ const planHeads = planPullRequests([...live.pullRequests.byBranch.values()].flat(), source.settings.planDirectory)
470
+ if (failure === null) await fetchPlanHeads(source.root, BASE_REMOTE, planHeads)
323
471
  live.fetch = {
324
472
  ...live.fetch,
325
473
  running: false,
@@ -346,17 +494,24 @@ export const runLive = (source: Source): void => {
346
494
  fetchWhenDue(readable && gone.length > 0)
347
495
  schedule()
348
496
  }
349
- const quit = () => {
497
+ const leave = (code: number) => {
350
498
  clearTitles(source, live.reported)
351
499
  source.terminal.labelOwnPane(null)
352
500
  for (const watcher of live.watchers.values()) watcher.close()
353
501
  process.stdout.write(TERMINAL_OFF)
354
- process.exit(0)
502
+ process.exit(code)
355
503
  }
504
+ const quit = () => leave(0)
505
+ const takenOver = () => leave(TAKEN_OVER_CODE)
356
506
  const onInput = (input: string) => (QUIT_KEYS.has(input) ? quit() : handleInput(live, input))
357
507
 
358
508
  source.terminal.labelOwnPane(herdLabel(source.options.lane))
359
- enterTerminal(onInput, quit, () => redraw(live))
509
+ process.removeAllListeners(TAKEOVER_SIGNAL)
510
+ process.on(TAKEOVER_SIGNAL, takenOver)
511
+ enterTerminal(onInput, quit, () => {
512
+ live.written = null
513
+ redraw(live)
514
+ })
360
515
  setInterval(() => {
361
516
  fetchWhenDue(false)
362
517
  void refresh(live, schedule)
@@ -0,0 +1,44 @@
1
+ import { sectionLines } from '../plan-format/markdown'
2
+ import { NEXT_VOCABULARY, type PlanLanguage } from '../vocabulary'
3
+ import { shortTitle } from './task-name'
4
+ import type { BlockItem } from './waiting-click'
5
+
6
+ /** A line of the board's next section as the herd shows it. */
7
+ export type NextRow = { text: string }
8
+
9
+ /** The next block: the section's lines without the ones the for-you block already shows. */
10
+ export type NextBlock = { rows: NextRow[] }
11
+
12
+ const MAX_NEXT_ROWS = 3
13
+
14
+ const LIST_LINE = /^(?:\d+[.)]|[-*])\s+(.+)$/
15
+
16
+ const sameText = (left: string, right: string): boolean => left.trim().toLowerCase() === right.trim().toLowerCase()
17
+
18
+ /**
19
+ * The next section of the board, up to three list lines in the order written; the parser cuts after three, as the board
20
+ * says. A line equal to one of `shown` (the texts of the for-you block) is left out: it stands above already.
21
+ */
22
+ export const nextBlock = (board: string, language: PlanLanguage, shown: readonly string[]): NextBlock => ({
23
+ rows: sectionLines(board, NEXT_VOCABULARY[language].heading)
24
+ .flatMap((line) => LIST_LINE.exec(line.trim())?.[1] ?? [])
25
+ .slice(0, MAX_NEXT_ROWS)
26
+ .filter((text) => !shown.some((known) => sameText(known, text)))
27
+ .map((text) => ({ text })),
28
+ })
29
+
30
+ const nextKey = ({ text }: NextRow): string => `next>${text}`
31
+
32
+ /** The rows as the block draws them, numbered; a click on one only expands it. */
33
+ export const nextItems = (rows: readonly NextRow[]): BlockItem[] =>
34
+ rows.map((row, index) => ({
35
+ key: nextKey(row),
36
+ mark: String(index + 1),
37
+ text: row.text,
38
+ title: shortTitle(row.text),
39
+ url: null,
40
+ ask: null,
41
+ }))
42
+
43
+ /** The keys of all rows: what the fold of the block remembers. */
44
+ export const nextKeys = (rows: readonly NextRow[]): string[] => rows.map(nextKey)
@@ -0,0 +1,8 @@
1
+ const SUB_PHASE_LETTERS = /[a-z]+$/i
2
+
3
+ /** The main phase a sub-phase belongs to: `10c` is part of `10`; a main phase is its own. */
4
+ export const mainPhaseIdOf = (id: string): string => id.replace(SUB_PHASE_LETTERS, '')
5
+
6
+ /** Whether `named` names the phase: by its own id, or by its main phase when it is a sub-phase (`21b` by `21`). */
7
+ export const isNamedBy = (id: string, named: ReadonlySet<string>): boolean =>
8
+ named.has(id) || named.has(mainPhaseIdOf(id))
@@ -35,6 +35,21 @@ const tickCount = (phase: PlanPhase): number => phase.items.filter((item) => ite
35
35
  const tickedFurther = (worktreePhase: PlanPhase, basePhase: PlanPhase): boolean =>
36
36
  tickCount(worktreePhase) > tickCount(basePhase)
37
37
 
38
+ /**
39
+ * The phases of `base` with each phase replaced by the copy's where the copy ticked it further (`tickedFurther`), and
40
+ * the phases only the copy has after them. Nothing is marked touched: the copy is someone's branch, not the lane's own
41
+ * work, so it never makes a phase the lane's current one.
42
+ */
43
+ export const newerPhases = (base: readonly PlanPhase[], copy: readonly PlanPhase[]): PlanPhase[] => {
44
+ const copyById = new Map(copy.map((phase) => [phase.id, phase]))
45
+ const baseIds = new Set(base.map((phase) => phase.id))
46
+ const inBaseOrder = base.map((basePhase) => {
47
+ const copied = copyById.get(basePhase.id)
48
+ return copied && tickedFurther(copied, basePhase) ? copied : basePhase
49
+ })
50
+ return [...inBaseOrder, ...copy.filter((phase) => !baseIds.has(phase.id))]
51
+ }
52
+
38
53
  /** A phase read from the worktree is touched when its ticks differ from the base's, or the base lacks it. */
39
54
  const markTouched = (phase: PlanPhase, basePhase: PlanPhase | undefined): PlanPhase =>
40
55
  basePhase && sameTicks(phase, basePhase) ? phase : { ...phase, touched: true }
@@ -0,0 +1,28 @@
1
+ import { type PlanLanguage, type PlanPhase, parsePlanPhases } from '../plan-format'
2
+ import type { CacheRound } from '../shared/stat-cache'
3
+
4
+ type ParsedPlan = { text: string; language: PlanLanguage; phases: PlanPhase[] }
5
+
6
+ /** The phases of each plan by key, kept while the plan's text and language stay the same. */
7
+ export type PlanCache = Map<string, ParsedPlan>
8
+
9
+ export type PlanRound = CacheRound<ParsedPlan>
10
+
11
+ /**
12
+ * `parsePlanPhases` of the text, asked again only when the text or the language under `key` changed since the cache
13
+ * saw it. The text decides, not a modification time: most plans come from the base ref, which has none.
14
+ */
15
+ export const parsedPlanPhases = (
16
+ round: PlanRound | undefined,
17
+ key: string,
18
+ text: string,
19
+ language: PlanLanguage,
20
+ ): PlanPhase[] => {
21
+ if (!round) return parsePlanPhases(text, language)
22
+ round.used.add(key)
23
+ const kept = round.entries.get(key)
24
+ if (kept && kept.language === language && kept.text === text) return kept.phases
25
+ const phases = parsePlanPhases(text, language)
26
+ round.entries.set(key, { text, language, phases })
27
+ return phases
28
+ }
@@ -0,0 +1,54 @@
1
+ import type { BaseReader } from './git'
2
+ import type { PullRequest } from './pull-requests'
3
+
4
+ /** What choosing the heads needs of an open pull request: the herd's and the watcher's lists both carry it. */
5
+ export type PlanPullRequest = Pick<PullRequest, 'number' | 'branch' | 'files'>
6
+
7
+ /** An open pull request that changes a plan, with a reader of the files at its head. */
8
+ export type PlanHead = { number: number; files: readonly string[]; read: BaseReader }
9
+
10
+ /** Where the herd keeps the fetched heads of open pull requests: its own namespace, apart from branches and remotes. */
11
+ const PULL_HEAD_NAMESPACE = 'refs/jackherd/pr'
12
+
13
+ export const pullHeadRef = (number: number): string => `${PULL_HEAD_NAMESPACE}/${number}`
14
+
15
+ /** The refspec that fetches a pull request's head from `refs/pull/<n>/head`, which GitHub keeps for forks too. */
16
+ export const pullHeadRefspec = (number: number): string => `+refs/pull/${number}/head:${pullHeadRef(number)}`
17
+
18
+ const changesPlan = (pullRequest: PlanPullRequest, planDirectory: string): boolean =>
19
+ (pullRequest.files ?? []).some((path) => path.startsWith(`${planDirectory}/`))
20
+
21
+ /** The open pull requests that change a file under `planDirectory`, by number, so several heads merge in a fixed order. */
22
+ export const planPullRequests = <Open extends PlanPullRequest>(
23
+ pullRequests: readonly Open[],
24
+ planDirectory: string,
25
+ ): Open[] =>
26
+ pullRequests
27
+ .filter((pullRequest) => changesPlan(pullRequest, planDirectory))
28
+ .sort((first, second) => first.number - second.number)
29
+
30
+ /**
31
+ * `readerOf` asked once per ref, the reader kept for every later ask: build it once per read of all lanes, so a
32
+ * head costs one `git rev-parse` however many lanes read it.
33
+ */
34
+ export const readerOncePerRef = (readerOf: (ref: string) => BaseReader): ((ref: string) => BaseReader) => {
35
+ const readers = new Map<string, BaseReader>()
36
+ return (ref) => {
37
+ const kept = readers.get(ref) ?? readerOf(ref)
38
+ readers.set(ref, kept)
39
+ return kept
40
+ }
41
+ }
42
+
43
+ /**
44
+ * The heads a lane's plans are read from besides the base: every plan pull request but the one of the lane's own
45
+ * branch, whose ticks the lane's worktree holds already.
46
+ */
47
+ export const laneHeads = (
48
+ pullRequests: readonly PlanPullRequest[],
49
+ ownBranch: string | null,
50
+ readerOf: (ref: string) => BaseReader,
51
+ ): PlanHead[] =>
52
+ pullRequests
53
+ .filter(({ branch }) => branch !== ownBranch)
54
+ .map(({ number, files }) => ({ number, files: files ?? [], read: readerOf(pullHeadRef(number)) }))
@@ -19,8 +19,15 @@ export type PlannedPlan = {
19
19
  phaseIds: string[]
20
20
  done: number
21
21
  total: number
22
+ /** Set when the plan file was last changed `STALE_PLAN_DAYS` or more days ago; absent otherwise or when git does not know. */
23
+ stale?: true
22
24
  }
23
25
 
26
+ /** Days without a change to its plan file from which a planned phase reads `stale?`. */
27
+ export const STALE_PLAN_DAYS = 14
28
+
29
+ const DAY_MS = 86_400_000
30
+
24
31
  /** A lane of the sprint and what its agent reports, null when it has none. */
25
32
  export type PlannedLane = { lane: SprintLane; agent: string | null }
26
33
 
@@ -76,9 +83,24 @@ export const plannedPlans = (input: PlannedInput): PlannedPlan[] => {
76
83
  })
77
84
  }
78
85
 
86
+ /** The plans with `stale` set on those whose file `touchedAt` says was last changed `STALE_PLAN_DAYS` or more days before `now`. */
87
+ export const markStale = (
88
+ plans: readonly PlannedPlan[],
89
+ touchedAt: (path: string) => number | null,
90
+ now: number,
91
+ ): PlannedPlan[] =>
92
+ plans.map((plan) => {
93
+ const touched = touchedAt(plan.path)
94
+ return touched !== null && now - touched >= STALE_PLAN_DAYS * DAY_MS ? { ...plan, stale: true } : plan
95
+ })
96
+
79
97
  /** `P3 P4 P5` */
80
98
  export const phaseList = (plan: Pick<PlannedPlan, 'phaseIds'>): string => phaseLabels(plan.phaseIds, ' ')
81
99
 
100
+ /** `P3`: the phase of the plan that comes next, the first open one in the order of the plan. */
101
+ export const plannedNextLabel = (plan: Pick<PlannedPlan, 'phaseIds' | 'stale'>): string =>
102
+ `${phaseLabels(plan.phaseIds.slice(0, 1), ' ')}${plan.stale ? ' stale?' : ''}`
103
+
82
104
  /** How many phases the plans list together. */
83
105
  export const plannedPhaseCount = (planned: readonly Pick<PlannedPlan, 'phaseIds'>[]): number =>
84
106
  planned.reduce((count, plan) => count + plan.phaseIds.length, 0)
@@ -14,6 +14,8 @@ export type PullRequest = {
14
14
  checks: ChecksState
15
15
  /** The names of the checks that failed, each once, in the rollup's order; empty while none failed. */
16
16
  failedChecks: string[]
17
+ /** The paths the pull request changes, as `gh` lists them (at most 100); absent when the answer has none. */
18
+ files?: string[]
17
19
  }
18
20
 
19
21
  /**
@@ -40,10 +42,11 @@ export type GhPullRequest = {
40
42
  headRefName: string
41
43
  isDraft: boolean
42
44
  statusCheckRollup: RollupEntry[] | null
45
+ files?: { path: string }[]
43
46
  }
44
47
 
45
48
  /** The fields `parsePullRequests` reads; pass them to `gh pr list --json`. */
46
- export const PULL_REQUEST_FIELDS = 'number,title,url,headRefName,isDraft,statusCheckRollup'
49
+ export const PULL_REQUEST_FIELDS = 'number,title,url,headRefName,isDraft,statusCheckRollup,files'
47
50
 
48
51
  const RUN_IN_PATH = /^\/([\w.-]+)\/([\w.-]+)\/actions\/runs\/(\d+)(?:\/|$)/
49
52
 
@@ -131,6 +134,16 @@ const isPullRequestPage = (url: string, number: number, host: string): boolean =
131
134
  }
132
135
  }
133
136
 
137
+ const isFileList = (value: unknown): boolean =>
138
+ Array.isArray(value) &&
139
+ value.every(
140
+ (file) => typeof file === 'object' && file !== null && typeof (file as { path?: unknown }).path === 'string',
141
+ )
142
+
143
+ /** The `files` of a `gh` answer as plain paths, to spread into a pull request; nothing when the answer has none. */
144
+ export const filePathsOf = ({ files }: Pick<GhPullRequest, 'files'>): { files?: string[] } =>
145
+ files ? { files: files.map(({ path }) => path) } : {}
146
+
134
147
  /** One entry of `gh pr list --json <PULL_REQUEST_FIELDS>` whose link is the pull request's own page on `host`. */
135
148
  export const isGhPullRequest = (value: unknown, host: string): value is GhPullRequest => {
136
149
  if (typeof value !== 'object' || value === null) return false
@@ -142,7 +155,8 @@ export const isGhPullRequest = (value: unknown, host: string): value is GhPullRe
142
155
  isPullRequestPage(candidate.url, candidate.number, host) &&
143
156
  typeof candidate.headRefName === 'string' &&
144
157
  typeof candidate.isDraft === 'boolean' &&
145
- (candidate.statusCheckRollup === null || Array.isArray(candidate.statusCheckRollup))
158
+ (candidate.statusCheckRollup === null || Array.isArray(candidate.statusCheckRollup)) &&
159
+ (candidate.files === undefined || isFileList(candidate.files))
146
160
  )
147
161
  }
148
162
 
@@ -163,6 +177,7 @@ export const parsePullRequests = (json: string, host = 'github.com'): PullReques
163
177
  draft: pullRequest.isDraft,
164
178
  checks: checksOf(pullRequest.statusCheckRollup ?? []),
165
179
  failedChecks: failedChecksOf(pullRequest.statusCheckRollup ?? [], pullRequest.url).map(({ name }) => name),
180
+ ...filePathsOf(pullRequest),
166
181
  }))
167
182
  }
168
183