diff --git a/.changeset/plugin-extensibility-context.md b/.changeset/plugin-extensibility-context.md new file mode 100644 index 000000000..4813586f7 --- /dev/null +++ b/.changeset/plugin-extensibility-context.md @@ -0,0 +1,12 @@ +--- +"@bradygaster/squad-cli": patch +"@bradygaster/squad-sdk": patch +--- + +feat: add declarative plugin behavior context proof + +Adds static plugin metadata, lifecycle state, runtime context injection, +declarative provider contracts, and behavior simulations for Graphify, +MemPalace, and Index Server plugin samples. Enabled plugins can now contribute +installed static guidance and typed memory/knowledge provider summaries to +spawned-agent prompts while preserving the no-execution security boundary. diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index 110291ac6..05a6dce47 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -3,14 +3,14 @@ name: Squad description: "Your AI team. Describe what you're building, get a team of specialists that live in your repo." --- - + You are **Squad (Coordinator)** — the orchestrator for this project's AI team. ### Coordinator Identity - **Name:** Squad (Coordinator) -- **Version:** 0.9.1 (see HTML comment above — this value is stamped during install/upgrade). Include it as `Squad v0.9.1` in your first response of each session (e.g., in the acknowledgment or greeting). +- **Version:** 0.0.0-source (see HTML comment above — this value is stamped during install/upgrade). Include it as `Squad v{version}` in your first response of each session (e.g., in the acknowledgment or greeting). - **Role:** Agent orchestration, handoff enforcement, reviewer gating - **Inputs:** User request, repository state, `.squad/decisions.md` - **Outputs owned:** Final assembled artifacts, orchestration log (via Scribe) diff --git a/cspell.json b/cspell.json index 705a91be3..1030c0872 100644 --- a/cspell.json +++ b/cspell.json @@ -1,44 +1,46 @@ -{ - "version": "0.2", - "language": "en", - "files": ["docs/src/content/**/*.md", "README.md"], - "ignorePaths": ["node_modules", "docs/dist", "*.json", "*.ts", "*.js", ".squad"], - "words": [ - "squad", "bradygaster", "astro", "pagefind", "starlight", - "copilot", "subagent", "subagents", "MCP", "REPL", "CLI", "DevRel", - "monorepo", "codespace", "codespaces", "worktree", "worktrees", "frontmatter", - "ralph", "scribe", "pao", "fido", "eecom", "capcom", - "retro", "inco", "guido", "booster", "gnc", "dsky", "vox", - "sims", "telemetry", "handbook", - "WIQL", "webhook", "webhooks", "dedup", "deduplicate", - "triage", "triaging", "untriaged", "kanban", "lifecycle", - "PRD", "SDK", "ADO", "CUDA", "RBAC", "RAAS", "OAuth", "JWT", - "Aspire", "Bicep", "Terraform", "Kubernetes", - "orchestrator", "orchestration", "runSubagent", - "Kujan", "kobayashi", "Kobayashi", "squadify", - "amolchanov", "Shayne", "spboyer", "mcmanus", "csharpfritz", - "anthropics", "dnoriegagoodwin", "unconfigured", "upgraders", - "Tamir", "Dresher", "Tetris", "tamirdresher", "tetris", - "Riker", "riker", "Troi", "Geordi", "Worf", - "recompiles", "edie", "Edie", "myorg", "myproject", "myrepo", - "WDATP", "wiisaacs", "Trejo", "Drucker", "dfberry", "diberry", - "unstarted", "ZUAER", "glab", "mlops", "Baer", - "devtunnel", "Devtunnel", "mysecret", "pyproject", - "Emmitt", "jsturtevant", "Sturtevant", "aadnesd", "Sardo", - "digitaldrummerj", "danielscholl", "llms", "dkirby", "lbouriez", - "vanartsdalen", "Artsdalen", "craigb", "tomasherceg", "Tomáš", "Herceg", - "uvirk", "Uday", "Eirik", "Haughom", "tihomir", "Tihomir", - "fboucher", "Pruthviraj", "cobey", "wbreza", "Breza", - "jbruce", "frankhaugen", "Haugen", "chrislomonico", - "johnwc", "swnger", "sturlath", "Fredo", - "Pomodoro", "pomodoro", "WASD", "Blazor", "narratively", - "MSMQ", "permadeath", "Permadeath", "CRDT", "httpx", - "Pydantic", "sdkgen", "ttft", "Strausz", "mycompany", - "slugified", "simplejwt", "pytest", "Luca", "Clemenza", - "Tessio", "Triaging", "Futurama", "mklink", "mktree", "slnx", "jqlang", - "benleane", "TELMU", "Automator", "kedacore", - "DEVBOX", "myaccount" - ], - "dictionaries": ["en_US", "typescript", "node", "npm", "bash"], - "allowCompoundWords": true -} +{ + "version": "0.2", + "language": "en", + "files": ["docs/src/content/**/*.md", "README.md"], + "ignorePaths": ["node_modules", "docs/dist", "*.json", "*.ts", "*.js", ".squad"], + "words": [ + "squad", "bradygaster", "astro", "pagefind", "starlight", + "copilot", "subagent", "subagents", "MCP", "REPL", "CLI", "DevRel", + "monorepo", "codespace", "codespaces", "worktree", "worktrees", "frontmatter", + "ralph", "scribe", "pao", "fido", "eecom", "capcom", + "retro", "inco", "guido", "booster", "gnc", "dsky", "vox", + "sims", "telemetry", "handbook", + "WIQL", "webhook", "webhooks", "dedup", "deduplicate", + "triage", "triaging", "untriaged", "kanban", "lifecycle", + "PRD", "SDK", "ADO", "CUDA", "RBAC", "RAAS", "OAuth", "JWT", + "Aspire", "Bicep", "Terraform", "Kubernetes", + "orchestrator", "orchestration", "runSubagent", + "Kujan", "kobayashi", "Kobayashi", "squadify", + "amolchanov", "Shayne", "spboyer", "mcmanus", "csharpfritz", + "anthropics", "dnoriegagoodwin", "unconfigured", "upgraders", + "Tamir", "Dresher", "Tetris", "tamirdresher", "tetris", + "Riker", "riker", "Troi", "Geordi", "Worf", + "recompiles", "edie", "Edie", "myorg", "myproject", "myrepo", + "WDATP", "wiisaacs", "Trejo", "Drucker", "dfberry", "diberry", + "unstarted", "ZUAER", "glab", "mlops", "Baer", + "devtunnel", "Devtunnel", "mysecret", "pyproject", "pypi", + "Emmitt", "jsturtevant", "Sturtevant", "aadnesd", "Sardo", + "digitaldrummerj", "danielscholl", "llms", "dkirby", "lbouriez", + "vanartsdalen", "Artsdalen", "craigb", "tomasherceg", "Tomáš", "Herceg", + "uvirk", "Uday", "Eirik", "Haughom", "tihomir", "Tihomir", + "fboucher", "Pruthviraj", "cobey", "wbreza", "Breza", + "jbruce", "frankhaugen", "Haugen", "chrislomonico", + "johnwc", "swnger", "sturlath", "Fredo", + "Pomodoro", "pomodoro", "WASD", "Blazor", "narratively", + "MSMQ", "permadeath", "Permadeath", "CRDT", "httpx", + "Pydantic", "sdkgen", "ttft", "Strausz", "mycompany", + "slugified", "simplejwt", "pytest", "Luca", "Clemenza", + "Tessio", "Triaging", "Futurama", "mklink", "mktree", "slnx", "jqlang", + "benleane", "TELMU", "Automator", "kedacore", + "DEVBOX", "myaccount", "graphify", "Graphify", "graphifyy", "safishamsi", "jagilber", + "benchmarkfn", "filterissuesbystream", "filterissuesbyworkstream", "Recognised", "recognised", + "squadified", "TOCTOU", "unflushed", "unparseable", "pluggability" + ], + "dictionaries": ["en_US", "typescript", "node", "npm", "bash"], + "allowCompoundWords": true +} diff --git a/docs/src/content/docs/features/plugins.md b/docs/src/content/docs/features/plugins.md index 2160b1195..13c6bbd28 100644 --- a/docs/src/content/docs/features/plugins.md +++ b/docs/src/content/docs/features/plugins.md @@ -2,429 +2,230 @@ > ⚠️ **Experimental** — Squad is alpha software. APIs, commands, and behavior may change between releases. +Plugins package reusable Squad capabilities: agents, knowledge packs, workflows, ceremonies, memory providers, routing guidance, decisions, hook metadata, adapter metadata, typed provider contracts, and generated knowledge artifacts. -**Try this to add instant expertise:** -``` -Install the AWS deployment plugin -``` +The everyday flow is simple: -**Try this to discover community bundles:** -``` -Show me available plugins for React development +```bash +squad plugin install ./my-plugin +squad plugin enable my-plugin +squad plugin refresh my-plugin ``` -Plugins are community-curated bundles of agent templates, skills, and best practices. Install plugins to give your squad instant expertise in React, Azure, security, testing, and more. - ---- +After that, spawned agents can receive the plugin's enabled context. For a knowledge plugin such as Graphify, refresh can generate approved artifacts under `.squad/knowledge/graphify/` so agents have a relationship map across code, docs, and decisions. -## What Are Plugins? +The MVP is declarative-first with a narrow governed runtime for Squad-owned built-in providers. Squad records hook, adapter, and provider metadata, and approved providers can generate static artifacts. It does not execute plugin-supplied code. -Your Squad can discover and install **plugins** — curated collections of agent templates, skills, and instructions from community repositories. Plugins solve recurring problems: Azure cloud development, React patterns, security hardening, testing strategies, and more. +Squad plugins do not replace Copilot plugins or Copilot skills. If a Squad plugin depends on Copilot-owned extensibility, declare that under `copilot.requires`; Squad records and surfaces the dependency, but it does not install it or run Copilot plugin commands. --- -## What Are Plugins? +## Plugin lifecycle -Plugins are **community-curated bundles** of reusable knowledge. Each plugin contains: +Install, activation, and artifact refresh are separate steps. -- **Agent templates** — specialized role charters for common needs (e.g., "AWS DevOps", "Python Data Science") -- **Skills** — reusable `.squad/skills/SKILL.md` files encoding patterns and best practices -- **Instructions** — `decisions.md` snippets for conventions and routing rules -- **Sample prompts** — ready-to-use prompts that activate plugin capabilities +1. `squad plugin validate ` checks the manifest and prints structured validation errors. +2. `squad plugin dry-run ` prints the files that would be written without changing `.squad/`. +3. `squad plugin install ` copies declared static files, records hashes in `.squad/plugins/lock.json`, and leaves the plugin disabled. +4. `squad plugin enable ` activates the plugin roles declared in its manifest. +5. `squad plugin switch ` makes an enabled plugin active for a role such as `memory` or `knowledge`. +6. `squad plugin refresh ` refreshes approved generated artifacts for built-in providers such as Graphify. +7. `squad plugin disable ` deactivates a plugin without deleting installed files. +8. `squad plugin uninstall ` removes files recorded in the lock and clears the registration. -A plugin marketplace is a **repository** that hosts these bundles. Squad ships with community marketplaces pre-configured. You can add your own. +Use `squad plugin list --json` when another tool needs stable machine-readable state. --- -## Available Marketplaces - -| Marketplace | URL | What's Inside | -|-------------|-----|-----------| -| **awesome-copilot** | `github/awesome-copilot` | Frontend frameworks (React, Vue, Svelte), backend stacks (Node, Python, Go), deployment patterns | -| **anthropic-skills** | `anthropics/skills` | Claude-optimized patterns, prompt engineering, token efficiency, RAG patterns | -| **azure-cloud-dev** | `github/azure-cloud-development` | Azure VMs, App Service, Cosmos DB, DevOps with GitHub Actions, infrastructure-as-code | -| **security-hardening** | `github/security-hardening` | OWASP, input validation, secrets management, cryptography, compliance patterns | - ---- - -## Commands - -### List registered marketplaces - -``` -> Show me available marketplaces -``` - -Squad displays all configured marketplaces with descriptions. - -``` -/plugin marketplace list -``` - -Same thing, command-line style. - -### Add a marketplace - -``` -> Add the awesome-copilot marketplace -``` - -Or use the command: - -``` -/plugin marketplace add github/awesome-copilot -``` - -Squad connects to the repository, indexes its plugins, and makes them available for browsing. - -### Remove a marketplace - -``` -> Remove the awesome-copilot marketplace -``` - -Or: +## Local MVP commands -``` -/plugin marketplace remove awesome-copilot -``` - -Removes the marketplace from your config. Installed plugins remain; new plugins can't be added from it. - -### Browse plugins in a marketplace - -``` -> Browse the awesome-copilot marketplace -``` - -Squad displays all available plugins with one-line descriptions. - -``` -/plugin marketplace browse awesome-copilot -``` - -Same command-line version. - -### Search for a plugin +```bash +squad plugin validate ./my-plugin +squad plugin dry-run ./my-plugin +squad plugin install ./my-plugin +squad plugin list +squad plugin list --json +squad plugin enable my-plugin +squad plugin switch memory my-plugin +squad plugin refresh my-plugin +squad plugin run-lifecycle my-plugin onMemoryRefresh +squad plugin disable my-plugin +squad plugin verify +squad plugin uninstall my-plugin +``` + +The current MVP supports local plugin directories. Marketplace registration still uses the existing commands: +```bash +squad plugin marketplace add github/awesome-copilot +squad plugin marketplace list +squad plugin marketplace browse awesome-copilot +squad plugin marketplace remove awesome-copilot ``` -> Find a plugin for React testing -``` - -Squad searches all configured marketplaces for matching plugins and displays results with usage details. --- -## Step-by-Step: Add the Awesome-Copilot Marketplace - -### Step 1: Add the marketplace - -In your Squad session, say: - -``` -> Add the awesome-copilot marketplace -``` - -Squad connects to the repo and indexes all available plugins. You'll see a confirmation: - -``` -✅ awesome-copilot marketplace added -📦 38 plugins indexed -``` - -### Step 2: Browse available plugins - -``` -> Browse awesome-copilot -``` - -Squad shows a table of plugins: - -``` -Plugin Name | Use Case | Install Command --------------------------|----------|---------------- -react-component-library | Frontend | /plugin install awesome-copilot/react-component-library -nextjs-fullstack | Frontend | /plugin install awesome-copilot/nextjs-fullstack -fastapi-rest-api | Backend | /plugin install awesome-copilot/fastapi-rest-api -postgresql-migrations | Database | /plugin install awesome-copilot/postgresql-migrations -[38 total plugins] -``` - -### Step 3: Install a plugin - -Pick one that matches your needs. For a React project: - -``` -> Install the react-component-library plugin -``` - -Or use the exact command: - -``` -/plugin install awesome-copilot/react-component-library -``` - -Squad: -1. Downloads the plugin bundle -2. Merges agent templates into your `.squad/agents/` folder -3. Adds skills to `.squad/skills/` -4. Updates `decisions.md` with plugin conventions -5. Seeds all relevant agents with plugin knowledge - -You'll see: - -``` -✅ react-component-library plugin installed - ✓ Added React Component Design skill - ✓ Added React Patterns decision (linting, naming, testing) - ✓ Seeded 2 agents with React component expertise -``` - -**Your agents immediately know the plugin conventions** and use them in their next work session. +## Manifest format + +The MVP manifest file is `plugin.manifest.json`. The validator also accepts legacy local names such as `squad-plugin.json` and `plugin.json` while the schema settles. + +```json +{ + "id": "demo-plugin", + "name": "Demo Plugin", + "version": "1.0.0", + "description": "A declarative test plugin.", + "authors": ["Squad"], + "license": "MIT", + "squad": ">=0.9.1", + "components": { + "knowledge": ["demo-plugin"], + "memory": { "provider": "demo-memory" } + }, + "copilot": { + "requires": [ + { + "id": "github/copilot-plugin-example", + "version": ">=1.0.0", + "optional": true, + "reason": "Enables Copilot-owned commands when the user has installed it." + } + ] + }, + "repository": { + "type": "github", + "url": "https://github.com/example/demo-plugin" + }, + "upstream": { + "package": "demo-tool", + "registry": "pypi", + "installCommand": "pip install demo-tool", + "docs": "https://github.com/example/demo-plugin" + }, + "mcp": { + "available": true, + "server": "demo-tool", + "entryPoint": "demo-tool-mcp", + "installCommand": "demo-tool-mcp", + "reason": "Optional external MCP server users may configure separately." + }, + "providers": [ + { + "id": "demo-memory", + "type": "memory", + "mode": "read-write", + "protocol": "mcp", + "description": "Declarative memory provider contract.", + "artifact": "memory/providers/demo-memory.md", + "mcp": { + "server": "demo-tool", + "tool": "query-memory", + "capability": "durable-memory" + }, + "capabilities": ["durable-memory", "context-recall"] + } + ], + "files": [ + { + "source": "guidance.md", + "target": "knowledge/demo-plugin/guidance.md", + "type": "knowledge" + } + ] +} +``` + +Supported Squad component keys are `agents`, `ceremonies`, `decisions`, `instructions`, `knowledge`, `memory`, `routing`, `templates`, `workflows`, `hooks`, and `adapters`. Capability roles are derived only from these declared components; arbitrary capability strings are not accepted. + +Declared files must be relative paths under approved `.squad/` roots such as `agents/`, `knowledge/`, `memory/`, `routing/`, `decisions/`, `ceremonies/`, `prompts/`, `instructions/`, `templates/`, `workflows/`, or `plugins/`. + +Copilot plugin dependencies are metadata only: + +```json +{ + "copilot": { + "requires": [ + { + "id": "github/copilot-plugin-example", + "version": ">=1.0.0", + "optional": false, + "reason": "Provides the Copilot-side command this Squad workflow references." + } + ] + } +} +``` + +Squad validates and records these dependencies so users know what Copilot plugins to install separately. Squad does not fetch, install, execute, or manage Copilot plugins. + +External integration metadata is also record-only. Fields such as `repository`, `upstream.installCommand`, and `mcp.installCommand` explain how a human can install external tools separately; Squad never runs those commands. + +Provider contracts are the typed extension seam for memory and knowledge systems. A contract declares the provider `type` (`memory`, `knowledge`, `persistence`, `event`, or `policy`), access `mode` (`read`, `write`, or `read-write`), `protocol` (`static-artifact` or `mcp`), optional static artifact binding, optional MCP binding metadata, and capability labels. These fields let spawned agents understand that a plugin represents a memory or knowledge provider without letting the plugin run code. During the MVP, provider contracts are prompt metadata only: Squad does not start MCP servers, call provider tools, query live memory backends, or install provider packages. --- -## Step-by-Step: Onboard a New Team Member with Plugins +## Runtime behavior -You're adding a DevOps person to your Azure-heavy team. Instead of manually explaining your Azure setup, you use plugins. +Enabled plugins affect spawned Squad agents through their installed static artifacts and provider contracts. When an agent session is spawned, Squad reads `.squad/plugins/runtime.json`, finds enabled active plugin roles, and injects the installed guidance/metadata files plus provider contract summaries into the agent system context under a `Plugin Context` section. -### Step 1: Create the new agent +This is still declarative-first behavior: Squad consumes copied Markdown/metadata from `.squad/`, but it does not install upstream packages, start MCP servers, execute plugin-supplied commands, call provider tools, or query external tools during plugin install or agent spawn. -In your Squad session: +The MVP also includes a narrow governed runtime for Squad-owned built-in providers. `squad plugin refresh ` and `squad plugin run-lifecycle ` can generate artifacts only when the provider name, lifecycle event, and output paths are allowlisted by Squad. The current approved provider is Graphify, which can refresh deterministic knowledge artifacts under `.squad/knowledge/graphify/`. -``` -> I need a DevOps person -``` - -Squad spawns a new agent and seeds them with your project context. - -### Step 2: Find Azure plugins - -``` -> Browse marketplaces for Azure plugins -``` - -Squad searches all configured marketplaces and shows: - -``` -Plugin Name | Marketplace | Use Case ---------------------------|-------------|---------- -azure-infrastructure | azure-cloud-dev | VMs, Load Balancers, networking -azure-devops-pipelines | azure-cloud-dev | GitHub Actions + ARM templates -azure-cosmos-setup | azure-cloud-dev | Cosmos DB design and optimization -aks-deployment | azure-cloud-dev | Kubernetes on Azure -``` - -### Step 3: Install the relevant plugin for the DevOps agent - -``` -> Install the azure-infrastructure plugin for the DevOps agent -``` - -Or: - -``` -/plugin install azure-cloud-dev/azure-infrastructure --agent DevOps -``` - -Squad: -1. Downloads the Azure infrastructure plugin -2. Adds its skills and conventions to the DevOps agent's history -3. Adds infrastructure decisions to the shared `decisions.md` -4. Seals the DevOps agent with Azure expertise on day 1 - -Your new DevOps person immediately understands: -- Your Azure resource naming convention -- How you structure ARM templates -- Your monitoring and alerting patterns -- Related decision history (why you chose certain regions, SKUs, etc.) - -### Step 4: Verify - -``` -> DevOps agent, what's your understanding of our infrastructure? -``` - -The DevOps agent summarizes Azure setup from the plugin without asking questions. +Disabled plugins do not contribute prompt context, even if their files remain installed on disk. --- -## How Squad Uses Plugins During Team Member Creation - -When you add a new agent, Squad can **auto-recommend plugins**: - -``` -> I'm adding someone for machine learning — they'll work with PyTorch and TensorFlow -``` - -Squad checks configured marketplaces for plugins matching "machine learning", "PyTorch", and "TensorFlow": - -``` -🔍 Searching marketplaces... - -Found 4 plugins: - ✓ pytorch-training-pipeline (awesome-copilot) - ✓ tensorflow-mlops (awesome-copilot) - ✓ ml-data-validation (anthropic-skills) - ✓ gpu-monitoring (azure-cloud-dev) - -Install these plugins for the ML engineer? -``` - -You say yes, and the agent arrives with full expertise. - ---- - -## Advanced: Creating Your Own Plugin Marketplace - -A plugin marketplace is just **a GitHub repository** with a specific structure: - -``` -my-team-plugins/ -├── awesome-patterns/ -│ ├── charter.md # Agent template -│ ├── skills/ -│ │ └── awesome-skill.md -│ └── decisions.md # Conventions -├── microservices-template/ -│ ├── charter.md -│ └── skills/ -│ ├── service-discovery.md -│ └── fault-tolerance.md -└── README.md # Plugin descriptions -``` - -### Step 1: Create the repository - -```bash -mkdir my-team-plugins -cd my-team-plugins -git init -``` - -### Step 2: Add plugins - -Create subdirectories for each plugin. Each should contain: -- `charter.md` — a sample agent role -- `skills/` folder with `.SKILL.md` files -- `decisions.md` — conventions the plugin enforces +## External integration examples -### Step 3: Add to Squad +The repository includes local sample plugins that exercise external integration metadata without adding executable provider code: -``` -> Add my-team-plugins marketplace -``` +| Example | Purpose | +| --- | --- | +| `samples/plugin-knowledge-graphify` | Knowledge graph profile for the real `safishamsi/graphify` project and PyPI package `graphifyy`. It declares a `knowledge` provider contract bound to a static artifact under `.squad/knowledge/graphify/`, and can refresh governed Graphify artifacts with `squad plugin refresh graphify-knowledge`. | +| `samples/plugin-knowledge-index-server` | Instruction and knowledge MCP profile for the real `jagilber-org/index-server` project and npm package `@jagilber-org/index-server`. It declares a metadata-only `knowledge` provider contract for the Index Server MCP catalog. | +| `samples/plugin-memory-mempalace` | Memory-palace-style provider profile for the real `MemPalace/mempalace` CLI and optional `mempalace-mcp` server. It declares a metadata-only `memory` provider contract for spatial memory. | -Or in config: - -``` -/plugin marketplace add github/my-org/my-team-plugins -``` - -Squad indexes all plugins and makes them available for install. +Graphify's Copilot support is a separately installed skill/integration, not a Squad memory provider or a Squad-managed Copilot plugin. Index Server is an MCP-governed instruction/knowledge catalog, not a memory provider, although it is adjacent because agents can persist validated knowledge across sessions and repositories. MemPalace is a real memory system, but Squad still does not install its package, start MCP, or configure assistant hooks. --- -## Sample Prompts - -### Browse and install in one go - -``` -Find a React plugin in awesome-copilot and install it for our Frontend agent. -``` - -### Create a plugin template - -``` -Package our current React conventions into a plugin called react-best-practices. -``` +## Runtime state -Squad exports your relevant skills, decisions, and agent history into a reusable plugin bundle that you can share. +Squad stores plugin state under `.squad/plugins/`: -### Search across marketplaces - -``` -Show me all database migration plugins across all marketplaces. -``` - -### Recommend plugins based on my team - -``` -What plugins from awesome-copilot would help my Frontend + Backend + Tester team? -``` - -### Update plugins - -``` -Check if any installed plugins have newer versions. -``` - -Squad compares your installed plugins against marketplace versions and suggests updates. +| File | Purpose | +| --- | --- | +| `installed.json` | Installed plugins, versions, enabled state, roles, source path, and deployed files. | +| `lock.json` | Manifest hash and per-file SHA-256 hashes for reproducibility and verification. | +| `runtime.json` | Active plugin bindings by role plus enabled runtime state. | +| `audit.jsonl` | JSON Lines lifecycle audit events for install, verify, enable, switch, disable, and uninstall. | --- -## Troubleshooting - -### "Marketplace not found" - -**Symptom:** `Error: Could not connect to marketplace github/awesome-copilot` - -**Fix:** - -1. Verify the marketplace is public on GitHub -2. Check your GitHub token has read access: - ```bash - gh auth status - ``` -3. Verify the repository name is spelled correctly - -### "Plugin conflicts with existing agent" - -**Symptom:** Installing a plugin tries to overwrite an existing agent. - -**Fix:** - -Squad won't overwrite by default. Instead, it will: -1. Ask which agent to merge the plugin into -2. Or suggest creating a new agent (e.g., "ReactFrontend" if "Frontend" already exists) - -You control the merge. - -### "Installed plugin isn't being used" - -**Symptom:** Agents don't know the plugin conventions. - -**Fix:** - -1. Verify the plugin was installed: - ``` - > Show installed plugins - ``` - -2. Restart your Squad session so agents reload their history - -3. Check that `decisions.md` was updated: - ```bash - cat .squad/decisions.md | grep -i plugin-name - ``` +## Guardrails -### "Marketplace is too slow" +The product goal is simple pluggability: install, enable, refresh, and give agents better context. The guardrails keep that model predictable: -**Symptom:** Browsing or installing plugins takes 30+ seconds. +- No plugin scripts, commands, shell snippets, or executable files are allowed. +- Lifecycle hooks are limited to Squad-owned, capability-gated providers that generate static artifacts. +- No plugin content is evaluated or run by Squad. +- No Copilot plugin commands are run by Squad; `copilot.requires` is dependency metadata only. +- No external package install hints or MCP setup hints are run by Squad; `repository`, `upstream`, and `mcp` fields are metadata only. +- Hook and adapter declarations are metadata only. +- Provider contracts are metadata only; they do not authorize live provider calls. +- Plugin file writes are limited to declared relative targets under `.squad/`. +- Path traversal, absolute paths, symlinks, and script/executable extensions are rejected. +- Governed runtime artifacts are generated only by built-in approved providers and only under approved `.squad/` paths. -**Fix:** +See [Plugin security model](../reference/plugin-security.md) for the threat model and the negative checks that gate this feature. -1. The marketplace repository is large. This is normal for the first browse. -2. Squad caches marketplace index locally — subsequent browses are instant. -3. To refresh the cache: - ``` - > Refresh marketplace cache - ``` +The follow-up roadmap tracks remote marketplace distribution, broader built-in providers, and the trusted executable-provider RFC in [#1102](https://github.com/bradygaster/squad/issues/1102), [#1103](https://github.com/bradygaster/squad/issues/1103), [#1104](https://github.com/bradygaster/squad/issues/1104), and [#1105](https://github.com/bradygaster/squad/issues/1105). --- -## See Also +## See also -- [Skills System](./skills.md) — how plugins encode reusable knowledge -- [Your Team](../concepts/your-team.md) — team member management and onboarding -- [Memory & Knowledge](../concepts/memory-and-knowledge.md) — decisions and shared conventions +- [Building extensions](../guide/building-extensions.md) — how to author a local plugin. +- [Extensibility guide](../guide/extensibility.md) — how to decide whether an idea belongs in core, a plugin, or team config. +- [Skills System](./skills.md) — the existing Squad knowledge layer; plugin manifests should prefer `knowledge` for reusable guidance and use `copilot.requires` for Copilot-owned plugins. diff --git a/docs/src/content/docs/guide/building-extensions.md b/docs/src/content/docs/guide/building-extensions.md index 89db805c1..f3466d4ce 100644 --- a/docs/src/content/docs/guide/building-extensions.md +++ b/docs/src/content/docs/guide/building-extensions.md @@ -1,126 +1,199 @@ -# Building extensions - -> ⚠️ **Experimental** — Squad is alpha software. APIs, commands, and behavior may change between releases. - -You've decided your idea is a Squad Extension (Layer 2). Now build one in five minutes. - ---- - -## What is an extension? - -An extension is a reusable collection of skills, ceremonies, and directives that any team can install. It lives outside Squad core, packaged as a GitHub repository or marketplace plugin. Extensions let you codify workflows (the client-delivery pattern), domain expertise (Azure deployment strategies), or testing ceremonies that other teams benefit from. - ---- - -## Extension structure - -``` -my-extension/ -├── skills/ -│ ├── SKILL1.md -│ └── SKILL2.md -├── ceremonies/ -│ └── CEREMONY.md -├── directives/ -│ └── DIRECTIVE.md -└── README.md -``` - ---- - -## Build one - -**Step 1: Create a repo** - -```bash -mkdir my-extension -cd my-extension -git init -``` - -**Step 2: Add a skill** - -Create `skills/example-skill.md`: - -```markdown -# Example Skill - -**When to use:** You need to do X. - -## Context - -Brief problem statement. - -## Steps - -1. Do the first thing -2. Do the second thing -3. Done -``` - -**Step 3 (optional): Add a ceremony** - -Create `ceremonies/code-review.md` following Squad ceremony format (decision gate, verdicts, escalation). - -**Step 4: Write the README** - -Explain the problem, installation, and usage: - -```markdown -# My Extension - -Codifies client-delivery workflows for consulting teams. - -## Install - -squad plugin install github/my-org/my-extension - -## What's Inside - -- **discovery-interview** skill — clarify requirements -- **evidence-bundler** skill — collect test results -- **plan-review** ceremony — gate for approval -``` - -**Step 5: Test locally** - -Copy your extension directory into `.squad/skills/`, `.squad/ceremonies/`, and `.squad/directives/`. Load your Squad session and verify the skills appear and work as expected. - ---- - -## Share it - -Push to GitHub: - -```bash -git add . -git commit -m "Initial extension: my-extension" -git push -``` - -Register with a marketplace or pin directly by repository URL: - -``` -squad plugin install github/my-org/my-extension -``` - ---- - -## Real examples - -- **Client-delivery workflow** ([RFC #328](https://github.com/bradygaster/squad/issues/328)) — discovery, research, multi-round review with evidence gates -- **Azure infrastructure patterns** — VM provisioning, Cosmos DB design, monitoring rules -- **Knowledge library skills** — document structured analysis, reference synthesis - ---- - -## Related docs - -- [Extensibility guide](./extensibility.md#decision-tree) — Where does your idea belong? (decision tree) -- [Plugin Marketplace](../features/plugins.md) — How teams discover and install your extension -- [Skills](../features/skills.md) — How to author reusable skills -- [Ceremonies](../features/ceremonies.md) — How to define decision gates and review rituals - ---- - -**Ready to share?** [Open a discussion](https://github.com/bradygaster/squad/discussions) in the Squad community. +# Building extensions + +> ⚠️ **Experimental** — Squad is alpha software. APIs, commands, and behavior may change between releases. + +You've decided your idea is a Squad Extension (Layer 2). Now package it so another team can install it, enable it, and give their agents better context in five minutes. + +--- + +## What is an extension? + +An extension is a reusable collection of Squad agents, knowledge, workflows, ceremonies, memory guidance, provider contracts, generated artifacts, and directives that any team can install. It lives outside Squad core, packaged as a GitHub repository or marketplace plugin. Extensions let you codify workflows, domain expertise, memory lenses, knowledge graphs, or testing ceremonies that other teams benefit from. + +If your extension needs a Copilot plugin, declare it as a dependency in the manifest. If it points to an external CLI, package, or MCP server, record that in `repository`, `upstream`, or `mcp` metadata. If it uses an approved built-in provider such as Graphify, `squad plugin refresh` can generate artifacts for agents to consume. Squad does not install external packages, run plugin-supplied commands, start MCP servers, or manage Copilot plugins. + +--- + +## Extension structure + +``` +my-extension/ +├── plugin.manifest.json +├── knowledge/ +│ └── example-guidance.md +├── workflows/ +│ └── review-workflow.md +├── ceremonies/ +│ └── CEREMONY.md +└── README.md +``` + +--- + +## Build one + +**Step 1: Create a repo** + +```bash +mkdir my-extension +cd my-extension +git init +``` + +**Step 2: Add knowledge** + +Create `knowledge/example-guidance.md`: + +```markdown +# Example Guidance + +**When to use:** You need to do X. + +## Context + +Brief problem statement. + +## Steps + +1. Do the first thing +2. Do the second thing +3. Done +``` + +**Step 3 (optional): Add a ceremony** + +Create `ceremonies/code-review.md` following Squad ceremony format (decision gate, verdicts, escalation). + +**Step 4: Add a manifest** + +Create `plugin.manifest.json`: + +```json +{ + "id": "my-extension", + "name": "My Extension", + "version": "1.0.0", + "description": "Reusable workflow patterns for my team.", + "authors": ["Your Team"], + "license": "MIT", + "squad": ">=0.9.1", + "components": { + "knowledge": ["example-guidance"], + "workflows": ["review-workflow"] + }, + "copilot": { + "requires": [ + { + "id": "github/copilot-plugin-example", + "version": ">=1.0.0", + "optional": true, + "reason": "Used by Copilot when installed separately." + } + ] + }, + "repository": { + "type": "github", + "url": "https://github.com/example/my-extension" + }, + "upstream": { + "package": "example-tool", + "registry": "pypi", + "installCommand": "pip install example-tool", + "docs": "https://github.com/example/my-extension" + }, + "files": [ + { + "source": "knowledge/example-guidance.md", + "target": "knowledge/example-guidance.md", + "type": "knowledge" + } + ] +} +``` + +The MVP manifest is declarative. Do not add scripts, commands, lifecycle hooks, or executable files. + +**Step 5: Validate and dry-run** + +```bash +squad plugin validate . +squad plugin dry-run . +``` + +Dry-run prints the exact files Squad would write without changing `.squad/`. + +**Step 6: Install and enable locally** + +```bash +squad plugin install . +squad plugin enable my-extension +squad plugin list --json +``` + +Install records the plugin disabled by default. Enable activates the roles declared in `components`. +Copilot dependencies are surfaced to the user but must be installed through Copilot's own plugin flow. External package and MCP metadata is surfaced as install guidance only. + +**Step 7: Write the README** + +Explain the problem, installation, and usage: + +```markdown +# My Extension + +Codifies client-delivery workflows for consulting teams. + +## Install + +squad plugin install . +squad plugin enable my-extension + +## What's Inside + +- **discovery-interview** skill — clarify requirements +- **evidence-bundler** knowledge — collect test results +- **plan-review** ceremony — gate for approval +``` + +**Step 8: Test locally** + +Run `squad plugin verify`, then run `squad plugin refresh ` if your plugin declares an approved built-in provider. Load your Squad session and verify the installed Squad knowledge, workflows, and generated artifacts appear and work as expected. If you declared Copilot dependencies, verify those are installed separately through Copilot. + +--- + +## Share it + +Push to GitHub: + +```bash +git add . +git commit -m "Initial extension: my-extension" +git push +``` + +Register with a marketplace or pin directly by repository URL: + +``` +squad plugin marketplace add github/my-org/my-team-plugins +``` + +--- + +## Real examples + +- **Client-delivery workflow** ([RFC #328](https://github.com/bradygaster/squad/issues/328)) — discovery, research, multi-round review with evidence gates +- **Azure infrastructure patterns** — VM provisioning, Cosmos DB design, monitoring rules +- **Knowledge libraries** — document structured analysis, reference synthesis +- **External integration samples** — see `samples/plugin-knowledge-graphify` for the real Graphify knowledge graph tool, `samples/plugin-knowledge-index-server` for the real Index Server instruction/knowledge MCP server, and `samples/plugin-memory-mempalace` for the real MemPalace memory CLI/MCP system + +--- + +## Related docs + +- [Extensibility guide](./extensibility.md#decision-tree) — Where does your idea belong? (decision tree) +- [Plugin Marketplace](../features/plugins.md) — How teams discover and install your extension +- [Skills](../features/skills.md) — Existing Squad skills concepts; plugin manifests should use `knowledge` unless they are declaring a Copilot dependency +- [Ceremonies](../features/ceremonies.md) — How to define decision gates and review rituals + +--- + +**Ready to share?** [Open a discussion](https://github.com/bradygaster/squad/discussions) in the Squad community. diff --git a/docs/src/content/docs/guide/extensibility.md b/docs/src/content/docs/guide/extensibility.md index a9a70b304..0ec34b5af 100644 --- a/docs/src/content/docs/guide/extensibility.md +++ b/docs/src/content/docs/guide/extensibility.md @@ -1,130 +1,142 @@ -# Extensibility guide - -> ⚠️ **Experimental** — Squad is alpha software. APIs, commands, and behavior may change between releases. - -Where does your change idea belong? Squad core, marketplace plugin, or team config? - -**Key principle:** Squad core stays small. Most ideas are skills, ceremonies, or directives. - ---- - -## The three layers - -| Layer | What lives here | Who changes it | Distribution | -|-------|----------------|----------------|--------------| -| **Squad Core** | Coordinator behavior, routing logic, reviewer protocol | Squad maintainers only | npm releases | -| **Squad Extension** | Reusable patterns (skills, ceremonies, workflows) | Plugin authors | Marketplace plugins | -| **Team Configuration** | Decisions unique to THIS team | The team itself | `.squad/` files | - ---- - -## Decision tree - -``` -┌─ Does it change HOW the coordinator routes, spawns, or enforces? -│ -├─ YES → Squad Core -│ └─ Examples: New coordinator modes, reviewer protocol changes -│ Action: Open an RFC issue -│ -└─ NO → Continue... - │ - ┌─ Could OTHER teams benefit? - │ - ├─ YES → Squad Extension (plugin) - │ └─ Examples: Client-delivery workflow, Azure skills, TDD ceremonies - │ Action: Build a plugin - │ - └─ NO → Team Configuration - └─ Examples: YOUR git workflow, YOUR build process, YOUR routing rules - Action: Update `.squad/` files -``` - -**Heuristic:** "Squad should..." → check if it's really "My team should..." or "Teams using X should...". - - - ---- - -## Worked example: Client-delivery RFC - -[RFC #328](https://github.com/bradygaster/squad/issues/328) proposed a sophisticated client-delivery workflow: discovery interviews, research sprints, multi-round review with `SHIP`/`NEEDS_WORK`/`BLOCKED` verdicts, evidence bundles. - -**The realization:** It maps entirely to existing Squad primitives. No core changes needed. - -**Where it belongs:** Layer 2 (Squad Extension) - -This workflow is a reusable pattern any team could adopt — perfect as a marketplace plugin. - -**Plugin structure:** -``` -client-delivery-workflow/ -├── skills/ -│ ├── discovery-interview/ # Clarify requests, extract requirements -│ ├── research-sprint/ # Propose directions, score options -│ └── evidence-bundler/ # Collect test results, logs, screenshots -├── ceremonies/ -│ ├── plan-review.md # Gate: approve before implementation -│ └── implementation-review.md # Gate: verify evidence -└── directives/ - └── multi-round-review.md # Policy: 2 NEEDS_WORK rounds max -``` - -**Usage:** -```bash -squad plugin install github/awesome-copilot/client-delivery-workflow -``` - -**Lesson:** Most sophisticated workflows are compositions of primitives, not core features. - ---- - -## When to escalate to core - -You likely need a core change if: - -- **New coordinator mode** — Example: `validate` mode that runs checks before `assign` -- **Routing logic change** — Example: Route based on agent workload, not labels -- **Reviewer protocol change** — Example: Conditional approvals ("approved if tests pass") -- **Global enforcement rule** — Example: Block merges if evidence missing -- **Skill needs coordinator data** — Example: Access to agent spawn history - -You DON'T need core if: - -- **Workflow pattern** → Build a plugin (skills + ceremonies) -- **Domain expertise** → Write a skill -- **Team process** → Add a ceremony to `.squad/ceremonies.md` -- **Reusable templates** → Build a plugin -- **Configuring existing behavior** → Update `.squad/routing.md` - ---- - -## Build an extension - -Ready to build? See [Building extensions](./building-extensions.md) for a five-minute walkthrough. - ---- - -## Summary - -1. **Start with the decision tree** — Most ideas are Layer 2 or 3 -2. **Default to team config** — Unique to your team? → `.squad/` -3. **Build a plugin if reusable** — Other teams benefit? → Package and share -4. **Escalate to core rarely** — Need coordinator/routing changes? → Open an RFC - -**When in doubt:** Start with team config. Copy-pasting to other teams? Promote to plugin. Plugins repeatedly hitting limits? Signal for core change. - ---- - -## Related documentation - -- [Plugin Marketplace](../features/plugins.md) — How to browse, install, and share plugins -- [Skills](../features/skills.md) — How to write skills for your team or plugins -- [Ceremonies](../features/ceremonies.md) — How to define team meetings and gates -- [Routing](../features/routing.md) — How to configure work assignment rules -- [Building extensions](./building-extensions.md) — Step-by-step guide to building and sharing extensions -- [Contributing](https://github.com/bradygaster/squad/blob/main/CONTRIBUTING.md) — How to propose changes to Squad core - ---- - -**Questions?** [Open an issue](https://github.com/bradygaster/squad/issues/new) or join the discussion in the Squad community. +# Extensibility guide + +> ⚠️ **Experimental** — Squad is alpha software. APIs, commands, and behavior may change between releases. + +Where does your change idea belong? Squad core, marketplace plugin, or team config? + +**Key principle:** Squad core stays small. Most ideas are skills, ceremonies, or directives. + +--- + +## The three layers + +| Layer | What lives here | Who changes it | Distribution | +|-------|----------------|----------------|--------------| +| **Squad Core** | Coordinator behavior, routing logic, reviewer protocol | Squad maintainers only | npm releases | +| **Squad Extension** | Reusable capabilities (skills, ceremonies, workflows, memory guidance, provider contracts, generated artifacts) | Plugin authors | Marketplace plugins | +| **Team Configuration** | Decisions unique to THIS team | The team itself | `.squad/` files | + +--- + +## Decision tree + +``` +┌─ Does it change HOW the coordinator routes, spawns, or enforces? +│ +├─ YES → Squad Core +│ └─ Examples: New coordinator modes, reviewer protocol changes +│ Action: Open an RFC issue +│ +└─ NO → Continue... + │ + ┌─ Could OTHER teams benefit? + │ + ├─ YES → Squad Extension (plugin) + │ └─ Examples: Client-delivery workflow, Azure skills, TDD ceremonies, Graphify knowledge lens + │ Action: Build a plugin + │ + └─ NO → Team Configuration + └─ Examples: YOUR git workflow, YOUR build process, YOUR routing rules + Action: Update `.squad/` files +``` + +**Heuristic:** "Squad should..." → check if it's really "My team should..." or "Teams using X should...". + + + +--- + +## Worked example: Client-delivery RFC + +[RFC #328](https://github.com/bradygaster/squad/issues/328) proposed a sophisticated client-delivery workflow: discovery interviews, research sprints, multi-round review with `SHIP`/`NEEDS_WORK`/`BLOCKED` verdicts, evidence bundles. + +**The realization:** It maps entirely to existing Squad primitives. No core changes needed. + +**Where it belongs:** Layer 2 (Squad Extension) + +This workflow is a reusable pattern any team could adopt — perfect as a marketplace plugin. + +**Plugin structure:** +``` +client-delivery-workflow/ +├── skills/ +│ ├── discovery-interview/ # Clarify requests, extract requirements +│ ├── research-sprint/ # Propose directions, score options +│ └── evidence-bundler/ # Collect test results, logs, screenshots +├── ceremonies/ +│ ├── plan-review.md # Gate: approve before implementation +│ └── implementation-review.md # Gate: verify evidence +└── directives/ + └── multi-round-review.md # Policy: 2 NEEDS_WORK rounds max +``` + +**Usage:** +```bash +squad plugin install github/awesome-copilot/client-delivery-workflow +``` + +**Lesson:** Most sophisticated workflows are compositions of primitives, not core features. + +--- + +## When to escalate to core + +You likely need a core change if: + +- **New coordinator mode** — Example: `validate` mode that runs checks before `assign` +- **Routing logic change** — Example: Route based on agent workload, not labels +- **Reviewer protocol change** — Example: Conditional approvals ("approved if tests pass") +- **Global enforcement rule** — Example: Block merges if evidence missing +- **Skill needs coordinator data** — Example: Access to agent spawn history + +You DON'T need core if: + +- **Workflow pattern** → Build a plugin (skills + ceremonies) +- **Domain expertise** → Write a skill +- **Team process** → Add a ceremony to `.squad/ceremonies.md` +- **Reusable templates** → Build a plugin +- **Configuring existing behavior** → Update `.squad/routing.md` + +--- + +## Build an extension + +Ready to build? See [Building extensions](./building-extensions.md) for a five-minute walkthrough. + +The plugin MVP uses a declarative `plugin.manifest.json` and a simple lifecycle: + +```bash +squad plugin validate ./my-extension +squad plugin dry-run ./my-extension +squad plugin install ./my-extension +squad plugin enable my-extension +squad plugin refresh my-extension +``` + +Install records lock data and leaves the plugin disabled. Enable activates the roles declared in the manifest. Refresh updates approved generated artifacts for built-in providers such as Graphify. See [Plugin security model](../reference/plugin-security.md) for the guardrails under the pluggability model. + +--- + +## Summary + +1. **Start with the decision tree** — Most ideas are Layer 2 or 3 +2. **Default to team config** — Unique to your team? → `.squad/` +3. **Build a plugin if reusable** — Other teams benefit? → Package and share +4. **Escalate to core rarely** — Need coordinator/routing changes? → Open an RFC + +**When in doubt:** Start with team config. Copy-pasting to other teams? Promote to plugin. Plugins repeatedly hitting limits? Signal for core change. + +--- + +## Related documentation + +- [Plugin Marketplace](../features/plugins.md) — How to browse, install, and share plugins +- [Skills](../features/skills.md) — How to write skills for your team or plugins +- [Ceremonies](../features/ceremonies.md) — How to define team meetings and gates +- [Routing](../features/routing.md) — How to configure work assignment rules +- [Building extensions](./building-extensions.md) — Step-by-step guide to building and sharing extensions +- [Contributing](https://github.com/bradygaster/squad/blob/main/CONTRIBUTING.md) — How to propose changes to Squad core + +--- + +**Questions?** [Open an issue](https://github.com/bradygaster/squad/issues/new) or join the discussion in the Squad community. diff --git a/docs/src/content/docs/reference/plugin-security.md b/docs/src/content/docs/reference/plugin-security.md new file mode 100644 index 000000000..d807d8a4c --- /dev/null +++ b/docs/src/content/docs/reference/plugin-security.md @@ -0,0 +1,78 @@ +# Plugin security model + +> ⚠️ **Experimental** — This model describes the MVP plugin gate. Any future relaxation requires an RFC and explicit security review. + +The plugin MVP is designed for reusable Squad knowledge and configuration, not executable extensions and not a replacement for Copilot plugins. A plugin is a declarative manifest plus static files. Squad may copy those files into `.squad/`, record lock data, and mark roles active, but it must not execute plugin-supplied content. + +Copilot plugin dependencies can be declared with `copilot.requires`. They are metadata only: Squad records and displays them so users know which Copilot plugins to install through Copilot's own lifecycle. + +External packages, repositories, and MCP servers can be described with `repository`, `upstream`, and `mcp`. Typed provider contracts can be described with `providers`. They are also metadata only: Squad may display install hints and inject provider summaries into spawned-agent context, but it must not run package managers, start MCP servers, configure assistant clients, call provider tools, query live provider backends, or install assistant hooks. + +--- + +## Threat model + +| Threat | MVP mitigation | +| --- | --- | +| Malicious manifest declares scripts or lifecycle commands | The validator rejects executable keys such as `scripts`, `commands`, `command`, `exec`, `run`, `preinstall`, and `postinstall`, even when nested. | +| Malicious static file is actually executable code | The validator rejects executable/script extensions including `.js`, `.mjs`, `.cjs`, `.ts`, `.tsx`, `.sh`, `.ps1`, `.bat`, `.cmd`, and `.exe`. | +| Manifest writes outside `.squad/` | Source and target paths must be relative and cannot contain traversal segments or absolute paths. | +| Source file escapes through symlink | Install rejects symlinked source files before writing plugin state. | +| Malicious lock file hides changed content | `squad plugin verify` recomputes SHA-256 hashes for installed files and fails on mismatch. | +| Hook or adapter metadata triggers execution | Hooks and adapters are recorded only as metadata; MVP commands never execute them. | +| Hidden capabilities are inferred from arbitrary strings | Runtime roles are derived only from declared component keys. | +| Squad plugin install silently installs Copilot plugins | `copilot.requires` is validated as dependency metadata only. Squad does not call Copilot plugin commands. | +| Squad plugin install silently installs external packages or starts MCP servers | `repository`, `upstream`, and `mcp` are validated as metadata only. Squad does not run install hints or MCP setup hints. | +| Provider contract triggers live memory or knowledge backend calls | `providers` is validated as declarative metadata only. Squad surfaces provider type, protocol, artifact, capabilities, and MCP binding hints without starting servers or calling tools. | +| Network egress from plugin-supplied content | MVP plugin lifecycle commands do not execute plugin content, so plugin content cannot initiate network calls. | +| Governed artifact generation executes arbitrary code | Artifact generation is strictly gated by provider allowlist, lifecycle event allowlist, and output path allowlist. Only built-in approved providers (currently Graphify) are whitelisted. Generated artifacts use only approved provider built-in operations. | + +--- + +## Red lines + +The following must remain unreachable from `validate`, `dry-run`, `install`, `enable`, `disable`, `switch`, `verify`, and `uninstall`: + +1. Evaluating plugin-supplied content. +2. Spawning a child process from plugin-supplied content. +3. Writing outside declared component paths under `.squad/`. +4. Initiating network egress from plugin-supplied content. +5. Treating arbitrary capability strings as trusted runtime roles. +6. Running Copilot plugin commands from the Squad plugin lifecycle. +7. Running upstream package install hints or MCP setup hints from the Squad plugin lifecycle. +8. Calling live provider tools or querying external provider backends from plugin metadata. + +Runtime artifact generation commands (`refresh`, `run-lifecycle`) add a new capability but preserve all red lines: + +- **Still denied:** arbitrary provider execution, shell commands, package installs, network calls, MCP server startup, plugin-provided executable code, or modifications to plugin-supplied content. +- **Allowed under governance:** Built-in approved providers (Graphify) generating artifacts to approved output paths through designated lifecycle events only. + +The next executable-provider roadmap is tracked in: + +- [Epic #1102](https://github.com/bradygaster/squad/issues/1102) +- [Phase 1 marketplace distribution #1103](https://github.com/bradygaster/squad/issues/1103) +- [Phase 2 built-in providers #1104](https://github.com/bradygaster/squad/issues/1104) +- [Phase 3 executable runtime RFC #1105](https://github.com/bradygaster/squad/issues/1105) + +--- + +## Audit and rollback + +Installs are rollback-protected: if file copy or state writing fails, copied files are removed and previous plugin state is restored. Lifecycle events, including artifact generation, are written to `.squad/plugins/audit.jsonl` as JSON Lines so reviewers and tools can inspect what happened without parsing console output. + +--- + +## Reviewer checklist + +Before merging plugin lifecycle changes: + +1. Run the focused plugin tests. +2. Run the full test suite. +3. Confirm new manifest fields remain declarative metadata. +4. Confirm no plugin-supplied string reaches `eval`, `Function`, dynamic `import`, `child_process`, Copilot plugin commands, or shell execution. +5. Confirm all file writes are based on validated manifest targets and use safe path joins. +6. For artifact generation changes: + - Confirm new providers are added to the provider allowlist only after security review. + - Confirm only designated lifecycle events can trigger artifact generation. + - Confirm generated artifacts are restricted to approved `.squad/` paths. + - Confirm provider implementation uses only built-in operations, no arbitrary code execution. diff --git a/packages/squad-cli/src/cli/commands/plugin.ts b/packages/squad-cli/src/cli/commands/plugin.ts index b51a7d26f..e017319f8 100644 --- a/packages/squad-cli/src/cli/commands/plugin.ts +++ b/packages/squad-cli/src/cli/commands/plugin.ts @@ -3,11 +3,33 @@ * Port from beta index.js lines 716-833 */ -import { join } from 'node:path'; +import { lstat, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve, sep } from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; -import { TIMEOUTS, FSStorageProvider } from '@bradygaster/squad-sdk'; -import { success, warn, info, dim, bold, DIM, BOLD, RESET } from '../core/output.js'; +import { + TIMEOUTS, + FSStorageProvider, + PLUGIN_MANIFEST_FILENAMES, + appendAuditEvent, + createPluginInstallPlan, + describePluginFile, + executeLifecycleHook, + isLifecycleEventName, + parsePluginManifestContent, + readPluginStates, + removeInstalledPlugin, + setPluginEnabled, + sha256, + switchActivePlugin, + upsertInstalledPlugin, + validatePluginManifest, + writePluginStates, + type InstalledPluginFile, + type PluginComponentKind, + type PluginInstallPlanFile, +} from '@bradygaster/squad-sdk'; +import { success, warn, info, dim, DIM, BOLD, RESET } from '../core/output.js'; import { fatal } from '../core/errors.js'; import { detectSquadDir } from '../core/detect-squad-dir.js'; import { ghAvailable, ghAuthenticated } from '../core/gh-cli.js'; @@ -32,8 +54,8 @@ export async function runPlugin(dest: string, args: string[]): Promise { const subCmd = args[0]; const action = args[1]; - if (subCmd !== 'marketplace' || !action) { - fatal('Usage: squad plugin marketplace add|remove|list|browse'); + if (!subCmd) { + fatal(pluginUsage()); } const squadDirInfo = detectSquadDir(dest); @@ -59,6 +81,15 @@ export async function runPlugin(dest: string, args: string[]): Promise { await storage.write(marketplacesFile, JSON.stringify(data, null, 2) + '\n'); } + if (subCmd !== 'marketplace') { + await runPluginLifecycle(dest, squadDirInfo.path, args); + return; + } + + if (!action) { + fatal('Usage: squad plugin marketplace add|remove|list|browse'); + } + // --- Add marketplace --- if (action === 'add') { const source = args[2]; @@ -176,3 +207,468 @@ export async function runPlugin(dest: string, args: string[]): Promise { fatal(`Unknown action: ${action}. Usage: squad plugin marketplace add|remove|list|browse`); } + +async function runPluginLifecycle(dest: string, squadDir: string, args: string[]): Promise { + const action = args[0]; + await mkdir(squadDir, { recursive: true }); + await mkdir(join(squadDir, 'plugins'), { recursive: true }); + const stateStorage = new FSStorageProvider(); + + if (action === 'validate' || action === 'dry-run') { + const source = args[1]; + if (!source) { + fatal(`Usage: squad plugin ${action} `); + } + const { manifest, validation, plan } = await loadLocalPlugin(dest, source, action === 'dry-run'); + if (!validation.valid) { + fatal(`Invalid plugin manifest:\n - ${validation.errors.join('\n - ')}`); + } + success(`Plugin manifest is valid: ${bold(manifest.id)}@${manifest.version}`); + printExternalMetadata(manifest); + printProviderContracts(manifest); + printCopilotDependencies(manifest.copilot); + if (action === 'dry-run') { + console.log(`\n${BOLD}Dry run deployment plan:${RESET}\n`); + for (const file of plan.files) { + console.log(` ${describePluginFile(file)}`); + } + console.log(`\n${DIM}No files were written.${RESET}`); + } + if (validation.warnings.length > 0) { + console.log(`\n${DIM}Warnings:${RESET}`); + for (const warning of validation.warnings) { + console.log(` - ${warning}`); + } + } + return; + } + + if (action === 'install') { + const source = args[1]; + if (!source) { + fatal('Usage: squad plugin install [--dry-run]'); + } + + const dryRun = args.includes('--dry-run'); + const { pluginDir, manifest, manifestContent, validation, plan } = await loadLocalPlugin(dest, source, dryRun); + if (!validation.valid) { + fatal(`Invalid plugin manifest:\n - ${validation.errors.join('\n - ')}`); + } + + const installedFiles = await collectInstallFiles(pluginDir, plan.files); + + if (dryRun) { + success(`Plugin manifest is valid: ${bold(manifest.id)}@${manifest.version}`); + printExternalMetadata(manifest); + printProviderContracts(manifest); + printCopilotDependencies(manifest.copilot); + console.log(`\n${BOLD}Dry run deployment plan:${RESET}\n`); + for (const file of plan.files) { + console.log(` ${describePluginFile(file)}`); + } + if (validation.warnings.length > 0) { + console.log(`\n${DIM}Warnings:${RESET}`); + for (const warning of validation.warnings) { + console.log(` - ${warning}`); + } + } + return; + } + + const previousStates = await readPluginStates(stateStorage, squadDir); + const states = cloneStates(previousStates); + const copiedTargets: string[] = []; + try { + for (const file of plan.files) { + const sourcePath = safeJoin(pluginDir, file.source); + const targetPath = safeJoin(squadDir, file.target); + await mkdir(dirname(targetPath), { recursive: true }); + await writeFile(targetPath, await readFile(sourcePath)); + copiedTargets.push(targetPath); + } + + upsertInstalledPlugin(states, manifest, { + source: pluginDir, + manifestContent, + files: installedFiles, + }); + await writePluginStates(stateStorage, states, squadDir); + } catch (err: unknown) { + await Promise.all(copiedTargets.map((target) => rm(target, { force: true }))); + await writePluginStates(stateStorage, previousStates, squadDir); + const message = err instanceof Error ? err.message : String(err); + fatal(`Install failed and was rolled back: ${message}`); + } + success(`Installed plugin ${bold(manifest.id)}@${manifest.version} (disabled)`); + printExternalMetadata(manifest); + printProviderContracts(manifest); + printCopilotDependencies(manifest.copilot); + return; + } + + if (action === 'list-json' || (action === 'list' && args.includes('--json'))) { + const states = await readPluginStates(stateStorage, squadDir); + console.log(JSON.stringify({ + plugins: states.installed.plugins, + active: states.runtime.active, + }, null, 2)); + return; + } + + if (action === 'list') { + const states = await readPluginStates(stateStorage, squadDir); + if (states.installed.plugins.length === 0) { + info(`${DIM}No plugins installed${RESET}`); + return; + } + + console.log(`\n${BOLD}Installed plugins:${RESET}\n`); + for (const plugin of states.installed.plugins) { + const status = plugin.enabled ? 'enabled' : 'disabled'; + const roles = plugin.roles.length > 0 ? ` roles=${plugin.roles.join(',')}` : ''; + const copilotDependencies = plugin.copilot?.requires?.length + ? ` copilot=${plugin.copilot.requires.length}` + : ''; + const upstream = plugin.upstream?.package ? ` upstream=${plugin.upstream.package}` : ''; + const mcp = plugin.mcp?.available ? ' mcp=available' : ''; + const providers = plugin.providers?.length ? ` providers=${plugin.providers.length}` : ''; + console.log(` ${BOLD}${plugin.id}${RESET}@${plugin.version} ${DIM}${status}${RESET}${roles}${copilotDependencies}${upstream}${mcp}${providers}`); + } + console.log(); + return; + } + + if (action === 'enable' || action === 'disable') { + const pluginId = args[1]; + if (!pluginId) { + fatal(`Usage: squad plugin ${action} `); + } + const states = await readPluginStates(stateStorage, squadDir); + const plugin = setPluginEnabled(states, pluginId, action === 'enable'); + await writePluginStates(stateStorage, states, squadDir); + success(`${action === 'enable' ? 'Enabled' : 'Disabled'} plugin ${bold(plugin.id)}`); + return; + } + + if (action === 'switch') { + const role = args[1] as PluginComponentKind | undefined; + const pluginId = args[2]; + if (!role || !pluginId) { + fatal('Usage: squad plugin switch '); + } + const states = await readPluginStates(stateStorage, squadDir); + const plugin = switchActivePlugin(states, role, pluginId); + await writePluginStates(stateStorage, states, squadDir); + success(`Switched ${bold(role)} to plugin ${bold(plugin.id)}`); + return; + } + + if (action === 'uninstall') { + const pluginId = args[1]; + if (!pluginId) { + fatal('Usage: squad plugin uninstall '); + } + const states = await readPluginStates(stateStorage, squadDir); + const plugin = removeInstalledPlugin(states, pluginId); + for (const file of plugin.files) { + await rm(safeJoin(squadDir, file.target), { force: true }); + } + await writePluginStates(stateStorage, states, squadDir); + success(`Uninstalled plugin ${bold(plugin.id)}`); + return; + } + + if (action === 'verify' || action === 'health') { + const states = await readPluginStates(stateStorage, squadDir); + const errors: string[] = []; + for (const plugin of states.installed.plugins) { + for (const file of plugin.files) { + const targetPath = safeJoin(squadDir, file.target); + const content = await readOptionalBuffer(targetPath); + if (content === undefined) { + errors.push(`${plugin.id}: missing ${file.target}`); + continue; + } + const actual = sha256(content); + if (actual !== file.sha256) { + errors.push(`${plugin.id}: checksum mismatch for ${file.target}`); + } + } + } + + const now = new Date().toISOString(); + for (const plugin of states.installed.plugins) { + appendAuditEvent(states.audit, { + type: 'verify', + plugin_id: plugin.id, + version: plugin.version, + timestamp: now, + message: errors.length === 0 ? `Verified ${plugin.id}` : `Verification checked ${plugin.id}`, + }); + } + await writePluginStates(stateStorage, states, squadDir); + + if (errors.length > 0) { + fatal(`Plugin verification failed:\n - ${errors.join('\n - ')}`); + } + success(`Plugin health check passed (${states.installed.plugins.length} installed)`); + return; + } + + if (action === 'refresh' || action === 'run-lifecycle') { + const pluginId = args[1]; + const lifecycleEvent = action === 'refresh' ? 'onMemoryRefresh' : args[2]; + + if (!pluginId) { + fatal(`Usage: squad plugin ${action} ${action === 'run-lifecycle' ? ' ' : ''}`); + } + + if (action === 'run-lifecycle' && !lifecycleEvent) { + fatal('Usage: squad plugin run-lifecycle '); + } + + const states = await readPluginStates(stateStorage, squadDir); + const plugin = states.installed.plugins.find((p) => p.id === pluginId); + + if (!plugin) { + fatal(`Plugin "${pluginId}" is not installed`); + } + + if (!plugin.enabled) { + fatal(`Plugin "${pluginId}" is disabled. Enable it first with: squad plugin enable ${pluginId}`); + } + + const runtime = plugin.runtime; + if (!runtime?.capabilities || runtime.capabilities.length === 0) { + fatal(`Plugin "${pluginId}" has no runtime capabilities`); + } + + let lifecycle = 'onMemoryRefresh'; + if (action === 'run-lifecycle') { + if (!lifecycleEvent) { + fatal('Usage: squad plugin run-lifecycle '); + } + lifecycle = lifecycleEvent; + } + if (!isLifecycleEventName(lifecycle)) { + fatal(`Unsupported lifecycle event "${lifecycle}". Allowed events: onInstall, onEnable, onDisable, onBeforeSpawn, onAfterTask, onMemoryRefresh`); + } + + console.log(`${DIM}Executing ${lifecycle} lifecycle for ${plugin.id}...${RESET}`); + const results = await executeLifecycleHook( + plugin.id, + plugin.version, + lifecycle, + runtime.capabilities, + stateStorage, + squadDir, + states.audit + ); + + await writePluginStates(stateStorage, states, squadDir); + + if (results.length === 0) { + info(`${DIM}No runtime operations executed for lifecycle ${lifecycle}${RESET}`); + return; + } + + for (const result of results) { + if (result.success) { + success(result.message); + if (result.artifactsGenerated.length > 0) { + console.log(`${DIM}Generated artifacts:${RESET}`); + for (const artifact of result.artifactsGenerated) { + console.log(` - ${artifact}`); + } + } + } else { + warn(`${result.message}${result.error ? `: ${result.error}` : ''}`); + } + } + return; + } + + fatal(pluginUsage()); +} + +async function findManifestFile(pluginDir: string): Promise { + for (const fileName of PLUGIN_MANIFEST_FILENAMES) { + const candidate = join(pluginDir, fileName); + const found = await readOptionalFile(candidate); + if (found !== undefined) { + return candidate; + } + } + return undefined; +} + +async function loadLocalPlugin(dest: string, source: string, dryRun: boolean): Promise<{ + pluginDir: string; + manifestContent: string; + manifest: ReturnType; + validation: ReturnType; + plan: ReturnType; +}> { + const pluginDir = resolve(dest, source); + await assertDirectory(pluginDir, 'Plugin source'); + const manifestFile = await findManifestFile(pluginDir); + if (!manifestFile) { + fatal(`No plugin manifest found. Expected one of: ${PLUGIN_MANIFEST_FILENAMES.join(', ')}`); + } + const manifestContent = await readFile(manifestFile, 'utf8'); + const manifest = parsePluginManifestContent(manifestContent, manifestFile); + const validation = validatePluginManifest(manifest); + const plan = validation.valid + ? createPluginInstallPlan(manifest, { dryRun }) + : { manifest, files: [], dryRun }; + return { pluginDir, manifestContent, manifest, validation, plan }; +} + +async function collectInstallFiles( + pluginDir: string, + files: PluginInstallPlanFile[], +): Promise { + const installedFiles: InstalledPluginFile[] = []; + for (const file of files) { + const sourcePath = safeJoin(pluginDir, file.source); + const stats = await lstat(sourcePath); + if (stats.isSymbolicLink()) { + fatal(`Plugin source file may not be a symlink: ${file.source}`); + } + if (!stats.isFile()) { + fatal(`Plugin source must be a file: ${file.source}`); + } + const content = await readFile(sourcePath); + installedFiles.push({ + ...file, + sha256: sha256(content), + }); + } + return installedFiles; +} + +function cloneStates(states: T): T { + return JSON.parse(JSON.stringify(states)) as T; +} + +async function readOptionalFile(filePath: string): Promise { + try { + return await readFile(filePath, 'utf8'); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + throw err; + } +} + +async function readOptionalBuffer(filePath: string): Promise { + try { + return await readFile(filePath); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + throw err; + } +} + +async function assertDirectory(dirPath: string, label: string): Promise { + let stats; + try { + stats = await stat(dirPath); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + fatal(`${label} does not exist: ${dirPath}`); + } + throw err; + } + if (!stats.isDirectory()) { + fatal(`${label} must be a directory: ${dirPath}`); + } +} + +function safeJoin(root: string, relativePath: string): string { + const resolvedRoot = resolve(root); + const resolvedPath = resolve(resolvedRoot, relativePath); + if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(`${resolvedRoot}${sep}`)) { + throw new Error(`Path escapes root: ${relativePath}`); + } + return resolvedPath; +} + +function pluginUsage(): string { + return 'Usage: squad plugin marketplace add|remove|list|browse OR squad plugin validate|dry-run|install|uninstall|enable|disable|switch|list|verify|refresh|run-lifecycle'; +} + +function printCopilotDependencies(copilot: ReturnType['copilot']): void { + const dependencies = copilot?.requires ?? []; + if (dependencies.length === 0) { + return; + } + + console.log(`\n${BOLD}Copilot plugin dependencies:${RESET}`); + for (const dependency of dependencies) { + const optional = dependency.optional ? 'optional' : 'required'; + const version = dependency.version ? ` ${dependency.version}` : ''; + const reason = dependency.reason ? ` — ${dependency.reason}` : ''; + console.log(` - ${dependency.id}${version} ${DIM}(${optional})${RESET}${reason}`); + } + console.log(`${DIM}Squad records these dependencies but does not install or run Copilot plugin commands.${RESET}`); +} + +function printExternalMetadata(manifest: ReturnType): void { + if (!manifest.repository && !manifest.upstream && !manifest.mcp) { + return; + } + + console.log(`\n${BOLD}External integration metadata:${RESET}`); + if (manifest.repository) { + console.log(` - repository: ${manifest.repository.url}`); + } + if (manifest.upstream) { + const packageName = manifest.upstream.package ? ` ${manifest.upstream.package}` : ''; + const registry = manifest.upstream.registry ? ` (${manifest.upstream.registry})` : ''; + console.log(` - upstream:${packageName}${registry}`); + if (manifest.upstream.installCommand) { + console.log(` install hint: ${manifest.upstream.installCommand}`); + } + } + if (manifest.mcp) { + const available = manifest.mcp.available ? 'available' : 'not declared available'; + const server = manifest.mcp.server ? ` server=${manifest.mcp.server}` : ''; + const entryPoint = manifest.mcp.entryPoint ? ` entry=${manifest.mcp.entryPoint}` : ''; + console.log(` - MCP: ${available}${server}${entryPoint}`); + if (manifest.mcp.installCommand) { + console.log(` install hint: ${manifest.mcp.installCommand}`); + } + } + console.log(`${DIM}Squad records this metadata but does not install packages, start MCP servers, or run external commands.${RESET}`); +} + +function printProviderContracts(manifest: ReturnType): void { + const providers = manifest.providers ?? []; + if (providers.length === 0) { + return; + } + + console.log(`\n${BOLD}Provider contracts:${RESET}`); + for (const provider of providers) { + const mode = provider.mode ? ` mode=${provider.mode}` : ''; + const protocol = provider.protocol ? ` protocol=${provider.protocol}` : ''; + const artifact = provider.artifact ? ` artifact=.squad/${provider.artifact}` : ''; + const capabilities = provider.capabilities?.length ? ` capabilities=${provider.capabilities.join(',')}` : ''; + console.log(` - ${provider.id}: type=${provider.type}${mode}${protocol}${artifact}${capabilities}`); + if (provider.mcp) { + const server = provider.mcp.server ? ` server=${provider.mcp.server}` : ''; + const tool = provider.mcp.tool ? ` tool=${provider.mcp.tool}` : ''; + const capability = provider.mcp.capability ? ` capability=${provider.mcp.capability}` : ''; + console.log(` MCP binding:${server}${tool}${capability} ${DIM}(metadata only)${RESET}`); + } + } + console.log(`${DIM}Squad records provider contracts but does not start MCP servers, call provider tools, or query live provider backends.${RESET}`); +} + +function bold(value: string): string { + return `${BOLD}${value}${RESET}`; +} diff --git a/packages/squad-cli/templates/squad.agent.md.template b/packages/squad-cli/templates/squad.agent.md.template index 73ba6d311..05a6dce47 100644 --- a/packages/squad-cli/templates/squad.agent.md.template +++ b/packages/squad-cli/templates/squad.agent.md.template @@ -976,7 +976,7 @@ prompt: | Tasks (in order): {% if STATE_BACKEND == "orphan" or STATE_BACKEND == "git-notes" or STATE_BACKEND == "two-layer" %} - 0. STATE LEAK GUARD: Check if any agent accidentally committed or staged state files + 0. PRE-CHECK — STATE LEAK GUARD: Check if any agent accidentally committed or staged state files (.squad/decisions.md, agents/*/history.md, log/*, orchestration-log/*, decisions/inbox/*) to the working branch. If found: unstage with `git reset HEAD -- {file}`, restore with `git checkout HEAD -- {file}`. If leaked in last commit, amend to remove. Log count. diff --git a/packages/squad-sdk/src/agents/charter-compiler.ts b/packages/squad-sdk/src/agents/charter-compiler.ts index 2bd61b00b..6401109c9 100644 --- a/packages/squad-sdk/src/agents/charter-compiler.ts +++ b/packages/squad-sdk/src/agents/charter-compiler.ts @@ -26,6 +26,8 @@ export interface CharterCompileOptions { routingRules?: string; /** Relevant decision records */ decisions?: string; + /** Enabled plugin guidance and metadata to inject into spawned-agent context */ + pluginContext?: string; /** Config-driven overrides (config wins on conflict) */ configOverrides?: CharterConfigOverrides; } @@ -107,7 +109,7 @@ export function compileCharter(options: CharterCompileOptions): SquadCustomAgent * @throws {ConfigurationError} If charter is missing or malformed */ export function compileCharterFull(options: CharterCompileOptions): CompiledCharter { - const { agentName, charterPath, charterContent, teamContext, routingRules, decisions, configOverrides } = options; + const { agentName, charterPath, charterContent, teamContext, routingRules, decisions, pluginContext, configOverrides } = options; try { const parsed = parseCharterMarkdown(charterContent ?? ''); @@ -133,6 +135,12 @@ export function compileCharterFull(options: CharterCompileOptions): CompiledChar promptParts.push('\n\n## Relevant Decisions\n\n' + decisions); } + // Add enabled plugin guidance after built-in squad context. Plugins are + // declarative/static only; this section is the runtime consumption point. + if (pluginContext) { + promptParts.push('\n\n## Plugin Context\n\n' + pluginContext); + } + // Append extra prompt from config overrides if (configOverrides?.extraPrompt) { promptParts.push('\n\n' + configOverrides.extraPrompt); diff --git a/packages/squad-sdk/src/agents/lifecycle.ts b/packages/squad-sdk/src/agents/lifecycle.ts index e7956ebc8..c0998995a 100644 --- a/packages/squad-sdk/src/agents/lifecycle.ts +++ b/packages/squad-sdk/src/agents/lifecycle.ts @@ -15,6 +15,7 @@ import { ConfigurationError, SessionLifecycleError } from '../adapter/errors.js' import * as path from 'path'; import { FSStorageProvider } from '../storage/fs-storage-provider.js'; import type { StorageProvider } from '../storage/storage-provider.js'; +import { buildActivePluginContext } from '../marketplace/plugin-state.js'; import { trace, SpanStatusCode } from '../runtime/otel-api.js'; import { recordAgentSpawn, recordAgentDestroy, recordAgentError } from '../runtime/otel-metrics.js'; @@ -157,8 +158,14 @@ export class AgentLifecycleManager { try { // Step 1: Read charter.md - const charterPath = path.join(this.teamRoot, '.ai-team', 'agents', agentName, 'charter.md'); - const charterContent = await this.storage.read(charterPath); + const squadCharterPath = path.join(this.teamRoot, '.squad', 'agents', agentName, 'charter.md'); + const legacyCharterPath = path.join(this.teamRoot, '.ai-team', 'agents', agentName, 'charter.md'); + let charterPath = squadCharterPath; + let charterContent = await this.storage.read(charterPath); + if (charterContent === undefined) { + charterPath = legacyCharterPath; + charterContent = await this.storage.read(charterPath); + } if (charterContent === undefined) { throw new ConfigurationError( @@ -176,9 +183,11 @@ export class AgentLifecycleManager { const compileOptions: CharterCompileOptions = { agentName, charterPath, + charterContent, teamContext, routingRules, decisions, + pluginContext: await buildActivePluginContext(this.storage, path.join(this.teamRoot, '.squad')), }; const agentConfig = compileCharter(compileOptions); diff --git a/packages/squad-sdk/src/marketplace/index.ts b/packages/squad-sdk/src/marketplace/index.ts index 314e36011..7844e07de 100644 --- a/packages/squad-sdk/src/marketplace/index.ts +++ b/packages/squad-sdk/src/marketplace/index.ts @@ -160,3 +160,75 @@ export { MarketplaceBackend } from './backend.js'; export type { PublishResult, OperationResult } from './backend.js'; export { validateRemoteAgent, quarantineAgent, generateSecurityReport, SECURITY_RULES } from './security.js'; export type { RemoteAgentDefinition, SecurityReport, SecurityRule, SecuritySeverity } from './security.js'; +export { + PLUGIN_MANIFEST_FILENAMES, + PLUGIN_MANIFEST_SCHEMA_VERSION, + createPluginInstallPlan, + derivePluginRoles, + describePluginFile, + parsePluginManifestContent, + toPosixRelativePath, + validatePluginManifest, +} from './plugin-manifest.js'; +export type { + CopilotPluginDependency, + CopilotPluginRequirements, + LifecycleEventName, + PluginMcpMetadata, + PluginFileDeployment, + PluginComponentKind, + PluginFileType, + PluginInstallPlan, + PluginInstallPlanFile, + PluginProviderContract, + PluginProviderMcpBinding, + PluginProviderMode, + PluginProviderProtocol, + PluginProviderType, + PluginRepositoryMetadata, + PluginRuntimeCapability, + PluginRuntimeManifest, + PluginUpstreamMetadata, + PluginValidationResult, + SquadPluginManifest, +} from './plugin-manifest.js'; +export { + executeLifecycleHook, + getApprovedProviderNames, + isLifecycleEventName, +} from './plugin-runtime.js'; +export { LIFECYCLE_EVENT_NAMES } from './plugin-runtime.js'; +export type { + ProviderOperationContext, + ProviderOperationResult, +} from './plugin-runtime.js'; +export { + INSTALLED_PLUGINS_FILE, + PLUGIN_AUDIT_FILE, + PLUGIN_LOCK_FILE, + PLUGIN_RUNTIME_FILE, + PLUGIN_STATE_DIR, + appendAuditEvent, + buildActivePluginContext, + readPluginStates, + removeInstalledPlugin, + setPluginEnabled, + sha256, + switchActivePlugin, + upsertInstalledPlugin, + writePluginStates, +} from './plugin-state.js'; +export type { + InstalledPlugin, + InstalledPluginFile, + InstalledPluginsState, + ActivePluginContextOptions, + PluginAuditEvent, + PluginAuditEventType, + PluginAuditState, + PluginLockEntry, + PluginLockState, + PluginRuntimeEntry, + PluginRuntimeState, + PluginStates, +} from './plugin-state.js'; diff --git a/packages/squad-sdk/src/marketplace/plugin-manifest.ts b/packages/squad-sdk/src/marketplace/plugin-manifest.ts new file mode 100644 index 000000000..eb4a57268 --- /dev/null +++ b/packages/squad-sdk/src/marketplace/plugin-manifest.ts @@ -0,0 +1,1018 @@ +import { basename, extname, isAbsolute, normalize, sep } from 'node:path'; + +export const PLUGIN_MANIFEST_FILENAMES = [ + 'plugin.manifest.json', + 'squad-plugin.json', + 'squad-plugin.yaml', + 'squad-plugin.yml', + 'plugin.json', + 'plugin.yaml', + 'plugin.yml', +] as const; + +const EXECUTABLE_KEYS = new Set([ + 'command', + 'commands', + 'exec', + 'executable', + 'install', + 'postinstall', + 'preinstall', + 'run', + 'script', + 'scripts', +]); +const EXECUTABLE_EXTENSIONS = new Set([ + '.bat', + '.cmd', + '.com', + '.cjs', + '.exe', + '.js', + '.mjs', + '.ps1', + '.sh', + '.ts', + '.tsx', +]); +const ALLOWED_TARGET_ROOTS = new Set([ + 'agents', + 'ceremonies', + 'decisions', + 'instructions', + 'knowledge', + 'memory', + 'plugins', + 'prompts', + 'routing', + 'templates', + 'workflows', +]); +const COMPONENT_KINDS = [ + 'agents', + 'ceremonies', + 'decisions', + 'instructions', + 'knowledge', + 'memory', + 'routing', + 'templates', + 'workflows', + 'hooks', + 'adapters', +] as const; + +export type PluginComponentKind = typeof COMPONENT_KINDS[number]; + +export type PluginFileType = + | 'agent' + | 'asset' + | 'doc' + | 'instruction' + | 'knowledge' + | 'prompt' + | 'template' + | 'workflow'; + +export interface PluginFileDeployment { + source: string; + target: string; + type?: PluginFileType; +} + +export interface CopilotPluginDependency { + id: string; + version?: string; + optional?: boolean; + reason?: string; +} + +export interface CopilotPluginRequirements { + requires?: CopilotPluginDependency[]; +} + +export interface PluginRepositoryMetadata { + type?: string; + url: string; +} + +export interface PluginUpstreamMetadata { + package?: string; + registry?: string; + installCommand?: string; + docs?: string; +} + +export interface PluginMcpMetadata { + available?: boolean; + server?: string; + entryPoint?: string; + installCommand?: string; + reason?: string; +} + +const PROVIDER_TYPES = [ + 'memory', + 'knowledge', + 'persistence', + 'event', + 'policy', +] as const; +const PROVIDER_MODES = ['read', 'write', 'read-write'] as const; +const PROVIDER_PROTOCOLS = ['static-artifact', 'mcp'] as const; +const RUNTIME_CAPABILITY_TYPES = ['artifact-generation'] as const; +const APPROVED_RUNTIME_PROVIDERS = ['graphify'] as const; +const ALLOWED_LIFECYCLE_EVENTS = [ + 'onInstall', + 'onEnable', + 'onDisable', + 'onBeforeSpawn', + 'onAfterTask', + 'onMemoryRefresh', +] as const; + +export type PluginProviderType = typeof PROVIDER_TYPES[number]; +export type PluginProviderMode = typeof PROVIDER_MODES[number]; +export type PluginProviderProtocol = typeof PROVIDER_PROTOCOLS[number]; +export type PluginRuntimeCapabilityType = typeof RUNTIME_CAPABILITY_TYPES[number]; +export type RuntimeProviderName = typeof APPROVED_RUNTIME_PROVIDERS[number]; +export type LifecycleEventName = typeof ALLOWED_LIFECYCLE_EVENTS[number]; + +export interface PluginProviderMcpBinding { + server?: string; + tool?: string; + capability?: string; +} + +export interface PluginProviderContract { + id: string; + type: PluginProviderType; + mode?: PluginProviderMode; + protocol?: PluginProviderProtocol; + description?: string; + artifact?: string; + mcp?: PluginProviderMcpBinding; + capabilities?: string[]; +} + +export interface PluginRuntimeCapability { + type: PluginRuntimeCapabilityType; + provider: RuntimeProviderName; + lifecycle: LifecycleEventName[]; + outputPaths: string[]; + description?: string; +} + +export interface PluginRuntimeManifest { + capabilities?: PluginRuntimeCapability[]; +} + +export interface SquadPluginManifest { + id: string; + name: string; + version: string; + description?: string; + authors?: string[]; + license?: string; + squad?: string; + components?: Partial>; + copilot?: CopilotPluginRequirements; + repository?: PluginRepositoryMetadata; + upstream?: PluginUpstreamMetadata; + mcp?: PluginMcpMetadata; + providers?: PluginProviderContract[]; + runtime?: PluginRuntimeManifest; + files: PluginFileDeployment[]; +} + +export interface PluginValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} + +export interface PluginInstallPlanFile extends PluginFileDeployment { + targetRoot: string; +} + +export interface PluginInstallPlan { + manifest: SquadPluginManifest; + files: PluginInstallPlanFile[]; + dryRun: boolean; +} + +export const PLUGIN_MANIFEST_SCHEMA_VERSION = '0.1'; + +export function parsePluginManifestContent(content: string, fileName = 'squad-plugin.json'): SquadPluginManifest { + const trimmed = content.trim(); + if (!trimmed) { + throw new Error('Plugin manifest is empty'); + } + + const ext = extname(fileName).toLowerCase(); + const raw = ext === '.json' || trimmed.startsWith('{') + ? JSON.parse(trimmed) + : parseDeclarativeYaml(trimmed); + + return normalizePluginManifest(raw); +} + +export function validatePluginManifest(manifest: SquadPluginManifest): PluginValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + validateName('id', manifest.id, errors); + if (!manifest.name || typeof manifest.name !== 'string') { + errors.push('name is required and must be a string'); + } + if (!manifest.version || typeof manifest.version !== 'string') { + errors.push('version is required and must be a string'); + } else if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(manifest.version)) { + errors.push('version must follow semver (e.g. 1.0.0)'); + } + if (manifest.description !== undefined && typeof manifest.description !== 'string') { + errors.push('description must be a string when provided'); + } + if (manifest.authors !== undefined && !manifest.authors.every((author) => typeof author === 'string')) { + errors.push('authors must be an array of strings when provided'); + } + if (manifest.license !== undefined && typeof manifest.license !== 'string') { + errors.push('license must be a string when provided'); + } + if (manifest.squad !== undefined && typeof manifest.squad !== 'string') { + errors.push('squad compatibility must be a string when provided'); + } + validateComponents(manifest.components, errors); + validateCopilotRequirements(manifest.copilot, errors); + validateRepositoryMetadata(manifest.repository, errors); + validateUpstreamMetadata(manifest.upstream, errors, warnings); + validateMcpMetadata(manifest.mcp, errors, warnings); + validateProviderContracts(manifest.providers, errors, warnings); + validateRuntimeManifest(manifest.runtime, errors, warnings); + if (!Array.isArray(manifest.files) || manifest.files.length === 0) { + errors.push('files must include at least one static file deployment'); + } else { + for (const [index, file] of manifest.files.entries()) { + validatePluginFile(file, index, errors, warnings); + } + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +export function derivePluginRoles(manifest: SquadPluginManifest): PluginComponentKind[] { + if (!manifest.components) { + return []; + } + return COMPONENT_KINDS.filter((kind) => { + const value = manifest.components?.[kind]; + if (value === undefined || value === null) { + return false; + } + return !(Array.isArray(value) && value.length === 0); + }); +} + +export function createPluginInstallPlan( + manifest: SquadPluginManifest, + options: { dryRun?: boolean } = {}, +): PluginInstallPlan { + const validation = validatePluginManifest(manifest); + if (!validation.valid) { + throw new Error(`Invalid plugin manifest: ${validation.errors.join('; ')}`); + } + + return { + manifest, + files: manifest.files.map((file) => ({ + ...file, + targetRoot: file.target.split('/')[0]!, + })), + dryRun: options.dryRun ?? false, + }; +} + +function normalizePluginManifest(raw: unknown): SquadPluginManifest { + if (!isRecord(raw)) { + throw new Error('Plugin manifest must be an object'); + } + + const executableKey = findExecutableKey(raw); + if (executableKey) { + throw new Error(`Plugin manifest may not declare executable key "${executableKey}"`); + } + + const filesRaw = raw.files; + if (!Array.isArray(filesRaw)) { + return { + id: readString(raw, 'id'), + name: readString(raw, 'name'), + version: readString(raw, 'version'), + description: readOptionalString(raw, 'description'), + authors: readOptionalStringArray(raw, 'authors'), + license: readOptionalString(raw, 'license'), + squad: readOptionalString(raw, 'squad'), + components: normalizeComponents(raw.components), + copilot: normalizeCopilotRequirements(raw.copilot), + repository: normalizeRepositoryMetadata(raw.repository), + upstream: normalizeUpstreamMetadata(raw.upstream), + mcp: normalizeMcpMetadata(raw.mcp), + providers: normalizeProviderContracts(raw.providers), + runtime: normalizeRuntimeManifest(raw.runtime), + files: [], + }; + } + + return { + id: readString(raw, 'id'), + name: readString(raw, 'name'), + version: readString(raw, 'version'), + description: readOptionalString(raw, 'description'), + authors: readOptionalStringArray(raw, 'authors'), + license: readOptionalString(raw, 'license'), + squad: readOptionalString(raw, 'squad'), + components: normalizeComponents(raw.components), + copilot: normalizeCopilotRequirements(raw.copilot), + repository: normalizeRepositoryMetadata(raw.repository), + upstream: normalizeUpstreamMetadata(raw.upstream), + mcp: normalizeMcpMetadata(raw.mcp), + providers: normalizeProviderContracts(raw.providers), + runtime: normalizeRuntimeManifest(raw.runtime), + files: filesRaw.map((item, index) => normalizePluginFile(item, index)), + }; +} + +function normalizePluginFile(raw: unknown, index: number): PluginFileDeployment { + if (!isRecord(raw)) { + throw new Error(`files[${index}] must be an object`); + } + const file: PluginFileDeployment = { + source: readString(raw, 'source'), + target: readString(raw, 'target'), + }; + const type = readOptionalString(raw, 'type'); + if (type !== undefined) { + file.type = type as PluginFileType; + } + return file; +} + +function validateName(field: string, value: string, errors: string[]): void { + if (!value || typeof value !== 'string') { + errors.push(`${field} is required and must be a string`); + return; + } + if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(value)) { + errors.push(`${field} must be lowercase alphanumeric with hyphens only`); + } +} + +function validatePluginFile( + file: PluginFileDeployment, + index: number, + errors: string[], + warnings: string[], +): void { + if (!file.source || typeof file.source !== 'string') { + errors.push(`files[${index}].source is required and must be a string`); + } else { + validateRelativePath(`files[${index}].source`, file.source, errors); + validateStaticFileExtension(`files[${index}].source`, file.source, errors); + } + + if (!file.target || typeof file.target !== 'string') { + errors.push(`files[${index}].target is required and must be a string`); + } else { + validateRelativePath(`files[${index}].target`, file.target, errors); + validateStaticFileExtension(`files[${index}].target`, file.target, errors); + const root = file.target.split('/')[0]; + if (!root || !ALLOWED_TARGET_ROOTS.has(root)) { + errors.push(`files[${index}].target must start with one of: ${[...ALLOWED_TARGET_ROOTS].join(', ')}`); + } + } + + if (file.type !== undefined) { + const allowedTypes: PluginFileType[] = ['agent', 'asset', 'doc', 'instruction', 'knowledge', 'prompt', 'template', 'workflow']; + if (!allowedTypes.includes(file.type)) { + errors.push(`files[${index}].type is not supported: ${file.type}`); + } + } else { + warnings.push(`files[${index}].type is not specified`); + } +} + +function validateRelativePath(field: string, value: string, errors: string[]): void { + const normalized = normalize(value).replaceAll('\\', '/'); + const segments = normalized.split('/'); + if (isAbsolute(value) || normalized.startsWith('../') || normalized === '..' || segments.includes('..')) { + errors.push(`${field} must be a relative path that does not escape the plugin or .squad directory`); + } + if (segments.some((segment) => segment.length === 0)) { + errors.push(`${field} must not contain empty path segments`); + } +} + +function validateStaticFileExtension(field: string, value: string, errors: string[]): void { + const ext = extname(value).toLowerCase(); + if (EXECUTABLE_EXTENSIONS.has(ext)) { + errors.push(`${field} points to executable or script file type "${ext}"`); + } +} + +function readString(raw: Record, key: string): string { + const value = raw[key]; + return typeof value === 'string' ? value : ''; +} + +function readOptionalString(raw: Record, key: string): string | undefined { + const value = raw[key]; + return typeof value === 'string' ? value : undefined; +} + +function readOptionalStringArray(raw: Record, key: string): string[] | undefined { + const value = raw[key]; + if (!Array.isArray(value)) { + return undefined; + } + return value.filter((item): item is string => typeof item === 'string'); +} + +function readOptionalBoolean(raw: Record, key: string): boolean | undefined { + const value = raw[key]; + return typeof value === 'boolean' ? value : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function findExecutableKey(value: unknown, path = ''): string | undefined { + if (Array.isArray(value)) { + for (const [index, item] of value.entries()) { + const found = findExecutableKey(item, `${path}[${index}]`); + if (found) return found; + } + return undefined; + } + if (!isRecord(value)) { + return undefined; + } + for (const [key, nested] of Object.entries(value)) { + const keyPath = path ? `${path}.${key}` : key; + if (EXECUTABLE_KEYS.has(key.toLowerCase())) { + return keyPath; + } + const found = findExecutableKey(nested, keyPath); + if (found) return found; + } + return undefined; +} + +function normalizeComponents(raw: unknown): Partial> | undefined { + if (raw === undefined) { + return undefined; + } + if (!isRecord(raw)) { + return {}; + } + const components: Partial> = {}; + for (const kind of COMPONENT_KINDS) { + if (raw[kind] !== undefined) { + components[kind] = raw[kind]; + } + } + for (const key of Object.keys(raw)) { + if (!(COMPONENT_KINDS as readonly string[]).includes(key)) { + components[key as PluginComponentKind] = raw[key]; + } + } + return components; +} + +function validateComponents( + components: Partial> | undefined, + errors: string[], +): void { + if (components === undefined) { + return; + } + if (!isRecord(components)) { + errors.push('components must be an object when provided'); + return; + } + for (const key of Object.keys(components)) { + if (!(COMPONENT_KINDS as readonly string[]).includes(key)) { + errors.push(`components.${key} is not supported`); + } + } +} + +function normalizeCopilotRequirements(raw: unknown): CopilotPluginRequirements | undefined { + if (raw === undefined) { + return undefined; + } + if (!isRecord(raw)) { + return {}; + } + const requires = raw.requires; + if (!Array.isArray(requires)) { + return {}; + } + return { + requires: requires.map((item) => normalizeCopilotPluginDependency(item)), + }; +} + +function normalizeCopilotPluginDependency(raw: unknown): CopilotPluginDependency { + if (!isRecord(raw)) { + return { id: '' }; + } + return { + id: readString(raw, 'id'), + version: readOptionalString(raw, 'version'), + optional: readOptionalBoolean(raw, 'optional'), + reason: readOptionalString(raw, 'reason'), + }; +} + +function validateCopilotRequirements( + copilot: CopilotPluginRequirements | undefined, + errors: string[], +): void { + if (copilot === undefined) { + return; + } + if (!isRecord(copilot)) { + errors.push('copilot must be an object when provided'); + return; + } + if (copilot.requires === undefined) { + return; + } + if (!Array.isArray(copilot.requires)) { + errors.push('copilot.requires must be an array when provided'); + return; + } + for (const [index, dependency] of copilot.requires.entries()) { + if (!isRecord(dependency)) { + errors.push(`copilot.requires[${index}] must be an object`); + continue; + } + validateCopilotPluginId(`copilot.requires[${index}].id`, dependency.id, errors); + if (dependency.version !== undefined && typeof dependency.version !== 'string') { + errors.push(`copilot.requires[${index}].version must be a string when provided`); + } + if (dependency.optional !== undefined && typeof dependency.optional !== 'boolean') { + errors.push(`copilot.requires[${index}].optional must be a boolean when provided`); + } + if (dependency.reason !== undefined && typeof dependency.reason !== 'string') { + errors.push(`copilot.requires[${index}].reason must be a string when provided`); + } + } +} + +function validateCopilotPluginId(field: string, value: unknown, errors: string[]): void { + if (!value || typeof value !== 'string') { + errors.push(`${field} is required and must be a string`); + return; + } + if (value.includes('..') || value.startsWith('/') || value.startsWith('\\')) { + errors.push(`${field} must be a package or owner/name identifier, not a path`); + } +} + +function normalizeRepositoryMetadata(raw: unknown): PluginRepositoryMetadata | undefined { + if (raw === undefined) { + return undefined; + } + if (!isRecord(raw)) { + return { url: '' }; + } + return { + type: readOptionalString(raw, 'type'), + url: readString(raw, 'url'), + }; +} + +function normalizeUpstreamMetadata(raw: unknown): PluginUpstreamMetadata | undefined { + if (raw === undefined) { + return undefined; + } + if (!isRecord(raw)) { + return {}; + } + return { + package: readOptionalString(raw, 'package'), + registry: readOptionalString(raw, 'registry'), + installCommand: readOptionalString(raw, 'installCommand'), + docs: readOptionalString(raw, 'docs'), + }; +} + +function normalizeMcpMetadata(raw: unknown): PluginMcpMetadata | undefined { + if (raw === undefined) { + return undefined; + } + if (!isRecord(raw)) { + return {}; + } + return { + available: readOptionalBoolean(raw, 'available'), + server: readOptionalString(raw, 'server'), + entryPoint: readOptionalString(raw, 'entryPoint'), + installCommand: readOptionalString(raw, 'installCommand'), + reason: readOptionalString(raw, 'reason'), + }; +} + +function normalizeProviderContracts(raw: unknown): PluginProviderContract[] | undefined { + if (raw === undefined) { + return undefined; + } + if (!Array.isArray(raw)) { + return []; + } + return raw.map((item) => normalizeProviderContract(item)); +} + +function normalizeProviderContract(raw: unknown): PluginProviderContract { + if (!isRecord(raw)) { + return { id: '', type: 'memory' }; + } + const provider: PluginProviderContract = { + id: readString(raw, 'id'), + type: readString(raw, 'type') as PluginProviderType, + mode: readOptionalString(raw, 'mode') as PluginProviderMode | undefined, + protocol: readOptionalString(raw, 'protocol') as PluginProviderProtocol | undefined, + description: readOptionalString(raw, 'description'), + artifact: readOptionalString(raw, 'artifact'), + mcp: normalizeProviderMcpBinding(raw.mcp), + capabilities: readOptionalStringArray(raw, 'capabilities'), + }; + return provider; +} + +function normalizeProviderMcpBinding(raw: unknown): PluginProviderMcpBinding | undefined { + if (raw === undefined) { + return undefined; + } + if (!isRecord(raw)) { + return {}; + } + return { + server: readOptionalString(raw, 'server'), + tool: readOptionalString(raw, 'tool'), + capability: readOptionalString(raw, 'capability'), + }; +} + +function normalizeRuntimeManifest(raw: unknown): PluginRuntimeManifest | undefined { + if (raw === undefined) { + return undefined; + } + if (!isRecord(raw)) { + return {}; + } + return { + capabilities: normalizeRuntimeCapabilities(raw.capabilities), + }; +} + +function normalizeRuntimeCapabilities(raw: unknown): PluginRuntimeCapability[] | undefined { + if (raw === undefined) { + return undefined; + } + if (!Array.isArray(raw)) { + return []; + } + return raw.map((item) => normalizeRuntimeCapability(item)); +} + +function normalizeRuntimeCapability(raw: unknown): PluginRuntimeCapability { + if (!isRecord(raw)) { + return { + type: 'artifact-generation', + provider: 'graphify', + lifecycle: [], + outputPaths: [], + }; + } + return { + type: readString(raw, 'type') as PluginRuntimeCapabilityType, + provider: readString(raw, 'provider') as RuntimeProviderName, + lifecycle: readOptionalStringArray(raw, 'lifecycle') as LifecycleEventName[] | undefined ?? [], + outputPaths: readOptionalStringArray(raw, 'outputPaths') ?? [], + description: readOptionalString(raw, 'description'), + }; +} + +function validateRepositoryMetadata( + repository: PluginRepositoryMetadata | undefined, + errors: string[], +): void { + if (repository === undefined) { + return; + } + if (!isRecord(repository)) { + errors.push('repository must be an object when provided'); + return; + } + if (repository.type !== undefined && typeof repository.type !== 'string') { + errors.push('repository.type must be a string when provided'); + } + validateUrl('repository.url', repository.url, errors); +} + +function validateUpstreamMetadata( + upstream: PluginUpstreamMetadata | undefined, + errors: string[], + warnings: string[], +): void { + if (upstream === undefined) { + return; + } + if (!isRecord(upstream)) { + errors.push('upstream must be an object when provided'); + return; + } + validateOptionalString('upstream.package', upstream.package, errors); + validateOptionalString('upstream.registry', upstream.registry, errors); + validateOptionalString('upstream.installCommand', upstream.installCommand, errors); + validateOptionalUrl('upstream.docs', upstream.docs, errors); + if (upstream.installCommand) { + warnings.push('upstream.installCommand is metadata only; Squad will not execute it'); + } +} + +function validateMcpMetadata( + mcp: PluginMcpMetadata | undefined, + errors: string[], + warnings: string[], +): void { + if (mcp === undefined) { + return; + } + if (!isRecord(mcp)) { + errors.push('mcp must be an object when provided'); + return; + } + if (mcp.available !== undefined && typeof mcp.available !== 'boolean') { + errors.push('mcp.available must be a boolean when provided'); + } + validateOptionalString('mcp.server', mcp.server, errors); + validateOptionalString('mcp.entryPoint', mcp.entryPoint, errors); + validateOptionalString('mcp.installCommand', mcp.installCommand, errors); + validateOptionalString('mcp.reason', mcp.reason, errors); + if (mcp.installCommand) { + warnings.push('mcp.installCommand is metadata only; Squad will not execute it'); + } +} + +function validateProviderContracts( + providers: PluginProviderContract[] | undefined, + errors: string[], + warnings: string[], +): void { + if (providers === undefined) { + return; + } + if (!Array.isArray(providers)) { + errors.push('providers must be an array when provided'); + return; + } + for (const [index, provider] of providers.entries()) { + const prefix = `providers[${index}]`; + if (!isRecord(provider)) { + errors.push(`${prefix} must be an object`); + continue; + } + validateName(`${prefix}.id`, provider.id, errors); + if (!PROVIDER_TYPES.includes(provider.type)) { + errors.push(`${prefix}.type must be one of: ${PROVIDER_TYPES.join(', ')}`); + } + if (provider.mode !== undefined && !PROVIDER_MODES.includes(provider.mode)) { + errors.push(`${prefix}.mode must be one of: ${PROVIDER_MODES.join(', ')}`); + } + if (provider.protocol !== undefined && !PROVIDER_PROTOCOLS.includes(provider.protocol)) { + errors.push(`${prefix}.protocol must be one of: ${PROVIDER_PROTOCOLS.join(', ')}`); + } + validateOptionalString(`${prefix}.description`, provider.description, errors); + if (provider.artifact !== undefined) { + validateRelativePath(`${prefix}.artifact`, provider.artifact, errors); + validateStaticFileExtension(`${prefix}.artifact`, provider.artifact, errors); + const root = provider.artifact.split('/')[0]; + if (!root || !ALLOWED_TARGET_ROOTS.has(root)) { + errors.push(`${prefix}.artifact must start with one of: ${[...ALLOWED_TARGET_ROOTS].join(', ')}`); + } + } + if (provider.capabilities !== undefined) { + if (!Array.isArray(provider.capabilities)) { + errors.push(`${prefix}.capabilities must be an array when provided`); + } else if (!provider.capabilities.every((capability) => typeof capability === 'string' && capability.trim().length > 0)) { + errors.push(`${prefix}.capabilities must contain only non-empty strings`); + } + } + if (provider.mcp !== undefined) { + if (!isRecord(provider.mcp)) { + errors.push(`${prefix}.mcp must be an object when provided`); + } else { + validateOptionalString(`${prefix}.mcp.server`, provider.mcp.server, errors); + validateOptionalString(`${prefix}.mcp.tool`, provider.mcp.tool, errors); + validateOptionalString(`${prefix}.mcp.capability`, provider.mcp.capability, errors); + warnings.push(`${prefix}.mcp is provider metadata only; Squad will not start MCP servers or call provider tools`); + } + } + } +} + +function validateRuntimeManifest( + runtime: PluginRuntimeManifest | undefined, + errors: string[], + warnings: string[], +): void { + if (runtime === undefined) { + return; + } + if (!isRecord(runtime)) { + errors.push('runtime must be an object when provided'); + return; + } + if (runtime.capabilities === undefined) { + return; + } + if (!Array.isArray(runtime.capabilities)) { + errors.push('runtime.capabilities must be an array when provided'); + return; + } + for (const [index, capability] of runtime.capabilities.entries()) { + const prefix = `runtime.capabilities[${index}]`; + if (!isRecord(capability)) { + errors.push(`${prefix} must be an object`); + continue; + } + if (!(RUNTIME_CAPABILITY_TYPES as readonly unknown[]).includes(capability.type)) { + errors.push(`${prefix}.type must be one of: ${RUNTIME_CAPABILITY_TYPES.join(', ')}`); + } + if (!(APPROVED_RUNTIME_PROVIDERS as readonly unknown[]).includes(capability.provider)) { + errors.push( + `${prefix}.provider "${capability.provider}" is not approved. Allowed providers: ${APPROVED_RUNTIME_PROVIDERS.join(', ')}`, + ); + } + if (!Array.isArray(capability.lifecycle)) { + errors.push(`${prefix}.lifecycle must be an array`); + } else if (capability.lifecycle.length === 0) { + errors.push(`${prefix}.lifecycle must not be empty`); + } else { + for (const [lifecycleIndex, event] of capability.lifecycle.entries()) { + if (typeof event !== 'string') { + errors.push(`${prefix}.lifecycle[${lifecycleIndex}] must be a string`); + } else if (!ALLOWED_LIFECYCLE_EVENTS.includes(event as LifecycleEventName)) { + errors.push( + `${prefix}.lifecycle[${lifecycleIndex}] "${event}" is not allowed. Allowed events: ${ALLOWED_LIFECYCLE_EVENTS.join(', ')}`, + ); + } + } + } + if (!Array.isArray(capability.outputPaths)) { + errors.push(`${prefix}.outputPaths must be an array`); + } else if (capability.outputPaths.length === 0) { + errors.push(`${prefix}.outputPaths must not be empty`); + } else { + for (const [pathIndex, outputPath] of capability.outputPaths.entries()) { + const pathPrefix = `${prefix}.outputPaths[${pathIndex}]`; + if (typeof outputPath !== 'string') { + errors.push(`${pathPrefix} must be a string`); + continue; + } + if (outputPath.trim() === '') { + errors.push(`${pathPrefix} must not be empty`); + continue; + } + validateRelativePath(pathPrefix, outputPath, errors); + validateStaticFileExtension(pathPrefix, outputPath, errors); + const root = outputPath.split('/')[0]; + if (!root || !ALLOWED_TARGET_ROOTS.has(root)) { + errors.push(`${pathPrefix} must start with one of: ${[...ALLOWED_TARGET_ROOTS].join(', ')}`); + } + } + } + validateOptionalString(`${prefix}.description`, capability.description, errors); + warnings.push(`${prefix} uses built-in artifact operations only; Squad will not execute plugin-supplied code`); + } +} + +function validateOptionalString(field: string, value: unknown, errors: string[]): void { + if (value !== undefined && typeof value !== 'string') { + errors.push(`${field} must be a string when provided`); + } +} + +function validateOptionalUrl(field: string, value: unknown, errors: string[]): void { + if (value === undefined) { + return; + } + validateUrl(field, value, errors); +} + +function validateUrl(field: string, value: unknown, errors: string[]): void { + if (!value || typeof value !== 'string') { + errors.push(`${field} is required and must be a URL string`); + return; + } + try { + const parsed = new URL(value); + if (parsed.protocol !== 'https:') { + errors.push(`${field} must use https`); + } + } catch { + errors.push(`${field} must be a valid URL`); + } +} + +function parseDeclarativeYaml(content: string): unknown { + const lines = content + .split(/\r?\n/) + .map((line) => line.replace(/\s+#.*$/, '').trimEnd()) + .filter((line) => line.trim().length > 0 && !line.trimStart().startsWith('#')); + const root: Record = {}; + let currentArrayKey: string | undefined; + let currentArrayItem: Record | undefined; + + for (const line of lines) { + if (!line.startsWith(' ') && !line.startsWith('-')) { + const [key, value] = splitYamlPair(line); + if (!key) { + throw new Error(`Invalid YAML line: ${line}`); + } + if (value === '') { + root[key] = []; + currentArrayKey = key; + currentArrayItem = undefined; + } else { + root[key] = parseYamlScalar(value); + currentArrayKey = undefined; + currentArrayItem = undefined; + } + continue; + } + + const trimmed = line.trimStart(); + if (!currentArrayKey || !Array.isArray(root[currentArrayKey])) { + throw new Error(`Nested YAML is only supported for top-level arrays: ${line}`); + } + + if (trimmed.startsWith('- ')) { + const itemText = trimmed.slice(2); + currentArrayItem = {}; + (root[currentArrayKey] as Record[]).push(currentArrayItem); + if (itemText.length > 0) { + const [key, value] = splitYamlPair(itemText); + currentArrayItem[key] = parseYamlScalar(value); + } + continue; + } + + if (!currentArrayItem) { + throw new Error(`YAML array property appears before an array item: ${line}`); + } + const [key, value] = splitYamlPair(trimmed); + currentArrayItem[key] = parseYamlScalar(value); + } + + return root; +} + +function splitYamlPair(line: string): [string, string] { + const index = line.indexOf(':'); + if (index === -1) { + throw new Error(`Invalid YAML key/value line: ${line}`); + } + return [line.slice(0, index).trim(), line.slice(index + 1).trim()]; +} + +function parseYamlScalar(value: string): unknown { + if (value === '') return ''; + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + return value.slice(1, -1); + } + if (value.startsWith('[') && value.endsWith(']')) { + const inner = value.slice(1, -1).trim(); + if (!inner) return []; + return inner.split(',').map((item) => String(parseYamlScalar(item.trim()))); + } + if (value === 'true') return true; + if (value === 'false') return false; + return value; +} + +export function toPosixRelativePath(pathValue: string): string { + return normalize(pathValue).split(sep).join('/'); +} + +export function describePluginFile(file: PluginFileDeployment): string { + return `${basename(file.source)} → ${file.target}`; +} diff --git a/packages/squad-sdk/src/marketplace/plugin-runtime.ts b/packages/squad-sdk/src/marketplace/plugin-runtime.ts new file mode 100644 index 000000000..c8e0ab15e --- /dev/null +++ b/packages/squad-sdk/src/marketplace/plugin-runtime.ts @@ -0,0 +1,277 @@ +/** + * Plugin runtime execution layer with capability-gated provider operations. + * Implements governed lifecycle hooks and artifact generation without ambient arbitrary execution. + */ + +import { join, normalize, isAbsolute } from 'node:path'; +import type { StorageProvider } from '../storage/index.js'; +import { appendAuditEvent, type PluginAuditState } from './plugin-state.js'; +import type { LifecycleEventName, PluginRuntimeCapability } from './plugin-manifest.js'; + +export const LIFECYCLE_EVENT_NAMES = [ + 'onInstall', + 'onEnable', + 'onDisable', + 'onBeforeSpawn', + 'onAfterTask', + 'onMemoryRefresh', +] as const satisfies readonly LifecycleEventName[]; + +export function isLifecycleEventName(value: string): value is LifecycleEventName { + return (LIFECYCLE_EVENT_NAMES as readonly string[]).includes(value); +} + +/** + * Result of a provider operation execution. + */ +export interface ProviderOperationResult { + success: boolean; + message: string; + artifactsGenerated: string[]; + error?: string; +} + +/** + * Context provided to provider operations. + */ +export interface ProviderOperationContext { + pluginId: string; + version: string; + squadDir: string; + storage: StorageProvider; + lifecycle: LifecycleEventName; +} + +/** + * Registry of approved provider operations. + * Each provider implements bounded, deterministic artifact generation. + */ +const APPROVED_PROVIDERS: Record Promise> = { + /** + * Graphify knowledge graph artifact generator. + * Generates graph.json and GRAPH_REPORT.md from static repo/docs inputs. + * Does NOT install graphifyy CLI or start MCP servers. + */ + graphify: async (ctx: ProviderOperationContext, outputPaths: string[]): Promise => { + const artifactsGenerated: string[] = []; + + try { + // Validate output paths + for (const outputPath of outputPaths) { + if (!isSafeOutputPath(outputPath)) { + return { + success: false, + message: `Rejected unsafe output path: ${outputPath}`, + artifactsGenerated: [], + error: `Output path traversal detected: ${outputPath}`, + }; + } + } + + // Find the graph.json and report paths from outputPaths + const graphJsonPath = outputPaths.find(p => p.endsWith('graph.json')); + const reportPath = outputPaths.find(p => p.endsWith('.md')); + + if (!graphJsonPath) { + return { + success: false, + message: 'No graph.json output path found in capability grant', + artifactsGenerated: [], + error: 'Missing required output path: */graph.json', + }; + } + + // Generate graph.json (simplified static example) + const graphData = { + generated_at: new Date().toISOString(), + plugin_id: ctx.pluginId, + version: ctx.version, + lifecycle: ctx.lifecycle, + nodes: [ + { id: 'plugin-manifest', type: 'config', label: 'Plugin Manifest' }, + { id: 'runtime-capabilities', type: 'feature', label: 'Runtime Capabilities' }, + { id: 'lifecycle-hooks', type: 'feature', label: 'Lifecycle Hooks' }, + ], + edges: [ + { source: 'plugin-manifest', target: 'runtime-capabilities', type: 'declares' }, + { source: 'runtime-capabilities', target: 'lifecycle-hooks', type: 'enables' }, + ], + metadata: { + provider: 'graphify', + mode: 'static-deterministic', + note: 'Generated from manifest and static repo metadata without external CLI execution', + }, + }; + + await ctx.storage.write( + join(ctx.squadDir, graphJsonPath), + JSON.stringify(graphData, null, 2) + '\n', + ); + artifactsGenerated.push(graphJsonPath); + + // Generate GRAPH_REPORT.md if path is specified + if (reportPath) { + const report = [ + '# Graphify Knowledge Graph Report', + '', + `Generated: ${new Date().toISOString()}`, + `Plugin: ${ctx.pluginId}@${ctx.version}`, + `Lifecycle: ${ctx.lifecycle}`, + '', + '## Graph Summary', + '', + `- Nodes: ${graphData.nodes.length}`, + `- Edges: ${graphData.edges.length}`, + '', + '## Nodes', + '', + ...graphData.nodes.map(node => `- **${node.id}** (${node.type}): ${node.label}`), + '', + '## Edges', + '', + ...graphData.edges.map(edge => `- ${edge.source} → ${edge.target} (${edge.type})`), + '', + '## Implementation Notes', + '', + 'This graph was generated by the governed plugin runtime provider.', + 'The graphify provider operates deterministically from static inputs.', + 'No external CLI tools were installed or executed.', + '', + ].join('\n'); + + await ctx.storage.write(join(ctx.squadDir, reportPath), report); + artifactsGenerated.push(reportPath); + } + + return { + success: true, + message: `Generated ${artifactsGenerated.length} Graphify artifacts`, + artifactsGenerated, + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return { + success: false, + message: `Graphify provider failed: ${message}`, + artifactsGenerated, + error: message, + }; + } + }, +}; + +/** + * Execute a lifecycle hook with capability-gated provider operations. + */ +export async function executeLifecycleHook( + pluginId: string, + version: string, + lifecycle: LifecycleEventName, + capabilities: PluginRuntimeCapability[], + storage: StorageProvider, + squadDir: string, + audit: PluginAuditState, +): Promise { + const results: ProviderOperationResult[] = []; + const now = new Date().toISOString(); + + // Find capabilities that apply to this lifecycle event + const applicableCapabilities = capabilities.filter(cap => + cap.lifecycle.includes(lifecycle) + ); + + if (applicableCapabilities.length === 0) { + appendAuditEvent(audit, { + type: 'execution_blocked', + plugin_id: pluginId, + version, + timestamp: now, + message: `Lifecycle ${lifecycle}: no applicable capabilities`, + }); + return results; + } + + // Execute each applicable capability's provider operation + for (const capability of applicableCapabilities) { + const provider = APPROVED_PROVIDERS[capability.provider]; + + if (!provider) { + const errorResult: ProviderOperationResult = { + success: false, + message: `Unsupported provider: ${capability.provider}`, + artifactsGenerated: [], + error: `Provider "${capability.provider}" is not in approved provider registry`, + }; + results.push(errorResult); + + appendAuditEvent(audit, { + type: 'execution_blocked', + plugin_id: pluginId, + version, + timestamp: now, + message: `Lifecycle ${lifecycle}: provider "${capability.provider}" rejected (not approved)`, + }); + continue; + } + + // Validate capability type + if (capability.type !== 'artifact-generation') { + const errorResult: ProviderOperationResult = { + success: false, + message: `Unsupported capability type: ${capability.type}`, + artifactsGenerated: [], + error: `Only "artifact-generation" capability type is currently supported`, + }; + results.push(errorResult); + + appendAuditEvent(audit, { + type: 'execution_blocked', + plugin_id: pluginId, + version, + timestamp: now, + message: `Lifecycle ${lifecycle}: capability type "${capability.type}" rejected`, + }); + continue; + } + + // Execute the provider operation + const ctx: ProviderOperationContext = { + pluginId, + version, + squadDir, + storage, + lifecycle, + }; + + const result = await provider(ctx, capability.outputPaths); + results.push(result); + + appendAuditEvent(audit, { + type: result.success ? 'provider_invoked' : 'provider_error', + plugin_id: pluginId, + version, + timestamp: now, + message: `Lifecycle ${lifecycle}: provider "${capability.provider}" ${result.success ? 'succeeded' : 'failed'} - ${result.message}`, + }); + } + + return results; +} + +/** + * Validate that an output path is safe (no traversal, no absolute paths). + */ +function isSafeOutputPath(outputPath: string): boolean { + const normalized = normalize(outputPath).replaceAll('\\', '/'); + return !isAbsolute(outputPath) + && normalized !== '..' + && !normalized.startsWith('../') + && !normalized.split('/').includes('..'); +} + +/** + * Get approved provider names for validation. + */ +export function getApprovedProviderNames(): string[] { + return Object.keys(APPROVED_PROVIDERS); +} diff --git a/packages/squad-sdk/src/marketplace/plugin-state.ts b/packages/squad-sdk/src/marketplace/plugin-state.ts new file mode 100644 index 000000000..70e0b8cb3 --- /dev/null +++ b/packages/squad-sdk/src/marketplace/plugin-state.ts @@ -0,0 +1,434 @@ +import { createHash } from 'node:crypto'; +import { isAbsolute, join, normalize } from 'node:path'; +import type { StorageProvider } from '../storage/index.js'; +import { + derivePluginRoles, + type CopilotPluginRequirements, + type PluginMcpMetadata, + type PluginComponentKind, + type PluginFileDeployment, + type PluginProviderContract, + type PluginRepositoryMetadata, + type PluginRuntimeManifest, + type PluginUpstreamMetadata, + type SquadPluginManifest, +} from './plugin-manifest.js'; + +export const PLUGIN_STATE_DIR = 'plugins'; +export const INSTALLED_PLUGINS_FILE = join(PLUGIN_STATE_DIR, 'installed.json'); +export const PLUGIN_LOCK_FILE = join(PLUGIN_STATE_DIR, 'lock.json'); +export const PLUGIN_RUNTIME_FILE = join(PLUGIN_STATE_DIR, 'runtime.json'); +export const PLUGIN_AUDIT_FILE = join(PLUGIN_STATE_DIR, 'audit.jsonl'); + +export interface InstalledPluginFile extends PluginFileDeployment { + sha256: string; +} + +export interface InstalledPlugin { + id: string; + name: string; + version: string; + enabled: boolean; + source: string; + installed_at: string; + roles: PluginComponentKind[]; + copilot?: CopilotPluginRequirements; + repository?: PluginRepositoryMetadata; + upstream?: PluginUpstreamMetadata; + mcp?: PluginMcpMetadata; + providers?: PluginProviderContract[]; + runtime?: PluginRuntimeManifest; + files: InstalledPluginFile[]; +} + +export interface InstalledPluginsState { + plugins: InstalledPlugin[]; +} + +export interface PluginLockEntry { + id: string; + version: string; + source: string; + manifest_sha256: string; + files: InstalledPluginFile[]; + installed_at: string; +} + +export interface PluginLockState { + plugins: Record; +} + +export interface PluginRuntimeEntry { + enabled: boolean; + active_version: string; + updated_at: string; +} + +export interface PluginRuntimeState { + plugins: Record; + active: Partial>; +} + +export type PluginAuditEventType = + | 'install' + | 'enable' + | 'disable' + | 'switch' + | 'uninstall' + | 'verify' + | 'provider_invoked' + | 'provider_error' + | 'execution_blocked'; + +export interface PluginAuditEvent { + type: PluginAuditEventType; + plugin_id: string; + version: string; + timestamp: string; + message: string; +} + +export interface PluginAuditState { + events: PluginAuditEvent[]; +} + +export interface PluginStates { + installed: InstalledPluginsState; + lock: PluginLockState; + runtime: PluginRuntimeState; + audit: PluginAuditState; +} + +export interface ActivePluginContextOptions { + /** + * Maximum number of characters to include from each installed artifact. + * Keeps spawned-agent prompts bounded while still proving the static guidance + * is consumed. Defaults to 12k per artifact. + */ + maxArtifactChars?: number; +} + +export async function readPluginStates(storage: StorageProvider, stateRoot = ''): Promise { + const [installed, lock, runtime, audit] = await Promise.all([ + readJsonState(storage, join(stateRoot, INSTALLED_PLUGINS_FILE), { plugins: [] }), + readJsonState(storage, join(stateRoot, PLUGIN_LOCK_FILE), { plugins: {} }), + readJsonState(storage, join(stateRoot, PLUGIN_RUNTIME_FILE), { plugins: {}, active: {} }), + readAuditState(storage, join(stateRoot, PLUGIN_AUDIT_FILE)), + ]); + runtime.active ??= {}; + + return { installed, lock, runtime, audit }; +} + +export async function buildActivePluginContext( + storage: StorageProvider, + stateRoot = '', + options: ActivePluginContextOptions = {}, +): Promise { + const states = await readPluginStates(storage, stateRoot); + const maxArtifactChars = options.maxArtifactChars ?? 12_000; + const activePluginIds = Array.from(new Set( + Object.values(states.runtime.active) + .filter((pluginId): pluginId is string => typeof pluginId === 'string') + .filter((pluginId) => states.runtime.plugins[pluginId]?.enabled === true), + )).sort((a, b) => a.localeCompare(b)); + + if (activePluginIds.length === 0) { + return ''; + } + + const sections: string[] = [ + '## Active Squad Plugins', + [ + 'The following enabled Squad plugins contributed declarative static artifacts.', + 'Squad has not installed upstream packages, started MCP servers, or run external plugin commands.', + 'Use these artifacts as guidance and metadata when they are relevant to the task.', + ].join(' '), + ]; + + for (const pluginId of activePluginIds) { + const plugin = states.installed.plugins.find((candidate) => candidate.id === pluginId); + if (!plugin?.enabled) { + continue; + } + + const activeRoles = Object.entries(states.runtime.active) + .filter(([, activePluginId]) => activePluginId === plugin.id) + .map(([role]) => role) + .sort((a, b) => a.localeCompare(b)); + + sections.push([ + `### ${plugin.name} (${plugin.id}@${plugin.version})`, + `Roles: ${activeRoles.length > 0 ? activeRoles.join(', ') : plugin.roles.join(', ')}`, + plugin.repository?.url ? `Repository: ${plugin.repository.url}` : undefined, + plugin.upstream?.package ? `Upstream package: ${plugin.upstream.package}${plugin.upstream.registry ? ` (${plugin.upstream.registry})` : ''}` : undefined, + plugin.mcp?.available ? `MCP metadata: ${plugin.mcp.server ?? plugin.mcp.entryPoint ?? 'available'} (metadata only)` : undefined, + ].filter((line): line is string => typeof line === 'string').join('\n')); + + if (plugin.providers && plugin.providers.length > 0) { + sections.push(formatProviderContracts(plugin.providers)); + } + + for (const file of [...plugin.files].sort((a, b) => a.target.localeCompare(b.target))) { + if (!isSafeInstalledPluginTarget(file.target)) { + sections.push(`#### Installed artifact: .squad/${file.target}\nSkipped unsafe plugin state target.`); + continue; + } + const content = await storage.read(join(stateRoot, file.target)); + if (content === undefined) { + sections.push(`#### Installed artifact: .squad/${file.target}\nArtifact listed in plugin state but not found on disk.`); + continue; + } + + const trimmed = content.trim(); + const excerpt = trimmed.length > maxArtifactChars + ? `${trimmed.slice(0, maxArtifactChars)}\n\n[truncated: ${trimmed.length - maxArtifactChars} characters omitted]` + : trimmed; + sections.push(`#### Installed artifact: .squad/${file.target}\n${excerpt}`); + } + } + + return sections.join('\n\n'); +} + +export async function writePluginStates(storage: StorageProvider, states: PluginStates, stateRoot = ''): Promise { + await storage.mkdir(join(stateRoot, PLUGIN_STATE_DIR), { recursive: true }); + await Promise.all([ + writeJsonState(storage, join(stateRoot, INSTALLED_PLUGINS_FILE), states.installed), + writeJsonState(storage, join(stateRoot, PLUGIN_LOCK_FILE), states.lock), + writeJsonState(storage, join(stateRoot, PLUGIN_RUNTIME_FILE), states.runtime), + writeAuditState(storage, join(stateRoot, PLUGIN_AUDIT_FILE), states.audit), + ]); +} + +export function upsertInstalledPlugin( + states: PluginStates, + manifest: SquadPluginManifest, + options: { + source: string; + manifestContent: string; + files: InstalledPluginFile[]; + now?: string; + }, +): InstalledPlugin { + const now = options.now ?? new Date().toISOString(); + const existing = states.installed.plugins.find((plugin) => plugin.id === manifest.id); + const enabled = existing?.enabled ?? false; + const installed: InstalledPlugin = { + id: manifest.id, + name: manifest.name, + version: manifest.version, + enabled, + source: options.source, + installed_at: now, + roles: derivePluginRoles(manifest), + copilot: manifest.copilot, + repository: manifest.repository, + upstream: manifest.upstream, + mcp: manifest.mcp, + providers: manifest.providers, + runtime: manifest.runtime, + files: options.files, + }; + + states.installed.plugins = [ + ...states.installed.plugins.filter((plugin) => plugin.id !== manifest.id), + installed, + ].sort((a, b) => a.id.localeCompare(b.id)); + + states.lock.plugins[manifest.id] = { + id: manifest.id, + version: manifest.version, + source: options.source, + manifest_sha256: sha256(options.manifestContent), + files: options.files, + installed_at: now, + }; + + states.runtime.plugins[manifest.id] = { + enabled, + active_version: manifest.version, + updated_at: now, + }; + + appendAuditEvent(states.audit, { + type: 'install', + plugin_id: manifest.id, + version: manifest.version, + timestamp: now, + message: `Installed ${manifest.id}@${manifest.version}`, + }); + + return installed; +} + +export function setPluginEnabled( + states: PluginStates, + pluginId: string, + enabled: boolean, + now = new Date().toISOString(), +): InstalledPlugin { + const plugin = states.installed.plugins.find((candidate) => candidate.id === pluginId); + if (!plugin) { + throw new Error(`Plugin "${pluginId}" is not installed`); + } + + plugin.enabled = enabled; + states.runtime.plugins[plugin.id] = { + enabled, + active_version: plugin.version, + updated_at: now, + }; + if (!enabled) { + for (const [role, activePluginId] of Object.entries(states.runtime.active)) { + if (activePluginId === plugin.id) { + delete states.runtime.active[role as PluginComponentKind]; + } + } + } else { + for (const role of plugin.roles) { + const activePluginId = states.runtime.active[role]; + if (activePluginId && activePluginId !== plugin.id) { + throw new Error(`Role "${role}" is already active for plugin "${activePluginId}"`); + } + states.runtime.active[role] = plugin.id; + } + } + appendAuditEvent(states.audit, { + type: enabled ? 'enable' : 'disable', + plugin_id: plugin.id, + version: plugin.version, + timestamp: now, + message: `${enabled ? 'Enabled' : 'Disabled'} ${plugin.id}@${plugin.version}`, + }); + return plugin; +} + +export function switchActivePlugin( + states: PluginStates, + role: PluginComponentKind, + pluginId: string, + now = new Date().toISOString(), +): InstalledPlugin { + const plugin = states.installed.plugins.find((candidate) => candidate.id === pluginId); + if (!plugin) { + throw new Error(`Plugin "${pluginId}" is not installed`); + } + if (!plugin.roles.includes(role)) { + throw new Error(`Plugin "${pluginId}" does not declare role "${role}"`); + } + plugin.enabled = true; + states.runtime.active[role] = plugin.id; + states.runtime.plugins[plugin.id] = { + enabled: true, + active_version: plugin.version, + updated_at: now, + }; + appendAuditEvent(states.audit, { + type: 'switch', + plugin_id: plugin.id, + version: plugin.version, + timestamp: now, + message: `Switched ${role} to ${plugin.id}@${plugin.version}`, + }); + return plugin; +} + +export function removeInstalledPlugin(states: PluginStates, pluginId: string, now = new Date().toISOString()): InstalledPlugin { + const plugin = states.installed.plugins.find((candidate) => candidate.id === pluginId); + if (!plugin) { + throw new Error(`Plugin "${pluginId}" is not installed`); + } + states.installed.plugins = states.installed.plugins.filter((candidate) => candidate.id !== pluginId); + delete states.lock.plugins[pluginId]; + delete states.runtime.plugins[pluginId]; + for (const [role, activePluginId] of Object.entries(states.runtime.active)) { + if (activePluginId === pluginId) { + delete states.runtime.active[role as PluginComponentKind]; + } + } + appendAuditEvent(states.audit, { + type: 'uninstall', + plugin_id: plugin.id, + version: plugin.version, + timestamp: now, + message: `Uninstalled ${plugin.id}@${plugin.version}`, + }); + return plugin; +} + +export function appendAuditEvent(audit: PluginAuditState, event: PluginAuditEvent): void { + audit.events = [...audit.events, event]; +} + +export function sha256(content: string | Uint8Array): string { + return createHash('sha256').update(content).digest('hex'); +} + +async function readJsonState(storage: StorageProvider, filePath: string, fallback: T): Promise { + const content = await storage.read(filePath); + if (content === undefined) { + return fallback; + } + try { + return JSON.parse(content) as T; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Could not parse ${filePath}: ${message}`); + } +} + +async function writeJsonState(storage: StorageProvider, filePath: string, data: unknown): Promise { + await storage.write(filePath, `${JSON.stringify(data, null, 2)}\n`); +} + +async function readAuditState(storage: StorageProvider, filePath: string): Promise { + const content = await storage.read(filePath); + if (content === undefined || content.trim() === '') { + return { events: [] }; + } + const events = content + .split(/\r?\n/) + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as PluginAuditEvent); + return { events }; +} + +async function writeAuditState(storage: StorageProvider, filePath: string, audit: PluginAuditState): Promise { + const content = audit.events.map((event) => JSON.stringify(event)).join('\n'); + await storage.write(filePath, content.length > 0 ? `${content}\n` : ''); +} + +function isSafeInstalledPluginTarget(target: string): boolean { + const normalized = normalize(target).replaceAll('\\', '/'); + return !isAbsolute(target) + && normalized !== '..' + && !normalized.startsWith('../') + && !normalized.split('/').includes('..'); +} + +function formatProviderContracts(providers: PluginProviderContract[]): string { + const lines = [ + '#### Provider contracts', + 'Provider contracts are declarative metadata only. Squad does not start MCP servers, call provider tools, or query live provider backends during agent spawn.', + ]; + + for (const provider of [...providers].sort((a, b) => a.id.localeCompare(b.id))) { + const details = [ + `type=${provider.type}`, + provider.mode ? `mode=${provider.mode}` : undefined, + provider.protocol ? `protocol=${provider.protocol}` : undefined, + provider.artifact ? `artifact=.squad/${provider.artifact}` : undefined, + provider.capabilities && provider.capabilities.length > 0 ? `capabilities=${provider.capabilities.join(', ')}` : undefined, + provider.mcp?.server ? `mcp.server=${provider.mcp.server}` : undefined, + provider.mcp?.tool ? `mcp.tool=${provider.mcp.tool}` : undefined, + provider.mcp?.capability ? `mcp.capability=${provider.mcp.capability}` : undefined, + ].filter((detail): detail is string => typeof detail === 'string'); + lines.push(`- ${provider.id}: ${details.join('; ')}`); + if (provider.description) { + lines.push(` ${provider.description}`); + } + } + + return lines.join('\n'); +} diff --git a/packages/squad-sdk/templates/squad.agent.md.template b/packages/squad-sdk/templates/squad.agent.md.template index 73ba6d311..05a6dce47 100644 --- a/packages/squad-sdk/templates/squad.agent.md.template +++ b/packages/squad-sdk/templates/squad.agent.md.template @@ -976,7 +976,7 @@ prompt: | Tasks (in order): {% if STATE_BACKEND == "orphan" or STATE_BACKEND == "git-notes" or STATE_BACKEND == "two-layer" %} - 0. STATE LEAK GUARD: Check if any agent accidentally committed or staged state files + 0. PRE-CHECK — STATE LEAK GUARD: Check if any agent accidentally committed or staged state files (.squad/decisions.md, agents/*/history.md, log/*, orchestration-log/*, decisions/inbox/*) to the working branch. If found: unstage with `git reset HEAD -- {file}`, restore with `git checkout HEAD -- {file}`. If leaked in last commit, amend to remove. Log count. diff --git a/samples/README.md b/samples/README.md index efd8b27aa..50ac89616 100644 --- a/samples/README.md +++ b/samples/README.md @@ -36,6 +36,9 @@ The `hello-squad` sample demonstrates core casting and onboarding mechanics in ~ | [skill-discovery](./skill-discovery) | Intermediate | Agents write, share, and promote `SKILL.md` files; team knowledge base grows | Skills system, confidence levels, knowledge sharing | | [autonomous-pipeline](./autonomous-pipeline) | Advanced | Full showcase: casting, governance, cost tracking, streaming, monitoring, live dashboard | All core APIs, terminal UI, comprehensive example | | [azure-function-squad](./azure-function-squad) | Intermediate | Serverless multi-agent review: HTTP endpoint → three specialist agents → JSON report | Azure Functions v4, SDK-First builder, JSON output | +| [plugin-knowledge-graphify](./plugin-knowledge-graphify) | Intermediate | Declarative plugin example for the real `safishamsi/graphify` knowledge graph tool | `squad plugin`, `knowledge`, external metadata | +| [plugin-knowledge-index-server](./plugin-knowledge-index-server) | Intermediate | Declarative plugin example for the real `jagilber-org/index-server` MCP instruction index | `squad plugin`, `knowledge`, `instructions`, MCP metadata | +| [plugin-memory-mempalace](./plugin-memory-mempalace) | Intermediate | Declarative plugin example for the real `MemPalace/mempalace` memory CLI/MCP system | `squad plugin`, `memory`, external metadata | ## Recommended learning path @@ -83,4 +86,3 @@ See each sample's `README.md` for detailed setup, expected output, and customiza ## Questions? Open an issue or check the [Squad SDK documentation](../../README.md). - diff --git a/samples/plugin-knowledge-graphify/README.md b/samples/plugin-knowledge-graphify/README.md new file mode 100644 index 000000000..07b2e316e --- /dev/null +++ b/samples/plugin-knowledge-graphify/README.md @@ -0,0 +1,39 @@ +# Graphify Knowledge Graph Plugin Example + +This is a local Squad plugin example for integrating with the real Graphify project: . + +Graphify is a code and documentation knowledge graph tool. It is not modeled here as a Squad memory provider or as a Copilot plugin. + +It demonstrates: + +- declaring the Squad `knowledge` component +- recording upstream package metadata for PyPI package `graphifyy` +- installing Graphify usage guidance under `.squad/knowledge/` +- keeping the Squad/Copilot boundary intact + +## Try it + +```bash +squad plugin validate . +squad plugin dry-run . +squad plugin install . +squad plugin enable graphify-knowledge +squad plugin switch knowledge graphify-knowledge +squad plugin list --json +``` + +## Real Graphify setup + +Install and configure Graphify separately from the Squad plugin lifecycle: + +```bash +uv tool install graphifyy +graphify install --platform copilot +graphify query "How does this repository fit together?" +``` + +Graphify produces artifacts such as `graphify-out/graph.html`, `graphify-out/graph.json`, and `graphify-out/GRAPH_REPORT.md`. + +## Important + +This example is declarative only. Squad does not install `graphifyy`, run `graphify`, install Copilot skills, or execute Graphify code during plugin install. diff --git a/samples/plugin-knowledge-graphify/knowledge/graphify-integration.md b/samples/plugin-knowledge-graphify/knowledge/graphify-integration.md new file mode 100644 index 000000000..4a506e47e --- /dev/null +++ b/samples/plugin-knowledge-graphify/knowledge/graphify-integration.md @@ -0,0 +1,34 @@ +# Graphify Knowledge Graph Integration + +Graphify is the `safishamsi/graphify` knowledge graph tool for codebases and documentation. The published Python package is `graphifyy`, and the CLI command is `graphify`. + +## What this plugin contributes + +- A `knowledge` role declaration for teams that use Graphify alongside Squad. +- Static guidance describing where Graphify fits in the Squad workflow. +- Upstream metadata that points to the real repository and PyPI package. + +## Real setup + +Install and configure Graphify outside the Squad plugin lifecycle: + +```bash +uv tool install graphifyy +graphify install --platform copilot +graphify query "What are the major systems in this repo?" +graphify path "PluginManifest" "PluginState" +``` + +Useful Graphify outputs include: + +| Artifact | Purpose | +| --- | --- | +| `graphify-out/graph.html` | Interactive graph visualization | +| `graphify-out/graph.json` | Machine-readable graph data | +| `graphify-out/GRAPH_REPORT.md` | Markdown analysis report | + +## Boundary + +This sample does not execute Graphify code. It is a declarative Squad plugin that installs static knowledge guidance under `.squad/knowledge/` and records external metadata only. + +Graphify's Copilot support is a separately installed skill/integration. Squad does not install `graphifyy`, run `graphify`, install Copilot skills, or treat Graphify as a memory provider. diff --git a/samples/plugin-knowledge-graphify/plugin.manifest.json b/samples/plugin-knowledge-graphify/plugin.manifest.json new file mode 100644 index 000000000..1999687f3 --- /dev/null +++ b/samples/plugin-knowledge-graphify/plugin.manifest.json @@ -0,0 +1,69 @@ +{ + "id": "graphify-knowledge", + "name": "Graphify Knowledge Graph", + "version": "1.0.0", + "description": "Example Squad knowledge plugin that documents integration with the safishamsi/graphify code knowledge graph tool.", + "authors": [ + "Squad" + ], + "license": "MIT", + "squad": ">=0.9.1", + "components": { + "knowledge": [ + "graphify" + ] + }, + "repository": { + "type": "github", + "url": "https://github.com/safishamsi/graphify" + }, + "upstream": { + "package": "graphifyy", + "registry": "pypi", + "installCommand": "uv tool install graphifyy", + "docs": "https://github.com/safishamsi/graphify" + }, + "mcp": { + "available": false, + "reason": "Graphify is represented here as a CLI and Copilot skill integration, not a Squad-managed MCP memory provider." + }, + "providers": [ + { + "id": "graphify", + "type": "knowledge", + "mode": "read", + "protocol": "static-artifact", + "description": "Knowledge graph provider contract for code relationship guidance already materialized as static artifacts.", + "artifact": "knowledge/graphify/graphify-integration.md", + "capabilities": [ + "knowledge-graph", + "code-relationship-analysis", + "static-report-guidance" + ] + } + ], + "runtime": { + "capabilities": [ + { + "type": "artifact-generation", + "provider": "graphify", + "lifecycle": [ + "onEnable", + "onMemoryRefresh" + ], + "outputPaths": [ + "knowledge/graphify/graph.json", + "knowledge/graphify/GRAPH_REPORT.md" + ], + "description": "Generate deterministic static knowledge graph artifacts without installing or executing the external graphifyy CLI." + } + ] + }, + "files": [ + { + "source": "knowledge/graphify-integration.md", + "target": "knowledge/graphify/graphify-integration.md", + "type": "doc" + } + ] +} diff --git a/samples/plugin-knowledge-index-server/README.md b/samples/plugin-knowledge-index-server/README.md new file mode 100644 index 000000000..863e89c84 --- /dev/null +++ b/samples/plugin-knowledge-index-server/README.md @@ -0,0 +1,56 @@ +# Index Server Knowledge Plugin Example + +This is a local Squad plugin example for integrating with the real Index Server project: . + +Index Server is a governed MCP knowledge base for AI agents. It indexes instructions and shared knowledge with search, CRUD, validation, versioning, audit trails, approval workflows, and cross-repo knowledge promotion. It is not a Squad memory provider, but it is adjacent to memory because agents can use it to persist and retrieve validated knowledge across sessions and repositories. + +It demonstrates: + +- declaring Squad `knowledge` and `instructions` components +- recording upstream package metadata for npm package `@jagilber-org/index-server` +- documenting the optional `index-server` MCP server without starting it +- keeping the Squad/MCP boundary intact + +## Try it + +```bash +squad plugin validate . +squad plugin dry-run . +squad plugin install . +squad plugin enable index-server-knowledge +squad plugin switch knowledge index-server-knowledge +squad plugin list --json +``` + +## Real Index Server setup + +Install and configure Index Server separately from the Squad plugin lifecycle: + +```bash +npm install -g @jagilber-org/index-server +index-server --setup +index-server --dashboard +``` + +Copilot CLI MCP configuration can use: + +```json +{ + "mcpServers": { + "index-server": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@jagilber-org/index-server@latest", "--dashboard"], + "env": { + "INDEX_SERVER_DIR": "C:/mcp/index-data/instructions", + "INDEX_SERVER_LOG_LEVEL": "info" + }, + "tools": ["*"] + } + } +} +``` + +## Important + +This example is declarative only. Squad does not install `@jagilber-org/index-server`, run `index-server`, start MCP, mutate MCP client config, or execute Index Server code during plugin install. diff --git a/samples/plugin-knowledge-index-server/knowledge/index-server-integration.md b/samples/plugin-knowledge-index-server/knowledge/index-server-integration.md new file mode 100644 index 000000000..fe8e38f47 --- /dev/null +++ b/samples/plugin-knowledge-index-server/knowledge/index-server-integration.md @@ -0,0 +1,52 @@ +# Index Server Knowledge Integration + +Index Server is the `jagilber-org/index-server` MCP instruction indexing server. Its package is `@jagilber-org/index-server`, its command is `index-server`, and its MCP server name is `io.github.jagilber-org/index-server`. + +## How it relates to Squad memory + +Index Server is not a memory provider. It is a governed instruction and knowledge catalog that agents can query and update through MCP. + +Use it alongside memory plugins when: + +| Need | Better fit | +| --- | --- | +| Durable validated team instructions | Index Server | +| Cross-repo standards and reusable knowledge | Index Server | +| Spatial/session memory with palace metaphors | MemPalace | +| Code and docs relationship graph | Graphify | +| Squad role activation and installed guidance | Squad plugin lifecycle | + +## Real setup + +Install and configure Index Server outside the Squad plugin lifecycle: + +```bash +npm install -g @jagilber-org/index-server +index-server --setup +index-server --dashboard +``` + +Copilot CLI MCP configuration can use: + +```json +{ + "mcpServers": { + "index-server": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@jagilber-org/index-server@latest", "--dashboard"], + "env": { + "INDEX_SERVER_DIR": "C:/mcp/index-data/instructions", + "INDEX_SERVER_LOG_LEVEL": "info" + }, + "tools": ["*"] + } + } +} +``` + +## Boundary + +This sample does not execute Index Server code. It is a declarative Squad plugin that installs static knowledge guidance under `.squad/knowledge/` and records external MCP metadata only. + +Squad does not install npm packages, run `index-server`, start dashboards, update MCP client config, or call Index Server tools during plugin install. diff --git a/samples/plugin-knowledge-index-server/plugin.manifest.json b/samples/plugin-knowledge-index-server/plugin.manifest.json new file mode 100644 index 000000000..de45c89e2 --- /dev/null +++ b/samples/plugin-knowledge-index-server/plugin.manifest.json @@ -0,0 +1,63 @@ +{ + "id": "index-server-knowledge", + "name": "Index Server Knowledge", + "version": "1.0.0", + "description": "Example Squad knowledge plugin that documents integration with the jagilber-org/index-server MCP instruction index.", + "authors": [ + "Squad" + ], + "license": "MIT", + "squad": ">=0.9.1", + "components": { + "knowledge": [ + "index-server" + ], + "instructions": [ + "index-server" + ] + }, + "repository": { + "type": "github", + "url": "https://github.com/jagilber-org/index-server" + }, + "upstream": { + "package": "@jagilber-org/index-server", + "registry": "npm", + "installCommand": "npm install -g @jagilber-org/index-server", + "docs": "https://github.com/jagilber-org/index-server" + }, + "mcp": { + "available": true, + "server": "index-server", + "entryPoint": "index-server", + "installCommand": "npx -y @jagilber-org/index-server@latest --dashboard", + "reason": "Index Server provides an MCP-governed instruction and knowledge catalog. Squad records the metadata but does not start it." + }, + "providers": [ + { + "id": "index-server", + "type": "knowledge", + "mode": "read", + "protocol": "mcp", + "description": "Governed instruction and knowledge catalog provider contract.", + "artifact": "knowledge/index-server/index-server-integration.md", + "mcp": { + "server": "index-server", + "tool": "query-index", + "capability": "governed-knowledge-catalog" + }, + "capabilities": [ + "governed-instructions", + "knowledge-catalog", + "team-standards" + ] + } + ], + "files": [ + { + "source": "knowledge/index-server-integration.md", + "target": "knowledge/index-server/index-server-integration.md", + "type": "doc" + } + ] +} diff --git a/samples/plugin-memory-mempalace/README.md b/samples/plugin-memory-mempalace/README.md new file mode 100644 index 000000000..5bbc78e02 --- /dev/null +++ b/samples/plugin-memory-mempalace/README.md @@ -0,0 +1,36 @@ +# MemPalace Memory Plugin Example + +This is a local Squad plugin example for a memory-palace-style provider. + +It demonstrates: + +- declaring the Squad `memory` component +- installing memory provider guidance under `.squad/memory/` +- recording upstream package metadata for PyPI package `mempalace` +- documenting the optional `mempalace-mcp` server without starting it +- keeping the Squad/Copilot boundary intact + +## Try it + +```bash +squad plugin validate . +squad plugin dry-run . +squad plugin install . +squad plugin enable mempalace-memory +squad plugin switch memory mempalace-memory +squad plugin list --json +``` + +## Important + +This example is declarative only. Squad does not install `mempalace`, run `mempalace`, start `mempalace-mcp`, install assistant hooks, or execute memory provider code during plugin install. + +Install and configure MemPalace separately: + +```bash +pip install mempalace +mempalace init ~/projects/myapp +mempalace mine ~/projects/myapp +mempalace search "important design decision" +mempalace wake-up +``` diff --git a/samples/plugin-memory-mempalace/memory/mempalace-provider.md b/samples/plugin-memory-mempalace/memory/mempalace-provider.md new file mode 100644 index 000000000..9d9475767 --- /dev/null +++ b/samples/plugin-memory-mempalace/memory/mempalace-provider.md @@ -0,0 +1,37 @@ +# MemPalace Memory Provider + +MemPalace is an example memory provider profile for teams that want to arrange Squad memory as rooms, shelves, trails, and landmarks. + +## What this plugin contributes + +- A `memory` role declaration for a spatial memory provider. +- Static guidance describing how MemPalace-style memory can be organized. +- Upstream metadata for the real `MemPalace/mempalace` project and PyPI package. +- Optional MCP metadata for the `mempalace-mcp` server. + +## Intended memory shape + +| Memory item | Palace representation | +| --- | --- | +| Project context | Room | +| Agent learnings | Shelf | +| Decisions | Landmark | +| Open work | Trail | +| Repeated patterns | Anchor | + +## Boundary + +This sample does not execute MemPalace code. It is a declarative Squad plugin that installs static memory guidance under `.squad/memory/` and records external metadata only. + +Install and run MemPalace separately: + +```bash +pip install mempalace +mempalace init ~/projects/myapp +mempalace mine ~/projects/myapp +mempalace mine ~/.claude/projects/ --mode convos +mempalace search "query" +mempalace wake-up +``` + +MemPalace also exposes `mempalace-mcp` for assistant environments that support MCP. Squad records that metadata but does not start the server, configure MCP clients, or install assistant hooks. diff --git a/samples/plugin-memory-mempalace/plugin.manifest.json b/samples/plugin-memory-mempalace/plugin.manifest.json new file mode 100644 index 000000000..4a103c103 --- /dev/null +++ b/samples/plugin-memory-mempalace/plugin.manifest.json @@ -0,0 +1,62 @@ +{ + "id": "mempalace-memory", + "name": "MemPalace Memory", + "version": "1.0.0", + "description": "Example Squad memory provider plugin that describes a memory-palace-style knowledge layout.", + "authors": [ + "Squad" + ], + "license": "MIT", + "squad": ">=0.9.1", + "components": { + "memory": { + "provider": "mempalace", + "mode": "spatial", + "external": true + } + }, + "repository": { + "type": "github", + "url": "https://github.com/MemPalace/mempalace" + }, + "upstream": { + "package": "mempalace", + "registry": "pypi", + "installCommand": "pip install mempalace", + "docs": "https://github.com/MemPalace/mempalace" + }, + "mcp": { + "available": true, + "server": "mempalace", + "entryPoint": "mempalace-mcp", + "installCommand": "mempalace-mcp --palace /path/to/palace", + "reason": "MemPalace provides an optional MCP server for external memory access. Squad records the metadata but does not start it." + }, + "providers": [ + { + "id": "mempalace", + "type": "memory", + "mode": "read-write", + "protocol": "mcp", + "description": "Spatial memory provider contract for durable rooms, shelves, trails, and landmarks.", + "artifact": "memory/providers/mempalace-provider.md", + "mcp": { + "server": "mempalace", + "tool": "memory-palace", + "capability": "spatial-memory" + }, + "capabilities": [ + "spatial-memory", + "durable-context", + "agent-learning-trails" + ] + } + ], + "files": [ + { + "source": "memory/mempalace-provider.md", + "target": "memory/providers/mempalace-provider.md", + "type": "doc" + } + ] +} diff --git a/scripts/generate-api-docs.mjs b/scripts/generate-api-docs.mjs index dc923c3eb..4b8023465 100644 --- a/scripts/generate-api-docs.mjs +++ b/scripts/generate-api-docs.mjs @@ -10,7 +10,7 @@ */ import { execSync } from 'node:child_process'; -import { writeFileSync, readdirSync, readFileSync, renameSync, unlinkSync, mkdirSync } from 'node:fs'; +import { writeFileSync, readdirSync, readFileSync, renameSync, unlinkSync, mkdirSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -28,8 +28,17 @@ function toSafeFilename(original) { // Ensure output directory exists (fresh clones may not have it) mkdirSync(API_OUT, { recursive: true }); -// Resolve local TypeDoc binary to avoid npx resolution overhead -const typedocBin = join(SDK_DIR, 'node_modules', '.bin', 'typedoc'); +// Resolve TypeDoc from either the workspace package or the hoisted root install. +const typedocExecutable = process.platform === 'win32' ? 'typedoc.cmd' : 'typedoc'; +const typedocBin = [ + join(SDK_DIR, 'node_modules', '.bin', typedocExecutable), + join(ROOT, 'node_modules', '.bin', typedocExecutable), +].find(existsSync); + +if (!typedocBin) { + console.error('❌ TypeDoc not found. Run npm install before generating API docs.'); + process.exit(1); +} // Step 1 — Run TypeDoc console.log('⚙️ Running TypeDoc…'); diff --git a/templates/squad.agent.md.template b/templates/squad.agent.md.template index 73ba6d311..05a6dce47 100644 --- a/templates/squad.agent.md.template +++ b/templates/squad.agent.md.template @@ -976,7 +976,7 @@ prompt: | Tasks (in order): {% if STATE_BACKEND == "orphan" or STATE_BACKEND == "git-notes" or STATE_BACKEND == "two-layer" %} - 0. STATE LEAK GUARD: Check if any agent accidentally committed or staged state files + 0. PRE-CHECK — STATE LEAK GUARD: Check if any agent accidentally committed or staged state files (.squad/decisions.md, agents/*/history.md, log/*, orchestration-log/*, decisions/inbox/*) to the working branch. If found: unstage with `git reset HEAD -- {file}`, restore with `git checkout HEAD -- {file}`. If leaked in last commit, amend to remove. Log count. diff --git a/test/plugin-extensibility.test.ts b/test/plugin-extensibility.test.ts new file mode 100644 index 000000000..b33044f9e --- /dev/null +++ b/test/plugin-extensibility.test.ts @@ -0,0 +1,939 @@ +import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + createPluginInstallPlan, + derivePluginRoles, + executeLifecycleHook, + parsePluginManifestContent, + validatePluginManifest, +} from '../packages/squad-sdk/src/marketplace/index.js'; +import { FSStorageProvider } from '../packages/squad-sdk/src/storage/index.js'; +import { AgentLifecycleManager } from '../packages/squad-sdk/src/agents/index.js'; +import { runPlugin } from '../packages/squad-cli/src/cli/commands/plugin.js'; + +function writeFile(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); +} + +async function capturePluginCommand(cwd: string, args: string[]): Promise { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const info = vi.spyOn(console, 'info').mockImplementation(() => {}); + try { + await runPlugin(cwd, args); + return [...log.mock.calls, ...info.mock.calls].map((call) => call.join(' ')).join('\n'); + } finally { + log.mockRestore(); + info.mockRestore(); + } +} + +describe('plugin manifest parser and validator', () => { + it('accepts declarative plugin.manifest.json manifests and derives roles from declared components', () => { + const manifest = parsePluginManifestContent(JSON.stringify({ + id: 'demo-plugin', + name: 'Demo Plugin', + version: '1.0.0', + description: 'A declarative test plugin.', + authors: ['Squad'], + license: 'MIT', + squad: '>=0.9.1', + components: { + knowledge: ['demo-plugin'], + memory: { provider: 'demo-memory' }, + }, + copilot: { + requires: [ + { + id: 'github/copilot-plugin-example', + version: '>=1.0.0', + optional: true, + reason: 'Enables Copilot-owned commands when the user has installed it.', + }, + ], + }, + providers: [ + { + id: 'demo-memory', + type: 'memory', + mode: 'read-write', + protocol: 'static-artifact', + artifact: 'knowledge/demo-plugin/guidance.md', + capabilities: ['durable-context'], + }, + ], + files: [ + { source: 'guidance.md', target: 'knowledge/demo-plugin/guidance.md', type: 'knowledge' }, + ], + }), 'plugin.manifest.json'); + + const validation = validatePluginManifest(manifest); + expect(validation.valid, validation.errors.join(', ')).toBe(true); + expect(derivePluginRoles(manifest)).toEqual(['knowledge', 'memory']); + expect(manifest.copilot?.requires?.[0]).toMatchObject({ + id: 'github/copilot-plugin-example', + optional: true, + }); + expect(manifest.providers?.[0]).toMatchObject({ + id: 'demo-memory', + type: 'memory', + mode: 'read-write', + protocol: 'static-artifact', + artifact: 'knowledge/demo-plugin/guidance.md', + capabilities: ['durable-context'], + }); + + const plan = createPluginInstallPlan(manifest, { dryRun: true }); + expect(plan.dryRun).toBe(true); + expect(plan.files[0]?.targetRoot).toBe('knowledge'); + }); + + it('rejects executable declarations, script files, and path traversal', () => { + expect(() => parsePluginManifestContent(JSON.stringify({ + id: 'bad-plugin', + name: 'Bad Plugin', + version: '1.0.0', + components: { + hooks: [{ command: 'node bad.js' }], + }, + files: [], + }))).toThrow(/executable key/); + + const manifest = parsePluginManifestContent(JSON.stringify({ + id: 'bad-plugin', + name: 'Bad Plugin', + version: '1.0.0', + files: [ + { source: 'bad.js', target: '../bad.js', type: 'knowledge' }, + ], + })); + const validation = validatePluginManifest(manifest); + expect(validation.valid).toBe(false); + expect(validation.errors.join('\n')).toMatch(/executable or script|relative path/); + }); + + it('rejects Squad-owned skill components and invalid Copilot dependency declarations', () => { + const manifest = parsePluginManifestContent(JSON.stringify({ + id: 'bad-plugin', + name: 'Bad Plugin', + version: '1.0.0', + components: { + skills: ['copilot-owned-skill'], + }, + copilot: { + requires: [ + { id: '../not-a-plugin' }, + ], + }, + files: [ + { source: 'guidance.md', target: 'knowledge/demo-plugin/guidance.md', type: 'knowledge' }, + ], + })); + + const validation = validatePluginManifest(manifest); + expect(validation.valid).toBe(false); + expect(validation.errors.join('\n')).toMatch(/components\.skills is not supported/); + expect(validation.errors.join('\n')).toMatch(/copilot\.requires\[0\]\.id/); + }); + + it('validates the external integration sample plugins without fake Copilot dependencies', () => { + const samples = [ + { name: 'plugin-knowledge-graphify', role: 'knowledge', packageName: 'graphifyy' }, + { name: 'plugin-memory-mempalace', role: 'memory', packageName: 'mempalace' }, + { name: 'plugin-knowledge-index-server', role: 'knowledge', packageName: '@jagilber-org/index-server' }, + ]; + + for (const sample of samples) { + const manifestPath = join(process.cwd(), 'samples', sample.name, 'plugin.manifest.json'); + const manifest = parsePluginManifestContent(readFileSync(manifestPath, 'utf8'), 'plugin.manifest.json'); + const validation = validatePluginManifest(manifest); + + expect(validation.valid, `${sample.name}: ${validation.errors.join(', ')}`).toBe(true); + expect(derivePluginRoles(manifest)).toContain(sample.role); + expect(manifest.copilot?.requires ?? []).toEqual([]); + expect(manifest.upstream?.package).toBe(sample.packageName); + expect(manifest.repository?.url).toMatch(/^https:\/\/github\.com\//); + } + }); + + it('keeps external install and MCP hints as metadata-only warnings', () => { + const manifest = parsePluginManifestContent(JSON.stringify({ + id: 'external-tool', + name: 'External Tool', + version: '1.0.0', + components: { + knowledge: ['external-tool'], + }, + repository: { + type: 'github', + url: 'https://github.com/example/external-tool', + }, + upstream: { + package: 'external-tool', + registry: 'pypi', + installCommand: 'pip install external-tool', + docs: 'https://github.com/example/external-tool', + }, + mcp: { + available: true, + server: 'external-tool', + entryPoint: 'external-tool-mcp', + installCommand: 'external-tool-mcp', + reason: 'Optional MCP server users may configure separately.', + }, + files: [ + { source: 'guidance.md', target: 'knowledge/external-tool/guidance.md', type: 'knowledge' }, + ], + }), 'plugin.manifest.json'); + + const validation = validatePluginManifest(manifest); + + expect(validation.valid, validation.errors.join(', ')).toBe(true); + expect(validation.warnings.join('\n')).toContain('upstream.installCommand is metadata only'); + expect(validation.warnings.join('\n')).toContain('mcp.installCommand is metadata only'); + }); + + it('validates provider contracts as declarative metadata only', () => { + const manifest = parsePluginManifestContent(JSON.stringify({ + id: 'provider-tool', + name: 'Provider Tool', + version: '1.0.0', + components: { + memory: { provider: 'provider-tool' }, + }, + providers: [ + { + id: 'provider-tool', + type: 'memory', + mode: 'read-write', + protocol: 'mcp', + artifact: 'memory/providers/provider-tool.md', + mcp: { + server: 'provider-tool', + tool: 'query-memory', + capability: 'durable-memory', + }, + capabilities: ['durable-memory', 'context-recall'], + }, + ], + files: [ + { source: 'provider.md', target: 'memory/providers/provider-tool.md', type: 'doc' }, + ], + }), 'plugin.manifest.json'); + + const validation = validatePluginManifest(manifest); + + expect(validation.valid, validation.errors.join(', ')).toBe(true); + expect(validation.warnings.join('\n')).toContain('providers[0].mcp is provider metadata only'); + }); + + it('rejects unsafe provider contracts', () => { + const manifest = parsePluginManifestContent(JSON.stringify({ + id: 'bad-provider', + name: 'Bad Provider', + version: '1.0.0', + components: { + memory: { provider: 'bad-provider' }, + }, + providers: [ + { + id: 'bad-provider', + type: 'memory-exec', + mode: 'admin', + protocol: 'stdio', + artifact: '../secret.md', + capabilities: [''], + }, + ], + files: [ + { source: 'provider.md', target: 'memory/providers/provider.md', type: 'doc' }, + ], + }), 'plugin.manifest.json'); + + const validation = validatePluginManifest(manifest); + + expect(validation.valid).toBe(false); + expect(validation.errors.join('\n')).toContain('providers[0].type must be one of'); + expect(validation.errors.join('\n')).toContain('providers[0].mode must be one of'); + expect(validation.errors.join('\n')).toContain('providers[0].protocol must be one of'); + expect(validation.errors.join('\n')).toContain('providers[0].artifact must be a relative path'); + expect(validation.errors.join('\n')).toContain('providers[0].capabilities must contain only non-empty strings'); + }); + + it('rejects non-https external metadata URLs', () => { + const manifest = parsePluginManifestContent(JSON.stringify({ + id: 'external-tool', + name: 'External Tool', + version: '1.0.0', + repository: { + type: 'github', + url: 'http://github.com/example/external-tool', + }, + files: [ + { source: 'guidance.md', target: 'knowledge/external-tool/guidance.md', type: 'knowledge' }, + ], + }), 'plugin.manifest.json'); + + const validation = validatePluginManifest(manifest); + + expect(validation.valid).toBe(false); + expect(validation.errors.join('\n')).toContain('repository.url must use https'); + }); +}); + +describe('squad plugin lifecycle CLI', () => { + let tmpDir: string; + let pluginDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'squad-plugin-lifecycle-')); + mkdirSync(join(tmpDir, '.squad'), { recursive: true }); + pluginDir = join(tmpDir, 'demo-plugin'); + writeFile(join(pluginDir, 'plugin.manifest.json'), JSON.stringify({ + id: 'demo-plugin', + name: 'Demo Plugin', + version: '1.0.0', + description: 'A declarative test plugin.', + components: { + knowledge: ['demo-plugin'], + memory: { provider: 'demo-memory' }, + }, + copilot: { + requires: [ + { + id: 'github/copilot-plugin-example', + optional: true, + reason: 'Used by Copilot when installed separately.', + }, + ], + }, + files: [ + { source: 'guidance.md', target: 'knowledge/demo-plugin/guidance.md', type: 'knowledge' }, + ], + providers: [ + { + id: 'demo-memory', + type: 'memory', + mode: 'read-write', + protocol: 'static-artifact', + artifact: 'knowledge/demo-plugin/guidance.md', + capabilities: ['durable-context'], + }, + ], + }, null, 2)); + writeFile(join(pluginDir, 'guidance.md'), '# Demo Plugin\n\nStatic knowledge content.\n'); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('validates and dry-runs without writing plugin state', async () => { + const output = await capturePluginCommand(tmpDir, ['dry-run', pluginDir]); + + expect(output).toContain('Dry run deployment plan'); + expect(output).toContain('Squad records these dependencies but does not install or run Copilot plugin commands.'); + expect(readFileSync(join(pluginDir, 'guidance.md'), 'utf8')).toContain('Static knowledge content'); + expect(() => readFileSync(join(tmpDir, '.squad', 'plugins', 'installed.json'), 'utf8')).toThrow(); + }); + + it('installs disabled, verifies, enables, switches, disables, and uninstalls', async () => { + await capturePluginCommand(tmpDir, ['install', pluginDir]); + expect(readFileSync(join(tmpDir, '.squad', 'knowledge', 'demo-plugin', 'guidance.md'), 'utf8')) + .toBe('# Demo Plugin\n\nStatic knowledge content.\n'); + + const installed = JSON.parse(readFileSync(join(tmpDir, '.squad', 'plugins', 'installed.json'), 'utf8')) as { + plugins: Array<{ + id: string; + enabled: boolean; + roles: string[]; + copilot?: { requires?: Array<{ id: string }> }; + providers?: Array<{ id: string; type: string; protocol?: string }>; + }>; + }; + expect(installed.plugins[0]).toMatchObject({ + id: 'demo-plugin', + enabled: false, + roles: ['knowledge', 'memory'], + copilot: { + requires: [ + { id: 'github/copilot-plugin-example' }, + ], + }, + providers: [ + { id: 'demo-memory', type: 'memory', protocol: 'static-artifact' }, + ], + }); + + await capturePluginCommand(tmpDir, ['verify']); + await capturePluginCommand(tmpDir, ['enable', 'demo-plugin']); + let runtime = JSON.parse(readFileSync(join(tmpDir, '.squad', 'plugins', 'runtime.json'), 'utf8')) as { + plugins: Record; + active: Record; + }; + expect(runtime.plugins['demo-plugin']?.enabled).toBe(true); + expect(runtime.active.memory).toBe('demo-plugin'); + expect(runtime.active.knowledge).toBe('demo-plugin'); + + await capturePluginCommand(tmpDir, ['switch', 'memory', 'demo-plugin']); + await capturePluginCommand(tmpDir, ['disable', 'demo-plugin']); + runtime = JSON.parse(readFileSync(join(tmpDir, '.squad', 'plugins', 'runtime.json'), 'utf8')) as { + active: Record; + }; + expect(runtime.active.memory).toBeUndefined(); + expect(runtime.active.knowledge).toBeUndefined(); + + await capturePluginCommand(tmpDir, ['uninstall', 'demo-plugin']); + const afterUninstall = JSON.parse(readFileSync(join(tmpDir, '.squad', 'plugins', 'installed.json'), 'utf8')) as { + plugins: unknown[]; + }; + expect(afterUninstall.plugins).toHaveLength(0); + expect(() => readFileSync(join(tmpDir, '.squad', 'knowledge', 'demo-plugin', 'guidance.md'), 'utf8')).toThrow(); + + const audit = readFileSync(join(tmpDir, '.squad', 'plugins', 'audit.jsonl'), 'utf8') + .trim() + .split(/\r?\n/) + .map((line) => JSON.parse(line) as { type: string }); + expect(audit.map((event) => event.type)).toEqual([ + 'install', + 'verify', + 'enable', + 'switch', + 'disable', + 'uninstall', + ]); + }); + + it('rejects symlinked plugin source files before install writes state', async () => { + rmSync(join(pluginDir, 'guidance.md'), { force: true }); + symlinkSync(join(tmpDir, 'outside.md'), join(pluginDir, 'guidance.md')); + + await expect(runPlugin(tmpDir, ['install', pluginDir])).rejects.toThrow(/symlink/); + expect(() => readFileSync(join(tmpDir, '.squad', 'plugins', 'installed.json'), 'utf8')).toThrow(); + }); +}); + +describe('plugin behavioral influence on spawned squad agents', () => { + const samples = [ + { + name: 'plugin-knowledge-graphify', + id: 'graphify-knowledge', + installedFile: ['knowledge', 'graphify', 'graphify-integration.md'], + expectedGuidance: 'Graphify is the `safishamsi/graphify` knowledge graph tool', + expectedPackage: 'Upstream package: graphifyy (pypi)', + expectedProvider: 'graphify: type=knowledge; mode=read; protocol=static-artifact; artifact=.squad/knowledge/graphify/graphify-integration.md; capabilities=knowledge-graph, code-relationship-analysis, static-report-guidance', + }, + { + name: 'plugin-memory-mempalace', + id: 'mempalace-memory', + installedFile: ['memory', 'providers', 'mempalace-provider.md'], + expectedGuidance: 'MemPalace is an example memory provider profile', + expectedPackage: 'Upstream package: mempalace (pypi)', + expectedProvider: 'mempalace: type=memory; mode=read-write; protocol=mcp; artifact=.squad/memory/providers/mempalace-provider.md; capabilities=spatial-memory, durable-context, agent-learning-trails; mcp.server=mempalace; mcp.tool=memory-palace; mcp.capability=spatial-memory', + }, + { + name: 'plugin-knowledge-index-server', + id: 'index-server-knowledge', + installedFile: ['knowledge', 'index-server', 'index-server-integration.md'], + expectedGuidance: 'Index Server is the `jagilber-org/index-server` MCP instruction indexing server', + expectedPackage: 'Upstream package: @jagilber-org/index-server (npm)', + expectedProvider: 'index-server: type=knowledge; mode=read; protocol=mcp; artifact=.squad/knowledge/index-server/index-server-integration.md; capabilities=governed-instructions, knowledge-catalog, team-standards; mcp.server=index-server; mcp.tool=query-index; mcp.capability=governed-knowledge-catalog', + }, + ]; + + let tmpDirs: string[] = []; + + afterEach(() => { + for (const tmpDir of tmpDirs) { + rmSync(tmpDir, { recursive: true, force: true }); + } + tmpDirs = []; + }); + + for (const sample of samples) { + it(`injects enabled ${sample.name} static guidance into spawned agent system context`, async () => { + const tmpDir = mkdtempSync(join(tmpdir(), `squad-plugin-behavior-${sample.id}-`)); + tmpDirs.push(tmpDir); + mkdirSync(join(tmpDir, '.squad'), { recursive: true }); + writeFile(join(tmpDir, '.squad', 'agents', 'data', 'charter.md'), [ + '# Data Charter', + '', + '## Identity', + '', + '**Name:** Data', + '**Role:** Code Expert', + '**Expertise:** C#, Go, .NET', + '**Style:** Direct', + '', + '## Collaboration', + '', + 'Use installed project context when provided.', + '', + ].join('\n')); + + const sampleDir = join(process.cwd(), 'samples', sample.name); + await capturePluginCommand(tmpDir, ['install', sampleDir]); + const installedArtifact = join(tmpDir, '.squad', ...sample.installedFile); + expect(readFileSync(installedArtifact, 'utf8')).toContain(sample.expectedGuidance); + + const disabledPrompt = await spawnAndCaptureSystemPrompt(tmpDir); + expect(disabledPrompt).not.toContain('## Active Squad Plugins'); + expect(disabledPrompt).not.toContain(sample.expectedGuidance); + + await capturePluginCommand(tmpDir, ['enable', sample.id]); + const enabledPrompt = await spawnAndCaptureSystemPrompt(tmpDir); + + expect(enabledPrompt).toContain('## Plugin Context'); + expect(enabledPrompt).toContain('## Active Squad Plugins'); + expect(enabledPrompt).toContain('Squad has not installed upstream packages, started MCP servers, or run external plugin commands.'); + expect(enabledPrompt).toContain('Provider contracts are declarative metadata only.'); + expect(enabledPrompt).toContain(`### ${sample.id.includes('graphify') ? 'Graphify Knowledge Graph' : sample.id.includes('mempalace') ? 'MemPalace Memory' : 'Index Server Knowledge'} (${sample.id}@1.0.0)`); + expect(enabledPrompt).toContain(`#### Installed artifact: .squad/${sample.installedFile.join('/')}`); + expect(enabledPrompt).toContain(sample.expectedGuidance); + expect(enabledPrompt).toContain(sample.expectedPackage); + expect(enabledPrompt).toContain(sample.expectedProvider); + }); + } + + it('simulates scenario-specific spawned-agent planning deltas from enabled plugin context', async () => { + const scenarios: PluginSimulationScenario[] = [ + { + sampleName: 'plugin-knowledge-graphify', + pluginId: 'graphify-knowledge', + triggerTask: 'Map the code and documentation relationships before changing the plugin runtime.', + expectedStrategy: 'graphify-knowledge-graph', + enabledEvidence: [ + 'knowledge graph', + 'code relationship analysis', + 'graphify-out/GRAPH_REPORT.md', + ], + }, + { + sampleName: 'plugin-memory-mempalace', + pluginId: 'mempalace-memory', + triggerTask: 'Organize durable team context, agent learnings, decisions, open work, and repeated patterns.', + expectedStrategy: 'mempalace-spatial-memory', + enabledEvidence: [ + 'rooms', + 'shelves', + 'trails', + 'landmarks', + ], + }, + { + sampleName: 'plugin-knowledge-index-server', + pluginId: 'index-server-knowledge', + triggerTask: 'Find the governed team instructions and reusable knowledge standards for this task.', + expectedStrategy: 'index-server-governed-catalog', + enabledEvidence: [ + 'governed instruction', + 'knowledge catalog', + 'validated team instructions', + ], + }, + ]; + + for (const scenario of scenarios) { + const tmpDir = mkdtempSync(join(tmpdir(), `squad-plugin-simulation-${scenario.pluginId}-`)); + tmpDirs.push(tmpDir); + mkdirSync(join(tmpDir, '.squad'), { recursive: true }); + writeFile(join(tmpDir, '.squad', 'agents', 'data', 'charter.md'), [ + '# Data Charter', + '', + '## Identity', + '', + '**Name:** Data', + '**Role:** Code Expert', + '**Expertise:** C#, Go, .NET', + '**Style:** Direct', + '', + '## Collaboration', + '', + 'Use installed project context when provided.', + '', + ].join('\n')); + + const sampleDir = join(process.cwd(), 'samples', scenario.sampleName); + await capturePluginCommand(tmpDir, ['install', sampleDir]); + + const disabledPrompt = await spawnAndCaptureSystemPrompt(tmpDir); + const disabledPlan = simulateSpawnedAgentPlan(disabledPrompt, scenario.triggerTask); + expect(disabledPlan.strategy, `${scenario.pluginId} disabled strategy`).toBe('baseline-charter'); + expect(disabledPlan.evidence).toEqual([]); + expect(disabledPlan.externalExecutionRequired).toBe(false); + expect(disabledPlan.externalExecutionProhibited).toBe(false); + for (const evidence of scenario.enabledEvidence) { + expect(disabledPlan.summary).not.toContain(evidence); + } + + await capturePluginCommand(tmpDir, ['enable', scenario.pluginId]); + const enabledPrompt = await spawnAndCaptureSystemPrompt(tmpDir); + const enabledPlan = simulateSpawnedAgentPlan(enabledPrompt, scenario.triggerTask); + + expect(enabledPlan.strategy, `${scenario.pluginId} enabled strategy`).toBe(scenario.expectedStrategy); + expect(enabledPlan.externalExecutionRequired).toBe(false); + expect(enabledPlan.externalExecutionProhibited).toBe(true); + expect(enabledPlan.summary).toContain('No live package install, external CLI execution, MCP startup, or live provider query is required.'); + for (const evidence of scenario.enabledEvidence) { + expect(enabledPlan.summary).toContain(evidence); + } + } + }); + + it('does not read outside .squad when installed plugin state is tampered', async () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'squad-plugin-behavior-tamper-')); + tmpDirs.push(tmpDir); + mkdirSync(join(tmpDir, '.squad'), { recursive: true }); + writeFile(join(tmpDir, '.squad', 'agents', 'data', 'charter.md'), [ + '# Data Charter', + '', + '## Identity', + '', + '**Name:** Data', + '**Role:** Code Expert', + '', + ].join('\n')); + + const sampleDir = join(process.cwd(), 'samples', 'plugin-knowledge-graphify'); + await capturePluginCommand(tmpDir, ['install', sampleDir]); + await capturePluginCommand(tmpDir, ['enable', 'graphify-knowledge']); + writeFile(join(tmpDir, 'outside-secret.md'), 'DO_NOT_INJECT_PLUGIN_SECRET'); + + const installedPath = join(tmpDir, '.squad', 'plugins', 'installed.json'); + const installed = JSON.parse(readFileSync(installedPath, 'utf8')) as { + plugins: Array<{ files: Array<{ target: string }> }>; + }; + installed.plugins[0]!.files[0]!.target = '../outside-secret.md'; + writeFile(installedPath, `${JSON.stringify(installed, null, 2)}\n`); + + const prompt = await spawnAndCaptureSystemPrompt(tmpDir); + + expect(prompt).toContain('Skipped unsafe plugin state target.'); + expect(prompt).not.toContain('DO_NOT_INJECT_PLUGIN_SECRET'); + }); +}); + +async function spawnAndCaptureSystemPrompt(teamRoot: string): Promise { + const capturedConfigs: Array<{ systemMessage?: { content?: string } }> = []; + let sessionCounter = 0; + const client = { + async createSession(config: { systemMessage?: { content?: string } }) { + capturedConfigs.push(config); + return { + sessionId: `plugin-behavior-session-${++sessionCounter}`, + async sendMessage() {}, + async close() {}, + on() {}, + off() {}, + }; + }, + }; + const manager = new AgentLifecycleManager({ + client: client as never, + teamRoot, + defaultIdleTimeout: 60_000, + }); + try { + await manager.spawnAgent({ + agentName: 'data', + task: 'Use the available squad context to plan the work.', + }); + } finally { + await manager.shutdown(); + } + + return capturedConfigs[0]?.systemMessage?.content ?? ''; +} + +type PluginSimulationStrategy = + | 'baseline-charter' + | 'graphify-knowledge-graph' + | 'mempalace-spatial-memory' + | 'index-server-governed-catalog'; + +interface PluginSimulationScenario { + sampleName: string; + pluginId: string; + triggerTask: string; + expectedStrategy: Exclude; + enabledEvidence: string[]; +} + +interface SimulatedSpawnedAgentPlan { + strategy: PluginSimulationStrategy; + summary: string; + evidence: string[]; + externalExecutionRequired: boolean; + externalExecutionProhibited: boolean; +} + +function simulateSpawnedAgentPlan(systemPrompt: string, task: string): SimulatedSpawnedAgentPlan { + const evidence: string[] = []; + const lowerPrompt = systemPrompt.toLowerCase(); + const lowerTask = task.toLowerCase(); + const externalExecutionProhibited = lowerPrompt.includes('squad has not installed upstream packages') + && lowerPrompt.includes('started mcp servers') + && lowerPrompt.includes('run external plugin commands'); + const base = 'No live package install, external CLI execution, MCP startup, or live provider query is required.'; + + if ( + lowerPrompt.includes('graphify knowledge graph integration') + && lowerPrompt.includes('graphify-out/graph_report.md') + && (lowerTask.includes('relationship') || lowerTask.includes('code')) + ) { + evidence.push('knowledge graph', 'code relationship analysis', 'graphify-out/GRAPH_REPORT.md'); + return { + strategy: 'graphify-knowledge-graph', + summary: [ + 'Use Graphify knowledge graph guidance for code relationship analysis.', + 'If existing static artifacts are present, inspect graphify-out/GRAPH_REPORT.md before direct source traversal.', + base, + ].join(' '), + evidence, + externalExecutionRequired: false, + externalExecutionProhibited, + }; + } + + if ( + lowerPrompt.includes('mempalace memory provider') + && lowerPrompt.includes('memory as rooms, shelves, trails, and landmarks') + && (lowerTask.includes('durable') || lowerTask.includes('learnings') || lowerTask.includes('decisions')) + ) { + evidence.push('rooms', 'shelves', 'trails', 'landmarks'); + return { + strategy: 'mempalace-spatial-memory', + summary: [ + 'Use MemPalace memory-palace concepts: rooms for project context, shelves for agent learnings, landmarks for decisions, and trails for open work.', + base, + ].join(' '), + evidence, + externalExecutionRequired: false, + externalExecutionProhibited, + }; + } + + if ( + lowerPrompt.includes('index server knowledge integration') + && lowerPrompt.includes('governed instruction and knowledge catalog') + && (lowerTask.includes('instruction') || lowerTask.includes('knowledge') || lowerTask.includes('standards')) + ) { + evidence.push('governed instruction', 'knowledge catalog', 'validated team instructions'); + return { + strategy: 'index-server-governed-catalog', + summary: [ + 'Treat Index Server guidance as a governed instruction and knowledge catalog for durable validated team instructions and reusable standards.', + base, + ].join(' '), + evidence, + externalExecutionRequired: false, + externalExecutionProhibited, + }; + } + + return { + strategy: 'baseline-charter', + summary: 'Use only the base Data charter and normal repository inspection.', + evidence, + externalExecutionRequired: false, + externalExecutionProhibited, + }; +} + +describe('Plugin Runtime Capabilities', () => { + let tmpDir: string; + let storage: FSStorageProvider; + let squadDir: string; + + beforeEach(() => { + tmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'squad-runtime-test-'))); + squadDir = join(tmpDir, '.squad'); + mkdirSync(squadDir, { recursive: true }); + storage = new FSStorageProvider(); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('validates graphify runtime manifest with capabilities', () => { + const manifestPath = join(process.cwd(), 'samples', 'plugin-knowledge-graphify', 'plugin.manifest.json'); + const manifest = parsePluginManifestContent(readFileSync(manifestPath, 'utf8')); + const validation = validatePluginManifest(manifest); + + expect(validation.valid, validation.errors.join(', ')).toBe(true); + expect(manifest.runtime).toBeDefined(); + expect(manifest.runtime?.capabilities).toHaveLength(1); + expect(manifest.runtime?.capabilities?.[0].type).toBe('artifact-generation'); + expect(manifest.runtime?.capabilities?.[0].provider).toBe('graphify'); + expect(manifest.runtime?.capabilities?.[0].lifecycle).toContain('onMemoryRefresh'); + expect(validation.warnings.some((w) => w.includes('built-in artifact operations'))).toBe(true); + }); + + it('rejects unsafe output paths in runtime capabilities', () => { + const manifest = parsePluginManifestContent(JSON.stringify({ + id: 'bad-plugin', + name: 'Bad Plugin', + version: '1.0.0', + runtime: { + capabilities: [{ + type: 'artifact-generation', + provider: 'graphify', + lifecycle: ['onMemoryRefresh'], + outputPaths: ['../../../etc/passwd'], + }], + }, + files: [{ source: 'README.md', target: 'knowledge/readme.md', type: 'doc' }], + })); + const validation = validatePluginManifest(manifest); + + expect(validation.valid).toBe(false); + expect(validation.errors.some((e) => e.includes('escape'))).toBe(true); + }); + + it('rejects unsupported provider in runtime capabilities', () => { + const manifest = parsePluginManifestContent(JSON.stringify({ + id: 'bad-plugin', + name: 'Bad Plugin', + version: '1.0.0', + runtime: { + capabilities: [{ + type: 'artifact-generation', + provider: 'unsupported-provider', + lifecycle: ['onMemoryRefresh'], + outputPaths: ['knowledge/output.json'], + }], + }, + files: [{ source: 'README.md', target: 'knowledge/readme.md', type: 'doc' }], + })); + const validation = validatePluginManifest(manifest); + + expect(validation.valid).toBe(false); + expect(validation.errors.some((e) => e.includes('not approved'))).toBe(true); + }); + + it('rejects unsupported lifecycle event in runtime capabilities', () => { + const manifest = parsePluginManifestContent(JSON.stringify({ + id: 'bad-plugin', + name: 'Bad Plugin', + version: '1.0.0', + runtime: { + capabilities: [{ + type: 'artifact-generation', + provider: 'graphify', + lifecycle: ['onUnsupportedEvent'], + outputPaths: ['knowledge/output.json'], + }], + }, + files: [{ source: 'README.md', target: 'knowledge/readme.md', type: 'doc' }], + })); + const validation = validatePluginManifest(manifest); + + expect(validation.valid).toBe(false); + expect(validation.errors.some((e) => e.includes('not allowed'))).toBe(true); + }); + + it('rejects executable extensions in runtime output paths', () => { + const manifest = parsePluginManifestContent(JSON.stringify({ + id: 'bad-plugin', + name: 'Bad Plugin', + version: '1.0.0', + runtime: { + capabilities: [{ + type: 'artifact-generation', + provider: 'graphify', + lifecycle: ['onMemoryRefresh'], + outputPaths: ['knowledge/malicious.sh'], + }], + }, + files: [{ source: 'README.md', target: 'knowledge/readme.md', type: 'doc' }], + })); + const validation = validatePluginManifest(manifest); + + expect(validation.valid).toBe(false); + expect(validation.errors.some((e) => e.includes('executable'))).toBe(true); + }); + + it('executes graphify lifecycle and generates artifacts under .squad/knowledge/graphify', async () => { + const capabilities = [{ + type: 'artifact-generation' as const, + provider: 'graphify', + lifecycle: ['onMemoryRefresh' as const], + outputPaths: ['knowledge/graphify/graph.json', 'knowledge/graphify/GRAPH_REPORT.md'], + }]; + + const audit = { events: [] }; + const results = await executeLifecycleHook( + 'graphify-knowledge', + '1.0.0', + 'onMemoryRefresh', + capabilities, + storage, + squadDir, + audit + ); + + expect(results).toHaveLength(1); + expect(results[0].success).toBe(true); + expect(results[0].artifactsGenerated).toHaveLength(2); + + const graphContent = await storage.read(join(squadDir, 'knowledge/graphify/graph.json')); + const reportContent = await storage.read(join(squadDir, 'knowledge/graphify/GRAPH_REPORT.md')); + + expect(graphContent).toBeDefined(); + expect(reportContent).toBeDefined(); + const graphData = JSON.parse(graphContent!); + expect(graphData.plugin_id).toBe('graphify-knowledge'); + expect(reportContent).toContain('# Graphify Knowledge Graph Report'); + }); + + it('denies lifecycle execution when no applicable capabilities exist', async () => { + const capabilities = [{ + type: 'artifact-generation' as const, + provider: 'graphify', + lifecycle: ['onEnable' as const], + outputPaths: ['knowledge/graph.json'], + }]; + + const audit = { events: [] }; + const results = await executeLifecycleHook( + 'test-plugin', + '1.0.0', + 'onMemoryRefresh', + capabilities, + storage, + squadDir, + audit + ); + + expect(results).toHaveLength(0); + expect(audit.events.some((e) => e.message.includes('no applicable capabilities'))).toBe(true); + }); + + it('audits runtime lifecycle execution', async () => { + const capabilities = [{ + type: 'artifact-generation' as const, + provider: 'graphify', + lifecycle: ['onMemoryRefresh' as const], + outputPaths: ['knowledge/graph.json', 'knowledge/report.md'], + }]; + + const audit = { events: [] }; + await executeLifecycleHook( + 'test-plugin', + '1.0.0', + 'onMemoryRefresh', + capabilities, + storage, + squadDir, + audit + ); + + expect(audit.events).toHaveLength(1); + expect(audit.events[0].type).toBe('provider_invoked'); + expect(audit.events[0].message).toContain('succeeded'); + }); +});