jackherd 1.8.1 → 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 (261) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/INSTALL.md +3 -1
  3. package/README.md +75 -64
  4. package/dist/{chunk-OU2KALXP.js → chunk-5MQBCUQ5.js} +24 -4
  5. package/dist/config.d.ts +45 -4
  6. package/dist/config.js +84 -27
  7. package/dist/plan-format.d.ts +63 -7
  8. package/dist/plan-format.js +66 -12
  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/approval-gate/index.ts +22 -4
  15. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  16. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  17. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  18. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  19. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  20. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  21. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  22. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  23. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  24. package/kit/hooks/hooks/merge-guard/decision.ts +127 -140
  25. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  26. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  27. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  28. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  29. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  30. package/kit/hooks/hooks/open-question/index.ts +26 -0
  31. package/kit/hooks/hooks/open-question/record.ts +135 -0
  32. package/kit/hooks/hooks/open-question/run.ts +52 -0
  33. package/kit/hooks/hooks/open-question/store.ts +39 -0
  34. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  35. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  36. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  37. package/kit/hooks/hooks/sent-file/store.ts +9 -0
  38. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  39. package/kit/hooks/hooks/shared/config.ts +7 -0
  40. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  41. package/kit/hooks/hooks/shared/files.ts +7 -0
  42. package/kit/hooks/hooks/shared/lane.ts +1 -1
  43. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  44. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  45. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  46. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  47. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  48. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  49. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  50. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  51. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  52. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  53. package/kit/hooks/hooks.json +21 -4
  54. package/kit/hooks/statusline.sh +1 -1
  55. package/kit/review/agents/skill-review.md +34 -0
  56. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  57. package/kit/workflow/skills/follow/SKILL.md +1 -8
  58. package/kit/workflow/skills/implement/SKILL.md +10 -6
  59. package/kit/workflow/skills/orchestrate/SKILL.md +213 -412
  60. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  61. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  62. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  63. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  64. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  65. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  66. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  67. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  68. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  69. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  70. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  71. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  72. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  73. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  74. package/kit/workflow/skills/plan/SKILL.md +6 -5
  75. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  76. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  77. package/package.json +4 -1
  78. package/recipes/README.md +2 -1
  79. package/recipes/sources.md +1 -1
  80. package/schemas/jackherd.schema.json +85 -7
  81. package/src/check/agent-text-reviewers.ts +23 -0
  82. package/src/check/command.ts +2 -0
  83. package/src/check/plan-structure-check.ts +49 -2
  84. package/src/check/plan.ts +8 -1
  85. package/src/check/schema.ts +9 -0
  86. package/src/check/skill-budget-defaults.ts +55 -0
  87. package/src/check/skill-budgets.ts +185 -0
  88. package/src/cli.ts +4 -0
  89. package/src/config/auto-window.ts +8 -0
  90. package/src/config/link-kinds.ts +7 -1
  91. package/src/config/schema.ts +80 -26
  92. package/src/context/fill.ts +12 -0
  93. package/src/context/session-log.ts +43 -1
  94. package/src/lane/close-board.ts +106 -0
  95. package/src/lane/close-check.ts +183 -0
  96. package/src/lane/close-command-line.ts +11 -0
  97. package/src/lane/close-command.ts +217 -0
  98. package/src/lane/close-options.ts +43 -0
  99. package/src/lane/close-survey.ts +83 -0
  100. package/src/lane/close.ts +141 -0
  101. package/src/lane/command.ts +13 -0
  102. package/src/lane-prompt/build.ts +74 -0
  103. package/src/lane-prompt/command.ts +87 -0
  104. package/src/lane-prompt/input.ts +116 -0
  105. package/src/lane-prompt/options.ts +68 -0
  106. package/src/lane-prompt/texts.ts +101 -0
  107. package/src/plan-format/clock.ts +12 -0
  108. package/src/plan-format/dependencies.ts +9 -9
  109. package/src/plan-format/index.ts +1 -0
  110. package/src/plan-format/lanes.ts +30 -5
  111. package/src/plan-format/short-names.ts +71 -0
  112. package/src/plan-format/terminal-text.ts +4 -0
  113. package/src/plan-format/waiting.ts +53 -0
  114. package/src/plans/active-tasks.ts +60 -0
  115. package/src/plans/appointment-rows.ts +59 -0
  116. package/src/plans/ask-coordinator.ts +77 -0
  117. package/src/plans/ask-target.ts +25 -0
  118. package/src/plans/child-stop.ts +51 -0
  119. package/src/plans/closable-records.ts +34 -0
  120. package/src/plans/command.ts +3 -2
  121. package/src/plans/current-phase.ts +17 -7
  122. package/src/plans/current-task.ts +63 -5
  123. package/src/plans/fetch.ts +26 -3
  124. package/src/plans/git.ts +19 -5
  125. package/src/plans/herd-claim.ts +173 -0
  126. package/src/plans/herd-memory.ts +36 -25
  127. package/src/plans/lane-plans.ts +44 -13
  128. package/src/plans/lane-standing.ts +9 -4
  129. package/src/plans/lane-summary.ts +367 -92
  130. package/src/plans/lane-tickets.ts +15 -0
  131. package/src/plans/layout-phase.ts +80 -19
  132. package/src/plans/layout-summary.ts +163 -74
  133. package/src/plans/layout.ts +472 -114
  134. package/src/plans/link-chips.ts +1 -0
  135. package/src/plans/live.ts +119 -10
  136. package/src/plans/phase-ids.ts +8 -0
  137. package/src/plans/phase-models.ts +1 -1
  138. package/src/plans/phase-ranges.ts +45 -0
  139. package/src/plans/phase-source.ts +15 -0
  140. package/src/plans/plan-cache.ts +28 -0
  141. package/src/plans/plan-heads.ts +54 -0
  142. package/src/plans/pull-requests.ts +23 -2
  143. package/src/plans/question-records.ts +48 -0
  144. package/src/plans/questions-read.ts +13 -0
  145. package/src/plans/read-cache.ts +8 -0
  146. package/src/plans/record-id.ts +4 -0
  147. package/src/plans/restart-decision.ts +13 -4
  148. package/src/plans/selection.ts +117 -34
  149. package/src/plans/sent-files-read.ts +13 -0
  150. package/src/plans/sent-files.ts +114 -0
  151. package/src/plans/settings.ts +2 -1
  152. package/src/plans/signalled-phase.ts +42 -0
  153. package/src/plans/signals-read.ts +62 -0
  154. package/src/plans/snapshot.ts +279 -60
  155. package/src/plans/sprint-export.ts +11 -11
  156. package/src/plans/sprint-page.ts +5 -5
  157. package/src/plans/subagent-phases.ts +95 -0
  158. package/src/plans/subagent-tasks.ts +51 -0
  159. package/src/plans/subagents-read.ts +131 -0
  160. package/src/plans/subagents.ts +190 -0
  161. package/src/plans/supervisor.ts +14 -4
  162. package/src/plans/symbols.ts +3 -0
  163. package/src/plans/task-name.ts +34 -0
  164. package/src/plans/texts.ts +36 -14
  165. package/src/plans/unlisted-lanes.ts +10 -5
  166. package/src/plans/view.ts +20 -12
  167. package/src/plans/waiting-click.ts +231 -0
  168. package/src/plans/waiting-records.ts +39 -0
  169. package/src/plans/waiting-window.ts +8 -0
  170. package/src/preview/command.ts +105 -0
  171. package/src/preview/embed.ts +209 -0
  172. package/src/preview/images.ts +89 -0
  173. package/src/preview/open.ts +12 -0
  174. package/src/preview/options.ts +32 -0
  175. package/src/preview/page.ts +89 -0
  176. package/src/preview/render.ts +41 -0
  177. package/src/settled/board-row.ts +1 -2
  178. package/src/shared/is-record.ts +2 -0
  179. package/src/shared/private-location.ts +89 -0
  180. package/src/shared/private-records.ts +48 -0
  181. package/src/shared/stat-cache.ts +48 -0
  182. package/src/sync/kit-source.ts +29 -10
  183. package/src/sync/kit.ts +2 -0
  184. package/src/sync/manifest.ts +2 -0
  185. package/src/sync/plan-ask-rules.ts +94 -0
  186. package/src/sync/plan-hooks.ts +2 -0
  187. package/src/sync/render.ts +3 -3
  188. package/src/sync/settings-hooks.ts +4 -1
  189. package/src/sync/sync-plan.ts +1 -0
  190. package/src/sync/sync.ts +24 -3
  191. package/src/task-signals/agent.ts +15 -0
  192. package/src/task-signals/claude-read.ts +89 -0
  193. package/src/task-signals/claude-signals.ts +112 -0
  194. package/src/task-signals/command.ts +71 -0
  195. package/src/task-signals/fixtures/session.jsonl +7 -0
  196. package/src/task-signals/matcher.ts +96 -0
  197. package/src/task-signals/record-store.ts +40 -0
  198. package/src/task-signals/record.ts +68 -0
  199. package/src/task-signals/text.ts +13 -0
  200. package/src/task-signals/types.ts +14 -0
  201. package/src/terminal/open.ts +13 -6
  202. package/src/terminal/text.ts +16 -4
  203. package/src/terminal/width.ts +2 -1
  204. package/src/version/skill-diff.ts +46 -0
  205. package/src/vocabulary/index.ts +44 -6
  206. package/src/waiting/command.ts +50 -0
  207. package/src/watch/appointments.ts +2 -4
  208. package/src/watch/approvals.ts +2 -2
  209. package/src/watch/asking-lanes.ts +40 -0
  210. package/src/watch/base-tip.ts +11 -0
  211. package/src/watch/command.ts +401 -90
  212. package/src/watch/failed-tests.ts +56 -10
  213. package/src/watch/failure-read.ts +18 -6
  214. package/src/watch/failures.ts +29 -5
  215. package/src/watch/feedback.ts +1 -1
  216. package/src/watch/gh-process.ts +9 -3
  217. package/src/watch/heartbeat-progress.ts +6 -18
  218. package/src/watch/json-guards.ts +0 -3
  219. package/src/watch/kit-update.ts +28 -9
  220. package/src/watch/lane-fill.ts +1 -1
  221. package/src/watch/lane-model.ts +1 -1
  222. package/src/watch/lane-name.ts +6 -0
  223. package/src/watch/lane-parallel.ts +1 -1
  224. package/src/watch/lane-progress.ts +58 -14
  225. package/src/watch/lane-report-file.ts +20 -5
  226. package/src/watch/lane-report.ts +37 -5
  227. package/src/watch/lane-rest.ts +28 -0
  228. package/src/watch/lane-sources.ts +31 -3
  229. package/src/watch/main-checkout.ts +34 -0
  230. package/src/watch/plan-badges-read.ts +13 -7
  231. package/src/watch/plan-badges.ts +9 -2
  232. package/src/watch/previews.ts +1 -1
  233. package/src/watch/pull-requests.ts +8 -3
  234. package/src/watch/reference-paths.ts +87 -0
  235. package/src/watch/running-subagents.ts +43 -0
  236. package/src/watch/skill-change.ts +124 -32
  237. package/src/watch/source-change.ts +48 -0
  238. package/src/watch/source-path.ts +1 -1
  239. package/src/watch/sources.ts +7 -4
  240. package/src/watch/stream-output.ts +14 -8
  241. package/src/watch/task-parallel-auto.ts +208 -0
  242. package/src/watch/task-parallel-data-paths.ts +46 -0
  243. package/src/watch/task-parallel-input.ts +108 -0
  244. package/src/watch/task-parallel-read.ts +151 -0
  245. package/src/watch/task-parallel-touched.ts +39 -0
  246. package/src/watch/task-parallel.ts +235 -0
  247. package/src/watch/task-signal-missing.ts +69 -0
  248. package/src/watch/task-signal-read.ts +29 -0
  249. package/src/watch/texts.ts +10 -0
  250. package/src/watch/update-available.ts +6 -3
  251. package/src/watch/waiting-line.ts +19 -0
  252. package/src/watch/watch-events.ts +36 -1
  253. package/src/watch/watcher-lock.ts +25 -29
  254. package/src/watch/workflow-runs.ts +2 -1
  255. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  256. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  257. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  258. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  259. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  260. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  261. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -2,7 +2,7 @@ import { existsSync } 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,14 @@ import {
13
13
  shortTitle,
14
14
  sprintTitleOf,
15
15
  } from '../plan-format'
