jackherd 1.9.0 → 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 (204) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +50 -32
  4. package/dist/{chunk-MCP4XJQH.js → chunk-5MQBCUQ5.js} +9 -4
  5. package/dist/config.d.ts +39 -4
  6. package/dist/config.js +67 -26
  7. package/dist/plan-format.d.ts +45 -6
  8. package/dist/plan-format.js +46 -6
  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 +52 -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/workflow/skills/follow/SKILL.md +1 -8
  50. package/kit/workflow/skills/implement/SKILL.md +8 -6
  51. package/kit/workflow/skills/orchestrate/SKILL.md +210 -433
  52. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  53. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  54. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  55. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  56. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  57. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  58. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  59. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  60. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  61. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  62. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  63. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  64. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  65. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  66. package/kit/workflow/skills/plan/SKILL.md +6 -5
  67. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  68. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  69. package/package.json +3 -1
  70. package/recipes/README.md +2 -1
  71. package/recipes/sources.md +1 -1
  72. package/schemas/jackherd.schema.json +59 -5
  73. package/src/check/agent-text-reviewers.ts +23 -0
  74. package/src/check/command.ts +2 -0
  75. package/src/check/plan-structure-check.ts +49 -2
  76. package/src/check/plan.ts +8 -1
  77. package/src/check/schema.ts +9 -0
  78. package/src/check/skill-budget-defaults.ts +55 -0
  79. package/src/check/skill-budgets.ts +185 -0
  80. package/src/cli.ts +1 -0
  81. package/src/config/auto-window.ts +8 -0
  82. package/src/config/link-kinds.ts +7 -1
  83. package/src/config/schema.ts +58 -24
  84. package/src/lane/close-board.ts +106 -0
  85. package/src/lane/close-check.ts +183 -0
  86. package/src/lane/close-command-line.ts +11 -0
  87. package/src/lane/close-command.ts +217 -0
  88. package/src/lane/close-options.ts +43 -0
  89. package/src/lane/close-survey.ts +83 -0
  90. package/src/lane/close.ts +141 -0
  91. package/src/lane/command.ts +13 -0
  92. package/src/lane-prompt/build.ts +74 -0
  93. package/src/lane-prompt/command.ts +87 -0
  94. package/src/lane-prompt/input.ts +116 -0
  95. package/src/lane-prompt/options.ts +68 -0
  96. package/src/lane-prompt/texts.ts +101 -0
  97. package/src/plan-format/clock.ts +8 -0
  98. package/src/plan-format/dependencies.ts +7 -4
  99. package/src/plan-format/index.ts +1 -0
  100. package/src/plan-format/short-names.ts +71 -0
  101. package/src/plan-format/waiting.ts +7 -2
  102. package/src/plans/appointment-rows.ts +59 -0
  103. package/src/plans/ask-coordinator.ts +77 -0
  104. package/src/plans/ask-target.ts +25 -0
  105. package/src/plans/child-stop.ts +51 -0
  106. package/src/plans/closable-records.ts +34 -0
  107. package/src/plans/command.ts +3 -2
  108. package/src/plans/fetch.ts +26 -3
  109. package/src/plans/git.ts +19 -5
  110. package/src/plans/herd-claim.ts +173 -0
  111. package/src/plans/herd-memory.ts +36 -25
  112. package/src/plans/lane-plans.ts +44 -13
  113. package/src/plans/lane-summary.ts +253 -45
  114. package/src/plans/layout-phase.ts +29 -7
  115. package/src/plans/layout-summary.ts +89 -18
  116. package/src/plans/layout.ts +262 -68
  117. package/src/plans/link-chips.ts +1 -0
  118. package/src/plans/live.ts +107 -20
  119. package/src/plans/phase-ids.ts +8 -0
  120. package/src/plans/phase-source.ts +15 -0
  121. package/src/plans/plan-cache.ts +28 -0
  122. package/src/plans/plan-heads.ts +54 -0
  123. package/src/plans/pull-requests.ts +17 -2
  124. package/src/plans/question-records.ts +48 -0
  125. package/src/plans/questions-read.ts +13 -0
  126. package/src/plans/read-cache.ts +8 -0
  127. package/src/plans/record-id.ts +4 -0
  128. package/src/plans/restart-decision.ts +13 -4
  129. package/src/plans/selection.ts +98 -18
  130. package/src/plans/sent-files-read.ts +6 -15
  131. package/src/plans/sent-files.ts +7 -14
  132. package/src/plans/settings.ts +2 -1
  133. package/src/plans/signalled-phase.ts +42 -0
  134. package/src/plans/signals-read.ts +12 -4
  135. package/src/plans/snapshot.ts +85 -62
  136. package/src/plans/sprint-export.ts +11 -11
  137. package/src/plans/sprint-page.ts +5 -5
  138. package/src/plans/subagent-phases.ts +95 -0
  139. package/src/plans/subagent-tasks.ts +51 -0
  140. package/src/plans/subagents-read.ts +66 -17
  141. package/src/plans/subagents.ts +64 -6
  142. package/src/plans/supervisor.ts +14 -4
  143. package/src/plans/symbols.ts +3 -0
  144. package/src/plans/task-name.ts +34 -0
  145. package/src/plans/texts.ts +23 -6
  146. package/src/plans/view.ts +17 -9
  147. package/src/plans/waiting-click.ts +192 -37
  148. package/src/plans/waiting-records.ts +39 -0
  149. package/src/plans/waiting-window.ts +8 -0
  150. package/src/preview/command.ts +25 -3
  151. package/src/shared/private-location.ts +89 -0
  152. package/src/shared/stat-cache.ts +48 -0
  153. package/src/sync/kit-source.ts +29 -10
  154. package/src/sync/kit.ts +2 -0
  155. package/src/sync/manifest.ts +2 -0
  156. package/src/sync/plan-ask-rules.ts +94 -0
  157. package/src/sync/plan-hooks.ts +2 -0
  158. package/src/sync/render.ts +3 -3
  159. package/src/sync/settings-hooks.ts +4 -1
  160. package/src/sync/sync-plan.ts +1 -0
  161. package/src/sync/sync.ts +24 -3
  162. package/src/task-signals/claude-read.ts +27 -10
  163. package/src/terminal/text.ts +8 -3
  164. package/src/version/skill-diff.ts +46 -0
  165. package/src/vocabulary/index.ts +16 -6
  166. package/src/waiting/command.ts +22 -7
  167. package/src/watch/appointments.ts +1 -2
  168. package/src/watch/asking-lanes.ts +40 -0
  169. package/src/watch/base-tip.ts +11 -0
  170. package/src/watch/command.ts +330 -105
  171. package/src/watch/heartbeat-progress.ts +6 -18
  172. package/src/watch/kit-update.ts +28 -9
  173. package/src/watch/lane-name.ts +6 -0
  174. package/src/watch/lane-progress.ts +40 -8
  175. package/src/watch/lane-report-file.ts +20 -5
  176. package/src/watch/lane-report.ts +32 -4
  177. package/src/watch/lane-sources.ts +31 -3
  178. package/src/watch/main-checkout.ts +34 -0
  179. package/src/watch/plan-badges-read.ts +13 -7
  180. package/src/watch/pull-requests.ts +6 -2
  181. package/src/watch/reference-paths.ts +87 -0
  182. package/src/watch/running-subagents.ts +43 -0
  183. package/src/watch/skill-change.ts +124 -32
  184. package/src/watch/source-change.ts +48 -0
  185. package/src/watch/sources.ts +6 -3
  186. package/src/watch/stream-output.ts +4 -3
  187. package/src/watch/task-parallel-auto.ts +208 -0
  188. package/src/watch/task-parallel-data-paths.ts +46 -0
  189. package/src/watch/task-parallel-input.ts +5 -15
  190. package/src/watch/task-parallel-read.ts +3 -1
  191. package/src/watch/task-parallel.ts +36 -7
  192. package/src/watch/task-signal-missing.ts +69 -0
  193. package/src/watch/task-signal-read.ts +29 -0
  194. package/src/watch/texts.ts +10 -0
  195. package/src/watch/update-available.ts +6 -3
  196. package/src/watch/watch-events.ts +23 -0
  197. package/src/watch/watcher-lock.ts +25 -29
  198. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  199. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  200. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  201. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  202. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  203. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  204. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
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,16 +23,24 @@ 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,
28
35
  parseMouse,
