Browse Source

fix(skills): implement robust managed skill synchronization and customization tracking

alvinreal 4 weeks ago
parent
commit
0d6bf25372

+ 8 - 5
docs/installation.md

@@ -99,11 +99,14 @@ bunx oh-my-opencode-slim@latest install --reset
 
 The installer generates both OpenAI and OpenCode Go presets, with OpenAI active by default (using variant-aware `gpt-5.5` and `gpt-5.4-mini` models, including `gpt-5.5 (medium)` for Orchestrator, `gpt-5.5 (high)` for Oracle, `gpt-5.5 (low)` for Fixer, and `gpt-5.4-mini` variants for other specialists). To make OpenCode Go active during install, run `bunx oh-my-opencode-slim@latest install --preset=opencode-go`. That preset uses GLM-5.1 for Orchestrator, so the installer also enables Observer with `opencode-go/kimi-k2.6` for visual analysis. To switch providers later or build a mixed setup, use **[Configuration Reference](configuration.md)** for the full option reference and the preset docs for copyable examples.
 
-When auto-update successfully installs a newer package version, it also copies
-new bundled skills from that updated package into your OpenCode skills directory
-if they are missing. This is additive only: existing skill folders are skipped,
-and skills are never removed automatically. Restart OpenCode after an auto-update
-to load the updated plugin and any newly copied skills.
+The plugin safely reconciles bundled skills on startup and after successful
+auto-updates. Missing bundled skills are installed, and previously managed skills
+are updated only when their local files still match a known plugin-installed
+version. If you customized a skill locally, the plugin preserves your active copy
+and stages the new bundled version under
+`~/.config/opencode/.oh-my-opencode-slim/skill-updates/` for manual review.
+Restart OpenCode after an auto-update to load the updated plugin and any changed
+skills.
 
 Then:
 

+ 4 - 0
docs/release.md

@@ -203,6 +203,10 @@ bun test
 bun run build
 ```
 
+### Skill Synchronization Hashes Gate
+
+If this release changes any bundled skill content (under `src/skills/`), you must populate the `LEGACY_MANAGED_SKILL_HASHES` table in `src/hooks/auto-update-checker/skill-sync.ts` with the hashes of the previously published versions of those skills (obtained from published npm package tarballs). This ensures existing users' installations are adopted and upgraded safely. If this is a migration-only release without skill changes, confirm the table is kept as-is.
+
 Before committing or tagging, inspect:
 
 ```bash

+ 4 - 1
docs/skills.md

@@ -2,7 +2,10 @@
 
 Skills are specialized capabilities you can assign to agents. Unlike MCPs (which are running servers), skills are **prompt-based tool configurations** — instructions injected into an agent's system prompt that describe how to use a particular tool.
 
-Bundled skills are installed by the `oh-my-opencode-slim` installer.
+Bundled skills are installed by the `oh-my-opencode-slim` installer and safely
+reconciled on plugin startup/auto-update. Local customizations are preserved;
+new bundled versions for customized skills are staged under
+`~/.config/opencode/.oh-my-opencode-slim/skill-updates/` for manual review.
 
 ---
 

+ 20 - 50
src/cli/custom-skills.ts

@@ -1,11 +1,4 @@
-import {
-  copyFileSync,
-  existsSync,
-  mkdirSync,
-  readdirSync,
-  statSync,
-} from 'node:fs';
-import { dirname, join } from 'node:path';
+import { join } from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { getConfigDir } from './paths';
 
@@ -90,56 +83,33 @@ export function getCustomSkillsDir(): string {
   return join(getConfigDir(), 'skills');
 }
 
-/**
- * Recursively copy a directory.
- */
-function copyDirRecursive(src: string, dest: string): void {
-  if (!existsSync(dest)) {
-    mkdirSync(dest, { recursive: true });
-  }
-
-  const entries = readdirSync(src);
-  for (const entry of entries) {
-    const srcPath = join(src, entry);
-    const destPath = join(dest, entry);
-    const stat = statSync(srcPath);
-
-    if (stat.isDirectory()) {
-      copyDirRecursive(srcPath, destPath);
-    } else {
-      const destDir = dirname(destPath);
-      if (!existsSync(destDir)) {
-        mkdirSync(destDir, { recursive: true });
-      }
-      copyFileSync(srcPath, destPath);
-    }
-  }
-}
-
 /**
  * Install a custom skill by copying from src/skills/ to the OpenCode skills directory
  * @param skill - The custom skill to install
- * @param projectRoot - Root directory of oh-my-opencode-slim project
  * @returns True if installation succeeded, false otherwise
+ * @deprecated Use syncBundledSkillsFromPackage instead.
  */
-export function installCustomSkill(skill: CustomSkill): boolean {
+export async function installCustomSkill(skill: CustomSkill): Promise<boolean> {
+  console.warn(
+    `[DEPRECATED] installCustomSkill is deprecated and will be removed. Use syncBundledSkillsFromPackage instead.`,
+  );
   try {
+    const { syncBundledSkillsFromPackage } = await import(
+      '../hooks/auto-update-checker/skill-sync'
+    );
     const packageRoot = fileURLToPath(new URL('../..', import.meta.url));
-    const sourcePath = join(packageRoot, skill.sourcePath);
-    const targetPath = join(getCustomSkillsDir(), skill.name);
-
-    // Validate source exists
-    if (!existsSync(sourcePath)) {
-      console.error(`Custom skill source not found: ${sourcePath}`);
-      return false;
-    }
-
-    // Copy skill directory
-    copyDirRecursive(sourcePath, targetPath);
-
-    return true;
+    const result = syncBundledSkillsFromPackage(packageRoot, {
+      skills: [skill],
+    });
+    return (
+      result.installed.includes(skill.name) ||
+      result.skippedExisting.includes(skill.name)
+    );
   } catch (error) {
-    console.error(`Failed to install custom skill: ${skill.name}`, error);
+    console.error(
+      `Failed to install custom skill safely: ${skill.name}`,
+      error,
+    );
     return false;
   }
 }

+ 33 - 15
src/cli/install.ts

@@ -1,5 +1,7 @@
 import { existsSync } from 'node:fs';
 import { createInterface } from 'node:readline/promises';
+import { fileURLToPath } from 'node:url';
+import { syncBundledSkillsFromPackage } from '../hooks/auto-update-checker/skill-sync';
 import {
   detectBackgroundSubagentsTarget,
   expandHomePath,
@@ -22,7 +24,7 @@ import {
   warmOpenCodePluginCache,
   writeLiteConfig,
 } from './config-manager';
-import { CUSTOM_SKILLS, installCustomSkill } from './custom-skills';
+import { CUSTOM_SKILLS } from './custom-skills';
 import { getExistingLiteConfigPath } from './paths';
 import type { ConfigMergeResult, InstallArgs, InstallConfig } from './types';
 
@@ -410,27 +412,43 @@ async function runInstall(config: InstallConfig): Promise<number> {
 
   // Install custom skills if requested
   if (config.installCustomSkills) {
-    printStep(step++, totalSteps, 'Installing custom skills...');
+    printStep(step++, totalSteps, 'Synchronizing custom skills...');
     if (config.dryRun) {
-      printInfo('Dry run mode - would install custom skills:');
+      printInfo('Dry run mode - would synchronize custom skills:');
       for (const skill of CUSTOM_SKILLS) {
         printInfo(`  - ${skill.name}`);
       }
     } else {
-      let customSkillsInstalled = 0;
-      for (const skill of CUSTOM_SKILLS) {
-        printInfo(`Installing ${skill.name}...`);
-        if (installCustomSkill(skill)) {
-          printSuccess(`Installed: ${skill.name}`);
-          customSkillsInstalled++;
-        } else {
-          printInfo(`Skipped: ${skill.name} (already installed)`);
+      try {
+        const packageRoot = fileURLToPath(new URL('../..', import.meta.url));
+        const result = syncBundledSkillsFromPackage(packageRoot);
+
+        if (result.installed.length > 0) {
+          for (const skill of result.installed) {
+            printSuccess(`Installed/Updated: ${skill}`);
+          }
+        }
+        if (result.skippedExisting.length > 0) {
+          for (const skill of result.skippedExisting) {
+            printInfo(`Skipped/Preserved: ${skill}`);
+          }
+        }
+        if (result.failed.length > 0) {
+          for (const skill of result.failed) {
+            printError(`Failed: ${skill}`);
+          }
         }
+
+        const totalCustom = CUSTOM_SKILLS.length;
+        printSuccess(
+          `Skill synchronization complete. Processed ${totalCustom} skills: ` +
+            `${result.installed.length} installed/updated, ` +
+            `${result.skippedExisting.length} skipped/preserved, ` +
+            `${result.failed.length} failed.`,
+        );
+      } catch (err) {
+        printError(`Failed to synchronize custom skills: ${err}`);
       }
-      const totalCustom = CUSTOM_SKILLS.length;
-      printSuccess(
-        `${customSkillsInstalled}/${totalCustom} custom skills processed`,
-      );
     }
   }
 

+ 30 - 0
src/hooks/auto-update-checker/index.test.ts

@@ -633,4 +633,34 @@ describe('auto-update-checker/index', () => {
     expect(crossSpawnMock).not.toHaveBeenCalled();
     expect(skillSyncMocks.syncBundledSkillsFromPackage).not.toHaveBeenCalled();
   });
+
+  test('runs startup skill reconciliation even when already on latest version', async () => {
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: null,
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => '0.9.11');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
+    checkerMocks.getCurrentRuntimePackageJsonPath.mockImplementation(
+      () => '/tmp/opencode/package.json',
+    );
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx } = createCtx();
+
+    const hook = createAutoUpdateCheckerHook(ctx as never);
+    hook.event({ event: { type: 'session.created', properties: {} } });
+
+    await waitForCalls(logMock, 1);
+
+    expect(skillSyncMocks.syncBundledSkillsFromPackage).toHaveBeenCalledWith(
+      '/tmp/opencode',
+    );
+  });
 });

+ 32 - 0
src/hooks/auto-update-checker/index.ts

@@ -11,6 +11,7 @@ import {
   extractChannel,
   findPluginEntry,
   getCachedVersion,
+  getCurrentRuntimePackageJsonPath,
   getLatestCompatibleVersion,
   getLocalDevVersion,
 } from './checker';
@@ -60,6 +61,8 @@ export function createAutoUpdateCheckerHook(
   };
 }
 
