@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.
- package/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- 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
|
+
}
|