@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,888 @@
1
+ /**
2
+ * `ab upgrade` — the classic vendoring problem (SPEC §16.3, D11). `ab init`
3
+ * recorded the pristine bytes of every distributed skill file; upgrade
4
+ * three-way merges pristine (base) × local (ours) × new default (theirs) so
5
+ * local customization survives and divergence is visible instead of silent:
6
+ *
7
+ * The cases below apply independently to every distributed file; their
8
+ * outcomes are folded into one deterministic per-skill report.
9
+ *
10
+ * - new default == pristine → nothing changed upstream; the local file
11
+ * stands, whatever the repo did to it (`current`).
12
+ * - local == pristine → no local edits; adopt the new default wholesale and
13
+ * advance the pristine record (`adopted`).
14
+ * - both diverged → three-way merge via `git merge-file` (git is guaranteed
15
+ * present; the exec seam keeps it injectable). Clean merge → write the
16
+ * result, advance pristine (`merged`). Conflict → the resolveConflict
17
+ * agent seam decides, with a standing bias: PREFER THE LOCAL CUSTOMIZATION
18
+ * — upstream is adopted only where it doesn't collide with what the repo
19
+ * deliberately changed (`resolved`). The returned full file is untrusted:
20
+ * deterministic validation protects the installed skill identity, rejects
21
+ * agent-authored marker/wrapper output, and preserves every already-clean
22
+ * merge region (including literal marker documentation). A
23
+ * missing, declined, failed, or invalid resolution escalates to a human: the
24
+ * LOCAL file is left byte-untouched and the report carries the merge-markered
25
+ * text (`conflicted`). Conflict markers are never written into the live skill.
26
+ * - missing pristine record (pre-record install) → ambiguous: adopt only
27
+ * when local == new (provably no divergence), otherwise `conflicted` —
28
+ * never silently clobber a file whose edit history is unknowable.
29
+ * - in the distribution but not installed → installed fresh, like init
30
+ * (`installed`).
31
+ * - installed ab-* skills absent from the distribution → left alone
32
+ * (`unknown`); local skill additions are legitimate. The only exception is
33
+ * the fixed, pristine-provenance retirement of `ab-setup` and
34
+ * `ab-verify-e2e`: exact unreferenced or already-missing canonical trees are
35
+ * `removed`, while customized, configured, unsafe, or user-owned Claude
36
+ * discovery entries are `kept`; every terminal classification relinquishes
37
+ * pristine ownership.
38
+ *
39
+ * Like init, upgrade runs OUTSIDE build sessions — no AB_* environment.
40
+ */
41
+ import { randomUUID } from 'node:crypto'
42
+ import type { Dirent, Stats } from 'node:fs'
43
+ import {
44
+ lstat,
45
+ mkdtemp,
46
+ readFile,
47
+ readdir,
48
+ readlink,
49
+ rm,
50
+ unlink,
51
+ writeFile,
52
+ } from 'node:fs/promises'
53
+ import { tmpdir } from 'node:os'
54
+ import { dirname, join, resolve } from 'node:path'
55
+ import { parseConfig } from '../config/load'
56
+ import type { Exec } from '../ports/workspace/git-worktree'
57
+ import { spawnExec } from '../ports/workspace/git-worktree'
58
+ import {
59
+ ClaudeSkillDiscoveryConflict,
60
+ claudeSkillPath,
61
+ defaultDistRoot,
62
+ ensureClaudeSkillLink,
63
+ installedSkillFilePath,
64
+ installedSkillPath,
65
+ listInstalledSkills,
66
+ migrateLegacyAgentSkills,
67
+ migrateLegacySkill,
68
+ pristineSkillFilePath,
69
+ readDistSkills,
70
+ readIfExists,
71
+ writeInstalledSkillFile,
72
+ writePristineFile,
73
+ type SkillDiscoveryConflict,
74
+ } from './init'
75
+
76
+ export type UpgradeSkillAction =
77
+ | 'current'
78
+ | 'adopted'
79
+ | 'merged'
80
+ | 'resolved'
81
+ | 'conflicted'
82
+ | 'installed'
83
+ | 'removed'
84
+ | 'kept'
85
+ | 'unknown'
86
+
87
+ export interface UpgradeReport {
88
+ /** Per-skill content outcome, keyed by the namespaced install name. */
89
+ skills: Array<{ skill: string; action: UpgradeSkillAction; detail?: string }>
90
+ /** User-owned Claude entries that prevented discovery-link maintenance. */
91
+ discoveryConflicts: SkillDiscoveryConflict[]
92
+ /** Nonzero only for discovery conflicts; content conflicts remain exit zero. */
93
+ exitCode: number
94
+ }
95
+
96
+ /**
97
+ * The agent seam for merge conflicts (§16.3). Receives the three exact texts
98
+ * of the merge (base = pristine record, local = the live file, incoming =
99
+ * the new default) and returns the resolved full text, or null to escalate
100
+ * to a human. Standing bias for any implementation: PREFER THE LOCAL
101
+ * CUSTOMIZATION — adopt upstream only where it doesn't collide with what
102
+ * the repo deliberately changed.
103
+ */
104
+ export interface ResolveConflictOptions {
105
+ /** Per-file caller cancellation. A cancelled proposal must never be applied. */
106
+ signal?: AbortSignal
107
+ }
108
+
109
+ export const UPGRADE_RESOLUTION_CANCELLED_MESSAGE = 'upgrade conflict resolution cancelled by human'
110
+
111
+ export class UpgradeResolutionCancelledError extends Error {
112
+ constructor() {
113
+ super(UPGRADE_RESOLUTION_CANCELLED_MESSAGE)
114
+ this.name = 'UpgradeResolutionCancelledError'
115
+ }
116
+ }
117
+
118
+ export type ResolveConflict = (
119
+ input: {
120
+ skill: string
121
+ /** POSIX-style path relative to the installed skill directory. */
122
+ path: string
123
+ base: string
124
+ local: string
125
+ incoming: string
126
+ },
127
+ options?: ResolveConflictOptions,
128
+ ) => Promise<string | null>
129
+
130
+ export interface MergeConflictLabels {
131
+ local: string
132
+ pristine: string
133
+ incoming: string
134
+ }
135
+
136
+ interface MergeFileResult {
137
+ clean: boolean
138
+ text: string
139
+ labels: MergeConflictLabels
140
+ }
141
+
142
+ function uniqueMergeLabels(): MergeConflictLabels {
143
+ const nonce = randomUUID()
144
+ return {
145
+ local: `ab-upgrade-local-${nonce}`,
146
+ pristine: `ab-upgrade-pristine-${nonce}`,
147
+ incoming: `ab-upgrade-incoming-${nonce}`,
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Three-way merge over `git merge-file -p` on temp copies. Exit code 0 is a
153
+ * clean merge; a positive code is the number of conflicts (stdout then holds
154
+ * the markered text); anything else is a real git error. Labels are
155
+ * unguessable per invocation so marker-looking skill content can never be
156
+ * mistaken for structure in this merge's output.
157
+ */
158
+ async function mergeFile(
159
+ exec: Exec,
160
+ input: { base: string; local: string; incoming: string },
161
+ ): Promise<MergeFileResult> {
162
+ const dir = await mkdtemp(join(tmpdir(), 'ab-upgrade-'))
163
+ const labels = uniqueMergeLabels()
164
+ try {
165
+ await writeFile(join(dir, 'local'), input.local)
166
+ await writeFile(join(dir, 'base'), input.base)
167
+ await writeFile(join(dir, 'incoming'), input.incoming)
168
+ const result = await exec(
169
+ [
170
+ 'git',
171
+ 'merge-file',
172
+ '-p',
173
+ '-L',
174
+ labels.local,
175
+ '-L',
176
+ labels.pristine,
177
+ '-L',
178
+ labels.incoming,
179
+ 'local',
180
+ 'base',
181
+ 'incoming',
182
+ ],
183
+ { cwd: dir },
184
+ )
185
+ if (result.exitCode === 0) {
186
+ return { clean: true, text: result.stdout, labels }
187
+ }
188
+ if (result.exitCode > 0 && result.exitCode < 127) {
189
+ return { clean: false, text: result.stdout, labels }
190
+ }
191
+ throw new Error(
192
+ `git merge-file failed (exit ${result.exitCode}): ${
193
+ result.stderr.trim() || result.stdout.trim() || '(no output)'
194
+ }`,
195
+ )
196
+ } finally {
197
+ await rm(dir, { recursive: true, force: true })
198
+ }
199
+ }
200
+
201
+ const CONFLICT_MARKER_LINE = /^(?:<{7,}|={7,}|>{7,}|\|{7,})(?:[ \t].*)?\r?$/m
202
+
203
+ /** Split text into lines while retaining exact line endings. */
204
+ function linesWithEndings(text: string): string[] {
205
+ const lines = text.match(/[^\n]*(?:\n|$)/g) ?? []
206
+ if (lines.at(-1) === '') lines.pop()
207
+ return lines
208
+ }
209
+
210
+ /**
211
+ * The failed merge already contains every non-colliding local and incoming
212
+ * edit. Extract the exact regions outside its uniquely labelled conflict
213
+ * hunks; a resolver may replace the hunks, but it has no authority to rewrite
214
+ * these regions.
215
+ */
216
+ function cleanMergeRegions(marked: string, labels: MergeConflictLabels): string[] {
217
+ const regions: string[] = []
218
+ const startMarker = `<<<<<<< ${labels.local}`
219
+ const endMarker = `>>>>>>> ${labels.incoming}`
220
+ let current = ''
221
+ let conflict = false
222
+ let separator = false
223
+ let sawConflict = false
224
+
225
+ for (const line of linesWithEndings(marked)) {
226
+ const marker = line.replace(/\r?\n$/, '')
227
+ if (!conflict && marker === startMarker) {
228
+ regions.push(current)
229
+ current = ''
230
+ conflict = true
231
+ separator = false
232
+ sawConflict = true
233
+ continue
234
+ }
235
+ if (conflict && marker === '=======') {
236
+ separator = true
237
+ continue
238
+ }
239
+ if (conflict && marker === endMarker) {
240
+ if (!separator) {
241
+ throw new Error('git merge-file produced a malformed conflict without a separator')
242
+ }
243
+ conflict = false
244
+ continue
245
+ }
246
+ if (!conflict) current += line
247
+ }
248
+
249
+ if (conflict) {
250
+ throw new Error('git merge-file produced an unterminated conflict')
251
+ }
252
+ if (!sawConflict) {
253
+ throw new Error('git merge-file reported a conflict without its labelled markers')
254
+ }
255
+ regions.push(current)
256
+ return regions
257
+ }
258
+
259
+ interface ContentInterval {
260
+ start: number
261
+ end: number
262
+ }
263
+
264
+ /** Match each protected region to its exact occurrence in the proposal. */
265
+ function locateCleanRegions(
266
+ candidate: string,
267
+ regions: string[],
268
+ ): { intervals: ContentInterval[] } | { error: string } {
269
+ const intervals: ContentInterval[] = []
270
+ let cursor = 0
271
+ for (let index = 0; index < regions.length; index += 1) {
272
+ const region = regions[index] ?? ''
273
+ if (region === '') continue
274
+
275
+ let start: number
276
+ if (index === 0) {
277
+ if (!candidate.startsWith(region)) {
278
+ return {
279
+ error:
280
+ 'output changed or wrapped the already-clean merge region before the first conflict',
281
+ }
282
+ }
283
+ start = 0
284
+ } else if (index === regions.length - 1) {
285
+ start = candidate.length - region.length
286
+ if (start < cursor || !candidate.endsWith(region)) {
287
+ return {
288
+ error: 'output changed or wrapped the already-clean merge region after the last conflict',
289
+ }
290
+ }
291
+ } else {
292
+ start = candidate.indexOf(region, cursor)
293
+ if (start === -1) {
294
+ return { error: 'output omitted or changed an already-clean merge region' }
295
+ }
296
+ }
297
+ intervals.push({ start, end: start + region.length })
298
+ cursor = start + region.length
299
+ }
300
+ return { intervals }
301
+ }
302
+
303
+ /** Text outside protected clean intervals is the agent-authored hunk content. */
304
+ function resolutionGaps(candidate: string, intervals: ContentInterval[]): string[] {
305
+ const gaps: string[] = []
306
+ let cursor = 0
307
+ for (const interval of intervals) {
308
+ gaps.push(candidate.slice(cursor, interval.start))
309
+ cursor = interval.end
310
+ }
311
+ gaps.push(candidate.slice(cursor))
312
+ return gaps
313
+ }
314
+
315
+ function frontmatterName(candidate: string): { name?: string; error?: string } {
316
+ const lines = candidate.split('\n')
317
+ if (lines[0]?.replace(/\r$/, '') !== '---') {
318
+ return { error: "output must begin at byte 0 with YAML frontmatter ('---')" }
319
+ }
320
+ const close = lines.findIndex((line, index) => index > 0 && line.replace(/\r$/, '') === '---')
321
+ if (close === -1) return { error: 'output has unterminated YAML frontmatter' }
322
+ if (close === 1) return { error: 'output has empty YAML frontmatter' }
323
+
324
+ const names: string[] = []
325
+ for (const rawLine of lines.slice(1, close)) {
326
+ const match = /^name:\s*(.*?)\s*\r?$/.exec(rawLine)
327
+ if (match === null) continue
328
+ let value = match[1] ?? ''
329
+ if (
330
+ value.length >= 2 &&
331
+ ((value.startsWith('"') && value.endsWith('"')) ||
332
+ (value.startsWith("'") && value.endsWith("'")))
333
+ ) {
334
+ value = value.slice(1, -1)
335
+ }
336
+ names.push(value)
337
+ }
338
+ if (names.length !== 1 || names[0] === '') {
339
+ return { error: 'frontmatter must contain exactly one nonempty name field' }
340
+ }
341
+ if (
342
+ lines
343
+ .slice(close + 1)
344
+ .join('\n')
345
+ .trim() === ''
346
+ ) {
347
+ return { error: 'output must contain a complete nonempty skill body' }
348
+ }
349
+ return { name: names[0] }
350
+ }
351
+
352
+ /** Return an actionable reason when an agent proposal is unsafe. */
353
+ export function validateConflictResolution(input: {
354
+ skill: string
355
+ path?: string
356
+ candidate: string
357
+ markedMerge: string
358
+ labels: MergeConflictLabels
359
+ }): string | undefined {
360
+ if (input.candidate.trim() === '') return 'output was empty'
361
+
362
+ if ((input.path ?? 'SKILL.md') === 'SKILL.md') {
363
+ const frontmatter = frontmatterName(input.candidate)
364
+ if (frontmatter.error !== undefined) return frontmatter.error
365
+ if (frontmatter.name !== input.skill) {
366
+ return `frontmatter names "${frontmatter.name}" instead of "${input.skill}"`
367
+ }
368
+ }
369
+
370
+ let regions: string[]
371
+ try {
372
+ regions = cleanMergeRegions(input.markedMerge, input.labels)
373
+ } catch (error) {
374
+ return error instanceof Error ? error.message : String(error)
375
+ }
376
+
377
+ const located = locateCleanRegions(input.candidate, regions)
378
+ if ('error' in located) return located.error
379
+
380
+ // Marker-looking lines already present in an exact clean region are skill
381
+ // content, not merge structure. Only agent-authored hunk gaps must be free
382
+ // of standard marker lines; this rejects unresolved output without making a
383
+ // skill that documents Git conflict syntax impossible to resolve.
384
+ if (
385
+ resolutionGaps(input.candidate, located.intervals).some((gap) => CONFLICT_MARKER_LINE.test(gap))
386
+ ) {
387
+ return 'output contains a Git conflict-marker line in a resolved hunk'
388
+ }
389
+ return undefined
390
+ }
391
+
392
+ function errorMessage(error: unknown): string {
393
+ return (
394
+ (error instanceof Error ? error.message : String(error)).replace(/\s+/g, ' ').trim() ||
395
+ 'unknown error'
396
+ )
397
+ }
398
+
399
+ /** The only product-owned skill retirements. Ordinary absent skills stay local. */
400
+ const RETIRED_SKILLS = ['ab-setup', 'ab-verify-e2e'] as const
401
+
402
+ type TreeEntry = { kind: 'directory' } | { kind: 'file'; content: Buffer }
403
+
404
+ interface InspectedTree {
405
+ exists: boolean
406
+ entries: Map<string, TreeEntry>
407
+ unsafe?: string
408
+ }
409
+
410
+ /** Inspect every path without following links; retirements require a plain tree. */
411
+ async function inspectTree(root: string): Promise<InspectedTree> {
412
+ let rootStat: Stats
413
+ try {
414
+ rootStat = await lstat(root)
415
+ } catch (error) {
416
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
417
+ return { exists: false, entries: new Map() }
418
+ }
419
+ throw error
420
+ }
421
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
422
+ return {
423
+ exists: true,
424
+ entries: new Map(),
425
+ unsafe: 'skill root is not a plain directory',
426
+ }
427
+ }
428
+
429
+ const entries = new Map<string, TreeEntry>()
430
+ const visit = async (dir: string, prefix = ''): Promise<string | undefined> => {
431
+ const children = await readdir(dir, { withFileTypes: true })
432
+ for (const child of children.sort((left, right) => left.name.localeCompare(right.name))) {
433
+ const path = prefix === '' ? child.name : `${prefix}/${child.name}`
434
+ const absolute = join(dir, child.name)
435
+ if (child.isDirectory()) {
436
+ entries.set(path, { kind: 'directory' })
437
+ const unsafe = await visit(absolute, path)
438
+ if (unsafe !== undefined) return unsafe
439
+ } else if (child.isFile()) {
440
+ entries.set(path, { kind: 'file', content: await readFile(absolute) })
441
+ } else {
442
+ return `${path} is not a regular file or directory`
443
+ }
444
+ }
445
+ return undefined
446
+ }
447
+
448
+ const unsafe = await visit(root)
449
+ return { exists: true, entries, ...(unsafe === undefined ? {} : { unsafe }) }
450
+ }
451
+
452
+ function treeMismatch(live: InspectedTree, pristine: InspectedTree): string | undefined {
453
+ if (!live.exists) return 'the installed skill tree is missing'
454
+ if (live.unsafe !== undefined) return live.unsafe
455
+ if (pristine.unsafe !== undefined) return `the pristine record is unsafe: ${pristine.unsafe}`
456
+ if (pristine.entries.get('SKILL.md')?.kind !== 'file') {
457
+ return 'the pristine record has no regular SKILL.md'
458
+ }
459
+
460
+ const paths = [...new Set([...live.entries.keys(), ...pristine.entries.keys()])].sort()
461
+ for (const path of paths) {
462
+ const local = live.entries.get(path)
463
+ const base = pristine.entries.get(path)
464
+ if (local === undefined) return `${path} is missing from the installed tree`
465
+ if (base === undefined) return `${path} is a repository-local addition`
466
+ if (local.kind !== base.kind) return `${path} changed file type`
467
+ if (local.kind === 'file' && base.kind === 'file' && !local.content.equals(base.content)) {
468
+ return `${path} has local edits`
469
+ }
470
+ }
471
+ return undefined
472
+ }
473
+
474
+ async function configuredAgentSkills(
475
+ targetRepo: string,
476
+ ): Promise<{ skills: Set<string> } | { error: string }> {
477
+ const path = join(targetRepo, 'autobuild.toml')
478
+ try {
479
+ const source = await readIfExists(path)
480
+ if (source === undefined) return { skills: new Set() }
481
+ const config = parseConfig(source, path)
482
+ const skills = new Set<string>()
483
+ for (const step of Object.values(config.verify.stepConfigs)) {
484
+ if (step.kind === 'agent') skills.add(step.skill)
485
+ }
486
+ for (const step of Object.values(config.finalize.stepConfigs)) {
487
+ if (step.kind === 'agent') skills.add(step.skill)
488
+ }
489
+ return { skills }
490
+ } catch (error) {
491
+ return { error: errorMessage(error) }
492
+ }
493
+ }
494
+
495
+ type ClaudeLinkOwnership = 'owned' | 'foreign'
496
+
497
+ /** Inspect a per-skill link without following or mutating the discovery entry. */
498
+ async function claudeLinkOwnership(
499
+ targetRepo: string,
500
+ installName: string,
501
+ ): Promise<ClaudeLinkOwnership | undefined> {
502
+ const link = claudeSkillPath(targetRepo, installName)
503
+ try {
504
+ const linkStat = await lstat(link)
505
+ if (!linkStat.isSymbolicLink()) return undefined
506
+ const target = resolve(dirname(link), await readlink(link))
507
+ const canonical = resolve(dirname(installedSkillPath(targetRepo, installName)))
508
+ return target === canonical ? 'owned' : 'foreign'
509
+ } catch (error) {
510
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
511
+ throw error
512
+ }
513
+ }
514
+
515
+ /** Remove only the per-skill link created by init; root aliases are untouched. */
516
+ async function removeOwnedClaudeLink(targetRepo: string, installName: string): Promise<void> {
517
+ if ((await claudeLinkOwnership(targetRepo, installName)) === 'owned') {
518
+ await unlink(claudeSkillPath(targetRepo, installName))
519
+ }
520
+ }
521
+
522
+ /** A real Claude directory is user-owned when the canonical tree is absent. */
523
+ async function hasDistinctClaudeDirectory(
524
+ targetRepo: string,
525
+ installName: string,
526
+ ): Promise<boolean> {
527
+ try {
528
+ const discovery = await lstat(claudeSkillPath(targetRepo, installName))
529
+ return discovery.isDirectory() && !discovery.isSymbolicLink()
530
+ } catch (error) {
531
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
532
+ throw error
533
+ }
534
+ }
535
+
536
+ export async function abUpgrade(opts: {
537
+ targetRepo: string
538
+ distRoot?: string
539
+ exec?: Exec
540
+ resolveConflict?: ResolveConflict
541
+ stdout?: (line: string) => void
542
+ }): Promise<UpgradeReport> {
543
+ const distRoot = opts.distRoot ?? defaultDistRoot()
544
+ const exec = opts.exec ?? spawnExec
545
+ const stdout = opts.stdout ?? (() => {})
546
+ const { targetRepo } = opts
547
+
548
+ await migrateLegacyAgentSkills(targetRepo)
549
+
550
+ const skills: UpgradeReport['skills'] = []
551
+ const discoveryConflictMap = new Map<string, SkillDiscoveryConflict>()
552
+ const containDiscoveryConflict = (error: unknown): void => {
553
+ if (!(error instanceof ClaudeSkillDiscoveryConflict)) throw error
554
+ discoveryConflictMap.set(error.skill, { skill: error.skill, message: error.message })
555
+ }
556
+ const report = (skill: string, action: UpgradeSkillAction, detail?: string): void => {
557
+ skills.push({ skill, action, ...(detail !== undefined ? { detail } : {}) })
558
+ }
559
+ const precedence: UpgradeSkillAction[] = [
560
+ 'current',
561
+ 'adopted',
562
+ 'merged',
563
+ 'resolved',
564
+ 'conflicted',
565
+ ]
566
+
567
+ const dist = await readDistSkills(distRoot)
568
+ for (const skill of dist) {
569
+ const name = skill.installName
570
+ const migrated = await migrateLegacySkill(targetRepo, name, stdout)
571
+ try {
572
+ await ensureClaudeSkillLink(targetRepo, name)
573
+ } catch (error) {
574
+ containDiscoveryConflict(error)
575
+ }
576
+
577
+ const incoming = new Map(skill.files.map((file) => [file.path, file.content]))
578
+ const pristineRoot = pristineSkillFilePath(targetRepo, name, 'SKILL.md')
579
+ const pristineFiles = new Set<string>()
580
+ const collectPristine = async (dir: string, prefix = ''): Promise<void> => {
581
+ let entries: Dirent[]
582
+ try {
583
+ entries = await readdir(dir, { withFileTypes: true })
584
+ } catch (error) {
585
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
586
+ throw error
587
+ }
588
+ for (const entry of entries) {
589
+ const path = prefix === '' ? entry.name : `${prefix}/${entry.name}`
590
+ if (entry.isDirectory()) await collectPristine(join(dir, entry.name), path)
591
+ else if (entry.isFile()) pristineFiles.add(path)
592
+ }
593
+ }
594
+ await collectPristine(dirname(pristineRoot))
595
+
596
+ const paths = [...new Set([...incoming.keys(), ...pristineFiles])].sort()
597
+ const initialLocals = new Map<string, string>()
598
+ for (const path of paths) {
599
+ const local =
600
+ path === 'SKILL.md' && migrated !== undefined
601
+ ? migrated
602
+ : await readIfExists(installedSkillFilePath(targetRepo, name, path))
603
+ if (local !== undefined) initialLocals.set(path, local)
604
+ }
605
+ const freshInstall = pristineFiles.size === 0 && initialLocals.size === 0
606
+ const outcomes: UpgradeSkillAction[] = []
607
+ const details: string[] = []
608
+ const conflictHints: Array<{ path: string; reason: string }> = []
609
+
610
+ for (const path of paths) {
611
+ const incomingText = incoming.get(path)
612
+ const livePath = installedSkillFilePath(targetRepo, name, path)
613
+ const pristinePath = pristineSkillFilePath(targetRepo, name, path)
614
+ const local = initialLocals.get(path)
615
+ const pristine = await readIfExists(pristinePath)
616
+
617
+ // New upstream support file (or an old install without a per-file base).
618
+ if (pristine === undefined) {
619
+ if (incomingText === undefined) continue
620
+ if (local === undefined || local === incomingText) {
621
+ await writeInstalledSkillFile(targetRepo, name, path, incomingText)
622
+ await writePristineFile(targetRepo, name, path, incomingText)
623
+ outcomes.push('adopted')
624
+ if (path === 'SKILL.md' && local === incomingText) {
625
+ details.push('SKILL.md: no pristine record; local already matches the new default')
626
+ }
627
+ } else {
628
+ const reason =
629
+ 'no pristine record and local differs from the new default — refusing to ' +
630
+ 'clobber; merge by hand or re-run `ab init --force` to adopt the default'
631
+ outcomes.push('conflicted')
632
+ details.push(`${path}: ${reason}`)
633
+ conflictHints.push({ path, reason })
634
+ }
635
+ continue
636
+ }
637
+
638
+ // Upstream removed a formerly distributed support file. An unedited
639
+ // copy can be removed safely. A customized copy becomes an ordinary
640
+ // repository-local support file and is never silently deleted.
641
+ if (incomingText === undefined) {
642
+ if (local === undefined || local === pristine) {
643
+ await rm(livePath, { force: true })
644
+ await rm(pristinePath, { force: true })
645
+ outcomes.push('adopted')
646
+ } else {
647
+ await rm(pristinePath, { force: true })
648
+ outcomes.push('merged')
649
+ details.push(`${path}: upstream removed this file; kept the locally customized copy`)
650
+ }
651
+ continue
652
+ }
653
+
654
+ // A missing live file is restored independently. This must not make a
655
+ // partially present skill overwrite customized siblings.
656
+ if (local === undefined) {
657
+ await writeInstalledSkillFile(targetRepo, name, path, incomingText)
658
+ await writePristineFile(targetRepo, name, path, incomingText)
659
+ outcomes.push('adopted')
660
+ continue
661
+ }
662
+ if (incomingText === pristine) {
663
+ outcomes.push('current')
664
+ continue
665
+ }
666
+ if (local === pristine) {
667
+ await writeInstalledSkillFile(targetRepo, name, path, incomingText)
668
+ await writePristineFile(targetRepo, name, path, incomingText)
669
+ outcomes.push('adopted')
670
+ continue
671
+ }
672
+ const merge = await mergeFile(exec, {
673
+ base: pristine,
674
+ local,
675
+ incoming: incomingText,
676
+ })
677
+ if (merge.clean) {
678
+ await writeInstalledSkillFile(targetRepo, name, path, merge.text)
679
+ await writePristineFile(targetRepo, name, path, incomingText)
680
+ outcomes.push('merged')
681
+ continue
682
+ }
683
+
684
+ const keepConflict = (reason: string): void => {
685
+ outcomes.push('conflicted')
686
+ details.push(`${path}: ${reason}\n\nmarked merge diagnostic (not written):\n${merge.text}`)
687
+ conflictHints.push({ path, reason })
688
+ }
689
+ if (opts.resolveConflict === undefined) {
690
+ keepConflict('agent resolution unavailable')
691
+ continue
692
+ }
693
+
694
+ let resolved: string | null
695
+ try {
696
+ resolved = await opts.resolveConflict({
697
+ skill: name,
698
+ path,
699
+ base: pristine,
700
+ local,
701
+ incoming: incomingText,
702
+ })
703
+ } catch (error) {
704
+ keepConflict(`agent resolution failed: ${errorMessage(error)}`)
705
+ continue
706
+ }
707
+ if (resolved === null) {
708
+ keepConflict('agent declined because the correct resolution is ambiguous')
709
+ continue
710
+ }
711
+
712
+ const invalid = validateConflictResolution({
713
+ skill: name,
714
+ path,
715
+ candidate: resolved,
716
+ markedMerge: merge.text,
717
+ labels: merge.labels,
718
+ })
719
+ if (invalid !== undefined) {
720
+ keepConflict(`agent resolution was invalid: ${invalid}`)
721
+ continue
722
+ }
723
+
724
+ await writeInstalledSkillFile(targetRepo, name, path, resolved)
725
+ await writePristineFile(targetRepo, name, path, incomingText)
726
+ outcomes.push('resolved')
727
+ }
728
+
729
+ const aggregatedAction = outcomes.reduce<UpgradeSkillAction>(
730
+ (highest, outcome) =>
731
+ precedence.indexOf(outcome) > precedence.indexOf(highest) ? outcome : highest,
732
+ 'current',
733
+ )
734
+ const action: UpgradeSkillAction = freshInstall ? 'installed' : aggregatedAction
735
+ const detail = details.length === 0 ? undefined : details.join('\n\n')
736
+ report(name, action, detail)
737
+ if (action === 'conflicted') {
738
+ const conflict = conflictHints[0] ?? {
739
+ path: 'SKILL.md',
740
+ reason: 'manual merge required',
741
+ }
742
+ stdout(
743
+ `${name}: conflicted — ${conflict.reason}; kept your local file ` +
744
+ `(merge by hand against .agents/skills/.ab-pristine/${name}/${conflict.path})`,
745
+ )
746
+ } else {
747
+ stdout(`${name}: ${action}`)
748
+ }
749
+ }
750
+
751
+ const distNames = new Set(dist.map((skill) => skill.installName))
752
+ const configured = await configuredAgentSkills(targetRepo)
753
+ for (const name of RETIRED_SKILLS) {
754
+ // A distribution that still ships the name owns it through the ordinary
755
+ // merge path. Retirement begins only once the incoming distribution drops it.
756
+ if (distNames.has(name)) continue
757
+
758
+ const liveRoot = dirname(installedSkillPath(targetRepo, name))
759
+ const pristineRoot = dirname(pristineSkillFilePath(targetRepo, name, 'SKILL.md'))
760
+ let pristine: InspectedTree
761
+ let live: InspectedTree
762
+ try {
763
+ ;[pristine, live] = await Promise.all([inspectTree(pristineRoot), inspectTree(liveRoot)])
764
+ } catch (error) {
765
+ // If provenance itself cannot be inspected, leave all bytes and surface
766
+ // the infrastructure error rather than guessing that deletion is safe.
767
+ report(name, 'kept', `could not inspect retirement candidate safely: ${errorMessage(error)}`)
768
+ stdout(`${name}: kept (could not inspect retirement candidate safely)`)
769
+ continue
770
+ }
771
+ // No pristine provenance means this is repository-authored (or a prior
772
+ // retirement already cleared ownership). Do not migrate a surviving
773
+ // user-owned Claude entry back into the canonical tree on a later run.
774
+ if (!pristine.exists) continue
775
+
776
+ // Ownership is an input to retirement, never something retirement may
777
+ // establish. In particular, ordinary link maintenance replaces foreign
778
+ // symlinks, so inspect before either legacy migration or link repair.
779
+ const foreignClaudeLink = (await claudeLinkOwnership(targetRepo, name)) === 'foreign'
780
+ if (foreignClaudeLink) {
781
+ containDiscoveryConflict(
782
+ new ClaudeSkillDiscoveryConflict(targetRepo, name, 'foreign-symlink'),
783
+ )
784
+ }
785
+
786
+ // Preserve legacy migration for complete canonical installations, but a
787
+ // missing canonical tree or foreign discovery symlink must be classified
788
+ // before discovery maintenance can move or replace the user-owned entry.
789
+ if (live.exists && !foreignClaudeLink) {
790
+ await migrateLegacySkill(targetRepo, name, stdout)
791
+ try {
792
+ ;[pristine, live] = await Promise.all([inspectTree(pristineRoot), inspectTree(liveRoot)])
793
+ } catch (error) {
794
+ report(
795
+ name,
796
+ 'kept',
797
+ `could not inspect retirement candidate safely: ${errorMessage(error)}`,
798
+ )
799
+ stdout(`${name}: kept (could not inspect retirement candidate safely)`)
800
+ continue
801
+ }
802
+ }
803
+
804
+ if (!live.exists) {
805
+ const distinctClaudeDirectory = await hasDistinctClaudeDirectory(targetRepo, name)
806
+ if (distinctClaudeDirectory) {
807
+ containDiscoveryConflict(new ClaudeSkillDiscoveryConflict(targetRepo, name))
808
+ }
809
+ await removeOwnedClaudeLink(targetRepo, name)
810
+ await rm(pristineRoot, { recursive: true, force: true })
811
+
812
+ const userOwnedDiscoveryEntry = foreignClaudeLink || distinctClaudeDirectory
813
+ const action: UpgradeSkillAction = userOwnedDiscoveryEntry ? 'kept' : 'removed'
814
+ const detail = userOwnedDiscoveryEntry
815
+ ? 'retired canonical tree was already missing; user-owned Claude discovery entry remains'
816
+ : 'retired distribution skill; installed tree was already missing'
817
+ report(name, action, detail)
818
+ stdout(`${name}: ${action} (${detail})`)
819
+ continue
820
+ }
821
+
822
+ let userOwnedDiscoveryEntry = foreignClaudeLink
823
+ if (!foreignClaudeLink) {
824
+ try {
825
+ await ensureClaudeSkillLink(targetRepo, name)
826
+ } catch (error) {
827
+ if (error instanceof ClaudeSkillDiscoveryConflict) userOwnedDiscoveryEntry = true
828
+ containDiscoveryConflict(error)
829
+ }
830
+ }
831
+
832
+ const mismatch = treeMismatch(live, pristine)
833
+ const reason =
834
+ 'error' in configured
835
+ ? `could not safely inspect autobuild.toml references: ${configured.error}`
836
+ : configured.skills.has(name)
837
+ ? `still referenced by autobuild.toml as an agent step skill`
838
+ : mismatch === undefined
839
+ ? undefined
840
+ : `locally customized: ${mismatch}`
841
+
842
+ if (reason !== undefined) {
843
+ // Relinquish obsolete ownership. The live tree is now an ordinary local
844
+ // skill, so later upgrades neither repeat this report nor remove it.
845
+ await rm(pristineRoot, { recursive: true, force: true })
846
+ report(name, 'kept', reason)
847
+ stdout(`${name}: kept (${reason})`)
848
+ continue
849
+ }
850
+
851
+ await removeOwnedClaudeLink(targetRepo, name)
852
+ await rm(liveRoot, { recursive: true, force: true })
853
+ await rm(pristineRoot, { recursive: true, force: true })
854
+ if (userOwnedDiscoveryEntry) {
855
+ const detail =
856
+ 'retired canonical tree matched pristine and was removed; user-owned Claude discovery entry remains'
857
+ report(name, 'kept', detail)
858
+ stdout(`${name}: kept (${detail})`)
859
+ } else {
860
+ report(name, 'removed', 'retired distribution skill; installed tree matched pristine')
861
+ stdout(`${name}: removed (retired distribution skill; installed tree matched pristine)`)
862
+ }
863
+ }
864
+
865
+ const retiredNames = new Set<string>(RETIRED_SKILLS)
866
+ for (const name of await listInstalledSkills(targetRepo)) {
867
+ if (distNames.has(name) || retiredNames.has(name)) continue
868
+ try {
869
+ await ensureClaudeSkillLink(targetRepo, name)
870
+ } catch (error) {
871
+ containDiscoveryConflict(error)
872
+ }
873
+ report(name, 'unknown', 'not in the distribution — left alone (local addition)')
874
+ stdout(`${name}: unknown (not in the distribution — left alone)`)
875
+ }
876
+
877
+ const discoveryConflicts = [...discoveryConflictMap.values()]
878
+ if (discoveryConflicts.length > 0) {
879
+ stdout('Claude discovery conflicts:')
880
+ for (const conflict of discoveryConflicts) stdout(` ${conflict.message}`)
881
+ }
882
+
883
+ return {
884
+ skills,
885
+ discoveryConflicts,
886
+ exitCode: discoveryConflicts.length > 0 ? 1 : 0,
887
+ }
888
+ }