Explorar el Código

Merge pull request #655 from alvinunreal/fix/managed-skill-sync

fix(skills): implement robust managed skill synchronization and customization tracking
Alvin hace 1 mes
padre
commit
196126af58

+ 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.
 
 ---
 

+ 73 - 0
src/cli/custom-skills-registry.ts

@@ -0,0 +1,73 @@
+/**
+ * A custom skill bundled in this repository.
+ * Unlike npx-installed skills, these are copied from src/skills/ to the OpenCode skills directory
+ */
+export interface CustomSkill {
+  /** Skill name (folder name) */
+  name: string;
+  /** Human-readable description */
+  description: string;
+  /** List of agents that should auto-allow this skill */
+  allowedAgents: string[];
+  /** Source path in this repo (relative to project root) */
+  sourcePath: string;
+}
+
+/**
+ * Registry of custom skills bundled in this repository.
+ */
+export const CUSTOM_SKILLS: CustomSkill[] = [
+  {
+    name: 'simplify',
+    description: 'Code simplification and readability-focused refactoring',
+    allowedAgents: ['oracle'],
+    sourcePath: 'src/skills/simplify',
+  },
+  {
+    name: 'codemap',
+    description: 'Repository understanding and hierarchical codemap generation',
+    allowedAgents: ['orchestrator'],
+    sourcePath: 'src/skills/codemap',
+  },
+  {
+    name: 'clonedeps',
+    description: 'Clone important dependency source for local inspection',
+    allowedAgents: ['orchestrator'],
+    sourcePath: 'src/skills/clonedeps',
+  },
+  {
+    name: 'deepwork',
+    description:
+      'Heavy/complex coding sessions and large modifications workflow',
+    allowedAgents: ['orchestrator'],
+    sourcePath: 'src/skills/deepwork',
+  },
+  {
+    name: 'reflect',
+    description:
+      'Review repeated work and suggest reusable workflow improvements',
+    allowedAgents: ['orchestrator'],
+    sourcePath: 'src/skills/reflect',
+  },
+  {
+    name: 'oh-my-opencode-slim',
+    description:
+      'Configure, customize, and safely improve oh-my-opencode-slim setups',
+    allowedAgents: ['orchestrator'],
+    sourcePath: 'src/skills/oh-my-opencode-slim',
+  },
+  {
+    name: 'release-smoke-test',
+    description:
+      'Validate packed release candidates and bugfixes before public publish',
+    allowedAgents: ['orchestrator'],
+    sourcePath: 'src/skills/release-smoke-test',
+  },
+  {
+    name: 'worktrees',
+    description:
+      'Manage Git worktrees as OMO safe isolated coding lanes for complex/risky/parallel work',
+    allowedAgents: ['orchestrator'],
+    sourcePath: 'src/skills/worktrees',
+  },
+];

+ 17 - 120
src/cli/custom-skills.ts

@@ -1,87 +1,10 @@
-import {
-  copyFileSync,
-  existsSync,
-  mkdirSync,
-  readdirSync,
-  statSync,
-} from 'node:fs';
-import { dirname, join } from 'node:path';
+import { cpSync, existsSync, mkdirSync } from 'node:fs';
+import { join } from 'node:path';
 import { fileURLToPath } from 'node:url';
+import { CUSTOM_SKILLS, type CustomSkill } from './custom-skills-registry';
 import { getConfigDir } from './paths';
 
