jackherd 1.18.2 → 1.19.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 (75) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +30 -25
  4. package/assets/icons/model.svg +1 -0
  5. package/dist/{chunk-N5FAOQZL.js → chunk-JDO6O3UO.js} +52 -2
  6. package/dist/config.d.ts +15 -2
  7. package/dist/config.js +22 -3
  8. package/dist/plan-format.d.ts +84 -1
  9. package/dist/plan-format.js +85 -1
  10. package/docs/plan-contract.md +8 -2
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/hooks/approval-gate/decision.ts +10 -11
  14. package/kit/hooks/hooks/approval-gate/run.ts +23 -3
  15. package/kit/hooks/hooks/browser-guard/decision.ts +7 -0
  16. package/kit/hooks/hooks/browser-guard/index.ts +50 -0
  17. package/kit/hooks/hooks/plain-question/arguments.ts +14 -1
  18. package/kit/hooks/hooks/plain-question/card.ts +102 -0
  19. package/kit/hooks/hooks/plain-question/form.ts +25 -9
  20. package/kit/hooks/hooks/plain-question/index.ts +20 -25
  21. package/kit/hooks/hooks/plain-question/recommendation.ts +6 -6
  22. package/kit/hooks/hooks/return-brief/decision.ts +29 -0
  23. package/kit/hooks/hooks/return-brief/index.ts +37 -0
  24. package/kit/hooks/hooks/return-brief/settings.ts +11 -0
  25. package/kit/hooks/hooks/shared/coordinator-call.ts +25 -0
  26. package/kit/hooks/hooks/shared/transcript.ts +9 -0
  27. package/kit/hooks/hooks.json +18 -1
  28. package/kit/review/agents/security-audit.md +1 -1
  29. package/kit/workflow/agents/browser-check.md +29 -0
  30. package/kit/workflow/skills/implement/SKILL.md +5 -3
  31. package/kit/workflow/skills/orchestrate/SKILL.md +36 -14
  32. package/kit/workflow/skills/orchestrate/references/acceptance.md +10 -9
  33. package/kit/workflow/skills/orchestrate/references/coordinator-clear.md +45 -0
  34. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +15 -12
  35. package/kit/workflow/skills/plan/SKILL.md +2 -0
  36. package/kit/workflow/skills/plan/references/template-English.md +8 -0
  37. package/kit/workflow/skills/plan/references/template-German.md +8 -0
  38. package/package.json +1 -1
  39. package/schemas/jackherd.schema.json +16 -0
  40. package/src/brief/command.ts +45 -0
  41. package/src/brief/lane-brief.ts +51 -0
  42. package/src/brief/lane-facts.ts +64 -0
  43. package/src/check/skill-budget-defaults.ts +13 -2
  44. package/src/cli.ts +1 -0
  45. package/src/config/schema.ts +22 -0
  46. package/src/lane-prompt/texts.ts +2 -2
  47. package/src/plan-contract/command.ts +1 -0
  48. package/src/plan-contract/contract.ts +28 -5
  49. package/src/plan-format/index.ts +1 -0
  50. package/src/plan-format/plan-head.ts +131 -0
  51. package/src/plans/active-tasks.ts +8 -1
  52. package/src/plans/click-hint.ts +2 -1
  53. package/src/plans/focus-overlay.ts +5 -2
  54. package/src/plans/folded-detail.ts +47 -0
  55. package/src/plans/lane-line.ts +86 -0
  56. package/src/plans/lane-reason.ts +24 -4
  57. package/src/plans/layout-phase.ts +4 -20
  58. package/src/plans/layout-summary.ts +56 -45
  59. package/src/plans/layout.ts +178 -44
  60. package/src/plans/port-listening.ts +25 -0
  61. package/src/plans/selection.ts +11 -0
  62. package/src/plans/snapshot.ts +27 -9
  63. package/src/plans/task-checklist.ts +57 -0
  64. package/src/plans/texts.ts +34 -9
  65. package/src/resume/command.ts +40 -6
  66. package/src/resume/coordinator-read.ts +136 -0
  67. package/src/resume/state.ts +163 -0
  68. package/src/resume/texts.ts +6 -0
  69. package/src/sync/render.ts +5 -1
  70. package/src/vocabulary/index.ts +74 -0
  71. package/src/watch/clear-moment.ts +228 -0
  72. package/src/watch/command.ts +92 -8
  73. package/src/watch/lane-report.ts +5 -1
  74. package/src/watch/reference-paths.ts +8 -4
  75. package/src/watch/watch-events.ts +13 -0
