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
@@ -3,13 +3,14 @@ import { join, relative } from 'node:path'
3
3
  import { type JackherdConfig, parseJackherdConfig } from '../config'
4
4
  import { PROJECT_CONFIG_PATH } from '../config/location'
5
5
  import { readConfig } from '../config/read-config'
6
+ import type { ToneName } from '../config/tones'
6
7
  import type { TicketSource } from '../watch/plan-badges'
7
8
 
8
9
  export const CONFIG_PATH = PROJECT_CONFIG_PATH
9
10
 
10
11
  export type PlansSettings = Pick<
11
12
  JackherdConfig,
12
- 'planDirectory' | 'board' | 'worktreeDirectory' | 'baseBranch' | 'planLanguage' | 'responseLanguage'
13
+ 'planDirectory' | 'board' | 'worktreeDirectory' | 'baseBranch' | 'planLanguage' | 'responseLanguage' | 'skillPrefix'
13
14
  > & {
14
15
  adapter: JackherdConfig['terminal']['adapter']
15
16
  fetchIntervalMinutes: number
@@ -17,6 +18,12 @@ export type PlansSettings = Pick<
17
18
  context: JackherdConfig['plans']['context']
18
19
  /** The `watch.sources` marked `ticket`, whose entries the view shows as badges. */
19
20
  ticketSources: TicketSource[]
21
+ /** The other `watch.sources` by name, with the tone each names for a new report; the herd lists them in a block of their own. */
22
+ herdSources: { name: string; tone: ToneName | null }[]
23
+ /** By source name: what the herd puts before a row of "Waiting for you" that came from the source, its `label` or else its name. */
24
+ sourceLabels: Record<string, string>
25
+ /** The tone of each role in the herd. */
26
+ tones: JackherdConfig['plans']['tones']
20
27
  }
21
28
 
22
29
  /** The locale of the herd's clock: the herd is English, with a 24-hour clock. */
@@ -30,6 +37,7 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
30
37
  baseBranch: config.baseBranch,
31
38
  planLanguage: config.planLanguage,
32
39
  responseLanguage: config.responseLanguage,
40
+ skillPrefix: config.skillPrefix,
33
41
  adapter: config.terminal.adapter,
34
42
  fetchIntervalMinutes: config.plans.fetchIntervalMinutes,
35
43
  linkKinds: config.plans.linkKinds,
@@ -37,6 +45,9 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
37
45
  ticketSources: config.watch.sources.flatMap(({ name, lane, ticket }) =>
38
46
  ticket ? [{ name, lane: lane ?? null }] : [],
39
47
  ),
48
+ herdSources: config.watch.sources.flatMap(({ name, ticket, tone }) => (ticket ? [] : [{ name, tone: tone ?? null }])),
49
+ sourceLabels: Object.fromEntries(config.watch.sources.map(({ name, label }) => [name, label ?? name])),
50
+ tones: config.plans.tones,
40
51
  })
41
52
 
42
53
  /** The herd's slice of `.jackherd/config.json`, validated by the shared schema. */
