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.
Files changed (83) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +27 -19
  3. package/assets/icons/skills.svg +1 -0
  4. package/dist/config.d.ts +9 -1
  5. package/dist/config.js +24 -1
  6. package/dist/plan-format.d.ts +24 -1
  7. package/dist/plan-format.js +25 -5
  8. package/docs/plan-contract.md +84 -0
  9. package/docs/skills-dark.svg +15 -2
  10. package/docs/skills.svg +15 -2
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/herdr-plugin/restore-plans.ts +56 -6
  13. package/install.sh +1 -1
  14. package/kit/hooks/hooks/merge-guard/board-settings.ts +5 -2
  15. package/kit/hooks/hooks/merge-guard/pull-request.ts +28 -15
  16. package/kit/hooks/hooks/merge-guard/run.ts +6 -3
  17. package/kit/hooks/hooks/merge-guard/settings.ts +12 -1
  18. package/kit/hooks/hooks/model-log/transcript.ts +4 -7
  19. package/kit/hooks/hooks/open-question/record.ts +4 -3
  20. package/kit/hooks/hooks/shared/dialog-answers.ts +7 -3
  21. package/kit/hooks/hooks/shared/log-vocabulary.ts +13 -0
  22. package/kit/hooks/hooks/shared/transcript.ts +7 -4
  23. package/kit/hooks/hooks/task-hint/decision.ts +3 -2
  24. package/kit/hooks/hooks/task-hint/run.ts +2 -4
  25. package/kit/rules/rules/kit/pull-requests.md +1 -0
  26. package/kit/workflow/skills/orchestrate/SKILL.md +22 -9
  27. package/kit/workflow/skills/orchestrate/handover/index.ts +8 -2
  28. package/kit/workflow/skills/orchestrate/references/adopt-plan.md +51 -0
  29. package/kit/workflow/skills/orchestrate/references/plan-commit.md +5 -5
  30. package/package.json +1 -1
  31. package/schemas/jackherd.schema.json +19 -0
  32. package/src/check/command.ts +1 -1
  33. package/src/check/file-kinds.ts +10 -8
  34. package/src/check/parallel-report.ts +10 -3
  35. package/src/check/settings.ts +4 -0
  36. package/src/check/skill-budget-defaults.ts +17 -5
  37. package/src/check/skill-budgets.ts +49 -19
  38. package/src/claude/paths.ts +12 -0
  39. package/src/cli.ts +1 -0
  40. package/src/config/schema.ts +27 -0
  41. package/src/context/fill.ts +2 -1
  42. package/src/context/log-vocabulary.ts +22 -0
  43. package/src/context/session-log.ts +2 -1
  44. package/src/lane-prompt/build.ts +10 -5
  45. package/src/lane-prompt/command.ts +6 -1
  46. package/src/lane-prompt/input.ts +4 -4
  47. package/src/lane-prompt/texts.ts +18 -10
  48. package/src/plan-contract/command.ts +69 -0
  49. package/src/plan-contract/contract.ts +144 -0
  50. package/src/plan-format/dependencies.ts +1 -4
  51. package/src/plan-format/index.ts +4 -0
  52. package/src/plan-format/lanes.ts +11 -0
  53. package/src/plan-format/markdown.ts +6 -5
  54. package/src/plan-format/model-table.ts +27 -0
  55. package/src/plans/activity-read.ts +98 -0
  56. package/src/plans/ask-coordinator.ts +20 -11
  57. package/src/plans/layout.ts +49 -4
  58. package/src/plans/live.ts +17 -4
  59. package/src/plans/plan-panes.ts +25 -5
  60. package/src/plans/snapshot.ts +18 -8
  61. package/src/plans/subagents-read.ts +4 -3
  62. package/src/plans/subagents.ts +2 -1
  63. package/src/plans/texts.ts +27 -1
  64. package/src/settled/send.ts +80 -4
  65. package/src/shared/parse-json.ts +8 -0
  66. package/src/sync/command.ts +17 -10
  67. package/src/sync/foreign-skills.ts +40 -0
  68. package/src/sync/global-kit.ts +31 -21
  69. package/src/sync/init.ts +2 -2
  70. package/src/sync/kit-exclude.ts +39 -0
  71. package/src/sync/plan-file.ts +1 -1
  72. package/src/sync/render.ts +2 -1
  73. package/src/sync/report.ts +37 -2
  74. package/src/sync/sync-result.ts +5 -2
  75. package/src/sync/sync.ts +13 -5
  76. package/src/task-signals/claude-signals.ts +6 -5
  77. package/src/watch/command.ts +56 -17
  78. package/src/watch/lane-message.ts +1 -1
  79. package/src/watch/lane-progress.ts +51 -2
  80. package/src/watch/lane-report.ts +57 -2
  81. package/src/watch/lane-watch.ts +2 -8
  82. package/src/watch/task-parallel-touched.ts +9 -2
  83. package/src/watch/texts.ts +4 -0
@@ -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 row = rowFrame(laneKey(view.progress.lane.name), lines, [], context)
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 { runHerdr } from '../terminal/herdr'
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 = askCoordinator(outcome.ask, {
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: runHerdr,
472
+ run: herdrRunnerAsync(process.env),
460
473
  })
461
474
  return
462
475
  }
@@ -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
- /** One herdr pane as `herd --ensure` sees it: where it is, whether an agent runs in it, its foreground commands. */
5
- export type PaneState = { pane: string; tab: string; cwd: string; agent: boolean; foreground: string[][] }
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 a free shell whose
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 free = tab.find((each) => isHome(each.cwd) && isIdleShell(each))
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
 
@@ -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, SubagentActivity>
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, SubagentActivity>
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(ANSWER_MARK))
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, SubagentActivity>> => {
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, SubagentActivity>> => {
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
- return subagentsByLane(runningOf(logs, source, now), source.worktreeDirectory)
183
+ const running = runningOf(logs, source, now)
184
+ return withSubagentLines(subagentsByLane(running, source.worktreeDirectory), running, logs, source.worktreeDirectory)
184
185
  }
@@ -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 !== 'tool_use' || !isRecord(block.input)) return []
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
  )
@@ -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]: '… checks',
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`.
@@ -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 type { HerdrRunner } from '../terminal/herdr'
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[] => ['agent', 'prompt', lane, message]
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
- const NO_TERMINAL = `no terminal to reach the lane (terminal.adapter is ${TERMINAL_ADAPTER.none})`
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 errorText(error)
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
  }
@@ -0,0 +1,8 @@
1
+ /** The parsed value of `text`, or null when it is not JSON. */
2
+ export const parseJsonOrNull = (text: string): unknown => {
3
+ try {
4
+ return JSON.parse(text)
5
+ } catch {
6
+ return null
7
+ }
8
+ }
@@ -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 runWithKit(
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
+ })