jackherd 1.8.1 → 1.9.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 (111) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/INSTALL.md +2 -0
  3. package/README.md +37 -44
  4. package/dist/{chunk-OU2KALXP.js → chunk-MCP4XJQH.js} +15 -0
  5. package/dist/config.d.ts +6 -0
  6. package/dist/config.js +19 -3
  7. package/dist/plan-format.d.ts +19 -2
  8. package/dist/plan-format.js +21 -7
  9. package/herdr-plugin/herdr-plugin.toml +1 -1
  10. package/install.sh +1 -1
  11. package/kit/hooks/hooks/approval-gate/index.ts +22 -4
  12. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  13. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  14. package/kit/hooks/hooks/merge-guard/decision.ts +67 -20
  15. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  16. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  17. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  18. package/kit/hooks/hooks/sent-file/store.ts +18 -0
  19. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  20. package/kit/hooks/hooks.json +9 -0
  21. package/kit/hooks/statusline.sh +1 -1
  22. package/kit/workflow/skills/implement/SKILL.md +2 -0
  23. package/kit/workflow/skills/orchestrate/SKILL.md +30 -6
  24. package/package.json +2 -1
  25. package/schemas/jackherd.schema.json +26 -2
  26. package/src/cli.ts +3 -0
  27. package/src/config/schema.ts +22 -2
  28. package/src/context/fill.ts +12 -0
  29. package/src/context/session-log.ts +43 -1
  30. package/src/plan-format/clock.ts +4 -0
  31. package/src/plan-format/dependencies.ts +2 -5
  32. package/src/plan-format/lanes.ts +30 -5
  33. package/src/plan-format/terminal-text.ts +4 -0
  34. package/src/plan-format/waiting.ts +48 -0
  35. package/src/plans/active-tasks.ts +60 -0
  36. package/src/plans/current-phase.ts +17 -7
  37. package/src/plans/current-task.ts +63 -5
  38. package/src/plans/lane-standing.ts +9 -4
  39. package/src/plans/lane-summary.ts +141 -74
  40. package/src/plans/lane-tickets.ts +15 -0
  41. package/src/plans/layout-phase.ts +57 -18
  42. package/src/plans/layout-summary.ts +85 -67
  43. package/src/plans/layout.ts +253 -89
  44. package/src/plans/live.ts +22 -0
  45. package/src/plans/phase-models.ts +1 -1
  46. package/src/plans/phase-ranges.ts +45 -0
  47. package/src/plans/pull-requests.ts +6 -0
  48. package/src/plans/selection.ts +19 -16
  49. package/src/plans/sent-files-read.ts +22 -0
  50. package/src/plans/sent-files.ts +121 -0
  51. package/src/plans/signals-read.ts +54 -0
  52. package/src/plans/snapshot.ts +251 -55
  53. package/src/plans/subagents-read.ts +82 -0
  54. package/src/plans/subagents.ts +132 -0
  55. package/src/plans/texts.ts +16 -11
  56. package/src/plans/unlisted-lanes.ts +10 -5
  57. package/src/plans/view.ts +3 -3
  58. package/src/plans/waiting-click.ts +76 -0
  59. package/src/preview/command.ts +83 -0
  60. package/src/preview/embed.ts +209 -0
  61. package/src/preview/images.ts +89 -0
  62. package/src/preview/open.ts +12 -0
  63. package/src/preview/options.ts +32 -0
  64. package/src/preview/page.ts +89 -0
  65. package/src/preview/render.ts +41 -0
  66. package/src/settled/board-row.ts +1 -2
  67. package/src/shared/is-record.ts +2 -0
  68. package/src/shared/private-records.ts +48 -0
  69. package/src/task-signals/agent.ts +15 -0
  70. package/src/task-signals/claude-read.ts +72 -0
  71. package/src/task-signals/claude-signals.ts +112 -0
  72. package/src/task-signals/command.ts +71 -0
  73. package/src/task-signals/fixtures/session.jsonl +7 -0
  74. package/src/task-signals/matcher.ts +96 -0
  75. package/src/task-signals/record-store.ts +40 -0
  76. package/src/task-signals/record.ts +68 -0
  77. package/src/task-signals/text.ts +13 -0
  78. package/src/task-signals/types.ts +14 -0
  79. package/src/terminal/open.ts +13 -6
  80. package/src/terminal/text.ts +8 -1
  81. package/src/terminal/width.ts +2 -1
  82. package/src/vocabulary/index.ts +28 -0
  83. package/src/waiting/command.ts +35 -0
  84. package/src/watch/appointments.ts +2 -3
  85. package/src/watch/approvals.ts +2 -2
  86. package/src/watch/command.ts +91 -5
  87. package/src/watch/failed-tests.ts +56 -10
  88. package/src/watch/failure-read.ts +18 -6
  89. package/src/watch/failures.ts +29 -5
  90. package/src/watch/feedback.ts +1 -1
  91. package/src/watch/gh-process.ts +9 -3
  92. package/src/watch/json-guards.ts +0 -3
  93. package/src/watch/lane-fill.ts +1 -1
  94. package/src/watch/lane-model.ts +1 -1
  95. package/src/watch/lane-parallel.ts +1 -1
  96. package/src/watch/lane-progress.ts +19 -7
  97. package/src/watch/lane-report.ts +5 -1
  98. package/src/watch/lane-rest.ts +28 -0
  99. package/src/watch/plan-badges.ts +9 -2
  100. package/src/watch/previews.ts +1 -1
  101. package/src/watch/pull-requests.ts +2 -1
  102. package/src/watch/source-path.ts +1 -1
  103. package/src/watch/sources.ts +1 -1
  104. package/src/watch/stream-output.ts +12 -7
  105. package/src/watch/task-parallel-input.ts +118 -0
  106. package/src/watch/task-parallel-read.ts +149 -0
  107. package/src/watch/task-parallel-touched.ts +39 -0
  108. package/src/watch/task-parallel.ts +206 -0
  109. package/src/watch/waiting-line.ts +19 -0
  110. package/src/watch/watch-events.ts +13 -1
  111. package/src/watch/workflow-runs.ts +2 -1
