jackherd 1.9.0 → 1.11.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 +49 -0
- package/INSTALL.md +1 -1
- package/README.md +65 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-RMMXD4G5.js} +18 -6
- package/dist/config.d.ts +99 -4
- package/dist/config.js +105 -29
- package/dist/plan-format.d.ts +51 -6
- package/dist/plan-format.js +49 -7
- package/docs/workflow-dark.svg +67 -63
- package/docs/workflow.svg +67 -63
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +15 -0
- package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
- package/kit/hooks/hooks/ask-hint/index.ts +15 -19
- package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
- package/kit/hooks/hooks/git-guard/decision.ts +50 -102
- package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
- package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
- package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
- package/kit/hooks/hooks/merge-guard/index.ts +6 -3
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
- package/kit/hooks/hooks/merge-guard/run.ts +59 -54
- package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
- package/kit/hooks/hooks/open-question/index.ts +26 -0
- package/kit/hooks/hooks/open-question/record.ts +135 -0
- package/kit/hooks/hooks/open-question/run.ts +57 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- package/kit/hooks/hooks/sent-file/store.ts +3 -12
- package/kit/hooks/hooks/shared/config.ts +7 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
- package/kit/hooks/hooks/shared/files.ts +7 -0
- package/kit/hooks/hooks/shared/lane.ts +1 -1
- package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
- package/kit/hooks/hooks/shared/private-directory.ts +56 -9
- package/kit/hooks/hooks/shared/program-position.ts +2 -75
- package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
- package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
- package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
- package/kit/hooks/hooks/task-hint/decision.ts +49 -0
- package/kit/hooks/hooks/task-hint/index.ts +79 -0
- package/kit/hooks/hooks/task-hint/run.ts +62 -0
- package/kit/hooks/hooks/task-hint/store.ts +51 -0
- package/kit/hooks/hooks.json +13 -5
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/rules/rules/kit/pull-requests.md +2 -0
- package/kit/workflow/skills/follow/SKILL.md +3 -10
- package/kit/workflow/skills/implement/SKILL.md +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +218 -433
- package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
- package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
- package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
- package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
- package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
- package/kit/workflow/skills/orchestrate/references/events.md +86 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
- package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
- package/kit/workflow/skills/plan/SKILL.md +6 -5
- package/kit/workflow/skills/plan/references/template-English.md +4 -4
- package/kit/workflow/skills/plan/references/template-German.md +4 -4
- package/package.json +3 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +5 -1
- package/schemas/jackherd.schema.json +139 -5
- package/src/check/agent-text-reviewers.ts +23 -0
- package/src/check/command.ts +2 -0
- package/src/check/plan-structure-check.ts +49 -2
- package/src/check/plan.ts +8 -1
- package/src/check/schema.ts +9 -0
- package/src/check/skill-budget-defaults.ts +55 -0
- package/src/check/skill-budgets.ts +185 -0
- package/src/cli.ts +2 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +82 -25
- package/src/config/tones.ts +27 -0
- package/src/lane/close-board.ts +106 -0
- package/src/lane/close-check.ts +183 -0
- package/src/lane/close-command-line.ts +11 -0
- package/src/lane/close-command.ts +217 -0
- package/src/lane/close-options.ts +43 -0
- package/src/lane/close-survey.ts +83 -0
- package/src/lane/close.ts +141 -0
- package/src/lane/command.ts +13 -0
- package/src/lane-prompt/additions.ts +14 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +93 -0
- package/src/lane-prompt/input.ts +157 -0
- package/src/lane-prompt/options.ts +70 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/mermaid/command.ts +36 -0
- package/src/mermaid/problems.ts +48 -0
- package/src/plan-format/clock.ts +8 -0
- package/src/plan-format/dependencies.ts +9 -6
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +22 -4
- package/src/plans/appointment-rows.ts +83 -0
- package/src/plans/ask-coordinator.ts +111 -0
- package/src/plans/ask-target.ts +36 -0
- package/src/plans/board-close.ts +85 -0
- package/src/plans/child-stop.ts +51 -0
- package/src/plans/closable-records.ts +34 -0
- package/src/plans/command.ts +3 -2
- package/src/plans/fetch.ts +26 -3
- package/src/plans/frame.ts +5 -12
- package/src/plans/git.ts +54 -5
- package/src/plans/groomed.ts +27 -0
- package/src/plans/herd-claim.ts +173 -0
- package/src/plans/herd-memory.ts +36 -25
- package/src/plans/lane-plans.ts +44 -13
- package/src/plans/lane-summary.ts +253 -45
- package/src/plans/layout-phase.ts +36 -11
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +502 -98
- package/src/plans/link-chips.ts +23 -4
- package/src/plans/live.ts +178 -23
- package/src/plans/next-rows.ts +44 -0
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-source.ts +15 -0
- package/src/plans/plan-cache.ts +28 -0
- package/src/plans/plan-heads.ts +54 -0
- package/src/plans/planned.ts +22 -0
- package/src/plans/pull-requests.ts +17 -2
- package/src/plans/question-records.ts +48 -0
- package/src/plans/questions-read.ts +13 -0
- package/src/plans/read-cache.ts +8 -0
- package/src/plans/record-id.ts +4 -0
- package/src/plans/restart-decision.ts +13 -4
- package/src/plans/row-actions.ts +52 -0
- package/src/plans/row-focus.ts +148 -0
- package/src/plans/row-targets.ts +7 -0
- package/src/plans/selection.ts +111 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +12 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +162 -81
- package/src/plans/source-asked.ts +19 -0
- package/src/plans/source-prefix.ts +17 -0
- package/src/plans/source-rows.ts +188 -0
- package/src/plans/sprint-export.ts +11 -11
- package/src/plans/sprint-page.ts +5 -5
- package/src/plans/subagent-phases.ts +95 -0
- package/src/plans/subagent-tasks.ts +51 -0
- package/src/plans/subagents-read.ts +66 -17
- package/src/plans/subagents.ts +64 -6
- package/src/plans/supervisor.ts +14 -4
- package/src/plans/symbols.ts +3 -0
- package/src/plans/task-name.ts +34 -0
- package/src/plans/texts.ts +48 -6
- package/src/plans/tones.ts +30 -0
- package/src/plans/view.ts +25 -11
- package/src/plans/waiting-click.ts +295 -35
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +25 -3
- package/src/shared/private-location.ts +89 -0
- package/src/shared/stat-cache.ts +48 -0
- package/src/sync/kit-source.ts +29 -10
- package/src/sync/kit.ts +2 -0
- package/src/sync/manifest.ts +2 -0
- package/src/sync/plan-ask-rules.ts +94 -0
- package/src/sync/plan-hooks.ts +2 -0
- package/src/sync/render.ts +3 -3
- package/src/sync/settings-hooks.ts +4 -1
- package/src/sync/sync-plan.ts +1 -0
- package/src/sync/sync.ts +24 -3
- package/src/task-signals/claude-read.ts +27 -10
- package/src/terminal/text.ts +16 -3
- package/src/terminal/types.ts +4 -0
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +26 -6
- package/src/waiting/command.ts +22 -7
- package/src/watch/appointments.ts +1 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +330 -105
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-progress.ts +40 -8
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +32 -4
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +22 -9
- package/src/watch/plan-badges.ts +10 -2
- package/src/watch/pull-requests.ts +6 -2
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/report-questions.ts +4 -3
- package/src/watch/running-subagents.ts +43 -0
- package/src/watch/skill-change.ts +124 -32
- package/src/watch/source-change.ts +48 -0
- package/src/watch/source-entries.ts +55 -0
- package/src/watch/source-fields.ts +1 -0
- package/src/watch/source-statuses.ts +60 -0
- package/src/watch/source-tickets.ts +21 -1
- package/src/watch/sources.ts +47 -4
- package/src/watch/stream-output.ts +4 -3
- package/src/watch/task-parallel-auto.ts +208 -0
- package/src/watch/task-parallel-data-paths.ts +46 -0
- package/src/watch/task-parallel-input.ts +5 -15
- package/src/watch/task-parallel-read.ts +3 -1
- package/src/watch/task-parallel.ts +36 -7
- package/src/watch/task-signal-missing.ts +69 -0
- package/src/watch/task-signal-read.ts +29 -0
- package/src/watch/texts.ts +10 -0
- package/src/watch/update-available.ts +6 -3
- package/src/watch/watch-events.ts +23 -0
- package/src/watch/watcher-lock.ts +25 -29
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
- package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
- package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { TONE, type ToneName } from '../config/tones'
|
|
2
|
+
import { padVisible } from '../terminal/text'
|
|
3
|
+
import { textWidth } from '../terminal/width'
|
|
4
|
+
import type { EntryStatus, SourceStatus } from '../watch/source-statuses'
|
|
5
|
+
import { ASK_KIND } from './ask-target'
|
|
6
|
+
import type { AskedCheck } from './source-asked'
|
|
7
|
+
import { chevronOf } from './symbols'
|
|
8
|
+
import type { BlockItem } from './waiting-click'
|
|
9
|
+
|
|
10
|
+
/** A source's status with the tone its config names for the mark while its report is new; null where it names none. */
|
|
11
|
+
export type HerdSource = SourceStatus & { tone?: ToneName | null }
|
|
12
|
+
|
|
13
|
+
/** The sources block: the listed sources, and the clock (epoch milliseconds) their ages are counted against. */
|
|
14
|
+
export type SourceBlock = { sources: HerdSource[]; now: number }
|
|
15
|
+
|
|
16
|
+
/** What decides how the rows of the block look: the keys the user opened, and which entries a row of "Waiting for you" asks about. */
|
|
17
|
+
export type SourceView = { expanded: ReadonlySet<string>; asked: AskedCheck }
|
|
18
|
+
|
|
19
|
+
/** How many entries of an opened source show before `+<n> more`. */
|
|
20
|
+
export const SOURCE_ENTRY_ROWS = 5
|
|
21
|
+
|
|
22
|
+
const SOURCE_MARK = '●'
|
|
23
|
+
const ENTRY_MARK = '•'
|
|
24
|
+
const MORE_MARK = ' '
|
|
25
|
+
const SEPARATOR = ' · '
|
|
26
|
+
const ASKED_TEXT = '↑ asked'
|
|
27
|
+
const MINUTE_MS = 60_000
|
|
28
|
+
const HOUR_MS = 60 * MINUTE_MS
|
|
29
|
+
const DAY_MS = 24 * HOUR_MS
|
|
30
|
+
|
|
31
|
+
/** `just now`, `12 min ago`, `5 h ago` or `2 d ago`: the whole units since `since`, the largest that is at least one. */
|
|
32
|
+
const agoOf = (since: number, now: number): string => {
|
|
33
|
+
const elapsed = Math.max(now - since, 0)
|
|
34
|
+
if (elapsed >= DAY_MS) return `${Math.floor(elapsed / DAY_MS)} d ago`
|
|
35
|
+
if (elapsed >= HOUR_MS) return `${Math.floor(elapsed / HOUR_MS)} h ago`
|
|
36
|
+
return elapsed >= MINUTE_MS ? `${Math.floor(elapsed / MINUTE_MS)} min ago` : 'just now'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ranText = (ranAt: number | null, now: number): string =>
|
|
40
|
+
ranAt === null ? 'not run yet' : `ran ${agoOf(ranAt, now)}`
|
|
41
|
+
|
|
42
|
+
const twoDigits = (value: number): string => String(value).padStart(2, '0')
|
|
43
|
+
|
|
44
|
+
/** `11:11` for a time on the day of `now`, else the day and month before it, in the local time of the terminal. */
|
|
45
|
+
const clockOf = (at: number, now: number): string => {
|
|
46
|
+
const date = new Date(at)
|
|
47
|
+
const time = `${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`
|
|
48
|
+
return date.toDateString() === new Date(now).toDateString()
|
|
49
|
+
? time
|
|
50
|
+
: `${twoDigits(date.getDate())}.${twoDigits(date.getMonth() + 1)}. ${time}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const sourceKey = ({ name }: SourceStatus): string => `source>${name}`
|
|
54
|
+
|
|
55
|
+
const entryKey = (name: string, { id }: EntryStatus): string => `source>${name}>${id}`
|
|
56
|
+
|
|
57
|
+
/** The key in the herd's expanded set that shows every entry of the source, not the first five. */
|
|
58
|
+
export const sourceMoreKey = (name: string): string => `more>source>${name}`
|
|
59
|
+
|
|
60
|
+
const isUnaskedNew = (name: string, entry: EntryStatus, asked: AskedCheck): boolean =>
|
|
61
|
+
entry.isNew && !asked(name, entry.title)
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Red while the source fails; else the source's own tone or `newTone` while it has a new entry nobody asked about (for a
|
|
65
|
+
* source without entries: while its newest report is new); none otherwise.
|
|
66
|
+
*/
|
|
67
|
+
const toneOf = (source: HerdSource, newTone: ToneName, asked: AskedCheck): ToneName | undefined => {
|
|
68
|
+
if (source.failingSince !== null) return TONE.red
|
|
69
|
+
const hasNew =
|
|
70
|
+
source.entries.length > 0
|
|
71
|
+
? source.entries.some((entry) => isUnaskedNew(source.name, entry, asked))
|
|
72
|
+
: source.latest?.isNew === true
|
|
73
|
+
return hasNew ? (source.tone ?? newTone) : undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const textOf = (source: HerdSource, now: number, expanded: boolean): string => {
|
|
77
|
+
const chevron = source.entries.length > 0 ? `${chevronOf(expanded)} ` : ''
|
|
78
|
+
const parts = [
|
|
79
|
+
...(source.failingSince === null ? [] : [`failing since ${agoOf(source.failingSince, now)}`]),
|
|
80
|
+
ranText(source.ranAt, now),
|
|
81
|
+
...(source.latest && source.entries.length === 0 ? [source.latest.title] : []),
|
|
82
|
+
]
|
|
83
|
+
return `${chevron}${parts.join(SEPARATOR)}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const headerOf = (
|
|
87
|
+
source: HerdSource,
|
|
88
|
+
view: SourceView,
|
|
89
|
+
now: number,
|
|
90
|
+
newTone: ToneName,
|
|
91
|
+
nameWidth: number,
|
|
92
|
+
): BlockItem => {
|
|
93
|
+
const key = sourceKey(source)
|
|
94
|
+
const hasEntries = source.entries.length > 0
|
|
95
|
+
return {
|
|
96
|
+
key,
|
|
97
|
+
mark: SOURCE_MARK,
|
|
98
|
+
lead: padVisible(source.name, nameWidth),
|
|
99
|
+
text: textOf(source, now, view.expanded.has(key)),
|
|
100
|
+
url: hasEntries ? null : (source.latest?.url ?? null),
|
|
101
|
+
ask: null,
|
|
102
|
+
tone: toneOf(source, newTone, view.asked),
|
|
103
|
+
...(hasEntries ? { toggle: key } : {}),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const entryToneOf = (
|
|
108
|
+
isAsked: boolean,
|
|
109
|
+
entry: EntryStatus,
|
|
110
|
+
source: HerdSource,
|
|
111
|
+
newTone: ToneName,
|
|
112
|
+
): ToneName | undefined => {
|
|
113
|
+
if (isAsked) return TONE.grey
|
|
114
|
+
return entry.isNew ? (source.tone ?? newTone) : undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const entryTextOf = (entry: EntryStatus, now: number, isAsked: boolean): string =>
|
|
118
|
+
[entry.count === null ? '' : `${entry.count}×`, clockOf(entry.at, now), isAsked ? ASKED_TEXT : '']
|
|
119
|
+
.filter((part) => part !== '')
|
|
120
|
+
.join(' ')
|
|
121
|
+
|
|
122
|
+
const entryItem = (
|
|
123
|
+
source: HerdSource,
|
|
124
|
+
entry: EntryStatus,
|
|
125
|
+
view: SourceView,
|
|
126
|
+
now: number,
|
|
127
|
+
newTone: ToneName,
|
|
128
|
+
titleWidth: number,
|
|
129
|
+
): BlockItem => {
|
|
130
|
+
const isAsked = view.asked(source.name, entry.title)
|
|
131
|
+
const tone = entryToneOf(isAsked, entry, source, newTone)
|
|
132
|
+
return {
|
|
133
|
+
key: entryKey(source.name, entry),
|
|
134
|
+
mark: ENTRY_MARK,
|
|
135
|
+
lead: padVisible(entry.title, titleWidth),
|
|
136
|
+
text: entryTextOf(entry, now, isAsked),
|
|
137
|
+
url: entry.url,
|
|
138
|
+
ask: { kind: ASK_KIND.sourceEntry, source: source.name, title: entry.title },
|
|
139
|
+
...(tone && { tone }),
|
|
140
|
+
...(isAsked && { dim: true }),
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const moreItem = (name: string, hidden: number, showsAll: boolean): BlockItem => ({
|
|
145
|
+
key: sourceMoreKey(name),
|
|
146
|
+
mark: MORE_MARK,
|
|
147
|
+
text: showsAll ? '▴ fewer' : `+${hidden} more`,
|
|
148
|
+
url: null,
|
|
149
|
+
ask: null,
|
|
150
|
+
toggle: sourceMoreKey(name),
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const entryItems = (source: HerdSource, view: SourceView, now: number, newTone: ToneName): BlockItem[] => {
|
|
154
|
+
const showsAll = view.expanded.has(sourceMoreKey(source.name))
|
|
155
|
+
const shown = showsAll ? source.entries : source.entries.slice(0, SOURCE_ENTRY_ROWS)
|
|
156
|
+
const titleWidth = Math.max(0, ...shown.map(({ title }) => textWidth(title)))
|
|
157
|
+
const hidden = source.entries.length - SOURCE_ENTRY_ROWS
|
|
158
|
+
return [
|
|
159
|
+
...shown.map((entry) => entryItem(source, entry, view, now, newTone, titleWidth)),
|
|
160
|
+
...(hidden > 0 ? [moreItem(source.name, hidden, showsAll)] : []),
|
|
161
|
+
]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const DEFAULT_VIEW: SourceView = { expanded: new Set(), asked: () => false }
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The rows of the sources block: per source one row with its name, when it ran, since when it fails and, for a source
|
|
168
|
+
* without entries, its newest report. A source with entries is folded to that row, and a click on it opens its entries
|
|
169
|
+
* (`view.expanded` holds its key): each with title, count and time, at most five and then `+<n> more`, a click on an
|
|
170
|
+
* entry opens its address, `[ask]` asks the coordinator to look at exactly that entry. An entry that a row of
|
|
171
|
+
* "Waiting for you" already asks about is dim and says `↑ asked`, and it does not make the source amber.
|
|
172
|
+
* `newTone` is the tone of the role `new`, which a source without its own tone wears while it has something new.
|
|
173
|
+
*/
|
|
174
|
+
export const sourceItems = (
|
|
175
|
+
sources: readonly HerdSource[],
|
|
176
|
+
now: number,
|
|
177
|
+
newTone: ToneName,
|
|
178
|
+
view: SourceView = DEFAULT_VIEW,
|
|
179
|
+
): BlockItem[] => {
|
|
180
|
+
const nameWidth = Math.max(0, ...sources.map(({ name }) => textWidth(name)))
|
|
181
|
+
return sources.flatMap((source) => [
|
|
182
|
+
headerOf(source, view, now, newTone, nameWidth),
|
|
183
|
+
...(view.expanded.has(sourceKey(source)) ? entryItems(source, view, now, newTone) : []),
|
|
184
|
+
])
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The keys of all rows: what the fold of the block remembers. */
|
|
188
|
+
export const sourceKeys = (sources: readonly SourceStatus[]): string[] => sources.map(sourceKey)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import { tmpdir } from 'node:os'
|
|
3
1
|
import { dirname } from 'node:path'
|
|
4
2
|
import { pathToFileURL } from 'node:url'
|
|
3
|
+
import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
|
|
4
|
+
import { pageDirectoryOf, systemLocation } from '../shared/private-location'
|
|
5
5
|
import { openInBrowser } from '../terminal/open'
|
|
6
6
|
import { errorText } from './error-text'
|
|
7
7
|
import { baseRefInfo, remoteBlobBase } from './git'
|
|
@@ -11,19 +11,19 @@ import { renderPlanBody, renderSprintHtml } from './sprint-html'
|
|
|
11
11
|
import { buildSprintPage, type SprintPage, sprintPagePath } from './sprint-page'
|
|
12
12
|
import { boardTexts } from './texts'
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
/**
|
|
15
|
+
* What writing and opening the page needs from outside; the defaults are the real ones. `pageDirectory` is the
|
|
16
|
+
* user's own directory the page goes into, verified private before anything is written.
|
|
17
|
+
*/
|
|
18
18
|
export type ExportEnvironment = {
|
|
19
19
|
now: () => Date
|
|
20
|
-
|
|
20
|
+
pageDirectory: string
|
|
21
21
|
open: (target: string) => string | null
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
const systemEnvironment = (): ExportEnvironment => ({
|
|
25
25
|
now: () => new Date(),
|
|
26
|
-
|
|
26
|
+
pageDirectory: pageDirectoryOf(systemLocation()),
|
|
27
27
|
open: (target) => openInBrowser(target),
|
|
28
28
|
})
|
|
29
29
|
|
|
@@ -46,10 +46,10 @@ const writePage = (
|
|
|
46
46
|
{ sprintFile, root, settings }: Source,
|
|
47
47
|
environment: ExportEnvironment,
|
|
48
48
|
): WrittenPage | { error: string } => {
|
|
49
|
-
const path = sprintPagePath(environment.
|
|
49
|
+
const path = sprintPagePath(environment.pageDirectory, dirname(boardRepoPathOf(root, settings, sprintFile)))
|
|
50
50
|
try {
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
ensurePrivateDirectory(environment.pageDirectory)
|
|
52
|
+
writePrivateFile(path, renderSprintHtml(page))
|
|
53
53
|
} catch (error) {
|
|
54
54
|
return { error: errorText(error) }
|
|
55
55
|
}
|
package/src/plans/sprint-page.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { PlanLanguage } from '../vocabulary'
|
|
|
16
16
|
import type { LaneView } from './layout'
|
|
17
17
|
import { extractLinkChips, type LinkChip } from './link-chips'
|
|
18
18
|
import { planAnchorOf } from './markdown-html'
|
|
19
|
+
import { mainPhaseIdOf } from './phase-ids'
|
|
19
20
|
import type { PullRequest } from './pull-requests'
|
|
20
21
|
import type { Snapshot } from './snapshot'
|
|
21
22
|
|
|
@@ -150,14 +151,13 @@ const uniqueId = (base: string, taken: Set<string>): string => {
|
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
const phaseOf = (phase: PhaseProgress, dependencies: Record<string, string>): PagePhase => {
|
|
153
|
-
const numbered = phase.id.replace(/[a-z]$/, '')
|
|
154
154
|
return {
|
|
155
155
|
id: phase.id,
|
|
156
156
|
title: phase.title,
|
|
157
157
|
done: phase.done,
|
|
158
158
|
total: phase.total,
|
|
159
159
|
items: phase.items,
|
|
160
|
-
dependsOn: dependencies[phase.id] ?? dependencies[
|
|
160
|
+
dependsOn: dependencies[phase.id] ?? dependencies[mainPhaseIdOf(phase.id)] ?? null,
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
|
|
@@ -282,6 +282,6 @@ export const buildSprintPage = (snapshot: Snapshot, context: PageContext): Sprin
|
|
|
282
282
|
}
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
-
/** The file the page is written to: `<
|
|
286
|
-
export const sprintPagePath = (
|
|
287
|
-
join(
|
|
285
|
+
/** The file the page is written to: `<page directory>/<board directory>.html`. */
|
|
286
|
+
export const sprintPagePath = (pageDirectory: string, sprintDirectory: string): string =>
|
|
287
|
+
join(pageDirectory, `${posix.basename(sprintDirectory) || 'board'}.html`)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { isPhaseOpen, type PlanPhase } from '../plan-format'
|
|
2
|
+
import { PHASE_ID_PREFIX, spannedPhaseIds } from '../plan-format/lanes'
|
|
3
|
+
import { isNamedBy } from './phase-ids'
|
|
4
|
+
|
|
5
|
+
/** The agent types that review a phase; they sit on its review gate. */
|
|
6
|
+
export const REVIEW_TYPE = {
|
|
7
|
+
uncleBob: 'uncle-bob',
|
|
8
|
+
specReview: 'spec-review',
|
|
9
|
+
securityAudit: 'security-audit',
|
|
10
|
+
review: 'review',
|
|
11
|
+
ive: 'ive',
|
|
12
|
+
jobs: 'jobs',
|
|
13
|
+
} as const
|
|
14
|
+
|
|
15
|
+
export type ReviewType = (typeof REVIEW_TYPE)[keyof typeof REVIEW_TYPE]
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The phases a running subagent names in its description, its review type (null for no review), and when its log was
|
|
19
|
+
* last written. `ids` is empty only for a review that names no phase: the lane places it (`PhaselessReview`).
|
|
20
|
+
*/
|
|
21
|
+
export type SubagentPhaseRun = { ids: string[]; review: ReviewType | null; at: number }
|
|
22
|
+
|
|
23
|
+
/** A running review whose description names no phase: it sits on the lane's signalled phase, else shows as its description. */
|
|
24
|
+
export type PhaselessReview = { review: ReviewType; at: number; description: string | null }
|
|
25
|
+
|
|
26
|
+
/** What the subagents say about one phase of the lane: the types of the reviews among them, and the newest log write. */
|
|
27
|
+
export type SubagentPhase = { reviews: ReviewType[]; at: number }
|
|
28
|
+
|
|
29
|
+
const isReviewTypeName = (type: string): type is ReviewType => (Object.values(REVIEW_TYPE) as string[]).includes(type)
|
|
30
|
+
|
|
31
|
+
/** `P21`, `P17-P20`, `P17 – 19`, `Phase 21`, `Phase 10c`: the first number, an optional sub-phase letter, an optional end. */
|
|
32
|
+
const PHASE_MENTION = new RegExp(
|
|
33
|
+
`(?:\\b${PHASE_ID_PREFIX}|\\bPhase\\s+)(\\d+)([a-z]?)(?:\\s*[-–—]\\s*(?:${PHASE_ID_PREFIX})?(\\d+)\\b)?`,
|
|
34
|
+
'gi',
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
/** The ids of a range `first`-`last`; a range that runs backwards or is absurdly wide names only its first end. */
|
|
38
|
+
const rangeIds = (first: number, last: number): string[] => {
|
|
39
|
+
const spanned = spannedPhaseIds(first, last)
|
|
40
|
+
return spanned.length > 0 ? spanned : [String(first)]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The ids of the phases a description names, in the order named; a range names every phase between its ends. */
|
|
44
|
+
export const phaseIdsOfDescription = (description: string): string[] =>
|
|
45
|
+
[...description.matchAll(PHASE_MENTION)].flatMap(([, first = '', letter = '', last]) =>
|
|
46
|
+
last === undefined ? [`${Number(first)}${letter.toLowerCase()}`] : rangeIds(Number(first), Number(last)),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const withoutPrefix = (type: string, prefixes: readonly string[]): string => {
|
|
50
|
+
const prefix = prefixes.find((each) => each !== '' && type.startsWith(each))
|
|
51
|
+
return prefix === undefined ? type : type.slice(prefix.length)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The `REVIEW_TYPE` an agent type is, plain or with one of the skill `prefixes` of a copy; null for any other type. */
|
|
55
|
+
export const reviewTypeOf = (type: string | null, prefixes: readonly string[]): ReviewType | null => {
|
|
56
|
+
const plain = type === null ? null : withoutPrefix(type, prefixes)
|
|
57
|
+
return plain !== null && isReviewTypeName(plain) ? plain : null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The run of a subagent from its `description` and `agentType`: null when the description names no phase and the
|
|
62
|
+
* subagent is no review. The description is read whole, not cut for the display.
|
|
63
|
+
*/
|
|
64
|
+
export const subagentPhaseRunOf = (
|
|
65
|
+
description: string | null,
|
|
66
|
+
agentType: string | null,
|
|
67
|
+
at: number,
|
|
68
|
+
reviewPrefixes: readonly string[],
|
|
69
|
+
): SubagentPhaseRun | null => {
|
|
70
|
+
const ids = description === null ? [] : phaseIdsOfDescription(description)
|
|
71
|
+
const review = reviewTypeOf(agentType, reviewPrefixes)
|
|
72
|
+
return ids.length === 0 && review === null ? null : { ids, review, at }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const names = (run: SubagentPhaseRun, id: string): boolean => isNamedBy(id, new Set(run.ids))
|
|
76
|
+
|
|
77
|
+
const reviewsOf = (runs: readonly SubagentPhaseRun[]): ReviewType[] => [
|
|
78
|
+
...new Set(runs.flatMap(({ review }) => (review === null ? [] : [review]))),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The open phases of the lane that running subagents name, by phase id: a sub-phase (`21b`) is named by its main phase.
|
|
83
|
+
* Phases the lane does not hold are left out, since another lane's phases are not its own.
|
|
84
|
+
*/
|
|
85
|
+
export const subagentPhases = (
|
|
86
|
+
phases: readonly Pick<PlanPhase, 'id' | 'items'>[],
|
|
87
|
+
runs: readonly SubagentPhaseRun[],
|
|
88
|
+
): Map<string, SubagentPhase> =>
|
|
89
|
+
new Map(
|
|
90
|
+
phases.filter(isPhaseOpen).flatMap(({ id }) => {
|
|
91
|
+
const naming = runs.filter((run) => names(run, id))
|
|
92
|
+
if (naming.length === 0) return []
|
|
93
|
+
return [[id, { reviews: reviewsOf(naming), at: Math.max(...naming.map(({ at }) => at)) }]]
|
|
94
|
+
}),
|
|
95
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ActiveTask } from './active-tasks'
|
|
2
|
+
import type { TaskPhase } from './current-task'
|
|
3
|
+
import { taskNameOf } from './task-name'
|
|
4
|
+
|
|
5
|
+
/** A running subagent that is no review: its description as the herd shows it, and when its log was last written. */
|
|
6
|
+
export type DescribedSubagent = { description: string; at: number }
|
|
7
|
+
|
|
8
|
+
/** Where the subagents sit: their tasks by phase id, and the descriptions placed, which show no line of their own. */
|
|
9
|
+
export type SubagentTaskPlacements = { byPhase: Map<string, ActiveTask[]>; placed: string[] }
|
|
10
|
+
|
|
11
|
+
type NamedTask = { phaseId: string; index: number; name: string }
|
|
12
|
+
|
|
13
|
+
/** A letter or digit right after the name continues a word, so `Probe` does not name `Proben lesen`. */
|
|
14
|
+
const WORD_GOES_ON = /^[\p{L}\p{N}]/u
|
|
15
|
+
|
|
16
|
+
/** The open tasks that carry a short name, the longest names first, so a name that starts another loses to it. */
|
|
17
|
+
const namedOpenTasks = (phases: readonly TaskPhase[]): NamedTask[] =>
|
|
18
|
+
phases
|
|
19
|
+
.flatMap(({ id, items }) =>
|
|
20
|
+
items.flatMap((item, index) => {
|
|
21
|
+
const name = item.done || item.gate ? null : taskNameOf(item.text)
|
|
22
|
+
return name === null ? [] : [{ phaseId: id, index, name }]
|
|
23
|
+
}),
|
|
24
|
+
)
|
|
25
|
+
.sort((first, second) => second.name.length - first.name.length)
|
|
26
|
+
|
|
27
|
+
const startsWithName = (description: string, name: string): boolean =>
|
|
28
|
+
description.toLowerCase().startsWith(name.toLowerCase()) && !WORD_GOES_ON.test(description.slice(name.length))
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The open task each subagent works on, from the short name its description starts with (`/implement` Step 0b, case
|
|
32
|
+
* ignored): it gets its `▶` like a reported task, one per task however many subagents share it. A ticked task and a
|
|
33
|
+
* review gate item take none; a subagent whose description starts with no name stays where it was.
|
|
34
|
+
*/
|
|
35
|
+
export const subagentTaskPlacements = (
|
|
36
|
+
phases: readonly TaskPhase[],
|
|
37
|
+
subagents: readonly DescribedSubagent[],
|
|
38
|
+
): SubagentTaskPlacements => {
|
|
39
|
+
const tasks = namedOpenTasks(phases)
|
|
40
|
+
const byPhase = new Map<string, ActiveTask[]>()
|
|
41
|
+
const placed: string[] = []
|
|
42
|
+
for (const { description, at } of subagents) {
|
|
43
|
+
const task = tasks.find(({ name }) => startsWithName(description, name))
|
|
44
|
+
if (task === undefined) continue
|
|
45
|
+
placed.push(description)
|
|
46
|
+
const known = byPhase.get(task.phaseId) ?? []
|
|
47
|
+
if (known.some(({ index }) => index === task.index)) continue
|
|
48
|
+
byPhase.set(task.phaseId, [...known, { index: task.index, agent: null, guessed: false, startedAt: at }])
|
|
49
|
+
}
|
|
50
|
+
return { byPhase, placed }
|
|
51
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import { projectDirectoryOf, readTailLines } from '../context/session-log'
|
|
3
|
+
import { defaultProjectsDirectory, projectDirectoryOf, readTailLines } from '../context/session-log'
|
|
4
4
|
import { MS_PER_MINUTE } from '../plan-format/clock'
|
|
5
|
+
import { DEFAULT_SKILL_PREFIX } from '../sync/init'
|
|
5
6
|
import { readApprovalRequests } from '../watch/approvals'
|
|
6
7
|
import {
|
|
8
|
+
type RunningSubagent,
|
|
7
9
|
runningSubagents,
|
|
8
10
|
SUBAGENT_STALE_MINUTES,
|
|
9
11
|
type SubagentActivity,
|
|
@@ -19,8 +21,19 @@ export type SubagentSource = {
|
|
|
19
21
|
approvalsRoot: string
|
|
20
22
|
/** The absolute worktree directory. */
|
|
21
23
|
worktreeDirectory: string
|
|
24
|
+
/** The project's `skillPrefix`, which its copies of the review agents carry; without it only the default one counts. */
|
|
25
|
+
skillPrefix?: string
|
|
22
26
|
}
|
|
23
27
|
|
|
28
|
+
/** The source of the main checkout `root`: Claude Code's own session logs, the gate's requests in the checkout itself. */
|
|
29
|
+
export const subagentSourceOf = (root: string, worktreeDirectory: string, skillPrefix?: string): SubagentSource => ({
|
|
30
|
+
projects: defaultProjectsDirectory(),
|
|
31
|
+
root,
|
|
32
|
+
approvalsRoot: root,
|
|
33
|
+
worktreeDirectory,
|
|
34
|
+
skillPrefix,
|
|
35
|
+
})
|
|
36
|
+
|
|
24
37
|
/** The end of a log that holds the directory of the last entries. */
|
|
25
38
|
const TAIL_BYTES = 64 * 1024
|
|
26
39
|
const META_FILE = /^agent-([0-9a-f]+)\.meta\.json$/
|
|
@@ -35,6 +48,15 @@ const directoriesOf = (path: string): string[] => {
|
|
|
35
48
|
}
|
|
36
49
|
}
|
|
37
50
|
|
|
51
|
+
/** The entry names of `path`; none when it is missing or cannot be read. */
|
|
52
|
+
const namesIn = (path: string): string[] => {
|
|
53
|
+
try {
|
|
54
|
+
return readdirSync(path)
|
|
55
|
+
} catch {
|
|
56
|
+
return []
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
38
60
|
const logOf = (directory: string, name: string, id: string, now: number): SubagentLog[] => {
|
|
39
61
|
try {
|
|
40
62
|
const logPath = join(directory, `agent-${id}.jsonl`)
|
|
@@ -47,22 +69,39 @@ const logOf = (directory: string, name: string, id: string, now: number): Subage
|
|
|
47
69
|
}
|
|
48
70
|
}
|
|
49
71
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
*/
|
|
54
|
-
export const readSubagentLogs = (projects: string, root: string, now: number): SubagentLog[] => {
|
|
55
|
-
const project = join(projects, projectDirectoryOf(root))
|
|
56
|
-
return directoriesOf(project).flatMap((session) => {
|
|
72
|
+
/** The subagent logs of every session in one project directory. */
|
|
73
|
+
const sessionLogsOf = (project: string, now: number): SubagentLog[] =>
|
|
74
|
+
directoriesOf(project).flatMap((session) => {
|
|
57
75
|
const directory = join(project, session, 'subagents')
|
|
58
|
-
|
|
59
|
-
return readdirSync(directory).flatMap((name) => {
|
|
76
|
+
return namesIn(directory).flatMap((name) => {
|
|
60
77
|
const id = META_FILE.exec(name)?.[1]
|
|
61
78
|
return id ? logOf(directory, name, id, now) : []
|
|
62
79
|
})
|
|
63
80
|
})
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The project directories Claude Code keeps for sessions started in the main checkout `root` and, when
|
|
84
|
+
* `worktreeDirectory` is given, in a worktree below it: a lane's own subagents, its reviews among them, live there.
|
|
85
|
+
*/
|
|
86
|
+
const projectDirectoriesOf = (projects: string, root: string, worktreeDirectory?: string): string[] => {
|
|
87
|
+
const main = projectDirectoryOf(root)
|
|
88
|
+
const lanes = worktreeDirectory === undefined ? null : `${projectDirectoryOf(worktreeDirectory)}-`
|
|
89
|
+
return directoriesOf(projects).filter((name) => name === main || (lanes !== null && name.startsWith(lanes)))
|
|
64
90
|
}
|
|
65
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The subagents with a recently written log in the sessions started in the main checkout `root` and its worktrees,
|
|
94
|
+
* from Claude Code's `<projects>/<project>/<session>/subagents/agent-<id>.{meta.json,jsonl}`; whatever cannot be read
|
|
95
|
+
* is left out.
|
|
96
|
+
*/
|
|
97
|
+
export const readSubagentLogs = (
|
|
98
|
+
projects: string,
|
|
99
|
+
root: string,
|
|
100
|
+
now: number,
|
|
101
|
+
worktreeDirectory?: string,
|
|
102
|
+
): SubagentLog[] =>
|
|
103
|
+
projectDirectoriesOf(projects, root, worktreeDirectory).flatMap((name) => sessionLogsOf(join(projects, name), now))
|
|
104
|
+
|
|
66
105
|
/** The names the gate holds an open request under, as the watcher reads them. */
|
|
67
106
|
const heldNames = (root: string, now: number): Set<string> =>
|
|
68
107
|
new Set(
|
|
@@ -71,12 +110,22 @@ const heldNames = (root: string, now: number): Set<string> =>
|
|
|
71
110
|
.map((request) => request.lane),
|
|
72
111
|
)
|
|
73
112
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
113
|
+
/**
|
|
114
|
+
* The subagents at work in the sessions of the main checkout and its worktrees: their logs read, the finished and the
|
|
115
|
+
* stale left out. The one chain the herd (`readSubagentActivity`) and the watcher's heartbeat share.
|
|
116
|
+
*/
|
|
117
|
+
export const readRunningSubagents = (
|
|
118
|
+
{ projects, root, approvalsRoot, worktreeDirectory, skillPrefix }: SubagentSource,
|
|
77
119
|
now: number = Date.now(),
|
|
78
|
-
):
|
|
79
|
-
|
|
80
|
-
|
|
120
|
+
): RunningSubagent[] =>
|
|
121
|
+
runningSubagents(
|
|
122
|
+
readSubagentLogs(projects, root, now, worktreeDirectory),
|
|
123
|
+
heldNames(approvalsRoot, now),
|
|
124
|
+
now,
|
|
81
125
|
worktreeDirectory,
|
|
126
|
+
skillPrefix === undefined ? [DEFAULT_SKILL_PREFIX] : [DEFAULT_SKILL_PREFIX, skillPrefix],
|
|
82
127
|
)
|
|
128
|
+
|
|
129
|
+
/** The state of every worktree a subagent of the main checkout's sessions works in. */
|
|
130
|
+
export const readSubagentActivity = (source: SubagentSource, now: number = Date.now()): Map<string, SubagentActivity> =>
|
|
131
|
+
subagentsByLane(readRunningSubagents(source, now), source.worktreeDirectory)
|
package/src/plans/subagents.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { terminalSafe } from '../plan-format/terminal-text'
|
|
|
4
4
|
import { isRecord } from '../shared/is-record'
|
|
5
5
|
import { AGENT_STATUS, type AgentStatus } from '../terminal/types'
|
|
6
6
|
import { activityTextOf } from './current-task'
|
|
7
|
+
import { type PhaselessReview, type SubagentPhaseRun, subagentPhaseRunOf } from './subagent-phases'
|
|
8
|
+
import type { DescribedSubagent } from './subagent-tasks'
|
|
7
9
|
import { laneDirectoryOf } from './unlisted-lanes'
|
|
8
10
|
|
|
9
11
|
/** What the log files of one subagent say: its id, its `meta.json` as parsed, when the log was written, its last lines. */
|
|
@@ -17,10 +19,25 @@ export type RunningSubagent = {
|
|
|
17
19
|
status: Extract<AgentStatus, 'working' | 'blocked'>
|
|
18
20
|
/** The text of its last log entries, from which the herd tells the task it works on (`taskSignalsOf`). */
|
|
19
21
|
activity: string
|
|
22
|
+
/** The phases its full description names (`P21`, `P17-P20`), none for a review that names none; null for any other. */
|
|
23
|
+
phaseRun: SubagentPhaseRun | null
|
|
24
|
+
/** When its log was last written. */
|
|
25
|
+
writtenAt: number
|
|
20
26
|
}
|
|
21
27
|
|
|
22
|
-
/** What a worktree shows of the subagents working in it. */
|
|
23
|
-
export type SubagentActivity = Pick<RunningSubagent, 'status' | 'description' | 'activity'>
|
|
28
|
+
/** What a worktree shows of the subagents working in it; `phaseRuns` are those of all its subagents that name a phase. */
|
|
29
|
+
export type SubagentActivity = Pick<RunningSubagent, 'status' | 'description' | 'activity'> & {
|
|
30
|
+
phaseRuns: SubagentPhaseRun[]
|
|
31
|
+
/** Its reviews that name no phase; the lane places them on its signalled phase (`laneSummary`). */
|
|
32
|
+
phaselessReviews: PhaselessReview[]
|
|
33
|
+
/**
|
|
34
|
+
* The descriptions of its other subagents that name no phase: what they work on, a signal of the lane like a
|
|
35
|
+
* reported task.
|
|
36
|
+
*/
|
|
37
|
+
taskDescriptions: string[]
|
|
38
|
+
/** Its subagents that are no review, phase or not; one whose description starts with a task's short name sits on it. */
|
|
39
|
+
describedSubagents: DescribedSubagent[]
|
|
40
|
+
}
|
|
24
41
|
|
|
25
42
|
/**
|
|
26
43
|
* A log untouched this long is not at work: Claude Code marks a finished subagent with `finished: true` in its
|
|
@@ -85,18 +102,21 @@ const workingDirectory = (tail: readonly string[], worktreeDirectory: string): s
|
|
|
85
102
|
/**
|
|
86
103
|
* The subagents at work: no `finished` mark in the meta, and the log written within `SUBAGENT_STALE_MINUTES`. One
|
|
87
104
|
* waits for an answer while the approval gate holds a request under its name (`openRequests` are the names of the open
|
|
88
|
-
* requests); the transcript alone cannot tell a permission dialog from a long tool call.
|
|
105
|
+
* requests); the transcript alone cannot tell a permission dialog from a long tool call. `reviewPrefixes` are the skill
|
|
106
|
+
* prefixes a copy of the review agents may carry.
|
|
89
107
|
*/
|
|
90
108
|
export const runningSubagents = (
|
|
91
109
|
logs: readonly SubagentLog[],
|
|
92
110
|
openRequests: ReadonlySet<string>,
|
|
93
111
|
now: number,
|
|
94
112
|
worktreeDirectory: string,
|
|
113
|
+
reviewPrefixes: readonly string[],
|
|
95
114
|
): RunningSubagent[] =>
|
|
96
115
|
logs.flatMap(({ id, meta, modifiedAt, tail }) => {
|
|
97
116
|
if (!isRecord(meta) || meta.finished === true || now - modifiedAt > SUBAGENT_STALE_MINUTES * MS_PER_MINUTE)
|
|
98
117
|
return []
|
|
99
|
-
const
|
|
118
|
+
const agentType = textOrNull(meta.agentType)
|
|
119
|
+
const label = subagentLabelOf(id, agentType)
|
|
100
120
|
if (label === null) return []
|
|
101
121
|
const description = textOrNull(typeof meta.description === 'string' ? terminalSafe(meta.description) : null)
|
|
102
122
|
const shortDescription = description?.slice(0, DESCRIPTION_LENGTH) ?? null
|
|
@@ -107,25 +127,63 @@ export const runningSubagents = (
|
|
|
107
127
|
description: shortDescription,
|
|
108
128
|
status: openRequests.has(label) ? AGENT_STATUS.blocked : AGENT_STATUS.working,
|
|
109
129
|
activity: activityTextOf(tail, ACTIVITY_ENTRIES),
|
|
130
|
+
phaseRun: subagentPhaseRunOf(description, agentType, modifiedAt, reviewPrefixes),
|
|
131
|
+
writtenAt: modifiedAt,
|
|
110
132
|
},
|
|
111
133
|
]
|
|
112
134
|
})
|
|
113
135
|
|
|
136
|
+
/** The description of a subagent that names no phase, as a list of at most one; empty for a review or one that names a phase. */
|
|
137
|
+
const taskDescriptionOf = ({ phaseRun, description }: RunningSubagent): string[] =>
|
|
138
|
+
phaseRun === null && description !== null ? [description] : []
|
|
139
|
+
|
|
140
|
+
/** A subagent that is no review, with its description, as a list of at most one. */
|
|
141
|
+
const describedSubagentOf = ({ phaseRun, description, writtenAt }: RunningSubagent): DescribedSubagent[] =>
|
|
142
|
+
description !== null && (phaseRun === null || phaseRun.review === null) ? [{ description, at: writtenAt }] : []
|
|
143
|
+
|
|
144
|
+
/** The run of a subagent that names a phase, as a list of at most one. */
|
|
145
|
+
const namedRunOf = ({ phaseRun }: RunningSubagent): SubagentPhaseRun[] =>
|
|
146
|
+
phaseRun !== null && phaseRun.ids.length > 0 ? [phaseRun] : []
|
|
147
|
+
|
|
148
|
+
/** A review that names no phase, as a list of at most one. */
|
|
149
|
+
const phaselessReviewOf = ({ phaseRun, description }: RunningSubagent): PhaselessReview[] => {
|
|
150
|
+
if (phaseRun === null || phaseRun.review === null || phaseRun.ids.length > 0) return []
|
|
151
|
+
return [{ review: phaseRun.review, at: phaseRun.at, description }]
|
|
152
|
+
}
|
|
153
|
+
|
|
114
154
|
/** The worktrees below `worktreeDirectory` the subagents work in; one that waits for an answer decides the state. */
|
|
115
155
|
export const subagentsByLane = (
|
|
116
156
|
running: readonly RunningSubagent[],
|
|
117
157
|
worktreeDirectory: string,
|
|
118
158
|
): Map<string, SubagentActivity> => {
|
|
119
159
|
const lanes = new Map<string, SubagentActivity>()
|
|
120
|
-
for (const
|
|
160
|
+
for (const subagent of running) {
|
|
161
|
+
const { cwd, status, description, activity } = subagent
|
|
121
162
|
const lane = cwd === null ? null : laneDirectoryOf(cwd, worktreeDirectory)
|
|
122
163
|
if (lane === null) continue
|
|
123
164
|
const known = lanes.get(lane)
|
|
124
|
-
|
|
165
|
+
const runs = namedRunOf(subagent)
|
|
166
|
+
const reviews = phaselessReviewOf(subagent)
|
|
167
|
+
const tasks = taskDescriptionOf(subagent)
|
|
168
|
+
const described = describedSubagentOf(subagent)
|
|
169
|
+
if (!known)
|
|
170
|
+
lanes.set(lane, {
|
|
171
|
+
status,
|
|
172
|
+
description,
|
|
173
|
+
activity,
|
|
174
|
+
phaseRuns: runs,
|
|
175
|
+
phaselessReviews: reviews,
|
|
176
|
+
taskDescriptions: tasks,
|
|
177
|
+
describedSubagents: described,
|
|
178
|
+
})
|
|
125
179
|
else
|
|
126
180
|
lanes.set(lane, {
|
|
127
181
|
...(status === AGENT_STATUS.blocked && known.status !== AGENT_STATUS.blocked ? { status, description } : known),
|
|
128
182
|
activity: `${known.activity} ${activity}`,
|
|
183
|
+
phaseRuns: [...known.phaseRuns, ...runs],
|
|
184
|
+
phaselessReviews: [...known.phaselessReviews, ...reviews],
|
|
185
|
+
taskDescriptions: [...known.taskDescriptions, ...tasks],
|
|
186
|
+
describedSubagents: [...known.describedSubagents, ...described],
|
|
129
187
|
})
|
|
130
188
|
}
|
|
131
189
|
return lanes
|