@defrex/autobuild 0.12.0 → 0.14.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 (151) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +138 -16
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +155 -73
  5. package/docs/configuration.md +41 -9
  6. package/docs/hosted-service.md +31 -5
  7. package/docs/operator-api.md +8 -1
  8. package/docs/remote-store-protocol.md +94 -2
  9. package/docs/remote-ticket-protocol.md +151 -9
  10. package/docs/setup.md +33 -7
  11. package/docs/ticket-migration.md +64 -0
  12. package/package.json +4 -3
  13. package/packages/core/src/cli/attention.ts +18 -164
  14. package/packages/core/src/cli/build-control.ts +23 -16
  15. package/packages/core/src/cli/bulk-control.ts +3 -2
  16. package/packages/core/src/cli/connect.ts +141 -38
  17. package/packages/core/src/cli/context.ts +23 -79
  18. package/packages/core/src/cli/dashboard/model.ts +36 -22
  19. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  20. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  21. package/packages/core/src/cli/dispatch-process.ts +14 -7
  22. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  23. package/packages/core/src/cli/dispatch.ts +304 -82
  24. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  25. package/packages/core/src/cli/harvest.ts +84 -42
  26. package/packages/core/src/cli/help.ts +14 -8
  27. package/packages/core/src/cli/historical-build-state.ts +27 -0
  28. package/packages/core/src/cli/installation.ts +67 -13
  29. package/packages/core/src/cli/main.ts +23 -5
  30. package/packages/core/src/cli/mcp.ts +2 -1
  31. package/packages/core/src/cli/pr-attachments.ts +10 -13
  32. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  33. package/packages/core/src/cli/pr-summary.ts +43 -13
  34. package/packages/core/src/cli/publication-settlement.ts +10 -20
  35. package/packages/core/src/cli/repository-status.ts +38 -2
  36. package/packages/core/src/cli/self-update.ts +5 -1
  37. package/packages/core/src/cli/status.ts +62 -32
  38. package/packages/core/src/cli/terminals.ts +25 -142
  39. package/packages/core/src/cli/ticket-assets.ts +7 -2
  40. package/packages/core/src/cli/ticket-editor.ts +82 -0
  41. package/packages/core/src/cli/ticket.ts +166 -36
  42. package/packages/core/src/cli/wait.ts +91 -84
  43. package/packages/core/src/cli/watch.ts +60 -97
  44. package/packages/core/src/config/pipeline-source.ts +4 -3
  45. package/packages/core/src/config/schema.ts +35 -3
  46. package/packages/core/src/connection.ts +3 -3
  47. package/packages/core/src/events/catalog.ts +3 -2
  48. package/packages/core/src/events/payloads.ts +6 -0
  49. package/packages/core/src/events/repository.ts +7 -3
  50. package/packages/core/src/integration/harness.ts +38 -4
  51. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2444 -9
  52. package/packages/core/src/kernel/auto-merge.ts +19 -3
  53. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  54. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  55. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  56. package/packages/core/src/kernel/harvest.ts +25 -23
  57. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  58. package/packages/core/src/kernel/log-index-value.ts +38 -0
  59. package/packages/core/src/kernel/read-facts.ts +637 -0
  60. package/packages/core/src/kernel/reducer-registry.ts +139 -3
  61. package/packages/core/src/kernel/repository-attention.ts +179 -0
  62. package/packages/core/src/kernel/service-facts.ts +135 -0
  63. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  64. package/packages/core/src/ontology.ts +1 -0
  65. package/packages/core/src/operator/annotations.ts +12 -0
  66. package/packages/core/src/operator/control.ts +5 -6
  67. package/packages/core/src/operator/index.ts +7 -0
  68. package/packages/core/src/operator/protocol.ts +19 -2
  69. package/packages/core/src/operator/query.ts +37 -20
  70. package/packages/core/src/operator/read-view.ts +4 -13
  71. package/packages/core/src/operator/registry.ts +61 -8
  72. package/packages/core/src/operator/requests.ts +2 -14
  73. package/packages/core/src/operator/sandbox.ts +10 -9
  74. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  75. package/packages/core/src/operator/tickets.ts +132 -22
  76. package/packages/core/src/plugin-sdk/index.ts +19 -0
  77. package/packages/core/src/plugins/runtimes.ts +5 -1
  78. package/packages/core/src/ports/runner/session-env.ts +2 -0
  79. package/packages/core/src/ports/tickets/contract.ts +66 -1
  80. package/packages/core/src/ports/tickets/create.ts +48 -12
  81. package/packages/core/src/ports/tickets/fake.ts +30 -1
  82. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  83. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  84. package/packages/core/src/ports/tickets/file.ts +114 -120
  85. package/packages/core/src/ports/tickets/grants.ts +47 -0
  86. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  87. package/packages/core/src/ports/tickets/identity.ts +20 -0
  88. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  89. package/packages/core/src/ports/tickets/linear.ts +74 -9
  90. package/packages/core/src/ports/tickets/migration.ts +336 -0
  91. package/packages/core/src/ports/tickets/operations.ts +38 -7
  92. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  93. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  94. package/packages/core/src/ports/tickets/remote.ts +60 -3
  95. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  96. package/packages/core/src/ports/tickets/revision.ts +62 -0
  97. package/packages/core/src/ports/tickets/states.ts +61 -0
  98. package/packages/core/src/ports/types.ts +40 -3
  99. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  100. package/packages/core/src/ports/workspace/create.ts +5 -1
  101. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  102. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  103. package/packages/core/src/processes/build-child.ts +6 -6
  104. package/packages/core/src/processes/build-runner.ts +62 -70
  105. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  106. package/packages/core/src/processes/dispatcher.ts +223 -205
  107. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  108. package/packages/core/src/processes/harvest-child.ts +30 -4
  109. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  110. package/packages/core/src/processes/harvest-runner.ts +58 -74
  111. package/packages/core/src/processes/harvest.ts +72 -29
  112. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  113. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  114. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  115. package/packages/core/src/processes/publication-loss.ts +35 -21
  116. package/packages/core/src/processes/repo-view.ts +229 -1099
  117. package/packages/core/src/processes/tick-publication.ts +110 -0
  118. package/packages/core/src/store/adapter.ts +23 -0
  119. package/packages/core/src/store/build-scope.ts +17 -0
  120. package/packages/core/src/store/contract.ts +283 -1
  121. package/packages/core/src/store/local/schema.ts +1 -0
  122. package/packages/core/src/store/local/store.ts +180 -55
  123. package/packages/core/src/store/memory.ts +119 -29
  124. package/packages/core/src/store/phase-session.ts +17 -0
  125. package/packages/core/src/store/reduced-reads.ts +239 -0
  126. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  127. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  128. package/packages/core/src/store/reducer-values.ts +145 -0
  129. package/packages/core/src/store/remote/client.ts +41 -0
  130. package/packages/core/src/store/remote/compatibility.ts +2 -0
  131. package/packages/core/src/store/remote/index.ts +19 -0
  132. package/packages/core/src/store/remote/protocol.ts +16 -0
  133. package/packages/core/src/store/remote/version.ts +3 -1
  134. package/packages/core/src/store/repo-state-events.ts +1 -1
  135. package/packages/core/src/store/repo-tick.ts +72 -0
  136. package/packages/core/src/store/session-handle.ts +19 -0
  137. package/packages/core/src/store/snapshots.ts +10 -0
  138. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  139. package/packages/core/src/store/ticket-assets.ts +86 -8
  140. package/packages/core/src/store/types.ts +40 -3
  141. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  142. package/packages/core/src/testing/index.ts +8 -0
  143. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  144. package/skills/guide/SKILL.md +97 -12
  145. package/skills/guide/references/operator-api.md +8 -1
  146. package/skills/guide/references/remote-store-protocol.md +94 -2
  147. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  148. package/skills/guide/references/setup.md +33 -7
  149. package/skills/guide/references/ticket-migration.md +64 -0
  150. package/skills/tickets/SKILL.md +71 -0
  151. package/templates/hosted-service/package.json +2 -2
