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/plans/github.ts
CHANGED
|
@@ -4,31 +4,40 @@ import { PULL_REQUEST_FIELDS, type PullRequest, parsePullRequests, pullRequestsB
|
|
|
4
4
|
/** Open pull requests by head branch, or none with the reason when `gh` fails. */
|
|
5
5
|
export type PullRequestState = { byBranch: Map<string, PullRequest[]>; errors: string[] }
|
|
6
6
|
|
|
7
|
+
/** What `gh` answered: stdout on success, else the reason. */
|
|
8
|
+
export type GhAnswer = { ok: true; stdout: string } | { ok: false; reason: string }
|
|
9
|
+
|
|
7
10
|
export const noPullRequests = (): PullRequestState => ({ byBranch: new Map(), errors: [] })
|
|
8
11
|
|
|
9
12
|
/** The GitHub host whose pull request links are trusted; `GH_HOST` names an Enterprise server. */
|
|
10
13
|
export const GITHUB_HOST = process.env.GH_HOST ?? 'github.com'
|
|
11
14
|
|
|
12
|
-
const runGh = async (
|
|
13
|
-
const child = Bun.spawn(['gh',
|
|
14
|
-
cwd: root,
|
|
15
|
-
stdout: 'pipe',
|
|
16
|
-
stderr: 'pipe',
|
|
17
|
-
})
|
|
15
|
+
const runGh = async (args: readonly string[], root: string): Promise<GhAnswer> => {
|
|
16
|
+
const child = Bun.spawn(['gh', ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
|
|
18
17
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
19
18
|
new Response(child.stdout).text(),
|
|
20
19
|
new Response(child.stderr).text(),
|
|
21
20
|
child.exited,
|
|
22
21
|
])
|
|
23
|
-
return { stdout, stderr
|
|
22
|
+
return exitCode === 0 ? { ok: true, stdout } : { ok: false, reason: stderr.trim() || `exit ${exitCode}` }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Runs `gh`; never rejects, also not when `gh` is not installed, and the reason is terminal-safe. */
|
|
26
|
+
const askGh = async (args: readonly string[], root: string): Promise<GhAnswer> => {
|
|
27
|
+
try {
|
|
28
|
+
const answer = await runGh(args, root)
|
|
29
|
+
return answer.ok ? answer : { ok: false, reason: errorText(answer.reason) }
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return { ok: false, reason: errorText(error) }
|
|
32
|
+
}
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
/** Open pull requests, or none with the reason; never rejects, also not when `gh` is not installed. */
|
|
27
36
|
export const fetchPullRequests = async (root: string): Promise<PullRequestState> => {
|
|
37
|
+
const answer = await askGh(['pr', 'list', '--state', 'open', '--limit', '100', '--json', PULL_REQUEST_FIELDS], root)
|
|
38
|
+
if (!answer.ok) return { byBranch: new Map(), errors: [`gh pr list: ${answer.reason}`] }
|
|
28
39
|
try {
|
|
29
|
-
|
|
30
|
-
if (exitCode !== 0) throw new Error(`gh pr list: ${stderr.trim() || `exit ${exitCode}`}`)
|
|
31
|
-
return { byBranch: pullRequestsByBranch(parsePullRequests(stdout, GITHUB_HOST)), errors: [] }
|
|
40
|
+
return { byBranch: pullRequestsByBranch(parsePullRequests(answer.stdout, GITHUB_HOST)), errors: [] }
|
|
32
41
|
} catch (error) {
|
|
33
42
|
return { byBranch: new Map(), errors: [errorText(error)] }
|
|
34
43
|
}
|
package/src/plans/layout.ts
CHANGED
|
@@ -81,8 +81,10 @@ import {
|
|
|
81
81
|
import {
|
|
82
82
|
BACKLOG_KEY,
|
|
83
83
|
type ClickTarget,
|
|
84
|
+
entryBlockHeadKey,
|
|
84
85
|
isBlockFolded,
|
|
85
86
|
isBlockHeadKey,
|
|
87
|
+
isEntryBlockFolded,
|
|
86
88
|
isLaneOpen,
|
|
87
89
|
isLanesOpen,
|
|
88
90
|
LANES_KEY,
|
|
@@ -239,18 +241,23 @@ type TopBlock = {
|
|
|
239
241
|
hidden: number
|
|
240
242
|
expanded: boolean
|
|
241
243
|
rows: BlockRow[]
|
|
242
|
-
/** The mark in front of the name in place of the chevron, for a block that does not fold; absent for the chevron. */
|
|
243
|
-
headMark?: string
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
/** What the head of a block says: its name, its count, its balance and the paint of the count. */
|
|
247
|
-
type BlockHeadParts = Pick<TopBlock, 'name' | 'count' | 'balance' | 'countPaint'
|
|
247
|
+
type BlockHeadParts = Pick<TopBlock, 'name' | 'count' | 'balance' | 'countPaint'>
|
|
248
248
|
|
|
249
249
|
/** The blocks on top of the herd one under the other: their lines below the title, and the hit of each drawn block. */
|
|
250
250
|
type BlockStack = { lines: string[]; hits: (BlockHit | null)[] }
|
|
251
251
|
|
|
252
252
|
/** What every lane of the lanes block is drawn with. */
|
|
253
|
-
type LanesContext = {
|
|
253
|
+
type LanesContext = {
|
|
254
|
+
state: Selection
|
|
255
|
+
width: number
|
|
256
|
+
badgeTexts: BadgeTexts
|
|
257
|
+
taskStyle: TaskStyle
|
|
258
|
+
/** How a lane's `↳` activity line is drawn: shimmering while the herd does, so its task rests, dim otherwise. */
|
|
259
|
+
activityStyle: TaskStyle
|
|
260
|
+
}
|
|
254
261
|
|
|
255
262
|
/** In front of every line of the lanes block under its head. */
|
|
256
263
|
const LANE_INDENT = ' '
|
|
@@ -782,6 +789,14 @@ const detailPlacement = (
|
|
|
782
789
|
return { beside, room: beside ? besideRoom : width - visibleLength(indent) }
|
|
783
790
|
}
|
|
784
791
|
|
|
792
|
+
/**
|
|
793
|
+
* Whether the whole head and a detail still stand on one line with `right`. A lane that rests has no bar, only the
|
|
794
|
+
* padding that aligns its badges with the others; `laneLines` gives that back before it cuts the name.
|
|
795
|
+
*/
|
|
796
|
+
const detailFitsBeside = (head: string, right: string, width: number, indent: string): boolean =>
|
|
797
|
+
visibleLength(head) + COLUMN_SEPARATOR.length + visibleLength(right) < width &&
|
|
798
|
+
detailPlacement(head, right, width, indent).beside
|
|
799
|
+
|
|
785
800
|
/** The summary the snapshot read for the lane, or the one its own steps give. */
|
|
786
801
|
const summaryOf = (view: LaneView): LaneSummary =>
|
|
787
802
|
view.summary ?? laneSummary({ progress: view.progress, phases: {}, activity: {} }, 0)
|
|
@@ -805,16 +820,23 @@ const laneRight = (view: LaneView, tallyWidth: number): string => {
|
|
|
805
820
|
/** The cells of the phase count column, 0 when the pane leaves it out. */
|
|
806
821
|
const tallyWidthOf = ({ phases, tally }: LaneColumns): number => (phases ? tally : 0)
|
|
807
822
|
|
|
808
|
-
/**
|
|
809
|
-
const
|
|
823
|
+
/** The marks in the column of `columns.marks`, `padOf(right)` cells before the lane's own context and bar. */
|
|
824
|
+
const marksBeforeRight = (view: LaneView, columns: LaneColumns, padOf: (right: string) => number): string => {
|
|
810
825
|
const right = laneRight(view, tallyWidthOf(columns))
|
|
811
826
|
const marks = laneMarks(view)
|
|
812
827
|
if (!marks) return right
|
|
813
828
|
if (columns.right === 0) return padVisible(marks, columns.marks)
|
|
814
|
-
|
|
815
|
-
return `${padVisible(marks, columns.marks)}${COLUMN_SEPARATOR}${pad}${right}`
|
|
829
|
+
return `${padVisible(marks, columns.marks)}${COLUMN_SEPARATOR}${' '.repeat(padOf(right))}${right}`
|
|
816
830
|
}
|
|
817
831
|
|
|
832
|
+
/** A folded lane's marks right before context and bar, padded to the widest right column of the pane. */
|
|
833
|
+
const markedRight = (view: LaneView, columns: LaneColumns): string =>
|
|
834
|
+
marksBeforeRight(view, columns, (right) => Math.max(columns.right - visibleLength(right), 0))
|
|
835
|
+
|
|
836
|
+
/** `markedRight` without the padding a lane without a bar only has to align its marks with the other lanes'. */
|
|
837
|
+
const markedRightTight = (view: LaneView, columns: LaneColumns): string =>
|
|
838
|
+
marksBeforeRight(view, columns, (right) => (right ? Math.max(columns.right - visibleLength(right), 0) : 0))
|
|
839
|
+
|
|
818
840
|
/** The task row below a folded lane's phase row, under the lane's name; none while the lane reports no task. */
|
|
819
841
|
const foldedTaskRow = (view: LaneView, indent: string, width: number, style: TaskStyle): string[] => {
|
|
820
842
|
const phase = headPhase(view)
|
|
@@ -839,8 +861,9 @@ const laneLines = (
|
|
|
839
861
|
const nameStyle = restsFolded(view, open) ? paint.dim : paint.bold
|
|
840
862
|
const cells = padVisible(nameStyle(fit(progress.lane.name, columns.name)), columns.name)
|
|
841
863
|
const head = `${paint.dim(chevronOf(open))} ${symbolCell(status.agent)} ${cells}`
|
|
842
|
-
const right = open ? laneProgressColumn(view, tallyWidthOf(columns)) : markedRight(view, columns)
|
|
843
864
|
const nameIndent = ' '.repeat(visibleLength(head) - visibleLength(cells))
|
|
865
|
+
const aligned = open ? laneProgressColumn(view, tallyWidthOf(columns)) : markedRight(view, columns)
|
|
866
|
+
const right = open || detailFitsBeside(head, aligned, width, nameIndent) ? aligned : markedRightTight(view, columns)
|
|
844
867
|
const { room } = detailPlacement(head, right, width, nameIndent)
|
|
845
868
|
const lines = withDetail(head, laneDetail(view, open, reason, room), right, width, nameIndent)
|
|
846
869
|
return open ? lines : [...lines, ...foldedTaskRow(view, nameIndent, width, taskStyle)]
|
|
@@ -935,7 +958,7 @@ const activityFrame = (
|
|
|
935
958
|
key: string,
|
|
936
959
|
indent: string,
|
|
937
960
|
text: string,
|
|
938
|
-
{ state, width }: { state: Selection; width: number },
|
|
961
|
+
{ state, width, style }: { state: Selection; width: number; style: TaskStyle },
|
|
939
962
|
): Frame => {
|
|
940
963
|
const head = `${indent}${ACTIVITY_MARK} `
|
|
941
964
|
const cut = textWidth(`${head}${text}`) > width
|
|
@@ -945,44 +968,69 @@ const activityFrame = (
|
|
|
945
968
|
index === 0 ? `${head}${line}` : `${' '.repeat(textWidth(head))}${line}`,
|
|
946
969
|
)
|
|
947
970
|
: [fit(`${head}${text}`, width)]
|
|
948
|
-
const
|
|
949
|
-
return cut ? rowFrame(key,
|
|
971
|
+
const styled = lines.map(style)
|
|
972
|
+
return cut ? rowFrame(key, styled, [], { state, width }) : textFrame(styled)
|
|
950
973
|
}
|
|
951
974
|
|
|
952
975
|
/**
|
|
953
976
|
* The last action of a working lane's agent and of each of its running subagents, one dim line each, under the lane
|
|
954
|
-
* line in the column of its children. None while the lane does not work, or without a source for it.
|
|
977
|
+
* line in the column of its children. None while the lane does not work, or without a source for it. The first
|
|
978
|
+
* subagent line shimmers when nothing else of the lane does (`leadsSubagent`).
|
|
955
979
|
*/
|
|
956
980
|
const activityFrames = (
|
|
957
981
|
{ activity, status, progress }: LaneView,
|
|
958
|
-
|
|
982
|
+
{
|
|
983
|
+
activityStyle,
|
|
984
|
+
leadsSubagent,
|
|
985
|
+
...context
|
|
986
|
+
}: Pick<LanesContext, 'state' | 'width' | 'activityStyle'> & {
|
|
987
|
+
leadsSubagent: boolean
|
|
988
|
+
},
|
|
959
989
|
): Frame[] => {
|
|
960
990
|
if (!activity || status.agent !== AGENT_STATUS.working) return []
|
|
961
991
|
const lane = progress.lane.name
|
|
962
992
|
const indent = ' '.repeat(LANE_HEAD_CELLS)
|
|
993
|
+
const still = { ...context, style: paint.dim }
|
|
963
994
|
return [
|
|
964
|
-
...(activity.lane === null
|
|
965
|
-
|
|
995
|
+
...(activity.lane === null
|
|
996
|
+
? []
|
|
997
|
+
: [activityFrame(laneActivityKey(lane), indent, activity.lane, { ...context, style: activityStyle })]),
|
|
998
|
+
...activity.subagents.map(({ label, description, line }, index) =>
|
|
966
999
|
activityFrame(
|
|
967
1000
|
subagentActivityKey(lane, label),
|
|
968
1001
|
`${indent}${SUBAGENT_ACTIVITY_INDENT}`,
|
|
969
1002
|
`${ellipsize(description ?? label, SUBAGENT_NAME_CELLS)}: ${line}`,
|
|
970
|
-
context,
|
|
1003
|
+
leadsSubagent && index === 0 ? { ...context, style: activityStyle } : still,
|
|
971
1004
|
),
|
|
972
1005
|
),
|
|
973
1006
|
]
|
|
974
1007
|
}
|
|
975
1008
|
|
|
1009
|
+
/** True when the lane shows its own `↳` activity line: the one line of the lane that shimmers, its task rests. */
|
|
1010
|
+
const showsLaneActivity = ({ activity, status }: LaneView): boolean =>
|
|
1011
|
+
typeof activity?.lane === 'string' && status.agent === AGENT_STATUS.working
|
|
1012
|
+
|
|
1013
|
+
/** True when a task line of the lane shimmers: the pane shimmers its tasks and the lane reports one to show. */
|
|
1014
|
+
const showsShimmeringTask = (
|
|
1015
|
+
view: LaneView,
|
|
1016
|
+
{ width, taskStyle }: Pick<LanesContext, 'width' | 'taskStyle'>,
|
|
1017
|
+
): boolean => taskStyle === SHIMMERING_TASK && foldedTaskRow(view, '', width, taskStyle).length > 0
|
|
1018
|
+
|
|
1019
|
+
/** True when no line of the lane shimmers yet, so its first subagent line does: neither its activity nor its task. */
|
|
1020
|
+
const subagentLeads = (view: LaneView, context: Pick<LanesContext, 'width' | 'taskStyle'>): boolean =>
|
|
1021
|
+
!showsLaneActivity(view) && !showsShimmeringTask(view, context)
|
|
1022
|
+
|
|
976
1023
|
/**
|
|
977
1024
|
* The lane line is a row of its own; an open lane continues after a blank line with its session, links, pull requests
|
|
978
1025
|
* and phases (`openLaneFrame`), indented to the column of the lane's name.
|
|
979
1026
|
*/
|
|
980
1027
|
const laneBlock = (
|
|
981
1028
|
view: LaneView,
|
|
982
|
-
{ state, width, badgeTexts, taskStyle }: LanesContext,
|
|
1029
|
+
{ state, width, badgeTexts, taskStyle: lanesTaskStyle, activityStyle }: LanesContext,
|
|
983
1030
|
columns: LaneColumns,
|
|
984
1031
|
reason: LaneReason | null,
|
|
985
1032
|
): Frame => {
|
|
1033
|
+
const taskStyle = showsLaneActivity(view) ? STILL_TASK : lanesTaskStyle
|
|
986
1034
|
const context: LaneContext = {
|
|
987
1035
|
progress: view.progress,
|
|
988
1036
|
state,
|
|
@@ -997,7 +1045,8 @@ const laneBlock = (
|
|
|
997
1045
|
const open = isLaneOpen(state, view.progress.lane.name, view.status.agent)
|
|
998
1046
|
const lines = laneLines(view, open, { width, taskStyle }, columns, reason)
|
|
999
1047
|
const lane = rowFrame(laneKey(view.progress.lane.name), lines, [], context)
|
|
1000
|
-
const
|
|
1048
|
+
const leadsSubagent = subagentLeads(view, { width, taskStyle: lanesTaskStyle })
|
|
1049
|
+
const row = joinFrames([lane, ...activityFrames(view, { state, width, activityStyle, leadsSubagent })])
|
|
1001
1050
|
if (!open) return row
|
|
1002
1051
|
const inner = narrower(context, LANE_HEAD_CELLS)
|
|
1003
1052
|
const head = {
|
|
@@ -1131,13 +1180,9 @@ const blockBar = (): string => ' '
|
|
|
1131
1180
|
* above the block; `▸ <name> · <n> <balance>` while folded. The count (in `countPaint` while it is more than 0) is
|
|
1132
1181
|
* left out for a block without one; the balance comes painted.
|
|
1133
1182
|
*/
|
|
1134
|
-
const blockHead = (
|
|
1135
|
-
{ name, count, balance, countPaint, headMark }: BlockHeadParts,
|
|
1136
|
-
open: boolean,
|
|
1137
|
-
width: number,
|
|
1138
|
-
): string => {
|
|
1183
|
+
const blockHead = ({ name, count, balance, countPaint }: BlockHeadParts, open: boolean, width: number): string => {
|
|
1139
1184
|
const counted = count === null ? '' : ` ${ITEM_SEPARATOR} ${count > 0 ? countPaint(String(count)) : count}`
|
|
1140
|
-
const lead = `${paint.dim(
|
|
1185
|
+
const lead = `${paint.dim(chevronOf(open))} ${name}${counted}${balance ? ` ${balance}` : ''}`
|
|
1141
1186
|
if (!open) return fit(lead, width)
|
|
1142
1187
|
const cut = fit(lead, Math.max(width - 2, 1))
|
|
1143
1188
|
return `${cut} ${paint.dim(RULE.repeat(Math.max(width - visibleLength(cut) - 1, 0)))}`
|
|
@@ -1334,9 +1379,9 @@ const entryRowShares = (counts: readonly number[], budget: number): number[] =>
|
|
|
1334
1379
|
type EntryBlock = { source: HerdSource; now: number; share: number }
|
|
1335
1380
|
|
|
1336
1381
|
/**
|
|
1337
|
-
* The block of a source that shows its entries:
|
|
1338
|
-
*
|
|
1339
|
-
*
|
|
1382
|
+
* The block of a source that shows its entries: open at the start, headed by its label (else its name) and the number
|
|
1383
|
+
* of entries, folded and opened again by its head as in the other blocks, its `share` of entries and `+<n>` for the
|
|
1384
|
+
* rest, which opens all of them and folds them again. An entry with an address opens it.
|
|
1340
1385
|
*/
|
|
1341
1386
|
const entryBlockLines = ({ source, now, share }: EntryBlock, state: Selection, width: number): BlockLines => {
|
|
1342
1387
|
const moreKey = sourceMoreKey(source.name)
|
|
@@ -1344,7 +1389,7 @@ const entryBlockLines = ({ source, now, share }: EntryBlock, state: Selection, w
|
|
|
1344
1389
|
const hidden = source.entries.length - share
|
|
1345
1390
|
const block: TopBlock = {
|
|
1346
1391
|
name: source.label ?? source.name,
|
|
1347
|
-
count:
|
|
1392
|
+
count: source.entries.length,
|
|
1348
1393
|
balance: '',
|
|
1349
1394
|
markPaint: paint.dim,
|
|
1350
1395
|
focused: (key) => isFocused(state, key),
|
|
@@ -1354,10 +1399,11 @@ const entryBlockLines = ({ source, now, share }: EntryBlock, state: Selection, w
|
|
|
1354
1399
|
hidden,
|
|
1355
1400
|
expanded,
|
|
1356
1401
|
rows: allBlockRows(entryBlockItems(source, now, expanded ? source.entries.length : share), new Set(), width),
|
|
1357
|
-
headMark: ' ',
|
|
1358
1402
|
}
|
|
1359
|
-
const
|
|
1360
|
-
return
|
|
1403
|
+
const headKey = entryBlockHeadKey(source.name)
|
|
1404
|
+
return isEntryBlockFolded(state, source.name)
|
|
1405
|
+
? foldedBlockLines(block, headKey, width)
|
|
1406
|
+
: openBlockLines(block, headKey, width)
|
|
1361
1407
|
}
|
|
1362
1408
|
|
|
1363
1409
|
const entryBlocksOf = (sourceBlock: SourceBlock | undefined, state: Selection, width: number): BlockLines[] => {
|
|
@@ -1533,6 +1579,7 @@ export const renderScreen = (
|
|
|
1533
1579
|
width: number,
|
|
1534
1580
|
): Screen => {
|
|
1535
1581
|
const taskStyle = shimmer ? SHIMMERING_TASK : STILL_TASK
|
|
1582
|
+
const activityStyle = shimmer ? SHIMMERING_TASK : paint.dim
|
|
1536
1583
|
const { active, waiting, noAgent } = partitionLanes(lanes)
|
|
1537
1584
|
const badgeTexts = BADGE_TEXTS
|
|
1538
1585
|
const turnRowsOf = yourTurn ? [...yourTurn.turn.now, ...yourTurn.turn.planned] : []
|
|
@@ -1547,7 +1594,9 @@ export const renderScreen = (
|
|
|
1547
1594
|
const stack = stackBlocks([turn, ...entryBlocks, listed], above.length)
|
|
1548
1595
|
const sourcesSlot = 1 + entryBlocks.length
|
|
1549
1596
|
const sections = [
|
|
1550
|
-
...(active.length > 0
|
|
1597
|
+
...(active.length > 0
|
|
1598
|
+
? [lanesFrame(active, turnRowsOf, { state, width, badgeTexts, taskStyle, activityStyle })]
|
|
1599
|
+
: []),
|
|
1551
1600
|
...(waiting.length > 0 ? [waitingFrame(waiting, state, width)] : []),
|
|
1552
1601
|
textFrame(parkedBlock(noAgent, width)),
|
|
1553
1602
|
...(planned.length > 0 ? [backlogFrame(planned, state, width)] : []),
|
package/src/plans/live.ts
CHANGED
|
@@ -47,10 +47,7 @@ import {
|
|
|
47
47
|
parseMouse,
|
|
48
48
|
splitKeys,
|
|
49
49
|
syncLaneFolds,
|
|
50
|
-
TOP_BLOCK,
|
|
51
|
-
type TopBlockId,
|
|
52
50
|
toggledExpansion,
|
|
53
|
-
topBlockHeadKey,
|
|
54
51
|
type View,
|
|
55
52
|
windowTop,
|
|
56
53
|
withoutStaleMotions,
|
|
@@ -527,18 +524,18 @@ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
|
|
|
527
524
|
}
|
|
528
525
|
|
|
529
526
|
/**
|
|
530
|
-
* A plain click on a block on top of the herd, matched against `hit` as it was drawn: the head
|
|
531
|
-
* opens
|
|
527
|
+
* A plain click on a block on top of the herd, matched against `hit` as it was drawn: the head folds or
|
|
528
|
+
* opens its block, a
|
|
532
529
|
* cut-off row expands, a file or Artifact opens, a button asks the coordinator or closes the row; the failure or the
|
|
533
530
|
* result shows in the footer.
|
|
534
531
|
*/
|
|
535
|
-
const clickBlock = (live: Live,
|
|
532
|
+
const clickBlock = (live: Live, hit: BlockHit | null, input: string): boolean => {
|
|
536
533
|
const mouse = parseMouse(input)
|
|
537
534
|
if (!mouse || !hit) return false
|
|
538
|
-
if (
|
|
535
|
+
if (hit.headKey && blockHeadClick(hit.firstLine - 1, mouse)) {
|
|
539
536
|
live.view = {
|
|
540
537
|
...live.view,
|
|
541
|
-
selection: applyClick(live.view.selection,
|
|
538
|
+
selection: applyClick(live.view.selection, hit.headKey),
|
|
542
539
|
}
|
|
543
540
|
return true
|
|
544
541
|
}
|
|
@@ -588,7 +585,6 @@ const handleInput = (live: Live, input: string) => {
|
|
|
588
585
|
hover(live, screen, mouse)
|
|
589
586
|
return
|
|
590
587
|
}
|
|
591
|
-
live.notice = null
|
|
592
588
|
const settled = settledSelection(live.view.selection, screen)
|
|
593
589
|
live.view = { ...live.view, selection: mouse ? settled : withoutHover(settled) }
|
|
594
590
|
if (activateBlockRow(live, screen, input)) {
|
|
@@ -600,9 +596,9 @@ const handleInput = (live: Live, input: string) => {
|
|
|
600
596
|
return
|
|
601
597
|
}
|
|
602
598
|
if (
|
|
603
|
-
clickBlock(live,
|
|
604
|
-
screen.entryBlocks.some((hit) => clickBlock(live,
|
|
605
|
-
clickBlock(live,
|
|
599
|
+
clickBlock(live, screen.yourTurnBlock, input) ||
|
|
600
|
+
screen.entryBlocks.some((hit) => clickBlock(live, hit, input)) ||
|
|
601
|
+
clickBlock(live, screen.sourceBlock, input)
|
|
606
602
|
) {
|
|
607
603
|
redraw(live)
|
|
608
604
|
return
|
package/src/plans/plan-panes.ts
CHANGED
|
@@ -35,6 +35,14 @@ export type PlanPaneResult = { actions: PaneAction[]; notes: string[] }
|
|
|
35
35
|
|
|
36
36
|
type ListedPane = { pane_id: string; tab_id: string; cwd: string; agent?: unknown; label?: unknown }
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The exit codes of `herd --ensure`: `noCoordinator` when the coordinator's pane is missing or ambiguous, so a
|
|
40
|
+
* caller that runs it after a restart knows to wait for the coordinator's session; 1 stays the error of the CLI.
|
|
41
|
+
*/
|
|
42
|
+
export const ENSURE_EXIT = { done: 0, noCoordinator: 3 } as const
|
|
43
|
+
|
|
44
|
+
export type EnsureExit = (typeof ENSURE_EXIT)[keyof typeof ENSURE_EXIT]
|
|
45
|
+
|
|
38
46
|
const isListedPane = (value: unknown): value is ListedPane => {
|
|
39
47
|
if (typeof value !== 'object' || value === null) return false
|
|
40
48
|
const candidate = value as Record<string, unknown>
|
|
@@ -124,9 +132,13 @@ export const isIdleShell = ({ agent, foreground }: PaneState): boolean =>
|
|
|
124
132
|
const isStartingShell = ({ agent, foreground }: PaneState): boolean =>
|
|
125
133
|
!agent && (foreground.length === 0 || SHELLS.has(programOf(foreground[0])))
|
|
126
134
|
|
|
135
|
+
const hasHerdBeside = (panes: readonly PaneState[], agent: PaneState): boolean =>
|
|
136
|
+
panes.some((each) => each.tab === agent.tab && each.label === herdLabel(null))
|
|
137
|
+
|
|
127
138
|
/**
|
|
128
139
|
* The coordinator's pane: the agent herdr lists under the coordinator's name, else the calling pane when an agent
|
|
129
|
-
* in the main checkout runs there, else the only agent in the main checkout
|
|
140
|
+
* in the main checkout runs there, else the only agent in the main checkout, else the only one there whose tab holds
|
|
141
|
+
* a pane labelled `herd` (herdr keeps labels across a restart, agent names not always). Otherwise none.
|
|
130
142
|
*/
|
|
131
143
|
export const coordinatorPaneOf = (
|
|
132
144
|
panes: readonly PaneState[],
|
|
@@ -136,15 +148,20 @@ export const coordinatorPaneOf = (
|
|
|
136
148
|
if (hints.named) return hints.named
|
|
137
149
|
const inRoot = panes.filter((each) => each.agent && each.cwd === root)
|
|
138
150
|
if (hints.caller && inRoot.some((each) => each.pane === hints.caller)) return hints.caller
|
|
139
|
-
|
|
151
|
+
const candidates = inRoot.length === 1 ? inRoot : inRoot.filter((agent) => hasHerdBeside(panes, agent))
|
|
152
|
+
return candidates.length === 1 ? (candidates[0]?.pane ?? null) : null
|
|
140
153
|
}
|
|
141
154
|
|
|
142
|
-
/**
|
|
155
|
+
/** The exit code of `herd --ensure` for the coordinator pane it found among `panes`, or none. */
|
|
156
|
+
export const ensureExitOf = (panes: readonly PaneState[], coordinator: string | null): EnsureExit =>
|
|
157
|
+
panes.some((each) => each.pane === coordinator) ? ENSURE_EXIT.done : ENSURE_EXIT.noCoordinator
|
|
158
|
+
|
|
159
|
+
/** Why no coordinator pane was found: no agent in the main checkout, or several, none named and none alone beside a herd pane. */
|
|
143
160
|
export const missingCoordinatorNote = (panes: readonly PaneState[], root: string, name: string): string => {
|
|
144
161
|
const inRoot = panes.filter((each) => each.agent && each.cwd === root).length
|
|
145
162
|
return inRoot === 0
|
|
146
163
|
? 'coordinator: no agent in the main checkout'
|
|
147
|
-
: `coordinator: ${inRoot} agents in the main checkout
|
|
164
|
+
: `coordinator: ${inRoot} agents in the main checkout, none named ${name} and none alone beside a herd pane; run herd --ensure in its pane, or herdr agent rename <pane> ${name}`
|
|
148
165
|
}
|
|
149
166
|
|
|
150
167
|
const quoted = (arg: string): string => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`)
|
package/src/plans/selection.ts
CHANGED
|
@@ -27,6 +27,8 @@ export type Selection = {
|
|
|
27
27
|
laneFolds: ReadonlyMap<string, LaneFold>
|
|
28
28
|
/** The top blocks the user opened; every other one is folded, at the start of the herd all of them. */
|
|
29
29
|
openBlocks?: ReadonlySet<TopBlockId>
|
|
30
|
+
/** The sources whose block of entries the user folded; absent or without the source while it is open, as at the start. */
|
|
31
|
+
foldedEntryBlocks?: ReadonlySet<string>
|
|
30
32
|
/** True once the user folded the lanes block, which is open at the start; absent or false while it is open. */
|
|
31
33
|
lanesFolded?: boolean
|
|
32
34
|
}
|
|
@@ -110,6 +112,26 @@ export const topBlockHeadKey = (block: TopBlockId): string => `${TOP_HEAD_KEY_PR
|
|
|
110
112
|
export const topBlockOfKey = (key: string): TopBlockId | null =>
|
|
111
113
|
Object.values(TOP_BLOCK).find((block) => topBlockHeadKey(block) === key) ?? null
|
|
112
114
|
|
|
115
|
+
const ENTRY_HEAD_KEY_PREFIX = `${TOP_HEAD_KEY_PREFIX}entries>`
|
|
116
|
+
|
|
117
|
+
/** The key of the head of the block of entries of a source, which the arrows reach like every other clickable row. */
|
|
118
|
+
export const entryBlockHeadKey = (source: string): string => `${ENTRY_HEAD_KEY_PREFIX}${source}`
|
|
119
|
+
|
|
120
|
+
/** The source whose block of entries a head key names, null for any other key. */
|
|
121
|
+
export const entryBlockOfKey = (key: string): string | null =>
|
|
122
|
+
key.startsWith(ENTRY_HEAD_KEY_PREFIX) ? key.slice(ENTRY_HEAD_KEY_PREFIX.length) : null
|
|
123
|
+
|
|
124
|
+
/** Whether the block of entries of `source` is drawn folded: it is open at the start, until the user folds it. */
|
|
125
|
+
export const isEntryBlockFolded = ({ foldedEntryBlocks }: Selection, source: string): boolean =>
|
|
126
|
+
foldedEntryBlocks?.has(source) === true
|
|
127
|
+
|
|
128
|
+
const withEntryBlockOpen = (selection: Selection, source: string, open: boolean): Selection => {
|
|
129
|
+
const folded = new Set(selection.foldedEntryBlocks)
|
|
130
|
+
if (open) folded.delete(source)
|
|
131
|
+
else folded.add(source)
|
|
132
|
+
return { ...selection, foldedEntryBlocks: folded }
|
|
133
|
+
}
|
|
134
|
+
|
|
113
135
|
/** Whether the lanes block is drawn open: it is at the start of the herd, until the user folds it. */
|
|
114
136
|
export const isLanesOpen = ({ lanesFolded }: Selection): boolean => !lanesFolded
|
|
115
137
|
|
|
@@ -274,6 +296,13 @@ const foldedHead = (selection: Selection, key: string, next: (open: boolean) =>
|
|
|
274
296
|
const lane = laneOfKey(key)
|
|
275
297
|
if (lane !== null) return { ...foldLane(selection, lane, next), selected: key }
|
|
276
298
|
if (key === LANES_KEY) return { ...withLanesOpen(selection, next(isLanesOpen(selection))), selected: key }
|
|
299
|
+
const entrySource = entryBlockOfKey(key)
|
|
300
|
+
if (entrySource !== null) {
|
|
301
|
+
return {
|
|
302
|
+
...withEntryBlockOpen(selection, entrySource, next(!isEntryBlockFolded(selection, entrySource))),
|
|
303
|
+
selected: key,
|
|
304
|
+
}
|
|
305
|
+
}
|
|
277
306
|
const block = topBlockOfKey(key)
|
|
278
307
|
if (block === null) return null
|
|
279
308
|
return { ...withBlockOpen(selection, block, next(!isBlockFolded(selection, block))), selected: key }
|
package/src/plans/settings.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { type JackherdConfig, parseJackherdConfig } from '../config'
|
|
|
4
4
|
import { PROJECT_CONFIG_PATH } from '../config/location'
|
|
5
5
|
import { readConfig } from '../config/read-config'
|
|
6
6
|
import type { ToneName } from '../config/tones'
|
|
7
|
+
import { DEFAULT_MIN_FREE_GB } from '../lane/space'
|
|
7
8
|
import type { TicketSource } from '../watch/plan-badges'
|
|
8
9
|
import type { SourceShow } from '../watch/sources'
|
|
9
10
|
import { type WatchedSource, watchedSourceOf } from '../watch/watched-source'
|
|
@@ -15,6 +16,8 @@ export type PlansSettings = Pick<
|
|
|
15
16
|
'planDirectory' | 'board' | 'worktreeDirectory' | 'baseBranch' | 'planLanguage' | 'responseLanguage' | 'skillPrefix'
|
|
16
17
|
> & {
|
|
17
18
|
adapter: JackherdConfig['terminal']['adapter']
|
|
19
|
+
/** Free gigabytes a new lane needs on the disk (`lanes.minFreeGb`). */
|
|
20
|
+
minFreeGb: number
|
|
18
21
|
fetchIntervalMinutes: number
|
|
19
22
|
/** Whether the running task of a folded lane shimmers; `NO_COLOR` keeps it still whatever this says. */
|
|
20
23
|
shimmer: boolean
|
|
@@ -58,6 +61,7 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
|
|
|
58
61
|
responseLanguage: config.responseLanguage,
|
|
59
62
|
skillPrefix: config.skillPrefix,
|
|
60
63
|
adapter: config.terminal.adapter,
|
|
64
|
+
minFreeGb: config.lanes.minFreeGb ?? DEFAULT_MIN_FREE_GB,
|
|
61
65
|
fetchIntervalMinutes: config.plans.fetchIntervalMinutes,
|
|
62
66
|
shimmer: config.plans.shimmer,
|
|
63
67
|
linkKinds: config.plans.linkKinds,
|