jackherd 1.9.0 → 1.11.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 (228) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +65 -32
  4. package/dist/{chunk-MCP4XJQH.js → chunk-RMMXD4G5.js} +18 -6
  5. package/dist/config.d.ts +99 -4
  6. package/dist/config.js +105 -29
  7. package/dist/plan-format.d.ts +51 -6
  8. package/dist/plan-format.js +49 -7
  9. package/docs/workflow-dark.svg +67 -63
  10. package/docs/workflow.svg +67 -63
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/ask-rules.json +15 -0
  14. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  15. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  16. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  17. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  18. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  19. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  20. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  21. package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
  22. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  23. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  24. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  25. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  26. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  27. package/kit/hooks/hooks/open-question/index.ts +26 -0
  28. package/kit/hooks/hooks/open-question/record.ts +135 -0
  29. package/kit/hooks/hooks/open-question/run.ts +57 -0
  30. package/kit/hooks/hooks/open-question/store.ts +39 -0
  31. package/kit/hooks/hooks/sent-file/store.ts +3 -12
  32. package/kit/hooks/hooks/shared/config.ts +7 -0
  33. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  34. package/kit/hooks/hooks/shared/files.ts +7 -0
  35. package/kit/hooks/hooks/shared/lane.ts +1 -1
  36. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  37. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  38. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  39. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  40. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  41. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  42. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  43. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  44. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  45. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  46. package/kit/hooks/hooks.json +13 -5
  47. package/kit/review/agents/skill-review.md +34 -0
  48. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  49. package/kit/rules/rules/kit/pull-requests.md +2 -0
  50. package/kit/workflow/skills/follow/SKILL.md +3 -10
  51. package/kit/workflow/skills/implement/SKILL.md +8 -6
  52. package/kit/workflow/skills/orchestrate/SKILL.md +218 -433
  53. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  54. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  55. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  56. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  57. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  58. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  59. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  60. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  61. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  62. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  63. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  64. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  65. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  66. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  67. package/kit/workflow/skills/plan/SKILL.md +6 -5
  68. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  69. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  70. package/package.json +3 -1
  71. package/recipes/README.md +2 -1
  72. package/recipes/sources.md +5 -1
  73. package/schemas/jackherd.schema.json +139 -5
  74. package/src/check/agent-text-reviewers.ts +23 -0
  75. package/src/check/command.ts +2 -0
  76. package/src/check/plan-structure-check.ts +49 -2
  77. package/src/check/plan.ts +8 -1
  78. package/src/check/schema.ts +9 -0
  79. package/src/check/skill-budget-defaults.ts +55 -0
  80. package/src/check/skill-budgets.ts +185 -0
  81. package/src/cli.ts +2 -0
  82. package/src/config/auto-window.ts +8 -0
  83. package/src/config/link-kinds.ts +7 -1
  84. package/src/config/schema.ts +82 -25
  85. package/src/config/tones.ts +27 -0
  86. package/src/lane/close-board.ts +106 -0
  87. package/src/lane/close-check.ts +183 -0
  88. package/src/lane/close-command-line.ts +11 -0
  89. package/src/lane/close-command.ts +217 -0
  90. package/src/lane/close-options.ts +43 -0
  91. package/src/lane/close-survey.ts +83 -0
  92. package/src/lane/close.ts +141 -0
  93. package/src/lane/command.ts +13 -0
  94. package/src/lane-prompt/additions.ts +14 -0
  95. package/src/lane-prompt/build.ts +74 -0
  96. package/src/lane-prompt/command.ts +93 -0
  97. package/src/lane-prompt/input.ts +157 -0
  98. package/src/lane-prompt/options.ts +70 -0
  99. package/src/lane-prompt/texts.ts +101 -0
  100. package/src/mermaid/command.ts +36 -0
  101. package/src/mermaid/problems.ts +48 -0
  102. package/src/plan-format/clock.ts +8 -0
  103. package/src/plan-format/dependencies.ts +9 -6
  104. package/src/plan-format/index.ts +1 -0
  105. package/src/plan-format/short-names.ts +71 -0
  106. package/src/plan-format/waiting.ts +22 -4
  107. package/src/plans/appointment-rows.ts +83 -0
  108. package/src/plans/ask-coordinator.ts +111 -0
  109. package/src/plans/ask-target.ts +36 -0
  110. package/src/plans/board-close.ts +85 -0
  111. package/src/plans/child-stop.ts +51 -0
  112. package/src/plans/closable-records.ts +34 -0
  113. package/src/plans/command.ts +3 -2
  114. package/src/plans/fetch.ts +26 -3
  115. package/src/plans/frame.ts +5 -12
  116. package/src/plans/git.ts +54 -5
  117. package/src/plans/groomed.ts +27 -0
  118. package/src/plans/herd-claim.ts +173 -0
  119. package/src/plans/herd-memory.ts +36 -25
  120. package/src/plans/lane-plans.ts +44 -13
  121. package/src/plans/lane-summary.ts +253 -45
  122. package/src/plans/layout-phase.ts +36 -11
  123. package/src/plans/layout-summary.ts +89 -18
  124. package/src/plans/layout.ts +502 -98
  125. package/src/plans/link-chips.ts +23 -4
  126. package/src/plans/live.ts +178 -23
  127. package/src/plans/next-rows.ts +44 -0
  128. package/src/plans/phase-ids.ts +8 -0
  129. package/src/plans/phase-source.ts +15 -0
  130. package/src/plans/plan-cache.ts +28 -0
  131. package/src/plans/plan-heads.ts +54 -0
  132. package/src/plans/planned.ts +22 -0
  133. package/src/plans/pull-requests.ts +17 -2
  134. package/src/plans/question-records.ts +48 -0
  135. package/src/plans/questions-read.ts +13 -0
  136. package/src/plans/read-cache.ts +8 -0
  137. package/src/plans/record-id.ts +4 -0
  138. package/src/plans/restart-decision.ts +13 -4
  139. package/src/plans/row-actions.ts +52 -0
  140. package/src/plans/row-focus.ts +148 -0
  141. package/src/plans/row-targets.ts +7 -0
  142. package/src/plans/selection.ts +111 -18
  143. package/src/plans/sent-files-read.ts +6 -15
  144. package/src/plans/sent-files.ts +7 -14
  145. package/src/plans/settings.ts +12 -1
  146. package/src/plans/signalled-phase.ts +42 -0
  147. package/src/plans/signals-read.ts +12 -4
  148. package/src/plans/snapshot.ts +162 -81
  149. package/src/plans/source-asked.ts +19 -0
  150. package/src/plans/source-prefix.ts +17 -0
  151. package/src/plans/source-rows.ts +188 -0
  152. package/src/plans/sprint-export.ts +11 -11
  153. package/src/plans/sprint-page.ts +5 -5
  154. package/src/plans/subagent-phases.ts +95 -0
  155. package/src/plans/subagent-tasks.ts +51 -0
  156. package/src/plans/subagents-read.ts +66 -17
  157. package/src/plans/subagents.ts +64 -6
  158. package/src/plans/supervisor.ts +14 -4
  159. package/src/plans/symbols.ts +3 -0
  160. package/src/plans/task-name.ts +34 -0
  161. package/src/plans/texts.ts +48 -6
  162. package/src/plans/tones.ts +30 -0
  163. package/src/plans/view.ts +25 -11
  164. package/src/plans/waiting-click.ts +295 -35
  165. package/src/plans/waiting-records.ts +39 -0
  166. package/src/plans/waiting-window.ts +8 -0
  167. package/src/preview/command.ts +25 -3
  168. package/src/shared/private-location.ts +89 -0
  169. package/src/shared/stat-cache.ts +48 -0
  170. package/src/sync/kit-source.ts +29 -10
  171. package/src/sync/kit.ts +2 -0
  172. package/src/sync/manifest.ts +2 -0
  173. package/src/sync/plan-ask-rules.ts +94 -0
  174. package/src/sync/plan-hooks.ts +2 -0
  175. package/src/sync/render.ts +3 -3
  176. package/src/sync/settings-hooks.ts +4 -1
  177. package/src/sync/sync-plan.ts +1 -0
  178. package/src/sync/sync.ts +24 -3
  179. package/src/task-signals/claude-read.ts +27 -10
  180. package/src/terminal/text.ts +16 -3
  181. package/src/terminal/types.ts +4 -0
  182. package/src/version/skill-diff.ts +46 -0
  183. package/src/vocabulary/index.ts +26 -6
  184. package/src/waiting/command.ts +22 -7
  185. package/src/watch/appointments.ts +1 -2
  186. package/src/watch/asking-lanes.ts +40 -0
  187. package/src/watch/base-tip.ts +11 -0
  188. package/src/watch/command.ts +330 -105
  189. package/src/watch/heartbeat-progress.ts +6 -18
  190. package/src/watch/kit-update.ts +28 -9
  191. package/src/watch/lane-name.ts +6 -0
  192. package/src/watch/lane-progress.ts +40 -8
  193. package/src/watch/lane-report-file.ts +20 -5
  194. package/src/watch/lane-report.ts +32 -4
  195. package/src/watch/lane-sources.ts +31 -3
  196. package/src/watch/main-checkout.ts +34 -0
  197. package/src/watch/plan-badges-read.ts +22 -9
  198. package/src/watch/plan-badges.ts +10 -2
  199. package/src/watch/pull-requests.ts +6 -2
  200. package/src/watch/reference-paths.ts +87 -0
  201. package/src/watch/report-questions.ts +4 -3
  202. package/src/watch/running-subagents.ts +43 -0
  203. package/src/watch/skill-change.ts +124 -32
  204. package/src/watch/source-change.ts +48 -0
  205. package/src/watch/source-entries.ts +55 -0
  206. package/src/watch/source-fields.ts +1 -0
  207. package/src/watch/source-statuses.ts +60 -0
  208. package/src/watch/source-tickets.ts +21 -1
  209. package/src/watch/sources.ts +47 -4
  210. package/src/watch/stream-output.ts +4 -3
  211. package/src/watch/task-parallel-auto.ts +208 -0
  212. package/src/watch/task-parallel-data-paths.ts +46 -0
  213. package/src/watch/task-parallel-input.ts +5 -15
  214. package/src/watch/task-parallel-read.ts +3 -1
  215. package/src/watch/task-parallel.ts +36 -7
  216. package/src/watch/task-signal-missing.ts +69 -0
  217. package/src/watch/task-signal-read.ts +29 -0
  218. package/src/watch/texts.ts +10 -0
  219. package/src/watch/update-available.ts +6 -3
  220. package/src/watch/watch-events.ts +23 -0
  221. package/src/watch/watcher-lock.ts +25 -29
  222. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  223. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  224. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  225. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  226. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  227. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  228. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -0,0 +1,188 @@
