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.
Files changed (46) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +18 -18
  3. package/herdr-plugin/herdr-plugin.toml +1 -1
  4. package/install.sh +1 -1
  5. package/kit/workflow/skills/orchestrate/references/lane-session.md +2 -0
  6. package/package.json +1 -1
  7. package/src/brief/lane-facts.ts +30 -18
  8. package/src/check/skill-budget-defaults.ts +3 -2
  9. package/src/context/command.ts +3 -1
  10. package/src/lane/close-command.ts +5 -3
  11. package/src/lane/close.ts +1 -1
  12. package/src/lane/send-options.ts +3 -2
  13. package/src/lane/send.ts +8 -1
  14. package/src/lane/subagent-view.ts +21 -0
  15. package/src/lane-prompt/command.ts +4 -1
  16. package/src/plans/active-tasks.ts +4 -2
  17. package/src/plans/activity-read.ts +3 -2
  18. package/src/plans/command.ts +3 -1
  19. package/src/plans/coordinator-row.ts +7 -2
  20. package/src/plans/ensure-panes.ts +2 -2
  21. package/src/plans/lane-discovery.ts +87 -0
  22. package/src/plans/lane-worktrees.ts +56 -0
  23. package/src/plans/sent-files.ts +10 -5
  24. package/src/plans/settings.ts +9 -1
  25. package/src/plans/snapshot.ts +23 -8
  26. package/src/plans/subagents-read.ts +17 -2
  27. package/src/plans/subagents.ts +3 -2
  28. package/src/plans/unlisted-lanes.ts +13 -0
  29. package/src/resume/command.ts +1 -1
  30. package/src/resume/coordinator-read.ts +23 -13
  31. package/src/resume/decision.ts +3 -0
  32. package/src/resume/record.ts +24 -5
  33. package/src/sync/command.ts +16 -2
  34. package/src/sync/downgrade.ts +9 -0
  35. package/src/sync/if-changed.ts +6 -7
  36. package/src/update/command.ts +17 -0
  37. package/src/update/registry-wait.ts +44 -0
  38. package/src/update/update.ts +23 -0
  39. package/src/version/registry.ts +8 -0
  40. package/src/watch/command.ts +46 -13
  41. package/src/watch/lane-watch.ts +13 -0
  42. package/src/watch/obsolete-proposals.ts +8 -6
  43. package/src/watch/obsolete-records.ts +3 -1
  44. package/src/watch/running-subagents.ts +3 -2
  45. package/src/watch/task-signal-read.ts +4 -1
  46. package/src/watch/unlinked-artifacts-read.ts +3 -1
@@ -2,7 +2,8 @@ import { readFile } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
  import { LEGACY_MANIFEST_PATH } from '../config/legacy-names'
4
4
  import { MANIFEST_FILE, TOOL_DIRECTORY } from '../config/location'
5
- import { CLI_NAME, isNewerVersion, PACKAGE_NAME } from '../version/release'
5
+ import { REGISTRY_TIMEOUT_MS, registryUrl } from '../version/registry'
6
+ import { CLI_NAME, isNewerVersion } from '../version/release'
6
7
  import { parseManifest } from './manifest'
7
8
  import { FILE_MODE } from './plan-file'
8
9
  import { assertOwnDirectoriesInProject, writeFileSafely } from './project-fs'
@@ -18,8 +19,6 @@ export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
18
19
  /** How soon npm is asked again after it gave no answer: an hour, so an offline morning does not cost the day. */
19
20
  export const UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000
20
21
 
