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
@@ -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'
@@ -37,17 +40,18 @@ import { contextReport } from '../context/report'
37
40
  import { agentLog } from '../context/session-log'
38
41
  import { type LaneProgress, parseSprintLanes, type SprintLane } from '../plan-format'
39
42
  import { terminalSafe } from '../plan-format/terminal-text'
40
- import { openWaitingItems } from '../plan-format/waiting'
41
43
  import { boardFor, foundLanesOf } from '../plans/board-pick'
42
44
  import { ensurePlanPanes } from '../plans/ensure-panes'
43
45
  import { errorText } from '../plans/error-text'
44
- import { mainCheckout, worktreeNames, worktreeRoot } from '../plans/git'
46
+ import { baseReaderOf, mainCheckout, worktreeNames, worktreeRoot } from '../plans/git'
45
47
  import { discoverLanes, isUnchanged, laneSetChange, laneSetLines } from '../plans/lane-discovery'
46
- import { sentWaitingItems } from '../plans/sent-files'
47
- import { readSentRecords } from '../plans/sent-files-read'
48
+ import { readerOncePerRef } from '../plans/plan-heads'
48
49
  import { CLOCK_LOCALE, type PlansSettings, plansSettingsOf, worktreeOf } from '../plans/settings'
50
+ import { readRunningSubagents, subagentSourceOf } from '../plans/subagents-read'
49
51
  import { boardTexts } from '../plans/texts'
52
+ import { readWaitingRecords, waitingItemsOf } from '../plans/waiting-records'
50
53
  import { defaultUpdateCheck } from '../sync/if-changed'
54
+ import { DEFAULT_SKILL_PREFIX } from '../sync/init'
51
55
  import { herdrLaneStatusSource } from '../terminal/herdr'
52
56
  import { AGENT_STATUS } from '../terminal/types'
53
57
  import { startedKit } from '../version/started-kit'
@@ -59,6 +63,7 @@ import {
59
63
  recordAppointments,
60
64
  } from './appointments'
61
65
  import { type ApprovalRequestFile, approvalEvents, readApprovalRequests, recordApprovals } from './approvals'
