refactor(pinch): Separate user and project scopes
User-only sources installed to ~/.pi/pinch/ instead of .pi/pinch/,
keeping project repos free of user-specific plugins. Each scope
gets its own lock file and install directory.

Assisted-by: Claude Opus 4.6 via pi
change mpzlmqmxmsnwroxxmmprmmwpykqoqszk
commit 4d4b1bdecbd7ab284e1a30c1ab6bf293ac6527a2
author Alpha Chen <alpha@kejadlen.dev>
date
parent wkklrtnm
diff --git a/AGENTS.md b/AGENTS.md
index 19288396..0c7dd490 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -45,13 +45,14 @@ tea comment -r alpha/dotfiles -l git.kejadlen.dev <index> "Comment body"
 
 ## Pinch (Plugin Manager)
 
-Pinch is a pi extension (`.pi/agent/extensions/pinch.ts`) that manages plugins from git repos. It caches repo clones in `$XDG_CACHE_HOME/pinch/` and copies only the selected plugins into `.pi/pinch/`, registering their skills automatically.
+Pinch is a pi extension (`.pi/agent/extensions/pinch.ts`) that manages plugins from git repos. It caches repo clones in `$XDG_CACHE_HOME/pinch/` and copies selected plugins into scope-appropriate directories, registering their skills automatically.
 
-- **Global manifest**: `~/.pi/agent/pinch.json` — applies everywhere
-- **Project manifest**: `.pi/pinch.json` — per-project, overrides global by name
-- **Lock file**: `.pi/pinch-lock.json` — exact commits, auto-generated
-- **Commands**: `/pinch:install`, `/pinch:update`, `/pinch:status`
-- **Skill**: `/skill:pinch` for full usage docs
+Plugins are scoped by which manifest defines them. Sources in the global manifest only install to `~/.pi/pinch/` (user scope). Sources referenced in a project manifest install to `.pi/pinch/` (project scope). This keeps user-specific plugins out of project repositories.
+
+- Global manifest: `~/.pi/agent/pinch.json` — user-wide sources
+- Project manifest: `.pi/pinch.json` — per-project, can reference global sources or define new ones
+- Lock files: `~/.pi/agent/pinch-lock.json` (user) and `.pi/pinch-lock.json` (project)
+- Commands: `/pinch:install`, `/pinch:update`, `/pinch:status`
 
 To add a plugin, add an entry to `pinch.json` and run `/pinch:install`. See the pinch skill for manifest format and troubleshooting.
 
diff --git a/ai/pi/extensions/pinch.ts b/ai/pi/extensions/pinch.ts
index e7468c60..8d151e9a 100644
--- a/ai/pi/extensions/pinch.ts
+++ b/ai/pi/extensions/pinch.ts
@@ -1,50 +1,39 @@
 /**
- * Pinch — per-project plugin manager for pi
+ * Pinch — scoped plugin manager for pi
  *
- * Manages Claude plugins from git repos declared in pinch.json manifests.
- * Global manifest: ~/.pi/agent/pinch.json
- * Project manifest: .pi/pinch.json (overrides global by name)
+ * Manages plugins from git repos declared in pinch.json manifests.
+ * Two manifest locations define two scopes:
  *
- * Repos are cloned into $XDG_CACHE_HOME/pinch/<source>/ as a shared cache.
- * Only the requested plugins are copied into the project at
- * .pi/pinch/<source>/<plugin>/. This keeps projects lightweight and works
- * in bind-mounted containers (no symlinks pointing outside the mount).
+ *   ~/.pi/agent/pinch.json   → user scope (applies everywhere)
+ *   .pi/pinch.json           → project scope (per-repo)
+ *
+ * Scope determines where plugins are installed:
+ *
+ *   User scope    → ~/.pi/pinch/<source>/<plugin>/
+ *   Project scope → .pi/pinch/<source>/<plugin>/
+ *
+ * A source belongs to project scope when it appears in the project
+ * manifest (either as a new definition or referencing a global source).
+ * Sources defined only in the global manifest stay in user scope,
+ * keeping project directories free of user-specific plugins.
+ *
+ * Repos are cloned into $XDG_CACHE_HOME/pinch/<source>/ as a shared
+ * cache. Only the requested plugins are copied into the scope directory.
  *
  * Plugin location within a repo is resolved by:
  * 1. Explicit `path` field in pinch.json (e.g. "plugins")
  * 2. marketplace.json `source` field (e.g. "./plugins/playground")
  * 3. Repo root fallback (<repo>/<plugin>/)
  *
- * Manifest format (pinch.json):
+ * Lock files track exact commits per source, one per scope:
  *
- *   {
- *     "anthropic": {
- *       "repo": "https://github.com/anthropics/claude-plugins-official",
- *       "ref": "main",
- *       "plugins": ["playground", "skill-creator"]
- *     },
- *     "my-plugins": {
- *       "repo": "git@github.com:me/plugins.git",
- *       "ref": "v1.2.0",
- *       "path": "plugins",
- *       "plugins": ["my-plugin"]
- *     }
- *   }
- *
- * Plugin structure (Claude plugin convention):
- *
- *   <plugin>/
- *     .claude-plugin/plugin.json
- *     skills/<skill-name>/SKILL.md
+ *   ~/.pi/agent/pinch-lock.json   → user scope
+ *   .pi/pinch-lock.json           → project scope
  *
  * Commands:
  *   /pinch:install  — clone/fetch all sources, copy plugins, register skills
  *   /pinch:update   — pull latest for unpinned sources, re-copy plugins
  *   /pinch:status   — show installed plugins and their skills
- *
- * Lock file (.pi/pinch-lock.json) tracks exact commits per source.
- * The `ref` field in lock entries is informational only — checkout uses
- * the commit hash directly for reproducibility.
  */
 
 import * as fs from "node:fs";
