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,60 @@
1
+ import { homedir } from 'node:os'
2
+ import { TERMINAL_ADAPTER } from '../config/schema'
3
+ import { laneStatusSourceFor } from '../terminal'
4
+ import { startedKit } from '../version/started-kit'
5
+ import { boardFor, foundLanesOf } from './board-pick'
6
+ import { ensurePlanPanes } from './ensure-panes'
7
+ import { mainCheckout, worktreeRoot } from './git'
8
+ import { fetchPullRequests } from './github'
9
+ import { runLive } from './live'
10
+ import { parseOptions } from './options'
11
+ import { isSupervisedChild } from './restart-decision'
12
+ import { clockLocaleOf, readSettings } from './settings'
13
+ import { loadSnapshot, type Source } from './snapshot'
14
+ import { superviseView } from './supervisor'
15
+ import { frameText, paneWidth, renderSnapshot, startSelection } from './view'
16
+
17
+ const printOnce = async (source: Source): Promise<void> => {
18
+ const snapshot = await loadSnapshot(source, await fetchPullRequests(source.root))
19
+ const screen = renderSnapshot(
20
+ snapshot,
21
+ startSelection(snapshot, source.options),
22
+ snapshot.errors,
23
+ paneWidth(),
24
+ clockLocaleOf(source.settings),
25
+ )
26
+ console.log(frameText(screen, Boolean(process.stdout.isTTY)))
27
+ }
28
+
29
+ /**
30
+ * `jh plans [<board.md>] [--lane <name>] [--once | --ensure]`: a live view of a board's lanes and their plan
31
+ * progress; `--ensure` instead brings back the missing views beside the coordinator and each lane.
32
+ * `home` is where the global config is looked up when the project has none.
33
+ */
34
+ export const run = async (args: string[], home: string = homedir()): Promise<number> => {
35
+ try {
36
+ const options = parseOptions(args)
37
+ const root = mainCheckout(process.cwd())
38
+ const settings = readSettings([worktreeRoot(process.cwd()), root], home)
39
+ const terminal = laneStatusSourceFor(settings.adapter)
40
+ const picked = boardFor(options.boardPath, [root, worktreeRoot(process.cwd())], settings, () =>
41
+ foundLanesOf(root, settings, () => terminal.listAgents().map((agent) => agent.name)),
42
+ )
43
+ if (options.ensure) {
44
+ const lines =
45
+ settings.adapter === TERMINAL_ADAPTER.herdr
46
+ ? ensurePlanPanes(root, settings, picked.file)
47
+ : ['the terminal adapter is not herdr: no panes to bring back']
48
+ for (const line of lines.length > 0 ? lines : ['every plans view is running']) console.log(line)
49
+ return 0
50
+ }
51
+ const source = { root, sprintFile: picked.file, options, settings, terminal, boardNotice: picked.notice }
52
+ if (options.once || !process.stdout.isTTY) await printOnce(source)
53
+ else if (isSupervisedChild(process.env)) runLive(source)
54
+ else return await superviseView(args, await startedKit())
55
+ return 0
56
+ } catch (error) {
57
+ console.error(error instanceof Error ? error.message : String(error))
58
+ return 1
59
+ }
60
+ }
@@ -0,0 +1,71 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { basename, join } from 'node:path'
3
+ import { defaultCoordinatorName } from '../context/coordinator-name'
4
+ import { parseSprintLanes } from '../plan-format'
5
+ import { parseAgentList, runHerdr } from '../terminal/herdr'
6
+ import { errorText } from './error-text'
7
+ import {
8
+ agentPanesOf,
9
+ coordinatorPaneOf,
10
+ foregroundOf,
11
+ missingCoordinatorNote,
12
+ type PaneAction,
13
+ type PaneState,
14
+ paneListOf,
15
+ planPaneActions,
16
+ splitPaneOf,
17
+ viewCommandOf,
18
+ withAgentPanes,
19
+ } from './plan-panes'
20
+ import type { PlansSettings } from './settings'
21
+
22
+ const panesWithForeground = (agentPanes: readonly string[]): PaneState[] =>
23
+ withAgentPanes(paneListOf(runHerdr(['pane', 'list'])), agentPanes).map((each) => ({
24
+ ...each,
25
+ foreground: foregroundOf(runHerdr(['pane', 'process-info', '--pane', each.pane])),
26
+ }))
27
+
28
+ /** The board's lanes that have a worktree, closed ones left out. */
29
+ const lanesOf = (root: string, settings: PlansSettings, boardFile: string) =>
30
+ parseSprintLanes(readFileSync(boardFile, 'utf8'), settings.planLanguage)
31
+ .filter((lane) => !lane.closed)
32
+ .map((lane) => ({ name: lane.name, cwd: join(root, settings.worktreeDirectory, lane.name) }))
33
+ .filter((lane) => existsSync(lane.cwd))
34
+
35
+ /** Carries out one action; a failure becomes the action's line, so the other views are still tried. */
36
+ const apply = (action: PaneAction): string => {
37
+ try {
38
+ const target =
39
+ action.kind === 'split'
40
+ ? splitPaneOf(runHerdr(['pane', 'split', action.pane, '--direction', 'right', '--no-focus']))
41
+ : action.pane
42
+ runHerdr(['pane', 'run', target, action.command])
43
+ return `${action.kind === 'split' ? `split ${action.pane}, ` : ''}started the plans view in ${target}`
44
+ } catch (error) {
45
+ return `${action.pane}: ${errorText(error)}`
46
+ }
47
+ }
48
+
49
+ /**
50
+ * `plans --ensure`: brings back every missing plans view beside the coordinator and each lane of the board, and
51
+ * leaves running ones alone, so it can run after every start. A lane gets its view once its agent runs. One line per
52
+ * action or skipped target, none when every view runs.
53
+ */
54
+ export const ensurePlanPanes = (root: string, settings: PlansSettings, boardFile: string): string[] => {
55
+ const agents = runHerdr(['agent', 'list'])
56
+ const panes = panesWithForeground(agentPanesOf(agents))
57
+ const coordinatorName = defaultCoordinatorName(basename(root))
58
+ const { actions, notes } = planPaneActions({
59
+ panes,
60
+ coordinator: coordinatorPaneOf(panes, root, {
61
+ named: parseAgentList(agents).find((agent) => agent.name === coordinatorName)?.pane ?? null,
62
+ caller: process.env.HERDR_PANE_ID ?? null,
63
+ }),
64
+ lanes: lanesOf(root, settings, boardFile),
65
+ root,
66
+ worktrees: join(root, settings.worktreeDirectory),
67
+ command: (lane) => viewCommandOf(process.argv, lane),
68
+ missingCoordinator: missingCoordinatorNote(panes, root, coordinatorName),
69
+ })
70
+ return [...actions.map(apply), ...notes]
71
+ }
@@ -0,0 +1,5 @@
1
+ import { terminalSafe } from '../plan-format/terminal-text'
2
+
3
+ /** Error text comes from git, gh and the terminal adapter and is printed, so it is made terminal-safe. */
4
+ export const errorText = (error: unknown): string =>
5
+ terminalSafe(error instanceof Error ? error.message : String(error))
@@ -0,0 +1,96 @@
1
+ import { errorText } from './error-text'
2
+ import type { PullRequestState } from './github'
3
+
4
+ /** The background fetch of the base branch: when it last ran, when it last worked and why it failed. */
5
+ export type FetchState = {
6
+ lastAttempt: number | null
7
+ lastSuccess: Date | null
8
+ failure: string | null
9
+ running: boolean
10
+ }
11
+
12
+ export type FetchDecision = {
13
+ now: number
14
+ state: Pick<FetchState, 'lastAttempt' | 'running'>
15
+ intervalMinutes: number
16
+ /** A pull request of a lane is not open any more: it was merged, or closed. */
17
+ pullRequestGone: boolean
18
+ }
19
+
20
+ const MINUTE_MS = 60_000
21
+ const FETCH_TIMEOUT_MS = 60_000
22
+
23
+ export const initialFetchState = (): FetchState => ({
24
+ lastAttempt: null,
25
+ lastSuccess: null,
26
+ failure: null,
27
+ running: false,
28
+ })
29
+
30
+ /** Fetch now: never while one runs or when the interval is 0; at once for a gone pull request, else when the interval has passed. */
31
+ export const shouldFetch = ({ now, state, intervalMinutes, pullRequestGone }: FetchDecision): boolean => {
32
+ if (intervalMinutes <= 0 || state.running) return false
33
+ if (pullRequestGone || state.lastAttempt === null) return true
34
+ return now - state.lastAttempt >= intervalMinutes * MINUTE_MS
35
+ }
36
+
37
+ /**
38
+ * The numbers of pull requests of the given lane branches that were open before and are not any more.
39
+ * Pull requests of other branches are none of the view's business.
40
+ */
41
+ export const goneLanePullRequests = (
42
+ previous: PullRequestState['byBranch'],
43
+ next: PullRequestState['byBranch'],
44
+ laneBranches: ReadonlySet<string>,
45
+ ): number[] => {
46
+ const stillOpen = new Set([...next.values()].flat().map(({ number }) => number))
47
+ return [...previous.values()]
48
+ .flat()
49
+ .filter(({ branch, number }) => laneBranches.has(branch) && !stillOpen.has(number))
50
+ .map(({ number }) => number)
51
+ }
52
+
53
+ /**
54
+ * The files whose change reloads the view: the plan files, and the main checkout's `FETCH_HEAD`,
55
+ * which every fetch there rewrites (a linked worktree has its own). So a fetch by another process in
56
+ * the main checkout (the lane watcher fetches the base branch as soon as it moved) shows at once
57
+ * instead of on the next refresh, and neither needs the other running.
58
+ */
59
+ export const watchedFiles = (planFiles: readonly string[], fetchHead: string | null): string[] => [
60
+ ...planFiles,
61
+ ...(fetchHead ? [fetchHead] : []),
62
+ ]
63
+
64
+ /** The footer line after a failed fetch; the view carries on with the ref it has. */
65
+ export const fetchNotice = (state: FetchState, baseRef: string, locale: string): string | null => {
66
+ if (state.failure === null) return null
67
+ const known = state.lastSuccess
68
+ ? `last fetch ${state.lastSuccess.toLocaleTimeString(locale)}`
69
+ : 'not fetched since start'
70
+ return `fetch failed (${state.failure}), reading ${baseRef} as it is: ${known}`
71
+ }
72
+
73
+ /** Fetches the base branch; returns the reason when it did not work, null when it did. Never rejects. */
74
+ export const fetchBase = async (root: string, remote: string, branch: string): Promise<string | null> => {
75
+ let timedOut = false
76
+ let timer: Timer | undefined
77
+ try {
78
+ const child = Bun.spawn(['git', '-C', root, 'fetch', '--quiet', remote, branch], {
79
+ stdout: 'ignore',
80
+ stderr: 'pipe',
81
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
82
+ })
83
+ timer = setTimeout(() => {
84
+ timedOut = true
85
+ child.kill()
86
+ }, FETCH_TIMEOUT_MS)
87
+ const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited])
88
+ if (timedOut) return `timed out after ${FETCH_TIMEOUT_MS / 1000} s`
89
+ if (exitCode === 0) return null
90
+ return stderr.trim().split('\n')[0] || `git fetch exited with ${exitCode}`
91
+ } catch (error) {
92
+ return errorText(error)
93
+ } finally {
94
+ clearTimeout(timer)
95
+ }
96
+ }
@@ -0,0 +1,145 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+ import type { LaneStatus } from './layout'
4
+ import type { BaseRefInfo } from './sprint-page'
5
+
6
+ const gitAsync = async (cwd: string, args: string[]): Promise<string | null> => {
7
+ const child = Bun.spawn(['git', '-C', cwd, ...args], { stdout: 'pipe', stderr: 'ignore' })
8
+ const [output, exitCode] = await Promise.all([new Response(child.stdout).text(), child.exited])
9
+ return exitCode === 0 ? output.trim() : null
10
+ }
11
+
12
+ const git = (cwd: string, args: string[]): string | null => {
13
+ const result = Bun.spawnSync(['git', '-C', cwd, ...args], { stdout: 'pipe', stderr: 'pipe' })
14
+ return result.exitCode === 0 ? result.stdout.toString().trim() : null
15
+ }
16
+
17
+ /** The text of `path` at `ref`, or null when the ref or the file does not exist there. */
18
+ export const showAtRef = (root: string, ref: string, path: string): string | null => {
19
+ const result = Bun.spawnSync(['git', '-C', root, 'show', `${ref}:${path}`], { stdout: 'pipe', stderr: 'pipe' })
20
+ return result.exitCode === 0 ? result.stdout.toString() : null
21
+ }
22
+
23
+ /** The commit `ref` points at, null when it does not exist. */
24
+ export const commitOf = (root: string, ref: string): string | null =>
25
+ git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
26
+
27
+ /** Reads files as they are at the base ref. */
28
+ export type BaseReader = (path: string) => string | null
29
+
30
+ let cached: { root: string; commit: string; texts: Map<string, string> } | null = null
31
+
32
+ /**
33
+ * A reader for the files at `ref`, with the commit resolved once. A file at a commit never changes, so what was
34
+ * read is kept for as long as the ref points at the same commit: a re-read of the sprint costs one `git
35
+ * rev-parse` instead of one `git show` per plan.
36
+ */
37
+ export const baseReaderOf = (root: string, ref: string): BaseReader => {
38
+ const commit = commitOf(root, ref)
39
+ if (commit === null) return () => null
40
+ if (cached?.root !== root || cached.commit !== commit) cached = { root, commit, texts: new Map() }
41
+ const { texts } = cached
42
+ return (path) => {
43
+ const kept = texts.get(path)
44
+ if (kept !== undefined) return kept
45
+ const text = showAtRef(root, commit, path)
46
+ if (text !== null) texts.set(path, text)
47
+ return text
48
+ }
49
+ }
50
+
51
+ export const refExists = (root: string, ref: string): boolean => commitOf(root, ref) !== null
52
+
53
+ /** Whether `cwd` lies in a linked worktree (`git worktree add`), not in the main checkout; false outside git. */
54
+ export const isLinkedWorktree = (cwd: string): boolean => {
55
+ const directories = git(cwd, ['rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir'])?.split('\n')
56
+ return directories?.length === 2 && directories[0] !== directories[1]
57
+ }
58
+
59
+ /** The main checkout, also when called from inside a linked worktree. */
60
+ export const mainCheckout = (cwd: string): string => {
61
+ const commonDirectory = git(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
62
+ if (!commonDirectory) throw new Error('not inside a git checkout')
63
+ return dirname(commonDirectory)
64
+ }
65
+
66
+ /** The top of the checkout `cwd` is in: the worktree itself, not the main checkout. */
67
+ export const worktreeRoot = (cwd: string): string => {
68
+ const top = git(cwd, ['rev-parse', '--show-toplevel'])
69
+ if (!top) throw new Error('not inside a git checkout')
70
+ return top
71
+ }
72
+
73
+ /**
74
+ * Branch, commits ahead of `baseRef` and changed files of a worktree; null and 0 when it does not exist. The three
75
+ * git calls run side by side and without holding the event loop, so a key pressed meanwhile is answered at once.
76
+ */
77
+ export const gitStatusOf = async (
78
+ worktree: string,
79
+ baseRef: string,
80
+ ): Promise<Pick<LaneStatus, 'branch' | 'ahead' | 'dirty'>> => {
81
+ if (!existsSync(worktree)) return { branch: null, ahead: null, dirty: 0 }
82
+ const [count, porcelain, branch] = await Promise.all([
83
+ gitAsync(worktree, ['rev-list', '--count', `${baseRef}..HEAD`]),
84
+ gitAsync(worktree, ['status', '--porcelain']),
85
+ gitAsync(worktree, ['branch', '--show-current']),
86
+ ])
87
+ const ahead = Number.parseInt(count ?? '', 10)
88
+ return {
89
+ branch: branch || null,
90
+ ahead: Number.isNaN(ahead) ? null : ahead,
91
+ dirty: (porcelain ?? '').split('\n').filter(Boolean).length,
92
+ }
93
+ }
94
+
95
+ /** Where this clone keeps `FETCH_HEAD`, whether it exists yet or not; null outside a git checkout. */
96
+ export const fetchHeadPath = (root: string): string | null =>
97
+ git(root, ['rev-parse', '--path-format=absolute', '--git-path', 'FETCH_HEAD'])
98
+
99
+ /** The base ref's commit and commit time, and when this clone last fetched (the time of `FETCH_HEAD`), as far as git says. */
100
+ export const baseRefInfo = (root: string, ref: string): BaseRefInfo => {
101
+ const fetchHead = fetchHeadPath(root)
102
+ const fetchedAt = fetchHead && existsSync(fetchHead) ? statSync(fetchHead).mtime : null
103
+ return {
104
+ name: ref,
105
+ commit: git(root, ['rev-parse', '--short', `${ref}^{commit}`]),
106
+ committedAt: git(root, ['log', '-1', '--format=%cI', ref]),
107
+ fetchedAt,
108
+ }
109
+ }
110
+
111
+ const SSH_REMOTE = /^git@([^:]+):(.+?)(?:\.git)?$/
112
+
113
+ /** The remote as a plain `https://host/path` without credentials, null when it is no http(s) URL. */
114
+ const withoutCredentials = (remote: string): string | null => {
115
+ try {
116
+ const url = new URL(remote)
117
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
118
+ return `${url.protocol}//${url.host}${url.pathname.replace(/\.git$/, '').replace(/\/$/, '')}`
119
+ } catch {
120
+ return null
121
+ }
122
+ }
123
+
124
+ /** `https://host/owner/repo/blob/<branch>/` for the `origin` remote, null when there is none or it is no URL we know. */
125
+ export const remoteBlobBase = (root: string, branch: string): string | null => {
126
+ const remote = git(root, ['remote', 'get-url', 'origin'])
127
+ if (!remote) return null
128
+ const ssh = SSH_REMOTE.exec(remote)
129
+ const base = ssh ? `https://${ssh[1]}/${ssh[2]}` : withoutCredentials(remote)
130
+ return base ? `${base}/blob/${branch}/` : null
131
+ }
132
+
133
+ /** The commit time (epoch seconds) of the last commit on `ref` that touched `path`; null when there is none. */
134
+ export const lastCommitTime = (root: string, ref: string, path: string): number | null => {
135
+ const seconds = Number.parseInt(git(root, ['log', '-1', '--format=%ct', ref, '--', path]) ?? '', 10)
136
+ return Number.isNaN(seconds) ? null : seconds
137
+ }
138
+
139
+ /** The names of the directories under the worktree directory; none when it does not exist. */
140
+ export const worktreeNames = (worktreeDirectory: string): string[] =>
141
+ existsSync(worktreeDirectory)
142
+ ? readdirSync(worktreeDirectory, { withFileTypes: true })
143
+ .filter((entry) => entry.isDirectory())
144
+ .map((entry) => entry.name)
145
+ : []
@@ -0,0 +1,35 @@
1
+ import { errorText } from './error-text'
2
+ import { PULL_REQUEST_FIELDS, type PullRequest, parsePullRequests, pullRequestsByBranch } from './pull-requests'
3
+
4
+ /** Open pull requests by head branch, or none with the reason when `gh` fails. */
5
+ export type PullRequestState = { byBranch: Map<string, PullRequest[]>; errors: string[] }
6
+
7
+ export const noPullRequests = (): PullRequestState => ({ byBranch: new Map(), errors: [] })
8
+
9
+ /** The GitHub host whose pull request links are trusted; `GH_HOST` names an Enterprise server. */
10
+ export const GITHUB_HOST = process.env.GH_HOST ?? 'github.com'
11
+
12
+ const runGh = async (root: string): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
13
+ const child = Bun.spawn(['gh', 'pr', 'list', '--state', 'open', '--limit', '100', '--json', PULL_REQUEST_FIELDS], {
14
+ cwd: root,
15
+ stdout: 'pipe',
16
+ stderr: 'pipe',
17
+ })
18
+ const [stdout, stderr, exitCode] = await Promise.all([
19
+ new Response(child.stdout).text(),
20
+ new Response(child.stderr).text(),
21
+ child.exited,
22
+ ])
23
+ return { stdout, stderr, exitCode }
24
+ }
25
+
26
+ /** Open pull requests, or none with the reason; never rejects, also not when `gh` is not installed. */
27
+ export const fetchPullRequests = async (root: string): Promise<PullRequestState> => {
28
+ try {
29
+ const { stdout, stderr, exitCode } = await runGh(root)
30
+ if (exitCode !== 0) throw new Error(`gh pr list: ${stderr.trim() || `exit ${exitCode}`}`)
31
+ return { byBranch: pullRequestsByBranch(parsePullRequests(stdout, GITHUB_HOST)), errors: [] }
32
+ } catch (error) {
33
+ return { byBranch: new Map(), errors: [errorText(error)] }
34
+ }
35
+ }
@@ -0,0 +1,34 @@
1
+ import { terminalSafe } from '../plan-format/terminal-text'
2
+
3
+ /** What the watcher finds a lane by: the worktree directories, the herdr agent names, the sprint's lane rows. */
4
+ export type LaneSources = {
5
+ worktrees: readonly string[]
6
+ agentNames: readonly string[]
7
+ sprintLanes: readonly string[]
8
+ }
9
+
10
+ export type LaneSetChange = { added: string[]; removed: string[] }
11
+
12
+ /**
13
+ * The lanes to watch when none are named: a worktree directory whose name a herdr agent carries or
14
+ * the board's lanes table lists. A worktree with neither (a branch parked in its own checkout) is no
15
+ * lane, and an agent or a row without a worktree is none either.
16
+ */
17
+ export const discoverLanes = ({ worktrees, agentNames, sprintLanes }: LaneSources): string[] => {
18
+ const claimed = new Set([...agentNames, ...sprintLanes])
19
+ return [...new Set(worktrees)].filter((worktree) => claimed.has(worktree)).sort()
20
+ }
21
+
22
+ /** The lanes that joined and left between two discoveries, each sorted. */
23
+ export const laneSetChange = (previous: readonly string[], next: readonly string[]): LaneSetChange => ({
24
+ added: next.filter((lane) => !previous.includes(lane)).sort(),
25
+ removed: previous.filter((lane) => !next.includes(lane)).sort(),
26
+ })
27
+
28
+ export const isUnchanged = ({ added, removed }: LaneSetChange): boolean => added.length === 0 && removed.length === 0
29
+
30
+ /** `lane added: <name>` and `lane removed: <name>`, one line per lane. */
31
+ export const laneSetLines = ({ added, removed }: LaneSetChange): string[] => [
32
+ ...added.map((lane) => `lane added: ${terminalSafe(lane)}`),
33
+ ...removed.map((lane) => `lane removed: ${terminalSafe(lane)}`),
34
+ ]
@@ -0,0 +1,80 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { type PlansByHref, parsePlanPhases, resolvePlanPath, type SprintLane } from '../plan-format'
4
+ import type { BaseReader } from './git'
5
+ import { hasOwnWork, mergePlanPhases } from './phase-source'
6
+ import type { PlansSettings } from './settings'
7
+
8
+ /** Paths that could not be read at the base ref and were read from a checkout instead. */
9
+ export type Fallbacks = string[]
10
+
11
+ export type LaneWork = { worktree: string | null; ahead: number | null; dirty: number }
12
+
13
+ /** What reading a lane's plans needs: the main checkout, the settings, the base ref's reader, the board's directory. */
14
+ export type PlanSources = { root: string; settings: PlansSettings; base: BaseReader; sprintDirectory: string }
15
+
16
+ const isOutsideRepository = (path: string): boolean => path.startsWith('..')
17
+
18
+ /** The file as the base ref has it; null when the ref lacks it or the path leaves the repository. */
19
+ export const textAtBase = (base: BaseReader, path: string): string | null =>
20
+ isOutsideRepository(path) ? null : base(path)
21
+
22
+ /** The checkout's copy of a file the base ref lacks, noted in `fallbacks`; null when the checkout lacks it too. */
23
+ export const readFallback = async (
24
+ path: string,
25
+ fallbackFile: string,
26
+ fallbacks: Fallbacks,
27
+ ): Promise<string | null> => {
28
+ if (!existsSync(fallbackFile)) return null
29
+ fallbacks.push(path)
30
+ return Bun.file(fallbackFile).text()
31
+ }
32
+
33
+ /**
34
+ * The text of a repository file as the base ref has it. A file the ref does not have (new, so only in a
35
+ * checkout) is read from `fallbackFile` and noted in `fallbacks`; null when it is nowhere.
36
+ */
37
+ export const readAtBase = async (
38
+ base: BaseReader,
39
+ path: string,
40
+ fallbackFile: string,
41
+ fallbacks: Fallbacks,
42
+ ): Promise<string | null> => textAtBase(base, path) ?? readFallback(path, fallbackFile, fallbacks)
43
+
44
+ /** The lane's worktree, null when it is missing, with the work the lane has there by its git status. */
45
+ export const laneWorkOf = (worktree: string, status: { ahead: number | null; dirty: number }): LaneWork => ({
46
+ worktree: existsSync(worktree) ? worktree : null,
47
+ ahead: status.ahead,
48
+ dirty: status.dirty,
49
+ })
50
+
51
+ /**
52
+ * The plans of a lane. The base ref is the truth; while the lane has work of its own in its worktree, the phases
53
+ * its rows name, and those new there or ticked further, come from the worktree (`mergePlanPhases`). A plan the base ref lacks is read from the lane's worktree, else from the main
54
+ * checkout (a repository that ignores `docs/` keeps plan and board only there). `files` are the plan files to
55
+ * watch for changes.
56
+ */
57
+ export const readPlans = async (lane: SprintLane, sources: PlanSources, work: LaneWork, fallbacks: Fallbacks) => {
58
+ const { root, settings, sprintDirectory } = sources
59
+ const plans: PlansByHref = {}
60
+ const files: string[] = []
61
+ const texts: Record<string, string> = {}
62
+ for (const href of new Set(lane.steps.flatMap((step) => (step.planHref ? [step.planHref] : [])))) {
63
+ const path = resolvePlanPath(sprintDirectory, href)
64
+ const worktreeFile = work.worktree ? join(work.worktree, path) : null
65
+ const file = worktreeFile && existsSync(worktreeFile) ? worktreeFile : join(root, path)
66
+ const text = await readAtBase(sources.base, path, file, fallbacks)
67
+ if (text === null) continue
68
+ const own = worktreeFile === file && hasOwnWork(work) ? await Bun.file(file).text() : null
69
+ const laneIds = lane.steps.filter((step) => step.planHref === href).flatMap((step) => step.phaseIds)
70
+ plans[href] = mergePlanPhases(
71
+ parsePlanPhases(text, settings.planLanguage),
72
+ own === null ? null : parsePlanPhases(own, settings.planLanguage),
73
+ laneIds,
74
+ work,
75
+ )
76
+ files.push(file)
77
+ texts[href] = text
78
+ }
79
+ return { plans, files, texts }
80
+ }