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
@@ -4,9 +4,11 @@
4
4
  * `watch.deploy` in the config), new feedback on a lane's open pull request (unless `watch.feedback` is false), a deploy preview for a
5
5
  * lane's new head commit (only with `watch.previews`), a new or changed entry of a configured source or its failure (only
6
6
  * with `watch.sources`), a gh outage, a lane's or the coordinator's own context fill rising to warn or alert, or the
7
- * heartbeat. A blocked lane adds one duty line, a merge one per lane, a lane at alert one, and every report ends with one status line
7
+ * heartbeat. A blocked lane adds one duty line, a merge one per lane, a lane at alert one, the main checkout behind
8
+ * `origin/<base>` one in every report and heartbeat until it caught up, and every report ends with one status line
8
9
  * per lane and one for the coordinator, in English. When the installed kit changes version
9
- * it prints `=== kit updated <old> → <new>` and ends; once a day it asks npm and prints `=== update available <old> → <new>`
10
+ * it prints `=== kit updated <old> → <new>` and ends, and so does a watcher that runs from a checkout's source when
11
+ * that source changes (`… (source changed)`, the version kept); once a day it asks npm and prints `=== update available <old> → <new>`
10
12
  * for a newer release, once per version. The coordinator is the agent in the
11
13
  * watcher's own herdr pane unless `--coordinator` names it, else `jack-<repository>` (or the old `orchestrator-<repository>`). A new start replaces a running watcher of the same
12
14
  * repository; one in another repository keeps running.
@@ -26,10 +28,11 @@
26
28
  * jh watch [--stream] [--heartbeat <minutes>] [--board <board.md>] [--coordinator <name>] [<lane> ...]
27
29
  * jh watch source <name|draft.json|-> --dry-run
28
30
  */
29
- import { readFileSync } from 'node:fs'
31
+ import { existsSync, readFileSync } from 'node:fs'
30
32
  import { homedir } from 'node:os'
31
33
  import { basename, dirname, join, relative } from 'node:path'