21
- const REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`
22
- const REGISTRY_TIMEOUT_MS = 3_000
23
22
  const UPDATE_STATE_FILE = 'update-check.json'
24
23
 
25
24
  /**
@@ -55,9 +54,9 @@ const versionOfLock = async (path: string): Promise<string | null> => {
55
54
  return parsed.ok ? parsed.manifest.kitVersion : null
56
55
  }
57
56
 
58
- /** The kit version the global lock was written by, under its old name while the global sync has not moved it. */
59
- export const lockedVersion = async (home: string): Promise<string | null> =>
60
- (await versionOfLock(join(home, TOOL_DIRECTORY, MANIFEST_FILE))) ?? versionOfLock(join(home, LEGACY_MANIFEST_PATH))
57
+ /** The kit version the lock under `root` (the home directory or a project) was written by, under its old name while a sync has not moved it. */
58
+ export const lockedVersion = async (root: string): Promise<string | null> =>
59
+ (await versionOfLock(join(root, TOOL_DIRECTORY, MANIFEST_FILE))) ?? versionOfLock(join(root, LEGACY_MANIFEST_PATH))
61
60
 
62
61
  const readUpdateState = async (path: string): Promise<UpdateState | null> => {
63
62
  const raw = await readJson(path)
@@ -107,7 +106,7 @@ export const dailyUpdateNotice = async (home: string, installed: string, check:
107
106
  /** Asks the npm registry for the `latest` tag; null on any failure, after a few seconds at most. */
108
107
  const registryLatestVersion = async (): Promise<string | null> => {
109
108
  try {
110
- const response = await fetch(REGISTRY_LATEST, { signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS) })
109
+ const response = await fetch(registryUrl('latest'), { signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS) })
111
110
  if (!response.ok) return null
112
111
  const { version } = (await response.json()) as { version?: unknown }
113
112
  return typeof version === 'string' ? version : null
@@ -13,9 +13,11 @@ import { findProjectRoot } from '../config/location'
13
13
  import { defaultUpdateCheck, lockedVersion } from '../sync/if-changed'
14
14
  import { locateKit } from '../sync/kit-source'
15
15
  import { readInstalledVersion } from '../version/installed-version'
16
+ import { REGISTRY_TIMEOUT_MS, registryUrl } from '../version/registry'
16
17
  import { CLI_NAME } from '../version/release'
17
18
  import { rewriteConfigShares } from './config-shares'
18
19
  import { detectPackageManager, legacyPackageRoot, shellHostedGlobalRoot } from './package-manager'
20
+ import { VERSION_AVAILABILITY, type VersionAvailability } from './registry-wait'
19
21
  import { runUpdate, UPDATE_EXIT, type UpdateAdapters } from './update'
20
22
 
21
23
  const USAGE = `Usage: ${CLI_NAME} update`
@@ -35,6 +37,19 @@ const runInherited = async (argv: readonly string[], cwd: string): Promise<numbe
35
37
  }
36
38
  }
37
39
 
40
+ const NOT_FOUND = 404
41
+
42
+ /** Asks the registry for one exact version: 404 is `missing`, any other failure `unknown`. */
43
+ const registryAvailability = async (version: string): Promise<VersionAvailability> => {
44
+ try {
45
+ const response = await fetch(registryUrl(version), { signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS) })
46
+ if (response.ok) return VERSION_AVAILABILITY.available
47
+ return response.status === NOT_FOUND ? VERSION_AVAILABILITY.missing : VERSION_AVAILABILITY.unknown
48
+ } catch {
49
+ return VERSION_AVAILABILITY.unknown
50
+ }
51
+ }
52
+
38
53
  const adaptersFor = async (): Promise<UpdateAdapters> => {
39
54
  const { version, packageRoot } = await locateKit()
40
55
  const home = homedir()
@@ -44,6 +59,8 @@ const adaptersFor = async (): Promise<UpdateAdapters> => {
44
59
  installedVersion: version,
45
60
  manager: detectPackageManager(realRoot),
46
61
  latestVersion: defaultUpdateCheck().latestVersion,
62
+ versionAvailability: registryAvailability,
63
+ sleep: (ms) => Bun.sleep(ms),
47
64
  installedVersionOnDisk: () => readInstalledVersion(targetRoot),
48
65
  kitCli: [process.execPath, join(targetRoot, 'src', 'cli.ts')],
49
66
  run: runInherited,
@@ -0,0 +1,44 @@
1
+ /** What the registry says about one exact version; `unknown` when it did not answer, which never blocks an install. */
2
+ export const VERSION_AVAILABILITY = { available: 'available', missing: 'missing', unknown: 'unknown' } as const
3
+
4
+ export type VersionAvailability = (typeof VERSION_AVAILABILITY)[keyof typeof VERSION_AVAILABILITY]
5
+
6
+ /** How long `jh update` waits for a version the registry has not distributed yet. */
7
+ export type RetryPolicy = {
8
+ /** Probes after the first one; the number of waits. */
9
+ retries: number
10
+ firstDelayMs: number
11
+ factor: number
12
+ maxDelayMs: number
13
+ }
14
+
15
+ export type WaitOutcome = { settled: VersionAvailability; waitedMs: number }
16
+
17
+ /** About a minute and a half in total: a fresh publish reaches the registry's mirrors well within it. */
18
+ export const DISTRIBUTION_WAIT: RetryPolicy = { retries: 6, firstDelayMs: 2_000, factor: 2, maxDelayMs: 30_000 }
19
+
20
+ /** The waits between the probes, growing by `factor` and capped at `maxDelayMs`; empty for no retries. */
21
+ export const retryDelays = (policy: RetryPolicy): number[] =>
22
+ Array.from({ length: Math.max(0, policy.retries) }, (_, index) =>
23
+ Math.min(policy.maxDelayMs, Math.round(policy.firstDelayMs * policy.factor ** index)),
24
+ )
25
+
26
+ /**
27
+ * Probes until the registry stops answering `missing`, sleeping the policy's delays in between; the sleep is an
28
+ * argument, so a test runs it without time passing. Settles on `available`, on `unknown` (the install decides),
29
+ * or on `missing` once the delays are used up: a version that never appears waits `sum(delays)` and no longer.
30
+ */
31
+ export const waitForAvailability = async (
32
+ probe: () => Promise<VersionAvailability>,
33
+ sleep: (ms: number) => Promise<void>,
34
+ policy: RetryPolicy,
35
+ ): Promise<WaitOutcome> => {
36
+ let waitedMs = 0
37
+ for (const delay of retryDelays(policy)) {
38
+ const settled = await probe()
39
+ if (settled !== VERSION_AVAILABILITY.missing) return { settled, waitedMs }
40
+ await sleep(delay)
41
+ waitedMs += delay
42
+ }
43
+ return { settled: await probe(), waitedMs }
44
+ }
@@ -1,5 +1,6 @@
1
1
  import { CLI_NAME, isNewerVersion, isRelease, PACKAGE_NAME } from '../version/release'
2
2
  import { installCommand, LEGACY_PACKAGE_NAME, type PackageManager, removeCommand } from './package-manager'
3
+ import { DISTRIBUTION_WAIT, VERSION_AVAILABILITY, type VersionAvailability, waitForAvailability } from './registry-wait'
3
4
 
4
5
  export const UPDATE_EXIT = { ok: 0, error: 1 } as const
5
6
 
@@ -9,6 +10,10 @@ export type UpdateAdapters = {
9
10
  /** The manager that installed the kit globally, or null when it is not a global install. */
10
11
  manager: PackageManager | null
11
12
  latestVersion: () => Promise<string | null>
13
+ /** Asks the registry whether this exact version can be installed yet; `unknown` when it did not answer. */
14
+ versionAvailability: (version: string) => Promise<VersionAvailability>
15
+ /** Waits `ms` milliseconds; a test passes one that returns at once. */
16
+ sleep: (ms: number) => Promise<void>
12
17
  /** The version in the package on disk now, after the install; null when it cannot be read. */
13
18
  installedVersionOnDisk: () => string | null
14
19
  /** The words that start this very package's CLI (runtime, then `src/cli.ts`), so a sync runs the code just installed and no `jh` found on the path. */
@@ -81,6 +86,23 @@ const installAndVerify = async (
81
86
  return UPDATE_EXIT.error
82
87
  }
83
88
 
89
+ /**
90
+ * Waits while the registry answers 404 for `version`, which it does right after a publish until the release is
91
+ * distributed; false, with a line saying why, once the waits are used up and nothing was changed.
92
+ */
93
+ const awaitDistribution = async (adapters: UpdateAdapters, version: string): Promise<boolean> => {
94
+ const { settled, waitedMs } = await waitForAvailability(
95
+ () => adapters.versionAvailability(version),
96
+ adapters.sleep,
97
+ DISTRIBUTION_WAIT,
98
+ )
99
+ if (settled !== VERSION_AVAILABILITY.missing) return true
100
+ adapters.log(
101
+ `${CLI_NAME} update: ${PACKAGE_NAME}@${version} is still not on the npm registry after ${Math.round(waitedMs / 1000)} seconds, so nothing was changed. Run \`${CLI_NAME} update\` again in a few minutes.`,
102
+ )
103
+ return false
104
+ }
105
+
84
106
  /** Runs the syncs with the freshly installed code; the first that fails stops the rest and names its command. */