16
+ import type { WaitingItem } from '../plan-format/waiting'
17
+ import { dropUnused } from '../shared/stat-cache'
18
+ import type { TaskSignal } from '../task-signals/types'
16
19
  import type { LaneAgent, LaneStatusSource } from '../terminal'
17
20
  import type { SourceTicket, WatchBadges, WatchHealth } from '../watch/plan-badges'
18
21
  import { readWatchReading } from '../watch/plan-badges-read'
22
+ import { assignSignals, laneOfDirectory, type UnplannedTask } from './active-tasks'
23
+ import { type AppointmentBlock, appointmentBlock } from './appointment-rows'
19
24
  import { boardRowsOf } from './board-rows'
20
25
  import {
21
26
  type ActivityTimes,
@@ -26,21 +31,39 @@ import {
26
31
  type PlanModified,
27
32
  withMovedPhase,
28
33
  } from './current-phase'
34
+ import { activityTextOf, taskSignalsOf } from './current-task'
29
35
  import { errorText } from './error-text'
30
36
  import { type BaseReader, baseReaderOf, gitStatusOf, refExists } from './git'
31
37
  import type { PullRequestState } from './github'
32
- import { type Fallbacks, laneWorkOf, readAtBase, readFallback, readPlans, textAtBase } from './lane-plans'
38
+ import {
39
+ type Fallbacks,
40
+ laneWorkOf,
41
+ type PlanSources,
42
+ readAtBase,
43
+ readFallback,
44
+ readPlans,
45
+ textAtBase,
46
+ } from './lane-plans'
33
47
  import { laneSummary } from './lane-summary'
34
48
  import { boardRowTexts, laneTicketsOf, withoutTicketLinks } from './lane-tickets'
35
- import { type LaneView, sortByUrgency } from './layout'
49
+ import type { LaneView } from './layout'
36
50
  import { extractLinkChips } from './link-chips'
37
51
  import type { Options } from './options'
38
52
  import { laneModelsOf, type PhaseModelRecord } from './phase-models'
39
53
  import { readPhaseModels } from './phase-models-read'
54
+ import { laneHeads, planPullRequests, readerOncePerRef } from './plan-heads'
40
55
  import { type PlannedPlan, plannedPlans } from './planned'
56
+ import type { PullRequest } from './pull-requests'
57
+ import type { ReadCache } from './read-cache'
58
+ import { type SentChip, sentChipsByLane } from './sent-files'
41
59
  import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from './settings'
60
+ import { signalledTitle } from './signalled-phase'
61
+ import { readAllSignals } from './signals-read'
62
+ import type { SubagentActivity } from './subagents'
63
+ import { readSubagentActivity, subagentSourceOf } from './subagents-read'
42
64
  import { boardTexts } from './texts'
43
65
  import { type UnlistedLane, unlistedLanes, unlistedSprintLane } from './unlisted-lanes'
66
+ import { readWaitingRecords, waitingItemsOf } from './waiting-records'
44
67
  import { withWorkedPhases } from './worked-phases'
45
68
 
46
69
  /** Where the view reads from: the main checkout, the sprint document, the command line and the project settings. */
@@ -52,6 +75,10 @@ export type Source = {
52
75
  terminal: LaneStatusSource
53
76
  /** The log lines of an agent's chat, null without one; defaults to reading them from `~/.claude/projects`. */
54
77
  readAgentLog?: AgentLogReader
78
+ /** The worktrees subagents of the coordinator work in; defaults to reading them from `~/.claude/projects`. */
79
+ readSubagents?: (root: string, worktreeDirectory: string) => Map<string, SubagentActivity>
80
+ /** What agents report doing (Claude's task lists, `jh task`); defaults to reading `~/.claude/projects` and `.jackherd/tasks/`. */
81
+ readSignals?: (root: string, worktreeDirectory: string, now: number) => TaskSignal[]
55
82
  }
56
83
 
57
84
  /**
@@ -86,6 +113,12 @@ export type Snapshot = {
86
113
  watch?: WatchHealth
87
114
  /** True when the config marks a ticket source, so a missing watcher is said without a pull request too. */
88
115
  watchesTickets?: boolean
116
+ /** What waits on the user's answer: the open rows of the board's section for it, from the checkout's board file. */
117
+ waiting?: WaitingItem[]
118
+ /** The board's open appointments, read from the same board text as `waiting`, for the block under it. */
119
+ appointments?: AppointmentBlock
120
+ /** What agents report doing that no lane takes: the coordinator's own, or a lane the board does not list. */
121
+ unplaced?: UnplannedTask[]
89
122
  }