+let hasReconciledAtStartup = false;
+
 /**
  * Orchestrates the version comparison and update process in the background.
  * @param ctx The plugin input context.
@@ -70,6 +73,35 @@ async function runBackgroundUpdateCheck(
   autoUpdate: boolean,
   companion: AutoUpdateCheckerOptions['companion'],
 ): Promise<void> {
+  // Startup reconciliation (run once per top-level startup)
+  if (!hasReconciledAtStartup) {
+    hasReconciledAtStartup = true;
+    try {
+      const runtimePackageJsonPath = getCurrentRuntimePackageJsonPath();
+      if (runtimePackageJsonPath) {
+        const packageRoot = path.dirname(runtimePackageJsonPath);
+        log('[auto-update-checker] Running startup skill reconciliation');
+        const syncResult = syncBundledSkillsFromPackage(packageRoot);
+        if (syncResult.installed.length > 0) {
+          log(
+            `[auto-update-checker] Startup skill sync installed: ${syncResult.installed.join(', ')}`,
+          );
+        }
+        if (syncResult.failed.length > 0) {
+          log(
+            `[auto-update-checker] Startup skill sync failures: ${syncResult.failed.join(', ')}`,
+          );
+        }
+      } else {
+        log(
+          '[auto-update-checker] Could not resolve runtime package path for startup skill reconciliation',
+        );
+      }
+    } catch (err) {
+      log('[auto-update-checker] Startup skill reconciliation failed:', err);
+    }
+  }
+
   const pluginInfo = findPluginEntry(ctx.directory);
   if (!pluginInfo) {
     log('[auto-update-checker] Plugin not found in config');

+ 573 - 3
src/hooks/auto-update-checker/skill-sync.test.ts

@@ -7,7 +7,21 @@ let importCounter = 0;
 
 async function syncBundledSkillsFromPackage(packageRoot: string) {
   const module = await import(`./skill-sync?test=${importCounter++}`);
-  return module.syncBundledSkillsFromPackage(packageRoot);
+  return module.syncBundledSkillsFromPackage(packageRoot, {
+    skills: getFakeManagedSkills(packageRoot),
+  });
+}
+
+function getFakeManagedSkills(packageRoot: string) {
+  const sourceSkillsDir = path.join(packageRoot, 'src', 'skills');
+  if (!fs.existsSync(sourceSkillsDir)) return [];
+  return fs
+    .readdirSync(sourceSkillsDir)
+    .filter((entry) => !entry.startsWith('.'))
+    .map((entry) => ({
+      name: entry,
+      sourcePath: path.relative(packageRoot, path.join(sourceSkillsDir, entry)),
+    }));
 }
 
 describe('syncBundledSkillsFromPackage', () => {
@@ -156,8 +170,8 @@ describe('syncBundledSkillsFromPackage', () => {
 
     // Verify no staging directories are left behind in destSkillsDir
     const destEntries = fs.readdirSync(destSkillsDir);
-    const stagingDirs = destEntries.filter((entry) =>
-      entry.startsWith('.sync-staging-'),
+    const stagingDirs = destEntries.filter(
+      (entry) => entry.startsWith('.staging-') || entry.startsWith('.backup-'),
     );
     expect(stagingDirs).toHaveLength(0);
   });
@@ -267,4 +281,560 @@ describe('syncBundledSkillsFromPackage', () => {
     const destSkillDir = path.join(fakeDestConfigDir, 'skills', symlinkSkill);
     expect(fs.existsSync(destSkillDir)).toBe(false);
   });
+
+  test('adopts and updates existing destination skill if it matches legacy official hashes (no manifest)', async () => {
+    const skillName = 'legacy-skill';
+    const legacyContent = 'old legacy skill content';
+
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(skillSrcDir, 'SKILL.md'),
+      '# Updated Legacy Skill',
+    );
+
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    fs.mkdirSync(destSkillsDir, { recursive: true });
+    const destSkillDir = path.join(destSkillsDir, skillName);
+    fs.mkdirSync(destSkillDir, { recursive: true });
+    fs.writeFileSync(path.join(destSkillDir, 'SKILL.md'), legacyContent);
+
+    const testIndex = importCounter++;
+    const {
+      computeDirectoryHash,
+      LEGACY_MANAGED_SKILL_HASHES: legHashes,
+      syncBundledSkillsFromPackage: syncFn,
+    } = await import(`./skill-sync?test=${testIndex}`);
+    const legacyHash = computeDirectoryHash(destSkillDir);
+
+    legHashes[skillName] = [legacyHash];
+
+    const result = syncFn(fakePackageRoot, {
+      skills: getFakeManagedSkills(fakePackageRoot),
+    });
+
+    expect(result.installed).toContain(skillName);
+    expect(fs.readFileSync(path.join(destSkillDir, 'SKILL.md'), 'utf-8')).toBe(
+      '# Updated Legacy Skill',
+    );
+
+    const manifestPath = path.join(
+      fakeDestConfigDir,
+      '.oh-my-opencode-slim',
+      'skills-manifest.json',
+    );
+    expect(fs.existsSync(manifestPath)).toBe(true);
+    const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
+    expect(manifest.skills[skillName].status).toBe('managed');
+
+    delete legHashes[skillName];
+  });
+
+  test('stages update and marks customized if managed skill was modified by user', async () => {
+    const skillName = 'custom-skill-test';
+
+    fs.writeFileSync(
+      path.join(fakePackageRoot, 'package.json'),
+      JSON.stringify({ version: '1.1.0' }),
+    );
+
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(skillSrcDir, 'SKILL.md'),
+      '# Current Bundled Skill',
+    );
+
+    const manifestDir = path.join(fakeDestConfigDir, '.oh-my-opencode-slim');
+    fs.mkdirSync(manifestDir, { recursive: true });
+    const manifestPath = path.join(manifestDir, 'skills-manifest.json');
+
+    const initialManifest = {
+      schemaVersion: 1,
+      updatedAt: new Date().toISOString(),
+      skills: {
+        [skillName]: {
+          status: 'managed',
+          packageVersion: '1.0.0',
+          sourceHash: 'old-source-hash',
+          lastManagedHash: 'old-managed-hash',
+          lastSeenHash: 'old-managed-hash',
+          updatedAt: new Date().toISOString(),
+        },
+      },
+    };
+    fs.writeFileSync(manifestPath, JSON.stringify(initialManifest, null, 2));
+
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    fs.mkdirSync(destSkillsDir, { recursive: true });
+    const destSkillDir = path.join(destSkillsDir, skillName);
+    fs.mkdirSync(destSkillDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(destSkillDir, 'SKILL.md'),
+      '# User Modified Skill',
+    );
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.skippedExisting).toContain(skillName);
+    expect(fs.readFileSync(path.join(destSkillDir, 'SKILL.md'), 'utf-8')).toBe(
+      '# User Modified Skill',
+    );
+
+    const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
+    const stagedPath = manifest.skills[skillName].stagedPath as string;
+    expect(stagedPath).toBeDefined();
+    expect(fs.existsSync(stagedPath)).toBe(true);
+    expect(fs.readFileSync(path.join(stagedPath, 'SKILL.md'), 'utf-8')).toBe(
+      '# Current Bundled Skill',
+    );
+
+    expect(manifest.skills[skillName].status).toBe('customized');
+  });
+
+  test('fails closed (only installs missing) when manifest is corrupt', async () => {
+    const missingSkill = 'missing-skill';
+    const existingSkill = 'existing-skill';
+
+    const missingSrcDir = path.join(
+      fakePackageRoot,
+      'src',
+      'skills',
+      missingSkill,
+    );
+    fs.mkdirSync(missingSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(missingSrcDir, 'SKILL.md'), '# Missing');
+
+    const existingSrcDir = path.join(
+      fakePackageRoot,
+      'src',
+      'skills',
+      existingSkill,
+    );
+    fs.mkdirSync(existingSrcDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(existingSrcDir, 'SKILL.md'),
+      '# Existing Source',
+    );
+
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    fs.mkdirSync(destSkillsDir, { recursive: true });
+    const destExistingDir = path.join(destSkillsDir, existingSkill);
+    fs.mkdirSync(destExistingDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(destExistingDir, 'SKILL.md'),
+      '# Existing Dest Original',
+    );
+
+    const manifestDir = path.join(fakeDestConfigDir, '.oh-my-opencode-slim');
+    fs.mkdirSync(manifestDir, { recursive: true });
+    const manifestPath = path.join(manifestDir, 'skills-manifest.json');
+    fs.writeFileSync(manifestPath, '{ corrupt json here');
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.installed).toContain(missingSkill);
+    expect(fs.existsSync(path.join(destSkillsDir, missingSkill))).toBe(true);
+
+    expect(result.skippedExisting).toContain(existingSkill);
+    expect(
+      fs.readFileSync(path.join(destExistingDir, 'SKILL.md'), 'utf-8'),
+    ).toBe('# Existing Dest Original');
+
+    expect(fs.readFileSync(manifestPath, 'utf-8')).toBe('{ corrupt json here');
+  });
+
+  test('prevents reinstall when manifest indicates skill was deleted by user', async () => {
+    const skillName = 'deleted-skill-test';
+
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Current');
+
+    const manifestDir = path.join(fakeDestConfigDir, '.oh-my-opencode-slim');
+    fs.mkdirSync(manifestDir, { recursive: true });
+    const manifestPath = path.join(manifestDir, 'skills-manifest.json');
+
+    const initialManifest = {
+      schemaVersion: 1,
+      updatedAt: new Date().toISOString(),
+      skills: {
+        [skillName]: {
+          status: 'deleted',
+          packageVersion: '1.0.0',
+          sourceHash: 'some-hash',
+          lastManagedHash: 'some-hash',
+          lastSeenHash: 'some-hash',
+          updatedAt: new Date().toISOString(),
+        },
+      },
+    };
+    fs.writeFileSync(manifestPath, JSON.stringify(initialManifest, null, 2));
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.installed).not.toContain(skillName);
+    expect(
+      fs.existsSync(path.join(fakeDestConfigDir, 'skills', skillName)),
+    ).toBe(false);
+  });
+
+  test('fails closed (only installs missing) when manifest validation fails (schemaVersion mismatch)', async () => {
+    const missingSkill = 'missing-skill';
+    const existingSkill = 'existing-skill';
+
+    const missingSrcDir = path.join(
+      fakePackageRoot,
+      'src',
+      'skills',
+      missingSkill,
+    );
+    fs.mkdirSync(missingSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(missingSrcDir, 'SKILL.md'), '# Missing');
+
+    const existingSrcDir = path.join(
+      fakePackageRoot,
+      'src',
+      'skills',
+      existingSkill,
+    );
+    fs.mkdirSync(existingSrcDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(existingSrcDir, 'SKILL.md'),
+      '# Existing Source',
+    );
+
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    fs.mkdirSync(destSkillsDir, { recursive: true });
+    const destExistingDir = path.join(destSkillsDir, existingSkill);
+    fs.mkdirSync(destExistingDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(destExistingDir, 'SKILL.md'),
+      '# Existing Dest Original',
+    );
+
+    const manifestDir = path.join(fakeDestConfigDir, '.oh-my-opencode-slim');
+    fs.mkdirSync(manifestDir, { recursive: true });
+    const manifestPath = path.join(manifestDir, 'skills-manifest.json');
+
+    const invalidManifest = {
+      schemaVersion: 2,
+      updatedAt: new Date().toISOString(),
+      skills: {
+        [existingSkill]: {
+          status: 'managed',
+          packageVersion: '1.0.0',
+          sourceHash: 'some-hash',
+          lastManagedHash: 'some-hash',
+          lastSeenHash: 'some-hash',
+          updatedAt: new Date().toISOString(),
+        },
+      },
+    };
+    fs.writeFileSync(manifestPath, JSON.stringify(invalidManifest, null, 2));
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.installed).toContain(missingSkill);
+    expect(fs.existsSync(path.join(destSkillsDir, missingSkill))).toBe(true);
+
+    expect(result.skippedExisting).toContain(existingSkill);
+    expect(
+      fs.readFileSync(path.join(destExistingDir, 'SKILL.md'), 'utf-8'),
+    ).toBe('# Existing Dest Original');
+  });
+
+  test('customized convergence: customized adopts back to managed when destHash equals current sourceHash', async () => {
+    const skillName = 'convergence-skill';
+
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Identical Content');
+
+    const manifestDir = path.join(fakeDestConfigDir, '.oh-my-opencode-slim');
+    fs.mkdirSync(manifestDir, { recursive: true });
+    const manifestPath = path.join(manifestDir, 'skills-manifest.json');
+
+    const initialManifest = {
+      schemaVersion: 1,
+      updatedAt: new Date().toISOString(),
+      skills: {
+        [skillName]: {
+          status: 'customized',
+          packageVersion: '1.0.0',
+          sourceHash: 'old-source-hash',
+          lastManagedHash: 'old-managed-hash',
+          lastSeenHash: 'user-custom-hash',
+          stagedPath: '/tmp/some-staged-path',
+          updatedAt: new Date().toISOString(),
+        },
+      },
+    };
+    fs.writeFileSync(manifestPath, JSON.stringify(initialManifest, null, 2));
+
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    fs.mkdirSync(destSkillsDir, { recursive: true });
+    const destSkillDir = path.join(destSkillsDir, skillName);
+    fs.mkdirSync(destSkillDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(destSkillDir, 'SKILL.md'),
+      '# Identical Content',
+    );
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.adopted).toContain(skillName);
+    const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
+    expect(manifest.skills[skillName].status).toBe('managed');
+    expect(manifest.skills[skillName].stagedPath).toBeUndefined();
+  });
+
+  test('lock recovery: steals lock when owner host matches and owner process is dead', async () => {
+    const skillName = 'lock-recovery-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Content');
+
+    const manifestDir = path.join(fakeDestConfigDir, '.oh-my-opencode-slim');
+    fs.mkdirSync(manifestDir, { recursive: true });
+    const lockDir = path.join(manifestDir, 'skills.lock');
+    fs.mkdirSync(lockDir, { recursive: true });
+
+    const deadOwner = {
+      pid: 999999,
+      host: require('node:os').hostname(),
+      time: Date.now() - 5000,
+    };
+    fs.writeFileSync(
+      path.join(lockDir, 'owner.json'),
+      JSON.stringify(deadOwner),
+      'utf-8',
+    );
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.installed).toContain(skillName);
+    expect(result.failed).not.toContain('__lock__');
+  });
+
+  test('returns failed: ["__lock__"] when lock acquisition fails', async () => {
+    const skillName = 'lock-fail-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Content');
+
+    const manifestDir = path.join(fakeDestConfigDir, '.oh-my-opencode-slim');
+    fs.mkdirSync(manifestDir, { recursive: true });
+    const lockDir = path.join(manifestDir, 'skills.lock');
+    fs.mkdirSync(lockDir, { recursive: true });
+
+    const activeOwner = {
+      pid: process.pid,
+      host: require('node:os').hostname(),
+      time: Date.now(),
+    };
+    fs.writeFileSync(
+      path.join(lockDir, 'owner.json'),
+      JSON.stringify(activeOwner),
+      'utf-8',
+    );
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.failed).toContain('__lock__');
+    expect(result.installed).not.toContain(skillName);
+  });
+
+  test('crash safe recovery: recovers backup directory when destination directory is missing', async () => {
+    const skillName = 'recovery-test-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Bundled Content');
+
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    fs.mkdirSync(destSkillsDir, { recursive: true });
+    const destSkillDir = path.join(destSkillsDir, skillName);
+
+    const backupDir = path.join(destSkillsDir, `.backup-${skillName}-12345`);
+    fs.mkdirSync(backupDir, { recursive: true });
+    fs.writeFileSync(path.join(backupDir, 'SKILL.md'), '# Backup Content');
+
+    const manifestDir = path.join(fakeDestConfigDir, '.oh-my-opencode-slim');
+    fs.mkdirSync(manifestDir, { recursive: true });
+    const manifestPath = path.join(manifestDir, 'skills-manifest.json');
+    const initialManifest = {
+      schemaVersion: 1,
+      updatedAt: new Date().toISOString(),
+      skills: {
+        [skillName]: {
+          status: 'managed',
+          packageVersion: '1.0.0',
+          sourceHash: 'some-hash',
+          lastManagedHash: 'some-hash',
+          lastSeenHash: 'some-hash',
+          updatedAt: new Date().toISOString(),
+        },
+      },
+    };
+    fs.writeFileSync(manifestPath, JSON.stringify(initialManifest, null, 2));
+
+    await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(fs.existsSync(destSkillDir)).toBe(true);
+    expect(fs.readFileSync(path.join(destSkillDir, 'SKILL.md'), 'utf-8')).toBe(
+      '# Backup Content',
+    );
+    const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
+    expect(manifest.skills[skillName].status).not.toBe('deleted');
+  });
+
+  test('preserves managed skill when user only adds nested symlink', async () => {
+    const skillName = 'symlink-customization-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Original');
+
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    fs.mkdirSync(destSkillsDir, { recursive: true });
+    const destSkillDir = path.join(destSkillsDir, skillName);
+    fs.mkdirSync(destSkillDir, { recursive: true });
+    fs.writeFileSync(path.join(destSkillDir, 'SKILL.md'), '# Original');
+
+    const { computeDirectoryHash } = await import(
+      `./skill-sync?test=${importCounter++}`
+    );
+    const managedHash = computeDirectoryHash(destSkillDir);
+
+    const symlinkTarget = path.join(fakeDestConfigDir, 'user-target.txt');
+    fs.writeFileSync(symlinkTarget, 'user data');
+    fs.symlinkSync(symlinkTarget, path.join(destSkillDir, 'user-link'));
+
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Updated');
+
+    const manifestDir = path.join(fakeDestConfigDir, '.oh-my-opencode-slim');
+    fs.mkdirSync(manifestDir, { recursive: true });
+    const manifestPath = path.join(manifestDir, 'skills-manifest.json');
+    fs.writeFileSync(
+      manifestPath,
+      JSON.stringify({
+        schemaVersion: 1,
+        updatedAt: new Date().toISOString(),
+        skills: {
+          [skillName]: {
+            status: 'managed',
+            packageVersion: '1.0.0',
+            sourceHash: managedHash,
+            lastManagedHash: managedHash,
+            lastSeenHash: managedHash,
+            updatedAt: new Date().toISOString(),
+          },
+        },
+      }),
+    );
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.customized).toContain(skillName);
+    expect(
+      fs.lstatSync(path.join(destSkillDir, 'user-link')).isSymbolicLink(),
+    ).toBe(true);
+    expect(fs.readFileSync(path.join(destSkillDir, 'SKILL.md'), 'utf-8')).toBe(
+      '# Original',
+    );
+  });
+
+  test('preserves managed skill when user only adds empty directory', async () => {
+    const skillName = 'empty-dir-customization-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Original');
+
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    fs.mkdirSync(destSkillsDir, { recursive: true });
+    const destSkillDir = path.join(destSkillsDir, skillName);
+    fs.mkdirSync(destSkillDir, { recursive: true });
+    fs.writeFileSync(path.join(destSkillDir, 'SKILL.md'), '# Original');
+
+    const { computeDirectoryHash } = await import(
+      `./skill-sync?test=${importCounter++}`
+    );
+    const managedHash = computeDirectoryHash(destSkillDir);
+    fs.mkdirSync(path.join(destSkillDir, 'user-empty-dir'));
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Updated');
+
+    const manifestDir = path.join(fakeDestConfigDir, '.oh-my-opencode-slim');
+    fs.mkdirSync(manifestDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(manifestDir, 'skills-manifest.json'),
+      JSON.stringify({
+        schemaVersion: 1,
+        updatedAt: new Date().toISOString(),
+        skills: {
+          [skillName]: {
+            status: 'managed',
+            packageVersion: '1.0.0',
+            sourceHash: managedHash,
+            lastManagedHash: managedHash,
+            lastSeenHash: managedHash,
+            updatedAt: new Date().toISOString(),
+          },
+        },
+      }),
+    );
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.customized).toContain(skillName);
+    expect(fs.existsSync(path.join(destSkillDir, 'user-empty-dir'))).toBe(true);
+    expect(fs.readFileSync(path.join(destSkillDir, 'SKILL.md'), 'utf-8')).toBe(
+      '# Original',
+    );
+  });
+
+  test('preserves managed skill when user only changes file mode', async () => {
+    const skillName = 'mode-customization-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Original');
+
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    fs.mkdirSync(destSkillsDir, { recursive: true });
+    const destSkillDir = path.join(destSkillsDir, skillName);
+    fs.mkdirSync(destSkillDir, { recursive: true });
+    const destSkillFile = path.join(destSkillDir, 'SKILL.md');
+    fs.writeFileSync(destSkillFile, '# Original');
+
+    const { computeDirectoryHash } = await import(
+      `./skill-sync?test=${importCounter++}`
+    );
+    const managedHash = computeDirectoryHash(destSkillDir);
+    fs.chmodSync(destSkillFile, 0o600);
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Updated');
+
+    const manifestDir = path.join(fakeDestConfigDir, '.oh-my-opencode-slim');
+    fs.mkdirSync(manifestDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(manifestDir, 'skills-manifest.json'),
+      JSON.stringify({
+        schemaVersion: 1,
+        updatedAt: new Date().toISOString(),
+        skills: {
+          [skillName]: {
+            status: 'managed',
+            packageVersion: '1.0.0',
+            sourceHash: managedHash,
+            lastManagedHash: managedHash,
+            lastSeenHash: managedHash,
+            updatedAt: new Date().toISOString(),
+          },
+        },
+      }),
+    );
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.customized).toContain(skillName);
+    expect((fs.statSync(destSkillFile).mode & 0o777).toString(8)).toBe('600');
+    expect(fs.readFileSync(destSkillFile, 'utf-8')).toBe('# Original');
+  });
 });

+ 858 - 71
src/hooks/auto-update-checker/skill-sync.ts

@@ -1,14 +1,16 @@
+import * as crypto from 'node:crypto';
+import * as fs from 'node:fs';
 import {
   copyFileSync,
   existsSync,
   lstatSync,
   mkdirSync,
-  mkdtempSync,
   readdirSync,
   renameSync,
-  rmSync,
 } from 'node:fs';
+import * as os from 'node:os';
 import * as path from 'node:path';
+import { CUSTOM_SKILLS } from '../../cli/custom-skills';
 import { getConfigDir } from '../../cli/paths';
 import { log } from '../../utils/logger';
 
@@ -16,6 +18,75 @@ export interface SkillSyncResult {
   installed: string[];
   skippedExisting: string[];
   failed: string[];
+  updated?: string[];
+  staged?: string[];
+  adopted?: string[];
+  customized?: string[];
+}
+
+export interface SkillManifestEntry {
+  status: 'managed' | 'customized' | 'deleted' | 'conflict';
+  packageVersion: string;
+  sourceHash: string;
+  lastManagedHash: string;
+  lastSeenHash: string;
+  stagedPath?: string;
+  updatedAt: string;
+}
+
+export interface SkillsManifest {
+  schemaVersion: number;
+  updatedAt: string;
+  skills: Record<string, SkillManifestEntry>;
+}
+
+interface ManagedSkillSource {
+  name: string;
+  sourcePath: string;
+}
+
+interface SkillSyncOptions {
+  skills?: ManagedSkillSource[];
+}
+
+/**
+ * Hashes of historically managed versions of skills.
+ * When a release changes skill content, this table must be populated
+ * from the published npm package tarballs to allow upgrading existing users.
+ */
+export const LEGACY_MANAGED_SKILL_HASHES: Record<string, string[]> = {};
+
+/**
+ * Full manifest validation: schemaVersion must be supported (1),
+ * skills object record, status in managed/customized/deleted/conflict.
+ */
+function validateManifest(data: unknown): data is SkillsManifest {
+  if (typeof data !== 'object' || data === null) return false;
+  const d = data as { schemaVersion?: unknown; skills?: unknown };
+  if (d.schemaVersion !== 1) return false;
+  if (typeof d.skills !== 'object' || d.skills === null) return false;
+
+  const allowedStatuses = new Set([
+    'managed',
+    'customized',
+    'deleted',
+    'conflict',
+  ]);
+  const skillsObj = d.skills as Record<string, unknown>;
+  for (const key of Object.keys(skillsObj)) {
+    const entry = skillsObj[key] as Record<string, unknown>;
+    if (typeof entry !== 'object' || entry === null) return false;
+    if (typeof entry.status !== 'string' || !allowedStatuses.has(entry.status))
+      return false;
+    if (typeof entry.packageVersion !== 'string') return false;
+    if (typeof entry.sourceHash !== 'string') return false;
+    if (typeof entry.lastManagedHash !== 'string') return false;
+    if (typeof entry.lastSeenHash !== 'string') return false;
+    if (entry.stagedPath !== undefined && typeof entry.stagedPath !== 'string')
+      return false;
+    if (typeof entry.updatedAt !== 'string') return false;
+  }
+  return true;
 }
 
 /**
@@ -41,15 +112,324 @@ function copyDirRecursive(src: string, dest: string): void {
   }
 }
 
+/**
+ * Computes a deterministic SHA-256 hash of a directory's files.
+ */
+export function computeDirectoryHash(dirPath: string): string {
+  const hash = crypto.createHash('sha256');
+  const entriesToHash: {
+    relativePath: string;
+    absolutePath: string;
+    kind: 'directory' | 'file' | 'symlink';
+    mode: number;
+  }[] = [];
+
+  function traverse(currentDir: string) {
+    const entries = readdirSync(currentDir);
+    for (const entry of entries) {
+      const absolutePath = path.join(currentDir, entry);
+      const stat = lstatSync(absolutePath);
+      const relativePath = path.relative(dirPath, absolutePath);
+      if (stat.isSymbolicLink()) {
+        entriesToHash.push({
+          relativePath,
+          absolutePath,
+          kind: 'symlink',
+          mode: stat.mode,
+        });
+      } else if (stat.isDirectory()) {
+        entriesToHash.push({
+          relativePath,
+          absolutePath,
+          kind: 'directory',
+          mode: stat.mode,
+        });
+        traverse(absolutePath);
+      } else if (stat.isFile()) {
+        entriesToHash.push({
+          relativePath,
+          absolutePath,
+          kind: 'file',
+          mode: stat.mode,
+        });
+      }
+    }
+  }
+
+  traverse(dirPath);
+
+  entriesToHash.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
+
+  for (const entry of entriesToHash) {
+    hash.update(entry.kind);
+    hash.update('\0');
+    hash.update(entry.relativePath);
+    hash.update('\0');
+    hash.update(String(entry.mode & 0o7777));
+    hash.update('\0');
+    if (entry.kind === 'file') {
+      const content = fs.readFileSync(entry.absolutePath);
+      hash.update(content);
+    } else if (entry.kind === 'symlink') {
+      hash.update(fs.readlinkSync(entry.absolutePath));
+    }
+  }
+
+  return hash.digest('hex');
+}
+
+/**
+ * Checks if a PID is alive on the current host.
+ */
+function isPidRunning(pid: number): boolean {
+  try {
+    process.kill(pid, 0);
+    return true;
+  } catch (err) {
+    return (err as { code?: string }).code === 'EPERM';
+  }
+}
+
+/**
+ * Acquires a simple lock under .oh-my-opencode-slim.
+ * Avoids stealing active locks purely by time; writes owner metadata
+ * and only steals dead same-host pid if detectable.
+ */
+function acquireLock(lockDir: string): boolean {
+  const metadataPath = path.join(lockDir, 'owner.json');
+  const currentHost = os.hostname();
+  const currentPid = process.pid;
+
+  const writeMetadata = () => {
+    try {
+      const metadata = {
+        pid: currentPid,
+        host: currentHost,
+        time: Date.now(),
+      };
+      fs.writeFileSync(metadataPath, JSON.stringify(metadata), 'utf-8');
+    } catch {
+      // Ignored
+    }
+  };
+
+  try {
+    mkdirSync(lockDir);
+    writeMetadata();
+    return true;
+  } catch (err) {
+    if ((err as { code?: string }).code !== 'EEXIST') {
+      throw err;
+    }
+  }
+
+  try {
+    let shouldSteal = false;
+    let ageMs = 0;
+
+    if (existsSync(metadataPath)) {
+      try {
+        const content = fs.readFileSync(metadataPath, 'utf-8');
+        const metadata = JSON.parse(content);
+        ageMs = Date.now() - metadata.time;
+
+        if (metadata.host === currentHost) {
+          if (!isPidRunning(metadata.pid)) {
+            log(
+              `[skill-sync] Lock owner process ${metadata.pid} is not running on this host. Recovery path.`,
+            );
+            shouldSteal = true;
+          }
+        } else {
+          log(
+            `[skill-sync] Lock is owned by different host ${metadata.host}; failing closed.`,
+          );
+        }
+      } catch {
+        shouldSteal = true;
+      }
+    } else {
+      const stat = fs.statSync(lockDir);
+      ageMs = Date.now() - stat.mtimeMs;
+      if (ageMs > 30000) {
+        shouldSteal = true;
+      }
+    }
+
+    if (!shouldSteal) return false;
+
+    log(`[skill-sync] Stealing/recovering lock directory.`);
+    fs.rmSync(lockDir, { recursive: true, force: true });
+    mkdirSync(lockDir);
+    writeMetadata();
+    return true;
+  } catch (err) {
+    log(`[skill-sync] Failed to check/recover lock at ${lockDir}:`, err);
+    return false;
+  }
+}
+
+/**
+ * Releases the lock.
+ */
+function releaseLock(lockDir: string): void {
+  try {
+    if (existsSync(lockDir)) {
+      fs.rmSync(lockDir, { recursive: true, force: true });
+    }
+  } catch (err) {
+    log(`[skill-sync] Failed to release lock at ${lockDir}:`, err);
+  }
+}
+
+/**
+ * Atomic directory replacement: copy to staging, backup dest, rename staging to dest, remove backup.
+ * Rolls back on failure.
+ */
+function atomicReplaceDir(sourceDir: string, destDir: string): void {
+  const parentDir = path.dirname(destDir);
+  if (!existsSync(parentDir)) {
+    mkdirSync(parentDir, { recursive: true });
+  }
+
+  const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
+  const stagingDir = path.join(
+    parentDir,
+    `.staging-${path.basename(destDir)}-${uniqueSuffix}`,
+  );
+  const backupDir = path.join(
+    parentDir,
+    `.backup-${path.basename(destDir)}-${uniqueSuffix}`,
+  );
+
+  let backupCreated = false;
+
+  try {
+    copyDirRecursive(sourceDir, stagingDir);
+
+    if (existsSync(destDir)) {
+      renameSync(destDir, backupDir);
+      backupCreated = true;
+    }
+
+    renameSync(stagingDir, destDir);
+
+    if (backupCreated) {
+      fs.rmSync(backupDir, { recursive: true, force: true });
+    }
+  } catch (err) {
+    log(
+      `[skill-sync] Error during atomic replace for ${destDir}. Rolling back:`,
+      err,
+    );
+
+    if (backupCreated) {
+      try {
+        if (existsSync(destDir)) {
+          fs.rmSync(destDir, { recursive: true, force: true });
+        }
+        renameSync(backupDir, destDir);
+      } catch (rollbackErr) {
+        log(
+          `[skill-sync] Critical error during rollback for ${destDir}:`,
+          rollbackErr,
+        );
+      }
+    }
+
+    try {
+      if (existsSync(stagingDir)) {
+        fs.rmSync(stagingDir, { recursive: true, force: true });
+      }
+    } catch {}
+
+    throw err;
+  }
+}
+
+/**
+ * Recovers orphan .backup-* and .staging-* directories.
+ * Returns true if any were found.
+ */
+function recoverOrphanArtifacts(
+  destSkillsDir: string,
+  skillName: string,
+): boolean {
+  if (!existsSync(destSkillsDir)) return false;
+
+  let hadArtifacts = false;
+  let entries: string[] = [];
+  try {
+    entries = readdirSync(destSkillsDir);
+  } catch {
+    return false;
+  }
+
+  const backups: string[] = [];
+  const stagings: string[] = [];
+
+  for (const entry of entries) {
+    if (entry.startsWith(`.backup-${skillName}-`)) {
+      backups.push(path.join(destSkillsDir, entry));
+      hadArtifacts = true;
+    } else if (entry.startsWith(`.staging-${skillName}-`)) {
+      stagings.push(path.join(destSkillsDir, entry));
+      hadArtifacts = true;
+    }
+  }
+
+  const destPath = path.join(destSkillsDir, skillName);
+
+  if (backups.length > 0) {
+    backups.sort();
+    const mostRecentBackup = backups[backups.length - 1];
+
+    if (!existsSync(destPath)) {
+      try {
+        renameSync(mostRecentBackup, destPath);
+        log(
+          `[skill-sync] Recovered backup for ${skillName} back to destination.`,
+        );
+        backups.pop();
+      } catch (err) {
+        log(`[skill-sync] Failed to restore backup for ${skillName}:`, err);
+      }
+    }
+
+    for (const backup of backups) {
+      try {
+        fs.rmSync(backup, { recursive: true, force: true });
+      } catch (err) {
+        log(`[skill-sync] Failed to clean up backup folder ${backup}:`, err);
+      }
+    }
+  }
+
+  for (const staging of stagings) {
+    try {
+      fs.rmSync(staging, { recursive: true, force: true });
+    } catch (err) {
+      log(`[skill-sync] Failed to clean up staging folder ${staging}:`, err);
+    }
+  }
+
+  return hadArtifacts;
+}
+
 /**
  * Synchronizes bundled skills from the newly installed package root to OpenCode config skills directory.
  */
 export function syncBundledSkillsFromPackage(
   packageRoot: string,
+  options: SkillSyncOptions = {},
 ): SkillSyncResult {
   const installed: string[] = [];
   const skippedExisting: string[] = [];
   const failed: string[] = [];
+  const updated: string[] = [];
+  const staged: string[] = [];
+  const adopted: string[] = [];
+  const customized: string[] = [];
 
   const sourceSkillsDir = path.join(packageRoot, 'src', 'skills');
 
@@ -59,120 +439,527 @@ export function syncBundledSkillsFromPackage(
       log(
         `[skill-sync] Source skills directory is not a valid directory: ${sourceSkillsDir}`,
       );
-      return { installed, skippedExisting, failed };
+      return {
+        installed,
+        skippedExisting,
+        failed,
+        updated,
+        staged,
+        adopted,
+        customized,
+      };
     }
   } catch {
     log(
       `[skill-sync] Source skills directory does not exist or is unreadable: ${sourceSkillsDir}`,
     );
-    return { installed, skippedExisting, failed };
+    return {
+      installed,
+      skippedExisting,
+      failed,
+      updated,
+      staged,
+      adopted,
+      customized,
+    };
   }
 
