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
@@ -0,0 +1,95 @@
1
+ import { isPhaseOpen, type PlanPhase } from '../plan-format'
2
+ import { PHASE_ID_PREFIX, spannedPhaseIds } from '../plan-format/lanes'
3
+ import { isNamedBy } from './phase-ids'
4
+
5
+ /** The agent types that review a phase; they sit on its review gate. */
6
+ export const REVIEW_TYPE = {
7
+ uncleBob: 'uncle-bob',
8
+ specReview: 'spec-review',
9
+ securityAudit: 'security-audit',
10
+ review: 'review',
11
+ ive: 'ive',
12
+ jobs: 'jobs',
13
+ } as const
14
+
15
+ export type ReviewType = (typeof REVIEW_TYPE)[keyof typeof REVIEW_TYPE]
16
+
17
+ /**
18
+ * The phases a running subagent names in its description, its review type (null for no review), and when its log was
19
+ * last written. `ids` is empty only for a review that names no phase: the lane places it (`PhaselessReview`).
20
+ */
21
+ export type SubagentPhaseRun = { ids: string[]; review: ReviewType | null; at: number }
22
+
23
+ /** A running review whose description names no phase: it sits on the lane's signalled phase, else shows as its description. */
24
+ export type PhaselessReview = { review: ReviewType; at: number; description: string | null }
25
+
26
+ /** What the subagents say about one phase of the lane: the types of the reviews among them, and the newest log write. */
27
+ export type SubagentPhase = { reviews: ReviewType[]; at: number }
28
+
29
+ const isReviewTypeName = (type: string): type is ReviewType => (Object.values(REVIEW_TYPE) as string[]).includes(type)
30
+
31
+ /** `P21`, `P17-P20`, `P17 – 19`, `Phase 21`, `Phase 10c`: the first number, an optional sub-phase letter, an optional end. */
32
+ const PHASE_MENTION = new RegExp(
33
+ `(?:\\b${PHASE_ID_PREFIX}|\\bPhase\\s+)(\\d+)([a-z]?)(?:\\s*[-–—]\\s*(?:${PHASE_ID_PREFIX})?(\\d+)\\b)?`,
34
+ 'gi',
35
+ )
36
+
37
+ /** The ids of a range `first`-`last`; a range that runs backwards or is absurdly wide names only its first end. */
38
+ const rangeIds = (first: number, last: number): string[] => {
39
+ const spanned = spannedPhaseIds(first, last)
40
+ return spanned.length > 0 ? spanned : [String(first)]
41
+ }
42
+
43
+ /** The ids of the phases a description names, in the order named; a range names every phase between its ends. */
44
+ export const phaseIdsOfDescription = (description: string): string[] =>
45
+ [...description.matchAll(PHASE_MENTION)].flatMap(([, first = '', letter = '', last]) =>
46
+ last === undefined ? [`${Number(first)}${letter.toLowerCase()}`] : rangeIds(Number(first), Number(last)),
47
+ )
48
+
49
+ const withoutPrefix = (type: string, prefixes: readonly string[]): string => {
50
+ const prefix = prefixes.find((each) => each !== '' && type.startsWith(each))
51
+ return prefix === undefined ? type : type.slice(prefix.length)
52
+ }
53
+
54
+ /** The `REVIEW_TYPE` an agent type is, plain or with one of the skill `prefixes` of a copy; null for any other type. */
55
+ export const reviewTypeOf = (type: string | null, prefixes: readonly string[]): ReviewType | null => {
56
+ const plain = type === null ? null : withoutPrefix(type, prefixes)
57
+ return plain !== null && isReviewTypeName(plain) ? plain : null
58
+ }
59
+
60
+ /**
61
+ * The run of a subagent from its `description` and `agentType`: null when the description names no phase and the
62
+ * subagent is no review. The description is read whole, not cut for the display.
63
+ */
64
+ export const subagentPhaseRunOf = (
65
+ description: string | null,
66
+ agentType: string | null,
67
+ at: number,
68
+ reviewPrefixes: readonly string[],
69
+ ): SubagentPhaseRun | null => {
70
+ const ids = description === null ? [] : phaseIdsOfDescription(description)
71
+ const review = reviewTypeOf(agentType, reviewPrefixes)
72
+ return ids.length === 0 && review === null ? null : { ids, review, at }
73
+ }
74
+
75
+ const names = (run: SubagentPhaseRun, id: string): boolean => isNamedBy(id, new Set(run.ids))
76
+
77
+ const reviewsOf = (runs: readonly SubagentPhaseRun[]): ReviewType[] => [
78
+ ...new Set(runs.flatMap(({ review }) => (review === null ? [] : [review]))),
79
+ ]
80
+
81
+ /**
82
+ * The open phases of the lane that running subagents name, by phase id: a sub-phase (`21b`) is named by its main phase.
83
+ * Phases the lane does not hold are left out, since another lane's phases are not its own.
84
+ */
85
+ export const subagentPhases = (
86
+ phases: readonly Pick<PlanPhase, 'id' | 'items'>[],
87
+ runs: readonly SubagentPhaseRun[],
88
+ ): Map<string, SubagentPhase> =>
89
+ new Map(
90
+ phases.filter(isPhaseOpen).flatMap(({ id }) => {
91
+ const naming = runs.filter((run) => names(run, id))
92
+ if (naming.length === 0) return []
93
+ return [[id, { reviews: reviewsOf(naming), at: Math.max(...naming.map(({ at }) => at)) }]]
94
+ }),
95
+ )
@@ -0,0 +1,51 @@
1
+ import type { ActiveTask } from './active-tasks'
2
+ import type { TaskPhase } from './current-task'
3
+ import { taskNameOf } from './task-name'
4
+
5
+ /** A running subagent that is no review: its description as the herd shows it, and when its log was last written. */
6
+ export type DescribedSubagent = { description: string; at: number }
7
+
8
+ /** Where the subagents sit: their tasks by phase id, and the descriptions placed, which show no line of their own. */
9
+ export type SubagentTaskPlacements = { byPhase: Map<string, ActiveTask[]>; placed: string[] }
10
+
11
+ type NamedTask = { phaseId: string; index: number; name: string }
12
+
13
+ /** A letter or digit right after the name continues a word, so `Probe` does not name `Proben lesen`. */
14
+ const WORD_GOES_ON = /^[\p{L}\p{N}]/u
15
+
16
+ /** The open tasks that carry a short name, the longest names first, so a name that starts another loses to it. */
17
+ const namedOpenTasks = (phases: readonly TaskPhase[]): NamedTask[] =>
18
+ phases
19
+ .flatMap(({ id, items }) =>
20
+ items.flatMap((item, index) => {
21
+ const name = item.done || item.gate ? null : taskNameOf(item.text)
22
+ return name === null ? [] : [{ phaseId: id, index, name }]
23
+ }),
24
+ )
25
+ .sort((first, second) => second.name.length - first.name.length)
26
+
27
+ const startsWithName = (description: string, name: string): boolean =>
28
+ description.toLowerCase().startsWith(name.toLowerCase()) && !WORD_GOES_ON.test(description.slice(name.length))
29
+
30
+ /**
31
+ * The open task each subagent works on, from the short name its description starts with (`/implement` Step 0b, case
32
+ * ignored): it gets its `▶` like a reported task, one per task however many subagents share it. A ticked task and a
33
+ * review gate item take none; a subagent whose description starts with no name stays where it was.
34
+ */
35
+ export const subagentTaskPlacements = (
36
+ phases: readonly TaskPhase[],
37
+ subagents: readonly DescribedSubagent[],
38
+ ): SubagentTaskPlacements => {
39
+ const tasks = namedOpenTasks(phases)
40
+ const byPhase = new Map<string, ActiveTask[]>()
41
+ const placed: string[] = []
42
+ for (const { description, at } of subagents) {
43
+ const task = tasks.find(({ name }) => startsWithName(description, name))
44
+ if (task === undefined) continue
45
+ placed.push(description)
46
+ const known = byPhase.get(task.phaseId) ?? []
47
+ if (known.some(({ index }) => index === task.index)) continue
48
+ byPhase.set(task.phaseId, [...known, { index: task.index, agent: null, guessed: false, startedAt: at }])
49
+ }
50
+ return { byPhase, placed }
51
+ }
@@ -0,0 +1,131 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { defaultProjectsDirectory, projectDirectoryOf, readTailLines } from '../context/session-log'
4
+ import { MS_PER_MINUTE } from '../plan-format/clock'
5
+ import { DEFAULT_SKILL_PREFIX } from '../sync/init'
6
+ import { readApprovalRequests } from '../watch/approvals'
7
+ import {
8
+ type RunningSubagent,
9
+ runningSubagents,
10
+ SUBAGENT_STALE_MINUTES,
11
+ type SubagentActivity,
12
+ type SubagentLog,
13
+ subagentsByLane,
14
+ } from './subagents'
15
+
16
+ export type SubagentSource = {
17
+ projects: string
18
+ /** The main checkout whose sessions are read. */
19
+ root: string
20
+ /** The main checkout whose `.jackherd/approvals/` holds the gate's requests. */
21
+ approvalsRoot: string
22
+ /** The absolute worktree directory. */
23
+ worktreeDirectory: string
24
+ /** The project's `skillPrefix`, which its copies of the review agents carry; without it only the default one counts. */
25
+ skillPrefix?: string
26
+ }
27
+
28
+ /** The source of the main checkout `root`: Claude Code's own session logs, the gate's requests in the checkout itself. */
29
+ export const subagentSourceOf = (root: string, worktreeDirectory: string, skillPrefix?: string): SubagentSource => ({
30
+ projects: defaultProjectsDirectory(),
31
+ root,
32
+ approvalsRoot: root,
33
+ worktreeDirectory,
34
+ skillPrefix,
35
+ })
36
+
37
+ /** The end of a log that holds the directory of the last entries. */
38
+ const TAIL_BYTES = 64 * 1024
39
+ const META_FILE = /^agent-([0-9a-f]+)\.meta\.json$/
40
+ /** A request the gate holds is reported for this long, as by the watcher. */
41
+ const OPEN_REQUEST_MINUTES = 30
42
+
43
+ const directoriesOf = (path: string): string[] => {
44
+ try {
45
+ return readdirSync(path, { withFileTypes: true }).flatMap((entry) => (entry.isDirectory() ? [entry.name] : []))
46
+ } catch {
47
+ return []
48
+ }
49
+ }
50
+
51
+ /** The entry names of `path`; none when it is missing or cannot be read. */
52
+ const namesIn = (path: string): string[] => {
53
+ try {
54
+ return readdirSync(path)
55
+ } catch {
56
+ return []
57
+ }
58
+ }
59
+
60
+ const logOf = (directory: string, name: string, id: string, now: number): SubagentLog[] => {
61
+ try {
62
+ const logPath = join(directory, `agent-${id}.jsonl`)
63
+ const modifiedAt = statSync(logPath).mtimeMs
64
+ if (now - modifiedAt > SUBAGENT_STALE_MINUTES * MS_PER_MINUTE) return []
65
+ const meta: unknown = JSON.parse(readFileSync(join(directory, name), 'utf8'))
66
+ return [{ id, meta, modifiedAt, tail: readTailLines(logPath, TAIL_BYTES) }]
67
+ } catch {
68
+ return []
69
+ }
70
+ }
71
+
72
+ /** The subagent logs of every session in one project directory. */
73
+ const sessionLogsOf = (project: string, now: number): SubagentLog[] =>
74
+ directoriesOf(project).flatMap((session) => {
75
+ const directory = join(project, session, 'subagents')
76
+ return namesIn(directory).flatMap((name) => {
77
+ const id = META_FILE.exec(name)?.[1]
78
+ return id ? logOf(directory, name, id, now) : []
79
+ })
80
+ })
81
+
82
+ /**
83
+ * The project directories Claude Code keeps for sessions started in the main checkout `root` and, when
84
+ * `worktreeDirectory` is given, in a worktree below it: a lane's own subagents, its reviews among them, live there.
85
+ */
86
+ const projectDirectoriesOf = (projects: string, root: string, worktreeDirectory?: string): string[] => {
87
+ const main = projectDirectoryOf(root)
88
+ const lanes = worktreeDirectory === undefined ? null : `${projectDirectoryOf(worktreeDirectory)}-`
89
+ return directoriesOf(projects).filter((name) => name === main || (lanes !== null && name.startsWith(lanes)))
90
+ }
91
+
92
+ /**
93
+ * The subagents with a recently written log in the sessions started in the main checkout `root` and its worktrees,
94
+ * from Claude Code's `<projects>/<project>/<session>/subagents/agent-<id>.{meta.json,jsonl}`; whatever cannot be read
95
+ * is left out.
96
+ */
97
+ export const readSubagentLogs = (
98
+ projects: string,
99
+ root: string,
100
+ now: number,
101
+ worktreeDirectory?: string,
102
+ ): SubagentLog[] =>
103
+ projectDirectoriesOf(projects, root, worktreeDirectory).flatMap((name) => sessionLogsOf(join(projects, name), now))
104
+
105
+ /** The names the gate holds an open request under, as the watcher reads them. */
106
+ const heldNames = (root: string, now: number): Set<string> =>
107
+ new Set(
108
+ readApprovalRequests(root)
109
+ .filter((request) => request.usedAt === null && now - request.createdAt <= OPEN_REQUEST_MINUTES * MS_PER_MINUTE)
110
+ .map((request) => request.lane),
111
+ )
112
+
113
+ /**
114
+ * The subagents at work in the sessions of the main checkout and its worktrees: their logs read, the finished and the
115
+ * stale left out. The one chain the herd (`readSubagentActivity`) and the watcher's heartbeat share.
116
+ */
117
+ export const readRunningSubagents = (
118
+ { projects, root, approvalsRoot, worktreeDirectory, skillPrefix }: SubagentSource,
119
+ now: number = Date.now(),
120
+ ): RunningSubagent[] =>
121
+ runningSubagents(
122
+ readSubagentLogs(projects, root, now, worktreeDirectory),
123
+ heldNames(approvalsRoot, now),
124
+ now,
125
+ worktreeDirectory,
126
+ skillPrefix === undefined ? [DEFAULT_SKILL_PREFIX] : [DEFAULT_SKILL_PREFIX, skillPrefix],
127
+ )
128
+
129
+ /** The state of every worktree a subagent of the main checkout's sessions works in. */
130
+ export const readSubagentActivity = (source: SubagentSource, now: number = Date.now()): Map<string, SubagentActivity> =>
131
+ subagentsByLane(readRunningSubagents(source, now), source.worktreeDirectory)
@@ -0,0 +1,190 @@
1
+ import { parseLogEntry } from '../context/session-log'
2
+ import { MS_PER_MINUTE } from '../plan-format/clock'
3
+ import { terminalSafe } from '../plan-format/terminal-text'
4
+ import { isRecord } from '../shared/is-record'
5
+ import { AGENT_STATUS, type AgentStatus } from '../terminal/types'
6
+ import { activityTextOf } from './current-task'
7
+ import { type PhaselessReview, type SubagentPhaseRun, subagentPhaseRunOf } from './subagent-phases'
8
+ import type { DescribedSubagent } from './subagent-tasks'
9
+ import { laneDirectoryOf } from './unlisted-lanes'
10
+
11
+ /** What the log files of one subagent say: its id, its `meta.json` as parsed, when the log was written, its last lines. */
12
+ export type SubagentLog = { id: string; meta: unknown; modifiedAt: number; tail: readonly string[] }
13
+
14
+ /** A subagent of the coordinator that is at work now. */
15
+ export type RunningSubagent = {
16
+ label: string
17
+ cwd: string | null
18
+ description: string | null
19
+ status: Extract<AgentStatus, 'working' | 'blocked'>
20
+ /** The text of its last log entries, from which the herd tells the task it works on (`taskSignalsOf`). */
21
+ activity: string
22
+ /** The phases its full description names (`P21`, `P17-P20`), none for a review that names none; null for any other. */
23
+ phaseRun: SubagentPhaseRun | null
24
+ /** When its log was last written. */
25
+ writtenAt: number
26
+ }
27
+
28
+ /** What a worktree shows of the subagents working in it; `phaseRuns` are those of all its subagents that name a phase. */
29
+ export type SubagentActivity = Pick<RunningSubagent, 'status' | 'description' | 'activity'> & {
30
+ phaseRuns: SubagentPhaseRun[]
31
+ /** Its reviews that name no phase; the lane places them on its signalled phase (`laneSummary`). */
32
+ phaselessReviews: PhaselessReview[]
33
+ /**
34
+ * The descriptions of its other subagents that name no phase: what they work on, a signal of the lane like a
35
+ * reported task.
36
+ */
37
+ taskDescriptions: string[]
38
+ /** Its subagents that are no review, phase or not; one whose description starts with a task's short name sits on it. */
39
+ describedSubagents: DescribedSubagent[]
40
+ }
41
+
42
+ /**
43
+ * A log untouched this long is not at work: Claude Code marks a finished subagent with `finished: true` in its
44
+ * `meta.json` only in its newer versions, so a subagent that was killed or ended before that has no mark.
45
+ */
46
+ export const SUBAGENT_STALE_MINUTES = 15
47
+ const AGENT_ID = /^[0-9a-f]{8,64}$/
48
+ const AGENT_TYPE = /^[a-z0-9][a-z0-9-]{0,31}$/
49
+ const ID_LENGTH = 8
50
+ const ACTIVITY_ENTRIES = 40
51
+ const DESCRIPTION_LENGTH = 80
52
+
53
+ /** The name the approval gate holds a subagent under (`kit/hooks/hooks/approval-gate/subagent.ts`, contract test). */
54
+ export const subagentLabelOf = (id: string, type: string | null): string | null =>
55
+ AGENT_ID.test(id)
56
+ ? `subagent-${type !== null && AGENT_TYPE.test(type) ? type : 'agent'}-${id.slice(0, ID_LENGTH)}`
57
+ : null
58
+
59
+ const textOrNull = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null)
60
+
61
+ const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
62
+
63
+ /** The strings a tool call of a log entry carries as paths: `file_path`, `path` and the `command` of a shell call. */
64
+ const toolCallTexts = (entry: Record<string, unknown>): string[] => {
65
+ const content = isRecord(entry.message) && Array.isArray(entry.message.content) ? entry.message.content : []
66
+ return content.flatMap((block) => {
67
+ if (!isRecord(block) || block.type !== 'tool_use' || !isRecord(block.input)) return []
68
+ return [block.input.file_path, block.input.path, block.input.command].filter(
69
+ (value): value is string => typeof value === 'string',
70
+ )
71
+ })
72
+ }
73
+
74
+ /** The last worktree below `worktreeDirectory` that a text names, as its directory; null when it names none. */
75
+ const lastWorktreeIn = (text: string, worktreeDirectory: string): string | null => {
76
+ const pattern = new RegExp(`${escapeRegExp(worktreeDirectory)}/([A-Za-z0-9._-]+)`, 'g')
77
+ const name = [...text.matchAll(pattern)].at(-1)?.[1]
78
+ return name === undefined ? null : `${worktreeDirectory}/${name}`
79
+ }
80
+
81
+ /**
82
+ * Where the subagent works: the worktree its most recent tool call names (a path, or a path in a shell command), since
83
+ * a coordinator's subagent starts in the main directory and changes directory per command, so its entries carry that
84
+ * directory; else the directory of the last entry that names one. Lines that are no JSON (the cut-off first one) are
85
+ * skipped.
86
+ */
87
+ const workingDirectory = (tail: readonly string[], worktreeDirectory: string): string | null => {
88
+ const entries = [...tail].reverse().flatMap((line): Record<string, unknown>[] => {
89
+ const entry = parseLogEntry(line)
90
+ return entry === null ? [] : [entry]
91
+ })
92
+ for (const entry of entries) {
93
+ for (const text of toolCallTexts(entry).reverse()) {
94
+ const named = lastWorktreeIn(text, worktreeDirectory)
95
+ if (named !== null) return named
96
+ }
97
+ }
98
+ const directory = entries.find((entry) => typeof entry.cwd === 'string')?.cwd
99
+ return typeof directory === 'string' ? directory : null
100
+ }
101
+
102
+ /**
103
+ * The subagents at work: no `finished` mark in the meta, and the log written within `SUBAGENT_STALE_MINUTES`. One
104
+ * waits for an answer while the approval gate holds a request under its name (`openRequests` are the names of the open
105
+ * requests); the transcript alone cannot tell a permission dialog from a long tool call. `reviewPrefixes` are the skill
106
+ * prefixes a copy of the review agents may carry.
107
+ */
108
+ export const runningSubagents = (
109
+ logs: readonly SubagentLog[],
110
+ openRequests: ReadonlySet<string>,
111
+ now: number,
112
+ worktreeDirectory: string,
113
+ reviewPrefixes: readonly string[],
114
+ ): RunningSubagent[] =>
115
+ logs.flatMap(({ id, meta, modifiedAt, tail }) => {
116
+ if (!isRecord(meta) || meta.finished === true || now - modifiedAt > SUBAGENT_STALE_MINUTES * MS_PER_MINUTE)
117
+ return []
118
+ const agentType = textOrNull(meta.agentType)
119
+ const label = subagentLabelOf(id, agentType)
120
+ if (label === null) return []
121
+ const description = textOrNull(typeof meta.description === 'string' ? terminalSafe(meta.description) : null)
122
+ const shortDescription = description?.slice(0, DESCRIPTION_LENGTH) ?? null
123
+ return [
124
+ {
125
+ label,
126
+ cwd: workingDirectory(tail, worktreeDirectory),
127
+ description: shortDescription,
128
+ status: openRequests.has(label) ? AGENT_STATUS.blocked : AGENT_STATUS.working,
129
+ activity: activityTextOf(tail, ACTIVITY_ENTRIES),
130
+ phaseRun: subagentPhaseRunOf(description, agentType, modifiedAt, reviewPrefixes),
131
+ writtenAt: modifiedAt,
132
+ },
133
+ ]
134
+ })
135
+
136
+ /** The description of a subagent that names no phase, as a list of at most one; empty for a review or one that names a phase. */
137
+ const taskDescriptionOf = ({ phaseRun, description }: RunningSubagent): string[] =>
138
+ phaseRun === null && description !== null ? [description] : []
139
+
140
+ /** A subagent that is no review, with its description, as a list of at most one. */
141
+ const describedSubagentOf = ({ phaseRun, description, writtenAt }: RunningSubagent): DescribedSubagent[] =>
142
+ description !== null && (phaseRun === null || phaseRun.review === null) ? [{ description, at: writtenAt }] : []
143
+
144
+ /** The run of a subagent that names a phase, as a list of at most one. */
145
+ const namedRunOf = ({ phaseRun }: RunningSubagent): SubagentPhaseRun[] =>
146
+ phaseRun !== null && phaseRun.ids.length > 0 ? [phaseRun] : []
147
+
148
+ /** A review that names no phase, as a list of at most one. */
149
+ const phaselessReviewOf = ({ phaseRun, description }: RunningSubagent): PhaselessReview[] => {
150
+ if (phaseRun === null || phaseRun.review === null || phaseRun.ids.length > 0) return []
151
+ return [{ review: phaseRun.review, at: phaseRun.at, description }]
152
+ }
153
+
154
+ /** The worktrees below `worktreeDirectory` the subagents work in; one that waits for an answer decides the state. */
155
+ export const subagentsByLane = (
156
+ running: readonly RunningSubagent[],
157
+ worktreeDirectory: string,
158
+ ): Map<string, SubagentActivity> => {
159
+ const lanes = new Map<string, SubagentActivity>()
160
+ for (const subagent of running) {
161
+ const { cwd, status, description, activity } = subagent
162
+ const lane = cwd === null ? null : laneDirectoryOf(cwd, worktreeDirectory)
163
+ if (lane === null) continue
164
+ const known = lanes.get(lane)
165
+ const runs = namedRunOf(subagent)
166
+ const reviews = phaselessReviewOf(subagent)
167
+ const tasks = taskDescriptionOf(subagent)
168
+ const described = describedSubagentOf(subagent)
169
+ if (!known)
170
+ lanes.set(lane, {
171
+ status,
172
+ description,
173
+ activity,
174
+ phaseRuns: runs,
175
+ phaselessReviews: reviews,
176
+ taskDescriptions: tasks,
177
+ describedSubagents: described,
178
+ })
179
+ else
180
+ lanes.set(lane, {
181
+ ...(status === AGENT_STATUS.blocked && known.status !== AGENT_STATUS.blocked ? { status, description } : known),
182
+ activity: `${known.activity} ${activity}`,
183
+ phaseRuns: [...known.phaseRuns, ...runs],
184
+ phaselessReviews: [...known.phaselessReviews, ...reviews],
185
+ taskDescriptions: [...known.taskDescriptions, ...tasks],
186
+ describedSubagents: [...known.describedSubagents, ...described],
187
+ })
188
+ }
189
+ return lanes
190
+ }
@@ -4,6 +4,7 @@ import { dirname } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { pendingKitUpdate, VERSION_POLL_MS } from '../version/installed-version'
6
6
  import type { StartedKit } from '../version/started-kit'