@@ -0,0 +1,64 @@
1
+ # Moving a project between ticket sources
2
+
3
+ Export and import use the ordinary file ticket layout. They operate on tickets
4
+ and current attachments through the ticket source; neither command contacts the
5
+ forge. File and hosted sources support import. Linear supports export only.
6
+
7
+ 1. Pause intake and stop other ticket writers for the cutover. Import preflight
8
+ does not reserve numbers against concurrent writers.
9
+ 2. With the old `[tickets]` configuration, run `ab ticket export /path/to/export`.
10
+ The destination must be absent or empty. Export reads every state, including
11
+ archived Linear tickets and empty discoverable states, and publishes the
12
+ directory only after all reads succeed. Back up the target tracker too.
13
+ 3. Configure the new `[tickets]` source, or use `ab connect --tickets URL` for a
14
+ hosted ticket service. Keep the export directory intact.
15
+ 4. Run `ab ticket import /path/to/export`. Inspect its printed ticket and state
16
+ mappings and `/path/to/export/.ab-import-map.json`.
17
+ 5. Inspect ticket bodies, labels, blockers, comments and attachments in the new
18
+ source. Confirm lifecycle configuration and the ready gate, then resume intake.
19
+
20
+ Both commands also accept
21
+ `--store <ref>` to reach legacy BuildStore attachments. Keep the old store available
22
+ through export when file or Linear tickets have attachments there.
23
+
24
+ State directory names use lowercase letters, digits and hyphens, start with a
25
+ letter and contain at most forty characters. Export maps `In Progress` to
26
+ `in-progress`, `Backlog` to `backlog`, and `Done` to `done`. It retains each ticket's
27
+ state rather than assigning lifecycle roles. Distinct names that collapse to the
28
+ same directory fail before publication. The hidden `.ab-migration.json` records
29
+ original display names and the state map. Import also accepts ordinary legacy
30
+ file layouts: it reuses exact or unique case-insensitive target states before
31
+ normalizing new names. A seeded `done` satisfies `Done`; absent `In Progress`
32
+ creates `in-progress`. Ambiguous matches and colliding state maps fail preflight.
33
+
34
+ File targets retain source ids. Hosted targets keep canonical own-team ids:
35
+ `AUT-706` imported into team `AUT` stays `AUT-706`. Other ids receive numbers above
36
+ all existing and preserved incoming numbers. Known clashes with unrelated tickets
37
+ are reported together before any target state, ticket, comment or asset write.
38
+ Content similarity and stale mapping files never establish identity.
39
+
40
+ Creation keys establish identity. Exports retain existing keys and assign keys to
41
+ legacy tickets. Importing an ordinary file layout first saves a hidden migration
42
+ identity and keys in that input directory. Preserve this file and the input across
43
+ retries. After interruption, rerun the same command: committed creates are adopted
44
+ by key even if their mapping write failed. Adopted bodies and other fields are not
45
+ overwritten. Missing blockers and comments are replayed, and unchanged attachments
46
+ do not create another revision. Comments are deduplicated by exact body, so two
47
+ identical source comment bodies produce one target comment. Original authors and
48
+ timestamps remain in the export; imported comments use the target's metadata.
49
+
50
+ Portable records use TOML frontmatter between `+++` fences and an unchanged
51
+ Markdown body. Alongside `id`, `title`, `labels`, `blockedBy` and `idempotencyKey`,
52
+ optional `comments` contain `body`, `createdAt` and optional `author`. Optional
53
+ `attachments` contain `kind`, `name`, `layout` (`file` or `tree`) and `entries`:
54
+ `{ type = "dir", path = "empty" }` or
55
+ `{ type = "file", path = "image.png", base64 = "…" }`. Common asset limits and
56
+ path validators apply. Only current attachment contents travel, including binary
57
+ files and empty directories; historical revisions do not. The exported directory
58
+ works directly as a file source, without its migration manifest or original store.
59
+ File source revision history stays in its hidden `.ab-assets` directory.
60
+
61
+ Legacy records without `comments` retain their Markdown comment append behavior;
62
+ export never interprets Markdown headings as structured history. New file tickets
63
+ use structured comments and preserve their body when a comment is added. There is
64
+ no continuous sync, assignee migration, state history or build-history migration.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defrex/autobuild",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Agent-driven software delivery from groomed ticket to merged PR",
5
5
  "repository": {
6
6
  "type": "git",
@@ -114,6 +114,7 @@
114
114
  "docs"
115
115
  ],
