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,352 @@
1
+ import { currentPhase, type LaneProgress } from '../plan-format'
2
+ import { AGENT_STATUS } from '../terminal/types'
3
+ import { chipUrlOf, isChipKey, planUrlOf } from './link-chips'
4
+
5
+ /**
6
+ * Whether a lane is drawn open, and the agent status that was true when that was decided. The fold
7
+ * counts only while the lane still has that status: a change of status hands the decision back to
8
+ * the automatic rule (`autoOpen`), so a lane that starts working opens by itself even if a person closed it.
9
+ */
10
+ export type LaneFold = { status: string | null; open: boolean }
11
+
12
+ /**
13
+ * The row under the cursor and the expanded phases, both as keys, whether ticked phases and items are
14
+ * listed one by one or summed up in one line, whether the footer shows the symbol legend, and the
15
+ * folds of the lanes by lane name, and the lanes whose notes (sprint rows without a phase) are listed.
16
+ */
17
+ export type Selection = {
18
+ selected: string | null
19
+ expanded: ReadonlySet<string>
20
+ showDone: boolean
21
+ showLegend: boolean
22
+ laneFolds: ReadonlyMap<string, LaneFold>
23
+ notesShown: ReadonlySet<string>
24
+ }
25
+
26
+ /** A lane as the folds need it: its name and the status its agent reports, null without an agent. */
27
+ export type LaneStatusEntry = { lane: string; status: string | null }
28
+
29
+ /** What the screen shows: the selection, the first visible body line, and whether the window follows the cursor. */
30
+ export type View = { selection: Selection; top: number; follow: boolean }
31
+
32
+ /** What an input needs to know about the frame on screen. */
33
+ export type InputContext = {
34
+ keys: readonly string[]
35
+ targets: readonly ClickTarget[]
36
+ headerRows: number
37
+ bodyRows: number
38
+ }
39
+
40
+ /** A clickable span of a frame line, columns 0-based and `to` exclusive. */
41
+ export type ClickTarget = { line: number; from: number; to: number; key: string }
42
+
43
+ /** An SGR mouse report; column and row are 1-based as the terminal sends them. */
44
+ export type MouseEvent = { button: number; column: number; row: number; pressed: boolean }
45
+
46
+ export const INPUT = {
47
+ up: ['\x1b[A', '\x1bOA', 'k'],
48
+ down: ['\x1b[B', '\x1bOB', 'j'],
49
+ toggle: ['\r', ' '],
50
+ expand: ['\x1b[C', '\x1bOC', 'l'],
51
+ collapse: ['\x1b[D', '\x1bOD', 'h'],
52
+ showDone: ['a'],
53
+ legend: ['?'],
54
+ notes: ['n'],
55
+ sprintPage: ['s'],
56
+ pageUp: ['\x1b[5~'],
57
+ pageDown: ['\x1b[6~'],
58
+ } as const
59
+
60
+ const WHEEL_LINES = 3
61
+
62
+ const ESC = '\x1b'
63
+ const ESCAPE_SEQUENCE = new RegExp(`${ESC}(?:\\[[<0-9;]*[A-Za-z~]|O[A-Za-z])|[\\s\\S]`, 'gu')
64
+ const MOUSE_REPORT = new RegExp(`^${ESC}\\[<(\\d+);(\\d+);(\\d+)([Mm])$`)
65
+
66
+ const MOUSE_BUTTON = { left: 0, wheelUp: 64, wheelDown: 65 } as const
67
+
68
+ /** One terminal read can carry several key presses; this splits it into keys, escape sequences kept whole. */
69
+ export const splitKeys = (chunk: string): string[] => chunk.match(ESCAPE_SEQUENCE) ?? []
70
+
71
+ /** The key of the closing row that lists the planned phases; no lane key starts without `@`, no phase key lacks a colon. */
72
+ export const PLANNED_KEY = 'planned'
73
+
74
+ export const phaseKey = (lane: string, stepIndex: number, phaseId: string): string => `${lane}:${stepIndex}:${phaseId}`
75
+
76
+ const NOTES_KEY_PREFIX = 'notes>'
77
+ const FOLLOWING_KEY_PREFIX = 'following>'
78
+ const TICKED_KEY_PREFIX = 'ticked>'
79
+
80
+ /** The key of the row that shows or hides the notes of a lane; selectable, since a key reaches it too. */
81
+ export const notesKey = (lane: string): string => `${NOTES_KEY_PREFIX}${lane}`
82
+
83
+ /** The key of the folded row under a lane that lists the open phases after its own. */
84
+ export const followingKey = (lane: string): string => `${FOLLOWING_KEY_PREFIX}${lane}`
85
+
86
+ /** The key of the "N ticked" line of an open phase; it is clicked only, and a click lands on the phase. */
87
+ export const tickedKey = (phase: string): string => `${TICKED_KEY_PREFIX}${phase}`
88
+
89
+ const ITEM_KEY = /^(.+:\d+:[^:#]+)#(\d+)$/
90
+
91
+ /**
92
+ * The key of a task row: its phase's key and the task's index in `phase.items`; a click or Enter opens its text.
93
+ * The index counts the checkboxes of the phase, so a checkbox inserted above a task shifts which task an open
94
+ * key names after the plan is reloaded; a task stays open by position, not by text.
95
+ */
96
+ export const itemKey = (phase: string, index: number): string => `${phase}#${index}`
97
+
98
+ /** The phase a task key belongs to, null for any other key. */
99
+ export const itemPhaseOfKey = (key: string): string | null => key.match(ITEM_KEY)?.[1] ?? null
100
+
101
+ const notesLaneOfKey = (key: string): string | null =>
102
+ key.startsWith(NOTES_KEY_PREFIX) ? key.slice(NOTES_KEY_PREFIX.length) : null
103
+
104
+ const followingLaneOfKey = (key: string): string | null =>
105
+ key.startsWith(FOLLOWING_KEY_PREFIX) ? key.slice(FOLLOWING_KEY_PREFIX.length) : null
106
+
107
+ const tickedPhaseOfKey = (key: string): string | null =>
108
+ key.startsWith(TICKED_KEY_PREFIX) ? key.slice(TICKED_KEY_PREFIX.length) : null
109
+
110
+ const LANE_KEY_PREFIX = '@'
111
+
112
+ /** The key of a lane's own row; it cannot collide with a phase key, which starts with a lane name and a colon. */
113
+ export const laneKey = (lane: string): string => `${LANE_KEY_PREFIX}${lane}`
114
+
115
+ const laneOfKey = (key: string): string | null => (key.startsWith(LANE_KEY_PREFIX) ? key.slice(1) : null)
116
+
117
+ const PHASE_KEY = /^(.+):\d+:[^:]+$/
118
+
119
+ /** The lane a row belongs to: its own row or one of its phases; null for a pull request row. */
120
+ const laneNameOfKey = (key: string): string | null =>
121
+ laneOfKey(key) ?? notesLaneOfKey(key) ?? followingLaneOfKey(key) ?? key.match(PHASE_KEY)?.[1] ?? null
122
+
123
+ /** Working and blocked lanes show their phases; idle and done ones, and lanes without an agent, fold into one line. */
124
+ export const autoOpen = (status: string | null): boolean =>
125
+ status !== null && status !== AGENT_STATUS.idle && status !== AGENT_STATUS.done
126
+
127
+ /** Whether a lane is drawn open: the person's choice while its status is unchanged, else the automatic rule. */
128
+ export const isLaneOpen = ({ laneFolds }: Selection, lane: string, status: string | null): boolean => {
129
+ const fold = laneFolds.get(lane)
130
+ return fold && fold.status === status ? fold.open : autoOpen(status)
131
+ }
132
+
133
+ /**
134
+ * Lets the folds follow the lanes: a lane keeps its fold while its status is the same, gets the
135
+ * automatic one when its status changed or it is new, and loses it when it left the sprint. Returns
136
+ * the same object when nothing changed.
137
+ */
138
+ export const syncLaneFolds = (selection: Selection, lanes: readonly LaneStatusEntry[]): Selection => {
139
+ const folds = new Map<string, LaneFold>()
140
+ for (const { lane, status } of lanes) {
141
+ const fold = selection.laneFolds.get(lane)
142
+ folds.set(lane, fold && fold.status === status ? fold : { status, open: autoOpen(status) })
143
+ }
144
+ const unchanged =
145
+ folds.size === selection.laneFolds.size &&
146
+ [...folds].every(([lane, fold]) => selection.laneFolds.get(lane) === fold)
147
+ return unchanged ? selection : { ...selection, laneFolds: folds }
148
+ }
149
+
150
+ export const overviewSelection = (): Selection => ({
151
+ selected: null,
152
+ expanded: new Set(),
153
+ showDone: false,
154
+ showLegend: false,
155
+ laneFolds: new Map(),
156
+ notesShown: new Set(),
157
+ })
158
+
159
+ /** The lane view opens its lane and, when it has a current phase, that phase, expanded. */
160
+ export const laneSelection = (progress: LaneProgress | undefined, status: string | null): Selection => {
161
+ const base = overviewSelection()
162
+ if (!progress) return base
163
+ const opened = { ...base, laneFolds: new Map([[progress.lane.name, { status, open: true }]]) }
164
+ const phase = currentPhase(progress)
165
+ if (!progress.current || !phase) return opened
166
+ const key = phaseKey(progress.lane.name, progress.current.stepIndex, phase.id)
167
+ return { ...opened, selected: key, expanded: new Set([key]) }
168
+ }
169
+
170
+ /** The selectable rows in screen order, once each: only what is drawn can be selected; a chip or a "N ticked" line is clicked, not selected. */
171
+ export const targetKeys = (targets: readonly ClickTarget[]): string[] => [
172
+ ...new Set(targets.map((target) => target.key).filter((key) => !isChipKey(key) && tickedPhaseOfKey(key) === null)),
173
+ ]
174
+
175
+ /** The selected key, or the first one while nothing is selected or the selected phase left the sprint. */
176
+ export const selectedKey = ({ selected }: Selection, keys: readonly string[]): string | null =>
177
+ selected && keys.includes(selected) ? selected : (keys[0] ?? null)
178
+
179
+ const isInput = (group: readonly string[], input: string): boolean => group.includes(input)
180
+
181
+ const move = (selection: Selection, keys: readonly string[], step: number): Selection => {
182
+ const current = selectedKey(selection, keys)
183
+ const index = current ? keys.indexOf(current) : 0
184
+ const next = keys[Math.min(Math.max(index + step, 0), keys.length - 1)] ?? null
185
+ return { ...selection, selected: next }
186
+ }
187
+
188
+ const toggleIn = (keys: Set<string>, key: string): void => {
189
+ if (!keys.delete(key)) keys.add(key)
190
+ }
191
+
192
+ const opened = () => true
193
+ const closed = () => false
194
+ const flipped = (open: boolean) => !open
195
+
196
+ /** Changes the fold of a lane; a lane without a fold is left alone, which `syncLaneFolds` rules out once a snapshot is loaded. */
197
+ const foldLane = (selection: Selection, lane: string, next: (open: boolean) => boolean): Selection => {
198
+ const fold = selection.laneFolds.get(lane)
199
+ if (!fold) return selection
200
+ return { ...selection, laneFolds: new Map(selection.laneFolds).set(lane, { ...fold, open: next(fold.open) }) }
201
+ }
202
+
203
+ const withNotes = (selection: Selection, lane: string, next: (shown: boolean) => boolean): Selection => {
204
+ const notesShown = new Set(selection.notesShown)
205
+ if (next(notesShown.has(lane))) notesShown.add(lane)
206
+ else notesShown.delete(lane)
207
+ return { ...selection, notesShown }
208
+ }
209
+
210
+ /** Opens a task and the phase it belongs to. */
211
+ const openTaskWithPhase = (expanded: Set<string>, phase: string, task: string): void => {
212
+ expanded.add(phase)
213
+ expanded.add(task)
214
+ }
215
+
216
+ /** Selects a row and opens it, or closes it when it is already open: a phase shows its items, a lane its phases. */
217
+ export const applyClick = (selection: Selection, key: string): Selection => {
218
+ const notesLane = notesLaneOfKey(key)
219
+ if (notesLane !== null) return { ...withNotes(selection, notesLane, flipped), selected: key }
220
+ const phase = tickedPhaseOfKey(key)
221
+ if (phase !== null) return { ...selection, showDone: !selection.showDone, selected: phase }
222
+ const lane = laneOfKey(key)
223
+ if (lane !== null) return { ...foldLane(selection, lane, flipped), selected: key }
224
+ const expanded = new Set(selection.expanded)
225
+ const itemPhase = itemPhaseOfKey(key)
226
+ if (itemPhase !== null && !expanded.has(itemPhase)) openTaskWithPhase(expanded, itemPhase, key)
227
+ else toggleIn(expanded, key)
228
+ return { ...selection, selected: key, expanded }
229
+ }
230
+
231
+ const toggle = (selection: Selection, keys: readonly string[]): Selection => {
232
+ const current = selectedKey(selection, keys)
233
+ return current ? applyClick(selection, current) : selection
234
+ }
235
+
236
+ const changeSelected = (selection: Selection, keys: readonly string[], next: (open: boolean) => boolean): Selection => {
237
+ const current = selectedKey(selection, keys)
238
+ if (!current) return selection
239
+ const notesLane = notesLaneOfKey(current)
240
+ if (notesLane !== null) return { ...withNotes(selection, notesLane, next), selected: current }
241
+ const lane = laneOfKey(current)
242
+ if (lane !== null) return { ...foldLane(selection, lane, next), selected: current }
243
+ const expanded = new Set(selection.expanded)
244
+ if (next(expanded.has(current))) {
245
+ expanded.add(current)
246
+ const itemPhase = itemPhaseOfKey(current)
247
+ if (itemPhase !== null) expanded.add(itemPhase)
248
+ } else expanded.delete(current)
249
+ return { ...selection, selected: current, expanded }
250
+ }
251
+
252
+ const expand = (selection: Selection, keys: readonly string[]): Selection => changeSelected(selection, keys, opened)
253
+
254
+ const collapse = (selection: Selection, keys: readonly string[]): Selection => changeSelected(selection, keys, closed)
255
+
256
+ /** Lists or hides the notes of the lane under the cursor. */
257
+ const toggleNotes = (selection: Selection, keys: readonly string[]): Selection => {
258
+ const current = selectedKey(selection, keys)
259
+ const lane = current ? laneNameOfKey(current) : null
260
+ if (!lane) return selection
261
+ return withNotes(selection, lane, flipped)
262
+ }
263
+
264
+ export const applyKey = (selection: Selection, input: string, keys: readonly string[]): Selection => {
265
+ if (isInput(INPUT.up, input)) return move(selection, keys, -1)
266
+ if (isInput(INPUT.down, input)) return move(selection, keys, 1)
267
+ if (isInput(INPUT.toggle, input)) return toggle(selection, keys)
268
+ if (isInput(INPUT.expand, input)) return expand(selection, keys)
269
+ if (isInput(INPUT.collapse, input)) return collapse(selection, keys)
270
+ if (isInput(INPUT.showDone, input)) return { ...selection, showDone: !selection.showDone }
271
+ if (isInput(INPUT.notes, input)) return toggleNotes(selection, keys)
272
+ if (isInput(INPUT.legend, input)) return { ...selection, showLegend: !selection.showLegend }
273
+ return selection
274
+ }
275
+
276
+ /** The key that writes the whole sprint as an HTML page and opens it. */
277
+ export const isSprintPageKey = (input: string): boolean => isInput(INPUT.sprintPage, input)
278
+
279
+ export const parseMouse = (key: string): MouseEvent | null => {
280
+ const match = key.match(MOUSE_REPORT)
281
+ if (!match) return null
282
+ return { button: Number(match[1]), column: Number(match[2]), row: Number(match[3]), pressed: match[4] === 'M' }
283
+ }
284
+
285
+ export const targetAt = (targets: readonly ClickTarget[], line: number, column: number): string | null =>
286
+ targets.find((target) => target.line === line && column >= target.from && column < target.to)?.key ?? null
287
+
288
+ const scrollDelta = (input: string, mouse: MouseEvent | null, bodyRows: number): number => {
289
+ if (mouse?.button === MOUSE_BUTTON.wheelUp) return -WHEEL_LINES
290
+ if (mouse?.button === MOUSE_BUTTON.wheelDown) return WHEEL_LINES
291
+ if (isInput(INPUT.pageUp, input)) return -Math.max(bodyRows - 1, 1)
292
+ if (isInput(INPUT.pageDown, input)) return Math.max(bodyRows - 1, 1)
293
+ return 0
294
+ }
295
+
296
+ const clicked = (mouse: MouseEvent, view: View, { targets, headerRows }: InputContext): string | null => {
297
+ if (!mouse.pressed || mouse.button !== MOUSE_BUTTON.left) return null
298
+ return targetAt(targets, view.top + mouse.row - 1 - headerRows, mouse.column - 1)
299
+ }
300
+
301
+ /**
302
+ * Applies one key or mouse report. Wheel and page keys move the window and stop it following the
303
+ * cursor; everything that changes the selection makes it follow again.
304
+ */
305
+ export const applyInput = (view: View, input: string, context: InputContext): View => {
306
+ const mouse = parseMouse(input)
307
+ const delta = scrollDelta(input, mouse, context.bodyRows)
308
+ if (delta !== 0) return { ...view, top: Math.max(view.top + delta, 0), follow: false }
309
+ const key = mouse ? clicked(mouse, view, context) : null
310
+ if (mouse)
311
+ return key && !isChipKey(key) ? { ...view, selection: applyClick(view.selection, key), follow: true } : view
312
+ const selection = applyKey(view.selection, input, context.keys)
313
+ return selection === view.selection ? view : { ...view, selection, follow: true }
314
+ }
315
+
316
+ /** The row an input acts on: enter, space or right on the selected row, or a left click; null for anything else. */
317
+ export const activatedKey = (view: View, input: string, context: InputContext): string | null => {
318
+ const mouse = parseMouse(input)
319
+ if (mouse) return clicked(mouse, view, context)
320
+ const activates = isInput(INPUT.toggle, input) || isInput(INPUT.expand, input)
321
+ return activates ? selectedKey(view.selection, context.keys) : null
322
+ }
323
+
324
+ /**
325
+ * What one input does: a click on a chip asks to open its link and leaves the selection alone; activating a row
326
+ * that has a link (a pull request) selects it and asks to open the link; every other input goes through `applyInput`. `linkOf` answers the link behind a row key.
327
+ */
328
+ export const inputOutcome = (
329
+ view: View,
330
+ input: string,
331
+ context: InputContext,
332
+ linkOf: (key: string) => string | null,
333
+ ): { view: View; open: string | null } => {
334
+ const activated = activatedKey(view, input, context)
335
+ const chipUrl = activated ? (chipUrlOf(activated) ?? planUrlOf(activated)) : null
336
+ if (chipUrl) return { view, open: chipUrl }
337
+ const link = activated ? linkOf(activated) : null
338
+ if (!link) return { view: applyInput(view, input, context), open: null }
339
+ return { view: { ...view, selection: { ...view.selection, selected: activated }, follow: true }, open: link }
340
+ }
341
+
342
+ /** The first visible body line: following the cursor, or where the user scrolled, never past the last line. */
343
+ export const windowTop = (view: View, cursorLine: number, lineCount: number, rows: number): number =>
344
+ view.follow && cursorLine >= 0
345
+ ? scrollTop(view.top, cursorLine, lineCount, rows)
346
+ : Math.min(view.top, Math.max(lineCount - rows, 0))
347
+
348
+ /** The first visible line of a window of `rows` lines that keeps `cursorLine` in view, moving as little as it can. */
349
+ export const scrollTop = (top: number, cursorLine: number, lineCount: number, rows: number): number => {
350
+ const followed = cursorLine < top ? cursorLine : cursorLine >= top + rows ? cursorLine - rows + 1 : top
351
+ return Math.min(followed, Math.max(lineCount - rows, 0))
352
+ }
@@ -0,0 +1,68 @@
1
+ import { homedir } from 'node:os'
2
+ import { join, relative } from 'node:path'
3
+ import { type JackherdConfig, parseJackherdConfig } from '../config'
4
+ import { PROJECT_CONFIG_PATH } from '../config/location'
5
+ import { readConfig } from '../config/read-config'
6
+ import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
7
+
8
+ export const CONFIG_PATH = PROJECT_CONFIG_PATH
9
+
10
+ export type PlansSettings = Pick<
11
+ JackherdConfig,
12
+ 'planDirectory' | 'board' | 'worktreeDirectory' | 'baseBranch' | 'planLanguage' | 'responseLanguage'
13
+ > & {
14
+ adapter: JackherdConfig['terminal']['adapter']
15
+ fetchIntervalMinutes: number
16
+ linkKinds: JackherdConfig['plans']['linkKinds']
17
+ context: JackherdConfig['plans']['context']
18
+ }
19
+
20
+ const CLOCK_LOCALE: Record<PlanLanguage, string> = {
21
+ [PLAN_LANGUAGE.de]: 'de-DE',
22
+ [PLAN_LANGUAGE.en]: 'en-GB',
23
+ }
24
+
25
+ /** The plans view's slice of a validated config. */
26
+ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
27
+ planDirectory: config.planDirectory,
28
+ board: config.board,
29
+ worktreeDirectory: config.worktreeDirectory,
30
+ baseBranch: config.baseBranch,
31
+ planLanguage: config.planLanguage,
32
+ responseLanguage: config.responseLanguage,
33
+ adapter: config.terminal.adapter,
34
+ fetchIntervalMinutes: config.plans.fetchIntervalMinutes,
35
+ linkKinds: config.plans.linkKinds,
36
+ context: config.plans.context,
37
+ })
38
+
39
+ /** The plans view's slice of `.jackherd/config.json`, validated by the shared schema. */
40
+ export const parsePlansSettings = (raw: unknown): PlansSettings => plansSettingsOf(parseJackherdConfig(raw ?? {}))
41
+
42
+ /** Board documents, relative to the main checkout. */
43
+ export const boardGlobOf = (settings: PlansSettings): string =>
44
+ `${settings.planDirectory}/${settings.board.pattern}/${settings.board.file}`
45
+
46
+ /**
47
+ * A board file's path in the repository: relative to the main checkout `root`, or, for a board inside a lane's
48
+ * worktree under `worktreeDirectory`, relative to that worktree. Both name the same file on the base branch.
49
+ */
50
+ export const boardRepoPathOf = (root: string, settings: PlansSettings, file: string): string => {
51
+ const path = relative(root, file)
52
+ const prefix = `${settings.worktreeDirectory}/`
53
+ return path.startsWith(prefix) ? path.slice(prefix.length).split('/').slice(1).join('/') : path
54
+ }
55
+
56
+ export const worktreeOf = (root: string, settings: PlansSettings, laneName: string): string =>
57
+ join(root, settings.worktreeDirectory, laneName)
58
+
59
+ /** The remote whose base branch the view reads plans and the board from. */
60
+ export const BASE_REMOTE = 'origin'
61
+
62
+ export const baseRefOf = ({ baseBranch }: Pick<PlansSettings, 'baseBranch'>): string => `${BASE_REMOTE}/${baseBranch}`
63
+
64
+ export const clockLocaleOf = (settings: PlansSettings): string => CLOCK_LOCALE[settings.responseLanguage]
65
+
66
+ /** The plans view's settings from the config `readConfig` finds. */
67
+ export const readSettings = (roots: string[], home: string = homedir()): PlansSettings =>
68
+ plansSettingsOf(readConfig(roots, home))
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Runs `task` one at a time. A call made while it runs does not start a second one alongside: it is remembered,
3
+ * and the task runs once more when the first has finished, however many calls came meanwhile. Every caller's
4
+ * promise settles when the run it asked for is done.
5
+ */
6
+ export const singleFlight = (task: () => Promise<void>): (() => Promise<void>) => {
7
+ let running: Promise<void> | null = null
8
+ let again = false
9
+ const run = async (): Promise<void> => {
10
+ do {
11
+ again = false
12
+ await task()
13
+ } while (again)
14
+ }
15
+ return () => {
16
+ if (running !== null) {
17
+ again = true
18
+ return running
19
+ }
20
+ running = run().finally(() => {
21
+ running = null
22
+ })
23
+ return running
24
+ }
25
+ }