jackherd 1.9.0 → 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 (204) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +50 -32
  4. package/dist/{chunk-MCP4XJQH.js → chunk-5MQBCUQ5.js} +9 -4
  5. package/dist/config.d.ts +39 -4
  6. package/dist/config.js +67 -26
  7. package/dist/plan-format.d.ts +45 -6
  8. package/dist/plan-format.js +46 -6
  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 +52 -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/workflow/skills/follow/SKILL.md +1 -8
  50. package/kit/workflow/skills/implement/SKILL.md +8 -6
  51. package/kit/workflow/skills/orchestrate/SKILL.md +210 -433
  52. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  53. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  54. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  55. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  56. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  57. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  58. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  59. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  60. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  61. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  62. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  63. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  64. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  65. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  66. package/kit/workflow/skills/plan/SKILL.md +6 -5
  67. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  68. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  69. package/package.json +3 -1
  70. package/recipes/README.md +2 -1
  71. package/recipes/sources.md +1 -1
  72. package/schemas/jackherd.schema.json +59 -5
  73. package/src/check/agent-text-reviewers.ts +23 -0
  74. package/src/check/command.ts +2 -0
  75. package/src/check/plan-structure-check.ts +49 -2
  76. package/src/check/plan.ts +8 -1
  77. package/src/check/schema.ts +9 -0
  78. package/src/check/skill-budget-defaults.ts +55 -0
  79. package/src/check/skill-budgets.ts +185 -0
  80. package/src/cli.ts +1 -0
  81. package/src/config/auto-window.ts +8 -0
  82. package/src/config/link-kinds.ts +7 -1
  83. package/src/config/schema.ts +58 -24
  84. package/src/lane/close-board.ts +106 -0
  85. package/src/lane/close-check.ts +183 -0
  86. package/src/lane/close-command-line.ts +11 -0
  87. package/src/lane/close-command.ts +217 -0
  88. package/src/lane/close-options.ts +43 -0
  89. package/src/lane/close-survey.ts +83 -0
  90. package/src/lane/close.ts +141 -0
  91. package/src/lane/command.ts +13 -0
  92. package/src/lane-prompt/build.ts +74 -0
  93. package/src/lane-prompt/command.ts +87 -0
  94. package/src/lane-prompt/input.ts +116 -0
  95. package/src/lane-prompt/options.ts +68 -0
  96. package/src/lane-prompt/texts.ts +101 -0
  97. package/src/plan-format/clock.ts +8 -0
  98. package/src/plan-format/dependencies.ts +7 -4
  99. package/src/plan-format/index.ts +1 -0
  100. package/src/plan-format/short-names.ts +71 -0
  101. package/src/plan-format/waiting.ts +7 -2
  102. package/src/plans/appointment-rows.ts +59 -0
  103. package/src/plans/ask-coordinator.ts +77 -0
  104. package/src/plans/ask-target.ts +25 -0
  105. package/src/plans/child-stop.ts +51 -0
  106. package/src/plans/closable-records.ts +34 -0
  107. package/src/plans/command.ts +3 -2
  108. package/src/plans/fetch.ts +26 -3
  109. package/src/plans/git.ts +19 -5
  110. package/src/plans/herd-claim.ts +173 -0
  111. package/src/plans/herd-memory.ts +36 -25
  112. package/src/plans/lane-plans.ts +44 -13
  113. package/src/plans/lane-summary.ts +253 -45
  114. package/src/plans/layout-phase.ts +29 -7
  115. package/src/plans/layout-summary.ts +89 -18
  116. package/src/plans/layout.ts +262 -68
  117. package/src/plans/link-chips.ts +1 -0
  118. package/src/plans/live.ts +107 -20
  119. package/src/plans/phase-ids.ts +8 -0
  120. package/src/plans/phase-source.ts +15 -0
  121. package/src/plans/plan-cache.ts +28 -0
  122. package/src/plans/plan-heads.ts +54 -0
  123. package/src/plans/pull-requests.ts +17 -2
  124. package/src/plans/question-records.ts +48 -0
  125. package/src/plans/questions-read.ts +13 -0
  126. package/src/plans/read-cache.ts +8 -0
  127. package/src/plans/record-id.ts +4 -0
  128. package/src/plans/restart-decision.ts +13 -4
  129. package/src/plans/selection.ts +98 -18
  130. package/src/plans/sent-files-read.ts +6 -15
  131. package/src/plans/sent-files.ts +7 -14
  132. package/src/plans/settings.ts +2 -1
  133. package/src/plans/signalled-phase.ts +42 -0
  134. package/src/plans/signals-read.ts +12 -4
  135. package/src/plans/snapshot.ts +85 -62
  136. package/src/plans/sprint-export.ts +11 -11
  137. package/src/plans/sprint-page.ts +5 -5
  138. package/src/plans/subagent-phases.ts +95 -0
  139. package/src/plans/subagent-tasks.ts +51 -0
  140. package/src/plans/subagents-read.ts +66 -17
  141. package/src/plans/subagents.ts +64 -6
  142. package/src/plans/supervisor.ts +14 -4
  143. package/src/plans/symbols.ts +3 -0
  144. package/src/plans/task-name.ts +34 -0
  145. package/src/plans/texts.ts +23 -6
  146. package/src/plans/view.ts +17 -9
  147. package/src/plans/waiting-click.ts +192 -37
  148. package/src/plans/waiting-records.ts +39 -0
  149. package/src/plans/waiting-window.ts +8 -0
  150. package/src/preview/command.ts +25 -3
  151. package/src/shared/private-location.ts +89 -0
  152. package/src/shared/stat-cache.ts +48 -0
  153. package/src/sync/kit-source.ts +29 -10
  154. package/src/sync/kit.ts +2 -0
  155. package/src/sync/manifest.ts +2 -0
  156. package/src/sync/plan-ask-rules.ts +94 -0
  157. package/src/sync/plan-hooks.ts +2 -0
  158. package/src/sync/render.ts +3 -3
  159. package/src/sync/settings-hooks.ts +4 -1
  160. package/src/sync/sync-plan.ts +1 -0
  161. package/src/sync/sync.ts +24 -3
  162. package/src/task-signals/claude-read.ts +27 -10
  163. package/src/terminal/text.ts +8 -3
  164. package/src/version/skill-diff.ts +46 -0
  165. package/src/vocabulary/index.ts +16 -6
  166. package/src/waiting/command.ts +22 -7
  167. package/src/watch/appointments.ts +1 -2
  168. package/src/watch/asking-lanes.ts +40 -0
  169. package/src/watch/base-tip.ts +11 -0
  170. package/src/watch/command.ts +330 -105
  171. package/src/watch/heartbeat-progress.ts +6 -18
  172. package/src/watch/kit-update.ts +28 -9
  173. package/src/watch/lane-name.ts +6 -0
  174. package/src/watch/lane-progress.ts +40 -8
  175. package/src/watch/lane-report-file.ts +20 -5
  176. package/src/watch/lane-report.ts +32 -4
  177. package/src/watch/lane-sources.ts +31 -3
  178. package/src/watch/main-checkout.ts +34 -0
  179. package/src/watch/plan-badges-read.ts +13 -7
  180. package/src/watch/pull-requests.ts +6 -2
  181. package/src/watch/reference-paths.ts +87 -0
  182. package/src/watch/running-subagents.ts +43 -0
  183. package/src/watch/skill-change.ts +124 -32
  184. package/src/watch/source-change.ts +48 -0
  185. package/src/watch/sources.ts +6 -3
  186. package/src/watch/stream-output.ts +4 -3
  187. package/src/watch/task-parallel-auto.ts +208 -0
  188. package/src/watch/task-parallel-data-paths.ts +46 -0
  189. package/src/watch/task-parallel-input.ts +5 -15
  190. package/src/watch/task-parallel-read.ts +3 -1
  191. package/src/watch/task-parallel.ts +36 -7
  192. package/src/watch/task-signal-missing.ts +69 -0
  193. package/src/watch/task-signal-read.ts +29 -0
  194. package/src/watch/texts.ts +10 -0
  195. package/src/watch/update-available.ts +6 -3
  196. package/src/watch/watch-events.ts +23 -0
  197. package/src/watch/watcher-lock.ts +25 -29
  198. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  199. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  200. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  201. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  202. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  203. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  204. 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,7 @@ 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'),
