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.
- package/CHANGELOG.md +39 -0
- package/INSTALL.md +2 -0
- package/README.md +41 -60
- package/assets/branding/README.md +5 -7
- package/assets/branding/jackherd-logo-outline.svg +1 -0
- package/assets/branding/outline.test.ts +30 -0
- package/assets/icons/bark.svg +1 -1
- package/assets/icons/bowl.svg +1 -1
- package/assets/icons/decide.svg +1 -1
- package/assets/icons/ear.svg +1 -1
- package/assets/icons/lanes.svg +1 -1
- package/assets/icons/plan.svg +1 -1
- package/dist/{chunk-M6DDZEW4.js → chunk-MCP4XJQH.js} +17 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +19 -3
- package/dist/plan-format.d.ts +22 -3
- package/dist/plan-format.js +21 -7
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/index.ts +22 -4
- package/kit/hooks/hooks/approval-gate/run.ts +29 -6
- package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +70 -21
- package/kit/hooks/hooks/merge-guard/xargs-program.ts +182 -0
- package/kit/hooks/hooks/sent-file/index.ts +19 -0
- package/kit/hooks/hooks/sent-file/record.ts +128 -0
- package/kit/hooks/hooks/sent-file/run.ts +48 -0
- package/kit/hooks/hooks/sent-file/store.ts +18 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
- package/kit/hooks/hooks.json +9 -0
- package/kit/hooks/statusline.sh +1 -1
- package/kit/workflow/skills/implement/SKILL.md +2 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +31 -6
- package/package.json +3 -1
- package/schemas/jackherd.schema.json +26 -2
- package/src/cli.ts +3 -0
- package/src/config/read-config.ts +5 -1
- package/src/config/schema.ts +22 -2
- package/src/context/fill.ts +12 -0
- package/src/context/session-log.ts +43 -1
- package/src/plan-format/clock.ts +4 -0
- package/src/plan-format/dependencies.ts +2 -5
- package/src/plan-format/lanes.ts +30 -5
- package/src/plan-format/terminal-text.ts +4 -0
- package/src/plan-format/waiting.ts +48 -0
- package/src/plans/active-tasks.ts +60 -0
- package/src/plans/current-phase.ts +17 -7
- package/src/plans/current-task.ts +63 -5
- package/src/plans/lane-standing.ts +12 -6
- package/src/plans/lane-summary.ts +141 -74
- package/src/plans/lane-tickets.ts +15 -0
- package/src/plans/layout-phase.ts +57 -18
- package/src/plans/layout-summary.ts +85 -67
- package/src/plans/layout.ts +253 -89
- package/src/plans/live.ts +22 -0
- package/src/plans/phase-models.ts +1 -1
- package/src/plans/phase-ranges.ts +45 -0
- package/src/plans/pull-requests.ts +6 -0
- package/src/plans/selection.ts +19 -16
- package/src/plans/sent-files-read.ts +22 -0
- package/src/plans/sent-files.ts +121 -0
- package/src/plans/signals-read.ts +54 -0
- package/src/plans/snapshot.ts +251 -55
- package/src/plans/subagents-read.ts +82 -0
- package/src/plans/subagents.ts +132 -0
- package/src/plans/texts.ts +16 -11
- package/src/plans/unlisted-lanes.ts +10 -5
- package/src/plans/view.ts +3 -3
- package/src/plans/waiting-click.ts +76 -0
- package/src/preview/command.ts +83 -0
- package/src/preview/embed.ts +209 -0
- package/src/preview/images.ts +89 -0
- package/src/preview/open.ts +12 -0
- package/src/preview/options.ts +32 -0
- package/src/preview/page.ts +89 -0
- package/src/preview/render.ts +41 -0
- package/src/settled/board-row.ts +1 -2
- package/src/shared/is-record.ts +2 -0
- package/src/shared/private-records.ts +48 -0
- package/src/task-signals/agent.ts +15 -0
- package/src/task-signals/claude-read.ts +72 -0
- package/src/task-signals/claude-signals.ts +112 -0
- package/src/task-signals/command.ts +71 -0
- package/src/task-signals/fixtures/session.jsonl +7 -0
- package/src/task-signals/matcher.ts +96 -0
- package/src/task-signals/record-store.ts +40 -0
- package/src/task-signals/record.ts +68 -0
- package/src/task-signals/text.ts +13 -0
- package/src/task-signals/types.ts +14 -0
- package/src/terminal/open.ts +13 -6
- package/src/terminal/text.ts +8 -1
- package/src/terminal/width.ts +2 -1
- package/src/update/command.ts +2 -0
- package/src/update/config-shares.ts +30 -0
- package/src/update/update.ts +12 -1
- package/src/vocabulary/index.ts +33 -1
- package/src/waiting/command.ts +35 -0
- package/src/watch/appointments.ts +5 -5
- package/src/watch/approvals.ts +2 -2
- package/src/watch/command.ts +91 -5
- package/src/watch/failed-tests.ts +56 -10
- package/src/watch/failure-read.ts +18 -6
- package/src/watch/failures.ts +29 -5
- package/src/watch/feedback.ts +1 -1
- package/src/watch/gh-process.ts +9 -3
- package/src/watch/json-guards.ts +0 -3
- package/src/watch/lane-fill.ts +1 -1
- package/src/watch/lane-model.ts +1 -1
- package/src/watch/lane-parallel.ts +1 -1
- package/src/watch/lane-progress.ts +19 -7
- package/src/watch/lane-report.ts +5 -1
- package/src/watch/lane-rest.ts +28 -0
- package/src/watch/plan-badges.ts +9 -2
- package/src/watch/previews.ts +1 -1
- package/src/watch/pull-requests.ts +2 -1
- package/src/watch/source-path.ts +1 -1
- package/src/watch/sources.ts +1 -1
- package/src/watch/stream-output.ts +12 -7
- package/src/watch/task-parallel-input.ts +118 -0
- package/src/watch/task-parallel-read.ts +149 -0
- package/src/watch/task-parallel-touched.ts +39 -0
- package/src/watch/task-parallel.ts +206 -0
- package/src/watch/waiting-line.ts +19 -0
- package/src/watch/watch-events.ts +13 -1
- package/src/watch/workflow-runs.ts +2 -1
- package/assets/icons/bark-dark.svg +0 -1
- package/assets/icons/bowl-dark.svg +0 -1
- package/assets/icons/decide-dark.svg +0 -1
- package/assets/icons/ear-dark.svg +0 -1
- package/assets/icons/lanes-dark.svg +0 -1
- 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
|
package/src/plans/selection.ts
CHANGED
|
@@ -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,
|
|
7
|
-
*
|
|
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 = {
|
|
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:
|
|
106
|
-
export const isLaneOpen = ({ laneFolds }: Selection, lane: string, status: string | null): boolean =>
|
|
107
|
-
|
|
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
|
|
113
|
-
*
|
|
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
|
|
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
|
|
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, {
|
|
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
|
+
}
|