36
+ settledBlockFolds,
37
+ showDoneNotice,
29
38
  splitKeys,
30
39
  syncLaneFolds,
40
+ TOP_BLOCK,
41
+ type TopBlockId,
31
42
  targetKeys,
43
+ toggledBlockFold,
32
44
  toggledExpansion,
33
45
  type View,
34
46
  windowTop,
@@ -38,8 +50,18 @@ import { singleFlight } from './single-flight'
38
50
  import { loadSnapshot, type Snapshot, type Source } from './snapshot'
39
51
  import { exportSprintPage, writeSprintPageInSteps } from './sprint-export'
40
52
  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'
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'
43
65
 
44
66
  /** The state of the running view; only the functions below `runLive` change it. */
45
67
  type Live = {
@@ -58,6 +80,8 @@ type Live = {
58
80
  reported: Map<string, string | null>
59
81
  /** Per lane, the ticks seen at the last read, the phase that moved last and when each moved; kept across herds (`rememberLanes`). */
60
82
  memories: Map<string, LaneMemory>
83
+ /** Session logs and plans parsed at the last read, kept while they did not change. */
84
+ cache: ReadCache
61
85
  pending: Timer | null
62
86
  /** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of `page`. */
63
87
  read: Snapshot | null
@@ -66,6 +90,8 @@ type Live = {
66
90
  pageWriting: boolean
67
91
  flight: (() => Promise<void>) | null
68
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
69
95
  }
70
96
 
71
97
  const REFRESH_MS = 5_000
@@ -143,7 +169,10 @@ const redraw = (live: Live) => {
143
169
  const top = windowTop(live.view, screen.body.cursorLine, screen.body.lines.length, bodyRows(screen))
144
170
  live.screen = screen
145
171
  live.view = { ...live.view, top }
146
- 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)
147
176
  }
148
177
 
149
178
  /** The whole sprint, also while the view shows one lane: the snapshot is read again without the lane filter. */
@@ -193,16 +222,27 @@ const refresh = (live: Live, onChange: () => void): Promise<void> => {
193
222
 
194
223
  const readOnce = async (live: Live, onChange: () => void) => {
195
224
  try {
196
- const read = await loadSnapshot(live.source, live.pullRequests, live.memories)
225
+ const read = await loadSnapshot(live.source, live.pullRequests, live.memories, live.cache)
197
226
  rememberLanes(live.source.root, live.memories, read.documents.laneOrder)
198
227
  const next = withKnownPage(live, read)
199
228
  live.read = read
200
- 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
+ }
201
234
  const folded = syncLaneFolds(
202
235
  live.view.selection,
203
- 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
+ })),
204
240
  )