7
+ import { STOP_REQUEST, type Stopper, stopperOf } from './child-stop'
7
8
  import { HERD_COMMAND } from './herd-command'
8
9
  import { CHILD_MARKER, exitCodeOf, isRestartTrigger, nextStep, RESTART_DEBOUNCE_MS } from './restart-decision'
9
10
  import { TERMINAL_OFF } from './view'
@@ -30,7 +31,8 @@ const spawnView = (scriptPath: string, args: string[]): ChildProcess =>
30
31
  /**
31
32
  * Runs the live view as a child process and restarts it when the package's own source changes,
32
33
  * so a long-lived `plans` never shows the behaviour of code that has since been edited. The child
33
- * stops on SIGTERM through the handler that restores the terminal. Resolves with the exit code
34
+ * stops on SIGTERM through the handler that restores the terminal, and is killed when it does not end within
35
+ * `GRACE_MS` (a restart included); a second signal while stopping kills it at once. Resolves with the exit code
34
36
  * of a wanted end only: the child's clean exit (`q`) or a signal forwarded to it. A child that
35
37
  * dies otherwise leaves the supervisor waiting, with the terminal restored, for the next change
36
38
  * to `src/`, which spawns a fresh child. `kit` is the package this process loaded (null when it cannot be
@@ -45,6 +47,7 @@ export const superviseView = (
45
47
  ): Promise<number> =>
46
48
  new Promise((resolveExit) => {
47
49
  let child: ChildProcess | undefined
50
+ let stopper: Stopper | undefined
48
51
  let restarting = false
49
52
  let stopping = false
50
53
  let timer: ReturnType<typeof setTimeout> | undefined
@@ -66,6 +69,12 @@ export const superviseView = (
66
69
  const start = (isFirst: boolean): void => {
67
70
  const current = spawnView(scriptPath, args)
68
71
  child = current
72
+ const currentStopper = stopperOf({
73
+ kill: (signal) => current.kill(signal),
74
+ setTimer: (run, ms) => setTimeout(run, ms),
75
+ clearTimer: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
76
+ })
77
+ stopper = currentStopper
69
78
 
70
79
  current.on('error', (error) => {
71
80
  if (child !== current) return
@@ -79,6 +88,7 @@ export const superviseView = (
79
88
  })
80
89
 
81
90
  current.on('exit', (code, signal) => {
91
+ currentStopper.ended()
82
92
  if (child !== current) return
83
93
  const context = { restarting, stopping, code, signal }
84
94
  restarting = false
@@ -94,7 +104,7 @@ export const superviseView = (
94
104
  const restartView = (): void => {
95
105
  if (child) {
96
106
  restarting = true
97
- child.kill('SIGTERM')
107
+ stopper?.request('SIGTERM')
98
108
  } else start(false)
99
109
  }
100
110
 
@@ -118,8 +128,8 @@ export const superviseView = (
118
128
  stopping = true
119
129
  clearTimeout(timer)
120
130
  clearInterval(versionTimer)
121
- if (child) child.kill(signal)
122
- else finish(exitCodeOf(null, signal))
131
+ if (!child || !stopper) finish(exitCodeOf(null, signal))
132
+ else if (stopper.request(signal) === STOP_REQUEST.killed) finish(exitCodeOf(null, signal))
123
133
  })
124
134
  }
125
135
  })
@@ -21,6 +21,9 @@ export const statusWord = (agent: string | null): string => agent ?? NO_AGENT_WO
21
21
  /** Before a row that opens and closes: lanes, the planned phases, the ticked items of a phase. */
