@defrex/autobuild 0.9.0 → 0.10.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 (82) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +10 -0
  3. package/docs/architecture.md +109 -0
  4. package/docs/configuration.md +24 -3
  5. package/docs/remote-store-protocol.md +35 -0
  6. package/docs/website.md +4 -0
  7. package/package.json +6 -4
  8. package/packages/core/src/cli/dashboard/facts.ts +215 -0
  9. package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
  10. package/packages/core/src/cli/dashboard/model.ts +85 -147
  11. package/packages/core/src/cli/dashboard/poll.ts +7 -2
  12. package/packages/core/src/cli/dispatch-process.ts +1 -13
  13. package/packages/core/src/cli/dispatch.ts +80 -37
  14. package/packages/core/src/cli/help.ts +15 -1
  15. package/packages/core/src/cli/init-validation.ts +18 -0
  16. package/packages/core/src/cli/installation.ts +50 -8
  17. package/packages/core/src/cli/self-update.ts +77 -2
  18. package/packages/core/src/cli/status.ts +1 -1
  19. package/packages/core/src/cli/ticket.ts +146 -1
  20. package/packages/core/src/cli/wait.ts +10 -2
  21. package/packages/core/src/cli/watch.ts +10 -2
  22. package/packages/core/src/config/live.ts +1 -0
  23. package/packages/core/src/config/pipeline-source.ts +17 -5
  24. package/packages/core/src/config/schema.ts +21 -0
  25. package/packages/core/src/integration/harness.ts +113 -1
  26. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
  27. package/packages/core/src/kernel/auto-merge-default.ts +43 -9
  28. package/packages/core/src/kernel/auto-merge.ts +99 -22
  29. package/packages/core/src/kernel/dispatch-settings.ts +42 -17
  30. package/packages/core/src/kernel/dispatch-status.ts +23 -0
  31. package/packages/core/src/kernel/engine.ts +9 -438
  32. package/packages/core/src/kernel/generators/build-log.ts +259 -0
  33. package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
  34. package/packages/core/src/kernel/generators/session-log.ts +72 -0
  35. package/packages/core/src/kernel/harvest.ts +62 -18
  36. package/packages/core/src/kernel/incremental-contract.ts +75 -0
  37. package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
  38. package/packages/core/src/kernel/incremental.ts +53 -0
  39. package/packages/core/src/kernel/log-index.ts +620 -0
  40. package/packages/core/src/kernel/pr-attachments.ts +164 -46
  41. package/packages/core/src/kernel/reducer-registry.ts +164 -0
  42. package/packages/core/src/kernel/reducer.ts +216 -74
  43. package/packages/core/src/operator/query.ts +68 -28
  44. package/packages/core/src/operator/read-view.ts +98 -0
  45. package/packages/core/src/ports/tickets/fake.ts +5 -1
  46. package/packages/core/src/ports/tickets/file.ts +169 -48
  47. package/packages/core/src/ports/tickets/linear.ts +33 -1
  48. package/packages/core/src/ports/tickets/operations.ts +1 -1
  49. package/packages/core/src/ports/tickets/remote.ts +8 -1
  50. package/packages/core/src/ports/types.ts +32 -1
  51. package/packages/core/src/processes/build-execution-state.ts +31 -15
  52. package/packages/core/src/processes/build-runner.ts +184 -90
  53. package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
  54. package/packages/core/src/processes/dispatcher.ts +270 -176
  55. package/packages/core/src/processes/execution-settlement.ts +72 -39
  56. package/packages/core/src/processes/harvest-execution-state.ts +41 -29
  57. package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
  58. package/packages/core/src/processes/orchestrator-tick.ts +44 -3
  59. package/packages/core/src/processes/publication-state.ts +164 -52
  60. package/packages/core/src/processes/repo-view.ts +1436 -0
  61. package/packages/core/src/processes/sandbox-state.ts +26 -2
  62. package/packages/core/src/processes/tick-state.ts +33 -0
  63. package/packages/core/src/store/adapter.ts +10 -1
  64. package/packages/core/src/store/build-scope.ts +26 -0
  65. package/packages/core/src/store/contract.ts +337 -0
  66. package/packages/core/src/store/digest.ts +71 -18
  67. package/packages/core/src/store/local/schema.ts +17 -0
  68. package/packages/core/src/store/local/store.ts +111 -4
  69. package/packages/core/src/store/memory.ts +68 -1
  70. package/packages/core/src/store/phase-session.ts +23 -0
  71. package/packages/core/src/store/remote/client.ts +50 -1
  72. package/packages/core/src/store/remote/index.ts +2 -0
  73. package/packages/core/src/store/remote/protocol.ts +15 -0
  74. package/packages/core/src/store/repo-state-events.ts +72 -7
  75. package/packages/core/src/store/session-handle.ts +30 -0
  76. package/packages/core/src/store/session-reducer.ts +52 -11
  77. package/packages/core/src/store/snapshots.ts +88 -0
  78. package/packages/core/src/store/ticket-assets.ts +41 -8
  79. package/packages/core/src/store/types.ts +38 -0
  80. package/skills/guide/SKILL.md +26 -7
  81. package/skills/guide/references/remote-store-protocol.md +35 -0
  82. package/skills/tickets/SKILL.md +57 -5
