Skip to content

Commit ba18936

Browse files
clkaoclaude
andauthored
Bump skill↔binary contract version 1->2 so 0.23.0 skills reject a contract-1 binary (#443)
The 0.23.0 skills hard-require binary verbs absent from a contract-1 (v0.22.0) binary (state ready/commit/sweep, merge guard) but the contract version stayed 1, so an upgrade-skills-not-binary user passed the boot gate (1 ∈ [1,2)) and broke cryptically on the missing verbs instead of getting the clean "binary too old — upgrade" abort. - CONTRACT_VERSION 1 -> 2 (internal/contract/contract.go); the only non-test source change — no verb behavior touched. - Both plugin manifests requires-contract >=1,<2 -> >=2,<3. - first-officer-shared-core Startup contract-version-gate range text -> >=2,<3 (byte-neutral: same length, stays 28583). - Doctor/bracketing fixtures retargeted to keep their verdicts: compatible.json and vendored testdata/plugin.json -> >=2,<3; too-old-binary.json -> >=3,<4. - TestCompatibleUpgradeHint cases pairing CONTRACT_VERSION with the range, the gate predicate cases, and the install/decoupling/codex manifest fixtures tracked to the new pair. - New skills/integration AC-1 skew test: the live repo range rejects a contract-1 binary (too-old-binary) and admits the current CONTRACT_VERSION — divergeable, fails if either side is left at 1 / >=1,<2. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 55039e7 commit ba18936

16 files changed

Lines changed: 80 additions & 24 deletions

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"automation"
1717
],
1818
"skills": "./skills/",
19-
"requires-contract": ">=1,<2",
19+
"requires-contract": ">=2,<3",
2020
"interface": {
2121
"displayName": "Spacedock",
2222
"shortDescription": "Plain text workflows for AI agents",

.codex-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"automation"
1717
],
1818
"skills": "./skills/",
19-
"requires-contract": ">=1,<2",
19+
"requires-contract": ">=2,<3",
2020
"interface": {
2121
"displayName": "Spacedock",
2222
"shortDescription": "Plain text workflows for AI agents",

internal/cli/codex_name_match_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ func buildCodexNamedMarketplace(t *testing.T, root, marketplaceName, entryName s
100100
}
101101
`)
102102
mustWrite(t, filepath.Join(plugin, ".codex-plugin", "plugin.json"),
103-
`{ "name": "spacedock", "version": "0.0.0", "requires-contract": ">=1,<2", "skills": "./skills/" }
103+
`{ "name": "spacedock", "version": "0.0.0", "requires-contract": ">=2,<3", "skills": "./skills/" }
104104
`)
105105
mustWrite(t, filepath.Join(plugin, "skills", "demo", "SKILL.md"), "---\nname: demo\ndescription: demo skill\n---\ndemo\n")
106106
return marketplace

internal/cli/decoupling_behavior_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ func buildPluginGitRepo(t *testing.T, root string) string {
121121
func writePluginVersion(t *testing.T, root, version string) {
122122
t.Helper()
123123
mustWrite(t, filepath.Join(root, ".claude-plugin", "plugin.json"),
124-
fmt.Sprintf(`{ "name": "spacedock", "version": "%s", "requires-contract": ">=1,<2", "skills": "./skills/" }`+"\n", version))
124+
fmt.Sprintf(`{ "name": "spacedock", "version": "%s", "requires-contract": ">=2,<3", "skills": "./skills/" }`+"\n", version))
125125
mustWrite(t, filepath.Join(root, "skills", "demo", "SKILL.md"),
126126
fmt.Sprintf("---\nname: demo\ndescription: demo\n---\nbody v%s\n", version))
127127
}

internal/cli/frontdoor_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func (f *fakeHost) Install(host, source, branch string) (string, error) {
4545
}
4646

