jackherd 1.9.0 → 1.11.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 (228) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +65 -32
  4. package/dist/{chunk-MCP4XJQH.js → chunk-RMMXD4G5.js} +18 -6
  5. package/dist/config.d.ts +99 -4
  6. package/dist/config.js +105 -29
  7. package/dist/plan-format.d.ts +51 -6
  8. package/dist/plan-format.js +49 -7
  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/ask-hint/decision.ts +82 -14
  15. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  16. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  17. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  18. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  19. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  20. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  21. package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
  22. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  23. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  24. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  25. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  26. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  27. package/kit/hooks/hooks/open-question/index.ts +26 -0
  28. package/kit/hooks/hooks/open-question/record.ts +135 -0
  29. package/kit/hooks/hooks/open-question/run.ts +57 -0
  30. package/kit/hooks/hooks/open-question/store.ts +39 -0
  31. package/kit/hooks/hooks/sent-file/store.ts +3 -12
  32. package/kit/hooks/hooks/shared/config.ts +7 -0
  33. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  34. package/kit/hooks/hooks/shared/files.ts +7 -0
  35. package/kit/hooks/hooks/shared/lane.ts +1 -1
  36. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  37. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  38. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  39. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  40. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  41. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  42. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  43. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  44. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  45. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  46. package/kit/hooks/hooks.json +13 -5
  47. package/kit/review/agents/skill-review.md +34 -0
  48. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  49. package/kit/rules/rules/kit/pull-requests.md +2 -0
  50. package/kit/workflow/skills/follow/SKILL.md +3 -10
  51. package/kit/workflow/skills/implement/SKILL.md +8 -6
  52. package/kit/workflow/skills/orchestrate/SKILL.md +218 -433
  53. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  54. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  55. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  56. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  57. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  58. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  59. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  60. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  61. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  62. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  63. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  64. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  65. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  66. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  67. package/kit/workflow/skills/plan/SKILL.md +6 -5
  68. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  69. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  70. package/package.json +3 -1
  71. package/recipes/README.md +2 -1
  72. package/recipes/sources.md +5 -1
  73. package/schemas/jackherd.schema.json +139 -5
  74. package/src/check/agent-text-reviewers.ts +23 -0
  75. package/src/check/command.ts +2 -0
  76. package/src/check/plan-structure-check.ts +49 -2
  77. package/src/check/plan.ts +8 -1
  78. package/src/check/schema.ts +9 -0
  79. package/src/check/skill-budget-defaults.ts +55 -0
  80. package/src/check/skill-budgets.ts +185 -0
  81. package/src/cli.ts +2 -0
  82. package/src/config/auto-window.ts +8 -0
  83. package/src/config/link-kinds.ts +7 -1
  84. package/src/config/schema.ts +82 -25
  85. package/src/config/tones.ts +27 -0
  86. package/src/lane/close-board.ts +106 -0
  87. package/src/lane/close-check.ts +183 -0
  88. package/src/lane/close-command-line.ts +11 -0
  89. package/src/lane/close-command.ts +217 -0
  90. package/src/lane/close-options.ts +43 -0
  91. package/src/lane/close-survey.ts +83 -0
  92. package/src/lane/close.ts +141 -0
  93. package/src/lane/command.ts +13 -0
  94. package/src/lane-prompt/additions.ts +14 -0
  95. package/src/lane-prompt/build.ts +74 -0
  96. package/src/lane-prompt/command.ts +93 -0
  97. package/src/lane-prompt/input.ts +157 -0
  98. package/src/lane-prompt/options.ts +70 -0
  99. package/src/lane-prompt/texts.ts +101 -0
  100. package/src/mermaid/command.ts +36 -0
  101. package/src/mermaid/problems.ts +48 -0
  102. package/src/plan-format/clock.ts +8 -0
  103. package/src/plan-format/dependencies.ts +9 -6
  104. package/src/plan-format/index.ts +1 -0
  105. package/src/plan-format/short-names.ts +71 -0
  106. package/src/plan-format/waiting.ts +22 -4
  107. package/src/plans/appointment-rows.ts +83 -0
  108. package/src/plans/ask-coordinator.ts +111 -0
  109. package/src/plans/ask-target.ts +36 -0
  110. package/src/plans/board-close.ts +85 -0
  111. package/src/plans/child-stop.ts +51 -0
  112. package/src/plans/closable-records.ts +34 -0
  113. package/src/plans/command.ts +3 -2
  114. package/src/plans/fetch.ts +26 -3
  115. package/src/plans/frame.ts +5 -12
  116. package/src/plans/git.ts +54 -5
  117. package/src/plans/groomed.ts +27 -0
  118. package/src/plans/herd-claim.ts +173 -0
  119. package/src/plans/herd-memory.ts +36 -25
  120. package/src/plans/lane-plans.ts +44 -13
  121. package/src/plans/lane-summary.ts +253 -45
  122. package/src/plans/layout-phase.ts +36 -11
  123. package/src/plans/layout-summary.ts +89 -18
  124. package/src/plans/layout.ts +502 -98
  125. package/src/plans/link-chips.ts +23 -4
  126. package/src/plans/live.ts +178 -23
  127. package/src/plans/next-rows.ts +44 -0
  128. package/src/plans/phase-ids.ts +8 -0
  129. package/src/plans/phase-source.ts +15 -0
  130. package/src/plans/plan-cache.ts +28 -0
  131. package/src/plans/plan-heads.ts +54 -0
  132. package/src/plans/planned.ts +22 -0
  133. package/src/plans/pull-requests.ts +17 -2
  134. package/src/plans/question-records.ts +48 -0
  135. package/src/plans/questions-read.ts +13 -0
  136. package/src/plans/read-cache.ts +8 -0
  137. package/src/plans/record-id.ts +4 -0
  138. package/src/plans/restart-decision.ts +13 -4
  139. package/src/plans/row-actions.ts +52 -0
  140. package/src/plans/row-focus.ts +148 -0
  141. package/src/plans/row-targets.ts +7 -0
  142. package/src/plans/selection.ts +111 -18
  143. package/src/plans/sent-files-read.ts +6 -15
  144. package/src/plans/sent-files.ts +7 -14
  145. package/src/plans/settings.ts +12 -1
  146. package/src/plans/signalled-phase.ts +42 -0
  147. package/src/plans/signals-read.ts +12 -4
  148. package/src/plans/snapshot.ts +162 -81
  149. package/src/plans/source-asked.ts +19 -0
  150. package/src/plans/source-prefix.ts +17 -0
  151. package/src/plans/source-rows.ts +188 -0
  152. package/src/plans/sprint-export.ts +11 -11
  153. package/src/plans/sprint-page.ts +5 -5
  154. package/src/plans/subagent-phases.ts +95 -0
  155. package/src/plans/subagent-tasks.ts +51 -0
  156. package/src/plans/subagents-read.ts +66 -17
  157. package/src/plans/subagents.ts +64 -6
  158. package/src/plans/supervisor.ts +14 -4
  159. package/src/plans/symbols.ts +3 -0
  160. package/src/plans/task-name.ts +34 -0
  161. package/src/plans/texts.ts +48 -6
  162. package/src/plans/tones.ts +30 -0
  163. package/src/plans/view.ts +25 -11
  164. package/src/plans/waiting-click.ts +295 -35
  165. package/src/plans/waiting-records.ts +39 -0
  166. package/src/plans/waiting-window.ts +8 -0
  167. package/src/preview/command.ts +25 -3
  168. package/src/shared/private-location.ts +89 -0
  169. package/src/shared/stat-cache.ts +48 -0
  170. package/src/sync/kit-source.ts +29 -10
  171. package/src/sync/kit.ts +2 -0
  172. package/src/sync/manifest.ts +2 -0
  173. package/src/sync/plan-ask-rules.ts +94 -0
  174. package/src/sync/plan-hooks.ts +2 -0
  175. package/src/sync/render.ts +3 -3
  176. package/src/sync/settings-hooks.ts +4 -1
  177. package/src/sync/sync-plan.ts +1 -0
  178. package/src/sync/sync.ts +24 -3
  179. package/src/task-signals/claude-read.ts +27 -10
  180. package/src/terminal/text.ts +16 -3
  181. package/src/terminal/types.ts +4 -0
  182. package/src/version/skill-diff.ts +46 -0
  183. package/src/vocabulary/index.ts +26 -6
  184. package/src/waiting/command.ts +22 -7
  185. package/src/watch/appointments.ts +1 -2
  186. package/src/watch/asking-lanes.ts +40 -0
  187. package/src/watch/base-tip.ts +11 -0
  188. package/src/watch/command.ts +330 -105
  189. package/src/watch/heartbeat-progress.ts +6 -18
  190. package/src/watch/kit-update.ts +28 -9
  191. package/src/watch/lane-name.ts +6 -0
  192. package/src/watch/lane-progress.ts +40 -8
  193. package/src/watch/lane-report-file.ts +20 -5
  194. package/src/watch/lane-report.ts +32 -4
  195. package/src/watch/lane-sources.ts +31 -3
  196. package/src/watch/main-checkout.ts +34 -0
  197. package/src/watch/plan-badges-read.ts +22 -9
  198. package/src/watch/plan-badges.ts +10 -2
  199. package/src/watch/pull-requests.ts +6 -2
  200. package/src/watch/reference-paths.ts +87 -0
  201. package/src/watch/report-questions.ts +4 -3
  202. package/src/watch/running-subagents.ts +43 -0
  203. package/src/watch/skill-change.ts +124 -32
  204. package/src/watch/source-change.ts +48 -0
  205. package/src/watch/source-entries.ts +55 -0
  206. package/src/watch/source-fields.ts +1 -0
  207. package/src/watch/source-statuses.ts +60 -0
  208. package/src/watch/source-tickets.ts +21 -1
  209. package/src/watch/sources.ts +47 -4
  210. package/src/watch/stream-output.ts +4 -3
  211. package/src/watch/task-parallel-auto.ts +208 -0
  212. package/src/watch/task-parallel-data-paths.ts +46 -0
  213. package/src/watch/task-parallel-input.ts +5 -15
  214. package/src/watch/task-parallel-read.ts +3 -1
  215. package/src/watch/task-parallel.ts +36 -7
  216. package/src/watch/task-signal-missing.ts +69 -0
  217. package/src/watch/task-signal-read.ts +29 -0
  218. package/src/watch/texts.ts +10 -0
  219. package/src/watch/update-available.ts +6 -3
  220. package/src/watch/watch-events.ts +23 -0
  221. package/src/watch/watcher-lock.ts +25 -29
  222. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  223. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  224. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  225. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  226. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  227. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  228. 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,6 +24,8 @@ 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
