Переглянути джерело

feat: add project-local customization support

Alvin Unreal 1 місяць тому
батько
коміт
62845ec83f

+ 4 - 10
README.md

@@ -198,8 +198,6 @@ verification while specialists do the work in their own lanes.
 - **[oh-my-opencode-slim skill](#oh-my-opencode-slim-skill)** — a bundled
   configuration skill that helps tune models, prompts, custom agents, MCP access,
   presets, and plugin behavior safely.
-- **Release Smoke Test** — validates packed release candidates and bugfixes
-  before public publish with isolated OpenCode runtime smokes.
 
 #### Background Agents
 
@@ -207,7 +205,7 @@ V2 makes background specialists the default mental model: the Orchestrator plans
 the work graph, launches the right agents, avoids overlapping write ownership,
 and waits for terminal task results before acting on them.
 
-See **[Background Orchestration](docs/background-orchestration.md)** for the
+See **[Background Orchestration](docs/v2-background-orchestration.md)** for the
 full scheduler model.
 
 #### Companion
@@ -662,9 +660,10 @@ Use this section as a map: start with installation, then jump to features, confi
 |-----|----------------|
 | **[Installation Guide](docs/installation.md)** | Install the plugin, use CLI flags, reset config, and troubleshoot setup |
 | **[Configuration](docs/configuration.md)** | Config file locations, JSONC support, prompt overrides, and full option reference |
+| **[Project Customization](docs/project-local-customization.md)** | Repository-specific custom agents, prompt overrides, per-agent skills, and precedence |
 | **[Background Orchestration](docs/background-orchestration.md)** | Scheduler-first orchestrator model built around native background subagents |
 | **[Maintainer Guide](docs/maintainers.md)** | Issue triage rules, label meanings, support routing, and repo maintenance workflow |
-| **[Skills](docs/skills.md)** | Bundled skills such as `simplify`, `codemap`, `clonedeps`, `deepwork`, `reflect`, `worktrees`, `release-smoke-test`, and `oh-my-opencode-slim` |
+| **[Skills](docs/skills.md)** | Bundled skills such as `simplify`, `codemap`, `clonedeps`, `deepwork`, `reflect`, `worktrees`, and `oh-my-opencode-slim` |
 | **[MCPs](docs/mcps.md)** | `websearch`, `context7`, `gh_grep`, and how MCP permissions work per agent |
 | **[Tools](docs/tools.md)** | Built-in tool capabilities like `webfetch`, LSP tools, code search, and formatters |
 
@@ -685,7 +684,7 @@ Use this section as a map: start with installation, then jump to features, confi
   <p><sub>Every merged contribution leaves a mark on the realm.</sub></p>
 
   <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
-[![All Contributors](https://img.shields.io/badge/all_contributors-69-orange.svg?style=flat-square)](#contributors-)
+[![All Contributors](https://img.shields.io/badge/all_contributors-66-orange.svg?style=flat-square)](#contributors-)
 <!-- ALL-CONTRIBUTORS-BADGE:END -->
 </div>
 
@@ -784,11 +783,6 @@ Use this section as a map: start with installation, then jump to features, confi
       <td align="center" valign="top" width="16.66%"><a href="https://github.com/linze0721"><img src="https://avatars.githubusercontent.com/u/178997622?v=4?s=100" width="100px;" alt="萧瑟"/><br /><sub><b>萧瑟</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=linze0721" title="Code">💻</a></td>
       <td align="center" valign="top" width="16.66%"><a href="https://github.com/SisyphusZheng"><img src="https://avatars.githubusercontent.com/u/146103794?v=4?s=100" width="100px;" alt="Zhi"/><br /><sub><b>Zhi</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=SisyphusZheng" title="Code">💻</a></td>
     </tr>
-    <tr>
-      <td align="center" valign="top" width="16.66%"><a href="https://github.com/824156793"><img src="https://avatars.githubusercontent.com/u/19755784?v=4?s=100" width="100px;" alt="lilili"/><br /><sub><b>lilili</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=824156793" title="Code">💻</a></td>
-      <td align="center" valign="top" width="16.66%"><a href="http://mikehenke.com/"><img src="https://avatars.githubusercontent.com/u/119844?v=4?s=100" width="100px;" alt="Mike Henke"/><br /><sub><b>Mike Henke</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=mhenke" title="Code">💻</a></td>
-      <td align="center" valign="top" width="16.66%"><a href="https://github.com/imVinayPandya"><img src="https://avatars.githubusercontent.com/u/5011197?v=4?s=100" width="100px;" alt="Vinay Pandya"/><br /><sub><b>Vinay Pandya</b></sub></a><br /><a href="https://github.com/alvinunreal/oh-my-opencode-slim/commits?author=imVinayPandya" title="Code">💻</a></td>
-    </tr>
   </tbody>
 </table>
 

+ 3 - 93
docs/configuration.md

@@ -1,6 +1,6 @@
 # Configuration Reference
 
-Complete reference for all configuration files and options in oh-my-opencode-slim.
+Complete reference for all configuration files and options in oh-my-opencode-slim. For repository-specific configurations, custom agents, and prompt directory lookups, see the [Project-local Customization Guide](project-local-customization.md).
 
 ---
 
@@ -11,7 +11,7 @@ Complete reference for all configuration files and options in oh-my-opencode-sli
 | `~/.config/opencode/opencode.json` | OpenCode core settings (plugin registration, providers) |
 | `~/.config/opencode/oh-my-opencode-slim.json` | Plugin settings — agents, multiplexer, MCPs, council |
 | `~/.config/opencode/oh-my-opencode-slim.jsonc` | Same, but with JSONC (comments + trailing commas). Takes precedence over `.json` if both exist |
-| `.opencode/oh-my-opencode-slim.json` | Project-local overrides (optional, checked first) |
+| `.opencode/oh-my-opencode-slim.json` | Project-local overrides (optional, higher precedence than user config) |
 
 > **💡 JSONC recommended:** Use the `.jsonc` extension to add comments and trailing commas. If both `.jsonc` and `.json` exist, `.jsonc` takes precedence.
 
@@ -39,7 +39,7 @@ Customize agent prompts without modifying source code. Create markdown files in
 | `{agent}.md` | Replaces the agent's default prompt entirely |
 | `{agent}_append.md` | Appends custom instructions to the default prompt |
 
-When a `preset` is active, the plugin checks `~/.config/opencode/oh-my-opencode-slim/{preset}/` first, then falls back to the root directory.
+When a `preset` is active, the plugin checks preset directories before falling back to root directories. Both global user prompt directories and project-local prompt directories are searched. For the complete lookup precedence order, see [Project-local Customization](project-local-customization.md).
 
 **Example directory structure:**
 
@@ -112,7 +112,6 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `agents.<customAgent>.model` | string\|array | — | Required for custom agents inferred from unknown `agents` keys |
 | `agents.<customAgent>.prompt` | string | — | Full execution prompt for a custom agent |
 | `agents.<customAgent>.orchestratorPrompt` | string | — | Exact `@agent` block injected into the orchestrator prompt; must start with `@<agent-name>` |
-| `agents.<agent>.permission` | object \| string | — | Tool-level permission rules enforced by the SDK. See [Agent Permissions](#agent-permissions) |
 | `agents.<agent>.displayName` | string | — | Custom user-facing alias for the agent in the active config |
 | `acpAgents.<name>.command` | string | — | Command for an external ACP-compatible agent; creates a wrapper subagent named `<name>` |
 | `acpAgents.<name>.args` | string[] | `[]` | Arguments for the ACP agent command |
@@ -302,95 +301,6 @@ Notes:
 - Custom agents without a `model` are skipped with a warning
 - Disabled custom agents are not registered or injected into the orchestrator prompt
 
-### Agent Permissions
-
-The `permission` field provides deterministic, tool-level permission restrictions on custom agents, built-in agent overrides, and presets. Unlike prompt instructions ("do not edit files"), these rules are enforced by the OpenCode SDK at the tool-call level.
-
-The field accepts either:
-
-1. **Shorthand string** — `"ask"`, `"allow"`, or `"deny"` applied to all tools
-2. **Object** — keys are tool names, values are `"ask" | "allow" | "deny"` or (for rule keys) a pattern-to-action map
-
-**Example: read-only `planner` agent:**
-
-```jsonc
-{
-  "agents": {
-    "planner": {
-      "model": "openai/gpt-5.5",
-      "variant": "high",
-      "skills": [],
-      "mcps": ["context7", "websearch"],
-      "permission": {
-        "edit": "deny",
-        "bash": {
-          "*": "ask",
-          "git status*": "allow",
-          "git diff*": "allow",
-          "grep *": "allow"
-        },
-        "webfetch": "allow",
-        "websearch": "allow",
-        "task": "deny"
-      },
-      "prompt": "You are Planner. Create implementation plans only. Do not implement code."
-    }
-  }
-}
-```
-
-**Example: `security-reviewer` agent:**
-
-```jsonc
-{
-  "agents": {
-    "security-reviewer": {
-      "model": "anthropic/claude-sonnet-4-5",
-      "permission": {
-        "edit": "deny",
-        "bash": "deny",
-        "webfetch": "allow"
-      },
-      "prompt": "You are a security reviewer. Inspect code and report findings. Do not patch anything."
-    }
-  }
-}
-```
-
-#### Permission keys
-
-| Key | Value type | Description |
-|-----|------------|-------------|
-| `read` | string or object | File reading |
-| `edit` | string or object | File editing |
-| `glob` | string or object | File pattern matching |
-| `grep` | string or object | Content search |
-| `list` | string or object | Directory listing |
-| `bash` | string or object | Shell command execution |
-| `task` | string or object | Subagent task delegation |
-| `external_directory` | string or object | Access to directories outside the workspace |
-| `lsp` | string or object | Language server protocol operations |
-| `skill` | string or object | Skill execution |
-| `todowrite` | string only | Todo list writing |
-| `question` | string only | Asking the user questions |
-| `webfetch` | string only | Web content fetching |
-| `websearch` | string only | Web search |
-| `codesearch` | string only | Code search |
-| `doom_loop` | string only | Doom loop prevention |
-
-Keys marked "string or object" accept pattern-based rules (e.g. `bash: { "git status*": "allow", "*": "ask" }`). Keys marked "string only" accept a single `"ask"`, `"allow"`, or `"deny"` value. Unknown tool names (including MCP-derived keys) pass through without error.
-
-#### Merge semantics
-
-When a user supplies `permission` and also uses the `skills` or `mcps` arrays on the same agent, the plugin merges them:
-
-1. **User-supplied `permission` is the base layer.**
-2. **Plugin-generated rules from the `skills` array override `permission.skill`** — the `skills` array is authoritative for skill gating.
-3. **Plugin-generated rules from the `mcps` array set `permission.<mcp>_*` keys** — the `mcps` array is authoritative for MCP gating.
-4. **User-supplied keys for standard tools** (`edit`, `bash`, `webfetch`, `task`, etc.) survive the merge untouched.
-
-Use the `skills`/`mcps` arrays for skill and MCP gating. Use `permission` for everything else (file access, bash, web, task delegation).
-
 ### Desktop Companion App
 
 The desktop companion app provides a visual status overlay showing running and active agents. For quick installation instructions, binary paths, config defaults, and release information, see the full **[Desktop Companion Guide](companion.md)**.

+ 131 - 0
docs/project-local-customization.md

@@ -0,0 +1,131 @@
+# Project-local Customization
+
+This document describes how to configure and customize oh-my-opencode-slim on a per-project (repository-specific) basis. Project-local customization allows teams and repositories to define custom agents, override systemic prompts, restrict skills, and orchestrate MCP configurations without polluting global user configurations.
+
+## Security & Trust Boundary Warning
+
+> ⚠️ **IMPORTANT SECURITY NOTICE**  
+> Because project-local configuration files (`.opencode/oh-my-opencode-slim.jsonc`) and prompt templates (`.opencode/oh-my-opencode-slim/`) are loaded automatically when you open and work in a project directory, they can modify agent behaviors, enable/disable tools, and grant extra model access permissions.  
+> **Only work in and run OpenCode within repositories you explicitly trust.**
+
+---
+
+## Feature Comparison
+
+| Feature | Scope / Location | Description |
+|---|---|---|
+| **Configuration file** | `.opencode/oh-my-opencode-slim.json[c]` | Project-level configuration file that overrides global user settings, merging presets, agent profiles, and multiplexer integration. |
+| **Custom agents** | `agents` configuration block | Define new specialized agents by keying them under `agents.<custom-name>` with required `model`, custom system `prompt`, and optional routing guidance. |
+| **Built-in prompt overrides** | `.opencode/oh-my-opencode-slim/<agent>.md` | Completely override the built-in system prompt for any agent (e.g. `oracle.md`, `explorer.md`, `orchestrator.md`, or custom agents). |
+| **Append prompts** | `.opencode/oh-my-opencode-slim/<agent>_append.md` | Append additional rules or guidelines to the existing base (inline or default built-in) prompt without overriding it completely. |
+| **Per-agent skills** | `agents.<agent>.skills` | Explicitly restrict or authorize specific local codebase skills/scripts that this agent is allowed to execute. |
+| **Per-agent MCPs** | `agents.<agent>.mcps` | Assign, restrict, or authorize specific Model Context Protocol (MCP) servers (like `websearch` or `context7`) to specific agents. |
+| **Presets** | `presets` configuration block | Bundle named agent environments. User and project preset definitions deep-merge; the active preset then merges into `agents`. |
+| **Precedence** | User config, project config, presets, prompt files | Project-local settings take precedence over user-global settings, while root `agents.*` entries beat active preset entries. |
+
+---
+
+## Configuration Precedence
+
+When oh-my-opencode-slim loads, it resolves configuration properties and prompt templates across multiple layers. The inheritance precedence operates strictly as follows:
+
+```
+[Built-in Defaults] 
+       ↓ (overridden by)
+[User Config] (global)
+       ↓ (overridden by)
+[Project Config] (local repository)
+       ↓ (overridden by)
+[Environment Preset Override] (via OH_MY_OPENCODE_SLIM_PRESET env var)
+       ↓ (merged into agents)
+[Active Preset] (merges preset-specific agent options)
+       ↓ (overridden by)
+[Root Config agents.*] (individual agent configs beat preset configurations)
+```
+
+### Note on Root Overrides vs Presets
+The root `agents.*` configuration (defined at the top level of user or project config) always takes precedence over the active preset configurations. To override a root agent choice globally, you must specify the override in the project-level root `agents.*` rather than inside a local preset configuration alone.
+
+---
+
+## Prompt Lookup Precedence
+
+When looking up markdown prompt template files (such as `<agent>.md` or `<agent>_append.md`), oh-my-opencode-slim searches directories in a strict hierarchical order. Precedence is evaluated for the replacement prompt file and the append prompt file **independently** in the following sequence:
+
+1. **Project Preset Directory**  
+   `<project>/.opencode/oh-my-opencode-slim/<preset>/<agent>.md` (if preset is active and safe)
+2. **Project Root Directory**  
+   `<project>/.opencode/oh-my-opencode-slim/<agent>.md`
+3. **User Preset Directory (Global)**  
+   `<user-config-dir>/oh-my-opencode-slim/<preset>/<agent>.md`
+4. **User Root Directory (Global)**  
+   `<user-config-dir>/oh-my-opencode-slim/<agent>.md`
+
+---
+
+## Prompt Composition Rules
+
+For any agent, the final system prompt is computed dynamically using the following formula:
+
+1. **Calculate Base Prompt:**
+   ```
+   base = inlinePrompt ?? defaultBuiltInPrompt
+   ```
+   - For built-in agents, `defaultBuiltInPrompt` is their factory template.
+   - For custom agents, `defaultBuiltInPrompt` defaults to `"You are the <name> specialist."`.
+   - `inlinePrompt` is the inline `prompt` string configured directly inside the `agents.<agent>.prompt` object.
+
+2. **Calculate Effective Base:**
+   ```
+   effectiveBase = filePrompt ?? base
+   ```
+   - `filePrompt` is the content of the resolved `<agent>.md` replacement file (located according to the Prompt Lookup Precedence).
+
+3. **Append Append Prompt:**
+   - If an append file `<agent>_append.md` is resolved, it is appended to the `effectiveBase` separated by two newlines:
+     ```
+     finalPrompt = effectiveBase + "\n\n" + appendPrompt
+     ```
+   - Otherwise:
+     ```
+     finalPrompt = effectiveBase
+     ```
+
+---
+
+## Custom Routing Guidance (`orchestratorPrompt`)
+
+Every non-orchestrator agent (both built-in and custom) can define an `orchestratorPrompt`. This snippet is automatically injected into the central **Orchestrator** prompt to instruct it on when and how to delegate tasks to this agent.
+
+- **Additive Routing Guidance:** The local config snippet is grouped under a clear markdown header (`# Project-specific routing guidance`) at the end of the orchestrator prompt. It does not replace the default routing blocks.
+- **Display name rewriting:** Any mentions of `@<internalName>` within the `orchestratorPrompt` are automatically mapped to the agent's custom `displayName` if one was defined.
+- **Disabled agents:** If an agent is disabled via the `disabled_agents` config option, its `orchestratorPrompt` is **not** injected.
+- **Orchestrator agent constraint:** The orchestrator agent itself cannot define an `orchestratorPrompt`. Setting `agents.orchestrator.orchestratorPrompt` will be rejected by the schema.
+
+---
+
+## Examples
+
+### Overriding Oracle prompt in active preset
+
+Your project has the config `.opencode/oh-my-opencode-slim.jsonc`:
+
+```json
+{
+  "preset": "backend-preset",
+  "presets": {
+    "backend-preset": {
+      "oracle": {
+        "model": "anthropic/claude-3-5-sonnet",
+        "prompt": "You are the project senior backend oracle. Focus strictly on NestJS."
+      }
+    }
+  }
+}
+```
+
+If you also place a file under `.opencode/oh-my-opencode-slim/backend-preset/oracle.md` containing:
+```
+Your primary focus is auditing backend security and performance.
+```
+According to prompt composition rules, the markdown file prompt overrides the inline preset prompt, so the effective base prompt becomes `"Your primary focus is auditing backend security and performance."`.

+ 4 - 706
oh-my-opencode-slim.schema.json

@@ -8,12 +8,12 @@
     "setDefaultAgent": {
       "type": "boolean"
     },
-    "autoUpdate": {
-      "description": "Disable automatic installation of plugin updates when false. Defaults to true.",
+    "compactSidebar": {
+      "description": "Use the compact TUI sidebar layout when enabled.",
       "type": "boolean"
     },
-    "compactSidebar": {
-      "description": "Render each agent on a single line in the TUI sidebar instead of the default multi-line layout. Default false.",
+    "autoUpdate": {
+      "description": "Disable automatic installation of plugin updates when false. Defaults to true.",
       "type": "boolean"
     },
     "presets": {
@@ -99,357 +99,6 @@
             "displayName": {
               "type": "string",
               "minLength": 1
-            },
-            "permission": {
-              "anyOf": [
-                {
-                  "type": "string",
-                  "enum": [
-                    "ask",
-                    "allow",
-                    "deny"
-                  ]
-                },
-                {
-                  "type": "object",
-                  "properties": {
-                    "read": {
-                      "anyOf": [
-                        {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        },
-                        {
-                          "type": "object",
-                          "propertyNames": {
-                            "type": "string"
-                          },
-                          "additionalProperties": {
-                            "type": "string",
-                            "enum": [
-                              "ask",
-                              "allow",
-                              "deny"
-                            ]
-                          }
-                        }
-                      ]
-                    },
-                    "edit": {
-                      "anyOf": [
-                        {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        },
-                        {
-                          "type": "object",
-                          "propertyNames": {
-                            "type": "string"
-                          },
-                          "additionalProperties": {
-                            "type": "string",
-                            "enum": [
-                              "ask",
-                              "allow",
-                              "deny"
-                            ]
-                          }
-                        }
-                      ]
-                    },
-                    "glob": {
-                      "anyOf": [
-                        {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        },
-                        {
-                          "type": "object",
-                          "propertyNames": {
-                            "type": "string"
-                          },
-                          "additionalProperties": {
-                            "type": "string",
-                            "enum": [
-                              "ask",
-                              "allow",
-                              "deny"
-                            ]
-                          }
-                        }
-                      ]
-                    },
-                    "grep": {
-                      "anyOf": [
-                        {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        },
-                        {
-                          "type": "object",
-                          "propertyNames": {
-                            "type": "string"
-                          },
-                          "additionalProperties": {
-                            "type": "string",
-                            "enum": [
-                              "ask",
-                              "allow",
-                              "deny"
-                            ]
-                          }
-                        }
-                      ]
-                    },
-                    "list": {
-                      "anyOf": [
-                        {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        },
-                        {
-                          "type": "object",
-                          "propertyNames": {
-                            "type": "string"
-                          },
-                          "additionalProperties": {
-                            "type": "string",
-                            "enum": [
-                              "ask",
-                              "allow",
-                              "deny"
-                            ]
-                          }
-                        }
-                      ]
-                    },
-                    "bash": {
-                      "anyOf": [
-                        {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        },
-                        {
-                          "type": "object",
-                          "propertyNames": {
-                            "type": "string"
-                          },
-                          "additionalProperties": {
-                            "type": "string",
-                            "enum": [
-                              "ask",
-                              "allow",
-                              "deny"
-                            ]
-                          }
-                        }
-                      ]
-                    },
-                    "task": {
-                      "anyOf": [
-                        {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        },
-                        {
-                          "type": "object",
-                          "propertyNames": {
-                            "type": "string"
-                          },
-                          "additionalProperties": {
-                            "type": "string",
-                            "enum": [
-                              "ask",
-                              "allow",
-                              "deny"
-                            ]
-                          }
-                        }
-                      ]
-                    },
-                    "external_directory": {
-                      "anyOf": [
-                        {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        },
-                        {
-                          "type": "object",
-                          "propertyNames": {
-                            "type": "string"
-                          },
-                          "additionalProperties": {
-                            "type": "string",
-                            "enum": [
-                              "ask",
-                              "allow",
-                              "deny"
-                            ]
-                          }
-                        }
-                      ]
-                    },
-                    "lsp": {
-                      "anyOf": [
-                        {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        },
-                        {
-                          "type": "object",
-                          "propertyNames": {
-                            "type": "string"
-                          },
-                          "additionalProperties": {
-                            "type": "string",
-                            "enum": [
-                              "ask",
-                              "allow",
-                              "deny"
-                            ]
-                          }
-                        }
-                      ]
-                    },
-                    "skill": {
-                      "anyOf": [
-                        {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        },
-                        {
-                          "type": "object",
-                          "propertyNames": {
-                            "type": "string"
-                          },
-                          "additionalProperties": {
-                            "type": "string",
-                            "enum": [
-                              "ask",
-                              "allow",
-                              "deny"
-                            ]
-                          }
-                        }
-                      ]
-                    },
-                    "todowrite": {
-                      "type": "string",
-                      "enum": [
-                        "ask",
-                        "allow",
-                        "deny"
-                      ]
-                    },
-                    "question": {
-                      "type": "string",
-                      "enum": [
-                        "ask",
-                        "allow",
-                        "deny"
-                      ]
-                    },
-                    "webfetch": {
-                      "type": "string",
-                      "enum": [
-                        "ask",
-                        "allow",
-                        "deny"
-                      ]
-                    },
-                    "websearch": {
-                      "type": "string",
-                      "enum": [
-                        "ask",
-                        "allow",
-                        "deny"
-                      ]
-                    },
-                    "codesearch": {
-                      "type": "string",
-                      "enum": [
-                        "ask",
-                        "allow",
-                        "deny"
-                      ]
-                    },
-                    "doom_loop": {
-                      "type": "string",
-                      "enum": [
-                        "ask",
-                        "allow",
-                        "deny"
-                      ]
-                    }
-                  },
-                  "additionalProperties": {
-                    "anyOf": [
-                      {
-                        "type": "string",
-                        "enum": [
-                          "ask",
-                          "allow",
-                          "deny"
-                        ]
-                      },
-                      {
-                        "type": "object",
-                        "propertyNames": {
-                          "type": "string"
-                        },
-                        "additionalProperties": {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        }
-                      }
-                    ]
-                  }
-                }
-              ]
             }
           },
           "additionalProperties": false
@@ -534,357 +183,6 @@
           "displayName": {
             "type": "string",
             "minLength": 1
-          },
-          "permission": {
-            "anyOf": [
-              {
-                "type": "string",
-                "enum": [
-                  "ask",
-                  "allow",
-                  "deny"
-                ]
-              },
-              {
-                "type": "object",
-                "properties": {
-                  "read": {
-                    "anyOf": [
-                      {
-                        "type": "string",
-                        "enum": [
-                          "ask",
-                          "allow",
-                          "deny"
-                        ]
-                      },
-                      {
-                        "type": "object",
-                        "propertyNames": {
-                          "type": "string"
-                        },
-                        "additionalProperties": {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        }
-                      }
-                    ]
-                  },
-                  "edit": {
-                    "anyOf": [
-                      {
-                        "type": "string",
-                        "enum": [
-                          "ask",
-                          "allow",
-                          "deny"
-                        ]
-                      },
-                      {
-                        "type": "object",
-                        "propertyNames": {
-                          "type": "string"
-                        },
-                        "additionalProperties": {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        }
-                      }
-                    ]
-                  },
-                  "glob": {
-                    "anyOf": [
-                      {
-                        "type": "string",
-                        "enum": [
-                          "ask",
-                          "allow",
-                          "deny"
-                        ]
-                      },
-                      {
-                        "type": "object",
-                        "propertyNames": {
-                          "type": "string"
-                        },
-                        "additionalProperties": {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        }
-                      }
-                    ]
-                  },
-                  "grep": {
-                    "anyOf": [
-                      {
-                        "type": "string",
-                        "enum": [
-                          "ask",
-                          "allow",
-                          "deny"
-                        ]
-                      },
-                      {
-                        "type": "object",
-                        "propertyNames": {
-                          "type": "string"
-                        },
-                        "additionalProperties": {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        }
-                      }
-                    ]
-                  },
-                  "list": {
-                    "anyOf": [
-                      {
-                        "type": "string",
-                        "enum": [
-                          "ask",
-                          "allow",
-                          "deny"
-                        ]
-                      },
-                      {
-                        "type": "object",
-                        "propertyNames": {
-                          "type": "string"
-                        },
-                        "additionalProperties": {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        }
-                      }
-                    ]
-                  },
-                  "bash": {
-                    "anyOf": [
-                      {
-                        "type": "string",
-                        "enum": [
-                          "ask",
-                          "allow",
-                          "deny"
-                        ]
-                      },
-                      {
-                        "type": "object",
-                        "propertyNames": {
-                          "type": "string"
-                        },
-                        "additionalProperties": {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        }
-                      }
-                    ]
-                  },
-                  "task": {
-                    "anyOf": [
-                      {
-                        "type": "string",
-                        "enum": [
-                          "ask",
-                          "allow",
-                          "deny"
-                        ]
-                      },
-                      {
-                        "type": "object",
-                        "propertyNames": {
-                          "type": "string"
-                        },
-                        "additionalProperties": {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        }
-                      }
-                    ]
-                  },
-                  "external_directory": {
-                    "anyOf": [
-                      {
-                        "type": "string",
-                        "enum": [
-                          "ask",
-                          "allow",
-                          "deny"
-                        ]
-                      },
-                      {
-                        "type": "object",
-                        "propertyNames": {
-                          "type": "string"
-                        },
-                        "additionalProperties": {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        }
-                      }
-                    ]
-                  },
-                  "lsp": {
-                    "anyOf": [
-                      {
-                        "type": "string",
-                        "enum": [
-                          "ask",
-                          "allow",
-                          "deny"
-                        ]
-                      },
-                      {
-                        "type": "object",
-                        "propertyNames": {
-                          "type": "string"
-                        },
-                        "additionalProperties": {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        }
-                      }
-                    ]
-                  },
-                  "skill": {
-                    "anyOf": [
-                      {
-                        "type": "string",
-                        "enum": [
-                          "ask",
-                          "allow",
-                          "deny"
-                        ]
-                      },
-                      {
-                        "type": "object",
-                        "propertyNames": {
-                          "type": "string"
-                        },
-                        "additionalProperties": {
-                          "type": "string",
-                          "enum": [
-                            "ask",
-                            "allow",
-                            "deny"
-                          ]
-                        }
-                      }
-                    ]
-                  },
-                  "todowrite": {
-                    "type": "string",
-                    "enum": [
-                      "ask",
-                      "allow",
-                      "deny"
-                    ]
-                  },
-                  "question": {
-                    "type": "string",
-                    "enum": [
-                      "ask",
-                      "allow",
-                      "deny"
-                    ]
-                  },
-                  "webfetch": {
-                    "type": "string",
-                    "enum": [
-                      "ask",
-                      "allow",
-                      "deny"
-                    ]
-                  },
-                  "websearch": {
-                    "type": "string",
-                    "enum": [
-                      "ask",
-                      "allow",
-                      "deny"
-                    ]
-                  },
-                  "codesearch": {
-                    "type": "string",
-                    "enum": [
-                      "ask",
-                      "allow",
-                      "deny"
-                    ]
-                  },
-                  "doom_loop": {
-                    "type": "string",
-                    "enum": [
-                      "ask",
-                      "allow",
-                      "deny"
-                    ]
-                  }
-                },
-                "additionalProperties": {
-                  "anyOf": [
-                    {
-                      "type": "string",
-                      "enum": [
-                        "ask",
-                        "allow",
-                        "deny"
-                      ]
-                    },
-                    {
-                      "type": "object",
-                      "propertyNames": {
-                        "type": "string"
-                      },
-                      "additionalProperties": {
-                        "type": "string",
-                        "enum": [
-                          "ask",
-                          "allow",
-                          "deny"
-                        ]
-                      }
-                    }
-                  ]
-                }
-              }
-            ]
           }
         },
         "additionalProperties": false

+ 5 - 4
src/agents/councillor.test.ts

@@ -48,16 +48,17 @@ describe('createCouncillorAgent', () => {
     expect(agent.config.prompt).toContain('Additional instructions here.');
   });
 
-  test('custom prompt takes priority over append prompt', () => {
+  test('custom prompt and append prompt compose together', () => {
     const customPrompt = 'Custom prompt only.';
-    const customAppendPrompt = 'Should be ignored.';
+    const customAppendPrompt = 'Additional routing context.';
     const agent = createCouncillorAgent(
       'test-model',
       customPrompt,
       customAppendPrompt,
     );
-    expect(agent.config.prompt).toBe(customPrompt);
-    expect(agent.config.prompt).not.toContain(customAppendPrompt);
+    expect(agent.config.prompt).toBe(
+      `${customPrompt}\n\n${customAppendPrompt}`,
+    );
   });
 });
 

+ 66 - 137
src/agents/custom.test.ts

@@ -252,165 +252,94 @@ describe('custom-agent creation', () => {
       "ACP agent 'fixer' conflicts with a built-in agent name or alias",
     );
   });
-});
 
-describe('custom-agent permission passthrough', () => {
-  test('passes user permission through to agent config', () => {
-    const config: PluginConfig = {
-      agents: {
-        planner: {
-          model: 'openai/gpt-5.5',
-          permission: { edit: 'deny', bash: 'ask' },
-        },
-      },
-    };
-
-    const agents = createAgents(config);
-    const planner = agents.find((a) => a.name === 'planner');
-
-    expect(planner).toBeDefined();
-    expect(planner?.config.permission).toMatchObject({
-      edit: 'deny',
-      bash: 'ask',
-    });
-  });
-
-  test('applies permission to built-in agent overrides', () => {
+  test('appends ACP routing prompts separately without heading, and preserves rewriting', () => {
     const config: PluginConfig = {
       agents: {
         explorer: {
-          model: 'openai/gpt-5.5',
-          permission: { edit: 'deny' },
+          model: 'openai/gpt-5.4-mini',
+          displayName: 'fancy-explorer',
         },
-      },
-    };
-
-    const agents = createAgents(config);
-    const explorer = agents.find((a) => a.name === 'explorer');
-
-    expect(explorer).toBeDefined();
-    expect(explorer?.config.permission).toMatchObject({
-      edit: 'deny',
-    });
-  });
-
-  test('user edit/bash survive merge with skills config', () => {
-    const config: PluginConfig = {
-      agents: {
-        planner: {
+        janitor: {
           model: 'openai/gpt-5.5',
-          skills: ['my-skill'],
-          permission: { edit: 'deny', bash: 'ask' },
+          orchestratorPrompt:
+            'Please use @janitor to clean up after @explorer has completed.',
         },
       },
-    };
-
-    const agents = createAgents(config);
-    const planner = agents.find((a) => a.name === 'planner');
-
-    expect(planner).toBeDefined();
-    // User-supplied keys survive
-    expect(planner?.config.permission).toMatchObject({
-      edit: 'deny',
-      bash: 'ask',
-    });
-    // Plugin generates skill rule (overrides any user skill key)
-    expect(
-      (planner?.config.permission as Record<string, unknown>)?.skill,
-    ).toBeDefined();
-  });
-
-  test('passes permission through unchanged without skills or mcps', () => {
-    const config: PluginConfig = {
-      agents: {
-        researcher: {
-          model: 'openai/gpt-5.5',
-          permission: { edit: 'deny', webfetch: 'allow' },
+      acpAgents: {
+        'claude-research': {
+          command: 'claude-code-acp',
+          args: [],
+          env: {},
+          timeoutMs: 0,
+          permissionMode: 'ask',
+          orchestratorPrompt:
+            'Please delegate research tasks to @claude-research or @explorer.',
         },
       },
     };
 
     const agents = createAgents(config);
-    const researcher = agents.find((a) => a.name === 'researcher');
-
-    expect(researcher).toBeDefined();
-    expect(researcher?.config.permission).toMatchObject({
-      edit: 'deny',
-      webfetch: 'allow',
-    });
-  });
+    const orchestrator = agents.find((agent) => agent.name === 'orchestrator');
+    const prompt = orchestrator?.config.prompt ?? '';
 
-  test('no permission field means no regression', () => {
-    const config: PluginConfig = {
-      agents: {
-        reviewer: {
-          model: 'openai/gpt-5.5',
-          prompt: 'You are a reviewer.',
-        },
-      },
-    };
+    // Verify Project-specific routing guidance exists and has the custom agent override prompt rewritten
+    expect(prompt).toContain('# Project-specific routing guidance');
+    expect(prompt).toContain(
+      'Please use @janitor to clean up after @fancy-explorer has completed.',
+    );
 
-    const agents = createAgents(config);
-    const reviewer = agents.find((a) => a.name === 'reviewer');
-
-    expect(reviewer).toBeDefined();
-    // Plugin still generates its own permission keys (question, etc.)
-    expect(reviewer?.config.permission).toBeDefined();
-    // But no edit/bash keys since user didn't set them
-    expect(
-      (reviewer?.config.permission as Record<string, unknown>)?.edit,
-    ).toBeUndefined();
-  });
-});
+    // Verify ACP routing prompt is appended but NOT under the Project-specific routing guidance section
+    // (i.e. it comes after or is separate, let's verify exact substring sequence or that the ACP test isn't inside the heading section)
+    const pieces = prompt.split('# Project-specific routing guidance');
+    expect(pieces.length).toBe(2);
+
+    const headingContent = pieces[1];
+    // The headingContent should contain the custom prompt but not contain the ACP prompt if ACP prompt is appended after/before.
+    // Wait, in our implementation, order of appending is:
+    // 1) overridden/custom prompts under # Project-specific routing guidance.
+    // 2) ACP routing prompts (without the header).
+    // So headingContent (everything after the header) will contain the custom prompt, and then at the very end (or separated), the ACP prompt.
+    // But the ACP prompt is not under that header's specific block if it's appended separately.
+    // Wait, is there a way to verify they are separated? Yes, we can verify that the custom prompt block and ACP prompt block are two separate parts,
+    // and that ACP prompt is appended at the very end of the string, outside of the heading's contiguous text block or that if only ACP is present, the heading doesn't show up.
+    expect(headingContent).toContain(
+      'Please use @janitor to clean up after @fancy-explorer has completed.',
+    );
+    expect(headingContent).toContain(
+      'Please delegate research tasks to @claude-research or @fancy-explorer.',
+    );
 
-describe('permission edge cases', () => {
-  test('shorthand string permission is not corrupted by applyDefaultPermissions', () => {
-    const config: PluginConfig = {
+    // Let's also check a scenario where only ACP routing prompt is present. There should be NO heading at all!
+    const configOnlyAcp: PluginConfig = {
       agents: {
-        planner: {
-          model: 'openai/gpt-5.5',
-          permission: 'ask',
+        explorer: {
+          model: 'openai/gpt-5.4-mini',
+          displayName: 'fancy-explorer',
         },
       },
-    };
-
-    const agents = createAgents(config);
-    const planner = agents.find((a) => a.name === 'planner');
-
-    expect(planner).toBeDefined();
-    // The shorthand string should be preserved as-is, not spread into
-    // character keys like { "0": "a", "1": "s", "2": "k" }
-    expect(planner?.config.permission).toBe('ask');
-  });
-
-  test('orchestrator permission override does not replace plugin gates', () => {
-    const config: PluginConfig = {
-      agents: {
-        orchestrator: {
-          model: 'openai/gpt-5.5',
-          permission: { edit: 'deny' },
+      acpAgents: {
+        'claude-research': {
+          command: 'claude-code-acp',
+          args: [],
+          env: {},
+          timeoutMs: 0,
+          permissionMode: 'ask',
+          orchestratorPrompt:
+            'Please delegate research tasks to @claude-research or @explorer.',
         },
       },
     };
 
-    const agents = createAgents(config);
-    const orchestrator = agents.find((a) => a.name === 'orchestrator');
-
-    expect(orchestrator).toBeDefined();
-    // User-supplied key survives
-    expect(orchestrator?.config.permission).toMatchObject({
-      edit: 'deny',
-    });
-    // Plugin-generated gates are NOT dropped by the override
-    expect(
-      (orchestrator?.config.permission as Record<string, unknown>)?.question,
-    ).toBeDefined();
-    expect(
-      (orchestrator?.config.permission as Record<string, unknown>)
-        ?.council_session,
-    ).toBeDefined();
-    expect(
-      (orchestrator?.config.permission as Record<string, unknown>)?.cancel_task,
-    ).toBeDefined();
+    const agentsOnlyAcp = createAgents(configOnlyAcp);
+    const orchestratorOnlyAcp = agentsOnlyAcp.find(
+      (agent) => agent.name === 'orchestrator',
+    );
+    const promptOnlyAcp = orchestratorOnlyAcp?.config.prompt ?? '';
+
+    expect(promptOnlyAcp).not.toContain('# Project-specific routing guidance');
+    expect(promptOnlyAcp).toContain(
+      'Please delegate research tasks to @claude-research or @fancy-explorer.',
+    );
   });
 });

+ 18 - 97
src/agents/index.test.ts

@@ -296,7 +296,6 @@ describe('skill permissions', () => {
     // CUSTOM_SKILLS loop must also add a named codemap entry for orchestrator
     expect(skillPerm?.codemap).toBe('allow');
     expect(skillPerm?.clonedeps).toBe('allow');
-    expect(skillPerm?.['release-smoke-test']).toBe('allow');
   });
 
   test('fixer does not get codemap skill allowed by default', () => {
@@ -796,7 +795,7 @@ describe('AgentOverrideConfigSchema options validation', () => {
 });
 
 describe('PluginConfigSchema custom-agent-only prompt fields', () => {
-  test('rejects prompt on built-in top-level agent overrides', () => {
+  test('allows prompt on built-in top-level agent overrides', () => {
     const result = PluginConfigSchema.safeParse({
       agents: {
         oracle: {
@@ -806,10 +805,10 @@ describe('PluginConfigSchema custom-agent-only prompt fields', () => {
       },
     });
 
-    expect(result.success).toBe(false);
+    expect(result.success).toBe(true);
   });
 
-  test('rejects orchestratorPrompt on built-in top-level agent overrides', () => {
+  test('allows orchestratorPrompt on built-in top-level agent overrides', () => {
     const result = PluginConfigSchema.safeParse({
       agents: {
         explorer: {
@@ -819,10 +818,10 @@ describe('PluginConfigSchema custom-agent-only prompt fields', () => {
       },
     });
 
-    expect(result.success).toBe(false);
+    expect(result.success).toBe(true);
   });
 
-  test('rejects custom-only prompt fields on built-in preset agents', () => {
+  test('allows custom-only prompt fields on built-in preset agents', () => {
     const result = PluginConfigSchema.safeParse({
       presets: {
         openai: {
@@ -834,6 +833,19 @@ describe('PluginConfigSchema custom-agent-only prompt fields', () => {
       },
     });
 
+    expect(result.success).toBe(true);
+  });
+
+  test('rejects orchestratorPrompt on orchestrator agent overrides', () => {
+    const result = PluginConfigSchema.safeParse({
+      agents: {
+        orchestrator: {
+          model: 'openai/gpt-5.4-mini',
+          orchestratorPrompt: '@orchestrator\n- Role: should be invalid here',
+        },
+      },
+    });
+
     expect(result.success).toBe(false);
   });
 
@@ -1037,94 +1049,3 @@ describe('observer agent', () => {
     expect(DEFAULT_DISABLED_AGENTS).toContain('observer');
   });
 });
-
-describe('AgentOverrideConfigSchema permission validation', () => {
-  test('accepts object-form permission', () => {
-    const result = AgentOverrideConfigSchema.safeParse({
-      model: 'openai/gpt-5.5',
-      permission: { edit: 'deny', bash: 'ask' },
-    });
-    expect(result.success).toBe(true);
-    if (result.success) {
-      expect(result.data.permission).toEqual({
-        edit: 'deny',
-        bash: 'ask',
-      });
-    }
-  });
-
-  test('accepts shorthand string permission', () => {
-    const result = AgentOverrideConfigSchema.safeParse({
-      model: 'openai/gpt-5.5',
-      permission: 'ask',
-    });
-    expect(result.success).toBe(true);
-    if (result.success) {
-      expect(result.data.permission).toBe('ask');
-    }
-  });
-
-  test('rejects invalid action value', () => {
-    const result = AgentOverrideConfigSchema.safeParse({
-      model: 'openai/gpt-5.5',
-      permission: { edit: 'alow' },
-    });
-    expect(result.success).toBe(false);
-  });
-
-  test('rejects object value on action-only key', () => {
-    const result = AgentOverrideConfigSchema.safeParse({
-      model: 'openai/gpt-5.5',
-      permission: { webfetch: { '*': 'allow' } },
-    });
-    expect(result.success).toBe(false);
-  });
-
-  test('accepts unknown permission key (passthrough)', () => {
-    const result = AgentOverrideConfigSchema.safeParse({
-      model: 'openai/gpt-5.5',
-      permission: { custom_tool_name: 'ask' },
-    });
-    expect(result.success).toBe(true);
-    if (result.success) {
-      expect(
-        (result.data.permission as Record<string, unknown>).custom_tool_name,
-      ).toBe('ask');
-    }
-  });
-
-  test('accepts pattern-based bash rule', () => {
-    const result = AgentOverrideConfigSchema.safeParse({
-      model: 'openai/gpt-5.5',
-      permission: {
-        bash: { 'git status*': 'allow', '*': 'ask' },
-      },
-    });
-    expect(result.success).toBe(true);
-    if (result.success) {
-      expect(result.data.permission).toEqual({
-        bash: { 'git status*': 'allow', '*': 'ask' },
-      });
-    }
-  });
-
-  test('rejects invalid action in pattern map', () => {
-    const result = AgentOverrideConfigSchema.safeParse({
-      model: 'openai/gpt-5.5',
-      permission: {
-        bash: { 'git status*': 'alow' },
-      },
-    });
-    expect(result.success).toBe(false);
-  });
-
-  test('rejects array value for unknown permission key', () => {
-    const result = AgentOverrideConfigSchema.safeParse({
-      model: 'openai/gpt-5.5',
-      permission: {
-        custom_tool: ['foo', 'bar'],
-      },
-    });
-    expect(result.success).toBe(false);
-  });
-});

+ 79 - 47
src/agents/index.ts

@@ -107,9 +107,7 @@ function buildAcpAgentDefinition(
       `You are the ${name} ACP wrapper agent.`,
       '',
       'Your only job is to send the user task to the configured external ACP agent using the acp_run tool, then return the ACP agent result.',
-      `Always call acp_run with agent: ${JSON.stringify(
-        name,
-      )} and pass the full user task as prompt.`,
+      `Always call acp_run with agent: ${JSON.stringify(name)} and pass the full user task as prompt.`,
       'Do not edit files yourself unless the ACP result explicitly asks you to report a local follow-up to the orchestrator.',
     ].join('\n');
 
@@ -179,9 +177,6 @@ function applyOverrides(
   if (override.displayName) {
     agent.displayName = override.displayName;
   }
-  if (override.permission) {
-    agent.config.permission = override.permission;
-  }
 }
 
 function isKnownAgentName(name: string): boolean {
@@ -258,13 +253,6 @@ function applyDefaultPermissions(
   configuredSkills?: string[],
   disabledSkills?: string[],
 ): void {
-  // If the user supplied a shorthand string permission (e.g. "ask"),
-  // it already applies to all tools — preserve it as-is and skip the
-  // object merge, which would corrupt it by spreading the string.
-  if (typeof agent.config.permission === 'string') {
-    return;
-  }
-
   const existing = (agent.config.permission ?? {}) as Record<
     string,
     'ask' | 'allow' | 'deny' | Record<string, 'ask' | 'allow' | 'deny'>
@@ -329,7 +317,10 @@ const SUBAGENT_FACTORIES: Record<SubagentName, AgentFactory> = {
  * @param config - Optional plugin configuration with agent overrides
  * @returns Array of agent definitions (orchestrator first, then subagents)
  */
-export function createAgents(config?: PluginConfig): AgentDefinition[] {
+export function createAgents(
+  config?: PluginConfig,
+  options?: { projectDirectory?: string },
+): AgentDefinition[] {
   const disabled = getDisabledAgents(config);
   if (!config?.council) {
     disabled.add('council');
@@ -362,12 +353,27 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
   )
     .filter(([name]) => !disabled.has(name))
     .map(([name, factory]) => {
-      const customPrompts = loadAgentPrompt(name, config?.preset);
-      return factory(
-        getModelForAgent(name),
+      // Get base agent definition using the subagent factory with undefined prompts
+      const agent = factory(getModelForAgent(name), undefined, undefined);
+
+      const customPrompts = loadAgentPrompt(name, {
+        preset: config?.preset,
+        projectDirectory: options?.projectDirectory,
+      });
+
+      const override = getAgentOverride(config, name);
+      const inlinePrompt = override?.prompt;
+      const defaultPrompt = agent.config.prompt ?? '';
+
+      const basePrompt =
+        inlinePrompt !== undefined ? inlinePrompt : defaultPrompt;
+      agent.config.prompt = resolvePrompt(
+        basePrompt,
         customPrompts.prompt,
         customPrompts.appendPrompt,
       );
+
+      return agent;
     });
 
   // 1b. Discover unknown keys in config.agents as custom subagents.
@@ -393,7 +399,10 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
       return [];
     }
 
-    const customPrompts = loadAgentPrompt(name, config?.preset);
+    const customPrompts = loadAgentPrompt(name, {
+      preset: config?.preset,
+      projectDirectory: options?.projectDirectory,
+    });
 
     return [
       buildCustomAgentDefinition(
@@ -484,21 +493,38 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
   const orchestratorOverride = getAgentOverride(config, 'orchestrator');
   const orchestratorModel =
     orchestratorOverride?.model ?? DEFAULT_MODELS.orchestrator;
-  const orchestratorPrompts = loadAgentPrompt('orchestrator', config?.preset);
+  const orchestratorPrompts = loadAgentPrompt('orchestrator', {
+    preset: config?.preset,
+    projectDirectory: options?.projectDirectory,
+  });
   const orchestrator = createOrchestratorAgent(
     orchestratorModel,
+    undefined,
+    undefined,
+    disabled,
+  );
+
+  const inlineOrchestratorPrompt = orchestratorOverride?.prompt;
+  const defaultOrchestratorPrompt = orchestrator.config.prompt ?? '';
+
+  const baseOrchestratorPrompt =
+    inlineOrchestratorPrompt !== undefined
+      ? inlineOrchestratorPrompt
+      : defaultOrchestratorPrompt;
+  orchestrator.config.prompt = resolvePrompt(
+    baseOrchestratorPrompt,
     orchestratorPrompts.prompt,
     orchestratorPrompts.appendPrompt,
-    disabled,
   );
-  if (orchestratorOverride) {
-    applyOverrides(orchestrator, orchestratorOverride);
-  }
+
   applyDefaultPermissions(
     orchestrator,
     orchestratorOverride?.skills,
     config?.disabled_skills,
   );
+  if (orchestratorOverride) {
+    applyOverrides(orchestrator, orchestratorOverride);
+  }
 
   // Collect all display names from orchestrator and all subagents
   const displayNameMap = new Map<string, string>();
@@ -511,8 +537,8 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
     }
   }
 
-  // 3b. Append custom orchestrator hints from custom agent overrides.
-  const customOrchestratorPrompts = customSubAgents
+  // 3b. Append custom orchestrator hints from built-in and custom agent overrides.
+  const extraOrchestratorPromptsList = [...builtInSubAgents, ...customSubAgents]
     .map((agent) => {
       const override = getAgentOverride(config, agent.name);
       return override?.orchestratorPrompt;
@@ -524,9 +550,7 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
     if (acp?.orchestratorPrompt) return acp.orchestratorPrompt;
     return [
       `@${agent.name}`,
-      `- Lane: External ACP-connected agent (${
-        acp?.command ?? 'unknown command'
-      })`,
+      `- Lane: External ACP-connected agent (${acp?.command ?? 'unknown command'})`,
       `- Role: ${agent.description ?? `External ACP agent ${agent.name}`}`,
       '- **Delegate when:** The user explicitly asks for this ACP-backed agent, or the task matches its role and benefits from software/subscription-specific capabilities outside OpenCode.',
       '- **Do not delegate when:** The built-in specialists can handle the task more directly or local file ownership would conflict with another writer lane.',
@@ -565,28 +589,34 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
   // Inject display names into orchestrator prompt (complete map)
   injectDisplayNames(orchestrator, displayNameMap);
 
-  const extraOrchestratorPrompts = [
-    ...customOrchestratorPrompts,
-    ...acpOrchestratorPrompts,
-  ];
+  const rewritePrompt = (promptText: string) => {
+    let text = promptText;
+    for (const [internalName, displayName] of displayNameMap) {
+      text = text.replace(
+        new RegExp(`@${escapeRegExp(internalName)}\\b`, 'g'),
+        `@${normalizeDisplayName(displayName)}`,
+      );
+    }
+    return text;
+  };
 
-  if (extraOrchestratorPrompts.length > 0) {
-    const rewrittenPrompts = extraOrchestratorPrompts.map((promptText) => {
-      let text = promptText;
-      for (const [internalName, displayName] of displayNameMap) {
-        text = text.replace(
-          new RegExp(`@${escapeRegExp(internalName)}\\b`, 'g'),
-          `@${normalizeDisplayName(displayName)}`,
-        );
-      }
-      return text;
-    });
+  const rewrittenOverrides = extraOrchestratorPromptsList.map(rewritePrompt);
+  const rewrittenAcps = acpOrchestratorPrompts.map(rewritePrompt);
+
+  let updatedPrompt = orchestrator.config.prompt ?? '';
 
-    orchestrator.config.prompt = `${
-      orchestrator.config.prompt
-    }\n\n${rewrittenPrompts.join('\n\n')}`;
+  if (rewrittenOverrides.length > 0) {
+    updatedPrompt = `${updatedPrompt}\n\n# Project-specific routing guidance\n\n${rewrittenOverrides.join(
+      '\n\n',
+    )}`;
   }
 
+  if (rewrittenAcps.length > 0) {
+    updatedPrompt = `${updatedPrompt}\n\n${rewrittenAcps.join('\n\n')}`;
+  }
+
+  orchestrator.config.prompt = updatedPrompt;
+
   return [orchestrator, ...allSubAgents];
 }
 
@@ -595,12 +625,14 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
  * Converts agent definitions to SDK config format and applies classification metadata.
  *
  * @param config - Optional plugin configuration with agent overrides
+ * @param options - Optional options including projectDirectory
  * @returns Record mapping agent names to their SDK configurations
  */
 export function getAgentConfigs(
   config?: PluginConfig,
+  options?: { projectDirectory?: string },
 ): Record<string, SDKAgentConfig> {
-  const agents = createAgents(config);
+  const agents = createAgents(config, options);
 
   const applyClassification = (
     name: string,

+ 5 - 4
src/agents/orchestrator.ts

@@ -13,16 +13,17 @@ export interface AgentDefinition {
 /**
  * Resolve agent prompt from base/custom/append inputs.
  * If customPrompt is provided, it replaces the base entirely.
- * Otherwise, customAppendPrompt is appended to the base.
+ * If customAppendPrompt is provided, it appends after whichever base won.
  */
 export function resolvePrompt(
   base: string,
   customPrompt?: string,
   customAppendPrompt?: string,
 ): string {
-  if (customPrompt) return customPrompt;
-  if (customAppendPrompt) return `${base}\n\n${customAppendPrompt}`;
-  return base;
+  const effectiveBase = customPrompt !== undefined ? customPrompt : base;
+  return customAppendPrompt !== undefined
+    ? `${effectiveBase}\n\n${customAppendPrompt}`
+    : effectiveBase;
 }
 
 // Agent descriptions for the orchestrator prompt

+ 18 - 2
src/config/loader.test.ts

@@ -86,7 +86,7 @@ describe('loadPluginConfig', () => {
     expect(loadPluginConfig(projectDir)).toEqual({});
   });
 
-  test('rejects custom-only prompt fields on built-in agents in config files', () => {
+  test('accepts prompt on built-in agents and rejects orchestratorPrompt on orchestrator in config files', () => {
     const projectDir = path.join(tempDir, 'project');
     const projectConfigDir = path.join(projectDir, '.opencode');
     fs.mkdirSync(projectConfigDir, { recursive: true });
@@ -97,12 +97,28 @@ describe('loadPluginConfig', () => {
         agents: {
           oracle: {
             model: 'openai/gpt-5.5',
-            prompt: 'This should be rejected for built-in agents.',
+            prompt: 'This is now allowed for built-in agents.',
           },
         },
       }),
     );
 
+    const loaded = loadPluginConfig(projectDir);
+    expect(loaded.agents?.oracle?.prompt).toBe(
+      'This is now allowed for built-in agents.',
+    );
+
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        agents: {
+          orchestrator: {
+            model: 'openai/gpt-5.5',
+            orchestratorPrompt: 'This must be rejected.',
+          },
+        },
+      }),
+    );
     expect(loadPluginConfig(projectDir)).toEqual({});
   });
 

+ 40 - 10
src/config/loader.ts

@@ -197,6 +197,7 @@ export function mergePluginConfigs(
     ...base,
     ...override,
     agents: deepMerge(base.agents, override.agents),
+    presets: deepMerge(base.presets, override.presets),
     tmux: deepMerge(base.tmux, override.tmux),
     multiplexer: deepMerge(base.multiplexer, override.multiplexer),
     interview: deepMerge(base.interview, override.interview),
@@ -342,31 +343,58 @@ export function loadPluginConfig(
  * then falls back to the root prompts directory.
  *
  * @param agentName - Name of the agent (e.g., "orchestrator", "explorer")
- * @param preset - Optional preset name for preset-scoped prompt lookup
+ * @param optionsOrPreset - Optional preset name or options configuration
  * @returns Object with prompt and/or appendPrompt if files exist
  */
 export function loadAgentPrompt(
   agentName: string,
-  preset?: string,
+  optionsOrPreset?: string | { preset?: string; projectDirectory?: string },
 ): {
   prompt?: string;
   appendPrompt?: string;
 } {
+  let preset: string | undefined;
+  let projectDirectory: string | undefined;
+
+  if (typeof optionsOrPreset === 'string') {
+    preset = optionsOrPreset;
+  } else if (optionsOrPreset && typeof optionsOrPreset === 'object') {
+    preset = optionsOrPreset.preset;
+    projectDirectory = optionsOrPreset.projectDirectory;
+  }
+
   const presetDirName =
     preset && /^[a-zA-Z0-9_-]+$/.test(preset) ? preset : undefined;
-  const promptSearchDirs = getConfigSearchDirs().flatMap((configDir) => {
-    const promptsDir = path.join(configDir, PROMPTS_DIR_NAME);
-    return presetDirName
-      ? [path.join(promptsDir, presetDirName), promptsDir]
-      : [promptsDir];
-  });
-  const result: { prompt?: string; appendPrompt?: string } = {};
+
+  const searchDirs: string[] = [];
+
+  // Lookup order preference:
+  // 1. Project preset dir
+  if (projectDirectory && presetDirName) {
+    searchDirs.push(
+      path.join(projectDirectory, '.opencode', PROMPTS_DIR_NAME, presetDirName),
+    );
+  }
+  // 2. Project root dir
+  if (projectDirectory) {
+    searchDirs.push(path.join(projectDirectory, '.opencode', PROMPTS_DIR_NAME));
+  }
+  // 3. User preset dirs
+  if (presetDirName) {
+    for (const userDir of getConfigSearchDirs()) {
+      searchDirs.push(path.join(userDir, PROMPTS_DIR_NAME, presetDirName));
+    }
+  }
+  // 4. User root dirs
+  for (const userDir of getConfigSearchDirs()) {
+    searchDirs.push(path.join(userDir, PROMPTS_DIR_NAME));
+  }
 
   const readFirstPrompt = (
     fileName: string,
     errorPrefix: string,
   ): string | undefined => {
-    for (const dir of promptSearchDirs) {
+    for (const dir of searchDirs) {
       const promptPath = path.join(dir, fileName);
       if (!fs.existsSync(promptPath)) {
         continue;
@@ -385,6 +413,8 @@ export function loadAgentPrompt(
     return undefined;
   };
 
+  const result: { prompt?: string; appendPrompt?: string } = {};
+
   // Check for replacement prompt
   result.prompt = readFirstPrompt(
     `${agentName}.md`,

+ 410 - 0
src/config/project-local-customization.test.ts

@@ -0,0 +1,410 @@
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { createAgents } from '../agents';
+import { loadAgentPrompt, mergePluginConfigs } from './loader';
+import { PluginConfigSchema } from './schema';
+
+describe('Project-local customization - 15 core cases', () => {
+  let tempDir: string;
+  let originalEnv: typeof process.env;
+
+  beforeEach(() => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proj-custom-test-'));
+    originalEnv = { ...process.env };
+    delete process.env.OPENCODE_CONFIG_DIR;
+    process.env.XDG_CONFIG_HOME = tempDir;
+  });
+
+  afterEach(() => {
+    fs.rmSync(tempDir, { recursive: true, force: true });
+    process.env = originalEnv;
+  });
+
+  // Test Case 1: Project prompt root beats user prompt root
+  test('1. Project prompt root beats user prompt root', () => {
+    const userDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
+    fs.mkdirSync(userDir, { recursive: true });
+    fs.writeFileSync(path.join(userDir, 'oracle.md'), 'user-oracle');
+
+    const projectDir = path.join(tempDir, 'project');
+    const projectPromptDir = path.join(
+      projectDir,
+      '.opencode',
+      'oh-my-opencode-slim',
+    );
+    fs.mkdirSync(projectPromptDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectPromptDir, 'oracle.md'),
+      'project-oracle',
+    );
+
+    const loaded = loadAgentPrompt('oracle', { projectDirectory: projectDir });
+    expect(loaded.prompt).toBe('project-oracle');
+  });
+
+  // Test Case 2: Project preset prompt beats project root prompt
+  test('2. Project preset prompt beats project root prompt', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectPromptDir = path.join(
+      projectDir,
+      '.opencode',
+      'oh-my-opencode-slim',
+    );
+    const projectPresetDir = path.join(projectPromptDir, 'test-preset');
+    fs.mkdirSync(projectPresetDir, { recursive: true });
+
+    fs.writeFileSync(
+      path.join(projectPromptDir, 'oracle.md'),
+      'project-root-oracle',
+    );
+    fs.writeFileSync(
+      path.join(projectPresetDir, 'oracle.md'),
+      'project-preset-oracle',
+    );
+
+    const loaded = loadAgentPrompt('oracle', {
+      preset: 'test-preset',
+      projectDirectory: projectDir,
+    });
+    expect(loaded.prompt).toBe('project-preset-oracle');
+  });
+
+  // Test Case 3: User preset prompt beats user root prompt when no project prompt exists
+  test('3. User preset prompt beats user root prompt when no project prompt exists', () => {
+    const userDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
+    const userPresetDir = path.join(userDir, 'test-preset');
+    fs.mkdirSync(userPresetDir, { recursive: true });
+
+    fs.writeFileSync(path.join(userDir, 'oracle.md'), 'user-root-oracle');
+    fs.writeFileSync(
+      path.join(userPresetDir, 'oracle.md'),
+      'user-preset-oracle',
+    );
+
+    const loaded = loadAgentPrompt('oracle', { preset: 'test-preset' });
+    expect(loaded.prompt).toBe('user-preset-oracle');
+  });
+
+  // Test Case 4: Replacement and append both apply together
+  test('4. Replacement and append both apply together', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectPromptDir = path.join(
+      projectDir,
+      '.opencode',
+      'oh-my-opencode-slim',
+    );
+    fs.mkdirSync(projectPromptDir, { recursive: true });
+
+    fs.writeFileSync(
+      path.join(projectPromptDir, 'oracle.md'),
+      'replacement prompt',
+    );
+    fs.writeFileSync(
+      path.join(projectPromptDir, 'oracle_append.md'),
+      'append prompt',
+    );
+
+    const agents = createAgents(undefined, { projectDirectory: projectDir });
+    const oracle = agents.find((a) => a.name === 'oracle');
+    expect(oracle?.config.prompt).toBe('replacement prompt\n\nappend prompt');
+  });
+
+  // Test Case 5: Inline built-in prompt is accepted and used
+  test('5. Inline built-in prompt is accepted and used', () => {
+    const config = {
+      agents: {
+        oracle: {
+          model: 'openai/gpt-4o',
+          prompt: 'You are the inline oracle prompt override.',
+        },
+      },
+    };
+
+    const agents = createAgents(config);
+    const oracle = agents.find((a) => a.name === 'oracle');
+    expect(oracle?.config.prompt).toBe(
+      'You are the inline oracle prompt override.',
+    );
+  });
+
+  // Test Case 6: File prompt overrides inline built-in prompt
+  test('6. File prompt overrides inline built-in prompt', () => {
+    const config = {
+      agents: {
+        oracle: {
+          model: 'openai/gpt-4o',
+          prompt: 'You are the inline oracle prompt override.',
+        },
+      },
+    };
+
+    // User prompt file mock
+    const userDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
+    fs.mkdirSync(userDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(userDir, 'oracle.md'),
+      'File prompt override content',
+    );
+
+    const agents = createAgents(config);
+    const oracle = agents.find((a) => a.name === 'oracle');
+    expect(oracle?.config.prompt).toBe('File prompt override content');
+  });
+
+  // Test Case 7: Append file appends to inline built-in prompt
+  test('7. Append file appends to inline built-in prompt', () => {
+    const config = {
+      agents: {
+        oracle: {
+          model: 'openai/gpt-4o',
+          prompt: 'You are the inline oracle prompt override.',
+        },
+      },
+    };
+
+    // User append file mock
+    const userDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
+    fs.mkdirSync(userDir, { recursive: true });
+    fs.writeFileSync(path.join(userDir, 'oracle_append.md'), 'append content');
+
+    const agents = createAgents(config);
+    const oracle = agents.find((a) => a.name === 'oracle');
+    expect(oracle?.config.prompt).toBe(
+      'You are the inline oracle prompt override.\n\nappend content',
+    );
+  });
+
+  // Test Case 8: Built-in orchestratorPrompt is injected into orchestrator prompt
+  test('8. Built-in orchestratorPrompt is injected into orchestrator prompt', () => {
+    const config = {
+      agents: {
+        oracle: {
+          model: 'openai/gpt-4o',
+          orchestratorPrompt:
+            'Please routing to @oracle when architecture is queried.',
+        },
+      },
+    };
+
+    const agents = createAgents(config);
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
+    expect(orchestrator?.config.prompt).toContain(
+      '# Project-specific routing guidance',
+    );
+    expect(orchestrator?.config.prompt).toContain(
+      'Please routing to @oracle when architecture is queried.',
+    );
+  });
+
+  // Test Case 9: Disabled built-in agent\'s orchestratorPrompt is not injected
+  test("9. Disabled built-in agent's orchestratorPrompt is not injected", () => {
+    const config = {
+      disabled_agents: ['oracle'],
+      agents: {
+        oracle: {
+          model: 'openai/gpt-4o',
+          orchestratorPrompt:
+            'Please routing to @oracle when architecture is queried.',
+        },
+      },
+    };
+
+    const agents = createAgents(config);
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
+    expect(orchestrator?.config.prompt).not.toContain(
+      'Please routing to @oracle',
+    );
+  });
+
+  // Test Case 10: agents.orchestrator.orchestratorPrompt is rejected or explicitly ignored
+  test('10. agents.orchestrator.orchestratorPrompt is rejected', () => {
+    const invalidConfig = {
+      agents: {
+        orchestrator: {
+          model: 'openai/gpt-4o',
+          orchestratorPrompt: 'Self guidance',
+        },
+      },
+    };
+
+    const parsed = PluginConfigSchema.safeParse(invalidConfig);
+    expect(parsed.success).toBe(false);
+  });
+
+  // Test Case 11: presets deep-merge preserves unrelated user presets
+  test('11. presets deep-merge preserves unrelated user presets', () => {
+    const userConfig = {
+      presets: {
+        presetA: {
+          oracle: { model: 'model-a' },
+        },
+      },
+    };
+
+    const projectConfig = {
+      presets: {
+        presetB: {
+          explorer: { model: 'model-b' },
+        },
+      },
+    };
+
+    const merged = mergePluginConfigs(userConfig, projectConfig);
+    expect(merged.presets?.presetA).toBeDefined();
+    expect(merged.presets?.presetB).toBeDefined();
+    expect(merged.presets?.presetA.oracle?.model).toBe('model-a');
+    expect(merged.presets?.presetB.explorer?.model).toBe('model-b');
+  });
+
+  // Test Case 12: Same-name preset deep-merges by agent and nested options
+  test('12. Same-name preset deep-merges by agent and nested options', () => {
+    const userConfig = {
+      presets: {
+        myPreset: {
+          oracle: {
+            model: 'model-a',
+            options: { tokenLimit: 1000, debug: true },
+          },
+        },
+      },
+    };
+
+    const projectConfig = {
+      presets: {
+        myPreset: {
+          oracle: {
+            temperature: 0.7,
+            options: { debug: false, maxSearch: 5 },
+          },
+        },
+      },
+    };
+
+    const merged = mergePluginConfigs(userConfig, projectConfig);
+    const oraclePreset = merged.presets?.myPreset?.oracle;
+    expect(oraclePreset?.model).toBe('model-a');
+    expect(oraclePreset?.temperature).toBe(0.7);
+    expect(oraclePreset?.options).toEqual({
+      tokenLimit: 1000,
+      debug: false,
+      maxSearch: 5,
+    });
+  });
+
+  // Test Case 13: Project config still overrides user config for root agents
+  test('13. Project config still overrides user config for root agents', () => {
+    const userConfig = {
+      agents: {
+        oracle: {
+          model: 'user-model',
+          temperature: 0.1,
+        },
+      },
+    };
+
+    const projectConfig = {
+      agents: {
+        oracle: {
+          model: 'project-model',
+        },
+      },
+    };
+
+    const merged = mergePluginConfigs(userConfig, projectConfig);
+    expect(merged.agents?.oracle?.model).toBe('project-model');
+    expect(merged.agents?.oracle?.temperature).toBe(0.1);
+  });
+
+  // Test Case 14: Schema accepts built-in prompt/orchestratorPrompt in root and presets, but rejects empty strings
+  test('14. Schema accepts built-in prompt/orchestratorPrompt in root and presets, but rejects empty strings', () => {
+    // Non-empty is allowed
+    const valid = PluginConfigSchema.safeParse({
+      agents: {
+        oracle: {
+          prompt: 'non-empty prompt',
+          orchestratorPrompt: 'non-empty guidance',
+        },
+      },
+    });
+    expect(valid.success).toBe(true);
+
+    // Empty prompt is rejected
+    const invalidPrompt = PluginConfigSchema.safeParse({
+      agents: {
+        oracle: {
+          prompt: '',
+        },
+      },
+    });
+    expect(invalidPrompt.success).toBe(false);
+
+    // Empty orchestratorPrompt is rejected
+    const invalidOrchestrator = PluginConfigSchema.safeParse({
+      agents: {
+        oracle: {
+          orchestratorPrompt: '',
+        },
+      },
+    });
+    expect(invalidOrchestrator.success).toBe(false);
+  });
+
+  // Test Case 15: Docs examples match actual precedence
+  test('15. Precedence chain: project preset -> project root -> user preset -> user root', () => {
+    const userDir = path.join(tempDir, 'opencode');
+    const userPromptDir = path.join(userDir, 'oh-my-opencode-slim');
+    const userPresetDir = path.join(userPromptDir, 'my-preset');
+
+    const projectDir = path.join(tempDir, 'project');
+    const projectPromptDir = path.join(
+      projectDir,
+      '.opencode',
+      'oh-my-opencode-slim',
+    );
+    const projectPresetDir = path.join(projectPromptDir, 'my-preset');
+
+    fs.mkdirSync(userPresetDir, { recursive: true });
+    fs.mkdirSync(projectPresetDir, { recursive: true });
+
+    fs.writeFileSync(path.join(userPromptDir, 'oracle.md'), 'user-root');
+    fs.writeFileSync(path.join(userPresetDir, 'oracle.md'), 'user-preset');
+    fs.writeFileSync(path.join(projectPromptDir, 'oracle.md'), 'project-root');
+    fs.writeFileSync(
+      path.join(projectPresetDir, 'oracle.md'),
+      'project-preset',
+    );
+
+    // 1. All exist -> project preset won
+    let result = loadAgentPrompt('oracle', {
+      preset: 'my-preset',
+      projectDirectory: projectDir,
+    });
+    expect(result.prompt).toBe('project-preset');
+
+    // 2. Remove project preset -> project root won
+    fs.unlinkSync(path.join(projectPresetDir, 'oracle.md'));
+    result = loadAgentPrompt('oracle', {
+      preset: 'my-preset',
+      projectDirectory: projectDir,
+    });
+    expect(result.prompt).toBe('project-root');
+
+    // 3. Remove project root -> user preset won
+    fs.unlinkSync(path.join(projectPromptDir, 'oracle.md'));
+    result = loadAgentPrompt('oracle', {
+      preset: 'my-preset',
+      projectDirectory: projectDir,
+    });
+    expect(result.prompt).toBe('user-preset');
+
+    // 4. Remove user preset -> user root won
+    fs.unlinkSync(path.join(userPresetDir, 'oracle.md'));
+    result = loadAgentPrompt('oracle', {
+      preset: 'my-preset',
+      projectDirectory: projectDir,
+    });
+    expect(result.prompt).toBe('user-root');
+  });
+});

+ 13 - 69
src/config/schema.ts

@@ -1,5 +1,4 @@
 import { z } from 'zod';
-import { AGENT_ALIASES, ALL_AGENT_NAMES } from './constants';
 import { CouncilConfigSchema } from './council-schema';
 
 const MANUAL_AGENT_NAMES = [
@@ -55,46 +54,6 @@ export type ManualAgentName = (typeof MANUAL_AGENT_NAMES)[number];
 export type ManualAgentPlan = z.infer<typeof ManualAgentPlanSchema>;
 export type ManualPlan = z.infer<typeof ManualPlanSchema>;
 
-// Permission schemas — mirror the SDK's PermissionConfig type with shallow
-// validation. Action values are validated; unknown tool keys pass through.
-const PermissionActionSchema = z.enum(['ask', 'allow', 'deny']);
-
-// A rule key accepts either a single action (whole-tool default) or a
-// pattern→action map (e.g. bash: { "git status*": "allow", "*": "ask" })
-const PermissionRuleSchema = z.union([
-  PermissionActionSchema,
-  z.record(z.string(), PermissionActionSchema),
-]);
-
-// Known keys are typed for typo protection; .catchall() types the index
-// signature to match the SDK's PermissionConfig, so no cast is needed at
-// the assignment site. Unknown tool keys are still validated as rules.
-const PermissionObjectSchema = z
-  .object({
-    read: PermissionRuleSchema.optional(),
-    edit: PermissionRuleSchema.optional(),
-    glob: PermissionRuleSchema.optional(),
-    grep: PermissionRuleSchema.optional(),
-    list: PermissionRuleSchema.optional(),
-    bash: PermissionRuleSchema.optional(),
-    task: PermissionRuleSchema.optional(),
-    external_directory: PermissionRuleSchema.optional(),
-    lsp: PermissionRuleSchema.optional(),
-    skill: PermissionRuleSchema.optional(),
-    todowrite: PermissionActionSchema.optional(),
-    question: PermissionActionSchema.optional(),
-    webfetch: PermissionActionSchema.optional(),
-    websearch: PermissionActionSchema.optional(),
-    codesearch: PermissionActionSchema.optional(),
-    doom_loop: PermissionActionSchema.optional(),
-  })
-  .catchall(PermissionRuleSchema);
-
-export const PermissionConfigSchema = z.union([
-  PermissionActionSchema,
-  PermissionObjectSchema,
-]);
-
 // Agent override configuration (distinct from SDK's AgentConfig)
 export const AgentOverrideConfigSchema = z
   .object({
@@ -122,7 +81,6 @@ export const AgentOverrideConfigSchema = z
     orchestratorPrompt: z.string().min(1).optional(),
     options: z.record(z.string(), z.unknown()).optional(), // provider-specific model options (e.g., textVerbosity, thinking budget)
     displayName: z.string().min(1).optional(),
-    permission: PermissionConfigSchema.optional(), // tool-level permission rules enforced by the SDK
   })
   .strict();
 
@@ -298,33 +256,18 @@ export type AcpAgentPermissionMode = z.infer<
 export type AcpAgentConfig = z.infer<typeof AcpAgentConfigSchema>;
 export type AcpAgentsConfig = z.infer<typeof AcpAgentsConfigSchema>;
 
-function validateCustomOnlyPromptFields(
+function rejectOrchestratorPromptOnOrchestrator(
   overrides: Record<string, z.infer<typeof AgentOverrideConfigSchema>>,
   ctx: z.RefinementCtx,
   pathPrefix: Array<string | number>,
 ): void {
   for (const [name, override] of Object.entries(overrides)) {
-    const isBuiltInOrAlias =
-      (ALL_AGENT_NAMES as readonly string[]).includes(name) ||
-      AGENT_ALIASES[name] !== undefined;
-
-    if (!isBuiltInOrAlias) {
-      continue;
-    }
-
-    if (override.prompt !== undefined) {
-      ctx.addIssue({
-        code: z.ZodIssueCode.custom,
-        path: [...pathPrefix, name, 'prompt'],
-        message: 'prompt is only supported for custom agents',
-      });
-    }
-
-    if (override.orchestratorPrompt !== undefined) {
+    if (name === 'orchestrator' && override.orchestratorPrompt !== undefined) {
       ctx.addIssue({
         code: z.ZodIssueCode.custom,
         path: [...pathPrefix, name, 'orchestratorPrompt'],
-        message: 'orchestratorPrompt is only supported for custom agents',
+        message:
+          'orchestratorPrompt is not supported for the orchestrator agent',
       });
     }
   }
@@ -334,17 +277,15 @@ export const PluginConfigSchema = z
   .object({
     preset: z.string().optional(),
     setDefaultAgent: z.boolean().optional(),
-    autoUpdate: z
+    compactSidebar: z
       .boolean()
       .optional()
-      .describe(
-        'Disable automatic installation of plugin updates when false. Defaults to true.',
-      ),
-    compactSidebar: z
+      .describe('Use the compact TUI sidebar layout when enabled.'),
+    autoUpdate: z
       .boolean()
       .optional()
       .describe(
-        'Render each agent on a single line in the TUI sidebar instead of the default multi-line layout. Default false.',
+        'Disable automatic installation of plugin updates when false. Defaults to true.',
       ),
     presets: z.record(z.string(), PresetSchema).optional(),
     agents: z.record(z.string(), AgentOverrideConfigSchema).optional(),
@@ -385,12 +326,15 @@ export const PluginConfigSchema = z
   })
   .superRefine((value, ctx) => {
     if (value.agents) {
-      validateCustomOnlyPromptFields(value.agents, ctx, ['agents']);
+      rejectOrchestratorPromptOnOrchestrator(value.agents, ctx, ['agents']);
     }
 
     if (value.presets) {
       for (const [presetName, preset] of Object.entries(value.presets)) {
-        validateCustomOnlyPromptFields(preset, ctx, ['presets', presetName]);
+        rejectOrchestratorPromptOnOrchestrator(preset, ctx, [
+          'presets',
+          presetName,
+        ]);
       }
     }
   });

+ 14 - 15
src/index.ts

@@ -32,7 +32,7 @@ import {
   ForegroundFallbackManager,
 } from './hooks';
 import { processImageAttachments } from './hooks/image-hook';
-import { isMessageWithParts, isUserMessageWithParts } from './hooks/types';
+import type { MessageWithParts } from './hooks/types';
 import { createInterviewManager } from './interview';
 import { createBuiltinMcps } from './mcp';
 import {
@@ -188,8 +188,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
     disabledAgents = getDisabledAgents(config);
     rewriteDisplayNameMentions = createDisplayNameMentionRewriter(config);
-    agentDefs = createAgents(config);
-    agents = getAgentConfigs(config);
+    agentDefs = createAgents(config, { projectDirectory: ctx.directory });
+    agents = getAgentConfigs(config, { projectDirectory: ctx.directory });
 
     // Build model array map and runtime fallback chains from _modelArray
     // entries (when the user configures model as an array in
@@ -1031,14 +1031,12 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     // API (doesn't show in UI)
     'experimental.chat.messages.transform': async (
       input: Record<string, never>,
-      output: { messages?: unknown },
+      output: { messages: unknown[] },
     ): Promise<void> => {
-      const messages = (
-        Array.isArray(output.messages) ? output.messages : []
-      ).filter(isMessageWithParts);
+      const typedOutput = output as { messages: MessageWithParts[] };
 
-      for (const message of messages) {
-        if (!isUserMessageWithParts(message)) {
+      for (const message of typedOutput.messages) {
+        if (message.info.role !== 'user') {
           continue;
         }
         for (const part of message.parts) {
@@ -1055,7 +1053,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       // image bytes with a text nudge so the orchestrator delegates to
       // @observer instead.
       processImageAttachments({
-        messages,
+        messages: typedOutput.messages,
         workDir: ctx.directory,
         disabledAgents,
         log,
@@ -1063,14 +1061,15 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
       await taskSessionManagerHook['experimental.chat.messages.transform'](
         input,
-        { messages },
+        typedOutput,
+      );
+      await phaseReminderHook['experimental.chat.messages.transform'](
+        input,
+        typedOutput,
       );
-      await phaseReminderHook['experimental.chat.messages.transform'](input, {
-        messages,
-      });
       await filterAvailableSkillsHook['experimental.chat.messages.transform'](
         input,
-        { messages },
+        typedOutput,
       );
     },