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
@@ -1,13 +1,17 @@
1
1
  import { currentPhase, type LaneProgress } from '../plan-format'
2
2
  import { AGENT_STATUS } from '../terminal/types'
3
3
  import { chipUrlOf, isChipKey, planUrlOf } from './link-chips'
4
+ import { TEXTS } from './texts'
4
5
 
5
6
  /**
6
- * Whether a lane is drawn open, and the agent status that was true when that was decided. The fold
7
- * counts only while the lane still has that status: a change of status hands the decision back to
8
- * the automatic rule (`autoOpen`), so a lane that starts working opens by itself even if a person closed it.
7
+ * Whether a lane is drawn open. It is decided once, when the lane first appears (`autoOpen`, by its status then), and
8
+ * changes only when a person opens or closes it: a change of status moves neither this nor the order of the lanes.
9
9
  */
10
- export type LaneFold = { status: string | null; open: boolean }
10
+ export type LaneFold = { open: boolean }
11
+
12
+ /** The blocks on top of the herd, by the name their fold is remembered under. */
13
+ export const TOP_BLOCK = { waiting: 'waiting', appointments: 'appointments' } as const
14
+ export type TopBlockId = (typeof TOP_BLOCK)[keyof typeof TOP_BLOCK]
11
15
 
12
16
  /**
13
17
  * The row under the cursor and the expanded phases, both as keys, whether ticked phases and items are
@@ -20,6 +24,8 @@ export type Selection = {
20
24
  showDone: boolean
21
25
  showLegend: boolean
22
26
  laneFolds: ReadonlyMap<string, LaneFold>
27
+ /** Per folded top block, the keys of its points seen when it was folded; a block that is open has no entry. */
28
+ blockFolds?: Readonly<Partial<Record<TopBlockId, ReadonlySet<string>>>>
23
29
  }
24
30
 
25
31
  /** A lane as the folds need it: its name and the status its agent reports, null without an agent. */
@@ -61,7 +67,7 @@ const ESC = '\x1b'
61
67
  const ESCAPE_SEQUENCE = new RegExp(`${ESC}(?:\\[[<0-9;]*[A-Za-z~]|O[A-Za-z])|[\\s\\S]`, 'gu')
62
68
  const MOUSE_REPORT = new RegExp(`^${ESC}\\[<(\\d+);(\\d+);(\\d+)([Mm])$`)
63
69
 
64
- const MOUSE_BUTTON = { left: 0, wheelUp: 64, wheelDown: 65 } as const
70
+ export const MOUSE_BUTTON = { left: 0, wheelUp: 64, wheelDown: 65 } as const
65
71
 
66
72
  /** One terminal read can carry several key presses; this splits it into keys, escape sequences kept whole. */
67
73
  export const splitKeys = (chunk: string): string[] => chunk.match(ESCAPE_SEQUENCE) ?? []
@@ -71,12 +77,25 @@ export const PLANNED_KEY = 'planned'
71
77
 
72
78
  export const phaseKey = (lane: string, stepIndex: number, phaseId: string): string => `${lane}:${stepIndex}:${phaseId}`
73
79
 
80
+ const PHASE_KEY_SHAPE = '[^@>#]+:\\d+:[^:#>]+'
81
+
82
+ const PHASE_KEY = new RegExp(`^${PHASE_KEY_SHAPE}$`)
83
+
84
+ /** Whether a key names a phase: not a lane, row or task key, which carry `@`, `>` or `#`. */
85
+ const isPhaseKey = (key: string): boolean => PHASE_KEY.test(key)
86
+
87
+ /** The key of a lane's `next` row, which opens to the phases that wait. */
88
+ export const nextKey = (lane: string): string => `next>${lane}`
89
+
90
+ /** The key of a lane's `✓` row, which opens to the finished phases. */
91
+ export const finishedKey = (lane: string): string => `done>${lane}`
92
+
74
93
  const TICKED_KEY_PREFIX = 'ticked>'
75
94
 
76
95
  /** The key of the "N ticked" line of an open phase; it is clicked only, and a click lands on the phase. */
77
96
  export const tickedKey = (phase: string): string => `${TICKED_KEY_PREFIX}${phase}`
78
97
 
