jackherd 0.0.1 → 1.0.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 (250) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +38 -0
  4. package/README.md +1121 -2
  5. package/assets/branding/README.md +62 -0
  6. package/assets/branding/jackherd-icon-256.png +0 -0
  7. package/assets/branding/jackherd-icon-512.png +0 -0
  8. package/assets/branding/jackherd-icon-64.png +0 -0
  9. package/assets/branding/jackherd-icon-small-32.png +0 -0
  10. package/assets/branding/jackherd-icon-small-64.png +0 -0
  11. package/assets/branding/jackherd-icon-small.svg +1 -0
  12. package/assets/branding/jackherd-icon.svg +1 -0
  13. package/assets/branding/jackherd-logo-1024.png +0 -0
  14. package/assets/branding/jackherd-logo-512.png +0 -0
  15. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  16. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  17. package/assets/branding/jackherd-logo-dark.svg +1 -0
  18. package/assets/branding/jackherd-logo-mark-1024.png +0 -0
  19. package/assets/branding/jackherd-logo-mark-512.png +0 -0
  20. package/assets/branding/jackherd-logo-mark.svg +1 -0
  21. package/assets/branding/jackherd-logo.svg +1 -0
  22. package/dist/chunk-R6MGHCD7.js +93 -0
  23. package/dist/config.d.ts +167 -0
  24. package/dist/config.js +401 -0
  25. package/dist/plan-format.d.ts +325 -0
  26. package/dist/plan-format.js +636 -0
  27. package/docs/lanes.mmd +9 -0
  28. package/docs/lanes.svg +1 -0
  29. package/docs/skills.mmd +10 -0
  30. package/docs/skills.svg +1 -0
  31. package/docs/workflow.svg +172 -0
  32. package/herdr-plugin/herdr-plugin.toml +22 -0
  33. package/herdr-plugin/open-plans.ts +43 -0
  34. package/herdr-plugin/restore-plans.ts +107 -0
  35. package/install.sh +157 -0
  36. package/kit/hooks/hooks/git-guard/decision.ts +438 -0
  37. package/kit/hooks/hooks/git-guard/index.ts +38 -0
  38. package/kit/hooks/hooks/git-guard/shell.ts +407 -0
  39. package/kit/hooks/hooks/watch-guard/background.ts +36 -0
  40. package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
  41. package/kit/hooks/hooks/watch-guard/index.ts +11 -0
  42. package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
  43. package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
  44. package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
  45. package/kit/hooks/hooks.json +30 -0
  46. package/kit/hooks/statusline.sh +72 -0
  47. package/kit/review/agents/devops.md +11 -0
  48. package/kit/review/agents/public-content.md +39 -0
  49. package/kit/review/agents/security-audit.md +11 -0
  50. package/kit/review/agents/uncle-bob.md +11 -0
  51. package/kit/review/skills/devops/SKILL.md +50 -0
  52. package/kit/review/skills/diagnose/SKILL.md +140 -0
  53. package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
  54. package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
  55. package/kit/review/skills/pre-commit/SKILL.md +100 -0
  56. package/kit/review/skills/review/SKILL.md +43 -0
  57. package/kit/review/skills/security-audit/SKILL.md +65 -0
  58. package/kit/review/skills/uncle-bob/SKILL.md +43 -0
  59. package/kit/rules/rules/kit/arc42.md +89 -0
  60. package/kit/rules/rules/kit/architecture-files.md +87 -0
  61. package/kit/rules/rules/kit/code-style.md +159 -0
  62. package/kit/rules/rules/kit/pull-requests.md +59 -0
  63. package/kit/rules/rules/kit/testing.md +91 -0
  64. package/kit/rules/rules/kit/writing-for-agents.md +110 -0
  65. package/kit/workflow/agents/explore.md +25 -0
  66. package/kit/workflow/agents/implement.md +16 -0
  67. package/kit/workflow/agents/plan.md +13 -0
  68. package/kit/workflow/agents/spec-review.md +96 -0
  69. package/kit/workflow/skills/follow/SKILL.md +48 -0
  70. package/kit/workflow/skills/grill/SKILL.md +60 -0
  71. package/kit/workflow/skills/implement/SKILL.md +107 -0
  72. package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
  73. package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
  74. package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
  75. package/kit/workflow/skills/plan/SKILL.md +118 -0
  76. package/kit/workflow/skills/plan/references/template-English.md +92 -0
  77. package/kit/workflow/skills/plan/references/template-German.md +92 -0
  78. package/kit/workflow/skills/start/SKILL.md +47 -0
  79. package/package.json +63 -5
  80. package/schemas/jackherd.schema.json +601 -0
  81. package/src/check/changes.ts +45 -0
  82. package/src/check/command-line.ts +12 -0
  83. package/src/check/command.ts +136 -0
  84. package/src/check/deferral-arrows.ts +41 -0
  85. package/src/check/execute.ts +29 -0
  86. package/src/check/glob.ts +15 -0
  87. package/src/check/plan-structure-check.ts +135 -0
  88. package/src/check/plan.ts +106 -0
  89. package/src/check/public-content-check.ts +32 -0
  90. package/src/check/public-content-facts.ts +177 -0
  91. package/src/check/public-content.ts +216 -0
  92. package/src/check/report.ts +55 -0
  93. package/src/check/result.ts +42 -0
  94. package/src/check/roots.ts +36 -0
  95. package/src/check/routing.ts +22 -0
  96. package/src/check/schema.ts +83 -0
  97. package/src/check/settings.ts +44 -0
  98. package/src/claude/paths.ts +14 -0
  99. package/src/cli.ts +46 -0
  100. package/src/config/command.ts +45 -0
  101. package/src/config/detect.ts +196 -0
  102. package/src/config/index.ts +2 -0
  103. package/src/config/json-schema.ts +5 -0
  104. package/src/config/legacy-names.ts +56 -0
  105. package/src/config/link-kinds.ts +25 -0
  106. package/src/config/location.ts +46 -0
  107. package/src/config/read-config.ts +42 -0
  108. package/src/config/repository-facts.ts +19 -0
  109. package/src/config/repository-values.ts +46 -0
  110. package/src/config/schema.ts +302 -0
  111. package/src/context/command.ts +39 -0
  112. package/src/context/coordinator-name.ts +16 -0
  113. package/src/context/fill.ts +69 -0
  114. package/src/context/index.ts +10 -0
  115. package/src/context/level.ts +22 -0
  116. package/src/context/options.ts +29 -0
  117. package/src/context/report.ts +42 -0
  118. package/src/context/session-log.ts +91 -0
  119. package/src/plan-format/drift.ts +69 -0
  120. package/src/plan-format/header.ts +27 -0
  121. package/src/plan-format/index.ts +9 -0
  122. package/src/plan-format/lanes.ts +128 -0
  123. package/src/plan-format/markdown.ts +98 -0
  124. package/src/plan-format/phase-tally.ts +55 -0
  125. package/src/plan-format/phases.ts +224 -0
  126. package/src/plan-format/plan-structure.ts +150 -0
  127. package/src/plan-format/progress.ts +202 -0
  128. package/src/plan-format/sprint-document.ts +69 -0
  129. package/src/plan-format/terminal-text.ts +28 -0
  130. package/src/plans/board-choice.ts +60 -0
  131. package/src/plans/board-pick.ts +72 -0
  132. package/src/plans/board-rows.ts +62 -0
  133. package/src/plans/command.ts +60 -0
  134. package/src/plans/ensure-panes.ts +71 -0
  135. package/src/plans/error-text.ts +5 -0
  136. package/src/plans/fetch.ts +96 -0
  137. package/src/plans/git.ts +145 -0
  138. package/src/plans/github.ts +35 -0
  139. package/src/plans/lane-discovery.ts +34 -0
  140. package/src/plans/lane-plans.ts +80 -0
  141. package/src/plans/layout.ts +881 -0
  142. package/src/plans/link-chips.ts +74 -0
  143. package/src/plans/live.ts +336 -0
  144. package/src/plans/markdown-html.ts +140 -0
  145. package/src/plans/options.ts +15 -0
  146. package/src/plans/phase-source.ts +71 -0
  147. package/src/plans/plan-chips.ts +75 -0
  148. package/src/plans/plan-panes.ts +206 -0
  149. package/src/plans/planned.ts +117 -0
  150. package/src/plans/pull-requests.ts +107 -0
  151. package/src/plans/restart-decision.ts +42 -0
  152. package/src/plans/selection.ts +352 -0
  153. package/src/plans/settings.ts +68 -0
  154. package/src/plans/single-flight.ts +25 -0
  155. package/src/plans/snapshot.ts +300 -0
  156. package/src/plans/sprint-export.ts +103 -0
  157. package/src/plans/sprint-html.ts +301 -0
  158. package/src/plans/sprint-page.ts +287 -0
  159. package/src/plans/supervisor.ts +124 -0
  160. package/src/plans/symbols.ts +54 -0
  161. package/src/plans/texts.ts +103 -0
  162. package/src/plans/unlisted-lanes.ts +45 -0
  163. package/src/plans/view.ts +68 -0
  164. package/src/plans/worked-phases.ts +53 -0
  165. package/src/sync/checksum.ts +13 -0
  166. package/src/sync/choice.ts +14 -0
  167. package/src/sync/command-context.ts +113 -0
  168. package/src/sync/command.ts +94 -0
  169. package/src/sync/error-message.ts +1 -0
  170. package/src/sync/gitignore.ts +45 -0
  171. package/src/sync/global-kit.ts +176 -0
  172. package/src/sync/herdr-plugin.ts +175 -0
  173. package/src/sync/if-changed.ts +109 -0
  174. package/src/sync/init-command.ts +239 -0
  175. package/src/sync/init.ts +136 -0
  176. package/src/sync/kit-hooks.ts +18 -0
  177. package/src/sync/kit-path.ts +77 -0
  178. package/src/sync/kit-source.ts +169 -0
  179. package/src/sync/kit.ts +9 -0
  180. package/src/sync/legacy-files.ts +116 -0
  181. package/src/sync/manifest.ts +44 -0
  182. package/src/sync/next-steps.ts +42 -0
  183. package/src/sync/plan-file.ts +154 -0
  184. package/src/sync/plan-hooks.ts +165 -0
  185. package/src/sync/project-fs.ts +132 -0
  186. package/src/sync/render.ts +60 -0
  187. package/src/sync/report.ts +96 -0
  188. package/src/sync/settings-hooks.ts +90 -0
  189. package/src/sync/settings-migration.ts +94 -0
  190. package/src/sync/settings-offer.ts +168 -0
  191. package/src/sync/setup-questions.ts +70 -0
  192. package/src/sync/setup.ts +52 -0
  193. package/src/sync/sync-plan.ts +120 -0
  194. package/src/sync/sync-result.ts +9 -0
  195. package/src/sync/sync.ts +307 -0
  196. package/src/sync/tools.ts +52 -0
  197. package/src/terminal/herdr.ts +91 -0
  198. package/src/terminal/index.ts +13 -0
  199. package/src/terminal/none.ts +7 -0
  200. package/src/terminal/open.ts +39 -0
  201. package/src/terminal/text.ts +159 -0
  202. package/src/terminal/types.ts +30 -0
  203. package/src/terminal/width.ts +90 -0
  204. package/src/update/command.ts +65 -0
  205. package/src/update/package-manager.ts +56 -0
  206. package/src/update/update.ts +105 -0
  207. package/src/version/installed-version.ts +32 -0
  208. package/src/version/release.ts +22 -0
  209. package/src/version/skill-diff.ts +63 -0
  210. package/src/version/started-kit.ts +14 -0
  211. package/src/vocabulary/index.ts +132 -0
  212. package/src/watch/base-tip.ts +18 -0
  213. package/src/watch/command.ts +919 -0
  214. package/src/watch/coordinator-fill.ts +76 -0
  215. package/src/watch/feedback-read.ts +73 -0
  216. package/src/watch/feedback.ts +308 -0
  217. package/src/watch/gh.ts +22 -0
  218. package/src/watch/github-read.ts +175 -0
  219. package/src/watch/herdr.ts +11 -0
  220. package/src/watch/json-guards.ts +9 -0
  221. package/src/watch/kit-update.ts +18 -0
  222. package/src/watch/lane-fill.ts +36 -0
  223. package/src/watch/lane-message.ts +99 -0
  224. package/src/watch/lane-progress.ts +44 -0
  225. package/src/watch/lane-report-file.ts +53 -0
  226. package/src/watch/lane-report.ts +270 -0
  227. package/src/watch/lane-search.ts +41 -0
  228. package/src/watch/lane-sources.ts +46 -0
  229. package/src/watch/lane-watch.ts +121 -0
  230. package/src/watch/monitor-tool.ts +19 -0
  231. package/src/watch/options.ts +79 -0
  232. package/src/watch/plan-badges-read.ts +30 -0
  233. package/src/watch/plan-badges.ts +66 -0
  234. package/src/watch/plan-views.ts +59 -0
  235. package/src/watch/preview-read.ts +60 -0
  236. package/src/watch/previews.ts +234 -0
  237. package/src/watch/pull-requests.ts +117 -0
  238. package/src/watch/single-report.ts +15 -0
  239. package/src/watch/skill-change.ts +61 -0
  240. package/src/watch/source-dry-run-command.ts +35 -0
  241. package/src/watch/source-dry-run.ts +124 -0
  242. package/src/watch/source-fields.ts +9 -0
  243. package/src/watch/source-path.ts +40 -0
  244. package/src/watch/source-run.ts +90 -0
  245. package/src/watch/sources.ts +344 -0
  246. package/src/watch/stream-output.ts +14 -0
  247. package/src/watch/texts.ts +76 -0
  248. package/src/watch/watch-events.ts +399 -0
  249. package/src/watch/watcher-lock.ts +145 -0
  250. package/src/watch/workflow-runs.ts +96 -0