@@ -55,6 +44,8 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-age
 
 // ── Types ──────────────────────────────────────────────────────────────
 
+type Scope = "user" | "project";
+
 interface PinchSource {
   repo: string;
   ref?: string;
@@ -66,6 +57,13 @@ interface PinchConfig {
   [name: string]: PinchSource;
 }
 
+/** Config split by scope — user (global-only) vs project (referenced in project manifest). */
+interface ScopedConfig {
+  user: PinchConfig;
+  project: PinchConfig;
+  errors: string[];
+}
+
 interface PinchLockEntry {
   commit: string;
   ref?: string; // informational — records which ref produced this commit
@@ -94,14 +92,24 @@ function cachedRepoDir(name: string): string {
   return path.join(cacheDir(), name);
 }
 
+/** User-scoped plugin install dir (~/.pi/pinch/) */
+function userPinchDir(): string {
+  return path.join(os.homedir(), ".pi", "pinch");
+}
+
 /** Project-local plugin install dir */
 function projectPinchDir(cwd: string): string {
   return path.join(cwd, ".pi", "pinch");
 }
 
-/** Installed plugin dir within the project */
-function installedPluginDir(cwd: string, sourceName: string, pluginName: string): string {
-  return path.join(projectPinchDir(cwd), sourceName, pluginName);
+/** Root install dir for a given scope */
+function pinchDir(scope: Scope, cwd: string): string {
+  return scope === "user" ? userPinchDir() : projectPinchDir(cwd);
+}
+
+/** Installed plugin dir */
+function installedPluginDir(scope: Scope, cwd: string, sourceName: string, pluginName: string): string {
+  return path.join(pinchDir(scope, cwd), sourceName, pluginName);
 }
 
 function globalManifestPath(): string {
@@ -112,10 +120,18 @@ function projectManifestPath(cwd: string): string {
   return path.join(cwd, ".pi", "pinch.json");
 }
 
-function lockPath(cwd: string): string {
+function userLockPath(): string {
+  return path.join(os.homedir(), ".pi", "agent", "pinch-lock.json");
+}
+
+function projectLockPath(cwd: string): string {
   return path.join(cwd, ".pi", "pinch-lock.json");
 }
 
+function lockPath(scope: Scope, cwd: string): string {
+  return scope === "user" ? userLockPath() : projectLockPath(cwd);
+}
+
 // ── Manifest / Lock ───────────────────────────────────────────────────
 
 function readJsonFile(filePath: string): Record<string, any> | null {
@@ -130,68 +146,91 @@ function readJsonFile(filePath: string): Record<string, any> | null {
 }
 
 /**
- * Merge global (~/.pi/agent/pinch.json) and project (.pi/pinch.json)
- * manifests.
+ * Read and scope-split global (~/.pi/agent/pinch.json) and project
+ * (.pi/pinch.json) manifests.
+ *
+ * Scope rules:
+ * - A source that appears only in the global manifest → user scope.
+ * - A source that appears in the project manifest (new or referencing
+ *   a global source) → project scope.
  *
- * - Global entries must have `repo` and `plugins`.
- * - Project entries referencing a global key can only set `plugins` —
- *   `repo`, `ref`, and `path` are disallowed (the global definition owns those).
- * - Project entries with a new key are full source definitions (must have `repo`).
+ * Global entries must have `repo` and `plugins`.
+ * Project entries referencing a global key can only set `plugins` —
+ * `repo`, `ref`, and `path` are disallowed (the global definition owns those).
+ * Project entries with a new key are full source definitions (must have `repo`).
  *
  * Returns null if no valid config is found.
  */
-interface ConfigResult {
-  config: PinchConfig;
-  errors: string[];
-}
-
-function readConfig(cwd: string): ConfigResult | null {
+function readConfig(cwd: string): ScopedConfig | null {
   const globalRaw = readJsonFile(globalManifestPath());
   const projectRaw = readJsonFile(projectManifestPath(cwd));
   if (!globalRaw && !projectRaw) return null;
 
-  const config: PinchConfig = {};
+  const globalSources: PinchConfig = {};
   const errors: string[] = [];
 
-  // Load global entries (must have repo)
+  // Parse global entries (must have repo)
   if (globalRaw) {
     for (const [name, entry] of Object.entries(globalRaw)) {
       if (typeof entry !== "object" || entry === null) continue;
       if (!("repo" in entry) || !("plugins" in entry)) continue;
-      config[name] = entry as PinchSource;
+      globalSources[name] = entry as PinchSource;
     }
   }
 
-  // Merge project entries
+  const user: PinchConfig = {};
+  const project: PinchConfig = {};
+  const projectNames = new Set<string>();
+
+  // Parse project entries, tracking which names the project claims
   if (projectRaw) {
     for (const [name, entry] of Object.entries(projectRaw)) {
       if (typeof entry !== "object" || entry === null) continue;
       if (!("plugins" in entry)) continue;
+      projectNames.add(name);
 
-      if (name in config) {
+      if (name in globalSources) {
         // Referencing a global source — only plugins allowed
         if ("repo" in entry || "ref" in entry || "path" in entry) {
           errors.push(`${name}: project manifest cannot override repo/ref/path of global source — only plugins allowed`);
           continue;
         }
-        config[name] = { ...config[name], plugins: (entry as PinchSource).plugins };
+        project[name] = { ...globalSources[name], plugins: (entry as PinchSource).plugins };
       } else {
         // New project-only source — full definition required
         if (!("repo" in entry)) {
           errors.push(`${name}: source must have repo`);
           continue;
         }
-        config[name] = entry as PinchSource;
+        project[name] = entry as PinchSource;
       }
     }
   }
 
-  if (Object.keys(config).length === 0 && errors.length === 0) return null;
-  return { config, errors };
+  // Global sources not referenced by the project stay in user scope
+  for (const [name, source] of Object.entries(globalSources)) {
+    if (!projectNames.has(name)) {
+      user[name] = source;
+    }
+  }
+
+  const total = Object.keys(user).length + Object.keys(project).length;
+  if (total === 0 && errors.length === 0) return null;
+  return { user, project, errors };
+}
+
+/** Flat view of all sources across both scopes. */
+function allSources(sc: ScopedConfig): PinchConfig {
+  return { ...sc.user, ...sc.project };
+}
+
+/** Determine which scope a source belongs to. */
+function sourceScope(sc: ScopedConfig, name: string): Scope {
+  return name in sc.project ? "project" : "user";
 }
 
-function readLock(cwd: string): PinchLock {
-  const p = lockPath(cwd);
+function readLock(scope: Scope, cwd: string): PinchLock {
+  const p = lockPath(scope, cwd);
   if (!fs.existsSync(p)) return {};
   try {
     return JSON.parse(fs.readFileSync(p, "utf-8")) as PinchLock;
@@ -200,9 +239,10 @@ function readLock(cwd: string): PinchLock {
   }
 }
 
-function writeLock(cwd: string, lock: PinchLock): void {
-  fs.mkdirSync(path.dirname(lockPath(cwd)), { recursive: true });
-  fs.writeFileSync(lockPath(cwd), JSON.stringify(lock, null, 2) + "\n", "utf-8");
+function writeLock(scope: Scope, cwd: string, lock: PinchLock): void {
+  const p = lockPath(scope, cwd);
+  fs.mkdirSync(path.dirname(p), { recursive: true });
+  fs.writeFileSync(p, JSON.stringify(lock, null, 2) + "\n", "utf-8");
 }
 
 // ── Git operations ─────────────────────────────────────────────────────
@@ -361,16 +401,16 @@ function copyDir(src: string, dest: string): void {
 }
 
 /**
- * Copy a plugin from the cache into the project.
+ * Copy a plugin from the cache into the appropriate scope directory.
  * Removes the old copy first to ensure a clean state.
  */
-function installPlugin(cwd: string, sourceName: string, source: PinchSource, plugin: string): { ok: boolean; error?: string } {
+function installPlugin(scope: Scope, cwd: string, sourceName: string, source: PinchSource, plugin: string): { ok: boolean; error?: string } {
   const src = cachedPluginDir(sourceName, source, plugin);
   if (!fs.existsSync(src)) {
     return { ok: false, error: `plugin "${plugin}" not found in repo` };
   }
 
-  const dest = installedPluginDir(cwd, sourceName, plugin);
+  const dest = installedPluginDir(scope, cwd, sourceName, plugin);
   fs.rmSync(dest, { recursive: true, force: true });
   copyDir(src, dest);
   return { ok: true };
@@ -379,11 +419,10 @@ function installPlugin(cwd: string, sourceName: string, source: PinchSource, plu
 // ── Cleanup ────────────────────────────────────────────────────────────
 
 /**
- * Remove source/plugin dirs under .pi/pinch/ that are no longer in the config.
+ * Remove source/plugin dirs that are no longer in the config for a given scope.
  * Cleans up empty source directories after pruning individual plugins.
  */
-function pruneStale(config: PinchConfig, cwd: string): string[] {
-  const dir = projectPinchDir(cwd);
+function pruneScopeDir(scopeConfig: PinchConfig, dir: string): string[] {
   if (!fs.existsSync(dir)) return [];
 
   const pruned: string[] = [];
@@ -396,13 +435,13 @@ function pruneStale(config: PinchConfig, cwd: string): string[] {
       continue;
     }
 
-    if (!config[entry]) {
-      // Entire source removed
+    if (!scopeConfig[entry]) {
+      // Entire source removed from this scope
       fs.rmSync(full, { recursive: true, force: true });
       pruned.push(entry);
     } else {
       // Prune plugins no longer in the list
-      const wantedPlugins = new Set(config[entry].plugins);
+      const wantedPlugins = new Set(scopeConfig[entry].plugins);
       for (const pluginEntry of fs.readdirSync(full)) {
         const pluginFull = path.join(full, pluginEntry);
         try {
@@ -429,6 +468,14 @@ function pruneStale(config: PinchConfig, cwd: string): string[] {
   return pruned;
 }
 
+/** Prune stale entries from both user and project scope directories. */
+function pruneStale(sc: ScopedConfig, cwd: string): string[] {
+  const pruned: string[] = [];
+  pruned.push(...pruneScopeDir(sc.user, userPinchDir()));
+  pruned.push(...pruneScopeDir(sc.project, projectPinchDir(cwd)));
+  return pruned;
+}
+
 // ── Skill discovery ────────────────────────────────────────────────────
 
 /**
@@ -450,16 +497,19 @@ function findPluginSkills(dir: string): string[] {
 }
 
 /**
- * Collect all skill paths from installed plugins (project copies).
+ * Collect all skill paths from installed plugins across both scopes.
  */
-function resolveSkillPaths(config: PinchConfig, cwd: string): string[] {
+function resolveSkillPaths(sc: ScopedConfig, cwd: string): string[] {
   const paths: string[] = [];
 
-  for (const [name, source] of Object.entries(config)) {
-    for (const plugin of source.plugins) {
-      const dir = installedPluginDir(cwd, name, plugin);
-      if (fs.existsSync(dir)) {
-        paths.push(...findPluginSkills(dir));
+  for (const scope of ["user", "project"] as Scope[]) {
+    const config = scope === "user" ? sc.user : sc.project;
+    for (const [name, source] of Object.entries(config)) {
+      for (const plugin of source.plugins) {
+        const dir = installedPluginDir(scope, cwd, name, plugin);
+        if (fs.existsSync(dir)) {
+          paths.push(...findPluginSkills(dir));
+        }
       }
     }
   }
@@ -481,87 +531,95 @@ interface SyncResult {
 type Log = (msg: string) => void;
 
 function installAll(cwd: string, update: boolean, log: Log): SyncResult {
-  const cr = readConfig(cwd);
-  if (!cr) return { installed: [], updated: [], copied: [], pruned: [], errors: ['No pinch.json manifest found'], skills: [] };
-
-  const { config, errors: configErrors } = cr;
-  const lock = readLock(cwd);
-  const newLock: PinchLock = {};
-  const result: SyncResult = { installed: [], updated: [], copied: [], pruned: [], errors: [...configErrors], skills: [] };
-
-  for (const [name, source] of Object.entries(config)) {
-    const repoExists = fs.existsSync(path.join(cachedRepoDir(name), ".git"));
-    const lockedCommit = lock[name]?.commit;
-    const ref = source.ref ?? "HEAD";
-    const isPinned = !!source.ref;
-
-    // Clone or fetch into cache
-    if (!repoExists) {
-      log(`cloning ${name} from ${source.repo}...`);
-      const clone = cloneOrFetch(name, source.repo);
-      if (!clone.ok) {
-        result.errors.push(`${name}: ${clone.error}`);
-        continue;
-      }
-      result.installed.push(name);
-    } else if (update) {
-      log(`fetching ${name}...`);
-      const fetch = cloneOrFetch(name, source.repo);
-      if (!fetch.ok) {
-        result.errors.push(`${name}: ${fetch.error}`);
-        if (lockedCommit) {
-          newLock[name] = { commit: lockedCommit, ...(source.ref ? { ref: source.ref } : {}) };
+  const sc = readConfig(cwd);
+  if (!sc) return { installed: [], updated: [], copied: [], pruned: [], errors: ['No pinch.json manifest found'], skills: [] };
+
+  const result: SyncResult = { installed: [], updated: [], copied: [], pruned: [], errors: [...sc.errors], skills: [] };
+
+  // Process each scope independently (separate lock files)
+  for (const scope of ["user", "project"] as Scope[]) {
+    const config = scope === "user" ? sc.user : sc.project;
+    if (Object.keys(config).length === 0) continue;
+
+    const lock = readLock(scope, cwd);
+    const newLock: PinchLock = {};
+
+    for (const [name, source] of Object.entries(config)) {
+      const repoExists = fs.existsSync(path.join(cachedRepoDir(name), ".git"));
+      const lockedCommit = lock[name]?.commit;
+      const ref = source.ref ?? "HEAD";
+      const isPinned = !!source.ref;
+
+      // Clone or fetch into cache
+      if (!repoExists) {
+        log(`cloning ${name} from ${source.repo}...`);
+        const clone = cloneOrFetch(name, source.repo);
+        if (!clone.ok) {
+          result.errors.push(`${name}: ${clone.error}`);
+          continue;
+        }
+        result.installed.push(name);
+      } else if (update) {
+        log(`fetching ${name}...`);
+        const fetch = cloneOrFetch(name, source.repo);
+        if (!fetch.ok) {
+          result.errors.push(`${name}: ${fetch.error}`);
+          if (lockedCommit) {
+            newLock[name] = { commit: lockedCommit, ...(source.ref ? { ref: source.ref } : {}) };
+          }
+          continue;
         }
-        continue;
       }
-    }
 
-    // Determine which ref to checkout
-    let targetRef = ref;
-    if (!update && lockedCommit && repoExists) {
-      targetRef = lockedCommit;
-    } else if (update && isPinned) {
-      targetRef = ref;
-    } else if (update) {
-      targetRef = "HEAD";
-    }
+      // Determine which ref to checkout
+      let targetRef = ref;
+      if (!update && lockedCommit && repoExists) {
+        targetRef = lockedCommit;
+      } else if (update && isPinned) {
+        targetRef = ref;
+      } else if (update) {
+        targetRef = "HEAD";
+      }
 
-    log(`checking out ${name} at ${targetRef === ref ? ref : targetRef.slice(0, 8)}...`);
-    clearMarketplaceCache();
-    const co = checkout(name, targetRef);
-    if (!co.ok) {
-      result.errors.push(`${name}: ${co.error}`);
-      continue;
-    }
+      log(`checking out ${name} at ${targetRef === ref ? ref : targetRef.slice(0, 8)}...`);
+      clearMarketplaceCache();
+      const co = checkout(name, targetRef);
+      if (!co.ok) {
+        result.errors.push(`${name}: ${co.error}`);
+        continue;
+      }
 
-    const commit = co.commit ?? getCurrentCommit(name) ?? "unknown";
+      const commit = co.commit ?? getCurrentCommit(name) ?? "unknown";
 
-    if (update && lockedCommit && lockedCommit !== commit) {
-      result.updated.push(name);
-    }
+      if (update && lockedCommit && lockedCommit !== commit) {
+        result.updated.push(name);
+      }
 
-    newLock[name] = { commit, ...(source.ref ? { ref: source.ref } : {}) };
+      newLock[name] = { commit, ...(source.ref ? { ref: source.ref } : {}) };
 
-    // Copy plugins from cache into project
-    for (const plugin of source.plugins) {
-      log(`copying ${name}/${plugin}...`);
-      const install = installPlugin(cwd, name, source, plugin);
-      if (!install.ok) {
-        result.errors.push(`${name}: ${install.error}`);
-        continue;
+      // Copy plugins into the scope-appropriate directory
+      for (const plugin of source.plugins) {
+        log(`copying ${name}/${plugin} (${scope})...`);
+        const install = installPlugin(scope, cwd, name, source, plugin);
+        if (!install.ok) {
+          result.errors.push(`${name}: ${install.error}`);
+          continue;
+        }
+        result.copied.push(`${name}/${plugin}`);
+        const dest = installedPluginDir(scope, cwd, name, plugin);
+        const skills = findPluginSkills(dest);
+        result.skills.push(...skills.map((s) => {
+          const base = pinchDir(scope, cwd);
+          const rel = path.relative(base, s);
+          return rel.replace(/\/SKILL\.md$/, "");
+        }));
       }
-      result.copied.push(`${name}/${plugin}`);
-      const dest = installedPluginDir(cwd, name, plugin);
-      const skills = findPluginSkills(dest);
-      result.skills.push(...skills.map((s) => {
-        const rel = path.relative(projectPinchDir(cwd), s);
-        return rel.replace(/\/SKILL\.md$/, "");
-      }));
     }
+
+    writeLock(scope, cwd, newLock);
   }
 
-  writeLock(cwd, newLock);
-  result.pruned = pruneStale(config, cwd);
+  result.pruned = pruneStale(sc, cwd);
 
   return result;
 }
@@ -573,28 +631,29 @@ export default function pinch(pi: ExtensionAPI) {
 
   pi.on("resources_discover", (event) => {
     cwd = event.cwd;
-    const cr = readConfig(cwd);
-    if (!cr) return;
+    const sc = readConfig(cwd);
+    if (!sc) return;
 
-    const skillPaths = resolveSkillPaths(cr.config, cwd);
+    const skillPaths = resolveSkillPaths(sc, cwd);
     if (skillPaths.length > 0) {
       return { skillPaths };
     }
   });
 
   pi.on("session_start", async (_event, ctx) => {
-    const cr = readConfig(cwd);
-    if (!cr) return;
+    const sc = readConfig(cwd);
+    if (!sc) return;
 
-    for (const err of cr.errors) {
+    for (const err of sc.errors) {
       ctx.ui.notify(`pinch: ${err}`, "error");
     }
 
-    const { config } = cr;
-    const skillPaths = resolveSkillPaths(config, cwd);
+    const config = allSources(sc);
+    const skillPaths = resolveSkillPaths(sc, cwd);
     const totalPlugins = Object.values(config).reduce((n, s) => n + s.plugins.length, 0);
     const installedPlugins = Object.entries(config).reduce((n, [name, source]) => {
-      return n + source.plugins.filter((p) => fs.existsSync(installedPluginDir(cwd, name, p))).length;
+      const scope = sourceScope(sc, name);
+      return n + source.plugins.filter((p) => fs.existsSync(installedPluginDir(scope, cwd, name, p))).length;
     }, 0);
     const missing = totalPlugins - installedPlugins;
 
@@ -645,40 +704,47 @@ export default function pinch(pi: ExtensionAPI) {
   pi.registerCommand("pinch:status", {
     description: "Show installed plugins and their skills",
     handler: async (_args, ctx) => {
-      const cr = readConfig(cwd);
-      if (!cr) {
+      const sc = readConfig(cwd);
+      if (!sc) {
         ctx.ui.notify('No pinch.json manifest found', "error");
         return;
       }
 
-      for (const err of cr.errors) {
+      for (const err of sc.errors) {
         ctx.ui.notify(`pinch: ${err}`, "error");
       }
 
-      const lock = readLock(cwd);
       const lines: string[] = [];
 
-      for (const [name, source] of Object.entries(cr.config)) {
-        const lockEntry = lock[name];
-        const cached = fs.existsSync(path.join(cachedRepoDir(name), ".git"));
-        const status = cached ? "✓" : "✗";
-        const commit = lockEntry?.commit?.slice(0, 8) ?? "—";
-        const ref = source.ref ?? "HEAD";
-
-        lines.push(`${status} ${name} (${ref} @ ${commit})`);
-
-        for (const plugin of source.plugins) {
-          const pDir = installedPluginDir(cwd, name, plugin);
-          const exists = fs.existsSync(pDir);
-          const skills = exists ? findPluginSkills(pDir) : [];
-          const skillNames = skills.map((s) => path.basename(path.dirname(s)));
-
-          if (skills.length > 0) {
-            lines.push(`  • ${plugin} (${skillNames.join(", ")})`);
-          } else if (exists) {
-            lines.push(`  • ${plugin}`);
-          } else {
-            lines.push(`  ✗ ${plugin} (not installed)`);
+      for (const scope of ["user", "project"] as Scope[]) {
+        const config = scope === "user" ? sc.user : sc.project;
+        if (Object.keys(config).length === 0) continue;
+
+        const lock = readLock(scope, cwd);
+        lines.push(`── ${scope} ──`);
+
+        for (const [name, source] of Object.entries(config)) {
+          const lockEntry = lock[name];
+          const cached = fs.existsSync(path.join(cachedRepoDir(name), ".git"));
+          const status = cached ? "✓" : "✗";
+          const commit = lockEntry?.commit?.slice(0, 8) ?? "—";
+          const ref = source.ref ?? "HEAD";
+
+          lines.push(`${status} ${name} (${ref} @ ${commit})`);
+
+          for (const plugin of source.plugins) {
+            const pDir = installedPluginDir(scope, cwd, name, plugin);
+            const exists = fs.existsSync(pDir);
+            const skills = exists ? findPluginSkills(pDir) : [];
+            const skillNames = skills.map((s) => path.basename(path.dirname(s)));
+
+            if (skills.length > 0) {
+              lines.push(`  • ${plugin} (${skillNames.join(", ")})`);
+            } else if (exists) {
+              lines.push(`  • ${plugin}`);
+            } else {
+              lines.push(`  ✗ ${plugin} (not installed)`);
+            }
           }
         }
       }
diff --git a/ai/pi/pinch-lock.json b/ai/pi/pinch-lock.json
new file mode 100644
index 00000000..3d0f3b5d
--- /dev/null
+++ b/ai/pi/pinch-lock.json
@@ -0,0 +1,14 @@
+{
+  "learning-opportunities": {
+    "commit": "e5f985d376461993253d285096ed0f4b4a095858",
+    "ref": "main"
+  },
+  "claude-plugins-official": {
+    "commit": "d5c15b861cd23e3102215c26020368ad5134dc47",
+    "ref": "main"
+  },
+  "superpowers": {
+    "commit": "363923f74aa9cd7b470c0aaa73dee629a8bfdc90",
+    "ref": "main"
+  }
+}
diff --git a/ai/pi/pinch.json b/ai/pi/pinch.json
index 2ac9f3f6..f8fd5160 100644
--- a/ai/pi/pinch.json
+++ b/ai/pi/pinch.json
@@ -8,5 +8,10 @@
     "repo": "https://github.com/anthropics/claude-plugins-official",
     "ref": "main",
     "plugins": ["playground"]
+  },
+  "superpowers": {
+    "repo": "https://github.com/obra/superpowers",
+    "ref": "main",
+    "plugins": ["superpowers"]
   }
 }
diff --git a/ai/pi/skills/pinch/SKILL.md b/ai/pi/skills/pinch/SKILL.md
index 4452d4c5..8da86899 100644
--- a/ai/pi/skills/pinch/SKILL.md
+++ b/ai/pi/skills/pinch/SKILL.md
@@ -5,20 +5,29 @@ description: Manage pi plugins from git repos via pinch — add/remove sources a
 
 # Pinch
 
-Pinch is a per-project plugin manager for pi, implemented as a pi extension (`~/.pi/agent/extensions/pinch.ts`). It clones git repos containing plugins (with skills, etc.) into `.pi/pinch/` and registers their skills automatically.
+Pinch is a scoped plugin manager for pi, implemented as a pi extension (`~/.pi/agent/extensions/pinch.ts`). It clones git repos containing plugins (with skills, etc.) and copies them into scope-appropriate directories.
+
+## Scoping
+
+Each source belongs to one of two scopes, determined by which manifests reference it:
+
+- User scope — sources defined only in `~/.pi/agent/pinch.json`. Plugins install to `~/.pi/pinch/`. Lock file: `~/.pi/agent/pinch-lock.json`.
+- Project scope — sources that appear in `.pi/pinch.json` (new definitions or references to global sources). Plugins install to `.pi/pinch/`. Lock file: `.pi/pinch-lock.json`.
+
+This separation keeps user-specific plugins out of project repositories.
 
 ## Concepts
 
-- **Source**: A named git repo entry in `pinch.json`. Each source has a `repo` URL, optional `ref` (branch/tag/commit), optional `path` (subdirectory containing plugins), and a `plugins` array listing which plugins to activate.
-- **Plugin**: A directory inside a source repo following Claude plugin conventions — contains `skills/<skill-name>/SKILL.md`.
-- **Lock file**: `.pi/pinch-lock.json` tracks the exact commit per source for reproducible installs.
+- Source: a named git repo entry in `pinch.json` with a `repo` URL, optional `ref` (branch/tag/commit), optional `path` (subdirectory containing plugins), and a `plugins` array.
+- Plugin: a directory inside a source repo containing `skills/<skill-name>/SKILL.md`.
+- Lock file: tracks the exact commit per source for reproducible installs. One lock file per scope.
 
 ## Manifest format
 
 Manifests are JSON files named `pinch.json`:
 
-- **Global**: `~/.pi/agent/pinch.json` — applies to all projects
-- **Project**: `.pi/pinch.json` — project-specific, overrides global entries by name
+- `~/.pi/agent/pinch.json` — global (user) manifest, applies everywhere
+- `.pi/pinch.json` — project manifest, overrides global entries by name
 
 ```json
 {
@@ -44,12 +53,13 @@ Manifests are JSON files named `pinch.json`:
 |---------|-------------|
 | `/pinch:install` | Clone sources and register skills. Respects lock file for already-installed sources. |
 | `/pinch:update` | Fetch latest and update to newest commits. Pinned refs stay put. |
-| `/pinch:status` | Show installed sources, plugins, and their skills. |
+| `/pinch:status` | Show installed sources, plugins, and their skills, grouped by scope. |
 
 ## Manifest rules
 
-- **Global manifest** (`~/.pi/agent/pinch.json`): Full source definitions with `repo`, `ref`, `path`, `plugins`.
-- **Project manifest** (`.pi/pinch.json`): Can define new sources (with `repo`), or reference global sources by name with only `plugins`. If a key exists in the global manifest, `repo`/`ref`/`path` cannot be overridden — only `plugins` is allowed.
+The global manifest (`~/.pi/agent/pinch.json`) holds full source definitions with `repo`, `ref`, `path`, and `plugins`.
+
+The project manifest (`.pi/pinch.json`) can define new sources (with `repo`) or reference global sources by name with only `plugins`. When a key exists in the global manifest, `repo`/`ref`/`path` cannot be overridden — only `plugins` is allowed. Referencing a global source in the project manifest promotes it to project scope.
 
 ```json
 // Project manifest referencing a global source (plugins only)
@@ -72,8 +82,8 @@ Manifests are JSON files named `pinch.json`:
 ## Adding a plugin
 
 1. Identify the repo URL and which plugin(s) you want from it.
-2. Choose a short source name (used as the directory name under `.pi/pinch/`).
-3. Add the entry to the appropriate `pinch.json`:
+2. Choose a short source name (used as the directory name under the install location).
+3. Add the entry to the appropriate `pinch.json` — global for user-wide plugins, project for repo-specific ones.
 
 ```json
 {
@@ -102,36 +112,44 @@ If the plugins live in a subdirectory of the repo (not at the root), set `path`:
 
 ## Removing a plugin
 
-- To stop using a single plugin from a source, remove it from the `plugins` array.
-- To remove an entire source, delete its key from `pinch.json`. The next `/pinch:install` prunes stale directories.
+To stop using a single plugin from a source, remove it from the `plugins` array. To remove an entire source, delete its key from `pinch.json`. The next `/pinch:install` prunes stale directories.
 
 ## File layout
 
 ```
-$XDG_CACHE_HOME/pinch/         # Shared repo cache (~/.cache/pinch/)
-└── <source-name>/             # Full repo clone
+$XDG_CACHE_HOME/pinch/            # Shared repo cache (~/.cache/pinch/)
+└── <source-name>/                # Full repo clone
+
+~/.pi/
+├── agent/
+│   ├── pinch.json                 # Global manifest
+│   ├── pinch-lock.json            # User-scope lock file
+│   └── extensions/
+│       └── pinch.ts               # The extension itself
+└── pinch/                         # User-scope installs
+    └── <source-name>/
+        └── <plugin>/
+            └── skills/
+                └── <skill>/
+                    └── SKILL.md
 
-.pi/
-├── pinch.json                  # Project manifest (optional)
-├── pinch-lock.json             # Lock file (auto-generated)
+.pi/                               # Project scope
+├── pinch.json                     # Project manifest (optional)
+├── pinch-lock.json                # Project-scope lock file
 └── pinch/
     └── <source-name>/
-        └── <plugin>/           # Copied from cache (only selected plugins)
+        └── <plugin>/
             └── skills/
                 └── <skill>/
                     └── SKILL.md
-
-~/.pi/agent/
-├── pinch.json                  # Global manifest
-└── extensions/
-    └── pinch.ts                # The extension itself
 ```
 
-Repos are cloned once into the XDG cache. Only the requested plugin directories are copied into the project, keeping `.pi/pinch/` lightweight and working correctly in bind-mounted containers.
+Repos are cloned once into the XDG cache. Only the requested plugin directories are copied into the scope-appropriate install location.
 
 ## Troubleshooting
 
-- **"No pinch.json manifest found"**: Neither `~/.pi/agent/pinch.json` nor `.pi/pinch.json` exists or contains valid entries.
-- **Plugin not found after install**: Check that the plugin name in `plugins` matches the actual directory name in the repo. Use `path` if plugins are nested.
-- **Network errors on clone/fetch**: Pinch uses `git` directly — check that the repo URL is reachable and credentials are configured.
-- **Skills not loading**: Plugin must have `skills/<name>/SKILL.md` structure. Run `/pinch:status` to verify.
+- "No pinch.json manifest found": neither `~/.pi/agent/pinch.json` nor `.pi/pinch.json` exists or contains valid entries.
+- Plugin not found after install: check that the plugin name in `plugins` matches the actual directory name in the repo. Use `path` if plugins are nested.
+- Network errors on clone/fetch: pinch uses `git` directly — check that the repo URL is reachable and credentials are configured.
+- Skills not loading: the plugin must have `skills/<name>/SKILL.md` structure. Run `/pinch:status` to verify.
+- Wrong scope: if a user plugin appears in the project, check whether the project manifest references that source name. Remove the reference to keep it in user scope.