+ mermaid: () => import('./mermaid/command'),
27
29
  preview: () => import('./preview/command'),
28
30
  settled: () => import('./settled/command'),
29
31
  sync: () => import('./sync/command'),
@@ -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,10 +5,12 @@ 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'
11
12
  import { isRepositoryDirectory } from './repository-directory'
13
+ import { DEFAULT_TONES, TONE_NAMES } from './tones'
12
14
 
13
15
  export const TERMINAL_ADAPTER = { none: 'none', herdr: 'herdr' } as const
14
16
 
@@ -127,6 +129,20 @@ const parallelTasksSchema = z.strictObject({
127
129
  * the first answer of a lane's session carried.
128
130
  */
129
131
  maxTokens: z.number().int().positive().default(DEFAULT_PARALLEL_TASKS_MAX_TOKENS),
132
+ /**
133
+ * The days, both included, on which a proposal that keeps every limit is marked `auto` and the coordinator starts it
134
+ * without asking: subagents beside running work in a lane, at most two extra agents, no path that always asks.
135
+ * `since` is the day it was switched on, `until` at most seven days later; outside the window it is off. Unset by
136
+ * default, so every proposal asks.
137
+ */
138
+ auto: z
139
+ .strictObject({ since: z.iso.date(), until: z.iso.date() })
140
+ .refine(({ since, until }) => until >= since, 'until must not be before since')
141
+ .refine(
142
+ ({ since, until }) => windowDays(since, until) <= MAX_AUTO_WINDOW_DAYS,
143
+ `until must be at most ${MAX_AUTO_WINDOW_DAYS} days after since`,
144
+ )
145
+ .optional(),
130
146
  })