-/**
- * A custom skill bundled in this repository.
- * Unlike npx-installed skills, these are copied from src/skills/ to the OpenCode skills directory
- */
-export interface CustomSkill {
-  /** Skill name (folder name) */
-  name: string;
-  /** Human-readable description */
-  description: string;
-  /** List of agents that should auto-allow this skill */
-  allowedAgents: string[];
-  /** Source path in this repo (relative to project root) */
-  sourcePath: string;
-}
-
-/**
- * Registry of custom skills bundled in this repository.
- */
-export const CUSTOM_SKILLS: CustomSkill[] = [
-  {
-    name: 'simplify',
-    description: 'Code simplification and readability-focused refactoring',
-    allowedAgents: ['oracle'],
-    sourcePath: 'src/skills/simplify',
-  },
-  {
-    name: 'codemap',
-    description: 'Repository understanding and hierarchical codemap generation',
-    allowedAgents: ['orchestrator'],
-    sourcePath: 'src/skills/codemap',
-  },
-  {
-    name: 'clonedeps',
-    description: 'Clone important dependency source for local inspection',
-    allowedAgents: ['orchestrator'],
-    sourcePath: 'src/skills/clonedeps',
-  },
-  {
-    name: 'deepwork',
-    description:
-      'Heavy/complex coding sessions and large modifications workflow',
-    allowedAgents: ['orchestrator'],
-    sourcePath: 'src/skills/deepwork',
-  },
-  {
-    name: 'reflect',
-    description:
-      'Review repeated work and suggest reusable workflow improvements',
-    allowedAgents: ['orchestrator'],
-    sourcePath: 'src/skills/reflect',
-  },
-  {
-    name: 'oh-my-opencode-slim',
-    description:
-      'Configure, customize, and safely improve oh-my-opencode-slim setups',
-    allowedAgents: ['orchestrator'],
-    sourcePath: 'src/skills/oh-my-opencode-slim',
-  },
-  {
-    name: 'release-smoke-test',
-    description:
-      'Validate packed release candidates and bugfixes before public publish',
-    allowedAgents: ['orchestrator'],
-    sourcePath: 'src/skills/release-smoke-test',
-  },
-  {
-    name: 'worktrees',
-    description:
-      'Manage Git worktrees as OMO safe isolated coding lanes for complex/risky/parallel work',
-    allowedAgents: ['orchestrator'],
-    sourcePath: 'src/skills/worktrees',
-  },
-];
+export { CUSTOM_SKILLS, type CustomSkill };
 
 /**
  * Get the target directory for custom skills installation.
@@ -90,56 +13,30 @@ 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 {
+  console.warn(
+    `[DEPRECATED] installCustomSkill is deprecated and will be removed. Use syncBundledSkillsFromPackage instead.`,
+  );
   try {
     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);
+    const sourceDir = join(packageRoot, skill.sourcePath);
+    if (!existsSync(sourceDir)) return false;
 
+    const targetDir = join(getCustomSkillsDir(), skill.name);
+    mkdirSync(getCustomSkillsDir(), { recursive: true });
+    cpSync(sourceDir, targetDir, { recursive: true, force: true });
     return true;
   } 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;
   }
 }

+ 338 - 1
src/cli/install.test.ts

@@ -1,10 +1,146 @@
-import { afterEach, describe, expect, test } from 'bun:test';
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
 import { shouldInstallCompanion } from './install';
 import type { InstallConfig } from './types';
 
 const ORIGINAL_ENV = { ...process.env };
 const ORIGINAL_STDIN_IS_TTY = process.stdin.isTTY;
 
+const actualSkillSync = require('../hooks/auto-update-checker/skill-sync');
+const actualConfigManager = require('./config-manager');
+const actualBackgroundSubagents = require('./background-subagents');
+const actualPaths = require('./paths');
+
+const originalSyncBundledSkillsFromPackage =
+  actualSkillSync.syncBundledSkillsFromPackage;
+
+const originalIsOpenCodeInstalled = actualConfigManager.isOpenCodeInstalled;
+const originalGetOpenCodeVersion = actualConfigManager.getOpenCodeVersion;
+const originalGetOpenCodePath = actualConfigManager.getOpenCodePath;
+const originalAddPluginToOpenCodeConfig =
+  actualConfigManager.addPluginToOpenCodeConfig;
+const originalAddPluginToOpenCodeTuiConfig =
+  actualConfigManager.addPluginToOpenCodeTuiConfig;
+const originalWarmOpenCodePluginCache =
+  actualConfigManager.warmOpenCodePluginCache;
+const originalDisableDefaultAgents = actualConfigManager.disableDefaultAgents;
+const originalEnableLspByDefault = actualConfigManager.enableLspByDefault;
+const originalDetectCurrentConfig = actualConfigManager.detectCurrentConfig;
+const originalGenerateLiteConfig = actualConfigManager.generateLiteConfig;
+const originalWriteLiteConfig = actualConfigManager.writeLiteConfig;
+
+const originalIsBackgroundSubagentsEnabled =
+  actualBackgroundSubagents.isBackgroundSubagentsEnabled;
+const originalDetectBackgroundSubagentsTarget =
+  actualBackgroundSubagents.detectBackgroundSubagentsTarget;
+const originalExpandHomePath = actualBackgroundSubagents.expandHomePath;
+const originalGetBackgroundSubagentsBlock =
+  actualBackgroundSubagents.getBackgroundSubagentsBlock;
+const originalWriteBackgroundSubagentsBlock =
+  actualBackgroundSubagents.writeBackgroundSubagentsBlock;
+const originalManualBackgroundSubagentsInstructions =
+  actualBackgroundSubagents.manualBackgroundSubagentsInstructions;
+
+const originalGetExistingLiteConfigPath = actualPaths.getExistingLiteConfigPath;
+
+let importCounter = 0;
+let mockSkippedResult: string[] = [];
+let mockFailedResult: string[] = [];
+let mockStagedResult: string[] = [];
+let mockAdoptedResult: string[] = [];
+let mockCustomizedResult: string[] = [];
+let enableInstallMocks = false;
+
+mock.module('../hooks/auto-update-checker/skill-sync', () => {
+  return {
+    ...actualSkillSync,
+    syncBundledSkillsFromPackage: (packageRoot: string, options?: any) =>
+      enableInstallMocks
+        ? {
+            installed: [],
+            skippedExisting: mockSkippedResult,
+            failed: mockFailedResult,
+            staged: mockStagedResult,
+            adopted: mockAdoptedResult,
+            customized: mockCustomizedResult,
+          }
+        : originalSyncBundledSkillsFromPackage(packageRoot, options),
+  };
+});
+
+mock.module('./config-manager', () => {
+  return {
+    ...actualConfigManager,
+    isOpenCodeInstalled: async () =>
+      enableInstallMocks ? true : originalIsOpenCodeInstalled(),
+    getOpenCodeVersion: async () =>
+      enableInstallMocks ? '1.0.0' : originalGetOpenCodeVersion(),
+    getOpenCodePath: () =>
+      enableInstallMocks
+        ? '/usr/local/bin/opencode'
+        : originalGetOpenCodePath(),
+    addPluginToOpenCodeConfig: async () =>
+      enableInstallMocks
+        ? { success: true, configPath: '/path' }
+        : originalAddPluginToOpenCodeConfig(),
+    addPluginToOpenCodeTuiConfig: async () =>
+      enableInstallMocks
+        ? { success: true, configPath: '/path' }
+        : originalAddPluginToOpenCodeTuiConfig(),
+    warmOpenCodePluginCache: async () =>
+      enableInstallMocks
+        ? { success: true, configPath: '/path' }
+        : originalWarmOpenCodePluginCache(),
+    disableDefaultAgents: () =>
+      enableInstallMocks
+        ? { success: true, configPath: '/path' }
+        : originalDisableDefaultAgents(),
+    enableLspByDefault: () =>
+      enableInstallMocks
+        ? { success: true, configPath: '/path' }
+        : originalEnableLspByDefault(),
+    detectCurrentConfig: () =>
+      enableInstallMocks
+        ? { isInstalled: true }
+        : originalDetectCurrentConfig(),
+    generateLiteConfig: (cfg: any) =>
+      enableInstallMocks ? {} : originalGenerateLiteConfig(cfg),
+    writeLiteConfig: (cfg: any, path?: string) =>
+      enableInstallMocks
+        ? { success: true, configPath: '/path' }
+        : originalWriteLiteConfig(cfg, path),
+  };
+});
+
+mock.module('./background-subagents', () => {
+  return {
+    ...actualBackgroundSubagents,
+    isBackgroundSubagentsEnabled: (env?: string) =>
+      enableInstallMocks ? true : originalIsBackgroundSubagentsEnabled(env),
+    detectBackgroundSubagentsTarget: () =>
+      enableInstallMocks ? '/path' : originalDetectBackgroundSubagentsTarget(),
+    expandHomePath: (p: string) =>
+      enableInstallMocks ? p : originalExpandHomePath(p),
+    getBackgroundSubagentsBlock: (target: string) =>
+      enableInstallMocks ? '' : originalGetBackgroundSubagentsBlock(target),
+    writeBackgroundSubagentsBlock: (target: string) =>
+      enableInstallMocks ? {} : originalWriteBackgroundSubagentsBlock(target),
+    manualBackgroundSubagentsInstructions: (opts?: any) =>
+      enableInstallMocks
+        ? ''
+        : originalManualBackgroundSubagentsInstructions(opts),
+  };
+});
+
+mock.module('./paths', () => {
+  return {
+    ...actualPaths,
+    getExistingLiteConfigPath: () =>
+      enableInstallMocks
+        ? '/path/lite-config.json'
+        : originalGetExistingLiteConfigPath(),
+  };
+});
+
 function baseConfig(): InstallConfig {
   return {
     hasTmux: false,
@@ -49,3 +185,204 @@ describe('shouldInstallCompanion', () => {
     expect(config.companion).toBe('no');
   });
 });
+
+describe('install skill synchronization error mapping', () => {
+  let logSpy: ReturnType<typeof mock>;
+  let originalConsoleLog: typeof console.log;
+
+  beforeEach(() => {
+    enableInstallMocks = true;
+    mockSkippedResult = [];
+    mockFailedResult = [];
+    mockStagedResult = [];
+    mockAdoptedResult = [];
+    mockCustomizedResult = [];
+    originalConsoleLog = console.log;
+    logSpy = mock(() => {});
+    console.log = logSpy;
+  });
+
+  afterEach(() => {
+    enableInstallMocks = false;
+    console.log = originalConsoleLog;
+  });
+
+  test('maps __lock__ to lock acquisition failure', async () => {
+    mockFailedResult = ['__lock__'];
+    const { install } = await import(`./install?test=${importCounter++}`);
+
+    await install({
+      skills: 'yes',
+      tui: false,
+      companion: 'no',
+    });
+
+    const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
+    const hasLockErr = calls.some((msg: string) =>
+      msg?.includes('Lock acquisition failed'),
+    );
+    expect(hasLockErr).toBe(true);
+
+    const hasRawSentinel = calls.some((msg: string) =>
+      msg?.includes('__lock__'),
+    );
+    expect(hasRawSentinel).toBe(false);
+
+    // Verify summary does not count __lock__ as a failed skill
+    const summaryMsg = calls.find((msg: string) =>
+      msg?.includes('Skill synchronization complete'),
+    );
+    expect(summaryMsg).toBeDefined();
+    expect(summaryMsg).toContain(
+      '0 staged, 0 adopted, 0 customized, 0 failed.',
+    );
+  });
+
+  test('maps __manifest__ to manifest write failure', async () => {
+    mockFailedResult = ['__manifest__'];
+    const { install } = await import(`./install?test=${importCounter++}`);
+
+    await install({
+      skills: 'yes',
+      tui: false,
+      companion: 'no',
+    });
+
+    const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
+    const hasManifestErr = calls.some((msg: string) =>
+      msg?.includes('Manifest write failed'),
+    );
+    expect(hasManifestErr).toBe(true);
+
+    const hasRawSentinel = calls.some((msg: string) =>
+      msg?.includes('__manifest__'),
+    );
+    expect(hasRawSentinel).toBe(false);
+
+    // Verify summary does not count __manifest__ as a failed skill
+    const summaryMsg = calls.find((msg: string) =>
+      msg?.includes('Skill synchronization complete'),
+    );
+    expect(summaryMsg).toBeDefined();
+    expect(summaryMsg).toContain(
+      '0 staged, 0 adopted, 0 customized, 0 failed.',
+    );
+  });
+
+  test('keeps normal skill names prefix as Failed: <name>', async () => {
+    mockFailedResult = ['some-custom-skill'];
+    const { install } = await import(`./install?test=${importCounter++}`);
+
+    await install({
+      skills: 'yes',
+      tui: false,
+      companion: 'no',
+    });
+
+    const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
+    const hasSkillErr = calls.some((msg: string) =>
+      msg?.includes('Failed: some-custom-skill'),
+    );
+    expect(hasSkillErr).toBe(true);
+
+    // Verify summary DOES count standard skill failures in the failed count
+    const summaryMsg = calls.find((msg: string) =>
+      msg?.includes('Skill synchronization complete'),
+    );
+    expect(summaryMsg).toBeDefined();
+    expect(summaryMsg).toContain(
+      '0 staged, 0 adopted, 0 customized, 1 failed.',
+    );
+  });
+
+  test('prints staged skills during sync', async () => {
+    mockStagedResult = ['staged-skill'];
+    const { install } = await import(`./install?test=${importCounter++}`);
+
+    await install({
+      skills: 'yes',
+      tui: false,
+      companion: 'no',
+    });
+
+    const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
+    expect(
+      calls.some((msg: string) =>
+        msg?.includes('Staged for review: staged-skill'),
+      ),
+    ).toBe(true);
+  });
+
+  test('prints adopted skills during sync', async () => {
+    mockAdoptedResult = ['adopted-skill'];
+    const { install } = await import(`./install?test=${importCounter++}`);
+
+    await install({
+      skills: 'yes',
+      tui: false,
+      companion: 'no',
+    });
+
+    const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
+    expect(
+      calls.some((msg: string) => msg?.includes('Adopted: adopted-skill')),
+    ).toBe(true);
+  });
+
+  test('prints customized skills during sync', async () => {
+    mockCustomizedResult = ['customized-skill'];
+    const { install } = await import(`./install?test=${importCounter++}`);
+
+    await install({
+      skills: 'yes',
+      tui: false,
+      companion: 'no',
+    });
+
+    const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
+    expect(
+      calls.some((msg: string) =>
+        msg?.includes('Customized: customized-skill'),
+      ),
+    ).toBe(true);
+  });
+
+  test('does not double-print categorized skipped skills', async () => {
+    mockSkippedResult = ['staged-skill', 'adopted-skill', 'customized-skill'];
+    mockStagedResult = ['staged-skill'];
+    mockAdoptedResult = ['adopted-skill'];
+    mockCustomizedResult = ['customized-skill'];
+    const { install } = await import(`./install?test=${importCounter++}`);
+
+    await install({
+      skills: 'yes',
+      tui: false,
+      companion: 'no',
+    });
+
+    const calls = logSpy.mock.calls.map((call: any[]) => call[0] as string);
+    expect(
+      calls.some((msg: string) =>
+        msg?.includes('Skipped/Preserved: staged-skill'),
+      ),
+    ).toBe(false);
+    expect(
+      calls.some((msg: string) =>
+        msg?.includes('Skipped/Preserved: adopted-skill'),
+      ),
+    ).toBe(false);
+    expect(
+      calls.some((msg: string) =>
+        msg?.includes('Skipped/Preserved: customized-skill'),
+      ),
+    ).toBe(false);
+
+    const summaryMsg = calls.find((msg: string) =>
+      msg?.includes('Skill synchronization complete'),
+    );
+    expect(summaryMsg).toBeDefined();
+    expect(summaryMsg).toContain(
+      '0 skipped/preserved, 1 staged, 1 adopted, 1 customized, 0 failed.',
+    );
+  });
+});

+ 67 - 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,77 @@ 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);
+        const categorizedSkipped = new Set([
+          ...result.staged,
+          ...result.adopted,
+          ...result.customized,
+        ]);
+        const preservedSkills = result.skippedExisting.filter(
+          (skill) => !categorizedSkipped.has(skill),
+        );
+
+        if (result.installed.length > 0) {
+          for (const skill of result.installed) {
+            printSuccess(`Installed/Updated: ${skill}`);
+          }
+        }
+        if (preservedSkills.length > 0) {
+          for (const skill of preservedSkills) {
+            printInfo(`Skipped/Preserved: ${skill}`);
+          }
+        }
+        if (result.failed.length > 0) {
+          for (const skill of result.failed) {
+            if (skill === '__lock__') {
+              printError('Lock acquisition failed');
+            } else if (skill === '__manifest__') {
+              printError('Manifest write failed');
+            } else {
+              printError(`Failed: ${skill}`);
+            }
+          }
+        }
+        if (result.staged.length > 0) {
+          for (const skill of result.staged) {
+            printInfo(`Staged for review: ${skill}`);
+          }
         }
+        if (result.adopted.length > 0) {
+          for (const skill of result.adopted) {
+            printInfo(`Adopted: ${skill}`);
+          }
+        }
+        if (result.customized.length > 0) {
+          for (const skill of result.customized) {
+            printInfo(`Customized: ${skill}`);
+          }
+        }
+
+        const realFailed = result.failed.filter(
+          (skill) => skill !== '__lock__' && skill !== '__manifest__',
+        );
+        printSuccess(
+          `Skill synchronization complete: ` +
+            `${result.installed.length} installed/updated, ` +
+            `${preservedSkills.length} skipped/preserved, ` +
+            `${result.staged.length} staged, ` +
+            `${result.adopted.length} adopted, ` +
+            `${result.customized.length} customized, ` +
+            `${realFailed.length} failed.`,
+        );
+      } catch (err) {
+        printError(`Failed to synchronize custom skills: ${err}`);
       }
-      const totalCustom = CUSTOM_SKILLS.length;
-      printSuccess(
-        `${customSkillsInstalled}/${totalCustom} custom skills processed`,
-      );
     }
   }
 

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

@@ -26,6 +26,9 @@ const skillSyncMocks = {
     installed: [],
     skippedExisting: [],
     failed: [],
+    staged: [],
+    adopted: [],
+    customized: [],
   })),
 };
 
@@ -141,6 +144,9 @@ describe('auto-update-checker/index', () => {
       installed: [],
       skippedExisting: [],
       failed: [],
+      staged: [],
+      adopted: [],
+      customized: [],
     }));
 
     companionUpdaterMocks.ensureCompanionVersion.mockReset();
@@ -252,6 +258,9 @@ describe('auto-update-checker/index', () => {
       installed: ['reflect', 'worktrees'],
       skippedExisting: ['codemap'],
       failed: [],
+      staged: [],
+      adopted: [],
+      customized: [],
     }));
 
     const { createAutoUpdateCheckerHook } = await import(
@@ -274,6 +283,46 @@ describe('auto-update-checker/index', () => {
     });
   });
 
+  test('includes staged and customized skills in success toast', async () => {
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: null,
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
+    skillSyncMocks.syncBundledSkillsFromPackage.mockImplementation(() => ({
+      installed: ['reflect'],
+      skippedExisting: [],
+      failed: [],
+      staged: ['worktrees'],
+      adopted: [],
+      customized: ['my-custom-skill'],
+    }));
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    const hook = createAutoUpdateCheckerHook(ctx as never);
+    hook.event({ event: { type: 'session.created', properties: {} } });
+    await waitForCalls(showToast);
+
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'OMO-Slim Updated!',
+        message:
+          'v0.9.1 → v0.9.11\nAdded bundled skills: reflect\nStaged skill updates: worktrees\nCustomized skills: my-custom-skill\nRestart OpenCode to apply.',
+        variant: 'success',
+        duration: 8000,
+      },
+    });
+  });
+
   test('updates enabled companion after plugin auto-update', async () => {
     checkerMocks.findPluginEntry.mockImplementation(() => ({
       pinnedVersion: null,
@@ -389,6 +438,9 @@ describe('auto-update-checker/index', () => {
       installed: [],
       skippedExisting: [],
       failed: ['reflect'],
+      staged: [],
+      adopted: [],
+      customized: [],
     }));
 
     const { createAutoUpdateCheckerHook } = await import(
@@ -633,4 +685,72 @@ 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',
+    );
+  });
+
+  test('logs staged and customized skills during startup reconciliation', async () => {
+    checkerMocks.getCurrentRuntimePackageJsonPath.mockImplementation(
+      () => '/tmp/opencode/package.json',
+    );
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: '0.9.11',
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => null);
+    skillSyncMocks.syncBundledSkillsFromPackage.mockImplementation(() => ({
+      installed: [],
+      skippedExisting: [],
+      failed: [],
+      staged: ['reflect'],
+      adopted: [],
+      customized: ['my-custom-skill'],
+    }));
+
+    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, 3);
+
+    const logs = logMock.mock.calls.map((entry: [string]) => entry[0]);
+
+    expect(logs).toContain(
+      '[auto-update-checker] Startup skill sync staged: reflect',
+    );
+    expect(logs).toContain(
+      '[auto-update-checker] Startup skill sync customized: my-custom-skill',
+    );
+  });
 });

+ 52 - 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,45 @@ async function runBackgroundUpdateCheck(
   autoUpdate: boolean,
   companion: AutoUpdateCheckerOptions['companion'],
 ): Promise<void> {
+  // Startup reconciliation (run once per top-level startup)
+  if (!hasReconciledAtStartup) {
+    try {
+      const runtimePackageJsonPath = getCurrentRuntimePackageJsonPath();
+      if (runtimePackageJsonPath) {
+        hasReconciledAtStartup = true;
+        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(', ')}`,
+          );
+        }
+        if (syncResult.staged.length > 0) {
+          log(
+            `[auto-update-checker] Startup skill sync staged: ${syncResult.staged.join(', ')}`,
+          );
+        }
+        if (syncResult.customized.length > 0) {
+          log(
+            `[auto-update-checker] Startup skill sync customized: ${syncResult.customized.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');
@@ -171,12 +213,16 @@ async function runBackgroundUpdateCheck(
 
   if (installSuccess) {
     let installedSkills: string[] = [];
+    let stagedSkills: string[] = [];
+    let customizedSkills: string[] = [];
     let companionUpdated = false;
     let companionWillRetry = false;
     const packageRoot = path.join(installDir, 'node_modules', PACKAGE_NAME);
     try {
       const syncResult = syncBundledSkillsFromPackage(packageRoot);
       installedSkills = syncResult.installed;
+      stagedSkills = syncResult.staged;
+      customizedSkills = syncResult.customized;
       if (syncResult.failed.length > 0) {
         log(
           `[auto-update-checker] Skill sync warnings/failures: ${syncResult.failed.join(', ')}`,
@@ -225,6 +271,12 @@ async function runBackgroundUpdateCheck(
     if (installedSkills.length > 0) {
       messageLines.push(`Added bundled skills: ${installedSkills.join(', ')}`);
     }
+    if (stagedSkills.length > 0) {
+      messageLines.push(`Staged skill updates: ${stagedSkills.join(', ')}`);
+    }
+    if (customizedSkills.length > 0) {
+      messageLines.push(`Customized skills: ${customizedSkills.join(', ')}`);
+    }
     if (companionUpdated) {
       messageLines.push('Companion updated.');
     } else if (companionWillRetry) {

+ 1411 - 4
src/hooks/auto-update-checker/skill-sync.test.ts

@@ -1,13 +1,42 @@
-import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
 import * as fs from 'node:fs';
 import * as os from 'node:os';
 import * as path from 'node:path';
 
+let shouldFailRename = false;
+
+mock.module('node:fs', () => {
+  const actualFs = require('node:fs');
+  return {
+    ...actualFs,
+    renameSync: (src: string, dest: string) => {
+      if (shouldFailRename && src.includes('recovery-failure-test-skill')) {
+        throw new Error('Mocked rename failure');
+      }
+      return actualFs.renameSync(src, dest);
+    },
+  };
+});
+
 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', () => {
@@ -17,6 +46,7 @@ describe('syncBundledSkillsFromPackage', () => {
   let origEnvConfigDir: string | undefined;
 
   beforeEach(() => {
+    shouldFailRename = false;
     origEnvConfigDir = process.env.OPENCODE_CONFIG_DIR;
     // Create a unique temporary directory for this test run
     const randomId = Math.random().toString(36).substring(2, 10);
@@ -156,8 +186,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 +297,1381 @@ 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');
+
+    const manifestParsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
+    expect(manifestParsed.schemaVersion).toBe(1);
+    expect(manifestParsed.skills[missingSkill].status).toBe('managed');
+    expect(manifestParsed.skills[existingSkill].status).toBe('customized');
+  });
+
+  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('recovers conflict status when destination is now a directory', async () => {
+    const skillName = 'conflict-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Bundled 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: 'conflict',
+          packageVersion: '1.0.0',
+          sourceHash: 'old-source-hash',
+          lastManagedHash: 'old-managed-hash',
+          lastSeenHash: 'old-seen-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'),
+      '# Customized Content',
+    );
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.customized).toContain(skillName);
+    const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
+    expect(manifest.skills[skillName].status).toBe('customized');
+    const stagedPath = manifest.skills[skillName].stagedPath as string;
+    expect(stagedPath).toBeDefined();
+    expect(fs.readFileSync(path.join(stagedPath, 'SKILL.md'), 'utf-8')).toBe(
+      '# Bundled Content',
+    );
+  });
+
+  test('conflict recovery deletes staged directory when adopted back as managed', async () => {
+    const skillName = 'conflict-adopt-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Bundle Content');
+
+    const manifestDir = path.join(fakeDestConfigDir, '.oh-my-opencode-slim');
+    fs.mkdirSync(manifestDir, { recursive: true });
+    const manifestPath = path.join(manifestDir, 'skills-manifest.json');
+
+    const stagedDir = path.join(
+      manifestDir,
+      'skill-updates',
+      '1.0.0',
+      skillName,
+    );
+    fs.mkdirSync(stagedDir, { recursive: true });
+    fs.writeFileSync(path.join(stagedDir, 'SKILL.md'), '# Staged');
+
+    const initialManifest = {
+      schemaVersion: 1,
+      updatedAt: new Date().toISOString(),
+      skills: {
+        [skillName]: {
+          status: 'conflict',
+          packageVersion: '1.0.0',
+          sourceHash: 'old-source-hash',
+          lastManagedHash: 'old-managed-hash',
+          lastSeenHash: 'old-seen-hash',
+          stagedPath: stagedDir,
+          updatedAt: new Date().toISOString(),
+        },
+      },
+    };
+    fs.writeFileSync(manifestPath, JSON.stringify(initialManifest, null, 2));
+
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    fs.mkdirSync(destSkillsDir, { recursive: true });
+    // Destination matches the incoming source content exactly
+    const destSkillDir = path.join(destSkillsDir, skillName);
+    fs.mkdirSync(destSkillDir, { recursive: true });
+    fs.writeFileSync(path.join(destSkillDir, 'SKILL.md'), '# Bundle 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();
+    expect(fs.existsSync(stagedDir)).toBe(false);
+  });
+
+  test('conflict overwrite deletes staged directory when destination becomes a non-directory file/symlink', async () => {
+    const skillName = 'conflict-file-overwrite-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Bundle Content');
+
+    const manifestDir = path.join(fakeDestConfigDir, '.oh-my-opencode-slim');
+    fs.mkdirSync(manifestDir, { recursive: true });
+    const manifestPath = path.join(manifestDir, 'skills-manifest.json');
+
+    const stagedDir = path.join(
+      manifestDir,
+      'skill-updates',
+      '1.0.0',
+      skillName,
+    );
+    fs.mkdirSync(stagedDir, { recursive: true });
+    fs.writeFileSync(path.join(stagedDir, 'SKILL.md'), '# Staged');
+
+    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: 'old-seen-hash',
+          stagedPath: stagedDir,
+          updatedAt: new Date().toISOString(),
+        },
+      },
+    };
+    fs.writeFileSync(manifestPath, JSON.stringify(initialManifest, null, 2));
+
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    fs.mkdirSync(destSkillsDir, { recursive: true });
+    // Destination is a file (conflict)
+    const destSkillPath = path.join(destSkillsDir, skillName);
+    fs.writeFileSync(destSkillPath, 'some conflict file');
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.skippedExisting).toContain(skillName);
+    const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
+    expect(manifest.skills[skillName].status).toBe('conflict');
+    expect(manifest.skills[skillName].stagedPath).toBeUndefined();
+    expect(fs.existsSync(stagedDir)).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 stagedDir = path.join(
+      manifestDir,
+      'skill-updates',
+      '1.0.0',
+      skillName,
+    );
+    fs.mkdirSync(stagedDir, { recursive: true });
+    fs.writeFileSync(path.join(stagedDir, 'SKILL.md'), '# Staged');
+
+    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: stagedDir,
+          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();
+    expect(fs.existsSync(stagedDir)).toBe(false);
+  });
+
+  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('cross-host lock: fails closed for fresh locks from another host', async () => {
+    const skillName = 'cross-host-lock-fresh';
+    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: 1234,
+      host: 'another-host',
+      time: Date.now() - 30 * 1000, // 30 seconds ago
+    };
+    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('cross-host lock: reclaims lock for stale locks from another host', async () => {
+    const skillName = 'cross-host-lock-stale';
+    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 staleOwner = {
+      pid: 1234,
+      host: 'another-host',
+      time: Date.now() - 6 * 60 * 1000, // 6 minutes ago
+    };
+    fs.writeFileSync(
+      path.join(lockDir, 'owner.json'),
+      JSON.stringify(staleOwner),
+      'utf-8',
+    );
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.failed).not.toContain('__lock__');
+    expect(result.installed).toContain(skillName);
+  });
+
+  test('managed skill: updates packageVersion and updatedAt metadata even if content is unchanged', async () => {
+    const skillName = 'version-update-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Managed Content');
+
+    const manifestDir = path.join(fakeDestConfigDir, '.oh-my-opencode-slim');
+    fs.mkdirSync(manifestDir, { recursive: true });
+    const manifestPath = path.join(manifestDir, 'skills-manifest.json');
+
+    const originalTime = new Date(
+      Date.now() - 24 * 60 * 60 * 1000,
+    ).toISOString();
+    const initialManifest = {
+      schemaVersion: 1,
+      updatedAt: originalTime,
+      skills: {
+        [skillName]: {
+          status: 'managed',
+          packageVersion: '1.0.0',
+          sourceHash: '', // Will be calculated and match below
+          lastManagedHash: '',
+          lastSeenHash: '',
+          updatedAt: originalTime,
+        },
+      },
+    };
+
+    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'), '# Managed Content');
+
+    const { computeDirectoryHash } = await import(
+      `./skill-sync?test=${importCounter++}`
+    );
+    const hashVal = computeDirectoryHash(destSkillDir);
+    initialManifest.skills[skillName].sourceHash = 'stale-source-hash';
+    initialManifest.skills[skillName].lastManagedHash = hashVal;
+    initialManifest.skills[skillName].lastSeenHash = 'stale-seen-hash';
+
+    fs.writeFileSync(manifestPath, JSON.stringify(initialManifest, null, 2));
+
+    // Write a mock version to package.json
+    fs.writeFileSync(
+      path.join(fakePackageRoot, 'package.json'),
+      JSON.stringify({ version: '1.2.3' }),
+    );
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.skippedExisting).toContain(skillName);
+    const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
+    expect(manifest.skills[skillName].packageVersion).toBe('1.2.3');
+    expect(manifest.skills[skillName].sourceHash).toBe(hashVal);
+    expect(manifest.skills[skillName].lastManagedHash).toBe(hashVal);
+    expect(manifest.skills[skillName].lastSeenHash).toBe(hashVal);
+    expect(manifest.skills[skillName].updatedAt).not.toBe(originalTime);
+  });
+
+  test('directory hashing symlink consistency: ignores symlinks when hashing, matching copy semantics', async () => {
+    const skillName = 'symlink-hash-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Bundle Content');
+
+    // Create a symlink in the source directory
+    const linkTarget = path.join(tempDir, 'outside-target.txt');
+    fs.writeFileSync(linkTarget, 'target content');
+    fs.symlinkSync(linkTarget, path.join(skillSrcDir, 'a-link.txt'));
+
+    const { computeDirectoryHash } = await import(
+      `./skill-sync?test=${importCounter++}`
+    );
+
+    const sourceHashWithSymlink = computeDirectoryHash(skillSrcDir);
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+    expect(result.installed).toContain(skillName);
+
+    const destSkillDir = path.join(fakeDestConfigDir, 'skills', skillName);
+    expect(fs.existsSync(destSkillDir)).toBe(true);
+
+    // The destination directory should have the symlink omitted due to copyDirRecursive
+    expect(fs.existsSync(path.join(destSkillDir, 'a-link.txt'))).toBe(false);
+
+    // The computed hash of destination should match the source hash
+    const destHash = computeDirectoryHash(destSkillDir);
+    expect(destHash).toBe(sourceHashWithSymlink);
+  });
+
+  test('deleted to customized: stages and marks customized', async () => {
+    const skillName = 'recreated-custom-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(skillSrcDir, 'SKILL.md'),
+      '# Current Bundled 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: 'deleted',
+          packageVersion: '1.0.0',
+          sourceHash: 'old-source-hash',
+          lastManagedHash: 'old-managed-hash',
+          lastSeenHash: '',
+          updatedAt: new Date().toISOString(),
+        },
+      },
+    };
+    fs.writeFileSync(manifestPath, JSON.stringify(initialManifest, null, 2));
+
+    // Create the destination but with customized content (doesn't match current source)
+    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'),
+      '# Customized Content',
+    );
+
+    // Mock version in package.json
+    fs.writeFileSync(
+      path.join(fakePackageRoot, 'package.json'),
+      JSON.stringify({ version: '1.2.3' }),
+    );
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.skippedExisting).toContain(skillName);
+    const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
+    expect(manifest.skills[skillName].status).toBe('customized');
+    expect(manifest.skills[skillName].packageVersion).toBe('1.2.3');
+    const { computeDirectoryHash } = await import(
+      `./skill-sync?test=${importCounter++}`
+    );
+    expect(manifest.skills[skillName].sourceHash).toBe(
+      computeDirectoryHash(skillSrcDir),
+    );
+    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 Content',
+    );
+  });
+
+  test('orphan artifact matching: avoids prefix collisions', async () => {
+    const skillName = 'foo';
+    const otherSkill = 'foo-bar';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Foo Content');
+
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    fs.mkdirSync(destSkillsDir, { recursive: true });
+
+    // Staging and backup folders for foo-bar
+    const otherBackup = path.join(
+      destSkillsDir,
+      `.backup-${otherSkill}-1700000000000-asdfasd`,
+    );
+    fs.mkdirSync(otherBackup, { recursive: true });
+    fs.writeFileSync(path.join(otherBackup, 'SKILL.md'), '# Foo Bar Backup');
+
+    // Run sync for foo. Because foo is missing at dest, it would normally trigger orphan recovery.
+    // If it is prefix-collision matching, it would match foo-bar backup and restore it!
+    // But with our delimiter-safe matching, it should ignore foreign backups of foo-bar and install foo fresh.
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.installed).toContain(skillName);
+    expect(fs.existsSync(path.join(destSkillsDir, skillName))).toBe(true);
+    expect(
+      fs.readFileSync(path.join(destSkillsDir, skillName, 'SKILL.md'), 'utf-8'),
+    ).toBe('# Foo Content');
+    // foo-bar's backup folder must STILL exist and NOT be deleted or recovered
+    expect(fs.existsSync(otherBackup)).toBe(true);
+  });
+
+  test('corrupt manifest recovery: conservative reconciliation and writing fresh manifest', async () => {
+    const skillName = 'reconciliation-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Bundled Content');
+
+    const customizedSkillName = 'reconciliation-customized-skill';
+    const customizedSkillSrcDir = path.join(
+      fakePackageRoot,
+      'src',
+      'skills',
+      customizedSkillName,
+    );
+    fs.mkdirSync(customizedSkillSrcDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(customizedSkillSrcDir, 'SKILL.md'),
+      '# Clean Source',
+    );
+
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    fs.mkdirSync(destSkillsDir, { recursive: true });
+
+    // Customized skill exists at destination but with customized content
+    const destCustomizedSkillDir = path.join(
+      destSkillsDir,
+      customizedSkillName,
+    );
+    fs.mkdirSync(destCustomizedSkillDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(destCustomizedSkillDir, 'SKILL.md'),
+      '# Customized Content',
+    );
+
+    const manifestDir = path.join(fakeDestConfigDir, '.oh-my-opencode-slim');
+    fs.mkdirSync(manifestDir, { recursive: true });
+    const manifestPath = path.join(manifestDir, 'skills-manifest.json');
+    // Write corrupt manifest content
+    fs.writeFileSync(manifestPath, '{ corrupt json...', 'utf-8');
+
+    // Write mock package.json version
+    fs.writeFileSync(
+      path.join(fakePackageRoot, 'package.json'),
+      JSON.stringify({ version: '1.2.3' }),
+    );
+
+    // Call sync. It should notice it is corrupt, do conservative reconciliation, and replace the corrupt manifest.
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    // Since destination didn't exist, it should install it
+    expect(result.installed).toContain(skillName);
+    expect(result.skippedExisting).toContain(customizedSkillName);
+    expect(result.customized).toContain(customizedSkillName);
+
+    // The manifest should be successfully reconciled and written as valid JSON
+    expect(fs.existsSync(manifestPath)).toBe(true);
+    const manifestContent = fs.readFileSync(manifestPath, 'utf-8');
+    expect(manifestContent).not.toContain('corrupt json');
+    const parsed = JSON.parse(manifestContent);
+    expect(parsed.schemaVersion).toBe(1);
+    expect(parsed.skills[skillName].status).toBe('managed');
+    expect(parsed.skills[skillName].packageVersion).toBe('1.2.3');
+
+    // Customized skill should be marked customized with correct stagedPath
+    const custEntry = parsed.skills[customizedSkillName];
+    expect(custEntry.status).toBe('customized');
+    expect(custEntry.stagedPath).toBeDefined();
+    expect(fs.existsSync(custEntry.stagedPath)).toBe(true);
+    expect(
+      fs.readFileSync(path.join(custEntry.stagedPath, 'SKILL.md'), 'utf-8'),
+    ).toBe('# Clean Source');
+  });
+
+  test('lock owner-safety: releaseLock cleans up its own lock even if owner.json is missing', async () => {
+    const { acquireLock, releaseLock } = await import(
+      `./skill-sync?test=${importCounter++}`
+    );
+    const lockDir = path.join(
+      fakeDestConfigDir,
+      'test-missing-owner-json.lock',
+    );
+
+    // Acquire the lock first
+    const acquired = acquireLock(lockDir);
+    expect(acquired).toBe(true);
+
+    // Delete owner.json to simulate missing metadata
+    const metadataPath = path.join(lockDir, 'owner.json');
+    if (fs.existsSync(metadataPath)) {
+      fs.unlinkSync(metadataPath);
+    }
+
+    // Now call releaseLock
+    releaseLock(lockDir);
+
+    // The lock directory should be deleted successfully!
+    expect(fs.existsSync(lockDir)).toBe(false);
+  });
+
+  test('staged path safety: does not delete staging directories outside managed root', async () => {
+    const skillName = 'safety-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');
+
+    // Create an outside directory that shouldn't be deleted!
+    const outsideStagedDir = path.join(tempDir, 'outside-staged-path');
+    fs.mkdirSync(outsideStagedDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(outsideStagedDir, 'SKILL.md'),
+      '# Outside Content',
+    );
+
+    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: outsideStagedDir,
+          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();
+
+    // The outside staged directory must NOT have been deleted!
+    expect(fs.existsSync(outsideStagedDir)).toBe(true);
+  });
+
+  test('lock owner-safety: releaseLock does not delete a lock owned by another host/process/token', async () => {
+    const { releaseLock } = await import(
+      `./skill-sync?test=${importCounter++}`
+    );
+    const lockDir = path.join(fakeDestConfigDir, 'test-owner-safety-fail.lock');
+    fs.mkdirSync(lockDir, { recursive: true });
+
+    // Lock is owned by someone else
+    const foreignOwner = {
+      pid: 99999,
+      host: 'foreign-host',
+      time: Date.now(),
+      token: 'foreign-token',
+    };
+    fs.writeFileSync(
+      path.join(lockDir, 'owner.json'),
+      JSON.stringify(foreignOwner),
+      'utf-8',
+    );
+
+    releaseLock(lockDir);
+
+    expect(fs.existsSync(lockDir)).toBe(true);
+    expect(fs.existsSync(path.join(lockDir, 'owner.json'))).toBe(true);
+  });
+
+  test('lock owner-safety: releaseLock deletes a lock owned by this process/token', async () => {
+    const { releaseLock } = await import(
+      `./skill-sync?test=${importCounter++}`
+    );
+    const lockDir = path.join(
+      fakeDestConfigDir,
+      'test-owner-safety-valid.lock',
+    );
+    fs.mkdirSync(lockDir, { recursive: true });
+
+    // Lock is owned by us
+    const ourOwner = {
+      pid: process.pid,
+      host: require('node:os').hostname(),
+      time: Date.now(),
+      // We retrieve process token from globalThis or module
+      token: (globalThis as any).OMO_SKILL_SYNC_PROCESS_TOKEN,
+    };
+    fs.writeFileSync(
+      path.join(lockDir, 'owner.json'),
+      JSON.stringify(ourOwner),
+      'utf-8',
+    );
+
+    releaseLock(lockDir);
+
+    expect(fs.existsSync(lockDir)).toBe(false);
+  });
+
+  test('lock owner-safety: releaseLock does not delete lock if metadata is overwritten by a foreign owner', async () => {
+    const { acquireLock, releaseLock } = await import(
+      `./skill-sync?test=${importCounter++}`
+    );
+    const lockDir = path.join(
+      fakeDestConfigDir,
+      'test-overwritten-metadata.lock',
+    );
+
+    // Acquire and track the path in memory
+    const acquired = acquireLock(lockDir);
+    expect(acquired).toBe(true);
+
+    // Overwrite owner.json with a foreign owner
+    const foreignOwner = {
+      pid: 99999,
+      host: 'foreign-host',
+      time: Date.now(),
+      token: 'foreign-token',
+    };
+    fs.writeFileSync(
+      path.join(lockDir, 'owner.json'),
+      JSON.stringify(foreignOwner),
+      'utf-8',
+    );
+
+    // releaseLock should NOT delete the lock directory because metadata is authoritative and foreign
+    releaseLock(lockDir);
+
+    expect(fs.existsSync(lockDir)).toBe(true);
+    expect(fs.existsSync(path.join(lockDir, 'owner.json'))).toBe(true);
+  });
+
+  test('customized to deleted transition: removes staged directory and metadata', async () => {
+    const skillName = 'customized-deleted-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Source');
+
+    const manifestDir = path.join(fakeDestConfigDir, '.oh-my-opencode-slim');
+    fs.mkdirSync(manifestDir, { recursive: true });
+    const manifestPath = path.join(manifestDir, 'skills-manifest.json');
+
+    // Create a mock staged directory for this customized skill
+    const stagedDir = path.join(
+      manifestDir,
+      'skill-updates',
+      '1.0.0',
+      skillName,
+    );
+    fs.mkdirSync(stagedDir, { recursive: true });
+    fs.writeFileSync(path.join(stagedDir, 'SKILL.md'), '# Staged Update');
+
+    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: stagedDir,
+          updatedAt: new Date().toISOString(),
+        },
+      },
+    };
+    fs.writeFileSync(manifestPath, JSON.stringify(initialManifest, null, 2));
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.skippedExisting).toContain(skillName);
+    const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
+    const entry = manifest.skills[skillName];
+    expect(entry.status).toBe('deleted');
+    expect(entry.stagedPath).toBeUndefined();
+    expect((entry as any).stagedVersion).toBeUndefined();
+    expect((entry as any).stagedHash).toBeUndefined();
+
+    // The staged directory on-disk must be deleted successfully!
+    expect(fs.existsSync(stagedDir)).toBe(false);
+  });
+
+  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.installed).toContain(skillName);
+    expect(fs.existsSync(path.join(destSkillDir, 'user-link'))).toBe(false);
+    expect(fs.readFileSync(path.join(destSkillDir, 'SKILL.md'), 'utf-8')).toBe(
+      '# Updated',
+    );
+  });
+
+  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');
+  });
+
+  test('crash safe recovery: preserves most recent backup when renameSync fails', async () => {
+    const skillName = 'recovery-failure-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 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));
+
+    shouldFailRename = true;
+
+    await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    // The destination directory should not exist (since rename failed)
+    const destSkillDir = path.join(destSkillsDir, skillName);
+    expect(fs.existsSync(destSkillDir)).toBe(false);
+
+    // The most recent backup directory should still exist and not be cleaned up
+    expect(fs.existsSync(backupDir)).toBe(true);
+    expect(fs.readFileSync(path.join(backupDir, 'SKILL.md'), 'utf-8')).toBe(
+      '# Backup Content',
+    );
+  });
+
+  test('LEGACY_MANAGED_SKILL_HASHES: is exported as a record mapping string keys to string arrays', async () => {
+    const { LEGACY_MANAGED_SKILL_HASHES } = await import(
+      `./skill-sync?test=${importCounter++}`
+    );
+    expect(typeof LEGACY_MANAGED_SKILL_HASHES).toBe('object');
+    expect(LEGACY_MANAGED_SKILL_HASHES).not.toBeNull();
+    for (const key of Object.keys(LEGACY_MANAGED_SKILL_HASHES)) {
+      expect(typeof key).toBe('string');
+      expect(Array.isArray(LEGACY_MANAGED_SKILL_HASHES[key])).toBe(true);
+    }
+  });
+
+  test('conflict staging failure: failed skills are not double-counted as skippedExisting', async () => {
+    const skillName = 'test-double-counting-skill';
+    const skillSrcDir = path.join(fakePackageRoot, 'src', 'skills', skillName);
+    fs.mkdirSync(skillSrcDir, { recursive: true });
+    fs.writeFileSync(path.join(skillSrcDir, 'SKILL.md'), '# Bundle 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: 'conflict',
+          packageVersion: '1.0.0',
+          sourceHash: 'old-source-hash',
+          lastManagedHash: 'old-managed-hash',
+          lastSeenHash: 'old-seen-hash',
+          updatedAt: new Date().toISOString(),
+        },
+      },
+    };
+    fs.writeFileSync(manifestPath, JSON.stringify(initialManifest, null, 2));
+
+    const destSkillsDir = path.join(fakeDestConfigDir, 'skills');
+    fs.mkdirSync(destSkillsDir, { recursive: true });
+    // Destination is different (so we fall to destHash !== sourceHash, which attempts staging)
+    const destSkillDir = path.join(destSkillsDir, skillName);
+    fs.mkdirSync(destSkillDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(destSkillDir, 'SKILL.md'),
+      '# Different Content',
+    );
+
+    // Force staging copy recursive to fail by making dest directory nested path locked
+    const stagedSkillDir = path.join(
+      manifestDir,
+      'skill-updates',
+      '1.2.3',
+      skillName,
+    );
+    // Write package.json mock version
+    fs.writeFileSync(
+      path.join(fakePackageRoot, 'package.json'),
+      JSON.stringify({ version: '1.2.3' }),
+    );
+
+    // Make fs mkdirSync throw on stagedSkillDir or hijack mkdirSync via mock module if possible and cleaner, OR lock it!
+    // Locking stagedParent updates directory
+    const stagedParent = path.dirname(stagedSkillDir);
+    fs.mkdirSync(stagedParent, { recursive: true });
+    fs.chmodSync(stagedParent, 0o000);
+
+    const result = await syncBundledSkillsFromPackage(fakePackageRoot);
+
+    expect(result.failed).toContain(skillName);
+    expect(result.skippedExisting).not.toContain(skillName);
+
+    // Reset permissions so afterEach cleanup succeeds
+    fs.chmodSync(stagedParent, 0o777);
+  });
 });

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

@@ -1,21 +1,120 @@
+import * as crypto from 'node:crypto';
 import {
   copyFileSync,
   existsSync,
   lstatSync,
   mkdirSync,
-  mkdtempSync,
   readdirSync,
+  readFileSync,
   renameSync,
   rmSync,
+  unlinkSync,
+  writeFileSync,
 } from 'node:fs';
+import * as os from 'node:os';
 import * as path from 'node:path';
+import { CUSTOM_SKILLS } from '../../cli/custom-skills-registry';
 import { getConfigDir } from '../../cli/paths';
 import { log } from '../../utils/logger';
 
+let localProcessToken = (
+  globalThis as { OMO_SKILL_SYNC_PROCESS_TOKEN?: string }
+).OMO_SKILL_SYNC_PROCESS_TOKEN;
+if (!localProcessToken) {
+  localProcessToken = crypto.randomUUID();
+  (
+    globalThis as { OMO_SKILL_SYNC_PROCESS_TOKEN?: string }
+  ).OMO_SKILL_SYNC_PROCESS_TOKEN = localProcessToken;
+}
+const PROCESS_TOKEN = localProcessToken;
+
+const ACQUIRED_LOCKS = new Set<string>();
+
 export interface SkillSyncResult {
   installed: string[];
   skippedExisting: string[];
   failed: 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.
+ *
+ * How to populate:
+ * 1. Download previous releases of the npm package: `npm pack oh-my-opencode-slim@<version>`
+ * 2. Compute directory hash for each legacy skill directory inside the unpacked tarball:
+ *    `import { computeDirectoryHash } from './skill-sync';`
+ *    `const hash = computeDirectoryHash('path/to/extracted/package/src/skills/<skill-name>');`
+ * 3. Append the hash to the skill's string array below:
+ *    ```typescript
+ *    export const LEGACY_MANAGED_SKILL_HASHES: Record<string, string[]> = {
+ *      'simplify': ['hash1', 'hash2'],
+ *      'codemap': ['hash3']
+ *    };
+ *    ```
+ */
+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 +140,424 @@ 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';
+    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()) {
+        continue;
+      }
+      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) => {
+    if (a.relativePath < b.relativePath) return -1;
+    if (a.relativePath > b.relativePath) return 1;
+    return 0;
+  });
+
+  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 = readFileSync(entry.absolutePath);
+      hash.update(content);
+    }
+  }
+
+  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';
+  }
+}
+
+const CROSS_HOST_LOCK_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes
+
+/**
+ * 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.
+ */
+export 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(),
+        token: PROCESS_TOKEN,
+      };
+      writeFileSync(metadataPath, JSON.stringify(metadata), 'utf-8');
+    } catch {
+      // Ignored
+    }
+  };
+
+  try {
+    mkdirSync(lockDir);
+    writeMetadata();
+    ACQUIRED_LOCKS.add(path.resolve(lockDir));
+    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 = 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 {
+          if (ageMs > CROSS_HOST_LOCK_EXPIRY_MS) {
+            log(
+              `[skill-sync] Lock owned by different host ${metadata.host} has expired (${Math.round(ageMs / 1000)}s old). Reclaiming lock.`,
+            );
+            shouldSteal = true;
+          } else {
+            log(
+              `[skill-sync] Lock is owned by different host ${metadata.host}; failing closed.`,
+            );
+          }
+        }
+      } catch {
+        shouldSteal = true;
+      }
+    } else {
+      const stat = lstatSync(lockDir);
+      ageMs = Date.now() - stat.mtimeMs;
+      if (ageMs > 30000) {
+        shouldSteal = true;
+      }
+    }
+
+    if (!shouldSteal) return false;
+
+    log(`[skill-sync] Stealing/recovering lock directory.`);
+    rmSync(lockDir, { recursive: true, force: true });
+    mkdirSync(lockDir);
+    writeMetadata();
+    ACQUIRED_LOCKS.add(path.resolve(lockDir));
+    return true;
+  } catch (err) {
+    log(`[skill-sync] Failed to check/recover lock at ${lockDir}:`, err);
+    return false;
+  }
+}
+
+/**
+ * Releases the lock.
+ */
+export function releaseLock(lockDir: string): void {
+  const resolvedPath = path.resolve(lockDir);
+  try {
+    let isOurLock = false;
+    const metadataPath = path.join(lockDir, 'owner.json');
+
+    if (existsSync(metadataPath)) {
+      try {
+        const content = readFileSync(metadataPath, 'utf-8');
+        const metadata = JSON.parse(content);
+        if (
+          metadata.host === os.hostname() &&
+          metadata.pid === process.pid &&
+          metadata.token === PROCESS_TOKEN
+        ) {
+          isOurLock = true;
+        } else {
+          isOurLock = false;
+        }
+      } catch (err) {
+        log(`[skill-sync] Lock owner.json is unreadable/corrupt:`, err);
+        isOurLock = false;
+      }
+    } else if (ACQUIRED_LOCKS.has(resolvedPath)) {
+      isOurLock = true;
+    }
+
+    if (isOurLock) {
+      if (existsSync(lockDir)) {
+        rmSync(lockDir, { recursive: true, force: true });
+      }
+    } else if (existsSync(lockDir)) {
+      log(
+        `[skill-sync] Skipping lock directory removal: lock is not owned by this process/token or owner.json check failed.`,
+      );
+    }
+  } catch (err) {
+    log(`[skill-sync] Failed to release lock at ${lockDir}:`, err);
+  } finally {
+    ACQUIRED_LOCKS.delete(resolvedPath);
+  }
+}
+
+/**
+ * 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) {
+      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)) {
+          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)) {
+        rmSync(stagingDir, { recursive: true, force: true });
+      }
+    } catch {}
+
+    throw err;
+  }
+}
+
+/**
+ * Verifies if an entry matches .backup-${skillName}-${uniqueSuffix} or .staging-${skillName}-${uniqueSuffix}.
+ */
+function matchesArtifactPattern(
+  entry: string,
+  prefix: string,
+  skillName: string,
+): boolean {
+  if (!entry.startsWith(prefix)) return false;
+  const rest = entry.slice(prefix.length);
+  if (!rest.startsWith(`${skillName}-`)) return false;
+
+  const suffix = rest.slice(skillName.length + 1);
+  const firstPart = suffix.split('-')[0];
+  const timestamp = Number(firstPart);
+  if (Number.isNaN(timestamp) || timestamp <= 0) return false;
+
+  return true;
+}
+
+/**
+ * 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 (matchesArtifactPattern(entry, '.backup-', skillName)) {
+      backups.push(path.join(destSkillsDir, entry));
+      hadArtifacts = true;
+    } else if (matchesArtifactPattern(entry, '.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)) {
+      backups.pop();
+      try {
+        renameSync(mostRecentBackup, destPath);
+        log(
+          `[skill-sync] Recovered backup for ${skillName} back to destination.`,
+        );
+      } catch (err) {
+        log(`[skill-sync] Failed to restore backup for ${skillName}:`, err);
+      }
+    }
+
+    for (const backup of backups) {
+      try {
+        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 {
+      rmSync(staging, { recursive: true, force: true });
+    } catch (err) {
+      log(`[skill-sync] Failed to clean up staging folder ${staging}:`, err);
+    }
+  }
+
+  return hadArtifacts;
+}
+
+/**
+ * Safely removes a directory only if it resides within the plugin staged updates directory.
+ */
+function removeManagedStagedPath(
+  stagedPath: string,
+  manifestDir: string,
+  skillName: string,
+): void {
+  try {
+    const absoluteStagedPath = path.resolve(stagedPath);
+    const absoluteAllowedRoot = path.resolve(
+      path.join(manifestDir, 'skill-updates'),
+    );
+
+    const relative = path.relative(absoluteAllowedRoot, absoluteStagedPath);
+    const isUnderRoot =
+      relative && !relative.startsWith('..') && !path.isAbsolute(relative);
+
+    if (isUnderRoot) {
+      if (existsSync(absoluteStagedPath)) {
+        rmSync(absoluteStagedPath, { recursive: true, force: true });
+        log(
+          `[skill-sync] Safely cleaned up staged path for ${skillName}: ${absoluteStagedPath}`,
+        );
+      }
+    } else {
+      log(
+        `[skill-sync] Refusing to delete staged path for ${skillName}: path ${absoluteStagedPath} is not under managed root ${absoluteAllowedRoot}`,
+      );
+    }
+  } catch (err) {
+    log(
+      `[skill-sync] Error while trying to verify and remove staged path for ${skillName} (${stagedPath}):`,
+      err,
+    );
+  }
+}
+
 /**
  * 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 staged: string[] = [];
+  const adopted: string[] = [];
+  const customized: string[] = [];
 
   const sourceSkillsDir = path.join(packageRoot, 'src', 'skills');
 
@@ -59,120 +567,714 @@ export function syncBundledSkillsFromPackage(
       log(
         `[skill-sync] Source skills directory is not a valid directory: ${sourceSkillsDir}`,
       );
-      return { installed, skippedExisting, failed };
+      return {
+        installed,
+        skippedExisting,
+        failed,
+        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,
+      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 = 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__'],
+      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 = 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);
-        } else {
-          renameSync(stagingDir, destPath);
-          installed.push(entry);
-          log(`[skill-sync] Successfully synced skill: ${entry}`);
+          skippedExisting.push(skill.name);
+          const sourceHash = computeDirectoryHash(sourcePath);
+          const entry = manifest.skills[skill.name];
+          if (entry?.stagedPath) {
+            removeManagedStagedPath(entry.stagedPath, manifestDir, skill.name);
+          }
+          manifest.skills[skill.name] = {
+            status: 'conflict',
+            packageVersion,
+            sourceHash,
+            lastManagedHash: '',
+            lastSeenHash: '',
+            updatedAt: new Date().toISOString(),
+          };
+          continue;
         }
-      } catch (err) {
-        log(`[skill-sync] Failed to sync skill ${entry}:`, err);
-        failed.push(entry);
-      } finally {
-        try {
-          if (existsSync(stagingDir)) {
-            rmSync(stagingDir, { recursive: true, force: true });
+
+        const sourceHash = computeDirectoryHash(sourcePath);
+
+        if (isManifestCorrupt) {
+          if (!destExists) {
+            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 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);
+            const destHash = computeDirectoryHash(destPath);
+            if (destHash === sourceHash) {
+              manifest.skills[skill.name] = {
+                status: 'managed',
+                packageVersion,
+                sourceHash,
+                lastManagedHash: sourceHash,
+                lastSeenHash: sourceHash,
+                updatedAt: new Date().toISOString(),
+              };
+            } else {
+              try {
+                const stagedSkillDir = path.join(
+                  manifestDir,
+                  'skill-updates',
+                  packageVersion,
+                  skill.name,
+                );
+                if (existsSync(stagedSkillDir)) {
+                  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);
+              } catch (err) {
+                log(
+                  `[skill-sync] Failed to stage update for customized skill ${skill.name} during recovery:`,
+                  err,
+                );
+                manifest.skills[skill.name] = {
+                  status: 'customized',
+                  packageVersion: 'unknown',
+                  sourceHash: '',
+                  lastManagedHash: '',
+                  lastSeenHash: destHash,
+                  updatedAt: new Date().toISOString(),
+                };
+              }
+            }
           }
-        } catch (err) {
-          log(
-            `[skill-sync] Failed to clean up staging directory ${stagingDir}:`,
-            err,
-          );
+          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 {
+              if (entry.stagedPath) {
+                removeManagedStagedPath(
+                  entry.stagedPath,
+                  manifestDir,
+                  skill.name,
+                );
+                delete entry.stagedPath;
+              }
+              const rawEntry = entry as unknown as Record<string, unknown>;
+              delete rawEntry.stagedVersion;
+              delete rawEntry.stagedHash;
+              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) {
+                entry.packageVersion = packageVersion;
+                entry.sourceHash = sourceHash;
+                entry.lastManagedHash = sourceHash;
+                entry.lastSeenHash = sourceHash;
+                entry.updatedAt = new Date().toISOString();
+                skippedExisting.push(skill.name);
+              } else {
+                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] 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 (entry.stagedPath && entry.stagedPath !== stagedSkillDir) {
+                    removeManagedStagedPath(
+                      entry.stagedPath,
+                      manifestDir,
+                      skill.name,
+                    );
+                  }
+                  if (existsSync(stagedSkillDir)) {
+                    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) {
+              if (entry.stagedPath) {
+                removeManagedStagedPath(
+                  entry.stagedPath,
+                  manifestDir,
+                  skill.name,
+                );
+              }
+              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 (entry.stagedPath && entry.stagedPath !== stagedSkillDir) {
+                    removeManagedStagedPath(
+                      entry.stagedPath,
+                      manifestDir,
+                      skill.name,
+                    );
+                  }
+                  if (existsSync(stagedSkillDir)) {
+                    rmSync(stagedSkillDir, { recursive: true, force: true });
+                  }
+                  mkdirSync(stagedSkillDir, { recursive: true });
+                  copyDirRecursive(sourcePath, stagedSkillDir);
+
+                  entry.stagedPath = stagedSkillDir;
+                  entry.sourceHash = sourceHash;
+                  entry.packageVersion = packageVersion;
+
+                  staged.push(skill.name);
+                  customized.push(skill.name);
+                  skippedExisting.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,
+                  );
+                  failed.push(skill.name);
+                }
+              } else {
+                customized.push(skill.name);
+                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 {
+              try {
+                const stagedSkillDir = path.join(
+                  manifestDir,
+                  'skill-updates',
+                  packageVersion,
+                  skill.name,
+                );
+                if (entry.stagedPath && entry.stagedPath !== stagedSkillDir) {
+                  removeManagedStagedPath(
+                    entry.stagedPath,
+                    manifestDir,
+                    skill.name,
+                  );
+                }
+                if (existsSync(stagedSkillDir)) {
+                  rmSync(stagedSkillDir, { recursive: true, force: true });
+                }
+                mkdirSync(stagedSkillDir, { recursive: true });
+                copyDirRecursive(sourcePath, stagedSkillDir);
+
+                entry.status = 'customized';
+                entry.packageVersion = packageVersion;
+                entry.sourceHash = sourceHash;
+                entry.lastManagedHash = sourceHash;
+                entry.lastSeenHash = destHash;
+                entry.stagedPath = stagedSkillDir;
+                entry.updatedAt = new Date().toISOString();
+
+                staged.push(skill.name);
+                customized.push(skill.name);
+                skippedExisting.push(skill.name);
+                log(
+                  `[skill-sync] Skill ${skill.name} re-created by user (custom). Marked customized and staged.`,
+                );
+              } catch (err) {
+                log(
+                  `[skill-sync] Failed to stage update for deleted/recreated skill ${skill.name}:`,
+                  err,
+                );
+                failed.push(skill.name);
+              }
+            }
+          } else if (entry.status === 'conflict') {
+            if (destHash === sourceHash) {
+              if (entry.stagedPath) {
+                removeManagedStagedPath(
+                  entry.stagedPath,
+                  manifestDir,
+                  skill.name,
+                );
+              }
+              entry.status = 'managed';
+              entry.packageVersion = packageVersion;
+              entry.sourceHash = sourceHash;
+              entry.lastManagedHash = sourceHash;
+              entry.lastSeenHash = sourceHash;
+              delete entry.stagedPath;
+              entry.updatedAt = new Date().toISOString();
+              adopted.push(skill.name);
+            } else {
+              try {
+                const stagedSkillDir = path.join(
+                  manifestDir,
+                  'skill-updates',
+                  packageVersion,
+                  skill.name,
+                );
+                if (entry.stagedPath && entry.stagedPath !== stagedSkillDir) {
+                  removeManagedStagedPath(
+                    entry.stagedPath,
+                    manifestDir,
+                    skill.name,
+                  );
+                }
+                if (existsSync(stagedSkillDir)) {
+                  rmSync(stagedSkillDir, { recursive: true, force: true });
+                }
+                mkdirSync(stagedSkillDir, { recursive: true });
+                copyDirRecursive(sourcePath, stagedSkillDir);
+
+                entry.status = 'customized';
+                entry.packageVersion = packageVersion;
+                entry.sourceHash = sourceHash;
+                entry.lastManagedHash = sourceHash;
+                entry.lastSeenHash = destHash;
+                entry.stagedPath = stagedSkillDir;
+                entry.updatedAt = new Date().toISOString();
+
+                staged.push(skill.name);
+                customized.push(skill.name);
+                skippedExisting.push(skill.name);
+                log(
+                  `[skill-sync] Conflicted skill ${skill.name} recovered as customized and staged at ${stagedSkillDir}`,
+                );
+              } catch (err) {
+                log(
+                  `[skill-sync] Failed to stage update for conflicted 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);
+            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);
+              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)) {
+                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 processing skill ${skill.name}:`, err);
+        failed.push(skill.name);
       }
+    }
+
+    let manifestWriteFailed = false;
+    manifest.updatedAt = new Date().toISOString();
+    const tempManifestPath = `${manifestPath}.${Math.random().toString(36).slice(2, 9)}.tmp`;
+    try {
+      writeFileSync(
+        tempManifestPath,
+        JSON.stringify(manifest, null, 2),
+        'utf-8',
+      );
+      renameSync(tempManifestPath, manifestPath);
     } catch (err) {
-      log(`[skill-sync] Error processing source entry ${entry}:`, err);
-      failed.push(entry);
+      log('[skill-sync] Failed to write skills manifest atomically:', err);
+      manifestWriteFailed = true;
+      try {
+        if (existsSync(tempManifestPath)) {
+          unlinkSync(tempManifestPath);
+        }
+      } catch {}
+    }
+
+    if (manifestWriteFailed) {
+      failed.push('__manifest__');
     }
+  } finally {
+    releaseLock(lockDir);
   }
 
-  return { installed, skippedExisting, failed };
+  return {
+    installed,
+    skippedExisting,
+    failed,
+    staged,
+    adopted,
+    customized,
+  };
 }