jackherd 1.8.0 → 1.9.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 (131) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/INSTALL.md +2 -0
  3. package/README.md +41 -60
  4. package/assets/branding/README.md +5 -7
  5. package/assets/branding/jackherd-logo-outline.svg +1 -0
  6. package/assets/branding/outline.test.ts +30 -0
  7. package/assets/icons/bark.svg +1 -1
  8. package/assets/icons/bowl.svg +1 -1
  9. package/assets/icons/decide.svg +1 -1
  10. package/assets/icons/ear.svg +1 -1
  11. package/assets/icons/lanes.svg +1 -1
  12. package/assets/icons/plan.svg +1 -1
  13. package/dist/{chunk-M6DDZEW4.js → chunk-MCP4XJQH.js} +17 -0
  14. package/dist/config.d.ts +6 -0
  15. package/dist/config.js +19 -3
  16. package/dist/plan-format.d.ts +22 -3
  17. package/dist/plan-format.js +21 -7
  18. package/herdr-plugin/herdr-plugin.toml +1 -1
  19. package/install.sh +1 -1
  20. package/kit/hooks/hooks/approval-gate/index.ts +22 -4
  21. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  22. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  23. package/kit/hooks/hooks/merge-guard/decision.ts +70 -21
  24. package/kit/hooks/hooks/merge-guard/xargs-program.ts +182 -0
  25. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  26. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  27. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  28. package/kit/hooks/hooks/sent-file/store.ts +18 -0
  29. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  30. package/kit/hooks/hooks.json +9 -0
  31. package/kit/hooks/statusline.sh +1 -1
  32. package/kit/workflow/skills/implement/SKILL.md +2 -0
  33. package/kit/workflow/skills/orchestrate/SKILL.md +31 -6
  34. package/package.json +3 -1
  35. package/schemas/jackherd.schema.json +26 -2
  36. package/src/cli.ts +3 -0
  37. package/src/config/read-config.ts +5 -1
  38. package/src/config/schema.ts +22 -2
  39. package/src/context/fill.ts +12 -0
  40. package/src/context/session-log.ts +43 -1
  41. package/src/plan-format/clock.ts +4 -0
  42. package/src/plan-format/dependencies.ts +2 -5
  43. package/src/plan-format/lanes.ts +30 -5
  44. package/src/plan-format/terminal-text.ts +4 -0
  45. package/src/plan-format/waiting.ts +48 -0
  46. package/src/plans/active-tasks.ts +60 -0
  47. package/src/plans/current-phase.ts +17 -7
  48. package/src/plans/current-task.ts +63 -5
  49. package/src/plans/lane-standing.ts +12 -6
  50. package/src/plans/lane-summary.ts +141 -74
  51. package/src/plans/lane-tickets.ts +15 -0
  52. package/src/plans/layout-phase.ts +57 -18
  53. package/src/plans/layout-summary.ts +85 -67
  54. package/src/plans/layout.ts +253 -89
  55. package/src/plans/live.ts +22 -0
  56. package/src/plans/phase-models.ts +1 -1
  57. package/src/plans/phase-ranges.ts +45 -0
  58. package/src/plans/pull-requests.ts +6 -0
  59. package/src/plans/selection.ts +19 -16
  60. package/src/plans/sent-files-read.ts +22 -0
  61. package/src/plans/sent-files.ts +121 -0
  62. package/src/plans/signals-read.ts +54 -0
  63. package/src/plans/snapshot.ts +251 -55
  64. package/src/plans/subagents-read.ts +82 -0
  65. package/src/plans/subagents.ts +132 -0
  66. package/src/plans/texts.ts +16 -11
  67. package/src/plans/unlisted-lanes.ts +10 -5
  68. package/src/plans/view.ts +3 -3
  69. package/src/plans/waiting-click.ts +76 -0
  70. package/src/preview/command.ts +83 -0
  71. package/src/preview/embed.ts +209 -0
  72. package/src/preview/images.ts +89 -0
  73. package/src/preview/open.ts +12 -0
  74. package/src/preview/options.ts +32 -0
  75. package/src/preview/page.ts +89 -0
  76. package/src/preview/render.ts +41 -0
  77. package/src/settled/board-row.ts +1 -2
  78. package/src/shared/is-record.ts +2 -0
  79. package/src/shared/private-records.ts +48 -0
  80. package/src/task-signals/agent.ts +15 -0
  81. package/src/task-signals/claude-read.ts +72 -0
  82. package/src/task-signals/claude-signals.ts +112 -0
  83. package/src/task-signals/command.ts +71 -0
  84. package/src/task-signals/fixtures/session.jsonl +7 -0
  85. package/src/task-signals/matcher.ts +96 -0
  86. package/src/task-signals/record-store.ts +40 -0
  87. package/src/task-signals/record.ts +68 -0
  88. package/src/task-signals/text.ts +13 -0
  89. package/src/task-signals/types.ts +14 -0
  90. package/src/terminal/open.ts +13 -6
  91. package/src/terminal/text.ts +8 -1
  92. package/src/terminal/width.ts +2 -1
  93. package/src/update/command.ts +2 -0
  94. package/src/update/config-shares.ts +30 -0
  95. package/src/update/update.ts +12 -1
  96. package/src/vocabulary/index.ts +33 -1
  97. package/src/waiting/command.ts +35 -0
  98. package/src/watch/appointments.ts +5 -5
  99. package/src/watch/approvals.ts +2 -2
  100. package/src/watch/command.ts +91 -5
  101. package/src/watch/failed-tests.ts +56 -10
  102. package/src/watch/failure-read.ts +18 -6
  103. package/src/watch/failures.ts +29 -5
  104. package/src/watch/feedback.ts +1 -1
  105. package/src/watch/gh-process.ts +9 -3
  106. package/src/watch/json-guards.ts +0 -3
  107. package/src/watch/lane-fill.ts +1 -1
  108. package/src/watch/lane-model.ts +1 -1
  109. package/src/watch/lane-parallel.ts +1 -1
  110. package/src/watch/lane-progress.ts +19 -7
  111. package/src/watch/lane-report.ts +5 -1
  112. package/src/watch/lane-rest.ts +28 -0
  113. package/src/watch/plan-badges.ts +9 -2
  114. package/src/watch/previews.ts +1 -1
  115. package/src/watch/pull-requests.ts +2 -1
  116. package/src/watch/source-path.ts +1 -1
  117. package/src/watch/sources.ts +1 -1
  118. package/src/watch/stream-output.ts +12 -7
  119. package/src/watch/task-parallel-input.ts +118 -0
  120. package/src/watch/task-parallel-read.ts +149 -0
  121. package/src/watch/task-parallel-touched.ts +39 -0
  122. package/src/watch/task-parallel.ts +206 -0
  123. package/src/watch/waiting-line.ts +19 -0
  124. package/src/watch/watch-events.ts +13 -1
  125. package/src/watch/workflow-runs.ts +2 -1
  126. package/assets/icons/bark-dark.svg +0 -1
  127. package/assets/icons/bowl-dark.svg +0 -1
  128. package/assets/icons/decide-dark.svg +0 -1
  129. package/assets/icons/ear-dark.svg +0 -1
  130. package/assets/icons/lanes-dark.svg +0 -1
  131. package/assets/icons/plan-dark.svg +0 -1
