@defrex/autobuild 0.8.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 (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,259 @@
1
+ import type { AbEvent, EventEnvelope, EventWrite } from '../events/catalog'
2
+ import { KERNEL } from '../events/envelope'
3
+ import { autoMergeDeferralClasses, type AutoMergeDeferralReason } from '../ports/types'
4
+ import type { BuildStore } from '../store/types'
5
+ import type { BuildState } from './reducer'
6
+
7
+ /** The complete GitHub `mergeStateStatus` enum. Keeping this list closed is a
8
+ * safety property: a future forge state cannot silently become direct-merge
9
+ * eligible. */
10
+ export const mergeStateStatuses = [
11
+ 'BEHIND',
12
+ 'BLOCKED',
13
+ 'CLEAN',
14
+ 'DIRTY',
15
+ 'DRAFT',
16
+ 'HAS_HOOKS',
17
+ 'UNKNOWN',
18
+ 'UNSTABLE',
19
+ ] as const
20
+
21
+ export type MergeStateStatus = (typeof mergeStateStatuses)[number]
22
+ export type MergeGatePresence = 'present' | 'absent'
23
+
24
+ export type AutoMergeEnableDisposition =
25
+ | { kind: 'native' }
26
+ | { kind: 'direct' }
27
+ | { kind: 'deferred' }
28
+ | { kind: 'error'; reason: string }
29
+
30
+ const NATIVE = { kind: 'native' } as const
31
+ const DIRECT = { kind: 'direct' } as const
32
+ const DEFERRED = { kind: 'deferred' } as const
33
+
34
+ /**
35
+ * Route from two independent facts: whether a real merge gate exists and the
36
+ * PR's current GitHub merge state. In particular, CLEAN never proves that a
37
+ * branch is ungated: a gated branch whose requirements passed is CLEAN too.
38
+ */
39
+ const ENABLE_DISPOSITIONS = {
40
+ BEHIND: { present: NATIVE, absent: DIRECT },
41
+ BLOCKED: {
42
+ present: NATIVE,
43
+ absent: {
44
+ kind: 'error',
45
+ reason: 'GitHub reports the PR BLOCKED despite no discovered merge-blocking gate',
46
+ },
47
+ },
48
+ CLEAN: { present: NATIVE, absent: DIRECT },
49
+ DIRTY: { present: DEFERRED, absent: DEFERRED },
50
+ DRAFT: {
51
+ present: {
52
+ kind: 'error',
53
+ reason: 'GitHub reports the PR as DRAFT; Autobuild only finalizes ready PRs',
54
+ },
55
+ absent: {
56
+ kind: 'error',
57
+ reason: 'GitHub reports the PR as DRAFT; Autobuild only finalizes ready PRs',
58
+ },
59
+ },
60
+ HAS_HOOKS: { present: NATIVE, absent: NATIVE },
61
+ UNKNOWN: { present: NATIVE, absent: DEFERRED },
62
+ UNSTABLE: { present: NATIVE, absent: DIRECT },
63
+ } as const satisfies Record<MergeStateStatus, Record<MergeGatePresence, AutoMergeEnableDisposition>>
64
+
65
+ export function classifyAutoMergeEnable(
66
+ mergeState: MergeStateStatus,
67
+ gate: MergeGatePresence,
68
+ ): AutoMergeEnableDisposition {
69
+ return ENABLE_DISPOSITIONS[mergeState][gate]
70
+ }
71
+
72
+ /** A human auto-merge command whose desired forge state has not yet been
73
+ * acknowledged by a matching application fact. */
74
+ export interface PendingAutoMerge {
75
+ enabled: boolean
76
+ commandSeq: number
77
+ }
78
+
79
+ /**
80
+ * Compare durable desired state with the correlated forge application fact.
81
+ * A value mismatch OR a command-seq mismatch is pending. This is the recovery
82
+ * predicate at both plumbing seams: retries are safe because Forge.setAutoMerge
83
+ * is idempotent.
84
+ */
85
+ export function pendingAutoMerge(
86
+ state: Pick<BuildState, 'autoMerge'>,
87
+ ): PendingAutoMerge | undefined {
88
+ const { requested, commandSeq, applied } = state.autoMerge
89
+ if (commandSeq === undefined) return undefined
90
+ if (applied?.commandSeq === commandSeq && applied.enabled === requested) {
91
+ return undefined
92
+ }
93
+ return { enabled: requested, commandSeq }
94
+ }
95
+
96
+ export function autoMergeApplicationType(
97
+ enabled: boolean,
98
+ ): 'pr.auto-merge-enabled' | 'pr.auto-merge-disabled' {
99
+ return enabled ? 'pr.auto-merge-enabled' : 'pr.auto-merge-disabled'
100
+ }
101
+
102
+ /** Stable correlation key for the one follow-up allowed per PR/consent command. */
103
+ export function autoMergeDeferralRef(prNumber: number, commandSeq: number): string {
104
+ return `auto-merge-gate:pr:${prNumber}:command:${commandSeq}`
105
+ }
106
+
107
+ /**
108
+ * The one live deferral observation for a PR/consent command, or undefined.
109
+ * An observation is current only while the PR is open — any `pr.merged` or
110
+ * `pr.closed` fact in the log ends it — and until later evidence proves the
111
+ * PR head changed, superseding the recorded condition until the gate
112
+ * re-examines consent and records a fresh observation. Head-changing evidence
113
+ * is a `reconcile.completed` (the resolution is a merge commit on the branch)
114
+ * or a `finalize.completed` whose head differs from the head the observation
115
+ * was recorded under. The dedupe and the projection share this predicate, so
116
+ * a stale observation stops suppressing and re-recording after reconcile
117
+ * works.
118
+ *
119
+ * The head at observation time is the newest `finalize.completed` before it:
120
+ * the record seam always runs right after that event is appended. An
121
+ * observation with no preceding `finalize.completed` (janitor-recorded seeds,
122
+ * synthetic logs) takes its head from the FIRST following `finalize.completed`
123
+ * instead of being superseded by it — the §8.7 retry that adopts the same PR
124
+ * at the same head must not invalidate the marker it honors. A
125
+ * `finalize.completed` naming a different head is a rebuilt pipeline
126
+ * re-finalizing after new commits, and it does make the observation stale.
127
+ *
128
+ * Ending currency on any terminal PR fact in the log is safe only because a
129
+ * build finalizes one PR; if a future feature ever re-finalizes onto a second
130
+ * PR, revisit.
131
+ */
132
+ export function currentDeferralObservation(
133
+ events: AbEvent[],
134
+ prNumber: number,
135
+ commandSeq: number,
136
+ ): Extract<AbEvent, { type: 'observation.recorded' }> | undefined {
137
+ const marker = autoMergeDeferralRef(prNumber, commandSeq)
138
+ let observation: Extract<AbEvent, { type: 'observation.recorded' }> | undefined
139
+ for (let index = events.length - 1; index >= 0; index -= 1) {
140
+ const event = events[index]
141
+ if (
142
+ event !== undefined &&
143
+ event.type === 'observation.recorded' &&
144
+ event.payload.refs?.includes(marker) === true
145
+ ) {
146
+ observation = event
147
+ break
148
+ }
149
+ }
150
+ if (observation === undefined) return undefined
151
+
152
+ let headAtObservation: string | undefined
153
+ for (const event of events) {
154
+ if (event.seq >= observation.seq) break
155
+ if (event.type === 'finalize.completed') headAtObservation = event.payload.pr.headSha
156
+ }
157
+
158
+ for (const event of events) {
159
+ if (event.seq <= observation.seq) continue
160
+ if (event.type === 'pr.merged' || event.type === 'pr.closed') return undefined
161
+ if (event.type === 'reconcile.completed') return undefined
162
+ if (event.type === 'finalize.completed') {
163
+ if (headAtObservation === undefined) headAtObservation = event.payload.pr.headSha
164
+ else if (event.payload.pr.headSha !== headAtObservation) return undefined
165
+ }
166
+ }
167
+ return observation
168
+ }
169
+
170
+ export function hasAutoMergeDeferralObservation(
171
+ events: AbEvent[],
172
+ prNumber: number,
173
+ commandSeq: number,
174
+ ): boolean {
175
+ return currentDeferralObservation(events, prNumber, commandSeq) !== undefined
176
+ }
177
+
178
+ /**
179
+ * The exact provider-bearing summary for the current, unapplied enable
180
+ * command. Deferral observations are durable history, so correlation to both
181
+ * the current PR and command is what prevents an old reason from looking live
182
+ * after consent is applied, cancelled, or replaced — and the shared currency
183
+ * predicate is what keeps a superseded or terminal-PR observation from
184
+ * looking live at all.
185
+ */
186
+ export function currentAutoMergeDeferral(
187
+ events: AbEvent[],
188
+ state: Pick<BuildState, 'autoMerge' | 'pr'>,
189
+ ): string | undefined {
190
+ const pending = pendingAutoMerge(state)
191
+ if (pending?.enabled !== true || state.pr === undefined) return undefined
192
+ return currentDeferralObservation(events, state.pr.number, pending.commandSeq)?.payload.summary
193
+ }
194
+
195
+ const DEFERRAL_SUMMARIES = {
196
+ 'github-plan-limitation':
197
+ 'GitHub rulesets are unavailable because of the repository account plan',
198
+ 'repository-auto-merge-disabled': 'repository-level auto-merge is disabled',
199
+ 'unproven-gate-state': 'merge-gate state or native auto-merge application could not be proven',
200
+ 'merge-conflicts': 'the PR has merge conflicts with its base branch',
201
+ 'mergeability-uncomputed':
202
+ 'GitHub has not finished computing mergeability (transient; expected to resolve on retry)',
203
+ 'local-base-checkout-dirty': 'local merge is blocked by uncommitted work in the base checkout',
204
+ 'local-git-identity-missing':
205
+ 'local squash requires a configured Git author and committer identity',
206
+ } as const satisfies Record<AutoMergeDeferralReason['code'], string>
207
+
208
+ /** Kernel-authored durable diagnostic for a human-actionable declined consent. */
209
+ export function autoMergeDeferralObservation(
210
+ reason: AutoMergeDeferralReason,
211
+ prNumber: number,
212
+ commandSeq: number,
213
+ id: string,
214
+ ): EventWrite<'observation.recorded'> {
215
+ return {
216
+ actor: KERNEL,
217
+ type: 'observation.recorded',
218
+ payload: {
219
+ id,
220
+ kind: 'followup',
221
+ summary:
222
+ `Auto-merge gate could not apply consent for PR #${prNumber}: ` +
223
+ `${DEFERRAL_SUMMARIES[reason.code]} — ${reason.detail}`,
224
+ refs: [autoMergeDeferralRef(prNumber, commandSeq)],
225
+ },
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Record the one durable diagnostic allowed for a PR/auto-merge command —
231
+ * only for a deferral a person must fix. Pipeline-owned and transient codes
232
+ * (`merge-conflicts`, `mergeability-uncomputed`) resolve through reconcile or
233
+ * a later poll, so they record nothing: consent stays pending and later ticks
234
+ * re-examine it. Every comparison is against the authoritative stream tail. A
235
+ * concurrent unrelated append merely causes a retry; a concurrent matching
236
+ * append makes the next read return without writing a duplicate.
237
+ */
238
+ export async function recordAutoMergeDeferralObservation(
239
+ store: BuildStore,
240
+ slug: string,
241
+ reason: AutoMergeDeferralReason,
242
+ prNumber: number,
243
+ commandSeq: number,
244
+ id: string,
245
+ ): Promise<EventEnvelope<'observation.recorded'> | null> {
246
+ if (autoMergeDeferralClasses[reason.code] === 'pipeline-resolved') return null
247
+ while (true) {
248
+ const events = await store.getEvents(slug)
249
+ if (hasAutoMergeDeferralObservation(events, prNumber, commandSeq)) return null
250
+
251
+ const expectedSeq = events.at(-1)?.seq ?? 0
252
+ const appended = await store.appendIfCurrent(
253
+ slug,
254
+ expectedSeq,
255
+ autoMergeDeferralObservation(reason, prNumber, commandSeq, id),
256
+ )
257
+ if (appended !== null) return appended
258
+ }
259
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The generic review-loop primitive (SPEC §10): one tested state machine for
3
+ * both the plan loop and the code loop. It owns rounds, feedback threading,
4
+ * verdict routing, and the stall/policy thresholds — and nothing else: the
5
+ * callbacks own all I/O (sessions, store deposits, plumbing), so this module
6
+ * never touches a port or the store.
7
+ *
8
+ * Memory model (§10): the producer continues its session across rounds — its
9
+ * feedback is only the latest verdict's finding ids, assembled
10
+ * deterministically. The reviewer is a fresh skeptic each round and receives
11
+ * ALL prior rounds' findings so it can mark `persists` (§15.4).
12
+ *
13
+ * Findings are opaque here: ids are stamped at deposit (D6) before a verdict
14
+ * ever reaches this function, and converge never copies or mutates them —
15
+ * the same objects flow into `stalledChains` and back out in the outcome.
16
+ *
17
+ * Relationship to the engine: the MAINLINE loop is `decideLoop` inside
18
+ * `decideNext` (src/kernel/engine.ts) — the event-sourced re-statement of
19
+ * this primitive, because the kernel's resume path must re-decide from the
20
+ * log (§2.2) rather than hold a loop on the stack. converge remains the §10
21
+ * reference semantics for in-process callers, and the two implementations
22
+ * are pinned together by the differential suite in converge.test.ts
23
+ * ('converge ⇄ decideNext differential'): the same verdict scripts must
24
+ * produce the same rounds, feedback, and outcome in both. Edit loop
25
+ * semantics (thresholds, stall ordering, feedback assembly) in BOTH places
26
+ * or the differential fails.
27
+ */
28
+ import type { Feedback, Finding, Verdict } from '../ontology'
29
+ import { stalledChains, type FindingChain } from './stall'
30
+
31
+ export interface ConvergePolicy {
32
+ /** Producer/review round pairs allowed before escalating (source: policy). */
33
+ maxRounds: number
34
+ /** Persistence-chain streak that auto-escalates (source: stall — §15.4). */
35
+ stallRounds: number
36
+ }
37
+
38
+ export type ConvergeOutcome<A> =
39
+ | { outcome: 'approved'; artifact: A; rounds: number }
40
+ | {
41
+ outcome: 'escalated'
42
+ source: 'agent' | 'stall' | 'policy'
43
+ reason: string
44
+ rounds: number
45
+ chain?: FindingChain
46
+ }
47
+
48
+ export async function converge<A>(opts: {
49
+ produce: (feedback: Feedback | null, round: number) => Promise<A>
50
+ review: (artifact: A, round: number, priorRounds: Finding[][]) => Promise<Verdict>
51
+ /** Static policy for ordinary callers, or a boundary accessor for a
52
+ * long-running repository workflow that hot-reloads policy between rounds. */
53
+ policy: ConvergePolicy | (() => ConvergePolicy)
54
+ /** Chains a human already resolved via dismiss-finding (§15.6-B). */
55
+ dismissedIds?: ReadonlySet<string>
56
+ /** Durable callers may resume after prior revise rounds. Defaults preserve
57
+ * the ordinary in-memory loop (round 1, no history). */
58
+ startRound?: number
59
+ priorRounds?: Finding[][]
60
+ initialFeedback?: Feedback | null
61
+ }): Promise<ConvergeOutcome<A>> {
62
+ const { produce, review, dismissedIds } = opts
63
+ const currentPolicy = (): ConvergePolicy =>
64
+ typeof opts.policy === 'function' ? opts.policy() : opts.policy
65
+ const roundFindings: Finding[][] = (opts.priorRounds ?? []).map((round) => [...round])
66
+ let feedback: Feedback | null = opts.initialFeedback ?? null
67
+
68
+ for (let round = opts.startRound ?? 1; ; round += 1) {
69
+ const openingPolicy = currentPolicy()
70
+ if (round > openingPolicy.maxRounds) {
71
+ return {
72
+ outcome: 'escalated',
73
+ source: 'policy',
74
+ reason: `maxRounds (${openingPolicy.maxRounds}) exhausted without approval`,
75
+ rounds: round - 1,
76
+ }
77
+ }
78
+
79
+ const artifact = await produce(feedback, round)
80
+ const verdict = await review(artifact, round, roundFindings.slice())
81
+
82
+ if (verdict.verdict === 'approve') {
83
+ return { outcome: 'approved', artifact, rounds: round }
84
+ }
85
+ if (verdict.verdict === 'escalate') {
86
+ return {
87
+ outcome: 'escalated',
88
+ source: 'agent',
89
+ reason: verdict.reason,
90
+ rounds: round,
91
+ }
92
+ }
93
+
94
+ roundFindings.push(verdict.findings)
95
+
96
+ // The kernel applies the stall threshold after every revise, BEFORE
97
+ // burning another produce round (§15.4). Deepest chain reported; first
98
+ // in root order on ties.
99
+ const stalled = stalledChains(roundFindings, currentPolicy().stallRounds, dismissedIds)
100
+ if (stalled.length > 0) {
101
+ const chain = stalled.reduce((deepest, candidate) =>
102
+ candidate.rounds > deepest.rounds ? candidate : deepest,
103
+ )
104
+ return {
105
+ outcome: 'escalated',
106
+ source: 'stall',
107
+ reason: `finding chain persisted ${chain.rounds} rounds: ${chain.ids.join(' -> ')}`,
108
+ rounds: round,
109
+ chain,
110
+ }
111
+ }
112
+
113
+ // Round N+1's producer prompt is assembled deterministically from finding
114
+ // ids (§10) — payloads carry refs, never blobs (§15.2.3).
115
+ feedback = { findings: verdict.findings.map((finding) => finding.id) }
116
+ }
117
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Pure projection of repository-scoped dispatcher controls — the intake gate,
3
+ * the repository-wide pause, and the claim-time auto-merge default. The journal
4
+ * is authoritative; fresh repositories retain the historical process defaults.
5
+ */
6
+ import type { RepositoryEvent } from '../events/repository'
7
+
8
+ export const DEFAULT_DISPATCH_INTAKE = true
9
+ export const DEFAULT_DISPATCH_PAUSED = false
10
+ export const DEFAULT_DISPATCH_AUTO_MERGE = false
11
+
12
+ export interface DispatchSettings {
13
+ intake: boolean
14
+ /**
15
+ * Repository-wide quiescence: while true, no queued build may be given a
16
+ * runner. Distinct from the per-build pause (a build-log fact with its own
17
+ * reducer precedence) and from `HarvestState.paused` (the observation
18
+ * workflow's own gate, which this does not govern).
19
+ */
20
+ paused: boolean
21
+ defaultAutoMerge: boolean
22
+ }
23
+
24
+ export function reduceDispatchSettings(events: RepositoryEvent[]): DispatchSettings {
25
+ let intake = DEFAULT_DISPATCH_INTAKE
26
+ let paused = DEFAULT_DISPATCH_PAUSED
27
+ let defaultAutoMerge = DEFAULT_DISPATCH_AUTO_MERGE
28
+ let intakeSeq = 0
29
+ let pausedSeq = 0
30
+ let autoMergeSeq = 0
31
+
32
+ for (const event of events) {
33
+ switch (event.type) {
34
+ case 'dispatcher.intake-set':
35
+ if (event.seq > intakeSeq) {
36
+ intake = event.payload.enabled
37
+ intakeSeq = event.seq
38
+ }
39
+ break
40
+ case 'dispatcher.pause-set':
41
+ if (event.seq > pausedSeq) {
42
+ paused = event.payload.enabled
43
+ pausedSeq = event.seq
44
+ }
45
+ break
46
+ case 'dispatcher.auto-merge-default-set':
47
+ if (event.seq > autoMergeSeq) {
48
+ defaultAutoMerge = event.payload.enabled
49
+ autoMergeSeq = event.seq
50
+ }
51
+ break
52
+ default:
53
+ // Harvest facts share this journal and have no dispatcher-setting
54
+ // meaning. Each setting is reduced independently.
55
+ break
56
+ }
57
+ }
58
+
59
+ return { intake, paused, defaultAutoMerge }
60
+ }
@@ -0,0 +1,141 @@
1
+ import type { RepositoryEvent } from '../events/repository'
2
+ import type { ArtifactRef } from '../ontology'
3
+
4
+ export type DispatchHealth = 'starting' | 'running' | 'stopped' | 'failed'
5
+
6
+ /** Store-only projection consumed by terminal (and future) frontends. */
7
+ export interface DispatchStatus {
8
+ run: string
9
+ health: DispatchHealth
10
+ effectiveConfig?: ArtifactRef
11
+ queued?: number
12
+ /** Standing count from newer tick facts; absent for historical events. */
13
+ creationWithheld?: number
14
+ availableUpgrade?: string
15
+ roleWarnings: string[]
16
+ diagnostics: string[]
17
+ /** Latest notice of warning/failure severity, retained independently from routine notices. */
18
+ warningNotice?: string
19
+ notice?: string
20
+ lastSeq: number
21
+ }
22
+
23
+ function errorNotice(prefix: string, error: string): string {
24
+ return `${prefix}: ${error}`
25
+ }
26
+
27
+ /** Reduce one correlated dispatch run without consulting process-local state.
28
+ * An optional prior projection permits strictly newer repository deltas.
29
+ * Successful ticks supersede standing diagnostics; failures retain the last
30
+ * known queue/config values instead of fabricating replacements. */
31
+ export function reduceDispatchStatus(
32
+ events: readonly RepositoryEvent[],
33
+ run: string,
34
+ previous?: Readonly<DispatchStatus>,
35
+ ): DispatchStatus {
36
+ if (previous !== undefined && previous.run !== run) {
37
+ throw new Error(`cannot continue dispatch run "${run}" from "${previous.run}" status`)
38
+ }
39
+ const state: DispatchStatus =
40
+ previous === undefined
41
+ ? {
42
+ run,
43
+ health: 'starting',
44
+ roleWarnings: [],
45
+ diagnostics: [],
46
+ lastSeq: 0,
47
+ }
48
+ : {
49
+ ...previous,
50
+ roleWarnings: [...previous.roleWarnings],
51
+ diagnostics: [...previous.diagnostics],
52
+ }
53
+ for (const event of events) {
54
+ if (event.seq <= state.lastSeq) continue
55
+ const payload = event.payload as { run?: string }
56
+ if (payload.run !== run) continue
57
+ state.lastSeq = event.seq
58
+ switch (event.type) {
59
+ case 'dispatcher.run-started':
60
+ state.health = 'running'
61
+ state.effectiveConfig = event.payload.effectiveConfig
62
+ state.roleWarnings = [...event.payload.roleWarnings]
63
+ state.notice = undefined
64
+ state.warningNotice = undefined
65
+ break
66
+ case 'dispatcher.config-reloaded':
67
+ if (event.payload.effectiveConfig !== undefined) {
68
+ state.effectiveConfig = event.payload.effectiveConfig
69
+ }
70
+ if (event.payload.roleWarnings !== undefined) {
71
+ state.roleWarnings = [...event.payload.roleWarnings]
72
+ }
73
+ state.notice =
74
+ event.payload.restartRequired.length > 0
75
+ ? `autobuild.toml reload requires dispatch restart for: ${event.payload.restartRequired.join(', ')}`
76
+ : 'autobuild.toml reloaded'
77
+ if (event.payload.restartRequired.length > 0) state.warningNotice = state.notice
78
+ break
79
+ case 'dispatcher.config-rejected':
80
+ state.notice = errorNotice('config reload rejected', event.payload.error)
81
+ state.warningNotice = state.notice
82
+ break
83
+ case 'dispatcher.config-publication-failed':
84
+ state.notice = errorNotice(
85
+ 'config reload not applied because its durable trace failed',
86
+ event.payload.error,
87
+ )
88
+ state.warningNotice = state.notice
89
+ break
90
+ case 'dispatcher.tick-completed':
91
+ state.queued = event.payload.queued
92
+ state.creationWithheld = event.payload.counters.creationWithheld
93
+ state.diagnostics = [
94
+ ...event.payload.janitorDiagnostics,
95
+ ...event.payload.ticketDiagnostics,
96
+ ...(event.payload.creationDiagnostics ?? []),
97
+ ...event.payload.dependencyDiagnostics,
98
+ ]
99
+ break
100
+ case 'dispatcher.tick-failed':
101
+ state.notice = errorNotice('tick failed', event.payload.error)
102
+ state.warningNotice = state.notice
103
+ break
104
+ case 'dispatcher.runner-settled':
105
+ if (event.payload.outcome === 'parked') {
106
+ state.notice = `build ${event.payload.slug} parked (${event.payload.status ?? 'unknown'})`
107
+ } else if (event.payload.outcome === 'lease-held') {
108
+ state.notice = `build ${event.payload.slug} already held by another runner — skipped`
109
+ } else {
110
+ state.notice = `build ${event.payload.slug} runner failed: ${event.payload.error ?? event.payload.outcome}`
111
+ }
112
+ state.warningNotice = state.notice
113
+ break
114
+ case 'dispatcher.harvest-runner-failed':
115
+ state.notice = errorNotice('harvest runner failed', event.payload.error)
116
+ state.warningNotice = state.notice
117
+ break
118
+ case 'dispatcher.upgrade-available':
119
+ state.availableUpgrade = event.payload.version
120
+ break
121
+ case 'dispatcher.operator-reported':
122
+ state.notice = event.payload.message
123
+ if (event.payload.level === 'warning') state.warningNotice = state.notice
124
+ break
125
+ case 'dispatcher.run-stopped':
126
+ state.health = event.payload.outcome === 'normal' ? 'stopped' : 'failed'
127
+ if (event.payload.outcome !== 'normal') {
128
+ state.notice = `dispatcher stopped unexpectedly${
129
+ event.payload.error !== undefined ? `: ${event.payload.error}` : ''
130
+ }`
131
+ state.warningNotice = state.notice
132
+ } else if (event.payload.reason === 'superseded') {
133
+ state.notice = 'dispatcher superseded by another invocation'
134
+ }
135
+ break
136
+ default:
137
+ break
138
+ }
139
+ }
140
+ return state
141
+ }