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
package/src/plans/live.ts CHANGED
@@ -3,6 +3,7 @@ import { dirname } from 'node:path'
3
3
  import { pathToFileURL } from 'node:url'
4
4
  import { terminalSafe } from '../plan-format/terminal-text'
5
5
  import { openInBrowser } from '../terminal/open'
6
+ import type { LaneMemory } from './current-phase'
6
7
  import { errorText } from './error-text'
7
8
  import {
8
9
  type FetchState,
@@ -50,6 +51,8 @@ type Live = {
50
51
  /** This clone's `FETCH_HEAD`, watched beside the plan files; null when git names none. */
51
52
  fetchHead: string | null
52
53
  reported: Map<string, string | null>
54
+ /** Per lane, the ticks seen at the last read and the phase that moved last; the herd's start empties it. */
55
+ memories: Map<string, LaneMemory>
53
56
  pending: Timer | null
54
57
  /** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of `page`. */
55
58
  read: Snapshot | null
@@ -185,7 +188,7 @@ const refresh = (live: Live, onChange: () => void): Promise<void> => {
185
188
 
186
189
  const readOnce = async (live: Live, onChange: () => void) => {
187
190
  try {
188
- const read = await loadSnapshot(live.source, live.pullRequests)
191
+ const read = await loadSnapshot(live.source, live.pullRequests, live.memories)
189
192
  const next = withKnownPage(live, read)
190
193
  live.read = read
191
194
  if (!live.snapshot) live.view = { ...live.view, selection: startSelection(next, live.source.options) }
@@ -277,6 +280,7 @@ export const runLive = (source: Source): void => {
277
280
  watchers: new Map(),
278
281
  fetchHead: fetchHeadPath(source.root),
279
282
  reported: new Map(),
283
+ memories: new Map(),
280
284
  pending: null,
281
285
  read: null,
282
286
  page: null,
@@ -0,0 +1,37 @@
1
+ import { lstatSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { isPhaseModelRecord, type PhaseModelRecord } from './phase-models'
4
+
5
+ /** The file name the hook gives a record: 16 hex characters of a hash over plan and phase. */
6
+ const RECORD_FILE = /^[0-9a-f]{16}\.json$/
7
+
8
+ /** A record is a line of JSON; anything larger was not written by the hook. */
9
+ const MAX_RECORD_BYTES = 4 * 1024
10
+
11
+ /** A plain file of record size: a link could read elsewhere and a pipe would block the herd, so neither is read. */
12
+ const isRecordFile = (path: string): boolean => {
13
+ const entry = lstatSync(path)
14
+ return entry.isFile() && entry.size < MAX_RECORD_BYTES
15
+ }
16
+
17
+ const recordOf = (path: string): PhaseModelRecord[] => {
18
+ try {
19
+ if (!isRecordFile(path)) return []
20
+ const record: unknown = JSON.parse(readFileSync(path, 'utf8'))
21
+ return isPhaseModelRecord(record) ? [record] : []
22
+ } catch {
23
+ return []
24
+ }
25
+ }
26
+
27
+ /** Every record of the model log in the main checkout; nothing when the directory is missing or unreadable. */
28
+ export const readPhaseModels = (root: string): PhaseModelRecord[] => {
29
+ const directory = join(root, '.jackherd', 'models')
30
+ try {
31
+ return readdirSync(directory)
32
+ .filter((name) => RECORD_FILE.test(name))
33
+ .flatMap((name) => recordOf(join(directory, name)))
34
+ } catch {
35
+ return []
36
+ }
37
+ }
@@ -0,0 +1,70 @@
1
+ import { type LaneProgress, resolvePlanPath } from '../plan-format'
2
+ import { isRecord } from '../watch/json-guards'
3
+
4
+ /** The model families a phase ran on and was recommended, as lower-case words; `fits` null without a recommendation. */
5
+ export type PhaseModel = { used: string; recommended: string | null; fits: boolean | null }
6
+
7
+ /**
8
+ * A record as the kit's model log hook writes it to `.jackherd/models/<key>.json` in the main checkout: the plan from
9
+ * the repository root, the phase, the model id of the subagent's last answer and when it ended. Same shape as
10
+ * `PhaseModelRecord` in the hook, which this package does not import at run time.
11
+ */
12
+ export type PhaseModelRecord = PhaseModel & { plan: string; phase: string; model: string; at: number }
13
+
14
+ /** A family as a word of at most 16 lower-case letters, so a record cannot widen the phase row. */
15
+ const FAMILY = /^[a-z]{1,16}$/
16
+
17
+ const isFamily = (value: unknown): value is string => typeof value === 'string' && FAMILY.test(value)
18
+
19
+ const isVerdict = (recommended: unknown, fits: unknown): boolean =>
20
+ (recommended === null && fits === null) || (isFamily(recommended) && typeof fits === 'boolean')
21
+
22
+ export const isPhaseModelRecord = (value: unknown): value is PhaseModelRecord =>
23
+ isRecord(value) &&
24
+ typeof value.plan === 'string' &&
25
+ typeof value.phase === 'string' &&
26
+ typeof value.model === 'string' &&
27
+ isFamily(value.used) &&
28
+ isVerdict(value.recommended, value.fits) &&
29
+ typeof value.at === 'number'
30
+
31
+ /** The key of a phase of a lane's step in `LaneView.models`. */
32
+ export const phaseModelKey = (stepIndex: number, phaseId: string): string => `${stepIndex}:${phaseId}`
33
+
34
+ const recordKey = (plan: string, phase: string): string => `${plan}\n${phase}`
35
+
36
+ /** The newest record per plan and phase. */
37
+ const newestByPhase = (records: readonly PhaseModelRecord[]): Map<string, PhaseModelRecord> => {
38
+ const newest = new Map<string, PhaseModelRecord>()
39
+ for (const record of records) {
40
+ const key = recordKey(record.plan, record.phase)
41
+ const known = newest.get(key)
42
+ if (!known || record.at > known.at) newest.set(key, record)
43
+ }
44
+ return newest
45
+ }
46
+
47
+ /** The model of every phase of the lane's steps that has a record, by `phaseModelKey`. */
48
+ export const laneModelsOf = (
49
+ progress: LaneProgress,
50
+ records: readonly PhaseModelRecord[],
51
+ sprintDirectory: string,
52
+ ): Record<string, PhaseModel> => {
53
+ const newest = newestByPhase(records)
54
+ return Object.fromEntries(
55
+ progress.steps.flatMap(({ step, phases }, stepIndex) => {
56
+ const plan = step.planHref ? resolvePlanPath(sprintDirectory, step.planHref) : null
57
+ return phases.flatMap((phase) => {
58
+ const record = plan === null ? undefined : newest.get(recordKey(plan, phase.id))
59
+ return record
60
+ ? [
61
+ [
62
+ phaseModelKey(stepIndex, phase.id),
63
+ { used: record.used, recommended: record.recommended, fits: record.fits },
64
+ ],
65
+ ]
66
+ : []
67
+ })
68
+ }),
69
+ )
70
+ }
@@ -1,6 +1,7 @@
1
1
  import { basename } from 'node:path'
2
+ import { HERD_COMMAND, HERD_COMMAND_WORDS } from './herd-command'
2
3
 
3
- /** One herdr pane as `plans --ensure` sees it: where it is, whether an agent runs in it, its foreground commands. */
4
+ /** One herdr pane as `herd --ensure` sees it: where it is, whether an agent runs in it, its foreground commands. */
4
5
  export type PaneState = { pane: string; tab: string; cwd: string; agent: boolean; foreground: string[][] }
5
6
 
6
7
  /** Runs a command in a free shell pane, or splits a pane to the right and runs it in the new one. */
@@ -79,7 +80,7 @@ export const foregroundOf = (json: string): string[][] => {
79
80
 
80
81
  const SHELLS = new Set(['bash', 'zsh', 'sh', 'fish', 'dash', 'ksh'])
81
82
 
82
- /** Arguments that make a `plans` call something other than the live view. */
83
+ /** Arguments that make a `herd` call something other than the live view. */
83
84
  const NOT_A_VIEW = ['--once', '--ensure']
84
85
 
85
86
  const programOf = (argv: readonly string[]): string => basename(argv[0] ?? '').replace(/^-/, '')
@@ -92,12 +93,12 @@ const laneOf = (argv: readonly string[]): string | null => {
92
93
  }
93
94
 
94
95
  const isViewCall = (argv: readonly string[], lane: string | null): boolean =>
95
- argv.includes('plans') &&
96
+ HERD_COMMAND_WORDS.includes(argv[argv.findIndex(isKitEntry) + 1] ?? '') &&
96
97
  argv.some(isKitEntry) &&
97
98
  !argv.some((arg) => NOT_A_VIEW.includes(arg)) &&
98
99
  laneOf(argv) === lane
99
100
 
100
- /** Whether a foreground command is the live plans view: of `lane`, or of every lane for null. */
101
+ /** Whether a foreground command is the live herd: of `lane`, or of every lane for null. */
101
102
  export const isPlanView = (foreground: readonly (readonly string[])[], lane: string | null): boolean =>
102
103
  foreground.some((argv) => isViewCall(argv, lane))
103
104
 
@@ -125,14 +126,14 @@ export const missingCoordinatorNote = (panes: readonly PaneState[], root: string
125
126
  const inRoot = panes.filter((each) => each.agent && each.cwd === root).length
126
127
  return inRoot === 0
127
128
  ? 'coordinator: no agent in the main checkout'
128
- : `coordinator: ${inRoot} agents in the main checkout and none named ${name}; run plans --ensure in its pane, or herdr agent rename <pane> ${name}`
129
+ : `coordinator: ${inRoot} agents in the main checkout and none named ${name}; run herd --ensure in its pane, or herdr agent rename <pane> ${name}`
129
130
  }
130
131
 
131
132
  const quoted = (arg: string): string => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`)
132
133
 
133
134
  /** The shell command that starts the view with the CLI `argv` names (runtime and entry), for `lane` or every lane. */
134
135
  export const viewCommandOf = (argv: readonly string[], lane: string | null): string =>
135
- [...argv.slice(0, 2), 'plans', ...(lane ? ['--lane', lane] : [])].map(quoted).join(' ')
136
+ [...argv.slice(0, 2), HERD_COMMAND.name, ...(lane ? ['--lane', lane] : [])].map(quoted).join(' ')
136
137
 
137
138
  /** The new pane of a `herdr pane split` answer; throws on a changed answer. */
138
139
  export const splitPaneOf = (json: string): string => {
@@ -178,7 +179,7 @@ const inMainCheckout = (root: string, worktrees: string) => {
178
179
  const laneAnchor = (panes: readonly PaneState[], inside: (cwd: string) => boolean): PaneState | undefined =>
179
180
  panes.find((each) => inside(each.cwd) && each.agent)
180
181
 
181
- /** The plans views that are missing beside the coordinator and each lane, and how to bring each back. */
182
+ /** The herd panes that are missing beside the coordinator and each lane, and how to bring each back. */
182
183
  export const planPaneActions = ({
183
184
  panes,
184
185
  coordinator,
@@ -12,10 +12,26 @@ export type PullRequest = {
12
12
  branch: string
13
13
  draft: boolean
14
14
  checks: ChecksState
15
+ /** The names of the checks that failed, each once, in the rollup's order; empty while none failed. */
16
+ failedChecks: string[]
15
17
  }
16
18
 
17
- /** One entry of `statusCheckRollup`: a check run carries status and conclusion, a commit status a state. */
18
- export type RollupEntry = { __typename?: string; status?: string; conclusion?: string | null; state?: string }
19
+ /**
20
+ * One entry of `statusCheckRollup`: a check run carries a name, status, conclusion and the link to its job
21
+ * (`detailsUrl`), a commit status a context and a state.
22
+ */
23
+ export type RollupEntry = {
24
+ __typename?: string
25
+ name?: string
26
+ context?: string
27
+ detailsUrl?: string
28
+ status?: string
29
+ conclusion?: string | null
30
+ state?: string
31
+ }
32
+
33
+ /** A failed check: its name, and the workflow run it belongs to when the link of its job names one. */
34
+ export type FailedCheck = { name: string; runId: number | null }
19
35
 
20
36
  export type GhPullRequest = {
21
37
  number: number
@@ -29,6 +45,18 @@ export type GhPullRequest = {
29
45
  /** The fields `parsePullRequests` reads; pass them to `gh pr list --json`. */
30
46
  export const PULL_REQUEST_FIELDS = 'number,title,url,headRefName,isDraft,statusCheckRollup'
31
47
 
48
+ const RUN_IN_PATH = /^\/([\w.-]+)\/([\w.-]+)\/actions\/runs\/(\d+)(?:\/|$)/
49
+
50
+ const PULL_REQUEST_PATH = /^\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)$/
51
+
52
+ /** The longest check name shown or reported; a name comes from the outside. */
53
+ export const CHECK_NAME_LENGTH = 60
54
+
55
+ const DEFAULT_HOST = 'github.com'
56
+
57
+ /** Where a run link must lead: `host`, and when `repository` (`<owner>/<repo>`) is set, that repository only. */
58
+ export type RunScope = { host: string; repository: string | null }
59
+
32
60
  const FAILED = new Set(['FAILURE', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'ERROR'])
33
61
  const PENDING_STATES = new Set(['PENDING', 'EXPECTED'])
34
62
 
@@ -44,7 +72,52 @@ export const checksOf = (rollup: readonly RollupEntry[]): ChecksState => {
44
72
  return rollup.some(isPending) ? CHECKS.pending : CHECKS.passed
45
73
  }
46
74
 
47
- const PULL_REQUEST_PATH = /^\/[\w.-]+\/[\w.-]+\/pull\/(\d+)$/
75
+ /**
76
+ * The workflow run a link of a check names: `https://<host>/<owner>/<repo>/actions/runs/<id>` with an optional rest.
77
+ * The link comes from the outside and ends up as an argument of `gh run view`, so the host must be `scope.host`
78
+ * (without credentials, over https), the repository `scope.repository` when set, and the id a safe integer.
79
+ */
80
+ export const runIdOfLink = (
81
+ link: string | undefined,
82
+ scope: RunScope = { host: DEFAULT_HOST, repository: null },
83
+ ): number | null => {
84
+ try {
85
+ const url = new URL(link ?? '')
86
+ const match = url.pathname.match(RUN_IN_PATH)
87
+ const own = url.protocol === 'https:' && url.hostname === scope.host && url.username === '' && url.password === ''
88
+ const sameRepository = scope.repository === null || `${match?.[1]}/${match?.[2]}` === scope.repository
89
+ const id = Number(match?.[3])
90
+ return own && match && sameRepository && Number.isSafeInteger(id) ? id : null
91
+ } catch {
92
+ return null
93
+ }
94
+ }
95
+
96
+ /** The scope of a pull request page: its host and repository, null when the page is not one. */
97
+ const scopeOfPullRequest = (pullRequestUrl: string | undefined): RunScope | undefined => {
98
+ if (pullRequestUrl === undefined) return undefined
99
+ try {
100
+ const url = new URL(pullRequestUrl)
101
+ const path = url.pathname.match(PULL_REQUEST_PATH)
102
+ return { host: url.hostname, repository: path ? `${path[1]}/${path[2]}` : null }
103
+ } catch {
104
+ return undefined
105
+ }
106
+ }
107
+
108
+ /**
109
+ * The checks of the rollup that failed, each name once with the run of its first failure, in the rollup's order. Names
110
+ * come from the outside and are made terminal-safe; an entry without a name is left out.
111
+ */
112
+ export const failedChecksOf = (rollup: readonly RollupEntry[], pullRequestUrl?: string): FailedCheck[] => {
113
+ const scope = scopeOfPullRequest(pullRequestUrl)
114
+ const failed = new Map<string, FailedCheck>()
115
+ for (const entry of rollup.filter(isFailed)) {
116
+ const name = terminalSafe(entry.name ?? entry.context ?? '').trim()
117
+ if (name !== '' && !failed.has(name)) failed.set(name, { name, runId: runIdOfLink(entry.detailsUrl, scope) })
118
+ }
119
+ return [...failed.values()]
120
+ }
48
121
 
49
122
  /** True only for `https://<host>/<owner>/<repo>/pull/<number>` of this very pull request: the view opens it on a click. */
50
123
  const isPullRequestPage = (url: string, number: number, host: string): boolean => {
@@ -52,7 +125,7 @@ const isPullRequestPage = (url: string, number: number, host: string): boolean =
52
125
  const parsed = new URL(url)
53
126
  const path = parsed.pathname.match(PULL_REQUEST_PATH)
54
127
  const exact = parsed.href === url && parsed.search === '' && parsed.hash === ''
55
- return exact && parsed.protocol === 'https:' && parsed.hostname === host && Number(path?.[1]) === number
128
+ return exact && parsed.protocol === 'https:' && parsed.hostname === host && Number(path?.[3]) === number
56
129
  } catch {
57
130
  return false
58
131
  }
@@ -89,6 +162,7 @@ export const parsePullRequests = (json: string, host = 'github.com'): PullReques
89
162
  branch: terminalSafe(pullRequest.headRefName),
90
163
  draft: pullRequest.isDraft,
91
164
  checks: checksOf(pullRequest.statusCheckRollup ?? []),
165
+ failedChecks: failedChecksOf(pullRequest.statusCheckRollup ?? [], pullRequest.url).map(({ name }) => name),
92
166
  }))
93
167
  }
94
168
 
@@ -12,7 +12,7 @@ export type LaneFold = { status: string | null; open: boolean }
12
12
  /**
13
13
  * The row under the cursor and the expanded phases, both as keys, whether ticked phases and items are
14
14
  * listed one by one or summed up in one line, whether the footer shows the symbol legend, and the
15
- * folds of the lanes by lane name, and the lanes whose notes (sprint rows without a phase) are listed.
15
+ * folds of the lanes by lane name.
16
16
  */
17
17
  export type Selection = {
18
18
  selected: string | null
@@ -20,7 +20,6 @@ export type Selection = {
20
20
  showDone: boolean
21
21
  showLegend: boolean
22
22
  laneFolds: ReadonlyMap<string, LaneFold>
23
- notesShown: ReadonlySet<string>
24
23
  }
25
24
 
26
25
  /** A lane as the folds need it: its name and the status its agent reports, null without an agent. */
@@ -51,7 +50,6 @@ export const INPUT = {
51
50
  collapse: ['\x1b[D', '\x1bOD', 'h'],
52
51
  showDone: ['a'],
53
52
  legend: ['?'],
54
- notes: ['n'],
55
53
  sprintPage: ['s'],
56
54
  pageUp: ['\x1b[5~'],
57
55
  pageDown: ['\x1b[6~'],
@@ -73,13 +71,9 @@ export const PLANNED_KEY = 'planned'
73
71
 
74
72
  export const phaseKey = (lane: string, stepIndex: number, phaseId: string): string => `${lane}:${stepIndex}:${phaseId}`
75
73
 
76
- const NOTES_KEY_PREFIX = 'notes>'
77
74
  const FOLLOWING_KEY_PREFIX = 'following>'
78
75
  const TICKED_KEY_PREFIX = 'ticked>'
79
76
 
80
- /** The key of the row that shows or hides the notes of a lane; selectable, since a key reaches it too. */
81
- export const notesKey = (lane: string): string => `${NOTES_KEY_PREFIX}${lane}`
82
-
83
77
  /** The key of the folded row under a lane that lists the open phases after its own. */
84
78
  export const followingKey = (lane: string): string => `${FOLLOWING_KEY_PREFIX}${lane}`
85
79
 
@@ -98,12 +92,6 @@ export const itemKey = (phase: string, index: number): string => `${phase}#${ind
98
92
  /** The phase a task key belongs to, null for any other key. */
99
93
  export const itemPhaseOfKey = (key: string): string | null => key.match(ITEM_KEY)?.[1] ?? null
100
94
 
101
- const notesLaneOfKey = (key: string): string | null =>
102
- key.startsWith(NOTES_KEY_PREFIX) ? key.slice(NOTES_KEY_PREFIX.length) : null
103
-
104
- const followingLaneOfKey = (key: string): string | null =>
105
- key.startsWith(FOLLOWING_KEY_PREFIX) ? key.slice(FOLLOWING_KEY_PREFIX.length) : null
106
-
107
95
  const tickedPhaseOfKey = (key: string): string | null =>
108
96
  key.startsWith(TICKED_KEY_PREFIX) ? key.slice(TICKED_KEY_PREFIX.length) : null
109
97
 
@@ -114,12 +102,6 @@ export const laneKey = (lane: string): string => `${LANE_KEY_PREFIX}${lane}`
114
102
 
115
103
  const laneOfKey = (key: string): string | null => (key.startsWith(LANE_KEY_PREFIX) ? key.slice(1) : null)
116
104
 
117
- const PHASE_KEY = /^(.+):\d+:[^:]+$/
118
-
119
- /** The lane a row belongs to: its own row or one of its phases; null for a pull request row. */
120
- const laneNameOfKey = (key: string): string | null =>
121
- laneOfKey(key) ?? notesLaneOfKey(key) ?? followingLaneOfKey(key) ?? key.match(PHASE_KEY)?.[1] ?? null
122
-
123
105
  /** Working and blocked lanes show their phases; idle and done ones, and lanes without an agent, fold into one line. */
124
106
  export const autoOpen = (status: string | null): boolean =>
125
107
  status !== null && status !== AGENT_STATUS.idle && status !== AGENT_STATUS.done
@@ -153,7 +135,6 @@ export const overviewSelection = (): Selection => ({
153
135
  showDone: false,
154
136
  showLegend: false,
155
137
  laneFolds: new Map(),
156
- notesShown: new Set(),
157
138
  })
158
139
 
159
140
  /** The lane view opens its lane and, when it has a current phase, that phase, expanded. */
@@ -200,13 +181,6 @@ const foldLane = (selection: Selection, lane: string, next: (open: boolean) => b
200
181
  return { ...selection, laneFolds: new Map(selection.laneFolds).set(lane, { ...fold, open: next(fold.open) }) }
201
182
  }
202
183
 
203
- const withNotes = (selection: Selection, lane: string, next: (shown: boolean) => boolean): Selection => {
204
- const notesShown = new Set(selection.notesShown)
205
- if (next(notesShown.has(lane))) notesShown.add(lane)
206
- else notesShown.delete(lane)
207
- return { ...selection, notesShown }
208
- }
209
-
210
184
  /** Opens a task and the phase it belongs to. */
211
185
  const openTaskWithPhase = (expanded: Set<string>, phase: string, task: string): void => {
212
186
  expanded.add(phase)
@@ -215,8 +189,6 @@ const openTaskWithPhase = (expanded: Set<string>, phase: string, task: string):
215
189
 
216
190
  /** Selects a row and opens it, or closes it when it is already open: a phase shows its items, a lane its phases. */
217
191
  export const applyClick = (selection: Selection, key: string): Selection => {
218
- const notesLane = notesLaneOfKey(key)
219
- if (notesLane !== null) return { ...withNotes(selection, notesLane, flipped), selected: key }
220
192
  const phase = tickedPhaseOfKey(key)
221
193
  if (phase !== null) return { ...selection, showDone: !selection.showDone, selected: phase }
222
194
  const lane = laneOfKey(key)
@@ -236,8 +208,6 @@ const toggle = (selection: Selection, keys: readonly string[]): Selection => {
236
208
  const changeSelected = (selection: Selection, keys: readonly string[], next: (open: boolean) => boolean): Selection => {
237
209
  const current = selectedKey(selection, keys)
238
210
  if (!current) return selection
239
- const notesLane = notesLaneOfKey(current)
240
- if (notesLane !== null) return { ...withNotes(selection, notesLane, next), selected: current }
241
211
  const lane = laneOfKey(current)
242
212
  if (lane !== null) return { ...foldLane(selection, lane, next), selected: current }
243
213
  const expanded = new Set(selection.expanded)
@@ -253,14 +223,6 @@ const expand = (selection: Selection, keys: readonly string[]): Selection => cha
253
223
 
254
224
  const collapse = (selection: Selection, keys: readonly string[]): Selection => changeSelected(selection, keys, closed)
255
225
 
256
- /** Lists or hides the notes of the lane under the cursor. */
257
- const toggleNotes = (selection: Selection, keys: readonly string[]): Selection => {
258
- const current = selectedKey(selection, keys)
259
- const lane = current ? laneNameOfKey(current) : null
260
- if (!lane) return selection
261
- return withNotes(selection, lane, flipped)
262
- }
263
-
264
226
  export const applyKey = (selection: Selection, input: string, keys: readonly string[]): Selection => {
265
227
  if (isInput(INPUT.up, input)) return move(selection, keys, -1)
266
228
  if (isInput(INPUT.down, input)) return move(selection, keys, 1)
@@ -268,7 +230,6 @@ export const applyKey = (selection: Selection, input: string, keys: readonly str
268
230
  if (isInput(INPUT.expand, input)) return expand(selection, keys)
269
231
  if (isInput(INPUT.collapse, input)) return collapse(selection, keys)
270
232
  if (isInput(INPUT.showDone, input)) return { ...selection, showDone: !selection.showDone }
271
- if (isInput(INPUT.notes, input)) return toggleNotes(selection, keys)
272
233
  if (isInput(INPUT.legend, input)) return { ...selection, showLegend: !selection.showLegend }
273
234
  return selection
274
235
  }
@@ -4,6 +4,7 @@ import { type JackherdConfig, parseJackherdConfig } from '../config'
4
4
  import { PROJECT_CONFIG_PATH } from '../config/location'
5
5
  import { readConfig } from '../config/read-config'
6
6
  import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
7
+ import type { TicketSource } from '../watch/plan-badges'
7
8
 
8
9
  export const CONFIG_PATH = PROJECT_CONFIG_PATH
9
10
 
@@ -15,6 +16,8 @@ export type PlansSettings = Pick<
15
16
  fetchIntervalMinutes: number
16
17
  linkKinds: JackherdConfig['plans']['linkKinds']
17
18
  context: JackherdConfig['plans']['context']
19
+ /** The `watch.sources` marked `ticket`, whose entries the view shows as badges. */
20
+ ticketSources: TicketSource[]
18
21
  }
19
22
 
20
23
  const CLOCK_LOCALE: Record<PlanLanguage, string> = {
@@ -22,7 +25,7 @@ const CLOCK_LOCALE: Record<PlanLanguage, string> = {
22
25
  [PLAN_LANGUAGE.en]: 'en-GB',
23
26
  }
24
27
 
25
- /** The plans view's slice of a validated config. */
28
+ /** The herd's slice of a validated config. */
26
29
  export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
27
30
  planDirectory: config.planDirectory,
28
31
  board: config.board,
@@ -34,9 +37,12 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
34
37
  fetchIntervalMinutes: config.plans.fetchIntervalMinutes,
35
38
  linkKinds: config.plans.linkKinds,
36
39
  context: config.plans.context,
40
+ ticketSources: config.watch.sources.flatMap(({ name, lane, ticket }) =>
41
+ ticket ? [{ name, lane: lane ?? null }] : [],
42
+ ),
37
43
  })
38
44
 
39
- /** The plans view's slice of `.jackherd/config.json`, validated by the shared schema. */
45
+ /** The herd's slice of `.jackherd/config.json`, validated by the shared schema. */
40
46
  export const parsePlansSettings = (raw: unknown): PlansSettings => plansSettingsOf(parseJackherdConfig(raw ?? {}))
41
47
 
42
48
  /** Board documents, relative to the main checkout. */
@@ -63,6 +69,6 @@ export const baseRefOf = ({ baseBranch }: Pick<PlansSettings, 'baseBranch'>): st
63
69
 
64
70
  export const clockLocaleOf = (settings: PlansSettings): string => CLOCK_LOCALE[settings.responseLanguage]
65
71
 
66
- /** The plans view's settings from the config `readConfig` finds. */
72
+ /** The herd's settings from the config `readConfig` finds. */
67
73
  export const readSettings = (roots: string[], home: string = homedir()): PlansSettings =>
68
74
  plansSettingsOf(readConfig(roots, home))
@@ -0,0 +1,5 @@
1
+ /** The first `limit` of `items`, and how many are left out; never negative. */
2
+ export const shownWithMore = <T>(items: readonly T[], limit: number): { shown: T[]; more: number } => ({
3
+ shown: items.slice(0, limit),
4
+ more: Math.max(items.length - limit, 0),
5
+ })