@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,995 @@
1
+ /**
2
+ * The three terminal commands — `ab done`, `ab verdict`, `ab escalate`
3
+ * (SPEC §8.4 D5, §8.5 D6, §8.6 D7).
4
+ *
5
+ * D5: every phase ends with exactly one terminal command; a second terminal
6
+ * call is rejected citing what already exists, and every terminal validates
7
+ * its preconditions before emitting the phase event.
8
+ *
9
+ * D6: terminal commands are atomic bundles — `appendWithArtifacts` deposits
10
+ * the artifact and appends the event in one operation, and validation
11
+ * failures return the schema and a precise error as in-session agent
12
+ * feedback, not build failures.
13
+ *
14
+ * D7: agents never touch the remote — push and PR creation happen here, as
15
+ * plumbing triggered by the terminal. Forge credentials never enter the
16
+ * agent's own toolbox; this CLI call IS the kernel plumbing.
17
+ */
18
+ import { join } from 'node:path'
19
+ import { z } from 'zod'
20
+ import { loadConfig } from '../config/load'
21
+ import type { AbEvent, EventEnvelope } from '../events/catalog'
22
+ import { agentActor, KERNEL } from '../events/envelope'
23
+ import type { IdSource } from '../ids'
24
+ import {
25
+ autoMergeApplicationType,
26
+ pendingAutoMerge,
27
+ recordAutoMergeDeferralObservation,
28
+ } from '../kernel/auto-merge'
29
+ import { phaseSpecFor } from '../kernel/phases'
30
+ import { resolvePlanVerifySteps } from '../kernel/plan-verify-selection'
31
+ import { reduceBuild } from '../kernel/reducer'
32
+ import {
33
+ findingDraftSchema,
34
+ isVerifyPhase,
35
+ verifyReportKind,
36
+ verifyStep,
37
+ type ArtifactRef,
38
+ type Finding,
39
+ type ReviewVerdictKind,
40
+ } from '../ontology'
41
+ import type { Forge } from '../ports/types'
42
+ import {
43
+ phaseScratchRejection,
44
+ reconcileScratchViolations,
45
+ scratchPathsTouchedInRange,
46
+ } from '../ports/workspace/phase-scratch'
47
+ import type { Exec } from '../ports/workspace/git-worktree'
48
+ import type { ArtifactMeta, BuildStore } from '../store/types'
49
+ import { textContent } from '../store/types'
50
+ import { expandIssues } from '../zod-issues'
51
+ import { preparePrAttachments } from './pr-attachments'
52
+ import { renderPrSummary } from './pr-summary'
53
+ import type { CliEnv } from './env'
54
+
55
+ /** Structural subset of CliDeps (src/cli/main.ts) — what terminals need. */
56
+ export interface TerminalDeps {
57
+ store: BuildStore
58
+ env: CliEnv
59
+ workspacePath: string
60
+ forge: Forge
61
+ /** All git interrogation goes through this seam — never a raw spawn. */
62
+ exec: Exec
63
+ ids: IdSource
64
+ }
65
+
66
+ // ── Shared discipline (D5) ───────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Reject a second terminal call for this phase+round: either the phase's
70
+ * terminal event already recorded at this round, or an `escalation.raised`
71
+ * by THIS session (a session that escalated is done — §8.4). Also reject a
72
+ * terminal from a ZOMBIE session — one the log already ended, or one whose
73
+ * phase round already failed after it started (D5: a session that ends
74
+ * without a terminal is failed and retried; only the retry may complete the
75
+ * round, so a still-in-flight CLI process from the failed session must not
76
+ * land a terminal the runner will misattribute to the retry).
77
+ */
78
+ function assertNoPriorTerminal(events: AbEvent[], env: CliEnv): void {
79
+ const spec = phaseSpecFor(env.phase)
80
+ // §6.3 restart boundary: verify attempts and finalize completion RESET
81
+ // across a spec.revised (decideNext ignores pre-restart results and
82
+ // re-runs the pipeline from plan), so a pre-restart verify.completed or
83
+ // finalize.completed must not shadow the rebuilt pipeline's terminal.
84
+ // Loop-phase rounds continue monotonically across restarts (engine
85
+ // LoopIndex.maxRoundEver) and never collide, so they match over the full
86
+ // log; the reconcile epilogue is restart-orthogonal (§15.7).
87
+ let restartSeq = 0
88
+ for (const event of events) {
89
+ if (event.type === 'spec.revised') restartSeq = event.seq
90
+ }
91
+ // reconcile.completed carries no attempt (§15.3); pair it with the latest
92
+ // reconcile.started seen before it.
93
+ let reconcileAttempt = 0
94
+ let sessionStartedSeq: number | undefined
95
+ for (const event of events) {
96
+ if (event.type === 'reconcile.started') reconcileAttempt = event.payload.attempt
97
+ if (event.type === 'session.started' && event.payload.session === env.session) {
98
+ sessionStartedSeq = event.seq
99
+ }
100
+ if (event.type === 'session.ended' && event.payload.session === env.session) {
101
+ throw new Error(
102
+ `terminal rejected (D5): session "${env.session}" already ended ` +
103
+ `(session.ended at seq ${event.seq}) — a session that ended without ` +
104
+ 'a terminal was failed and retried; only the live retry may complete ' +
105
+ 'this phase round (§8.4).',
106
+ )
107
+ }
108
+ if (
109
+ sessionStartedSeq !== undefined &&
110
+ event.type === 'phase.failed' &&
111
+ event.payload.phase === env.phase &&
112
+ event.payload.round === env.round &&
113
+ event.seq > sessionStartedSeq
114
+ ) {
115
+ throw new Error(
116
+ `terminal rejected (D5): ${env.phase}@${env.round} already failed after ` +
117
+ `this session started (phase.failed at seq ${event.seq}) — the retry ` +
118
+ 'session owns this round now (§8.4).',
119
+ )
120
+ }
121
+ if (
122
+ event.type === 'escalation.raised' &&
123
+ event.actor.kind === 'agent' &&
124
+ event.actor.session === env.session
125
+ ) {
126
+ throw new Error(
127
+ `second terminal call rejected (D5): this session already escalated — ` +
128
+ `escalation.raised "${event.payload.id}" at seq ${event.seq}. ` +
129
+ 'Every phase ends with exactly one terminal command (§8.4).',
130
+ )
131
+ }
132
+ if (
133
+ event.type === 'publication.requested' &&
134
+ event.actor.kind === 'agent' &&
135
+ event.actor.session === env.session &&
136
+ ((env.phase === 'implement' &&
137
+ event.payload.operation === 'implement' &&
138
+ event.payload.round === env.round) ||
139
+ (env.phase === 'reconcile' && event.payload.operation === 'reconcile') ||
140
+ (env.phase === 'finalize' && event.payload.operation === 'finalize'))
141
+ ) {
142
+ throw new Error(
143
+ `second terminal call rejected (D5): ${event.type} for ` +
144
+ `${env.phase}@${env.round} already recorded at seq ${event.seq}. ` +
145
+ 'Remote publication is pending; do not run ab done again (§8.4).',
146
+ )
147
+ }
148
+ if (event.type !== spec.terminalEvent) continue
149
+ if (
150
+ (event.type === 'verify.completed' || event.type === 'finalize.completed') &&
151
+ event.seq <= restartSeq
152
+ ) {
153
+ continue
154
+ }
155
+ if (!terminalMatchesRound(event, env, reconcileAttempt)) continue
156
+ throw new Error(
157
+ `second terminal call rejected (D5): ${event.type} for ` +
158
+ `${env.phase}@${env.round} already recorded at seq ${event.seq}. ` +
159
+ 'Every phase ends with exactly one terminal command (§8.4).',
160
+ )
161
+ }
162
+ }
163
+
164
+ function terminalMatchesRound(event: AbEvent, env: CliEnv, reconcileAttempt: number): boolean {
165
+ switch (event.type) {
166
+ case 'plan.completed':
167
+ case 'plan-review.verdict':
168
+ case 'implement.completed':
169
+ case 'code-review.verdict':
170
+ return event.payload.round === env.round
171
+ case 'verify.completed':
172
+ return (
173
+ isVerifyPhase(env.phase) &&
174
+ event.payload.step === verifyStep(env.phase) &&
175
+ event.payload.attempt === env.round
176
+ )
177
+ case 'finalize.completed':
178
+ // finalize runs once per build (§5) — any prior completion counts.
179
+ return true
180
+ case 'reconcile.completed':
181
+ // No started event before it → conservative match (total over any log).
182
+ return reconcileAttempt === env.round || reconcileAttempt === 0
183
+ default:
184
+ return false
185
+ }
186
+ }
187
+
188
+ // ── Small helpers ────────────────────────────────────────────────────────────
189
+
190
+ async function readTextFile(path: string, flag: string): Promise<string> {
191
+ const file = Bun.file(path)
192
+ if (!(await file.exists())) {
193
+ throw new Error(`${flag} file not found: ${path}`)
194
+ }
195
+ return file.text()
196
+ }
197
+
198
+ async function git(deps: TerminalDeps, args: string[]): Promise<string> {
199
+ const result = await deps.exec(['git', ...args], { cwd: deps.workspacePath })
200
+ if (result.exitCode !== 0) {
201
+ throw new Error(
202
+ `git ${args.join(' ')} exited ${result.exitCode}: ` +
203
+ `${result.stderr.trim() || result.stdout.trim() || '(no output)'}`,
204
+ )
205
+ }
206
+ return result.stdout
207
+ }
208
+
209
+ /** Git object ids are 40 hex characters for SHA-1 repositories and 64 for
210
+ * SHA-256 repositories. The corresponding rev-parse calls also prove type. */
211
+ const GIT_OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
212
+
213
+ function gitObjectIds(output: string, command: string): string[] {
214
+ const body = output.endsWith('\n') ? output.slice(0, -1) : output
215
+ if (body === '') return []
216
+ const ids = body.split('\n')
217
+ const malformed = ids.find((id) => !GIT_OBJECT_ID.test(id))
218
+ if (malformed !== undefined) {
219
+ throw new Error(`${command} returned malformed commit output: ${JSON.stringify(output)}`)
220
+ }
221
+ return ids
222
+ }
223
+
224
+ function singleGitObjectId(output: string, command: string): string {
225
+ const ids = gitObjectIds(output, command)
226
+ if (ids.length !== 1) {
227
+ throw new Error(`${command} expected exactly one commit SHA, found ${ids.length}`)
228
+ }
229
+ return ids[0]!
230
+ }
231
+
232
+ /** No `done` on a dirty worktree (D5) — the error lists the offending files. */
233
+ async function assertCleanWorktree(deps: TerminalDeps): Promise<void> {
234
+ const status = (await git(deps, ['status', '--porcelain'])).trimEnd()
235
+ if (status !== '') {
236
+ throw new Error(
237
+ `'ab done' in ${deps.env.phase} requires a clean worktree (D5) — ` +
238
+ `commit or discard these first:\n${status}`,
239
+ )
240
+ }
241
+ }
242
+
243
+ function baseBranchOf(events: AbEvent[]): string {
244
+ for (const event of events) {
245
+ if (event.type === 'build.created') return event.payload.baseBranch
246
+ }
247
+ throw new Error('this build has no build.created event — cannot resolve its baseBranch (§15.3)')
248
+ }
249
+
250
+ function requireImplementationProvisioning(events: AbEvent[]): void {
251
+ if (events.some((event) => event.type === 'workspace.provisioned')) return
252
+ throw new Error(
253
+ "implement's 'ab done' requires workspace.provisioned provenance — " +
254
+ 'this build has no workspace.provisioned event (§7.4, §15.3)',
255
+ )
256
+ }
257
+
258
+ function remotePublication(events: readonly AbEvent[]): boolean {
259
+ let remote = false
260
+ for (const event of events) {
261
+ if (event.type === 'workspace.provisioned') remote = event.payload.provider === 'vercel-sandbox'
262
+ else if (event.type === 'workspace.released') remote = false
263
+ }
264
+ return remote
265
+ }
266
+
267
+ async function buildBranch(deps: TerminalDeps): Promise<string> {
268
+ const build = await deps.store.getBuild(deps.env.build)
269
+ if (build === null) {
270
+ throw new Error(
271
+ `build "${deps.env.build}" could not be resolved — cannot determine its durable branch`,
272
+ )
273
+ }
274
+ return build.branch ?? `ab/${build.slug}`
275
+ }
276
+
277
+ /**
278
+ * Establish the focused range that this implementation completion makes
279
+ * durable. The target snapshot lives only in a build-scoped internal ref:
280
+ * neither FETCH_HEAD nor operator/remote-tracking refs are review inputs.
281
+ */
282
+ async function implementationReviewBoundary(
283
+ deps: TerminalDeps,
284
+ events: AbEvent[],
285
+ ): Promise<{ branch: string; base: string; head: string }> {
286
+ const diagnostic = 'cannot establish implementation review boundary'
287
+ try {
288
+ requireImplementationProvisioning(events)
289
+ const baseBranch = baseBranchOf(events)
290
+ const branch = await buildBranch(deps)
291
+ const headCommand = 'git rev-parse --verify HEAD^{commit}'
292
+ const head = singleGitObjectId(
293
+ await git(deps, ['rev-parse', '--verify', 'HEAD^{commit}']),
294
+ headCommand,
295
+ )
296
+
297
+ const targetRef = `refs/autobuild/review/${branch}/base`
298
+ const target =
299
+ deps.forge.snapshotBase !== undefined
300
+ ? singleGitObjectId(
301
+ `${await deps.forge.snapshotBase({
302
+ workspacePath: deps.workspacePath,
303
+ base: baseBranch,
304
+ destinationRef: targetRef,
305
+ })}\n`,
306
+ `${deps.forge.name} base snapshot`,
307
+ )
308
+ : await (async () => {
309
+ await git(deps, [
310
+ 'fetch',
311
+ '--no-tags',
312
+ '--no-write-fetch-head',
313
+ '--refmap=',
314
+ 'origin',
315
+ `+refs/heads/${baseBranch}:${targetRef}`,
316
+ ])
317
+ const targetCommand = `git rev-parse --verify ${targetRef}^{commit}`
318
+ return singleGitObjectId(
319
+ await git(deps, ['rev-parse', '--verify', `${targetRef}^{commit}`]),
320
+ targetCommand,
321
+ )
322
+ })()
323
+
324
+ const mergeBaseArgs = ['merge-base', '--all', target, head]
325
+ const mergeBaseCommand = `git ${mergeBaseArgs.join(' ')}`
326
+ const merged = await deps.exec(['git', ...mergeBaseArgs], {
327
+ cwd: deps.workspacePath,
328
+ })
329
+ if (merged.exitCode === 1) {
330
+ throw new Error(`${mergeBaseCommand} found no common ancestor`)
331
+ }
332
+ if (merged.exitCode !== 0) {
333
+ throw new Error(
334
+ `${mergeBaseCommand} exited ${merged.exitCode}: ` +
335
+ `${merged.stderr.trim() || merged.stdout.trim() || '(no output)'}`,
336
+ )
337
+ }
338
+ const mergeBases = gitObjectIds(merged.stdout, mergeBaseCommand)
339
+ if (mergeBases.length !== 1) {
340
+ throw new Error(
341
+ `${mergeBaseCommand} expected exactly one merge-base, found ` +
342
+ `${mergeBases.length}${mergeBases.length > 0 ? `: ${mergeBases.join(', ')}` : ''}`,
343
+ )
344
+ }
345
+ return { branch, base: mergeBases[0]!, head }
346
+ } catch (error) {
347
+ const detail = error instanceof Error ? error.message : String(error)
348
+ throw new Error(`${diagnostic}: ${detail}`)
349
+ }
350
+ }
351
+
352
+ function refOf(meta: ArtifactMeta | undefined): ArtifactRef {
353
+ if (meta === undefined) {
354
+ throw new Error('deposit produced no artifact meta — this is a store bug')
355
+ }
356
+ return { kind: meta.kind, rev: meta.revision }
357
+ }
358
+
359
+ function requireNotes(notes: string | undefined, why: string): string {
360
+ if (notes === undefined) {
361
+ throw new Error(`--notes <file> is required: ${why} (D5, §8.2)`)
362
+ }
363
+ return notes
364
+ }
365
+
366
+ // ── ab done (producer phases — D5/D7) ────────────────────────────────────────
367
+
368
+ export interface DoneOpts {
369
+ /** Path to the notes file (implement/reconcile — deposited as the phase's artifact). */
370
+ notes?: string
371
+ }
372
+
373
+ export interface FinalizePrInput {
374
+ branch: string
375
+ baseBranch: string
376
+ title: string
377
+ body: string
378
+ mergeMessage: string
379
+ /** Remote settlement pins the PR to the already-verified published head. */
380
+ expectedHeadSha?: string
381
+ }
382
+
383
+ /** Shared local/remote finalize plumbing. The caller must publish the exact
384
+ * requested head before entering this idempotent PR-side sequence. */
385
+ export async function completeFinalizePr(
386
+ deps: TerminalDeps,
387
+ events: AbEvent[],
388
+ input: FinalizePrInput,
389
+ ): Promise<EventEnvelope> {
390
+ const { env, store } = deps
391
+ const pr = await deps.forge.openPr({
392
+ workspacePath: deps.workspacePath,
393
+ head: input.branch,
394
+ base: input.baseBranch,
395
+ title: input.title,
396
+ body: input.body,
397
+ mergeMessage: input.mergeMessage,
398
+ })
399
+ if (input.expectedHeadSha !== undefined && pr.headSha !== input.expectedHeadSha) {
400
+ throw new Error(
401
+ `PR head ${pr.headSha} did not match published request ${input.expectedHeadSha}`,
402
+ )
403
+ }
404
+
405
+ await preparePrAttachments(deps, events, pr.url)
406
+
407
+ const latest = await store.getEvents(env.build)
408
+ const autoMerge = pendingAutoMerge(reduceBuild(latest))
409
+ const autoMergeResult =
410
+ autoMerge === undefined
411
+ ? undefined
412
+ : await deps.forge.setAutoMerge(deps.workspacePath, pr.number, autoMerge.enabled)
413
+
414
+ const event = await store.append(env.build, {
415
+ actor: KERNEL,
416
+ type: 'finalize.completed',
417
+ payload: { pr },
418
+ })
419
+
420
+ if (autoMerge !== undefined) {
421
+ try {
422
+ if (autoMergeResult?.kind === 'applied') {
423
+ await store.append(env.build, {
424
+ actor: KERNEL,
425
+ type: autoMergeApplicationType(autoMerge.enabled),
426
+ payload: { commandSeq: autoMerge.commandSeq },
427
+ })
428
+ } else if (autoMergeResult?.kind === 'deferred' && autoMergeResult.reason !== undefined) {
429
+ await recordAutoMergeDeferralObservation(
430
+ store,
431
+ env.build,
432
+ autoMergeResult.reason,
433
+ pr.number,
434
+ autoMerge.commandSeq,
435
+ deps.ids('obs'),
436
+ )
437
+ }
438
+ } catch {
439
+ // The janitor retries unmatched commands and provider diagnostics.
440
+ }
441
+ }
442
+
443
+ try {
444
+ await deps.forge.commentOnPr(
445
+ deps.workspacePath,
446
+ pr.number,
447
+ renderPrSummary(env, await store.getEvents(env.build)),
448
+ )
449
+ } catch {
450
+ // The durable audit trail remains authoritative.
451
+ }
452
+ return event
453
+ }
454
+
455
+ export async function done(deps: TerminalDeps, opts: DoneOpts = {}): Promise<EventEnvelope> {
456
+ const { env, store } = deps
457
+ const spec = phaseSpecFor(env.phase)
458
+ if (spec.kind !== 'producer') {
459
+ throw new Error(
460
+ `'ab done' is not ${env.phase}'s terminal — ${env.phase} is a ` +
461
+ `${spec.kind} phase; use 'ab verdict' (§8.2)`,
462
+ )
463
+ }
464
+ const events = await store.getEvents(env.build)
465
+ assertNoPriorTerminal(events, env)
466
+ const actor = agentActor(env.phase, env.session)
467
+
468
+ switch (spec.name) {
469
+ case 'plan': {
470
+ // THIS round's plan must already be deposited (D5: no done without the
471
+ // required artifacts): a revision newer than the one the last
472
+ // plan.completed cited. A bare revision count is no proxy — crash
473
+ // re-runs leave orphaned revs (§8.7) and a round may self-correct with
474
+ // several deposits, so count ≥ round can hold while the latest rev is
475
+ // still the already-reviewed plan, re-entering review as if revised.
476
+ const revs = await store.listArtifacts(env.build, 'plan')
477
+ const latest = revs[revs.length - 1]
478
+ let lastCited = -1
479
+ for (const event of events) {
480
+ if (event.type === 'plan.completed') {
481
+ lastCited = Math.max(lastCited, event.payload.artifact.rev)
482
+ }
483
+ }
484
+ if (latest === undefined || latest.revision <= lastCited) {
485
+ throw new Error(
486
+ `plan@${env.round} 'ab done' requires this round's plan deposit: found ` +
487
+ (latest === undefined
488
+ ? '0 plan revisions'
489
+ : `no plan revision newer than rev ${lastCited}, which an earlier ` +
490
+ `round's plan.completed already cited`) +
491
+ ` — run 'ab artifact put plan <file>' first (D5)`,
492
+ )
493
+ }
494
+ // The event, not a later artifact read, is the engine's durable input.
495
+ // Validate the exact fresh revision before appending so an invalid plan
496
+ // can be corrected with another immutable deposit and no partial fact.
497
+ const plan = await store.getArtifact(env.build, 'plan', latest.revision)
498
+ if (plan === null) {
499
+ throw new Error(
500
+ `plan@${latest.revision} was listed but could not be fetched — this is a store bug`,
501
+ )
502
+ }
503
+ const config = await loadConfig(join(deps.workspacePath, 'autobuild.toml'))
504
+ const verifySteps = resolvePlanVerifySteps(textContent(plan), config)
505
+ return store.append(env.build, {
506
+ actor,
507
+ type: 'plan.completed',
508
+ payload: {
509
+ round: env.round,
510
+ artifact: { kind: 'plan', rev: latest.revision },
511
+ verifySteps,
512
+ },
513
+ })
514
+ }
515
+
516
+ case 'implement': {
517
+ const notesPath = requireNotes(opts.notes, "implement's 'ab done' deposits implement-notes")
518
+ const notes = await readTextFile(notesPath, '--notes')
519
+ await assertCleanWorktree(deps)
520
+ // Resolve the current target and focused divergence BEFORE publication
521
+ // or deposit. A misleading range is worse than rejecting this terminal.
522
+ const { branch, base, head } = await implementationReviewBoundary(deps, events)
523
+ const scratchPaths = await scratchPathsTouchedInRange(
524
+ deps.exec,
525
+ deps.workspacePath,
526
+ base,
527
+ head,
528
+ )
529
+ if (scratchPaths.length > 0) throw phaseScratchRejection(scratchPaths)
530
+ if (remotePublication(events)) {
531
+ const { event } = await store.appendWithArtifacts(
532
+ env.build,
533
+ [{ kind: 'implement-notes', content: notes }],
534
+ (deposited) => ({
535
+ actor,
536
+ type: 'publication.requested',
537
+ payload: {
538
+ operation: 'implement',
539
+ branch,
540
+ sha: head,
541
+ round: env.round,
542
+ base,
543
+ artifact: refOf(deposited[0]),
544
+ },
545
+ }),
546
+ )
547
+ return event
548
+ }
549
+ // Local publication remains immediate and push-before-fact.
550
+ await deps.forge.pushBranch(deps.workspacePath, branch)
551
+ const { event } = await store.appendWithArtifacts(
552
+ env.build,
553
+ [{ kind: 'implement-notes', content: notes }],
554
+ (deposited) => ({
555
+ actor,
556
+ type: 'implement.completed',
557
+ payload: { round: env.round, commits: { base, head }, artifact: refOf(deposited[0]) },
558
+ }),
559
+ )
560
+ return event
561
+ }
562
+
563
+ case 'finalize': {
564
+ const description = await store.getArtifact(env.build, 'pr-description')
565
+ if (description === null) {
566
+ throw new Error(
567
+ "finalize's 'ab done' requires a deposited pr-description artifact — " +
568
+ "run 'ab artifact put pr-description <file>' first (D5, §8.3). " +
569
+ 'Its first line becomes the PR title; the rest is the body.',
570
+ )
571
+ }
572
+ const text = textContent(description)
573
+ const newline = text.indexOf('\n')
574
+ const firstLine = (newline === -1 ? text : text.slice(0, newline))
575
+ .replace(/^#+\s*/, '')
576
+ .trim()
577
+ const body = newline === -1 ? '' : text.slice(newline + 1).replace(/^\n+/, '')
578
+ if (firstLine === '') {
579
+ throw new Error(
580
+ 'the pr-description artifact has an empty first line — it becomes the PR title',
581
+ )
582
+ }
583
+ const baseBranch = baseBranchOf(events)
584
+ const branch = await buildBranch(deps)
585
+ if (remotePublication(events)) {
586
+ await assertCleanWorktree(deps)
587
+ const head = await git(deps, ['rev-parse', '--verify', 'HEAD^{commit}'])
588
+ return await store.append(env.build, {
589
+ actor,
590
+ type: 'publication.requested',
591
+ payload: {
592
+ operation: 'finalize',
593
+ branch,
594
+ sha: head.trim(),
595
+ description: { kind: description.meta.kind, rev: description.meta.revision },
596
+ },
597
+ })
598
+ }
599
+ return completeFinalizePr(deps, events, {
600
+ branch,
601
+ baseBranch,
602
+ title: firstLine,
603
+ body,
604
+ mergeMessage: text,
605
+ })
606
+ }
607
+
608
+ case 'reconcile': {
609
+ const notesPath = requireNotes(opts.notes, "reconcile's 'ab done' deposits reconcile-notes")
610
+ const notes = await readTextFile(notesPath, '--notes')
611
+ await assertCleanWorktree(deps)
612
+ // The resolution lands as a merge commit (§15.7) — HEAD must have 2+
613
+ // parents (base merged into the branch, never rebase — D1).
614
+ const parentsLine = (await git(deps, ['rev-list', '--parents', '-n', '1', 'HEAD'])).trim()
615
+ const parts = parentsLine.split(/\s+/)
616
+ const head = parts[0]!
617
+ if (parts.length < 3) {
618
+ throw new Error(
619
+ `reconcile's 'ab done' requires HEAD to be a merge commit (2+ parents) — ` +
620
+ `HEAD ${head} has ${parts.length - 1} parent(s); merge the base branch ` +
621
+ 'into this branch first (§15.7, D1: never rebase)',
622
+ )
623
+ }
624
+ const scratchPaths = await reconcileScratchViolations(
625
+ deps.exec,
626
+ deps.workspacePath,
627
+ head,
628
+ parts.slice(1),
629
+ )
630
+ if (scratchPaths.length > 0) throw phaseScratchRejection(scratchPaths)
631
+ const branch = await buildBranch(deps)
632
+ if (remotePublication(events)) {
633
+ const { event } = await store.appendWithArtifacts(
634
+ env.build,
635
+ [{ kind: 'reconcile-notes', content: notes }],
636
+ (deposited) => ({
637
+ actor,
638
+ type: 'publication.requested',
639
+ payload: {
640
+ operation: 'reconcile',
641
+ branch,
642
+ sha: head,
643
+ artifact: refOf(deposited[0]),
644
+ },
645
+ }),
646
+ )
647
+ return event
648
+ }
649
+ // Regular push, NEVER force (D1): the merge commit extends the branch.
650
+ await deps.forge.pushBranch(deps.workspacePath, branch)
651
+ const { event } = await store.appendWithArtifacts(
652
+ env.build,
653
+ [{ kind: 'reconcile-notes', content: notes }],
654
+ (deposited) => ({
655
+ actor,
656
+ type: 'reconcile.completed',
657
+ payload: { mergeCommit: head, artifact: refOf(deposited[0]) },
658
+ }),
659
+ )
660
+ return event
661
+ }
662
+
663
+ default:
664
+ throw new Error(`'ab done' has no plumbing for phase "${env.phase}"`)
665
+ }
666
+ }
667
+
668
+ // ── ab verdict (review + agent-verify phases — D5/D6) ────────────────────────
669
+
670
+ export interface VerdictOpts {
671
+ verdict: string
672
+ /** Notes file — review phases: required, deposited as the phase's artifact;
673
+ * agent-verify pass: optional, deposited as `verify-report:<step>`. */
674
+ notes?: string
675
+ /** FindingDraft[] JSON file (revise only). */
676
+ findings?: string
677
+ /** Escalation question, or the required explanation for a verify skip. */
678
+ reason?: string
679
+ /** Failure report file (agent-verify fail — required). */
680
+ report?: string
681
+ }
682
+
683
+ const FINDING_DRAFT_SHAPE = [
684
+ '[',
685
+ ' {',
686
+ ' "severity": "blocking" | "important" | "minor",',
687
+ ' "summary": "<one-line defect statement>",',
688
+ ' "file": "src/…", // optional',
689
+ ' "lines": [40, 62], // optional',
690
+ ' "detail": "…", // optional',
691
+ ' "persists": ["f_1c22"] // optional: prior-round finding ids this one continues (§15.4)',
692
+ ' }',
693
+ ']',
694
+ ].join('\n')
695
+
696
+ export async function verdict(deps: TerminalDeps, opts: VerdictOpts): Promise<EventEnvelope[]> {
697
+ const { env, store } = deps
698
+ const spec = phaseSpecFor(env.phase)
699
+ if (spec.kind === 'producer') {
700
+ throw new Error(
701
+ `'ab verdict' is not ${env.phase}'s terminal — ${env.phase} is a ` +
702
+ `producer phase; use 'ab done' (§8.2)`,
703
+ )
704
+ }
705
+ // The verdict vocabulary is phase-dependent and enforced here (§8.2):
706
+ // review phases accept approve|revise|escalate; agent-verify steps accept
707
+ // pass|fail|skip.
708
+ const vocabulary = spec.verdictVocabulary ?? []
709
+ if (!vocabulary.includes(opts.verdict)) {
710
+ throw new Error(
711
+ `verdict "${opts.verdict}" is not in ${env.phase}'s vocabulary — §8.2: ` +
712
+ 'review phases accept approve|revise|escalate; agent-verify steps ' +
713
+ `accept pass|fail|skip. ${env.phase} accepts: ${vocabulary.join('|')}`,
714
+ )
715
+ }
716
+ const events = await store.getEvents(env.build)
717
+ assertNoPriorTerminal(events, env)
718
+ const actor = agentActor(env.phase, env.session)
719
+
720
+ if (spec.kind === 'agent-verify') {
721
+ return agentVerifyVerdict(deps, opts, actor)
722
+ }
723
+ return reviewVerdict(deps, opts, events, actor)
724
+ }
725
+
726
+ async function reviewVerdict(
727
+ deps: TerminalDeps,
728
+ opts: VerdictOpts,
729
+ events: AbEvent[],
730
+ actor: ReturnType<typeof agentActor>,
731
+ ): Promise<EventEnvelope[]> {
732
+ const { env, store } = deps
733
+ const spec = phaseSpecFor(env.phase)
734
+ const kind = spec.requiredArtifact
735
+ if (kind === undefined) {
736
+ throw new Error(`review phase "${env.phase}" declares no artifact kind — phase table bug`)
737
+ }
738
+ if (opts.notes === undefined) {
739
+ throw new Error(
740
+ `'ab verdict ${opts.verdict}' in ${env.phase} requires --notes <file> — ` +
741
+ `the notes are deposited as the ${kind} artifact (D6, §8.3)`,
742
+ )
743
+ }
744
+ const notes = await readTextFile(opts.notes, '--notes')
745
+
746
+ let findings: Finding[] = []
747
+ let reason: string | undefined
748
+ if (opts.verdict === 'revise') {
749
+ if (opts.findings === undefined) {
750
+ throw new Error(
751
+ `'ab verdict revise' requires --findings <json file> with the structured ` +
752
+ `findings (D6). Expected shape:\n${FINDING_DRAFT_SHAPE}`,
753
+ )
754
+ }
755
+ findings = await parseFindings(deps, opts.findings, events)
756
+ }
757
+ if (opts.verdict === 'escalate') {
758
+ if (opts.reason === undefined || opts.reason.trim() === '') {
759
+ throw new Error(
760
+ `'ab verdict escalate' requires --reason <text> — it becomes the ` +
761
+ 'escalation question a human answers (§8.2, §11)',
762
+ )
763
+ }
764
+ reason = opts.reason
765
+ }
766
+
767
+ const type: 'plan-review.verdict' | 'code-review.verdict' =
768
+ env.phase === 'plan-review' ? 'plan-review.verdict' : 'code-review.verdict'
769
+ // Atomic bundle (D6): notes artifact + verdict event in one operation — no
770
+ // state where the artifact exists without its event or vice versa.
771
+ const { event } = await store.appendWithArtifacts(
772
+ env.build,
773
+ [{ kind, content: notes }],
774
+ (deposited) => ({
775
+ actor,
776
+ type,
777
+ payload: {
778
+ round: env.round,
779
+ verdict: opts.verdict as ReviewVerdictKind,
780
+ findings,
781
+ artifact: refOf(deposited[0]),
782
+ ...(reason !== undefined ? { reason } : {}),
783
+ },
784
+ }),
785
+ )
786
+ const out: EventEnvelope[] = [event]
787
+
788
+ if (opts.verdict === 'escalate') {
789
+ // Two appends: the verdict, then the escalation. A crash between them
790
+ // leaves a verdict{escalate} without its escalation.raised — the engine
791
+ // repairs that gap on resume (state is a reduction of events, §15.5).
792
+ out.push(
793
+ await store.append(env.build, {
794
+ actor,
795
+ type: 'escalation.raised',
796
+ payload: {
797
+ id: deps.ids('esc'),
798
+ phase: env.phase,
799
+ round: env.round,
800
+ source: 'agent',
801
+ question: reason!,
802
+ },
803
+ }),
804
+ )
805
+ }
806
+ return out
807
+ }
808
+
809
+ /**
810
+ * Render findings-schema issues without discarding an untagged union's branch
811
+ * detail. Kept separate from parsing because the current accepted findings
812
+ * schema has no such union; this is the presentation contract for future ones.
813
+ */
814
+ export function renderFindingsIssues(issues: readonly z.core.$ZodIssue[]): string {
815
+ return expandIssues(issues)
816
+ .map((issue) => ` ${issue.path.join('.') || '(root)'}: ${issue.message}`)
817
+ .join('\n')
818
+ }
819
+
820
+ /**
821
+ * Parse, validate (D6: failures return the schema shape and the precise
822
+ * issue), check `persists` against prior rounds' finding ids for this loop,
823
+ * and stamp kernel-assigned ids (§15.4 — agents never self-assign).
824
+ */
825
+ async function parseFindings(
826
+ deps: TerminalDeps,
827
+ path: string,
828
+ events: AbEvent[],
829
+ ): Promise<Finding[]> {
830
+ const raw = await readTextFile(path, '--findings')
831
+ let json: unknown
832
+ try {
833
+ json = JSON.parse(raw)
834
+ } catch (error) {
835
+ const message = error instanceof Error ? error.message : String(error)
836
+ throw new Error(
837
+ `--findings ${path} is not valid JSON: ${message}\n` +
838
+ `Expected shape (D6):\n${FINDING_DRAFT_SHAPE}`,
839
+ )
840
+ }
841
+ const parsed = z.array(findingDraftSchema).safeParse(json)
842
+ if (!parsed.success) {
843
+ const issues = renderFindingsIssues(parsed.error.issues)
844
+ throw new Error(
845
+ `--findings ${path} does not match the finding schema (D6):\n${issues}\n` +
846
+ `Expected shape:\n${FINDING_DRAFT_SHAPE}`,
847
+ )
848
+ }
849
+
850
+ // `persists` may only reference ids from prior rounds of THIS loop (§15.4;
851
+ // ids are deposit-stamped, so a reviewer can only ever see rounds it was
852
+ // shown — §8.3).
853
+ const state = reduceBuild(events)
854
+ const loop =
855
+ deps.env.phase === 'plan-review'
856
+ ? state.reviewFindings.planReview
857
+ : state.reviewFindings.codeReview
858
+ const known = new Set<string>()
859
+ loop.forEach((roundFindings, index) => {
860
+ if (index + 1 < deps.env.round) {
861
+ for (const finding of roundFindings) known.add(finding.id)
862
+ }
863
+ })
864
+ for (const draft of parsed.data) {
865
+ for (const id of draft.persists) {
866
+ if (!known.has(id)) {
867
+ throw new Error(
868
+ `persists id "${id}" does not exist in prior rounds' findings for this ` +
869
+ `loop — known ids: ${
870
+ known.size > 0
871
+ ? [...known].join(', ')
872
+ : '(none — round 1 findings cannot persist anything)'
873
+ }`,
874
+ )
875
+ }
876
+ }
877
+ }
878
+
879
+ return parsed.data.map((draft) => ({ ...draft, id: deps.ids('f') }))
880
+ }
881
+
882
+ async function agentVerifyVerdict(
883
+ deps: TerminalDeps,
884
+ opts: VerdictOpts,
885
+ actor: ReturnType<typeof agentActor>,
886
+ ): Promise<EventEnvelope[]> {
887
+ const { env, store } = deps
888
+ if (!isVerifyPhase(env.phase)) {
889
+ throw new Error(`agent-verify verdict outside a verify phase: "${env.phase}"`)
890
+ }
891
+ const step = verifyStep(env.phase)
892
+ const reportKind = verifyReportKind(step)
893
+
894
+ if (opts.verdict === 'pass') {
895
+ // Optional --notes are deposited as the step's report kind and the event
896
+ // carries the ref — provenance for a pass is welcome, just not required.
897
+ if (opts.notes !== undefined) {
898
+ const notes = await readTextFile(opts.notes, '--notes')
899
+ const { event } = await store.appendWithArtifacts(
900
+ env.build,
901
+ [{ kind: reportKind, content: notes }],
902
+ (deposited) => ({
903
+ actor,
904
+ type: 'verify.completed',
905
+ payload: {
906
+ step,
907
+ attempt: env.round,
908
+ outcome: 'pass',
909
+ report: refOf(deposited[0]),
910
+ },
911
+ }),
912
+ )
913
+ return [event]
914
+ }
915
+ return [
916
+ await store.append(env.build, {
917
+ actor,
918
+ type: 'verify.completed',
919
+ payload: { step, attempt: env.round, outcome: 'pass' },
920
+ }),
921
+ ]
922
+ }
923
+
924
+ if (opts.verdict === 'skip') {
925
+ const reason = opts.reason?.trim()
926
+ if (reason === undefined || reason === '') {
927
+ throw new Error(
928
+ `'ab verdict skip' requires --reason <text> — a skipped verification ` +
929
+ 'must leave a human-readable reason in the event log',
930
+ )
931
+ }
932
+ return [
933
+ await store.append(env.build, {
934
+ actor,
935
+ type: 'verify.completed',
936
+ payload: { step, attempt: env.round, outcome: 'skipped', reason },
937
+ }),
938
+ ]
939
+ }
940
+
941
+ // fail: the report is what routes back into the code loop (§5) — required.
942
+ if (opts.report === undefined) {
943
+ throw new Error(
944
+ `'ab verdict fail' requires --report <file> — the failure report is ` +
945
+ `deposited as ${reportKind} and routed back to implement (§5, D6)`,
946
+ )
947
+ }
948
+ const report = await readTextFile(opts.report, '--report')
949
+ const { event } = await store.appendWithArtifacts(
950
+ env.build,
951
+ [{ kind: reportKind, content: report }],
952
+ (deposited) => ({
953
+ actor,
954
+ type: 'verify.completed',
955
+ payload: {
956
+ step,
957
+ attempt: env.round,
958
+ outcome: 'fail',
959
+ report: refOf(deposited[0]),
960
+ },
961
+ }),
962
+ )
963
+ return [event]
964
+ }
965
+
966
+ // ── ab escalate (any phase — D5, §11) ────────────────────────────────────────
967
+
968
+ export interface EscalateOpts {
969
+ question: string
970
+ refs?: string[]
971
+ }
972
+
973
+ export async function escalate(
974
+ deps: TerminalDeps,
975
+ opts: EscalateOpts,
976
+ ): Promise<EventEnvelope<'escalation.raised'>> {
977
+ const { env, store } = deps
978
+ if (opts.question.trim() === '') {
979
+ throw new Error("'ab escalate' requires a question — it is what the human answers (§11)")
980
+ }
981
+ const events = await store.getEvents(env.build)
982
+ assertNoPriorTerminal(events, env)
983
+ return store.append(env.build, {
984
+ actor: agentActor(env.phase, env.session),
985
+ type: 'escalation.raised',
986
+ payload: {
987
+ id: deps.ids('esc'),
988
+ phase: env.phase,
989
+ round: env.round,
990
+ source: 'agent',
991
+ question: opts.question,
992
+ ...(opts.refs !== undefined && opts.refs.length > 0 ? { refs: opts.refs } : {}),
993
+ },
994
+ })
995
+ }