116
116
  "scripts": {
117
+ "ab": "bun bin/ab.ts",
117
118
  "dev:cli": "bun --hot --no-clear-screen bin/ab-dev.ts",
118
119
  "capture:dashboard": "bun tools/dashboard-capture.ts",
119
120
  "capture:web-dashboard": "bun packages/web-dashboard-capture/src/web-dashboard-capture.tsx",
@@ -143,8 +144,8 @@
143
144
  },
144
145
  "devDependencies": {
145
146
  "@biomejs/biome": "2.5.5",
146
- "@defrex/autobuild-postgres-store": "0.11.0",
147
- "@defrex/autobuild-vercel-sandbox": "0.11.0",
147
+ "@defrex/autobuild-postgres-store": "0.14.0",
148
+ "@defrex/autobuild-vercel-sandbox": "0.14.0",
148
149
  "@resvg/resvg-js": "2.6.2",
149
150
  "@types/bun": "^1.3.14",
150
151
  "@types/node": "^24.0.0",
@@ -1,6 +1,10 @@
1
1
  import type { AbEvent } from '../events/catalog'
2
2
  import type { RepositoryEvent } from '../events/repository'
3
- import { currentAutoMergeDeferral } from '../kernel/auto-merge'
3
+ import {
4
+ currentDeferralObservationReducer,
5
+ mergeAttentionFromFacts,
6
+ type DeferralLedger,
7
+ } from '../kernel/auto-merge'
4
8
  import { reduceBuild, type BuildState } from '../kernel/reducer'
5
9
 
6
10
  export interface MergeDecision {
@@ -17,22 +21,18 @@ export function mergeDecision(
17
21
  now: Date,
18
22
  state = reduceBuild(events),
19
23
  ): MergeDecision | null {
20
- if (
21
- !state.finalizeCompletedSeq ||
22
- !state.pr ||
23
- state.prState !== 'open' ||
24
- state.status === 'done' ||
25
- state.status === 'aborted'
26
- )
27
- return null
28
- const deferral = currentAutoMergeDeferral(events, state)
29
- if (state.autoMerge.requested && deferral === undefined) return null
30
- return {
31
- type: 'build.merge-decision',
32
- pr: state.pr,
33
- reason: deferral ?? 'Auto-merge is not armed',
34
- observedAt: now.toISOString(),
35
- }
24
+ return mergeDecisionFromFacts(state, currentDeferralObservationReducer.reduce(events), now)
25
+ }
26
+
27
+ export function mergeDecisionFromFacts(
28
+ state: BuildState,
29
+ ledger: DeferralLedger,
30
+ now: Date,
31
+ ): MergeDecision | null {
32
+ const decision = mergeAttentionFromFacts(state, ledger)
33
+ return decision
34
+ ? { type: 'build.merge-decision', ...decision, observedAt: now.toISOString() }
35
+ : null
36
36
  }
37
37
 
38
38
  export function buildNeedsAttention(events: AbEvent[], state: BuildState): boolean {
@@ -60,150 +60,4 @@ export function repositoryNeedsAttention(event: RepositoryEvent): boolean {
60
60
  }
61
61
  }
62
62
 
63
- type Degraded = Extract<RepositoryEvent, { type: 'dispatcher.ticket-source-degraded' }>
64
- type Failed = Extract<RepositoryEvent, { type: 'dispatcher.tick-failed' }>
65
- export type PersistenceObservation =
66
- | {
67
- type: 'dispatcher.ticket-source-persistent'
68
- source: string
69
- cause: string
70
- message: string
71
- resetAt?: string
72
- episodeSeq: number
73
- observedAt: string
74
- dependencyLimited: boolean
75
- }
76
- | {
77
- type: 'dispatcher.tick-failure-persistent'
78
- run: string
79
- firstFailure: string
80
- error: string
81
- episodeSeq: number
82
- observedAt: string
83
- }
84
-
85
- /** Incremental journal projection. Episode starts survive run anchors and
86
- * repeated evidence can strengthen, but never postpone, an existing deadline. */
87
- export class RepositoryAttention {
88
- private sources = new Map<
89
- string,
90
- {
91
- event: Degraded
92
- deadline: number
93
- dependencyLimited: boolean
94
- requiresRetry: boolean
95
- retryObserved: boolean
96
- }
97
- >()
98
- private failure: Failed | undefined
99
- private tickStarts = new Map<string, number>()
100
-
101
- private sourceDefersTick(now: number): boolean {
102
- return [...this.sources.values()].some(
103
- (source) => now < source.deadline || (source.requiresRetry && !source.retryObserved),
104
- )
105
- }
106
-
107
- /** Crossing resetAt alone does not prove persistence: the dispatcher must
108
- * finish a tick that could actually retry after the provider cooldown. */
109
- private observeRetry(run: string): void {
110
- const startedAt = this.tickStarts.get(run)
111
- this.tickStarts.delete(run)
112
- if (startedAt === undefined) return
113
- for (const source of this.sources.values()) {
114
- if (startedAt >= source.deadline) source.retryObserved = true
115
- }
116
- }
117
- fold(event: RepositoryEvent): void {
118
- switch (event.type) {
119
- case 'dispatcher.ticket-source-degraded': {
120
- const existing = this.sources.get(event.payload.source)
121
- const deadline =
122
- 'resetAt' in event.payload
123
- ? Date.parse(event.payload.resetAt)
124
- : Date.parse(event.ts) + 15 * 60_000
125
- if (existing) {
126
- existing.deadline = Math.min(existing.deadline, deadline)
127
- existing.requiresRetry ||= 'resetAt' in event.payload
128
- } else
129
- this.sources.set(event.payload.source, {
130
- event,
131
- deadline,
132
- dependencyLimited: false,
133
- requiresRetry: 'resetAt' in event.payload,
134
- retryObserved: false,
135
- })
136
- // Cooldown-driven failures are not an independent dispatcher outage.
137
- if (this.sourceDefersTick(Date.parse(event.ts))) this.failure = undefined
138
- break
139
- }
140
- case 'dispatcher.ticket-source-dependency-limited': {
141
- const source = this.sources.get(event.payload.source)
142
- if (source) source.dependencyLimited = true
143
- break
144
- }
145
- case 'dispatcher.ticket-source-recovered':
146
- this.sources.delete(event.payload.source)
147
- break
148
- case 'dispatcher.run-started':
149
- this.failure = undefined
150
- this.tickStarts.clear()
151
- break
152
- case 'dispatcher.run-stopped':
153
- this.tickStarts.delete(event.payload.run)
154
- if (this.failure?.payload.run === event.payload.run) this.failure = undefined
155
- break
156
- case 'dispatcher.tick-started':
157
- this.tickStarts.set(event.payload.run, Date.parse(event.ts))
158
- break
159
- case 'dispatcher.tick-completed':
160
- this.observeRetry(event.payload.run)
161
- if (this.failure?.payload.run === event.payload.run) this.failure = undefined
162
- break
163
- case 'dispatcher.tick-failed':
164
- this.observeRetry(event.payload.run)
165
- if (this.sourceDefersTick(Date.parse(event.ts))) this.failure = undefined
166
- else if (this.failure?.payload.run !== event.payload.run) this.failure = event
167
- break
168
- }
169
- }
170
- pending(now: Date): { identity: string; observation: PersistenceObservation }[] {
171
- const results: { identity: string; observation: PersistenceObservation }[] = []
172
- for (const {
173
- event,
174
- deadline,
175
- dependencyLimited,
176
- requiresRetry,
177
- retryObserved,
178
- } of this.sources.values()) {
179
- if (now.getTime() < deadline || (requiresRetry && !retryObserved)) continue
180
- results.push({
181
- identity: `source:${event.seq}`,
182
- observation: {
183
- type: 'dispatcher.ticket-source-persistent',
184
- ...event.payload,
185
- episodeSeq: event.seq,
186
- observedAt: now.toISOString(),
187
- dependencyLimited,
188
- },
189
- })
190
- }
191
- const event = this.failure
192
- if (
193
- event &&
194
- !this.sourceDefersTick(now.getTime()) &&
195
- now.getTime() >= Date.parse(event.ts) + 5 * 60_000
196
- )
197
- results.push({
198
- identity: `tick:${event.seq}`,
199
- observation: {
200
- type: 'dispatcher.tick-failure-persistent',
201
- ...event.payload,
202
- firstFailure: event.ts,
203
- episodeSeq: event.seq,
204
- observedAt: now.toISOString(),
205
- },
206
- })
207
- return results
208
- }
209
- }
63
+ export { RepositoryAttention, type PersistenceObservation } from '../kernel/repository-attention'
@@ -1,3 +1,4 @@
1
+ import { readBuildFacts } from '../store/reduced-reads'
1
2
  /**
2
3
  * Durable operator controls for one build.
3
4
  *
@@ -9,12 +10,7 @@
9
10
  import type { AbEvent } from '../events/catalog'
10
11
  import type { PinnedAsset } from '../events/payloads'
11
12
  import { humanActor, KERNEL, type Via } from '../events/envelope'
12
- import {
13
- reduceBuild,
14
- discardInFlight,
15
- type BuildState,
16
- type OpenEscalation,
17
- } from '../kernel/reducer'
13
+ import { discardInFlight, type BuildState, type OpenEscalation } from '../kernel/reducer'
18
14
  import type { ArtifactRef, BuildOutcome, BuildStatus, TicketRef } from '../ontology'
19
15
  import type { Exec } from '../ports/workspace/git-worktree'
20
16
  import { specConformance } from '../spec-standard'
@@ -276,7 +272,7 @@ async function appendConsentCommand(
276
272
  via?: Via,
277
273
  ): Promise<BuildControlResult> {
278
274
  while (true) {
279
- const state = reduceBuild(await store.getEvents(slug))
275
+ const state = (await readBuildFacts(store, slug)).value.state
280
276
  activeState(slug, state)
281
277
  const command =
282
278
  action === 'toggle-auto-merge'
@@ -464,6 +460,7 @@ export interface ControlBuildOpts {
464
460
  via?: Via
465
461
  action: BuildControlAction
466
462
  /** Injected only for --revise-spec-from-ticket. */
463
+ prepareTicketAssets?: (ref: TicketRef, store: BuildStore, repo: string) => Promise<PinnedAsset[]>
467
464
  readTicketBody?: (ref: TicketRef) => Promise<string>
468
465
  }
469
466
 
@@ -483,8 +480,8 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
483
480
  )
