jackherd 1.26.1 → 1.26.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +18 -18
- package/dist/config.d.ts +1 -0
- package/dist/config.js +2 -0
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -8
- package/kit/workflow/skills/orchestrate/references/lane-session.md +1 -1
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +8 -0
- package/src/brief/lane-facts.ts +30 -18
- package/src/config/schema.ts +2 -0
- package/src/context/command.ts +3 -1
- package/src/lane/close-check.ts +51 -15
- package/src/lane/close-command.ts +41 -8
- package/src/lane/close-survey.ts +9 -0
- package/src/lane/close.ts +54 -6
- package/src/lane/send-options.ts +3 -2
- package/src/lane/send.ts +8 -1
- package/src/lane/subagent-view.ts +21 -0
- package/src/lane-prompt/command.ts +4 -1
- package/src/plans/active-tasks.ts +4 -2
- package/src/plans/activity-read.ts +3 -2
- package/src/plans/command.ts +3 -1
- package/src/plans/coordinator-row.ts +7 -2
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/lane-discovery.ts +42 -3
- package/src/plans/lane-worktrees.ts +56 -0
- package/src/plans/sent-files.ts +10 -5
- package/src/plans/settings.ts +12 -1
- package/src/plans/snapshot.ts +23 -8
- package/src/plans/subagents-read.ts +17 -2
- package/src/plans/subagents.ts +3 -2
- package/src/plans/unlisted-lanes.ts +13 -0
- package/src/resume/command.ts +1 -1
- package/src/resume/coordinator-read.ts +12 -12
- package/src/resume/decision.ts +3 -0
- package/src/resume/record.ts +24 -5
- package/src/sync/command.ts +16 -2
- package/src/sync/downgrade.ts +9 -0
- package/src/sync/if-changed.ts +6 -7
- package/src/update/command.ts +17 -0
- package/src/update/registry-wait.ts +44 -0
- package/src/update/update.ts +23 -0
- package/src/version/registry.ts +8 -0
- package/src/watch/command.ts +28 -14
- package/src/watch/obsolete-proposals.ts +8 -6
- package/src/watch/obsolete-records.ts +3 -1
- package/src/watch/running-subagents.ts +3 -2
- package/src/watch/task-signal-read.ts +4 -1
- package/src/watch/unlinked-artifacts-read.ts +3 -1
|
@@ -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,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 = (
|
|
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
|
|
115
|
-
const { root,
|
|
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(
|
|
120
|
-
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)
|
|
121
121
|
const lines = coordinatorStateLines({
|
|
122
122
|
boardPath: relative(root, boardFile),
|
|
123
123
|
board,
|
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.`
|
package/src/sync/if-changed.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
59
|
-
export const lockedVersion = async (
|
|
60
|
-
(await versionOfLock(join(
|
|
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(
|
|
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
|
package/src/update/command.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/update/update.ts
CHANGED
|
@@ -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}`
|
package/src/watch/command.ts
CHANGED
|
@@ -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,
|
|
@@ -302,6 +304,7 @@ type Watch = {
|
|
|
302
304
|
boardNotice: string | null
|
|
303
305
|
options: WatchOptions
|
|
304
306
|
files: Files
|
|
307
|
+
/** Set at the start and again at every poll, once the lanes are placed by where their agents run (`placeLanes`). */
|
|
305
308
|
settings: PlansSettings
|
|
306
309
|
/** `watch.deploy`, `feedback`, `previews` and `checks` as read at the start; the loop reads them from `Watching.live`. */
|
|
307
310
|
live: LiveValues
|
|
@@ -1158,13 +1161,16 @@ const readSprintLanes = (watch: Watch): SprintLane[] | null => {
|
|
|
1158
1161
|
const sprintLaneNames = (watch: Watch, previous: string[]): string[] =>
|
|
1159
1162
|
readSprintLanes(watch)?.map((lane) => lane.name) ?? previous
|
|
1160
1163
|
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
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 })
|
|
1168
1174
|
|
|
1169
1175
|
/** Prints the `lane <name> not watched` lines that are new since the last poll; named lanes are never discovered, so they have none. */
|
|
1170
1176
|
const reportMisplaced = (watch: Watch, watching: Watching) => {
|
|
@@ -1203,7 +1209,9 @@ const keepBackendPane = async (watch: Watch, watching: Watching) => {
|
|
|
1203
1209
|
const followLaneSet = (watch: Watch, watching: Watching): string[] => {
|
|
1204
1210
|
if (watch.options.lanes !== null) return []
|
|
1205
1211
|
watching.sprintLanes = sprintLaneNames(watch, watching.sprintLanes)
|
|
1206
|
-
const
|
|
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)
|
|
1207
1215
|
const change = laneSetChange(watching.lanes, lanes)
|
|
1208
1216
|
if (isUnchanged(change)) return []
|
|
1209
1217
|
watching.departed = [...watching.departed, ...departedLanes(change.removed, watching.baseline, watching.current)]
|
|
@@ -1333,6 +1341,7 @@ const noTaskSignalEventsOf = (
|
|
|
1333
1341
|
const signalled = signalledLanes(watching.lanes, {
|
|
1334
1342
|
root: watch.root,
|
|
1335
1343
|
worktreeDirectory: watch.settings.worktreeDirectory,
|
|
1344
|
+
laneWorktrees: watch.settings.laneWorktrees,
|
|
1336
1345
|
now,
|
|
1337
1346
|
})
|
|
1338
1347
|
const counts = currentPhaseCountsOf(progress)
|
|
@@ -1400,23 +1409,25 @@ const waitingObsoleteEventsOf = (
|
|
|
1400
1409
|
if (!watch.inMainCheckout) return []
|
|
1401
1410
|
try {
|
|
1402
1411
|
const worktreeDirectory = join(watch.root, watch.settings.worktreeDirectory)
|
|
1412
|
+
const { laneWorktrees } = watch.settings
|
|
1403
1413
|
if (!existsSync(worktreeDirectory)) return []
|
|
1404
1414
|
const sends = readSentRecords(watch.root)
|
|
1405
1415
|
const questions = readQuestionRecords(watch.root)
|
|
1406
1416
|
const boardText = readBoardText(watch)
|
|
1407
1417
|
const board = boardText === null ? null : openWaitingItems(boardText, watch.settings.boardLanguage)
|
|
1408
1418
|
const { missing, gone } = laneAbsenceOf(
|
|
1409
|
-
waitingLanesOf({ sends, questions, board, worktreeDirectory }),
|
|
1410
|
-
new Set(worktreeNames(worktreeDirectory)),
|
|
1419
|
+
waitingLanesOf({ sends, questions, board, worktreeDirectory, laneWorktrees }),
|
|
1420
|
+
new Set([...worktreeNames(worktreeDirectory), ...Object.keys(laneWorktrees)]),
|
|
1411
1421
|
watching.waitingMissingLanes,
|
|
1412
1422
|
)
|
|
1413
|
-
const closings = closeObsoleteSends(watch.root, obsoleteSendsOf(sends, worktreeDirectory, gone), now)
|
|
1423
|
+
const closings = closeObsoleteSends(watch.root, obsoleteSendsOf(sends, worktreeDirectory, gone, laneWorktrees), now)
|
|
1414
1424
|
const closed = new Set(closings.map(({ id }) => id))
|
|
1415
1425
|
const { events, state } = obsoleteProposalEvents(watching.waitingProposals, {
|
|
1416
1426
|
sends: sends.filter(({ id }) => !closed.has(id)),
|
|
1417
1427
|
questions,
|
|
1418
1428
|
board,
|
|
1419
1429
|
worktreeDirectory,
|
|
1430
|
+
laneWorktrees,
|
|
1420
1431
|
gone,
|
|
1421
1432
|
...proposalContextOf(watching),
|
|
1422
1433
|
})
|
|
@@ -1477,10 +1488,10 @@ const subagentCountsOf = (watch: Watch, now: number): Record<string, number> =>
|
|
|
1477
1488
|
try {
|
|
1478
1489
|
const worktreeDirectory = join(watch.root, watch.settings.worktreeDirectory)
|
|
1479
1490
|
const running = readRunningSubagents(
|
|
1480
|
-
subagentSourceOf(watch.root, worktreeDirectory, watch.settings.skillPrefix),
|
|
1491
|
+
subagentSourceOf(watch.root, worktreeDirectory, watch.settings.skillPrefix, watch.settings.laneWorktrees),
|
|
1481
1492
|
now,
|
|
1482
1493
|
)
|
|
1483
|
-
return runningSubagentCountsOf(running, worktreeDirectory)
|
|
1494
|
+
return runningSubagentCountsOf(running, worktreeDirectory, watch.settings.laneWorktrees)
|
|
1484
1495
|
} catch {
|
|
1485
1496
|
return {}
|
|
1486
1497
|
}
|
|
@@ -1719,7 +1730,9 @@ const watchLanes = async (watch: Watch): Promise<boolean> => {
|
|
|
1719
1730
|
const state = await readState(watch.files)
|
|
1720
1731
|
const agents = listAgents()
|
|
1721
1732
|
const sprintLanes = watch.options.lanes ? [] : sprintLaneNames(watch, [])
|
|
1722
|
-
const
|
|
1733
|
+
const placed = placeLanesOf(watch, agents, sprintLanes)
|
|
1734
|
+
watch.settings = placed.settings
|
|
1735
|
+
const lanes = lanesFor(watch, agents, sprintLanes, placed.worktrees)
|
|
1723
1736
|
const armed = toSnapshot(agents, lanes)
|
|
1724
1737
|
const arm = armNotice(state.armed, lanes, armed, takeOver, relative(watch.root, watch.sprintFile))
|
|
1725
1738
|
if (arm.line !== null)
|
|
@@ -1742,6 +1755,7 @@ const watchLanes = async (watch: Watch): Promise<boolean> => {
|
|
|
1742
1755
|
process.env.HERDR_PANE_ID,
|
|
1743
1756
|
),
|
|
1744
1757
|
laneModels: laneModelColumnsOf(watching.laneModelReadings),
|
|
1758
|
+
laneWorktrees: { ...watch.settings.laneWorktrees },
|
|
1745
1759
|
})
|
|
1746
1760
|
resumeRecord.follow(armed, Date.now(), observed())
|
|
1747
1761
|
process.on('SIGTERM', () => {
|
|
@@ -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 {
|
|
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 ? (
|
|
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 =
|
|
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)] : [])]
|