jackherd 1.8.1 → 1.10.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 (261) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/INSTALL.md +3 -1
  3. package/README.md +75 -64
  4. package/dist/{chunk-OU2KALXP.js → chunk-5MQBCUQ5.js} +24 -4
  5. package/dist/config.d.ts +45 -4
  6. package/dist/config.js +84 -27
  7. package/dist/plan-format.d.ts +63 -7
  8. package/dist/plan-format.js +66 -12
  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/approval-gate/index.ts +22 -4
  15. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  16. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  17. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  18. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  19. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  20. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  21. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  22. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  23. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  24. package/kit/hooks/hooks/merge-guard/decision.ts +127 -140
  25. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  26. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  27. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  28. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  29. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  30. package/kit/hooks/hooks/open-question/index.ts +26 -0
  31. package/kit/hooks/hooks/open-question/record.ts +135 -0
  32. package/kit/hooks/hooks/open-question/run.ts +52 -0
  33. package/kit/hooks/hooks/open-question/store.ts +39 -0
  34. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  35. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  36. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  37. package/kit/hooks/hooks/sent-file/store.ts +9 -0
  38. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  39. package/kit/hooks/hooks/shared/config.ts +7 -0
  40. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  41. package/kit/hooks/hooks/shared/files.ts +7 -0
  42. package/kit/hooks/hooks/shared/lane.ts +1 -1
  43. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  44. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  45. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  46. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  47. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  48. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  49. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  50. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  51. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  52. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  53. package/kit/hooks/hooks.json +21 -4
  54. package/kit/hooks/statusline.sh +1 -1
  55. package/kit/review/agents/skill-review.md +34 -0
  56. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  57. package/kit/workflow/skills/follow/SKILL.md +1 -8
  58. package/kit/workflow/skills/implement/SKILL.md +10 -6
  59. package/kit/workflow/skills/orchestrate/SKILL.md +213 -412
  60. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  61. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  62. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  63. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  64. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  65. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  66. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  67. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  68. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  69. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  70. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  71. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  72. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  73. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  74. package/kit/workflow/skills/plan/SKILL.md +6 -5
  75. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  76. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  77. package/package.json +4 -1
  78. package/recipes/README.md +2 -1
  79. package/recipes/sources.md +1 -1
  80. package/schemas/jackherd.schema.json +85 -7
  81. package/src/check/agent-text-reviewers.ts +23 -0
  82. package/src/check/command.ts +2 -0
  83. package/src/check/plan-structure-check.ts +49 -2
  84. package/src/check/plan.ts +8 -1
  85. package/src/check/schema.ts +9 -0
  86. package/src/check/skill-budget-defaults.ts +55 -0
  87. package/src/check/skill-budgets.ts +185 -0
  88. package/src/cli.ts +4 -0
  89. package/src/config/auto-window.ts +8 -0
  90. package/src/config/link-kinds.ts +7 -1
  91. package/src/config/schema.ts +80 -26
  92. package/src/context/fill.ts +12 -0
  93. package/src/context/session-log.ts +43 -1
  94. package/src/lane/close-board.ts +106 -0
  95. package/src/lane/close-check.ts +183 -0
  96. package/src/lane/close-command-line.ts +11 -0
  97. package/src/lane/close-command.ts +217 -0
  98. package/src/lane/close-options.ts +43 -0
  99. package/src/lane/close-survey.ts +83 -0
  100. package/src/lane/close.ts +141 -0
  101. package/src/lane/command.ts +13 -0
  102. package/src/lane-prompt/build.ts +74 -0
  103. package/src/lane-prompt/command.ts +87 -0
  104. package/src/lane-prompt/input.ts +116 -0
  105. package/src/lane-prompt/options.ts +68 -0
  106. package/src/lane-prompt/texts.ts +101 -0
  107. package/src/plan-format/clock.ts +12 -0
  108. package/src/plan-format/dependencies.ts +9 -9
  109. package/src/plan-format/index.ts +1 -0
  110. package/src/plan-format/lanes.ts +30 -5
  111. package/src/plan-format/short-names.ts +71 -0
  112. package/src/plan-format/terminal-text.ts +4 -0
  113. package/src/plan-format/waiting.ts +53 -0
  114. package/src/plans/active-tasks.ts +60 -0
  115. package/src/plans/appointment-rows.ts +59 -0
  116. package/src/plans/ask-coordinator.ts +77 -0
  117. package/src/plans/ask-target.ts +25 -0
  118. package/src/plans/child-stop.ts +51 -0
  119. package/src/plans/closable-records.ts +34 -0
  120. package/src/plans/command.ts +3 -2
  121. package/src/plans/current-phase.ts +17 -7
  122. package/src/plans/current-task.ts +63 -5
  123. package/src/plans/fetch.ts +26 -3
  124. package/src/plans/git.ts +19 -5
  125. package/src/plans/herd-claim.ts +173 -0
  126. package/src/plans/herd-memory.ts +36 -25
  127. package/src/plans/lane-plans.ts +44 -13
  128. package/src/plans/lane-standing.ts +9 -4
  129. package/src/plans/lane-summary.ts +367 -92
  130. package/src/plans/lane-tickets.ts +15 -0
  131. package/src/plans/layout-phase.ts +80 -19
  132. package/src/plans/layout-summary.ts +163 -74
  133. package/src/plans/layout.ts +472 -114
  134. package/src/plans/link-chips.ts +1 -0
  135. package/src/plans/live.ts +119 -10
  136. package/src/plans/phase-ids.ts +8 -0
  137. package/src/plans/phase-models.ts +1 -1
  138. package/src/plans/phase-ranges.ts +45 -0
  139. package/src/plans/phase-source.ts +15 -0
  140. package/src/plans/plan-cache.ts +28 -0
  141. package/src/plans/plan-heads.ts +54 -0
  142. package/src/plans/pull-requests.ts +23 -2
  143. package/src/plans/question-records.ts +48 -0
  144. package/src/plans/questions-read.ts +13 -0
  145. package/src/plans/read-cache.ts +8 -0
  146. package/src/plans/record-id.ts +4 -0
  147. package/src/plans/restart-decision.ts +13 -4
  148. package/src/plans/selection.ts +117 -34
  149. package/src/plans/sent-files-read.ts +13 -0
  150. package/src/plans/sent-files.ts +114 -0
  151. package/src/plans/settings.ts +2 -1
  152. package/src/plans/signalled-phase.ts +42 -0
  153. package/src/plans/signals-read.ts +62 -0
  154. package/src/plans/snapshot.ts +279 -60
  155. package/src/plans/sprint-export.ts +11 -11
  156. package/src/plans/sprint-page.ts +5 -5
  157. package/src/plans/subagent-phases.ts +95 -0
  158. package/src/plans/subagent-tasks.ts +51 -0
  159. package/src/plans/subagents-read.ts +131 -0
  160. package/src/plans/subagents.ts +190 -0
  161. package/src/plans/supervisor.ts +14 -4
  162. package/src/plans/symbols.ts +3 -0
  163. package/src/plans/task-name.ts +34 -0
  164. package/src/plans/texts.ts +36 -14
  165. package/src/plans/unlisted-lanes.ts +10 -5
  166. package/src/plans/view.ts +20 -12
  167. package/src/plans/waiting-click.ts +231 -0
  168. package/src/plans/waiting-records.ts +39 -0
  169. package/src/plans/waiting-window.ts +8 -0
  170. package/src/preview/command.ts +105 -0
  171. package/src/preview/embed.ts +209 -0
  172. package/src/preview/images.ts +89 -0
  173. package/src/preview/open.ts +12 -0
  174. package/src/preview/options.ts +32 -0
  175. package/src/preview/page.ts +89 -0
  176. package/src/preview/render.ts +41 -0
  177. package/src/settled/board-row.ts +1 -2
  178. package/src/shared/is-record.ts +2 -0
  179. package/src/shared/private-location.ts +89 -0
  180. package/src/shared/private-records.ts +48 -0
  181. package/src/shared/stat-cache.ts +48 -0
  182. package/src/sync/kit-source.ts +29 -10
  183. package/src/sync/kit.ts +2 -0
  184. package/src/sync/manifest.ts +2 -0
  185. package/src/sync/plan-ask-rules.ts +94 -0
  186. package/src/sync/plan-hooks.ts +2 -0
  187. package/src/sync/render.ts +3 -3
  188. package/src/sync/settings-hooks.ts +4 -1
  189. package/src/sync/sync-plan.ts +1 -0
  190. package/src/sync/sync.ts +24 -3
  191. package/src/task-signals/agent.ts +15 -0
  192. package/src/task-signals/claude-read.ts +89 -0
  193. package/src/task-signals/claude-signals.ts +112 -0
  194. package/src/task-signals/command.ts +71 -0
  195. package/src/task-signals/fixtures/session.jsonl +7 -0
  196. package/src/task-signals/matcher.ts +96 -0
  197. package/src/task-signals/record-store.ts +40 -0
  198. package/src/task-signals/record.ts +68 -0
  199. package/src/task-signals/text.ts +13 -0
  200. package/src/task-signals/types.ts +14 -0
  201. package/src/terminal/open.ts +13 -6
  202. package/src/terminal/text.ts +16 -4
  203. package/src/terminal/width.ts +2 -1
  204. package/src/version/skill-diff.ts +46 -0
  205. package/src/vocabulary/index.ts +44 -6
  206. package/src/waiting/command.ts +50 -0
  207. package/src/watch/appointments.ts +2 -4
  208. package/src/watch/approvals.ts +2 -2
  209. package/src/watch/asking-lanes.ts +40 -0
  210. package/src/watch/base-tip.ts +11 -0
  211. package/src/watch/command.ts +401 -90
  212. package/src/watch/failed-tests.ts +56 -10
  213. package/src/watch/failure-read.ts +18 -6
  214. package/src/watch/failures.ts +29 -5
  215. package/src/watch/feedback.ts +1 -1
  216. package/src/watch/gh-process.ts +9 -3
  217. package/src/watch/heartbeat-progress.ts +6 -18
  218. package/src/watch/json-guards.ts +0 -3
  219. package/src/watch/kit-update.ts +28 -9
  220. package/src/watch/lane-fill.ts +1 -1
  221. package/src/watch/lane-model.ts +1 -1
  222. package/src/watch/lane-name.ts +6 -0
  223. package/src/watch/lane-parallel.ts +1 -1
  224. package/src/watch/lane-progress.ts +58 -14
  225. package/src/watch/lane-report-file.ts +20 -5
  226. package/src/watch/lane-report.ts +37 -5
  227. package/src/watch/lane-rest.ts +28 -0
  228. package/src/watch/lane-sources.ts +31 -3
  229. package/src/watch/main-checkout.ts +34 -0
  230. package/src/watch/plan-badges-read.ts +13 -7
  231. package/src/watch/plan-badges.ts +9 -2
  232. package/src/watch/previews.ts +1 -1
  233. package/src/watch/pull-requests.ts +8 -3
  234. package/src/watch/reference-paths.ts +87 -0
  235. package/src/watch/running-subagents.ts +43 -0
  236. package/src/watch/skill-change.ts +124 -32
  237. package/src/watch/source-change.ts +48 -0
  238. package/src/watch/source-path.ts +1 -1
  239. package/src/watch/sources.ts +7 -4
  240. package/src/watch/stream-output.ts +14 -8
  241. package/src/watch/task-parallel-auto.ts +208 -0
  242. package/src/watch/task-parallel-data-paths.ts +46 -0
  243. package/src/watch/task-parallel-input.ts +108 -0
  244. package/src/watch/task-parallel-read.ts +151 -0
  245. package/src/watch/task-parallel-touched.ts +39 -0
  246. package/src/watch/task-parallel.ts +235 -0
  247. package/src/watch/task-signal-missing.ts +69 -0
  248. package/src/watch/task-signal-read.ts +29 -0
  249. package/src/watch/texts.ts +10 -0
  250. package/src/watch/update-available.ts +6 -3
  251. package/src/watch/waiting-line.ts +19 -0
  252. package/src/watch/watch-events.ts +36 -1
  253. package/src/watch/watcher-lock.ts +25 -29
  254. package/src/watch/workflow-runs.ts +2 -1
  255. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  256. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  257. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  258. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  259. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  260. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  261. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -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 })
