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,7 @@
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
7
  * Whether a lane is drawn open. It is decided once, when the lane first appears (`autoOpen`, by its status then), and
@@ -8,6 +9,10 @@ import { chipUrlOf, isChipKey, planUrlOf } from './link-chips'
8
9
  */
9
10
  export type LaneFold = { open: boolean }
10
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]
15
+
11
16
  /**
12
17
  * The row under the cursor and the expanded phases, both as keys, whether ticked phases and items are
13
18
  * listed one by one or summed up in one line, whether the footer shows the symbol legend, and the
@@ -19,6 +24,8 @@ export type Selection = {
19
24
  showDone: boolean
20
25
  showLegend: boolean
21
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>>>>
22
29
  }
23
30
 
24
31
  /** A lane as the folds need it: its name and the status its agent reports, null without an agent. */
@@ -70,12 +77,25 @@ export const PLANNED_KEY = 'planned'
70
77
 
71
78
  export const phaseKey = (lane: string, stepIndex: number, phaseId: string): string => `${lane}:${stepIndex}:${phaseId}`
72
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
+
73
93
  const TICKED_KEY_PREFIX = 'ticked>'
74
94
 
75
95
  /** The key of the "N ticked" line of an open phase; it is clicked only, and a click lands on the phase. */
76
96
  export const tickedKey = (phase: string): string => `${TICKED_KEY_PREFIX}${phase}`
77
97
 
78
- const ITEM_KEY = /^(.+:\d+:[^:#]+)#(\d+)$/
98
+ const ITEM_KEY = new RegExp(`^(${PHASE_KEY_SHAPE})#(\\d+)$`)
79
99
 
80
100
  /**
81
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.
@@ -173,13 +193,10 @@ const foldLane = (selection: Selection, lane: string, next: (open: boolean) => b
173
193
  return { ...selection, laneFolds: new Map(selection.laneFolds).set(lane, { ...fold, open: next(fold.open) }) }
174
194
  }
175
195
 
176
- /** Opens a task and the phase it belongs to. */
177
- const openTaskWithPhase = (expanded: Set<string>, phase: string, task: string): void => {
178
- expanded.add(phase)
179
- expanded.add(task)
180
- }
181
-
182
- /** 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
+ */
183
200
  export const applyClick = (selection: Selection, key: string): Selection => {
184
201
  const phase = tickedPhaseOfKey(key)
185
202
  if (phase !== null) return { ...selection, showDone: !selection.showDone, selected: phase }
@@ -187,7 +204,7 @@ export const applyClick = (selection: Selection, key: string): Selection => {
187
204
  if (lane !== null) return { ...foldLane(selection, lane, flipped), selected: key }
188
205
  const expanded = new Set(selection.expanded)
189
206
  const itemPhase = itemPhaseOfKey(key)
190
- if (itemPhase !== null && !expanded.has(itemPhase)) openTaskWithPhase(expanded, itemPhase, key)
207
+ if (itemPhase !== null && !expanded.has(itemPhase)) addWithPhase(expanded, key)
191
208
  else toggleIn(expanded, key)
192
209
  return { ...selection, selected: key, expanded }
193
210
  }
@@ -197,17 +214,30 @@ const toggle = (selection: Selection, keys: readonly string[]): Selection => {
197
214
  return current ? applyClick(selection, current) : selection
198
215
  }
199
216
 
200
- 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 => {
201
235
  const current = selectedKey(selection, keys)
202
236
  if (!current) return selection
203
237
  const lane = laneOfKey(current)
204
- if (lane !== null) return { ...foldLane(selection, lane, next), selected: current }
238
+ if (lane !== null) return { ...foldLane(selection, lane, nextFold), selected: current }
205
239
  const expanded = new Set(selection.expanded)
206
- if (next(expanded.has(current))) {
207
- expanded.add(current)
208
- const itemPhase = itemPhaseOfKey(current)
209
- if (itemPhase !== null) expanded.add(itemPhase)
210
- } else expanded.delete(current)
240
+ change(expanded, current)
211
241
  return { ...selection, selected: current, expanded }
212
242
  }
213
243
 
@@ -218,9 +248,59 @@ export const toggledExpansion = (selection: Selection, key: string): Selection =
218
248
  return { ...selection, expanded }
219
249
  }
220
250
 
221
- const expand = (selection: Selection, keys: readonly string[]): Selection => changeSelected(selection, keys, opened)
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
+ }
222
278
 
223
- 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
+ }
224
304
 
