jackherd 1.8.0 → 1.9.0
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 +39 -0
- package/INSTALL.md +2 -0
- package/README.md +41 -60
- package/assets/branding/README.md +5 -7
- package/assets/branding/jackherd-logo-outline.svg +1 -0
- package/assets/branding/outline.test.ts +30 -0
- package/assets/icons/bark.svg +1 -1
- package/assets/icons/bowl.svg +1 -1
- package/assets/icons/decide.svg +1 -1
- package/assets/icons/ear.svg +1 -1
- package/assets/icons/lanes.svg +1 -1
- package/assets/icons/plan.svg +1 -1
- package/dist/{chunk-M6DDZEW4.js → chunk-MCP4XJQH.js} +17 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +19 -3
- package/dist/plan-format.d.ts +22 -3
- package/dist/plan-format.js +21 -7
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/index.ts +22 -4
- package/kit/hooks/hooks/approval-gate/run.ts +29 -6
- package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +70 -21
- package/kit/hooks/hooks/merge-guard/xargs-program.ts +182 -0
- package/kit/hooks/hooks/sent-file/index.ts +19 -0
- package/kit/hooks/hooks/sent-file/record.ts +128 -0
- package/kit/hooks/hooks/sent-file/run.ts +48 -0
- package/kit/hooks/hooks/sent-file/store.ts +18 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
- package/kit/hooks/hooks.json +9 -0
- package/kit/hooks/statusline.sh +1 -1
- package/kit/workflow/skills/implement/SKILL.md +2 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +31 -6
- package/package.json +3 -1
- package/schemas/jackherd.schema.json +26 -2
- package/src/cli.ts +3 -0
- package/src/config/read-config.ts +5 -1
- package/src/config/schema.ts +22 -2
- package/src/context/fill.ts +12 -0
- package/src/context/session-log.ts +43 -1
- package/src/plan-format/clock.ts +4 -0
- package/src/plan-format/dependencies.ts +2 -5
- package/src/plan-format/lanes.ts +30 -5
- package/src/plan-format/terminal-text.ts +4 -0
- package/src/plan-format/waiting.ts +48 -0
- package/src/plans/active-tasks.ts +60 -0
- package/src/plans/current-phase.ts +17 -7
- package/src/plans/current-task.ts +63 -5
- package/src/plans/lane-standing.ts +12 -6
- package/src/plans/lane-summary.ts +141 -74
- package/src/plans/lane-tickets.ts +15 -0
- package/src/plans/layout-phase.ts +57 -18
- package/src/plans/layout-summary.ts +85 -67
- package/src/plans/layout.ts +253 -89
- package/src/plans/live.ts +22 -0
- package/src/plans/phase-models.ts +1 -1
- package/src/plans/phase-ranges.ts +45 -0
- package/src/plans/pull-requests.ts +6 -0
- package/src/plans/selection.ts +19 -16
- package/src/plans/sent-files-read.ts +22 -0
- package/src/plans/sent-files.ts +121 -0
- package/src/plans/signals-read.ts +54 -0
- package/src/plans/snapshot.ts +251 -55
- package/src/plans/subagents-read.ts +82 -0
- package/src/plans/subagents.ts +132 -0
- package/src/plans/texts.ts +16 -11
- package/src/plans/unlisted-lanes.ts +10 -5
- package/src/plans/view.ts +3 -3
- package/src/plans/waiting-click.ts +76 -0
- package/src/preview/command.ts +83 -0
- package/src/preview/embed.ts +209 -0
- package/src/preview/images.ts +89 -0
- package/src/preview/open.ts +12 -0
- package/src/preview/options.ts +32 -0
- package/src/preview/page.ts +89 -0
- package/src/preview/render.ts +41 -0
- package/src/settled/board-row.ts +1 -2
- package/src/shared/is-record.ts +2 -0
- package/src/shared/private-records.ts +48 -0
- package/src/task-signals/agent.ts +15 -0
- package/src/task-signals/claude-read.ts +72 -0
- package/src/task-signals/claude-signals.ts +112 -0
- package/src/task-signals/command.ts +71 -0
- package/src/task-signals/fixtures/session.jsonl +7 -0
- package/src/task-signals/matcher.ts +96 -0
- package/src/task-signals/record-store.ts +40 -0
- package/src/task-signals/record.ts +68 -0
- package/src/task-signals/text.ts +13 -0
- package/src/task-signals/types.ts +14 -0
- package/src/terminal/open.ts +13 -6
- package/src/terminal/text.ts +8 -1
- package/src/terminal/width.ts +2 -1
- package/src/update/command.ts +2 -0
- package/src/update/config-shares.ts +30 -0
- package/src/update/update.ts +12 -1
- package/src/vocabulary/index.ts +33 -1
- package/src/waiting/command.ts +35 -0
- package/src/watch/appointments.ts +5 -5
- package/src/watch/approvals.ts +2 -2
- package/src/watch/command.ts +91 -5
- package/src/watch/failed-tests.ts +56 -10
- package/src/watch/failure-read.ts +18 -6
- package/src/watch/failures.ts +29 -5
- package/src/watch/feedback.ts +1 -1
- package/src/watch/gh-process.ts +9 -3
- package/src/watch/json-guards.ts +0 -3
- package/src/watch/lane-fill.ts +1 -1
- package/src/watch/lane-model.ts +1 -1
- package/src/watch/lane-parallel.ts +1 -1
- package/src/watch/lane-progress.ts +19 -7
- package/src/watch/lane-report.ts +5 -1
- package/src/watch/lane-rest.ts +28 -0
- package/src/watch/plan-badges.ts +9 -2
- package/src/watch/previews.ts +1 -1
- package/src/watch/pull-requests.ts +2 -1
- package/src/watch/source-path.ts +1 -1
- package/src/watch/sources.ts +1 -1
- package/src/watch/stream-output.ts +12 -7
- package/src/watch/task-parallel-input.ts +118 -0
- package/src/watch/task-parallel-read.ts +149 -0
- package/src/watch/task-parallel-touched.ts +39 -0
- package/src/watch/task-parallel.ts +206 -0
- package/src/watch/waiting-line.ts +19 -0
- package/src/watch/watch-events.ts +13 -1
- package/src/watch/workflow-runs.ts +2 -1
- package/assets/icons/bark-dark.svg +0 -1
- package/assets/icons/bowl-dark.svg +0 -1
- package/assets/icons/decide-dark.svg +0 -1
- package/assets/icons/ear-dark.svg +0 -1
- package/assets/icons/lanes-dark.svg +0 -1
- package/assets/icons/plan-dark.svg +0 -1
package/src/plans/snapshot.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs'
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
3
|
import type { ContextReading } from '../context'
|
|
4
4
|
import { type AgentLogReader, contextOfAgent } from '../context/report'
|
|
5
|
-
import { agentLog } from '../context/session-log'
|
|
5
|
+
import { agentLog, defaultProjectsDirectory } from '../context/session-log'
|
|
6
6
|
import {
|
|
7
7
|
type LaneProgress,
|
|
8
8
|
laneProgress,
|
|
@@ -13,9 +13,12 @@ import {
|
|
|
13
13
|
shortTitle,
|
|
14
14
|
sprintTitleOf,
|
|
15
15
|
} from '../plan-format'
|
|
16
|
+
import { openWaitingItems, type WaitingItem } from '../plan-format/waiting'
|
|
17
|
+
import type { TaskSignal } from '../task-signals/types'
|
|
16
18
|
import type { LaneAgent, LaneStatusSource } from '../terminal'
|
|
17
19
|
import type { SourceTicket, WatchBadges, WatchHealth } from '../watch/plan-badges'
|
|
18
20
|
import { readWatchReading } from '../watch/plan-badges-read'
|
|
21
|
+
import { assignSignals, laneOfDirectory, type UnplannedTask } from './active-tasks'
|
|
19
22
|
import { boardRowsOf } from './board-rows'
|
|
20
23
|
import {
|
|
21
24
|
type ActivityTimes,
|
|
@@ -26,19 +29,25 @@ import {
|
|
|
26
29
|
type PlanModified,
|
|
27
30
|
withMovedPhase,
|
|
28
31
|
} from './current-phase'
|
|
32
|
+
import { activityTextOf, taskSignalsOf } from './current-task'
|
|
29
33
|
import { errorText } from './error-text'
|
|
30
34
|
import { type BaseReader, baseReaderOf, gitStatusOf, refExists } from './git'
|
|
31
35
|
import type { PullRequestState } from './github'
|
|
32
36
|
import { type Fallbacks, laneWorkOf, readAtBase, readFallback, readPlans, textAtBase } from './lane-plans'
|
|
33
37
|
import { laneSummary } from './lane-summary'
|
|
34
38
|
import { boardRowTexts, laneTicketsOf, withoutTicketLinks } from './lane-tickets'
|
|
35
|
-
import {
|
|
39
|
+
import type { LaneView } from './layout'
|
|
36
40
|
import { extractLinkChips } from './link-chips'
|
|
37
41
|
import type { Options } from './options'
|
|
38
42
|
import { laneModelsOf, type PhaseModelRecord } from './phase-models'
|
|
39
43
|
import { readPhaseModels } from './phase-models-read'
|
|
40
44
|
import { type PlannedPlan, plannedPlans } from './planned'
|
|
45
|
+
import { type SentChip, type SentFileRecord, sentChipsByLane, sentWaitingItems } from './sent-files'
|
|
46
|
+
import { readSentRecords } from './sent-files-read'
|
|
41
47
|
import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from './settings'
|
|
48
|
+
import { readAllSignals } from './signals-read'
|
|
49
|
+
import type { SubagentActivity } from './subagents'
|
|
50
|
+
import { readSubagentActivity } from './subagents-read'
|
|
42
51
|
import { boardTexts } from './texts'
|
|
43
52
|
import { type UnlistedLane, unlistedLanes, unlistedSprintLane } from './unlisted-lanes'
|
|
44
53
|
import { withWorkedPhases } from './worked-phases'
|
|
@@ -52,6 +61,10 @@ export type Source = {
|
|
|
52
61
|
terminal: LaneStatusSource
|
|
53
62
|
/** The log lines of an agent's chat, null without one; defaults to reading them from `~/.claude/projects`. */
|
|
54
63
|
readAgentLog?: AgentLogReader
|
|
64
|
+
/** The worktrees subagents of the coordinator work in; defaults to reading them from `~/.claude/projects`. */
|
|
65
|
+
readSubagents?: (root: string, worktreeDirectory: string) => Map<string, SubagentActivity>
|
|
66
|
+
/** What agents report doing (Claude's task lists, `jh task`); defaults to reading `~/.claude/projects` and `.jackherd/tasks/`. */
|
|
67
|
+
readSignals?: (root: string, worktreeDirectory: string, now: number) => TaskSignal[]
|
|
55
68
|
}
|
|
56
69
|
|
|
57
70
|
/**
|
|
@@ -86,6 +99,10 @@ export type Snapshot = {
|
|
|
86
99
|
watch?: WatchHealth
|
|
87
100
|
/** True when the config marks a ticket source, so a missing watcher is said without a pull request too. */
|
|
88
101
|
watchesTickets?: boolean
|
|
102
|
+
/** What waits on the user's answer: the open rows of the board's section for it, from the checkout's board file. */
|
|
103
|
+
waiting?: WaitingItem[]
|
|
104
|
+
/** What agents report doing that no lane takes: the coordinator's own, or a lane the board does not list. */
|
|
105
|
+
unplaced?: UnplannedTask[]
|
|
89
106
|
}
|
|
90
107
|
|
|
91
108
|
type LaneSources = {
|
|
@@ -93,7 +110,15 @@ type LaneSources = {
|
|
|
93
110
|
base: BaseReader
|
|
94
111
|
sprintDirectory: string
|
|
95
112
|
agents: LaneAgent[]
|
|
113
|
+
/** What subagents of the coordinator do in each worktree; a herdr agent in the worktree comes first. */
|
|
114
|
+
subagents: Map<string, SubagentActivity>
|
|
115
|
+
/** The agents' log reader, asking once per agent however often the read is used in one snapshot. */
|
|
116
|
+
readLog: AgentLogReader
|
|
96
117
|
pullRequests: PullRequestState
|
|
118
|
+
/** What agents report doing, in every lane and outside them. */
|
|
119
|
+
signals: TaskSignal[]
|
|
120
|
+
/** The directory the lanes' worktrees lie in, which tells a signal's lane. */
|
|
121
|
+
worktrees: string
|
|
97
122
|
}
|
|
98
123
|
|
|
99
124
|
type BoardLanes = { markdown: string; lanes: SprintLane[] }
|
|
@@ -102,23 +127,54 @@ const SHOWN_NAMES = 2
|
|
|
102
127
|
|
|
103
128
|
type LaneResult = { view: LaneView; files: string[]; texts: Record<string, string>; pane: string | null }
|
|
104
129
|
|
|
130
|
+
/** The board's lanes the view shows, the running lanes it does not list, and what the lanes read from. */
|
|
131
|
+
type LaneSet = { lanes: SprintLane[]; unlisted: UnlistedLane[]; sources: LaneSources; errors: string[] }
|
|
132
|
+
|
|
133
|
+
/** What a lane's view is decorated with after it was read. */
|
|
134
|
+
type Decorations = {
|
|
135
|
+
reading: ReturnType<typeof readWatchReading> | null
|
|
136
|
+
phaseModels: readonly PhaseModelRecord[]
|
|
137
|
+
sprintDirectory: string
|
|
138
|
+
sentChips: ReadonlyMap<string | null, SentChip[]>
|
|
139
|
+
}
|
|
140
|
+
|
|
105
141
|
/** How full the agent's context is, null when the terminal gave no session or the session has no log. */
|
|
106
|
-
const contextOf = (agent: LaneAgent | undefined, source: Source): ContextReading | null => {
|
|
107
|
-
const report = contextOfAgent(agent,
|
|
142
|
+
const contextOf = (agent: LaneAgent | undefined, source: Source, readLog: AgentLogReader): ContextReading | null => {
|
|
143
|
+
const report = contextOfAgent(agent, readLog, source.settings.context)
|
|
108
144
|
return report && { share: report.share, level: report.level }
|
|
109
145
|
}
|
|
110
146
|
|
|
147
|
+
/** A reader that asks once per agent (session and directory), so context and task signal share one read of a large log. */
|
|
148
|
+
const onceReader = (read: AgentLogReader): AgentLogReader => {
|
|
149
|
+
const answers = new Map<string, string[] | null>()
|
|
150
|
+
return (agent) => {
|
|
151
|
+
const key = `${agent.session}\0${agent.cwd}`
|
|
152
|
+
if (!answers.has(key)) answers.set(key, read(agent))
|
|
153
|
+
return answers.get(key) ?? null
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const WORK_ENTRIES = 40
|
|
158
|
+
|
|
159
|
+
/** What the agents of a lane did lately: the end of its own agent's log and the log ends of subagents working in its worktree. */
|
|
160
|
+
const workText = ({ readLog, subagents }: LaneSources, lane: string, agent: LaneAgent | undefined): string => {
|
|
161
|
+
const lines = agent ? readLog(agent) : null
|
|
162
|
+
return [lines ? activityTextOf(lines, WORK_ENTRIES) : '', subagents.get(lane)?.activity ?? ''].join(' ')
|
|
163
|
+
}
|
|
164
|
+
|
|
111
165
|
/** What a lane's worktree and agent say, listed in the sprint or not: git state, pull requests, context. */
|
|
112
166
|
const runningState = async (sources: LaneSources, name: string, agent: LaneAgent | undefined) => {
|
|
113
167
|
const { source, pullRequests } = sources
|
|
114
168
|
const gitStatus = await gitStatusOf(worktreeOf(source.root, source.settings, name), baseRefOf(source.settings))
|
|
169
|
+
const subagent = agent ? undefined : sources.subagents.get(name)
|
|
170
|
+
const title = agent?.title ?? subagent?.description
|
|
115
171
|
return {
|
|
116
172
|
gitStatus,
|
|
117
173
|
view: {
|
|
118
|
-
status: { agent: agent?.status ?? null, ...gitStatus },
|
|
119
|
-
...(
|
|
174
|
+
status: { agent: agent?.status ?? subagent?.status ?? null, ...gitStatus },
|
|
175
|
+
...(title ? { sessionTitle: title } : {}),
|
|
120
176
|
pullRequests: gitStatus.branch ? (pullRequests.byBranch.get(gitStatus.branch) ?? []) : [],
|
|
121
|
-
context: contextOf(agent, source),
|
|
177
|
+
context: contextOf(agent, source, sources.readLog),
|
|
122
178
|
},
|
|
123
179
|
}
|
|
124
180
|
}
|
|
@@ -175,7 +231,16 @@ const laneView = async (
|
|
|
175
231
|
chips: Object.fromEntries(
|
|
176
232
|
Object.entries(texts).map(([href, text]) => [href, extractLinkChips(text, settings.linkKinds)]),
|
|
177
233
|
),
|
|
178
|
-
summary: laneSummary(
|
|
234
|
+
summary: laneSummary(
|
|
235
|
+
{
|
|
236
|
+
progress,
|
|
237
|
+
phases: phasesByHref,
|
|
238
|
+
activity,
|
|
239
|
+
guesses: taskSignalsOf(Object.values(phasesByHref).flat(), workText(sources, lane.name, agent), now),
|
|
240
|
+
reported: sources.signals.filter(({ cwd }) => laneOfDirectory(cwd, sources.worktrees) === lane.name),
|
|
241
|
+
},
|
|
242
|
+
now,
|
|
243
|
+
),
|
|
179
244
|
}
|
|
180
245
|
return { view, files, texts, pane: agent?.pane ?? null }
|
|
181
246
|
}
|
|
@@ -185,14 +250,14 @@ const laneView = async (
|
|
|
185
250
|
* and without phases, marked as unlisted.
|
|
186
251
|
*/
|
|
187
252
|
const unlistedView = async (sources: LaneSources, { name, agent }: UnlistedLane): Promise<LaneResult> => {
|
|
188
|
-
const { view: running } = await runningState(sources, name, agent)
|
|
253
|
+
const { view: running } = await runningState(sources, name, agent ?? undefined)
|
|
189
254
|
const view: LaneView = {
|
|
190
255
|
progress: laneProgress(unlistedSprintLane(name), {}),
|
|
191
256
|
...running,
|
|
192
257
|
chips: {},
|
|
193
258
|
unlisted: true,
|
|
194
259
|
}
|
|
195
|
-
return { view, files: [], texts: {}, pane: agent
|
|
260
|
+
return { view, files: [], texts: {}, pane: agent?.pane ?? null }
|
|
196
261
|
}
|
|
197
262
|
|
|
198
263
|
/** The view with the watcher's badges of its own pull requests, only when there are any, so it otherwise stays as it was. */
|
|
@@ -223,6 +288,30 @@ const withTickets = (result: LaneResult, tickets: readonly SourceTicket[]): Lane
|
|
|
223
288
|
return found ? { ...view, chips: withoutTicketLinks(view.chips, found), tickets: found } : view
|
|
224
289
|
}
|
|
225
290
|
|
|
291
|
+
/** What agents report doing; nothing when it cannot be read. */
|
|
292
|
+
const readSignals = ({ root, readSignals: read }: Source, worktrees: string, now: number): TaskSignal[] => {
|
|
293
|
+
try {
|
|
294
|
+
return (
|
|
295
|
+
read ??
|
|
296
|
+
((main, directory, at) =>
|
|
297
|
+
readAllSignals({ projects: defaultProjectsDirectory(), root: main, worktrees: directory }, at))
|
|
298
|
+
)(root, worktrees, now)
|
|
299
|
+
} catch {
|
|
300
|
+
return []
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The reports no shown lane takes, as lines for the overview: the coordinator's own and those of lanes without a plan. */
|
|
305
|
+
const unplacedOf = (sources: LaneSources, lanes: readonly SprintLane[], onlyLane: boolean): UnplannedTask[] => {
|
|
306
|
+
if (onlyLane) return []
|
|
307
|
+
const listed = new Set(lanes.map((lane) => lane.name))
|
|
308
|
+
const rest = sources.signals.filter(({ cwd }) => {
|
|
309
|
+
const lane = laneOfDirectory(cwd, sources.worktrees)
|
|
310
|
+
return lane === null || !listed.has(lane)
|
|
311
|
+
})
|
|
312
|
+
return assignSignals([], rest).unplanned
|
|
313
|
+
}
|
|
314
|
+
|
|
226
315
|
/** The terminal's agents, or none with the reason when it cannot be asked, so the screen says why lanes show "no agent". */
|
|
227
316
|
const readAgents = (terminal: LaneStatusSource): { agents: LaneAgent[]; errors: string[] } => {
|
|
228
317
|
try {
|
|
@@ -232,6 +321,53 @@ const readAgents = (terminal: LaneStatusSource): { agents: LaneAgent[]; errors:
|
|
|
232
321
|
}
|
|
233
322
|
}
|
|
234
323
|
|
|
324
|
+
/** What subagents of the coordinator do in the worktrees; nothing when their logs cannot be read. */
|
|
325
|
+
const readSubagents = ({ root, readSubagents: read }: Source, worktrees: string): Map<string, SubagentActivity> => {
|
|
326
|
+
try {
|
|
327
|
+
return (
|
|
328
|
+
read ??
|
|
329
|
+
((main, directory) =>
|
|
330
|
+
readSubagentActivity({
|
|
331
|
+
projects: defaultProjectsDirectory(),
|
|
332
|
+
root: main,
|
|
333
|
+
approvalsRoot: main,
|
|
334
|
+
worktreeDirectory: directory,
|
|
335
|
+
}))
|
|
336
|
+
)(root, worktrees)
|
|
337
|
+
} catch {
|
|
338
|
+
return new Map()
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* The open items that wait on the user, from the checkout's board file (the coordinator edits it before any commit),
|
|
344
|
+
* else from the text read at the base ref.
|
|
345
|
+
*/
|
|
346
|
+
const waitingOf = (
|
|
347
|
+
{ sprintFile, settings }: Source,
|
|
348
|
+
markdown: string,
|
|
349
|
+
sends: readonly SentFileRecord[],
|
|
350
|
+
now: number,
|
|
351
|
+
): WaitingItem[] => {
|
|
352
|
+
const board = (() => {
|
|
353
|
+
try {
|
|
354
|
+
return openWaitingItems(
|
|
355
|
+
existsSync(sprintFile) ? readFileSync(sprintFile, 'utf8') : markdown,
|
|
356
|
+
settings.planLanguage,
|
|
357
|
+
)
|
|
358
|
+
} catch {
|
|
359
|
+
return openWaitingItems(markdown, settings.planLanguage)
|
|
360
|
+
}
|
|
361
|
+
})()
|
|
362
|
+
return [...board, ...sentWaitingItems(sends, now)]
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The view with the files its session sent to the user, only when there are any, so it otherwise stays as it was. */
|
|
366
|
+
const withSentFiles = (view: LaneView, chips: ReadonlyMap<string | null, SentChip[]>): LaneView => {
|
|
367
|
+
const found = chips.get(view.progress.lane.name)
|
|
368
|
+
return found ? { ...view, sentFiles: found } : view
|
|
369
|
+
}
|
|
370
|
+
|
|
235
371
|
/** Up to two names, then how many more: `a, b and 3 more`. */
|
|
236
372
|
const namedWithMore = (names: readonly string[]): string => {
|
|
237
373
|
const more = names.length - SHOWN_NAMES
|
|
@@ -291,6 +427,92 @@ const forgetLanes = (memories: Map<string, LaneMemory> | undefined, lanes: reado
|
|
|
291
427
|
for (const name of [...(memories?.keys() ?? [])]) if (!names.has(name)) memories?.delete(name)
|
|
292
428
|
}
|
|
293
429
|
|
|
430
|
+
const laneSetOf = (
|
|
431
|
+
source: Source,
|
|
432
|
+
context: { base: BaseReader; board: BoardLanes; sprintDirectory: string; worktrees: string },
|
|
433
|
+
pullRequests: PullRequestState,
|
|
434
|
+
now: number,
|
|
435
|
+
): LaneSet => {
|
|
436
|
+
const { base, board, sprintDirectory, worktrees } = context
|
|
437
|
+
const { agents, errors } = readAgents(source.terminal)
|
|
438
|
+
const isWanted = ({ name }: { name: string }) => !source.options.lane || name === source.options.lane
|
|
439
|
+
const subagents = readSubagents(source, worktrees)
|
|
440
|
+
const listed = new Set(board.lanes.map((lane) => lane.name))
|
|
441
|
+
return {
|
|
442
|
+
lanes: board.lanes.filter(isWanted),
|
|
443
|
+
unlisted: unlistedLanes(agents, listed, worktrees, new Set(subagents.keys())).filter(isWanted),
|
|
444
|
+
sources: {
|
|
445
|
+
source,
|
|
446
|
+
base,
|
|
447
|
+
sprintDirectory,
|
|
448
|
+
agents,
|
|
449
|
+
subagents,
|
|
450
|
+
readLog: onceReader(source.readAgentLog ?? agentLog),
|
|
451
|
+
pullRequests,
|
|
452
|
+
signals: readSignals(source, worktrees, now),
|
|
453
|
+
worktrees,
|
|
454
|
+
},
|
|
455
|
+
errors,
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** The views of the listed lanes, then of the unlisted ones; the lanes' memories are cleaned of lanes the board dropped. */
|
|
460
|
+
const laneResultsOf = async (
|
|
461
|
+
{ lanes, unlisted, sources }: LaneSet,
|
|
462
|
+
fallbacks: Fallbacks,
|
|
463
|
+
now: number,
|
|
464
|
+
memories: Map<string, LaneMemory> | undefined,
|
|
465
|
+
): Promise<LaneResult[]> => {
|
|
466
|
+
const results = [
|
|
467
|
+
...(await Promise.all(lanes.map((lane) => laneView(sources, lane, fallbacks, now, memories)))),
|
|
468
|
+
...(await Promise.all(unlisted.map((lane) => unlistedView(sources, lane)))),
|
|
469
|
+
]
|
|
470
|
+
forgetLanes(memories, lanes)
|
|
471
|
+
return results
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Each lane's view with its tickets, the watcher's badges, its phases' models and the files sent from it. */
|
|
475
|
+
const decoratedViews = (
|
|
476
|
+
results: readonly LaneResult[],
|
|
477
|
+
{ reading, phaseModels, sprintDirectory, sentChips }: Decorations,
|
|
478
|
+
): LaneView[] =>
|
|
479
|
+
results.map((result) =>
|
|
480
|
+
withSentFiles(
|
|
481
|
+
withModels(
|
|
482
|
+
withBadges(withTickets(result, reading?.tickets ?? []), reading?.badges ?? {}),
|
|
483
|
+
phaseModels,
|
|
484
|
+
sprintDirectory,
|
|
485
|
+
),
|
|
486
|
+
sentChips,
|
|
487
|
+
),
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
/** The files sent to the user, read once: the records, and their badges by lane. */
|
|
491
|
+
const sendsOf = (
|
|
492
|
+
root: string,
|
|
493
|
+
worktrees: string,
|
|
494
|
+
now: number,
|
|
495
|
+
): { records: SentFileRecord[]; chips: Map<string | null, SentChip[]> } => {
|
|
496
|
+
const records = readSentRecords(root)
|
|
497
|
+
return { records, chips: sentChipsByLane(records, worktrees, now) }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** The open phases no lane with an agent leads; none while the view shows one lane. */
|
|
501
|
+
const plannedOf = (
|
|
502
|
+
source: Source,
|
|
503
|
+
plans: Record<string, string>,
|
|
504
|
+
results: readonly LaneResult[],
|
|
505
|
+
sprintDirectory: string,
|
|
506
|
+
): PlannedPlan[] =>
|
|
507
|
+
source.options.lane
|
|
508
|
+
? []
|
|
509
|
+
: plannedPlans({
|
|
510
|
+
plans,
|
|
511
|
+
lanes: results.map(({ view }) => ({ lane: view.progress.lane, agent: view.status.agent })),
|
|
512
|
+
sprintDirectory,
|
|
513
|
+
language: source.settings.planLanguage,
|
|
514
|
+
})
|
|
515
|
+
|
|
294
516
|
/**
|
|
295
517
|
* Reads the board, its plans and the lanes' state. `memories` holds, per lane, what the herd saw at the last read; it
|
|
296
518
|
* is how the current phase becomes the one whose boxes moved since, and it is cleaned of lanes the board dropped.
|
|
@@ -300,74 +522,48 @@ export const loadSnapshot = async (
|
|
|
300
522
|
pullRequests: PullRequestState,
|
|
301
523
|
memories?: Map<string, LaneMemory>,
|
|
302
524
|
): Promise<Snapshot> => {
|
|
303
|
-
const { root, sprintFile, options, settings
|
|
525
|
+
const { root, sprintFile, options, settings } = source
|
|
304
526
|
const fallbacks: Fallbacks = []
|
|
305
|
-
const
|
|
527
|
+
const baseRef = baseRefOf(settings)
|
|
528
|
+
const base = baseReaderOf(root, baseRef)
|
|
306
529
|
const boardPath = boardRepoPathOf(root, settings, sprintFile)
|
|
307
530
|
const board = await readBoardLanes(source, base, boardPath, fallbacks)
|
|
308
531
|
const { markdown } = board
|
|
309
532
|
const sprintDirectory = dirname(boardPath)
|
|
310
|
-
const
|
|
311
|
-
const isWanted = ({ name }: { name: string }) => !options.lane || name === options.lane
|
|
312
|
-
const lanes = board.lanes.filter(isWanted)
|
|
313
|
-
const unlisted = unlistedLanes(
|
|
314
|
-
agents,
|
|
315
|
-
new Set(board.lanes.map((lane) => lane.name)),
|
|
316
|
-
join(root, settings.worktreeDirectory),
|
|
317
|
-
).filter(isWanted)
|
|
318
|
-
const sources = {
|
|
319
|
-
source,
|
|
320
|
-
base,
|
|
321
|
-
sprintDirectory,
|
|
322
|
-
agents,
|
|
323
|
-
pullRequests,
|
|
324
|
-
}
|
|
533
|
+
const worktrees = join(root, settings.worktreeDirectory)
|
|
325
534
|
const now = Date.now()
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
...(await Promise.all(unlisted.map((lane) => unlistedView(sources, lane)))),
|
|
329
|
-
]
|
|
330
|
-
forgetLanes(memories, lanes)
|
|
535
|
+
const laneSet = laneSetOf(source, { base, board, sprintDirectory, worktrees }, pullRequests, now)
|
|
536
|
+
const results = await laneResultsOf(laneSet, fallbacks, now, memories)
|
|
331
537
|
const listedPlans = await readListedPlans(source, base, sprintDirectory, markdown, fallbacks)
|
|
332
538
|
const documentsPlans = Object.assign({}, listedPlans, ...results.map((result) => result.texts))
|
|
333
|
-
const planned = options.lane
|
|
334
|
-
? []
|
|
335
|
-
: plannedPlans({
|
|
336
|
-
plans: documentsPlans,
|
|
337
|
-
lanes: results.map(({ view }) => ({ lane: view.progress.lane, agent: view.status.agent })),
|
|
338
|
-
sprintDirectory,
|
|
339
|
-
language: settings.planLanguage,
|
|
340
|
-
})
|
|
341
539
|
const { ticketSources } = settings
|
|
342
540
|
const watched = ticketSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
|
|
343
541
|
const reading = watched ? readWatchReading(root, now, ticketSources) : null
|
|
344
|
-
const
|
|
345
|
-
const baseRef = baseRefOf(settings)
|
|
542
|
+
const sends = sendsOf(root, worktrees, now)
|
|
346
543
|
const notice = fallbacks.length > 0 ? fallbackNotice(fallbacks, baseRef, refExists(root, baseRef)) : null
|
|
347
544
|
const unknownLaneTitle =
|
|
348
545
|
options.lane && results.length === 0 ? boardTexts(settings.board.term).laneNotListed(options.lane) : null
|
|
349
546
|
return {
|
|
350
547
|
title: unknownLaneTitle ?? sprintTitleOf(markdown, sprintDirectory),
|
|
351
|
-
views:
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
),
|
|
358
|
-
),
|
|
359
|
-
),
|
|
548
|
+
views: decoratedViews(results, {
|
|
549
|
+
reading,
|
|
550
|
+
phaseModels: readPhaseModels(root),
|
|
551
|
+
sprintDirectory,
|
|
552
|
+
sentChips: sends.chips,
|
|
553
|
+
}),
|
|
360
554
|
documents: {
|
|
361
555
|
sprint: markdown,
|
|
362
556
|
plans: documentsPlans,
|
|
363
|
-
laneOrder: [...lanes, ...unlisted].map((lane) => lane.name),
|
|
557
|
+
laneOrder: [...laneSet.lanes, ...laneSet.unlisted].map((lane) => lane.name),
|
|
364
558
|
lanes: board.lanes,
|
|
365
559
|
},
|
|
366
|
-
planned,
|
|
560
|
+
planned: plannedOf(source, documentsPlans, results, sprintDirectory),
|
|
367
561
|
files: [sprintFile, ...results.flatMap((result) => result.files)],
|
|
368
562
|
titles: new Map(results.flatMap(({ view, pane }) => (pane ? [[pane, shortTitle(view.progress)]] : []))),
|
|
369
|
-
errors: [...errors, ...pullRequests.errors, ...(notice ? [notice] : [])],
|
|
563
|
+
errors: [...laneSet.errors, ...pullRequests.errors, ...(notice ? [notice] : [])],
|
|
370
564
|
watch: reading?.health,
|
|
565
|
+
waiting: waitingOf(source, markdown, sends.records, now),
|
|
566
|
+
unplaced: unplacedOf(laneSet.sources, laneSet.lanes, Boolean(options.lane)),
|
|
371
567
|
...(ticketSources.length > 0 ? { watchesTickets: true } : {}),
|
|
372
568
|
}
|
|
373
569
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { projectDirectoryOf, readTailLines } from '../context/session-log'
|
|
4
|
+
import { MS_PER_MINUTE } from '../plan-format/clock'
|
|
5
|
+
import { readApprovalRequests } from '../watch/approvals'
|
|
6
|
+
import {
|
|
7
|
+
runningSubagents,
|
|
8
|
+
SUBAGENT_STALE_MINUTES,
|
|
9
|
+
type SubagentActivity,
|
|
10
|
+
type SubagentLog,
|
|
11
|
+
subagentsByLane,
|
|
12
|
+
} from './subagents'
|
|
13
|
+
|
|
14
|
+
export type SubagentSource = {
|
|
15
|
+
projects: string
|
|
16
|
+
/** The main checkout whose sessions are read. */
|
|
17
|
+
root: string
|
|
18
|
+
/** The main checkout whose `.jackherd/approvals/` holds the gate's requests. */
|
|
19
|
+
approvalsRoot: string
|
|
20
|
+
/** The absolute worktree directory. */
|
|
21
|
+
worktreeDirectory: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The end of a log that holds the directory of the last entries. */
|
|
25
|
+
const TAIL_BYTES = 64 * 1024
|
|
26
|
+
const META_FILE = /^agent-([0-9a-f]+)\.meta\.json$/
|
|
27
|
+
/** A request the gate holds is reported for this long, as by the watcher. */
|
|
28
|
+
const OPEN_REQUEST_MINUTES = 30
|
|
29
|
+
|
|
30
|
+
const directoriesOf = (path: string): string[] => {
|
|
31
|
+
try {
|
|
32
|
+
return readdirSync(path, { withFileTypes: true }).flatMap((entry) => (entry.isDirectory() ? [entry.name] : []))
|
|
33
|
+
} catch {
|
|
34
|
+
return []
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const logOf = (directory: string, name: string, id: string, now: number): SubagentLog[] => {
|
|
39
|
+
try {
|
|
40
|
+
const logPath = join(directory, `agent-${id}.jsonl`)
|
|
41
|
+
const modifiedAt = statSync(logPath).mtimeMs
|
|
42
|
+
if (now - modifiedAt > SUBAGENT_STALE_MINUTES * MS_PER_MINUTE) return []
|
|
43
|
+
const meta: unknown = JSON.parse(readFileSync(join(directory, name), 'utf8'))
|
|
44
|
+
return [{ id, meta, modifiedAt, tail: readTailLines(logPath, TAIL_BYTES) }]
|
|
45
|
+
} catch {
|
|
46
|
+
return []
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The subagents with a recently written log in the sessions started in the main checkout `root`, from Claude Code's
|
|
52
|
+
* `<projects>/<project>/<session>/subagents/agent-<id>.{meta.json,jsonl}`; whatever cannot be read is left out.
|
|
53
|
+
*/
|
|
54
|
+
export const readSubagentLogs = (projects: string, root: string, now: number): SubagentLog[] => {
|
|
55
|
+
const project = join(projects, projectDirectoryOf(root))
|
|
56
|
+
return directoriesOf(project).flatMap((session) => {
|
|
57
|
+
const directory = join(project, session, 'subagents')
|
|
58
|
+
if (!existsSync(directory)) return []
|
|
59
|
+
return readdirSync(directory).flatMap((name) => {
|
|
60
|
+
const id = META_FILE.exec(name)?.[1]
|
|
61
|
+
return id ? logOf(directory, name, id, now) : []
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The names the gate holds an open request under, as the watcher reads them. */
|
|
67
|
+
const heldNames = (root: string, now: number): Set<string> =>
|
|
68
|
+
new Set(
|
|
69
|
+
readApprovalRequests(root)
|
|
70
|
+
.filter((request) => request.usedAt === null && now - request.createdAt <= OPEN_REQUEST_MINUTES * MS_PER_MINUTE)
|
|
71
|
+
.map((request) => request.lane),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
/** The state of every worktree a subagent of the main checkout's sessions works in. */
|
|
75
|
+
export const readSubagentActivity = (
|
|
76
|
+
{ projects, root, approvalsRoot, worktreeDirectory }: SubagentSource,
|
|
77
|
+
now: number = Date.now(),
|
|
78
|
+
): Map<string, SubagentActivity> =>
|
|
79
|
+
subagentsByLane(
|
|
80
|
+
runningSubagents(readSubagentLogs(projects, root, now), heldNames(approvalsRoot, now), now, worktreeDirectory),
|
|
81
|
+
worktreeDirectory,
|
|
82
|
+
)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { parseLogEntry } from '../context/session-log'
|
|
2
|
+
import { MS_PER_MINUTE } from '../plan-format/clock'
|
|
3
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
4
|
+
import { isRecord } from '../shared/is-record'
|
|
5
|
+
import { AGENT_STATUS, type AgentStatus } from '../terminal/types'
|
|
6
|
+
import { activityTextOf } from './current-task'
|
|
7
|
+
import { laneDirectoryOf } from './unlisted-lanes'
|
|
8
|
+
|
|
9
|
+
/** What the log files of one subagent say: its id, its `meta.json` as parsed, when the log was written, its last lines. */
|
|
10
|
+
export type SubagentLog = { id: string; meta: unknown; modifiedAt: number; tail: readonly string[] }
|
|
11
|
+
|
|
12
|
+
/** A subagent of the coordinator that is at work now. */
|
|
13
|
+
export type RunningSubagent = {
|
|
14
|
+
label: string
|
|
15
|
+
cwd: string | null
|
|
16
|
+
description: string | null
|
|
17
|
+
status: Extract<AgentStatus, 'working' | 'blocked'>
|
|
18
|
+
/** The text of its last log entries, from which the herd tells the task it works on (`taskSignalsOf`). */
|
|
19
|
+
activity: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What a worktree shows of the subagents working in it. */
|
|
23
|
+
export type SubagentActivity = Pick<RunningSubagent, 'status' | 'description' | 'activity'>
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A log untouched this long is not at work: Claude Code marks a finished subagent with `finished: true` in its
|
|
27
|
+
* `meta.json` only in its newer versions, so a subagent that was killed or ended before that has no mark.
|
|
28
|
+
*/
|
|
29
|
+
export const SUBAGENT_STALE_MINUTES = 15
|
|
30
|
+
const AGENT_ID = /^[0-9a-f]{8,64}$/
|
|
31
|
+
const AGENT_TYPE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
|
32
|
+
const ID_LENGTH = 8
|
|
33
|
+
const ACTIVITY_ENTRIES = 40
|
|
34
|
+
const DESCRIPTION_LENGTH = 80
|
|
35
|
+
|
|
36
|
+
/** The name the approval gate holds a subagent under (`kit/hooks/hooks/approval-gate/subagent.ts`, contract test). */
|
|
37
|
+
export const subagentLabelOf = (id: string, type: string | null): string | null =>
|
|
38
|
+
AGENT_ID.test(id)
|
|
39
|
+
? `subagent-${type !== null && AGENT_TYPE.test(type) ? type : 'agent'}-${id.slice(0, ID_LENGTH)}`
|
|
40
|
+
: null
|
|
41
|
+
|
|
42
|
+
const textOrNull = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null)
|
|
43
|
+
|
|
44
|
+
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
45
|
+
|
|
46
|
+
/** The strings a tool call of a log entry carries as paths: `file_path`, `path` and the `command` of a shell call. */
|
|
47
|
+
const toolCallTexts = (entry: Record<string, unknown>): string[] => {
|
|
48
|
+
const content = isRecord(entry.message) && Array.isArray(entry.message.content) ? entry.message.content : []
|
|
49
|
+
return content.flatMap((block) => {
|
|
50
|
+
if (!isRecord(block) || block.type !== 'tool_use' || !isRecord(block.input)) return []
|
|
51
|
+
return [block.input.file_path, block.input.path, block.input.command].filter(
|
|
52
|
+
(value): value is string => typeof value === 'string',
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The last worktree below `worktreeDirectory` that a text names, as its directory; null when it names none. */
|
|
58
|
+
const lastWorktreeIn = (text: string, worktreeDirectory: string): string | null => {
|
|
59
|
+
const pattern = new RegExp(`${escapeRegExp(worktreeDirectory)}/([A-Za-z0-9._-]+)`, 'g')
|
|
60
|
+
const name = [...text.matchAll(pattern)].at(-1)?.[1]
|
|
61
|
+
return name === undefined ? null : `${worktreeDirectory}/${name}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Where the subagent works: the worktree its most recent tool call names (a path, or a path in a shell command), since
|
|
66
|
+
* a coordinator's subagent starts in the main directory and changes directory per command, so its entries carry that
|
|
67
|
+
* directory; else the directory of the last entry that names one. Lines that are no JSON (the cut-off first one) are
|
|
68
|
+
* skipped.
|
|
69
|
+
*/
|
|
70
|
+
const workingDirectory = (tail: readonly string[], worktreeDirectory: string): string | null => {
|
|
71
|
+
const entries = [...tail].reverse().flatMap((line): Record<string, unknown>[] => {
|
|
72
|
+
const entry = parseLogEntry(line)
|
|
73
|
+
return entry === null ? [] : [entry]
|
|
74
|
+
})
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
for (const text of toolCallTexts(entry).reverse()) {
|
|
77
|
+
const named = lastWorktreeIn(text, worktreeDirectory)
|
|
78
|
+
if (named !== null) return named
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const directory = entries.find((entry) => typeof entry.cwd === 'string')?.cwd
|
|
82
|
+
return typeof directory === 'string' ? directory : null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The subagents at work: no `finished` mark in the meta, and the log written within `SUBAGENT_STALE_MINUTES`. One
|
|
87
|
+
* waits for an answer while the approval gate holds a request under its name (`openRequests` are the names of the open
|
|
88
|
+
* requests); the transcript alone cannot tell a permission dialog from a long tool call.
|
|
89
|
+
*/
|
|
90
|
+
export const runningSubagents = (
|
|
91
|
+
logs: readonly SubagentLog[],
|
|
92
|
+
openRequests: ReadonlySet<string>,
|
|
93
|
+
now: number,
|
|
94
|
+
worktreeDirectory: string,
|
|
95
|
+
): RunningSubagent[] =>
|
|
96
|
+
logs.flatMap(({ id, meta, modifiedAt, tail }) => {
|
|
97
|
+
if (!isRecord(meta) || meta.finished === true || now - modifiedAt > SUBAGENT_STALE_MINUTES * MS_PER_MINUTE)
|
|
98
|
+
return []
|
|
99
|
+
const label = subagentLabelOf(id, textOrNull(meta.agentType))
|
|
100
|
+
if (label === null) return []
|
|
101
|
+
const description = textOrNull(typeof meta.description === 'string' ? terminalSafe(meta.description) : null)
|
|
102
|
+
const shortDescription = description?.slice(0, DESCRIPTION_LENGTH) ?? null
|
|
103
|
+
return [
|
|
104
|
+
{
|
|
105
|
+
label,
|
|
106
|
+
cwd: workingDirectory(tail, worktreeDirectory),
|
|
107
|
+
description: shortDescription,
|
|
108
|
+
status: openRequests.has(label) ? AGENT_STATUS.blocked : AGENT_STATUS.working,
|
|
109
|
+
activity: activityTextOf(tail, ACTIVITY_ENTRIES),
|
|
110
|
+
},
|
|
111
|
+
]
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
/** The worktrees below `worktreeDirectory` the subagents work in; one that waits for an answer decides the state. */
|
|
115
|
+
export const subagentsByLane = (
|
|
116
|
+
running: readonly RunningSubagent[],
|
|
117
|
+
worktreeDirectory: string,
|
|
118
|
+
): Map<string, SubagentActivity> => {
|
|
119
|
+
const lanes = new Map<string, SubagentActivity>()
|
|
120
|
+
for (const { cwd, status, description, activity } of running) {
|
|
121
|
+
const lane = cwd === null ? null : laneDirectoryOf(cwd, worktreeDirectory)
|
|
122
|
+
if (lane === null) continue
|
|
123
|
+
const known = lanes.get(lane)
|
|
124
|
+
if (!known) lanes.set(lane, { status, description, activity })
|
|
125
|
+
else
|
|
126
|
+
lanes.set(lane, {
|
|
127
|
+
...(status === AGENT_STATUS.blocked && known.status !== AGENT_STATUS.blocked ? { status, description } : known),
|
|
128
|
+
activity: `${known.activity} ${activity}`,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
return lanes
|
|
132
|
+
}
|