1
+ import { TONE, type ToneName } from '../config/tones'
2
+ import { padVisible } from '../terminal/text'
3
+ import { textWidth } from '../terminal/width'
4
+ import type { EntryStatus, SourceStatus } from '../watch/source-statuses'
5
+ import { ASK_KIND } from './ask-target'
6
+ import type { AskedCheck } from './source-asked'
7
+ import { chevronOf } from './symbols'
8
+ import type { BlockItem } from './waiting-click'
9
+
10
+ /** A source's status with the tone its config names for the mark while its report is new; null where it names none. */
11
+ export type HerdSource = SourceStatus & { tone?: ToneName | null }
12
+
13
+ /** The sources block: the listed sources, and the clock (epoch milliseconds) their ages are counted against. */
14
+ export type SourceBlock = { sources: HerdSource[]; now: number }
15
+
16
+ /** What decides how the rows of the block look: the keys the user opened, and which entries a row of "Waiting for you" asks about. */
17
+ export type SourceView = { expanded: ReadonlySet<string>; asked: AskedCheck }
18
+
19
+ /** How many entries of an opened source show before `+<n> more`. */
20
+ export const SOURCE_ENTRY_ROWS = 5
21
+
22
+ const SOURCE_MARK = '●'
23
+ const ENTRY_MARK = '•'
24
+ const MORE_MARK = ' '
25
+ const SEPARATOR = ' · '
26
+ const ASKED_TEXT = '↑ asked'
27
+ const MINUTE_MS = 60_000
28
+ const HOUR_MS = 60 * MINUTE_MS
29
+ const DAY_MS = 24 * HOUR_MS
30
+
31
+ /** `just now`, `12 min ago`, `5 h ago` or `2 d ago`: the whole units since `since`, the largest that is at least one. */
32
+ const agoOf = (since: number, now: number): string => {
33
+ const elapsed = Math.max(now - since, 0)
34
+ if (elapsed >= DAY_MS) return `${Math.floor(elapsed / DAY_MS)} d ago`
35
+ if (elapsed >= HOUR_MS) return `${Math.floor(elapsed / HOUR_MS)} h ago`
36
+ return elapsed >= MINUTE_MS ? `${Math.floor(elapsed / MINUTE_MS)} min ago` : 'just now'
37
+ }
38
+
39
+ const ranText = (ranAt: number | null, now: number): string =>
40
+ ranAt === null ? 'not run yet' : `ran ${agoOf(ranAt, now)}`
41
+
42
+ const twoDigits = (value: number): string => String(value).padStart(2, '0')
43
+
44
+ /** `11:11` for a time on the day of `now`, else the day and month before it, in the local time of the terminal. */
45
+ const clockOf = (at: number, now: number): string => {
46
+ const date = new Date(at)
47
+ const time = `${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`
48
+ return date.toDateString() === new Date(now).toDateString()
49
+ ? time
50
+ : `${twoDigits(date.getDate())}.${twoDigits(date.getMonth() + 1)}. ${time}`
51
+ }
52
+
53
+ const sourceKey = ({ name }: SourceStatus): string => `source>${name}`
54
+
55
+ const entryKey = (name: string, { id }: EntryStatus): string => `source>${name}>${id}`
56
+
57
+ /** The key in the herd's expanded set that shows every entry of the source, not the first five. */
58
+ export const sourceMoreKey = (name: string): string => `more>source>${name}`
59
+
60
+ const isUnaskedNew = (name: string, entry: EntryStatus, asked: AskedCheck): boolean =>
61
+ entry.isNew && !asked(name, entry.title)
62
+
63
+ /**
64
+ * Red while the source fails; else the source's own tone or `newTone` while it has a new entry nobody asked about (for a
65
+ * source without entries: while its newest report is new); none otherwise.
66
+ */
67
+ const toneOf = (source: HerdSource, newTone: ToneName, asked: AskedCheck): ToneName | undefined => {
68
+ if (source.failingSince !== null) return TONE.red
69
+ const hasNew =
70
+ source.entries.length > 0
71
+ ? source.entries.some((entry) => isUnaskedNew(source.name, entry, asked))
72
+ : source.latest?.isNew === true
73
+ return hasNew ? (source.tone ?? newTone) : undefined
74
+ }
75
+
76
+ const textOf = (source: HerdSource, now: number, expanded: boolean): string => {
77
+ const chevron = source.entries.length > 0 ? `${chevronOf(expanded)} ` : ''
78
+ const parts = [
79
+ ...(source.failingSince === null ? [] : [`failing since ${agoOf(source.failingSince, now)}`]),
80
+ ranText(source.ranAt, now),
81
+ ...(source.latest && source.entries.length === 0 ? [source.latest.title] : []),
82
+ ]
83
+ return `${chevron}${parts.join(SEPARATOR)}`
84
+ }
85
+
86
+ const headerOf = (
87
+ source: HerdSource,
88
+ view: SourceView,
89
+ now: number,
90
+ newTone: ToneName,
91
+ nameWidth: number,
92
+ ): BlockItem => {
93
+ const key = sourceKey(source)
94
+ const hasEntries = source.entries.length > 0
95
+ return {
96
+ key,
97
+ mark: SOURCE_MARK,
98
+ lead: padVisible(source.name, nameWidth),
99
+ text: textOf(source, now, view.expanded.has(key)),
100
+ url: hasEntries ? null : (source.latest?.url ?? null),
101
+ ask: null,
102
+ tone: toneOf(source, newTone, view.asked),
103
+ ...(hasEntries ? { toggle: key } : {}),
104
+ }
105
+ }
106
+
107
+ const entryToneOf = (
108
+ isAsked: boolean,
109
+ entry: EntryStatus,
110
+ source: HerdSource,
111
+ newTone: ToneName,
112
+ ): ToneName | undefined => {
113
+ if (isAsked) return TONE.grey
114
+ return entry.isNew ? (source.tone ?? newTone) : undefined
115
+ }
116
+
117
+ const entryTextOf = (entry: EntryStatus, now: number, isAsked: boolean): string =>
118
+ [entry.count === null ? '' : `${entry.count}×`, clockOf(entry.at, now), isAsked ? ASKED_TEXT : '']
119
+ .filter((part) => part !== '')
120
+ .join(' ')
121
+
122
+ const entryItem = (
123
+ source: HerdSource,
124
+ entry: EntryStatus,
125
+ view: SourceView,
126
+ now: number,
127
+ newTone: ToneName,
128
+ titleWidth: number,
129
+ ): BlockItem => {
130
+ const isAsked = view.asked(source.name, entry.title)
131
+ const tone = entryToneOf(isAsked, entry, source, newTone)
132
+ return {
133
+ key: entryKey(source.name, entry),
134
+ mark: ENTRY_MARK,
135
+ lead: padVisible(entry.title, titleWidth),
136
+ text: entryTextOf(entry, now, isAsked),
137
+ url: entry.url,
138
+ ask: { kind: ASK_KIND.sourceEntry, source: source.name, title: entry.title },
139
+ ...(tone && { tone }),
140
+ ...(isAsked && { dim: true }),
141
+ }
142
+ }
143
+
144
+ const moreItem = (name: string, hidden: number, showsAll: boolean): BlockItem => ({
145
+ key: sourceMoreKey(name),
146
+ mark: MORE_MARK,
147
+ text: showsAll ? '▴ fewer' : `+${hidden} more`,
148
+ url: null,
149
+ ask: null,
150
+ toggle: sourceMoreKey(name),
151
+ })
152
+
153
+ const entryItems = (source: HerdSource, view: SourceView, now: number, newTone: ToneName): BlockItem[] => {
154
+ const showsAll = view.expanded.has(sourceMoreKey(source.name))
155
+ const shown = showsAll ? source.entries : source.entries.slice(0, SOURCE_ENTRY_ROWS)
156
+ const titleWidth = Math.max(0, ...shown.map(({ title }) => textWidth(title)))
157
+ const hidden = source.entries.length - SOURCE_ENTRY_ROWS
158
+ return [
159
+ ...shown.map((entry) => entryItem(source, entry, view, now, newTone, titleWidth)),
160
+ ...(hidden > 0 ? [moreItem(source.name, hidden, showsAll)] : []),
161
+ ]
162
+ }
163
+
164
+ const DEFAULT_VIEW: SourceView = { expanded: new Set(), asked: () => false }
165
+
166
+ /**
167
+ * The rows of the sources block: per source one row with its name, when it ran, since when it fails and, for a source
168
+ * without entries, its newest report. A source with entries is folded to that row, and a click on it opens its entries
169
+ * (`view.expanded` holds its key): each with title, count and time, at most five and then `+<n> more`, a click on an
170
+ * entry opens its address, `[ask]` asks the coordinator to look at exactly that entry. An entry that a row of
171
+ * "Waiting for you" already asks about is dim and says `↑ asked`, and it does not make the source amber.
172
+ * `newTone` is the tone of the role `new`, which a source without its own tone wears while it has something new.
173
+ */
174
+ export const sourceItems = (
175
+ sources: readonly HerdSource[],
176
+ now: number,
177
+ newTone: ToneName,
178
+ view: SourceView = DEFAULT_VIEW,
179
+ ): BlockItem[] => {
180
+ const nameWidth = Math.max(0, ...sources.map(({ name }) => textWidth(name)))
181
+ return sources.flatMap((source) => [
182
+ headerOf(source, view, now, newTone, nameWidth),
183
+ ...(view.expanded.has(sourceKey(source)) ? entryItems(source, view, now, newTone) : []),
184
+ ])
185
+ }
186
+
187
+ /** The keys of all rows: what the fold of the block remembers. */
188
+ export const sourceKeys = (sources: readonly SourceStatus[]): string[] => sources.map(sourceKey)
@@ -1,7 +1,7 @@
1
- import { mkdirSync, writeFileSync } from 'node:fs'
2
- import { tmpdir } from 'node:os'
3
1
  import { dirname } from 'node:path'