-  const destSkillsDir = path.join(getConfigDir(), 'skills');
-
+  let packageVersion = 'unknown';
   try {
-    if (!existsSync(destSkillsDir)) {
-      mkdirSync(destSkillsDir, { recursive: true });
+    const pkgJsonPath = path.join(packageRoot, 'package.json');
+    if (existsSync(pkgJsonPath)) {
+      const content = fs.readFileSync(pkgJsonPath, 'utf-8');
+      const pkg = JSON.parse(content);
+      if (pkg.version) {
+        packageVersion = pkg.version;
+      }
     }
   } catch (err) {
     log(
-      `[skill-sync] Failed to create destination skills directory: ${destSkillsDir}`,
+      `[skill-sync] Failed to read package version from ${packageRoot}:`,
       err,
     );
   }
 
-  let entries: string[] = [];
+  const manifestDir = path.join(getConfigDir(), '.oh-my-opencode-slim');
+  const lockDir = path.join(manifestDir, 'skills.lock');
+
   try {
-    entries = readdirSync(sourceSkillsDir);
+    mkdirSync(manifestDir, { recursive: true });
   } catch (err) {
     log(
-      `[skill-sync] Failed to read source skills directory: ${sourceSkillsDir}`,
+      `[skill-sync] Failed to create manifest directory: ${manifestDir}`,
       err,
     );
-    return { installed, skippedExisting, failed };
   }
 
-  for (const entry of entries) {
-    const entryPath = path.join(sourceSkillsDir, entry);
-    try {
-      if (entry.startsWith('.')) {
-        continue;
-      }
+  if (!acquireLock(lockDir)) {
+    log(
+      '[skill-sync] Failed to acquire lock for skill synchronization. Skipping.',
+    );
+    return {
+      installed,
+      skippedExisting,
+      failed: ['__lock__'],
+      updated,
+      staged,
+      adopted,
+      customized,
+    };
+  }
 
-      const entryStat = lstatSync(entryPath);
-      if (entryStat.isSymbolicLink() || !entryStat.isDirectory()) {
-        continue;
-      }
+  try {
+    const manifestPath = path.join(manifestDir, 'skills-manifest.json');
+    let manifest: SkillsManifest = {
+      schemaVersion: 1,
+      updatedAt: new Date().toISOString(),
+      skills: {},
+    };
+    let isManifestCorrupt = false;
 
-      const skillMdPath = path.join(entryPath, 'SKILL.md');
+    if (existsSync(manifestPath)) {
       try {
-        const skillMdStat = lstatSync(skillMdPath);
-        if (skillMdStat.isSymbolicLink() || !skillMdStat.isFile()) {
-          continue;
+        const content = fs.readFileSync(manifestPath, 'utf-8');
+        const parsed = JSON.parse(content);
+        if (validateManifest(parsed)) {
+          manifest = parsed;
+        } else {
+          throw new Error('Manifest validation failed');
         }
-      } catch {
-        continue;
+      } catch (err) {
+        log(
+          '[skill-sync] Manifest is corrupt/unreadable. Failing closed.',
+          err,
+        );
+        isManifestCorrupt = true;
+      }
+    }
+
+    const destSkillsDir = path.join(getConfigDir(), 'skills');
+    try {
+      if (!existsSync(destSkillsDir)) {
+        mkdirSync(destSkillsDir, { recursive: true });
       }
+    } catch (err) {
+      log(
+        `[skill-sync] Failed to create destination skills directory: ${destSkillsDir}`,
+        err,
+      );
+    }
 
-      const destPath = path.join(destSkillsDir, entry);
+    const skillsToProcess = (options.skills ?? CUSTOM_SKILLS).map((s) => ({
+      name: s.name,
+      sourcePath: s.sourcePath,
+    }));
 
-      let destExists = false;
+    for (const skill of skillsToProcess) {
       try {
-        lstatSync(destPath);
-        destExists = true;
-      } catch {
-        // Does not exist
-      }
+        const sourcePath = path.join(packageRoot, skill.sourcePath);
 
-      if (destExists) {
-        log(`[skill-sync] Skill already exists in destination: ${entry}`);
-        skippedExisting.push(entry);
-        continue;
-      }
+        try {
+          const stat = lstatSync(sourcePath);
+          if (stat.isSymbolicLink() || !stat.isDirectory()) {
+            continue;
+          }
+          const skillMdPath = path.join(sourcePath, 'SKILL.md');
+          const skillMdStat = lstatSync(skillMdPath);
+          if (skillMdStat.isSymbolicLink() || !skillMdStat.isFile()) {
+            continue;
+          }
+        } catch {
+          continue;
+        }
 
-      const stagingDir = mkdtempSync(
-        path.join(destSkillsDir, `.sync-staging-${entry}-`),
-      );
+        const destPath = path.join(destSkillsDir, skill.name);
 
-      try {
-        copyDirRecursive(entryPath, stagingDir);
+        // Crash-safe recovery
+        const hadArtifacts = recoverOrphanArtifacts(destSkillsDir, skill.name);
 
-        let destExistsLate = false;
+        let destExists = false;
+        let destIsDir = false;
         try {
-          lstatSync(destPath);
-          destExistsLate = true;
-        } catch {}
+          const destStat = lstatSync(destPath);
+          destExists = true;
+          destIsDir = destStat.isDirectory() && !destStat.isSymbolicLink();
+        } catch {
+          // Does not exist
+        }
 
-        if (destExistsLate) {
+        if (destExists && !destIsDir) {
           log(
-            `[skill-sync] Destination path was created during staging for ${entry}, skipping promotion.`,
+            `[skill-sync] Skill ${skill.name} destination is a file or symlink (conflict). Skipping.`,
           );
-          skippedExisting.push(entry);
+          skippedExisting.push(skill.name);
+          if (!isManifestCorrupt) {
+            const sourceHash = computeDirectoryHash(sourcePath);
+            manifest.skills[skill.name] = {
+              status: 'conflict',
+              packageVersion,
+              sourceHash,
+              lastManagedHash: '',
+              lastSeenHash: '',
+              updatedAt: new Date().toISOString(),
+            };
+          }
+          continue;
+        }
+
+        const sourceHash = computeDirectoryHash(sourcePath);
+
+        if (isManifestCorrupt) {
+          if (!destExists) {
+            try {
+              atomicReplaceDir(sourcePath, destPath);
+              installed.push(skill.name);
+            } catch (err) {
+              log(
+                `[skill-sync] Failed to install missing skill ${skill.name} (corrupt manifest mode):`,
+                err,
+              );
+              failed.push(skill.name);
+            }
+          } else {
+            log(
+              `[skill-sync] Skipping existing skill ${skill.name} because manifest is corrupt.`,
+            );
+            skippedExisting.push(skill.name);
+          }
+          continue;
+        }
+
+        const entry = manifest.skills[skill.name];
+
+        if (!destExists) {
+          if (entry && entry.status === 'deleted') {
+            log(
+              `[skill-sync] Skill ${skill.name} was deleted by user. Skipping.`,
+            );
+            skippedExisting.push(skill.name);
+            continue;
+          }
+          if (entry && entry.status !== 'deleted') {
+            if (hadArtifacts) {
+              log(
+                `[skill-sync] Managed skill ${skill.name} has backup/staging artifacts. Skipping delete, re-installing.`,
+              );
+              try {
+                atomicReplaceDir(sourcePath, destPath);
+                installed.push(skill.name);
+                manifest.skills[skill.name] = {
+                  status: 'managed',
+                  packageVersion,
+                  sourceHash,
+                  lastManagedHash: sourceHash,
+                  lastSeenHash: sourceHash,
+                  updatedAt: new Date().toISOString(),
+                };
+              } catch (err) {
+                log(
+                  `[skill-sync] Failed to re-install skill ${skill.name}:`,
+                  err,
+                );
+                failed.push(skill.name);
+              }
+              continue;
+            } else {
+              entry.status = 'deleted';
+              entry.updatedAt = new Date().toISOString();
+              log(
+                `[skill-sync] Skill ${skill.name} was deleted by user (detected now). Skipping.`,
+              );
+              skippedExisting.push(skill.name);
+              continue;
+            }
+          }
+
+          try {
+            atomicReplaceDir(sourcePath, destPath);
+            installed.push(skill.name);
+            manifest.skills[skill.name] = {
+              status: 'managed',
+              packageVersion,
+              sourceHash,
+              lastManagedHash: sourceHash,
+              lastSeenHash: sourceHash,
+              updatedAt: new Date().toISOString(),
+            };
+            log(
+              `[skill-sync] Successfully installed missing skill: ${skill.name}`,
+            );
+          } catch (err) {
+            log(`[skill-sync] Failed to install skill ${skill.name}:`, err);
+            failed.push(skill.name);
+          }
+          continue;
+        }
+
+        const destHash = computeDirectoryHash(destPath);
+
+        if (entry) {
+          if (entry.status === 'managed') {
+            if (destHash === entry.lastManagedHash) {
+              if (destHash === sourceHash) {
+                skippedExisting.push(skill.name);
+              } else {
+                try {
+                  atomicReplaceDir(sourcePath, destPath);
+                  installed.push(skill.name);
+                  updated.push(skill.name);
+                  manifest.skills[skill.name] = {
+                    status: 'managed',
+                    packageVersion,
+                    sourceHash,
+                    lastManagedHash: sourceHash,
+                    lastSeenHash: sourceHash,
+                    updatedAt: new Date().toISOString(),
+                  };
+                  log(`[skill-sync] Updated managed skill: ${skill.name}`);
+                } catch (err) {
+                  log(
+                    `[skill-sync] Failed to update managed skill ${skill.name}:`,
+                    err,
+                  );
+                  failed.push(skill.name);
+                }
+              }
+            } else {
+              if (destHash === sourceHash) {
+                manifest.skills[skill.name] = {
+                  status: 'managed',
+                  packageVersion,
+                  sourceHash,
+                  lastManagedHash: sourceHash,
+                  lastSeenHash: sourceHash,
+                  updatedAt: new Date().toISOString(),
+                };
+                skippedExisting.push(skill.name);
+              } else {
+                try {
+                  const stagedSkillDir = path.join(
+                    manifestDir,
+                    'skill-updates',
+                    packageVersion,
+                    skill.name,
+                  );
+                  if (existsSync(stagedSkillDir)) {
+                    fs.rmSync(stagedSkillDir, { recursive: true, force: true });
+                  }
+                  mkdirSync(stagedSkillDir, { recursive: true });
+                  copyDirRecursive(sourcePath, stagedSkillDir);
+
+                  entry.status = 'customized';
+                  entry.lastSeenHash = destHash;
+                  entry.stagedPath = stagedSkillDir;
+                  entry.sourceHash = sourceHash;
+                  entry.packageVersion = packageVersion;
+                  entry.updatedAt = new Date().toISOString();
+
+                  staged.push(skill.name);
+                  customized.push(skill.name);
+                  skippedExisting.push(skill.name);
+                  log(
+                    `[skill-sync] Skill ${skill.name} is customized. Staged update at ${stagedSkillDir}`,
+                  );
+                } catch (err) {
+                  log(
+                    `[skill-sync] Failed to stage update for customized skill ${skill.name}:`,
+                    err,
+                  );
+                  failed.push(skill.name);
+                }
+              }
+            }
+          } else if (entry.status === 'customized') {
+            if (destHash === sourceHash) {
+              entry.status = 'managed';
+              entry.lastManagedHash = sourceHash;
+              entry.lastSeenHash = sourceHash;
+              entry.sourceHash = sourceHash;
+              entry.packageVersion = packageVersion;
+              delete entry.stagedPath;
+              entry.updatedAt = new Date().toISOString();
+              adopted.push(skill.name);
+              skippedExisting.push(skill.name);
+              log(
+                `[skill-sync] Customized skill ${skill.name} converged with current version. Adopted back to managed.`,
+              );
+            } else {
+              entry.lastSeenHash = destHash;
+              entry.updatedAt = new Date().toISOString();
+
+              if (destHash !== sourceHash && entry.sourceHash !== sourceHash) {
+                try {
+                  const stagedSkillDir = path.join(
+                    manifestDir,
+                    'skill-updates',
+                    packageVersion,
+                    skill.name,
+                  );
+                  if (existsSync(stagedSkillDir)) {
+                    fs.rmSync(stagedSkillDir, { recursive: true, force: true });
+                  }
+                  mkdirSync(stagedSkillDir, { recursive: true });
+                  copyDirRecursive(sourcePath, stagedSkillDir);
+
+                  entry.stagedPath = stagedSkillDir;
+                  entry.sourceHash = sourceHash;
+
+                  staged.push(skill.name);
+                  log(
+                    `[skill-sync] Staged new update for customized skill ${skill.name} at ${stagedSkillDir}`,
+                  );
+                } catch (err) {
+                  log(
+                    `[skill-sync] Failed to stage update for customized skill ${skill.name}:`,
+                    err,
+                  );
+                }
+              }
+              skippedExisting.push(skill.name);
+            }
+          } else if (entry.status === 'deleted') {
+            if (destHash === sourceHash) {
+              entry.status = 'managed';
+              entry.packageVersion = packageVersion;
+              entry.sourceHash = sourceHash;
+              entry.lastManagedHash = sourceHash;
+              entry.lastSeenHash = sourceHash;
+              entry.updatedAt = new Date().toISOString();
+              skippedExisting.push(skill.name);
+              adopted.push(skill.name);
+              log(
+                `[skill-sync] Skill ${skill.name} re-created by user (matching current). Adopted as managed.`,
+              );
+            } else {
+              entry.status = 'customized';
+              entry.lastSeenHash = destHash;
+              entry.updatedAt = new Date().toISOString();
+              skippedExisting.push(skill.name);
+              log(
+                `[skill-sync] Skill ${skill.name} re-created by user (custom). Marked customized.`,
+              );
+            }
+          } else if (entry.status === 'conflict') {
+            skippedExisting.push(skill.name);
+          }
         } else {
-          renameSync(stagingDir, destPath);
-          installed.push(entry);
-          log(`[skill-sync] Successfully synced skill: ${entry}`);
+          if (destHash === sourceHash) {
+            manifest.skills[skill.name] = {
+              status: 'managed',
+              packageVersion,
+              sourceHash,
+              lastManagedHash: sourceHash,
+              lastSeenHash: sourceHash,
+              updatedAt: new Date().toISOString(),
+            };
+            skippedExisting.push(skill.name);
+            adopted.push(skill.name);
+            log(`[skill-sync] Adopted existing matching skill: ${skill.name}`);
+          } else if (
+            LEGACY_MANAGED_SKILL_HASHES[skill.name]?.includes(destHash)
+          ) {
+            try {
+              atomicReplaceDir(sourcePath, destPath);
+              installed.push(skill.name);
+              updated.push(skill.name);
+              manifest.skills[skill.name] = {
+                status: 'managed',
+                packageVersion,
+                sourceHash,
+                lastManagedHash: sourceHash,
+                lastSeenHash: sourceHash,
+                updatedAt: new Date().toISOString(),
+              };
+              log(
+                `[skill-sync] Adopted and updated legacy skill: ${skill.name}`,
+              );
+            } catch (err) {
+              log(
+                `[skill-sync] Failed to update legacy skill ${skill.name}:`,
+                err,
+              );
+              failed.push(skill.name);
+            }
+          } else {
+            try {
+              const stagedSkillDir = path.join(
+                manifestDir,
+                'skill-updates',
+                packageVersion,
+                skill.name,
+              );
+              if (existsSync(stagedSkillDir)) {
+                fs.rmSync(stagedSkillDir, { recursive: true, force: true });
+              }
+              mkdirSync(stagedSkillDir, { recursive: true });
+              copyDirRecursive(sourcePath, stagedSkillDir);
+
+              manifest.skills[skill.name] = {
+                status: 'customized',
+                packageVersion,
+                sourceHash,
+                lastManagedHash: '',
+                lastSeenHash: destHash,
+                stagedPath: stagedSkillDir,
+                updatedAt: new Date().toISOString(),
+              };
+              staged.push(skill.name);
+              customized.push(skill.name);
+              skippedExisting.push(skill.name);
+              log(
+                `[skill-sync] Skill ${skill.name} is customized (no manifest entry). Staged update at ${stagedSkillDir}`,
+              );
+            } catch (err) {
+              log(
+                `[skill-sync] Failed to stage update for customized skill ${skill.name}:`,
+                err,
+              );
+              failed.push(skill.name);
+            }
+          }
         }
       } catch (err) {
-        log(`[skill-sync] Failed to sync skill ${entry}:`, err);
-        failed.push(entry);
-      } finally {
+        log(`[skill-sync] Failed processing skill ${skill.name}:`, err);
+        failed.push(skill.name);
+      }
+    }
+
+    let manifestWriteFailed = false;
+    if (!isManifestCorrupt) {
+      manifest.updatedAt = new Date().toISOString();
+      const tempManifestPath = `${manifestPath}.${Math.random().toString(36).slice(2, 9)}.tmp`;
+      try {
+        fs.writeFileSync(
+          tempManifestPath,
+          JSON.stringify(manifest, null, 2),
+          'utf-8',
+        );
+        fs.renameSync(tempManifestPath, manifestPath);
+      } catch (err) {
+        log('[skill-sync] Failed to write skills manifest atomically:', err);
+        manifestWriteFailed = true;
         try {
-          if (existsSync(stagingDir)) {
-            rmSync(stagingDir, { recursive: true, force: true });
+          if (fs.existsSync(tempManifestPath)) {
+            fs.unlinkSync(tempManifestPath);
           }
-        } catch (err) {
-          log(
-            `[skill-sync] Failed to clean up staging directory ${stagingDir}:`,
-            err,
-          );
-        }
+        } catch {}
       }
-    } catch (err) {
-      log(`[skill-sync] Error processing source entry ${entry}:`, err);
-      failed.push(entry);
     }
+
+    if (manifestWriteFailed) {
+      failed.push('__manifest__');
+    }
+  } finally {
+    releaseLock(lockDir);
   }
 
-  return { installed, skippedExisting, failed };
+  return {
+    installed,
+    skippedExisting,
+    failed,
+    updated,
+    staged,
+    adopted,
+    customized,
+  };
 }