diff --git a/.changeset/external-tool-stack.md b/.changeset/external-tool-stack.md new file mode 100644 index 0000000..9750a21 --- /dev/null +++ b/.changeset/external-tool-stack.md @@ -0,0 +1,11 @@ +--- +"@drmahdikazempour/agent-stack": minor +--- + +v0.3.0 — real external tool stack + coordinator. + +- **Full permissive stack wired in** (the `max` profile / `init --all`): rtk (Apache-2.0) + code-review-graph, graphify, caveman, claude-handoff, gbrain (all MIT), over the built-in fallbacks. Detect → install-if-missing → use. +- **Multi-toolchain installer** — cargo / uv / pipx / pip / bun / `claude plugin`, tried in order with fallthrough on failure (e.g. a PEP-668 pip3 falls through to a working pip). Prints the tool's own install command as guidance when no toolchain is available; never auto-runs `curl|sh`. +- **MCP registration** — code-review-graph's server is merged into `.mcp.json` (preserving user-defined servers); the SessionStart hook refreshes the external graph backend when active. +- **Tool coordinator** — generated `CLAUDE.md` and `AGENTS.md` route each job to the right tool, with built-ins named as the explicit fallback. Cursor mirror references only the portable subset (rtk + MCP/CLI graph tools). +- **Honest sourcing** — every install/MCP command transcribed from each repo's own docs; the two unlicensed candidates (token-optimizer, intent-layer) dropped since permissive members cover their function. Single source of truth: `integrations/tools.json`. diff --git a/CREDITS.md b/CREDITS.md index 4852200..6f186c9 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -1,50 +1,47 @@ # Credits & prior art -agent-stack stands on the shoulders of the Claude/agent token-optimization ecosystem. This file is a transparent record of every project that inspired or is integrated by agent-stack, how it relates, and under what license. +agent-stack stands on the shoulders of the Claude Code token-optimization ecosystem. This file is a transparent record of every project agent-stack integrates or was inspired by, how it relates, and under what license. > **Transparency notes** -> - agent-stack **does not vendor** (copy in) any third-party code. Its built-in code map and output compression are original MIT code in [`src/builtin/`](src/builtin/), inspired by — not derived from — the tools below. -> - The **only** tool agent-stack auto-installs is `ccusage`. -> - "Optional" tools are used **only if their genuine binary is already on your PATH** (detect-only); they are never auto-installed, because the bare npm names are unrelated/squatted packages. -> - Links reflect the handles cited in this project's PRD. Where a canonical repository wasn't specified upstream, the tool is named without a link rather than guessing a URL. +> - agent-stack **does not vendor** (copy in) any third-party code. Its built-in code map and output compression are original MIT code in [`src/builtin/`](src/builtin/), and act as the **fallback** when an external tool isn't installed. +> - Every integrated tool below is **permissive** (MIT / Apache-2.0). There is no consent gate and nothing non-permissive is wired in. +> - Tools are **detected first**; a missing one is installed via its own toolchain (cargo / uv / pipx / bun / `claude plugin`). When no toolchain is available, agent-stack prints the tool's own install command as guidance and never silently runs a `curl | sh` one-liner. +> - All commands in [`integrations/tools.json`](integrations/tools.json) were transcribed from each repo's own `INSTALL.md` / `README` / `.mcp.json` — not guessed. -## Integrated (used directly) +## Integrated stack (the `max` profile / `init --all`) -| Project | License | How agent-stack uses it | -|---|---|---| -| [ryoppippi/ccusage](https://github.com/ryoppippi/ccusage) | MIT | Auto-installed; the neutral source of truth for token-usage baselines and `measure` reports. | - -## Optional integrations (detect-only — used if present on PATH) +| Project | License | Integration | How agent-stack uses it | +|---|---|---|---| +| [ryoppippi/ccusage](https://github.com/ryoppippi/ccusage) | MIT | npm binary | Always installed; neutral source of truth for token-usage baselines and `measure` reports. | +| [rtk-ai/rtk](https://github.com/rtk-ai/rtk) | Apache-2.0 | PATH binary | Command proxy — route heavy git/npm/build/test output through it to cut 60-90% of tokens. Composes with the built-in `compress`. Installed via `cargo install --git` (never crates.io `rtk`, a different project). | +| [tirth8205/code-review-graph](https://github.com/tirth8205/code-review-graph) | MIT | MCP server | Primary code map — its MCP server (`uvx code-review-graph serve`) is merged into `.mcp.json`; the SessionStart hook runs `code-review-graph update`. Replaces the built-in regex code map when present. | +| [safishamsi/graphify](https://github.com/safishamsi/graphify) | MIT | CLI / skill | Knowledge graph for whole-repo, multi-file-type questions. PyPI package is `graphifyy`; CLI stays `graphify`. | +| [JuliusBrussee/caveman](https://github.com/JuliusBrussee/caveman) | MIT | Claude Code plugin | Terse-output mode. Installed via `claude plugin marketplace add` + `claude plugin install`. | +| [willseltzer/claude-handoff](https://github.com/willseltzer/claude-handoff) | MIT | Claude Code plugin | Session continuity (`/handoff:create`, `/handoff:resume`). | +| [garrytan/gbrain](https://github.com/garrytan/gbrain) | MIT | Bun CLI / plugin | Persistent cross-session memory. Requires Bun; an embedding API key enables vector search (keyword search works without). | -| Project | License | Role | -|---|---|---| -| `rtk` | Apache-2.0 | Shell-output compression (alternative to the built-in `compress`). | -| `codegraph` | MIT | Code graph backend (alternative to the built-in code map). | -| [tirth8205/code-review-graph](https://github.com/tirth8205/code-review-graph) | MIT | Review-focused graph for the `review` profile. | -| [safishamsi/graphify](https://github.com/safishamsi/graphify) | MIT | Multimodal graph for the `multimodal` profile. | -| [rhuss/cc-spex](https://github.com/rhuss/cc-spex) | Apache-2.0 | Spec-driven workflow for the `spec` profile. | +**Cursor portability:** only `rtk` and the MCP/CLI graph tools (`code-review-graph`, `graphify`) are referenced in the Cursor mirror. The Claude Code plugins (`caveman`, `claude-handoff`, `gbrain`) are Claude-Code-only and are intentionally not named for Cursor. -## Opt-in (runtime shell-out only, behind `--allow-noncommercial`) +## Considered but dropped (no clear license) -These are **never vendored** and never auto-installed; they are shelled out to at runtime only when you explicitly enable them. +These cover functions already provided by permissive members of the stack, so they were dropped rather than wired in: -| Project | License | Role | +| Project | License | Why dropped | |---|---|---| -| [mksglu/context-mode](https://github.com/mksglu/context-mode) | Elastic-2.0 | Large-output sandbox for the `research` profile. | -| [alexgreensh/token-optimizer](https://github.com/alexgreensh/token-optimizer) | PolyForm Noncommercial | Deep token audit (`audit --deep`). | +| [alexgreensh/token-optimizer](https://github.com/alexgreensh/token-optimizer) | NOASSERTION (no clear license) | Token/compaction savings already covered by `rtk` + `caveman` + the built-in `compress`. | +| [orban/intent-layer](https://github.com/orban/intent-layer) | none (no LICENSE file) | Intent-driven "read only what matters" already covered by `code-review-graph` + `graphify`. | ## Prior art & inspiration -Patterns, taxonomy, and ideas that shaped agent-stack's design — not integrated, but gratefully acknowledged. +Patterns and ideas that shaped agent-stack's design — not integrated. | Project | License | Influence | |---|---|---| | [nadimtuhin/claude-token-optimizer](https://github.com/nadimtuhin/claude-token-optimizer) | MIT | The generated-file taxonomy (`CLAUDE.md`, `COMMON_MISTAKES.md`, `ARCHITECTURE_MAP.md`). | | [obra/superpowers](https://github.com/obra/superpowers) | MIT | Workflow-kernel and command patterns. | | [vercel-labs/skills](https://github.com/vercel-labs/skills) | MIT | Cross-agent skill installation model. | -| `caveman` | MIT | Terse output-style inspiration (agent-stack's "terse mode"). | -| [garrytan/gbrain](https://github.com/garrytan/gbrain) | — | Long-term memory tier (future roadmap). | +| [garrytan/gstack](https://github.com/garrytan/gstack) | MIT | The reviewer/coordinator skill taxonomy. | ## License compatibility -agent-stack is **MIT**. Its default install pulls only MIT/Apache-2.0 tooling. Non-permissive tools (Elastic-2.0, PolyForm Noncommercial) are gated behind `--allow-noncommercial` and are never vendored — CI fails if any code under `src/` imports them. See [`integrations/licenses.json`](integrations/licenses.json). +agent-stack is **MIT** and integrates only MIT / Apache-2.0 tooling — no consent flag, nothing non-permissive. agent-stack never vendors third-party code. The single source of truth for every tool's source, install strategy, integration kind, and license is [`integrations/tools.json`](integrations/tools.json). diff --git a/README.md b/README.md index 9a2038f..4463e84 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ A **profile** bundles a graph backend + compression + skill set + hook config. ` | 🟣 `multimodal` | built-in map | built-in | ≥ 5 PDFs / video / large images | | 🟡 `spec` | built-in map | built-in | spec-kit / cc-spex detected | | ⚪ `research` | none | built-in | `--profile research --allow-noncommercial` | -| 🔴 `max` | built-in map | built-in **+ terse** | `--all` — everything on at once | +| 🔴 `max` | external graph + built-in fallback | built-in **+ terse + rtk** | `--all` — full external stack on at once | ```bash agent-stack profile use review # swap & regenerate @@ -241,13 +241,12 @@ agent-stack uninstall # restore backup, remove generated files | Flag | Effect | |------|--------| -| `--all` | Turn on **every** feature at once (the `max` profile) | +| `--all` | Full external stack at once (the `max` profile): rtk + code-review-graph + graphify + caveman + claude-handoff + gbrain | | `--yes` | Skip the single confirm prompt | | `--dry-run` | Print the plan, write nothing | -| `--targets claude,cursor` | Force the host list (skip auto-detect) | +| `--targets claude,cursor` | Force the host list (Cursor gets the portable subset: rtk + MCP graph tools) | | `--profile ` | Force a profile (`code` `review` `multimodal` `spec` `research` `max`) | -| `--no-install` | Don't install `ccusage` (configs only) | -| `--allow-noncommercial` | Enable opt-in adapters (`context-mode`, `token-optimizer`) | +| `--no-install` | Write configs only; print install guidance instead of installing | | `--overwrite` | Replace existing files instead of merging (still backs up) | | `--force` | Re-run even if already installed | @@ -291,14 +290,14 @@ agent-stack/ │ ├── constants.ts # all spec values (token budgets, limits) │ ├── core/ # detect · plan · safe-writer · backup · token estimator │ ├── builtin/ # graph (code map) · compress (output compression) -│ ├── generate/ # claude · cursor · mcp file builders -│ ├── adapters/ # ccusage + detect-only externals; install + hooks +│ ├── generate/ # claude · cursor · mcp · coordinator file builders +│ ├── adapters/ # registry · detect-tools · install · hooks │ ├── wire-hooks.ts # SOLE writer of settings.json hooks │ ├── activate.ts # post-write verification chain │ ├── audit.ts # token-budget linting │ └── commands/ # init + maintenance commands ├── skills/ # 5 Agent Skills (stack-bootstrap, -doctor, …) -├── integrations/ # profiles.json · versions.json · licenses.json +├── integrations/ # profiles.json · tools.json ├── templates/ # generation notes └── test/ # vitest: unit · golden · e2e init in a tmpdir ``` @@ -408,16 +407,21 @@ Yes — `init` is idempotent. A matching prior install is a no-op unless you pas ## 🙏 Credits & prior art -agent-stack composes ideas from across the Claude/agent token-optimization ecosystem. It **vendors none** of them — its built-in code map and compression are original MIT code, and the only tool it auto-installs is `ccusage`. Full, transparent attribution (integrated vs. optional vs. inspiration, with licenses) lives in **[CREDITS.md](CREDITS.md)**. +agent-stack composes a permissive, real tool stack. It **vendors none** of it — its built-in code map and compression are original MIT code that act as the **fallback** when a tool isn't installed. Every integrated tool is MIT or Apache-2.0; nothing non-permissive is wired in. Tools are detected first, then installed via their own toolchains (cargo / uv / pipx / bun / `claude plugin`), with guidance when a toolchain is missing. Full, transparent attribution with licenses and exact install commands lives in **[CREDITS.md](CREDITS.md)** and **[integrations/tools.json](integrations/tools.json)**. -At a glance: +The `max` profile (`init --all`) activates, all at once: -| Relationship | Projects | -|---|---| -| **Integrated** | [ccusage](https://github.com/ryoppippi/ccusage) (measurement) | -| **Optional** (detect-only) | `rtk`, `codegraph`, [code-review-graph](https://github.com/tirth8205/code-review-graph), [graphify](https://github.com/safishamsi/graphify), [cc-spex](https://github.com/rhuss/cc-spex) | -| **Opt-in** (`--allow-noncommercial`) | [context-mode](https://github.com/mksglu/context-mode), [token-optimizer](https://github.com/alexgreensh/token-optimizer) | -| **Inspiration** | [claude-token-optimizer](https://github.com/nadimtuhin/claude-token-optimizer), [superpowers](https://github.com/obra/superpowers), [vercel-labs/skills](https://github.com/vercel-labs/skills), `caveman` | +| Tool | License | Integration | Job | +|---|---|---|---| +| [ccusage](https://github.com/ryoppippi/ccusage) | MIT | npm binary | Token-usage measurement (always on) | +| [rtk](https://github.com/rtk-ai/rtk) | Apache-2.0 | PATH binary | Command proxy — cut heavy command output 60-90% | +| [code-review-graph](https://github.com/tirth8205/code-review-graph) | MIT | MCP server | Primary code map (graph with edges + impact radius) | +| [graphify](https://github.com/safishamsi/graphify) | MIT | CLI / skill | Knowledge graph for whole-repo, multi-file-type questions | +| [caveman](https://github.com/JuliusBrussee/caveman) | MIT | Claude Code plugin | Terse-output mode | +| [claude-handoff](https://github.com/willseltzer/claude-handoff) | MIT | Claude Code plugin | Session continuity (`/handoff:*`) | +| [gbrain](https://github.com/garrytan/gbrain) | MIT | Bun CLI / plugin | Persistent cross-session memory | + +The generated `CLAUDE.md` and `AGENTS.md` carry a **tool coordinator** that routes each job to the right tool, with the built-ins named as the explicit fallback. Cursor gets only the portable subset (`rtk` + the MCP/CLI graph tools). **Inspiration** (not integrated): [claude-token-optimizer](https://github.com/nadimtuhin/claude-token-optimizer), [superpowers](https://github.com/obra/superpowers), [vercel-labs/skills](https://github.com/vercel-labs/skills), [gstack](https://github.com/garrytan/gstack). ## 🔗 References diff --git a/integrations/licenses.json b/integrations/licenses.json deleted file mode 100644 index 099531b..0000000 --- a/integrations/licenses.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$comment": "Gates --allow-noncommercial. The built-in graph/compression are MIT (part of this repo). Third-party adapters are detect-only; non-permissive ones still require the opt-in flag before agent-stack will shell out to them.", - "adapters": { - "ccusage": { "spdx": "MIT", "permissive": true }, - "rtk": { "spdx": "Apache-2.0", "permissive": true }, - "codegraph": { "spdx": "MIT", "permissive": true }, - "code-review-graph": { "spdx": "MIT", "permissive": true }, - "graphify": { "spdx": "MIT", "permissive": true }, - "context-mode": { "spdx": "Elastic-2.0", "permissive": false, "requires": "allow-noncommercial" }, - "token-optimizer": { "spdx": "PolyForm-Noncommercial-1.0.0", "permissive": false, "requires": "allow-noncommercial" } - } -} diff --git a/integrations/profiles.json b/integrations/profiles.json index f456cf3..7401238 100644 --- a/integrations/profiles.json +++ b/integrations/profiles.json @@ -47,13 +47,14 @@ "requires": [] }, "max": { - "description": "Everything on at once: code map + compression + terse output + all skills, agents, and hooks + measurement. Maximum token reduction.", + "description": "Everything on at once: the full external tool stack (rtk + code-review-graph + graphify + caveman + claude-handoff + gbrain) layered over agent-stack's built-ins, plus all skills, agents, hooks, and measurement. Maximum token reduction.", "graph": "builtin", "compression": "builtin", "caveman": true, "skills": ["stack-bootstrap", "stack-doctor", "stack-graph-profile", "stack-handoff", "stack-measure"], "agents": ["stack-explorer", "stack-reviewer"], - "requires": [] + "requires": [], + "tools": ["rtk", "code-review-graph", "graphify", "caveman", "claude-handoff", "gbrain"] } } } diff --git a/integrations/tools.json b/integrations/tools.json new file mode 100644 index 0000000..4561d32 --- /dev/null +++ b/integrations/tools.json @@ -0,0 +1,107 @@ +{ + "$comment": "Single source of truth for every external tool agent-stack wires in. Commands here were transcribed from each repo's own INSTALL.md/README/.mcp.json — never guessed. Install strategies are tried in order; the first whose `needs` toolchain is on PATH wins. `curl|sh` one-liners are intentionally NOT auto-run — they live in `guidance` so the user runs them deliberately. All tools are permissive (MIT/Apache-2.0); the two unlicensed tools (token-optimizer, intent-layer) were dropped because permissive members already cover their function.", + "tools": { + "ccusage": { + "version": "20.0.6", + "repo": "ryoppippi/ccusage", + "integration": "path", + "detect": { "kind": "path", "bin": "ccusage" }, + "install": [{ "needs": "npm", "run": ["npm", "i", "-g", "ccusage@20.0.6"] }], + "role": "Measurement only — token usage is logged via the Stop hook; you never call this directly.", + "cursor": true, + "guidance": "Install measurement tool: npm i -g ccusage", + "spdx": "MIT", + "permissive": true + }, + "rtk": { + "repo": "rtk-ai/rtk", + "integration": "path", + "detect": { "kind": "path", "bin": "rtk", "verify": ["--version"] }, + "install": [{ "needs": "cargo", "run": ["cargo", "install", "--git", "https://github.com/rtk-ai/rtk"] }], + "role": "Route heavy shell commands (git, npm, build, test, grep) through `rtk` — it proxies them and cuts 60-90% of the output tokens before they hit context.", + "cursor": true, + "guidance": "Install rtk (Rust Token Killer — NOT crates.io `rtk`, that is a different project): curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/master/install.sh | sh — then verify with `rtk gain`.", + "spdx": "Apache-2.0", + "permissive": true + }, + "code-review-graph": { + "repo": "tirth8205/code-review-graph", + "integration": "mcp", + "detect": { "kind": "path", "bin": "code-review-graph" }, + "install": [ + { "needs": "uv", "run": ["uv", "tool", "install", "code-review-graph"] }, + { "needs": "pipx", "run": ["pipx", "install", "code-review-graph"] }, + { "needs": "pip3", "run": ["pip3", "install", "code-review-graph"] }, + { "needs": "pip", "run": ["pip", "install", "code-review-graph"] } + ], + "mcp": { "server": "code-review-graph", "command": "uvx", "args": ["code-review-graph", "serve"] }, + "refresh": ["code-review-graph", "update"], + "role": "Primary code map. To find where a symbol lives, what depends on it, or a review's impact radius, query the `code-review-graph` MCP server instead of reading whole files.", + "cursor": true, + "guidance": "Install code intelligence graph (Python 3.10+): uv tool install code-review-graph (or pipx/pip). Then `code-review-graph build` to parse the repo.", + "spdx": "MIT", + "permissive": true + }, + "graphify": { + "repo": "safishamsi/graphify", + "integration": "skill", + "detect": { "kind": "path", "bin": "graphify" }, + "install": [ + { "needs": "uv", "run": ["uv", "tool", "install", "graphifyy"] }, + { "needs": "pipx", "run": ["pipx", "install", "graphifyy"] }, + { "needs": "pip3", "run": ["pip3", "install", "graphifyy"] }, + { "needs": "pip", "run": ["pip", "install", "graphifyy"] } + ], + "role": "Knowledge graph for whole-repo questions spanning code + docs + schemas. Use the `graphify` skill to build/query a queryable graph when a question crosses many files or file types.", + "cursor": true, + "guidance": "Install graphify (PyPI package is `graphifyy` (double-y); CLI stays `graphify`): uv tool install graphifyy (or pipx). Avoid plain `pip` on macOS — see the repo README.", + "spdx": "MIT", + "permissive": true + }, + "caveman": { + "repo": "JuliusBrussee/caveman", + "integration": "plugin", + "detect": { "kind": "plugin", "name": "caveman" }, + "install": [ + { + "needs": "claude", + "run": ["claude", "plugin", "marketplace", "add", "JuliusBrussee/caveman"], + "then": ["claude", "plugin", "install", "caveman@caveman"] + } + ], + "role": "Terse-output mode. When the user wants minimum token cost, follow caveman conventions to compress your own prose.", + "cursor": false, + "guidance": "Install caveman as a Claude Code plugin: `claude plugin marketplace add JuliusBrussee/caveman && claude plugin install caveman@caveman` (or: curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash).", + "spdx": "MIT", + "permissive": true + }, + "claude-handoff": { + "repo": "willseltzer/claude-handoff", + "integration": "plugin", + "detect": { "kind": "plugin", "name": "handoff" }, + "install": [ + { + "needs": "claude", + "run": ["claude", "plugin", "marketplace", "add", "willseltzer/claude-handoff"], + "then": ["claude", "plugin", "install", "handoff@handoff-marketplace"] + } + ], + "role": "Session continuity. At session end or when switching context, run `/handoff:create`; when resuming, `/handoff:resume`.", + "cursor": false, + "guidance": "Install handoff as a Claude Code plugin: `claude plugin marketplace add willseltzer/claude-handoff && claude plugin install handoff@handoff-marketplace`. If the clone fails with `git@github.com: Permission denied (publickey)`, Claude is cloning over SSH — add a GitHub SSH key, or route SSH→HTTPS with `git config --global url.\"https://github.com/\".insteadOf \"git@github.com:\"`.", + "spdx": "MIT", + "permissive": true + }, + "gbrain": { + "repo": "garrytan/gbrain", + "integration": "skill", + "detect": { "kind": "path", "bin": "gbrain" }, + "install": [{ "needs": "bun", "run": ["bun", "install", "-g", "github:garrytan/gbrain"] }], + "role": "Persistent memory across sessions. Store durable decisions/context in gbrain and search it before re-deriving prior work.", + "cursor": false, + "guidance": "Install gbrain (requires Bun): curl -fsSL https://bun.sh/install | bash && bun install -g github:garrytan/gbrain — then `gbrain --version`. Needs an embedding API key for vector search (keyword search works without).", + "spdx": "MIT", + "permissive": true + } + } +} diff --git a/integrations/versions.json b/integrations/versions.json deleted file mode 100644 index bd4e2e1..0000000 --- a/integrations/versions.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "$comment": "Only ccusage is auto-installed (it is the genuine Claude Code usage tool on npm). The graph and compression tools agent-stack uses by default are BUILT IN (see src/builtin/). Third-party graph/compression binaries are 'preinstalled' kind: used only if the real tool is already on PATH, never auto-installed by name (those npm names are unrelated/squatted).", - "adapters": { - "ccusage": { - "version": "20.0.6", - "install": { "kind": "npm", "package": "ccusage", "bin": "ccusage" } - }, - "rtk": { - "version": "*", - "install": { "kind": "preinstalled", "package": "rtk", "bin": "rtk" } - }, - "codegraph": { - "version": "*", - "install": { "kind": "preinstalled", "package": "codegraph", "bin": "codegraph" } - }, - "code-review-graph": { - "version": "*", - "install": { "kind": "preinstalled", "package": "code-review-graph", "bin": "code-review-graph" } - }, - "graphify": { - "version": "*", - "install": { "kind": "preinstalled", "package": "graphify", "bin": "graphify" } - }, - "context-mode": { - "version": "*", - "install": { "kind": "preinstalled", "package": "context-mode", "bin": "context-mode" } - }, - "token-optimizer": { - "version": "*", - "install": { "kind": "preinstalled", "package": "token-optimizer", "bin": "token-optimizer" } - } - } -} diff --git a/package.json b/package.json index 958a19a..cd68008 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "@drmahdikazempour/agent-stack", - "version": "0.2.0", + "version": "0.3.0", "publishConfig": { "access": "public" }, - "description": "Skills-first optimization toolkit for Claude Code (+ Cursor). One command sets up, activates, and measures an optimized agent context.", + "description": "Claude Code optimization toolkit and tool coordinator. One command detects, installs, and wires a permissive external stack (rtk, code-review-graph, graphify, caveman, claude-handoff, gbrain) over built-in fallbacks, then measures the token savings. Cursor gets the portable subset (rtk + MCP graph tools).", "keywords": [ "claude", "claude-code", diff --git a/src/activate.ts b/src/activate.ts index b0d4945..ec1c8fd 100644 --- a/src/activate.ts +++ b/src/activate.ts @@ -53,13 +53,13 @@ export function activate( detail: `${hookCount}/${opts.expectedHooks} agent-stack hooks wired`, }); - // 3. Adapter binaries callable (skill-only adapters pass trivially). + // 3. Tools available (detection-aware: PATH binary, plugin, skill, or MCP server). for (const a of opts.adapters) { - const ok = verifyAdapter(a); + const ok = verifyAdapter(a, cwd); checks.push({ name: `adapter:${a.name}`, ok, - detail: ok ? "callable" : "binary not on PATH (config written; install to fully activate)", + detail: ok ? "available" : "not detected (config written; install to fully activate)", }); } diff --git a/src/adapters/detect-tools.ts b/src/adapters/detect-tools.ts new file mode 100644 index 0000000..e6d417d --- /dev/null +++ b/src/adapters/detect-tools.ts @@ -0,0 +1,52 @@ +import path from "node:path"; +import { PATHS } from "../constants.js"; +import type { AdapterDescriptor } from "../core/types.js"; +import { fileExists, hasBinary, homePath, readJsonSafe, run } from "../core/util.js"; + +/** + * Is a tool already available? Detection is integration-aware: + * - path: binary on PATH (the verify args are advisory, not required, to + * avoid false negatives when a tool errors outside a project). + * - plugin: a Claude Code plugin is installed (`claude plugin list` or the + * plugins dir under ~/.claude). + * - skill: a skill dir exists under ~/.claude, or its CLI is on PATH. + * - mcp: its server key is present in the repo's .mcp.json. + */ +export function isToolPresent(adapter: AdapterDescriptor, cwd: string): boolean { + const d = adapter.detect; + switch (d.kind) { + case "path": + return hasBinary(d.bin); + case "plugin": + return pluginInstalled(d.name); + case "skill": + return skillInstalled(d.name) || hasBinary(d.name); + case "mcp": + return mcpServerPresent(cwd, d.server); + } +} + +function pluginInstalled(name: string): boolean { + if (hasBinary("claude")) { + const r = run("claude", ["plugin", "list"], { timeoutMs: 15_000 }); + if (r.ok && new RegExp(`(^|[^\\w-])${escapeRe(name)}([^\\w-]|$)`, "m").test(r.stdout)) return true; + } + // Fallback: look for the plugin under the Claude plugins dir. + return ( + fileExists(homePath(".claude", "plugins", name)) || + fileExists(homePath(".claude", "plugins", "marketplaces", name)) + ); +} + +function skillInstalled(name: string): boolean { + return fileExists(homePath(".claude", "skills", name)) || fileExists(homePath(".claude", "plugins", name)); +} + +function mcpServerPresent(cwd: string, server: string): boolean { + const mcp = readJsonSafe<{ mcpServers?: Record }>(path.join(cwd, PATHS.mcpConfig)); + return !!mcp?.mcpServers && server in mcp.mcpServers; +} + +function escapeRe(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/src/adapters/hooks.ts b/src/adapters/hooks.ts index b8ec13a..5e8a1a8 100644 --- a/src/adapters/hooks.ts +++ b/src/adapters/hooks.ts @@ -1,12 +1,13 @@ import { HOOK_SIGNATURE } from "../constants.js"; -import type { HookSpec, ProfileConfig } from "../core/types.js"; +import type { AdapterDescriptor, HookSpec, ProfileConfig } from "../core/types.js"; /** - * Hooks are derived from the profile and use agent-stack's OWN built-in - * commands (graph refresh, ccusage logging) rather than fictional third-party - * binaries. wire-hooks remains the sole writer of settings.json; this module - * only returns specs. Every command carries the agent-stack signature so the - * merger can find and dedupe its own entries. + * Hooks are derived from the profile + active stack. The SessionStart code-map + * refresh prefers an external graph backend's own refresh command (e.g. + * `code-review-graph update`) when that tool is active, and falls back to + * agent-stack's built-in graph otherwise. wire-hooks remains the sole writer of + * settings.json; this module only returns specs. Every command carries the + * agent-stack signature so the merger can find and dedupe its own entries. */ const NPX = "npx -y @drmahdikazempour/agent-stack"; @@ -17,18 +18,29 @@ function tag(cmd: string): string { const BUILTIN_GRAPH = new Set(["builtin", ""]); -export function hooksForProfile(profile: ProfileConfig): HookSpec[] { +/** First active tool that provides a graph refresh command (external backend). */ +function externalGraphRefresh(adapters: AdapterDescriptor[]): string | null { + const g = adapters.find((a) => a.refresh && a.refresh.length > 0); + return g ? g.refresh!.join(" ") : null; +} + +export function hooksForProfile(profile: ProfileConfig, adapters: AdapterDescriptor[] = []): HookSpec[] { const hooks: HookSpec[] = []; - // Refresh the compact code map at session start (skip for graph: none). + // Refresh the code map at session start (skip for graph: none). if (profile.graph && profile.graph !== "none") { - const cmd = BUILTIN_GRAPH.has(profile.graph) - ? `${NPX} graph refresh --quiet` - : `${profile.graph} refresh --quiet`; + const external = externalGraphRefresh(adapters); + const cmd = external + ? external + : BUILTIN_GRAPH.has(profile.graph) + ? `${NPX} graph refresh --quiet` + : `${profile.graph} refresh --quiet`; hooks.push({ event: "SessionStart", command: tag(`${cmd} 2>/dev/null || true`), - reason: "Refresh the code map at session start so the agent greps it instead of reading files", + reason: external + ? `Refresh ${external.split(" ")[0]} index at session start (external graph backend)` + : "Refresh the code map at session start so the agent greps it instead of reading files", }); } diff --git a/src/adapters/install.ts b/src/adapters/install.ts index 004bb0b..14d628c 100644 --- a/src/adapters/install.ts +++ b/src/adapters/install.ts @@ -1,97 +1,79 @@ -import type { AdapterDescriptor, AdapterInstallSpec } from "../core/types.js"; +import type { AdapterDescriptor } from "../core/types.js"; import { hasBinary, run } from "../core/util.js"; +import { isToolPresent } from "./detect-tools.js"; export interface InstallOutcome { adapter: string; - status: "present" | "installed" | "skipped" | "failed" | "config-only" | "optional-absent"; + status: "present" | "installed" | "skipped" | "failed" | "config-only" | "needs-toolchain"; detail: string; -} - -function binName(spec: AdapterInstallSpec): string | null { - return spec.bin ?? (spec.kind === "skill" ? null : spec.package); -} - -function toolchainAvailable(kind: AdapterInstallSpec["kind"]): boolean { - if (kind === "npm" || kind === "skill") return hasBinary("npm") || hasBinary("npx"); - if (kind === "cargo") return hasBinary("cargo"); - return false; -} - -function doInstall(spec: AdapterInstallSpec, version: string): { ok: boolean; detail: string } { - switch (spec.kind) { - case "npm": { - const r = run("npm", ["i", "-g", `${spec.package}@${version}`], { timeoutMs: 180_000 }); - return { ok: r.ok, detail: r.ok ? `npm i -g ${spec.package}@${version}` : r.stderr.slice(0, 200) }; - } - case "cargo": { - const r = run("cargo", ["install", spec.package, "--version", version], { timeoutMs: 600_000 }); - return { ok: r.ok, detail: r.ok ? `cargo install ${spec.package}` : r.stderr.slice(0, 200) }; - } - case "skill": { - // Cross-agent skills install via vercel-labs/skills (`npx skills add`). - const r = run("npx", ["-y", "skills", "add", spec.package], { timeoutMs: 180_000 }); - return { ok: r.ok, detail: r.ok ? `skills add ${spec.package}` : r.stderr.slice(0, 200) }; - } - case "preinstalled": - // Never auto-installed; handled before doInstall is ever reached. - return { ok: false, detail: "preinstalled adapters are not auto-installed" }; - } + /** Manual finish-it instruction, shown when we couldn't fully auto-install. */ + guidance?: string; } /** - * Ensure an adapter binary/skill is available. Honors --no-install (configOnly) - * and falls back gracefully when a toolchain is missing (PRD §12 risk row). + * Detect → install-if-missing → use. Tries the tool's ordered install + * strategies and stops at the first whose toolchain is on PATH. Never auto-runs + * `curl|sh` one-liners — those live in `guidance` so the user runs them + * deliberately. Honors --no-install (configOnly) and degrades to clear guidance + * when no toolchain is available rather than failing the whole init. */ export function ensureAdapter( adapter: AdapterDescriptor, - opts: { install: boolean }, + opts: { install: boolean; cwd: string }, ): InstallOutcome { - const bin = binName(adapter.install); - - if (bin && hasBinary(bin)) { - return { adapter: adapter.name, status: "present", detail: `${bin} already on PATH` }; + // 1. Already there? Use it. + if (isToolPresent(adapter, opts.cwd)) { + return { adapter: adapter.name, status: "present", detail: `${adapter.name} already available` }; } - // Never auto-install 'preinstalled' adapters (their npm names are unrelated). - if (adapter.install.kind === "preinstalled") { + // 2. Config-only mode: write configs, leave install to the user. + if (!opts.install) { return { adapter: adapter.name, - status: "optional-absent", - detail: `optional; install the real ${adapter.install.package} yourself to enable it`, + status: "config-only", + detail: "skipped (--no-install)", + guidance: adapter.guidance, }; } - if (!opts.install) { - return { adapter: adapter.name, status: "config-only", detail: "skipped (--no-install)" }; - } - - if (!toolchainAvailable(adapter.install.kind)) { - // Try fallback if its toolchain exists. - if (adapter.fallback && toolchainAvailable(adapter.fallback.kind)) { - const r = doInstall(adapter.fallback, adapter.version); - if (r.ok) return { adapter: adapter.name, status: "installed", detail: r.detail }; - } + // 3. Try each strategy whose toolchain is present, in order, until one lands. + // Falling through on failure matters: e.g. a PEP-668 "externally-managed" + // pip3 fails, but the next available pip (or pipx/uv) may succeed. + const available = adapter.install.filter((s) => hasBinary(s.needs)); + if (available.length === 0) { + const needed = [...new Set(adapter.install.map((s) => s.needs))].join(" / "); return { adapter: adapter.name, - status: "config-only", - detail: `no ${adapter.install.kind} toolchain; config written, install ${adapter.install.package} manually`, + status: "needs-toolchain", + detail: `no toolchain on PATH (need ${needed})`, + guidance: adapter.guidance, }; } - const primary = doInstall(adapter.install, adapter.version); - if (primary.ok) return { adapter: adapter.name, status: "installed", detail: primary.detail }; - - if (adapter.fallback && toolchainAvailable(adapter.fallback.kind)) { - const fb = doInstall(adapter.fallback, adapter.version); - if (fb.ok) return { adapter: adapter.name, status: "installed", detail: `${fb.detail} (fallback)` }; + let lastErr = ""; + for (const strategy of available) { + const primary = run(strategy.run[0]!, strategy.run.slice(1), { timeoutMs: 600_000 }); + if (!primary.ok) { + lastErr = `${strategy.run.join(" ")} → ${primary.stderr.slice(0, 140)}`; + continue; + } + if (strategy.then) { + const second = run(strategy.then[0]!, strategy.then.slice(1), { timeoutMs: 600_000 }); + if (!second.ok) { + lastErr = `${strategy.then.join(" ")} → ${second.stderr.slice(0, 140)}`; + continue; + } + } + if (isToolPresent(adapter, opts.cwd)) { + return { adapter: adapter.name, status: "installed", detail: strategy.run.join(" ") }; + } + lastErr = `${strategy.run.join(" ")} ran but tool still not detected`; } - return { adapter: adapter.name, status: "failed", detail: primary.detail }; + return { adapter: adapter.name, status: "failed", detail: lastErr, guidance: adapter.guidance }; } -/** Verify a binary adapter is callable after install (activation step). */ -export function verifyAdapter(adapter: AdapterDescriptor): boolean { - const bin = binName(adapter.install); - if (!bin) return true; // skill-only adapters are verified by the host, not us - return hasBinary(bin); +/** Post-write verification (activation step): is the tool callable/available now? */ +export function verifyAdapter(adapter: AdapterDescriptor, cwd: string): boolean { + return isToolPresent(adapter, cwd); } diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts index 4bdd376..7af2690 100644 --- a/src/adapters/registry.ts +++ b/src/adapters/registry.ts @@ -1,50 +1,32 @@ import fs from "node:fs"; -import type { AdapterDescriptor } from "../core/types.js"; +import type { AdapterDescriptor, ProfileConfig } from "../core/types.js"; import { integrationsPath } from "../core/pkg-root.js"; -// Loaded from the integrations/ JSON so versions & licenses live in one place. -const versions = JSON.parse(fs.readFileSync(integrationsPath("versions.json"), "utf8")) as { - adapters: Record; -}; -const licenses = JSON.parse(fs.readFileSync(integrationsPath("licenses.json"), "utf8")) as { - adapters: Record; +// Loaded from integrations/tools.json — the single source of truth for every +// external tool's source repo, ordered install strategies, integration kind, +// detection method, MCP server entry, coordinator role, and license. +const data = JSON.parse(fs.readFileSync(integrationsPath("tools.json"), "utf8")) as { + tools: Record>; }; export function getAdapter(name: string): AdapterDescriptor { - const v = versions.adapters[name]; - const l = licenses.adapters[name]; - if (!v || !l) throw new Error(`Unknown adapter: ${name}`); - return { - name, - version: v.version, - install: v.install, - fallback: v.fallback, - spdx: l.spdx, - permissive: l.permissive, - requires: l.requires, - }; + const t = data.tools[name]; + if (!t) throw new Error(`Unknown tool: ${name}`); + return { name, ...t }; } export function allAdapterNames(): string[] { - return Object.keys(versions.adapters); + return Object.keys(data.tools); } -const BUILTIN = new Set(["builtin", "none", ""]); - /** - * Adapters a profile needs. Built-in graph/compression aren't adapters (they - * ship in src/builtin/). ccusage (measurement) is always present. An external - * graph/compression name maps to a detect-only adapter that is used only if its - * real binary is already on PATH. + * The external tools a profile activates. Measurement (ccusage) is always + * present; a profile's `tools` list adds the rest. Built-in graph/compression + * are not adapters (they ship in src/builtin/) — they're the fallback when an + * external tool is absent. */ -export function adaptersForProfile( - graph: string, - compression: string, - _caveman: boolean, -): AdapterDescriptor[] { - const names = new Set(); - names.add("ccusage"); // measurement is always present - if (graph && !BUILTIN.has(graph)) names.add(graph); - if (compression && !BUILTIN.has(compression)) names.add(compression); +export function adaptersForProfile(profile: ProfileConfig): AdapterDescriptor[] { + const names = new Set(["ccusage"]); + for (const t of profile.tools ?? []) names.add(t); return [...names].map(getAdapter); } diff --git a/src/cli.ts b/src/cli.ts index b63b94a..e70bddb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -70,15 +70,18 @@ ${color.bold("Maintenance:")} agent-stack uninstall restore backup, remove generated files ${color.bold("init flags:")} - --all turn on EVERY feature at once (the 'max' profile) + --all full stack at once (the 'max' profile): rtk + code-review-graph + + graphify + caveman + claude-handoff + gbrain over the built-ins --yes skip the confirm prompt --dry-run show the plan, write nothing - --targets claude,cursor force target list + --targets claude,cursor force target list (Cursor uses the portable subset: rtk + MCP graph tools) --profile force profile (code|review|multimodal|spec|research|max) - --no-install don't install ccusage (configs only) - --allow-noncommercial enable opt-in adapters (context-mode, token-optimizer) + --no-install write configs only; print install guidance instead of installing --overwrite replace existing files instead of merging (still backs up) --force re-run even if already installed + +${color.bold("External tools are detected first; missing ones are installed via their own")} +${color.bold("toolchains (cargo/uv/pipx/bun/claude plugin), with guidance when a toolchain is absent.")} `; export async function main(argv = process.argv.slice(2)): Promise { diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index f198546..f4b791f 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -4,6 +4,8 @@ import { PATHS } from "../constants.js"; import { audit } from "../audit.js"; import { planHooks } from "../wire-hooks.js"; import { hooksForProfile } from "../adapters/hooks.js"; +import { adaptersForProfile } from "../adapters/registry.js"; +import { isToolPresent } from "../adapters/detect-tools.js"; import { getProfileConfig } from "../core/plan.js"; import { color, sym, fileExists, readJsonSafe } from "../core/util.js"; import { parseFrontmatter } from "../generate/frontmatter.js"; @@ -53,16 +55,28 @@ export function runDoctor(cwd: string, opts: { skillsOnly?: boolean } = {}): Doc if (!item.ok) failures.push(`${item.name}: ${item.detail} (limit ${item.limit})`); } - // Hook conflicts: re-merge our profile's hook specs against current settings. + // Hook conflicts + tool availability for the installed profile. const manifest = readJsonSafe<{ profile: string }>(path.join(cwd, PATHS.installedManifest)); if (manifest) { try { const profile = getProfileConfig(manifest.profile as any); - const specs = hooksForProfile(profile); + const adapters = adaptersForProfile(profile); + const specs = hooksForProfile(profile, adapters); const { result } = planHooks(cwd, specs); const ok = result.conflicts.length === 0; report("hook conflicts", ok, `${result.conflicts.length} conflict(s)`); if (!ok) failures.push(`${result.conflicts.length} hook conflict(s)`); + + // Each active tool: present (use it) or absent (finish-it guidance). Absence is + // non-fatal — the built-in fallback keeps the stack working — so it shows a + // neutral marker, not a failing ✗. + for (const a of adapters) { + if (a.name === "ccusage") continue; + const present = isToolPresent(a, cwd); + const mark = present ? sym.ok : sym.warn; + console.log(` ${mark} ${`tool:${a.name}`.padEnd(34)} ${color.dim(present ? "available" : "absent — using built-in fallback")}`); + if (!present) console.log(` ${color.dim(a.guidance)}`); + } } catch { /* profile gone; skip */ } diff --git a/src/commands/init.ts b/src/commands/init.ts index 3308d1b..e0b4d0c 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -26,9 +26,10 @@ function printPlan(plan: Plan): void { if (d.existing.claudeMd) console.log(` Existing: CLAUDE.md (${d.existing.claudeMd.tokens} tokens, will merge)`); if (d.existing.claudeDir) console.log(` Existing: .claude/ (will back up)`); console.log(""); - console.log(color.bold("Will install:")); + console.log(color.bold("Will set up tools:")); for (const a of plan.adapters) { - console.log(` - ${a.name.padEnd(18)} @ ${a.version.padEnd(8)} (${a.spdx})`); + const src = a.repo ?? a.version ?? ""; + console.log(` - ${a.name.padEnd(18)} ${a.integration.padEnd(7)} ${src.padEnd(28)} (${a.spdx})`); } console.log(` - ${getProfileSkillCount(plan)} skills (${plan.profile} profile)`); console.log(""); @@ -110,17 +111,24 @@ export async function runInit(opts: InitOptions): Promise { const writer = new SafeWriter(opts.cwd); try { - // 5. Install adapter binaries + // 5. Install the active tool stack (detect → install-if-missing → guidance) const installs: InstallOutcome[] = []; for (const a of plan.adapters) { - installs.push(ensureAdapter(a, { install: !opts.noInstall })); + installs.push(ensureAdapter(a, { install: !opts.noInstall, cwd: opts.cwd })); } const installedNames = installs .filter((i) => i.status === "installed" || i.status === "present") .map((i) => i.adapter); console.log( - ` ${sym.ok} Adapters: ${installs.map((i) => `${i.adapter}(${i.status})`).join(", ")}`, + ` ${sym.ok} Tools: ${installs.map((i) => `${i.adapter}(${i.status})`).join(", ")}`, ); + // Surface clear, deliberate guidance for anything we couldn't fully auto-install. + const needsHelp = installs.filter((i) => i.guidance && i.status !== "present" && i.status !== "installed"); + if (needsHelp.length) { + console.log(""); + console.log(color.bold(" Finish installing (run these yourself):")); + for (const i of needsHelp) console.log(` ${sym.warn} ${i.adapter}: ${i.guidance}`); + } // 6. Generate files const writeRes = writer.writePlanned(plan.files); diff --git a/src/constants.ts b/src/constants.ts index fda4cbb..bb67738 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -45,4 +45,4 @@ export type HookEvent = (typeof HOOK_EVENTS)[number]; /** Marker prefix on every hook command agent-stack writes, so we can find & dedupe our own. */ export const HOOK_SIGNATURE = "agent-stack"; -export const TOOL_VERSION = "0.1.0"; +export const TOOL_VERSION = "0.3.0"; diff --git a/src/core/pkg-root.ts b/src/core/pkg-root.ts index ac8a7b8..ee95e59 100644 --- a/src/core/pkg-root.ts +++ b/src/core/pkg-root.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; * Resolve the package root robustly. The integrations/ JSON lives at the * package root, but the code that reads it runs from two locations: src/ under * vitest, and a flattened dist/ after tsup bundling. Walking up to the dir that - * contains integrations/versions.json works for both without per-file fragile + * contains integrations/tools.json works for both without per-file fragile * relative paths. */ let cached: string | null = null; @@ -15,7 +15,7 @@ export function packageRoot(): string { if (cached) return cached; let dir = path.dirname(fileURLToPath(import.meta.url)); for (let i = 0; i < 8; i++) { - if (fs.existsSync(path.join(dir, "integrations", "versions.json"))) { + if (fs.existsSync(path.join(dir, "integrations", "tools.json"))) { cached = dir; return dir; } diff --git a/src/core/plan.ts b/src/core/plan.ts index 8e46941..257543b 100644 --- a/src/core/plan.ts +++ b/src/core/plan.ts @@ -31,17 +31,17 @@ export function buildPlan(detection: Detection, opts: InitOptions): Plan { const targets = opts.targets ?? detection.hosts; - const adapters = adaptersForProfile(profile.graph, profile.compression, profile.caveman).filter( + const adapters = adaptersForProfile(profile).filter( (a) => a.permissive || opts.allowNoncommercial, ); - const ctx = buildContext(detection, profileName, profile); + const ctx = buildContext(detection, profileName, profile, adapters, targets); const files = generateClaude(ctx); if (targets.includes("cursor")) files.push(...generateCursor(ctx)); files.push(...generateMcp(ctx)); - const hooks = hooksForProfile(profile); + const hooks = hooksForProfile(profile, adapters); const alreadyInstalled = !opts.force && diff --git a/src/core/types.ts b/src/core/types.ts index 26ef9ce..f4a8fa7 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -39,28 +39,61 @@ export interface ProfileConfig { skills: string[]; agents: string[]; requires: string[]; + /** External tools this profile activates (keys into integrations/tools.json). */ + tools?: string[]; } -export interface AdapterInstallSpec { - /** - * "npm" auto-installs from the real registry. "preinstalled" means agent-stack - * never installs it (the npm name is unrelated/squatted) — it is only used if - * the genuine binary is already on PATH. "cargo"/"skill" are legacy install - * kinds kept for completeness. - */ - kind: "npm" | "cargo" | "skill" | "preinstalled"; - package: string; - bin?: string; +/** How a tool plugs into the host. Drives MCP wiring and coordinator docs. */ +export type AdapterIntegration = "path" | "mcp" | "plugin" | "skill"; + +/** + * One ordered install attempt. `needs` is the toolchain binary that must be on + * PATH for this strategy to run (cargo/uv/pipx/pip/bun/claude/npm). `run` is the + * primary argv; `then` is an optional second argv (e.g. `claude plugin install` + * after `claude plugin marketplace add`). The installer tries strategies in + * order and stops at the first whose toolchain is present. + */ +export interface InstallStrategy { + needs: string; + run: string[]; + then?: string[]; } +/** An MCP server entry written (merged) into `.mcp.json`. */ +export interface McpServerSpec { + server: string; + command: string; + args: string[]; +} + +/** How to tell whether a tool is already available. */ +export type DetectSpec = + | { kind: "path"; bin: string; verify?: string[] } + | { kind: "plugin"; name: string } + | { kind: "skill"; name: string } + | { kind: "mcp"; server: string }; + export interface AdapterDescriptor { name: string; - version: string; - install: AdapterInstallSpec; - fallback?: AdapterInstallSpec; + /** Pinned version, when meaningful (ccusage). Omitted for git/HEAD installs. */ + version?: string; + /** Source repo (owner/name), for guidance + credits. */ + repo?: string; + integration: AdapterIntegration; + detect: DetectSpec; + install: InstallStrategy[]; + /** MCP server entry to merge into .mcp.json (integration "mcp"). */ + mcp?: McpServerSpec; + /** Command to refresh this tool's index at SessionStart (external graph backends). */ + refresh?: string[]; + /** One-line coordinator instruction telling the agent when to reach for this tool. */ + role?: string; + /** Portable to Cursor (CLI or MCP). Claude-Code-only plugins/skills are false. */ + cursor?: boolean; + /** Shown when auto-install can't finish (missing toolchain / manual step). */ + guidance: string; spdx: string; permissive: boolean; - requires?: string; } /** A hook spec returned by an adapter. wire-hooks is the SOLE writer of settings.json. */ diff --git a/src/generate/claude.ts b/src/generate/claude.ts index a35a010..bdd351e 100644 --- a/src/generate/claude.ts +++ b/src/generate/claude.ts @@ -3,17 +3,14 @@ import type { PlannedFile } from "../core/types.js"; import { estimateTokens } from "../core/token-estimator.js"; import { buildFrontmatter } from "./frontmatter.js"; import type { GenContext } from "./context.js"; +import { coordinatorSection } from "./coordinator.js"; function pf(path: string, contents: string, host: PlannedFile["host"] = "shared"): PlannedFile { return { path, contents, tokens: estimateTokens(contents), host }; } -/** Factual root CLAUDE.md, kept ≤800 tokens (PRD success metric). */ +/** Factual root CLAUDE.md. The coordinator section routes work to the active stack. */ function claudeMd(ctx: GenContext): string { - const graphLine = - ctx.graph === "none" - ? "" - : `- Code map: \`.agent-stack/graph.md\` — grep it to find where a symbol lives before opening files.\n`; const terseLine = ctx.terse ? "- Output style: TERSE. Answer in the fewest words; no preamble, no recap, no restating the question.\n" : "- Keep responses terse; the user values low token cost.\n"; @@ -26,12 +23,9 @@ function claudeMd(ctx: GenContext): string { - Framework: ${ctx.framework} - Package manager: ${ctx.packageManager} -## Context tooling (active) -${graphLine}- Compression: \`${ctx.compressionLabel}\` — pipe large output through it, e.g. \`npm run build 2>&1 | npx -y @drmahdikazempour/agent-stack compress\`. -- Measurement: \`ccusage\` — token usage logged per turn to \`.agent-stack/usage.jsonl\`. - +${coordinatorSection(ctx)} ## How to work here -- Grep \`.agent-stack/graph.md\` and do targeted reads instead of loading whole directories. +- Find code via the code map / graph and do targeted reads instead of loading whole directories. - Compress large command output before letting it into context. ${terseLine}- Skills: ${ctx.profile.skills.map((s) => `\`/${s}\``).join(", ")}. - See \`ARCHITECTURE_MAP.md\` for structure and \`COMMON_MISTAKES.md\` for known traps. @@ -42,6 +36,30 @@ ${terseLine}- Skills: ${ctx.profile.skills.map((s) => `\`/${s}\``).join(", ")}. `; } +/** + * AGENTS.md — the shared coordinator both Claude Code and (when targeted) Cursor + * read. Always generated (Claude Code is the primary host); the Cursor mention + * appears only when Cursor is an actual target. + */ +function agentsMd(ctx: GenContext): string { + const hosts = ctx.targetsCursor ? "Claude Code and Cursor" : "Claude Code"; + return `# AGENTS.md + +Shared agent convention for **${ctx.repoName}** (read by ${hosts}). Profile: \`${ctx.profileName}\`. + +${coordinatorSection(ctx)} +## Working agreement +- Reach for the right tool above first; fall back to the built-ins only when it's absent. +- Find code via the code map / graph; do targeted reads instead of loading whole directories. +- Compress large command output before letting it into context. +- Keep responses terse; the user values low token cost. +- Match existing code style; never reformat unrelated code. +- Run the project's own test/lint commands before claiming done. + +_Generated by agent-stack. Edit \`CLAUDE.md\` and run \`npx @drmahdikazempour/agent-stack sync\` to regenerate this mirror._ +`; +} + function architectureMap(ctx: GenContext): string { return `# Architecture Map @@ -218,6 +236,7 @@ function commandFile(name: string, instruction: string): PlannedFile { export function generateClaude(ctx: GenContext): PlannedFile[] { const files: PlannedFile[] = [ pf(PATHS.claudeMd, claudeMd(ctx)), + pf(PATHS.agentsMd, agentsMd(ctx)), pf(PATHS.architectureMap, architectureMap(ctx)), pf(PATHS.commonMistakes, commonMistakes(ctx)), pf(PATHS.claudeIgnore, claudeIgnore()), diff --git a/src/generate/context.ts b/src/generate/context.ts index c562bd8..43e4f27 100644 --- a/src/generate/context.ts +++ b/src/generate/context.ts @@ -1,6 +1,15 @@ -import type { Detection, ProfileConfig, ProfileName } from "../core/types.js"; +import type { AdapterDescriptor, AdapterIntegration, Detection, McpServerSpec, ProfileConfig, ProfileName } from "../core/types.js"; -/** Everything a template needs to render, derived from detection + profile. */ +/** One active tool, as the coordinator docs present it. */ +export interface ActiveTool { + name: string; + role: string; + integration: AdapterIntegration; + cursor: boolean; + repo?: string; +} + +/** Everything a template needs to render, derived from detection + profile + active stack. */ export interface GenContext { cwd: string; profileName: ProfileName; @@ -16,6 +25,12 @@ export interface GenContext { compressionLabel: string; /** Terse output mode (caveman) is on. */ terse: boolean; + /** Whether Cursor is a target host (gates the .cursor mirror + Cursor wording). */ + targetsCursor: boolean; + /** Active external tools (excludes measurement-only ccusage from the coordinator list). */ + tools: ActiveTool[]; + /** MCP servers contributed by the active stack, merged into .mcp.json. */ + mcpServers: McpServerSpec[]; date: string; } @@ -23,7 +38,20 @@ export function buildContext( detection: Detection, profileName: ProfileName, profile: ProfileConfig, + adapters: AdapterDescriptor[] = [], + targets: string[] = [], ): GenContext { + const tools: ActiveTool[] = adapters + .filter((a) => a.name !== "ccusage" && a.role) + .map((a) => ({ + name: a.name, + role: a.role!, + integration: a.integration, + cursor: !!a.cursor, + repo: a.repo, + })); + const mcpServers: McpServerSpec[] = adapters.flatMap((a) => (a.mcp ? [a.mcp] : [])); + return { cwd: detection.cwd, profileName, @@ -43,6 +71,9 @@ export function buildContext( compressionLabel: profile.compression === "builtin" ? "agent-stack compress" : profile.compression, terse: profile.caveman, + targetsCursor: targets.includes("cursor"), + tools, + mcpServers, date: new Date().toISOString().slice(0, 10), }; } diff --git a/src/generate/coordinator.ts b/src/generate/coordinator.ts new file mode 100644 index 0000000..05e2d79 --- /dev/null +++ b/src/generate/coordinator.ts @@ -0,0 +1,36 @@ +import type { ActiveTool, GenContext } from "./context.js"; + +/** + * The coordinator section — the heart of the stack. It tells the agent which + * tool to reach for, for which job, in precise Claude Code terms (skills, + * plugins, MCP servers, hooks). Built-ins are named as the explicit fallback so + * the agent stays productive when a tool isn't installed. Rendered into both + * CLAUDE.md and AGENTS.md from the same active-stack data. + */ +export function coordinatorSection(ctx: GenContext): string { + if (ctx.tools.length === 0) { + return `## Context tooling (active) +- **Code map** — \`.agent-stack/graph.md\`: grep it to find where a symbol lives before opening files. +- **Compression** — pipe large command output through \`npx -y @drmahdikazempour/agent-stack compress\`. +- **Measurement** — \`ccusage\` logs token usage per turn to \`.agent-stack/usage.jsonl\`. +`; + } + + const lines = ctx.tools.map((t) => `- **${t.name}** — ${t.role}`); + return `## Tool coordinator (route each job to the right tool) + +The full stack is active. Reach for the right tool first; the built-ins below are the fallback when a tool isn't installed. + +${lines.join("\n")} + +### Built-in fallbacks (used only when the matching tool above is absent) +- **Code map** — \`.agent-stack/graph.md\` (grep before reading) backs up code-review-graph / graphify. +- **Compression** — \`npx -y @drmahdikazempour/agent-stack compress\` backs up rtk for piped output. +- **Measurement** — \`ccusage\` logs token usage per turn to \`.agent-stack/usage.jsonl\`. +`; +} + +/** Cursor-portable subset only: CLI + MCP tools. Claude-Code-only plugins/skills are excluded. */ +export function cursorPortableTools(ctx: GenContext): ActiveTool[] { + return ctx.tools.filter((t) => t.cursor); +} diff --git a/src/generate/cursor.ts b/src/generate/cursor.ts index a0fc06f..174447b 100644 --- a/src/generate/cursor.ts +++ b/src/generate/cursor.ts @@ -2,15 +2,18 @@ import { PATHS } from "../constants.js"; import type { PlannedFile } from "../core/types.js"; import { estimateTokens } from "../core/token-estimator.js"; import type { GenContext } from "./context.js"; +import { cursorPortableTools } from "./coordinator.js"; function pf(path: string, contents: string): PlannedFile { return { path, contents, tokens: estimateTokens(contents), host: "cursor" }; } /** - * Cursor mirror: `.cursor/rules/*.mdc` (glob-scoped) + `AGENTS.md` (shared - * convention). Generated from the same context as the Claude surface — one - * source, two faces (PRD §4). `mdc` files carry frontmatter with globs. + * Cursor mirror: `.cursor/rules/*.mdc` (glob-scoped). AGENTS.md is generated + * once by the Claude surface (shared coordinator), not here. Only the + * Cursor-portable tools (CLI + MCP — rtk, code-review-graph, graphify) are + * referenced; Claude-Code-only plugins/skills (caveman, claude-handoff, gbrain) + * are intentionally NOT named for Cursor. */ function mdc(description: string, globs: string, body: string): string { return `---\ndescription: ${description}\nglobs: ${globs}\nalwaysApply: false\n---\n\n${body}\n`; @@ -18,6 +21,10 @@ function mdc(description: string, globs: string, body: string): string { export function generateCursor(ctx: GenContext): PlannedFile[] { const files: PlannedFile[] = []; + const portable = cursorPortableTools(ctx); + const toolLines = portable.length + ? portable.map((t) => `- **${t.name}** — ${t.role}`).join("\n") + "\n" + : ""; files.push( pf( @@ -25,7 +32,7 @@ export function generateCursor(ctx: GenContext): PlannedFile[] { mdc( "agent-stack core conventions for this repo", "**/*", - `# ${ctx.repoName} — core conventions\n\n- Profile: \`${ctx.profileName}\`. Code map: \`.agent-stack/graph.md\`. Compression: \`${ctx.compressionLabel}\`.\n- Grep the code map and do targeted reads instead of loading whole directories.\n- Compress large command output before letting it into context.\n- Keep responses terse to minimize token cost.\n- See \`AGENTS.md\` for the convention shared with Claude Code.`, + `# ${ctx.repoName} — core conventions\n\n- Profile: \`${ctx.profileName}\`. Code map: \`.agent-stack/graph.md\`. Compression: \`${ctx.compressionLabel}\`.\n${toolLines}- Find code via the code map / graph and do targeted reads instead of loading whole directories.\n- Compress large command output before letting it into context.\n- Keep responses terse to minimize token cost.\n- See \`AGENTS.md\` for the full convention shared with Claude Code.`, ), ), ); @@ -55,30 +62,5 @@ export function generateCursor(ctx: GenContext): PlannedFile[] { ), ); - // AGENTS.md — the shared convention both hosts read. - files.push( - pf( - PATHS.agentsMd, - `# AGENTS.md - -Shared agent convention for **${ctx.repoName}** (read by Claude Code and Cursor). - -- **Profile:** \`${ctx.profileName}\` -- **Code map:** \`.agent-stack/graph.md\` — grep it to find symbols before reading files. -- **Compression:** \`${ctx.compressionLabel}\` — pipe large output through it. -- **Measurement:** \`ccusage\`. - -## Working agreement -- Grep the code map and do targeted reads instead of loading whole directories. -- Compress large command output before letting it into context. -- Keep responses terse; the user values low token cost. -- Match existing code style; never reformat unrelated code. -- Run the project's own test/lint commands before claiming done. - -_Generated by agent-stack. Edit \`CLAUDE.md\` and run \`npx @drmahdikazempour/agent-stack sync\` to regenerate this mirror._ -`, - ), - ); - return files; } diff --git a/src/generate/mcp.ts b/src/generate/mcp.ts index 740fadc..2446132 100644 --- a/src/generate/mcp.ts +++ b/src/generate/mcp.ts @@ -1,26 +1,42 @@ +import path from "node:path"; import { PATHS } from "../constants.js"; -import type { PlannedFile } from "../core/types.js"; +import type { McpServerSpec, PlannedFile } from "../core/types.js"; import { estimateTokens } from "../core/token-estimator.js"; +import { readJsonSafe } from "../core/util.js"; import type { GenContext } from "./context.js"; +interface McpFile { + $schema?: string; + mcpServers?: Record; + _agentStack?: { note: string }; + [k: string]: unknown; +} + /** - * MCP config (`.mcp.json`). v1 ships an empty-but-valid scaffold with a comment - * pointing at Tool Search — we don't auto-wire third-party MCP servers (that's - * the user's call), but we leave the file ready and documented so the - * MCP-tool-def budget (≤10K tokens, PRD §8) is something audit can track. + * Generate `.mcp.json` by MERGING the active stack's MCP servers into whatever + * the repo already has — we never clobber a user-defined server (same + * non-destructive discipline as wire-hooks for settings.json). Tools whose + * integration is "mcp" (e.g. code-review-graph) contribute their server entry; + * an empty stack still produces a valid, documented scaffold. */ -export function generateMcp(_ctx: GenContext): PlannedFile[] { - const contents = JSON.stringify( - { - $schema: "https://modelcontextprotocol.io/schema.json", - mcpServers: {}, - _agentStack: { - note: "Add MCP servers here. agent-stack audit tracks the active tool-def budget (≤10K tokens after Tool Search filtering).", - }, - }, - null, - 2, - ); +export function generateMcp(ctx: GenContext): PlannedFile[] { + const existing = readJsonSafe(path.join(ctx.cwd, PATHS.mcpConfig)) ?? {}; + const merged: McpFile = structuredClone(existing); + merged.$schema ??= "https://modelcontextprotocol.io/schema.json"; + merged.mcpServers ??= {}; + + for (const server of ctx.mcpServers) { + // Preserve a user's existing entry for the same key; only add if absent. + if (!(server.server in merged.mcpServers)) { + merged.mcpServers[server.server] = { command: server.command, args: server.args }; + } + } + + merged._agentStack = { + note: "agent-stack manages the servers it added (e.g. code-review-graph); your own entries are preserved. audit tracks the active tool-def budget (≤10K tokens after Tool Search filtering).", + }; + + const contents = JSON.stringify(merged, null, 2); return [ { path: PATHS.mcpConfig, @@ -30,3 +46,8 @@ export function generateMcp(_ctx: GenContext): PlannedFile[] { }, ]; } + +/** Pull the MCP server specs out of the active adapter set. */ +export function mcpServersFrom(adapters: { mcp?: McpServerSpec }[]): McpServerSpec[] { + return adapters.flatMap((a) => (a.mcp ? [a.mcp] : [])); +} diff --git a/test/generate.test.ts b/test/generate.test.ts index 15b9ef0..ca20c51 100644 --- a/test/generate.test.ts +++ b/test/generate.test.ts @@ -58,11 +58,21 @@ describe("generators", () => { } }); - it("cursor mirror produces .mdc rules and AGENTS.md", () => { + it("AGENTS.md is generated by the Claude surface (always, Claude Code primary)", () => { const dir = makeTmpRepo({ "package.json": "{}" }); try { - const files = generateCursor(ctxFor(dir)); + const files = generateClaude(ctxFor(dir)); expect(files.some((f) => f.path === "AGENTS.md")).toBe(true); + } finally { + cleanup(dir); + } + }); + + it("cursor mirror produces .mdc rules only (AGENTS.md is shared, not duplicated here)", () => { + const dir = makeTmpRepo({ "package.json": "{}" }); + try { + const files = generateCursor(ctxFor(dir)); + expect(files.some((f) => f.path === "AGENTS.md")).toBe(false); expect(files.some((f) => f.path.startsWith(".cursor/rules/") && f.path.endsWith(".mdc"))).toBe(true); } finally { cleanup(dir); diff --git a/test/stack.test.ts b/test/stack.test.ts new file mode 100644 index 0000000..729d901 --- /dev/null +++ b/test/stack.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from "vitest"; +import { getAdapter, adaptersForProfile } from "../src/adapters/registry.js"; +import { ensureAdapter } from "../src/adapters/install.js"; +import { isToolPresent } from "../src/adapters/detect-tools.js"; +import { generateMcp } from "../src/generate/mcp.js"; +import { generateClaude } from "../src/generate/claude.js"; +import { buildContext } from "../src/generate/context.js"; +import { coordinatorSection, cursorPortableTools } from "../src/generate/coordinator.js"; +import { getProfileConfig } from "../src/core/plan.js"; +import { detect } from "../src/core/detect.js"; +import { makeTmpRepo, cleanup } from "./helpers.js"; + +describe("tools registry (real, verified commands)", () => { + it("rtk installs from the rtk-ai git repo, never crates.io", () => { + const rtk = getAdapter("rtk"); + expect(rtk.integration).toBe("path"); + expect(rtk.install[0]!.needs).toBe("cargo"); + expect(rtk.install[0]!.run.join(" ")).toBe("cargo install --git https://github.com/rtk-ai/rtk"); + // never bare `cargo install rtk` (that resolves to a different project on crates.io) + expect(rtk.install.some((s) => s.run.join(" ") === "cargo install rtk")).toBe(false); + expect(rtk.guidance).toMatch(/install\.sh|rtk gain/); + }); + + it("graphify uses the real PyPI name `graphifyy` (double-y)", () => { + const g = getAdapter("graphify"); + expect(g.install.every((s) => s.run.includes("graphifyy"))).toBe(true); + expect(g.detect).toMatchObject({ kind: "path", bin: "graphify" }); + }); + + it("code-review-graph contributes an MCP server entry", () => { + const c = getAdapter("code-review-graph"); + expect(c.integration).toBe("mcp"); + expect(c.mcp).toEqual({ server: "code-review-graph", command: "uvx", args: ["code-review-graph", "serve"] }); + expect(c.refresh).toEqual(["code-review-graph", "update"]); + }); + + it("caveman + claude-handoff install as Claude Code plugins and are NOT Cursor-portable", () => { + for (const name of ["caveman", "claude-handoff"]) { + const t = getAdapter(name); + expect(t.integration).toBe("plugin"); + expect(t.install[0]!.needs).toBe("claude"); + expect(t.install[0]!.run.slice(0, 3)).toEqual(["claude", "plugin", "marketplace"]); + expect(t.install[0]!.then?.[1]).toBe("plugin"); + expect(t.cursor).toBe(false); + } + }); + + it("every wired tool is permissive (no consent gate)", () => { + for (const a of adaptersForProfile(getProfileConfig("max"))) { + expect(a.permissive).toBe(true); + expect(["MIT", "Apache-2.0"]).toContain(a.spdx); + } + }); +}); + +describe("install: detect-first + guidance", () => { + it("config-only mode returns guidance instead of installing", () => { + const dir = makeTmpRepo({}); + try { + // Use a synthetic mcp-detected adapter so it is deterministically ABSENT in an + // empty tmp dir (a real tool may happen to be installed on the test machine). + const absent = { ...getAdapter("code-review-graph"), detect: { kind: "mcp" as const, server: "code-review-graph" } }; + const out = ensureAdapter(absent, { install: false, cwd: dir }); + expect(out.status).toBe("config-only"); + expect(out.guidance).toBeTruthy(); + } finally { + cleanup(dir); + } + }); +}); + +describe("detect-tools", () => { + // A synthetic adapter that detects via MCP-server presence (exercises the mcp branch). + const mcpDetect = { ...getAdapter("code-review-graph"), detect: { kind: "mcp" as const, server: "code-review-graph" } }; + + it("mcp detection reads the repo's .mcp.json", () => { + const dir = makeTmpRepo({ + ".mcp.json": JSON.stringify({ mcpServers: { "code-review-graph": { command: "uvx" } } }), + }); + try { + expect(isToolPresent(mcpDetect, dir)).toBe(true); + } finally { + cleanup(dir); + } + }); + + it("mcp detection is false when the server is absent", () => { + const dir = makeTmpRepo({ ".mcp.json": JSON.stringify({ mcpServers: {} }) }); + try { + expect(isToolPresent(mcpDetect, dir)).toBe(false); + } finally { + cleanup(dir); + } + }); +}); + +function maxCtx(dir: string) { + return buildContext( + detect(dir), + "max", + getProfileConfig("max"), + adaptersForProfile(getProfileConfig("max")), + ["claude", "cursor"], + ); +} + +describe("generate/mcp merge", () => { + it("adds code-review-graph while preserving a user's existing server", () => { + const dir = makeTmpRepo({ + ".mcp.json": JSON.stringify({ mcpServers: { mine: { command: "node", args: ["server.js"] } } }), + }); + try { + const [file] = generateMcp(maxCtx(dir)); + const parsed = JSON.parse(file!.contents); + expect(parsed.mcpServers.mine).toEqual({ command: "node", args: ["server.js"] }); + expect(parsed.mcpServers["code-review-graph"]).toEqual({ command: "uvx", args: ["code-review-graph", "serve"] }); + } finally { + cleanup(dir); + } + }); +}); + +describe("coordinator docs", () => { + it("CLAUDE.md + AGENTS.md route each job to the right tool, by name", () => { + const dir = makeTmpRepo({ "package.json": "{}" }); + try { + const section = coordinatorSection(maxCtx(dir)); + for (const name of ["rtk", "code-review-graph", "graphify", "caveman", "claude-handoff", "gbrain"]) { + expect(section).toContain(name); + } + expect(section).toMatch(/fallback/i); + } finally { + cleanup(dir); + } + }); + + it("Cursor subset excludes Claude-Code-only plugins/skills", () => { + const dir = makeTmpRepo({ "package.json": "{}" }); + try { + const portable = cursorPortableTools(maxCtx(dir)).map((t) => t.name).sort(); + expect(portable).toEqual(["code-review-graph", "graphify", "rtk"]); + expect(portable).not.toContain("caveman"); + expect(portable).not.toContain("gbrain"); + } finally { + cleanup(dir); + } + }); +}); + +describe("generated coordinator surfaces in real files", () => { + it("AGENTS.md names the active tools and mentions Cursor only when targeted", () => { + const dir = makeTmpRepo({ "package.json": "{}" }); + try { + const files = generateClaude(maxCtx(dir)); + const agents = files.find((f) => f.path === "AGENTS.md")!; + expect(agents.contents).toContain("rtk"); + expect(agents.contents).toContain("Cursor"); // targets include cursor in maxCtx + } finally { + cleanup(dir); + } + }); +}); diff --git a/test/v2.test.ts b/test/v2.test.ts index 34a6e66..884e7a9 100644 --- a/test/v2.test.ts +++ b/test/v2.test.ts @@ -49,7 +49,7 @@ describe("graph (built-in code map)", () => { }); }); -describe("max profile + adapter model", () => { +describe("max profile + tool stack", () => { it("max turns on caveman (terse) and all skills/agents", () => { const p = getProfileConfig("max"); expect(p.caveman).toBe(true); @@ -57,18 +57,36 @@ describe("max profile + adapter model", () => { expect(p.agents.length).toBeGreaterThan(0); }); - it("only ccusage is an installable adapter; built-in graph adds none", () => { - const p = getProfileConfig("max"); - const adapters = adaptersForProfile(p.graph, p.compression, p.caveman); + it("max activates the full permissive stack (ccusage + the 6 external tools)", () => { + const adapters = adaptersForProfile(getProfileConfig("max")); + const names = adapters.map((a) => a.name).sort(); + expect(names).toEqual( + ["caveman", "ccusage", "claude-handoff", "code-review-graph", "gbrain", "graphify", "rtk"].sort(), + ); + // Every wired tool is permissive — no consent gate anywhere. + expect(adapters.every((a) => a.permissive)).toBe(true); + }); + + it("default 'code' profile stays built-ins-only (just ccusage)", () => { + const adapters = adaptersForProfile(getProfileConfig("code")); expect(adapters.map((a) => a.name)).toEqual(["ccusage"]); }); - it("hooks use built-in commands (graph refresh + ccusage)", () => { - const hooks = hooksForProfile(getProfileConfig("max")); - expect(hooks.some((h) => h.event === "SessionStart" && h.command.includes("graph refresh"))).toBe(true); + it("SessionStart hook prefers the external graph backend's own refresh when active", () => { + const adapters = adaptersForProfile(getProfileConfig("max")); + const hooks = hooksForProfile(getProfileConfig("max"), adapters); + const start = hooks.find((h) => h.event === "SessionStart")!; + expect(start.command).toContain("code-review-graph update"); + expect(start.command).not.toContain("agent-stack graph refresh"); expect(hooks.some((h) => h.event === "Stop" && h.command.includes("ccusage"))).toBe(true); }); + it("falls back to the built-in graph refresh when no external backend is active", () => { + const hooks = hooksForProfile(getProfileConfig("max"), []); + const start = hooks.find((h) => h.event === "SessionStart")!; + expect(start.command).toContain("graph refresh"); + }); + it("research profile (graph: none) emits no graph hook", () => { const hooks = hooksForProfile(getProfileConfig("research")); expect(hooks.some((h) => h.command.includes("graph refresh"))).toBe(false);