jackherd 1.8.1 → 1.10.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 (261) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/INSTALL.md +3 -1
  3. package/README.md +75 -64
  4. package/dist/{chunk-OU2KALXP.js → chunk-5MQBCUQ5.js} +24 -4
  5. package/dist/config.d.ts +45 -4
  6. package/dist/config.js +84 -27
  7. package/dist/plan-format.d.ts +63 -7
  8. package/dist/plan-format.js +66 -12
  9. package/docs/workflow-dark.svg +67 -63
  10. package/docs/workflow.svg +67 -63
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/ask-rules.json +15 -0
  14. package/kit/hooks/hooks/approval-gate/index.ts +22 -4
  15. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  16. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  17. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  18. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  19. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  20. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  21. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  22. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  23. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  24. package/kit/hooks/hooks/merge-guard/decision.ts +127 -140
  25. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  26. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  27. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  28. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  29. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  30. package/kit/hooks/hooks/open-question/index.ts +26 -0
  31. package/kit/hooks/hooks/open-question/record.ts +135 -0
  32. package/kit/hooks/hooks/open-question/run.ts +52 -0
  33. package/kit/hooks/hooks/open-question/store.ts +39 -0
  34. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  35. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  36. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  37. package/kit/hooks/hooks/sent-file/store.ts +9 -0
  38. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  39. package/kit/hooks/hooks/shared/config.ts +7 -0
  40. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  41. package/kit/hooks/hooks/shared/files.ts +7 -0
  42. package/kit/hooks/hooks/shared/lane.ts +1 -1
  43. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  44. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  45. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  46. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  47. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  48. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  49. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  50. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  51. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  52. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  53. package/kit/hooks/hooks.json +21 -4
  54. package/kit/hooks/statusline.sh +1 -1
  55. package/kit/review/agents/skill-review.md +34 -0
  56. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  57. package/kit/workflow/skills/follow/SKILL.md +1 -8
  58. package/kit/workflow/skills/implement/SKILL.md +10 -6
  59. package/kit/workflow/skills/orchestrate/SKILL.md +213 -412
  60. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  61. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  62. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  63. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  64. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  65. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  66. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  67. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  68. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  69. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  70. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  71. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  72. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  73. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  74. package/kit/workflow/skills/plan/SKILL.md +6 -5
  75. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  76. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  77. package/package.json +4 -1
  78. package/recipes/README.md +2 -1
  79. package/recipes/sources.md +1 -1
  80. package/schemas/jackherd.schema.json +85 -7
  81. package/src/check/agent-text-reviewers.ts +23 -0
  82. package/src/check/command.ts +2 -0
  83. package/src/check/plan-structure-check.ts +49 -2
  84. package/src/check/plan.ts +8 -1
  85. package/src/check/schema.ts +9 -0
  86. package/src/check/skill-budget-defaults.ts +55 -0
  87. package/src/check/skill-budgets.ts +185 -0
  88. package/src/cli.ts +4 -0
  89. package/src/config/auto-window.ts +8 -0
  90. package/src/config/link-kinds.ts +7 -1
  91. package/src/config/schema.ts +80 -26
  92. package/src/context/fill.ts +12 -0
  93. package/src/context/session-log.ts +43 -1
  94. package/src/lane/close-board.ts +106 -0
  95. package/src/lane/close-check.ts +183 -0
  96. package/src/lane/close-command-line.ts +11 -0
  97. package/src/lane/close-command.ts +217 -0
  98. package/src/lane/close-options.ts +43 -0
  99. package/src/lane/close-survey.ts +83 -0
  100. package/src/lane/close.ts +141 -0
  101. package/src/lane/command.ts +13 -0
  102. package/src/lane-prompt/build.ts +74 -0
  103. package/src/lane-prompt/command.ts +87 -0
  104. package/src/lane-prompt/input.ts +116 -0
  105. package/src/lane-prompt/options.ts +68 -0
  106. package/src/lane-prompt/texts.ts +101 -0
  107. package/src/plan-format/clock.ts +12 -0
  108. package/src/plan-format/dependencies.ts +9 -9
  109. package/src/plan-format/index.ts +1 -0
  110. package/src/plan-format/lanes.ts +30 -5
  111. package/src/plan-format/short-names.ts +71 -0
  112. package/src/plan-format/terminal-text.ts +4 -0
  113. package/src/plan-format/waiting.ts +53 -0
  114. package/src/plans/active-tasks.ts +60 -0
  115. package/src/plans/appointment-rows.ts +59 -0
  116. package/src/plans/ask-coordinator.ts +77 -0
  117. package/src/plans/ask-target.ts +25 -0
  118. package/src/plans/child-stop.ts +51 -0
  119. package/src/plans/closable-records.ts +34 -0
  120. package/src/plans/command.ts +3 -2
  121. package/src/plans/current-phase.ts +17 -7
  122. package/src/plans/current-task.ts +63 -5
  123. package/src/plans/fetch.ts +26 -3
  124. package/src/plans/git.ts +19 -5
  125. package/src/plans/herd-claim.ts +173 -0
  126. package/src/plans/herd-memory.ts +36 -25
  127. package/src/plans/lane-plans.ts +44 -13
  128. package/src/plans/lane-standing.ts +9 -4
  129. package/src/plans/lane-summary.ts +367 -92
  130. package/src/plans/lane-tickets.ts +15 -0
  131. package/src/plans/layout-phase.ts +80 -19
  132. package/src/plans/layout-summary.ts +163 -74
  133. package/src/plans/layout.ts +472 -114
  134. package/src/plans/link-chips.ts +1 -0
  135. package/src/plans/live.ts +119 -10
  136. package/src/plans/phase-ids.ts +8 -0
  137. package/src/plans/phase-models.ts +1 -1
  138. package/src/plans/phase-ranges.ts +45 -0
  139. package/src/plans/phase-source.ts +15 -0
  140. package/src/plans/plan-cache.ts +28 -0
  141. package/src/plans/plan-heads.ts +54 -0
  142. package/src/plans/pull-requests.ts +23 -2
  143. package/src/plans/question-records.ts +48 -0
  144. package/src/plans/questions-read.ts +13 -0
  145. package/src/plans/read-cache.ts +8 -0
  146. package/src/plans/record-id.ts +4 -0
  147. package/src/plans/restart-decision.ts +13 -4
  148. package/src/plans/selection.ts +117 -34
  149. package/src/plans/sent-files-read.ts +13 -0
  150. package/src/plans/sent-files.ts +114 -0
  151. package/src/plans/settings.ts +2 -1
  152. package/src/plans/signalled-phase.ts +42 -0
  153. package/src/plans/signals-read.ts +62 -0
  154. package/src/plans/snapshot.ts +279 -60
  155. package/src/plans/sprint-export.ts +11 -11
  156. package/src/plans/sprint-page.ts +5 -5
  157. package/src/plans/subagent-phases.ts +95 -0
  158. package/src/plans/subagent-tasks.ts +51 -0
  159. package/src/plans/subagents-read.ts +131 -0
  160. package/src/plans/subagents.ts +190 -0
  161. package/src/plans/supervisor.ts +14 -4
  162. package/src/plans/symbols.ts +3 -0
  163. package/src/plans/task-name.ts +34 -0
  164. package/src/plans/texts.ts +36 -14
  165. package/src/plans/unlisted-lanes.ts +10 -5
  166. package/src/plans/view.ts +20 -12
  167. package/src/plans/waiting-click.ts +231 -0
  168. package/src/plans/waiting-records.ts +39 -0
  169. package/src/plans/waiting-window.ts +8 -0
  170. package/src/preview/command.ts +105 -0
  171. package/src/preview/embed.ts +209 -0
  172. package/src/preview/images.ts +89 -0
  173. package/src/preview/open.ts +12 -0
  174. package/src/preview/options.ts +32 -0
  175. package/src/preview/page.ts +89 -0
  176. package/src/preview/render.ts +41 -0
  177. package/src/settled/board-row.ts +1 -2
  178. package/src/shared/is-record.ts +2 -0
  179. package/src/shared/private-location.ts +89 -0
  180. package/src/shared/private-records.ts +48 -0
  181. package/src/shared/stat-cache.ts +48 -0
  182. package/src/sync/kit-source.ts +29 -10
  183. package/src/sync/kit.ts +2 -0
  184. package/src/sync/manifest.ts +2 -0
  185. package/src/sync/plan-ask-rules.ts +94 -0
  186. package/src/sync/plan-hooks.ts +2 -0
  187. package/src/sync/render.ts +3 -3
  188. package/src/sync/settings-hooks.ts +4 -1
  189. package/src/sync/sync-plan.ts +1 -0
  190. package/src/sync/sync.ts +24 -3
  191. package/src/task-signals/agent.ts +15 -0
  192. package/src/task-signals/claude-read.ts +89 -0
  193. package/src/task-signals/claude-signals.ts +112 -0
  194. package/src/task-signals/command.ts +71 -0
  195. package/src/task-signals/fixtures/session.jsonl +7 -0
  196. package/src/task-signals/matcher.ts +96 -0
  197. package/src/task-signals/record-store.ts +40 -0
  198. package/src/task-signals/record.ts +68 -0
  199. package/src/task-signals/text.ts +13 -0
  200. package/src/task-signals/types.ts +14 -0
  201. package/src/terminal/open.ts +13 -6
  202. package/src/terminal/text.ts +16 -4
  203. package/src/terminal/width.ts +2 -1
  204. package/src/version/skill-diff.ts +46 -0
  205. package/src/vocabulary/index.ts +44 -6
  206. package/src/waiting/command.ts +50 -0
  207. package/src/watch/appointments.ts +2 -4
  208. package/src/watch/approvals.ts +2 -2
  209. package/src/watch/asking-lanes.ts +40 -0
  210. package/src/watch/base-tip.ts +11 -0
  211. package/src/watch/command.ts +401 -90
  212. package/src/watch/failed-tests.ts +56 -10
  213. package/src/watch/failure-read.ts +18 -6
  214. package/src/watch/failures.ts +29 -5
  215. package/src/watch/feedback.ts +1 -1
  216. package/src/watch/gh-process.ts +9 -3
  217. package/src/watch/heartbeat-progress.ts +6 -18
  218. package/src/watch/json-guards.ts +0 -3
  219. package/src/watch/kit-update.ts +28 -9
  220. package/src/watch/lane-fill.ts +1 -1
  221. package/src/watch/lane-model.ts +1 -1
  222. package/src/watch/lane-name.ts +6 -0
  223. package/src/watch/lane-parallel.ts +1 -1
  224. package/src/watch/lane-progress.ts +58 -14
  225. package/src/watch/lane-report-file.ts +20 -5
  226. package/src/watch/lane-report.ts +37 -5
  227. package/src/watch/lane-rest.ts +28 -0
  228. package/src/watch/lane-sources.ts +31 -3
  229. package/src/watch/main-checkout.ts +34 -0
  230. package/src/watch/plan-badges-read.ts +13 -7
  231. package/src/watch/plan-badges.ts +9 -2
  232. package/src/watch/previews.ts +1 -1
  233. package/src/watch/pull-requests.ts +8 -3
  234. package/src/watch/reference-paths.ts +87 -0
  235. package/src/watch/running-subagents.ts +43 -0
  236. package/src/watch/skill-change.ts +124 -32
  237. package/src/watch/source-change.ts +48 -0
  238. package/src/watch/source-path.ts +1 -1
  239. package/src/watch/sources.ts +7 -4
  240. package/src/watch/stream-output.ts +14 -8
  241. package/src/watch/task-parallel-auto.ts +208 -0
  242. package/src/watch/task-parallel-data-paths.ts +46 -0
  243. package/src/watch/task-parallel-input.ts +108 -0
  244. package/src/watch/task-parallel-read.ts +151 -0
  245. package/src/watch/task-parallel-touched.ts +39 -0
  246. package/src/watch/task-parallel.ts +235 -0
  247. package/src/watch/task-signal-missing.ts +69 -0
  248. package/src/watch/task-signal-read.ts +29 -0
  249. package/src/watch/texts.ts +10 -0
  250. package/src/watch/update-available.ts +6 -3
  251. package/src/watch/waiting-line.ts +19 -0
  252. package/src/watch/watch-events.ts +36 -1
  253. package/src/watch/watcher-lock.ts +25 -29
  254. package/src/watch/workflow-runs.ts +2 -1
  255. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  256. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  257. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  258. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  259. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  260. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  261. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -0,0 +1,185 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { CLAUDE_DIRECTORY, claudePath, SKILLS_DIRECTORY } from '../claude/paths'
