jackherd 1.14.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 +15 -0
- package/README.md +27 -19
- package/assets/icons/skills.svg +1 -0
- package/dist/config.d.ts +9 -1
- package/dist/config.js +24 -1
- 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 +22 -9
- 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/plan-commit.md +5 -5
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +19 -0
- package/src/check/command.ts +1 -1
- package/src/check/file-kinds.ts +10 -8
- package/src/check/parallel-report.ts +10 -3
- package/src/check/settings.ts +4 -0
- package/src/check/skill-budget-defaults.ts +17 -5
- 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/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 +56 -17
- package/src/watch/lane-message.ts +1 -1
- package/src/watch/lane-progress.ts +51 -2
- package/src/watch/lane-report.ts +57 -2
- package/src/watch/lane-watch.ts +2 -8
- package/src/watch/task-parallel-touched.ts +9 -2
- package/src/watch/texts.ts +4 -0
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`.
|
package/src/settled/send.ts
CHANGED
|
@@ -1,17 +1,80 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
1
2
|
import { TERMINAL_ADAPTER, type TerminalAdapter } from '../config'
|
|
2
3
|
import { errorText } from '../plans/error-text'
|
|
3
|
-
import
|
|
4
|
+
import { parseJsonOrNull } from '../shared/parse-json'
|
|
5
|
+
import type { HerdrAsyncRunner, HerdrRunner } from '../terminal/herdr'
|
|
6
|
+
import { AGENT_STATUS } from '../terminal/types'
|
|
4
7
|
|
|
5
8
|
/** Hands a text to a lane's agent; returns the error text, or null when the agent took it. */
|
|
6
9
|
export type LaneSender = (lane: string, message: string) => string | null
|
|
7
10
|
|
|
11
|
+
/** As `LaneSender`, for a caller that must not hold the event loop while herdr answers. */
|
|
12
|
+
export type AsyncLaneSender = (lane: string, message: string) => Promise<string | null>
|
|
13
|
+
|
|
14
|
+
/** How long herdr itself waits for a working state before it reports `agent_prompt_stalled`. */
|
|
15
|
+
export const HERDR_STALL_WINDOW_MS = 5_000
|
|
16
|
+
|
|
17
|
+
/** Longer than herdr's own stall window, so a stalled paste is reported as stalled and not as a timeout. */
|
|
18
|
+
export const PROMPT_CONFIRM_TIMEOUT_MS = 10_000
|
|
19
|
+
|
|
20
|
+
/** The error codes of `herdr agent prompt --wait` that mean the agent never started working. */
|
|
21
|
+
const HERDR_PROMPT_ERROR = { stalled: 'agent_prompt_stalled', timeout: 'timeout' } as const
|
|
22
|
+
|
|
23
|
+
const herdrErrorSchema = z
|
|
24
|
+
.object({ error: z.object({ code: z.string(), message: z.string().optional() }).loose() })
|
|
25
|
+
.loose()
|
|
26
|
+
|
|
27
|
+
type HerdrError = z.infer<typeof herdrErrorSchema>['error']
|
|
28
|
+
|
|
29
|
+
/** What came before herdr's JSON (the command's name), and the error in it. */
|
|
30
|
+
type ParsedHerdrError = { prefix: string; error: HerdrError }
|
|
31
|
+
|
|
32
|
+
const notWorking = (reason: string): string => `the agent did not start working after the prompt (${reason})`
|
|
33
|
+
|
|
34
|
+
const PROMPT_FAILURE_TEXT: Record<string, string> = {
|
|
35
|
+
[HERDR_PROMPT_ERROR.stalled]: notWorking(
|
|
36
|
+
`herdr: the text sits as a paste, nothing happened within ${HERDR_STALL_WINDOW_MS / 1000} s`,
|
|
37
|
+
),
|
|
38
|
+
[HERDR_PROMPT_ERROR.timeout]: notWorking(`herdr: no working state within ${PROMPT_CONFIRM_TIMEOUT_MS / 1000} s`),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const NO_TERMINAL = `no terminal to reach the lane (terminal.adapter is ${TERMINAL_ADAPTER.none})`
|
|
42
|
+
|
|
8
43
|
/**
|
|
9
44
|
* `herdr agent prompt <lane> <text>`, the way the coordinator prompts a lane: the text as one argument of the
|
|
10
45
|
* process, so no shell expands its backticks or `$(…)`.
|
|
11
46
|
*/
|
|
12
|
-
export const promptArguments = (lane: string, message: string): string[] => [
|
|
47
|
+
export const promptArguments = (lane: string, message: string): string[] => [
|
|
48
|
+
'agent',
|
|
49
|
+
'prompt',
|
|
50
|
+
lane,
|
|
51
|
+
message,
|
|
52
|
+
'--wait',
|
|
53
|
+
'--until',
|
|
54
|
+
AGENT_STATUS.working,
|
|
55
|
+
'--timeout',
|
|
56
|
+
String(PROMPT_CONFIRM_TIMEOUT_MS),
|
|
57
|
+
]
|
|
13
58
|
|
|
14
|
-
|
|
59
|
+
/** The `{"error": …}` herdr prints after the command's name, or null for any other text. */
|
|
60
|
+
const herdrErrorOf = (text: string): ParsedHerdrError | null => {
|
|
61
|
+
const start = text.indexOf('{')
|
|
62
|
+
const parsed = start < 0 ? null : herdrErrorSchema.safeParse(parseJsonOrNull(text.slice(start)))
|
|
63
|
+
if (!parsed?.success) return null
|
|
64
|
+
return { prefix: text.slice(0, start).replace(/:\s*$/, ''), error: parsed.data.error }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const unknownFailureText = ({ prefix, error }: ParsedHerdrError): string =>
|
|
68
|
+
[prefix, error.code, error.message].filter((part) => part !== undefined && part !== '').join(': ')
|
|
69
|
+
|
|
70
|
+
/** Turns the text of a failed `herdr agent prompt` into one that says what happened; unknown text stays as it is. */
|
|
71
|
+
export const promptFailureText = (text: string): string => {
|
|
72
|
+
const parsed = herdrErrorOf(text)
|
|
73
|
+
if (parsed === null) return text
|
|
74
|
+
return PROMPT_FAILURE_TEXT[parsed.error.code] ?? unknownFailureText(parsed)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const sendFailureText = (error: unknown): string => promptFailureText(errorText(error))
|
|
15
78
|
|
|
16
79
|
/** The sender for the configured terminal; without one (`none`) every send fails with the reason. */
|
|
17
80
|
export const laneSenderFor =
|
|
@@ -22,6 +85,19 @@ export const laneSenderFor =
|
|
|
22
85
|
run(promptArguments(lane, message))
|
|
23
86
|
return null
|
|
24
87
|
} catch (error) {
|
|
25
|
-
return
|
|
88
|
+
return sendFailureText(error)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** As `laneSenderFor`, awaiting herdr instead of holding the event loop for up to `PROMPT_CONFIRM_TIMEOUT_MS`. */
|
|
93
|
+
export const laneSenderForAsync =
|
|
94
|
+
(adapter: TerminalAdapter, run: HerdrAsyncRunner): AsyncLaneSender =>
|
|
95
|
+
async (lane, message) => {
|
|
96
|
+
if (adapter === TERMINAL_ADAPTER.none) return NO_TERMINAL
|
|
97
|
+
try {
|
|
98
|
+
await run(promptArguments(lane, message))
|
|
99
|
+
return null
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return sendFailureText(error)
|
|
26
102
|
}
|
|
27
103
|
}
|
package/src/sync/command.ts
CHANGED
|
@@ -11,8 +11,9 @@ import {
|
|
|
11
11
|
usageError,
|
|
12
12
|
} from './command-context'
|
|
13
13
|
import { errorMessage } from './error-message'
|
|
14
|
+
import { readForeignSkills } from './foreign-skills'
|
|
14
15
|
import { dailyUpdateNotice, defaultUpdateCheck, lockedVersion, syncNeeded, type UpdateCheck } from './if-changed'
|
|
15
|
-
import { SYNC_EXIT } from './report'
|
|
16
|
+
import { foreignSkillLines, SYNC_EXIT } from './report'
|
|
16
17
|
import { migrateGlobalSettings } from './settings-migration'
|
|
17
18
|
import { runGlobalSync, runSync } from './sync'
|
|
18
19
|
|
|
@@ -101,6 +102,20 @@ const syncGlobalIfChanged = async (context: CommandContext, accept: string[], ch
|
|
|
101
102
|
return exitCode
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
const syncProject = (context: CommandContext, accept: string[], dryRun: boolean): Promise<number> =>
|
|
106
|
+
runWithKit(
|
|
107
|
+
context,
|
|
108
|
+
'sync',
|
|
109
|
+
async (kit) => {
|
|
110
|
+
const result = await runSync({ projectRoot: context.projectRoot, home: context.home, kit, accept, check: dryRun })
|
|
111
|
+
const foreignLines = foreignSkillLines(result, readForeignSkills(context.projectRoot))
|
|
112
|
+
const foreign = foreignLines.length > 0 ? [foreignLines.join('\n')] : []
|
|
113
|
+
const dryRunNote = dryRun ? [`${CLI_NAME} sync ${CHECK_FLAG}: nothing is written`] : []
|
|
114
|
+
return { notes: [...dryRunNote, ...foreign], result }
|
|
115
|
+
},
|
|
116
|
+
dryRun,
|
|
117
|
+
)
|
|
118
|
+
|
|
104
119
|
export const runSyncCommand = async (
|
|
105
120
|
args: string[],
|
|
106
121
|
context: CommandContext = defaultContext(),
|
|
@@ -113,15 +128,7 @@ export const runSyncCommand = async (
|
|
|
113
128
|
if (global) return syncGlobal(context, accept)
|
|
114
129
|
if (isConfigWithoutInstall(context.projectRoot, existsSync))
|
|
115
130
|
return usageError(context, notInstalledLine(context.projectRoot))
|
|
116
|
-
return
|
|
117
|
-
context,
|
|
118
|
-
'sync',
|
|
119
|
-
async (kit) => ({
|
|
120
|
-
notes: dryRun ? [`${CLI_NAME} sync ${CHECK_FLAG}: nothing is written`] : [],
|
|
121
|
-
result: await runSync({ projectRoot: context.projectRoot, home: context.home, kit, accept, check: dryRun }),
|
|
122
|
-
}),
|
|
123
|
-
dryRun,
|
|
124
|
-
)
|
|
131
|
+
return syncProject(context, accept, dryRun)
|
|
125
132
|
}
|
|
126
133
|
|
|
127
134
|
export const run = (args: string[]): Promise<number> => runSyncCommand(args)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { SKILLS_DIRECTORY } from '../claude/paths'
|
|
5
|
+
|
|
6
|
+
/** Written by `npx skills add` into the project root; only the skill names are read. */
|
|
7
|
+
export const SKILLS_LOCK_FILE = 'skills-lock.json'
|
|
8
|
+
|
|
9
|
+
const skillsLockSchema = z.object({ skills: z.record(z.string(), z.unknown()) }).loose()
|
|
10
|
+
|
|
11
|
+
const SKILL_ENTRY_FILE = 'SKILL.md'
|
|
12
|
+
const SKILL_ENTRY = new RegExp(`(?:^|/)${SKILLS_DIRECTORY}/([^/]+)/${SKILL_ENTRY_FILE.replace('.', '\\.')}$`)
|
|
13
|
+
|
|
14
|
+
/** The kit path (relative to `.claude/`) of a skill's entry file. */
|
|
15
|
+
export const skillEntryPath = (name: string): string => `${SKILLS_DIRECTORY}/${name}/${SKILL_ENTRY_FILE}`
|
|
16
|
+
|
|
17
|
+
/** The skill an entry file path belongs to, from a kit path or a project path; undefined for any other file. */
|
|
18
|
+
export const skillNameOfEntry = (path: string): string | undefined => SKILL_ENTRY.exec(path)?.[1]
|
|
19
|
+
|
|
20
|
+
/** The skill names `skills-lock.json` lists; anything that is not that file's shape is the empty set. */
|
|
21
|
+
export const parseForeignSkills = (value: unknown): ReadonlySet<string> => {
|
|
22
|
+
const parsed = skillsLockSchema.safeParse(value)
|
|
23
|
+
return new Set(parsed.success ? Object.keys(parsed.data.skills) : [])
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The foreign skills of a project: the empty set when the lock file is missing, unreadable or invalid. */
|
|
27
|
+
export const readForeignSkills = (root: string): ReadonlySet<string> => {
|
|
28
|
+
try {
|
|
29
|
+
return parseForeignSkills(JSON.parse(readFileSync(join(root, SKILLS_LOCK_FILE), 'utf8')))
|
|
30
|
+
} catch {
|
|
31
|
+
return new Set()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Of the kit paths (relative to `.claude/`), the names of the skills whose entry file a foreign skill sits on. */
|
|
36
|
+
export const foreignSkillNamesOf = (kitPaths: readonly string[], foreign: ReadonlySet<string>): string[] =>
|
|
37
|
+
kitPaths.flatMap((path) => {
|
|
38
|
+
const name = skillNameOfEntry(path)
|
|
39
|
+
return name !== undefined && foreign.has(name) ? [name] : []
|
|
40
|
+
})
|