@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,756 @@
1
+ /**
2
+ * `ab init` — vendor the canonical default skills into a repo (SPEC §16.3,
3
+ * D11). Copies, not references: per-repo customization is the point, so each
4
+ * complete skill tree lands in the Agent Skills standard
5
+ * `.agents/skills/ab-<name>/`, where the repo may edit every file freely. Pi
6
+ * discovers that project directory
7
+ * directly; Claude discovers the same skill through a
8
+ * `.claude/skills/ab-<name>` symlink, so there is only one editable copy.
9
+ * Alongside the live copy, init records the PRISTINE installed bytes under
10
+ * `.agents/skills/.ab-pristine/` — repo-versioned, the base of `ab upgrade`'s
11
+ * three-way merges (src/cli/upgrade.ts).
12
+ *
13
+ * Init runs OUTSIDE build sessions: it takes a repo path, not a build, and
14
+ * needs no AB_* environment. It is safe to re-run — an existing
15
+ * autobuild.toml is never overwritten, and an installed skill with local
16
+ * edits is never clobbered (`force: true` is the explicit human override).
17
+ */
18
+ import type { Dirent, Stats } from 'node:fs'
19
+ import {
20
+ cp,
21
+ lstat,
22
+ mkdir,
23
+ readdir,
24
+ readFile,
25
+ readlink,
26
+ rename,
27
+ rm,
28
+ rmdir,
29
+ stat,
30
+ symlink,
31
+ unlink,
32
+ writeFile,
33
+ } from 'node:fs/promises'
34
+ import { dirname, join, relative, resolve } from 'node:path'
35
+ import { distributionRoot } from '../distribution'
36
+ import { installedSkillName, SKILL_NAMESPACE } from '../skills'
37
+ import { createProductionRuntimes } from '../ports/runner/production'
38
+ import type { RuntimeRegistry } from '../ports/runner/runtime'
39
+ import {
40
+ launchSetupAgent,
41
+ probeInitRuntimes,
42
+ selectSetupRuntime,
43
+ SETUP_RUNTIME_PREFERENCE,
44
+ type SetupAgentLauncher,
45
+ } from './init-agent'
46
+
47
+ export { SKILL_NAMESPACE }
48
+
49
+ /** Agent Skills standard project directory for vendored skills. */
50
+ export const AGENTS_SKILLS_DIR = join('.agents', 'skills')
51
+
52
+ /** The unsupported project path used by earlier autobuild releases. */
53
+ export const LEGACY_AGENT_SKILLS_DIR = join('.agent', 'skills')
54
+
55
+ /** Claude-compatible discovery links point at the canonical skills. */
56
+ export const CLAUDE_SKILLS_DIR = join('.claude', 'skills')
57
+
58
+ /** Where init records pristine installs, under `.agents/skills/`. */
59
+ export const PRISTINE_DIR = '.ab-pristine'
60
+
61
+ /** Repository-local state is always excluded by a fresh/repeated init. */
62
+ export const LOCAL_STATE_IGNORE_RULE = '.autobuild/'
63
+
64
+ /**
65
+ * The autobuild distribution root, resolved relative to THIS module file
66
+ * so `ab init` works from any cwd. Its `skills/` and `templates/` directories
67
+ * are the canonical source.
68
+ */
69
+ export function defaultDistRoot(): string {
70
+ return distributionRoot()
71
+ }
72
+
73
+ function assertSkillRelativePath(file: string): void {
74
+ if (
75
+ file === '' ||
76
+ file.startsWith('/') ||
77
+ file.startsWith('\\') ||
78
+ file.split(/[\\/]/).some((segment) => segment === '' || segment === '.' || segment === '..')
79
+ ) {
80
+ throw new Error(`invalid skill-relative file path "${file}"`)
81
+ }
82
+ }
83
+
84
+ /** Live path for one file in an installed skill directory. */
85
+ export function installedSkillFilePath(
86
+ targetRepo: string,
87
+ installName: string,
88
+ file: string,
89
+ ): string {
90
+ assertSkillRelativePath(file)
91
+ return join(targetRepo, AGENTS_SKILLS_DIR, installName, ...file.split('/'))
92
+ }
93
+
94
+ /** Live install path: `<target>/.agents/skills/ab-<name>/SKILL.md`. */
95
+ export function installedSkillPath(targetRepo: string, installName: string): string {
96
+ return installedSkillFilePath(targetRepo, installName, 'SKILL.md')
97
+ }
98
+
99
+ /** Claude discovery path: a directory symlink to the live `.agents` skill. */
100
+ export function claudeSkillPath(targetRepo: string, installName: string): string {
101
+ return join(targetRepo, CLAUDE_SKILLS_DIR, installName)
102
+ }
103
+
104
+ /** Pristine path for one distributed file in a skill directory. */
105
+ export function pristineSkillFilePath(
106
+ targetRepo: string,
107
+ installName: string,
108
+ file: string,
109
+ ): string {
110
+ assertSkillRelativePath(file)
111
+ return join(targetRepo, AGENTS_SKILLS_DIR, PRISTINE_DIR, installName, ...file.split('/'))
112
+ }
113
+
114
+ /** Pristine record: `<target>/.agents/skills/.ab-pristine/ab-<name>/SKILL.md`. */
115
+ export function pristineSkillPath(targetRepo: string, installName: string): string {
116
+ return pristineSkillFilePath(targetRepo, installName, 'SKILL.md')
117
+ }
118
+
119
+ function legacyInstalledSkillPath(targetRepo: string, installName: string): string {
120
+ return join(targetRepo, CLAUDE_SKILLS_DIR, installName, 'SKILL.md')
121
+ }
122
+
123
+ function legacyPristineSkillPath(targetRepo: string, installName: string): string {
124
+ return join(targetRepo, CLAUDE_SKILLS_DIR, PRISTINE_DIR, installName, 'SKILL.md')
125
+ }
126
+
127
+ /** Read a file's text, or undefined when it does not exist. */
128
+ export async function readIfExists(path: string): Promise<string | undefined> {
129
+ try {
130
+ return await readFile(path, 'utf8')
131
+ } catch (error) {
132
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
133
+ throw error
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Ensure the exact repository-local state rule exists without rewriting any
139
+ * existing ignore bytes. Re-running is duplicate-free, including when the
140
+ * original file had no trailing newline.
141
+ */
142
+ export async function ensureLocalStateIgnored(targetRepo: string): Promise<boolean> {
143
+ const path = join(targetRepo, '.gitignore')
144
+ const current = await readIfExists(path)
145
+ if (current !== undefined) {
146
+ const rules = current.split(/\r?\n/)
147
+ if (rules.includes(LOCAL_STATE_IGNORE_RULE)) return false
148
+ const separator = current === '' || current.endsWith('\n') ? '' : '\n'
149
+ await writeFile(path, `${current}${separator}${LOCAL_STATE_IGNORE_RULE}\n`)
150
+ return true
151
+ }
152
+ await mkdir(targetRepo, { recursive: true })
153
+ await writeFile(path, `${LOCAL_STATE_IGNORE_RULE}\n`)
154
+ return true
155
+ }
156
+
157
+ /**
158
+ * The skills a model may trigger from its description (§16.3). Everything else
159
+ * gets `disable-model-invocation: true`: phase skills are invoked explicitly by
160
+ * the runner or a human, and a model must never start a pipeline phase by
161
+ * pattern-matching a description. Membership here is reserved for skills that
162
+ * drive NO phase — `spec` is the conversation that writes a spec before a build
163
+ * exists, `tickets` is how "move ticket X to ready" becomes an action, and
164
+ * `guide` is read-only reference material about the system. For these, model
165
+ * invocation is precisely the point. Keep this set small; widening it needs the
166
+ * §16.3 criterion, not convenience.
167
+ */
168
+ export const MODEL_INVOCABLE_SKILLS = new Set(['spec', 'tickets', 'guide'])
169
+
170
+ /**
171
+ * Rewrite a canonical skill's YAML frontmatter for installation (§16.3):
172
+ * `name` becomes the namespaced `ab-<name>`, and every skill outside
173
+ * `MODEL_INVOCABLE_SKILLS` gets `disable-model-invocation: true`. The
174
+ * description and the body below the frontmatter are preserved verbatim.
175
+ * Deliberately minimal and line-based: two known keys do not justify a YAML
176
+ * dependency.
177
+ */
178
+ export function rewriteSkillSource(source: string, skillName: string): string {
179
+ const lines = source.split('\n')
180
+ if (lines[0] !== '---') {
181
+ throw new Error(
182
+ `skill "${skillName}" has no YAML frontmatter — expected the file to open with '---'`,
183
+ )
184
+ }
185
+ const close = lines.indexOf('---', 1)
186
+ if (close === -1) {
187
+ throw new Error(
188
+ `skill "${skillName}" has unterminated YAML frontmatter — no closing '---' found`,
189
+ )
190
+ }
191
+ const front = lines
192
+ .slice(1, close)
193
+ .filter((line) => !line.startsWith('disable-model-invocation:'))
194
+ .map((line) => (line.startsWith('name:') ? `name: ${installedSkillName(skillName)}` : line))
195
+ if (!MODEL_INVOCABLE_SKILLS.has(skillName)) {
196
+ front.push('disable-model-invocation: true')
197
+ }
198
+ return ['---', ...front, ...lines.slice(close)].join('\n')
199
+ }
200
+
201
+ export interface DistSkillFile {
202
+ /** POSIX-style path relative to `skills/<name>/`. */
203
+ path: string
204
+ /** Install-ready text. Only SKILL.md receives frontmatter rewriting. */
205
+ content: string
206
+ }
207
+
208
+ export interface DistSkill {
209
+ /** Bare name in the distribution (`plan`). */
210
+ name: string
211
+ /** Namespaced install name (`ab-plan`). */
212
+ installName: string
213
+ /** SKILL.md install content, retained as a convenience for existing callers. */
214
+ content: string
215
+ /** Every regular file in the canonical skill tree, path-sorted. */
216
+ files: DistSkillFile[]
217
+ }
218
+
219
+ async function readSkillTree(root: string, prefix = ''): Promise<DistSkillFile[]> {
220
+ const entries = await readdir(join(root, ...prefix.split('/').filter(Boolean)), {
221
+ withFileTypes: true,
222
+ })
223
+ const files: DistSkillFile[] = []
224
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
225
+ const path = prefix === '' ? entry.name : `${prefix}/${entry.name}`
226
+ if (entry.isDirectory()) {
227
+ files.push(...(await readSkillTree(root, path)))
228
+ } else if (entry.isFile()) {
229
+ assertSkillRelativePath(path)
230
+ files.push({
231
+ path,
232
+ content: await readFile(join(root, ...path.split('/')), 'utf8'),
233
+ })
234
+ }
235
+ }
236
+ return files.sort((a, b) => a.path.localeCompare(b.path))
237
+ }
238
+
239
+ /** Enumerate complete `<distRoot>/skills/<name>/` trees, sorted, install-ready. */
240
+ export async function readDistSkills(distRoot: string): Promise<DistSkill[]> {
241
+ const skillsDir = join(distRoot, 'skills')
242
+ const entries = await readdir(skillsDir, { withFileTypes: true })
243
+ const skills: DistSkill[] = []
244
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
245
+ if (!entry.isDirectory()) continue
246
+ const root = join(skillsDir, entry.name)
247
+ const files = await readSkillTree(root)
248
+ const skillFile = files.find((file) => file.path === 'SKILL.md')
249
+ if (skillFile === undefined) continue
250
+ skillFile.content = rewriteSkillSource(skillFile.content, entry.name)
251
+ skills.push({
252
+ name: entry.name,
253
+ installName: installedSkillName(entry.name),
254
+ content: skillFile.content,
255
+ files,
256
+ })
257
+ }
258
+ return skills
259
+ }
260
+
261
+ /** True when two existing directory paths resolve to the same filesystem entry. */
262
+ async function sameDirectoryEntry(left: string, right: string): Promise<boolean> {
263
+ let leftStat: Stats
264
+ let rightStat: Stats
265
+ try {
266
+ ;[leftStat, rightStat] = await Promise.all([stat(left), stat(right)])
267
+ } catch (error) {
268
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
269
+ throw error
270
+ }
271
+ return (
272
+ leftStat.isDirectory() &&
273
+ rightStat.isDirectory() &&
274
+ leftStat.dev === rightStat.dev &&
275
+ leftStat.ino === rightStat.ino
276
+ )
277
+ }
278
+
279
+ async function symlinkPointsAt(link: string, target: string): Promise<boolean> {
280
+ try {
281
+ const linkStat = await lstat(link)
282
+ if (!linkStat.isSymbolicLink()) return false
283
+ return resolve(dirname(link), await readlink(link)) === resolve(target)
284
+ } catch (error) {
285
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
286
+ throw error
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Recognize a root alias even while its target is still missing, and
292
+ * materialize that target so later writes through either spelling succeed.
293
+ */
294
+ async function prepareAliasedSkillRoots(targetRepo: string): Promise<boolean> {
295
+ const agentsRoot = join(targetRepo, AGENTS_SKILLS_DIR)
296
+ const claudeRoot = join(targetRepo, CLAUDE_SKILLS_DIR)
297
+ if (await sameDirectoryEntry(agentsRoot, claudeRoot)) return true
298
+ if (await symlinkPointsAt(claudeRoot, agentsRoot)) {
299
+ await mkdir(agentsRoot, { recursive: true })
300
+ return true
301
+ }
302
+ if (await symlinkPointsAt(agentsRoot, claudeRoot)) {
303
+ await mkdir(claudeRoot, { recursive: true })
304
+ return true
305
+ }
306
+ return false
307
+ }
308
+
309
+ /** An actionable collision with a distinct, user-owned Claude discovery entry. */
310
+ export class ClaudeSkillDiscoveryConflict extends Error {
311
+ readonly skill: string
312
+
313
+ constructor(
314
+ targetRepo: string,
315
+ installName: string,
316
+ kind: 'directory' | 'foreign-symlink' = 'directory',
317
+ ) {
318
+ const claude = relative(targetRepo, claudeSkillPath(targetRepo, installName))
319
+ const canonical = relative(targetRepo, dirname(installedSkillPath(targetRepo, installName)))
320
+ const conflict =
321
+ kind === 'foreign-symlink'
322
+ ? `${claude} is a foreign symlink rather than an Autobuild-owned link to ${canonical}`
323
+ : `${claude} is a distinct real directory from ${canonical}`
324
+ super(
325
+ `cannot create Claude discovery link for "${installName}": ${conflict}; ` +
326
+ `move or remove ${claude}, then rerun the command`,
327
+ )
328
+ this.name = 'ClaudeSkillDiscoveryConflict'
329
+ this.skill = installName
330
+ }
331
+ }
332
+
333
+ /** Ensure Claude discovers the canonical `.agents` skill through a symlink. */
334
+ export async function ensureClaudeSkillLink(
335
+ targetRepo: string,
336
+ installName: string,
337
+ ): Promise<void> {
338
+ const link = claudeSkillPath(targetRepo, installName)
339
+ const target = dirname(installedSkillPath(targetRepo, installName))
340
+ // A repository-level alias already gives Claude the canonical tree. Check
341
+ // before touching a per-skill path: on a fresh upgrade its target root may
342
+ // not yet exist, and creating through the alias would create a self-link.
343
+ if (await prepareAliasedSkillRoots(targetRepo)) return
344
+
345
+ await mkdir(dirname(link), { recursive: true })
346
+
347
+ try {
348
+ const linkStat = await lstat(link)
349
+ if (linkStat.isSymbolicLink()) {
350
+ const current = resolve(dirname(link), await readlink(link))
351
+ if (current === resolve(target) || (await sameDirectoryEntry(link, target))) return
352
+ await unlink(link)
353
+ } else if (linkStat.isDirectory()) {
354
+ if (await sameDirectoryEntry(link, target)) return
355
+ throw new ClaudeSkillDiscoveryConflict(targetRepo, installName)
356
+ } else {
357
+ throw new Error(
358
+ `cannot create Claude link for "${installName}": ` +
359
+ `${CLAUDE_SKILLS_DIR}/${installName} exists and is not a directory symlink`,
360
+ )
361
+ }
362
+ } catch (error) {
363
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
364
+ }
365
+
366
+ await symlink(relative(dirname(link), target), link, 'dir')
367
+ }
368
+
369
+ /**
370
+ * Recursively move non-conflicting entries from an obsolete directory into
371
+ * its replacement. Existing destination entries always win, so migration
372
+ * cannot clobber data; conflicting source entries remain for manual recovery.
373
+ */
374
+ async function moveMissingEntries(source: string, destination: string): Promise<void> {
375
+ let entries: Dirent[]
376
+ try {
377
+ entries = await readdir(source, { withFileTypes: true })
378
+ } catch (error) {
379
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
380
+ throw error
381
+ }
382
+
383
+ await mkdir(destination, { recursive: true })
384
+ for (const entry of entries) {
385
+ const from = join(source, entry.name)
386
+ const to = join(destination, entry.name)
387
+ let destinationStat: Stats
388
+ try {
389
+ destinationStat = await lstat(to)
390
+ } catch (error) {
391
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
392
+ await rename(from, to)
393
+ continue
394
+ }
395
+
396
+ const sourceStat = await lstat(from)
397
+ if (
398
+ sourceStat.isDirectory() &&
399
+ !sourceStat.isSymbolicLink() &&
400
+ destinationStat.isDirectory() &&
401
+ !destinationStat.isSymbolicLink()
402
+ ) {
403
+ await moveMissingEntries(from, to)
404
+ }
405
+ }
406
+
407
+ try {
408
+ await rmdir(source)
409
+ } catch (error) {
410
+ const code = (error as NodeJS.ErrnoException).code
411
+ if (code !== 'ENOENT' && code !== 'ENOTEMPTY') throw error
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Migrate the unsupported `.agent/skills` project layout used by older
417
+ * releases into the Agent Skills standard `.agents/skills` directory. The
418
+ * whole tree is considered so local `ab-*` additions, supporting files, and
419
+ * pristine merge bases move together. Safe and idempotent.
420
+ */
421
+ export async function migrateLegacyAgentSkills(targetRepo: string): Promise<void> {
422
+ await prepareAliasedSkillRoots(targetRepo)
423
+ await moveMissingEntries(
424
+ join(targetRepo, LEGACY_AGENT_SKILLS_DIR),
425
+ join(targetRepo, AGENTS_SKILLS_DIR),
426
+ )
427
+ try {
428
+ await rmdir(join(targetRepo, '.agent'))
429
+ } catch (error) {
430
+ const code = (error as NodeJS.ErrnoException).code
431
+ if (code !== 'ENOENT' && code !== 'ENOTEMPTY') throw error
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Move a pre-canonical-layout `.claude` install into `.agents` without losing
437
+ * local edits or the pristine merge base. Returns the migrated live bytes.
438
+ */
439
+ export async function migrateLegacySkill(
440
+ targetRepo: string,
441
+ installName: string,
442
+ warning?: (line: string) => void,
443
+ ): Promise<string | undefined> {
444
+ // When the roots are aliases, `.claude` is not a legacy copy: it is the
445
+ // canonical live and pristine tree viewed through another path.
446
+ if (await prepareAliasedSkillRoots(targetRepo)) return undefined
447
+
448
+ let migrated: string | undefined
449
+ if ((await readIfExists(installedSkillPath(targetRepo, installName))) === undefined) {
450
+ const legacy = await readIfExists(legacyInstalledSkillPath(targetRepo, installName))
451
+ if (legacy !== undefined) {
452
+ const legacyDir = dirname(legacyInstalledSkillPath(targetRepo, installName))
453
+ const live = installedSkillPath(targetRepo, installName)
454
+ await mkdir(dirname(dirname(live)), { recursive: true })
455
+ await cp(legacyDir, dirname(live), { recursive: true })
456
+ // The complete directory now lives under `.agents`; clear the old
457
+ // discovery location so ensureClaudeSkillLink can replace it.
458
+ await rm(legacyDir, { recursive: true, force: true })
459
+ migrated = legacy
460
+ }
461
+ }
462
+
463
+ const legacyPristineDir = dirname(legacyPristineSkillPath(targetRepo, installName))
464
+ try {
465
+ const stat = await lstat(legacyPristineDir)
466
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
467
+ await moveMissingEntries(
468
+ legacyPristineDir,
469
+ dirname(pristineSkillPath(targetRepo, installName)),
470
+ )
471
+ try {
472
+ await rmdir(join(targetRepo, CLAUDE_SKILLS_DIR, PRISTINE_DIR))
473
+ } catch (error) {
474
+ const code = (error as NodeJS.ErrnoException).code
475
+ if (code !== 'ENOENT' && code !== 'ENOTEMPTY') throw error
476
+ }
477
+ try {
478
+ await lstat(legacyPristineDir)
479
+ warning?.(
480
+ `${installName}: warning — conflicting legacy pristine files remain at ` +
481
+ `${relative(targetRepo, legacyPristineDir)} for manual recovery`,
482
+ )
483
+ } catch (error) {
484
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
485
+ }
486
+ }
487
+ } catch (error) {
488
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
489
+ }
490
+ return migrated
491
+ }
492
+
493
+ /** Installed `ab-*` skill directories under `.agents/skills`, sorted. */
494
+ export async function listInstalledSkills(targetRepo: string): Promise<string[]> {
495
+ const dir = join(targetRepo, AGENTS_SKILLS_DIR)
496
+ let entries: Dirent[]
497
+ try {
498
+ entries = await readdir(dir, { withFileTypes: true })
499
+ } catch (error) {
500
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
501
+ throw error
502
+ }
503
+ const names: string[] = []
504
+ for (const entry of entries) {
505
+ if (!entry.isDirectory() || !entry.name.startsWith(SKILL_NAMESPACE)) continue
506
+ if ((await readIfExists(join(dir, entry.name, 'SKILL.md'))) === undefined) continue
507
+ names.push(entry.name)
508
+ }
509
+ return names.sort()
510
+ }
511
+
512
+ /** Write one live skill file and create its parent directories. */
513
+ export async function writeInstalledSkillFile(
514
+ targetRepo: string,
515
+ installName: string,
516
+ file: string,
517
+ content: string,
518
+ ): Promise<void> {
519
+ const live = installedSkillFilePath(targetRepo, installName, file)
520
+ await mkdir(dirname(live), { recursive: true })
521
+ await writeFile(live, content)
522
+ }
523
+
524
+ /** Write one pristine skill file and create its parent directories. */
525
+ export async function writePristineFile(
526
+ targetRepo: string,
527
+ installName: string,
528
+ file: string,
529
+ content: string,
530
+ ): Promise<void> {
531
+ const pristine = pristineSkillFilePath(targetRepo, installName, file)
532
+ await mkdir(dirname(pristine), { recursive: true })
533
+ await writeFile(pristine, content)
534
+ }
535
+
536
+ /** Write a skill's complete distributed tree and Claude discovery link. */
537
+ export async function installSkillTree(
538
+ targetRepo: string,
539
+ skill: Pick<DistSkill, 'installName' | 'files'>,
540
+ ): Promise<void> {
541
+ for (const file of skill.files) {
542
+ await writeInstalledSkillFile(targetRepo, skill.installName, file.path, file.content)
543
+ await writePristineFile(targetRepo, skill.installName, file.path, file.content)
544
+ }
545
+ await ensureClaudeSkillLink(targetRepo, skill.installName)
546
+ }
547
+
548
+ /** Backward-compatible SKILL.md-only install helper. */
549
+ export async function installSkillFiles(
550
+ targetRepo: string,
551
+ installName: string,
552
+ content: string,
553
+ ): Promise<void> {
554
+ await installSkillTree(targetRepo, {
555
+ installName,
556
+ files: [{ path: 'SKILL.md', content }],
557
+ })
558
+ }
559
+
560
+ /** Update only the pristine SKILL.md record for a skill. */
561
+ export async function writePristine(
562
+ targetRepo: string,
563
+ installName: string,
564
+ content: string,
565
+ ): Promise<void> {
566
+ await writePristineFile(targetRepo, installName, 'SKILL.md', content)
567
+ }
568
+
569
+ export type InitSkillAction = 'installed' | 'kept' | 'unchanged' | 'overwritten'
570
+ export type InitConfigAction = 'written' | 'skipped'
571
+
572
+ export interface SkillDiscoveryConflict {
573
+ skill: string
574
+ message: string
575
+ }
576
+
577
+ export interface InitReport {
578
+ /** What happened to autobuild.toml. */
579
+ config: InitConfigAction
580
+ /** Per-skill outcome, keyed by the namespaced install name. */
581
+ skills: Array<{ skill: string; action: InitSkillAction }>
582
+ /** User-owned Claude entries that prevented discovery-link maintenance. */
583
+ discoveryConflicts: SkillDiscoveryConflict[]
584
+ /** Interactive setup child status, or one when discovery conflicts remain. */
585
+ exitCode: number
586
+ }
587
+
588
+ function setupPrompt(configExists: boolean): string {
589
+ const preface = configExists
590
+ ? 'Review and improve the existing autobuild.toml. Preserve intentional repository choices; do not replace it with a generic template.'
591
+ : 'Complete the new minimal autobuild.toml skeleton using this repository and the user as your sources of truth.'
592
+ return (
593
+ `${preface}\n\n` +
594
+ 'Read .agents/skills/ab-guide/references/setup.md and follow it to configure Autobuild for this repository.\n'
595
+ )
596
+ }
597
+
598
+ function renderSkeleton(template: string, runtime: string): string {
599
+ const token = '@ab-init/runtime@'
600
+ const occurrences = template.split(token).length - 1
601
+ // Test/plugin distributions from before agent-driven init can still carry an
602
+ // explicit runtime. Preserve those valid templates rather than making skill
603
+ // installation depend on a newly introduced rendering token.
604
+ if (occurrences === 0) return template
605
+ if (occurrences !== 1) {
606
+ throw new Error(`autobuild.toml template must contain at most one ${token} token`)
607
+ }
608
+ return template.replace(token, runtime)
609
+ }
610
+
611
+ export async function abInit(opts: {
612
+ targetRepo: string
613
+ distRoot?: string
614
+ stdout?: (line: string) => void
615
+ force?: boolean
616
+ runtimes?: RuntimeRegistry
617
+ env?: Readonly<Record<string, string | undefined>>
618
+ interactive?: boolean
619
+ launcher?: SetupAgentLauncher
620
+ signal?: AbortSignal
621
+ }): Promise<InitReport> {
622
+ const distRoot = opts.distRoot ?? defaultDistRoot()
623
+ const stdout = opts.stdout ?? (() => {})
624
+ const force = opts.force ?? false
625
+ const env = opts.env ?? process.env
626
+ const runtimes = opts.runtimes ?? createProductionRuntimes().runtimes
627
+ const configPath = join(opts.targetRepo, 'autobuild.toml')
628
+ const configExists = (await readIfExists(configPath)) !== undefined
629
+ const probes = await probeInitRuntimes(runtimes, opts.targetRepo, env)
630
+ const selectedRuntime = selectSetupRuntime(probes)
631
+ const skeletonRuntime = selectedRuntime ?? SETUP_RUNTIME_PREFERENCE[0]
632
+
633
+ await migrateLegacyAgentSkills(opts.targetRepo)
634
+
635
+ let config: InitConfigAction
636
+ if (!configExists) {
637
+ const template = await readFile(join(distRoot, 'templates', 'autobuild.toml'), 'utf8')
638
+ await mkdir(opts.targetRepo, { recursive: true })
639
+ await writeFile(configPath, renderSkeleton(template, skeletonRuntime))
640
+ config = 'written'
641
+ } else {
642
+ config = 'skipped'
643
+ }
644
+
645
+ await ensureLocalStateIgnored(opts.targetRepo)
646
+
647
+ const attention: string[] = []
648
+ const discoveryConflictMap = new Map<string, SkillDiscoveryConflict>()
649
+ const containDiscoveryConflict = (error: unknown): void => {
650
+ if (!(error instanceof ClaudeSkillDiscoveryConflict)) throw error
651
+ discoveryConflictMap.set(error.skill, { skill: error.skill, message: error.message })
652
+ }
653
+ const skills: InitReport['skills'] = []
654
+ for (const skill of await readDistSkills(distRoot)) {
655
+ const migrated = await migrateLegacySkill(opts.targetRepo, skill.installName, (line) => {
656
+ attention.push(line)
657
+ })
658
+ const rootLocal =
659
+ migrated ?? (await readIfExists(installedSkillPath(opts.targetRepo, skill.installName)))
660
+ let divergent = false
661
+ for (const file of skill.files) {
662
+ const livePath = installedSkillFilePath(opts.targetRepo, skill.installName, file.path)
663
+ const local =
664
+ file.path === 'SKILL.md' && migrated !== undefined ? migrated : await readIfExists(livePath)
665
+ if (local === undefined || local === file.content) {
666
+ await writeInstalledSkillFile(opts.targetRepo, skill.installName, file.path, file.content)
667
+ await writePristineFile(opts.targetRepo, skill.installName, file.path, file.content)
668
+ } else if (force) {
669
+ divergent = true
670
+ await writeInstalledSkillFile(opts.targetRepo, skill.installName, file.path, file.content)
671
+ await writePristineFile(opts.targetRepo, skill.installName, file.path, file.content)
672
+ } else {
673
+ divergent = true
674
+ }
675
+ }
676
+ const action: InitSkillAction = divergent
677
+ ? force
678
+ ? 'overwritten'
679
+ : 'kept'
680
+ : rootLocal === undefined
681
+ ? 'installed'
682
+ : 'unchanged'
683
+ try {
684
+ await ensureClaudeSkillLink(opts.targetRepo, skill.installName)
685
+ } catch (error) {
686
+ containDiscoveryConflict(error)
687
+ }
688
+ skills.push({ skill: skill.installName, action })
689
+ if (action === 'kept' || action === 'overwritten')
690
+ attention.push(`${skill.installName}: ${action}`)
691
+ }
692
+
693
+ for (const name of await listInstalledSkills(opts.targetRepo)) {
694
+ if (discoveryConflictMap.has(name)) continue
695
+ try {
696
+ await ensureClaudeSkillLink(opts.targetRepo, name)
697
+ } catch (error) {
698
+ containDiscoveryConflict(error)
699
+ }
700
+ }
701
+ const discoveryConflicts = [...discoveryConflictMap.values()]
702
+
703
+ const skillCounts: Record<InitSkillAction, number> = {
704
+ installed: 0,
705
+ unchanged: 0,
706
+ kept: 0,
707
+ overwritten: 0,
708
+ }
709
+ for (const skill of skills) skillCounts[skill.action] += 1
710
+
711
+ stdout(`autobuild.toml: ${config}`)
712
+ stdout(
713
+ `Skills: ${skillCounts.installed} installed, ${skillCounts.unchanged} unchanged, ` +
714
+ `${skillCounts.kept} kept, ${skillCounts.overwritten} overwritten`,
715
+ )
716
+ for (const line of attention) stdout(line)
717
+ if (discoveryConflicts.length > 0) {
718
+ stdout('Claude discovery conflicts:')
719
+ for (const conflict of discoveryConflicts) stdout(` ${conflict.message}`)
720
+ }
721
+ stdout('')
722
+ stdout('Runtime probes:')
723
+ for (const probe of probes) {
724
+ stdout(` ${probe.runtime}: ${probe.usable ? 'usable' : 'unusable'} — ${probe.reason}`)
725
+ }
726
+
727
+ if (discoveryConflicts.length > 0) {
728
+ return { config, skills, discoveryConflicts, exitCode: 1 }
729
+ }
730
+
731
+ // The handoff is deliberately a stable pointer rather than embedded guide
732
+ // content. Installation remains successful for an older or partial
733
+ // distribution whose guide tree does not contain the reference yet.
734
+ const prompt = setupPrompt(configExists)
735
+ if (selectedRuntime !== undefined && opts.interactive === true) {
736
+ stdout('')
737
+ stdout(`Starting setup agent with ${selectedRuntime}…`)
738
+ const exitCode = await (opts.launcher ?? launchSetupAgent)({
739
+ runtime: selectedRuntime,
740
+ prompt,
741
+ cwd: opts.targetRepo,
742
+ env,
743
+ ...(opts.signal === undefined ? {} : { signal: opts.signal }),
744
+ })
745
+ return { config, skills, discoveryConflicts, exitCode }
746
+ }
747
+
748
+ stdout('')
749
+ stdout(
750
+ selectedRuntime === undefined
751
+ ? 'No usable interactive runtime was detected. Run the following prompt in a coding agent:'
752
+ : 'No interactive terminal was detected. Run the following prompt in a coding agent:',
753
+ )
754
+ stdout(prompt)
755
+ return { config, skills, discoveryConflicts, exitCode: 0 }
756
+ }