@@ -0,0 +1,45 @@
1
+ import { phaseLabel } from '../plan-format'
2
+
3
+ const RANGE_DASH = '–'
4
+ const RANGE_SEPARATOR = ', '
5
+ /** A phase id as a number with an optional sub-phase letter: `10`, `10c`. */
6
+ const NUMBERED_ID = /^(\d+)([a-z]*)$/i
7
+
8
+ /** One entry of the text: a run of whole phases `first`–`last`, or one id that stands alone. */
9
+ type Entry = { first: number; last: number } | { alone: string }
10
+
11
+ const byNumber = (first: string, second: string): number => first.localeCompare(second, undefined, { numeric: true })
12
+
13
+ const entryText = (entry: Entry): string => {
14
+ if ('alone' in entry) return phaseLabel(entry.alone)
15
+ const first = phaseLabel(String(entry.first))
16
+ return entry.first === entry.last ? first : `${first}${RANGE_DASH}${phaseLabel(String(entry.last))}`
17
+ }
18
+
19
+ /** The entry `id` extends, or the entries with `id` after them; `wholes` are the main phases among the ids. */
20
+ const withId = (entries: Entry[], id: string, wholes: ReadonlySet<string>): Entry[] => {
21
+ const [, main = '', sub = ''] = NUMBERED_ID.exec(id) ?? []
22
+ if (main === '' || (sub !== '' && !wholes.has(String(Number(main))))) return [...entries, { alone: id }]
23
+ if (sub !== '') return entries
24
+ const number = Number(main)
25
+ const last = entries.at(-1)
26
+ if (last && !('alone' in last) && last.last === number - 1)
27
+ return [...entries.slice(0, -1), { ...last, last: number }]
28
+ return [...entries, { first: number, last: number }]
29
+ }
30
+
31
+ /**
32
+ * Phase ids as ranges, numbered ids by number first: `P1–P7, P10–P15`. A gap ends a range; a sub-phase (`P10c`) is
33
+ * inside its main phase when that one is among the ids too, else it stands alone between the ranges. An id that is
34
+ * no number stands alone after the numbered ones. Empty for no id.
35
+ */
36
+ export const phaseRanges = (ids: readonly string[]): string => {
37
+ const sorted = [...new Set(ids)].sort(byNumber)
38
+ const numbered = sorted.filter((id) => NUMBERED_ID.test(id))
39
+ const wholes = new Set(numbered.filter((id) => /^\d+$/.test(id)).map((id) => String(Number(id))))
40
+ const others = sorted.filter((id) => !NUMBERED_ID.test(id))
41
+ return [...numbered, ...others]
42
+ .reduce<Entry[]>((entries, id) => withId(entries, id, wholes), [])
43
+ .map(entryText)
44
+ .join(RANGE_SEPARATOR)
45
+ }
@@ -176,6 +176,12 @@ export const pullRequestsByBranch = (pullRequests: readonly PullRequest[]): Map<
176
176
  /** The selection key of a pull request row; two parts, so it never equals a three-part phase key. */