4
+ import { projectLockCandidates } from '../config/legacy-names'
5
+ import { parseManifest } from '../sync/manifest'
6
+ import { matchesGlob } from './glob'
7
+ import { CHECK_STATUS, type CheckResult, type CheckStatus } from './result'
8
+ import type { SkillBudgets } from './schema'
9
+ import {
10
+ KIT_FILE_FALLBACK_BUDGET,
11
+ KIT_SKILL_BUDGETS,
12
+ OWN_SKILL_BUDGET,
13
+ SUPPLEMENT_BUDGET,
14
+ } from './skill-budget-defaults'
15
+
16
+ /** Words per path or glob from the project root; a key joining paths with ` + ` budgets them together. */
17
+ export type BudgetTable = Readonly<SkillBudgets>
18
+
19
+ /** A file with the budget it falls back on when neither the project nor the kit names one. */
20
+ export type FileWords = { path: string; words: number; fallback: number }
21
+
22
+ export type Measurement = { label: string; words: number; budget: number }
23
+
24
+ type Overrun = typeof CHECK_STATUS.warn | typeof CHECK_STATUS.fail
25
+
26
+ type MeasuredOverrun = { entry: Measurement; overrun: Overrun }
27
+
28
+ const SUPPLEMENTS_DIRECTORY = 'project'
29
+ const KIT_DOCUMENT = /^(skills\/.+\.md|agents\/[^/]+\.md|rules\/kit\/[^/]+\.md)$/
30
+ const KIT_RULE = /^rules\//
31
+ const PATHS_IN_FRONTMATTER = /^---\r?\n(?:(?!---)[^\n]*\n)*?paths:/
32
+ const SUM_SEPARATOR = ' + '
33
+ const FAIL_FACTOR = 1.1
34
+
35
+ const OVERRUN_LABEL: Record<Overrun, string> = { warn: 'warning', fail: 'error' }
36
+
37
+ const isGlob = (key: string): boolean => /[*?]/.test(key)
38
+
39
+ /**
40
+ * The words of a file as `wc -w` counts them: runs of non-whitespace, over the whole file. The frontmatter counts,
41
+ * because an agent reads it with the rest, and the skill's description in it is loaded before the body is.
42
+ */
43
+ export const countWords = (text: string): number => text.split(/\s+/).filter(Boolean).length
44
+
45
+ /** The paths a budget key names: one, or several joined with ` + ` for what loads together. */
46
+ export const budgetKeyPaths = (key: string): string[] => key.split(SUM_SEPARATOR)
47
+
48
+ /**
49
+ * Whether a kit file, by its path in the kit (`skills/…`, `agents/…`, `rules/kit/…`), is text an agent loads as it
50
+ * is and so counts against a budget: a skill file, an agent, or a kit rule without a `paths:` glob in its
51
+ * frontmatter; a rule with one loads only on demand. The one choice `jh check` and the kit's own test share.
52
+ */
53
+ export const isBudgetedKitDocument = (kitPath: string, text: string): boolean =>
54
+ KIT_DOCUMENT.test(kitPath) && !(KIT_RULE.test(kitPath) && PATHS_IN_FRONTMATTER.test(text))
55
+
56
+ /** A file warns above its budget and fails above the budget plus 10 %. */
57
+ export const overrunOf = (words: number, budget: number): Overrun | null => {
58
+ if (words > budget * FAIL_FACTOR) return CHECK_STATUS.fail
59
+ return words > budget ? CHECK_STATUS.warn : null
60
+ }
61
+
62
+ /** The project's entry for the exact path, else its first glob that matches, else the kit's default, else `fallback`. */
63
+ export const budgetOf = (path: string, project: BudgetTable, defaults: BudgetTable, fallback: number): number => {
64
+ const glob = Object.keys(project).find((key) => isGlob(key) && matchesGlob(path, key))
65
+ return project[path] ?? (glob === undefined ? undefined : project[glob]) ?? defaults[path] ?? fallback
66
+ }
67
+
68
+ const sumEntry = (key: string, files: readonly FileWords[]): number | null => {
69
+ const parts = budgetKeyPaths(key).map((part) => files.find((file) => file.path === part))
70
+ return parts.every((part) => part !== undefined) ? parts.reduce((total, part) => total + part.words, 0) : null
71
+ }
72
+
73
+ /** Every file against its budget, then every sum entry of the project and the kit whose files all exist. */
74
+ export const measureFiles = (
75
+ files: readonly FileWords[],
76
+ project: BudgetTable,
77
+ defaults: BudgetTable,
78
+ ): Measurement[] => {
79
+ const single = files.map(({ path, words, fallback }) => ({
80
+ label: path,
81
+ words,
82
+ budget: budgetOf(path, project, defaults, fallback),
83
+ }))
84
+ const sums = [...new Set([...Object.keys(project), ...Object.keys(defaults)])]
85
+ .filter((key) => key.includes(SUM_SEPARATOR))
86
+ .flatMap((key) => {
87
+ const words = sumEntry(key, files)
88
+ return words === null ? [] : [{ label: key, words, budget: budgetOf(key, project, defaults, 0) }]
89
+ })
90
+ return [...single, ...sums]
91
+ }
92
+
93
+ const overrunLine = ({ entry: { label, words, budget }, overrun }: MeasuredOverrun): string =>
94
+ `${OVERRUN_LABEL[overrun]}: ${label} has ${words} words, budget ${budget}`
95
+
96
+ const withinLine = (count: number): string =>
97
+ count === 1 ? '1 file within its word budget' : `${count} files within their word budget`
98
+
99
+ const statusOf = (overruns: readonly MeasuredOverrun[]): CheckStatus => {
100
+ if (overruns.some(({ overrun }) => overrun === CHECK_STATUS.fail)) return CHECK_STATUS.fail
101
+ return overruns.length > 0 ? CHECK_STATUS.warn : CHECK_STATUS.pass
102
+ }
103
+
104
+ /** One line per overrun; one line in total when none is over. Warns above a budget, fails above the budget plus 10 %. */
105
+ export const budgetResult = (measured: readonly Measurement[], durationMs: number): CheckResult => {
106
+ const overruns = measured.flatMap((entry) => {
107
+ const overrun = overrunOf(entry.words, entry.budget)
108
+ return overrun === null ? [] : [{ entry, overrun }]
109
+ })
110
+ return {
111
+ name: 'Skill budgets',
112
+ command: `${measured.length} measured, words per budget`,
113
+ status: statusOf(overruns),
114
+ detail: overruns.map(overrunLine).join('\n') || withinLine(measured.length),
115
+ durationMs,
116
+ }
117
+ }
118
+
119
+ const readText = (path: string): string | null => {
120
+ try {
121
+ return readFileSync(path, 'utf8')
122
+ } catch {
123
+ return null
124
+ }
125
+ }
126
+
127
+ const parseJson = (text: string): unknown => {
128
+ try {
129
+ return JSON.parse(text)
130
+ } catch {
131
+ return null
132
+ }
133
+ }
134
+
135
+ /** The kit documents the first valid lock file lists, the lock under its old name counting while sync has not moved it. */
136
+ const kitPathsOf = (root: string): string[] => {
137
+ for (const candidate of projectLockCandidates(root)) {
138
+ const text = readText(candidate)
139
+ const parsed = text === null ? null : parseManifest(parseJson(text))
140
+ if (parsed?.ok) return Object.keys(parsed.manifest.files).filter((key) => KIT_DOCUMENT.test(key))
141
+ }
142
+ return []
143
+ }
144
+
145
+ const listDirectory = (path: string): string[] => (existsSync(path) ? readdirSync(path) : [])
146
+
147
+ const fileWordsOf = (root: string, path: string, fallback: number): FileWords[] => {
148
+ const text = readText(join(root, path))
149
+ return text === null ? [] : [{ path, words: countWords(text), fallback }]
150
+ }
151
+
152
+ const kitFileWordsOf = (root: string, kitPath: string): FileWords[] => {
153
+ const path = claudePath(kitPath)
154
+ const text = readText(join(root, path))
155
+ return text === null || !isBudgetedKitDocument(kitPath, text)
156
+ ? []
157
+ : [{ path, words: countWords(text), fallback: KIT_FILE_FALLBACK_BUDGET }]
158
+ }
159
+
160
+ const ownSkillPaths = (root: string, kit: ReadonlySet<string>): string[] =>
161
+ listDirectory(join(root, CLAUDE_DIRECTORY, SKILLS_DIRECTORY))
162
+ .map((name) => claudePath(SKILLS_DIRECTORY, name, 'SKILL.md'))
163
+ .filter((path) => !kit.has(path))
164
+
165
+ const supplementPaths = (root: string): string[] =>
166
+ listDirectory(join(root, CLAUDE_DIRECTORY, SUPPLEMENTS_DIRECTORY))
167
+ .filter((name) => name.endsWith('.md'))
168
+ .map((name) => claudePath(SUPPLEMENTS_DIRECTORY, name))
169
+
170
+ /**
171
+ * The installed kit skills with their references, kit agents and kit rules that load every session (the lock file
172
+ * says which files the kit installed), the project's supplements and its own skills, measured against the budgets.
173
+ * Null when there is nothing to measure.
174
+ */
175
+ export const skillBudgetCheck = (root: string, project: BudgetTable = {}): CheckResult | null => {
176
+ const started = Date.now()
177
+ const kitPaths = kitPathsOf(root)
178
+ const kit = new Set(kitPaths.map((key) => claudePath(key)))
179
+ const files = [
180
+ ...kitPaths.flatMap((key) => kitFileWordsOf(root, key)),
181
+ ...supplementPaths(root).flatMap((path) => fileWordsOf(root, path, SUPPLEMENT_BUDGET)),
182
+ ...ownSkillPaths(root, kit).flatMap((path) => fileWordsOf(root, path, OWN_SKILL_BUDGET)),
183
+ ]
184
+ return files.length === 0 ? null : budgetResult(measureFiles(files, project, KIT_SKILL_BUDGETS), Date.now() - started)
185
+ }
package/src/cli.ts CHANGED
@@ -24,9 +24,13 @@ export const COMMANDS: CommandTable = {
24
24
  [HERD_COMMAND.name]: () => import('./plans/command'),
25
25
  init: () => import('./sync/init-command'),
26
26
  [HERD_COMMAND.alias]: () => import('./plans/command'),
27
+ lane: () => import('./lane/command'),
28
+ preview: () => import('./preview/command'),
27
29
  settled: () => import('./settled/command'),
28
30
  sync: () => import('./sync/command'),
31
+ task: () => import('./task-signals/command'),
29
32
  update: () => import('./update/command'),
33
+ waiting: () => import('./waiting/command'),
30
34
  watch: () => import('./watch/command'),
31
35
  }