22
22
  export const CHEVRON = { closed: '▸', open: '▾' } as const
23
23
 
24
+ /** Before the count under a block that shows all its rows; a click folds it back to five. */
25
+ export const LESS_MARK = '▴'
26
+
24
27
  export const chevronOf = (open: boolean): string => (open ? CHEVRON.open : CHEVRON.closed)
25
28
 
26
29
  export const PHASE_STATE = { missing: 'missing', empty: 'empty', all: 'all', open: 'open' } as const
@@ -0,0 +1,34 @@
1
+ const TASK_NAME = /^\*\*([^*]+)\*\*:/
2
+
3
+ const TITLE_END = ': '
4
+
5
+ const titleEndOf = (text: string): number => {
6
+ let inCode = false
7
+ for (let index = 0; index < text.length; index++) {
8
+ if (text[index] === '`') inCode = !inCode
9
+ else if (!inCode && text.startsWith(TITLE_END, index)) return index
10
+ }
11
+ return -1
12
+ }
13
+
14
+ /**
15
+ * The text up to its first colon followed by a space outside backticks, as a folded row shows it; the text as it is
16
+ * without such a colon or with nothing before it. A time like `18:55` has no space after its colon and does not split.
17
+ */
18
+ export const shortTitle = (text: string): string => {
19
+ const end = titleEndOf(text)
20
+ return end > 0 ? text.slice(0, end) : text
21
+ }
22
+
23
+ /**
24
+ * The short name a task carries in bold at its start, `**Name**: content`, as written; null for a line without one.
25
+ * Nothing is guessed from the text up to its first colon: a path at the start of a line would read as a name.
26
+ */
27
+ export const taskNameOf = (text: string): string | null => {
28
+ const name = text.match(TASK_NAME)?.[1]?.trim()
29
+ return name ? name : null
30
+ }
31
+
32
+ /** The text after a task's `**Name**:`, as an opened task shows it under its name; the text as it is without a name. */
33
+ export const taskBodyOf = (text: string): string =>
34
+ taskNameOf(text) === null ? text : text.replace(TASK_NAME, '').trimStart()