jackherd 1.6.0 → 1.7.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 (152) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/INSTALL.md +176 -0
  3. package/README.md +260 -315
  4. package/assets/branding/README.md +7 -3
  5. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  6. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  7. package/assets/branding/jackherd-logo-dark.svg +1 -1
  8. package/assets/icons/bark-dark.svg +1 -0
  9. package/assets/icons/bark.svg +1 -0
  10. package/assets/icons/bowl-dark.svg +1 -0
  11. package/assets/icons/bowl.svg +1 -0
  12. package/assets/icons/decide-dark.svg +1 -0
  13. package/assets/icons/decide.svg +1 -0
  14. package/assets/icons/ear-dark.svg +1 -0
  15. package/assets/icons/ear.svg +1 -0
  16. package/assets/icons/lanes-dark.svg +1 -0
  17. package/assets/icons/lanes.svg +1 -0
  18. package/assets/icons/plan-dark.svg +1 -0
  19. package/assets/icons/plan.svg +1 -0
  20. package/assets/icons/space.svg +1 -0
  21. package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
  22. package/dist/config.d.ts +14 -3
  23. package/dist/config.js +111 -10
  24. package/dist/plan-format.d.ts +180 -77
  25. package/dist/plan-format.js +292 -93
  26. package/docs/lanes-dark.svg +50 -0
  27. package/docs/lanes.svg +50 -1
  28. package/docs/skills-dark.svg +65 -0
  29. package/docs/skills.svg +65 -1
  30. package/docs/workflow-dark.svg +138 -0
  31. package/docs/workflow.svg +135 -169
  32. package/herdr-plugin/herdr-plugin.toml +6 -6
  33. package/herdr-plugin/open-plans.ts +1 -1
  34. package/herdr-plugin/restore-plans.ts +7 -7
  35. package/install.sh +1 -1
  36. package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
  37. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
  38. package/kit/hooks/hooks/approval-gate/index.ts +69 -0
  39. package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
  40. package/kit/hooks/hooks/approval-gate/request.ts +111 -0
  41. package/kit/hooks/hooks/approval-gate/run.ts +94 -0
  42. package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
  43. package/kit/hooks/hooks/approval-gate/store.ts +70 -0
  44. package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
  45. package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
  46. package/kit/hooks/hooks/merge-guard/run.ts +2 -1
  47. package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
  48. package/kit/hooks/hooks/model-gate/index.ts +58 -0
  49. package/kit/hooks/hooks/model-gate/run.ts +63 -0
  50. package/kit/hooks/hooks/model-log/index.ts +60 -0
  51. package/kit/hooks/hooks/model-log/run.ts +57 -0
  52. package/kit/hooks/hooks/model-log/store.ts +40 -0
  53. package/kit/hooks/hooks/model-log/transcript.ts +37 -0
  54. package/kit/hooks/hooks/shared/checkout.ts +22 -0
  55. package/kit/hooks/hooks/shared/command.ts +6 -0
  56. package/kit/hooks/hooks/shared/config.ts +21 -0
  57. package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
  58. package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
  59. package/kit/hooks/hooks/shared/files.ts +19 -0
  60. package/kit/hooks/hooks/shared/fresh.ts +4 -0
  61. package/kit/hooks/hooks/shared/json.ts +4 -0
  62. package/kit/hooks/hooks/shared/model-family.ts +48 -0
  63. package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
  64. package/kit/hooks/hooks/shared/private-directory.ts +29 -0
  65. package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
  66. package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
  67. package/kit/hooks/hooks.json +25 -0
  68. package/kit/workflow/skills/follow/SKILL.md +4 -3
  69. package/kit/workflow/skills/implement/SKILL.md +6 -6
  70. package/kit/workflow/skills/orchestrate/SKILL.md +113 -30
  71. package/kit/workflow/skills/plan/SKILL.md +5 -5
  72. package/kit/workflow/skills/start/SKILL.md +2 -2
  73. package/package.json +4 -1
  74. package/schemas/jackherd.schema.json +43 -5
  75. package/src/check/command.ts +31 -4
  76. package/src/check/deferral-arrows.ts +1 -1
  77. package/src/check/parallel-report.ts +23 -0
  78. package/src/check/plan-structure-check.ts +120 -53
  79. package/src/cli.ts +8 -2
  80. package/src/config/nested-quantifier.ts +73 -0
  81. package/src/config/repository-directory.ts +11 -0
  82. package/src/config/schema.ts +63 -8
  83. package/src/plan-format/board-prose.ts +36 -0
  84. package/src/plan-format/dependencies.ts +227 -0
  85. package/src/plan-format/documentation-gate.ts +16 -0
  86. package/src/plan-format/index.ts +23 -1
  87. package/src/plan-format/lanes.ts +35 -1
  88. package/src/plan-format/model-column.ts +52 -0
  89. package/src/plan-format/phases.ts +16 -1
  90. package/src/plan-format/plan-structure.ts +10 -28
  91. package/src/plan-format/progress.ts +7 -29
  92. package/src/plans/command.ts +3 -3
  93. package/src/plans/current-phase.ts +71 -0
  94. package/src/plans/ensure-panes.ts +2 -2
  95. package/src/plans/herd-command.ts +4 -0
  96. package/src/plans/lane-discovery.ts +14 -2
  97. package/src/plans/lane-tickets.ts +69 -0
  98. package/src/plans/layout.ts +182 -88
  99. package/src/plans/live.ts +5 -1
  100. package/src/plans/phase-models-read.ts +37 -0
  101. package/src/plans/phase-models.ts +70 -0
  102. package/src/plans/plan-panes.ts +8 -7
  103. package/src/plans/pull-requests.ts +78 -4
  104. package/src/plans/selection.ts +1 -40
  105. package/src/plans/settings.ts +9 -3
  106. package/src/plans/shown-with-more.ts +5 -0
  107. package/src/plans/snapshot.ts +82 -8
  108. package/src/plans/sprint-html.ts +2 -2
  109. package/src/plans/supervisor.ts +5 -4
  110. package/src/plans/symbols.ts +9 -6
  111. package/src/plans/texts.ts +30 -13
  112. package/src/plans/view.ts +1 -0
  113. package/src/sync/herdr-plugin.ts +1 -1
  114. package/src/sync/next-steps.ts +1 -1
  115. package/src/sync/render.ts +2 -1
  116. package/src/sync/setup-questions.ts +2 -2
  117. package/src/terminal/text.ts +4 -0
  118. package/src/terminal/types.ts +1 -1
  119. package/src/vocabulary/index.ts +39 -0
  120. package/src/watch/approvals.ts +96 -0
  121. package/src/watch/command.ts +154 -50
  122. package/src/watch/failed-tests.ts +52 -0
  123. package/src/watch/failure-read.ts +56 -0
  124. package/src/watch/failures.ts +107 -0
  125. package/src/watch/feedback-item.ts +21 -0
  126. package/src/watch/feedback-read.ts +2 -1
  127. package/src/watch/feedback.ts +37 -28
  128. package/src/watch/gh-process.ts +53 -0
  129. package/src/watch/gh.ts +10 -20
  130. package/src/watch/heartbeat-progress.ts +1 -1
  131. package/src/watch/lane-model.ts +54 -0
  132. package/src/watch/lane-parallel.ts +46 -0
  133. package/src/watch/lane-progress.ts +28 -4
  134. package/src/watch/lane-report.ts +62 -9
  135. package/src/watch/lane-sources.ts +1 -1
  136. package/src/watch/no-finding.ts +47 -0
  137. package/src/watch/plan-badges-read.ts +19 -7
  138. package/src/watch/plan-badges.ts +59 -7
  139. package/src/watch/plan-views.ts +5 -5
  140. package/src/watch/previews.ts +3 -9
  141. package/src/watch/pull-requests.ts +12 -2
  142. package/src/watch/recorded-reads.ts +25 -0
  143. package/src/watch/source-fields.ts +4 -0
  144. package/src/watch/source-tickets.ts +99 -0
  145. package/src/watch/sources.ts +62 -15
  146. package/src/watch/texts.ts +21 -1
  147. package/src/watch/watch-defaults.ts +8 -0
  148. package/src/watch/watch-events.ts +64 -4
  149. package/src/watch/watcher-lock.ts +1 -1
  150. package/src/watch/workflow-runs.ts +5 -7
  151. package/docs/lanes.mmd +0 -9
  152. package/docs/skills.mmd +0 -10