32
36
 
@@ -0,0 +1,8 @@
1
+ import { DAY_MS } from '../plan-format/clock'
2
+
3
+ /** The longest window `watch.parallelTasks.auto` may span: a forgotten switch ends within a week. */
4
+ export const MAX_AUTO_WINDOW_DAYS = 7
5
+
6
+ /** The days from `since` to `until`, two `YYYY-MM-DD` dates; read as UTC midnights, so no clock change shifts them. */
7
+ export const windowDays = (since: string, until: string): number =>
8
+ Math.round((Date.parse(`${until}T00:00:00Z`) - Date.parse(`${since}T00:00:00Z`)) / DAY_MS)
@@ -8,11 +8,17 @@ export type LinkKind = {
8
8
  icon: string
9
9
  }
10
10
 
11
+ export const ARTIFACT_LINK_KIND: LinkKind = {
12
+ pattern: '^https://claude\\.ai/(code/)?artifact/',
13
+ label: 'Artifact',
14
+ icon: '✦',
15
+ }
16
+
11
17
  /** The kinds known without configuration: plain URL patterns, no service is called. */
12
18
  export const DEFAULT_LINK_KINDS: readonly LinkKind[] = [
13
19
  { pattern: '^https://github\\.com/[^/]+/[^/]+/(pull|issues)/\\d+', label: 'GitHub', icon: '⑂' },
14
20
  { pattern: '^https://(www\\.)?figma\\.com/', label: 'Figma', icon: '◈' },
15
- { pattern: '^https://claude\\.ai/(code/)?artifact/', label: 'Artifact', icon: '✦' },
21
+ ARTIFACT_LINK_KIND,
16
22
  ]