4
2
  import { pathToFileURL } from 'node:url'
3
+ import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
4
+ import { pageDirectoryOf, systemLocation } from '../shared/private-location'
5
5
  import { openInBrowser } from '../terminal/open'
6
6
  import { errorText } from './error-text'
7
7
  import { baseRefInfo, remoteBlobBase } from './git'
@@ -11,19 +11,19 @@ import { renderPlanBody, renderSprintHtml } from './sprint-html'
11
11
  import { buildSprintPage, type SprintPage, sprintPagePath } from './sprint-page'
12
12
  import { boardTexts } from './texts'
13
13
 
14
- const DIRECTORY_MODE = 0o700
15
- const FILE_MODE = 0o600
16
-
17
- /** What writing and opening the page needs from outside; the defaults are the real ones. */
14
+ /**
15
+ * What writing and opening the page needs from outside; the defaults are the real ones. `pageDirectory` is the
16
+ * user's own directory the page goes into, verified private before anything is written.
17
+ */
18
18
  export type ExportEnvironment = {
19
19
  now: () => Date
20
- temporaryDirectory: string
20
+ pageDirectory: string
21
21
  open: (target: string) => string | null
22
22
  }
23
23
 
24
24
  const systemEnvironment = (): ExportEnvironment => ({
25
25
  now: () => new Date(),
26
- temporaryDirectory: tmpdir(),
26
+ pageDirectory: pageDirectoryOf(systemLocation()),
27
27
  open: (target) => openInBrowser(target),
28
28
  })
