@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,194 @@
1
+ /**
2
+ * Test-support fixtures for the CLI suite — imported only by the colocated
3
+ * `*.test.ts` files, never by production code (mirrors the src/testing rule).
4
+ *
5
+ * The store fixture is a seeded MemoryBuildStore (the reference adapter — its
6
+ * behavior IS the contract, store/contract.ts) and the git fixtures are real
7
+ * throwaway repos, matching the patterns in git-worktree.test.ts.
8
+ */
9
+ import { mkdir, writeFile } from 'node:fs/promises'
10
+ import { join } from 'node:path'
11
+ import type { EventEnvelope, EventWrite } from '../events/catalog'
12
+ import { DISPATCHER } from '../events/envelope'
13
+ import type { EventType } from '../events/payloads'
14
+ import type { PrImageHostTarget } from '../ontology'
15
+ import { sequentialIds } from '../ids'
16
+ import { FakeForge } from '../ports/forge/fake'
17
+ import { spawnExec } from '../ports/workspace/git-worktree'
18
+ import { MemoryBuildStore } from '../store/memory'
19
+ import type { BuildStore } from '../store/types'
20
+ import { steppingClock } from '../testing/fixed'
21
+ import type { CliEnv } from './env'
22
+ import type { CliDeps } from './main'
23
+
24
+ export const BUILD = 'auth-rate-limit'
25
+ export const BRANCH = 'ab/auth-rate-limit'
26
+
27
+ export function makeEnv(overrides: Partial<CliEnv> = {}): CliEnv {
28
+ return {
29
+ store: '/tmp/ab-store',
30
+ build: BUILD,
31
+ phase: 'implement',
32
+ round: 1,
33
+ session: 's_test',
34
+ ...overrides,
35
+ }
36
+ }
37
+
38
+ /** Build + `build.created` + spec@0 (`spec.imported`) — every phase's floor. */
39
+ export async function seedStore(
40
+ opts: { imageHost?: PrImageHostTarget } = {},
41
+ ): Promise<MemoryBuildStore> {
42
+ const store = new MemoryBuildStore({ clock: steppingClock() })
43
+ await store.createBuild({ slug: BUILD, repo: 'acme/app', branch: BRANCH })
44
+ await store.append(BUILD, {
45
+ actor: DISPATCHER,
46
+ type: 'build.created',
47
+ payload: {
48
+ ticket: {
49
+ source: 'linear',
50
+ id: 'ENG-42',
51
+ url: 'https://linear.app/acme/issue/ENG-42',
52
+ title: 'Auth rate limiting',
53
+ },
54
+ repo: 'acme/app',
55
+ baseBranch: 'main',
56
+ ...(opts.imageHost !== undefined ? { pr: { imageHost: opts.imageHost } } : {}),
57
+ },
58
+ })
59
+ await store.putArtifact(BUILD, {
60
+ kind: 'spec',
61
+ content: '# Spec: auth rate limiting\n',
62
+ })
63
+ await store.append(BUILD, {
64
+ actor: DISPATCHER,
65
+ type: 'spec.imported',
66
+ payload: {
67
+ artifact: { kind: 'spec', rev: 0 },
68
+ ticket: { source: 'linear', id: 'ENG-42' },
69
+ },
70
+ })
71
+ return store
72
+ }
73
+
74
+ export interface TestDeps extends CliDeps {
75
+ forge: FakeForge
76
+ out: string[]
77
+ err: string[]
78
+ }
79
+
80
+ export function makeDeps(opts: {
81
+ store: MemoryBuildStore
82
+ env: CliEnv
83
+ workspacePath?: string
84
+ forge?: FakeForge
85
+ }): TestDeps {
86
+ const out: string[] = []
87
+ const err: string[] = []
88
+ const forge = opts.forge ?? new FakeForge()
89
+ return {
90
+ store: opts.store,
91
+ env: opts.env,
92
+ workspacePath: opts.workspacePath ?? '/nonexistent-workspace',
93
+ forge,
94
+ exec: spawnExec,
95
+ ids: sequentialIds(),
96
+ clock: steppingClock(),
97
+ stdout: (line) => out.push(line),
98
+ stderr: (line) => err.push(line),
99
+ out,
100
+ err,
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Wrap a store so ONE build's compare-and-set append fails outright, to drive
106
+ * the bulk walk's partial-progress path. Prototype-proxy idiom, as
107
+ * `withRacingAppend` in bulk-control.test.ts; every other build and every other
108
+ * operation delegates untouched, so the walk's real bookkeeping is what the
109
+ * failure report is built from.
110
+ */
111
+ export function withFailingAppend(store: MemoryBuildStore, slug: string, error: Error): BuildStore {
112
+ const proxy = Object.create(store) as MemoryBuildStore
113
+ proxy.appendIfCurrent = async <T extends EventType>(
114
+ target: string,
115
+ expectedSeq: number,
116
+ event: EventWrite<T>,
117
+ ): Promise<EventEnvelope<T> | null> => {
118
+ if (target === slug) throw error
119
+ return store.appendIfCurrent(target, expectedSeq, event)
120
+ }
121
+ return proxy
122
+ }
123
+
124
+ // ── Real-git fixtures (identity pinned per invocation, like git-worktree.test) ─
125
+
126
+ export const GIT_ID = [
127
+ '-c',
128
+ 'user.email=ab@test.invalid',
129
+ '-c',
130
+ 'user.name=ab-test',
131
+ '-c',
132
+ 'commit.gpgsign=false',
133
+ ]
134
+
135
+ export async function runGit(args: string[], cwd: string): Promise<string> {
136
+ const result = await spawnExec(['git', ...args], { cwd })
137
+ if (result.exitCode !== 0) {
138
+ throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`)
139
+ }
140
+ return result.stdout.trim()
141
+ }
142
+
143
+ /**
144
+ * A workspace repo on branch `ab/auth-rate-limit`, branched off `main`.
145
+ * Deliberately does NOT gitignore `.ab/` — real repos don't either (§7:
146
+ * establishing the gitignored scratch dir is the PRODUCT's job, via the
147
+ * self-excluding `.ab/.gitignore` that `ab context` writes); a fixture that
148
+ * hand-wrote the ignore would hide a regression in that mechanism.
149
+ */
150
+ export async function initWorkspaceRepo(dir: string, branch = BRANCH): Promise<void> {
151
+ await mkdir(dir, { recursive: true })
152
+ await runGit(['init', '-q', '-b', 'main'], dir)
153
+ await writeFile(join(dir, 'README.md'), 'fixture\n')
154
+ await runGit(['add', '.'], dir)
155
+ await runGit([...GIT_ID, 'commit', '-q', '-m', 'initial'], dir)
156
+ await runGit(['checkout', '-q', '-b', branch], dir)
157
+ }
158
+
159
+ /**
160
+ * Attach a throwaway bare origin, seed its build ref at the local branch tip,
161
+ * and seed `main` at an independently selected target commit. Directly moving
162
+ * the bare target ref deliberately leaves operator-owned local/tracking refs
163
+ * untouched, matching the real workspace boundary tests.
164
+ */
165
+ export async function initBareOrigin(
166
+ workspace: string,
167
+ remote: string,
168
+ branch = BRANCH,
169
+ target = 'HEAD',
170
+ ): Promise<void> {
171
+ const targetSha = await runGit(['rev-parse', '--verify', `${target}^{commit}`], workspace)
172
+ await mkdir(remote, { recursive: true })
173
+ await runGit(['init', '-q', '--bare'], remote)
174
+ await runGit(['remote', 'add', 'origin', remote], workspace)
175
+ await runGit(['push', '-q', 'origin', `${branch}:refs/heads/${branch}`], workspace)
176
+ await runGit(['update-ref', 'refs/heads/main', targetSha], remote)
177
+ await runGit(['symbolic-ref', 'HEAD', 'refs/heads/main'], remote)
178
+ }
179
+
180
+ export function remoteBranchHead(remote: string, branch = BRANCH): Promise<string> {
181
+ return runGit(['rev-parse', `refs/heads/${branch}`], remote)
182
+ }
183
+
184
+ export async function commitFile(
185
+ dir: string,
186
+ file: string,
187
+ content: string,
188
+ message: string,
189
+ ): Promise<string> {
190
+ await writeFile(join(dir, file), content)
191
+ await runGit(['add', file], dir)
192
+ await runGit([...GIT_ID, 'commit', '-q', '-m', message], dir)
193
+ return runGit(['rev-parse', 'HEAD'], dir)
194
+ }
@@ -0,0 +1,484 @@
1
+ /**
2
+ * Source-agnostic pre-build ticket operations (SPEC §8.8). These commands run
3
+ * outside build sessions and resolve the repository's configured TicketSource,
4
+ * so the same CLI works for Linear and the file tracker. Adapter secrets come
5
+ * from the process environment (for example LINEAR_API_KEY), never from config.
6
+ */
7
+ import { readFile } from 'node:fs/promises'
8
+ import { join, resolve } from 'node:path'
9
+ import { loadConfig } from '../config/load'
10
+ import type { Config, TicketsConfig } from '../config/schema'
11
+ import { loadPlugins } from '../plugins/load'
12
+ import type { PluginRegistry } from '../plugins/registry'
13
+ import { createTicketSource } from '../ports/tickets/create'
14
+ import {
15
+ changeBlockers,
16
+ createTicket,
17
+ moveTicket,
18
+ requireTicket,
19
+ ticketListCriteria,
20
+ updateTicket,
21
+ } from '../ports/tickets/operations'
22
+ import type { Ticket, TicketSource, TicketUpdate } from '../ports/types'
23
+ import type { Exec } from '../ports/workspace/git-worktree'
24
+ import { parseArgs, stringFlag } from './args'
25
+ import { resolveMainRepo, resolveRepoStatePaths } from './repo-state'
26
+
27
+ export type TicketSourceFactory = (
28
+ config: TicketsConfig,
29
+ env: Record<string, string | undefined>,
30
+ targetRepo: string,
31
+ localStateRoot: string | undefined,
32
+ plugins: PluginRegistry,
33
+ ) => TicketSource | Promise<TicketSource>
34
+
35
+ export interface TicketCommandOpts {
36
+ targetRepo: string
37
+ /** Process environment — store selection and adapter secrets. */
38
+ env: Record<string, string | undefined>
39
+ /** Git seam supplied by the CLI; omitted direct callers use targetRepo. */
40
+ exec?: Exec
41
+ stdout: (line: string) => void
42
+ /** Injectable for tests; defaults to the real adapter factory. */
43
+ sourceFactory?: TicketSourceFactory
44
+ }
45
+
46
+ export interface TicketCreateOpts extends TicketCommandOpts {
47
+ title: string
48
+ json?: boolean
49
+ /** Path to the ticket body — the spec (docs/spec-standard.md). */
50
+ bodyFile: string
51
+ labels?: string[]
52
+ /** Source-local workflow state for this create; validation belongs to the
53
+ * adapter. Omission preserves its configured/provider default. */
54
+ state?: string
55
+ /** Source-local ids of tickets that must complete before this one is
56
+ * dispatched (§13). Validated against the configured source before create. */
57
+ blockedBy?: string[]
58
+ }
59
+
60
+ export interface TicketUpdateOpts extends TicketCommandOpts {
61
+ id: string
62
+ json?: boolean
63
+ title?: string
64
+ /** Replacement body file. Omission preserves the current body. */
65
+ bodyFile?: string
66
+ /** Complete label replacement. An explicit [] clears labels. */
67
+ labels?: string[]
68
+ }
69
+
70
+ export interface TicketBlockerOpts extends TicketCommandOpts {
71
+ id: string
72
+ blockerIds: string[]
73
+ json?: boolean
74
+ }
75
+
76
+ export interface TicketListOpts extends TicketCommandOpts {
77
+ /** Separate diagnostic sink so `--json` stdout remains one bare value. */
78
+ stderr: (line: string) => void
79
+ /** Source-local workflow state. Omitted with labels means any state. */
80
+ state?: string
81
+ /** Every requested label must match. Omitted with state means no label gate. */
82
+ labels?: string[]
83
+ json?: boolean
84
+ }
85
+
86
+ export interface TicketShowOpts extends TicketCommandOpts {
87
+ id: string
88
+ json?: boolean
89
+ }
90
+
91
+ export interface TicketMoveOpts extends TicketCommandOpts {
92
+ id: string
93
+ /** Source-local workflow state; validation belongs to the adapter. */
94
+ state: string
95
+ json?: boolean
96
+ }
97
+
98
+ type TicketCommandName = 'create' | 'update' | 'block' | 'unblock' | 'list' | 'show' | 'move'
99
+
100
+ export interface ResolvedTicketCommand {
101
+ config: Config
102
+ source: TicketSource
103
+ }
104
+
105
+ /** Resolve linked-worktree identity, config, selected local state, and adapter
106
+ * exactly once for one ticket command or another sessionless caller. */
107
+ export async function openTicketSource(
108
+ opts: TicketCommandOpts & { storeRef?: string },
109
+ label: string,
110
+ ): Promise<ResolvedTicketCommand> {
111
+ const targetRepo =
112
+ opts.exec === undefined
113
+ ? resolve(opts.targetRepo)
114
+ : await resolveMainRepo(opts.targetRepo, opts.exec)
115
+ const configPath = join(targetRepo, 'autobuild.toml')
116
+ let config: Config
117
+ try {
118
+ config = await loadConfig(configPath)
119
+ } catch (error) {
120
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
121
+ throw new Error(
122
+ `${configPath}: not found — '${label}' reads autobuild.toml ` +
123
+ 'from the resolved Git main checkout (SPEC §8.8)',
124
+ )
125
+ }
126
+ throw error
127
+ }
128
+
129
+ // Ticket commands use the same trusted plugin catalog as dispatch. Loading
130
+ // and registration finish before any adapter is constructed or called.
131
+ const plugins = await loadPlugins(config.plugins, targetRepo)
132
+ const repoState = resolveRepoStatePaths({
133
+ repo: targetRepo,
134
+ ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
135
+ ...(opts.env.AB_STORE !== undefined ? { envStore: opts.env.AB_STORE } : {}),
136
+ })
137
+ const factory = opts.sourceFactory ?? createTicketSource
138
+ return {
139
+ config,
140
+ source: await factory(config.tickets, opts.env, targetRepo, repoState.localStateRoot, plugins),
141
+ }
142
+ }
143
+
144
+ async function resolveTicketCommand(
145
+ opts: TicketCommandOpts,
146
+ command: TicketCommandName,
147
+ ): Promise<ResolvedTicketCommand> {
148
+ return openTicketSource(opts, `ab ticket ${command}`)
149
+ }
150
+
151
+ async function readBody(path: string): Promise<string> {
152
+ try {
153
+ return await readFile(path, 'utf8')
154
+ } catch (error) {
155
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
156
+ throw new Error(`--body ${path}: file not found — expected a file holding the ticket body`)
157
+ }
158
+ throw error
159
+ }
160
+ }
161
+
162
+ function ticketSummary(ticket: Ticket): string {
163
+ const fields = [
164
+ `${ticket.ref.source}:${ticket.ref.id}`,
165
+ `(${ticket.state ?? 'state unknown'})`,
166
+ `— ${ticket.title}`,
167
+ ]
168
+ if (ticket.labels.length > 0) fields.push(`— labels: ${ticket.labels.join(', ')}`)
169
+ if (ticket.blockedBy !== undefined && ticket.blockedBy.length > 0) {
170
+ fields.push(`— blocked by: ${ticket.blockedBy.join(', ')}`)
171
+ }
172
+ if (ticket.ref.url !== undefined) fields.push(`— ${ticket.ref.url}`)
173
+ return fields.join(' ')
174
+ }
175
+
176
+ function ticketDetail(ticket: Ticket): string[] {
177
+ const lines = [
178
+ `ticket ${ticket.ref.source}:${ticket.ref.id}`,
179
+ ` title: ${ticket.title}`,
180
+ ` state: ${ticket.state ?? '(unknown)'}`,
181
+ ` labels: ${ticket.labels.join(', ') || '(none)'}`,
182
+ ]
183
+ if (ticket.blockedBy !== undefined && ticket.blockedBy.length > 0) {
184
+ lines.push(` blocked by: ${ticket.blockedBy.join(', ')}`)
185
+ }
186
+ if (ticket.ref.url !== undefined) lines.push(` url: ${ticket.ref.url}`)
187
+ lines.push(' body:')
188
+ return lines
189
+ }
190
+
191
+ function emitTicketJson(opts: TicketCommandOpts, ticket: Ticket): void {
192
+ opts.stdout(JSON.stringify(ticket, null, 2))
193
+ }
194
+
195
+ export async function abTicketCreate(opts: TicketCreateOpts): Promise<void> {
196
+ // Read the complete body before constructing or calling a mutable source.
197
+ const body = await readBody(opts.bodyFile)
198
+ const { source } = await resolveTicketCommand(opts, 'create')
199
+
200
+ const ticket = await createTicket(
201
+ source,
202
+ {
203
+ title: opts.title,
204
+ body,
205
+ ...(opts.labels !== undefined ? { labels: opts.labels } : {}),
206
+ ...(opts.blockedBy !== undefined ? { blockedBy: opts.blockedBy } : {}),
207
+ ...(opts.state !== undefined ? { state: opts.state } : {}),
208
+ },
209
+ {
210
+ blockerErrorPrefix: '--blocked-by: ',
211
+ blockerErrorSuffix: ' (e.g. AUT-8 for linear, file-1 for file)',
212
+ },
213
+ )
214
+ if (opts.json === true) {
215
+ emitTicketJson(opts, ticket)
216
+ return
217
+ }
218
+ const state = ticket.state ?? 'created'
219
+ const url = ticket.ref.url !== undefined ? ` — ${ticket.ref.url}` : ''
220
+ const blockers =
221
+ ticket.blockedBy !== undefined && ticket.blockedBy.length > 0
222
+ ? ` — blocked by ${ticket.blockedBy.join(', ')}`
223
+ : ''
224
+ opts.stdout(`ticket created: ${ticket.ref.source}:${ticket.ref.id} (${state})${blockers}${url}`)
225
+ }
226
+
227
+ export async function abTicketUpdate(opts: TicketUpdateOpts): Promise<void> {
228
+ const body = opts.bodyFile === undefined ? undefined : await readBody(opts.bodyFile)
229
+ const { source } = await resolveTicketCommand(opts, 'update')
230
+ const patch: TicketUpdate = {
231
+ ...(opts.title !== undefined ? { title: opts.title } : {}),
232
+ ...(body !== undefined ? { body } : {}),
233
+ ...(opts.labels !== undefined ? { labels: [...opts.labels] } : {}),
234
+ }
235
+ if (opts.json === true) {
236
+ const updated = await updateTicket(source, opts.id, patch)
237
+ if (updated !== null) emitTicketJson(opts, updated)
238
+ return
239
+ }
240
+ await updateTicket(source, opts.id, patch, false)
241
+ opts.stdout(`ticket updated: ${source.name}:${opts.id}`)
242
+ }
243
+
244
+ export async function abTicketBlock(opts: TicketBlockerOpts): Promise<void> {
245
+ const { source } = await resolveTicketCommand(opts, 'block')
246
+ const blockerIds = [...new Set(opts.blockerIds)]
247
+ const updated = await changeBlockers(source, opts.id, blockerIds, 'block')
248
+ if (opts.json === true) {
249
+ emitTicketJson(opts, updated)
250
+ return
251
+ }
252
+ opts.stdout(
253
+ `ticket blocker added: ${source.name}:${opts.id} — blocked by ${blockerIds.join(', ')}`,
254
+ )
255
+ }
256
+
257
+ export async function abTicketUnblock(opts: TicketBlockerOpts): Promise<void> {
258
+ const { source } = await resolveTicketCommand(opts, 'unblock')
259
+ const blockerIds = [...new Set(opts.blockerIds)]
260
+ const updated = await changeBlockers(source, opts.id, blockerIds, 'unblock')
261
+ if (opts.json === true) {
262
+ emitTicketJson(opts, updated)
263
+ return
264
+ }
265
+ opts.stdout(
266
+ `ticket blocker removed: ${source.name}:${opts.id} — no longer blocked by ${blockerIds.join(', ')}`,
267
+ )
268
+ }
269
+
270
+ /** `ab ticket list` — ready-to-dispatch by default, explicit criteria otherwise. */
271
+ export async function abTicketList(opts: TicketListOpts): Promise<void> {
272
+ const { config, source } = await resolveTicketCommand(opts, 'list')
273
+ const criteria = ticketListCriteria(config, {
274
+ ...(opts.state !== undefined ? { state: opts.state } : {}),
275
+ ...(opts.labels !== undefined ? { labels: opts.labels } : {}),
276
+ })
277
+ const listing = await source.listReady(criteria)
278
+ for (const diagnostic of listing.diagnostics) opts.stderr(diagnostic)
279
+ if (opts.json === true) {
280
+ opts.stdout(JSON.stringify(listing.tickets, null, 2))
281
+ return
282
+ }
283
+ if (listing.tickets.length === 0) {
284
+ opts.stdout(`no tickets matched in the configured ${source.name} ticket source`)
285
+ return
286
+ }
287
+ for (const ticket of listing.tickets) opts.stdout(ticketSummary(ticket))
288
+ }
289
+
290
+ /** `ab ticket show <id>` — complete metadata plus the body/spec. */
291
+ export async function abTicketShow(opts: TicketShowOpts): Promise<void> {
292
+ const { source } = await resolveTicketCommand(opts, 'show')
293
+ const ticket = await requireTicket(source, opts.id)
294
+ if (opts.json === true) {
295
+ opts.stdout(JSON.stringify(ticket, null, 2))
296
+ return
297
+ }
298
+ for (const line of ticketDetail(ticket)) opts.stdout(line)
299
+ // Keep the adapter-provided body untouched. It may itself be multiline and
300
+ // may intentionally end (or not end) with a newline.
301
+ opts.stdout(ticket.body)
302
+ }
303
+
304
+ /** `ab ticket move <id> <state>` — adapter-owned state validation and move. */
305
+ export async function abTicketMove(opts: TicketMoveOpts): Promise<void> {
306
+ const { source } = await resolveTicketCommand(opts, 'move')
307
+ const moved = await moveTicket(source, opts.id, opts.state)
308
+ if (opts.json === true) {
309
+ opts.stdout(JSON.stringify(moved, null, 2))
310
+ return
311
+ }
312
+ opts.stdout(`ticket moved: ${ticketSummary(moved)}`)
313
+ }
314
+
315
+ const CREATE_USAGE =
316
+ 'usage: ab ticket create <title> --body <file> [--state <state>] [--labels a,b] [--blocked-by id,id] [--json] (§8.8)'
317
+ const UPDATE_USAGE =
318
+ 'usage: ab ticket update <id> [--title <title>] [--body <file>] [--labels a,b] [--json] (§8.8)'
319
+ const BLOCK_USAGE = 'usage: ab ticket block <id> <blocker-id[,blocker-id...]> [--json] (§8.8)'
320
+ const UNBLOCK_USAGE = 'usage: ab ticket unblock <id> <blocker-id[,blocker-id...]> [--json] (§8.8)'
321
+ const LIST_USAGE = 'usage: ab ticket list [--state <state>] [--labels a,b] [--json] (§8.8)'
322
+ const SHOW_USAGE = 'usage: ab ticket show <id> [--json] (§8.8)'
323
+ const MOVE_USAGE = 'usage: ab ticket move <id> <state> [--json] (§8.8)'
324
+ export const TICKET_USAGE = [
325
+ CREATE_USAGE,
326
+ UPDATE_USAGE,
327
+ BLOCK_USAGE,
328
+ UNBLOCK_USAGE,
329
+ LIST_USAGE,
330
+ SHOW_USAGE,
331
+ MOVE_USAGE,
332
+ ].join('\n')
333
+
334
+ function commaList(value: string): string[] {
335
+ return value
336
+ .split(',')
337
+ .map((entry) => entry.trim())
338
+ .filter((entry) => entry !== '')
339
+ }
340
+
341
+ interface TicketCliOpts extends TicketCommandOpts {
342
+ /** Required because the `list` subcommand can produce record diagnostics. */
343
+ stderr: (line: string) => void
344
+ }
345
+
346
+ /** Parse and execute the complete ticket argv tail. Each subcommand supplies
347
+ * only its own flags to the shared command-scoped parser. */
348
+ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<void> {
349
+ const [command, ...args] = argv
350
+ switch (command) {
351
+ case 'create': {
352
+ const parsed = parseArgs(
353
+ args,
354
+ { body: 'value', state: 'value', labels: 'value', 'blocked-by': 'value', json: 'boolean' },
355
+ TICKET_USAGE,
356
+ )
357
+ const title = parsed.positionals.join(' ')
358
+ const bodyFile = stringFlag(parsed, 'body')
359
+ if (title.trim() === '' || bodyFile === undefined || bodyFile.trim() === '') {
360
+ throw new Error(TICKET_USAGE)
361
+ }
362
+ const state = stringFlag(parsed, 'state')
363
+ if (state !== undefined && state.trim() === '') throw new Error(TICKET_USAGE)
364
+ const labels = stringFlag(parsed, 'labels')
365
+ const blockedBy = stringFlag(parsed, 'blocked-by')
366
+ await abTicketCreate({
367
+ ...opts,
368
+ title,
369
+ bodyFile,
370
+ ...(state !== undefined ? { state } : {}),
371
+ ...(labels !== undefined ? { labels: commaList(labels) } : {}),
372
+ ...(blockedBy !== undefined ? { blockedBy: commaList(blockedBy) } : {}),
373
+ json: parsed.flags.has('json'),
374
+ })
375
+ return
376
+ }
377
+
378
+ case 'update': {
379
+ const parsed = parseArgs(
380
+ args,
381
+ { title: 'value', body: 'value', labels: 'value', json: 'boolean' },
382
+ TICKET_USAGE,
383
+ )
384
+ const [id, ...extra] = parsed.positionals
385
+ const hasUpdate = ['title', 'body', 'labels'].some((flag) => parsed.flags.has(flag))
386
+ if (id === undefined || id.trim() === '' || extra.length > 0 || !hasUpdate) {
387
+ throw new Error(TICKET_USAGE)
388
+ }
389
+ const title = stringFlag(parsed, 'title')
390
+ const bodyFile = stringFlag(parsed, 'body')
391
+ const labels = stringFlag(parsed, 'labels')
392
+ if (bodyFile !== undefined && bodyFile.trim() === '') {
393
+ throw new Error(TICKET_USAGE)
394
+ }
395
+ await abTicketUpdate({
396
+ ...opts,
397
+ id,
398
+ ...(title !== undefined ? { title } : {}),
399
+ ...(bodyFile !== undefined ? { bodyFile } : {}),
400
+ ...(labels !== undefined ? { labels: commaList(labels) } : {}),
401
+ json: parsed.flags.has('json'),
402
+ })
403
+ return
404
+ }
405
+
406
+ case 'block':
407
+ case 'unblock': {
408
+ const parsed = parseArgs(args, { json: 'boolean' }, TICKET_USAGE)
409
+ const [id, blockerList, ...extra] = parsed.positionals
410
+ if (
411
+ id === undefined ||
412
+ id.trim() === '' ||
413
+ blockerList === undefined ||
414
+ blockerList.trim() === '' ||
415
+ extra.length > 0
416
+ ) {
417
+ throw new Error(TICKET_USAGE)
418
+ }
419
+ const blockerIds = commaList(blockerList)
420
+ if (blockerIds.length === 0) throw new Error(TICKET_USAGE)
421
+ const blockerOpts = { ...opts, id, blockerIds, json: parsed.flags.has('json') }
422
+ if (command === 'block') await abTicketBlock(blockerOpts)
423
+ else await abTicketUnblock(blockerOpts)
424
+ return
425
+ }
426
+
427
+ case 'list': {
428
+ const parsed = parseArgs(
429
+ args,
430
+ { state: 'value', labels: 'value', json: 'boolean' },
431
+ TICKET_USAGE,
432
+ )
433
+ if (parsed.positionals.length !== 0) throw new Error(TICKET_USAGE)
434
+ const state = stringFlag(parsed, 'state')
435
+ if (state !== undefined && state.trim() === '') throw new Error(TICKET_USAGE)
436
+ const labels = stringFlag(parsed, 'labels')
437
+ await abTicketList({
438
+ ...opts,
439
+ ...(state !== undefined ? { state } : {}),
440
+ ...(labels !== undefined ? { labels: commaList(labels) } : {}),
441
+ json: parsed.flags.has('json'),
442
+ })
443
+ return
444
+ }
445
+
446
+ case 'show': {
447
+ const parsed = parseArgs(args, { json: 'boolean' }, TICKET_USAGE)
448
+ const [id, ...extra] = parsed.positionals
449
+ if (id === undefined || id.trim() === '' || extra.length > 0) {
450
+ throw new Error(TICKET_USAGE)
451
+ }
452
+ await abTicketShow({
453
+ ...opts,
454
+ id,
455
+ json: parsed.flags.has('json'),
456
+ })
457
+ return
458
+ }
459
+
460
+ case 'move': {
461
+ const parsed = parseArgs(args, { json: 'boolean' }, TICKET_USAGE)
462
+ const [id, state, ...extra] = parsed.positionals
463
+ if (
464
+ id === undefined ||
465
+ id.trim() === '' ||
466
+ state === undefined ||
467
+ state.trim() === '' ||
468
+ extra.length > 0
469
+ ) {
470
+ throw new Error(TICKET_USAGE)
471
+ }
472
+ await abTicketMove({
473
+ ...opts,
474
+ id,
475
+ state,
476
+ json: parsed.flags.has('json'),
477
+ })
478
+ return
479
+ }
480
+
481
+ default:
482
+ throw new Error(TICKET_USAGE)
483
+ }
484
+ }