jackherd 1.26.1 → 1.26.3
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 +13 -0
- package/README.md +18 -18
- package/dist/config.d.ts +1 -0
- package/dist/config.js +2 -0
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -8
- package/kit/workflow/skills/orchestrate/references/lane-session.md +1 -1
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +8 -0
- package/src/brief/lane-facts.ts +30 -18
- package/src/config/schema.ts +2 -0
- package/src/context/command.ts +3 -1
- package/src/lane/close-check.ts +51 -15
- package/src/lane/close-command.ts +41 -8
- package/src/lane/close-survey.ts +9 -0
- package/src/lane/close.ts +54 -6
- package/src/lane/send-options.ts +3 -2
- package/src/lane/send.ts +8 -1
- package/src/lane/subagent-view.ts +21 -0
- package/src/lane-prompt/command.ts +4 -1
- package/src/plans/active-tasks.ts +4 -2
- package/src/plans/activity-read.ts +3 -2
- package/src/plans/command.ts +3 -1
- package/src/plans/coordinator-row.ts +7 -2
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/lane-discovery.ts +42 -3
- package/src/plans/lane-worktrees.ts +56 -0
- package/src/plans/sent-files.ts +10 -5
- package/src/plans/settings.ts +12 -1
- package/src/plans/snapshot.ts +23 -8
- package/src/plans/subagents-read.ts +17 -2
- package/src/plans/subagents.ts +3 -2
- package/src/plans/unlisted-lanes.ts +13 -0
- package/src/resume/command.ts +1 -1
- package/src/resume/coordinator-read.ts +12 -12
- package/src/resume/decision.ts +3 -0
- package/src/resume/record.ts +24 -5
- package/src/sync/command.ts +16 -2
- package/src/sync/downgrade.ts +9 -0
- package/src/sync/if-changed.ts +6 -7
- package/src/update/command.ts +17 -0
- package/src/update/registry-wait.ts +44 -0
- package/src/update/update.ts +23 -0
- package/src/version/registry.ts +8 -0
- package/src/watch/command.ts +28 -14
- package/src/watch/obsolete-proposals.ts +8 -6
- package/src/watch/obsolete-records.ts +3 -1
- package/src/watch/running-subagents.ts +3 -2
- package/src/watch/task-signal-read.ts +4 -1
- package/src/watch/unlinked-artifacts-read.ts +3 -1
package/src/lane/close.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
+
import { claudePath, HANDOVER_DIRECTORY } from '../claude/paths'
|
|
1
2
|
import { twoDigits } from '../plan-format/clock'
|
|
2
3
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
3
4
|
import { errorText } from '../plans/error-text'
|
|
4
5
|
import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
5
6
|
import { closedMark, historyDate, withHistoryRow, withLaneClosed } from './close-board'
|
|
6
|
-
import { BOARD_LANE, type ClosableLane, type CloseFacts, closeVerdict } from './close-check'
|
|
7
|
+
import { BOARD_LANE, type ClosableLane, type CloseFacts, closeVerdict, remoteBranchPlanOf } from './close-check'
|
|
7
8
|
import { LANE_CLOSE_COMMAND } from './close-command-line'
|
|
8
9
|
|
|
9
10
|
/** The facts the check reads, and the herdr workspace the worktree is open in, null when none is. */
|
|
10
11
|
export type CloseSurvey = { facts: CloseFacts; workspace: string | null }
|
|
11
12
|
|
|
12
|
-
/** What `jh lane close` was asked for; `worktree` comes from the
|
|
13
|
+
/** What `jh lane close` was asked for; `worktree` comes from the lane's name or from the directory its agent runs in (`worktreeOf`), never from a reply. */
|
|
13
14
|
export type CloseRequest = {
|
|
14
15
|
lane: string
|
|
15
16
|
worktree: string
|
|
@@ -27,6 +28,10 @@ export type ClosePorts = {
|
|
|
27
28
|
exitAgent: (pane: string) => Promise<void>
|
|
28
29
|
/** Removes the worktree without force, then closes the terminal workspace `workspace` names, if any. */
|
|
29
30
|
removeWorktree: (worktree: string, workspace: string | null) => void
|
|
31
|
+
/** Copies the handover directory of `worktree` to `destination`, a path relative to the main checkout; merges into one that exists. */
|
|
32
|
+
archiveHandover: (worktree: string, destination: string) => void
|
|
33
|
+
/** Deletes the branch on origin only while it still points to `head`; throws when it moved or the push fails. */
|
|
34
|
+
deleteRemoteBranch: (branch: string, head: string) => void
|
|
30
35
|
/** Deletes the branch only while it still points to `head`, the commit the check compared. */
|
|
31
36
|
deleteBranch: (branch: string, head: string) => void
|
|
32
37
|
readBoard: () => string
|
|
@@ -58,6 +63,10 @@ const DETACHED_HISTORY_TEXT: Record<PlanLanguage, (lane: string, head: string) =
|
|
|
58
63
|
`Lane \`${lane}\` cleaned up with \`${LANE_CLOSE_COMMAND}\`: worktree on no branch, head \`${head}\` is on the base branch, worktree removed`,
|
|
59
64
|
}
|
|
60
65
|
|
|
66
|
+
/** Where the lane's handover directory is kept once its worktree is gone: below the main checkout's own handover directory. */
|
|
67
|
+
export const handoverArchiveOf = (lane: string, now: Date): string =>
|
|
68
|
+
claudePath(HANDOVER_DIRECTORY, 'closed', `${lane}-${historyDate(now)}`)
|
|
69
|
+
|
|
61
70
|
const clockOf = (time: Date): string => `${twoDigits(time.getHours())}:${twoDigits(time.getMinutes())}`
|
|
62
71
|
|
|
63
72
|
/** The history row's text: the time, then what was closed and proven, in the board's language. */
|
|
@@ -101,12 +110,44 @@ const removalSteps = (closed: ClosedLane, survey: CloseSurvey, request: CloseReq
|
|
|
101
110
|
]
|
|
102
111
|
: []
|
|
103
112
|
return [
|
|
113
|
+
...archiveSteps(survey, request, ports),
|
|
104
114
|
...worktree,
|
|
105
115
|
...branchSteps(closed.closable, ports),
|
|
106
116
|
{ label: boardLabel(facts, request), run: () => ports.writeBoard(closedBoard(ports.readBoard(), closed, request)) },
|
|
117
|
+
...remoteBranchSteps(facts, ports),
|
|
107
118
|
]
|
|
108
119
|
}
|
|
109
120
|
|
|
121
|
+
/** The handover files are copied before the worktree goes; when the copy fails, the worktree stays. */
|
|
122
|
+
const archiveSteps = ({ facts }: CloseSurvey, request: CloseRequest, ports: ClosePorts): Step[] => {
|
|
123
|
+
if (!facts.worktreeExists || facts.handoverEntries.length === 0) return []
|
|
124
|
+
const destination = handoverArchiveOf(request.lane, request.now)
|
|
125
|
+
return [
|
|
126
|
+
{
|
|
127
|
+
label: `archive the handover files of ${request.lane} in ${destination}`,
|
|
128
|
+
run: () => ports.archiveHandover(request.worktree, destination),
|
|
129
|
+
},
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Last, so a failed push on origin leaves everything else done: a rerun with `--branch` finishes it. */
|
|
134
|
+
const remoteBranchSteps = (facts: CloseFacts, ports: ClosePorts): Step[] => {
|
|
135
|
+
const plan = remoteBranchPlanOf(facts)
|
|
136
|
+
if (plan.kind !== 'delete') return []
|
|
137
|
+
return [
|
|
138
|
+
{
|
|
139
|
+
label: `delete branch ${plan.branch} on origin`,
|
|
140
|
+
run: () => ports.deleteRemoteBranch(plan.branch, plan.head),
|
|
141
|
+
},
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The one line saying why a branch on origin stays; null when it goes or there is none. */
|
|
146
|
+
const remoteKeptNote = (facts: CloseFacts): string | null => {
|
|
147
|
+
const plan = remoteBranchPlanOf(facts)
|
|
148
|
+
return plan.kind === 'keep' ? `keeping branch ${plan.branch} on origin: ${plan.reason}` : null
|
|
149
|
+
}
|
|
150
|
+
|
|
110
151
|
/** Deleting the branch is a step only while the branch exists: a lane resumed after a partial failure may have lost it. */
|
|
111
152
|
const branchSteps = (closable: ClosableLane, ports: ClosePorts): Step[] => {
|
|
112
153
|
if (closable.kind === 'detached' || closable.head === null) return []
|
|
@@ -123,8 +164,8 @@ const exitSteps = ({ facts }: CloseSurvey, request: CloseRequest, ports: ClosePo
|
|
|
123
164
|
}
|
|
124
165
|
|
|
125
166
|
/** 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
|
|
167
|
+
const isClosedInFull = ({ worktreeExists, head, boardLane, remoteHead }: CloseFacts): boolean =>
|
|
168
|
+
!worktreeExists && head === null && boardLane === BOARD_LANE.closed && remoteHead === null
|
|
128
169
|
|
|
129
170
|
/** The lane closable from `survey`, or null after printing every reason against it under `heading`. */
|
|
130
171
|
const checked = (survey: CloseSurvey, heading: string, request: CloseRequest, ports: ClosePorts): ClosedLane | null => {
|
|
@@ -135,6 +176,11 @@ const checked = (survey: CloseSurvey, heading: string, request: CloseRequest, po
|
|
|
135
176
|
return null
|
|
136
177
|
}
|
|
137
178
|
|
|
179
|
+
const printKept = (facts: CloseFacts, ports: ClosePorts): void => {
|
|
180
|
+
const note = remoteKeptNote(facts)
|
|
181
|
+
if (note !== null) ports.print(note)
|
|
182
|
+
}
|
|
183
|
+
|
|
138
184
|
/** Runs the steps in order, one line each; at the first failure, names it and every step left undone. */
|
|
139
185
|
const runSteps = async (steps: readonly Step[], ports: ClosePorts): Promise<boolean> => {
|
|
140
186
|
for (const [index, step] of steps.entries()) {
|
|
@@ -153,8 +199,8 @@ const runSteps = async (steps: readonly Step[], ports: ClosePorts): Promise<bool
|
|
|
153
199
|
/**
|
|
154
200
|
* Closes a lane in one run, or refuses with every reason and changes nothing. Checks first (`closeVerdict`); a dry run
|
|
155
201
|
* stops there and prints the steps. Otherwise it ends the lane's agent, checks again, since the agent may have written
|
|
156
|
-
* until it exited, then removes the worktree and its workspace, deletes the local branch,
|
|
157
|
-
* the board never records a removal that failed. Exit 0 closed or dry run, 1 refused or failed.
|
|
202
|
+
* until it exited, then archives the handover files, removes the worktree and its workspace, deletes the local branch, marks the board, and deletes the branch on origin last when it
|
|
203
|
+
* still holds the merged head (`remoteBranchPlanOf`), so the board never records a removal that failed. Exit 0 closed or dry run, 1 refused or failed.
|
|
158
204
|
*/
|
|
159
205
|
export const closeLane = async (request: CloseRequest, ports: ClosePorts): Promise<number> => {
|
|
160
206
|
const first = await ports.survey()
|
|
@@ -168,12 +214,14 @@ export const closeLane = async (request: CloseRequest, ports: ClosePorts): Promi
|
|
|
168
214
|
if (request.dryRun) {
|
|
169
215
|
ports.print(`dry run, nothing changed; closing ${request.lane} would:`)
|
|
170
216
|
for (const step of [...exit, ...removalSteps(closed, first, request, ports)]) ports.print(` ${step.label}`)
|
|
217
|
+
printKept(first.facts, ports)
|
|
171
218
|
return 0
|
|
172
219
|
}
|
|
173
220
|
if (!(await runSteps(exit, ports))) return 1
|
|
174
221
|
const settled = exit.length === 0 ? first : await ports.survey()
|
|
175
222
|
const late = checked(settled, `not closing ${request.lane} after its agent exited:`, request, ports)
|
|
176
223
|
if (late === null) return 1
|
|
224
|
+
printKept(settled.facts, ports)
|
|
177
225
|
if (!(await runSteps(removalSteps(late, settled, request, ports), ports))) return 1
|
|
178
226
|
ports.print(`closed ${request.lane}`)
|
|
179
227
|
return 0
|
package/src/lane/send-options.ts
CHANGED
|
@@ -7,8 +7,9 @@ const FILE = '--file'
|
|
|
7
7
|
|
|
8
8
|
export const LANE_SEND_USAGE = `usage: ${CLI_NAME} lane send <lane> [${FILE} <prompt-file>]
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
Refuses (exit 1, nothing sent) while a background agent's view is open on the lane's screen: the input line would
|
|
11
|
+
write to that agent. Otherwise sends the lane's prompt file (default: .claude/handover/<lane>-prompt.md of the main
|
|
12
|
+
checkout) to its agent and waits 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
13
|
file is sent once more. A second stall exits 1 with one line for the coordinator.`
|
|
13
14
|
|
|
14
15
|
/** Reads `<lane> [--file <path>]`; throws with the usage on anything else. */
|
package/src/lane/send.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isSubagentViewOpen } from './subagent-view'
|
|
2
|
+
|
|
1
3
|
/** The codes of herdr's answer to `herdr agent prompt --wait` that do not prove the text is lost. */
|
|
2
4
|
export const SEND_CODE = { stalled: 'agent_prompt_stalled', timeout: 'timeout' } as const
|
|
3
5
|
|
|
@@ -30,6 +32,9 @@ export type SendOutcome = {
|
|
|
30
32
|
line: string
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
const SUBAGENT_VIEW_REFUSAL =
|
|
36
|
+
'the input line belongs to a background agent whose view is open, so the prompt would go to it and not to the lane; nothing was sent. Close the view (select main in the agent list) or send later.'
|
|
37
|
+
|
|
33
38
|
const isStall = ({ code }: HerdrAnswer): boolean => code === SEND_CODE.stalled || code === SEND_CODE.timeout
|
|
34
39
|
|
|
35
40
|
/** The start of the prompt's first line: what the input line shows while the text waits there, unsent. */
|
|
@@ -68,12 +73,14 @@ const recovered = async (
|
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
/**
|
|
71
|
-
* Sends the prompt text to the lane and makes sure it arrived.
|
|
76
|
+
* Sends the prompt text to the lane and makes sure it arrived. An open background agent's view refuses the send first:
|
|
77
|
+
* the input line would write to that agent. A stall or a timeout does not prove the text is lost:
|
|
72
78
|
* the screen decides. A lane at work is done; a prompt on the screen gets `enter`, and an `enter` the lane does not
|
|
73
79
|
* answer ends with exit 1, since another send would append to the waiting text; a screen without the prompt sends the
|
|
74
80
|
* text once more. A second stall, or any other error, ends with exit 1 and a line for Jack.
|
|
75
81
|
*/
|
|
76
82
|
export const sendLanePrompt = async (ports: SendPorts, lane: string, prompt: string): Promise<SendOutcome> => {
|
|
83
|
+
if (isSubagentViewOpen(await ports.screen(lane))) return outcome('failed', lane, SUBAGENT_VIEW_REFUSAL)
|
|
77
84
|
const first = await ports.prompt(lane, prompt)
|
|
78
85
|
if (first.ok) return outcome('sent', lane, 'prompt sent.')
|
|
79
86
|
if (!isStall(first)) return refused(lane, first)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A row of Claude Code's agent list that shows the main agent not selected: `◯ main`, the row of the main agent
|
|
3
|
+
* without the filled `●`. The list sits under the input line; `●` marks the agent whose view is open, so `◯ main`
|
|
4
|
+
* means a background agent's view is open and the input line writes to that agent. Seen in a real Claude Code pane:
|
|
5
|
+
* the list reads `◯ main` / `● general-purpose …`. The optional `❯` is the list cursor, which stays on the row while
|
|
6
|
+
* the list is in selection mode.
|
|
7
|
+
*/
|
|
8
|
+
const MAIN_NOT_SELECTED_ROW = /^\s*(?:❯\s+)?◯ main\s*$/m
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The empty input line of an open agent view: `❯ Message @general-purpose…`. It does not depend on how long the agent
|
|
12
|
+
* list is, which can push the `◯ main` row out of a short screen read; it shows only while the line is empty.
|
|
13
|
+
*/
|
|
14
|
+
const AGENT_INPUT_PLACEHOLDER = /^\s*❯ Message @\S/m
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether the screen text shows a background agent's view as the open one, so a prompt would go to that agent. A
|
|
18
|
+
* screen cut so short that neither mark is on it reads as no view: the send then goes ahead as it did before.
|
|
19
|
+
*/
|
|
20
|
+
export const isSubagentViewOpen = (screen: string): boolean =>
|
|
21
|
+
MAIN_NOT_SELECTED_ROW.test(screen) || AGENT_INPUT_PLACEHOLDER.test(screen)
|
|
@@ -3,6 +3,7 @@ import { dirname, join, relative, resolve } from 'node:path'
|
|
|
3
3
|
import { claudePath, HANDOVER_DIRECTORY } from '../claude/paths'
|
|
4
4
|
import { boardFor } from '../plans/board-pick'
|
|
5
5
|
import { mainCheckout, worktreeRoot } from '../plans/git'
|
|
6
|
+
import { settingsPlacingBoardLanes } from '../plans/lane-worktrees'
|
|
6
7
|
import { boardRepoPathOf, readSettings, worktreeOf } from '../plans/settings'
|
|
7
8
|
import { asksForHelp } from '../preview/options'
|
|
8
9
|
import { withAdditions } from './additions'
|
|
@@ -54,7 +55,9 @@ const promptSourcesOf = (options: LanePromptOptions, cwd: string): PromptSources
|
|
|
54
55
|
},
|
|
55
56
|
board: { text: readFileSync(board.file, 'utf8'), directory, path: relative(cwd, board.file) },
|
|
56
57
|
readPlan: readFirstIn([root, checkout]),
|
|
57
|
-
readLanePlan: readFirstIn([
|
|
58
|
+
readLanePlan: readFirstIn([
|
|
59
|
+
worktreeOf(root, settingsPlacingBoardLanes(root, settings, board.file, options.lane), options.lane),
|
|
60
|
+
]),
|
|
58
61
|
root,
|
|
59
62
|
}
|
|
60
63
|
}
|
|
@@ -3,6 +3,7 @@ import { terminalSafe } from '../plan-format/terminal-text'
|
|
|
3
3
|
import { matchSignals, type OpenPlanTask } from '../task-signals/matcher'
|
|
4
4
|
import type { TaskSignal } from '../task-signals/types'
|
|
5
5
|
import type { TaskPhase } from './current-task'
|
|
6
|
+
import { type LaneWorktrees, laneOfWorktree } from './unlisted-lanes'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* A task an agent is working on: its index among the phase's checkboxes, who works on it (null while only one agent
|
|
@@ -83,8 +84,9 @@ export const freshSignals = (signals: readonly TaskSignal[], lastTickAt: number
|
|
|
83
84
|
signals.filter(({ source, startedAt }) => source !== 'jh' || lastTickAt === null || startedAt >= lastTickAt)
|
|
84
85
|
|
|
85
86
|
/** The lane a directory belongs to: the first directory below `worktrees` it lies in; null anywhere else. */
|
|
86
|
-
export const laneOfDirectory = (directory: string, worktrees: string): string | null => {
|
|
87
|
+
export const laneOfDirectory = (directory: string, worktrees: string, placed: LaneWorktrees = {}): string | null => {
|
|
87
88
|
const prefix = worktrees.endsWith(sep) ? worktrees : `${worktrees}${sep}`
|
|
88
89
|
if (!directory.startsWith(prefix)) return null
|
|
89
|
-
|
|
90
|
+
const name = directory.slice(prefix.length).split(sep)[0]
|
|
91
|
+
return name ? laneOfWorktree(name, placed) : null
|
|
90
92
|
}
|
|
@@ -3,7 +3,7 @@ import { keptLogEntry } from '../context/session-log'
|
|
|
3
3
|
import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
|
|
4
4
|
import { isRecord } from '../shared/is-record'
|
|
5
5
|
import { type RunningSubagent, type SubagentActivity, type SubagentLog, subagentLabelOf } from './subagents'
|
|
6
|
-
import {
|
|
6
|
+
import { type LaneWorktrees, laneOfCwd } from './unlisted-lanes'
|
|
7
7
|
|
|
8
8
|
/** What a running subagent did last, with the name the herd calls it by. */
|
|
9
9
|
export type SubagentLine = { label: string; description: string | null; line: string }
|
|
@@ -83,11 +83,12 @@ export const withSubagentLines = (
|
|
|
83
83
|
running: readonly RunningSubagent[],
|
|
84
84
|
logs: readonly SubagentLog[],
|
|
85
85
|
worktreeDirectory: string,
|
|
86
|
+
placed: LaneWorktrees = {},
|
|
86
87
|
): Map<string, LaneSubagents> => {
|
|
87
88
|
const result = new Map<string, LaneSubagents>(lanes)
|
|
88
89
|
const tails = new Map(logs.flatMap((log) => [[labelOfLog(log), log.tail] as const]))
|
|
89
90
|
for (const { label, cwd, description } of running) {
|
|
90
|
-
const lane = cwd === null ? null :
|
|
91
|
+
const lane = cwd === null ? null : laneOfCwd(cwd, worktreeDirectory, placed)
|
|
91
92
|
const tail = tails.get(label)
|
|
92
93
|
const known = lane === null ? undefined : result.get(lane)
|
|
93
94
|
const line = tail === undefined ? null : activityLineOf(tail)
|
package/src/plans/command.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { fetchPullRequests } from './github'
|
|
|
10
10
|
import { claimOwnTerminal, startExclusive } from './herd-claim'
|
|
11
11
|
import { recallLanes, rememberLanes } from './herd-memory'
|
|
12
12
|
import { publishedKey } from './herd-state'
|
|
13
|
+
import { settingsPlacingBoardLanes } from './lane-worktrees'
|
|
13
14
|
import { runLive } from './live'
|
|
14
15
|
import { parseOptions } from './options'
|
|
15
16
|
import { ENSURE_EXIT } from './plan-panes'
|
|
@@ -41,6 +42,7 @@ const printOnceWithNotice = async (source: Source, notice: string | null): Promi
|
|
|
41
42
|
await printOnce(source)
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/** The settings placing each open lane of the board by where its agent runs. */
|
|
44
46
|
/**
|
|
45
47
|
* `jh herd [<board.md>] [--lane <name>] [--once | --ensure]`: a live view of a board's lanes and their plan
|
|
46
48
|
* progress; `--ensure` instead brings back the missing herd panes beside the coordinator and each lane.
|
|
@@ -58,7 +60,7 @@ export const run = async (args: string[], home: string = homedir()): Promise<num
|
|
|
58
60
|
if (options.ensure) {
|
|
59
61
|
const { lines, exitCode } =
|
|
60
62
|
settings.adapter === TERMINAL_ADAPTER.herdr
|
|
61
|
-
? ensurePlanPaneRun(root, settings, picked.file)
|
|
63
|
+
? ensurePlanPaneRun(root, settingsPlacingBoardLanes(root, settings, picked.file, null), picked.file)
|
|
62
64
|
: { lines: ['the terminal adapter is not herdr: no panes to bring back'], exitCode: ENSURE_EXIT.done }
|
|
63
65
|
for (const line of lines.length > 0 ? lines : ['every herd pane is running']) console.log(line)
|
|
64
66
|
return exitCode
|
|
@@ -7,6 +7,7 @@ import { fit, visibleLength } from '../terminal/text'
|
|
|
7
7
|
import { AGENT_STATUS, type LaneAgent } from '../terminal/types'
|
|
8
8
|
import { laneOfDirectory } from './active-tasks'
|
|
9
9
|
import { statusWord } from './symbols'
|
|
10
|
+
import type { LaneWorktrees } from './unlisted-lanes'
|
|
10
11
|
|
|
11
12
|
export type CoordinatorTask = Pick<TaskSignal, 'text' | 'startedAt'>
|
|
12
13
|
|
|
@@ -43,9 +44,13 @@ export const coordinatorAgentOf = (agents: readonly LaneAgent[], repository: str
|
|
|
43
44
|
* The coordinator's task: the newest signal reported outside the lanes' worktrees, where the coordinator works (its
|
|
44
45
|
* Claude task list, or `jh task start` from the main checkout); null without one.
|
|
45
46
|
*/
|
|
46
|
-
export const coordinatorTaskOf = (
|
|
47
|
+
export const coordinatorTaskOf = (
|
|
48
|
+
signals: readonly TaskSignal[],
|
|
49
|
+
worktrees: string,
|
|
50
|
+
placed: LaneWorktrees = {},
|
|
51
|
+
): TaskSignal | null =>
|
|
47
52
|
signals
|
|
48
|
-
.filter(({ cwd }) => laneOfDirectory(cwd, worktrees) === null)
|
|
53
|
+
.filter(({ cwd }) => laneOfDirectory(cwd, worktrees, placed) === null)
|
|
49
54
|
.reduce<TaskSignal | null>(
|
|
50
55
|
(newest, signal) => (newest && newest.startedAt >= signal.startedAt ? newest : signal),
|
|
51
56
|
null,
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
withAgentPanes,
|
|
22
22
|
withForeground,
|
|
23
23
|
} from './plan-panes'
|
|
24
|
-
import { type PlansSettings } from './settings'
|
|
24
|
+
import { type PlansSettings, worktreeOf } from './settings'
|
|
25
25
|
|
|
26
26
|
const panesWithForeground = (agentPanes: readonly string[]): { panes: PaneState[]; notes: string[] } =>
|
|
27
27
|
withForeground(withAgentPanes(paneListOf(runHerdr(['pane', 'list'])), agentPanes), (pane) =>
|
|
@@ -32,7 +32,7 @@ const panesWithForeground = (agentPanes: readonly string[]): { panes: PaneState[
|
|
|
32
32
|
const lanesOf = (root: string, settings: PlansSettings, boardFile: string) =>
|
|
33
33
|
parseSprintLanes(readFileSync(boardFile, 'utf8'), settings.boardLanguage)
|
|
34
34
|
.filter((lane) => !lane.closed)
|
|
35
|
-
.map((lane) => ({ name: lane.name, cwd:
|
|
35
|
+
.map((lane) => ({ name: lane.name, cwd: worktreeOf(root, settings, lane.name) }))
|
|
36
36
|
.filter((lane) => existsSync(lane.cwd))
|
|
37
37
|
|
|
38
38
|
/** Carries out one action; a failure becomes the action's line, so the other views are still tried. */
|
|
@@ -46,14 +46,53 @@ export const discoverLanes = ({ worktrees, agentNames, sprintLanes }: LaneSource
|
|
|
46
46
|
return [...lanes, ...helpersOf(lanes, worktrees, agentNames)].sort()
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** What `resolveLaneWorktrees` reads: the agents, the board's lane rows, the existing worktree directory names, the absolute worktree directory. */
|
|
50
|
+
export type ResolutionSources = {
|
|
51
|
+
agents: readonly AgentPlace[]
|
|
52
|
+
sprintLanes: readonly string[]
|
|
53
|
+
worktrees: readonly string[]
|
|
54
|
+
worktreeDirectory: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** An agent named after a board lane that has no worktree of that name, running in a directory of another one. */
|
|
58
|
+
const lacksWorktreeOfItsName = (
|
|
59
|
+
name: string,
|
|
60
|
+
directory: string,
|
|
61
|
+
{ sprintLanes, worktrees }: ResolutionSources,
|
|
62
|
+
): boolean => directory !== name && sprintLanes.includes(name) && !worktrees.includes(name)
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The worktree of each board lane that has none of its own name, from where its agent runs: lane name to directory name.
|
|
66
|
+
* A lane resolves when its agents (named like the lane) all run in the one existing worktree below `worktreeDirectory`
|
|
67
|
+
* and that directory is no board lane's own name and no other lane's choice. Several directories, a directory of another lane or one that does not
|
|
68
|
+
* exist leave the lane out: the lane stays unwatched and `misplacedLanes` names it.
|
|
69
|
+
*/
|
|
70
|
+
export const resolveLaneWorktrees = (sources: ResolutionSources): Record<string, string> => {
|
|
71
|
+
const { agents, sprintLanes, worktrees, worktreeDirectory } = sources
|
|
72
|
+
const directories = new Map<string, Set<string>>()
|
|
73
|
+
for (const { name, cwd } of agents) {
|
|
74
|
+
const directory = cwd === null ? null : laneDirectoryOf(cwd, worktreeDirectory)
|
|
75
|
+
if (directory !== null && lacksWorktreeOfItsName(name, directory, sources))
|
|
76
|
+
directories.set(name, (directories.get(name) ?? new Set()).add(directory))
|
|
77
|
+
}
|
|
78
|
+
const claims = [...directories].flatMap(([lane, found]) => {
|
|
79
|
+
const [directory] = found
|
|
80
|
+
const usable = found.size === 1 && worktrees.includes(directory) && !sprintLanes.includes(directory)
|
|
81
|
+
return usable ? [[lane, directory] as const] : []
|
|
82
|
+
})
|
|
83
|
+
return Object.fromEntries(
|
|
84
|
+
claims.filter(([, directory]) => claims.filter(([, other]) => other === directory).length === 1),
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
49
88
|
/** An agent named after a board lane that nobody watches, running in a worktree that carries another name. */
|
|
50
89
|
const isMisplaced = (name: string, worktree: string, sprintLanes: readonly string[], watched: readonly string[]) =>
|
|
51
90
|
worktree !== name && sprintLanes.includes(name) && !watched.includes(name)
|
|
52
91
|
|
|
53
92
|
/**
|
|
54
|
-
* The board lanes
|
|
55
|
-
* another name
|
|
56
|
-
*
|
|
93
|
+
* The board lanes that stay unwatched: an agent named after a lane row runs in a worktree whose directory carries
|
|
94
|
+
* another name and `resolveLaneWorktrees` could not tell which one is the lane's (several, another lane's, none). A lane
|
|
95
|
+
* watched anyway is left out.
|
|
57
96
|
*/
|
|
58
97
|
export const misplacedLanes = ({
|
|
59
98
|
agents,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { parseSprintLanes } from '../plan-format'
|
|
4
|
+
import { laneStatusSourceFor } from '../terminal'
|
|
5
|
+
import { worktreeNames } from './git'
|
|
6
|
+
import { type AgentPlace, resolveLaneWorktrees } from './lane-discovery'
|
|
7
|
+
import type { PlansSettings } from './settings'
|
|
8
|
+
|
|
9
|
+
/** The settings that place the lanes by where their agents run, and the worktree names discovery counts. */
|
|
10
|
+
export type PlacedLanes = { settings: PlansSettings; worktrees: string[] }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Places the lanes whose worktree carries another name (`resolveLaneWorktrees`): `settings` answers `worktreeOf` with
|
|
14
|
+
* it, and `worktrees` holds the directories that exist plus those lanes' names, which discovery counts as worktrees.
|
|
15
|
+
*/
|
|
16
|
+
export const placeLanes = (
|
|
17
|
+
root: string,
|
|
18
|
+
settings: PlansSettings,
|
|
19
|
+
agents: readonly AgentPlace[],
|
|
20
|
+
sprintLanes: readonly string[],
|
|
21
|
+
): PlacedLanes => {
|
|
22
|
+
const worktreeDirectory = join(root, settings.worktreeDirectory)
|
|
23
|
+
const worktrees = worktreeNames(worktreeDirectory)
|
|
24
|
+
const laneWorktrees = resolveLaneWorktrees({ agents, sprintLanes, worktrees, worktreeDirectory })
|
|
25
|
+
return { settings: { ...settings, laneWorktrees }, worktrees: [...worktrees, ...Object.keys(laneWorktrees)] }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const agentsOf = (settings: PlansSettings): AgentPlace[] => {
|
|
29
|
+
try {
|
|
30
|
+
return laneStatusSourceFor(settings.adapter).listAgents()
|
|
31
|
+
} catch {
|
|
32
|
+
return []
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The names of the lanes the board file lists, none when it cannot be read. */
|
|
37
|
+
const boardLaneNamesOf = (settings: PlansSettings, boardFile: string): string[] => {
|
|
38
|
+
try {
|
|
39
|
+
return parseSprintLanes(readFileSync(boardFile, 'utf8'), settings.boardLanguage).map(({ name }) => name)
|
|
40
|
+
} catch {
|
|
41
|
+
return []
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The settings of a command that reads the terminal's agents itself: a terminal that does not answer places no lane. */
|
|
46
|
+
export const settingsPlacingLanes = (root: string, settings: PlansSettings, lanes: readonly string[]): PlansSettings =>
|
|
47
|
+
placeLanes(root, settings, agentsOf(settings), lanes).settings
|
|
48
|
+
|
|
49
|
+
/** `settingsPlacingLanes` for the lanes of the board `boardFile` and `lane`: a worktree that is another board lane's is never taken for `lane`. */
|
|
50
|
+
export const settingsPlacingBoardLanes = (
|
|
51
|
+
root: string,
|
|
52
|
+
settings: PlansSettings,
|
|
53
|
+
boardFile: string,
|
|
54
|
+
lane: string | null,
|
|
55
|
+
): PlansSettings =>
|
|
56
|
+
settingsPlacingLanes(root, settings, [...boardLaneNamesOf(settings, boardFile), ...(lane === null ? [] : [lane])])
|
package/src/plans/sent-files.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { clockText, DAY_MS } from '../plan-format/clock'
|
|
|
3
3
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
4
4
|
import { WAITING_KIND, type WaitingItem } from '../plan-format/waiting'
|
|
5
5
|
import { isRecordId } from './record-id'
|
|
6
|
-
import {
|
|
6
|
+
import { type LaneWorktrees, laneOfCwd } from './unlisted-lanes'
|
|
7
7
|
import { isStillWaiting } from './waiting-window'
|
|
8
8
|
|
|
9
9
|
/** The record of a send of files to the user, as the kit hook `sent-file` writes it to `.jackherd/sent/`. */
|
|
@@ -66,10 +66,14 @@ export const isSentFileRecord = (value: unknown): value is SentFileRecord => {
|
|
|
66
66
|
* The lane a send belongs to: the worktree of the first file below `worktreeDirectory`, else the worktree of the
|
|
67
67
|
* directory it was sent from; null for the coordinator.
|
|
68
68
|
*/
|
|
69
|
-
export const sentLaneOf = (
|
|
69
|
+
export const sentLaneOf = (
|
|
70
|
+
{ files, cwd }: SentFileRecord,
|
|
71
|
+
worktreeDirectory: string,
|
|
72
|
+
placed: LaneWorktrees = {},
|
|
73
|
+
): string | null =>
|
|
70
74
|
files
|
|
71
|
-
.map((file) => (isAddress(file) ? null :
|
|
72
|
-
.find((lane) => lane !== null) ??
|
|
75
|
+
.map((file) => (isAddress(file) ? null : laneOfCwd(file, worktreeDirectory, placed)))
|
|
76
|
+
.find((lane) => lane !== null) ?? laneOfCwd(cwd, worktreeDirectory, placed)
|
|
73
77
|
|
|
74
78
|
/** The records without an earlier send of the same first file: an artifact updated each round is one send, the newest. */
|
|
75
79
|
export const latestPerFile = (records: readonly SentFileRecord[]): SentFileRecord[] =>
|
|
@@ -83,13 +87,14 @@ export const sentChipsByLane = (
|
|
|
83
87
|
records: readonly SentFileRecord[],
|
|
84
88
|
worktreeDirectory: string,
|
|
85
89
|
now: number,
|
|
90
|
+
placed: LaneWorktrees = {},
|
|
86
91
|
): Map<string | null, SentChip[]> => {
|
|
87
92
|
const lanes = new Map<string | null, SentChip[]>()
|
|
88
93
|
const recent = latestPerFile([...records].sort((left, right) => left.sentAt - right.sentAt))
|
|
89
94
|
.filter(({ sentAt }) => now - sentAt <= DAY_MS)
|
|
90
95
|
.sort((left, right) => right.sentAt - left.sentAt)
|
|
91
96
|
for (const record of recent) {
|
|
92
|
-
const lane = sentLaneOf(record, worktreeDirectory)
|
|
97
|
+
const lane = sentLaneOf(record, worktreeDirectory, placed)
|
|
93
98
|
const chips = lanes.get(lane) ?? []
|
|
94
99
|
for (const file of record.files) chips.push({ url: addressOf(file), label: labelOf(file, record.caption) })
|
|
95
100
|
lanes.set(lane, chips.slice(0, SHOWN_PER_LANE))
|
package/src/plans/settings.ts
CHANGED
|
@@ -23,6 +23,8 @@ export type PlansSettings = Pick<
|
|
|
23
23
|
minFreeGb: number
|
|
24
24
|
/** The port `jh lane port` starts from (`lanes.basePort`). */
|
|
25
25
|
basePort: number
|
|
26
|
+
/** The ignored paths `jh lane close` deletes with a worktree without asking (`lanes.disposable`). */
|
|
27
|
+
disposablePaths: JackherdConfig['lanes']['disposable']
|
|
26
28
|
/** The command `jh lane dev` starts a lane's dev server with (`lanes.devCommand`); undefined when none is set. */
|
|
27
29
|
devCommand: string | undefined
|
|
28
30
|
/** The shared backend of the lanes (`lanes.backend`); undefined when none is set. */
|
|
@@ -43,6 +45,8 @@ export type PlansSettings = Pick<
|
|
|
43
45
|
sourceLabels: Record<string, string>
|
|
44
46
|
/** The tone of each role in the herd. */
|
|
45
47
|
tones: JackherdConfig['plans']['tones']
|
|
48
|
+
/** The lanes whose worktree directory carries another name, lane name to directory name (`resolveLaneWorktrees`); none from a config. */
|
|
49
|
+
laneWorktrees: Readonly<Record<string, string>>
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
/** What the herd draws of the watcher's sources: from the watcher's state when it keeps them, else from the config. */
|
|
@@ -73,6 +77,7 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
|
|
|
73
77
|
adapter: config.terminal.adapter,
|
|
74
78
|
minFreeGb: config.lanes.minFreeGb ?? DEFAULT_MIN_FREE_GB,
|
|
75
79
|
basePort: config.lanes.basePort ?? DEFAULT_BASE_PORT,
|
|
80
|
+
disposablePaths: config.lanes.disposable,
|
|
76
81
|
devCommand: config.lanes.devCommand,
|
|
77
82
|
backend: config.lanes.backend,
|
|
78
83
|
fetchIntervalMinutes: config.plans.fetchIntervalMinutes,
|
|
@@ -81,6 +86,7 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
|
|
|
81
86
|
context: config.plans.context,
|
|
82
87
|
...herdSourceSettingsOf(config.watch.sources.map(watchedSourceOf)),
|
|
83
88
|
tones: config.plans.tones,
|
|
89
|
+
laneWorktrees: {},
|
|
84
90
|
})
|
|
85
91
|
|
|
86
92
|
/** The herd's slice of `.jackherd/config.json`, validated by the shared schema. */
|
|
@@ -100,8 +106,13 @@ export const boardRepoPathOf = (root: string, settings: PlansSettings, file: str
|
|
|
100
106
|
return path.startsWith(prefix) ? path.slice(prefix.length).split('/').slice(1).join('/') : path
|
|
101
107
|
}
|
|
102
108
|
|
|
109
|
+
/** The worktree of a lane: its adopted directory, else the one carrying its name. The one place a lane name becomes a path. */
|
|
103
110
|
export const worktreeOf = (root: string, settings: PlansSettings, laneName: string): string =>
|
|
104
|
-
join(
|
|
111
|
+
join(
|
|
112
|
+
root,
|
|
113
|
+
settings.worktreeDirectory,
|
|
114
|
+
Object.hasOwn(settings.laneWorktrees, laneName) ? settings.laneWorktrees[laneName] : laneName,
|
|
115
|
+
)
|
|
105
116
|
|
|
106
117
|
/** The remote whose base branch the view reads plans and the board from. */
|
|
107
118
|
export const BASE_REMOTE = 'origin'
|
package/src/plans/snapshot.ts
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
} from './lane-plans'
|
|
56
56
|
import { laneSummary } from './lane-summary'
|
|
57
57
|
import { boardRowTexts, laneTicketsOf, withoutTicketLinks } from './lane-tickets'
|
|
58
|
+
import { placeLanes } from './lane-worktrees'
|
|
58
59
|
import type { LaneView } from './layout'
|
|
59
60
|
import { extractLinkChips } from './link-chips'
|
|
60
61
|
import type { Options } from './options'
|
|
@@ -368,7 +369,9 @@ const laneView = async (
|
|
|
368
369
|
phases: phasesByHref,
|
|
369
370
|
activity,
|
|
370
371
|
guesses: taskSignalsOf(Object.values(phasesByHref).flat(), workText(sources, lane.name, agent), now),
|
|
371
|
-
reported: sources.signals.filter(
|
|
372
|
+
reported: sources.signals.filter(
|
|
373
|
+
({ cwd }) => laneOfDirectory(cwd, sources.worktrees, sources.source.settings.laneWorktrees) === lane.name,
|
|
374
|
+
),
|
|
372
375
|
subagents: laneSubagents?.phaseRuns ?? [],
|
|
373
376
|
subagentTasks: laneSubagents?.taskDescriptions ?? [],
|
|
374
377
|
phaselessReviews: laneSubagents?.phaselessReviews ?? [],
|
|
@@ -446,7 +449,7 @@ const readSignals = async (
|
|
|
446
449
|
const coordinatorOf = (sources: LaneSources): { view: CoordinatorView; shown: TaskSignal | null } | null => {
|
|
447
450
|
const agent = coordinatorAgentOf(sources.agents, basename(sources.source.root))
|
|
448
451
|
if (!agent) return null
|
|
449
|
-
const shown = coordinatorTaskOf(sources.signals, sources.worktrees)
|
|
452
|
+
const shown = coordinatorTaskOf(sources.signals, sources.worktrees, sources.source.settings.laneWorktrees)
|
|
450
453
|
const report = contextOfAgent(agent, sources.readLog, coordinatorSettings(sources.source.settings.context))
|
|
451
454
|
const view = {
|
|
452
455
|
name: agent.name,
|
|
@@ -463,7 +466,7 @@ const unplacedOf = (sources: LaneSources, lanes: readonly SprintLane[], onlyLane
|
|
|
463
466
|
if (onlyLane) return []
|
|
464
467
|
const listed = new Set(lanes.map((lane) => lane.name))
|
|
465
468
|
const rest = sources.signals.filter(({ cwd }) => {
|
|
466
|
-
const lane = laneOfDirectory(cwd, sources.worktrees)
|
|
469
|
+
const lane = laneOfDirectory(cwd, sources.worktrees, sources.source.settings.laneWorktrees)
|
|
467
470
|
return lane === null || !listed.has(lane)
|
|
468
471
|
})
|
|
469
472
|
return assignSignals([], rest).unplanned
|
|
@@ -486,7 +489,10 @@ const readSubagents = async (
|
|
|
486
489
|
): Promise<Map<string, LaneSubagents>> => {
|
|
487
490
|
try {
|
|
488
491
|
if (read) return read(root, worktrees)
|
|
489
|
-
return await readSubagentActivitySliced(
|
|
492
|
+
return await readSubagentActivitySliced(
|
|
493
|
+
subagentSourceOf(root, worktrees, settings.skillPrefix, settings.laneWorktrees),
|
|
494
|
+
gate.pass,
|
|
495
|
+
)
|
|
490
496
|
} catch {
|
|
491
497
|
return new Map()
|
|
492
498
|
}
|
|
@@ -586,8 +592,15 @@ const laneSetOf = async (
|
|
|
586
592
|
const { base, baseCommit, board, sprintDirectory, worktrees, cache, gate } = context
|
|
587
593
|
await gate.pass()
|
|
588
594
|
const { agents, errors } = await readAgents(source)
|
|
595
|
+
const { settings } = placeLanes(
|
|
596
|
+
source.root,
|
|
597
|
+
source.settings,
|
|
598
|
+
agents,
|
|
599
|
+
board.lanes.map((lane) => lane.name),
|
|
600
|
+
)
|
|
601
|
+
const placedSource = { ...source, settings }
|
|
589
602
|
const isWanted = ({ name }: { name: string }) => !source.options.lane || name === source.options.lane
|
|
590
|
-
const subagents = await readSubagents(
|
|
603
|
+
const subagents = await readSubagents(placedSource, worktrees, gate)
|
|
591
604
|
const signals = await readSignals(source, { worktrees, now, gate, cache })
|
|
592
605
|
const planPulls = planPullRequests([...pullRequests.byBranch.values()].flat(), source.settings.planDirectory)
|
|
593
606
|
const headCommits = planPulls.length > 0 ? await refCommits(source.root, PULL_HEAD_NAMESPACE) : new Map()
|
|
@@ -596,7 +609,7 @@ const laneSetOf = async (
|
|
|
596
609
|
lanes: board.lanes.filter(isWanted),
|
|
597
610
|
unlisted: unlistedLanes(agents, listed, worktrees, new Set(subagents.keys())).filter(isWanted),
|
|
598
611
|
sources: {
|
|
599
|
-
source,
|
|
612
|
+
source: placedSource,
|
|
600
613
|
base,
|
|
601
614
|
sprintDirectory,
|
|
602
615
|
agents,
|
|
@@ -763,7 +776,7 @@ export const loadSnapshot = async (
|
|
|
763
776
|
waitingItemsOf(liveBoard, settings.boardLanguage, waitingRecords, now),
|
|
764
777
|
sourceSettings.sourceLabels,
|
|
765
778
|
)
|
|
766
|
-
const sentChips = sentChipsByLane(waitingRecords.sent, worktrees, now)
|
|
779
|
+
const sentChips = sentChipsByLane(waitingRecords.sent, worktrees, now, laneSet.sources.source.settings.laneWorktrees)
|
|
767
780
|
const notice = fallbacks.length > 0 ? fallbackNotice(fallbacks, baseRef, refExists(root, baseRef)) : null
|
|
768
781
|
const unknownLaneTitle =
|
|
769
782
|
options.lane && results.length === 0 ? boardTexts(settings.board.term).laneNotListed(options.lane) : null
|
|
@@ -776,7 +789,9 @@ export const loadSnapshot = async (
|
|
|
776
789
|
sprintDirectory,
|
|
777
790
|
sentChips,
|
|
778
791
|
restNoteOf: (lane) =>
|
|
779
|
-
restNote(
|
|
792
|
+
restNote(
|
|
793
|
+
readLaneReport(laneReportPath(worktreeOf(root, laneSet.sources.source.settings, lane), lane))?.text ?? null,
|
|
794
|
+
),
|
|
780
795
|
})
|
|
781
796
|
await gate.pass()
|
|
782
797
|
const planned = await plannedOf(source, results, {
|