jackherd 1.16.0 → 1.18.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 +28 -0
- package/INSTALL.md +2 -1
- package/README.md +29 -33
- 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 +16 -22
- package/kit/rules/rules/kit/code-style.md +26 -54
- package/kit/rules/rules/kit/pull-requests.md +17 -40
- package/kit/rules/rules/kit/testing.md +6 -12
- 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 +28 -43
- package/kit/workflow/skills/orchestrate/SKILL.md +201 -458
- package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
- package/kit/workflow/skills/orchestrate/references/approvals.md +23 -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 +10 -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 +29 -48
- 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 +90 -32
- package/src/check/skill-budgets.ts +96 -18
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { CLI_NAME } from '../version/release'
|
|
2
|
+
|
|
3
|
+
/** What `jh lane send` was asked for; `file` is null where `--file` was left out. */
|
|
4
|
+
export type SendOptions = { lane: string; file: string | null }
|
|
5
|
+
|
|
6
|
+
const FILE = '--file'
|
|
7
|
+
|
|
8
|
+
export const LANE_SEND_USAGE = `usage: ${CLI_NAME} lane send <lane> [${FILE} <prompt-file>]
|
|
9
|
+
|
|
10
|
+
Sends the lane's prompt file (default: .claude/handover/<lane>-prompt.md of the main checkout) to its agent and waits
|
|
11
|
+
until it works. On a stall it reads the screen: a lane at work counts as sent, a prompt on the screen gets enter (exit 1 if that does not start the lane), else the
|
|
12
|
+
file is sent once more. A second stall exits 1 with one line for the coordinator.`
|
|
13
|
+
|
|
14
|
+
/** Reads `<lane> [--file <path>]`; throws with the usage on anything else. */
|
|
15
|
+
export const parseSendOptions = (args: readonly string[]): SendOptions => {
|
|
16
|
+
const [lane, ...rest] = args
|
|
17
|
+
if (lane === undefined || lane.startsWith('-')) throw new Error(LANE_SEND_USAGE)
|
|
18
|
+
if (rest.length === 0) return { lane, file: null }
|
|
19
|
+
const [option, file, ...extra] = rest
|
|
20
|
+
if (option !== FILE || file === undefined || file.startsWith('--') || extra.length > 0)
|
|
21
|
+
throw new Error(LANE_SEND_USAGE)
|
|
22
|
+
return { lane, file }
|
|
23
|
+
}
|
package/src/lane/send.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** The codes of herdr's answer to `herdr agent prompt --wait` that do not prove the text is lost. */
|
|
2
|
+
export const SEND_CODE = { stalled: 'agent_prompt_stalled', timeout: 'timeout' } as const
|
|
3
|
+
|
|
4
|
+
/** How long the input line's mark is: whole on any screen width the lane's pane has. */
|
|
5
|
+
const MARK_LENGTH = 40
|
|
6
|
+
|
|
7
|
+
type Awaitable<T> = T | Promise<T>
|
|
8
|
+
|
|
9
|
+
/** What one call of herdr answered: `ok`, else the `error.code` of its answer, null when it named none. */
|
|
10
|
+
export type HerdrAnswer = { ok: boolean; code: string | null }
|
|
11
|
+
|
|
12
|
+
/** The calls the send makes on the lane's agent; the command wires them to `herdr`. */
|
|
13
|
+
export type SendPorts = {
|
|
14
|
+
/** `herdr agent prompt <lane> <text> --wait --until working`. */
|
|
15
|
+
prompt: (lane: string, text: string) => Awaitable<HerdrAnswer>
|
|
16
|
+
/** Whether herdr reports the lane's agent as working. */
|
|
17
|
+
isWorking: (lane: string) => Awaitable<boolean>
|
|
18
|
+
/** The visible screen of the lane's agent. */
|
|
19
|
+
screen: (lane: string) => Awaitable<string>
|
|
20
|
+
/** `herdr agent send-keys <lane> enter`. */
|
|
21
|
+
enter: (lane: string) => Awaitable<HerdrAnswer>
|
|
22
|
+
/** Whether the lane's agent starts working within a few seconds. */
|
|
23
|
+
awaitWorking: (lane: string) => Awaitable<boolean>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SendOutcome = {
|
|
27
|
+
kind: 'sent' | 'entered' | 'resent' | 'failed'
|
|
28
|
+
exitCode: 0 | 1
|
|
29
|
+
/** The one line for Jack. */
|
|
30
|
+
line: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const isStall = ({ code }: HerdrAnswer): boolean => code === SEND_CODE.stalled || code === SEND_CODE.timeout
|
|
34
|
+
|
|
35
|
+
/** The start of the prompt's first line: what the input line shows while the text waits there, unsent. */
|
|
36
|
+
export const inputLineMarkOf = (prompt: string): string =>
|
|
37
|
+
(prompt.split('\n').find((line) => line.trim() !== '') ?? '').slice(0, MARK_LENGTH)
|
|
38
|
+
|
|
39
|
+
const outcome = (kind: SendOutcome['kind'], lane: string, text: string): SendOutcome => ({
|
|
40
|
+
kind,
|
|
41
|
+
exitCode: kind === 'failed' ? 1 : 0,
|
|
42
|
+
line: `${lane}: ${text}`,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const refused = (lane: string, answer: HerdrAnswer): SendOutcome =>
|
|
46
|
+
outcome('failed', lane, `herdr refused the prompt (${answer.code ?? 'unknown'}); ask the user.`)
|
|
47
|
+
|
|
48
|
+
/** Whether the start of the prompt is on the visible screen: in the input line, or already in the transcript. */
|
|
49
|
+
const screenShowsPrompt = async (ports: SendPorts, lane: string, prompt: string): Promise<boolean> => {
|
|
50
|
+
const mark = inputLineMarkOf(prompt)
|
|
51
|
+
return mark !== '' && (await ports.screen(lane)).includes(mark)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* After a stall: done when the lane works; `enter` when the prompt is on the screen (an `enter` on an empty input line
|
|
56
|
+
* does nothing); null when the screen does not show it. `stuck` is a prompt on the screen that `enter` did not start:
|
|
57
|
+
* sending the file again would append it to the text that waits.
|
|
58
|
+
*/
|
|
59
|
+
const recovered = async (
|
|
60
|
+
ports: SendPorts,
|
|
61
|
+
lane: string,
|
|
62
|
+
prompt: string,
|
|
63
|
+
): Promise<'working' | 'entered' | 'stuck' | null> => {
|
|
64
|
+
if (await ports.isWorking(lane)) return 'working'
|
|
65
|
+
if (!(await screenShowsPrompt(ports, lane, prompt))) return null
|
|
66
|
+
const answer = await ports.enter(lane)
|
|
67
|
+
return answer.ok && (await ports.awaitWorking(lane)) ? 'entered' : 'stuck'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Sends the prompt text to the lane and makes sure it arrived. A stall or a timeout does not prove the text is lost:
|
|
72
|
+
* the screen decides. A lane at work is done; a prompt on the screen gets `enter`, and an `enter` the lane does not
|
|
73
|
+
* answer ends with exit 1, since another send would append to the waiting text; a screen without the prompt sends the
|
|
74
|
+
* text once more. A second stall, or any other error, ends with exit 1 and a line for Jack.
|
|
75
|
+
*/
|
|
76
|
+
export const sendLanePrompt = async (ports: SendPorts, lane: string, prompt: string): Promise<SendOutcome> => {
|
|
77
|
+
const first = await ports.prompt(lane, prompt)
|
|
78
|
+
if (first.ok) return outcome('sent', lane, 'prompt sent.')
|
|
79
|
+
if (!isStall(first)) return refused(lane, first)
|
|
80
|
+
const state = await recovered(ports, lane, prompt)
|
|
81
|
+
if (state === 'working') return outcome('sent', lane, 'prompt sent, the lane works.')
|
|
82
|
+
if (state === 'entered') return outcome('entered', lane, 'prompt was waiting in the input line, sent with enter.')
|
|
83
|
+
if (state === 'stuck')
|
|
84
|
+
return outcome('failed', lane, 'the prompt is on the screen and enter did not start the lane; ask the user.')
|
|
85
|
+
const second = await ports.prompt(lane, prompt)
|
|
86
|
+
if (second.ok || (await ports.isWorking(lane))) return outcome('resent', lane, 'prompt sent a second time.')
|
|
87
|
+
return isStall(second)
|
|
88
|
+
? outcome('failed', lane, 'the prompt stalled twice and the lane does not work; ask the user.')
|
|
89
|
+
: refused(lane, second)
|
|
90
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { DEFAULT_SKILL_PREFIX } from '../config/schema'
|
|
4
|
+
import { mainCheckoutOrNull } from '../plans/git'
|
|
5
|
+
import { readSettings } from '../plans/settings'
|
|
6
|
+
import { type Reference, referenceHint, skillCopiesFor, skillDirectoryFor } from '../watch/reference-paths'
|
|
7
|
+
|
|
8
|
+
/** The directory of the orchestrate skill installed for `root`: the project's copy, else the global one; null without. */
|
|
9
|
+
export const installedSkillDirectory = (root: string, home: string = homedir()): string | null =>
|
|
10
|
+
skillDirectoryFor(skillCopiesFor(root, home, readSettings([root]).skillPrefix, DEFAULT_SKILL_PREFIX), existsSync)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* ` · read <skill directory>/<reference>` for the orchestrate skill installed for `root`; empty without one. A command
|
|
14
|
+
* that also runs without orchestrate (`jh check`, `jh plan check`, `jh waiting`) stays silent then, so a user who does
|
|
15
|
+
* not have the skill is not sent to its files; orchestrate's own commands (`jh lane`, the watcher) name the skill
|
|
16
|
+
* instead (`referenceHint` with no directory).
|
|
17
|
+
*/
|
|
18
|
+
export const installedReferenceHint = (root: string, reference: Reference, home: string = homedir()): string => {
|
|
19
|
+
const directory = installedSkillDirectory(root, home)
|
|
20
|
+
return directory === null ? '' : referenceHint(reference, directory)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `installedReferenceHint` for the main checkout of the repository `cwd` is in; empty outside a git checkout. */
|
|
24
|
+
export const installedReferenceHintFor = (cwd: string, reference: Reference): string => {
|
|
25
|
+
const root = mainCheckoutOrNull(cwd)
|
|
26
|
+
return root === null ? '' : installedReferenceHint(root, reference)
|
|
27
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { statfsSync } from 'node:fs'
|
|
2
|
+
import { mainCheckout, worktreeRoot } from '../plans/git'
|
|
3
|
+
import { readSettings } from '../plans/settings'
|
|
4
|
+
import { asksForHelp } from '../preview/options'
|
|
5
|
+
import { freeSpaceLine, LANE_SPACE_USAGE, parseSpaceOptions, spaceVerdict } from './space'
|
|
6
|
+
|
|
7
|
+
/** The bytes a normal user can still write on the disk that holds `path`. */
|
|
8
|
+
const freeBytesAt = (path: string): number => {
|
|
9
|
+
const { bavail, bsize } = statfsSync(path)
|
|
10
|
+
return Number(bavail) * Number(bsize)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** `jh lane space [--min-gb <n>]`: prints one line; exit 0 with room for a lane, 1 without, or on a wrong line. */
|
|
14
|
+
export const run = (args: string[], cwd: string = process.cwd()): number => {
|
|
15
|
+
if (asksForHelp(args)) {
|
|
16
|
+
console.log(LANE_SPACE_USAGE)
|
|
17
|
+
return 0
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const root = mainCheckout(cwd)
|
|
21
|
+
const { minFreeGb } = readSettings([root, worktreeRoot(cwd)])
|
|
22
|
+
const needed = parseSpaceOptions(args).minFreeGb ?? minFreeGb
|
|
23
|
+
const verdict = spaceVerdict(freeBytesAt(root), needed)
|
|
24
|
+
console.log(freeSpaceLine(verdict))
|
|
25
|
+
return verdict.enough ? 0 : 1
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
28
|
+
return 1
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { CLI_NAME } from '../version/release'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Free space a new lane needs before its dependencies are installed, in gigabytes. Measured 24.10.2026: a first
|
|
5
|
+
* `bun install` of a worktree writes 2.3 GB when the cache holds none of it, and a warm one about 0.1 GB beside the
|
|
6
|
+
* 0.2 GB checkout (the packages are hardlinks into the cache); the rest is room to build and test.
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_MIN_FREE_GB = 5
|
|
9
|
+
|
|
10
|
+
const BYTES_PER_GB = 1024 ** 3
|
|
11
|
+
const COLD_INSTALL_GB = 2.3
|
|
12
|
+
|
|
13
|
+
/** `minFreeGb` is null where `--min-gb` was left out: the configured threshold then stands. */
|
|
14
|
+
export type SpaceOptions = { minFreeGb: number | null }
|
|
15
|
+
|
|
16
|
+
export type SpaceVerdict = { enough: boolean; freeGb: number; neededGb: number }
|
|
17
|
+
|
|
18
|
+
const MIN_GB_OPTION = '--min-gb'
|
|
19
|
+
|
|
20
|
+
export const LANE_SPACE_USAGE = `usage: ${CLI_NAME} lane space [${MIN_GB_OPTION} <gigabytes>]
|
|
21
|
+
|
|
22
|
+
Checks that the disk holds enough free space for a new lane before its dependencies are installed; exit 1 and one line
|
|
23
|
+
when it does not. The threshold is lanes.minFreeGb of the config (default ${DEFAULT_MIN_FREE_GB} GB) unless ${MIN_GB_OPTION} says otherwise.`
|
|
24
|
+
|
|
25
|
+
/** Reads `[--min-gb <n>]`; throws with the usage on anything else. */
|
|
26
|
+
export const parseSpaceOptions = (args: readonly string[]): SpaceOptions => {
|
|
27
|
+
if (args.length === 0) return { minFreeGb: null }
|
|
28
|
+
const [option, value, ...rest] = args
|
|
29
|
+
if (option !== MIN_GB_OPTION || rest.length > 0) throw new Error(LANE_SPACE_USAGE)
|
|
30
|
+
const minFreeGb = Number(value)
|
|
31
|
+
if (value === undefined || !Number.isFinite(minFreeGb) || minFreeGb <= 0)
|
|
32
|
+
throw new Error(`${MIN_GB_OPTION} needs a number of gigabytes above 0\n${LANE_SPACE_USAGE}`)
|
|
33
|
+
return { minFreeGb }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const roundedDown = (value: number): number => Math.floor(value * 10) / 10
|
|
37
|
+
|
|
38
|
+
/** Whether `freeBytes` reach the threshold; the free value is rounded down to one decimal. */
|
|
39
|
+
export const spaceVerdict = (freeBytes: number, neededGb: number): SpaceVerdict => ({
|
|
40
|
+
enough: freeBytes >= neededGb * BYTES_PER_GB,
|
|
41
|
+
freeGb: roundedDown(freeBytes / BYTES_PER_GB),
|
|
42
|
+
neededGb,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
/** The one line the command prints, for the coordinator to pass on. */
|
|
46
|
+
export const freeSpaceLine = ({ enough, freeGb, neededGb }: SpaceVerdict): string =>
|
|
47
|
+
enough
|
|
48
|
+
? `${freeGb} GB free, ${neededGb} GB needed: room for a lane`
|
|
49
|
+
: `no lane started: ${freeGb} GB free, ${neededGb} GB needed (a fresh install takes about ${COLD_INSTALL_GB} GB, a lane needs room to build); free space first`
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { mainCheckoutOrNull, worktreeRoot } from '../plans/git'
|
|
3
|
+
import { REFERENCE, referenceHint } from '../watch/reference-paths'
|
|
4
|
+
import { installedSkillDirectory } from './skill-hint'
|
|
5
|
+
|
|
6
|
+
/** `· read <skill directory>/references/start-lane.md`, the line after the output of `jh lane base` and `jh lane prompt`. */
|
|
7
|
+
export const startLaneHintLine = (skillDirectory: string | null): string =>
|
|
8
|
+
referenceHint(REFERENCE.startLane, skillDirectory).trimStart()
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The hint line for a call from the main checkout, where a lane is started from; null from a lane's worktree or outside a
|
|
12
|
+
* checkout, which have no start to guide. The skill copy is the project's, else the global one.
|
|
13
|
+
*/
|
|
14
|
+
export const startLaneHintFor = (cwd: string): string | null => {
|
|
15
|
+
const root = mainCheckoutOrNull(cwd)
|
|
16
|
+
if (root === null || worktreeRoot(cwd) !== root) return null
|
|
17
|
+
return startLaneHintLine(installedSkillDirectory(root, homedir()))
|
|
18
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { basename, resolve } from 'node:path'
|
|
3
|
+
import { commitOf, existsOnRef, mainCheckout, worktreeRoot } from '../plans/git'
|
|
4
|
+
import { baseRefOf, boardGlobOf, boardRepoPathOf, readSettings } from '../plans/settings'
|
|
5
|
+
import { baseGapLines, repoPathOf } from './base-check'
|
|
6
|
+
|
|
7
|
+
/** The boards of the main checkout that link the plan, by its file name, as paths from the repository root. */
|
|
8
|
+
const boardPathsLinking = (root: string, plan: string): string[] => {
|
|
9
|
+
const settings = readSettings([root])
|
|
10
|
+
const name = basename(plan)
|
|
11
|
+
return [...new Bun.Glob(boardGlobOf(settings)).scanSync({ cwd: root })]
|
|
12
|
+
.map((file) => resolve(root, file))
|
|
13
|
+
.filter((file) => readFileSync(file, 'utf8').includes(name))
|
|
14
|
+
.map((file) => boardRepoPathOf(root, settings, file))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The lines that say the plan or a board that links it is not on the base branch (orchestrate § 0), each with its path
|
|
19
|
+
* from the repository root. None where git cannot judge: outside a repository, without the base ref, or for a file
|
|
20
|
+
* outside the repository.
|
|
21
|
+
*/
|
|
22
|
+
export const baseGapLinesFor = (file: string, cwd: string): string[] => {
|
|
23
|
+
try {
|
|
24
|
+
const root = mainCheckout(cwd)
|
|
25
|
+
const baseRef = baseRefOf(readSettings([root]))
|
|
26
|
+
if (commitOf(root, baseRef) === null) return []
|
|
27
|
+
const plan = repoPathOf(worktreeRoot(cwd), resolve(cwd, file))
|
|
28
|
+
if (plan === null) return []
|
|
29
|
+
return baseGapLines(baseRef, [plan, ...boardPathsLinking(root, plan)], (path) => existsOnRef(root, baseRef, path))
|
|
30
|
+
} catch {
|
|
31
|
+
return []
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve } from 'node:path'
|
|
2
|
+
|
|
3
|
+
/** The path of `file` from the repository root, without `..`; null for the root itself or a file outside it. */
|
|
4
|
+
export const repoPathOf = (root: string, file: string): string | null => {
|
|
5
|
+
const path = relative(resolve(root), resolve(file))
|
|
6
|
+
return path === '' || path.startsWith('..') || isAbsolute(path) ? null : path
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** One line per file of `paths` that `isOnBase` does not find on `baseRef`: git does not resolve `..` in a tree path. */
|
|
10
|
+
export const baseGapLines = (
|
|
11
|
+
baseRef: string,
|
|
12
|
+
paths: readonly string[],
|
|
13
|
+
isOnBase: (path: string) => boolean,
|
|
14
|
+
): string[] =>
|
|
15
|
+
paths
|
|
16
|
+
.filter((path) => !isOnBase(path))
|
|
17
|
+
.map((path) => `base: ${path} is not on ${baseRef}: ask for its documentation commit (orchestrate § 2 step 0).`)
|