@@ -0,0 +1,68 @@
1
+ import { isRecord } from '../shared/is-record'
2
+ import { cleanAgentName, cleanTaskText, MAX_TASK_TEXT } from './text'
3
+ import type { TaskSignal } from './types'
4
+
5
+ /** What `jh task start` stores in `.jackherd/tasks/<id>.json`: one record per agent, closed by `jh task done`. */
6
+ export type TaskRecord = {
7
+ id: string
8
+ agent: string
9
+ text: string
10
+ startedAt: number
11
+ cwd: string
12
+ closedAt: number | null
13
+ }
14
+
15
+ const ID = /^[0-9a-f]{12}$/
16
+ const ID_BYTES = 6
17
+
18
+ /** The id of a record from random bytes the caller drew, so a test reaches the drawing; 12 hex characters. */
19
+ export const buildTaskId = (randomBytes: Uint8Array): string =>
20
+ [...randomBytes.slice(0, ID_BYTES)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
21
+
22
+ export const isTaskRecord = (value: unknown): value is TaskRecord => {
23
+ if (!isRecord(value)) return false
24
+ const record = value
25
+ return (
26
+ typeof record.id === 'string' &&
27
+ ID.test(record.id) &&
28
+ typeof record.agent === 'string' &&
29
+ typeof record.text === 'string' &&
30
+ record.text.length <= MAX_TASK_TEXT &&
31
+ typeof record.startedAt === 'number' &&
32
+ typeof record.cwd === 'string' &&
33
+ (record.closedAt === null || typeof record.closedAt === 'number')
34
+ )
35
+ }
36
+
37
+ /** A fresh open record; the text and the agent name are cleaned here, whoever wrote them. */
38
+ export const openRecord = (
39
+ fields: { id: string; agent: string; text: string; cwd: string },
40
+ now: number,
41
+ ): TaskRecord => ({
42
+ id: fields.id,
43
+ agent: cleanAgentName(fields.agent),
44
+ text: cleanTaskText(fields.text),
45
+ startedAt: now,
46
+ cwd: fields.cwd,
47
+ closedAt: null,
48
+ })
49
+
50
+ /** Whether `text` names the record's task: empty text names any, else it is the start of the record's text. */
51
+ export const namesTask = (record: TaskRecord, text: string): boolean => {
52
+ const wanted = cleanTaskText(text)
53
+ return wanted === '' || record.text.startsWith(wanted)
54
+ }
55
+
56
+ /** The open records as signals; a record cleaned on write is cleaned again on read, as a hand-written file may differ. */
57
+ export const recordSignals = (records: readonly TaskRecord[]): TaskSignal[] =>
58
+ records
59
+ .filter(({ closedAt }) => closedAt === null)
60
+ .map(({ agent, text, startedAt, cwd }) => ({
61
+ agent: cleanAgentName(agent),
62
+ source: 'jh' as const,
63
+ text: cleanTaskText(text),
64
+ startedAt,
65
+ cwd,
66
+ }))
67
+ .filter(({ text }) => text !== '')
68
+ .sort((left, right) => left.startedAt - right.startedAt)
@@ -0,0 +1,13 @@
1
+ import { terminalSafe } from '../plan-format/terminal-text'
2
+
3
+ /** The longest task text a signal carries; a plan task is matched on its first 200 characters as well. */
4
+ export const MAX_TASK_TEXT = 200
5
+ const MAX_AGENT_NAME = 100
6
+
7
+ const capped = (text: string, max: number): string => terminalSafe(text.replace(/\s+/g, ' ')).trim().slice(0, max)
8
+
9
+ /** Agent-written task text made safe to store and print: no control characters, one line, at most 200 characters. */
10
+ export const cleanTaskText = (text: string): string => capped(text, MAX_TASK_TEXT)
11
+
12
+ /** The same for the name of an agent. */
13
+ export const cleanAgentName = (name: string): string => capped(name, MAX_AGENT_NAME)
@@ -0,0 +1,14 @@
1
+ /** Who reported a task: Claude Code's task list, Codex's plan tool, or `jh task start`. */
2
+ export type TaskSource = 'claude' | 'codex' | 'jh'
3
+
4
+ /**
5
+ * One task an agent is working on right now. `agent` names the session or subagent that reported it, `startedAt` is
6
+ * epoch milliseconds, `cwd` the directory the agent worked in (its worktree tells the lane).
7
+ */
8
+ export type TaskSignal = {
9
+ agent: string
10
+ source: TaskSource
11
+ text: string
12
+ startedAt: number
13
+ cwd: string
14
+ }
@@ -5,10 +5,16 @@ export type OpenEnvironment = {
5
5
  start: (command: string, args: string[]) => void
6
6
  }
7
7
 
8
- const OPENERS: Partial<Record<NodeJS.Platform, string>> = { darwin: 'open', linux: 'xdg-open' }
8
+ const OPENERS: Partial<Record<NodeJS.Platform, (target: string) => string[]>> = {
9
+ darwin: (target) => ['open', target],
10
+ linux: (target) => ['xdg-open', target],
11
+ /** Not `cmd /c start`: cmd reads `&`, `|`, `%` and `^` in the target as its own syntax; rundll32 takes it as one argument. */
12
+ win32: (target) => ['rundll32', 'url.dll,FileProtocolHandler', target],
13
+ }
9
14
 
10
- /** The command that opens a file or a link in the default application of the platform, null when there is none. */
11
- export const openerFor = (platform: NodeJS.Platform): string | null => OPENERS[platform] ?? null
15
+ /** The command and arguments that open a file or a link in the default application of the platform, null when there is none. */
16
+ export const openCommandFor = (platform: NodeJS.Platform, target: string): string[] | null =>
17
+ OPENERS[platform]?.(target) ?? null
12
18
 
13
19
  /** Linux opens things in a graphical session only; elsewhere the platform's opener is assumed to work. */
14
20
  export const hasDisplay = ({ platform, env }: Pick<OpenEnvironment, 'platform' | 'env'>): boolean =>
@@ -27,11 +33,12 @@ const systemEnvironment = (): OpenEnvironment => ({
27
33
  * opener for the platform, no display, the opener could not be started), null when it was started.
28
34
  */
29
35
  export const openInBrowser = (target: string, environment: OpenEnvironment = systemEnvironment()): string | null => {
30
- const opener = openerFor(environment.platform)
31
- if (!opener) return `no way to open ${target} on ${environment.platform}`
36
+ const command = openCommandFor(environment.platform, target)
37
+ if (!command) return `no way to open ${target} on ${environment.platform}`
32
38
  if (!hasDisplay(environment)) return 'no display'
39
+ const [opener, ...args] = command
33
40
  try {
34
- environment.start(opener, [target])
41
+ environment.start(opener, args)
35
42
  return null
36
43
  } catch (error) {
37
44
  return `${opener} ${target}: ${error instanceof Error ? error.message : String(error)}`
@@ -34,6 +34,13 @@ const FAINT_OFF = `${ESC}[22m`
34
34
  const BAR_WIDTH = 10
35
35
 
36
36
  const style = (code: string) => (text: string) => `${ESC}[${code}m${text}${RESET}`
37
+ const BRAND_TEAL_TRUE_COLOUR = '38;2;3;181;189'
38
+ const BRAND_TEAL_256 = '38;5;37'
39
+
40
+ /** The brand teal `#03B5BD` as true colour where `COLORTERM` says `truecolor` or `24bit`, else 256-colour 37. */
41
+ export const tealCodeOf = (env: Record<string, string | undefined>): string =>
42
+ env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit' ? BRAND_TEAL_TRUE_COLOUR : BRAND_TEAL_256
43
+
37
44
  export const paint = {
38
45
  bold: style('1'),
39
46
  dim: style('2'),
@@ -41,7 +48,7 @@ export const paint = {
41
48
  green: style('32'),
42
49
  yellow: style('33'),
43
50
  magenta: style('35'),
44
- teal: style('38;5;37'),
51
+ teal: style(tealCodeOf(process.env)),
45
52
  cyan: style('36'),
46
53
  inverse: style('7'),
47
54
  }
@@ -1,4 +1,5 @@
1
1
  const VARIATION_SELECTOR = 0xfe0f
2
+ const TEXT_SELECTOR = 0xfe0e
2
3
  const ZERO_WIDTH_JOINER = 0x200d
3
4
  const COMBINING_MARKS: readonly [number, number][] = [[0x0300, 0x036f]]
4
5
 
@@ -59,7 +60,7 @@ const within = (ranges: readonly [number, number][], codePoint: number): boolean
59
60
  * variation selector (`⏸`) is one cell on its own; `textWidth` counts it as two when the selector follows.
60
61
  */
61
62
  export const cellWidth = (codePoint: number): number => {
62
- if (codePoint === VARIATION_SELECTOR || codePoint === ZERO_WIDTH_JOINER) return 0
63
+ if (codePoint === VARIATION_SELECTOR || codePoint === TEXT_SELECTOR || codePoint === ZERO_WIDTH_JOINER) return 0
63
64
  if (within(COMBINING_MARKS, codePoint)) return 0
64
65
  return within(WIDE, codePoint) ? 2 : 1
65
66
  }
@@ -93,6 +93,34 @@ export const APPOINTMENT_VOCABULARY: Record<
93
93
  },
94
94
  }
95
95
 
96
+ /**
97
+ * The board section of what waits on the user's answer (a sent preview, an open question, a report to accept): its
98
+ * heading, the header names of its columns (since, item, origin, status, found by name in any order; the status
99
+ * column may also carry a name of `statusAlso`) and the lower-case words a status cell of a still open item starts with.
100
+ */
101
+ export const WAITING_VOCABULARY: Record<
102
+ PlanLanguage,
103
+ {
104
+ heading: string
105
+ columns: { since: string; item: string; origin: string; status: string }
106
+ statusAlso: readonly string[]
107
+ open: readonly string[]
108
+ }
109
+ > = {
110
+ [PLAN_LANGUAGE.de]: {
111
+ heading: 'Wartet auf dich',
112
+ columns: { since: 'Seit', item: 'Punkt', origin: 'Herkunft', status: 'Stand' },
113
+ statusAlso: ['Status'],
114
+ open: ['offen'],
115
+ },
116
+ [PLAN_LANGUAGE.en]: {
117
+ heading: 'Waiting for you',
118
+ columns: { since: 'Since', item: 'Item', origin: 'Origin', status: 'Status' },
119
+ statusAlso: [],
120
+ open: ['open'],
121
+ },
122
+ }
123
+
96
124
  export const isPlanLanguage = (value: string): value is PlanLanguage =>
97
125
  (Object.values(PLAN_LANGUAGE) as string[]).includes(value)
98
126
 
@@ -0,0 +1,35 @@
1
+ import { terminalSafe } from '../plan-format/terminal-text'
2
+ import type { WaitingItem } from '../plan-format/waiting'
3
+ import { mainCheckout } from '../plans/git'
4
+ import { sentWaitingItems } from '../plans/sent-files'
5
+ import { closeAllSentRecords, closeSentRecords, readSentRecords } from '../plans/sent-files-read'
6
+ import { CLI_NAME } from '../version/release'
7
+
8
+ const USAGE = `usage: ${CLI_NAME} waiting [close <id> ... | close --all]`
9
+
10
+ /** The lines `jh waiting` prints: id, time and text of each item, made safe for the terminal; a note when none waits. */
11
+ export const waitingListing = (items: readonly WaitingItem[]): string[] =>
12
+ items.length === 0
13
+ ? ['nothing waits on you']
14
+ : items.map(({ since, item, id }) => terminalSafe(`${id} · ${since} · ${item}`))
15
+
16
+ /**
17
+ * `jh waiting` lists the files sent to the user that no one closed (their id, time and caption);
18
+ * `jh waiting close <id> ...` or `close --all` closes them once the user has answered. Exit 0, 1 on a wrong command line.
19
+ */
20
+ export const run = async (args: string[]): Promise<number> => {
21
+ const root = mainCheckout(process.cwd())
22
+ if (args.length === 0) {
23
+ for (const line of waitingListing(sentWaitingItems(readSentRecords(root), Date.now()))) console.log(line)
24
+ return 0
25
+ }
26
+ const [command, ...rest] = args
27
+ if (command !== 'close' || rest.length === 0) {
28
+ console.error(USAGE)
29
+ return 1
30
+ }
31
+ const now = Date.now()
32
+ const closed = rest.includes('--all') ? closeAllSentRecords(root, now) : closeSentRecords(root, rest, now)
33
+ console.log(`closed ${closed}`)
34
+ return 0
35
+ }
@@ -1,6 +1,7 @@
1
+ import { twoDigits } from '../plan-format/clock'
1
2
  import { firstTableWithHeader, sectionLines } from '../plan-format/markdown'
3
+ import { isRecord } from '../shared/is-record'
2
4
  import { APPOINTMENT_VOCABULARY, type PlanLanguage } from '../vocabulary'
3
- import { isRecord } from './json-guards'
4
5
 
5
6
  export const APPOINTMENT_EVENT = { due: 'due', unreadable: 'appointment unreadable' } as const
6
7
 
@@ -90,8 +91,6 @@ export const parseAppointmentTime = (text: string, now: Date): Date | null => {
90
91
  return parts.year === null ? nearestToClock(parts, now) : localTime(parts, parts.year)
91
92
  }
92
93
 
93
- const twoDigits = (value: number): string => String(value).padStart(2, '0')
94
-
95
94
  /** The local time to the minute and the action: a moved appointment gets a new key, a reworded day name does not. */
96
95
  export const appointmentKey = (when: Date, action: string): string =>
97
96
  `${when.getFullYear()}-${twoDigits(when.getMonth() + 1)}-${twoDigits(when.getDate())}T${twoDigits(when.getHours())}:${twoDigits(when.getMinutes())} ${action}`
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { isRecord } from './json-guards'
3
+ import { MS_PER_MINUTE } from '../plan-format/clock'
4
+ import { isRecord } from '../shared/is-record'
4
5
 
5
6
  export const APPROVAL_EVENT = 'approval'
6
7
 
@@ -49,7 +50,6 @@ export const isApprovalRequestFile = (value: unknown): value is ApprovalRequestF
49
50
  (value.usedAt === null || typeof value.usedAt === 'number')
50
51
 
51
52
  const REPORT_MINUTES = 30
52
- const MS_PER_MINUTE = 60_000
53
53
 
54
54
  /** One event per open request younger than 30 minutes the watcher has not reported yet; an older one is stale. */
55
55
  export const approvalEvents = (
@@ -37,11 +37,14 @@ import { contextReport } from '../context/report'
37
37
  import { agentLog } from '../context/session-log'
38
38
  import { type LaneProgress, parseSprintLanes, type SprintLane } from '../plan-format'
39
39
  import { terminalSafe } from '../plan-format/terminal-text'
40
+ import { openWaitingItems } from '../plan-format/waiting'
40
41
  import { boardFor, foundLanesOf } from '../plans/board-pick'
41
42
  import { ensurePlanPanes } from '../plans/ensure-panes'
42
43
  import { errorText } from '../plans/error-text'
43
44
  import { mainCheckout, worktreeNames, worktreeRoot } from '../plans/git'
44
45
  import { discoverLanes, isUnchanged, laneSetChange, laneSetLines } from '../plans/lane-discovery'
46
+ import { sentWaitingItems } from '../plans/sent-files'
47
+ import { readSentRecords } from '../plans/sent-files-read'
45
48
  import { CLOCK_LOCALE, type PlansSettings, plansSettingsOf, worktreeOf } from '../plans/settings'
46
49
  import { boardTexts } from '../plans/texts'
47
50
  import { defaultUpdateCheck } from '../sync/if-changed'
@@ -107,6 +110,7 @@ import {
107
110
  watchEventLine,
108
111
  } from './lane-report'
109
112
  import { laneReportPath, readLaneReport, reportMtime } from './lane-report-file'
113
+ import { restNote } from './lane-rest'
110
114
  import { branchOf, fetchBaseWhenMoved } from './lane-sources'
111
115
  import {
112
116
  advanceBaseline,
@@ -148,8 +152,17 @@ import { runSource, SOURCE_LIMITS } from './source-run'
148
152
  import { recordWatchedSources } from './source-tickets'
149
153
  import { isSourceDue, type SourceRun, type SourceSettings, type SourceState, sourceEvents } from './sources'
150
154
  import { streamHeartbeatLine } from './stream-output'
155
+ import {
156
+ formatTaskParallelLine,
157
+ TASK_PARALLEL_EVENT,
158
+ type TaskParallelEvent,
159
+ type TaskParallelFingerprints,
160
+ taskParallelReport,
161
+ } from './task-parallel'
162
+ import { readTaskParallelInputs } from './task-parallel-read'
151
163
  import { type WatchTexts, watchTexts } from './texts'
152
164
  import { updateAnnouncer } from './update-available'
165
+ import { waitingLine } from './waiting-line'
153
166
  import {
154
167
  coordinatorWatchEvent,
155
168
  deployEvents,
@@ -184,6 +197,7 @@ type Files = { state: string; lock: string }
184
197
  type FeedbackConfig = JackherdConfig['watch']['feedback']
185
198
 
186
199
  type ChecksConfig = JackherdConfig['watch']['checks']
200
+ type ParallelTasksConfig = JackherdConfig['watch']['parallelTasks']
187
201
 
188
202
  /** Where deploy previews are read from; undefined when the project reads none. */
189
203
  type PreviewsConfig = JackherdConfig['watch']['previews']
@@ -203,6 +217,7 @@ type Watch = {
203
217
  deploy: DeployConfig | undefined
204
218
  feedback: FeedbackConfig
205
219
  checks: ChecksConfig
220
+ parallelTasks: ParallelTasksConfig
206
221
  previews: PreviewsConfig
207
222
  sources: SourceSettings[]
208
223
  texts: WatchTexts
@@ -260,6 +275,8 @@ type Watching = {
260
275
  laneModelReadings: Record<string, LaneModelReading>
261
276
  /** The phases of each lane's plan that can run together at the last read; recorded with every state like the models. */
262
277
  laneParallelReadings: Record<string, string[]>
278
+ /** The parallel-task fingerprint of each lane at the last heartbeat; persisted like the lane parallels. */
279
+ taskParallels: TaskParallelFingerprints
263
280
  /** The approval requests of the last poll; null before the first read, so the stored ids stay. */
264
281
  approvalRequests: ApprovalRequestFile[] | null
265
282
  /** The board's appointment table at the last poll and the time it was read at; null before the first read and while the board cannot be read. */
@@ -299,6 +316,7 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
299
316
  laneLevels: recordLaneLevels(withReads.laneLevels, watching.laneReadings, watching.lanes),
300
317
  laneModels: recordLaneModels(withReads.laneModels, watching.laneModelReadings, watching.lanes),
301
318
  laneParallels: recordLaneParallels(withReads.laneParallels, watching.laneParallelReadings, watching.lanes),
319
+ taskParallels: watching.taskParallels,
302
320
  approvals: watching.approvalRequests ? recordApprovals(watching.approvalRequests) : withReads.approvals,
303
321
  sources: recordWatchedSources(withReads.sources, watching.sourceRuns, watch.sources, watching.githubReadAt),
304
322
  appointments: watching.appointments
@@ -340,6 +358,12 @@ const screenOf = (lane: string): string => {
340
358
  const reportPathOf = (watch: Watch, lane: string): string | null =>
341
359
  laneReportPath(worktreeOf(watch.root, watch.settings, lane), lane)
342
360
 
361
+ /** What a resting lane waits on, from its report file (`restNote`); null while it works or when it asks nothing. */
362
+ const restNoteOf = (watch: Watch, lane: string, status: string | undefined): string | null =>
363
+ status !== undefined && IDLE_STATUSES.has(status)
364
+ ? restNote(readLaneReport(reportPathOf(watch, lane))?.text ?? null)
365
+ : null
366
+
343
367
  /** The lines under a stopped lane's header and its report time to remember (`laneMessage`). */
344
368
  const stopMessageOf = (watch: Watch, lane: string, status: string, state: WatchState) =>
345
369
  laneMessage({
@@ -451,6 +475,7 @@ const boardReadOf = async (
451
475
 
452
476
  /** What the status line of one lane is made of, from the loop's state. */
453
477
  const laneReportOf = (
478
+ watch: Watch,
454
479
  lane: string,
455
480
  watching: Watching,
456
481
  state: WatchState,
@@ -467,6 +492,7 @@ const laneReportOf = (
467
492
  pullRequest: pullRequest ? { number: pullRequest.number, checks: pullRequest.checks, feedback } : null,
468
493
  progress,
469
494
  context: watching.laneReadings[lane]?.level ?? null,
495
+ rest: restNoteOf(watch, lane, watching.current[lane]?.status),
470
496
  }
471
497
  }
472
498
 
@@ -500,16 +526,17 @@ const statusLinesOf = async (
500
526
  const others = otherPullRequestsLine(pullRequests, github?.own ?? [], watch.texts)
501
527
  const { progress, checkoutRows, notes } = await boardReadOrNote(watch, watching.lanes)
502
528
  const laneLines = watching.lanes.map((lane) =>
503
- laneReportLine(laneReportOf(lane, watching, state, pullRequests, progress[lane] ?? null), watch.texts),
529
+ laneReportLine(laneReportOf(watch, lane, watching, state, pullRequests, progress[lane] ?? null), watch.texts),
504
530
  )
505
531
  const outage = state.ghDownSince === null ? [] : [ghUnavailableStatusLine(clock(state.ghDownSince))]
532
+ const waiting = waitingLineOf(watch)
506
533
  const board = { term: watch.settings.board.term, checkoutRows }
507
534
  const coordinator = coordinatorStatusLine(watching.coordinator.name, watching.coordinator.read, watch.texts, board)
508
535
  return {
509
536
  laneLines,
510
537
  coordinator,
511
538
  others: others === null ? [] : [others],
512
- rest: [...notes, ...outage, ...failingSourceLines(state.sources)],
539
+ rest: [...notes, ...(waiting === null ? [] : [waiting]), ...outage, ...failingSourceLines(state.sources)],
513
540
  }
514
541
  }
515
542
 
@@ -813,6 +840,13 @@ const readAppointments = (watch: Watch, now: number): Watching['appointments'] =
813
840
  return { table: readAppointmentTable(board, watch.settings.planLanguage, new Date(now)), readAt: now }
814
841
  }
815
842
 
843
+ /** The line of what waits on the user's answer, from the board's own section; null when nothing is open or the board cannot be read. */
844
+ const waitingLineOf = (watch: Watch): string | null => {
845
+ const board = readBoardText(watch)
846
+ const sends = sentWaitingItems(readSentRecords(watch.root), Date.now())
847
+ return waitingLine([...(board === null ? [] : openWaitingItems(board, watch.settings.planLanguage)), ...sends])
848
+ }
849
+
816
850
  /** The board's lanes, null when the board cannot be read. */
817
851
  const readSprintLanes = (watch: Watch): SprintLane[] | null => {
818
852
  const board = readBoardText(watch)
@@ -896,6 +930,39 @@ const report = async (
896
930
  return shown
897
931
  }
898
932
 
933
+ /**
934
+ * The parallel-task proposals of this heartbeat, one event per open lane whose situation changed since the last
935
+ * heartbeat and that has a proposal, and the fingerprints of every lane read, kept for the next one. Nothing when
936
+ * herdr or the board cannot be read: a proposal is a hint, and the next heartbeat reads again.
937
+ */
938
+ const taskParallelEventsOf = (
939
+ watch: Watch,
940
+ watching: Watching,
941
+ now: number,
942
+ ): { events: TaskParallelEvent[]; fingerprints: TaskParallelFingerprints } => {
943
+ try {
944
+ const lanes = (readSprintLanes(watch) ?? []).filter((lane) => !lane.closed && watching.lanes.includes(lane.name))
945
+ const inputs = readTaskParallelInputs(lanes, herdrLaneStatusSource.listAgents(), {
946
+ root: watch.root,
947
+ settings: watch.settings,
948
+ boardFile: watch.sprintFile,
949
+ thresholdTokens: watch.parallelTasks.maxTokens,
950
+ now,
951
+ })
952
+ const reports = Object.entries(inputs).map(
953
+ ([lane, input]) => [lane, taskParallelReport(watching.taskParallels[lane], input)] as const,
954
+ )
955
+ return {
956
+ events: reports.flatMap(([lane, { proposal }]) =>
957
+ proposal ? [{ kind: TASK_PARALLEL_EVENT, lane, line: formatTaskParallelLine(lane, proposal) }] : [],
958
+ ),
959
+ fingerprints: Object.fromEntries(reports.map(([lane, { fingerprint }]) => [lane, fingerprint])),
960
+ }
961
+ } catch {
962
+ return { events: [], fingerprints: {} }
963
+ }
964
+ }
965
+
899
966
  /**
900
967
  * The heartbeat of a stream: one line, a working lane with its phase count when that moved since the last heartbeat.
901
968
  * The plans are read only when a lane works; a board that cannot be read leaves the lines without a count, and the
@@ -907,7 +974,16 @@ const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: numbe
907
974
  const counts = currentPhaseCountsOf(progress)
908
975
  const notes = progressNotes(watching.lanes, watching.current, counts, watching.heartbeatCounts)
909
976
  watching.heartbeatCounts = { ...watching.heartbeatCounts, ...counts }
910
- console.log(streamHeartbeatLine(clock(now), watching.lanes, watching.current, notes))
977
+ const rests = Object.fromEntries(
978
+ watching.lanes.flatMap((lane) => {
979
+ const rest = restNoteOf(watch, lane, watching.current[lane]?.status)
980
+ return rest === null ? [] : [[lane, rest]]
981
+ }),
982
+ )
983
+ console.log(streamHeartbeatLine(clock(now), watching.lanes, watching.current, notes, rests, waitingLineOf(watch)))
984
+ const { events, fingerprints } = taskParallelEventsOf(watch, watching, now)
985
+ watching.taskParallels = { ...watching.taskParallels, ...fingerprints }
986
+ for (const event of events) console.log(watchEventLine(event, null))
911
987
  }
912
988
 
913
989
  /**
@@ -941,9 +1017,17 @@ const pollOnce = async (watch: Watch, watching: Watching): Promise<boolean> => {
941
1017
  next = await report(watch, watching, stopped, polledEvents, next)
942
1018
  watching.baseline = { ...watching.current }
943
1019
  } else if (heartbeat && !stream) {
944
- next = await report(watch, watching, [], [{ kind: WATCH_EVENT.heartbeat }], { ...next, statusLines: null })
1020
+ const { events: proposals, fingerprints } = taskParallelEventsOf(watch, watching, now)
1021
+ watching.taskParallels = { ...watching.taskParallels, ...fingerprints }
1022
+ next = await report(watch, watching, [], [{ kind: WATCH_EVENT.heartbeat }, ...proposals], {
1023
+ ...recordedOf(watch, watching),
1024
+ statusLines: null,
1025
+ })
945
1026
  } else {
946
- if (heartbeat) await printStreamHeartbeat(watch, watching, now)
1027
+ if (heartbeat) {
1028
+ await printStreamHeartbeat(watch, watching, now)
1029
+ next = recordedOf(watch, watching)
1030
+ }
947
1031
  if (due) writePrivate(watch.files.state, JSON.stringify(next))
948
1032
  watching.baseline = advanceBaseline(watching.baseline, watching.current)
949
1033
  }
@@ -983,6 +1067,7 @@ const initialWatching = (
983
1067
  laneReadings: {},
984
1068
  laneModelReadings: {},
985
1069
  laneParallelReadings: {},
1070
+ taskParallels: start.state.taskParallels,
986
1071
  approvalRequests: null,
987
1072
  appointments: null,
988
1073
  planViews: { failureReported: false },
@@ -1048,6 +1133,7 @@ const watchOf = (options: WatchOptions): Watch => {
1048
1133
  deploy: config.watch.deploy,
1049
1134
  feedback: config.watch.feedback,
1050
1135
  checks: config.watch.checks,
1136
+ parallelTasks: config.watch.parallelTasks,
1051
1137
  previews: config.watch.previews,
1052
1138
  sources: config.watch.sources,
1053
1139
  texts: watchTexts(settings.responseLanguage),
@@ -1,4 +1,5 @@
1
1
  import { terminalSafe } from '../plan-format/terminal-text'
2
+ import { stripAnsi } from '../terminal/text'
2
3
 
3
4
  /** The most failed tests kept for one head commit. */
4
5
  export const FAILED_TEST_LIMIT = 50
@@ -29,24 +30,69 @@ const compiled = (pattern: string): RegExp | null => {
29
30
  }
30
31
  }
31
32
 
33
+ /** `places` holds, by test name, the package or test file the log printed before the test; a test before any has none. */
34
+ export type FailedTestScan = { tests: string[]; places: Record<string, string> }
35
+
36
+ /** `##[group]`, which GitHub Actions folds a section behind, in front of a header line. */
37
+ const GROUP_MARK = /^##\[group\]/
38
+
39
+ /** The task header a monorepo runner (turbo) prints before a package's run: `@scope/name:test`, the package first. */
40
+ const TASK_HEADER = /^(@[\w.-]+\/[\w.-]+|[\w.-]+):[\w:-]*test[\w:-]*$/
41
+
42
+ /** The header Bun prints before the results of one test file: its path and a colon. */
43
+ const FILE_HEADER = /^(\S+\.(?:test|spec)\.[cm]?[jt]sx?):$/
44
+
45
+ /** Where the scan stands in the log: the package of the current task and the test file of the current run. */
46
+ type ScanPlace = { task: string | null; file: string | null }
47
+
48
+ /** The place after `line`: a task header starts a new package, a file header a new file. */
49
+ const placeAfter = (place: ScanPlace, line: string): ScanPlace => {
50
+ const header = stripAnsi(line).replace(GROUP_MARK, '')
51
+ const task = TASK_HEADER.exec(header)?.[1]
52
+ if (task) return { task: nameOf(task), file: null }
53
+ const file = FILE_HEADER.exec(header)?.[1]
54
+ return file ? { ...place, file: nameOf(file) } : place
55
+ }
56
+
57
+ const cleanLine = (raw: string): string => raw.replace(/\r$/, '').replace(LOG_PREFIX, '')
58
+
32
59
  /**
33
- * The names of the failed tests in the log `gh run view <id> --log-failed` printed, in the order first seen. Each line
34
- * loses its job, step and timestamp prefix, then `pattern` (a regular expression) is matched against it: the first
35
- * group is the name, else the whole match. A test the log names twice (Bun lists it in the run and in the summary) is
36
- * listed once. Names are made terminal-safe and cut; at most `FAILED_TEST_LIMIT` are kept. A pattern that is no
37
- * regular expression finds nothing.
60
+ * The failed tests in the log `gh run view <id> --log-failed` printed, in the order first seen, with the place each
61
+ * was first seen in. Each line loses its job, step and timestamp prefix, then `pattern` (a regular expression) is
62
+ * matched against it: the first group is the name, else the whole match. The place is the package of the task header
63
+ * before it (`@scope/name:test`), else the test file Bun printed before it. A test the log names twice (Bun lists it
64
+ * in the run and in the summary) is listed once, at its first place. Names and places are made terminal-safe and cut;
65
+ * at most `FAILED_TEST_LIMIT` are kept. A pattern that is no regular expression finds nothing.
38
66
  */
39
- export const extractFailedTests = (log: string, pattern: string): string[] => {
67
+ export const scanFailedTests = (log: string, pattern: string): FailedTestScan => {
40
68
  const expression = compiled(pattern)
41
- if (!expression) return []
69
+ const places: Record<string, string> = {}
70
+ if (!expression) return { tests: [], places }
42
71
  const names = new Set<string>()
72
+ let place: ScanPlace = { task: null, file: null }
43
73
  for (const raw of log.split('\n')) {
44
74
  if (names.size >= FAILED_TEST_LIMIT) break
45
- const line = raw.replace(/\r$/, '').replace(LOG_PREFIX, '')
75
+ const line = cleanLine(raw)
46
76
  if (line.length > MAX_LINE_LENGTH) continue
77
+ place = placeAfter(place, line)
47
78
  const match = expression.exec(line)
48
79
  const name = nameOf(match?.[1] ?? match?.[0] ?? '')
49
- if (name !== '') names.add(name)
80
+ if (name === '' || names.has(name)) continue
81
+ names.add(name)
82
+ const where = place.task ?? place.file
83
+ if (where) places[name] = where
50
84
  }
51
- return [...names]
85
+ return { tests: [...names], places }
52
86
  }
87
+
88
+ /** `places` read back from the state file, for the names in `names` only, made terminal-safe and cut like the names. */
89
+ export const sanitisedFailedTestPlaces = (
90
+ places: Readonly<Record<string, string>>,
91
+ names: readonly string[],
92
+ ): Record<string, string> =>
93
+ Object.fromEntries(
94
+ Object.entries(places).flatMap(([name, place]) => {
95
+ const shown = nameOf(name)
96
+ return names.includes(shown) && nameOf(place) !== '' ? [[shown, nameOf(place)]] : []
97
+ }),
98
+ )
@@ -1,6 +1,6 @@
1
- import { extractFailedTests, FAILED_TEST_LIMIT } from './failed-tests'
1
+ import { isRecord } from '../shared/is-record'
2
+ import { FAILED_TEST_LIMIT, scanFailedTests } from './failed-tests'
2
3
  import { type GhAnswer, runGh } from './gh'
3
- import { isRecord } from './json-guards'
4
4
 
5
5
  /** How long `gh run view --log-failed` may take before the call is given up; a read that timed out is tried again. */
6
6
  export const LOG_TIMEOUT_MS = 30_000
@@ -8,8 +8,13 @@ export const LOG_TIMEOUT_MS = 30_000
8
8
  /** Of a longer log only this many characters from the end are kept; Bun repeats its failures in the summary there. */
9
9
  export const LOG_TAIL_CHARS = 2_000_000
10
10
 
11
- /** `running` is true when a run was still going, which is no failure of the read and is not counted as an attempt. */
12
- export type FailedTestsRead = { ok: true; tests: string[] } | { ok: false; running: boolean }
11
+ /**
12
+ * `running` is true when a run was still going, which is no failure of the read and is not counted as an attempt.
13
+ * `places` names, by test, the package or test file the logs printed before it; absent when they named none.
14
+ */
15
+ export type FailedTestsRead =
16
+ | { ok: true; tests: string[]; places?: Record<string, string> }
17
+ | { ok: false; running: boolean }
13
18
 
14
19
  type RunGh = (args: string[]) => Promise<GhAnswer>
15
20
 
@@ -51,6 +56,13 @@ export const readFailedTests = async (
51
56
  const answers = await Promise.all(runIds.map((runId) => run(logArgs(runId))))
52
57
  const logs = answers.flatMap((answer) => (answer.ok ? [answer.stdout] : []))
53
58
  if (logs.length < answers.length) return { ok: false, running: false }
54
- const tests = new Set(logs.flatMap((text) => extractFailedTests(text, pattern)))
55
- return { ok: true, tests: [...tests].slice(0, FAILED_TEST_LIMIT) }
59
+ const scans = logs.map((text) => scanFailedTests(text, pattern))
60
+ const tests = [...new Set(scans.flatMap((scan) => scan.tests))].slice(0, FAILED_TEST_LIMIT)
61
+ const places = Object.fromEntries(
62
+ tests.flatMap((name) => {
63
+ const place = scans.find((scan) => scan.places[name] !== undefined)?.places[name]
64
+ return place === undefined ? [] : [[name, place]]
65
+ }),
66
+ )
67
+ return { ok: true, tests, ...(Object.keys(places).length > 0 && { places }) }
56
68
  }