29
29
 
@@ -46,10 +46,10 @@ const writePage = (
46
46
  { sprintFile, root, settings }: Source,
47
47
  environment: ExportEnvironment,
48
48
  ): WrittenPage | { error: string } => {
49
- const path = sprintPagePath(environment.temporaryDirectory, dirname(boardRepoPathOf(root, settings, sprintFile)))
49
+ const path = sprintPagePath(environment.pageDirectory, dirname(boardRepoPathOf(root, settings, sprintFile)))
50
50
  try {
51
- mkdirSync(dirname(path), { recursive: true, mode: DIRECTORY_MODE })
52
- writeFileSync(path, renderSprintHtml(page), { mode: FILE_MODE })
51
+ ensurePrivateDirectory(environment.pageDirectory)
52
+ writePrivateFile(path, renderSprintHtml(page))
53
53
  } catch (error) {
54
54
  return { error: errorText(error) }
55
55
  }
@@ -16,6 +16,7 @@ import type { PlanLanguage } from '../vocabulary'
16
16
  import type { LaneView } from './layout'
17
17
  import { extractLinkChips, type LinkChip } from './link-chips'
18
18
  import { planAnchorOf } from './markdown-html'
19
+ import { mainPhaseIdOf } from './phase-ids'
19
20
  import type { PullRequest } from './pull-requests'
20
21
  import type { Snapshot } from './snapshot'
21
22
 
@@ -150,14 +151,13 @@ const uniqueId = (base: string, taken: Set<string>): string => {
150
151
  }
151
152
 
152
153
  const phaseOf = (phase: PhaseProgress, dependencies: Record<string, string>): PagePhase => {
153
- const numbered = phase.id.replace(/[a-z]$/, '')
154
154
  return {
155
155
  id: phase.id,
156
156
  title: phase.title,
157
157
  done: phase.done,
158
158
  total: phase.total,
159
159
  items: phase.items,
160
- dependsOn: dependencies[phase.id] ?? dependencies[numbered] ?? null,
160
+ dependsOn: dependencies[phase.id] ?? dependencies[mainPhaseIdOf(phase.id)] ?? null,
161
161
  }
162
162
  }
163
163
 
@@ -282,6 +282,6 @@ export const buildSprintPage = (snapshot: Snapshot, context: PageContext): Sprin
282
282
  }