177
177
  export const pullRequestKey = (number: number): string => `pr:${number}`
178
178
 
179
+ /**
180
+ * The key of a failed test's line under a pull request, by its place in the list: a click unfolds a cut name and folds
181
+ * it again. No colon and no `#`, so it never reads as a phase or task key.
182
+ */
183
+ export const failedTestKey = (number: number, index: number): string => `failed>${number}>${index}`
184
+
179
185
  /** The link behind a row key, or null when the key is a phase or a pull request no longer open. */
180
186
  export const pullRequestUrl = (pullRequests: readonly PullRequest[], key: string): string | null =>
181
187
  pullRequests.find((pullRequest) => pullRequestKey(pullRequest.number) === key)?.url ?? null
@@ -3,11 +3,10 @@ import { AGENT_STATUS } from '../terminal/types'
3
3
  import { chipUrlOf, isChipKey, planUrlOf } from './link-chips'
4
4
 
5
5
  /**
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.
6
+ * Whether a lane is drawn open. It is decided once, when the lane first appears (`autoOpen`, by its status then), and
7
+ * changes only when a person opens or closes it: a change of status moves neither this nor the order of the lanes.
9
8
  */
10
- export type LaneFold = { status: string | null; open: boolean }
9
+ export type LaneFold = { open: boolean }
11
10
 
12
11
  /**
13
12
  * The row under the cursor and the expanded phases, both as keys, whether ticked phases and items are
@@ -61,7 +60,7 @@ const ESC = '\x1b'
61
60
  const ESCAPE_SEQUENCE = new RegExp(`${ESC}(?:\\[[<0-9;]*[A-Za-z~]|O[A-Za-z])|[\\s\\S]`, 'gu')
62
61
  const MOUSE_REPORT = new RegExp(`^${ESC}\\[<(\\d+);(\\d+);(\\d+)([Mm])$`)
63
62
 
64
- const MOUSE_BUTTON = { left: 0, wheelUp: 64, wheelDown: 65 } as const
63
+ export const MOUSE_BUTTON = { left: 0, wheelUp: 64, wheelDown: 65 } as const
65
64
 
66
65
  /** One terminal read can carry several key presses; this splits it into keys, escape sequences kept whole. */
