@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,698 @@
1
+ /** Typed agent/operator surface for repository-scoped harvest workflows. Agent
2
+ * stdout is never interpreted: proposal sets and review verdicts land through
3
+ * this module as validated repository events and versioned artifacts. */
4
+ import { mkdir, rm, writeFile } from 'node:fs/promises'
5
+ import { dirname, join } from 'node:path'
6
+ import { z } from 'zod'
7
+ import {
8
+ isHarvestEvent,
9
+ type HarvestEvent,
10
+ type RepositoryEvent,
11
+ type RepositoryEventEnvelope,
12
+ } from '../events/repository'
13
+ import { agentActor } from '../events/envelope'
14
+ import {
15
+ harvestProposalSetSchema,
16
+ type HarvestProposalSet,
17
+ type HarvestScanPacket,
18
+ } from '../harvest/schema'
19
+ import type { IdSource } from '../ids'
20
+ import {
21
+ DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS,
22
+ openHarvestRuns,
23
+ parkedHarvestRuns,
24
+ proposalArtifactForRound,
25
+ reduceHarvest,
26
+ unresolvedHarvestAttentionRuns,
27
+ type HarvestPendingCommand,
28
+ type HarvestRunState,
29
+ } from '../kernel/harvest'
30
+ import { findingDraftSchema, type Finding } from '../ontology'
31
+ import type { Exec } from '../ports/workspace/git-worktree'
32
+ import {
33
+ HARVEST_PROPOSALS_ARTIFACT,
34
+ HARVEST_REVIEW_ARTIFACT,
35
+ loadScanPacket,
36
+ validateProposalCoverage,
37
+ } from '../processes/harvest'
38
+ import type { BuildStore } from '../store/types'
39
+ import type { HarvestCliEnv } from './env'
40
+ import { withSessionlessStore, type StoreOpener } from './store-opening'
41
+
42
+ export interface HarvestCliDeps {
43
+ store: BuildStore
44
+ env: HarvestCliEnv
45
+ workspacePath: string
46
+ ids: IdSource
47
+ }
48
+
49
+ export interface HarvestContextManifest {
50
+ repo: string
51
+ run: string
52
+ phase: 'synthesize' | 'review'
53
+ round: number
54
+ required: string[]
55
+ allowedTerminal: 'submit' | 'verdict'
56
+ materialized: string[]
57
+ }
58
+
59
+ async function wipeAbDir(dir: string): Promise<void> {
60
+ await rm(dir, { recursive: true, force: true })
61
+ await mkdir(dir, { recursive: true })
62
+ await writeFile(join(dir, '.gitignore'), '*\n')
63
+ }
64
+
65
+ async function writeJson(path: string, value: unknown): Promise<void> {
66
+ await mkdir(dirname(path), { recursive: true })
67
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`)
68
+ }
69
+
70
+ function runForEnv(events: RepositoryEvent[], env: HarvestCliEnv): HarvestRunState {
71
+ const run = reduceHarvest(events).runs.find((candidate) => candidate.run === env.run)
72
+ if (!run) throw new Error(`unknown harvest run "${env.run}" in repo "${env.repo}"`)
73
+ if (run.status !== 'running') {
74
+ throw new Error(
75
+ `harvest run "${env.run}" is already ${run.status} — its agent session may not deposit another terminal`,
76
+ )
77
+ }
78
+ return run
79
+ }
80
+
81
+ function assertNoSessionTerminal(events: RepositoryEvent[], env: HarvestCliEnv): void {
82
+ for (const event of events) {
83
+ if (
84
+ event.type === 'harvest.proposals.submitted' &&
85
+ env.phase === 'synthesize' &&
86
+ event.payload.run === env.run &&
87
+ event.payload.round === env.round
88
+ ) {
89
+ throw new Error(
90
+ `second harvest terminal rejected: proposals for ${env.run} synthesize@${env.round} already landed at repo seq ${event.seq}`,
91
+ )
92
+ }
93
+ if (
94
+ event.type === 'harvest.review.verdict' &&
95
+ env.phase === 'review' &&
96
+ event.payload.run === env.run &&
97
+ event.payload.round === env.round
98
+ ) {
99
+ throw new Error(
100
+ `second harvest terminal rejected: verdict for ${env.run} review@${env.round} already landed at repo seq ${event.seq}`,
101
+ )
102
+ }
103
+ if (event.type === 'harvest.session.ended' && event.payload.session === env.session) {
104
+ throw new Error(
105
+ `harvest terminal rejected: session "${env.session}" already ended at repo seq ${event.seq}`,
106
+ )
107
+ }
108
+ }
109
+ }
110
+
111
+ async function readArtifactJson(
112
+ store: BuildStore,
113
+ repo: string,
114
+ kind: string,
115
+ rev: number,
116
+ ): Promise<unknown> {
117
+ const artifact = await store.getRepoArtifact(repo, kind, rev)
118
+ if (!artifact) throw new Error(`missing repository artifact ${kind}@${rev}`)
119
+ return JSON.parse(new TextDecoder().decode(artifact.content))
120
+ }
121
+
122
+ export async function buildHarvestContext(deps: HarvestCliDeps): Promise<HarvestContextManifest> {
123
+ const { store, env, workspacePath } = deps
124
+ const events = await store.getRepoEvents(env.repo)
125
+ const run = runForEnv(events, env)
126
+ const packet = await loadScanPacket(store, env.repo, run.scan)
127
+ const abDir = join(workspacePath, '.ab')
128
+ await wipeAbDir(abDir)
129
+
130
+ const materialized: string[] = []
131
+ const materialize = async (relative: string, value: unknown): Promise<void> => {
132
+ await writeJson(join(abDir, relative), value)
133
+ materialized.push(relative)
134
+ }
135
+
136
+ await materialize('observations.json', packet.observations)
137
+ await materialize('originating-tickets.json', packet.originatingTickets)
138
+ await materialize('ledger.json', packet.ledger)
139
+
140
+ const proposal = proposalArtifactForRound(run, env.round)
141
+ if (proposal !== undefined) {
142
+ await materialize(
143
+ 'proposals.json',
144
+ await readArtifactJson(store, env.repo, proposal.kind, proposal.rev),
145
+ )
146
+ }
147
+
148
+ const priorReviews = run.reviews
149
+ .filter((review) => review.round < env.round)
150
+ .sort((a, b) => a.round - b.round)
151
+ for (const review of priorReviews) {
152
+ if (review.findings.length > 0) {
153
+ await materialize(`history/findings-r${review.round}.json`, review.findings)
154
+ }
155
+ }
156
+ if (env.phase === 'synthesize' && env.round > 1) {
157
+ const previous = [...run.reviews]
158
+ .reverse()
159
+ .find((review) => review.round === env.round - 1 && review.verdict === 'revise')
160
+ if (previous !== undefined) {
161
+ await materialize('findings.json', previous.findings)
162
+ const previousProposal = proposalArtifactForRound(run, env.round - 1)
163
+ if (previousProposal !== undefined && proposal === undefined) {
164
+ await materialize(
165
+ 'proposals.json',
166
+ await readArtifactJson(store, env.repo, previousProposal.kind, previousProposal.rev),
167
+ )
168
+ }
169
+ }
170
+ }
171
+
172
+ const manifest: HarvestContextManifest = {
173
+ repo: env.repo,
174
+ run: env.run,
175
+ phase: env.phase,
176
+ round: env.round,
177
+ required: env.phase === 'synthesize' ? [HARVEST_PROPOSALS_ARTIFACT] : [HARVEST_REVIEW_ARTIFACT],
178
+ allowedTerminal: env.phase === 'synthesize' ? 'submit' : 'verdict',
179
+ materialized,
180
+ }
181
+ await writeJson(join(abDir, 'context.json'), manifest)
182
+ return manifest
183
+ }
184
+
185
+ async function readJsonFile(path: string): Promise<unknown> {
186
+ const file = Bun.file(path)
187
+ if (!(await file.exists())) throw new Error(`file not found: ${path}`)
188
+ try {
189
+ return JSON.parse(await file.text())
190
+ } catch (error) {
191
+ throw new Error(
192
+ `${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
193
+ )
194
+ }
195
+ }
196
+
197
+ function validateJoinTargets(set: HarvestProposalSet, packet: HarvestScanPacket): void {
198
+ const allowed = new Set(packet.ledger.map((entry) => `${entry.ticket.source}:${entry.ticket.id}`))
199
+ for (const proposal of set.proposals) {
200
+ if (proposal.action !== 'join') continue
201
+ const key = `${proposal.ticket.source}:${proposal.ticket.id}`
202
+ if (!allowed.has(key)) {
203
+ throw new Error(
204
+ `join target ${key} was not supplied in the harvest ledger context — agents may only join a known prior proposal`,
205
+ )
206
+ }
207
+ }
208
+ }
209
+
210
+ export async function submitHarvestProposals(
211
+ deps: HarvestCliDeps,
212
+ file: string,
213
+ ): Promise<RepositoryEventEnvelope<'harvest.proposals.submitted'>> {
214
+ const { store, env } = deps
215
+ if (env.phase !== 'synthesize') {
216
+ throw new Error(
217
+ `'ab harvest submit' is only valid in synthesize@<round>; current phase is ${env.phase}@${env.round}`,
218
+ )
219
+ }
220
+ const events = await store.getRepoEvents(env.repo)
221
+ const run = runForEnv(events, env)
222
+ assertNoSessionTerminal(events, env)
223
+
224
+ const parsed = harvestProposalSetSchema.safeParse(await readJsonFile(file))
225
+ if (!parsed.success) {
226
+ throw new Error(`${file} does not match the harvest proposal schema: ${parsed.error.message}`)
227
+ }
228
+ const coverage = validateProposalCoverage(parsed.data, run.observations)
229
+ if (!coverage.ok) {
230
+ throw new Error(
231
+ `harvest proposals must cover every claimed observation exactly once: ${coverage.errors.join('; ')}`,
232
+ )
233
+ }
234
+ const packet = await loadScanPacket(store, env.repo, run.scan)
235
+ validateJoinTargets(parsed.data, packet)
236
+
237
+ const { event } = await store.appendRepoWithArtifacts(
238
+ env.repo,
239
+ [
240
+ {
241
+ kind: HARVEST_PROPOSALS_ARTIFACT,
242
+ content: `${JSON.stringify(parsed.data, null, 2)}\n`,
243
+ metadata: { run: env.run, round: env.round },
244
+ },
245
+ ],
246
+ (deposited) => ({
247
+ actor: agentActor('harvest', env.session),
248
+ type: 'harvest.proposals.submitted',
249
+ payload: {
250
+ run: env.run,
251
+ round: env.round,
252
+ artifact: {
253
+ kind: deposited[0]!.kind,
254
+ rev: deposited[0]!.revision,
255
+ },
256
+ },
257
+ }),
258
+ )
259
+ return event
260
+ }
261
+
262
+ const FINDINGS_SHAPE = z.array(findingDraftSchema)
263
+
264
+ async function parseHarvestFindings(
265
+ deps: HarvestCliDeps,
266
+ file: string,
267
+ run: HarvestRunState,
268
+ ): Promise<Finding[]> {
269
+ const parsed = FINDINGS_SHAPE.safeParse(await readJsonFile(file))
270
+ if (!parsed.success) {
271
+ throw new Error(`${file} does not match the finding schema: ${parsed.error.message}`)
272
+ }
273
+ const known = new Set(
274
+ run.reviews
275
+ .filter((review) => review.round < deps.env.round)
276
+ .flatMap((review) => review.findings.map((finding) => finding.id)),
277
+ )
278
+ for (const finding of parsed.data) {
279
+ for (const id of finding.persists) {
280
+ if (!known.has(id)) {
281
+ throw new Error(`persists id "${id}" is not a finding from an earlier harvest review round`)
282
+ }
283
+ }
284
+ }
285
+ return parsed.data.map((finding) => ({ ...finding, id: deps.ids('hf') }))
286
+ }
287
+
288
+ export interface HarvestVerdictOpts {
289
+ verdict: string
290
+ notes: string
291
+ findings?: string
292
+ reason?: string
293
+ }
294
+
295
+ export async function submitHarvestVerdict(
296
+ deps: HarvestCliDeps,
297
+ opts: HarvestVerdictOpts,
298
+ ): Promise<RepositoryEventEnvelope<'harvest.review.verdict'>> {
299
+ const { store, env } = deps
300
+ if (env.phase !== 'review') {
301
+ throw new Error(
302
+ `'ab harvest verdict' is only valid in review@<round>; current phase is ${env.phase}@${env.round}`,
303
+ )
304
+ }
305
+ if (!['approve', 'revise', 'escalate'].includes(opts.verdict)) {
306
+ throw new Error(
307
+ `harvest verdict "${opts.verdict}" is invalid — expected approve | revise | escalate`,
308
+ )
309
+ }
310
+ const events = await store.getRepoEvents(env.repo)
311
+ const run = runForEnv(events, env)
312
+ assertNoSessionTerminal(events, env)
313
+ if (proposalArtifactForRound(run, env.round) === undefined) {
314
+ throw new Error(
315
+ `review@${env.round} has no proposal artifact to review — synthesize must submit first`,
316
+ )
317
+ }
318
+
319
+ const notesFile = Bun.file(opts.notes)
320
+ if (!(await notesFile.exists())) throw new Error(`--notes file not found: ${opts.notes}`)
321
+ const notes = await notesFile.text()
322
+ let findings: Finding[] = []
323
+ if (opts.verdict === 'revise') {
324
+ if (opts.findings === undefined) {
325
+ throw new Error(`'ab harvest verdict revise' requires --findings <json>`)
326
+ }
327
+ findings = await parseHarvestFindings(deps, opts.findings, run)
328
+ if (findings.length === 0) {
329
+ throw new Error(`'ab harvest verdict revise' requires at least one finding`)
330
+ }
331
+ } else if (opts.findings !== undefined) {
332
+ throw new Error(`--findings is only valid with the revise verdict`)
333
+ }
334
+ const reason = opts.reason?.trim()
335
+ if (opts.verdict === 'escalate' && !reason) {
336
+ throw new Error(`'ab harvest verdict escalate' requires --reason <text>`)
337
+ }
338
+
339
+ const { event } = await store.appendRepoWithArtifacts(
340
+ env.repo,
341
+ [
342
+ {
343
+ kind: HARVEST_REVIEW_ARTIFACT,
344
+ content: notes,
345
+ metadata: {
346
+ run: env.run,
347
+ round: env.round,
348
+ verdict: opts.verdict,
349
+ },
350
+ },
351
+ ],
352
+ (deposited) => ({
353
+ actor: agentActor('harvest-review', env.session),
354
+ type: 'harvest.review.verdict',
355
+ payload: {
356
+ run: env.run,
357
+ round: env.round,
358
+ verdict: opts.verdict as 'approve' | 'revise' | 'escalate',
359
+ findings,
360
+ artifact: {
361
+ kind: deposited[0]!.kind,
362
+ rev: deposited[0]!.revision,
363
+ },
364
+ ...(reason ? { reason } : {}),
365
+ },
366
+ }),
367
+ )
368
+ return event
369
+ }
370
+
371
+ export interface HarvestRecoveryStatus {
372
+ /** True only for an ordinary infrastructure stop the dispatcher may reopen. */
373
+ recoverable: boolean
374
+ /** Completed, escalated, and recovery-exhausted runs are genuinely terminal. */
375
+ finished: boolean
376
+ automatic: {
377
+ attempts: number
378
+ limit: number
379
+ exhausted: boolean
380
+ }
381
+ stopped?: {
382
+ step: NonNullable<HarvestRunState['failure']>['step']
383
+ round?: number
384
+ }
385
+ attention: {
386
+ required: boolean
387
+ acknowledged: boolean
388
+ }
389
+ pending: {
390
+ observations: Array<{ build: string; seq: number }>
391
+ proposalKeys: string[]
392
+ }
393
+ }
394
+
395
+ export interface HarvestFiledStatusView {
396
+ proposalKey: string
397
+ ticket: { source: string; id: string }
398
+ blockers?: { declared: string[]; derived: string[] }
399
+ }
400
+
401
+ export interface HarvestRunStatusView {
402
+ run: string
403
+ status: HarvestRunState['status']
404
+ startedSeq: number
405
+ startedAt: string
406
+ observations: number
407
+ trigger?: HarvestRunState['trigger']
408
+ steps: HarvestRunState['steps']
409
+ rounds: number
410
+ filed: HarvestFiledStatusView[]
411
+ escalation?: HarvestRunState['escalation']
412
+ failure?: HarvestRunState['failure']
413
+ recovery: HarvestRecoveryStatus
414
+ }
415
+
416
+ export interface HarvestStatusView {
417
+ repo: string
418
+ run?: string
419
+ /** Repository control takes display precedence while paused; runStatus keeps
420
+ * the primary run lifecycle queryable without making pause terminal. */
421
+ status: 'idle' | 'paused' | HarvestRunState['status']
422
+ runStatus?: HarvestRunState['status']
423
+ paused: boolean
424
+ pausedSeq?: number
425
+ pausedAt?: string
426
+ pendingCommands: HarvestPendingCommand[]
427
+ /** Every unresolved failed run plus all open runs and the latest historical
428
+ * summary, in durable start order and without duplicate entries. */
429
+ runs: HarvestRunStatusView[]
430
+ /** Backward-compatible primary-run fields. `runs` is authoritative when
431
+ * more than one workflow needs to be reported. */
432
+ observations: number
433
+ trigger?: HarvestRunState['trigger']
434
+ steps: HarvestRunState['steps']
435
+ rounds: number
436
+ filed: HarvestFiledStatusView[]
437
+ escalation?: HarvestRunState['escalation']
438
+ failure?: HarvestRunState['failure']
439
+ recovery: HarvestRecoveryStatus
440
+ events?: HarvestEvent[]
441
+ }
442
+
443
+ function projectRecovery(run: HarvestRunState | undefined): HarvestRecoveryStatus {
444
+ const exhaustion = run?.recoveryExhaustion
445
+ const attempts = run?.recoveryRequests.length ?? 0
446
+ const limit =
447
+ exhaustion?.limit ??
448
+ run?.recoveryRequests.at(-1)?.limit ??
449
+ DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS
450
+ const stopped = run?.failure
451
+ return {
452
+ recoverable: run?.status === 'failed' && exhaustion === undefined,
453
+ finished:
454
+ run?.status === 'completed' || run?.status === 'escalated' || exhaustion !== undefined,
455
+ automatic: {
456
+ attempts,
457
+ limit,
458
+ exhausted: exhaustion !== undefined,
459
+ },
460
+ ...(stopped !== undefined
461
+ ? {
462
+ stopped: {
463
+ step: stopped.step,
464
+ ...(stopped.round !== undefined ? { round: stopped.round } : {}),
465
+ },
466
+ }
467
+ : {}),
468
+ attention: {
469
+ required: exhaustion !== undefined && exhaustion.attentionAcknowledgedSeq === undefined,
470
+ acknowledged: exhaustion?.attentionAcknowledgedSeq !== undefined,
471
+ },
472
+ pending: {
473
+ // Before give-up the immutable claimed snapshot is the exact retained
474
+ // work. Exhaustion classifies it atomically, after which only the
475
+ // selectively released subset remains pending.
476
+ observations: structuredClone(
477
+ exhaustion?.releasedObservations ?? (run?.status === 'failed' ? run.observations : []),
478
+ ),
479
+ proposalKeys: exhaustion?.pendingProposals.map((proposal) => proposal.proposalKey) ?? [],
480
+ },
481
+ }
482
+ }
483
+
484
+ function projectHarvestRunStatus(run: HarvestRunState): HarvestRunStatusView {
485
+ return {
486
+ run: run.run,
487
+ status: run.status,
488
+ startedSeq: run.startedSeq,
489
+ startedAt: run.startedAt,
490
+ observations: run.observations.length,
491
+ ...(run.trigger !== undefined ? { trigger: run.trigger } : {}),
492
+ steps: run.steps,
493
+ rounds: Math.max(0, ...run.reviews.map((review) => review.round)),
494
+ filed: run.filed.map((entry) => ({
495
+ proposalKey: entry.proposalKey,
496
+ ticket: { source: entry.ticket.source, id: entry.ticket.id },
497
+ ...(entry.blockers !== undefined ? { blockers: structuredClone(entry.blockers) } : {}),
498
+ })),
499
+ ...(run.escalation !== undefined ? { escalation: run.escalation } : {}),
500
+ ...(run.failure !== undefined ? { failure: run.failure } : {}),
501
+ recovery: projectRecovery(run),
502
+ }
503
+ }
504
+
505
+ export function projectHarvestStatus(
506
+ repo: string,
507
+ events: RepositoryEvent[],
508
+ newestEvents?: number,
509
+ ): HarvestStatusView {
510
+ const state = reduceHarvest(events)
511
+ const history =
512
+ newestEvents === undefined ? undefined : events.filter(isHarvestEvent).slice(-newestEvents)
513
+ const unresolved = [...parkedHarvestRuns(state), ...unresolvedHarvestAttentionRuns(state)]
514
+ const open = openHarvestRuns(state)
515
+ const included = new Set(
516
+ [...unresolved, ...open, ...(state.latest ? [state.latest] : [])].map((run) => run.run),
517
+ )
518
+ const runs = state.runs.filter((run) => included.has(run.run)).map(projectHarvestRunStatus)
519
+ const unresolvedIds = new Set(unresolved.map((run) => run.run))
520
+ const primary =
521
+ runs.find((run) => unresolvedIds.has(run.run)) ??
522
+ runs.find((run) => run.status === 'running') ??
523
+ runs.at(-1)
524
+
525
+ const control = {
526
+ paused: state.paused,
527
+ ...(state.pausedSeq !== undefined ? { pausedSeq: state.pausedSeq } : {}),
528
+ ...(state.pausedAt !== undefined ? { pausedAt: state.pausedAt } : {}),
529
+ pendingCommands: state.pendingCommands,
530
+ runs,
531
+ ...(history !== undefined ? { events: history } : {}),
532
+ }
533
+ if (primary === undefined) {
534
+ return {
535
+ repo,
536
+ status: state.paused ? 'paused' : 'idle',
537
+ ...control,
538
+ observations: 0,
539
+ steps: [],
540
+ rounds: 0,
541
+ filed: [],
542
+ recovery: projectRecovery(undefined),
543
+ }
544
+ }
545
+
546
+ const { status: runStatus, startedSeq: _startedSeq, startedAt: _startedAt, ...summary } = primary
547
+ return {
548
+ repo,
549
+ status: state.paused ? 'paused' : runStatus,
550
+ runStatus,
551
+ ...control,
552
+ ...summary,
553
+ }
554
+ }
555
+
556
+ function renderHarvestRunStatus(run: HarvestRunStatusView, paused: boolean): string[] {
557
+ const displayStatus = paused ? 'paused' : run.status
558
+ const lines = [
559
+ `harvest ${run.run} — ${displayStatus}${paused ? ` (run ${run.status})` : ''}`,
560
+ `observations: ${run.observations}`,
561
+ ...(run.trigger !== undefined ? [`trigger: ${run.trigger}`] : []),
562
+ `review rounds: ${run.rounds}`,
563
+ 'steps:',
564
+ ]
565
+ for (const step of run.steps) {
566
+ lines.push(
567
+ ` ${step.step}${step.round !== undefined ? ` r${step.round}` : ''}: ` +
568
+ `${step.outcome ?? (step.completedSeq === undefined ? 'running' : 'done')}`,
569
+ )
570
+ }
571
+ if (run.recovery.stopped !== undefined) {
572
+ lines.push(
573
+ `stopped at: ${run.recovery.stopped.step}${
574
+ run.recovery.stopped.round !== undefined ? ` r${run.recovery.stopped.round}` : ''
575
+ }`,
576
+ )
577
+ }
578
+ if (
579
+ run.recovery.recoverable ||
580
+ run.recovery.automatic.attempts > 0 ||
581
+ run.recovery.automatic.exhausted
582
+ ) {
583
+ lines.push(
584
+ `automatic recovery: ${run.recovery.automatic.attempts}/${run.recovery.automatic.limit}${
585
+ run.recovery.automatic.exhausted
586
+ ? ' exhausted'
587
+ : run.recovery.recoverable
588
+ ? ' available'
589
+ : ' resumed'
590
+ }`,
591
+ )
592
+ }
593
+ const observations = run.recovery.pending.observations.map(
594
+ (occurrence) => `${occurrence.build}:${occurrence.seq}`,
595
+ )
596
+ const proposals = run.recovery.pending.proposalKeys
597
+ if (observations.length > 0 || proposals.length > 0) {
598
+ lines.push(
599
+ `pending: ${observations.length} observation${observations.length === 1 ? '' : 's'}` +
600
+ `${observations.length > 0 ? ` (${observations.join(', ')})` : ''}` +
601
+ `${proposals.length > 0 ? `; proposals ${proposals.join(', ')}` : ''}`,
602
+ )
603
+ }
604
+ if (run.recovery.automatic.exhausted) {
605
+ lines.push(
606
+ run.recovery.attention.required
607
+ ? 'attention: human acknowledgement required'
608
+ : 'attention: acknowledged',
609
+ )
610
+ }
611
+ if (run.escalation) lines.push(`escalation: ${run.escalation.reason}`)
612
+ if (run.failure) lines.push(`failure: ${run.failure.error}`)
613
+ if (run.filed.length > 0) {
614
+ lines.push('filed:')
615
+ for (const entry of run.filed) {
616
+ lines.push(` ${entry.proposalKey} -> ${entry.ticket.source}:${entry.ticket.id}`)
617
+ if (entry.blockers !== undefined) {
618
+ lines.push(
619
+ ` declared blockers: ${entry.blockers.declared.join(', ') || 'none'}`,
620
+ ` originating blockers: ${entry.blockers.derived.join(', ') || 'none'}`,
621
+ )
622
+ }
623
+ }
624
+ }
625
+ return lines
626
+ }
627
+
628
+ export function renderHarvestStatus(view: HarvestStatusView): string[] {
629
+ if (view.runs.length === 0) {
630
+ const lines = [
631
+ `harvest ${view.repo}: ${view.status === 'paused' ? 'paused' : 'idle'} (no runs)`,
632
+ ]
633
+ if (view.pendingCommands.length > 0) {
634
+ lines.push(
635
+ `control pending: ${view.pendingCommands.map((command) => command.command).join(', ')}`,
636
+ )
637
+ }
638
+ if (view.events !== undefined) {
639
+ lines.push('events:')
640
+ for (const event of view.events) {
641
+ lines.push(` ${event.seq} ${event.ts} ${event.type}`)
642
+ }
643
+ }
644
+ return lines
645
+ }
646
+
647
+ const lines: string[] = []
648
+ for (const run of view.runs) {
649
+ if (lines.length > 0) lines.push('')
650
+ lines.push(...renderHarvestRunStatus(run, view.paused))
651
+ }
652
+ if (view.pendingCommands.length > 0) {
653
+ lines.splice(
654
+ 1,
655
+ 0,
656
+ `control pending: ${view.pendingCommands.map((command) => command.command).join(', ')}`,
657
+ )
658
+ }
659
+ if (view.events !== undefined) {
660
+ lines.push('events:')
661
+ for (const event of view.events) {
662
+ lines.push(` ${event.seq} ${event.ts} ${event.type}`)
663
+ }
664
+ }
665
+ return lines
666
+ }
667
+
668
+ export interface HarvestStatusOpts {
669
+ /** Current directory; normalized to the main checkout before journal reads. */
670
+ repo: string
671
+ env: Record<string, string | undefined>
672
+ exec: Exec
673
+ stdout: (line: string) => void
674
+ storeRef?: string
675
+ json?: boolean
676
+ events?: number
677
+ /** Injectable store seam for command tests. */
678
+ openStore?: StoreOpener
679
+ }
680
+
681
+ export async function abHarvestStatus(opts: HarvestStatusOpts): Promise<void> {
682
+ await withSessionlessStore(
683
+ {
684
+ targetRepo: opts.repo,
685
+ env: opts.env,
686
+ exec: opts.exec,
687
+ ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
688
+ ...(opts.openStore !== undefined ? { openStore: opts.openStore } : {}),
689
+ },
690
+ async ({ store, repo }) => {
691
+ const record = await store.getRepo(repo)
692
+ const events = record === null ? [] : await store.getRepoEvents(repo)
693
+ const view = projectHarvestStatus(repo, events, opts.events)
694
+ if (opts.json === true) opts.stdout(JSON.stringify(view, null, 2))
695
+ else for (const line of renderHarvestStatus(view)) opts.stdout(line)
696
+ },
697
+ )
698
+ }