283
283
  }
284
284
 
285
- /** The file the page is written to: `<temporary directory>/jackherd/<board directory>.html`. */
286
- export const sprintPagePath = (temporaryDirectory: string, sprintDirectory: string): string =>
287
- join(temporaryDirectory, 'jackherd', `${posix.basename(sprintDirectory) || 'board'}.html`)
285
+ /** The file the page is written to: `<page directory>/<board directory>.html`. */
286
+ export const sprintPagePath = (pageDirectory: string, sprintDirectory: string): string =>
287
+ join(pageDirectory, `${posix.basename(sprintDirectory) || 'board'}.html`)
@@ -0,0 +1,95 @@
1
+ import { isPhaseOpen, type PlanPhase } from '../plan-format'
2
+ import { PHASE_ID_PREFIX, spannedPhaseIds } from '../plan-format/lanes'
3
+ import { isNamedBy } from './phase-ids'
4
+
5
+ /** The agent types that review a phase; they sit on its review gate. */
6
+ export const REVIEW_TYPE = {
7
+ uncleBob: 'uncle-bob',
8
+ specReview: 'spec-review',
9
+ securityAudit: 'security-audit',
10
+ review: 'review',
11
+ ive: 'ive',
12
+ jobs: 'jobs',
13
+ } as const
14
+
15
+ export type ReviewType = (typeof REVIEW_TYPE)[keyof typeof REVIEW_TYPE]
16
+
17
+ /**
18
+ * The phases a running subagent names in its description, its review type (null for no review), and when its log was
19
+ * last written. `ids` is empty only for a review that names no phase: the lane places it (`PhaselessReview`).
20
+ */
21
+ export type SubagentPhaseRun = { ids: string[]; review: ReviewType | null; at: number }
22
+
23
+ /** A running review whose description names no phase: it sits on the lane's signalled phase, else shows as its description. */
24
+ export type PhaselessReview = { review: ReviewType; at: number; description: string | null }
25
+
26
+ /** What the subagents say about one phase of the lane: the types of the reviews among them, and the newest log write. */
27
+ export type SubagentPhase = { reviews: ReviewType[]; at: number }
28
+
29
+ const isReviewTypeName = (type: string): type is ReviewType => (Object.values(REVIEW_TYPE) as string[]).includes(type)
30
+
31
+ /** `P21`, `P17-P20`, `P17 – 19`, `Phase 21`, `Phase 10c`: the first number, an optional sub-phase letter, an optional end. */
32
+ const PHASE_MENTION = new RegExp(
33
+ `(?:\\b${PHASE_ID_PREFIX}|\\bPhase\\s+)(\\d+)([a-z]?)(?:\\s*[-–—]\\s*(?:${PHASE_ID_PREFIX})?(\\d+)\\b)?`,
34
+ 'gi',
35
+ )
36
+
37
+ /** The ids of a range `first`-`last`; a range that runs backwards or is absurdly wide names only its first end. */
38
+ const rangeIds = (first: number, last: number): string[] => {
39
+ const spanned = spannedPhaseIds(first, last)
40
+ return spanned.length > 0 ? spanned : [String(first)]
41
+ }
42
+
43
+ /** The ids of the phases a description names, in the order named; a range names every phase between its ends. */
44
+ export const phaseIdsOfDescription = (description: string): string[] =>
45
+ [...description.matchAll(PHASE_MENTION)].flatMap(([, first = '', letter = '', last]) =>
46
+ last === undefined ? [`${Number(first)}${letter.toLowerCase()}`] : rangeIds(Number(first), Number(last)),
47
+ )
48
+
49
+ const withoutPrefix = (type: string, prefixes: readonly string[]): string => {
50
+ const prefix = prefixes.find((each) => each !== '' && type.startsWith(each))
51
+ return prefix === undefined ? type : type.slice(prefix.length)
52
+ }
53
+
54
+ /** The `REVIEW_TYPE` an agent type is, plain or with one of the skill `prefixes` of a copy; null for any other type. */
55
+ export const reviewTypeOf = (type: string | null, prefixes: readonly string[]): ReviewType | null => {
56
+ const plain = type === null ? null : withoutPrefix(type, prefixes)
57
+ return plain !== null && isReviewTypeName(plain) ? plain : null
58
+ }
59
+
60
+ /**
61
+ * The run of a subagent from its `description` and `agentType`: null when the description names no phase and the
62
+ * subagent is no review. The description is read whole, not cut for the display.
63
+ */
64
+ export const subagentPhaseRunOf = (
65
+ description: string | null,
66
+ agentType: string | null,
67
+ at: number,
68
+ reviewPrefixes: readonly string[],
69
+ ): SubagentPhaseRun | null => {
70
+ const ids = description === null ? [] : phaseIdsOfDescription(description)
71
+ const review = reviewTypeOf(agentType, reviewPrefixes)
72
+ return ids.length === 0 && review === null ? null : { ids, review, at }
73
+ }
74
+
75
+ const names = (run: SubagentPhaseRun, id: string): boolean => isNamedBy(id, new Set(run.ids))
76
+
77
+ const reviewsOf = (runs: readonly SubagentPhaseRun[]): ReviewType[] => [
78
+ ...new Set(runs.flatMap(({ review }) => (review === null ? [] : [review]))),
79
+ ]
80
+
81
+ /**
82
+ * The open phases of the lane that running subagents name, by phase id: a sub-phase (`21b`) is named by its main phase.
83
+ * Phases the lane does not hold are left out, since another lane's phases are not its own.
84
+ */
85
+ export const subagentPhases = (
86
+ phases: readonly Pick<PlanPhase, 'id' | 'items'>[],
87
+ runs: readonly SubagentPhaseRun[],
88
+ ): Map<string, SubagentPhase> =>
89
+ new Map(
90
+ phases.filter(isPhaseOpen).flatMap(({ id }) => {
91
+ const naming = runs.filter((run) => names(run, id))
92
+ if (naming.length === 0) return []
93
+ return [[id, { reviews: reviewsOf(naming), at: Math.max(...naming.map(({ at }) => at)) }]]
94
+ }),
95
+ )
@@ -0,0 +1,51 @@
1
+ import type { ActiveTask } from './active-tasks'
2
+ import type { TaskPhase } from './current-task'
3
+ import { taskNameOf } from './task-name'
4
+
5
+ /** A running subagent that is no review: its description as the herd shows it, and when its log was last written. */
6
+ export type DescribedSubagent = { description: string; at: number }
7
+
8
+ /** Where the subagents sit: their tasks by phase id, and the descriptions placed, which show no line of their own. */
9
+ export type SubagentTaskPlacements = { byPhase: Map<string, ActiveTask[]>; placed: string[] }
10
+
11
+ type NamedTask = { phaseId: string; index: number; name: string }
12
+
13
+ /** A letter or digit right after the name continues a word, so `Probe` does not name `Proben lesen`. */
14
+ const WORD_GOES_ON = /^[\p{L}\p{N}]/u
15
+
16
+ /** The open tasks that carry a short name, the longest names first, so a name that starts another loses to it. */
17
+ const namedOpenTasks = (phases: readonly TaskPhase[]): NamedTask[] =>
18
+ phases
19
+ .flatMap(({ id, items }) =>
20
+ items.flatMap((item, index) => {
21
+ const name = item.done || item.gate ? null : taskNameOf(item.text)
22
+ return name === null ? [] : [{ phaseId: id, index, name }]
23
+ }),
24
+ )
25
+ .sort((first, second) => second.name.length - first.name.length)
26
+
27
+ const startsWithName = (description: string, name: string): boolean =>
28
+ description.toLowerCase().startsWith(name.toLowerCase()) && !WORD_GOES_ON.test(description.slice(name.length))
29
+
30
+ /**
31
+ * The open task each subagent works on, from the short name its description starts with (`/implement` Step 0b, case
32
+ * ignored): it gets its `▶` like a reported task, one per task however many subagents share it. A ticked task and a
33
+ * review gate item take none; a subagent whose description starts with no name stays where it was.
34
+ */
35
+ export const subagentTaskPlacements = (
36
+ phases: readonly TaskPhase[],
37
+ subagents: readonly DescribedSubagent[],
38
+ ): SubagentTaskPlacements => {
39
+ const tasks = namedOpenTasks(phases)
40
+ const byPhase = new Map<string, ActiveTask[]>()
41
+ const placed: string[] = []
42
+ for (const { description, at } of subagents) {
43
+ const task = tasks.find(({ name }) => startsWithName(description, name))
44
+ if (task === undefined) continue
45
+ placed.push(description)
46
+ const known = byPhase.get(task.phaseId) ?? []
47
+ if (known.some(({ index }) => index === task.index)) continue
48
+ byPhase.set(task.phaseId, [...known, { index: task.index, agent: null, guessed: false, startedAt: at }])
49
+ }
50
+ return { byPhase, placed }
51
+ }
@@ -1,9 +1,11 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { projectDirectoryOf, readTailLines } from '../context/session-log'
3
+ import { defaultProjectsDirectory, projectDirectoryOf, readTailLines } from '../context/session-log'
4
4
  import { MS_PER_MINUTE } from '../plan-format/clock'
