jackherd 1.6.1 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/INSTALL.md +176 -0
  3. package/README.md +228 -312
  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 +109 -27
  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/legacy-names.ts +8 -0
  81. package/src/config/nested-quantifier.ts +73 -0
  82. package/src/config/repository-directory.ts +11 -0
  83. package/src/config/schema.ts +63 -8
  84. package/src/plan-format/board-prose.ts +36 -0
  85. package/src/plan-format/dependencies.ts +227 -0
  86. package/src/plan-format/documentation-gate.ts +16 -0
  87. package/src/plan-format/index.ts +23 -1
  88. package/src/plan-format/lanes.ts +35 -1
  89. package/src/plan-format/model-column.ts +52 -0
  90. package/src/plan-format/phases.ts +16 -1
  91. package/src/plan-format/plan-structure.ts +10 -28
  92. package/src/plan-format/progress.ts +7 -29
  93. package/src/plans/command.ts +3 -3
  94. package/src/plans/current-phase.ts +71 -0
  95. package/src/plans/ensure-panes.ts +2 -2
  96. package/src/plans/herd-command.ts +4 -0
  97. package/src/plans/lane-discovery.ts +14 -2
  98. package/src/plans/lane-tickets.ts +69 -0
  99. package/src/plans/layout.ts +182 -88
  100. package/src/plans/live.ts +5 -1
  101. package/src/plans/phase-models-read.ts +37 -0
  102. package/src/plans/phase-models.ts +70 -0
  103. package/src/plans/plan-panes.ts +8 -7
  104. package/src/plans/pull-requests.ts +78 -4
  105. package/src/plans/selection.ts +1 -40
  106. package/src/plans/settings.ts +9 -3
  107. package/src/plans/shown-with-more.ts +5 -0
  108. package/src/plans/snapshot.ts +82 -8
  109. package/src/plans/sprint-html.ts +2 -2
  110. package/src/plans/supervisor.ts +5 -4
  111. package/src/plans/symbols.ts +9 -6
  112. package/src/plans/texts.ts +30 -13
  113. package/src/plans/view.ts +1 -0
  114. package/src/sync/command.ts +16 -1
  115. package/src/sync/herdr-plugin.ts +1 -1
  116. package/src/sync/next-steps.ts +1 -1
  117. package/src/sync/render.ts +2 -1
  118. package/src/sync/setup-questions.ts +2 -2
  119. package/src/terminal/text.ts +4 -0
  120. package/src/terminal/types.ts +1 -1
  121. package/src/update/command.ts +4 -2
  122. package/src/update/update.ts +33 -9
  123. package/src/vocabulary/index.ts +39 -0
  124. package/src/watch/approvals.ts +96 -0
  125. package/src/watch/command.ts +154 -50
  126. package/src/watch/failed-tests.ts +52 -0
  127. package/src/watch/failure-read.ts +56 -0
  128. package/src/watch/failures.ts +107 -0
  129. package/src/watch/feedback-item.ts +21 -0
  130. package/src/watch/feedback-read.ts +2 -1
  131. package/src/watch/feedback.ts +37 -28
  132. package/src/watch/gh-process.ts +53 -0
  133. package/src/watch/gh.ts +10 -20
  134. package/src/watch/heartbeat-progress.ts +1 -1
  135. package/src/watch/lane-model.ts +54 -0
  136. package/src/watch/lane-parallel.ts +46 -0
  137. package/src/watch/lane-progress.ts +28 -4
  138. package/src/watch/lane-report.ts +62 -9
  139. package/src/watch/lane-sources.ts +1 -1
  140. package/src/watch/no-finding.ts +47 -0
  141. package/src/watch/plan-badges-read.ts +19 -7
  142. package/src/watch/plan-badges.ts +59 -7
  143. package/src/watch/plan-views.ts +5 -5
  144. package/src/watch/previews.ts +3 -9
  145. package/src/watch/pull-requests.ts +12 -2
  146. package/src/watch/recorded-reads.ts +25 -0
  147. package/src/watch/source-fields.ts +4 -0
  148. package/src/watch/source-tickets.ts +99 -0
  149. package/src/watch/sources.ts +62 -15
  150. package/src/watch/texts.ts +21 -1
  151. package/src/watch/watch-defaults.ts +8 -0
  152. package/src/watch/watch-events.ts +64 -4
  153. package/src/watch/watcher-lock.ts +1 -1
  154. package/src/watch/workflow-runs.ts +5 -7
  155. package/docs/lanes.mmd +0 -9
  156. package/docs/skills.mmd +0 -10
