jackherd 1.9.0 → 1.10.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 (204) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +50 -32
  4. package/dist/{chunk-MCP4XJQH.js → chunk-5MQBCUQ5.js} +9 -4
  5. package/dist/config.d.ts +39 -4
  6. package/dist/config.js +67 -26
  7. package/dist/plan-format.d.ts +45 -6
  8. package/dist/plan-format.js +46 -6
  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 +52 -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/workflow/skills/follow/SKILL.md +1 -8
  50. package/kit/workflow/skills/implement/SKILL.md +8 -6
  51. package/kit/workflow/skills/orchestrate/SKILL.md +210 -433
  52. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  53. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  54. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  55. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  56. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  57. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  58. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  59. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  60. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  61. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  62. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  63. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  64. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  65. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  66. package/kit/workflow/skills/plan/SKILL.md +6 -5
  67. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  68. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  69. package/package.json +3 -1
  70. package/recipes/README.md +2 -1
  71. package/recipes/sources.md +1 -1
  72. package/schemas/jackherd.schema.json +59 -5
  73. package/src/check/agent-text-reviewers.ts +23 -0
  74. package/src/check/command.ts +2 -0
  75. package/src/check/plan-structure-check.ts +49 -2
  76. package/src/check/plan.ts +8 -1
  77. package/src/check/schema.ts +9 -0
  78. package/src/check/skill-budget-defaults.ts +55 -0
  79. package/src/check/skill-budgets.ts +185 -0
  80. package/src/cli.ts +1 -0
  81. package/src/config/auto-window.ts +8 -0
  82. package/src/config/link-kinds.ts +7 -1
  83. package/src/config/schema.ts +58 -24
  84. package/src/lane/close-board.ts +106 -0
  85. package/src/lane/close-check.ts +183 -0
  86. package/src/lane/close-command-line.ts +11 -0
  87. package/src/lane/close-command.ts +217 -0
  88. package/src/lane/close-options.ts +43 -0
  89. package/src/lane/close-survey.ts +83 -0
  90. package/src/lane/close.ts +141 -0
  91. package/src/lane/command.ts +13 -0
  92. package/src/lane-prompt/build.ts +74 -0
  93. package/src/lane-prompt/command.ts +87 -0
  94. package/src/lane-prompt/input.ts +116 -0
  95. package/src/lane-prompt/options.ts +68 -0
  96. package/src/lane-prompt/texts.ts +101 -0
  97. package/src/plan-format/clock.ts +8 -0
  98. package/src/plan-format/dependencies.ts +7 -4
  99. package/src/plan-format/index.ts +1 -0
  100. package/src/plan-format/short-names.ts +71 -0
  101. package/src/plan-format/waiting.ts +7 -2
  102. package/src/plans/appointment-rows.ts +59 -0
  103. package/src/plans/ask-coordinator.ts +77 -0
  104. package/src/plans/ask-target.ts +25 -0
  105. package/src/plans/child-stop.ts +51 -0
  106. package/src/plans/closable-records.ts +34 -0
  107. package/src/plans/command.ts +3 -2
  108. package/src/plans/fetch.ts +26 -3
  109. package/src/plans/git.ts +19 -5
  110. package/src/plans/herd-claim.ts +173 -0
  111. package/src/plans/herd-memory.ts +36 -25
  112. package/src/plans/lane-plans.ts +44 -13
  113. package/src/plans/lane-summary.ts +253 -45
  114. package/src/plans/layout-phase.ts +29 -7
  115. package/src/plans/layout-summary.ts +89 -18
  116. package/src/plans/layout.ts +262 -68
  117. package/src/plans/link-chips.ts +1 -0
  118. package/src/plans/live.ts +107 -20
  119. package/src/plans/phase-ids.ts +8 -0
  120. package/src/plans/phase-source.ts +15 -0
  121. package/src/plans/plan-cache.ts +28 -0
  122. package/src/plans/plan-heads.ts +54 -0
  123. package/src/plans/pull-requests.ts +17 -2
  124. package/src/plans/question-records.ts +48 -0
  125. package/src/plans/questions-read.ts +13 -0
  126. package/src/plans/read-cache.ts +8 -0
  127. package/src/plans/record-id.ts +4 -0
  128. package/src/plans/restart-decision.ts +13 -4
  129. package/src/plans/selection.ts +98 -18
  130. package/src/plans/sent-files-read.ts +6 -15
  131. package/src/plans/sent-files.ts +7 -14
  132. package/src/plans/settings.ts +2 -1
  133. package/src/plans/signalled-phase.ts +42 -0
  134. package/src/plans/signals-read.ts +12 -4
  135. package/src/plans/snapshot.ts +85 -62
  136. package/src/plans/sprint-export.ts +11 -11
  137. package/src/plans/sprint-page.ts +5 -5
  138. package/src/plans/subagent-phases.ts +95 -0
  139. package/src/plans/subagent-tasks.ts +51 -0
  140. package/src/plans/subagents-read.ts +66 -17
  141. package/src/plans/subagents.ts +64 -6
  142. package/src/plans/supervisor.ts +14 -4
  143. package/src/plans/symbols.ts +3 -0
  144. package/src/plans/task-name.ts +34 -0
  145. package/src/plans/texts.ts +23 -6
  146. package/src/plans/view.ts +17 -9
  147. package/src/plans/waiting-click.ts +192 -37
  148. package/src/plans/waiting-records.ts +39 -0
  149. package/src/plans/waiting-window.ts +8 -0
  150. package/src/preview/command.ts +25 -3
  151. package/src/shared/private-location.ts +89 -0
  152. package/src/shared/stat-cache.ts +48 -0
  153. package/src/sync/kit-source.ts +29 -10
  154. package/src/sync/kit.ts +2 -0
  155. package/src/sync/manifest.ts +2 -0
  156. package/src/sync/plan-ask-rules.ts +94 -0
  157. package/src/sync/plan-hooks.ts +2 -0
  158. package/src/sync/render.ts +3 -3
  159. package/src/sync/settings-hooks.ts +4 -1
  160. package/src/sync/sync-plan.ts +1 -0
  161. package/src/sync/sync.ts +24 -3
  162. package/src/task-signals/claude-read.ts +27 -10
  163. package/src/terminal/text.ts +8 -3
  164. package/src/version/skill-diff.ts +46 -0
  165. package/src/vocabulary/index.ts +16 -6
  166. package/src/waiting/command.ts +22 -7
  167. package/src/watch/appointments.ts +1 -2
  168. package/src/watch/asking-lanes.ts +40 -0
  169. package/src/watch/base-tip.ts +11 -0
  170. package/src/watch/command.ts +330 -105
  171. package/src/watch/heartbeat-progress.ts +6 -18
  172. package/src/watch/kit-update.ts +28 -9
  173. package/src/watch/lane-name.ts +6 -0
  174. package/src/watch/lane-progress.ts +40 -8
  175. package/src/watch/lane-report-file.ts +20 -5
  176. package/src/watch/lane-report.ts +32 -4
  177. package/src/watch/lane-sources.ts +31 -3
  178. package/src/watch/main-checkout.ts +34 -0
  179. package/src/watch/plan-badges-read.ts +13 -7
  180. package/src/watch/pull-requests.ts +6 -2
  181. package/src/watch/reference-paths.ts +87 -0
  182. package/src/watch/running-subagents.ts +43 -0
  183. package/src/watch/skill-change.ts +124 -32
  184. package/src/watch/source-change.ts +48 -0
  185. package/src/watch/sources.ts +6 -3
  186. package/src/watch/stream-output.ts +4 -3
  187. package/src/watch/task-parallel-auto.ts +208 -0
  188. package/src/watch/task-parallel-data-paths.ts +46 -0
  189. package/src/watch/task-parallel-input.ts +5 -15
  190. package/src/watch/task-parallel-read.ts +3 -1
  191. package/src/watch/task-parallel.ts +36 -7
  192. package/src/watch/task-signal-missing.ts +69 -0
  193. package/src/watch/task-signal-read.ts +29 -0
  194. package/src/watch/texts.ts +10 -0
  195. package/src/watch/update-available.ts +6 -3
  196. package/src/watch/watch-events.ts +23 -0
  197. package/src/watch/watcher-lock.ts +25 -29
  198. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  199. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  200. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  201. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  202. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  203. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  204. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -1,17 +1,27 @@
