jackherd 1.14.0 → 1.15.1

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 (84) hide show
  1. package/CHANGELOG.md +20 -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/decision.ts +3 -1
  16. package/kit/hooks/hooks/merge-guard/pull-request.ts +28 -15
  17. package/kit/hooks/hooks/merge-guard/run.ts +6 -3
  18. package/kit/hooks/hooks/merge-guard/settings.ts +12 -1
  19. package/kit/hooks/hooks/model-log/transcript.ts +4 -7
  20. package/kit/hooks/hooks/open-question/record.ts +4 -3
  21. package/kit/hooks/hooks/shared/dialog-answers.ts +7 -3
  22. package/kit/hooks/hooks/shared/log-vocabulary.ts +13 -0
  23. package/kit/hooks/hooks/shared/transcript.ts +7 -4
  24. package/kit/hooks/hooks/task-hint/decision.ts +3 -2
  25. package/kit/hooks/hooks/task-hint/run.ts +2 -4
  26. package/kit/rules/rules/kit/pull-requests.md +1 -0
  27. package/kit/workflow/skills/orchestrate/SKILL.md +22 -9
  28. package/kit/workflow/skills/orchestrate/handover/index.ts +8 -2
  29. package/kit/workflow/skills/orchestrate/references/adopt-plan.md +51 -0
  30. package/kit/workflow/skills/orchestrate/references/plan-commit.md +5 -5
  31. package/package.json +1 -1
  32. package/schemas/jackherd.schema.json +19 -0
  33. package/src/check/command.ts +1 -1
  34. package/src/check/file-kinds.ts +10 -8
  35. package/src/check/parallel-report.ts +10 -3
  36. package/src/check/settings.ts +4 -0
  37. package/src/check/skill-budget-defaults.ts +17 -5
  38. package/src/check/skill-budgets.ts +49 -19
  39. package/src/claude/paths.ts +12 -0
  40. package/src/cli.ts +1 -0
  41. package/src/config/schema.ts +27 -0
  42. package/src/context/fill.ts +2 -1
  43. package/src/context/log-vocabulary.ts +22 -0
  44. package/src/context/session-log.ts +2 -1
  45. package/src/lane-prompt/build.ts +10 -5
  46. package/src/lane-prompt/command.ts +6 -1
  47. package/src/lane-prompt/input.ts +4 -4
  48. package/src/lane-prompt/texts.ts +18 -10
  49. package/src/plan-contract/command.ts +69 -0
  50. package/src/plan-contract/contract.ts +144 -0
  51. package/src/plan-format/dependencies.ts +1 -4
  52. package/src/plan-format/index.ts +4 -0
  53. package/src/plan-format/lanes.ts +11 -0
  54. package/src/plan-format/markdown.ts +6 -5
  55. package/src/plan-format/model-table.ts +27 -0
  56. package/src/plans/activity-read.ts +98 -0
  57. package/src/plans/ask-coordinator.ts +20 -11
  58. package/src/plans/layout.ts +49 -4
  59. package/src/plans/live.ts +17 -4
  60. package/src/plans/plan-panes.ts +25 -5
  61. package/src/plans/snapshot.ts +18 -8
  62. package/src/plans/subagents-read.ts +4 -3
  63. package/src/plans/subagents.ts +2 -1
  64. package/src/plans/texts.ts +27 -1
  65. package/src/settled/send.ts +80 -4
  66. package/src/shared/parse-json.ts +8 -0
  67. package/src/sync/command.ts +17 -10
  68. package/src/sync/foreign-skills.ts +40 -0
  69. package/src/sync/global-kit.ts +31 -21
  70. package/src/sync/init.ts +2 -2
  71. package/src/sync/kit-exclude.ts +39 -0
  72. package/src/sync/plan-file.ts +1 -1
  73. package/src/sync/render.ts +2 -1
  74. package/src/sync/report.ts +37 -2
  75. package/src/sync/sync-result.ts +5 -2
  76. package/src/sync/sync.ts +13 -5
  77. package/src/task-signals/claude-signals.ts +6 -5
  78. package/src/watch/command.ts +56 -17
  79. package/src/watch/lane-message.ts +1 -1
  80. package/src/watch/lane-progress.ts +51 -2
  81. package/src/watch/lane-report.ts +57 -2
  82. package/src/watch/lane-watch.ts +2 -8
  83. package/src/watch/task-parallel-touched.ts +9 -2
  84. package/src/watch/texts.ts +4 -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 { laneSenderFor } from '../settled/send'
4
- import { type HerdrRunner, parseAgentList } from '../terminal/herdr'
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: HerdrRunner
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: HerdrRunner): string[] => parseAgentList(run(['agent', 'list'])).map(({ name }) => name)
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 = ({ adapter, repository, run }: AskDependencies): { name: string } | { reason: string } => {
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. Returns the
100
- * footer line: who was asked, or why nobody was.
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 = laneSenderFor(dependencies.adapter, dependencies.run)(
115
+ const failure = await laneSenderForAsync(dependencies.adapter, dependencies.run)(
107
116
  coordinator.name,
108
117
  askLine(target, dependencies.language),
109
118
  )
@@ -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`.