67
66
  export const splitKeys = (chunk: string): string[] => chunk.match(ESCAPE_SEQUENCE) ?? []
@@ -102,22 +101,19 @@ const laneOfKey = (key: string): string | null => (key.startsWith(LANE_KEY_PREFI
102
101
  export const autoOpen = (status: string | null): boolean =>
103
102
  status !== null && status !== AGENT_STATUS.idle && status !== AGENT_STATUS.done
104
103
 
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
- }
104
+ /** Whether a lane is drawn open: its fold, else, for a lane that has none yet, the automatic rule for its status. */
105
+ export const isLaneOpen = ({ laneFolds }: Selection, lane: string, status: string | null): boolean =>
106
+ laneFolds.get(lane)?.open ?? autoOpen(status)
110
107
 
111
108
  /**
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.
109
+ * Lets the folds follow the lanes: a lane keeps its fold whatever its status does, gets the automatic one only when
110
+ * it is new, and loses it when it left the sprint. Returns the same object when nothing changed.
115
111
  */
116
112
  export const syncLaneFolds = (selection: Selection, lanes: readonly LaneStatusEntry[]): Selection => {
117
113
  const folds = new Map<string, LaneFold>()
118
114
  for (const { lane, status } of lanes) {
119
115
  const fold = selection.laneFolds.get(lane)
120
- folds.set(lane, fold && fold.status === status ? fold : { status, open: autoOpen(status) })
116
+ folds.set(lane, fold ?? { open: autoOpen(status) })
121
117
  }
122
118
  const unchanged =
123
119
  folds.size === selection.laneFolds.size &&
@@ -134,10 +130,10 @@ export const overviewSelection = (): Selection => ({
134
130
  })
135
131
 
136
132
  /** 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 => {
133
+ export const laneSelection = (progress: LaneProgress | undefined): Selection => {
138
134
  const base = overviewSelection()
139
135
  if (!progress) return base
140
- const opened = { ...base, laneFolds: new Map([[progress.lane.name, { status, open: true }]]) }
136
+ const opened = { ...base, laneFolds: new Map([[progress.lane.name, { open: true }]]) }
141
137
  const phase = currentPhase(progress)
142
138
  if (!progress.current || !phase) return opened
143
139
  const key = phaseKey(progress.lane.name, progress.current.stepIndex, phase.id)
@@ -215,6 +211,13 @@ const changeSelected = (selection: Selection, keys: readonly string[], next: (op
215
211
  return { ...selection, selected: current, expanded }
216
212
  }
217
213
 
214
+ /** The selection with `key` expanded when it was folded and folded when it was expanded; nothing else changes. */
215
+ export const toggledExpansion = (selection: Selection, key: string): Selection => {
216
+ const expanded = new Set(selection.expanded)
217
+ if (!expanded.delete(key)) expanded.add(key)
218
+ return { ...selection, expanded }
219
+ }
220
+
218
221
  const expand = (selection: Selection, keys: readonly string[]): Selection => changeSelected(selection, keys, opened)
219
222
 
220
223
  const collapse = (selection: Selection, keys: readonly string[]): Selection => changeSelected(selection, keys, closed)
@@ -0,0 +1,22 @@
1
+ import { join } from 'node:path'
2
+ import { readRecords, writeRecordAtomically } from '../shared/private-records'
3
+ import { isSentFileRecord, type SentFileRecord } from './sent-files'
4
+
5
+ const directoryOf = (root: string): string => join(root, '.jackherd', 'sent')
6
+
7
+ /** 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
+ }
16
+
17
+ /** 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)
20
+
21
+ /** Marks every open record `closedAt` and returns how many it closed. */
22
+ export const closeAllSentRecords = (root: string, now: number): number => closeWhere(root, () => true, now)
@@ -0,0 +1,121 @@
1
+ import { basename } from 'node:path'
2
+ import { twoDigits } from '../plan-format/clock'
3
+ import { terminalSafe } from '../plan-format/terminal-text'
4
+ import type { WaitingItem } from '../plan-format/waiting'
5
+ import { laneDirectoryOf } from './unlisted-lanes'
6
+
7
+ /** The record of a send of files to the user, as the kit hook `sent-file` writes it to `.jackherd/sent/`. */
8
+ export type SentFileRecord = {
9
+ id: string
10
+ files: string[]
11
+ caption: string | null
12
+ sentAt: number
13
+ cwd: string
14
+ session: string
15
+ agentId: string | null
16
+ closedAt: number | null
17
+ }
18
+
19
+ /** A badge for a sent file: a `file:` address and the file's name. */
20
+ export type SentChip = { url: string; label: string }
21
+
22
+ const ID = /^[0-9a-f]{12}$/
23
+ /** The address of a published artifact, as the hook reads it from the tool's answer, as a whole. */
24
+ 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
+ const SHOWN_PER_LANE = 5
29
+
30
+ /** A published artifact is recorded by its address; a sent file by its path. */
31
+ const isAddress = (file: string): boolean => file.startsWith('https://')
32
+
33
+ const addressOf = (file: string): string =>
34
+ isAddress(file) ? file : `file://${file.split('/').map(encodeURIComponent).join('/')}`
35
+
36
+ /** What a badge or a for-you row shows of a file, without the control characters a hand-written record may carry. */
37
+ const labelOf = (file: string, caption: string | null): string =>
38
+ terminalSafe(isAddress(file) ? (caption ?? 'artifact') : basename(file))
39
+
40
+ /** A file entry: an artifact address as a whole, else a path; an https entry of another shape is refused. */
41
+ const isRecordedFile = (file: string): boolean => !isAddress(file) || ARTIFACT_ADDRESS.test(file)
42
+
43
+ const isStringArray = (value: unknown): value is string[] =>
44
+ Array.isArray(value) && value.length > 0 && value.every((entry) => typeof entry === 'string')
45
+
46
+ /** The same shape as `isSentRecord` in the hook, which this package does not import at run time (contract test). */
47
+ export const isSentFileRecord = (value: unknown): value is SentFileRecord => {
48
+ if (typeof value !== 'object' || value === null) return false
49
+ const record = value as Record<string, unknown>
50
+ return (
51
+ typeof record.id === 'string' &&
52
+ ID.test(record.id) &&
53
+ isStringArray(record.files) &&
54
+ record.files.every(isRecordedFile) &&
55
+ (record.caption === null || typeof record.caption === 'string') &&
56
+ typeof record.sentAt === 'number' &&
57
+ typeof record.cwd === 'string' &&
58
+ typeof record.session === 'string' &&
59
+ (record.agentId === null || typeof record.agentId === 'string') &&
60
+ (record.closedAt === null || typeof record.closedAt === 'number')
61
+ )
62
+ }
63
+
64
+ /**
65
+ * The lane a send belongs to: the worktree of the first file below `worktreeDirectory`, else the worktree of the
66
+ * directory it was sent from; null for the coordinator.
67
+ */
68
+ export const sentLaneOf = ({ files, cwd }: SentFileRecord, worktreeDirectory: string): string | null =>
69
+ files
70
+ .map((file) => (isAddress(file) ? null : laneDirectoryOf(file, worktreeDirectory)))
71
+ .find((lane) => lane !== null) ?? laneDirectoryOf(cwd, worktreeDirectory)
72
+
73
+ /** The records without an earlier send of the same first file: an artifact updated each round is one send, the newest. */
74
+ const latestPerFile = (records: readonly SentFileRecord[]): SentFileRecord[] =>
75
+ records.filter((record, index) => !records.slice(index + 1).some((later) => later.files[0] === record.files[0]))
76
+
77
+ /**
78
+ * The badges of the files sent within the last day, by lane (null: the coordinator's), newest first and at most five
79
+ * per lane. A closed send still shows its files: the badge is the way back to them.
80
+ */
81
+ export const sentChipsByLane = (
82
+ records: readonly SentFileRecord[],
83
+ worktreeDirectory: string,
84
+ now: number,
85
+ ): Map<string | null, SentChip[]> => {
86
+ const lanes = new Map<string | null, SentChip[]>()
87
+ const recent = latestPerFile([...records].sort((left, right) => left.sentAt - right.sentAt))
88
+ .filter(({ sentAt }) => now - sentAt <= DAY_MS)
89
+ .sort((left, right) => right.sentAt - left.sentAt)
90
+ for (const record of recent) {
91
+ const lane = sentLaneOf(record, worktreeDirectory)
92
+ const chips = lanes.get(lane) ?? []
93
+ for (const file of record.files) chips.push({ url: addressOf(file), label: labelOf(file, record.caption) })
94
+ lanes.set(lane, chips.slice(0, SHOWN_PER_LANE))
95
+ }
96
+ return lanes
97
+ }
98
+
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
+ const itemOf = (record: SentFileRecord): WaitingItem => {
105
+ const [first] = record.files
106
+ const more = record.files.length > 1 ? ` +${record.files.length - 1}` : ''
107
+ return {
108
+ since: clockText(record.sentAt),
109
+ item: `${labelOf(first, record.caption)}${more}`,
110
+ kind: 'file',
111
+ id: record.id,
112
+ open: true,
113
+ url: addressOf(first),
114
+ }
115
+ }
116
+
117
+ /** What the sends that no one closed wait on: one open item per record younger than three days, oldest first. */
118
+ export const sentWaitingItems = (records: readonly SentFileRecord[], now: number): WaitingItem[] =>
119
+ latestPerFile(records)
120
+ .filter(({ closedAt, sentAt }) => closedAt === null && now - sentAt <= WAITING_DAYS * DAY_MS)
121
+ .map(itemOf)
@@ -0,0 +1,54 @@
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 { readClaudeSignals } from '../task-signals/claude-read'
6
+ import { recordSignals } from '../task-signals/record'
7
+ import { readTaskRecords } from '../task-signals/record-store'
8
+ import type { TaskSignal } from '../task-signals/types'
9
+ import { STALE_ACTIVITY_MINUTES } from './lane-summary'
10
+
11
+ export type SignalSource = {
12
+ /** Claude Code's `projects` directory. */
13
+ projects: string
14
+ /** The main checkout, where the coordinator works and `.jackherd/tasks/` lives. */
15
+ root: string
16
+ /** The directory the lanes' worktrees lie in. */
17
+ worktrees: string
18
+ }
19
+
20
+ const subdirectories = (path: string): string[] => {
21
+ try {
22
+ return readdirSync(path, { withFileTypes: true }).flatMap((entry) =>
23
+ entry.isDirectory() ? [join(path, entry.name)] : [],
24
+ )
25
+ } catch {
26
+ return []
27
+ }
28
+ }
29
+
30
+ const recentSessionLogs = (project: string, notOlderThan: number): string[] => {
31
+ try {
32
+ return readdirSync(project)
33
+ .filter((name) => name.endsWith('.jsonl'))
34
+ .map((name) => join(project, name))
35
+ .filter((path) => statSync(path).mtimeMs >= notOlderThan)
36
+ } catch {
37
+ return []
38
+ }
39
+ }
40
+
41
+ /**
42
+ * What agents report doing, as `TaskSignal`s: the tasks in progress in the recent Claude Code sessions of the main
43
+ * 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
+ */
46
+ export const readAllSignals = ({ projects, root, worktrees }: SignalSource, now: number): TaskSignal[] => {
47
+ const notOlderThan = now - STALE_ACTIVITY_MINUTES * MS_PER_MINUTE
48
+ const claude = [root, ...subdirectories(worktrees)].flatMap((directory) =>
49
+ recentSessionLogs(join(projects, projectDirectoryOf(directory)), notOlderThan).flatMap((path) =>
50
+ readClaudeSignals(path, { notOlderThan }),
51
+ ),
52
+ )
53
+ return [...claude, ...recordSignals(readTaskRecords(root))]
54
+ }