@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,752 @@
1
+ /**
2
+ * The state reducer (SPEC §15.5, §3.4): state is a reduction of events — any
3
+ * snapshot is a cache, never the source of truth, and resumability falls out.
4
+ * `reduceBuild` is pure, total (any prefix of a valid log reduces), and O(n).
5
+ *
6
+ * This projection is the single read model: the engine picks the next phase
7
+ * from it, the operator UI's build list is exactly this reduction over every
8
+ * build (§15.5), and the dispatcher's janitor reads `pr`/`prState` (§15.7).
9
+ */
10
+ import type { Actor } from '../events/envelope'
11
+ import type { AbEvent } from '../events/catalog'
12
+ import { normalizeVerifyCompletion, type EventPayload } from '../events/payloads'
13
+ import type {
14
+ ArtifactRef,
15
+ BuildOutcome,
16
+ BuildStatus,
17
+ CommitRange,
18
+ EscalationResolution,
19
+ EscalationSource,
20
+ EscalationTarget,
21
+ Finding,
22
+ Phase,
23
+ PolicyEscalationCause,
24
+ VerifyOutcome,
25
+ } from '../ontology'
26
+ import { verifyPhase } from '../ontology'
27
+
28
+ /** An `escalation.raised` without a matching `escalation.answered` — the
29
+ * definition of `blocked` (§15.5). Matched by id, so answers may arrive in
30
+ * any order. */
31
+ export interface OpenEscalation {
32
+ id: string
33
+ phase: EscalationTarget
34
+ round?: number
35
+ source: EscalationSource
36
+ /** Policy guard identity. Absent only on historical policy raises. */
37
+ policyCause?: PolicyEscalationCause
38
+ question: string
39
+ refs?: string[]
40
+ /** seq of the `escalation.raised` event — what `spec.revised` cites (§15.3). */
41
+ seq: number
42
+ }
43
+
44
+ /** Answered escalations keep their resolution because the engine routes on it
45
+ * (§15.6-B, §6.3): `guidance` feeds the next producer round as authoritative
46
+ * feedback, `dismiss-finding` marks the chain human-resolved for the next
47
+ * reviewer, `revise-spec` restarts the build from plan, `abort` ends it, and
48
+ * bare `retry` re-arms the applicable policy-exhausted budget without
49
+ * inventing guidance. */
50
+ export interface AnsweredEscalation extends OpenEscalation {
51
+ answer: string
52
+ resolution: EscalationResolution
53
+ /** Exact replacement spec revision authorized by a `revise-spec` answer. */
54
+ artifact?: ArtifactRef
55
+ /** Absolute review budget accepted with a review-round-limit answer. */
56
+ reviewRoundCeiling?: number
57
+ /** seq of the `escalation.answered` event. */
58
+ answeredSeq: number
59
+ }
60
+
61
+ /** One phase occurrence. Loop phases carry `round` (§15.3); verify and
62
+ * reconcile carry `attempt`; finalize carries neither. */
63
+ export interface PhaseContext {
64
+ phase: Phase
65
+ round?: number
66
+ attempt?: number
67
+ /** seq of the `*.started` event (currentPhase) or the terminal event
68
+ * (lastCompletedPhase) — the resume anchor (§15.6-C). */
69
+ seq: number
70
+ }
71
+
72
+ /** An accepted operator command the kernel has not yet acknowledged with its
73
+ * fact event (D2, §15.2.7). Most come from `*-requested`; an escalation answer
74
+ * with the abort resolution is the equivalent accepted abort intent. A dead
75
+ * runner receives these on resume. Commands are ordered events (§15.2.7): a
76
+ * request supersedes any earlier pending request of the OPPOSING kind (pause
77
+ * vs resume) — the operator's latest command wins, and a countermanded request
78
+ * is never delivered. */
79
+ export interface PendingCommand {
80
+ command: 'pause' | 'resume' | 'abort'
81
+ /** seq of the event that accepted the command. */
82
+ seq: number
83
+ reason?: string
84
+ actor: Actor
85
+ }
86
+
87
+ /** A `session.started` without its `session.ended`. Historical orphan-only
88
+ * logs remain open and readable; a recovering runner explicitly ends every
89
+ * session open at its resume boundary before announcing attachment (§15.6-C). */
90
+ export interface OpenSession {
91
+ session: string
92
+ role: string
93
+ runner: string
94
+ model?: string
95
+ phase: Phase
96
+ round?: number
97
+ /** Live-view stream id (SPEC §9); absent on historical events. */
98
+ stream?: string
99
+ /** seq of the `session.started` event. */
100
+ seq: number
101
+ }
102
+
103
+ /** One `verify.completed` fact. Results accumulate across attempts; membership
104
+ * in the current cycle is solely
105
+ * `results.filter(r => r.seq > verify.cycleSince)`. `maxAttemptSeen` is only
106
+ * the aggregate attempt high-water and cannot identify the current cycle in
107
+ * the window between a verify failure and the next `verify.started`. */
108
+ export interface VerifyResult {
109
+ step: string
110
+ attempt: number
111
+ outcome: VerifyOutcome
112
+ report?: ArtifactRef
113
+ /** Present exactly when `outcome === "skipped"`. */
114
+ reason?: string
115
+ /** seq of the `verify.completed` event — what `cycleSince` is compared to. */
116
+ seq: number
117
+ }
118
+
119
+ /** PR lifecycle (§15.7): 'open' once `finalize.completed` records the PR;
120
+ * then per `pr.*` janitor events. 'conflicted' holds until a
121
+ * `reconcile.completed` appears after the `pr.conflicted`, which returns the
122
+ * PR to 'open' while verify re-runs in full. */
123
+ export type PrLifecycle = 'open' | 'merged' | 'closed' | 'conflicted'
124
+
125
+ /** Durable desired state for GitHub native auto-merge plus the latest forge
126
+ * application fact. The command seq correlates the two sides of the
127
+ * non-transactional forge/event boundary: consumers treat the command as
128
+ * settled only when both `enabled` and `commandSeq` match. */
129
+ export interface AutoMergeProjection {
130
+ /** Latest human command. False with no commandSeq means the default: off. */
131
+ requested: boolean
132
+ commandSeq?: number
133
+ /** Latest recorded external application. It may acknowledge an older
134
+ * command; such a stale fact never changes `requested`/`commandSeq`. */
135
+ applied?: { enabled: boolean; commandSeq: number }
136
+ }
137
+
138
+ export type ObservationRecord = EventPayload<'observation.recorded'>
139
+
140
+ export interface BuildState {
141
+ /**
142
+ * §15.5: 'queued' until `runner.attached`, 'running' after;
143
+ * 'blocked' ≡ open escalation; 'paused' ≡ `build.paused` without a later
144
+ * `build.resumed`; 'done' after `build.completed`; 'aborted' after
145
+ * `build.aborted`. Precedence: aborted/done (terminal, latest wins) >
146
+ * paused > blocked > running > queued.
147
+ *
148
+ * Paused + blocked overlap: both can hold at once (an escalation is open
149
+ * when the operator pauses). Paused wins — it is the operator's explicit
150
+ * instruction — but the escalation stays in `openEscalations`, so resuming
151
+ * a still-blocked build reports 'blocked' again. No information is lost.
152
+ */
153
+ status: BuildStatus
154
+ /** From `build.completed` — present only once status is 'done'. */
155
+ outcome?: BuildOutcome
156
+ /** currentPhase if one is started-but-not-completed, else the latest
157
+ * completed phase. A started-without-terminal phase is exactly what
158
+ * §15.6-C re-runs from its start. */
159
+ phase?: Phase
160
+ /** The current loop round: the round carried by the most recent
161
+ * plan/plan-review/implement/code-review event. Verify, finalize, and
162
+ * reconcile events do not reset it (verify attempt 2 still belongs to the
163
+ * loop round that produced the code — §15.6-A). 0 before `plan.started`. */
164
+ round: number
165
+ /** Started, not completed — set by `*.started`, cleared by the phase's
166
+ * terminal event (§8.4). `escalation.raised` and `phase.failed` do NOT
167
+ * clear it: the phase still needs re-running (§15.6-B/C). */
168
+ currentPhase?: PhaseContext
169
+ lastCompletedPhase?: PhaseContext
170
+ /** In raise order. Non-empty ≡ blocked (§15.5). */
171
+ openEscalations: OpenEscalation[]
172
+ /** In answer order, with resolutions for engine routing (§15.6-B). */
173
+ answeredEscalations: AnsweredEscalation[]
174
+ /** Human-selected absolute caps for the current spec's independent loops. */
175
+ reviewRoundCeilings: { plan?: number; code?: number }
176
+ /** From `finalize.completed` (§15.3) — the kernel opened the PR (D7). */
177
+ pr?: { number: number; url: string; headSha: string }
178
+ prState?: PrLifecycle
179
+ /** Human auto-merge intent and its correlated forge application fact. */
180
+ autoMerge: AutoMergeProjection
181
+ /** seq of the latest `finalize.completed`, else 0 — "has finalize run for
182
+ * the CURRENT spec". `prState` cannot answer that: it is the full-log PR
183
+ * fact and must stay so, because the janitor (dispatcher.ts:342,:367,:396)
184
+ * and the deliberately restart-orthogonal epilogue (engine.ts:161-162,:402)
185
+ * read it. Compare this against `restartSince`, exactly as engine.ts:707-708
186
+ * post-filters its own `finalizeCompleted`. */
187
+ finalizeCompletedSeq: number
188
+ /** Full-log `finalize.step-completed` facts, in event order (§5).
189
+ * Post-steps are failure-tolerant, so a completion counts whether `ok` is
190
+ * true or false. Current-spec membership is determined by
191
+ * `finalizeSteps.filter(f => f.seq > restartBoundary)`, matching
192
+ * engine.ts's post-restart `finalizeStepsDone` scope (engine.ts:710-712). */
193
+ finalizeSteps: { step: string; ok: boolean; seq: number }[]
194
+ lastEvent?: AbEvent
195
+ /** 0 for an empty log; `runner.attached {resumedFromSeq}` cites this. */
196
+ lastSeq: number
197
+ /** Latest spec artifact rev — `spec.imported`/`spec.authored` set it,
198
+ * `spec.revised` bumps it (§6.3: rev N+1 restarts the build from plan). */
199
+ specRev?: number
200
+ /** seq of the latest `spec.revised`, else 0 — the restart boundary (§6.3).
201
+ * Rev N+1 restarts the build from plan, so every approval and result at or
202
+ * before this seq describes a spec that no longer exists. Mirrors
203
+ * engine.ts's `restartSeq` (engine.ts:126). */
204
+ restartSince: number
205
+ /** `approved` ≡ the latest `plan-review.verdict` is 'approve' (§10);
206
+ * `artifactRev` is the latest deposited plan rev.
207
+ *
208
+ * `approval` is the `plan-review.verdict` approve that currently STANDS —
209
+ * its event seq and round. Set only on an approve; cleared by any other
210
+ * verdict, so `approval !== undefined` ⇔ `approved`. A consumer asking "is
211
+ * the plan loop settled?" must check this against `restartSince` and
212
+ * `plan.round`, not `approved` alone — `approved` spans the full log and
213
+ * survives a restart, which re-runs the whole loop (engine.ts:466). */
214
+ plan: {
215
+ round: number
216
+ approved: boolean
217
+ artifactRev?: number
218
+ approval?: { seq: number; round: number }
219
+ }
220
+ /** `round` tracks the latest `implement.started`/`.completed`; `commits`
221
+ * and `artifactRev` come from the latest `implement.completed` only — so
222
+ * mid-round they still point at the last pushed head, the cross-sandbox
223
+ * resume anchor (D3, §15.6-C). */
224
+ implement: { round: number; commits?: CommitRange; artifactRev?: number }
225
+ /** ≡ the latest `code-review.verdict` is 'approve'. */
226
+ codeReviewApproved: boolean
227
+ /** The `code-review.verdict` approve that currently stands — its event seq
228
+ * and round. Same discipline as `plan.approval`: check it against
229
+ * `restartSince` and `implement.round`, AND against the verify cycle, which
230
+ * a failure reopens (§15.6-A) before any new implement round lands. */
231
+ codeReviewApproval?: { seq: number; round: number }
232
+ /** Findings per round, in round order (index round-1); rounds that never
233
+ * produced a verdict are empty. Stall detection walks `persists` chains
234
+ * across these (§15.4); reviewers get all prior rounds as context (§8.3). */
235
+ reviewFindings: { planReview: Finding[][]; codeReview: Finding[][] }
236
+ /** `maxAttemptSeen` is the highest verify attempt seen across the full log;
237
+ * it is a high-water mark, not a current-cycle identifier. `results`
238
+ * accumulate across attempts (filter by `cycleSince` for the current cycle
239
+ * — see VerifyResult); `currentStep` is a `verify.started` without its
240
+ * `verify.completed`.
241
+ *
242
+ * `cycleSince` is the seq after which results describe the CURRENT code
243
+ * (§15.6-A): max(restartSince, latest code-review approve, latest
244
+ * `reconcile.completed`). Results at or before it describe code that no
245
+ * longer exists — implement or reconcile changed it, and a new cycle re-runs
246
+ * from the FIRST step. 0 before any boundary lands. This is the same filter
247
+ * engine.ts:275-276 applies over its post-restart index. */
248
+ verify: {
249
+ maxAttemptSeen: number
250
+ results: VerifyResult[]
251
+ currentStep?: string
252
+ cycleSince: number
253
+ }
254
+ /** Highest `reconcile.started.attempt` — the kernel's own occurrence
255
+ * counter, so a re-run of the same attempt after sandbox death does not
256
+ * double-count. Progress-budget accounting is projected separately from
257
+ * authoritative repeat-conflict evidence (§15.7). */
258
+ reconcileAttempts: number
259
+ /** `observation.recorded` payloads in order — harvest input (§12). */
260
+ observations: ObservationRecord[]
261
+ /** Every setup failure remains historical evidence. `setupFailure` is the
262
+ * latest one not superseded by a later successful runner attachment. */
263
+ setupFailures: Array<EventPayload<'runner.setup-failed'> & { seq: number }>
264
+ setupFailure?: EventPayload<'runner.setup-failed'> & { seq: number }
265
+ /** Pre-run dispatch attempts, retained as durable operator diagnostics. */
266
+ dispatchFailures: Array<EventPayload<'dispatch.failed'> & { seq: number }>
267
+ /** Provider identities and failures are durable even after recovery clears the current error. */
268
+ executions: Array<EventPayload<'execution.started'> & { seq: number }>
269
+ infrastructureFailures: Array<EventPayload<'infrastructure.failed'> & { seq: number }>
270
+ infrastructureFailure?: EventPayload<'infrastructure.failed'> & { seq: number }
271
+ cleanupAttempts: Array<EventPayload<'infrastructure.cleanup-attempted'> & { seq: number }>
272
+ /** Outstanding human discard intent. It is settled only by terminal completion. */
273
+ discardRequest?: { seq: number; actor: Actor }
274
+ /** Unacknowledged runner-owned operator commands in request order (D2). */
275
+ pendingCommands: PendingCommand[]
276
+ sessions: { open: OpenSession[] }
277
+ /** `phase.failed` tally per phase (verify steps key as `verify:<step>`) —
278
+ * retry policy input (§8.4). */
279
+ failures: Record<string, number>
280
+ }
281
+
282
+ export function reduceBuild(events: AbEvent[]): BuildState {
283
+ let attached = false
284
+ let pausedFlag = false
285
+ let terminal: 'done' | 'aborted' | undefined
286
+ let outcome: BuildOutcome | undefined
287
+ let round = 0
288
+ let currentPhase: PhaseContext | undefined
289
+ let lastCompletedPhase: PhaseContext | undefined
290
+ const openEscalations = new Map<string, OpenEscalation>()
291
+ const answeredEscalations: AnsweredEscalation[] = []
292
+ const reviewRoundCeilings: BuildState['reviewRoundCeilings'] = {}
293
+ let pr: BuildState['pr']
294
+ let prState: PrLifecycle | undefined
295
+ const autoMerge: AutoMergeProjection = { requested: false }
296
+ let specRev: number | undefined
297
+ let restartSince = 0
298
+ let finalizeCompletedSeq = 0
299
+ const finalizeSteps: BuildState['finalizeSteps'] = []
300
+ const plan: BuildState['plan'] = { round: 0, approved: false }
301
+ const implement: BuildState['implement'] = { round: 0 }
302
+ let codeReviewApproved = false
303
+ let codeReviewApproval: BuildState['codeReviewApproval']
304
+ const planReviewFindings: Finding[][] = []
305
+ const codeReviewFindings: Finding[][] = []
306
+ const verify: BuildState['verify'] = { maxAttemptSeen: 0, results: [], cycleSince: 0 }
307
+ let reconcileAttempts = 0
308
+ const observations: ObservationRecord[] = []
309
+ const setupFailures: BuildState['setupFailures'] = []
310
+ let setupFailure: BuildState['setupFailure']
311
+ const dispatchFailures: BuildState['dispatchFailures'] = []
312
+ const executions: BuildState['executions'] = []
313
+ const infrastructureFailures: BuildState['infrastructureFailures'] = []
314
+ let infrastructureFailure: BuildState['infrastructureFailure']
315
+ const cleanupAttempts: BuildState['cleanupAttempts'] = []
316
+ let discardRequest: BuildState['discardRequest']
317
+ const pending: Record<PendingCommand['command'], PendingCommand[]> = {
318
+ pause: [],
319
+ resume: [],
320
+ abort: [],
321
+ }
322
+ const openSessions = new Map<string, OpenSession>()
323
+ const failures: Record<string, number> = {}
324
+
325
+ const start = (ctx: PhaseContext): void => {
326
+ currentPhase = ctx
327
+ }
328
+ /** Terminal event for `phase` (§8.4): record completion, close the current
329
+ * context if it is this phase. A terminal with no matching start still
330
+ * records — the reducer is total over any log. */
331
+ const complete = (
332
+ phase: Phase,
333
+ seq: number,
334
+ ctx?: { round?: number; attempt?: number },
335
+ ): void => {
336
+ lastCompletedPhase = { phase, seq, ...ctx }
337
+ if (currentPhase?.phase === phase) currentPhase = undefined
338
+ }
339
+ const setFindings = (per: Finding[][], r: number, findings: Finding[]): void => {
340
+ while (per.length < r) per.push([])
341
+ per[r - 1] = findings
342
+ }
343
+
344
+ let lastEvent: AbEvent | undefined
345
+ for (const event of events) {
346
+ switch (event.type) {
347
+ // Facts the projection does not need (workspace liveness is the
348
+ // dispatcher's concern; finalize post-steps are failure-tolerant §5).
349
+ case 'build.created':
350
+ case 'workspace.provisioned':
351
+ case 'workspace.released':
352
+ case 'publication.requested':
353
+ // Inert loss fact (AUT-328): the dispatcher released a workspace with a
354
+ // pending publication. Recovery runs through the existing
355
+ // abandoned-publication flow (abandonedPublicationPending), which re-runs
356
+ // the phase — the reducer needs no state from it.
357
+ case 'publication.lost':
358
+ case 'dispatch.comment-posted':
359
+ case 'pr-attachment.designated':
360
+ case 'pr-attachment.hosted':
361
+ case 'pr-attachment.reclaimed':
362
+ case 'pr-attachment.reclaim-failed':
363
+ break
364
+
365
+ case 'build.completed':
366
+ terminal = 'done' // terminal, latest wins (§15.5) — in-order overwrite
367
+ outcome = event.payload.outcome
368
+ break
369
+ case 'runner.attached':
370
+ attached = true
371
+ // On a recovery attempt attachment is delayed until setup succeeds,
372
+ // so this fact is also the durable proof that the condition cleared.
373
+ setupFailure = undefined
374
+ break
375
+ case 'runner.setup-failed': {
376
+ const failure = { ...event.payload, seq: event.seq }
377
+ setupFailures.push(failure)
378
+ setupFailure = failure
379
+ break
380
+ }
381
+ case 'dispatch.failed':
382
+ dispatchFailures.push({ ...event.payload, seq: event.seq })
383
+ break
384
+ case 'execution.started':
385
+ executions.push({ ...event.payload, seq: event.seq })
386
+ infrastructureFailure = undefined
387
+ break
388
+ case 'execution.ended':
389
+ infrastructureFailure = undefined
390
+ break
391
+ case 'infrastructure.failed': {
392
+ const failure = { ...event.payload, seq: event.seq }
393
+ infrastructureFailures.push(failure)
394
+ infrastructureFailure = failure
395
+ break
396
+ }
397
+ case 'infrastructure.cleanup-attempted':
398
+ cleanupAttempts.push({ ...event.payload, seq: event.seq })
399
+ if (
400
+ event.payload.outcome !== 'unknown' &&
401
+ infrastructureFailure?.workspaceRef === event.payload.workspaceRef
402
+ ) {
403
+ infrastructureFailure = undefined
404
+ }
405
+ break
406
+
407
+ // Operator commands (D2): requests queue until the kernel's fact event
408
+ // acknowledges them; one ack clears every earlier request of its kind.
409
+ // Commands are ORDERED events (§15.2.7), so a request also expires every
410
+ // earlier pending request of the opposing kind: a pause countermands a
411
+ // still-pending resume and vice versa. Otherwise a stale retained
412
+ // request would later be acknowledged against the operator's newest
413
+ // command — an old resume un-pausing a later pause.
414
+ case 'build.pause-requested':
415
+ pending.resume = []
416
+ pending.pause.push({
417
+ command: 'pause',
418
+ seq: event.seq,
419
+ reason: event.payload.reason,
420
+ actor: event.actor,
421
+ })
422
+ break
423
+ case 'build.resume-requested':
424
+ pending.pause = []
425
+ pending.resume.push({
426
+ command: 'resume',
427
+ seq: event.seq,
428
+ reason: event.payload.reason,
429
+ actor: event.actor,
430
+ })
431
+ break
432
+ case 'build.abort-requested':
433
+ pending.abort.push({
434
+ command: 'abort',
435
+ seq: event.seq,
436
+ reason: event.payload.reason,
437
+ actor: event.actor,
438
+ })
439
+ break
440
+ case 'build.discard-requested':
441
+ discardRequest = { seq: event.seq, actor: event.actor }
442
+ break
443
+ case 'build.auto-merge-requested':
444
+ autoMerge.requested = true
445
+ autoMerge.commandSeq = event.seq
446
+ break
447
+ case 'build.auto-merge-cancelled':
448
+ autoMerge.requested = false
449
+ autoMerge.commandSeq = event.seq
450
+ break
451
+ case 'build.paused':
452
+ pausedFlag = true
453
+ pending.pause = []
454
+ break
455
+ case 'build.resumed':
456
+ pausedFlag = false
457
+ pending.resume = []
458
+ break
459
+ case 'build.aborted':
460
+ terminal = 'aborted'
461
+ pending.abort = []
462
+ break
463
+
464
+ case 'spec.imported':
465
+ case 'spec.authored':
466
+ specRev = event.payload.artifact.rev
467
+ break
468
+ case 'spec.revised':
469
+ specRev = event.payload.artifact.rev
470
+ // §6.3: rev N+1 restarts the build from plan. The restart boundary
471
+ // invalidates every approval and result at or before it, so it is also
472
+ // a verify cycle boundary. Finalize post-steps remain in full-log
473
+ // history; consumers use this boundary to select the current spec,
474
+ // matching engine.ts:710-712.
475
+ restartSince = event.seq
476
+ verify.cycleSince = Math.max(verify.cycleSince, event.seq)
477
+ delete reviewRoundCeilings.plan
478
+ delete reviewRoundCeilings.code
479
+ break
480
+
481
+ case 'session.started':
482
+ openSessions.set(event.payload.session, {
483
+ session: event.payload.session,
484
+ role: event.payload.role,
485
+ runner: event.payload.runner,
486
+ model: event.payload.model,
487
+ phase: event.payload.phase,
488
+ round: event.payload.round,
489
+ stream: event.payload.stream,
490
+ seq: event.seq,
491
+ })
492
+ break
493
+ case 'session.ended':
494
+ openSessions.delete(event.payload.session)
495
+ break
496
+
497
+ case 'plan.started':
498
+ round = event.payload.round
499
+ plan.round = round
500
+ start({ phase: 'plan', round, seq: event.seq })
501
+ break
502
+ case 'plan.completed':
503
+ round = event.payload.round
504
+ plan.round = round
505
+ plan.artifactRev = event.payload.artifact.rev
506
+ complete('plan', event.seq, { round })
507
+ break
508
+ case 'plan-review.started':
509
+ round = event.payload.round
510
+ start({ phase: 'plan-review', round, seq: event.seq })
511
+ break
512
+ case 'plan-review.verdict':
513
+ round = event.payload.round
514
+ plan.approved = event.payload.verdict === 'approve'
515
+ // Only an approve stands; any other verdict clears it, so
516
+ // `approval !== undefined` ⇔ `approved`.
517
+ if (plan.approved) plan.approval = { seq: event.seq, round }
518
+ else delete plan.approval
519
+ setFindings(planReviewFindings, round, event.payload.findings)
520
+ complete('plan-review', event.seq, { round })
521
+ break
522
+ case 'implement.started':
523
+ round = event.payload.round
524
+ implement.round = round
525
+ start({ phase: 'implement', round, seq: event.seq })
526
+ break
527
+ case 'implement.completed':
528
+ round = event.payload.round
529
+ implement.round = round
530
+ implement.commits = event.payload.commits
531
+ implement.artifactRev = event.payload.artifact.rev
532
+ complete('implement', event.seq, { round })
533
+ break
534
+ case 'code-review.started':
535
+ round = event.payload.round
536
+ start({ phase: 'code-review', round, seq: event.seq })
537
+ break
538
+ case 'code-review.verdict':
539
+ round = event.payload.round
540
+ codeReviewApproved = event.payload.verdict === 'approve'
541
+ codeReviewApproval = codeReviewApproved ? { seq: event.seq, round } : undefined
542
+ // §15.6-A: an approve means the code is settled and verifiable, so it
543
+ // opens a new verify cycle. This tracker is deliberately SEPARATE from
544
+ // `codeReviewApproval` — a later revise clears the approval but must
545
+ // NOT move the cycle boundary backwards (mirrors engine.ts:637, which
546
+ // never resets `latestApproveSeq`).
547
+ if (codeReviewApproved) verify.cycleSince = Math.max(verify.cycleSince, event.seq)
548
+ setFindings(codeReviewFindings, round, event.payload.findings)
549
+ complete('code-review', event.seq, { round })
550
+ break
551
+
552
+ case 'verify.started':
553
+ verify.maxAttemptSeen = Math.max(verify.maxAttemptSeen, event.payload.attempt)
554
+ verify.currentStep = event.payload.step
555
+ start({
556
+ phase: verifyPhase(event.payload.step),
557
+ attempt: event.payload.attempt,
558
+ seq: event.seq,
559
+ })
560
+ break
561
+ case 'verify.completed': {
562
+ const result = normalizeVerifyCompletion(event.payload)
563
+ verify.maxAttemptSeen = Math.max(verify.maxAttemptSeen, result.attempt)
564
+ verify.results.push({
565
+ step: result.step,
566
+ attempt: result.attempt,
567
+ outcome: result.outcome,
568
+ report: result.report,
569
+ ...(result.reason !== undefined ? { reason: result.reason } : {}),
570
+ seq: event.seq,
571
+ })
572
+ if (verify.currentStep === result.step) {
573
+ verify.currentStep = undefined
574
+ }
575
+ complete(verifyPhase(result.step), event.seq, {
576
+ attempt: result.attempt,
577
+ })
578
+ break
579
+ }
580
+
581
+ case 'finalize.started':
582
+ start({ phase: 'finalize', seq: event.seq })
583
+ break
584
+ case 'finalize.completed':
585
+ pr = event.payload.pr
586
+ prState = 'open'
587
+ finalizeCompletedSeq = event.seq
588
+ complete('finalize', event.seq)
589
+ break
590
+ case 'finalize.step-completed':
591
+ // §5: post-steps are independent and failure-tolerant — a completion
592
+ // counts whether `ok` is true or false. Retain full-log history with
593
+ // occurrence identity; readers scope it with `seq > restartBoundary`.
594
+ finalizeSteps.push({ step: event.payload.step, ok: event.payload.ok, seq: event.seq })
595
+ break
596
+
597
+ case 'pr.auto-merge-enabled':
598
+ autoMerge.applied = { enabled: true, commandSeq: event.payload.commandSeq }
599
+ break
600
+ case 'pr.auto-merge-disabled':
601
+ autoMerge.applied = { enabled: false, commandSeq: event.payload.commandSeq }
602
+ break
603
+ case 'pr.merged':
604
+ prState = 'merged'
605
+ break
606
+ case 'pr.closed':
607
+ prState = 'closed'
608
+ break
609
+ case 'pr.conflicted':
610
+ prState = 'conflicted'
611
+ break
612
+ case 'reconcile.started':
613
+ reconcileAttempts = Math.max(reconcileAttempts, event.payload.attempt)
614
+ start({
615
+ phase: 'reconcile',
616
+ attempt: event.payload.attempt,
617
+ seq: event.seq,
618
+ })
619
+ break
620
+ case 'reconcile.completed': {
621
+ // The terminal payload has no attempt, so preserve it from the
622
+ // matching in-flight occurrence before complete() clears that context.
623
+ // An unmatched completion remains a bare context rather than borrowing
624
+ // the aggregate reconcileAttempts high-water.
625
+ const attempt = currentPhase?.phase === 'reconcile' ? currentPhase.attempt : undefined
626
+ // §15.7: conflicted until a reconcile.completed appears after the
627
+ // pr.conflicted — the PR is open again while verify:* re-runs.
628
+ if (prState === 'conflicted') prState = 'open'
629
+ // Reconciliation changed the code, so verify re-runs in full — a new
630
+ // cycle (§15.7, engine.ts:275).
631
+ verify.cycleSince = Math.max(verify.cycleSince, event.seq)
632
+ complete('reconcile', event.seq, attempt !== undefined ? { attempt } : undefined)
633
+ break
634
+ }
635
+
636
+ case 'observation.recorded':
637
+ observations.push(event.payload)
638
+ break
639
+ case 'escalation.raised':
640
+ openEscalations.set(event.payload.id, {
641
+ id: event.payload.id,
642
+ phase: event.payload.phase,
643
+ round: event.payload.round,
644
+ source: event.payload.source,
645
+ ...(event.payload.policyCause !== undefined
646
+ ? { policyCause: event.payload.policyCause }
647
+ : {}),
648
+ question: event.payload.question,
649
+ refs: event.payload.refs,
650
+ seq: event.seq,
651
+ })
652
+ break
653
+ case 'escalation.answered': {
654
+ // Matched by id — answers may arrive out of raise order. An answer
655
+ // with no open raise is ignored (total over any prefix).
656
+ const open = openEscalations.get(event.payload.id)
657
+ if (open) {
658
+ openEscalations.delete(event.payload.id)
659
+ answeredEscalations.push({
660
+ ...open,
661
+ answer: event.payload.answer,
662
+ resolution: event.payload.resolution,
663
+ ...(event.payload.artifact !== undefined ? { artifact: event.payload.artifact } : {}),
664
+ ...(event.payload.reviewRoundCeiling !== undefined
665
+ ? { reviewRoundCeiling: event.payload.reviewRoundCeiling }
666
+ : {}),
667
+ answeredSeq: event.seq,
668
+ })
669
+ if (
670
+ event.payload.reviewRoundCeiling !== undefined &&
671
+ open.source === 'policy' &&
672
+ open.policyCause === 'review-round-limit'
673
+ ) {
674
+ if (open.phase === 'plan-review') {
675
+ reviewRoundCeilings.plan = event.payload.reviewRoundCeiling
676
+ } else if (open.phase === 'code-review') {
677
+ reviewRoundCeilings.code = event.payload.reviewRoundCeiling
678
+ }
679
+ }
680
+ // Abort is accepted through either supported operator surface. Keep
681
+ // the answer as the audit reason and project its event identity as
682
+ // the same pending command an explicit abort request creates; the
683
+ // kernel's one build.aborted fact acknowledges both pathways.
684
+ if (event.payload.resolution === 'abort') {
685
+ pending.abort.push({
686
+ command: 'abort',
687
+ seq: event.seq,
688
+ reason: event.payload.answer,
689
+ actor: event.actor,
690
+ })
691
+ }
692
+ }
693
+ break
694
+ }
695
+ case 'phase.failed':
696
+ failures[event.payload.phase] = (failures[event.payload.phase] ?? 0) + 1
697
+ break
698
+ }
699
+ lastEvent = event
700
+ }
701
+
702
+ // §15.5 precedence: aborted/done (terminal, latest wins) > paused >
703
+ // blocked > running > queued. See BuildState.status for the paused+blocked
704
+ // overlap rule.
705
+ const status: BuildStatus =
706
+ terminal ??
707
+ (pausedFlag ? 'paused' : openEscalations.size > 0 ? 'blocked' : attached ? 'running' : 'queued')
708
+
709
+ const active = currentPhase ?? lastCompletedPhase
710
+
711
+ return {
712
+ status,
713
+ outcome,
714
+ phase: active?.phase,
715
+ round,
716
+ currentPhase,
717
+ lastCompletedPhase,
718
+ openEscalations: [...openEscalations.values()],
719
+ answeredEscalations,
720
+ reviewRoundCeilings,
721
+ pr,
722
+ prState,
723
+ autoMerge,
724
+ finalizeCompletedSeq,
725
+ finalizeSteps,
726
+ lastEvent,
727
+ lastSeq: lastEvent?.seq ?? 0,
728
+ specRev,
729
+ restartSince,
730
+ plan,
731
+ implement,
732
+ codeReviewApproved,
733
+ codeReviewApproval,
734
+ reviewFindings: { planReview: planReviewFindings, codeReview: codeReviewFindings },
735
+ verify,
736
+ reconcileAttempts,
737
+ observations,
738
+ setupFailures,
739
+ setupFailure,
740
+ dispatchFailures,
741
+ executions,
742
+ infrastructureFailures,
743
+ infrastructureFailure,
744
+ cleanupAttempts,
745
+ ...(terminal === undefined && discardRequest !== undefined ? { discardRequest } : {}),
746
+ pendingCommands: [...pending.pause, ...pending.resume, ...pending.abort].sort(
747
+ (a, b) => a.seq - b.seq,
748
+ ),
749
+ sessions: { open: [...openSessions.values()] },
750
+ failures,
751
+ }
752
+ }