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
@@ -0,0 +1,21 @@
1
+ /** The three forms feedback takes on a pull request: a review, an inline review comment, a pull request comment. */
2
+ export const FEEDBACK_FORM = { review: 'review', inline: 'inline', comment: 'comment' } as const
3
+
4
+ export type FeedbackForm = (typeof FEEDBACK_FORM)[keyof typeof FEEDBACK_FORM]
5
+
6
+ /**
7
+ * One piece of feedback as the watcher reads it; `state` is a review's (`APPROVED`, …), null for a comment. A review's
8
+ * `updatedAt` is when it was submitted: GitHub reports no later edit of a review, so an edited review is not new.
9
+ */
10
+ export type FeedbackItem = {
11
+ form: FeedbackForm
12
+ id: number
13
+ pullRequest: number
14
+ author: string
15
+ createdAt: string
16
+ updatedAt: string
17
+ state: string | null
18
+ path: string | null
19
+ line: number | null
20
+ body: string
21
+ }
@@ -1,5 +1,6 @@
1
1
  import { errorText } from '../plans/error-text'
2
- import { FEEDBACK_FORM, type FeedbackItem, type FeedbackTarget, parseFeedbackLines } from './feedback'
2
+ import { type FeedbackTarget, parseFeedbackLines } from './feedback'
3
+ import { FEEDBACK_FORM, type FeedbackItem } from './feedback-item'
3
4
  import { type GhAnswer, runGh } from './gh'
4
5
 
5
6
  export type FeedbackItemsRead = { ok: true; items: FeedbackItem[] } | { ok: false; error: string }
@@ -1,30 +1,10 @@
1
+ import { FEEDBACK_FORM, type FeedbackForm, type FeedbackItem } from './feedback-item'
1
2
  import { isRecord } from './json-guards'
3
+ import { isNoFindingComment, ROUND_WINDOW_MS } from './no-finding'
2
4
  import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
3
5
 
4
- /** The three forms feedback takes on a pull request: a review, an inline review comment, a pull request comment. */
5
- export const FEEDBACK_FORM = { review: 'review', inline: 'inline', comment: 'comment' } as const
6
-
7
- export type FeedbackForm = (typeof FEEDBACK_FORM)[keyof typeof FEEDBACK_FORM]
8
-
9
6
  export const FEEDBACK_EVENT = 'feedback'
10
7
 
11
- /**
12
- * One piece of feedback as the watcher reads it; `state` is a review's (`APPROVED`, …), null for a comment. A review's
13
- * `updatedAt` is when it was submitted: GitHub reports no later edit of a review, so an edited review is not new.
14
- */
15
- export type FeedbackItem = {
16
- form: FeedbackForm
17
- id: number
18
- pullRequest: number
19
- author: string
20
- createdAt: string
21
- updatedAt: string
22
- state: string | null
23
- path: string | null
24
- line: number | null
25
- body: string
26
- }
27
-
28
8
  /** An open pull request of a lane whose feedback is read. */
29
9
  export type FeedbackTarget = { number: number; lane: string; headCommit: string }
30
10
 
@@ -53,8 +33,13 @@ export type FeedbackRead = {
53
33
  changesRequested: number[]
54
34
  }
55
35
 
56
- /** Whose feedback does not count: `own` is the account the lanes push with, `ignore` the project's list. */
57
- export type FeedbackFilter = { own: string; ignore: readonly string[] }
36
+ /**
37
+ * What does not count as feedback: `own` is the account the lanes push with, `ignore` the project's list of authors,
38
+ * `noFindingTemplates` the texts of a bot's comment that reports no finding (`isNoFindingComment`); none when left out.
39
+ */
40
+ export type FeedbackFilter = { own: string; ignore: readonly string[]; noFindingTemplates: readonly string[] }
41
+
42
+ type FeedbackConfig = { ignore: readonly string[]; noFindingTemplates: readonly string[] }
58
43
 
59
44
  /** `place` is `<path>:<line>` for an inline comment, else null; `text` the first line of the body. */
60
45
  export type FeedbackEntry = { place: string | null; text: string }
@@ -69,8 +54,11 @@ export type FeedbackEvent = {
69
54
  entries: FeedbackEntry[]
70
55
  }
71
56
 
72
- /** How far a read reaches back before the last one: room for a clock that differs from GitHub's. */
73
- const SINCE_MARGIN_MS = 5 * 60_000
57
+ /**
58
+ * How far a read reaches back before the last one: room for a clock that differs from GitHub's, and the round of a
59
+ * bot's comment (`ROUND_WINDOW_MS`), so its inline comments are read with it.
60
+ */
61
+ const SINCE_MARGIN_MS = ROUND_WINDOW_MS
74
62
  /** The latest time a JavaScript date holds; a stored `since` past it cannot come from a read. */
