@defrex/autobuild 0.8.0 → 0.9.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 (147) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +313 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +57 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +151 -25
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +169 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +10 -0
  13. package/package.json +43 -23
  14. package/packages/core/src/cli/artifact.ts +130 -6
  15. package/packages/core/src/cli/binary.ts +9 -1
  16. package/packages/core/src/cli/build-control.ts +110 -13
  17. package/packages/core/src/cli/context.ts +46 -0
  18. package/packages/core/src/cli/dashboard/model.ts +57 -7
  19. package/packages/core/src/cli/dashboard/poll.ts +69 -10
  20. package/packages/core/src/cli/dashboard/render.ts +34 -5
  21. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  22. package/packages/core/src/cli/dispatch.ts +320 -46
  23. package/packages/core/src/cli/harvest.ts +122 -8
  24. package/packages/core/src/cli/help.ts +47 -8
  25. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  26. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  27. package/packages/core/src/cli/init-validation.ts +248 -222
  28. package/packages/core/src/cli/main.ts +70 -11
  29. package/packages/core/src/cli/mcp.ts +23 -1
  30. package/packages/core/src/cli/plugin.ts +7 -4
  31. package/packages/core/src/cli/remote-poll.ts +277 -0
  32. package/packages/core/src/cli/repo-state.ts +22 -0
  33. package/packages/core/src/cli/repository-status.ts +39 -1
  34. package/packages/core/src/cli/status.ts +149 -27
  35. package/packages/core/src/cli/terminals.ts +2 -1
  36. package/packages/core/src/cli/ticket-assets.ts +247 -0
  37. package/packages/core/src/cli/ticket.ts +307 -7
  38. package/packages/core/src/cli/wait.ts +147 -39
  39. package/packages/core/src/cli/watch.ts +128 -166
  40. package/packages/core/src/config/live.ts +22 -0
  41. package/packages/core/src/config/pipeline-source.ts +376 -0
  42. package/packages/core/src/config/schema.ts +230 -19
  43. package/packages/core/src/events/catalog.ts +1 -0
  44. package/packages/core/src/events/globs.ts +47 -0
  45. package/packages/core/src/events/payloads.ts +52 -2
  46. package/packages/core/src/events/repository.ts +52 -1
  47. package/packages/core/src/events/sessions.ts +69 -10
  48. package/packages/core/src/events/workspace-remote.ts +12 -0
  49. package/packages/core/src/integration/harness.ts +2 -1
  50. package/packages/core/src/kernel/auto-merge-default.ts +123 -0
  51. package/packages/core/src/kernel/phases.ts +16 -2
  52. package/packages/core/src/kernel/reducer.ts +40 -1
  53. package/packages/core/src/operator/annotations.ts +6 -0
  54. package/packages/core/src/operator/control.ts +19 -5
  55. package/packages/core/src/operator/index.ts +71 -6
  56. package/packages/core/src/operator/query.ts +208 -19
  57. package/packages/core/src/operator/registry.ts +55 -10
  58. package/packages/core/src/operator/requests.ts +9 -6
  59. package/packages/core/src/operator/sandbox-host.ts +140 -0
  60. package/packages/core/src/operator/sandbox.ts +247 -14
  61. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  62. package/packages/core/src/orchestrator/approvals.ts +115 -0
  63. package/packages/core/src/orchestrator/conversation.ts +167 -0
  64. package/packages/core/src/orchestrator/model.ts +86 -0
  65. package/packages/core/src/orchestrator/prompt.ts +61 -0
  66. package/packages/core/src/orchestrator/tools.ts +117 -0
  67. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  68. package/packages/core/src/plugin-sdk/index.ts +51 -1
  69. package/packages/core/src/plugins/load.ts +135 -7
  70. package/packages/core/src/plugins/manifest.ts +183 -10
  71. package/packages/core/src/plugins/registry.ts +73 -4
  72. package/packages/core/src/ports/forge/create.ts +1 -1
  73. package/packages/core/src/ports/forge/fake.ts +31 -0
  74. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  75. package/packages/core/src/ports/forge/github.ts +13 -2
  76. package/packages/core/src/ports/forge/local-git.ts +31 -1
  77. package/packages/core/src/ports/runner/claude.ts +2 -2
  78. package/packages/core/src/ports/runner/codex.ts +6 -2
  79. package/packages/core/src/ports/runner/fake.ts +7 -1
  80. package/packages/core/src/ports/runner/pi.ts +8 -6
  81. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  82. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  83. package/packages/core/src/ports/types.ts +17 -2
  84. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  85. package/packages/core/src/ports/workspace/create.ts +74 -39
  86. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  87. package/packages/core/src/ports/workspace/fake.ts +169 -4
  88. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  89. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  90. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  91. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  92. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  93. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  94. package/packages/core/src/processes/build-child.ts +17 -2
  95. package/packages/core/src/processes/build-execution-state.ts +37 -0
  96. package/packages/core/src/processes/build-runner.ts +54 -3
  97. package/packages/core/src/processes/dispatcher.ts +247 -41
  98. package/packages/core/src/processes/harvest-child.ts +5 -0
  99. package/packages/core/src/processes/harvest-runner.ts +66 -19
  100. package/packages/core/src/processes/harvest.ts +196 -24
  101. package/packages/core/src/processes/orchestrator-tick.ts +319 -0
  102. package/packages/core/src/processes/sandbox-state.ts +10 -0
  103. package/packages/core/src/store/adapter.ts +32 -0
  104. package/packages/core/src/store/build-scope.ts +64 -0
  105. package/packages/core/src/store/contract.ts +868 -0
  106. package/packages/core/src/store/digest.ts +49 -0
  107. package/packages/core/src/store/local/schema.ts +29 -2
  108. package/packages/core/src/store/local/store.ts +338 -4
  109. package/packages/core/src/store/memory.ts +179 -0
  110. package/packages/core/src/store/new-build.ts +17 -0
  111. package/packages/core/src/store/phase-session.ts +71 -0
  112. package/packages/core/src/store/remote/client.ts +260 -1
  113. package/packages/core/src/store/remote/index.ts +12 -2
  114. package/packages/core/src/store/remote/protocol.ts +100 -12
  115. package/packages/core/src/store/remote/version.ts +6 -2
  116. package/packages/core/src/store/repo-state-events.ts +140 -0
  117. package/packages/core/src/store/retention.ts +7 -0
  118. package/packages/core/src/store/session-handle.ts +64 -0
  119. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
  120. package/packages/core/src/store/streams/session-writer.ts +42 -2
  121. package/packages/core/src/store/streams/wait.ts +4 -5
  122. package/packages/core/src/store/ticket-assets.ts +425 -0
  123. package/packages/core/src/store/types.ts +122 -1
  124. package/packages/core/src/testing/index.ts +59 -0
  125. package/skills/code-review/SKILL.md +5 -1
  126. package/skills/guide/SKILL.md +126 -33
  127. package/skills/guide/references/operator-api.md +2 -2
  128. package/skills/guide/references/plugin-authoring.md +17 -0
  129. package/skills/guide/references/remote-store-protocol.md +169 -16
  130. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  131. package/skills/guide/references/setup.md +12 -9
  132. package/skills/implement/SKILL.md +3 -0
  133. package/skills/operate/SKILL.md +126 -0
  134. package/skills/plan/SKILL.md +4 -0
  135. package/skills/plan-review/SKILL.md +5 -1
  136. package/skills/spec/SKILL.md +46 -3
  137. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  138. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  139. package/packages/core/src/cli/testkit.ts +0 -194
  140. package/packages/core/src/markdown.ts +0 -67
  141. package/packages/core/src/operator/client.ts +0 -311
  142. package/packages/core/src/operator/server.ts +0 -616
  143. package/packages/core/src/operator/sessions.ts +0 -223
  144. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  145. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  146. package/packages/core/src/store/remote/server.ts +0 -923
  147. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -1,4 +1,5 @@
