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,74 @@
1
+ import type { LinkKind } from '../config/link-kinds'
2
+
3
+ /** A link of a plan that matches a link kind, as the sprint page shows it. */
4
+ export type LinkChip = { url: string; label: string; icon: string; private: boolean }
5
+
6
+ type Found = { url: string; text: string | null; before: string; line: string }
7
+
8
+ const MARKDOWN_LINK = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g
9
+ const AUTOLINK = /<(https?:\/\/[^>\s]+)>/g
10
+ const BARE_LINK = /(?<![(<\w"])(https?:\/\/[^\s)>\]]+)/g
11
+ const LABEL_BEFORE = /\*\*([^*]+?):?\*\*:?\s*(?:[-–>]\s*)?$/
12
+ const PRIVATE = /\bprivat/i
13
+ const MARKS = /\*\*|`/g
14
+ const CONTROL = /\p{Cc}/gu
15
+
16
+ const linksOf = (line: string): Found[] => {
17
+ const found: Found[] = []
18
+ const take = (url: string, text: string | null, index: number) =>
19
+ found.push({ url, text, before: line.slice(0, index), line })
20
+ for (const match of line.matchAll(MARKDOWN_LINK)) take(match[2], match[1], match.index)
21
+ for (const match of line.matchAll(AUTOLINK)) take(match[1], null, match.index)
22
+ const rest = line
23
+ .replace(MARKDOWN_LINK, (whole) => ' '.repeat(whole.length))
24
+ .replace(AUTOLINK, (whole) => ' '.repeat(whole.length))
25
+ for (const match of rest.matchAll(BARE_LINK)) take(match[1].replace(/[.,;]+$/, ''), null, match.index)
26
+ return found.sort((left, right) => line.indexOf(left.url) - line.indexOf(right.url))
27
+ }
28
+
29
+ const labelOf = ({ text, before }: Found, kind: LinkKind): string => {
30
+ const written = text ?? LABEL_BEFORE.exec(before)?.[1]
31
+ return written ? written.replace(MARKS, '').replace(CONTROL, '').trim() || kind.label : kind.label
32
+ }
33
+
34
+ /**
35
+ * The links of a plan's text that match one of the kinds, once each and in the order they appear. The chip's
36
+ * text is the link text, else the bold label written before the link (`**Mockup:** <url>`), else the kind's
37
+ * label; a link on a line that says it is private carries that. Nothing is fetched.
38
+ */
39
+ export const extractLinkChips = (markdown: string, kinds: readonly LinkKind[]): LinkChip[] => {
40
+ const compiled = kinds.map((kind) => ({ kind, pattern: new RegExp(kind.pattern) }))
41
+ const chips = new Map<string, LinkChip>()
42
+ for (const line of markdown.split('\n')) {
43
+ for (const link of linksOf(line)) {
44
+ const match = compiled.find(({ pattern }) => pattern.test(link.url))
45
+ if (!match || chips.has(link.url)) continue
46
+ chips.set(link.url, {
47
+ url: link.url,
48
+ label: labelOf(link, match.kind),
49
+ icon: match.kind.icon,
50
+ private: PRIVATE.test(link.line),
51
+ })
52
+ }
53
+ }
54
+ return [...chips.values()]
55
+ }
56
+
57
+ const CHIP_KEY = /^chip>(https?:\/\/.+)$/
58
+ const PLAN_KEY = /^plan>(file:\/\/\/.+)$/
59
+
60
+ /**
61
+ * The click target of a chip. A `file:` address is the sprint page's own (the plan chip) and gets a key of its
62
+ * own, so no link of a plan's text can ever be taken for it; the keys cannot collide with a lane key (`@`), a
63
+ * phase key or a pull request key.
64
+ */
65
+ export const chipKey = (url: string): string => (url.startsWith('file:') ? `plan>${url}` : `chip>${url}`)
66
+
67
+ /** The address behind the click target of a link of a plan's text: http(s) only, null for any other key. */
68
+ export const chipUrlOf = (key: string): string | null => CHIP_KEY.exec(key)?.[1] ?? null
69
+
70
+ /** The `file:` address behind the plan chip's click target; the caller opens it only when it is the page. */
71
+ export const planUrlOf = (key: string): string | null => PLAN_KEY.exec(key)?.[1] ?? null
72
+
73
+ /** Whether a click target belongs to a chip, of either kind. */
74
+ export const isChipKey = (key: string): boolean => chipUrlOf(key) !== null || planUrlOf(key) !== null
@@ -0,0 +1,336 @@
1
+ import { existsSync, type FSWatcher, watch } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import { terminalSafe } from '../plan-format/terminal-text'
5
+ import { openInBrowser } from '../terminal/open'
6
+ import { errorText } from './error-text'
7
+ import {
8
+ type FetchState,
9
+ fetchBase,
10
+ fetchNotice,
11
+ goneLanePullRequests,
12
+ initialFetchState,
13
+ shouldFetch,
14
+ watchedFiles,
15
+ } from './fetch'
16
+ import { fetchHeadPath } from './git'
17
+ import { fetchPullRequests, noPullRequests, type PullRequestState } from './github'
18
+ import type { Screen } from './layout'
19
+ import { type KnownPage, pageAfterWrite, pageKey, pageNeedsWriting, planAnchorOn, withPlanChips } from './plan-chips'
20
+ import { pullRequestUrl } from './pull-requests'
21
+ import {
22
+ inputOutcome,
23
+ isSprintPageKey,
24
+ overviewSelection,
25
+ splitKeys,
26
+ syncLaneFolds,
27
+ targetKeys,
28
+ type View,
29
+ windowTop,
30
+ } from './selection'
31
+ import { BASE_REMOTE, baseRefOf, boardRepoPathOf, clockLocaleOf } from './settings'
32
+ import { singleFlight } from './single-flight'
33
+ import { loadSnapshot, type Snapshot, type Source } from './snapshot'
34
+ import { exportSprintPage, writeSprintPageInSteps } from './sprint-export'
35
+ import { boardTexts } from './texts'
36
+ import { bodyRows, clockOf, draw, paneWidth, renderSnapshot, startSelection, TERMINAL_OFF, TERMINAL_ON } from './view'
37
+
38
+ /** The state of the running view; only the functions below `runLive` change it. */
39
+ type Live = {
40
+ source: Source
41
+ snapshot: Snapshot | null
42
+ screen: Screen | null
43
+ view: View
44
+ errors: string[]
45
+ pullRequests: PullRequestState
46
+ fetch: FetchState
47
+ /** One footer line about the last action of the person, such as where the sprint page was written; cleared by the next key. */
48
+ notice: string | null
49
+ watchers: Map<string, FSWatcher>
50
+ /** This clone's `FETCH_HEAD`, watched beside the plan files; null when git names none. */
51
+ fetchHead: string | null
52
+ reported: Map<string, string | null>
53
+ pending: Timer | null
54
+ /** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of `page`. */
55
+ read: Snapshot | null
56
+ /** The sprint page last written, and the key of the plans it was built from; a click on a plan chip opens it. */
57
+ page: KnownPage | null
58
+ pageWriting: boolean
59
+ flight: (() => Promise<void>) | null
60
+ pageError: string | null
61
+ }
62
+
63
+ const REFRESH_MS = 5_000
64
+ const PULL_REQUEST_REFRESH_MS = 60_000
65
+ const DEBOUNCE_MS = 150
66
+ const QUIT_KEYS = new Set(['\x03', 'q'])
67
+
68
+ /** The sidebar titles that differ from the ones last reported, in the order of `titles`; null clears one. */
69
+ export const changedTitles = (
70
+ titles: ReadonlyMap<string, string | null>,
71
+ reported: ReadonlyMap<string, string | null>,
72
+ ): [string, string | null][] => [...titles].filter(([pane, title]) => reported.get(pane) !== title)
73
+
74
+ /** Reports the titles that changed since the last frame; a failed report is retried on the next one. */
75
+ const reportTitles = (source: Source, titles: Map<string, string | null>, reported: Map<string, string | null>) => {
76
+ const errors: string[] = []
77
+ for (const [pane, title] of changedTitles(titles, reported)) {
78
+ const error = source.terminal.setTitle(pane, title)
79
+ if (error) errors.push(`sidebar title not set: ${terminalSafe(error)}`)
80
+ else reported.set(pane, title)
81
+ }
82
+ return errors
83
+ }
84
+
85
+ const clearTitles = (source: Source, reported: Map<string, string | null>) => {
86
+ for (const [pane, title] of reported) {
87
+ if (title) source.terminal.setTitle(pane, null)
88
+ }
89
+ }
90
+
91
+ const syncWatchers = (watchers: Map<string, FSWatcher>, files: string[], onChange: () => void) => {
92
+ for (const [file, watcher] of watchers) {
93
+ if (files.includes(file)) continue
94
+ watcher.close()
95
+ watchers.delete(file)
96
+ }
97
+ for (const file of files.filter((candidate) => !watchers.has(candidate))) {
98
+ try {
99
+ const watcher = watch(file, (event) => {
100
+ if (event === 'rename') {
101
+ watcher.close()
102
+ watchers.delete(file)
103
+ }
104
+ onChange()
105
+ })
106
+ watchers.set(file, watcher)
107
+ } catch {
108
+ // A file that cannot be watched right now is retried on the next refresh; the 5 s refresh covers it meanwhile.
109
+ }
110
+ }
111
+ }
112
+
113
+ /** Alternate screen, hidden cursor, mouse reports and raw keys; `quit` restores the terminal. */
114
+ const enterTerminal = (onInput: (input: string) => void, quit: () => void, onResize: () => void) => {
115
+ process.stdout.write(TERMINAL_ON)
116
+ process.on('SIGINT', quit)
117
+ process.on('SIGTERM', quit)
118
+ process.stdout.on('resize', onResize)
119
+ if (!process.stdin.isTTY) return
120
+ process.stdin.setRawMode(true)
121
+ process.stdin.on('data', (chunk) => {
122
+ for (const input of splitKeys(chunk.toString())) onInput(input)
123
+ })
124
+ }
125
+
126
+ const redraw = (live: Live) => {
127
+ if (!live.snapshot) return
128
+ const screen = renderSnapshot(
129
+ live.snapshot,
130
+ live.view.selection,
131
+ [...live.errors, ...(live.pageError ? [live.pageError] : []), ...(live.notice ? [live.notice] : [])],
132
+ paneWidth(),
133
+ clockLocaleOf(live.source.settings),
134
+ )
135
+ const top = windowTop(live.view, screen.body.cursorLine, screen.body.lines.length, bodyRows(screen))
136
+ live.screen = screen
137
+ live.view = { ...live.view, top }
138
+ draw(screen, top)
139
+ }
140
+
141
+ /** The whole sprint, also while the view shows one lane: the snapshot is read again without the lane filter. */
142
+ const wholeSprint = async (live: Live, snapshot: Snapshot): Promise<Snapshot> =>
143
+ live.source.options.lane
144
+ ? loadSnapshot({ ...live.source, options: { ...live.source.options, lane: null } }, live.pullRequests)
145
+ : snapshot
146
+
147
+ const boardTextsOf = (live: Live) => boardTexts(live.source.settings.board.term)
148
+
149
+ const sprintDirectoryOf = ({ root, sprintFile, settings }: Source): string =>
150
+ dirname(boardRepoPathOf(root, settings, sprintFile))
151
+
152
+ /** The snapshot as read with the plan chips of the page written so far, none before the first one. */
153
+ const withKnownPage = (live: Live, read: Snapshot): Snapshot =>
154
+ live.page ? withPlanChips(read, live.page, sprintDirectoryOf(live.source)) : read
155
+
156
+ const isCurrent = (live: Live, read: Snapshot): boolean =>
157
+ live.page !== null && !pageNeedsWriting(live.page, pageKey(read), existsSync(live.page.path))
158
+
159
+ /**
160
+ * Writes the sprint page, so a plan chip's link points at a file that exists. Only when the plans it is built
161
+ * from changed or the file is gone: building it takes seconds, with the event loop blocked for all of them.
162
+ */
163
+ const syncSprintPage = async (live: Live, read: Snapshot) => {
164
+ const key = pageKey(read)
165
+ if (live.pageWriting || !pageNeedsWriting(live.page, key, live.page !== null && existsSync(live.page.path))) return
166
+ live.pageWriting = true
167
+ try {
168
+ const written = await writeSprintPageInSteps(live.source, await wholeSprint(live, read))
169
+ live.pageError = 'error' in written ? boardTextsOf(live).pageNotWritten(written.error) : null
170
+ live.page = pageAfterWrite(live.page, written, key)
171
+ } catch (error) {
172
+ live.pageError = boardTextsOf(live).pageNotWritten(errorText(error))
173
+ } finally {
174
+ live.pageWriting = false
175
+ }
176
+ if (live.read) live.snapshot = withKnownPage(live, live.read)
177
+ redraw(live)
178
+ }
179
+
180
+ /** A re-read never overlaps another: one asked for meanwhile runs once after the running one, so the screen cannot go back to an older read. */
181
+ const refresh = (live: Live, onChange: () => void): Promise<void> => {
182
+ live.flight ??= singleFlight(() => readOnce(live, onChange))
183
+ return live.flight()
184
+ }
185
+
186
+ const readOnce = async (live: Live, onChange: () => void) => {
187
+ try {
188
+ const read = await loadSnapshot(live.source, live.pullRequests)
189
+ const next = withKnownPage(live, read)
190
+ live.read = read
191
+ if (!live.snapshot) live.view = { ...live.view, selection: startSelection(next, live.source.options) }
192
+ const folded = syncLaneFolds(
193
+ live.view.selection,
194
+ next.views.map(({ progress, status }) => ({ lane: progress.lane.name, status: status.agent })),
195
+ )
196
+ live.view = { ...live.view, selection: folded }
197
+ live.snapshot = next
198
+ const fetchLine = fetchNotice(live.fetch, baseRefOf(live.source.settings), clockLocaleOf(live.source.settings))
199
+ live.errors = [
200
+ ...next.errors,
201
+ ...(fetchLine ? [fetchLine] : []),
202
+ ...reportTitles(live.source, next.titles, live.reported),
203
+ ]
204
+ syncWatchers(live.watchers, watchedFiles(next.files, live.fetchHead), onChange)
205
+ } catch (error) {
206
+ const at = clockOf(clockLocaleOf(live.source.settings))
207
+ live.errors = [`refresh failed at ${at}, showing the last good state: ${errorText(error)}`]
208
+ }
209
+ redraw(live)
210
+ const { read } = live
211
+ if (read) setTimeout(() => void syncSprintPage(live, read), 0)
212
+ }
213
+
214
+ const writeSprintPage = async (live: Live, snapshot: Snapshot, anchor: string | null = null) => {
215
+ const { source } = live
216
+ try {
217
+ live.notice = exportSprintPage(source, await wholeSprint(live, snapshot), undefined, anchor)
218
+ } catch (error) {
219
+ live.notice = boardTextsOf(live).pageNotWritten(errorText(error))
220
+ }
221
+ redraw(live)
222
+ }
223
+
224
+ /**
225
+ * A click on a plan chip. A page built from the current plans is opened at once; otherwise it is written again
226
+ * first. Any other `file:` address is refused.
227
+ */
228
+ const openPlan = (live: Live, snapshot: Snapshot, address: string) => {
229
+ const anchor = planAnchorOn(live.page?.path ?? null, address)
230
+ if (!anchor || !live.page) return
231
+ if (!live.read || !isCurrent(live, live.read)) {
232
+ void writeSprintPage(live, snapshot, anchor)
233
+ return
234
+ }
235
+ const reason = openInBrowser(`${pathToFileURL(live.page.path).href}#${anchor}`)
236
+ const texts = boardTextsOf(live)
237
+ live.notice = reason === null ? texts.pageOpened(live.page.path) : texts.pageNotOpenedReason(reason)
238
+ }
239
+
240
+ const handleInput = (live: Live, input: string) => {
241
+ const { snapshot, screen } = live
242
+ if (!snapshot || !screen) return
243
+ live.notice = null
244
+ if (isSprintPageKey(input)) {
245
+ void writeSprintPage(live, snapshot)
246
+ return
247
+ }
248
+ const context = {
249
+ keys: targetKeys(screen.body.targets),
250
+ targets: screen.body.targets,
251
+ headerRows: screen.header.length,
252
+ bodyRows: bodyRows(screen),
253
+ }
254
+ const pullRequests = snapshot.views.flatMap((view) => view.pullRequests)
255
+ const outcome = inputOutcome(live.view, input, context, (key) => pullRequestUrl(pullRequests, key))
256
+ live.view = outcome.view
257
+ if (outcome.open?.startsWith('file:')) {
258
+ openPlan(live, snapshot, outcome.open)
259
+ redraw(live)
260
+ return
261
+ }
262
+ const error = outcome.open ? openInBrowser(outcome.open) : null
263
+ if (error) live.errors = [...live.errors, error]
264
+ redraw(live)
265
+ }
266
+
267
+ export const runLive = (source: Source): void => {
268
+ const live: Live = {
269
+ source,
270
+ snapshot: null,
271
+ screen: null,
272
+ view: { selection: overviewSelection(), top: 0, follow: true },
273
+ errors: [],
274
+ pullRequests: noPullRequests(),
275
+ fetch: initialFetchState(),
276
+ notice: null,
277
+ watchers: new Map(),
278
+ fetchHead: fetchHeadPath(source.root),
279
+ reported: new Map(),
280
+ pending: null,
281
+ read: null,
282
+ page: null,
283
+ pageWriting: false,
284
+ flight: null,
285
+ pageError: null,
286
+ }
287
+ const schedule = () => {
288
+ if (live.pending) clearTimeout(live.pending)
289
+ live.pending = setTimeout(() => refresh(live, schedule), DEBOUNCE_MS)
290
+ }
291
+ const fetchBaseBranch = async () => {
292
+ live.fetch = { ...live.fetch, lastAttempt: Date.now(), running: true }
293
+ const failure = await fetchBase(source.root, BASE_REMOTE, source.settings.baseBranch)
294
+ live.fetch = {
295
+ ...live.fetch,
296
+ running: false,
297
+ failure,
298
+ lastSuccess: failure === null ? new Date() : live.fetch.lastSuccess,
299
+ }
300
+ schedule()
301
+ }
302
+ const fetchWhenDue = (pullRequestGone: boolean) => {
303
+ const due = shouldFetch({
304
+ now: Date.now(),
305
+ state: live.fetch,
306
+ intervalMinutes: source.settings.fetchIntervalMinutes,
307
+ pullRequestGone,
308
+ })
309
+ if (due) void fetchBaseBranch()
310
+ }
311
+ const refreshPullRequests = async () => {
312
+ const previous = live.pullRequests
313
+ live.pullRequests = await fetchPullRequests(source.root)
314
+ const readable = previous.errors.length === 0 && live.pullRequests.errors.length === 0
315
+ const laneBranches = new Set(live.snapshot?.views.flatMap(({ status }) => (status.branch ? [status.branch] : [])))
316
+ const gone = goneLanePullRequests(previous.byBranch, live.pullRequests.byBranch, laneBranches)
317
+ fetchWhenDue(readable && gone.length > 0)
318
+ schedule()
319
+ }
320
+ const quit = () => {
321
+ clearTitles(source, live.reported)
322
+ for (const watcher of live.watchers.values()) watcher.close()
323
+ process.stdout.write(TERMINAL_OFF)
324
+ process.exit(0)
325
+ }
326
+ const onInput = (input: string) => (QUIT_KEYS.has(input) ? quit() : handleInput(live, input))
327
+
328
+ enterTerminal(onInput, quit, () => redraw(live))
329
+ setInterval(() => {
330
+ fetchWhenDue(false)
331
+ void refresh(live, schedule)
332
+ }, REFRESH_MS)
333
+ setInterval(refreshPullRequests, PULL_REQUEST_REFRESH_MS)
334
+ void refresh(live, schedule)
335
+ void refreshPullRequests()
336
+ }
@@ -0,0 +1,140 @@
1
+ import { dirname, join, posix } from 'node:path'
2
+ import { pathToFileURL } from 'node:url'
3
+ import { Marked, type Tokens } from 'marked'
4
+
5
+ /** Where a plan's links can go: the other plans on the page, the repository, the local checkout. */
6
+ export type LinkContext = {
7
+ /** The repository path of the plan the markdown comes from. */
8
+ fromPath: string
9
+ /** Repository path of a plan on the page → the id of its section. */
10
+ anchors: ReadonlyMap<string, string>
11
+ /** `https://host/owner/repo/blob/<branch>/` or null when the remote is unknown. */
12
+ blobBase: string | null
13
+ /** The main checkout, for `file:` links when there is no remote. */
14
+ root: string
15
+ }
16
+
17
+ const escapeHtml = (text: string): string =>
18
+ text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
19
+
20
+ /** The id of a heading as GitHub makes it: lower case, punctuation gone, spaces to hyphens. */
21
+ export const headingSlug = (text: string): string =>
22
+ text
23
+ .toLowerCase()
24
+ .trim()
25
+ .replace(/[^\p{L}\p{N}\s-]/gu, '')
26
+ .replace(/\s/g, '-')
27
+
28
+ const SCHEME = /^[a-z][a-z0-9+.-]*:/i
29
+ const WEB = /^https?:\/\//i
30
+ const MAX_HEADING = 6
31
+ const HEADING_SHIFT = 2
32
+
33
+ /** The id of the section of a plan, from its repository path. */
34
+ export const planAnchorOf = (repoPath: string): string =>
35
+ `plan-${headingSlug(posix.basename(repoPath, '.md')).replace(/-+/g, '-')}`
36
+
37
+ /**
38
+ * Where a link of a plan goes on the page: another plan of the page becomes its anchor, a link inside the
39
+ * plan becomes the id of that heading, any other file of the repository goes to GitHub (or to the checkout),
40
+ * a web link stays. Anything else, a script or a path out of the repository, has no target.
41
+ */
42
+ export const resolveLink = (href: string, context: LinkContext, ownAnchor: string): string | null => {
43
+ if (href === '') return null
44
+ if (href.startsWith('#')) return `#${ownAnchor}--${href.slice(1)}`
45
+ if (WEB.test(href)) return href
46
+ if (SCHEME.test(href)) return null
47
+ const [path, fragment] = href.split('#')
48
+ const target = posix.normalize(posix.join(dirname(context.fromPath), path))
49
+ if (target.startsWith('..')) return null
50
+ const anchor = context.anchors.get(target)
51
+ if (anchor) return `#${anchor}${fragment ? `--${fragment}` : ''}`
52
+ const base = context.blobBase ? `${context.blobBase}${target}` : pathToFileURL(join(context.root, target)).href
53
+ return fragment ? `${base}#${fragment}` : base
54
+ }
55
+
56
+ /** Every table in its own container that scrolls sideways, so a wide one is not cut off. */
57
+ export const scrollable = (html: string): string =>
58
+ html.replace(/<table/g, '<div class="table-scroll"><table').replace(/<\/table>/g, '</table></div>')
59
+
60
+ // The header fields (`**Date:**`, `**Status:**`, `**Scope:**`) sit on consecutive lines, which markdown folds
61
+ // into one paragraph; a trailing double space keeps each on its own line.
62
+ const hardBreakFields = (markdown: string): string =>
63
+ markdown.replace(/^(\*\*\w+:\*\*[^\n]*)\n(?=\*\*\w+:\*\*)/gm, '$1 \n')
64
+
65
+ const renderUncached = (
66
+ markdown: string,
67
+ context: LinkContext,
68
+ ownAnchor: string,
69
+ /** The title the section already shows; a first level 1 heading with that text is left out. */
70
+ shownTitle: string | null = null,
71
+ ): string => {
72
+ const seen = new Map<string, number>()
73
+ const marked = new Marked({
74
+ gfm: true,
75
+ renderer: {
76
+ html: ({ text }: Tokens.HTML | Tokens.Tag) => escapeHtml(text),
77
+ image: ({ text }: Tokens.Image) => escapeHtml(text),
78
+ checkbox: ({ checked }: Tokens.Checkbox) => (checked ? '☑' : '☐'),
79
+ link(this: { parser: { parseInline: (tokens: Tokens.Generic[]) => string } }, { href, tokens }: Tokens.Link) {
80
+ const label = this.parser.parseInline(tokens)
81
+ const target = resolveLink(href, context, ownAnchor)
82
+ return target === null ? label : `<a href="${escapeHtml(target)}">${label}</a>`
83
+ },
84
+ heading(
85
+ this: { parser: { parseInline: (tokens: Tokens.Generic[]) => string } },
86
+ { tokens, depth, text }: Tokens.Heading,
87
+ ) {
88
+ if (depth === 1 && shownTitle !== null && text.trim() === shownTitle.trim()) return ''
89
+ const slug = headingSlug(text)
90
+ const count = seen.get(slug) ?? 0
91
+ seen.set(slug, count + 1)
92
+ const id = `${ownAnchor}--${count === 0 ? slug : `${slug}-${count}`}`
93
+ const level = Math.min(depth + HEADING_SHIFT, MAX_HEADING)
94
+ return `<h${level} id="${escapeHtml(id)}">${this.parser.parseInline(tokens)}</h${level}>\n`
95
+ },
96
+ },
97
+ })
98
+ return scrollable(marked.parse(hardBreakFields(markdown), { async: false }))
99
+ }
100
+
101
+ const rendered = new Map<string, string>()
102
+ const RENDERED_MAX = 64
103
+
104
+ const renderKey = (markdown: string, context: LinkContext, ownAnchor: string, shownTitle: string | null): string =>
105
+ JSON.stringify([
106
+ Bun.hash(markdown).toString(36),
107
+ markdown.length,
108
+ context.fromPath,
109
+ [...context.anchors],
110
+ context.blobBase,
111
+ context.root,
112
+ ownAnchor,
113
+ shownTitle,
114
+ ])
115
+
116
+ /**
117
+ * A plan's markdown as HTML for the page. Raw HTML in the text is shown as text and images as their alt text;
118
+ * headings get ids under the plan's anchor and move two levels down; task items are ☐ and ☑. Rendering takes
119
+ * about two milliseconds per kilobyte, so what was rendered is kept: the same text under the same links yields
120
+ * the same HTML, and a page built again after one plan changed renders that plan only.
121
+ */
122
+ export const renderPlanMarkdown = (
123
+ markdown: string,
124
+ context: LinkContext,
125
+ ownAnchor: string,
126
+ /** The title the section already shows; a first level 1 heading with that text is left out. */
127
+ shownTitle: string | null = null,
128
+ ): string => {
129
+ const key = renderKey(markdown, context, ownAnchor, shownTitle)
130
+ const kept = rendered.get(key)
131
+ if (kept !== undefined) {
132
+ rendered.delete(key)
133
+ rendered.set(key, kept)
134
+ return kept
135
+ }
136
+ const html = renderUncached(markdown, context, ownAnchor, shownTitle)
137
+ if (rendered.size >= RENDERED_MAX) rendered.delete(rendered.keys().next().value as string)
138
+ rendered.set(key, html)
139
+ return html
140
+ }
@@ -0,0 +1,15 @@
1
+ export type Options = { boardPath: string | null; lane: string | null; once: boolean; ensure: boolean }
2
+
3
+ const KNOWN_OPTIONS = ['--lane', '--once', '--ensure']
4
+
5
+ export const parseOptions = (args: string[]): Options => {
6
+ const unknown = args.find((arg) => arg.startsWith('--') && !KNOWN_OPTIONS.includes(arg))
7
+ if (unknown) throw new Error(`unknown option ${unknown} (known: ${KNOWN_OPTIONS.join(', ')})`)
8
+ const laneIndex = args.indexOf('--lane')
9
+ const lane = laneIndex === -1 ? null : args[laneIndex + 1]
10
+ if (lane === undefined || lane?.startsWith('--')) throw new Error('--lane needs a lane name')
11
+ const positional = args.filter((arg, index) => !arg.startsWith('--') && (laneIndex === -1 || index !== laneIndex + 1))
12
+ const ensure = args.includes('--ensure')
13
+ if (ensure && (lane !== null || args.includes('--once'))) throw new Error('--ensure takes no --lane or --once')
14
+ return { boardPath: positional[0] ?? null, lane, once: args.includes('--once'), ensure }
15
+ }
@@ -0,0 +1,71 @@
1
+ import type { PlanPhase } from '../plan-format'
2
+
3
+ /** What a lane has done in its own worktree: commits ahead of the base ref (null when unknown) and changed files. */
4
+ export type OwnWork = { ahead: number | null; dirty: number }
5
+
6
+ export const PHASE_SOURCE = { base: 'base', worktree: 'worktree' } as const
7
+
8
+ export type PhaseSource = (typeof PHASE_SOURCE)[keyof typeof PHASE_SOURCE]
9
+
10
+ export const hasOwnWork = ({ ahead, dirty }: OwnWork): boolean => (ahead ?? 0) > 0 || dirty > 0
11
+
12
+ /**
13
+ * Where a phase that a row of the lane names is read from: its worktree while the lane has unmerged work of
14
+ * its own there, where the lane ticks it, else the base ref. `mergePlanPhases` adds the phases no row names.
15
+ */
16
+ export const phaseSource = (work: OwnWork, ownedByLane: boolean): PhaseSource =>
17
+ ownedByLane && hasOwnWork(work) ? PHASE_SOURCE.worktree : PHASE_SOURCE.base
18
+
19
+ const LETTER_SUFFIX = /[a-z]$/
20
+
21
+ /** A sprint phase `4b` belongs to the plan phase `4` as well as to `4b`. */
22
+ export const belongsToLane = (planPhaseId: string, laneIds: readonly string[]): boolean =>
23
+ laneIds.some((id) => id === planPhaseId || id.replace(LETTER_SUFFIX, '') === planPhaseId)
24
+
25
+ const doneStates = (phase: PlanPhase): boolean[] => phase.items.map((item) => item.done)
26
+
27
+ const sameTicks = (left: PlanPhase, right: PlanPhase): boolean => {
28
+ const [leftStates, rightStates] = [doneStates(left), doneStates(right)]
29
+ return leftStates.length === rightStates.length && leftStates.every((done, index) => done === rightStates[index])
30
+ }
31
+
32
+ const tickCount = (phase: PlanPhase): number => phase.items.filter((item) => item.done).length
33
+
34
+ /** The lane ticked further than the base: the worktree's copy is newer, not an old copy of another lane's work. */
35
+ const tickedFurther = (worktreePhase: PlanPhase, basePhase: PlanPhase): boolean =>
36
+ tickCount(worktreePhase) > tickCount(basePhase)
37
+
38
+ /** A phase read from the worktree is touched when its ticks differ from the base's, or the base lacks it. */
39
+ const markTouched = (phase: PlanPhase, basePhase: PlanPhase | undefined): PlanPhase =>
40
+ basePhase && sameTicks(phase, basePhase) ? phase : { ...phase, touched: true }
41
+
42
+ /**
43
+ * The phases of a plan as the lane sees them: the base ref's phases in their order, each replaced by the
44
+ * worktree's version when `phaseSource` says so, or, for a phase no row of the lane names, when the lane has
45
+ * work of its own and ticked it further than the base; a phase only the worktree has follows. A phase the
46
+ * worktree ticked less stays at the base: the lane holds an old copy of what another lane finished. Only the
47
+ * count is compared, so a tick of the lane's own stays hidden while the base has more ticks in that phase.
48
+ */
49
+ export const mergePlanPhases = (
50
+ base: readonly PlanPhase[],
51
+ worktree: readonly PlanPhase[] | null,
52
+ laneIds: readonly string[],
53
+ work: OwnWork,
54
+ ): PlanPhase[] => {
55
+ const laneHasWork = hasOwnWork(work)
56
+ const worktreeById = new Map((worktree ?? []).map((phase) => [phase.id, phase]))
57
+ const baseIds = new Set(base.map((phase) => phase.id))
58
+ const readsWorktree = (id: string) => phaseSource(work, belongsToLane(id, laneIds)) === PHASE_SOURCE.worktree
59
+ const readsWorktreeCopy = (worktreePhase: PlanPhase, basePhase: PlanPhase) =>
60
+ readsWorktree(basePhase.id) || (laneHasWork && tickedFurther(worktreePhase, basePhase))
61
+ const inBaseOrder = base.map((basePhase) => {
62
+ const worktreePhase = worktreeById.get(basePhase.id)
63
+ return worktreePhase && readsWorktreeCopy(worktreePhase, basePhase)
64
+ ? markTouched(worktreePhase, basePhase)
65
+ : basePhase
66
+ })
67
+ const added = laneHasWork
68
+ ? [...worktreeById.values()].filter((phase) => !baseIds.has(phase.id)).map((phase) => markTouched(phase, undefined))
69
+ : []
70
+ return [...inBaseOrder, ...added]
71
+ }