85
107
  const syncAfterInstall = async (adapters: UpdateAdapters, project: string | null): Promise<number> => {
86
108
  for (const { argv, cwd } of await syncSteps(adapters, project)) {
@@ -117,6 +139,7 @@ const updateToLatest = async (adapters: UpdateAdapters, manager: PackageManager)
117
139
  if (adapters.projectRoot && !installedProject(adapters)) logConfigShares(adapters, adapters.projectRoot)
118
140
  return UPDATE_EXIT.ok
119
141
  }
142
+ if (!(await awaitDistribution(adapters, latest))) return UPDATE_EXIT.error
120
143
  const installed = await installAndVerify(adapters, manager, latest)
121
144
  if (installed !== UPDATE_EXIT.ok) return installed
122
145
  const project = installedProject(adapters)
@@ -0,0 +1,8 @@
1
+ import { PACKAGE_NAME } from './release'
2
+
3
+ /** How long one question to the npm registry may take before it counts as unanswered. */
4
+ export const REGISTRY_TIMEOUT_MS = 3_000
5
+
6
+ /** The registry's document for a tag (`latest`) or for one exact version of this package. */
7
+ export const registryUrl = (versionOrTag: string): string =>
8
+ `https://registry.npmjs.org/${PACKAGE_NAME}/${versionOrTag}`
@@ -56,6 +56,7 @@ import { ensurePlanPanes } from '../plans/ensure-panes'
56
56
  import { errorText } from '../plans/error-text'
57
57
  import { baseReaderOf, mainCheckout, worktreeNames, worktreeRoot } from '../plans/git'
58
58
  import { discoverLanes, isUnchanged, laneSetChange } from '../plans/lane-discovery'
59
+ import { type PlacedLanes, placeLanes } from '../plans/lane-worktrees'
59
60
  import { readerOncePerRef } from '../plans/plan-heads'
60
61
  import { readQuestionRecords } from '../plans/questions-read'
61
62
  import { closeSentRecords, readSentRecords } from '../plans/sent-files-read'
@@ -172,6 +173,7 @@ import { branchOf, fetchBaseWhenMoved, fetchPlanHeadsWhenMoved, readMainCheckout
172
173
  import { type LaneStalledEvent, laneStalledEvents, STALLED_HEARTBEATS, type StallState } from './lane-stalled'
173
174
  import {
174
175
  advanceBaseline,
176
+ agentPlacesOf,
175
177
  armBaseline,
176
178
  baselineWithNewAgents,
177
179
  departedLanes,
@@ -179,6 +181,7 @@ import {
179
181
  type HerdrAgent,
180
182
  LANE_GONE,
181
183
  type LaneSnapshot,
184
+ misplacedLinesOf,
182
185
  NO_AGENT,
183
186
  toSnapshot,
184
187
  } from './lane-watch'
@@ -301,6 +304,7 @@ type Watch = {
301
304
  boardNotice: string | null
302
305
  options: WatchOptions
303
306
  files: Files
307
+ /** Set at the start and again at every poll, once the lanes are placed by where their agents run (`placeLanes`). */
304
308
  settings: PlansSettings
305
309
  /** `watch.deploy`, `feedback`, `previews` and `checks` as read at the start; the loop reads them from `Watching.live`. */
306
310
  live: LiveValues
@@ -343,6 +347,8 @@ type Watching = {
343
347
  sprintLanes: string[]
344
348
  /** Lanes that left the set with their agent since the last poll; they fire as gone once. */
345
349
  departed: string[]
350
+ /** The `lane <name> not watched` lines of the last poll; only a new one is printed. */
351
+ misplaced: string[]
346
352
  /** When the last report or heartbeat was printed, the start before the first. */
347
353
  quietSince: number
348
354
  state: WatchState
@@ -1155,13 +1161,29 @@ const readSprintLanes = (watch: Watch): SprintLane[] | null => {
1155
1161
  const sprintLaneNames = (watch: Watch, previous: string[]): string[] =>
1156
1162
  readSprintLanes(watch)?.map((lane) => lane.name) ?? previous
1157
1163
 
1158
- const lanesFor = (watch: Watch, agents: readonly HerdrAgent[], sprintLanes: readonly string[]): string[] =>
1159
- watch.options.lanes ??
1160
- discoverLanes({
1161
- worktrees: worktreeNames(join(watch.root, watch.settings.worktreeDirectory)),
1162
- agentNames: agentNamesOf(agents),
1163
- sprintLanes,
1164
+ /** Places the lanes by where their agents run (`placeLanes`), named lanes included; the caller takes up `settings` into `watch.settings`. */
1165
+ const placeLanesOf = (watch: Watch, agents: readonly HerdrAgent[], sprintLanes: readonly string[]): PlacedLanes =>
1166
+ placeLanes(watch.root, watch.settings, agentPlacesOf(agents), [...sprintLanes, ...(watch.options.lanes ?? [])])
1167
+
1168
+ const lanesFor = (
1169
+ watch: Watch,
1170
+ agents: readonly HerdrAgent[],
1171
+ sprintLanes: readonly string[],
1172
+ worktrees: readonly string[],
1173
+ ): string[] => watch.options.lanes ?? discoverLanes({ worktrees, agentNames: agentNamesOf(agents), sprintLanes })
1174
+
1175
+ /** Prints the `lane <name> not watched` lines that are new since the last poll; named lanes are never discovered, so they have none. */
1176
+ const reportMisplaced = (watch: Watch, watching: Watching) => {
1177
+ if (watch.options.lanes !== null) return
1178
+ const lines = misplacedLinesOf(watching.agents, {
1179
+ sprintLanes: watching.sprintLanes,
1180
+ watched: watching.lanes,
1181
+ worktreeDirectory: join(watch.root, watch.settings.worktreeDirectory),
1164
1182
  })
1183
+ const fresh = laneSetChange(watching.misplaced, lines).added
1184
+ if (fresh.length > 0) console.log(annotateEach(fresh, hintFor(watch, REFERENCE.laneSession)).join('\n'))
1185
+ watching.misplaced = lines
1186
+ }
1165
1187
 
1166
1188
  /** Brings back missing herd panes beside the coordinator and the board's lanes when `trigger` calls for it; only inside herdr. */
1167
1189
  const keepViews = (watch: Watch, watching: Watching, trigger: EnsureTrigger) => {
@@ -1187,7 +1209,9 @@ const keepBackendPane = async (watch: Watch, watching: Watching) => {
1187
1209
  const followLaneSet = (watch: Watch, watching: Watching): string[] => {
1188
1210
  if (watch.options.lanes !== null) return []
1189
1211
  watching.sprintLanes = sprintLaneNames(watch, watching.sprintLanes)
1190
- const lanes = lanesFor(watch, watching.agents, watching.sprintLanes)
1212
+ const placed = placeLanesOf(watch, watching.agents, watching.sprintLanes)
1213
+ watch.settings = placed.settings
1214
+ const lanes = lanesFor(watch, watching.agents, watching.sprintLanes, placed.worktrees)
1191
1215
  const change = laneSetChange(watching.lanes, lanes)
1192
1216
  if (isUnchanged(change)) return []
1193
1217
  watching.departed = [...watching.departed, ...departedLanes(change.removed, watching.baseline, watching.current)]
@@ -1216,6 +1240,7 @@ const pollLanes = (watch: Watch, watching: Watching) => {
1216
1240
  const previous = watching.current
1217
1241
  if (!readAgents(watching)) return
1218
1242
  const added = followLaneSet(watch, watching)
1243
+ reportMisplaced(watch, watching)
1219
1244
  const withNewAgent = lanesWithNewAgent(previous, watching.current, watching.lanes)
1220
1245
  keepViews(watch, watching, { kind: ENSURE_TRIGGER.poll, added, withNewAgent })
1221
1246
  watching.baseline = baselineWithNewAgents(watching.baseline, watching.current)
@@ -1316,6 +1341,7 @@ const noTaskSignalEventsOf = (
1316
1341
  const signalled = signalledLanes(watching.lanes, {
1317
1342
  root: watch.root,
1318
1343
  worktreeDirectory: watch.settings.worktreeDirectory,
1344
+ laneWorktrees: watch.settings.laneWorktrees,
1319
1345
  now,
1320
1346
  })
1321
1347
  const counts = currentPhaseCountsOf(progress)
@@ -1383,23 +1409,25 @@ const waitingObsoleteEventsOf = (
1383
1409
  if (!watch.inMainCheckout) return []
1384
1410
  try {
1385
1411
  const worktreeDirectory = join(watch.root, watch.settings.worktreeDirectory)
1412
+ const { laneWorktrees } = watch.settings
1386
1413
  if (!existsSync(worktreeDirectory)) return []
1387
1414
  const sends = readSentRecords(watch.root)
1388
1415
  const questions = readQuestionRecords(watch.root)
1389
1416
  const boardText = readBoardText(watch)
1390
1417
  const board = boardText === null ? null : openWaitingItems(boardText, watch.settings.boardLanguage)
1391
1418
  const { missing, gone } = laneAbsenceOf(
1392
- waitingLanesOf({ sends, questions, board, worktreeDirectory }),
1393
- new Set(worktreeNames(worktreeDirectory)),
1419
+ waitingLanesOf({ sends, questions, board, worktreeDirectory, laneWorktrees }),
1420
+ new Set([...worktreeNames(worktreeDirectory), ...Object.keys(laneWorktrees)]),
1394
1421
  watching.waitingMissingLanes,
1395
1422
  )
1396
- const closings = closeObsoleteSends(watch.root, obsoleteSendsOf(sends, worktreeDirectory, gone), now)
1423
+ const closings = closeObsoleteSends(watch.root, obsoleteSendsOf(sends, worktreeDirectory, gone, laneWorktrees), now)
1397
1424
  const closed = new Set(closings.map(({ id }) => id))
1398
1425
  const { events, state } = obsoleteProposalEvents(watching.waitingProposals, {
1399
1426
  sends: sends.filter(({ id }) => !closed.has(id)),
1400
1427
  questions,
1401
1428
  board,
1402
1429
  worktreeDirectory,
1430
+ laneWorktrees,
1403
1431
  gone,
1404
1432
  ...proposalContextOf(watching),
1405
1433
  })
@@ -1460,10 +1488,10 @@ const subagentCountsOf = (watch: Watch, now: number): Record<string, number> =>
1460
1488
  try {
1461
1489
  const worktreeDirectory = join(watch.root, watch.settings.worktreeDirectory)
1462
1490
  const running = readRunningSubagents(
1463
- subagentSourceOf(watch.root, worktreeDirectory, watch.settings.skillPrefix),
1491
+ subagentSourceOf(watch.root, worktreeDirectory, watch.settings.skillPrefix, watch.settings.laneWorktrees),
1464
1492
  now,
1465
1493
  )
1466
- return runningSubagentCountsOf(running, worktreeDirectory)
1494
+ return runningSubagentCountsOf(running, worktreeDirectory, watch.settings.laneWorktrees)
1467
1495
  } catch {
1468
1496
  return {}
1469
1497
  }
@@ -1649,6 +1677,7 @@ const initialWatching = (
1649
1677
  lanes: start.lanes,
1650
1678
  sprintLanes: start.sprintLanes,
1651
1679
  departed: [],
1680
+ misplaced: [],
1652
1681
  quietSince: Date.now(),
1653
1682
  state: start.state,
1654
1683
  baseline: armBaseline(start.armed, start.state.seenSeqs),
@@ -1701,7 +1730,9 @@ const watchLanes = async (watch: Watch): Promise<boolean> => {
1701
1730
  const state = await readState(watch.files)
1702
1731
  const agents = listAgents()
1703
1732
  const sprintLanes = watch.options.lanes ? [] : sprintLaneNames(watch, [])
1704
- const lanes = lanesFor(watch, agents, sprintLanes)
1733
+ const placed = placeLanesOf(watch, agents, sprintLanes)
1734
+ watch.settings = placed.settings
1735
+ const lanes = lanesFor(watch, agents, sprintLanes, placed.worktrees)
1705
1736
  const armed = toSnapshot(agents, lanes)
1706
1737
  const arm = armNotice(state.armed, lanes, armed, takeOver, relative(watch.root, watch.sprintFile))
1707
1738
  if (arm.line !== null)
@@ -1709,6 +1740,7 @@ const watchLanes = async (watch: Watch): Promise<boolean> => {
1709
1740
  if (watch.boardNotice) console.log(watch.boardNotice)
1710
1741
 
1711
1742
  const watching = initialWatching(watch, { lanes, sprintLanes, state: { ...state, armed: arm.state }, armed, agents })
1743
+ reportMisplaced(watch, watching)
1712
1744
  keepViews(watch, watching, { kind: ENSURE_TRIGGER.start })
1713
1745
  await keepBackendPane(watch, watching)
1714
1746
  const resumeRecord = keepRecord(
@@ -1723,6 +1755,7 @@ const watchLanes = async (watch: Watch): Promise<boolean> => {
1723
1755
  process.env.HERDR_PANE_ID,
1724
1756
  ),
1725
1757
  laneModels: laneModelColumnsOf(watching.laneModelReadings),
1758
+ laneWorktrees: { ...watch.settings.laneWorktrees },
1726
1759
  })
1727
1760
  resumeRecord.follow(armed, Date.now(), observed())
1728
1761
  process.on('SIGTERM', () => {
@@ -1,3 +1,4 @@
1
+ import { type AgentPlace, type MisplacementSources, misplacedLaneLines, misplacedLanes } from '../plans/lane-discovery'
1
2
  import { AGENT_STATUS } from '../terminal/types'
2
3
 
3
4
  export type HerdrAgent = {
@@ -6,6 +7,8 @@ export type HerdrAgent = {
6
7
  agent_status: string
7
8
  agent_session?: unknown
8
9
  state_change_seq?: number
10
+ /** The directory the agent runs in; herdr's own field, read as a string only where it is one. */
11
+ cwd?: unknown
9
12
  }
10
13
 
11
14
  /** The state a report gives a lane whose agent left the herdr listing. */
@@ -31,6 +34,16 @@ export const toSnapshot = (agents: readonly HerdrAgent[], lanes: readonly string
31
34
  .map((agent) => [agent.name, { status: agent.agent_status, seq: agent.state_change_seq ?? null }]),
32
35
  )
33
36
 
37
+ /** The named agents with the directory each runs in; a `cwd` that is no string reads as none. */
38
+ export const agentPlacesOf = (agents: readonly HerdrAgent[]): AgentPlace[] =>
39
+ agents.flatMap(({ name, cwd }) => (name ? [{ name, cwd: typeof cwd === 'string' ? cwd : null }] : []))
40
+
41
+ /** The `lane <name> not watched` lines of a `herdr agent list` answer (`misplacedLanes`). */
42
+ export const misplacedLinesOf = (
43
+ agents: readonly HerdrAgent[],
44
+ sources: Omit<MisplacementSources, 'agents'>,
45
+ ): string[] => misplacedLaneLines(misplacedLanes({ ...sources, agents: agentPlacesOf(agents) }))
46
+
34
47
  const isHerdrAgent = (value: unknown): value is HerdrAgent => {
35
48
  if (typeof value !== 'object' || value === null) return false
36
49
  const candidate = value as Record<string, unknown>
@@ -4,7 +4,7 @@ import type { WaitingItem } from '../plan-format/waiting'
4
4
  import { CLOSE_REASON, closeReasonOf } from '../plans/close-reason'
5
5
  import { type QuestionRecord, questionTextOf } from '../plans/question-records'
6
6
  import { type SentFileRecord, sentLaneOf, sentTextOf } from '../plans/sent-files'
7
- import { laneDirectoryOf } from '../plans/unlisted-lanes'
7
+ import { type LaneWorktrees, laneOfCwd } from '../plans/unlisted-lanes'
8
8
  import { isRecord } from '../shared/is-record'
9
9
  import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
10
10
  import { latestPullRequestOf, PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
@@ -33,6 +33,8 @@ export type ProposalInput = {
33
33
  /** Lane → branch, as the watcher keeps it after a lane was closed. */
34
34
  laneBranches: Readonly<Record<string, string>>
35
35
  worktreeDirectory: string
36
+ /** The lanes placed in worktrees of another name (`PlansSettings.laneWorktrees`). */
37
+ laneWorktrees?: LaneWorktrees
36
38
  /** The lanes whose directory was missing in two heartbeats in a row (`laneAbsenceOf`). */
37
39
  gone: ReadonlySet<string>
38
40
  /** The lanes known to have existed; a board row naming a lane that never had a worktree proposes nothing. */
@@ -64,13 +66,13 @@ export const textReferencesOf = (text: string): TextReferences => ({
64
66
 
65
67
  /** The lanes the open points name: a send's lane, a question's lane by its directory, a board row's lanes in its text. */
66
68
  export const waitingLanesOf = (
67
- input: Pick<ProposalInput, 'sends' | 'questions' | 'board' | 'worktreeDirectory'>,
69
+ input: Pick<ProposalInput, 'sends' | 'questions' | 'board' | 'worktreeDirectory' | 'laneWorktrees'>,
68
70
  ): string[] => [
69
71
  ...input.sends.flatMap((record) =>
70
- record.closedAt === null ? (sentLaneOf(record, input.worktreeDirectory) ?? []) : [],
72
+ record.closedAt === null ? (sentLaneOf(record, input.worktreeDirectory, input.laneWorktrees) ?? []) : [],
71
73
  ),
72
74
  ...input.questions.flatMap((record) =>
73
- record.closedAt === null ? (laneDirectoryOf(record.cwd, input.worktreeDirectory) ?? []) : [],
75
+ record.closedAt === null ? (laneOfCwd(record.cwd, input.worktreeDirectory, input.laneWorktrees) ?? []) : [],
74
76
  ),
75
77
  ...(input.board ?? []).flatMap((row) => textReferencesOf(rowText(row)).lanes),
76
78
  ]
@@ -87,7 +89,7 @@ const laneRemovedReason = (lane: string): string => closeReasonOf(CLOSE_REASON.l
87
89
 
88
90
  const sendProposals = (input: ProposalInput): Proposal[] =>
89
91
  input.sends.flatMap((record) => {
90
- const lane = sentLaneOf(record, input.worktreeDirectory)
92
+ const lane = sentLaneOf(record, input.worktreeDirectory, input.laneWorktrees)
91
93
  const branch = lane === null ? undefined : input.laneBranches[lane]
92
94
  const pullRequest = branch === undefined ? null : latestPullRequestOf(input.pullRequests ?? [], branch)
93
95
  return record.closedAt === null && pullRequest !== null && hasEnded(pullRequest)
@@ -96,7 +98,7 @@ const sendProposals = (input: ProposalInput): Proposal[] =>
96
98
  })
97
99
 
98
100
  const questionReasons = (record: QuestionRecord, input: ProposalInput): string[] => {
99
- const lane = laneDirectoryOf(record.cwd, input.worktreeDirectory)
101
+ const lane = laneOfCwd(record.cwd, input.worktreeDirectory, input.laneWorktrees)
100
102
  const superseded = record.supersededBy ? [`${SUPERSEDED}: ${record.supersededBy.question}`] : []
101
103
  const ended = typeof record.sessionEndedAt === 'number' ? [SESSION_ENDED] : []
102
104
  return [...superseded, ...ended, ...(lane !== null && input.gone.has(lane) ? [laneRemovedReason(lane)] : [])]
@@ -1,6 +1,7 @@
1
1
  import { terminalSafe } from '../plan-format/terminal-text'
2
2
  import { CLOSE_REASON, closeReasonOf } from '../plans/close-reason'
3
3
  import { latestPerFile, type SentFileRecord, sentLaneOf } from '../plans/sent-files'
4
+ import type { LaneWorktrees } from '../plans/unlisted-lanes'
4
5
 
5
6
  export const WAITING_CLOSED_EVENT = 'waiting closed'
6
7
 
@@ -49,9 +50,10 @@ export const obsoleteSendsOf = (
49
50
  records: readonly SentFileRecord[],
50
51
  worktreeDirectory: string,
51
52
  gone: ReadonlySet<string>,
53
+ placed: LaneWorktrees = {},
52
54
  ): Closing[] => {
53
55
  const open = records.flatMap((record) => {
54
- const lane = sentLaneOf(record, worktreeDirectory)
56
+ const lane = sentLaneOf(record, worktreeDirectory, placed)
55
57
  return record.closedAt === null ? [{ id: record.id, lane }] : []
56
58
  })
57
59
  const openIds = new Set(open.map(({ id }) => id))
@@ -1,15 +1,16 @@
1
1
  import type { RunningSubagent } from '../plans/subagents'
2
- import { laneDirectoryOf } from '../plans/unlisted-lanes'
2
+ import { type LaneWorktrees, laneOfCwd } from '../plans/unlisted-lanes'
3
3
  import { type LaneNotes, type PhaseCount, progressNotes } from './heartbeat-progress'
4
4
 
5
5
  /** The running subagents of each worktree below `worktreeDirectory`, by lane name; one that works elsewhere is left out. */
6
6
  export const runningSubagentCountsOf = (
7
7
  running: readonly Pick<RunningSubagent, 'cwd'>[],
8
8
  worktreeDirectory: string,
9
+ placed: LaneWorktrees = {},
9
10
  ): Record<string, number> => {
10
11
  const counts: Record<string, number> = {}
11
12
  for (const { cwd } of running) {
12
- const lane = cwd === null ? null : laneDirectoryOf(cwd, worktreeDirectory)
13
+ const lane = cwd === null ? null : laneOfCwd(cwd, worktreeDirectory, placed)
13
14
  if (lane !== null) counts[lane] = (counts[lane] ?? 0) + 1
14
15
  }
15
16
  return counts
@@ -2,12 +2,15 @@ import { join } from 'node:path'
2
2
  import { defaultProjectsDirectory } from '../context/session-log'
3
3
  import { laneOfDirectory } from '../plans/active-tasks'
4
4
  import { readAllSignals } from '../plans/signals-read'
5
+ import type { LaneWorktrees } from '../plans/unlisted-lanes'
5
6
 
6
7
  export type SignalSources = {
7
8
  /** The main checkout, which holds `.jackherd/tasks/`. */
8
9
  root: string
9
10
  /** The lanes' worktree directory, relative to `root`. */
10
11
  worktreeDirectory: string
12
+ /** The lanes placed in worktrees of another name (`PlansSettings.laneWorktrees`). */
13
+ laneWorktrees?: LaneWorktrees
11
14
  now: number
12
15
  /** Where Claude Code keeps its session logs; the user's by default. */
13
16
  projectsDirectory?: string
@@ -24,6 +27,6 @@ export const signalledLanes = (lanes: readonly string[], sources: SignalSources)
24
27
  { projects: sources.projectsDirectory ?? defaultProjectsDirectory(), root: sources.root, worktrees },
25
28
  sources.now,
26
29
  )
27
- const signalled = new Set(signals.flatMap(({ cwd }) => laneOfDirectory(cwd, worktrees) ?? []))
30
+ const signalled = new Set(signals.flatMap(({ cwd }) => laneOfDirectory(cwd, worktrees, sources.laneWorktrees) ?? []))
28
31
  return new Set(lanes.filter((lane) => signalled.has(lane)))
29
32
  }
@@ -35,7 +35,9 @@ export const readUnlinkedArtifactEvents = (
35
35
  const ids = new Set(artifacts.map(({ id }) => id))
36
36
  const worktrees = join(root, settings.worktreeDirectory)
37
37
  const lanes = new Set(
38
- records.filter(({ id }) => ids.has(id)).flatMap((record) => sentLaneOf(record, worktrees) ?? []),
38
+ records
39
+ .filter(({ id }) => ids.has(id))
40
+ .flatMap((record) => sentLaneOf(record, worktrees, settings.laneWorktrees) ?? []),
39
41
  )
40
42
  const checkouts = [root, ...[...lanes].map((lane) => worktreeOf(root, settings, lane))]
41
43
  const texts = checkouts.flatMap((checkout) => markdownTextsUnder(join(checkout, settings.planDirectory)))