@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,135 @@
1
+ /**
2
+ * TicketSource factory (SPEC §3.2, §13): construct the builtin or plugin source
3
+ * named by [tickets]. Plugin credentials, like LINEAR_API_KEY, come only from
4
+ * the environment. This remains the single boundary for file-directory
5
+ * defaulting and for registry-aware source selection.
6
+ */
7
+ import { resolve } from 'node:path'
8
+ import type { TicketsConfig } from '../../config/schema'
9
+ import { createPluginRegistry, type PluginRegistry } from '../../plugins/registry'
10
+ import type { PluginFactoryContext } from '../../plugins/manifest'
11
+ import type { TicketSource } from '../types'
12
+ import { DEFAULT_TICKETS_DIR, FileTicketSource } from './file'
13
+ import { LinearTicketSource } from './linear'
14
+ import { HostedTicketSource } from './remote'
15
+
16
+ function reason(error: unknown): string {
17
+ return error instanceof Error ? error.message : String(error)
18
+ }
19
+
20
+ export async function createTicketSource(
21
+ config: TicketsConfig,
22
+ env: Record<string, string | undefined>,
23
+ /** A relative [tickets].dir is relative to the repo, not this process's cwd. */
24
+ targetRepo: string,
25
+ /** Selected local state root. Remote stores pass the repo-local default. */
26
+ localStateRoot?: string,
27
+ /** Registry loaded from this repository's configured plugins. */
28
+ plugins: PluginRegistry = createPluginRegistry(),
29
+ ): Promise<TicketSource> {
30
+ if (config.source === 'hosted') {
31
+ const rawUrl = env.AB_STORE?.trim()
32
+ if (!rawUrl) {
33
+ throw new Error(
34
+ 'AB_STORE is not set — required when [tickets].source = "hosted"; set it to the hosted service HTTP(S) URL',
35
+ )
36
+ }
37
+ let serviceUrl: URL
38
+ try {
39
+ serviceUrl = new URL(rawUrl)
40
+ } catch {
41
+ throw new Error('AB_STORE must be a valid HTTP(S) URL when [tickets].source = "hosted"')
42
+ }
43
+ if (serviceUrl.protocol !== 'http:' && serviceUrl.protocol !== 'https:') {
44
+ throw new Error('AB_STORE must be an HTTP(S) URL when [tickets].source = "hosted"')
45
+ }
46
+ const token = env.AB_TOKEN?.trim()
47
+ if (!token) {
48
+ throw new Error(
49
+ 'AB_TOKEN is not set — an operator-scope token is required when [tickets].source = "hosted"',
50
+ )
51
+ }
52
+ if (config.teamKey === undefined) {
53
+ throw new Error('[tickets].source = "hosted" requires teamKey')
54
+ }
55
+ return new HostedTicketSource({
56
+ url: serviceUrl.toString(),
57
+ token,
58
+ teamKey: config.teamKey,
59
+ ...(config.claimedState !== undefined ? { claimedState: config.claimedState } : {}),
60
+ ...(config.createState !== undefined ? { createState: config.createState } : {}),
61
+ })
62
+ }
63
+
64
+ if (config.source === 'linear') {
65
+ const apiKey = env.LINEAR_API_KEY
66
+ if (apiKey === undefined || apiKey === '') {
67
+ throw new Error(
68
+ 'LINEAR_API_KEY is not set — expected a Linear personal API key; ' +
69
+ 'required when [tickets].source = "linear". Set it in the ' +
70
+ 'environment or a local .env file.',
71
+ )
72
+ }
73
+ // teamKey presence is cross-validated at config parse; re-checked here
74
+ // because plugin configurations can carry the same optional field.
75
+ if (config.teamKey === undefined) {
76
+ throw new Error(
77
+ '[tickets].source = "linear" requires teamKey — the Linear team key (e.g. "ENG")',
78
+ )
79
+ }
80
+ return new LinearTicketSource({
81
+ apiKey,
82
+ teamKey: config.teamKey,
83
+ ...(config.claimedState !== undefined ? { claimedState: config.claimedState } : {}),
84
+ ...(config.createState !== undefined ? { createState: config.createState } : {}),
85
+ })
86
+ }
87
+
88
+ if (config.source === 'file') {
89
+ return new FileTicketSource({
90
+ dir:
91
+ config.dir === undefined && localStateRoot !== undefined
92
+ ? resolve(localStateRoot, 'tickets')
93
+ : resolve(targetRepo, config.dir ?? DEFAULT_TICKETS_DIR),
94
+ // Only the DEFAULTED backlog hides itself from git. An explicit dir is
95
+ // user-owned and may intentionally be tracked.
96
+ selfIgnore: config.dir === undefined,
97
+ ...(config.createState !== undefined ? { createState: config.createState } : {}),
98
+ })
99
+ }
100
+
101
+ const registration = plugins.ticketSources.get(config.source)
102
+ if (registration === undefined || registration.factory === undefined) {
103
+ const available = [...plugins.ticketSources.keys()].sort().join(', ')
104
+ throw new Error(
105
+ `unknown ticket source "${config.source}"; available ticket sources: ${available}`,
106
+ )
107
+ }
108
+
109
+ const missing = (registration.requiredEnv ?? []).filter((name) => {
110
+ const value = env[name]
111
+ return value === undefined || value === ''
112
+ })
113
+ if (missing.length > 0) {
114
+ throw new Error(
115
+ `ticket source "${config.source}" from plugin "${registration.owner.name}" ` +
116
+ `requires environment ${missing.map((name) => `variable "${name}"`).join(', ')}; ` +
117
+ 'set the missing credentials in the environment or a local .env file',
118
+ )
119
+ }
120
+
121
+ const context: PluginFactoryContext = {
122
+ config: config as unknown as Record<string, unknown>,
123
+ env,
124
+ repoRoot: resolve(targetRepo),
125
+ }
126
+ try {
127
+ return await registration.factory(context)
128
+ } catch (error) {
129
+ throw new Error(
130
+ `ticket source "${config.source}" from plugin "${registration.owner.name}" ` +
131
+ `failed during construction: ${reason(error)}`,
132
+ { cause: error },
133
+ )
134
+ }
135
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * In-memory TicketSource for seam tests (SPEC §3.2, §13). Mirrors the policy
3
+ * shape of the real adapters: initiation (listReady/get/claim/create),
4
+ * pre-build grooming writes (update/blockers), and outward projections
5
+ * (comment/transition), all journaled where seam tests need exact evidence.
6
+ */
7
+ import type {
8
+ DependencyState,
9
+ Ticket,
10
+ TicketCreateOptions,
11
+ TicketDraft,
12
+ TicketListing,
13
+ TicketSource,
14
+ TicketUpdate,
15
+ } from '../types'
16
+ import { validateTicketUpdate } from './update'
17
+
18
+ function cloneTicket(ticket: Ticket): Ticket {
19
+ return {
20
+ ref: { ...ticket.ref },
21
+ ...(ticket.creationKey !== undefined ? { creationKey: ticket.creationKey } : {}),
22
+ title: ticket.title,
23
+ body: ticket.body,
24
+ state: ticket.state,
25
+ labels: [...ticket.labels],
26
+ ...(ticket.blockedBy !== undefined ? { blockedBy: [...ticket.blockedBy] } : {}),
27
+ }
28
+ }
29
+
30
+ export class FakeTicketSource implements TicketSource {
31
+ readonly name = 'fake'
32
+
33
+ /** Journal of every projection sent outward (§13), in call order. */
34
+ readonly comments: Array<{ id: string; body: string }> = []
35
+ readonly transitions: Array<{ id: string; state: string }> = []
36
+ /** Every accepted grooming write, including idempotent blocker retries. */
37
+ readonly updates: Array<{ id: string; patch: TicketUpdate }> = []
38
+ readonly blockerAdds: Array<{ id: string; blockerId: string }> = []
39
+ readonly blockerRemovals: Array<{ id: string; blockerId: string }> = []
40
+ /** Ids claimed, in call order — lets tests assert a blocked ticket was
41
+ * never claimed (§12: gating precedes claim-before-launch). */
42
+ readonly claims: string[] = []
43
+ /** Every `dependencyStates` request, in call order — lets tests assert a
44
+ * dependency-free ticket costs zero port calls. */
45
+ readonly dependencyQueries: string[][] = []
46
+
47
+ private readonly tickets = new Map<string, Ticket>()
48
+ private readonly claimed = new Set<string>()
49
+ private readonly idempotency = new Map<string, string>()
50
+ private readonly createState: string
51
+ private readonly doneState: string
52
+ private nextId = 1
53
+
54
+ constructor(
55
+ seed: Ticket[] = [],
56
+ opts: {
57
+ /** State assigned by create — proposals land in Triage (SPEC §12). */
58
+ createState?: string
59
+ /** State this fake treats as resolved for dependencies (§13: the
60
+ * adapter owns what "complete" means). */
61
+ doneState?: string
62
+ } = {},
63
+ ) {
64
+ for (const ticket of seed) {
65
+ this.tickets.set(ticket.ref.id, cloneTicket(ticket))
66
+ if (ticket.creationKey !== undefined) {
67
+ this.idempotency.set(ticket.creationKey, ticket.ref.id)
68
+ }
69
+ }
70
+ this.createState = opts.createState ?? 'Triage'
71
+ this.doneState = opts.doneState ?? 'Done'
72
+ }
73
+
74
+ /** Seed a ticket after construction — lets a test make one become Ready
75
+ * mid-run, which is how "a single pass does not claim tickets that arrive
76
+ * after its initial selection" (§12) gets a real oracle rather than a
77
+ * vacuous one. */
78
+ add(ticket: Ticket): void {
79
+ this.tickets.set(ticket.ref.id, cloneTicket(ticket))
80
+ }
81
+
82
+ async listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing> {
83
+ const labels = criteria.labels ?? []
84
+ return {
85
+ tickets: [...this.tickets.values()]
86
+ .filter(
87
+ (ticket) =>
88
+ (criteria.state === undefined || ticket.state === criteria.state) &&
89
+ labels.every((label) => ticket.labels.includes(label)),
90
+ )
91
+ .map(cloneTicket),
92
+ diagnostics: [],
93
+ }
94
+ }
95
+
96
+ async get(id: string): Promise<Ticket | null> {
97
+ const ticket = this.tickets.get(id)
98
+ return ticket ? cloneTicket(ticket) : null
99
+ }
100
+
101
+ /** Claim-before-launch: true once per nonterminal lifecycle stay. A later
102
+ * explicit transition releases the fake claim, but the configured terminal
103
+ * state remains unclaimable and cannot be used as an implicit reopen. */
104
+ async claim(id: string): Promise<boolean> {
105
+ this.claims.push(id)
106
+ const ticket = this.tickets.get(id)
107
+ if (!ticket || ticket.state === this.doneState || this.claimed.has(id)) return false
108
+ this.claimed.add(id)
109
+ return true
110
+ }
111
+
112
+ async comment(id: string, body: string): Promise<void> {
113
+ this.require(id, 'comment')
114
+ this.comments.push({ id, body })
115
+ }
116
+
117
+ async transition(id: string, state: string): Promise<void> {
118
+ const ticket = this.require(id, 'transition')
119
+ ticket.state = state
120
+ this.claimed.delete(id)
121
+ this.transitions.push({ id, state })
122
+ }
123
+
124
+ async create(draft: TicketDraft, opts: TicketCreateOptions = {}): Promise<Ticket> {
125
+ if (opts.idempotencyKey !== undefined) {
126
+ const adopted = this.idempotency.get(opts.idempotencyKey)
127
+ if (adopted !== undefined) return cloneTicket(this.require(adopted, 'create'))
128
+ }
129
+ const id = `fake-${this.nextId++}`
130
+ const ticket: Ticket = {
131
+ ref: { source: this.name, id, title: draft.title },
132
+ ...(opts.idempotencyKey !== undefined ? { creationKey: opts.idempotencyKey } : {}),
133
+ title: draft.title,
134
+ body: draft.body,
135
+ state: opts.state ?? this.createState,
136
+ labels: [...(draft.labels ?? [])],
137
+ // Recorded verbatim, never discarded — the same contract the real
138
+ // adapters owe (§13).
139
+ ...(draft.blockedBy !== undefined && draft.blockedBy.length > 0
140
+ ? { blockedBy: [...draft.blockedBy] }
141
+ : {}),
142
+ }
143
+ this.tickets.set(id, ticket)
144
+ if (opts.idempotencyKey !== undefined) {
145
+ this.idempotency.set(opts.idempotencyKey, id)
146
+ }
147
+ return cloneTicket(ticket)
148
+ }
149
+
150
+ async update(id: string, patch: TicketUpdate): Promise<void> {
151
+ const validated = validateTicketUpdate(patch)
152
+ const ticket = this.require(id, 'update')
153
+
154
+ if (validated.title !== undefined) {
155
+ ticket.title = validated.title
156
+ ticket.ref.title = validated.title
157
+ }
158
+ if (validated.body !== undefined) ticket.body = validated.body
159
+ if (validated.labels !== undefined) ticket.labels = [...validated.labels]
160
+
161
+ this.updates.push({ id, patch: cloneUpdate(validated) })
162
+ }
163
+
164
+ async addBlocker(id: string, blockerId: string): Promise<void> {
165
+ const ticket = this.require(id, 'addBlocker')
166
+ if (id === blockerId) {
167
+ throw new Error(`fake ticket source: ticket "${id}" cannot block itself`)
168
+ }
169
+ this.require(blockerId, 'addBlocker')
170
+
171
+ this.blockerAdds.push({ id, blockerId })
172
+ if (ticket.blockedBy?.includes(blockerId)) return
173
+ ticket.blockedBy = [...(ticket.blockedBy ?? []), blockerId]
174
+ }
175
+
176
+ async removeBlocker(id: string, blockerId: string): Promise<void> {
177
+ const ticket = this.require(id, 'removeBlocker')
178
+ this.blockerRemovals.push({ id, blockerId })
179
+
180
+ const remaining = (ticket.blockedBy ?? []).filter((candidate) => candidate !== blockerId)
181
+ if (remaining.length === 0) delete ticket.blockedBy
182
+ else ticket.blockedBy = remaining
183
+ }
184
+
185
+ /** Resolution is this fake's own lifecycle: `doneState` and nothing else. */
186
+ async dependencyStates(ids: string[]): Promise<DependencyState[]> {
187
+ this.dependencyQueries.push([...ids])
188
+ return ids.map((id) => {
189
+ const ticket = this.tickets.get(id)
190
+ if (!ticket) return { id, exists: false, resolved: false, blockedBy: [] }
191
+ return {
192
+ id,
193
+ exists: true,
194
+ resolved: ticket.state === this.doneState,
195
+ blockedBy: [...(ticket.blockedBy ?? [])],
196
+ }
197
+ })
198
+ }
199
+
200
+ private require(id: string, operation: string): Ticket {
201
+ const ticket = this.tickets.get(id)
202
+ if (!ticket) {
203
+ throw new Error(`fake ticket source: ${operation} on unknown ticket "${id}"`)
204
+ }
205
+ return ticket
206
+ }
207
+ }
208
+
209
+ function cloneUpdate(patch: TicketUpdate): TicketUpdate {
210
+ return {
211
+ ...(patch.title !== undefined ? { title: patch.title } : {}),
212
+ ...(patch.body !== undefined ? { body: patch.body } : {}),
213
+ ...(patch.labels !== undefined ? { labels: [...patch.labels] } : {}),
214
+ }
215
+ }