5
+ import { DEFAULT_SKILL_PREFIX } from '../sync/init'
5
6
  import { readApprovalRequests } from '../watch/approvals'
6
7
  import {
8
+ type RunningSubagent,
7
9
  runningSubagents,
8
10
  SUBAGENT_STALE_MINUTES,
9
11
  type SubagentActivity,
@@ -19,8 +21,19 @@ export type SubagentSource = {
19
21
  approvalsRoot: string
20
22
  /** The absolute worktree directory. */
21
23
  worktreeDirectory: string
24
+ /** The project's `skillPrefix`, which its copies of the review agents carry; without it only the default one counts. */
25
+ skillPrefix?: string
22
26
  }
23
27
 
28
+ /** The source of the main checkout `root`: Claude Code's own session logs, the gate's requests in the checkout itself. */
29
+ export const subagentSourceOf = (root: string, worktreeDirectory: string, skillPrefix?: string): SubagentSource => ({
30
+ projects: defaultProjectsDirectory(),
31
+ root,
32
+ approvalsRoot: root,
33
+ worktreeDirectory,
34
+ skillPrefix,
35
+ })
36
+
24
37
  /** The end of a log that holds the directory of the last entries. */
25
38
  const TAIL_BYTES = 64 * 1024
26
39
  const META_FILE = /^agent-([0-9a-f]+)\.meta\.json$/
@@ -35,6 +48,15 @@ const directoriesOf = (path: string): string[] => {
35
48
  }
36
49
  }
