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,6 +1,4 @@
1
1
  import { currentPhase, type LaneProgress, PHASE_ID_PREFIX, type PhaseProgress } from '../plan-format'
2
- import { AGENT_STATUS } from '../terminal/types'
3
- import type { LaneSnapshot } from './lane-watch'
4
2
 
5
3
  /** The current phase of a lane and how many of its checkboxes are ticked, as the herd counts them. */
6
4
  export type PhaseCount = Pick<PhaseProgress, 'id' | 'done' | 'total'>
@@ -23,25 +21,15 @@ export const currentPhaseCountsOf = (progress: Readonly<Record<string, LaneProgr
23
21
  }),
24
22
  )
25
23
 
26
- const sameCount = (left: PhaseCount | undefined, right: PhaseCount): boolean =>
27
- left?.id === right.id && left.done === right.done && left.total === right.total
28
-
29
24
  /**
30
- * The note of each working lane whose count differs from the one at the last heartbeat (or has none before): a lane
31
- * that has not moved says nothing, so a stretch on one number tells the coordinator to look for itself.
25
+ * The note of each working lane that has a count, at every heartbeat: a stretch of reviews or subagents without a new
26
+ * tick still shows where the lane stands, and an unchanged number tells the coordinator the lane is not stuck on
27
+ * silence.
32
28
  */