79
- const ITEM_KEY = /^(.+:\d+:[^:#]+)#(\d+)$/
98
+ const ITEM_KEY = new RegExp(`^(${PHASE_KEY_SHAPE})#(\\d+)$`)
80
99
 
81
100
  /**
82
101
  * The key of a task row: its phase's key and the task's index in `phase.items`; a click or Enter opens its text.
@@ -102,22 +121,19 @@ const laneOfKey = (key: string): string | null => (key.startsWith(LANE_KEY_PREFI
102
121
  export const autoOpen = (status: string | null): boolean =>
103
122
  status !== null && status !== AGENT_STATUS.idle && status !== AGENT_STATUS.done
104
123
 
105
- /** Whether a lane is drawn open: the person's choice while its status is unchanged, else the automatic rule. */
106
- export const isLaneOpen = ({ laneFolds }: Selection, lane: string, status: string | null): boolean => {
107
- const fold = laneFolds.get(lane)
108
- return fold && fold.status === status ? fold.open : autoOpen(status)
109
- }
124
+ /** Whether a lane is drawn open: its fold, else, for a lane that has none yet, the automatic rule for its status. */
125
+ export const isLaneOpen = ({ laneFolds }: Selection, lane: string, status: string | null): boolean =>
126
+ laneFolds.get(lane)?.open ?? autoOpen(status)
110
127
 
111
128
  /**
112
- * Lets the folds follow the lanes: a lane keeps its fold while its status is the same, gets the
113
- * automatic one when its status changed or it is new, and loses it when it left the sprint. Returns
114
- * the same object when nothing changed.
129
+ * Lets the folds follow the lanes: a lane keeps its fold whatever its status does, gets the automatic one only when
130
+ * it is new, and loses it when it left the sprint. Returns the same object when nothing changed.
115
131
  */
116
132
  export const syncLaneFolds = (selection: Selection, lanes: readonly LaneStatusEntry[]): Selection => {
117
133
  const folds = new Map<string, LaneFold>()
118
134
  for (const { lane, status } of lanes) {
119
135
  const fold = selection.laneFolds.get(lane)
120
- folds.set(lane, fold && fold.status === status ? fold : { status, open: autoOpen(status) })
136
+ folds.set(lane, fold ?? { open: autoOpen(status) })
121
137
  }
122
138
  const unchanged =
123
139
  folds.size === selection.laneFolds.size &&
@@ -134,10 +150,10 @@ export const overviewSelection = (): Selection => ({
134
150
  })
135
151
 
136
152
  /** The lane view opens its lane and, when it has a current phase, that phase, expanded. */
137
- export const laneSelection = (progress: LaneProgress | undefined, status: string | null): Selection => {
153
+ export const laneSelection = (progress: LaneProgress | undefined): Selection => {
138
154
  const base = overviewSelection()
139
155
  if (!progress) return base
140
- const opened = { ...base, laneFolds: new Map([[progress.lane.name, { status, open: true }]]) }
156
+ const opened = { ...base, laneFolds: new Map([[progress.lane.name, { open: true }]]) }
141
157
  const phase = currentPhase(progress)
142
158
  if (!progress.current || !phase) return opened
143
159
  const key = phaseKey(progress.lane.name, progress.current.stepIndex, phase.id)
@@ -177,13 +193,10 @@ const foldLane = (selection: Selection, lane: string, next: (open: boolean) => b
177
193
  return { ...selection, laneFolds: new Map(selection.laneFolds).set(lane, { ...fold, open: next(fold.open) }) }
178
194
  }
179
195
 
180
- /** Opens a task and the phase it belongs to. */
181
- const openTaskWithPhase = (expanded: Set<string>, phase: string, task: string): void => {
182
- expanded.add(phase)
183
- expanded.add(task)
184
- }
185
-
186
- /** Selects a row and opens it, or closes it when it is already open: a phase shows its items, a lane its phases. */
196
+ /**
197
+ * Selects the row under a click. A "N ticked" line flips `showDone` and selects its phase, a lane line flips the
198
+ * fold of its lane, any other row opens when closed and closes when open: a phase shows its items.
199
+ */
187
200
  export const applyClick = (selection: Selection, key: string): Selection => {
188
201
  const phase = tickedPhaseOfKey(key)
189
202
  if (phase !== null) return { ...selection, showDone: !selection.showDone, selected: phase }
@@ -191,7 +204,7 @@ export const applyClick = (selection: Selection, key: string): Selection => {
191
204
  if (lane !== null) return { ...foldLane(selection, lane, flipped), selected: key }
192
205
  const expanded = new Set(selection.expanded)
193
206
  const itemPhase = itemPhaseOfKey(key)
194
- if (itemPhase !== null && !expanded.has(itemPhase)) openTaskWithPhase(expanded, itemPhase, key)
207
+ if (itemPhase !== null && !expanded.has(itemPhase)) addWithPhase(expanded, key)
195
208
  else toggleIn(expanded, key)
196
209
  return { ...selection, selected: key, expanded }
197
210
  }
@@ -201,23 +214,93 @@ const toggle = (selection: Selection, keys: readonly string[]): Selection => {
201
214
  return current ? applyClick(selection, current) : selection
202
215
  }
203
216
 
204
- const changeSelected = (selection: Selection, keys: readonly string[], next: (open: boolean) => boolean): Selection => {
217
+ /** Adds a key to the expanded ones, with the phase of a task. */
218
+ const addWithPhase = (expanded: Set<string>, key: string): void => {
219
+ expanded.add(key)
220
+ const itemPhase = itemPhaseOfKey(key)
221
+ if (itemPhase !== null) expanded.add(itemPhase)
222
+ }
223
+
224
+ const removeKey = (expanded: Set<string>, key: string): void => {
225
+ expanded.delete(key)
226
+ }
227
+
228
+ /** Applies a change to the selected row: `nextFold` to the fold of a lane, `change` to the expanded keys for any other row. */
229
+ const changeSelected = (
230
+ selection: Selection,
231
+ keys: readonly string[],
232
+ nextFold: (open: boolean) => boolean,
233
+ change: (expanded: Set<string>, key: string) => void,
234
+ ): Selection => {
205
235
  const current = selectedKey(selection, keys)
206
236
  if (!current) return selection
207
237
  const lane = laneOfKey(current)
208
- if (lane !== null) return { ...foldLane(selection, lane, next), selected: current }
238
+ if (lane !== null) return { ...foldLane(selection, lane, nextFold), selected: current }
209
239
  const expanded = new Set(selection.expanded)
210
- if (next(expanded.has(current))) {
211
- expanded.add(current)
212
- const itemPhase = itemPhaseOfKey(current)
213
- if (itemPhase !== null) expanded.add(itemPhase)
214
- } else expanded.delete(current)
240
+ change(expanded, current)
215
241
  return { ...selection, selected: current, expanded }
216
242
  }
217
243
 
218
- const expand = (selection: Selection, keys: readonly string[]): Selection => changeSelected(selection, keys, opened)
244
+ /** The selection with `key` expanded when it was folded and folded when it was expanded; nothing else changes. */
245
+ export const toggledExpansion = (selection: Selection, key: string): Selection => {
246
+ const expanded = new Set(selection.expanded)
247
+ if (!expanded.delete(key)) expanded.add(key)
248
+ return { ...selection, expanded }
249
+ }
250
+
251
+ /**
252
+ * Whether `block` is drawn folded: it was folded and every point now open was there then. A point that arrived since
253
+ * opens it again, so folding never hides something new. Each block remembers its own points.
254
+ */
255
+ export const isBlockFolded = ({ blockFolds }: Selection, block: TopBlockId, keys: readonly string[]): boolean => {
256
+ const folded = blockFolds?.[block]
257
+ return folded !== undefined && keys.every((key) => folded.has(key))
258
+ }
259
+
260
+ /**
261
+ * The selection with the fold of each block forgotten that a new point opened, given the points each block holds now.
262
+ * Without it a block that opened for a new point would fold again by itself once that point is gone. The selection
263
+ * itself when no fold is forgotten.
264
+ */
265
+ export const settledBlockFolds = (
266
+ selection: Selection,
267
+ keysOf: Readonly<Record<TopBlockId, readonly string[]>>,
268
+ ): Selection => {
269
+ const folds = selection.blockFolds ?? {}
270
+ const opened = Object.values(TOP_BLOCK).filter(
271
+ (block) => folds[block] !== undefined && !isBlockFolded(selection, block, keysOf[block]),
272
+ )
273
+ if (opened.length === 0) return selection
274
+ const kept = Object.fromEntries(Object.entries(folds).filter(([block]) => !opened.includes(block as TopBlockId)))
275
+ const { blockFolds: _forgotten, ...rest } = selection
276
+ return Object.keys(kept).length > 0 ? { ...rest, blockFolds: kept } : rest
277
+ }
219
278
 
220
- const collapse = (selection: Selection, keys: readonly string[]): Selection => changeSelected(selection, keys, closed)
279
+ /** The selection with `block` folded over the points `keys` when it was open, and open when it was folded. */
280
+ export const toggledBlockFold = (selection: Selection, block: TopBlockId, keys: readonly string[]): Selection => {
281
+ if (!isBlockFolded(selection, block, keys)) {
282
+ return { ...selection, blockFolds: { ...selection.blockFolds, [block]: new Set(keys) } }
283
+ }
284
+ const { blockFolds, ...rest } = selection
285
+ const { [block]: _folded, ...otherFolds } = blockFolds ?? {}
286
+ return Object.keys(otherFolds).length > 0 ? { ...rest, blockFolds: otherFolds } : rest
287
+ }
288
+
289
+ const expand = (selection: Selection, keys: readonly string[]): Selection =>
290
+ changeSelected(selection, keys, opened, addWithPhase)
291
+
292
+ const collapse = (selection: Selection, keys: readonly string[]): Selection =>
293
+ changeSelected(selection, keys, closed, removeKey)
294
+
295
+ /**
296
+ * The line for a press of `a` that changes nothing on screen, null for any other input. The key lists the ticked
297
+ * tasks inside an expanded phase, so it needs one among the `keys` drawn.
298
+ */
299
+ export const showDoneNotice = (selection: Selection, input: string, keys: readonly string[]): string | null => {
300
+ if (!isInput(INPUT.showDone, input)) return null
301
+ const phaseOpen = keys.some((key) => isPhaseKey(key) && selection.expanded.has(key))
302
+ return phaseOpen ? null : TEXTS.showDoneNeedsPhase
303
+ }
221
304
 
222
305
  export const applyKey = (selection: Selection, input: string, keys: readonly string[]): Selection => {
223
306
  if (isInput(INPUT.up, input)) return move(selection, keys, -1)
@@ -0,0 +1,13 @@
1
+ import { closableRecordLog } from './closable-records'
2
+ import { isSentFileRecord } from './sent-files'
3
+
4
+ const log = closableRecordLog('sent', isSentFileRecord, ({ sentAt }) => sentAt)
5
+
6
+ /** Every record of the sent-file log in the main checkout, oldest first; nothing when the directory is missing or unreadable. */
7
+ export const readSentRecords = log.read
8
+
9
+ /** Marks the named records `closedAt` and returns how many it closed; one closed already, or unknown, counts for nothing. */
10
+ export const closeSentRecords = log.close
11
+
12
+ /** Marks every open record `closedAt` and returns how many it closed. */
13
+ export const closeAllSentRecords = log.closeAll
@@ -0,0 +1,114 @@
1
+ import { basename } from 'node:path'
2
+ import { clockText, DAY_MS } from '../plan-format/clock'
3
+ import { terminalSafe } from '../plan-format/terminal-text'
4
+ import { WAITING_KIND, type WaitingItem } from '../plan-format/waiting'
5
+ import { isRecordId } from './record-id'
6
+ import { laneDirectoryOf } from './unlisted-lanes'
7
+ import { isStillWaiting } from './waiting-window'
8
+
9
+ /** The record of a send of files to the user, as the kit hook `sent-file` writes it to `.jackherd/sent/`. */
10
+ export type SentFileRecord = {
11
+ id: string
12
+ files: string[]
13
+ caption: string | null
14
+ sentAt: number
15
+ cwd: string
16
+ session: string
17
+ agentId: string | null
18
+ closedAt: number | null
19
+ }
20
+
21
+ /** A badge for a sent file: a `file:` address and the file's name. */
22
+ export type SentChip = { url: string; label: string }
23
+
24
+ /** The address of a published artifact, as the hook reads it from the tool's answer, as a whole. */
25
+ const ARTIFACT_ADDRESS = /^https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9_-]+$/
26
+ const SHOWN_PER_LANE = 5
27
+
28
+ /** A published artifact is recorded by its address; a sent file by its path. */
29
+ const isAddress = (file: string): boolean => file.startsWith('https://')
30
+
31
+ const addressOf = (file: string): string =>
32
+ isAddress(file) ? file : `file://${file.split('/').map(encodeURIComponent).join('/')}`
33
+
34
+ /** What a badge or a for-you row shows of a file, without the control characters a hand-written record may carry. */
35
+ const labelOf = (file: string, caption: string | null): string =>
36
+ terminalSafe(isAddress(file) ? (caption ?? 'artifact') : basename(file))
37
+
38
+ /** A file entry: an artifact address as a whole, else a path; an https entry of another shape is refused. */
39
+ const isRecordedFile = (file: string): boolean => !isAddress(file) || ARTIFACT_ADDRESS.test(file)
40
+
41
+ const isStringArray = (value: unknown): value is string[] =>
42
+ Array.isArray(value) && value.length > 0 && value.every((entry) => typeof entry === 'string')
43
+
44
+ /** The same shape as `isSentRecord` in the hook, which this package does not import at run time (contract test). */
45
+ export const isSentFileRecord = (value: unknown): value is SentFileRecord => {
46
+ if (typeof value !== 'object' || value === null) return false
47
+ const record = value as Record<string, unknown>
48
+ return (
49
+ typeof record.id === 'string' &&
50
+ isRecordId(record.id) &&
51
+ isStringArray(record.files) &&
52
+ record.files.every(isRecordedFile) &&
53
+ (record.caption === null || typeof record.caption === 'string') &&
54
+ typeof record.sentAt === 'number' &&
55
+ typeof record.cwd === 'string' &&
56
+ typeof record.session === 'string' &&
57
+ (record.agentId === null || typeof record.agentId === 'string') &&
58
+ (record.closedAt === null || typeof record.closedAt === 'number')
59
+ )
60
+ }
61
+
62
+ /**
63
+ * The lane a send belongs to: the worktree of the first file below `worktreeDirectory`, else the worktree of the
64
+ * directory it was sent from; null for the coordinator.
65
+ */
66
+ export const sentLaneOf = ({ files, cwd }: SentFileRecord, worktreeDirectory: string): string | null =>
67
+ files
68
+ .map((file) => (isAddress(file) ? null : laneDirectoryOf(file, worktreeDirectory)))
69
+ .find((lane) => lane !== null) ?? laneDirectoryOf(cwd, worktreeDirectory)
70
+
71
+ /** The records without an earlier send of the same first file: an artifact updated each round is one send, the newest. */
72
+ const latestPerFile = (records: readonly SentFileRecord[]): SentFileRecord[] =>
73
+ records.filter((record, index) => !records.slice(index + 1).some((later) => later.files[0] === record.files[0]))
74
+
75
+ /**
76
+ * The badges of the files sent within the last day, by lane (null: the coordinator's), newest first and at most five
77
+ * per lane. A closed send still shows its files: the badge is the way back to them.
78
+ */
79
+ export const sentChipsByLane = (
80
+ records: readonly SentFileRecord[],
81
+ worktreeDirectory: string,
82
+ now: number,
83
+ ): Map<string | null, SentChip[]> => {
84
+ const lanes = new Map<string | null, SentChip[]>()
85
+ const recent = latestPerFile([...records].sort((left, right) => left.sentAt - right.sentAt))
86
+ .filter(({ sentAt }) => now - sentAt <= DAY_MS)
87
+ .sort((left, right) => right.sentAt - left.sentAt)
88
+ for (const record of recent) {
89
+ const lane = sentLaneOf(record, worktreeDirectory)
90
+ const chips = lanes.get(lane) ?? []
91
+ for (const file of record.files) chips.push({ url: addressOf(file), label: labelOf(file, record.caption) })
92
+ lanes.set(lane, chips.slice(0, SHOWN_PER_LANE))
93
+ }
94
+ return lanes
95
+ }
96
+
97
+ const itemOf = (record: SentFileRecord): WaitingItem => {
98
+ const [first] = record.files
99
+ const more = record.files.length > 1 ? ` +${record.files.length - 1}` : ''
100
+ return {
101
+ since: clockText(record.sentAt),
102
+ item: `${labelOf(first, record.caption)}${more}`,
103
+ kind: WAITING_KIND.file,
104
+ id: record.id,
105
+ open: true,
106
+ url: addressOf(first),
107
+ }
108
+ }
109
+
110
+ /** What the sends that no one closed wait on: one open item per record younger than three days, oldest first. */
111
+ export const sentWaitingItems = (records: readonly SentFileRecord[], now: number): WaitingItem[] =>
112
+ latestPerFile(records)
113
+ .filter(({ closedAt, sentAt }) => isStillWaiting(closedAt, sentAt, now))
114
+ .map(itemOf)
@@ -9,7 +9,7 @@ export const CONFIG_PATH = PROJECT_CONFIG_PATH
9
9
 
10
10
  export type PlansSettings = Pick<
11
11
  JackherdConfig,
12
- 'planDirectory' | 'board' | 'worktreeDirectory' | 'baseBranch' | 'planLanguage' | 'responseLanguage'
12
+ 'planDirectory' | 'board' | 'worktreeDirectory' | 'baseBranch' | 'planLanguage' | 'responseLanguage' | 'skillPrefix'
13
13
  > & {
14
14
  adapter: JackherdConfig['terminal']['adapter']
15
15
  fetchIntervalMinutes: number
@@ -30,6 +30,7 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
30
30
  baseBranch: config.baseBranch,
31
31
  planLanguage: config.planLanguage,
32
32
  responseLanguage: config.responseLanguage,
33
+ skillPrefix: config.skillPrefix,
33
34
  adapter: config.terminal.adapter,
34
35
  fetchIntervalMinutes: config.plans.fetchIntervalMinutes,
35
36
  linkKinds: config.plans.linkKinds,
@@ -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
@@ -0,0 +1,62 @@
1
+ import { readdirSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { projectDirectoryOf } from '../context/session-log'
4
+ import { MS_PER_MINUTE } from '../plan-format/clock'
5
+ import { dropUnused, type StatCache } from '../shared/stat-cache'
6
+ import { readClaudeSignals, type SignalCache } from '../task-signals/claude-read'
7
+ import { recordSignals } from '../task-signals/record'
8
+ import { readTaskRecords } from '../task-signals/record-store'
9
+ import type { TaskSignal } from '../task-signals/types'
10
+ import { STALE_ACTIVITY_MINUTES } from './lane-summary'
11
+
12
+ export type SignalSource = {
13
+ /** Claude Code's `projects` directory. */
14
+ projects: string
15
+ /** The main checkout, where the coordinator works and `.jackherd/tasks/` lives. */
16
+ root: string
17
+ /** The directory the lanes' worktrees lie in. */
18
+ worktrees: string
19
+ }
20
+
21
+ const subdirectories = (path: string): string[] => {
22
+ try {
23
+ return readdirSync(path, { withFileTypes: true }).flatMap((entry) =>
24
+ entry.isDirectory() ? [join(path, entry.name)] : [],
25
+ )
26
+ } catch {
27
+ return []
28
+ }
29
+ }
30
+
31
+ const recentSessionLogs = (project: string, notOlderThan: number): string[] => {
32
+ try {
33
+ return readdirSync(project)
34
+ .filter((name) => name.endsWith('.jsonl'))
35
+ .map((name) => join(project, name))
36
+ .filter((path) => statSync(path).mtimeMs >= notOlderThan)
37
+ } catch {
38
+ return []
39
+ }
40
+ }
41
+
42
+ /**
43
+ * What agents report doing, as `TaskSignal`s: the tasks in progress in the recent Claude Code sessions of the main
44
+ * checkout (the coordinator and its subagents) and of every worktree, and the open `jh task` records. Which lane a
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.
47
+ */
48
+ export const readAllSignals = (
49
+ { projects, root, worktrees }: SignalSource,
50
+ now: number,
51
+ cache: StatCache<TaskSignal[]> = new Map(),
52
+ ): TaskSignal[] => {
53
+ const notOlderThan = now - STALE_ACTIVITY_MINUTES * MS_PER_MINUTE
54
+ const round: SignalCache = { entries: cache, used: new Set<string>() }
55
+ const claude = [root, ...subdirectories(worktrees)].flatMap((directory) =>
56
+ recentSessionLogs(join(projects, projectDirectoryOf(directory)), notOlderThan).flatMap((path) =>
57
+ readClaudeSignals(path, { notOlderThan, cache: round }),
58
+ ),
59
+ )
60
+ dropUnused(round.entries, round.used)
61
+ return [...claude, ...recordSignals(readTaskRecords(root))]
62
+ }