jackherd 0.0.0-stage → 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 +1123 -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 +65 -4
  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,300 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import type { ContextReading } from '../context'
4
+ import { type AgentLogReader, contextOfAgent } from '../context/report'
5
+ import { agentLog } from '../context/session-log'
6
+ import {
7
+ type LaneProgress,
8
+ laneProgress,
9
+ readSprintDocument,
10
+ resolvePlanPath,
11
+ type SprintLane,
12
+ shortTitle,
13
+ sprintTitleOf,
14
+ } from '../plan-format'
15
+ import type { LaneAgent, LaneStatusSource } from '../terminal'
16
+ import type { PlanLanguage } from '../vocabulary'
17
+ import type { WatchBadges, WatchHealth } from '../watch/plan-badges'
18
+ import { readWatchReading } from '../watch/plan-badges-read'
19
+ import { boardRowsOf } from './board-rows'
20
+ import { errorText } from './error-text'
21
+ import { type BaseReader, baseReaderOf, gitStatusOf, refExists } from './git'
22
+ import type { PullRequestState } from './github'
23
+ import { type Fallbacks, laneWorkOf, readAtBase, readFallback, readPlans, textAtBase } from './lane-plans'
24
+ import { type LaneView, sortByUrgency } from './layout'
25
+ import { extractLinkChips } from './link-chips'
26
+ import type { Options } from './options'
27
+ import { currentPlanFirst, followingPlans, type PlannedPlan, plannedPlans } from './planned'
28
+ import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from './settings'
29
+ import { boardTexts } from './texts'
30
+ import { type UnlistedLane, unlistedLanes, unlistedSprintLane } from './unlisted-lanes'
31
+ import { withWorkedPhases } from './worked-phases'
32
+
33
+ /** Where the view reads from: the main checkout, the sprint document, the command line and the project settings. */
34
+ export type Source = {
35
+ root: string
36
+ sprintFile: string
37
+ options: Options
38
+ settings: PlansSettings
39
+ terminal: LaneStatusSource
40
+ /** The log lines of an agent's chat, null without one; defaults to reading them from `~/.claude/projects`. */
41
+ readAgentLog?: AgentLogReader
42
+ /** Why this board was chosen over others, shown with the errors; null or absent when there was no choice. */
43
+ boardNotice?: string | null
44
+ }
45
+
46
+ /**
47
+ * The documents a snapshot was read from, for views that draw more than the lane lines: the sprint text, the
48
+ * plan texts by link as the sprint writes it, and the lanes in the order of the sprint table.
49
+ */
50
+ export type SnapshotDocuments = {
51
+ sprint: string
52
+ plans: Record<string, string>
53
+ laneOrder: string[]
54
+ /** The board's lanes as the view draws them: the base ref's rows plus the rows only the checkout has. */
55
+ lanes: SprintLane[]
56
+ }
57
+
58
+ /**
59
+ * What one read of the sprint, the plans, git and the terminal produced; keys redraw from it without
60
+ * reading again. `errors` are shown in the footer.
61
+ */
62
+ export type Snapshot = {
63
+ title: string
64
+ views: LaneView[]
65
+ documents: SnapshotDocuments
66
+ /** The open phases no lane with an agent leads; empty while the view shows one lane. */
67
+ planned: PlannedPlan[]
68
+ files: string[]
69
+ titles: Map<string, string | null>
70
+ errors: string[]
71
+ /** The watcher's health; absent when no lane has an open pull request, so the watcher was not looked at. */
72
+ watch?: WatchHealth
73
+ /** The project's response language, for the words of the watcher's badges. */
74
+ language?: PlanLanguage
75
+ }
76
+
77
+ type LaneSources = {
78
+ source: Source
79
+ base: BaseReader
80
+ sprintDirectory: string
81
+ agents: LaneAgent[]
82
+ pullRequests: PullRequestState
83
+ }
84
+
85
+ type BoardLanes = { markdown: string; lanes: SprintLane[] }
86
+
87
+ const SHOWN_NAMES = 2
88
+
89
+ type LaneResult = { view: LaneView; files: string[]; texts: Record<string, string>; pane: string | null }
90
+
91
+ /** How full the agent's context is, null when the terminal gave no session or the session has no log. */
92
+ const contextOf = (agent: LaneAgent | undefined, source: Source): ContextReading | null => {
93
+ const report = contextOfAgent(agent, source.readAgentLog ?? agentLog, source.settings.context)
94
+ return report && { share: report.share, level: report.level }
95
+ }
96
+
97
+ /** The repository path of the plan the lane's current phase belongs to; null when every phase is done. */
98
+ const currentPlanPath = ({ current, steps }: LaneProgress, sprintDirectory: string): string | null => {
99
+ const href = current ? steps[current.stepIndex].step.planHref : null
100
+ return href ? resolvePlanPath(sprintDirectory, href) : null
101
+ }
102
+
103
+ /** What a lane's worktree and agent say, listed in the sprint or not: git state, pull requests, context. */
104
+ const runningState = async (sources: LaneSources, name: string, agent: LaneAgent | undefined) => {
105
+ const { source, pullRequests } = sources
106
+ const gitStatus = await gitStatusOf(worktreeOf(source.root, source.settings, name), baseRefOf(source.settings))
107
+ return {
108
+ gitStatus,
109
+ view: {
110
+ status: { agent: agent?.status ?? null, ...gitStatus },
111
+ pullRequests: gitStatus.branch ? (pullRequests.byBranch.get(gitStatus.branch) ?? []) : [],
112
+ context: contextOf(agent, source),
113
+ },
114
+ }
115
+ }
116
+
117
+ const laneView = async (sources: LaneSources, lane: SprintLane, fallbacks: Fallbacks): Promise<LaneResult> => {
118
+ const { source, agents } = sources
119
+ const { root, settings } = source
120
+ const worktree = worktreeOf(root, settings, lane.name)
121
+ const agent = agents.find((candidate) => candidate.name === lane.name)
122
+ const { gitStatus, view: running } = await runningState(sources, lane.name, agent)
123
+ const { plans, files, texts } = await readPlans(
124
+ lane,
125
+ { root, settings, base: sources.base, sprintDirectory: sources.sprintDirectory },
126
+ laneWorkOf(worktree, gitStatus),
127
+ fallbacks,
128
+ )
129
+ const shown = withWorkedPhases(lane, plans)
130
+ const progress = laneProgress(shown, plans)
131
+ const view: LaneView = {
132
+ progress,
133
+ ...running,
134
+ chips: Object.fromEntries(
135
+ Object.entries(texts).map(([href, text]) => [href, extractLinkChips(text, settings.linkKinds)]),
136
+ ),
137
+ following: currentPlanFirst(
138
+ followingPlans(
139
+ { lane: shown, agent: agent?.status ?? null },
140
+ { plans: texts, sprintDirectory: sources.sprintDirectory, language: settings.planLanguage },
141
+ ),
142
+ currentPlanPath(progress, sources.sprintDirectory),
143
+ ),
144
+ }
145
+ return { view, files, texts, pane: agent?.pane ?? null }
146
+ }
147
+
148
+ /**
149
+ * A running lane the sprint table does not list: its worktree's git state, pull requests and context, without plan
150
+ * and without phases, marked as unlisted.
151
+ */
152
+ const unlistedView = async (sources: LaneSources, { name, agent }: UnlistedLane): Promise<LaneResult> => {
153
+ const { view: running } = await runningState(sources, name, agent)
154
+ const view: LaneView = {
155
+ progress: laneProgress(unlistedSprintLane(name), {}),
156
+ ...running,
157
+ chips: {},
158
+ unlisted: true,
159
+ }
160
+ return { view, files: [], texts: {}, pane: agent.pane }
161
+ }
162
+
163
+ /** The view with the watcher's badges of its own pull requests, only when there are any, so it otherwise stays as it was. */
164
+ const withBadges = (view: LaneView, badges: WatchBadges): LaneView => {
165
+ const own = Object.fromEntries(
166
+ view.pullRequests.flatMap(({ number }) => {
167
+ const found = badges[String(number)]
168
+ return found ? [[String(number), found]] : []
169
+ }),
170
+ )
171
+ return Object.keys(own).length > 0 ? { ...view, badges: own } : view
172
+ }
173
+
174
+ /** The terminal's agents, or none with the reason when it cannot be asked, so the screen says why lanes show "no agent". */
175
+ const readAgents = (terminal: LaneStatusSource): { agents: LaneAgent[]; errors: string[] } => {
176
+ try {
177
+ return { agents: terminal.listAgents(), errors: [] }
178
+ } catch (error) {
179
+ return { agents: [], errors: [errorText(error)] }
180
+ }
181
+ }
182
+
183
+ /** Up to two names, then how many more: `a, b and 3 more`. */
184
+ const namedWithMore = (names: readonly string[]): string => {
185
+ const more = names.length - SHOWN_NAMES
186
+ return `${names.slice(0, SHOWN_NAMES).join(', ')}${more > 0 ? ` and ${more} more` : ''}`
187
+ }
188
+
189
+ /** The footer line for files read from a checkout because the base ref lacks them or does not exist. */
190
+ export const fallbackNotice = (fallbacks: readonly string[], baseRef: string, baseRefKnown: boolean): string | null => {
191
+ if (fallbacks.length === 0) return null
192
+ if (!baseRefKnown) return `${baseRef} not found, reading plans from the checkout`
193
+ return `not on ${baseRef}, read from the checkout: ${namedWithMore([...new Set(fallbacks)])}`
194
+ }
195
+
196
+ /**
197
+ * The board and its lanes. The text is the base ref's; rows the checkout's board file has beyond it are added
198
+ * (the watcher reminds the coordinator of them, the view does not mark them). A board the base ref lacks is read from the checkout alone, and a
199
+ * checkout with the same text adds nothing.
200
+ */
201
+ const readBoardLanes = async (
202
+ source: Source,
203
+ base: BaseReader,
204
+ boardPath: string,
205
+ fallbacks: Fallbacks,
206
+ ): Promise<BoardLanes> => {
207
+ const { sprintFile, settings } = source
208
+ const atBase = textAtBase(base, boardPath)
209
+ const markdown = atBase ?? (await readFallback(boardPath, sprintFile, fallbacks)) ?? ''
210
+ const checkoutText = atBase !== null && existsSync(sprintFile) ? await Bun.file(sprintFile).text() : markdown
211
+ return { markdown, lanes: boardRowsOf(markdown, checkoutText, settings.planLanguage).lanes }
212
+ }
213
+
214
+ /** The plans the sprint's plans tables name, read at the base ref, by link; lanes read theirs themselves. */
215
+ const readListedPlans = async (
216
+ source: Source,
217
+ base: BaseReader,
218
+ sprintDirectory: string,
219
+ markdown: string,
220
+ fallbacks: Fallbacks,
221
+ ): Promise<Record<string, string>> => {
222
+ const hrefs = new Set(
223
+ readSprintDocument(markdown, source.settings.planLanguage).planTables.flatMap(({ rows }) =>
224
+ rows.flatMap((row) => (row.href ? [row.href] : [])),
225
+ ),
226
+ )
227
+ const entries = await Promise.all(
228
+ [...hrefs].map(async (href) => {
229
+ const path = resolvePlanPath(sprintDirectory, href)
230
+ const text = await readAtBase(base, path, join(source.root, path), fallbacks)
231
+ return text === null ? [] : [[href, text] as const]
232
+ }),
233
+ )
234
+ return Object.fromEntries(entries.flat())
235
+ }
236
+
237
+ export const loadSnapshot = async (source: Source, pullRequests: PullRequestState): Promise<Snapshot> => {
238
+ const { root, sprintFile, options, settings, terminal } = source
239
+ const fallbacks: Fallbacks = []
240
+ const base = baseReaderOf(root, baseRefOf(settings))
241
+ const boardPath = boardRepoPathOf(root, settings, sprintFile)
242
+ const board = await readBoardLanes(source, base, boardPath, fallbacks)
243
+ const { markdown } = board
244
+ const sprintDirectory = dirname(boardPath)
245
+ const { agents, errors } = readAgents(terminal)
246
+ const isWanted = ({ name }: { name: string }) => !options.lane || name === options.lane
247
+ const lanes = board.lanes.filter(isWanted)
248
+ const unlisted = unlistedLanes(
249
+ agents,
250
+ new Set(board.lanes.map((lane) => lane.name)),
251
+ join(root, settings.worktreeDirectory),
252
+ ).filter(isWanted)
253
+ const sources = {
254
+ source,
255
+ base,
256
+ sprintDirectory,
257
+ agents,
258
+ pullRequests,
259
+ }
260
+ const results = [
261
+ ...(await Promise.all(lanes.map((lane) => laneView(sources, lane, fallbacks)))),
262
+ ...(await Promise.all(unlisted.map((lane) => unlistedView(sources, lane)))),
263
+ ]
264
+ const listedPlans = await readListedPlans(source, base, sprintDirectory, markdown, fallbacks)
265
+ const documentsPlans = Object.assign({}, listedPlans, ...results.map((result) => result.texts))
266
+ const planned = options.lane
267
+ ? []
268
+ : plannedPlans({
269
+ plans: documentsPlans,
270
+ lanes: results.map(({ view }) => ({ lane: view.progress.lane, agent: view.status.agent })),
271
+ sprintDirectory,
272
+ language: settings.planLanguage,
273
+ })
274
+ const reading = results.some(({ view }) => view.pullRequests.length > 0) ? readWatchReading(root, Date.now()) : null
275
+ const baseRef = baseRefOf(settings)
276
+ const notice = fallbacks.length > 0 ? fallbackNotice(fallbacks, baseRef, refExists(root, baseRef)) : null
277
+ const unknownLaneTitle =
278
+ options.lane && results.length === 0 ? boardTexts(settings.board.term).laneNotListed(options.lane) : null
279
+ return {
280
+ title: unknownLaneTitle ?? sprintTitleOf(markdown, sprintDirectory),
281
+ views: sortByUrgency(results.map((result) => withBadges(result.view, reading?.badges ?? {}))),
282
+ documents: {
283
+ sprint: markdown,
284
+ plans: documentsPlans,
285
+ laneOrder: [...lanes, ...unlisted].map((lane) => lane.name),
286
+ lanes: board.lanes,
287
+ },
288
+ planned,
289
+ files: [sprintFile, ...results.flatMap((result) => result.files)],
290
+ titles: new Map(results.flatMap(({ view, pane }) => (pane ? [[pane, shortTitle(view.progress)]] : []))),
291
+ errors: [
292
+ ...errors,
293
+ ...pullRequests.errors,
294
+ ...(notice ? [notice] : []),
295
+ ...(source.boardNotice ? [source.boardNotice] : []),
296
+ ],
297
+ watch: reading?.health,
298
+ language: settings.responseLanguage,
299
+ }
300
+ }
@@ -0,0 +1,103 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { dirname } from 'node:path'
4
+ import { pathToFileURL } from 'node:url'
5
+ import { openInBrowser } from '../terminal/open'
6
+ import { errorText } from './error-text'
7
+ import { baseRefInfo, remoteBlobBase } from './git'
8
+ import { baseRefOf, boardRepoPathOf } from './settings'
9
+ import type { Snapshot, Source } from './snapshot'
10
+ import { renderPlanBody, renderSprintHtml } from './sprint-html'
11
+ import { buildSprintPage, type SprintPage, sprintPagePath } from './sprint-page'
12
+ import { boardTexts } from './texts'
13
+
14
+ const DIRECTORY_MODE = 0o700
15
+ const FILE_MODE = 0o600
16
+
17
+ /** What writing and opening the page needs from outside; the defaults are the real ones. */
18
+ export type ExportEnvironment = {
19
+ now: () => Date
20
+ temporaryDirectory: string
21
+ open: (target: string) => string | null
22
+ }
23
+
24
+ const systemEnvironment = (): ExportEnvironment => ({
25
+ now: () => new Date(),
26
+ temporaryDirectory: tmpdir(),
27
+ open: (target) => openInBrowser(target),
28
+ })
29
+
30
+ /** A page on disk: where it is, and the section id of each plan it carries by the plan's repository path. */
31
+ export type WrittenPage = { path: string; anchors: ReadonlyMap<string, string> }
32
+
33
+ const pageOf = ({ root, sprintFile, settings }: Source, snapshot: Snapshot, environment: ExportEnvironment) =>
34
+ buildSprintPage(snapshot, {
35
+ root,
36
+ generatedAt: environment.now(),
37
+ baseRef: baseRefInfo(root, baseRefOf(settings)),
38
+ repoBlobBase: remoteBlobBase(root, settings.baseBranch),
39
+ sprintDirectory: dirname(boardRepoPathOf(root, settings, sprintFile)),
40
+ language: settings.planLanguage,
41
+ linkKinds: settings.linkKinds,
42
+ })
43
+
44
+ const writePage = (
45
+ page: SprintPage,
46
+ { sprintFile, root, settings }: Source,
47
+ environment: ExportEnvironment,
48
+ ): WrittenPage | { error: string } => {
49
+ const path = sprintPagePath(environment.temporaryDirectory, dirname(boardRepoPathOf(root, settings, sprintFile)))
50
+ try {
51
+ mkdirSync(dirname(path), { recursive: true, mode: DIRECTORY_MODE })
52
+ writeFileSync(path, renderSprintHtml(page), { mode: FILE_MODE })
53
+ } catch (error) {
54
+ return { error: errorText(error) }
55
+ }
56
+ return { path, anchors: page.links.anchors }
57
+ }
58
+
59
+ /** The page as a file, without opening it; the reason when it could not be written. */
60
+ export const writeSprintPageFile = (
61
+ source: Source,
62
+ snapshot: Snapshot,
63
+ environment: ExportEnvironment = systemEnvironment(),
64
+ ): WrittenPage | { error: string } => writePage(pageOf(source, snapshot, environment), source, environment)
65
+
66
+ const yieldToEventLoop = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0))
67
+
68
+ /**
69
+ * The same as `writeSprintPageFile`, but the plans are rendered one at a time with the event loop given back in
70
+ * between, so a key pressed while the page is built waits for one plan (a few hundred milliseconds at most), not
71
+ * for all of them (seconds). The final write then finds every plan already rendered.
72
+ */
73
+ export const writeSprintPageInSteps = async (
74
+ source: Source,
75
+ snapshot: Snapshot,
76
+ environment: ExportEnvironment = systemEnvironment(),
77
+ ): Promise<WrittenPage | { error: string }> => {
78
+ const page = pageOf(source, snapshot, environment)
79
+ for (const document of page.planDocuments) {
80
+ renderPlanBody(document, page)
81
+ await yieldToEventLoop()
82
+ }
83
+ return writePage(page, source, environment)
84
+ }
85
+
86
+ /**
87
+ * Writes the whole board as one HTML file and opens it in the default browser, at `anchor` when given. Returns
88
+ * the footer line: the path when it was opened, the path and the reason when it could not be (no display is no
89
+ * error), the reason when nothing could be written.
90
+ */
91
+ export const exportSprintPage = (
92
+ source: Source,
93
+ snapshot: Snapshot,
94
+ environment: ExportEnvironment = systemEnvironment(),
95
+ anchor: string | null = null,
96
+ ): string => {
97
+ const texts = boardTexts(source.settings.board.term)
98
+ const written = writeSprintPageFile(source, snapshot, environment)
99
+ if ('error' in written) return texts.pageNotWritten(written.error)
100
+ const { path } = written
101
+ const reason = environment.open(anchor ? `${pathToFileURL(path).href}#${anchor}` : path)
102
+ return reason === null ? texts.pageOpened(path) : texts.pageNotOpened(path, reason)
103
+ }