32
- import { readResolvedConfig } from '../config/read-config'
34
+ import { writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
35
+ import { readConfig, readResolvedConfig } from '../config/read-config'
33
36
  import type { JackherdConfig } from '../config/schema'
34
37
  import { resolveCoordinatorName } from '../context/coordinator-name'
35
38
  import { coordinatorSettings } from '../context/options'
@@ -40,11 +43,15 @@ import { terminalSafe } from '../plan-format/terminal-text'
40
43
  import { boardFor, foundLanesOf } from '../plans/board-pick'
41
44
  import { ensurePlanPanes } from '../plans/ensure-panes'
42
45
  import { errorText } from '../plans/error-text'
43
- import { mainCheckout, worktreeNames, worktreeRoot } from '../plans/git'
46
+ import { baseReaderOf, mainCheckout, worktreeNames, worktreeRoot } from '../plans/git'
44
47
  import { discoverLanes, isUnchanged, laneSetChange, laneSetLines } from '../plans/lane-discovery'
48
+ import { readerOncePerRef } from '../plans/plan-heads'
45
49
  import { CLOCK_LOCALE, type PlansSettings, plansSettingsOf, worktreeOf } from '../plans/settings'
50
+ import { readRunningSubagents, subagentSourceOf } from '../plans/subagents-read'
46
51
  import { boardTexts } from '../plans/texts'
52
+ import { readWaitingRecords, waitingItemsOf } from '../plans/waiting-records'
47
53
  import { defaultUpdateCheck } from '../sync/if-changed'
54
+ import { DEFAULT_SKILL_PREFIX } from '../sync/init'
48
55
  import { herdrLaneStatusSource } from '../terminal/herdr'
49
56
  import { AGENT_STATUS } from '../terminal/types'
50
57
  import { startedKit } from '../version/started-kit'
@@ -56,6 +63,7 @@ import {
56
63
  recordAppointments,
57
64
  } from './appointments'
58
65
  import { type ApprovalRequestFile, approvalEvents, readApprovalRequests, recordApprovals } from './approvals'
66
+ import { askingLanes, questionsFingerprint, recordReportQuestions } from './asking-lanes'
59
67
  import {
60
68
  type CoordinatorRead,
61
69
  contextReadingOf,
@@ -79,14 +87,14 @@ import {
79
87
  import { type FeedbackItem } from './feedback-item'
80
88
  import { type FeedbackItemsRead, readCommentItems, readFeedbackItems, readOwnLogin } from './feedback-read'
81
89
  import { answered, deployTargetOf, type GithubRead, readGithub, targetText } from './github-read'
82
- import { currentPhaseCountsOf, type PhaseCount, progressNotes } from './heartbeat-progress'
90
+ import { currentPhaseCountsOf } from './heartbeat-progress'
83
91
  import { agentScreen, listAgents } from './herdr'
84
92
  import { endedByKitUpdate } from './kit-update'
85
93
  import { type LaneReadings, laneFillEvents, recordLaneLevels } from './lane-fill'
86
94
  import { laneMessage, rememberedStatusLines, statusBlock } from './lane-message'
87
95
  import { type LaneModelReading, laneModelEvents, recordLaneModels } from './lane-model'
88
96
  import { laneParallelEvents, recordLaneParallels } from './lane-parallel'
89
- import { boardCheckoutRows, watchedLaneParallel, watchedLaneProgress } from './lane-progress'
97
+ import { boardCheckoutRows, watchedLaneParallel, watchedLaneProgress, watchedPlanPullRequests } from './lane-progress'
90
98
  import {
91
99
  appointmentEventLine,
92
100
  approvalDutyLines,
@@ -99,15 +107,18 @@ import {
99
107
  laneFillDutyLines,
100
108
  laneModelDutyLines,
101
109
  laneReportLine,
110
+ laneStopHeader,
102
111
  mergeDutyLines,
112
+ noTaskSignalDutyLines,
103
113
  otherPullRequestsLine,
104
114
  parallelDutyLines,
105
115
  sourceEventLines,
106
116
  sourceFailingStatusLine,
107
117
  watchEventLine,
108
118
  } from './lane-report'
109
- import { laneReportPath, readLaneReport, reportMtime } from './lane-report-file'
110
- import { branchOf, fetchBaseWhenMoved } from './lane-sources'
119
+ import { hasLaneHandover, laneReportPath, readLaneReport, reportMtime } from './lane-report-file'
120
+ import { restNote } from './lane-rest'
121
+ import { branchOf, fetchBaseWhenMoved, fetchPlanHeadsWhenMoved, readMainCheckout } from './lane-sources'
111
122
  import {
112
123
  advanceBaseline,
113
124
  armBaseline,
@@ -120,6 +131,7 @@ import {
120
131
  NO_AGENT,
121
132
  toSnapshot,
122
133
  } from './lane-watch'
134
+ import { mainCheckoutDutyLine } from './main-checkout'
123
135
  import { monitorToolAvailable } from './monitor-tool'
124
136
  import { parseWatchOptions, type WatchOptions } from './options'
125
137
  import {
@@ -141,15 +153,40 @@ import {
141
153
  } from './previews'
142
154
  import { latestPullRequestOf, type WatchedPullRequest } from './pull-requests'
143
155
  import { recordedReadsOf } from './recorded-reads'
156
+ import {
157
+ annotateFirstLine,
158
+ eventReference,
159
+ REFERENCE,
160
+ type Reference,
161
+ referenceHint,
162
+ type SkillCopy,
163
+ skillCopiesFor,
164
+ skillDirectoryFor,
165
+ } from './reference-paths'
166
+ import { heartbeatNotesOf, runningSubagentCountsOf } from './running-subagents'
144
167
  import { withSingleReportNotice } from './single-report'
145
- import { readOrchestrateSkill, skillLineFor } from './skill-change'
168
+ import { readOrchestrateSnapshot, skillLineFor } from './skill-change'
169
+ import { runsFromSource, sourceChangeSettled, watchSourceChanges } from './source-change'
146
170
  import { sourceDryRun } from './source-dry-run-command'
147
171
  import { runSource, SOURCE_LIMITS } from './source-run'
148
172
  import { recordWatchedSources } from './source-tickets'
149
173
  import { isSourceDue, type SourceRun, type SourceSettings, type SourceState, sourceEvents } from './sources'
150
174
  import { streamHeartbeatLine } from './stream-output'
175
+ import {
176
+ formatTaskParallelLine,
177
+ TASK_PARALLEL_EVENT,
178
+ type TaskParallelEvent,
179
+ type TaskParallelFingerprints,
180
+ taskParallelReport,
181
+ } from './task-parallel'
182
+ import { AUTO_ASK_PATHS, type AutoSettings, autoEndedReport, autoVerdict, localDay } from './task-parallel-auto'
183
+ import { holdsDataPathIn } from './task-parallel-data-paths'
184
+ import { readTaskParallelInputs } from './task-parallel-read'
185
+ import { type NoSignalState, type NoTaskSignalEvent, noTaskSignalEvents } from './task-signal-missing'
186
+ import { signalledLanes } from './task-signal-read'
151
187
  import { type WatchTexts, watchTexts } from './texts'
152
188
  import { updateAnnouncer } from './update-available'
189
+ import { waitingLine } from './waiting-line'
153
190
  import {
154
191
  coordinatorWatchEvent,
155
192
  deployEvents,
@@ -163,20 +200,14 @@ import {
163
200
  pullRequestEvents,
164
201
  recordCoordinatorLevel,
165
202
  recordWatchState,
203
+ UPDATE_AVAILABLE_EVENT,
166
204
  WATCH_EVENT,
167
205
  type WatchEvent,
168
206
  type WatchState,
169
207
  withReportBaselines,
170
208
  } from './watch-events'
171
- import {
172
- ensureWatcherDirectory,
173
- releaseLock,
174
- stopNoticeOf,
175
- takeOverLock,
176
- WATCHER_FILES,
177
- writePrivate,
178
- } from './watcher-lock'
179
- import type { DeployConfig } from './workflow-runs'
209
+ import { ensureWatcherDirectory, releaseLock, stopNoticeOf, takeOverLock, WATCHER_FILES } from './watcher-lock'
210
+ import type { DeployConfig, WorkflowRun } from './workflow-runs'
180
211
 
181
212
  type Files = { state: string; lock: string }
182
213
 
@@ -184,6 +215,7 @@ type Files = { state: string; lock: string }
184
215
  type FeedbackConfig = JackherdConfig['watch']['feedback']
185
216
 
186
217
  type ChecksConfig = JackherdConfig['watch']['checks']
218
+ type ParallelTasksConfig = JackherdConfig['watch']['parallelTasks']
187
219
 
188
220
  /** Where deploy previews are read from; undefined when the project reads none. */
189
221
  type PreviewsConfig = JackherdConfig['watch']['previews']
@@ -203,9 +235,16 @@ type Watch = {
203
235
  deploy: DeployConfig | undefined
204
236
  feedback: FeedbackConfig
205
237
  checks: ChecksConfig
238
+ parallelTasks: ParallelTasksConfig
239
+ /** `watch.taskSignalMinutes`: how long a lane works without a task signal before `no task signal`. */
240
+ taskSignalMinutes: number
241
+ /** The switch `watch.parallelTasks.auto` and the paths that always ask, both from the main checkout's config. */
242
+ parallelAuto: AutoSettings
206
243
  previews: PreviewsConfig
207
244
  sources: SourceSettings[]
208
245
  texts: WatchTexts
246
+ /** Where the orchestrate skill is installed, the project's copy first; the events name a reference inside the one found. */
247
+ skillCopies: SkillCopy[]
209
248
  /** Runs inside herdr with the herdr adapter, so missing herd panes can be brought back. */
210
249
  herdr: boolean
211
250
  }
@@ -230,8 +269,6 @@ type Watching = {
230
269
  state: WatchState
231
270
  baseline: LaneSnapshot
232
271
  current: LaneSnapshot
233
- /** The phase count of each lane at the last heartbeat, so a heartbeat names only a count that moved. */
234
- heartbeatCounts: Record<string, PhaseCount>
235
272
  /** The last `herdr agent list` answer, kept for finding the coordinator by its pane. */
236
273
  agents: HerdrAgent[]
237
274
  github: GithubRead | null
@@ -240,8 +277,10 @@ type Watching = {
240
277
  coordinator: Coordinator
241
278
  herdrDown: boolean
242
279
  reporting: boolean
243
- /** A fetch of the base branch is under way; the next one waits for it. */
244
- fetchingBase: boolean
280
+ /** The fetch of the base branch under way, null when none runs; the next one waits for it, a report after a merge too. */
281
+ baseFetch: Promise<void> | null
282
+ /** A fetch of the plan pull request heads is under way; the next one waits for it. */
283
+ fetchingHeads: boolean
245
284
  /** The account `gh` runs as, read once it answers; feedback is read only once it is known. */
246
285
  ownLogin: string | null
247
286
  /** The last feedback read that answered; its targets and items are recorded with every state. */
@@ -252,18 +291,24 @@ type Watching = {
252
291
  previewRead: PreviewRead | null
253
292
  /** The runs of the sources due at the last GitHub poll; recorded with every state like the feedback read. */
254
293
  sourceRuns: SourceRun[]
255
- /** Source name → when it was last started in this process; a source without an entry runs at the next GitHub poll. */
256
- sourceRanAt: Record<string, number>
257
294
  /** The lanes' fills of the last read; a lane without an entry or with null has no value. */
258
295
  laneReadings: LaneReadings
259
296
  /** The lanes' models against their board rows at the last read; recorded with every state like the fills. */
260
297
  laneModelReadings: Record<string, LaneModelReading>
261
298
  /** The phases of each lane's plan that can run together at the last read; recorded with every state like the models. */
262
299
  laneParallelReadings: Record<string, string[]>
300
+ /** The parallel-task fingerprint of each lane at the last heartbeat; persisted like the lane parallels. */
301
+ taskParallels: TaskParallelFingerprints
302
+ /** The last day of `watch.parallelTasks.auto` whose end was reported; persisted like the fingerprints. */
303
+ parallelAutoEnded: string | null
304
+ /** Since when each lane works without a task signal and the phase last reported; persisted like the fingerprints. */
305
+ noTaskSignals: NoSignalState
263
306
  /** The approval requests of the last poll; null before the first read, so the stored ids stay. */
264
307
  approvalRequests: ApprovalRequestFile[] | null
265
308
  /** The board's appointment table at the last poll and the time it was read at; null before the first read and while the board cannot be read. */
266
309
  appointments: { table: AppointmentTable; readAt: number } | null
310
+ /** The questions of each watched lane's report at the last poll (`questionsFingerprint`); null before the first read, so the stored ones stay. */
311
+ reportQuestions: Record<string, string> | null
267
312
  planViews: PlanViews
268
313
  }
269
314
 
@@ -282,6 +327,14 @@ const observationOf = (watching: Watching): Observation => ({
282
327
  github: answered(watching.github),
283
328
  })
284
329
 
330
+ /** The report questions to remember with the lanes whose report was `shown`; before the first read, the stored ones. */
331
+ const reportQuestionsAfter = (
332
+ watching: Watching,
333
+ remembered: Record<string, string>,
334
+ shown: readonly string[],
335
+ ): Record<string, string> =>
336
+ watching.reportQuestions === null ? remembered : recordReportQuestions(remembered, watching.reportQuestions, shown)
337
+
285
338
  /** Stamped with the time of the last GitHub read, not of the poll: a run created after it is still unseen. */
286
339
  const recordedOf = (watch: Watch, watching: Watching): WatchState => {
287
340
  const recorded = recordCoordinatorLevel(
@@ -299,11 +352,15 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
299
352
  laneLevels: recordLaneLevels(withReads.laneLevels, watching.laneReadings, watching.lanes),
300
353
  laneModels: recordLaneModels(withReads.laneModels, watching.laneModelReadings, watching.lanes),
301
354
  laneParallels: recordLaneParallels(withReads.laneParallels, watching.laneParallelReadings, watching.lanes),
355
+ taskParallels: watching.taskParallels,
356
+ parallelAutoEnded: watching.parallelAutoEnded,
357
+ noTaskSignals: watching.noTaskSignals,
302
358
  approvals: watching.approvalRequests ? recordApprovals(watching.approvalRequests) : withReads.approvals,
303
359
  sources: recordWatchedSources(withReads.sources, watching.sourceRuns, watch.sources, watching.githubReadAt),
304
360
  appointments: watching.appointments
305
361
  ? recordAppointments(withReads.appointments, watching.appointments.table, watching.appointments.readAt)
306
362
  : withReads.appointments,
363
+ reportQuestions: reportQuestionsAfter(watching, withReads.reportQuestions, []),
307
364
  }
308
365
  return withReportBaselines(withLevels, watching.lanes, (lane) => reportMtime(reportPathOf(watch, lane)))
309
366
  }
@@ -340,6 +397,18 @@ const screenOf = (lane: string): string => {
340
397
  const reportPathOf = (watch: Watch, lane: string): string | null =>
341
398
  laneReportPath(worktreeOf(watch.root, watch.settings, lane), lane)
342
399
 
400
+ /** What a resting lane waits on, from its report file (`restNote`); null while it works or when it asks nothing. */
401
+ const restNoteOf = (watch: Watch, lane: string, status: string | undefined): string | null =>
402
+ status !== undefined && IDLE_STATUSES.has(status)
403
+ ? restNote(readLaneReport(reportPathOf(watch, lane))?.text ?? null)
404
+ : null
405
+
406
+ /** The questions of each watched lane's report (`questionsFingerprint`), read at every poll. */
407
+ const readReportQuestions = (watch: Watch, lanes: readonly string[]): Record<string, string> =>
408
+ Object.fromEntries(
409
+ lanes.map((lane) => [lane, questionsFingerprint(readLaneReport(reportPathOf(watch, lane))?.text ?? null)]),
410
+ )
411
+
343
412
  /** The lines under a stopped lane's header and its report time to remember (`laneMessage`). */
344
413
  const stopMessageOf = (watch: Watch, lane: string, status: string, state: WatchState) =>
345
414
  laneMessage({
@@ -401,6 +470,24 @@ const laneBranchesOf = (watch: Watch, lanes: readonly string[]): Record<string,
401
470
  }),
402
471
  )
403
472
 
473
+ /** ` · read <path>` of `reference` in the skill copy found now; read per line, so a skill installed meanwhile is found. */
474
+ const hintFor = (watch: Watch, reference: Reference | null): string =>
475
+ reference === null ? '' : referenceHint(reference, skillDirectoryFor(watch.skillCopies, existsSync))
476
+
477
+ /** The lines of one event with its reference hint; a deploy names its target when `watch.deploy` can read it. */
478
+ const linesOfEvent = async (watch: Watch, event: WatchEvent, runs: readonly WorkflowRun[]): Promise<string[]> => {
479
+ const hint = hintFor(watch, eventReference(event))
480
+ if (event.kind === WATCH_EVENT.feedback) return annotateFirstLine(feedbackEventLines(event), hint)
481
+ if (event.kind === WATCH_EVENT.source) return annotateFirstLine(sourceEventLines(event), hint)
482
+ if (isAppointmentEvent(event)) return [`${appointmentEventLine(event)}${hint}`]
483
+ const headSha = event.kind === WATCH_EVENT.deploy ? runs.find((run) => run.id === event.runId)?.headSha : undefined
484
+ const target =
485
+ event.kind === WATCH_EVENT.deploy && headSha && watch.deploy
486
+ ? await deployTargetOf(watch.root, watch.deploy, event.runId, headSha)
487
+ : null
488
+ return [`${watchEventLine(event, target ? targetText(target) : null)}${hint}`]
489
+ }
490
+
404
491
  const eventLinesOf = async (
405
492
  watch: Watch,
406
493
  events: readonly WatchEvent[],
@@ -408,26 +495,7 @@ const eventLinesOf = async (
408
495
  ): Promise<string[]> => {
409
496
  const runs = answered(github)?.runs ?? []
410
497
  const lines: string[] = []
411
- for (const event of events) {
412
- if (event.kind === WATCH_EVENT.feedback) {
413
- lines.push(...feedbackEventLines(event))
414
- continue
415
- }
416
- if (event.kind === WATCH_EVENT.source) {
417
- lines.push(...sourceEventLines(event))
418
- continue
419
- }
420
- if (isAppointmentEvent(event)) {
421
- lines.push(appointmentEventLine(event))
422
- continue
423
- }
424
- const headSha = event.kind === WATCH_EVENT.deploy ? runs.find((run) => run.id === event.runId)?.headSha : undefined
425
- const target =
426
- event.kind === WATCH_EVENT.deploy && headSha && watch.deploy
427
- ? await deployTargetOf(watch.root, watch.deploy, event.runId, headSha)
428
- : null
429
- lines.push(watchEventLine(event, target ? targetText(target) : null))
430
- }
498
+ for (const event of events) lines.push(...(await linesOfEvent(watch, event, runs)))
431
499
  return lines
432
500
  }
433
501
 
@@ -435,22 +503,23 @@ const eventLinesOf = async (
435
503
  const boardReadOf = async (
436
504
  watch: Watch,
437
505
  lanes: readonly string[],
506
+ pullRequests: readonly WatchedPullRequest[],
438
507
  ): Promise<{ progress: Record<string, LaneProgress>; checkoutRows: number }> => {
439
508
  const text = await Bun.file(watch.sprintFile).text()
440
509
  const sprintLanes = parseSprintLanes(text, watch.settings.planLanguage)
441
510
  const watched = sprintLanes.filter((lane) => lanes.includes(lane.name))
442
- const entries = await Promise.all(
443
- watched.map(
444
- async (lane) =>
445
- [lane.name, await watchedLaneProgress(watch.root, watch.settings, watch.sprintFile, lane)] as const,
446
- ),
447
- )
511
+ const planPulls = watchedPlanPullRequests(pullRequests, watch.settings)
512
+ const headReaderOf = readerOncePerRef((ref) => baseReaderOf(watch.root, ref))
513
+ const progressOf = (lane: SprintLane) =>
514
+ watchedLaneProgress(watch.root, watch.settings, watch.sprintFile, lane, planPulls, headReaderOf)
515
+ const entries = await Promise.all(watched.map(async (lane) => [lane.name, await progressOf(lane)] as const))
448
516
  const checkoutRows = boardCheckoutRows(watch.root, watch.settings, watch.sprintFile, text)
449
517
  return { progress: Object.fromEntries(entries), checkoutRows }
450
518
  }
451
519
 
452
520
  /** What the status line of one lane is made of, from the loop's state. */
453
521
  const laneReportOf = (
522
+ watch: Watch,
454
523
  lane: string,
455
524
  watching: Watching,
456
525
  state: WatchState,
@@ -467,6 +536,7 @@ const laneReportOf = (
467
536
  pullRequest: pullRequest ? { number: pullRequest.number, checks: pullRequest.checks, feedback } : null,
468
537
  progress,
469
538
  context: watching.laneReadings[lane]?.level ?? null,
539
+ rest: restNoteOf(watch, lane, watching.current[lane]?.status),
470
540
  }
471
541
  }
472
542
 
@@ -474,9 +544,10 @@ const laneReportOf = (
474
544
  const boardReadOrNote = async (
475
545
  watch: Watch,
476
546
  lanes: readonly string[],
547
+ pullRequests: readonly WatchedPullRequest[],
477
548
  ): Promise<{ progress: Record<string, LaneProgress>; checkoutRows: number; notes: string[] }> => {
478
549
  try {
479
- return { ...(await boardReadOf(watch, lanes)), notes: [] }
550
+ return { ...(await boardReadOf(watch, lanes, pullRequests)), notes: [] }
480
551
  } catch (error) {
481
552
  return {
482
553
  progress: {},
@@ -498,18 +569,24 @@ const statusLinesOf = async (
498
569
  const github = answered(watching.github)
499
570
  const pullRequests = github?.pullRequests ?? []
500
571
  const others = otherPullRequestsLine(pullRequests, github?.own ?? [], watch.texts)
501
- const { progress, checkoutRows, notes } = await boardReadOrNote(watch, watching.lanes)
572
+ const { progress, checkoutRows, notes } = await boardReadOrNote(watch, watching.lanes, pullRequests)
573
+ const closeLaneHint = hintFor(watch, REFERENCE.closeLane)
502
574
  const laneLines = watching.lanes.map((lane) =>
503
- laneReportLine(laneReportOf(lane, watching, state, pullRequests, progress[lane] ?? null), watch.texts),
575
+ laneReportLine(
576
+ laneReportOf(watch, lane, watching, state, pullRequests, progress[lane] ?? null),
577
+ watch.texts,
578
+ closeLaneHint,
579
+ ),
504
580
  )
505
581
  const outage = state.ghDownSince === null ? [] : [ghUnavailableStatusLine(clock(state.ghDownSince))]
582
+ const waiting = waitingLineOf(watch)
506
583
  const board = { term: watch.settings.board.term, checkoutRows }
507
584
  const coordinator = coordinatorStatusLine(watching.coordinator.name, watching.coordinator.read, watch.texts, board)
508
585
  return {
509
586
  laneLines,
510
587
  coordinator,
511
588
  others: others === null ? [] : [others],
512
- rest: [...notes, ...outage, ...failingSourceLines(state.sources)],
589
+ rest: [...notes, ...(waiting === null ? [] : [waiting]), ...outage, ...failingSourceLines(state.sources)],
513
590
  }
514
591
  }
515
592
 
@@ -529,7 +606,10 @@ const stoppedLaneSections = (
529
606
  const status = watching.current[lane]?.status
530
607
  if (!status) return { lane, lines: [`=== ${lane}: ${terminalSafe(LANE_GONE)}`], mtime: undefined }
531
608
  const message = stopMessageOf(watch, lane, status, next)
532
- return { lane, lines: [`=== ${lane}: ${terminalSafe(status)}`, ...message.lines], mtime: message.reportedMtime }
609
+ const handoverHint = hasLaneHandover(worktreeOf(watch.root, watch.settings, lane), lane)
610
+ ? hintFor(watch, REFERENCE.laneHandover)
611
+ : null
612
+ return { lane, lines: [laneStopHeader(lane, status, handoverHint), ...message.lines], mtime: message.reportedMtime }
533
613
  })
534
614
  const shown = sections.flatMap(({ lane, mtime }) => (mtime === undefined ? [] : [[lane, mtime] as const]))
535
615
  return {
@@ -538,6 +618,20 @@ const stoppedLaneSections = (
538
618
  }
539
619
  }
540
620
 
621
+ /** The duty line while the main checkout lags behind `origin/<base>`, read from the local refs; null when caught up. */
622
+ const mainCheckoutLineOf = (watch: Watch): string | null =>
623
+ mainCheckoutDutyLine(readMainCheckout(watch.root, watch.settings.baseBranch), watch.settings.baseBranch, watch.texts)
624
+
625
+ /** `mainCheckoutLineOf` once a fetch of the base under way has ended when `events` hold a merge, so the merge already counts. */
626
+ const mainCheckoutLineAfter = async (
627
+ watch: Watch,
628
+ watching: Watching,
629
+ events: readonly WatchEvent[],
630
+ ): Promise<string | null> => {
631
+ if (events.some((event) => event.kind === WATCH_EVENT.merged)) await watching.baseFetch
632
+ return mainCheckoutLineOf(watch)
633
+ }
634
+
541
635
  /**
542
636
  * Every line of one return, gathered before any is printed, so the output is never cut halfway by a slow lookup, and
543
637
  * the state with what it showed: the report times of the stopped lanes and the status lines. A state without status
@@ -552,21 +646,26 @@ const reportOf = async (
552
646
  ): Promise<{ lines: string[]; next: WatchState }> => {
553
647
  const stops = stoppedLaneSections(watch, watching, stopped, next)
554
648
  const eventLines = await eventLinesOf(watch, events, watching.github)
649
+ const mainCheckout = await mainCheckoutLineAfter(watch, watching, events)
555
650
  const dutyLines = [
556
651
  ...blockedDutyLines(stopped, watching.current, watch.texts),
557
652
  ...mergeDutyLines(events, watching.lanes, watch.texts),
653
+ ...(mainCheckout === null ? [] : [mainCheckout]),
558
654
  ...laneFillDutyLines(events, watch.texts),
559
655
  ...laneModelDutyLines(events, watch.texts),
560
656
  ...parallelDutyLines(events, watch.texts),
657
+ ...noTaskSignalDutyLines(events, watch.texts),
561
658
  ...approvalDutyLines(events, watch.texts),
562
659
  ]
563
660
  const status = await statusLinesOf(watch, watching, next)
564
661
  const block = statusBlock(status.laneLines, status.coordinator, next.statusLines, status.others)
662
+ const shownReports = stopped.filter((lane) => stops.reportMtimes[lane] !== next.reportMtimes[lane])
565
663
  return {
566
664
  lines: [...stops.lines, ...eventLines, ...dutyLines, ...block, ...status.rest],
567
665
  next: {
568
666
  ...next,
569
667
  reportMtimes: stops.reportMtimes,
668
+ reportQuestions: reportQuestionsAfter(watching, next.reportQuestions, shownReports),
570
669
  statusLines: rememberedStatusLines(status.laneLines, status.others),
571
670
  },
572
671
  }
@@ -739,12 +838,14 @@ const recordPreviewRead = (watching: Watching, read: PreviewRead | null): WatchE
739
838
  }
740
839
 
741
840
  /**
742
- * Runs the sources whose interval has passed, all at once, each without a shell and within `SOURCE_LIMITS`; a failed
743
- * run is a result, never a throw, so no source stops the watcher.
841
+ * Runs the sources whose interval has passed since the start the saved state holds (`ranAt`, so a restart runs none
842
+ * early), all at once, each without a shell and within `SOURCE_LIMITS`; a failed run is a result, never a throw, so no
843
+ * source stops the watcher. The run's start is recorded with the GitHub read's time, which is `now`.
744
844
  */
745
845
  const runDueSources = (watch: Watch, watching: Watching, now: number): Promise<SourceRun[]> => {
746
- const due = watch.sources.filter((source) => isSourceDue(watching.sourceRanAt[source.name], source.every, now))
747
- for (const source of due) watching.sourceRanAt[source.name] = now
846
+ const due = watch.sources.filter((source) =>
847
+ isSourceDue(watching.state.sources[source.name]?.ranAt, source.every, now),
848
+ )
748
849
  return Promise.all(due.map((source) => runSource(source, watch.root, SOURCE_LIMITS)))
749
850
  }
750
851
 
@@ -755,10 +856,18 @@ const recordSourceRuns = (watch: Watch, watching: Watching, runs: SourceRun[]):
755
856
 
756
857
  /** Starts a fetch of the base branch in the background when none runs; the loop does not wait for it. */
757
858
  const syncBase = (watch: Watch, watching: Watching) => {
758
- if (watching.fetchingBase) return
759
- watching.fetchingBase = true
760
- void fetchBaseWhenMoved(watch.root, watch.settings.baseBranch).finally(() => {
761
- watching.fetchingBase = false
859
+ if (watching.baseFetch) return
860
+ watching.baseFetch = fetchBaseWhenMoved(watch.root, watch.settings.baseBranch).finally(() => {
861
+ watching.baseFetch = null
862
+ })
863
+ }
864
+
865
+ /** Starts a fetch of the moved plan pull request heads in the background when none runs, like `syncBase`. */
866
+ const syncPlanHeads = (watch: Watch, watching: Watching, pullRequests: readonly WatchedPullRequest[]) => {
867
+ if (watching.fetchingHeads) return
868
+ watching.fetchingHeads = true
869
+ void fetchPlanHeadsWhenMoved(watch.root, watchedPlanPullRequests(pullRequests, watch.settings)).finally(() => {
870
+ watching.fetchingHeads = false
762
871
  })
763
872
  }
764
873
 
@@ -777,6 +886,7 @@ const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: n
777
886
  runDueSources(watch, watching, now),
778
887
  ])
779
888
  const pullRequests = answered(github)?.pullRequests ?? null
889
+ syncPlanHeads(watch, watching, pullRequests ?? [])
780
890
  const branches = { ...watching.state.laneBranches, ...laneBranches }
781
891
  const failureReadings = await readFailures(watch, watching, github, branches)
782
892
  const targets = feedbackTargets(pullRequests, branches, watching.state.feedback)
@@ -813,6 +923,13 @@ const readAppointments = (watch: Watch, now: number): Watching['appointments'] =
813
923
  return { table: readAppointmentTable(board, watch.settings.planLanguage, new Date(now)), readAt: now }
814
924
  }
815
925
 
926
+ /** The line of what waits on the user's answer, from the board's own section; null when nothing is open or the board cannot be read. */
927
+ const waitingLineOf = (watch: Watch): string | null => {
928
+ return waitingLine(
929
+ waitingItemsOf(readBoardText(watch), watch.settings.planLanguage, readWaitingRecords(watch.root), Date.now()),
930
+ )
931
+ }
932
+
816
933
  /** The board's lanes, null when the board cannot be read. */
817
934
  const readSprintLanes = (watch: Watch): SprintLane[] | null => {
818
935
  const board = readBoardText(watch)
@@ -891,23 +1008,169 @@ const report = async (
891
1008
  console.log(
892
1009
  (watch.options.stream ? lines : withSingleReportNotice(lines, monitorToolAvailable(process.env))).join('\n'),
893
1010
  )
894
- writePrivate(watch.files.state, JSON.stringify(shown))
1011
+ writePrivateFile(watch.files.state, JSON.stringify(shown))
895
1012
  watching.reporting = false
896
1013
  return shown
897
1014
  }
898
1015
 
899
1016
  /**
900
- * The heartbeat of a stream: one line, a working lane with its phase count when that moved since the last heartbeat.
901
- * The plans are read only when a lane works; a board that cannot be read leaves the lines without a count, and the
902
- * next report names the failure.
1017
+ * The parallel-task proposals of this heartbeat, one event per open lane whose situation changed since the last
1018
+ * heartbeat and that has a proposal, marked by `autoVerdict` against the switch, and the fingerprints of every lane
1019
+ * read, kept for the next one. Nothing when herdr or the board cannot be read: a proposal is a hint, and the next
1020
+ * heartbeat reads again.
1021
+ */
1022
+ const taskParallelProposalsOf = (
1023
+ watch: Watch,
1024
+ watching: Watching,
1025
+ now: number,
1026
+ ): { events: TaskParallelEvent[]; fingerprints: TaskParallelFingerprints } => {
1027
+ try {
1028
+ const lanes = (readSprintLanes(watch) ?? []).filter((lane) => !lane.closed && watching.lanes.includes(lane.name))
1029
+ const inputs = readTaskParallelInputs(lanes, herdrLaneStatusSource.listAgents(), {
1030
+ root: watch.root,
1031
+ settings: watch.settings,
1032
+ boardFile: watch.sprintFile,
1033
+ thresholdTokens: watch.parallelTasks.maxTokens,
1034
+ now,
1035
+ })
1036
+ const reports = Object.entries(inputs).map(
1037
+ ([lane, input]) => [lane, input, taskParallelReport(watching.taskParallels[lane], input)] as const,
1038
+ )
1039
+ const today = localDay(now)
1040
+ return {
1041
+ events: reports.flatMap(([lane, input, { proposal }]) =>
1042
+ proposal
1043
+ ? [
1044
+ {
1045
+ kind: TASK_PARALLEL_EVENT,
1046
+ lane,
1047
+ line: formatTaskParallelLine(lane, proposal, autoVerdict(input, proposal, watch.parallelAuto, today)),
1048
+ },
1049
+ ]
1050
+ : [],
1051
+ ),
1052
+ fingerprints: Object.fromEntries(reports.map(([lane, , { fingerprint }]) => [lane, fingerprint])),
1053
+ }
1054
+ } catch {
1055
+ return { events: [], fingerprints: {} }
1056
+ }
1057
+ }
1058
+
1059
+ /**
1060
+ * This heartbeat's parallel-task events: the line that `watch.parallelTasks.auto` ended, once per last day, then the
1061
+ * proposals. Records the fingerprints and the reported end in `watching`.
1062
+ */
1063
+ const taskParallelEventsOf = (watch: Watch, watching: Watching, now: number): TaskParallelEvent[] => {
1064
+ const { events, fingerprints } = taskParallelProposalsOf(watch, watching, now)
1065
+ const { ended, line } = autoEndedReport(watching.parallelAutoEnded, watch.parallelAuto.auto, localDay(now))
1066
+ watching.taskParallels = { ...watching.taskParallels, ...fingerprints }
1067
+ watching.parallelAutoEnded = ended
1068
+ return line === null ? events : [{ kind: TASK_PARALLEL_EVENT, lane: null, line }, ...events]
1069
+ }
1070
+
1071
+ const workingLanes = (watching: Watching): string[] =>
1072
+ watching.lanes.filter((lane) => watching.current[lane]?.status === AGENT_STATUS.working)
1073
+
1074
+ /**
1075
+ * This heartbeat's `no task signal` events (D112): each watched lane read as working or not, with an open task signal
1076
+ * or not, and its current phase from `progress`; records the clocks in `watching`. Nothing when herdr or the logs
1077
+ * cannot be read: the event is a reminder, and the next heartbeat reads again.
1078
+ */
1079
+ const noTaskSignalEventsOf = (
1080
+ watch: Watch,
1081
+ watching: Watching,
1082
+ progress: Readonly<Record<string, LaneProgress>>,
1083
+ now: number,
1084
+ ): NoTaskSignalEvent[] => {
1085
+ try {
1086
+ const working = new Set(workingLanes(watching))
1087
+ const signalled = signalledLanes(watching.lanes, {
1088
+ root: watch.root,
1089
+ worktreeDirectory: watch.settings.worktreeDirectory,
1090
+ now,
1091
+ })
1092
+ const counts = currentPhaseCountsOf(progress)
1093
+ const readings = Object.fromEntries(
1094
+ watching.lanes.map((lane) => [
1095
+ lane,
1096
+ { working: working.has(lane), signalled: signalled.has(lane), phaseId: counts[lane]?.id ?? null },
1097
+ ]),
1098
+ )
1099
+ const { events, state } = noTaskSignalEvents(watching.noTaskSignals, readings, now, watch.taskSignalMinutes)
1100
+ watching.noTaskSignals = state
1101
+ return events
1102
+ } catch {
1103
+ return []
1104
+ }
1105
+ }
1106
+
1107
+ /** The progress of the working lanes, with the pull requests of the last GitHub read; nothing while no lane works. */
1108
+ const workingProgressOf = async (watch: Watch, watching: Watching): Promise<Record<string, LaneProgress>> => {
1109
+ const working = workingLanes(watching)
1110
+ if (working.length === 0) return {}
1111
+ const { progress } = await boardReadOrNote(watch, working, answered(watching.github)?.pullRequests ?? [])
1112
+ return progress
1113
+ }
1114
+
1115
+ /** The events a heartbeat of a report brings: the parallel-task proposals, then the lanes without a task signal. */
1116
+ const heartbeatEventsOf = async (watch: Watch, watching: Watching, now: number): Promise<WatchEvent[]> => {
1117
+ const progress = await workingProgressOf(watch, watching)
1118
+ return [...taskParallelEventsOf(watch, watching, now), ...noTaskSignalEventsOf(watch, watching, progress, now)]
1119
+ }
1120
+
1121
+ /**
1122
+ * The subagents running in each worktree, from the chain the herd reads for its own view (`readRunningSubagents`). Nothing
1123
+ * when the logs cannot be read: a note is a hint, and the next heartbeat reads again.
1124
+ */
1125
+ const subagentCountsOf = (watch: Watch, now: number): Record<string, number> => {
1126
+ try {
1127
+ const worktreeDirectory = join(watch.root, watch.settings.worktreeDirectory)
1128
+ const running = readRunningSubagents(
1129
+ subagentSourceOf(watch.root, worktreeDirectory, watch.settings.skillPrefix),
1130
+ now,
1131
+ )
1132
+ return runningSubagentCountsOf(running, worktreeDirectory)
1133
+ } catch {
1134
+ return {}
1135
+ }
1136
+ }
1137
+
1138
+ /**
1139
+ * The heartbeat of a stream: one line, a working lane with its phase count at every heartbeat and the subagents running
1140
+ * in its worktree. The plans are read only when a lane works; a board that cannot be read leaves the lines without a
1141
+ * count, and the next report names the failure.
903
1142
  */
904
1143
  const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: number): Promise<void> => {
905
- const working = watching.lanes.filter((lane) => watching.current[lane]?.status === AGENT_STATUS.working)
906
- const { progress } = working.length > 0 ? await boardReadOrNote(watch, working) : { progress: {} }
1144
+ const working = workingLanes(watching)
1145
+ const progress = await workingProgressOf(watch, watching)
907
1146
  const counts = currentPhaseCountsOf(progress)
908
- const notes = progressNotes(watching.lanes, watching.current, counts, watching.heartbeatCounts)
909
- watching.heartbeatCounts = { ...watching.heartbeatCounts, ...counts }
910
- console.log(streamHeartbeatLine(clock(now), watching.lanes, watching.current, notes))
1147
+ const notes = heartbeatNotesOf(working, counts, working.length > 0 ? subagentCountsOf(watch, now) : {})
1148
+ const rests = Object.fromEntries(
1149
+ watching.lanes.flatMap((lane) => {
1150
+ const rest = restNoteOf(watch, lane, watching.current[lane]?.status)
1151
+ return rest === null ? [] : [[lane, rest]]
1152
+ }),
1153
+ )
1154
+ const standing = [waitingLineOf(watch), mainCheckoutLineOf(watch)].filter((line) => line !== null)
1155
+ console.log(streamHeartbeatLine(clock(now), watching.lanes, watching.current, notes, rests, standing))
1156
+ const events = [
1157
+ ...taskParallelEventsOf(watch, watching, now),
1158
+ ...noTaskSignalEventsOf(watch, watching, progress, now),
1159
+ ]
1160
+ for (const event of events) console.log(`${watchEventLine(event, null)}${hintFor(watch, eventReference(event))}`)
1161
+ }
1162
+
1163
+ /**
1164
+ * The lanes to report as stopped: those that stopped or left since the last poll, and the working lanes whose report
1165
+ * asks something new (`askingLanes`): a lane waiting on its background subagents ends no turn. Takes the departed
1166
+ * lanes and records the questions read.
1167
+ */
1168
+ const takeStoppedLanes = (watch: Watch, watching: Watching): string[] => {
1169
+ const fired = [...firedLanes(watching.baseline, watching.current, watching.lanes), ...watching.departed]
1170
+ watching.departed = []
1171
+ watching.reportQuestions = readReportQuestions(watch, watching.lanes)
1172
+ const working = workingLanes(watching).filter((lane) => !fired.includes(lane))
1173
+ return [...fired, ...askingLanes(working, watching.reportQuestions, watching.state.reportQuestions)]
911
1174
  }
912
1175
 
913
1176
  /**
@@ -932,8 +1195,7 @@ const pollOnce = async (watch: Watch, watching: Watching): Promise<boolean> => {
932
1195
  ...approvalEvents(watching.state.approvals, watching.approvalRequests, now),
933
1196
  ...appointmentEvents(watching.state.appointments, watching.appointments?.table ?? null, now),
934
1197
  ]
935
- const stopped = [...firedLanes(watching.baseline, watching.current, watching.lanes), ...watching.departed]
936
- watching.departed = []
1198
+ const stopped = takeStoppedLanes(watch, watching)
937
1199
  const heartbeat = isHeartbeatDue(watching.quietSince, now, heartbeatMinutes * MINUTE_MS)
938
1200
  let next = recordedOf(watch, watching)
939
1201
  const reported = stopped.length > 0 || polledEvents.length > 0
@@ -941,10 +1203,17 @@ const pollOnce = async (watch: Watch, watching: Watching): Promise<boolean> => {
941
1203
  next = await report(watch, watching, stopped, polledEvents, next)
942
1204
  watching.baseline = { ...watching.current }
943
1205
  } else if (heartbeat && !stream) {
944
- next = await report(watch, watching, [], [{ kind: WATCH_EVENT.heartbeat }], { ...next, statusLines: null })
1206
+ const proposals = await heartbeatEventsOf(watch, watching, now)
1207
+ next = await report(watch, watching, [], [{ kind: WATCH_EVENT.heartbeat }, ...proposals], {
1208
+ ...recordedOf(watch, watching),
1209
+ statusLines: null,
1210
+ })
945
1211
  } else {
946
- if (heartbeat) await printStreamHeartbeat(watch, watching, now)
947
- if (due) writePrivate(watch.files.state, JSON.stringify(next))
1212
+ if (heartbeat) {
1213
+ await printStreamHeartbeat(watch, watching, now)
1214
+ next = recordedOf(watch, watching)
1215
+ }
1216
+ if (due) writePrivateFile(watch.files.state, JSON.stringify(next))
948
1217
  watching.baseline = advanceBaseline(watching.baseline, watching.current)
949
1218
  }
950
1219
  watching.state = next
@@ -968,30 +1237,33 @@ const initialWatching = (
968
1237
  agents: start.agents,
969
1238
  github: null,
970
1239
  laneBranches: {},
971
- heartbeatCounts: {},
972
1240
  githubReadAt: 0,
973
1241
  coordinator: { name: watch.options.coordinator, read: null },
974
1242
  herdrDown: false,
975
1243
  reporting: false,
976
- fetchingBase: false,
1244
+ baseFetch: null,
1245
+ fetchingHeads: false,
977
1246
  ownLogin: null,
978
1247
  feedbackRead: null,
979
1248
  failureReadings: [],
980
1249
  previewRead: null,
981
1250
  sourceRuns: [],
982
- sourceRanAt: {},
983
1251
  laneReadings: {},
984
1252
  laneModelReadings: {},
985
1253
  laneParallelReadings: {},
1254
+ taskParallels: start.state.taskParallels,
1255
+ parallelAutoEnded: start.state.parallelAutoEnded,
1256
+ noTaskSignals: start.state.noTaskSignals,
986
1257
  approvalRequests: null,
987
1258
  appointments: null,
1259
+ reportQuestions: null,
988
1260
  planViews: { failureReported: false },
989
1261
  })
990
1262
 
991
1263
  /** Saves the state a stop leaves behind: the one from before an unfinished report or an unread GitHub answer, else the latest. */
992
1264
  const saveState = (watch: Watch, watching: Watching) => {
993
1265
  const unread = watching.reporting || watching.github === null
994
- writePrivate(watch.files.state, JSON.stringify(unread ? watching.state : recordedOf(watch, watching)))
1266
+ writePrivateFile(watch.files.state, JSON.stringify(unread ? watching.state : recordedOf(watch, watching)))
995
1267
  }
996
1268
 
997
1269
  const watchLanes = async (watch: Watch) => {
@@ -1013,14 +1285,25 @@ const watchLanes = async (watch: Watch) => {
1013
1285
  })
1014
1286
 
1015
1287
  const kit = await startedKit()
1016
- const skillSnapshot = kit ? readOrchestrateSkill(kit.packageRoot) : null
1288
+ const skillSnapshot = kit ? readOrchestrateSnapshot(kit.packageRoot) : null
1017
1289
  const skillLine = () => skillLineFor(kit, skillSnapshot, dirname(watch.files.state))
1018
- const announceUpdate = kit ? updateAnnouncer(homedir(), kit.version, defaultUpdateCheck(), console.log) : null
1019
- while (await pollOnce(watch, watching)) {
1020
- await Bun.sleep(POLL_MS)
1021
- pollLanes(watch, watching)
1022
- await announceUpdate?.()
1023
- if (endedByKitUpdate(kit, { saveState: () => saveState(watch, watching), log: console.log, skillLine })) return
1290
+ const announceUpdate = kit
1291
+ ? updateAnnouncer(homedir(), kit.version, defaultUpdateCheck(), console.log, () =>
1292
+ hintFor(watch, eventReference({ kind: UPDATE_AVAILABLE_EVENT })),
1293
+ )
1294
+ : null
1295
+ const sourceChanges = kit && runsFromSource(kit.packageRoot) ? watchSourceChanges(kit.packageRoot) : null
1296
+ const sourceChanged = () => sourceChangeSettled(sourceChanges?.lastChangeAt() ?? null, Date.now())
1297
+ const effects = { saveState: () => saveState(watch, watching), log: console.log, skillLine, sourceChanged }
1298
+ try {
1299
+ while (await pollOnce(watch, watching)) {
1300
+ await Bun.sleep(POLL_MS)
1301
+ pollLanes(watch, watching)
1302
+ await announceUpdate?.()
1303
+ if (endedByKitUpdate(kit, effects)) return
1304
+ }
1305
+ } finally {
1306
+ sourceChanges?.close()
1024
1307
  }
1025
1308
  }
1026
1309
 
@@ -1029,10 +1312,34 @@ const filesOf = (root: string): Files => {
1029
1312
  return { state: join(directory, WATCHER_FILES.state), lock: join(directory, WATCHER_FILES.lock) }
1030
1313
  }
1031
1314
 
1315
+ /**
1316
+ * The switch and the paths that always ask from the main checkout's config only, so a lane's worktree can neither switch
1317
+ * `auto` on, shorten the list nor raise the cost limit; off and the kit's paths when that config cannot be read.
1318
+ */
1319
+ const autoSettingsOf = (root: string, checkoutRoot: string, config: JackherdConfig) => {
1320
+ try {
1321
+ const main = checkoutRoot === root ? config : readConfig([root])
1322
+ return {
1323
+ auto: main.watch.parallelTasks.auto,
1324
+ askPaths: [...AUTO_ASK_PATHS, ...main.guard.alwaysAsk],
1325
+ maxTokens: main.watch.parallelTasks.maxTokens,
1326
+ holdsDataPath: holdsDataPathIn(root),
1327
+ }
1328
+ } catch {
1329
+ return {
1330
+ auto: undefined,
1331
+ askPaths: AUTO_ASK_PATHS,
1332
+ maxTokens: config.watch.parallelTasks.maxTokens,
1333
+ holdsDataPath: holdsDataPathIn(root),
1334
+ }
1335
+ }
1336
+ }
1337
+
1032
1338
  /** The watch as the command line and the config set it up; throws when the config or the sprint is missing. */
1033
1339
  const watchOf = (options: WatchOptions): Watch => {
1034
1340
  const root = mainCheckout(process.cwd())
1035
- const { config, notes } = readResolvedConfig([worktreeRoot(process.cwd()), root])
1341
+ const checkoutRoot = worktreeRoot(process.cwd())
1342
+ const { config, notes } = readResolvedConfig([checkoutRoot, root])
1036
1343
  for (const note of notes) console.log(note)
1037
1344
  const settings = plansSettingsOf(config)
1038
1345
  const picked = boardFor(options.boardPath, [root, worktreeRoot(process.cwd())], settings, () =>
@@ -1048,9 +1355,13 @@ const watchOf = (options: WatchOptions): Watch => {
1048
1355
  deploy: config.watch.deploy,
1049
1356
  feedback: config.watch.feedback,
1050
1357
  checks: config.watch.checks,
1358
+ parallelTasks: config.watch.parallelTasks,
1359
+ taskSignalMinutes: config.watch.taskSignalMinutes,
1360
+ parallelAuto: autoSettingsOf(root, checkoutRoot, config),
1051
1361
  previews: config.watch.previews,
1052
1362
  sources: config.watch.sources,
1053
1363
  texts: watchTexts(settings.responseLanguage),
1364
+ skillCopies: skillCopiesFor(root, homedir(), config.skillPrefix, DEFAULT_SKILL_PREFIX),
1054
1365
  herdr: insideHerdr(settings.adapter, process.env),
1055
1366
  }
1056
1367
  }