75
63
  const LATEST_TIME_MS = 8.64e15
76
64
  /** GitHub's review states as the REST API spells them. */
@@ -135,6 +123,20 @@ export const parseFeedbackLines = (text: string, form: FeedbackForm, pullRequest
135
123
  return itemOf(value, form, number)
136
124
  })
137
125
 
126
+ /**
127
+ * Whose feedback does not count: the account the watcher runs as, the config's ignored logins (`feedback.ignore`) and
128
+ * the preview authors, with the texts of a bot's comment that reports no finding.
129
+ */
130
+ export const feedbackFilterOf = (
131
+ own: string,
132
+ feedback: Pick<FeedbackConfig, 'ignore' | 'noFindingTemplates'>,
133
+ previewAuthors: readonly string[],
134
+ ): FeedbackFilter => ({
135
+ own,
136
+ ignore: [...feedback.ignore, ...previewAuthors],
137
+ noFindingTemplates: feedback.noFindingTemplates,
138
+ })
139
+
138
140
  /** A review without text that only carries inline comments; those are reported on their own. */
139
141
  const isReviewShell = (item: FeedbackItem): boolean =>
140
142
  item.form === FEEDBACK_FORM.review && item.state === REVIEW_STATE.commented && item.body.trim() === ''
@@ -156,7 +158,10 @@ const requestsChanges = (items: readonly FeedbackItem[]): boolean => {
156
158
  return [...verdicts.values()].includes(REVIEW_STATE.changesRequested)
157
159
  }
158
160
 