package/src/plans/live.ts CHANGED
@@ -1,14 +1,18 @@
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 { appointmentKeys } from './appointment-rows'
8
+ import { askCoordinator } from './ask-coordinator'
6
9
  import type { LaneMemory } from './current-phase'
7
10
  import { errorText } from './error-text'
8
11
  import {
9
12
  type FetchState,
10
13
  fetchBase,
11
14
  fetchNotice,
15
+ fetchPlanHeads,
12
16
  goneLanePullRequests,
13
17
  initialFetchState,
14
18
  shouldFetch,
@@ -19,15 +23,25 @@ import { fetchPullRequests, noPullRequests, type PullRequestState } from './gith
19
23
  import { recallLanes, rememberLanes } from './herd-memory'
20
24
  import type { Screen } from './layout'
21
25
  import { type KnownPage, pageAfterWrite, pageKey, pageNeedsWriting, planAnchorOn, withPlanChips } from './plan-chips'
26
+ import { planPullRequests } from './plan-heads'
22
27
  import { herdLabel } from './plan-panes'
23
28
  import { pullRequestUrl } from './pull-requests'
29
+ import { newReadCache, type ReadCache } from './read-cache'
30
+ import { TAKEN_OVER_CODE, TAKEOVER_SIGNAL } from './restart-decision'
24
31
  import {
25
32
  inputOutcome,
26
33
  isSprintPageKey,
27
34
  overviewSelection,
35
+ parseMouse,
36
+ settledBlockFolds,
37
+ showDoneNotice,
28
38
  splitKeys,
29
39
  syncLaneFolds,
40
+ TOP_BLOCK,
41
+ type TopBlockId,
30
42
  targetKeys,
43
+ toggledBlockFold,
44
+ toggledExpansion,
31
45
  type View,
32
46
  windowTop,
33
47
  } from './selection'
@@ -36,7 +50,18 @@ import { singleFlight } from './single-flight'
36
50
  import { loadSnapshot, type Snapshot, type Source } from './snapshot'
37
51
  import { exportSprintPage, writeSprintPageInSteps } from './sprint-export'
38
52
  import { boardTexts } from './texts'
39
- import { bodyRows, clockOf, draw, paneWidth, renderSnapshot, startSelection, TERMINAL_OFF, TERMINAL_ON } from './view'
53
+ import {
54
+ bodyRows,
55
+ clockOf,
56
+ frameOutput,
57
+ frameToWrite,
58
+ paneWidth,
59
+ renderSnapshot,
60
+ startSelection,
61
+ TERMINAL_OFF,
62
+ TERMINAL_ON,
63
+ } from './view'
64
+ import { type BlockClick, type BlockHit, blockClick, blockHeadClick, waitingKeys } from './waiting-click'
40
65
 
41
66
  /** The state of the running view; only the functions below `runLive` change it. */
42
67
  type Live = {
@@ -55,6 +80,8 @@ type Live = {
55
80
  reported: Map<string, string | null>
56
81
  /** Per lane, the ticks seen at the last read, the phase that moved last and when each moved; kept across herds (`rememberLanes`). */
57
82
  memories: Map<string, LaneMemory>
83
+ /** Session logs and plans parsed at the last read, kept while they did not change. */
84
+ cache: ReadCache
58
85
  pending: Timer | null
59
86
  /** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of `page`. */
60
87
  read: Snapshot | null
@@ -63,6 +90,8 @@ type Live = {
63
90
  pageWriting: boolean
64
91
  flight: (() => Promise<void>) | null
65
92
  pageError: string | null
93
+ /** The frame last written to the terminal; null while its content is unknown, such as after a resize. */
94
+ written: string | null
66
95
  }
67
96
 
68
97
  const REFRESH_MS = 5_000
@@ -140,7 +169,10 @@ const redraw = (live: Live) => {
140
169
  const top = windowTop(live.view, screen.body.cursorLine, screen.body.lines.length, bodyRows(screen))
141
170
  live.screen = screen
142
171
  live.view = { ...live.view, top }
143
- draw(screen, top)
172
+ const output = frameToWrite(live.written, frameOutput(screen, top, paneWidth(), bodyRows(screen)))
173
+ if (output === null) return
174
+ live.written = output
175
+ process.stdout.write(output)
144
176
  }
145
177
 
146
178
  /** The whole sprint, also while the view shows one lane: the snapshot is read again without the lane filter. */
@@ -190,16 +222,27 @@ const refresh = (live: Live, onChange: () => void): Promise<void> => {
190
222
 
191
223
  const readOnce = async (live: Live, onChange: () => void) => {
192
224
  try {
193
- const read = await loadSnapshot(live.source, live.pullRequests, live.memories)
225
+ const read = await loadSnapshot(live.source, live.pullRequests, live.memories, live.cache)
194
226
  rememberLanes(live.source.root, live.memories, read.documents.laneOrder)
195
227
  const next = withKnownPage(live, read)
196
228
  live.read = read
197
- if (!live.snapshot) live.view = { ...live.view, selection: startSelection(next, live.source.options) }
229
+ if (!live.snapshot)
230
+ live.view = {
231
+ ...live.view,
232
+ selection: startSelection(next, live.source.options),
233
+ }
198
234
  const folded = syncLaneFolds(
199
235
  live.view.selection,
200
- next.views.map(({ progress, status }) => ({ lane: progress.lane.name, status: status.agent })),
236
+ next.views.map(({ progress, status }) => ({
237
+ lane: progress.lane.name,
238
+ status: status.agent,
239
+ })),
201
240
  )
202
- live.view = { ...live.view, selection: folded }
241
+ const settled = settledBlockFolds(folded, {
242
+ waiting: waitingKeys(next.waiting ?? []),
243
+ appointments: appointmentKeys(next.appointments?.rows ?? []),
244
+ })
245
+ live.view = { ...live.view, selection: settled }
203
246
  live.snapshot = next
204
247
  const fetchLine = fetchNotice(live.fetch, baseRefOf(live.source.settings), CLOCK_LOCALE)
205
248
  live.errors = [
@@ -243,6 +286,53 @@ const openPlan = (live: Live, snapshot: Snapshot, address: string) => {
243
286
  live.notice = reason === null ? texts.pageOpened(live.page.path) : texts.pageNotOpenedReason(reason)
244
287
  }
245
288
 
289
+ /**
290
+ * 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
291
+ * (the result shows in the footer) or opens its address (a failure shows there). An appointment row only ever expands.
292
+ */
293
+ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
294
+ if ('toggle' in outcome) {
295
+ live.view = {
296
+ ...live.view,
297
+ selection: toggledExpansion(live.view.selection, outcome.toggle),
298
+ }
299
+ return
300
+ }
301
+ if ('ask' in outcome) {
302
+ const { root, settings } = live.source
303
+ live.notice = askCoordinator(outcome.ask, {
304
+ language: settings.responseLanguage,
305
+ adapter: settings.adapter,
306
+ repository: basename(root),
307
+ run: runHerdr,
308
+ })
309
+ return
310
+ }
311
+ const reason = openInBrowser(outcome.open)
312
+ if (reason) live.notice = boardTextsOf(live).notOpened(terminalSafe(reason))
313
+ }
314
+
315
+ /**
316
+ * 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
317
+ * cut-off row expands, a file or Artifact opens, a question or a board row asks the coordinator; the failure or the result
318
+ * shows in the footer. An appointment row has nothing to open or ask.
319
+ */
320
+ const clickBlock = (live: Live, block: TopBlockId, hit: BlockHit | null, input: string): boolean => {
321
+ const mouse = parseMouse(input)
322
+ if (!mouse || !hit) return false
323
+ if (blockHeadClick(hit.firstLine - 1, mouse)) {
324
+ live.view = {
325
+ ...live.view,
326
+ selection: toggledBlockFold(live.view.selection, block, hit.keys),
327
+ }
328
+ return true
329
+ }
330
+ const outcome = blockClick(hit.rows, hit.firstLine, mouse, hit.moreKey)
331
+ if (!outcome) return false
332
+ applyBlockClick(live, outcome)
333
+ return true
334
+ }
335
+
246
336
  const handleInput = (live: Live, input: string) => {
247
337
  const { snapshot, screen } = live
248
338
  if (!snapshot || !screen) return
@@ -251,12 +341,20 @@ const handleInput = (live: Live, input: string) => {
251
341
  void writeSprintPage(live, snapshot)
252
342
  return
253
343
  }
344
+ if (
345
+ clickBlock(live, TOP_BLOCK.waiting, screen.waitingBlock, input) ||
346
+ clickBlock(live, TOP_BLOCK.appointments, screen.appointmentBlock, input)
347
+ ) {
348
+ redraw(live)
349
+ return
350
+ }
254
351
  const context = {
255
352
  keys: targetKeys(screen.body.targets),
256
353
  targets: screen.body.targets,
257
354
  headerRows: screen.header.length,
258
355
  bodyRows: bodyRows(screen),
259
356
  }
357
+ live.notice = showDoneNotice(live.view.selection, input, context.keys)
260
358
  const pullRequests = snapshot.views.flatMap((view) => view.pullRequests)
261
359
  const outcome = inputOutcome(live.view, input, context, (key) => pullRequestUrl(pullRequests, key))
262
360
  live.view = outcome.view
@@ -284,12 +382,14 @@ export const runLive = (source: Source): void => {
284
382
  fetchHead: fetchHeadPath(source.root),
285
383
  reported: new Map(),
286
384
  memories: recallLanes(source.root),
385
+ cache: newReadCache(),
287
386
  pending: null,
288
387
  read: null,
289
388
  page: null,
290
389
  pageWriting: false,
291
390
  flight: null,
292
391
  pageError: null,
392
+ written: null,
293
393
  }
294
394
  const schedule = () => {
295
395
  if (live.pending) clearTimeout(live.pending)
@@ -298,6 +398,8 @@ export const runLive = (source: Source): void => {
298
398
  const fetchBaseBranch = async () => {
299
399
  live.fetch = { ...live.fetch, lastAttempt: Date.now(), running: true }
300
400
  const failure = await fetchBase(source.root, BASE_REMOTE, source.settings.baseBranch)
401
+ const planHeads = planPullRequests([...live.pullRequests.byBranch.values()].flat(), source.settings.planDirectory)
402
+ if (failure === null) await fetchPlanHeads(source.root, BASE_REMOTE, planHeads)
301
403
  live.fetch = {
302
404
  ...live.fetch,
303
405
  running: false,
@@ -324,17 +426,24 @@ export const runLive = (source: Source): void => {
324
426
  fetchWhenDue(readable && gone.length > 0)
325
427
  schedule()
326
428
  }
327
- const quit = () => {
429
+ const leave = (code: number) => {
328
430
  clearTitles(source, live.reported)
329
431
  source.terminal.labelOwnPane(null)
330
432
  for (const watcher of live.watchers.values()) watcher.close()
331
433
  process.stdout.write(TERMINAL_OFF)
332
- process.exit(0)
434
+ process.exit(code)
333
435
  }
436
+ const quit = () => leave(0)
437
+ const takenOver = () => leave(TAKEN_OVER_CODE)
334
438
  const onInput = (input: string) => (QUIT_KEYS.has(input) ? quit() : handleInput(live, input))
335
439
 
336
440
  source.terminal.labelOwnPane(herdLabel(source.options.lane))
337
- enterTerminal(onInput, quit, () => redraw(live))
441
+ process.removeAllListeners(TAKEOVER_SIGNAL)
442
+ process.on(TAKEOVER_SIGNAL, takenOver)
443
+ enterTerminal(onInput, quit, () => {
444
+ live.written = null
445
+ redraw(live)
446
+ })
338
447
  setInterval(() => {
339
448
  fetchWhenDue(false)
340
449
  void refresh(live, schedule)
@@ -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))
@@ -1,5 +1,5 @@
1
1
  import { type LaneProgress, resolvePlanPath } from '../plan-format'
2
- import { isRecord } from '../watch/json-guards'
2
+ import { isRecord } from '../shared/is-record'
3
3
 
4
4
  /** The model families a phase ran on and was recommended, as lower-case words; `fits` null without a recommendation. */
5
5
  export type PhaseModel = { used: string; recommended: string | null; fits: boolean | null }
@@ -0,0 +1,45 @@
1
+ import { phaseLabel } from '../plan-format'
2
+
3
+ const RANGE_DASH = '–'
4
+ const RANGE_SEPARATOR = ', '
5
+ /** A phase id as a number with an optional sub-phase letter: `10`, `10c`. */
6
+ const NUMBERED_ID = /^(\d+)([a-z]*)$/i
7
+
8
+ /** One entry of the text: a run of whole phases `first`–`last`, or one id that stands alone. */
9
+ type Entry = { first: number; last: number } | { alone: string }
10
+
11
+ const byNumber = (first: string, second: string): number => first.localeCompare(second, undefined, { numeric: true })
12
+
13
+ const entryText = (entry: Entry): string => {
14
+ if ('alone' in entry) return phaseLabel(entry.alone)
15
+ const first = phaseLabel(String(entry.first))
16
+ return entry.first === entry.last ? first : `${first}${RANGE_DASH}${phaseLabel(String(entry.last))}`
17
+ }
18
+
19
+ /** The entry `id` extends, or the entries with `id` after them; `wholes` are the main phases among the ids. */
20
+ const withId = (entries: Entry[], id: string, wholes: ReadonlySet<string>): Entry[] => {
21
+ const [, main = '', sub = ''] = NUMBERED_ID.exec(id) ?? []
22
+ if (main === '' || (sub !== '' && !wholes.has(String(Number(main))))) return [...entries, { alone: id }]
23
+ if (sub !== '') return entries
24
+ const number = Number(main)
25
+ const last = entries.at(-1)
26
+ if (last && !('alone' in last) && last.last === number - 1)
27
+ return [...entries.slice(0, -1), { ...last, last: number }]
28
+ return [...entries, { first: number, last: number }]
29
+ }
30
+
31
+ /**
32
+ * Phase ids as ranges, numbered ids by number first: `P1–P7, P10–P15`. A gap ends a range; a sub-phase (`P10c`) is
33
+ * inside its main phase when that one is among the ids too, else it stands alone between the ranges. An id that is
34
+ * no number stands alone after the numbered ones. Empty for no id.
35
+ */
36
+ export const phaseRanges = (ids: readonly string[]): string => {
37
+ const sorted = [...new Set(ids)].sort(byNumber)
38
+ const numbered = sorted.filter((id) => NUMBERED_ID.test(id))
39
+ const wholes = new Set(numbered.filter((id) => /^\d+$/.test(id)).map((id) => String(Number(id))))
40
+ const others = sorted.filter((id) => !NUMBERED_ID.test(id))
41
+ return [...numbered, ...others]
42
+ .reduce<Entry[]>((entries, id) => withId(entries, id, wholes), [])
43
+ .map(entryText)
44
+ .join(RANGE_SEPARATOR)
45
+ }
@@ -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)) }))
@@ -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
 
@@ -176,6 +191,12 @@ export const pullRequestsByBranch = (pullRequests: readonly PullRequest[]): Map<
176
191
  /** The selection key of a pull request row; two parts, so it never equals a three-part phase key. */
177
192
  export const pullRequestKey = (number: number): string => `pr:${number}`
178
193
 
194
+ /**
195
+ * The key of a failed test's line under a pull request, by its place in the list: a click unfolds a cut name and folds
196
+ * it again. No colon and no `#`, so it never reads as a phase or task key.
197
+ */
198
+ export const failedTestKey = (number: number, index: number): string => `failed>${number}>${index}`
199
+
179
200
  /** The link behind a row key, or null when the key is a phase or a pull request no longer open. */
180
201
  export const pullRequestUrl = (pullRequests: readonly PullRequest[], key: string): string | null =>
181
202
  pullRequests.find((pullRequest) => pullRequestKey(pullRequest.number) === key)?.url ?? null
@@ -0,0 +1,48 @@
1
+ import { clockText } from '../plan-format/clock'
2
+ import { terminalSafe } from '../plan-format/terminal-text'
3
+ import { WAITING_KIND, type WaitingItem } from '../plan-format/waiting'
4
+ import { isRecordId } from './record-id'
5
+ import { isStillWaiting } from './waiting-window'
6
+
7
+ /** The record of a question that ended without an answer, as the kit hook `open-question` writes it to `.jackherd/questions/`. */
8
+ export type QuestionRecord = {
9
+ id: string
10
+ question: string
11
+ askedAt: number
12
+ cwd: string
13
+ session: string
14
+ closedAt: number | null
15
+ }
16
+
17
+ /** Whether `value` has the shape of a question record's id: twelve lower-case hex digits. */
18
+ export const isQuestionId = isRecordId
19
+
20
+ /** The same shape as `isQuestionRecord` in the hook, which this package does not import at run time (contract test). */
21
+ export const isQuestionRecord = (value: unknown): value is QuestionRecord => {
22
+ if (typeof value !== 'object' || value === null) return false
23
+ const record = value as Record<string, unknown>
24
+ return (
25
+ typeof record.id === 'string' &&
26
+ isQuestionId(record.id) &&
27
+ typeof record.question === 'string' &&
28
+ typeof record.askedAt === 'number' &&
29
+ typeof record.cwd === 'string' &&
30
+ typeof record.session === 'string' &&
31
+ (record.closedAt === null || typeof record.closedAt === 'number')
32
+ )
33
+ }
34
+
35
+ const itemOf = (record: QuestionRecord): WaitingItem => ({
36
+ since: clockText(record.askedAt),
37
+ item: terminalSafe(record.question),
38
+ kind: WAITING_KIND.question,
39
+ id: record.id,
40
+ open: true,
41
+ })
42
+
43
+ /** What the unanswered questions wait on: one open item per record younger than three days, oldest first. */
44
+ export const questionWaitingItems = (records: readonly QuestionRecord[], now: number): WaitingItem[] =>
45
+ records
46
+ .filter(({ closedAt, askedAt }) => isStillWaiting(closedAt, askedAt, now))
47
+ .sort((left, right) => left.askedAt - right.askedAt)
48
+ .map(itemOf)
@@ -0,0 +1,13 @@
1
+ import { closableRecordLog } from './closable-records'
2
+ import { isQuestionRecord } from './question-records'
3
+
4
+ const log = closableRecordLog('questions', isQuestionRecord, ({ askedAt }) => askedAt)
5
+
6
+ /** Every record of the question log in the main checkout, oldest first; nothing when the directory is missing or unreadable. */
7
+ export const readQuestionRecords = log.read
8
+
9
+ /** Marks the named records `closedAt` and returns how many it closed; one closed already, or unknown, counts for nothing. */
10
+ export const closeQuestionRecords = log.close
11
+
12
+ /** Marks every open record `closedAt` and returns how many it closed. */
13
+ export const closeAllQuestionRecords = log.closeAll
@@ -0,0 +1,8 @@
1
+ import type { StatCache } from '../shared/stat-cache'
2
+ import type { TaskSignal } from '../task-signals/types'
3
+ import type { PlanCache } from './plan-cache'
4
+
5
+ /** What the herd keeps between its reads, so session logs and plans that did not change are not parsed again. */
6
+ export type ReadCache = { signals: StatCache<TaskSignal[]>; plans: PlanCache }
7
+
8
+ export const newReadCache = (): ReadCache => ({ signals: new Map(), plans: new Map() })
@@ -0,0 +1,4 @@
1
+ const RECORD_ID = /^[0-9a-f]{12}$/
2
+
3
+ /** Whether `value` has the shape of a record's id: twelve lower-case hex digits. */
4
+ export const isRecordId = (value: string): boolean => RECORD_ID.test(value)
@@ -2,6 +2,12 @@ import { constants } from 'node:os'
2
2
 
3
3
  export const CHILD_MARKER = 'JACKHERD_PLANS_CHILD'
4
4
 
5
+ /** The signal a newer herd view sends the older one of its terminal, so the older one ends as a wanted end. */
6
+ export const TAKEOVER_SIGNAL = 'SIGUSR2'
7
+
8
+ /** The exit code of a view that ended because a newer herd took its terminal over. */
9
+ export const TAKEN_OVER_CODE = 75
10
+
5
11
  /** Milliseconds a burst of file changes is collapsed into one restart. */
6
12
  export const RESTART_DEBOUNCE_MS = 300
7
13
 
@@ -26,12 +32,15 @@ export type NextStep = 'respawn' | 'wait' | 'exit'
26
32
 
27
33
  /**
28
34
  * What the supervisor does once the child has ended. Only a wanted end stops it: a forwarded
29
- * signal (`stopping`) or a clean exit code 0 (`q`, Ctrl-C in raw mode). A restart we asked for
30
- * respawns; any other end is a crash, and the supervisor waits for the next change to the source.
35
+ * signal (`stopping`), a takeover by a newer herd (the exit code `TAKEN_OVER_CODE`, or the end by SIGKILL or
36
+ * `TAKEOVER_SIGNAL` that the newer herd causes when the child does not answer in time) or a clean exit code 0 (`q`,
37
+ * Ctrl-C in raw mode). A restart we asked for respawns, even when our own stopper had to SIGKILL the child; any other
38
+ * end is a crash, and the supervisor waits for the next change to the source.
31
39
  */
32
- export const nextStep = ({ restarting, stopping, code }: ChildExit): NextStep => {
33
- if (stopping) return 'exit'
40
+ export const nextStep = ({ restarting, stopping, code, signal }: ChildExit): NextStep => {
41
+ if (stopping || code === TAKEN_OVER_CODE) return 'exit'
34
42
  if (restarting) return 'respawn'
43
+ if (signal === 'SIGKILL' || signal === TAKEOVER_SIGNAL) return 'exit'
35
44
  return code === 0 ? 'exit' : 'wait'
36
45
  }
37
46