jackherd 1.15.2 → 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 +34 -0
- package/INSTALL.md +2 -1
- package/README.md +26 -22
- 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 +438 -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 +18 -11
- package/kit/workflow/skills/implement/SKILL.md +1 -1
- package/kit/workflow/skills/orchestrate/SKILL.md +124 -300
- 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 +3 -1
- package/recipes/README.md +4 -2
- package/recipes/google-calendar.md +30 -0
- package/recipes/jira-cli.md +1 -1
- package/recipes/sources/google-calendar-today.ts +38 -0
- package/recipes/sources/google-calendar.ts +368 -0
- 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 +17 -3
- 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 +81 -23
- 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 +191 -25
- package/src/plans/live.ts +57 -14
- package/src/plans/plan-panes.ts +21 -4
- package/src/plans/row-actions.ts +17 -10
- package/src/plans/selection.ts +29 -0
- package/src/plans/settings.ts +4 -0
- package/src/plans/snapshot.ts +8 -3
- package/src/plans/source-rows.ts +35 -2
- 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 +68 -12
- 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-dry-run.ts +1 -1
- 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 +15 -4
- package/src/watch/waiting-line.ts +4 -3
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,
|
|
@@ -98,7 +100,17 @@ import {
|
|
|
98
100
|
import type { SentChip } from './sent-files'
|
|
99
101
|
import { shownWithMore } from './shown-with-more'
|
|
100
102
|
import { askedCheckOf } from './source-asked'
|
|
101
|
-
import {
|
|
103
|
+
import {
|
|
104
|
+
entryBlockItems,
|
|
105
|
+
entryBlockSources,
|
|
106
|
+
type HerdSource,
|
|
107
|
+
type SourceBlock,
|
|
108
|
+
type SourceSummary,
|
|
109
|
+
sourceItems,
|
|
110
|
+
sourceMoreKey,
|
|
111
|
+
sourceSummaries,
|
|
112
|
+
statusOnly,
|
|
113
|
+
} from './source-rows'
|
|
102
114
|
import { chevronOf, LESS_MARK, NO_AGENT_SYMBOL, statusSymbol, statusWord, TURN_MARK } from './symbols'
|
|
103
115
|
import { BADGE_TEXTS, type BadgeTexts, checksText, foldedChecksText, laneMarkText, TEXTS } from './texts'
|
|
104
116
|
import { paintOfRole, paintOfTone, toneOfRole } from './tones'
|
|
@@ -177,14 +189,16 @@ export type OverviewView = {
|
|
|
177
189
|
|
|
178
190
|
/**
|
|
179
191
|
* The drawn screen: a fixed header and footer around a body that scrolls; body lines and targets count from the body's
|
|
180
|
-
* top. `yourTurnBlock` and `sourceBlock` are what a click on these blocks is matched against, null
|
|
181
|
-
* not drawn.
|
|
192
|
+
* top. `yourTurnBlock`, `entryBlocks` and `sourceBlock` are what a click on these blocks is matched against, null (or
|
|
193
|
+
* no entry block) while the block is not drawn.
|
|
182
194
|
*/
|
|
183
195
|
export type Screen = {
|
|
184
196
|
header: string[]
|
|
185
197
|
body: Frame
|
|
186
198
|
footer: string[]
|
|
187
199
|
yourTurnBlock: BlockHit | null
|
|
200
|
+
/** The blocks of the sources that show their entries, in their order under "Your turn". */
|
|
201
|
+
entryBlocks: BlockHit[]
|
|
188
202
|
sourceBlock: BlockHit | null
|
|
189
203
|
/** Every clickable row by the screen line it starts on (header lines first, then the body unscrolled): what the mouse and the keys land on. */
|
|
190
204
|
focusHits: FocusHit[]
|
|
@@ -236,7 +250,14 @@ type BlockHeadParts = Pick<TopBlock, 'name' | 'count' | 'balance' | 'countPaint'
|
|
|
236
250
|
type BlockStack = { lines: string[]; hits: (BlockHit | null)[] }
|
|
237
251
|
|
|
238
252
|
/** What every lane of the lanes block is drawn with. */
|
|
239
|
-
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
|
+
}
|
|
240
261
|
|
|
241
262
|
/** In front of every line of the lanes block under its head. */
|
|
242
263
|
const LANE_INDENT = ' '
|
|
@@ -768,6 +789,14 @@ const detailPlacement = (
|
|
|
768
789
|
return { beside, room: beside ? besideRoom : width - visibleLength(indent) }
|
|
769
790
|
}
|
|
770
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
|
+
|
|
771
800
|
/** The summary the snapshot read for the lane, or the one its own steps give. */
|
|
772
801
|
const summaryOf = (view: LaneView): LaneSummary =>
|
|
773
802
|
view.summary ?? laneSummary({ progress: view.progress, phases: {}, activity: {} }, 0)
|
|
@@ -791,16 +820,23 @@ const laneRight = (view: LaneView, tallyWidth: number): string => {
|
|
|
791
820
|
/** The cells of the phase count column, 0 when the pane leaves it out. */
|
|
792
821
|
const tallyWidthOf = ({ phases, tally }: LaneColumns): number => (phases ? tally : 0)
|
|
793
822
|
|
|
794
|
-
/**
|
|
795
|
-
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 => {
|
|
796
825
|
const right = laneRight(view, tallyWidthOf(columns))
|
|
797
826
|
const marks = laneMarks(view)
|
|
798
827
|
if (!marks) return right
|
|
799
828
|
if (columns.right === 0) return padVisible(marks, columns.marks)
|
|
800
|
-
|
|
801
|
-
return `${padVisible(marks, columns.marks)}${COLUMN_SEPARATOR}${pad}${right}`
|
|
829
|
+
return `${padVisible(marks, columns.marks)}${COLUMN_SEPARATOR}${' '.repeat(padOf(right))}${right}`
|
|
802
830
|
}
|
|
803
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
|
+
|
|
804
840
|
/** The task row below a folded lane's phase row, under the lane's name; none while the lane reports no task. */
|
|
805
841
|
const foldedTaskRow = (view: LaneView, indent: string, width: number, style: TaskStyle): string[] => {
|
|
806
842
|
const phase = headPhase(view)
|
|
@@ -825,8 +861,9 @@ const laneLines = (
|
|
|
825
861
|
const nameStyle = restsFolded(view, open) ? paint.dim : paint.bold
|
|
826
862
|
const cells = padVisible(nameStyle(fit(progress.lane.name, columns.name)), columns.name)
|
|
827
863
|
const head = `${paint.dim(chevronOf(open))} ${symbolCell(status.agent)} ${cells}`
|
|
828
|
-
const right = open ? laneProgressColumn(view, tallyWidthOf(columns)) : markedRight(view, columns)
|
|
829
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)
|
|
830
867
|
const { room } = detailPlacement(head, right, width, nameIndent)
|
|
831
868
|
const lines = withDetail(head, laneDetail(view, open, reason, room), right, width, nameIndent)
|
|
832
869
|
return open ? lines : [...lines, ...foldedTaskRow(view, nameIndent, width, taskStyle)]
|
|
@@ -903,39 +940,97 @@ const SUBAGENT_ACTIVITY_INDENT = ' '
|
|
|
903
940
|
/** How long a subagent's name may grow before its action, so a narrow pane cuts the name and keeps the action. */
|
|
904
941
|
const SUBAGENT_NAME_CELLS = 28
|
|
905
942
|
|
|
906
|
-
/**
|
|
907
|
-
const
|
|
908
|
-
|
|
943
|
+
/** The key of a lane's activity line, flipped in `expanded`. */
|
|
944
|
+
const laneActivityKey = (lane: string): string => `activity>${lane}`
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* The key of a subagent's activity line, by its label: a subagent that moves up when another ends keeps its own state,
|
|
948
|
+
* and the key is no child of the lane's, so folding one leaves the other.
|
|
949
|
+
*/
|
|
950
|
+
const subagentActivityKey = (lane: string, label: string): string => `subagent-activity>${lane}>${label}`
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* One dim line of activity behind `indent`, cut to the pane on plain text before the paint. A cut line is a row: a
|
|
954
|
+
* click unfolds it over several lines, the text hanging under its first word, and a second click folds it; a line
|
|
955
|
+
* that fits is no click target.
|
|
956
|
+
*/
|
|
957
|
+
const activityFrame = (
|
|
958
|
+
key: string,
|
|
959
|
+
indent: string,
|
|
960
|
+
text: string,
|
|
961
|
+
{ state, width, style }: { state: Selection; width: number; style: TaskStyle },
|
|
962
|
+
): Frame => {
|
|
963
|
+
const head = `${indent}${ACTIVITY_MARK} `
|
|
964
|
+
const cut = textWidth(`${head}${text}`) > width
|
|
965
|
+
const lines =
|
|
966
|
+
cut && state.expanded.has(key)
|
|
967
|
+
? wrap(text, Math.max(width - textWidth(head), 1)).map((line, index) =>
|
|
968
|
+
index === 0 ? `${head}${line}` : `${' '.repeat(textWidth(head))}${line}`,
|
|
969
|
+
)
|
|
970
|
+
: [fit(`${head}${text}`, width)]
|
|
971
|
+
const styled = lines.map(style)
|
|
972
|
+
return cut ? rowFrame(key, styled, [], { state, width }) : textFrame(styled)
|
|
973
|
+
}
|
|
909
974
|
|
|
910
975
|
/**
|
|
911
976
|
* The last action of a working lane's agent and of each of its running subagents, one dim line each, under the lane
|
|
912
|
-
* 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`).
|
|
913
979
|
*/
|
|
914
|
-
const
|
|
980
|
+
const activityFrames = (
|
|
981
|
+
{ activity, status, progress }: LaneView,
|
|
982
|
+
{
|
|
983
|
+
activityStyle,
|
|
984
|
+
leadsSubagent,
|
|
985
|
+
...context
|
|
986
|
+
}: Pick<LanesContext, 'state' | 'width' | 'activityStyle'> & {
|
|
987
|
+
leadsSubagent: boolean
|
|
988
|
+
},
|
|
989
|
+
): Frame[] => {
|
|
915
990
|
if (!activity || status.agent !== AGENT_STATUS.working) return []
|
|
991
|
+
const lane = progress.lane.name
|
|
916
992
|
const indent = ' '.repeat(LANE_HEAD_CELLS)
|
|
993
|
+
const still = { ...context, style: paint.dim }
|
|
917
994
|
return [
|
|
918
|
-
...(activity.lane === null
|
|
919
|
-
|
|
920
|
-
|
|
995
|
+
...(activity.lane === null
|
|
996
|
+
? []
|
|
997
|
+
: [activityFrame(laneActivityKey(lane), indent, activity.lane, { ...context, style: activityStyle })]),
|
|
998
|
+
...activity.subagents.map(({ label, description, line }, index) =>
|
|
999
|
+
activityFrame(
|
|
1000
|
+
subagentActivityKey(lane, label),
|
|
921
1001
|
`${indent}${SUBAGENT_ACTIVITY_INDENT}`,
|
|
922
1002
|
`${ellipsize(description ?? label, SUBAGENT_NAME_CELLS)}: ${line}`,
|
|
923
|
-
|
|
1003
|
+
leadsSubagent && index === 0 ? { ...context, style: activityStyle } : still,
|
|
924
1004
|
),
|
|
925
1005
|
),
|
|
926
1006
|
]
|
|
927
1007
|
}
|
|
928
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
|
+
|
|
929
1023
|
/**
|
|
930
1024
|
* The lane line is a row of its own; an open lane continues after a blank line with its session, links, pull requests
|
|
931
1025
|
* and phases (`openLaneFrame`), indented to the column of the lane's name.
|
|
932
1026
|
*/
|
|
933
1027
|
const laneBlock = (
|
|
934
1028
|
view: LaneView,
|
|
935
|
-
{ state, width, badgeTexts, taskStyle }: LanesContext,
|
|
1029
|
+
{ state, width, badgeTexts, taskStyle: lanesTaskStyle, activityStyle }: LanesContext,
|
|
936
1030
|
columns: LaneColumns,
|
|
937
1031
|
reason: LaneReason | null,
|
|
938
1032
|
): Frame => {
|
|
1033
|
+
const taskStyle = showsLaneActivity(view) ? STILL_TASK : lanesTaskStyle
|
|
939
1034
|
const context: LaneContext = {
|
|
940
1035
|
progress: view.progress,
|
|
941
1036
|
state,
|
|
@@ -950,8 +1045,8 @@ const laneBlock = (
|
|
|
950
1045
|
const open = isLaneOpen(state, view.progress.lane.name, view.status.agent)
|
|
951
1046
|
const lines = laneLines(view, open, { width, taskStyle }, columns, reason)
|
|
952
1047
|
const lane = rowFrame(laneKey(view.progress.lane.name), lines, [], context)
|
|
953
|
-
const
|
|
954
|
-
const row =
|
|
1048
|
+
const leadsSubagent = subagentLeads(view, { width, taskStyle: lanesTaskStyle })
|
|
1049
|
+
const row = joinFrames([lane, ...activityFrames(view, { state, width, activityStyle, leadsSubagent })])
|
|
955
1050
|
if (!open) return row
|
|
956
1051
|
const inner = narrower(context, LANE_HEAD_CELLS)
|
|
957
1052
|
const head = {
|
|
@@ -1226,11 +1321,12 @@ const sourceSummaryText = (summaries: readonly SourceSummary[]): string =>
|
|
|
1226
1321
|
* fails, and a source's entries under it once opened.
|
|
1227
1322
|
*/
|
|
1228
1323
|
const sourceLines = (
|
|
1229
|
-
{ sources, now }: SourceBlock,
|
|
1324
|
+
{ sources: listed, now }: SourceBlock,
|
|
1230
1325
|
awaiting: readonly WaitingItem[],
|
|
1231
1326
|
state: Selection,
|
|
1232
1327
|
width: number,
|
|
1233
1328
|
): BlockLines => {
|
|
1329
|
+
const sources = listed.map(statusOnly)
|
|
1234
1330
|
const shown = state.expanded.has(SOURCE_MORE_KEY) ? sources : sources.slice(0, SHOWN_BLOCK_ROWS)
|
|
1235
1331
|
const asked = askedCheckOf(awaiting)
|
|
1236
1332
|
const newTone = toneOfRole(TONE_ROLE.new)
|
|
@@ -1258,6 +1354,70 @@ const sourceLinesOf = (
|
|
|
1258
1354
|
): BlockLines | null =>
|
|
1259
1355
|
sourceBlock && sourceBlock.sources.length > 0 ? sourceLines(sourceBlock, awaiting, state, width) : null
|
|
1260
1356
|
|
|
1357
|
+
/**
|
|
1358
|
+
* How many entry rows all blocks of entries show together while folded: one calendar keeps the five of the other
|
|
1359
|
+
* blocks, a second one takes from the same five, so the head of the herd, which does not scroll, leaves the lanes room.
|
|
1360
|
+
*/
|
|
1361
|
+
const ENTRY_BLOCK_ROW_BUDGET = SHOWN_BLOCK_ROWS
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* The rows each block of entries shows while folded, in the order of the sources: the shared budget, with one row kept
|
|
1365
|
+
* for each block after it, and at least one row per block, so a block past the budget still shows its first entry; a
|
|
1366
|
+
* block with fewer entries than its share leaves the rest to the blocks after it. More blocks than the budget take one
|
|
1367
|
+
* row each, past the budget.
|
|
1368
|
+
*/
|
|
1369
|
+
const entryRowShares = (counts: readonly number[], budget: number): number[] => {
|
|
1370
|
+
let left = budget
|
|
1371
|
+
return counts.map((count, index) => {
|
|
1372
|
+
const share = Math.min(count, Math.max(left - (counts.length - index - 1), 1))
|
|
1373
|
+
left -= share
|
|
1374
|
+
return share
|
|
1375
|
+
})
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/** A source with a block of its own, the clock its times are counted against and the rows it shows while folded. */
|
|
1379
|
+
type EntryBlock = { source: HerdSource; now: number; share: number }
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
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.
|
|
1385
|
+
*/
|
|
1386
|
+
const entryBlockLines = ({ source, now, share }: EntryBlock, state: Selection, width: number): BlockLines => {
|
|
1387
|
+
const moreKey = sourceMoreKey(source.name)
|
|
1388
|
+
const expanded = state.expanded.has(moreKey)
|
|
1389
|
+
const hidden = source.entries.length - share
|
|
1390
|
+
const block: TopBlock = {
|
|
1391
|
+
name: source.label ?? source.name,
|
|
1392
|
+
count: source.entries.length,
|
|
1393
|
+
balance: '',
|
|
1394
|
+
markPaint: paint.dim,
|
|
1395
|
+
focused: (key) => isFocused(state, key),
|
|
1396
|
+
allBold: (key) => buttonsAllBold(state, key),
|
|
1397
|
+
countPaint: (text) => text,
|
|
1398
|
+
moreKey: hidden > 0 ? moreKey : null,
|
|
1399
|
+
hidden,
|
|
1400
|
+
expanded,
|
|
1401
|
+
rows: allBlockRows(entryBlockItems(source, now, expanded ? source.entries.length : share), new Set(), width),
|
|
1402
|
+
}
|
|
1403
|
+
const headKey = entryBlockHeadKey(source.name)
|
|
1404
|
+
return isEntryBlockFolded(state, source.name)
|
|
1405
|
+
? foldedBlockLines(block, headKey, width)
|
|
1406
|
+
: openBlockLines(block, headKey, width)
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
const entryBlocksOf = (sourceBlock: SourceBlock | undefined, state: Selection, width: number): BlockLines[] => {
|
|
1410
|
+
if (!sourceBlock) return []
|
|
1411
|
+
const sources = entryBlockSources(sourceBlock.sources)
|
|
1412
|
+
const shares = entryRowShares(
|
|
1413
|
+
sources.map(({ entries }) => entries.length),
|
|
1414
|
+
ENTRY_BLOCK_ROW_BUDGET,
|
|
1415
|
+
)
|
|
1416
|
+
return sources.map((source, index) =>
|
|
1417
|
+
entryBlockLines({ source, now: sourceBlock.now, share: shares[index] ?? 0 }, state, width),
|
|
1418
|
+
)
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1261
1421
|
/**
|
|
1262
1422
|
* The blocks one under the other, `offset` lines below the top of the screen, a blank line after each, so one stands
|
|
1263
1423
|
* between neighbours and one between the last and the lanes. An absent block (null) takes no line and has no hit.
|
|
@@ -1419,6 +1579,7 @@ export const renderScreen = (
|
|
|
1419
1579
|
width: number,
|
|
1420
1580
|
): Screen => {
|
|
1421
1581
|
const taskStyle = shimmer ? SHIMMERING_TASK : STILL_TASK
|
|
1582
|
+
const activityStyle = shimmer ? SHIMMERING_TASK : paint.dim
|
|
1422
1583
|
const { active, waiting, noAgent } = partitionLanes(lanes)
|
|
1423
1584
|
const badgeTexts = BADGE_TEXTS
|
|
1424
1585
|
const turnRowsOf = yourTurn ? [...yourTurn.turn.now, ...yourTurn.turn.planned] : []
|
|
@@ -1429,9 +1590,13 @@ export const renderScreen = (
|
|
|
1429
1590
|
const nowLines = unplaced.map((task) => nowLine(task, width, taskStyle))
|
|
1430
1591
|
const above = nowLines.length > 0 ? [titleLine, rule(width), ...nowLines] : [titleLine]
|
|
1431
1592
|
const listed = sourceLinesOf(sourceBlock, awaiting, state, width)
|
|
1432
|
-
const
|
|
1593
|
+
const entryBlocks = entryBlocksOf(sourceBlock, state, width)
|
|
1594
|
+
const stack = stackBlocks([turn, ...entryBlocks, listed], above.length)
|
|
1595
|
+
const sourcesSlot = 1 + entryBlocks.length
|
|
1433
1596
|
const sections = [
|
|
1434
|
-
...(active.length > 0
|
|
1597
|
+
...(active.length > 0
|
|
1598
|
+
? [lanesFrame(active, turnRowsOf, { state, width, badgeTexts, taskStyle, activityStyle })]
|
|
1599
|
+
: []),
|
|
1435
1600
|
...(waiting.length > 0 ? [waitingFrame(waiting, state, width)] : []),
|
|
1436
1601
|
textFrame(parkedBlock(noAgent, width)),
|
|
1437
1602
|
...(planned.length > 0 ? [backlogFrame(planned, state, width)] : []),
|
|
@@ -1448,7 +1613,8 @@ export const renderScreen = (
|
|
|
1448
1613
|
const body = { ...plainBody, lines: emphasisedAt(plainBody.lines, plainHeader.length, focusHits, state, width) }
|
|
1449
1614
|
return {
|
|
1450
1615
|
yourTurnBlock: stack.hits[0],
|
|
1451
|
-
|
|
1616
|
+
entryBlocks: stack.hits.slice(1, sourcesSlot).filter((hit) => hit !== null),
|
|
1617
|
+
sourceBlock: stack.hits[sourcesSlot],
|
|
1452
1618
|
focusHits: state.showLegend ? focusHits.filter(({ line }) => line < plainHeader.length) : focusHits,
|
|
1453
1619
|
rowKeys: [...blockHits.map(({ key }) => key), ...targetKeys(body.targets)],
|
|
1454
1620
|
header,
|
package/src/plans/live.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { REFRESH_PACE } from './refresh-pace'
|
|
|
35
35
|
import { TAKEN_OVER_CODE, TAKEOVER_SIGNAL } from './restart-decision'
|
|
36
36
|
import { closeRow } from './row-actions'
|
|
37
37
|
import { blockRowByKey, type HoverScreen, hoveredSelection, rehovered, withoutHover } from './row-focus'
|
|
38
|
+
import { DONE_KIND, type DoneTarget } from './row-targets'
|
|
38
39
|
import {
|
|
39
40
|
applyClick,
|
|
40
41
|
inputOutcome,
|
|
@@ -46,10 +47,7 @@ import {
|
|
|
46
47
|
parseMouse,
|
|
47
48
|
splitKeys,
|
|
48
49
|
syncLaneFolds,
|
|
49
|
-
TOP_BLOCK,
|
|
50
|
-
type TopBlockId,
|
|
51
50
|
toggledExpansion,
|
|
52
|
-
topBlockHeadKey,
|
|
53
51
|
type View,
|
|
54
52
|
windowTop,
|
|
55
53
|
withoutStaleMotions,
|
|
@@ -76,6 +74,7 @@ import {
|
|
|
76
74
|
startSelection,
|
|
77
75
|
} from './view'
|
|
78
76
|
import { type BlockClick, type BlockHit, blockClick, blockHeadClick, mainClickOf } from './waiting-click'
|
|
77
|
+
import type { TurnRow, YourTurn } from './your-turn'
|
|
79
78
|
|
|
80
79
|
/**
|
|
81
80
|
* Where the herd's reads come from: its own reader, or the read the watcher publishes for every herd of the board
|
|
@@ -97,6 +96,8 @@ type Live = {
|
|
|
97
96
|
errors: string[]
|
|
98
97
|
/** One footer line about the last action of the person, such as where the sprint page was written; cleared by the next key. */
|
|
99
98
|
notice: string | null
|
|
99
|
+
/** What a `[done]` closed, left out of "Your turn" from the click until a read no longer holds its row. */
|
|
100
|
+
closing: DoneTarget[]
|
|
100
101
|
reported: Map<string, string | null>
|
|
101
102
|
/** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of the page. */
|
|
102
103
|
read: Snapshot | null
|
|
@@ -123,6 +124,39 @@ type Live = {
|
|
|
123
124
|
|
|
124
125
|
const QUIT_KEYS = new Set(['\x03', 'q'])
|
|
125
126
|
|
|
127
|
+
/** Whether `row` of "Your turn" is what `target` closes: a record by its id, a board row or an appointment by its cells. */
|
|
128
|
+
const isClosedBy = (target: DoneTarget, { waiting, appointment }: TurnRow): boolean => {
|
|
129
|
+
switch (target.kind) {
|
|
130
|
+
case DONE_KIND.record:
|
|
131
|
+
return waiting?.id === target.id
|
|
132
|
+
case DONE_KIND.boardRow:
|
|
133
|
+
return waiting?.id === undefined && waiting?.since === target.since && waiting.item === target.item
|
|
134
|
+
case DONE_KIND.appointment:
|
|
135
|
+
return appointment?.when === target.when && appointment.action === target.action
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const isClosing = (closing: readonly DoneTarget[], row: TurnRow): boolean =>
|
|
140
|
+
closing.some((target) => isClosedBy(target, row))
|
|
141
|
+
|
|
142
|
+
/** "Your turn" without the rows `closing` names, so a closed row leaves at the click, not at the next read. */
|
|
143
|
+
export const withoutClosing = (turn: YourTurn, closing: readonly DoneTarget[]): YourTurn => {
|
|
144
|
+
if (closing.length === 0) return turn
|
|
145
|
+
const open = (rows: readonly TurnRow[]) => rows.filter((row) => !isClosing(closing, row))
|
|
146
|
+
return { now: open(turn.now), planned: open(turn.planned) }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The closings a read still holds a row of; one whose row the read no longer holds is closed on the board as well. */
|
|
150
|
+
export const pendingClosings = (turn: YourTurn | undefined, closing: readonly DoneTarget[]): DoneTarget[] => {
|
|
151
|
+
const rows = turn ? [...turn.now, ...turn.planned] : []
|
|
152
|
+
return closing.filter((target) => rows.some((row) => isClosedBy(target, row)))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const withClosingLeftOut = (snapshot: Snapshot, closing: readonly DoneTarget[]): Snapshot =>
|
|
156
|
+
snapshot.yourTurn && closing.length > 0
|
|
157
|
+
? { ...snapshot, yourTurn: { ...snapshot.yourTurn, turn: withoutClosing(snapshot.yourTurn.turn, closing) } }
|
|
158
|
+
: snapshot
|
|
159
|
+
|
|
126
160
|
/** The sidebar titles that differ from the ones last reported, in the order of `titles`; null clears one. */
|
|
127
161
|
export const changedTitles = (
|
|
128
162
|
titles: ReadonlyMap<string, string | null>,
|
|
@@ -171,7 +205,7 @@ const renderContextOf = (live: Live): RenderContext => ({
|
|
|
171
205
|
|
|
172
206
|
/** The screen for the current state, with the window scrolled to keep the cursor in view. */
|
|
173
207
|
const renderLive = (live: Live, snapshot: Snapshot): { screen: Screen; top: number } => {
|
|
174
|
-
const screen = renderSnapshot(snapshot, renderContextOf(live))
|
|
208
|
+
const screen = renderSnapshot(withClosingLeftOut(snapshot, live.closing), renderContextOf(live))
|
|
175
209
|
return { screen, top: windowTop(live.view, screen.body.cursorLine, screen.body.lines.length, bodyRows(screen)) }
|
|
176
210
|
}
|
|
177
211
|
|
|
@@ -236,7 +270,7 @@ const followShimmer = (live: Live, prepared: PreparedShimmer) => {
|
|
|
236
270
|
const drawHover = (live: Live) => {
|
|
237
271
|
live.frame = null
|
|
238
272
|
if (!live.snapshot) return
|
|
239
|
-
live.plain ??= renderUnfocused(live.snapshot, renderContextOf(live))
|
|
273
|
+
live.plain ??= renderUnfocused(withClosingLeftOut(live.snapshot, live.closing), renderContextOf(live))
|
|
240
274
|
const laid = focusedScreen(live.plain, live.view.selection, paneWidth(), agentOfSnapshot(live.snapshot))
|
|
241
275
|
if (laid === null) redraw(live)
|
|
242
276
|
else write(live, laid, live.view.top)
|
|
@@ -311,6 +345,7 @@ const takeRead = (live: Live, outcome: ReadOutcome, notes: readonly string[]): b
|
|
|
311
345
|
)
|
|
312
346
|
live.view = { ...live.view, selection: folded }
|
|
313
347
|
live.snapshot = next
|
|
348
|
+
live.closing = pendingClosings(next.yourTurn?.turn, live.closing)
|
|
314
349
|
live.errors = [...next.errors, ...notes, ...reportTitles(live.source, next.titles, live.reported)]
|
|
315
350
|
return redraw(live)
|
|
316
351
|
}
|
|
@@ -474,12 +509,14 @@ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
|
|
|
474
509
|
return
|
|
475
510
|
}
|
|
476
511
|
if ('done' in outcome) {
|
|
477
|
-
|
|
512
|
+
const { closed, notice } = closeRow(outcome.done, {
|
|
478
513
|
sprintFile: live.source.sprintFile,
|
|
479
514
|
language: live.source.settings.planLanguage,
|
|
480
515
|
now: new Date(),
|
|
481
516
|
closeRecords: (ids) => closeWaiting(live.source.root, ids, Date.now()),
|
|
482
517
|
})
|
|
518
|
+
if (closed) live.closing = [...live.closing, outcome.done]
|
|
519
|
+
live.notice = notice
|
|
483
520
|
return
|
|
484
521
|
}
|
|
485
522
|
const reason = openInBrowser(outcome.open)
|
|
@@ -487,17 +524,18 @@ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
|
|
|
487
524
|
}
|
|
488
525
|
|
|
489
526
|
/**
|
|
490
|
-
* A plain click on a block on top of the herd, matched against `hit` as it was drawn: the head folds or
|
|
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
|
|
491
529
|
* cut-off row expands, a file or Artifact opens, a button asks the coordinator or closes the row; the failure or the
|
|
492
530
|
* result shows in the footer.
|
|
493
531
|
*/
|
|
494
|
-
const clickBlock = (live: Live,
|
|
532
|
+
const clickBlock = (live: Live, hit: BlockHit | null, input: string): boolean => {
|
|
495
533
|
const mouse = parseMouse(input)
|
|
496
534
|
if (!mouse || !hit) return false
|
|
497
|
-
if (blockHeadClick(hit.firstLine - 1, mouse)) {
|
|
535
|
+
if (hit.headKey && blockHeadClick(hit.firstLine - 1, mouse)) {
|
|
498
536
|
live.view = {
|
|
499
537
|
...live.view,
|
|
500
|
-
selection: applyClick(live.view.selection,
|
|
538
|
+
selection: applyClick(live.view.selection, hit.headKey),
|
|
501
539
|
}
|
|
502
540
|
return true
|
|
503
541
|
}
|
|
@@ -533,7 +571,11 @@ const activateBlockRow = (live: Live, screen: Screen, input: string): boolean =>
|
|
|
533
571
|
return true
|
|
534
572
|
}
|
|
535
573
|
|
|
536
|
-
const blocksOf = ({ yourTurnBlock, sourceBlock }: Screen): (BlockHit | null)[] => [
|
|
574
|
+
const blocksOf = ({ yourTurnBlock, entryBlocks, sourceBlock }: Screen): (BlockHit | null)[] => [
|
|
575
|
+
yourTurnBlock,
|
|
576
|
+
...entryBlocks,
|
|
577
|
+
sourceBlock,
|
|
578
|
+
]
|
|
537
579
|
|
|
538
580
|
const handleInput = (live: Live, input: string) => {
|
|
539
581
|
const { snapshot, screen } = live
|
|
@@ -543,7 +585,6 @@ const handleInput = (live: Live, input: string) => {
|
|
|
543
585
|
hover(live, screen, mouse)
|
|
544
586
|
return
|
|
545
587
|
}
|
|
546
|
-
live.notice = null
|
|
547
588
|
const settled = settledSelection(live.view.selection, screen)
|
|
548
589
|
live.view = { ...live.view, selection: mouse ? settled : withoutHover(settled) }
|
|
549
590
|
if (activateBlockRow(live, screen, input)) {
|
|
@@ -555,8 +596,9 @@ const handleInput = (live: Live, input: string) => {
|
|
|
555
596
|
return
|
|
556
597
|
}
|
|
557
598
|
if (
|
|
558
|
-
clickBlock(live,
|
|
559
|
-
clickBlock(live,
|
|
599
|
+
clickBlock(live, screen.yourTurnBlock, input) ||
|
|
600
|
+
screen.entryBlocks.some((hit) => clickBlock(live, hit, input)) ||
|
|
601
|
+
clickBlock(live, screen.sourceBlock, input)
|
|
560
602
|
) {
|
|
561
603
|
redraw(live)
|
|
562
604
|
return
|
|
@@ -594,6 +636,7 @@ export const runLive = (source: Source, key: string | null = null): void => {
|
|
|
594
636
|
view: { selection: overviewSelection(), top: 0, follow: true },
|
|
595
637
|
errors: [],
|
|
596
638
|
notice: null,
|
|
639
|
+
closing: [],
|
|
597
640
|
reported: new Map(),
|
|
598
641
|
read: null,
|
|
599
642
|
whole: null,
|
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/row-actions.ts
CHANGED
|
@@ -16,9 +16,16 @@ export type CloseDependencies = {
|
|
|
16
16
|
closeRecords: (ids: readonly string[]) => number
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
/** Whether the row was closed, and the footer line that says so or why not. */
|
|
20
|
+
export type CloseResult = { closed: boolean; notice: string }
|
|
21
|
+
|
|
22
|
+
const closedAs = (subject: string): CloseResult => ({ closed: true, notice: TEXTS.closed(subject) })
|
|
23
|
+
|
|
24
|
+
const notClosed = (reason: string): CloseResult => ({ closed: false, notice: TEXTS.notClosed(reason) })
|
|
25
|
+
|
|
26
|
+
const closeRecord = (id: string, { closeRecords }: CloseDependencies): CloseResult => {
|
|
27
|
+
if (!isQuestionId(id)) return notClosed('not a record id')
|
|
28
|
+
return closeRecords([id]) > 0 ? closedAs(`record ${id}`) : notClosed('record not found or closed already')
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
const closedBoard = (target: DoneTarget, board: string, { language, now }: CloseDependencies): string | null => {
|
|
@@ -31,22 +38,22 @@ const subjectOfRow = (target: DoneTarget): string => {
|
|
|
31
38
|
return target.kind === DONE_KIND.appointment ? `appointment ${boardRowKey(target.when, target.action)}` : ''
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
const closeBoardRow = (target: DoneTarget, dependencies: CloseDependencies):
|
|
41
|
+
const closeBoardRow = (target: DoneTarget, dependencies: CloseDependencies): CloseResult => {
|
|
35
42
|
try {
|
|
36
43
|
const board = readFileSync(dependencies.sprintFile, 'utf8')
|
|
37
44
|
const closed = closedBoard(target, board, dependencies)
|
|
38
|
-
if (closed === null) return
|
|
45
|
+
if (closed === null) return notClosed('row not found in the board')
|
|
39
46
|
writeFileSync(dependencies.sprintFile, closed)
|
|
40
|
-
return
|
|
47
|
+
return closedAs(subjectOfRow(target))
|
|
41
48
|
} catch (error) {
|
|
42
|
-
return
|
|
49
|
+
return notClosed(errorText(error))
|
|
43
50
|
}
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
/**
|
|
47
54
|
* Closes what a `[done]` button names: a log record by its id, or a row of the board's file by writing the stamp into its
|
|
48
|
-
* status cell. Returns the footer line. The board is read again just before it is written, so a
|
|
49
|
-
* changed since the herd drew it is found by its text or not at all.
|
|
55
|
+
* status cell. Returns whether it closed and the footer line. The board is read again just before it is written, so a
|
|
56
|
+
* row the coordinator changed since the herd drew it is found by its text or not at all.
|
|
50
57
|
*/
|
|
51
|
-
export const closeRow = (target: DoneTarget, dependencies: CloseDependencies):
|
|
58
|
+
export const closeRow = (target: DoneTarget, dependencies: CloseDependencies): CloseResult =>
|
|
52
59
|
target.kind === DONE_KIND.record ? closeRecord(target.id, dependencies) : closeBoardRow(target, dependencies)
|