jackherd 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/INSTALL.md +176 -0
  3. package/README.md +260 -315
  4. package/assets/branding/README.md +7 -3
  5. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  6. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  7. package/assets/branding/jackherd-logo-dark.svg +1 -1
  8. package/assets/icons/bark-dark.svg +1 -0
  9. package/assets/icons/bark.svg +1 -0
  10. package/assets/icons/bowl-dark.svg +1 -0
  11. package/assets/icons/bowl.svg +1 -0
  12. package/assets/icons/decide-dark.svg +1 -0
  13. package/assets/icons/decide.svg +1 -0
  14. package/assets/icons/ear-dark.svg +1 -0
  15. package/assets/icons/ear.svg +1 -0
  16. package/assets/icons/lanes-dark.svg +1 -0
  17. package/assets/icons/lanes.svg +1 -0
  18. package/assets/icons/plan-dark.svg +1 -0
  19. package/assets/icons/plan.svg +1 -0
  20. package/assets/icons/space.svg +1 -0
  21. package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
  22. package/dist/config.d.ts +14 -3
  23. package/dist/config.js +111 -10
  24. package/dist/plan-format.d.ts +180 -77
  25. package/dist/plan-format.js +292 -93
  26. package/docs/lanes-dark.svg +50 -0
  27. package/docs/lanes.svg +50 -1
  28. package/docs/skills-dark.svg +65 -0
  29. package/docs/skills.svg +65 -1
  30. package/docs/workflow-dark.svg +138 -0
  31. package/docs/workflow.svg +135 -169
  32. package/herdr-plugin/herdr-plugin.toml +6 -6
  33. package/herdr-plugin/open-plans.ts +1 -1
  34. package/herdr-plugin/restore-plans.ts +7 -7
  35. package/install.sh +1 -1
  36. package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
  37. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
  38. package/kit/hooks/hooks/approval-gate/index.ts +69 -0
  39. package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
  40. package/kit/hooks/hooks/approval-gate/request.ts +111 -0
  41. package/kit/hooks/hooks/approval-gate/run.ts +94 -0
  42. package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
  43. package/kit/hooks/hooks/approval-gate/store.ts +70 -0
  44. package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
  45. package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
  46. package/kit/hooks/hooks/merge-guard/run.ts +2 -1
  47. package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
  48. package/kit/hooks/hooks/model-gate/index.ts +58 -0
  49. package/kit/hooks/hooks/model-gate/run.ts +63 -0
  50. package/kit/hooks/hooks/model-log/index.ts +60 -0
  51. package/kit/hooks/hooks/model-log/run.ts +57 -0
  52. package/kit/hooks/hooks/model-log/store.ts +40 -0
  53. package/kit/hooks/hooks/model-log/transcript.ts +37 -0
  54. package/kit/hooks/hooks/shared/checkout.ts +22 -0
  55. package/kit/hooks/hooks/shared/command.ts +6 -0
  56. package/kit/hooks/hooks/shared/config.ts +21 -0
  57. package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
  58. package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
  59. package/kit/hooks/hooks/shared/files.ts +19 -0
  60. package/kit/hooks/hooks/shared/fresh.ts +4 -0
  61. package/kit/hooks/hooks/shared/json.ts +4 -0
  62. package/kit/hooks/hooks/shared/model-family.ts +48 -0
  63. package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
  64. package/kit/hooks/hooks/shared/private-directory.ts +29 -0
  65. package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
  66. package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
  67. package/kit/hooks/hooks.json +25 -0
  68. package/kit/workflow/skills/follow/SKILL.md +4 -3
  69. package/kit/workflow/skills/implement/SKILL.md +6 -6
  70. package/kit/workflow/skills/orchestrate/SKILL.md +113 -30
  71. package/kit/workflow/skills/plan/SKILL.md +5 -5
  72. package/kit/workflow/skills/start/SKILL.md +2 -2
  73. package/package.json +4 -1
  74. package/schemas/jackherd.schema.json +43 -5
  75. package/src/check/command.ts +31 -4
  76. package/src/check/deferral-arrows.ts +1 -1
  77. package/src/check/parallel-report.ts +23 -0
  78. package/src/check/plan-structure-check.ts +120 -53
  79. package/src/cli.ts +8 -2
  80. package/src/config/nested-quantifier.ts +73 -0
  81. package/src/config/repository-directory.ts +11 -0
  82. package/src/config/schema.ts +63 -8
  83. package/src/plan-format/board-prose.ts +36 -0
  84. package/src/plan-format/dependencies.ts +227 -0
  85. package/src/plan-format/documentation-gate.ts +16 -0
  86. package/src/plan-format/index.ts +23 -1
  87. package/src/plan-format/lanes.ts +35 -1
  88. package/src/plan-format/model-column.ts +52 -0
  89. package/src/plan-format/phases.ts +16 -1
  90. package/src/plan-format/plan-structure.ts +10 -28
  91. package/src/plan-format/progress.ts +7 -29
  92. package/src/plans/command.ts +3 -3
  93. package/src/plans/current-phase.ts +71 -0
  94. package/src/plans/ensure-panes.ts +2 -2
  95. package/src/plans/herd-command.ts +4 -0
  96. package/src/plans/lane-discovery.ts +14 -2
  97. package/src/plans/lane-tickets.ts +69 -0
  98. package/src/plans/layout.ts +182 -88
  99. package/src/plans/live.ts +5 -1
  100. package/src/plans/phase-models-read.ts +37 -0
  101. package/src/plans/phase-models.ts +70 -0
  102. package/src/plans/plan-panes.ts +8 -7
  103. package/src/plans/pull-requests.ts +78 -4
  104. package/src/plans/selection.ts +1 -40
  105. package/src/plans/settings.ts +9 -3
  106. package/src/plans/shown-with-more.ts +5 -0
  107. package/src/plans/snapshot.ts +82 -8
  108. package/src/plans/sprint-html.ts +2 -2
  109. package/src/plans/supervisor.ts +5 -4
  110. package/src/plans/symbols.ts +9 -6
  111. package/src/plans/texts.ts +30 -13
  112. package/src/plans/view.ts +1 -0
  113. package/src/sync/herdr-plugin.ts +1 -1
  114. package/src/sync/next-steps.ts +1 -1
  115. package/src/sync/render.ts +2 -1
  116. package/src/sync/setup-questions.ts +2 -2
  117. package/src/terminal/text.ts +4 -0
  118. package/src/terminal/types.ts +1 -1
  119. package/src/vocabulary/index.ts +39 -0
  120. package/src/watch/approvals.ts +96 -0
  121. package/src/watch/command.ts +154 -50
  122. package/src/watch/failed-tests.ts +52 -0
  123. package/src/watch/failure-read.ts +56 -0
  124. package/src/watch/failures.ts +107 -0
  125. package/src/watch/feedback-item.ts +21 -0
  126. package/src/watch/feedback-read.ts +2 -1
  127. package/src/watch/feedback.ts +37 -28
  128. package/src/watch/gh-process.ts +53 -0
  129. package/src/watch/gh.ts +10 -20
  130. package/src/watch/heartbeat-progress.ts +1 -1
  131. package/src/watch/lane-model.ts +54 -0
  132. package/src/watch/lane-parallel.ts +46 -0
  133. package/src/watch/lane-progress.ts +28 -4
  134. package/src/watch/lane-report.ts +62 -9
  135. package/src/watch/lane-sources.ts +1 -1
  136. package/src/watch/no-finding.ts +47 -0
  137. package/src/watch/plan-badges-read.ts +19 -7
  138. package/src/watch/plan-badges.ts +59 -7
  139. package/src/watch/plan-views.ts +5 -5
  140. package/src/watch/previews.ts +3 -9
  141. package/src/watch/pull-requests.ts +12 -2
  142. package/src/watch/recorded-reads.ts +25 -0
  143. package/src/watch/source-fields.ts +4 -0
  144. package/src/watch/source-tickets.ts +99 -0
  145. package/src/watch/sources.ts +62 -15
  146. package/src/watch/texts.ts +21 -1
  147. package/src/watch/watch-defaults.ts +8 -0
  148. package/src/watch/watch-events.ts +64 -4
  149. package/src/watch/watcher-lock.ts +1 -1
  150. package/src/watch/workflow-runs.ts +5 -7
  151. package/docs/lanes.mmd +0 -9
  152. package/docs/skills.mmd +0 -10
