jackherd 1.8.1 → 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.
Files changed (111) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/INSTALL.md +2 -0
  3. package/README.md +37 -44
  4. package/dist/{chunk-OU2KALXP.js → chunk-MCP4XJQH.js} +15 -0
  5. package/dist/config.d.ts +6 -0
  6. package/dist/config.js +19 -3
  7. package/dist/plan-format.d.ts +19 -2
  8. package/dist/plan-format.js +21 -7
  9. package/herdr-plugin/herdr-plugin.toml +1 -1
  10. package/install.sh +1 -1
  11. package/kit/hooks/hooks/approval-gate/index.ts +22 -4
  12. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  13. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  14. package/kit/hooks/hooks/merge-guard/decision.ts +67 -20
  15. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  16. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  17. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  18. package/kit/hooks/hooks/sent-file/store.ts +18 -0
  19. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  20. package/kit/hooks/hooks.json +9 -0
  21. package/kit/hooks/statusline.sh +1 -1
  22. package/kit/workflow/skills/implement/SKILL.md +2 -0
  23. package/kit/workflow/skills/orchestrate/SKILL.md +30 -6
  24. package/package.json +2 -1
  25. package/schemas/jackherd.schema.json +26 -2
  26. package/src/cli.ts +3 -0
  27. package/src/config/schema.ts +22 -2
  28. package/src/context/fill.ts +12 -0
  29. package/src/context/session-log.ts +43 -1
  30. package/src/plan-format/clock.ts +4 -0
  31. package/src/plan-format/dependencies.ts +2 -5
  32. package/src/plan-format/lanes.ts +30 -5
  33. package/src/plan-format/terminal-text.ts +4 -0
  34. package/src/plan-format/waiting.ts +48 -0
  35. package/src/plans/active-tasks.ts +60 -0
  36. package/src/plans/current-phase.ts +17 -7
  37. package/src/plans/current-task.ts +63 -5
  38. package/src/plans/lane-standing.ts +9 -4
  39. package/src/plans/lane-summary.ts +141 -74
  40. package/src/plans/lane-tickets.ts +15 -0
  41. package/src/plans/layout-phase.ts +57 -18
  42. package/src/plans/layout-summary.ts +85 -67
  43. package/src/plans/layout.ts +253 -89
  44. package/src/plans/live.ts +22 -0
  45. package/src/plans/phase-models.ts +1 -1
  46. package/src/plans/phase-ranges.ts +45 -0
  47. package/src/plans/pull-requests.ts +6 -0
  48. package/src/plans/selection.ts +19 -16
  49. package/src/plans/sent-files-read.ts +22 -0
  50. package/src/plans/sent-files.ts +121 -0
  51. package/src/plans/signals-read.ts +54 -0
  52. package/src/plans/snapshot.ts +251 -55
  53. package/src/plans/subagents-read.ts +82 -0
  54. package/src/plans/subagents.ts +132 -0
  55. package/src/plans/texts.ts +16 -11
  56. package/src/plans/unlisted-lanes.ts +10 -5
  57. package/src/plans/view.ts +3 -3
  58. package/src/plans/waiting-click.ts +76 -0
  59. package/src/preview/command.ts +83 -0
  60. package/src/preview/embed.ts +209 -0
  61. package/src/preview/images.ts +89 -0
  62. package/src/preview/open.ts +12 -0
  63. package/src/preview/options.ts +32 -0
  64. package/src/preview/page.ts +89 -0
  65. package/src/preview/render.ts +41 -0
  66. package/src/settled/board-row.ts +1 -2
  67. package/src/shared/is-record.ts +2 -0
  68. package/src/shared/private-records.ts +48 -0
  69. package/src/task-signals/agent.ts +15 -0
  70. package/src/task-signals/claude-read.ts +72 -0
  71. package/src/task-signals/claude-signals.ts +112 -0
  72. package/src/task-signals/command.ts +71 -0
  73. package/src/task-signals/fixtures/session.jsonl +7 -0
  74. package/src/task-signals/matcher.ts +96 -0
  75. package/src/task-signals/record-store.ts +40 -0
  76. package/src/task-signals/record.ts +68 -0
  77. package/src/task-signals/text.ts +13 -0
  78. package/src/task-signals/types.ts +14 -0
  79. package/src/terminal/open.ts +13 -6
  80. package/src/terminal/text.ts +8 -1
  81. package/src/terminal/width.ts +2 -1
  82. package/src/vocabulary/index.ts +28 -0
  83. package/src/waiting/command.ts +35 -0
  84. package/src/watch/appointments.ts +2 -3
  85. package/src/watch/approvals.ts +2 -2
  86. package/src/watch/command.ts +91 -5
  87. package/src/watch/failed-tests.ts +56 -10
  88. package/src/watch/failure-read.ts +18 -6
  89. package/src/watch/failures.ts +29 -5
  90. package/src/watch/feedback.ts +1 -1
  91. package/src/watch/gh-process.ts +9 -3
  92. package/src/watch/json-guards.ts +0 -3
  93. package/src/watch/lane-fill.ts +1 -1
  94. package/src/watch/lane-model.ts +1 -1
  95. package/src/watch/lane-parallel.ts +1 -1
  96. package/src/watch/lane-progress.ts +19 -7
  97. package/src/watch/lane-report.ts +5 -1
  98. package/src/watch/lane-rest.ts +28 -0
  99. package/src/watch/plan-badges.ts +9 -2
  100. package/src/watch/previews.ts +1 -1
  101. package/src/watch/pull-requests.ts +2 -1
  102. package/src/watch/source-path.ts +1 -1
  103. package/src/watch/sources.ts +1 -1
  104. package/src/watch/stream-output.ts +12 -7
  105. package/src/watch/task-parallel-input.ts +118 -0
  106. package/src/watch/task-parallel-read.ts +149 -0
  107. package/src/watch/task-parallel-touched.ts +39 -0
  108. package/src/watch/task-parallel.ts +206 -0
  109. package/src/watch/waiting-line.ts +19 -0
  110. package/src/watch/watch-events.ts +13 -1
  111. package/src/watch/workflow-runs.ts +2 -1
@@ -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 { type LaneView, sortByUrgency } from './layout'
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, source.readAgentLog ?? agentLog, source.settings.context)
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
- ...(agent?.title ? { sessionTitle: agent.title } : {}),
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({ progress, phases: phasesByHref, texts, activity, language: settings.planLanguage }, now),
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.pane }
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, terminal } = source
525
+ const { root, sprintFile, options, settings } = source
304
526
  const fallbacks: Fallbacks = []
305
- const base = baseReaderOf(root, baseRefOf(settings))
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 { agents, errors } = readAgents(terminal)
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 results = [
327
- ...(await Promise.all(lanes.map((lane) => laneView(sources, lane, fallbacks, now, memories)))),
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 phaseModels = readPhaseModels(root)
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: sortByUrgency(
352
- results.map((result) =>
353
- withModels(
354
- withBadges(withTickets(result, reading?.tickets ?? []), reading?.badges ?? {}),
355
- phaseModels,
356
- sprintDirectory,
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
+ }