@@ -0,0 +1,620 @@
1
+ /**
2
+ * The engine's raw-log index (see `LogIndex`): the projections `reduceBuild`
3
+ * does not carry. Extracted from `engine.ts` as an incremental reducer.
4
+ *
5
+ * The restart boundary (`restartSeq`, the seq of the LAST `spec.revised` in
6
+ * array order) is only known once the whole log has been read, and a later
7
+ * `spec.revised` retroactively demotes events already folded as post-restart.
8
+ * The accumulator therefore has two parts: running state that never depends on
9
+ * the boundary (folded as the loop always did), and an in-array-order candidate
10
+ * list of compact records for every event the post-restart projections read.
11
+ * `finish` replays the candidates against the final `restartSeq`, so any array
12
+ * order — including shuffled — reproduces the whole-array result exactly.
13
+ */
14
+ import type { AbEvent } from '../events/catalog'
15
+ import { normalizeVerifyCompletion, type EventPayload } from '../events/payloads'
16
+ import {
17
+ verifyPhase,
18
+ type ArtifactRef,
19
+ type Feedback,
20
+ type Finding,
21
+ type ReviewVerdictKind,
22
+ type VerifyOutcome,
23
+ } from '../ontology'
24
+ import { defineReducer } from './incremental'
25
+
26
+ // ── Raw-log index ────────────────────────────────────────────────────────────
27
+ //
28
+ // Every field here is a projection the reducer does not carry (or carries
29
+ // without the seq the engine routes on). Loop and verify progress counts only
30
+ // events with seq > restartSeq (§6.3 immutability: every reviewer approves
31
+ // conformance to ONE spec revision — a drifting spec silently converts
32
+ // approvals into approvals-of-something-else, so approvals of an old spec do
33
+ // not carry across `spec.revised`).
34
+
35
+ export interface VerdictRecord {
36
+ seq: number
37
+ round: number
38
+ verdict: ReviewVerdictKind
39
+ findings: Finding[]
40
+ reason?: string
41
+ }
42
+
43
+ export interface PlanCompletionRecord {
44
+ seq: number
45
+ artifact: ArtifactRef
46
+ verifySteps?: string[]
47
+ }
48
+
49
+ export interface RoundRecord {
50
+ startedSeq?: number
51
+ completedSeq?: number
52
+ /** Plan only. Kept separately so approval snapshots this exact completion. */
53
+ planCompletion?: PlanCompletionRecord
54
+ verdict?: VerdictRecord
55
+ }
56
+
57
+ export interface LoopIndex {
58
+ /**
59
+ * Max round in any of this loop's events over the FULL log. Round numbers
60
+ * continue monotonically across spec restarts (next round = max round ever
61
+ * seen + 1) so the log stays unambiguous: "plan round 1" must name exactly
62
+ * one producer run, not one per spec revision.
63
+ */
64
+ maxRoundEver: number
65
+ /** Max round with post-restart events; 0 when the loop is untouched. */
66
+ maxRound: number
67
+ /** Post-restart per-round event records (latest occurrence wins). */
68
+ rounds: Map<number, RoundRecord>
69
+ /** Latest post-restart revise verdict — the findings-feedback source. */
70
+ latestRevise?: VerdictRecord
71
+ /** seq of the latest post-restart approve verdict (verify cycle boundary). */
72
+ latestApproveSeq: number
73
+ /** Plan only: the completion present when the latest approve verdict landed.
74
+ * A later orphan or superseded completion cannot replace this snapshot. */
75
+ approvedPlan?: PlanCompletionRecord
76
+ /** Findings per round (index round-1), post-restart — stalledChains input.
77
+ * The reducer's reviewFindings spans restarts; this one must not. */
78
+ findingsByRound: Finding[][]
79
+ }
80
+
81
+ export interface VerifyRecord {
82
+ seq: number
83
+ step: string
84
+ attempt: number
85
+ outcome: VerifyOutcome
86
+ report?: ArtifactRef
87
+ reason?: string
88
+ }
89
+
90
+ export interface GuidanceDelivery {
91
+ escalation: string
92
+ /** seq of the matching `session.started` that made the carrier actionable. */
93
+ seq: number
94
+ }
95
+
96
+ export interface ReconcileStartRecord {
97
+ seq: number
98
+ attempt: number
99
+ baseSha: string
100
+ }
101
+
102
+ export interface ReconcileCompletionRecord extends ReconcileStartRecord {
103
+ completedSeq: number
104
+ }
105
+
106
+ export interface ReconcileProgressRecord {
107
+ seq: number
108
+ conflictSeq: number
109
+ attempt: number
110
+ baseSha: string
111
+ }
112
+
113
+ export interface LogIndex {
114
+ /** seq of the latest `spec.revised`, else 0 — the restart boundary (§6.3). */
115
+ restartSeq: number
116
+ plan: LoopIndex
117
+ code: LoopIndex
118
+ /** Post-restart `verify.completed` facts with seq, for the same sequence-based
119
+ * cycle boundary query exposed by the reducer (§15.6-A). */
120
+ verifyCompleted: VerifyRecord[]
121
+ /** Post-restart `verify.started` facts — a crashed step re-runs at the SAME
122
+ * attempt (§15.6-C), so the current cycle's attempt must be readable from
123
+ * its start events even before any completion lands. */
124
+ verifyStarted: { seq: number; attempt: number }[]
125
+ /**
126
+ * Max verify attempt in any `verify.started`/`verify.completed` over the
127
+ * FULL log. Attempt numbers continue monotonically across spec restarts and
128
+ * reconcile cycles — the same rationale as LoopIndex.maxRoundEver: the log
129
+ * stays unambiguous ("verify attempt 2" names exactly one cycle) and D5
130
+ * failure keys (verify:<step>, round = attempt) never collide across cycles.
131
+ * This high-water allocates attempt numbers; current-cycle membership is
132
+ * independently sequence-based.
133
+ */
134
+ maxVerifyAttemptEver: number
135
+ /** seq of the latest post-restart `reconcile.completed` — cycle boundary
136
+ * input: reconciliation changed code, verify re-runs in full (§15.7). */
137
+ lastReconcileCompletedSeq: number
138
+ /** seq of the latest post-restart `implement.completed` — a verify failure
139
+ * with an implement round after it was already routed (§15.6-A). */
140
+ lastImplementCompletedSeq: number
141
+ /** Post-restart `finalize.completed` seen (the reducer only projects pr). */
142
+ finalizeCompleted: boolean
143
+ /** Post-restart `finalize.step-completed` steps, ok true OR false —
144
+ * post-steps are failure-tolerant (§5), so any completion counts. */
145
+ finalizeStepsDone: Set<string>
146
+ /** Latest `pr.conflicted` (full log — the epilogue is restart-orthogonal);
147
+ * only its seq is needed for policy/dedupe. Its baseSha is detection-time
148
+ * evidence, not the reconcile merge target (§15.7). */
149
+ lastConflict?: { seq: number }
150
+ /** A `reconcile.started` after lastConflict without its completion — a
151
+ * crashed reconcile re-runs the SAME attempt from its start (§15.6-C). */
152
+ conflictReconcileStarted?: { attempt: number }
153
+ /** Most recent completed reconcile, paired with the latest same-occurrence
154
+ * start so crash retries use the base that actually completed. */
155
+ lastReconcileCompleted?: ReconcileCompletionRecord
156
+ /** Explicit authoritative observation for the current repeat conflict. */
157
+ lastConflictProgressCheck?: ReconcileProgressRecord
158
+ /** Distinct completed attempts observed still conflicted at the same base.
159
+ * A later attempt start supplies the observation for pre-event logs. */
160
+ reconcileNoProgressCount: number
161
+ /** Guidance carriers that reached a matching durable session launch.
162
+ * Plan, implement, and agent-verify starts may cite feedback (§15.3), but a
163
+ * citation alone remains recoverable across the pre-launch crash boundary.
164
+ * Only a later `session.started` for the same phase and round/attempt marks
165
+ * that answer delivered (§15.6-B). */
166
+ guidanceDeliveries: GuidanceDelivery[]
167
+ }
168
+
169
+ export const LOG_INDEX_REDUCER_VERSION = 1
170
+
171
+ type ReviewVerdictPayload = EventPayload<'plan-review.verdict'>
172
+
173
+ type LoopName = 'plan' | 'code'
174
+
175
+ /** A post-restart candidate: the fields the replay reads, in array order. */
176
+ export type LogIndexCandidate =
177
+ | { kind: 'round-start'; loop: LoopName; seq: number; round: number }
178
+ | {
179
+ kind: 'plan-completed'
180
+ seq: number
181
+ round: number
182
+ artifact: ArtifactRef
183
+ verifySteps?: string[]
184
+ }
185
+ | { kind: 'round-touch'; loop: LoopName; seq: number; round: number }
186
+ | { kind: 'round-completed'; loop: LoopName; seq: number; round: number }
187
+ | {
188
+ kind: 'verdict'
189
+ loop: LoopName
190
+ seq: number
191
+ round: number
192
+ verdict: ReviewVerdictKind
193
+ findings: Finding[]
194
+ reason?: string
195
+ }
196
+ | { kind: 'implement-completed'; seq: number }
197
+ | { kind: 'verify-started'; seq: number; attempt: number }
198
+ | { kind: 'verify-completed'; record: VerifyRecord }
199
+ | { kind: 'finalize-completed'; seq: number }
200
+ | { kind: 'finalize-step'; seq: number; step: string }
201
+ | { kind: 'reconcile-completed'; seq: number }
202
+
203
+ export interface LogIndexAcc {
204
+ /** Full-log running state. */
205
+ restartSeq: number
206
+ maxRoundEver: { plan: number; code: number }
207
+ maxVerifyAttemptEver: number
208
+ pendingGuidanceStarts: Record<string, { escalation: string; seq: number }>
209
+ guidanceDeliveries: GuidanceDelivery[]
210
+ lastConflict?: { seq: number }
211
+ conflictSeqs: number[]
212
+ conflictReconcileStarted?: { attempt: number }
213
+ activeReconcileStart?: ReconcileStartRecord
214
+ reconcileStarts: ReconcileStartRecord[]
215
+ reconcileCompletions: ReconcileCompletionRecord[]
216
+ reconcileProgressChecks: ReconcileProgressRecord[]
217
+ /** Post-restart candidates, replayed in `finish` against the final restart. */
218
+ candidates: LogIndexCandidate[]
219
+ }
220
+
221
+ function initialLogIndexAcc(): LogIndexAcc {
222
+ return {
223
+ restartSeq: 0,
224
+ maxRoundEver: { plan: 0, code: 0 },
225
+ maxVerifyAttemptEver: 0,
226
+ pendingGuidanceStarts: {},
227
+ guidanceDeliveries: [],
228
+ conflictSeqs: [],
229
+ reconcileStarts: [],
230
+ reconcileCompletions: [],
231
+ reconcileProgressChecks: [],
232
+ candidates: [],
233
+ }
234
+ }
235
+
236
+ function emptyLoop(): LoopIndex {
237
+ return {
238
+ maxRoundEver: 0,
239
+ maxRound: 0,
240
+ rounds: new Map(),
241
+ latestApproveSeq: 0,
242
+ findingsByRound: [],
243
+ }
244
+ }
245
+
246
+ function foldLogIndex(acc: LogIndexAcc, events: readonly AbEvent[]): void {
247
+ const noteRound = (loop: LoopName, round: number): void => {
248
+ if (round > acc.maxRoundEver[loop]) acc.maxRoundEver[loop] = round
249
+ }
250
+ const noteGuidanceStart = (key: string, feedback: Feedback | undefined, seq: number): void => {
251
+ // A newer start is the authoritative carrier for this exact occurrence.
252
+ // In particular, a guidance-free retry must not let its later session
253
+ // consume an older citation that never launched.
254
+ if (feedback !== undefined && 'guidance' in feedback) {
255
+ acc.pendingGuidanceStarts[key] = {
256
+ escalation: feedback.guidance.escalation,
257
+ seq,
258
+ }
259
+ } else {
260
+ delete acc.pendingGuidanceStarts[key]
261
+ }
262
+ }
263
+
264
+ for (const event of events) {
265
+ switch (event.type) {
266
+ case 'spec.revised':
267
+ acc.restartSeq = event.seq
268
+ break
269
+ case 'plan.started':
270
+ noteGuidanceStart(`plan@${event.payload.round}`, event.payload.feedback, event.seq)
271
+ noteRound('plan', event.payload.round)
272
+ acc.candidates.push({
273
+ kind: 'round-start',
274
+ loop: 'plan',
275
+ seq: event.seq,
276
+ round: event.payload.round,
277
+ })
278
+ break
279
+ case 'plan.completed':
280
+ noteRound('plan', event.payload.round)
281
+ acc.candidates.push({
282
+ kind: 'plan-completed',
283
+ seq: event.seq,
284
+ round: event.payload.round,
285
+ artifact: event.payload.artifact,
286
+ ...(event.payload.verifySteps !== undefined
287
+ ? { verifySteps: [...event.payload.verifySteps] }
288
+ : {}),
289
+ })
290
+ break
291
+ case 'plan-review.started':
292
+ noteRound('plan', event.payload.round)
293
+ acc.candidates.push({
294
+ kind: 'round-touch',
295
+ loop: 'plan',
296
+ seq: event.seq,
297
+ round: event.payload.round,
298
+ })
299
+ break
300
+ case 'plan-review.verdict':
301
+ noteRound('plan', event.payload.round)
302
+ acc.candidates.push(verdictCandidate('plan', event.payload, event.seq))
303
+ break
304
+
305
+ case 'implement.started':
306
+ noteGuidanceStart(`implement@${event.payload.round}`, event.payload.feedback, event.seq)
307
+ noteRound('code', event.payload.round)
308
+ acc.candidates.push({
309
+ kind: 'round-start',
310
+ loop: 'code',
311
+ seq: event.seq,
312
+ round: event.payload.round,
313
+ })
314
+ break
315
+ case 'implement.completed':
316
+ noteRound('code', event.payload.round)
317
+ acc.candidates.push({
318
+ kind: 'round-completed',
319
+ loop: 'code',
320
+ seq: event.seq,
321
+ round: event.payload.round,
322
+ })
323
+ acc.candidates.push({ kind: 'implement-completed', seq: event.seq })
324
+ break
325
+ case 'code-review.started':
326
+ noteRound('code', event.payload.round)
327
+ acc.candidates.push({
328
+ kind: 'round-touch',
329
+ loop: 'code',
330
+ seq: event.seq,
331
+ round: event.payload.round,
332
+ })
333
+ break
334
+ case 'code-review.verdict':
335
+ noteRound('code', event.payload.round)
336
+ acc.candidates.push(verdictCandidate('code', event.payload, event.seq))
337
+ break
338
+
339
+ case 'verify.started':
340
+ noteGuidanceStart(
341
+ `${verifyPhase(event.payload.step)}@${event.payload.attempt}`,
342
+ event.payload.feedback,
343
+ event.seq,
344
+ )
345
+ acc.maxVerifyAttemptEver = Math.max(acc.maxVerifyAttemptEver, event.payload.attempt)
346
+ acc.candidates.push({
347
+ kind: 'verify-started',
348
+ seq: event.seq,
349
+ attempt: event.payload.attempt,
350
+ })
351
+ break
352
+ case 'session.started': {
353
+ if (event.payload.round === undefined) break
354
+ const key = `${event.payload.phase}@${event.payload.round}`
355
+ const carrier = acc.pendingGuidanceStarts[key]
356
+ if (carrier !== undefined && carrier.seq < event.seq) {
357
+ acc.guidanceDeliveries.push({
358
+ escalation: carrier.escalation,
359
+ seq: event.seq,
360
+ })
361
+ delete acc.pendingGuidanceStarts[key]
362
+ }
363
+ break
364
+ }
365
+ case 'verify.completed': {
366
+ const result = normalizeVerifyCompletion(event.payload)
367
+ acc.maxVerifyAttemptEver = Math.max(acc.maxVerifyAttemptEver, result.attempt)
368
+ acc.candidates.push({
369
+ kind: 'verify-completed',
370
+ record: {
371
+ seq: event.seq,
372
+ step: result.step,
373
+ attempt: result.attempt,
374
+ outcome: result.outcome,
375
+ ...(result.report !== undefined ? { report: result.report } : {}),
376
+ ...(result.reason !== undefined ? { reason: result.reason } : {}),
377
+ },
378
+ })
379
+ break
380
+ }
381
+
382
+ case 'finalize.completed':
383
+ acc.candidates.push({ kind: 'finalize-completed', seq: event.seq })
384
+ break
385
+ case 'finalize.step-completed':
386
+ acc.candidates.push({
387
+ kind: 'finalize-step',
388
+ seq: event.seq,
389
+ step: event.payload.step,
390
+ })
391
+ break
392
+
393
+ case 'pr.conflicted':
394
+ acc.lastConflict = { seq: event.seq }
395
+ acc.conflictSeqs.push(event.seq)
396
+ delete acc.conflictReconcileStarted
397
+ break
398
+ case 'reconcile.progress-checked':
399
+ acc.reconcileProgressChecks.push({ seq: event.seq, ...event.payload })
400
+ break
401
+ case 'reconcile.started': {
402
+ const start = { seq: event.seq, ...event.payload }
403
+ acc.reconcileStarts.push(start)
404
+ acc.activeReconcileStart = start
405
+ if (acc.lastConflict !== undefined && event.seq > acc.lastConflict.seq) {
406
+ acc.conflictReconcileStarted = { attempt: event.payload.attempt }
407
+ }
408
+ break
409
+ }
410
+ case 'reconcile.completed':
411
+ acc.candidates.push({ kind: 'reconcile-completed', seq: event.seq })
412
+ if (acc.activeReconcileStart !== undefined) {
413
+ acc.reconcileCompletions.push({
414
+ ...acc.activeReconcileStart,
415
+ completedSeq: event.seq,
416
+ })
417
+ delete acc.activeReconcileStart
418
+ }
419
+ delete acc.conflictReconcileStarted
420
+ break
421
+
422
+ default:
423
+ break
424
+ }
425
+ }
426
+ }
427
+
428
+ function verdictCandidate(
429
+ loop: LoopName,
430
+ payload: ReviewVerdictPayload,
431
+ seq: number,
432
+ ): LogIndexCandidate {
433
+ return {
434
+ kind: 'verdict',
435
+ loop,
436
+ seq,
437
+ round: payload.round,
438
+ verdict: payload.verdict,
439
+ findings: payload.findings,
440
+ ...(payload.reason !== undefined ? { reason: payload.reason } : {}),
441
+ }
442
+ }
443
+
444
+ function finishLogIndex(acc: LogIndexAcc): LogIndex {
445
+ const { restartSeq } = acc
446
+ const plan = emptyLoop()
447
+ const code = emptyLoop()
448
+ plan.maxRoundEver = acc.maxRoundEver.plan
449
+ code.maxRoundEver = acc.maxRoundEver.code
450
+ const loops = { plan, code }
451
+ const verifyCompleted: VerifyRecord[] = []
452
+ const verifyStarted: { seq: number; attempt: number }[] = []
453
+ const finalizeStepsDone = new Set<string>()
454
+ let lastReconcileCompletedSeq = 0
455
+ let lastImplementCompletedSeq = 0
456
+ let finalizeCompleted = false
457
+
458
+ const roundRecord = (loop: LoopIndex, r: number): RoundRecord => {
459
+ if (r > loop.maxRound) loop.maxRound = r
460
+ let record = loop.rounds.get(r)
461
+ if (record === undefined) {
462
+ record = {}
463
+ loop.rounds.set(r, record)
464
+ }
465
+ return record
466
+ }
467
+
468
+ // Replay only what is post-restart under the FINAL boundary, in array order.
469
+ for (const candidate of acc.candidates) {
470
+ const seq = candidate.kind === 'verify-completed' ? candidate.record.seq : candidate.seq
471
+ if (seq <= restartSeq) continue
472
+ switch (candidate.kind) {
473
+ case 'round-start':
474
+ roundRecord(loops[candidate.loop], candidate.round).startedSeq = candidate.seq
475
+ break
476
+ case 'plan-completed': {
477
+ const record = roundRecord(plan, candidate.round)
478
+ record.completedSeq = candidate.seq
479
+ record.planCompletion = {
480
+ seq: candidate.seq,
481
+ artifact: candidate.artifact,
482
+ ...(candidate.verifySteps !== undefined
483
+ ? { verifySteps: [...candidate.verifySteps] }
484
+ : {}),
485
+ }
486
+ break
487
+ }
488
+ case 'round-touch':
489
+ roundRecord(loops[candidate.loop], candidate.round)
490
+ break
491
+ case 'round-completed':
492
+ roundRecord(loops[candidate.loop], candidate.round).completedSeq = candidate.seq
493
+ break
494
+ case 'verdict': {
495
+ const loop = loops[candidate.loop]
496
+ const record = roundRecord(loop, candidate.round)
497
+ const verdict: VerdictRecord = {
498
+ seq: candidate.seq,
499
+ round: candidate.round,
500
+ verdict: candidate.verdict,
501
+ findings: candidate.findings,
502
+ reason: candidate.reason,
503
+ }
504
+ record.verdict = verdict
505
+ if (candidate.verdict === 'revise') loop.latestRevise = verdict
506
+ if (candidate.verdict === 'approve') {
507
+ loop.latestApproveSeq = candidate.seq
508
+ // Snapshot at verdict time. A plan.completed appended later — even
509
+ // for this round — was never reviewed and therefore has no authority.
510
+ if (loop === plan) loop.approvedPlan = record.planCompletion
511
+ }
512
+ // Findings per round, reducer-style padding (rounds without verdicts
513
+ // stay empty — including every pre-restart round, which is the point).
514
+ while (loop.findingsByRound.length < candidate.round) loop.findingsByRound.push([])
515
+ loop.findingsByRound[candidate.round - 1] = candidate.findings
516
+ break
517
+ }
518
+ case 'implement-completed':
519
+ lastImplementCompletedSeq = candidate.seq
520
+ break
521
+ case 'verify-started':
522
+ verifyStarted.push({ seq: candidate.seq, attempt: candidate.attempt })
523
+ break
524
+ case 'verify-completed':
525
+ verifyCompleted.push({ ...candidate.record })
526
+ break
527
+ case 'finalize-completed':
528
+ finalizeCompleted = true
529
+ break
530
+ case 'finalize-step':
531
+ finalizeStepsDone.add(candidate.step)
532
+ break
533
+ case 'reconcile-completed':
534
+ lastReconcileCompletedSeq = candidate.seq
535
+ break
536
+ }
537
+ }
538
+
539
+ const { lastConflict, reconcileStarts, reconcileProgressChecks } = acc
540
+ const conflictSeqs = new Set(acc.conflictSeqs)
541
+
542
+ // One completed occurrence per monotonic attempt. A later duplicate
543
+ // completion is safer to pair with its own latest start than with an older
544
+ // aggregate high-water, and malformed unmatched completions classify nothing.
545
+ const completedByAttempt = new Map<number, ReconcileCompletionRecord>()
546
+ for (const completion of acc.reconcileCompletions) {
547
+ completedByAttempt.set(completion.attempt, completion)
548
+ }
549
+ const completed = [...completedByAttempt.values()].sort(
550
+ (left, right) => left.completedSeq - right.completedSeq,
551
+ )
552
+
553
+ let reconcileNoProgressCount = 0
554
+ for (const completion of completed) {
555
+ const nextStart = reconcileStarts.find(
556
+ (start) => start.seq > completion.completedSeq && start.attempt > completion.attempt,
557
+ )
558
+ const observationLimit = nextStart?.seq ?? Number.POSITIVE_INFINITY
559
+ // Explicit checks win over the historical fallback. Use the latest one
560
+ // that could have routed this next occurrence; duplicate conflict facts do
561
+ // not count one completed reconcile more than once.
562
+ const explicit = reconcileProgressChecks
563
+ .filter(
564
+ (check) =>
565
+ check.attempt === completion.attempt &&
566
+ check.seq > completion.completedSeq &&
567
+ check.seq < observationLimit &&
568
+ check.conflictSeq > completion.completedSeq &&
569
+ check.seq > check.conflictSeq &&
570
+ conflictSeqs.has(check.conflictSeq),
571
+ )
572
+ .at(-1)
573
+ const observedBase = explicit?.baseSha ?? nextStart?.baseSha
574
+ if (observedBase === completion.baseSha) reconcileNoProgressCount += 1
575
+ }
576
+
577
+ const lastReconcileCompleted = completed.at(-1)
578
+ const lastConflictProgressCheck =
579
+ lastConflict === undefined || lastReconcileCompleted === undefined
580
+ ? undefined
581
+ : reconcileProgressChecks
582
+ .filter(
583
+ (check) =>
584
+ check.conflictSeq === lastConflict.seq &&
585
+ check.attempt === lastReconcileCompleted.attempt &&
586
+ check.seq > lastConflict.seq,
587
+ )
588
+ .at(-1)
589
+
590
+ return {
591
+ restartSeq,
592
+ plan,
593
+ code,
594
+ verifyCompleted,
595
+ verifyStarted,
596
+ maxVerifyAttemptEver: acc.maxVerifyAttemptEver,
597
+ lastReconcileCompletedSeq,
598
+ lastImplementCompletedSeq,
599
+ finalizeCompleted,
600
+ finalizeStepsDone,
601
+ lastConflict,
602
+ conflictReconcileStarted: acc.conflictReconcileStarted,
603
+ lastReconcileCompleted,
604
+ lastConflictProgressCheck,
605
+ reconcileNoProgressCount,
606
+ guidanceDeliveries: [...acc.guidanceDeliveries],
607
+ }
608
+ }
609
+
610
+ export const logIndexReducer = defineReducer<LogIndexAcc, AbEvent, LogIndex>({
611
+ version: LOG_INDEX_REDUCER_VERSION,
612
+ initial: initialLogIndexAcc,
613
+ fold: foldLogIndex,
614
+ finish: finishLogIndex,
615
+ })
616
+
617
+ /** The whole-array form: `finish(fold(initial(), events))`. */
618
+ export function indexLog(events: readonly AbEvent[]): LogIndex {
619
+ return logIndexReducer.reduce(events)
620
+ }