27
28
  preview: () => import('./preview/command'),
28
29
  settled: () => import('./settled/command'),
29
30
  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,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'
@@ -127,6 +128,20 @@ const parallelTasksSchema = z.strictObject({
127
128
  * the first answer of a lane's session carried.
128
129
  */
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(),
130
145
  })
131
146
 
132
147
  const checksSchema = z.strictObject({
@@ -269,7 +284,7 @@ const checkFreePattern = z
269
284
  )
270
285
 
271
286
  /**
272
- * 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
273
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,
274
289
  * classes and negation stay out, so the hook, which has no schema library, reads exactly what this accepts.
275
290
  */
@@ -304,13 +319,13 @@ const mergeSchema = z.strictObject({
304
319
  withoutChecks: z.array(checkFreePattern).default([]),
305
320
  })
306
321
 
307
- const guardSchema = z.strictObject({
322
+ const guardFields = z.strictObject({
308
323
  /**
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.
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.
312
327
  */
313
- 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']),
314
329
  selfMerge: z
315
330
  .strictObject({
316
331
  /** Whether `/orchestrate` offers "merge it yourself after green checks" in the pull request question; off also stops the hook honouring such an answer. */
@@ -325,32 +340,49 @@ const guardSchema = z.strictObject({
325
340
  waitMinutes: z.number().int().min(1).max(9).default(8),
326
341
  })
327
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. */
328
358
  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({}),
359
+ .strictObject({ exceptPaths: z.array(alwaysAskPattern).optional(), requiredChecks: z.array(checkName).optional() })
360
+ .optional()
361
+ .meta({ deprecated: true }),
347
362
  })
348
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
+
349
376
  const hintsSchema = z.strictObject({
350
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. */
351
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),
352
381
  })
353
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
+
354
386
  const contextSchema = z
355
387
  .strictObject({
356
388
  /** Tokens in a lane's context from which its fill shows yellow and `/implement` offers a new chat. */
@@ -449,6 +481,7 @@ export const jackherdConfigSchema = z.strictObject({
449
481
  * Read by `jh watch`; without `deploy` it reports no deploy runs. `feedback` is on unless set to `false`:
450
482
  * it reports new feedback on the lanes' pull requests except from the account the watcher runs as and `ignore`.
451
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.
452
485
  * `checks` reads the failed tests of a red lane pull request (`failedTestPattern`), also with `feedback` off.
453
486
  * Without `previews` it reads no deploy preview; with it, one `preview` per new head commit. Each of `sources` is a
454
487
  * command whose JSON output reports `source` for a new or changed entry.
@@ -459,6 +492,7 @@ export const jackherdConfigSchema = z.strictObject({
459
492
  feedback: z.union([z.literal(false), feedbackSchema]).prefault({}),
460
493
  checks: checksSchema.prefault({}),
461
494
  parallelTasks: parallelTasksSchema.prefault({}),
495
+ taskSignalMinutes: z.number().int().positive().default(DEFAULT_TASK_SIGNAL_MINUTES),
462
496
  previews: previewsSchema.optional(),
463
497
  sources: z.array(sourceSchema).refine(hasUniqueNames, 'source names must be unique').default([]),
464
498
  })
@@ -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
+ }
@@ -0,0 +1,183 @@
1
+ /** The lane's merged pull request: the newest one on its branch, with the commit the merge left on the base. */
2
+ export type MergedPullRequest = { number: number; headCommit: string; mergeCommit: string }
3
+
4
+ import { claudePath, HANDOVER_DIRECTORY } from '../claude/paths'
5
+ import { AGENT_STATUS } from '../terminal/types'
6
+
7
+ /** How the board's lanes table names the lane: with open rows, only with rows marked cleaned up, or not at all. */
8
+ export const BOARD_LANE = { open: 'open', closed: 'closed', missing: 'missing' } as const
9
+
10
+ export type BoardLane = (typeof BOARD_LANE)[keyof typeof BOARD_LANE]
11
+
12
+ /** The lane's branch, the head the check compared, and its merged pull request once nothing stands against closing it. */
13
+ export type ClosableLane = { branch: string; head: string; pullRequest: MergedPullRequest }
14
+
15
+ /** Every reason against closing, or the lane ready to close; the second only when the first is empty. */
16
+ export type CloseVerdict = { kind: 'refused'; refusals: string[] } | ({ kind: 'closable' } & ClosableLane)
17
+
18
+ /** One entry of `git status --porcelain`: its two status letters (`!!` ignored, `??` untracked) and its path. */
19
+ export type StatusEntry = { code: string; path: string }
20
+
21
+ /**
22
+ * What git, GitHub and the terminal answered about one lane, gathered before anything is removed. `treeDiff` lists the
23
+ * paths in which the branch head differs from the merge commit; `status` holds the worktree's status with ignored
24
+ * files (`statusEntriesOf`); `setupFiles` the patterns of the lane setup's copy list; `handoverEntries` every
25
+ * entry of the worktree's handover directory, which git shows only as the directory.
26
+ */
27
+ export type CloseFacts = {
28
+ lane: string
29
+ boardLane: BoardLane
30
+ worktreeExists: boolean
31
+ branch: string | null
32
+ /** The commit the worktree's branch points to, as `git worktree list` reports it; null without a worktree. */
33
+ head: string | null
34
+ pullRequest: MergedPullRequest | null
35
+ treeDiff: readonly string[]
36
+ status: readonly StatusEntry[]
37
+ setupFiles: readonly string[]
38
+ handoverEntries: readonly string[]
39
+ /** The agent named after the lane whose directory lies in the worktree, with its pane. */
40
+ agent: { pane: string; status: string } | null
41
+ /** The panes of other agents whose directory lies in the worktree: a helper, or a session without the lane's name. */
42
+ otherAgents: readonly string[]
43
+ /** Whether the command itself runs in the worktree it would remove. */
44
+ runsInside: boolean
45
+ /** The reason `git worktree lock` recorded, empty for a lock without one; null when the worktree is not locked. */
46
+ locked: string | null
47
+ }
48
+
49
+ const RESTING_AGENT = new Set<string>([AGENT_STATUS.idle, AGENT_STATUS.done])
50
+ const IGNORED_CODE = '!!'
51
+ const RENAME_OR_COPY = new Set(['R', 'C'])
52
+ /** A status field is two letters, a space, then the path. */
53
+ const STATUS_PATH_START = 3
54
+ const DEPENDENCY_DIRECTORY = 'node_modules'
55
+ const BUILD_OUTPUT_DIRECTORIES = ['dist', '.turbo', 'test-results', '__pycache__']
56
+ const BUILD_OUTPUT = [
57
+ ...BUILD_OUTPUT_DIRECTORIES.flatMap((directory) => [`**/${directory}`, `**/${directory}/**`]),
58
+ '**/*.tsbuildinfo',
59
+ 'packages/*/bin/*.{js,mjs,cjs}',
60
+ '.husky/_',
61
+ '.husky/_/**',
62
+ ].map((glob) => new Bun.Glob(glob))
63
+ /** Build output only as a directory entry: a package's `bin/` that an ignore rule hides as a whole. */
64
+ const BUILD_OUTPUT_DIRECTORY_ENTRIES = [new Bun.Glob('packages/*/bin')]
65
+ const HANDOVER = claudePath(HANDOVER_DIRECTORY)
66
+
67
+ const withoutSlashes = (path: string): string => path.replace(/^\/+|\/+$/g, '')
68
+
69
+ const isHandoverDirectory = (path: string): boolean => withoutSlashes(path) === HANDOVER
70
+
71
+ /** The patterns of a `.gitignore`-style list that name files; comments, blanks and negations name none. */
72
+ const patternsOf = (lines: readonly string[]): string[] =>
73
+ lines.map((line) => line.trim()).filter((line) => line !== '' && !line.startsWith('#') && !line.startsWith('!'))
74
+
75
+ /**
76
+ * Whether `path` (relative to the worktree, a directory with its trailing slash) is one the lane setup copies in: a
77
+ * pattern with an inner slash is anchored at the root, one without matches the name at any depth, as in `.gitignore`.
78
+ */
79
+ export const isSetupFile = (path: string, setupFiles: readonly string[]): boolean => {
80
+ const target = withoutSlashes(path)
81
+ return patternsOf(setupFiles).some((pattern) => {
82
+ const bare = withoutSlashes(pattern)
83
+ const glob = bare.includes('/') ? bare : `**/${bare}`
84
+ return new Bun.Glob(glob).match(target) || new Bun.Glob(bare).match(target)
85
+ })
86
+ }
87
+
88
+ const isDependencyPath = (path: string): boolean => withoutSlashes(path).split('/').includes(DEPENDENCY_DIRECTORY)
89
+
90
+ /**
91
+ * The entries of `git status --porcelain=v1 -z --ignored=matching`: NUL-separated, never quoted, a rename or copy followed by
92
+ * its source as a field of its own, which is skipped. Names are read as git wrote them, so no file name can pose as
93
+ * another path.
94
+ */
95
+ export const statusEntriesOf = (output: string): StatusEntry[] => {
96
+ const fields = output.split('\0')
97
+ const entries: StatusEntry[] = []
98
+ for (let i = 0; i < fields.length; i += 1) {
99
+ const field = fields[i]
100
+ if (field.length < STATUS_PATH_START) continue
101
+ const code = field.slice(0, 2)
102
+ entries.push({ code, path: field.slice(STATUS_PATH_START) })
103
+ if (RENAME_OR_COPY.has(code[0])) i += 1
104
+ }
105
+ return entries
106
+ }
107
+
108
+ /**
109
+ * Whether `path` (relative to the worktree, a directory with its trailing slash) is output a build makes again, from a
110
+ * fixed list: `dist`, `.turbo`, `test-results` and `__pycache__` at any depth, a `*.tsbuildinfo`, a
111
+ * `packages/*\/bin/*.{js,mjs,cjs}` or a whole `packages/*\/bin/`, and husky's helpers in `.husky/_`. Everything else an
112
+ * ignore rule hides may hold work and is not.
113
+ */
114
+ export const isBuildOutput = (path: string): boolean => {
115
+ const target = withoutSlashes(path)
116
+ const directory = path.endsWith('/') && BUILD_OUTPUT_DIRECTORY_ENTRIES.some((glob) => glob.match(target))
117
+ return directory || BUILD_OUTPUT.some((glob) => glob.match(target))
118
+ }
119
+
120
+ /**
121
+ * The paths removing the worktree would delete for good: every tracked change and untracked file, and every ignored
122
+ * path except dependencies (`node_modules` at any depth), the setup files, build output and the handover directory.
123
+ */
124
+ export const lostPathsOf = (status: readonly StatusEntry[], setupFiles: readonly string[]): string[] => {
125
+ const kept = (path: string) =>
126
+ isDependencyPath(path) || isSetupFile(path, setupFiles) || isBuildOutput(path) || isHandoverDirectory(path)
127
+ return status.filter(({ code, path }) => code !== IGNORED_CODE || !kept(path)).map(({ path }) => path)
128
+ }
129
+
130
+ /** The lost paths and the entries of the handover directory, each once: the directory stands for all of them. */
131
+ const deletedPathsOf = ({ status, setupFiles, handoverEntries }: CloseFacts): string[] => [
132
+ ...new Set([...lostPathsOf(status, setupFiles), ...handoverEntries]),
133
+ ]
134
+
135
+ /** A lane the board marks cleaned up already may still have its worktree left over; only a lane it never named is refused. */
136
+ const boardRefusals = ({ lane, boardLane }: CloseFacts): string[] =>
137
+ boardLane === BOARD_LANE.missing ? [`the board has no row for lane ${lane}`] : []
138
+
139
+ const mergeRefusals = ({ lane, branch, pullRequest, treeDiff }: CloseFacts): string[] => {
140
+ if (branch === null) return [`the worktree of ${lane} is on no branch`]
141
+ if (pullRequest === null) return [`branch ${branch} has no merged pull request`]
142
+ return treeDiff.map(
143
+ (path) => `branch ${branch} differs from the merge of #${pullRequest.number} (${pullRequest.mergeCommit}): ${path}`,
144
+ )
145
+ }
146
+
147
+ const agentRefusals = ({ lane, agent, otherAgents }: CloseFacts): string[] => [
148
+ ...(agent === null || RESTING_AGENT.has(agent.status) ? [] : [`the agent of ${lane} is ${agent.status}`]),
149
+ ...otherAgents.map((pane) => `pane ${pane} runs another agent in the worktree of ${lane}`),
150
+ ]
151
+
152
+ const lockRefusals = ({ lane, locked }: CloseFacts): string[] =>
153
+ locked === null
154
+ ? []
155
+ : [`the worktree of ${lane} is locked${locked ? ` (${locked})` : ''}; unlock it once its session has ended`]
156
+
157
+ /**
158
+ * Every reason not to close the lane, all at once; empty when nothing would be lost. Closing needs an open board row,
159
+ * the worktree on a branch whose newest merged pull request left the same tree on the base (a squash merge leaves the
160
+ * branch unmerged in git's eyes, so the trees are compared), nothing in the worktree beyond dependencies, the setup
161
+ * files and build output (`isBuildOutput`), an empty handover directory, an agent that is gone or rests, and no lock
162
+ * on the worktree (a Claude Code session may still hold it).
163
+ */
164
+ export const closeRefusals = (facts: CloseFacts): string[] => {
165
+ if (!facts.worktreeExists) return [...boardRefusals(facts), `lane ${facts.lane} has no worktree`]
166
+ if (facts.runsInside) return [`run it outside the worktree of ${facts.lane}, which it would remove`]
167
+ return [
168
+ ...boardRefusals(facts),
169
+ ...mergeRefusals(facts),
170
+ ...deletedPathsOf(facts).map((path) => `removing the worktree deletes ${path}`),
171
+ ...agentRefusals(facts),
172
+ ...lockRefusals(facts),
173
+ ]
174
+ }
175
+
176
+ /** `closeRefusals` as a verdict that carries the branch and pull request it proved, so a caller needs no second check. */
177
+ export const closeVerdict = (facts: CloseFacts): CloseVerdict => {
178
+ const refusals = closeRefusals(facts)
179
+ const { branch, head, pullRequest } = facts
180
+ if (refusals.length > 0 || branch === null || head === null || pullRequest === null)
181
+ return { kind: 'refused', refusals }
182
+ return { kind: 'closable', branch, head, pullRequest }
183
+ }
@@ -0,0 +1,11 @@
1
+ import { CLI_NAME } from '../version/release'
2
+ import { SAFE_LANE_NAME } from '../watch/lane-name'
3
+
4
+ /** The command as the usage, the board's history and the watcher's status line name it. */
5
+ export const LANE_CLOSE_COMMAND = `${CLI_NAME} lane close`
6
+
7
+ export const DRY_RUN_OPTION = '--dry-run'
8
+
9
+ /** The dry run the watcher suggests for a lane; null for a name no command line should carry. */
10
+ export const closeDryRunOf = (lane: string): string | null =>
11
+ SAFE_LANE_NAME.test(lane) ? `${LANE_CLOSE_COMMAND} ${lane} ${DRY_RUN_OPTION}` : null