484
481
  }
485
482
 
486
- const events = await opts.store.getEvents(opts.slug)
487
- const state = reduceBuild(events)
483
+ const events = (await readBuildFacts(opts.store, opts.slug)).value
484
+ const state = events.state
488
485
  const user = opts.user?.trim() || buildControlUser(opts.env ?? {})
489
486
 
490
487
  if (opts.action.kind === 'discard') {
@@ -494,7 +491,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
494
491
  `build "${opts.slug}" cannot be discarded (status: ${state.status}); discard requires queued`,
495
492
  )
496
493
  }
497
- const existing = events.findLast((event) => event.type === 'build.discard-requested')
494
+ const existing = events.latestDiscard
498
495
  if (existing !== undefined) {
499
496
  return { kind: 'command', slug: opts.slug, command: 'discard', event: existing }
500
497
  }
@@ -503,7 +500,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
503
500
 
504
501
  if (opts.action.kind === 'abort') {
505
502
  if (state.status !== 'queued') activeState(opts.slug, state)
506
- const existing = events.findLast((event) => event.type === 'build.abort-requested')
503
+ const existing = events.latestAbort
507
504
  if (existing !== undefined) {
508
505
  return { kind: 'command', slug: opts.slug, command: 'abort', event: existing }
509
506
  }
@@ -624,7 +621,15 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
624
621
  )
