@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,279 @@
1
+ /**
2
+ * `ab mcp` — the first binding of the agent tool registry (AUT-338): the
3
+ * registry served as an MCP server over stdio, using the official MCP
4
+ * TypeScript SDK's current major.
5
+ *
6
+ * The CLI remains the primary local interface for agents that have a shell.
7
+ * The stdio binding exists so the registry can be exercised end to end from a
8
+ * local install, for dogfooding, and for local agents that cannot run
9
+ * commands. Every tool is registered from the registry's closed table — name,
10
+ * description, JSON-schema input, annotations, handler — so this surface can
11
+ * never drift from the in-process registry the contract suite proves.
12
+ *
13
+ * Identity: the sessionless operator identity the other operator commands use
14
+ * (`buildControlUser` — USER/USERNAME, else "dashboard"), with a via marker
15
+ * naming the connected MCP client (learned lazily at the initialize handshake).
16
+ * The server fails closed inside a phase: repository-wide operator authority
17
+ * cannot be narrowed to the ambient build (the §8.2 rule that makes
18
+ * repository-wide `ab builds` fail inside a phase, moved to process start
19
+ * because a server cannot scope per request).
20
+ */
21
+ import { join } from 'node:path'
22
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
23
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
24
+ import { loadConfig } from '../config/load'
25
+ import type { Config } from '../config/schema'
26
+ import type { Via } from '../events/envelope'
27
+ import { defaultTriageState } from '../processes/dispatcher'
28
+ import { createWorkspaceProvider } from '../ports/workspace/create'
29
+ import { createTicketSource } from '../ports/tickets/create'
30
+ import { loadPlugins } from '../plugins/load'
31
+ import { AUTOBUILD_VERSION } from '../store/remote/version'
32
+ import type { Clock } from '../store/types'
33
+ import { buildControlUser } from './build-control'
34
+ import type { Exec } from '../ports/workspace/git-worktree'
35
+ import { openSessionlessStore, type StoreOpener } from './store-opening'
36
+ import { InvalidAmbientContextError, resolveAmbientReadSession } from './env'
37
+ import { buildRegistry, RegistryError, type OperatorToolRegistry } from '../operator/registry'
38
+ import { createOperatorSandboxService, type OperatorSandboxService } from '../operator/sandbox'
39
+ import type { OperatorTicketBackend } from '../operator/tickets'
40
+
41
+ export const AB_MCP_USAGE = 'usage: ab mcp [--store <ref>] [--repo <id>] (§8.2)'
42
+
43
+ export const AB_MCP_PHASE_REFUSAL =
44
+ 'ab mcp serves repository-wide operator authority and cannot run inside a phase session (§8.2)'
45
+
46
+ const SERVER_INSTRUCTIONS = [
47
+ 'Autobuild operator tools for one repository: builds, repository and harvest controls, tickets, and operator notes.',
48
+ 'The `ab` CLI is the primary local interface for agents that have a shell; these tools mirror its operator commands',
49
+ 'and the operator API routes — every result equals the corresponding route. Prefer the CLI when a command exists;',
50
+ 'use these tools when no shell is available, for dogfooding, and for parity testing of the tool registry.',
51
+ 'Every tool names its repository explicitly via `repo`; mutating tools append durable, human-attributed events.',
52
+ ].join(' ')
53
+
54
+ export interface AbMcpOpts {
55
+ targetRepo: string
56
+ env: Record<string, string | undefined>
57
+ exec: Exec
58
+ stdout: (line: string) => void
59
+ stderr: (line: string) => void
60
+ /** Explicit --store; selection remains --store > AB_STORE > repo-local. */
61
+ storeRef?: string
62
+ /** Explicit --repo: refuse calls targeting any other repository. */
63
+ repo?: string
64
+ /** Injectable adapter seam for tests; production composes the real adapter. */
65
+ openStore?: StoreOpener
66
+ /** Injectable ticket-source factory for tests. */
67
+ ticketSourceFactory?: typeof createTicketSource
68
+ clock?: Clock
69
+ }
70
+
71
+ /**
72
+ * The operator ticket backend for the stdio binding: one TicketSource built
73
+ * once from the checkout's own `[tickets]` config. `statesFor` returns the
74
+ * configured lifecycle names; `sourceFor` cross-checks the effective
75
+ * config's teamKey against this startup config and refuses on drift.
76
+ */
77
+ export async function buildMcpTicketBackend(
78
+ opts: AbMcpOpts & { checkout: string; localStateRoot: string },
79
+ ): Promise<OperatorTicketBackend> {
80
+ const configPath = join(opts.checkout, 'autobuild.toml')
81
+ let config: Config
82
+ try {
83
+ config = await loadConfig(configPath)
84
+ } catch (error) {
85
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
86
+ throw new Error(
87
+ `${configPath}: not found — 'ab mcp' reads autobuild.toml from the resolved Git main checkout (SPEC §8.8)`,
88
+ )
89
+ }
90
+ throw error
91
+ }
92
+ const plugins = await loadPlugins(config.plugins, opts.checkout)
93
+ const factory = opts.ticketSourceFactory ?? createTicketSource
94
+ const source = await factory(
95
+ config.tickets,
96
+ opts.env,
97
+ opts.checkout,
98
+ opts.localStateRoot,
99
+ plugins,
100
+ )
101
+ const states = [
102
+ ...new Set(
103
+ [
104
+ defaultTriageState(config),
105
+ config.tickets.readyState,
106
+ config.tickets.claimedState,
107
+ config.tickets.createState,
108
+ ].filter((state): state is string => state !== undefined),
109
+ ),
110
+ ]
111
+ return {
112
+ sourceFor: async (context) => {
113
+ if (config.tickets.teamKey !== context.teamKey) {
114
+ throw new Error(
115
+ `the repository's effective ticket config names team "${context.teamKey}", but this checkout's autobuild.toml names team "${String(config.tickets.teamKey)}"; refusing to serve tickets across that drift — run ab mcp from the checkout dispatch uses`,
116
+ )
117
+ }
118
+ return source
119
+ },
120
+ statesFor: async () => states,
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Map every registry entry onto the MCP server: name, description (with the
126
+ * output description appended), input schema, annotations, and a handler that
127
+ * routes through registry.call so validation, identity enforcement, and
128
+ * failure-body mapping stay identical across bindings. Successes and failures
129
+ * both surface as JSON text content; failures additionally carry isError.
130
+ */
131
+ export function serveRegistry(
132
+ registry: OperatorToolRegistry,
133
+ server: McpServer,
134
+ ctx: { identity?: string; via?: Via | (() => Via | undefined) },
135
+ ): void {
136
+ for (const entry of registry.entries) {
137
+ server.registerTool(
138
+ entry.name,
139
+ {
140
+ description: `${entry.description}\n\nReturns: ${entry.outputDescription}`,
141
+ inputSchema: entry.inputSchema,
142
+ annotations: entry.annotations,
143
+ },
144
+ // The SDK validates args against the entry's schema before this runs;
145
+ // registry.call re-validates so every binding shares one enforcement
146
+ // path.
147
+ async (args: unknown) => {
148
+ try {
149
+ // A lazy via resolves per call — the stdio server is long-lived and
150
+ // only learns the client's identity at the initialize handshake.
151
+ const via = typeof ctx.via === 'function' ? ctx.via() : ctx.via
152
+ const value = await registry.call(entry.name, args, {
153
+ identity: ctx.identity,
154
+ ...(via !== undefined ? { via } : {}),
155
+ })
156
+ return { content: [{ type: 'text' as const, text: JSON.stringify(value) }] }
157
+ } catch (error) {
158
+ const body =
159
+ error instanceof RegistryError
160
+ ? error.body
161
+ : { kind: 'internal' as const, error: 'operator tool is unavailable' }
162
+ return {
163
+ content: [{ type: 'text' as const, text: JSON.stringify(body) }],
164
+ isError: true,
165
+ }
166
+ }
167
+ },
168
+ )
169
+ }
170
+ }
171
+
172
+ /** The `ab mcp` command core. Returns the process exit code. */
173
+ export async function abMcp(opts: AbMcpOpts): Promise<number> {
174
+ // Fail closed before any store open: a complete ambient tuple (or a
175
+ // malformed one) must not start a repository-wide server.
176
+ let ambient: unknown
177
+ try {
178
+ ambient = resolveAmbientReadSession(opts.env)
179
+ } catch (error) {
180
+ if (error instanceof InvalidAmbientContextError) {
181
+ opts.stderr(`${AB_MCP_PHASE_REFUSAL} ${error.message}`)
182
+ return 1
183
+ }
184
+ throw error
185
+ }
186
+ if (ambient !== undefined) {
187
+ opts.stderr(AB_MCP_PHASE_REFUSAL)
188
+ return 1
189
+ }
190
+
191
+ const context = await openSessionlessStore({
192
+ targetRepo: opts.targetRepo,
193
+ env: opts.env,
194
+ exec: opts.exec,
195
+ ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
196
+ ...(opts.openStore !== undefined ? { openStore: opts.openStore } : {}),
197
+ })
198
+ try {
199
+ const backend = await buildMcpTicketBackend({
200
+ ...opts,
201
+ checkout: context.checkout,
202
+ localStateRoot: context.localStateRoot,
203
+ })
204
+ // The operator-sandbox backend (AUT-340) is built only when the
205
+ // repository enables it. A construction failure is contained: the
206
+ // server still serves every other tool, one diagnostic names the
207
+ // failure, and the sandbox tools are absent from advertisement AND
208
+ // dispatch (never broken).
209
+ let sandbox: OperatorSandboxService | undefined
210
+ try {
211
+ const configPath = join(context.checkout, 'autobuild.toml')
212
+ let mcpConfig: Config
213
+ try {
214
+ mcpConfig = await loadConfig(configPath)
215
+ } catch (error) {
216
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
217
+ throw new Error(
218
+ `${configPath}: not found — 'ab mcp' reads autobuild.toml from the resolved Git main checkout (SPEC §8.8)`,
219
+ )
220
+ }
221
+ throw error
222
+ }
223
+ if (mcpConfig.orchestrator.enabled) {
224
+ const provider = await createWorkspaceProvider(mcpConfig.workspace, {
225
+ registry: await loadPlugins(mcpConfig.plugins, context.checkout),
226
+ worktreeRoot: context.worktreeRoot,
227
+ repoRoot: context.checkout,
228
+ env: opts.env,
229
+ storeRef: context.storeRef,
230
+ ...(context.token !== undefined ? { storeToken: context.token } : {}),
231
+ sandboxSetupCommand: mcpConfig.commands.setup,
232
+ sandboxRoot: join(context.localStateRoot, 'orchestrator-sandboxes'),
233
+ sandboxEnvironmentVariables: mcpConfig.orchestrator.sandbox.environmentVariables,
234
+ })
235
+ sandbox = await createOperatorSandboxService({
236
+ store: context.store,
237
+ repo: context.repo,
238
+ provider,
239
+ sandbox: mcpConfig.orchestrator.sandbox,
240
+ baseBranch: mcpConfig.baseBranch,
241
+ ...(opts.clock !== undefined ? { clock: opts.clock } : {}),
242
+ })
243
+ }
244
+ } catch (error) {
245
+ opts.stderr(
246
+ `ab mcp: operator sandbox backend unavailable, sandbox tools omitted: ${error instanceof Error ? error.message : String(error)}`,
247
+ )
248
+ sandbox = undefined
249
+ }
250
+ const registry = buildRegistry({
251
+ store: context.store,
252
+ tickets: backend,
253
+ ...(sandbox !== undefined ? { sandbox } : {}),
254
+ clock: opts.clock,
255
+ ...(opts.repo !== undefined ? { allowedRepo: opts.repo } : {}),
256
+ })
257
+
258
+ const server = new McpServer(
259
+ { name: 'autobuild', version: AUTOBUILD_VERSION },
260
+ { instructions: SERVER_INSTRUCTIONS },
261
+ )
262
+ serveRegistry(registry, server, {
263
+ identity: buildControlUser(opts.env),
264
+ // Unlike the hosted stateless server, the stdio server is long-lived:
265
+ // the initialize handshake populates the client version before any tool
266
+ // call, so the via marker names the connected client.
267
+ via: () => ({ kind: 'mcp', client: server.server.getClientVersion()?.name ?? 'stdio' }),
268
+ })
269
+
270
+ const done = new Promise<void>((resolve) => {
271
+ server.server.onclose = () => resolve()
272
+ })
273
+ await server.connect(new StdioServerTransport())
274
+ await done
275
+ return 0
276
+ } finally {
277
+ await context.store.close()
278
+ }
279
+ }
@@ -0,0 +1,65 @@
1
+ /** `ab models` lists the catalog resolved by the operator's local Pi CLI. */
2
+ import { checkLocalPi } from '../ports/runner/pi'
3
+ import { readLocalPiCatalog } from '../ports/runner/pi-rpc'
4
+
5
+ /** One catalog entry — a provider-qualified id, split for display. */
6
+ export interface PiCatalogEntry {
7
+ provider: string
8
+ id: string
9
+ }
10
+
11
+ /**
12
+ * Fetch Pi's model catalog. `availableOnly` narrows to models whose provider
13
+ * credentials are configured (a network/credential check); otherwise the full
14
+ * offline catalog is returned.
15
+ */
16
+ export type PiModelCatalogFn = (opts: { availableOnly: boolean }) => Promise<PiCatalogEntry[]>
17
+
18
+ const defaultCatalog: PiModelCatalogFn = async ({ availableOnly }) => {
19
+ const env = Object.fromEntries(
20
+ Object.entries(process.env).filter(
21
+ (entry): entry is [string, string] => entry[1] !== undefined,
22
+ ),
23
+ )
24
+ await checkLocalPi(process.cwd(), env)
25
+ return readLocalPiCatalog({ cwd: process.cwd(), env, availableOnly })
26
+ }
27
+
28
+ export interface AbModelsOptions {
29
+ /** Case-insensitive substring over `provider/id`; absent ⇒ list everything. */
30
+ query?: string
31
+ /** Restrict to models with configured credentials. */
32
+ availableOnly: boolean
33
+ stdout: (line: string) => void
34
+ /** Injectable for tests; defaults to the local Pi CLI catalog. */
35
+ catalog?: PiModelCatalogFn
36
+ }
37
+
38
+ export async function abModels(opts: AbModelsOptions): Promise<void> {
39
+ const catalog = opts.catalog ?? defaultCatalog
40
+ const entries = await catalog({ availableOnly: opts.availableOnly })
41
+
42
+ const needle = opts.query?.toLowerCase()
43
+ const matched = (
44
+ needle === undefined
45
+ ? entries
46
+ : entries.filter((e) => `${e.provider}/${e.id}`.toLowerCase().includes(needle))
47
+ ).sort((a, b) => `${a.provider}/${a.id}`.localeCompare(`${b.provider}/${b.id}`))
48
+
49
+ if (matched.length === 0) {
50
+ opts.stdout(
51
+ opts.query !== undefined
52
+ ? `no Pi models match "${opts.query}"${opts.availableOnly ? ' with configured credentials' : ''}`
53
+ : 'no Pi models found',
54
+ )
55
+ return
56
+ }
57
+
58
+ for (const e of matched) {
59
+ opts.stdout(`${e.provider}/${e.id}`)
60
+ }
61
+ opts.stdout('')
62
+ opts.stdout(
63
+ `${matched.length} model(s) — paste a provider-qualified id into autobuild.toml [roles.default] or a concrete [roles.<name>] entry.`,
64
+ )
65
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `ab observe` (SPEC §8.2, §12): structured observations at the point of
3
+ * capture — followup | refactor | latent-bug, with file/ticket refs — so
4
+ * `harvest` clusters records, not prose mined from a markdown file.
5
+ *
6
+ * NOT a terminal: usable in any phase, at any time, any number of times.
7
+ * The id is kernel-assigned at deposit, like all ids (§15.4).
8
+ */
9
+ import type { EventEnvelope } from '../events/catalog'
10
+ import { agentActor } from '../events/envelope'
11
+ import type { IdSource } from '../ids'
12
+ import { observationKindSchema } from '../ontology'
13
+ import type { BuildStore } from '../store/types'
14
+ import type { CliEnv } from './env'
15
+
16
+ export interface ObserveDeps {
17
+ store: BuildStore
18
+ env: CliEnv
19
+ ids: IdSource
20
+ }
21
+
22
+ export interface ObserveOpts {
23
+ kind: string
24
+ summary: string
25
+ files?: string[]
26
+ refs?: string[]
27
+ }
28
+
29
+ export async function observe(
30
+ deps: ObserveDeps,
31
+ opts: ObserveOpts,
32
+ ): Promise<EventEnvelope<'observation.recorded'>> {
33
+ const kind = observationKindSchema.safeParse(opts.kind)
34
+ if (!kind.success) {
35
+ throw new Error(
36
+ `--kind "${opts.kind}" is not an observation kind — expected ` +
37
+ 'followup | refactor | latent-bug (§8.2)',
38
+ )
39
+ }
40
+ if (opts.summary.trim() === '') {
41
+ throw new Error("'ab observe' requires a non-empty <summary> (§8.2)")
42
+ }
43
+ const { env } = deps
44
+ return deps.store.append(env.build, {
45
+ actor: agentActor(env.phase, env.session),
46
+ type: 'observation.recorded',
47
+ payload: {
48
+ id: deps.ids('obs'),
49
+ kind: kind.data,
50
+ summary: opts.summary,
51
+ ...(opts.files !== undefined && opts.files.length > 0 ? { files: opts.files } : {}),
52
+ ...(opts.refs !== undefined && opts.refs.length > 0 ? { refs: opts.refs } : {}),
53
+ },
54
+ })
55
+ }
@@ -0,0 +1,227 @@
1
+ import { join } from 'node:path'
2
+ import { loadConfig } from '../config/load'
3
+ import { CONTRACT_ADAPTER_ENV, CONTRACT_PORT_ENV, CONTRACT_REPO_ENV } from '../plugins/contract-env'
4
+ import { diagnosePlugins, type PluginDiagnosis } from '../plugins/load'
5
+ import {
6
+ PLUGIN_PORTS,
7
+ pluginPortLabel,
8
+ type AdapterProjection,
9
+ type PluginPort,
10
+ } from '../plugins/registry'
11
+ import type { Exec } from '../ports/workspace/git-worktree'
12
+ import { resolveMainRepo } from './repo-state'
13
+
14
+ export interface PluginContractProcessInput {
15
+ repoRoot: string
16
+ port: PluginPort
17
+ adapter: string
18
+ env: Record<string, string | undefined>
19
+ stdout: (line: string) => void
20
+ stderr: (line: string) => void
21
+ }
22
+
23
+ export type PluginContractSubprocess = (input: PluginContractProcessInput) => Promise<number>
24
+
25
+ export interface PluginCliOpts {
26
+ targetRepo: string
27
+ env: Record<string, string | undefined>
28
+ exec: Exec
29
+ stdout: (line: string) => void
30
+ stderr: (line: string) => void
31
+ subprocess?: PluginContractSubprocess
32
+ }
33
+
34
+ function definedEnv(env: Record<string, string | undefined>): Record<string, string> {
35
+ return Object.fromEntries(
36
+ Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined),
37
+ )
38
+ }
39
+
40
+ function forwardText(text: string, sink: (line: string) => void): void {
41
+ const normalized = text.endsWith('\n') ? text.slice(0, -1) : text
42
+ if (normalized === '') return
43
+ for (const line of normalized.split('\n')) sink(line)
44
+ }
45
+
46
+ export const spawnPluginContract: PluginContractSubprocess = async (input) => {
47
+ const entry = join(import.meta.dir, '..', 'plugins', 'contract-entry.ts')
48
+ // Use the current Bun executable by absolute path: the plugin's declared
49
+ // environment may intentionally omit PATH, but contract execution must not
50
+ // depend on executable lookup inside that replacement environment.
51
+ const child = Bun.spawn([process.execPath, 'test', entry], {
52
+ cwd: input.repoRoot,
53
+ env: {
54
+ ...definedEnv(input.env),
55
+ [CONTRACT_REPO_ENV]: input.repoRoot,
56
+ [CONTRACT_PORT_ENV]: input.port,
57
+ [CONTRACT_ADAPTER_ENV]: input.adapter,
58
+ },
59
+ stdout: 'pipe',
60
+ stderr: 'pipe',
61
+ })
62
+ const [stdout, stderr, status] = await Promise.all([
63
+ new Response(child.stdout).text(),
64
+ new Response(child.stderr).text(),
65
+ child.exited,
66
+ ])
67
+ forwardText(stdout, input.stdout)
68
+ forwardText(stderr, input.stderr)
69
+ return status
70
+ }
71
+
72
+ function renderModuleReport(report: PluginDiagnosis['reports'][number]): string {
73
+ const location = report.resolved === undefined ? '' : ` resolved=${report.resolved}`
74
+ const source = report.resolvedFrom === undefined ? '' : ` from=${report.resolvedFrom}`
75
+ const plugin = report.pluginName === undefined ? '' : ` plugin=${report.pluginName}`
76
+ const api =
77
+ report.api === undefined
78
+ ? ''
79
+ : ` api=${report.api.declaredRange} host=${report.api.hostVersion} ${report.api.status}`
80
+ return report.status === 'loaded'
81
+ ? `OK ${report.module} kind=${report.resolutionKind}${location}${source}${plugin}${api}`
82
+ : `FAIL ${report.module} kind=${report.resolutionKind} stage=${report.stage}${location}${source}${plugin}${api}: ${report.error}`
83
+ }
84
+
85
+ function renderAdapter(adapter: AdapterProjection): string {
86
+ const contract = adapter.hasContract
87
+ ? adapter.live
88
+ ? 'contract=live'
89
+ : 'contract=available'
90
+ : 'contract=missing'
91
+ if (adapter.source.kind === 'builtin') {
92
+ return ` ${adapter.name} owner=builtin module=(builtin) resolution=builtin api=host-compatible ${contract}`
93
+ }
94
+ const { source } = adapter
95
+ return (
96
+ ` ${adapter.name} owner=plugin:${source.pluginName} module=${source.module} ` +
97
+ `resolution=${source.resolutionKind} resolved=${source.resolved} ` +
98
+ `api=${source.api.declaredRange} host=${source.api.hostVersion} ${source.api.status} ${contract}`
99
+ )
100
+ }
101
+
102
+ function contractField(port: PluginPort, adapter: string): string {
103
+ const map: Record<PluginPort, string> = {
104
+ 'ticket-source': 'ticketSources',
105
+ 'agent-runtime': 'agentRuntimes',
106
+ 'workspace-provider': 'workspaceProviders',
107
+ forge: 'forges',
108
+ }
109
+ return `${map[port]}.${adapter}.contract.factory`
110
+ }
111
+
112
+ async function loadDiagnosis(opts: PluginCliOpts): Promise<{
113
+ repoRoot: string
114
+ diagnosis: PluginDiagnosis
115
+ }> {
116
+ const repoRoot = await resolveMainRepo(opts.targetRepo, opts.exec)
117
+ const configPath = join(repoRoot, 'autobuild.toml')
118
+ let config: Awaited<ReturnType<typeof loadConfig>>
119
+ try {
120
+ config = await loadConfig(configPath)
121
+ } catch (error) {
122
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
123
+ throw new Error(
124
+ `${configPath}: not found — 'ab plugin' reads autobuild.toml from the resolved Git main checkout`,
125
+ )
126
+ }
127
+ throw error
128
+ }
129
+ return {
130
+ repoRoot,
131
+ diagnosis: await diagnosePlugins(config.plugins, repoRoot),
132
+ }
133
+ }
134
+
135
+ function parsePort(value: string): PluginPort {
136
+ if (!PLUGIN_PORTS.includes(value as PluginPort)) {
137
+ throw new Error(`unknown plugin port "${value}" — expected one of: ${PLUGIN_PORTS.join(', ')}`)
138
+ }
139
+ return value as PluginPort
140
+ }
141
+
142
+ /** Sessionless `ab plugin` operator/authoring namespace. */
143
+ export async function abPlugin(argv: readonly string[], opts: PluginCliOpts): Promise<number> {
144
+ const [command, ...rest] = argv
145
+ if (command === 'list') {
146
+ if (rest.length !== 0) throw new Error('usage: ab plugin list')
147
+ const { diagnosis } = await loadDiagnosis(opts)
148
+ opts.stdout('configured modules:')
149
+ if (diagnosis.reports.length === 0) opts.stdout(' (none)')
150
+ for (const report of diagnosis.reports) {
151
+ const line = ` ${renderModuleReport(report)}`
152
+ if (report.status === 'loaded') opts.stdout(line)
153
+ else opts.stderr(line)
154
+ }
155
+ opts.stdout('adapters:')
156
+ for (const port of PLUGIN_PORTS) {
157
+ opts.stdout(`${port}:`)
158
+ for (const adapter of diagnosis.registry.adapters(port)) {
159
+ opts.stdout(renderAdapter(adapter))
160
+ }
161
+ }
162
+ return diagnosis.healthy ? 0 : 1
163
+ }
164
+
165
+ if (command === 'doctor') {
166
+ if (rest.length !== 0) throw new Error('usage: ab plugin doctor')
167
+ const { diagnosis } = await loadDiagnosis(opts)
168
+ if (diagnosis.reports.length === 0) {
169
+ opts.stdout('No configured plugin modules.')
170
+ }
171
+ for (const report of diagnosis.reports) {
172
+ const line = renderModuleReport(report)
173
+ if (report.status === 'loaded') opts.stdout(line)
174
+ else opts.stderr(line)
175
+ }
176
+ return diagnosis.healthy ? 0 : 1
177
+ }
178
+
179
+ if (command === 'test') {
180
+ if (rest.length !== 2) {
181
+ throw new Error(
182
+ 'usage: ab plugin test <ticket-source|agent-runtime|workspace-provider|forge> <adapter>',
183
+ )
184
+ }
185
+ const port = parsePort(rest[0]!)
186
+ const adapter = rest[1]!
187
+ const { repoRoot, diagnosis } = await loadDiagnosis(opts)
188
+ if (!diagnosis.healthy) {
189
+ for (const report of diagnosis.reports) {
190
+ if (report.status === 'failed') opts.stderr(renderModuleReport(report))
191
+ }
192
+ return 1
193
+ }
194
+ const registration = diagnosis.registry.registration(port, adapter)
195
+ if (registration === undefined) {
196
+ const names = diagnosis.registry.adapters(port).map((entry) => entry.name)
197
+ throw new Error(
198
+ `unknown ${pluginPortLabel(port)} adapter "${adapter}" — configured adapters: ${names.join(', ') || '(none)'}`,
199
+ )
200
+ }
201
+ if (registration.contract === undefined) {
202
+ throw new Error(
203
+ `${pluginPortLabel(port)} adapter "${adapter}" cannot be contract-tested: ` +
204
+ `its manifest does not provide ${contractField(port, adapter)}. ` +
205
+ 'Register the adapter as { factory, contract: { factory } } where the contract factory returns the shared suite harness factory.',
206
+ )
207
+ }
208
+ if (registration.contract.live === true && opts.env.AB_RUN_LIVE_PORT_CONTRACTS !== '1') {
209
+ throw new Error(
210
+ `${pluginPortLabel(port)} adapter "${adapter}" declares a live contract; ` +
211
+ 'set AB_RUN_LIVE_PORT_CONTRACTS=1 to explicitly allow external side effects',
212
+ )
213
+ }
214
+ return await (opts.subprocess ?? spawnPluginContract)({
215
+ repoRoot,
216
+ port,
217
+ adapter,
218
+ env: opts.env,
219
+ stdout: opts.stdout,
220
+ stderr: opts.stderr,
221
+ })
222
+ }
223
+
224
+ throw new Error(
225
+ 'usage: ab plugin <list|doctor|test> — test requires <ticket-source|agent-runtime|workspace-provider|forge> <adapter>',
226
+ )
227
+ }