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.
- package/CHANGELOG.md +31 -0
- package/INSTALL.md +176 -0
- package/README.md +260 -315
- package/assets/branding/README.md +7 -3
- package/assets/branding/jackherd-logo-dark-1024.png +0 -0
- package/assets/branding/jackherd-logo-dark-512.png +0 -0
- package/assets/branding/jackherd-logo-dark.svg +1 -1
- package/assets/icons/bark-dark.svg +1 -0
- package/assets/icons/bark.svg +1 -0
- package/assets/icons/bowl-dark.svg +1 -0
- package/assets/icons/bowl.svg +1 -0
- package/assets/icons/decide-dark.svg +1 -0
- package/assets/icons/decide.svg +1 -0
- package/assets/icons/ear-dark.svg +1 -0
- package/assets/icons/ear.svg +1 -0
- package/assets/icons/lanes-dark.svg +1 -0
- package/assets/icons/lanes.svg +1 -0
- package/assets/icons/plan-dark.svg +1 -0
- package/assets/icons/plan.svg +1 -0
- package/assets/icons/space.svg +1 -0
- package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
- package/dist/config.d.ts +14 -3
- package/dist/config.js +111 -10
- package/dist/plan-format.d.ts +180 -77
- package/dist/plan-format.js +292 -93
- package/docs/lanes-dark.svg +50 -0
- package/docs/lanes.svg +50 -1
- package/docs/skills-dark.svg +65 -0
- package/docs/skills.svg +65 -1
- package/docs/workflow-dark.svg +138 -0
- package/docs/workflow.svg +135 -169
- package/herdr-plugin/herdr-plugin.toml +6 -6
- package/herdr-plugin/open-plans.ts +1 -1
- package/herdr-plugin/restore-plans.ts +7 -7
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
- package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
- package/kit/hooks/hooks/approval-gate/index.ts +69 -0
- package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
- package/kit/hooks/hooks/approval-gate/request.ts +111 -0
- package/kit/hooks/hooks/approval-gate/run.ts +94 -0
- package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
- package/kit/hooks/hooks/approval-gate/store.ts +70 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
- package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
- package/kit/hooks/hooks/merge-guard/run.ts +2 -1
- package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
- package/kit/hooks/hooks/model-gate/index.ts +58 -0
- package/kit/hooks/hooks/model-gate/run.ts +63 -0
- package/kit/hooks/hooks/model-log/index.ts +60 -0
- package/kit/hooks/hooks/model-log/run.ts +57 -0
- package/kit/hooks/hooks/model-log/store.ts +40 -0
- package/kit/hooks/hooks/model-log/transcript.ts +37 -0
- package/kit/hooks/hooks/shared/checkout.ts +22 -0
- package/kit/hooks/hooks/shared/command.ts +6 -0
- package/kit/hooks/hooks/shared/config.ts +21 -0
- package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
- package/kit/hooks/hooks/shared/files.ts +19 -0
- package/kit/hooks/hooks/shared/fresh.ts +4 -0
- package/kit/hooks/hooks/shared/json.ts +4 -0
- package/kit/hooks/hooks/shared/model-family.ts +48 -0
- package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
- package/kit/hooks/hooks/shared/private-directory.ts +29 -0
- package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
- package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
- package/kit/hooks/hooks.json +25 -0
- package/kit/workflow/skills/follow/SKILL.md +4 -3
- package/kit/workflow/skills/implement/SKILL.md +6 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +113 -30
- package/kit/workflow/skills/plan/SKILL.md +5 -5
- package/kit/workflow/skills/start/SKILL.md +2 -2
- package/package.json +4 -1
- package/schemas/jackherd.schema.json +43 -5
- package/src/check/command.ts +31 -4
- package/src/check/deferral-arrows.ts +1 -1
- package/src/check/parallel-report.ts +23 -0
- package/src/check/plan-structure-check.ts +120 -53
- package/src/cli.ts +8 -2
- package/src/config/nested-quantifier.ts +73 -0
- package/src/config/repository-directory.ts +11 -0
- package/src/config/schema.ts +63 -8
- package/src/plan-format/board-prose.ts +36 -0
- package/src/plan-format/dependencies.ts +227 -0
- package/src/plan-format/documentation-gate.ts +16 -0
- package/src/plan-format/index.ts +23 -1
- package/src/plan-format/lanes.ts +35 -1
- package/src/plan-format/model-column.ts +52 -0
- package/src/plan-format/phases.ts +16 -1
- package/src/plan-format/plan-structure.ts +10 -28
- package/src/plan-format/progress.ts +7 -29
- package/src/plans/command.ts +3 -3
- package/src/plans/current-phase.ts +71 -0
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/herd-command.ts +4 -0
- package/src/plans/lane-discovery.ts +14 -2
- package/src/plans/lane-tickets.ts +69 -0
- package/src/plans/layout.ts +182 -88
- package/src/plans/live.ts +5 -1
- package/src/plans/phase-models-read.ts +37 -0
- package/src/plans/phase-models.ts +70 -0
- package/src/plans/plan-panes.ts +8 -7
- package/src/plans/pull-requests.ts +78 -4
- package/src/plans/selection.ts +1 -40
- package/src/plans/settings.ts +9 -3
- package/src/plans/shown-with-more.ts +5 -0
- package/src/plans/snapshot.ts +82 -8
- package/src/plans/sprint-html.ts +2 -2
- package/src/plans/supervisor.ts +5 -4
- package/src/plans/symbols.ts +9 -6
- package/src/plans/texts.ts +30 -13
- package/src/plans/view.ts +1 -0
- package/src/sync/herdr-plugin.ts +1 -1
- package/src/sync/next-steps.ts +1 -1
- package/src/sync/render.ts +2 -1
- package/src/sync/setup-questions.ts +2 -2
- package/src/terminal/text.ts +4 -0
- package/src/terminal/types.ts +1 -1
- package/src/vocabulary/index.ts +39 -0
- package/src/watch/approvals.ts +96 -0
- package/src/watch/command.ts +154 -50
- package/src/watch/failed-tests.ts +52 -0
- package/src/watch/failure-read.ts +56 -0
- package/src/watch/failures.ts +107 -0
- package/src/watch/feedback-item.ts +21 -0
- package/src/watch/feedback-read.ts +2 -1
- package/src/watch/feedback.ts +37 -28
- package/src/watch/gh-process.ts +53 -0
- package/src/watch/gh.ts +10 -20
- package/src/watch/heartbeat-progress.ts +1 -1
- package/src/watch/lane-model.ts +54 -0
- package/src/watch/lane-parallel.ts +46 -0
- package/src/watch/lane-progress.ts +28 -4
- package/src/watch/lane-report.ts +62 -9
- package/src/watch/lane-sources.ts +1 -1
- package/src/watch/no-finding.ts +47 -0
- package/src/watch/plan-badges-read.ts +19 -7
- package/src/watch/plan-badges.ts +59 -7
- package/src/watch/plan-views.ts +5 -5
- package/src/watch/previews.ts +3 -9
- package/src/watch/pull-requests.ts +12 -2
- package/src/watch/recorded-reads.ts +25 -0
- package/src/watch/source-fields.ts +4 -0
- package/src/watch/source-tickets.ts +99 -0
- package/src/watch/sources.ts +62 -15
- package/src/watch/texts.ts +21 -1
- package/src/watch/watch-defaults.ts +8 -0
- package/src/watch/watch-events.ts +64 -4
- package/src/watch/watcher-lock.ts +1 -1
- package/src/watch/workflow-runs.ts +5 -7
- package/docs/lanes.mmd +0 -9
- package/docs/skills.mmd +0 -10
package/src/watch/plan-badges.ts
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
|
+
import { CHECKS } from '../plans/pull-requests'
|
|
2
|
+
import { sanitisedFailedTests } from './failed-tests'
|
|
3
|
+
import { failedTestsOf } from './failures'
|
|
4
|
+
import { previewUrlOf } from './previews'
|
|
1
5
|
import { PULL_REQUEST_STATE } from './pull-requests'
|
|
6
|
+
import { lineText, type TicketRecord } from './sources'
|
|
2
7
|
import type { WatchState } from './watch-events'
|
|
3
8
|
|
|
4
9
|
/**
|
|
5
|
-
* What the
|
|
10
|
+
* What the herd shows beside a pull request: the checked URL of its preview, the feedback since its last push,
|
|
6
11
|
* and whether a reviewer requests changes.
|
|
7
12
|
*/
|
|
8
|
-
export type PullRequestBadges = {
|
|
13
|
+
export type PullRequestBadges = {
|
|
14
|
+
preview: string | null
|
|
15
|
+
feedback: number
|
|
16
|
+
changesRequested: boolean
|
|
17
|
+
/** The tests a red run failed on this head commit, as the watcher read them from the run's log; absent reads as none. */
|
|
18
|
+
failedTests?: string[]
|
|
19
|
+
}
|
|
9
20
|
|
|
10
21
|
/** The badges of a pull request the watcher shows nothing for. */
|
|
11
22
|
export const NO_BADGES: PullRequestBadges = { preview: null, feedback: 0, changesRequested: false }
|
|
@@ -22,14 +33,29 @@ export type WatchHealth =
|
|
|
22
33
|
| { kind: typeof WATCH_HEALTH.off }
|
|
23
34
|
| { kind: typeof WATCH_HEALTH.stale; minutes: number }
|
|
24
35
|
|
|
25
|
-
/**
|
|
26
|
-
export type
|
|
36
|
+
/** A source marked `ticket` in the config, with the lane its entries belong to, null when it names none. */
|
|
37
|
+
export type TicketSource = { name: string; lane: string | null }
|
|
38
|
+
|
|
39
|
+
/** A ticket as its badge shows it; `isNew` while its last reported change is younger than `NEW_FOR_MS`. */
|
|
40
|
+
export type TicketBadge = { id: string; url: string | null; status: string | null; isNew: boolean }
|
|
41
|
+
|
|
42
|
+
/** A ticket with the lane its source names, before the view matched it to a lane. */
|
|
43
|
+
export type SourceTicket = TicketBadge & { lane: string | null }
|
|
44
|
+
|
|
45
|
+
/** The badges the view draws, the tickets of the marked sources, and why there may be none. */
|
|
46
|
+
export type WatchReading = { badges: WatchBadges; tickets: SourceTicket[]; health: WatchHealth }
|
|
27
47
|
|
|
28
48
|
/** Three missed GitHub reads of the watcher (one a minute): an older state is not shown (D96). */
|
|
29
49
|
export const STALE_AFTER_MS = 180_000
|
|
30
50
|
|
|
51
|
+
/** How long a ticket stays marked new after its last reported change: a fixed day, no configuration. */
|
|
52
|
+
export const NEW_FOR_MS = 24 * 60 * 60_000
|
|
53
|
+
|
|
31
54
|
const MINUTE_MS = 60_000
|
|
32
55
|
|
|
56
|
+
const shownFailedTestsOf = (state: WatchState, number: string, headCommit: string): string[] =>
|
|
57
|
+
sanitisedFailedTests(failedTestsOf(state.failures, Number(number), headCommit))
|
|
58
|
+
|
|
33
59
|
const isFresh = (savedAt: number | null, now: number): boolean => savedAt !== null && now - savedAt <= STALE_AFTER_MS
|
|
34
60
|
|
|
35
61
|
/**
|
|
@@ -44,23 +70,49 @@ export const watchHealthOf = (savedAt: number | null, running: boolean, now: num
|
|
|
44
70
|
|
|
45
71
|
/**
|
|
46
72
|
* The badges of the open pull requests the watcher follows, from its saved state alone: the preview reported for the
|
|
47
|
-
* head the watcher knows, the feedback counted since that head,
|
|
73
|
+
* head the watcher knows, the feedback counted since that head, the requested changes, which a push keeps, and the tests
|
|
74
|
+
* a red run failed on that head. A value
|
|
48
75
|
* of an older head, a preview recorded without its URL, and a state older than `STALE_AFTER_MS` show nothing.
|
|
49
76
|
*/
|
|
50
77
|
export const watchBadgesOf = (state: WatchState, now: number): WatchBadges => {
|
|
51
78
|
if (!isFresh(state.savedAt, now)) return {}
|
|
52
79
|
return Object.fromEntries(
|
|
53
|
-
Object.entries(state.pullRequests).flatMap(([number, { state: prState, headCommit }]) => {
|
|
80
|
+
Object.entries(state.pullRequests).flatMap(([number, { state: prState, headCommit, checks }]) => {
|
|
54
81
|
if (prState !== PULL_REQUEST_STATE.open) return []
|
|
55
82
|
const preview = state.previews.pullRequests[number]
|
|
56
83
|
const feedback = state.feedback.pullRequests[number]
|
|
84
|
+
const failedTests = checks === CHECKS.failed ? shownFailedTestsOf(state, number, headCommit) : []
|
|
57
85
|
const badges = {
|
|
58
86
|
preview: preview?.reportedHead === headCommit ? (preview.url ?? null) : null,
|
|
59
87
|
feedback: feedback?.headCommit === headCommit ? feedback.count : 0,
|
|
60
88
|
changesRequested: feedback?.changesRequested === true,
|
|
89
|
+
...(failedTests.length > 0 && { failedTests }),
|
|
61
90
|
}
|
|
62
|
-
const shown = badges.preview !== null || badges.feedback > 0 || badges.changesRequested
|
|
91
|
+
const shown = badges.preview !== null || badges.feedback > 0 || badges.changesRequested || failedTests.length > 0
|
|
63
92
|
return shown ? [[number, badges]] : []
|
|
64
93
|
}),
|
|
65
94
|
)
|
|
66
95
|
}
|
|
96
|
+
|
|
97
|
+
const ticketBadgeOf = (id: string, { url, status, changedAt }: TicketRecord, now: number): TicketBadge => ({
|
|
98
|
+
id: lineText(id),
|
|
99
|
+
url: url === null ? null : previewUrlOf(url, undefined),
|
|
100
|
+
status: status === null ? null : lineText(status) || null,
|
|
101
|
+
isNew: changedAt !== null && now - changedAt < NEW_FOR_MS,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The tickets of the sources the config marks, in their order, from the watcher's saved state alone. Id and status
|
|
106
|
+
* are drawn as one line and the URL is checked again, since the view does not trust the file more than the watcher
|
|
107
|
+
* trusted the source; an id that is empty on one line is dropped, and a state older than `STALE_AFTER_MS` shows none,
|
|
108
|
+
* as for the pull request badges.
|
|
109
|
+
*/
|
|
110
|
+
export const sourceTicketsOf = (state: WatchState, sources: readonly TicketSource[], now: number): SourceTicket[] => {
|
|
111
|
+
if (!isFresh(state.savedAt, now)) return []
|
|
112
|
+
return sources.flatMap(({ name, lane }) =>
|
|
113
|
+
Object.entries(state.sources[name]?.tickets ?? {}).flatMap(([id, record]) => {
|
|
114
|
+
const badge = ticketBadgeOf(id, record, now)
|
|
115
|
+
return badge.id === '' ? [] : [{ ...badge, lane }]
|
|
116
|
+
}),
|
|
117
|
+
)
|
|
118
|
+
}
|
package/src/watch/plan-views.ts
CHANGED
|
@@ -6,14 +6,14 @@ import type { LaneSnapshot } from './lane-watch'
|
|
|
6
6
|
export const ENSURE_TRIGGER = { start: 'start', poll: 'poll' } as const
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Why the watcher may bring back
|
|
9
|
+
* Why the watcher may bring back herd panes: its own start, or a poll that found lanes added to the watched set
|
|
10
10
|
* or watched lanes whose agent appeared (a lane gets its view only once its agent runs).
|
|
11
11
|
*/
|
|
12
12
|
export type EnsureTrigger =
|
|
13
13
|
| { kind: typeof ENSURE_TRIGGER.start }
|
|
14
14
|
| { kind: typeof ENSURE_TRIGGER.poll; added: string[]; withNewAgent: string[] }
|
|
15
15
|
|
|
16
|
-
/** Whether the last failure of `
|
|
16
|
+
/** Whether the last failure of `herd --ensure` was already printed; a run that works clears it. */
|
|
17
17
|
export type PlanViews = { failureReported: boolean }
|
|
18
18
|
|
|
19
19
|
type Ensured = { ok: true; lines: string[] } | { ok: false; error: string }
|
|
@@ -34,9 +34,9 @@ export const ensureDue = (trigger: EnsureTrigger): boolean =>
|
|
|
34
34
|
/** The lines one run leaves in the watcher's output: what it brought back, or a failure once per streak. */
|
|
35
35
|
export const ensuredLines = (ensured: Ensured, views: PlanViews): { lines: string[]; views: PlanViews } => {
|
|
36
36
|
if (ensured.ok)
|
|
37
|
-
return { lines: ensured.lines.map((line) => `
|
|
37
|
+
return { lines: ensured.lines.map((line) => `herd --ensure: ${line}`), views: { failureReported: false } }
|
|
38
38
|
if (views.failureReported) return { lines: [], views }
|
|
39
|
-
return { lines: [`
|
|
39
|
+
return { lines: [`herd --ensure failed, still watching: ${ensured.error}`], views: { failureReported: true } }
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const ensuredOf = (ensure: () => string[]): Ensured => {
|
|
@@ -48,7 +48,7 @@ const ensuredOf = (ensure: () => string[]): Ensured => {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
* Brings back missing
|
|
51
|
+
* Brings back missing herd panes when `trigger` calls for it, as `jh herd --ensure` does. A failure
|
|
52
52
|
* never ends the watch; it is printed once until a run works again.
|
|
53
53
|
*/
|
|
54
54
|
export const keepPlanViews = (trigger: EnsureTrigger, views: PlanViews, effects: Effects): PlanViews => {
|
package/src/watch/previews.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type FeedbackTarget, isReadTime } from './feedback'
|
|
2
|
+
import { FEEDBACK_FORM, type FeedbackItem } from './feedback-item'
|
|
2
3
|
import { isRecord } from './json-guards'
|
|
3
4
|
|
|
4
5
|
export const PREVIEW_EVENT = 'preview'
|
|
@@ -25,7 +26,7 @@ export type PreviewCandidate = {
|
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* `reportedHead` is the head a preview was last reported (or, at the first sighting, found) for, `url` that preview's
|
|
28
|
-
* checked URL (missing in a state written before the
|
|
29
|
+
* checked URL (missing in a state written before the herd showed it); `stamps` maps `key` to `stamp`.
|
|
29
30
|
*/
|
|
30
31
|
export type PreviewRecord = { reportedHead: string | null; stamps: Record<string, string>; url?: string | null }
|
|
31
32
|
|
|
@@ -72,13 +73,6 @@ export const previewUrlOf = (text: string, pattern: string | undefined): string
|
|
|
72
73
|
export const isPreviewAuthor = (author: string, authors: readonly string[]): boolean =>
|
|
73
74
|
authors.some((name) => name.toLowerCase() === author.toLowerCase())
|
|
74
75
|
|
|
75
|
-
/** Whose comments are no feedback: the account the watcher runs as, the ignored logins and the preview authors. */
|
|
76
|
-
export const feedbackFilterOf = (
|
|
77
|
-
own: string,
|
|
78
|
-
ignore: readonly string[],
|
|
79
|
-
previews: Pick<PreviewSettings, 'authors'> | undefined,
|
|
80
|
-
): FeedbackFilter => ({ own, ignore: [...ignore, ...(previews?.authors ?? [])] })
|
|
81
|
-
|
|
82
76
|
/**
|
|
83
77
|
* Whether the feedback read, which reached back from its own last read, also covers everything since the previews'
|
|
84
78
|
* last read; a first preview read is a baseline and needs no history.
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type ChecksState,
|
|
4
|
+
checksOf,
|
|
5
|
+
type FailedCheck,
|
|
6
|
+
failedChecksOf,
|
|
7
|
+
type GhPullRequest,
|
|
8
|
+
isGhPullRequest,
|
|
9
|
+
} from '../plans/pull-requests'
|
|
3
10
|
import { isRecord, parseArray } from './json-guards'
|
|
4
11
|
|
|
5
12
|
/** The states of a pull request in `gh pr list --json state`. */
|
|
@@ -13,6 +20,8 @@ export type WatchedPullRequest = {
|
|
|
13
20
|
branch: string
|
|
14
21
|
state: PullRequestLifecycle
|
|
15
22
|
checks: ChecksState
|
|
23
|
+
/** The checks that failed with the run each belongs to; empty while none failed, and in the light list. */
|
|
24
|
+
failedRuns: FailedCheck[]
|
|
16
25
|
headCommit: string
|
|
17
26
|
mergeCommit: string | null
|
|
18
27
|
}
|
|
@@ -66,6 +75,7 @@ const watchedPullRequestsOf = (
|
|
|
66
75
|
branch: terminalSafe(pullRequest.headRefName),
|
|
67
76
|
state: pullRequest.state,
|
|
68
77
|
checks: checksOf(pullRequest.statusCheckRollup ?? []),
|
|
78
|
+
failedRuns: failedChecksOf(pullRequest.statusCheckRollup ?? [], pullRequest.url),
|
|
69
79
|
headCommit: pullRequest.headRefOid,
|
|
70
80
|
mergeCommit: pullRequest.mergeCommit?.oid ?? null,
|
|
71
81
|
}))
|
|
@@ -73,7 +83,7 @@ const watchedPullRequestsOf = (
|
|
|
73
83
|
const asIs = (entry: unknown): unknown => entry
|
|
74
84
|
|
|
75
85
|
/**
|
|
76
|
-
* Reads the JSON of `gh pr list --json <WATCHED_PULL_REQUEST_FIELDS>`, checked like the
|
|
86
|
+
* Reads the JSON of `gh pr list --json <WATCHED_PULL_REQUEST_FIELDS>`, checked like the herd's
|
|
77
87
|
* `parsePullRequests`; throws on anything else. The list carries no rollup, so its checks are `none`.
|
|
78
88
|
*/
|
|
79
89
|
export const parseWatchedPullRequests = (json: string, host = 'github.com'): WatchedPullRequest[] =>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type FailureReading, recordFailures } from './failures'
|
|
2
|
+
import { type FeedbackRead, recordFeedback } from './feedback'
|
|
3
|
+
import { type PreviewRead, recordPreviews } from './previews'
|
|
4
|
+
import type { WatchedPullRequest } from './pull-requests'
|
|
5
|
+
import type { WatchState } from './watch-events'
|
|
6
|
+
|
|
7
|
+
/** What the last GitHub poll read besides the pull requests' state; a read that did not answer is null. */
|
|
8
|
+
export type RecordedReads = {
|
|
9
|
+
feedback: FeedbackRead | null
|
|
10
|
+
previews: PreviewRead | null
|
|
11
|
+
pullRequests: readonly WatchedPullRequest[] | null
|
|
12
|
+
failures: readonly FailureReading[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Step = (state: WatchState) => WatchState
|
|
16
|
+
|
|
17
|
+
const stepsOf = (reads: RecordedReads): Step[] => [
|
|
18
|
+
(state) => (reads.feedback ? { ...state, feedback: recordFeedback(state.feedback, reads.feedback) } : state),
|
|
19
|
+
(state) => ({ ...state, failures: recordFailures(state.failures, reads.pullRequests, reads.failures) }),
|
|
20
|
+
(state) => (reads.previews ? { ...state, previews: recordPreviews(state.previews, reads.previews) } : state),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
/** The state after the feedback, failure and preview reads, each folded onto the result of the one before. */
|
|
24
|
+
export const recordedReadsOf = (state: WatchState, reads: RecordedReads): WatchState =>
|
|
25
|
+
stepsOf(reads).reduce((current, step) => step(current), state)
|
|
@@ -6,4 +6,8 @@ export const SOURCE_FIELD = {
|
|
|
6
6
|
url: 'url',
|
|
7
7
|
body: 'body',
|
|
8
8
|
author: 'author',
|
|
9
|
+
status: 'status',
|
|
9
10
|
} as const
|
|
11
|
+
|
|
12
|
+
/** The fields a reader sees of an entry: a change of one of them is a change of the entry (`contentFingerprint`). */
|
|
13
|
+
export const CONTENT_FIELDS = [SOURCE_FIELD.title, SOURCE_FIELD.author, SOURCE_FIELD.body, SOURCE_FIELD.url] as const
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
recordSources,
|
|
3
|
+
reportsItem,
|
|
4
|
+
SEEN_LIMIT,
|
|
5
|
+
type SeenEntry,
|
|
6
|
+
type SourceItem,
|
|
7
|
+
type SourceRecord,
|
|
8
|
+
type SourceRun,
|
|
9
|
+
type SourceSettings,
|
|
10
|
+
type SourceState,
|
|
11
|
+
seenMapOf,
|
|
12
|
+
type TicketRecord,
|
|
13
|
+
} from './sources'
|
|
14
|
+
|
|
15
|
+
const ticketOf = (
|
|
16
|
+
item: SourceItem,
|
|
17
|
+
known: TicketRecord | undefined,
|
|
18
|
+
seen: ReadonlyMap<string, SeenEntry> | null,
|
|
19
|
+
settings: SourceSettings,
|
|
20
|
+
now: number,
|
|
21
|
+
): TicketRecord => ({
|
|
22
|
+
url: item.url,
|
|
23
|
+
status: item.status,
|
|
24
|
+
changedAt: seen && reportsItem(seen, item, settings) ? now : (known?.changedAt ?? null),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
/** The tickets after a successful run: its entries, at most `SEEN_LIMIT`, each with the time of its last reported change. */
|
|
28
|
+
const ticketsAfter = (
|
|
29
|
+
previous: SourceRecord | undefined,
|
|
30
|
+
items: readonly SourceItem[],
|
|
31
|
+
settings: SourceSettings,
|
|
32
|
+
now: number,
|
|
33
|
+
): Record<string, TicketRecord> => {
|
|
34
|
+
const seen = seenMapOf(previous)
|
|
35
|
+
return Object.fromEntries(
|
|
36
|
+
items
|
|
37
|
+
.slice(-SEEN_LIMIT)
|
|
38
|
+
.map((item) => [item.id, ticketOf(item, previous?.tickets?.[item.id], seen, settings, now)]),
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const withoutTickets = ({ tickets: _tickets, ...record }: SourceRecord): SourceRecord => record
|
|
43
|
+
|
|
44
|
+
/** The tickets a source keeps: those of a successful run, else the ones it had; none for a source not marked. */
|
|
45
|
+
const ticketsOf = (
|
|
46
|
+
previous: SourceRecord | undefined,
|
|
47
|
+
run: SourceRun | undefined,
|
|
48
|
+
settings: SourceSettings | undefined,
|
|
49
|
+
now: number,
|
|
50
|
+
): Record<string, TicketRecord> | undefined => {
|
|
51
|
+
if (!settings?.ticket) return undefined
|
|
52
|
+
return run?.result.ok ? ticketsAfter(previous, run.result.items, settings, now) : previous?.tickets
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The state `recordSources` wrote with the tickets of every source marked `ticket`: the entries of its last
|
|
57
|
+
* successful run with URL, status and the time the run that last reported a change of the entry was recorded at
|
|
58
|
+
* (`now`). A baseline run and a change by an ignored author record no time, an unchanged entry keeps its time, a
|
|
59
|
+
* failed run keeps the tickets, and a source no longer marked keeps none. `previous` is the state the runs were
|
|
60
|
+
* compared with, as for `sourceEvents`.
|
|
61
|
+
*/
|
|
62
|
+
const recordTickets = (
|
|
63
|
+
previous: SourceState,
|
|
64
|
+
recorded: SourceState,
|
|
65
|
+
runs: readonly SourceRun[],
|
|
66
|
+
settings: readonly SourceSettings[],
|
|
67
|
+
now: number,
|
|
68
|
+
): SourceState =>
|
|
69
|
+
Object.fromEntries(
|
|
70
|
+
Object.entries(recorded).map(([name, record]) => {
|
|
71
|
+
const source = settings.find((candidate) => candidate.name === name)
|
|
72
|
+
const run = runs.find((candidate) => candidate.name === name)
|
|
73
|
+
const tickets = ticketsOf(previous[name], run, source, now)
|
|
74
|
+
return [name, tickets ? { ...withoutTickets(record), tickets } : withoutTickets(record)]
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The source state after `runs`: `recordSources` for every configured source, then the tickets of the sources marked
|
|
80
|
+
* `ticket`, both against `previous`, the state the runs were compared with.
|
|
81
|
+
*/
|
|
82
|
+
export const recordWatchedSources = (
|
|
83
|
+
previous: SourceState,
|
|
84
|
+
runs: readonly SourceRun[],
|
|
85
|
+
settings: readonly SourceSettings[],
|
|
86
|
+
now: number,
|
|
87
|
+
): SourceState =>
|
|
88
|
+
recordTickets(
|
|
89
|
+
previous,
|
|
90
|
+
recordSources(
|
|
91
|
+
previous,
|
|
92
|
+
runs,
|
|
93
|
+
settings.map(({ name }) => name),
|
|
94
|
+
now,
|
|
95
|
+
),
|
|
96
|
+
runs,
|
|
97
|
+
settings,
|
|
98
|
+
now,
|
|
99
|
+
)
|
package/src/watch/sources.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'
|
|
|
2
2
|
import { terminalSafe } from '../plan-format/terminal-text'
|
|
3
3
|
import { isRecord } from './json-guards'
|
|
4
4
|
import { previewUrlOf } from './previews'
|
|
5
|
-
import { SOURCE_FIELD } from './source-fields'
|
|
5
|
+
import { CONTENT_FIELDS, SOURCE_FIELD } from './source-fields'
|
|
6
6
|
import { ROOT_PATH, type SourcePath, selectPath } from './source-path'
|
|
7
7
|
|
|
8
8
|
export const SOURCE_EVENT = 'source'
|
|
@@ -21,14 +21,18 @@ export const SOURCE_FAILURE = {
|
|
|
21
21
|
shape: 'unexpected shape',
|
|
22
22
|
} as const
|
|
23
23
|
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* The paths of an entry's fields: `id`, `updatedAt` (optional where a content field or `reportOn` tells a change), the
|
|
26
|
+
* content fields, and any further named field.
|
|
27
|
+
*/
|
|
25
28
|
export type SourceMap = {
|
|
26
29
|
id: SourcePath
|
|
27
|
-
updatedAt
|
|
30
|
+
updatedAt?: SourcePath
|
|
28
31
|
title?: SourcePath
|
|
29
32
|
url?: SourcePath
|
|
30
33
|
body?: SourcePath
|
|
31
34
|
author?: SourcePath
|
|
35
|
+
status?: SourcePath
|
|
32
36
|
} & Record<string, SourcePath | undefined>
|
|
33
37
|
|
|
34
38
|
/**
|
|
@@ -46,6 +50,8 @@ export type SourceSettings = {
|
|
|
46
50
|
lane?: string
|
|
47
51
|
/** The mapped fields whose change reports, in place of the content fingerprint; without it the fingerprint decides. */
|
|
48
52
|
reportOn?: readonly string[]
|
|
53
|
+
/** Its entries are tickets, which the state keeps for the herd's badges (`TicketRecord`). */
|
|
54
|
+
ticket?: boolean
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
/**
|
|
@@ -59,6 +65,8 @@ export type SourceItem = {
|
|
|
59
65
|
url: string | null
|
|
60
66
|
body: string
|
|
61
67
|
author: string | null
|
|
68
|
+
/** `map.status` as the state keeps it (`storedValue`), null when not mapped or empty. */
|
|
69
|
+
status: string | null
|
|
62
70
|
fingerprint: string
|
|
63
71
|
/** The `reportOn` fields as the state keeps them (`storedValue`); empty without `reportOn`. */
|
|
64
72
|
values: Record<string, string>
|
|
@@ -72,12 +80,23 @@ export type SourceRun = { name: string; result: SourceResult }
|
|
|
72
80
|
/** `[id, fingerprint]`, or with `reportOn` `[id, fingerprint, values]`: the stored value of each `reportOn` field. */
|
|
73
81
|
export type SeenEntry = [string, string] | [string, string, Record<string, string>]
|
|
74
82
|
|
|
83
|
+
/**
|
|
84
|
+
* What the herd needs of a ticket: its checked URL, its status, and the epoch milliseconds the watcher recorded
|
|
85
|
+
* its last reported change at, null when none was reported since it was first seen.
|
|
86
|
+
*/
|
|
87
|
+
export type TicketRecord = { url: string | null; status: string | null; changedAt: number | null }
|
|
88
|
+
|
|
75
89
|
/**
|
|
76
90
|
* What the watcher keeps per source: `seen` holds a `SeenEntry` per id, the entries seen most recently last, or null
|
|
77
91
|
* before the first successful run (which is a baseline); `failingSince` is the epoch milliseconds of the first failed
|
|
78
92
|
* run of the current streak, null while the source answers. Of an entry's text only the `reportOn` fields are kept.
|
|
79
93
|
*/
|
|
80
|
-
export type SourceRecord = {
|
|
94
|
+
export type SourceRecord = {
|
|
95
|
+
seen: SeenEntry[] | null
|
|
96
|
+
failingSince: number | null
|
|
97
|
+
/** Of a ticket source only: the entries of its last successful run by id. */
|
|
98
|
+
tickets?: Record<string, TicketRecord>
|
|
99
|
+
}
|
|
81
100
|
|
|
82
101
|
export type SourceState = Record<string, SourceRecord>
|
|
83
102
|
|
|
@@ -104,7 +123,7 @@ export type SourceOverflowEvent = { kind: typeof SOURCE_EVENT; name: string; lan
|
|
|
104
123
|
|
|
105
124
|
export type SourceEvent = SourceItemEvent | SourceFailureEvent | SourceOverflowEvent
|
|
106
125
|
|
|
107
|
-
const SEEN_LIMIT = 1000
|
|
126
|
+
export const SEEN_LIMIT = 1000
|
|
108
127
|
const FINGERPRINT_LENGTH = 16
|
|
109
128
|
const STORED_VALUE_LENGTH = 200
|
|
110
129
|
const STORED_PREFIX_LENGTH = 180
|
|
@@ -124,7 +143,7 @@ const scalarText = (value: unknown): string | null => {
|
|
|
124
143
|
export const textAt = (entry: unknown, path: SourcePath | undefined): string | null =>
|
|
125
144
|
path === undefined ? null : scalarText(selectPath(entry, path))
|
|
126
145
|
|
|
127
|
-
type MappedFields = Omit<SourceItem, 'fingerprint' | 'values'>
|
|
146
|
+
type MappedFields = Omit<SourceItem, 'fingerprint' | 'values' | 'status'>
|
|
128
147
|
|
|
129
148
|
const hashOf = (text: string, length: number): string =>
|
|
130
149
|
createHash('sha256').update(text).digest('hex').slice(0, length)
|
|
@@ -135,15 +154,14 @@ export const storedValue = (text: string): string =>
|
|
|
135
154
|
? text
|
|
136
155
|
: `${text.slice(0, STORED_PREFIX_LENGTH)}…${hashOf(text, VALUE_HASH_LENGTH)}`
|
|
137
156
|
|
|
138
|
-
const hasContentField = (map: SourceMap): boolean =>
|
|
139
|
-
[map.title, map.author, map.body, map.url].some((path) => path !== undefined)
|
|
157
|
+
const hasContentField = (map: SourceMap): boolean => CONTENT_FIELDS.some((field) => map[field] !== undefined)
|
|
140
158
|
|
|
141
159
|
/**
|
|
142
160
|
* A short hash of what a reader sees of an entry: title, author, body and URL, so a change of the time alone (a label,
|
|
143
161
|
* a status) is no change. Without any of them mapped it is `updatedAt`, else the entry could never change.
|
|
144
162
|
*/
|
|
145
163
|
const contentFingerprint = (fields: MappedFields, map: SourceMap): string => {
|
|
146
|
-
const content = hasContentField(map) ?
|
|
164
|
+
const content = hasContentField(map) ? CONTENT_FIELDS.map((field) => fields[field]) : [fields.updatedAt]
|
|
147
165
|
return hashOf(JSON.stringify(content), FINGERPRINT_LENGTH)
|
|
148
166
|
}
|
|
149
167
|
|
|
@@ -168,11 +186,14 @@ const valuesOf = (
|
|
|
168
186
|
]),
|
|
169
187
|
)
|
|
170
188
|
|
|
171
|
-
|
|
189
|
+
const statusOf = (entry: unknown, map: SourceMap): string | null =>
|
|
190
|
+
storedValue(lineText(textAt(entry, map.status) ?? '')) || null
|
|
191
|
+
|
|
192
|
+
/** One entry after `map`, or null when its id, or a mapped updatedAt, is missing or no text; unmapped it is `''`. */
|
|
172
193
|
const sourceItemOf = (entry: unknown, settings: Pick<SourceSettings, 'map' | 'reportOn'>): SourceItem | null => {
|
|
173
194
|
const { map } = settings
|
|
174
195
|
const id = textAt(entry, map.id)
|
|
175
|
-
const updatedAt = textAt(entry, map.updatedAt)
|
|
196
|
+
const updatedAt = map.updatedAt === undefined ? '' : textAt(entry, map.updatedAt)
|
|
176
197
|
if (id === null || id === '' || updatedAt === null) return null
|
|
177
198
|
const url = textAt(entry, map.url)
|
|
178
199
|
const fields = {
|
|
@@ -183,7 +204,12 @@ const sourceItemOf = (entry: unknown, settings: Pick<SourceSettings, 'map' | 're
|
|
|
183
204
|
body: textAt(entry, map.body) ?? '',
|
|
184
205
|
author: textAt(entry, map.author),
|
|
185
206
|
}
|
|
186
|
-
return {
|
|
207
|
+
return {
|
|
208
|
+
...fields,
|
|
209
|
+
status: statusOf(entry, map),
|
|
210
|
+
fingerprint: contentFingerprint(fields, map),
|
|
211
|
+
values: valuesOf(entry, fields, settings),
|
|
212
|
+
}
|
|
187
213
|
}
|
|
188
214
|
|
|
189
215
|
/** The items once each: an id listed twice counts with its last entry. */
|
|
@@ -252,13 +278,25 @@ const changeOf = (
|
|
|
252
278
|
return changes.length === 0 ? null : { change: SOURCE_CHANGE.updated, changes }
|
|
253
279
|
}
|
|
254
280
|
|
|
281
|
+
/** Whether a run against `seen` reports `item`: new or changed, by an author not ignored. */
|
|
282
|
+
export const reportsItem = (
|
|
283
|
+
seen: ReadonlyMap<string, SeenEntry>,
|
|
284
|
+
item: SourceItem,
|
|
285
|
+
settings: SourceSettings,
|
|
286
|
+
): boolean => changeOf(seen, item, settings) !== null && !isIgnoredAuthor(item, settings.ignoreAuthors)
|
|
287
|
+
|
|
288
|
+
/** The seen entries of a record by id; null before the first successful run, which is a baseline. */
|
|
289
|
+
export const seenMapOf = (record: SourceRecord | undefined): ReadonlyMap<string, SeenEntry> | null =>
|
|
290
|
+
record?.seen ? new Map(record.seen.map((entry) => [entry[0], entry])) : null
|
|
291
|
+
|
|
292
|
+
/** Reports the items `reportsItem` names, with the change; the two must stay one rule, `changedAt` relies on it. */
|
|
255
293
|
const itemEvents = (
|
|
256
294
|
record: SourceRecord | undefined,
|
|
257
295
|
items: readonly SourceItem[],
|
|
258
296
|
settings: SourceSettings,
|
|
259
297
|
): SourceEvent[] => {
|
|
260
|
-
|
|
261
|
-
|
|
298
|
+
const seen = seenMapOf(record)
|
|
299
|
+
if (!seen) return []
|
|
262
300
|
const lane = settings.lane ?? null
|
|
263
301
|
const events = items.flatMap((item): SourceItemEvent[] => {
|
|
264
302
|
const found = changeOf(seen, item, settings)
|
|
@@ -335,10 +373,19 @@ const isSeenEntry = (value: unknown): boolean =>
|
|
|
335
373
|
typeof value[1] === 'string' &&
|
|
336
374
|
(value.length === 2 || isStoredValues(value[2]))
|
|
337
375
|
|
|
376
|
+
const isTextOrNull = (value: unknown): boolean => value === null || typeof value === 'string'
|
|
377
|
+
|
|
378
|
+
const isTicketRecord = (value: unknown): boolean =>
|
|
379
|
+
isRecord(value) &&
|
|
380
|
+
isTextOrNull(value.url) &&
|
|
381
|
+
isTextOrNull(value.status) &&
|
|
382
|
+
(value.changedAt === null || typeof value.changedAt === 'number')
|
|
383
|
+
|
|
338
384
|
const isSourceRecord = (value: unknown): boolean =>
|
|
339
385
|
isRecord(value) &&
|
|
340
386
|
(value.seen === null || (Array.isArray(value.seen) && value.seen.every(isSeenEntry))) &&
|
|
341
|
-
(value.failingSince === null || typeof value.failingSince === 'number')
|
|
387
|
+
(value.failingSince === null || typeof value.failingSince === 'number') &&
|
|
388
|
+
(value.tickets === undefined || (isRecord(value.tickets) && Object.values(value.tickets).every(isTicketRecord)))
|
|
342
389
|
|
|
343
390
|
export const isSourceState = (value: unknown): value is SourceState =>
|
|
344
391
|
isRecord(value) && Object.values(value).every(isSourceRecord)
|
package/src/watch/texts.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
1
|
+
import { APPROVAL_TEXTS, PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
|
|
2
2
|
|
|
3
3
|
/** The words of the watcher's status and duty lines, in the project's response language. */
|
|
4
4
|
export type WatchTexts = {
|
|
@@ -22,12 +22,21 @@ export type WatchTexts = {
|
|
|
22
22
|
blockedDuty: string
|
|
23
23
|
/** What a lane whose context reached `alert` asks of the coordinator. */
|
|
24
24
|
laneAlertDuty: string
|
|
25
|
+
/** What a lane whose model fits none of its board row's model column asks of the coordinator. */
|
|
26
|
+
laneModelDuty: string
|
|
27
|
+
/** What phases of one lane that can run together ask of the coordinator, with the phases as `P19 ∥ P20`. */
|
|
28
|
+
parallelDuty: (phases: string) => string
|
|
29
|
+
/** What a lane's call waiting for approval asks of the coordinator, with the mark (request id and call hash) the question must carry. */
|
|
30
|
+
approvalDuty: (id: string, hash: string) => string
|
|
25
31
|
/** The reminder after the coordinator line: `<term>: <count> rows only in the checkout`. */
|
|
26
32
|
checkoutRows: (term: string, count: number) => string
|
|
27
33
|
/** The head of the status line of the user's own open pull requests without a lane. */
|
|
28
34
|
otherPullRequests: string
|
|
29
35
|
}
|
|
30
36
|
|
|
37
|
+
/** The mark the approval gate hook reads from the question, as its `requestMark` writes it (contract test). */
|
|
38
|
+
const approvalMark = (id: string, hash: string): string => `\`approval ${id} ${hash}\``
|
|
39
|
+
|
|
31
40
|
export const WATCH_TEXTS: Record<PlanLanguage, WatchTexts> = {
|
|
32
41
|
[PLAN_LANGUAGE.de]: {
|
|
33
42
|
noBranch: 'kein Branch',
|
|
@@ -48,6 +57,11 @@ export const WATCH_TEXTS: Record<PlanLanguage, WatchTexts> = {
|
|
|
48
57
|
blockedDuty:
|
|
49
58
|
'Frage gegen Plan und Regeln prüfen, in der Spur beantworten oder per Dialog an den User; nie das Pane weiterreichen',
|
|
50
59
|
laneAlertDuty: 'Übergabe an der nächsten Phasengrenze anfordern (§ 8)',
|
|
60
|
+
approvalDuty: (id, hash) =>
|
|
61
|
+
`per AskUserQuestion fragen, die Frage enthält ${approvalMark(id, hash)} (Hash wie in der gezeigten Datei), Optionen „${APPROVAL_TEXTS.de.allow}“ und „${APPROVAL_TEXTS.de.deny}“ (§ 4)`,
|
|
62
|
+
parallelDuty: (phases) =>
|
|
63
|
+
`${phases} parallel möglich: dem User vorschlagen, Inhalt und Release-Dateien selbst prüfen (Tabelle prüft nur Pfade und Marker)`,
|
|
64
|
+
laneModelDuty: 'idle umstellen: /exit, dann claude --resume <session> --model <id> im selben Pane (§ 2 Schritt 6)',
|
|
51
65
|
checkoutRows: (term, count) => `${term}: ${count} ${count === 1 ? 'Zeile' : 'Zeilen'} nur im Checkout`,
|
|
52
66
|
otherPullRequests: 'andere PRs',
|
|
53
67
|
},
|
|
@@ -70,6 +84,12 @@ export const WATCH_TEXTS: Record<PlanLanguage, WatchTexts> = {
|
|
|
70
84
|
blockedDuty:
|
|
71
85
|
'vet the question against the plan and the rules, answer it in the lane or ask the user in a dialog; never hand the pane over',
|
|
72
86
|
laneAlertDuty: 'request the hand-over at the next phase boundary (§ 8)',
|
|
87
|
+
approvalDuty: (id, hash) =>
|
|
88
|
+
`ask through AskUserQuestion, the question contains ${approvalMark(id, hash)} (hash as in the file shown), options "${APPROVAL_TEXTS.en.allow}" and "${APPROVAL_TEXTS.en.deny}" (§ 4)`,
|
|
89
|
+
parallelDuty: (phases) =>
|
|
90
|
+
`${phases} can run in parallel: propose it to the user, check content and release files yourself (the table covers paths and markers only)`,
|
|
91
|
+
laneModelDuty:
|
|
92
|
+
'switch it while idle: /exit, then claude --resume <session> --model <id> in the same pane (§ 2 step 6)',
|
|
73
93
|
checkoutRows: (term, count) => `${term}: ${count} ${count === 1 ? 'row' : 'rows'} only in the checkout`,
|
|
74
94
|
otherPullRequests: 'other PRs',
|
|
75
95
|
},
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The comment the CI code review posts when a run found nothing; the default of `watch.feedback.noFindingTemplates`. */
|
|
2
|
+
export const CLEAN_REVIEW_TEXT = '## Code review\n\nNo issues found. Checked for bugs and CLAUDE.md compliance.'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The default of `watch.checks.failedTestPattern`: Bun's `(fail) <name>` line, with the duration ` [1.00ms]` or
|
|
6
|
+
* ` [1.2s]` after the name left out. The first group is the name.
|
|
7
|
+
*/
|
|
8
|
+
export const BUN_FAILED_TEST_PATTERN = '^\\(fail\\) (.+?)(?: \\[[\\d.]+m?s\\])?$'
|