4747
// compatibleManifest returns a fixture path whose requires-contract brackets
48-
// CONTRACT_VERSION (the testdata/compatible.json fixture is >=1,<2).
48+
// CONTRACT_VERSION (the testdata/compatible.json fixture is >=2,<3).
4949
func compatibleManifest(t *testing.T) string {
5050
t.Helper()
5151
p, err := filepath.Abs(filepath.Join("..", "contract", "testdata", "compatible.json"))

internal/cli/install_behavior_codex_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ func buildCodexMarketplaceAtVersion(t *testing.T, root, version string) string {
184184
}
185185
`)
186186
mustWrite(t, filepath.Join(plugin, ".codex-plugin", "plugin.json"),
187-
`{ "name": "spacedock", "version": "`+version+`", "requires-contract": ">=1,<2", "skills": "./skills/" }
187+
`{ "name": "spacedock", "version": "`+version+`", "requires-contract": ">=2,<3", "skills": "./skills/" }
188188
`)
189189
mustWrite(t, filepath.Join(plugin, "skills", "demo", "SKILL.md"), "---\nname: demo\ndescription: demo skill\n---\ndemo\n")
190190
return marketplace

internal/cli/install_behavior_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ func TestClaudePluginInstallIsHostNative(t *testing.T) {
7878

7979
// buildLocalMarketplace writes a minimal valid local-path marketplace under root
8080
// and returns the marketplace directory. The plugin manifest carries a
81-
// requires-contract bracketing CONTRACT_VERSION (>=1,<2).
81+
// requires-contract bracketing CONTRACT_VERSION (>=2,<3).
8282
func buildLocalMarketplace(t *testing.T, root string) string {
8383
t.Helper()
8484
marketplace := filepath.Join(root, "marketplace")
@@ -95,7 +95,7 @@ func buildLocalMarketplace(t *testing.T, root string) string {
9595
]
9696
}
9797
`)
98-
mustWrite(t, filepath.Join(plugin, ".claude-plugin", "plugin.json"), `{ "name": "spacedock", "version": "0.0.0", "requires-contract": ">=1,<2", "skills": "./skills/" }
98+
mustWrite(t, filepath.Join(plugin, ".claude-plugin", "plugin.json"), `{ "name": "spacedock", "version": "0.0.0", "requires-contract": ">=2,<3", "skills": "./skills/" }
9999
`)
100100
mustWrite(t, filepath.Join(plugin, "skills", "demo", "SKILL.md"), "---\nname: demo\ndescription: demo skill\n---\ndemo\n")
101101
return marketplace

internal/cli/upgrade_from_stale_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616
// installed from a stale marketplace (no requires-contract, the 0.12.1 shape)
1717
// resolves to the plugin-predates-contract verdict (exit 1, the dead-end the
1818
// captain hit); running installArgvSequence against an upgraded marketplace
19-
// (requires-contract >=1,<2) then leaves doctor reporting compatible (exit 0).
19+
// (requires-contract >=2,<3) then leaves doctor reporting compatible (exit 0).
2020
// This proves plain `plugin install` no-ops on an already-installed plugin and
2121
// the inserted `plugin uninstall` is what moves the stale install off. The
2222
// remove step is tolerated — a fresh `marketplace remove` may exit 1 in some

internal/contract/contract.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import (
1414
// display semver and from the build version. Bump it only when a change to the
1515
// binary alters the observable surface the FO/ensign contracts call — never as a
1616
// side effect of a routine release bump (see the entity's OPEN-2 bump discipline).
17-
const CONTRACT_VERSION = 1
17+
const CONTRACT_VERSION = 2
1818

1919
// Verdict is the compatibility class produced by comparing a binary's contract
2020
// version against a plugin's declared requires-contract range.

internal/contract/contract_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ func TestCompare(t *testing.T) {
106106
func TestCompatibleUpgradeHint(t *testing.T) {
107107
t.Run("behind-plugin-hints", func(t *testing.T) {
108108
for _, host := range []string{"claude", "codex"} {
109-
res := Compare(CONTRACT_VERSION, ">=1,<2", host, "0.19.8", "0.20.0")
109+
res := Compare(CONTRACT_VERSION, ">=2,<3", host, "0.19.8", "0.20.0")
110110
if res.Verdict != Compatible {
111111
t.Fatalf("host %s: verdict = %v, want Compatible (the hint must not change the verdict)", host, res.Verdict)
112112
}
@@ -124,7 +124,7 @@ func TestCompatibleUpgradeHint(t *testing.T) {
124124

125125
// Negative: equal versions carry no hint — there is nothing to upgrade to.
126126
t.Run("equal-version-no-hint", func(t *testing.T) {
127-
res := Compare(CONTRACT_VERSION, ">=1,<2", "claude", "0.20.0", "0.20.0")
127+
res := Compare(CONTRACT_VERSION, ">=2,<3", "claude", "0.20.0", "0.20.0")
128128
if res.Verdict != Compatible {
129129
t.Fatalf("verdict = %v, want Compatible", res.Verdict)
130130
}
@@ -136,7 +136,7 @@ func TestCompatibleUpgradeHint(t *testing.T) {
136136
// Negative: an unstamped `dev` binary version is not valid semver — the hint
137137
// must not fire (no false "you must upgrade" against a dev build).
138138
t.Run("dev-binary-no-hint", func(t *testing.T) {
139-
res := Compare(CONTRACT_VERSION, ">=1,<2", "claude", "0.19.8", "dev")
139+
res := Compare(CONTRACT_VERSION, ">=2,<3", "claude", "0.19.8", "dev")
140140
if res.Verdict != Compatible {
141141
t.Fatalf("verdict = %v, want Compatible", res.Verdict)
142142
}
@@ -148,7 +148,7 @@ func TestCompatibleUpgradeHint(t *testing.T) {
148148
// Negative: a binary OLDER than the plugin (but still contract-compatible)
149149
// carries no hint — the hint is for a behind plugin, not a behind binary.
150150
t.Run("older-binary-no-hint", func(t *testing.T) {
151-
res := Compare(CONTRACT_VERSION, ">=1,<2", "claude", "0.21.0", "0.20.0")
151+
res := Compare(CONTRACT_VERSION, ">=2,<3", "claude", "0.21.0", "0.20.0")
152152
if res.Verdict != Compatible {
153153
t.Fatalf("verdict = %v, want Compatible", res.Verdict)
154154
}
@@ -162,7 +162,7 @@ func TestCompatibleUpgradeHint(t *testing.T) {
162162
// sorts BEFORE "0.9.0" ("1" < "9"), so a lexical-compare regression of
163163
// semverCompare would wrongly suppress the hint here. Pins the integer compare.
164164
t.Run("behind-plugin-double-digit-minor-hints", func(t *testing.T) {
165-
res := Compare(CONTRACT_VERSION, ">=1,<2", "claude", "0.9.0", "0.10.0")
165+
res := Compare(CONTRACT_VERSION, ">=2,<3", "claude", "0.9.0", "0.10.0")
166166
if res.Verdict != Compatible {
167167
t.Fatalf("verdict = %v, want Compatible", res.Verdict)
168168
}
@@ -176,7 +176,7 @@ func TestCompatibleUpgradeHint(t *testing.T) {
176176
// so a lexical-compare regression would wrongly FIRE the hint on this older
177177
// binary. The two boundary cases together RED any lexical compare.
178178
t.Run("older-binary-double-digit-minor-no-hint", func(t *testing.T) {
179-
res := Compare(CONTRACT_VERSION, ">=1,<2", "claude", "0.10.0", "0.9.0")
179+
res := Compare(CONTRACT_VERSION, ">=2,<3", "claude", "0.10.0", "0.9.0")
180180
if res.Verdict != Compatible {
181181
t.Fatalf("verdict = %v, want Compatible", res.Verdict)
182182
}
@@ -189,7 +189,7 @@ func TestCompatibleUpgradeHint(t *testing.T) {
189189
// dotted-int semver — the conservative gate emits no hint. Pins that
190190
// parseDottedInts rejects a `-rc1` suffix rather than stripping it.
191191
t.Run("prerelease-binary-no-hint", func(t *testing.T) {
192-
res := Compare(CONTRACT_VERSION, ">=1,<2", "claude", "0.19.8", "0.20.0-rc1")
192+
res := Compare(CONTRACT_VERSION, ">=2,<3", "claude", "0.19.8", "0.20.0-rc1")
193193
if res.Verdict != Compatible {
194194
t.Fatalf("verdict = %v, want Compatible", res.Verdict)
195195
}

0 commit comments

Comments
 (0)