jackherd 0.0.1 → 1.0.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 (250) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +38 -0
  4. package/README.md +1121 -2
  5. package/assets/branding/README.md +62 -0
  6. package/assets/branding/jackherd-icon-256.png +0 -0
  7. package/assets/branding/jackherd-icon-512.png +0 -0
  8. package/assets/branding/jackherd-icon-64.png +0 -0
  9. package/assets/branding/jackherd-icon-small-32.png +0 -0
  10. package/assets/branding/jackherd-icon-small-64.png +0 -0
  11. package/assets/branding/jackherd-icon-small.svg +1 -0
  12. package/assets/branding/jackherd-icon.svg +1 -0
  13. package/assets/branding/jackherd-logo-1024.png +0 -0
  14. package/assets/branding/jackherd-logo-512.png +0 -0
  15. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  16. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  17. package/assets/branding/jackherd-logo-dark.svg +1 -0
  18. package/assets/branding/jackherd-logo-mark-1024.png +0 -0
  19. package/assets/branding/jackherd-logo-mark-512.png +0 -0
  20. package/assets/branding/jackherd-logo-mark.svg +1 -0
  21. package/assets/branding/jackherd-logo.svg +1 -0
  22. package/dist/chunk-R6MGHCD7.js +93 -0
  23. package/dist/config.d.ts +167 -0
  24. package/dist/config.js +401 -0
  25. package/dist/plan-format.d.ts +325 -0
  26. package/dist/plan-format.js +636 -0
  27. package/docs/lanes.mmd +9 -0
  28. package/docs/lanes.svg +1 -0
  29. package/docs/skills.mmd +10 -0
  30. package/docs/skills.svg +1 -0
  31. package/docs/workflow.svg +172 -0
  32. package/herdr-plugin/herdr-plugin.toml +22 -0
  33. package/herdr-plugin/open-plans.ts +43 -0
  34. package/herdr-plugin/restore-plans.ts +107 -0
  35. package/install.sh +157 -0
  36. package/kit/hooks/hooks/git-guard/decision.ts +438 -0
  37. package/kit/hooks/hooks/git-guard/index.ts +38 -0
  38. package/kit/hooks/hooks/git-guard/shell.ts +407 -0
  39. package/kit/hooks/hooks/watch-guard/background.ts +36 -0
  40. package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
  41. package/kit/hooks/hooks/watch-guard/index.ts +11 -0
  42. package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
  43. package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
  44. package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
  45. package/kit/hooks/hooks.json +30 -0
  46. package/kit/hooks/statusline.sh +72 -0
  47. package/kit/review/agents/devops.md +11 -0
  48. package/kit/review/agents/public-content.md +39 -0
  49. package/kit/review/agents/security-audit.md +11 -0
  50. package/kit/review/agents/uncle-bob.md +11 -0
  51. package/kit/review/skills/devops/SKILL.md +50 -0
  52. package/kit/review/skills/diagnose/SKILL.md +140 -0
  53. package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
  54. package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
  55. package/kit/review/skills/pre-commit/SKILL.md +100 -0
  56. package/kit/review/skills/review/SKILL.md +43 -0
  57. package/kit/review/skills/security-audit/SKILL.md +65 -0
  58. package/kit/review/skills/uncle-bob/SKILL.md +43 -0
  59. package/kit/rules/rules/kit/arc42.md +89 -0
  60. package/kit/rules/rules/kit/architecture-files.md +87 -0
  61. package/kit/rules/rules/kit/code-style.md +159 -0
  62. package/kit/rules/rules/kit/pull-requests.md +59 -0
  63. package/kit/rules/rules/kit/testing.md +91 -0
  64. package/kit/rules/rules/kit/writing-for-agents.md +110 -0
  65. package/kit/workflow/agents/explore.md +25 -0
  66. package/kit/workflow/agents/implement.md +16 -0
  67. package/kit/workflow/agents/plan.md +13 -0
  68. package/kit/workflow/agents/spec-review.md +96 -0
  69. package/kit/workflow/skills/follow/SKILL.md +48 -0
  70. package/kit/workflow/skills/grill/SKILL.md +60 -0
  71. package/kit/workflow/skills/implement/SKILL.md +107 -0
  72. package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
  73. package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
  74. package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
  75. package/kit/workflow/skills/plan/SKILL.md +118 -0
  76. package/kit/workflow/skills/plan/references/template-English.md +92 -0
  77. package/kit/workflow/skills/plan/references/template-German.md +92 -0
  78. package/kit/workflow/skills/start/SKILL.md +47 -0
  79. package/package.json +63 -5
  80. package/schemas/jackherd.schema.json +601 -0
  81. package/src/check/changes.ts +45 -0
  82. package/src/check/command-line.ts +12 -0
  83. package/src/check/command.ts +136 -0
  84. package/src/check/deferral-arrows.ts +41 -0
  85. package/src/check/execute.ts +29 -0
  86. package/src/check/glob.ts +15 -0
  87. package/src/check/plan-structure-check.ts +135 -0
  88. package/src/check/plan.ts +106 -0
  89. package/src/check/public-content-check.ts +32 -0
  90. package/src/check/public-content-facts.ts +177 -0
  91. package/src/check/public-content.ts +216 -0
  92. package/src/check/report.ts +55 -0
  93. package/src/check/result.ts +42 -0
  94. package/src/check/roots.ts +36 -0
  95. package/src/check/routing.ts +22 -0
  96. package/src/check/schema.ts +83 -0
  97. package/src/check/settings.ts +44 -0
  98. package/src/claude/paths.ts +14 -0
  99. package/src/cli.ts +46 -0
  100. package/src/config/command.ts +45 -0
  101. package/src/config/detect.ts +196 -0
  102. package/src/config/index.ts +2 -0
  103. package/src/config/json-schema.ts +5 -0
  104. package/src/config/legacy-names.ts +56 -0
  105. package/src/config/link-kinds.ts +25 -0
  106. package/src/config/location.ts +46 -0
  107. package/src/config/read-config.ts +42 -0
  108. package/src/config/repository-facts.ts +19 -0
  109. package/src/config/repository-values.ts +46 -0
  110. package/src/config/schema.ts +302 -0
  111. package/src/context/command.ts +39 -0
  112. package/src/context/coordinator-name.ts +16 -0
  113. package/src/context/fill.ts +69 -0
  114. package/src/context/index.ts +10 -0
  115. package/src/context/level.ts +22 -0
  116. package/src/context/options.ts +29 -0
  117. package/src/context/report.ts +42 -0
  118. package/src/context/session-log.ts +91 -0
  119. package/src/plan-format/drift.ts +69 -0
  120. package/src/plan-format/header.ts +27 -0
  121. package/src/plan-format/index.ts +9 -0
  122. package/src/plan-format/lanes.ts +128 -0
  123. package/src/plan-format/markdown.ts +98 -0
  124. package/src/plan-format/phase-tally.ts +55 -0
  125. package/src/plan-format/phases.ts +224 -0
  126. package/src/plan-format/plan-structure.ts +150 -0
  127. package/src/plan-format/progress.ts +202 -0
  128. package/src/plan-format/sprint-document.ts +69 -0
  129. package/src/plan-format/terminal-text.ts +28 -0
  130. package/src/plans/board-choice.ts +60 -0
  131. package/src/plans/board-pick.ts +72 -0
  132. package/src/plans/board-rows.ts +62 -0
  133. package/src/plans/command.ts +60 -0
  134. package/src/plans/ensure-panes.ts +71 -0
  135. package/src/plans/error-text.ts +5 -0
  136. package/src/plans/fetch.ts +96 -0
  137. package/src/plans/git.ts +145 -0
  138. package/src/plans/github.ts +35 -0
  139. package/src/plans/lane-discovery.ts +34 -0
  140. package/src/plans/lane-plans.ts +80 -0
  141. package/src/plans/layout.ts +881 -0
  142. package/src/plans/link-chips.ts +74 -0
  143. package/src/plans/live.ts +336 -0
  144. package/src/plans/markdown-html.ts +140 -0
  145. package/src/plans/options.ts +15 -0
  146. package/src/plans/phase-source.ts +71 -0
  147. package/src/plans/plan-chips.ts +75 -0
  148. package/src/plans/plan-panes.ts +206 -0
  149. package/src/plans/planned.ts +117 -0
  150. package/src/plans/pull-requests.ts +107 -0
  151. package/src/plans/restart-decision.ts +42 -0
  152. package/src/plans/selection.ts +352 -0
  153. package/src/plans/settings.ts +68 -0
  154. package/src/plans/single-flight.ts +25 -0
  155. package/src/plans/snapshot.ts +300 -0
  156. package/src/plans/sprint-export.ts +103 -0
  157. package/src/plans/sprint-html.ts +301 -0
  158. package/src/plans/sprint-page.ts +287 -0
  159. package/src/plans/supervisor.ts +124 -0
  160. package/src/plans/symbols.ts +54 -0
  161. package/src/plans/texts.ts +103 -0
  162. package/src/plans/unlisted-lanes.ts +45 -0
  163. package/src/plans/view.ts +68 -0
  164. package/src/plans/worked-phases.ts +53 -0
  165. package/src/sync/checksum.ts +13 -0
  166. package/src/sync/choice.ts +14 -0
  167. package/src/sync/command-context.ts +113 -0
  168. package/src/sync/command.ts +94 -0
  169. package/src/sync/error-message.ts +1 -0
  170. package/src/sync/gitignore.ts +45 -0
  171. package/src/sync/global-kit.ts +176 -0
  172. package/src/sync/herdr-plugin.ts +175 -0
  173. package/src/sync/if-changed.ts +109 -0
  174. package/src/sync/init-command.ts +239 -0
  175. package/src/sync/init.ts +136 -0
  176. package/src/sync/kit-hooks.ts +18 -0
  177. package/src/sync/kit-path.ts +77 -0
  178. package/src/sync/kit-source.ts +169 -0
  179. package/src/sync/kit.ts +9 -0
  180. package/src/sync/legacy-files.ts +116 -0
  181. package/src/sync/manifest.ts +44 -0
  182. package/src/sync/next-steps.ts +42 -0
  183. package/src/sync/plan-file.ts +154 -0
  184. package/src/sync/plan-hooks.ts +165 -0
  185. package/src/sync/project-fs.ts +132 -0
  186. package/src/sync/render.ts +60 -0
  187. package/src/sync/report.ts +96 -0
  188. package/src/sync/settings-hooks.ts +90 -0
  189. package/src/sync/settings-migration.ts +94 -0
  190. package/src/sync/settings-offer.ts +168 -0
  191. package/src/sync/setup-questions.ts +70 -0
  192. package/src/sync/setup.ts +52 -0
  193. package/src/sync/sync-plan.ts +120 -0
  194. package/src/sync/sync-result.ts +9 -0
  195. package/src/sync/sync.ts +307 -0
  196. package/src/sync/tools.ts +52 -0
  197. package/src/terminal/herdr.ts +91 -0
  198. package/src/terminal/index.ts +13 -0
  199. package/src/terminal/none.ts +7 -0
  200. package/src/terminal/open.ts +39 -0
  201. package/src/terminal/text.ts +159 -0
  202. package/src/terminal/types.ts +30 -0
  203. package/src/terminal/width.ts +90 -0
  204. package/src/update/command.ts +65 -0
  205. package/src/update/package-manager.ts +56 -0
  206. package/src/update/update.ts +105 -0
  207. package/src/version/installed-version.ts +32 -0
  208. package/src/version/release.ts +22 -0
  209. package/src/version/skill-diff.ts +63 -0
  210. package/src/version/started-kit.ts +14 -0
  211. package/src/vocabulary/index.ts +132 -0
  212. package/src/watch/base-tip.ts +18 -0
  213. package/src/watch/command.ts +919 -0
  214. package/src/watch/coordinator-fill.ts +76 -0
  215. package/src/watch/feedback-read.ts +73 -0
  216. package/src/watch/feedback.ts +308 -0
  217. package/src/watch/gh.ts +22 -0
  218. package/src/watch/github-read.ts +175 -0
  219. package/src/watch/herdr.ts +11 -0
  220. package/src/watch/json-guards.ts +9 -0
  221. package/src/watch/kit-update.ts +18 -0
  222. package/src/watch/lane-fill.ts +36 -0
  223. package/src/watch/lane-message.ts +99 -0
  224. package/src/watch/lane-progress.ts +44 -0
  225. package/src/watch/lane-report-file.ts +53 -0
  226. package/src/watch/lane-report.ts +270 -0
  227. package/src/watch/lane-search.ts +41 -0
  228. package/src/watch/lane-sources.ts +46 -0
  229. package/src/watch/lane-watch.ts +121 -0
  230. package/src/watch/monitor-tool.ts +19 -0
  231. package/src/watch/options.ts +79 -0
  232. package/src/watch/plan-badges-read.ts +30 -0
  233. package/src/watch/plan-badges.ts +66 -0
  234. package/src/watch/plan-views.ts +59 -0
  235. package/src/watch/preview-read.ts +60 -0
  236. package/src/watch/previews.ts +234 -0
  237. package/src/watch/pull-requests.ts +117 -0
  238. package/src/watch/single-report.ts +15 -0
  239. package/src/watch/skill-change.ts +61 -0
  240. package/src/watch/source-dry-run-command.ts +35 -0
  241. package/src/watch/source-dry-run.ts +124 -0
  242. package/src/watch/source-fields.ts +9 -0
  243. package/src/watch/source-path.ts +40 -0
  244. package/src/watch/source-run.ts +90 -0
  245. package/src/watch/sources.ts +344 -0
  246. package/src/watch/stream-output.ts +14 -0
  247. package/src/watch/texts.ts +76 -0
  248. package/src/watch/watch-events.ts +399 -0
  249. package/src/watch/watcher-lock.ts +145 -0
  250. package/src/watch/workflow-runs.ts +96 -0