@@ -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
+ })
@@ -6,6 +6,7 @@ import { agentLog } from '../context/session-log'
6
6
  import {
7
7
  type LaneProgress,
8
8
  laneProgress,
9
+ type PlansByHref,
9
10
  readSprintDocument,
10
11
  resolvePlanPath,
11
12
  type SprintLane,
@@ -14,16 +15,20 @@ import {
14
15
  } from '../plan-format'
15
16
  import type { LaneAgent, LaneStatusSource } from '../terminal'
16
17
  import type { PlanLanguage } from '../vocabulary'
17
- import type { WatchBadges, WatchHealth } from '../watch/plan-badges'
18
+ import type { SourceTicket, WatchBadges, WatchHealth } from '../watch/plan-badges'
18
19
  import { readWatchReading } from '../watch/plan-badges-read'
19
20
  import { boardRowsOf } from './board-rows'
21
+ import { type LaneMemory, newestRowIdentity, nextMemory, withMovedPhase } from './current-phase'
20
22
  import { errorText } from './error-text'
21
23
  import { type BaseReader, baseReaderOf, gitStatusOf, refExists } from './git'
22
24
  import type { PullRequestState } from './github'
23
25
  import { type Fallbacks, laneWorkOf, readAtBase, readFallback, readPlans, textAtBase } from './lane-plans'
26
+ import { boardRowTexts, laneTicketsOf, withoutTicketLinks } from './lane-tickets'
24
27
  import { type LaneView, sortByUrgency } from './layout'
25
28
  import { extractLinkChips } from './link-chips'
26
29
  import type { Options } from './options'
30
+ import { laneModelsOf, type PhaseModelRecord } from './phase-models'
31
+ import { readPhaseModels } from './phase-models-read'
27
32
  import { currentPlanFirst, followingPlans, type PlannedPlan, plannedPlans } from './planned'
28
33
  import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from './settings'
29
34
  import { boardTexts } from './texts'
@@ -68,8 +73,13 @@ export type Snapshot = {
68
73
  files: string[]
69
74
  titles: Map<string, string | null>
70
75
  errors: string[]
71
- /** The watcher's health; absent when no lane has an open pull request, so the watcher was not looked at. */
76
+ /**
77
+ * The watcher's health; absent when no lane has an open pull request and no source is marked `ticket`, so the
78
+ * watcher was not looked at.
79
+ */
72
80
  watch?: WatchHealth
81
+ /** True when the config marks a ticket source, so a missing watcher is said without a pull request too. */
82
+ watchesTickets?: boolean
73
83
  /** The project's response language, for the words of the watcher's badges. */
74
84
  language?: PlanLanguage
75
85
  }
@@ -114,7 +124,28 @@ const runningState = async (sources: LaneSources, name: string, agent: LaneAgent
114
124
  }
115
125
  }
116
126
 
