@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,774 @@
1
+ /**
2
+ * autobuild.toml schema (SPEC §16.1, D9): the one declarative per-repo config
3
+ * file. Declarative, not executable — the kernel, dispatcher, CLI, and any
4
+ * future tooling parse it without evaluating anything; commands are plain
5
+ * shell strings.
6
+ *
7
+ * Strictness policy (§16.1): unknown top-level keys/tables and unknown keys
8
+ * inside known tables are ERRORS — a typo must not silently disable a
9
+ * verifier. Open maps ([commands], [roles], [workspace.config], and the named
10
+ * [verify.<step>] and [finalize.<step>] table sets) are exempt by construction:
11
+ * their keys are user-chosen names, while every value remains strictly
12
+ * validated.
13
+ */
14
+ import { z } from 'zod'
15
+ import { prImageHostSchema } from '../ontology'
16
+ import { SANDBOX_FORBIDDEN_ENV } from '../ports/workspace/operator-sandbox'
17
+ import { defineEntry, openMap, ownEntries, parseEntry } from '../open-map'
18
+ import { forwardIssues } from '../zod-issues'
19
+ import { displayName, effectiveRuntimeReferences, tomlKey } from './roles'
20
+
21
+ // ── Open maps ────────────────────────────────────────────────────────────────
22
+
23
+ //
24
+ // Every open map below is parsed through `../open-map`, which keeps a
25
+ // user-chosen key intact instead of losing it to the prototype chain: the
26
+ // perfectly valid TOML table `[roles."__proto__"]` would otherwise vanish while
27
+ // parsing SUCCEEDS — never eagerly resolved, never dispatchable, and invisible
28
+ // to the unconsumed-key warning that exists to catch exactly this class of dead
29
+ // configuration (§9). A silent drop is the one outcome the strictness policy
30
+ // above rules out.
31
+ //
32
+ // The TOML parser already hands us null-prototype tables with the key intact,
33
+ // and `openMap` keeps it that way end to end.
34
+ //
35
+ // `[workspace.config]` is the one open map here whose keys are PLUGIN-owned
36
+ // rather than Autobuild-owned, which makes faithful pass-through more important
37
+ // rather than less. Autobuild cannot validate a key it does not interpret, so a
38
+ // dropped one is undetectable downstream: the plugin sees an absent key and
39
+ // cannot tell an operator who omitted it from one who declared it, then reports
40
+ // its own error against a table that plainly contains it.
41
+
42
+ /**
43
+ * A section mixing ONE known key (`steps`) with an open set of named step
44
+ * tables — the same preservation contract as `openMap`.
45
+ *
46
+ * Without it a valid `[verify."__proto__"]` table vanished before
47
+ * cross-validation, which then reported "is listed in verify.steps but has no
48
+ * [verify.__proto__] table" about a table sitting right there in the file. Loud
49
+ * rather than silent, but a diagnostic that contradicts the source is its own
50
+ * defect: §16.1 errors are feedback to whoever edits the file.
51
+ */
52
+ function stepSection<T, C>(
53
+ label: string,
54
+ stepsSchema: z.ZodType<string[]>,
55
+ tableSchema: z.ZodType<T>,
56
+ compose: (steps: string[], stepConfigs: Record<string, T>) => C,
57
+ ) {
58
+ return z
59
+ .unknown()
60
+ .transform((input, ctx): C => {
61
+ const stepConfigs: Record<string, T> = Object.create(null)
62
+ let steps: string[] = []
63
+ for (const [key, raw] of ownEntries(input, label, ctx)) {
64
+ if (key === 'steps') {
65
+ const parsed = stepsSchema.safeParse(raw)
66
+ if (parsed.success) steps = parsed.data
67
+ else forwardIssues(parsed.error.issues, ctx, ['steps'])
68
+ continue
69
+ }
70
+ const table = parseEntry(tableSchema, raw, key, ctx)
71
+ if (table.ok) defineEntry(stepConfigs, key, table.value)
72
+ }
73
+ return compose(steps, stepConfigs)
74
+ })
75
+ .prefault({})
76
+ }
77
+
78
+ // ── [pr] / [pr.imageHost] ────────────────────────────────────────────────────
79
+
80
+ /** Optional public GitHub release used for review-window image copies.
81
+ * Omission is intentionally `undefined`: hosting is off by default. */
82
+ export const imageHostSchema = prImageHostSchema
83
+ export type ImageHostConfig = z.infer<typeof imageHostSchema>
84
+
85
+ export const prSchema = z.strictObject({
86
+ imageHost: imageHostSchema.optional(),
87
+ })
88
+ export type PrConfig = z.infer<typeof prSchema>
89
+
90
+ // ── [workspace] ─────────────────────────────────────────────────────────────
91
+
92
+ const envNameSchema = z
93
+ .string()
94
+ .regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'must be a nonblank environment variable name')
95
+
96
+ /** Strict built-in Vercel configuration. Values are operational policy only;
97
+ * credentials are referenced by variable name and never accepted as literals. */
98
+ const nonblankProvisioningString = (field: string) =>
99
+ z.string().refine((value) => value.trim().length > 0, `${field} must be nonblank`)
100
+
101
+ export const vercelProvisioningStepSchema = z.strictObject({
102
+ name: nonblankProvisioningString('provisioning step name'),
103
+ command: nonblankProvisioningString('provisioning step command'),
104
+ })
105
+ export type VercelProvisioningStep = z.infer<typeof vercelProvisioningStepSchema>
106
+
107
+ const vercelUniversalImageSchema = z
108
+ .string()
109
+ .refine(
110
+ (image) =>
111
+ /^vercel\/sandbox\/universal(?::[A-Za-z0-9_][A-Za-z0-9._-]{0,127}|@sha256:[0-9a-f]{64})?$/.test(
112
+ image,
113
+ ),
114
+ 'Autobuild Bun provisioning is validated only on the vercel/sandbox/universal managed image; use its bare name, a tag, or a sha256 digest containing exactly 64 lowercase hexadecimal characters',
115
+ )
116
+
117
+ export const runtimeProvisioningEntrySchema = z.strictObject({
118
+ install: z.string().refine((value) => value.trim().length > 0, 'install must be nonblank'),
119
+ preflight: z.string().refine((value) => value.trim().length > 0, 'preflight must be nonblank'),
120
+ })
121
+ export type RuntimeProvisioningEntry = z.infer<typeof runtimeProvisioningEntrySchema>
122
+
123
+ export const runtimeProvisioningSchema = openMap(
124
+ '[workspace.config.runtimeProvisioning]',
125
+ runtimeProvisioningEntrySchema,
126
+ { keys: 'nonblank' },
127
+ )
128
+
129
+ export const vercelSandboxConfigSchema = z
130
+ .strictObject({
131
+ image: vercelUniversalImageSchema.default('vercel/sandbox/universal:latest'),
132
+ vcpus: z.number().int().min(1).max(32).default(4),
133
+ timeoutSeconds: z.number().int().min(60).max(86_400),
134
+ /** Deadline for each provider acknowledgement; distinct from VM lifetime. */
135
+ operationTimeoutMs: z.number().int().min(1_000).max(300_000).default(30_000),
136
+ /** Billing safety net: snapshots created for this environment expire after
137
+ * this duration instead of the provider's 30-day default. Absent means the
138
+ * provider default. */
139
+ snapshotExpirationSeconds: z.number().int().min(300).max(2_592_000).optional(),
140
+ region: z.string().min(1).optional(),
141
+ failoverRegions: z.array(z.string().min(1)).default([]),
142
+ environmentVariables: z.array(envNameSchema).default([]),
143
+ provisioning: z.array(vercelProvisioningStepSchema).default([]),
144
+ runtimeProvisioning: runtimeProvisioningSchema,
145
+ gitUsernameEnv: envNameSchema.optional(),
146
+ gitPasswordEnv: envNameSchema.optional(),
147
+ })
148
+ .superRefine((value, ctx) => {
149
+ const unique = (entries: readonly string[], path: string) => {
150
+ const seen = new Set<string>()
151
+ entries.forEach((entry, index) => {
152
+ if (seen.has(entry))
153
+ ctx.addIssue({
154
+ code: 'custom',
155
+ path: [path, index],
156
+ message: `duplicate environment variable ${JSON.stringify(entry)}`,
157
+ })
158
+ seen.add(entry)
159
+ })
160
+ }
161
+ unique(value.environmentVariables, 'environmentVariables')
162
+ unique(value.failoverRegions, 'failoverRegions')
163
+ const provisioningNames = new Map<string, number>()
164
+ value.provisioning.forEach((step, index) => {
165
+ const first = provisioningNames.get(step.name)
166
+ if (first !== undefined) {
167
+ ctx.addIssue({
168
+ code: 'custom',
169
+ path: ['provisioning', index, 'name'],
170
+ message: `duplicate provisioning step name ${JSON.stringify(step.name)} — first declared at provisioning[${first}].name`,
171
+ })
172
+ } else {
173
+ provisioningNames.set(step.name, index)
174
+ }
175
+ })
176
+ if (value.region !== undefined && value.failoverRegions.includes(value.region)) {
177
+ ctx.addIssue({
178
+ code: 'custom',
179
+ path: ['failoverRegions'],
180
+ message: 'failover regions must not include the primary region',
181
+ })
182
+ }
183
+ if ((value.gitUsernameEnv === undefined) !== (value.gitPasswordEnv === undefined)) {
184
+ ctx.addIssue({
185
+ code: 'custom',
186
+ path: ['gitPasswordEnv'],
187
+ message: 'gitUsernameEnv and gitPasswordEnv must be configured together',
188
+ })
189
+ }
190
+ for (const [key, name] of [
191
+ ['gitUsernameEnv', value.gitUsernameEnv],
192
+ ['gitPasswordEnv', value.gitPasswordEnv],
193
+ ] as const) {
194
+ if (name !== undefined && value.environmentVariables.includes(name)) {
195
+ ctx.addIssue({
196
+ code: 'custom',
197
+ path: [key],
198
+ message:
199
+ 'private repository clone credentials must not be exposed as runtime environment variables',
200
+ })
201
+ }
202
+ if (
203
+ name !== undefined &&
204
+ /^(?:GITHUB_TOKEN|GH_TOKEN|VERCEL_(?:TOKEN|OIDC_TOKEN|TEAM_ID|PROJECT_ID))$/.test(name)
205
+ ) {
206
+ ctx.addIssue({
207
+ code: 'custom',
208
+ path: [key],
209
+ message:
210
+ 'private repository read credentials must be separate from Forge and Vercel credentials',
211
+ })
212
+ }
213
+ }
214
+ })
215
+ type NormalizedVercelSandboxConfig = z.infer<typeof vercelSandboxConfigSchema>
216
+ /** Direct provider construction remains source-compatible; schema-parsed
217
+ * production config always materializes operationTimeoutMs. */
218
+ export type VercelSandboxConfig = Omit<
219
+ NormalizedVercelSandboxConfig,
220
+ 'operationTimeoutMs' | 'provisioning' | 'runtimeProvisioning'
221
+ > & {
222
+ operationTimeoutMs?: number
223
+ /** Optional only for source compatibility with direct adapter construction;
224
+ * schema-loaded repository configuration always materializes this list. */
225
+ provisioning?: VercelProvisioningStep[]
226
+ /** Optional only for source compatibility in direct adapter construction;
227
+ * parsed production configuration always materializes this map. */
228
+ runtimeProvisioning?: NormalizedVercelSandboxConfig['runtimeProvisioning']
229
+ }
230
+
231
+ /** Workspace selector. The host validates the selector envelope; plugin nested
232
+ * config remains open, while built-in config is validated by configSchema. */
233
+ export const workspaceSchema = z.strictObject({
234
+ provider: z
235
+ .string()
236
+ .refine(
237
+ (value) => value.trim().length > 0,
238
+ '[workspace].provider must be a nonblank provider name',
239
+ )
240
+ .default('git-worktree'),
241
+ config: openMap('[workspace.config]', z.unknown(), { keys: 'any' }),
242
+ })
243
+ export type WorkspaceConfig = z.infer<typeof workspaceSchema>
244
+
245
+ // ── [commands] ───────────────────────────────────────────────────────────────
246
+ //
247
+ // Open map of deterministic verbs the kernel may run. `setup`, `lint`,
248
+ // `typecheck`, `test` are conventions, not required keys (§16.1).
249
+
250
+ export const commandsSchema = openMap('[commands]', z.string().min(1))
251
+ export type Commands = z.infer<typeof commandsSchema>
252
+
253
+ // ── [verify.<step>] ──────────────────────────────────────────────────────────
254
+
255
+ /**
256
+ * The intentionally small path-selector grammar (§16.1): positive,
257
+ * repository-relative globs with literal characters, `*`, `?`, and `**` only
258
+ * as a complete path segment. Reject every ambiguous/unsafe form at config
259
+ * load rather than allowing a typo to become a never-running safety gate.
260
+ */
261
+ export function verifyPathGlobError(pattern: string): string | undefined {
262
+ if (pattern.length === 0) return 'path selectors must be nonempty'
263
+ if (pattern.includes('\0')) return 'path selectors must not contain NUL bytes'
264
+ if (pattern.startsWith('/') || /^[A-Za-z]:\//.test(pattern)) {
265
+ return 'path selectors must be repository-relative, not absolute'
266
+ }
267
+ if (pattern.includes('\\')) {
268
+ return 'path selectors use Git-style "/" separators; backslashes and escapes are unsupported'
269
+ }
270
+ if (pattern.startsWith('!')) {
271
+ return 'path selectors are positive globs; negation is unsupported'
272
+ }
273
+ if (/[[\]{}]/.test(pattern)) {
274
+ return 'path selectors support only literal characters, *, ?, and whole-segment **; character classes and brace expansion are unsupported'
275
+ }
276
+ if (/[?*+@!]\(/.test(pattern)) {
277
+ return 'path selectors support only literal characters, *, ?, and whole-segment **; extglobs are unsupported'
278
+ }
279
+
280
+ const segments = pattern.split('/')
281
+ if (segments.some((segment) => segment.length === 0)) {
282
+ return 'path selectors must not contain empty path segments'
283
+ }
284
+ if (segments.some((segment) => segment === '.' || segment === '..')) {
285
+ return 'path selectors must not contain "." or ".." traversal segments'
286
+ }
287
+ if (segments.some((segment) => segment.includes('**') && segment !== '**')) {
288
+ return 'the ** wildcard must occupy a complete path segment'
289
+ }
290
+ return undefined
291
+ }
292
+
293
+ export const verifyPathGlobSchema = z.string().superRefine((pattern, ctx) => {
294
+ const message = verifyPathGlobError(pattern)
295
+ if (message !== undefined) ctx.addIssue({ code: 'custom', message })
296
+ })
297
+
298
+ const verifyApplicabilityShape = {
299
+ /** Any-match positive path selectors; absent means unconditional. */
300
+ paths: z
301
+ .array(verifyPathGlobSchema)
302
+ .min(1, 'paths must contain at least one repository-relative glob')
303
+ .optional(),
304
+ /** Explicit mandatory-gate guard: true takes precedence over paths and a
305
+ * plan may not deselect the step. */
306
+ always: z.boolean().optional(),
307
+ }
308
+
309
+ export const verifyCheckStepSchema = z.strictObject({
310
+ /** Deterministic: command + pass/fail (§16.1). */
311
+ kind: z.literal('check'),
312
+ /** Ref into [commands] — cross-validated below. */
313
+ command: z.string().min(1),
314
+ ...verifyApplicabilityShape,
315
+ })
316
+
317
+ export const verifyAgentStepSchema = z.strictObject({
318
+ /** Agent-verify: skill + verdict schema (§16.1). */
319
+ kind: z.literal('agent'),
320
+ skill: z.string().min(1),
321
+ ...verifyApplicabilityShape,
322
+ })
323
+
324
+ export const verifyStepConfigSchema = z.discriminatedUnion('kind', [
325
+ verifyCheckStepSchema,
326
+ verifyAgentStepSchema,
327
+ ])
328
+ export type VerifyStepConfig = z.infer<typeof verifyStepConfigSchema>
329
+
330
+ export interface VerifyConfig {
331
+ /** Step order — each entry names a `verify:<step>` phase (§15.2.1). */
332
+ steps: string[]
333
+ /** The [verify.<step>] tables, keyed by step name. */
334
+ stepConfigs: Record<string, VerifyStepConfig>
335
+ }
336
+
337
+ /**
338
+ * [verify] mixes one known key (`steps`) with an open set of per-step
339
+ * subtables, so it cannot be a strictObject; instead every non-`steps` key is
340
+ * validated as a step table — nothing passes through loosely, and no step name
341
+ * is lost to the prototype chain (see `stepSection`).
342
+ */
343
+ export const verifySectionSchema = stepSection(
344
+ '[verify]',
345
+ z.array(z.string().min(1)).default([]),
346
+ verifyStepConfigSchema,
347
+ (steps, stepConfigs): VerifyConfig => ({ steps, stepConfigs }),
348
+ )
349
+
350
+ // ── [finalize.<step>] ────────────────────────────────────────────────────────
351
+
352
+ /** Finalize steps deliberately omit verify-only applicability fields. */
353
+ export const finalizeCheckStepSchema = z.strictObject({
354
+ kind: z.literal('check'),
355
+ /** Ref into [commands] — cross-validated below. */
356
+ command: z.string().min(1),
357
+ })
358
+
359
+ export const finalizeAgentStepSchema = z.strictObject({
360
+ kind: z.literal('agent'),
361
+ /** Exact installed skill name passed to the configured runtime. */
362
+ skill: z.string().min(1),
363
+ })
364
+
365
+ export const finalizeStepConfigSchema = z.discriminatedUnion('kind', [
366
+ finalizeCheckStepSchema,
367
+ finalizeAgentStepSchema,
368
+ ])
369
+ export type FinalizeStepConfig = z.infer<typeof finalizeStepConfigSchema>
370
+
371
+ export interface FinalizeConfig {
372
+ /** Ordered, failure-tolerant post-steps (§5). */
373
+ steps: string[]
374
+ /** The [finalize.<step>] tables, keyed by logical step name. */
375
+ stepConfigs: Record<string, FinalizeStepConfig>
376
+ }
377
+
378
+ /** Like [verify], [finalize] mixes `steps` with a strict named table set. */
379
+ export const finalizeSectionSchema = stepSection(
380
+ '[finalize]',
381
+ z.array(z.string().min(1, 'finalize.steps entries must be nonempty step names')).default([]),
382
+ finalizeStepConfigSchema,
383
+ (steps, stepConfigs): FinalizeConfig => ({ steps, stepConfigs }),
384
+ )
385
+
386
+ // ── [roles] ──────────────────────────────────────────────────────────────────
387
+ //
388
+ // Open map: role → fields on the runtime, model, and args axes (SPEC §9,
389
+ // §16.1). The reserved `default` entry is the raw inheritance base and must
390
+ // name a runtime; registry-aware enforcement lives in the eager resolver;
391
+ // every other role overrides it independently per field. Registry-dependent
392
+ // compatibility validation happens in the eager runtime resolver, because the
393
+ // config loader does not know the injected runtime registry.
394
+
395
+ const roleArgsSchema = z.array(z.string().min(1))
396
+
397
+ const runtimeAxesSchema = z.strictObject({
398
+ runtime: z.string().min(1).optional(),
399
+ model: z.string().min(1).optional(),
400
+ args: roleArgsSchema.optional(),
401
+ // Deprecated compatibility field. It remains schema-valid but is a warning-only no-op.
402
+ extensions: z.array(z.string().min(1)).optional(),
403
+ })
404
+
405
+ export const roleSchema = runtimeAxesSchema.extend({
406
+ // Wall-clock budget for each session dispatched through this logical role.
407
+ // Absent ⇒ inherit [roles.default], then [policy].sessionBudgetSeconds.
408
+ // Alternates deliberately use runtimeAxesSchema: one logical role owns one
409
+ // budget regardless of which provider target executes the attempt.
410
+ sessionBudgetSeconds: z.number().int().positive().optional(),
411
+ // Extra CLI arguments. Absent ⇒ inherit [roles.default].args; a declared
412
+ // list, including [], replaces the default wholesale rather than unioning.
413
+ args: roleArgsSchema.optional(),
414
+ // Deprecated compatibility field. It is accepted only so dispatch can emit
415
+ // a migration diagnostic; it has no effect on runtime resolution.
416
+ extensions: z.array(z.string().min(1)).optional(),
417
+ // Ordered failure-triggered execution targets. Absent ⇒ inherit the base
418
+ // list; a declared list (including []) replaces it wholesale. Each strict
419
+ // entry overlays the concrete role's resolved primary axes.
420
+ alternates: z.array(runtimeAxesSchema).optional(),
421
+ })
422
+ export type RoleConfig = z.infer<typeof roleSchema>
423
+
424
+ // ── [policy] ─────────────────────────────────────────────────────────────────
425
+
426
+ export const policySchema = z.strictObject({
427
+ /** Wall-clock bound for an agent phase session; roles may override it. */
428
+ sessionBudgetSeconds: z.number().int().positive().default(3600),
429
+ /** Same-finding survival threshold before auto-escalate (§10, §15.4). */
430
+ stallRounds: z.number().int().positive().default(3),
431
+ maxVerifyAttempts: z.number().int().positive().default(3),
432
+ /** Consecutive workspace setup failures before human intervention is required. */
433
+ maxSetupAttempts: z.number().int().positive().default(3),
434
+ /** Consecutive provider lifecycle failures before recovery requires a human retry. */
435
+ maxInfrastructureAttempts: z.number().int().positive().default(3),
436
+ maxReconcileAttempts: z.number().int().positive().default(3),
437
+ /** converge's `maxRounds` for the review loops (SPEC §10). */
438
+ maxReviewRounds: z.number().int().positive().default(6),
439
+ /** Unclaimed observation occurrences required to start one harvest run. */
440
+ harvestThreshold: z.number().int().positive().default(5),
441
+ /** Merged builds after the oldest observation that start harvest; zero disables drift. */
442
+ harvestMaxDrift: z.number().int().nonnegative().default(3),
443
+ })
444
+ export type PolicyConfig = z.infer<typeof policySchema>
445
+
446
+ // ── [tickets] ────────────────────────────────────────────────────────────────
447
+ //
448
+ // Which TicketSource the dispatcher drives and the source-vocabulary states
449
+ // that govern its lifecycle (§3.2, §3.3, §13). Declarative only: the Linear
450
+ // API key comes from the LINEAR_API_KEY environment variable, never from this
451
+ // file.
452
+
453
+ export const ticketsSchema = z.strictObject({
454
+ /** Builtin or plugin-registered TicketSource name. Registry membership is
455
+ * validated after configured plugins load. */
456
+ source: z
457
+ .string()
458
+ .refine(
459
+ (value) => value.trim().length > 0,
460
+ '[tickets].source must be a nonblank builtin or plugin ticket source name',
461
+ ),
462
+ /**
463
+ * Ticket labels that additionally narrow the mandatory readyState gate
464
+ * (§3.3). A nonempty list is conjunctive: every configured label must be
465
+ * present. For readyLabels = ["autobuild", "ready"], a ticket carrying only
466
+ * "autobuild" does not satisfy the label gate. An explicit [] disables the
467
+ * label gate; absent uses the ticket source's default (linear: ["autobuild"];
468
+ * file: none). Resolved by readyCriteria in src/processes/dispatcher.ts.
469
+ */
470
+ readyLabels: z.array(z.string().min(1)).optional(),
471
+ /**
472
+ * The single workflow state a ticket must sit in to be dispatchable — the
473
+ * mandatory ready gate (§3.3). Required and non-blank: without it every
474
+ * ticket from the source would be eligible in any state, including completed
475
+ * ones (the AUT-10 mis-gating). Applied by readyCriteria on top of any label
476
+ * gate. Linear matches exactly and case-sensitively; the file source
477
+ * canonicalizes it to a state directory (`ready` → `ready/`).
478
+ */
479
+ readyState: z
480
+ .string({
481
+ error:
482
+ '[tickets].readyState is required — name the one workflow state a ticket must sit in to be dispatched (e.g. "ready"). Omitting it would make every ticket from the source eligible, including completed ones.',
483
+ })
484
+ .refine(
485
+ (s) => s.trim().length > 0,
486
+ '[tickets].readyState must not be blank — name the one workflow state a ticket must sit in to be dispatched (e.g. "ready").',
487
+ ),
488
+ /** Backend team key (e.g. "ENG") — required for linear and hosted. */
489
+ teamKey: z.string().min(1).optional(),
490
+ /** Workflow state claim() moves an issue to (§12); Linear and hosted. */
491
+ claimedState: z.string().min(1).optional(),
492
+ /** State create() files new tickets into. Absent = the provider's default
493
+ * (Linear: the team's default state, e.g. Backlog; file: Triage). */
494
+ createState: z.string().min(1).optional(),
495
+ /** State the dispatcher hands tickets back to for human triage — spec-gate
496
+ * bounces (§6.3), aborted builds, closed-unmerged PRs. Absent = the
497
+ * provider's default (Linear: Backlog; file: Triage) — resolved by
498
+ * defaultTriageState in src/processes/dispatcher.ts. */
499
+ triageState: z.string().min(1).optional(),
500
+ /** State harvest files its synthesized proposals into (§12). Absent = the
501
+ * triage state, which keeps the constitutional grooming gate: a proposal
502
+ * waits for a human before it can be dispatched. Setting it to readyState
503
+ * waives that gate for this repository — proposals dispatch unread — and is
504
+ * the only supported way to do so. Separate from triageState so the waiver
505
+ * does not also redirect spec-gate bounces, aborts, and closed-unmerged PRs
506
+ * into the ready state, where a bounce would re-dispatch itself. Resolved by
507
+ * defaultProposalState in src/processes/dispatcher.ts. */
508
+ proposalState: z.string().min(1).optional(),
509
+ /** Directory of state dirs (`triage/ ready/ doing/ done/`) holding `<id>.md`
510
+ * ticket files; optional — defaults to `.autobuild/tickets`, resolved
511
+ * relative to the repo. Kept schema-optional (not `.default()`) so the
512
+ * linear-only cross-validation below stays meaningful and the factory can
513
+ * still tell a defaulted dir from an explicit one. */
514
+ dir: z.string().min(1).optional(),
515
+ })
516
+ export type TicketsConfig = z.infer<typeof ticketsSchema>
517
+
518
+ // ── [orchestrator] ──────────────────────────────────────────────────────────
519
+ //
520
+ // The operator-agent gate (AUT-340). Absent table ≡ disabled: with `enabled =
521
+ // false` (the default) the sandbox registry tools are absent from every
522
+ // binding and no environment is ever provisioned.
523
+
524
+ export const orchestratorSandboxSchema = z.strictObject({
525
+ /** Idle minutes without a sandbox tool call before the dispatcher tick's
526
+ * janitor stops the environment, keeping its snapshot for the next resume. */
527
+ idleMinutes: z.number().int().positive().default(30),
528
+ /** Names of non-secret host environment variables forwarded into the
529
+ * sandbox (during provisioning/setup and tool exec/start alike). These are
530
+ * the ONLY non-toolchain environment the sandbox ever sees; forwarding any
531
+ * store, forge, ticket-provider, model, or Vercel credential name is a
532
+ * config error. Unset means none. */
533
+ environmentVariables: z.array(z.string().min(1)).default([]),
534
+ })
535
+ export type OrchestratorSandboxConfig = z.infer<typeof orchestratorSandboxSchema>
536
+
537
+ export const orchestratorSchema = z.strictObject({
538
+ enabled: z.boolean().default(false),
539
+ sandbox: orchestratorSandboxSchema.prefault({}),
540
+ })
541
+ export type OrchestratorConfig = z.infer<typeof orchestratorSchema>
542
+
543
+ // ── Whole file ───────────────────────────────────────────────────────────────
544
+
545
+ const configRootSchema = z.strictObject({
546
+ /** Base branch builds cut from and target (§16.1). */
547
+ baseBranch: z.string().min(1).default('main'),
548
+ /** Concurrent builds for this repository (§16.1). */
549
+ capacity: z.number().int().positive().default(1),
550
+ /** Forge adapter selected from builtins and configured plugins. */
551
+ forge: z
552
+ .string()
553
+ .refine((value) => value.trim().length > 0, 'forge adapter name must be nonblank')
554
+ .default('github'),
555
+ /** Trusted in-process plugin modules, loaded in declaration order from the
556
+ * consuming repository before production adapters are constructed. Exact
557
+ * specifier strings must be unique (cross-validated below). */
558
+ plugins: z
559
+ .array(
560
+ z
561
+ .string()
562
+ .refine((value) => value.trim().length > 0, 'plugin module specifiers must be nonblank'),
563
+ )
564
+ .default([]),
565
+ pr: prSchema.optional(),
566
+ workspace: workspaceSchema.prefault({}),
567
+ commands: commandsSchema,
568
+ verify: verifySectionSchema,
569
+ finalize: finalizeSectionSchema,
570
+ // `default` is reserved inside this open map (§9). The schema preserves the
571
+ // raw map — EVERY declared key, `__proto__` included, or an entry would
572
+ // disappear ahead of both eager resolution and the unconsumed-key warning;
573
+ // the registry-aware eager resolver requires default.runtime and can
574
+ // therefore include the complete materialized runtime list in its diagnostic.
575
+ roles: openMap('[roles]', roleSchema),
576
+ policy: policySchema.prefault({}),
577
+ orchestrator: orchestratorSchema.prefault({}),
578
+ // An absent [tickets] table must NOT silently default past the mandatory
579
+ // ready gate. Prefault feeds the file-source identity through ticketsSchema,
580
+ // which deliberately fails on missing `readyState` at `tickets.readyState`.
581
+ // The cast only satisfies prefault's input type; `{ source: 'file' }` is
582
+ // intentionally invalid until the repository names its ready state.
583
+ tickets: ticketsSchema.prefault({
584
+ source: 'file',
585
+ } as z.input<typeof ticketsSchema>),
586
+ })
587
+
588
+ /** Root metadata keeps strict error prose and documentation coverage honest. */
589
+ export const TOP_LEVEL_SCALARS = ['baseBranch', 'capacity', 'forge', 'plugins'] as const
590
+ export const TOP_LEVEL_TABLES = Object.keys(configRootSchema.shape).filter(
591
+ (key) => !(TOP_LEVEL_SCALARS as readonly string[]).includes(key),
592
+ )
593
+ export const TOP_LEVEL_KEYS = Object.keys(configRootSchema.shape)
594
+
595
+ /**
596
+ * Cross-validation (§16.1): errors carry the path and what would be accepted,
597
+ * because validation failures are feedback to whoever edits the file (D6
598
+ * discipline applied to config).
599
+ */
600
+ export const configSchema = configRootSchema.superRefine((config, ctx) => {
601
+ const firstPluginIndex = new Map<string, number>()
602
+ config.plugins.forEach((specifier, index) => {
603
+ const firstIndex = firstPluginIndex.get(specifier)
604
+ if (firstIndex === undefined) {
605
+ firstPluginIndex.set(specifier, index)
606
+ return
607
+ }
608
+ ctx.addIssue({
609
+ code: 'custom',
610
+ path: ['plugins', index],
611
+ message:
612
+ `duplicate plugin module specifier ${JSON.stringify(specifier)} — first declared at ` +
613
+ `plugins[${firstIndex}]; remove or deduplicate this entry`,
614
+ })
615
+ })
616
+
617
+ if (
618
+ config.workspace.provider === 'git-worktree' &&
619
+ Object.keys(config.workspace.config).length > 0
620
+ ) {
621
+ ctx.addIssue({
622
+ code: 'custom',
623
+ path: ['workspace', 'config'],
624
+ message:
625
+ '[workspace.config] is not supported by the builtin "git-worktree" provider — remove it or select a plugin workspace provider',
626
+ })
627
+ } else if (config.workspace.provider === 'vercel-sandbox') {
628
+ const parsed = vercelSandboxConfigSchema.safeParse(config.workspace.config)
629
+ if (!parsed.success) {
630
+ forwardIssues(parsed.error.issues, ctx, ['workspace', 'config'])
631
+ } else if (
632
+ config.roles !== undefined &&
633
+ config.verify !== undefined &&
634
+ config.finalize !== undefined
635
+ ) {
636
+ for (const group of effectiveRuntimeReferences(config)) {
637
+ if (Object.hasOwn(parsed.data.runtimeProvisioning, group.runtime)) continue
638
+ ctx.addIssue({
639
+ code: 'custom',
640
+ path: ['workspace', 'config', 'runtimeProvisioning', group.runtime],
641
+ message:
642
+ `runtime ${displayName(group.runtime)} is selected by ${group.references.join(', ')} but has no sandbox provisioning; add ` +
643
+ `[workspace.config.runtimeProvisioning.${tomlKey(group.runtime)}] with nonblank install and preflight commands`,
644
+ })
645
+ }
646
+ }
647
+ }
648
+
649
+ const commandNames = Object.keys(config.commands)
650
+ const knownCommands =
651
+ commandNames.length > 0
652
+ ? `known commands: ${commandNames.join(', ')}`
653
+ : '[commands] has no entries'
654
+
655
+ // A failed transformed subsection can be absent from Zod's partial object
656
+ // while sibling issues are collected. Guard the cross-check so malformed
657
+ // tables report validation feedback instead of throwing inside validation.
658
+ if (config.verify?.stepConfigs !== undefined) {
659
+ config.verify.steps.forEach((step, index) => {
660
+ if (!Object.hasOwn(config.verify.stepConfigs, step)) {
661
+ ctx.addIssue({
662
+ code: 'custom',
663
+ path: ['verify', 'steps', index],
664
+ message:
665
+ `verify step "${step}" is listed in verify.steps but has no [verify.${step}] table — ` +
666
+ `add one with kind = "check" (command = <name in [commands]>) or kind = "agent" (skill = <skill name>)`,
667
+ })
668
+ }
669
+ })
670
+
671
+ const listed = new Set(config.verify.steps)
672
+ for (const [step, stepConfig] of Object.entries(config.verify.stepConfigs)) {
673
+ if (!listed.has(step)) {
674
+ ctx.addIssue({
675
+ code: 'custom',
676
+ path: ['verify', step],
677
+ message:
678
+ `[verify.${step}] is defined but "${step}" is not listed in verify.steps — ` +
679
+ `add "${step}" to verify.steps or remove the table`,
680
+ })
681
+ }
682
+ if (stepConfig.kind === 'check' && !Object.hasOwn(config.commands, stepConfig.command)) {
683
+ ctx.addIssue({
684
+ code: 'custom',
685
+ path: ['verify', step, 'command'],
686
+ message: `[verify.${step}].command = "${stepConfig.command}" does not name a key in [commands] — ${knownCommands}`,
687
+ })
688
+ }
689
+ }
690
+ }
691
+
692
+ if (config.finalize?.stepConfigs !== undefined) {
693
+ config.finalize.steps.forEach((step, index) => {
694
+ if (!Object.hasOwn(config.finalize.stepConfigs, step)) {
695
+ ctx.addIssue({
696
+ code: 'custom',
697
+ path: ['finalize', 'steps', index],
698
+ message:
699
+ `finalize step "${step}" is listed in finalize.steps but has no [finalize.${step}] table — ` +
700
+ `add one with kind = "check" (command = <name in [commands]>) or kind = "agent" (skill = <skill name>)`,
701
+ })
702
+ }
703
+ })
704
+
705
+ const listedFinalize = new Set(config.finalize.steps)
706
+ for (const [step, stepConfig] of Object.entries(config.finalize.stepConfigs)) {
707
+ if (!listedFinalize.has(step)) {
708
+ ctx.addIssue({
709
+ code: 'custom',
710
+ path: ['finalize', step],
711
+ message:
712
+ `[finalize.${step}] is defined but "${step}" is not listed in finalize.steps — ` +
713
+ `add "${step}" to finalize.steps or remove the table`,
714
+ })
715
+ }
716
+ if (stepConfig.kind === 'check' && !Object.hasOwn(config.commands, stepConfig.command)) {
717
+ ctx.addIssue({
718
+ code: 'custom',
719
+ path: ['finalize', step, 'command'],
720
+ message: `[finalize.${step}].command = "${stepConfig.command}" does not name a key in [commands] — ${knownCommands}`,
721
+ })
722
+ }
723
+ }
724
+ }
725
+
726
+ const tickets = config.tickets
727
+ if (tickets.source === 'linear' || tickets.source === 'hosted') {
728
+ if (tickets.teamKey === undefined) {
729
+ ctx.addIssue({
730
+ code: 'custom',
731
+ path: ['tickets', 'teamKey'],
732
+ message: `[tickets].source = "${tickets.source}" requires teamKey${
733
+ tickets.source === 'linear' ? ' — the Linear team key (e.g. "ENG")' : ''
734
+ }`,
735
+ })
736
+ }
737
+ if (tickets.dir !== undefined) {
738
+ ctx.addIssue({
739
+ code: 'custom',
740
+ path: ['tickets', 'dir'],
741
+ message: '[tickets].dir applies only to source = "file" — remove it or set source = "file"',
742
+ })
743
+ }
744
+ } else if (tickets.source === 'file') {
745
+ // dir is optional for the file source: absent = .autobuild/tickets.
746
+ for (const key of ['teamKey', 'claimedState'] as const) {
747
+ if (tickets[key] !== undefined) {
748
+ ctx.addIssue({
749
+ code: 'custom',
750
+ path: ['tickets', key],
751
+ message: `[tickets].${key} applies only to source = "linear" or "hosted" — remove it or select one of those sources`,
752
+ })
753
+ }
754
+ }
755
+ }
756
+ // Plugin sources receive the existing ticket lifecycle/configuration fields
757
+ // unchanged; adapter-specific validation belongs to their factory.
758
+
759
+ // The credential-free sandbox rule (AUT-340): a forwarded variable name may
760
+ // never name a store, forge, ticket-provider, model, or Vercel credential.
761
+ if (config.orchestrator.enabled) {
762
+ config.orchestrator.sandbox.environmentVariables.forEach((name, index) => {
763
+ if (SANDBOX_FORBIDDEN_ENV.includes(name)) {
764
+ ctx.addIssue({
765
+ code: 'custom',
766
+ path: ['orchestrator', 'sandbox', 'environmentVariables', index],
767
+ message: `environment variable ${JSON.stringify(name)} is a store, forge, ticket-provider, model, or Vercel credential and may never be forwarded into an operator sandbox`,
768
+ })
769
+ }
770
+ })
771
+ }
772
+ })
773
+
774
+ export type Config = z.infer<typeof configSchema>