90
123
 
91
124
  type LaneSources = {
@@ -93,32 +126,77 @@ type LaneSources = {
93
126
  base: BaseReader
94
127
  sprintDirectory: string
95
128
  agents: LaneAgent[]
129
+ /** What subagents of the coordinator do in each worktree; a herdr agent in the worktree comes first. */
130
+ subagents: Map<string, SubagentActivity>
131
+ /** The agents' log reader, asking once per agent however often the read is used in one snapshot. */
132
+ readLog: AgentLogReader
96
133
  pullRequests: PullRequestState
134
+ /** The open pull requests that change a plan, whose heads raise the ticks the base ref has. */
135
+ planPullRequests: PullRequest[]
136
+ /** The reader of a pull request head, resolved once per read whatever the number of lanes. */
137
+ headReaderOf: (ref: string) => BaseReader
138
+ /** What agents report doing, in every lane and outside them. */
139
+ signals: TaskSignal[]
140
+ /** The directory the lanes' worktrees lie in, which tells a signal's lane. */
141
+ worktrees: string
142
+ parsed?: PlanSources['parsed']
97
143
  }
98
144
 
99
- type BoardLanes = { markdown: string; lanes: SprintLane[] }
145
+ /** `liveMarkdown` is the checkout's board file (the coordinator edits it before any commit), else `markdown`. */
146
+ type BoardLanes = { markdown: string; liveMarkdown: string; lanes: SprintLane[] }
100
147
 
101
148
  const SHOWN_NAMES = 2
102
149
 
103
150
  type LaneResult = { view: LaneView; files: string[]; texts: Record<string, string>; pane: string | null }
104
151
 
152
+ /** The board's lanes the view shows, the running lanes it does not list, and what the lanes read from. */
153
+ type LaneSet = { lanes: SprintLane[]; unlisted: UnlistedLane[]; sources: LaneSources; errors: string[] }
154
+
155
+ /** What a lane's view is decorated with after it was read. */
156
+ type Decorations = {
157
+ reading: ReturnType<typeof readWatchReading> | null
158
+ phaseModels: readonly PhaseModelRecord[]
159
+ sprintDirectory: string
160
+ sentChips: ReadonlyMap<string | null, SentChip[]>
161
+ }
162
+
105
163
  /** 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)
164
+ const contextOf = (agent: LaneAgent | undefined, source: Source, readLog: AgentLogReader): ContextReading | null => {
165
+ const report = contextOfAgent(agent, readLog, source.settings.context)
108
166
  return report && { share: report.share, level: report.level }
109
167
  }
110
168
 
169
+ /** A reader that asks once per agent (session and directory), so context and task signal share one read of a large log. */
170
+ const onceReader = (read: AgentLogReader): AgentLogReader => {
171
+ const answers = new Map<string, string[] | null>()
172
+ return (agent) => {
173
+ const key = `${agent.session}\0${agent.cwd}`
174
+ if (!answers.has(key)) answers.set(key, read(agent))
175
+ return answers.get(key) ?? null
176
+ }
177
+ }
178
+
179
+ const WORK_ENTRIES = 40
180
+
181
+ /** 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. */
182
+ const workText = ({ readLog, subagents }: LaneSources, lane: string, agent: LaneAgent | undefined): string => {
183
+ const lines = agent ? readLog(agent) : null
184
+ return [lines ? activityTextOf(lines, WORK_ENTRIES) : '', subagents.get(lane)?.activity ?? ''].join(' ')
185
+ }
186
+
111
187
  /** What a lane's worktree and agent say, listed in the sprint or not: git state, pull requests, context. */
112
188
  const runningState = async (sources: LaneSources, name: string, agent: LaneAgent | undefined) => {
113
189
  const { source, pullRequests } = sources
114
190
  const gitStatus = await gitStatusOf(worktreeOf(source.root, source.settings, name), baseRefOf(source.settings))
191
+ const subagent = agent ? undefined : sources.subagents.get(name)
192
+ const title = agent?.title ?? subagent?.description
115
193
  return {
116
194
  gitStatus,
117
195
  view: {
118
- status: { agent: agent?.status ?? null, ...gitStatus },
119
- ...(agent?.title ? { sessionTitle: agent.title } : {}),
196
+ status: { agent: agent?.status ?? subagent?.status ?? null, ...gitStatus },
197
+ ...(title ? { sessionTitle: title } : {}),
120
198
  pullRequests: gitStatus.branch ? (pullRequests.byBranch.get(gitStatus.branch) ?? []) : [],
121
- context: contextOf(agent, source),
199
+ context: contextOf(agent, source, sources.readLog),
122
200
  },
123
201
  }
124
202
  }
@@ -163,19 +241,41 @@ const laneView = async (
163
241
  modified,
164
242
  } = await readPlans(
165
243
  lane,
166
- { root, settings, base: sources.base, sprintDirectory: sources.sprintDirectory },
244
+ {
245
+ root,
246
+ settings,
247
+ base: sources.base,
248
+ sprintDirectory: sources.sprintDirectory,
249
+ heads: laneHeads(sources.planPullRequests, gitStatus.branch, sources.headReaderOf),
250
+ parsed: sources.parsed,
251
+ },
167
252
  laneWorkOf(worktree, gitStatus),
168
253
  fallbacks,
169
254
  )
170
255
  const shown = withWorkedPhases(lane, phasesByHref)
171
256
  const { progress, activity } = progressWithMemory(shown, phasesByHref, memories, { now, modified })
257
+ const laneSubagents = sources.subagents.get(lane.name)
172
258
  const view: LaneView = {
173
259
  progress,
174
260
  ...running,
175
261
  chips: Object.fromEntries(
176
262
  Object.entries(texts).map(([href, text]) => [href, extractLinkChips(text, settings.linkKinds)]),
177
263
  ),
178
- summary: laneSummary({ progress, phases: phasesByHref, texts, activity, language: settings.planLanguage }, now),
264
+ summary: laneSummary(
265
+ {
266
+ progress,
267
+ phases: phasesByHref,
268
+ activity,
269
+ guesses: taskSignalsOf(Object.values(phasesByHref).flat(), workText(sources, lane.name, agent), now),
270
+ reported: sources.signals.filter(({ cwd }) => laneOfDirectory(cwd, sources.worktrees) === lane.name),
271
+ subagents: laneSubagents?.phaseRuns ?? [],
272
+ subagentTasks: laneSubagents?.taskDescriptions ?? [],
273
+ phaselessReviews: laneSubagents?.phaselessReviews ?? [],
274
+ describedSubagents: laneSubagents?.describedSubagents ?? [],
275
+ branch: gitStatus.branch,
276
+ },
277
+ now,
278
+ ),
179
279
  }
180
280
  return { view, files, texts, pane: agent?.pane ?? null }
181
281
  }
