@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,1399 @@
1
+ /**
2
+ * `ab` — the only channel between agents and the store, and the enforcement
3
+ * point of the entire ontology (SPEC §8). This module is pure routing: every
4
+ * dependency is injected via CliDeps, so the whole surface is testable over
5
+ * fakes; wiring real deps (resolved env, resolved store, real forge, real
6
+ * exec, random ids) is `src/cli/binary.ts`'s job.
7
+ *
8
+ * Errors go to stderr with exit code 1, formatted as agent feedback (D6):
9
+ * what was wrong and what would be accepted, so the in-session correction
10
+ * loop is immediate and cheap.
11
+ */
12
+ import { readFile } from 'node:fs/promises'
13
+ import { join } from 'node:path'
14
+ import type { TicketRef } from '../ontology'
15
+ import type { Clock } from '../store/types'
16
+ import type { BuildStore } from '../store/types'
17
+ import { textContent } from '../store/types'
18
+ import type { Forge } from '../ports/types'
19
+ import type { Exec } from '../ports/workspace/git-worktree'
20
+ import type { IdSource } from '../ids'
21
+ import { reduceBuild } from '../kernel/reducer'
22
+ import { artifactDownload, artifactGet, artifactPut } from './artifact'
23
+ import { multiFlag, parseArgs, stringFlag, type ParsedArgs } from './args'
24
+ import { abBuildControl, type BuildControlAction, type BuildControlResult } from './build-control'
25
+ import {
26
+ abBulkControl,
27
+ bulkControlLines,
28
+ bulkFailureLines,
29
+ BulkWalkError,
30
+ type BulkDirection,
31
+ } from './bulk-control'
32
+ import { buildContext, type ContextManifest } from './context'
33
+ import { abDispatch } from './dispatch'
34
+ import type { DashboardRendererResolver } from './dashboard/render'
35
+ import type { TerminalInput, TerminalOut } from './terminal'
36
+ import type { CliEnv, HarvestCliEnv } from './env'
37
+ import { abInit } from './init'
38
+ import { validateInitReadiness } from './init-validation'
39
+ import { loadDotEnv } from './dotenv'
40
+ import type { RuntimeRegistry } from '../ports/runner/runtime'
41
+ import type { SetupAgentLauncher } from './init-agent'
42
+ import { abModels } from './models'
43
+ import { recognizeHelpRequest, renderCommandHelp, renderTopLevelHelp } from './help'
44
+ import { observe } from './observe'
45
+ import { abPlugin, type PluginContractSubprocess } from './plugin'
46
+ import { preparePrAttachments } from './pr-attachments'
47
+ import { renderPrSummary } from './pr-summary'
48
+ import { abBuilds, abBuildStatus } from './status'
49
+ import { abRepositoryStatus } from './repository-status'
50
+ import { abWatch, WATCH_USAGE } from './watch'
51
+ import { abWait, WAIT_USAGE } from './wait'
52
+ import { abMcp, AB_MCP_USAGE } from './mcp'
53
+ import type { StoreOpener } from './store-opening'
54
+ import { done, escalate, verdict } from './terminals'
55
+ import { abTicket, openTicketSource } from './ticket'
56
+ import { abUpgrade, type ResolveConflict } from './upgrade'
57
+ import { withUpgradeProgress } from './upgrade-progress'
58
+ import {
59
+ cleanupUpgradeCommitContext,
60
+ createUpgradeCommitContext,
61
+ finishUpgradeCommit,
62
+ loadUpgradeCommitContext,
63
+ UPGRADE_COMMIT_CONTEXT_ENV,
64
+ type UpgradeCommitContext,
65
+ } from './upgrade-commit'
66
+ import { formatInstalledVersion, readDistributionIdentity } from './installation'
67
+ import {
68
+ selfUpdate,
69
+ SELF_UPDATE_HANDOFF_ENV,
70
+ type SelfUpdateCommand,
71
+ type SelfUpdateOptions,
72
+ type SelfUpdateResult,
73
+ } from './self-update'
74
+ import {
75
+ abHarvestStatus,
76
+ buildHarvestContext,
77
+ submitHarvestProposals,
78
+ submitHarvestVerdict,
79
+ type HarvestCliDeps,
80
+ } from './harvest'
81
+
82
+ /**
83
+ * Commands that run OUTSIDE phase sessions (§16.3, §8.8, §3.3). Repository
84
+ * commands resolve the cwd; durable controls additionally take a target build
85
+ * slug. None requires the ambient AB_* phase tuple.
86
+ *
87
+ * `src/cli/binary.ts` routes through `isSessionlessInvocation` below. This set
88
+ * owns flat command names; that helper additionally owns the few mixed nested
89
+ * namespaces (`artifact download`, `harvest status`). The `builds`, `build
90
+ * status`, and `artifact download` shells remain on this operator-sessionless
91
+ * route but use their dedicated Store opener to honor a complete ambient phase
92
+ * identity and reject malformed identity. Everything else first attempts
93
+ * strict ambient resolution in `binary.ts`. Keeping classification beside the
94
+ * switch makes it unit-testable.
95
+ */
96
+ export const SESSIONLESS_COMMANDS = new Set([
97
+ 'init',
98
+ 'upgrade',
99
+ 'update',
100
+ 'ticket',
101
+ 'dispatch',
102
+ 'builds',
103
+ 'build',
104
+ 'watch',
105
+ 'wait',
106
+ 'mcp',
107
+ 'repository',
108
+ 'pause',
109
+ 'resume',
110
+ 'auto-merge',
111
+ 'answer',
112
+ 'abort',
113
+ 'models',
114
+ 'plugin',
115
+ 'help',
116
+ '--help',
117
+ '-h',
118
+ '--version',
119
+ ])
120
+
121
+ /** Nested namespaces can mix operator and phase forms. `artifact download`
122
+ * uses operator-sessionless routing plus ambient-aware read authority; put/get
123
+ * remain phase-only. */
124
+ export function isSessionlessInvocation(argv: readonly string[]): boolean {
125
+ const command = argv[0]
126
+ return (
127
+ command === undefined ||
128
+ recognizeHelpRequest(argv) !== undefined ||
129
+ SESSIONLESS_COMMANDS.has(command) ||
130
+ (command === 'artifact' && argv[1] === 'download') ||
131
+ (command === 'harvest' && argv[1] === 'status')
132
+ )
133
+ }
134
+
135
+ export interface CliDeps {
136
+ store: BuildStore
137
+ /** Resolved ambient auth (D8) — see resolveCliEnv (src/cli/env.ts). */
138
+ env: CliEnv
139
+ workspacePath: string
140
+ forge: Forge
141
+ exec: Exec
142
+ ids: IdSource
143
+ clock: Clock
144
+ stdout: (line: string) => void
145
+ stderr: (line: string) => void
146
+ }
147
+
148
+ /**
149
+ * What `runCli` minimally needs. `ab init` and `ab upgrade` run OUTSIDE
150
+ * build sessions (§16.3) — they take a repo path, not a build, and must
151
+ * work with no AB_* environment — so the session surface is optional here.
152
+ * The binary also uses this narrow shape when ambient phase context is absent,
153
+ * allowing requireSession/requireHarvestSession to return namespace-aware
154
+ * guidance without constructing a store. Full CliDeps satisfies this
155
+ * structurally, so wired-up callers are unchanged.
156
+ */
157
+ export interface SessionlessCliDeps {
158
+ /** For init/upgrade this is just the cwd — the default target repo. */
159
+ workspacePath: string
160
+ stdout: (line: string) => void
161
+ stderr: (line: string) => void
162
+ /** Raw process environment for sessionless store selection, operator
163
+ * provenance/self-control checks, and adapter secrets; distinct from `env`,
164
+ * the resolved phase-session tuple. */
165
+ processEnv?: Record<string, string | undefined>
166
+ /** Stop signal for long-running sessionless commands (`ab dispatch`'s watch
167
+ * loop); the binary aborts it on SIGINT. */
168
+ signal?: AbortSignal
169
+ /** Interactive output seam for live dispatch and upgrade rendering. Absent ⇒
170
+ * non-interactive ⇒ plain, line-oriented output. */
171
+ terminal?: TerminalOut
172
+ /** Raw keyboard seam for interactive dispatch and upgrade controls. */
173
+ input?: TerminalInput
174
+ /** True only when both stdin and stdout are interactive terminals. */
175
+ initInteractive?: boolean
176
+ /** Injectable direct interactive child-process seam for init tests. */
177
+ initLauncher?: SetupAgentLauncher
178
+ /** Injectable init detection registry; production uses shipped registrations. */
179
+ initRuntimes?: RuntimeRegistry
180
+ /** Complete readiness seam for CLI routing tests. */
181
+ initValidation?: typeof validateInitReadiness
182
+ /** Optional per-paint presentation lookup used only by the repo-local dev
183
+ * entry. The published binary never supplies it. */
184
+ resolveDashboardRenderer?: DashboardRendererResolver
185
+ /** Distribution root overrides are test-only; production resolves relative
186
+ * to the running module, independent of the current repository. */
187
+ distributionRoot?: string
188
+ /** Self-update seams keep release/package-manager orchestration testable. */
189
+ selfUpdateCommand?: SelfUpdateCommand
190
+ selfUpdate?: (options: SelfUpdateOptions) => Promise<SelfUpdateResult>
191
+ /** Production supplies the tool-free upgrade agent; tests inject a fake.
192
+ * Construction is deferred until a skill actually conflicts. */
193
+ upgradeResolverFactory?: (opts: {
194
+ targetRepo: string
195
+ env: Record<string, string | undefined>
196
+ }) => ResolveConflict
197
+ /** Injectable child-process seam for plugin contract CLI tests. */
198
+ pluginSubprocess?: PluginContractSubprocess
199
+ /** Injectable store-adapter seam for operator-sessionless commands, including
200
+ * ambient-authority-aware reads; production composes the real adapter. */
201
+ openStore?: StoreOpener
202
+ store?: BuildStore
203
+ env?: CliEnv
204
+ harvestEnv?: HarvestCliEnv
205
+ forge?: Forge
206
+ exec?: Exec
207
+ ids?: IdSource
208
+ clock?: Clock
209
+ }
210
+
211
+ /** Narrow to full session deps, or fail with agent feedback (D6). */
212
+ function requireHarvestSession(command: string, deps: SessionlessCliDeps): HarvestCliDeps {
213
+ const { store, harvestEnv: env, ids } = deps
214
+ if (store === undefined || env === undefined || ids === undefined) {
215
+ throw new Error(
216
+ `'ab harvest ${command}' runs inside a harvest agent session — the ` +
217
+ 'runner sets AB_STORE, AB_REPO, AB_HARVEST, AB_PHASE, and AB_SESSION.',
218
+ )
219
+ }
220
+ return { store, env, ids, workspacePath: deps.workspacePath }
221
+ }
222
+
223
+ function requireSession(command: string, deps: SessionlessCliDeps): CliDeps {
224
+ const { store, env, forge, exec, ids, clock } = deps
225
+ if (
226
+ store === undefined ||
227
+ env === undefined ||
228
+ forge === undefined ||
229
+ exec === undefined ||
230
+ ids === undefined ||
231
+ clock === undefined
232
+ ) {
233
+ throw new Error(
234
+ `'ab ${command}' runs inside a build session — the runner sets AB_STORE, ` +
235
+ 'AB_BUILD, AB_PHASE, AB_SESSION for every session (SPEC §8.1, D8). ' +
236
+ 'Use `ab help` for commands that run sessionless.',
237
+ )
238
+ }
239
+ return { ...deps, store, env, forge, exec, ids, clock }
240
+ }
241
+
242
+ export function buildControlConfirmation(result: BuildControlResult): string {
243
+ switch (result.kind) {
244
+ case 'command': {
245
+ const outcome: Record<typeof result.command, string> = {
246
+ pause: 'pause requested',
247
+ resume: 'resume requested',
248
+ abort: 'abort requested',
249
+ discard: 'discard requested',
250
+ 'auto-merge-on': 'auto-merge requested',
251
+ 'auto-merge-off': 'auto-merge cancelled',
252
+ }
253
+ return `build ${result.slug}: ${outcome[result.command]}`
254
+ }
255
+ case 'answered': {
256
+ const plural = result.count === 1 ? '' : 's'
257
+ const terminal = result.terminalSignal
258
+ if (terminal !== undefined) {
259
+ const ending =
260
+ terminal.kind === 'terminal-status'
261
+ ? `the build ${terminal.status === 'done' ? 'completed' : 'aborted'} while the answer was recorded${
262
+ terminal.outcome !== undefined ? ` (${terminal.outcome})` : ''
263
+ }; it will not restart`
264
+ : terminal.kind === 'abort-requested'
265
+ ? 'abort was requested during the action; the build will be aborted and will not restart'
266
+ : `the PR ${terminal.state}; build completion is underway and it will not restart`
267
+ return `build ${result.slug}: answered ${result.count} escalation${plural} with ${result.resolution}; ${ending}`
268
+ }
269
+ if (result.reviewRoundCeiling !== undefined) {
270
+ const ceiling = result.reviewRoundCeiling
271
+ return `build ${result.slug}: answered ${result.count} open escalation${plural} with ${result.resolution}; ${ceiling.loop} review round ceiling set to ${ceiling.value}${
272
+ result.resumed ? '; resume requested' : ''
273
+ }`
274
+ }
275
+ if (result.resolution === 'revise-spec') {
276
+ const revision = result.specRev === undefined ? '' : ` (spec rev ${result.specRev})`
277
+ if (result.authorizedEarlier === true) {
278
+ return `build ${result.slug}: completed the spec revision recorded earlier${revision}; the body supplied now was not used${
279
+ result.resumed ? '; resume requested' : ''
280
+ }`
281
+ }
282
+ return `build ${result.slug}: answered ${result.count} escalation${plural} with revise-spec${revision}; restarting from plan${
283
+ result.resumed ? '; resume requested' : ''
284
+ }`
285
+ }
286
+ if (result.resolution === 'dismiss-finding' && (result.remainingOpen ?? 0) > 0) {
287
+ const remaining = result.remainingOpen ?? 0
288
+ return `build ${result.slug}: dismissed ${result.count} escalation${plural}; ${remaining} blocker${
289
+ remaining === 1 ? '' : 's'
290
+ } remains (cites no review findings) — answer with guidance or an empty answer to retry`
291
+ }
292
+ return `build ${result.slug}: answered ${result.count} open escalation${plural} with ${result.resolution}${
293
+ result.resumed ? '; resume requested' : ''
294
+ }`
295
+ }
296
+ case 'answer-required':
297
+ throw new Error('build-control requested interactive feedback for an explicit CLI command')
298
+ }
299
+ }
300
+
301
+ async function runBuildControl(
302
+ deps: SessionlessCliDeps,
303
+ slug: string,
304
+ action: BuildControlAction,
305
+ storeRef?: string,
306
+ readTicketBody?: (ref: TicketRef) => Promise<string>,
307
+ ): Promise<void> {
308
+ if (deps.exec === undefined) {
309
+ throw new Error(
310
+ 'build-control commands need an exec seam — this is a wiring bug in the ab binary',
311
+ )
312
+ }
313
+ const result = await abBuildControl({
314
+ targetRepo: deps.workspacePath,
315
+ env: deps.processEnv ?? {},
316
+ exec: deps.exec,
317
+ slug,
318
+ action,
319
+ ...(storeRef !== undefined ? { storeRef } : {}),
320
+ ...(readTicketBody !== undefined ? { readTicketBody } : {}),
321
+ ...(deps.openStore !== undefined ? { openStore: deps.openStore } : {}),
322
+ })
323
+ deps.stdout(buildControlConfirmation(result))
324
+ }
325
+
326
+ /**
327
+ * The repository-wide form. Success renders the walk's summary; a partial walk
328
+ * is the one failure this route owns rather than delegates — it reports what
329
+ * did complete on stderr and exits 1, and nothing reaches stdout. Every other
330
+ * error propagates to `runCli`'s stderr+1 handler.
331
+ */
332
+ async function runBulkControl(
333
+ deps: SessionlessCliDeps,
334
+ direction: BulkDirection,
335
+ opts: { json: boolean; storeRef?: string },
336
+ ): Promise<number> {
337
+ if (deps.exec === undefined) {
338
+ throw new Error(
339
+ 'build-control commands need an exec seam — this is a wiring bug in the ab binary',
340
+ )
341
+ }
342
+ try {
343
+ const summary = await abBulkControl({
344
+ targetRepo: deps.workspacePath,
345
+ env: deps.processEnv ?? {},
346
+ exec: deps.exec,
347
+ direction,
348
+ ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
349
+ ...(deps.openStore !== undefined ? { openStore: deps.openStore } : {}),
350
+ })
351
+ if (opts.json) deps.stdout(JSON.stringify(summary, null, 2))
352
+ else for (const line of bulkControlLines(summary)) deps.stdout(line)
353
+ return 0
354
+ } catch (error) {
355
+ if (!(error instanceof BulkWalkError)) throw error
356
+ if (opts.json) deps.stderr(JSON.stringify({ ...error.progress, error: error.message }, null, 2))
357
+ else for (const line of bulkFailureLines(error)) deps.stderr(line)
358
+ return 1
359
+ }
360
+ }
361
+
362
+ function listFlag(parsed: ParsedArgs, name: string): string[] | undefined {
363
+ const value = stringFlag(parsed, name)
364
+ if (value === undefined) return undefined
365
+ return value
366
+ .split(',')
367
+ .map((entry) => entry.trim())
368
+ .filter((entry) => entry !== '')
369
+ }
370
+
371
+ function renderManifest(manifest: ContextManifest): string[] {
372
+ const lines = [
373
+ `context materialized for ${manifest.build} — ${manifest.phase}@${manifest.round}`,
374
+ `required deposits: ${manifest.required.join(', ') || '(none)'}`,
375
+ `allowed terminals: ${manifest.allowedTerminals.join(', ')}`,
376
+ `manifest: ${manifest.manifestPath}`,
377
+ ]
378
+ if (manifest.notesPath !== undefined) lines.push(`notes path: ${manifest.notesPath}`)
379
+ if (manifest.commitRange !== undefined) {
380
+ lines.push(`commit range: ${manifest.commitRange.base}..${manifest.commitRange.head}`)
381
+ }
382
+ if (manifest.conflict !== undefined) {
383
+ lines.push(`conflict baseSha: ${manifest.conflict.baseSha}`)
384
+ }
385
+ if (manifest.step !== undefined) {
386
+ lines.push(`verify step: ${manifest.step.name} (kind ${manifest.step.config.kind})`)
387
+ }
388
+ lines.push('.ab/ files:')
389
+ const entries = Object.entries(manifest.materialized)
390
+ if (entries.length === 0) {
391
+ lines.push(' (none)')
392
+ }
393
+ for (const [relPath, source] of entries) {
394
+ lines.push(
395
+ ` ${relPath} — ${source === 'derived' ? 'derived' : `${source.kind}@${source.rev}`}`,
396
+ )
397
+ }
398
+ return lines
399
+ }
400
+
401
+ export async function runCli(argv: string[], deps: SessionlessCliDeps): Promise<number> {
402
+ try {
403
+ return await dispatch(argv, deps)
404
+ } catch (error) {
405
+ deps.stderr(error instanceof Error ? error.message : String(error))
406
+ return 1
407
+ }
408
+ }
409
+
410
+ async function dispatch(argv: string[], deps: SessionlessCliDeps): Promise<number> {
411
+ const [command, ...rest] = argv
412
+ const { stdout, stderr } = deps
413
+ const helpRequest = recognizeHelpRequest(argv)
414
+
415
+ // Help is resolved before every command handler. This keeps both detailed
416
+ // forms byte-identical and prevents config, store, plugin, or session wiring
417
+ // from becoming a prerequisite for documentation.
418
+ if (helpRequest?.kind === 'overview') {
419
+ stdout(renderTopLevelHelp())
420
+ return 0
421
+ }
422
+ if (helpRequest?.kind === 'command') {
423
+ stdout(renderCommandHelp(helpRequest.command))
424
+ return 0
425
+ }
426
+
427
+ switch (command) {
428
+ case undefined: {
429
+ stderr(renderTopLevelHelp())
430
+ return 1
431
+ }
432
+
433
+ case 'help':
434
+ case '--help':
435
+ case '-h': {
436
+ throw new Error('usage: ab help [command]\n ab <command> --help')
437
+ }
438
+
439
+ case '--version': {
440
+ if (rest.length > 0) throw new Error('usage: ab --version')
441
+ stdout(formatInstalledVersion(await readDistributionIdentity(deps.distributionRoot)))
442
+ return 0
443
+ }
444
+
445
+ // init and upgrade run OUTSIDE build sessions (§16.3): they operate on a
446
+ // repo, not a build, so they route before any store/env requirement.
447
+ case 'init': {
448
+ const usage = 'usage: ab init [target] [--force | --validate] (§16.3)'
449
+ const removed = [
450
+ '--forge',
451
+ '--ticket-source',
452
+ '--workspace-provider',
453
+ '--role-profile',
454
+ '--no-interactive',
455
+ ].find((flag) => rest.some((arg) => arg === flag || arg.startsWith(`${flag}=`)))
456
+ if (removed !== undefined) {
457
+ throw new Error(
458
+ `${removed} was removed — ab init now installs a neutral skeleton and hands setup to a coding agent. ${usage}`,
459
+ )
460
+ }
461
+ const parsed = parseArgs(rest, { force: 'boolean', validate: 'boolean' }, usage)
462
+ if (parsed.positionals.length > 1) throw new Error(usage)
463
+ if (parsed.flags.has('force') && parsed.flags.has('validate')) {
464
+ throw new Error(`--force and --validate cannot be combined — ${usage}`)
465
+ }
466
+ const targetRepo = parsed.positionals[0] ?? deps.workspacePath
467
+ if (parsed.flags.has('validate')) {
468
+ const env = { ...(deps.processEnv ?? process.env) }
469
+ loadDotEnv(join(targetRepo, '.env'), env)
470
+ if (
471
+ env.VERCEL_OIDC_TOKEN !== undefined &&
472
+ (deps.processEnv ?? process.env).VERCEL_OIDC_TOKEN === undefined
473
+ ) {
474
+ throw new Error(
475
+ 'VERCEL_OIDC_TOKEN loaded only from the target .env is unavailable to the Vercel SDK; export it in the launcher environment or configure VERCEL_TOKEN, VERCEL_TEAM_ID, and VERCEL_PROJECT_ID',
476
+ )
477
+ }
478
+ const report = await (deps.initValidation ?? validateInitReadiness)({
479
+ targetRepo,
480
+ env,
481
+ stdout,
482
+ ...(deps.exec !== undefined ? { exec: deps.exec } : {}),
483
+ ...(deps.initRuntimes !== undefined ? { runtimes: deps.initRuntimes } : {}),
484
+ ...(deps.openStore !== undefined ? { openStore: deps.openStore } : {}),
485
+ ...(deps.signal !== undefined ? { signal: deps.signal } : {}),
486
+ })
487
+ return report.exitCode
488
+ }
489
+ const report = await abInit({
490
+ targetRepo,
491
+ force: parsed.flags.has('force'),
492
+ stdout,
493
+ interactive: deps.initInteractive === true,
494
+ ...(deps.initLauncher === undefined ? {} : { launcher: deps.initLauncher }),
495
+ ...(deps.initRuntimes !== undefined ? { runtimes: deps.initRuntimes } : {}),
496
+ env: deps.processEnv ?? process.env,
497
+ ...(deps.signal === undefined ? {} : { signal: deps.signal }),
498
+ })
499
+ return report.exitCode
500
+ }
501
+
502
+ case 'upgrade':
503
+ case 'update': {
504
+ const usage =
505
+ 'usage: ab upgrade [target] [--no-self-update | --version <semver>] [--no-commit] (§16.3)'
506
+ const parsed = parseArgs(
507
+ rest,
508
+ { 'no-self-update': 'boolean', 'no-commit': 'boolean', version: 'value' },
509
+ usage,
510
+ )
511
+ if (parsed.positionals.length > 1) throw new Error(usage)
512
+ if (parsed.flags.has('no-self-update') && parsed.flags.has('version')) {
513
+ throw new Error(`--no-self-update and --version cannot be combined — ${usage}`)
514
+ }
515
+ const targetRepo = parsed.positionals[0] ?? deps.workspacePath
516
+ const noCommit = parsed.flags.has('no-commit')
517
+ const handoff = deps.processEnv?.[SELF_UPDATE_HANDOFF_ENV] === '1'
518
+ let commitContext: UpgradeCommitContext | undefined
519
+ if (!noCommit) {
520
+ const handoffPath = handoff ? deps.processEnv?.[UPGRADE_COMMIT_CONTEXT_ENV] : undefined
521
+ if (handoff && handoffPath === undefined) {
522
+ stderr(
523
+ 'ab upgrade did not commit: automatic commit suppressed for cross-version compatibility because the parent binary did not provide a pre-self-update Git baseline; all upgrade-owned changes remain uncommitted for the operator',
524
+ )
525
+ } else {
526
+ try {
527
+ commitContext =
528
+ handoffPath === undefined
529
+ ? await createUpgradeCommitContext(targetRepo)
530
+ : await loadUpgradeCommitContext(handoffPath, targetRepo)
531
+ } catch (error) {
532
+ stderr(
533
+ `ab upgrade did not commit: could not capture or restore the pre-upgrade Git baseline: ${
534
+ error instanceof Error ? error.message : String(error)
535
+ }`,
536
+ )
537
+ }
538
+ }
539
+ }
540
+
541
+ try {
542
+ if (!parsed.flags.has('no-self-update') && !handoff) {
543
+ const update = await (deps.selfUpdate ?? selfUpdate)({
544
+ targetRepo,
545
+ stdout,
546
+ stderr,
547
+ env: deps.processEnv ?? {},
548
+ noCommit: noCommit || commitContext === undefined,
549
+ ...(commitContext === undefined
550
+ ? {}
551
+ : { upgradeCommitContextPath: commitContext.path }),
552
+ ...(deps.distributionRoot === undefined ? {} : { distRoot: deps.distributionRoot }),
553
+ ...(deps.selfUpdateCommand === undefined ? {} : { command: deps.selfUpdateCommand }),
554
+ ...(stringFlag(parsed, 'version') === undefined
555
+ ? {}
556
+ : { version: stringFlag(parsed, 'version') }),
557
+ })
558
+ if (update.kind === 'handoff') return update.exitCode
559
+ if (update.kind === 'failed') return 1
560
+ }
561
+ const resolverFactory = deps.upgradeResolverFactory
562
+ let resolver: ResolveConflict | undefined
563
+ const resolveConflict: ResolveConflict | undefined =
564
+ resolverFactory === undefined
565
+ ? undefined
566
+ : async (input, options) => {
567
+ // Factory/config/runtime work is lazy: a clean upgrade never
568
+ // needs agent infrastructure. Throws are caught per skill by
569
+ // abUpgrade and become the byte-preserving conflicted outcome.
570
+ resolver ??= resolverFactory({
571
+ targetRepo,
572
+ env: deps.processEnv ?? {},
573
+ })
574
+ return resolver(input, options)
575
+ }
576
+ const progressResolver =
577
+ resolveConflict === undefined || deps.terminal === undefined
578
+ ? resolveConflict
579
+ : withUpgradeProgress(resolveConflict, {
580
+ terminal: deps.terminal,
581
+ input: deps.input ?? { start: () => () => {} },
582
+ })
583
+ const report = await abUpgrade({
584
+ targetRepo,
585
+ stdout,
586
+ ...(deps.distributionRoot !== undefined ? { distRoot: deps.distributionRoot } : {}),
587
+ ...(deps.exec !== undefined ? { exec: deps.exec } : {}),
588
+ ...(progressResolver !== undefined ? { resolveConflict: progressResolver } : {}),
589
+ })
590
+ if (commitContext !== undefined) {
591
+ await finishUpgradeCommit(commitContext, report, { stdout, stderr })
592
+ }
593
+ return report.exitCode
594
+ } finally {
595
+ if (commitContext !== undefined) await cleanupUpgradeCommitContext(commitContext)
596
+ }
597
+ }
598
+
599
+ // Ticket grooming runs OUTSIDE build sessions (§8.8): command-scoped flag
600
+ // declarations and one configured-source seam own every pre-build operation.
601
+ case 'ticket': {
602
+ if (deps.exec === undefined) {
603
+ throw new Error("'ab ticket' needs an exec seam — this is a wiring bug in the ab binary")
604
+ }
605
+ await abTicket(rest, {
606
+ targetRepo: deps.workspacePath,
607
+ env: deps.processEnv ?? {},
608
+ exec: deps.exec,
609
+ stdout,
610
+ stderr,
611
+ })
612
+ return 0
613
+ }
614
+
615
+ // dispatch runs OUTSIDE build sessions (§3.3, §12): it serves a repo, not
616
+ // a build, so it routes before any store/env requirement and does its own
617
+ // heavy wiring (like ticket create). One dispatcher per repo (§12).
618
+ case 'dispatch': {
619
+ const usage =
620
+ 'usage: ab dispatch [--once] [--interval <seconds>] [--store <ref>] [--repository <origin>] [--plain] [--intake | --no-intake] [--auto-merge | --no-auto-merge] (§3.3)'
621
+ const parsed = parseArgs(
622
+ rest,
623
+ {
624
+ once: 'boolean',
625
+ interval: 'value',
626
+ store: 'value',
627
+ repository: 'value',
628
+ plain: 'boolean',
629
+ intake: 'boolean',
630
+ 'no-intake': 'boolean',
631
+ 'auto-merge': 'boolean',
632
+ 'no-auto-merge': 'boolean',
633
+ },
634
+ usage,
635
+ )
636
+ if (parsed.positionals.length > 0) throw new Error(usage)
637
+
638
+ const once = parsed.flags.has('once')
639
+ const plain = parsed.flags.has('plain')
640
+ const sawIntake = parsed.flags.has('intake')
641
+ const sawNoIntake = parsed.flags.has('no-intake')
642
+ const sawAutoMerge = parsed.flags.has('auto-merge')
643
+ const sawNoAutoMerge = parsed.flags.has('no-auto-merge')
644
+ if (sawIntake && sawNoIntake) {
645
+ throw new Error(`--intake and --no-intake cannot be combined — ${usage}`)
646
+ }
647
+ if (sawAutoMerge && sawNoAutoMerge) {
648
+ throw new Error(`--auto-merge and --no-auto-merge cannot be combined — ${usage}`)
649
+ }
650
+
651
+ const interval = stringFlag(parsed, 'interval')
652
+ let intervalMs: number | undefined
653
+ if (interval !== undefined) {
654
+ const seconds = Number(interval)
655
+ if (!Number.isFinite(seconds) || seconds <= 0) {
656
+ throw new Error(`--interval requires a positive number of seconds — ${usage}`)
657
+ }
658
+ intervalMs = Math.round(seconds * 1000)
659
+ }
660
+ const storeRef = stringFlag(parsed, 'store')
661
+ const repository = stringFlag(parsed, 'repository') ?? deps.processEnv?.AB_REPOSITORY
662
+ const intake = sawIntake ? true : sawNoIntake ? false : undefined
663
+ const defaultAutoMerge = sawAutoMerge ? true : sawNoAutoMerge ? false : undefined
664
+ if (deps.exec === undefined) {
665
+ throw new Error("'ab dispatch' needs an exec seam — this is a wiring bug in the ab binary")
666
+ }
667
+ await abDispatch({
668
+ targetRepo: deps.workspacePath,
669
+ env: deps.processEnv ?? {},
670
+ exec: deps.exec,
671
+ stdout,
672
+ stderr,
673
+ once,
674
+ plain,
675
+ ...(intake !== undefined ? { intake } : {}),
676
+ ...(defaultAutoMerge !== undefined ? { defaultAutoMerge } : {}),
677
+ ...(intervalMs !== undefined ? { intervalMs } : {}),
678
+ ...(storeRef !== undefined ? { storeRef } : {}),
679
+ ...(repository !== undefined && repository !== '' ? { repository } : {}),
680
+ ...(deps.signal !== undefined ? { signal: deps.signal } : {}),
681
+ ...(deps.terminal !== undefined ? { terminal: deps.terminal } : {}),
682
+ ...(deps.input !== undefined ? { input: deps.input } : {}),
683
+ ...(deps.resolveDashboardRenderer !== undefined
684
+ ? { resolveDashboardRenderer: deps.resolveDashboardRenderer }
685
+ : {}),
686
+ })
687
+ return 0
688
+ }
689
+
690
+ case 'plugin': {
691
+ if (deps.exec === undefined) {
692
+ throw new Error("'ab plugin' needs an exec seam — this is a wiring bug in the ab binary")
693
+ }
694
+ return await abPlugin(rest, {
695
+ targetRepo: deps.workspacePath,
696
+ env: deps.processEnv ?? {},
697
+ exec: deps.exec,
698
+ stdout,
699
+ stderr,
700
+ ...(deps.pluginSubprocess !== undefined ? { subprocess: deps.pluginSubprocess } : {}),
701
+ })
702
+ }
703
+
704
+ // models runs OUTSIDE build sessions (§9): it lists Pi's model catalog so a
705
+ // human can find the provider-qualified id to paste into autobuild.toml. No
706
+ // store needed — the local Pi CLI is behind an injectable catalog seam.
707
+ case 'models': {
708
+ const usage = 'usage: ab models [query] [--available] (§9)'
709
+ const parsed = parseArgs(rest, { available: 'boolean' }, usage)
710
+ await abModels({
711
+ ...(parsed.positionals.length > 0 ? { query: parsed.positionals.join(' ') } : {}),
712
+ availableOnly: parsed.flags.has('available'),
713
+ stdout,
714
+ })
715
+ return 0
716
+ }
717
+
718
+ // Read-only status commands run OUTSIDE build sessions (§16.3) like
719
+ // dispatch: they resolve their own store (--store > AB_STORE > default).
720
+ case 'repository': {
721
+ const usage = 'usage: ab repository status [--json] [--store <ref>] (§8.2)'
722
+ const [sub, ...more] = rest
723
+ if (sub !== 'status') throw new Error(usage)
724
+ const parsed = parseArgs(more, { json: 'boolean', store: 'value' }, usage)
725
+ if (parsed.positionals.length > 0) throw new Error(usage)
726
+ if (deps.exec === undefined) {
727
+ throw new Error(
728
+ "'ab repository status' needs an exec seam — this is a wiring bug in the ab binary",
729
+ )
730
+ }
731
+ const storeRef = stringFlag(parsed, 'store')
732
+ await abRepositoryStatus({
733
+ targetRepo: deps.workspacePath,
734
+ env: deps.processEnv ?? {},
735
+ exec: deps.exec,
736
+ stdout,
737
+ json: parsed.flags.has('json'),
738
+ ...(storeRef !== undefined ? { storeRef } : {}),
739
+ ...(deps.openStore !== undefined ? { openStore: deps.openStore } : {}),
740
+ })
741
+ return 0
742
+ }
743
+
744
+ case 'builds': {
745
+ const usage = 'usage: ab builds [--queued] [--all] [--json] [--store <ref>] (§8.2)'
746
+ const parsed = parseArgs(
747
+ rest,
748
+ { queued: 'boolean', all: 'boolean', json: 'boolean', store: 'value' },
749
+ usage,
750
+ )
751
+ if (parsed.positionals.length > 0) throw new Error(usage)
752
+ const storeRef = stringFlag(parsed, 'store')
753
+ if (deps.exec === undefined) {
754
+ throw new Error("'ab builds' needs an exec seam — this is a wiring bug in the ab binary")
755
+ }
756
+ await abBuilds({
757
+ targetRepo: deps.workspacePath,
758
+ env: deps.processEnv ?? {},
759
+ exec: deps.exec,
760
+ stdout,
761
+ queued: parsed.flags.has('queued'),
762
+ all: parsed.flags.has('all'),
763
+ json: parsed.flags.has('json'),
764
+ ...(storeRef !== undefined ? { storeRef } : {}),
765
+ ...(deps.openStore !== undefined ? { openStore: deps.openStore } : {}),
766
+ ...(deps.clock !== undefined ? { now: deps.clock } : {}),
767
+ })
768
+ return 0
769
+ }
770
+
771
+ case 'mcp': {
772
+ const parsed = parseArgs(rest, { store: 'value', repo: 'value' }, AB_MCP_USAGE)
773
+ if (parsed.positionals.length > 0) throw new Error(AB_MCP_USAGE)
774
+ if (deps.exec === undefined) {
775
+ throw new Error("'ab mcp' needs an exec seam — this is a wiring bug in the ab binary")
776
+ }
777
+ const storeRef = stringFlag(parsed, 'store')
778
+ const repo = stringFlag(parsed, 'repo')
779
+ return await abMcp({
780
+ targetRepo: deps.workspacePath,
781
+ env: deps.processEnv ?? {},
782
+ exec: deps.exec,
783
+ stdout,
784
+ stderr,
785
+ ...(storeRef !== undefined ? { storeRef } : {}),
786
+ ...(repo !== undefined ? { repo } : {}),
787
+ ...(deps.openStore !== undefined ? { openStore: deps.openStore } : {}),
788
+ ...(deps.clock !== undefined ? { clock: deps.clock } : {}),
789
+ })
790
+ }
791
+
792
+ case 'build': {
793
+ const usage = 'usage: ab build status <slug> [--events <n>] [--json] [--store <ref>] (§8.2)'
794
+ const [sub, ...more] = rest
795
+ // Only `status` today; the subcommand shape keeps room for `ab build <verb>`.
796
+ if (sub !== 'status') {
797
+ throw new Error(usage)
798
+ }
799
+ const parsed = parseArgs(more, { events: 'value', json: 'boolean', store: 'value' }, usage)
800
+ const [slug] = parsed.positionals
801
+ if (slug === undefined || parsed.positionals.length !== 1) {
802
+ throw new Error(usage)
803
+ }
804
+ const eventCount = stringFlag(parsed, 'events')
805
+ let events: number | undefined
806
+ if (eventCount !== undefined) {
807
+ const count = Number(eventCount)
808
+ if (!Number.isInteger(count) || count <= 0) {
809
+ throw new Error(`--events requires a positive integer, got "${eventCount}" — ${usage}`)
810
+ }
811
+ events = count
812
+ }
813
+ const storeRef = stringFlag(parsed, 'store')
814
+ if (deps.exec === undefined) {
815
+ throw new Error("'ab build' needs an exec seam — this is a wiring bug in the ab binary")
816
+ }
817
+ await abBuildStatus({
818
+ targetRepo: deps.workspacePath,
819
+ env: deps.processEnv ?? {},
820
+ exec: deps.exec,
821
+ stdout,
822
+ slug,
823
+ json: parsed.flags.has('json'),
824
+ ...(events !== undefined ? { events } : {}),
825
+ ...(storeRef !== undefined ? { storeRef } : {}),
826
+ ...(deps.openStore !== undefined ? { openStore: deps.openStore } : {}),
827
+ ...(deps.clock !== undefined ? { now: deps.clock } : {}),
828
+ })
829
+ return 0
830
+ }
831
+
832
+ // Read-only streaming runs OUTSIDE build sessions (§16.3) like the other
833
+ // status commands: it resolves its own store (--store > AB_STORE > default)
834
+ // and honors a complete ambient identity as a scope restriction (watch.ts).
835
+ case 'watch': {
836
+ const parsed = parseArgs(
837
+ rest,
838
+ {
839
+ repository: 'boolean',
840
+ event: 'multi',
841
+ since: 'value',
842
+ timeout: 'value',
843
+ interval: 'value',
844
+ count: 'value',
845
+ json: 'boolean',
846
+ store: 'value',
847
+ },
848
+ WATCH_USAGE,
849
+ )
850
+ const countFlag = stringFlag(parsed, 'count')
851
+ let count: number | undefined
852
+ if (countFlag !== undefined) {
853
+ const parsedCount = Number(countFlag)
854
+ if (!Number.isInteger(parsedCount) || parsedCount <= 0) {
855
+ throw new Error(
856
+ `--count requires a positive integer, got "${countFlag}" — ${WATCH_USAGE}`,
857
+ )
858
+ }
859
+ count = parsedCount
860
+ }
861
+ const storeRef = stringFlag(parsed, 'store')
862
+ if (deps.exec === undefined) {
863
+ throw new Error("'ab watch' needs an exec seam — this is a wiring bug in the ab binary")
864
+ }
865
+ await abWatch({
866
+ targetRepo: deps.workspacePath,
867
+ env: deps.processEnv ?? {},
868
+ exec: deps.exec,
869
+ stdout,
870
+ stderr,
871
+ slugs: parsed.positionals,
872
+ repository: parsed.flags.has('repository'),
873
+ events: multiFlag(parsed, 'event'),
874
+ since: stringFlag(parsed, 'since'),
875
+ timeout: stringFlag(parsed, 'timeout'),
876
+ interval: stringFlag(parsed, 'interval'),
877
+ ...(count !== undefined ? { count } : {}),
878
+ json: parsed.flags.has('json'),
879
+ ...(storeRef !== undefined ? { storeRef } : {}),
880
+ ...(deps.openStore !== undefined ? { openStore: deps.openStore } : {}),
881
+ ...(deps.clock !== undefined ? { now: deps.clock } : {}),
882
+ ...(deps.signal !== undefined ? { signal: deps.signal } : {}),
883
+ })
884
+ return 0
885
+ }
886
+
887
+ // Read-only blocking runs OUTSIDE build sessions (§16.3) exactly like
888
+ // `ab watch`: it resolves its own store and honors a complete ambient
889
+ // identity as a scope restriction. Exit codes 0/2/3/4 are the command's
890
+ // contract (wait.ts); thrown usage errors exit 1 via runCli.
891
+ case 'wait': {
892
+ const parsed = parseArgs(
893
+ rest,
894
+ {
895
+ for: 'multi',
896
+ event: 'multi',
897
+ since: 'value',
898
+ timeout: 'value',
899
+ interval: 'value',
900
+ json: 'boolean',
901
+ store: 'value',
902
+ },
903
+ WAIT_USAGE,
904
+ )
905
+ if (multiFlag(parsed, 'for').length === 0 && multiFlag(parsed, 'event').length === 0) {
906
+ throw new Error(WAIT_USAGE)
907
+ }
908
+ const storeRef = stringFlag(parsed, 'store')
909
+ if (deps.exec === undefined) {
910
+ throw new Error("'ab wait' needs an exec seam — this is a wiring bug in the ab binary")
911
+ }
912
+ return await abWait({
913
+ targetRepo: deps.workspacePath,
914
+ env: deps.processEnv ?? {},
915
+ exec: deps.exec,
916
+ stdout,
917
+ stderr,
918
+ slugs: parsed.positionals,
919
+ forValues: multiFlag(parsed, 'for'),
920
+ events: multiFlag(parsed, 'event'),
921
+ since: stringFlag(parsed, 'since'),
922
+ timeout: stringFlag(parsed, 'timeout'),
923
+ interval: stringFlag(parsed, 'interval'),
924
+ json: parsed.flags.has('json'),
925
+ ...(storeRef !== undefined ? { storeRef } : {}),
926
+ ...(deps.openStore !== undefined ? { openStore: deps.openStore } : {}),
927
+ ...(deps.clock !== undefined ? { now: deps.clock } : {}),
928
+ ...(deps.signal !== undefined ? { signal: deps.signal } : {}),
929
+ })
930
+ }
931
+
932
+ case 'abort': {
933
+ const usage = 'usage: ab abort <slug> [--store <ref>]'
934
+ const parsed = parseArgs(rest, { store: 'value' }, usage)
935
+ if (parsed.positionals.length !== 1) throw new Error(usage)
936
+ await runBuildControl(
937
+ deps,
938
+ parsed.positionals[0]!,
939
+ { kind: 'abort' },
940
+ stringFlag(parsed, 'store'),
941
+ )
942
+ return 0
943
+ }
944
+
945
+ case 'pause':
946
+ case 'resume': {
947
+ const usage = `usage: ab ${command} <slug> | --all [--store <ref>] [--json]`
948
+ const parsed = parseArgs(rest, { store: 'value', all: 'boolean', json: 'boolean' }, usage)
949
+ const all = parsed.flags.has('all')
950
+ const json = parsed.flags.has('json')
951
+ const storeRef = stringFlag(parsed, 'store')
952
+ if (json && !all) throw new Error(`--json requires --all — ${usage}`)
953
+ if (all && parsed.positionals.length > 0) {
954
+ throw new Error(`--all takes no build slug — ${usage}`)
955
+ }
956
+ if (all) {
957
+ return runBulkControl(deps, command, {
958
+ json,
959
+ ...(storeRef !== undefined ? { storeRef } : {}),
960
+ })
961
+ }
962
+ if (parsed.positionals.length !== 1) throw new Error(usage)
963
+ await runBuildControl(deps, parsed.positionals[0]!, { kind: command }, storeRef)
964
+ return 0
965
+ }
966
+
967
+ case 'auto-merge': {
968
+ const usage = 'usage: ab auto-merge <slug> <on|off> [--store <ref>]'
969
+ const parsed = parseArgs(rest, { store: 'value' }, usage)
970
+ const [slug, setting] = parsed.positionals
971
+ if (
972
+ parsed.positionals.length !== 2 ||
973
+ slug === undefined ||
974
+ (setting !== 'on' && setting !== 'off')
975
+ ) {
976
+ throw new Error(usage)
977
+ }
978
+ await runBuildControl(
979
+ deps,
980
+ slug,
981
+ { kind: setting === 'on' ? 'auto-merge-on' : 'auto-merge-off' },
982
+ stringFlag(parsed, 'store'),
983
+ )
984
+ return 0
985
+ }
986
+
987
+ case 'answer': {
988
+ const usage =
989
+ 'usage: ab answer <slug> [<text>] [--review-round-ceiling <n> | --dismiss | --revise-spec <file> | --revise-spec-from-ticket] [--store <ref>]'
990
+ const parsed = parseArgs(
991
+ rest,
992
+ {
993
+ store: 'value',
994
+ dismiss: 'boolean',
995
+ 'revise-spec': 'value',
996
+ 'revise-spec-from-ticket': 'boolean',
997
+ 'review-round-ceiling': 'value',
998
+ },
999
+ usage,
1000
+ )
1001
+ const [slug, ...text] = parsed.positionals
1002
+ if (slug === undefined) throw new Error(usage)
1003
+ const resolutionFlags = [
1004
+ parsed.flags.has('dismiss'),
1005
+ parsed.flags.has('revise-spec'),
1006
+ parsed.flags.has('revise-spec-from-ticket'),
1007
+ ].filter(Boolean).length
1008
+ if (resolutionFlags > 1) throw new Error(`resolution flags are mutually exclusive — ${usage}`)
1009
+ const answer = text.join(' ')
1010
+ const storeRef = stringFlag(parsed, 'store')
1011
+ const specPath = stringFlag(parsed, 'revise-spec')
1012
+ const ceilingFlag = stringFlag(parsed, 'review-round-ceiling')
1013
+ let reviewRoundCeiling: number | undefined
1014
+ if (ceilingFlag !== undefined) {
1015
+ const parsedCeiling = Number(ceilingFlag)
1016
+ if (!Number.isInteger(parsedCeiling) || parsedCeiling <= 0) {
1017
+ throw new Error(
1018
+ `--review-round-ceiling requires a positive integer, got "${ceilingFlag}" — ${usage}`,
1019
+ )
1020
+ }
1021
+ if (specPath !== undefined || parsed.flags.has('revise-spec-from-ticket')) {
1022
+ throw new Error(
1023
+ 'cannot combine --review-round-ceiling with a spec revision: revision resets the loop round budget on its own',
1024
+ )
1025
+ }
1026
+ if (parsed.flags.has('dismiss')) {
1027
+ throw new Error(
1028
+ 'cannot combine --review-round-ceiling with --dismiss; use guidance or a bare retry to answer the review-round-limit escalation',
1029
+ )
1030
+ }
1031
+ reviewRoundCeiling = parsedCeiling
1032
+ }
1033
+ const resolve = parsed.flags.has('dismiss')
1034
+ ? ({ kind: 'dismiss-finding' } as const)
1035
+ : specPath !== undefined
1036
+ ? ({
1037
+ kind: 'revise-spec',
1038
+ body: {
1039
+ kind: 'supplied',
1040
+ origin: `--revise-spec ${specPath}`,
1041
+ read: async (): Promise<string> => {
1042
+ try {
1043
+ return await readFile(specPath, 'utf8')
1044
+ } catch (error) {
1045
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
1046
+ throw new Error(
1047
+ `--revise-spec ${specPath}: file not found — expected a file holding the replacement spec body`,
1048
+ )
1049
+ }
1050
+ throw error
1051
+ }
1052
+ },
1053
+ },
1054
+ } as const)
1055
+ : parsed.flags.has('revise-spec-from-ticket')
1056
+ ? ({ kind: 'revise-spec', body: { kind: 'ticket' } } as const)
1057
+ : undefined
1058
+ const readTicketBody =
1059
+ resolve?.kind === 'revise-spec' && resolve.body.kind === 'ticket'
1060
+ ? async (ref: TicketRef): Promise<string> => {
1061
+ const { source } = await openTicketSource(
1062
+ {
1063
+ targetRepo: deps.workspacePath,
1064
+ env: deps.processEnv ?? {},
1065
+ ...(deps.exec !== undefined ? { exec: deps.exec } : {}),
1066
+ stdout,
1067
+ ...(storeRef !== undefined ? { storeRef } : {}),
1068
+ },
1069
+ 'ab answer --revise-spec-from-ticket',
1070
+ )
1071
+ if (source.name !== ref.source) {
1072
+ throw new Error(
1073
+ `build ticket source is "${ref.source}", but this repository is configured for "${source.name}"; refusing to read a same-id ticket from a different source`,
1074
+ )
1075
+ }
1076
+ const ticket = await source.get(ref.id)
1077
+ if (ticket === null) {
1078
+ throw new Error(
1079
+ `ticket "${ref.id}" was not found in the configured ${source.name} source`,
1080
+ )
1081
+ }
1082
+ if (ticket.ref.source !== ref.source || ticket.ref.id !== ref.id) {
1083
+ throw new Error(
1084
+ `ticket adapter returned ${ticket.ref.source}:${ticket.ref.id}, expected ${ref.source}:${ref.id}`,
1085
+ )
1086
+ }
1087
+ return ticket.body
1088
+ }
1089
+ : undefined
1090
+ await runBuildControl(
1091
+ deps,
1092
+ slug,
1093
+ {
1094
+ kind: 'answer',
1095
+ ...(answer !== '' ? { text: answer } : {}),
1096
+ ...(resolve !== undefined ? { resolve } : {}),
1097
+ ...(reviewRoundCeiling !== undefined ? { reviewRoundCeiling } : {}),
1098
+ },
1099
+ storeRef,
1100
+ readTicketBody,
1101
+ )
1102
+ return 0
1103
+ }
1104
+
1105
+ case 'harvest': {
1106
+ const [sub, ...more] = rest
1107
+ if (sub === 'status') {
1108
+ const usage = 'usage: ab harvest status [--events <n>] [--json] [--store <ref>]'
1109
+ const parsed = parseArgs(more, { events: 'value', json: 'boolean', store: 'value' }, usage)
1110
+ if (parsed.positionals.length > 0) throw new Error(usage)
1111
+ const eventCount = stringFlag(parsed, 'events')
1112
+ let events: number | undefined
1113
+ if (eventCount !== undefined) {
1114
+ const count = Number(eventCount)
1115
+ if (!Number.isInteger(count) || count <= 0) {
1116
+ throw new Error(`--events requires a positive integer — ${usage}`)
1117
+ }
1118
+ events = count
1119
+ }
1120
+ const storeRef = stringFlag(parsed, 'store')
1121
+ if (deps.exec === undefined) {
1122
+ throw new Error(
1123
+ "'ab harvest status' needs an exec seam — this is a wiring bug in the ab binary",
1124
+ )
1125
+ }
1126
+ await abHarvestStatus({
1127
+ repo: deps.workspacePath,
1128
+ env: deps.processEnv ?? {},
1129
+ exec: deps.exec,
1130
+ stdout,
1131
+ json: parsed.flags.has('json'),
1132
+ ...(events !== undefined ? { events } : {}),
1133
+ ...(storeRef !== undefined ? { storeRef } : {}),
1134
+ })
1135
+ return 0
1136
+ }
1137
+ if (sub === 'context') {
1138
+ const usage = 'usage: ab harvest context [--json]'
1139
+ const parsed = parseArgs(more, { json: 'boolean' }, usage)
1140
+ if (parsed.positionals.length > 0) throw new Error(usage)
1141
+ const manifest = await buildHarvestContext(requireHarvestSession('context', deps))
1142
+ if (parsed.flags.get('json') === true) {
1143
+ stdout(JSON.stringify(manifest, null, 2))
1144
+ } else {
1145
+ stdout(
1146
+ `harvest context materialized for ${manifest.run} — ${manifest.phase}@${manifest.round}`,
1147
+ )
1148
+ stdout(`required deposit: ${manifest.required.join(', ')}`)
1149
+ for (const file of manifest.materialized) stdout(` .ab/${file}`)
1150
+ }
1151
+ return 0
1152
+ }
1153
+ if (sub === 'submit') {
1154
+ const usage = 'usage: ab harvest submit <proposals.json>'
1155
+ const parsed = parseArgs(more, {}, usage)
1156
+ const [file] = parsed.positionals
1157
+ if (file === undefined || parsed.positionals.length !== 1) {
1158
+ throw new Error(usage)
1159
+ }
1160
+ const event = await submitHarvestProposals(requireHarvestSession('submit', deps), file)
1161
+ stdout(`${event.type} recorded (repo seq ${event.seq})`)
1162
+ return 0
1163
+ }
1164
+ if (sub === 'verdict') {
1165
+ const usage =
1166
+ 'usage: ab harvest verdict <approve|revise|escalate> --notes <file> ' +
1167
+ '[--findings <json>] [--reason <text>]'
1168
+ const parsed = parseArgs(
1169
+ more,
1170
+ { notes: 'value', findings: 'value', reason: 'value' },
1171
+ usage,
1172
+ )
1173
+ const [kind] = parsed.positionals
1174
+ const notes = stringFlag(parsed, 'notes')
1175
+ if (kind === undefined || parsed.positionals.length !== 1 || notes === undefined) {
1176
+ throw new Error(usage)
1177
+ }
1178
+ const event = await submitHarvestVerdict(requireHarvestSession('verdict', deps), {
1179
+ verdict: kind,
1180
+ notes,
1181
+ ...(stringFlag(parsed, 'findings') !== undefined
1182
+ ? { findings: stringFlag(parsed, 'findings')! }
1183
+ : {}),
1184
+ ...(stringFlag(parsed, 'reason') !== undefined
1185
+ ? { reason: stringFlag(parsed, 'reason')! }
1186
+ : {}),
1187
+ })
1188
+ stdout(`${event.type} recorded (repo seq ${event.seq})`)
1189
+ return 0
1190
+ }
1191
+ throw new Error('usage: ab harvest <status|context|submit|verdict> …')
1192
+ }
1193
+
1194
+ case 'context': {
1195
+ const session = requireSession(command, deps)
1196
+ const usage = 'usage: ab context [--json]'
1197
+ const parsed = parseArgs(rest, { json: 'boolean' }, usage)
1198
+ if (parsed.positionals.length > 0) throw new Error(usage)
1199
+ const manifest = await buildContext(session)
1200
+ if (parsed.flags.get('json') === true) {
1201
+ stdout(JSON.stringify(manifest, null, 2))
1202
+ } else {
1203
+ for (const line of renderManifest(manifest)) stdout(line)
1204
+ }
1205
+ return 0
1206
+ }
1207
+
1208
+ case 'artifact': {
1209
+ const [sub, ...more] = rest
1210
+ if (sub === 'download') {
1211
+ const usage =
1212
+ 'usage: ab artifact download <build> <kind>[@rev] --output <file> [--store <ref>] (§8.2)'
1213
+ const parsed = parseArgs(more, { output: 'value', store: 'value' }, usage)
1214
+ const [build, spec] = parsed.positionals
1215
+ const outputPath = stringFlag(parsed, 'output')
1216
+ if (
1217
+ build === undefined ||
1218
+ spec === undefined ||
1219
+ parsed.positionals.length !== 2 ||
1220
+ outputPath === undefined
1221
+ ) {
1222
+ throw new Error(usage)
1223
+ }
1224
+ const storeRef = stringFlag(parsed, 'store')
1225
+ if (deps.exec === undefined) {
1226
+ throw new Error(
1227
+ "'ab artifact download' needs an exec seam — this is a wiring bug in the ab binary",
1228
+ )
1229
+ }
1230
+ const downloaded = await artifactDownload({
1231
+ targetRepo: deps.workspacePath,
1232
+ env: deps.processEnv ?? {},
1233
+ exec: deps.exec,
1234
+ build,
1235
+ spec,
1236
+ outputPath,
1237
+ ...(storeRef !== undefined ? { storeRef } : {}),
1238
+ ...(deps.openStore !== undefined ? { openStore: deps.openStore } : {}),
1239
+ })
1240
+ stdout(
1241
+ `downloaded ${downloaded.artifact.meta.kind}@${downloaded.artifact.meta.revision} to ${downloaded.outputPath}`,
1242
+ )
1243
+ return 0
1244
+ }
1245
+
1246
+ const session = requireSession(command, deps)
1247
+ if (sub === 'put') {
1248
+ const usage = 'usage: ab artifact put <kind> <file> [--attach] (§8.2)'
1249
+ const parsed = parseArgs(more, { attach: 'boolean' }, usage)
1250
+ const [kind, file] = parsed.positionals
1251
+ if (kind === undefined || file === undefined || parsed.positionals.length !== 2) {
1252
+ throw new Error(usage)
1253
+ }
1254
+ const attach = parsed.flags.has('attach')
1255
+ const meta = await artifactPut(session, kind, file, { attach })
1256
+
1257
+ // A designation made after the PR exists (verify after reconcile or a
1258
+ // finalize post-step) republishes the same complete projection. Every
1259
+ // external side effect is best-effort after the atomic designation.
1260
+ if (attach) {
1261
+ try {
1262
+ const events = await session.store.getEvents(session.env.build)
1263
+ const pr = reduceBuild(events).pr
1264
+ if (pr !== undefined) {
1265
+ try {
1266
+ await preparePrAttachments(session, events, pr.url)
1267
+ } catch {
1268
+ // A post-upload hosted-fact failure records the exact public
1269
+ // asset identity before throwing. Keep going so its hosting
1270
+ // failure cannot suppress the complete text projection.
1271
+ }
1272
+ try {
1273
+ await session.forge.commentOnPr(
1274
+ session.workspacePath,
1275
+ pr.number,
1276
+ renderPrSummary(session.env, await session.store.getEvents(session.env.build)),
1277
+ )
1278
+ } catch {
1279
+ // The exact artifact and designation are already durable. A
1280
+ // comment refresh cannot turn the agent's deposit into failure.
1281
+ }
1282
+ }
1283
+ } catch {
1284
+ // Reading the optional PR projection is best-effort too.
1285
+ }
1286
+ }
1287
+
1288
+ // The assigned rev is the command's one output (§8.2).
1289
+ stdout(String(meta.revision))
1290
+ return 0
1291
+ }
1292
+ if (sub === 'get') {
1293
+ const usage = 'usage: ab artifact get <kind>[@rev] (§8.2)'
1294
+ const parsed = parseArgs(more, {}, usage)
1295
+ const [spec] = parsed.positionals
1296
+ if (spec === undefined || parsed.positionals.length !== 1) {
1297
+ throw new Error(usage)
1298
+ }
1299
+ const artifact = await artifactGet(session, spec)
1300
+ stdout(textContent(artifact))
1301
+ return 0
1302
+ }
1303
+ throw new Error('usage: ab artifact <put|get|download> … (§8.2)')
1304
+ }
1305
+
1306
+ case 'observe': {
1307
+ const usage =
1308
+ 'usage: ab observe --kind <followup|refactor|latent-bug> [--files a,b] [--refs x,y] <summary> (§8.2)'
1309
+ const parsed = parseArgs(rest, { kind: 'value', files: 'value', refs: 'value' }, usage)
1310
+ const kind = stringFlag(parsed, 'kind')
1311
+ if (kind === undefined) {
1312
+ throw new Error("'ab observe' requires --kind <followup|refactor|latent-bug> (§8.2)")
1313
+ }
1314
+ const session = requireSession(command, deps)
1315
+ const summary = parsed.positionals.join(' ')
1316
+ const files = listFlag(parsed, 'files')
1317
+ const refs = listFlag(parsed, 'refs')
1318
+ const event = await observe(session, {
1319
+ kind,
1320
+ summary,
1321
+ ...(files !== undefined ? { files } : {}),
1322
+ ...(refs !== undefined ? { refs } : {}),
1323
+ })
1324
+ stdout(`observation recorded: ${event.payload.id}`)
1325
+ return 0
1326
+ }
1327
+
1328
+ case 'done': {
1329
+ const session = requireSession(command, deps)
1330
+ const usage = 'usage: ab done [--notes <file>] (§8.2)'
1331
+ const parsed = parseArgs(rest, { notes: 'value' }, usage)
1332
+ if (parsed.positionals.length > 0) throw new Error(usage)
1333
+ const notes = stringFlag(parsed, 'notes')
1334
+ const event = await done(session, notes !== undefined ? { notes } : {})
1335
+ stdout(
1336
+ event.type === 'publication.requested'
1337
+ ? `${event.type} recorded (seq ${event.seq}); remote publication is pending — do not run ab done again`
1338
+ : `${event.type} recorded (seq ${event.seq})`,
1339
+ )
1340
+ return 0
1341
+ }
1342
+
1343
+ case 'verdict': {
1344
+ const usage =
1345
+ 'usage: ab verdict <approve|revise|escalate|pass|fail|skip> ' +
1346
+ '[--findings <json>] [--notes <file>] [--reason <text>] [--report <file>] (§8.2)'
1347
+ const parsed = parseArgs(
1348
+ rest,
1349
+ {
1350
+ findings: 'value',
1351
+ notes: 'value',
1352
+ reason: 'value',
1353
+ report: 'value',
1354
+ },
1355
+ usage,
1356
+ )
1357
+ const [kind] = parsed.positionals
1358
+ if (kind === undefined || parsed.positionals.length !== 1) {
1359
+ throw new Error(usage)
1360
+ }
1361
+ const session = requireSession(command, deps)
1362
+ const notes = stringFlag(parsed, 'notes')
1363
+ const findings = stringFlag(parsed, 'findings')
1364
+ const reason = stringFlag(parsed, 'reason')
1365
+ const report = stringFlag(parsed, 'report')
1366
+ const events = await verdict(session, {
1367
+ verdict: kind,
1368
+ ...(notes !== undefined ? { notes } : {}),
1369
+ ...(findings !== undefined ? { findings } : {}),
1370
+ ...(reason !== undefined ? { reason } : {}),
1371
+ ...(report !== undefined ? { report } : {}),
1372
+ })
1373
+ for (const event of events) stdout(`${event.type} recorded (seq ${event.seq})`)
1374
+ return 0
1375
+ }
1376
+
1377
+ case 'escalate': {
1378
+ const usage = 'usage: ab escalate <question> [--refs a,b] (§8.2)'
1379
+ const parsed = parseArgs(rest, { refs: 'value' }, usage)
1380
+ const question = parsed.positionals.join(' ')
1381
+ if (question === '') {
1382
+ throw new Error('usage: ab escalate <question> [--refs a,b] (§8.2)')
1383
+ }
1384
+ const session = requireSession(command, deps)
1385
+ const refs = listFlag(parsed, 'refs')
1386
+ const event = await escalate(session, {
1387
+ question,
1388
+ ...(refs !== undefined ? { refs } : {}),
1389
+ })
1390
+ stdout(`escalation raised: ${event.payload.id}`)
1391
+ return 0
1392
+ }
1393
+
1394
+ default: {
1395
+ stderr(`unknown command "${command}"\n\n${renderTopLevelHelp()}`)
1396
+ return 1
1397
+ }
1398
+ }
1399
+ }