@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,595 @@
1
+ /** Deterministic halves of observation harvest: scan, dedup, validation, and
2
+ * proposal rendering. Agent judgment is deliberately absent from this file. */
3
+ import type { ArtifactRef } from '../ontology'
4
+ import {
5
+ harvestProposalSetSchema,
6
+ harvestScanPacketSchema,
7
+ occurrenceKey,
8
+ type HarvestBlockerProvenance,
9
+ type HarvestDisposition,
10
+ type HarvestLedgerTicket,
11
+ type HarvestObservation,
12
+ type HarvestOriginatingTicket,
13
+ type HarvestPendingProposal,
14
+ type HarvestProposal,
15
+ type HarvestProposalSet,
16
+ type HarvestScanPacket,
17
+ type HarvestTrigger,
18
+ type OccurrenceKey,
19
+ } from '../harvest/schema'
20
+ import {
21
+ claimedOccurrenceKeys,
22
+ proposalArtifactForRound,
23
+ reduceHarvest,
24
+ type HarvestRunState,
25
+ type HarvestState,
26
+ } from '../kernel/harvest'
27
+ import type { TicketSource } from '../ports/types'
28
+ import { specConformance } from '../spec-standard'
29
+ import { contentHash, toBytes, type BuildStore } from '../store/types'
30
+
31
+ export const HARVEST_SCAN_ARTIFACT = 'harvest-scan'
32
+ export const HARVEST_PROPOSALS_ARTIFACT = 'harvest-proposals'
33
+ export const HARVEST_REVIEW_ARTIFACT = 'harvest-review'
34
+ export const HARVEST_REPORT_ARTIFACT = 'harvest-report'
35
+ export const HARVEST_TRANSCRIPT_ARTIFACT = 'harvest-transcript'
36
+
37
+ export interface HarvestMerge {
38
+ build: string
39
+ ts: string
40
+ }
41
+
42
+ export interface HarvestScanResult {
43
+ observations: HarvestObservation[]
44
+ /** One durable merge fact per build, collected during the existing event-stream reads. */
45
+ merges: HarvestMerge[]
46
+ state: HarvestState
47
+ }
48
+
49
+ export interface HarvestPressure {
50
+ observationCount: number
51
+ drift: number
52
+ trigger?: HarvestTrigger
53
+ }
54
+
55
+ /** Pure two-dimensional Harvest gate. Drift is measured from the oldest
56
+ * unclaimed observation and excludes that observation's own build. */
57
+ export function evaluateHarvestPressure(
58
+ scan: Pick<HarvestScanResult, 'observations' | 'merges'>,
59
+ policy: { harvestThreshold: number; harvestMaxDrift: number },
60
+ ): HarvestPressure {
61
+ const observationCount = scan.observations.length
62
+ if (observationCount === 0) return { observationCount, drift: 0 }
63
+
64
+ const oldest = scan.observations.reduce((candidate, observation) =>
65
+ observation.ts < candidate.ts ? observation : candidate,
66
+ )
67
+ const mergedBuilds = new Set<string>()
68
+ for (const merge of scan.merges) {
69
+ if (merge.build === oldest.occurrence.build || merge.ts <= oldest.ts) continue
70
+ mergedBuilds.add(merge.build)
71
+ }
72
+ const drift = mergedBuilds.size
73
+ const countTriggered = observationCount >= policy.harvestThreshold
74
+ const driftTriggered = policy.harvestMaxDrift > 0 && drift >= policy.harvestMaxDrift
75
+ const trigger =
76
+ countTriggered && driftTriggered
77
+ ? 'both'
78
+ : countTriggered
79
+ ? 'count'
80
+ : driftTriggered
81
+ ? 'drift'
82
+ : undefined
83
+ return {
84
+ observationCount,
85
+ drift,
86
+ ...(trigger !== undefined ? { trigger } : {}),
87
+ }
88
+ }
89
+
90
+ /** Raw structured `observation.recorded` envelopes across this repository.
91
+ * The pair `{build, seq}` is the occurrence key; payload ids are not assumed
92
+ * globally unique. */
93
+ export async function scanUnclaimedObservations(
94
+ store: BuildStore,
95
+ repo: string,
96
+ ): Promise<HarvestScanResult> {
97
+ await store.ensureRepo(repo)
98
+ const harvestEvents = await store.getRepoEvents(repo)
99
+ const state = reduceHarvest(harvestEvents)
100
+ const claimed = claimedOccurrenceKeys(state)
101
+ const observations: HarvestObservation[] = []
102
+ const merges: HarvestMerge[] = []
103
+
104
+ for (const record of await store.listBuilds()) {
105
+ if (record.repo !== repo) continue
106
+ const events = await store.getEvents(record.slug)
107
+ for (const event of events) {
108
+ if (event.type === 'pr.merged') {
109
+ merges.push({ build: record.slug, ts: event.ts })
110
+ continue
111
+ }
112
+ if (event.type !== 'observation.recorded') continue
113
+ const occurrence = { build: record.slug, seq: event.seq }
114
+ if (claimed.has(occurrenceKey(occurrence))) continue
115
+ observations.push({
116
+ occurrence,
117
+ id: event.payload.id,
118
+ kind: event.payload.kind,
119
+ summary: event.payload.summary,
120
+ ...(event.payload.files !== undefined ? { files: [...event.payload.files] } : {}),
121
+ ...(event.payload.refs !== undefined ? { refs: [...event.payload.refs] } : {}),
122
+ ts: event.ts,
123
+ ...(record.ticket !== undefined ? { ticket: structuredClone(record.ticket) } : {}),
124
+ })
125
+ }
126
+ }
127
+
128
+ observations.sort(
129
+ (a, b) =>
130
+ a.occurrence.build.localeCompare(b.occurrence.build) || a.occurrence.seq - b.occurrence.seq,
131
+ )
132
+ return { observations, merges, state }
133
+ }
134
+
135
+ /** Distinct previously filed/joined proposal tickets, reconciled through the
136
+ * TicketSource's native lifecycle semantics. Resolved and missing entries stay
137
+ * in the packet as tombstones rather than disappearing and being re-filed. */
138
+ export async function reconcileHarvestLedger(
139
+ state: HarvestState,
140
+ tickets: TicketSource,
141
+ ): Promise<HarvestLedgerTicket[]> {
142
+ const byProposal = new Map<
143
+ string,
144
+ { proposalKey: string; ticket: NonNullable<(typeof state.ledger)[number]['ticket']> }
145
+ >()
146
+ for (const entry of state.ledger) {
147
+ if (entry.proposalKey === undefined || entry.ticket === undefined) continue
148
+ byProposal.set(entry.proposalKey, {
149
+ proposalKey: entry.proposalKey,
150
+ ticket: entry.ticket,
151
+ })
152
+ }
153
+ const entries = [...byProposal.values()].sort((a, b) =>
154
+ a.proposalKey.localeCompare(b.proposalKey),
155
+ )
156
+ if (entries.length === 0) return []
157
+
158
+ const ids = entries.map((entry) => entry.ticket.id)
159
+ const states = await tickets.dependencyStates(ids)
160
+ const out: HarvestLedgerTicket[] = []
161
+ for (let index = 0; index < entries.length; index += 1) {
162
+ const entry = entries[index]!
163
+ const dependency = states[index] ?? {
164
+ id: entry.ticket.id,
165
+ exists: false,
166
+ resolved: false,
167
+ blockedBy: [],
168
+ }
169
+ const current = dependency.exists ? await tickets.get(entry.ticket.id) : null
170
+ out.push({
171
+ proposalKey: entry.proposalKey,
172
+ ticket: structuredClone(entry.ticket),
173
+ exists: dependency.exists,
174
+ resolved: dependency.resolved,
175
+ ...(current?.title !== undefined ? { title: current.title } : {}),
176
+ ...(current?.body !== undefined ? { body: current.body } : {}),
177
+ })
178
+ }
179
+ return out
180
+ }
181
+
182
+ /** Distinct observation-origin tickets in first-seen order. Lifecycle state is
183
+ * informational at scan time; filing refreshes matching refs before create. */
184
+ export async function reconcileOriginatingTickets(
185
+ observations: HarvestObservation[],
186
+ tickets: TicketSource,
187
+ ): Promise<HarvestOriginatingTicket[]> {
188
+ const distinct = new Map<string, NonNullable<HarvestObservation['ticket']>>()
189
+ for (const observation of observations) {
190
+ if (observation.ticket === undefined) continue
191
+ const key = `${observation.ticket.source}:${observation.ticket.id}`
192
+ if (!distinct.has(key)) distinct.set(key, structuredClone(observation.ticket))
193
+ }
194
+
195
+ const refs = [...distinct.values()]
196
+ const matchingIds = refs
197
+ .filter((ticket) => ticket.source === tickets.name)
198
+ .map((ticket) => ticket.id)
199
+ const states = matchingIds.length > 0 ? await tickets.dependencyStates(matchingIds) : []
200
+ const byId = new Map(states.map((state) => [state.id, state]))
201
+
202
+ return refs.map((ticket) => {
203
+ const sourceMatches = ticket.source === tickets.name
204
+ const state = sourceMatches ? byId.get(ticket.id) : undefined
205
+ return {
206
+ ticket,
207
+ sourceMatches,
208
+ exists: state?.exists ?? false,
209
+ resolved: state?.resolved ?? false,
210
+ }
211
+ })
212
+ }
213
+
214
+ export async function makeHarvestScanPacket(opts: {
215
+ store: BuildStore
216
+ tickets: TicketSource
217
+ repo: string
218
+ run: string
219
+ observations: HarvestObservation[]
220
+ state: HarvestState
221
+ }): Promise<HarvestScanPacket> {
222
+ return harvestScanPacketSchema.parse({
223
+ repo: opts.repo,
224
+ run: opts.run,
225
+ observations: opts.observations,
226
+ originatingTickets: await reconcileOriginatingTickets(opts.observations, opts.tickets),
227
+ ledger: await reconcileHarvestLedger(opts.state, opts.tickets),
228
+ })
229
+ }
230
+
231
+ export function artifactRef(meta: { kind: string; revision: number }): ArtifactRef {
232
+ return { kind: meta.kind, rev: meta.revision }
233
+ }
234
+
235
+ export interface CoverageResult {
236
+ ok: boolean
237
+ errors: string[]
238
+ }
239
+
240
+ /** Every claimed observation must occur exactly once across create/join/
241
+ * suppress outcomes; no unclaimed key may be smuggled into an artifact. */
242
+ export function validateProposalCoverage(
243
+ set: HarvestProposalSet,
244
+ claimed: OccurrenceKey[],
245
+ ): CoverageResult {
246
+ const expected = new Set(claimed.map(occurrenceKey))
247
+ const counts = new Map<string, number>()
248
+ for (const proposal of set.proposals) {
249
+ for (const occurrence of proposal.observations) {
250
+ const key = occurrenceKey(occurrence)
251
+ counts.set(key, (counts.get(key) ?? 0) + 1)
252
+ }
253
+ }
254
+ const errors: string[] = []
255
+ for (const key of expected) {
256
+ const count = counts.get(key) ?? 0
257
+ if (count === 0) errors.push(`claimed observation ${key} is not covered`)
258
+ else if (count > 1) errors.push(`claimed observation ${key} is covered ${count} times`)
259
+ }
260
+ for (const key of counts.keys()) {
261
+ if (!expected.has(key)) errors.push(`proposal covers unclaimed observation ${key}`)
262
+ }
263
+ return { ok: errors.length === 0, errors }
264
+ }
265
+
266
+ /** Parse an approved proposal artifact and re-assert its partition at the
267
+ * deterministic filing boundary. */
268
+ export function parseApprovedProposalSet(
269
+ raw: string,
270
+ claimed: OccurrenceKey[],
271
+ ): HarvestProposalSet {
272
+ let json: unknown
273
+ try {
274
+ json = JSON.parse(raw)
275
+ } catch (error) {
276
+ throw new Error(
277
+ `approved harvest proposal artifact is not JSON: ${
278
+ error instanceof Error ? error.message : String(error)
279
+ }`,
280
+ )
281
+ }
282
+ const parsed = harvestProposalSetSchema.safeParse(json)
283
+ if (!parsed.success) {
284
+ throw new Error(
285
+ `approved harvest proposal artifact does not match the schema: ${parsed.error.message}`,
286
+ )
287
+ }
288
+ const coverage = validateProposalCoverage(parsed.data, claimed)
289
+ if (!coverage.ok) {
290
+ throw new Error(`approved harvest proposals are not a partition: ${coverage.errors.join('; ')}`)
291
+ }
292
+ return parsed.data
293
+ }
294
+
295
+ /** Stable proposal identity: semantic cluster membership, independent of prose
296
+ * edits, review rounds, and the separately reserved external-create UUID. */
297
+ export function harvestProposalKey(proposal: HarvestProposal): string {
298
+ const members = proposal.observations.map(occurrenceKey).sort().join('\n')
299
+ return `harvest-${contentHash(toBytes(members)).slice(0, 24)}`
300
+ }
301
+
302
+ /** Resolve the authoritative blocker union immediately before create. Agent
303
+ * declarations retain strict validation; observation origins are best-effort
304
+ * prerequisites and contribute only while they still exist and are unresolved. */
305
+ export interface HarvestCreateBlockers {
306
+ blockedBy: string[]
307
+ provenance: HarvestBlockerProvenance
308
+ }
309
+
310
+ export async function resolveHarvestCreateBlockers(
311
+ proposal: Extract<HarvestProposal, { action: 'create' }>,
312
+ observations: HarvestObservation[],
313
+ tickets: TicketSource,
314
+ ): Promise<HarvestCreateBlockers> {
315
+ const declared = [...new Set(proposal.blockedBy ?? [])]
316
+ const byOccurrence = new Map(
317
+ observations.map((observation) => [occurrenceKey(observation.occurrence), observation]),
318
+ )
319
+ const originIds: string[] = []
320
+ const seenOrigins = new Set<string>()
321
+ for (const occurrence of proposal.observations) {
322
+ const observation = byOccurrence.get(occurrenceKey(occurrence))
323
+ if (observation === undefined) {
324
+ throw new Error(
325
+ `cannot resolve blockers for harvest proposal "${proposal.title}": ` +
326
+ `missing scan observation ${occurrenceKey(occurrence)}`,
327
+ )
328
+ }
329
+ const origin = observation.ticket
330
+ if (origin === undefined || origin.source !== tickets.name || seenOrigins.has(origin.id)) {
331
+ continue
332
+ }
333
+ seenOrigins.add(origin.id)
334
+ originIds.push(origin.id)
335
+ }
336
+
337
+ const candidates = [...new Set([...declared, ...originIds])]
338
+ if (candidates.length === 0) {
339
+ return { blockedBy: [], provenance: { declared, derived: [] } }
340
+ }
341
+
342
+ let states: Awaited<ReturnType<TicketSource['dependencyStates']>>
343
+ try {
344
+ states = await tickets.dependencyStates(candidates)
345
+ } catch (error) {
346
+ throw new Error(
347
+ `cannot file harvest proposal "${proposal.title}" through ticket source ` +
348
+ `"${tickets.name}": blocker validation failed for ${candidates.map((id) => `"${id}"`).join(', ')}: ` +
349
+ `${error instanceof Error ? error.message : String(error)}`,
350
+ { cause: error },
351
+ )
352
+ }
353
+
354
+ const byId = new Map(states.map((state) => [state.id, state]))
355
+ const invalid = declared.filter((id) => byId.get(id)?.exists !== true)
356
+ if (invalid.length > 0) {
357
+ throw new Error(
358
+ `cannot file harvest proposal "${proposal.title}" through ticket source ` +
359
+ `"${tickets.name}": unknown or invalid blocker ${invalid.map((id) => `"${id}"`).join(', ')}`,
360
+ )
361
+ }
362
+
363
+ const derived = originIds.filter((id) => {
364
+ const state = byId.get(id)
365
+ return state?.exists === true && !state.resolved
366
+ })
367
+ return {
368
+ blockedBy: [...new Set([...declared, ...derived])],
369
+ provenance: { declared, derived },
370
+ }
371
+ }
372
+
373
+ export interface HarvestExhaustionPartition {
374
+ releasedObservations: OccurrenceKey[]
375
+ committedDispositions: HarvestDisposition[]
376
+ pendingProposals: HarvestPendingProposal[]
377
+ }
378
+
379
+ /** Determine the only safe give-up partition from frozen repository facts.
380
+ * No provider is queried and no external side effect occurs here: filed facts,
381
+ * the approved proposal artifact, and its original scan packet are the entire
382
+ * authority.
383
+ *
384
+ * Store read failures propagate so a transient outage can be retried. Once a
385
+ * read succeeds, malformed/missing artifacts and unclassifiable proposal
386
+ * members fail safe as pending work instead of making exhaustion itself a hot
387
+ * loop. */
388
+ export async function partitionHarvestExhaustion(opts: {
389
+ store: BuildStore
390
+ repo: string
391
+ run: HarvestRunState
392
+ }): Promise<HarvestExhaustionPartition> {
393
+ const { store, repo, run } = opts
394
+ const releaseWholeSnapshot = (): HarvestExhaustionPartition => ({
395
+ releasedObservations: structuredClone(run.observations),
396
+ committedDispositions: [],
397
+ pendingProposals: [],
398
+ })
399
+ const approval = [...run.reviews].reverse().find((review) => review.verdict === 'approve')
400
+ if (approval === undefined) return releaseWholeSnapshot()
401
+
402
+ const approved = proposalArtifactForRound(run, approval.round)
403
+ if (approved === undefined) return releaseWholeSnapshot()
404
+ // A rejected read is transient and must remain retryable. A successful
405
+ // missing result is durable corruption, so release rather than relaunching
406
+ // this same rejecting exhaustion settlement forever.
407
+ const artifact = await store.getRepoArtifact(repo, approved.kind, approved.rev)
408
+ if (artifact === null) return releaseWholeSnapshot()
409
+
410
+ let set: HarvestProposalSet
411
+ try {
412
+ set = parseApprovedProposalSet(new TextDecoder().decode(artifact.content), run.observations)
413
+ } catch {
414
+ return releaseWholeSnapshot()
415
+ }
416
+
417
+ // The scan packet is needed only to prove a join is still a valid frozen
418
+ // disposition. As above, transport/read errors propagate; missing,
419
+ // malformed, or mismatched content simply makes joins pending. Creates with
420
+ // durable filing facts and suppressions remain independently classifiable.
421
+ const scanArtifact = await store.getRepoArtifact(repo, run.scan.kind, run.scan.rev)
422
+ let packet: HarvestScanPacket | undefined
423
+ if (scanArtifact !== null) {
424
+ let raw: unknown
425
+ try {
426
+ raw = JSON.parse(new TextDecoder().decode(scanArtifact.content))
427
+ } catch {
428
+ raw = undefined
429
+ }
430
+ const parsed = harvestScanPacketSchema.safeParse(raw)
431
+ if (parsed.success && parsed.data.run === run.run) {
432
+ const packetOccurrences = new Set(
433
+ parsed.data.observations.map((item) => occurrenceKey(item.occurrence)),
434
+ )
435
+ if (
436
+ packetOccurrences.size === run.observations.length &&
437
+ run.observations.every((item) => packetOccurrences.has(occurrenceKey(item)))
438
+ ) {
439
+ packet = parsed.data
440
+ }
441
+ }
442
+ }
443
+
444
+ const knownLedger = new Map(
445
+ (packet?.ledger ?? []).map((entry) => [`${entry.ticket.source}:${entry.ticket.id}`, entry]),
446
+ )
447
+ const filed = new Map(run.filed.map((entry) => [entry.proposalKey, entry.ticket]))
448
+ const proposalKeys = new Set<string>()
449
+ for (const proposal of set.proposals) {
450
+ const proposalKey = harvestProposalKey(proposal)
451
+ if (proposalKeys.has(proposalKey)) return releaseWholeSnapshot()
452
+ proposalKeys.add(proposalKey)
453
+ }
454
+
455
+ const releasedObservations: OccurrenceKey[] = []
456
+ const committedDispositions: HarvestDisposition[] = []
457
+ const pendingProposals: HarvestPendingProposal[] = []
458
+ const releaseProposal = (proposal: HarvestProposal): void => {
459
+ const proposalKey = harvestProposalKey(proposal)
460
+ releasedObservations.push(...structuredClone(proposal.observations))
461
+ pendingProposals.push({
462
+ proposalKey,
463
+ action: proposal.action,
464
+ observations: structuredClone(proposal.observations),
465
+ })
466
+ }
467
+
468
+ for (const proposal of set.proposals) {
469
+ if (proposal.action === 'create') {
470
+ const proposalKey = harvestProposalKey(proposal)
471
+ const ticket = filed.get(proposalKey)
472
+ if (ticket === undefined) {
473
+ releaseProposal(proposal)
474
+ } else {
475
+ for (const occurrence of proposal.observations) {
476
+ committedDispositions.push({
477
+ occurrence: { ...occurrence },
478
+ action: 'filed',
479
+ proposalKey,
480
+ ticket: structuredClone(ticket),
481
+ })
482
+ }
483
+ }
484
+ continue
485
+ }
486
+
487
+ if (proposal.action === 'join') {
488
+ const known = knownLedger.get(`${proposal.ticket.source}:${proposal.ticket.id}`)
489
+ if (known === undefined || !known.exists || known.resolved) {
490
+ releaseProposal(proposal)
491
+ continue
492
+ }
493
+ for (const occurrence of proposal.observations) {
494
+ committedDispositions.push({
495
+ occurrence: { ...occurrence },
496
+ action: 'joined',
497
+ proposalKey: known.proposalKey,
498
+ ticket: structuredClone(known.ticket),
499
+ reason: proposal.reason,
500
+ })
501
+ }
502
+ continue
503
+ }
504
+
505
+ const proposalKey = harvestProposalKey(proposal)
506
+ for (const occurrence of proposal.observations) {
507
+ committedDispositions.push({
508
+ occurrence: { ...occurrence },
509
+ action: 'suppressed',
510
+ proposalKey,
511
+ reason: proposal.reason,
512
+ })
513
+ }
514
+ }
515
+
516
+ // Coverage was revalidated above, so this is a defensive fail-safe against a
517
+ // future classifier bug rather than a reachable content error.
518
+ const expected = new Set(run.observations.map(occurrenceKey))
519
+ const seen = new Set<string>()
520
+ for (const occurrence of [
521
+ ...committedDispositions.map((item) => item.occurrence),
522
+ ...releasedObservations,
523
+ ]) {
524
+ const key = occurrenceKey(occurrence)
525
+ if (!expected.has(key) || seen.has(key)) return releaseWholeSnapshot()
526
+ seen.add(key)
527
+ }
528
+ if (seen.size !== expected.size) return releaseWholeSnapshot()
529
+
530
+ return {
531
+ releasedObservations,
532
+ committedDispositions,
533
+ pendingProposals,
534
+ }
535
+ }
536
+
537
+ export function renderHarvestProposal(
538
+ proposal: Extract<HarvestProposal, { action: 'create' }>,
539
+ observations: HarvestObservation[],
540
+ ): string {
541
+ const byKey = new Map(
542
+ observations.map((observation) => [occurrenceKey(observation.occurrence), observation]),
543
+ )
544
+ const evidence = proposal.observations.map((key) => {
545
+ const observation = byKey.get(occurrenceKey(key))
546
+ if (!observation) {
547
+ throw new Error(
548
+ `cannot render proposal "${proposal.title}": missing claimed observation ${occurrenceKey(key)}`,
549
+ )
550
+ }
551
+ return (
552
+ `- build \`${key.build}\`, event seq ${key.seq}, observation ` +
553
+ `\`${observation.id}\`: ${observation.summary}`
554
+ )
555
+ })
556
+ const body = [
557
+ `# ${proposal.title}`,
558
+ '',
559
+ '## What and why',
560
+ '',
561
+ proposal.whatWhy,
562
+ '',
563
+ '## Acceptance criteria',
564
+ '',
565
+ ...proposal.acceptanceCriteria.map((criterion) => `- ${criterion}`),
566
+ '',
567
+ '## Out of scope',
568
+ '',
569
+ ...proposal.outOfScope.map((item) => `- ${item}`),
570
+ '',
571
+ '## Evidence',
572
+ '',
573
+ ...evidence,
574
+ '',
575
+ ].join('\n')
576
+ const conformance = specConformance(body)
577
+ if (!conformance.conforms) {
578
+ throw new Error(
579
+ `rendered harvest proposal "${proposal.title}" does not conform to the spec standard: ${conformance.missing.join('; ')}`,
580
+ )
581
+ }
582
+ return body
583
+ }
584
+
585
+ export async function loadScanPacket(
586
+ store: BuildStore,
587
+ repo: string,
588
+ ref: ArtifactRef,
589
+ ): Promise<HarvestScanPacket> {
590
+ const artifact = await store.getRepoArtifact(repo, ref.kind, ref.rev)
591
+ if (!artifact) {
592
+ throw new Error(`missing harvest scan artifact ${ref.kind}@${ref.rev}`)
593
+ }
594
+ return harvestScanPacketSchema.parse(JSON.parse(new TextDecoder().decode(artifact.content)))
595
+ }