@@ -0,0 +1,228 @@
1
+ import { MS_PER_MINUTE } from '../plan-format/clock'
2
+ import { firstTableWithHeader, sectionLines } from '../plan-format/markdown'
3
+ import { isRecord } from '../shared/is-record'
4
+ import { AGENT_STATUS, isRestingAgent } from '../terminal/types'
5
+ import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
6
+ import { coordinatorTargetOf } from './coordinator-fill'
7
+ import { type HerdrAgent, NO_AGENT } from './lane-watch'
8
+ import { questionCount, questionSections } from './report-questions'
9
+
10
+ export const COORDINATOR_CLEAR_EVENT = 'coordinator clear'
11
+
12
+ /** The word of a merge in the report; `watch-events.ts` imports this module, so its constant cannot be read here. */
13
+ const MERGED_EVENT = 'merged'
14
+
15
+ export type CoordinatorClearEvent = { kind: typeof COORDINATOR_CLEAR_EVENT; tokens: number }
16
+
17
+ /** The fills at which the coordinator clears: `plans.context.coordinator`. */
18
+ type ClearThresholds = { clearTokens: number; clearAfterMergeTokens: number }
19
+
20
+ type ClearLane = {
21
+ name: string
22
+ status: string
23
+ /** The questions of its report not yet shown to the coordinator (`unshownQuestionCount`). */
24
+ questions: number
25
+ }
26
+
27
+ /** Everything a moment to clear depends on; `tokens` is null without a value, `coordinatorStatus` when it is not listed. */
28
+ export type ClearInput = {
29
+ tokens: number | null
30
+ thresholds: ClearThresholds
31
+ coordinatorStatus: string | null
32
+ /** The coordinator has something to read: a report goes out in this poll or just went out. */
33
+ reportPending: boolean
34
+ lanes: readonly ClearLane[]
35
+ /** The ids of the approval requests a lane still waits on. */
36
+ openApprovals: readonly string[]
37
+ /** The open pull requests the user approved with self-merge. */
38
+ selfMergePullRequests: readonly number[]
39
+ /** When the last merge not yet taken as a moment to clear was seen; null without one. */
40
+ mergedAt: number | null
41
+ /** The change time of the board; null when it cannot be read. */
42
+ boardWrittenAt: number | null
43
+ }
44
+
45
+ type ClearVerdict = { clear: true } | { clear: false; reason: string }
46
+
47
+ /** What one poll saw besides the fill: the lanes it reports as stopped, its events and when the last report went out. */
48
+ type PollNews = {
49
+ stopped: readonly string[]
50
+ events: readonly { kind: string }[]
51
+ quietSince: number
52
+ now: number
53
+ /** How long a report just printed counts as unread: the coordinator's status may still say it rests. */
54
+ settleMs: number
55
+ }
56
+
57
+ /** What the watcher remembers: the fill above `clearTokens` was reported, and the merge still to be taken as a moment. */
58
+ export type ClearState = { fillReported: boolean; mergedAt: number | null }
59
+
60
+ export const emptyClearState = (): ClearState => ({ fillReported: false, mergedAt: null })
61
+
62
+ export const isClearState = (value: unknown): value is ClearState =>
63
+ isRecord(value) &&
64
+ typeof value.fillReported === 'boolean' &&
65
+ (value.mergedAt === null || typeof value.mergedAt === 'number')
66
+
67
+ /** A merge is a moment once the fill reached its threshold and the board was written after it. */
68
+ const isMergeMoment = ({ tokens, thresholds, mergedAt, boardWrittenAt }: ClearInput): boolean =>
69
+ tokens !== null &&
70
+ tokens >= thresholds.clearAfterMergeTokens &&
71
+ mergedAt !== null &&
72
+ boardWrittenAt !== null &&
73
+ boardWrittenAt > mergedAt
74
+
75
+ const coordinatorRefusal = ({ coordinatorStatus, reportPending }: ClearInput): string | null => {
76
+ if (coordinatorStatus === null) return 'coordinator not listed'
77
+ if (coordinatorStatus === AGENT_STATUS.blocked) return 'dialog open'
78
+ if (!isRestingAgent(coordinatorStatus)) return `coordinator ${coordinatorStatus}`
79
+ return reportPending ? 'report pending' : null
80
+ }
81
+
82
+ const laneRefusal = ({ name, status, questions }: ClearLane): string | null => {
83
+ if (status === AGENT_STATUS.blocked) return `lane ${name} blocked`
84
+ return questions > 0 ? `lane ${name} has ${questions} unshown questions` : null
85
+ }
86
+
87
+ /** Why what is open forbids clearing now; null when nothing is. */
88
+ const openWorkRefusal = (input: ClearInput): string | null => {
89
+ const lane = input.lanes.map(laneRefusal).find((refusal) => refusal !== null)
90
+ if (lane) return lane
91
+ const [approval] = input.openApprovals
92
+ if (approval !== undefined) return `approval ${approval} open`
93
+ const [pullRequest] = input.selfMergePullRequests
94
+ return pullRequest === undefined ? null : `self-merge #${pullRequest} open`
95
+ }
96
+
97
+ /**
98
+ * Whether the coordinator may clear its context now: its fill reached `clearTokens`, or `clearAfterMergeTokens` after
99
+ * a merge with the board written afterwards, and nothing lives only in its chat: no dialog, nothing unread, no blocked
100
+ * lane, no question not yet shown, no approval a lane waits on, no open pull request with self-merge.
101
+ */
102
+ export const clearMoment = (input: ClearInput): ClearVerdict => {
103
+ if (input.tokens === null) return { clear: false, reason: 'no value' }
104
+ if (input.tokens < input.thresholds.clearTokens && !isMergeMoment(input))
105
+ return { clear: false, reason: 'below threshold' }
106
+ const reason = coordinatorRefusal(input) ?? openWorkRefusal(input)
107
+ return reason === null ? { clear: true } : { clear: false, reason }
108
+ }
109
+
110
+ /**
111
+ * One poll's step: the event when the moment is quiet and not yet reported, once per rise above `clearTokens` and once
112
+ * per merge, and the state to keep. A fill below a threshold forgets what it counted for, so a clear does not fire
113
+ * again and a later rise does. No value keeps the state.
114
+ */
115
+ export const clearStep = (
116
+ state: ClearState,
117
+ input: ClearInput,
118
+ ): { event: CoordinatorClearEvent | null; state: ClearState } => {
119
+ const { tokens, thresholds, mergedAt } = input
120
+ if (tokens === null) return { event: null, state: { ...state, mergedAt } }
121
+ const isDue = isMergeMoment(input) || !state.fillReported
122
+ const fires = isDue && clearMoment(input).clear
123
+ const isBelowMergeThreshold = tokens < thresholds.clearAfterMergeTokens
124
+ return {
125
+ event: fires ? { kind: COORDINATOR_CLEAR_EVENT, tokens } : null,
126
+ state: {
127
+ fillReported: tokens >= thresholds.clearTokens && (state.fillReported || fires),
128
+ mergedAt: fires || isBelowMergeThreshold ? null : mergedAt,
129
+ },
130
+ }
131
+ }
132
+
133
+ /** Whether the coordinator still has something to read: a report goes out in this poll, or went out just now. */
134
+ export const isReportPending = ({ stopped, events, quietSince, now, settleMs }: PollNews): boolean =>
135
+ stopped.length > 0 || events.length > 0 || now - quietSince < settleMs
136
+
137
+ /** The merge to keep for the next step: this poll's when it saw one, else the one still remembered. */
138
+ export const mergedAtAfter = (
139
+ events: readonly { kind: string }[],
140
+ remembered: number | null,
141
+ now: number,
142
+ ): number | null => (events.some(({ kind }) => kind === MERGED_EVENT) ? now : remembered)
143
+
144
+ /** The status of the coordinator as `coordinatorTargetOf` finds it; null when it is not listed or no name is known. */
145
+ export const coordinatorStatusOf = (
146
+ agents: readonly HerdrAgent[],
147
+ name: string | null,
148
+ pane: string | undefined,
149
+ ): string | null => {
150
+ if (name === null) return null
151
+ const target = coordinatorTargetOf(agents, name, pane)
152
+ if (target.kind === 'absent') return null
153
+ const agent = agents.find((candidate) =>
154
+ target.kind === 'named' ? candidate.name === name : candidate.pane_id === target.pane,
155
+ )
156
+ return agent?.agent_status ?? null
157
+ }
158
+
159
+ /** The questions in a report's questions sections, as `questionsFingerprint` keeps them. */
160
+ export const questionCountOf = (fingerprint: string): number => {
161
+ const lines = fingerprint.split('\n')
162
+ return questionCount(lines, questionSections(lines))
163
+ }
164
+
165
+ /**
166
+ * The questions of a lane's report the coordinator has not been shown: all of them while the fingerprint differs from
167
+ * the one remembered when its report was last shown, none once they match. A report seen for the first time is the
168
+ * baseline and holds nothing, as in `askingLanes`. A question shown or taken as the baseline stays open in the lane's
169
+ * report, and `jh resume --coordinator` names it after a clear.
170
+ */
171
+ export const unshownQuestionCount = (current: string, shown: string | undefined): number =>
172
+ shown === undefined || current === shown ? 0 : questionCountOf(current)
173
+
174
+ /**
175
+ * The lanes as `clearMoment` reads them. A lane without an agent holds nothing with its report: it will not fire again,
176
+ * so the hold would last until its report changes, and `jh resume --coordinator` names its questions.
177
+ */
178
+ export const clearLanesOf = (
179
+ lanes: readonly string[],
180
+ current: Readonly<Record<string, { status: string } | undefined>>,
181
+ questions: Readonly<Record<string, string>> | null,
182
+ shown: Readonly<Record<string, string>>,
183
+ ): ClearLane[] =>
184
+ lanes.map((name) => {
185
+ const status = current[name]?.status ?? NO_AGENT
186
+ const count = status === NO_AGENT ? 0 : unshownQuestionCount(questions?.[name] ?? '', shown[name])
187
+ return { name, status, questions: count }
188
+ })
189
+
190
+ /** The time at the start of an entry of a history cell: `11:39: ` or `~11:39: `. */
191
+ const ENTRY_TIME = /(?:^|\s)~?(\d{1,2}:\d{2}):(?=\s)/g
192
+ const PULL_REQUEST_NUMBER = /#(\d+)/g
193
+ /** An approval in the board's words or the question's, never one a word before it denies. */
194
+ const SELF_MERGE =
195
+ /(?<!\b(?:kein|keine|keinen|nicht|ohne|no|not|without)\s)\b(?:self[- ]?merge|selbst[- ]?merge|merge it yourself)/i
196
+
197
+ /** The entries of a history cell, each with its time; text before the first time belongs to none. */
198
+ const entriesOf = (cell: string): { time: string; text: string }[] => {
199
+ const matches = [...cell.matchAll(ENTRY_TIME)]
200
+ return matches.map((match, position) => ({
201
+ time: (match[1] ?? '').padStart(5, '0'),
202
+ text: cell.slice(match.index + match[0].length, matches[position + 1]?.index),
203
+ }))
204
+ }
205
+
206
+ const numbersIn = (text: string): number[] => [...text.matchAll(PULL_REQUEST_NUMBER)].map((match) => Number(match[1]))
207
+
208
+ /**
209
+ * The open pull requests the board's history says the user approved with self-merge within the last `minutes`. The
210
+ * entry is free text, so this reads it by its words and its time; a miss only means the merge guard asks again, and a
211
+ * wrong hit holds a clear up until the approval lapses.
212
+ */
213
+ export const selfMergePullRequests = (
214
+ board: string,
215
+ language: PlanLanguage,
216
+ open: readonly number[],
217
+ now: number,
218
+ minutes: number,
219
+ ): number[] => {
220
+ const { rows } = firstTableWithHeader(sectionLines(board, PLAN_VOCABULARY[language].sections.history))
221
+ const approved = rows.flatMap(([date, ...cells]) =>
222
+ entriesOf(cells.join(' '))
223
+ .filter(({ text }) => SELF_MERGE.test(text))
224
+ .filter(({ time }) => now - new Date(`${date}T${time}:00`).getTime() <= minutes * MS_PER_MINUTE)
225
+ .flatMap(({ text }) => numbersIn(text)),
226
+ )
227
+ return open.filter((number) => approved.includes(number))
228
+ }
@@ -68,6 +68,16 @@ import {
68
68
  } from './appointments'
