@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,438 @@
1
+ /**
2
+ * Role-key consumability diagnostics (SPEC §9).
3
+ *
4
+ * `[roles]` is an open map, so a `[roles.<name>]` entry whose name nothing
5
+ * consumes is accepted, eagerly resolved, validated — and then never looked up.
6
+ * The session that was meant to run on it runs on `[roles.default]` instead.
7
+ * This module is the pure derivation that lets `ab dispatch` say so at startup.
8
+ * It never throws and never changes which runtime, model, or args run.
9
+ *
10
+ * TWO MECHANISMS, DELIBERATELY KEPT APART. No notice, comment, or document may
11
+ * say a declared key "inherits default":
12
+ *
13
+ * - *Declaration.* `createRuntimeResolver` eagerly resolves EVERY declared role
14
+ * by merging its own fields over `default` independently per field
15
+ * (`src/ports/runner/routing.ts`). `[roles.ghost]` with its own runtime and
16
+ * model resolves to exactly that pair, and is validated — an unconsumed role
17
+ * naming an unregistered runtime still fails `ab dispatch` loudly. The
18
+ * warnings here neither suppress nor compete with that error.
19
+ * - *Lookup.* `resolve(role, ...aliases)` returns the validated
20
+ * `[roles.default]` result when NO candidate key is declared. That wholesale
21
+ * fallback is what silently runs a verify step on the default model.
22
+ *
23
+ * The harm named here is the pair of the two: a key declared on one side and
24
+ * never requested on the other.
25
+ */
26
+ import { CORE_PHASES } from '../ontology'
27
+ import type { Config } from './schema'
28
+
29
+ // ── Vocabulary ───────────────────────────────────────────────────────────────
30
+
31
+ /** `ab dispatch`'s pre-build title completion (src/cli/dispatch.ts). */
32
+ export const SLUG_ROLE = 'slug'
33
+ /** `ab upgrade`'s skill-conflict resolver (src/cli/upgrade-agent.ts). */
34
+ export const UPGRADE_ROLE = 'upgrade'
35
+ /** The harvest run's synthesize session (src/processes/harvest-runner.ts). */
36
+ export const HARVEST_ROLE = 'harvest'
37
+ /** The harvest run's review session (src/processes/harvest-runner.ts). */
38
+ export const HARVEST_REVIEW_ROLE = 'harvest-review'
39
+
40
+ /**
41
+ * Roles the pipeline requests outside the configured phases and steps. These
42
+ * are shared constants rather than bare string literals at their call sites so
43
+ * a consumer and this diagnostic cannot drift apart — a consumable key missing
44
+ * from here would become a false "unconsumed" warning, which is the same class
45
+ * of bug this module exists to report.
46
+ */
47
+ export const INTERNAL_ROLES = [SLUG_ROLE, UPGRADE_ROLE, HARVEST_ROLE, HARVEST_REVIEW_ROLE] as const
48
+
49
+ /** The reserved `[roles]` inheritance base (§9). Never reported unconsumed. */
50
+ export const RESERVED_ROLE = 'default'
51
+
52
+ // ── Derivation ───────────────────────────────────────────────────────────────
53
+
54
+ /** Every consumer a declared key has, of every kind. */
55
+ export interface RoleKeyConsumers {
56
+ key: string
57
+ /** The human phrase for this key's own canonical use, when it has one. */
58
+ canonicalUse?: string
59
+ /** Agent verify steps this key currently ROUTES through the deprecated
60
+ * skill-name convention — the step declares no `[roles.<step>]` of its own. */
61
+ activeAlias: string[]
62
+ /** Agent verify steps whose configured skill is this key but which declare
63
+ * their own step-named role, so the key is inert for them. */
64
+ supersededAlias: string[]
65
+ }
66
+
67
+ export interface RoleKeyDiagnostics {
68
+ /** The sorted canonical keys this configuration can consume. Deliberately
69
+ * excludes deprecated aliases: the message must not teach the dying form. */
70
+ valid: string[]
71
+ /** Declared keys with no consumer of any kind, sorted. */
72
+ unconsumed: string[]
73
+ /** Declared keys a deprecated skill-name alias reaches, sorted by key —
74
+ * both the still-routing and the superseded-and-inert cases. */
75
+ deprecated: RoleKeyConsumers[]
76
+ }
77
+
78
+ /** The structural shape both `[verify.<step>]` and `[finalize.<step>]` share. */
79
+ type StepTable = { kind: 'check' } | { kind: 'agent'; skill: string }
80
+
81
+ export interface RuntimeReferenceGroup {
82
+ runtime: string
83
+ /** Human-readable routes selecting this runtime, including alternates. */
84
+ references: string[]
85
+ /** Explicit configured models selected for readiness probing. */
86
+ models: string[]
87
+ /** At least one selected route leaves its model to the runtime registration. */
88
+ usesRuntimeDefaultModel: boolean
89
+ }
90
+
91
+ /**
92
+ * Derive the runtime targets that can actually start sessions. This mirrors the
93
+ * resolver's per-axis inheritance, role/alias precedence, and alternate overlay
94
+ * without requiring a runtime registry, so config validation and remote
95
+ * bootstrap share one source of truth.
96
+ */
97
+ export function effectiveRuntimeReferences(
98
+ config: Pick<Config, 'roles' | 'verify' | 'finalize'>,
99
+ ): RuntimeReferenceGroup[] {
100
+ const defaultSpec = config.roles.default ?? {}
101
+ const routes: Array<{ role: string; aliases?: string[]; label: string }> = [
102
+ ...CORE_PHASES.map((role) => ({ role, label: `role ${displayName(role)}` })),
103
+ ...INTERNAL_ROLES.map((role) => ({ role, label: `role ${displayName(role)}` })),
104
+ ...agentSteps(config.verify.steps, config.verify.stepConfigs).map(({ step, skill }) => ({
105
+ role: step,
106
+ aliases: [skill],
107
+ label: `agent verify role ${displayName(step)}`,
108
+ })),
109
+ ...agentSteps(config.finalize.steps, config.finalize.stepConfigs).map(({ step }) => ({
110
+ role: step,
111
+ label: `agent finalize role ${displayName(step)}`,
112
+ })),
113
+ ]
114
+
115
+ const grouped = new Map<
116
+ string,
117
+ { references: Set<string>; models: Set<string>; usesRuntimeDefaultModel: boolean }
118
+ >()
119
+ const declaredSpec = (role: string, aliases: readonly string[] = []) => {
120
+ for (const key of [role, ...aliases]) {
121
+ if (key === RESERVED_ROLE) return defaultSpec
122
+ if (Object.hasOwn(config.roles, key)) return config.roles[key]!
123
+ }
124
+ return defaultSpec
125
+ }
126
+ const add = (runtime: string | undefined, model: string | undefined, reference: string) => {
127
+ if (runtime === undefined || runtime.trim() === '') return
128
+ const group = grouped.get(runtime) ?? {
129
+ references: new Set(),
130
+ models: new Set(),
131
+ usesRuntimeDefaultModel: false,
132
+ }
133
+ group.references.add(reference)
134
+ if (model !== undefined) group.models.add(model)
135
+ else group.usesRuntimeDefaultModel = true
136
+ grouped.set(runtime, group)
137
+ }
138
+
139
+ for (const route of routes) {
140
+ const spec = declaredSpec(route.role, route.aliases)
141
+ const runtime = spec.runtime ?? defaultSpec.runtime
142
+ const model = spec.model ?? defaultSpec.model
143
+ add(runtime, model, `${route.label} primary`)
144
+ const alternates = spec.alternates ?? defaultSpec.alternates ?? []
145
+ alternates.forEach((alternate, index) => {
146
+ add(
147
+ alternate.runtime ?? runtime,
148
+ alternate.model ?? model,
149
+ `${route.label} alternate[${index}]`,
150
+ )
151
+ })
152
+ }
153
+
154
+ return [...grouped.entries()]
155
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
156
+ .map(([runtime, group]) => ({
157
+ runtime,
158
+ references: [...group.references].sort(),
159
+ models: [...group.models].sort(),
160
+ usesRuntimeDefaultModel: group.usesRuntimeDefaultModel,
161
+ }))
162
+ }
163
+
164
+ /** Declared steps that actually run, in declaration order, agent kind only —
165
+ * a `kind = "check"` step starts no session and consumes no role. */
166
+ function agentSteps(
167
+ steps: readonly string[],
168
+ stepConfigs: Record<string, StepTable>,
169
+ ): { step: string; skill: string }[] {
170
+ const agents: { step: string; skill: string }[] = []
171
+ const seen = new Set<string>()
172
+ for (const step of steps) {
173
+ if (seen.has(step)) continue
174
+ seen.add(step)
175
+ // OWN property, for the same reason `declared` below is spelled out: step
176
+ // names are user-chosen from an open set, so `constructor` and `toString`
177
+ // are legal, and a bare read would answer an inherited function for a
178
+ // listed-but-tableless step. Cross-validation rejects that config before it
179
+ // can reach here, so this is redundant in practice — it keeps the read
180
+ // sound on its own rather than on a guarantee made in another file.
181
+ if (!Object.hasOwn(stepConfigs, step)) continue
182
+ const table = stepConfigs[step]!
183
+ if (table.kind !== 'agent') continue
184
+ agents.push({ step, skill: table.skill })
185
+ }
186
+ return agents
187
+ }
188
+
189
+ // ── Rendering a user-chosen name ─────────────────────────────────────────────
190
+ //
191
+ // Role names, verify step names, and finalize step names are ARBITRARY nonempty
192
+ // strings. Every one of them reaches an operator's terminal, so every one is
193
+ // rendered through `displayName`/`tomlKey` below and never hand-quoted or
194
+ // interpolated bare. Two failures follow from getting this wrong, and both have
195
+ // happened: `[roles.ui.visual]` is not the key it looks like (TOML reads the dot
196
+ // as nesting), and a name carrying a newline splits ONE notice into two physical
197
+ // stderr lines, breaking the one-notice-per-line contract every surface
198
+ // downstream assumes.
199
+
200
+ /** TOML bare keys are letters, digits, `-`, and `_` only. */
201
+ const TOML_BARE_KEY = /^[A-Za-z0-9_-]+$/
202
+
203
+ /**
204
+ * The body of a TOML basic string, escaped on an ALLOWLIST: a character is
205
+ * emitted raw only when it is printable ASCII a basic string accepts unescaped.
206
+ * Everything else — every C0 control, U+007F, and every non-ASCII code point —
207
+ * becomes TOML's own `\uXXXX`, or `\UXXXXXXXX` above the BMP.
208
+ *
209
+ * The allowlist is the whole design. `JSON.stringify` was the escaper here for
210
+ * three rounds and is a DENYLIST: it escapes C0 controls, then passes U+007F
211
+ * through raw, then passes every other non-ASCII code point through raw. Each
212
+ * gap was found and patched one code point at a time, which is not a process
213
+ * that terminates. Nothing outside the allowlist can reach a terminal now,
214
+ * whatever it is, and there is no per-character rule left to get wrong.
215
+ *
216
+ * Iterating with `for…of` walks code points, so an astral character is escaped
217
+ * once as `\U0001F600` rather than as two lone surrogates — which TOML rejects
218
+ * outright and which therefore would not round-trip.
219
+ */
220
+ function escapeBasic(name: string): string {
221
+ let out = ''
222
+ for (const char of name) {
223
+ const code = char.codePointAt(0)!
224
+ if (char === '"' || char === '\\') out += `\\${char}`
225
+ else if (code >= 0x20 && code <= 0x7e) out += char
226
+ else if (code > 0xffff) out += `\\U${code.toString(16).toUpperCase().padStart(8, '0')}`
227
+ else out += `\\u${code.toString(16).toUpperCase().padStart(4, '0')}`
228
+ }
229
+ return out
230
+ }
231
+
232
+ /** A name IN PROSE — always quoted, always escaped. The one place a name is
233
+ * wrapped in quotes, so a quote cannot break out of the phrase and nothing
234
+ * unprintable can reach a line-oriented sink raw. */
235
+ export function displayName(name: string): string {
236
+ return `"${escapeBasic(name)}"`
237
+ }
238
+
239
+ /**
240
+ * A name AS THE OPERATOR MUST TYPE IT in a table header: bare when TOML's
241
+ * bare-key grammar accepts it — which keeps every ordinary notice
242
+ * byte-identical — and otherwise the quoted prose form, unchanged. The two
243
+ * renderings differ in exactly that one condition and share the escaper, so
244
+ * they cannot drift into disagreeing about what is safe.
245
+ */
246
+ export function tomlKey(name: string): string {
247
+ return TOML_BARE_KEY.test(name) ? name : displayName(name)
248
+ }
249
+
250
+ /** The table header to write, quoted when the name needs it. */
251
+ const roleTable = (name: string): string => `[roles.${tomlKey(name)}]`
252
+
253
+ /**
254
+ * Classify every declared `[roles.<key>]` against every consumer it has.
255
+ * Iterates `steps` rather than the `stepConfigs` key sets so only steps that
256
+ * actually run contribute; cross-validation already guarantees the two agree.
257
+ */
258
+ export function roleKeyDiagnostics(config: Config): RoleKeyDiagnostics {
259
+ const verifyAgents = agentSteps(config.verify.steps, config.verify.stepConfigs)
260
+ const finalizeAgents = agentSteps(config.finalize.steps, config.finalize.stepConfigs)
261
+
262
+ // `default` is tested FIRST so the reserved phrase always wins, which is also
263
+ // what keeps the rename/delete advice structurally unreachable for it.
264
+ //
265
+ // Every arm renders through `displayName`, including the two whose names come
266
+ // from fixed lists. Those two are safe on their own, but a phrase built here
267
+ // is appended verbatim to a notice by the shared/colliding branch, and one
268
+ // uniform rule is what stops the next arm added to this list from being the
269
+ // hand-quoted one.
270
+ const canonicalUse = (key: string): string | undefined => {
271
+ if (key === RESERVED_ROLE) return `the reserved ${roleTable(RESERVED_ROLE)} inheritance base`
272
+ if ((CORE_PHASES as readonly string[]).includes(key)) {
273
+ return `the ${displayName(key)} core phase role`
274
+ }
275
+ if (verifyAgents.some((agent) => agent.step === key)) {
276
+ return `agent verify step ${displayName(key)}`
277
+ }
278
+ if (finalizeAgents.some((agent) => agent.step === key)) {
279
+ return `agent finalize step ${displayName(key)}`
280
+ }
281
+ if ((INTERNAL_ROLES as readonly string[]).includes(key)) {
282
+ return `the internal ${displayName(key)} role`
283
+ }
284
+ return undefined
285
+ }
286
+
287
+ // MUST mirror the resolver's candidate walk (src/ports/runner/routing.ts),
288
+ // reserved arm included: a step named `default` always has its step-named
289
+ // role declared, so its skill-name alias really is inert. The `||` arm is
290
+ // redundant in practice — construction fails without `[roles.default]` — but
291
+ // it keeps the two implementations of "declared" from drifting apart.
292
+ const declared = (step: string): boolean =>
293
+ step === RESERVED_ROLE || Object.hasOwn(config.roles, step)
294
+
295
+ const valid = [
296
+ ...new Set([
297
+ RESERVED_ROLE,
298
+ ...CORE_PHASES,
299
+ ...verifyAgents.map((agent) => agent.step),
300
+ ...finalizeAgents.map((agent) => agent.step),
301
+ ...INTERNAL_ROLES,
302
+ ]),
303
+ ].sort()
304
+
305
+ const unconsumed: string[] = []
306
+ const deprecated: RoleKeyConsumers[] = []
307
+
308
+ for (const key of Object.keys(config.roles).sort()) {
309
+ const use = canonicalUse(key)
310
+ // A step whose own name IS the key has no alias at all: the key is
311
+ // canonical for it, and step-name precedence means it routes that way.
312
+ const aliasFor = verifyAgents.filter((agent) => agent.skill === key && agent.step !== key)
313
+ const activeAlias = aliasFor.filter((a) => !declared(a.step)).map((a) => a.step)
314
+ const supersededAlias = aliasFor.filter((a) => declared(a.step)).map((a) => a.step)
315
+
316
+ if (activeAlias.length > 0 || (supersededAlias.length > 0 && use === undefined)) {
317
+ deprecated.push({
318
+ key,
319
+ ...(use !== undefined ? { canonicalUse: use } : {}),
320
+ activeAlias,
321
+ supersededAlias,
322
+ })
323
+ continue
324
+ }
325
+ // Fully consumed. An inert alias on an already-canonical key needs no
326
+ // advice, and `canonicalUse(RESERVED_ROLE)` is what exempts the reserved
327
+ // key from the unconsumed bucket — structurally, not by an early skip.
328
+ if (use !== undefined) continue
329
+ unconsumed.push(key)
330
+ }
331
+
332
+ return { valid, unconsumed, deprecated }
333
+ }
334
+
335
+ // ── Notices ──────────────────────────────────────────────────────────────────
336
+ //
337
+ // One string per key, so advice is never blanket, and every branch's advice is
338
+ // safe to follow literally — which means BOTH that following it converges (one
339
+ // edit, then silence) and that what it tells you to type is valid TOML. Two
340
+ // shape constraints: the key and its replacement land in the FIRST CLAUSE, so
341
+ // they are on the first rendered row at any realistic width; and the valid-key
342
+ // list is its own string, so it starts at column 0 of its own row instead of
343
+ // trailing a paragraph. House style is subject-first, `subject: predicate —
344
+ // detail`, with no `warning:` prefix.
345
+
346
+ function andList(parts: readonly string[]): string {
347
+ if (parts.length <= 1) return parts[0] ?? ''
348
+ if (parts.length === 2) return `${parts[0]} and ${parts[1]}`
349
+ return `${parts.slice(0, -1).join(', ')}, and ${parts[parts.length - 1]}`
350
+ }
351
+
352
+ /** Step names in prose — each through `displayName`, never hand-quoted. */
353
+ const named = (steps: readonly string[]): string => andList(steps.map(displayName))
354
+
355
+ const roleTables = (names: readonly string[]): string => andList(names.map(roleTable))
356
+
357
+ function deprecationNotice(entry: RoleKeyConsumers): string {
358
+ const { key, canonicalUse, activeAlias, supersededAlias } = entry
359
+
360
+ // A SUPERSEDED alias is not a consumer. It is inert by definition — the step
361
+ // declares its own step-named role, which wins — so it can never be a reason
362
+ // to keep this key, and it never blocks the rename. Counting it as one made
363
+ // the advice non-converging: "keep it, it is also the superseded key for
364
+ // e2e", followed on the next dispatch by "it can be deleted".
365
+
366
+ // Inert: the key routes nothing at all.
367
+ if (activeAlias.length === 0) {
368
+ const subject =
369
+ supersededAlias.length === 1
370
+ ? `agent verify step ${named(supersededAlias)} declares its own step-named role`
371
+ : `agent verify steps ${named(supersededAlias)} each declare their own step-named role`
372
+ return (
373
+ `autobuild.toml: ${roleTable(key)} can be deleted — ${subject}, so this ` +
374
+ `deprecated skill-name key changes nothing.`
375
+ )
376
+ }
377
+
378
+ // Safe rename: one step still routed and no OTHER consumer, so the key moves
379
+ // in one edit. Superseded steps are deliberately not "other consumers".
380
+ if (canonicalUse === undefined && activeAlias.length === 1) {
381
+ return (
382
+ `autobuild.toml: ${roleTable(key)} should be ${roleTable(activeAlias[0]!)} — it is the ` +
383
+ `deprecated skill-name key for agent verify step ${named(activeAlias)} and stops ` +
384
+ `working in a future release.`
385
+ )
386
+ }
387
+
388
+ // Shared or colliding: the key routes several steps, or its name is itself
389
+ // canonical, so the advice is to declare each step and then keep or delete.
390
+ const routed =
391
+ activeAlias.length === 1
392
+ ? 'it routes that agent verify step through the deprecated skill-name key'
393
+ : 'it routes those agent verify steps through the deprecated skill-name key'
394
+ const advice =
395
+ canonicalUse !== undefined
396
+ ? `Keep ${roleTable(key)}: it is also ${canonicalUse}.`
397
+ : `Delete ${roleTable(key)} once those are declared.`
398
+ return `autobuild.toml: ${roleTable(key)} should be ${roleTables(activeAlias)} — ${routed}. ${advice}`
399
+ }
400
+
401
+ /**
402
+ * The rendered notices — THE one message set. Empty for a clean configuration.
403
+ *
404
+ * Order: the unconsumed-keys line, then the valid-key line (only when there are
405
+ * unconsumed keys), then one notice per deprecated key in key order. Every
406
+ * surface renders these exact strings in full: nothing here or downstream may
407
+ * cap, summarize, or truncate them, or a surface would report that a class of
408
+ * problem exists without naming the keys the operator has to change.
409
+ *
410
+ * The unconsumed line deliberately stops at the harm and does not explain what
411
+ * runs instead — that belongs in SPEC §9 and `docs/configuration.md`, and
412
+ * putting it here pushed the required valid-key list past the right edge.
413
+ */
414
+ export function roleKeyWarnings(config: Config): string[] {
415
+ const { valid, unconsumed, deprecated } = roleKeyDiagnostics(config)
416
+ const lines: string[] = []
417
+ if (unconsumed.length > 0) {
418
+ const keys = unconsumed.map(roleTable).join(', ')
419
+ lines.push(
420
+ unconsumed.length === 1
421
+ ? `autobuild.toml: ${keys} is declared but nothing requests it — its runtime, model, and args never reach a session.`
422
+ : `autobuild.toml: ${keys} are declared but nothing requests them — their runtime, model, and args never reach a session.`,
423
+ )
424
+ // Rendered as keys, not as prose names: this list is what the operator
425
+ // types next, so a name needing quotes must show them here too.
426
+ lines.push(`Valid role keys: ${valid.map(tomlKey).join(', ')}`)
427
+ }
428
+ for (const entry of deprecated) lines.push(deprecationNotice(entry))
429
+ for (const [role, spec] of Object.entries(config.roles)) {
430
+ const declared =
431
+ spec.extensions !== undefined || spec.alternates?.some((alt) => alt.extensions !== undefined)
432
+ if (!declared) continue
433
+ lines.push(
434
+ `autobuild.toml: ${roleTable(role)} declares deprecated extensions — it has no effect; use args for explicit runtime CLI arguments.`,
435
+ )
436
+ }
437
+ return lines
438
+ }