@@ -17,8 +17,8 @@
17
17
  * under Claude Code's Monitor tool, which turns every line into a message. Without lane names it watches every
18
18
  * worktree whose name a herdr agent carries or the board's lanes table lists, and prints `lane added: <name>` /
19
19
  * `lane removed: <name>` as that set changes. With every GitHub read it fetches `origin/<baseBranch>` once the
20
- * remote moved past it, so the plans view, which watches `FETCH_HEAD`, shows a merged lane row at once. Inside herdr
21
- * it brings back missing plans views of the board's lanes like `jh plans --ensure`: at its start, for every
20
+ * remote moved past it, so the herd, which watches `FETCH_HEAD`, shows a merged lane row at once. Inside herdr
21
+ * it brings back missing herd panes of the board's lanes like `jh herd --ensure`: at its start, for every
22
22
  * added lane and for every lane whose agent appeared.
23
23
  *
24
24
  * `watch source <name> --dry-run` runs one source once instead and prints what it would report (`source-dry-run-command.ts`).
@@ -35,7 +35,7 @@ import { defaultCoordinatorName } from '../context/coordinator-name'
35
35
  import { coordinatorSettings } from '../context/options'
36
36
  import { contextReport } from '../context/report'
37
37
  import { agentLog } from '../context/session-log'
38
- import { type LaneProgress, parseSprintLanes } from '../plan-format'
38
+ import { type LaneProgress, parseSprintLanes, type SprintLane } from '../plan-format'
39
39
  import { terminalSafe } from '../plan-format/terminal-text'
40
40
  import { boardFor, foundLanesOf } from '../plans/board-pick'
41
41
  import { ensurePlanPanes } from '../plans/ensure-panes'
@@ -48,6 +48,7 @@ import { defaultUpdateCheck } from '../sync/if-changed'
48
48
  import { herdrLaneStatusSource } from '../terminal/herdr'
49
49
  import { AGENT_STATUS } from '../terminal/types'
50
50
  import { startedKit } from '../version/started-kit'