69
69
  import { type ApprovalRequestFile, approvalEvents, readApprovalRequests, recordApprovals } from './approvals'
70
70
  import { askingLanes, questionsFingerprint, recordReportQuestions } from './asking-lanes'
71
+ import {
72
+ type ClearInput,
73
+ type ClearState,
74
+ clearLanesOf,
75
+ clearStep,
76
+ coordinatorStatusOf,
77
+ isReportPending,
78
+ mergedAtAfter,
79
+ selfMergePullRequests,
80
+ } from './clear-moment'
71
81
  import {
72
82
  baselinesOf,
73
83
  type LiveValues,
@@ -184,7 +194,7 @@ import {
184
194
  previewReadOf,
185
195
  unreportedTargets,
186
196
  } from './previews'
187
- import { latestPullRequestOf, type WatchedPullRequest } from './pull-requests'
197
+ import { latestPullRequestOf, PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
188
198
  import { recordedReadsOf } from './recorded-reads'
189
199
  import {
190
200
  annotateEach,
@@ -280,12 +290,14 @@ type Watch = {
280
290
  skillCopies: SkillCopy[]
281
291
  /** The tools of `guard.approvals.standing`: an approval of one of them points to the stage approval reference. */
282
292
  stageTools: readonly string[]
293
+ /** `guard.selfMerge.approvalMinutes`: how long an entry in the board's history holds a clear up. */
294
+ selfMergeMinutes: number
283
295
  /** Runs inside herdr with the herdr adapter, so missing herd panes can be brought back. */
284
296
  herdr: boolean
285
297
  }
286
298
 
287
- /** The coordinator's agent name (null when none is found) and its last read (null before the first one). */
288
- type Coordinator = { name: string | null; read: CoordinatorRead | null }
299
+ /** The coordinator's agent name (null when none is found), its last read (null before the first one) and the tokens of that read (null without a value). */
300
+ type Coordinator = { name: string | null; read: CoordinatorRead | null; tokens: number | null }
289
301
 
290
302
  /**
291
303
  * What the loop knows between polls. `state` is the record as of the last GitHub read that did not
@@ -310,6 +322,8 @@ type Watching = {
310
322
  laneBranches: Record<string, string>
311
323
  githubReadAt: number
312
324
  coordinator: Coordinator
325
+ /** What `coordinator clear` already reported and the merge still to be taken as a moment; persisted like the fingerprints. */
326
+ coordinatorClear: ClearState
313
327
  herdrDown: boolean
314
328
  reporting: boolean
315
329
  /** The fetch of the base branch under way, null when none runs; the next one waits for it, a report after a merge too. */
@@ -408,6 +422,7 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
408
422
  laneLevels: recordLaneLevels(withReads.laneLevels, watching.laneReadings, watching.lanes),
409
423
  laneModels: recordLaneModels(withReads.laneModels, watching.laneModelReadings, watching.lanes),
410
424
  laneParallels: recordLaneParallels(withReads.laneParallels, watching.laneParallelReadings, watching.lanes),
425
+ coordinatorClear: watching.coordinatorClear,
411
426
  taskParallels: watching.taskParallels,
412
427
  parallelAutoEnded: watching.parallelAutoEnded,
413
428
  noTaskSignals: watching.noTaskSignals,
@@ -490,7 +505,12 @@ const coordinatorNameFor = (watch: Watch, agents: readonly HerdrAgent[]): string
490
505
  * One read of the fills: the coordinator's, and each watched lane's (null without a value), with `models`, lane → the
491
506
  * model of its agent's last answer (null without one), read from the same report.
492
507
  */
493
- type FillRead = { coordinator: CoordinatorRead; lanes: LaneReadings; models: Record<string, string | null> }
508
+ type FillRead = {
509
+ coordinator: CoordinatorRead
510
+ coordinatorTokens: number | null
511
+ lanes: LaneReadings
512
+ models: Record<string, string | null>
513
+ }
494
514
 
495
515
  /**
496
516
  * The coordinator's fill with its own thresholds, as `jh context <name> --coordinator` computes it, or why there
@@ -507,11 +527,12 @@ const fillReadFor = (watch: Watch, name: string, agents: readonly HerdrAgent[],
507
527
  const reports = lanes.map((lane) => [lane, contextReport(listed, lane, agentLog, watch.settings.context)] as const)
508
528
  return {
509
529
  coordinator: explainedRead(coordinatorReadOf(own), target, name, watch.texts),
530
+ coordinatorTokens: own?.tokens ?? null,
510
531
  lanes: Object.fromEntries(reports.map(([lane, report]) => [lane, contextReadingOf(report)])),
511
532
  models: Object.fromEntries(reports.map(([lane, report]) => [lane, report?.model ?? null])),
512
533
  }
513
534
  } catch (error) {
514
- return { coordinator: { reason: errorText(error) }, lanes: {}, models: {} }
535
+ return { coordinator: { reason: errorText(error) }, coordinatorTokens: null, lanes: {}, models: {} }
515
536
  }
516
537
  }
517
538
 
@@ -1050,7 +1071,7 @@ const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: n
1050
1071
  ...recordFeedbackRead(watching, feedback?.read ?? null),
1051
1072
  ...recordPreviewRead(watching, previews),
1052
1073
  ...recordSourceRuns(watching, sourceRuns),
1053
- ...recordCoordinator(watching, { name, read: fills.coordinator }),
1074
+ ...recordCoordinator(watching, { name, read: fills.coordinator, tokens: fills.coordinatorTokens }),
1054
1075
  ...recordLaneFills(watching, fills.lanes),
1055
1076
  ...recordLaneModelReads(watching, laneModelReadingsOf(watch, watching, fills.models, modelProgress)),
1056
1077
  ...recordLaneParallelReads(watching, laneParallelReadingsOf(watch, watching)),
@@ -1395,6 +1416,66 @@ const takeStoppedLanes = (watch: Watch, watching: Watching): string[] => {
1395
1416
  return [...fired, ...askingLanes(working, watching.reportQuestions, watching.state.reportQuestions)]
1396
1417
  }
1397
1418
 
1419
+ /** The open pull requests the board's history approves with self-merge; none below the thresholds, where it decides nothing, or when the board cannot be read. */
1420
+ const selfMergesOf = (watch: Watch, watching: Watching, now: number): number[] => {
1421
+ const { tokens } = watching.coordinator
1422
+ if (tokens === null || tokens < watch.settings.context.coordinator.clearAfterMergeTokens) return []
1423
+ const board = readBoardText(watch)
1424
+ if (board === null) return []
1425
+ const open = Object.entries(watching.state.pullRequests).flatMap(([number, { state }]) =>
1426
+ state === PULL_REQUEST_STATE.open ? [Number(number)] : [],
1427
+ )
1428
+ return selfMergePullRequests(board, watch.settings.planLanguage, open, now, watch.selfMergeMinutes)
1429
+ }
1430
+
1431
+ const clearInputOf = (
1432
+ watch: Watch,
1433
+ watching: Watching,
1434
+ reportPending: boolean,
1435
+ mergedAt: number | null,
1436
+ now: number,
1437
+ ): ClearInput => {
1438
+ const openRequests = approvalEvents([], watching.approvalRequests ?? [], now)
1439
+ return {
1440
+ tokens: watching.coordinator.tokens,
1441
+ thresholds: watch.settings.context.coordinator,
1442
+ coordinatorStatus: coordinatorStatusOf(watching.agents, watching.coordinator.name, process.env.HERDR_PANE_ID),
1443
+ reportPending,
1444
+ lanes: clearLanesOf(watching.lanes, watching.current, watching.reportQuestions, watching.state.reportQuestions),
1445
+ openApprovals: openRequests.map(({ id }) => id),
1446
+ selfMergePullRequests: selfMergesOf(watch, watching, now),
1447
+ mergedAt,
1448
+ boardWrittenAt: reportMtime(watch.sprintFile),
1449
+ }
1450
+ }
1451
+
1452
+ /**
1453
+ * This poll's `coordinator clear`, when the moment is quiet: a report that goes out now or went out within the last
1454
+ * GitHub poll still has to be read, so it holds the moment up. Records the state in `watching`.
1455
+ */
1456
+ const recordCoordinatorClear = (
1457
+ watch: Watch,
1458
+ watching: Watching,
1459
+ stopped: readonly string[],
1460
+ events: readonly WatchEvent[],
1461
+ now: number,
1462
+ ): WatchEvent[] => {
1463
+ const mergedAt = mergedAtAfter(events, watching.coordinatorClear.mergedAt, now)
1464
+ const reportPending = isReportPending({
1465
+ stopped,
1466
+ events,
1467
+ quietSince: watching.quietSince,
1468
+ now,
1469
+ settleMs: GITHUB_POLL_MS,
1470
+ })
1471
+ const { event, state } = clearStep(
1472
+ watching.coordinatorClear,
1473
+ clearInputOf(watch, watching, reportPending, mergedAt, now),
1474
+ )
1475
+ watching.coordinatorClear = state
1476
+ return event ? [event] : []
1477
+ }
1478
+
1398
1479
  /**
1399
1480
  * One poll: reports what is due and tells whether to keep watching. Without `--stream` the first
1400
1481
  * report ends the watch; in a stream every reported lane counts as seen and the heartbeat is one line.
@@ -1412,12 +1493,13 @@ const pollOnce = async (watch: Watch, watching: Watching): Promise<boolean> => {
1412
1493
  // read at every poll like the approvals; the events and the record use the same read and time, so an appointment
1413
1494
  // that falls due between the two is neither recorded unreported nor reported twice
1414
1495
  watching.appointments = readAppointments(watch, now)
1415
- const polledEvents = [
1496
+ const newsEvents = [
1416
1497
  ...githubEvents,
1417
1498
  ...approvalEvents(watching.state.approvals, watching.approvalRequests, now),
1418
1499
  ...appointmentEvents(watching.state.appointments, watching.appointments?.table ?? null, now),
1419
1500
  ]
1420
1501
  const stopped = takeStoppedLanes(watch, watching)
1502
+ const polledEvents = [...newsEvents, ...recordCoordinatorClear(watch, watching, stopped, newsEvents, now)]
1421
1503
  const heartbeat = isHeartbeatDue(watching.quietSince, now, heartbeatMinutes * MINUTE_MS)
1422
1504
  let next = recordedOf(watch, watching)
1423
1505
  const reported = stopped.length > 0 || polledEvents.length > 0
@@ -1460,7 +1542,8 @@ const initialWatching = (
1460
1542
  github: null,
1461
1543
  laneBranches: {},
1462
1544
  githubReadAt: 0,
1463
- coordinator: { name: watch.options.coordinator, read: null },
1545
+ coordinator: { name: watch.options.coordinator, read: null, tokens: null },
1546
+ coordinatorClear: start.state.coordinatorClear,
1464
1547
  herdrDown: false,
1465
1548
  reporting: false,
1466
1549
  baseFetch: null,
@@ -1616,6 +1699,7 @@ const watchOf = (options: WatchOptions): Watch => {
1616
1699
  texts: watchTexts(settings.responseLanguage),
1617
1700
  skillCopies: skillCopiesFor(root, homedir(), config.skillPrefix, DEFAULT_SKILL_PREFIX),
1618
1701
  stageTools: stageToolsOf(config.guard.approvals.standing),
1702
+ selfMergeMinutes: config.guard.selfMerge.approvalMinutes,
1619
1703
  herdr: insideHerdr(settings.adapter, process.env),
1620
1704
  }
1621
1705
  }
@@ -166,6 +166,9 @@ export const otherPullRequestsLine = (
166
166
  return `${texts.otherPullRequests}: ${[...named, ...(more > 0 ? [`+${more}`] : [])].join(' · ')}`
167
167
  }
168
168
 
169
+ /** `250k`. */
170
+ const thousandsLabel = (tokens: number): string => `${Math.round(tokens / 1000)}k`
171
+
169
172
  /** The header line of an event about one lane's agent (fill, model, parallel phases, task signal, standing still); null for any other. */
170
173
  const laneEventLine = (event: WatchEvent): string | null => {
171
174
  if (event.kind === WATCH_EVENT.laneFill)
@@ -184,7 +187,7 @@ const laneEventLine = (event: WatchEvent): string | null => {
184
187
  * The header line of one event: `=== merged #<number> (lane)`, `=== checks failed #<number> (lane|branch <name>): <checks> (<k> tests: <names>)`,
185
188
  * the checks and tests only when the event carries them,
186
189
  * `=== deploy success run 9 (push, lane|#<number>) target stage`, `=== heartbeat`, `=== gh unavailable: <reason>`,
187
- * `=== coordinator warn 62 %`, `=== lane <name> alert 47 %`, `=== preview #<number> (lane) <url> · <sha7>`.
190
+ * `=== coordinator warn 62 %`, `=== coordinator clear 250k`, `=== lane <name> alert 47 %`, `=== preview #<number> (lane) <url> · <sha7>`.
188
191
  * `target` is the deploy environment read from the run, null when none was found.
189
192
  */
190
193
  export const watchEventLine = (event: WatchEvent, target: string | null): string => {
@@ -204,6 +207,7 @@ export const watchEventLine = (event: WatchEvent, target: string | null): string
204
207
  if (event.kind === WATCH_EVENT.approval)
205
208
  return `=== ${event.kind} ${terminalSafe(event.lane)} ${event.id} ${terminalSafe(event.tool)} · ${terminalSafe(event.summary)}`
206
209
  if (event.kind === WATCH_EVENT.coordinator) return `=== ${event.kind} ${event.level} ${contextLabel(event.share)}`
210
+ if (event.kind === WATCH_EVENT.coordinatorClear) return `=== ${event.kind} ${thousandsLabel(event.tokens)}`
207
211
  if (isPullRequestEvent(event))
208
212
  return `=== ${event.kind} #${event.number} (${pullRequestOwner(event)})${failedChecksSuffix(event)}`
209
213
  if ('number' in event) return `=== ${event.kind} #${event.number} (${pullRequestOwner(event)})`
@@ -13,6 +13,7 @@ export const REFERENCE = {
13
13
  events: 'references/events.md',
14
14
  laneSession: 'references/lane-session.md',
15
15
  coordinatorHandover: 'references/coordinator-handover.md',
16
+ coordinatorClear: 'references/coordinator-clear.md',
16
17
  closeLane: 'references/close-lane.md',
17
18
  laneHandover: 'references/lane-handover.md',
18
19
  startLane: 'references/start-lane.md',
@@ -38,6 +39,7 @@ const SKILL_ENTRY = 'SKILL.md'
38
39
  * lines do not grow. A lane's fill and an approval depend on more than the kind: see `eventReference`.
39
40
  */
40
41
  const EVENT_REFERENCES: Readonly<Record<string, Reference>> = {
42
+ [WATCH_EVENT.coordinatorClear]: REFERENCE.coordinatorClear,
41
43
  [WATCH_EVENT.feedback]: REFERENCE.events,
42
44
  [WATCH_EVENT.preview]: REFERENCE.events,
43
45
  [WATCH_EVENT.source]: REFERENCE.events,
@@ -54,8 +56,11 @@ const EVENT_REFERENCES: Readonly<Record<string, Reference>> = {
54
56
  [WATCH_EVENT.laneModel]: REFERENCE.laneSession,
55
57
  }
56
58
 
57
- const isCoordinatorHandoverLevel = (level: unknown): boolean =>
58
- level === CONTEXT_LEVEL.warn || level === CONTEXT_LEVEL.alert
59
+ /** The reference of the coordinator's rising context: clearing at `warn`, its hand-over at `alert`, none at `ok`. */
60
+ const coordinatorFillReference = (level: unknown): Reference | null => {
61
+ if (level === CONTEXT_LEVEL.alert) return REFERENCE.coordinatorHandover
62
+ return level === CONTEXT_LEVEL.warn ? REFERENCE.coordinatorClear : null
63
+ }
59
64
 
60
65
  /** The reference of a lane whose context rose: its hand-over at `alert`, the events at `warn`. */
61
66
  const laneFillReference = (level: unknown): Reference =>
@@ -69,8 +74,7 @@ export const eventReference = (
69
74
  event: { kind: string; level?: unknown; tool?: string },
70
75
  isStageTool: (tool: string) => boolean = () => false,
71
76
  ): Reference | null => {
72
- if (event.kind === WATCH_EVENT.coordinator)
73
- return isCoordinatorHandoverLevel(event.level) ? REFERENCE.coordinatorHandover : null
77
+ if (event.kind === WATCH_EVENT.coordinator) return coordinatorFillReference(event.level)
74
78
  if (event.kind === WATCH_EVENT.laneFill) return laneFillReference(event.level)
75
79
  if (event.kind === WATCH_EVENT.approval)
76
80
  return event.tool !== undefined && isStageTool(event.tool) ? REFERENCE.stageApproval : null
@@ -8,6 +8,13 @@ import {
8
8
  isAppointmentState,
9
9
  } from './appointments'
10
10
  import { APPROVAL_EVENT, type ApprovalEvent } from './approvals'
11
+ import {
12
+ type ClearState,
13
+ COORDINATOR_CLEAR_EVENT,
14
+ type CoordinatorClearEvent,
15
+ emptyClearState,
16
+ isClearState,
17
+ } from './clear-moment'
11
18
  import { type CoordinatorLevel, type CoordinatorReading, isContextLevel, levelStep } from './coordinator-fill'
12
19
  import { emptyFailureState, type FailureState, failedTestsOf, isFailureState } from './failures'
13
20
  import { emptyFeedbackState, FEEDBACK_EVENT, type FeedbackEvent, type FeedbackState, isFeedbackState } from './feedback'
@@ -53,6 +60,7 @@ export const WATCH_EVENT = {
53
60
  heartbeat: 'heartbeat',
54
61
  ghUnavailable: 'gh unavailable',
55
62
  coordinator: 'coordinator',
63
+ coordinatorClear: COORDINATOR_CLEAR_EVENT,
56
64
  feedback: FEEDBACK_EVENT,
57
65
  preview: PREVIEW_EVENT,
58
66
  laneFill: LANE_FILL_EVENT,
@@ -103,6 +111,8 @@ export type WatchState = {
103
111
  laneBranches: Record<string, string>
104
112
  /** The coordinator's level when it was last read with a value; null before the first one (counts as `ok`). */
105
113
  coordinatorLevel: CoordinatorLevel | null
114
+ /** What `coordinator clear` already reported and the merge still to be taken as a moment to clear; see `clear-moment.ts`. */
115
+ coordinatorClear: ClearState
106
116
  /** The feedback seen on the lanes' open pull requests; kept apart from `pullRequests`, it is read over REST. */
107
117
  feedback: FeedbackState
108
118
  /** The failed tests read from the logs of the lanes' red pull requests, once per head commit; see `failures.ts`. */
@@ -186,6 +196,7 @@ export type WatchEvent =
186
196
  | DeployEvent
187
197
  | SignalEvent
188
198
  | CoordinatorEvent
199
+ | CoordinatorClearEvent
189
200
  | FeedbackEvent
190
201
  | PreviewEvent
191
202
  | LaneFillEvent
@@ -224,6 +235,7 @@ export const emptyWatchState = (): WatchState => ({
224
235
  savedAt: null,
225
236
  laneBranches: {},
226
237
  coordinatorLevel: null,
238
+ coordinatorClear: emptyClearState(),
227
239
  feedback: emptyFeedbackState(),
228
240
  failures: emptyFailureState(),
229
241
  previews: emptyPreviewState(),
@@ -279,6 +291,7 @@ const isWatchState = (value: Record<string, unknown>): value is WatchState =>
279
291
  isSeq(value.savedAt) &&
280
292
  everyValue(value.laneBranches, (entry) => typeof entry === 'string') &&
281
293
  (value.coordinatorLevel === null || isContextLevel(value.coordinatorLevel)) &&
294
+ isClearState(value.coordinatorClear) &&
282
295
  isFeedbackState(value.feedback) &&
283
296
  isFailureState(value.failures) &&
284
297
  isPreviewState(value.previews) &&