@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,789 @@
1
+ /**
2
+ * Durable operator controls for one build.
3
+ *
4
+ * The dashboard and the sessionless CLI both call this module: it owns the
5
+ * event-reduced active-build checks, human attribution, and event ordering so
6
+ * the two surfaces cannot drift. Controls only append requests/answers; the
7
+ * normal runner and dispatcher consume them.
8
+ */
9
+ import type { AbEvent } from '../events/catalog'
10
+ import { humanActor, KERNEL, type Via } from '../events/envelope'
11
+ import { reduceBuild, type BuildState, type OpenEscalation } from '../kernel/reducer'
12
+ import type { ArtifactRef, BuildOutcome, BuildStatus, TicketRef } from '../ontology'
13
+ import type { Exec } from '../ports/workspace/git-worktree'
14
+ import { specConformance } from '../spec-standard'
15
+ import type { BuildStore } from '../store/types'
16
+ import { withSessionlessStore, type StoreOpener } from './store-opening'
17
+
18
+ const ACTIVE_STATUSES: readonly BuildStatus[] = ['running', 'paused', 'blocked']
19
+
20
+ /** Retry carries no phase guidance, but the event schema still requires a
21
+ * nonempty audit answer. The reducer/materializer route on `resolution`, so
22
+ * this text is never presented to the next phase as guidance. */
23
+ export const BARE_RETRY_ANSWER = 'Operator requested a bare retry with no feedback'
24
+ export const DISMISSAL_ANSWER = 'Operator dismissed the cited findings with no feedback'
25
+ export const SPEC_REVISION_ANSWER = 'Operator replaced the spec; the build restarts from plan'
26
+
27
+ /** Both forms are lazy: completing an earlier authorization must not open the
28
+ * replacement source supplied to the retrying invocation. */
29
+ export type SpecBodySource =
30
+ | { kind: 'supplied'; origin: string; read: () => Promise<string> }
31
+ | { kind: 'ticket' }
32
+
33
+ export type AnswerResolutionRequest =
34
+ | { kind: 'dismiss-finding' }
35
+ | { kind: 'revise-spec'; body: SpecBodySource }
36
+
37
+ export type BuildControlAction =
38
+ | { kind: 'pause' }
39
+ | { kind: 'resume' }
40
+ | { kind: 'abort' }
41
+ | { kind: 'discard' }
42
+ | { kind: 'auto-merge-on' }
43
+ | { kind: 'auto-merge-off' }
44
+ | { kind: 'dashboard-pause' }
45
+ | { kind: 'dashboard-resume' }
46
+ | { kind: 'toggle-auto-merge' }
47
+ | {
48
+ kind: 'answer'
49
+ text?: string
50
+ /** Omitted preserves the historical nonblank-guidance/blank-retry rule. */
51
+ resolve?: AnswerResolutionRequest
52
+ /** Absolute cap for the review loop named by an open round-limit escalation. */
53
+ reviewRoundCeiling?: number
54
+ /** Dashboard prompts answer only blockers captured when the field
55
+ * opened. Omitted by the CLI, which answers every currently open one. */
56
+ escalationIds?: readonly string[]
57
+ }
58
+
59
+ export type BuildControlCommand =
60
+ | 'pause'
61
+ | 'resume'
62
+ | 'abort'
63
+ | 'discard'
64
+ | 'auto-merge-on'
65
+ | 'auto-merge-off'
66
+
67
+ export type BuildControlResult =
68
+ | {
69
+ kind: 'command'
70
+ slug: string
71
+ command: BuildControlCommand
72
+ event: AbEvent
73
+ }
74
+ | {
75
+ kind: 'answer-required'
76
+ slug: string
77
+ escalationIds: string[]
78
+ }
79
+ | {
80
+ kind: 'answered'
81
+ slug: string
82
+ count: number
83
+ resolution: 'guidance' | 'retry' | 'dismiss-finding' | 'revise-spec'
84
+ resumed: boolean
85
+ specRev?: number
86
+ authorizedEarlier?: boolean
87
+ remainingOpen?: number
88
+ reviewRoundCeiling?: { loop: 'plan' | 'code'; value: number }
89
+ /** A durable post-append fact says this build cannot restart. */
90
+ terminalSignal?: TerminalSignal
91
+ }
92
+
93
+ export type TerminalSignal =
94
+ | { kind: 'terminal-status'; status: 'done' | 'aborted'; outcome?: BuildOutcome }
95
+ | { kind: 'abort-requested' }
96
+ | { kind: 'pr-ended'; state: 'merged' | 'closed' }
97
+
98
+ export type BuildControlErrorCode =
99
+ | 'not-found'
100
+ | 'wrong-repository'
101
+ | 'inactive'
102
+ | 'no-open-escalations'
103
+ | 'own-session'
104
+ | 'spec-nonconforming'
105
+ | 'no-cited-findings'
106
+ | 'ticket-unavailable'
107
+ | 'missing-authorization'
108
+ | 'no-longer-active'
109
+ | 'abort-pending'
110
+ | 'review-round-ceiling-unavailable'
111
+ | 'incompatible-answer-options'
112
+
113
+ /** Typed codes let the dashboard turn stale selections/prompts into warnings
114
+ * while the CLI can surface the same underlying conflict as a command error. */
115
+ export class BuildControlError extends Error {
116
+ readonly code: BuildControlErrorCode
117
+
118
+ constructor(code: BuildControlErrorCode, message: string) {
119
+ super(message)
120
+ this.name = 'BuildControlError'
121
+ this.code = code
122
+ }
123
+ }
124
+
125
+ /** Preserve the dashboard's attribution rule for both operator surfaces. */
126
+ export function buildControlUser(env: Record<string, string | undefined>): string {
127
+ for (const name of ['USER', 'USERNAME']) {
128
+ const value = env[name]?.trim()
129
+ if (value !== undefined && value !== '') return value
130
+ }
131
+ return 'dashboard'
132
+ }
133
+
134
+ function activeState(slug: string, state: BuildState): void {
135
+ if (ACTIVE_STATUSES.includes(state.status)) return
136
+ throw new BuildControlError(
137
+ 'inactive',
138
+ `build "${slug}" is not active (status: ${state.status}); ` +
139
+ 'build controls require running, paused, or blocked',
140
+ )
141
+ }
142
+
143
+ async function appendCommand(
144
+ store: BuildStore,
145
+ slug: string,
146
+ user: string,
147
+ command: BuildControlCommand,
148
+ via?: Via,
149
+ ): Promise<BuildControlResult> {
150
+ const actor = humanActor(user, via)
151
+ let event: AbEvent
152
+ switch (command) {
153
+ case 'pause':
154
+ event = await store.append(slug, {
155
+ actor,
156
+ type: 'build.pause-requested',
157
+ payload: {},
158
+ })
159
+ break
160
+ case 'resume':
161
+ event = await store.append(slug, {
162
+ actor,
163
+ type: 'build.resume-requested',
164
+ payload: {},
165
+ })
166
+ break
167
+ case 'abort':
168
+ event = await store.append(slug, {
169
+ actor,
170
+ type: 'build.abort-requested',
171
+ payload: {},
172
+ })
173
+ break
174
+ case 'discard':
175
+ event = await store.append(slug, {
176
+ actor,
177
+ type: 'build.discard-requested',
178
+ payload: {},
179
+ })
180
+ break
181
+ case 'auto-merge-on':
182
+ event = await store.append(slug, {
183
+ actor,
184
+ type: 'build.auto-merge-requested',
185
+ payload: {},
186
+ })
187
+ break
188
+ case 'auto-merge-off':
189
+ event = await store.append(slug, {
190
+ actor,
191
+ type: 'build.auto-merge-cancelled',
192
+ payload: {},
193
+ })
194
+ break
195
+ }
196
+ return { kind: 'command', slug, command, event }
197
+ }
198
+
199
+ function selectedOpen(state: BuildState, captured?: Set<string>): OpenEscalation[] {
200
+ return captured === undefined
201
+ ? state.openEscalations
202
+ : state.openEscalations.filter((item) => captured.has(item.id))
203
+ }
204
+
205
+ function reviewRoundLimitEscalation(
206
+ slug: string,
207
+ open: OpenEscalation[],
208
+ ): OpenEscalation & { phase: 'plan-review' | 'code-review' } {
209
+ const eligible = open.filter(
210
+ (item): item is OpenEscalation & { phase: 'plan-review' | 'code-review' } =>
211
+ item.source === 'policy' &&
212
+ item.policyCause === 'review-round-limit' &&
213
+ (item.phase === 'plan-review' || item.phase === 'code-review'),
214
+ )
215
+ if (eligible.length !== 1) {
216
+ throw new BuildControlError(
217
+ 'review-round-ceiling-unavailable',
218
+ `cannot set a review round ceiling for build "${slug}": ` +
219
+ 'exactly one open review-round-limit escalation is required; nothing was recorded',
220
+ )
221
+ }
222
+ return eligible[0]!
223
+ }
224
+
225
+ function hasPendingAbort(state: BuildState): boolean {
226
+ return state.pendingCommands.some((command) => command.command === 'abort')
227
+ }
228
+
229
+ function abortPending(slug: string): BuildControlError {
230
+ return new BuildControlError(
231
+ 'abort-pending',
232
+ `build "${slug}" is aborting; nothing was recorded. Abort is not revocable — ` +
233
+ 'after cleanup returns the ticket to Triage, dispatch it as a fresh build',
234
+ )
235
+ }
236
+
237
+ export function terminalSignal(state: BuildState): TerminalSignal | undefined {
238
+ if (state.status === 'done' || state.status === 'aborted') {
239
+ return {
240
+ kind: 'terminal-status',
241
+ status: state.status,
242
+ ...(state.outcome !== undefined ? { outcome: state.outcome } : {}),
243
+ }
244
+ }
245
+ if (hasPendingAbort(state)) return { kind: 'abort-requested' }
246
+ if (state.prState === 'merged' || state.prState === 'closed') {
247
+ return { kind: 'pr-ended', state: state.prState }
248
+ }
249
+ return undefined
250
+ }
251
+
252
+ export type ReviseDecision =
253
+ | { kind: 'refuse'; code: BuildControlErrorCode; message: string }
254
+ | {
255
+ kind: 'complete'
256
+ artifact: ArtifactRef
257
+ open: OpenEscalation[]
258
+ escalationSeq: number
259
+ }
260
+ | { kind: 'fresh'; open: OpenEscalation[]; escalationSeq: number }
261
+
262
+ /** Pure restart decision, run once before body preparation and once against a
263
+ * fresh reduction immediately before publication. */
264
+ export function decideRevise(
265
+ slug: string,
266
+ state: BuildState,
267
+ captured?: Set<string>,
268
+ ): ReviseDecision {
269
+ const open = selectedOpen(state, captured)
270
+ const pending = state.answeredEscalations.filter(
271
+ (item) => item.resolution === 'revise-spec' && item.answeredSeq > state.restartSince,
272
+ )
273
+ const authorized = pending[0]
274
+
275
+ if (hasPendingAbort(state)) {
276
+ return { kind: 'refuse', code: 'abort-pending', message: abortPending(slug).message }
277
+ }
278
+ if (authorized !== undefined) {
279
+ if (authorized.artifact === undefined) {
280
+ return {
281
+ kind: 'refuse',
282
+ code: 'missing-authorization',
283
+ message:
284
+ `build "${slug}" has a pending spec revision from escalation ` +
285
+ `"${authorized.id}", but its recorded answer names no replacement body. ` +
286
+ 'Autobuild will not infer authorization from the newest deposited spec: the spec is ' +
287
+ 'the contract for every downstream approval. The build remains parked; use ' +
288
+ `ab abort ${slug} and dispatch the ticket fresh to exit this state.`,
289
+ }
290
+ }
291
+ const escalationSeq = Math.max(...[...open, ...pending].map((item) => item.seq))
292
+ return { kind: 'complete', artifact: authorized.artifact, open, escalationSeq }
293
+ }
294
+ if (open.length === 0) {
295
+ const paused =
296
+ state.status === 'paused'
297
+ ? ` If a revision already landed, the build remains paused; run ab resume ${slug}.`
298
+ : ''
299
+ return {
300
+ kind: 'refuse',
301
+ code: 'no-open-escalations',
302
+ message:
303
+ `build "${slug}" has no open escalation for a spec revision (status: ${state.status}). ` +
304
+ 'The spec is the build contract and changes only through an escalation raised by a phase; ' +
305
+ `the phase must use ab escalate first.${paused}`,
306
+ }
307
+ }
308
+ return {
309
+ kind: 'fresh',
310
+ open,
311
+ escalationSeq: Math.max(...open.map((item) => item.seq)),
312
+ }
313
+ }
314
+
315
+ function throwRefusal(decision: Extract<ReviseDecision, { kind: 'refuse' }>): never {
316
+ throw new BuildControlError(decision.code, decision.message)
317
+ }
318
+
319
+ function assertStillActive(slug: string, state: BuildState): void {
320
+ if (ACTIVE_STATUSES.includes(state.status)) return
321
+ throw new BuildControlError(
322
+ 'no-longer-active',
323
+ `build "${slug}" became ${state.status}${
324
+ state.outcome !== undefined ? ` (${state.outcome})` : ''
325
+ } while the answer was being prepared; nothing was appended. ` +
326
+ 'Any deposited replacement revision is inert because no spec event cites it.',
327
+ )
328
+ }
329
+
330
+ function knownFindingIds(state: BuildState): Set<string> {
331
+ return new Set(
332
+ [...state.reviewFindings.planReview.flat(), ...state.reviewFindings.codeReview.flat()].map(
333
+ (finding) => finding.id,
334
+ ),
335
+ )
336
+ }
337
+
338
+ function dismissPartition(
339
+ state: BuildState,
340
+ captured?: Set<string>,
341
+ ): {
342
+ eligible: OpenEscalation[]
343
+ ineligible: OpenEscalation[]
344
+ } {
345
+ const known = knownFindingIds(state)
346
+ const open = selectedOpen(state, captured)
347
+ return {
348
+ eligible: open.filter((item) => (item.refs ?? []).some((ref) => known.has(ref))),
349
+ ineligible: open.filter((item) => !(item.refs ?? []).some((ref) => known.has(ref))),
350
+ }
351
+ }
352
+
353
+ function noCitedFindings(slug: string, open: OpenEscalation[]): BuildControlError {
354
+ const refs = [...new Set(open.flatMap((item) => item.refs ?? []))]
355
+ const detail =
356
+ refs.length === 0
357
+ ? 'the selected escalation(s) cite no references'
358
+ : `their references are not review finding ids: ${refs.join(', ')}`
359
+ return new BuildControlError(
360
+ 'no-cited-findings',
361
+ `cannot dismiss findings for build "${slug}": ${detail}. ` +
362
+ `Use ab answer ${slug} <text> for guidance, or ab answer ${slug} for a bare retry.`,
363
+ )
364
+ }
365
+
366
+ export interface ControlBuildOpts {
367
+ store: BuildStore
368
+ /** Canonical main-repository identity stored on BuildRecord.repo. */
369
+ repo: string
370
+ slug: string
371
+ /** Explicit identity for non-CLI callers. CLI adapters may supply env. */
372
+ user?: string
373
+ env?: Record<string, string | undefined>
374
+ /** Delegated-write attribution marker threaded onto every event actor. */
375
+ via?: Via
376
+ action: BuildControlAction
377
+ /** Injected only for --revise-spec-from-ticket. */
378
+ readTicketBody?: (ref: TicketRef) => Promise<string>
379
+ }
380
+
381
+ /** Apply one control against a freshly reduced build log. */
382
+ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControlResult> {
383
+ const record = await opts.store.getBuild(opts.slug)
384
+ if (record === null) {
385
+ throw new BuildControlError(
386
+ 'not-found',
387
+ `no build "${opts.slug}" in this store; run 'ab builds --all' to list builds`,
388
+ )
389
+ }
390
+ if (record.repo !== opts.repo) {
391
+ throw new BuildControlError(
392
+ 'wrong-repository',
393
+ `build "${opts.slug}" belongs to repository "${record.repo}", ` + `not "${opts.repo}"`,
394
+ )
395
+ }
396
+
397
+ const events = await opts.store.getEvents(opts.slug)
398
+ const state = reduceBuild(events)
399
+ const user = opts.user?.trim() || buildControlUser(opts.env ?? {})
400
+
401
+ if (opts.action.kind === 'discard') {
402
+ if (state.status !== 'queued') {
403
+ throw new BuildControlError(
404
+ 'inactive',
405
+ `build "${opts.slug}" cannot be discarded (status: ${state.status}); discard requires queued`,
406
+ )
407
+ }
408
+ const existing = events.findLast((event) => event.type === 'build.discard-requested')
409
+ if (existing !== undefined) {
410
+ return { kind: 'command', slug: opts.slug, command: 'discard', event: existing }
411
+ }
412
+ return appendCommand(opts.store, opts.slug, user, 'discard', opts.via)
413
+ }
414
+
415
+ if (opts.action.kind === 'abort') {
416
+ if (state.status !== 'queued') activeState(opts.slug, state)
417
+ const existing = events.findLast((event) => event.type === 'build.abort-requested')
418
+ if (existing !== undefined) {
419
+ return { kind: 'command', slug: opts.slug, command: 'abort', event: existing }
420
+ }
421
+ return appendCommand(opts.store, opts.slug, user, 'abort', opts.via)
422
+ }
423
+
424
+ activeState(opts.slug, state)
425
+ switch (opts.action.kind) {
426
+ case 'pause':
427
+ case 'resume':
428
+ case 'auto-merge-on':
429
+ case 'auto-merge-off':
430
+ return appendCommand(opts.store, opts.slug, user, opts.action.kind, opts.via)
431
+
432
+ case 'dashboard-pause': {
433
+ const pendingPause = state.pendingCommands.some((command) => command.command === 'pause')
434
+ if (state.status !== 'running') {
435
+ throw new BuildControlError(
436
+ 'inactive',
437
+ `build "${opts.slug}" cannot use dashboard pause (status: ${state.status}); ` +
438
+ 'dashboard pause requires running or a pending pause',
439
+ )
440
+ }
441
+ // The reducer already makes an opposing command supersede an
442
+ // unacknowledged request. Reuse that durable rule to cancel PAUSING.
443
+ return appendCommand(opts.store, opts.slug, user, pendingPause ? 'resume' : 'pause', opts.via)
444
+ }
445
+
446
+ case 'dashboard-resume': {
447
+ // A blocker takes precedence over pause state in the operator flow. The
448
+ // prompt itself is process-local and writes nothing until submitted.
449
+ if (state.openEscalations.length > 0) {
450
+ return {
451
+ kind: 'answer-required',
452
+ slug: opts.slug,
453
+ escalationIds: state.openEscalations.map((item) => item.id),
454
+ }
455
+ }
456
+ const pendingResume = state.pendingCommands.some((command) => command.command === 'resume')
457
+ if (state.status !== 'paused' || pendingResume) {
458
+ throw new BuildControlError(
459
+ 'inactive',
460
+ `build "${opts.slug}" cannot use dashboard resume (status: ${state.status}); ` +
461
+ 'dashboard resume requires paused with no pending resume',
462
+ )
463
+ }
464
+ return appendCommand(opts.store, opts.slug, user, 'resume', opts.via)
465
+ }
466
+
467
+ case 'toggle-auto-merge':
468
+ return appendCommand(
469
+ opts.store,
470
+ opts.slug,
471
+ user,
472
+ state.autoMerge.requested ? 'auto-merge-off' : 'auto-merge-on',
473
+ opts.via,
474
+ )
475
+
476
+ case 'answer': {
477
+ const captured =
478
+ opts.action.escalationIds === undefined ? undefined : new Set(opts.action.escalationIds)
479
+ const guidance = (opts.action.text ?? '').trim()
480
+ const actor = humanActor(user, opts.via)
481
+ const requestedCeiling = opts.action.reviewRoundCeiling
482
+ if (
483
+ requestedCeiling !== undefined &&
484
+ (!Number.isInteger(requestedCeiling) || requestedCeiling <= 0)
485
+ ) {
486
+ throw new BuildControlError(
487
+ 'review-round-ceiling-unavailable',
488
+ 'review round ceiling must be a positive integer; nothing was recorded',
489
+ )
490
+ }
491
+ if (requestedCeiling !== undefined && opts.action.resolve?.kind === 'revise-spec') {
492
+ throw new BuildControlError(
493
+ 'incompatible-answer-options',
494
+ 'cannot combine a review round ceiling with a spec revision: revision resets the loop round budget on its own; nothing was recorded',
495
+ )
496
+ }
497
+ if (requestedCeiling !== undefined && opts.action.resolve?.kind === 'dismiss-finding') {
498
+ throw new BuildControlError(
499
+ 'incompatible-answer-options',
500
+ 'cannot combine a review round ceiling with --dismiss; use guidance or a bare retry to answer the review-round-limit escalation; nothing was recorded',
501
+ )
502
+ }
503
+ const ceilingEscalation =
504
+ requestedCeiling === undefined
505
+ ? undefined
506
+ : reviewRoundLimitEscalation(opts.slug, selectedOpen(state, captured))
507
+
508
+ if (opts.action.resolve?.kind === 'revise-spec') {
509
+ const initial = decideRevise(opts.slug, state, captured)
510
+ if (initial.kind === 'refuse') throwRefusal(initial)
511
+
512
+ let artifact: ArtifactRef
513
+ let authorizedEarlier = initial.kind === 'complete'
514
+ if (initial.kind === 'complete') {
515
+ // Completion rule: recorded authorization wins before the caller's
516
+ // lazy body source is opened, regardless of remaining blockers.
517
+ artifact = initial.artifact
518
+ } else {
519
+ let body: string
520
+ const source = opts.action.resolve.body
521
+ if (source.kind === 'supplied') {
522
+ body = await source.read()
523
+ } else {
524
+ if (record.ticket === undefined) {
525
+ throw new BuildControlError(
526
+ 'ticket-unavailable',
527
+ `build "${opts.slug}" has no recorded ticket to re-import`,
528
+ )
529
+ }
530
+ if (opts.readTicketBody === undefined) {
531
+ throw new BuildControlError(
532
+ 'ticket-unavailable',
533
+ 'ticket body reader is unavailable for --revise-spec-from-ticket',
534
+ )
535
+ }
536
+ body = await opts.readTicketBody(record.ticket)
537
+ }
538
+ const conformance = specConformance(body)
539
+ if (!conformance.conforms) {
540
+ throw new BuildControlError(
541
+ 'spec-nonconforming',
542
+ `replacement spec from ${source.kind === 'supplied' ? source.origin : 'the build ticket'} ` +
543
+ `does not conform: missing ${conformance.missing.join('; ')}; ` +
544
+ 'nothing was recorded and the build remains blocked',
545
+ )
546
+ }
547
+ // A deposit without a later human authorization is deliberately inert:
548
+ // every consumer reads the revision anchored by a spec.* event.
549
+ const deposited = await opts.store.putArtifact(opts.slug, {
550
+ kind: 'spec',
551
+ content: body,
552
+ ...(record.ticket !== undefined
553
+ ? { metadata: { ticket: record.ticket.id, source: record.ticket.source } }
554
+ : {}),
555
+ })
556
+ artifact = { kind: 'spec', rev: deposited.revision }
557
+ }
558
+
559
+ // Re-read at the last durable moment. The PR janitor and abort
560
+ // acknowledgement may still write while a blocked build is parked.
561
+ const freshState = reduceBuild(await opts.store.getEvents(opts.slug))
562
+ assertStillActive(opts.slug, freshState)
563
+ const decision = decideRevise(opts.slug, freshState, captured)
564
+ if (decision.kind === 'refuse') throwRefusal(decision)
565
+ if (decision.kind === 'complete') {
566
+ artifact = decision.artifact
567
+ authorizedEarlier = true
568
+ }
569
+ const answer = guidance === '' ? SPEC_REVISION_ANSWER : guidance
570
+ const alsoPaused = freshState.status === 'paused'
571
+
572
+ // Answers must precede spec.revised: the engine parks an answered
573
+ // revise-spec escalation at awaiting-spec until this kernel fact lands.
574
+ for (const escalation of decision.open) {
575
+ await opts.store.append(opts.slug, {
576
+ actor,
577
+ type: 'escalation.answered',
578
+ payload: {
579
+ id: escalation.id,
580
+ answer,
581
+ resolution: 'revise-spec',
582
+ artifact,
583
+ },
584
+ })
585
+ }
586
+ await opts.store.append(opts.slug, {
587
+ actor: KERNEL,
588
+ type: 'spec.revised',
589
+ payload: { artifact, escalation: decision.escalationSeq },
590
+ })
591
+ if (alsoPaused) {
592
+ await opts.store.append(opts.slug, {
593
+ actor,
594
+ type: 'build.resume-requested',
595
+ payload: {},
596
+ })
597
+ }
598
+ const after = reduceBuild(await opts.store.getEvents(opts.slug))
599
+ const signal = terminalSignal(after)
600
+ return {
601
+ kind: 'answered',
602
+ slug: opts.slug,
603
+ count: decision.open.length,
604
+ resolution: 'revise-spec',
605
+ resumed: alsoPaused,
606
+ ...(after.specRev !== undefined ? { specRev: after.specRev } : {}),
607
+ ...(authorizedEarlier ? { authorizedEarlier: true } : {}),
608
+ ...(signal !== undefined ? { terminalSignal: signal } : {}),
609
+ }
610
+ }
611
+
612
+ if (opts.action.resolve?.kind === 'dismiss-finding') {
613
+ if (hasPendingAbort(state)) throw abortPending(opts.slug)
614
+ const initialOpen = selectedOpen(state, captured)
615
+ if (initialOpen.length === 0) {
616
+ throw new BuildControlError(
617
+ 'no-open-escalations',
618
+ captured === undefined
619
+ ? `build "${opts.slug}" has no open escalations to answer`
620
+ : `build "${opts.slug}" is no longer blocked by the captured escalation(s)`,
621
+ )
622
+ }
623
+ const initial = dismissPartition(state, captured)
624
+ if (initial.eligible.length === 0) throw noCitedFindings(opts.slug, initialOpen)
625
+
626
+ const freshState = reduceBuild(await opts.store.getEvents(opts.slug))
627
+ assertStillActive(opts.slug, freshState)
628
+ if (hasPendingAbort(freshState)) throw abortPending(opts.slug)
629
+ const freshOpen = selectedOpen(freshState, captured)
630
+ if (freshOpen.length === 0) {
631
+ throw new BuildControlError(
632
+ 'no-open-escalations',
633
+ `build "${opts.slug}" has no open escalations to answer`,
634
+ )
635
+ }
636
+ const partition = dismissPartition(freshState, captured)
637
+ if (partition.eligible.length === 0) throw noCitedFindings(opts.slug, freshOpen)
638
+ const answer = guidance === '' ? DISMISSAL_ANSWER : guidance
639
+ for (const escalation of partition.eligible) {
640
+ await opts.store.append(opts.slug, {
641
+ actor,
642
+ type: 'escalation.answered',
643
+ payload: { id: escalation.id, answer, resolution: 'dismiss-finding' },
644
+ })
645
+ }
646
+ const resumed = freshState.status === 'paused' && partition.ineligible.length === 0
647
+ if (resumed) {
648
+ await opts.store.append(opts.slug, {
649
+ actor,
650
+ type: 'build.resume-requested',
651
+ payload: {},
652
+ })
653
+ }
654
+ const after = reduceBuild(await opts.store.getEvents(opts.slug))
655
+ const signal = terminalSignal(after)
656
+ return {
657
+ kind: 'answered',
658
+ slug: opts.slug,
659
+ count: partition.eligible.length,
660
+ resolution: 'dismiss-finding',
661
+ resumed,
662
+ remainingOpen: partition.ineligible.length,
663
+ ...(signal !== undefined ? { terminalSignal: signal } : {}),
664
+ }
665
+ }
666
+
667
+ const open = selectedOpen(state, captured)
668
+ if (open.length === 0) {
669
+ throw new BuildControlError(
670
+ 'no-open-escalations',
671
+ captured === undefined
672
+ ? `build "${opts.slug}" has no open escalations to answer`
673
+ : `build "${opts.slug}" is no longer blocked by the captured escalation(s)`,
674
+ )
675
+ }
676
+ const resolution = guidance === '' ? 'retry' : 'guidance'
677
+ const answer = guidance === '' ? BARE_RETRY_ANSWER : guidance
678
+ const alsoPaused = state.status === 'paused'
679
+ // Appends are intentionally one-at-a-time. If an append fails midway, a
680
+ // retry re-reduces the log and filters already answered ids out.
681
+ for (const escalation of open) {
682
+ await opts.store.append(opts.slug, {
683
+ actor,
684
+ type: 'escalation.answered',
685
+ payload: {
686
+ id: escalation.id,
687
+ answer,
688
+ resolution,
689
+ ...(ceilingEscalation?.id === escalation.id
690
+ ? { reviewRoundCeiling: requestedCeiling }
691
+ : {}),
692
+ },
693
+ })
694
+ }
695
+ if (alsoPaused) {
696
+ await opts.store.append(opts.slug, {
697
+ actor,
698
+ type: 'build.resume-requested',
699
+ payload: {},
700
+ })
701
+ }
702
+ return {
703
+ kind: 'answered',
704
+ slug: opts.slug,
705
+ count: open.length,
706
+ resolution,
707
+ resumed: alsoPaused,
708
+ ...(ceilingEscalation !== undefined && requestedCeiling !== undefined
709
+ ? {
710
+ reviewRoundCeiling: {
711
+ loop:
712
+ ceilingEscalation.phase === 'plan-review' ? ('plan' as const) : ('code' as const),
713
+ value: requestedCeiling,
714
+ },
715
+ }
716
+ : {}),
717
+ }
718
+ }
719
+ }
720
+ }
721
+
722
+ function actionLabel(action: BuildControlAction): string {
723
+ switch (action.kind) {
724
+ case 'auto-merge-on':
725
+ return 'enable auto-merge for'
726
+ case 'auto-merge-off':
727
+ return 'disable auto-merge for'
728
+ case 'toggle-auto-merge':
729
+ return 'toggle auto-merge for'
730
+ case 'dashboard-pause':
731
+ return 'pause or cancel pause for'
732
+ case 'dashboard-resume':
733
+ return 'resume'
734
+ case 'discard':
735
+ return 'discard'
736
+ case 'answer':
737
+ return 'answer'
738
+ default:
739
+ return action.kind
740
+ }
741
+ }
742
+
743
+ /** A phase agent may operate on another build, but never control its own. */
744
+ export function refuseOwnSessionControl(
745
+ slug: string,
746
+ action: BuildControlAction,
747
+ env: Record<string, string | undefined>,
748
+ ): void {
749
+ const session = env.AB_SESSION?.trim()
750
+ const build = env.AB_BUILD?.trim()
751
+ if (session === undefined || session === '' || build !== slug) return
752
+ throw new BuildControlError(
753
+ 'own-session',
754
+ `cannot ${actionLabel(action)} build "${slug}" from its own phase session ` +
755
+ '(AB_SESSION/AB_BUILD conflict); run this command outside that build session',
756
+ )
757
+ }
758
+
759
+ export interface AbBuildControlOpts {
760
+ targetRepo: string
761
+ env: Record<string, string | undefined>
762
+ exec: Exec
763
+ slug: string
764
+ action: BuildControlAction
765
+ /** Explicit `--store`; selection remains --store > AB_STORE > repo-local. */
766
+ storeRef?: string
767
+ /** Injected only for --revise-spec-from-ticket. */
768
+ readTicketBody?: (ref: TicketRef) => Promise<string>
769
+ /** Injectable adapter seam for unit tests. */
770
+ openStore?: StoreOpener
771
+ }
772
+
773
+ /** Sessionless command shell: resolve repository/store, control, always close. */
774
+ export async function abBuildControl(opts: AbBuildControlOpts): Promise<BuildControlResult> {
775
+ // Do this before opening a store: the conflict is ambient and no read is
776
+ // needed to know that an own-phase control attempt is forbidden.
777
+ refuseOwnSessionControl(opts.slug, opts.action, opts.env)
778
+
779
+ return withSessionlessStore(opts, ({ store, repo }) =>
780
+ controlBuild({
781
+ store,
782
+ repo,
783
+ slug: opts.slug,
784
+ env: opts.env,
785
+ action: opts.action,
786
+ ...(opts.readTicketBody !== undefined ? { readTicketBody: opts.readTicketBody } : {}),
787
+ }),
788
+ )
789
+ }