117
- const laneView = async (sources: LaneSources, lane: SprintLane, fallbacks: Fallbacks): Promise<LaneResult> => {
127
+ /**
128
+ * The progress of a lane with the phase whose boxes moved since the last read as its current one: the lane's memory
129
+ * in `memories` is updated by this read. Without `memories` the newest board row decides alone.
130
+ */
131
+ const progressWithMemory = (
132
+ shown: SprintLane,
133
+ plans: PlansByHref,
134
+ memories: Map<string, LaneMemory> | undefined,
135
+ ): LaneProgress => {
136
+ const progress = laneProgress(shown, plans)
137
+ if (!memories) return progress
138
+ const memory = nextMemory(memories.get(shown.name), plans, newestRowIdentity(progress))
139
+ memories.set(shown.name, memory)
140
+ return withMovedPhase(progress, memory.moved)
141
+ }
142
+
143
+ const laneView = async (
144
+ sources: LaneSources,
145
+ lane: SprintLane,
146
+ fallbacks: Fallbacks,
147
+ memories?: Map<string, LaneMemory>,
148
+ ): Promise<LaneResult> => {
118
149
  const { source, agents } = sources
119
150
  const { root, settings } = source
120
151
  const worktree = worktreeOf(root, settings, lane.name)
@@ -127,7 +158,7 @@ const laneView = async (sources: LaneSources, lane: SprintLane, fallbacks: Fallb
127
158
  fallbacks,
128
159
  )
129
160
  const shown = withWorkedPhases(lane, plans)
130
- const progress = laneProgress(shown, plans)
161
+ const progress = progressWithMemory(shown, plans, memories)
131
162
  const view: LaneView = {
132
163
  progress,
133
164
  ...running,
@@ -171,6 +202,23 @@ const withBadges = (view: LaneView, badges: WatchBadges): LaneView => {
171
202
  return Object.keys(own).length > 0 ? { ...view, badges: own } : view
172
203
  }
173
204
 
205
+ /** The view with the model each of its phases last ran on, only when there is one, so it otherwise stays as it was. */
206
+ const withModels = (view: LaneView, records: readonly PhaseModelRecord[], sprintDirectory: string): LaneView => {
207
+ const models = laneModelsOf(view.progress, records, sprintDirectory)
208
+ return Object.keys(models).length > 0 ? { ...view, models } : view
209
+ }
210
+
211
+ /**
212
+ * The view with the lane's tickets, found by its source lane or by its id in the board rows and plan texts of the
213
+ * lane; a link chip with the URL of a shown ticket gives way to the badge. Without tickets it stays as it was.
214
+ */
215
+ const withTickets = (result: LaneResult, tickets: readonly SourceTicket[]): LaneView => {
216
+ const { view, texts } = result
217
+ const lane = view.progress.lane
218
+ const found = laneTicketsOf(tickets, lane.name, [...boardRowTexts(lane), ...Object.values(texts)])
219
+ return found ? { ...view, chips: withoutTicketLinks(view.chips, found), tickets: found } : view
220
+ }
221
+
174
222
  /** The terminal's agents, or none with the reason when it cannot be asked, so the screen says why lanes show "no agent". */
175
223
  const readAgents = (terminal: LaneStatusSource): { agents: LaneAgent[]; errors: string[] } => {
176
224
  try {
@@ -234,7 +282,20 @@ const readListedPlans = async (
234
282
  return Object.fromEntries(entries.flat())
235
283
  }
236
284
 
237
- export const loadSnapshot = async (source: Source, pullRequests: PullRequestState): Promise<Snapshot> => {
285
+ const forgetLanes = (memories: Map<string, LaneMemory> | undefined, lanes: readonly SprintLane[]): void => {
286
+ const names = new Set(lanes.map((lane) => lane.name))
287
+ for (const name of [...(memories?.keys() ?? [])]) if (!names.has(name)) memories?.delete(name)
288
+ }
289
+
290
+ /**
291
+ * Reads the board, its plans and the lanes' state. `memories` holds, per lane, what the herd saw at the last read; it
292
+ * is how the current phase becomes the one whose boxes moved since, and it is cleaned of lanes the board dropped.
293
+ */
294
+ export const loadSnapshot = async (
295
+ source: Source,
296
+ pullRequests: PullRequestState,
297
+ memories?: Map<string, LaneMemory>,
298
+ ): Promise<Snapshot> => {
238
299
  const { root, sprintFile, options, settings, terminal } = source
239
300
  const fallbacks: Fallbacks = []
240
301
  const base = baseReaderOf(root, baseRefOf(settings))
@@ -258,9 +319,10 @@ export const loadSnapshot = async (source: Source, pullRequests: PullRequestStat
258
319
  pullRequests,
259
320
  }
260
321
  const results = [
261
- ...(await Promise.all(lanes.map((lane) => laneView(sources, lane, fallbacks)))),
322
+ ...(await Promise.all(lanes.map((lane) => laneView(sources, lane, fallbacks, memories)))),
262
323
  ...(await Promise.all(unlisted.map((lane) => unlistedView(sources, lane)))),
263
324
  ]
325
+ forgetLanes(memories, lanes)
264
326
  const listedPlans = await readListedPlans(source, base, sprintDirectory, markdown, fallbacks)
265
327
  const documentsPlans = Object.assign({}, listedPlans, ...results.map((result) => result.texts))
266
328
  const planned = options.lane
@@ -271,14 +333,25 @@ export const loadSnapshot = async (source: Source, pullRequests: PullRequestStat
271
333
  sprintDirectory,
272
334
  language: settings.planLanguage,
273
335
  })
274
- const reading = results.some(({ view }) => view.pullRequests.length > 0) ? readWatchReading(root, Date.now()) : null
336
+ const { ticketSources } = settings
337
+ const watched = ticketSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
338
+ const reading = watched ? readWatchReading(root, Date.now(), ticketSources) : null
339
+ const phaseModels = readPhaseModels(root)
275
340
  const baseRef = baseRefOf(settings)
276
341
  const notice = fallbacks.length > 0 ? fallbackNotice(fallbacks, baseRef, refExists(root, baseRef)) : null
277
342
  const unknownLaneTitle =
278
343
  options.lane && results.length === 0 ? boardTexts(settings.board.term).laneNotListed(options.lane) : null
279
344
  return {
280
345
  title: unknownLaneTitle ?? sprintTitleOf(markdown, sprintDirectory),
281
- views: sortByUrgency(results.map((result) => withBadges(result.view, reading?.badges ?? {}))),
346
+ views: sortByUrgency(
347
+ results.map((result) =>
348
+ withModels(
349
+ withBadges(withTickets(result, reading?.tickets ?? []), reading?.badges ?? {}),
350
+ phaseModels,
351
+ sprintDirectory,
352
+ ),
353
+ ),
354
+ ),
282
355
  documents: {
283
356
  sprint: markdown,
284
357
  plans: documentsPlans,
@@ -295,6 +368,7 @@ export const loadSnapshot = async (source: Source, pullRequests: PullRequestStat
295
368
  ...(source.boardNotice ? [source.boardNotice] : []),
296
369
  ],
297
370
  watch: reading?.health,
371
+ ...(ticketSources.length > 0 ? { watchesTickets: true } : {}),
298
372
  language: settings.responseLanguage,
299
373
  }
300
374
  }
@@ -15,7 +15,7 @@ import type {
15
15
  SprintPage,
16
16
  } from './sprint-page'
17
17
  import { phaseCountOf, phaseStateOf, statusSymbol, statusWord } from './symbols'
18
- import { laneMarkText, TEXTS } from './texts'
18
+ import { checksText, laneMarkText, TEXTS } from './texts'
19
19
 
20
20
  const BAR_PERCENT = 100
21
21
 
@@ -149,7 +149,7 @@ const pullRequestLine = (lane: PageLane): string =>
149
149
  lane.pullRequests
150
150
  .map(
151
151
  (pull) =>
152
- `<div class="pr">↗ ${linked(pull.url, `#${pull.number}`)} <span class="checks-${pull.checks}">${escapeHtml(TEXTS.checks[pull.checks])}</span> ${escapeHtml(pull.draft ? TEXTS.draft : '')}${escapeHtml(pull.title)}</div>`,
152
+ `<div class="pr">↗ ${linked(pull.url, `#${pull.number}`)} <span class="checks-${pull.checks}">${escapeHtml(checksText(pull))}</span> ${escapeHtml(pull.draft ? TEXTS.draft : '')}${escapeHtml(pull.title)}</div>`,
153
153
  )
154
154
  .join('')
155
155
 
@@ -4,6 +4,7 @@ import { dirname } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { pendingKitUpdate, VERSION_POLL_MS } from '../version/installed-version'
6
6
  import type { StartedKit } from '../version/started-kit'
7
+ import { HERD_COMMAND } from './herd-command'
7
8
  import { CHILD_MARKER, exitCodeOf, isRestartTrigger, nextStep, RESTART_DEBOUNCE_MS } from './restart-decision'
8
9
  import { TERMINAL_OFF } from './view'
9
10
 
@@ -13,7 +14,7 @@ const packageSourceDirectory = (): string => dirname(dirname(fileURLToPath(impor
13
14
 
14
15
  const entryScript = (): string => {
15
16
  const script = process.argv[1]
16
- if (!script) throw new Error('Cannot supervise the plans view: process.argv[1] is missing')
17
+ if (!script) throw new Error('Cannot supervise the herd: process.argv[1] is missing')
17
18
  return script
18
19
  }
19
20
 
@@ -21,7 +22,7 @@ const describeEnd = (code: number | null, signal: NodeJS.Signals | null): string
21
22
  signal ? `signal ${signal}` : `exit ${code}`
22
23
 
23
24
  const spawnView = (scriptPath: string, args: string[]): ChildProcess =>
24
- spawn(process.execPath, [scriptPath, 'plans', ...args], {
25
+ spawn(process.execPath, [scriptPath, HERD_COMMAND.name, ...args], {
25
26
  stdio: 'inherit',
26
27
  env: { ...process.env, [CHILD_MARKER]: '1' },
27
28
  })
@@ -59,7 +60,7 @@ export const superviseView = (
59
60
  const waitForChange = (reason: string): void => {
60
61
  child = undefined
61
62
  process.stdout.write(TERMINAL_OFF)
62
- console.log(`plans view stopped (${reason}); waiting for the next change to src/`)
63
+ console.log(`herd stopped (${reason}); waiting for the next change to src/`)
63
64
  }
64
65
 
65
66
  const start = (isFirst: boolean): void => {
@@ -69,7 +70,7 @@ export const superviseView = (
69
70
  current.on('error', (error) => {
70
71
  if (child !== current) return
71
72
  if (isFirst) {
72
- console.error(`plans view could not start: ${error.message}`)
73
+ console.error(`herd could not start: ${error.message}`)
73
74
  finish(1)
74
75
  return
75
76
  }
@@ -1,11 +1,11 @@
1
1
  import { AGENT_STATUS, type AgentStatus, isAgentStatus } from '../terminal/types'
2
2
 
3
- /** The symbols and words the plans views share: herdr's own status words, and the state of a phase. */
3
+ /** The symbols and words the herd panes share: herdr's own status words, and the state of a phase. Every lane state is one terminal cell; the layout colours it. */
4
4
  export const STATUS_SYMBOL: Record<AgentStatus, string> = {
5
- [AGENT_STATUS.working]: '⚡',
6
- [AGENT_STATUS.blocked]: '❓',
7
- [AGENT_STATUS.idle]: '⏸',
8
- [AGENT_STATUS.done]: '📨',
5
+ [AGENT_STATUS.working]: '●',
6
+ [AGENT_STATUS.blocked]: '◆',
7
+ [AGENT_STATUS.idle]: '○',
8
+ [AGENT_STATUS.done]: '◉',
9
9
  }
10
10
  export const NO_AGENT_SYMBOL = '·'
11
11
  export const NO_AGENT_WORD = 'no agent'
@@ -18,7 +18,7 @@ export const statusSymbol = (agent: string | null): string => {
18
18
 
19
19
  export const statusWord = (agent: string | null): string => agent ?? NO_AGENT_WORD
20
20
 
21
- /** Before a row that opens and closes: lanes, the notes of a lane, the planned phases, the ticked items of a phase. */
21
+ /** Before a row that opens and closes: lanes, the planned phases, the ticked items of a phase. */
22
22
  export const CHEVRON = { closed: '▸', open: '▾' } as const
23
23
 
24
24
  export const chevronOf = (open: boolean): string => (open ? CHEVRON.open : CHEVRON.closed)
@@ -45,6 +45,9 @@ export const phaseStateOf = ({
45
45
  /** What a phase shows in place of a symbol: `0/5`, `✓ 15/15`, `–` without checkboxes, `?` when the plan lacks it. */
46
46
  export const PHASE_COUNT = { missing: '?', empty: '–', allTicked: '✓' } as const
47
47
 
48
+ /** Beside the model a phase's subagent ran on: it fits the plan's recommendation, or it differs from it. */
49
+ export const MODEL_MARK = { fits: '✓', differs: '≠' } as const
50
+
48
51
  export const phaseCountOf = (phase: { title: string | null; done: number; total: number }): string => {
49
52
  const state = phaseStateOf(phase)
50
53
  if (state === PHASE_STATE.missing) return PHASE_COUNT.missing