625
622
  }
626
623
  body = await opts.readTicketBody(record.ticket)
627
- assets = await samplePinnedAssets(opts.store, record.repo, record.ticket.id)
624
+ if (record.ticket.source === 'hosted' && !opts.prepareTicketAssets) {
625
+ throw new BuildControlError(
626
+ 'ticket-unavailable',
627
+ 'hosted ticket asset preparation is unavailable',
628
+ )
629
+ }
630
+ assets = opts.prepareTicketAssets
631
+ ? await opts.prepareTicketAssets(record.ticket, opts.store, record.repo)
632
+ : await samplePinnedAssets(opts.store, record.repo, record.ticket.id)
628
633
  }
629
634
  const conformance = specConformance(body)
630
635
  if (!conformance.conforms) {
@@ -649,7 +654,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
649
654
 
650
655
  // Re-read at the last durable moment. The PR janitor and abort
651
656
  // acknowledgement may still write while a blocked build is parked.
652
- const freshState = reduceBuild(await opts.store.getEvents(opts.slug))
657
+ const freshState = (await readBuildFacts(opts.store, opts.slug)).value.state
653
658
  assertStillActive(opts.slug, freshState)
654
659
  const decision = decideRevise(opts.slug, freshState, captured)
655
660
  if (decision.kind === 'refuse') throwRefusal(decision)
@@ -692,7 +697,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
692
697
  payload: {},
693
698
  })
