@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,568 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { z } from 'zod'
5
+ import type { AutoMergeResult, ClosePrResult, Forge, PrRef, PrState } from '../types'
6
+ import { bunExec, type Exec } from './github'
7
+
8
+ const objectId = z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/)
9
+ const pendingLanding = z.strictObject({
10
+ baseSha: objectId,
11
+ headSha: objectId,
12
+ treeSha: objectId,
13
+ commitSha: objectId,
14
+ })
15
+ const localPrRecord = z.strictObject({
16
+ version: z.literal(1),
17
+ number: z.number().int().positive(),
18
+ head: z.string().min(1),
19
+ base: z.string().min(1),
20
+ title: z.string(),
21
+ body: z.string(),
22
+ mergeMessage: z.string(),
23
+ openedHeadSha: objectId,
24
+ status: z.enum(['open', 'closed', 'merged']),
25
+ mergedSha: objectId.optional(),
26
+ pendingLanding: pendingLanding.optional(),
27
+ comments: z.array(z.string()),
28
+ })
29
+ type LocalPrRecord = z.infer<typeof localPrRecord>
30
+
31
+ const PR_PREFIX = 'refs/autobuild/local-git/prs/'
32
+
33
+ function detail(error: unknown): string {
34
+ return error instanceof Error ? error.message : String(error)
35
+ }
36
+
37
+ /** Offline Forge backed only by refs and objects in one repository's shared
38
+ * Git database. No method invokes a shell, remote command, network client, or
39
+ * `gh`; every subprocess is an argv-form `git` command. */
40
+ export class LocalGitForge implements Forge {
41
+ readonly name = 'local-git'
42
+ private readonly exec: Exec
43
+
44
+ constructor(opts: { exec?: Exec } = {}) {
45
+ this.exec = opts.exec ?? bunExec
46
+ }
47
+
48
+ private async command(args: string[], cwd: string, allowFailure = false) {
49
+ const result = await this.exec(['git', ...args], { cwd })
50
+ if (!allowFailure && result.exitCode !== 0) {
51
+ throw new Error(
52
+ `local-git command failed (exit ${result.exitCode}): git ${args.join(' ')}\n` +
53
+ `${result.stderr.trim() || result.stdout.trim() || '(no output)'}`,
54
+ )
55
+ }
56
+ return result
57
+ }
58
+
59
+ private async sha(ref: string, cwd: string): Promise<string> {
60
+ const result = await this.command(['rev-parse', '--verify', `${ref}^{commit}`], cwd)
61
+ const sha = result.stdout.trim()
62
+ if (!objectId.safeParse(sha).success) {
63
+ throw new Error(
64
+ `local-git expected ${ref} to resolve to one commit, found ${JSON.stringify(sha)}`,
65
+ )
66
+ }
67
+ return sha
68
+ }
69
+
70
+ private async assertRef(ref: string, cwd: string): Promise<void> {
71
+ const result = await this.command(['check-ref-format', ref], cwd, true)
72
+ if (result.exitCode !== 0)
73
+ throw new Error(`local-git rejected invalid ref ${JSON.stringify(ref)}`)
74
+ }
75
+
76
+ private recordRef(number: number): string {
77
+ return `${PR_PREFIX}${number}`
78
+ }
79
+
80
+ private async readRecord(
81
+ cwd: string,
82
+ number: number,
83
+ ): Promise<{ record: LocalPrRecord; blob: string }> {
84
+ const ref = this.recordRef(number)
85
+ const resolved = await this.command(['rev-parse', '--verify', ref], cwd, true)
86
+ if (resolved.exitCode !== 0) throw new Error(`local-git: unknown PR #${number}`)
87
+ const blob = resolved.stdout.trim()
88
+ if (!objectId.safeParse(blob).success) {
89
+ throw new Error(
90
+ `local-git PR #${number} ref contains malformed object id ${JSON.stringify(blob)}`,
91
+ )
92
+ }
93
+ const source = await this.command(['cat-file', 'blob', blob], cwd)
94
+ let value: unknown
95
+ try {
96
+ value = JSON.parse(source.stdout)
97
+ } catch (error) {
98
+ throw new Error(`local-git PR #${number} record is not JSON: ${detail(error)}`)
99
+ }
100
+ const parsed = localPrRecord.safeParse(value)
101
+ if (!parsed.success || parsed.data.number !== number) {
102
+ throw new Error(
103
+ `local-git PR #${number} record is invalid: ` +
104
+ (parsed.success ? `record names PR #${parsed.data.number}` : parsed.error.message),
105
+ )
106
+ }
107
+ return { record: parsed.data, blob }
108
+ }
109
+
110
+ private async writeBlob(cwd: string, record: LocalPrRecord): Promise<string> {
111
+ const dir = await mkdtemp(join(tmpdir(), 'ab-local-git-'))
112
+ try {
113
+ const path = join(dir, 'record.json')
114
+ await writeFile(path, `${JSON.stringify(record)}\n`, 'utf8')
115
+ const result = await this.command(['hash-object', '-w', path], cwd)
116
+ const sha = result.stdout.trim()
117
+ if (!objectId.safeParse(sha).success)
118
+ throw new Error('local-git failed to write PR record blob')
119
+ return sha
120
+ } finally {
121
+ await rm(dir, { recursive: true, force: true })
122
+ }
123
+ }
124
+
125
+ private async objectZeros(cwd: string): Promise<string> {
126
+ const result = await this.command(['rev-parse', '--show-object-format'], cwd)
127
+ return result.stdout.trim() === 'sha256' ? '0'.repeat(64) : '0'.repeat(40)
128
+ }
129
+
130
+ private async createRecord(cwd: string, record: LocalPrRecord): Promise<boolean> {
131
+ const blob = await this.writeBlob(cwd, record)
132
+ const result = await this.command(
133
+ ['update-ref', this.recordRef(record.number), blob, await this.objectZeros(cwd)],
134
+ cwd,
135
+ true,
136
+ )
137
+ return result.exitCode === 0
138
+ }
139
+
140
+ private async updateRecord(
141
+ cwd: string,
142
+ number: number,
143
+ mutate: (record: LocalPrRecord) => LocalPrRecord,
144
+ ): Promise<LocalPrRecord> {
145
+ for (let attempt = 0; attempt < 8; attempt += 1) {
146
+ const current = await this.readRecord(cwd, number)
147
+ const next = localPrRecord.parse(mutate(current.record))
148
+ const blob = await this.writeBlob(cwd, next)
149
+ const result = await this.command(
150
+ ['update-ref', this.recordRef(number), blob, current.blob],
151
+ cwd,
152
+ true,
153
+ )
154
+ if (result.exitCode === 0) return next
155
+ }
156
+ throw new Error(`local-git PR #${number} record changed concurrently too many times`)
157
+ }
158
+
159
+ private async records(cwd: string): Promise<LocalPrRecord[]> {
160
+ const listed = await this.command(['for-each-ref', '--format=%(refname)', PR_PREFIX], cwd)
161
+ const records: LocalPrRecord[] = []
162
+ for (const ref of listed.stdout.split('\n').filter(Boolean)) {
163
+ const suffix = ref.slice(PR_PREFIX.length)
164
+ if (!/^[1-9][0-9]*$/.test(suffix)) {
165
+ throw new Error(`local-git found malformed private PR ref ${JSON.stringify(ref)}`)
166
+ }
167
+ records.push((await this.readRecord(cwd, Number(suffix))).record)
168
+ }
169
+ return records.sort((a, b) => a.number - b.number)
170
+ }
171
+
172
+ async pushBranch(workspacePath: string, branch: string): Promise<void> {
173
+ const ref = `refs/heads/${branch}`
174
+ await this.assertRef(ref, workspacePath)
175
+ const symbolic = await this.command(
176
+ ['symbolic-ref', '--quiet', '--short', 'HEAD'],
177
+ workspacePath,
178
+ )
179
+ if (symbolic.stdout.trim() !== branch) {
180
+ throw new Error(
181
+ `local-git push requires workspace HEAD on ${JSON.stringify(branch)}, found ${JSON.stringify(symbolic.stdout.trim())}`,
182
+ )
183
+ }
184
+ const [head, published] = await Promise.all([
185
+ this.sha('HEAD', workspacePath),
186
+ this.sha(ref, workspacePath),
187
+ ])
188
+ if (head !== published) {
189
+ throw new Error(`local-git branch ${ref} does not resolve to workspace HEAD ${head}`)
190
+ }
191
+ }
192
+
193
+ async snapshotBase(opts: {
194
+ workspacePath: string
195
+ base: string
196
+ destinationRef: string
197
+ }): Promise<string> {
198
+ const source = `refs/heads/${opts.base}`
199
+ await this.assertRef(source, opts.workspacePath)
200
+ if (!opts.destinationRef.startsWith('refs/autobuild/')) {
201
+ throw new Error('local-git base snapshot destination must be a private refs/autobuild ref')
202
+ }
203
+ await this.assertRef(opts.destinationRef, opts.workspacePath)
204
+ const sha = await this.sha(source, opts.workspacePath)
205
+ await this.command(['update-ref', opts.destinationRef, sha], opts.workspacePath)
206
+ return sha
207
+ }
208
+
209
+ async openPr(opts: {
210
+ workspacePath: string
211
+ head: string
212
+ base: string
213
+ title: string
214
+ body: string
215
+ mergeMessage?: string
216
+ }): Promise<PrRef> {
217
+ const headRef = `refs/heads/${opts.head}`
218
+ const baseRef = `refs/heads/${opts.base}`
219
+ await this.assertRef(headRef, opts.workspacePath)
220
+ await this.assertRef(baseRef, opts.workspacePath)
221
+ const headSha = await this.sha(headRef, opts.workspacePath)
222
+ await this.sha(baseRef, opts.workspacePath)
223
+
224
+ for (;;) {
225
+ const records = await this.records(opts.workspacePath)
226
+ const adopted = records.find(
227
+ (record) => record.status === 'open' && record.head === opts.head,
228
+ )
229
+ if (adopted !== undefined) {
230
+ return { number: adopted.number, url: headRef, headSha }
231
+ }
232
+ const number = (records.at(-1)?.number ?? 0) + 1
233
+ const created: LocalPrRecord = {
234
+ version: 1,
235
+ number,
236
+ head: opts.head,
237
+ base: opts.base,
238
+ title: opts.title,
239
+ body: opts.body,
240
+ mergeMessage: opts.mergeMessage ?? [opts.title, opts.body].filter(Boolean).join('\n\n'),
241
+ openedHeadSha: headSha,
242
+ status: 'open',
243
+ comments: [],
244
+ }
245
+ if (await this.createRecord(opts.workspacePath, created)) {
246
+ return { number, url: headRef, headSha }
247
+ }
248
+ // Another process allocated this number. Re-scan so an equivalent
249
+ // creation is adopted before considering the next number.
250
+ }
251
+ }
252
+
253
+ private async mergeTree(cwd: string, baseSha: string, headSha: string): Promise<string | null> {
254
+ const result = await this.command(['merge-tree', '--write-tree', baseSha, headSha], cwd, true)
255
+ if (result.exitCode === 1) return null
256
+ if (result.exitCode !== 0) {
257
+ throw new Error(
258
+ `local-git mergeability check failed (exit ${result.exitCode}): ` +
259
+ `${result.stderr.trim() || result.stdout.trim() || '(no output)'}`,
260
+ )
261
+ }
262
+ const tree = result.stdout.split('\n', 1)[0]!.trim()
263
+ if (!objectId.safeParse(tree).success) {
264
+ throw new Error(`local-git merge-tree returned malformed tree ${JSON.stringify(tree)}`)
265
+ }
266
+ return tree
267
+ }
268
+
269
+ private async isAncestor(cwd: string, ancestor: string, descendant: string): Promise<boolean> {
270
+ const result = await this.command(
271
+ ['merge-base', '--is-ancestor', ancestor, descendant],
272
+ cwd,
273
+ true,
274
+ )
275
+ if (result.exitCode === 0) return true
276
+ if (result.exitCode === 1) return false
277
+ throw new Error(`local-git could not compare landing ancestry: ${result.stderr.trim()}`)
278
+ }
279
+
280
+ /** Apply Git's two-tree fast-forward operation. It carries index/worktree
281
+ * changes on paths unchanged by the landing and refuses any tracked or
282
+ * untracked overwrite. `dryRun` exercises those exact checks without writing. */
283
+ private async transitionCheckout(
284
+ checkout: string,
285
+ from: string,
286
+ to: string,
287
+ dryRun: boolean,
288
+ ): Promise<{ ok: true } | { ok: false; detail: string }> {
289
+ const result = await this.command(
290
+ ['read-tree', '-m', '-u', ...(dryRun ? ['-n'] : []), `${from}^{tree}`, `${to}^{tree}`],
291
+ checkout,
292
+ true,
293
+ )
294
+ if (result.exitCode === 0) return { ok: true }
295
+ return {
296
+ ok: false,
297
+ detail:
298
+ result.stderr.trim() ||
299
+ result.stdout.trim() ||
300
+ `git read-tree refused the checkout transition (exit ${result.exitCode})`,
301
+ }
302
+ }
303
+
304
+ /** Repair the crash window after the base ref moved but before its checked-out
305
+ * index/worktree was updated. A collision remains retryable and untouched. */
306
+ private async repairObservedCheckout(
307
+ cwd: string,
308
+ baseRef: string,
309
+ pending: z.infer<typeof pendingLanding>,
310
+ ): Promise<boolean> {
311
+ const checkout = await this.checkedOutBase(cwd, baseRef)
312
+ if (checkout === undefined) return true
313
+ return (await this.transitionCheckout(checkout, pending.baseSha, pending.commitSha, false)).ok
314
+ }
315
+
316
+ async getPrState(workspacePath: string, number: number): Promise<PrState> {
317
+ let { record } = await this.readRecord(workspacePath, number)
318
+ if (record.status === 'merged') return { state: 'merged', sha: record.mergedSha! }
319
+ if (record.status === 'closed') return { state: 'closed' }
320
+
321
+ const baseRef = `refs/heads/${record.base}`
322
+ const baseSha = await this.sha(baseRef, workspacePath)
323
+ if (
324
+ record.pendingLanding !== undefined &&
325
+ (await this.isAncestor(workspacePath, record.pendingLanding.commitSha, baseSha))
326
+ ) {
327
+ const landingSha = record.pendingLanding.commitSha
328
+ // At the exact ref-move crash boundary, merged observation waits until
329
+ // the checkout transition succeeds. A later descendant means another Git
330
+ // operation already advanced the landed base, so there is no old checkout
331
+ // left for this pending transition to repair.
332
+ if (
333
+ baseSha === landingSha &&
334
+ !(await this.repairObservedCheckout(workspacePath, baseRef, record.pendingLanding))
335
+ ) {
336
+ return { state: 'open', mergeable: true }
337
+ }
338
+ record = await this.updateRecord(workspacePath, number, (current) => ({
339
+ ...current,
340
+ status: 'merged',
341
+ mergedSha: landingSha,
342
+ pendingLanding: undefined,
343
+ }))
344
+ return { state: 'merged', sha: record.mergedSha! }
345
+ }
346
+
347
+ const headRef = `refs/heads/${record.head}`
348
+ const head = await this.command(
349
+ ['rev-parse', '--verify', `${headRef}^{commit}`],
350
+ workspacePath,
351
+ true,
352
+ )
353
+ if (head.exitCode !== 0) {
354
+ await this.updateRecord(workspacePath, number, (current) => ({
355
+ ...current,
356
+ status: 'closed',
357
+ }))
358
+ return { state: 'closed' }
359
+ }
360
+ const headSha = head.stdout.trim()
361
+ if (!objectId.safeParse(headSha).success) {
362
+ throw new Error(`local-git PR #${number} head ref is malformed`)
363
+ }
364
+ return {
365
+ state: 'open',
366
+ mergeable: (await this.mergeTree(workspacePath, baseSha, headSha)) !== null,
367
+ }
368
+ }
369
+
370
+ async closePr(workspacePath: string, number: number): Promise<ClosePrResult> {
371
+ const current = await this.getPrState(workspacePath, number)
372
+ if (current.state === 'merged') return current
373
+ if (current.state === 'closed') return current
374
+ const record = await this.updateRecord(workspacePath, number, (value) =>
375
+ value.status === 'merged' ? value : { ...value, status: 'closed', pendingLanding: undefined },
376
+ )
377
+ return record.status === 'merged'
378
+ ? { state: 'merged', sha: record.mergedSha! }
379
+ : { state: 'closed' }
380
+ }
381
+
382
+ async deleteBranch(workspacePath: string, branch: string): Promise<void> {
383
+ const ref = `refs/heads/${branch}`
384
+ await this.assertRef(ref, workspacePath)
385
+ await this.command(['update-ref', '-d', ref], workspacePath)
386
+ }
387
+
388
+ async setAutoMerge(
389
+ workspacePath: string,
390
+ number: number,
391
+ enabled: boolean,
392
+ ): Promise<AutoMergeResult> {
393
+ const { record } = await this.readRecord(workspacePath, number)
394
+ if (!enabled) return { kind: 'applied' }
395
+ if (record.status !== 'open') return { kind: 'deferred' }
396
+ const headSha = await this.sha(`refs/heads/${record.head}`, workspacePath)
397
+ const baseRef = `refs/heads/${record.base}`
398
+ const baseSha = await this.sha(baseRef, workspacePath)
399
+
400
+ let transitionFrom = baseSha
401
+ let transitionTo: string
402
+ const pending = record.pendingLanding
403
+ if (
404
+ pending !== undefined &&
405
+ pending.headSha === headSha &&
406
+ (baseSha === pending.baseSha || baseSha === pending.commitSha)
407
+ ) {
408
+ // This includes the post-ref checkout-repair window. Always inspect the
409
+ // recorded old-to-landed transition; current-base-to-head is a no-op there
410
+ // and would conceal the operator path that still blocks repair.
411
+ transitionFrom = pending.baseSha
412
+ transitionTo = pending.commitSha
413
+ } else {
414
+ const treeSha = await this.mergeTree(workspacePath, baseSha, headSha)
415
+ if (treeSha === null) return { kind: 'deferred' }
416
+ transitionTo = treeSha
417
+ }
418
+
419
+ const checkout = await this.checkedOutBase(workspacePath, baseRef)
420
+ if (checkout !== undefined) {
421
+ const preflight = await this.transitionCheckout(checkout, transitionFrom, transitionTo, true)
422
+ if (!preflight.ok) {
423
+ return {
424
+ kind: 'deferred',
425
+ reason: { code: 'local-base-checkout-dirty', detail: preflight.detail },
426
+ }
427
+ }
428
+ }
429
+
430
+ const identityFailures: string[] = []
431
+ for (const variable of ['GIT_AUTHOR_IDENT', 'GIT_COMMITTER_IDENT']) {
432
+ const result = await this.command(['var', variable], workspacePath, true)
433
+ if (result.exitCode !== 0) {
434
+ identityFailures.push(
435
+ `${variable}: ${result.stderr.trim() || result.stdout.trim() || `git var failed (exit ${result.exitCode})`}`,
436
+ )
437
+ }
438
+ }
439
+ if (identityFailures.length > 0) {
440
+ return {
441
+ kind: 'deferred',
442
+ reason: {
443
+ code: 'local-git-identity-missing',
444
+ detail:
445
+ 'Configure the repository identity with `git config user.name "Your Name"` and ' +
446
+ '`git config user.email "you@example.com"`. ' +
447
+ identityFailures.join(' | '),
448
+ },
449
+ }
450
+ }
451
+ return { kind: 'ungated', headSha }
452
+ }
453
+
454
+ private async checkedOutBase(cwd: string, baseRef: string): Promise<string | undefined> {
455
+ const result = await this.command(['worktree', 'list', '--porcelain'], cwd)
456
+ let path: string | undefined
457
+ for (const line of `${result.stdout}\n`.split('\n')) {
458
+ if (line.startsWith('worktree ')) path = line.slice('worktree '.length)
459
+ if (line === `branch ${baseRef}`) return path
460
+ if (line === '') path = undefined
461
+ }
462
+ return undefined
463
+ }
464
+
465
+ private async commitTree(
466
+ cwd: string,
467
+ treeSha: string,
468
+ baseSha: string,
469
+ message: string,
470
+ ): Promise<string> {
471
+ const dir = await mkdtemp(join(tmpdir(), 'ab-local-git-commit-'))
472
+ try {
473
+ const path = join(dir, 'message.md')
474
+ await writeFile(path, message, 'utf8')
475
+ const result = await this.command(
476
+ ['-c', 'commit.gpgSign=false', 'commit-tree', treeSha, '-p', baseSha, '-F', path],
477
+ cwd,
478
+ )
479
+ const sha = result.stdout.trim()
480
+ if (!objectId.safeParse(sha).success)
481
+ throw new Error('local-git commit-tree returned no commit')
482
+ return sha
483
+ } finally {
484
+ await rm(dir, { recursive: true, force: true })
485
+ }
486
+ }
487
+
488
+ async squashMerge(workspacePath: string, number: number, expectedHeadSha: string): Promise<void> {
489
+ let { record } = await this.readRecord(workspacePath, number)
490
+ if (record.status === 'merged') return
491
+ const headRef = `refs/heads/${record.head}`
492
+ const baseRef = `refs/heads/${record.base}`
493
+ const headSha = await this.sha(headRef, workspacePath)
494
+ if (headSha !== expectedHeadSha) {
495
+ throw new Error(
496
+ `local-git PR #${number} head changed (expected ${expectedHeadSha}, found ${headSha})`,
497
+ )
498
+ }
499
+ let baseSha = await this.sha(baseRef, workspacePath)
500
+
501
+ if (record.pendingLanding !== undefined) {
502
+ if (await this.isAncestor(workspacePath, record.pendingLanding.commitSha, baseSha)) return
503
+ if (record.pendingLanding.baseSha === baseSha && record.pendingLanding.headSha === headSha) {
504
+ // Resume the crash window after pending evidence but before ref update.
505
+ } else {
506
+ record = await this.updateRecord(workspacePath, number, (current) => ({
507
+ ...current,
508
+ pendingLanding: undefined,
509
+ }))
510
+ }
511
+ }
512
+
513
+ let pending = record.pendingLanding
514
+ if (pending === undefined) {
515
+ const treeSha = await this.mergeTree(workspacePath, baseSha, headSha)
516
+ if (treeSha === null) throw new Error(`local-git PR #${number} is not mergeable`)
517
+ const commitSha = await this.commitTree(workspacePath, treeSha, baseSha, record.mergeMessage)
518
+ pending = { baseSha, headSha, treeSha, commitSha }
519
+ record = await this.updateRecord(workspacePath, number, (current) => ({
520
+ ...current,
521
+ pendingLanding: pending,
522
+ }))
523
+ }
524
+
525
+ baseSha = await this.sha(baseRef, workspacePath)
526
+ if (baseSha !== pending.baseSha) {
527
+ if (await this.isAncestor(workspacePath, pending.commitSha, baseSha)) return
528
+ throw new Error(`local-git base ${baseRef} moved during guarded squash inspection`)
529
+ }
530
+
531
+ const checkout = await this.checkedOutBase(workspacePath, baseRef)
532
+ if (checkout !== undefined) {
533
+ // Close the inspection-to-landing race at the last safe point. A refusal
534
+ // is a condition, not a failed dispatcher tick; pending evidence remains
535
+ // reusable and the next poll reports the typed deferral.
536
+ const preflight = await this.transitionCheckout(
537
+ checkout,
538
+ pending.baseSha,
539
+ pending.commitSha,
540
+ true,
541
+ )
542
+ if (!preflight.ok) return
543
+ }
544
+ const moved = await this.command(
545
+ ['update-ref', baseRef, pending.commitSha, pending.baseSha],
546
+ workspacePath,
547
+ true,
548
+ )
549
+ if (moved.exitCode !== 0) {
550
+ throw new Error(`local-git base ${baseRef} moved before squash landing`)
551
+ }
552
+ if (checkout !== undefined) {
553
+ // A racing operator edit can still appear after preflight. Never roll the
554
+ // ref back or overwrite it: leave the pending landing for getPrState and
555
+ // setAutoMerge to repair/report on a later poll.
556
+ await this.transitionCheckout(checkout, pending.baseSha, pending.commitSha, false)
557
+ }
558
+ // Deliberately do not mark the record merged here. The next getPrState
559
+ // independently observes the landed commit and records that fact.
560
+ }
561
+
562
+ async commentOnPr(workspacePath: string, number: number, body: string): Promise<void> {
563
+ await this.updateRecord(workspacePath, number, (record) => ({
564
+ ...record,
565
+ comments: record.comments.includes(body) ? record.comments : [...record.comments, body],
566
+ }))
567
+ }
568
+ }