17
23
 
18
24
  export const isRegularExpression = (source: string): boolean => {
@@ -5,6 +5,7 @@ import { PLAN_LANGUAGE, RESPONSE_ADDRESS } from '../vocabulary'
5
5
  import { CONTENT_FIELDS, SOURCE_FIELD } from '../watch/source-fields'
6
6
  import { isSourcePath } from '../watch/source-path'
7
7
  import { BUN_FAILED_TEST_PATTERN, CLEAN_REVIEW_TEXT } from '../watch/watch-defaults'
8
+ import { MAX_AUTO_WINDOW_DAYS, windowDays } from './auto-window'
8
9
  import { BUN_COMMANDS, DEFAULT_BASE_BRANCH } from './detect'
9
10
  import { DEFAULT_LINK_KINDS, isRegularExpression } from './link-kinds'
10
11
  import { hasNestedQuantifier } from './nested-quantifier'
@@ -118,6 +119,31 @@ const feedbackSchema = z.strictObject({
118
119
  noFindingTemplates: z.array(z.string().min(1)).default([CLEAN_REVIEW_TEXT]),
119
120
  })
120
121
 
122
+ const DEFAULT_PARALLEL_TASKS_MAX_TOKENS = 100_000
123
+
124
+ const parallelTasksSchema = z.strictObject({
125
+ /**
126
+ * The most the extra agents of a parallel proposal may cost before they do anything: the token baseline of a fresh
127
+ * agent times the agents to add. Above it the watcher does not propose parallel tasks. The baseline is the context
128
+ * the first answer of a lane's session carried.
129
+ */
130
+ maxTokens: z.number().int().positive().default(DEFAULT_PARALLEL_TASKS_MAX_TOKENS),
131
+ /**
132
+ * The days, both included, on which a proposal that keeps every limit is marked `auto` and the coordinator starts it
133
+ * without asking: subagents beside running work in a lane, at most two extra agents, no path that always asks.
134
+ * `since` is the day it was switched on, `until` at most seven days later; outside the window it is off. Unset by
135
+ * default, so every proposal asks.
136
+ */
137
+ auto: z
138
+ .strictObject({ since: z.iso.date(), until: z.iso.date() })
139
+ .refine(({ since, until }) => until >= since, 'until must not be before since')
140
+ .refine(
141
+ ({ since, until }) => windowDays(since, until) <= MAX_AUTO_WINDOW_DAYS,
142
+ `until must be at most ${MAX_AUTO_WINDOW_DAYS} days after since`,
143
+ )
144
+ .optional(),
145
+ })
146
+
121
147
  const checksSchema = z.strictObject({
122
148
  /**
123
149
  * Finds a failed test in a line of a red run's log (`gh run view --log-failed`) after its job, step and timestamp
@@ -228,9 +254,9 @@ const hasUniqueNames = (sources: readonly { name: string }[]): boolean =>
228
254
  const coordinatorSchema = z
229
255
  .strictObject({
230
256
  /** Tokens in the coordinator's context from which the watcher reports `coordinator warn`. */
231
- warnTokens: tokenCount.default(200_000),
257
+ warnTokens: tokenCount.default(350_000),
232
258
  /** Tokens in the coordinator's context from which the watcher reports `coordinator alert`. */
233
- alertTokens: tokenCount.default(300_000),
259
+ alertTokens: tokenCount.default(500_000),
234
260
  })
235
261
  .refine(warnBelowAlert, WARN_BELOW_ALERT)
236
262
 
@@ -258,7 +284,7 @@ const checkFreePattern = z
258
284
  )
