jackherd 1.13.0 → 1.15.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 +20 -0
- package/README.md +28 -38
- package/assets/icons/skills.svg +1 -0
- package/dist/config.d.ts +12 -1
- package/dist/config.js +35 -2
- package/dist/plan-format.d.ts +24 -1
- package/dist/plan-format.js +25 -5
- package/docs/plan-contract.md +84 -0
- package/docs/skills-dark.svg +15 -2
- package/docs/skills.svg +15 -2
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/herdr-plugin/restore-plans.ts +56 -6
- package/install.sh +1 -1
- package/kit/hooks/hooks/merge-guard/board-settings.ts +5 -2
- package/kit/hooks/hooks/merge-guard/pull-request.ts +28 -15
- package/kit/hooks/hooks/merge-guard/run.ts +6 -3
- package/kit/hooks/hooks/merge-guard/settings.ts +12 -1
- package/kit/hooks/hooks/model-log/transcript.ts +4 -7
- package/kit/hooks/hooks/open-question/record.ts +4 -3
- package/kit/hooks/hooks/shared/dialog-answers.ts +7 -3
- package/kit/hooks/hooks/shared/log-vocabulary.ts +13 -0
- package/kit/hooks/hooks/shared/transcript.ts +7 -4
- package/kit/hooks/hooks/task-hint/decision.ts +3 -2
- package/kit/hooks/hooks/task-hint/run.ts +2 -4
- package/kit/rules/rules/kit/pull-requests.md +1 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +24 -10
- package/kit/workflow/skills/orchestrate/handover/index.ts +8 -2
- package/kit/workflow/skills/orchestrate/references/adopt-plan.md +51 -0
- package/kit/workflow/skills/orchestrate/references/events.md +6 -1
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +7 -6
- package/kit/workflow/skills/start/SKILL.md +6 -2
- package/kit/workflow/skills/start/references/missing-commands.md +17 -0
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +43 -0
- package/src/check/command.ts +53 -7
- package/src/check/file-kinds.ts +144 -0
- package/src/check/parallel-report.ts +10 -3
- package/src/check/plan.ts +118 -16
- package/src/check/report.ts +44 -1
- package/src/check/roots.ts +9 -0
- package/src/check/schema.ts +12 -0
- package/src/check/settings.ts +11 -3
- package/src/check/skill-budget-defaults.ts +21 -7
- package/src/check/skill-budgets.ts +49 -19
- package/src/claude/paths.ts +12 -0
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +27 -0
- package/src/context/fill.ts +2 -1
- package/src/context/log-vocabulary.ts +22 -0
- package/src/context/session-log.ts +2 -1
- package/src/lane-prompt/build.ts +10 -5
- package/src/lane-prompt/command.ts +6 -1
- package/src/lane-prompt/input.ts +4 -4
- package/src/lane-prompt/texts.ts +18 -10
- package/src/plan-contract/command.ts +69 -0
- package/src/plan-contract/contract.ts +144 -0
- package/src/plan-format/dependencies.ts +1 -4
- package/src/plan-format/index.ts +4 -0
- package/src/plan-format/lanes.ts +11 -0
- package/src/plan-format/markdown.ts +6 -5
- package/src/plan-format/model-table.ts +27 -0
- package/src/plans/activity-read.ts +98 -0
- package/src/plans/ask-coordinator.ts +20 -11
- package/src/plans/lane-plans.ts +2 -1
- package/src/plans/layout.ts +49 -4
- package/src/plans/live.ts +17 -4
- package/src/plans/plan-panes.ts +25 -5
- package/src/plans/snapshot.ts +18 -8
- package/src/plans/subagents-read.ts +4 -3
- package/src/plans/subagents.ts +2 -1
- package/src/plans/texts.ts +27 -1
- package/src/settled/send.ts +80 -4
- package/src/shared/parse-json.ts +8 -0
- package/src/sync/command.ts +17 -10
- package/src/sync/foreign-skills.ts +40 -0
- package/src/sync/global-kit.ts +31 -21
- package/src/sync/init.ts +2 -2
- package/src/sync/kit-exclude.ts +39 -0
- package/src/sync/plan-file.ts +1 -1
- package/src/sync/render.ts +2 -1
- package/src/sync/report.ts +37 -2
- package/src/sync/sync-result.ts +5 -2
- package/src/sync/sync.ts +13 -5
- package/src/task-signals/claude-signals.ts +6 -5
- package/src/watch/command.ts +88 -18
- package/src/watch/lane-message.ts +1 -1
- package/src/watch/lane-progress.ts +57 -3
- package/src/watch/lane-report.ts +67 -2
- package/src/watch/lane-watch.ts +2 -8
- package/src/watch/plan-missing-read.ts +47 -0
- package/src/watch/plan-missing.ts +43 -0
- package/src/watch/reference-paths.ts +1 -0
- package/src/watch/task-parallel-touched.ts +9 -2
- package/src/watch/texts.ts +8 -0
- package/src/watch/watch-events.ts +7 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { CONTENT_BLOCK_TYPE, LOG_ENTRY_TYPE } from '../context/log-vocabulary'
|
|
2
|
+
import { keptLogEntry } from '../context/session-log'
|
|
3
|
+
import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
|
|
4
|
+
import { isRecord } from '../shared/is-record'
|
|
5
|
+
import { type RunningSubagent, type SubagentActivity, type SubagentLog, subagentLabelOf } from './subagents'
|
|
6
|
+
import { laneDirectoryOf } from './unlisted-lanes'
|
|
7
|
+
|
|
8
|
+
/** What a running subagent did last, with the name the herd calls it by. */
|
|
9
|
+
export type SubagentLine = { label: string; description: string | null; line: string }
|
|
10
|
+
|
|
11
|
+
/** What a worktree shows of its subagents, and the last action of each. */
|
|
12
|
+
export type LaneSubagents = SubagentActivity & { lines?: readonly SubagentLine[] }
|
|
13
|
+
|
|
14
|
+
/** The end of a log that is looked at: the last action is among the last entries, however long the log is. */
|
|
15
|
+
const SCANNED_ENTRIES = 60
|
|
16
|
+
const LINE_LENGTH = 200
|
|
17
|
+
const PATH_SEGMENTS = 2
|
|
18
|
+
/** The input fields that name what a tool works on, the first one present wins. */
|
|
19
|
+
const TARGET_FIELDS = ['file_path', 'command', 'pattern', 'url', 'query', 'description', 'path']
|
|
20
|
+
|
|
21
|
+
const singleLine = (text: string): string => terminalSafe(text).replace(/\s+/g, ' ').trim()
|
|
22
|
+
|
|
23
|
+
const shortPath = (path: string): string =>
|
|
24
|
+
path.includes('/') ? path.split('/').slice(-PATH_SEGMENTS).join('/') : path
|
|
25
|
+
|
|
26
|
+
const targetOf = (input: unknown): string => {
|
|
27
|
+
if (!isRecord(input)) return ''
|
|
28
|
+
const field = TARGET_FIELDS.find((name) => typeof input[name] === 'string' && input[name] !== '')
|
|
29
|
+
if (field === undefined) return ''
|
|
30
|
+
const value = String(input[field]).split('\n')[0]
|
|
31
|
+
return singleLine(field === 'file_path' || field === 'path' ? shortPath(value) : value)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The line of one content block, null for a block that is no action: reasoning, whether it carries text or not. */
|
|
35
|
+
const lineOfBlock = (block: unknown): string | null => {
|
|
36
|
+
if (!isRecord(block)) return null
|
|
37
|
+
if (block.type === CONTENT_BLOCK_TYPE.toolUse && typeof block.name === 'string')
|
|
38
|
+
return singleLine(`${block.name} ${targetOf(block.input)}`)
|
|
39
|
+
if (block.type !== CONTENT_BLOCK_TYPE.text || typeof block.text !== 'string') return null
|
|
40
|
+
const first = block.text.split('\n').find((line) => line.trim() !== '')
|
|
41
|
+
return first === undefined ? null : singleLine(first)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const lineOfEntry = (line: string): string | null => {
|
|
45
|
+
const entry = keptLogEntry(line)
|
|
46
|
+
if (entry?.type !== LOG_ENTRY_TYPE.assistant || !isRecord(entry.message) || !Array.isArray(entry.message.content))
|
|
47
|
+
return null
|
|
48
|
+
const blocks: unknown[] = entry.message.content
|
|
49
|
+
for (let index = blocks.length - 1; index >= 0; index--) {
|
|
50
|
+
const found = lineOfBlock(blocks[index])
|
|
51
|
+
if (found) return found
|
|
52
|
+
}
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const scanned = new WeakMap<readonly string[], string | null>()
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The last action in a log's lines: the newest assistant text line or tool call, a tool as its name and its short
|
|
60
|
+
* target (`Read plans/layout.ts`). Reasoning is left out, as most of it is empty in the log. Cut to a length a line of
|
|
61
|
+
* the herd can take; null when the end of the log holds none. A log that did not change (`followTailLines` hands back
|
|
62
|
+
* the very same array) is not scanned again.
|
|
63
|
+
*/
|
|
64
|
+
export const activityLineOf = (lines: readonly string[]): string | null => {
|
|
65
|
+
if (scanned.has(lines)) return scanned.get(lines) ?? null
|
|
66
|
+
let found: string | null = null
|
|
67
|
+
for (let index = lines.length - 1; index >= Math.max(lines.length - SCANNED_ENTRIES, 0) && found === null; index--)
|
|
68
|
+
found = lineOfEntry(lines[index])
|
|
69
|
+
const line = found === null ? null : ellipsize(found, LINE_LENGTH)
|
|
70
|
+
scanned.set(lines, line)
|
|
71
|
+
return line
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const labelOfLog = ({ id, meta }: SubagentLog): string | null =>
|
|
75
|
+
subagentLabelOf(id, isRecord(meta) && typeof meta.agentType === 'string' ? meta.agentType : null)
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `lanes` with the last action of each running subagent added to the lane it works in; a subagent whose log holds no
|
|
79
|
+
* action, or that works in no known lane, adds none. `lanes` itself stays as it was.
|
|
80
|
+
*/
|
|
81
|
+
export const withSubagentLines = (
|
|
82
|
+
lanes: ReadonlyMap<string, SubagentActivity>,
|
|
83
|
+
running: readonly RunningSubagent[],
|
|
84
|
+
logs: readonly SubagentLog[],
|
|
85
|
+
worktreeDirectory: string,
|
|
86
|
+
): Map<string, LaneSubagents> => {
|
|
87
|
+
const result = new Map<string, LaneSubagents>(lanes)
|
|
88
|
+
const tails = new Map(logs.flatMap((log) => [[labelOfLog(log), log.tail] as const]))
|
|
89
|
+
for (const { label, cwd, description } of running) {
|
|
90
|
+
const lane = cwd === null ? null : laneDirectoryOf(cwd, worktreeDirectory)
|
|
91
|
+
const tail = tails.get(label)
|
|
92
|
+
const known = lane === null ? undefined : result.get(lane)
|
|
93
|
+
const line = tail === undefined ? null : activityLineOf(tail)
|
|
94
|
+
if (lane === null || known === undefined || line === null) continue
|
|
95
|
+
result.set(lane, { ...known, lines: [...(known.lines ?? []), { label, description, line }] })
|
|
96
|
+
}
|
|
97
|
+
return result
|
|
98
|
+
}
|
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import { TERMINAL_ADAPTER, type TerminalAdapter } from '../config'
|
|
2
2
|
import { resolveCoordinatorName } from '../context/coordinator-name'
|
|
3
|
-
import {
|
|
4
|
-
import { type
|
|
3
|
+
import { laneSenderForAsync } from '../settled/send'
|
|
4
|
+
import { type HerdrAsyncRunner, parseAgentList } from '../terminal/herdr'
|
|
5
5
|
import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
6
6
|
import { ASK_KIND, type AskTarget, boardRowKey } from './ask-target'
|
|
7
7
|
import { errorText } from './error-text'
|
|
8
8
|
import { isQuestionId } from './question-records'
|
|
9
9
|
import { TEXTS } from './texts'
|
|
10
10
|
|
|
11
|
+
/** The footer line while the coordinator is being asked. */
|
|
12
|
+
export const ASKING_NOTICE = 'asking the coordinator …'
|
|
13
|
+
|
|
11
14
|
/** What `askCoordinator` needs from the outside: the dialog language, the terminal, the repository and herdr. */
|
|
12
15
|
export type AskDependencies = {
|
|
13
16
|
language: PlanLanguage
|
|
14
17
|
adapter: TerminalAdapter
|
|
15
18
|
/** The repository's directory name, from which the coordinator's herdr name follows. */
|
|
16
19
|
repository: string
|
|
17
|
-
run:
|
|
20
|
+
run: HerdrAsyncRunner
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
/** A sentence takes the subject's key and, for an entry of a source, the source it belongs to (else `''`). */
|
|
@@ -80,13 +83,18 @@ const subjectOf = (target: AskTarget): string => {
|
|
|
80
83
|
return `${SUBJECT_NAMES[target.kind]} ${subjectKeyOf(target)}${scope === '' ? '' : ` of ${scope}`}`
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
const agentNamesOf = (run:
|
|
86
|
+
const agentNamesOf = async (run: HerdrAsyncRunner): Promise<string[]> =>
|
|
87
|
+
parseAgentList(await run(['agent', 'list'])).map(({ name }) => name)
|
|
84
88
|
|
|
85
89
|
/** The coordinator's herdr name, or why there is none to reach; without a terminal the sender gives the reason. */
|
|
86
|
-
const coordinatorOf = ({
|
|
90
|
+
const coordinatorOf = async ({
|
|
91
|
+
adapter,
|
|
92
|
+
repository,
|
|
93
|
+
run,
|
|
94
|
+
}: AskDependencies): Promise<{ name: string } | { reason: string }> => {
|
|
87
95
|
if (adapter === TERMINAL_ADAPTER.none) return { name: resolveCoordinatorName(repository, []) }
|
|
88
96
|
try {
|
|
89
|
-
const names = agentNamesOf(run)
|
|
97
|
+
const names = await agentNamesOf(run)
|
|
90
98
|
const name = resolveCoordinatorName(repository, names)
|
|
91
99
|
return names.includes(name) ? { name } : { reason: `no agent named ${name}` }
|
|
92
100
|
} catch (error) {
|
|
@@ -96,14 +104,15 @@ const coordinatorOf = ({ adapter, repository, run }: AskDependencies): { name: s
|
|
|
96
104
|
|
|
97
105
|
/**
|
|
98
106
|
* Asks the coordinator, by `herdr agent prompt`, to put the open question or board row `target` to the user. A
|
|
99
|
-
* coordinator that is working gets the line too: herdr queues it like a message in the middle of a turn.
|
|
100
|
-
* footer line
|
|
107
|
+
* coordinator that is working gets the line too: herdr queues it like a message in the middle of a turn. Resolves to
|
|
108
|
+
* the footer line, who was asked or why nobody was; the prompt waits up to `PROMPT_CONFIRM_TIMEOUT_MS`, so a live
|
|
109
|
+
* view shows `ASKING_NOTICE` meanwhile.
|
|
101
110
|
*/
|
|
102
|
-
export const askCoordinator = (target: AskTarget, dependencies: AskDependencies): string => {
|
|
111
|
+
export const askCoordinator = async (target: AskTarget, dependencies: AskDependencies): Promise<string> => {
|
|
103
112
|
if (!isAskable(target)) return TEXTS.notAsked('nothing to ask')
|
|
104
|
-
const coordinator = coordinatorOf(dependencies)
|
|
113
|
+
const coordinator = await coordinatorOf(dependencies)
|
|
105
114
|
if ('reason' in coordinator) return TEXTS.notAsked(coordinator.reason)
|
|
106
|
-
const failure =
|
|
115
|
+
const failure = await laneSenderForAsync(dependencies.adapter, dependencies.run)(
|
|
107
116
|
coordinator.name,
|
|
108
117
|
askLine(target, dependencies.language),
|
|
109
118
|
)
|
package/src/plans/lane-plans.ts
CHANGED
|
@@ -27,7 +27,8 @@ export type PlanSources = {
|
|
|
27
27
|
parsed?: PlanRound
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
/** A repository path that a link resolved above the repository root. */
|
|
31
|
+
export const isOutsideRepository = (path: string): boolean => path.startsWith('..')
|
|
31
32
|
|
|
32
33
|
/** The file as the base ref has it; null when the ref lacks it or the path leaves the repository. */
|
|
33
34
|
export const textAtBase = (base: BaseReader, path: string): string | null =>
|
package/src/plans/layout.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { TONE, TONE_ROLE } from '../config/tones'
|
|
|
2
2
|
import { CONTEXT_LEVEL, type ContextLevel, type ContextReading, contextLabel } from '../context'
|
|
3
3
|
import type { LaneProgress, PhaseProgress } from '../plan-format'
|
|
4
4
|
import { currentPhase, isPhaseOpen, phaseLabel } from '../plan-format'
|
|
5
|
+
import { ellipsize } from '../plan-format/terminal-text'
|
|
5
6
|
import type { WaitingItem } from '../plan-format/waiting'
|
|
6
7
|
import { boundedMemo } from '../shared/bounded-memo'
|
|
7
8
|
import {
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
type WatchHealth,
|
|
33
34
|
} from '../watch/plan-badges'
|
|
34
35
|
import type { UnplannedTask } from './active-tasks'
|
|
36
|
+
import type { SubagentLine } from './activity-read'
|
|
35
37
|
import { type AgentOf, clickHint } from './click-hint'
|
|
36
38
|
import { type CoordinatorView, coordinatorShortName, coordinatorText } from './coordinator-row'
|
|
37
39
|
import { type Frame, indented, joinFrames, narrower, rowFrame, spaced, textFrame, unpainted } from './frame'
|
|
@@ -98,7 +100,7 @@ import { shownWithMore } from './shown-with-more'
|
|
|
98
100
|
import { askedCheckOf } from './source-asked'
|
|
99
101
|
import { type SourceBlock, type SourceSummary, sourceItems, sourceSummaries } from './source-rows'
|
|
100
102
|
import { chevronOf, LESS_MARK, NO_AGENT_SYMBOL, statusSymbol, statusWord, TURN_MARK } from './symbols'
|
|
101
|
-
import { BADGE_TEXTS, type BadgeTexts, checksText, laneMarkText, TEXTS } from './texts'
|
|
103
|
+
import { BADGE_TEXTS, type BadgeTexts, checksText, foldedChecksText, laneMarkText, TEXTS } from './texts'
|
|
102
104
|
import { paintOfRole, paintOfTone, toneOfRole } from './tones'
|
|
103
105
|
import {
|
|
104
106
|
allBlockRows,
|
|
@@ -142,8 +144,13 @@ export type LaneView = {
|
|
|
142
144
|
sentFiles?: SentChip[]
|
|
143
145
|
/** What a resting lane waits on, from its report (`restNote`); absent or null while it works or asks nothing. */
|
|
144
146
|
restNote?: string | null
|
|
147
|
+
/** The last action of the lane's agent and of its running subagents, from their logs; absent without a source. */
|
|
148
|
+
activity?: LaneActivity
|
|
145
149
|
}
|
|
146
150
|
|
|
151
|
+
/** `lane` is null while the agent's log holds no action; the lines show only while the lane works. */
|
|
152
|
+
export type LaneActivity = { lane: string | null; subagents: readonly SubagentLine[] }
|
|
153
|
+
|
|
147
154
|
/** `watch` is the health of the watcher the badges come from, absent when it was not read. */
|
|
148
155
|
export type OverviewView = {
|
|
149
156
|
title: string
|
|
@@ -652,15 +659,23 @@ const markTone = ({ feedback, changesRequested }: PullRequestBadges): BadgeTone
|
|
|
652
659
|
return feedback > 0 ? BADGE_TONE.amber : BADGE_TONE.grey
|
|
653
660
|
}
|
|
654
661
|
|
|
662
|
+
/** The check state behind a folded badge in the colour of its state, a draft dim; empty without checks. */
|
|
663
|
+
const foldedChecksMark = (pullRequest: PullRequest): string => {
|
|
664
|
+
const text = foldedChecksText(pullRequest)
|
|
665
|
+
if (!text) return ''
|
|
666
|
+
return ` ${pullRequest.draft ? paint.dim(text) : CHECKS_PAINT[pullRequest.checks](text)}`
|
|
667
|
+
}
|
|
668
|
+
|
|
655
669
|
/**
|
|
656
670
|
* `↗ #<n>` as a badge linked to the pull request, with `💬 <n>` while it has feedback since its last push and `✋`
|
|
657
|
-
* while changes are requested; dark red then, else amber with feedback, else grey.
|
|
671
|
+
* while changes are requested; dark red then, else amber with feedback, else grey. Its check state follows outside
|
|
672
|
+
* the badge, so a red check stays red beside the tone of the feedback.
|
|
658
673
|
*/
|
|
659
674
|
const pullRequestMark = (pullRequest: PullRequest, badges: WatchBadges): string => {
|
|
660
675
|
const found = badges[String(pullRequest.number)] ?? NO_BADGES
|
|
661
676
|
const feedback = found.feedback > 0 ? ` 💬 ${found.feedback}` : ''
|
|
662
677
|
const text = `↗ #${pullRequest.number}${feedback}${found.changesRequested ? ' ✋' : ''}`
|
|
663
|
-
return linkedBox(pullRequest.url, text, markTone(found))
|
|
678
|
+
return `${linkedBox(pullRequest.url, text, markTone(found))}${foldedChecksMark(pullRequest)}`
|
|
664
679
|
}
|
|
665
680
|
|
|
666
681
|
/** `📡 <n>` in amber while tickets of the lane are new, linked to the first of them; nothing otherwise. */
|
|
@@ -883,6 +898,34 @@ const pullRequestsFrame = (view: LaneView, context: LaneContext): Frame => {
|
|
|
883
898
|
)
|
|
884
899
|
}
|
|
885
900
|
|
|
901
|
+
const ACTIVITY_MARK = '↳'
|
|
902
|
+
const SUBAGENT_ACTIVITY_INDENT = ' '
|
|
903
|
+
/** How long a subagent's name may grow before its action, so a narrow pane cuts the name and keeps the action. */
|
|
904
|
+
const SUBAGENT_NAME_CELLS = 28
|
|
905
|
+
|
|
906
|
+
/** One dim line of activity behind `indent`, cut to `width`; the cut is made before the paint, on plain text. */
|
|
907
|
+
const activityLine = (indent: string, text: string, width: number): string =>
|
|
908
|
+
paint.dim(fit(`${indent}${ACTIVITY_MARK} ${text}`, width))
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* The last action of a working lane's agent and of each of its running subagents, one dim line each, under the lane
|
|
912
|
+
* line in the column of its children. None while the lane does not work, or without a source for it.
|
|
913
|
+
*/
|
|
914
|
+
const activityLines = ({ activity, status }: LaneView, width: number): string[] => {
|
|
915
|
+
if (!activity || status.agent !== AGENT_STATUS.working) return []
|
|
916
|
+
const indent = ' '.repeat(LANE_HEAD_CELLS)
|
|
917
|
+
return [
|
|
918
|
+
...(activity.lane === null ? [] : [activityLine(indent, activity.lane, width)]),
|
|
919
|
+
...activity.subagents.map(({ label, description, line }) =>
|
|
920
|
+
activityLine(
|
|
921
|
+
`${indent}${SUBAGENT_ACTIVITY_INDENT}`,
|
|
922
|
+
`${ellipsize(description ?? label, SUBAGENT_NAME_CELLS)}: ${line}`,
|
|
923
|
+
width,
|
|
924
|
+
),
|
|
925
|
+
),
|
|
926
|
+
]
|
|
927
|
+
}
|
|
928
|
+
|
|
886
929
|
/**
|
|
887
930
|
* The lane line is a row of its own; an open lane continues after a blank line with its session, links, pull requests
|
|
888
931
|
* and phases (`openLaneFrame`), indented to the column of the lane's name.
|
|
@@ -906,7 +949,9 @@ const laneBlock = (
|
|
|
906
949
|
}
|
|
907
950
|
const open = isLaneOpen(state, view.progress.lane.name, view.status.agent)
|
|
908
951
|
const lines = laneLines(view, open, { width, taskStyle }, columns, reason)
|
|
909
|
-
const
|
|
952
|
+
const lane = rowFrame(laneKey(view.progress.lane.name), lines, [], context)
|
|
953
|
+
const activity = activityLines(view, width)
|
|
954
|
+
const row = activity.length > 0 ? joinFrames([lane, textFrame(activity)]) : lane
|
|
910
955
|
if (!open) return row
|
|
911
956
|
const inner = narrower(context, LANE_HEAD_CELLS)
|
|
912
957
|
const head = {
|
package/src/plans/live.ts
CHANGED
|
@@ -2,13 +2,14 @@ import { existsSync, type FSWatcher, watch } from 'node:fs'
|
|
|
2
2
|
import { basename, dirname } from 'node:path'
|
|
3
3
|
import { pathToFileURL } from 'node:url'
|
|
4
4
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
5
|
-
import {
|
|
5
|
+
import { herdrRunnerAsync } from '../terminal/herdr'
|
|
6
6
|
import { enterTerminal, reportCrash, terminalRestore } from '../terminal/lifetime'
|
|
7
7
|
import { openInBrowser } from '../terminal/open'
|
|
8
8
|
import { crashEnd } from '../terminal/restore'
|
|
9
9
|
import { closeWaiting } from '../waiting/command'
|
|
10
10
|
import { isWatcherRunning } from '../watch/watcher-lock'
|
|
11
|
-
import { askCoordinator } from './ask-coordinator'
|
|
11
|
+
import { ASKING_NOTICE, type AskDependencies, askCoordinator } from './ask-coordinator'
|
|
12
|
+
import type { AskTarget } from './ask-target'
|
|
12
13
|
import { errorText } from './error-text'
|
|
13
14
|
import { focusedScreen } from './focus-overlay'
|
|
14
15
|
import { noPullRequests } from './github'
|
|
@@ -438,6 +439,17 @@ const openPlan = (live: Live, snapshot: Snapshot, address: string) => {
|
|
|
438
439
|
live.notice = reason === null ? texts.pageOpened(page.path) : texts.pageNotOpenedReason(reason)
|
|
439
440
|
}
|
|
440
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Asks the coordinator without holding the herd still: the answer replaces the footer line when it arrives, unless
|
|
444
|
+
* another click has set the footer in the meantime.
|
|
445
|
+
*/
|
|
446
|
+
const askAndShow = async (live: Live, target: AskTarget, dependencies: AskDependencies) => {
|
|
447
|
+
const answer = await askCoordinator(target, dependencies)
|
|
448
|
+
if (live.notice !== ASKING_NOTICE) return
|
|
449
|
+
live.notice = answer
|
|
450
|
+
redraw(live)
|
|
451
|
+
}
|
|
452
|
+
|
|
441
453
|
/**
|
|
442
454
|
* What a hit on a row of a block of the herd does: expands or folds it, asks the coordinator to put a question or a board row
|
|
443
455
|
* (the result shows in the footer) or opens its address (a failure shows there). An appointment row only ever expands.
|
|
@@ -452,11 +464,12 @@ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
|
|
|
452
464
|
}
|
|
453
465
|
if ('ask' in outcome) {
|
|
454
466
|
const { root, settings } = live.source
|
|
455
|
-
live.notice =
|
|
467
|
+
live.notice = ASKING_NOTICE
|
|
468
|
+
void askAndShow(live, outcome.ask, {
|
|
456
469
|
language: settings.responseLanguage,
|
|
457
470
|
adapter: settings.adapter,
|
|
458
471
|
repository: basename(root),
|
|
459
|
-
run:
|
|
472
|
+
run: herdrRunnerAsync(process.env),
|
|
460
473
|
})
|
|
461
474
|
return
|
|
462
475
|
}
|
package/src/plans/plan-panes.ts
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import { basename } from 'node:path'
|
|
2
2
|
import { HERD_COMMAND, HERD_COMMAND_WORDS } from './herd-command'
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* One herdr pane as `herd --ensure` sees it: where it is, whether an agent runs in it, its foreground commands, and
|
|
6
|
+
* its name (`label`), which herdr keeps across a restart; null without one.
|
|
7
|
+
*/
|
|
8
|
+
export type PaneState = {
|
|
9
|
+
pane: string
|
|
10
|
+
tab: string
|
|
11
|
+
cwd: string
|
|
12
|
+
agent: boolean
|
|
13
|
+
foreground: string[][]
|
|
14
|
+
label: string | null
|
|
15
|
+
}
|
|
6
16
|
|
|
7
17
|
/** Runs a command in a free shell pane, or splits a pane to the right and runs it in the new one. */
|
|
8
18
|
export type PaneAction = { kind: 'run' | 'split'; pane: string; command: string }
|
|
@@ -23,7 +33,7 @@ export type PlanPaneInput = {
|
|
|
23
33
|
|
|
24
34
|
export type PlanPaneResult = { actions: PaneAction[]; notes: string[] }
|
|
25
35
|
|
|
26
|
-
type ListedPane = { pane_id: string; tab_id: string; cwd: string; agent?: unknown }
|
|
36
|
+
type ListedPane = { pane_id: string; tab_id: string; cwd: string; agent?: unknown; label?: unknown }
|
|
27
37
|
|
|
28
38
|
const isListedPane = (value: unknown): value is ListedPane => {
|
|
29
39
|
if (typeof value !== 'object' || value === null) return false
|
|
@@ -42,6 +52,7 @@ export const paneListOf = (json: string): Omit<PaneState, 'foreground'>[] => {
|
|
|
42
52
|
tab: each.tab_id,
|
|
43
53
|
cwd: each.cwd,
|
|
44
54
|
agent: typeof each.agent === 'string',
|
|
55
|
+
label: typeof each.label === 'string' ? each.label : null,
|
|
45
56
|
}))
|
|
46
57
|
}
|
|
47
58
|
|
|
@@ -106,6 +117,13 @@ export const isPlanView = (foreground: readonly (readonly string[])[], lane: str
|
|
|
106
117
|
export const isIdleShell = ({ agent, foreground }: PaneState): boolean =>
|
|
107
118
|
!agent && foreground.length > 0 && foreground.every((argv) => SHELLS.has(programOf(argv)))
|
|
108
119
|
|
|
120
|
+
/**
|
|
121
|
+
* A restored pane whose shell is still starting: nothing in the foreground yet, or a shell first (its prompt may run
|
|
122
|
+
* `git` beside it). Another program there, an editor or a pager, would take the command as its input.
|
|
123
|
+
*/
|
|
124
|
+
const isStartingShell = ({ agent, foreground }: PaneState): boolean =>
|
|
125
|
+
!agent && (foreground.length === 0 || SHELLS.has(programOf(foreground[0])))
|
|
126
|
+
|
|
109
127
|
/**
|
|
110
128
|
* The coordinator's pane: the agent herdr lists under the coordinator's name, else the calling pane when an agent
|
|
111
129
|
* in the main checkout runs there, else the only agent in the main checkout. Two agents there and no name: none.
|
|
@@ -158,7 +176,8 @@ export const splitPaneOf = (json: string): string => {
|
|
|
158
176
|
}
|
|
159
177
|
|
|
160
178
|
/**
|
|
161
|
-
* What one view needs in the tab of `anchor`: nothing when a pane there shows it, a run in
|
|
179
|
+
* What one view needs in the tab of `anchor`: nothing when a pane there shows it, a run in the pane without an agent
|
|
180
|
+
* that carries the view's name (restored by herdr, its shell may still be starting), else in a free shell whose
|
|
162
181
|
* directory `isHome` accepts (the pane the view ran in before a restart), else a split of the anchor pane.
|
|
163
182
|
*/
|
|
164
183
|
const actionFor = (
|
|
@@ -170,7 +189,8 @@ const actionFor = (
|
|
|
170
189
|
): PaneAction | null => {
|
|
171
190
|
const tab = panes.filter((each) => each.tab === anchor.tab)
|
|
172
191
|
if (tab.some((each) => isPlanView(each.foreground, lane))) return null
|
|
173
|
-
const
|
|
192
|
+
const named = tab.find((each) => each.label === herdLabel(lane) && isStartingShell(each))
|
|
193
|
+
const free = named ?? tab.find((each) => isHome(each.cwd) && isIdleShell(each))
|
|
174
194
|
return free ? { kind: 'run', pane: free.pane, command } : { kind: 'split', pane: anchor.pane, command }
|
|
175
195
|
}
|
|
176
196
|
|
package/src/plans/snapshot.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { basename, dirname, join } from 'node:path'
|
|
3
3
|
import type { ContextReading } from '../context'
|
|
4
|
+
import { ASSISTANT_ENTRY_MARK } from '../context/log-vocabulary'
|
|
4
5
|
import { coordinatorSettings } from '../context/options'
|
|
5
6
|
import { type AgentLogReader, contextOfAgent } from '../context/report'
|
|
6
7
|
import { agentLog, defaultProjectsDirectory } from '../context/session-log'
|
|
@@ -25,6 +26,7 @@ import type { SourceTicket, WatchBadges, WatchHealth } from '../watch/plan-badge
|
|
|
25
26
|
import { readWatchReading } from '../watch/plan-badges-read'
|
|
26
27
|
import type { SourceStatus } from '../watch/source-statuses'
|
|
27
28
|
import { assignSignals, laneOfDirectory, type UnplannedTask } from './active-tasks'
|
|
29
|
+
import { activityLineOf, type LaneSubagents } from './activity-read'
|
|
28
30
|
import { type BoardRowsReader, boardRowsOf } from './board-rows'
|
|
29
31
|
import { type CoordinatorView, coordinatorAgentOf, coordinatorTaskOf } from './coordinator-row'
|
|
30
32
|
import {
|
|
@@ -70,7 +72,6 @@ import { createSliceGate, type SliceGate } from './slice-gate'
|
|
|
70
72
|
import { withSourcePrefix } from './source-prefix'
|
|
71
73
|
import type { HerdSource, SourceBlock } from './source-rows'
|
|
72
74
|
import { type KeptStatus, logMark, pacedStatus, type WorktreeStatus } from './status-pace'
|
|
73
|
-
import type { SubagentActivity } from './subagents'
|
|
74
75
|
import { readSubagentActivitySliced, subagentSourceOf } from './subagents-read'
|
|
75
76
|
import { boardTexts } from './texts'
|
|
76
77
|
import { type UnlistedLane, unlistedLanes, unlistedSprintLane } from './unlisted-lanes'
|
|
@@ -91,7 +92,7 @@ export type Source = {
|
|
|
91
92
|
/** The log lines of an agent's chat, null without one; defaults to reading them from `~/.claude/projects`. */
|
|
92
93
|
readAgentLog?: AgentLogReader
|
|
93
94
|
/** The worktrees subagents of the coordinator work in; defaults to reading them from `~/.claude/projects`. */
|
|
94
|
-
readSubagents?: (root: string, worktreeDirectory: string) => Map<string,
|
|
95
|
+
readSubagents?: (root: string, worktreeDirectory: string) => Map<string, LaneSubagents>
|
|
95
96
|
/** What agents report doing (Claude's task lists, `jh task`); defaults to reading `~/.claude/projects` and `.jackherd/tasks/`. */
|
|
96
97
|
readSignals?: (root: string, worktreeDirectory: string, now: number) => TaskSignal[]
|
|
97
98
|
}
|
|
@@ -152,7 +153,7 @@ type LaneSources = {
|
|
|
152
153
|
sprintDirectory: string
|
|
153
154
|
agents: LaneAgent[]
|
|
154
155
|
/** What subagents of the coordinator do in each worktree; a herdr agent in the worktree comes first. */
|
|
155
|
-
subagents: Map<string,
|
|
156
|
+
subagents: Map<string, LaneSubagents>
|
|
156
157
|
/** The agents' log reader, asking once per agent however often the read is used in one snapshot. */
|
|
157
158
|
readLog: AgentLogReader
|
|
158
159
|
pullRequests: PullRequestState
|
|
@@ -200,9 +201,6 @@ type Decorations = {
|
|
|
200
201
|
restNoteOf: (lane: string) => string | null
|
|
201
202
|
}
|
|
202
203
|
|
|
203
|
-
/** What every answer line of a session log holds; the fill is read from answers only. */
|
|
204
|
-
const ANSWER_MARK = '"type":"assistant"'
|
|
205
|
-
|
|
206
204
|
const answerLines = new WeakMap<readonly string[], string[]>()
|
|
207
205
|
|
|
208
206
|
/**
|
|
@@ -212,7 +210,7 @@ const answerLines = new WeakMap<readonly string[], string[]>()
|
|
|
212
210
|
const answerLinesOf = (lines: readonly string[]): string[] => {
|
|
213
211
|
const kept = answerLines.get(lines)
|
|
214
212
|
if (kept) return kept
|
|
215
|
-
const found = lines.filter((line) => line.includes(
|
|
213
|
+
const found = lines.filter((line) => line.includes(ASSISTANT_ENTRY_MARK))
|
|
216
214
|
answerLines.set(lines, found)
|
|
217
215
|
return found
|
|
218
216
|
}
|
|
@@ -263,6 +261,17 @@ const pacedGitStatus = (sources: LaneSources, name: string, agent: LaneAgent | u
|
|
|
263
261
|
return pacedStatus(statuses, { worktree, stamp: worktreeStamp(worktree, activity), now }, read)
|
|
264
262
|
}
|
|
265
263
|
|
|
264
|
+
/**
|
|
265
|
+
* The last action of a lane's agent and of its running subagents, from the logs read for the lane anyway; absent
|
|
266
|
+
* while neither has one, so a lane without a source shows nothing.
|
|
267
|
+
*/
|
|
268
|
+
const activityOf = (sources: LaneSources, name: string, agent: LaneAgent | undefined): Pick<LaneView, 'activity'> => {
|
|
269
|
+
const lines = agent ? sources.readLog(agent) : null
|
|
270
|
+
const lane = lines ? activityLineOf(lines) : null
|
|
271
|
+
const subagents = sources.subagents.get(name)?.lines ?? []
|
|
272
|
+
return lane === null && subagents.length === 0 ? {} : { activity: { lane, subagents } }
|
|
273
|
+
}
|
|
274
|
+
|
|
266
275
|
/** What a lane's worktree and agent say, listed in the sprint or not: git state, pull requests, context. */
|
|
267
276
|
const runningState = async (sources: LaneSources, name: string, agent: LaneAgent | undefined) => {
|
|
268
277
|
const { source, pullRequests } = sources
|
|
@@ -275,6 +284,7 @@ const runningState = async (sources: LaneSources, name: string, agent: LaneAgent
|
|
|
275
284
|
status: { agent: agent?.status ?? subagent?.status ?? null, ...gitStatus },
|
|
276
285
|
pullRequests: gitStatus.branch ? (pullRequests.byBranch.get(gitStatus.branch) ?? []) : [],
|
|
277
286
|
context: contextOf(agent, source, sources.readLog),
|
|
287
|
+
...activityOf(sources, name, agent),
|
|
278
288
|
},
|
|
279
289
|
}
|
|
280
290
|
}
|
|
@@ -462,7 +472,7 @@ const readSubagents = async (
|
|
|
462
472
|
{ root, settings, readSubagents: read }: Source,
|
|
463
473
|
worktrees: string,
|
|
464
474
|
gate: SliceGate,
|
|
465
|
-
): Promise<Map<string,
|
|
475
|
+
): Promise<Map<string, LaneSubagents>> => {
|
|
466
476
|
try {
|
|
467
477
|
if (read) return read(root, worktrees)
|
|
468
478
|
return await readSubagentActivitySliced(subagentSourceOf(root, worktrees, settings.skillPrefix), gate.pass)
|
|
@@ -6,11 +6,11 @@ import { boundedMemo } from '../shared/bounded-memo'
|
|
|
6
6
|
import { stampOf } from '../shared/stat-cache'
|
|
7
7
|
import { DEFAULT_SKILL_PREFIX } from '../sync/init'
|
|
8
8
|
import { readApprovalRequests } from '../watch/approvals'
|
|
9
|
+
import { type LaneSubagents, withSubagentLines } from './activity-read'
|
|
9
10
|
import {
|
|
10
11
|
type RunningSubagent,
|
|
11
12
|
runningSubagents,
|
|
12
13
|
SUBAGENT_STALE_MINUTES,
|
|
13
|
-
type SubagentActivity,
|
|
14
14
|
type SubagentLog,
|
|
15
15
|
subagentsByLane,
|
|
16
16
|
} from './subagents'
|
|
@@ -174,11 +174,12 @@ export const readSubagentActivitySliced = async (
|
|
|
174
174
|
source: SubagentSource,
|
|
175
175
|
pass: () => Promise<void>,
|
|
176
176
|
now: number = Date.now(),
|
|
177
|
-
): Promise<Map<string,
|
|
177
|
+
): Promise<Map<string, LaneSubagents>> => {
|
|
178
178
|
const logs: SubagentLog[] = []
|
|
179
179
|
for (const directory of allSessionDirectories(source.projects, source.root, now, source.worktreeDirectory)) {
|
|
180
180
|
await pass()
|
|
181
181
|
logs.push(...logsInSession(directory, now))
|
|
182
182
|
}
|
|
183
|
-
|
|
183
|
+
const running = runningOf(logs, source, now)
|
|
184
|
+
return withSubagentLines(subagentsByLane(running, source.worktreeDirectory), running, logs, source.worktreeDirectory)
|
|
184
185
|
}
|
package/src/plans/subagents.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CONTENT_BLOCK_TYPE } from '../context/log-vocabulary'
|
|
1
2
|
import { keptLogEntry } from '../context/session-log'
|
|
2
3
|
import { MS_PER_MINUTE } from '../plan-format/clock'
|
|
3
4
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
@@ -64,7 +65,7 @@ const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/
|
|
|
64
65
|
const toolCallTexts = (entry: Record<string, unknown>): string[] => {
|
|
65
66
|
const content = isRecord(entry.message) && Array.isArray(entry.message.content) ? entry.message.content : []
|
|
66
67
|
return content.flatMap((block) => {
|
|
67
|
-
if (!isRecord(block) || block.type !==
|
|
68
|
+
if (!isRecord(block) || block.type !== CONTENT_BLOCK_TYPE.toolUse || !isRecord(block.input)) return []
|
|
68
69
|
return [block.input.file_path, block.input.path, block.input.command].filter(
|
|
69
70
|
(value): value is string => typeof value === 'string',
|
|
70
71
|
)
|
package/src/plans/texts.ts
CHANGED
|
@@ -88,6 +88,8 @@ export type PlanTexts = {
|
|
|
88
88
|
phaseTally: (done: number, total: number) => string
|
|
89
89
|
herd: HerdTexts
|
|
90
90
|
checks: Record<ChecksState, string>
|
|
91
|
+
/** Behind a folded lane's badge of a draft, in place of its check state. */
|
|
92
|
+
foldedDraft: string
|
|
91
93
|
hint: string
|
|
92
94
|
/** The help behind `?`: marks and keys with their meaning; the rules behind them stand in the README. */
|
|
93
95
|
legend: LegendSection[]
|
|
@@ -186,9 +188,10 @@ export const TEXTS: PlanTexts = {
|
|
|
186
188
|
checks: {
|
|
187
189
|
[CHECKS.passed]: '✓ checks',
|
|
188
190
|
[CHECKS.failed]: '✗ checks',
|
|
189
|
-
[CHECKS.pending]: '
|
|
191
|
+
[CHECKS.pending]: 'checks running',
|
|
190
192
|
[CHECKS.none]: 'no checks',
|
|
191
193
|
},
|
|
194
|
+
foldedDraft: 'draft',
|
|
192
195
|
hint: '↑↓ select →← open s html q quit ? help',
|
|
193
196
|
legend: [
|
|
194
197
|
{
|
|
@@ -302,6 +305,29 @@ export const laneMarkText = (lane: { unlisted?: boolean }): string | null => (la
|
|
|
302
305
|
/** The most red checks the checks column names; the rest is counted. */
|
|
303
306
|
export const NAMED_CHECKS = 3
|
|
304
307
|
|
|
308
|
+
/** The most characters of the red check a folded lane's badge names; a longer name ends in `…`. */
|
|
309
|
+
export const FOLDED_CHECK_NAME_LENGTH = 8
|
|
310
|
+
|
|
311
|
+
const cutCheckName = (name: string): string =>
|
|
312
|
+
name.length > FOLDED_CHECK_NAME_LENGTH ? `${name.slice(0, FOLDED_CHECK_NAME_LENGTH - 1)}…` : name
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The check state behind a folded lane's pull request badge, in the words of `TEXTS.checks` (`✓ checks`,
|
|
316
|
+
* `checks running`): `✗ test +1` names the first red check, cut to `FOLDED_CHECK_NAME_LENGTH`, and counts the others;
|
|
317
|
+
* nothing without checks, and `draft` for a draft, whose checks were mostly skipped.
|
|
318
|
+
*/
|
|
319
|
+
export const foldedChecksText = ({
|
|
320
|
+
checks,
|
|
321
|
+
failedChecks,
|
|
322
|
+
draft,
|
|
323
|
+
}: Pick<PullRequest, 'checks' | 'failedChecks' | 'draft'>): string => {
|
|
324
|
+
if (draft) return TEXTS.foldedDraft
|
|
325
|
+
if (checks === CHECKS.none) return ''
|
|
326
|
+
const [first, ...rest] = failedChecks
|
|
327
|
+
if (checks !== CHECKS.failed || first === undefined) return TEXTS.checks[checks]
|
|
328
|
+
return `✗ ${cutCheckName(first)}${rest.length > 0 ? ` +${rest.length}` : ''}`
|
|
329
|
+
}
|
|
330
|
+
|
|
305
331
|
/**
|
|
306
332
|
* The words of a pull request's check state: `✗ test, lint` names the red checks (at most `NAMED_CHECKS`, then
|
|
307
333
|
* `+<n>`), every other state and a red one without names is the fixed word of `PLAN_TEXTS.checks`.
|