66
+ import { askingLanes, questionsFingerprint, recordReportQuestions } from './asking-lanes'
62
67
  import {
63
68
  type CoordinatorRead,
64
69
  contextReadingOf,
@@ -82,14 +87,14 @@ import {
82
87
  import { type FeedbackItem } from './feedback-item'
83
88
  import { type FeedbackItemsRead, readCommentItems, readFeedbackItems, readOwnLogin } from './feedback-read'
84
89
  import { answered, deployTargetOf, type GithubRead, readGithub, targetText } from './github-read'
85
- import { currentPhaseCountsOf, type PhaseCount, progressNotes } from './heartbeat-progress'
90
+ import { currentPhaseCountsOf } from './heartbeat-progress'
86
91
  import { agentScreen, listAgents } from './herdr'
87
92
  import { endedByKitUpdate } from './kit-update'
88
93
  import { type LaneReadings, laneFillEvents, recordLaneLevels } from './lane-fill'
89
94
  import { laneMessage, rememberedStatusLines, statusBlock } from './lane-message'
90
95
  import { type LaneModelReading, laneModelEvents, recordLaneModels } from './lane-model'
91
96
  import { laneParallelEvents, recordLaneParallels } from './lane-parallel'
92
- import { boardCheckoutRows, watchedLaneParallel, watchedLaneProgress } from './lane-progress'
97
+ import { boardCheckoutRows, watchedLaneParallel, watchedLaneProgress, watchedPlanPullRequests } from './lane-progress'
93
98
  import {
94
99
  appointmentEventLine,
95
100
  approvalDutyLines,
@@ -102,16 +107,18 @@ import {
102
107
  laneFillDutyLines,
103
108
  laneModelDutyLines,
104
109
  laneReportLine,
110
+ laneStopHeader,
105
111
  mergeDutyLines,
112
+ noTaskSignalDutyLines,
106
113
  otherPullRequestsLine,
107
114
  parallelDutyLines,
108
115
  sourceEventLines,
109
116
  sourceFailingStatusLine,
110
117
  watchEventLine,
111
118
  } from './lane-report'
112
- import { laneReportPath, readLaneReport, reportMtime } from './lane-report-file'
119
+ import { hasLaneHandover, laneReportPath, readLaneReport, reportMtime } from './lane-report-file'
113
120
  import { restNote } from './lane-rest'
114
- import { branchOf, fetchBaseWhenMoved } from './lane-sources'
121
+ import { branchOf, fetchBaseWhenMoved, fetchPlanHeadsWhenMoved, readMainCheckout } from './lane-sources'
115
122
  import {
116
123
  advanceBaseline,
117
124
  armBaseline,
@@ -124,6 +131,7 @@ import {
124
131
  NO_AGENT,
125
132
  toSnapshot,
126
133
  } from './lane-watch'
134
+ import { mainCheckoutDutyLine } from './main-checkout'
127
135
  import { monitorToolAvailable } from './monitor-tool'
128
136
  import { parseWatchOptions, type WatchOptions } from './options'
129
137
  import {
@@ -145,8 +153,20 @@ import {
145
153
  } from './previews'
146
154
  import { latestPullRequestOf, type WatchedPullRequest } from './pull-requests'
147
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'
148
167
  import { withSingleReportNotice } from './single-report'
149
- import { readOrchestrateSkill, skillLineFor } from './skill-change'
168
+ import { readOrchestrateSnapshot, skillLineFor } from './skill-change'
169
+ import { runsFromSource, sourceChangeSettled, watchSourceChanges } from './source-change'
150
170
  import { sourceDryRun } from './source-dry-run-command'
151
171
  import { runSource, SOURCE_LIMITS } from './source-run'
152
172
  import { recordWatchedSources } from './source-tickets'
@@ -159,7 +179,11 @@ import {
159
179
  type TaskParallelFingerprints,
160
180
  taskParallelReport,
161
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'
162
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'
163
187
  import { type WatchTexts, watchTexts } from './texts'
164
188
  import { updateAnnouncer } from './update-available'
165
189
  import { waitingLine } from './waiting-line'
@@ -176,20 +200,14 @@ import {
176
200
  pullRequestEvents,
177
201
  recordCoordinatorLevel,
178
202
  recordWatchState,
203
+ UPDATE_AVAILABLE_EVENT,
179
204
  WATCH_EVENT,
180
205
  type WatchEvent,
181
206
  type WatchState,
182
207
  withReportBaselines,
183
208
  } from './watch-events'
184
- import {
185
- ensureWatcherDirectory,
186
- releaseLock,
187
- stopNoticeOf,
188
- takeOverLock,
189
- WATCHER_FILES,
190
- writePrivate,
191
- } from './watcher-lock'
192
- 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'
193
211
 
194
212
  type Files = { state: string; lock: string }
195
213
 
@@ -218,9 +236,15 @@ type Watch = {
218
236
  feedback: FeedbackConfig
219
237
  checks: ChecksConfig
220
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
221
243
  previews: PreviewsConfig
222
244
  sources: SourceSettings[]
223
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[]
224
248
  /** Runs inside herdr with the herdr adapter, so missing herd panes can be brought back. */
225
249
  herdr: boolean
226
250
  }
@@ -245,8 +269,6 @@ type Watching = {
245
269
  state: WatchState
246
270
  baseline: LaneSnapshot
247
271
  current: LaneSnapshot
248
- /** The phase count of each lane at the last heartbeat, so a heartbeat names only a count that moved. */
249
- heartbeatCounts: Record<string, PhaseCount>
250
272
  /** The last `herdr agent list` answer, kept for finding the coordinator by its pane. */
251
273
  agents: HerdrAgent[]
252
274
  github: GithubRead | null
@@ -255,8 +277,10 @@ type Watching = {
255
277
  coordinator: Coordinator
256
278
  herdrDown: boolean
257
279
  reporting: boolean
258
- /** A fetch of the base branch is under way; the next one waits for it. */
259
- 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
260
284
  /** The account `gh` runs as, read once it answers; feedback is read only once it is known. */
261
285
  ownLogin: string | null
262
286
  /** The last feedback read that answered; its targets and items are recorded with every state. */
@@ -267,8 +291,6 @@ type Watching = {
267
291
  previewRead: PreviewRead | null
268
292
  /** The runs of the sources due at the last GitHub poll; recorded with every state like the feedback read. */
269
293
  sourceRuns: SourceRun[]
270
- /** Source name → when it was last started in this process; a source without an entry runs at the next GitHub poll. */
271
- sourceRanAt: Record<string, number>
272
294
  /** The lanes' fills of the last read; a lane without an entry or with null has no value. */
273
295
  laneReadings: LaneReadings
274
296
  /** The lanes' models against their board rows at the last read; recorded with every state like the fills. */
@@ -277,10 +299,16 @@ type Watching = {
277
299
  laneParallelReadings: Record<string, string[]>
278
300
  /** The parallel-task fingerprint of each lane at the last heartbeat; persisted like the lane parallels. */
279
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
280
306
  /** The approval requests of the last poll; null before the first read, so the stored ids stay. */
281
307
  approvalRequests: ApprovalRequestFile[] | null
282
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. */
283
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
284
312
  planViews: PlanViews
285
313
  }
286
314
 
@@ -299,6 +327,14 @@ const observationOf = (watching: Watching): Observation => ({
299
327
  github: answered(watching.github),
300
328
  })
301
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
+
302
338
  /** Stamped with the time of the last GitHub read, not of the poll: a run created after it is still unseen. */
303
339
  const recordedOf = (watch: Watch, watching: Watching): WatchState => {
304
340
  const recorded = recordCoordinatorLevel(
@@ -317,11 +353,14 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
317
353
  laneModels: recordLaneModels(withReads.laneModels, watching.laneModelReadings, watching.lanes),
318
354
  laneParallels: recordLaneParallels(withReads.laneParallels, watching.laneParallelReadings, watching.lanes),
319
355
  taskParallels: watching.taskParallels,
356
+ parallelAutoEnded: watching.parallelAutoEnded,
357
+ noTaskSignals: watching.noTaskSignals,
320
358
  approvals: watching.approvalRequests ? recordApprovals(watching.approvalRequests) : withReads.approvals,
321
359
  sources: recordWatchedSources(withReads.sources, watching.sourceRuns, watch.sources, watching.githubReadAt),
322
360
  appointments: watching.appointments
323
361
  ? recordAppointments(withReads.appointments, watching.appointments.table, watching.appointments.readAt)
324
362
  : withReads.appointments,
363
+ reportQuestions: reportQuestionsAfter(watching, withReads.reportQuestions, []),
325
364
  }
326
365
  return withReportBaselines(withLevels, watching.lanes, (lane) => reportMtime(reportPathOf(watch, lane)))
327
366
  }
@@ -364,6 +403,12 @@ const restNoteOf = (watch: Watch, lane: string, status: string | undefined): str
364
403
  ? restNote(readLaneReport(reportPathOf(watch, lane))?.text ?? null)
365
404
  : null
366
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
+
367
412
  /** The lines under a stopped lane's header and its report time to remember (`laneMessage`). */
368
413
  const stopMessageOf = (watch: Watch, lane: string, status: string, state: WatchState) =>
369
414
  laneMessage({
@@ -425,6 +470,24 @@ const laneBranchesOf = (watch: Watch, lanes: readonly string[]): Record<string,
425
470
  }),
426
471
  )
427
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
+
428
491
  const eventLinesOf = async (
429
492
  watch: Watch,
430
493
  events: readonly WatchEvent[],
@@ -432,26 +495,7 @@ const eventLinesOf = async (
432
495
  ): Promise<string[]> => {
433
496
  const runs = answered(github)?.runs ?? []
434
497
  const lines: string[] = []
435
- for (const event of events) {
436
- if (event.kind === WATCH_EVENT.feedback) {
437
- lines.push(...feedbackEventLines(event))
438
- continue
439
- }
440
- if (event.kind === WATCH_EVENT.source) {
441
- lines.push(...sourceEventLines(event))
442
- continue
443
- }
444
- if (isAppointmentEvent(event)) {
445
- lines.push(appointmentEventLine(event))
446
- continue
447
- }
448
- const headSha = event.kind === WATCH_EVENT.deploy ? runs.find((run) => run.id === event.runId)?.headSha : undefined
449
- const target =
450
- event.kind === WATCH_EVENT.deploy && headSha && watch.deploy
451
- ? await deployTargetOf(watch.root, watch.deploy, event.runId, headSha)
452
- : null
453
- lines.push(watchEventLine(event, target ? targetText(target) : null))
454
- }
498
+ for (const event of events) lines.push(...(await linesOfEvent(watch, event, runs)))
455
499
  return lines
456
500
  }
457
501
 
@@ -459,16 +503,16 @@ const eventLinesOf = async (
459
503
  const boardReadOf = async (
460
504
  watch: Watch,
461
505
  lanes: readonly string[],
506
+ pullRequests: readonly WatchedPullRequest[],
462
507
  ): Promise<{ progress: Record<string, LaneProgress>; checkoutRows: number }> => {
463
508
  const text = await Bun.file(watch.sprintFile).text()
464
509
  const sprintLanes = parseSprintLanes(text, watch.settings.planLanguage)
465
510
  const watched = sprintLanes.filter((lane) => lanes.includes(lane.name))
466
- const entries = await Promise.all(
467
- watched.map(
468
- async (lane) =>
469
- [lane.name, await watchedLaneProgress(watch.root, watch.settings, watch.sprintFile, lane)] as const,
470
- ),
471
- )
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))
472
516
  const checkoutRows = boardCheckoutRows(watch.root, watch.settings, watch.sprintFile, text)
473
517
  return { progress: Object.fromEntries(entries), checkoutRows }
474
518
  }
@@ -500,9 +544,10 @@ const laneReportOf = (
500
544
  const boardReadOrNote = async (
501
545
  watch: Watch,
502
546
  lanes: readonly string[],
547
+ pullRequests: readonly WatchedPullRequest[],
503
548
  ): Promise<{ progress: Record<string, LaneProgress>; checkoutRows: number; notes: string[] }> => {
504
549
  try {
505
- return { ...(await boardReadOf(watch, lanes)), notes: [] }
550
+ return { ...(await boardReadOf(watch, lanes, pullRequests)), notes: [] }
506
551
  } catch (error) {
507
552
  return {
508
553
  progress: {},
@@ -524,9 +569,14 @@ const statusLinesOf = async (
524
569
  const github = answered(watching.github)
525
570
  const pullRequests = github?.pullRequests ?? []
526
571
  const others = otherPullRequestsLine(pullRequests, github?.own ?? [], watch.texts)
527
- 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)
528
574
  const laneLines = watching.lanes.map((lane) =>
529
- laneReportLine(laneReportOf(watch, 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
+ ),
530
580
  )
531
581
  const outage = state.ghDownSince === null ? [] : [ghUnavailableStatusLine(clock(state.ghDownSince))]
532
582
  const waiting = waitingLineOf(watch)
@@ -556,7 +606,10 @@ const stoppedLaneSections = (
556
606
  const status = watching.current[lane]?.status
557
607
  if (!status) return { lane, lines: [`=== ${lane}: ${terminalSafe(LANE_GONE)}`], mtime: undefined }
558
608
  const message = stopMessageOf(watch, lane, status, next)
559
- 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 }
560
613
  })
561
614
  const shown = sections.flatMap(({ lane, mtime }) => (mtime === undefined ? [] : [[lane, mtime] as const]))
562
615
  return {
@@ -565,6 +618,20 @@ const stoppedLaneSections = (
565
618
  }
566
619
  }
567
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
+
568
635
  /**
569
636
  * Every line of one return, gathered before any is printed, so the output is never cut halfway by a slow lookup, and
570
637
  * the state with what it showed: the report times of the stopped lanes and the status lines. A state without status
@@ -579,21 +646,26 @@ const reportOf = async (
579
646
  ): Promise<{ lines: string[]; next: WatchState }> => {
580
647
  const stops = stoppedLaneSections(watch, watching, stopped, next)
581
648
  const eventLines = await eventLinesOf(watch, events, watching.github)
649
+ const mainCheckout = await mainCheckoutLineAfter(watch, watching, events)
582
650
  const dutyLines = [
583
651
  ...blockedDutyLines(stopped, watching.current, watch.texts),
584
652
  ...mergeDutyLines(events, watching.lanes, watch.texts),
653
+ ...(mainCheckout === null ? [] : [mainCheckout]),
585
654
  ...laneFillDutyLines(events, watch.texts),
586
655
  ...laneModelDutyLines(events, watch.texts),
587
656
  ...parallelDutyLines(events, watch.texts),
657
+ ...noTaskSignalDutyLines(events, watch.texts),
588
658
  ...approvalDutyLines(events, watch.texts),
589
659
  ]
590
660
  const status = await statusLinesOf(watch, watching, next)
591
661
  const block = statusBlock(status.laneLines, status.coordinator, next.statusLines, status.others)
662
+ const shownReports = stopped.filter((lane) => stops.reportMtimes[lane] !== next.reportMtimes[lane])
592
663
  return {
593
664
  lines: [...stops.lines, ...eventLines, ...dutyLines, ...block, ...status.rest],
594
665
  next: {
595
666
  ...next,
596
667
  reportMtimes: stops.reportMtimes,
668
+ reportQuestions: reportQuestionsAfter(watching, next.reportQuestions, shownReports),
597
669
  statusLines: rememberedStatusLines(status.laneLines, status.others),
598
670
  },
599
671
  }
@@ -766,12 +838,14 @@ const recordPreviewRead = (watching: Watching, read: PreviewRead | null): WatchE
766
838
  }
767
839
 
768
840
  /**
769
- * Runs the sources whose interval has passed, all at once, each without a shell and within `SOURCE_LIMITS`; a failed
770
- * 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`.
771
844
  */
772
845
  const runDueSources = (watch: Watch, watching: Watching, now: number): Promise<SourceRun[]> => {
773
- const due = watch.sources.filter((source) => isSourceDue(watching.sourceRanAt[source.name], source.every, now))
774
- 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
+ )
775
849
  return Promise.all(due.map((source) => runSource(source, watch.root, SOURCE_LIMITS)))
776
850
  }
777
851
 
@@ -782,10 +856,18 @@ const recordSourceRuns = (watch: Watch, watching: Watching, runs: SourceRun[]):
782
856
 
783
857
  /** Starts a fetch of the base branch in the background when none runs; the loop does not wait for it. */
784
858
  const syncBase = (watch: Watch, watching: Watching) => {
785
- if (watching.fetchingBase) return
786
- watching.fetchingBase = true
787
- void fetchBaseWhenMoved(watch.root, watch.settings.baseBranch).finally(() => {
788
- 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
789
871
  })
790
872
  }
791
873
 
@@ -804,6 +886,7 @@ const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: n
804
886
  runDueSources(watch, watching, now),
805
887
  ])
806
888
  const pullRequests = answered(github)?.pullRequests ?? null
889
+ syncPlanHeads(watch, watching, pullRequests ?? [])
807
890
  const branches = { ...watching.state.laneBranches, ...laneBranches }
808
891
  const failureReadings = await readFailures(watch, watching, github, branches)
809
892
  const targets = feedbackTargets(pullRequests, branches, watching.state.feedback)
@@ -842,9 +925,9 @@ const readAppointments = (watch: Watch, now: number): Watching['appointments'] =
842
925
 
843
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. */
844
927
  const waitingLineOf = (watch: Watch): string | null => {
845
- const board = readBoardText(watch)
846
- const sends = sentWaitingItems(readSentRecords(watch.root), Date.now())
847
- return waitingLine([...(board === null ? [] : openWaitingItems(board, watch.settings.planLanguage)), ...sends])
928
+ return waitingLine(
929
+ waitingItemsOf(readBoardText(watch), watch.settings.planLanguage, readWaitingRecords(watch.root), Date.now()),
930
+ )
848
931
  }
849
932
 
850
933
  /** The board's lanes, null when the board cannot be read. */
@@ -925,17 +1008,18 @@ const report = async (
925
1008
  console.log(
926
1009
  (watch.options.stream ? lines : withSingleReportNotice(lines, monitorToolAvailable(process.env))).join('\n'),
927
1010
  )
928
- writePrivate(watch.files.state, JSON.stringify(shown))
1011
+ writePrivateFile(watch.files.state, JSON.stringify(shown))
929
1012
  watching.reporting = false
930
1013
  return shown
931
1014
  }
932
1015
 
933
1016
  /**
934
1017
  * The parallel-task proposals of this heartbeat, one event per open lane whose situation changed since the last
935
- * heartbeat and that has a proposal, and the fingerprints of every lane read, kept for the next one. Nothing when
936
- * herdr or the board cannot be read: a proposal is a hint, and the next heartbeat reads again.
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.
937
1021
  */
938
- const taskParallelEventsOf = (
1022
+ const taskParallelProposalsOf = (
939
1023
  watch: Watch,
940
1024
  watching: Watching,
941
1025
  now: number,
@@ -950,13 +1034,22 @@ const taskParallelEventsOf = (
950
1034
  now,
951
1035
  })
952
1036
  const reports = Object.entries(inputs).map(
953
- ([lane, input]) => [lane, taskParallelReport(watching.taskParallels[lane], input)] as const,
1037
+ ([lane, input]) => [lane, input, taskParallelReport(watching.taskParallels[lane], input)] as const,
954
1038
  )
1039
+ const today = localDay(now)
955
1040
  return {
956
- events: reports.flatMap(([lane, { proposal }]) =>
957
- proposal ? [{ kind: TASK_PARALLEL_EVENT, lane, line: formatTaskParallelLine(lane, proposal) }] : [],
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
+ : [],
958
1051
  ),
959
- fingerprints: Object.fromEntries(reports.map(([lane, { fingerprint }]) => [lane, fingerprint])),
1052
+ fingerprints: Object.fromEntries(reports.map(([lane, , { fingerprint }]) => [lane, fingerprint])),
960
1053
  }
961
1054
  } catch {
962
1055
  return { events: [], fingerprints: {} }
@@ -964,26 +1057,120 @@ const taskParallelEventsOf = (
964
1057
  }
965
1058
 
966
1059
  /**
967
- * The heartbeat of a stream: one line, a working lane with its phase count when that moved since the last heartbeat.
968
- * The plans are read only when a lane works; a board that cannot be read leaves the lines without a count, and the
969
- * next report names the failure.
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.
970
1142
  */
971
1143
  const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: number): Promise<void> => {
972
- const working = watching.lanes.filter((lane) => watching.current[lane]?.status === AGENT_STATUS.working)
973
- const { progress } = working.length > 0 ? await boardReadOrNote(watch, working) : { progress: {} }
1144
+ const working = workingLanes(watching)
1145
+ const progress = await workingProgressOf(watch, watching)
974
1146
  const counts = currentPhaseCountsOf(progress)
975
- const notes = progressNotes(watching.lanes, watching.current, counts, watching.heartbeatCounts)
976
- watching.heartbeatCounts = { ...watching.heartbeatCounts, ...counts }
1147
+ const notes = heartbeatNotesOf(working, counts, working.length > 0 ? subagentCountsOf(watch, now) : {})
977
1148
  const rests = Object.fromEntries(
978
1149
  watching.lanes.flatMap((lane) => {
979
1150
  const rest = restNoteOf(watch, lane, watching.current[lane]?.status)
980
1151
  return rest === null ? [] : [[lane, rest]]
981
1152
  }),
982
1153
  )
983
- console.log(streamHeartbeatLine(clock(now), watching.lanes, watching.current, notes, rests, waitingLineOf(watch)))
984
- const { events, fingerprints } = taskParallelEventsOf(watch, watching, now)
985
- watching.taskParallels = { ...watching.taskParallels, ...fingerprints }
986
- for (const event of events) console.log(watchEventLine(event, null))
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)]
987
1174
  }
988
1175
 
989
1176
  /**
@@ -1008,8 +1195,7 @@ const pollOnce = async (watch: Watch, watching: Watching): Promise<boolean> => {
1008
1195
  ...approvalEvents(watching.state.approvals, watching.approvalRequests, now),
1009
1196
  ...appointmentEvents(watching.state.appointments, watching.appointments?.table ?? null, now),
1010
1197
  ]
1011
- const stopped = [...firedLanes(watching.baseline, watching.current, watching.lanes), ...watching.departed]
1012
- watching.departed = []
1198
+ const stopped = takeStoppedLanes(watch, watching)
1013
1199
  const heartbeat = isHeartbeatDue(watching.quietSince, now, heartbeatMinutes * MINUTE_MS)
1014
1200
  let next = recordedOf(watch, watching)
1015
1201
  const reported = stopped.length > 0 || polledEvents.length > 0
@@ -1017,8 +1203,7 @@ const pollOnce = async (watch: Watch, watching: Watching): Promise<boolean> => {
1017
1203
  next = await report(watch, watching, stopped, polledEvents, next)
1018
1204
  watching.baseline = { ...watching.current }
1019
1205
  } else if (heartbeat && !stream) {
1020
- const { events: proposals, fingerprints } = taskParallelEventsOf(watch, watching, now)
1021
- watching.taskParallels = { ...watching.taskParallels, ...fingerprints }
1206
+ const proposals = await heartbeatEventsOf(watch, watching, now)
1022
1207
  next = await report(watch, watching, [], [{ kind: WATCH_EVENT.heartbeat }, ...proposals], {
1023
1208
  ...recordedOf(watch, watching),
1024
1209
  statusLines: null,
@@ -1028,7 +1213,7 @@ const pollOnce = async (watch: Watch, watching: Watching): Promise<boolean> => {
1028
1213
  await printStreamHeartbeat(watch, watching, now)
1029
1214
  next = recordedOf(watch, watching)
1030
1215
  }
1031
- if (due) writePrivate(watch.files.state, JSON.stringify(next))
1216
+ if (due) writePrivateFile(watch.files.state, JSON.stringify(next))
1032
1217
  watching.baseline = advanceBaseline(watching.baseline, watching.current)
1033
1218
  }
1034
1219
  watching.state = next
@@ -1052,31 +1237,33 @@ const initialWatching = (
1052
1237
  agents: start.agents,
1053
1238
  github: null,
1054
1239
  laneBranches: {},
1055
- heartbeatCounts: {},
1056
1240
  githubReadAt: 0,
1057
1241
  coordinator: { name: watch.options.coordinator, read: null },
1058
1242
  herdrDown: false,
1059
1243
  reporting: false,
1060
- fetchingBase: false,
1244
+ baseFetch: null,
1245
+ fetchingHeads: false,
1061
1246
  ownLogin: null,
1062
1247
  feedbackRead: null,
1063
1248
  failureReadings: [],
1064
1249
  previewRead: null,
1065
1250
  sourceRuns: [],
1066
- sourceRanAt: {},
1067
1251
  laneReadings: {},
1068
1252
  laneModelReadings: {},
1069
1253
  laneParallelReadings: {},
1070
1254
  taskParallels: start.state.taskParallels,
1255
+ parallelAutoEnded: start.state.parallelAutoEnded,
1256
+ noTaskSignals: start.state.noTaskSignals,
1071
1257
  approvalRequests: null,
1072
1258
  appointments: null,
1259
+ reportQuestions: null,
1073
1260
  planViews: { failureReported: false },
1074
1261
  })
1075
1262
 
1076
1263
  /** Saves the state a stop leaves behind: the one from before an unfinished report or an unread GitHub answer, else the latest. */
1077
1264
  const saveState = (watch: Watch, watching: Watching) => {
1078
1265
  const unread = watching.reporting || watching.github === null
1079
- 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)))
1080
1267
  }
1081
1268
 
1082
1269
  const watchLanes = async (watch: Watch) => {
@@ -1098,14 +1285,25 @@ const watchLanes = async (watch: Watch) => {
1098
1285
  })
1099
1286
 
1100
1287
  const kit = await startedKit()
1101
- const skillSnapshot = kit ? readOrchestrateSkill(kit.packageRoot) : null
1288
+ const skillSnapshot = kit ? readOrchestrateSnapshot(kit.packageRoot) : null
1102
1289
  const skillLine = () => skillLineFor(kit, skillSnapshot, dirname(watch.files.state))
1103
- const announceUpdate = kit ? updateAnnouncer(homedir(), kit.version, defaultUpdateCheck(), console.log) : null
1104
- while (await pollOnce(watch, watching)) {
1105
- await Bun.sleep(POLL_MS)
1106
- pollLanes(watch, watching)
1107
- await announceUpdate?.()
1108
- 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()
1109
1307
  }
1110
1308
  }
1111
1309
 
@@ -1114,10 +1312,34 @@ const filesOf = (root: string): Files => {
1114
1312
  return { state: join(directory, WATCHER_FILES.state), lock: join(directory, WATCHER_FILES.lock) }
1115
1313
  }
1116
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
+
1117
1338
  /** The watch as the command line and the config set it up; throws when the config or the sprint is missing. */
1118
1339
  const watchOf = (options: WatchOptions): Watch => {
1119
1340
  const root = mainCheckout(process.cwd())
1120
- const { config, notes } = readResolvedConfig([worktreeRoot(process.cwd()), root])
1341
+ const checkoutRoot = worktreeRoot(process.cwd())
1342
+ const { config, notes } = readResolvedConfig([checkoutRoot, root])
1121
1343
  for (const note of notes) console.log(note)
1122
1344
  const settings = plansSettingsOf(config)
1123
1345
  const picked = boardFor(options.boardPath, [root, worktreeRoot(process.cwd())], settings, () =>
@@ -1134,9 +1356,12 @@ const watchOf = (options: WatchOptions): Watch => {
1134
1356
  feedback: config.watch.feedback,
1135
1357
  checks: config.watch.checks,
1136
1358
  parallelTasks: config.watch.parallelTasks,
1359
+ taskSignalMinutes: config.watch.taskSignalMinutes,
1360
+ parallelAuto: autoSettingsOf(root, checkoutRoot, config),
1137
1361
  previews: config.watch.previews,
1138
1362
  sources: config.watch.sources,
1139
1363
  texts: watchTexts(settings.responseLanguage),
1364
+ skillCopies: skillCopiesFor(root, homedir(), config.skillPrefix, DEFAULT_SKILL_PREFIX),
1140
1365
  herdr: insideHerdr(settings.adapter, process.env),
1141
1366
  }
1142
1367
  }