jackherd 1.16.0 → 1.17.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 +22 -0
- package/INSTALL.md +2 -1
- package/README.md +22 -21
- package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
- package/dist/config.d.ts +33 -1
- package/dist/config.js +57 -5
- package/dist/plan-format.d.ts +18 -6
- package/dist/plan-format.js +8 -1
- package/docs/plan-contract.md +1 -0
- package/herdr-plugin/herdr-plugin.toml +4 -1
- package/herdr-plugin/restore-plans.ts +60 -26
- package/herdr-plugin/resume.ts +18 -0
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +11 -0
- package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
- package/kit/hooks/hooks/approval-gate/index.ts +36 -7
- package/kit/hooks/hooks/approval-gate/run.ts +72 -16
- package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
- package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
- package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
- package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
- package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
- package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
- package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
- package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +423 -35
- package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
- package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
- package/kit/hooks/hooks/merge-guard/run.ts +110 -28
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
- package/kit/hooks/hooks/plain-question/form.ts +89 -0
- package/kit/hooks/hooks/plain-question/index.ts +44 -4
- package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
- package/kit/hooks/hooks/plain-question/run.ts +14 -7
- package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
- package/kit/hooks/hooks.json +11 -2
- package/kit/review/agents/skill-review.md +3 -2
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/rules/rules/kit/code-style.md +6 -6
- package/kit/rules/rules/kit/writing-for-agents.md +3 -0
- package/kit/workflow/skills/follow/SKILL.md +2 -3
- package/kit/workflow/skills/implement/SKILL.md +1 -1
- package/kit/workflow/skills/orchestrate/SKILL.md +120 -299
- package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
- package/kit/workflow/skills/orchestrate/references/events.md +46 -1
- package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
- package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +9 -2
- package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
- package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
- package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
- package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
- package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
- package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
- package/kit/workflow/skills/plan/SKILL.md +3 -2
- package/package.json +2 -1
- package/recipes/README.md +1 -0
- package/recipes/jira-cli.md +1 -1
- package/recipes/sources/google-calendar-today.ts +8 -2
- package/recipes/sources/jira.ts +10 -2
- package/recipes/sources/linear.ts +9 -3
- package/recipes/sources/sentry.ts +8 -2
- package/recipes/sources.md +4 -2
- package/schemas/jackherd.schema.json +67 -0
- package/schemas/sources.schema.json +9 -0
- package/src/approval/checked.ts +60 -0
- package/src/approval/command.ts +16 -0
- package/src/check/command.ts +51 -6
- package/src/check/lint-pointers.ts +79 -0
- package/src/check/lint-shaped.ts +196 -0
- package/src/check/plan-structure-check.ts +116 -4
- package/src/check/report.ts +9 -3
- package/src/check/schema.ts +37 -0
- package/src/check/settings.ts +4 -0
- package/src/check/skill-budget-defaults.ts +77 -26
- package/src/check/skill-budgets.ts +58 -10
- package/src/cli.ts +2 -0
- package/src/config/personal-sources.ts +46 -10
- package/src/config/repository-identity.ts +40 -0
- package/src/config/schema.ts +53 -1
- package/src/context/command.ts +33 -2
- package/src/context/options.ts +33 -8
- package/src/context/usage-logs.ts +53 -0
- package/src/context/usage.ts +110 -0
- package/src/lane/base-command.ts +35 -0
- package/src/lane/base.ts +43 -0
- package/src/lane/close-check.ts +49 -15
- package/src/lane/close-command.ts +71 -11
- package/src/lane/close-options.ts +29 -8
- package/src/lane/close-survey.ts +13 -1
- package/src/lane/close.ts +51 -12
- package/src/lane/command.ts +21 -3
- package/src/lane/send-command.ts +32 -0
- package/src/lane/send-herdr.ts +77 -0
- package/src/lane/send-options.ts +23 -0
- package/src/lane/send.ts +90 -0
- package/src/lane/skill-hint.ts +27 -0
- package/src/lane/space-command.ts +30 -0
- package/src/lane/space.ts +49 -0
- package/src/lane/start-lane-hint.ts +18 -0
- package/src/plan-contract/base-check-git.ts +33 -0
- package/src/plan-contract/base-check.ts +17 -0
- package/src/plan-contract/command.ts +25 -4
- package/src/plan-format/plan-structure.ts +17 -5
- package/src/plans/ask-coordinator.ts +23 -13
- package/src/plans/board-rows.ts +9 -4
- package/src/plans/click-hint.ts +4 -0
- package/src/plans/command.ts +6 -5
- package/src/plans/ensure-panes.ts +14 -3
- package/src/plans/git.ts +14 -0
- package/src/plans/github.ts +19 -10
- package/src/plans/layout.ts +82 -33
- package/src/plans/live.ts +8 -12
- package/src/plans/plan-panes.ts +21 -4
- package/src/plans/selection.ts +29 -0
- package/src/plans/settings.ts +4 -0
- package/src/resume/command.ts +213 -0
- package/src/resume/decision.ts +102 -0
- package/src/resume/keeper.ts +65 -0
- package/src/resume/location.ts +22 -0
- package/src/resume/log.ts +27 -0
- package/src/resume/ports.ts +102 -0
- package/src/resume/record.ts +116 -0
- package/src/resume/targets.ts +84 -0
- package/src/resume/texts.ts +27 -0
- package/src/secrets/command.ts +15 -5
- package/src/sync/herdr-plugin.ts +2 -11
- package/src/sync/plan-ask-rules.ts +11 -0
- package/src/sync/render.ts +3 -1
- package/src/vocabulary/index.ts +13 -0
- package/src/waiting/command.ts +13 -4
- package/src/watch/command.ts +177 -78
- package/src/watch/config-reload.ts +119 -0
- package/src/watch/lane-model.ts +11 -1
- package/src/watch/lane-progress.ts +70 -8
- package/src/watch/lane-report.ts +66 -11
- package/src/watch/main-checkout.ts +5 -1
- package/src/watch/merge-duties.ts +33 -0
- package/src/watch/merge-overlap-read.ts +69 -0
- package/src/watch/plan-views.ts +16 -4
- package/src/watch/reference-paths.ts +46 -5
- package/src/watch/report-questions.ts +5 -2
- package/src/watch/single-report.ts +3 -2
- package/src/watch/source-dry-run-command.ts +22 -4
- package/src/watch/source-reason.ts +58 -0
- package/src/watch/source-reload.ts +21 -17
- package/src/watch/source-run.ts +24 -7
- package/src/watch/sources.ts +9 -2
- package/src/watch/texts.ts +14 -3
- package/src/watch/waiting-line.ts +4 -3
package/src/lane/close-check.ts
CHANGED
|
@@ -9,11 +9,17 @@ export const BOARD_LANE = { open: 'open', closed: 'closed', missing: 'missing' }
|
|
|
9
9
|
|
|
10
10
|
export type BoardLane = (typeof BOARD_LANE)[keyof typeof BOARD_LANE]
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* What the check proved once nothing stands against closing: a lane on a branch (its branch, the head the check
|
|
14
|
+
* compared, null when the branch is gone already, and its merged pull request), or a worktree on no branch whose head
|
|
15
|
+
* stands on the base branch.
|
|
16
|
+
*/
|
|
17
|
+
export type ClosableLane =
|
|
18
|
+
| { kind: 'branch'; branch: string; head: string | null; pullRequest: MergedPullRequest }
|
|
19
|
+
| { kind: 'detached'; head: string }
|
|
14
20
|
|
|
15
21
|
/** 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[] } |
|
|
22
|
+
export type CloseVerdict = { kind: 'refused'; refusals: string[] } | { kind: 'closable'; lane: ClosableLane }
|
|
17
23
|
|
|
18
24
|
/** One entry of `git status --porcelain`: its two status letters (`!!` ignored, `??` untracked) and its path. */
|
|
19
25
|
export type StatusEntry = { code: string; path: string }
|
|
@@ -28,14 +34,19 @@ export type CloseFacts = {
|
|
|
28
34
|
lane: string
|
|
29
35
|
boardLane: BoardLane
|
|
30
36
|
worktreeExists: boolean
|
|
37
|
+
/** The worktree's branch; without a worktree the branch `--branch` names, null when none was named. */
|
|
31
38
|
branch: string | null
|
|
32
|
-
/** The commit the worktree
|
|
39
|
+
/** The commit the worktree or, without one, the named branch points to; null when neither exists. */
|
|
33
40
|
head: string | null
|
|
34
41
|
pullRequest: MergedPullRequest | null
|
|
35
42
|
treeDiff: readonly string[]
|
|
36
43
|
status: readonly StatusEntry[]
|
|
37
44
|
setupFiles: readonly string[]
|
|
38
45
|
handoverEntries: readonly string[]
|
|
46
|
+
/** Whether the head of a worktree on no branch is an ancestor of the base branch on the remote. */
|
|
47
|
+
headOnBase: boolean
|
|
48
|
+
/** The staged paths of a worktree on no branch that the base branch on the remote holds with the same content. */
|
|
49
|
+
baseHeldPaths: readonly string[]
|
|
39
50
|
/** The agent named after the lane whose directory lies in the worktree, with its pane. */
|
|
40
51
|
agent: { pane: string; status: string } | null
|
|
41
52
|
/** The panes of other agents whose directory lies in the worktree: a helper, or a session without the lane's name. */
|
|
@@ -64,6 +75,10 @@ const BUILD_OUTPUT = [
|
|
|
64
75
|
const BUILD_OUTPUT_DIRECTORY_ENTRIES = [new Bun.Glob('packages/*/bin')]
|
|
65
76
|
const HANDOVER = claudePath(HANDOVER_DIRECTORY)
|
|
66
77
|
|
|
78
|
+
/** A change that is staged and nothing more: the worktree file equals the index. */
|
|
79
|
+
const isStagedOnly = (code: string): boolean =>
|
|
80
|
+
code[0] !== ' ' && code[0] !== '?' && code[0] !== IGNORED_CODE[0] && code[1] === ' '
|
|
81
|
+
|
|
67
82
|
const withoutSlashes = (path: string): string => path.replace(/^\/+|\/+$/g, '')
|
|
68
83
|
|
|
69
84
|
const isHandoverDirectory = (path: string): boolean => withoutSlashes(path) === HANDOVER
|
|
@@ -121,23 +136,33 @@ export const isBuildOutput = (path: string): boolean => {
|
|
|
121
136
|
* The paths removing the worktree would delete for good: every tracked change and untracked file, and every ignored
|
|
122
137
|
* path except dependencies (`node_modules` at any depth), the setup files, build output and the handover directory.
|
|
123
138
|
*/
|
|
124
|
-
export const lostPathsOf = (
|
|
139
|
+
export const lostPathsOf = (
|
|
140
|
+
status: readonly StatusEntry[],
|
|
141
|
+
setupFiles: readonly string[],
|
|
142
|
+
baseHeldPaths: readonly string[] = [],
|
|
143
|
+
): string[] => {
|
|
125
144
|
const kept = (path: string) =>
|
|
126
145
|
isDependencyPath(path) || isSetupFile(path, setupFiles) || isBuildOutput(path) || isHandoverDirectory(path)
|
|
127
|
-
|
|
146
|
+
const held = ({ code, path }: StatusEntry) => isStagedOnly(code) && baseHeldPaths.includes(path)
|
|
147
|
+
return status
|
|
148
|
+
.filter((entry) => (entry.code !== IGNORED_CODE || !kept(entry.path)) && !held(entry))
|
|
149
|
+
.map(({ path }) => path)
|
|
128
150
|
}
|
|
129
151
|
|
|
130
152
|
/** 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]),
|
|
153
|
+
const deletedPathsOf = ({ status, setupFiles, handoverEntries, baseHeldPaths }: CloseFacts): string[] => [
|
|
154
|
+
...new Set([...lostPathsOf(status, setupFiles, baseHeldPaths), ...handoverEntries]),
|
|
133
155
|
]
|
|
134
156
|
|
|
135
157
|
/** A lane the board marks cleaned up already may still have its worktree left over; only a lane it never named is refused. */
|
|
136
158
|
const boardRefusals = ({ lane, boardLane }: CloseFacts): string[] =>
|
|
137
159
|
boardLane === BOARD_LANE.missing ? [`the board has no row for lane ${lane}`] : []
|
|
138
160
|
|
|
139
|
-
const
|
|
140
|
-
|
|
161
|
+
const detachedRefusals = ({ lane, headOnBase }: CloseFacts): string[] =>
|
|
162
|
+
headOnBase ? [] : [`the worktree of ${lane} is on no branch and its head is not on the base branch`]
|
|
163
|
+
|
|
164
|
+
const mergeRefusals = ({ branch, pullRequest, treeDiff }: CloseFacts): string[] => {
|
|
165
|
+
if (branch === null) return []
|
|
141
166
|
if (pullRequest === null) return [`branch ${branch} has no merged pull request`]
|
|
142
167
|
return treeDiff.map(
|
|
143
168
|
(path) => `branch ${branch} differs from the merge of #${pullRequest.number} (${pullRequest.mergeCommit}): ${path}`,
|
|
@@ -162,11 +187,17 @@ const lockRefusals = ({ lane, locked }: CloseFacts): string[] =>
|
|
|
162
187
|
* on the worktree (a Claude Code session may still hold it).
|
|
163
188
|
*/
|
|
164
189
|
export const closeRefusals = (facts: CloseFacts): string[] => {
|
|
165
|
-
if (!facts.worktreeExists)
|
|
190
|
+
if (!facts.worktreeExists)
|
|
191
|
+
return [
|
|
192
|
+
...boardRefusals(facts),
|
|
193
|
+
...(facts.branch === null
|
|
194
|
+
? [`lane ${facts.lane} has no worktree; name its branch with --branch to finish closing it`]
|
|
195
|
+
: mergeRefusals(facts)),
|
|
196
|
+
]
|
|
166
197
|
if (facts.runsInside) return [`run it outside the worktree of ${facts.lane}, which it would remove`]
|
|
167
198
|
return [
|
|
168
199
|
...boardRefusals(facts),
|
|
169
|
-
...mergeRefusals(facts),
|
|
200
|
+
...(facts.branch === null ? detachedRefusals(facts) : mergeRefusals(facts)),
|
|
170
201
|
...deletedPathsOf(facts).map((path) => `removing the worktree deletes ${path}`),
|
|
171
202
|
...agentRefusals(facts),
|
|
172
203
|
...lockRefusals(facts),
|
|
@@ -176,8 +207,11 @@ export const closeRefusals = (facts: CloseFacts): string[] => {
|
|
|
176
207
|
/** `closeRefusals` as a verdict that carries the branch and pull request it proved, so a caller needs no second check. */
|
|
177
208
|
export const closeVerdict = (facts: CloseFacts): CloseVerdict => {
|
|
178
209
|
const refusals = closeRefusals(facts)
|
|
210
|
+
if (refusals.length > 0) return { kind: 'refused', refusals }
|
|
179
211
|
const { branch, head, pullRequest } = facts
|
|
180
|
-
if (
|
|
181
|
-
return { kind: '
|
|
182
|
-
return
|
|
212
|
+
if (branch !== null && pullRequest !== null)
|
|
213
|
+
return { kind: 'closable', lane: { kind: 'branch', branch, head, pullRequest } }
|
|
214
|
+
return head === null
|
|
215
|
+
? { kind: 'refused', refusals: [`the worktree of ${facts.lane} has no head to compare`] }
|
|
216
|
+
: { kind: 'closable', lane: { kind: 'detached', head } }
|
|
183
217
|
}
|
|
@@ -13,9 +13,17 @@ import { runGh } from '../watch/gh'
|
|
|
13
13
|
import { parseWatchedPullRequests, WATCHED_PULL_REQUEST_FIELDS } from '../watch/pull-requests'
|
|
14
14
|
import { type ClosePorts, type CloseRequest, type CloseSurvey, closeLane } from './close'
|
|
15
15
|
import { boardLaneOf } from './close-board'
|
|
16
|
-
import { type CloseFacts, type MergedPullRequest, statusEntriesOf } from './close-check'
|
|
16
|
+
import { type CloseFacts, type MergedPullRequest, type StatusEntry, statusEntriesOf } from './close-check'
|
|
17
17
|
import { type CloseOptions, LANE_CLOSE_USAGE, parseCloseOptions } from './close-options'
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
agentFactsOf,
|
|
20
|
+
baseHeldPathsOf,
|
|
21
|
+
mergedPullRequestOf,
|
|
22
|
+
openWorkspaceOf,
|
|
23
|
+
stagedPlanPathsOf,
|
|
24
|
+
type WorktreeEntry,
|
|
25
|
+
worktreeEntryOf,
|
|
26
|
+
} from './close-survey'
|
|
19
27
|
|
|
20
28
|
/** Where the command runs and what it acts on, located once. */
|
|
21
29
|
type CloseContext = { root: string; cwd: string; settings: PlansSettings; worktree: string; boardFile: string }
|
|
@@ -23,7 +31,16 @@ type CloseContext = { root: string; cwd: string; settings: PlansSettings; worktr
|
|
|
23
31
|
/** The facts that only a worktree git lists has. */
|
|
24
32
|
type WorktreeFacts = Pick<
|
|
25
33
|
CloseFacts,
|
|
26
|
-
|
|
34
|
+
| 'worktreeExists'
|
|
35
|
+
| 'branch'
|
|
36
|
+
| 'head'
|
|
37
|
+
| 'pullRequest'
|
|
38
|
+
| 'treeDiff'
|
|
39
|
+
| 'status'
|
|
40
|
+
| 'handoverEntries'
|
|
41
|
+
| 'headOnBase'
|
|
42
|
+
| 'baseHeldPaths'
|
|
43
|
+
| 'locked'
|
|
27
44
|
>
|
|
28
45
|
|
|
29
46
|
/** The file the lane setup copies into every new worktree, in `.gitignore` syntax. */
|
|
@@ -51,6 +68,8 @@ const ABSENT_WORKTREE: WorktreeFacts = {
|
|
|
51
68
|
treeDiff: [],
|
|
52
69
|
status: [],
|
|
53
70
|
handoverEntries: [],
|
|
71
|
+
headOnBase: false,
|
|
72
|
+
baseHeldPaths: [],
|
|
54
73
|
locked: null,
|
|
55
74
|
}
|
|
56
75
|
|
|
@@ -97,30 +116,71 @@ const mergedPullRequestOn = async (context: CloseContext, branch: string): Promi
|
|
|
97
116
|
return mergedPullRequestOf(parseWatchedPullRequests(answer.stdout), branch)
|
|
98
117
|
}
|
|
99
118
|
|
|
119
|
+
const remoteBase = (context: CloseContext): string => `origin/${context.settings.baseBranch}`
|
|
120
|
+
|
|
121
|
+
/** Whether `head` is an ancestor of the base branch on the remote; false for an answer other than yes. */
|
|
122
|
+
const isOnBase = (context: CloseContext, head: string): boolean =>
|
|
123
|
+
Bun.spawnSync(['git', '-C', context.root, 'merge-base', '--is-ancestor', head, remoteBase(context)]).exitCode === 0
|
|
124
|
+
|
|
125
|
+
/** The staged plan changes of the worktree that the base branch on the remote holds as they are staged. */
|
|
126
|
+
const baseHeldPathsIn = (context: CloseContext, status: readonly StatusEntry[]): string[] => {
|
|
127
|
+
const candidates = stagedPlanPathsOf(status, context.settings.planDirectory)
|
|
128
|
+
if (candidates.length === 0) return []
|
|
129
|
+
const differing = linesOf(
|
|
130
|
+
runGit(context.worktree, ['diff', '--cached', '--name-only', remoteBase(context), '--', ...candidates]),
|
|
131
|
+
)
|
|
132
|
+
return baseHeldPathsOf(candidates, differing)
|
|
133
|
+
}
|
|
134
|
+
|
|
100
135
|
/** Branch, merged pull request, tree difference, status, handover files and lock of the worktree git lists for the lane. */
|
|
101
136
|
const worktreeFactsOf = async (context: CloseContext, entry: WorktreeEntry): Promise<WorktreeFacts> => {
|
|
102
137
|
const pullRequest = entry.branch === null ? null : await mergedPullRequestOn(context, entry.branch)
|
|
138
|
+
const status = statusEntriesOf(
|
|
139
|
+
runGit(context.worktree, ['status', '--porcelain=v1', '-z', IGNORED_MATCHING, ALL_UNTRACKED]),
|
|
140
|
+
)
|
|
141
|
+
const detached = entry.branch === null
|
|
103
142
|
return {
|
|
104
143
|
worktreeExists: true,
|
|
105
144
|
branch: entry.branch,
|
|
106
145
|
head: entry.head,
|
|
107
146
|
pullRequest,
|
|
108
147
|
treeDiff: pullRequest === null ? [] : treeDiffOf(context, entry.head, pullRequest.mergeCommit),
|
|
109
|
-
status
|
|
110
|
-
runGit(context.worktree, ['status', '--porcelain=v1', '-z', IGNORED_MATCHING, ALL_UNTRACKED]),
|
|
111
|
-
),
|
|
148
|
+
status,
|
|
112
149
|
handoverEntries: handoverEntriesOf(context.worktree),
|
|
150
|
+
headOnBase: detached && isOnBase(context, entry.head),
|
|
151
|
+
baseHeldPaths: detached ? baseHeldPathsIn(context, status) : [],
|
|
113
152
|
locked: entry.locked,
|
|
114
153
|
}
|
|
115
154
|
}
|
|
116
155
|
|
|
156
|
+
/** The commit the local branch points to; null when there is no such branch. */
|
|
157
|
+
const branchHeadOf = (context: CloseContext, branch: string): string | null => {
|
|
158
|
+
const result = Bun.spawnSync(
|
|
159
|
+
['git', '-C', context.root, 'rev-parse', '--verify', '--quiet', `${BRANCH_REF}${branch}^{commit}`],
|
|
160
|
+
{ stdout: 'pipe' },
|
|
161
|
+
)
|
|
162
|
+
return result.exitCode === 0 ? result.stdout.toString().trim() : null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** What survives of a lane whose worktree is gone: the branch `--branch` names, if it is still there, and its merged pull request. */
|
|
166
|
+
const branchFactsOf = async (context: CloseContext, branch: string): Promise<WorktreeFacts> => {
|
|
167
|
+
const head = branchHeadOf(context, branch)
|
|
168
|
+
const pullRequest = await mergedPullRequestOn(context, branch)
|
|
169
|
+
const treeDiff = head === null || pullRequest === null ? [] : treeDiffOf(context, head, pullRequest.mergeCommit)
|
|
170
|
+
return { ...ABSENT_WORKTREE, branch, head, pullRequest, treeDiff }
|
|
171
|
+
}
|
|
172
|
+
|
|
117
173
|
const workspaceOf = (context: CloseContext): string | null =>
|
|
118
174
|
context.settings.adapter === TERMINAL_ADAPTER.herdr
|
|
119
175
|
? openWorkspaceOf(runHerdr(['worktree', 'list', '--cwd', context.root]), context.worktree)
|
|
120
176
|
: null
|
|
121
177
|
|
|
178
|
+
/** The facts of a lane without a worktree: only what survives in the branch `--branch` names, if one was named. */
|
|
179
|
+
const absentWorktreeFactsOf = (context: CloseContext, branch: string | null): Promise<WorktreeFacts> | WorktreeFacts =>
|
|
180
|
+
branch === null ? ABSENT_WORKTREE : branchFactsOf(context, branch)
|
|
181
|
+
|
|
122
182
|
/** 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> => {
|
|
183
|
+
const surveyOf = async (lane: string, branch: string | null, context: CloseContext): Promise<CloseSurvey> => {
|
|
124
184
|
const entry = worktreeEntryOf(runGit(context.root, ['worktree', 'list', '--porcelain']), context.worktree)
|
|
125
185
|
const present = entry !== null && existsSync(context.worktree)
|
|
126
186
|
const facts: CloseFacts = {
|
|
@@ -129,7 +189,7 @@ const surveyOf = async (lane: string, context: CloseContext): Promise<CloseSurve
|
|
|
129
189
|
setupFiles: setupFilesOf(context.root),
|
|
130
190
|
...agentFactsOf(laneStatusSourceFor(context.settings.adapter).listAgents(), lane, context.worktree),
|
|
131
191
|
runsInside: realPath(worktreeRoot(context.cwd)) === realPath(context.worktree),
|
|
132
|
-
...(present ? await worktreeFactsOf(context, entry) :
|
|
192
|
+
...(present ? await worktreeFactsOf(context, entry) : await absentWorktreeFactsOf(context, branch)),
|
|
133
193
|
}
|
|
134
194
|
return { facts, workspace: present ? workspaceOf(context) : null }
|
|
135
195
|
}
|
|
@@ -166,8 +226,8 @@ const deleteBranchIn =
|
|
|
166
226
|
runGit(context.root, ['update-ref', '-d', `${BRANCH_REF}${branch}`, head])
|
|
167
227
|
}
|
|
168
228
|
|
|
169
|
-
const portsFor = (lane: string, context: CloseContext): ClosePorts => ({
|
|
170
|
-
survey: () => surveyOf(lane, context),
|
|
229
|
+
const portsFor = (lane: string, branch: string | null, context: CloseContext): ClosePorts => ({
|
|
230
|
+
survey: () => surveyOf(lane, branch, context),
|
|
171
231
|
exitAgent: exitAgentIn(context),
|
|
172
232
|
removeWorktree: removeWorktreeIn(context),
|
|
173
233
|
deleteBranch: deleteBranchIn(context),
|
|
@@ -209,7 +269,7 @@ export const run = async (args: string[], cwd: string = process.cwd()): Promise<
|
|
|
209
269
|
now: new Date(),
|
|
210
270
|
language: context.settings.planLanguage,
|
|
211
271
|
}
|
|
212
|
-
return await closeLane(request, portsFor(options.lane, context))
|
|
272
|
+
return await closeLane(request, portsFor(options.lane, options.branch, context))
|
|
213
273
|
} catch (error) {
|
|
214
274
|
console.error(terminalSafe(error instanceof Error ? error.message : String(error)))
|
|
215
275
|
return 1
|
|
@@ -3,19 +3,37 @@ import { SAFE_LANE_NAME } from '../watch/lane-name'
|
|
|
3
3
|
import { DRY_RUN_OPTION, LANE_CLOSE_COMMAND } from './close-command-line'
|
|
4
4
|
|
|
5
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 }
|
|
6
|
+
export type CloseOptions = { lane: string; dryRun: boolean; boardPath: string | null; branch: string | null }
|
|
7
7
|
|
|
8
8
|
const BOARD = '--board'
|
|
9
|
+
const BRANCH = '--branch'
|
|
10
|
+
/** A branch name as the lane setup makes one; no leading dash or dot, no `..`, no `//`, no trailing `/` or `.lock`. */
|
|
11
|
+
const SAFE_BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/
|
|
12
|
+
const isBranchName = (name: string): boolean =>
|
|
13
|
+
SAFE_BRANCH.test(name) &&
|
|
14
|
+
!name.includes('..') &&
|
|
15
|
+
!name.includes('//') &&
|
|
16
|
+
!name.endsWith('/') &&
|
|
17
|
+
!name.endsWith('.lock')
|
|
9
18
|
|
|
10
|
-
export const LANE_CLOSE_USAGE = `usage: ${LANE_CLOSE_COMMAND} <lane> [${DRY_RUN_OPTION}] [--board <board.md>]
|
|
19
|
+
export const LANE_CLOSE_USAGE = `usage: ${LANE_CLOSE_COMMAND} <lane> [${DRY_RUN_OPTION}] [--board <board.md>] [${BRANCH} <branch>]
|
|
11
20
|
|
|
12
21
|
Closes a lane whose pull request is merged, once nothing in it would be lost: exits its agent, removes its workspace,
|
|
13
22
|
worktree and local branch, and marks its rows in the board with a history row. Refuses with every reason otherwise.
|
|
14
23
|
--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
|
|
24
|
+
--board <board.md> the board to mark; without it the board is picked as for ${CLI_NAME} herd
|
|
25
|
+
--branch <branch> finishes a lane whose worktree is gone already (a close that stopped half way): the branch
|
|
26
|
+
to prove merged and delete`
|
|
16
27
|
|
|
17
28
|
const usageError = (message: string): Error => new Error(`${message}\n${LANE_CLOSE_USAGE}`)
|
|
18
29
|
|
|
30
|
+
/** The value after an option; refused when the line ends or the next word is an option. */
|
|
31
|
+
const valueAfter = (args: readonly string[], index: number, option: string): string => {
|
|
32
|
+
const value = args[index + 1]
|
|
33
|
+
if (value === undefined || value.startsWith('--')) throw usageError(`${option} needs a value`)
|
|
34
|
+
return value
|
|
35
|
+
}
|
|
36
|
+
|
|
19
37
|
const laneOf = (words: readonly string[]): string => {
|
|
20
38
|
if (words.length === 0) throw usageError('the lane is required')
|
|
21
39
|
if (words.length > 1) throw usageError(`one lane at most, got ${words.join(' ')}`)
|
|
@@ -23,21 +41,24 @@ const laneOf = (words: readonly string[]): string => {
|
|
|
23
41
|
return words[0]
|
|
24
42
|
}
|
|
25
43
|
|
|
26
|
-
/** Reads `<lane> [--dry-run] [--board <file>]` in any order, the arguments after `close`; throws on a wrong line. */
|
|
44
|
+
/** Reads `<lane> [--dry-run] [--board <file>] [--branch <name>]` in any order, the arguments after `close`; throws on a wrong line. */
|
|
27
45
|
export const parseCloseOptions = (args: readonly string[]): CloseOptions => {
|
|
28
46
|
const words: string[] = []
|
|
29
47
|
let dryRun = false
|
|
30
48
|
let boardPath: string | null = null
|
|
49
|
+
let branch: string | null = null
|
|
31
50
|
for (let i = 0; i < args.length; i += 1) {
|
|
32
51
|
const arg = args[i]
|
|
33
52
|
if (arg === DRY_RUN_OPTION) dryRun = true
|
|
34
53
|
else if (arg === BOARD) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
54
|
+
boardPath = valueAfter(args, i, BOARD)
|
|
55
|
+
i += 1
|
|
56
|
+
} else if (arg === BRANCH) {
|
|
57
|
+
branch = valueAfter(args, i, BRANCH)
|
|
58
|
+
if (!isBranchName(branch)) throw usageError(`${branch} is no branch name`)
|
|
38
59
|
i += 1
|
|
39
60
|
} else if (arg.startsWith('--')) throw usageError(`unknown option ${arg}`)
|
|
40
61
|
else words.push(arg)
|
|
41
62
|
}
|
|
42
|
-
return { lane: laneOf(words), dryRun, boardPath }
|
|
63
|
+
return { lane: laneOf(words), dryRun, boardPath, branch }
|
|
43
64
|
}
|
package/src/lane/close-survey.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { sep } from 'node:path'
|
|
|
2
2
|
import { isRecord } from '../shared/is-record'
|
|
3
3
|
import type { LaneAgent } from '../terminal'
|
|
4
4
|
import { PULL_REQUEST_STATE, type WatchedPullRequest } from '../watch/pull-requests'
|
|
5
|
-
import type { CloseFacts, MergedPullRequest } from './close-check'
|
|
5
|
+
import type { CloseFacts, MergedPullRequest, StatusEntry } from './close-check'
|
|
6
6
|
|
|
7
7
|
/** A worktree as `git worktree list --porcelain` lists it; `branch` null when detached, `locked` as in `CloseFacts`. */
|
|
8
8
|
export type WorktreeEntry = { head: string; branch: string | null; locked: string | null }
|
|
@@ -81,3 +81,15 @@ export const agentFactsOf = (
|
|
|
81
81
|
otherAgents: inWorktree.filter((agent) => agent !== own).map(({ pane }) => pane),
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
const STAGED_ONLY = /^[MARC] $/
|
|
86
|
+
|
|
87
|
+
/** The staged-and-nothing-more changes under `planDirectory`: the paths a worktree on no branch may hold if the base does. */
|
|
88
|
+
export const stagedPlanPathsOf = (status: readonly StatusEntry[], planDirectory: string): string[] =>
|
|
89
|
+
status
|
|
90
|
+
.filter(({ code, path }) => STAGED_ONLY.test(code) && path.startsWith(`${planDirectory.replace(/\/+$/, '')}/`))
|
|
91
|
+
.map(({ path }) => path)
|
|
92
|
+
|
|
93
|
+
/** The candidates the base branch holds as they are staged, that is, those `git diff --cached <base>` does not list. */
|
|
94
|
+
export const baseHeldPathsOf = (candidates: readonly string[], differing: readonly string[]): string[] =>
|
|
95
|
+
candidates.filter((path) => !differing.includes(path))
|
package/src/lane/close.ts
CHANGED
|
@@ -35,25 +35,44 @@ export type ClosePorts = {
|
|
|
35
35
|
printError: (line: string) => void
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
type
|
|
38
|
+
type BranchLane = Extract<ClosableLane, { kind: 'branch' }>
|
|
39
|
+
|
|
40
|
+
type ClosedLane = { lane: string; closable: ClosableLane }
|
|
39
41
|
|
|
40
42
|
type Step = { label: string; run: () => Promise<void> | void }
|
|
41
43
|
|
|
42
44
|
/** A merge commit as the history row cites it. */
|
|
43
45
|
const SHORT_COMMIT_LENGTH = 7
|
|
44
46
|
|
|
45
|
-
const
|
|
46
|
-
[PLAN_LANGUAGE.de]: (
|
|
47
|
+
const BRANCH_HISTORY_TEXT: Record<PlanLanguage, (lane: string, closed: BranchLane, mergeCommit: string) => string> = {
|
|
48
|
+
[PLAN_LANGUAGE.de]: (lane, { branch, pullRequest }, mergeCommit) =>
|
|
47
49
|
`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]: (
|
|
50
|
+
[PLAN_LANGUAGE.en]: (lane, { branch, pullRequest }, mergeCommit) =>
|
|
49
51
|
`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
52
|
}
|
|
51
53
|
|
|
54
|
+
const DETACHED_HISTORY_TEXT: Record<PlanLanguage, (lane: string, head: string) => string> = {
|
|
55
|
+
[PLAN_LANGUAGE.de]: (lane, head) =>
|
|
56
|
+
`Spur \`${lane}\` abgeräumt mit \`${LANE_CLOSE_COMMAND}\`: Worktree ohne Branch, Stand \`${head}\` liegt auf dem Basis-Branch, Worktree entfernt`,
|
|
57
|
+
[PLAN_LANGUAGE.en]: (lane, head) =>
|
|
58
|
+
`Lane \`${lane}\` cleaned up with \`${LANE_CLOSE_COMMAND}\`: worktree on no branch, head \`${head}\` is on the base branch, worktree removed`,
|
|
59
|
+
}
|
|
60
|
+
|
|
52
61
|
const clockOf = (time: Date): string => `${twoDigits(time.getHours())}:${twoDigits(time.getMinutes())}`
|
|
53
62
|
|
|
54
63
|
/** 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
|
-
|
|
64
|
+
export const historyText = (closed: ClosedLane, request: Pick<CloseRequest, 'now' | 'language'>): string => {
|
|
65
|
+
const { lane, closable } = closed
|
|
66
|
+
const text =
|
|
67
|
+
closable.kind === 'detached'
|
|
68
|
+
? DETACHED_HISTORY_TEXT[request.language](lane, closable.head.slice(0, SHORT_COMMIT_LENGTH))
|
|
69
|
+
: BRANCH_HISTORY_TEXT[request.language](
|
|
70
|
+
lane,
|
|
71
|
+
closable,
|
|
72
|
+
closable.pullRequest.mergeCommit.slice(0, SHORT_COMMIT_LENGTH),
|
|
73
|
+
)
|
|
74
|
+
return `${clockOf(request.now)}: ${text}`
|
|
75
|
+
}
|
|
57
76
|
|
|
58
77
|
/** The board with the lane's rows marked and the history row on top. */
|
|
59
78
|
const closedBoard = (board: string, closed: ClosedLane, request: CloseRequest): string =>
|
|
@@ -73,16 +92,28 @@ const boardLabel = (facts: CloseFacts, request: CloseRequest): string =>
|
|
|
73
92
|
const removalSteps = (closed: ClosedLane, survey: CloseSurvey, request: CloseRequest, ports: ClosePorts): Step[] => {
|
|
74
93
|
const { workspace, facts } = survey
|
|
75
94
|
const place = workspace === null ? '' : ` and close workspace ${workspace}`
|
|
95
|
+
const worktree: Step[] = facts.worktreeExists
|
|
96
|
+
? [
|
|
97
|
+
{
|
|
98
|
+
label: `remove worktree ${request.worktree}${place}`,
|
|
99
|
+
run: () => ports.removeWorktree(request.worktree, workspace),
|
|
100
|
+
},
|
|
101
|
+
]
|
|
102
|
+
: []
|
|
76
103
|
return [
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
run: () => ports.removeWorktree(request.worktree, workspace),
|
|
80
|
-
},
|
|
81
|
-
{ label: `delete branch ${closed.branch}`, run: () => ports.deleteBranch(closed.branch, closed.head) },
|
|
104
|
+
...worktree,
|
|
105
|
+
...branchSteps(closed.closable, ports),
|
|
82
106
|
{ label: boardLabel(facts, request), run: () => ports.writeBoard(closedBoard(ports.readBoard(), closed, request)) },
|
|
83
107
|
]
|
|
84
108
|
}
|
|
85
109
|
|
|
110
|
+
/** Deleting the branch is a step only while the branch exists: a lane resumed after a partial failure may have lost it. */
|
|
111
|
+
const branchSteps = (closable: ClosableLane, ports: ClosePorts): Step[] => {
|
|
112
|
+
if (closable.kind === 'detached' || closable.head === null) return []
|
|
113
|
+
const { branch, head } = closable
|
|
114
|
+
return [{ label: `delete branch ${branch}`, run: () => ports.deleteBranch(branch, head) }]
|
|
115
|
+
}
|
|
116
|
+
|
|
86
117
|
const exitSteps = ({ facts }: CloseSurvey, request: CloseRequest, ports: ClosePorts): Step[] => {
|
|
87
118
|
const { agent } = facts
|
|
88
119
|
if (agent === null) return []
|
|
@@ -91,10 +122,14 @@ const exitSteps = ({ facts }: CloseSurvey, request: CloseRequest, ports: ClosePo
|
|
|
91
122
|
]
|
|
92
123
|
}
|
|
93
124
|
|
|
125
|
+
/** Nothing is left to close: no worktree, no branch, and the board marks the lane. */
|
|
126
|
+
const isClosedInFull = ({ worktreeExists, head, boardLane }: CloseFacts): boolean =>
|
|
127
|
+
!worktreeExists && head === null && boardLane === BOARD_LANE.closed
|
|
128
|
+
|
|
94
129
|
/** The lane closable from `survey`, or null after printing every reason against it under `heading`. */
|
|
95
130
|
const checked = (survey: CloseSurvey, heading: string, request: CloseRequest, ports: ClosePorts): ClosedLane | null => {
|
|
96
131
|
const verdict = closeVerdict(survey.facts)
|
|
97
|
-
if (verdict.kind === 'closable') return { lane: request.lane,
|
|
132
|
+
if (verdict.kind === 'closable') return { lane: request.lane, closable: verdict.lane }
|
|
98
133
|
ports.printError(heading)
|
|
99
134
|
for (const refusal of verdict.refusals) ports.printError(` ${terminalSafe(refusal)}`)
|
|
100
135
|
return null
|
|
@@ -123,6 +158,10 @@ const runSteps = async (steps: readonly Step[], ports: ClosePorts): Promise<bool
|
|
|
123
158
|
*/
|
|
124
159
|
export const closeLane = async (request: CloseRequest, ports: ClosePorts): Promise<number> => {
|
|
125
160
|
const first = await ports.survey()
|
|
161
|
+
if (isClosedInFull(first.facts)) {
|
|
162
|
+
ports.print(`${request.lane} is closed already: no worktree, no branch, and the board marks it`)
|
|
163
|
+
return 0
|
|
164
|
+
}
|
|
126
165
|
const closed = checked(first, `not closing ${request.lane}:`, request, ports)
|
|
127
166
|
if (closed === null) return 1
|
|
128
167
|
const exit = exitSteps(first, request, ports)
|
package/src/lane/command.ts
CHANGED
|
@@ -1,13 +1,31 @@
|
|
|
1
1
|
import { run as runPrompt } from '../lane-prompt/command'
|
|
2
|
+
import { asksForHelp } from '../preview/options'
|
|
3
|
+
import { startLaneHintFor } from './start-lane-hint'
|
|
2
4
|
|
|
3
5
|
const CLOSE = 'close'
|
|
6
|
+
const SPACE = 'space'
|
|
7
|
+
const SEND = 'send'
|
|
8
|
+
const BASE = 'base'
|
|
9
|
+
|
|
10
|
+
/** `code` after the call; a call from the main checkout that succeeded adds the hint to the start-lane reference on stderr, so the output a caller captures stays the one value. */
|
|
11
|
+
const withStartLaneHint = (code: number, args: readonly string[], cwd: string): number => {
|
|
12
|
+
const hint = code === 0 && !asksForHelp(args) ? startLaneHintFor(cwd) : null
|
|
13
|
+
if (hint !== null) console.error(hint)
|
|
14
|
+
return code
|
|
15
|
+
}
|
|
4
16
|
|
|
5
17
|
/**
|
|
6
|
-
* `jh lane close …` closes a lane,
|
|
7
|
-
* `jh lane
|
|
18
|
+
* `jh lane close …` closes a lane, `jh lane space …` checks the disk for a new one, `jh lane send …` sends its prompt
|
|
19
|
+
* file, `jh lane base …` names the ref its worktree starts from, everything else is `jh lane prompt …`. `close` must
|
|
20
|
+
* come first, so the ask rule `jh lane close` always sees it; `jh lane --board b.md close x` reaches the prompt parser
|
|
21
|
+
* and is refused there.
|
|
8
22
|
*/
|
|
9
23
|
export const run = async (args: string[], cwd: string = process.cwd()): Promise<number> => {
|
|
10
|
-
if (args[0]
|
|
24
|
+
if (args[0] === SPACE) return (await import('./space-command')).run(args.slice(1), cwd)
|
|
25
|
+
if (args[0] === SEND) return (await import('./send-command')).run(args.slice(1), cwd)
|
|
26
|
+
if (args[0] === BASE)
|
|
27
|
+
return withStartLaneHint(await (await import('./base-command')).run(args.slice(1), cwd), args.slice(1), cwd)
|
|
28
|
+
if (args[0] !== CLOSE) return withStartLaneHint(await runPrompt(args, cwd), args, cwd)
|
|
11
29
|
const { run: runClose } = await import('./close-command')
|
|
12
30
|
return runClose(args.slice(1), cwd)
|
|
13
31
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { defaultPromptFile } from '../lane-prompt/command'
|
|
4
|
+
import { mainCheckout } from '../plans/git'
|
|
5
|
+
import { asksForHelp } from '../preview/options'
|
|
6
|
+
import { SAFE_LANE_NAME } from '../watch/lane-name'
|
|
7
|
+
import { sendLanePrompt } from './send'
|
|
8
|
+
import { herdrSendPorts } from './send-herdr'
|
|
9
|
+
import { LANE_SEND_USAGE, parseSendOptions } from './send-options'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `jh lane send <lane> [--file <prompt>]`: sends the lane's prompt file (default: the one `jh lane prompt` wrote) to
|
|
13
|
+
* its agent with `--wait --until working`, and reads the screen on a stall (orchestrate § 2 step 8). Prints one line.
|
|
14
|
+
* Exit 0 sent, 1 refused or stalled twice.
|
|
15
|
+
*/
|
|
16
|
+
export const run = async (args: string[], cwd: string = process.cwd()): Promise<number> => {
|
|
17
|
+
if (asksForHelp(args)) {
|
|
18
|
+
console.log(LANE_SEND_USAGE)
|
|
19
|
+
return 0
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const { lane, file } = parseSendOptions(args)
|
|
23
|
+
if (!SAFE_LANE_NAME.test(lane)) throw new Error(`${lane} is no lane name (letters, digits, . _ -)`)
|
|
24
|
+
const path = file === null ? defaultPromptFile(mainCheckout(cwd), lane) : resolve(cwd, file)
|
|
25
|
+
const outcome = await sendLanePrompt(herdrSendPorts(), lane, readFileSync(path, 'utf8'))
|
|
26
|
+
console.log(outcome.line)
|
|
27
|
+
return outcome.exitCode
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
30
|
+
return 1
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { isRecord } from '../shared/is-record'
|
|
2
|
+
import { herdrBinary } from '../sync/tools'
|
|
3
|
+
import type { HerdrAnswer, SendPorts } from './send'
|
|
4
|
+
|
|
5
|
+
const PROMPT_TIMEOUT_MS = '30000'
|
|
6
|
+
const SCREEN_LINES = '12'
|
|
7
|
+
const WORKING = 'working'
|
|
8
|
+
const WORKING_POLLS = 10
|
|
9
|
+
const WORKING_POLL_MS = 1000
|
|
10
|
+
|
|
11
|
+
/** The JSON object herdr printed; empty for anything else. */
|
|
12
|
+
const answerObject = (stdout: string): Record<string, unknown> => {
|
|
13
|
+
try {
|
|
14
|
+
const answer: unknown = JSON.parse(stdout)
|
|
15
|
+
return isRecord(answer) ? answer : {}
|
|
16
|
+
} catch {
|
|
17
|
+
return {}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The answer of one herdr call from its exit code and its JSON on stdout: `error.code` where it failed. */
|
|
22
|
+
export const answerOf = (exitCode: number, stdout: string): HerdrAnswer => {
|
|
23
|
+
if (exitCode === 0) return { ok: true, code: null }
|
|
24
|
+
const { error } = answerObject(stdout)
|
|
25
|
+
const code = isRecord(error) ? error.code : undefined
|
|
26
|
+
return { ok: false, code: typeof code === 'string' ? code : null }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Whether the answer of `herdr agent get <lane>` reports the agent as working. */
|
|
30
|
+
export const isWorkingAnswer = (stdout: string): boolean => {
|
|
31
|
+
const { result } = answerObject(stdout)
|
|
32
|
+
const agent = isRecord(result) ? result.agent : undefined
|
|
33
|
+
return isRecord(agent) && agent.agent_status === WORKING
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Run = (args: string[]) => { exitCode: number; stdout: string }
|
|
37
|
+
|
|
38
|
+
const spawnHerdr =
|
|
39
|
+
(binary: string): Run =>
|
|
40
|
+
(args) => {
|
|
41
|
+
const result = Bun.spawnSync([binary, ...args], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
|
|
42
|
+
return { exitCode: result.exitCode, stdout: result.stdout.toString() }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The send's calls on the lane's agent, made with the `herdr` binary; the text is one argument, no shell reads it. */
|
|
46
|
+
export const herdrSendPorts = (run: Run = spawnHerdr(herdrBinary(process.env))): SendPorts => {
|
|
47
|
+
const isWorking = (lane: string): boolean => isWorkingAnswer(run(['agent', 'get', lane]).stdout)
|
|
48
|
+
return {
|
|
49
|
+
prompt: (lane, text) => {
|
|
50
|
+
const { exitCode, stdout } = run([
|
|
51
|
+
'agent',
|
|
52
|
+
'prompt',
|
|
53
|
+
lane,
|
|
54
|
+
text,
|
|
55
|
+
'--wait',
|
|
56
|
+
'--until',
|
|
57
|
+
WORKING,
|
|
58
|
+
'--timeout',
|
|
59
|
+
PROMPT_TIMEOUT_MS,
|
|
60
|
+
])
|
|
61
|
+
return answerOf(exitCode, stdout)
|
|
62
|
+
},
|
|
63
|
+
isWorking,
|
|
64
|
+
screen: (lane) => run(['agent', 'read', lane, '--source', 'visible', '--lines', SCREEN_LINES]).stdout,
|
|
65
|
+
enter: (lane) => {
|
|
66
|
+
const { exitCode, stdout } = run(['agent', 'send-keys', lane, 'enter'])
|
|
67
|
+
return answerOf(exitCode, stdout)
|
|
68
|
+
},
|
|
69
|
+
awaitWorking: async (lane) => {
|
|
70
|
+
for (let poll = 0; poll < WORKING_POLLS; poll++) {
|
|
71
|
+
if (isWorking(lane)) return true
|
|
72
|
+
await Bun.sleep(WORKING_POLL_MS)
|
|
73
|
+
}
|
|
74
|
+
return false
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
}
|