259
285
 
260
286
  /**
261
- * A path pattern that always asks before a merge under a plan approval: plain path words with wildcards, relative, no
287
+ * A path pattern that always asks (`guard.alwaysAsk`): plain path words with wildcards, relative, no
262
288
  * empty, `.` or `..` segment (GitHub lists no file with one, so such a pattern would never ask). Broad on purpose (`.github/**`, `**`), since a pattern here only ever adds a question; braces,
263
289
  * classes and negation stay out, so the hook, which has no schema library, reads exactly what this accepts.
264
290
  */
@@ -293,13 +319,13 @@ const mergeSchema = z.strictObject({
293
319
  withoutChecks: z.array(checkFreePattern).default([]),
294
320
  })
295
321
 
296
- const guardSchema = z.strictObject({
322
+ const guardFields = z.strictObject({
297
323
  /**
298
- * The commands the merge guard hook asks the user about before they run, also behind options before the verb, a chain,
299
- * a wrapper or `sh -c`. `gh api` asks only for a write to a merge, a file, a dispatch, a rerun or a ref. An empty list switches the
300
- * guard off.
324
+ * The commands the merge guard hook asks the user about before they run, where they stand at a program position, also
325
+ * behind options before the verb, a chain, a wrapper or `sh -c`. `gh api`, when listed, asks only for a write to a
326
+ * merge, a file, a dispatch, a rerun or a ref. An empty list switches the guard off.
301
327
  */
302
- commands: z.array(guardedCommand).default(['gh pr merge', 'gh workflow run', 'gh run rerun', 'gh api']),
328
+ commands: z.array(guardedCommand).default(['gh pr merge', 'gh workflow run']),
303
329
  selfMerge: z
304
330
  .strictObject({
305
331
  /** Whether `/orchestrate` offers "merge it yourself after green checks" in the pull request question; off also stops the hook honouring such an answer. */
@@ -314,32 +340,49 @@ const guardSchema = z.strictObject({
314
340
  waitMinutes: z.number().int().min(1).max(9).default(8),
315
341
  })
316
342
  .prefault({}),
343
+ /**
344
+ * Paths that always ask, on top of the ones jackherd brings (`.github/**`, `**\/migrations/**`, `**\/.env*`,
345
+ * `.claude/**`, `.jackherd/**`, `bunfig.toml`, `**\/CLAUDE.md`, `**\/AGENTS.md`, `.agents/**`, `.mcp.json`,
346
+ * `.husky/**`, `lefthook.yml`, the lockfiles of bun, npm, pnpm and yarn, `**\/.npmrc`, `**\/CODEOWNERS`): a
347
+ * `merge.withoutChecks` pattern never opens them, and the watcher never starts parallel work on them by itself. They
348
+ * add to that list, never replace it. A standing approval covers them; only workflows, migrations and env files ask.
349
+ */
350
+ alwaysAsk: z.array(alwaysAskPattern).default([]),
351
+ /**
352
+ * Checks that must be among the head commit's and green before a standing approval lets a merge pass, by their names
353
+ * on GitHub. Unset, the merge needs at least one check and every one green; set, also each of these. Either way
354
+ * GitHub must be able to merge it.
355
+ */
356
+ requiredChecks: z.array(checkName).optional(),
357
+ /** Read until 2.0.0 as `alwaysAsk` (`exceptPaths`) and `requiredChecks`; the plan approval it was for is gone. */
317
358
  planApproval: z
318
- .strictObject({
319
- /**
320
- * Paths that always ask, on top of the ones jackherd brings (`.github/**`, `**\/migrations/**`, `**\/.env*`,
321
- * `.claude/**`, `.jackherd/**`, `bunfig.toml`, `**\/CLAUDE.md`, `**\/AGENTS.md`, `.agents/**`, `.mcp.json`,
322
- * `.husky/**`, `lefthook.yml`, the lockfiles of bun, npm, pnpm and yarn, `**\/.npmrc`, `**\/CODEOWNERS`, every
323
- * package.json, and the CHANGELOG.md of every package without `"private": true`):
324
- * a pull request that changes one is never merged on a plan approval alone. They add to that list, never replace
325
- * it; an invalid entry turns plan approval off, so every merge asks.
326
- */
327
- exceptPaths: z.array(alwaysAskPattern).default([]),
328
- /**
329
- * Checks that must be among the head commit's and green before a plan approval lets a merge pass, by their names
330
- * on GitHub. Unset, the merge needs at least one check and every one green; set, also each of these. Either way
331
- * GitHub must be able to merge it and the head must contain the base branch.
332
- */
333
- requiredChecks: z.array(checkName).optional(),
334
- })
335
- .prefault({}),
359
+ .strictObject({ exceptPaths: z.array(alwaysAskPattern).optional(), requiredChecks: z.array(checkName).optional() })
360
+ .optional()
361
+ .meta({ deprecated: true }),
336
362
  })
337
363
 
364
+ /** A config from before 1.10.0 kept the paths that always ask and the required checks under `planApproval`. */
365
+ const guardWithoutPlanApproval = ({ planApproval, ...guard }: z.output<typeof guardFields>) => {
366
+ const requiredChecks = guard.requiredChecks ?? planApproval?.requiredChecks
367
+ return {
368
+ ...guard,
369
+ alwaysAsk: [...guard.alwaysAsk, ...(planApproval?.exceptPaths ?? [])],
370
+ ...(requiredChecks === undefined ? {} : { requiredChecks }),
371
+ }
372
+ }
373
+
374
+ const guardSchema = guardFields.transform(guardWithoutPlanApproval)
375
+
338
376
  const hintsSchema = z.strictObject({
339
377
  /** Whether the `ask-hint` hook points out a last message that ends in a question while the turn used no `AskUserQuestion`; a lane session is never hinted. */
340
378
  askUserQuestion: z.boolean().default(true),
379
+ /** Whether the `task-hint` hook points a lane that edits a file without an open task signal to `jh task start`, once per task. */
380
+ taskSignal: z.boolean().default(true),
341
381
  })
342
382
 
383
+ /** Minutes a lane works without a task signal before the watcher reports `no task signal`. */
384
+ const DEFAULT_TASK_SIGNAL_MINUTES = 10
385
+
343
386
  const contextSchema = z
344
387
  .strictObject({
345
388
  /** Tokens in a lane's context from which its fill shows yellow and `/implement` offers a new chat. */
@@ -410,6 +453,13 @@ export const jackherdConfigSchema = z.strictObject({
410
453
  terminal: z
411
454
  .strictObject({ adapter: z.enum(Object.values(TERMINAL_ADAPTER)).default(TERMINAL_ADAPTER.none) })
412
455
  .prefault({}),
456
+ /** How the coordinator shows a draft for review (`orchestrate` § 4). */
457
+ preview: z
458
+ .strictObject({
459
+ /** Publish the draft as a private Artifact when the session has the tool; `false` sends a file card or `jh preview --open` instead. */
460
+ publish: z.boolean().default(true),
461
+ })
462
+ .prefault({}),
413
463
  plans: z
414
464
  .strictObject({
415
465
  /** Minutes between background fetches of the base branch while `jh herd` runs; 0 turns them off. */
@@ -430,6 +480,8 @@ export const jackherdConfigSchema = z.strictObject({
430
480
  /**
431
481
  * Read by `jh watch`; without `deploy` it reports no deploy runs. `feedback` is on unless set to `false`:
432
482
  * it reports new feedback on the lanes' pull requests except from the account the watcher runs as and `ignore`.
483
+ * `parallelTasks.maxTokens` caps the cost of the extra agents in a proposal of parallel tasks at the heartbeat.
484
+ * `taskSignalMinutes` is how long a lane works without a task signal before it reports `no task signal`, once per phase.
433
485
  * `checks` reads the failed tests of a red lane pull request (`failedTestPattern`), also with `feedback` off.
434
486
  * Without `previews` it reads no deploy preview; with it, one `preview` per new head commit. Each of `sources` is a
435
487
  * command whose JSON output reports `source` for a new or changed entry.
@@ -439,6 +491,8 @@ export const jackherdConfigSchema = z.strictObject({
439
491
  deploy: deploySchema.optional(),
440
492
  feedback: z.union([z.literal(false), feedbackSchema]).prefault({}),
441
493
  checks: checksSchema.prefault({}),
494
+ parallelTasks: parallelTasksSchema.prefault({}),
495
+ taskSignalMinutes: z.number().int().positive().default(DEFAULT_TASK_SIGNAL_MINUTES),
442
496
  previews: previewsSchema.optional(),
443
497
  sources: z.array(sourceSchema).refine(hasUniqueNames, 'source names must be unique').default([]),
444
498
  })
@@ -67,3 +67,15 @@ export const contextFill = (lines: readonly string[], windows: ContextWindows):
67
67
  const window = windowFor(last.model, highest, windows)
68
68
  return { tokens: last.tokens, window, share: Math.min(last.tokens / window, 1), model: last.model }
69
69
  }
70
+
71
+ /**
72
+ * What a fresh agent carries before it has worked: the input of the first main-chain answer in the lines, which holds
73
+ * the system prompt, the project instructions and the first request. Null when no line is a usable answer.
74
+ */
75
+ export const firstAnswerTokens = (lines: readonly string[]): number | null => {
76
+ for (const line of lines) {
77
+ const answer = answerOf(line)
78
+ if (answer) return answer.tokens
79
+ }
80
+ return null
81
+ }
@@ -2,6 +2,7 @@ import { closeSync, existsSync, fstatSync, openSync, readdirSync, readSync, stat
2
2
  import { homedir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { CLAUDE_DIRECTORY, SESSION_LOGS_DIRECTORY } from '../claude/paths'
5
+ import { isRecord } from '../shared/is-record'
5
6
 
6
7
  /** How much of the end of a log is read: logs grow to hundreds of megabytes, and the last answers are at the end. */
7
8
  export const TAIL_BYTES = 2 * 1024 * 1024
@@ -40,6 +41,20 @@ export const readTailLines = (path: string, bytes: number): string[] => {
40
41
  }
41
42
  }
42
43
 
44
+ /** The complete lines of the first `bytes` of a file: a cut-off last line is dropped, a file read to its end keeps its last one. */
45
+ export const readHeadLines = (path: string, bytes: number): string[] => {
46
+ const descriptor = openSync(path, 'r')
47
+ try {
48
+ const { size } = fstatSync(descriptor)
49
+ const buffer = Buffer.alloc(Math.min(size, bytes))
50
+ readSync(descriptor, buffer, 0, buffer.length, 0)
51
+ const lines = buffer.toString('utf8').split('\n')
52
+ return size > bytes ? lines.slice(0, -1) : lines
53
+ } finally {
54
+ closeSync(descriptor)
55
+ }
56
+ }
57
+
43
58
  /** The tail of a session's log, read only; null when the session has no log or it cannot be read. */
44
59
  export const sessionLog = (
45
60
  sessionId: string,
@@ -59,7 +74,7 @@ export type AgentSession = { session: string | null; cwd: string | null }
59
74
  const hasAnswer = (lines: readonly string[]): boolean => lines.some((line) => line.includes('"type":"assistant"'))
60
75
 
61
76
  /** Claude Code names a project directory after the working directory, `/` and `.` turned into `-`. */
62
- const projectDirectoryOf = (cwd: string): string => cwd.replace(/[/.]/g, '-')
77
+ export const projectDirectoryOf = (cwd: string): string => cwd.replace(/[/.]/g, '-')
63
78
 
64
79
  const newestLogOf = (cwd: string, projectsDirectory: string): string | null => {
65
80
  const directory = join(projectsDirectory, projectDirectoryOf(cwd))
@@ -89,3 +104,30 @@ export const agentLog = (
89
104
  return reported
90
105
  }
91
106
  }
107
+
108
+ /**
109
+ * The path of the log `agentLog` would read: the reported session's log when it holds an answer, else the newest log
110
+ * of the agent's working directory, else the reported one; null when there is none.
111
+ */
112
+ export const agentLogPath = (
113
+ { session, cwd }: AgentSession,
114
+ projectsDirectory: string = defaultProjectsDirectory(),
115
+ ): string | null => {
116
+ const reported = session ? sessionLogPath(session, projectsDirectory) : null
117
+ try {
118
+ if (reported && hasAnswer(readTailLines(reported, TAIL_BYTES))) return reported
119
+ return (cwd ? newestLogOf(cwd, projectsDirectory) : null) ?? reported
120
+ } catch {
121
+ return reported
122
+ }
123
+ }
124
+
125
+ /** One line of a session log as an object; null for a line that is no JSON or not an object. */
126
+ export const parseLogEntry = (line: string): Record<string, unknown> | null => {
127
+ try {
128
+ const entry: unknown = JSON.parse(line)
129
+ return isRecord(entry) ? entry : null
130
+ } catch {
131
+ return null
132
+ }
133
+ }
@@ -0,0 +1,106 @@
1
+ import { twoDigits } from '../plan-format/clock'
2
+ import { laneNameOf, parseSprintLanes } from '../plan-format/lanes'
3
+ import { firstTableRows, sectionLines, sectionStart } from '../plan-format/markdown'
4
+ import { terminalSafe } from '../plan-format/terminal-text'
5
+ import { PLAN_LANGUAGE, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
6
+ import { BOARD_LANE, type BoardLane } from './close-check'
7
+
8
+ const CONTINUATION = '↳'
9
+ const FIRST_CELL = /^\|((?:[^|\\]|\\.)*)\|/
10
+
11
+ const HISTORY_HEADER: Record<PlanLanguage, readonly [string, string]> = {
12
+ [PLAN_LANGUAGE.de]: ['Datum', 'Ereignis'],
13
+ [PLAN_LANGUAGE.en]: ['Date', 'Event'],
14
+ }
15
+
16
+ const isoDate = (time: Date): string =>
17
+ `${time.getFullYear()}-${twoDigits(time.getMonth() + 1)}-${twoDigits(time.getDate())}`
18
+
19
+ /** The word for a cleaned-up lane and the day, as a name cell carries it: `abgeräumt DD.MM.`, `cleaned up YYYY-MM-DD`. */
20
+ export const closedMark = (time: Date, language: PlanLanguage): string => {
21
+ const day =
22
+ language === PLAN_LANGUAGE.de ? `${twoDigits(time.getDate())}.${twoDigits(time.getMonth() + 1)}.` : isoDate(time)
23
+ return `${PLAN_VOCABULARY[language].laneClosed} ${day}`
24
+ }
25
+
26
+ /** The date cell of a history row, `YYYY-MM-DD` in every plan language. */
27
+ export const historyDate = (time: Date): string => isoDate(time)
28
+
29
+ /** How the lanes table names `lane`, read as the herd reads it. */
30
+ export const boardLaneOf = (board: string, lane: string, language: PlanLanguage): BoardLane => {
31
+ const found = parseSprintLanes(board, language).find(({ name }) => name === lane)
32
+ if (found === undefined) return BOARD_LANE.missing
33
+ return found.closed ? BOARD_LANE.closed : BOARD_LANE.open
34
+ }
35
+
36
+ /**
37
+ * The line indexes of the lane's current rows: the rows since it was last cleaned up, as `parseSprintLanes` keeps them,
38
+ * a row that names the lane again without the mark starting it anew. Empty when the lane is closed or missing.
39
+ */
40
+ const currentRowIndexes = (board: string, lane: string, language: PlanLanguage): number[] => {
41
+ const { sections, laneClosed } = PLAN_VOCABULARY[language]
42
+ const start = sectionStart(board, sections.lanes)
43
+ if (start === -1) return []
44
+ let previous: string | null = null
45
+ let closed = false
46
+ let indexes: number[] = []
47
+ for (const { index, cells } of firstTableRows(sectionLines(board, sections.lanes))) {
48
+ const first = cells[0] ?? ''
49
+ const name = laneNameOf(first, previous)
50
+ previous = name ?? previous
51
+ if (name !== lane) continue
52
+ const marked = first.includes(laneClosed)
53
+ if (closed && !marked && !first.startsWith(CONTINUATION)) {
54
+ closed = false
55
+ indexes = []
56
+ }
57
+ closed ||= marked
58
+ indexes.push(start + index)
59
+ }
60
+ return closed ? [] : indexes
61
+ }
62
+
63
+ const withMarkedNameCell = (line: string, mark: string): string =>
64
+ line.replace(FIRST_CELL, (_cell, name: string) => `| ${name.trim()}, ${mark} |`)
65
+
66
+ /** The board with `, <mark>` appended to the name cell of each current row of the lane; unchanged without one. */
67
+ export const withLaneClosed = (board: string, lane: string, mark: string, language: PlanLanguage): string => {
68
+ const marked = new Set(currentRowIndexes(board, lane, language))
69
+ return board
70
+ .split('\n')
71
+ .map((line, index) => (marked.has(index) ? withMarkedNameCell(line, mark) : line))
72
+ .join('\n')
73
+ }
74
+
75
+ /** Text that stays inside one table cell: on one line, with its pipes escaped. */
76
+ const cell = (text: string): string => terminalSafe(text.replace(/\s+/g, ' ')).trim().replaceAll('|', '\\|')
77
+
78
+ const historyTableHead = (language: PlanLanguage): string[] => {
79
+ const header = HISTORY_HEADER[language]
80
+ return [`| ${header.join(' | ')} |`, `|${header.map((name) => '-'.repeat(name.length + 2)).join('|')}|`]
81
+ }
82
+
83
+ const withoutTrailingBlanks = (lines: readonly string[]): string[] => {
84
+ let end = lines.length
85
+ while (end > 0 && lines[end - 1].trim() === '') end -= 1
86
+ return lines.slice(0, end)
87
+ }
88
+
89
+ /**
90
+ * The board with a row on top of its history table, newest first as the coordinator writes them. A history section
91
+ * without a table gets one right under its heading; a board without the section gets it at its end.
92
+ */
93
+ export const withHistoryRow = (board: string, date: string, text: string, language: PlanLanguage): string => {
94
+ const heading = PLAN_VOCABULARY[language].sections.history
95
+ const row = `| ${date} | ${cell(text)} |`
96
+ const lines = board.split('\n')
97
+ const start = sectionStart(board, heading)
98
+ if (start === -1)
99
+ return [...withoutTrailingBlanks(lines), '', `## ${heading}`, '', ...historyTableHead(language), row, ''].join('\n')
100
+ const tableAt = sectionLines(board, heading).findIndex((line) => line.startsWith('|'))
101
+ const firstRow = start + tableAt + 2
102
+ if (tableAt !== -1) return [...lines.slice(0, firstRow), row, ...lines.slice(firstRow)].join('\n')
103
+ const at = lines[start]?.trim() === '' ? start + 1 : start
104
+ const table = [...historyTableHead(language), row, ...(lines[start]?.trim() === '' ? [''] : [])]
105
+ return [...lines.slice(0, at), ...table, ...lines.slice(at)].join('\n')
106
+ }