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.
Files changed (51) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +18 -18
  3. package/dist/config.d.ts +1 -0
  4. package/dist/config.js +2 -0
  5. package/herdr-plugin/herdr-plugin.toml +1 -1
  6. package/install.sh +1 -1
  7. package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -8
  8. package/kit/workflow/skills/orchestrate/references/lane-session.md +1 -1
  9. package/package.json +1 -1
  10. package/schemas/jackherd.schema.json +8 -0
  11. package/src/brief/lane-facts.ts +30 -18
  12. package/src/config/schema.ts +2 -0
  13. package/src/context/command.ts +3 -1
  14. package/src/lane/close-check.ts +51 -15
  15. package/src/lane/close-command.ts +41 -8
  16. package/src/lane/close-survey.ts +9 -0
  17. package/src/lane/close.ts +54 -6
  18. package/src/lane/send-options.ts +3 -2
  19. package/src/lane/send.ts +8 -1
  20. package/src/lane/subagent-view.ts +21 -0
  21. package/src/lane-prompt/command.ts +4 -1
  22. package/src/plans/active-tasks.ts +4 -2
  23. package/src/plans/activity-read.ts +3 -2
  24. package/src/plans/command.ts +3 -1
  25. package/src/plans/coordinator-row.ts +7 -2
  26. package/src/plans/ensure-panes.ts +2 -2
  27. package/src/plans/lane-discovery.ts +42 -3
  28. package/src/plans/lane-worktrees.ts +56 -0
  29. package/src/plans/sent-files.ts +10 -5
  30. package/src/plans/settings.ts +12 -1
  31. package/src/plans/snapshot.ts +23 -8
  32. package/src/plans/subagents-read.ts +17 -2
  33. package/src/plans/subagents.ts +3 -2
  34. package/src/plans/unlisted-lanes.ts +13 -0
  35. package/src/resume/command.ts +1 -1
  36. package/src/resume/coordinator-read.ts +12 -12
  37. package/src/resume/decision.ts +3 -0
  38. package/src/resume/record.ts +24 -5
  39. package/src/sync/command.ts +16 -2
  40. package/src/sync/downgrade.ts +9 -0
  41. package/src/sync/if-changed.ts +6 -7
  42. package/src/update/command.ts +17 -0
  43. package/src/update/registry-wait.ts +44 -0
  44. package/src/update/update.ts +23 -0
  45. package/src/version/registry.ts +8 -0
  46. package/src/watch/command.ts +28 -14
  47. package/src/watch/obsolete-proposals.ts +8 -6
  48. package/src/watch/obsolete-records.ts +3 -1
  49. package/src/watch/running-subagents.ts +3 -2
  50. package/src/watch/task-signal-read.ts +4 -1
  51. 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 settings and the lane's name, never from a reply. */
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, and marks the board last, so
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
@@ -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
- Sends the lane's prompt file (default: .claude/handover/<lane>-prompt.md of the main checkout) to its agent and waits
11
- until it works. On a stall it reads the screen: a lane at work counts as sent, a prompt on the screen gets enter (exit 1 if that does not start the lane), else the
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. A stall or a timeout does not prove the text is lost:
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([worktreeOf(root, settings, options.lane)]),
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
- return directory.slice(prefix.length).split(sep)[0] || null
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 { laneDirectoryOf } from './unlisted-lanes'
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 : laneDirectoryOf(cwd, worktreeDirectory)
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)
@@ -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 = (signals: readonly TaskSignal[], worktrees: string): TaskSignal | null =>
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: join(root, settings.worktreeDirectory, lane.name) }))
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 no discovery can find: an agent named after a lane row runs in a worktree whose directory carries
55
- * another name, as after taking over a worktree started for something else. Every reader derives a lane's worktree
56
- * from its name (`worktreeOf`), so such a lane is not watched; a lane watched anyway is left out.
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])])
@@ -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 { laneDirectoryOf } from './unlisted-lanes'
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 = ({ files, cwd }: SentFileRecord, worktreeDirectory: string): string | null =>
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 : laneDirectoryOf(file, worktreeDirectory)))
72
- .find((lane) => lane !== null) ?? laneDirectoryOf(cwd, worktreeDirectory)
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))
@@ -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(root, settings.worktreeDirectory, laneName)
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'
@@ -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(({ cwd }) => laneOfDirectory(cwd, sources.worktrees) === lane.name),
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(subagentSourceOf(root, worktrees, settings.skillPrefix), gate.pass)
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(source, worktrees, gate)
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(readLaneReport(laneReportPath(worktreeOf(root, settings, lane), lane))?.text ?? null),
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, {