205
- 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 }
206
246
  live.snapshot = next
207
247
  const fetchLine = fetchNotice(live.fetch, baseRefOf(live.source.settings), CLOCK_LOCALE)
208
248
  live.errors = [
@@ -246,18 +286,50 @@ const openPlan = (live: Live, snapshot: Snapshot, address: string) => {
246
286
  live.notice = reason === null ? texts.pageOpened(live.page.path) : texts.pageNotOpenedReason(reason)
247
287
  }
248
288
 
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
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 => {
255
294
  if ('toggle' in outcome) {
256
- live.view = { ...live.view, selection: toggledExpansion(live.view.selection, outcome.toggle) }
257
- return true
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
258
310
  }
259
311
  const reason = openInBrowser(outcome.open)
260
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)
261
333
  return true
262
334
  }
263
335
 
@@ -269,7 +341,10 @@ const handleInput = (live: Live, input: string) => {
269
341
  void writeSprintPage(live, snapshot)
270
342
  return
271
343
  }
272
- if (clickWaiting(live, snapshot, screen, input)) {
344
+ if (
345
+ clickBlock(live, TOP_BLOCK.waiting, screen.waitingBlock, input) ||
346
+ clickBlock(live, TOP_BLOCK.appointments, screen.appointmentBlock, input)
347
+ ) {
273
348
  redraw(live)
274
349
  return
275
350
  }
@@ -279,6 +354,7 @@ const handleInput = (live: Live, input: string) => {
279
354
  headerRows: screen.header.length,
280
355
  bodyRows: bodyRows(screen),
281
356
  }
357
+ live.notice = showDoneNotice(live.view.selection, input, context.keys)
282
358
  const pullRequests = snapshot.views.flatMap((view) => view.pullRequests)
283
359
  const outcome = inputOutcome(live.view, input, context, (key) => pullRequestUrl(pullRequests, key))
284
360
  live.view = outcome.view
@@ -306,12 +382,14 @@ export const runLive = (source: Source): void => {
306
382
  fetchHead: fetchHeadPath(source.root),
307
383
  reported: new Map(),
308
384
  memories: recallLanes(source.root),
385
+ cache: newReadCache(),
309
386
  pending: null,
310
387
  read: null,
311
388
  page: null,
312
389
  pageWriting: false,
313
390
  flight: null,
314
391
  pageError: null,
392
+ written: null,
315
393
  }
316
394
  const schedule = () => {
317
395
  if (live.pending) clearTimeout(live.pending)
@@ -320,6 +398,8 @@ export const runLive = (source: Source): void => {
320
398
  const fetchBaseBranch = async () => {
321
399
  live.fetch = { ...live.fetch, lastAttempt: Date.now(), running: true }
322
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)
323
403
  live.fetch = {
324
404
  ...live.fetch,
325
405
  running: false,
@@ -346,17 +426,24 @@ export const runLive = (source: Source): void => {
346
426
  fetchWhenDue(readable && gone.length > 0)
347
427
  schedule()
348
428
  }
349
- const quit = () => {
429
+ const leave = (code: number) => {
350
430
  clearTitles(source, live.reported)
351
431
  source.terminal.labelOwnPane(null)
352
432
  for (const watcher of live.watchers.values()) watcher.close()
353
433
  process.stdout.write(TERMINAL_OFF)
354
- process.exit(0)
434
+ process.exit(code)
355
435
  }
436
+ const quit = () => leave(0)
437
+ const takenOver = () => leave(TAKEN_OVER_CODE)
356
438
  const onInput = (input: string) => (QUIT_KEYS.has(input) ? quit() : handleInput(live, input))
357
439
 
358
440
  source.terminal.labelOwnPane(herdLabel(source.options.lane))
359
- 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
+ })
360
447
  setInterval(() => {
361
448
  fetchWhenDue(false)
362
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))
@@ -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
 
@@ -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