1
1
  import {
2
2
  isPhaseFinished,
3
3
  isPhaseOpen,
4
- isTickedUnmerged,
5
4
  type LaneProgress,
6
5
  type PhaseProgress,
7
6
  type PlanPhase,
8
7
  type PlansByHref,
9
8
  phaseProgress,
10
9
  } from '../plan-format'
10
+ import { MS_PER_MINUTE } from '../plan-format/clock'
11
11
  import type { TaskSignal } from '../task-signals/types'
12
12
  import { type ActiveTask, assignSignals, freshSignals, type UnplannedTask } from './active-tasks'
13
13
  import { type ActivityTimes, activityKey } from './current-phase'
14
14
  import { currentTasks, type TaskActivity } from './current-task'
15
+ import { isNamedBy } from './phase-ids'
16
+ import { signalledPhase } from './signalled-phase'
17
+ import {
18
+ type PhaselessReview,
19
+ type ReviewType,
20
+ type SubagentPhase,
21
+ type SubagentPhaseRun,
22
+ subagentPhases,
23
+ } from './subagent-phases'
24
+ import { type DescribedSubagent, subagentTaskPlacements } from './subagent-tasks'
15
25
 
16
26
  /**
17
27
  * A phase the lane works on now: an open one ticked less than `STALE_ACTIVITY_MINUTES` ago, one an agent reports, or
@@ -29,10 +39,13 @@ export type ActivePhase = {
29
39
  tasks: ActiveTask[]
30
40
  }
31
41
 
42
+ /** A phase with the step that shows it (null for none), which keys its row (`phaseKey`). */
43
+ export type PlacedPhase = { phase: PhaseProgress; stepIndex: number | null }
44
+
32
45
  /** Open phases that do not run, by number; `planTitle` is null for the lane's current plan, the board's title for another. */