33
- export const progressNotes = (
34
- lanes: readonly string[],
35
- current: LaneSnapshot,
36
- counts: Readonly<Record<string, PhaseCount>>,
37
- previous: Readonly<Record<string, PhaseCount>>,
38
- ): LaneNotes =>
29
+ export const progressNotes = (working: readonly string[], counts: Readonly<Record<string, PhaseCount>>): LaneNotes =>
39
30
  Object.fromEntries(
40
- lanes.flatMap((lane) => {
31
+ working.flatMap((lane) => {
41
32
  const count = counts[lane]
42
- const moved = count !== undefined && !sameCount(previous[lane], count)
43
- return moved && current[lane]?.status === AGENT_STATUS.working
44
- ? [[lane, `${PHASE_ID_PREFIX}${count.id} ${count.done}/${count.total}`]]
45
- : []
33
+ return count !== undefined ? [[lane, `${PHASE_ID_PREFIX}${count.id} ${count.done}/${count.total}`]] : []
46
34
  }),
47
35
  )
@@ -1,18 +1,37 @@
1
- import { kitUpdatedLine, pendingKitUpdate } from '../version/installed-version'
1
+ import { type KitUpdate, kitUpdatedLine, pendingKitUpdate } from '../version/installed-version'
2
2
  import type { StartedKit } from '../version/started-kit'
3
3
 
4
- type Effects = { saveState: () => void; log: (line: string) => void; skillLine: () => string }
4
+ type Effects = {
5
+ saveState: () => void
6
+ log: (line: string) => void
7
+ skillLine: () => string
8
+ /** Whether the source the watcher runs from changed and the change settled; always false for an installed copy. */
9
+ sourceChanged: () => boolean
10
+ }
11
+
12
+ /**
13
+ * The line that ends the watcher: a new version on disk, else a settled change to the source it runs from, which
14
+ * keeps the version (`=== kit updated 1.9.0 → 1.9.0 (source changed)`); null while neither.
15
+ */
16
+ export const kitEndLine = (update: KitUpdate | null, version: string, sourceChanged: boolean): string | null => {
17
+ if (update) return kitUpdatedLine(update)
18
+ return sourceChanged ? `${kitUpdatedLine({ from: version, to: version })} (source changed)` : null
19
+ }
5
20
 
6
21
  /**
7
- * Ends the watcher when the kit on disk is another version than the one it started with: saves the state
8
- * as any stop does, then prints the line that wakes the coordinator and the line about the orchestrate
9
- * skill. True when the watcher should end.
22
+ * Ends the watcher when the kit on disk is another version than the one it started with, or when the source it runs
23
+ * from changed: saves the state as any stop does, then prints the line that wakes the coordinator and the line about
24
+ * the orchestrate skill. True when the watcher should end.
10
25
  */
11
- export const endedByKitUpdate = (kit: StartedKit | null, { saveState, log, skillLine }: Effects): boolean => {
12
- const update = pendingKitUpdate(kit)
13
- if (!update) return false
26
+ export const endedByKitUpdate = (
27
+ kit: StartedKit | null,
28
+ { saveState, log, skillLine, sourceChanged }: Effects,
29
+ ): boolean => {
30
+ if (!kit) return false
31
+ const line = kitEndLine(pendingKitUpdate(kit), kit.version, sourceChanged())
32
+ if (line === null) return false
14
33
  saveState()
15
- log(kitUpdatedLine(update))
34
+ log(line)
16
35
  log(skillLine())
17
36
  return true
18
37
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * A lane name that stays a single file name: no separator, no `..`. The prompt, report and hand-over files are named
3
+ * after the lane, and the watcher puts it on a suggested command line, so every reader of a lane name checks it
4
+ * against this.
5
+ */
6
+ export const SAFE_LANE_NAME = /^(?!\.\.?$)[A-Za-z0-9._-]+$/
@@ -2,35 +2,57 @@ import { existsSync, readFileSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
3
  import { type LaneProgress, parallelGroupsOfPlan, resolvePlanPath, type SprintLane } from '../plan-format'
4
4
  import { boardRowsOf } from '../plans/board-rows'
5
- import { baseReaderOf, gitStatusOf } from '../plans/git'
5
+ import { type BaseReader, baseReaderOf, gitStatusOf } from '../plans/git'
6
6
  import { laneWorkOf, readPlans, textAtBase } from '../plans/lane-plans'
7
+ import { laneHeads, type PlanPullRequest, planPullRequests } from '../plans/plan-heads'
7
8
  import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from '../plans/settings'
8
9
  import { workedLaneProgress } from '../plans/worked-phases'
9
10
  import { parallelBatchOf } from './lane-parallel'
11
+ import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
12
+
13
+ /** The open pull requests of the watcher's list that change a plan, as the herd chooses them. */
14
+ export const watchedPlanPullRequests = (
15
+ pullRequests: readonly WatchedPullRequest[],
16
+ settings: PlansSettings,
17
+ ): WatchedPullRequest[] =>
18
+ planPullRequests(
19
+ pullRequests.filter(({ state }) => state === PULL_REQUEST_STATE.open),
20
+ settings.planDirectory,
21
+ )
10
22
 
11
23
  /**
12
- * The progress the watcher reports for a lane, from the same reading as the herd: the base ref's plans, the
13
- * phases the lane works on from its worktree (also without a board row), so both name the same open phase.
24
+ * The progress the watcher reports for a lane, from the same reading as the herd: the base ref's plans, raised by
25
+ * the fetched heads of `openPlanPullRequests` (`watchedPlanPullRequests`), the phases the lane works on from its
26
+ * worktree (also without a board row), so both name the same open phase.
14
27
  */
15
28
  export const watchedLaneProgress = async (
16
29
  root: string,
17
30
  settings: PlansSettings,
18
31
  boardFile: string,
19
32
  lane: SprintLane,
33
+ openPlanPullRequests: readonly PlanPullRequest[],
34
+ headReaderOf: (ref: string) => BaseReader = (ref) => baseReaderOf(root, ref),
20
35
  ): Promise<LaneProgress> => {
21
36
  const baseRef = baseRefOf(settings)
22
37
  const worktree = worktreeOf(root, settings, lane.name)
38
+ const status = await gitStatusOf(worktree, baseRef)
23
39
  const sources = {
24
40
  root,
25
41
  settings,
26
42
  base: baseReaderOf(root, baseRef),
27
43
  sprintDirectory: dirname(boardRepoPathOf(root, settings, boardFile)),
44
+ heads: laneHeads(openPlanPullRequests, status.branch, headReaderOf),
28
45
  }
29
- const work = laneWorkOf(worktree, await gitStatusOf(worktree, baseRef))
30
- const { plans } = await readPlans(lane, sources, work, [])
46
+ const { plans } = await readPlans(lane, sources, laneWorkOf(worktree, status), [])
31
47
  return workedLaneProgress(lane, plans)
32
48
  }
33
49
 
50
+ /** The repository path of the plan of a lane's newest row that links one; null without such a row. */
51
+ const lanePlanPath = (root: string, settings: PlansSettings, boardFile: string, lane: SprintLane): string | null => {
52
+ const href = lane.steps.filter((step) => step.planHref !== null).at(-1)?.planHref
53
+ return href ? resolvePlanPath(dirname(boardRepoPathOf(root, settings, boardFile)), href) : null
54
+ }
55
+
34
56
  /**
35
57
  * The text of a lane's plan, the plan of its newest row, as the lane's worktree has it, else as the base ref has it;
36
58
  * null without a row that links a plan or when neither has the file.
@@ -41,15 +63,25 @@ export const watchedLanePlanText = (
41
63
  boardFile: string,
42
64
  lane: SprintLane,
43
65
  ): string | null => {
44
- const href = lane.steps.filter((step) => step.planHref !== null).at(-1)?.planHref
45
- if (!href) return null
46
- const path = resolvePlanPath(dirname(boardRepoPathOf(root, settings, boardFile)), href)
66
+ const path = lanePlanPath(root, settings, boardFile, lane)
67
+ if (path === null) return null
47
68
  const worktreeFile = join(worktreeOf(root, settings, lane.name), path)
48
69
  return existsSync(worktreeFile)
49
70
  ? readFileSync(worktreeFile, 'utf8')
50
71
  : textAtBase(baseReaderOf(root, baseRefOf(settings)), path)
51
72
  }
52
73
 
74
+ /** The text of the same plan as the base ref has it, which the lane cannot change; null without a plan or the file. */
75
+ export const baseLanePlanText = (
76
+ root: string,
77
+ settings: PlansSettings,
78
+ boardFile: string,
79
+ lane: SprintLane,
80
+ ): string | null => {
81
+ const path = lanePlanPath(root, settings, boardFile, lane)
82
+ return path === null ? null : textAtBase(baseReaderOf(root, baseRefOf(settings)), path)
83
+ }
84
+
53
85
  /**
54
86
  * The phases of a lane's plan that can run together, by the plan's dependency table and ticks; empty without a plan,
55
87
  * without a table or without two such phases.
@@ -2,18 +2,27 @@ import { closeSync, lstatSync, openSync, readSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { claudePath, HANDOVER_DIRECTORY } from '../claude/paths'
4
4
  import type { LaneReportFile } from './lane-message'
5
+ import { SAFE_LANE_NAME } from './lane-name'
5
6
 
6
- /** A lane name that stays a single file name: no separator, no `..`. */
7
- const SAFE_LANE_NAME = /^(?!\.\.?$)[A-Za-z0-9._-]+$/
8
7
  /** The head of a report the watcher reads; it prints far less, and a huge file must not stall it. */
9
8
  const READ_LIMIT_BYTES = 8192
10
9
 
10
+ /** Where a lane writes its report, relative to its worktree: `.claude/handover/<lane>-report.md`. */
11
+ export const laneReportFile = (lane: string): string => claudePath(HANDOVER_DIRECTORY, `${lane}-report.md`)
12
+
11
13
  /**
12
- * Where a lane writes its report, inside its worktree: `.claude/handover/<lane>-report.md`. Null for a lane name
13
- * that could leave that directory: names come from board rows and herdr agents.
14
+ * Where a lane writes its report, inside its worktree (`laneReportFile`). Null for a lane name that could leave that
15
+ * directory: names come from board rows and herdr agents.
14
16
  */
15
17
  export const laneReportPath = (worktree: string, lane: string): string | null =>
16
- SAFE_LANE_NAME.test(lane) ? join(worktree, claudePath(HANDOVER_DIRECTORY), `${lane}-report.md`) : null
18
+ SAFE_LANE_NAME.test(lane) ? join(worktree, laneReportFile(lane)) : null
19
+
20
+ /**
21
+ * Where a lane's hand-over lies, inside its worktree: `.claude/handover/<lane>.md`, which the coordinator opens a
22
+ * fresh chat on. Null for a lane name that could leave that directory.
23
+ */
24
+ export const laneHandoverPath = (worktree: string, lane: string): string | null =>
25
+ SAFE_LANE_NAME.test(lane) ? join(worktree, claudePath(HANDOVER_DIRECTORY), `${lane}.md`) : null
17
26
 
18
27
  /** The change time of a regular file at `path`, not followed through a symlink; null for anything else. */
19
28
  const regularFileMtime = (path: string): number | null => {
@@ -35,6 +44,12 @@ const readHead = (path: string): string => {
35
44
  }
36
45
  }
37
46
 
47
+ /** Whether the lane left its hand-over file in the worktree: a regular file, not a link. */
48
+ export const hasLaneHandover = (worktree: string, lane: string): boolean => {
49
+ const path = laneHandoverPath(worktree, lane)
50
+ return path !== null && regularFileMtime(path) !== null
51
+ }
52
+
38
53
  /** The change time of the report file; null when there is none or it is no regular file. */
39
54
  export const reportMtime = (path: string | null): number | null => (path === null ? null : regularFileMtime(path))
40
55
 
@@ -1,4 +1,5 @@
1
1
  import { CONTEXT_LEVEL, type ContextLevel, contextLabel } from '../context'
2
+ import { closeDryRunOf } from '../lane/close-command-line'
2
3
  import { currentPhase, isTickedUnmerged, type LaneProgress, phasesText } from '../plan-format'
3
4
  import { terminalSafe } from '../plan-format/terminal-text'
4
5
  import { CHECK_NAME_LENGTH, type ChecksState } from '../plans/pull-requests'
@@ -14,6 +15,7 @@ import { AGENT_STATUS, type LaneSnapshot, NO_AGENT } from './lane-watch'
14
15
  import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
15
16
  import { SOURCE_FIELD } from './source-fields'
16
17
  import { type FieldChange, lineText, type SourceEvent } from './sources'
18
+ import { NO_TASK_SIGNAL_EVENT, type NoTaskSignalEvent, noTaskSignalLine } from './task-signal-missing'
17
19
  import type { WatchTexts } from './texts'
18
20
  import {
19
21
  type DeployEvent,
@@ -40,6 +42,7 @@ export type LaneReport = {
40
42
  /** The board as the coordinator line reminds of it: its term and the rows only the checkout's board file has. */
41
43
  export type BoardReminder = { term: string; checkoutRows: number }
42
44
 
45
+ const RESTING_STATUSES: ReadonlySet<string> = new Set([AGENT_STATUS.done, AGENT_STATUS.idle])
43
46
  const UNKNOWN = 'unknown'
44
47
  const REPORT_TEXT_LENGTH = 160
45
48
  const FIELD_VALUE_LENGTH = 60
@@ -78,17 +81,33 @@ const contextSegment = (level: ContextLevel | null): string[] =>
78
81
 
79
82
  /**
80
83
  * One line per lane, in the words of `texts`: `<lane> <status> [· context warn|alert] [· waits on: <question>, <n> open questions] · <branch>|no branch ·
81
- * PR #<n> <checks>|no PR · open phase …|phase … ticked, not merged|plan finished|no plan`.
84
+ * PR #<n> <checks>|no PR · open phase …|phase … ticked, not merged|plan finished|no plan`. A resting lane whose plan is
85
+ * finished is followed by the dry run of `jh lane close` for it and `closeLaneHint`, the path of the reference on
86
+ * closing a lane.
82
87
  */
83
- export const laneReportLine = (report: LaneReport, texts: WatchTexts): string =>
84
- [
88
+ export const laneReportLine = (report: LaneReport, texts: WatchTexts, closeLaneHint = ''): string => {
89
+ const phase = phaseSegment(report.progress, texts)
90
+ const waitsForClosing = phase === texts.planFinished && RESTING_STATUSES.has(report.status)
91
+ const dryRun = closeDryRunOf(report.lane)
92
+ const closing = `${phase}${dryRun === null ? '' : ` · close: ${dryRun}`}${closeLaneHint}`
93
+ return [
85
94
  `${terminalSafe(report.lane)} ${terminalSafe(report.status)}`,
86
95
  ...contextSegment(report.context),
87
96
  ...(report.rest ? [terminalSafe(report.rest)] : []),
88
97
  report.branch ? terminalSafe(report.branch) : texts.noBranch,
89
98
  pullRequestSegment(report.pullRequest, texts),
90
- phaseSegment(report.progress, texts),
99
+ waitsForClosing ? closing : phase,
91
100
  ].join(' · ')
101
+ }
102
+
103
+ /**
104
+ * The header of a stopped lane's section: `=== <lane>: <status>`. A lane that stopped `done` or `idle` with a hand-over
105
+ * file in its worktree (`handoverHint`, null without one) names the file and the reference on it.
106
+ */
107
+ export const laneStopHeader = (lane: string, status: string, handoverHint: string | null): string => {
108
+ const header = `=== ${terminalSafe(lane)}: ${terminalSafe(status)}`
109
+ return handoverHint !== null && RESTING_STATUSES.has(status) ? `${header} · hand-over file${handoverHint}` : header
110
+ }
92
111
 
93
112
  /** The lane, or `branch <name>` for an own pull request without a lane, its name cut like other third-party text. */
94
113
  const pullRequestOwner = (event: PullRequestEvent): string =>
@@ -154,6 +173,7 @@ export const watchEventLine = (event: WatchEvent, target: string | null): string
154
173
  if (event.kind === WATCH_EVENT.laneParallel)
155
174
  return `=== ${event.kind} ${terminalSafe(event.lane)} ${phasesText(event.phases)}`
156
175
  if (event.kind === WATCH_EVENT.taskParallel) return `=== ${terminalSafe(event.line)}`
176
+ if (event.kind === WATCH_EVENT.noTaskSignal) return `=== ${terminalSafe(noTaskSignalLine(event))}`
157
177
  if (event.kind === WATCH_EVENT.approval)
158
178
  return `=== ${event.kind} ${terminalSafe(event.lane)} ${event.id} ${terminalSafe(event.tool)} · ${terminalSafe(event.summary)}`
159
179
  if (event.kind === WATCH_EVENT.coordinator) return `=== ${event.kind} ${event.level} ${contextLabel(event.share)}`
@@ -300,6 +320,14 @@ export const parallelDutyLines = (events: readonly WatchEvent[], texts: WatchTex
300
320
  .filter(isLaneParallel)
301
321
  .map(({ lane, phases }) => `${terminalSafe(lane)} parallel: ${texts.parallelDuty(phasesText(phases))}`)
302
322
 
323
+ const isNoTaskSignal = (event: WatchEvent): event is NoTaskSignalEvent => event.kind === WATCH_EVENT.noTaskSignal
324
+
325
+ /** What a lane that works without a task signal asks of the coordinator (`references/events.md`): one line per such lane. */
326
+ export const noTaskSignalDutyLines = (events: readonly WatchEvent[], texts: WatchTexts): string[] =>
327
+ events
328
+ .filter(isNoTaskSignal)
329
+ .map(({ lane }) => `${terminalSafe(lane)} ${NO_TASK_SIGNAL_EVENT}: ${texts.noTaskSignalDuty}`)
330
+
303
331
  const isApproval = (event: WatchEvent): event is ApprovalEvent => event.kind === WATCH_EVENT.approval
304
332
 
305
333
  /**
@@ -1,7 +1,10 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { fetchBase } from '../plans/fetch'
3
- import { BASE_REMOTE } from '../plans/settings'
4
- import { baseFetchNeeded, remoteTipOf } from './base-tip'
2
+ import { fetchBase, fetchPlanHeads } from '../plans/fetch'
3
+ import { commitOf } from '../plans/git'
4
+ import { BASE_REMOTE, baseRefOf } from '../plans/settings'
5
+ import { baseFetchNeeded, movedHeads, remoteTipOf } from './base-tip'
6
+ import { behindCountOf, type MainCheckoutRead } from './main-checkout'
7
+ import type { WatchedPullRequest } from './pull-requests'
5
8
 
6
9
  const GIT_ASYNC_TIMEOUT_MS = 30_000
7
10
 
@@ -31,6 +34,20 @@ const git = (cwd: string, args: string[]): string | null => {
31
34
  export const branchOf = (worktree: string): string | null =>
32
35
  existsSync(worktree) ? git(worktree, ['branch', '--show-current']) || null : null
33
36
 
37
+ /**
38
+ * The main checkout's branch and the commits of `origin/<base>` it lacks, as far as git can tell; read from local refs
39
+ * only. Null when git cannot resolve HEAD, so an unreadable checkout never reads as a detached head.
40
+ */
41
+ export const readMainCheckout = (root: string, baseBranch: string): MainCheckoutRead | null => {
42
+ if (git(root, ['rev-parse', '--verify', '--quiet', 'HEAD']) === null) return null
43
+ const remoteBase = baseRefOf({ baseBranch })
44
+ return {
45
+ branch: branchOf(root),
46
+ behind: behindCountOf(git(root, ['rev-list', '--count', `HEAD..${remoteBase}`])),
47
+ remoteBase,
48
+ }
49
+ }
50
+
34
51
  /**
35
52
  * Fetches the base branch when the remote moved past `origin/<branch>`, so every reader of that ref
36
53
  * (the herd watches `FETCH_HEAD`) sees a merge as soon as the watcher does. Asks the remote
@@ -44,3 +61,14 @@ export const fetchBaseWhenMoved = async (root: string, branch: string): Promise<
44
61
  if (!baseFetchNeeded(remote === null ? null : remoteTipOf(remote, branch), local?.trim() || null)) return
45
62
  await fetchBase(root, BASE_REMOTE, branch)
46
63
  }
64
+
65
+ /**
66
+ * Fetches the heads of the plan pull requests whose head commit moved past the herd's ref of it, in one call, so the
67
+ * watcher and the herd read the same ticks; an unchanged head costs no fetch, a failure leaves the refs as they are.
68
+ */
69
+ export const fetchPlanHeadsWhenMoved = (root: string, pullRequests: readonly WatchedPullRequest[]): Promise<void> =>
70
+ fetchPlanHeads(
71
+ root,
72
+ BASE_REMOTE,
73
+ movedHeads(pullRequests, (ref) => commitOf(root, ref)),
74
+ )
@@ -0,0 +1,34 @@
1
+ import { terminalSafe } from '../plan-format/terminal-text'
2
+ import type { WatchTexts } from './texts'
3
+
4
+ /**
5
+ * What git says about the main checkout: the branch it has checked out (null for a detached head) and the commits of
6
+ * `remoteBase` (`origin/<base>`) it lacks.
7
+ */
8
+ export type MainCheckoutRead = { branch: string | null; behind: number | null; remoteBase: string }
9
+
10
+ const COUNT = /^\d+$/
11
+
12
+ /** The count `git rev-list --count` printed; null when git failed or printed something else. */
13
+ export const behindCountOf = (revList: string | null): number | null => {
14
+ const count = revList?.trim() ?? ''
15
+ return COUNT.test(count) ? Number(count) : null
16
+ }
17
+
18
+ /**
19
+ * The duty line of the main checkout, in every report and heartbeat until it is resolved. With another branch or a
20
+ * detached head checked out: `main checkout on <branch>, not <base>`, since the main checkout is the coordinator's seat
21
+ * on the base and a fast-forward would land on the wrong branch. With the base checked out and behind the merges on
22
+ * the remote: `main checkout behind origin/<base> by <n> (git merge --ff-only origin/<base>)`. Null when caught up or
23
+ * unreadable; a `read` of null (git could not resolve the main checkout's HEAD) is unreadable, not a detached head.
24
+ */
25
+ export const mainCheckoutDutyLine = (
26
+ read: MainCheckoutRead | null,
27
+ baseBranch: string,
28
+ texts: WatchTexts,
29
+ ): string | null => {
30
+ if (read === null) return null
31
+ if (read.branch !== baseBranch) return terminalSafe(texts.mainCheckoutOffBase(read.branch, baseBranch))
32
+ if (read.behind === null || read.behind === 0) return null
33
+ return terminalSafe(texts.mainCheckoutBehind(read.remoteBase, read.behind))
34
+ }
@@ -1,5 +1,5 @@
1
- import { readFileSync } from 'node:fs'
2
- import { join } from 'node:path'
1
+ import { dirname, join } from 'node:path'
2
+ import { isPrivateLevels, type LocationSystem, readPrivateText, systemLocation } from '../shared/private-location'
3
3
  import {
4
4
  sourceTicketsOf,
5
5
  type TicketSource,
@@ -12,8 +12,10 @@ import { parseWatchState, type WatchState } from './watch-events'
12
12
  import { type CommandLineOf, isWatcherRunning, WATCHER_FILES, watcherDirectoryOf } from './watcher-lock'
13
13
 
14
14
  const readState = (file: string): WatchState | null => {
15
+ const text = readPrivateText(file)
16
+ if (text === null) return null
15
17
  try {
16
- return parseWatchState(readFileSync(file, 'utf8'))
18
+ return parseWatchState(text)
17
19
  } catch {
18
20
  return null
19
21
  }
@@ -22,19 +24,23 @@ const readState = (file: string): WatchState | null => {
22
24
  /**
23
25
  * What the watcher of the repository at `root` gives the herd, read from its state file without a GitHub call:
24
26
  * its badges, the tickets of `ticketSources`, and its health, so the view can say why there are none. A missing or
25
- * damaged file shows no badges; the view goes on without them.
27
+ * damaged file shows no badges; the view goes on without them. Nothing is read unless both levels of the watcher
28
+ * directory are private (`isPrivateLevels`), and only regular files, so a directory, a file or a FIFO someone else
29
+ * planted neither feeds the view nor blocks it.
26
30
  */
27
31
  export const readWatchReading = (
28
32
  root: string,
29
33
  now: number,
30
34
  ticketSources: readonly TicketSource[],
31
35
  lookUp?: CommandLineOf,
36
+ system: LocationSystem = systemLocation(),
32
37
  ): WatchReading => {
33
- const directory = watcherDirectoryOf(root)
34
- const state = readState(join(directory, WATCHER_FILES.state))
38
+ const directory = watcherDirectoryOf(root, system)
39
+ const isPrivate = isPrivateLevels(dirname(directory), directory, system)
40
+ const state = isPrivate ? readState(join(directory, WATCHER_FILES.state)) : null
35
41
  const health = watchHealthOf(
36
42
  state?.savedAt ?? null,
37
- isWatcherRunning(join(directory, WATCHER_FILES.lock), lookUp),
43
+ isPrivate && isWatcherRunning(join(directory, WATCHER_FILES.lock), lookUp),
38
44
  now,
39
45
  )
40
46
  if (!state || health.kind !== WATCH_HEALTH.live) return { badges: {}, tickets: [], health }
@@ -4,6 +4,7 @@ import {
4
4
  checksOf,
5
5
  type FailedCheck,
6
6
  failedChecksOf,
7
+ filePathsOf,
7
8
  type GhPullRequest,
8
9
  isGhPullRequest,
9
10
  } from '../plans/pull-requests'
@@ -25,6 +26,8 @@ export type WatchedPullRequest = {
25
26
  failedRuns: FailedCheck[]
26
27
  headCommit: string
27
28
  mergeCommit: string | null
29
+ /** The paths it changes (at most 100), from the open list only; a plan among them makes its head a plan head. */
30
+ files?: string[]
28
31
  }
29
32
 
30
33
  /** The rollup is optional: the light list has none. */
@@ -41,8 +44,8 @@ type GhWatchedPullRequest = Omit<GhPullRequest, 'statusCheckRollup'> & {
41
44
  */
42
45
  export const WATCHED_PULL_REQUEST_FIELDS = 'number,title,url,headRefName,isDraft,state,headRefOid,mergeCommit'
43
46
 
44
- /** The fields of the list of open pull requests, which is the only one that asks for their checks. */
45
- export const OPEN_PULL_REQUEST_FIELDS = `${WATCHED_PULL_REQUEST_FIELDS},statusCheckRollup`
47
+ /** The fields of the list of open pull requests, which is the only one that asks for their checks and their files. */
48
+ export const OPEN_PULL_REQUEST_FIELDS = `${WATCHED_PULL_REQUEST_FIELDS},statusCheckRollup,files`
46
49
 
47
50
  const GH_PR_LIST = 'gh pr list'
48
51
 
@@ -79,6 +82,7 @@ const watchedPullRequestsOf = (
79
82
  failedRuns: failedChecksOf(pullRequest.statusCheckRollup ?? [], pullRequest.url),
80
83
  headCommit: pullRequest.headRefOid,
81
84
  mergeCommit: pullRequest.mergeCommit?.oid ?? null,
85
+ ...filePathsOf(pullRequest),
82
86
  }))
83
87
 
84
88
  const asIs = (entry: unknown): unknown => entry
@@ -0,0 +1,87 @@
1
+ import { join } from 'node:path'
2
+ import { claudePath, SKILLS_DIRECTORY } from '../claude/paths'
3
+ import { CONTEXT_LEVEL } from '../context'
4
+ import { APPOINTMENT_EVENT } from './appointments'
5
+ import { UPDATE_AVAILABLE_EVENT, WATCH_EVENT } from './watch-events'
6
+
7
+ /** The skill the watcher points the coordinator to, by its name without a prefix. */
8
+ export const ORCHESTRATE_SKILL_NAME = 'orchestrate'
9
+
10
+ /** The reference files of the orchestrate skill, relative to its directory. */
11
+ export const REFERENCE = {
12
+ events: 'references/events.md',
13
+ laneSession: 'references/lane-session.md',
14
+ coordinatorHandover: 'references/coordinator-handover.md',
15
+ closeLane: 'references/close-lane.md',
16
+ laneHandover: 'references/lane-handover.md',
17
+ } as const
18
+
19
+ export type Reference = (typeof REFERENCE)[keyof typeof REFERENCE]
20
+
21
+ /** A copy of the orchestrate skill: the directory as the session reads it, and where that is on disk. */
22
+ export type SkillCopy = { shown: string; path: string }
23
+
24
+ const SKILL_ENTRY = 'SKILL.md'
25
+
26
+ /**
27
+ * The rare events and the reference that holds their handling. The frequent ones (`blocked`, `done`/`idle`, `merged`,
28
+ * the checks, `heartbeat`, `approval`, the lanes' fill, the watcher's end, `kit updated`) stay in the skill and have
29
+ * no path, so their lines do not grow.
30
+ */
31
+ const EVENT_REFERENCES: Readonly<Record<string, Reference>> = {
32
+ [WATCH_EVENT.feedback]: REFERENCE.events,
33
+ [WATCH_EVENT.preview]: REFERENCE.events,
34
+ [WATCH_EVENT.source]: REFERENCE.events,
35
+ [APPOINTMENT_EVENT.unreadable]: REFERENCE.events,
36
+ [WATCH_EVENT.laneParallel]: REFERENCE.events,
37
+ [WATCH_EVENT.taskParallel]: REFERENCE.events,
38
+ [WATCH_EVENT.noTaskSignal]: REFERENCE.events,
39
+ [UPDATE_AVAILABLE_EVENT]: REFERENCE.events,
40
+ [WATCH_EVENT.laneModel]: REFERENCE.laneSession,
41
+ }
42
+
43
+ const isCoordinatorHandoverLevel = (level: unknown): boolean =>
44
+ level === CONTEXT_LEVEL.warn || level === CONTEXT_LEVEL.alert
45
+
46
+ /** The reference to read for `event`; null for a frequent event, and for a coordinator still at `ok`. */
47
+ export const eventReference = (event: { kind: string; level?: unknown }): Reference | null => {
48
+ if (event.kind === WATCH_EVENT.coordinator)
49
+ return isCoordinatorHandoverLevel(event.level) ? REFERENCE.coordinatorHandover : null
50
+ return EVENT_REFERENCES[event.kind] ?? null
51
+ }
52
+
53
+ /**
54
+ * The places the orchestrate skill is installed, the project's first: `<root>/.claude/skills/<prefix>orchestrate`, and
55
+ * the global copy `~/.claude/skills/<globalPrefix>orchestrate` that `jh sync --global` writes (`toGlobalKit`). The
56
+ * project shows its path relative to the root the session runs in, the global one with `~`.
57
+ */
58
+ export const skillCopiesFor = (
59
+ root: string,
60
+ home: string,
61
+ projectPrefix: string,
62
+ globalPrefix: string,
63
+ ): SkillCopy[] => {
64
+ const project = claudePath(SKILLS_DIRECTORY, `${projectPrefix}${ORCHESTRATE_SKILL_NAME}`)
65
+ const global = claudePath(SKILLS_DIRECTORY, `${globalPrefix}${ORCHESTRATE_SKILL_NAME}`)
66
+ return [
67
+ { shown: project, path: join(root, project) },
68
+ { shown: `~/${global}`, path: join(home, global) },
69
+ ]
70
+ }
71
+
72
+ /**
73
+ * The directory of the first copy that holds the skill: the project's installation, else the global one. `exists`
74
+ * answers for a path on disk, so the choice stays a pure function.
75
+ */
76
+ export const skillDirectoryFor = (copies: readonly SkillCopy[], exists: (path: string) => boolean): string | null =>
77
+ copies.find(({ path }) => exists(join(path, SKILL_ENTRY)))?.shown ?? null
78
+
79
+ /** ` · read <skill directory>/<reference>`, naming the skill when no copy of it was found. */
80
+ export const referenceHint = (reference: Reference, skillDirectory: string | null): string =>
81
+ skillDirectory === null
82
+ ? ` · read ${ORCHESTRATE_SKILL_NAME} skill ${reference}`
83
+ : ` · read ${skillDirectory}/${reference}`
84
+
85
+ /** `lines` with `hint` on the first one, the header of a multi-line event. */
86
+ export const annotateFirstLine = (lines: readonly string[], hint: string): string[] =>
87
+ lines.map((line, index) => (index === 0 ? `${line}${hint}` : line))
@@ -0,0 +1,43 @@
1
+ import type { RunningSubagent } from '../plans/subagents'
2
+ import { laneDirectoryOf } from '../plans/unlisted-lanes'
3
+ import { type LaneNotes, type PhaseCount, progressNotes } from './heartbeat-progress'
4
+
5
+ /** The running subagents of each worktree below `worktreeDirectory`, by lane name; one that works elsewhere is left out. */
6
+ export const runningSubagentCountsOf = (
7
+ running: readonly Pick<RunningSubagent, 'cwd'>[],
8
+ worktreeDirectory: string,
9
+ ): Record<string, number> => {
10
+ const counts: Record<string, number> = {}
11
+ for (const { cwd } of running) {
12
+ const lane = cwd === null ? null : laneDirectoryOf(cwd, worktreeDirectory)
13
+ if (lane !== null) counts[lane] = (counts[lane] ?? 0) + 1
14
+ }
15
+ return counts
16
+ }
17
+
18
+ /** What a heartbeat says about running subagents: `3 subagents`; null for none. */
19
+ export const subagentsNote = (count: number): string | null =>
20
+ count <= 0 ? null : `${count} ${count === 1 ? 'subagent' : 'subagents'}`
21
+
22
+ /**
23
+ * The heartbeat notes with the running subagents of each working lane appended (`P12 7/12, 3 subagents`); a lane that
24
+ * has no count note yet gets the subagents alone. Resting lanes and lanes without running subagents keep their note.
25
+ */
26
+ export const withSubagentNotes = (
27
+ notes: LaneNotes,
28
+ working: readonly string[],
29
+ counts: Readonly<Record<string, number>>,
30
+ ): LaneNotes => {
31
+ const extra = working.flatMap((lane) => {
32
+ const note = subagentsNote(counts[lane] ?? 0)
33
+ return note === null ? [] : [[lane, [notes[lane], note].filter(Boolean).join(', ')]]
34
+ })
35
+ return { ...notes, ...Object.fromEntries(extra) }
36
+ }
37
+
38
+ /** The notes of a heartbeat: each working lane's phase count and its running subagents; resting lanes have neither. */
39
+ export const heartbeatNotesOf = (
40
+ working: readonly string[],
41
+ phaseCounts: Readonly<Record<string, PhaseCount>>,
42
+ subagentCounts: Readonly<Record<string, number>>,
43
+ ): LaneNotes => withSubagentNotes(progressNotes(working, phaseCounts), working, subagentCounts)