1
- import { isAbsolute } from 'node:path'
1
+ import { existsSync } from 'node:fs'
2
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
2
3
  import { pathToFileURL } from 'node:url'
3
4
  import { ZodError } from 'zod'
4
5
  import { distributionRoot } from '../distribution'
@@ -7,6 +8,7 @@ import {
7
8
  parsePluginManifest,
8
9
  pluginApiCompatibility,
9
10
  PluginApiCompatibilityError,
11
+ type AutobuildPluginManifest,
10
12
  type PluginApiCompatibility,
11
13
  } from './manifest'
12
14
  import { createPluginRegistry, type PluginRegistry, type PluginResolutionKind } from './registry'
@@ -15,6 +17,49 @@ function reason(error: unknown): string {
15
17
  return error instanceof Error ? error.message : String(error)
16
18
  }
17
19
 
20
+ /** The package directory a bare specifier names: `@scope/name/sub` names
21
+ * `@scope/name`, `name/sub` names `name`. Subpath exports resolve inside the
22
+ * package, so the on-disk gate below must check the package, not the
23
+ * subpath. */
24
+ function packageNameOf(specifier: string): string {
25
+ const segments = specifier.split('/')
26
+ return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : (segments[0] ?? specifier)
27
+ }
28
+
29
+ /** First directory of `root`'s ancestor chain (inclusive) under which
30
+ * `node_modules/<specifier>` exists on disk, or undefined. Pure filesystem
31
+ * syscalls — deliberately no Bun APIs: `Bun.resolveSync` on a bare specifier
32
+ * that no `node_modules` lookup satisfies hands the specifier to Bun's
33
+ * runtime auto-installer, which fetches from the network and writes a cache;
34
+ * on a read-only filesystem (the hosted dispatcher function) that is a
35
+ * fatal, uncatchable process exit (AUT-587). Gating on a disk hit keeps the
36
+ * installer unreachable: per the planner-verified Bun 1.4 behavior, a
37
+ * resolver call whose base chain holds the package on disk resolves from
38
+ * disk and never touches the installer.
39
+ *
40
+ * Termination (AUT-597): `root` is resolved to an absolute path before the
41
+ * walk begins, so a relative candidate root cannot make `dirname` a fixed
42
+ * point and spin the loop forever. The `parent === current` guard backstops
43
+ * the fixed point (`dirname('/') === '/'`) should the loop's start point
44
+ * ever change.
45
+ *
46
+ * Candidate-root provenance (AUT-604): production callers never supply a
47
+ * non-filesystem token here. Origin mode does not pass
48
+ * `'<hosted-dispatcher>'` — `abDispatch` normalizes `targetRepo` to the
49
+ * per-origin scratch root before `loadPlugins` runs — so candidate roots
50
+ * are always real filesystem roots; a relative one reaching this walk is a
51
+ * caller bug with a fail-closed outcome, not a supported input. */
52
+ function diskNodeModulesDirectory(specifier: string, root: string): string | undefined {
53
+ const pkg = packageNameOf(specifier)
54
+ let current = resolve(root)
55
+ while (true) {
56
+ if (existsSync(join(current, 'node_modules', pkg))) return current
57
+ const parent = dirname(current)
58
+ if (parent === current) return undefined
59
+ current = parent
60
+ }
61
+ }
62
+
18
63
  function importUrl(resolved: string): string {
19
64
  return resolved.startsWith('file:') ? resolved : pathToFileURL(resolved).href
20
65
  }