51
+ import { type ApprovalRequestFile, approvalEvents, readApprovalRequests, recordApprovals } from './approvals'
51
52
  import {
52
53
  type CoordinatorRead,
53
54
  contextReadingOf,
@@ -57,16 +58,18 @@ import {
57
58
  explainedRead,
58
59
  readingOf,
59
60
  } from './coordinator-fill'
61
+ import { readFailedTests } from './failure-read'
62
+ import { type FailureReading, failureReadingOf, failureTargets, recordFailures } from './failures'
60
63
  import {
61
- type FeedbackItem,
62
64
  type FeedbackRead,
63
65
  type FeedbackTarget,
64
66
  feedbackEvents,
67
+ feedbackFilterOf,
65
68
  feedbackReadOf,
66
69
  feedbackSince,
67
70
  feedbackTargets,
68
- recordFeedback,
69
71
  } from './feedback'
72
+ import { type FeedbackItem } from './feedback-item'
70
73
  import { type FeedbackItemsRead, readCommentItems, readFeedbackItems, readOwnLogin } from './feedback-read'
71
74
  import { answered, deployTargetOf, type GithubRead, readGithub, targetText } from './github-read'
72
75
  import { currentPhaseCountsOf, type PhaseCount, progressNotes } from './heartbeat-progress'
@@ -74,8 +77,11 @@ import { agentScreen, listAgents } from './herdr'
74
77
  import { endedByKitUpdate } from './kit-update'
75
78
  import { type LaneReadings, laneFillEvents, recordLaneLevels } from './lane-fill'
76
79
  import { laneMessage, rememberedStatusLines, statusBlock } from './lane-message'
77
- import { boardCheckoutRows, watchedLaneProgress } from './lane-progress'
80
+ import { type LaneModelReading, laneModelEvents, recordLaneModels } from './lane-model'
81
+ import { laneParallelEvents, recordLaneParallels } from './lane-parallel'
82
+ import { boardCheckoutRows, watchedLaneParallel, watchedLaneProgress } from './lane-progress'
78
83
  import {
84
+ approvalDutyLines,
79
85
  armLine,
80
86
  blockedDutyLines,
81
87
  coordinatorStatusLine,
@@ -83,9 +89,11 @@ import {
83
89
  ghUnavailableStatusLine,
84
90
  type LaneReport,
85
91
  laneFillDutyLines,
92
+ laneModelDutyLines,
86
93
  laneReportLine,
87
94
  mergeDutyLines,
88
95
  otherPullRequestsLine,
96
+ parallelDutyLines,
89
97
  sourceEventLines,
90
98
  sourceFailingStatusLine,
91
99
  watchEventLine,
@@ -117,27 +125,20 @@ import {
117
125
  import { readDeploymentCandidates } from './preview-read'
118
126
  import {
119
127
  commentCandidates,
120
- feedbackFilterOf,
121
128
  feedbackReadCovers,
122
129
  type PreviewRead,
123
130
  previewEvents,
124
131
  previewReadOf,
125
- recordPreviews,
126
132
  unreportedTargets,
127
133
  } from './previews'
128
134
  import { latestPullRequestOf, type WatchedPullRequest } from './pull-requests'
135
+ import { recordedReadsOf } from './recorded-reads'
129
136
  import { withSingleReportNotice } from './single-report'
130
137
  import { readOrchestrateSkill, skillLineFor } from './skill-change'
131
138
  import { sourceDryRun } from './source-dry-run-command'
132
139
  import { runSource, SOURCE_LIMITS } from './source-run'
133
- import {
134
- isSourceDue,
135
- recordSources,
136
- type SourceRun,
137
- type SourceSettings,
138
- type SourceState,
139
- sourceEvents,
140
- } from './sources'
140
+ import { recordWatchedSources } from './source-tickets'
141
+ import { isSourceDue, type SourceRun, type SourceSettings, type SourceState, sourceEvents } from './sources'
141
142
  import { streamHeartbeatLine } from './stream-output'
142
143
  import { WATCH_TEXTS, type WatchTexts } from './texts'
143
144
  import { updateAnnouncer } from './update-available'
@@ -174,6 +175,8 @@ type Files = { state: string; lock: string }
174
175
  /** The logins whose feedback does not count, or false when the project turned feedback off. */
175
176
  type FeedbackConfig = JackherdConfig['watch']['feedback']
176
177
 
178
+ type ChecksConfig = JackherdConfig['watch']['checks']
179
+
177
180
  /** Where deploy previews are read from; undefined when the project reads none. */
178
181
  type PreviewsConfig = JackherdConfig['watch']['previews']
179
182
 
@@ -191,10 +194,11 @@ type Watch = {
191
194
  settings: PlansSettings
192
195
  deploy: DeployConfig | undefined
193
196
  feedback: FeedbackConfig
197
+ checks: ChecksConfig
194
198
  previews: PreviewsConfig
195
199
  sources: SourceSettings[]
196
200
  texts: WatchTexts
197
- /** Runs inside herdr with the herdr adapter, so missing plans views can be brought back. */
201
+ /** Runs inside herdr with the herdr adapter, so missing herd panes can be brought back. */
198
202
  herdr: boolean
199
203
  }
200
204
 
@@ -234,6 +238,8 @@ type Watching = {
234
238
  ownLogin: string | null
235
239
  /** The last feedback read that answered; its targets and items are recorded with every state. */
236
240
  feedbackRead: FeedbackRead | null
241
+ /** The logs of the red pull requests read at the last GitHub poll; recorded with every state like the feedback read. */
242
+ failureReadings: FailureReading[]
237
243
  /** The last preview read that answered; recorded with every state like the feedback read. */
238
244
  previewRead: PreviewRead | null
239
245
  /** The runs of the sources due at the last GitHub poll; recorded with every state like the feedback read. */
@@ -242,6 +248,12 @@ type Watching = {
242
248
  sourceRanAt: Record<string, number>
243
249
  /** The lanes' fills of the last read; a lane without an entry or with null has no value. */
244
250
  laneReadings: LaneReadings
251
+ /** The lanes' models against their board rows at the last read; recorded with every state like the fills. */
252
+ laneModelReadings: Record<string, LaneModelReading>
253
+ /** The phases of each lane's plan that can run together at the last read; recorded with every state like the models. */
254
+ laneParallelReadings: Record<string, string[]>
255
+ /** The approval requests of the last poll; null before the first read, so the stored ids stay. */
256
+ approvalRequests: ApprovalRequestFile[] | null
245
257
  planViews: PlanViews
246
258
  }
247
259
 
@@ -267,24 +279,30 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
267
279
  recordWatchState(watching.state, observationOf(watching), watching.githubReadAt),
268
280
  readingOf(watching.coordinator.read),
269
281
  )
270
- const read = watching.feedbackRead
271
- const withFeedback = read ? { ...recorded, feedback: recordFeedback(recorded.feedback, read) } : recorded
272
- const previews = watching.previewRead
273
- const withPreviews = previews
274
- ? { ...withFeedback, previews: recordPreviews(withFeedback.previews, previews) }
275
- : withFeedback
282
+ const withReads = recordedReadsOf(recorded, {
283
+ feedback: watching.feedbackRead,
284
+ previews: watching.previewRead,
285
+ pullRequests: answered(watching.github)?.pullRequests ?? null,
286
+ failures: watching.failureReadings,
287
+ })
276
288
  const withLevels = {
277
- ...withPreviews,
278
- laneLevels: recordLaneLevels(withPreviews.laneLevels, watching.laneReadings, watching.lanes),
279
- sources: recordSources(withPreviews.sources, watching.sourceRuns, sourceNamesOf(watch), watching.githubReadAt),
289
+ ...withReads,
290
+ laneLevels: recordLaneLevels(withReads.laneLevels, watching.laneReadings, watching.lanes),
291
+ laneModels: recordLaneModels(withReads.laneModels, watching.laneModelReadings, watching.lanes),
292
+ laneParallels: recordLaneParallels(withReads.laneParallels, watching.laneParallelReadings, watching.lanes),
293
+ approvals: watching.approvalRequests ? recordApprovals(watching.approvalRequests) : withReads.approvals,
294
+ sources: recordWatchedSources(withReads.sources, watching.sourceRuns, watch.sources, watching.githubReadAt),
280
295
  }
281
296
  return withReportBaselines(withLevels, watching.lanes, (lane) => reportMtime(reportPathOf(watch, lane)))
282
297
  }
283
298
 
284
- const sourceNamesOf = (watch: Watch): string[] => watch.sources.map(({ name }) => name)
285
-
286
299
  /** The events of one GitHub read against the state it is compared with. */
287
- const githubEventsOf = (state: WatchState, github: GithubRead, laneBranches: Record<string, string>): WatchEvent[] => {
300
+ const githubEventsOf = (
301
+ state: WatchState,
302
+ github: GithubRead,
303
+ laneBranches: Record<string, string>,
304
+ readings: readonly FailureReading[],
305
+ ): WatchEvent[] => {
288
306
  if (!github.ok) {
289
307
  const outage = ghOutageEvent(state, github.error)
290
308
  return outage ? [outage] : []
@@ -292,8 +310,9 @@ const githubEventsOf = (state: WatchState, github: GithubRead, laneBranches: Rec
292
310
  const branches = { ...state.laneBranches, ...laneBranches }
293
311
  const merges = mergeCommitsOf(github.pullRequests, branches)
294
312
  const ownMerges = ownMergeCommitsOf(state, github.pullRequests, branches)
313
+ const failures = recordFailures(state.failures, github.pullRequests, readings)
295
314
  return [
296
- ...pullRequestEvents(state, github.pullRequests, branches),
315
+ ...pullRequestEvents(state, github.pullRequests, branches, failures),
297
316
  ...deployEvents(state, github.runs, merges, ownMerges),
298
317
  ]
299
318
  }
@@ -324,13 +343,16 @@ const coordinatorNameFor = (watch: Watch, agents: readonly HerdrAgent[]): string
324
343
  coordinatorNameOf(agents, process.env.HERDR_PANE_ID) ??
325
344
  defaultCoordinatorName(basename(watch.root))
326
345
 
327
- /** One read of the fills: the coordinator's, and each watched lane's (null without a value). */
328
- type FillRead = { coordinator: CoordinatorRead; lanes: LaneReadings }
346
+ /**
347
+ * One read of the fills: the coordinator's, and each watched lane's (null without a value), with `models`, lane → the
348
+ * model of its agent's last answer (null without one), read from the same report.
349
+ */
350
+ type FillRead = { coordinator: CoordinatorRead; lanes: LaneReadings; models: Record<string, string | null> }
329
351
 
330
352
  /**
331
353
  * The coordinator's fill with its own thresholds, as `jh context <name> --coordinator` computes it, or why there
332
- * is none, and each lane's fill with the lanes' thresholds, as the plans view shows it: from one herdr listing. When
333
- * herdr cannot be asked, the lanes have no reading, so their stored levels stay.
354
+ * is none, and each lane's fill with the lanes' thresholds, as the herd shows it: from one herdr listing. When
355
+ * herdr cannot be asked, the lanes have no reading, so their stored levels stay. Each lane's report also gives its model.
334
356
  */
335
357
  const fillReadFor = (watch: Watch, name: string, agents: readonly HerdrAgent[], lanes: readonly string[]): FillRead => {
336
358
  const target = coordinatorTargetOf(agents, name, process.env.HERDR_PANE_ID)
@@ -339,14 +361,14 @@ const fillReadFor = (watch: Watch, name: string, agents: readonly HerdrAgent[],
339
361
  target.kind === 'unnamedPane' ? { pane: target.pane, name } : undefined,
340
362
  )
341
363
  const own = contextReport(listed, name, agentLog, coordinatorSettings(watch.settings.context))
342
- const laneReading = (lane: string) =>
343
- contextReadingOf(contextReport(listed, lane, agentLog, watch.settings.context))
364
+ const reports = lanes.map((lane) => [lane, contextReport(listed, lane, agentLog, watch.settings.context)] as const)
344
365
  return {
345
366
  coordinator: explainedRead(coordinatorReadOf(own), target, name, watch.texts),
346
- lanes: Object.fromEntries(lanes.map((lane) => [lane, laneReading(lane)])),
367
+ lanes: Object.fromEntries(reports.map(([lane, report]) => [lane, contextReadingOf(report)])),
368
+ models: Object.fromEntries(reports.map(([lane, report]) => [lane, report?.model ?? null])),
347
369
  }
348
370
  } catch (error) {
349
- return { coordinator: { reason: errorText(error) }, lanes: {} }
371
+ return { coordinator: { reason: errorText(error) }, lanes: {}, models: {} }
350
372
  }
351
373
  }
352
374
 
@@ -515,6 +537,9 @@ const reportOf = async (
515
537
  ...blockedDutyLines(stopped, watching.current, watch.texts),
516
538
  ...mergeDutyLines(events, watching.lanes, watch.texts),
517
539
  ...laneFillDutyLines(events, watch.texts),
540
+ ...laneModelDutyLines(events, watch.texts),
541
+ ...parallelDutyLines(events, watch.texts),
542
+ ...approvalDutyLines(events, watch.texts),
518
543
  ]
519
544
  const status = await statusLinesOf(watch, watching, next)
520
545
  const block = statusBlock(status.laneLines, status.coordinator, next.statusLines, status.others)
@@ -533,11 +558,33 @@ const recordGithub = (
533
558
  github: GithubRead,
534
559
  laneBranches: Record<string, string>,
535
560
  now: number,
561
+ readings: FailureReading[],
536
562
  ): WatchEvent[] => {
537
563
  watching.github = github
538
564
  watching.laneBranches = laneBranches
539
565
  watching.githubReadAt = now
540
- return githubEventsOf(watching.state, watching.github, watching.laneBranches)
566
+ watching.failureReadings = readings
567
+ return githubEventsOf(watching.state, watching.github, watching.laneBranches, readings)
568
+ }
569
+
570
+ /**
571
+ * The logs of the lanes' red pull requests, once per head commit (`failureTargets`): `gh run view --log-failed` of each
572
+ * failed run, matched with `watch.checks.failedTestPattern`, which holds with feedback off too. A read
573
+ * that fails is recorded as not read, so the next GitHub poll asks again, a few times at most.
574
+ */
575
+ const readFailures = async (
576
+ watch: Watch,
577
+ watching: Watching,
578
+ github: GithubRead,
579
+ branches: Record<string, string>,
580
+ ): Promise<FailureReading[]> => {
581
+ const answer = answered(github)
582
+ if (!answer) return []
583
+ const pattern = watch.checks.failedTestPattern
584
+ const targets = failureTargets(watching.state.failures, answer.pullRequests, branches)
585
+ return Promise.all(
586
+ targets.map(async (target) => failureReadingOf(target, await readFailedTests(watch.root, target.runIds, pattern))),
587
+ )
541
588
  }
542
589
 
543
590
  const recordCoordinator = (watching: Watching, coordinator: Coordinator): WatchEvent[] => {
@@ -551,6 +598,49 @@ const recordLaneFills = (watching: Watching, readings: LaneReadings): WatchEvent
551
598
  return laneFillEvents(watching.state.laneLevels, readings)
552
599
  }
553
600
 
601
+ /** Lane → the model column of its newest board row; none when the board cannot be read, so no lane reports. */
602
+ const laneModelColumns = (watch: Watch): Record<string, string> =>
603
+ Object.fromEntries(
604
+ (readSprintLanes(watch) ?? []).flatMap((lane) => lane.steps.slice(-1).map((step) => [lane.name, step.model])),
605
+ )
606
+
607
+ /** The agent states a lane can be switched in: it ended its turn and waits for no answer. */
608
+ const IDLE_STATUSES: ReadonlySet<string> = new Set([AGENT_STATUS.done, AGENT_STATUS.idle])
609
+
610
+ /** Each lane's model against its column, as far as both are known, with whether its agent is idle. */
611
+ const laneModelReadingsOf = (
612
+ watch: Watch,
613
+ watching: Watching,
614
+ models: Record<string, string | null>,
615
+ ): Record<string, LaneModelReading> => {
616
+ const columns = laneModelColumns(watch)
617
+ return Object.fromEntries(
618
+ Object.entries(models).flatMap(([lane, model]) => {
619
+ const column = columns[lane]
620
+ const idle = IDLE_STATUSES.has(watching.current[lane]?.status ?? '')
621
+ return column === undefined ? [] : [[lane, { model, idle, column }]]
622
+ }),
623
+ )
624
+ }
625
+
626
+ const recordLaneModelReads = (watching: Watching, readings: Record<string, LaneModelReading>): WatchEvent[] => {
627
+ watching.laneModelReadings = readings
628
+ return laneModelEvents(watching.state.laneModels, readings)
629
+ }
630
+
631
+ /** Each open lane's phases that can run together, read from the plan of its newest row; none when the board cannot be read. */
632
+ const laneParallelReadingsOf = (watch: Watch, watching: Watching): Record<string, string[]> =>
633
+ Object.fromEntries(
634
+ (readSprintLanes(watch) ?? [])
635
+ .filter((lane) => !lane.closed && watching.lanes.includes(lane.name))
636
+ .map((lane) => [lane.name, watchedLaneParallel(watch.root, watch.settings, watch.sprintFile, lane)]),
637
+ )
638
+
639
+ const recordLaneParallelReads = (watching: Watching, readings: Record<string, string[]>): WatchEvent[] => {
640
+ watching.laneParallelReadings = readings
641
+ return laneParallelEvents(watching.state.laneParallels, readings)
642
+ }
643
+
554
644
  /** The account `gh` runs as, read once and kept; null while `gh` cannot tell. */
555
645
  const ensureOwnLogin = async (watch: Watch, watching: Watching): Promise<string | null> => {
556
646
  watching.ownLogin ??= await readOwnLogin(watch.root)
@@ -573,7 +663,7 @@ const readFeedback = async (
573
663
  if (own === null) return null
574
664
  const items = await readFeedbackItems(watch.root, targets, feedbackSince(watching.state.feedback, now))
575
665
  if (!items.ok) return null
576
- const filter = feedbackFilterOf(own, watch.feedback.ignore, watch.previews)
666
+ const filter = feedbackFilterOf(own, watch.feedback, watch.previews?.authors ?? [])
577
667
  return { read: feedbackReadOf(now, targets, items.items, filter), items: items.items }
578
668
  }
579
669
 
@@ -669,30 +759,35 @@ const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: n
669
759
  ])
670
760
  const pullRequests = answered(github)?.pullRequests ?? null
671
761
  const branches = { ...watching.state.laneBranches, ...laneBranches }
762
+ const failureReadings = await readFailures(watch, watching, github, branches)
672
763
  const targets = feedbackTargets(pullRequests, branches, watching.state.feedback)
673
764
  const feedback = await readFeedback(watch, watching, targets, now)
674
765
  const previews = pullRequests ? await readPreviews(watch, watching, targets, feedback?.items ?? null, now) : null
675
766
  return [
676
- ...recordGithub(watching, github, laneBranches, now),
767
+ ...recordGithub(watching, github, laneBranches, now, failureReadings),
677
768
  ...recordFeedbackRead(watching, feedback?.read ?? null),
678
769
  ...recordPreviewRead(watching, previews),
679
770
  ...recordSourceRuns(watch, watching, sourceRuns),
680
771
  ...recordCoordinator(watching, { name, read: fills.coordinator }),
681
772
  ...recordLaneFills(watching, fills.lanes),
773
+ ...recordLaneModelReads(watching, laneModelReadingsOf(watch, watching, fills.models)),
774
+ ...recordLaneParallelReads(watching, laneParallelReadingsOf(watch, watching)),
682
775
  ]
683
776
  }
684
777
 
685
- /** The sprint's lane names; the previous ones when the sprint cannot be read, so no lane drops out over it. */
686
- const sprintLaneNames = (watch: Watch, previous: string[]): string[] => {
778
+ /** The board's lanes, null when the board cannot be read. */
779
+ const readSprintLanes = (watch: Watch): SprintLane[] | null => {
687
780
  try {
688
- return parseSprintLanes(readFileSync(watch.sprintFile, 'utf8'), watch.settings.planLanguage).map(
689
- (lane) => lane.name,
690
- )
781
+ return parseSprintLanes(readFileSync(watch.sprintFile, 'utf8'), watch.settings.planLanguage)
691
782
  } catch {
692
- return previous
783
+ return null
693
784
  }
694
785
  }
695
786
 
787
+ /** The sprint's lane names; the previous ones when the sprint cannot be read, so no lane drops out over it. */
788
+ const sprintLaneNames = (watch: Watch, previous: string[]): string[] =>
789
+ readSprintLanes(watch)?.map((lane) => lane.name) ?? previous
790
+
696
791
  const lanesFor = (watch: Watch, agents: readonly HerdrAgent[], sprintLanes: readonly string[]): string[] =>
697
792
  watch.options.lanes ??
698
793
  discoverLanes({
@@ -701,7 +796,7 @@ const lanesFor = (watch: Watch, agents: readonly HerdrAgent[], sprintLanes: read
701
796
  sprintLanes,
702
797
  })
703
798
 
704
- /** Brings back missing plans views beside the coordinator and the board's lanes when `trigger` calls for it; only inside herdr. */
799
+ /** Brings back missing herd panes beside the coordinator and the board's lanes when `trigger` calls for it; only inside herdr. */
705
800
  const keepViews = (watch: Watch, watching: Watching, trigger: EnsureTrigger) => {
706
801
  if (!watch.herdr) return
707
802
  watching.planViews = keepPlanViews(trigger, watching.planViews, {
@@ -738,7 +833,7 @@ const readAgents = (watching: Watching): boolean => {
738
833
  }
739
834
  }
740
835
 
741
- /** One herdr poll: the agents, the lane set, and once per poll the plans views of added lanes and of new agents. */
836
+ /** One herdr poll: the agents, the lane set, and once per poll the herd panes of added lanes and of new agents. */
742
837
  const pollLanes = (watch: Watch, watching: Watching) => {
743
838
  const previous = watching.current
744
839
  if (!readAgents(watching)) return
@@ -790,7 +885,11 @@ const pollOnce = async (watch: Watch, watching: Watching): Promise<boolean> => {
790
885
  const { stream, heartbeatMinutes } = watch.options
791
886
  const now = Date.now()
792
887
  const due = now - watching.githubReadAt >= GITHUB_POLL_MS
793
- const polledEvents = due ? await pollGithubAndCoordinator(watch, watching, now) : []
888
+ const githubEvents = due ? await pollGithubAndCoordinator(watch, watching, now) : []
889
+ // read at every poll, not with GitHub: a lane waits for the answer only minutes; after the await, so a state saved
890
+ // during the GitHub read records no request as seen before it was reported
891
+ watching.approvalRequests = readApprovalRequests(watch.root)
892
+ const polledEvents = [...githubEvents, ...approvalEvents(watching.state.approvals, watching.approvalRequests, now)]
794
893
  const stopped = [...firedLanes(watching.baseline, watching.current, watching.lanes), ...watching.departed]
795
894
  watching.departed = []
796
895
  const heartbeat = isHeartbeatDue(watching.quietSince, now, heartbeatMinutes * MINUTE_MS)
@@ -835,10 +934,14 @@ const initialWatching = (
835
934
  fetchingBase: false,
836
935
  ownLogin: null,
837
936
  feedbackRead: null,
937
+ failureReadings: [],
838
938
  previewRead: null,
839
939
  sourceRuns: [],
840
940
  sourceRanAt: {},
841
941
  laneReadings: {},
942
+ laneModelReadings: {},
943
+ laneParallelReadings: {},
944
+ approvalRequests: null,
842
945
  planViews: { failureReported: false },
843
946
  })
844
947
 
@@ -901,6 +1004,7 @@ const watchOf = (options: WatchOptions): Watch => {
901
1004
  settings,
902
1005
  deploy: config.watch.deploy,
903
1006
  feedback: config.watch.feedback,
1007
+ checks: config.watch.checks,
904
1008
  previews: config.watch.previews,
905
1009
  sources: config.watch.sources,
906
1010
  texts: WATCH_TEXTS[settings.responseLanguage],
@@ -0,0 +1,52 @@
1
+ import { terminalSafe } from '../plan-format/terminal-text'
2
+
3
+ /** The most failed tests kept for one head commit. */
4
+ export const FAILED_TEST_LIMIT = 50
5
+
6
+ /** The longest test name kept; a log line is third-party text. */
7
+ const FAILED_TEST_NAME_LENGTH = 200
8
+
9
+ /** Log lines longer than this are not matched against the pattern, which bounds its work. */
10
+ const MAX_LINE_LENGTH = 2_000
11
+
12
+ /** `<job>\t<step>\t<ISO timestamp> ` in front of every line of `gh run view --log-failed`. */
13
+ const LOG_PREFIX = /^[^\t]*\t[^\t]*\t\d{4}-\d{2}-\d{2}T[\d:.]+Z /
14
+
15
+ const nameOf = (text: string): string => terminalSafe(text).trim().slice(0, FAILED_TEST_NAME_LENGTH)
16
+
17
+ /**
18
+ * `names` as the view and the report may show them: terminal-safe, cut to the name length, empty and repeated ones
19
+ * dropped, at most `FAILED_TEST_LIMIT`. For names read back from the state file, which is no more trusted than the log.
20
+ */
21
+ export const sanitisedFailedTests = (names: readonly string[]): string[] =>
22
+ [...new Set(names.map(nameOf).filter((name) => name !== ''))].slice(0, FAILED_TEST_LIMIT)
23
+
24
+ const compiled = (pattern: string): RegExp | null => {
25
+ try {
26
+ return new RegExp(pattern)
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ /**
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.
38
+ */
39
+ export const extractFailedTests = (log: string, pattern: string): string[] => {
40
+ const expression = compiled(pattern)
41
+ if (!expression) return []
42
+ const names = new Set<string>()
43
+ for (const raw of log.split('\n')) {
44
+ if (names.size >= FAILED_TEST_LIMIT) break
45
+ const line = raw.replace(/\r$/, '').replace(LOG_PREFIX, '')
46
+ if (line.length > MAX_LINE_LENGTH) continue
47
+ const match = expression.exec(line)
48
+ const name = nameOf(match?.[1] ?? match?.[0] ?? '')
49
+ if (name !== '') names.add(name)
50
+ }
51
+ return [...names]
52
+ }
@@ -0,0 +1,56 @@
1
+ import { extractFailedTests, FAILED_TEST_LIMIT } from './failed-tests'
2
+ import { type GhAnswer, runGh } from './gh'
3
+ import { isRecord } from './json-guards'
4
+
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
+ export const LOG_TIMEOUT_MS = 30_000
7
+
8
+ /** Of a longer log only this many characters from the end are kept; Bun repeats its failures in the summary there. */
9
+ export const LOG_TAIL_CHARS = 2_000_000
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 }
13
+
14
+ type RunGh = (args: string[]) => Promise<GhAnswer>
15
+
16
+ export const logArgs = (runId: number): string[] => ['run', 'view', String(runId), '--log-failed']
17
+
18
+ export const statusArgs = (runId: number): string[] => ['run', 'view', String(runId), '--json', 'status,conclusion']
19
+
20
+ const COMPLETED = 'completed'
21
+
22
+ const isRunning = (answer: GhAnswer): boolean => {
23
+ if (!answer.ok) return false
24
+ try {
25
+ const value: unknown = JSON.parse(answer.stdout)
26
+ return isRecord(value) && typeof value.status === 'string' && value.status !== COMPLETED
27
+ } catch {
28
+ return false
29
+ }
30
+ }
31
+
32
+ /**
33
+ * The failed tests in the logs of `runIds` matched with `pattern`. Each run is first asked for its status
34
+ * (`gh run view --json status`, cheap): while any is not completed, `gh` cannot give its failed log, so the read
35
+ * fails with `running` and the log is not asked for. Then one `gh run view --log-failed` per run, in parallel; any
36
+ * call that fails makes the read fail. The default calls give up after `LOG_TIMEOUT_MS` and keep only the last
37
+ * `LOG_TAIL_CHARS` characters of a log as it streams in. The log is whatever the project's jobs print, never interpreted.
38
+ */
39
+ export const readFailedTests = async (
40
+ root: string,
41
+ runIds: readonly number[],
42
+ pattern: string,
43
+ run: RunGh = (args) =>
44
+ runGh(args, root, {
45
+ timeoutMs: LOG_TIMEOUT_MS,
46
+ ...(args.includes('--log-failed') && { tailChars: LOG_TAIL_CHARS }),
47
+ }),
48
+ ): Promise<FailedTestsRead> => {
49
+ const statuses = await Promise.all(runIds.map((runId) => run(statusArgs(runId))))
50
+ if (statuses.some(isRunning)) return { ok: false, running: true }
51
+ const answers = await Promise.all(runIds.map((runId) => run(logArgs(runId))))
52
+ const logs = answers.flatMap((answer) => (answer.ok ? [answer.stdout] : []))
53
+ 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) }
56
+ }
@@ -0,0 +1,107 @@
1
+ import { CHECKS } from '../plans/pull-requests'
2
+ import type { FailedTestsRead } from './failure-read'
3
+ import { isRecord } from './json-guards'
4
+ import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
5
+
6
+ /**
7
+ * What the watcher read of a red pull request's logs: the tests that failed on `headCommit`. `read` is true once the
8
+ * logs answered (with no test among them, too), `attempts` counts the reads tried, so a run that was still going when
9
+ * it was asked is asked again, a few times, and no head commit is read more than once it has answered.
10
+ */
11
+ export type FailureRecord = { headCommit: string; tests: string[]; read: boolean; attempts: number }
12
+
13
+ /** Pull request number → what was read for it; kept for the open pull requests only. */
14
+ export type FailureState = Record<string, FailureRecord>
15
+
16
+ /** A red pull request of a lane whose logs are to be read: the runs of its failed checks, and the reads tried so far. */
17
+ export type FailureTarget = { number: number; headCommit: string; runIds: number[]; attempts: number }
18
+
19
+ /** The result of reading one target; `attempts` is the count including this read. */
20
+ export type FailureReading = FailureRecord & { number: number }
21
+
22
+ /** A read that failed for another reason than a run still going is tried again at the next GitHub read, this often at most. */
23
+ export const FAILURE_READ_ATTEMPTS = 3
24
+
25
+ /** The most runs read for one head commit. */
26
+ export const FAILURE_RUN_LIMIT = 3
27
+
28
+ export const emptyFailureState = (): FailureState => ({})
29
+
30
+ const isFailureRecord = (value: unknown): boolean =>
31
+ isRecord(value) &&
32
+ typeof value.headCommit === 'string' &&
33
+ Array.isArray(value.tests) &&
34
+ value.tests.every((test) => typeof test === 'string') &&
35
+ typeof value.read === 'boolean' &&
36
+ typeof value.attempts === 'number'
37
+
38
+ export const isFailureState = (value: unknown): value is FailureState =>
39
+ isRecord(value) && Object.values(value).every(isFailureRecord)
40
+
41
+ const runIdsOf = (pullRequest: WatchedPullRequest): number[] => [
42
+ ...new Set(pullRequest.failedRuns.flatMap(({ runId }) => (runId === null ? [] : [runId]))),
43
+ ]
44
+
45
+ const needsReading = (record: FailureRecord | undefined, headCommit: string): boolean =>
46
+ record?.headCommit !== headCommit || (!record.read && record.attempts < FAILURE_READ_ATTEMPTS)
47
+
48
+ /**
49
+ * The pull requests whose logs to read now: open, red, on a lane's branch, with a run among their failed checks, and
50
+ * not read yet on this head commit. Other pull requests get the names of their checks and nothing more.
51
+ */
52
+ export const failureTargets = (
53
+ previous: FailureState,
54
+ pullRequests: readonly WatchedPullRequest[],
55
+ laneBranches: Readonly<Record<string, string>>,
56
+ ): FailureTarget[] => {
57
+ const branches = new Set(Object.values(laneBranches))
58
+ return pullRequests.flatMap((pullRequest) => {
59
+ const record = previous[String(pullRequest.number)]
60
+ const runIds = runIdsOf(pullRequest).slice(0, FAILURE_RUN_LIMIT)
61
+ const due =
62
+ pullRequest.state === PULL_REQUEST_STATE.open &&
63
+ pullRequest.checks === CHECKS.failed &&
64
+ branches.has(pullRequest.branch) &&
65
+ runIds.length > 0 &&
66
+ needsReading(record, pullRequest.headCommit)
67
+ const known = record?.headCommit === pullRequest.headCommit ? record.attempts : 0
68
+ return due ? [{ number: pullRequest.number, headCommit: pullRequest.headCommit, runIds, attempts: known }] : []
69
+ })
70
+ }
71
+
72
+ /**
73
+ * The reading of `target` from what `readFailedTests` answered. A failed read counts as an attempt, except one that
74
+ * found the run still going: that is asked again at every GitHub read until it ends, and the cap is for real errors.
75
+ */
76
+ export const failureReadingOf = (
77
+ { number, headCommit, attempts }: FailureTarget,
78
+ result: FailedTestsRead,
79
+ ): FailureReading =>
80
+ result.ok
81
+ ? { number, headCommit, tests: result.tests, read: true, attempts: attempts + 1 }
82
+ : { number, headCommit, tests: [], read: false, attempts: result.running ? attempts : attempts + 1 }
83
+
84
+ /**
85
+ * The state after `readings`: the readings stored, and only the open pull requests of `pullRequests` kept. Without
86
+ * GitHub's list (`null`) nothing is forgotten. Recording a reading twice gives the same state.
87
+ */
88
+ export const recordFailures = (
89
+ previous: FailureState,
90
+ pullRequests: readonly WatchedPullRequest[] | null,
91
+ readings: readonly FailureReading[],
92
+ ): FailureState => {
93
+ const open = pullRequests
94
+ ? new Set(pullRequests.filter(({ state }) => state === PULL_REQUEST_STATE.open).map(({ number }) => String(number)))
95
+ : null
96
+ const merged: FailureState = {
97
+ ...previous,
98
+ ...Object.fromEntries(readings.map(({ number, ...record }) => [String(number), record])),
99
+ }
100
+ return open ? Object.fromEntries(Object.entries(merged).filter(([number]) => open.has(number))) : merged
101
+ }
102
+
103
+ /** The failed tests read for `headCommit` of the pull request; none for any other head commit. */
104
+ export const failedTestsOf = (state: FailureState, number: number, headCommit: string): string[] => {
105
+ const record = state[String(number)]
106
+ return record?.headCommit === headCommit ? record.tests : []
107
+ }