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.
- package/CHANGELOG.md +30 -0
- package/INSTALL.md +1 -1
- package/README.md +50 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-5MQBCUQ5.js} +9 -4
- package/dist/config.d.ts +39 -4
- package/dist/config.js +67 -26
- package/dist/plan-format.d.ts +45 -6
- package/dist/plan-format.js +46 -6
- package/docs/workflow-dark.svg +67 -63
- package/docs/workflow.svg +67 -63
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +15 -0
- package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
- package/kit/hooks/hooks/ask-hint/index.ts +15 -19
- package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
- package/kit/hooks/hooks/git-guard/decision.ts +50 -102
- package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
- package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
- package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
- package/kit/hooks/hooks/merge-guard/index.ts +6 -3
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
- package/kit/hooks/hooks/merge-guard/run.ts +59 -54
- package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
- package/kit/hooks/hooks/open-question/index.ts +26 -0
- package/kit/hooks/hooks/open-question/record.ts +135 -0
- package/kit/hooks/hooks/open-question/run.ts +52 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- package/kit/hooks/hooks/sent-file/store.ts +3 -12
- package/kit/hooks/hooks/shared/config.ts +7 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
- package/kit/hooks/hooks/shared/files.ts +7 -0
- package/kit/hooks/hooks/shared/lane.ts +1 -1
- package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
- package/kit/hooks/hooks/shared/private-directory.ts +56 -9
- package/kit/hooks/hooks/shared/program-position.ts +2 -75
- package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
- package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
- package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
- package/kit/hooks/hooks/task-hint/decision.ts +49 -0
- package/kit/hooks/hooks/task-hint/index.ts +79 -0
- package/kit/hooks/hooks/task-hint/run.ts +62 -0
- package/kit/hooks/hooks/task-hint/store.ts +51 -0
- package/kit/hooks/hooks.json +13 -5
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +1 -8
- package/kit/workflow/skills/implement/SKILL.md +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +210 -433
- package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
- package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
- package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
- package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
- package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
- package/kit/workflow/skills/orchestrate/references/events.md +86 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
- package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
- package/kit/workflow/skills/plan/SKILL.md +6 -5
- package/kit/workflow/skills/plan/references/template-English.md +4 -4
- package/kit/workflow/skills/plan/references/template-German.md +4 -4
- package/package.json +3 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +1 -1
- package/schemas/jackherd.schema.json +59 -5
- package/src/check/agent-text-reviewers.ts +23 -0
- package/src/check/command.ts +2 -0
- package/src/check/plan-structure-check.ts +49 -2
- package/src/check/plan.ts +8 -1
- package/src/check/schema.ts +9 -0
- package/src/check/skill-budget-defaults.ts +55 -0
- package/src/check/skill-budgets.ts +185 -0
- package/src/cli.ts +1 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +58 -24
- package/src/lane/close-board.ts +106 -0
- package/src/lane/close-check.ts +183 -0
- package/src/lane/close-command-line.ts +11 -0
- package/src/lane/close-command.ts +217 -0
- package/src/lane/close-options.ts +43 -0
- package/src/lane/close-survey.ts +83 -0
- package/src/lane/close.ts +141 -0
- package/src/lane/command.ts +13 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +87 -0
- package/src/lane-prompt/input.ts +116 -0
- package/src/lane-prompt/options.ts +68 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/plan-format/clock.ts +8 -0
- package/src/plan-format/dependencies.ts +7 -4
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +7 -2
- package/src/plans/appointment-rows.ts +59 -0
- package/src/plans/ask-coordinator.ts +77 -0
- package/src/plans/ask-target.ts +25 -0
- package/src/plans/child-stop.ts +51 -0
- package/src/plans/closable-records.ts +34 -0
- package/src/plans/command.ts +3 -2
- package/src/plans/fetch.ts +26 -3
- package/src/plans/git.ts +19 -5
- package/src/plans/herd-claim.ts +173 -0
- package/src/plans/herd-memory.ts +36 -25
- package/src/plans/lane-plans.ts +44 -13
- package/src/plans/lane-summary.ts +253 -45
- package/src/plans/layout-phase.ts +29 -7
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +262 -68
- package/src/plans/link-chips.ts +1 -0
- package/src/plans/live.ts +107 -20
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-source.ts +15 -0
- package/src/plans/plan-cache.ts +28 -0
- package/src/plans/plan-heads.ts +54 -0
- package/src/plans/pull-requests.ts +17 -2
- package/src/plans/question-records.ts +48 -0
- package/src/plans/questions-read.ts +13 -0
- package/src/plans/read-cache.ts +8 -0
- package/src/plans/record-id.ts +4 -0
- package/src/plans/restart-decision.ts +13 -4
- package/src/plans/selection.ts +98 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +2 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +85 -62
- package/src/plans/sprint-export.ts +11 -11
- package/src/plans/sprint-page.ts +5 -5
- package/src/plans/subagent-phases.ts +95 -0
- package/src/plans/subagent-tasks.ts +51 -0
- package/src/plans/subagents-read.ts +66 -17
- package/src/plans/subagents.ts +64 -6
- package/src/plans/supervisor.ts +14 -4
- package/src/plans/symbols.ts +3 -0
- package/src/plans/task-name.ts +34 -0
- package/src/plans/texts.ts +23 -6
- package/src/plans/view.ts +17 -9
- package/src/plans/waiting-click.ts +192 -37
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +25 -3
- package/src/shared/private-location.ts +89 -0
- package/src/shared/stat-cache.ts +48 -0
- package/src/sync/kit-source.ts +29 -10
- package/src/sync/kit.ts +2 -0
- package/src/sync/manifest.ts +2 -0
- package/src/sync/plan-ask-rules.ts +94 -0
- package/src/sync/plan-hooks.ts +2 -0
- package/src/sync/render.ts +3 -3
- package/src/sync/settings-hooks.ts +4 -1
- package/src/sync/sync-plan.ts +1 -0
- package/src/sync/sync.ts +24 -3
- package/src/task-signals/claude-read.ts +27 -10
- package/src/terminal/text.ts +8 -3
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +16 -6
- package/src/waiting/command.ts +22 -7
- package/src/watch/appointments.ts +1 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +330 -105
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-progress.ts +40 -8
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +32 -4
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +13 -7
- package/src/watch/pull-requests.ts +6 -2
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/running-subagents.ts +43 -0
- package/src/watch/skill-change.ts +124 -32
- package/src/watch/source-change.ts +48 -0
- package/src/watch/sources.ts +6 -3
- package/src/watch/stream-output.ts +4 -3
- package/src/watch/task-parallel-auto.ts +208 -0
- package/src/watch/task-parallel-data-paths.ts +46 -0
- package/src/watch/task-parallel-input.ts +5 -15
- package/src/watch/task-parallel-read.ts +3 -1
- package/src/watch/task-parallel.ts +36 -7
- package/src/watch/task-signal-missing.ts +69 -0
- package/src/watch/task-signal-read.ts +29 -0
- package/src/watch/texts.ts +10 -0
- package/src/watch/update-available.ts +6 -3
- package/src/watch/watch-events.ts +23 -0
- package/src/watch/watcher-lock.ts +25 -29
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
- package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
- 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
|
+
}
|