694
699
  }
695
- const after = reduceBuild(await opts.store.getEvents(opts.slug))
700
+ const after = (await readBuildFacts(opts.store, opts.slug)).value.state
696
701
  const signal = terminalSignal(after)
697
702
  return {
698
703
  kind: 'answered',
@@ -720,7 +725,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
720
725
  const initial = dismissPartition(state, captured)
721
726
  if (initial.eligible.length === 0) throw noCitedFindings(opts.slug, initialOpen)
722
727
 
723
- const freshState = reduceBuild(await opts.store.getEvents(opts.slug))
728
+ const freshState = (await readBuildFacts(opts.store, opts.slug)).value.state
724
729
  assertStillActive(opts.slug, freshState)
725
730
  if (hasPendingAbort(freshState)) throw abortPending(opts.slug)
726
731
  const freshOpen = selectedOpen(freshState, captured)
@@ -748,7 +753,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
748
753
  payload: {},
749
754
  })
750
755
  }
751
- const after = reduceBuild(await opts.store.getEvents(opts.slug))
756
+ const after = (await readBuildFacts(opts.store, opts.slug)).value.state
752
757
  const signal = terminalSignal(after)
753
758
  return {
754
759
  kind: 'answered',
@@ -862,6 +867,7 @@ export interface AbBuildControlOpts {
862
867
  /** Explicit `--store`; selection remains --store > AB_STORE > repo-local. */
863
868
  storeRef?: string
864
869
  /** Injected only for --revise-spec-from-ticket. */
870
+ prepareTicketAssets?: (ref: TicketRef, store: BuildStore, repo: string) => Promise<PinnedAsset[]>
865
871
  readTicketBody?: (ref: TicketRef) => Promise<string>
866
872
  /** Injectable adapter seam for unit tests. */
867
873
  openStore?: StoreOpener
@@ -881,6 +887,7 @@ export async function abBuildControl(opts: AbBuildControlOpts): Promise<BuildCon
881
887
  env: opts.env,
882
888
  action: opts.action,
883
889
  ...(opts.readTicketBody !== undefined ? { readTicketBody: opts.readTicketBody } : {}),
890
+ ...(opts.prepareTicketAssets ? { prepareTicketAssets: opts.prepareTicketAssets } : {}),
884
891
  }),