37
50
 
51
+ /** The entry names of `path`; none when it is missing or cannot be read. */
52
+ const namesIn = (path: string): string[] => {
53
+ try {
54
+ return readdirSync(path)
55
+ } catch {
56
+ return []
57
+ }
58
+ }
59
+
38
60
  const logOf = (directory: string, name: string, id: string, now: number): SubagentLog[] => {
39
61
  try {
40
62
  const logPath = join(directory, `agent-${id}.jsonl`)
@@ -47,22 +69,39 @@ const logOf = (directory: string, name: string, id: string, now: number): Subage
47
69
  }
48
70
  }
49
71
 
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) => {
72
+ /** The subagent logs of every session in one project directory. */
73
+ const sessionLogsOf = (project: string, now: number): SubagentLog[] =>
74
+ directoriesOf(project).flatMap((session) => {
57
75
  const directory = join(project, session, 'subagents')
58
- if (!existsSync(directory)) return []
59
- return readdirSync(directory).flatMap((name) => {
76
+ return namesIn(directory).flatMap((name) => {
60
77
  const id = META_FILE.exec(name)?.[1]
61
78
  return id ? logOf(directory, name, id, now) : []
62
79
  })
63
80
  })
81
+
82
+ /**
83
+ * The project directories Claude Code keeps for sessions started in the main checkout `root` and, when
84
+ * `worktreeDirectory` is given, in a worktree below it: a lane's own subagents, its reviews among them, live there.
85
+ */
86
+ const projectDirectoriesOf = (projects: string, root: string, worktreeDirectory?: string): string[] => {
87
+ const main = projectDirectoryOf(root)
88
+ const lanes = worktreeDirectory === undefined ? null : `${projectDirectoryOf(worktreeDirectory)}-`
89
+ return directoriesOf(projects).filter((name) => name === main || (lanes !== null && name.startsWith(lanes)))
64
90
  }
65
91
 
92
+ /**
93
+ * The subagents with a recently written log in the sessions started in the main checkout `root` and its worktrees,
94
+ * from Claude Code's `<projects>/<project>/<session>/subagents/agent-<id>.{meta.json,jsonl}`; whatever cannot be read
95
+ * is left out.
96
+ */
97
+ export const readSubagentLogs = (
98
+ projects: string,
99
+ root: string,
100
+ now: number,
101
+ worktreeDirectory?: string,
102
+ ): SubagentLog[] =>
103
+ projectDirectoriesOf(projects, root, worktreeDirectory).flatMap((name) => sessionLogsOf(join(projects, name), now))
104
+
66
105
  /** The names the gate holds an open request under, as the watcher reads them. */
67
106
  const heldNames = (root: string, now: number): Set<string> =>
68
107
  new Set(
@@ -71,12 +110,22 @@ const heldNames = (root: string, now: number): Set<string> =>
71
110
  .map((request) => request.lane),
72
111
  )
73
112
 
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,
113
+ /**
114
+ * The subagents at work in the sessions of the main checkout and its worktrees: their logs read, the finished and the
115
+ * stale left out. The one chain the herd (`readSubagentActivity`) and the watcher's heartbeat share.
116
+ */
117
+ export const readRunningSubagents = (
118
+ { projects, root, approvalsRoot, worktreeDirectory, skillPrefix }: SubagentSource,
77
119
  now: number = Date.now(),
78
- ): Map<string, SubagentActivity> =>
79
- subagentsByLane(
80
- runningSubagents(readSubagentLogs(projects, root, now), heldNames(approvalsRoot, now), now, worktreeDirectory),
120
+ ): RunningSubagent[] =>
121
+ runningSubagents(
122
+ readSubagentLogs(projects, root, now, worktreeDirectory),
123
+ heldNames(approvalsRoot, now),
124
+ now,
81
125
  worktreeDirectory,
126
+ skillPrefix === undefined ? [DEFAULT_SKILL_PREFIX] : [DEFAULT_SKILL_PREFIX, skillPrefix],
82
127
  )
128
+
129
+ /** The state of every worktree a subagent of the main checkout's sessions works in. */
130
+ export const readSubagentActivity = (source: SubagentSource, now: number = Date.now()): Map<string, SubagentActivity> =>
131
+ subagentsByLane(readRunningSubagents(source, now), source.worktreeDirectory)
@@ -4,6 +4,8 @@ import { terminalSafe } from '../plan-format/terminal-text'
4
4
  import { isRecord } from '../shared/is-record'
5
5
  import { AGENT_STATUS, type AgentStatus } from '../terminal/types'
6
6
  import { activityTextOf } from './current-task'
7
+ import { type PhaselessReview, type SubagentPhaseRun, subagentPhaseRunOf } from './subagent-phases'
8
+ import type { DescribedSubagent } from './subagent-tasks'
7
9
  import { laneDirectoryOf } from './unlisted-lanes'
8
10
 
9
11
  /** What the log files of one subagent say: its id, its `meta.json` as parsed, when the log was written, its last lines. */
@@ -17,10 +19,25 @@ export type RunningSubagent = {
17
19
  status: Extract<AgentStatus, 'working' | 'blocked'>
18
20
  /** The text of its last log entries, from which the herd tells the task it works on (`taskSignalsOf`). */
19
21
  activity: string
22
+ /** The phases its full description names (`P21`, `P17-P20`), none for a review that names none; null for any other. */
23
+ phaseRun: SubagentPhaseRun | null
24
+ /** When its log was last written. */
25
+ writtenAt: number
20
26
  }
21
27
 
22
- /** What a worktree shows of the subagents working in it. */
23
- export type SubagentActivity = Pick<RunningSubagent, 'status' | 'description' | 'activity'>
28
+ /** What a worktree shows of the subagents working in it; `phaseRuns` are those of all its subagents that name a phase. */
29
+ export type SubagentActivity = Pick<RunningSubagent, 'status' | 'description' | 'activity'> & {
30
+ phaseRuns: SubagentPhaseRun[]
31
+ /** Its reviews that name no phase; the lane places them on its signalled phase (`laneSummary`). */
32
+ phaselessReviews: PhaselessReview[]
33
+ /**
34
+ * The descriptions of its other subagents that name no phase: what they work on, a signal of the lane like a
35
+ * reported task.
36
+ */
37
+ taskDescriptions: string[]
38
+ /** Its subagents that are no review, phase or not; one whose description starts with a task's short name sits on it. */
39
+ describedSubagents: DescribedSubagent[]
40
+ }
24
41
 
25
42
  /**
26
43
  * A log untouched this long is not at work: Claude Code marks a finished subagent with `finished: true` in its
@@ -85,18 +102,21 @@ const workingDirectory = (tail: readonly string[], worktreeDirectory: string): s
85
102
  /**
86
103
  * The subagents at work: no `finished` mark in the meta, and the log written within `SUBAGENT_STALE_MINUTES`. One
87
104
  * 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.
105
+ * requests); the transcript alone cannot tell a permission dialog from a long tool call. `reviewPrefixes` are the skill
106
+ * prefixes a copy of the review agents may carry.
89
107
  */
90
108
  export const runningSubagents = (
91
109
  logs: readonly SubagentLog[],
92
110
  openRequests: ReadonlySet<string>,
93
111
  now: number,
94
112
  worktreeDirectory: string,
113
+ reviewPrefixes: readonly string[],
95
114
  ): RunningSubagent[] =>
96
115
  logs.flatMap(({ id, meta, modifiedAt, tail }) => {
97
116
  if (!isRecord(meta) || meta.finished === true || now - modifiedAt > SUBAGENT_STALE_MINUTES * MS_PER_MINUTE)
98
117
  return []
99
- const label = subagentLabelOf(id, textOrNull(meta.agentType))
118
+ const agentType = textOrNull(meta.agentType)
119
+ const label = subagentLabelOf(id, agentType)
100
120
  if (label === null) return []
101
121
  const description = textOrNull(typeof meta.description === 'string' ? terminalSafe(meta.description) : null)
102
122
  const shortDescription = description?.slice(0, DESCRIPTION_LENGTH) ?? null
@@ -107,25 +127,63 @@ export const runningSubagents = (
107
127
  description: shortDescription,
108
128
  status: openRequests.has(label) ? AGENT_STATUS.blocked : AGENT_STATUS.working,
109
129
  activity: activityTextOf(tail, ACTIVITY_ENTRIES),
130
+ phaseRun: subagentPhaseRunOf(description, agentType, modifiedAt, reviewPrefixes),
131
+ writtenAt: modifiedAt,
110
132
  },
111
133
  ]
112
134
  })
113
135
 
136
+ /** The description of a subagent that names no phase, as a list of at most one; empty for a review or one that names a phase. */
137
+ const taskDescriptionOf = ({ phaseRun, description }: RunningSubagent): string[] =>
138
+ phaseRun === null && description !== null ? [description] : []
139
+
140
+ /** A subagent that is no review, with its description, as a list of at most one. */
141
+ const describedSubagentOf = ({ phaseRun, description, writtenAt }: RunningSubagent): DescribedSubagent[] =>
142
+ description !== null && (phaseRun === null || phaseRun.review === null) ? [{ description, at: writtenAt }] : []
143
+
144
+ /** The run of a subagent that names a phase, as a list of at most one. */
145
+ const namedRunOf = ({ phaseRun }: RunningSubagent): SubagentPhaseRun[] =>
146
+ phaseRun !== null && phaseRun.ids.length > 0 ? [phaseRun] : []
147
+
148
+ /** A review that names no phase, as a list of at most one. */
149
+ const phaselessReviewOf = ({ phaseRun, description }: RunningSubagent): PhaselessReview[] => {
150
+ if (phaseRun === null || phaseRun.review === null || phaseRun.ids.length > 0) return []
151
+ return [{ review: phaseRun.review, at: phaseRun.at, description }]
152
+ }
153
+
114
154
  /** The worktrees below `worktreeDirectory` the subagents work in; one that waits for an answer decides the state. */
115
155
  export const subagentsByLane = (
116
156
  running: readonly RunningSubagent[],
117
157
  worktreeDirectory: string,
118
158
  ): Map<string, SubagentActivity> => {
119
159
  const lanes = new Map<string, SubagentActivity>()
120
- for (const { cwd, status, description, activity } of running) {
160
+ for (const subagent of running) {
161
+ const { cwd, status, description, activity } = subagent
121
162
  const lane = cwd === null ? null : laneDirectoryOf(cwd, worktreeDirectory)
122
163
  if (lane === null) continue
123
164
  const known = lanes.get(lane)
124
- if (!known) lanes.set(lane, { status, description, activity })
165
+ const runs = namedRunOf(subagent)
166
+ const reviews = phaselessReviewOf(subagent)
167
+ const tasks = taskDescriptionOf(subagent)
168
+ const described = describedSubagentOf(subagent)
169
+ if (!known)
170
+ lanes.set(lane, {
171
+ status,
172
+ description,
173
+ activity,
174
+ phaseRuns: runs,
175
+ phaselessReviews: reviews,
176
+ taskDescriptions: tasks,
177
+ describedSubagents: described,
178
+ })
125
179
  else
126
180
  lanes.set(lane, {
127
181
  ...(status === AGENT_STATUS.blocked && known.status !== AGENT_STATUS.blocked ? { status, description } : known),
128
182
  activity: `${known.activity} ${activity}`,
183
+ phaseRuns: [...known.phaseRuns, ...runs],
184
+ phaselessReviews: [...known.phaselessReviews, ...reviews],
185
+ taskDescriptions: [...known.taskDescriptions, ...tasks],
186
+ describedSubagents: [...known.describedSubagents, ...described],
129
187
  })
130
188
  }
131
189
  return lanes