@@ -185,14 +285,14 @@ const laneView = async (
185
285
  * and without phases, marked as unlisted.
186
286
  */
187
287
  const unlistedView = async (sources: LaneSources, { name, agent }: UnlistedLane): Promise<LaneResult> => {
188
- const { view: running } = await runningState(sources, name, agent)
288
+ const { view: running } = await runningState(sources, name, agent ?? undefined)
189
289
  const view: LaneView = {
190
290
  progress: laneProgress(unlistedSprintLane(name), {}),
191
291
  ...running,
192
292
  chips: {},
193
293
  unlisted: true,
194
294
  }
195
- return { view, files: [], texts: {}, pane: agent.pane }
295
+ return { view, files: [], texts: {}, pane: agent?.pane ?? null }
196
296
  }
197
297
 
198
298
  /** 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 +323,35 @@ const withTickets = (result: LaneResult, tickets: readonly SourceTicket[]): Lane
223
323
  return found ? { ...view, chips: withoutTicketLinks(view.chips, found), tickets: found } : view
224
324
  }
225
325
 
326
+ /** What agents report doing; nothing when it cannot be read. */
327
+ const readSignals = (
328
+ { root, readSignals: read }: Source,
329
+ worktrees: string,
330
+ now: number,
331
+ cache?: ReadCache,
332
+ ): TaskSignal[] => {
333
+ try {
334
+ return (
335
+ read ??
336
+ ((main, directory, at) =>
337
+ readAllSignals({ projects: defaultProjectsDirectory(), root: main, worktrees: directory }, at, cache?.signals))
338
+ )(root, worktrees, now)
339
+ } catch {
340
+ return []
341
+ }
342
+ }
343
+
344
+ /** The reports no shown lane takes, as lines for the overview: the coordinator's own and those of lanes without a plan. */
345
+ const unplacedOf = (sources: LaneSources, lanes: readonly SprintLane[], onlyLane: boolean): UnplannedTask[] => {
346
+ if (onlyLane) return []
347
+ const listed = new Set(lanes.map((lane) => lane.name))
348
+ const rest = sources.signals.filter(({ cwd }) => {
349
+ const lane = laneOfDirectory(cwd, sources.worktrees)
350
+ return lane === null || !listed.has(lane)
351
+ })
352
+ return assignSignals([], rest).unplanned
353
+ }
354
+
226
355
  /** The terminal's agents, or none with the reason when it cannot be asked, so the screen says why lanes show "no agent". */
227
356
  const readAgents = (terminal: LaneStatusSource): { agents: LaneAgent[]; errors: string[] } => {
228
357
  try {
@@ -232,6 +361,26 @@ const readAgents = (terminal: LaneStatusSource): { agents: LaneAgent[]; errors:
232
361
  }
233
362
  }
234
363
 
364
+ /** What subagents of the coordinator do in the worktrees; nothing when their logs cannot be read. */
365
+ const readSubagents = (
366
+ { root, settings, readSubagents: read }: Source,
367
+ worktrees: string,
368
+ ): Map<string, SubagentActivity> => {
369
+ try {
370
+ return (
371
+ read ?? ((main, directory) => readSubagentActivity(subagentSourceOf(main, directory, settings.skillPrefix)))
372
+ )(root, worktrees)
373
+ } catch {
374
+ return new Map()
375
+ }
376
+ }
377
+
378
+ /** The view with the files its session sent to the user, only when there are any, so it otherwise stays as it was. */
379
+ const withSentFiles = (view: LaneView, chips: ReadonlyMap<string | null, SentChip[]>): LaneView => {
380
+ const found = chips.get(view.progress.lane.name)
381
+ return found ? { ...view, sentFiles: found } : view
382
+ }
383
+
235
384
  /** Up to two names, then how many more: `a, b and 3 more`. */
236
385
  const namedWithMore = (names: readonly string[]): string => {
237
386
  const more = names.length - SHOWN_NAMES
@@ -259,8 +408,8 @@ const readBoardLanes = async (
259
408
  const { sprintFile, settings } = source
260
409
  const atBase = textAtBase(base, boardPath)
261
410
  const markdown = atBase ?? (await readFallback(boardPath, sprintFile, fallbacks)) ?? ''
262
- const checkoutText = atBase !== null && existsSync(sprintFile) ? await Bun.file(sprintFile).text() : markdown
263
- return { markdown, lanes: boardRowsOf(markdown, checkoutText, settings.planLanguage).lanes }
411
+ const liveMarkdown = atBase !== null && existsSync(sprintFile) ? await Bun.file(sprintFile).text() : markdown
412
+ return { markdown, liveMarkdown, lanes: boardRowsOf(markdown, liveMarkdown, settings.planLanguage).lanes }
264
413
  }
265
414
 
266
415
  /** The plans the sprint's plans tables name, read at the base ref, by link; lanes read theirs themselves. */
@@ -291,83 +440,153 @@ const forgetLanes = (memories: Map<string, LaneMemory> | undefined, lanes: reado
291
440
  for (const name of [...(memories?.keys() ?? [])]) if (!names.has(name)) memories?.delete(name)
292
441
  }
293
442
 
443
+ const laneSetOf = (
444
+ source: Source,
445
+ context: {
446
+ base: BaseReader
447
+ board: BoardLanes
448
+ sprintDirectory: string
449
+ worktrees: string
450
+ cache?: ReadCache
451
+ },
452
+ pullRequests: PullRequestState,
453
+ now: number,
454
+ ): LaneSet => {
455
+ const { base, board, sprintDirectory, worktrees, cache } = context
456
+ const { agents, errors } = readAgents(source.terminal)
457
+ const isWanted = ({ name }: { name: string }) => !source.options.lane || name === source.options.lane
458
+ const subagents = readSubagents(source, worktrees)
459
+ const listed = new Set(board.lanes.map((lane) => lane.name))
460
+ return {
461
+ lanes: board.lanes.filter(isWanted),
462
+ unlisted: unlistedLanes(agents, listed, worktrees, new Set(subagents.keys())).filter(isWanted),
463
+ sources: {
464
+ source,
465
+ base,
466
+ sprintDirectory,
467
+ agents,
468
+ subagents,
469
+ readLog: onceReader(source.readAgentLog ?? agentLog),
470
+ pullRequests,
471
+ planPullRequests: planPullRequests([...pullRequests.byBranch.values()].flat(), source.settings.planDirectory),
472
+ headReaderOf: readerOncePerRef((ref) => baseReaderOf(source.root, ref)),
473
+ signals: readSignals(source, worktrees, now, cache),
474
+ worktrees,
475
+ parsed: cache && { entries: cache.plans, used: new Set<string>() },
476
+ },
477
+ errors,
478
+ }
479
+ }
480
+
481
+ /** The views of the listed lanes, then of the unlisted ones; the lanes' memories are cleaned of lanes the board dropped. */
482
+ const laneResultsOf = async (
483
+ { lanes, unlisted, sources }: LaneSet,
484
+ fallbacks: Fallbacks,
485
+ now: number,
486
+ memories: Map<string, LaneMemory> | undefined,
487
+ ): Promise<LaneResult[]> => {
488
+ const results = [
489
+ ...(await Promise.all(lanes.map((lane) => laneView(sources, lane, fallbacks, now, memories)))),
490
+ ...(await Promise.all(unlisted.map((lane) => unlistedView(sources, lane)))),
491
+ ]
492
+ forgetLanes(memories, lanes)
493
+ return results
494
+ }
495
+
496
+ /** Each lane's view with its tickets, the watcher's badges, its phases' models and the files sent from it. */
497
+ const decoratedViews = (
498
+ results: readonly LaneResult[],
499
+ { reading, phaseModels, sprintDirectory, sentChips }: Decorations,
500
+ ): LaneView[] =>
501
+ results.map((result) =>
502
+ withSentFiles(
503
+ withModels(
504
+ withBadges(withTickets(result, reading?.tickets ?? []), reading?.badges ?? {}),
505
+ phaseModels,
506
+ sprintDirectory,
507
+ ),
508
+ sentChips,
509
+ ),
510
+ )
511
+
512
+ /** The open phases no lane with an agent leads; none while the view shows one lane. */
513
+ const plannedOf = (
514
+ source: Source,
515
+ plans: Record<string, string>,
516
+ results: readonly LaneResult[],
517
+ sprintDirectory: string,
518
+ ): PlannedPlan[] =>
519
+ source.options.lane
520
+ ? []
521
+ : plannedPlans({
522
+ plans,
523
+ lanes: results.map(({ view }) => ({ lane: view.progress.lane, agent: view.status.agent })),
524
+ sprintDirectory,
525
+ language: source.settings.planLanguage,
526
+ })
527
+
294
528
  /**
295
529
  * Reads the board, its plans and the lanes' state. `memories` holds, per lane, what the herd saw at the last read; it
296
530
  * is how the current phase becomes the one whose boxes moved since, and it is cleaned of lanes the board dropped.
531
+ * `cache`, kept by the caller between reads, spares parsing the session logs and plans that did not change.
297
532
  */
298
533
  export const loadSnapshot = async (
299
534
  source: Source,
300
535
  pullRequests: PullRequestState,
301
536
  memories?: Map<string, LaneMemory>,
537
+ cache?: ReadCache,
302
538
  ): Promise<Snapshot> => {
303
- const { root, sprintFile, options, settings, terminal } = source
539
+ const { root, sprintFile, options, settings } = source
304
540
  const fallbacks: Fallbacks = []
305
- const base = baseReaderOf(root, baseRefOf(settings))
541
+ const baseRef = baseRefOf(settings)
542
+ const base = baseReaderOf(root, baseRef)
306
543
  const boardPath = boardRepoPathOf(root, settings, sprintFile)
307
544
  const board = await readBoardLanes(source, base, boardPath, fallbacks)
308
545
  const { markdown } = board
309
546
  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
- }
547
+ const worktrees = join(root, settings.worktreeDirectory)
325
548
  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)
549
+ const liveBoard = board.liveMarkdown
550
+ const laneSet = laneSetOf(source, { base, board, sprintDirectory, worktrees, cache }, pullRequests, now)
551
+ const results = await laneResultsOf(laneSet, fallbacks, now, memories)
552
+ const { parsed } = laneSet.sources
553
+ if (parsed) dropUnused(parsed.entries, parsed.used)
331
554
  const listedPlans = await readListedPlans(source, base, sprintDirectory, markdown, fallbacks)
332
555
  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
556
  const { ticketSources } = settings
342
557
  const watched = ticketSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
343
558
  const reading = watched ? readWatchReading(root, now, ticketSources) : null
344
- const phaseModels = readPhaseModels(root)
345
- const baseRef = baseRefOf(settings)
559
+ const waitingRecords = readWaitingRecords(root)
560
+ const sentChips = sentChipsByLane(waitingRecords.sent, worktrees, now)
346
561
  const notice = fallbacks.length > 0 ? fallbackNotice(fallbacks, baseRef, refExists(root, baseRef)) : null
347
562
  const unknownLaneTitle =
348
563
  options.lane && results.length === 0 ? boardTexts(settings.board.term).laneNotListed(options.lane) : null
349
564
  return {
350
565
  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
- ),
566
+ views: decoratedViews(results, {
567
+ reading,
568
+ phaseModels: readPhaseModels(root),
569
+ sprintDirectory,
570
+ sentChips,
571
+ }),
360
572
  documents: {
361
573
  sprint: markdown,
362
574
  plans: documentsPlans,
363
- laneOrder: [...lanes, ...unlisted].map((lane) => lane.name),
575
+ laneOrder: [...laneSet.lanes, ...laneSet.unlisted].map((lane) => lane.name),
364
576
  lanes: board.lanes,
365
577
  },
366
- planned,
578
+ planned: plannedOf(source, documentsPlans, results, sprintDirectory),
367
579
  files: [sprintFile, ...results.flatMap((result) => result.files)],
368
- titles: new Map(results.flatMap(({ view, pane }) => (pane ? [[pane, shortTitle(view.progress)]] : []))),
369
- errors: [...errors, ...pullRequests.errors, ...(notice ? [notice] : [])],
580
+ titles: new Map(
581
+ results.flatMap(({ view, pane }) =>
582
+ pane ? [[pane, signalledTitle(view.summary?.signalled ?? null, shortTitle(view.progress))]] : [],
583
+ ),
584
+ ),
585
+ errors: [...laneSet.errors, ...pullRequests.errors, ...(notice ? [notice] : [])],
370
586
  watch: reading?.health,
587
+ waiting: waitingItemsOf(liveBoard, settings.planLanguage, waitingRecords, now),
588
+ appointments: appointmentBlock(liveBoard, settings.planLanguage, new Date(now)),
589
+ unplaced: unplacedOf(laneSet.sources, laneSet.lanes, Boolean(options.lane)),
371
590
  ...(ticketSources.length > 0 ? { watchesTickets: true } : {}),
372
591
  }
373
592
  }
@@ -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`)