@@ -0,0 +1,42 @@
1
+ import { isPhaseOpen, type PhaseProgress, phaseLabel } from '../plan-format'
2
+ import type { ActiveTask } from './active-tasks'
3
+
4
+ /** `p24e` in a branch name, standing alone between separators: `feat/jackherd-p24e-task-signal`. */
5
+ const BRANCH_PHASE = /(?:^|[^a-z0-9])p(\d+[a-z]?)(?![a-z0-9])/i
6
+
7
+ /** The phase id a lane branch names (`feat/jackherd-p24e-task-signal` → `24e`); null for none. */
8
+ export const branchPhaseId = (branch: string | null): string | null =>
9
+ branch?.match(BRANCH_PHASE)?.[1]?.toLowerCase() ?? null
10
+
11
+ const newestStart = (tasks: readonly ActiveTask[]): number => Math.max(...tasks.map(({ startedAt }) => startedAt ?? 0))
12
+
13
+ /** The open phase with the newest task an agent reported; null when no open phase has one. */
14
+ const reportedPhase = (
15
+ phases: readonly PhaseProgress[],
16
+ reported: ReadonlyMap<string, readonly ActiveTask[]>,
17
+ ): PhaseProgress | null =>
18
+ phases
19
+ .filter((phase) => isPhaseOpen(phase) && (reported.get(phase.id)?.length ?? 0) > 0)
20
+ .reduce<PhaseProgress | null>(
21
+ (best, phase) =>
22
+ best && newestStart(reported.get(best.id) ?? []) >= newestStart(reported.get(phase.id) ?? []) ? best : phase,
23
+ null,
24
+ )
25
+
26
+ /**
27
+ * The phase the lane says it works on (D112): the open phase with the newest reported task, else the open phase its
28
+ * branch names (`branchPhaseId`); null otherwise, and the ticks decide as before. A phase ticked to the end is never
29
+ * signalled, so a stale report or an old branch name cannot make finished work current.
30
+ */
31
+ export const signalledPhase = (
32
+ phases: readonly PhaseProgress[],
33
+ reported: ReadonlyMap<string, readonly ActiveTask[]>,
34
+ branch: string | null,
35
+ ): PhaseProgress | null => {
36
+ const id = branchPhaseId(branch)
37
+ return reportedPhase(phases, reported) ?? phases.find((phase) => phase.id === id && isPhaseOpen(phase)) ?? null
38
+ }
39
+
40
+ /** The sidebar title of a lane: the signalled phase as `P24d · 1/5`, else `fromProgress` (`shortTitle`). */
41
+ export const signalledTitle = (signalled: PhaseProgress | null, fromProgress: string | null): string | null =>
42
+ signalled ? `${phaseLabel(signalled.id)} · ${signalled.done}/${signalled.total}` : fromProgress
@@ -2,7 +2,8 @@ import { readdirSync, statSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { projectDirectoryOf } from '../context/session-log'
4
4
  import { MS_PER_MINUTE } from '../plan-format/clock'
5
- import { readClaudeSignals } from '../task-signals/claude-read'
5
+ import { dropUnused, type StatCache } from '../shared/stat-cache'
6
+ import { readClaudeSignals, type SignalCache } from '../task-signals/claude-read'
6
7
  import { recordSignals } from '../task-signals/record'
7
8
  import { readTaskRecords } from '../task-signals/record-store'
8
9
  import type { TaskSignal } from '../task-signals/types'
@@ -41,14 +42,21 @@ const recentSessionLogs = (project: string, notOlderThan: number): string[] => {
41
42
  /**
42
43
  * What agents report doing, as `TaskSignal`s: the tasks in progress in the recent Claude Code sessions of the main
43
44
  * checkout (the coordinator and its subagents) and of every worktree, and the open `jh task` records. Which lane a
44
- * signal belongs to is decided by its `cwd`, not by where it was read.
45
+ * signal belongs to is decided by its `cwd`, not by where it was read. A `cache` kept between calls spares parsing a
46
+ * log whose modification time and size did not change; logs this call did not look at are dropped from it.
45
47
  */
46
- export const readAllSignals = ({ projects, root, worktrees }: SignalSource, now: number): TaskSignal[] => {
48
+ export const readAllSignals = (
49
+ { projects, root, worktrees }: SignalSource,
50
+ now: number,
51
+ cache: StatCache<TaskSignal[]> = new Map(),
52
+ ): TaskSignal[] => {
47
53
  const notOlderThan = now - STALE_ACTIVITY_MINUTES * MS_PER_MINUTE
54
+ const round: SignalCache = { entries: cache, used: new Set<string>() }
48
55
  const claude = [root, ...subdirectories(worktrees)].flatMap((directory) =>
49
56
  recentSessionLogs(join(projects, projectDirectoryOf(directory)), notOlderThan).flatMap((path) =>
50
- readClaudeSignals(path, { notOlderThan }),
57
+ readClaudeSignals(path, { notOlderThan, cache: round }),
51
58
  ),
52
59
  )
60
+ dropUnused(round.entries, round.used)
53
61
  return [...claude, ...recordSignals(readTaskRecords(root))]
54
62
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs'
1
+ 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'
@@ -13,12 +13,18 @@ import {
13
13
  shortTitle,
14
14
  sprintTitleOf,
15
15
  } from '../plan-format'
16
- import { openWaitingItems, type WaitingItem } from '../plan-format/waiting'
16
+ import type { WaitingItem } from '../plan-format/waiting'
17
+ import { dropUnused } from '../shared/stat-cache'
17
18
  import type { TaskSignal } from '../task-signals/types'
18
19
  import type { LaneAgent, LaneStatusSource } from '../terminal'
20
+ import { isRestingAgent } from '../terminal/types'
21
+ import { laneReportPath, readLaneReport } from '../watch/lane-report-file'
22
+ import { restNote } from '../watch/lane-rest'
19
23
  import type { SourceTicket, WatchBadges, WatchHealth } from '../watch/plan-badges'
20
24
  import { readWatchReading } from '../watch/plan-badges-read'
25
+ import type { SourceStatus } from '../watch/source-statuses'
21
26
  import { assignSignals, laneOfDirectory, type UnplannedTask } from './active-tasks'
27
+ import { type AppointmentBlock, appointmentBlock } from './appointment-rows'
22
28
  import { boardRowsOf } from './board-rows'
23
29
  import {
24
30
  type ActivityTimes,
@@ -31,25 +37,41 @@ import {
31
37
  } from './current-phase'
32
38
  import { activityTextOf, taskSignalsOf } from './current-task'
33
39
  import { errorText } from './error-text'
34
- import { type BaseReader, baseReaderOf, gitStatusOf, refExists } from './git'
40
+ import { type BaseReader, baseReaderOf, gitStatusOf, lastChangeReaderOf, refExists } from './git'
35
41
  import type { PullRequestState } from './github'
36
- import { type Fallbacks, laneWorkOf, readAtBase, readFallback, readPlans, textAtBase } from './lane-plans'
42
+ import { groomedDaysAgo } from './groomed'
43
+ import {
44
+ type Fallbacks,
45
+ laneWorkOf,
46
+ type PlanSources,
47
+ readAtBase,
48
+ readFallback,
49
+ readPlans,
50
+ textAtBase,
51
+ } from './lane-plans'
37
52
  import { laneSummary } from './lane-summary'
38
53
  import { boardRowTexts, laneTicketsOf, withoutTicketLinks } from './lane-tickets'
39
54
  import type { LaneView } from './layout'
40
55
  import { extractLinkChips } from './link-chips'
56
+ import { type NextBlock, nextBlock } from './next-rows'
41
57
  import type { Options } from './options'
42
58
  import { laneModelsOf, type PhaseModelRecord } from './phase-models'
43
59
  import { readPhaseModels } from './phase-models-read'
44
- import { type PlannedPlan, plannedPlans } from './planned'
45
- import { type SentChip, type SentFileRecord, sentChipsByLane, sentWaitingItems } from './sent-files'
46
- import { readSentRecords } from './sent-files-read'
60
+ import { laneHeads, planPullRequests, readerOncePerRef } from './plan-heads'
61
+ import { markStale, type PlannedPlan, plannedPlans } from './planned'
62
+ import type { PullRequest } from './pull-requests'
63
+ import type { ReadCache } from './read-cache'
64
+ import { type SentChip, sentChipsByLane } from './sent-files'
47
65
  import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from './settings'
66
+ import { signalledTitle } from './signalled-phase'
48
67
  import { readAllSignals } from './signals-read'
68
+ import { withSourcePrefix } from './source-prefix'
69
+ import type { HerdSource, SourceBlock } from './source-rows'
49
70
  import type { SubagentActivity } from './subagents'
50
- import { readSubagentActivity } from './subagents-read'
71
+ import { readSubagentActivity, subagentSourceOf } from './subagents-read'
51
72
  import { boardTexts } from './texts'
52
73
  import { type UnlistedLane, unlistedLanes, unlistedSprintLane } from './unlisted-lanes'
74
+ import { readWaitingRecords, waitingItemsOf } from './waiting-records'
53
75
  import { withWorkedPhases } from './worked-phases'
54
76
 
55
77
  /** Where the view reads from: the main checkout, the sprint document, the command line and the project settings. */
@@ -101,6 +123,16 @@ export type Snapshot = {
101
123
  watchesTickets?: boolean
102
124
  /** What waits on the user's answer: the open rows of the board's section for it, from the checkout's board file. */
103
125
  waiting?: WaitingItem[]
126
+ /** Whole days since the board's grooming (`groomedDaysAgo`); null when its appointment is missing. */
127
+ groomedDays?: number | null
128
+ /** The board's next section, read from the same board text as `waiting`, for the block under it. */
129
+ next?: NextBlock
130
+ /** The board's open appointments, read from the same board text as `waiting`, for the block under the next one. */
131
+ appointments?: AppointmentBlock
132
+ /** The sources of `watch.sources` not marked `ticket`, from the watcher's state, for the block under the appointments. */
133
+ sourceBlock?: SourceBlock
134
+ /** The tone of each role, from the config, which `renderSnapshot` sets before it draws. */
135
+ tones?: PlansSettings['tones']
104
136
  /** What agents report doing that no lane takes: the coordinator's own, or a lane the board does not list. */
105
137
  unplaced?: UnplannedTask[]
106
138
  }
@@ -115,13 +147,19 @@ type LaneSources = {
115
147
  /** The agents' log reader, asking once per agent however often the read is used in one snapshot. */
116
148
  readLog: AgentLogReader
117
149
  pullRequests: PullRequestState
150
+ /** The open pull requests that change a plan, whose heads raise the ticks the base ref has. */
151
+ planPullRequests: PullRequest[]
152
+ /** The reader of a pull request head, resolved once per read whatever the number of lanes. */
153
+ headReaderOf: (ref: string) => BaseReader
118
154
  /** What agents report doing, in every lane and outside them. */
119
155
  signals: TaskSignal[]
120
156
  /** The directory the lanes' worktrees lie in, which tells a signal's lane. */
121
157
  worktrees: string
158
+ parsed?: PlanSources['parsed']
122
159
  }
123
160
 
124
- type BoardLanes = { markdown: string; lanes: SprintLane[] }
161
+ /** `liveMarkdown` is the checkout's board file (the coordinator edits it before any commit), else `markdown`. */
162
+ type BoardLanes = { markdown: string; liveMarkdown: string; lanes: SprintLane[] }
125
163
 
126
164
  const SHOWN_NAMES = 2
127
165
 
@@ -136,6 +174,7 @@ type Decorations = {
136
174
  phaseModels: readonly PhaseModelRecord[]
137
175
  sprintDirectory: string
138
176
  sentChips: ReadonlyMap<string | null, SentChip[]>
177
+ restNoteOf: (lane: string) => string | null
139
178
  }
140
179
 
141
180
  /** How full the agent's context is, null when the terminal gave no session or the session has no log. */
@@ -219,12 +258,20 @@ const laneView = async (
219
258
  modified,
220
259
  } = await readPlans(
221
260
  lane,
222
- { root, settings, base: sources.base, sprintDirectory: sources.sprintDirectory },
261
+ {
262
+ root,
263
+ settings,
264
+ base: sources.base,
265
+ sprintDirectory: sources.sprintDirectory,
266
+ heads: laneHeads(sources.planPullRequests, gitStatus.branch, sources.headReaderOf),
267
+ parsed: sources.parsed,
268
+ },
223
269
  laneWorkOf(worktree, gitStatus),
224
270
  fallbacks,
225
271
  )
226
272
  const shown = withWorkedPhases(lane, phasesByHref)
227
273
  const { progress, activity } = progressWithMemory(shown, phasesByHref, memories, { now, modified })
274
+ const laneSubagents = sources.subagents.get(lane.name)
228
275
  const view: LaneView = {
229
276
  progress,
230
277
  ...running,
@@ -238,6 +285,11 @@ const laneView = async (
238
285
  activity,
239
286
  guesses: taskSignalsOf(Object.values(phasesByHref).flat(), workText(sources, lane.name, agent), now),
240
287
  reported: sources.signals.filter(({ cwd }) => laneOfDirectory(cwd, sources.worktrees) === lane.name),
288
+ subagents: laneSubagents?.phaseRuns ?? [],
289
+ subagentTasks: laneSubagents?.taskDescriptions ?? [],
290
+ phaselessReviews: laneSubagents?.phaselessReviews ?? [],
291
+ describedSubagents: laneSubagents?.describedSubagents ?? [],
292
+ branch: gitStatus.branch,
241
293
  },
242
294
  now,
243
295
  ),
@@ -289,12 +341,17 @@ const withTickets = (result: LaneResult, tickets: readonly SourceTicket[]): Lane
289
341
  }
290
342
 
291
343
  /** What agents report doing; nothing when it cannot be read. */
292
- const readSignals = ({ root, readSignals: read }: Source, worktrees: string, now: number): TaskSignal[] => {
344
+ const readSignals = (
345
+ { root, readSignals: read }: Source,
346
+ worktrees: string,
347
+ now: number,
348
+ cache?: ReadCache,
349
+ ): TaskSignal[] => {
293
350
  try {
294
351
  return (
295
352
  read ??
296
353
  ((main, directory, at) =>
297
- readAllSignals({ projects: defaultProjectsDirectory(), root: main, worktrees: directory }, at))
354
+ readAllSignals({ projects: defaultProjectsDirectory(), root: main, worktrees: directory }, at, cache?.signals))
298
355
  )(root, worktrees, now)
299
356
  } catch {
300
357
  return []
@@ -322,52 +379,31 @@ const readAgents = (terminal: LaneStatusSource): { agents: LaneAgent[]; errors:
322
379
  }
323
380
 
324
381
  /** What subagents of the coordinator do in the worktrees; nothing when their logs cannot be read. */
325
- const readSubagents = ({ root, readSubagents: read }: Source, worktrees: string): Map<string, SubagentActivity> => {
382
+ const readSubagents = (
383
+ { root, settings, readSubagents: read }: Source,
384
+ worktrees: string,
385
+ ): Map<string, SubagentActivity> => {
326
386
  try {
327
387
  return (
328
- read ??
329
- ((main, directory) =>
330
- readSubagentActivity({
331
- projects: defaultProjectsDirectory(),
332
- root: main,
333
- approvalsRoot: main,
334
- worktreeDirectory: directory,
335
- }))
388
+ read ?? ((main, directory) => readSubagentActivity(subagentSourceOf(main, directory, settings.skillPrefix)))
336
389
  )(root, worktrees)
337
390
  } catch {
338
391
  return new Map()
339
392
  }
340
393
  }
341
394
 
342
- /**
343
- * The open items that wait on the user, from the checkout's board file (the coordinator edits it before any commit),
344
- * else from the text read at the base ref.
345
- */
346
- const waitingOf = (
347
- { sprintFile, settings }: Source,
348
- markdown: string,
349
- sends: readonly SentFileRecord[],
350
- now: number,
351
- ): WaitingItem[] => {
352
- const board = (() => {
353
- try {
354
- return openWaitingItems(
355
- existsSync(sprintFile) ? readFileSync(sprintFile, 'utf8') : markdown,
356
- settings.planLanguage,
357
- )
358
- } catch {
359
- return openWaitingItems(markdown, settings.planLanguage)
360
- }
361
- })()
362
- return [...board, ...sentWaitingItems(sends, now)]
363
- }
364
-
365
395
  /** The view with the files its session sent to the user, only when there are any, so it otherwise stays as it was. */
366
396
  const withSentFiles = (view: LaneView, chips: ReadonlyMap<string | null, SentChip[]>): LaneView => {
367
397
  const found = chips.get(view.progress.lane.name)
368
398
  return found ? { ...view, sentFiles: found } : view
369
399
  }
370
400
 
401
+ /** The view with what its resting lane waits on, read from the lane's report as the watcher reads it; as it was while the agent works. */
402
+ const withRestNote = (view: LaneView, restNoteOf: Decorations['restNoteOf']): LaneView => {
403
+ const note = isRestingAgent(view.status.agent) ? restNoteOf(view.progress.lane.name) : null
404
+ return note === null ? view : { ...view, restNote: note }
405
+ }
406
+
371
407
  /** Up to two names, then how many more: `a, b and 3 more`. */
372
408
  const namedWithMore = (names: readonly string[]): string => {
373
409
  const more = names.length - SHOWN_NAMES
@@ -395,8 +431,8 @@ const readBoardLanes = async (
395
431
  const { sprintFile, settings } = source
396
432
  const atBase = textAtBase(base, boardPath)
397
433
  const markdown = atBase ?? (await readFallback(boardPath, sprintFile, fallbacks)) ?? ''
398
- const checkoutText = atBase !== null && existsSync(sprintFile) ? await Bun.file(sprintFile).text() : markdown
399
- return { markdown, lanes: boardRowsOf(markdown, checkoutText, settings.planLanguage).lanes }
434
+ const liveMarkdown = atBase !== null && existsSync(sprintFile) ? await Bun.file(sprintFile).text() : markdown
435
+ return { markdown, liveMarkdown, lanes: boardRowsOf(markdown, liveMarkdown, settings.planLanguage).lanes }
400
436
  }
401
437
 
402
438
  /** The plans the sprint's plans tables name, read at the base ref, by link; lanes read theirs themselves. */
@@ -429,11 +465,17 @@ const forgetLanes = (memories: Map<string, LaneMemory> | undefined, lanes: reado
429
465
 
430
466
  const laneSetOf = (
431
467
  source: Source,
432
- context: { base: BaseReader; board: BoardLanes; sprintDirectory: string; worktrees: string },
468
+ context: {
469
+ base: BaseReader
470
+ board: BoardLanes
471
+ sprintDirectory: string
472
+ worktrees: string
473
+ cache?: ReadCache
474
+ },
433
475
  pullRequests: PullRequestState,
434
476
  now: number,
435
477
  ): LaneSet => {
436
- const { base, board, sprintDirectory, worktrees } = context
478
+ const { base, board, sprintDirectory, worktrees, cache } = context
437
479
  const { agents, errors } = readAgents(source.terminal)
438
480
  const isWanted = ({ name }: { name: string }) => !source.options.lane || name === source.options.lane
439
481
  const subagents = readSubagents(source, worktrees)
@@ -449,8 +491,11 @@ const laneSetOf = (
449
491
  subagents,
450
492
  readLog: onceReader(source.readAgentLog ?? agentLog),
451
493
  pullRequests,
452
- signals: readSignals(source, worktrees, now),
494
+ planPullRequests: planPullRequests([...pullRequests.byBranch.values()].flat(), source.settings.planDirectory),
495
+ headReaderOf: readerOncePerRef((ref) => baseReaderOf(source.root, ref)),
496
+ signals: readSignals(source, worktrees, now, cache),
453
497
  worktrees,
498
+ parsed: cache && { entries: cache.plans, used: new Set<string>() },
454
499
  },
455
500
  errors,
456
501
  }
@@ -474,53 +519,56 @@ const laneResultsOf = async (
474
519
  /** Each lane's view with its tickets, the watcher's badges, its phases' models and the files sent from it. */
475
520
  const decoratedViews = (
476
521
  results: readonly LaneResult[],
477
- { reading, phaseModels, sprintDirectory, sentChips }: Decorations,
522
+ { reading, phaseModels, sprintDirectory, sentChips, restNoteOf }: Decorations,
478
523
  ): LaneView[] =>
479
524
  results.map((result) =>
480
- withSentFiles(
481
- withModels(
482
- withBadges(withTickets(result, reading?.tickets ?? []), reading?.badges ?? {}),
483
- phaseModels,
484
- sprintDirectory,
525
+ withRestNote(
526
+ withSentFiles(
527
+ withModels(
528
+ withBadges(withTickets(result, reading?.tickets ?? []), reading?.badges ?? {}),
529
+ phaseModels,
530
+ sprintDirectory,
531
+ ),
532
+ sentChips,
485
533
  ),
486
- sentChips,
534
+ restNoteOf,
487
535
  ),
488
536
  )
489
537
 
490
- /** The files sent to the user, read once: the records, and their badges by lane. */
491
- const sendsOf = (
492
- root: string,
493
- worktrees: string,
494
- now: number,
495
- ): { records: SentFileRecord[]; chips: Map<string | null, SentChip[]> } => {
496
- const records = readSentRecords(root)
497
- return { records, chips: sentChipsByLane(records, worktrees, now) }
498
- }
499
-
500
538
  /** The open phases no lane with an agent leads; none while the view shows one lane. */
539
+ const withSourceTones = (statuses: readonly SourceStatus[], configured: PlansSettings['herdSources']): HerdSource[] =>
540
+ statuses.map((status) => ({ ...status, tone: configured.find(({ name }) => name === status.name)?.tone ?? null }))
541
+
501
542
  const plannedOf = (
502
543
  source: Source,
503
544
  plans: Record<string, string>,
504
545
  results: readonly LaneResult[],
505
546
  sprintDirectory: string,
547
+ now: number,
506
548
  ): PlannedPlan[] =>
507
549
  source.options.lane
508
550
  ? []
509
- : plannedPlans({
510
- plans,
511
- lanes: results.map(({ view }) => ({ lane: view.progress.lane, agent: view.status.agent })),
512
- sprintDirectory,
513
- language: source.settings.planLanguage,
514
- })
551
+ : markStale(
552
+ plannedPlans({
553
+ plans,
554
+ lanes: results.map(({ view }) => ({ lane: view.progress.lane, agent: view.status.agent })),
555
+ sprintDirectory,
556
+ language: source.settings.planLanguage,
557
+ }),
558
+ lastChangeReaderOf(source.root, baseRefOf(source.settings)),
559
+ now,
560
+ )
515
561
 
516
562
  /**
517
563
  * Reads the board, its plans and the lanes' state. `memories` holds, per lane, what the herd saw at the last read; it
518
564
  * is how the current phase becomes the one whose boxes moved since, and it is cleaned of lanes the board dropped.
565
+ * `cache`, kept by the caller between reads, spares parsing the session logs and plans that did not change.
519
566
  */
520
567
  export const loadSnapshot = async (
521
568
  source: Source,
522
569
  pullRequests: PullRequestState,
523
570
  memories?: Map<string, LaneMemory>,
571
+ cache?: ReadCache,
524
572
  ): Promise<Snapshot> => {
525
573
  const { root, sprintFile, options, settings } = source
526
574
  const fallbacks: Fallbacks = []
@@ -532,14 +580,32 @@ export const loadSnapshot = async (
532
580
  const sprintDirectory = dirname(boardPath)
533
581
  const worktrees = join(root, settings.worktreeDirectory)
534
582
  const now = Date.now()
535
- const laneSet = laneSetOf(source, { base, board, sprintDirectory, worktrees }, pullRequests, now)
583
+ const liveBoard = board.liveMarkdown
584
+ const laneSet = laneSetOf(source, { base, board, sprintDirectory, worktrees, cache }, pullRequests, now)
536
585
  const results = await laneResultsOf(laneSet, fallbacks, now, memories)
586
+ const { parsed } = laneSet.sources
587
+ if (parsed) dropUnused(parsed.entries, parsed.used)
537
588
  const listedPlans = await readListedPlans(source, base, sprintDirectory, markdown, fallbacks)
538
589
  const documentsPlans = Object.assign({}, listedPlans, ...results.map((result) => result.texts))
539
- const { ticketSources } = settings
540
- const watched = ticketSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
541
- const reading = watched ? readWatchReading(root, now, ticketSources) : null
542
- const sends = sendsOf(root, worktrees, now)
590
+ const { ticketSources, herdSources } = settings
591
+ const watched =
592
+ ticketSources.length > 0 || herdSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
593
+ const reading = watched
594
+ ? readWatchReading(
595
+ root,
596
+ now,
597
+ ticketSources,
598
+ undefined,
599
+ undefined,
600
+ herdSources.map(({ name }) => name),
601
+ )
602
+ : null
603
+ const waitingRecords = readWaitingRecords(root)
604
+ const waiting = withSourcePrefix(
605
+ waitingItemsOf(liveBoard, settings.planLanguage, waitingRecords, now),
606
+ settings.sourceLabels,
607
+ )
608
+ const sentChips = sentChipsByLane(waitingRecords.sent, worktrees, now)
543
609
  const notice = fallbacks.length > 0 ? fallbackNotice(fallbacks, baseRef, refExists(root, baseRef)) : null
544
610
  const unknownLaneTitle =
545
611
  options.lane && results.length === 0 ? boardTexts(settings.board.term).laneNotListed(options.lane) : null
@@ -549,7 +615,9 @@ export const loadSnapshot = async (
549
615
  reading,
550
616
  phaseModels: readPhaseModels(root),
551
617
  sprintDirectory,
552
- sentChips: sends.chips,
618
+ sentChips,
619
+ restNoteOf: (lane) =>
620
+ restNote(readLaneReport(laneReportPath(worktreeOf(root, settings, lane), lane))?.text ?? null),
553
621
  }),
554
622
  documents: {
555
623
  sprint: markdown,
@@ -557,12 +625,25 @@ export const loadSnapshot = async (
557
625
  laneOrder: [...laneSet.lanes, ...laneSet.unlisted].map((lane) => lane.name),
558
626
  lanes: board.lanes,
559
627
  },
560
- planned: plannedOf(source, documentsPlans, results, sprintDirectory),
628
+ planned: plannedOf(source, documentsPlans, results, sprintDirectory, now),
561
629
  files: [sprintFile, ...results.flatMap((result) => result.files)],
562
- titles: new Map(results.flatMap(({ view, pane }) => (pane ? [[pane, shortTitle(view.progress)]] : []))),
630
+ titles: new Map(
631
+ results.flatMap(({ view, pane }) =>
632
+ pane ? [[pane, signalledTitle(view.summary?.signalled ?? null, shortTitle(view.progress))]] : [],
633
+ ),
634
+ ),
563
635
  errors: [...laneSet.errors, ...pullRequests.errors, ...(notice ? [notice] : [])],
564
636
  watch: reading?.health,
565
- waiting: waitingOf(source, markdown, sends.records, now),
637
+ waiting,
638
+ groomedDays: groomedDaysAgo(liveBoard, settings.planLanguage, new Date(now)),
639
+ next: nextBlock(
640
+ liveBoard,
641
+ settings.planLanguage,
642
+ waiting.map(({ item }) => item),
643
+ ),
644
+ appointments: appointmentBlock(liveBoard, settings.planLanguage, new Date(now)),
645
+ ...(reading?.sources ? { sourceBlock: { sources: withSourceTones(reading.sources, herdSources), now } } : {}),
646
+ tones: settings.tones,
566
647
  unplaced: unplacedOf(laneSet.sources, laneSet.lanes, Boolean(options.lane)),
567
648
  ...(ticketSources.length > 0 ? { watchesTickets: true } : {}),
568
649
  }
@@ -0,0 +1,19 @@
1
+ import type { WaitingItem } from '../plan-format/waiting'
2
+
3
+ /** Whether the source `source` has an entry titled `title` that an open row of "Waiting for you" asks about. */
4
+ export type AskedCheck = (source: string, title: string) => boolean
5
+
6
+ const same = (left: string, right: string): boolean => left.trim().toLowerCase() === right.trim().toLowerCase()
7
+
8
+ /**
9
+ * The check against the open rows: a row asks about an entry when its origin cell names the entry's source and its text
10
+ * contains the entry's title, in any case. The rows come from the board, whose text names the entry in words; no id links them.
11
+ */
12
+ export const askedCheckOf =
13
+ (waiting: readonly WaitingItem[]): AskedCheck =>
14
+ (source, title) =>
15
+ title.trim() !== '' &&
16
+ waiting.some(
17
+ ({ origin, item }) =>
18
+ origin !== undefined && same(origin, source) && item.toLowerCase().includes(title.trim().toLowerCase()),
19
+ )
@@ -0,0 +1,17 @@
1
+ import type { WaitingItem } from '../plan-format/waiting'
2
+
3
+ /** By source name in lower case: what the herd puts before a row that came from the source, its `label` or else its name. */
4
+ export type SourceLabels = Readonly<Record<string, string>>
5
+
6
+ const lookup = (labels: SourceLabels, origin: string): string | undefined =>
7
+ Object.entries(labels).find(([name]) => name.toLowerCase() === origin.trim().toLowerCase())?.[1]
8
+
9
+ /**
10
+ * The rows with `prefix` set on those whose origin cell names a source: the board row of a source's report then
11
+ * reads `Error log · Checkout: Resource Not Found` without a look at the plan. Any other row stays as it is.
12
+ */
13
+ export const withSourcePrefix = (items: readonly WaitingItem[], labels: SourceLabels): WaitingItem[] =>
14
+ items.map((item) => {
15
+ const prefix = item.origin === undefined ? undefined : lookup(labels, item.origin)
16
+ return prefix === undefined ? item : { ...item, prefix }
17
+ })