885
892
  )
886
893
  }
@@ -1,3 +1,4 @@
1
+ import { readBuildFacts } from '../store/reduced-reads'
1
2
  /**
2
3
  * Repository-wide quiescence: one keypress that parks every running build and
3
4
  * stops intake, and one that reverses it.
@@ -59,7 +60,7 @@
59
60
  * what the operator sees, and a unit test pins that agreement.
60
61
  */
61
62
  import { humanActor, type Actor, type Via } from '../events/envelope'
62
- import { reduceBuild, type BuildState } from '../kernel/reducer'
63
+ import type { BuildState } from '../kernel/reducer'
63
64
  import type { Exec } from '../ports/workspace/git-worktree'
64
65
  import type { BuildRecord, BuildStore } from '../store/types'
65
66
  import { BuildControlError, buildControlUser } from './build-control'
@@ -132,7 +133,7 @@ async function requestOne(
132
133
  direction: BulkDirection,
133
134
  ): Promise<boolean> {
134
135
  while (true) {
135
- const state = reduceBuild(await store.getEvents(slug))
136
+ const state = (await readBuildFacts(store, slug)).value.state
136
137
  if (!eligible(state, direction)) return false
137
138
  // The two branches are written out rather than selecting the event type
138
139
  // into a variable so `appendIfCurrent`'s `T extends EventType` infers a