@@ -0,0 +1,919 @@
1
+ /**
2
+ * `jh watch`: waits until the coordinator has something to do and prints it: a lane that stopped, a lane's
3
+ * pull request that was merged or whose checks settled, a deploy run of a lane's merge or a manual one (only with
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
+ * lane's new head commit (only with `watch.previews`), a new or changed entry of a configured source or its failure (only
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
8
+ * per lane and one for the coordinator, in the words of `responseLanguage`. When the installed kit changes version
9
+ * it prints `=== kit updated <old> → <new>` and ends. The coordinator is the agent in the
10
+ * watcher's own herdr pane unless `--coordinator` names it, else `orchestrator-<repository>`. A new start replaces a running watcher of the same
11
+ * repository; one in another repository keeps running.
12
+ *
13
+ * Without `--stream` it exits after one report, whose last line says so and points to the stream: that run is the
14
+ * fallback where no Monitor tool exists, a background task armed again after every wake-up.
15
+ * With `--stream` it prints each report in one write and keeps watching, the heartbeat as a single line: run it
16
+ * under Claude Code's Monitor tool, which turns every line into a message. Without lane names it watches every
17
+ * worktree whose name a herdr agent carries or the board's lanes table lists, and prints `lane added: <name>` /
18
+ * `lane removed: <name>` as that set changes. With every GitHub read it fetches `origin/<baseBranch>` once the
19
+ * remote moved past it, so the plans view, which watches `FETCH_HEAD`, shows a merged lane row at once. Inside herdr
20
+ * it brings back missing plans views of the board's lanes like `jh plans --ensure`: at its start, for every
21
+ * added lane and for every lane whose agent appeared.
22
+ *
23
+ * `watch source <name> --dry-run` runs one source once instead and prints what it would report (`source-dry-run-command.ts`).
24
+ *
25
+ * jh watch [--stream] [--heartbeat <minutes>] [--board <board.md>] [--coordinator <name>] [<lane> ...]
26
+ * jh watch source <name|draft.json|-> --dry-run
27
+ */
28
+ import { readFileSync } from 'node:fs'
29
+ import { basename, dirname, join, relative } from 'node:path'
30
+ import { readResolvedConfig } from '../config/read-config'
31
+ import type { JackherdConfig } from '../config/schema'
32
+ import { defaultCoordinatorName } from '../context/coordinator-name'
33
+ import { coordinatorSettings } from '../context/options'
34
+ import { contextReport } from '../context/report'
35
+ import { agentLog } from '../context/session-log'
36
+ import { type LaneProgress, parseSprintLanes } from '../plan-format'
37
+ import { terminalSafe } from '../plan-format/terminal-text'
38
+ import { boardFor, foundLanesOf } from '../plans/board-pick'
39
+ import { ensurePlanPanes } from '../plans/ensure-panes'
40
+ import { errorText } from '../plans/error-text'
41
+ import { mainCheckout, worktreeNames, worktreeRoot } from '../plans/git'
42
+ import { discoverLanes, isUnchanged, laneSetChange, laneSetLines } from '../plans/lane-discovery'
43
+ import { clockLocaleOf, type PlansSettings, plansSettingsOf, worktreeOf } from '../plans/settings'
44
+ import { boardTexts } from '../plans/texts'
45
+ import { herdrLaneStatusSource } from '../terminal/herdr'
46
+ import { startedKit } from '../version/started-kit'
47
+ import {
48
+ type CoordinatorRead,
49
+ contextReadingOf,
50
+ coordinatorNameOf,
51
+ coordinatorReadOf,
52
+ coordinatorTargetOf,
53
+ explainedRead,
54
+ readingOf,
55
+ } from './coordinator-fill'
56
+ import {
57
+ type FeedbackItem,
58
+ type FeedbackRead,
59
+ type FeedbackTarget,
60
+ feedbackEvents,
61
+ feedbackReadOf,
62
+ feedbackSince,
63
+ feedbackTargets,
64
+ recordFeedback,
65
+ } from './feedback'
66
+ import { type FeedbackItemsRead, readCommentItems, readFeedbackItems, readOwnLogin } from './feedback-read'
67
+ import { answered, deployTargetOf, type GithubRead, readGithub, targetText } from './github-read'
68
+ import { agentScreen, listAgents } from './herdr'
69
+ import { endedByKitUpdate } from './kit-update'
70
+ import { type LaneReadings, laneFillEvents, recordLaneLevels } from './lane-fill'
71
+ import { laneMessage, rememberedStatusLines, statusBlock } from './lane-message'
72
+ import { boardCheckoutRows, watchedLaneProgress } from './lane-progress'
73
+ import {
74
+ armLine,
75
+ blockedDutyLines,
76
+ coordinatorStatusLine,
77
+ feedbackEventLines,
78
+ ghUnavailableStatusLine,
79
+ type LaneReport,
80
+ laneFillDutyLines,
81
+ laneReportLine,
82
+ mergeDutyLines,
83
+ otherPullRequestsLine,
84
+ sourceEventLines,
85
+ sourceFailingStatusLine,
86
+ watchEventLine,
87
+ } from './lane-report'
88
+ import { laneReportPath, readLaneReport, reportMtime } from './lane-report-file'
89
+ import { branchOf, fetchBaseWhenMoved } from './lane-sources'
90
+ import {
91
+ advanceBaseline,
92
+ armBaseline,
93
+ baselineWithNewAgents,
94
+ departedLanes,
95
+ firedLanes,
96
+ type HerdrAgent,
97
+ LANE_GONE,
98
+ type LaneSnapshot,
99
+ NO_AGENT,
100
+ toSnapshot,
101
+ } from './lane-watch'
102
+ import { monitorToolAvailable } from './monitor-tool'
103
+ import { parseWatchOptions, type WatchOptions } from './options'
104
+ import {
105
+ ENSURE_TRIGGER,
106
+ type EnsureTrigger,
107
+ insideHerdr,
108
+ keepPlanViews,
109
+ lanesWithNewAgent,
110
+ type PlanViews,
111
+ } from './plan-views'
112
+ import { readDeploymentCandidates } from './preview-read'
113
+ import {
114
+ commentCandidates,
115
+ feedbackFilterOf,
116
+ feedbackReadCovers,
117
+ type PreviewRead,
118
+ previewEvents,
119
+ previewReadOf,
120
+ recordPreviews,
121
+ unreportedTargets,
122
+ } from './previews'
123
+ import { latestPullRequestOf, type WatchedPullRequest } from './pull-requests'
124
+ import { withSingleReportNotice } from './single-report'
125
+ import { readOrchestrateSkill, skillLineFor } from './skill-change'
126
+ import { sourceDryRun } from './source-dry-run-command'
127
+ import { runSource, SOURCE_LIMITS } from './source-run'
128
+ import {
129
+ isSourceDue,
130
+ recordSources,
131
+ type SourceRun,
132
+ type SourceSettings,
133
+ type SourceState,
134
+ sourceEvents,
135
+ } from './sources'
136
+ import { streamHeartbeatLine } from './stream-output'
137
+ import { WATCH_TEXTS, type WatchTexts } from './texts'
138
+ import {
139
+ coordinatorWatchEvent,
140
+ deployEvents,
141
+ emptyWatchState,
142
+ ghOutageEvent,
143
+ isHeartbeatDue,
144
+ mergeCommitsOf,
145
+ type Observation,
146
+ ownMergeCommitsOf,
147
+ parseWatchState,
148
+ pullRequestEvents,
149
+ recordCoordinatorLevel,
150
+ recordWatchState,
151
+ WATCH_EVENT,
152
+ type WatchEvent,
153
+ type WatchState,
154
+ withReportBaselines,
155
+ } from './watch-events'
156
+ import {
157
+ ensureWatcherDirectory,
158
+ releaseLock,
159
+ stopNoticeOf,
160
+ takeOverLock,
161
+ WATCHER_FILES,
162
+ writePrivate,
163
+ } from './watcher-lock'
164
+ import type { DeployConfig } from './workflow-runs'
165
+
166
+ type Files = { state: string; lock: string }
167
+
168
+ /** The logins whose feedback does not count, or false when the project turned feedback off. */
169
+ type FeedbackConfig = JackherdConfig['watch']['feedback']
170
+
171
+ /** Where deploy previews are read from; undefined when the project reads none. */
172
+ type PreviewsConfig = JackherdConfig['watch']['previews']
173
+
174
+ /** One feedback read: what counts as feedback, and every item it read, which the previews take their comments from. */
175
+ type FeedbackPoll = { read: FeedbackRead; items: FeedbackItem[] }
176
+
177
+ /** What one run of the watcher is set up with: the main checkout, the sprint, the command line and the config. */
178
+ type Watch = {
179
+ root: string
180
+ sprintFile: string
181
+ /** Why this board was chosen over others; null when there was no choice or `--board` named it. */
182
+ boardNotice: string | null
183
+ options: WatchOptions
184
+ files: Files
185
+ settings: PlansSettings
186
+ deploy: DeployConfig | undefined
187
+ feedback: FeedbackConfig
188
+ previews: PreviewsConfig
189
+ sources: SourceSettings[]
190
+ texts: WatchTexts
191
+ /** Runs inside herdr with the herdr adapter, so missing plans views can be brought back. */
192
+ herdr: boolean
193
+ }
194
+
195
+ /** The coordinator's agent name (null when none is found) and its last read (null before the first one). */
196
+ type Coordinator = { name: string | null; read: CoordinatorRead | null }
197
+
198
+ /**
199
+ * What the loop knows between polls. `state` is the record as of the last GitHub read that did not
200
+ * return; while a report is being written, a stop saves that and not the newer read, so the events
201
+ * of an interrupted report are reported again instead of being taken as done.
202
+ */
203
+ type Watching = {
204
+ /** The lanes watched now: the named ones, or those found at the last herdr poll. */
205
+ lanes: string[]
206
+ /** The lane names of the board's lanes table as last read; a failed read keeps them. */
207
+ sprintLanes: string[]
208
+ /** Lanes that left the set with their agent since the last poll; they fire as gone once. */
209
+ departed: string[]
210
+ /** When the last report or heartbeat was printed, the start before the first. */
211
+ quietSince: number
212
+ state: WatchState
213
+ baseline: LaneSnapshot
214
+ current: LaneSnapshot
215
+ /** The last `herdr agent list` answer, kept for finding the coordinator by its pane. */
216
+ agents: HerdrAgent[]
217
+ github: GithubRead | null
218
+ laneBranches: Record<string, string>
219
+ githubReadAt: number
220
+ coordinator: Coordinator
221
+ herdrDown: boolean
222
+ reporting: boolean
223
+ /** A fetch of the base branch is under way; the next one waits for it. */
224
+ fetchingBase: boolean
225
+ /** The account `gh` runs as, read once it answers; feedback is read only once it is known. */
226
+ ownLogin: string | null
227
+ /** The last feedback read that answered; its targets and items are recorded with every state. */
228
+ feedbackRead: FeedbackRead | null
229
+ /** The last preview read that answered; recorded with every state like the feedback read. */
230
+ previewRead: PreviewRead | null
231
+ /** The runs of the sources due at the last GitHub poll; recorded with every state like the feedback read. */
232
+ sourceRuns: SourceRun[]
233
+ /** Source name → when it was last started in this process; a source without an entry runs at the next GitHub poll. */
234
+ sourceRanAt: Record<string, number>
235
+ /** The lanes' fills of the last read; a lane without an entry or with null has no value. */
236
+ laneReadings: LaneReadings
237
+ planViews: PlanViews
238
+ }
239
+
240
+ const POLL_MS = 5_000
241
+ const GITHUB_POLL_MS = 60_000
242
+ const MINUTE_MS = 60_000
243
+ const USAGE_EXIT = 2
244
+
245
+ const clock = (watch: Watch, epochMs: number): string =>
246
+ new Date(epochMs).toLocaleTimeString(clockLocaleOf(watch.settings))
247
+
248
+ const agentNamesOf = (agents: readonly HerdrAgent[]): string[] => agents.flatMap((agent) => agent.name ?? [])
249
+
250
+ const observationOf = (watching: Watching): Observation => ({
251
+ lanes: watching.current,
252
+ laneBranches: watching.laneBranches,
253
+ github: answered(watching.github),
254
+ })
255
+
256
+ /** Stamped with the time of the last GitHub read, not of the poll: a run created after it is still unseen. */
257
+ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
258
+ const recorded = recordCoordinatorLevel(
259
+ recordWatchState(watching.state, observationOf(watching), watching.githubReadAt),
260
+ readingOf(watching.coordinator.read),
261
+ )
262
+ const read = watching.feedbackRead
263
+ const withFeedback = read ? { ...recorded, feedback: recordFeedback(recorded.feedback, read) } : recorded
264
+ const previews = watching.previewRead
265
+ const withPreviews = previews
266
+ ? { ...withFeedback, previews: recordPreviews(withFeedback.previews, previews) }
267
+ : withFeedback
268
+ const withLevels = {
269
+ ...withPreviews,
270
+ laneLevels: recordLaneLevels(withPreviews.laneLevels, watching.laneReadings, watching.lanes),
271
+ sources: recordSources(withPreviews.sources, watching.sourceRuns, sourceNamesOf(watch), watching.githubReadAt),
272
+ }
273
+ return withReportBaselines(withLevels, watching.lanes, (lane) => reportMtime(reportPathOf(watch, lane)))
274
+ }
275
+
276
+ const sourceNamesOf = (watch: Watch): string[] => watch.sources.map(({ name }) => name)
277
+
278
+ /** The events of one GitHub read against the state it is compared with. */
279
+ const githubEventsOf = (state: WatchState, github: GithubRead, laneBranches: Record<string, string>): WatchEvent[] => {
280
+ if (!github.ok) {
281
+ const outage = ghOutageEvent(state, github.error)
282
+ return outage ? [outage] : []
283
+ }
284
+ const branches = { ...state.laneBranches, ...laneBranches }
285
+ const merges = mergeCommitsOf(github.pullRequests, branches)
286
+ const ownMerges = ownMergeCommitsOf(state, github.pullRequests, branches)
287
+ return [
288
+ ...pullRequestEvents(state, github.pullRequests, branches),
289
+ ...deployEvents(state, github.runs, merges, ownMerges),
290
+ ]
291
+ }
292
+
293
+ const screenOf = (lane: string): string => {
294
+ try {
295
+ return agentScreen(lane)
296
+ } catch (error) {
297
+ return `screen not readable: ${errorText(error)}`
298
+ }
299
+ }
300
+
301
+ const reportPathOf = (watch: Watch, lane: string): string | null =>
302
+ laneReportPath(worktreeOf(watch.root, watch.settings, lane), lane)
303
+
304
+ /** The lines under a stopped lane's header and its report time to remember (`laneMessage`). */
305
+ const stopMessageOf = (watch: Watch, lane: string, status: string, state: WatchState) =>
306
+ laneMessage({
307
+ status,
308
+ screen: screenOf(lane),
309
+ report: readLaneReport(reportPathOf(watch, lane)),
310
+ reportedMtime: state.reportMtimes[lane] ?? null,
311
+ })
312
+
313
+ /** `--coordinator` if given, else the agent in the watcher's own pane, else `orchestrator-<repository>`. */
314
+ const coordinatorNameFor = (watch: Watch, agents: readonly HerdrAgent[]): string =>
315
+ watch.options.coordinator ??
316
+ coordinatorNameOf(agents, process.env.HERDR_PANE_ID) ??
317
+ defaultCoordinatorName(basename(watch.root))
318
+
319
+ /** One read of the fills: the coordinator's, and each watched lane's (null without a value). */
320
+ type FillRead = { coordinator: CoordinatorRead; lanes: LaneReadings }
321
+
322
+ /**
323
+ * The coordinator's fill with its own thresholds, as `jh context <name> --coordinator` computes it, or why there
324
+ * is none, and each lane's fill with the lanes' thresholds, as the plans view shows it: from one herdr listing. When
325
+ * herdr cannot be asked, the lanes have no reading, so their stored levels stay.
326
+ */
327
+ const fillReadFor = (watch: Watch, name: string, agents: readonly HerdrAgent[], lanes: readonly string[]): FillRead => {
328
+ const target = coordinatorTargetOf(agents, name, process.env.HERDR_PANE_ID)
329
+ try {
330
+ const listed = herdrLaneStatusSource.listAgents(
331
+ target.kind === 'unnamedPane' ? { pane: target.pane, name } : undefined,
332
+ )
333
+ const own = contextReport(listed, name, agentLog, coordinatorSettings(watch.settings.context))
334
+ const laneReading = (lane: string) =>
335
+ contextReadingOf(contextReport(listed, lane, agentLog, watch.settings.context))
336
+ return {
337
+ coordinator: explainedRead(coordinatorReadOf(own), target, name, watch.texts),
338
+ lanes: Object.fromEntries(lanes.map((lane) => [lane, laneReading(lane)])),
339
+ }
340
+ } catch (error) {
341
+ return { coordinator: { reason: errorText(error) }, lanes: {} }
342
+ }
343
+ }
344
+
345
+ /** The saved state, or an empty one; a damaged file throws. */
346
+ const readState = async (files: Files): Promise<WatchState> => {
347
+ const file = Bun.file(files.state)
348
+ return (await file.exists()) ? parseWatchState(await file.text()) : emptyWatchState()
349
+ }
350
+
351
+ const laneBranchesOf = (watch: Watch, lanes: readonly string[]): Record<string, string> =>
352
+ Object.fromEntries(
353
+ lanes.flatMap((lane) => {
354
+ const branch = branchOf(worktreeOf(watch.root, watch.settings, lane))
355
+ return branch ? [[lane, branch]] : []
356
+ }),
357
+ )
358
+
359
+ const eventLinesOf = async (
360
+ watch: Watch,
361
+ events: readonly WatchEvent[],
362
+ github: GithubRead | null,
363
+ ): Promise<string[]> => {
364
+ const runs = answered(github)?.runs ?? []
365
+ const lines: string[] = []
366
+ for (const event of events) {
367
+ if (event.kind === WATCH_EVENT.feedback) {
368
+ lines.push(...feedbackEventLines(event))
369
+ continue
370
+ }
371
+ if (event.kind === WATCH_EVENT.source) {
372
+ lines.push(...sourceEventLines(event))
373
+ continue
374
+ }
375
+ const headSha = event.kind === WATCH_EVENT.deploy ? runs.find((run) => run.id === event.runId)?.headSha : undefined
376
+ const target =
377
+ event.kind === WATCH_EVENT.deploy && headSha && watch.deploy
378
+ ? await deployTargetOf(watch.root, watch.deploy, event.runId, headSha)
379
+ : null
380
+ lines.push(watchEventLine(event, target ? targetText(target) : null))
381
+ }
382
+ return lines
383
+ }
384
+
385
+ /** The progress of each watched lane, and the rows only the checkout's board file has, for the coordinator line. */
386
+ const boardReadOf = async (
387
+ watch: Watch,
388
+ lanes: readonly string[],
389
+ ): Promise<{ progress: Record<string, LaneProgress>; checkoutRows: number }> => {
390
+ const text = await Bun.file(watch.sprintFile).text()
391
+ const sprintLanes = parseSprintLanes(text, watch.settings.planLanguage)
392
+ const watched = sprintLanes.filter((lane) => lanes.includes(lane.name))
393
+ const entries = await Promise.all(
394
+ watched.map(
395
+ async (lane) =>
396
+ [lane.name, await watchedLaneProgress(watch.root, watch.settings, watch.sprintFile, lane)] as const,
397
+ ),
398
+ )
399
+ const checkoutRows = boardCheckoutRows(watch.root, watch.settings, watch.sprintFile, text)
400
+ return { progress: Object.fromEntries(entries), checkoutRows }
401
+ }
402
+
403
+ /** What the status line of one lane is made of, from the loop's state. */
404
+ const laneReportOf = (
405
+ lane: string,
406
+ watching: Watching,
407
+ state: WatchState,
408
+ pullRequests: readonly WatchedPullRequest[],
409
+ progress: LaneProgress | null,
410
+ ): LaneReport => {
411
+ const branch = watching.laneBranches[lane] ?? null
412
+ const pullRequest = branch ? latestPullRequestOf(pullRequests, branch) : null
413
+ const feedback = pullRequest ? (state.feedback.pullRequests[String(pullRequest.number)]?.count ?? 0) : 0
414
+ return {
415
+ lane,
416
+ status: watching.current[lane]?.status ?? NO_AGENT,
417
+ branch,
418
+ pullRequest: pullRequest ? { number: pullRequest.number, checks: pullRequest.checks, feedback } : null,
419
+ progress,
420
+ context: watching.laneReadings[lane]?.level ?? null,
421
+ }
422
+ }
423
+
424
+ /** `boardReadOf`, or when the board cannot be read, nothing of it and a note that says why. */
425
+ const boardReadOrNote = async (
426
+ watch: Watch,
427
+ lanes: readonly string[],
428
+ ): Promise<{ progress: Record<string, LaneProgress>; checkoutRows: number; notes: string[] }> => {
429
+ try {
430
+ return { ...(await boardReadOf(watch, lanes)), notes: [] }
431
+ } catch (error) {
432
+ return {
433
+ progress: {},
434
+ checkoutRows: 0,
435
+ notes: [boardTexts(watch.settings.board.term).notReadable(errorText(error))],
436
+ }
437
+ }
438
+ }
439
+
440
+ /**
441
+ * The lane lines of the status block, the coordinator's line, the line of the own pull requests without a lane that
442
+ * follows it (`others`), and the lines after the block (notes, a gh outage).
443
+ */
444
+ const statusLinesOf = async (
445
+ watch: Watch,
446
+ watching: Watching,
447
+ state: WatchState,
448
+ ): Promise<{ laneLines: string[]; coordinator: string; others: string[]; rest: string[] }> => {
449
+ const github = answered(watching.github)
450
+ const pullRequests = github?.pullRequests ?? []
451
+ const others = otherPullRequestsLine(pullRequests, github?.own ?? [], watch.texts)
452
+ const { progress, checkoutRows, notes } = await boardReadOrNote(watch, watching.lanes)
453
+ const laneLines = watching.lanes.map((lane) =>
454
+ laneReportLine(laneReportOf(lane, watching, state, pullRequests, progress[lane] ?? null), watch.texts),
455
+ )
456
+ const outage = state.ghDownSince === null ? [] : [ghUnavailableStatusLine(clock(watch, state.ghDownSince))]
457
+ const board = { term: watch.settings.board.term, checkoutRows }
458
+ const coordinator = coordinatorStatusLine(watching.coordinator.name, watching.coordinator.read, watch.texts, board)
459
+ return {
460
+ laneLines,
461
+ coordinator,
462
+ others: others === null ? [] : [others],
463
+ rest: [...notes, ...outage, ...failingSourceLines(watch, state.sources)],
464
+ }
465
+ }
466
+
467
+ const failingSourceLines = (watch: Watch, sources: SourceState): string[] =>
468
+ Object.entries(sources).flatMap(([name, { failingSince }]) =>
469
+ failingSince === null ? [] : [sourceFailingStatusLine(name, clock(watch, failingSince))],
470
+ )
471
+
472
+ /** The section of each stopped lane (its header and message) and the report times it leaves behind. */
473
+ const stoppedLaneSections = (
474
+ watch: Watch,
475
+ watching: Watching,
476
+ stopped: readonly string[],
477
+ next: WatchState,
478
+ ): { lines: string[]; reportMtimes: Record<string, number | null> } => {
479
+ const sections = stopped.map((lane) => {
480
+ const status = watching.current[lane]?.status
481
+ if (!status) return { lane, lines: [`=== ${lane}: ${terminalSafe(LANE_GONE)}`], mtime: undefined }
482
+ const message = stopMessageOf(watch, lane, status, next)
483
+ return { lane, lines: [`=== ${lane}: ${terminalSafe(status)}`, ...message.lines], mtime: message.reportedMtime }
484
+ })
485
+ const shown = sections.flatMap(({ lane, mtime }) => (mtime === undefined ? [] : [[lane, mtime] as const]))
486
+ return {
487
+ lines: sections.flatMap(({ lines }) => lines),
488
+ reportMtimes: { ...next.reportMtimes, ...Object.fromEntries(shown) },
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Every line of one return, gathered before any is printed, so the output is never cut halfway by a slow lookup, and
494
+ * the state with what it showed: the report times of the stopped lanes and the status lines. A state without status
495
+ * lines prints the whole status block.
496
+ */
497
+ const reportOf = async (
498
+ watch: Watch,
499
+ watching: Watching,
500
+ stopped: string[],
501
+ events: WatchEvent[],
502
+ next: WatchState,
503
+ ): Promise<{ lines: string[]; next: WatchState }> => {
504
+ const stops = stoppedLaneSections(watch, watching, stopped, next)
505
+ const eventLines = await eventLinesOf(watch, events, watching.github)
506
+ const dutyLines = [
507
+ ...blockedDutyLines(stopped, watching.current, watch.texts),
508
+ ...mergeDutyLines(events, watching.lanes, watch.texts),
509
+ ...laneFillDutyLines(events, watch.texts),
510
+ ]
511
+ const status = await statusLinesOf(watch, watching, next)
512
+ const block = statusBlock(status.laneLines, status.coordinator, next.statusLines, status.others)
513
+ return {
514
+ lines: [...stops.lines, ...eventLines, ...dutyLines, ...block, ...status.rest],
515
+ next: {
516
+ ...next,
517
+ reportMtimes: stops.reportMtimes,
518
+ statusLines: rememberedStatusLines(status.laneLines, status.others),
519
+ },
520
+ }
521
+ }
522
+
523
+ const recordGithub = (
524
+ watching: Watching,
525
+ github: GithubRead,
526
+ laneBranches: Record<string, string>,
527
+ now: number,
528
+ ): WatchEvent[] => {
529
+ watching.github = github
530
+ watching.laneBranches = laneBranches
531
+ watching.githubReadAt = now
532
+ return githubEventsOf(watching.state, watching.github, watching.laneBranches)
533
+ }
534
+
535
+ const recordCoordinator = (watching: Watching, coordinator: Coordinator): WatchEvent[] => {
536
+ watching.coordinator = coordinator
537
+ const event = coordinatorWatchEvent(watching.state, readingOf(coordinator.read))
538
+ return event ? [event] : []
539
+ }
540
+
541
+ const recordLaneFills = (watching: Watching, readings: LaneReadings): WatchEvent[] => {
542
+ watching.laneReadings = readings
543
+ return laneFillEvents(watching.state.laneLevels, readings)
544
+ }
545
+
546
+ /** The account `gh` runs as, read once and kept; null while `gh` cannot tell. */
547
+ const ensureOwnLogin = async (watch: Watch, watching: Watching): Promise<string | null> => {
548
+ watching.ownLogin ??= await readOwnLogin(watch.root)
549
+ return watching.ownLogin
550
+ }
551
+
552
+ /**
553
+ * The feedback on the lanes' open pull requests that counts under the project's config, read over REST. Null when it
554
+ * is off, there is no target, the login is unknown or the read failed: the last read then stays, so the next one
555
+ * reaches back over the gap (`feedbackSince`) and no pull request loses its record.
556
+ */
557
+ const readFeedback = async (
558
+ watch: Watch,
559
+ watching: Watching,
560
+ targets: FeedbackTarget[],
561
+ now: number,
562
+ ): Promise<FeedbackPoll | null> => {
563
+ if (watch.feedback === false || targets.length === 0) return null
564
+ const own = await ensureOwnLogin(watch, watching)
565
+ if (own === null) return null
566
+ const items = await readFeedbackItems(watch.root, targets, feedbackSince(watching.state.feedback, now))
567
+ if (!items.ok) return null
568
+ const filter = feedbackFilterOf(own, watch.feedback.ignore, watch.previews)
569
+ return { read: feedbackReadOf(now, targets, items.items, filter), items: items.items }
570
+ }
571
+
572
+ const recordFeedbackRead = (watching: Watching, read: FeedbackRead | null): WatchEvent[] => {
573
+ if (!read) return []
574
+ watching.feedbackRead = read
575
+ return feedbackEvents(watching.state.feedback, read)
576
+ }
577
+
578
+ /**
579
+ * The comments a preview author may have written: none without authors, else the feedback read's when it reached back
580
+ * as far as the last preview read (`feedbackReadCovers`), else a read of their own since then.
581
+ */
582
+ const previewComments = async (
583
+ watch: Watch,
584
+ state: WatchState,
585
+ feedbackItems: FeedbackItem[] | null,
586
+ now: number,
587
+ ): Promise<FeedbackItemsRead> => {
588
+ if (!watch.previews || watch.previews.authors.length === 0) return { ok: true, items: [] }
589
+ if (feedbackItems && feedbackReadCovers(state.feedback.since, state.previews.since))
590
+ return { ok: true, items: feedbackItems }
591
+ return readCommentItems(watch.root, feedbackSince(state.previews, now))
592
+ }
593
+
594
+ /**
595
+ * The deploy previews of `targets`, the lanes' open pull requests as GitHub's list just named them, under the project's
596
+ * config: the configured authors' comments and, with `deployments`, the deployments of each head that has no preview
597
+ * reported yet. Null when previews are off, there is no target or a read failed: the state then stays as it was.
598
+ */
599
+ const readPreviews = async (
600
+ watch: Watch,
601
+ watching: Watching,
602
+ targets: FeedbackTarget[],
603
+ feedbackItems: FeedbackItem[] | null,
604
+ now: number,
605
+ ): Promise<PreviewRead | null> => {
606
+ const previews = watch.previews
607
+ if (!previews || targets.length === 0) return null
608
+ const unreported = previews.deployments ? unreportedTargets(watching.state.previews, targets) : []
609
+ const [comments, deployments] = await Promise.all([
610
+ previewComments(watch, watching.state, feedbackItems, now),
611
+ readDeploymentCandidates(watch.root, unreported, watch.deploy?.environments ?? []),
612
+ ])
613
+ if (!comments.ok || !deployments.ok) return null
614
+ const candidates = [...commentCandidates(comments.items, previews), ...deployments.candidates]
615
+ return previewReadOf(now, targets, candidates)
616
+ }
617
+
618
+ const recordPreviewRead = (watching: Watching, read: PreviewRead | null): WatchEvent[] => {
619
+ if (!read) return []
620
+ watching.previewRead = read
621
+ return previewEvents(watching.state.previews, read)
622
+ }
623
+
624
+ /**
625
+ * Runs the sources whose interval has passed, all at once, each without a shell and within `SOURCE_LIMITS`; a failed
626
+ * run is a result, never a throw, so no source stops the watcher.
627
+ */
628
+ const runDueSources = (watch: Watch, watching: Watching, now: number): Promise<SourceRun[]> => {
629
+ const due = watch.sources.filter((source) => isSourceDue(watching.sourceRanAt[source.name], source.every, now))
630
+ for (const source of due) watching.sourceRanAt[source.name] = now
631
+ return Promise.all(due.map((source) => runSource(source, watch.root, SOURCE_LIMITS)))
632
+ }
633
+
634
+ const recordSourceRuns = (watch: Watch, watching: Watching, runs: SourceRun[]): WatchEvent[] => {
635
+ watching.sourceRuns = runs
636
+ return sourceEvents(watching.state.sources, runs, watch.sources)
637
+ }
638
+
639
+ /** Starts a fetch of the base branch in the background when none runs; the loop does not wait for it. */
640
+ const syncBase = (watch: Watch, watching: Watching) => {
641
+ if (watching.fetchingBase) return
642
+ watching.fetchingBase = true
643
+ void fetchBaseWhenMoved(watch.root, watch.settings.baseBranch).finally(() => {
644
+ watching.fetchingBase = false
645
+ })
646
+ }
647
+
648
+ /**
649
+ * Reads the coordinator's fill, GitHub, the feedback, the previews and the due sources, and stores them only once every read is complete: a SIGTERM
650
+ * while a read is pending saves the state as it was, so no event is taken as reported.
651
+ */
652
+ const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: number): Promise<WatchEvent[]> => {
653
+ syncBase(watch, watching)
654
+ const name = coordinatorNameFor(watch, watching.agents)
655
+ const fills = fillReadFor(watch, name, watching.agents, watching.lanes)
656
+ const laneBranches = laneBranchesOf(watch, watching.lanes)
657
+ const searched = Object.values({ ...watching.state.laneBranches, ...laneBranches })
658
+ const [github, sourceRuns] = await Promise.all([
659
+ readGithub(watch.root, watch.deploy, watch.settings.baseBranch, searched),
660
+ runDueSources(watch, watching, now),
661
+ ])
662
+ const pullRequests = answered(github)?.pullRequests ?? null
663
+ const branches = { ...watching.state.laneBranches, ...laneBranches }
664
+ const targets = feedbackTargets(pullRequests, branches, watching.state.feedback)
665
+ const feedback = await readFeedback(watch, watching, targets, now)
666
+ const previews = pullRequests ? await readPreviews(watch, watching, targets, feedback?.items ?? null, now) : null
667
+ return [
668
+ ...recordGithub(watching, github, laneBranches, now),
669
+ ...recordFeedbackRead(watching, feedback?.read ?? null),
670
+ ...recordPreviewRead(watching, previews),
671
+ ...recordSourceRuns(watch, watching, sourceRuns),
672
+ ...recordCoordinator(watching, { name, read: fills.coordinator }),
673
+ ...recordLaneFills(watching, fills.lanes),
674
+ ]
675
+ }
676
+
677
+ /** The sprint's lane names; the previous ones when the sprint cannot be read, so no lane drops out over it. */
678
+ const sprintLaneNames = (watch: Watch, previous: string[]): string[] => {
679
+ try {
680
+ return parseSprintLanes(readFileSync(watch.sprintFile, 'utf8'), watch.settings.planLanguage).map(
681
+ (lane) => lane.name,
682
+ )
683
+ } catch {
684
+ return previous
685
+ }
686
+ }
687
+
688
+ const lanesFor = (watch: Watch, agents: readonly HerdrAgent[], sprintLanes: readonly string[]): string[] =>
689
+ watch.options.lanes ??
690
+ discoverLanes({
691
+ worktrees: worktreeNames(join(watch.root, watch.settings.worktreeDirectory)),
692
+ agentNames: agentNamesOf(agents),
693
+ sprintLanes,
694
+ })
695
+
696
+ /** Brings back missing plans views beside the coordinator and the board's lanes when `trigger` calls for it; only inside herdr. */
697
+ const keepViews = (watch: Watch, watching: Watching, trigger: EnsureTrigger) => {
698
+ if (!watch.herdr) return
699
+ watching.planViews = keepPlanViews(trigger, watching.planViews, {
700
+ ensure: () => ensurePlanPanes(watch.root, watch.settings, watch.sprintFile),
701
+ log: console.log,
702
+ })
703
+ }
704
+
705
+ /** Takes up lanes that were found or dropped since the last poll and returns the lanes it added; named lanes never change. */
706
+ const followLaneSet = (watch: Watch, watching: Watching): string[] => {
707
+ if (watch.options.lanes !== null) return []
708
+ watching.sprintLanes = sprintLaneNames(watch, watching.sprintLanes)
709
+ const lanes = lanesFor(watch, watching.agents, watching.sprintLanes)
710
+ const change = laneSetChange(watching.lanes, lanes)
711
+ if (isUnchanged(change)) return []
712
+ watching.departed = [...watching.departed, ...departedLanes(change.removed, watching.baseline, watching.current)]
713
+ watching.lanes = lanes
714
+ watching.current = toSnapshot(watching.agents, lanes)
715
+ console.log(laneSetLines(change).join('\n'))
716
+ return change.added
717
+ }
718
+
719
+ /** Reads herdr's agents into the loop; false when herdr did not answer, said once per outage. */
720
+ const readAgents = (watching: Watching): boolean => {
721
+ try {
722
+ watching.agents = listAgents()
723
+ watching.current = toSnapshot(watching.agents, watching.lanes)
724
+ watching.herdrDown = false
725
+ return true
726
+ } catch (error) {
727
+ if (!watching.herdrDown) console.log(`herdr poll failed, still watching: ${errorText(error)}`)
728
+ watching.herdrDown = true
729
+ return false
730
+ }
731
+ }
732
+
733
+ /** One herdr poll: the agents, the lane set, and once per poll the plans views of added lanes and of new agents. */
734
+ const pollLanes = (watch: Watch, watching: Watching) => {
735
+ const previous = watching.current
736
+ if (!readAgents(watching)) return
737
+ const added = followLaneSet(watch, watching)
738
+ const withNewAgent = lanesWithNewAgent(previous, watching.current, watching.lanes)
739
+ keepViews(watch, watching, { kind: ENSURE_TRIGGER.poll, added, withNewAgent })
740
+ watching.baseline = baselineWithNewAgents(watching.baseline, watching.current)
741
+ }
742
+
743
+ /** Prints one report in a single write, so a stream delivers it as one message, then records its state; a single report ends with the notice that the call is over. */
744
+ const report = async (
745
+ watch: Watch,
746
+ watching: Watching,
747
+ stopped: string[],
748
+ events: WatchEvent[],
749
+ next: WatchState,
750
+ ): Promise<WatchState> => {
751
+ watching.reporting = true
752
+ const { lines, next: shown } = await reportOf(watch, watching, stopped, events, next)
753
+ console.log(
754
+ (watch.options.stream ? lines : withSingleReportNotice(lines, monitorToolAvailable(process.env))).join('\n'),
755
+ )
756
+ writePrivate(watch.files.state, JSON.stringify(shown))
757
+ watching.reporting = false
758
+ return shown
759
+ }
760
+
761
+ /**
762
+ * One poll: reports what is due and tells whether to keep watching. Without `--stream` the first
763
+ * report ends the watch; in a stream every reported lane counts as seen and the heartbeat is one line.
764
+ * The state is also saved after every GitHub read, so a watcher killed without SIGTERM (a monitor
765
+ * that expires) loses no first sighting of a pull request.
766
+ */
767
+ const pollOnce = async (watch: Watch, watching: Watching): Promise<boolean> => {
768
+ const { stream, heartbeatMinutes } = watch.options
769
+ const now = Date.now()
770
+ const due = now - watching.githubReadAt >= GITHUB_POLL_MS
771
+ const polledEvents = due ? await pollGithubAndCoordinator(watch, watching, now) : []
772
+ const stopped = [...firedLanes(watching.baseline, watching.current, watching.lanes), ...watching.departed]
773
+ watching.departed = []
774
+ const heartbeat = isHeartbeatDue(watching.quietSince, now, heartbeatMinutes * MINUTE_MS)
775
+ let next = recordedOf(watch, watching)
776
+ const reported = stopped.length > 0 || polledEvents.length > 0
777
+ if (reported) {
778
+ next = await report(watch, watching, stopped, polledEvents, next)
779
+ watching.baseline = { ...watching.current }
780
+ } else if (heartbeat && !stream) {
781
+ next = await report(watch, watching, [], [{ kind: WATCH_EVENT.heartbeat }], { ...next, statusLines: null })
782
+ } else {
783
+ if (heartbeat) console.log(streamHeartbeatLine(clock(watch, now), watching.lanes, watching.current))
784
+ if (due) writePrivate(watch.files.state, JSON.stringify(next))
785
+ watching.baseline = advanceBaseline(watching.baseline, watching.current)
786
+ }
787
+ watching.state = next
788
+ const spoke = reported || heartbeat
789
+ if (spoke) watching.quietSince = Date.now()
790
+ return stream || !spoke
791
+ }
792
+
793
+ /** The loop's state at the start: the found lanes armed from what they show now and from the saved state. */
794
+ const initialWatching = (
795
+ watch: Watch,
796
+ start: { lanes: string[]; sprintLanes: string[]; state: WatchState; armed: LaneSnapshot; agents: HerdrAgent[] },
797
+ ): Watching => ({
798
+ lanes: start.lanes,
799
+ sprintLanes: start.sprintLanes,
800
+ departed: [],
801
+ quietSince: Date.now(),
802
+ state: start.state,
803
+ baseline: armBaseline(start.armed, start.state.seenSeqs),
804
+ current: start.armed,
805
+ agents: start.agents,
806
+ github: null,
807
+ laneBranches: {},
808
+ githubReadAt: 0,
809
+ coordinator: { name: watch.options.coordinator, read: null },
810
+ herdrDown: false,
811
+ reporting: false,
812
+ fetchingBase: false,
813
+ ownLogin: null,
814
+ feedbackRead: null,
815
+ previewRead: null,
816
+ sourceRuns: [],
817
+ sourceRanAt: {},
818
+ laneReadings: {},
819
+ planViews: { failureReported: false },
820
+ })
821
+
822
+ /** Saves the state a stop leaves behind: the one from before an unfinished report or an unread GitHub answer, else the latest. */
823
+ const saveState = (watch: Watch, watching: Watching) => {
824
+ const unread = watching.reporting || watching.github === null
825
+ writePrivate(watch.files.state, JSON.stringify(unread ? watching.state : recordedOf(watch, watching)))
826
+ }
827
+
828
+ const watchLanes = async (watch: Watch) => {
829
+ const takeOver = await takeOverLock(watch.files.lock)
830
+ const state = await readState(watch.files)
831
+ const agents = listAgents()
832
+ const sprintLanes = watch.options.lanes ? [] : sprintLaneNames(watch, [])
833
+ const lanes = lanesFor(watch, agents, sprintLanes)
834
+ const armed = toSnapshot(agents, lanes)
835
+ console.log(armLine(lanes, armed, takeOver, relative(watch.root, watch.sprintFile)))
836
+ if (watch.boardNotice) console.log(watch.boardNotice)
837
+
838
+ const watching = initialWatching(watch, { lanes, sprintLanes, state, armed, agents })
839
+ keepViews(watch, watching, { kind: ENSURE_TRIGGER.start })
840
+ process.on('SIGTERM', () => {
841
+ saveState(watch, watching)
842
+ console.log(stopNoticeOf(watch.files.lock))
843
+ process.exit(0)
844
+ })
845
+
846
+ const kit = await startedKit()
847
+ const skillSnapshot = kit ? readOrchestrateSkill(kit.packageRoot) : null
848
+ const skillLine = () => skillLineFor(kit, skillSnapshot, dirname(watch.files.state))
849
+ while (await pollOnce(watch, watching)) {
850
+ await Bun.sleep(POLL_MS)
851
+ pollLanes(watch, watching)
852
+ if (endedByKitUpdate(kit, { saveState: () => saveState(watch, watching), log: console.log, skillLine })) return
853
+ }
854
+ }
855
+
856
+ const filesOf = (root: string): Files => {
857
+ const directory = ensureWatcherDirectory(root)
858
+ return { state: join(directory, WATCHER_FILES.state), lock: join(directory, WATCHER_FILES.lock) }
859
+ }
860
+
861
+ /** The watch as the command line and the config set it up; throws when the config or the sprint is missing. */
862
+ const watchOf = (options: WatchOptions): Watch => {
863
+ const root = mainCheckout(process.cwd())
864
+ const { config, notes } = readResolvedConfig([worktreeRoot(process.cwd()), root])
865
+ for (const note of notes) console.log(note)
866
+ const settings = plansSettingsOf(config)
867
+ const picked = boardFor(options.boardPath, [root, worktreeRoot(process.cwd())], settings, () =>
868
+ foundLanesOf(root, settings, () => agentNamesOf(listAgents())),
869
+ )
870
+ return {
871
+ root,
872
+ sprintFile: picked.file,
873
+ boardNotice: picked.notice,
874
+ options,
875
+ files: filesOf(root),
876
+ settings,
877
+ deploy: config.watch.deploy,
878
+ feedback: config.watch.feedback,
879
+ previews: config.watch.previews,
880
+ sources: config.watch.sources,
881
+ texts: WATCH_TEXTS[settings.responseLanguage],
882
+ herdr: insideHerdr(settings.adapter, process.env),
883
+ }
884
+ }
885
+
886
+ export const run = async (args: string[]): Promise<number> => {
887
+ let options: WatchOptions
888
+ try {
889
+ options = parseWatchOptions(args)
890
+ } catch (error) {
891
+ // The usage text is the program's own and spans lines; errorText would strip its line breaks.
892
+ console.error(error instanceof Error ? error.message : String(error))
893
+ return USAGE_EXIT
894
+ }
895
+ if (options.sourceDryRun !== null) {
896
+ try {
897
+ return await sourceDryRun(options.sourceDryRun)
898
+ } catch (error) {
899
+ console.error(errorText(error))
900
+ return 1
901
+ }
902
+ }
903
+ let watch: Watch
904
+ try {
905
+ watch = watchOf(options)
906
+ } catch (error) {
907
+ console.error(errorText(error))
908
+ return 1
909
+ }
910
+ try {
911
+ await watchLanes(watch)
912
+ return 0
913
+ } catch (error) {
914
+ console.log(`watcher stopped: ${errorText(error)}`)
915
+ return 1
916
+ } finally {
917
+ releaseLock(watch.files.lock)
918
+ }
919
+ }