jackherd 1.26.0 → 1.26.2
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 +11 -0
- package/README.md +18 -18
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/workflow/skills/orchestrate/references/lane-session.md +2 -0
- package/package.json +1 -1
- package/src/brief/lane-facts.ts +30 -18
- package/src/check/skill-budget-defaults.ts +3 -2
- package/src/context/command.ts +3 -1
- package/src/lane/close-command.ts +5 -3
- package/src/lane/close.ts +1 -1
- 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 +87 -0
- package/src/plans/lane-worktrees.ts +56 -0
- package/src/plans/sent-files.ts +10 -5
- package/src/plans/settings.ts +9 -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 +23 -13
- 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 +46 -13
- package/src/watch/lane-watch.ts +13 -0
- 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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
+
import { laneDirectoryOf } from './unlisted-lanes'
|
|
2
3
|
|
|
3
4
|
/** What the watcher finds a lane by: the worktree directories, the herdr agent names, the sprint's lane rows. */
|
|
4
5
|
export type LaneSources = {
|
|
@@ -9,6 +10,20 @@ export type LaneSources = {
|
|
|
9
10
|
|
|
10
11
|
export type LaneSetChange = { added: string[]; removed: string[] }
|
|
11
12
|
|
|
13
|
+
/** A herdr agent by its name and the directory it runs in, null when herdr gives none. */
|
|
14
|
+
export type AgentPlace = { name: string; cwd: string | null }
|
|
15
|
+
|
|
16
|
+
/** What `misplacedLanes` compares: the agents, the board's lane rows, the lanes watched now, the absolute worktree directory. */
|
|
17
|
+
export type MisplacementSources = {
|
|
18
|
+
agents: readonly AgentPlace[]
|
|
19
|
+
sprintLanes: readonly string[]
|
|
20
|
+
watched: readonly string[]
|
|
21
|
+
worktreeDirectory: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A board lane whose agent runs in a worktree named otherwise, `worktree` being that directory's name. */
|
|
25
|
+
export type MisplacedLane = { lane: string; worktree: string }
|
|
26
|
+
|
|
12
27
|
/**
|
|
13
28
|
* The helper agents of the lanes: an agent named `<lane>-<purpose>` that a lane started in a pane of its own, such as
|
|
14
29
|
* for a measurement. A name that is a worktree of its own is that lane, not a helper. The prefix only decides whether
|
|
@@ -31,6 +46,78 @@ export const discoverLanes = ({ worktrees, agentNames, sprintLanes }: LaneSource
|
|
|
31
46
|
return [...lanes, ...helpersOf(lanes, worktrees, agentNames)].sort()
|
|
32
47
|
}
|
|
33
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
|
+
|
|
88
|
+
/** An agent named after a board lane that nobody watches, running in a worktree that carries another name. */
|
|
89
|
+
const isMisplaced = (name: string, worktree: string, sprintLanes: readonly string[], watched: readonly string[]) =>
|
|
90
|
+
worktree !== name && sprintLanes.includes(name) && !watched.includes(name)
|
|
91
|
+
|
|
92
|
+
/**
|
|
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.
|
|
96
|
+
*/
|
|
97
|
+
export const misplacedLanes = ({
|
|
98
|
+
agents,
|
|
99
|
+
sprintLanes,
|
|
100
|
+
watched,
|
|
101
|
+
worktreeDirectory,
|
|
102
|
+
}: MisplacementSources): MisplacedLane[] => {
|
|
103
|
+
const byLane = new Map<string, string>()
|
|
104
|
+
for (const { name, cwd } of agents) {
|
|
105
|
+
const worktree = cwd === null ? null : laneDirectoryOf(cwd, worktreeDirectory)
|
|
106
|
+
if (worktree !== null && isMisplaced(name, worktree, sprintLanes, watched) && !byLane.has(name))
|
|
107
|
+
byLane.set(name, worktree)
|
|
108
|
+
}
|
|
109
|
+
return [...byLane]
|
|
110
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
111
|
+
.map(([lane, worktree]) => ({ lane, worktree }))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `lane <name> not watched: its agent runs in worktree <dir>, not <name>`, one line per lane. */
|
|
115
|
+
export const misplacedLaneLines = (lanes: readonly MisplacedLane[]): string[] =>
|
|
116
|
+
lanes.map(({ lane, worktree }) => {
|
|
117
|
+
const name = terminalSafe(lane)
|
|
118
|
+
return `lane ${name} not watched: its agent runs in worktree ${terminalSafe(worktree)}, not ${name}`
|
|
119
|
+
})
|
|
120
|
+
|
|
34
121
|
/** The lanes that joined and left between two discoveries, each sorted. */
|
|
35
122
|
export const laneSetChange = (previous: readonly string[], next: readonly string[]): LaneSetChange => ({
|
|
36
123
|
added: next.filter((lane) => !previous.includes(lane)).sort(),
|
|
@@ -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
|
@@ -43,6 +43,8 @@ export type PlansSettings = Pick<
|
|
|
43
43
|
sourceLabels: Record<string, string>
|
|
44
44
|
/** The tone of each role in the herd. */
|
|
45
45
|
tones: JackherdConfig['plans']['tones']
|
|
46
|
+
/** The lanes whose worktree directory carries another name, lane name to directory name (`resolveLaneWorktrees`); none from a config. */
|
|
47
|
+
laneWorktrees: Readonly<Record<string, string>>
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
/** What the herd draws of the watcher's sources: from the watcher's state when it keeps them, else from the config. */
|
|
@@ -81,6 +83,7 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
|
|
|
81
83
|
context: config.plans.context,
|
|
82
84
|
...herdSourceSettingsOf(config.watch.sources.map(watchedSourceOf)),
|
|
83
85
|
tones: config.plans.tones,
|
|
86
|
+
laneWorktrees: {},
|
|
84
87
|
})
|
|
85
88
|
|
|
86
89
|
/** The herd's slice of `.jackherd/config.json`, validated by the shared schema. */
|
|
@@ -100,8 +103,13 @@ export const boardRepoPathOf = (root: string, settings: PlansSettings, file: str
|
|
|
100
103
|
return path.startsWith(prefix) ? path.slice(prefix.length).split('/').slice(1).join('/') : path
|
|
101
104
|
}
|
|
102
105
|
|
|
106
|
+
/** The worktree of a lane: its adopted directory, else the one carrying its name. The one place a lane name becomes a path. */
|
|
103
107
|
export const worktreeOf = (root: string, settings: PlansSettings, laneName: string): string =>
|
|
104
|
-
join(
|
|
108
|
+
join(
|
|
109
|
+
root,
|
|
110
|
+
settings.worktreeDirectory,
|
|
111
|
+
Object.hasOwn(settings.laneWorktrees, laneName) ? settings.laneWorktrees[laneName] : laneName,
|
|
112
|
+
)
|
|
105
113
|
|
|
106
114
|
/** The remote whose base branch the view reads plans and the board from. */
|
|
107
115
|
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, {
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type SubagentLog,
|
|
15
15
|
subagentsByLane,
|
|
16
16
|
} from './subagents'
|
|
17
|
+
import type { LaneWorktrees } from './unlisted-lanes'
|
|
17
18
|
|
|
18
19
|
export type SubagentSource = {
|
|
19
20
|
projects: string
|
|
@@ -23,16 +24,24 @@ export type SubagentSource = {
|
|
|
23
24
|
approvalsRoot: string
|
|
24
25
|
/** The absolute worktree directory. */
|
|
25
26
|
worktreeDirectory: string
|
|
27
|
+
/** The lanes placed in worktrees of another name (`PlansSettings.laneWorktrees`). */
|
|
28
|
+
laneWorktrees?: LaneWorktrees
|
|
26
29
|
/** The project's `skillPrefix`, which its copies of the review agents carry; without it only the default one counts. */
|
|
27
30
|
skillPrefix?: string
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
/** The source of the main checkout `root`: Claude Code's own session logs, the gate's requests in the checkout itself. */
|
|
31
|
-
export const subagentSourceOf = (
|
|
34
|
+
export const subagentSourceOf = (
|
|
35
|
+
root: string,
|
|
36
|
+
worktreeDirectory: string,
|
|
37
|
+
skillPrefix?: string,
|
|
38
|
+
laneWorktrees?: LaneWorktrees,
|
|
39
|
+
): SubagentSource => ({
|
|
32
40
|
projects: defaultProjectsDirectory(),
|
|
33
41
|
root,
|
|
34
42
|
approvalsRoot: root,
|
|
35
43
|
worktreeDirectory,
|
|
44
|
+
laneWorktrees,
|
|
36
45
|
skillPrefix,
|
|
37
46
|
})
|
|
38
47
|
|
|
@@ -181,5 +190,11 @@ export const readSubagentActivitySliced = async (
|
|
|
181
190
|
logs.push(...logsInSession(directory, now))
|
|
182
191
|
}
|
|
183
192
|
const running = runningOf(logs, source, now)
|
|
184
|
-
return withSubagentLines(
|
|
193
|
+
return withSubagentLines(
|
|
194
|
+
subagentsByLane(running, source.worktreeDirectory, source.laneWorktrees),
|
|
195
|
+
running,
|
|
196
|
+
logs,
|
|
197
|
+
source.worktreeDirectory,
|
|
198
|
+
source.laneWorktrees,
|
|
199
|
+
)
|
|
185
200
|
}
|
package/src/plans/subagents.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { AGENT_STATUS, type AgentStatus } from '../terminal/types'
|
|
|
7
7
|
import { activityTextOf } from './current-task'
|
|
8
8
|
import { type PhaselessReview, type SubagentPhaseRun, subagentPhaseRunOf } from './subagent-phases'
|
|
9
9
|
import type { DescribedSubagent } from './subagent-tasks'
|
|
10
|
-
import {
|
|
10
|
+
import { type LaneWorktrees, laneOfCwd } from './unlisted-lanes'
|
|
11
11
|
|
|
12
12
|
/** What the log files of one subagent say: its id, its `meta.json` as parsed, when the log was written, its last lines. */
|
|
13
13
|
export type SubagentLog = { id: string; meta: unknown; modifiedAt: number; tail: readonly string[] }
|
|
@@ -156,11 +156,12 @@ const phaselessReviewOf = ({ phaseRun, description }: RunningSubagent): Phaseles
|
|
|
156
156
|
export const subagentsByLane = (
|
|
157
157
|
running: readonly RunningSubagent[],
|
|
158
158
|
worktreeDirectory: string,
|
|
159
|
+
placed: LaneWorktrees = {},
|
|
159
160
|
): Map<string, SubagentActivity> => {
|
|
160
161
|
const lanes = new Map<string, SubagentActivity>()
|
|
161
162
|
for (const subagent of running) {
|
|
162
163
|
const { cwd, status, description, activity } = subagent
|
|
163
|
-
const lane = cwd === null ? null :
|
|
164
|
+
const lane = cwd === null ? null : laneOfCwd(cwd, worktreeDirectory, placed)
|
|
164
165
|
if (lane === null) continue
|
|
165
166
|
const known = lanes.get(lane)
|
|
166
167
|
const runs = namedRunOf(subagent)
|
|
@@ -14,6 +14,19 @@ export const laneDirectoryOf = (cwd: string, worktreeDirectory: string): string
|
|
|
14
14
|
return below.split(sep)[0]
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** The lanes whose worktree directory carries another name, lane name to directory name (`PlansSettings.laneWorktrees`). */
|
|
18
|
+
export type LaneWorktrees = Readonly<Record<string, string>>
|
|
19
|
+
|
|
20
|
+
/** The inverse of `worktreeOf`'s naming: the lane that works in the worktree directory `directory`, which is `directory` itself unless a lane was placed there. */
|
|
21
|
+
export const laneOfWorktree = (directory: string, placed: LaneWorktrees): string =>
|
|
22
|
+
Object.entries(placed).find(([, placedDirectory]) => placedDirectory === directory)?.[0] ?? directory
|
|
23
|
+
|
|
24
|
+
/** The lane an agent or a record belongs to by the directory `cwd` lies in: `laneDirectoryOf`, read through the lanes placed in other worktrees. */
|
|
25
|
+
export const laneOfCwd = (cwd: string, worktreeDirectory: string, placed: LaneWorktrees = {}): string | null => {
|
|
26
|
+
const directory = laneDirectoryOf(cwd, worktreeDirectory)
|
|
27
|
+
return directory === null ? null : laneOfWorktree(directory, placed)
|
|
28
|
+
}
|
|
29
|
+
|
|
17
30
|
/** An unlisted lane as a sprint lane: its name and nothing else, no port, no steps. */
|
|
18
31
|
export const unlistedSprintLane = (name: string): SprintLane => ({ name, port: null, steps: [], closed: false })
|
|
19
32
|
|
package/src/resume/command.ts
CHANGED
|
@@ -174,7 +174,7 @@ const awaitCoordinator = async (ports: ResumePorts, decision: Resume): Promise<s
|
|
|
174
174
|
const resumeLanes = async (ports: ResumePorts, decision: Resume, worktreeDirectory: string, text: string) => {
|
|
175
175
|
const recipients = decision.interrupted.map((name) => ({
|
|
176
176
|
name,
|
|
177
|
-
directory: join(decision.root, worktreeDirectory, name),
|
|
177
|
+
directory: join(decision.root, worktreeDirectory, decision.laneWorktrees[name] ?? name),
|
|
178
178
|
}))
|
|
179
179
|
const search = await awaitTargets(ports, decision.root, recipients, RESUME_LIMITS.lanes, (target) =>
|
|
180
180
|
sendTo(ports, decision.root, target, text),
|
|
@@ -6,8 +6,9 @@ import { parseSprintLanes } from '../plan-format'
|
|
|
6
6
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
7
7
|
import { boardFor, foundLanesOf } from '../plans/board-pick'
|
|
8
8
|
import { errorText } from '../plans/error-text'
|
|
9
|
-
import { mainCheckout
|
|
9
|
+
import { mainCheckout } from '../plans/git'
|
|
10
10
|
import { discoverLanes } from '../plans/lane-discovery'
|
|
11
|
+
import { placeLanes } from '../plans/lane-worktrees'
|
|
11
12
|
import { type PlansSettings, readSettings, worktreeOf } from '../plans/settings'
|
|
12
13
|
import { recordWaitingItems } from '../plans/waiting-records'
|
|
13
14
|
import { readApprovalRequests } from '../watch/approvals'
|
|
@@ -15,8 +16,8 @@ import { readGithub } from '../watch/github-read'
|
|
|
15
16
|
import { listAgents } from '../watch/herdr'
|
|
16
17
|
import { laneReportPath, readLaneReport, reportMtime } from '../watch/lane-report-file'
|
|
17
18
|
import { branchOf } from '../watch/lane-sources'
|
|
18
|
-
import type
|
|
19
|
-
import { REFERENCE, referenceLine } from '../watch/reference-paths'
|
|
19
|
+
import { agentPlacesOf, type HerdrAgent, misplacedLinesOf } from '../watch/lane-watch'
|
|
20
|
+
import { annotateEach, REFERENCE, referenceLine } from '../watch/reference-paths'
|
|
20
21
|
import {
|
|
21
22
|
type CoordinatorStateInput,
|
|
22
23
|
coordinatorStateLines,
|
|
@@ -99,21 +100,24 @@ const readPullRequestsSafely = async (
|
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
})
|
|
103
|
+
const sprintLanesOf = ({ settings }: Place, board: string | null): string[] =>
|
|
104
|
+
board === null ? [] : parseSprintLanes(board, settings.boardLanguage).map((lane) => lane.name)
|
|
105
|
+
|
|
106
|
+
const lanesOf = (sprintLanes: string[], agents: readonly HerdrAgent[] | null, worktrees: readonly string[]): string[] =>
|
|
107
|
+
discoverLanes({ worktrees, agentNames: (agents ?? []).flatMap((agent) => agent.name ?? []), sprintLanes })
|
|
108
108
|
|
|
109
109
|
/** `jh resume --coordinator`: prints what a cleared coordinator takes up, then the reference on it. Reads only. */
|
|
110
110
|
export const printCoordinatorState = async (boardPath: string | null): Promise<void> => {
|
|
111
|
-
const
|
|
112
|
-
const { root,
|
|
111
|
+
const found = placeOf(boardPath)
|
|
112
|
+
const { root, boardFile } = found
|
|
113
113
|
const now = Date.now()
|
|
114
114
|
const board = readBoard(boardFile)
|
|
115
115
|
const agents = readAgents()
|
|
116
|
-
const
|
|
116
|
+
const sprintLanes = sprintLanesOf(found, board)
|
|
117
|
+
const placed = placeLanes(root, found.settings, agentPlacesOf(agents ?? []), sprintLanes)
|
|
118
|
+
const place = { ...found, settings: placed.settings }
|
|
119
|
+
const { settings } = place
|
|
120
|
+
const lanes = lanesOf(sprintLanes, agents, placed.worktrees)
|
|
117
121
|
const lines = coordinatorStateLines({
|
|
118
122
|
boardPath: relative(root, boardFile),
|
|
119
123
|
board,
|
|
@@ -125,8 +129,14 @@ export const printCoordinatorState = async (boardPath: string | null): Promise<v
|
|
|
125
129
|
records: recordWaitingItems(root, now),
|
|
126
130
|
now,
|
|
127
131
|
})
|
|
132
|
+
const misplaced = misplacedLinesOf(agents ?? [], {
|
|
133
|
+
sprintLanes,
|
|
134
|
+
watched: lanes,
|
|
135
|
+
worktreeDirectory: join(root, settings.worktreeDirectory),
|
|
136
|
+
})
|
|
137
|
+
const misplacedHint = misplaced.length === 0 ? '' : installedReferenceHint(root, REFERENCE.laneSession)
|
|
128
138
|
const hint = installedReferenceHint(root, REFERENCE.coordinatorClear)
|
|
129
|
-
for (const line of [...lines, ...referenceLine(hint)]) console.log(line)
|
|
139
|
+
for (const line of [...lines, ...annotateEach(misplaced, misplacedHint), ...referenceLine(hint)]) console.log(line)
|
|
130
140
|
}
|
|
131
141
|
|
|
132
142
|
/** `jh resume --clear-line`: prints the first input of the chat after a clear, for the script's `--send`. */
|
package/src/resume/decision.ts
CHANGED
|
@@ -49,6 +49,8 @@ export type ResumeDecision =
|
|
|
49
49
|
coordinatorIdentity: CoordinatorIdentity | null
|
|
50
50
|
/** The interrupted lanes with a model column on record, each with that column made printable; only logged. */
|
|
51
51
|
laneModels: Record<string, string>
|
|
52
|
+
/** Lane → worktree directory of the lanes placed in a directory of another name. */
|
|
53
|
+
laneWorktrees: Record<string, string>
|
|
52
54
|
/** Lane names that are no single file name: never sent to, only logged. */
|
|
53
55
|
unsafe: string[]
|
|
54
56
|
}
|
|
@@ -119,6 +121,7 @@ export const resumeDecision = ({ record, now, watcherRunning, boardExists }: Res
|
|
|
119
121
|
coordinator: record.coordinator,
|
|
120
122
|
coordinatorIdentity: record.coordinatorIdentity ?? null,
|
|
121
123
|
laneModels: modelsOf(record, interrupted),
|
|
124
|
+
laneWorktrees: record.laneWorktrees ?? {},
|
|
122
125
|
interrupted,
|
|
123
126
|
blocked: lanesWith(record.lanes, AGENT_STATUS.blocked),
|
|
124
127
|
unsafe: Object.keys(record.lanes).filter((name) => !isSafeName(name)),
|
package/src/resume/record.ts
CHANGED
|
@@ -36,6 +36,8 @@ export type ResumeRecord = {
|
|
|
36
36
|
lanes: Record<string, string>
|
|
37
37
|
/** Lane → the model column of its board row as the watcher last read it; absent in older records. */
|
|
38
38
|
laneModels?: Record<string, string>
|
|
39
|
+
/** Lane → the worktree directory of a lane whose directory carries another name (`PlansSettings.laneWorktrees`); absent in older records and while no lane is placed so. */
|
|
40
|
+
laneWorktrees?: Record<string, string>
|
|
39
41
|
stoppedAt: number | null
|
|
40
42
|
resumedAt: number | null
|
|
41
43
|
}
|
|
@@ -70,6 +72,7 @@ export const isResumeRecord = (value: unknown): value is ResumeRecord =>
|
|
|
70
72
|
isLaneStatuses(value.lanes) &&
|
|
71
73
|
(value.coordinatorIdentity === undefined || isCoordinatorIdentity(value.coordinatorIdentity)) &&
|
|
72
74
|
(value.laneModels === undefined || isLaneStatuses(value.laneModels)) &&
|
|
75
|
+
(value.laneWorktrees === undefined || isLaneStatuses(value.laneWorktrees)) &&
|
|
73
76
|
'stoppedAt' in value &&
|
|
74
77
|
isTimeOrNull(value.stoppedAt) &&
|
|
75
78
|
'resumedAt' in value &&
|
|
@@ -129,8 +132,20 @@ export const withLaneModels = (record: ResumeRecord, laneModels: Record<string,
|
|
|
129
132
|
return sameStatuses(record.laneModels ?? {}, merged) ? record : { ...record, laneModels: merged, savedAt: now }
|
|
130
133
|
}
|
|
131
134
|
|
|
132
|
-
/**
|
|
133
|
-
export
|
|
135
|
+
/** The record with the lanes placed in worktrees of another name, as the watcher has them now; the same object when unchanged. */
|
|
136
|
+
export const withLaneWorktrees = (
|
|
137
|
+
record: ResumeRecord,
|
|
138
|
+
laneWorktrees: Record<string, string>,
|
|
139
|
+
now: number,
|
|
140
|
+
): ResumeRecord =>
|
|
141
|
+
sameStatuses(record.laneWorktrees ?? {}, laneWorktrees) ? record : { ...record, laneWorktrees, savedAt: now }
|
|
142
|
+
|
|
143
|
+
/** What the watcher saw at one poll besides the lane statuses; the parts may be unknown. */
|
|
144
|
+
export type Observed = {
|
|
145
|
+
coordinator?: CoordinatorIdentity | null
|
|
146
|
+
laneModels?: Record<string, string>
|
|
147
|
+
laneWorktrees?: Record<string, string>
|
|
148
|
+
}
|
|
134
149
|
|
|
135
150
|
/**
|
|
136
151
|
* How often the watcher saves its record although no lane changed: once an hour, so a watcher that rests longer than
|
|
@@ -150,9 +165,13 @@ export const followedRecord = (
|
|
|
150
165
|
observed: Observed = {},
|
|
151
166
|
): ResumeRecord =>
|
|
152
167
|
refreshedRecord(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
168
|
+
withLaneWorktrees(
|
|
169
|
+
withLaneModels(
|
|
170
|
+
withIdentity(withLanes(record, lanes, now), observed.coordinator ?? null, now),
|
|
171
|
+
observed.laneModels ?? {},
|
|
172
|
+
now,
|
|
173
|
+
),
|
|
174
|
+
observed.laneWorktrees ?? record.laneWorktrees ?? {},
|
|
156
175
|
now,
|
|
157
176
|
),
|
|
158
177
|
now,
|
package/src/sync/command.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
runWithKit,
|
|
11
11
|
usageError,
|
|
12
12
|
} from './command-context'
|
|
13
|
+
import { downgradeRefusal, isDowngrade } from './downgrade'
|
|
13
14
|
import { errorMessage } from './error-message'
|
|
14
15
|
import { readForeignSkills } from './foreign-skills'
|
|
15
16
|
import { dailyUpdateNotice, defaultUpdateCheck, lockedVersion, syncNeeded, type UpdateCheck } from './if-changed'
|
|
@@ -102,8 +103,20 @@ const syncGlobalIfChanged = async (context: CommandContext, accept: string[], ch
|
|
|
102
103
|
return exitCode
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
/** The refusal line when the running kit is older than the one that wrote the project's lock; null otherwise, and when the kit cannot be located (the sync reports that itself). */
|
|
107
|
+
const projectDowngradeRefusal = async (context: CommandContext): Promise<string | null> => {
|
|
108
|
+
try {
|
|
109
|
+
const [running, locked] = [(await context.locate()).version, await lockedVersion(context.projectRoot)]
|
|
110
|
+
return isDowngrade(running, locked) ? downgradeRefusal(running, locked) : null
|
|
111
|
+
} catch {
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const syncProject = async (context: CommandContext, accept: string[], dryRun: boolean): Promise<number> => {
|
|
117
|
+
const refusal = await projectDowngradeRefusal(context)
|
|
118
|
+
if (refusal) return usageError(context, refusal)
|
|
119
|
+
return runWithKit(
|
|
107
120
|
context,
|
|
108
121
|
'sync',
|
|
109
122
|
async (kit) => {
|
|
@@ -116,6 +129,7 @@ const syncProject = (context: CommandContext, accept: string[], dryRun: boolean)
|
|
|
116
129
|
},
|
|
117
130
|
dryRun,
|
|
118
131
|
)
|
|
132
|
+
}
|
|
119
133
|
|
|
120
134
|
export const runSyncCommand = async (
|
|
121
135
|
args: string[],
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { CLI_NAME, isNewerVersion } from '../version/release'
|
|
2
|
+
|
|
3
|
+
/** True when the repo's lock was written by a newer kit than the one running: a sync would turn its files back. */
|
|
4
|
+
export const isDowngrade = (running: string, locked: string | null): locked is string =>
|
|
5
|
+
locked !== null && isNewerVersion(locked, running)
|
|
6
|
+
|
|
7
|
+
/** The line a refused sync prints; only plain release versions reach it, since `isDowngrade` admits no other. */
|
|
8
|
+
export const downgradeRefusal = (running: string, locked: string): string =>
|
|
9
|
+
`${CLI_NAME} sync: the repository was synced by ${CLI_NAME} ${locked}, but this ${CLI_NAME} is ${running}, so nothing was synced: it would turn newer kit files back. Update this ${CLI_NAME} with \`${CLI_NAME} update\`, then sync again.`
|