@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,1047 @@
1
+ /**
2
+ * The kernel pipeline engine (SPEC §8): "read the event log, decide the next
3
+ * phase per the transition table, invoke the AgentRunner with `/{skill}
4
+ * {build}`, wait for the terminal event, repeat." `decideNext` IS that
5
+ * transition table — the determinism half of the constitution (§2.1): agents
6
+ * never decide phase transitions; this pure, total function does. Same events
7
+ * + config → same decision, and any prefix of a valid log decides — which is
8
+ * why resumability is not a feature (§2.2): re-deciding over the log IS the
9
+ * resume path (§15.6-C), and a fresh sandbox resumes a dead sandbox's build
10
+ * by pulling events and asking the same question (§7.4).
11
+ *
12
+ * The engine performs no I/O and appends nothing. It returns a Decision; the
13
+ * build-runner executes it (starts sessions, runs check commands, appends the
14
+ * events execution produces) and asks again. Escalation raising is itself a
15
+ * decision (`raise-escalation`), so the stall/policy thresholds live here —
16
+ * §15.4's split made code: judgment marks `persists`, determinism applies the
17
+ * threshold.
18
+ *
19
+ * Projection policy: `reduceBuild` is used for everything it already projects
20
+ * (status, pending commands, escalations, spec rev, prState, reconcile
21
+ * attempts). The engine drops to raw events only where the reducer lacks a
22
+ * projection; each such spot is a documented field of `LogIndex` below.
23
+ */
24
+ import type { Config } from '../config/schema'
25
+ import type { AbEvent } from '../events/catalog'
26
+ import { normalizeVerifyCompletion, type EventPayload } from '../events/payloads'
27
+ import {
28
+ isVerifyPhase,
29
+ verifyPhase,
30
+ type ArtifactRef,
31
+ type CorePhase,
32
+ type EscalationSource,
33
+ type EscalationTarget,
34
+ type Feedback,
35
+ type Finding,
36
+ type Phase,
37
+ type PolicyEscalationCause,
38
+ type ReviewVerdictKind,
39
+ type VerifyOutcome,
40
+ } from '../ontology'
41
+ import { reduceBuild, type AnsweredEscalation } from './reducer'
42
+ import { stalledChains, type FindingChain } from './stall'
43
+
44
+ // ── The decision contract ────────────────────────────────────────────────────
45
+
46
+ export type WaitReason = 'blocked' | 'paused' | 'awaiting-spec' | 'awaiting-pr' | 'done' | 'aborted'
47
+
48
+ export type VerifyAction =
49
+ | { kind: 'run-check'; step: string; command: string; attempt: number }
50
+ | {
51
+ kind: 'run-agent-verify'
52
+ step: string
53
+ skill: string
54
+ attempt: number
55
+ feedback?: Feedback
56
+ }
57
+
58
+ export type FinalizeAction = { kind: 'check'; command: string } | { kind: 'agent'; skill: string }
59
+
60
+ export type Decision =
61
+ | { kind: 'wait'; reason: WaitReason }
62
+ | { kind: 'acknowledge'; command: 'pause' | 'resume' | 'abort' }
63
+ | {
64
+ kind: 'run-phase'
65
+ phase: CorePhase
66
+ round: number
67
+ feedback?: Feedback
68
+ /** Present iff phase === 'reconcile'; execution resolves its base (§15.7). */
69
+ reconcile?: { attempt: number }
70
+ }
71
+ | VerifyAction
72
+ | {
73
+ /** Kernel-side path gating to resolve against the live Git diff. */
74
+ kind: 'evaluate-verify'
75
+ step: string
76
+ attempt: number
77
+ paths: string[]
78
+ action: VerifyAction
79
+ }
80
+ | {
81
+ /** Kernel-authored exclusion that launches no verifier work. */
82
+ kind: 'skip-verify'
83
+ step: string
84
+ attempt: number
85
+ reason: string
86
+ }
87
+ | { kind: 'run-finalize-step'; step: string; action: FinalizeAction }
88
+ | {
89
+ /** Authoritative repeat-conflict observation before policy routing. */
90
+ kind: 'check-reconcile-progress'
91
+ conflictSeq: number
92
+ completedAttempt: number
93
+ nextAttempt: number
94
+ }
95
+ | {
96
+ kind: 'raise-escalation'
97
+ source: 'agent' | 'stall'
98
+ phase: Phase
99
+ round?: number
100
+ question: string
101
+ refs?: string[]
102
+ }
103
+ | {
104
+ kind: 'raise-escalation'
105
+ source: 'policy'
106
+ policyCause: PolicyEscalationCause
107
+ phase: Phase
108
+ round?: number
109
+ question: string
110
+ refs?: string[]
111
+ }
112
+
113
+ // ── Raw-log index ────────────────────────────────────────────────────────────
114
+ //
115
+ // Every field here is a projection the reducer does not carry (or carries
116
+ // without the seq the engine routes on). Loop and verify progress counts only
117
+ // events with seq > restartSeq (§6.3 immutability: every reviewer approves
118
+ // conformance to ONE spec revision — a drifting spec silently converts
119
+ // approvals into approvals-of-something-else, so approvals of an old spec do
120
+ // not carry across `spec.revised`).
121
+
122
+ interface VerdictRecord {
123
+ seq: number
124
+ round: number
125
+ verdict: ReviewVerdictKind
126
+ findings: Finding[]
127
+ reason?: string
128
+ }
129
+
130
+ interface PlanCompletionRecord {
131
+ seq: number
132
+ artifact: ArtifactRef
133
+ verifySteps?: string[]
134
+ }
135
+
136
+ interface RoundRecord {
137
+ startedSeq?: number
138
+ completedSeq?: number
139
+ /** Plan only. Kept separately so approval snapshots this exact completion. */
140
+ planCompletion?: PlanCompletionRecord
141
+ verdict?: VerdictRecord
142
+ }
143
+
144
+ interface LoopIndex {
145
+ /**
146
+ * Max round in any of this loop's events over the FULL log. Round numbers
147
+ * continue monotonically across spec restarts (next round = max round ever
148
+ * seen + 1) so the log stays unambiguous: "plan round 1" must name exactly
149
+ * one producer run, not one per spec revision.
150
+ */
151
+ maxRoundEver: number
152
+ /** Max round with post-restart events; 0 when the loop is untouched. */
153
+ maxRound: number
154
+ /** Post-restart per-round event records (latest occurrence wins). */
155
+ rounds: Map<number, RoundRecord>
156
+ /** Latest post-restart revise verdict — the findings-feedback source. */
157
+ latestRevise?: VerdictRecord
158
+ /** seq of the latest post-restart approve verdict (verify cycle boundary). */
159
+ latestApproveSeq: number
160
+ /** Plan only: the completion present when the latest approve verdict landed.
161
+ * A later orphan or superseded completion cannot replace this snapshot. */
162
+ approvedPlan?: PlanCompletionRecord
163
+ /** Findings per round (index round-1), post-restart — stalledChains input.
164
+ * The reducer's reviewFindings spans restarts; this one must not. */
165
+ findingsByRound: Finding[][]
166
+ }
167
+
168
+ interface VerifyRecord {
169
+ seq: number
170
+ step: string
171
+ attempt: number
172
+ outcome: VerifyOutcome
173
+ report?: ArtifactRef
174
+ reason?: string
175
+ }
176
+
177
+ interface GuidanceDelivery {
178
+ escalation: string
179
+ /** seq of the matching `session.started` that made the carrier actionable. */
180
+ seq: number
181
+ }
182
+
183
+ interface ReconcileStartRecord {
184
+ seq: number
185
+ attempt: number
186
+ baseSha: string
187
+ }
188
+
189
+ interface ReconcileCompletionRecord extends ReconcileStartRecord {
190
+ completedSeq: number
191
+ }
192
+
193
+ interface ReconcileProgressRecord {
194
+ seq: number
195
+ conflictSeq: number
196
+ attempt: number
197
+ baseSha: string
198
+ }
199
+
200
+ interface LogIndex {
201
+ /** seq of the latest `spec.revised`, else 0 — the restart boundary (§6.3). */
202
+ restartSeq: number
203
+ plan: LoopIndex
204
+ code: LoopIndex
205
+ /** Post-restart `verify.completed` facts with seq, for the same sequence-based
206
+ * cycle boundary query exposed by the reducer (§15.6-A). */
207
+ verifyCompleted: VerifyRecord[]
208
+ /** Post-restart `verify.started` facts — a crashed step re-runs at the SAME
209
+ * attempt (§15.6-C), so the current cycle's attempt must be readable from
210
+ * its start events even before any completion lands. */
211
+ verifyStarted: { seq: number; attempt: number }[]
212
+ /**
213
+ * Max verify attempt in any `verify.started`/`verify.completed` over the
214
+ * FULL log. Attempt numbers continue monotonically across spec restarts and
215
+ * reconcile cycles — the same rationale as LoopIndex.maxRoundEver: the log
216
+ * stays unambiguous ("verify attempt 2" names exactly one cycle) and D5
217
+ * failure keys (verify:<step>, round = attempt) never collide across cycles.
218
+ * This high-water allocates attempt numbers; current-cycle membership is
219
+ * independently sequence-based.
220
+ */
221
+ maxVerifyAttemptEver: number
222
+ /** seq of the latest post-restart `reconcile.completed` — cycle boundary
223
+ * input: reconciliation changed code, verify re-runs in full (§15.7). */
224
+ lastReconcileCompletedSeq: number
225
+ /** seq of the latest post-restart `implement.completed` — a verify failure
226
+ * with an implement round after it was already routed (§15.6-A). */
227
+ lastImplementCompletedSeq: number
228
+ /** Post-restart `finalize.completed` seen (the reducer only projects pr). */
229
+ finalizeCompleted: boolean
230
+ /** Post-restart `finalize.step-completed` steps, ok true OR false —
231
+ * post-steps are failure-tolerant (§5), so any completion counts. */
232
+ finalizeStepsDone: Set<string>
233
+ /** Latest `pr.conflicted` (full log — the epilogue is restart-orthogonal);
234
+ * only its seq is needed for policy/dedupe. Its baseSha is detection-time
235
+ * evidence, not the reconcile merge target (§15.7). */
236
+ lastConflict?: { seq: number }
237
+ /** A `reconcile.started` after lastConflict without its completion — a
238
+ * crashed reconcile re-runs the SAME attempt from its start (§15.6-C). */
239
+ conflictReconcileStarted?: { attempt: number }
240
+ /** Most recent completed reconcile, paired with the latest same-occurrence
241
+ * start so crash retries use the base that actually completed. */
242
+ lastReconcileCompleted?: ReconcileCompletionRecord
243
+ /** Explicit authoritative observation for the current repeat conflict. */
244
+ lastConflictProgressCheck?: ReconcileProgressRecord
245
+ /** Distinct completed attempts observed still conflicted at the same base.
246
+ * A later attempt start supplies the observation for pre-event logs. */
247
+ reconcileNoProgressCount: number
248
+ /** Guidance carriers that reached a matching durable session launch.
249
+ * Plan, implement, and agent-verify starts may cite feedback (§15.3), but a
250
+ * citation alone remains recoverable across the pre-launch crash boundary.
251
+ * Only a later `session.started` for the same phase and round/attempt marks
252
+ * that answer delivered (§15.6-B). */
253
+ guidanceDeliveries: GuidanceDelivery[]
254
+ }
255
+
256
+ export function decideNext(events: AbEvent[], config: Config): Decision {
257
+ const state = reduceBuild(events)
258
+ const log = indexLog(events)
259
+
260
+ // ── 1. Terminal (§15.5: terminal wins, latest wins) ────────────────────────
261
+ if (state.status === 'aborted') return { kind: 'wait', reason: 'aborted' }
262
+ if (state.status === 'done') return { kind: 'wait', reason: 'done' }
263
+
264
+ // ── 2. Operator commands (D2, §15.2.7) ─────────────────────────────────────
265
+ // Requests queue in the log; the kernel acknowledges with fact events, and a
266
+ // dead runner receives its commands on resume. Abort wins over everything
267
+ // below; paused parks every phase decision until resumed (§15.5 precedence:
268
+ // paused wins over blocked, and the escalation is not lost).
269
+ if (state.pendingCommands.some((c) => c.command === 'abort')) {
270
+ return { kind: 'acknowledge', command: 'abort' }
271
+ }
272
+ if (state.status === 'paused') {
273
+ if (state.pendingCommands.some((c) => c.command === 'resume')) {
274
+ return { kind: 'acknowledge', command: 'resume' }
275
+ }
276
+ return { kind: 'wait', reason: 'paused' }
277
+ }
278
+ if (state.pendingCommands.some((c) => c.command === 'pause')) {
279
+ return { kind: 'acknowledge', command: 'pause' }
280
+ }
281
+
282
+ // ── 3. Escalations (§11, §15.6-B) ──────────────────────────────────────────
283
+ // blocked ≡ any open (unanswered) escalation (§15.5).
284
+ if (state.openEscalations.length > 0) return { kind: 'wait', reason: 'blocked' }
285
+ // revise-spec: park until the human lands spec rev N+1 (§6.3); the
286
+ // spec.revised event's seq becomes the restart boundary used everywhere
287
+ // below. guidance and dismiss-finding resolutions do not decide anything
288
+ // here — they route inside the loop rules (feedback priority and stall
289
+ // suppression respectively).
290
+ if (
291
+ state.answeredEscalations.some(
292
+ (e) => e.resolution === 'revise-spec' && e.answeredSeq > log.restartSeq,
293
+ )
294
+ ) {
295
+ return { kind: 'wait', reason: 'awaiting-spec' }
296
+ }
297
+
298
+ // ── 4. Spec (§6.3: dispatch owns the import; `spec` is not a phase §5) ─────
299
+ if (state.specRev === undefined) return { kind: 'wait', reason: 'awaiting-spec' }
300
+
301
+ // ── Shared routing inputs ──────────────────────────────────────────────────
302
+
303
+ // dismiss-finding contributes the raised escalation's refs to the dismissed
304
+ // set (§15.6-B). The reducer already joins escalation.raised (refs) to
305
+ // escalation.answered (resolution) by id, so no raw-event join is needed.
306
+ const dismissedIds = new Set<string>()
307
+ for (const e of state.answeredEscalations) {
308
+ if (e.resolution !== 'dismiss-finding') continue
309
+ for (const ref of e.refs ?? []) dismissedIds.add(ref)
310
+ }
311
+
312
+ // "Has this exact escalation class already landed after seq X?" — open and
313
+ // answered escalations both carry their raise seq, source, and target, so
314
+ // the union is the complete durable deduplication history. Both dimensions
315
+ // must match: a later raise for another source or target cannot repair this
316
+ // condition's crash gap or acknowledge its exhausted budget.
317
+ const allRaised = [...state.openEscalations, ...state.answeredEscalations]
318
+ const raisedAfter = (seq: number, source: EscalationSource, phase: EscalationTarget): boolean =>
319
+ allRaised.some((e) => e.seq > seq && e.source === source && e.phase === phase)
320
+ const reconcileNoProgressRaisedAfter = (seq: number): boolean =>
321
+ allRaised.some(
322
+ (e) =>
323
+ e.seq > seq &&
324
+ e.source === 'policy' &&
325
+ e.phase === 'reconcile' &&
326
+ (e.policyCause === 'reconcile-no-progress' ||
327
+ // Compatibility for durable logs written before policyCause existed.
328
+ // An explicit different cause must never inherit this old meaning.
329
+ (e.policyCause === undefined && e.round === undefined)),
330
+ )
331
+
332
+ // Latest-only guidance for an explicit destination (§15.6-B). Plan/code
333
+ // escalations feed their producer; an agent verifier's own escalation feeds
334
+ // that exact verifier occurrence; policy verify guidance keeps its existing
335
+ // implement destination. A cited *.started payload carries the answer; its
336
+ // matching durable session launch consumes it. Select the destination winner
337
+ // before checking delivery: a newer answer durably supersedes older
338
+ // same-destination answers, so consuming the winner cannot reveal one of them
339
+ // later. An answer appended after that delivery becomes the new winner and
340
+ // remains eligible.
341
+ const guidanceFeedback = (destination: GuidanceDestination): Feedback | undefined => {
342
+ let latest: AnsweredEscalation | undefined
343
+ for (const e of state.answeredEscalations) {
344
+ if (e.resolution !== 'guidance' || guidanceDestination(e) !== destination) continue
345
+ if (latest === undefined || e.answeredSeq > latest.answeredSeq) latest = e
346
+ }
347
+ if (
348
+ latest === undefined ||
349
+ log.guidanceDeliveries.some(
350
+ (delivery) => delivery.escalation === latest.id && delivery.seq > latest.answeredSeq,
351
+ )
352
+ ) {
353
+ return undefined
354
+ }
355
+ return { guidance: { escalation: latest.id, answer: latest.answer } }
356
+ }
357
+
358
+ // Verify cycle (§15.6-A): results only count after the latest of (last
359
+ // code-review approve, last reconcile.completed) — implement or reconcile
360
+ // changed the code, so earlier results describe code that no longer exists
361
+ // and the cycle re-runs from the FIRST step, cheap checks first.
362
+ const cycleBoundary = Math.max(log.code.latestApproveSeq, log.lastReconcileCompletedSeq)
363
+ const cycleResults = log.verifyCompleted.filter((v) => v.seq > cycleBoundary)
364
+ const cycleFails = cycleResults.filter((v) => v.outcome === 'fail')
365
+ const lastCycleFail = cycleFails.at(-1)
366
+ // "Without a subsequent implement round": an implement.completed after the
367
+ // failure means the code loop already picked it up. implement.*started* is
368
+ // deliberately not enough — a crashed fail-routed round must recompute the
369
+ // same verify feedback on re-run (§15.6-C).
370
+ const pendingFail =
371
+ lastCycleFail !== undefined && lastCycleFail.seq > log.lastImplementCompletedSeq
372
+ ? lastCycleFail
373
+ : undefined
374
+
375
+ const findingsFeedback = (loop: LoopIndex): Feedback | undefined =>
376
+ loop.latestRevise === undefined
377
+ ? undefined
378
+ : { findings: loop.latestRevise.findings.map((f) => f.id) }
379
+ // A failure without a report has nothing to materialize (§8.3 routes the
380
+ // report into .ab/verify/); the run-phase decision still fires without it.
381
+ const verifyFeedback = (): Feedback | undefined =>
382
+ pendingFail?.report === undefined
383
+ ? undefined
384
+ : { verify: { step: pendingFail.step, report: pendingFail.report } }
385
+
386
+ // ── 5. Plan loop (§5, §10): rounds pair plan ⇄ plan-review ─────────────────
387
+ const planDecision = decideLoop({
388
+ loop: log.plan,
389
+ producer: 'plan',
390
+ reviewer: 'plan-review',
391
+ policy: config.policy,
392
+ reviewRoundCeiling: state.reviewRoundCeilings.plan,
393
+ dismissedIds,
394
+ raisedAfter,
395
+ // Feedback: unconsumed guidance, else the latest revise verdict's ids.
396
+ producerFeedback: () => guidanceFeedback('plan') ?? findingsFeedback(log.plan),
397
+ })
398
+ if (planDecision !== 'approved') return planDecision
399
+
400
+ // ── 6. Code loop: identical structure, plus verify routing ─────────────────
401
+ const codeDecision = decideLoop({
402
+ loop: log.code,
403
+ producer: 'implement',
404
+ reviewer: 'code-review',
405
+ policy: config.policy,
406
+ reviewRoundCeiling: state.reviewRoundCeilings.code,
407
+ dismissedIds,
408
+ raisedAfter,
409
+ // Feedback priority: guidance > verify failure > findings (§15.6-A/B).
410
+ producerFeedback: () =>
411
+ guidanceFeedback('code') ?? verifyFeedback() ?? findingsFeedback(log.code),
412
+ })
413
+ if (codeDecision !== 'approved') return codeDecision
414
+
415
+ // ── 7. Verify (§5, §15.6-A) — gated on code-review approved ────────────────
416
+ // Exhaustion counts post-restart failures only; pre-restart results
417
+ // (including failures) do not carry across a spec revision (§6.3). Attempt
418
+ // NUMBERS, by contrast, continue monotonically over the full log — see the
419
+ // attempt computation below and LogIndex.maxVerifyAttemptEver.
420
+ const fails = log.verifyCompleted.filter((v) => v.outcome === 'fail')
421
+ const lastFail = fails.at(-1)
422
+ if (
423
+ lastFail !== undefined &&
424
+ fails.length >= config.policy.maxVerifyAttempts &&
425
+ !raisedAfter(lastFail.seq, 'policy', verifyPhase(lastFail.step))
426
+ ) {
427
+ // Exhaustion escalates once per failure: an exact policy raise for this
428
+ // verify target after the last failure (answered or not) suppresses replay;
429
+ // a NEW failure re-arms it.
430
+ return {
431
+ kind: 'raise-escalation',
432
+ source: 'policy',
433
+ policyCause: 'verify-failure-limit',
434
+ phase: verifyPhase(lastFail.step),
435
+ question: `maxVerifyAttempts (${config.policy.maxVerifyAttempts}) exhausted: verify:${lastFail.step} is still failing`,
436
+ }
437
+ }
438
+ if (pendingFail !== undefined) {
439
+ // A pending fail in the current cycle routes back into the code loop
440
+ // (§15.6-A) — it never re-runs the step directly. Guidance outranks the
441
+ // verify report (§15.6-B: a human answer is authoritative feedback).
442
+ const feedback = guidanceFeedback('code') ?? verifyFeedback()
443
+ const round = log.code.maxRound + 1
444
+ return feedback === undefined
445
+ ? { kind: 'run-phase', phase: 'implement', round }
446
+ : { kind: 'run-phase', phase: 'implement', round, feedback }
447
+ }
448
+ // Attempt numbering (§15.6-A): every step in one cycle shares one attempt.
449
+ // A cycle already underway (started or completed events after the boundary)
450
+ // keeps its number — a crashed step re-runs at the SAME attempt (§15.6-C);
451
+ // a fresh cycle takes max attempt ever + 1, monotonic across spec restarts
452
+ // and reconcile cycles exactly like loop rounds (see maxVerifyAttemptEver).
453
+ const cycleStarted = log.verifyStarted.filter((v) => v.seq > cycleBoundary)
454
+ const cycleAttempts = [...cycleResults, ...cycleStarted].map((v) => v.attempt)
455
+ const attempt =
456
+ cycleAttempts.length > 0 ? Math.max(...cycleAttempts) : log.maxVerifyAttemptEver + 1
457
+ const approvedPlan = log.plan.approvedPlan
458
+ const approvedSelection =
459
+ approvedPlan?.verifySteps === undefined
460
+ ? undefined
461
+ : {
462
+ steps: new Set(approvedPlan.verifySteps),
463
+ planRev: approvedPlan.artifact.rev,
464
+ }
465
+ for (const step of config.verify.steps) {
466
+ // First unsatisfied step in the current cycle runs next — only an explicit
467
+ // pass or skip satisfies that step. A failure anywhere was handled above
468
+ // and therefore can never be hidden by another step's skip.
469
+ if (
470
+ cycleResults.some((v) => v.step === step && (v.outcome === 'pass' || v.outcome === 'skipped'))
471
+ ) {
472
+ continue
473
+ }
474
+ const stepConfig = config.verify.stepConfigs[step]
475
+ if (stepConfig === undefined) continue // unreachable: configSchema cross-validates (§16.1)
476
+
477
+ // Missing selection data is the historical default-all behavior. For a
478
+ // current explicit selection, exclusion precedes path evaluation and any
479
+ // verifier construction. Mandatory gates remain selected defensively even
480
+ // if a directly-authored event bypassed the CLI's deposit validation.
481
+ if (
482
+ approvedSelection !== undefined &&
483
+ !approvedSelection.steps.has(step) &&
484
+ stepConfig.always !== true
485
+ ) {
486
+ return {
487
+ kind: 'skip-verify',
488
+ step,
489
+ attempt,
490
+ reason:
491
+ `excluded by approved plan selection (plan@${approvedSelection.planRev}): ` +
492
+ `verify step ${JSON.stringify(step)} was not selected`,
493
+ }
494
+ }
495
+
496
+ const verifierFeedback = guidanceFeedback(verifyPhase(step))
497
+ const action: VerifyAction =
498
+ stepConfig.kind === 'check'
499
+ ? {
500
+ // Resolve the [commands] ref (§16.1) — config validation guarantees
501
+ // it exists; the raw-ref fallback only keeps decideNext total.
502
+ kind: 'run-check',
503
+ step,
504
+ command: config.commands[stepConfig.command] ?? stepConfig.command,
505
+ attempt,
506
+ }
507
+ : {
508
+ kind: 'run-agent-verify',
509
+ step,
510
+ skill: stepConfig.skill,
511
+ attempt,
512
+ ...(verifierFeedback !== undefined ? { feedback: verifierFeedback } : {}),
513
+ }
514
+
515
+ // Omitted paths preserve the historical unconditional behavior. Explicit
516
+ // `always = true` wins even when selectors are present, making the
517
+ // mandatory-gate guard structural rather than dependent on truthiness.
518
+ if (stepConfig.paths === undefined || stepConfig.always === true) return action
519
+ return {
520
+ kind: 'evaluate-verify',
521
+ step,
522
+ attempt,
523
+ paths: stepConfig.paths,
524
+ action,
525
+ }
526
+ }
527
+
528
+ // ── 8. Finalize (§5): all verify steps satisfied in the current cycle ─────
529
+ if (!log.finalizeCompleted) return { kind: 'run-phase', phase: 'finalize', round: 1 }
530
+ for (const step of config.finalize.steps) {
531
+ // Post-steps are independent and failure-tolerant (§5): a completion with
532
+ // ok false still counts — it filed its observation and never re-runs.
533
+ if (log.finalizeStepsDone.has(step)) continue
534
+ const stepConfig = config.finalize.stepConfigs[step]
535
+ if (stepConfig === undefined) continue // unreachable: configSchema cross-validates
536
+ const action: FinalizeAction =
537
+ stepConfig.kind === 'check'
538
+ ? {
539
+ kind: 'check',
540
+ // Resolve the [commands] ref; fallback keeps decideNext total for
541
+ // directly-constructed Config values that bypass validation.
542
+ command: config.commands[stepConfig.command] ?? stepConfig.command,
543
+ }
544
+ : { kind: 'agent', skill: stepConfig.skill }
545
+ return { kind: 'run-finalize-step', step, action }
546
+ }
547
+
548
+ // ── 9. Post-PR epilogue (§15.7): finalize → (conflicted → reconcile →
549
+ // verify:*)* → merged | closed. The dispatcher's janitor emits pr.* and
550
+ // completes the build; the engine only ever runs reconcile here.
551
+ if (state.prState === 'conflicted' && log.lastConflict !== undefined) {
552
+ if (log.conflictReconcileStarted !== undefined) {
553
+ // Crashed reconcile: re-run the SAME attempt from its start (§15.6-C) —
554
+ // the reducer's reconcileAttempts is the kernel's own counter precisely
555
+ // so a re-run does not double-count.
556
+ const crashAttempt = log.conflictReconcileStarted.attempt
557
+ return {
558
+ kind: 'run-phase',
559
+ phase: 'reconcile',
560
+ round: crashAttempt,
561
+ reconcile: { attempt: crashAttempt },
562
+ }
563
+ }
564
+ const nextAttempt = state.reconcileAttempts + 1
565
+ const completed = log.lastReconcileCompleted
566
+ if (completed !== undefined && completed.completedSeq < log.lastConflict.seq) {
567
+ const progress = log.lastConflictProgressCheck
568
+ if (progress === undefined || progress.attempt !== completed.attempt) {
569
+ // A repeat conflict cannot spend policy budget using the janitor's
570
+ // detection-time base. Ask execution for a fresh authoritative fact;
571
+ // replay after that append consumes the same durable decision.
572
+ return {
573
+ kind: 'check-reconcile-progress',
574
+ conflictSeq: log.lastConflict.seq,
575
+ completedAttempt: completed.attempt,
576
+ nextAttempt,
577
+ }
578
+ }
579
+
580
+ const baseUnchanged = progress.baseSha === completed.baseSha
581
+ if (
582
+ baseUnchanged &&
583
+ log.reconcileNoProgressCount >= config.policy.maxReconcileAttempts &&
584
+ !reconcileNoProgressRaisedAfter(log.lastConflict.seq)
585
+ ) {
586
+ // Only reconciles that leave the PR conflicted against the same
587
+ // authoritative base consume this budget. policyCause is this
588
+ // conflict-scoped condition's durable identity; round remains only
589
+ // occurrence scope. A moving-base race always gets another monotonic
590
+ // attempt, even after prior no-progress outcomes.
591
+ return {
592
+ kind: 'raise-escalation',
593
+ source: 'policy',
594
+ policyCause: 'reconcile-no-progress',
595
+ phase: 'reconcile',
596
+ question:
597
+ `maxReconcileAttempts (${config.policy.maxReconcileAttempts}) exhausted: ` +
598
+ 'reconciliation made no progress against an unchanged base',
599
+ }
600
+ }
601
+ }
602
+ return {
603
+ kind: 'run-phase',
604
+ phase: 'reconcile',
605
+ round: nextAttempt,
606
+ reconcile: { attempt: nextAttempt },
607
+ }
608
+ }
609
+ // open → the janitor is watching the PR; merged/closed → the janitor
610
+ // releases the workspace and emits build.completed (§15.7).
611
+ return { kind: 'wait', reason: 'awaiting-pr' }
612
+ }
613
+
614
+ // ── Review-loop transition (rules 5 and 6 share one structure — §10) ─────────
615
+
616
+ interface LoopArgs {
617
+ loop: LoopIndex
618
+ producer: 'plan' | 'implement'
619
+ reviewer: 'plan-review' | 'code-review'
620
+ policy: Config['policy']
621
+ reviewRoundCeiling?: number
622
+ dismissedIds: ReadonlySet<string>
623
+ raisedAfter: (seq: number, source: EscalationSource, phase: EscalationTarget) => boolean
624
+ producerFeedback: () => Feedback | undefined
625
+ }
626
+
627
+ function decideLoop(args: LoopArgs): Decision | 'approved' {
628
+ const {
629
+ loop,
630
+ producer,
631
+ reviewer,
632
+ policy,
633
+ reviewRoundCeiling,
634
+ dismissedIds,
635
+ raisedAfter,
636
+ producerFeedback,
637
+ } = args
638
+ const runProducer = (round: number): Decision => {
639
+ const feedback = producerFeedback()
640
+ return feedback === undefined
641
+ ? { kind: 'run-phase', phase: producer, round }
642
+ : { kind: 'run-phase', phase: producer, round, feedback }
643
+ }
644
+
645
+ // No rounds since the restart boundary: the loop is due. Round numbers
646
+ // continue monotonically across restarts (§6.3): next = max ever seen + 1.
647
+ if (loop.maxRound === 0) return runProducer(loop.maxRoundEver + 1)
648
+
649
+ const round = loop.rounds.get(loop.maxRound)
650
+ const verdict = round?.verdict
651
+
652
+ if (verdict === undefined) {
653
+ if (round?.completedSeq !== undefined) {
654
+ // Producer completed, no verdict yet: the reviewer is due (or crashed
655
+ // mid-round and re-runs from its start — §15.6-C).
656
+ return { kind: 'run-phase', phase: reviewer, round: loop.maxRound }
657
+ }
658
+ // Started-or-due without a completion is a crashed producer: re-run the
659
+ // same round from its start (§15.6-C); the runner owns retry counting,
660
+ // not the engine (§8.4). Feedback is recomputed, never replayed.
661
+ return runProducer(loop.maxRound)
662
+ }
663
+
664
+ if (verdict.verdict === 'approve') return 'approved'
665
+
666
+ if (verdict.verdict === 'escalate') {
667
+ // CLI crash gap: the verdict landed but its escalation.raised did not
668
+ // (§8.5 makes the pair near-atomic; the repair keeps decideNext total).
669
+ if (!raisedAfter(verdict.seq, 'agent', reviewer)) {
670
+ return {
671
+ kind: 'raise-escalation',
672
+ source: 'agent',
673
+ phase: reviewer,
674
+ round: verdict.round,
675
+ question: verdict.reason ?? 'reviewer escalated',
676
+ }
677
+ }
678
+ // The escalation exists and was answered (an open one already returned
679
+ // wait/blocked in rule 3): the loop proceeds to the next producer round;
680
+ // a guidance answer rides producerFeedback (§15.6-B).
681
+ return runProducer(loop.maxRound + 1)
682
+ }
683
+
684
+ // revise — stall check FIRST (§15.4), then the policy round cap (§10), then
685
+ // the next producer round with findings feedback.
686
+ const stalled = liveChains(
687
+ stalledChains(loop.findingsByRound, policy.stallRounds, dismissedIds),
688
+ loop.findingsByRound,
689
+ verdict.round,
690
+ )
691
+ if (stalled.length > 0 && !raisedAfter(verdict.seq, 'stall', reviewer)) {
692
+ // Deepest chain reported; first in root order on ties (converge does the
693
+ // same). Dedupe: raise once per verdict — a stall raise for this reviewer
694
+ // already recorded after this verdict (answered or not) suppresses replay.
695
+ const chain = stalled.reduce((deepest, candidate) =>
696
+ candidate.rounds > deepest.rounds ? candidate : deepest,
697
+ )
698
+ return {
699
+ kind: 'raise-escalation',
700
+ source: 'stall',
701
+ phase: reviewer,
702
+ round: verdict.round,
703
+ question: `finding chain persisted ${chain.rounds} rounds: ${chain.ids.join(' -> ')}`,
704
+ refs: chain.ids,
705
+ }
706
+ }
707
+ const effectiveCeiling = reviewRoundCeiling ?? policy.maxReviewRounds
708
+ const reviewedRounds = [...loop.rounds.values()].filter(
709
+ (record) => record.verdict !== undefined,
710
+ ).length
711
+ if (reviewedRounds >= effectiveCeiling && !raisedAfter(verdict.seq, 'policy', reviewer)) {
712
+ return {
713
+ kind: 'raise-escalation',
714
+ source: 'policy',
715
+ policyCause: 'review-round-limit',
716
+ phase: reviewer,
717
+ round: verdict.round,
718
+ question: `maxReviewRounds (${effectiveCeiling}) exhausted without approval`,
719
+ }
720
+ }
721
+ return runProducer(loop.maxRound + 1)
722
+ }
723
+
724
+ /**
725
+ * §15.4 reads "a chain survives N rounds" as a LIVE streak. `stalledChains`
726
+ * reports the longest historical streak, but a chain the current round's
727
+ * reviewer did not continue was judged resolved by that round's fresh skeptic
728
+ * (see src/kernel/stall.ts's streak-break rationale) — re-raising it would
729
+ * park the build on a disagreement nobody is still having, e.g. on the first
730
+ * revise after a guidance answer settled the chain. Only chains with a member
731
+ * in the round whose verdict is being routed can raise.
732
+ */
733
+ function liveChains(
734
+ chains: FindingChain[],
735
+ findingsByRound: Finding[][],
736
+ round: number,
737
+ ): FindingChain[] {
738
+ const current = new Set((findingsByRound[round - 1] ?? []).map((f) => f.id))
739
+ return chains.filter((chain) => chain.ids.some((id) => current.has(id)))
740
+ }
741
+
742
+ type GuidanceDestination = 'plan' | 'code' | `verify:${string}` | 'other'
743
+
744
+ /**
745
+ * Where an escalation answer is delivered (§15.6-B). Producer/reviewer and
746
+ * policy/stall routes retain their loop producer. The deliberate exception is
747
+ * an agent verifier's own `ab escalate`: its answer returns to the exact
748
+ * verifier that asked, while a policy escalation after verify failures still
749
+ * feeds implement. Finalize/reconcile guidance travels through `ab context`.
750
+ */
751
+ function guidanceDestination(
752
+ escalation: Pick<AnsweredEscalation, 'phase' | 'source'>,
753
+ ): GuidanceDestination {
754
+ const { phase, source } = escalation
755
+ if (phase === 'plan' || phase === 'plan-review') return 'plan'
756
+ if (phase === 'implement' || phase === 'code-review') return 'code'
757
+ if (isVerifyPhase(phase)) return source === 'agent' ? phase : 'code'
758
+ return 'other'
759
+ }
760
+
761
+ // ── Raw-log indexing ─────────────────────────────────────────────────────────
762
+
763
+ type ReviewVerdictPayload = EventPayload<'plan-review.verdict'>
764
+
765
+ function emptyLoop(): LoopIndex {
766
+ return {
767
+ maxRoundEver: 0,
768
+ maxRound: 0,
769
+ rounds: new Map(),
770
+ latestApproveSeq: 0,
771
+ findingsByRound: [],
772
+ }
773
+ }
774
+
775
+ function indexLog(events: AbEvent[]): LogIndex {
776
+ // Pass 1: the restart boundary — seq of the latest spec.revised (§6.3).
777
+ let restartSeq = 0
778
+ for (const event of events) {
779
+ if (event.type === 'spec.revised') restartSeq = event.seq
780
+ }
781
+
782
+ const plan = emptyLoop()
783
+ const code = emptyLoop()
784
+ const verifyCompleted: VerifyRecord[] = []
785
+ const verifyStarted: { seq: number; attempt: number }[] = []
786
+ let maxVerifyAttemptEver = 0
787
+ const finalizeStepsDone = new Set<string>()
788
+ const guidanceDeliveries: GuidanceDelivery[] = []
789
+ const pendingGuidanceStarts = new Map<string, { escalation: string; seq: number }>()
790
+ let lastReconcileCompletedSeq = 0
791
+ let lastImplementCompletedSeq = 0
792
+ let finalizeCompleted = false
793
+ let lastConflict: { seq: number } | undefined
794
+ let conflictReconcileStarted: { attempt: number } | undefined
795
+ const conflictSeqs = new Set<number>()
796
+ const reconcileStarts: ReconcileStartRecord[] = []
797
+ const reconcileCompletions: ReconcileCompletionRecord[] = []
798
+ const reconcileProgressChecks: ReconcileProgressRecord[] = []
799
+ let activeReconcileStart: ReconcileStartRecord | undefined
800
+
801
+ /** Track a loop round: maxRoundEver over the full log; the per-round record
802
+ * only for post-restart events (returns undefined pre-restart). */
803
+ const roundRecord = (loop: LoopIndex, r: number, post: boolean): RoundRecord | undefined => {
804
+ if (r > loop.maxRoundEver) loop.maxRoundEver = r
805
+ if (!post) return undefined
806
+ if (r > loop.maxRound) loop.maxRound = r
807
+ let record = loop.rounds.get(r)
808
+ if (record === undefined) {
809
+ record = {}
810
+ loop.rounds.set(r, record)
811
+ }
812
+ return record
813
+ }
814
+
815
+ const noteGuidanceStart = (key: string, feedback: Feedback | undefined, seq: number): void => {
816
+ // A newer start is the authoritative carrier for this exact occurrence.
817
+ // In particular, a guidance-free retry must not let its later session
818
+ // consume an older citation that never launched.
819
+ if (feedback !== undefined && 'guidance' in feedback) {
820
+ pendingGuidanceStarts.set(key, { escalation: feedback.guidance.escalation, seq })
821
+ } else {
822
+ pendingGuidanceStarts.delete(key)
823
+ }
824
+ }
825
+
826
+ const noteVerdict = (
827
+ loop: LoopIndex,
828
+ payload: ReviewVerdictPayload,
829
+ seq: number,
830
+ post: boolean,
831
+ ): void => {
832
+ const record = roundRecord(loop, payload.round, post)
833
+ if (record === undefined) return
834
+ const verdict: VerdictRecord = {
835
+ seq,
836
+ round: payload.round,
837
+ verdict: payload.verdict,
838
+ findings: payload.findings,
839
+ reason: payload.reason,
840
+ }
841
+ record.verdict = verdict
842
+ if (payload.verdict === 'revise') loop.latestRevise = verdict
843
+ if (payload.verdict === 'approve') {
844
+ loop.latestApproveSeq = seq
845
+ // Snapshot at verdict time. A plan.completed appended later — even for
846
+ // this round — was never reviewed and therefore has no authority.
847
+ if (loop === plan) loop.approvedPlan = record.planCompletion
848
+ }
849
+ // Findings per round, reducer-style padding (rounds without verdicts stay
850
+ // empty — including every pre-restart round, which is the point).
851
+ while (loop.findingsByRound.length < payload.round) loop.findingsByRound.push([])
852
+ loop.findingsByRound[payload.round - 1] = payload.findings
853
+ }
854
+
855
+ for (const event of events) {
856
+ const post = event.seq > restartSeq
857
+ switch (event.type) {
858
+ case 'plan.started': {
859
+ noteGuidanceStart(`plan@${event.payload.round}`, event.payload.feedback, event.seq)
860
+ const record = roundRecord(plan, event.payload.round, post)
861
+ if (record !== undefined) record.startedSeq = event.seq
862
+ break
863
+ }
864
+ case 'plan.completed': {
865
+ const record = roundRecord(plan, event.payload.round, post)
866
+ if (record !== undefined) {
867
+ record.completedSeq = event.seq
868
+ record.planCompletion = {
869
+ seq: event.seq,
870
+ artifact: event.payload.artifact,
871
+ ...(event.payload.verifySteps !== undefined
872
+ ? { verifySteps: [...event.payload.verifySteps] }
873
+ : {}),
874
+ }
875
+ }
876
+ break
877
+ }
878
+ case 'plan-review.started':
879
+ roundRecord(plan, event.payload.round, post)
880
+ break
881
+ case 'plan-review.verdict':
882
+ noteVerdict(plan, event.payload, event.seq, post)
883
+ break
884
+
885
+ case 'implement.started': {
886
+ noteGuidanceStart(`implement@${event.payload.round}`, event.payload.feedback, event.seq)
887
+ const record = roundRecord(code, event.payload.round, post)
888
+ if (record !== undefined) record.startedSeq = event.seq
889
+ break
890
+ }
891
+ case 'implement.completed': {
892
+ const record = roundRecord(code, event.payload.round, post)
893
+ if (record !== undefined) record.completedSeq = event.seq
894
+ if (post) lastImplementCompletedSeq = event.seq
895
+ break
896
+ }
897
+ case 'code-review.started':
898
+ roundRecord(code, event.payload.round, post)
899
+ break
900
+ case 'code-review.verdict':
901
+ noteVerdict(code, event.payload, event.seq, post)
902
+ break
903
+
904
+ case 'verify.started': {
905
+ noteGuidanceStart(
906
+ `${verifyPhase(event.payload.step)}@${event.payload.attempt}`,
907
+ event.payload.feedback,
908
+ event.seq,
909
+ )
910
+ maxVerifyAttemptEver = Math.max(maxVerifyAttemptEver, event.payload.attempt)
911
+ if (post) verifyStarted.push({ seq: event.seq, attempt: event.payload.attempt })
912
+ break
913
+ }
914
+ case 'session.started': {
915
+ if (event.payload.round === undefined) break
916
+ const key = `${event.payload.phase}@${event.payload.round}`
917
+ const carrier = pendingGuidanceStarts.get(key)
918
+ if (carrier !== undefined && carrier.seq < event.seq) {
919
+ guidanceDeliveries.push({ escalation: carrier.escalation, seq: event.seq })
920
+ pendingGuidanceStarts.delete(key)
921
+ }
922
+ break
923
+ }
924
+ case 'verify.completed': {
925
+ const result = normalizeVerifyCompletion(event.payload)
926
+ maxVerifyAttemptEver = Math.max(maxVerifyAttemptEver, result.attempt)
927
+ if (post) {
928
+ verifyCompleted.push({
929
+ seq: event.seq,
930
+ step: result.step,
931
+ attempt: result.attempt,
932
+ outcome: result.outcome,
933
+ ...(result.report !== undefined ? { report: result.report } : {}),
934
+ ...(result.reason !== undefined ? { reason: result.reason } : {}),
935
+ })
936
+ }
937
+ break
938
+ }
939
+
940
+ case 'finalize.completed':
941
+ if (post) finalizeCompleted = true
942
+ break
943
+ case 'finalize.step-completed':
944
+ if (post) finalizeStepsDone.add(event.payload.step)
945
+ break
946
+
947
+ case 'pr.conflicted':
948
+ lastConflict = { seq: event.seq }
949
+ conflictSeqs.add(event.seq)
950
+ conflictReconcileStarted = undefined
951
+ break
952
+ case 'reconcile.progress-checked':
953
+ reconcileProgressChecks.push({ seq: event.seq, ...event.payload })
954
+ break
955
+ case 'reconcile.started': {
956
+ const start = { seq: event.seq, ...event.payload }
957
+ reconcileStarts.push(start)
958
+ activeReconcileStart = start
959
+ if (lastConflict !== undefined && event.seq > lastConflict.seq) {
960
+ conflictReconcileStarted = { attempt: event.payload.attempt }
961
+ }
962
+ break
963
+ }
964
+ case 'reconcile.completed':
965
+ if (post) lastReconcileCompletedSeq = event.seq
966
+ if (activeReconcileStart !== undefined) {
967
+ reconcileCompletions.push({
968
+ ...activeReconcileStart,
969
+ completedSeq: event.seq,
970
+ })
971
+ activeReconcileStart = undefined
972
+ }
973
+ conflictReconcileStarted = undefined
974
+ break
975
+
976
+ default:
977
+ break
978
+ }
979
+ }
980
+
981
+ // One completed occurrence per monotonic attempt. A later duplicate
982
+ // completion is safer to pair with its own latest start than with an older
983
+ // aggregate high-water, and malformed unmatched completions classify nothing.
984
+ const completedByAttempt = new Map<number, ReconcileCompletionRecord>()
985
+ for (const completion of reconcileCompletions) {
986
+ completedByAttempt.set(completion.attempt, completion)
987
+ }
988
+ const completed = [...completedByAttempt.values()].sort(
989
+ (left, right) => left.completedSeq - right.completedSeq,
990
+ )
991
+
992
+ let reconcileNoProgressCount = 0
993
+ for (const completion of completed) {
994
+ const nextStart = reconcileStarts.find(
995
+ (start) => start.seq > completion.completedSeq && start.attempt > completion.attempt,
996
+ )
997
+ const observationLimit = nextStart?.seq ?? Number.POSITIVE_INFINITY
998
+ // Explicit checks win over the historical fallback. Use the latest one
999
+ // that could have routed this next occurrence; duplicate conflict facts do
1000
+ // not count one completed reconcile more than once.
1001
+ const explicit = reconcileProgressChecks
1002
+ .filter(
1003
+ (check) =>
1004
+ check.attempt === completion.attempt &&
1005
+ check.seq > completion.completedSeq &&
1006
+ check.seq < observationLimit &&
1007
+ check.conflictSeq > completion.completedSeq &&
1008
+ check.seq > check.conflictSeq &&
1009
+ conflictSeqs.has(check.conflictSeq),
1010
+ )
1011
+ .at(-1)
1012
+ const observedBase = explicit?.baseSha ?? nextStart?.baseSha
1013
+ if (observedBase === completion.baseSha) reconcileNoProgressCount += 1
1014
+ }
1015
+
1016
+ const lastReconcileCompleted = completed.at(-1)
1017
+ const lastConflictProgressCheck =
1018
+ lastConflict === undefined || lastReconcileCompleted === undefined
1019
+ ? undefined
1020
+ : reconcileProgressChecks
1021
+ .filter(
1022
+ (check) =>
1023
+ check.conflictSeq === lastConflict.seq &&
1024
+ check.attempt === lastReconcileCompleted.attempt &&
1025
+ check.seq > lastConflict.seq,
1026
+ )
1027
+ .at(-1)
1028
+
1029
+ return {
1030
+ restartSeq,
1031
+ plan,
1032
+ code,
1033
+ verifyCompleted,
1034
+ verifyStarted,
1035
+ maxVerifyAttemptEver,
1036
+ lastReconcileCompletedSeq,
1037
+ lastImplementCompletedSeq,
1038
+ finalizeCompleted,
1039
+ finalizeStepsDone,
1040
+ lastConflict,
1041
+ conflictReconcileStarted,
1042
+ lastReconcileCompleted,
1043
+ lastConflictProgressCheck,
1044
+ reconcileNoProgressCount,
1045
+ guidanceDeliveries,
1046
+ }
1047
+ }