@@ -30,12 +75,15 @@ export interface PluginModuleReport {
30
75
  resolutionKind: PluginResolutionKind
31
76
  resolved?: string
32
77
  resolvedFrom?: PluginResolutionSource
33
- status: 'loaded' | 'failed'
78
+ status: 'loaded' | 'failed' | 'skipped'
34
79
  stage: PluginLoadStage | 'loaded'
35
80
  pluginName?: string
36
81
  api?: PluginApiCompatibility
37
82
  error?: string
38
83
  cause?: unknown
84
+ /** One-line explanation for a `skipped` report, emitted through the
85
+ * loader's notice channel. Undefined for `loaded` and `failed`. */
86
+ notice?: string
39
87
  }
40
88
 
41
89
  export interface PluginDiagnosis {
@@ -52,6 +100,14 @@ export interface PluginLoadOptions {
52
100
  * loads without being added to the repository. Defaults to the running
53
101
  * distribution's root. */
54
102
  installationRoot?: string
103
+ /** Guest context (AUT-517): a package-kind specifier that cannot be
104
+ * resolved from either candidate is skipped with a notice instead of
105
+ * failing the process, because guests never construct workspace providers
106
+ * and may legitimately be handed a provider plugin they need not load.
107
+ * Repo-path specifiers and every post-resolution failure stay fail-closed. */
108
+ guest?: boolean
109
+ /** Notice channel for skipped loads. Defaults to one line on stderr. */
110
+ onNotice?: (line: string) => void
55
111
  }
56
112
 
57
113
  export function pluginResolutionKind(moduleSpecifier: string): PluginResolutionKind {
@@ -79,6 +135,14 @@ function failed(
79
135
  }
80
136
  }
81
137
 
138
+ /** True when the manifest declares no registrations outside the
139
+ * workspace-provider map. */
140
+ function declaresOnlyWorkspaceProviderRegistrations(manifest: AutobuildPluginManifest): boolean {
141
+ const empty = (registrations: Record<string, unknown> | undefined): boolean =>
142
+ registrations === undefined || Object.keys(registrations).length === 0
143
+ return empty(manifest.ticketSources) && empty(manifest.agentRuntimes) && empty(manifest.forges)
144
+ }
145
+
82
146
  function manifestIdentity(value: unknown): {
83
147
  pluginName?: string
84
148
  api?: PluginApiCompatibility
@@ -130,8 +194,17 @@ export async function attemptPlugin(
130
194
  let found: { path: string; source: PluginResolutionSource } | undefined
131
195
  let lastError: unknown
132
196
  for (const candidate of candidates) {
197
+ // Disk-first gate (AUT-587): only a candidate whose `node_modules`
198
+ // chain holds the package on disk reaches the resolver. Bun's runtime
199
+ // auto-installer fires exactly when disk resolution fails, so a miss
200
+ // here must be recorded without calling `Bun.resolveSync` at all.
201
+ const diskRoot = diskNodeModulesDirectory(moduleSpecifier, candidate.root)
202
+ if (diskRoot === undefined) {
203
+ failures.push(`${candidate.source} "${candidate.root}"`)
204
+ continue
205
+ }
133
206
  try {
134
- found = { path: Bun.resolveSync(moduleSpecifier, candidate.root), source: candidate.source }
207
+ found = { path: Bun.resolveSync(moduleSpecifier, diskRoot), source: candidate.source }
135
208
  break
136
209
  } catch (error) {
137
210
  lastError = error
@@ -139,10 +212,26 @@ export async function attemptPlugin(
139
212
  }
140
213
  }
141
214
  if (found === undefined) {
215
+ if (options.guest === true) {
216
+ return {
217
+ ...initial,
218
+ status: 'skipped',
219
+ stage: 'resolution',
220
+ notice:
221
+ `plugin module "${moduleSpecifier}" could not be resolved from ${failures.join(' or ')}; ` +
222
+ 'guests never construct workspace providers, so the provider plugin is skipped here',
223
+ }
224
+ }
225
+ // A disk miss never reached the resolver, so there is no Bun error to
226
+ // quote — say directly what the gate established instead.
227
+ const detail =
228
+ lastError !== undefined
229
+ ? reason(lastError)
230
+ : `no "node_modules/${packageNameOf(moduleSpecifier)}" entry exists on disk under any candidate root`
142
231
  return failed(
143
232
  initial,
144
233
  'resolution',
145
- `plugin module "${moduleSpecifier}" could not be resolved from ${failures.join(' or ')}: ${reason(lastError)}`,
234
+ `plugin module "${moduleSpecifier}" could not be resolved from ${failures.join(' or ')}: ${detail}`,
146
235
  lastError,
147
236
  )
148
237
  }
@@ -201,6 +290,33 @@ export async function attemptPlugin(
201
290
  pluginName: manifest.name,
202
291
  api,
203
292
  }
293
+ // Transitional duplicate-skip (AUT-517): while the builtin hosts the
294
+ // vercel-sandbox implementation, a configured plugin that re-registers the
295
+ // same workspace-provider name must be accepted and skipped, not thrown
296
+ // out of startup. Skip the whole module IFF it declares at least one
297
+ // registration, only workspace-provider registrations, and EVERY declared
298
+ // name collides with a builtin workspace-provider registration. Any other
299
+ // collision (other ports, plugin-vs-plugin, mixed fresh/colliding) throws
300
+ // exactly as before via registry.register below. Keyed on builtin
301
+ // ownership, so the rule retires itself when the builtin is removed.
302
+ const collisions = registry.builtinWorkspaceProviderCollisions(manifest)
303
+ if (
304
+ collisions.length > 0 &&
305
+ declaresOnlyWorkspaceProviderRegistrations(manifest) &&
306
+ collisions.length === Object.keys(manifest.workspaceProviders ?? {}).length
307
+ ) {
308
+ const names = Object.keys(manifest.workspaceProviders ?? {})
309
+ .map((name) => JSON.stringify(name))
310
+ .join(', ')
311
+ return {
312
+ ...identified,
313
+ status: 'skipped',
314
+ stage: 'registration',
315
+ notice:
316
+ `plugin "${manifest.name}" declares only builtin workspace-provider registration(s) ${names}; ` +
317
+ 'skipping it — the builtin keeps serving the provider',
318
+ }
319
+ }
204
320
  try {
205
321
  registry.register(manifest, {
206
322
  module: moduleSpecifier,
@@ -224,7 +340,9 @@ export async function attemptPlugin(
224
340
  }
225
341
 
226
342
  /** Exhaustively attempt configured modules in declaration order. Failed
227
- * modules leave no registrations; later healthy modules still load. */
343
+ * modules leave no registrations; later healthy modules still load. Skipped
344
+ * modules (builtin duplicate-skip, guest tolerance) register nothing and
345
+ * count as healthy. */
228
346
  export async function diagnosePlugins(
229
347
  modules: readonly string[],
230
348
  repoRoot: string,
@@ -238,22 +356,32 @@ export async function diagnosePlugins(
238
356
  return {
239
357
  registry,
240
358
  reports,
241
- healthy: reports.every((report) => report.status === 'loaded'),
359
+ healthy: reports.every((report) => report.status !== 'failed'),
242
360
  }
243
361
  }
244
362
 
245
- /** Dispatch compatibility wrapper: preserve first-failure, fail-closed startup. */
363
+ function defaultPluginNotice(line: string): void {
364
+ console.error(line)
365
+ }
366
+
367
+ /** Dispatch compatibility wrapper: preserve first-failure, fail-closed startup.
368
+ * Skipped modules are announced through the notice channel and load
369
+ * continues. */
246
370
  export async function loadPlugins(
247
371
  modules: readonly string[],
248
372
  repoRoot: string,
249
373
  options: PluginLoadOptions = {},
250
374
  registry: PluginRegistry = createPluginRegistry(),
251
375
  ): Promise<PluginRegistry> {
376
+ const onNotice = options.onNotice ?? defaultPluginNotice
252
377
  for (const moduleSpecifier of modules) {
253
378
  const report = await attemptPlugin(moduleSpecifier, repoRoot, registry, options)
254
379
  if (report.status === 'failed') {
255
380
  throw new Error(report.error, { cause: report.cause })
256
381
  }
382
+ if (report.status === 'skipped' && report.notice !== undefined) {
383
+ onNotice(report.notice)
384
+ }
257
385
  }
258
386
  return registry
259
387
  }
@@ -8,9 +8,13 @@ import type { RuntimeRegistration } from '../ports/runner/runtime'
8
8
  import type { TicketSourceContractFactory } from '../ports/tickets/contract'
9
9
  import type { ForgeContractFactory } from '../ports/forge/contract'
10
10
  import type { WorkspaceProviderContractFactory } from '../ports/workspace/contract'
11
+ import type {
12
+ RuntimeReferencesSource,
13
+ WorkspaceProviderCapabilities,
14
+ } from '../ports/workspace/provider-capabilities'
11
15
 
12
16
  /** Version of the in-process plugin contract exposed by `@defrex/autobuild/plugin-sdk`. */
13
- export const PLUGIN_API_VERSION = '1.5.0' as const
17
+ export const PLUGIN_API_VERSION = '1.7.0' as const
14
18
 
15
19
  /** Context supplied when a registered adapter or contract fixture is selected. */
16
20
  export interface PluginFactoryContext<Config = Record<string, unknown>> {
@@ -22,12 +26,48 @@ export interface PluginFactoryContext<Config = Record<string, unknown>> {
22
26
  repoRoot: string
23
27
  }
24
28
 
25
- export type PluginFactory<Adapter, Config = Record<string, unknown>> = {
29
+ export type PluginFactory<
30
+ Adapter,
31
+ Config = Record<string, unknown>,
32
+ Context = PluginFactoryContext<Config>,
33
+ > = {
26
34
  /** The method-index form intentionally makes this callback bivariant, so a
27
- * plugin can retain its concrete config type across the erased manifest. */
28
- invoke(context: PluginFactoryContext<Config>): Adapter | Promise<Adapter>
35
+ * plugin can retain its concrete config type across the erased manifest —
36
+ * and a port that widens the context (workspace providers receive the
37
+ * host-derived seams, AUT-560) keeps every base-context factory assignable. */
38
+ invoke(context: Context): Adapter | Promise<Adapter>
29
39
  }['invoke']
30
40
 
41
+ /** Context supplied to a workspace-provider factory: the shared
42
+ * `{ config, env, repoRoot }` context plus the host-derived seams a
43
+ * store-requiring remote provider needs (AUT-560). Every added field is
44
+ * optional — the shared call sites that carry no seam construct the plain
45
+ * shared context, and a factory that ignores the new fields changes nothing.
46
+ * Plugin API 1.7.0. */
47
+ export interface WorkspaceProviderPluginFactoryContext<Config = Record<string, unknown>>
48
+ extends PluginFactoryContext<Config> {
49
+ /** Git store URL the dispatcher selected (`AB_STORE`). Guaranteed present
50
+ * when the registration declares `capabilities.storeRequirements`:
51
+ * `createWorkspaceProvider` refuses construction before the factory runs
52
+ * when either store seam is missing. */
53
+ storeRef?: string
54
+ /** Scoped store token (`AB_TOKEN`). Guaranteed present under
55
+ * `capabilities.storeRequirements` for the same reason. */
56
+ storeToken?: string
57
+ /** Host-derived effective runtime routes (the referenced runtimes and their
58
+ * selection provenance). Absent at call sites that reference no runtimes
59
+ * (for example the MCP server), which never construct a workspace. */
60
+ runtimeReferences?: RuntimeReferencesSource
61
+ /** Checkout-less seam: the repository's HTTPS origin. When absent the
62
+ * provider falls back to host `git` from `repoRoot` — the same default the
63
+ * builtin `VercelSandboxProvider` applies. */
64
+ origin?: () => Promise<string>
65
+ /** Checkout-less seam: the remote branch's current head, `undefined` when
66
+ * the branch does not exist. When absent the provider falls back to host
67
+ * `git` from `repoRoot`. */
68
+ remoteBranchHead?: (branch: string) => Promise<string | undefined>
69
+ }
70
+
31
71
  export type TicketSourcePluginFactory<Config = Record<string, unknown>> = PluginFactory<
32
72
  TicketSource,
33
73
  Config
@@ -53,10 +93,29 @@ export type AgentRuntimePluginFactory<Config = Record<string, unknown>> = Plugin
53
93
  >
54
94
  export type WorkspaceProviderPluginFactory<Config = Record<string, unknown>> = PluginFactory<
55
95
  WorkspaceProvider,
56
- Config
96
+ Config,
97
+ WorkspaceProviderPluginFactoryContext<Config>
57
98
  >
58
99
  export type ForgePluginFactory<Config = Record<string, unknown>> = PluginFactory<Forge, Config>
59
100
 
101
+ /** Optional host-enforced metadata for a workspace provider. Bare factories
102
+ * remain valid for plugin API 1.0 compatibility. Required environment lives
103
+ * on `capabilities.requiredEnv` — a descriptor-level `requiredEnv` is
104
+ * rejected at manifest parse (see `workspaceProviderRegistrationSchema`). */
105
+ export interface WorkspaceProviderPluginDescriptor<Config = Record<string, unknown>> {
106
+ factory: WorkspaceProviderPluginFactory<Config>
107
+ /** Optional shared-suite certification fixture. */
108
+ contract?: PluginContractDescriptor<WorkspaceProviderContractFactory>
109
+ /** Behavior declarations the host enforces without naming the provider
110
+ * (AUT-516): config schema, forges, environment groups, store requirements,
111
+ * origin validation, and remote readiness. */
112
+ capabilities?: WorkspaceProviderCapabilities
113
+ }
114
+
115
+ export type WorkspaceProviderPluginRegistration<Config = Record<string, unknown>> =
116
+ | WorkspaceProviderPluginFactory<Config>
117
+ | WorkspaceProviderPluginDescriptor<Config>
118
+
60
119
  export interface PluginContractDescriptor<ContractFactory, Config = Record<string, unknown>> {
61
120
  /** Creates the unchanged shared-suite harness factory when the test verb runs. */
62
121
  factory: PluginFactory<ContractFactory, Config>
@@ -75,10 +134,6 @@ export type AgentRuntimePluginRegistration = PluginAdapterRegistration<
75
134
  AgentRuntimePluginFactory,
76
135
  AgentRunnerContractFactory
77
136
  >
78
- export type WorkspaceProviderPluginRegistration = PluginAdapterRegistration<
79
- WorkspaceProviderPluginFactory,
80
- WorkspaceProviderContractFactory
81
- >
82
137
  export type ForgePluginRegistration = PluginAdapterRegistration<
83
138
  ForgePluginFactory,
84
139
  ForgeContractFactory
@@ -212,6 +267,120 @@ const ticketSourceRegistrationSchema = z.unknown().transform((value, ctx) => {
212
267
  return z.NEVER
213
268
  })
214
269
 
270
+ /** Function-valued capability fields (origin validation, redaction, summary
271
+ * lines, remote readiness). */
272
+ const capabilityFunctionSchema = z.custom<(...args: never[]) => unknown>(
273
+ (value) => typeof value === 'function',
274
+ 'must be a function',
275
+ )
276
+
277
+ const workspaceProviderCapabilitiesSchema = z.strictObject({
278
+ // A Zod schema carries both `parse` and `safeParse`; the consumers use each
279
+ // (`create.ts` parses with `safeParse`, `init-validation.ts` with `parse`),
280
+ // so requiring only one would let a half-shaped object past manifest parsing
281
+ // and crash the other seam with a TypeError instead of the config
282
+ // diagnostic (f_69fc887c).
283
+ configSchema: z
284
+ .custom<z.ZodType>((value) => {
285
+ if (value === null || typeof value !== 'object') return false
286
+ const shape = value as { parse?: unknown; safeParse?: unknown }
287
+ return typeof shape.parse === 'function' && typeof shape.safeParse === 'function'
288
+ }, 'must be a Zod schema')
289
+ .optional(),
290
+ configRefusal: z.string().optional(),
291
+ requireRuntimeProvisioning: z.boolean().optional(),
292
+ sandboxForbiddenEnv: z.array(z.string()).optional(),
293
+ supportedForges: z.array(z.string()).optional(),
294
+ forgeDispatchMessage: z.string().optional(),
295
+ forgeValidationMessage: z.string().optional(),
296
+ requiredEnv: z
297
+ .array(
298
+ z.strictObject({
299
+ alternatives: z.array(z.array(z.string().min(1)).min(1)).min(1),
300
+ dispatchMessage: z.string().optional(),
301
+ validationMessage: z.string().optional(),
302
+ }),
303
+ )
304
+ // AUT-554: a group carrying neither message is skipped at both
305
+ // enforcement sites (dispatch.ts omits an undefined dispatchMessage;
306
+ // init-validation.ts's declaredForgeEnvChecks omits an undefined
307
+ // validationMessage), so accepting it here would license a declaration
308
+ // the host never enforces. The issue anchors at the group's index so the
309
+ // rendered path names it by location and the message names it by its
310
+ // alternatives. The shape guard keeps the check quiet when the group's
311
+ // structural diagnostics already speak: zod v4 runs refinements even
312
+ // after a check-stage failure inside the group (an empty alternatives
313
+ // array still parses through), and a malformed group must not grow a
314
+ // second, degraded copy of this issue.
315
+ .superRefine((groups, ctx) => {
316
+ groups.forEach((group, index) => {
317
+ if (group.dispatchMessage !== undefined || group.validationMessage !== undefined) return
318
+ // Skip a structurally malformed group: its own diagnostics speak.
319
+ if (
320
+ group.alternatives.length === 0 ||
321
+ group.alternatives.some((names) => names.length === 0)
322
+ )
323
+ return
324
+ const alternatives = group.alternatives.map((names) => names.join(', ')).join(' | ')
325
+ ctx.addIssue({
326
+ code: 'custom',
327
+ path: [index],
328
+ message: `requiredEnv group (alternatives: ${alternatives}) declares neither dispatchMessage nor validationMessage; declare at least one so the host enforces this group — a group with neither is silently skipped at dispatch and init validation`,
329
+ })
330
+ })
331
+ })
332
+ .optional(),
333
+ processEnvOnly: z
334
+ .array(z.strictObject({ name: z.string().min(1), message: z.string() }))
335
+ .optional(),
336
+ storeRequirements: z
337
+ .strictObject({
338
+ constructionMessage: z.string(),
339
+ storeRefMessage: z.string(),
340
+ storeTokenMessage: z.string(),
341
+ })
342
+ .optional(),
343
+ validateOrigin: capabilityFunctionSchema.optional(),
344
+ originReadFailureMessage: z.string().optional(),
345
+ guestEnvNames: capabilityFunctionSchema.optional(),
346
+ describeEnvironment: capabilityFunctionSchema.optional(),
347
+ validateReadiness: capabilityFunctionSchema.optional(),
348
+ })
349
+
350
+ const workspaceProviderDescriptorSchema = z.strictObject({
351
+ factory: factorySchema,
352
+ contract: contractSchema.optional(),
353
+ capabilities: workspaceProviderCapabilitiesSchema.optional(),
354
+ })
355
+
356
+ /**
357
+ * The workspace-provider counterpart of `ticketSourceRegistrationSchema`, with
358
+ * one deliberate tightening (AUT-516): a top-level `requiredEnv` key is
359
+ * rejected with a bespoke remediation message instead of the generic
360
+ * unrecognized-key complaint. The generic registration schema accepts the key
361
+ * today, but its only consumer is ticket-source construction — routing
362
+ * workspace-provider env requirements there would leave the declaration
363
+ * parsed and never checked, exactly the ignored-declaration failure mode this
364
+ * port's capabilities exist to eliminate. Required environment belongs on
365
+ * `capabilities.requiredEnv`, which carries the per-site messages a bare name
366
+ * list cannot.
367
+ */
368
+ const workspaceProviderRegistrationSchema = z.unknown().transform((value, ctx) => {
369
+ if (typeof value === 'function') return value
370
+ if (value !== null && typeof value === 'object' && 'requiredEnv' in value) {
371
+ ctx.addIssue({
372
+ code: 'custom',
373
+ message:
374
+ 'workspaceProviders.<name>.requiredEnv is not supported; declare required environment as workspaceProviders.<name>.capabilities.requiredEnv',
375
+ })
376
+ return z.NEVER
377
+ }
378
+ const parsed = workspaceProviderDescriptorSchema.safeParse(value)
379
+ if (parsed.success) return parsed.data
380
+ forwardIssues(parsed.error.issues, ctx)
381
+ return z.NEVER
382
+ })
383
+
215
384
  /** Strict runtime contract for a plugin module's default export.
216
385
  *
217
386
  * `.optional()` stays outermost, so an undeclared port remains `undefined`
@@ -221,7 +390,11 @@ export const pluginManifestSchema = z.strictObject({
221
390
  apiVersion: nonblank,
222
391
  ticketSources: openMap('ticketSources', ticketSourceRegistrationSchema, ADAPTER_MAP).optional(),
223
392
  agentRuntimes: registrationMap('agentRuntimes').optional(),
224
- workspaceProviders: registrationMap('workspaceProviders').optional(),
393
+ workspaceProviders: openMap(
394
+ 'workspaceProviders',
395
+ workspaceProviderRegistrationSchema,
396
+ ADAPTER_MAP,
397
+ ).optional(),
225
398
  forges: registrationMap('forges').optional(),
226
399
  })
227
400
 
@@ -12,6 +12,23 @@ import type { AgentRunnerContractFactory } from '../ports/runner/contract'
12
12
  import type { TicketSourceContractFactory } from '../ports/tickets/contract'
13
13
  import type { WorkspaceProviderContractFactory } from '../ports/workspace/contract'
14
14
  import type { ForgeContractFactory } from '../ports/forge/contract'
15
+ import type { WorkspaceConfig } from '../config/schema'
16
+ import type { CreateWorkspaceProviderOptions } from '../ports/workspace/create'
17
+ import type { WorkspaceProviderCapabilities } from '../ports/workspace/provider-capabilities'
18
+ import type { WorkspaceProvider } from '../ports/types'
19
+ import {
20
+ builtinWorkspaceProviderNames,
21
+ builtinWorkspaceProviderRegistration,
22
+ } from '../ports/workspace/builtin-capabilities'
23
+
24
+ /** Host-owned construction closure for a builtin workspace provider. `parsed`
25
+ * is the declared `configSchema`'s parse output, or the raw
26
+ * `[workspace.config]` when no schema is declared. */
27
+ export type BuiltinWorkspaceProviderFactory = (
28
+ config: WorkspaceConfig,
29
+ opts: CreateWorkspaceProviderOptions,
30
+ parsed: unknown,
31
+ ) => WorkspaceProvider | Promise<WorkspaceProvider>
15
32
 
16
33
  export type PluginPort = 'ticket-source' | 'agent-runtime' | 'workspace-provider' | 'forge'
17
34
 
@@ -49,6 +66,14 @@ export interface AdapterRegistration<Factory, ContractFactory = unknown> {
49
66
  * builtins and every other plugin port. */
50
67
  requiredEnv?: readonly string[]
51
68
  contract?: PluginContractDescriptor<ContractFactory>
69
+ /** Workspace-provider capability declarations (AUT-516). Attached to the
70
+ * builtin registrations and normalized from plugin descriptors; every other
71
+ * port leaves it undefined. */
72
+ capabilities?: WorkspaceProviderCapabilities
73
+ /** Builtin workspace providers construct their host-owned adapters through
74
+ * this closure (`createWorkspaceProvider` calls it after the uniform
75
+ * capability checks); plugins keep `factory`. */
76
+ builtinFactory?: BuiltinWorkspaceProviderFactory
52
77
  source: RegistrationSource
53
78
  }
54
79
 
@@ -92,6 +117,7 @@ interface PendingRegistration {
92
117
  factory: unknown
93
118
  requiredEnv?: readonly string[]
94
119
  contract?: PluginContractDescriptor<unknown>
120
+ capabilities?: WorkspaceProviderCapabilities
95
121
  target: Map<string, AdapterRegistration<unknown, unknown>>
96
122
  }
97
123
 
@@ -101,6 +127,7 @@ type InternalRegistration<AdapterFactory, ContractFactory> =
101
127
  factory: AdapterFactory
102
128
  requiredEnv?: readonly string[]
103
129
  contract?: PluginContractDescriptor<ContractFactory>
130
+ capabilities?: WorkspaceProviderCapabilities
104
131
  }
105
132
 
106
133
  function normalize<AdapterFactory, ContractFactory>(
@@ -109,6 +136,7 @@ function normalize<AdapterFactory, ContractFactory>(
109
136
  factory: AdapterFactory
110
137
  requiredEnv?: readonly string[]
111
138
  contract?: PluginContractDescriptor<ContractFactory>
139
+ capabilities?: WorkspaceProviderCapabilities
112
140
  } {
113
141
  if (typeof registration === 'function') {
114
142
  return { factory: registration as AdapterFactory }
@@ -117,11 +145,13 @@ function normalize<AdapterFactory, ContractFactory>(
117
145
  factory: AdapterFactory
118
146
  requiredEnv?: readonly string[]
119
147
  contract?: PluginContractDescriptor<ContractFactory>
148
+ capabilities?: WorkspaceProviderCapabilities
120
149
  }
121
150
  return {
122
151
  factory: object.factory,
123
152
  ...(object.requiredEnv !== undefined ? { requiredEnv: [...object.requiredEnv] } : {}),
124
153
  ...(object.contract !== undefined ? { contract: object.contract } : {}),
154
+ ...(object.capabilities !== undefined ? { capabilities: object.capabilities } : {}),
125
155
  }
126
156
  }
127
157
 
@@ -141,10 +171,29 @@ export class PluginRegistry {
141
171
  // profile value, so a runtime with the same name could never be selected.
142
172
  'split',
143
173
  ])
144
- readonly workspaceProviders = reserved<
145
- WorkspaceProviderPluginFactory,
146
- WorkspaceProviderContractFactory
147
- >(['git-worktree', 'vercel-sandbox'])
174
+ // Builtin workspace-provider names are derived from the canonical builtin
175
+ // list in builtin-capabilities.ts (AUT-579), so a builtin added there
176
+ // registers here automatically — no registry edit is needed.
177
+ readonly workspaceProviders = new Map<
178
+ string,
179
+ AdapterRegistration<WorkspaceProviderPluginFactory, WorkspaceProviderContractFactory>
180
+ >(
181
+ builtinWorkspaceProviderNames().map((name) => {
182
+ const builtin = builtinWorkspaceProviderRegistration(name)
183
+ if (builtin === undefined) {
184
+ throw new Error(`builtin workspace provider "${name}" has no capability declaration`)
185
+ }
186
+ return [
187
+ name,
188
+ {
189
+ owner: BUILTIN,
190
+ source: { kind: 'builtin' } as const,
191
+ capabilities: builtin.capabilities,
192
+ builtinFactory: builtin.builtinFactory,
193
+ },
194
+ ]
195
+ }),
196
+ )
148
197
  readonly forges = reserved<ForgePluginFactory, ForgeContractFactory>(['github', 'local-git'])
149
198
 
150
199
  register(
@@ -184,6 +233,9 @@ export class PluginRegistry {
184
233
  ...(normalized.contract !== undefined
185
234
  ? { contract: normalized.contract as PluginContractDescriptor<unknown> }
186
235
  : {}),
236
+ ...(normalized.capabilities !== undefined
237
+ ? { capabilities: normalized.capabilities }
238
+ : {}),
187
239
  target: target as Map<string, AdapterRegistration<unknown, unknown>>,
188
240
  })
189
241
  }
@@ -223,6 +275,9 @@ export class PluginRegistry {
223
275
  ? { requiredEnv: registration.requiredEnv }
224
276
  : {}),
225
277
  ...(registration.contract !== undefined ? { contract: registration.contract } : {}),
278
+ ...(registration.capabilities !== undefined
279
+ ? { capabilities: registration.capabilities }
280
+ : {}),
226
281
  source,
227
282
  })
228
283
  }
@@ -267,6 +322,20 @@ export class PluginRegistry {
267
322
  return this.forges.get(name)
268
323
  }
269
324
  }
325
+
326
+ /** Workspace-provider names a manifest declares that currently belong to a
327
+ * BUILTIN registration (AUT-517). Names owned by another plugin are not
328
+ * reported — those collisions throw as always. The loader uses this for
329
+ * the transitional duplicate-skip: while the builtin hosts the
330
+ * vercel-sandbox implementation, a configured plugin re-registering the
331
+ * name is skipped instead of failing startup. Keyed on builtin ownership,
332
+ * so the rule retires itself when the builtin is removed (AUT-505). */
333
+ builtinWorkspaceProviderCollisions(manifest: AutobuildPluginManifest): string[] {
334
+ return Object.keys(manifest.workspaceProviders ?? {}).filter((name) => {
335
+ const existing = this.workspaceProviders.get(name)
336
+ return existing !== undefined && existing.owner.kind === 'builtin'
337
+ })
338
+ }
270
339
  }
271
340
 
272
341
  export function createPluginRegistry(): PluginRegistry {
@@ -47,7 +47,7 @@ export async function createForge(opts: {
47
47
  ...(opts.repository !== undefined ? { repository: opts.repository } : {}),
48
48
  })
49
49
  }
50
- if (opts.name === 'local-git') return new LocalGitForge()
50
+ if (opts.name === 'local-git') return new LocalGitForge({ repoRoot: opts.repoRoot })
51
51
  throw new Error(`builtin forge adapter ${JSON.stringify(opts.name)} has no constructor`)
52
52
  }
53
53
 
@@ -83,6 +83,13 @@ export class FakeForge implements Forge {
83
83
  readonly name = 'fake'
84
84
  readonly prAttachments?: PrAttachmentHosting
85
85
 
86
+ /** Optional checkout-less read seam (SPEC §16.1 pipeline pinning): seeded
87
+ * remote branches (`branch` → sha) and file contents keyed `ref\0path`.
88
+ * `remoteBranchSha` throws when the branch was never seeded — the same
89
+ * not-found contract as the production adapters. */
90
+ readonly remoteBranches = new Map<string, string>()
91
+ readonly files = new Map<string, string>()
92
+
86
93
  /** Journals — public so tests assert directly on call order and args. */
87
94
  readonly pushes: PushRecord[] = []
88
95
  readonly opened: OpenPrRecord[] = []
@@ -330,6 +337,30 @@ export class FakeForge implements Forge {
330
337
  return this.autoMerge.get(number) ?? false
331
338
  }
332
339
 
340
+ /** Seed a remote branch head for the optional checkout-less read seam. */
341
+ seedBranch(branch: string, sha: string): void {
342
+ this.remoteBranches.set(branch, sha)
343
+ }
344
+
345
+ /** Seed file content readable at a given ref (sha or branch name). */
346
+ seedFile(ref: string, path: string, content: string): void {
347
+ this.files.set(`${ref}\0${path}`, content)
348
+ }
349
+
350
+ async remoteBranchSha(branch: string): Promise<string> {
351
+ const sha = this.remoteBranches.get(branch)
352
+ if (sha === undefined) throw new Error(`FakeForge: unknown remote branch ${branch}`)
353
+ return sha
354
+ }
355
+
356
+ async readFile(path: string, ref?: string): Promise<string> {
357
+ const key = `${ref ?? 'default'}\0${path}`
358
+ const content = this.files.get(key)
359
+ if (content === undefined)
360
+ throw new Error(`FakeForge: no file ${path} at ref ${ref ?? 'default'}`)
361
+ return content
362
+ }
363
+
333
364
  async pushBranch(workspacePath: string, branch: string): Promise<void> {
334
365
  this.pushes.push({ workspacePath, branch })
335
366
  this.publishedBranches.add(branch)