225
305
  export const applyKey = (selection: Selection, input: string, keys: readonly string[]): Selection => {
226
306
  if (isInput(INPUT.up, input)) return move(selection, keys, -1)
@@ -1,22 +1,13 @@
1
- import { join } from 'node:path'
2
- import { readRecords, writeRecordAtomically } from '../shared/private-records'
3
- import { isSentFileRecord, type SentFileRecord } from './sent-files'
1
+ import { closableRecordLog } from './closable-records'
2
+ import { isSentFileRecord } from './sent-files'
4
3
 
5
- const directoryOf = (root: string): string => join(root, '.jackherd', 'sent')
4
+ const log = closableRecordLog('sent', isSentFileRecord, ({ sentAt }) => sentAt)
6
5
 
7
6
  /** Every record of the sent-file log in the main checkout, oldest first; nothing when the directory is missing or unreadable. */
8
- export const readSentRecords = (root: string): SentFileRecord[] =>
9
- readRecords(directoryOf(root), isSentFileRecord, ({ sentAt }) => sentAt)
10
-
11
- const closeWhere = (root: string, isChosen: (record: SentFileRecord) => boolean, now: number): number => {
12
- const open = readSentRecords(root).filter((record) => record.closedAt === null && isChosen(record))
13
- for (const record of open) writeRecordAtomically(directoryOf(root), { ...record, closedAt: now })
14
- return open.length
15
- }
7
+ export const readSentRecords = log.read
16
8
 
17
9
  /** Marks the named records `closedAt` and returns how many it closed; one closed already, or unknown, counts for nothing. */
18
- export const closeSentRecords = (root: string, ids: readonly string[], now: number): number =>
19
- closeWhere(root, (record) => ids.includes(record.id), now)
10
+ export const closeSentRecords = log.close
20
11
 
21
12
  /** Marks every open record `closedAt` and returns how many it closed. */
22
- export const closeAllSentRecords = (root: string, now: number): number => closeWhere(root, () => true, now)
13
+ export const closeAllSentRecords = log.closeAll
@@ -1,8 +1,10 @@
1
1
  import { basename } from 'node:path'
2
- import { twoDigits } from '../plan-format/clock'
2
+ import { clockText, DAY_MS } from '../plan-format/clock'
3
3
  import { terminalSafe } from '../plan-format/terminal-text'
4
- import type { WaitingItem } from '../plan-format/waiting'
4
+ import { WAITING_KIND, type WaitingItem } from '../plan-format/waiting'
5
+ import { isRecordId } from './record-id'
5
6
  import { laneDirectoryOf } from './unlisted-lanes'
7
+ import { isStillWaiting } from './waiting-window'
6
8
 
7
9
  /** The record of a send of files to the user, as the kit hook `sent-file` writes it to `.jackherd/sent/`. */
8
10
  export type SentFileRecord = {
@@ -19,12 +21,8 @@ export type SentFileRecord = {
19
21
  /** A badge for a sent file: a `file:` address and the file's name. */
20
22
  export type SentChip = { url: string; label: string }
21
23
 
22
- const ID = /^[0-9a-f]{12}$/
23
24
  /** The address of a published artifact, as the hook reads it from the tool's answer, as a whole. */
24
25
  const ARTIFACT_ADDRESS = /^https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9_-]+$/
25
- const DAY_MS = 86_400_000
26
- /** A send waits on the user this long at most; the coordinator closes it earlier, with the answer. */
27
- const WAITING_DAYS = 3
28
26
  const SHOWN_PER_LANE = 5
29
27
 
30
28
  /** A published artifact is recorded by its address; a sent file by its path. */
@@ -49,7 +47,7 @@ export const isSentFileRecord = (value: unknown): value is SentFileRecord => {
49
47
  const record = value as Record<string, unknown>
50
48
  return (
51
49
  typeof record.id === 'string' &&
52
- ID.test(record.id) &&
50
+ isRecordId(record.id) &&
53
51
  isStringArray(record.files) &&
54
52
  record.files.every(isRecordedFile) &&
55
53
  (record.caption === null || typeof record.caption === 'string') &&
@@ -96,18 +94,13 @@ export const sentChipsByLane = (
96
94
  return lanes
97
95
  }
98
96
 
99
- const clockText = (at: number): string => {
100
- const date = new Date(at)
101
- return `${twoDigits(date.getDate())}.${twoDigits(date.getMonth() + 1)}. ${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`
102
- }
103
-
104
97
  const itemOf = (record: SentFileRecord): WaitingItem => {
105
98
  const [first] = record.files
106
99
  const more = record.files.length > 1 ? ` +${record.files.length - 1}` : ''
107
100
  return {
108
101
  since: clockText(record.sentAt),
109
102
  item: `${labelOf(first, record.caption)}${more}`,
110
- kind: 'file',
103
+ kind: WAITING_KIND.file,
111
104
  id: record.id,
112
105
  open: true,
113
106
  url: addressOf(first),
@@ -117,5 +110,5 @@ const itemOf = (record: SentFileRecord): WaitingItem => {
117
110
  /** What the sends that no one closed wait on: one open item per record younger than three days, oldest first. */
118
111
  export const sentWaitingItems = (records: readonly SentFileRecord[], now: number): WaitingItem[] =>
119
112
  latestPerFile(records)
120
- .filter(({ closedAt, sentAt }) => closedAt === null && now - sentAt <= WAITING_DAYS * DAY_MS)
113
+ .filter(({ closedAt, sentAt }) => isStillWaiting(closedAt, sentAt, now))
121
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
@@ -2,7 +2,8 @@ import { readdirSync, statSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { projectDirectoryOf } from '../context/session-log'
4
4
  import { MS_PER_MINUTE } from '../plan-format/clock'
5
- import { readClaudeSignals } from '../task-signals/claude-read'
5
+ import { dropUnused, type StatCache } from '../shared/stat-cache'
6
+ import { readClaudeSignals, type SignalCache } from '../task-signals/claude-read'
6
7
  import { recordSignals } from '../task-signals/record'
7
8
  import { readTaskRecords } from '../task-signals/record-store'
8
9
  import type { TaskSignal } from '../task-signals/types'
@@ -41,14 +42,21 @@ const recentSessionLogs = (project: string, notOlderThan: number): string[] => {
41
42
  /**
42
43
  * What agents report doing, as `TaskSignal`s: the tasks in progress in the recent Claude Code sessions of the main
43
44
  * checkout (the coordinator and its subagents) and of every worktree, and the open `jh task` records. Which lane a
44
- * signal belongs to is decided by its `cwd`, not by where it was read.
45
+ * signal belongs to is decided by its `cwd`, not by where it was read. A `cache` kept between calls spares parsing a
46
+ * log whose modification time and size did not change; logs this call did not look at are dropped from it.
45
47
  */
46
- export const readAllSignals = ({ projects, root, worktrees }: SignalSource, now: number): TaskSignal[] => {
48
+ export const readAllSignals = (
49
+ { projects, root, worktrees }: SignalSource,
50
+ now: number,
51
+ cache: StatCache<TaskSignal[]> = new Map(),
52
+ ): TaskSignal[] => {
47
53
  const notOlderThan = now - STALE_ACTIVITY_MINUTES * MS_PER_MINUTE
54
+ const round: SignalCache = { entries: cache, used: new Set<string>() }
48
55
  const claude = [root, ...subdirectories(worktrees)].flatMap((directory) =>
49
56
  recentSessionLogs(join(projects, projectDirectoryOf(directory)), notOlderThan).flatMap((path) =>
50
- readClaudeSignals(path, { notOlderThan }),
57
+ readClaudeSignals(path, { notOlderThan, cache: round }),
51
58
  ),
52
59
  )
60
+ dropUnused(round.entries, round.used)
53
61
  return [...claude, ...recordSignals(readTaskRecords(root))]
54
62
  }