33
46
  export type WaitingPhases = { planTitle: string | null; phaseIds: string[] }
34
47
 
35
- export type FinishedPhase = { id: string; unmerged: boolean }
48
+ export type FinishedPhase = { id: string }
36
49
 
37
50
  /** A phase the lane's board rows name that has nothing to run: `missing` when the plan lacks it, else it has no checkbox. */
38
51
  export type UnwrittenPhase = { id: string; missing: boolean }
@@ -46,13 +59,21 @@ export type LaneSummary = {
46
59
  active: ActivePhase[]
47
60
  /** The open phase of the current plan moved last, at any age; null while none has a time. */
48
61
  latest: PhaseProgress | null
62
+ /** The open phase the lane's newest report or its branch names (`signalledPhase`); it runs and the lane line names it. */
63
+ signalled: PhaseProgress | null
49
64
  /** The open phases of the current plan that do not run, then the open phases of the lane's other plans. */
50
65
  waiting: WaitingPhases[]
66
+ /** The phases behind `waiting`, in its order, for the rows `next` opens to. */
67
+ waitingPhases: PlacedPhase[]
51
68
  finished: FinishedPhase[]
69
+ /** The phases behind `finished`, for the rows `✓` opens to. */
70
+ finishedPhases: PlacedPhase[]
52
71
  unwritten: UnwrittenPhase[]
53
72
  tally: PlanTally
54
73
  /** What agents report doing that no open task of the plan names. */
55
74
  unplanned: UnplannedTask[]
75
+ /** What the running subagents without a phase in their description work on, as they described it. */
76
+ subagentTasks: string[]
56
77
  }
57
78
 
58
79
  /**
@@ -67,13 +88,21 @@ export type SummaryInput = {
67
88
  guesses?: readonly TaskActivity[]
68
89
  /** What the lane's agents report doing (task lists, `jh task`), matched to open tasks of the current plan. */
69
90
  reported?: readonly TaskSignal[]
91
+ /** The phases the descriptions of the lane's running subagents name; a signal like a report, never a tick. */
92
+ subagents?: readonly SubagentPhaseRun[]
93
+ /** The descriptions of the running subagents that name no phase; the folded lane shows them as tasks. */
94
+ subagentTasks?: readonly string[]
95
+ /** The running reviews that name no phase; they sit on the `signalled` phase, else show as tasks like `subagentTasks`. */
96
+ phaselessReviews?: readonly PhaselessReview[]
97
+ /** The running subagents that are no review; one whose description starts with a task's short name sits on it (`subagentTaskPlacements`). */
98
+ describedSubagents?: readonly DescribedSubagent[]
99
+ /** The branch checked out in the lane's worktree; a phase it names (`branchPhaseId`) is current while no report names one. */
100
+ branch?: string | null
70
101
  }
71
102
 
72
103
  /** Minutes without a tick after which a phase no longer runs, unless an agent reports it. */
73
104
  export const STALE_ACTIVITY_MINUTES = 20
74
105
 
75
- const MINUTE = 60_000
76
-
77
106
  const isWritten = ({ total }: PhaseProgress): boolean => total > 0
78
107
 
