@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,274 @@
1
+ import type {
2
+ AgentRuntimePluginFactory,
3
+ AutobuildPluginManifest,
4
+ ForgePluginFactory,
5
+ PluginApiCompatibility,
6
+ PluginContractDescriptor,
7
+ TicketSourcePluginFactory,
8
+ WorkspaceProviderPluginFactory,
9
+ } from './manifest'
10
+ import { pluginApiCompatibility } from './manifest'
11
+ import type { AgentRunnerContractFactory } from '../ports/runner/contract'
12
+ import type { TicketSourceContractFactory } from '../ports/tickets/contract'
13
+ import type { WorkspaceProviderContractFactory } from '../ports/workspace/contract'
14
+ import type { ForgeContractFactory } from '../ports/forge/contract'
15
+
16
+ export type PluginPort = 'ticket-source' | 'agent-runtime' | 'workspace-provider' | 'forge'
17
+
18
+ export const PLUGIN_PORTS: readonly PluginPort[] = [
19
+ 'ticket-source',
20
+ 'agent-runtime',
21
+ 'workspace-provider',
22
+ 'forge',
23
+ ]
24
+
25
+ export type PluginResolutionKind = 'repo-path' | 'package'
26
+
27
+ export type RegistrationOwner =
28
+ | { kind: 'builtin'; name: 'autobuild' }
29
+ | { kind: 'plugin'; name: string }
30
+
31
+ export interface ConfiguredPluginSource {
32
+ kind: 'configured'
33
+ module: string
34
+ resolved: string
35
+ resolutionKind: PluginResolutionKind
36
+ pluginName: string
37
+ api: PluginApiCompatibility
38
+ }
39
+
40
+ export type RegistrationSource = { kind: 'builtin' } | ConfiguredPluginSource
41
+
42
+ export interface AdapterRegistration<Factory, ContractFactory = unknown> {
43
+ owner: RegistrationOwner
44
+ /** Builtins reserve their names while host construction may remain outside
45
+ * the registry. Plugin registrations always carry a normalized factory for
46
+ * open selectors to consume lazily. */
47
+ factory?: Factory
48
+ /** Host-enforced ticket-source credential declarations. Undefined for
49
+ * builtins and every other plugin port. */
50
+ requiredEnv?: readonly string[]
51
+ contract?: PluginContractDescriptor<ContractFactory>
52
+ source: RegistrationSource
53
+ }
54
+
55
+ export interface AdapterProjection {
56
+ port: PluginPort
57
+ name: string
58
+ owner: RegistrationOwner
59
+ source: RegistrationSource
60
+ hasContract: boolean
61
+ live: boolean
62
+ }
63
+
64
+ const BUILTIN = { kind: 'builtin', name: 'autobuild' } as const
65
+
66
+ function reserved<Factory, ContractFactory>(
67
+ names: readonly string[],
68
+ ): Map<string, AdapterRegistration<Factory, ContractFactory>> {
69
+ return new Map(names.map((name) => [name, { owner: BUILTIN, source: { kind: 'builtin' } }]))
70
+ }
71
+
72
+ export function pluginPortLabel(port: PluginPort): string {
73
+ switch (port) {
74
+ case 'ticket-source':
75
+ return 'ticket source'
76
+ case 'agent-runtime':
77
+ return 'agent runtime'
78
+ case 'workspace-provider':
79
+ return 'workspace provider'
80
+ case 'forge':
81
+ return 'forge'
82
+ }
83
+ }
84
+
85
+ function ownerDescription(owner: RegistrationOwner): string {
86
+ return owner.kind === 'builtin' ? 'builtin adapter' : `plugin "${owner.name}"`
87
+ }
88
+
89
+ interface PendingRegistration {
90
+ port: PluginPort
91
+ name: string
92
+ factory: unknown
93
+ requiredEnv?: readonly string[]
94
+ contract?: PluginContractDescriptor<unknown>
95
+ target: Map<string, AdapterRegistration<unknown, unknown>>
96
+ }
97
+
98
+ type InternalRegistration<AdapterFactory, ContractFactory> =
99
+ | AdapterFactory
100
+ | {
101
+ factory: AdapterFactory
102
+ requiredEnv?: readonly string[]
103
+ contract?: PluginContractDescriptor<ContractFactory>
104
+ }
105
+
106
+ function normalize<AdapterFactory, ContractFactory>(
107
+ registration: InternalRegistration<AdapterFactory, ContractFactory>,
108
+ ): {
109
+ factory: AdapterFactory
110
+ requiredEnv?: readonly string[]
111
+ contract?: PluginContractDescriptor<ContractFactory>
112
+ } {
113
+ if (typeof registration === 'function') {
114
+ return { factory: registration as AdapterFactory }
115
+ }
116
+ const object = registration as {
117
+ factory: AdapterFactory
118
+ requiredEnv?: readonly string[]
119
+ contract?: PluginContractDescriptor<ContractFactory>
120
+ }
121
+ return {
122
+ factory: object.factory,
123
+ ...(object.requiredEnv !== undefined ? { requiredEnv: [...object.requiredEnv] } : {}),
124
+ ...(object.contract !== undefined ? { contract: object.contract } : {}),
125
+ }
126
+ }
127
+
128
+ /** Owner-aware catalog populated at startup. Registration is atomic per
129
+ * plugin: every map and collision is checked before the first write. */
130
+ export class PluginRegistry {
131
+ readonly ticketSources = reserved<TicketSourcePluginFactory, TicketSourceContractFactory>([
132
+ 'file',
133
+ 'hosted',
134
+ 'linear',
135
+ ])
136
+ readonly agentRuntimes = reserved<AgentRuntimePluginFactory, AgentRunnerContractFactory>([
137
+ 'claude',
138
+ 'codex',
139
+ 'pi',
140
+ // Init's independent author/reviewer arrangement is addressed by this
141
+ // profile value, so a runtime with the same name could never be selected.
142
+ 'split',
143
+ ])
144
+ readonly workspaceProviders = reserved<
145
+ WorkspaceProviderPluginFactory,
146
+ WorkspaceProviderContractFactory
147
+ >(['git-worktree', 'vercel-sandbox'])
148
+ readonly forges = reserved<ForgePluginFactory, ForgeContractFactory>(['github', 'local-git'])
149
+
150
+ register(
151
+ plugin: AutobuildPluginManifest,
152
+ configuredSource?: Omit<ConfiguredPluginSource, 'kind' | 'pluginName' | 'api'>,
153
+ ): void {
154
+ const source: ConfiguredPluginSource = {
155
+ kind: 'configured',
156
+ module: configuredSource?.module ?? '<direct>',
157
+ resolved: configuredSource?.resolved ?? '<direct>',
158
+ resolutionKind: configuredSource?.resolutionKind ?? 'repo-path',
159
+ pluginName: plugin.name,
160
+ api: pluginApiCompatibility(plugin.apiVersion),
161
+ }
162
+ const pending: PendingRegistration[] = []
163
+ const collect = <AdapterFactory, ContractFactory>(
164
+ port: PluginPort,
165
+ registrations:
166
+ | Record<string, InternalRegistration<AdapterFactory, ContractFactory>>
167
+ | undefined,
168
+ target: Map<string, AdapterRegistration<AdapterFactory, ContractFactory>>,
169
+ ): void => {
170
+ for (const [name, registration] of Object.entries(registrations ?? {})) {
171
+ const existing = target.get(name)
172
+ if (existing !== undefined) {
173
+ throw new Error(
174
+ `${pluginPortLabel(port)} adapter "${name}" from plugin "${plugin.name}" collides ` +
175
+ `with ${ownerDescription(existing.owner)}; choose a unique adapter name`,
176
+ )
177
+ }
178
+ const normalized = normalize(registration)
179
+ pending.push({
180
+ port,
181
+ name,
182
+ factory: normalized.factory,
183
+ ...(normalized.requiredEnv !== undefined ? { requiredEnv: normalized.requiredEnv } : {}),
184
+ ...(normalized.contract !== undefined
185
+ ? { contract: normalized.contract as PluginContractDescriptor<unknown> }
186
+ : {}),
187
+ target: target as Map<string, AdapterRegistration<unknown, unknown>>,
188
+ })
189
+ }
190
+ }
191
+
192
+ collect(
193
+ 'ticket-source',
194
+ plugin.ticketSources as
195
+ | Record<
196
+ string,
197
+ InternalRegistration<TicketSourcePluginFactory, TicketSourceContractFactory>
198
+ >
199
+ | undefined,
200
+ this.ticketSources,
201
+ )
202
+ collect('agent-runtime', plugin.agentRuntimes, this.agentRuntimes)
203
+ collect('workspace-provider', plugin.workspaceProviders, this.workspaceProviders)
204
+ collect('forge', plugin.forges, this.forges)
205
+
206
+ const seen = new Set<string>()
207
+ for (const registration of pending) {
208
+ const key = `${registration.port}\0${registration.name}`
209
+ if (seen.has(key)) {
210
+ throw new Error(
211
+ `${pluginPortLabel(registration.port)} adapter "${registration.name}" is registered ` +
212
+ `more than once by plugin "${plugin.name}"`,
213
+ )
214
+ }
215
+ seen.add(key)
216
+ }
217
+
218
+ for (const registration of pending) {
219
+ registration.target.set(registration.name, {
220
+ owner: { kind: 'plugin', name: plugin.name },
221
+ factory: registration.factory,
222
+ ...(registration.requiredEnv !== undefined
223
+ ? { requiredEnv: registration.requiredEnv }
224
+ : {}),
225
+ ...(registration.contract !== undefined ? { contract: registration.contract } : {}),
226
+ source,
227
+ })
228
+ }
229
+ }
230
+
231
+ adapters(port?: PluginPort): AdapterProjection[] {
232
+ const maps: Array<[PluginPort, Map<string, AdapterRegistration<unknown, unknown>>]> = [
233
+ ['ticket-source', this.ticketSources as Map<string, AdapterRegistration<unknown, unknown>>],
234
+ ['agent-runtime', this.agentRuntimes as Map<string, AdapterRegistration<unknown, unknown>>],
235
+ [
236
+ 'workspace-provider',
237
+ this.workspaceProviders as Map<string, AdapterRegistration<unknown, unknown>>,
238
+ ],
239
+ ['forge', this.forges as Map<string, AdapterRegistration<unknown, unknown>>],
240
+ ]
241
+ const result: AdapterProjection[] = []
242
+ for (const [candidate, registrations] of maps) {
243
+ if (port !== undefined && candidate !== port) continue
244
+ for (const [name, registration] of registrations) {
245
+ result.push({
246
+ port: candidate,
247
+ name,
248
+ owner: registration.owner,
249
+ source: registration.source,
250
+ hasContract: registration.contract !== undefined,
251
+ live: registration.contract?.live === true,
252
+ })
253
+ }
254
+ }
255
+ return result
256
+ }
257
+
258
+ registration(port: PluginPort, name: string): AdapterRegistration<unknown, unknown> | undefined {
259
+ switch (port) {
260
+ case 'ticket-source':
261
+ return this.ticketSources.get(name)
262
+ case 'agent-runtime':
263
+ return this.agentRuntimes.get(name)
264
+ case 'workspace-provider':
265
+ return this.workspaceProviders.get(name)
266
+ case 'forge':
267
+ return this.forges.get(name)
268
+ }
269
+ }
270
+ }
271
+
272
+ export function createPluginRegistry(): PluginRegistry {
273
+ return new PluginRegistry()
274
+ }
@@ -0,0 +1,75 @@
1
+ import type { PluginFactoryContext } from './manifest'
2
+ import type { PluginRegistry } from './registry'
3
+ import { validateRuntimeRegistration, type RuntimeRegistry } from '../ports/runner/runtime'
4
+
5
+ export interface MaterializePluginRuntimesOpts {
6
+ repoRoot: string
7
+ env: Readonly<Record<string, string | undefined>>
8
+ }
9
+
10
+ function reason(error: unknown): string {
11
+ return error instanceof Error ? error.message : String(error)
12
+ }
13
+
14
+ /**
15
+ * Materialize plugin runtime factories into a fresh registry. Factories run in
16
+ * plugin registration order and receive the same repository-scoped context as
17
+ * the other adapter selectors. The caller's registry is never mutated, and a
18
+ * failure never exposes a partially assembled registry.
19
+ */
20
+ export async function materializePluginRuntimes(
21
+ builtins: RuntimeRegistry,
22
+ plugins: PluginRegistry,
23
+ opts: MaterializePluginRuntimesOpts,
24
+ ): Promise<RuntimeRegistry> {
25
+ const pluginEntries = [...plugins.agentRuntimes.entries()].filter(
26
+ (entry) => entry[1].owner.kind === 'plugin',
27
+ )
28
+
29
+ // The normal PluginRegistry reserves shipped names. Keep this independent
30
+ // preflight for injected/test registries and future composition callers.
31
+ for (const [name, registration] of pluginEntries) {
32
+ if (Object.hasOwn(builtins, name)) {
33
+ throw new Error(
34
+ `agent runtime "${name}" from plugin "${registration.owner.name}" ` +
35
+ 'collides with an existing runtime registration',
36
+ )
37
+ }
38
+ }
39
+
40
+ const merged = Object.assign(Object.create(null) as RuntimeRegistry, builtins)
41
+ const config = Object.freeze({})
42
+ const env = Object.freeze({ ...opts.env })
43
+ const context: PluginFactoryContext = {
44
+ config,
45
+ env,
46
+ repoRoot: opts.repoRoot,
47
+ }
48
+
49
+ for (const [name, adapter] of pluginEntries) {
50
+ const owner = adapter.owner
51
+ if (owner.kind !== 'plugin' || adapter.factory === undefined) continue
52
+
53
+ let value: unknown
54
+ try {
55
+ value = await adapter.factory(context)
56
+ } catch (error) {
57
+ throw new Error(
58
+ `agent runtime "${name}" factory from plugin "${owner.name}" failed: ${reason(error)}`,
59
+ { cause: error },
60
+ )
61
+ }
62
+
63
+ try {
64
+ merged[name] = validateRuntimeRegistration(value)
65
+ } catch (error) {
66
+ throw new Error(
67
+ `agent runtime "${name}" from plugin "${owner.name}" returned an ` +
68
+ `invalid runtime registration: ${reason(error)}`,
69
+ { cause: error },
70
+ )
71
+ }
72
+ }
73
+
74
+ return merged
75
+ }
@@ -0,0 +1,258 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { Forge, PrRef } from '../types'
3
+
4
+ export interface ForgeContractFactoryOptions {
5
+ /** Install/represent a real merge-blocking gate on the temporary base. */
6
+ gated?: boolean
7
+ }
8
+
9
+ /** Fixture controls arrange provider lifecycle that the Forge port does not
10
+ * expose. Probes are independent of the port call under assertion. */
11
+ export interface ForgeContractControls {
12
+ remoteHead(branch: string): Promise<string>
13
+ prepareMergeable(number: number): Promise<void>
14
+ closePr(number: number): Promise<void>
15
+ makeConflict(number: number): Promise<void>
16
+ advanceHead(number: number): Promise<string>
17
+ nativeAutoMergeEnabled(number: number): Promise<boolean>
18
+ commentExists(number: number, body: string): Promise<boolean>
19
+ mergeSha(number: number): Promise<string>
20
+ trackPr(number: number): Promise<void> | void
21
+ }
22
+
23
+ export interface ForgeContractHarness {
24
+ forge: Forge
25
+ /** Whether this provider exposes a native gated auto-merge setting. Local
26
+ * forges may support only the universal ungated guarded-squash path. */
27
+ nativeAutoMerge?: boolean
28
+ workspacePath: string
29
+ head: string
30
+ base: string
31
+ title: string
32
+ body: string
33
+ controls: ForgeContractControls
34
+ cleanup?: () => Promise<void>
35
+ }
36
+
37
+ export type ForgeContractFactory = (
38
+ opts?: ForgeContractFactoryOptions,
39
+ ) => Promise<ForgeContractHarness>
40
+
41
+ async function withForge(
42
+ factory: ForgeContractFactory,
43
+ opts: ForgeContractFactoryOptions | undefined,
44
+ run: (harness: ForgeContractHarness) => Promise<void>,
45
+ ): Promise<void> {
46
+ const harness = await factory(opts)
47
+ let failure: unknown
48
+ try {
49
+ await run(harness)
50
+ } catch (error) {
51
+ failure = error
52
+ }
53
+
54
+ try {
55
+ await harness.cleanup?.()
56
+ } catch (cleanupError) {
57
+ if (failure !== undefined) {
58
+ throw new AggregateError(
59
+ [failure, cleanupError],
60
+ 'Forge contract assertion and cleanup both failed',
61
+ )
62
+ }
63
+ throw cleanupError
64
+ }
65
+ if (failure !== undefined) throw failure
66
+ }
67
+
68
+ async function pushAndOpen(harness: ForgeContractHarness): Promise<{
69
+ pr: PrRef
70
+ remoteHead: string
71
+ }> {
72
+ await harness.forge.pushBranch(harness.workspacePath, harness.head)
73
+ const remoteHead = await harness.controls.remoteHead(harness.head)
74
+ const pr = await harness.forge.openPr({
75
+ workspacePath: harness.workspacePath,
76
+ head: harness.head,
77
+ base: harness.base,
78
+ title: harness.title,
79
+ body: harness.body,
80
+ mergeMessage: `${harness.title}\n\n${harness.body}`,
81
+ })
82
+ await harness.controls.trackPr(pr.number)
83
+ return { pr, remoteHead }
84
+ }
85
+
86
+ /**
87
+ * Reusable Forge semantics. Live fixtures manipulate only UUID-namespaced
88
+ * scratch branches/PRs; fake controls arrange the same states without changing
89
+ * the expected answers declared here.
90
+ */
91
+ export function describeForgeContract(name: string, factory: ForgeContractFactory): void {
92
+ describe(`Forge contract: ${name}`, () => {
93
+ test('pushBranch publishes the head and openPr returns/adopts the actual PR', async () => {
94
+ await withForge(factory, undefined, async (harness) => {
95
+ const { pr, remoteHead } = await pushAndOpen(harness)
96
+ expect(pr.number).toBeGreaterThan(0)
97
+ expect(pr.url.trim()).not.toBe('')
98
+ expect(pr.headSha).toBe(remoteHead)
99
+
100
+ const retry = await harness.forge.openPr({
101
+ workspacePath: harness.workspacePath,
102
+ head: harness.head,
103
+ base: harness.base,
104
+ title: `${harness.title} retry must adopt`,
105
+ body: 'retry body must not create another PR',
106
+ mergeMessage: 'retry merge message must not replace the original',
107
+ })
108
+ expect(retry).toEqual(pr)
109
+ })
110
+ })
111
+
112
+ test('getPrState discriminates an open mergeable PR', async () => {
113
+ await withForge(factory, undefined, async (harness) => {
114
+ const { pr } = await pushAndOpen(harness)
115
+ await harness.controls.prepareMergeable(pr.number)
116
+ expect(await harness.forge.getPrState(harness.workspacePath, pr.number)).toEqual({
117
+ state: 'open',
118
+ mergeable: true,
119
+ })
120
+ })
121
+ })
122
+
123
+ test('getPrState discriminates an open conflict', async () => {
124
+ await withForge(factory, undefined, async (harness) => {
125
+ const { pr } = await pushAndOpen(harness)
126
+ await harness.controls.makeConflict(pr.number)
127
+ expect(await harness.forge.getPrState(harness.workspacePath, pr.number)).toEqual({
128
+ state: 'open',
129
+ mergeable: false,
130
+ })
131
+ })
132
+ })
133
+
134
+ test('getPrState discriminates a closed PR', async () => {
135
+ await withForge(factory, undefined, async (harness) => {
136
+ const { pr } = await pushAndOpen(harness)
137
+ await harness.controls.closePr(pr.number)
138
+ expect(await harness.forge.getPrState(harness.workspacePath, pr.number)).toEqual({
139
+ state: 'closed',
140
+ })
141
+ })
142
+ })
143
+
144
+ test('getPrState discriminates a merged PR with its independently observed landing SHA', async () => {
145
+ await withForge(factory, undefined, async (harness) => {
146
+ const { pr } = await pushAndOpen(harness)
147
+ await harness.controls.prepareMergeable(pr.number)
148
+ const candidate = await harness.forge.setAutoMerge(harness.workspacePath, pr.number, true)
149
+ expect(candidate).toEqual({ kind: 'ungated', headSha: pr.headSha })
150
+ await harness.forge.squashMerge(harness.workspacePath, pr.number, pr.headSha)
151
+ const landingSha = await harness.controls.mergeSha(pr.number)
152
+ expect(await harness.forge.getPrState(harness.workspacePath, pr.number)).toEqual({
153
+ state: 'merged',
154
+ sha: landingSha,
155
+ })
156
+ })
157
+ })
158
+
159
+ test('setAutoMerge acknowledges native state and is idempotent', async () => {
160
+ await withForge(factory, { gated: true }, async (harness) => {
161
+ if (harness.nativeAutoMerge === false) return
162
+ const { pr } = await pushAndOpen(harness)
163
+ await harness.controls.prepareMergeable(pr.number)
164
+ expect(await harness.controls.nativeAutoMergeEnabled(pr.number)).toBe(false)
165
+
166
+ expect(await harness.forge.setAutoMerge(harness.workspacePath, pr.number, true)).toEqual({
167
+ kind: 'applied',
168
+ })
169
+ expect(await harness.controls.nativeAutoMergeEnabled(pr.number)).toBe(true)
170
+
171
+ expect(await harness.forge.setAutoMerge(harness.workspacePath, pr.number, true)).toEqual({
172
+ kind: 'applied',
173
+ })
174
+ expect(await harness.controls.nativeAutoMergeEnabled(pr.number)).toBe(true)
175
+
176
+ expect(await harness.forge.setAutoMerge(harness.workspacePath, pr.number, false)).toEqual({
177
+ kind: 'applied',
178
+ })
179
+ expect(await harness.controls.nativeAutoMergeEnabled(pr.number)).toBe(false)
180
+ })
181
+ })
182
+
183
+ test('squashMerge rejects a moved head and merges only the matching head', async () => {
184
+ await withForge(factory, undefined, async (harness) => {
185
+ const { pr } = await pushAndOpen(harness)
186
+ await harness.controls.prepareMergeable(pr.number)
187
+ const inspected = await harness.forge.setAutoMerge(harness.workspacePath, pr.number, true)
188
+ expect(inspected).toEqual({ kind: 'ungated', headSha: pr.headSha })
189
+
190
+ const advancedHead = await harness.controls.advanceHead(pr.number)
191
+ expect(advancedHead).not.toBe(pr.headSha)
192
+ await expect(
193
+ harness.forge.squashMerge(harness.workspacePath, pr.number, pr.headSha),
194
+ ).rejects.toThrow()
195
+ expect(await harness.forge.getPrState(harness.workspacePath, pr.number)).toEqual({
196
+ state: 'open',
197
+ mergeable: true,
198
+ })
199
+
200
+ const refreshed = await harness.forge.setAutoMerge(harness.workspacePath, pr.number, true)
201
+ expect(refreshed).toEqual({ kind: 'ungated', headSha: advancedHead })
202
+ await harness.forge.squashMerge(harness.workspacePath, pr.number, advancedHead)
203
+ const landingSha = await harness.controls.mergeSha(pr.number)
204
+ expect(await harness.forge.getPrState(harness.workspacePath, pr.number)).toEqual({
205
+ state: 'merged',
206
+ sha: landingSha,
207
+ })
208
+ })
209
+ })
210
+
211
+ test('closePr is idempotent and preserves merged PRs', async () => {
212
+ await withForge(factory, undefined, async (harness) => {
213
+ expect(harness.forge.closePr).toBeDefined()
214
+ const { pr } = await pushAndOpen(harness)
215
+ expect(await harness.forge.closePr!(harness.workspacePath, pr.number)).toEqual({
216
+ state: 'closed',
217
+ })
218
+ expect(await harness.forge.closePr!(harness.workspacePath, pr.number)).toEqual({
219
+ state: 'closed',
220
+ })
221
+
222
+ const merged = await pushAndOpen(harness)
223
+ await harness.controls.prepareMergeable(merged.pr.number)
224
+ const candidate = await harness.forge.setAutoMerge(
225
+ harness.workspacePath,
226
+ merged.pr.number,
227
+ true,
228
+ )
229
+ expect(candidate).toEqual({ kind: 'ungated', headSha: merged.pr.headSha })
230
+ await harness.forge.squashMerge(harness.workspacePath, merged.pr.number, merged.pr.headSha)
231
+ const sha = await harness.controls.mergeSha(merged.pr.number)
232
+ expect(await harness.forge.closePr!(harness.workspacePath, merged.pr.number)).toEqual({
233
+ state: 'merged',
234
+ sha,
235
+ })
236
+ })
237
+ })
238
+
239
+ test('deleteBranch removes the exact published branch and is idempotent', async () => {
240
+ await withForge(factory, undefined, async (harness) => {
241
+ expect(harness.forge.deleteBranch).toBeDefined()
242
+ await harness.forge.pushBranch(harness.workspacePath, harness.head)
243
+ await harness.forge.deleteBranch!(harness.workspacePath, harness.head)
244
+ await expect(harness.controls.remoteHead(harness.head)).rejects.toThrow()
245
+ await harness.forge.deleteBranch!(harness.workspacePath, harness.head)
246
+ })
247
+ })
248
+
249
+ test('commentOnPr delivers the exact unique body', async () => {
250
+ await withForge(factory, undefined, async (harness) => {
251
+ const { pr } = await pushAndOpen(harness)
252
+ const body = `Autobuild forge contract comment ${crypto.randomUUID()}`
253
+ await harness.forge.commentOnPr(harness.workspacePath, pr.number, body)
254
+ expect(await harness.controls.commentExists(pr.number, body)).toBe(true)
255
+ })
256
+ })
257
+ })
258
+ }
@@ -0,0 +1,74 @@
1
+ import { resolve } from 'node:path'
2
+ import type { PluginFactoryContext } from '../../plugins/manifest'
3
+ import type { AdapterRegistration, PluginRegistry } from '../../plugins/registry'
4
+ import type { Forge } from '../types'
5
+ import { GitHubForge } from './github'
6
+ import { LocalGitForge } from './local-git'
7
+
8
+ const EMPTY_CONFIG: Readonly<Record<string, unknown>> = Object.freeze({})
9
+
10
+ function reason(error: unknown): string {
11
+ return error instanceof Error ? error.message : String(error)
12
+ }
13
+
14
+ /** Resolve a forge selector without constructing the adapter. Composition
15
+ * boundaries use this before opening stores or invoking injectable wiring. */
16
+ export function resolveForgeRegistration(
17
+ name: string,
18
+ registry: PluginRegistry,
19
+ ): AdapterRegistration<(context: PluginFactoryContext) => Forge | Promise<Forge>> {
20
+ const registration = registry.forges.get(name)
21
+ if (registration !== undefined) return registration
22
+
23
+ const available = [...registry.forges.keys()].sort()
24
+ throw new Error(
25
+ `unknown forge adapter ${JSON.stringify(name)}; available forges: ${available.join(', ')}`,
26
+ )
27
+ }
28
+
29
+ /** Construct the configured Forge lazily from the validated startup catalog.
30
+ * The selected object is returned by identity so optional capabilities such as
31
+ * `prAttachments` remain authoritative. */
32
+ export async function createForge(opts: {
33
+ name: string
34
+ registry: PluginRegistry
35
+ env: Readonly<Record<string, string | undefined>>
36
+ repoRoot: string
37
+ /** Explicit repository identity (normalized origin). Origin-mode dispatch
38
+ * passes it so the builtin GitHub forge never probes a local checkout. */
39
+ repository?: string
40
+ }): Promise<Forge> {
41
+ const registration = resolveForgeRegistration(opts.name, opts.registry)
42
+ if (registration.owner.kind === 'builtin') {
43
+ if (opts.name === 'github') {
44
+ return new GitHubForge({
45
+ env: opts.env,
46
+ repoRoot: opts.repoRoot,
47
+ ...(opts.repository !== undefined ? { repository: opts.repository } : {}),
48
+ })
49
+ }
50
+ if (opts.name === 'local-git') return new LocalGitForge()
51
+ throw new Error(`builtin forge adapter ${JSON.stringify(opts.name)} has no constructor`)
52
+ }
53
+
54
+ if (registration.factory === undefined) {
55
+ throw new Error(
56
+ `forge adapter ${JSON.stringify(opts.name)} from plugin ` +
57
+ `${JSON.stringify(registration.owner.name)} has no factory`,
58
+ )
59
+ }
60
+
61
+ try {
62
+ return await registration.factory({
63
+ config: EMPTY_CONFIG,
64
+ env: opts.env,
65
+ repoRoot: resolve(opts.repoRoot),
66
+ })
67
+ } catch (error) {
68
+ throw new Error(
69
+ `forge adapter ${JSON.stringify(opts.name)} from plugin ` +
70
+ `${JSON.stringify(registration.owner.name)} failed to initialize: ${reason(error)}`,
71
+ { cause: error },
72
+ )
73
+ }
74
+ }