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,217 @@
1
+ import { existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
2
+ import { join, relative, resolve } from 'node:path'
3
+ import { claudePath, HANDOVER_DIRECTORY } from '../claude/paths'
4
+ import { TERMINAL_ADAPTER } from '../config'
5
+ import { terminalSafe } from '../plan-format/terminal-text'
6
+ import { boardFor } from '../plans/board-pick'
7
+ import { commitOf, mainCheckout, worktreeRoot } from '../plans/git'
8
+ import { type PlansSettings, readSettings, worktreeOf } from '../plans/settings'
9
+ import { asksForHelp } from '../preview/options'
10
+ import { laneStatusSourceFor } from '../terminal'
11
+ import { runHerdr } from '../terminal/herdr'
12
+ import { runGh } from '../watch/gh'
13
+ import { parseWatchedPullRequests, WATCHED_PULL_REQUEST_FIELDS } from '../watch/pull-requests'
14
+ import { type ClosePorts, type CloseRequest, type CloseSurvey, closeLane } from './close'
15
+ import { boardLaneOf } from './close-board'
16
+ import { type CloseFacts, type MergedPullRequest, statusEntriesOf } from './close-check'
17
+ import { type CloseOptions, LANE_CLOSE_USAGE, parseCloseOptions } from './close-options'
18
+ import { agentFactsOf, mergedPullRequestOf, openWorkspaceOf, type WorktreeEntry, worktreeEntryOf } from './close-survey'
19
+
20
+ /** Where the command runs and what it acts on, located once. */
21
+ type CloseContext = { root: string; cwd: string; settings: PlansSettings; worktree: string; boardFile: string }
22
+
23
+ /** The facts that only a worktree git lists has. */
24
+ type WorktreeFacts = Pick<
25
+ CloseFacts,
26
+ 'worktreeExists' | 'branch' | 'head' | 'pullRequest' | 'treeDiff' | 'status' | 'handoverEntries' | 'locked'
27
+ >
28
+
29
+ /** The file the lane setup copies into every new worktree, in `.gitignore` syntax. */
30
+ const SETUP_FILES = '.worktreeinclude'
31
+ const GH_TIMEOUT_MS = 30_000
32
+ const PULL_REQUEST_LIMIT = 20
33
+ const EXIT_WAIT_MS = 60_000
34
+ const EXIT_POLL_MS = 1_000
35
+ const EXIT_COMMAND = '/exit'
36
+ const BRANCH_REF = 'refs/heads/'
37
+ /**
38
+ * Ignored paths as an ignore pattern names them: a directory whose files are all ignored by a file pattern
39
+ * (`packages/*\/bin/*.js`) shows as those files, not folded into a directory no pattern names, so the build output
40
+ * list can judge each one.
41
+ */
42
+ const IGNORED_MATCHING = '--ignored=matching'
43
+ /** Untracked files are listed whatever `status.showUntrackedFiles` says, so a project setting cannot hide one. */
44
+ const ALL_UNTRACKED = '--untracked-files=all'
45
+
46
+ const ABSENT_WORKTREE: WorktreeFacts = {
47
+ worktreeExists: false,
48
+ branch: null,
49
+ head: null,
50
+ pullRequest: null,
51
+ treeDiff: [],
52
+ status: [],
53
+ handoverEntries: [],
54
+ locked: null,
55
+ }
56
+
57
+ /** Runs git and returns its stdout; throws with git's own error text. */
58
+ const runGit = (cwd: string, args: string[]): string => {
59
+ const result = Bun.spawnSync(['git', '-C', cwd, ...args], { stdout: 'pipe', stderr: 'pipe' })
60
+ if (result.exitCode !== 0)
61
+ throw new Error(`git ${args[0]}: ${result.stderr.toString().trim() || `exit ${result.exitCode}`}`)
62
+ return result.stdout.toString()
63
+ }
64
+
65
+ const realPath = (path: string): string => (existsSync(path) ? realpathSync(path) : path)
66
+
67
+ const linesOf = (text: string): string[] => text.split('\n').filter((line) => line.trim() !== '')
68
+
69
+ const setupFilesOf = (root: string): string[] => {
70
+ const file = join(root, SETUP_FILES)
71
+ return existsSync(file) ? readFileSync(file, 'utf8').split('\n') : []
72
+ }
73
+
74
+ /** The paths in which the branch head differs from the merge commit; fetches the base once when the commit is missing. */
75
+ const treeDiffOf = (context: CloseContext, head: string, mergeCommit: string): string[] => {
76
+ if (commitOf(context.root, mergeCommit) === null)
77
+ runGit(context.root, ['fetch', '--quiet', 'origin', context.settings.baseBranch])
78
+ return linesOf(runGit(context.root, ['diff', '--name-only', '--end-of-options', head, mergeCommit]))
79
+ }
80
+
81
+ /** Every entry of the worktree's handover directory, a folder with its trailing slash, as git names paths. */
82
+ const handoverEntriesOf = (worktree: string): string[] => {
83
+ const directory = claudePath(HANDOVER_DIRECTORY)
84
+ const absolute = join(worktree, directory)
85
+ if (!existsSync(absolute)) return []
86
+ return readdirSync(absolute, { withFileTypes: true })
87
+ .map((entry) => `${directory}/${entry.name}${entry.isDirectory() ? '/' : ''}`)
88
+ .sort()
89
+ }
90
+
91
+ const mergedPullRequestOn = async (context: CloseContext, branch: string): Promise<MergedPullRequest | null> => {
92
+ const fields = ['--json', WATCHED_PULL_REQUEST_FIELDS, '--limit', String(PULL_REQUEST_LIMIT)]
93
+ const answer = await runGh(['pr', 'list', '--head', branch, '--state', 'merged', ...fields], context.root, {
94
+ timeoutMs: GH_TIMEOUT_MS,
95
+ })
96
+ if (!answer.ok) throw new Error(answer.error)
97
+ return mergedPullRequestOf(parseWatchedPullRequests(answer.stdout), branch)
98
+ }
99
+
100
+ /** Branch, merged pull request, tree difference, status, handover files and lock of the worktree git lists for the lane. */
101
+ const worktreeFactsOf = async (context: CloseContext, entry: WorktreeEntry): Promise<WorktreeFacts> => {
102
+ const pullRequest = entry.branch === null ? null : await mergedPullRequestOn(context, entry.branch)
103
+ return {
104
+ worktreeExists: true,
105
+ branch: entry.branch,
106
+ head: entry.head,
107
+ pullRequest,
108
+ treeDiff: pullRequest === null ? [] : treeDiffOf(context, entry.head, pullRequest.mergeCommit),
109
+ status: statusEntriesOf(
110
+ runGit(context.worktree, ['status', '--porcelain=v1', '-z', IGNORED_MATCHING, ALL_UNTRACKED]),
111
+ ),
112
+ handoverEntries: handoverEntriesOf(context.worktree),
113
+ locked: entry.locked,
114
+ }
115
+ }
116
+
117
+ const workspaceOf = (context: CloseContext): string | null =>
118
+ context.settings.adapter === TERMINAL_ADAPTER.herdr
119
+ ? openWorkspaceOf(runHerdr(['worktree', 'list', '--cwd', context.root]), context.worktree)
120
+ : null
121
+
122
+ /** What git, GitHub and the terminal say about the lane now; throws when one of them cannot be asked. */
123
+ const surveyOf = async (lane: string, context: CloseContext): Promise<CloseSurvey> => {
124
+ const entry = worktreeEntryOf(runGit(context.root, ['worktree', 'list', '--porcelain']), context.worktree)
125
+ const present = entry !== null && existsSync(context.worktree)
126
+ const facts: CloseFacts = {
127
+ lane,
128
+ boardLane: boardLaneOf(readFileSync(context.boardFile, 'utf8'), lane, context.settings.planLanguage),
129
+ setupFiles: setupFilesOf(context.root),
130
+ ...agentFactsOf(laneStatusSourceFor(context.settings.adapter).listAgents(), lane, context.worktree),
131
+ runsInside: realPath(worktreeRoot(context.cwd)) === realPath(context.worktree),
132
+ ...(present ? await worktreeFactsOf(context, entry) : ABSENT_WORKTREE),
133
+ }
134
+ return { facts, workspace: present ? workspaceOf(context) : null }
135
+ }
136
+
137
+ const sleep = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms))
138
+
139
+ /** Sends `/exit` to the agent in `pane` and waits until the terminal lists no session in that pane. */
140
+ const exitAgentIn =
141
+ (context: CloseContext) =>
142
+ async (pane: string): Promise<void> => {
143
+ if (context.settings.adapter !== TERMINAL_ADAPTER.herdr) throw new Error('only herdr can end an agent')
144
+ runHerdr(['agent', 'prompt', pane, EXIT_COMMAND])
145
+ const source = laneStatusSourceFor(context.settings.adapter)
146
+ for (let waited = 0; waited < EXIT_WAIT_MS; waited += EXIT_POLL_MS) {
147
+ await sleep(EXIT_POLL_MS)
148
+ const agent = source.listAgents().find((listed) => listed.pane === pane)
149
+ if (agent === undefined || agent.session === null) return
150
+ }
151
+ throw new Error(`the agent in pane ${pane} still runs after ${EXIT_WAIT_MS / 1000} s`)
152
+ }
153
+
154
+ /** Git removes the worktree and refuses on any change itself; herdr then only closes the emptied workspace. */
155
+ const removeWorktreeIn =
156
+ (context: CloseContext) =>
157
+ (worktree: string, workspace: string | null): void => {
158
+ runGit(context.root, ['worktree', 'remove', worktree])
159
+ if (workspace !== null) runHerdr(['workspace', 'close', workspace])
160
+ }
161
+
162
+ /** Deletes the branch only while it points to `head`; git refuses when it moved since the check. */
163
+ const deleteBranchIn =
164
+ (context: CloseContext) =>
165
+ (branch: string, head: string): void => {
166
+ runGit(context.root, ['update-ref', '-d', `${BRANCH_REF}${branch}`, head])
167
+ }
168
+
169
+ const portsFor = (lane: string, context: CloseContext): ClosePorts => ({
170
+ survey: () => surveyOf(lane, context),
171
+ exitAgent: exitAgentIn(context),
172
+ removeWorktree: removeWorktreeIn(context),
173
+ deleteBranch: deleteBranchIn(context),
174
+ readBoard: () => readFileSync(context.boardFile, 'utf8'),
175
+ writeBoard: (text) => writeFileSync(context.boardFile, text),
176
+ print: (line) => console.log(terminalSafe(line)),
177
+ printError: (line) => console.error(terminalSafe(line)),
178
+ })
179
+
180
+ /** The main checkout, its settings, the lane's worktree from those settings, and the board, with the pick's notice. */
181
+ const contextOf = (options: CloseOptions, cwd: string): { context: CloseContext; notice: string | null } => {
182
+ const root = mainCheckout(cwd)
183
+ const settings = readSettings([root, worktreeRoot(cwd)])
184
+ const boardPath = options.boardPath === null ? null : resolve(cwd, options.boardPath)
185
+ const board = boardFor(boardPath, [root], settings, () => [options.lane])
186
+ const worktree = worktreeOf(root, settings, options.lane)
187
+ return { context: { root, cwd, settings, worktree, boardFile: board.file }, notice: board.notice }
188
+ }
189
+
190
+ /**
191
+ * `jh lane close <lane> [--dry-run] [--board <board.md>]`: closes a lane in one run once nothing would be lost, see
192
+ * `closeLane`. The worktree's path comes from the settings and the lane's name, never from a reply. Exit 0 closed or
193
+ * dry run, 1 refused or failed.
194
+ */
195
+ export const run = async (args: string[], cwd: string = process.cwd()): Promise<number> => {
196
+ if (asksForHelp(args)) {
197
+ console.log(LANE_CLOSE_USAGE)
198
+ return 0
199
+ }
200
+ try {
201
+ const options = parseCloseOptions(args)
202
+ const { context, notice } = contextOf(options, cwd)
203
+ if (notice) console.error(terminalSafe(notice))
204
+ const request: CloseRequest = {
205
+ lane: options.lane,
206
+ worktree: context.worktree,
207
+ boardPath: relative(context.root, context.boardFile),
208
+ dryRun: options.dryRun,
209
+ now: new Date(),
210
+ language: context.settings.planLanguage,
211
+ }
212
+ return await closeLane(request, portsFor(options.lane, context))
213
+ } catch (error) {
214
+ console.error(terminalSafe(error instanceof Error ? error.message : String(error)))
215
+ return 1
216
+ }
217
+ }
@@ -0,0 +1,43 @@
1
+ import { CLI_NAME } from '../version/release'
2
+ import { SAFE_LANE_NAME } from '../watch/lane-name'
3
+ import { DRY_RUN_OPTION, LANE_CLOSE_COMMAND } from './close-command-line'
4
+
5
+ /** What `jh lane close` was asked for; `boardPath` is null where `--board` was left out. */
6
+ export type CloseOptions = { lane: string; dryRun: boolean; boardPath: string | null }
7
+
8
+ const BOARD = '--board'
9
+
10
+ export const LANE_CLOSE_USAGE = `usage: ${LANE_CLOSE_COMMAND} <lane> [${DRY_RUN_OPTION}] [--board <board.md>]
11
+
12
+ Closes a lane whose pull request is merged, once nothing in it would be lost: exits its agent, removes its workspace,
13
+ worktree and local branch, and marks its rows in the board with a history row. Refuses with every reason otherwise.
14
+ --dry-run check and print the steps, change nothing
15
+ --board <board.md> the board to mark; without it the board is picked as for ${CLI_NAME} herd`
16
+
17
+ const usageError = (message: string): Error => new Error(`${message}\n${LANE_CLOSE_USAGE}`)
18
+
19
+ const laneOf = (words: readonly string[]): string => {
20
+ if (words.length === 0) throw usageError('the lane is required')
21
+ if (words.length > 1) throw usageError(`one lane at most, got ${words.join(' ')}`)
22
+ if (!SAFE_LANE_NAME.test(words[0])) throw usageError(`${words[0]} is no lane name (letters, digits, . _ -)`)
23
+ return words[0]
24
+ }
25
+
26
+ /** Reads `<lane> [--dry-run] [--board <file>]` in any order, the arguments after `close`; throws on a wrong line. */
27
+ export const parseCloseOptions = (args: readonly string[]): CloseOptions => {
28
+ const words: string[] = []
29
+ let dryRun = false
30
+ let boardPath: string | null = null
31
+ for (let i = 0; i < args.length; i += 1) {
32
+ const arg = args[i]
33
+ if (arg === DRY_RUN_OPTION) dryRun = true
34
+ else if (arg === BOARD) {
35
+ const value = args[i + 1]
36
+ if (value === undefined || value.startsWith('--')) throw usageError(`${BOARD} needs a value`)
37
+ boardPath = value
38
+ i += 1
39
+ } else if (arg.startsWith('--')) throw usageError(`unknown option ${arg}`)
40
+ else words.push(arg)
41
+ }
42
+ return { lane: laneOf(words), dryRun, boardPath }
43
+ }
@@ -0,0 +1,83 @@
1
+ import { sep } from 'node:path'
2
+ import { isRecord } from '../shared/is-record'
3
+ import type { LaneAgent } from '../terminal'
4
+ import { PULL_REQUEST_STATE, type WatchedPullRequest } from '../watch/pull-requests'
5
+ import type { CloseFacts, MergedPullRequest } from './close-check'
6
+
7
+ /** A worktree as `git worktree list --porcelain` lists it; `branch` null when detached, `locked` as in `CloseFacts`. */
8
+ export type WorktreeEntry = { head: string; branch: string | null; locked: string | null }
9
+
10
+ const BRANCH_REF = 'refs/heads/'
11
+ const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
12
+
13
+ const fieldOf = (lines: readonly string[], key: string): string | null => {
14
+ const line = lines.find((entry) => entry === key || entry.startsWith(`${key} `))
15
+ return line === undefined ? null : line.slice(key.length + 1)
16
+ }
17
+
18
+ /** The entry of the worktree at `path` in `git worktree list --porcelain`; null when git lists none there. */
19
+ export const worktreeEntryOf = (porcelain: string, path: string): WorktreeEntry | null => {
20
+ const entry = porcelain
21
+ .split('\n\n')
22
+ .map((block) => block.split('\n').filter(Boolean))
23
+ .find((lines) => fieldOf(lines, 'worktree') === path)
24
+ if (entry === undefined) return null
25
+ const branch = fieldOf(entry, 'branch')
26
+ return {
27
+ head: fieldOf(entry, 'HEAD') ?? '',
28
+ branch: branch?.startsWith(BRANCH_REF) ? branch.slice(BRANCH_REF.length) : null,
29
+ locked: fieldOf(entry, 'locked'),
30
+ }
31
+ }
32
+
33
+ type HerdrWorktree = { path: string; open_workspace_id?: string }
34
+
35
+ const isHerdrWorktree = (value: unknown): value is HerdrWorktree =>
36
+ isRecord(value) &&
37
+ typeof value.path === 'string' &&
38
+ (value.open_workspace_id === undefined || typeof value.open_workspace_id === 'string')
39
+
40
+ /** The herdr workspace the worktree at `path` is open in, from `herdr worktree list`; null when it is in none. */
41
+ export const openWorkspaceOf = (json: string, path: string): string | null => {
42
+ const result = (JSON.parse(json) as { result?: { worktrees?: unknown } }).result
43
+ const worktrees = result?.worktrees
44
+ if (!Array.isArray(worktrees) || !worktrees.every(isHerdrWorktree))
45
+ throw new Error('herdr worktree list: unexpected answer')
46
+ return worktrees.find((worktree) => worktree.path === path)?.open_workspace_id ?? null
47
+ }
48
+
49
+ /** A full git object id, SHA-1 or SHA-256; anything else from GitHub never reaches a git command line. */
50
+ export const isObjectId = (value: string): boolean => OBJECT_ID.test(value)
51
+
52
+ /** The newest merged pull request on `branch`, by number; null when none was merged with a well-formed merge commit. */
53
+ export const mergedPullRequestOf = (
54
+ pullRequests: readonly WatchedPullRequest[],
55
+ branch: string,
56
+ ): MergedPullRequest | null => {
57
+ const merged = pullRequests
58
+ .filter((pullRequest) => pullRequest.branch === branch && pullRequest.state === PULL_REQUEST_STATE.merged)
59
+ .flatMap(({ number, headCommit, mergeCommit }) =>
60
+ mergeCommit === null || !isObjectId(mergeCommit) ? [] : [{ number, headCommit, mergeCommit }],
61
+ )
62
+ return merged.sort((left, right) => right.number - left.number)[0] ?? null
63
+ }
64
+
65
+ const isInside = (cwd: string | null, worktree: string): boolean =>
66
+ cwd !== null && (cwd === worktree || cwd.startsWith(`${worktree}${sep}`))
67
+
68
+ /**
69
+ * The lane's agent, by its name and a directory in the worktree, so an agent of that name elsewhere is never ended;
70
+ * and the panes of every other agent in the worktree.
71
+ */
72
+ export const agentFactsOf = (
73
+ agents: readonly LaneAgent[],
74
+ lane: string,
75
+ worktree: string,
76
+ ): Pick<CloseFacts, 'agent' | 'otherAgents'> => {
77
+ const inWorktree = agents.filter(({ cwd }) => isInside(cwd, worktree))
78
+ const own = inWorktree.find(({ name }) => name === lane)
79
+ return {
80
+ agent: own === undefined ? null : { pane: own.pane, status: own.status },
81
+ otherAgents: inWorktree.filter((agent) => agent !== own).map(({ pane }) => pane),
82
+ }
83
+ }
@@ -0,0 +1,141 @@
1
+ import { twoDigits } from '../plan-format/clock'
2
+ import { terminalSafe } from '../plan-format/terminal-text'
3
+ import { errorText } from '../plans/error-text'
4
+ import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
5
+ import { closedMark, historyDate, withHistoryRow, withLaneClosed } from './close-board'
6
+ import { BOARD_LANE, type ClosableLane, type CloseFacts, closeVerdict } from './close-check'
7
+ import { LANE_CLOSE_COMMAND } from './close-command-line'
8
+
9
+ /** The facts the check reads, and the herdr workspace the worktree is open in, null when none is. */
10
+ export type CloseSurvey = { facts: CloseFacts; workspace: string | null }
11
+
12
+ /** What `jh lane close` was asked for; `worktree` comes from the settings and the lane's name, never from a reply. */
13
+ export type CloseRequest = {
14
+ lane: string
15
+ worktree: string
16
+ /** The board as the output names it, relative to the main checkout. */
17
+ boardPath: string
18
+ dryRun: boolean
19
+ now: Date
20
+ language: PlanLanguage
21
+ }
22
+
23
+ /** The I/O `closeLane` drives; every port but `survey` and the printers throws when it fails. */
24
+ export type ClosePorts = {
25
+ survey: () => Promise<CloseSurvey>
26
+ /** Ends the agent in this pane and returns once the terminal no longer lists its session. */
27
+ exitAgent: (pane: string) => Promise<void>
28
+ /** Removes the worktree without force, then closes the terminal workspace `workspace` names, if any. */
29
+ removeWorktree: (worktree: string, workspace: string | null) => void
30
+ /** Deletes the branch only while it still points to `head`, the commit the check compared. */
31
+ deleteBranch: (branch: string, head: string) => void
32
+ readBoard: () => string
33
+ writeBoard: (text: string) => void
34
+ print: (line: string) => void
35
+ printError: (line: string) => void
36
+ }
37
+
38
+ type ClosedLane = { lane: string } & ClosableLane
39
+
40
+ type Step = { label: string; run: () => Promise<void> | void }
41
+
42
+ /** A merge commit as the history row cites it. */
43
+ const SHORT_COMMIT_LENGTH = 7
44
+
45
+ const HISTORY_TEXT: Record<PlanLanguage, (closed: ClosedLane, mergeCommit: string) => string> = {
46
+ [PLAN_LANGUAGE.de]: ({ lane, branch, pullRequest }, mergeCommit) =>
47
+ `Spur \`${lane}\` abgeräumt mit \`${LANE_CLOSE_COMMAND}\`: PR #${pullRequest.number} gemergt (\`${mergeCommit}\`), Baum gleich Merge, Worktree entfernt, lokaler Branch \`${branch}\` gelöscht`,
48
+ [PLAN_LANGUAGE.en]: ({ lane, branch, pullRequest }, mergeCommit) =>
49
+ `Lane \`${lane}\` cleaned up with \`${LANE_CLOSE_COMMAND}\`: PR #${pullRequest.number} merged (\`${mergeCommit}\`), tree equal to the merge, worktree removed, local branch \`${branch}\` deleted`,
50
+ }
51
+
52
+ const clockOf = (time: Date): string => `${twoDigits(time.getHours())}:${twoDigits(time.getMinutes())}`
53
+
54
+ /** The history row's text: the time, then what was closed and proven, in the board's language. */
55
+ export const historyText = (closed: ClosedLane, request: Pick<CloseRequest, 'now' | 'language'>): string =>
56
+ `${clockOf(request.now)}: ${HISTORY_TEXT[request.language](closed, closed.pullRequest.mergeCommit.slice(0, SHORT_COMMIT_LENGTH))}`
57
+
58
+ /** The board with the lane's rows marked and the history row on top. */
59
+ const closedBoard = (board: string, closed: ClosedLane, request: CloseRequest): string =>
60
+ withHistoryRow(
61
+ withLaneClosed(board, closed.lane, closedMark(request.now, request.language), request.language),
62
+ historyDate(request.now),
63
+ historyText(closed, request),
64
+ request.language,
65
+ )
66
+
67
+ const boardLabel = (facts: CloseFacts, request: CloseRequest): string =>
68
+ facts.boardLane === BOARD_LANE.closed
69
+ ? `add a history row in ${request.boardPath}, whose rows mark ${request.lane} already`
70
+ : `mark the rows of ${request.lane} "${closedMark(request.now, request.language)}" and add a history row in ${request.boardPath}`
71
+
72
+ /** The removal steps after the agent is gone, in the order that never leaves the board claiming more than was done. */
73
+ const removalSteps = (closed: ClosedLane, survey: CloseSurvey, request: CloseRequest, ports: ClosePorts): Step[] => {
74
+ const { workspace, facts } = survey
75
+ const place = workspace === null ? '' : ` and close workspace ${workspace}`
76
+ return [
77
+ {
78
+ label: `remove worktree ${request.worktree}${place}`,
79
+ run: () => ports.removeWorktree(request.worktree, workspace),
80
+ },
81
+ { label: `delete branch ${closed.branch}`, run: () => ports.deleteBranch(closed.branch, closed.head) },
82
+ { label: boardLabel(facts, request), run: () => ports.writeBoard(closedBoard(ports.readBoard(), closed, request)) },
83
+ ]
84
+ }
85
+
86
+ const exitSteps = ({ facts }: CloseSurvey, request: CloseRequest, ports: ClosePorts): Step[] => {
87
+ const { agent } = facts
88
+ if (agent === null) return []
89
+ return [
90
+ { label: `exit the agent of ${request.lane} and wait until it is gone`, run: () => ports.exitAgent(agent.pane) },
91
+ ]
92
+ }
93
+
94
+ /** The lane closable from `survey`, or null after printing every reason against it under `heading`. */
95
+ const checked = (survey: CloseSurvey, heading: string, request: CloseRequest, ports: ClosePorts): ClosedLane | null => {
96
+ const verdict = closeVerdict(survey.facts)
97
+ if (verdict.kind === 'closable') return { lane: request.lane, ...verdict }
98
+ ports.printError(heading)
99
+ for (const refusal of verdict.refusals) ports.printError(` ${terminalSafe(refusal)}`)
100
+ return null
101
+ }
102
+
103
+ /** Runs the steps in order, one line each; at the first failure, names it and every step left undone. */
104
+ const runSteps = async (steps: readonly Step[], ports: ClosePorts): Promise<boolean> => {
105
+ for (const [index, step] of steps.entries()) {
106
+ try {
107
+ await step.run()
108
+ ports.print(`done: ${step.label}`)
109
+ } catch (error) {
110
+ ports.printError(`failed: ${step.label}: ${terminalSafe(errorText(error))}`)
111
+ for (const left of steps.slice(index + 1)) ports.printError(`not done: ${left.label}`)
112
+ return false
113
+ }
114
+ }
115
+ return true
116
+ }
117
+
118
+ /**
119
+ * Closes a lane in one run, or refuses with every reason and changes nothing. Checks first (`closeVerdict`); a dry run
120
+ * stops there and prints the steps. Otherwise it ends the lane's agent, checks again, since the agent may have written
121
+ * until it exited, then removes the worktree and its workspace, deletes the local branch, and marks the board last, so
122
+ * the board never records a removal that failed. Exit 0 closed or dry run, 1 refused or failed.
123
+ */
124
+ export const closeLane = async (request: CloseRequest, ports: ClosePorts): Promise<number> => {
125
+ const first = await ports.survey()
126
+ const closed = checked(first, `not closing ${request.lane}:`, request, ports)
127
+ if (closed === null) return 1
128
+ const exit = exitSteps(first, request, ports)
129
+ if (request.dryRun) {
130
+ ports.print(`dry run, nothing changed; closing ${request.lane} would:`)
131
+ for (const step of [...exit, ...removalSteps(closed, first, request, ports)]) ports.print(` ${step.label}`)
132
+ return 0
133
+ }
134
+ if (!(await runSteps(exit, ports))) return 1
135
+ const settled = exit.length === 0 ? first : await ports.survey()
136
+ const late = checked(settled, `not closing ${request.lane} after its agent exited:`, request, ports)
137
+ if (late === null) return 1
138
+ if (!(await runSteps(removalSteps(late, settled, request, ports), ports))) return 1
139
+ ports.print(`closed ${request.lane}`)
140
+ return 0
141
+ }
@@ -0,0 +1,13 @@
1
+ import { run as runPrompt } from '../lane-prompt/command'
2
+
3
+ const CLOSE = 'close'
4
+
5
+ /**
6
+ * `jh lane close …` closes a lane, everything else is `jh lane prompt …`. `close` must come first, so the ask rule
7
+ * `jh lane close` always sees it; `jh lane --board b.md close x` reaches the prompt parser and is refused there.
8
+ */
9
+ export const run = async (args: string[], cwd: string = process.cwd()): Promise<number> => {
10
+ if (args[0] !== CLOSE) return runPrompt(args, cwd)
11
+ const { run: runClose } = await import('./close-command')
12
+ return runClose(args.slice(1), cwd)
13
+ }
@@ -0,0 +1,74 @@
1
+ import { phaseLabel } from '../plan-format/lanes'
2
+ import { LANE_PROMPT_OPENING, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
3
+ import { laneReportFile } from '../watch/lane-report-file'
4
+ import { LANE_PROMPT_TEXTS, type LanePromptTexts } from './texts'
5
+
6
+ /** A phase of the lane with the paths its row of the dependency table writes (`''` is the whole repository); null without a row. */
7
+ export type LanePhase = { id: string; writes: string[] | null }
8
+
9
+ /** A plan the lane works on, by its path from the repository root, with the lane's phases in it. */
10
+ export type LanePlan = { path: string; phases: LanePhase[] }
11
+
12
+ /**
13
+ * Everything a lane prompt is made of. `port` is null when the board gives the lane none; `handover` names the
14
+ * hand-over file a restarted lane reads first (`/orchestrate` § 8), null for a fresh start.
15
+ */
16
+ export type LanePromptInput = {
17
+ language: PlanLanguage
18
+ lane: string
19
+ plans: LanePlan[]
20
+ port: number | null
21
+ handover: string | null
22
+ }
23
+
24
+ const writesText = (language: PlanLanguage, texts: LanePromptTexts, writes: string[] | null): string => {
25
+ if (writes === null) return texts.noWrites
26
+ const paths = writes.map((path) => (path === '' ? texts.wholeRepository : `\`${path}\``)).join(', ')
27
+ return `${PLAN_VOCABULARY[language].columns.writes}: ${paths}`
28
+ }
29
+
30
+ const phaseText = (language: PlanLanguage, texts: LanePromptTexts, phase: LanePhase): string =>
31
+ `\`${PLAN_VOCABULARY[language].phase} ${phaseLabel(phase.id)}\` (${writesText(language, texts, phase.writes)})`
32
+
33
+ const planLines = (language: PlanLanguage, texts: LanePromptTexts, plan: LanePlan): string[] => [
34
+ `- \`${plan.path}\``,
35
+ ...plan.phases.map((phase) => ` - ${phaseText(language, texts, phase)}`),
36
+ ]
37
+
38
+ const ruleLines = (input: LanePromptInput, texts: LanePromptTexts): string[] => [
39
+ input.port === null ? texts.noPort : texts.port(input.port),
40
+ texts.blocking,
41
+ texts.mustNot,
42
+ texts.commit,
43
+ texts.report(laneReportFile(input.lane)),
44
+ texts.textBlock,
45
+ texts.secret,
46
+ texts.askRule,
47
+ texts.approvalGate,
48
+ texts.helper(`${input.lane}-${texts.helperPurpose}`),
49
+ ]
50
+
51
+ /**
52
+ * The prompt that starts a lane, in the plan language: a first line that names the lane (the words the ask hint hook
53
+ * and the project's lane exception recognise), the plans with their phases as `Phase P<n>` and each phase's write
54
+ * set, the rules of `/orchestrate` § 1 that `/implement` does not carry already, and the start: `/implement`, or the
55
+ * hand-over file after a restart.
56
+ */
57
+ export const buildLanePrompt = (input: LanePromptInput): string => {
58
+ const texts = LANE_PROMPT_TEXTS[input.language]
59
+ return [
60
+ `${LANE_PROMPT_OPENING[input.language]} ${input.lane}. ${texts.answersTo}`,
61
+ '',
62
+ texts.phasesIntro,
63
+ '',
64
+ ...input.plans.flatMap((plan) => planLines(input.language, texts, plan)),
65
+ '',
66
+ texts.editInside,
67
+ texts.writeTools,
68
+ '',
69
+ ...ruleLines(input, texts).map((line) => `- ${line}`),
70
+ '',
71
+ input.handover === null ? texts.start : texts.restart(input.handover),
72
+ '',
73
+ ].join('\n')
74
+ }
@@ -0,0 +1,87 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join, relative, resolve } from 'node:path'
3
+ import { claudePath, HANDOVER_DIRECTORY } from '../claude/paths'
4
+ import { boardFor } from '../plans/board-pick'
5
+ import { mainCheckout, worktreeRoot } from '../plans/git'
6
+ import { boardRepoPathOf, readSettings, worktreeOf } from '../plans/settings'
7
+ import { asksForHelp } from '../preview/options'
8
+ import { buildLanePrompt } from './build'
9
+ import { type BoardSource, type LanePromptRequest, lanePromptInputOf, type ReadPlan } from './input'
10
+ import { LANE_PROMPT_USAGE, type LanePromptOptions, parseLanePromptOptions } from './options'
11
+
12
+ /**
13
+ * What `lanePromptInputOf` reads, located from the command line, and the main checkout the default file goes to;
14
+ * `readLanePlan` reads from the lane's worktree, null for every plan while it has none.
15
+ */
16
+ type PromptSources = {
17
+ request: LanePromptRequest
18
+ board: BoardSource
19
+ readPlan: ReadPlan
20
+ readLanePlan: ReadPlan
21
+ root: string
22
+ }
23
+
24
+ /** Where the prompt goes without `--out`: the hand-over directory of the main checkout, which git ignores. */
25
+ export const defaultPromptFile = (root: string, lane: string): string =>
26
+ join(root, claudePath(HANDOVER_DIRECTORY), `${lane}-prompt.md`)
27
+
28
+ /** The first of `roots` that holds `path` as a regular file, read; null when none does. */
29
+ const readFirstIn =
30
+ (roots: readonly string[]) =>
31
+ (path: string): string | null => {
32
+ const file = roots
33
+ .map((root) => join(root, path))
34
+ .find((candidate) => existsSync(candidate) && statSync(candidate).isFile())
35
+ return file === undefined ? null : readFileSync(file, 'utf8')
36
+ }
37
+
38
+ /** The lane's request, its board and the plan reader, from the checkouts of `cwd`; prints the board pick's notice. */
39
+ const promptSourcesOf = (options: LanePromptOptions, cwd: string): PromptSources => {
40
+ const checkout = worktreeRoot(cwd)
41
+ const root = mainCheckout(cwd)
42
+ const settings = readSettings([checkout, root])
43
+ const boardPath = options.boardPath === null ? null : resolve(cwd, options.boardPath)
44
+ const board = boardFor(boardPath, [root, checkout], settings, () => [options.lane])
45
+ if (board.notice) console.error(board.notice)
46
+ const directory = dirname(boardRepoPathOf(root, settings, board.file))
47
+ return {
48
+ request: { lane: options.lane, handover: options.handover, language: settings.planLanguage },
49
+ board: { text: readFileSync(board.file, 'utf8'), directory, path: relative(cwd, board.file) },
50
+ readPlan: readFirstIn([root, checkout]),
51
+ readLanePlan: readFirstIn([worktreeOf(root, settings, options.lane)]),
52
+ root,
53
+ }
54
+ }
55
+
56
+ const writePrompt = (out: string, prompt: string): void => {
57
+ mkdirSync(dirname(out), { recursive: true })
58
+ writeFileSync(out, prompt)
59
+ }
60
+
61
+ /**
62
+ * `jh lane prompt <lane> [--board <board.md>] [--out <file>] [--handover <file>]`: writes the lane's prompt, built
63
+ * from its board row and its plan in the plan language, and prints the file's path, so the coordinator sends it with
64
+ * `herdr agent prompt <lane> "$(cat <file>)"`. `--help` prints the usage. Exit 0 written, 1 refused or failed.
65
+ */
66
+ export const run = async (args: string[], cwd: string = process.cwd()): Promise<number> => {
67
+ if (asksForHelp(args)) {
68
+ console.log(LANE_PROMPT_USAGE)
69
+ return 0
70
+ }
71
+ try {
72
+ const options = parseLanePromptOptions(args)
73
+ const { request, board, readPlan, readLanePlan, root } = promptSourcesOf(options, cwd)
74
+ const result = lanePromptInputOf(request, board, readPlan, readLanePlan)
75
+ if (result.kind === 'refused') {
76
+ console.error(result.reason)
77
+ return 1
78
+ }
79
+ const out = options.out === null ? defaultPromptFile(root, options.lane) : resolve(cwd, options.out)
80
+ writePrompt(out, buildLanePrompt(result.input))
81
+ console.log(out)
82
+ return 0
83
+ } catch (error) {
84
+ console.error(error instanceof Error ? error.message : String(error))
85
+ return 1
86
+ }
87
+ }