159
- /** The read with only the targets' feedback that counts: no own answers, no ignored authors, no empty review shells. */
161
+ /**
162
+ * The read with only the targets' feedback that counts: no own answers, no ignored authors, no empty review shells,
163
+ * no bot comment that reports no finding.
164
+ */
160
165
  export const feedbackReadOf = (
161
166
  readAt: number,
162
167
  targets: FeedbackTarget[],
@@ -165,7 +170,11 @@ export const feedbackReadOf = (
165
170
  ): FeedbackRead => {
166
171
  const numbers = new Set(targets.map(({ number }) => number))
167
172
  const counted = items.filter(
168
- (item) => numbers.has(item.pullRequest) && !isFiltered(item.author, filter) && !isReviewShell(item),
173
+ (item) =>
174
+ numbers.has(item.pullRequest) &&
175
+ !isFiltered(item.author, filter) &&
176
+ !isReviewShell(item) &&
177
+ !isNoFindingComment(item, items, filter.noFindingTemplates),
169
178
  )
170
179
  const changesRequested = targets
171
180
  .map(({ number }) => number)
@@ -0,0 +1,53 @@
1
+ import { errorText } from '../plans/error-text'
2
+
3
+ /** The answer of one `gh` call: its output, or the reason it failed (exit code, stderr, gh not installed). */
4
+ export type GhAnswer = { ok: true; stdout: string } | { ok: false; error: string }
5
+
6
+ /**
7
+ * `timeoutMs` ends a call that has not finished by then; without it the call waits as long as `gh` does.
8
+ * `tailChars` keeps only that many characters from the end of the output: stdout is read as a stream and never held
9
+ * whole, so memory stays near twice `tailChars` however much `gh` prints. `executable` replaces the `gh` that is run,
10
+ * for a test that stands in for it.
11
+ */
12
+ export type GhOptions = { timeoutMs?: number; tailChars?: number; executable?: string }
13
+
14
+ const readTail = async (stream: ReadableStream<Uint8Array>, tailChars: number): Promise<string> => {
15
+ const decoder = new TextDecoder()
16
+ let tail = ''
17
+ for await (const chunk of stream) {
18
+ tail += decoder.decode(chunk, { stream: true })
19
+ if (tail.length > 2 * tailChars) tail = tail.slice(-tailChars)
20
+ }
21
+ return (tail + decoder.decode()).slice(-tailChars)
22
+ }
23
+
24
+ const readOutput = (stream: ReadableStream<Uint8Array>, tailChars: number | undefined): Promise<string> =>
25
+ tailChars === undefined ? new Response(stream).text() : readTail(stream, tailChars)
26
+
27
+ /** Runs `gh` in `cwd`; never rejects, so a caller decides what a failed call means. */
28
+ export const runGh = async (args: string[], cwd: string, options: GhOptions = {}): Promise<GhAnswer> => {
29
+ const command = `gh ${args.slice(0, 2).join(' ')}`
30
+ let timedOut = false
31
+ let timer: ReturnType<typeof setTimeout> | undefined
32
+ try {
33
+ const child = Bun.spawn([options.executable ?? 'gh', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' })
34
+ if (options.timeoutMs !== undefined)
35
+ timer = setTimeout(() => {
36
+ timedOut = true
37
+ child.kill()
38
+ }, options.timeoutMs)
39
+ const [stdout, stderr, exitCode] = await Promise.all([
40
+ readOutput(child.stdout, options.tailChars),
41
+ new Response(child.stderr).text(),
42
+ child.exited,
43
+ ])
44
+ if (exitCode === 0) return { ok: true, stdout }
45
+ if (timedOut) return { ok: false, error: `${command}: timed out after ${(options.timeoutMs ?? 0) / 1000} s` }
46
+ const reason = stderr.trim().replace(/\s*\n\s*/g, ' ') || `exit ${exitCode}`
47
+ return { ok: false, error: `${command}: ${reason}` }
48
+ } catch (error) {
49
+ return { ok: false, error: `${command}: ${errorText(error)}` }
50
+ } finally {
51
+ clearTimeout(timer)
52
+ }
53
+ }
package/src/watch/gh.ts CHANGED
@@ -1,22 +1,12 @@
1
- import { errorText } from '../plans/error-text'
1
+ import { type GhAnswer, type GhOptions, runGh as runGhProcess } from './gh-process'
2
2
 
3
- /** The answer of one `gh` call: its output, or the reason it failed (exit code, stderr, gh not installed). */
4
- export type GhAnswer = { ok: true; stdout: string } | { ok: false; error: string }
3
+ export type { GhAnswer, GhOptions }
5
4
 
6
- /** Runs `gh` in `cwd`; never rejects, so a caller decides what a failed call means. */
7
- export const runGh = async (args: string[], cwd: string): Promise<GhAnswer> => {
8
- const command = `gh ${args.slice(0, 2).join(' ')}`
9
- try {
10
- const child = Bun.spawn(['gh', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' })
11
- const [stdout, stderr, exitCode] = await Promise.all([
12
- new Response(child.stdout).text(),
13
- new Response(child.stderr).text(),
14
- child.exited,
15
- ])
16
- if (exitCode === 0) return { ok: true, stdout }
17
- const reason = stderr.trim().replace(/\s*\n\s*/g, ' ') || `exit ${exitCode}`
18
- return { ok: false, error: `${command}: ${reason}` }
19
- } catch (error) {
20
- return { ok: false, error: `${command}: ${errorText(error)}` }
21
- }
22
- }
5
+ /**
6
+ * The seam the watcher's readers call `gh` through, and the module their tests replace with `mock.module('./gh')`.
7
+ * Bun keeps such a mock for the rest of the process and rewrites the exports of a module already loaded, a re-export
8
+ * included, so `runGh` here is a binding of its own and the real runner in `gh-process.ts` stays reachable for its
9
+ * test whatever file ran before.
10
+ */
11
+ export const runGh = (args: string[], cwd: string, options?: GhOptions): Promise<GhAnswer> =>
12
+ runGhProcess(args, cwd, options)
@@ -2,7 +2,7 @@ import { currentPhase, type LaneProgress, PHASE_ID_PREFIX, type PhaseProgress }
2
2
  import { AGENT_STATUS } from '../terminal/types'
3
3
  import type { LaneSnapshot } from './lane-watch'
4
4
 
5
- /** The current phase of a lane and how many of its checkboxes are ticked, as the plans view counts them. */
5
+ /** The current phase of a lane and how many of its checkboxes are ticked, as the herd counts them. */
6
6
  export type PhaseCount = Pick<PhaseProgress, 'id' | 'done' | 'total'>
7
7
 
8
8
  /** What a heartbeat adds to a working lane, by lane name: `P12 7/12`, without the middle dot of `shortTitle`, which the heartbeat's separator rewrite would turn into a slash. */
@@ -0,0 +1,54 @@
1
+ import { differsFromColumn } from '../plan-format'
2
+ import { isRecord } from './json-guards'
3
+
4
+ export const LANE_MODEL_EVENT = 'model'
5
+
6
+ /** Lane → the differing model last reported for its agent; a lane without an entry runs a fitting model or is unread. */
7
+ export type LaneModels = Record<string, string>
8
+
9
+ /**
10
+ * The model of a lane's last answer (null without one), whether its agent is idle (`done` or `idle`: neither working
11
+ * nor waiting for an answer, so it can be switched), and its board row's model column.
12
+ */
13
+ export type LaneModelReading = { model: string | null; idle: boolean; column: string }
14
+
15
+ export type LaneModelEvent = { kind: typeof LANE_MODEL_EVENT; lane: string; model: string; column: string }
16
+
17
+ const differs = (reading: LaneModelReading): reading is LaneModelReading & { model: string } =>
18
+ reading.model !== null && differsFromColumn(reading.column, reading.model)
19
+
20
+ /**
21
+ * One event per idle lane whose model fits none of its column and differs from the one last reported: once per
22
+ * deviation, again only after the model changed. A lane that is not idle waits, since a lane is switched only while idle.
23
+ */
24
+ export const laneModelEvents = (previous: LaneModels, readings: Record<string, LaneModelReading>): LaneModelEvent[] =>
25
+ Object.entries(readings).flatMap(([lane, reading]) => {
26
+ return differs(reading) && reading.idle && reading.model !== previous[lane]
27
+ ? [{ kind: LANE_MODEL_EVENT, lane, model: reading.model, column: reading.column }]
28
+ : []
29
+ })
30
+
31
+ const recordedModel = (reported: string | undefined, reading: LaneModelReading | undefined): string | undefined => {
32
+ if (!reading || reading.model === null) return reported
33
+ if (!differs(reading)) return undefined
34
+ return reading.idle ? reading.model : reported
35
+ }
36
+
37
+ /**
38
+ * The models after a read: an idle lane keeps the differing model it reported, a fitting model clears the entry,
39
+ * a lane that is not idle and one without a reading keep what they had; a lane no longer watched is dropped.
40
+ */
41
+ export const recordLaneModels = (
42
+ previous: LaneModels,
43
+ readings: Record<string, LaneModelReading>,
44
+ lanes: readonly string[],
45
+ ): LaneModels =>
46
+ Object.fromEntries(
47
+ lanes.flatMap((lane) => {
48
+ const model = recordedModel(previous[lane], readings[lane])
49
+ return model === undefined ? [] : [[lane, model]]
50
+ }),
51
+ )
52
+
53
+ export const isLaneModels = (value: unknown): value is LaneModels =>
54
+ isRecord(value) && Object.values(value).every((model) => typeof model === 'string')
@@ -0,0 +1,46 @@
1
+ import { isRecord } from './json-guards'
2
+
3
+ export const LANE_PARALLEL_EVENT = 'parallel'
4
+
5
+ /** Lane → the phases last reported as able to run together; a lane without an entry has none to report. */
6
+ export type LaneParallels = Record<string, string[]>
7
+
8
+ export type LaneParallelEvent = { kind: typeof LANE_PARALLEL_EVENT; lane: string; phases: string[] }
9
+
10
+ const MIN_PHASES = 2
11
+
12
+ /** The first group of `parallelGroups` with at least two phases, which is what the watcher reports; empty without one. */
13
+ export const parallelBatchOf = (groups: readonly string[][]): string[] =>
14
+ groups.find((group) => group.length >= MIN_PHASES) ?? []
15
+
16
+ const sameAs = (left: readonly string[] | undefined, right: readonly string[]): boolean =>
17
+ left !== undefined && left.length === right.length && left.every((id, index) => id === right[index])
18
+
19
+ /**
20
+ * One event per lane whose plan has two phases or more that can run together and that differ from the ones last
21
+ * reported: once per set, again only after the set changed or after a read without any.
22
+ */
23
+ export const laneParallelEvents = (previous: LaneParallels, readings: Record<string, string[]>): LaneParallelEvent[] =>
24
+ Object.entries(readings).flatMap(([lane, phases]) =>
25
+ phases.length >= MIN_PHASES && !sameAs(previous[lane], phases) ? [{ kind: LANE_PARALLEL_EVENT, lane, phases }] : [],
26
+ )
27
+
28
+ /**
29
+ * The phases after a read: a lane with two phases or more keeps them, a shorter reading clears the entry, a lane
30
+ * without a reading keeps what it had; a lane no longer watched is dropped.
31
+ */
32
+ export const recordLaneParallels = (
33
+ previous: LaneParallels,
34
+ readings: Record<string, string[]>,
35
+ lanes: readonly string[],
36
+ ): LaneParallels =>
37
+ Object.fromEntries(
38
+ lanes.flatMap((lane) => {
39
+ const phases = readings[lane] ?? previous[lane]
40
+ return phases !== undefined && phases.length >= MIN_PHASES ? [[lane, phases]] : []
41
+ }),
42
+ )
43
+
44
+ export const isLaneParallels = (value: unknown): value is LaneParallels =>
45
+ isRecord(value) &&
46
+ Object.values(value).every((phases) => Array.isArray(phases) && phases.every((id) => typeof id === 'string'))
@@ -1,13 +1,15 @@
1
- import { dirname } from 'node:path'
2
- import type { LaneProgress, SprintLane } from '../plan-format'
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { type LaneProgress, parallelGroupsOfPlan, resolvePlanPath, type SprintLane } from '../plan-format'
3
4
  import { boardRowsOf } from '../plans/board-rows'
4
5
  import { baseReaderOf, gitStatusOf } from '../plans/git'
5
6
  import { laneWorkOf, readPlans, textAtBase } from '../plans/lane-plans'
6
7
  import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from '../plans/settings'
7
8
  import { workedLaneProgress } from '../plans/worked-phases'
9
+ import { parallelBatchOf } from './lane-parallel'
8
10
 
9
11
  /**
10
- * The progress the watcher reports for a lane, from the same reading as the plans view: the base ref's plans, the
12
+ * The progress the watcher reports for a lane, from the same reading as the herd: the base ref's plans, the
11
13
  * phases the lane works on from its worktree (also without a board row), so both name the same open phase.
12
14
  */
13
15
  export const watchedLaneProgress = async (
@@ -30,7 +32,29 @@ export const watchedLaneProgress = async (
30
32
  }
31
33
 
32
34
  /**
33
- * The rows the checkout's board file (`checkoutText`) has beyond the board at the base ref, counted as the plans view
35
+ * The phases of a lane's plan (the plan of its newest row, as the lane's worktree has it, else as the base ref has
36
+ * it) that can run together, by the plan's dependency table and ticks; empty without a plan, without a table or
37
+ * without two such phases.
38
+ */
39
+ export const watchedLaneParallel = (
40
+ root: string,
41
+ settings: PlansSettings,
42
+ boardFile: string,
43
+ lane: SprintLane,
44
+ ): string[] => {
45
+ const href = lane.steps.filter((step) => step.planHref !== null).at(-1)?.planHref
46
+ if (!href) return []
47
+ const path = resolvePlanPath(dirname(boardRepoPathOf(root, settings, boardFile)), href)
48
+ const worktreeFile = join(worktreeOf(root, settings, lane.name), path)
49
+ const text = existsSync(worktreeFile)
50
+ ? readFileSync(worktreeFile, 'utf8')
51
+ : textAtBase(baseReaderOf(root, baseRefOf(settings)), path)
52
+ const groups = text === null ? null : parallelGroupsOfPlan(text, settings.planLanguage)
53
+ return groups === null ? [] : parallelBatchOf(groups)
54
+ }
55
+
56
+ /**
57
+ * The rows the checkout's board file (`checkoutText`) has beyond the board at the base ref, counted as the herd
34
58
  * adds them; 0 when the base ref lacks the board, which is then read from the checkout as a whole.
35
59
  */
36
60
  export const boardCheckoutRows = (
@@ -1,10 +1,14 @@
1
1
  import { CONTEXT_LEVEL, type ContextLevel, contextLabel } from '../context'
2
- import { currentPhase, isTickedUnmerged, type LaneProgress } from '../plan-format'
2
+ import { currentPhase, isTickedUnmerged, type LaneProgress, phasesText } from '../plan-format'
3
3
  import { terminalSafe } from '../plan-format/terminal-text'
4
- import type { ChecksState } from '../plans/pull-requests'
4
+ import { CHECK_NAME_LENGTH, type ChecksState } from '../plans/pull-requests'
5
+ import { shownWithMore } from '../plans/shown-with-more'
6
+ import type { ApprovalEvent } from './approvals'
5
7
  import type { CoordinatorRead } from './coordinator-fill'
6
8
  import { type FeedbackEvent, firstLine } from './feedback'
7
9
  import type { LaneFillEvent } from './lane-fill'
10
+ import type { LaneModelEvent } from './lane-model'
11
+ import type { LaneParallelEvent } from './lane-parallel'
8
12
  import { AGENT_STATUS, type LaneSnapshot, NO_AGENT } from './lane-watch'
9
13
  import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
10
14
  import { SOURCE_FIELD } from './source-fields'
@@ -44,6 +48,8 @@ const REPORT_INDENT = ' '
44
48
  const SHORT_SHA_LENGTH = 7
45
49
  const NO_REMINDER: BoardReminder = { term: '', checkoutRows: 0 }
46
50
  const OTHER_PULL_REQUEST_LIMIT = 5
51
+ const FAILED_TEST_LENGTH = 100
52
+ const FAILED_TESTS_IN_LINE = 5
47
53
 
48
54
  const pullRequestSegment = (pullRequest: LaneReport['pullRequest'], texts: WatchTexts): string => {
49
55
  if (!pullRequest) return texts.noPullRequest
@@ -90,6 +96,18 @@ const deployOwner = (event: DeployEvent): string => {
90
96
  return event.pullRequest === undefined ? 'manual' : `#${event.pullRequest}`
91
97
  }
92
98
 
99
+ /** `: test, lint (3 tests: a, b, c +1)` after a red pull request's header; empty without a failed check's name. */
100
+ const failedChecksSuffix = (event: PullRequestEvent): string => {
101
+ if (event.kind !== WATCH_EVENT.checksFailed || !event.failedChecks?.length) return ''
102
+ const checks = event.failedChecks.map((name) => terminalSafe(name).slice(0, CHECK_NAME_LENGTH)).join(', ')
103
+ const tests = event.failedTests ?? []
104
+ if (tests.length === 0) return `: ${checks}`
105
+ const { shown, more } = shownWithMore(tests, FAILED_TESTS_IN_LINE)
106
+ const names = shown.map((name) => terminalSafe(name).slice(0, FAILED_TEST_LENGTH)).join(', ')
107
+ const count = `${tests.length} ${tests.length === 1 ? 'test' : 'tests'}`
108
+ return `: ${checks} (${count}: ${names}${more > 0 ? ` +${more}` : ''})`
109
+ }
110
+
93
111
  /**
94
112
  * `other PRs: #<n> passed · #<m> none`, in the words of `texts`: the open pull requests among `own` (the user's own without a
95
113
  * lane), newest first, at most `OTHER_PULL_REQUEST_LIMIT` and `+<k>` for the rest; null when there is none.
@@ -103,13 +121,14 @@ export const otherPullRequestsLine = (
103
121
  .filter(({ number, state }) => state === PULL_REQUEST_STATE.open && own.includes(number))
104
122
  .sort((left, right) => right.number - left.number)
105
123
  if (open.length === 0) return null
106
- const shown = open.slice(0, OTHER_PULL_REQUEST_LIMIT).map(({ number, checks }) => `#${number} ${checks}`)
107
- const more = open.length - OTHER_PULL_REQUEST_LIMIT
108
- return `${texts.otherPullRequests}: ${[...shown, ...(more > 0 ? [`+${more}`] : [])].join(' · ')}`
124
+ const { shown, more } = shownWithMore(open, OTHER_PULL_REQUEST_LIMIT)
125
+ const named = shown.map(({ number, checks }) => `#${number} ${checks}`)
126
+ return `${texts.otherPullRequests}: ${[...named, ...(more > 0 ? [`+${more}`] : [])].join(' · ')}`
109
127
  }
110
128
 
111
129
  /**
112
- * The header line of one event: `=== merged #<number> (lane)`, `=== checks failed #<number> (lane|branch <name>)`,
130
+ * The header line of one event: `=== merged #<number> (lane)`, `=== checks failed #<number> (lane|branch <name>): <checks> (<k> tests: <names>)`,
131
+ * the checks and tests only when the event carries them,
113
132
  * `=== deploy success run 9 (push, lane|#<number>) target stage`, `=== heartbeat`, `=== gh unavailable: <reason>`,
114
133
  * `=== coordinator warn 62 %`, `=== lane <name> alert 47 %`, `=== preview #<number> (lane) <url> · <sha7>`.
115
134
  * `target` is the deploy environment read from the run, null when none was found.
@@ -126,8 +145,15 @@ export const watchEventLine = (event: WatchEvent, target: string | null): string
126
145
  return `=== ${event.kind} #${event.number} (${terminalSafe(event.lane)}) ${terminalSafe(event.url)} · ${terminalSafe(event.headCommit.slice(0, SHORT_SHA_LENGTH))}`
127
146
  if (event.kind === WATCH_EVENT.laneFill)
128
147
  return `=== ${event.kind} ${terminalSafe(event.lane)} ${event.level} ${contextLabel(event.share)}`
148
+ if (event.kind === WATCH_EVENT.laneModel)
149
+ return `=== ${event.kind} ${terminalSafe(event.lane)} ${terminalSafe(event.model)} · board ${terminalSafe(event.column)}`
150
+ if (event.kind === WATCH_EVENT.laneParallel)
151
+ return `=== ${event.kind} ${terminalSafe(event.lane)} ${phasesText(event.phases)}`
152
+ if (event.kind === WATCH_EVENT.approval)
153
+ return `=== ${event.kind} ${terminalSafe(event.lane)} ${event.id} ${terminalSafe(event.tool)} · ${terminalSafe(event.summary)}`
129
154
  if (event.kind === WATCH_EVENT.coordinator) return `=== ${event.kind} ${event.level} ${contextLabel(event.share)}`
130
- if (isPullRequestEvent(event)) return `=== ${event.kind} #${event.number} (${pullRequestOwner(event)})`
155
+ if (isPullRequestEvent(event))
156
+ return `=== ${event.kind} #${event.number} (${pullRequestOwner(event)})${failedChecksSuffix(event)}`
131
157
  if ('number' in event) return `=== ${event.kind} #${event.number} (${terminalSafe(event.lane)})`
132
158
  return `=== ${event.kind}`
133
159
  }
@@ -138,11 +164,11 @@ export const watchEventLine = (event: WatchEvent, target: string | null): string
138
164
  */
139
165
  export const feedbackEventLines = (event: FeedbackEvent): string[] => {
140
166
  const header = `=== ${event.kind} #${event.number} (${terminalSafe(event.lane)}) ${terminalSafe(event.author)} ${terminalSafe(event.label)} · ${event.entries.length} new`
141
- const entries = event.entries.slice(0, FEEDBACK_ENTRY_LIMIT).map(({ place, text }) => {
167
+ const { shown: shownEntries, more } = shownWithMore(event.entries, FEEDBACK_ENTRY_LIMIT)
168
+ const entries = shownEntries.map(({ place, text }) => {
142
169
  const line = terminalSafe(place === null ? text : `${place} ${text}`).slice(0, REPORT_TEXT_LENGTH)
143
170
  return `${REPORT_INDENT}${line}`
144
171
  })
145
- const more = event.entries.length - FEEDBACK_ENTRY_LIMIT
146
172
  return [header, ...entries, ...(more > 0 ? [`${REPORT_INDENT}… ${more} more`] : [])]
147
173
  }
148
174
 
@@ -246,6 +272,33 @@ export const laneFillDutyLines = (events: readonly WatchEvent[], texts: WatchTex
246
272
  .filter(isLaneAlert)
247
273
  .map(({ lane }) => `${terminalSafe(lane)} context ${CONTEXT_LEVEL.alert}: ${texts.laneAlertDuty}`)
248
274
 
275
+ const isLaneModel = (event: WatchEvent): event is LaneModelEvent => event.kind === WATCH_EVENT.laneModel
276
+
277
+ /** What a lane whose model fits none of its model column asks of the coordinator (`orchestrate` § 2): one line per such lane. */
278
+ export const laneModelDutyLines = (events: readonly WatchEvent[], texts: WatchTexts): string[] =>
279
+ events
280
+ .filter(isLaneModel)
281
+ .map(({ lane, model }) => `${terminalSafe(lane)} model ${terminalSafe(model)}: ${texts.laneModelDuty}`)
282
+
283
+ const isLaneParallel = (event: WatchEvent): event is LaneParallelEvent => event.kind === WATCH_EVENT.laneParallel
284
+
285
+ /** What phases of one lane that can run together ask of the coordinator (`orchestrate` § 4): one line per such lane. */
286
+ export const parallelDutyLines = (events: readonly WatchEvent[], texts: WatchTexts): string[] =>
287
+ events
288
+ .filter(isLaneParallel)
289
+ .map(({ lane, phases }) => `${terminalSafe(lane)} parallel: ${texts.parallelDuty(phasesText(phases))}`)
290
+
291
+ const isApproval = (event: WatchEvent): event is ApprovalEvent => event.kind === WATCH_EVENT.approval
292
+
293
+ /**
294
+ * What a lane's call waiting for approval asks of the coordinator (`orchestrate` § 4): one line per request, naming the
295
+ * mark the question carries and the two labels the hook matches.
296
+ */
297
+ export const approvalDutyLines = (events: readonly WatchEvent[], texts: WatchTexts): string[] =>
298
+ events
299
+ .filter(isApproval)
300
+ .map(({ lane, id, hash }) => `${terminalSafe(lane)} approval ${id}: ${texts.approvalDuty(id, hash)}`)
301
+
249
302
  /**
250
303
  * What a blocked lane asks of the coordinator (`orchestrate` § 4), one line per stopped lane that
251
304
  * sits in `blocked`: vet the question, answer it in the lane or ask the user, never hand the pane
@@ -33,7 +33,7 @@ export const branchOf = (worktree: string): string | null =>
33
33
 
34
34
  /**
35
35
  * Fetches the base branch when the remote moved past `origin/<branch>`, so every reader of that ref
36
- * (the plans view watches `FETCH_HEAD`) sees a merge as soon as the watcher does. Asks the remote
36
+ * (the herd watches `FETCH_HEAD`) sees a merge as soon as the watcher does. Asks the remote
37
37
  * first, so an unchanged base costs no fetch; a failure leaves the ref as it is.
38
38
  */
39
39
  export const fetchBaseWhenMoved = async (root: string, branch: string): Promise<void> => {
@@ -0,0 +1,47 @@
1
+ import { FEEDBACK_FORM, type FeedbackItem } from './feedback-item'
2
+
3
+ /**
4
+ * How far apart a bot's comment and its inline comments may be and still belong to one round: a review posts its
5
+ * inline comments and its summary within moments, and a read reaches back by the same margin.
6
+ */
7
+ export const ROUND_WINDOW_MS = 5 * 60_000
8
+
9
+ const BOT_SUFFIX = '[bot]'
10
+
11
+ /** GitHub names the login of every app account with the suffix `[bot]`; logins ignore case. */
12
+ export const isBotLogin = (login: string): boolean => login.toLowerCase().endsWith(BOT_SUFFIX)
13
+
14
+ /** Trimmed, every run of whitespace (line breaks included) as one space. */
15
+ export const normalisedText = (text: string): string => text.trim().replace(/\s+/g, ' ')
16
+
17
+ const isInRound = (comment: FeedbackItem, other: FeedbackItem): boolean => {
18
+ const distance = Math.abs(Date.parse(comment.createdAt) - Date.parse(other.createdAt))
19
+ return distance <= ROUND_WINDOW_MS
20
+ }
21
+
22
+ const hasInlineInRound = (comment: FeedbackItem, items: readonly FeedbackItem[]): boolean =>
23
+ items.some(
24
+ (other) =>
25
+ other.form === FEEDBACK_FORM.inline &&
26
+ other.pullRequest === comment.pullRequest &&
27
+ other.author.toLowerCase() === comment.author.toLowerCase() &&
28
+ isInRound(comment, other),
29
+ )
30
+
31
+ /**
32
+ * True for a pull request comment of a bot that reports no finding: its text equals one of `templates` once both are
33
+ * normalised, and the same author left no inline comment on the pull request in the same round, which is the time
34
+ * window `ROUND_WINDOW_MS` around the comment's creation. A comment whose time cannot be read is never one: the guess
35
+ * would drop feedback. `items` are all the feedback read, the inline comments among them.
36
+ */
37
+ export const isNoFindingComment = (
38
+ comment: FeedbackItem,
39
+ items: readonly FeedbackItem[],
40
+ templates: readonly string[],
41
+ ): boolean => {
42
+ if (comment.form !== FEEDBACK_FORM.comment || !isBotLogin(comment.author)) return false
43
+ if (Number.isNaN(Date.parse(comment.createdAt))) return false
44
+ const text = normalisedText(comment.body)
45
+ const matches = text !== '' && templates.some((template) => normalisedText(template) === text)
46
+ return matches && !hasInlineInRound(comment, items)
47
+ }
@@ -1,6 +1,13 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { WATCH_HEALTH, type WatchReading, watchBadgesOf, watchHealthOf } from './plan-badges'
3
+ import {
4
+ sourceTicketsOf,
5
+ type TicketSource,
6
+ WATCH_HEALTH,
7
+ type WatchReading,
8
+ watchBadgesOf,
9
+ watchHealthOf,
10
+ } from './plan-badges'
4
11
  import { parseWatchState, type WatchState } from './watch-events'
5
12
  import { type CommandLineOf, isWatcherRunning, WATCHER_FILES, watcherDirectoryOf } from './watcher-lock'
6
13
 
@@ -13,11 +20,16 @@ const readState = (file: string): WatchState | null => {
13
20
  }
14
21
 
15
22
  /**
16
- * What the watcher of the repository at `root` gives the plans view, read from its state file without a GitHub call:
17
- * its badges, and its health, so the view can say why there are none. A missing or damaged file shows no badges; the
18
- * view goes on without them.
23
+ * What the watcher of the repository at `root` gives the herd, read from its state file without a GitHub call:
24
+ * its badges, the tickets of `ticketSources`, and its health, so the view can say why there are none. A missing or
25
+ * damaged file shows no badges; the view goes on without them.
19
26
  */
20
- export const readWatchReading = (root: string, now: number, lookUp?: CommandLineOf): WatchReading => {
27
+ export const readWatchReading = (
28
+ root: string,
29
+ now: number,
30
+ ticketSources: readonly TicketSource[],
31
+ lookUp?: CommandLineOf,
32
+ ): WatchReading => {
21
33
  const directory = watcherDirectoryOf(root)
22
34
  const state = readState(join(directory, WATCHER_FILES.state))
23
35
  const health = watchHealthOf(
@@ -25,6 +37,6 @@ export const readWatchReading = (root: string, now: number, lookUp?: CommandLine
25
37
  isWatcherRunning(join(directory, WATCHER_FILES.lock), lookUp),
26
38
  now,
27
39
  )
28
- const badges = state && health.kind === WATCH_HEALTH.live ? watchBadgesOf(state, now) : {}
29
- return { badges, health }
40
+ if (!state || health.kind !== WATCH_HEALTH.live) return { badges: {}, tickets: [], health }
41
+ return { badges: watchBadgesOf(state, now), tickets: sourceTicketsOf(state, ticketSources, now), health }
30
42
  }