79
108
  const planHrefs = ({ steps }: LaneProgress): string[] => [
@@ -92,18 +121,12 @@ const stepPhases = ({ steps }: LaneProgress, href: string): PhaseProgress[] => {
92
121
  return shown.filter((phase, index) => shown.findIndex(({ id }) => id === phase.id) === index)
93
122
  }
94
123
 
95
- const SUB_PHASE_LETTERS = /[a-z]+$/i
96
-
97
124
  /** The ids the lane's steps on `href` name, a row's repeats and the phases it worked on without a row included. */
98
125
  const namedIds = ({ steps }: LaneProgress, href: string): Set<string> =>
99
126
  new Set(
100
127
  steps.flatMap(({ step }) => (step.planHref === href ? [...step.phaseIds, ...(step.namedPhaseIds ?? [])] : [])),
101
128
  )
102
129
 
103
- /** A plan phase the lane names: by its own id, or a sub-phase (`10c`) of a phase it names (`P10`). */
104
- const isNamed = (id: string, named: ReadonlySet<string>): boolean =>
105
- named.has(id) || named.has(id.replace(SUB_PHASE_LETTERS, ''))
106
-
107
130
  /**
108
131
  * The phases of `href` the lane names, in plan order: another lane's phases of the same plan are not its own, however
109
132
  * lately they moved.
@@ -112,7 +135,7 @@ const phasesOf = (input: SummaryInput, href: string): PhaseProgress[] => {
112
135
  const plan: readonly PlanPhase[] | undefined = input.phases[href]
113
136
  if (!plan) return stepPhases(input.progress, href)
114
137
  const named = namedIds(input.progress, href)
115
- return plan.filter(({ id }) => isNamed(id, named)).map(({ id }) => phaseProgress(plan, id))
138
+ return plan.filter(({ id }) => isNamedBy(id, named)).map(({ id }) => phaseProgress(plan, id))
116
139
  }
117
140
 
118
141
  const newestStepShowing = ({ steps }: LaneProgress, href: string, id: string): number | null => {
@@ -135,56 +158,166 @@ const isStarted = ({ items }: PhaseProgress): boolean => {
135
158
  return tasks.some(({ done }) => done) && tasks.some(({ done }) => !done)
136
159
  }
137
160
 
161
+ const minutesSince = (at: number, now: number): number => Math.max(Math.floor((now - at) / MS_PER_MINUTE), 0)
162
+
138
163
  /** The open phases with a time of their last movement, the one moved last first; a phase without a time is absent. */
139
164
  const timedPhases = (input: SummaryInput, href: string, phases: PhaseProgress[], now: number): TimedPhase[] =>
140
165
  phases
141
166
  .filter(isPhaseOpen)
142
167
  .flatMap((phase) => {
143
168
  const at = input.activity[activityKey(href, phase.id)]
144
- return at === undefined ? [] : [{ phase, minutesAgo: Math.max(Math.floor((now - at) / MINUTE), 0) }]
169
+ return at === undefined ? [] : [{ phase, minutesAgo: minutesSince(at, now) }]
145
170
  })
146
171
  .sort((first, second) => first.minutesAgo - second.minutesAgo)
147
172
 
148
173
  const newest = (times: readonly number[]): number | null => (times.length > 0 ? Math.max(...times) : null)
149
174
 
150
- const minutesSince = (at: number, now: number): number => Math.max(Math.floor((now - at) / MINUTE), 0)
151
-
152
175
  const isListed = (listed: readonly RunningPhase[], phase: PhaseProgress): boolean =>
153
176
  listed.some((each) => each.phase.id === phase.id)
154
177
 
155
- /** The open phases a reported task names that are not `listed` yet, aged from the newest report. */
178
+ /** What names a phase as running besides its ticks, by phase id: the tasks agents reported and the subagents at work. */
179
+ type PhaseSignals = {
180
+ reported: ReadonlyMap<string, ActiveTask[]>
181
+ subagents: ReadonlyMap<string, SubagentPhase>
182
+ }
183
+
184
+ /** When the newest report or running subagent of a phase began, in milliseconds; null for none. */
185
+ const reportedAt = (id: string, { reported, subagents }: PhaseSignals): number | null => {
186
+ const subagent = subagents.get(id)
187
+ return newest([
188
+ ...(reported.get(id) ?? []).flatMap((task) => task.startedAt ?? []),
189
+ ...(subagent === undefined ? [] : [subagent.at]),
190
+ ])
191
+ }
192
+
193
+ /** The open phases a reported task or a running subagent names that are not `listed` yet, aged from the newest of them. */
156
194
  const reportedPhases = (
157
195
  phases: PhaseProgress[],
158
- reported: ReadonlyMap<string, ActiveTask[]>,
196
+ signals: PhaseSignals,
159
197
  listed: readonly RunningPhase[],
160
198
  now: number,
161
199
  ): RunningPhase[] =>
162
200
  phases
163
- .filter((phase) => isPhaseOpen(phase) && reported.has(phase.id) && !isListed(listed, phase))
201
+ .filter(
202
+ (phase) =>
203
+ isPhaseOpen(phase) &&
204
+ (signals.reported.has(phase.id) || signals.subagents.has(phase.id)) &&
205
+ !isListed(listed, phase),
206
+ )
164
207
  .map((phase) => {
165
- const startedAt = newest((reported.get(phase.id) ?? []).flatMap((task) => task.startedAt ?? []))
208
+ const startedAt = reportedAt(phase.id, signals)
166
209
  return { phase, minutesAgo: startedAt === null ? 0 : minutesSince(startedAt, now) }
167
210
  })
168
211
 
212
+ /**
213
+ * Whether a text names the review type as a word (`/security-audit`, `` `uncle-bob` ``): `review` is not named by
214
+ * `skill-review` or `Review-Gate`. The types hold no character a pattern reads specially.
215
+ */
216
+ const namesReview = (text: string, review: ReviewType): boolean =>
217
+ new RegExp(`(?<![\\w-])${review}(?![\\w-])`).test(text)
218
+
219
+ /**
220
+ * The review gate box a running review sits on, which gets its `▶` like a reported task: the open one that names the
221
+ * review, else the first open one. A phase whose gate is ticked or that has none shows no arrow.
222
+ */
223
+ const reviewTask = ({ items }: PhaseProgress, review: ReviewType, at: number): ActiveTask[] => {
224
+ const isOpenGate = (item: PhaseProgress['items'][number]): boolean => item.gate === true && !item.done
225
+ const named = items.findIndex((item) => isOpenGate(item) && namesReview(item.text, review))
226
+ const index = named === -1 ? items.findIndex(isOpenGate) : named
227
+ return index === -1 ? [] : [{ index, agent: null, guessed: false, startedAt: at }]
228
+ }
229
+
230
+ /** Adds `tasks` to the phase's tasks in `merged`, each task once. */
231
+ const addTasks = (merged: Map<string, ActiveTask[]>, phaseId: string, tasks: readonly ActiveTask[]): void => {
232
+ const known = merged.get(phaseId) ?? []
233
+ merged.set(phaseId, [...known, ...tasks.filter(({ index }) => !known.some((task) => task.index === index))])
234
+ }
235
+
236
+ /** The reported tasks with the gate box each running review of a phase sits on added, unless a report names it already. */
237
+ const withReviewTasks = (
238
+ reported: ReadonlyMap<string, ActiveTask[]>,
239
+ phases: PhaseProgress[],
240
+ subagents: ReadonlyMap<string, SubagentPhase>,
241
+ ): Map<string, ActiveTask[]> => {
242
+ const merged = new Map(reported)
243
+ for (const phase of phases) {
244
+ const found = subagents.get(phase.id)
245
+ if (!found) continue
246
+ for (const review of found.reviews) addTasks(merged, phase.id, reviewTask(phase, review, found.at))
247
+ }
248
+ return merged
249
+ }
250
+
251
+ /** The reported tasks with the tasks the subagents sit on added (`subagentTaskPlacements`), unless a report names them already. */
252
+ const withTasks = (
253
+ reported: ReadonlyMap<string, ActiveTask[]>,
254
+ added: ReadonlyMap<string, ActiveTask[]>,
255
+ ): Map<string, ActiveTask[]> => {
256
+ const merged = new Map(reported)
257
+ for (const [phaseId, tasks] of added) addTasks(merged, phaseId, tasks)
258
+ return merged
259
+ }
260
+
261
+ /** The signalled phase when a review can sit on it: one with an open gate box, else none. */
262
+ const reviewPhaseOf = (signalled: PhaseProgress | null): PhaseProgress | null =>
263
+ signalled?.items.some((item) => item.gate && !item.done) ? signalled : null
264
+
265
+ /**
266
+ * The runs of the lane's subagents, its reviews that name no phase placed on the `signalled` phase while its gate has
267
+ * an open box; otherwise they stay unplaced and show as tasks (`unplacedTasks`), so a running review never vanishes.
268
+ */
269
+ const placedRuns = (input: SummaryInput, signalled: PhaseProgress | null): SubagentPhaseRun[] => {
270
+ const phase = reviewPhaseOf(signalled)
271
+ return [
272
+ ...(input.subagents ?? []),
273
+ ...(phase === null
274
+ ? []
275
+ : (input.phaselessReviews ?? []).map(({ review, at }) => ({ ids: [phase.id], review, at }))),
276
+ ]
277
+ }
278
+
279
+ /**
280
+ * The descriptions of the subagents that name no phase and sit on no task (`placed`), then those of the reviews no
281
+ * signalled phase takes.
282
+ */
283
+ const unplacedTasks = (input: SummaryInput, signalled: PhaseProgress | null, placed: readonly string[]): string[] => [
284
+ ...(input.subagentTasks ?? []).filter((description) => !placed.includes(description)),
285
+ ...(reviewPhaseOf(signalled) === null
286
+ ? (input.phaselessReviews ?? []).flatMap(({ description }) => (description === null ? [] : [description]))
287
+ : []),
288
+ ]
289
+
169
290
  /** The open phases with a ticked task (`isStarted`) that are not `listed` yet, aged from their last tick when one is known. */
170
291
  const startedPhases = (phases: PhaseProgress[], timed: TimedPhase[], listed: readonly RunningPhase[]): RunningPhase[] =>
171
292
  phases
172
293
  .filter((phase) => isPhaseOpen(phase) && isStarted(phase) && !isListed(listed, phase))
173
294
  .map((phase) => ({ phase, minutesAgo: timed.find((each) => each.phase.id === phase.id)?.minutesAgo ?? null }))
174
295
 
296
+ /** The signalled phase when no other source lists it yet, aged from its last tick when one is known. */
297
+ const signalledRunning = (
298
+ signalled: PhaseProgress | null,
299
+ timed: TimedPhase[],
300
+ listed: readonly RunningPhase[],
301
+ ): RunningPhase[] =>
302
+ signalled && !isListed(listed, signalled)
303
+ ? [{ phase: signalled, minutesAgo: timed.find((each) => each.phase.id === signalled.id)?.minutesAgo ?? null }]
304
+ : []
305
+
175
306
  /**
176
- * The phases that run: the timed ones ticked less than `STALE_ACTIVITY_MINUTES` ago, the phases a reported task
177
- * names, then every other open phase with a ticked task (`isStarted`), with the age of its last tick when one is
178
- * known, else none (17:38). A phase only written down, without a tick or a report, waits.
307
+ * The phases that run: the timed ones ticked less than `STALE_ACTIVITY_MINUTES` ago, the phases a reported task or a
308
+ * running subagent names, the signalled phase (`signalledPhase`), then every other open phase with a ticked task
309
+ * (`isStarted`), with the age of its last tick when one is known, else none. A phase only written down, without a
310
+ * tick, a report or the lane's branch naming it, waits.
179
311
  */
180
312
  const runningPhases = (
181
313
  timed: TimedPhase[],
182
314
  phases: PhaseProgress[],
183
- reported: ReadonlyMap<string, ActiveTask[]>,
315
+ signals: PhaseSignals & { signalled: PhaseProgress | null },
184
316
  now: number,
185
317
  ): RunningPhase[] => {
186
318
  const moved = timed.filter(({ minutesAgo }) => minutesAgo < STALE_ACTIVITY_MINUTES)
187
- const listed = [...moved, ...reportedPhases(phases, reported, moved, now)]
319
+ const reported = [...moved, ...reportedPhases(phases, signals, moved, now)]
320
+ const listed = [...reported, ...signalledRunning(signals.signalled, timed, reported)]
188
321
  return [...listed, ...startedPhases(phases, timed, listed)]
189
322
  }
190
323
 
@@ -193,7 +326,7 @@ const runningPhases = (
193
326
  * passes no phase while an agent reports, so a lane with a report guesses nothing.
194
327
  */
195
328
  const guessedTasks = (input: SummaryInput, phases: PhaseProgress[], now: number): ReadonlyMap<string, number> =>
196
- currentTasks(phases, input.guesses ?? [], now, STALE_ACTIVITY_MINUTES * MINUTE)
329
+ currentTasks(phases, input.guesses ?? [], now, STALE_ACTIVITY_MINUTES * MS_PER_MINUTE)
197
330
 
198
331
  /** The running phases, each with the tasks agents reported on it, else its task in `guessed`, marked as guessed. */
199
332
  const activeOf = (
@@ -234,6 +367,16 @@ const otherPlans = (input: SummaryInput, hrefs: string[]): WaitingPhases[] =>
234
367
  return open.length > 0 ? [{ planTitle: titleOf(input.progress, href), phaseIds: open.map(({ id }) => id) }] : []
235
368
  })
236
369
 
370
+ const byPhaseNumber = (first: PhaseProgress, second: PhaseProgress): number =>
371
+ first.id.localeCompare(second.id, undefined, { numeric: true })
372
+
373
+ /** The open phases of the current plan that do not run, by number. */
374
+ const waitingIn = (phases: PhaseProgress[], running: ReadonlySet<string>): PhaseProgress[] =>
375
+ phases.filter((phase) => isPhaseOpen(phase) && !running.has(phase.id)).sort(byPhaseNumber)
376
+
377
+ const otherHrefs = (input: SummaryInput, href: string): string[] =>
378
+ planHrefs(input.progress).filter((each) => each !== href)
379
+
237
380
  /** The open phases of the current plan that do not run, by number, then the open phases of the lane's other plans. */
238
381
  const waitingOf = (
239
382
  input: SummaryInput,
@@ -241,24 +384,75 @@ const waitingOf = (
241
384
  phases: PhaseProgress[],
242
385
  running: ReadonlySet<string>,
243
386
  ): WaitingPhases[] => {
244
- const waiting = phases
245
- .filter((phase) => isPhaseOpen(phase) && !running.has(phase.id))
246
- .map(({ id }) => id)
247
- .sort((first, second) => first.localeCompare(second, undefined, { numeric: true }))
248
- const others = otherPlans(
249
- input,
250
- planHrefs(input.progress).filter((each) => each !== href),
251
- )
252
- return [...(waiting.length > 0 ? [{ planTitle: null, phaseIds: waiting }] : []), ...others]
387
+ const waiting = waitingIn(phases, running).map(({ id }) => id)
388
+ return [
389
+ ...(waiting.length > 0 ? [{ planTitle: null, phaseIds: waiting }] : []),
390
+ ...otherPlans(input, otherHrefs(input, href)),
391
+ ]
253
392
  }
254
393
 
255
- /** The finished phases of the current plan, each marked while its ticks are not merged. */
394
+ const placedIn =
395
+ (progress: LaneProgress, href: string) =>
396
+ (phase: PhaseProgress): PlacedPhase => ({ phase, stepIndex: newestStepShowing(progress, href, phase.id) })
397
+
398
+ /** The phases behind `waitingOf`, in its order, each with the step that shows it. */
399
+ const waitingPlaced = (
400
+ input: SummaryInput,
401
+ href: string,
402
+ phases: PhaseProgress[],
403
+ running: ReadonlySet<string>,
404
+ ): PlacedPhase[] => [
405
+ ...waitingIn(phases, running).map(placedIn(input.progress, href)),
406
+ ...otherHrefs(input, href).flatMap((other) =>
407
+ phasesOf(input, other).filter(isPhaseOpen).map(placedIn(input.progress, other)),
408
+ ),
409
+ ]
410
+
411
+ /** The finished phases of the current plan. */
256
412
  const finishedOf = (phases: PhaseProgress[]): FinishedPhase[] =>
257
- phases.filter(isPhaseFinished).map((phase) => ({ id: phase.id, unmerged: isTickedUnmerged(phase) }))
413
+ phases.filter(isPhaseFinished).map((phase) => ({ id: phase.id }))
414
+
415
+ type Resting = Pick<LaneSummary, 'waiting' | 'waitingPhases' | 'finished' | 'finishedPhases' | 'unwritten'>
416
+
417
+ /** What does not run: the phases that wait or are finished, as ids and as phases to open, and those without a checkbox. */
418
+ const restingOf = (input: SummaryInput, href: string, phases: PhaseProgress[], active: ActivePhase[]): Resting => {
419
+ const running = new Set(active.map(({ phase }) => phase.id))
420
+ return {
421
+ waiting: waitingOf(input, href, phases, running),
422
+ waitingPhases: waitingPlaced(input, href, phases, running),
423
+ finished: finishedOf(phases),
424
+ finishedPhases: phases.filter(isPhaseFinished).map(placedIn(input.progress, href)),
425
+ unwritten: stepPhases(input.progress, href)
426
+ .filter((phase) => !isWritten(phase))
427
+ .map(({ id, title }) => ({ id, missing: title === null })),
428
+ }
429
+ }
430
+
431
+ type ActiveSources = {
432
+ phases: PhaseProgress[]
433
+ timed: TimedPhase[]
434
+ /** The tasks agents reported, by phase id. */
435
+ reported: ReadonlyMap<string, ActiveTask[]>
436
+ /** Whether any report is fresh; the herd guesses no task then. */
437
+ anyFresh: boolean
438
+ signalled: PhaseProgress | null
439
+ /** The runs of the lane's subagents, the reviews that name no phase placed (`placedRuns`). */
440
+ subagents: readonly SubagentPhaseRun[]
441
+ }
442
+
443
+ /** The running phases of the plan on `href`, each with the tasks reported on it, the review gate or the guessed one. */
444
+ const activePhases = (input: SummaryInput, href: string, sources: ActiveSources, now: number): ActivePhase[] => {
445
+ const { phases, timed, reported, anyFresh, signalled } = sources
446
+ const named = subagentPhases(phases, sources.subagents)
447
+ const running = runningPhases(timed, phases, { reported, subagents: named, signalled }, now)
448
+ const tasks = withReviewTasks(reported, phases, named)
449
+ const guessable = anyFresh ? [] : running.map(({ phase }) => phase).filter(({ id }) => !tasks.has(id))
450
+ return activeOf(input, href, running, tasks, guessedTasks(input, guessable, now))
451
+ }
258
452
 
259
453
  /**
260
454
  * The lane summed up over its plans at `now`: the open phases running in its current plan (`runningPhases`), the open
261
- * phase moved last, the open phases that wait, the finished ones (marked when not merged yet), the phases its rows
455
+ * phase moved last, the open phases that wait, the finished ones, the phases its rows
262
456
  * name with nothing to run, and the tally over every plan of the lane. A phase ticked to the end, review gate
263
457
  * included, is finished, never running.
264
458
  */
@@ -266,24 +460,38 @@ export const laneSummary = (input: SummaryInput, now: number): LaneSummary => {
266
460
  const href = focusHref(input.progress)
267
461
  const tally = tallyOf(planHrefs(input.progress).flatMap((each) => phasesOf(input, each)))
268
462
  if (href === null) {
269
- return { active: [], latest: null, waiting: [], finished: [], unwritten: [], tally, unplanned: [] }
463
+ const subagentTasks = unplacedTasks(input, null, [])
464
+ return {
465
+ active: [],
466
+ latest: null,
467
+ signalled: null,
468
+ waiting: [],
469
+ waitingPhases: [],
470
+ finished: [],
471
+ finishedPhases: [],
472
+ unwritten: [],
473
+ tally,
474
+ unplanned: [],
475
+ subagentTasks,
476
+ }
270
477
  }
271
478
  const phases = phasesOf(input, href)
272
479
  const timed = timedPhases(input, href, phases, now)
273
480
  const fresh = freshSignals(input.reported ?? [], newest(Object.values(input.activity)))
274
481
  const { byPhase, unplanned } = assignSignals(phases, fresh)
275
- const running = runningPhases(timed, phases, byPhase, now)
276
- const guessable = fresh.length > 0 ? [] : running.map(({ phase }) => phase).filter(({ id }) => !byPhase.has(id))
277
- const active = activeOf(input, href, running, byPhase, guessedTasks(input, guessable, now))
482
+ const signalled = signalledPhase(phases, byPhase, input.branch ?? null)
483
+ const subagents = placedRuns(input, signalled)
484
+ const onTasks = subagentTaskPlacements(phases, input.describedSubagents ?? [])
485
+ const reported = withTasks(byPhase, onTasks.byPhase)
486
+ const sources = { phases, timed, reported, anyFresh: fresh.length > 0, signalled, subagents }
487
+ const active = activePhases(input, href, sources, now)
278
488
  return {
279
489
  active,
280
490
  latest: timed[0]?.phase ?? null,
281
- waiting: waitingOf(input, href, phases, new Set(active.map(({ phase }) => phase.id))),
282
- finished: finishedOf(phases),
283
- unwritten: stepPhases(input.progress, href)
284
- .filter((phase) => !isWritten(phase))
285
- .map(({ id, title }) => ({ id, missing: title === null })),
491
+ signalled,
492
+ ...restingOf(input, href, phases, active),
286
493
  tally,
287
494
  unplanned,
495
+ subagentTasks: unplacedTasks(input, signalled, onTasks.placed),
288
496
  }
289
497
  }
@@ -11,6 +11,7 @@ import type { LinkChip } from './link-chips'
11
11
  import type { PhaseModel } from './phase-models'
12
12
  import { itemKey, type Selection, tickedKey } from './selection'
13
13
  import { chevronOf, MODEL_MARK, PHASE_COUNT, PHASE_STATE, type PhaseState, phaseStateOf } from './symbols'
14
+ import { taskBodyOf, taskNameOf } from './task-name'
14
15
  import { type BadgeTexts, TEXTS } from './texts'
15
16
 
16
17
  /** What every block of one lane is drawn with. */
@@ -57,6 +58,9 @@ const PHASE_MARK: Record<PhaseState, string> = {
57
58
 
58
59
  export const plain = (text: string): string => text.replace(MARKDOWN_MARKS, '')
59
60
 
61
+ /** What a task reads as on one row: its short name where it carries one (`taskNameOf`), else its whole text. */
62
+ export const taskTitle = (text: string): string => plain(taskNameOf(text) ?? text)
63
+
60
64
  export const countText = (done: number, total: number): string =>
61
65
  `${String(done).padStart(COUNT_PLACES)}/${String(total).padEnd(COUNT_PLACES)}`
62
66
 
@@ -117,9 +121,16 @@ export const agentCell = (agent: string | null): string => (agent ? paint.dim(te
117
121
  /** Two cells: a task row hangs under its phase's label, which an arrow would otherwise push right. */
118
122
  export const TASK_INDENT = ' '
119
123
 
124
+ /** `▶ <label> <text>`, the text bold, the agent dim at the right edge. */
125
+ const labelledLine = (label: string, { text, agent }: UnplannedTask, width: number): string =>
126
+ spread(`${activeArrow(false)} ${label} ${paint.bold(plain(terminalSafe(text)))}`, agentCell(agent), width)
127
+
120
128
  /** `▶ now: <text>`: what an agent reports that no task of the plan names; the agent dim at the right edge. */
121
- export const nowLine = ({ text, agent }: UnplannedTask, width: number): string =>
122
- spread(`${activeArrow(false)} ${TEXTS.now} ${paint.bold(plain(terminalSafe(text)))}`, agentCell(agent), width)
129
+ export const nowLine = (task: UnplannedTask, width: number): string => labelledLine(TEXTS.now, task, width)
130
+
131
+ /** `▶ Subagent: <description>`: a running subagent that names no phase and sits on none. */
132
+ export const subagentLine = (description: string, width: number): string =>
133
+ labelledLine(TEXTS.subagent, { text: description, agent: null }, width)
123
134
 
124
135
  type TaskLook = { mark: string; style: (line: string) => string; suffix: string }
125
136
 
@@ -137,15 +148,26 @@ const taskLook = ({ done }: ChecklistItem, active: ActiveTask | undefined): Task
137
148
  : { mark: '☐', style: (line) => line, suffix: '' }
138
149
  }
139
150
 
140
- /** A closed task as one row, cut at the pane's edge with `…`. */
151
+ /** A closed task as one row, its short name or its text cut at the pane's edge with `…`. */
141
152
  const closedTaskRow = (item: ChecklistItem, active: ActiveTask | undefined, width: number): string[] => {
142
153
  const { mark, style } = taskLook(item, active)
143
- return [spread(`${mark} ${style(plain(item.text))}`, agentCell(active?.agent ?? null), width)]
154
+ return [spread(`${mark} ${style(taskTitle(item.text))}`, agentCell(active?.agent ?? null), width)]
144
155
  }
145
156
 
146
- /** An open task as rows: its whole text wrapped. */
147
- const openTaskRows = (item: ChecklistItem, active: ActiveTask | undefined, width: number): string[] =>
148
- markedLines(item.text, taskLook(item, active), width)
157
+ /** A named task opened: its name bold beside the mark, below it the line after the name (`taskBodyOf`) wrapped. */
158
+ const namedTaskRows = (name: string, item: ChecklistItem, look: TaskLook, width: number): string[] => {
159
+ const body = wrap(plain(taskBodyOf(item.text)), Math.max(width - 2, 1)).map(
160
+ (line) => ` ${item.done ? paint.dim(line) : line}`,
161
+ )
162
+ return [fit(`${look.mark} ${paint.bold(plain(name))}${look.suffix}`, width), ...body]
163
+ }
164
+
165
+ /** An open task as rows: a named one by `namedTaskRows`, else its whole text wrapped. */
166
+ const openTaskRows = (item: ChecklistItem, active: ActiveTask | undefined, width: number): string[] => {
167
+ const name = taskNameOf(item.text)
168
+ const look = taskLook(item, active)
169
+ return name ? namedTaskRows(name, item, look, width) : markedLines(item.text, look, width)
170
+ }
149
171
 
150
172
  /** A task as a selectable row under its `key`: clickable across every line it takes, the cursor on its first. */
151
173
  export const taskFrame = (key: string, rows: string[], context: Pick<LaneContext, 'state' | 'width'>): Frame =>