jackherd 1.18.1 → 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.
- package/CHANGELOG.md +23 -0
- package/INSTALL.md +1 -1
- package/README.md +31 -26
- package/assets/icons/model.svg +1 -0
- package/dist/{chunk-N5FAOQZL.js → chunk-JDO6O3UO.js} +52 -2
- package/dist/config.d.ts +15 -2
- package/dist/config.js +22 -3
- package/dist/plan-format.d.ts +84 -1
- package/dist/plan-format.js +85 -1
- package/docs/plan-contract.md +8 -2
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +1 -7
- package/kit/hooks/hooks/approval-gate/decision.ts +10 -11
- package/kit/hooks/hooks/approval-gate/run.ts +23 -3
- package/kit/hooks/hooks/browser-guard/decision.ts +7 -0
- package/kit/hooks/hooks/browser-guard/index.ts +50 -0
- package/kit/hooks/hooks/plain-question/arguments.ts +14 -1
- package/kit/hooks/hooks/plain-question/card.ts +102 -0
- package/kit/hooks/hooks/plain-question/form.ts +25 -9
- package/kit/hooks/hooks/plain-question/index.ts +20 -25
- package/kit/hooks/hooks/plain-question/recommendation.ts +6 -6
- package/kit/hooks/hooks/return-brief/decision.ts +29 -0
- package/kit/hooks/hooks/return-brief/index.ts +37 -0
- package/kit/hooks/hooks/return-brief/settings.ts +11 -0
- package/kit/hooks/hooks/shared/coordinator-call.ts +25 -0
- package/kit/hooks/hooks/shared/transcript.ts +9 -0
- package/kit/hooks/hooks.json +18 -1
- package/kit/review/agents/security-audit.md +1 -1
- package/kit/workflow/agents/browser-check.md +29 -0
- package/kit/workflow/skills/implement/SKILL.md +5 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +36 -14
- package/kit/workflow/skills/orchestrate/references/acceptance.md +10 -9
- package/kit/workflow/skills/orchestrate/references/close-lane.md +1 -1
- package/kit/workflow/skills/orchestrate/references/coordinator-clear.md +45 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +15 -12
- package/kit/workflow/skills/plan/SKILL.md +2 -0
- package/kit/workflow/skills/plan/references/template-English.md +8 -0
- package/kit/workflow/skills/plan/references/template-German.md +8 -0
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +16 -0
- package/src/brief/command.ts +45 -0
- package/src/brief/lane-brief.ts +51 -0
- package/src/brief/lane-facts.ts +64 -0
- package/src/check/skill-budget-defaults.ts +15 -4
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +22 -0
- package/src/lane/command.ts +1 -2
- package/src/lane-prompt/texts.ts +2 -2
- package/src/plan-contract/command.ts +1 -0
- package/src/plan-contract/contract.ts +28 -5
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/plan-head.ts +131 -0
- package/src/plans/active-tasks.ts +8 -1
- package/src/plans/click-hint.ts +2 -1
- package/src/plans/focus-overlay.ts +5 -2
- package/src/plans/folded-detail.ts +47 -0
- package/src/plans/lane-line.ts +86 -0
- package/src/plans/lane-reason.ts +24 -4
- package/src/plans/layout-phase.ts +4 -20
- package/src/plans/layout-summary.ts +56 -45
- package/src/plans/layout.ts +178 -44
- package/src/plans/port-listening.ts +25 -0
- package/src/plans/selection.ts +11 -0
- package/src/plans/snapshot.ts +27 -9
- package/src/plans/task-checklist.ts +57 -0
- package/src/plans/texts.ts +34 -9
- package/src/resume/command.ts +40 -6
- package/src/resume/coordinator-read.ts +136 -0
- package/src/resume/state.ts +163 -0
- package/src/resume/texts.ts +6 -0
- package/src/sync/render.ts +5 -1
- package/src/vocabulary/index.ts +74 -0
- package/src/watch/clear-moment.ts +228 -0
- package/src/watch/command.ts +92 -8
- package/src/watch/lane-report.ts +5 -1
- package/src/watch/reference-paths.ts +8 -4
- 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
|
+
}
|
package/src/watch/command.ts
CHANGED
|
@@ -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)
|
|
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 = {
|
|
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
|
|
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
|
}
|
package/src/watch/lane-report.ts
CHANGED
|
@@ -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
|
-
|
|
58
|
-
|
|
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) &&
|