jackherd 0.0.1 → 1.0.1

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 +65 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +38 -0
  4. package/README.md +1122 -3
  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,399 @@
1
+ import { CHECKS, type ChecksState } from '../plans/pull-requests'
2
+ import { type CoordinatorLevel, type CoordinatorReading, isContextLevel, levelStep } from './coordinator-fill'
3
+ import { emptyFeedbackState, FEEDBACK_EVENT, type FeedbackEvent, type FeedbackState, isFeedbackState } from './feedback'
4
+ import { isRecord } from './json-guards'
5
+ import { isLaneLevels, LANE_FILL_EVENT, type LaneFillEvent, type LaneLevels } from './lane-fill'
6
+ import { type LaneSnapshot, type SeenSeqs, seenSeqsOf } from './lane-watch'
7
+ import { emptyPreviewState, isPreviewState, PREVIEW_EVENT, type PreviewEvent, type PreviewState } from './previews'
8
+ import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
9
+ import { emptySourceState, isSourceState, SOURCE_EVENT, type SourceEvent, type SourceState } from './sources'
10
+ import { RUN_EVENT, RUN_STATUS, type WorkflowRun } from './workflow-runs'
11
+
12
+ /** What the coordinator is told besides a lane that stopped. The values are the words of the report. */
13
+ export const WATCH_EVENT = {
14
+ merged: 'merged',
15
+ checksFailed: 'checks failed',
16
+ checksPassed: 'checks passed',
17
+ deploy: 'deploy',
18
+ heartbeat: 'heartbeat',
19
+ ghUnavailable: 'gh unavailable',
20
+ coordinator: 'coordinator',
21
+ feedback: FEEDBACK_EVENT,
22
+ preview: PREVIEW_EVENT,
23
+ laneFill: LANE_FILL_EVENT,
24
+ source: SOURCE_EVENT,
25
+ } as const
26
+
27
+ export type PullRequestObservation = Omit<WatchedPullRequest, 'number' | 'branch'>
28
+
29
+ /** The settled verdict of a pull request's checks on one head commit, the unit that fires once. */
30
+ export type CheckVerdict = { headCommit: string; checks: ChecksState }
31
+
32
+ export type PullRequestRecord = PullRequestObservation & {
33
+ /** The last failed or passed verdict reported; pending and none never replace it. */
34
+ reportedChecks: CheckVerdict | null
35
+ /** Seen by the search for the user's own pull requests on a branch without a lane; kept until the record drops out. */
36
+ own?: true
37
+ }
38
+
39
+ export type PullRequestSnapshot = WatchedPullRequest
40
+
41
+ /**
42
+ * Everything the watcher remembers between returns. A lane, pull request number or run ID without an
43
+ * entry has never been seen, and its first sighting is a baseline, not an event.
44
+ */
45
+ export type WatchState = {
46
+ seenSeqs: SeenSeqs
47
+ pullRequests: Record<string, PullRequestRecord>
48
+ /** Per run ID: whether the run had completed when it was last seen. */
49
+ runs: Record<string, boolean>
50
+ /** Epoch milliseconds of the first failed `gh` call of the current streak; null while `gh` answers. */
51
+ ghDownSince: number | null
52
+ /** Epoch milliseconds of the last state recorded from an answering `gh`; null before the first one. */
53
+ savedAt: number | null
54
+ /**
55
+ * Lane → branch, kept while the lane is watched or a pull request of the branch is still listed:
56
+ * a deploy run that ends after its lane was closed still finds the lane's merge commit.
57
+ */
58
+ laneBranches: Record<string, string>
59
+ /** The coordinator's level when it was last read with a value; null before the first one (counts as `ok`). */
60
+ coordinatorLevel: CoordinatorLevel | null
61
+ /** The feedback seen on the lanes' open pull requests; kept apart from `pullRequests`, it is read over REST. */
62
+ feedback: FeedbackState
63
+ /** The previews seen on the lanes' open pull requests: per pull request the head last reported and the source versions. */
64
+ previews: PreviewState
65
+ /** The context level last read for each watched lane's agent; a rise above it fires `lane <name> <level>`. */
66
+ laneLevels: LaneLevels
67
+ /** Per configured source of `watch.sources`: the entries seen (`[id, fingerprint]`) and the start of a failure streak. */
68
+ sources: SourceState
69
+ /** Lane → change time of the report file last shown for it, null when it had none; a lane without an entry is unseen. */
70
+ reportMtimes: Record<string, number | null>
71
+ /** The lane lines of the status block last printed; null before the first report. */
72
+ statusLines: string[] | null
73
+ }
74
+
75
+ /** The kinds of the events a pull request fires. */
76
+ export const PULL_REQUEST_KINDS = [WATCH_EVENT.merged, WATCH_EVENT.checksFailed, WATCH_EVENT.checksPassed] as const
77
+
78
+ /** A lane's pull request, or an own one without a lane, which names its branch instead. */
79
+ export type PullRequestEvent = {
80
+ kind: (typeof PULL_REQUEST_KINDS)[number]
81
+ number: number
82
+ headCommit: string
83
+ mergeCommit: string | null
84
+ } & ({ lane: string } | { lane: null; branch: string })
85
+
86
+ /** A run of a lane's merge, or without a lane: of an own pull request's merge (`pullRequest`), else started by hand. */
87
+ export type DeployEvent = {
88
+ kind: typeof WATCH_EVENT.deploy
89
+ runId: number
90
+ trigger: string
91
+ conclusion: string | null
92
+ } & ({ lane: string } | { lane: null; pullRequest?: number })
93
+
94
+ export type SignalEvent =
95
+ | { kind: typeof WATCH_EVENT.heartbeat }
96
+ | { kind: typeof WATCH_EVENT.ghUnavailable; reason: string }
97
+
98
+ export type CoordinatorEvent = { kind: typeof WATCH_EVENT.coordinator } & CoordinatorReading
99
+
100
+ export type WatchEvent =
101
+ | PullRequestEvent
102
+ | DeployEvent
103
+ | SignalEvent
104
+ | CoordinatorEvent
105
+ | FeedbackEvent
106
+ | PreviewEvent
107
+ | LaneFillEvent
108
+ | SourceEvent
109
+
110
+ export const isPullRequestEvent = (event: WatchEvent): event is PullRequestEvent =>
111
+ (PULL_REQUEST_KINDS as readonly string[]).includes(event.kind)
112
+
113
+ /** `github` is null when `gh` failed; the pull requests and runs of the last good answer then stay as they were. */
114
+ export type Observation = {
115
+ lanes: LaneSnapshot
116
+ /** The branch checked out in each watched lane's worktree. */
117
+ laneBranches: Record<string, string>
118
+ github: {
119
+ pullRequests: readonly PullRequestSnapshot[]
120
+ runs: readonly WorkflowRun[]
121
+ /** The numbers of the user's own open pull requests without a lane; none when left out. */
122
+ own?: readonly number[]
123
+ } | null
124
+ }
125
+
126
+ export const emptyWatchState = (): WatchState => ({
127
+ seenSeqs: {},
128
+ pullRequests: {},
129
+ runs: {},
130
+ ghDownSince: null,
131
+ savedAt: null,
132
+ laneBranches: {},
133
+ coordinatorLevel: null,
134
+ feedback: emptyFeedbackState(),
135
+ previews: emptyPreviewState(),
136
+ laneLevels: {},
137
+ sources: emptySourceState(),
138
+ reportMtimes: {},
139
+ statusLines: null,
140
+ })
141
+
142
+ const isOneOf = (values: Record<string, string>, value: unknown): boolean =>
143
+ typeof value === 'string' && Object.values(values).includes(value)
144
+
145
+ /** A sequence or an epoch time: a number, or null when there is none yet. */
146
+ const isSeq = (value: unknown): value is number | null => value === null || typeof value === 'number'
147
+
148
+ const everyValue = (value: unknown, check: (entry: unknown) => boolean): boolean =>
149
+ isRecord(value) && Object.values(value).every(check)
150
+
151
+ const isCheckVerdict = (value: unknown): boolean =>
152
+ isRecord(value) && typeof value.headCommit === 'string' && isOneOf(CHECKS, value.checks)
153
+
154
+ const isPullRequestRecord = (value: unknown): boolean =>
155
+ isRecord(value) &&
156
+ isOneOf(PULL_REQUEST_STATE, value.state) &&
157
+ isOneOf(CHECKS, value.checks) &&
158
+ typeof value.headCommit === 'string' &&
159
+ (value.mergeCommit === null || typeof value.mergeCommit === 'string') &&
160
+ (value.reportedChecks === null || isCheckVerdict(value.reportedChecks)) &&
161
+ (value.own === undefined || value.own === true)
162
+
163
+ const isWatchState = (value: Record<string, unknown>): value is WatchState =>
164
+ everyValue(value.seenSeqs, isSeq) &&
165
+ everyValue(value.pullRequests, isPullRequestRecord) &&
166
+ everyValue(value.runs, (entry) => typeof entry === 'boolean') &&
167
+ isSeq(value.ghDownSince) &&
168
+ isSeq(value.savedAt) &&
169
+ everyValue(value.laneBranches, (entry) => typeof entry === 'string') &&
170
+ (value.coordinatorLevel === null || isContextLevel(value.coordinatorLevel)) &&
171
+ isFeedbackState(value.feedback) &&
172
+ isPreviewState(value.previews) &&
173
+ isLaneLevels(value.laneLevels) &&
174
+ isSourceState(value.sources) &&
175
+ everyValue(value.reportMtimes, isSeq) &&
176
+ (value.statusLines === null ||
177
+ (Array.isArray(value.statusLines) && value.statusLines.every((line) => typeof line === 'string')))
178
+
179
+ /**
180
+ * Reads the state file; throws on any other shape, so a damaged file is seen, not read as empty. The
181
+ * flat `Record<lane, seq>` of the earlier watcher is taken over as the seen sequences.
182
+ */
183
+ export const parseWatchState = (json: string): WatchState => {
184
+ const value: unknown = JSON.parse(json)
185
+ if (!isRecord(value)) throw new Error('watch state: unexpected shape')
186
+ if (isRecord(value.seenSeqs)) {
187
+ const complete = {
188
+ savedAt: null,
189
+ laneBranches: {},
190
+ coordinatorLevel: null,
191
+ feedback: emptyFeedbackState(),
192
+ previews: emptyPreviewState(),
193
+ laneLevels: {},
194
+ sources: emptySourceState(),
195
+ reportMtimes: {},
196
+ statusLines: null,
197
+ ...value,
198
+ }
199
+ if (!isWatchState(complete)) throw new Error('watch state: unexpected shape')
200
+ return complete
201
+ }
202
+ if (!Object.values(value).every(isSeq)) throw new Error('watch state: unexpected shape')
203
+ return { ...emptyWatchState(), seenSeqs: value as SeenSeqs }
204
+ }
205
+
206
+ const isSettled = (checks: ChecksState): boolean => checks === CHECKS.failed || checks === CHECKS.passed
207
+
208
+ const reportedChecksOf = (known: PullRequestRecord | undefined, observed: PullRequestSnapshot): CheckVerdict | null =>
209
+ isSettled(observed.checks)
210
+ ? { headCommit: observed.headCommit, checks: observed.checks }
211
+ : (known?.reportedChecks ?? null)
212
+
213
+ const recordsOf = (
214
+ previous: WatchState,
215
+ pullRequests: readonly PullRequestSnapshot[],
216
+ own: readonly number[],
217
+ ): Record<string, PullRequestRecord> =>
218
+ Object.fromEntries(
219
+ pullRequests.map((observed) => {
220
+ const { number, state, checks, headCommit, mergeCommit } = observed
221
+ const known = previous.pullRequests[String(number)]
222
+ const reportedChecks = reportedChecksOf(known, observed)
223
+ const isOwn = known?.own === true || own.includes(number)
224
+ return [String(number), { state, checks, headCommit, mergeCommit, reportedChecks, ...(isOwn && { own: true }) }]
225
+ }),
226
+ )
227
+
228
+ /** The remembered lane branches plus the observed ones, minus those of lanes no longer watched whose branch has no listed pull request. */
229
+ const keptLaneBranches = (
230
+ previous: WatchState,
231
+ observation: Observation,
232
+ pullRequests: readonly PullRequestSnapshot[],
233
+ ): Record<string, string> => {
234
+ const listed = new Set(pullRequests.map((pullRequest) => pullRequest.branch))
235
+ const all = { ...previous.laneBranches, ...observation.laneBranches }
236
+ return Object.fromEntries(
237
+ Object.entries(all).filter(([lane, branch]) => lane in observation.laneBranches || listed.has(branch)),
238
+ )
239
+ }
240
+
241
+ const runsOf = (runs: readonly WorkflowRun[]): Record<string, boolean> =>
242
+ Object.fromEntries(runs.map((run) => [String(run.id), run.status === RUN_STATUS.completed]))
243
+
244
+ /**
245
+ * The state to save after an observation: every observed lane, pull request and run, whether or not
246
+ * it fired. Entries that dropped out of the listings are forgotten.
247
+ */
248
+ export const recordWatchState = (previous: WatchState, observation: Observation, now: number): WatchState => {
249
+ const { lanes, github } = observation
250
+ if (!github) {
251
+ const laneBranches = { ...previous.laneBranches, ...observation.laneBranches }
252
+ return { ...previous, seenSeqs: seenSeqsOf(lanes), ghDownSince: previous.ghDownSince ?? now, laneBranches }
253
+ }
254
+ return {
255
+ seenSeqs: seenSeqsOf(lanes),
256
+ pullRequests: recordsOf(previous, github.pullRequests, github.own ?? []),
257
+ runs: runsOf(github.runs),
258
+ ghDownSince: null,
259
+ savedAt: now,
260
+ laneBranches: keptLaneBranches(previous, observation, github.pullRequests),
261
+ coordinatorLevel: previous.coordinatorLevel,
262
+ feedback: previous.feedback,
263
+ previews: previous.previews,
264
+ laneLevels: previous.laneLevels,
265
+ sources: previous.sources,
266
+ reportMtimes: previous.reportMtimes,
267
+ statusLines: previous.statusLines,
268
+ }
269
+ }
270
+
271
+ /**
272
+ * The report times of the watched lanes only: a known lane keeps its time, a lane seen for the first time takes the
273
+ * report already there as its baseline (`mtimeOf`), so an old file never reads as new; a lane no longer watched is
274
+ * forgotten and starts afresh when it comes back.
275
+ */
276
+ export const withReportBaselines = (
277
+ state: WatchState,
278
+ lanes: readonly string[],
279
+ mtimeOf: (lane: string) => number | null,
280
+ ): WatchState => ({
281
+ ...state,
282
+ reportMtimes: Object.fromEntries(
283
+ lanes.map((lane) => [lane, lane in state.reportMtimes ? (state.reportMtimes[lane] ?? null) : mtimeOf(lane)]),
284
+ ),
285
+ })
286
+
287
+ /** The state with the coordinator's level of `reading` stored; no reading keeps the stored level. */
288
+ export const recordCoordinatorLevel = (state: WatchState, reading: CoordinatorReading | null): WatchState => ({
289
+ ...state,
290
+ coordinatorLevel: levelStep(state.coordinatorLevel, reading?.level ?? null).level,
291
+ })
292
+
293
+ /** Fires when the coordinator's level rose to `warn` or `alert` since `previous` (see `levelStep`). */
294
+ export const coordinatorWatchEvent = (
295
+ previous: WatchState,
296
+ reading: CoordinatorReading | null,
297
+ ): CoordinatorEvent | null =>
298
+ reading && levelStep(previous.coordinatorLevel, reading.level).fire
299
+ ? { kind: WATCH_EVENT.coordinator, ...reading }
300
+ : null
301
+
302
+ const settledChecksKind = (checks: ChecksState): PullRequestEvent['kind'] | null => {
303
+ if (checks === CHECKS.failed) return WATCH_EVENT.checksFailed
304
+ return checks === CHECKS.passed ? WATCH_EVENT.checksPassed : null
305
+ }
306
+
307
+ const isReported = (known: PullRequestRecord, observed: PullRequestSnapshot): boolean =>
308
+ known.reportedChecks?.headCommit === observed.headCommit && known.reportedChecks.checks === observed.checks
309
+
310
+ const eventKindOf = (known: PullRequestRecord, observed: PullRequestSnapshot): PullRequestEvent['kind'] | null => {
311
+ if (observed.state === PULL_REQUEST_STATE.merged)
312
+ return known.state === PULL_REQUEST_STATE.merged ? null : WATCH_EVENT.merged
313
+ if (observed.state !== PULL_REQUEST_STATE.open || isReported(known, observed)) return null
314
+ return settledChecksKind(observed.checks)
315
+ }
316
+
317
+ const laneOfBranch = (laneBranches: Readonly<Record<string, string>>, branch: string): string | undefined =>
318
+ Object.keys(laneBranches).find((lane) => laneBranches[lane] === branch)
319
+
320
+ /**
321
+ * The merges and settled checks of the lanes' pull requests since `previous`, and of the user's own pull requests on
322
+ * a branch without a lane, which name their branch. A pull request number never seen is a baseline, and so is an own
323
+ * one the state does not mark as own yet. Checks fire once per head commit and settled verdict: a new head commit
324
+ * fires when its checks settle, not while they are pending, and pending never clears what was reported.
325
+ */
326
+ export const pullRequestEvents = (
327
+ previous: WatchState,
328
+ current: readonly PullRequestSnapshot[],
329
+ laneBranches: Readonly<Record<string, string>>,
330
+ ): PullRequestEvent[] =>
331
+ current.flatMap((pullRequest) => {
332
+ const lane = laneOfBranch(laneBranches, pullRequest.branch)
333
+ const known = previous.pullRequests[String(pullRequest.number)]
334
+ const followed = lane !== undefined || known?.own === true
335
+ const kind = followed && known ? eventKindOf(known, pullRequest) : null
336
+ if (!kind) return []
337
+ const { number, headCommit, mergeCommit, branch } = pullRequest
338
+ const owner = lane === undefined ? { lane: null, branch } : { lane }
339
+ return [{ kind, ...owner, number, headCommit, mergeCommit }]
340
+ })
341
+
342
+ /** The lane each merge commit belongs to, for matching deploy runs to a lane's merge. */
343
+ export const mergeCommitsOf = (
344
+ pullRequests: readonly PullRequestSnapshot[],
345
+ laneBranches: Readonly<Record<string, string>>,
346
+ ): Record<string, string> =>
347
+ Object.fromEntries(
348
+ pullRequests.flatMap(({ branch, mergeCommit }) => {
349
+ const lane = laneOfBranch(laneBranches, branch)
350
+ return lane && mergeCommit ? [[mergeCommit, lane]] : []
351
+ }),
352
+ )
353
+
354
+ /** The number of the own pull request without a lane each merge commit belongs to, for matching deploy runs. */
355
+ export const ownMergeCommitsOf = (
356
+ previous: WatchState,
357
+ pullRequests: readonly PullRequestSnapshot[],
358
+ laneBranches: Readonly<Record<string, string>>,
359
+ ): Record<string, number> =>
360
+ Object.fromEntries(
361
+ pullRequests.flatMap(({ number, branch, mergeCommit }) => {
362
+ const own = previous.pullRequests[String(number)]?.own === true
363
+ return own && mergeCommit && !laneOfBranch(laneBranches, branch) ? [[mergeCommit, number]] : []
364
+ }),
365
+ )
366
+
367
+ const wasOpen = (previous: WatchState, run: WorkflowRun): boolean => {
368
+ const completedBefore = previous.runs[String(run.id)]
369
+ if (completedBefore !== undefined) return !completedBefore
370
+ return previous.savedAt !== null && Date.parse(run.createdAt) >= previous.savedAt
371
+ }
372
+
373
+ /**
374
+ * The deploy runs that completed since `previous`, any conclusion included (a cancelled run is news).
375
+ * A run counts when its head commit is the merge commit of a lane or of an own pull request without a lane
376
+ * (`ownMergeCommits`, merge commit → number), or when someone started it by hand.
377
+ * A run that had already completed does not fire again. A run ID never seen is a baseline,
378
+ * unless it was created after the last saved state: it started and ended between two returns and counts as open.
379
+ */
380
+ export const deployEvents = (
381
+ previous: WatchState,
382
+ runs: readonly WorkflowRun[],
383
+ mergeCommits: Readonly<Record<string, string>>,
384
+ ownMergeCommits: Readonly<Record<string, number>>,
385
+ ): DeployEvent[] =>
386
+ runs.flatMap((run) => {
387
+ const lane = mergeCommits[run.headSha] ?? null
388
+ const pullRequest = lane === null ? ownMergeCommits[run.headSha] : undefined
389
+ const relevant = lane !== null || pullRequest !== undefined || run.event === RUN_EVENT.workflowDispatch
390
+ if (!wasOpen(previous, run) || run.status !== RUN_STATUS.completed || !relevant) return []
391
+ const deploy = { kind: WATCH_EVENT.deploy, runId: run.id, trigger: run.event, conclusion: run.conclusion, lane }
392
+ return [pullRequest === undefined ? deploy : { ...deploy, pullRequest }]
393
+ })
394
+
395
+ /** Fires once, at the first failed `gh` read of a streak (`error` is its reason); a streak that has begun is already known. */
396
+ export const ghOutageEvent = (previous: WatchState, error: string | null): SignalEvent | null =>
397
+ error !== null && previous.ghDownSince === null ? { kind: WATCH_EVENT.ghUnavailable, reason: error } : null
398
+
399
+ export const isHeartbeatDue = (armedAt: number, now: number, intervalMs: number): boolean => now - armedAt >= intervalMs
@@ -0,0 +1,145 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { basename, join } from 'node:path'
5
+ import { CLI_NAME, PACKAGE_NAME } from '../version/release'
6
+
7
+ /** Looks up the command line of a process; null when there is no such process. */
8
+ export type CommandLineOf = (pid: number) => string | null
9
+
10
+ /** What taking the lock did to a watcher that held it: stopped it, or found it still running after the wait. */
11
+ export type TakeOver = { replaced: number | null; stillRunning: number | null }
12
+
13
+ /** The CLI's entry by source path or bin link (`jh`, and `jackherd` for `bunx jackherd`); `agent-kit` until 2.0.0, so a watcher of the old CLI is replaced. */
14
+ const CLI_ENTRIES = ['cli.ts', CLI_NAME, PACKAGE_NAME, 'agent-kit']
15
+ const WATCH_COMMAND = 'watch'
16
+ const RUNTIME = 'bun'
17
+ const DIRECTORY_PREFIX = 'jackherd-watch'
18
+ const KEY_HASH_LENGTH = 12
19
+ const NOT_A_SEGMENT = /[^A-Za-z0-9._-]+/g
20
+ const PRIVATE_DIRECTORY = 0o700
21
+ const PRIVATE_FILE = 0o600
22
+ const STOP_WAIT_MS = 5_000
23
+ const STOP_POLL_MS = 50
24
+
25
+ export const WATCHER_FILES = { state: 'watch-state.json', lock: 'watch.lock' } as const
26
+
27
+ /** The process ID a lock file holds, or null for a missing, empty or damaged file. */
28
+ export const lockedPid = (content: string | null): number | null => {
29
+ const pid = Number.parseInt(content?.trim() ?? '', 10)
30
+ return Number.isInteger(pid) && pid > 0 ? pid : null
31
+ }
32
+
33
+ /**
34
+ * True for `bun <…/cli.ts|…/jh> watch …`: the runtime first, the kit's CLI as an argument of its own and
35
+ * `watch` right after it; not an editor, a pager or another command of the CLI.
36
+ */
37
+ export const isWatcherCommand = (commandLine: string): boolean => {
38
+ const [program, ...args] = commandLine.trim().split(/\s+/)
39
+ if (basename(program ?? '') !== RUNTIME) return false
40
+ const entry = args.findIndex((arg) => CLI_ENTRIES.includes(basename(arg)))
41
+ return entry !== -1 && args[entry + 1] === WATCH_COMMAND
42
+ }
43
+
44
+ /**
45
+ * The watcher to stop before this one takes the lock, or null. Only a process whose command line
46
+ * runs `jh watch` is ever named: a process ID is reused after its process ends, and a signal
47
+ * must never reach whatever holds the number now.
48
+ */
49
+ export const supersedeTarget = (
50
+ content: string | null,
51
+ ownPid: number,
52
+ commandLineOf: CommandLineOf,
53
+ ): number | null => {
54
+ const pid = lockedPid(content)
55
+ if (pid === null || pid === ownPid) return null
56
+ const commandLine = commandLineOf(pid)
57
+ return commandLine !== null && isWatcherCommand(commandLine) ? pid : null
58
+ }
59
+
60
+ /** What a watcher that received SIGTERM prints: who replaced it, or that it was stopped from outside. */
61
+ export const stopNotice = (content: string | null, ownPid: number): string => {
62
+ const pid = lockedPid(content)
63
+ return pid !== null && pid !== ownPid ? `superseded by ${pid}` : 'stopped by SIGTERM'
64
+ }
65
+
66
+ /**
67
+ * The name of a repository's watcher directory: the checkout's directory name, reduced to a plain path segment,
68
+ * and the first hex digits of a SHA-256 of its path, so two checkouts of the same name stay apart.
69
+ */
70
+ export const repositoryKey = (root: string): string => {
71
+ const name = basename(root).replace(NOT_A_SEGMENT, '-')
72
+ const hash = createHash('sha256').update(root).digest('hex').slice(0, KEY_HASH_LENGTH)
73
+ return `${name}-${hash}`
74
+ }
75
+
76
+ const readText = (file: string): string | null => {
77
+ try {
78
+ return readFileSync(file, 'utf8')
79
+ } catch {
80
+ return null
81
+ }
82
+ }
83
+
84
+ const commandLineOf: CommandLineOf = (pid) => {
85
+ const result = Bun.spawnSync(['ps', '-o', 'command=', '-p', String(pid)], { stdout: 'pipe', stderr: 'ignore' })
86
+ return result.exitCode === 0 ? result.stdout.toString().trim() || null : null
87
+ }
88
+
89
+ const isAlive = (pid: number): boolean => {
90
+ try {
91
+ process.kill(pid, 0)
92
+ return true
93
+ } catch {
94
+ return false
95
+ }
96
+ }
97
+
98
+ /** Where `ensureWatcherDirectory` puts the watcher's files, without creating anything: for readers such as the plans view. */
99
+ export const watcherDirectoryOf = (root: string): string =>
100
+ join(tmpdir(), `${DIRECTORY_PREFIX}-${process.getuid?.() ?? 'user'}`, repositoryKey(root))
101
+
102
+ /**
103
+ * The directory for the lock and state of the watcher of the repository at `root`, one per user and repository,
104
+ * readable by that user only: with `TMPDIR` unset, `tmpdir()` is the shared `/tmp`, where another account could
105
+ * plant the files. A watcher in another repository has its own lock and does not supersede this one.
106
+ */
107
+ export const ensureWatcherDirectory = (root: string): string => {
108
+ const directory = watcherDirectoryOf(root)
109
+ mkdirSync(directory, { recursive: true, mode: PRIVATE_DIRECTORY })
110
+ return directory
111
+ }
112
+
113
+ /** Whether the lock names a live process that is a watcher, so its saved state is being kept up to date. */
114
+ export const isWatcherRunning = (lockFile: string, lookUp: CommandLineOf = commandLineOf): boolean => {
115
+ const pid = lockedPid(readText(lockFile))
116
+ return pid !== null && isAlive(pid) && isWatcherCommand(lookUp(pid) ?? '')
117
+ }
118
+
119
+ /** Writes a file only the user can read. */
120
+ export const writePrivate = (file: string, content: string) => writeFileSync(file, content, { mode: PRIVATE_FILE })
121
+
122
+ /**
123
+ * Takes the lock for this process. A watcher that held it gets SIGTERM and is waited for, so the state
124
+ * file it writes on the way out is complete before this one reads it.
125
+ */
126
+ export const takeOverLock = async (lockFile: string): Promise<TakeOver> => {
127
+ const previous = supersedeTarget(readText(lockFile), process.pid, commandLineOf)
128
+ writePrivate(lockFile, String(process.pid))
129
+ if (previous === null || !isAlive(previous)) return { replaced: null, stillRunning: null }
130
+ try {
131
+ process.kill(previous, 'SIGTERM')
132
+ } catch {
133
+ return { replaced: null, stillRunning: null }
134
+ }
135
+ const deadline = Date.now() + STOP_WAIT_MS
136
+ while (isAlive(previous) && Date.now() < deadline) await Bun.sleep(STOP_POLL_MS)
137
+ return isAlive(previous) ? { replaced: null, stillRunning: previous } : { replaced: previous, stillRunning: null }
138
+ }
139
+
140
+ export const stopNoticeOf = (lockFile: string): string => stopNotice(readText(lockFile), process.pid)
141
+
142
+ /** Removes the lock only while it still names this process; a successor's lock stays. */
143
+ export const releaseLock = (lockFile: string) => {
144
+ if (lockedPid(readText(lockFile)) === process.pid) rmSync(lockFile, { force: true })
145
+ }
@@ -0,0 +1,96 @@
1
+ import type { JackherdConfig } from '../config'
2
+ import { isRecord, parseArray } from './json-guards'
3
+
4
+ /** Which workflow's runs are deploys, and which environments count as their target (`watch.deploy`). */
5
+ export type DeployConfig = NonNullable<JackherdConfig['watch']['deploy']>
6
+
7
+ /** The statuses of a run in `gh run list --json status`. */
8
+ export const RUN_STATUS = { queued: 'queued', inProgress: 'in_progress', completed: 'completed' } as const
9
+
10
+ /** The triggers of a run in `gh run list --json event` a deploy workflow answers to. */
11
+ export const RUN_EVENT = { push: 'push', workflowRun: 'workflow_run', workflowDispatch: 'workflow_dispatch' } as const
12
+
13
+ /** `conclusion` is null while a run is open; `createdAt` is ISO 8601. */
14
+ export type WorkflowRun = {
15
+ id: number
16
+ event: string
17
+ headSha: string
18
+ status: string
19
+ conclusion: string | null
20
+ createdAt: string
21
+ }
22
+
23
+ /** A GitHub deployment of a commit; its statuses point at the run that made it. */
24
+ type Deployment = { id: number; environment: string }
25
+
26
+ type GhWorkflowRun = {
27
+ databaseId: number
28
+ event: string
29
+ headSha: string
30
+ status: string
31
+ conclusion: string
32
+ createdAt: string
33
+ }
34
+
35
+ /** The fields `parseWorkflowRuns` reads; pass them to `gh run list --json`. */
36
+ export const WORKFLOW_RUN_FIELDS = 'databaseId,event,headSha,status,conclusion,createdAt'
37
+
38
+ const RUN_LIMIT = '20'
39
+
40
+ const RUN_LOG_PATH = /\/actions\/runs\/(\d+)\//
41
+
42
+ const isGhWorkflowRun = (value: unknown): value is GhWorkflowRun =>
43
+ isRecord(value) &&
44
+ typeof value.databaseId === 'number' &&
45
+ typeof value.event === 'string' &&
46
+ typeof value.headSha === 'string' &&
47
+ typeof value.status === 'string' &&
48
+ typeof value.conclusion === 'string' &&
49
+ typeof value.createdAt === 'string'
50
+
51
+ const isDeployment = (value: unknown): value is Deployment =>
52
+ isRecord(value) && typeof value.id === 'number' && typeof value.environment === 'string'
53
+
54
+ /**
55
+ * Reads the JSON of `gh run list --json <WORKFLOW_RUN_FIELDS>`; throws on anything else. `gh` sends an
56
+ * open run's conclusion as an empty string, which becomes null here.
57
+ */
58
+ export const parseWorkflowRuns = (json: string): WorkflowRun[] =>
59
+ parseArray(json, isGhWorkflowRun, 'gh run list').map((run) => ({
60
+ id: run.databaseId,
61
+ event: run.event,
62
+ headSha: run.headSha,
63
+ status: run.status,
64
+ conclusion: run.conclusion || null,
65
+ createdAt: run.createdAt,
66
+ }))
67
+
68
+ /** Reads `[{id, environment}]` as `gh api …/deployments --jq '[.[] | {id, environment}]'` prints it. */
69
+ export const parseDeployments = (json: string): Deployment[] => parseArray(json, isDeployment, 'gh api deployments')
70
+
71
+ /** True for an environment the config names as a target; any other name in a deployment is not taken as one. */
72
+ export const isDeployTarget = (environment: string, environments: readonly string[]): boolean =>
73
+ environments.includes(environment)
74
+
75
+ /** The `gh` arguments that list the deploy workflow's runs on the base branch; null when the project reports no deploys. */
76
+ export const runListArgs = (deploy: DeployConfig | undefined, baseBranch: string): string[] | null =>
77
+ deploy
78
+ ? [
79
+ 'run',
80
+ 'list',
81
+ '--workflow',
82
+ deploy.workflow,
83
+ '--branch',
84
+ baseBranch,
85
+ '--limit',
86
+ RUN_LIMIT,
87
+ '--json',
88
+ WORKFLOW_RUN_FIELDS,
89
+ ]
90
+ : null
91
+
92
+ /** The run ID a deployment status's `log_url` (`…/actions/runs/<id>/job/<job>`) points at, or null. */
93
+ export const runIdOfLogUrl = (logUrl: string): number | null => {
94
+ const match = logUrl.match(RUN_LOG_PATH)
95
+ return match ? Number(match[1]) : null
96
+ }