Skip to content

Commit 8a63a6c

Browse files
committed
fix(skills): rebind profiles across plugin upgrades
1 parent a53d2b9 commit 8a63a6c

3 files changed

Lines changed: 105 additions & 3 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ skill paths without starting the server. Without `--skills`, a workspace
5050
`gestalt-skills.yml` is used when present; otherwise Codex-native selection is
5151
preserved. Explicit profiles take precedence over project defaults, which take
5252
precedence over native configuration. Gestalt Mobile never rewrites Codex
53-
configuration or skill files.
53+
configuration or skill files. Exact skill paths remain authoritative, while
54+
paths inside Codex's versioned plugin cache are rebound to the currently
55+
discovered plugin version when their marketplace, plugin, and skill-relative
56+
path still match.
5457

5558
## Themes
5659

src/server/features/skills/model/skill-profile.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,67 @@ describe('skill profile codec', () => {
128128
});
129129
});
130130

131+
it('rebinds a saved plugin skill across cache versions', () => {
132+
const currentPath =
133+
'/home/test/.codex/plugins/cache/dyne-gestalt-agents/gestalt/2.2.0/skills/development-testing/SKILL.md';
134+
const result = compileSkillOverride({
135+
discovered: [
136+
{ name: 'gestalt:development-testing', path: currentPath, enabled: false },
137+
{ ...alpha, enabled: true },
138+
],
139+
explicit: createSkillSelection([
140+
{
141+
name: 'gestalt:development-testing',
142+
path: '/home/test/.codex/plugins/cache/dyne-gestalt-agents/gestalt/2.1.0/skills/development-testing/SKILL.md',
143+
enabled: true,
144+
},
145+
]),
146+
});
147+
148+
expect(result).toEqual({
149+
source: 'explicit',
150+
skillsConfig: [
151+
{ path: currentPath, enabled: true },
152+
{ path: '/skills/alpha/SKILL.md', enabled: false },
153+
],
154+
warnings: [],
155+
});
156+
});
157+
158+
it('does not rebind a stale plugin skill to a different plugin or skill path', () => {
159+
const stalePath =
160+
'/home/test/.codex/plugins/cache/dyne-gestalt-agents/gestalt/2.1.0/skills/development-testing/SKILL.md';
161+
const result = compileSkillOverride({
162+
discovered: [
163+
{
164+
name: 'other:development-testing',
165+
path: '/home/test/.codex/plugins/cache/other-marketplace/other-plugin/2.2.0/skills/development-testing/SKILL.md',
166+
enabled: true,
167+
},
168+
{
169+
name: 'gestalt:verification-before-completion',
170+
path: '/home/test/.codex/plugins/cache/dyne-gestalt-agents/gestalt/2.2.0/skills/verification-before-completion/SKILL.md',
171+
enabled: true,
172+
},
173+
],
174+
explicit: createSkillSelection([
175+
{ name: 'gestalt:development-testing', path: stalePath, enabled: true },
176+
]),
177+
});
178+
179+
expect(result.skillsConfig).toEqual([
180+
{
181+
path: '/home/test/.codex/plugins/cache/dyne-gestalt-agents/gestalt/2.2.0/skills/verification-before-completion/SKILL.md',
182+
enabled: false,
183+
},
184+
{
185+
path: '/home/test/.codex/plugins/cache/other-marketplace/other-plugin/2.2.0/skills/development-testing/SKILL.md',
186+
enabled: false,
187+
},
188+
]);
189+
expect(result.warnings).toEqual([`Saved skill path is no longer discovered: ${stalePath}`]);
190+
});
191+
131192
it('emits no override when neither explicit nor project selection exists', () => {
132193
expect(compileSkillOverride({ discovered: [alpha] })).toEqual({
133194
source: 'native',

src/server/features/skills/model/skill-profile.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,43 @@ function canonicalSkillPath(value: string): string {
116116
return value;
117117
}
118118

119+
/**
120+
* Return the durable identity of a skill inside Codex's versioned plugin cache.
121+
* The concrete version directory is installation state, while marketplace,
122+
* plugin, and skill-relative path remain stable across plugin upgrades.
123+
*/
124+
function versionNeutralPluginSkillPath(value: string): string | undefined {
125+
const match = value.match(
126+
/^(.*[\\/]plugins[\\/]cache[\\/][^\\/]+[\\/][^\\/]+)[\\/][^\\/]+([\\/]skills[\\/].+[\\/]SKILL\.md)$/,
127+
);
128+
return match ? `${match[1]}${match[2]}` : undefined;
129+
}
130+
131+
function rebindVersionedPluginSkills(
132+
discovered: readonly AvailableSkill[],
133+
selection: SkillSelection,
134+
): SkillSelection {
135+
const discoveredPaths = new Set(discovered.map((skill) => canonicalSkillPath(skill.path)));
136+
const discoveredByDurablePath = new Map<string, AvailableSkill[]>();
137+
for (const skill of discovered) {
138+
const durablePath = versionNeutralPluginSkillPath(canonicalSkillPath(skill.path));
139+
if (durablePath === undefined) continue;
140+
const matches = discoveredByDurablePath.get(durablePath) ?? [];
141+
matches.push(skill);
142+
discoveredByDurablePath.set(durablePath, matches);
143+
}
144+
145+
return createSkillSelection(
146+
selection.map((entry) => {
147+
if (discoveredPaths.has(entry.path)) return entry;
148+
const durablePath = versionNeutralPluginSkillPath(entry.path);
149+
if (durablePath === undefined) return entry;
150+
const matches = discoveredByDurablePath.get(durablePath) ?? [];
151+
return matches.length === 1 ? { ...entry, path: matches[0].path } : entry;
152+
}),
153+
);
154+
}
155+
119156
/**
120157
* Validate a complete selection and return its canonical deterministic order.
121158
* This is lexical only: resolving symlinks is I/O and belongs to a platform
@@ -201,11 +238,12 @@ export function compileSkillOverride(input: {
201238
const effective = selectEffectiveSkillSelection(input);
202239
if (effective.selection === undefined)
203240
return { source: 'native', skillsConfig: undefined, warnings: [] };
241+
const reboundSelection = rebindVersionedPluginSkills(input.discovered, effective.selection);
204242
const discoveredPaths = new Set(input.discovered.map((skill) => canonicalSkillPath(skill.path)));
205-
const warnings = effective.selection
243+
const warnings = reboundSelection
206244
.filter((entry) => !discoveredPaths.has(entry.path))
207245
.map((entry) => `Saved skill path is no longer discovered: ${entry.path}`);
208-
const configured = applySkillSelectionSnapshot(input.discovered, effective.selection);
246+
const configured = applySkillSelectionSnapshot(input.discovered, reboundSelection);
209247
return {
210248
source: effective.source,
211249
skillsConfig: configured

0 commit comments

Comments
 (0)