@@ -0,0 +1,344 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { terminalSafe } from '../plan-format/terminal-text'
3
+ import { isRecord } from './json-guards'
4
+ import { previewUrlOf } from './previews'
5
+ import { SOURCE_FIELD } from './source-fields'
6
+ import { ROOT_PATH, type SourcePath, selectPath } from './source-path'
7
+
8
+ export const SOURCE_EVENT = 'source'
9
+
10
+ export const SOURCE_CHANGE = { new: 'new', updated: 'updated' } as const
11
+
12
+ export type SourceChange = (typeof SOURCE_CHANGE)[keyof typeof SOURCE_CHANGE]
13
+
14
+ /** Why a run of a source failed, as a class only: never its stderr, which may carry a token. `exit <code>` comes from `exitFailure`. */
15
+ export const SOURCE_FAILURE = {
16
+ timeout: 'timeout',
17
+ tooLarge: 'output too large',
18
+ unreadable: 'output unreadable',
19
+ notStarted: 'not started',
20
+ noJson: 'no JSON',
21
+ shape: 'unexpected shape',
22
+ } as const
23
+
24
+ /** The paths of an entry's fields: `id` and `updatedAt`, the content fields, and any further named field. */
25
+ export type SourceMap = {
26
+ id: SourcePath
27
+ updatedAt: SourcePath
28
+ title?: SourcePath
29
+ url?: SourcePath
30
+ body?: SourcePath
31
+ author?: SourcePath
32
+ } & Record<string, SourcePath | undefined>
33
+
34
+ /**
35
+ * One `watch.sources` entry as the watcher uses it; `every` is in minutes, `items` the path of the list (the root
36
+ * without it), `reportLimit` the most entries one report shows of it.
37
+ */
38
+ export type SourceSettings = {
39
+ name: string
40
+ command: readonly string[]
41
+ every: number
42
+ items?: SourcePath
43
+ map: SourceMap
44
+ ignoreAuthors: readonly string[]
45
+ reportLimit: number
46
+ lane?: string
47
+ /** The mapped fields whose change reports, in place of the content fingerprint; without it the fingerprint decides. */
48
+ reportOn?: readonly string[]
49
+ }
50
+
51
+ /**
52
+ * One entry of a source's output after `map`; `url` is a checked https URL or null, a missing text field is empty.
53
+ * `fingerprint` stands for its content (`contentFingerprint`): it changes with the text, not with the time alone.
54
+ */
55
+ export type SourceItem = {
56
+ id: string
57
+ updatedAt: string
58
+ title: string
59
+ url: string | null
60
+ body: string
61
+ author: string | null
62
+ fingerprint: string
63
+ /** The `reportOn` fields as the state keeps them (`storedValue`); empty without `reportOn`. */
64
+ values: Record<string, string>
65
+ }
66
+
67
+ export type SourceResult = { ok: true; items: SourceItem[] } | { ok: false; reason: string }
68
+
69
+ /** The outcome of one run of the source `name`. */
70
+ export type SourceRun = { name: string; result: SourceResult }
71
+
72
+ /** `[id, fingerprint]`, or with `reportOn` `[id, fingerprint, values]`: the stored value of each `reportOn` field. */
73
+ export type SeenEntry = [string, string] | [string, string, Record<string, string>]
74
+
75
+ /**
76
+ * What the watcher keeps per source: `seen` holds a `SeenEntry` per id, the entries seen most recently last, or null
77
+ * before the first successful run (which is a baseline); `failingSince` is the epoch milliseconds of the first failed
78
+ * run of the current streak, null while the source answers. Of an entry's text only the `reportOn` fields are kept.
79
+ */
80
+ export type SourceRecord = { seen: SeenEntry[] | null; failingSince: number | null }
81
+
82
+ export type SourceState = Record<string, SourceRecord>
83
+
84
+ /** A `reportOn` field whose stored value changed; an empty value is `''`. */
85
+ export type FieldChange = { field: string; from: string; to: string }
86
+
87
+ export type SourceItemEvent = {
88
+ kind: typeof SOURCE_EVENT
89
+ name: string
90
+ lane: string | null
91
+ id: string
92
+ title: string
93
+ change: SourceChange
94
+ body: string
95
+ url: string | null
96
+ /** With `reportOn`, the fields an `updated` reports. */
97
+ changes?: FieldChange[]
98
+ }
99
+
100
+ export type SourceFailureEvent = { kind: typeof SOURCE_EVENT; name: string; lane: string | null; failure: string }
101
+
102
+ /** The entries of one run past `reportLimit`: seen and recorded, only counted in the report. */
103
+ export type SourceOverflowEvent = { kind: typeof SOURCE_EVENT; name: string; lane: string | null; more: number }
104
+
105
+ export type SourceEvent = SourceItemEvent | SourceFailureEvent | SourceOverflowEvent
106
+
107
+ const SEEN_LIMIT = 1000
108
+ const FINGERPRINT_LENGTH = 16
109
+ const STORED_VALUE_LENGTH = 200
110
+ const STORED_PREFIX_LENGTH = 180
111
+ const VALUE_HASH_LENGTH = 8
112
+ const MINUTE_MS = 60_000
113
+
114
+ export const exitFailure = (code: number): string => `exit ${code}`
115
+
116
+ export const emptySourceState = (): SourceState => ({})
117
+
118
+ const scalarText = (value: unknown): string | null => {
119
+ if (typeof value === 'string') return value
120
+ return typeof value === 'number' && Number.isFinite(value) ? String(value) : null
121
+ }
122
+
123
+ /** The text at `path` of an entry: a string, or a finite number as text; null for anything else or no path. */
124
+ export const textAt = (entry: unknown, path: SourcePath | undefined): string | null =>
125
+ path === undefined ? null : scalarText(selectPath(entry, path))
126
+
127
+ type MappedFields = Omit<SourceItem, 'fingerprint' | 'values'>
128
+
129
+ const hashOf = (text: string, length: number): string =>
130
+ createHash('sha256').update(text).digest('hex').slice(0, length)
131
+
132
+ /** A value as the state keeps it: whole up to 200 characters, else its first 180, `…` and a hash of the whole. */
133
+ export const storedValue = (text: string): string =>
134
+ text.length <= STORED_VALUE_LENGTH
135
+ ? text
136
+ : `${text.slice(0, STORED_PREFIX_LENGTH)}…${hashOf(text, VALUE_HASH_LENGTH)}`
137
+
138
+ const hasContentField = (map: SourceMap): boolean =>
139
+ [map.title, map.author, map.body, map.url].some((path) => path !== undefined)
140
+
141
+ /**
142
+ * 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
+ * a status) is no change. Without any of them mapped it is `updatedAt`, else the entry could never change.
144
+ */
145
+ const contentFingerprint = (fields: MappedFields, map: SourceMap): string => {
146
+ const content = hasContentField(map) ? [fields.title, fields.author, fields.body, fields.url] : [fields.updatedAt]
147
+ return hashOf(JSON.stringify(content), FINGERPRINT_LENGTH)
148
+ }
149
+
150
+ /** A value as one line: whitespace runs one space, control characters gone, trimmed. */
151
+ export const lineText = (text: string): string => terminalSafe(text.replace(/\s+/g, ' ')).trim()
152
+
153
+ /** The value of a `reportOn` field: the checked URL for `url`, the mapped text otherwise, `''` when missing. */
154
+ const reportedValue = (entry: unknown, field: string, fields: MappedFields, map: SourceMap): string => {
155
+ if (field === SOURCE_FIELD.url) return fields.url ?? ''
156
+ return Object.hasOwn(map, field) ? (textAt(entry, map[field]) ?? '') : ''
157
+ }
158
+
159
+ const valuesOf = (
160
+ entry: unknown,
161
+ fields: MappedFields,
162
+ settings: Pick<SourceSettings, 'map' | 'reportOn'>,
163
+ ): Record<string, string> =>
164
+ Object.fromEntries(
165
+ (settings.reportOn ?? []).map((field) => [
166
+ field,
167
+ storedValue(lineText(reportedValue(entry, field, fields, settings.map))),
168
+ ]),
169
+ )
170
+
171
+ /** One entry after `map`, or null when its id or updatedAt is missing or no text. */
172
+ const sourceItemOf = (entry: unknown, settings: Pick<SourceSettings, 'map' | 'reportOn'>): SourceItem | null => {
173
+ const { map } = settings
174
+ const id = textAt(entry, map.id)
175
+ const updatedAt = textAt(entry, map.updatedAt)
176
+ if (id === null || id === '' || updatedAt === null) return null
177
+ const url = textAt(entry, map.url)
178
+ const fields = {
179
+ id,
180
+ updatedAt,
181
+ title: textAt(entry, map.title) ?? '',
182
+ url: url === null ? null : previewUrlOf(url, undefined),
183
+ body: textAt(entry, map.body) ?? '',
184
+ author: textAt(entry, map.author),
185
+ }
186
+ return { ...fields, fingerprint: contentFingerprint(fields, map), values: valuesOf(entry, fields, settings) }
187
+ }
188
+
189
+ /** The items once each: an id listed twice counts with its last entry. */
190
+ const uniqueById = (items: readonly SourceItem[]): SourceItem[] => [
191
+ ...new Map(items.map((item) => [item.id, item])).values(),
192
+ ]
193
+
194
+ const parseJson = (text: string): { ok: true; value: unknown } | { ok: false } => {
195
+ try {
196
+ return { ok: true, value: JSON.parse(text) }
197
+ } catch {
198
+ return { ok: false }
199
+ }
200
+ }
201
+
202
+ /** The raw entries of a source's stdout, the list at `items`; `no JSON` or `unexpected shape` when there is none. */
203
+ export const listAt = (
204
+ stdout: string,
205
+ items: SourcePath | undefined,
206
+ ): { ok: true; list: unknown[] } | { ok: false; reason: string } => {
207
+ const parsed = parseJson(stdout)
208
+ if (!parsed.ok) return { ok: false, reason: SOURCE_FAILURE.noJson }
209
+ const list = selectPath(parsed.value, items ?? ROOT_PATH)
210
+ return Array.isArray(list) ? { ok: true, list } : { ok: false, reason: SOURCE_FAILURE.shape }
211
+ }
212
+
213
+ /** A source's stdout read over `items` and `map`; `no JSON` or `unexpected shape` when it cannot be. */
214
+ export const parseSourceOutput = (
215
+ stdout: string,
216
+ settings: Pick<SourceSettings, 'items' | 'map' | 'reportOn'>,
217
+ ): SourceResult => {
218
+ const raw = listAt(stdout, settings.items)
219
+ if (!raw.ok) return raw
220
+ const items = raw.list.map((entry) => sourceItemOf(entry, settings))
221
+ if (items.some((item) => item === null)) return { ok: false, reason: SOURCE_FAILURE.shape }
222
+ return { ok: true, items: uniqueById(items as SourceItem[]) }
223
+ }
224
+
225
+ /** Whether a source last started at `lastRunAt` (undefined: never in this process) is due again at `now`. */
226
+ export const isSourceDue = (lastRunAt: number | undefined, everyMinutes: number, now: number): boolean =>
227
+ lastRunAt === undefined || now - lastRunAt >= everyMinutes * MINUTE_MS
228
+
229
+ /** Whether `ignoreAuthors` holds the item back; case is ignored. */
230
+ export const isIgnoredAuthor = (item: SourceItem, ignoreAuthors: readonly string[]): boolean =>
231
+ item.author !== null && ignoreAuthors.some((author) => author.toLowerCase() === item.author?.toLowerCase())
232
+
233
+ type ItemChange = { change: SourceChange; changes?: FieldChange[] }
234
+
235
+ /** The `reportOn` fields whose stored value differs; a field without a stored value is a baseline, not a change. */
236
+ const fieldChanges = (stored: Record<string, string>, item: SourceItem): FieldChange[] =>
237
+ Object.entries(item.values).flatMap(([field, to]) => {
238
+ const from = Object.hasOwn(stored, field) ? stored[field] : undefined
239
+ return from === undefined || from === to ? [] : [{ field, from, to }]
240
+ })
241
+
242
+ /** With `reportOn` the stored values decide (an entry stored without them is a baseline), else the fingerprint. */
243
+ const changeOf = (
244
+ seen: ReadonlyMap<string, SeenEntry>,
245
+ item: SourceItem,
246
+ settings: SourceSettings,
247
+ ): ItemChange | null => {
248
+ const known = seen.get(item.id)
249
+ if (known === undefined) return { change: SOURCE_CHANGE.new }
250
+ if (settings.reportOn === undefined) return known[1] === item.fingerprint ? null : { change: SOURCE_CHANGE.updated }
251
+ const changes = known[2] === undefined ? [] : fieldChanges(known[2], item)
252
+ return changes.length === 0 ? null : { change: SOURCE_CHANGE.updated, changes }
253
+ }
254
+
255
+ const itemEvents = (
256
+ record: SourceRecord | undefined,
257
+ items: readonly SourceItem[],
258
+ settings: SourceSettings,
259
+ ): SourceEvent[] => {
260
+ if (!record?.seen) return []
261
+ const seen = new Map(record.seen.map((entry) => [entry[0], entry]))
262
+ const lane = settings.lane ?? null
263
+ const events = items.flatMap((item): SourceItemEvent[] => {
264
+ const found = changeOf(seen, item, settings)
265
+ if (found === null || isIgnoredAuthor(item, settings.ignoreAuthors)) return []
266
+ const { id, title, body, url } = item
267
+ return [{ kind: SOURCE_EVENT, name: settings.name, lane, id, title, body, url, ...found }]
268
+ })
269
+ const more = events.length - settings.reportLimit
270
+ if (more <= 0) return events
271
+ return [...events.slice(0, settings.reportLimit), { kind: SOURCE_EVENT, name: settings.name, lane, more }]
272
+ }
273
+
274
+ /**
275
+ * The events of `runs` against `previous`: per item `new` for an unknown id, `updated` for a changed fingerprint (with
276
+ * `reportOn`: a changed stored value of a listed field, named in `changes`), none
277
+ * for an author in `ignoreAuthors`, at most `reportLimit` per source and the rest counted in one event; a source's
278
+ * first successful run is a baseline. A failure fires once per streak.
279
+ */
280
+ export const sourceEvents = (
281
+ previous: SourceState,
282
+ runs: readonly SourceRun[],
283
+ settings: readonly SourceSettings[],
284
+ ): SourceEvent[] =>
285
+ runs.flatMap(({ name, result }): SourceEvent[] => {
286
+ const source = settings.find((candidate) => candidate.name === name)
287
+ if (!source) return []
288
+ const record = previous[name]
289
+ if (result.ok) return itemEvents(record, result.items, source)
290
+ const known = record?.failingSince != null
291
+ return known ? [] : [{ kind: SOURCE_EVENT, name, lane: source.lane ?? null, failure: result.reason }]
292
+ })
293
+
294
+ /** `[id, fingerprint]`, with the `reportOn` values as a third part when there are any. */
295
+ const seenEntryOf = ({ id, fingerprint, values }: SourceItem): SeenEntry =>
296
+ Object.keys(values).length === 0 ? [id, fingerprint] : [id, fingerprint, values]
297
+
298
+ /** The seen entries with `items` moved to the end, the most recent `SEEN_LIMIT` kept. */
299
+ const seenAfter = (seen: readonly SeenEntry[], items: readonly SourceItem[]): SeenEntry[] => {
300
+ const current = new Set(items.map(({ id }) => id))
301
+ const kept = seen.filter(([id]) => !current.has(id))
302
+ return [...kept, ...items.map(seenEntryOf)].slice(-SEEN_LIMIT)
303
+ }
304
+
305
+ const recordOf = (record: SourceRecord | undefined, result: SourceResult, now: number): SourceRecord =>
306
+ result.ok
307
+ ? { seen: seenAfter(record?.seen ?? [], result.items), failingSince: null }
308
+ : { seen: record?.seen ?? null, failingSince: record?.failingSince ?? now }
309
+
310
+ /**
311
+ * The state after `runs` for the sources configured as `names`: a run records its outcome, a source without a run keeps
312
+ * its record, a source no longer configured is dropped. Recording the same runs twice gives the same state.
313
+ */
314
+ export const recordSources = (
315
+ previous: SourceState,
316
+ runs: readonly SourceRun[],
317
+ names: readonly string[],
318
+ now: number,
319
+ ): SourceState =>
320
+ Object.fromEntries(
321
+ names.flatMap((name) => {
322
+ const run = runs.find((candidate) => candidate.name === name)
323
+ const record = run ? recordOf(previous[name], run.result, now) : previous[name]
324
+ return record ? [[name, record]] : []
325
+ }),
326
+ )
327
+
328
+ const isStoredValues = (value: unknown): boolean =>
329
+ isRecord(value) && Object.values(value).every((part) => typeof part === 'string')
330
+
331
+ const isSeenEntry = (value: unknown): boolean =>
332
+ Array.isArray(value) &&
333
+ (value.length === 2 || value.length === 3) &&
334
+ typeof value[0] === 'string' &&
335
+ typeof value[1] === 'string' &&
336
+ (value.length === 2 || isStoredValues(value[2]))
337
+
338
+ const isSourceRecord = (value: unknown): boolean =>
339
+ isRecord(value) &&
340
+ (value.seen === null || (Array.isArray(value.seen) && value.seen.every(isSeenEntry))) &&
341
+ (value.failingSince === null || typeof value.failingSince === 'number')
342
+
343
+ export const isSourceState = (value: unknown): value is SourceState =>
344
+ isRecord(value) && Object.values(value).every(isSourceRecord)
@@ -0,0 +1,14 @@
1
+ import { terminalSafe } from '../plan-format/terminal-text'
2
+ import { type LaneSnapshot, NO_AGENT } from './lane-watch'
3
+ import { WATCH_EVENT } from './watch-events'
4
+
5
+ /**
6
+ * The heartbeat of a stream, as one line, since every line of a stream reaches the coordinator as a
7
+ * message of its own: `=== heartbeat <time> · <lane> <status> · …`.
8
+ */
9
+ export const streamHeartbeatLine = (time: string, lanes: readonly string[], current: LaneSnapshot): string => {
10
+ const states = lanes.map((lane) => `${terminalSafe(lane)} ${terminalSafe(current[lane]?.status ?? NO_AGENT)}`)
11
+ return [`=== ${WATCH_EVENT.heartbeat} ${terminalSafe(time)}`, ...(states.length > 0 ? states : ['no lanes'])].join(
12
+ ' · ',
13
+ )
14
+ }
@@ -0,0 +1,76 @@
1
+ import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
2
+
3
+ /** The words of the watcher's status and duty lines, in the project's response language. */
4
+ export type WatchTexts = {
5
+ noBranch: string
6
+ noPullRequest: string
7
+ openPhase: string
8
+ /** The current phase ticked to the end in the lane's worktree, its ticks not yet on the base branch. */
9
+ tickedUnmerged: string
10
+ phase: string
11
+ startsAfter: string
12
+ planFinished: string
13
+ noPlan: string
14
+ noName: string
15
+ noValue: string
16
+ unnamedPane: (name: string) => string
17
+ noAgentNamed: (name: string) => string
18
+ /** The prefix of a merge's duty line, with the pull request number. */
19
+ afterMerge: (number: number) => string
20
+ mergedLaneDuty: string
21
+ otherLaneDuty: string
22
+ blockedDuty: string
23
+ /** What a lane whose context reached `alert` asks of the coordinator. */
24
+ laneAlertDuty: string
25
+ /** The reminder after the coordinator line: `<term>: <count> rows only in the checkout`. */
26
+ checkoutRows: (term: string, count: number) => string
27
+ /** The head of the status line of the user's own open pull requests without a lane. */
28
+ otherPullRequests: string
29
+ }
30
+
31
+ export const WATCH_TEXTS: Record<PlanLanguage, WatchTexts> = {
32
+ [PLAN_LANGUAGE.de]: {
33
+ noBranch: 'kein Branch',
34
+ noPullRequest: 'kein PR',
35
+ openPhase: 'offene Phase',
36
+ tickedUnmerged: 'abgehakt, nicht gemergt',
37
+ phase: 'Phase',
38
+ startsAfter: 'Startet nach',
39
+ planFinished: 'Plan fertig',
40
+ noPlan: 'kein Plan',
41
+ noName: 'kein Name',
42
+ noValue: 'kein Wert',
43
+ unnamedPane: (name) => `Pane ohne Namen, herdr agent rename "$HERDR_PANE_ID" ${name}`,
44
+ noAgentNamed: (name) => `kein Agent namens ${name}`,
45
+ afterMerge: (number) => `nach Merge #${number}`,
46
+ mergedLaneDuty: 'nächste Phase starten oder Spur schließen',
47
+ otherLaneDuty: 'rebasen, dann nächste Phase starten oder Grund nennen',
48
+ blockedDuty:
49
+ 'Frage gegen Plan und Regeln prüfen, in der Spur beantworten oder per Dialog an den User; nie das Pane weiterreichen',
50
+ laneAlertDuty: 'Übergabe an der nächsten Phasengrenze anfordern (§ 8)',
51
+ checkoutRows: (term, count) => `${term}: ${count} ${count === 1 ? 'Zeile' : 'Zeilen'} nur im Checkout`,
52
+ otherPullRequests: 'andere PRs',
53
+ },
54
+ [PLAN_LANGUAGE.en]: {
55
+ noBranch: 'no branch',
56
+ noPullRequest: 'no PR',
57
+ openPhase: 'open phase',
58
+ tickedUnmerged: 'ticked, not merged',
59
+ phase: 'phase',
60
+ startsAfter: 'starts after',
61
+ planFinished: 'plan finished',
62
+ noPlan: 'no plan',
63
+ noName: 'no name',
64
+ noValue: 'no value',
65
+ unnamedPane: (name) => `pane without a name, herdr agent rename "$HERDR_PANE_ID" ${name}`,
66
+ noAgentNamed: (name) => `no agent named ${name}`,
67
+ afterMerge: (number) => `after merge #${number}`,
68
+ mergedLaneDuty: 'start the next phase or close the lane',
69
+ otherLaneDuty: 'rebase, then start the next phase or name the reason to wait',
70
+ blockedDuty:
71
+ '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
+ laneAlertDuty: 'request the hand-over at the next phase boundary (§ 8)',
73
+ checkoutRows: (term, count) => `${term}: ${count} ${count === 1 ? 'row' : 'rows'} only in the checkout`,
74
+ otherPullRequests: 'other PRs',
75
+ },
76
+ }