jackherd 0.0.1 → 1.0.1

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 +65 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +38 -0
  4. package/README.md +1122 -3
  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,18 @@
1
+ import { kitUpdatedLine, pendingKitUpdate } from '../version/installed-version'
2
+ import type { StartedKit } from '../version/started-kit'
3
+
4
+ type Effects = { saveState: () => void; log: (line: string) => void; skillLine: () => string }
5
+
6
+ /**
7
+ * Ends the watcher when the kit on disk is another version than the one it started with: saves the state
8
+ * as any stop does, then prints the line that wakes the coordinator and the line about the orchestrate
9
+ * skill. True when the watcher should end.
10
+ */
11
+ export const endedByKitUpdate = (kit: StartedKit | null, { saveState, log, skillLine }: Effects): boolean => {
12
+ const update = pendingKitUpdate(kit)
13
+ if (!update) return false
14
+ saveState()
15
+ log(kitUpdatedLine(update))
16
+ log(skillLine())
17
+ return true
18
+ }
@@ -0,0 +1,36 @@
1
+ import type { ContextLevel, ContextReading } from '../context'
2
+ import { isContextLevel, levelStep } from './coordinator-fill'
3
+ import { isRecord } from './json-guards'
4
+
5
+ export const LANE_FILL_EVENT = 'lane'
6
+
7
+ /** Lane → the context level last read for its agent; a lane without an entry counts as `ok`. */
8
+ export type LaneLevels = Record<string, ContextLevel>
9
+
10
+ /** Lane → its agent's fill as one read found it, null when there was no value (no agent, no log, no answer). */
11
+ export type LaneReadings = Record<string, ContextReading | null>
12
+
13
+ export type LaneFillEvent = { kind: typeof LANE_FILL_EVENT; lane: string; level: ContextLevel; share: number }
14
+
15
+ /**
16
+ * One event per lane whose level rose above the stored one, as for the coordinator (`levelStep`). A lane seen
17
+ * for the first time counts as `ok`: the fill is a condition, so one already above a threshold reports once.
18
+ */
19
+ export const laneFillEvents = (previous: LaneLevels, readings: LaneReadings): LaneFillEvent[] =>
20
+ Object.entries(readings).flatMap(([lane, reading]) =>
21
+ reading && levelStep(previous[lane], reading.level).fire
22
+ ? [{ kind: LANE_FILL_EVENT, lane, level: reading.level, share: reading.share }]
23
+ : [],
24
+ )
25
+
26
+ /** The levels after a read: each watched lane at its current level, its stored one without a value; others dropped. */
27
+ export const recordLaneLevels = (previous: LaneLevels, readings: LaneReadings, lanes: readonly string[]): LaneLevels =>
28
+ Object.fromEntries(
29
+ lanes.flatMap((lane) => {
30
+ const level = levelStep(previous[lane], readings[lane]?.level ?? null).level
31
+ return level === null ? [] : [[lane, level]]
32
+ }),
33
+ )
34
+
35
+ export const isLaneLevels = (value: unknown): value is LaneLevels =>
36
+ isRecord(value) && Object.values(value).every(isContextLevel)
@@ -0,0 +1,99 @@
1
+ import { terminalSafe } from '../plan-format/terminal-text'
2
+ import { AGENT_STATUS } from './lane-watch'
3
+
4
+ /** A lane's report file as the watcher found it: its path, when it was last written, its text. */
5
+ export type LaneReportFile = { path: string; mtimeMs: number; text: string }
6
+
7
+ /**
8
+ * What the watcher knows when a lane stops: `reportedMtime` is the change time of the report file it showed last
9
+ * for this lane, null when it never showed one.
10
+ */
11
+ export type LaneStop = { status: string; screen: string; report: LaneReportFile | null; reportedMtime: number | null }
12
+
13
+ /** The lines printed under `=== <lane>: <status>` and the report change time to remember for the lane. */
14
+ export type LaneMessage = { lines: string[]; reportedMtime: number | null }
15
+
16
+ const SCREEN_LINES = 30
17
+ const SCREEN_LINE_LENGTH = 220
18
+ /** How far up from the bottom the input box's rule may sit; a rule higher up belongs to the lane's text. */
19
+ const INPUT_BOX_DEPTH = 15
20
+ /** A line of box-drawing rule only, as Claude Code draws around its input box. */
21
+ const INPUT_BOX_RULE = /^[─▔]{20,}$/
22
+ const REPORT_LINES = 15
23
+ const REPORT_CHARACTERS = 1500
24
+
25
+ /** The last `SCREEN_LINES` lines, each made safe and cut. */
26
+ const lastScreenLines = (lines: readonly string[]): string =>
27
+ lines
28
+ .slice(-SCREEN_LINES)
29
+ .map((line) => terminalSafe(line).slice(0, SCREEN_LINE_LENGTH))
30
+ .join('\n')
31
+
32
+ /**
33
+ * The screen without the input box and what follows it (the status bar, the mode line, the agent list): everything
34
+ * from the first rule line in the bottom `INPUT_BOX_DEPTH` lines on. Without such a line the screen stays whole. The
35
+ * last `SCREEN_LINES` lines above are kept, each made safe and cut.
36
+ */
37
+ export const cutScreen = (screen: string): string => {
38
+ const lines = screen.split('\n')
39
+ const ruleIndex = lines.findIndex(
40
+ (line, index) => index >= lines.length - INPUT_BOX_DEPTH && INPUT_BOX_RULE.test(line.trim()),
41
+ )
42
+ return lastScreenLines(ruleIndex === -1 ? lines : lines.slice(0, ruleIndex))
43
+ }
44
+
45
+ /** The head of a report file: its first lines, made safe, within a character budget. */
46
+ export const reportExcerpt = (text: string): string =>
47
+ text.split('\n').slice(0, REPORT_LINES).map(terminalSafe).join('\n').slice(0, REPORT_CHARACTERS)
48
+
49
+ /** What `reportExcerpt` leaves out, in lines or else characters; null when the excerpt is the whole report. */
50
+ const reportRest = (text: string): string | null => {
51
+ const lines = text.split('\n')
52
+ if (lines.length > REPORT_LINES) return `+${lines.length - REPORT_LINES} lines`
53
+ const shown = lines.map(terminalSafe).join('\n').length
54
+ return shown > REPORT_CHARACTERS ? `+${shown - REPORT_CHARACTERS} characters` : null
55
+ }
56
+
57
+ const isNewReport = (report: LaneReportFile | null, reportedMtime: number | null): report is LaneReportFile =>
58
+ report !== null && (reportedMtime === null || report.mtimeMs > reportedMtime)
59
+
60
+ /**
61
+ * What a stopped lane shows the coordinator: a blocked lane its whole screen, since the dialog is the news; else a
62
+ * report the lane wrote since the last one shown (its path and head), else the screen without the input box.
63
+ */
64
+ export const laneMessage = ({ status, screen, report, reportedMtime }: LaneStop): LaneMessage => {
65
+ if (status === AGENT_STATUS.blocked) return { lines: [lastScreenLines(screen.split('\n'))], reportedMtime }
66
+ if (isNewReport(report, reportedMtime)) {
67
+ const path = terminalSafe(report.path)
68
+ const rest = reportRest(report.text)
69
+ const more = rest === null ? [] : [`… ${rest}, read ${path}`]
70
+ return { lines: [`report ${path}`, reportExcerpt(report.text), ...more], reportedMtime: report.mtimeMs }
71
+ }
72
+ return { lines: [cutScreen(screen)], reportedMtime }
73
+ }
74
+
75
+ /** The lines a report remembers for the next comparison: the lane lines, then the lines after the coordinator's. */
76
+ export const rememberedStatusLines = (laneLines: readonly string[], otherLines: readonly string[]): string[] => [
77
+ ...laneLines,
78
+ ...otherLines,
79
+ ]
80
+
81
+ /**
82
+ * The status block of a report: whole when a lane line or a line after the coordinator's (`otherLines`) differs from
83
+ * `previous` (`rememberedStatusLines` of the last report) or nothing was reported before (null, which a heartbeat
84
+ * report passes to show it whole), else one line that says so, with the coordinator's line, which changes with every
85
+ * read.
86
+ */
87
+ export const statusBlock = (
88
+ laneLines: readonly string[],
89
+ coordinatorLine: string,
90
+ previous: readonly string[] | null,
91
+ otherLines: readonly string[],
92
+ ): string[] => {
93
+ const compared = rememberedStatusLines(laneLines, otherLines)
94
+ const unchanged =
95
+ previous !== null && previous.length === compared.length && previous.every((line, i) => line === compared[i])
96
+ return unchanged
97
+ ? [`status: unchanged · ${coordinatorLine}`]
98
+ : ['status:', ...laneLines, coordinatorLine, ...otherLines]
99
+ }
@@ -0,0 +1,44 @@
1
+ import { dirname } from 'node:path'
2
+ import type { LaneProgress, SprintLane } from '../plan-format'
3
+ import { boardRowsOf } from '../plans/board-rows'
4
+ import { baseReaderOf, gitStatusOf } from '../plans/git'
5
+ import { laneWorkOf, readPlans, textAtBase } from '../plans/lane-plans'
6
+ import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from '../plans/settings'
7
+ import { workedLaneProgress } from '../plans/worked-phases'
8
+
9
+ /**
10
+ * The progress the watcher reports for a lane, from the same reading as the plans view: the base ref's plans, the
11
+ * phases the lane works on from its worktree (also without a board row), so both name the same open phase.
12
+ */
13
+ export const watchedLaneProgress = async (
14
+ root: string,
15
+ settings: PlansSettings,
16
+ boardFile: string,
17
+ lane: SprintLane,
18
+ ): Promise<LaneProgress> => {
19
+ const baseRef = baseRefOf(settings)
20
+ const worktree = worktreeOf(root, settings, lane.name)
21
+ const sources = {
22
+ root,
23
+ settings,
24
+ base: baseReaderOf(root, baseRef),
25
+ sprintDirectory: dirname(boardRepoPathOf(root, settings, boardFile)),
26
+ }
27
+ const work = laneWorkOf(worktree, await gitStatusOf(worktree, baseRef))
28
+ const { plans } = await readPlans(lane, sources, work, [])
29
+ return workedLaneProgress(lane, plans)
30
+ }
31
+
32
+ /**
33
+ * The rows the checkout's board file (`checkoutText`) has beyond the board at the base ref, counted as the plans view
34
+ * adds them; 0 when the base ref lacks the board, which is then read from the checkout as a whole.
35
+ */
36
+ export const boardCheckoutRows = (
37
+ root: string,
38
+ settings: PlansSettings,
39
+ boardFile: string,
40
+ checkoutText: string,
41
+ ): number => {
42
+ const atBase = textAtBase(baseReaderOf(root, baseRefOf(settings)), boardRepoPathOf(root, settings, boardFile))
43
+ return atBase === null ? 0 : boardRowsOf(atBase, checkoutText, settings.planLanguage).rowsFromCheckout
44
+ }
@@ -0,0 +1,53 @@
1
+ import { closeSync, lstatSync, openSync, readSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { claudePath, HANDOVER_DIRECTORY } from '../claude/paths'
4
+ import type { LaneReportFile } from './lane-message'
5
+
6
+ /** A lane name that stays a single file name: no separator, no `..`. */
7
+ const SAFE_LANE_NAME = /^(?!\.\.?$)[A-Za-z0-9._-]+$/
8
+ /** The head of a report the watcher reads; it prints far less, and a huge file must not stall it. */
9
+ const READ_LIMIT_BYTES = 8192
10
+
11
+ /**
12
+ * Where a lane writes its report, inside its worktree: `.claude/handover/<lane>-report.md`. Null for a lane name
13
+ * that could leave that directory: names come from board rows and herdr agents.
14
+ */
15
+ export const laneReportPath = (worktree: string, lane: string): string | null =>
16
+ SAFE_LANE_NAME.test(lane) ? join(worktree, claudePath(HANDOVER_DIRECTORY), `${lane}-report.md`) : null
17
+
18
+ /** The change time of a regular file at `path`, not followed through a symlink; null for anything else. */
19
+ const regularFileMtime = (path: string): number | null => {
20
+ try {
21
+ const stats = lstatSync(path)
22
+ return stats.isFile() ? stats.mtimeMs : null
23
+ } catch {
24
+ return null
25
+ }
26
+ }
27
+
28
+ const readHead = (path: string): string => {
29
+ const descriptor = openSync(path, 'r')
30
+ try {
31
+ const buffer = Buffer.alloc(READ_LIMIT_BYTES)
32
+ return buffer.subarray(0, readSync(descriptor, buffer, 0, READ_LIMIT_BYTES, 0)).toString('utf8')
33
+ } finally {
34
+ closeSync(descriptor)
35
+ }
36
+ }
37
+
38
+ /** The change time of the report file; null when there is none or it is no regular file. */
39
+ export const reportMtime = (path: string | null): number | null => (path === null ? null : regularFileMtime(path))
40
+
41
+ /**
42
+ * The report file with its change time and the first `READ_LIMIT_BYTES` of its text; null when it is missing,
43
+ * unreadable, or no regular file (a symlink, a directory, a FIFO).
44
+ */
45
+ export const readLaneReport = (path: string | null): LaneReportFile | null => {
46
+ const mtimeMs = reportMtime(path)
47
+ if (path === null || mtimeMs === null) return null
48
+ try {
49
+ return { path, mtimeMs, text: readHead(path) }
50
+ } catch {
51
+ return null
52
+ }
53
+ }
@@ -0,0 +1,270 @@
1
+ import { CONTEXT_LEVEL, type ContextLevel, contextLabel } from '../context'
2
+ import { currentPhase, isTickedUnmerged, type LaneProgress } from '../plan-format'
3
+ import { terminalSafe } from '../plan-format/terminal-text'
4
+ import type { ChecksState } from '../plans/pull-requests'
5
+ import type { CoordinatorRead } from './coordinator-fill'
6
+ import { type FeedbackEvent, firstLine } from './feedback'
7
+ import type { LaneFillEvent } from './lane-fill'
8
+ import { AGENT_STATUS, type LaneSnapshot, NO_AGENT } from './lane-watch'
9
+ import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
10
+ import { SOURCE_FIELD } from './source-fields'
11
+ import { type FieldChange, lineText, type SourceEvent } from './sources'
12
+ import type { WatchTexts } from './texts'
13
+ import {
14
+ type DeployEvent,
15
+ isPullRequestEvent,
16
+ type PullRequestEvent,
17
+ WATCH_EVENT,
18
+ type WatchEvent,
19
+ } from './watch-events'
20
+ import type { TakeOver } from './watcher-lock'
21
+
22
+ export type LaneReport = {
23
+ lane: string
24
+ status: string
25
+ branch: string | null
26
+ /** `feedback` counts the feedback reported since the pull request's last push. */
27
+ pullRequest: { number: number; checks: ChecksState; feedback: number } | null
28
+ progress: LaneProgress | null
29
+ /** The context level last read for the lane's agent; null without a value. */
30
+ context: ContextLevel | null
31
+ }
32
+
33
+ /** The board as the coordinator line reminds of it: its term and the rows only the checkout's board file has. */
34
+ export type BoardReminder = { term: string; checkoutRows: number }
35
+
36
+ const UNKNOWN = 'unknown'
37
+ const REPORT_TEXT_LENGTH = 160
38
+ const FIELD_VALUE_LENGTH = 60
39
+ const EMPTY_VALUE = '(empty)'
40
+ const CHANGE_ARROW = '→'
41
+ const VALUE_ARROW = '->'
42
+ const FEEDBACK_ENTRY_LIMIT = 10
43
+ const REPORT_INDENT = ' '
44
+ const SHORT_SHA_LENGTH = 7
45
+ const NO_REMINDER: BoardReminder = { term: '', checkoutRows: 0 }
46
+ const OTHER_PULL_REQUEST_LIMIT = 5
47
+
48
+ const pullRequestSegment = (pullRequest: LaneReport['pullRequest'], texts: WatchTexts): string => {
49
+ if (!pullRequest) return texts.noPullRequest
50
+ const feedback = pullRequest.feedback ? ` · ${pullRequest.feedback} ${WATCH_EVENT.feedback}` : ''
51
+ return `PR #${pullRequest.number} ${pullRequest.checks}${feedback}`
52
+ }
53
+
54
+ const phaseSegment = (progress: LaneProgress | null, texts: WatchTexts): string => {
55
+ if (!progress) return texts.noPlan
56
+ if (!progress.current) return progress.total > 0 ? texts.planFinished : texts.noPlan
57
+ const phase = currentPhase(progress)
58
+ if (!phase) return texts.noPlan
59
+ const title = phase.title ? ` ${terminalSafe(phase.title)}` : ''
60
+ if (isTickedUnmerged(phase)) return `${texts.phase} ${terminalSafe(phase.id)}${title} ${texts.tickedUnmerged}`
61
+ const startsAfter = progress.steps[progress.current.stepIndex]?.step.startsAfter
62
+ const after = startsAfter ? ` (${texts.startsAfter}: ${terminalSafe(startsAfter)})` : ''
63
+ return `${texts.openPhase} ${terminalSafe(phase.id)}${title}${after}`
64
+ }
65
+
66
+ /** `context warn` or `context alert`; nothing at `ok` or without a value, so the line stays the same while the share moves. */
67
+ const contextSegment = (level: ContextLevel | null): string[] =>
68
+ level === null || level === CONTEXT_LEVEL.ok ? [] : [`context ${level}`]
69
+
70
+ /**
71
+ * One line per lane, in the words of `texts`: `<lane> <status> [· context warn|alert] · <branch>|kein Branch ·
72
+ * PR #<n> <checks>|kein PR · offene Phase …|Phase … abgehakt, nicht gemergt|Plan fertig|kein Plan` in German.
73
+ */
74
+ export const laneReportLine = (report: LaneReport, texts: WatchTexts): string =>
75
+ [
76
+ `${terminalSafe(report.lane)} ${terminalSafe(report.status)}`,
77
+ ...contextSegment(report.context),
78
+ report.branch ? terminalSafe(report.branch) : texts.noBranch,
79
+ pullRequestSegment(report.pullRequest, texts),
80
+ phaseSegment(report.progress, texts),
81
+ ].join(' · ')
82
+
83
+ /** The lane, or `branch <name>` for an own pull request without a lane, its name cut like other third-party text. */
84
+ const pullRequestOwner = (event: PullRequestEvent): string =>
85
+ event.lane === null ? `branch ${terminalSafe(event.branch).slice(0, REPORT_TEXT_LENGTH)}` : terminalSafe(event.lane)
86
+
87
+ /** The lane, `#<number>` for an own pull request without a lane, or `manual`. */
88
+ const deployOwner = (event: DeployEvent): string => {
89
+ if (event.lane !== null) return terminalSafe(event.lane)
90
+ return event.pullRequest === undefined ? 'manual' : `#${event.pullRequest}`
91
+ }
92
+
93
+ /**
94
+ * `other PRs: #<n> passed · #<m> none`, in the words of `texts`: the open pull requests among `own` (the user's own without a
95
+ * lane), newest first, at most `OTHER_PULL_REQUEST_LIMIT` and `+<k>` for the rest; null when there is none.
96
+ */
97
+ export const otherPullRequestsLine = (
98
+ pullRequests: readonly WatchedPullRequest[],
99
+ own: readonly number[],
100
+ texts: WatchTexts,
101
+ ): string | null => {
102
+ const open = pullRequests
103
+ .filter(({ number, state }) => state === PULL_REQUEST_STATE.open && own.includes(number))
104
+ .sort((left, right) => right.number - left.number)
105
+ if (open.length === 0) return null
106
+ const shown = open.slice(0, OTHER_PULL_REQUEST_LIMIT).map(({ number, checks }) => `#${number} ${checks}`)
107
+ const more = open.length - OTHER_PULL_REQUEST_LIMIT
108
+ return `${texts.otherPullRequests}: ${[...shown, ...(more > 0 ? [`+${more}`] : [])].join(' · ')}`
109
+ }
110
+
111
+ /**
112
+ * The header line of one event: `=== merged #<number> (lane)`, `=== checks failed #<number> (lane|branch <name>)`,
113
+ * `=== deploy success run 9 (push, lane|#<number>) target stage`, `=== heartbeat`, `=== gh unavailable: <reason>`,
114
+ * `=== coordinator warn 62 %`, `=== lane <name> alert 47 %`, `=== preview #<number> (lane) <url> · <sha7>`.
115
+ * `target` is the deploy environment read from the run, null when none was found.
116
+ */
117
+ export const watchEventLine = (event: WatchEvent, target: string | null): string => {
118
+ if (event.kind === WATCH_EVENT.deploy) {
119
+ const by = deployOwner(event)
120
+ const outcome = terminalSafe(event.conclusion ?? UNKNOWN)
121
+ const where = terminalSafe(target ?? UNKNOWN)
122
+ return `=== deploy ${outcome} run ${event.runId} (${terminalSafe(event.trigger)}, ${by}) target ${where}`
123
+ }
124
+ if (event.kind === WATCH_EVENT.ghUnavailable) return `=== ${event.kind}: ${terminalSafe(event.reason)}`
125
+ if (event.kind === WATCH_EVENT.preview)
126
+ return `=== ${event.kind} #${event.number} (${terminalSafe(event.lane)}) ${terminalSafe(event.url)} · ${terminalSafe(event.headCommit.slice(0, SHORT_SHA_LENGTH))}`
127
+ if (event.kind === WATCH_EVENT.laneFill)
128
+ return `=== ${event.kind} ${terminalSafe(event.lane)} ${event.level} ${contextLabel(event.share)}`
129
+ if (event.kind === WATCH_EVENT.coordinator) return `=== ${event.kind} ${event.level} ${contextLabel(event.share)}`
130
+ if (isPullRequestEvent(event)) return `=== ${event.kind} #${event.number} (${pullRequestOwner(event)})`
131
+ if ('number' in event) return `=== ${event.kind} #${event.number} (${terminalSafe(event.lane)})`
132
+ return `=== ${event.kind}`
133
+ }
134
+
135
+ /**
136
+ * The lines of a feedback event: `=== feedback #<number> (lane) <author> <label> · <k> new`, then one line per entry,
137
+ * `<path>:<line> <first line>` or `<first line>`, cut to a length and to `FEEDBACK_ENTRY_LIMIT` entries.
138
+ */
139
+ export const feedbackEventLines = (event: FeedbackEvent): string[] => {
140
+ const header = `=== ${event.kind} #${event.number} (${terminalSafe(event.lane)}) ${terminalSafe(event.author)} ${terminalSafe(event.label)} · ${event.entries.length} new`
141
+ const entries = event.entries.slice(0, FEEDBACK_ENTRY_LIMIT).map(({ place, text }) => {
142
+ const line = terminalSafe(place === null ? text : `${place} ${text}`).slice(0, REPORT_TEXT_LENGTH)
143
+ return `${REPORT_INDENT}${line}`
144
+ })
145
+ const more = event.entries.length - FEEDBACK_ENTRY_LIMIT
146
+ return [header, ...entries, ...(more > 0 ? [`${REPORT_INDENT}… ${more} more`] : [])]
147
+ }
148
+
149
+ /**
150
+ * A field value on one line (`lineText`), cut to 60, `(empty)` when empty; an arrow inside it becomes `->`, so a value
151
+ * cannot pass for a further change.
152
+ */
153
+ const fieldValueText = (value: string): string => {
154
+ const text = lineText(value).replaceAll(CHANGE_ARROW, VALUE_ARROW).slice(0, FIELD_VALUE_LENGTH)
155
+ return text === '' ? EMPTY_VALUE : text
156
+ }
157
+
158
+ /** `status In progress → Ready, assignee (empty) → Kim`. */
159
+ const fieldChangesText = (changes: readonly FieldChange[]): string =>
160
+ changes
161
+ .map(
162
+ ({ field, from, to }) => `${terminalSafe(field)} ${fieldValueText(from)} ${CHANGE_ARROW} ${fieldValueText(to)}`,
163
+ )
164
+ .join(', ')
165
+
166
+ /** A field change shows the body only when the body is one of the changed fields. */
167
+ const showsBody = (changes: readonly FieldChange[] | undefined): boolean =>
168
+ changes === undefined || changes.some(({ field }) => field === SOURCE_FIELD.body)
169
+
170
+ /**
171
+ * The lines of a source event: `=== source <name> [(lane)] <id> <title> · new|updated`, with `reportOn`
172
+ * `· updated: <field> <old> → <new>, …` (no body line unless the body changed), then the first line of the body
173
+ * with text that is no Markdown heading (`firstLine`), cut to a length, and the checked URL, each only when there is one; a failure is
174
+ * `=== source <name> [(lane)] failed: <reason>`, the entries past the report limit `=== source <name> [(lane)] +<n> more`. Everything but the reason comes from the external service.
175
+ */
176
+ export const sourceEventLines = (event: SourceEvent): string[] => {
177
+ const lane = event.lane === null ? '' : ` (${terminalSafe(event.lane)})`
178
+ const head = `=== ${event.kind} ${terminalSafe(event.name)}${lane}`
179
+ if ('failure' in event) return [`${head} failed: ${event.failure}`]
180
+ if ('more' in event) return [`${head} +${event.more} more`]
181
+ const id = terminalSafe(event.id).slice(0, REPORT_TEXT_LENGTH)
182
+ const title = terminalSafe(event.title).slice(0, REPORT_TEXT_LENGTH)
183
+ const body = showsBody(event.changes) ? terminalSafe(firstLine(event.body)).slice(0, REPORT_TEXT_LENGTH) : ''
184
+ const changes = event.changes ? `: ${fieldChangesText(event.changes)}` : ''
185
+ return [
186
+ `${head} ${id}${title ? ` ${title}` : ''} · ${event.change}${changes}`,
187
+ ...(body ? [`${REPORT_INDENT}${body}`] : []),
188
+ ...(event.url ? [`${REPORT_INDENT}${terminalSafe(event.url)}`] : []),
189
+ ]
190
+ }
191
+
192
+ /** The status line while a source fails; `since` is the start of the streak as a clock time. */
193
+ export const sourceFailingStatusLine = (name: string, since: string): string =>
194
+ `${WATCH_EVENT.source} ${terminalSafe(name)} failing since ${since}`
195
+
196
+ const coordinatorValue = (name: string | null, read: CoordinatorRead | null, texts: WatchTexts): string => {
197
+ if (name === null) return `coordinator ${texts.noName}`
198
+ if (read === null) return `coordinator ${terminalSafe(name)} ${texts.noValue}`
199
+ const value =
200
+ 'reading' in read
201
+ ? `${contextLabel(read.reading.share)} ${read.reading.level}${read.note ? ` (${terminalSafe(read.note)})` : ''}`
202
+ : `${texts.noValue} (${terminalSafe(read.reason)})`
203
+ return `coordinator ${terminalSafe(name)} ${value}`
204
+ }
205
+
206
+ /**
207
+ * `coordinator <name> 62 % warn`, `coordinator <name> kein Wert (<reason>)` or `coordinator kein Name` in German;
208
+ * while the board has rows only in the checkout, `· board: 2 rows only in the checkout` follows.
209
+ */
210
+ export const coordinatorStatusLine = (
211
+ name: string | null,
212
+ read: CoordinatorRead | null,
213
+ texts: WatchTexts,
214
+ board: BoardReminder = NO_REMINDER,
215
+ ): string => {
216
+ const line = coordinatorValue(name, read, texts)
217
+ return board.checkoutRows > 0 ? `${line} · ${texts.checkoutRows(terminalSafe(board.term), board.checkoutRows)}` : line
218
+ }
219
+
220
+ /** The status line while `gh` is unavailable; `since` is the start of the outage as a clock time. */
221
+ export const ghUnavailableStatusLine = (since: string): string => `${WATCH_EVENT.ghUnavailable} since ${since}`
222
+
223
+ type MergeEvent = PullRequestEvent & { kind: typeof WATCH_EVENT.merged }
224
+
225
+ const isMerge = (event: WatchEvent): event is MergeEvent => event.kind === WATCH_EVENT.merged
226
+
227
+ const mergeDuty = (merged: MergeEvent, lane: string, texts: WatchTexts): string => {
228
+ const duty = lane === merged.lane ? texts.mergedLaneDuty : texts.otherLaneDuty
229
+ return `${texts.afterMerge(merged.number)}: ${terminalSafe(lane)} ${duty}`
230
+ }
231
+
232
+ /**
233
+ * What a merge asks of every watched lane (`orchestrate` § 6), one line per lane and merge: the
234
+ * merged lane goes on or closes, every other lane rebases and goes on or waits with a reason. The
235
+ * watcher only names the duty; the coordinator decides and prompts the lanes.
236
+ */
237
+ export const mergeDutyLines = (events: readonly WatchEvent[], lanes: readonly string[], texts: WatchTexts): string[] =>
238
+ events.filter(isMerge).flatMap((merged) => lanes.map((lane) => mergeDuty(merged, lane, texts)))
239
+
240
+ const isLaneAlert = (event: WatchEvent): event is LaneFillEvent =>
241
+ event.kind === WATCH_EVENT.laneFill && event.level === CONTEXT_LEVEL.alert
242
+
243
+ /** What a lane whose context reached `alert` asks of the coordinator (`orchestrate` § 8): one line per such lane. */
244
+ export const laneFillDutyLines = (events: readonly WatchEvent[], texts: WatchTexts): string[] =>
245
+ events
246
+ .filter(isLaneAlert)
247
+ .map(({ lane }) => `${terminalSafe(lane)} context ${CONTEXT_LEVEL.alert}: ${texts.laneAlertDuty}`)
248
+
249
+ /**
250
+ * What a blocked lane asks of the coordinator (`orchestrate` § 4), one line per stopped lane that
251
+ * sits in `blocked`: vet the question, answer it in the lane or ask the user, never hand the pane
252
+ * over. Lanes that stopped as `done` or `idle`, or left the listing, get none.
253
+ */
254
+ export const blockedDutyLines = (stopped: readonly string[], current: LaneSnapshot, texts: WatchTexts): string[] =>
255
+ stopped
256
+ .filter((lane) => current[lane]?.status === AGENT_STATUS.blocked)
257
+ .map((lane) => `${terminalSafe(lane)} ${AGENT_STATUS.blocked}: ${texts.blockedDuty}`)
258
+
259
+ const takeOverNote = ({ replaced, stillRunning }: TakeOver): string => {
260
+ if (stillRunning !== null) return ` (watcher ${stillRunning} did not stop)`
261
+ return replaced === null ? '' : ` (replaced watcher ${replaced})`
262
+ }
263
+
264
+ /** The first line of a run: `armed: <lane>=<state> … board <path>`, with a note when an older watcher was replaced or did not stop. */
265
+ export const armLine = (lanes: readonly string[], armed: LaneSnapshot, takeOver: TakeOver, board: string): string => {
266
+ const states =
267
+ lanes.map((lane) => `${terminalSafe(lane)}=${terminalSafe(armed[lane]?.status ?? NO_AGENT)}`).join(' ') ||
268
+ 'no lanes'
269
+ return `armed: ${states}${takeOverNote(takeOver)} board ${terminalSafe(board)}`
270
+ }
@@ -0,0 +1,41 @@
1
+ import type { WatchedPullRequest } from './pull-requests'
2
+
3
+ /**
4
+ * Branches in one search. GitHub documents a limit of 256 characters for a search and counts qualifiers
5
+ * differently from what was measured (16 branches, 506 characters, accepted). The cut is by count, not by length:
6
+ * ten names of 55 characters make a longer text than was ever measured.
7
+ */
8
+ export const BRANCHES_PER_SEARCH = 10
9
+
10
+ /** Characters of a branch name that `head:` takes literally; anything else is left out of the search. */
11
+ const SEARCHABLE_BRANCH = /^[A-Za-z0-9._/-]+$/
12
+
13
+ /**
14
+ * The branches worth a search: no base branch (every merged lane ends there), no duplicates, no name the search
15
+ * syntax could misread (quotes, colons, brackets; `head:` with such a name was never seen to match). Such a lane
16
+ * keeps its merge detection from the light list and only goes without a check status.
17
+ */
18
+ export const searchableBranches = (branches: readonly string[], baseBranch: string): string[] =>
19
+ [...new Set(branches)].filter((branch) => branch !== baseBranch && SEARCHABLE_BRANCH.test(branch)).sort()
20
+
21
+ /** The branches cut into batches of at most `BRANCHES_PER_SEARCH`. */
22
+ export const branchBatches = (branches: readonly string[]): string[][] =>
23
+ Array.from({ length: Math.ceil(branches.length / BRANCHES_PER_SEARCH) }, (_, index) =>
24
+ branches.slice(index * BRANCHES_PER_SEARCH, (index + 1) * BRANCHES_PER_SEARCH),
25
+ )
26
+
27
+ /** `head:a head:b`: GitHub combines several `head:` qualifiers with or. */
28
+ export const searchTextOf = (branches: readonly string[]): string =>
29
+ branches.map((branch) => `head:${branch}`).join(' ')
30
+
31
+ /**
32
+ * The pull requests whose branch is one of `branches`, exactly. `head:` matches a prefix and ignores case, so
33
+ * `head:fix/order-opening` also finds `fix/order-opening-hours`; a pull request of a foreign branch must not pass.
34
+ */
35
+ export const onBranches = (
36
+ pullRequests: readonly WatchedPullRequest[],
37
+ branches: readonly string[],
38
+ ): WatchedPullRequest[] => {
39
+ const wanted = new Set(branches)
40
+ return pullRequests.filter((pullRequest) => wanted.has(pullRequest.branch))
41
+ }
@@ -0,0 +1,46 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { fetchBase } from '../plans/fetch'
3
+ import { BASE_REMOTE } from '../plans/settings'
4
+ import { baseFetchNeeded, remoteTipOf } from './base-tip'
5
+
6
+ const GIT_ASYNC_TIMEOUT_MS = 30_000
7
+
8
+ /** Runs git without blocking, killed after the timeout; its stdout, or null when it failed. Never rejects. */
9
+ const gitAsync = async (cwd: string, args: string[]): Promise<string | null> => {
10
+ try {
11
+ const child = Bun.spawn(['git', '-C', cwd, ...args], {
12
+ stdout: 'pipe',
13
+ stderr: 'ignore',
14
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
15
+ })
16
+ const timer = setTimeout(() => child.kill(), GIT_ASYNC_TIMEOUT_MS)
17
+ const [stdout, exitCode] = await Promise.all([new Response(child.stdout).text(), child.exited])
18
+ clearTimeout(timer)
19
+ return exitCode === 0 ? stdout : null
20
+ } catch {
21
+ return null
22
+ }
23
+ }
24
+
25
+ const git = (cwd: string, args: string[]): string | null => {
26
+ const result = Bun.spawnSync(['git', '-C', cwd, ...args], { stdout: 'pipe', stderr: 'pipe' })
27
+ return result.exitCode === 0 ? result.stdout.toString().trim() : null
28
+ }
29
+
30
+ /** The branch checked out in a worktree, or null for a missing worktree or a detached head. */
31
+ export const branchOf = (worktree: string): string | null =>
32
+ existsSync(worktree) ? git(worktree, ['branch', '--show-current']) || null : null
33
+
34
+ /**
35
+ * Fetches the base branch when the remote moved past `origin/<branch>`, so every reader of that ref
36
+ * (the plans view watches `FETCH_HEAD`) sees a merge as soon as the watcher does. Asks the remote
37
+ * first, so an unchanged base costs no fetch; a failure leaves the ref as it is.
38
+ */
39
+ export const fetchBaseWhenMoved = async (root: string, branch: string): Promise<void> => {
40
+ const [remote, local] = await Promise.all([
41
+ gitAsync(root, ['ls-remote', BASE_REMOTE, `refs/heads/${branch}`]),
42
+ gitAsync(root, ['rev-parse', '--verify', '--quiet', `${BASE_REMOTE}/${branch}^{commit}`]),
43
+ ])
44
+ if (!baseFetchNeeded(remote === null ? null : remoteTipOf(remote, branch), local?.trim() || null)) return
45
+ await fetchBase(root, BASE_REMOTE, branch)
46
+ }