131
147
 
132
148
  const checksSchema = z.strictObject({
@@ -161,7 +177,7 @@ const sourcePath = z.string().refine(isSourcePath, 'not a source path: `.`, or `
161
177
 
162
178
  const SOURCE_FIELD_NAME = /^[A-Za-z][A-Za-z0-9_]*$/
163
179
 
164
- /** The seven known fields, and any further field (`priority`, `assignee`, …) whose name is a plain name, for `reportOn`. */
180
+ /** The eight known fields, and any further field (`priority`, `assignee`, …) whose name is a plain name, for `reportOn`. */
165
181
  const sourceMapSchema = z
166
182
  .object({
167
183
  [SOURCE_FIELD.id]: sourcePath,
@@ -178,6 +194,8 @@ const sourceMapSchema = z
178
194
  [SOURCE_FIELD.author]: sourcePath.optional(),
179
195
  /** A ticket source's status, shown on its badge in the herd; also a field `reportOn` can name. */
180
196
  [SOURCE_FIELD.status]: sourcePath.optional(),
197
+ /** How often the entry happened (an error count), shown as `113×` beside the entry in the herd's `Sources` block. */
198
+ [SOURCE_FIELD.count]: sourcePath.optional(),
181
199
  })
182
200
  .catchall(sourcePath)
183
201
  .refine((map) => Object.keys(map).every((field) => SOURCE_FIELD_NAME.test(field)), {
@@ -188,6 +206,8 @@ const sourceMapSchema = z
188
206
  const canChange = ({ map, reportOn }: { map: Record<string, string | undefined>; reportOn?: string[] }): boolean =>
189
207
  map.updatedAt !== undefined || CONTENT_FIELDS.some((field) => map[field] !== undefined) || reportOn !== undefined
190
208
 
209
+ const toneName = z.enum(TONE_NAMES)
210
+
191
211
  const sourceSchema = z
192
212
  .strictObject({
193
213
  /** Printed in every report of the source and the key of its saved state. */
@@ -203,6 +223,13 @@ const sourceSchema = z
203
223
  ignoreAuthors: z.array(z.string().min(1)).default([]),
204
224
  /** The most new or changed entries one report shows of this source; the rest is one line `+<n> more`. */
205
225
  reportLimit: z.number().int().min(1).default(5),
226
+ /** Shown before a row of "Waiting for you" that came from this source (`Error log · Checkout: …`); without it the source's name. */
227
+ label: z
228
+ .string()
229
+ .min(1)
230
+ .max(40)
231
+ .regex(/^[^\r\n]+$/, 'one line')
232
+ .optional(),
206
233
  /** The lane the source's reports name. */
207
234
  lane: z.string().min(1).optional(),
208
235
  /**
@@ -210,6 +237,8 @@ const sourceSchema = z
210
237
  * the lane above or the lane whose board row or plan names its id.
211
238
  */
212
239
  ticket: z.boolean().optional(),
240
+ /** The tone of the source's mark in the herd's `Sources` block while its newest report is new; without it the tone of the role `new`. */
241
+ tone: toneName.optional(),
213
242
  /**
214
243
  * The mapped fields whose change reports, with the old and the new value, in place of the content of `title`,
215
244
  * `author`, `body` and `url`; without it a change of that content reports.
@@ -269,7 +298,7 @@ const checkFreePattern = z
269
298
  )
270
299
 
271
300
  /**
272
- * A path pattern that always asks before a merge under a plan approval: plain path words with wildcards, relative, no
301
+ * A path pattern that always asks (`guard.alwaysAsk`): plain path words with wildcards, relative, no
273
302
  * 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,
274
303
  * classes and negation stay out, so the hook, which has no schema library, reads exactly what this accepts.
275
304
  */
@@ -304,13 +333,13 @@ const mergeSchema = z.strictObject({
304
333
  withoutChecks: z.array(checkFreePattern).default([]),
305
334
  })
306
335
 
307
- const guardSchema = z.strictObject({
336
+ const guardFields = z.strictObject({
308
337
  /**
309
- * The commands the merge guard hook asks the user about before they run, also behind options before the verb, a chain,
310
- * 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
311
- * guard off.
338
+ * The commands the merge guard hook asks the user about before they run, where they stand at a program position, also
339
+ * behind options before the verb, a chain, a wrapper or `sh -c`. `gh api`, when listed, asks only for a write to a
340
+ * merge, a file, a dispatch, a rerun or a ref. An empty list switches the guard off.
312
341
  */
313
- commands: z.array(guardedCommand).default(['gh pr merge', 'gh workflow run', 'gh run rerun', 'gh api']),
342
+ commands: z.array(guardedCommand).default(['gh pr merge', 'gh workflow run']),
314
343
  selfMerge: z
315
344
  .strictObject({
316
345
  /** Whether `/orchestrate` offers "merge it yourself after green checks" in the pull request question; off also stops the hook honouring such an answer. */
@@ -325,30 +354,54 @@ const guardSchema = z.strictObject({
325
354
  waitMinutes: z.number().int().min(1).max(9).default(8),
326
355
  })
327
356
  .prefault({}),
357
+ /**
358
+ * Paths that always ask, on top of the ones jackherd brings (`.github/**`, `**\/migrations/**`, `**\/.env*`,
359
+ * `.claude/**`, `.jackherd/**`, `bunfig.toml`, `**\/CLAUDE.md`, `**\/AGENTS.md`, `.agents/**`, `.mcp.json`,
360
+ * `.husky/**`, `lefthook.yml`, the lockfiles of bun, npm, pnpm and yarn, `**\/.npmrc`, `**\/CODEOWNERS`): a
361
+ * `merge.withoutChecks` pattern never opens them, and the watcher never starts parallel work on them by itself. They
362
+ * add to that list, never replace it. A standing approval covers them; only workflows, migrations and env files ask.
363
+ */
364
+ alwaysAsk: z.array(alwaysAskPattern).default([]),
365
+ /**
366
+ * Checks that must be among the head commit's and green before a standing approval lets a merge pass, by their names
367
+ * on GitHub. Unset, the merge needs at least one check and every one green; set, also each of these. Either way
368
+ * GitHub must be able to merge it.
369
+ */
370
+ requiredChecks: z.array(checkName).optional(),
371
+ /** Read until 2.0.0 as `alwaysAsk` (`exceptPaths`) and `requiredChecks`; the plan approval it was for is gone. */
328
372
  planApproval: z
329
- .strictObject({
330
- /**
331
- * Paths that always ask, on top of the ones jackherd brings (`.github/**`, `**\/migrations/**`, `**\/.env*`,
332
- * `.claude/**`, `.jackherd/**`, `bunfig.toml`, `**\/CLAUDE.md`, `**\/AGENTS.md`, `.agents/**`, `.mcp.json`,
333
- * `.husky/**`, `lefthook.yml`, the lockfiles of bun, npm, pnpm and yarn, `**\/.npmrc`, `**\/CODEOWNERS`, every
334
- * package.json, and the CHANGELOG.md of every package without `"private": true`):
335
- * a pull request that changes one is never merged on a plan approval alone. They add to that list, never replace
336
- * it; an invalid entry turns plan approval off, so every merge asks.
337
- */
338
- exceptPaths: z.array(alwaysAskPattern).default([]),
339
- /**
340
- * Checks that must be among the head commit's and green before a plan approval lets a merge pass, by their names
341
- * on GitHub. Unset, the merge needs at least one check and every one green; set, also each of these. Either way
342
- * GitHub must be able to merge it and the head must contain the base branch.
343
- */
344
- requiredChecks: z.array(checkName).optional(),
345
- })
346
- .prefault({}),
373
+ .strictObject({ exceptPaths: z.array(alwaysAskPattern).optional(), requiredChecks: z.array(checkName).optional() })
374
+ .optional()
375
+ .meta({ deprecated: true }),
347
376
  })
348
377
 
378
+ /** A config from before 1.10.0 kept the paths that always ask and the required checks under `planApproval`. */
379
+ const guardWithoutPlanApproval = ({ planApproval, ...guard }: z.output<typeof guardFields>) => {
380
+ const requiredChecks = guard.requiredChecks ?? planApproval?.requiredChecks
381
+ return {
382
+ ...guard,
383
+ alwaysAsk: [...guard.alwaysAsk, ...(planApproval?.exceptPaths ?? [])],
384
+ ...(requiredChecks === undefined ? {} : { requiredChecks }),
385
+ }
386
+ }
387
+
388
+ const guardSchema = guardFields.transform(guardWithoutPlanApproval)
389
+
349
390
  const hintsSchema = z.strictObject({
350
391
  /** 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. */
351
392
  askUserQuestion: z.boolean().default(true),
393
+ /** Whether the `task-hint` hook points a lane that edits a file without an open task signal to `jh task start`, once per task. */
394
+ taskSignal: z.boolean().default(true),
395
+ })
396
+
397
+ /** Minutes a lane works without a task signal before the watcher reports `no task signal`. */
398
+ const DEFAULT_TASK_SIGNAL_MINUTES = 10
399
+
400
+ const tonesSchema = z.strictObject({
401
+ waiting: toneName.default(DEFAULT_TONES.waiting),
402
+ new: toneName.default(DEFAULT_TONES.new),
403
+ running: toneName.default(DEFAULT_TONES.running),
404
+ resting: toneName.default(DEFAULT_TONES.resting),
352
405
  })
353
406
 
354
407
  const contextSchema = z
@@ -443,12 +496,15 @@ export const jackherdConfigSchema = z.strictObject({
443
496
  )
444
497
  .default(DEFAULT_LINK_KINDS.map((kind) => ({ ...kind }))),
445
498
  context: contextSchema.prefault({}),
499
+ /** The colour of each role in the herd, by name: what waits for you, what is new, what runs, what rests. */
500
+ tones: tonesSchema.prefault({}),
446
501
  })
447
502
  .prefault({}),
448
503
  /**
449
504
  * Read by `jh watch`; without `deploy` it reports no deploy runs. `feedback` is on unless set to `false`:
450
505
  * it reports new feedback on the lanes' pull requests except from the account the watcher runs as and `ignore`.
451
506
  * `parallelTasks.maxTokens` caps the cost of the extra agents in a proposal of parallel tasks at the heartbeat.
507
+ * `taskSignalMinutes` is how long a lane works without a task signal before it reports `no task signal`, once per phase.
452
508
  * `checks` reads the failed tests of a red lane pull request (`failedTestPattern`), also with `feedback` off.
453
509
  * Without `previews` it reads no deploy preview; with it, one `preview` per new head commit. Each of `sources` is a
454
510
  * command whose JSON output reports `source` for a new or changed entry.
@@ -459,6 +515,7 @@ export const jackherdConfigSchema = z.strictObject({
459
515
  feedback: z.union([z.literal(false), feedbackSchema]).prefault({}),
460
516
  checks: checksSchema.prefault({}),
461
517
  parallelTasks: parallelTasksSchema.prefault({}),
518
+ taskSignalMinutes: z.number().int().positive().default(DEFAULT_TASK_SIGNAL_MINUTES),
462
519
  previews: previewsSchema.optional(),
463
520
  sources: z.array(sourceSchema).refine(hasUniqueNames, 'source names must be unique').default([]),
464
521
  })
@@ -0,0 +1,27 @@
1
+ /** The colours the herd paints with by name; the terminal's own palette decides how each looks, `grey` is the dimmed text colour. */
2
+ export const TONE = {
3
+ amber: 'amber',
4
+ teal: 'teal',
5
+ grey: 'grey',
6
+ red: 'red',
7
+ green: 'green',
8
+ blue: 'blue',
9
+ magenta: 'magenta',
10
+ } as const
11
+
12
+ export type ToneName = (typeof TONE)[keyof typeof TONE]
13
+
14
+ export const TONE_NAMES = Object.values(TONE)
15
+
16
+ /** What a tone is for in the herd: what waits for you, what is new, what runs, what rests. */
17
+ export const TONE_ROLE = { waiting: 'waiting', new: 'new', running: 'running', resting: 'resting' } as const
18
+
19
+ export type ToneRole = (typeof TONE_ROLE)[keyof typeof TONE_ROLE]
20
+
21
+ /** The tone of each role, as the herd looked before the tones were configurable. */
22
+ export const DEFAULT_TONES: Record<ToneRole, ToneName> = {
23
+ [TONE_ROLE.waiting]: TONE.amber,
24
+ [TONE_ROLE.new]: TONE.amber,
25
+ [TONE_ROLE.running]: TONE.teal,
26
+ [TONE_ROLE.resting]: TONE.grey,
27
+ }
@@ -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
+ }