@@ -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 plans view shows beside a pull request: the checked URL of its preview, the feedback since its last push,
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 = { preview: string | null; feedback: number; changesRequested: boolean }
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
- /** The badges the view draws and why there may be none. */
26
- export type WatchReading = { badges: WatchBadges; health: WatchHealth }
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, and the requested changes, which a push keeps. A value
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
+ }
@@ -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 plans views: its own start, or a poll that found lanes added to the watched set
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 `plans --ensure` was already printed; a run that works clears it. */
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) => `plans --ensure: ${line}`), views: { failureReported: false } }
37
+ return { lines: ensured.lines.map((line) => `herd --ensure: ${line}`), views: { failureReported: false } }
38
38
  if (views.failureReported) return { lines: [], views }
39
- return { lines: [`plans --ensure failed, still watching: ${ensured.error}`], views: { failureReported: true } }
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 plans views when `trigger` calls for it, as `jh plans --ensure` does. A failure
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 => {
@@ -1,4 +1,5 @@
1
- import { FEEDBACK_FORM, type FeedbackFilter, type FeedbackItem, type FeedbackTarget, isReadTime } from './feedback'
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 plans view showed it); `stamps` maps `key` to `stamp`.
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 { type ChecksState, checksOf, type GhPullRequest, isGhPullRequest } from '../plans/pull-requests'
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 plans view's
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
+ )
@@ -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
- /** The paths of an entry's fields: `id` and `updatedAt`, the content fields, and any further named field. */
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: SourcePath
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 = { seen: SeenEntry[] | null; failingSince: number | null }
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) ? [fields.title, fields.author, fields.body, fields.url] : [fields.updatedAt]
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
- /** One entry after `map`, or null when its id or updatedAt is missing or no text. */
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 { ...fields, fingerprint: contentFingerprint(fields, map), values: valuesOf(entry, fields, settings) }
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
- if (!record?.seen) return []
261
- const seen = new Map(record.seen.map((entry) => [entry[0], entry]))
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)
@@ -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\\])?$'