jackherd 1.26.1 → 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 (44) hide show
  1. package/CHANGELOG.md +7 -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 +1 -1
  6. package/package.json +1 -1
  7. package/src/brief/lane-facts.ts +30 -18
  8. package/src/context/command.ts +3 -1
  9. package/src/lane/close-command.ts +5 -3
  10. package/src/lane/close.ts +1 -1
  11. package/src/lane/send-options.ts +3 -2
  12. package/src/lane/send.ts +8 -1
  13. package/src/lane/subagent-view.ts +21 -0
  14. package/src/lane-prompt/command.ts +4 -1
  15. package/src/plans/active-tasks.ts +4 -2
  16. package/src/plans/activity-read.ts +3 -2
  17. package/src/plans/command.ts +3 -1
  18. package/src/plans/coordinator-row.ts +7 -2
  19. package/src/plans/ensure-panes.ts +2 -2
  20. package/src/plans/lane-discovery.ts +42 -3
  21. package/src/plans/lane-worktrees.ts +56 -0
  22. package/src/plans/sent-files.ts +10 -5
  23. package/src/plans/settings.ts +9 -1
  24. package/src/plans/snapshot.ts +23 -8
  25. package/src/plans/subagents-read.ts +17 -2
  26. package/src/plans/subagents.ts +3 -2
  27. package/src/plans/unlisted-lanes.ts +13 -0
  28. package/src/resume/command.ts +1 -1
  29. package/src/resume/coordinator-read.ts +12 -12
  30. package/src/resume/decision.ts +3 -0
  31. package/src/resume/record.ts +24 -5
  32. package/src/sync/command.ts +16 -2
  33. package/src/sync/downgrade.ts +9 -0
  34. package/src/sync/if-changed.ts +6 -7
  35. package/src/update/command.ts +17 -0
  36. package/src/update/registry-wait.ts +44 -0
  37. package/src/update/update.ts +23 -0
  38. package/src/version/registry.ts +8 -0
  39. package/src/watch/command.ts +28 -14
  40. package/src/watch/obsolete-proposals.ts +8 -6
  41. package/src/watch/obsolete-records.ts +3 -1
  42. package/src/watch/running-subagents.ts +3 -2
  43. package/src/watch/task-signal-read.ts +4 -1
  44. package/src/watch/unlinked-artifacts-read.ts +3 -1
@@ -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))
@@ -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(root, settings.worktreeDirectory, laneName)
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'
@@ -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, {
@@ -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 = (root: string, worktreeDirectory: string, skillPrefix?: string): SubagentSource => ({
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(subagentsByLane(running, source.worktreeDirectory), running, logs, source.worktreeDirectory)
193
+ return withSubagentLines(
194
+ subagentsByLane(running, source.worktreeDirectory, source.laneWorktrees),
195
+ running,
196
+ logs,
197
+ source.worktreeDirectory,
198
+ source.laneWorktrees,
199
+ )
185
200
  }
@@ -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 { laneDirectoryOf } from './unlisted-lanes'
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 : laneDirectoryOf(cwd, worktreeDirectory)
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
 
@@ -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, worktreeNames } from '../plans/git'
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,7 +16,7 @@ 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 HerdrAgent, misplacedLinesOf } from '../watch/lane-watch'
19
+ import { agentPlacesOf, type HerdrAgent, misplacedLinesOf } from '../watch/lane-watch'
19
20
  import { annotateEach, REFERENCE, referenceLine } from '../watch/reference-paths'
20
21
  import {
21
22
  type CoordinatorStateInput,
@@ -102,22 +103,21 @@ const readPullRequestsSafely = async (
102
103
  const sprintLanesOf = ({ settings }: Place, board: string | null): string[] =>
103
104
  board === null ? [] : parseSprintLanes(board, settings.boardLanguage).map((lane) => lane.name)
104
105
 
105
- const lanesOf = ({ root, settings }: Place, sprintLanes: string[], agents: readonly HerdrAgent[] | null): string[] =>
106
- discoverLanes({
107
- worktrees: worktreeNames(join(root, settings.worktreeDirectory)),
108
- agentNames: (agents ?? []).flatMap((agent) => agent.name ?? []),
109
- sprintLanes,
110
- })
106
+ const lanesOf = (sprintLanes: string[], agents: readonly HerdrAgent[] | null, worktrees: readonly string[]): string[] =>
107
+ discoverLanes({ worktrees, agentNames: (agents ?? []).flatMap((agent) => agent.name ?? []), sprintLanes })
111
108
 
112
109
  /** `jh resume --coordinator`: prints what a cleared coordinator takes up, then the reference on it. Reads only. */
113
110
  export const printCoordinatorState = async (boardPath: string | null): Promise<void> => {
114
- const place = placeOf(boardPath)
115
- const { root, settings, boardFile } = place
111
+ const found = placeOf(boardPath)
112
+ const { root, boardFile } = found
116
113
  const now = Date.now()
117
114
  const board = readBoard(boardFile)
118
115
  const agents = readAgents()
119
- const sprintLanes = sprintLanesOf(place, board)
120
- const lanes = lanesOf(place, sprintLanes, agents)
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)
121
121
  const lines = coordinatorStateLines({
122
122
  boardPath: relative(root, boardFile),
123
123
  board,
@@ -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)),
@@ -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
- /** What the watcher saw at one poll besides the lane statuses; both parts may be unknown. */
133
- export type Observed = { coordinator?: CoordinatorIdentity | null; laneModels?: Record<string, string> }
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
- withLaneModels(
154
- withIdentity(withLanes(record, lanes, now), observed.coordinator ?? null, now),
155
- observed.laneModels ?? {},
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,
@@ -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
- const syncProject = (context: CommandContext, accept: string[], dryRun: boolean): Promise<number> =>
106
- runWithKit(
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.`
@@ -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}`