@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,681 @@
1
+ /**
2
+ * End-to-end harness (SPEC §15.6 as LIVE scenarios, §8.7, §12, §15.7):
3
+ * real components everywhere the seam matters.
4
+ *
5
+ * - ONE MemoryBuildStore shared by dispatcher, build-runner, and every CLI
6
+ * invocation (steppingClock so store-assigned `ts` are deterministic).
7
+ * - A REAL temp git repo with a real bare `origin` remote and a committed
8
+ * autobuild.toml fixture (§16.1 D9) whose check command is controllable:
9
+ * `test -f ok.marker` fails until an implement round commits the marker.
10
+ * - REAL worktrees via GitWorktreeProvider for the dispatcher's provisioning.
11
+ * - A Dispatcher whose `launchRunner` constructs a REAL in-process
12
+ * BuildRunner over the SAME store and the provisioned workspace path.
13
+ * - THE POINT — scripted agents drive the REAL `ab` CLI: each script derives
14
+ * CliDeps from its ScriptContext (store = the shared store, env =
15
+ * resolveCliEnv(ctx.opts.env), workspacePath = ctx.opts.workspacePath,
16
+ * forge = the shared FakeForge, exec = spawnExec — real git) and invokes
17
+ * runCli(argv, deps) exactly like an agent typing `ab` commands (§8).
18
+ * A nonzero exit inside a script is a seam failure: it is recorded in
19
+ * `cliErrors` and thrown with the captured stderr.
20
+ *
21
+ * Only the world's edges are fakes, both journaled: FakeForge (D7 §8.6 —
22
+ * agents never touch the remote, so a journal IS the remote) and
23
+ * FakeTicketSource (§13 — projections flow outward only).
24
+ */
25
+ import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
26
+ import { tmpdir } from 'node:os'
27
+ import { dirname, join } from 'node:path'
28
+ import type { DispatchWiring } from '../cli/dispatch'
29
+ import { resolveCliEnv, type CliEnv } from '../cli/env'
30
+ import { runCli } from '../cli/main'
31
+ import { parseConfig } from '../config/load'
32
+ import type { Config } from '../config/schema'
33
+ import type { AbEvent } from '../events/catalog'
34
+ import type { EventType } from '../events/payloads'
35
+ import { sequentialIds, sequentialUuids, type IdSource } from '../ids'
36
+ import type { BuildState } from '../kernel/reducer'
37
+ import { createPluginRegistry, type PluginRegistry } from '../plugins/registry'
38
+ import { materializePluginRuntimes } from '../plugins/runtimes'
39
+ import type { RuntimeRegistry } from '../ports/runner/runtime'
40
+ import { createForge } from '../ports/forge/create'
41
+ import { FakeForge } from '../ports/forge/fake'
42
+ import { LocalGitForge } from '../ports/forge/local-git'
43
+ import {
44
+ defaultTurnResult,
45
+ ScriptedAgentRunner,
46
+ type Script,
47
+ type ScriptContext,
48
+ } from '../ports/runner/fake'
49
+ import { FakeTicketSource } from '../ports/tickets/fake'
50
+ import type {
51
+ AgentTurnResult,
52
+ Forge,
53
+ Ticket,
54
+ TicketSource,
55
+ WorkspaceProvider,
56
+ } from '../ports/types'
57
+ import { GitWorktreeProvider, spawnExec } from '../ports/workspace/git-worktree'
58
+ import { InProcessBuildExecution } from '../ports/workspace/in-process-build-execution'
59
+ import { BuildRunner, LeaseHeldError, SetupFailureError } from '../processes/build-runner'
60
+ import { diagnosticArtifact } from '../processes/build-execution-state'
61
+ import { Dispatcher, type LaunchRunnerResult } from '../processes/dispatcher'
62
+ import { MemoryBuildStore } from '../store/memory'
63
+ import type { BuildStore } from '../store/types'
64
+ import { steppingClock } from '../testing/fixed'
65
+
66
+ // ── The committed autobuild.toml fixture (§16.1) ─────────────────────────────
67
+ //
68
+ // The check command is the harness's control knob (§15.6-A): `test -f
69
+ // ok.marker` fails until an implement round commits the marker file.
70
+
71
+ export const CONFIG_TOML = `
72
+ baseBranch = "main"
73
+ capacity = 2
74
+
75
+ [commands]
76
+ test = "test -f ok.marker"
77
+
78
+ [verify]
79
+ steps = ["unit"]
80
+
81
+ [verify.unit]
82
+ kind = "check"
83
+ command = "test"
84
+
85
+ [policy]
86
+ stallRounds = 3
87
+
88
+ [roles.default]
89
+ runtime = "scripted"
90
+
91
+ [tickets]
92
+ source = "file"
93
+ readyLabels = ["autobuild"]
94
+ readyState = "Ready"
95
+ `
96
+
97
+ /** Conforming per the dispatcher's exported heuristic (§6.3,
98
+ * docs/spec-standard.md): nonempty body, '## Acceptance criteria' with a
99
+ * list item, '## Out of scope'. */
100
+ export const CONFORMING_BODY = [
101
+ 'Login attempts are currently unlimited; throttle repeated failures.',
102
+ '',
103
+ '## Acceptance criteria',
104
+ '',
105
+ '- a sixth failed login within five minutes returns 429',
106
+ '',
107
+ '## Out of scope',
108
+ '',
109
+ '- captcha',
110
+ '',
111
+ ].join('\n')
112
+
113
+ export function readyTicket(id: string, over: Partial<Omit<Ticket, 'ref'>> = {}): Ticket {
114
+ const title = over.title ?? 'Add rate limiting'
115
+ return {
116
+ ref: { source: 'fake', id, title },
117
+ title,
118
+ body: over.body ?? CONFORMING_BODY,
119
+ state: over.state ?? 'Ready',
120
+ labels: over.labels ?? ['autobuild'],
121
+ }
122
+ }
123
+
124
+ // ── Real-git helpers ─────────────────────────────────────────────────────────
125
+
126
+ /** Identity/signing pinned per invocation so tests ignore user git config. */
127
+ export const GIT_ID = [
128
+ '-c',
129
+ 'user.email=ab@e2e.invalid',
130
+ '-c',
131
+ 'user.name=ab-e2e',
132
+ '-c',
133
+ 'commit.gpgsign=false',
134
+ ]
135
+
136
+ export async function git(args: string[], cwd: string): Promise<string> {
137
+ const result = await spawnExec(['git', ...args], { cwd })
138
+ if (result.exitCode !== 0) {
139
+ throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`)
140
+ }
141
+ return result.stdout.trim()
142
+ }
143
+
144
+ /** Stage everything and commit; returns the commit sha. `.ab/` never dirties
145
+ * the tree (§7) because `ab context` writes a self-excluding
146
+ * `.ab/.gitignore` — the origin deliberately does NOT ignore it, exactly
147
+ * like a real repo, so these scenarios exercise that mechanism. */
148
+ export async function commitAll(ws: string, message: string): Promise<string> {
149
+ await git(['add', '-A'], ws)
150
+ await git([...GIT_ID, 'commit', '-q', '-m', message], ws)
151
+ return git(['rev-parse', 'HEAD'], ws)
152
+ }
153
+
154
+ export async function writeFileIn(dir: string, rel: string, content: string): Promise<string> {
155
+ const path = join(dir, rel)
156
+ await mkdir(dirname(path), { recursive: true })
157
+ await writeFile(path, content)
158
+ return path
159
+ }
160
+
161
+ async function initOrigin(dir: string, configToml: string, noRemote = false): Promise<string> {
162
+ // Reconcile fetches the current base from the conventional `origin` remote
163
+ // at phase startup. Keep that seam real in every integration scenario: the
164
+ // dispatcher works from `dir`, while a separate bare repo is its remote.
165
+ const remote = join(dirname(dir), 'remote.git')
166
+ if (!noRemote) {
167
+ await mkdir(remote, { recursive: true })
168
+ await git(['init', '--bare', '-q', '-b', 'main'], remote)
169
+ }
170
+
171
+ await mkdir(dir, { recursive: true })
172
+ await git(['init', '-q', '-b', 'main'], dir)
173
+ if (!noRemote) await git(['remote', 'add', 'origin', remote], dir)
174
+ // No `.ab/` ignore here — see commitAll: the product establishes it.
175
+ await writeFile(join(dir, 'autobuild.toml'), configToml)
176
+ await writeFile(join(dir, 'README.md'), 'e2e origin\n')
177
+ await git(['add', '-A'], dir)
178
+ await git([...GIT_ID, 'commit', '-q', '-m', 'initial'], dir)
179
+ if (!noRemote) await git(['push', '-q', '-u', 'origin', 'main'], dir)
180
+ return remote
181
+ }
182
+
183
+ // ── Event helpers ────────────────────────────────────────────────────────────
184
+
185
+ export function typesOf(events: AbEvent[]): string[] {
186
+ return events.map((event) => event.type)
187
+ }
188
+
189
+ export function ofType<T extends EventType>(
190
+ events: AbEvent[],
191
+ type: T,
192
+ ): Extract<AbEvent, { type: T }>[] {
193
+ return events.filter((event): event is Extract<AbEvent, { type: T }> => event.type === type)
194
+ }
195
+
196
+ /** The agent session an event was emitted under (throws on non-agent actors
197
+ * so a wrong-actor event fails the test loudly). */
198
+ export function agentSession(event: AbEvent): string {
199
+ if (event.actor.kind !== 'agent') {
200
+ throw new Error(`${event.type} (seq ${event.seq}) has a ${event.actor.kind} actor, not agent`)
201
+ }
202
+ return event.actor.session
203
+ }
204
+
205
+ /** Latest open workspace's local path, with provider ref fallback for logs
206
+ * written before workspace path evidence was added. */
207
+ export function openWorkspacePath(events: AbEvent[]): string | null {
208
+ let open: string | null = null
209
+ for (const event of events) {
210
+ if (event.type === 'workspace.provisioned') {
211
+ open = event.payload.path ?? event.payload.ref
212
+ } else if (event.type === 'workspace.released') open = null
213
+ }
214
+ return open
215
+ }
216
+
217
+ // ── Scripted agents over the real CLI ────────────────────────────────────────
218
+
219
+ /**
220
+ * What a skill handler gets per turn: the real `ab` CLI bound to this
221
+ * session's ambient auth (D8) and the real worktree.
222
+ */
223
+ export interface Cli {
224
+ /** Invoke the REAL `ab` CLI (runCli); returns stdout lines. A nonzero exit
225
+ * is a seam failure — recorded in harness.cliErrors and thrown with the
226
+ * captured stderr. */
227
+ run(argv: string[]): Promise<string[]>
228
+ /** Absolute path of the real worktree (ctx.opts.workspacePath). */
229
+ ws: string
230
+ /** This turn's round, resolved from ambient AB_PHASE (D8) — the runner
231
+ * re-issues the env on every continued producer turn (§10). */
232
+ round: number
233
+ /** The ambient env this turn resolved (D8). */
234
+ env: CliEnv
235
+ ctx: ScriptContext
236
+ }
237
+
238
+ export type SkillHandler = (
239
+ cli: Cli,
240
+ ) => Promise<AgentTurnResult | undefined | void> | AgentTurnResult | undefined | void
241
+
242
+ /** Scripts are routed by ctx.opts.skill — one handler per phase skill. */
243
+ export type SkillHandlers = Record<string, SkillHandler>
244
+
245
+ // ── The harness ──────────────────────────────────────────────────────────────
246
+
247
+ export interface E2eHarness {
248
+ tmp: string
249
+ /** The real local checkout (also the dispatcher's `repo`). */
250
+ origin: string
251
+ /** The bare `origin` remote used by the local checkout. */
252
+ remote: string
253
+ store: MemoryBuildStore
254
+ clock: ReturnType<typeof steppingClock>
255
+ ids: IdSource
256
+ forge: FakeForge
257
+ /** Forge actually threaded through CLI, runner, and dispatcher. */
258
+ forgeAdapter: Forge
259
+ tickets: FakeTicketSource
260
+ workspaces: WorkspaceProvider
261
+ agents: ScriptedAgentRunner
262
+ dispatcher: Dispatcher
263
+ /** The same fakes, store, clocks, ids, workspaces, and scripted runtime as a
264
+ * DispatchWiring so callers can drive the real `abDispatch` composition. */
265
+ wiring: DispatchWiring
266
+ config: Config
267
+ /** BuildRunners constructed by the dispatcher's launchRunner, in order.
268
+ * launchRunner only CONSTRUCTS (the dispatcher never awaits a pipeline);
269
+ * tests run them via runLatest(). */
270
+ launched: Array<{ slug: string; runner: BuildRunner; instance: string }>
271
+ /** Nonzero-exit `ab` invocations (message + stderr). Scenarios assert []. */
272
+ cliErrors: string[]
273
+ /** Advance bare origin through an independent checkout, deliberately
274
+ * leaving the dispatcher's local base ref stale. */
275
+ advanceRemote(changes: Record<string, string>, message: string): Promise<string>
276
+ /** Run the most recently launched BuildRunner to its park point (§11). */
277
+ runLatest(): Promise<BuildState>
278
+ events(slug: string): Promise<AbEvent[]>
279
+ cleanup(): Promise<void>
280
+ }
281
+
282
+ export async function makeHarness(opts: {
283
+ handlers: SkillHandlers
284
+ tickets?: Ticket[]
285
+ /** Drive the dispatcher with a REAL TicketSource instead of the fake — e.g.
286
+ * FileTicketSource, to prove the source's own dependency representation and
287
+ * lifecycle end-to-end. `h.tickets` stays the (then unused) fake. */
288
+ ticketSource?: TicketSource
289
+ /** The committed autobuild.toml (§16.1) driving this build. Default
290
+ * CONFIG_TOML, so every existing scenario is untouched; a scenario proving
291
+ * two-axis routing (§9) supplies its own `[roles.default]`/phase roles. */
292
+ configToml?: string
293
+ /** Forge gate existence is independent from current PR mergeability. */
294
+ gatePresence?: 'present' | 'absent'
295
+ /** Optional adapter-level runtime composition for focused integration tests.
296
+ * The scripted agent remains available as the fake provider behind it. */
297
+ createRuntimeRegistry?: (agents: ScriptedAgentRunner) => RuntimeRegistry
298
+ /** Pass false to launch runners with `streamSessions: false` — the exact
299
+ * event log a build produces when session streaming is off. */
300
+ streamSessions?: boolean
301
+ /** Optional production-like workspace composition seam. The default remains
302
+ * the real git-worktree adapter used by existing scenarios. */
303
+ createWorkspaceProvider?: (context: {
304
+ config: Config
305
+ repoRoot: string
306
+ worktreeRoot: string
307
+ }) => WorkspaceProvider | Promise<WorkspaceProvider>
308
+ /** Resolve the config's forge name through a plugin registration instead of
309
+ * injecting FakeForge directly. The selected adapter still shares this one
310
+ * fake journal across CLI terminals, runner, dispatcher, and janitor. */
311
+ pluginForge?: { prAttachments?: boolean }
312
+ /** Exercise the shipped offline forge against a repository with no remote. */
313
+ localGitForge?: boolean
314
+ /** Optional transport adapter around the backing memory store. Hosted tests
315
+ * use this to force dispatcher, runner, and CLI traffic through HTTP. */
316
+ storeAdapter?: (backing: MemoryBuildStore) => Promise<{
317
+ store: BuildStore
318
+ storeRef: string
319
+ token?: string
320
+ cleanup?: () => Promise<void>
321
+ }>
322
+ /** Execute scripted phase commands through the private `ab` launcher on
323
+ * PATH, forcing production ambient-env parsing and Store reopening. */
324
+ processCli?: boolean
325
+ }): Promise<E2eHarness> {
326
+ const tmp = await mkdtemp(join(tmpdir(), 'ab-e2e-'))
327
+ const originPath = join(tmp, 'origin')
328
+ const configToml = opts.configToml ?? CONFIG_TOML
329
+ const remote = await initOrigin(originPath, configToml, opts.localGitForge === true)
330
+ // Git canonicalizes temporary paths on macOS (`/var` → `/private/var`).
331
+ // Records and every dispatch caller must use that same repository identity.
332
+ const origin = await realpath(originPath)
333
+ const remoteUpdater = join(tmp, 'remote-updater')
334
+ if (opts.localGitForge !== true) await git(['clone', '-q', remote, remoteUpdater], tmp)
335
+
336
+ const clock = steppingClock()
337
+ const ids = sequentialIds()
338
+ const uuids = sequentialUuids()
339
+ const store = new MemoryBuildStore({ clock })
340
+ const adapted = await opts.storeAdapter?.(store)
341
+ const activeStore = adapted?.store ?? store
342
+ const config = parseConfig(configToml, 'e2e autobuild.toml')
343
+ // Ad-hoc scenario configs predating mandatory runtime defaults stay explicit
344
+ // at the harness boundary rather than relying on production wiring.
345
+ config.roles.default ??= { runtime: 'scripted' }
346
+ const forge = new FakeForge({
347
+ gatePresence: opts.gatePresence ?? 'present',
348
+ ...(opts.pluginForge?.prAttachments === true ? { prAttachments: true } : {}),
349
+ })
350
+ let selectedForge: Forge = opts.localGitForge === true ? new LocalGitForge() : forge
351
+ let plugins: PluginRegistry | undefined
352
+ if (opts.pluginForge !== undefined) {
353
+ plugins = createPluginRegistry()
354
+ plugins.register({
355
+ name: 'e2e-forge-plugin',
356
+ apiVersion: '^1.0.0',
357
+ forges: { [config.forge]: async () => forge },
358
+ })
359
+ selectedForge = await createForge({
360
+ name: config.forge,
361
+ registry: plugins,
362
+ env: process.env,
363
+ repoRoot: origin,
364
+ })
365
+ }
366
+ const tickets = new FakeTicketSource(opts.tickets ?? [])
367
+ const ticketSource: TicketSource = opts.ticketSource ?? tickets
368
+ const worktreeRoot = join(tmp, 'worktrees')
369
+ const workspaces =
370
+ opts.createWorkspaceProvider === undefined
371
+ ? new GitWorktreeProvider({ root: worktreeRoot })
372
+ : await opts.createWorkspaceProvider({ config, repoRoot: origin, worktreeRoot })
373
+
374
+ const launched: Array<{ slug: string; runner: BuildRunner; instance: string }> = []
375
+ const cliErrors: string[] = []
376
+
377
+ // The script IS the agent (§9): route by skill, hand the handler the real
378
+ // CLI bound to this turn's ambient env (D8).
379
+ const script: Script = async (ctx) => {
380
+ const handler =
381
+ opts.handlers[ctx.opts.skill] ?? opts.handlers[ctx.opts.skill.replace(/^ab-/, '')]
382
+ if (handler === undefined) {
383
+ throw new Error(`no scripted handler for skill "${ctx.opts.skill}"`)
384
+ }
385
+ const result = await handler(makeCli(ctx))
386
+ return result ?? defaultTurnResult(`${ctx.opts.skill} finished`)
387
+ }
388
+ const agents = new ScriptedAgentRunner({ script })
389
+
390
+ function makeCli(ctx: ScriptContext): Cli {
391
+ // D8, VERBATIM: exactly what the real adapters do — the CLI resolves the
392
+ // env the turn was launched with, nothing rebuilt by the harness. The
393
+ // runner re-issues AB_PHASE/AB_SESSION on every continued producer turn
394
+ // (§10, build-runner.ts executeSession); a harness that hand-advanced
395
+ // the round here would mask a stale-env adapter (the continued round's
396
+ // `ab done` would resolve round 1 and be rejected as a D5 second call).
397
+ const env = resolveCliEnv(ctx.opts.env)
398
+ const ws = ctx.opts.workspacePath
399
+
400
+ const run = async (argv: string[]): Promise<string[]> => {
401
+ let code: number
402
+ let out: string[]
403
+ let err: string[]
404
+ if (opts.processCli === true) {
405
+ const child = Bun.spawn(['ab', ...argv], {
406
+ cwd: ws,
407
+ env: ctx.opts.env,
408
+ stdout: 'pipe',
409
+ stderr: 'pipe',
410
+ })
411
+ const [exitCode, stdout, stderr] = await Promise.all([
412
+ child.exited,
413
+ new Response(child.stdout).text(),
414
+ new Response(child.stderr).text(),
415
+ ])
416
+ code = exitCode
417
+ out = stdout.trimEnd() === '' ? [] : stdout.trimEnd().split('\n')
418
+ err = stderr.trimEnd() === '' ? [] : stderr.trimEnd().split('\n')
419
+ } else {
420
+ out = []
421
+ err = []
422
+ code = await runCli(argv, {
423
+ store: activeStore,
424
+ env,
425
+ workspacePath: ws,
426
+ forge: selectedForge,
427
+ exec: spawnExec,
428
+ ids,
429
+ clock,
430
+ stdout: (line) => out.push(line),
431
+ stderr: (line) => err.push(line),
432
+ })
433
+ }
434
+ if (code !== 0) {
435
+ const message =
436
+ `ab ${argv.join(' ')} exited ${code} in ${env.phase}@${env.round}: ` +
437
+ `${err.join('\n') || '(no stderr)'}`
438
+ cliErrors.push(message)
439
+ throw new Error(message)
440
+ }
441
+ return out
442
+ }
443
+ return { run, ws, round: env.round, env, ctx }
444
+ }
445
+
446
+ // Both direct harness runners and injected abDispatch wiring use this one
447
+ // registry, so capture scenarios cannot accidentally exercise a second fake
448
+ // pipeline implementation. `plugin-scripted` crosses the real plugin
449
+ // materialization boundary while reusing the contract-tested scripted fake.
450
+ const pluginRegistry = createPluginRegistry()
451
+ pluginRegistry.register({
452
+ name: 'e2e-runtime-plugin',
453
+ apiVersion: '^1.0.0',
454
+ agentRuntimes: {
455
+ 'plugin-scripted': () => ({
456
+ runner: agents,
457
+ servesModels: ['plugin/'],
458
+ defaultModel: 'plugin/default',
459
+ }),
460
+ },
461
+ })
462
+ const baseRuntimes = opts.createRuntimeRegistry?.(agents) ?? {
463
+ scripted: { runner: agents, servesModels: [] },
464
+ claude: { runner: agents, servesModels: ['claude-'] },
465
+ pi: { runner: agents, servesModels: ['kimi-'] },
466
+ }
467
+ const runtimes = await materializePluginRuntimes(baseRuntimes, pluginRegistry, {
468
+ repoRoot: origin,
469
+ env: {},
470
+ })
471
+ // launchRunner (§3.3, §15.6-C, §15.7): construct a REAL BuildRunner over
472
+ // the SAME store and the provisioned workspace path; the dispatcher never
473
+ // runs agents itself.
474
+ let instances = 0
475
+ const makeRunner = async (
476
+ slug: string,
477
+ instance?: string,
478
+ sessionEnv?: Record<string, string>,
479
+ ): Promise<{ runner: BuildRunner; instance: string }> => {
480
+ const record = await activeStore.getBuild(slug)
481
+ const workspacePath = openWorkspacePath(await activeStore.getEvents(slug))
482
+ if (record === null || workspacePath === null) {
483
+ throw new Error(`launchRunner("${slug}"): no build record or open workspace`)
484
+ }
485
+ instances += 1
486
+ const resolvedInstance = instance ?? `runner-${instances}`
487
+ return {
488
+ instance: resolvedInstance,
489
+ runner: new BuildRunner({
490
+ store: activeStore.scopeBuild(slug),
491
+ config,
492
+ // Two-axis registry (§9): every runtime is backed by the SAME scripted
493
+ // runner instance, so the `s_1…s_N` session numbering scenarios rely on
494
+ // is preserved regardless of which runtime a role selects. `scripted`
495
+ // is the configured default and runs only with its un-named built-in model; `pi`
496
+ // serves the Kimi family for exact configured-pair validation.
497
+ runtimes,
498
+ workspacePath,
499
+ branch: record.branch ?? `ab/${slug}`,
500
+ slug,
501
+ exec: spawnExec,
502
+ forge: selectedForge,
503
+ ids,
504
+ clock,
505
+ instance: resolvedInstance,
506
+ host: 'e2e-host',
507
+ ...(sessionEnv !== undefined ? { sessionEnv } : {}),
508
+ // Long lease/heartbeat: liveness is driven by the shared stepping
509
+ // clock; scenarios advance it explicitly to expire a lease.
510
+ opts: {
511
+ heartbeatMs: 3_600_000,
512
+ leaseTtlMs: 3_600_000,
513
+ ...(opts.streamSessions === false ? { streamSessions: false } : {}),
514
+ },
515
+ }),
516
+ }
517
+ }
518
+ const launchRunner = async (slug: string): Promise<LaunchRunnerResult> => {
519
+ launched.push({ slug, ...(await makeRunner(slug)) })
520
+ return 'scheduled'
521
+ }
522
+
523
+ const dispatcher = new Dispatcher({
524
+ store: activeStore,
525
+ tickets: ticketSource,
526
+ workspaces,
527
+ forge: selectedForge,
528
+ config,
529
+ repo: origin,
530
+ exec: spawnExec,
531
+ launchRunner,
532
+ ids,
533
+ clock,
534
+ })
535
+ const wiring: DispatchWiring = {
536
+ store: activeStore,
537
+ tickets: ticketSource,
538
+ forge: selectedForge,
539
+ workspaces,
540
+ buildExecution: new InProcessBuildExecution(async (input) => {
541
+ const { runner } = await makeRunner(input.slug, input.instance, {
542
+ AB_STORE: input.storeRef,
543
+ ...(adapted?.token !== undefined ? { AB_TOKEN: adapted.token } : {}),
544
+ })
545
+ await runner.run().catch(async (error) => {
546
+ await activeStore.putArtifact(
547
+ input.slug,
548
+ diagnosticArtifact({
549
+ instance: input.instance,
550
+ outcome:
551
+ error instanceof LeaseHeldError
552
+ ? 'lease-held'
553
+ : error instanceof SetupFailureError
554
+ ? 'setup-failed'
555
+ : 'failed',
556
+ error: error instanceof Error ? error.message : String(error),
557
+ }),
558
+ )
559
+ throw error
560
+ })
561
+ }),
562
+ runtimes,
563
+ // Scripted agents resolve this ambient value, then invoke runCli against
564
+ // the injected shared store. It is an identity label, never opened.
565
+ storeRef: adapted?.storeRef ?? 'memory://e2e',
566
+ ...(adapted?.token !== undefined ? { token: adapted.token } : {}),
567
+ ids,
568
+ uuids,
569
+ clock,
570
+ ...(plugins !== undefined ? { plugins } : {}),
571
+ }
572
+
573
+ return {
574
+ tmp,
575
+ origin,
576
+ remote,
577
+ store,
578
+ clock,
579
+ ids,
580
+ forge,
581
+ forgeAdapter: selectedForge,
582
+ tickets,
583
+ workspaces,
584
+ agents,
585
+ dispatcher,
586
+ wiring,
587
+ config,
588
+ launched,
589
+ cliErrors,
590
+ async advanceRemote(changes: Record<string, string>, message: string): Promise<string> {
591
+ if (opts.localGitForge === true) {
592
+ throw new Error('advanceRemote is unavailable for a no-remote local-git harness')
593
+ }
594
+ for (const [path, content] of Object.entries(changes)) {
595
+ await writeFileIn(remoteUpdater, path, content)
596
+ }
597
+ const sha = await commitAll(remoteUpdater, message)
598
+ await git(['push', '-q', 'origin', 'main'], remoteUpdater)
599
+ return sha
600
+ },
601
+ async runLatest(): Promise<BuildState> {
602
+ const entry = launched.at(-1)
603
+ if (entry === undefined) throw new Error('runLatest: nothing launched')
604
+ try {
605
+ return await entry.runner.run()
606
+ } finally {
607
+ // Direct Dispatcher integration bypasses DispatchLoop's execution
608
+ // supervisor; mirror its exact post-completion lease release here.
609
+ await activeStore.releaseLease(entry.slug, entry.instance)
610
+ }
611
+ },
612
+ events: (slug) => store.getEvents(slug),
613
+ cleanup: async () => {
614
+ await activeStore.close()
615
+ if (activeStore !== store) await store.close()
616
+ await adapted?.cleanup?.()
617
+ await rm(tmp, { recursive: true, force: true })
618
+ },
619
+ }
620
+ }
621
+
622
+ // ── Default (§15.6 happy-path) handlers over the REAL CLI ────────────────────
623
+
624
+ /**
625
+ * Every phase terminates the way §8.7 walks it: `ab context`, real work in
626
+ * the worktree (implement commits with real git), deposits via `ab artifact
627
+ * put`, exactly one terminal (`ab done` / `ab verdict`). Scenarios override
628
+ * individual entries.
629
+ */
630
+ export function happyHandlers(): SkillHandlers {
631
+ return {
632
+ plan: async (cli) => {
633
+ await cli.run(['context'])
634
+ const plan = await writeFileIn(
635
+ cli.ws,
636
+ '.ab/plan.md',
637
+ `# Plan (round ${cli.round})\n\n1. Add rate-limit.txt and ok.marker.\n`,
638
+ )
639
+ await cli.run(['artifact', 'put', 'plan', plan])
640
+ await cli.run(['done'])
641
+ },
642
+ 'plan-review': async (cli) => {
643
+ await cli.run(['context'])
644
+ const notes = await writeFileIn(cli.ws, '.ab/plan-review.md', 'Plan conforms to the spec.\n')
645
+ await cli.run(['verdict', 'approve', '--notes', notes])
646
+ },
647
+ implement: async (cli) => {
648
+ await cli.run(['context'])
649
+ await writeFileIn(cli.ws, 'rate-limit.txt', `throttle after 5 (r${cli.round})\n`)
650
+ await writeFileIn(cli.ws, 'ok.marker', 'ok\n')
651
+ await commitAll(cli.ws, `implement: rate limiting r${cli.round}`)
652
+ // §8.7: a mid-phase structured observation — never a terminal.
653
+ await cli.run(['observe', '--kind', 'refactor', 'extract limiter config into settings'])
654
+ const notes = await writeFileIn(
655
+ cli.ws,
656
+ '.ab/implement-notes.md',
657
+ `Added rate-limit.txt (round ${cli.round}).\n`,
658
+ )
659
+ await cli.run(['done', '--notes', notes])
660
+ },
661
+ 'code-review': async (cli) => {
662
+ await cli.run(['context'])
663
+ const notes = await writeFileIn(
664
+ cli.ws,
665
+ '.ab/code-review.md',
666
+ 'Diff matches the approved plan.\n',
667
+ )
668
+ await cli.run(['verdict', 'approve', '--notes', notes])
669
+ },
670
+ finalize: async (cli) => {
671
+ await cli.run(['context'])
672
+ const pr = await writeFileIn(
673
+ cli.ws,
674
+ '.ab/pr-description.md',
675
+ 'Add login rate limiting\n\nThrottles repeated failed logins per the spec.\n',
676
+ )
677
+ await cli.run(['artifact', 'put', 'pr-description', pr])
678
+ await cli.run(['done'])
679
+ },
680
+ }
681
+ }