From f77cd16ba5b6e5ce5082bb90efb7e3527cb80e2d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 30 May 2026 17:03:35 -0700 Subject: [PATCH 01/33] feat(agents): Add subagent runtime support Add subagent dependency declarations, native runtime discovery, canonical installed subagent storage, and generated runtime outputs for Claude, Cursor, Codex, and OpenCode. Document the subagent contract in a dedicated spec and cover native preservation, conversion, sync repair, and pruning behavior with tests. Co-Authored-By: Codex --- README.md | 50 +- docs/public/llms.txt | 86 ++- docs/src/content/docs/cli.mdx | 40 +- docs/src/content/docs/guide.mdx | 27 +- docs/src/content/docs/index.mdx | 29 +- packages/dotagents-lib/src/index.ts | 9 +- .../dotagents-lib/src/skills/loader.test.ts | 3 + packages/dotagents-lib/src/skills/loader.ts | 58 +- .../src/agents/definitions/claude.ts | 22 +- .../dotagents/src/agents/definitions/codex.ts | 22 +- .../src/agents/definitions/cursor.ts | 22 +- .../src/agents/definitions/helpers.test.ts | 23 +- .../src/agents/definitions/helpers.ts | 60 ++ .../src/agents/definitions/opencode.ts | 26 +- packages/dotagents/src/agents/index.ts | 33 ++ packages/dotagents/src/agents/paths.test.ts | 32 ++ .../src/agents/subagent-store.test.ts | 300 ++++++++++ .../dotagents/src/agents/subagent-store.ts | 529 ++++++++++++++++++ .../src/agents/subagent-writer.test.ts | 292 ++++++++++ .../dotagents/src/agents/subagent-writer.ts | 302 ++++++++++ packages/dotagents/src/agents/types.ts | 44 +- packages/dotagents/src/cli/commands/doctor.ts | 6 +- .../src/cli/commands/install.test.ts | 201 +++++++ .../dotagents/src/cli/commands/install.ts | 77 ++- packages/dotagents/src/cli/commands/remove.ts | 6 +- .../dotagents/src/cli/commands/sync.test.ts | 126 +++++ packages/dotagents/src/cli/commands/sync.ts | 76 ++- packages/dotagents/src/config/index.ts | 1 + packages/dotagents/src/config/loader.test.ts | 70 +++ packages/dotagents/src/config/loader.ts | 37 ++ packages/dotagents/src/config/schema.test.ts | 69 +++ packages/dotagents/src/config/schema.ts | 22 + .../dotagents/src/gitignore/writer.test.ts | 9 + packages/dotagents/src/gitignore/writer.ts | 8 +- packages/dotagents/src/index.ts | 37 +- packages/dotagents/src/lockfile/index.ts | 2 +- .../dotagents/src/lockfile/schema.test.ts | 14 + packages/dotagents/src/lockfile/schema.ts | 6 + .../dotagents/src/lockfile/writer.test.ts | 35 ++ packages/dotagents/src/lockfile/writer.ts | 23 +- packages/dotagents/src/scope.test.ts | 40 +- specs/SPEC.md | 91 ++- specs/subagents.md | 119 ++++ 43 files changed, 2958 insertions(+), 126 deletions(-) create mode 100644 packages/dotagents/src/agents/subagent-store.test.ts create mode 100644 packages/dotagents/src/agents/subagent-store.ts create mode 100644 packages/dotagents/src/agents/subagent-writer.test.ts create mode 100644 packages/dotagents/src/agents/subagent-writer.ts create mode 100644 specs/subagents.md diff --git a/README.md b/README.md index a2b0679..3f339f4 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ # dotagents -Shared tooling for coding agents. Declare skills, MCP servers, and hooks in `agents.toml` — dotagents wires them into every agent tool on your team. +Shared tooling for coding agents. Declare skills, MCP servers, hooks, and subagents in `agents.toml` — dotagents wires them into every agent tool on your team. ## Why dotagents? -**One source of truth.** Skills live in `.agents/skills/` and symlink into `.claude/skills/`, `.cursor/skills/`, or wherever your tools expect them. No copy-pasting between directories. +**One source of truth.** Skills live in `.agents/skills/` and symlink into `.claude/skills/` or wherever your tools expect them. Cursor shares Claude-compatible skills. No copy-pasting between directories. -**One command to install.** `agents.toml` is committed, managed skills are gitignored. Collaborators run `dotagents install` to fetch or refresh skills. +**One command to install.** `agents.toml` is committed, managed skills and canonical installed subagents under `.agents/` are gitignored. Collaborators run `dotagents install` to fetch or refresh local agent state. **Shareable.** Skills are directories with a `SKILL.md`. Host them in any git repo, discover them automatically, install with one command. -**Multi-agent.** Configure Claude, Cursor, Codex, VS Code, and OpenCode from a single `agents.toml` -- skills, MCP servers, and hooks where supported. Pi reads `.agents/skills/` directly. +**Multi-agent.** Configure Claude, Cursor, Codex, VS Code, and OpenCode from a single `agents.toml` -- skills, MCP servers, hooks, and subagents where supported. Pi reads `.agents/skills/` directly. ## Quick Start @@ -33,7 +33,7 @@ npx @sentry/dotagents add getsentry/skills --all This creates an `agents.toml` at your project root and an `agents.lock` tracking installed skills. -After cloning a project that already has `agents.toml`, run `install` to fetch skills. Run it again to refresh managed skills: +After cloning a project that already has `agents.toml`, run `install` to fetch skills and subagents. Run it again to refresh managed local state: ```bash npx @sentry/dotagents install @@ -92,22 +92,44 @@ Shorthand (`owner/repo`) resolves to GitHub by default. Set `defaultRepositorySo The `agents` field tells dotagents which tools to configure: ```toml -agents = ["claude", "cursor"] +agents = ["claude", "cursor", "codex", "opencode"] ``` -| Agent | Config Dir | MCP Config | Hooks | -|-------|-----------|------------|-------| -| `claude` | `.claude` | `.mcp.json` | `.claude/settings.json` | -| `cursor` | `.cursor` | `.cursor/mcp.json` | `.cursor/hooks.json` | -| `codex` | `.codex` | `.codex/config.toml` | -- | -| `vscode` | `.vscode` | `.vscode/mcp.json` | `.claude/settings.json` | -| `opencode` | `.claude` | `opencode.json` | -- | +| Agent | Config Dir | MCP Config | Hooks | Subagents | +|-------|-----------|------------|-------|-----------| +| `claude` | `.claude` | `.mcp.json` | `.claude/settings.json` | `.claude/agents/*.md` | +| `cursor` | `.cursor` | `.cursor/mcp.json` | `.cursor/hooks.json` | `.cursor/agents/*.md` | +| `codex` | `.codex` | `.codex/config.toml` | -- | `.codex/agents/*.toml` | +| `vscode` | `.vscode` | `.vscode/mcp.json` | `.claude/settings.json` | -- | +| `opencode` | `.opencode` | `opencode.json` | -- | `.opencode/agents/*.md` | + +Custom subagents are declared with `[[subagents]]` entries. dotagents writes generated runtime-specific files during `install` and repairs them during `sync`: + +```toml +[[subagents]] +name = "code-reviewer" +source = "getsentry/agent-pack" +targets = ["claude", "codex", "opencode"] +``` + +dotagents discovers portable subagent Markdown from conventional source directories such as `agents/` and `.agents/agents/`. The frontmatter supplies the portable `name` and `description`; the body supplies the runtime instructions: + +```md +--- +name: code-reviewer +description: Review code for correctness, security, and missing tests. +--- + +Review the current diff and return findings with file references. +``` + +dotagents can also import native runtime subagent files from `.claude/agents/`, `.cursor/agents/`, `.codex/agents/*.toml`, and `.opencode/agents/`. Input and matching-runtime output use the same native format: Markdown with YAML frontmatter for Claude, Cursor, and OpenCode; TOML for Codex. When the source format matches a target runtime, dotagents reuses the native source content for that runtime and only adds its managed-file marker. Other runtimes are generated from the portable `name`, `description`, and instructions. Subagent declarations intentionally cover only dependency source and runtime targets, not universal model/tool/permission behavior. [Pi](https://github.com/badlogic/pi-mono) reads `.agents/skills/` natively and needs no configuration. ## Documentation -For the full guide -- including MCP servers, hooks, trust policies, wildcard skills, user scope, and CI setup -- see the [documentation site](https://dotagents.sentry.dev). +For the full guide -- including MCP servers, hooks, subagents, trust policies, wildcard skills, user scope, and CI setup -- see the [documentation site](https://dotagents.sentry.dev). ## Contributing diff --git a/docs/public/llms.txt b/docs/public/llms.txt index dd08665..08168b4 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -2,7 +2,7 @@ > Shared tooling for coding agents -dotagents manages agent skills, MCP servers, and hooks declared in `agents.toml`, and handles symlinks and config generation so tools like Claude Code, Cursor, Codex, VS Code, and OpenCode are configured from a single source of truth. +dotagents manages agent skills, MCP servers, hooks, and subagents declared in `agents.toml`, and handles symlinks and config generation so tools like Claude Code, Cursor, Codex, VS Code, and OpenCode are configured from a single source of truth. Install: `npm install -g @sentry/dotagents` Run without installing: `npx @sentry/dotagents ` @@ -44,9 +44,9 @@ And a lockfile (`agents.lock`) tracking which skills are managed. Both `agents.l 1. Declare skill dependencies in `agents.toml` at the project root (or `~/.agents/agents.toml` for user scope) 2. `install` clones or refreshes sources, discovers skills by convention, and copies them into `.agents/skills/` 3. `agents.lock` tracks which skills are managed (gitignored automatically) -4. Managed skills are gitignored. Collaborators run `npx @sentry/dotagents install` after cloning. Custom skills in `.agents/skills/` are tracked by git normally. -5. Symlinks connect `.agents/skills/` to each agent's expected location (`.claude/skills/`, `.cursor/skills/`, etc.) -6. MCP and hook configs are generated for each declared agent +4. Managed skills and canonical installed subagents under `.agents/` are gitignored. Collaborators run `npx @sentry/dotagents install` after cloning. Custom skills in `.agents/skills/` are tracked by git normally. +5. Symlinks connect `.agents/skills/` to each agent's expected location (`.claude/skills/` for Claude and Cursor) +6. MCP, hook, and subagent configs are generated for each declared agent where supported ## Configuration (agents.toml) @@ -54,7 +54,7 @@ Full example with all sections: ```toml version = 1 -agents = ["claude", "cursor"] +agents = ["claude", "cursor", "codex", "opencode"] minimum_release_age = 60 minimum_release_age_exclude = ["getsentry/*"] @@ -125,6 +125,12 @@ command = "my-lint-check" [[hooks]] event = "Stop" command = "notify-done" + +# Custom subagent +[[subagents]] +name = "code-reviewer" +source = "getsentry/agent-pack" +targets = ["claude", "codex", "opencode"] ``` ### Top-level Fields @@ -134,6 +140,7 @@ command = "notify-done" | `version` | integer | Yes | -- | Schema version. Always `1`. | | `defaultRepositorySource` | string | No | `github` | Host used for shorthand `owner/repo` skill sources. Valid values: `github`, `gitlab`. | | `agents` | string[] | No | `[]` | Agent tool IDs: `claude`, `cursor`, `codex`, `vscode`, `opencode`. Creates symlinks and config files for each. | +| `subagents` | table[] | No | `[]` | Custom subagent declarations. Generates runtime-specific files for Claude, Cursor, Codex, and OpenCode. | | `minimum_release_age` | integer | No | -- | Minimum commit age, in minutes, before a git skill can install. | | `minimum_release_age_exclude` | string[] | No | `[]` | Sources that bypass the minimum release age gate. Supports org names, `org/repo`, and `org/*`. | @@ -214,6 +221,51 @@ Cursor event mapping: - `UserPromptSubmit` -> `beforeSubmitPrompt` - `Stop` -> `stop` +### Subagents + +Each `[[subagents]]` entry requires `name` and `source`. Optional: `ref`, `path`, and `targets`. When `targets` is absent, dotagents attempts every agent listed in `agents` and warns for agents that do not support custom subagents. + +dotagents treats subagents as best-effort portable dependencies, not a universal behavior schema. Runtime-specific behavior such as model routing, tool permissions, read-only modes, background execution, and reasoning effort stays in each tool's native artifact. + +dotagents discovers portable subagent Markdown from `agents/` and `.agents/agents/`. It also imports native runtime artifacts from `.claude/agents/*.md`, `.cursor/agents/*.md`, `.codex/agents/*.toml`, and `.opencode/agents/*.md`. When the source format matches a target runtime, dotagents reuses the native source content for that runtime and only adds its managed-file marker. Other runtimes are generated from the portable `name`, `description`, and instructions. + +Accepted source/output formats: + +| Format | Source path | Matching output path | Required source fields | +|--------|-------------|----------------------|------------------------| +| Portable Markdown | `agents/*.md`, `.agents/agents/*.md` | `.agents/agents/.md` | YAML `name`, `description`; Markdown body | +| Claude Markdown | `.claude/agents/*.md` | `.claude/agents/.md` | YAML `name`, `description`; Markdown body | +| Cursor Markdown | `.cursor/agents/*.md` | `.cursor/agents/.md` | YAML `name`, `description`; Markdown body | +| Codex TOML | `.codex/agents/*.toml` | `.codex/agents/.toml` | TOML `name`, `description`, `developer_instructions` | +| OpenCode Markdown | `.opencode/agents/*.md` | `.opencode/agents/.md` | YAML `description`; Markdown body; `name` optional | + +Codex native `name` may use Codex-specific naming; dotagents uses the `agents.toml` name or filename as the portable ID when needed. OpenCode native Markdown may use the filename as the subagent name. + +```md +--- +name: code-reviewer +description: Review code for correctness, security, and missing tests. +--- + +Review the current diff and return findings with file references. +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Lowercase subagent identifier to discover. Pattern: `^[a-z][a-z0-9-]*$`. | +| `source` | string | Yes | Source repository or local directory. Supports GitHub/GitLab shorthands, git URLs, and `path:` sources; HTTPS well-known skill indexes are not supported for subagents. | +| `ref` | string | No | Optional git ref override. | +| `path` | string | No | Optional explicit subagent file path inside the source. Markdown paths are portable or native Markdown; `.toml` paths are Codex native artifacts. | +| `targets` | string[] | No | Optional subset of agent IDs. Unsupported configured agents produce warnings. | + +Generated subagent files: +- Claude: `.claude/agents/.md`, or `~/.claude/agents/.md` for user scope +- Cursor: `.cursor/agents/.md`, or `~/.cursor/agents/.md` for user scope +- Codex: `.codex/agents/.toml`, or `~/.codex/agents/.toml` for user scope +- OpenCode: `.opencode/agents/.md`, or `~/.config/opencode/agents/.md` for user scope + +Generated files include a dotagents marker. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the marker. + ### Trust Optional `[trust]` section to restrict allowed skill sources. @@ -295,7 +347,7 @@ When the argument is a source specifier (e.g. `owner/repo`, a URL) instead of a npx @sentry/dotagents sync ``` -Reconcile project state without network access: adopt truly local orphaned skills, prune stale managed skills removed from config, regenerate `.agents/.gitignore`, check for missing skills, repair symlinks, and verify/repair MCP and hook configs. Reports issues as warnings or errors. +Reconcile project state without network access: adopt truly local orphaned skills, prune stale managed skills and subagents removed from config, regenerate `.agents/.gitignore`, check for missing skills, repair symlinks, and verify/repair MCP, hook, and subagent configs. Reports issues as warnings or errors. ### mcp add @@ -385,13 +437,13 @@ Check project health: gitignore setup, installed skills, symlinks, and legacy co ## Agent Targets -| ID | Tool | Config Dir | Skills Symlink | MCP Config | Hooks | -|----|------|-----------|----------------|------------|-------| -| `claude` | Claude Code | `.claude` | `.claude/skills/` -> `.agents/skills/` | `.mcp.json` | `.claude/settings.json` | -| `cursor` | Cursor | `.cursor` | `.cursor/skills/` -> `.agents/skills/` | `.cursor/mcp.json` | `.cursor/hooks.json` | -| `codex` | Codex | `.codex` | (reads `.agents/skills/` natively) | `.codex/config.toml` | Not supported | -| `vscode` | VS Code Copilot | `.vscode` | (reads `.agents/skills/` natively) | `.vscode/mcp.json` | `.claude/settings.json` | -| `opencode` | OpenCode | `.claude` | (reads `.agents/skills/` natively) | `opencode.json` | Not supported | +| ID | Tool | Config Dir | Skills Symlink | MCP Config | Hooks | Subagents | +|----|------|-----------|----------------|------------|-------|-----------| +| `claude` | Claude Code | `.claude` | `.claude/skills/` -> `.agents/skills/` | `.mcp.json` | `.claude/settings.json` | `.claude/agents/*.md` | +| `cursor` | Cursor | `.cursor` | `.claude/skills/` -> `.agents/skills/` | `.cursor/mcp.json` | `.cursor/hooks.json` | `.cursor/agents/*.md` | +| `codex` | Codex | `.codex` | (reads `.agents/skills/` natively) | `.codex/config.toml` | Not supported | `.codex/agents/*.toml` | +| `vscode` | VS Code Copilot | `.vscode` | (reads `.agents/skills/` natively) | `.vscode/mcp.json` | `.claude/settings.json` | Not supported | +| `opencode` | OpenCode | `.opencode` | (reads `.agents/skills/` natively) | `opencode.json` | Not supported | `.opencode/agents/*.md` | Claude and Cursor use symlinks from their config directory to `.agents/skills/`. Codex, VS Code, OpenCode, and Pi read `.agents/skills/` directly. @@ -416,7 +468,7 @@ Operates on the user's home directory. For skills shared across all projects. - Lockfile: `~/.agents/agents.lock` - Override location: `DOTAGENTS_HOME` environment variable -User-scope symlinks: `~/.claude/skills/` and `~/.cursor/skills/`. +User-scope symlinks: `~/.claude/skills/` for Claude and Cursor. When no `agents.toml` exists and you are not inside a git repo, dotagents falls back to user scope automatically. @@ -491,8 +543,8 @@ Location: `~/.local/dotagents/` (override: `DOTAGENTS_STATE_DIR`) ## Gitignore dotagents always manages gitignore. Two files are gitignored automatically: -- `agents.lock` -- tracks managed skills -- `.agents/.gitignore` -- excludes managed skill directories from git +- `agents.lock` -- tracks managed skills and subagents +- `.agents/.gitignore` -- excludes managed skill directories and canonical installed subagent files from git `npx @sentry/dotagents init` adds both to the root `.gitignore`. If they're missing, `install` and `sync` warn. Run `npx @sentry/dotagents doctor --fix` to add them. @@ -502,7 +554,7 @@ Custom skills created directly in `.agents/skills/` are not gitignored. They're ## Refresh Strategy -Run `npx @sentry/dotagents install` after cloning or pulling changes. It fetches or refreshes managed skills unless a ref is pinned. There is no separate update command. +Run `npx @sentry/dotagents install` after cloning or pulling changes. It fetches or refreshes managed skills and subagents unless a ref is pinned. There is no separate update command. ## Links diff --git a/docs/src/content/docs/cli.mdx b/docs/src/content/docs/cli.mdx index 77c45af..a5ff36f 100644 --- a/docs/src/content/docs/cli.mdx +++ b/docs/src/content/docs/cli.mdx @@ -149,7 +149,7 @@ dotagents sync ``` Reconcile project state without network access. Adopts local orphaned skills, -prunes stale managed skills removed from config, regenerates gitignore, and +prunes stale managed skills and subagents removed from config, regenerates gitignore, and repairs symlinks plus MCP/hook configs. Reports issues as warnings or errors. @@ -337,6 +337,7 @@ Status output: | --- | --- | --- | --- | | `version` | integer | -- | Schema version. Always `1`. | | `agents` | string[] | `[]` | Agent targets: `claude`, `cursor`, `codex`, `vscode`, `opencode` | +| `subagents` | table[] | `[]` | Custom subagent declarations for Claude, Cursor, Codex, and OpenCode | | `minimum_release_age` | integer | -- | Minimum commit age, in minutes, before a git skill can install. | | `minimum_release_age_exclude` | string[] | `[]` | Sources that bypass the minimum release age gate. Supports org names, `org/repo`, and `org/*`. | | `defaultRepositorySource` | string | `github` | Host used for shorthand `owner/repo` sources. Valid values: `github` or `gitlab`. | @@ -370,6 +371,39 @@ Status output: | `matcher` | string | No | Tool name filter | | `command` | string | Yes | Shell command to execute | +### Subagents + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | Yes | Lowercase subagent name to discover. Must match `^[a-z][a-z0-9-]*$`. | +| `source` | string | Yes | Source repository or local directory. Supports GitHub/GitLab shorthands, git URLs, and `path:` sources; HTTPS well-known skill indexes are not supported for subagents. | +| `ref` | string | No | Optional git ref override. | +| `path` | string | No | Optional explicit subagent file path inside the source. Markdown paths are portable or native Markdown; `.toml` paths are Codex native artifacts. | +| `targets` | string[] | No | Optional subset of agent IDs. Unsupported configured agents produce warnings. | + +dotagents treats subagents as best-effort portable dependencies, not a universal behavior schema. Runtime-specific behavior such as model routing, tool permissions, read-only modes, background execution, and reasoning effort stays in each tool's native artifact. + +dotagents discovers portable subagent Markdown from `agents/` and `.agents/agents/`. It also imports native runtime artifacts from `.claude/agents/*.md`, `.cursor/agents/*.md`, `.codex/agents/*.toml`, and `.opencode/agents/*.md`. When the source format matches a target runtime, dotagents reuses the native source content for that runtime and only adds its managed-file marker. Other runtimes are generated from the portable `name`, `description`, and instructions. + +| Format | Source path | Matching output path | Required source fields | +| --- | --- | --- | --- | +| Portable Markdown | `agents/*.md`, `.agents/agents/*.md` | `.agents/agents/.md` | YAML `name`, `description`; Markdown body | +| Claude Markdown | `.claude/agents/*.md` | `.claude/agents/.md` | YAML `name`, `description`; Markdown body | +| Cursor Markdown | `.cursor/agents/*.md` | `.cursor/agents/.md` | YAML `name`, `description`; Markdown body | +| Codex TOML | `.codex/agents/*.toml` | `.codex/agents/.toml` | TOML `name`, `description`, `developer_instructions` | +| OpenCode Markdown | `.opencode/agents/*.md` | `.opencode/agents/.md` | YAML `description`; Markdown body; `name` optional | + +Codex native `name` may use Codex-specific naming; dotagents uses the `agents.toml` name or filename as the portable ID when needed. OpenCode native Markdown may use the filename as the subagent name. + +Generated files: + +- Claude: `.claude/agents/.md` +- Cursor: `.cursor/agents/.md` +- Codex: `.codex/agents/.toml` +- OpenCode: `.opencode/agents/.md` + +Generated files include a dotagents marker. `install` and `sync` update managed files and do not overwrite hand-written files without the marker. + ## Scopes ### Project Scope (default) @@ -380,8 +414,8 @@ Skills go to `.agents/skills/` and the lockfile goes to `agents.lock`. ### User Scope Manages skills shared across all projects. Files live in `~/.agents/`, and you -can override that with `DOTAGENTS_HOME`. Symlinks go to `~/.claude/skills/` and -`~/.cursor/skills/`. +can override that with `DOTAGENTS_HOME`. Claude and Cursor share the +`~/.claude/skills/` symlink. ```shell dotagents --user init diff --git a/docs/src/content/docs/guide.mdx b/docs/src/content/docs/guide.mdx index a12ecf9..1d70eae 100644 --- a/docs/src/content/docs/guide.mdx +++ b/docs/src/content/docs/guide.mdx @@ -49,8 +49,8 @@ formats. ### 3. Install After cloning the repo or pulling changes, run `install` to fetch or refresh -managed skills. Managed skills are gitignored, so collaborators run this command -locally. +managed skills and subagents. Managed `.agents/` files are gitignored, so +collaborators run this command locally. ```shell dotagents install @@ -63,8 +63,8 @@ This is also the update path. There is no separate update command. dotagents manages gitignore automatically. Two generated files are kept out of version control: -- `agents.lock` tracks which skills are managed. -- `.agents/.gitignore` excludes managed skill directories. +- `agents.lock` tracks which skills and subagents are managed. +- `.agents/.gitignore` excludes managed skill directories and canonical installed subagent files. Custom skills you create directly in `.agents/skills/` are not gitignored. They are tracked by git normally, so collaborators get them without running install. @@ -90,7 +90,7 @@ See the [Security page](/security/) for the full trust configuration reference. ## Auto-install with Git Hooks -Since managed skills are gitignored, run `dotagents install` after pulling. A +Since managed `.agents/` files are gitignored, run `dotagents install` after pulling. A `post-merge` hook automates this: ```shell @@ -115,10 +115,11 @@ Use `dotagents sync` for offline repair. It does not fetch anything from the network. Instead, it: - Adopts local orphaned skills, meaning installed skills that are not declared. -- Prunes stale managed skills removed from config. +- Prunes stale managed skills and subagents removed from config. - Regenerates `.agents/.gitignore`. - Repairs broken symlinks. - Fixes MCP and hook configs. +- Repairs generated subagent files. ## Diagnosing Issues @@ -147,8 +148,8 @@ dotagents --user add getsentry/skills --all dotagents --user install ``` -Personal skills live in `~/.agents/` and symlink to `~/.claude/skills/` and -`~/.cursor/skills/`. Override the location with `DOTAGENTS_HOME`. +Personal skills live in `~/.agents/` and symlink to `~/.claude/skills/` for +Claude and Cursor. Override the location with `DOTAGENTS_HOME`. When you run dotagents outside a git repo without an `agents.toml`, it falls back to user scope automatically. @@ -157,11 +158,11 @@ back to user scope automatically. ## Full Configuration Example -`agents.toml` with skills, wildcards, MCP servers, and hooks: +`agents.toml` with skills, wildcards, MCP servers, hooks, and subagents: ```toml version = 1 -agents = ["claude", "cursor"] +agents = ["claude", "cursor", "codex", "opencode"] minimum_release_age = 60 minimum_release_age_exclude = ["getsentry/*"] @@ -206,6 +207,12 @@ url = "https://mcp.example.com/sse" event = "PreToolUse" matcher = "Bash" command = "my-lint-check" + +# Custom subagent +[[subagents]] +name = "code-reviewer" +source = "getsentry/agent-pack" +targets = ["claude", "codex", "opencode"] ``` See the [CLI reference](/cli/#configuration-agentstoml) for all fields and diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 16ff796..ed75a04 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -4,7 +4,7 @@ description: Shared tooling for coding agents. template: splash hero: title: 'dotagentsOne Config for Your Coding Agents' - tagline: 'Declare skills, MCP servers, and hooks in agents.toml. dotagents installs shared skills and writes each tool''s local config.' + tagline: 'Declare skills, MCP servers, hooks, and subagents in agents.toml. dotagents installs shared skills and writes each tool''s local config.' actions: - text: Get Started link: /guide/ @@ -18,7 +18,7 @@ hero: agents.toml
{`version = 1
-agents = ["claude", "cursor", "codex", "vscode"]
+agents = ["claude", "cursor", "codex", "vscode", "opencode"]
 
 [trust]
 github_orgs = ["getsentry"]
@@ -29,7 +29,12 @@ source = "getsentry/skills"
 
 [[skills]]
 name = "commit"
-source = "getsentry/skills"`}
+source = "getsentry/skills" + +[[subagents]] +name = "code-reviewer" +source = "getsentry/agent-pack" +targets = ["claude", "codex", "opencode"]`} @@ -52,6 +57,10 @@ source = "getsentry/skills"`} Hooks Generate hook config for supported agents and refresh local state after pulls. + + Subagents + Declare custom Claude, Cursor, Codex, and OpenCode subagents once and generate native runtime files. + Trust policy Restrict skill sources before dotagents performs any network operation. @@ -81,13 +90,13 @@ source = "getsentry/skills"`}

The `agents` array tells dotagents which tools to configure. Pi reads `.agents/skills/` directly and does not need generated config.

- | Agent | Config Dir | MCP Config | Hooks | - | --- | --- | --- | --- | - | `claude` | `.claude` | `.mcp.json` | `.claude/settings.json` | - | `cursor` | `.cursor` | `.cursor/mcp.json` | `.cursor/hooks.json` | - | `codex` | `.codex` | `.codex/config.toml` | None | - | `vscode` | `.vscode` | `.vscode/mcp.json` | `.claude/settings.json` | - | `opencode` | `.claude` | `opencode.json` | None | + | Agent | Config Dir | MCP Config | Hooks | Subagents | + | --- | --- | --- | --- | --- | + | `claude` | `.claude` | `.mcp.json` | `.claude/settings.json` | `.claude/agents/*.md` | + | `cursor` | `.cursor` | `.cursor/mcp.json` | `.cursor/hooks.json` | `.cursor/agents/*.md` | + | `codex` | `.codex` | `.codex/config.toml` | None | `.codex/agents/*.toml` | + | `vscode` | `.vscode` | `.vscode/mcp.json` | `.claude/settings.json` | None | + | `opencode` | `.opencode` | `opencode.json` | None | `.opencode/agents/*.md` |
diff --git a/packages/dotagents-lib/src/index.ts b/packages/dotagents-lib/src/index.ts index a779a2f..c9db959 100644 --- a/packages/dotagents-lib/src/index.ts +++ b/packages/dotagents-lib/src/index.ts @@ -1,6 +1,11 @@ // SKILL.md loading -export { loadSkillMd, SkillLoadError } from "./skills/loader.js"; -export type { SkillMeta, LoadSkillMdOptions } from "./skills/loader.js"; +export { loadSkillMd, loadMarkdownFrontmatter, SkillLoadError } from "./skills/loader.js"; +export type { + SkillMeta, + LoadSkillMdOptions, + MarkdownFrontmatter, + LoadMarkdownFrontmatterOptions, +} from "./skills/loader.js"; // Tool name vocabulary (allowed-tools frontmatter) export { TOOL_NAMES, isToolName } from "./skills/tool-name.js"; diff --git a/packages/dotagents-lib/src/skills/loader.test.ts b/packages/dotagents-lib/src/skills/loader.test.ts index 8c86453..0feb03b 100644 --- a/packages/dotagents-lib/src/skills/loader.test.ts +++ b/packages/dotagents-lib/src/skills/loader.test.ts @@ -59,6 +59,9 @@ Content. await expect(loadSkillMd(join(dir, "nope.md"))).rejects.toThrow( SkillLoadError, ); + await expect(loadSkillMd(join(dir, "nope.md"))).rejects.toThrow( + `SKILL.md not found: ${join(dir, "nope.md")}`, + ); }); it("throws SkillLoadError for missing frontmatter", async () => { diff --git a/packages/dotagents-lib/src/skills/loader.ts b/packages/dotagents-lib/src/skills/loader.ts index 85fde66..46cbfc7 100644 --- a/packages/dotagents-lib/src/skills/loader.ts +++ b/packages/dotagents-lib/src/skills/loader.ts @@ -17,6 +17,16 @@ export interface SkillMeta { [key: string]: unknown; } +export interface MarkdownFrontmatter { + meta: Record; + body: string; + raw: string; +} + +export interface LoadMarkdownFrontmatterOptions { + fileDescription?: string; +} + export interface LoadSkillMdOptions { /** Called for parser warnings (unknown allowed-tools tokens, etc.) */ onWarning?: (message: string) => void; @@ -32,11 +42,37 @@ export async function loadSkillMd( filePath: string, opts?: LoadSkillMdOptions, ): Promise { + const { meta } = await loadMarkdownFrontmatter(filePath, { + fileDescription: "SKILL.md", + }); + + if (typeof meta["name"] !== "string" || !meta["name"]) { + throw new SkillLoadError(`Missing 'name' in SKILL.md frontmatter: ${filePath}`); + } + if (typeof meta["description"] !== "string" || !meta["description"]) { + throw new SkillLoadError(`Missing 'description' in SKILL.md frontmatter: ${filePath}`); + } + + const allowedTools = parseAllowedTools(meta["allowed-tools"], opts?.onWarning); + if (allowedTools !== undefined) { + meta["allowedTools"] = allowedTools; + } + + return meta as SkillMeta; +} + +/** + * Parse a Markdown file and extract YAML frontmatter plus body content. + */ +export async function loadMarkdownFrontmatter( + filePath: string, + opts?: LoadMarkdownFrontmatterOptions, +): Promise { let content: string; try { content = await readFile(filePath, "utf-8"); } catch { - throw new SkillLoadError(`SKILL.md not found: ${filePath}`); + throw new SkillLoadError(`${opts?.fileDescription ?? "Markdown file"} not found: ${filePath}`); } const match = FRONTMATTER_RE.exec(content); @@ -56,21 +92,11 @@ export async function loadSkillMd( throw new SkillLoadError(`Frontmatter must be a YAML object: ${filePath}`); } - const meta = parsed as Record; - - if (typeof meta["name"] !== "string" || !meta["name"]) { - throw new SkillLoadError(`Missing 'name' in SKILL.md frontmatter: ${filePath}`); - } - if (typeof meta["description"] !== "string" || !meta["description"]) { - throw new SkillLoadError(`Missing 'description' in SKILL.md frontmatter: ${filePath}`); - } - - const allowedTools = parseAllowedTools(meta["allowed-tools"], opts?.onWarning); - if (allowedTools !== undefined) { - meta["allowedTools"] = allowedTools; - } - - return meta as SkillMeta; + return { + meta: parsed as Record, + body: content.slice(match[0].length).trim(), + raw: content, + }; } function isPlainObject(value: unknown): value is Record { diff --git a/packages/dotagents/src/agents/definitions/claude.ts b/packages/dotagents/src/agents/definitions/claude.ts index cc96915..2aa0d2a 100644 --- a/packages/dotagents/src/agents/definitions/claude.ts +++ b/packages/dotagents/src/agents/definitions/claude.ts @@ -1,7 +1,7 @@ import { join } from "node:path"; import { homedir } from "node:os"; import type { AgentDefinition } from "../types.js"; -import { envRecord, httpServer, serializeClaudeHooks } from "./helpers.js"; +import { envRecord, httpServer, markManagedMarkdownSubagent, serializeClaudeHooks, serializeMarkdownSubagent } from "./helpers.js"; const claude: AgentDefinition = { id: "claude", @@ -27,6 +27,26 @@ const claude: AgentDefinition = { shared: true, }, serializeHooks: serializeClaudeHooks, + subagents: { + projectDir: ".claude/agents", + userDir: join(homedir(), ".claude", "agents"), + fileExtension: ".md", + serialize(subagent) { + const native = subagent.native?.claude; + return { + fileName: `${subagent.name}.md`, + content: native + ? markManagedMarkdownSubagent(native) + : serializeMarkdownSubagent( + { + name: subagent.name, + description: subagent.description, + }, + subagent.instructions, + ), + }; + }, + }, }; export default claude; diff --git a/packages/dotagents/src/agents/definitions/codex.ts b/packages/dotagents/src/agents/definitions/codex.ts index 0f854e4..c87069e 100644 --- a/packages/dotagents/src/agents/definitions/codex.ts +++ b/packages/dotagents/src/agents/definitions/codex.ts @@ -1,7 +1,9 @@ +import { join } from "node:path"; +import { homedir } from "node:os"; import type { AgentDefinition } from "../types.js"; import { UnsupportedFeature } from "../errors.js"; import claude from "./claude.js"; -import { envRecord, extractCodexHeaders } from "./helpers.js"; +import { envRecord, extractCodexHeaders, markManagedTomlSubagent, serializeCodexSubagent } from "./helpers.js"; const codex: AgentDefinition = { ...claude, @@ -33,6 +35,24 @@ const codex: AgentDefinition = { serializeHooks() { throw new UnsupportedFeature("codex", "hooks"); }, + subagents: { + projectDir: ".codex/agents", + userDir: join(homedir(), ".codex", "agents"), + fileExtension: ".toml", + serialize(subagent) { + const native = subagent.native?.codex; + return { + fileName: `${subagent.name}.toml`, + content: native + ? markManagedTomlSubagent(native) + : serializeCodexSubagent({ + name: subagent.name, + description: subagent.description, + developer_instructions: subagent.instructions.trim(), + }), + }; + }, + }, }; export default codex; diff --git a/packages/dotagents/src/agents/definitions/cursor.ts b/packages/dotagents/src/agents/definitions/cursor.ts index 1015b81..b2c4240 100644 --- a/packages/dotagents/src/agents/definitions/cursor.ts +++ b/packages/dotagents/src/agents/definitions/cursor.ts @@ -3,7 +3,7 @@ import { homedir } from "node:os"; import type { AgentDefinition, HookDeclaration } from "../types.js"; import type { HookEvent } from "../../config/schema.js"; import claude from "./claude.js"; -import { httpServer } from "./helpers.js"; +import { httpServer, markManagedMarkdownSubagent, serializeMarkdownSubagent } from "./helpers.js"; /** * Maps universal hook events to Cursor event names. @@ -54,6 +54,26 @@ const cursor: AgentDefinition = { } return result; }, + subagents: { + projectDir: ".cursor/agents", + userDir: join(homedir(), ".cursor", "agents"), + fileExtension: ".md", + serialize(subagent) { + const native = subagent.native?.cursor; + return { + fileName: `${subagent.name}.md`, + content: native + ? markManagedMarkdownSubagent(native) + : serializeMarkdownSubagent( + { + name: subagent.name, + description: subagent.description, + }, + subagent.instructions, + ), + }; + }, + }, }; export default cursor; diff --git a/packages/dotagents/src/agents/definitions/helpers.test.ts b/packages/dotagents/src/agents/definitions/helpers.test.ts index 21b6547..ed0fb0b 100644 --- a/packages/dotagents/src/agents/definitions/helpers.test.ts +++ b/packages/dotagents/src/agents/definitions/helpers.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; -import { interpolateEnvRefs, interpolateHeaders, extractCodexHeaders } from "./helpers.js"; +import { + interpolateEnvRefs, + interpolateHeaders, + extractCodexHeaders, + serializeMarkdownSubagent, +} from "./helpers.js"; const cursorTpl = (k: string) => `\${env:${k}}`; @@ -89,3 +94,19 @@ describe("extractCodexHeaders", () => { expect(extractCodexHeaders(noHeaders)).toEqual({}); }); }); + +describe("serializeMarkdownSubagent", () => { + it("serializes simple frontmatter fields", () => { + const content = serializeMarkdownSubagent( + { + description: "Review code.", + mode: "subagent", + }, + "Review the diff.", + ); + + expect(content).toContain('description: "Review code."'); + expect(content).toContain('mode: "subagent"'); + expect(content).toContain("Review the diff."); + }); +}); diff --git a/packages/dotagents/src/agents/definitions/helpers.ts b/packages/dotagents/src/agents/definitions/helpers.ts index 970530a..f949854 100644 --- a/packages/dotagents/src/agents/definitions/helpers.ts +++ b/packages/dotagents/src/agents/definitions/helpers.ts @@ -1,5 +1,8 @@ +import { stringify as tomlStringify } from "smol-toml"; import type { McpDeclaration, HookDeclaration } from "../types.js"; +export const DOTAGENTS_SUBAGENT_MARKER = "Generated by dotagents. Edit agents.toml instead."; + export function envRecord( env: string[] | undefined, template: (key: string) => string, @@ -91,3 +94,60 @@ export function serializeClaudeHooks(hooks: HookDeclaration[]): Record, + instructions: string, +): string { + const lines = ["---", `# ${DOTAGENTS_SUBAGENT_MARKER}`]; + + for (const [key, value] of Object.entries(fields)) { + appendYamlField(lines, key, value); + } + + lines.push("---", "", instructions.trim(), ""); + return lines.join("\n"); +} + +export function markManagedMarkdownSubagent(content: string): string { + if (content.includes(DOTAGENTS_SUBAGENT_MARKER)) {return content;} + return content.replace(/^---\r?\n/, (opening) => `${opening}# ${DOTAGENTS_SUBAGENT_MARKER}\n`); +} + +export function serializeCodexSubagent( + fields: Record, +): string { + const doc: Record = {}; + for (const [key, value] of Object.entries(fields)) { + if (value !== undefined) { + doc[key] = value; + } + } + + return `# ${DOTAGENTS_SUBAGENT_MARKER}\n${tomlStringify(doc)}`; +} + +export function markManagedTomlSubagent(content: string): string { + if (content.includes(DOTAGENTS_SUBAGENT_MARKER)) {return content;} + return `# ${DOTAGENTS_SUBAGENT_MARKER}\n${content}`; +} + +function appendYamlField( + lines: string[], + key: string, + value: unknown, +): void { + if (value === undefined) {return;} + + const serialized = JSON.stringify(value); + if (serialized !== undefined) { + lines.push(`${toYamlKey(key)}: ${serialized}`); + } +} + +function toYamlKey(key: string): string { + if (/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key)) { + return key; + } + return JSON.stringify(key); +} diff --git a/packages/dotagents/src/agents/definitions/opencode.ts b/packages/dotagents/src/agents/definitions/opencode.ts index 411caf2..3bf09c2 100644 --- a/packages/dotagents/src/agents/definitions/opencode.ts +++ b/packages/dotagents/src/agents/definitions/opencode.ts @@ -1,11 +1,13 @@ +import { join } from "node:path"; +import { homedir } from "node:os"; import type { AgentDefinition } from "../types.js"; import { UnsupportedFeature } from "../errors.js"; -import { envRecord, httpServer } from "./helpers.js"; +import { envRecord, httpServer, markManagedMarkdownSubagent, serializeMarkdownSubagent } from "./helpers.js"; const opencode: AgentDefinition = { id: "opencode", displayName: "OpenCode", - configDir: ".claude", + configDir: ".opencode", // reads .agents/skills/ natively at both project and user scope skillsParentDir: undefined, userSkillsParentDirs: undefined, @@ -32,6 +34,26 @@ const opencode: AgentDefinition = { serializeHooks() { throw new UnsupportedFeature("opencode", "hooks"); }, + subagents: { + projectDir: ".opencode/agents", + userDir: join(homedir(), ".config", "opencode", "agents"), + fileExtension: ".md", + serialize(subagent) { + const native = subagent.native?.opencode; + return { + fileName: `${subagent.name}.md`, + content: native + ? markManagedMarkdownSubagent(native) + : serializeMarkdownSubagent( + { + description: subagent.description, + mode: "subagent", + }, + subagent.instructions, + ), + }; + }, + }, }; export default opencode; diff --git a/packages/dotagents/src/agents/index.ts b/packages/dotagents/src/agents/index.ts index dfa1f7b..dbe6ffe 100644 --- a/packages/dotagents/src/agents/index.ts +++ b/packages/dotagents/src/agents/index.ts @@ -3,6 +3,33 @@ export { writeMcpConfigs, verifyMcpConfigs, toMcpDeclarations, projectMcpResolve export type { McpTargetResolver, McpResolvedTarget } from "./mcp-writer.js"; export { writeHookConfigs, verifyHookConfigs, toHookDeclarations, projectHookResolver } from "./hook-writer.js"; export type { HookTargetResolver, HookResolvedTarget } from "./hook-writer.js"; +export { + writeSubagentConfigs, + verifySubagentConfigs, + projectSubagentResolver, + userSubagentResolver, +} from "./subagent-writer.js"; +export { + resolveSubagent, + writeInstalledSubagents, + loadInstalledSubagents, + pruneInstalledSubagents, + lockEntryForSubagent, +} from "./subagent-store.js"; +export type { + SubagentTargetResolver, + SubagentResolvedTarget, + SubagentWriteWarning, + SubagentWriteResult, + SubagentVerifyIssue, +} from "./subagent-writer.js"; +export type { + SubagentResolveOptions, + ResolvedSubagent, + ResolvedSubagentType, + InstalledSubagentLoadIssue, +} from "./subagent-store.js"; +export type { LockedSubagent } from "../lockfile/schema.js"; export { UnsupportedFeature } from "./errors.js"; export { getUserMcpTarget, userMcpResolver } from "./paths.js"; export type { UserMcpTarget } from "./paths.js"; @@ -14,4 +41,10 @@ export type { HookDeclaration, HookConfigSpec, HookSerializer, + SubagentDeclaration, + NativeSubagentConfig, + NativeSubagentContent, + NativeSubagentTarget, + SubagentConfigSpec, + SubagentSerializer, } from "./types.js"; diff --git a/packages/dotagents/src/agents/paths.test.ts b/packages/dotagents/src/agents/paths.test.ts index e2a03ab..de83457 100644 --- a/packages/dotagents/src/agents/paths.test.ts +++ b/packages/dotagents/src/agents/paths.test.ts @@ -78,3 +78,35 @@ describe("skill discovery paths", () => { expect(agent.userSkillsParentDirs).toBeUndefined(); }); }); + +describe("subagent paths", () => { + const home = homedir(); + + it("claude supports project and user subagents", () => { + const agent = getAgent("claude")!; + expect(agent.subagents?.projectDir).toBe(".claude/agents"); + expect(agent.subagents?.userDir).toBe(join(home, ".claude", "agents")); + }); + + it("cursor supports project and user subagents", () => { + const agent = getAgent("cursor")!; + expect(agent.subagents?.projectDir).toBe(".cursor/agents"); + expect(agent.subagents?.userDir).toBe(join(home, ".cursor", "agents")); + }); + + it("codex supports project and user subagents", () => { + const agent = getAgent("codex")!; + expect(agent.subagents?.projectDir).toBe(".codex/agents"); + expect(agent.subagents?.userDir).toBe(join(home, ".codex", "agents")); + }); + + it("opencode supports project and user subagents", () => { + const agent = getAgent("opencode")!; + expect(agent.subagents?.projectDir).toBe(".opencode/agents"); + expect(agent.subagents?.userDir).toBe(join(home, ".config", "opencode", "agents")); + }); + + it("vscode does not support custom subagents", () => { + expect(getAgent("vscode")!.subagents).toBeUndefined(); + }); +}); diff --git a/packages/dotagents/src/agents/subagent-store.test.ts b/packages/dotagents/src/agents/subagent-store.test.ts new file mode 100644 index 0000000..34c3a70 --- /dev/null +++ b/packages/dotagents/src/agents/subagent-store.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { loadInstalledSubagents, resolveSubagent, writeInstalledSubagents } from "./subagent-store.js"; +import type { SubagentConfig } from "../config/schema.js"; + +const SUBAGENT_MD = (name: string) => `--- +name: ${name} +description: Review code for correctness. +--- + +Review the current diff. +`; + +function subagentConfig(overrides: Partial = {}): SubagentConfig { + return { + name: "code-reviewer", + source: "path:repo", + ...overrides, + }; +} + +describe("resolveSubagent", () => { + let tmpDir: string; + let repoDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "dotagents-subagent-store-")); + repoDir = join(tmpDir, "repo"); + await mkdir(repoDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true }); + }); + + it("discovers subagents by frontmatter name", async () => { + await mkdir(join(repoDir, "agents"), { recursive: true }); + await writeFile(join(repoDir, "agents", "reviewer.md"), SUBAGENT_MD("code-reviewer")); + + const resolved = await resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }); + + expect(resolved.subagent.name).toBe("code-reviewer"); + expect(resolved.subagent.description).toBe("Review code for correctness."); + }); + + it("imports Codex TOML subagents as portable declarations with native content", async () => { + await mkdir(join(repoDir, ".codex", "agents"), { recursive: true }); + await writeFile( + join(repoDir, ".codex", "agents", "code-reviewer.toml"), + [ + 'name = "code_reviewer"', + 'description = "Review code for correctness."', + 'developer_instructions = "Review the current diff."', + 'sandbox_mode = "read-only"', + "", + ].join("\n"), + ); + + const resolved = await resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }); + + expect(resolved.subagent.name).toBe("code-reviewer"); + expect(resolved.subagent.instructions).toBe("Review the current diff."); + expect(resolved.subagent.native?.codex).toContain('sandbox_mode = "read-only"'); + expect(resolved.subagent.native?.codex).toContain('name = "code_reviewer"'); + }); + + it("discovers Codex TOML subagents by native name when it is portable", async () => { + await mkdir(join(repoDir, ".codex", "agents"), { recursive: true }); + await writeFile( + join(repoDir, ".codex", "agents", "reviewer.toml"), + [ + 'name = "code-reviewer"', + 'description = "Review code for correctness."', + 'developer_instructions = "Review the current diff."', + "", + ].join("\n"), + ); + + const resolved = await resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }); + + expect(resolved.subagent.name).toBe("code-reviewer"); + expect(resolved.subagent.native?.codex).toContain('name = "code-reviewer"'); + }); + + it("imports OpenCode markdown subagents using the filename as the name", async () => { + await mkdir(join(repoDir, ".opencode", "agents"), { recursive: true }); + await writeFile( + join(repoDir, ".opencode", "agents", "code-reviewer.md"), + `--- +description: Review code for correctness. +mode: subagent +permission: + edit: deny +--- + +Review the current diff. +`, + ); + + const resolved = await resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }); + + expect(resolved.subagent.name).toBe("code-reviewer"); + expect(resolved.subagent.native?.opencode).toContain("mode: subagent"); + expect(resolved.subagent.native?.opencode).toContain("permission:"); + }); + + it("merges native subagent artifacts for the same portable declaration", async () => { + await mkdir(join(repoDir, ".claude", "agents"), { recursive: true }); + await mkdir(join(repoDir, ".codex", "agents"), { recursive: true }); + await writeFile( + join(repoDir, ".claude", "agents", "code-reviewer.md"), + `--- +name: code-reviewer +description: Review code for correctness. +tools: Read, Grep +--- + +Review the current diff for Claude. +`, + ); + await writeFile( + join(repoDir, ".codex", "agents", "code-reviewer.toml"), + [ + 'name = "code_reviewer"', + 'description = "Review code for correctness."', + 'developer_instructions = "Review the current diff for Codex."', + 'sandbox_mode = "read-only"', + "", + ].join("\n"), + ); + + const resolved = await resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }); + + expect(resolved.subagent.instructions).toBe("Review the current diff for Claude."); + expect(resolved.subagent.native?.claude).toContain("tools: Read, Grep"); + expect(resolved.subagent.native?.codex).toContain('sandbox_mode = "read-only"'); + }); + + it("rejects explicit paths whose frontmatter name differs from agents.toml", async () => { + await mkdir(join(repoDir, "agents"), { recursive: true }); + await writeFile(join(repoDir, "agents", "reviewer.md"), SUBAGENT_MD("other-reviewer")); + + await expect( + resolveSubagent(subagentConfig({ path: "agents/reviewer.md" }), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }), + ).rejects.toThrow( + 'Subagent "agents/reviewer.md" declares name "other-reviewer", but agents.toml requested "code-reviewer".', + ); + }); + + it("rejects filename matches whose frontmatter name differs from agents.toml", async () => { + await mkdir(join(repoDir, "agents"), { recursive: true }); + await writeFile(join(repoDir, "agents", "code-reviewer.md"), SUBAGENT_MD("other-reviewer")); + + await expect( + resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }), + ).rejects.toThrow( + 'Subagent "agents/code-reviewer.md" declares name "other-reviewer", but agents.toml requested "code-reviewer".', + ); + }); + + it("rejects filename matches with invalid frontmatter", async () => { + await mkdir(join(repoDir, "agents"), { recursive: true }); + await writeFile(join(repoDir, "agents", "code-reviewer.md"), "# No frontmatter\n"); + + await expect( + resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }), + ).rejects.toThrow("No YAML frontmatter"); + }); + + it("rejects invalid frontmatter names", async () => { + await mkdir(join(repoDir, "agents"), { recursive: true }); + await writeFile(join(repoDir, "agents", "code-reviewer.md"), SUBAGENT_MD("CodeReviewer")); + + await expect( + resolveSubagent(subagentConfig({ path: "agents/code-reviewer.md" }), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }), + ).rejects.toThrow( + 'Invalid subagent name "CodeReviewer"', + ); + }); + + it("rejects explicit paths outside the source directory", async () => { + await expect( + resolveSubagent(subagentConfig({ path: "../outside.md" }), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }), + ).rejects.toThrow("Subagent path resolves outside source: ../outside.md"); + }); + + it("rejects HTTPS well-known sources", async () => { + await expect( + resolveSubagent(subagentConfig({ source: "https://example.com" }), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }), + ).rejects.toThrow("HTTPS well-known sources are not supported for subagents"); + }); + + it("reports installed subagents whose frontmatter name does not match config", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + await writeFile(join(installedDir, "code-reviewer.md"), SUBAGENT_MD("other-reviewer")); + + const result = await loadInstalledSubagents(installedDir, [subagentConfig()]); + + expect(result.subagents).toEqual([]); + expect(result.issues).toHaveLength(1); + expect(result.issues[0]!.issue).toContain( + 'declares name "other-reviewer", but agents.toml requested "code-reviewer"', + ); + }); +}); + +describe("writeInstalledSubagents", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "dotagents-subagent-store-write-")); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true }); + }); + + it("does not create an empty install directory when there are no subagents", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + + const written = await writeInstalledSubagents(installedDir, []); + + expect(written).toEqual([]); + expect(existsSync(installedDir)).toBe(false); + }); + + it("prunes stale managed files when there are no subagents", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await writeInstalledSubagents(installedDir, [{ + name: "code-reviewer", + description: "Review code for correctness.", + instructions: "Review the current diff.", + }]); + + await writeInstalledSubagents(installedDir, []); + + expect(existsSync(join(installedDir, "code-reviewer.md"))).toBe(false); + }); + + it("roundtrips native overlays through the installed portable markdown", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await writeInstalledSubagents(installedDir, [{ + name: "code-reviewer", + description: "Review code for correctness.", + instructions: "Review the current diff.", + native: { + codex: [ + 'name = "code_reviewer"', + 'description = "Review code for correctness."', + 'developer_instructions = "Review the current diff."', + 'sandbox_mode = "read-only"', + "", + ].join("\n"), + }, + }]); + + const result = await loadInstalledSubagents(installedDir, [subagentConfig()]); + + expect(result.issues).toEqual([]); + expect(result.subagents[0]!.native?.codex).toContain('sandbox_mode = "read-only"'); + expect(result.subagents[0]!.native?.codex).toContain('name = "code_reviewer"'); + }); +}); diff --git a/packages/dotagents/src/agents/subagent-store.ts b/packages/dotagents/src/agents/subagent-store.ts new file mode 100644 index 0000000..e260d22 --- /dev/null +++ b/packages/dotagents/src/agents/subagent-store.ts @@ -0,0 +1,529 @@ +import { existsSync } from "node:fs"; +import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { basename, extname, isAbsolute, join, relative, resolve } from "node:path"; +import { parse as parseTOML } from "smol-toml"; +import { + applyDefaultRepositorySource, + ensureCached, + isSourceExcluded, + loadMarkdownFrontmatter, + parseSource, + resolveLocalSource, + sanitizeCacheKey, + validateTrustedSource, + type RepositorySource, + type TrustPolicy, +} from "@sentry/dotagents-lib"; +import { DOTAGENTS_SUBAGENT_MARKER, serializeMarkdownSubagent } from "./definitions/helpers.js"; +import { SUBAGENT_NAME_PATTERN, type SubagentConfig } from "../config/schema.js"; +import type { LockedSubagent } from "../lockfile/schema.js"; +import type { NativeSubagentContent, NativeSubagentTarget, SubagentDeclaration } from "./types.js"; + +const DOTAGENTS_NATIVE_FIELD = "dotagents_native"; +const NATIVE_SUBAGENT_TARGETS = ["claude", "cursor", "codex", "opencode"] satisfies NativeSubagentTarget[]; + +interface SubagentScanDir { + dir: string; + flat?: boolean; + nativeTarget?: NativeSubagentTarget; + extensions: readonly string[]; +} + +interface DiscoveredSubagent { + path: string; + subagent: SubagentDeclaration; +} + +const SUBAGENT_SCAN_DIRS: readonly SubagentScanDir[] = [ + { dir: ".", flat: true, extensions: [".md"] }, + { dir: "agents", extensions: [".md"] }, + { dir: ".agents/agents", extensions: [".md"] }, + { dir: ".claude/agents", nativeTarget: "claude", extensions: [".md"] }, + { dir: ".cursor/agents", nativeTarget: "cursor", extensions: [".md"] }, + { dir: ".codex/agents", nativeTarget: "codex", extensions: [".toml"] }, + { dir: ".opencode/agents", nativeTarget: "opencode", extensions: [".md"] }, +] as const; + +export interface SubagentResolveOptions { + stateDir: string; + projectRoot: string; + defaultRepositorySource?: RepositorySource; + minimumReleaseAge?: number; + minimumReleaseAgeExclude?: string[]; + trust?: TrustPolicy; +} + +export type ResolvedSubagentType = "git" | "local"; + +export interface ResolvedSubagent { + type: ResolvedSubagentType; + source: string; + resolvedUrl?: string; + resolvedPath?: string; + resolvedRef?: string; + commit?: string; + subagent: SubagentDeclaration; +} + +export interface InstalledSubagentLoadIssue { + name: string; + issue: string; + repairable: boolean; +} + +export async function resolveSubagent( + config: SubagentConfig, + opts: SubagentResolveOptions, +): Promise { + const sourceForResolve = applyDefaultRepositorySource( + config.source, + opts.defaultRepositorySource, + ); + if (opts.trust) {validateTrustedSource(sourceForResolve, opts.trust);} + + const parsed = parseSource(sourceForResolve); + + if (parsed.type === "local") { + const sourceDir = await resolveLocalSource(opts.projectRoot, parsed.path!); + const subagent = await loadSubagentFromSource(sourceDir, config); + return { type: "local", source: config.source, subagent }; + } + + if (parsed.type === "well-known") { + throw new Error( + `HTTPS well-known sources are not supported for subagents. Use a git: URL, GitHub/GitLab repository, or path: source for "${config.name}".`, + ); + } + + const url = parsed.url!; + const cloneUrl = parsed.cloneUrl ?? url; + const ref = config.ref ?? parsed.ref; + const cacheKey = parsed.type === "github" + ? `${parsed.owner}/${parsed.repo}` + : sanitizeCacheKey(url); + const excluded = isSourceExcluded(config.source, opts.minimumReleaseAgeExclude); + const cached = await ensureCached({ + stateDir: opts.stateDir, + url: cloneUrl, + cacheKey, + ref, + minimumReleaseAge: excluded ? undefined : opts.minimumReleaseAge, + }); + + const discovered = await discoverSubagent(cached.repoDir, config); + if (!discovered) { + throw new Error(`Subagent "${config.name}" not found in ${config.source}.`); + } + + return { + type: "git", + source: config.source, + resolvedUrl: cloneUrl, + resolvedPath: discovered.path, + resolvedRef: ref, + commit: cached.commit, + subagent: { ...discovered.subagent, targets: config.targets }, + }; +} + +export async function writeInstalledSubagents( + subagentsDir: string, + subagents: SubagentDeclaration[], +): Promise { + if (subagents.length === 0 && !existsSync(subagentsDir)) { + return []; + } + + await mkdir(subagentsDir, { recursive: true }); + const desired = new Set(); + const written: string[] = []; + + for (const subagent of subagents) { + const fileName = `${subagent.name}.md`; + desired.add(fileName); + const filePath = join(subagentsDir, fileName); + const content = serializeInstalledSubagent(subagent); + if (await writeManagedFile(filePath, content)) { + written.push(filePath); + } + } + + await pruneManagedMarkdownFiles(subagentsDir, desired); + return written; +} + +export async function loadInstalledSubagents( + subagentsDir: string, + configs: SubagentConfig[], +): Promise<{ subagents: SubagentDeclaration[]; issues: InstalledSubagentLoadIssue[] }> { + const subagents: SubagentDeclaration[] = []; + const issues: InstalledSubagentLoadIssue[] = []; + + for (const config of configs) { + const filePath = join(subagentsDir, `${config.name}.md`); + if (!existsSync(filePath)) { + issues.push({ + name: config.name, + issue: `Subagent "${config.name}" is in agents.toml but not installed. Run 'npx @sentry/dotagents install'.`, + repairable: false, + }); + continue; + } + + try { + const subagent = await loadSubagentFile(filePath); + assertSubagentNameMatches(subagent.name, config.name, `${config.name}.md`); + subagents.push({ ...subagent, targets: config.targets }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + issues.push({ + name: config.name, + issue: `Failed to load installed subagent "${config.name}": ${message}`, + repairable: false, + }); + } + } + + return { subagents, issues }; +} + +export async function pruneInstalledSubagents( + subagentsDir: string, + configs: SubagentConfig[], +): Promise { + const desired = new Set(configs.map((config) => `${config.name}.md`)); + return pruneManagedMarkdownFiles(subagentsDir, desired); +} + +export function lockEntryForSubagent(resolved: ResolvedSubagent): LockedSubagent { + return { + source: resolved.source, + ...(resolved.resolvedUrl ? { resolved_url: resolved.resolvedUrl } : {}), + ...(resolved.resolvedPath ? { resolved_path: resolved.resolvedPath } : {}), + ...(resolved.resolvedRef ? { resolved_ref: resolved.resolvedRef } : {}), + ...(resolved.commit ? { resolved_commit: resolved.commit } : {}), + }; +} + +async function loadSubagentFromSource( + sourceDir: string, + config: SubagentConfig, +): Promise { + const discovered = await discoverSubagent(sourceDir, config); + if (!discovered) { + throw new Error(`Subagent "${config.name}" not found in ${config.source}.`); + } + return { ...discovered.subagent, targets: config.targets }; +} + +async function discoverSubagent( + sourceDir: string, + config: SubagentConfig, +): Promise<{ path: string; subagent: SubagentDeclaration } | null> { + if (config.path) { + const sourceRoot = resolve(sourceDir); + const filePath = resolve(sourceRoot, config.path); + const relPath = relative(sourceRoot, filePath); + if (relPath.startsWith("..") || isAbsolute(relPath)) { + throw new Error(`Subagent path resolves outside source: ${config.path}`); + } + const nativeTarget = inferNativeTarget(config.path); + const subagent = await loadSubagentFile(filePath, { + expectedName: nativeTarget ? config.name : undefined, + nativeTarget, + nameFromFile: basename(filePath, extname(filePath)), + }); + if (nativeTarget !== "codex") { + assertSubagentNameMatches(subagent.name, config.name, config.path); + } + return { path: config.path, subagent }; + } + + const matches: DiscoveredSubagent[] = []; + for (const scanDir of SUBAGENT_SCAN_DIRS) { + const absDir = join(sourceDir, scanDir.dir); + const candidates = await listSubagentFiles(absDir, { + flat: scanDir.flat ?? false, + extensions: scanDir.extensions, + }); + let fileNameMatch: DiscoveredSubagent | null = null; + let frontmatterMatch: DiscoveredSubagent | null = null; + + for (const filePath of candidates) { + const nameFromFile = basename(filePath, extname(filePath)); + let subagent: SubagentDeclaration; + try { + subagent = await loadSubagentFile(filePath, { + nativeTarget: scanDir.nativeTarget, + nameFromFile, + }); + } catch (err) { + if (nameFromFile === config.name) {throw err;} + // Ignore Markdown/TOML files that are not subagent declarations. + continue; + } + + const relPath = relativePath(sourceDir, filePath); + if (nameFromFile === config.name && subagent.name !== config.name) { + assertSubagentNameMatches(subagent.name, config.name, relPath); + } + if (subagent.name !== config.name) {continue;} + if (!fileNameMatch && nameFromFile === config.name) { + fileNameMatch = { path: relPath, subagent }; + } else if (!frontmatterMatch) { + frontmatterMatch = { path: relPath, subagent }; + } + } + + if (fileNameMatch) {matches.push(fileNameMatch);} + if (frontmatterMatch) {matches.push(frontmatterMatch);} + } + + if (matches.length === 0) {return null;} + return mergeDiscoveredSubagents(matches); +} + +async function loadSubagentFile( + filePath: string, + opts: { + expectedName?: string; + nativeTarget?: NativeSubagentTarget; + nameFromFile?: string; + } = {}, +): Promise { + if (extname(filePath) === ".toml") { + return loadCodexSubagentFile(filePath, opts); + } + + const { meta, body, raw } = await loadMarkdownFrontmatter(filePath); + const name = typeof meta["name"] === "string" && meta["name"] + ? meta["name"] + : opts.nativeTarget === "opencode" + ? opts.nameFromFile + : undefined; + if (!name) { + throw new Error(`Missing 'name' in subagent frontmatter: ${filePath}`); + } + if (!SUBAGENT_NAME_PATTERN.test(name)) { + throw new Error( + `Invalid subagent name "${name}" in ${filePath}; expected lowercase letters, numbers, and hyphens`, + ); + } + if (typeof meta["description"] !== "string" || !meta["description"]) { + throw new Error(`Missing 'description' in subagent frontmatter: ${filePath}`); + } + if (!body) { + throw new Error(`Missing subagent instructions body: ${filePath}`); + } + + const native = readNativeOverlays(meta); + if (opts.nativeTarget) { + native[opts.nativeTarget] = raw; + } + + return { + name, + description: meta["description"], + instructions: body, + ...(Object.keys(native).length > 0 ? { native } : {}), + }; +} + +async function loadCodexSubagentFile( + filePath: string, + opts: { + expectedName?: string; + nameFromFile?: string; + } = {}, +): Promise { + let content: string; + try { + content = await readFile(filePath, "utf-8"); + } catch { + throw new Error(`Codex subagent TOML not found: ${filePath}`); + } + + let parsed: unknown; + try { + parsed = parseTOML(content); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Invalid Codex subagent TOML in ${filePath}: ${message}`, { cause: err }); + } + + if (!isPlainObject(parsed)) { + throw new Error(`Codex subagent TOML must be an object: ${filePath}`); + } + + const name = parsed["name"]; + if (typeof name !== "string" || !name) { + throw new Error(`Missing 'name' in Codex subagent TOML: ${filePath}`); + } + + const portableName = opts.expectedName ?? ( + SUBAGENT_NAME_PATTERN.test(name) ? name : opts.nameFromFile ?? name + ); + if (!SUBAGENT_NAME_PATTERN.test(portableName)) { + throw new Error( + `Invalid subagent name "${portableName}" in ${filePath}; expected lowercase letters, numbers, and hyphens`, + ); + } + + const description = parsed["description"]; + if (typeof description !== "string" || !description) { + throw new Error(`Missing 'description' in Codex subagent TOML: ${filePath}`); + } + + const instructions = parsed["developer_instructions"]; + if (typeof instructions !== "string" || !instructions.trim()) { + throw new Error(`Missing 'developer_instructions' in Codex subagent TOML: ${filePath}`); + } + + return { + name: portableName, + description, + instructions, + native: { + codex: content, + }, + }; +} + +function mergeDiscoveredSubagents(matches: DiscoveredSubagent[]): DiscoveredSubagent { + const base = matches[0]!; + const native: NativeSubagentContent = { ...base.subagent.native }; + + for (const match of matches.slice(1)) { + for (const [target, content] of Object.entries(match.subagent.native ?? {})) { + const nativeTarget = target as NativeSubagentTarget; + native[nativeTarget] ??= content; + } + } + + return { + path: base.path, + subagent: { + ...base.subagent, + ...(Object.keys(native).length > 0 ? { native } : {}), + }, + }; +} + +function assertSubagentNameMatches( + actualName: string, + expectedName: string, + sourcePath: string, +): void { + if (actualName === expectedName) {return;} + throw new Error( + `Subagent "${sourcePath}" declares name "${actualName}", but agents.toml requested "${expectedName}".`, + ); +} + +async function listSubagentFiles( + dirPath: string, + opts: { flat: boolean; extensions: readonly string[] }, +): Promise { + if (!existsSync(dirPath)) {return [];} + let entries; + try { + entries = await readdir(dirPath, { withFileTypes: true }); + } catch { + return []; + } + + const files: string[] = []; + for (const entry of entries) { + const absPath = join(dirPath, entry.name); + if (entry.isFile() && opts.extensions.includes(extname(entry.name))) { + files.push(absPath); + } else if (!opts.flat && entry.isDirectory()) { + files.push(...await listSubagentFiles(absPath, opts)); + } + } + return files; +} + +function serializeInstalledSubagent(subagent: SubagentDeclaration): string { + return serializeMarkdownSubagent( + { + name: subagent.name, + description: subagent.description, + ...(subagent.native ? { [DOTAGENTS_NATIVE_FIELD]: subagent.native } : {}), + }, + subagent.instructions, + ); +} + +async function writeManagedFile(filePath: string, content: string): Promise { + try { + const existing = await readFile(filePath, "utf-8"); + if (existing === content) {return false;} + if (!existing.includes(DOTAGENTS_SUBAGENT_MARKER)) { + throw new Error(`Subagent file exists and is not managed by dotagents: ${filePath}`); + } + } catch (err) { + if (!isNotFoundError(err)) {throw err;} + } + + await writeFile(filePath, content, "utf-8"); + return true; +} + +async function pruneManagedMarkdownFiles(dirPath: string, desired: Set): Promise { + if (!existsSync(dirPath)) {return [];} + + const pruned: string[] = []; + const entries = await readdir(dirPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) {continue;} + if (!entry.name.endsWith(".md")) {continue;} + if (desired.has(entry.name)) {continue;} + + const filePath = join(dirPath, entry.name); + const existing = await readFile(filePath, "utf-8"); + if (existing.includes(DOTAGENTS_SUBAGENT_MARKER)) { + await rm(filePath); + pruned.push(filePath); + } + } + + return pruned; +} + +function relativePath(root: string, filePath: string): string { + return relative(resolve(root), filePath).replaceAll("\\", "/"); +} + +function inferNativeTarget(filePath: string): NativeSubagentTarget | undefined { + const normalized = filePath.replaceAll("\\", "/"); + if (normalized.endsWith(".toml")) {return "codex";} + if (normalized.includes(".claude/agents/")) {return "claude";} + if (normalized.includes(".cursor/agents/")) {return "cursor";} + if (normalized.includes(".opencode/agents/")) {return "opencode";} + return undefined; +} + +function readNativeOverlays(meta: Record): NativeSubagentContent { + const raw = meta[DOTAGENTS_NATIVE_FIELD]; + if (!isPlainObject(raw)) {return {};} + + const native: NativeSubagentContent = {}; + for (const target of NATIVE_SUBAGENT_TARGETS) { + const config = raw[target]; + if (typeof config === "string") { + native[target] = config; + } else if (isPlainObject(config) && typeof config["content"] === "string") { + native[target] = config["content"]; + } + } + return native; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNotFoundError(err: unknown): boolean { + return err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "ENOENT"; +} diff --git a/packages/dotagents/src/agents/subagent-writer.test.ts b/packages/dotagents/src/agents/subagent-writer.test.ts new file mode 100644 index 0000000..c1f1f36 --- /dev/null +++ b/packages/dotagents/src/agents/subagent-writer.test.ts @@ -0,0 +1,292 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { parse as parseTOML } from "smol-toml"; +import { + projectSubagentResolver, + verifySubagentConfigs, + writeSubagentConfigs, +} from "./subagent-writer.js"; +import { DOTAGENTS_SUBAGENT_MARKER } from "./definitions/helpers.js"; +import type { SubagentDeclaration } from "./types.js"; + +const SUBAGENT: SubagentDeclaration = { + name: "code-reviewer", + description: "Review code for correctness and missing tests.", + instructions: "Review the current diff and return findings.", +}; + +describe("writeSubagentConfigs", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "dotagents-subagents-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true }); + }); + + it("writes Claude markdown subagents", async () => { + const result = await writeSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(result.warnings).toEqual([]); + expect(result.written).toBe(1); + + const content = await readFile(join(dir, ".claude", "agents", "code-reviewer.md"), "utf-8"); + expect(content).toContain(DOTAGENTS_SUBAGENT_MARKER); + expect(content).toContain('name: "code-reviewer"'); + expect(content).toContain('description: "Review code for correctness and missing tests."'); + expect(content).toContain("Review the current diff and return findings."); + }); + + it("writes Cursor markdown subagents", async () => { + await writeSubagentConfigs(["cursor"], [SUBAGENT], projectSubagentResolver(dir)); + + const content = await readFile(join(dir, ".cursor", "agents", "code-reviewer.md"), "utf-8"); + expect(content).toContain('name: "code-reviewer"'); + expect(content).toContain('description: "Review code for correctness and missing tests."'); + expect(content).toContain("Review the current diff and return findings."); + }); + + it("writes Codex TOML subagents", async () => { + await writeSubagentConfigs(["codex"], [SUBAGENT], projectSubagentResolver(dir)); + + const raw = await readFile(join(dir, ".codex", "agents", "code-reviewer.toml"), "utf-8"); + expect(raw).toContain(DOTAGENTS_SUBAGENT_MARKER); + + const content = parseTOML(raw) as Record; + expect(content["name"]).toBe("code-reviewer"); + expect(content["description"]).toBe("Review code for correctness and missing tests."); + expect(content["developer_instructions"]).toBe("Review the current diff and return findings."); + }); + + it("preserves native Codex fields only for the Codex target", async () => { + await writeSubagentConfigs( + ["codex", "claude"], + [{ + ...SUBAGENT, + instructions: "Portable instructions.", + native: { + codex: [ + "# upstream comment", + 'name = "code_reviewer"', + 'description = "Review code for correctness and missing tests."', + 'developer_instructions = "Native Codex instructions."', + 'sandbox_mode = "read-only"', + "", + ].join("\n"), + }, + }], + projectSubagentResolver(dir), + ); + + const codexRaw = await readFile(join(dir, ".codex", "agents", "code-reviewer.toml"), "utf-8"); + const codex = parseTOML(codexRaw) as Record; + expect(codex["developer_instructions"]).toBe("Native Codex instructions."); + expect(codex["sandbox_mode"]).toBe("read-only"); + expect(codexRaw).toContain("# upstream comment"); + + const claude = await readFile(join(dir, ".claude", "agents", "code-reviewer.md"), "utf-8"); + expect(claude).toContain("Portable instructions."); + expect(claude).not.toContain("sandbox_mode"); + }); + + it("writes OpenCode markdown subagents", async () => { + await writeSubagentConfigs(["opencode"], [SUBAGENT], projectSubagentResolver(dir)); + + const content = await readFile(join(dir, ".opencode", "agents", "code-reviewer.md"), "utf-8"); + expect(content).toContain(DOTAGENTS_SUBAGENT_MARKER); + expect(content).toContain('description: "Review code for correctness and missing tests."'); + expect(content).toContain('mode: "subagent"'); + expect(content).toContain("Review the current diff and return findings."); + }); + + it("preserves native markdown fields only for the matching markdown target", async () => { + await writeSubagentConfigs( + ["claude", "cursor"], + [{ + ...SUBAGENT, + native: { + claude: [ + "---", + "name: code-reviewer", + "description: Review code for correctness and missing tests.", + "tools: Read, Grep", + "---", + "", + "Native Claude instructions.", + "", + ].join("\n"), + }, + }], + projectSubagentResolver(dir), + ); + + const claude = await readFile(join(dir, ".claude", "agents", "code-reviewer.md"), "utf-8"); + const cursor = await readFile(join(dir, ".cursor", "agents", "code-reviewer.md"), "utf-8"); + expect(claude).toContain("tools: Read, Grep"); + expect(claude).toContain("Native Claude instructions."); + expect(cursor).not.toContain("tools"); + expect(cursor).toContain("Review the current diff and return findings."); + }); + + it("respects explicit targets", async () => { + await writeSubagentConfigs( + ["claude", "codex"], + [{ ...SUBAGENT, targets: ["codex"] }], + projectSubagentResolver(dir), + ); + + expect(existsSync(join(dir, ".codex", "agents", "code-reviewer.toml"))).toBe(true); + expect(existsSync(join(dir, ".claude", "agents", "code-reviewer.md"))).toBe(false); + }); + + it("warns when a target is not configured", async () => { + const result = await writeSubagentConfigs( + ["claude"], + [{ ...SUBAGENT, targets: ["codex"] }], + projectSubagentResolver(dir), + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]!.message).toContain("not listed in agents"); + }); + + it("warns for unsupported agents", async () => { + const result = await writeSubagentConfigs(["vscode"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]!.message).toContain("does not support custom subagents"); + }); + + it("does not overwrite unmanaged files", async () => { + const targetDir = join(dir, ".claude", "agents"); + await mkdir(targetDir, { recursive: true }); + await writeFile(join(targetDir, "code-reviewer.md"), "hand-written", "utf-8"); + + const result = await writeSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(result.warnings).toHaveLength(1); + expect(result.written).toBe(0); + expect(await readFile(join(targetDir, "code-reviewer.md"), "utf-8")).toBe("hand-written"); + }); + + it("prunes stale dotagents-managed files", async () => { + const targetDir = join(dir, ".claude", "agents"); + await mkdir(targetDir, { recursive: true }); + const stalePath = join(targetDir, "old-reviewer.md"); + await writeFile( + stalePath, + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "old-reviewer"\n---\n`, + "utf-8", + ); + + const result = await writeSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(result.pruned).toEqual([stalePath]); + expect(existsSync(stalePath)).toBe(false); + expect(existsSync(join(targetDir, "code-reviewer.md"))).toBe(true); + }); + + it("prunes managed files for runtimes no longer listed in agents", async () => { + const targetDir = join(dir, ".codex", "agents"); + await mkdir(targetDir, { recursive: true }); + const stalePath = join(targetDir, "code-reviewer.toml"); + await writeFile( + stalePath, + `# ${DOTAGENTS_SUBAGENT_MARKER}\nname = "code-reviewer"\n`, + "utf-8", + ); + + const result = await writeSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(result.pruned).toEqual([stalePath]); + expect(existsSync(stalePath)).toBe(false); + }); + + it("prunes managed files when no subagents remain", async () => { + const targetDir = join(dir, ".opencode", "agents"); + await mkdir(targetDir, { recursive: true }); + const stalePath = join(targetDir, "code-reviewer.md"); + await writeFile( + stalePath, + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\ndescription: "old"\n---\n`, + "utf-8", + ); + + const result = await writeSubagentConfigs(["opencode"], [], projectSubagentResolver(dir)); + + expect(result.pruned).toEqual([stalePath]); + expect(existsSync(stalePath)).toBe(false); + }); + + it("preserves declared-but-unloaded subagent files while pruning other stale files", async () => { + const targetDir = join(dir, ".claude", "agents"); + await mkdir(targetDir, { recursive: true }); + const declaredPath = join(targetDir, "code-reviewer.md"); + const stalePath = join(targetDir, "old-reviewer.md"); + await writeFile( + declaredPath, + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "code-reviewer"\n---\n`, + "utf-8", + ); + await writeFile( + stalePath, + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "old-reviewer"\n---\n`, + "utf-8", + ); + + const result = await writeSubagentConfigs( + ["claude"], + [], + projectSubagentResolver(dir), + { desiredSubagents: [{ name: "code-reviewer" }] }, + ); + + expect(result.pruned).toEqual([stalePath]); + expect(existsSync(declaredPath)).toBe(true); + expect(existsSync(stalePath)).toBe(false); + }); +}); + +describe("verifySubagentConfigs", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "dotagents-subagents-verify-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true }); + }); + + it("returns no issues when configs match", async () => { + await writeSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + const issues = await verifySubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + expect(issues).toEqual([]); + }); + + it("reports missing configs as repairable", async () => { + const issues = await verifySubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(issues).toHaveLength(1); + expect(issues[0]!.issue).toContain("missing"); + expect(issues[0]!.repairable).toBe(true); + }); + + it("reports unmanaged existing configs as not repairable", async () => { + const targetDir = join(dir, ".claude", "agents"); + await mkdir(targetDir, { recursive: true }); + await writeFile(join(targetDir, "code-reviewer.md"), "hand-written", "utf-8"); + + const issues = await verifySubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(issues).toHaveLength(1); + expect(issues[0]!.issue).toContain("not managed by dotagents"); + expect(issues[0]!.repairable).toBe(false); + }); +}); diff --git a/packages/dotagents/src/agents/subagent-writer.ts b/packages/dotagents/src/agents/subagent-writer.ts new file mode 100644 index 0000000..b7cad48 --- /dev/null +++ b/packages/dotagents/src/agents/subagent-writer.ts @@ -0,0 +1,302 @@ +import { existsSync } from "node:fs"; +import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { allAgents, getAgent } from "./registry.js"; +import { DOTAGENTS_SUBAGENT_MARKER } from "./definitions/helpers.js"; +import type { SubagentConfigSpec, SubagentDeclaration } from "./types.js"; + +export interface SubagentResolvedTarget { + dirPath: string; +} + +export type SubagentTargetResolver = ( + agentId: string, + spec: SubagentConfigSpec, +) => SubagentResolvedTarget; + +export interface SubagentWriteWarning { + agent: string; + name: string; + message: string; +} + +export interface SubagentWriteResult { + warnings: SubagentWriteWarning[]; + written: number; + pruned: string[]; +} + +export interface SubagentVerifyIssue { + agent: string; + name: string; + issue: string; + repairable: boolean; +} + +type DesiredSubagent = Pick; + +interface DesiredDir { + extension: string; + files: Set; +} + +export function projectSubagentResolver(projectRoot: string): SubagentTargetResolver { + return (_id: string, spec: SubagentConfigSpec) => ({ + dirPath: join(projectRoot, spec.projectDir), + }); +} + +export function userSubagentResolver(): SubagentTargetResolver { + return (_id: string, spec: SubagentConfigSpec) => ({ + dirPath: spec.userDir, + }); +} + +export async function writeSubagentConfigs( + agentIds: string[], + subagents: SubagentDeclaration[], + resolveTarget: SubagentTargetResolver, + opts: { desiredSubagents?: DesiredSubagent[] } = {}, +): Promise { + const warnings: SubagentWriteWarning[] = []; + let written = 0; + const configuredAgents = new Set(agentIds); + const desiredByDir = initDesiredDirs( + agentIds, + opts.desiredSubagents ?? subagents, + resolveTarget, + ); + + for (const subagent of subagents) { + for (const agentId of selectedAgentIds(agentIds, subagent)) { + if (!configuredAgents.has(agentId)) { + warnings.push({ + agent: agentId, + name: subagent.name, + message: `Subagent "${subagent.name}" targets agent "${agentId}", but "${agentId}" is not listed in agents`, + }); + continue; + } + + const agent = getAgent(agentId); + if (!agent) {continue;} + if (!agent.subagents) { + warnings.push({ + agent: agentId, + name: subagent.name, + message: `Agent "${agent.displayName}" does not support custom subagents`, + }); + continue; + } + + const { dirPath } = resolveTarget(agentId, agent.subagents); + const generated = agent.subagents.serialize(subagent); + const content = normalizeContent(generated.content); + if (!content.includes(DOTAGENTS_SUBAGENT_MARKER)) { + throw new Error(`Internal error: generated subagent "${subagent.name}" is missing the dotagents marker`); + } + + await mkdir(dirPath, { recursive: true }); + markDesired(desiredByDir, dirPath, agent.subagents.fileExtension, generated.fileName); + const didWrite = await writeManagedFile(join(dirPath, generated.fileName), content, { + agent: agentId, + name: subagent.name, + warnings, + }); + if (didWrite) {written++;} + } + } + + const pruned = await pruneManagedFiles(desiredByDir); + return { warnings, written, pruned }; +} + +export async function verifySubagentConfigs( + agentIds: string[], + subagents: SubagentDeclaration[], + resolveTarget: SubagentTargetResolver, +): Promise { + if (subagents.length === 0) {return [];} + + const issues: SubagentVerifyIssue[] = []; + const configuredAgents = new Set(agentIds); + const seen = new Set(); + + for (const subagent of subagents) { + for (const agentId of selectedAgentIds(agentIds, subagent)) { + const issueBase = { agent: agentId, name: subagent.name }; + + if (!configuredAgents.has(agentId)) { + issues.push({ + ...issueBase, + issue: `Subagent "${subagent.name}" targets agent "${agentId}", but "${agentId}" is not listed in agents`, + repairable: false, + }); + continue; + } + + const agent = getAgent(agentId); + if (!agent) {continue;} + if (!agent.subagents) { + issues.push({ + ...issueBase, + issue: `Agent "${agent.displayName}" does not support custom subagents`, + repairable: false, + }); + continue; + } + + const { dirPath } = resolveTarget(agentId, agent.subagents); + const generated = agent.subagents.serialize(subagent); + const filePath = join(dirPath, generated.fileName); + if (seen.has(filePath)) {continue;} + seen.add(filePath); + + if (!existsSync(filePath)) { + issues.push({ + ...issueBase, + issue: `Subagent config missing: ${filePath}`, + repairable: true, + }); + continue; + } + + try { + const existing = await readFile(filePath, "utf-8"); + if (!existing.includes(DOTAGENTS_SUBAGENT_MARKER)) { + issues.push({ + ...issueBase, + issue: `Subagent config exists and is not managed by dotagents: ${filePath}`, + repairable: false, + }); + continue; + } + + if (existing !== normalizeContent(generated.content)) { + issues.push({ + ...issueBase, + issue: `Subagent config out of date: ${filePath}`, + repairable: true, + }); + } + } catch { + issues.push({ + ...issueBase, + issue: `Failed to read subagent config: ${filePath}`, + repairable: false, + }); + } + } + } + + return issues; +} + +function initDesiredDirs( + agentIds: string[], + subagents: DesiredSubagent[], + resolveTarget: SubagentTargetResolver, +): Map { + const desiredByDir = new Map(); + const configuredAgents = new Set(agentIds); + for (const agent of allAgents()) { + if (!agent.subagents) {continue;} + const { dirPath } = resolveTarget(agent.id, agent.subagents); + markDesired(desiredByDir, dirPath, agent.subagents.fileExtension); + } + + for (const subagent of subagents) { + for (const agentId of selectedAgentIds(agentIds, subagent)) { + if (!configuredAgents.has(agentId)) {continue;} + + const agent = getAgent(agentId); + if (!agent?.subagents) {continue;} + + const { dirPath } = resolveTarget(agentId, agent.subagents); + markDesired( + desiredByDir, + dirPath, + agent.subagents.fileExtension, + `${subagent.name}${agent.subagents.fileExtension}`, + ); + } + } + + return desiredByDir; +} + +function selectedAgentIds( + agentIds: string[], + subagent: DesiredSubagent, +): string[] { + return [...new Set(subagent.targets ?? agentIds)]; +} + +function markDesired( + desiredByDir: Map, + dirPath: string, + extension: string, + fileName?: string, +): void { + const desired = desiredByDir.get(dirPath) ?? { extension, files: new Set() }; + if (fileName) { + desired.files.add(fileName); + } + desiredByDir.set(dirPath, desired); +} + +async function writeManagedFile( + filePath: string, + content: string, + context: { agent: string; name: string; warnings: SubagentWriteWarning[] }, +): Promise { + try { + const existing = await readFile(filePath, "utf-8"); + if (existing === content) {return false;} + if (!existing.includes(DOTAGENTS_SUBAGENT_MARKER)) { + context.warnings.push({ + agent: context.agent, + name: context.name, + message: `Subagent config exists and is not managed by dotagents: ${filePath}`, + }); + return false; + } + } catch (err) { + if (!isNotFoundError(err)) {throw err;} + } + + await writeFile(filePath, content, "utf-8"); + return true; +} + +async function pruneManagedFiles( + desiredByDir: Map, +): Promise { + const pruned: string[] = []; + for (const [dirPath, desired] of desiredByDir) { + if (!existsSync(dirPath)) {continue;} + + const entries = await readdir(dirPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) {continue;} + if (!entry.name.endsWith(desired.extension)) {continue;} + if (desired.files.has(entry.name)) {continue;} + + const filePath = join(dirPath, entry.name); + const existing = await readFile(filePath, "utf-8"); + if (existing.includes(DOTAGENTS_SUBAGENT_MARKER)) { + await rm(filePath); + pruned.push(filePath); + } + } + } + return pruned; +} + +function normalizeContent(content: string): string { + return content.endsWith("\n") ? content : `${content}\n`; +} + +function isNotFoundError(err: unknown): boolean { + return err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "ENOENT"; +} diff --git a/packages/dotagents/src/agents/types.ts b/packages/dotagents/src/agents/types.ts index 0b3b908..7804be8 100644 --- a/packages/dotagents/src/agents/types.ts +++ b/packages/dotagents/src/agents/types.ts @@ -1,4 +1,4 @@ -import type { HookEvent } from "../config/schema.js"; +import type { HookEvent, SubagentConfig } from "../config/schema.js"; /** * Universal MCP server declaration from agents.toml [[mcp]] sections. @@ -73,6 +73,46 @@ export interface HookConfigSpec { */ export type HookSerializer = (hooks: HookDeclaration[]) => unknown; +/** + * Universal subagent declaration loaded from an installed subagent Markdown file. + */ +export interface SubagentDeclaration { + name: string; + description: string; + instructions: string; + targets?: SubagentConfig["targets"]; + native?: NativeSubagentContent; +} + +export type NativeSubagentTarget = "claude" | "cursor" | "codex" | "opencode"; + +/** Raw source content in the runtime's native subagent format. */ +export type NativeSubagentConfig = string; + +export type NativeSubagentContent = Partial>; + +/** + * Describes where an agent stores custom subagent definitions. + */ +export interface SubagentConfigSpec { + /** Project-scope directory, relative to project root */ + projectDir: string; + /** User-scope directory, absolute path */ + userDir: string; + /** Generated file extension, including the leading dot */ + fileExtension: ".md" | ".toml"; + /** Transforms a universal subagent declaration into an agent-specific file */ + serialize: SubagentSerializer; +} + +/** + * Transforms a universal SubagentDeclaration into a runtime-specific file. + */ +export type SubagentSerializer = (subagent: SubagentDeclaration) => { + fileName: string; + content: string; +}; + /** * Definition of an agent tool that dotagents manages. */ @@ -99,4 +139,6 @@ export interface AgentDefinition { hooks?: HookConfigSpec; /** Transforms universal hook declarations to agent-specific format */ serializeHooks: HookSerializer; + /** Subagent config specification (undefined if agent doesn't support custom subagents) */ + subagents?: SubagentConfigSpec; } diff --git a/packages/dotagents/src/cli/commands/doctor.ts b/packages/dotagents/src/cli/commands/doctor.ts index b0a9b81..8817dfa 100644 --- a/packages/dotagents/src/cli/commands/doctor.ts +++ b/packages/dotagents/src/cli/commands/doctor.ts @@ -155,7 +155,11 @@ export async function runDoctor(opts: DoctorOptions): Promise { status: "warn", message: ".agents/.gitignore is missing. Run 'npx @sentry/dotagents install' or 'npx @sentry/dotagents sync' to regenerate.", fix: async () => { - await writeAgentsGitignore(scope.agentsDir, managedNames); + await writeAgentsGitignore( + scope.agentsDir, + managedNames, + config.subagents.map((subagent) => subagent.name), + ); }, }); } diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index f2390a3..fab76d9 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -4,9 +4,11 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { runInstall, InstallError } from "./install.js"; +import { runSync } from "./sync.js"; import { exec } from "@sentry/dotagents-lib"; import { loadLockfile } from "../../lockfile/loader.js"; import { resolveScope } from "../../scope.js"; +import { DOTAGENTS_SUBAGENT_MARKER } from "../../agents/definitions/helpers.js"; const SKILL_MD = (name: string) => `--- name: ${name} @@ -16,6 +18,14 @@ description: Test skill ${name} # ${name} `; +const SUBAGENT_MD = (name: string) => `--- +name: ${name} +description: Review code for correctness. +--- + +Review the current diff. +`; + describe("runInstall", () => { let tmpDir: string; let stateDir: string; @@ -230,6 +240,197 @@ describe("runInstall", () => { expect(result.hookWarnings[0]!.agent).toBe("codex"); }); + it("writes subagent configs for declared agents", async () => { + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude", "codex", "opencode"] + +[[subagents]] +name = "code-reviewer" +source = "path:agents" +`, + ); + + const scope = resolveScope("project", projectRoot); + const result = await runInstall({ scope }); + expect(result.subagentWarnings).toHaveLength(0); + + const claude = await readFile(join(projectRoot, ".claude", "agents", "code-reviewer.md"), "utf-8"); + expect(claude).toContain('description: "Review code for correctness."'); + + const codex = await readFile(join(projectRoot, ".codex", "agents", "code-reviewer.toml"), "utf-8"); + expect(codex).toContain('developer_instructions = "Review the current diff."'); + expect(await readFile(join(projectRoot, ".agents", "agents", "code-reviewer.md"), "utf-8")).toContain(DOTAGENTS_SUBAGENT_MARKER); + + const opencode = await readFile(join(projectRoot, ".opencode", "agents", "code-reviewer.md"), "utf-8"); + expect(opencode).toContain('mode: "subagent"'); + + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.subagents["code-reviewer"]?.source).toBe("path:agents"); + }); + + it("preserves native Codex content through install and sync", async () => { + const sourceDir = join(projectRoot, "upstream", ".codex", "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile( + join(sourceDir, "code-reviewer.toml"), + [ + "# upstream comment", + 'name = "code_reviewer"', + 'description = "Review code for correctness."', + 'developer_instructions = "Review the current diff."', + 'sandbox_mode = "read-only"', + "", + ].join("\n"), + ); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["codex", "claude"] + +[[subagents]] +name = "code-reviewer" +source = "path:upstream" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + const codexPath = join(projectRoot, ".codex", "agents", "code-reviewer.toml"); + expect(await readFile(codexPath, "utf-8")).toContain('sandbox_mode = "read-only"'); + expect(await readFile(codexPath, "utf-8")).toContain("# upstream comment"); + expect(await readFile(codexPath, "utf-8")).toContain('name = "code_reviewer"'); + + const claude = await readFile(join(projectRoot, ".claude", "agents", "code-reviewer.md"), "utf-8"); + expect(claude).not.toContain("sandbox_mode"); + + await rm(codexPath); + await runSync({ scope }); + + expect(await readFile(codexPath, "utf-8")).toContain('sandbox_mode = "read-only"'); + expect(await readFile(codexPath, "utf-8")).toContain("# upstream comment"); + expect(await readFile(codexPath, "utf-8")).toContain('name = "code_reviewer"'); + }); + + it("installs merged native subagent artifacts for matching runtimes", async () => { + await mkdir(join(projectRoot, "upstream", ".claude", "agents"), { recursive: true }); + await mkdir(join(projectRoot, "upstream", ".codex", "agents"), { recursive: true }); + await writeFile( + join(projectRoot, "upstream", ".claude", "agents", "code-reviewer.md"), + `--- +name: code-reviewer +description: Review code for correctness. +tools: Read, Grep +--- + +Native Claude instructions. +`, + ); + await writeFile( + join(projectRoot, "upstream", ".codex", "agents", "code-reviewer.toml"), + [ + "# native codex comment", + 'name = "code_reviewer"', + 'description = "Review code for correctness."', + 'developer_instructions = "Native Codex instructions."', + 'sandbox_mode = "read-only"', + "", + ].join("\n"), + ); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude", "codex"] + +[[subagents]] +name = "code-reviewer" +source = "path:upstream" +`, + ); + + await runInstall({ scope: resolveScope("project", projectRoot) }); + + const claude = await readFile(join(projectRoot, ".claude", "agents", "code-reviewer.md"), "utf-8"); + expect(claude).toContain("tools: Read, Grep"); + expect(claude).toContain("Native Claude instructions."); + expect(claude).not.toContain("sandbox_mode"); + + const codex = await readFile(join(projectRoot, ".codex", "agents", "code-reviewer.toml"), "utf-8"); + expect(codex).toContain("# native codex comment"); + expect(codex).toContain('name = "code_reviewer"'); + expect(codex).toContain("Native Codex instructions."); + expect(codex).toContain('sandbox_mode = "read-only"'); + + const installed = await readFile(join(projectRoot, ".agents", "agents", "code-reviewer.md"), "utf-8"); + expect(installed).toContain("Native Claude instructions."); + expect(installed).toContain("sandbox_mode"); + }); + + it("prunes subagent files and lock entries when removed from config", async () => { + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] + +[[subagents]] +name = "code-reviewer" +source = "path:agents" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] +`, + ); + + await runInstall({ scope }); + + expect(existsSync(join(projectRoot, ".agents", "agents", "code-reviewer.md"))).toBe(false); + expect(existsSync(join(projectRoot, ".claude", "agents", "code-reviewer.md"))).toBe(false); + + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.subagents).toEqual({}); + }); + + it("returns subagent warnings for unsupported agents", async () => { + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "reviewer.md"), SUBAGENT_MD("reviewer")); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["vscode"] + +[[subagents]] +name = "reviewer" +source = "path:agents" +`, + ); + + const scope = resolveScope("project", projectRoot); + const result = await runInstall({ scope }); + expect(result.subagentWarnings).toHaveLength(1); + expect(result.subagentWarnings[0]!.agent).toBe("vscode"); + }); + it("skips copy for in-place path skill", async () => { // Pre-install the skill directory (simulating an adopted orphan) const skillDir = join(projectRoot, ".agents", "skills", "local-skill"); diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index e7632eb..6485b48 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -30,7 +30,10 @@ import { ensureSkillsSymlink } from "../../symlinks/manager.js"; import { getAgent } from "../../agents/registry.js"; import { writeMcpConfigs, toMcpDeclarations, projectMcpResolver } from "../../agents/mcp-writer.js"; import { writeHookConfigs, toHookDeclarations, projectHookResolver } from "../../agents/hook-writer.js"; +import { writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../agents/subagent-writer.js"; +import { lockEntryForSubagent, resolveSubagent, writeInstalledSubagents } from "../../agents/subagent-store.js"; import { userMcpResolver } from "../../agents/paths.js"; +import type { SubagentDeclaration } from "../../agents/types.js"; import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; @@ -51,6 +54,7 @@ export interface InstallResult { skipped: string[]; pruned: string[]; hookWarnings: { agent: string; message: string }[]; + subagentWarnings: { agent: string; name: string; message: string }[]; } /** Expanded skill ready for install — either from an explicit entry or a wildcard */ @@ -146,6 +150,7 @@ async function expandSkills( export async function runInstall(opts: InstallOptions): Promise { const { scope, frozen } = opts; const { configPath, lockPath, agentsDir, skillsDir } = scope; + const subagentsDir = join(agentsDir, "agents"); // 1. Read config const config = await loadConfig(configPath); @@ -186,7 +191,7 @@ export async function runInstall(opts: InstallOptions): Promise { } } - const newLock: Lockfile = { version: 1, skills: {} }; + const newLock: Lockfile = { version: 1, skills: {}, subagents: lockfile?.subagents ?? {} }; for (const item of expanded) { const { name, dep } = item; @@ -269,7 +274,48 @@ export async function runInstall(opts: InstallOptions): Promise { } } - // 3. Gitignore (skip for user scope — ~/.agents/ is not a git repo) + // 3. Resolve and install subagent markdown files + const installedSubagents: SubagentDeclaration[] = []; + if (config.subagents.length > 0) { + const resolvedSubagents = []; + for (const subagentConfig of config.subagents) { + const resolved = await resolveSubagent(subagentConfig, { + stateDir: getCacheStateDir(), + projectRoot: scope.root, + defaultRepositorySource: config.defaultRepositorySource, + minimumReleaseAge: config.minimum_release_age, + minimumReleaseAgeExclude: config.minimum_release_age_exclude, + trust: config.trust, + }); + resolvedSubagents.push(resolved); + installedSubagents.push(resolved.subagent); + } + + await writeInstalledSubagents(subagentsDir, installedSubagents); + + if (!frozen) { + const lockfile = await loadLockfile(lockPath); + const newLock: Lockfile = { + version: 1, + skills: lockfile?.skills ?? {}, + subagents: {}, + }; + for (const resolved of resolvedSubagents) { + newLock.subagents[resolved.subagent.name] = lockEntryForSubagent(resolved); + } + await writeLockfile(lockPath, newLock); + } + } else { + await writeInstalledSubagents(subagentsDir, []); + if (!frozen) { + const lockfile = await loadLockfile(lockPath); + if (lockfile && Object.keys(lockfile.subagents).length > 0) { + await writeLockfile(lockPath, { ...lockfile, subagents: {} }); + } + } + } + + // 4. Gitignore (skip for user scope — ~/.agents/ is not a git repo) if (scope.scope === "project") { // For wildcard entries all expanded skills are managed (wildcards can't be in-place) const managedNames = installed.filter((name) => { @@ -278,7 +324,11 @@ export async function runInstall(opts: InstallOptions): Promise { if (!dep || isWildcardDep(dep)) {return true;} return !isInPlaceSkill(dep.source); }); - await writeAgentsGitignore(agentsDir, managedNames); + await writeAgentsGitignore( + agentsDir, + managedNames, + installedSubagents.map((subagent) => subagent.name), + ); // Health check: warn if agents.lock and .agents/.gitignore are not in root .gitignore const missing = await checkRootGitignoreEntries(scope.root); @@ -287,7 +337,7 @@ export async function runInstall(opts: InstallOptions): Promise { } } - // 4. Symlinks — create per-agent symlinks so each agent discovers skills + // 5. Symlinks — create per-agent symlinks so each agent discovers skills if (scope.scope === "user") { const seen = new Set(); for (const agentId of config.agents) { @@ -315,11 +365,11 @@ export async function runInstall(opts: InstallOptions): Promise { } } - // 5. Write MCP config files + // 6. Write MCP config files const mcpResolver = scope.scope === "user" ? userMcpResolver() : projectMcpResolver(scope.root); await writeMcpConfigs(config.agents, toMcpDeclarations(config.mcp), mcpResolver); - // 6. Write hook config files (skip for user scope) + // 7. Write hook config files (skip for user scope) let hookWarnings: { agent: string; message: string }[] = []; if (scope.scope === "project") { hookWarnings = await writeHookConfigs( @@ -329,7 +379,17 @@ export async function runInstall(opts: InstallOptions): Promise { ); } - return { installed, skipped, pruned, hookWarnings }; + // 8. Write custom subagent files + const subagentResolver = scope.scope === "user" + ? userSubagentResolver() + : projectSubagentResolver(scope.root); + const subagentResult = await writeSubagentConfigs( + config.agents, + installedSubagents, + subagentResolver, + ); + + return { installed, skipped, pruned, hookWarnings, subagentWarnings: subagentResult.warnings }; } export default async function install(args: string[], flags?: { user?: boolean }): Promise { @@ -366,6 +426,9 @@ export default async function install(args: string[], flags?: { user?: boolean } for (const w of result.hookWarnings) { console.log(chalk.yellow(` warn: ${w.message}`)); } + for (const w of result.subagentWarnings) { + console.log(chalk.yellow(` warn: ${w.message}`)); + } } catch (err) { if (err instanceof TrustError) { console.error(chalk.red(formatTrustError(err))); diff --git a/packages/dotagents/src/cli/commands/remove.ts b/packages/dotagents/src/cli/commands/remove.ts index a35b3cf..f0c3a6f 100644 --- a/packages/dotagents/src/cli/commands/remove.ts +++ b/packages/dotagents/src/cli/commands/remove.ts @@ -156,7 +156,11 @@ async function updateProjectGitignore(scope: ScopeRoot): Promise { if (!dep || isWildcardDep(dep)) {return true;} return !isInPlaceSkill(dep.source); }); - await writeAgentsGitignore(scope.agentsDir, managedNames); + await writeAgentsGitignore( + scope.agentsDir, + managedNames, + config.subagents.map((subagent) => subagent.name), + ); } async function promptYesNo(question: string): Promise { diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index 6a9fb0f..443feaf 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -10,6 +10,7 @@ import { writeLockfile } from "../../lockfile/writer.js"; import { loadLockfile } from "../../lockfile/loader.js"; import { loadConfig } from "../../config/loader.js"; import { resolveScope } from "../../scope.js"; +import { DOTAGENTS_SUBAGENT_MARKER } from "../../agents/definitions/helpers.js"; import { exec } from "@sentry/dotagents-lib"; const SKILL_MD = (name: string) => `--- @@ -384,6 +385,131 @@ describe("runSync", () => { expect(result.issues.filter((i) => i.type === "hooks")).toHaveLength(0); }); + it("repairs missing subagent configs", async () => { + const installedDir = join(projectRoot, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + await writeFile( + join(installedDir, "reviewer.md"), + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "reviewer"\ndescription: "Review code."\n---\n\nReview code.\n`, + "utf-8", + ); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] + +[[subagents]] +name = "reviewer" +source = "path:agents" +`, + ); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + expect(result.subagentsRepaired).toBe(1); + expect(existsSync(join(projectRoot, ".claude", "agents", "reviewer.md"))).toBe(true); + }); + + it("reports unmanaged subagent config conflicts without overwriting them", async () => { + const installedDir = join(projectRoot, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + await writeFile( + join(installedDir, "reviewer.md"), + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "reviewer"\ndescription: "Review code."\n---\n\nReview code.\n`, + "utf-8", + ); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] + +[[subagents]] +name = "reviewer" +source = "path:agents" +`, + ); + const agentsDir = join(projectRoot, ".claude", "agents"); + await mkdir(agentsDir, { recursive: true }); + await writeFile(join(agentsDir, "reviewer.md"), "hand-written", "utf-8"); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + + expect(result.subagentsRepaired).toBe(0); + expect(result.issues.some((i) => i.type === "subagents" && i.message.includes("not managed"))).toBe(true); + expect(await readFile(join(agentsDir, "reviewer.md"), "utf-8")).toBe("hand-written"); + }); + + it("does not prune runtime files for declared subagents that are not installed", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] + +[[subagents]] +name = "reviewer" +source = "path:agents" +`, + ); + const agentsDir = join(projectRoot, ".claude", "agents"); + await mkdir(agentsDir, { recursive: true }); + await writeFile( + join(agentsDir, "reviewer.md"), + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "reviewer"\ndescription: "Review code."\n---\n\nReview code.\n`, + "utf-8", + ); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + + expect(result.issues.some((i) => i.type === "subagents" && i.message.includes("not installed"))).toBe(true); + expect(result.subagentsRepaired).toBe(0); + expect(existsSync(join(agentsDir, "reviewer.md"))).toBe(true); + }); + + it("reports pruned subagent configs as repaired", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] +`, + ); + const agentsDir = join(projectRoot, ".claude", "agents"); + await mkdir(agentsDir, { recursive: true }); + await writeFile( + join(agentsDir, "old-reviewer.md"), + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "old-reviewer"\n---\n`, + "utf-8", + ); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + + expect(result.subagentsRepaired).toBe(1); + expect(existsSync(join(agentsDir, "old-reviewer.md"))).toBe(false); + }); + + it("prunes stale subagent lock entries", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] +`, + ); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + subagents: { + "old-reviewer": { + source: "path:agents", + }, + }, + }); + + await runSync({ scope: resolveScope("project", projectRoot) }); + + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.subagents).toEqual({}); + }); + it("does not auto-create root .gitignore", async () => { await writeFile( join(projectRoot, "agents.toml"), diff --git a/packages/dotagents/src/cli/commands/sync.ts b/packages/dotagents/src/cli/commands/sync.ts index 071b332..6d0cf88 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -13,13 +13,15 @@ import { ensureSkillsSymlink, verifySymlinks } from "../../symlinks/manager.js"; import { getAgent } from "../../agents/registry.js"; import { verifyMcpConfigs, writeMcpConfigs, toMcpDeclarations, projectMcpResolver } from "../../agents/mcp-writer.js"; import { verifyHookConfigs, writeHookConfigs, toHookDeclarations, projectHookResolver } from "../../agents/hook-writer.js"; +import { verifySubagentConfigs, writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../agents/subagent-writer.js"; +import { loadInstalledSubagents, pruneInstalledSubagents } from "../../agents/subagent-store.js"; import { userMcpResolver } from "../../agents/paths.js"; import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; import { isInPlaceSkill } from "../../utils/fs.js"; export interface SyncIssue { - type: "symlink" | "missing" | "mcp" | "hooks"; + type: "symlink" | "missing" | "mcp" | "hooks" | "subagents"; name: string; message: string; } @@ -36,11 +38,13 @@ export interface SyncResult { symlinksRepaired: number; mcpRepaired: number; hooksRepaired: number; + subagentsRepaired: number; } export async function runSync(opts: SyncOptions): Promise { const { scope } = opts; const { configPath, lockPath, agentsDir, skillsDir } = scope; + const subagentsDir = join(agentsDir, "agents"); let config = await loadConfig(configPath); let lockfile = await loadLockfile(lockPath); @@ -93,6 +97,7 @@ export async function runSync(opts: SyncOptions): Promise { lockfile = { version: 1, skills: { ...lockfile?.skills, ...adoptedLockEntries }, + subagents: lockfile?.subagents ?? {}, }; await writeLockfile(lockPath, lockfile); } @@ -115,7 +120,11 @@ export async function runSync(opts: SyncOptions): Promise { if (!dep || isWildcardDep(dep)) {return true;} // wildcard-sourced skills are always managed return !isInPlaceSkill(dep.source); }); - await writeAgentsGitignore(agentsDir, managedNames); + await writeAgentsGitignore( + agentsDir, + managedNames, + config.subagents.map((subagent) => subagent.name), + ); gitignoreUpdated = true; // Health check: warn if agents.lock and .agents/.gitignore are not in root .gitignore @@ -223,6 +232,63 @@ export async function runSync(opts: SyncOptions): Promise { } } + // 7. Verify and repair custom subagent files + let subagentsRepaired = 0; + const installedSubagentResult = await loadInstalledSubagents(subagentsDir, config.subagents); + const prunedInstalledSubagents = await pruneInstalledSubagents(subagentsDir, config.subagents); + const declaredSubagentNames = new Set(config.subagents.map((subagent) => subagent.name)); + if (lockfile && Object.keys(lockfile.subagents).some((name) => !declaredSubagentNames.has(name))) { + const subagents = Object.fromEntries( + Object.entries(lockfile.subagents).filter(([name]) => declaredSubagentNames.has(name)), + ); + lockfile = { ...lockfile, subagents }; + await writeLockfile(lockPath, lockfile); + } + const subagentDecls = installedSubagentResult.subagents; + const subagentResolver = scope.scope === "user" + ? userSubagentResolver() + : projectSubagentResolver(scope.root); + const subagentIssues = await verifySubagentConfigs( + config.agents, + subagentDecls, + subagentResolver, + ); + + const subagentResult = await writeSubagentConfigs( + config.agents, + subagentDecls, + subagentResolver, + { desiredSubagents: config.subagents }, + ); + subagentsRepaired = subagentResult.written + subagentResult.pruned.length + prunedInstalledSubagents.length; + + for (const issue of installedSubagentResult.issues) { + issues.push({ + type: "subagents", + name: issue.name, + message: issue.issue, + }); + } + for (const issue of subagentIssues) { + issues.push({ + type: "subagents", + name: issue.name, + message: issue.issue, + }); + } + for (const warning of subagentResult.warnings) { + const alreadyReported = issues.some( + (issue) => issue.type === "subagents" && issue.message === warning.message, + ); + if (!alreadyReported) { + issues.push({ + type: "subagents", + name: warning.name, + message: warning.message, + }); + } + } + return { issues, adopted, @@ -231,6 +297,7 @@ export async function runSync(opts: SyncOptions): Promise { symlinksRepaired, mcpRepaired, hooksRepaired, + subagentsRepaired, }; } @@ -277,6 +344,10 @@ export default async function sync(_args: string[], flags?: { user?: boolean }): console.log(chalk.green(`Repaired ${result.hooksRepaired} hook config(s)`)); } + if (result.subagentsRepaired > 0) { + console.log(chalk.green(`Repaired ${result.subagentsRepaired} subagent config(s)`)); + } + if (result.issues.length === 0) { console.log(chalk.green("Everything in sync.")); return; @@ -286,6 +357,7 @@ export default async function sync(_args: string[], flags?: { user?: boolean }): switch (issue.type) { case "mcp": case "hooks": + case "subagents": console.log(chalk.yellow(` warn: ${issue.message}`)); break; case "missing": diff --git a/packages/dotagents/src/config/index.ts b/packages/dotagents/src/config/index.ts index 244bd08..eb6e015 100644 --- a/packages/dotagents/src/config/index.ts +++ b/packages/dotagents/src/config/index.ts @@ -16,5 +16,6 @@ export type { ProjectConfig, SkillSource, McpConfig, + SubagentConfig, TrustConfig, } from "./schema.js"; diff --git a/packages/dotagents/src/config/loader.test.ts b/packages/dotagents/src/config/loader.test.ts index 14a62e0..8ab52e0 100644 --- a/packages/dotagents/src/config/loader.test.ts +++ b/packages/dotagents/src/config/loader.test.ts @@ -176,4 +176,74 @@ env = ["GITHUB_TOKEN"] const config = await loadConfig(configPath); expect(config.skills).toHaveLength(2); }); + + it("loads subagent entries", async () => { + const configPath = join(dir, "agents.toml"); + await writeFile( + configPath, + `version = 1 +agents = ["claude", "codex", "opencode"] + +[[subagents]] +name = "code-reviewer" +source = "getsentry/agents" +targets = ["claude", "codex", "opencode"] +`, + ); + + const config = await loadConfig(configPath); + expect(config.subagents).toHaveLength(1); + expect(config.subagents[0]!.targets).toEqual(["claude", "codex", "opencode"]); + expect(config.subagents[0]!.source).toBe("getsentry/agents"); + }); + + it("rejects unknown subagent targets", async () => { + const configPath = join(dir, "agents.toml"); + await writeFile( + configPath, + `version = 1 + +[[subagents]] +name = "reviewer" +source = "getsentry/agents" +targets = ["emacs"] +`, + ); + + await expect(loadConfig(configPath)).rejects.toThrow(/Unknown subagent target/); + }); + + it("rejects duplicate subagent names", async () => { + const configPath = join(dir, "agents.toml"); + await writeFile( + configPath, + `version = 1 + +[[subagents]] +name = "reviewer" +source = "getsentry/agents" + +[[subagents]] +name = "reviewer" +source = "getsentry/agents" +`, + ); + + await expect(loadConfig(configPath)).rejects.toThrow(/Duplicate subagent/); + }); + + it("rejects HTTPS well-known subagent sources", async () => { + const configPath = join(dir, "agents.toml"); + await writeFile( + configPath, + `version = 1 + +[[subagents]] +name = "reviewer" +source = "https://agents.example.com" +`, + ); + + await expect(loadConfig(configPath)).rejects.toThrow(/unsupported HTTPS well-known source/); + }); }); diff --git a/packages/dotagents/src/config/loader.ts b/packages/dotagents/src/config/loader.ts index 793170b..3b92ed4 100644 --- a/packages/dotagents/src/config/loader.ts +++ b/packages/dotagents/src/config/loader.ts @@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises"; import { parse as parseTOML } from "smol-toml"; import { agentsConfigSchema, isWildcardDep, type AgentsConfig } from "./schema.js"; import { allAgentIds } from "../agents/registry.js"; +import { applyDefaultRepositorySource, parseSource } from "@sentry/dotagents-lib"; export class ConfigError extends Error { constructor(message: string) { @@ -43,6 +44,31 @@ export async function loadConfig(filePath: string): Promise { ); } + const unknownSubagentTargets = [ + ...new Set( + result.data.subagents.flatMap((subagent) => + (subagent.targets ?? []).filter((id) => !validIds.includes(id)) + ), + ), + ]; + if (unknownSubagentTargets.length > 0) { + throw new ConfigError( + `Unknown subagent target(s) in ${filePath}: ${unknownSubagentTargets.join(", ")}. Valid agents: ${validIds.join(", ")}`, + ); + } + + for (const subagent of result.data.subagents) { + const sourceForResolve = applyDefaultRepositorySource( + subagent.source, + result.data.defaultRepositorySource, + ); + if (parseSource(sourceForResolve).type === "well-known") { + throw new ConfigError( + `Subagent "${subagent.name}" uses an unsupported HTTPS well-known source in ${filePath}. Use a git: URL, GitHub/GitLab repository, or path: source.`, + ); + } + } + // Post-parse validation: no two wildcard entries may share the same source const wildcardSources = new Set(); for (const dep of result.data.skills) { @@ -56,5 +82,16 @@ export async function loadConfig(filePath: string): Promise { } } + // Post-parse validation: no two subagent entries may share the same name. + const subagentNames = new Set(); + for (const subagent of result.data.subagents) { + if (subagentNames.has(subagent.name)) { + throw new ConfigError( + `Duplicate subagent in ${filePath}: "${subagent.name}". Subagent names must be unique.`, + ); + } + subagentNames.add(subagent.name); + } + return result.data; } diff --git a/packages/dotagents/src/config/schema.test.ts b/packages/dotagents/src/config/schema.test.ts index f82af01..ddfcf73 100644 --- a/packages/dotagents/src/config/schema.test.ts +++ b/packages/dotagents/src/config/schema.test.ts @@ -237,6 +237,75 @@ describe("agentsConfigSchema", () => { }); }); + describe("subagents field", () => { + it("defaults to empty array when absent", () => { + const result = agentsConfigSchema.safeParse({ version: 1 }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.subagents).toEqual([]); + } + }); + + it("accepts a portable subagent declaration", () => { + const result = agentsConfigSchema.safeParse({ + version: 1, + agents: ["claude", "codex", "opencode", "vscode"], + subagents: [ + { + name: "code-reviewer", + source: "getsentry/agents", + targets: ["claude", "codex", "opencode", "vscode"], + }, + ], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.subagents[0]!.name).toBe("code-reviewer"); + } + }); + + it("rejects runtime-specific subagent options", () => { + const result = agentsConfigSchema.safeParse({ + version: 1, + subagents: [ + { + name: "test-runner", + source: "getsentry/agents", + model: "inherit", + }, + ], + }); + expect(result.success).toBe(false); + }); + + it("rejects invalid subagent names", () => { + const result = agentsConfigSchema.safeParse({ + version: 1, + subagents: [ + { + name: "CodeReviewer", + source: "getsentry/agents", + }, + ], + }); + expect(result.success).toBe(false); + }); + + it("accepts known-but-unsupported subagent targets at the schema layer", () => { + const result = agentsConfigSchema.safeParse({ + version: 1, + subagents: [ + { + name: "reviewer", + source: "getsentry/agents", + targets: ["vscode"], + }, + ], + }); + expect(result.success).toBe(true); + }); + }); + describe("mcp field", () => { it("defaults to empty array when absent", () => { const result = agentsConfigSchema.safeParse({ version: 1 }); diff --git a/packages/dotagents/src/config/schema.ts b/packages/dotagents/src/config/schema.ts index dba21ad..933deec 100644 --- a/packages/dotagents/src/config/schema.ts +++ b/packages/dotagents/src/config/schema.ts @@ -148,6 +148,27 @@ const hookSchema = z.object({ export type HookConfig = z.infer; +export const SUBAGENT_NAME_PATTERN = /^[a-z][a-z0-9-]*$/; + +const subagentNameSchema = z + .string() + .regex( + SUBAGENT_NAME_PATTERN, + "Subagent names must start with lowercase a-z and contain only lowercase letters, numbers, and hyphens", + ); + +const subagentTargetSchema = z.string().min(1); + +const subagentSchema = z.object({ + name: subagentNameSchema, + source: skillSourceSchema, + ref: z.string().optional(), + path: z.string().optional(), + targets: z.array(subagentTargetSchema).optional(), +}).strict(); + +export type SubagentConfig = z.infer; + const trustConfigSchema = z.object({ allow_all: z.boolean().default(false), github_orgs: z.array(z.string()).default([]), @@ -168,6 +189,7 @@ export const agentsConfigSchema = z.object({ skills: z.array(skillDependencySchema).default([]), mcp: z.array(mcpSchema).default([]), hooks: z.array(hookSchema).default([]), + subagents: z.array(subagentSchema).default([]), trust: trustConfigSchema.optional(), minimum_release_age: z.number().int().min(0).optional(), minimum_release_age_exclude: z.array(z.string()).default([]), diff --git a/packages/dotagents/src/gitignore/writer.test.ts b/packages/dotagents/src/gitignore/writer.test.ts index 35bae39..153e56b 100644 --- a/packages/dotagents/src/gitignore/writer.test.ts +++ b/packages/dotagents/src/gitignore/writer.test.ts @@ -35,6 +35,15 @@ describe("writeAgentsGitignore", () => { expect(content).toContain("/skills/pdf/"); }); + it("lists managed subagent files", async () => { + const agentsDir = join(dir, ".agents"); + await writeAgentsGitignore(agentsDir, [], ["reviewer", "test-runner"]); + + const content = await readFile(join(agentsDir, ".gitignore"), "utf-8"); + expect(content).toContain("/agents/reviewer.md"); + expect(content).toContain("/agents/test-runner.md"); + }); + it("sorts skill names alphabetically", async () => { const agentsDir = join(dir, ".agents"); await writeAgentsGitignore(agentsDir, ["zebra", "alpha", "middle"]); diff --git a/packages/dotagents/src/gitignore/writer.ts b/packages/dotagents/src/gitignore/writer.ts index 0b2a225..8323902 100644 --- a/packages/dotagents/src/gitignore/writer.ts +++ b/packages/dotagents/src/gitignore/writer.ts @@ -3,20 +3,24 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; const HEADER = `# Auto-generated by dotagents. Do not edit. -# Managed skills (installed by dotagents)`; +# Managed artifacts (installed by dotagents)`; /** - * Generate .agents/.gitignore listing managed skill directories. + * Generate .agents/.gitignore listing managed skill directories and installed subagents. * Skills with path: sources are also gitignored since they're installed by dotagents. */ export async function writeAgentsGitignore( agentsDir: string, managedSkillNames: string[], + managedSubagentNames: string[] = [], ): Promise { const lines = [HEADER]; for (const name of managedSkillNames.toSorted()) { lines.push(`/skills/${name}/`); } + for (const name of managedSubagentNames.toSorted()) { + lines.push(`/agents/${name}.md`); + } lines.push(""); // trailing newline await writeFile(join(agentsDir, ".gitignore"), lines.join("\n"), "utf-8"); diff --git a/packages/dotagents/src/index.ts b/packages/dotagents/src/index.ts index a232033..a7b850c 100644 --- a/packages/dotagents/src/index.ts +++ b/packages/dotagents/src/index.ts @@ -6,21 +6,52 @@ export type { RegularSkillDependency, SkillSource, McpConfig, + SubagentConfig, TrustConfig, } from "./config/index.js"; export { resolveScope } from "./scope.js"; export type { Scope, ScopeRoot } from "./scope.js"; -export { getAgent, allAgentIds, writeMcpConfigs, verifyMcpConfigs, projectMcpResolver, getUserMcpTarget, userMcpResolver } from "./agents/index.js"; -export type { AgentDefinition, McpDeclaration, McpConfigSpec, McpTargetResolver } from "./agents/index.js"; +export { + getAgent, + allAgentIds, + writeMcpConfigs, + verifyMcpConfigs, + projectMcpResolver, + writeSubagentConfigs, + verifySubagentConfigs, + projectSubagentResolver, + getUserMcpTarget, + userMcpResolver, + userSubagentResolver, + resolveSubagent, + writeInstalledSubagents, + loadInstalledSubagents, + pruneInstalledSubagents, +} from "./agents/index.js"; +export type { + AgentDefinition, + McpDeclaration, + McpConfigSpec, + McpTargetResolver, + SubagentDeclaration, + NativeSubagentConfig, + NativeSubagentContent, + NativeSubagentTarget, + SubagentConfigSpec, + SubagentSerializer, + SubagentTargetResolver, + SubagentWriteResult, + ResolvedSubagent, +} from "./agents/index.js"; export { writeAgentsGitignore, ensureRootGitignoreEntries } from "./gitignore/index.js"; export { ensureSkillsSymlink, verifySymlinks } from "./symlinks/index.js"; export { lockfileSchema, loadLockfile, LockfileError, writeLockfile } from "./lockfile/index.js"; -export type { Lockfile, LockedSkill } from "./lockfile/index.js"; +export type { Lockfile, LockedSkill, LockedSubagent } from "./lockfile/index.js"; // --------------------------------------------------------------------------- // Re-exports from @sentry/dotagents-lib. diff --git a/packages/dotagents/src/lockfile/index.ts b/packages/dotagents/src/lockfile/index.ts index 1435bf0..cd4d865 100644 --- a/packages/dotagents/src/lockfile/index.ts +++ b/packages/dotagents/src/lockfile/index.ts @@ -1,4 +1,4 @@ export { lockfileSchema } from "./schema.js"; -export type { Lockfile, LockedSkill } from "./schema.js"; +export type { Lockfile, LockedSkill, LockedSubagent } from "./schema.js"; export { loadLockfile, LockfileError } from "./loader.js"; export { writeLockfile } from "./writer.js"; diff --git a/packages/dotagents/src/lockfile/schema.test.ts b/packages/dotagents/src/lockfile/schema.test.ts index 2af8994..8fa14ef 100644 --- a/packages/dotagents/src/lockfile/schema.test.ts +++ b/packages/dotagents/src/lockfile/schema.test.ts @@ -24,4 +24,18 @@ describe("lockfileSchema", () => { expect("resolved_commit" in skill).toBe(false); } }); + + it("rejects well-known-style subagent lock entries", () => { + const result = lockfileSchema.safeParse({ + version: 1, + skills: {}, + subagents: { + reviewer: { + source: "https://example.com", + resolved_url: "https://example.com", + }, + }, + }); + expect(result.success).toBe(false); + }); }); diff --git a/packages/dotagents/src/lockfile/schema.ts b/packages/dotagents/src/lockfile/schema.ts index 09bb24b..fc47205 100644 --- a/packages/dotagents/src/lockfile/schema.ts +++ b/packages/dotagents/src/lockfile/schema.ts @@ -19,12 +19,18 @@ const lockedLocalSkillSchema = z.object({ }); const lockedSkillSchema = z.union([lockedGitSkillSchema, lockedWellKnownSkillSchema, lockedLocalSkillSchema]); +const lockedLocalSubagentSchema = z.object({ + source: z.string(), +}).strict(); +const lockedSubagentSchema = z.union([lockedGitSkillSchema, lockedLocalSubagentSchema]); export type LockedSkill = z.infer; +export type LockedSubagent = z.infer; export const lockfileSchema = z.object({ version: z.literal(1), skills: z.record(z.string(), lockedSkillSchema).default({}), + subagents: z.record(z.string(), lockedSubagentSchema).default({}), }); export type Lockfile = z.infer; diff --git a/packages/dotagents/src/lockfile/writer.test.ts b/packages/dotagents/src/lockfile/writer.test.ts index 6ceadfa..b9f13cb 100644 --- a/packages/dotagents/src/lockfile/writer.test.ts +++ b/packages/dotagents/src/lockfile/writer.test.ts @@ -70,6 +70,41 @@ describe("writeLockfile + loadLockfile", () => { expect(keys).toEqual(["a-skill", "z-skill"]); }); + it("sorts subagents alphabetically", async () => { + const lockPath = join(dir, "agents.lock"); + await writeLockfile(lockPath, { + version: 1, + skills: {}, + subagents: { + "z-reviewer": { + source: "org/z-repo", + }, + "a-reviewer": { + source: "org/a-repo", + }, + }, + }); + + const loaded = await loadLockfile(lockPath); + const keys = Object.keys(loaded!.subagents); + expect(keys).toEqual(["a-reviewer", "z-reviewer"]); + }); + + it("omits empty subagents from the serialized lockfile", async () => { + const lockPath = join(dir, "agents.lock"); + await writeLockfile(lockPath, { + version: 1, + skills: {}, + subagents: {}, + }); + + const content = await readFile(lockPath, "utf-8"); + expect(content).not.toContain("[subagents]"); + + const loaded = await loadLockfile(lockPath); + expect(loaded!.subagents).toEqual({}); + }); + it("ends with exactly one trailing newline", async () => { const lockPath = join(dir, "agents.lock"); await writeLockfile(lockPath, { diff --git a/packages/dotagents/src/lockfile/writer.ts b/packages/dotagents/src/lockfile/writer.ts index 100627a..0a5b057 100644 --- a/packages/dotagents/src/lockfile/writer.ts +++ b/packages/dotagents/src/lockfile/writer.ts @@ -4,20 +4,37 @@ import type { Lockfile } from "./schema.js"; const HEADER = "# Auto-generated by dotagents. Do not edit.\n"; +type WritableLockfile = Omit & { + subagents?: Lockfile["subagents"]; +}; + /** * Write an agents.lock file. - * Skills are sorted alphabetically for deterministic output. + * Managed entries are sorted alphabetically for deterministic output. */ export async function writeLockfile( filePath: string, - lockfile: Lockfile, + lockfile: WritableLockfile, ): Promise { // Sort skills by name for deterministic output const sortedSkills: Record = {}; for (const name of Object.keys(lockfile.skills).toSorted()) { sortedSkills[name] = lockfile.skills[name]; } + const sortedSubagents: Record = {}; + const subagents = lockfile.subagents ?? {}; + for (const name of Object.keys(subagents).toSorted()) { + sortedSubagents[name] = subagents[name]; + } + + const doc: Record = { + version: lockfile.version, + skills: sortedSkills, + }; + if (Object.keys(sortedSubagents).length > 0) { + doc["subagents"] = sortedSubagents; + } - const toml = stringify({ version: lockfile.version, skills: sortedSkills }); + const toml = stringify(doc); await writeFile(filePath, `${(HEADER + toml).trimEnd()}\n`, "utf-8"); } diff --git a/packages/dotagents/src/scope.test.ts b/packages/dotagents/src/scope.test.ts index 94bf561..bd63381 100644 --- a/packages/dotagents/src/scope.test.ts +++ b/packages/dotagents/src/scope.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, afterEach, vi } from "vitest"; -import { join } from "node:path"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir, homedir } from "node:os"; import { resolveScope, isInsideGitRepo, findGitDir, resolveDefaultScope, ScopeError } from "./scope.js"; @@ -72,7 +72,7 @@ describe("isInsideGitRepo", () => { }); it("returns false when no .git in any parent", () => { - tempDir = mkdtempSync(join(tmpdir(), "scope-test-")); + tempDir = mkNonGitTempDir(); // No .git directory created expect(isInsideGitRepo(tempDir)).toBe(false); }); @@ -121,7 +121,7 @@ describe("findGitDir", () => { }); it("returns undefined when no .git exists", () => { - tempDir = mkdtempSync(join(tmpdir(), "scope-test-")); + tempDir = mkNonGitTempDir(); expect(findGitDir(tempDir)).toBeUndefined(); }); }); @@ -143,7 +143,7 @@ describe("resolveDefaultScope", () => { }); it("falls back to user scope when not in a git repo", () => { - tempDir = mkdtempSync(join(tmpdir(), "scope-test-")); + tempDir = mkNonGitTempDir(); process.env["DOTAGENTS_HOME"] = join(tempDir, "user-home"); const spy = vi.spyOn(console, "error").mockImplementation(() => {}); const s = resolveDefaultScope(tempDir); @@ -159,3 +159,33 @@ describe("resolveDefaultScope", () => { expect(() => resolveDefaultScope(tempDir)).toThrow(/dotagents init/); }); }); + +function mkNonGitTempDir(): string { + for (const base of tempBases()) { + const dir = mkdtempSync(join(base, "scope-test-")); + if (!hasGitParent(dir)) { + return dir; + } + rmSync(dir, { recursive: true, force: true }); + } + + throw new Error("Could not create a temporary directory outside a git repository"); +} + +function tempBases(): string[] { + return [tmpdir(), "/var/tmp", "/dev/shm"].filter((base) => existsSync(base)); +} + +function hasGitParent(dir: string): boolean { + let current = resolve(dir); + while (true) { + if (existsSync(join(current, ".git"))) { + return true; + } + const parent = dirname(current); + if (parent === current) { + return false; + } + current = parent; + } +} diff --git a/specs/SPEC.md b/specs/SPEC.md index 692f142..ffcfab2 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -2,21 +2,21 @@ ## Overview -dotagents is shared tooling for coding agents. It manages agent skill dependencies using the [agentskills.io](https://agentskills.io) standard, and handles MCP servers, hooks, and symlinks so that multiple agent tools (Claude Code, Cursor, Codex, etc.) can be configured from a single `agents.toml`. +dotagents is shared tooling for coding agents. It manages agent skill dependencies using the [agentskills.io](https://agentskills.io) standard, and handles MCP servers, hooks, subagents, and symlinks so that multiple agent tools (Claude Code, Cursor, Codex, etc.) can be configured from a single `agents.toml`. -Declare what you need, run `dotagents install`, and skills appear in `.agents/skills/` with symlinks into each tool's expected directory. MCP and hook configs are generated per agent. +Declare what you need, run `dotagents install`, and skills appear in `.agents/skills/` with symlinks into each tool's expected directory. MCP, hook, and subagent configs are generated per agent. > **Implementation note.** The skill-loading, source-fetching, and trust-validation primitives that drive the CLI are factored into a separate npm package, [`@sentry/dotagents-lib`](../packages/dotagents-lib/), versioned in lock-step with `@sentry/dotagents`. The `agents.toml` grammar and the `.agents/` convention described below remain entirely the host's responsibility — the lib only knows about source strings, SKILL.md, and the cache. ### Why -Agent skills, MCP servers, and hooks are configured differently for every agent tool, and skills are distributed as loose folders copied from git repos. There's no way to declare what you need once and have it work everywhere, or to keep a team's agent setup in sync. dotagents fills this gap. +Agent skills, MCP servers, hooks, and subagents are configured differently for every agent tool, and skills are distributed as loose folders copied from git repos. There's no way to declare what you need once and have it work everywhere, or to keep a team's agent setup in sync. dotagents fills this gap. ### Key Principles - **`.agents/skills/` is the canonical home** for all skills (managed and custom) - **`agents.toml`** declares what you want; **`agents.lock`** tracks what's managed -- **Selective gitignore**: managed skills are gitignored, custom skills are tracked +- **Selective gitignore**: managed skills and canonical installed subagents are gitignored, custom skills are tracked - **Subdirectory symlinks**: `.claude/skills/ -> .agents/skills/`, not full directory symlinks - **agentskills.io format**: skills are folders with a `SKILL.md` file containing YAML frontmatter @@ -30,7 +30,7 @@ The manifest file. Lives at the project root. ```toml version = 1 -agents = ["claude", "cursor"] +agents = ["claude", "cursor", "codex", "opencode"] [project] name = "my-project" # Optional. For display purposes. @@ -71,6 +71,11 @@ headers = { Authorization = "Bearer tok" } name = "authed-api" url = "https://${API_HOST}/mcp" headers = { X-Api-Key = "${API_KEY}" } + +[[subagents]] +name = "code-reviewer" +source = "getsentry/agent-pack" +targets = ["claude", "codex", "opencode"] ``` ### Fields @@ -86,6 +91,8 @@ headers = { X-Api-Key = "${API_KEY}" } | `symlinks` | No | Symlink configuration (legacy — prefer `agents` for new projects). | | `skills` | No | Skill dependencies (array of tables). | | `mcp` | No | MCP server declarations (array of tables). Generates agent-specific config files during install/sync. | +| `hooks` | No | Hook declarations (array of tables). Generates agent-specific hook config files during install/sync for agents that support hooks. | +| `subagents` | No | Custom subagent declarations (array of tables). Generates runtime-specific subagent files during install/sync for Claude, Cursor, Codex, and OpenCode. | | `trust` | No | Trusted source restrictions. When absent, all sources allowed. See `[trust]` below. | | `minimum_release_age` | No | Minimum age in **minutes** a commit must have before it's eligible for install. Applies to all git skills (pinned and unpinned). For unpinned skills, resolves to the newest qualifying commit. For pinned skills (`ref`), rejects if the pinned commit is too new. Install fails with an error if no qualifying commit exists. When absent, always uses HEAD. | | `minimum_release_age_exclude` | No | Sources excluded from the age gate. Accepts org names (`"myorg"` matches all repos), org/repo (`"myorg/skills"` exact match), or org wildcards (`"myorg/*"`). Defaults to `[]`. | @@ -186,15 +193,53 @@ Hook declarations. Each entry defines a hook that dotagents will configure for a | `matcher` | No | Tool name to match (e.g. `Bash`). Only for `PreToolUse` and `PostToolUse`. | | `command` | Yes | Shell command to execute when the hook fires. | +#### `[[subagents]]` + +Custom subagent dependencies. Each entry selects one subagent artifact from a source. dotagents imports the artifact into a portable subagent declaration, installs canonical managed Markdown into `.agents/agents/`, and writes generated runtime files for agents listed in `agents` that support custom subagents: Claude, Cursor, Codex, and OpenCode. + +Subagents are best-effort portable dependencies, not a universal behavior schema. dotagents preserves native artifacts for their matching runtime and converts only from the portable `name`, `description`, and instructions when the target runtime is different. + +See [Subagents Specification](subagents.md) for discovery order, native input/output formats, naming rules, merge behavior, and non-goals. + +```md +--- +name: code-reviewer +description: Review code for correctness, security, and missing tests. +--- + +Review the current diff and return findings with file references. +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Subagent name to discover. Must start with lowercase `a-z` and contain only lowercase letters, numbers, and hyphens. | +| `source` | Yes | Source repository or local directory. Supports GitHub/GitLab shorthands, git URLs, and `path:` sources; HTTPS well-known skill indexes are not supported for subagents. | +| `ref` | No | Optional git ref override. | +| `path` | No | Optional explicit subagent file path inside the source. Markdown paths are portable or native Markdown; `.toml` paths are treated as Codex native artifacts. | +| `targets` | No | Optional subset of agent IDs. Defaults to every configured agent in `agents`; unsupported agents produce warnings. | + +dotagents intentionally does not standardize runtime-specific subagent behavior such as model routing, tool permissions, read-only modes, background execution, or reasoning effort. Those controls differ across runtimes and should stay in each tool's native config until there is a maintainable common contract. + +Installed and generated files are marked as dotagents-managed. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the marker. + +Generated paths: + +| Agent | Project Scope | User Scope | Format | +|-------|---------------|------------|--------| +| Claude Code | `.claude/agents/.md` | `~/.claude/agents/.md` | Markdown with YAML frontmatter | +| Cursor | `.cursor/agents/.md` | `~/.cursor/agents/.md` | Markdown with YAML frontmatter | +| Codex | `.codex/agents/.toml` | `~/.codex/agents/.toml` | TOML | +| OpenCode | `.opencode/agents/.md` | `~/.config/opencode/agents/.md` | Markdown with YAML frontmatter | + #### Supported Agents -| ID | Tool | Config Dir | MCP File | MCP Format | -|----|------|-----------|----------|------------| -| `claude` | Claude Code | `.claude` | `.mcp.json` | JSON | -| `cursor` | Cursor | `.cursor` | `.cursor/mcp.json` | JSON | -| `codex` | Codex | `.codex` | `.codex/config.toml` | TOML (shared) | -| `vscode` | VS Code Copilot | `.vscode` | `.vscode/mcp.json` | JSON | -| `opencode` | OpenCode | `.claude` | `opencode.json` | JSON (shared) | +| ID | Tool | Config Dir | MCP File | MCP Format | Subagents | +|----|------|-----------|----------|------------|-----------| +| `claude` | Claude Code | `.claude` | `.mcp.json` | JSON | `.claude/agents/*.md` | +| `cursor` | Cursor | `.cursor` | `.cursor/mcp.json` | JSON | `.cursor/agents/*.md` | +| `codex` | Codex | `.codex` | `.codex/config.toml` | TOML (shared) | `.codex/agents/*.toml` | +| `vscode` | VS Code Copilot | `.vscode` | `.vscode/mcp.json` | JSON | Not supported | +| `opencode` | OpenCode | `.opencode` | `opencode.json` | JSON (shared) | `.opencode/agents/*.md` | Each agent has its own MCP config format. dotagents translates the universal `[[mcp]]` declarations into the format each tool expects during `install` and `sync`. @@ -384,7 +429,9 @@ dotagents install 5. Warn if `agents.lock` and `.agents/.gitignore` are not in the root `.gitignore` 6. Create/verify symlinks (legacy `[symlinks]` and agent-specific) 7. Write MCP config files for each declared agent -8. Print summary +8. Write hook config files for each declared agent that supports hooks +9. Write generated subagent files for each declared agent that supports custom subagents +10. Print summary ### `dotagents add ` @@ -468,6 +515,7 @@ dotagents sync 6. Create/verify/repair symlinks 7. Verify and repair MCP config files for declared agents 8. Verify and repair hook config files for declared agents +9. Verify and repair generated subagent files for declared agents ### `dotagents mcp` @@ -597,12 +645,12 @@ The YAML frontmatter is parsed with the `yaml` package. `allowed-tools` can be a ## Gitignore Strategy dotagents always manages gitignore. Two files are added to the root `.gitignore` during `init`: -- `agents.lock` — tracks managed skills -- `.agents/.gitignore` — excludes managed skill directories from git +- `agents.lock` — tracks managed skills and subagents +- `.agents/.gitignore` — excludes managed skill directories and canonical installed subagent files from git ### How It Works -Managed (external) skills are gitignored. Custom (local) skills are tracked. dotagents generates `.agents/.gitignore` listing every managed skill: +Managed (external) skills and canonical installed subagent files are gitignored. Custom (local) skills are tracked. dotagents generates `.agents/.gitignore` listing every managed skill and installed subagent: ```gitignore # Auto-generated by dotagents. Do not edit. @@ -638,21 +686,20 @@ Custom skills in `.agents/skills/my-local-skill/` are NOT listed, so git tracks Each agent tool has its own directory with tool-specific files: - `.claude/` -- `settings.json`, `commands/`, `skills/` -- `.cursor/` -- `rules/`, `skills/` -- `.codex/` -- `skills/` +- `.cursor/` -- `rules/`, MCP, hooks, agents +- `.codex/` -- config, agents Symlinking the entire directory (e.g., `.claude/ -> .agents/`) would clobber tool-specific files. ### Solution -Symlink only the `skills/` subdirectory: +Symlink only the `skills/` subdirectory for agents that need one. Cursor uses Claude-compatible skills, so it shares `.claude/skills/`: ``` .claude/skills/ -> .agents/skills/ -.cursor/skills/ -> .agents/skills/ ``` -`.agents/skills/` is the canonical home. Each tool's `skills/` directory is a symlink. +`.agents/skills/` is the canonical home. Legacy `[symlinks]` targets can still create additional `/skills/` symlinks when explicitly configured. ### Configuration @@ -679,7 +726,7 @@ dotagents/ AGENTS.md # Agent instructions CLAUDE.md -> AGENTS.md # Symlink agents.toml # Self-dogfooding - agents.lock # Tracks managed skills (gitignored) + agents.lock # Tracks managed skills and subagents (gitignored) warden.toml # Warden config for code analysis package.json # pnpm workspace root pnpm-workspace.yaml diff --git a/specs/subagents.md b/specs/subagents.md new file mode 100644 index 0000000..8b93565 --- /dev/null +++ b/specs/subagents.md @@ -0,0 +1,119 @@ +# Subagents Specification + +Subagents are best-effort portable dependencies. dotagents should let teams declare a subagent once, preserve native runtime artifacts when they already exist, and generate reasonable files for other supported runtimes from a small portable projection. + +This is not a universal behavior schema. Runtime-specific behavior such as model routing, tool permissions, read-only modes, background execution, and reasoning effort belongs in the runtime's native artifact. + +## Configuration + +Subagents are declared in `agents.toml`: + +```toml +[[subagents]] +name = "code-reviewer" +source = "getsentry/agent-pack" +targets = ["claude", "codex"] +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Portable dotagents ID. Must start with lowercase `a-z` and contain only lowercase letters, numbers, and hyphens. | +| `source` | Yes | Source repository or local directory. Supports GitHub/GitLab shorthands, git URLs, and `path:` sources. HTTPS well-known skill indexes are not supported for subagents. | +| `ref` | No | Optional git ref override. | +| `path` | No | Optional explicit subagent file path inside the source. Markdown paths are portable or native Markdown; `.toml` paths are Codex native artifacts. | +| `targets` | No | Optional subset of agent IDs. Defaults to every configured agent in `agents`; unsupported agents produce warnings. | + +## Portable Projection + +Every imported subagent must produce this portable shape: + +| Field | Meaning | +|-------|---------| +| `name` | dotagents portable ID from `agents.toml`, filename, or portable source metadata | +| `description` | short description used by runtimes that support it | +| `instructions` | prompt/instructions body used when converting to another runtime | +| `native` | optional raw native source content keyed by runtime | + +Only `name`, `description`, and `instructions` are portable. Native-only fields are preserved for the matching runtime but are not standardized or converted. + +## Source and Output Formats + +Input and matching-runtime output use the same format. dotagents adds only its managed-file marker when writing a native artifact back to its own runtime. + +| Format | Source Discovery | Matching Runtime Output | Required Source Fields | Portable Projection | +|--------|------------------|-------------------------|------------------------|---------------------| +| Portable Markdown | `agents/*.md`, `.agents/agents/*.md` | `.agents/agents/.md` canonical install file | YAML `name`, `description`; Markdown body | `name`, `description`, body instructions | +| Claude Markdown | `.claude/agents/*.md` | `.claude/agents/.md` | YAML `name`, `description`; Markdown body | `name`, `description`, body instructions | +| Cursor Markdown | `.cursor/agents/*.md` | `.cursor/agents/.md` | YAML `name`, `description`; Markdown body | `name`, `description`, body instructions | +| Codex TOML | `.codex/agents/*.toml` | `.codex/agents/.toml` | TOML `name`, `description`, `developer_instructions` | dotagents ID from `agents.toml` or filename when native `name` is not portable; `description`; `developer_instructions` | +| OpenCode Markdown | `.opencode/agents/*.md` | `.opencode/agents/.md` | YAML `description`; Markdown body; `name` optional | filename or YAML `name`; `description`; body instructions | + +Portable Markdown example: + +```md +--- +name: code-reviewer +description: Review code for correctness, security, and missing tests. +--- + +Review the current diff and return findings with file references. +``` + +Codex TOML example: + +```toml +name = "code_reviewer" +description = "Review code for correctness, security, and missing tests." +developer_instructions = "Review the current diff and return findings with file references." +sandbox_mode = "read-only" +``` + +When installing the Codex example to Codex, dotagents preserves `name = "code_reviewer"` and `sandbox_mode = "read-only"`. When installing it to Claude or Cursor, dotagents generates Markdown from the portable projection and does not attempt to convert `sandbox_mode`. + +## Discovery + +Without an explicit `path`, dotagents scans source directories in this order: + +1. `*.md` at the source root, flat only +2. `agents/**/*.md` +3. `.agents/agents/**/*.md` +4. `.claude/agents/**/*.md` +5. `.cursor/agents/**/*.md` +6. `.codex/agents/**/*.toml` +7. `.opencode/agents/**/*.md` + +Within each scanned directory, filename matches for `agents.toml` `name` take precedence over metadata-only matches. Matching artifacts from multiple runtimes are merged into one subagent declaration so native Claude and native Codex sources for the same portable ID can both be preserved. + +If `path` is set, dotagents imports only that file. A `.toml` path is treated as Codex native. Markdown under `.claude/agents/`, `.cursor/agents/`, or `.opencode/agents/` is treated as native for that runtime; other Markdown is treated as portable. + +## Naming + +The `agents.toml` `name` is the portable dotagents ID and controls generated filenames. + +Runtime native names may differ when the runtime allows a different naming convention. Codex commonly uses underscores, so `name = "code_reviewer"` in native TOML can map to portable dotagents ID `code-reviewer` when the file is selected by filename or explicit config. The Codex TOML name is preserved when writing Codex output. + +OpenCode native Markdown may omit `name`; dotagents uses the filename as the portable name in that case. + +## Install and Sync + +Install writes two layers: + +1. Canonical installed Markdown in `.agents/agents/.md` +2. Runtime output for each configured target that supports subagents + +The canonical installed Markdown stores the portable projection plus raw native overlays so `sync` can regenerate matching native outputs without network access. + +Generated runtime paths: + +| Agent | Project Scope | User Scope | Format | +|-------|---------------|------------|--------| +| Claude Code | `.claude/agents/.md` | `~/.claude/agents/.md` | Markdown with YAML frontmatter | +| Cursor | `.cursor/agents/.md` | `~/.cursor/agents/.md` | Markdown with YAML frontmatter | +| Codex | `.codex/agents/.toml` | `~/.codex/agents/.toml` | TOML | +| OpenCode | `.opencode/agents/.md` | `~/.config/opencode/agents/.md` | Markdown with YAML frontmatter | + +Installed and generated files are marked as dotagents-managed. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the marker. + +## Non-goals + +dotagents does not standardize or enforce runtime-specific agent behavior. In particular, dotagents should not add portable config for model selection, permissions, sandboxing, background execution, reasoning effort, hooks, or other runtime-specific controls unless there is a maintainable common contract across runtimes. From 16c34815c11195bfdccb0377fcf9137d79a7c73a Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 30 May 2026 17:38:15 -0700 Subject: [PATCH 02/33] fix(subagents): Respect native runtime identities Preserve runtime-specific identity rules when importing and writing subagents. OpenCode now uses the Markdown filename as the portable identity, Cursor can fall back to the filename when name is omitted, and duplicate discovery matches fail instead of depending on filesystem order. Avoid writing managed runtime files when an unmanaged file already declares the same runtime identity under another filename. This prevents Claude, Cursor, and Codex from seeing duplicate custom agents that dotagents cannot safely overwrite. Co-Authored-By: Codex --- README.md | 2 +- docs/public/llms.txt | 8 +- docs/src/content/docs/cli.mdx | 8 +- .../src/agents/subagent-store.test.ts | 40 ++++++ .../dotagents/src/agents/subagent-store.ts | 59 +++++++-- .../src/agents/subagent-writer.test.ts | 81 ++++++++++++ .../dotagents/src/agents/subagent-writer.ts | 124 +++++++++++++++++- specs/subagents.md | 10 +- 8 files changed, 303 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 3f339f4..d030c4b 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ description: Review code for correctness, security, and missing tests. Review the current diff and return findings with file references. ``` -dotagents can also import native runtime subagent files from `.claude/agents/`, `.cursor/agents/`, `.codex/agents/*.toml`, and `.opencode/agents/`. Input and matching-runtime output use the same native format: Markdown with YAML frontmatter for Claude, Cursor, and OpenCode; TOML for Codex. When the source format matches a target runtime, dotagents reuses the native source content for that runtime and only adds its managed-file marker. Other runtimes are generated from the portable `name`, `description`, and instructions. Subagent declarations intentionally cover only dependency source and runtime targets, not universal model/tool/permission behavior. +dotagents can also import native runtime subagent files from `.claude/agents/`, `.cursor/agents/`, `.codex/agents/*.toml`, and `.opencode/agents/`. Input and matching-runtime output use the same native format: Markdown with YAML frontmatter for Claude, Cursor, and OpenCode; TOML for Codex. Claude and Codex identify agents by `name`, Cursor can derive `name` from the filename when omitted, and OpenCode uses the filename as the agent name. When the source format matches a target runtime, dotagents reuses the native source content for that runtime and only adds its managed-file marker. Other runtimes are generated from the portable `name`, `description`, and instructions. Subagent declarations intentionally cover only dependency source and runtime targets, not universal model/tool/permission behavior. [Pi](https://github.com/badlogic/pi-mono) reads `.agents/skills/` natively and needs no configuration. diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 08168b4..dd52673 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -235,11 +235,11 @@ Accepted source/output formats: |--------|-------------|----------------------|------------------------| | Portable Markdown | `agents/*.md`, `.agents/agents/*.md` | `.agents/agents/.md` | YAML `name`, `description`; Markdown body | | Claude Markdown | `.claude/agents/*.md` | `.claude/agents/.md` | YAML `name`, `description`; Markdown body | -| Cursor Markdown | `.cursor/agents/*.md` | `.cursor/agents/.md` | YAML `name`, `description`; Markdown body | +| Cursor Markdown | `.cursor/agents/*.md` | `.cursor/agents/.md` | YAML `description`; Markdown body; `name` optional | | Codex TOML | `.codex/agents/*.toml` | `.codex/agents/.toml` | TOML `name`, `description`, `developer_instructions` | -| OpenCode Markdown | `.opencode/agents/*.md` | `.opencode/agents/.md` | YAML `description`; Markdown body; `name` optional | +| OpenCode Markdown | `.opencode/agents/*.md` | `.opencode/agents/.md` | YAML `description`; Markdown body | -Codex native `name` may use Codex-specific naming; dotagents uses the `agents.toml` name or filename as the portable ID when needed. OpenCode native Markdown may use the filename as the subagent name. +Codex native `name` may use Codex-specific naming; dotagents uses the `agents.toml` name or filename as the portable ID when needed. Cursor native Markdown may omit `name`; dotagents uses the filename in that case. OpenCode native Markdown always uses the filename as the subagent name. ```md --- @@ -264,7 +264,7 @@ Generated subagent files: - Codex: `.codex/agents/.toml`, or `~/.codex/agents/.toml` for user scope - OpenCode: `.opencode/agents/.md`, or `~/.config/opencode/agents/.md` for user scope -Generated files include a dotagents marker. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the marker. +Generated files include a dotagents marker. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the marker. They also avoid creating duplicate runtime identities when an unmanaged file in the same agent directory already declares the same subagent. ### Trust diff --git a/docs/src/content/docs/cli.mdx b/docs/src/content/docs/cli.mdx index a5ff36f..da4088f 100644 --- a/docs/src/content/docs/cli.mdx +++ b/docs/src/content/docs/cli.mdx @@ -389,11 +389,11 @@ dotagents discovers portable subagent Markdown from `agents/` and `.agents/agent | --- | --- | --- | --- | | Portable Markdown | `agents/*.md`, `.agents/agents/*.md` | `.agents/agents/.md` | YAML `name`, `description`; Markdown body | | Claude Markdown | `.claude/agents/*.md` | `.claude/agents/.md` | YAML `name`, `description`; Markdown body | -| Cursor Markdown | `.cursor/agents/*.md` | `.cursor/agents/.md` | YAML `name`, `description`; Markdown body | +| Cursor Markdown | `.cursor/agents/*.md` | `.cursor/agents/.md` | YAML `description`; Markdown body; `name` optional | | Codex TOML | `.codex/agents/*.toml` | `.codex/agents/.toml` | TOML `name`, `description`, `developer_instructions` | -| OpenCode Markdown | `.opencode/agents/*.md` | `.opencode/agents/.md` | YAML `description`; Markdown body; `name` optional | +| OpenCode Markdown | `.opencode/agents/*.md` | `.opencode/agents/.md` | YAML `description`; Markdown body | -Codex native `name` may use Codex-specific naming; dotagents uses the `agents.toml` name or filename as the portable ID when needed. OpenCode native Markdown may use the filename as the subagent name. +Codex native `name` may use Codex-specific naming; dotagents uses the `agents.toml` name or filename as the portable ID when needed. Cursor native Markdown may omit `name`; dotagents uses the filename in that case. OpenCode native Markdown always uses the filename as the subagent name. Generated files: @@ -402,7 +402,7 @@ Generated files: - Codex: `.codex/agents/.toml` - OpenCode: `.opencode/agents/.md` -Generated files include a dotagents marker. `install` and `sync` update managed files and do not overwrite hand-written files without the marker. +Generated files include a dotagents marker. `install` and `sync` update managed files and do not overwrite hand-written files without the marker. They also avoid creating duplicate runtime identities when an unmanaged file in the same agent directory already declares the same subagent. ## Scopes diff --git a/packages/dotagents/src/agents/subagent-store.test.ts b/packages/dotagents/src/agents/subagent-store.test.ts index 34c3a70..befd8ee 100644 --- a/packages/dotagents/src/agents/subagent-store.test.ts +++ b/packages/dotagents/src/agents/subagent-store.test.ts @@ -99,6 +99,7 @@ describe("resolveSubagent", () => { await writeFile( join(repoDir, ".opencode", "agents", "code-reviewer.md"), `--- +name: other-reviewer description: Review code for correctness. mode: subagent permission: @@ -117,6 +118,29 @@ Review the current diff. expect(resolved.subagent.name).toBe("code-reviewer"); expect(resolved.subagent.native?.opencode).toContain("mode: subagent"); expect(resolved.subagent.native?.opencode).toContain("permission:"); + expect(resolved.subagent.native?.opencode).toContain("name: other-reviewer"); + }); + + it("imports Cursor markdown subagents using the filename when name is omitted", async () => { + await mkdir(join(repoDir, ".cursor", "agents"), { recursive: true }); + await writeFile( + join(repoDir, ".cursor", "agents", "code-reviewer.md"), + `--- +description: Review code for correctness. +model: inherit +--- + +Review the current diff. +`, + ); + + const resolved = await resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }); + + expect(resolved.subagent.name).toBe("code-reviewer"); + expect(resolved.subagent.native?.cursor).toContain("model: inherit"); }); it("merges native subagent artifacts for the same portable declaration", async () => { @@ -194,6 +218,22 @@ Review the current diff for Claude. ).rejects.toThrow("No YAML frontmatter"); }); + it("rejects ambiguous metadata matches in the same scan directory", async () => { + await mkdir(join(repoDir, "agents", "a"), { recursive: true }); + await mkdir(join(repoDir, "agents", "b"), { recursive: true }); + await writeFile(join(repoDir, "agents", "a", "reviewer.md"), SUBAGENT_MD("code-reviewer")); + await writeFile(join(repoDir, "agents", "b", "reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await expect( + resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }), + ).rejects.toThrow( + 'Ambiguous metadata matches for subagent "code-reviewer" in agents', + ); + }); + it("rejects invalid frontmatter names", async () => { await mkdir(join(repoDir, "agents"), { recursive: true }); await writeFile(join(repoDir, "agents", "code-reviewer.md"), SUBAGENT_MD("CodeReviewer")); diff --git a/packages/dotagents/src/agents/subagent-store.ts b/packages/dotagents/src/agents/subagent-store.ts index e260d22..c9200fc 100644 --- a/packages/dotagents/src/agents/subagent-store.ts +++ b/packages/dotagents/src/agents/subagent-store.ts @@ -246,8 +246,8 @@ async function discoverSubagent( flat: scanDir.flat ?? false, extensions: scanDir.extensions, }); - let fileNameMatch: DiscoveredSubagent | null = null; - let frontmatterMatch: DiscoveredSubagent | null = null; + const fileNameMatches: DiscoveredSubagent[] = []; + const metadataMatches: DiscoveredSubagent[] = []; for (const filePath of candidates) { const nameFromFile = basename(filePath, extname(filePath)); @@ -268,15 +268,16 @@ async function discoverSubagent( assertSubagentNameMatches(subagent.name, config.name, relPath); } if (subagent.name !== config.name) {continue;} - if (!fileNameMatch && nameFromFile === config.name) { - fileNameMatch = { path: relPath, subagent }; - } else if (!frontmatterMatch) { - frontmatterMatch = { path: relPath, subagent }; + const match = { path: relPath, subagent }; + if (nameFromFile === config.name) { + fileNameMatches.push(match); + } else { + metadataMatches.push(match); } } - if (fileNameMatch) {matches.push(fileNameMatch);} - if (frontmatterMatch) {matches.push(frontmatterMatch);} + const selected = selectDiscoveredSubagent(config.name, scanDir, fileNameMatches, metadataMatches); + if (selected) {matches.push(selected);} } if (matches.length === 0) {return null;} @@ -296,11 +297,10 @@ async function loadSubagentFile( } const { meta, body, raw } = await loadMarkdownFrontmatter(filePath); - const name = typeof meta["name"] === "string" && meta["name"] - ? meta["name"] - : opts.nativeTarget === "opencode" - ? opts.nameFromFile - : undefined; + const declaredName = typeof meta["name"] === "string" && meta["name"] ? meta["name"] : undefined; + const name = markdownIdentityFromFilename(opts.nativeTarget) + ? opts.nameFromFile + : declaredName ?? (opts.nativeTarget === "cursor" ? opts.nameFromFile : undefined); if (!name) { throw new Error(`Missing 'name' in subagent frontmatter: ${filePath}`); } @@ -433,7 +433,7 @@ async function listSubagentFiles( } const files: string[] = []; - for (const entry of entries) { + for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) { const absPath = join(dirPath, entry.name); if (entry.isFile() && opts.extensions.includes(extname(entry.name))) { files.push(absPath); @@ -444,6 +444,37 @@ async function listSubagentFiles( return files; } +function selectDiscoveredSubagent( + name: string, + scanDir: SubagentScanDir, + fileNameMatches: DiscoveredSubagent[], + metadataMatches: DiscoveredSubagent[], +): DiscoveredSubagent | null { + assertSingleDiscoveryMatch(name, scanDir, "filename", fileNameMatches); + if (fileNameMatches.length > 0) { + return fileNameMatches[0]!; + } + + assertSingleDiscoveryMatch(name, scanDir, "metadata", metadataMatches); + return metadataMatches[0] ?? null; +} + +function assertSingleDiscoveryMatch( + name: string, + scanDir: SubagentScanDir, + kind: string, + matches: DiscoveredSubagent[], +): void { + if (matches.length <= 1) {return;} + throw new Error( + `Ambiguous ${kind} matches for subagent "${name}" in ${scanDir.dir}: ${matches.map((m) => m.path).join(", ")}`, + ); +} + +function markdownIdentityFromFilename(target: NativeSubagentTarget | undefined): boolean { + return target === "opencode"; +} + function serializeInstalledSubagent(subagent: SubagentDeclaration): string { return serializeMarkdownSubagent( { diff --git a/packages/dotagents/src/agents/subagent-writer.test.ts b/packages/dotagents/src/agents/subagent-writer.test.ts index c1f1f36..63d182c 100644 --- a/packages/dotagents/src/agents/subagent-writer.test.ts +++ b/packages/dotagents/src/agents/subagent-writer.test.ts @@ -174,6 +174,65 @@ describe("writeSubagentConfigs", () => { expect(await readFile(join(targetDir, "code-reviewer.md"), "utf-8")).toBe("hand-written"); }); + it("does not create duplicate unmanaged Claude identities", async () => { + const targetDir = join(dir, ".claude", "agents"); + await mkdir(targetDir, { recursive: true }); + await writeFile( + join(targetDir, "reviewer.md"), + `--- +name: code-reviewer +description: Hand-written reviewer. +--- + +Hand-written instructions. +`, + "utf-8", + ); + + const result = await writeSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]!.message).toContain("identity conflicts with unmanaged file"); + expect(result.written).toBe(0); + expect(existsSync(join(targetDir, "code-reviewer.md"))).toBe(false); + }); + + it("does not create duplicate unmanaged Codex identities", async () => { + const targetDir = join(dir, ".codex", "agents"); + await mkdir(targetDir, { recursive: true }); + await writeFile( + join(targetDir, "reviewer.toml"), + [ + 'name = "code_reviewer"', + 'description = "Hand-written reviewer."', + 'developer_instructions = "Hand-written instructions."', + "", + ].join("\n"), + "utf-8", + ); + + const result = await writeSubagentConfigs( + ["codex"], + [{ + ...SUBAGENT, + native: { + codex: [ + 'name = "code_reviewer"', + 'description = "Review code for correctness and missing tests."', + 'developer_instructions = "Native Codex instructions."', + "", + ].join("\n"), + }, + }], + projectSubagentResolver(dir), + ); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]!.message).toContain("identity conflicts with unmanaged file"); + expect(result.written).toBe(0); + expect(existsSync(join(targetDir, "code-reviewer.toml"))).toBe(false); + }); + it("prunes stale dotagents-managed files", async () => { const targetDir = join(dir, ".claude", "agents"); await mkdir(targetDir, { recursive: true }); @@ -289,4 +348,26 @@ describe("verifySubagentConfigs", () => { expect(issues[0]!.issue).toContain("not managed by dotagents"); expect(issues[0]!.repairable).toBe(false); }); + + it("reports unmanaged identity conflicts as not repairable", async () => { + const targetDir = join(dir, ".claude", "agents"); + await mkdir(targetDir, { recursive: true }); + await writeFile( + join(targetDir, "reviewer.md"), + `--- +name: code-reviewer +description: Hand-written reviewer. +--- + +Hand-written instructions. +`, + "utf-8", + ); + + const issues = await verifySubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(issues).toHaveLength(1); + expect(issues[0]!.issue).toContain("identity conflicts with unmanaged file"); + expect(issues[0]!.repairable).toBe(false); + }); }); diff --git a/packages/dotagents/src/agents/subagent-writer.ts b/packages/dotagents/src/agents/subagent-writer.ts index b7cad48..377f9f1 100644 --- a/packages/dotagents/src/agents/subagent-writer.ts +++ b/packages/dotagents/src/agents/subagent-writer.ts @@ -1,6 +1,8 @@ import { existsSync } from "node:fs"; import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { basename, extname, join } from "node:path"; +import { parse as parseTOML } from "smol-toml"; +import { loadMarkdownFrontmatter } from "@sentry/dotagents-lib"; import { allAgents, getAgent } from "./registry.js"; import { DOTAGENTS_SUBAGENT_MARKER } from "./definitions/helpers.js"; import type { SubagentConfigSpec, SubagentDeclaration } from "./types.js"; @@ -95,9 +97,30 @@ export async function writeSubagentConfigs( if (!content.includes(DOTAGENTS_SUBAGENT_MARKER)) { throw new Error(`Internal error: generated subagent "${subagent.name}" is missing the dotagents marker`); } + const generatedIdentity = generatedSubagentIdentity( + agentId, + generated.fileName, + content, + subagent.name, + ); await mkdir(dirPath, { recursive: true }); markDesired(desiredByDir, dirPath, agent.subagents.fileExtension, generated.fileName); + const identityConflict = await findUnmanagedIdentityConflict( + agentId, + dirPath, + generated.fileName, + agent.subagents.fileExtension, + generatedIdentity, + ); + if (identityConflict) { + warnings.push({ + agent: agentId, + name: subagent.name, + message: `Subagent config identity conflicts with unmanaged file: ${identityConflict}`, + }); + continue; + } const didWrite = await writeManagedFile(join(dirPath, generated.fileName), content, { agent: agentId, name: subagent.name, @@ -151,6 +174,23 @@ export async function verifySubagentConfigs( const filePath = join(dirPath, generated.fileName); if (seen.has(filePath)) {continue;} seen.add(filePath); + const content = normalizeContent(generated.content); + + const identityConflict = await findUnmanagedIdentityConflict( + agentId, + dirPath, + generated.fileName, + agent.subagents.fileExtension, + generatedSubagentIdentity(agentId, generated.fileName, content, subagent.name), + ); + if (identityConflict) { + issues.push({ + ...issueBase, + issue: `Subagent config identity conflicts with unmanaged file: ${identityConflict}`, + repairable: false, + }); + continue; + } if (!existsSync(filePath)) { issues.push({ @@ -293,10 +333,92 @@ async function pruneManagedFiles( return pruned; } +async function findUnmanagedIdentityConflict( + agentId: string, + dirPath: string, + generatedFileName: string, + extension: string, + generatedIdentity: string | null, +): Promise { + if (!existsSync(dirPath)) {return null;} + if (!generatedIdentity) {return null;} + + const entries = await readdir(dirPath, { withFileTypes: true }); + for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) { + if (!entry.isFile()) {continue;} + if (entry.name === generatedFileName) {continue;} + if (!entry.name.endsWith(extension)) {continue;} + + const filePath = join(dirPath, entry.name); + const existing = await readFile(filePath, "utf-8"); + if (existing.includes(DOTAGENTS_SUBAGENT_MARKER)) {continue;} + + const existingIdentity = await readExistingSubagentIdentity(agentId, filePath, entry.name, existing); + if (existingIdentity === generatedIdentity) { + return filePath; + } + } + + return null; +} + +function generatedSubagentIdentity( + agentId: string, + fileName: string, + content: string, + portableName: string, +): string | null { + if (agentId === "opencode") { + return basename(fileName, extname(fileName)); + } + if (agentId === "codex") { + return readCodexSubagentIdentity(content); + } + return portableName; +} + +async function readExistingSubagentIdentity( + agentId: string, + filePath: string, + fileName: string, + content: string, +): Promise { + if (agentId === "opencode") { + return basename(fileName, extname(fileName)); + } + if (agentId === "codex") { + return readCodexSubagentIdentity(content); + } + + try { + const { meta } = await loadMarkdownFrontmatter(filePath); + const name = typeof meta["name"] === "string" && meta["name"] ? meta["name"] : null; + return name ?? (agentId === "cursor" ? basename(fileName, extname(fileName)) : null); + } catch { + return null; + } +} + +function readCodexSubagentIdentity(content: string): string | null { + try { + const parsed = parseTOML(content); + if (isPlainObject(parsed) && typeof parsed["name"] === "string" && parsed["name"]) { + return parsed["name"]; + } + } catch { + return null; + } + return null; +} + function normalizeContent(content: string): string { return content.endsWith("\n") ? content : `${content}\n`; } +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function isNotFoundError(err: unknown): boolean { return err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "ENOENT"; } diff --git a/specs/subagents.md b/specs/subagents.md index 8b93565..6e00fd4 100644 --- a/specs/subagents.md +++ b/specs/subagents.md @@ -44,9 +44,9 @@ Input and matching-runtime output use the same format. dotagents adds only its m |--------|------------------|-------------------------|------------------------|---------------------| | Portable Markdown | `agents/*.md`, `.agents/agents/*.md` | `.agents/agents/.md` canonical install file | YAML `name`, `description`; Markdown body | `name`, `description`, body instructions | | Claude Markdown | `.claude/agents/*.md` | `.claude/agents/.md` | YAML `name`, `description`; Markdown body | `name`, `description`, body instructions | -| Cursor Markdown | `.cursor/agents/*.md` | `.cursor/agents/.md` | YAML `name`, `description`; Markdown body | `name`, `description`, body instructions | +| Cursor Markdown | `.cursor/agents/*.md` | `.cursor/agents/.md` | YAML `description`; Markdown body; `name` optional | YAML `name` or filename; `description`; body instructions | | Codex TOML | `.codex/agents/*.toml` | `.codex/agents/.toml` | TOML `name`, `description`, `developer_instructions` | dotagents ID from `agents.toml` or filename when native `name` is not portable; `description`; `developer_instructions` | -| OpenCode Markdown | `.opencode/agents/*.md` | `.opencode/agents/.md` | YAML `description`; Markdown body; `name` optional | filename or YAML `name`; `description`; body instructions | +| OpenCode Markdown | `.opencode/agents/*.md` | `.opencode/agents/.md` | YAML `description`; Markdown body | filename; `description`; body instructions | Portable Markdown example: @@ -82,7 +82,7 @@ Without an explicit `path`, dotagents scans source directories in this order: 6. `.codex/agents/**/*.toml` 7. `.opencode/agents/**/*.md` -Within each scanned directory, filename matches for `agents.toml` `name` take precedence over metadata-only matches. Matching artifacts from multiple runtimes are merged into one subagent declaration so native Claude and native Codex sources for the same portable ID can both be preserved. +Within each scanned directory, filename matches for `agents.toml` `name` take precedence over metadata-only matches. Multiple filename or metadata matches in one scanned directory are rejected as ambiguous. Matching artifacts from multiple runtimes are merged into one subagent declaration so native Claude and native Codex sources for the same portable ID can both be preserved. If `path` is set, dotagents imports only that file. A `.toml` path is treated as Codex native. Markdown under `.claude/agents/`, `.cursor/agents/`, or `.opencode/agents/` is treated as native for that runtime; other Markdown is treated as portable. @@ -92,7 +92,7 @@ The `agents.toml` `name` is the portable dotagents ID and controls generated fil Runtime native names may differ when the runtime allows a different naming convention. Codex commonly uses underscores, so `name = "code_reviewer"` in native TOML can map to portable dotagents ID `code-reviewer` when the file is selected by filename or explicit config. The Codex TOML name is preserved when writing Codex output. -OpenCode native Markdown may omit `name`; dotagents uses the filename as the portable name in that case. +Cursor native Markdown may omit `name`; dotagents uses the filename only when `name` is absent. OpenCode native Markdown always uses the filename as the portable name because OpenCode treats the Markdown filename as the agent name. ## Install and Sync @@ -112,7 +112,7 @@ Generated runtime paths: | Codex | `.codex/agents/.toml` | `~/.codex/agents/.toml` | TOML | | OpenCode | `.opencode/agents/.md` | `~/.config/opencode/agents/.md` | Markdown with YAML frontmatter | -Installed and generated files are marked as dotagents-managed. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the marker. +Installed and generated files are marked as dotagents-managed. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the marker. For runtimes whose agent identity can differ from the filename, dotagents also avoids writing a managed file when an unmanaged file in the same runtime directory already declares the same runtime identity. ## Non-goals From 44c8ffc1785460fd60d4c25719c2e2bf278b44ef Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 30 May 2026 17:59:41 -0700 Subject: [PATCH 03/33] fix(subagents): Prune stale identity conflicts When an unmanaged runtime file already owns a subagent identity, stop treating the generated filename as desired for that run. This lets sync prune stale dotagents-managed output instead of leaving duplicate runtime agents active. Use locale-independent path ordering for subagent discovery and identity conflict scans so generated output stays stable across environments. Co-Authored-By: Codex --- .../dotagents/src/agents/subagent-store.ts | 10 ++--- .../src/agents/subagent-writer.test.ts | 38 ++++++++++++++++++ .../dotagents/src/agents/subagent-writer.ts | 5 ++- .../dotagents/src/cli/commands/sync.test.ts | 40 +++++++++++++++++++ 4 files changed, 86 insertions(+), 7 deletions(-) diff --git a/packages/dotagents/src/agents/subagent-store.ts b/packages/dotagents/src/agents/subagent-store.ts index c9200fc..84798a4 100644 --- a/packages/dotagents/src/agents/subagent-store.ts +++ b/packages/dotagents/src/agents/subagent-store.ts @@ -298,7 +298,7 @@ async function loadSubagentFile( const { meta, body, raw } = await loadMarkdownFrontmatter(filePath); const declaredName = typeof meta["name"] === "string" && meta["name"] ? meta["name"] : undefined; - const name = markdownIdentityFromFilename(opts.nativeTarget) + const name = opts.nativeTarget === "opencode" ? opts.nameFromFile : declaredName ?? (opts.nativeTarget === "cursor" ? opts.nameFromFile : undefined); if (!name) { @@ -433,7 +433,9 @@ async function listSubagentFiles( } const files: string[] = []; - for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) { + for (const entry of entries.toSorted((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0 + )) { const absPath = join(dirPath, entry.name); if (entry.isFile() && opts.extensions.includes(extname(entry.name))) { files.push(absPath); @@ -471,10 +473,6 @@ function assertSingleDiscoveryMatch( ); } -function markdownIdentityFromFilename(target: NativeSubagentTarget | undefined): boolean { - return target === "opencode"; -} - function serializeInstalledSubagent(subagent: SubagentDeclaration): string { return serializeMarkdownSubagent( { diff --git a/packages/dotagents/src/agents/subagent-writer.test.ts b/packages/dotagents/src/agents/subagent-writer.test.ts index 63d182c..02b2432 100644 --- a/packages/dotagents/src/agents/subagent-writer.test.ts +++ b/packages/dotagents/src/agents/subagent-writer.test.ts @@ -197,6 +197,44 @@ Hand-written instructions. expect(existsSync(join(targetDir, "code-reviewer.md"))).toBe(false); }); + it("prunes stale managed files when an unmanaged identity conflict exists", async () => { + const targetDir = join(dir, ".claude", "agents"); + await mkdir(targetDir, { recursive: true }); + const managedPath = join(targetDir, "code-reviewer.md"); + const unmanagedPath = join(targetDir, "reviewer.md"); + await writeFile( + managedPath, + `--- +# ${DOTAGENTS_SUBAGENT_MARKER} +name: "code-reviewer" +description: "Managed reviewer." +--- + +Managed instructions. +`, + "utf-8", + ); + await writeFile( + unmanagedPath, + `--- +name: code-reviewer +description: Hand-written reviewer. +--- + +Hand-written instructions. +`, + "utf-8", + ); + + const result = await writeSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]!.message).toContain("identity conflicts with unmanaged file"); + expect(result.pruned).toEqual([managedPath]); + expect(existsSync(managedPath)).toBe(false); + expect(existsSync(unmanagedPath)).toBe(true); + }); + it("does not create duplicate unmanaged Codex identities", async () => { const targetDir = join(dir, ".codex", "agents"); await mkdir(targetDir, { recursive: true }); diff --git a/packages/dotagents/src/agents/subagent-writer.ts b/packages/dotagents/src/agents/subagent-writer.ts index 377f9f1..55a3265 100644 --- a/packages/dotagents/src/agents/subagent-writer.ts +++ b/packages/dotagents/src/agents/subagent-writer.ts @@ -119,6 +119,7 @@ export async function writeSubagentConfigs( name: subagent.name, message: `Subagent config identity conflicts with unmanaged file: ${identityConflict}`, }); + desiredByDir.get(dirPath)?.files.delete(generated.fileName); continue; } const didWrite = await writeManagedFile(join(dirPath, generated.fileName), content, { @@ -344,7 +345,9 @@ async function findUnmanagedIdentityConflict( if (!generatedIdentity) {return null;} const entries = await readdir(dirPath, { withFileTypes: true }); - for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) { + for (const entry of entries.toSorted((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0 + )) { if (!entry.isFile()) {continue;} if (entry.name === generatedFileName) {continue;} if (!entry.name.endsWith(extension)) {continue;} diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index 443feaf..f4200c6 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -440,6 +440,46 @@ source = "path:agents" expect(await readFile(join(agentsDir, "reviewer.md"), "utf-8")).toBe("hand-written"); }); + it("prunes managed subagent configs when an unmanaged identity conflict exists", async () => { + const installedDir = join(projectRoot, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + await writeFile( + join(installedDir, "reviewer.md"), + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "reviewer"\ndescription: "Review code."\n---\n\nReview code.\n`, + "utf-8", + ); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] + +[[subagents]] +name = "reviewer" +source = "path:agents" +`, + ); + const agentsDir = join(projectRoot, ".claude", "agents"); + await mkdir(agentsDir, { recursive: true }); + await writeFile( + join(agentsDir, "reviewer.md"), + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "reviewer"\ndescription: "Managed reviewer."\n---\n\nManaged instructions.\n`, + "utf-8", + ); + await writeFile( + join(agentsDir, "alias.md"), + `---\nname: reviewer\ndescription: Hand-written reviewer.\n---\n\nHand-written instructions.\n`, + "utf-8", + ); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + + expect(result.subagentsRepaired).toBe(1); + expect(result.issues.some((i) => i.type === "subagents" && i.message.includes("identity conflicts"))).toBe(true); + expect(existsSync(join(agentsDir, "reviewer.md"))).toBe(false); + expect(existsSync(join(agentsDir, "alias.md"))).toBe(true); + }); + it("does not prune runtime files for declared subagents that are not installed", async () => { await writeFile( join(projectRoot, "agents.toml"), From fa08c2d8379ce8603cbb3bf83944c885c3463ea0 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 30 May 2026 18:13:45 -0700 Subject: [PATCH 04/33] fix(subagents): Centralize runtime identity rules Declare each runtime's subagent identity strategy on the agent definition and use that shared rule for source import and unmanaged conflict detection. Reject competing portable subagent declarations across scan roots while still allowing native runtime artifacts to merge into the portable projection. Co-Authored-By: Codex --- README.md | 2 +- docs/public/llms.txt | 2 +- docs/src/content/docs/cli.mdx | 2 +- .../src/agents/definitions/claude.ts | 1 + .../dotagents/src/agents/definitions/codex.ts | 1 + .../src/agents/definitions/cursor.ts | 1 + .../src/agents/definitions/opencode.ts | 1 + packages/dotagents/src/agents/index.ts | 1 + .../dotagents/src/agents/subagent-identity.ts | 83 +++++++++++++++++++ .../src/agents/subagent-store.test.ts | 43 ++++++++++ .../dotagents/src/agents/subagent-store.ts | 52 ++++++++++-- .../dotagents/src/agents/subagent-writer.ts | 75 ++--------------- packages/dotagents/src/agents/types.ts | 8 ++ specs/subagents.md | 2 +- 14 files changed, 195 insertions(+), 79 deletions(-) create mode 100644 packages/dotagents/src/agents/subagent-identity.ts diff --git a/README.md b/README.md index d030c4b..c7c51ac 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ description: Review code for correctness, security, and missing tests. Review the current diff and return findings with file references. ``` -dotagents can also import native runtime subagent files from `.claude/agents/`, `.cursor/agents/`, `.codex/agents/*.toml`, and `.opencode/agents/`. Input and matching-runtime output use the same native format: Markdown with YAML frontmatter for Claude, Cursor, and OpenCode; TOML for Codex. Claude and Codex identify agents by `name`, Cursor can derive `name` from the filename when omitted, and OpenCode uses the filename as the agent name. When the source format matches a target runtime, dotagents reuses the native source content for that runtime and only adds its managed-file marker. Other runtimes are generated from the portable `name`, `description`, and instructions. Subagent declarations intentionally cover only dependency source and runtime targets, not universal model/tool/permission behavior. +dotagents can also import native runtime subagent files from `.claude/agents/`, `.cursor/agents/`, `.codex/agents/*.toml`, and `.opencode/agents/`. Input and matching-runtime output use the same native format: Markdown with YAML frontmatter for Claude, Cursor, and OpenCode; TOML for Codex. Claude and Codex identify agents by `name`, Cursor can derive `name` from the filename when omitted, and OpenCode uses the filename as the agent name. Multiple portable matches for the same subagent are rejected as ambiguous, while matching native runtime artifacts are merged. When the source format matches a target runtime, dotagents reuses the native source content for that runtime and only adds its managed-file marker. Other runtimes are generated from the portable `name`, `description`, and instructions. Subagent declarations intentionally cover only dependency source and runtime targets, not universal model/tool/permission behavior. [Pi](https://github.com/badlogic/pi-mono) reads `.agents/skills/` natively and needs no configuration. diff --git a/docs/public/llms.txt b/docs/public/llms.txt index dd52673..280b21f 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -227,7 +227,7 @@ Each `[[subagents]]` entry requires `name` and `source`. Optional: `ref`, `path` dotagents treats subagents as best-effort portable dependencies, not a universal behavior schema. Runtime-specific behavior such as model routing, tool permissions, read-only modes, background execution, and reasoning effort stays in each tool's native artifact. -dotagents discovers portable subagent Markdown from `agents/` and `.agents/agents/`. It also imports native runtime artifacts from `.claude/agents/*.md`, `.cursor/agents/*.md`, `.codex/agents/*.toml`, and `.opencode/agents/*.md`. When the source format matches a target runtime, dotagents reuses the native source content for that runtime and only adds its managed-file marker. Other runtimes are generated from the portable `name`, `description`, and instructions. +dotagents discovers portable subagent Markdown from `agents/` and `.agents/agents/`. It also imports native runtime artifacts from `.claude/agents/*.md`, `.cursor/agents/*.md`, `.codex/agents/*.toml`, and `.opencode/agents/*.md`. Multiple portable matches for the same subagent are rejected as ambiguous, while matching native runtime artifacts are merged. When the source format matches a target runtime, dotagents reuses the native source content for that runtime and only adds its managed-file marker. Other runtimes are generated from the portable `name`, `description`, and instructions. Accepted source/output formats: diff --git a/docs/src/content/docs/cli.mdx b/docs/src/content/docs/cli.mdx index da4088f..44160c0 100644 --- a/docs/src/content/docs/cli.mdx +++ b/docs/src/content/docs/cli.mdx @@ -383,7 +383,7 @@ Status output: dotagents treats subagents as best-effort portable dependencies, not a universal behavior schema. Runtime-specific behavior such as model routing, tool permissions, read-only modes, background execution, and reasoning effort stays in each tool's native artifact. -dotagents discovers portable subagent Markdown from `agents/` and `.agents/agents/`. It also imports native runtime artifacts from `.claude/agents/*.md`, `.cursor/agents/*.md`, `.codex/agents/*.toml`, and `.opencode/agents/*.md`. When the source format matches a target runtime, dotagents reuses the native source content for that runtime and only adds its managed-file marker. Other runtimes are generated from the portable `name`, `description`, and instructions. +dotagents discovers portable subagent Markdown from `agents/` and `.agents/agents/`. It also imports native runtime artifacts from `.claude/agents/*.md`, `.cursor/agents/*.md`, `.codex/agents/*.toml`, and `.opencode/agents/*.md`. Multiple portable matches for the same subagent are rejected as ambiguous, while matching native runtime artifacts are merged. When the source format matches a target runtime, dotagents reuses the native source content for that runtime and only adds its managed-file marker. Other runtimes are generated from the portable `name`, `description`, and instructions. | Format | Source path | Matching output path | Required source fields | | --- | --- | --- | --- | diff --git a/packages/dotagents/src/agents/definitions/claude.ts b/packages/dotagents/src/agents/definitions/claude.ts index 2aa0d2a..d9e5a13 100644 --- a/packages/dotagents/src/agents/definitions/claude.ts +++ b/packages/dotagents/src/agents/definitions/claude.ts @@ -31,6 +31,7 @@ const claude: AgentDefinition = { projectDir: ".claude/agents", userDir: join(homedir(), ".claude", "agents"), fileExtension: ".md", + identity: "frontmatter-name", serialize(subagent) { const native = subagent.native?.claude; return { diff --git a/packages/dotagents/src/agents/definitions/codex.ts b/packages/dotagents/src/agents/definitions/codex.ts index c87069e..e02bc0a 100644 --- a/packages/dotagents/src/agents/definitions/codex.ts +++ b/packages/dotagents/src/agents/definitions/codex.ts @@ -39,6 +39,7 @@ const codex: AgentDefinition = { projectDir: ".codex/agents", userDir: join(homedir(), ".codex", "agents"), fileExtension: ".toml", + identity: "toml-name", serialize(subagent) { const native = subagent.native?.codex; return { diff --git a/packages/dotagents/src/agents/definitions/cursor.ts b/packages/dotagents/src/agents/definitions/cursor.ts index b2c4240..a3eaf90 100644 --- a/packages/dotagents/src/agents/definitions/cursor.ts +++ b/packages/dotagents/src/agents/definitions/cursor.ts @@ -58,6 +58,7 @@ const cursor: AgentDefinition = { projectDir: ".cursor/agents", userDir: join(homedir(), ".cursor", "agents"), fileExtension: ".md", + identity: "frontmatter-name-or-filename", serialize(subagent) { const native = subagent.native?.cursor; return { diff --git a/packages/dotagents/src/agents/definitions/opencode.ts b/packages/dotagents/src/agents/definitions/opencode.ts index 3bf09c2..7e8b231 100644 --- a/packages/dotagents/src/agents/definitions/opencode.ts +++ b/packages/dotagents/src/agents/definitions/opencode.ts @@ -38,6 +38,7 @@ const opencode: AgentDefinition = { projectDir: ".opencode/agents", userDir: join(homedir(), ".config", "opencode", "agents"), fileExtension: ".md", + identity: "filename", serialize(subagent) { const native = subagent.native?.opencode; return { diff --git a/packages/dotagents/src/agents/index.ts b/packages/dotagents/src/agents/index.ts index dbe6ffe..ed9b6cc 100644 --- a/packages/dotagents/src/agents/index.ts +++ b/packages/dotagents/src/agents/index.ts @@ -46,5 +46,6 @@ export type { NativeSubagentContent, NativeSubagentTarget, SubagentConfigSpec, + SubagentIdentityStrategy, SubagentSerializer, } from "./types.js"; diff --git a/packages/dotagents/src/agents/subagent-identity.ts b/packages/dotagents/src/agents/subagent-identity.ts new file mode 100644 index 0000000..0c137c5 --- /dev/null +++ b/packages/dotagents/src/agents/subagent-identity.ts @@ -0,0 +1,83 @@ +import { basename, extname } from "node:path"; +import { parse as parseTOML } from "smol-toml"; +import { loadMarkdownFrontmatter } from "@sentry/dotagents-lib"; +import type { SubagentConfigSpec, SubagentIdentityStrategy } from "./types.js"; + +export function subagentIdentityFromMarkdownMeta( + strategy: SubagentIdentityStrategy, + fileName: string | undefined, + meta: Record, +): string | null { + const declaredName = typeof meta["name"] === "string" && meta["name"] ? meta["name"] : null; + + switch (strategy) { + case "frontmatter-name": + return declaredName; + case "frontmatter-name-or-filename": + return declaredName ?? (fileName ? subagentIdentityFromFileName(fileName) : null); + case "filename": + return fileName ? subagentIdentityFromFileName(fileName) : null; + case "toml-name": + return null; + } +} + +export function generatedSubagentIdentity( + spec: SubagentConfigSpec, + fileName: string, + content: string, + portableName: string, +): string | null { + switch (spec.identity) { + case "frontmatter-name": + case "frontmatter-name-or-filename": + return portableName; + case "filename": + return subagentIdentityFromFileName(fileName); + case "toml-name": + return subagentIdentityFromTomlContent(content); + } +} + +export async function readSubagentFileIdentity( + spec: SubagentConfigSpec, + filePath: string, + fileName: string, + content: string, +): Promise { + switch (spec.identity) { + case "frontmatter-name": + case "frontmatter-name-or-filename": { + try { + const { meta } = await loadMarkdownFrontmatter(filePath); + return subagentIdentityFromMarkdownMeta(spec.identity, fileName, meta); + } catch { + return null; + } + } + case "filename": + return subagentIdentityFromFileName(fileName); + case "toml-name": + return subagentIdentityFromTomlContent(content); + } +} + +export function subagentIdentityFromTomlContent(content: string): string | null { + try { + const parsed = parseTOML(content); + if (isPlainObject(parsed) && typeof parsed["name"] === "string" && parsed["name"]) { + return parsed["name"]; + } + } catch { + return null; + } + return null; +} + +function subagentIdentityFromFileName(fileName: string): string { + return basename(fileName, extname(fileName)); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/dotagents/src/agents/subagent-store.test.ts b/packages/dotagents/src/agents/subagent-store.test.ts index befd8ee..cc177bc 100644 --- a/packages/dotagents/src/agents/subagent-store.test.ts +++ b/packages/dotagents/src/agents/subagent-store.test.ts @@ -178,6 +178,33 @@ Review the current diff for Claude. expect(resolved.subagent.native?.codex).toContain('sandbox_mode = "read-only"'); }); + it("merges a portable declaration with matching native artifacts", async () => { + await mkdir(join(repoDir, "agents"), { recursive: true }); + await mkdir(join(repoDir, ".codex", "agents"), { recursive: true }); + await writeFile( + join(repoDir, "agents", "code-reviewer.md"), + SUBAGENT_MD("code-reviewer"), + ); + await writeFile( + join(repoDir, ".codex", "agents", "code-reviewer.toml"), + [ + 'name = "code_reviewer"', + 'description = "Review code for correctness."', + 'developer_instructions = "Review the current diff for Codex."', + 'sandbox_mode = "read-only"', + "", + ].join("\n"), + ); + + const resolved = await resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }); + + expect(resolved.subagent.instructions).toBe("Review the current diff."); + expect(resolved.subagent.native?.codex).toContain('sandbox_mode = "read-only"'); + }); + it("rejects explicit paths whose frontmatter name differs from agents.toml", async () => { await mkdir(join(repoDir, "agents"), { recursive: true }); await writeFile(join(repoDir, "agents", "reviewer.md"), SUBAGENT_MD("other-reviewer")); @@ -234,6 +261,22 @@ Review the current diff for Claude. ); }); + it("rejects ambiguous portable matches across scan directories", async () => { + await mkdir(join(repoDir, "agents"), { recursive: true }); + await mkdir(join(repoDir, ".agents", "agents"), { recursive: true }); + await writeFile(join(repoDir, "agents", "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + await writeFile(join(repoDir, ".agents", "agents", "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await expect( + resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }), + ).rejects.toThrow( + 'Ambiguous portable matches for subagent "code-reviewer": agents/code-reviewer.md, .agents/agents/code-reviewer.md', + ); + }); + it("rejects invalid frontmatter names", async () => { await mkdir(join(repoDir, "agents"), { recursive: true }); await writeFile(join(repoDir, "agents", "code-reviewer.md"), SUBAGENT_MD("CodeReviewer")); diff --git a/packages/dotagents/src/agents/subagent-store.ts b/packages/dotagents/src/agents/subagent-store.ts index 84798a4..c0280aa 100644 --- a/packages/dotagents/src/agents/subagent-store.ts +++ b/packages/dotagents/src/agents/subagent-store.ts @@ -15,9 +15,16 @@ import { type TrustPolicy, } from "@sentry/dotagents-lib"; import { DOTAGENTS_SUBAGENT_MARKER, serializeMarkdownSubagent } from "./definitions/helpers.js"; +import { getAgent } from "./registry.js"; +import { subagentIdentityFromMarkdownMeta } from "./subagent-identity.js"; import { SUBAGENT_NAME_PATTERN, type SubagentConfig } from "../config/schema.js"; import type { LockedSubagent } from "../lockfile/schema.js"; -import type { NativeSubagentContent, NativeSubagentTarget, SubagentDeclaration } from "./types.js"; +import type { + NativeSubagentContent, + NativeSubagentTarget, + SubagentDeclaration, + SubagentIdentityStrategy, +} from "./types.js"; const DOTAGENTS_NATIVE_FIELD = "dotagents_native"; const NATIVE_SUBAGENT_TARGETS = ["claude", "cursor", "codex", "opencode"] satisfies NativeSubagentTarget[]; @@ -32,6 +39,7 @@ interface SubagentScanDir { interface DiscoveredSubagent { path: string; subagent: SubagentDeclaration; + nativeTarget?: NativeSubagentTarget; } const SUBAGENT_SCAN_DIRS: readonly SubagentScanDir[] = [ @@ -268,7 +276,11 @@ async function discoverSubagent( assertSubagentNameMatches(subagent.name, config.name, relPath); } if (subagent.name !== config.name) {continue;} - const match = { path: relPath, subagent }; + const match = { + path: relPath, + subagent, + ...(scanDir.nativeTarget ? { nativeTarget: scanDir.nativeTarget } : {}), + }; if (nameFromFile === config.name) { fileNameMatches.push(match); } else { @@ -281,7 +293,7 @@ async function discoverSubagent( } if (matches.length === 0) {return null;} - return mergeDiscoveredSubagents(matches); + return mergeDiscoveredSubagents(config.name, matches); } async function loadSubagentFile( @@ -297,10 +309,11 @@ async function loadSubagentFile( } const { meta, body, raw } = await loadMarkdownFrontmatter(filePath); - const declaredName = typeof meta["name"] === "string" && meta["name"] ? meta["name"] : undefined; - const name = opts.nativeTarget === "opencode" - ? opts.nameFromFile - : declaredName ?? (opts.nativeTarget === "cursor" ? opts.nameFromFile : undefined); + const name = subagentIdentityFromMarkdownMeta( + identityStrategyForNativeTarget(opts.nativeTarget), + opts.nameFromFile, + meta, + ); if (!name) { throw new Error(`Missing 'name' in subagent frontmatter: ${filePath}`); } @@ -389,8 +402,18 @@ async function loadCodexSubagentFile( }; } -function mergeDiscoveredSubagents(matches: DiscoveredSubagent[]): DiscoveredSubagent { - const base = matches[0]!; +function mergeDiscoveredSubagents( + name: string, + matches: DiscoveredSubagent[], +): DiscoveredSubagent { + const portableMatches = matches.filter((match) => !match.nativeTarget); + if (portableMatches.length > 1) { + throw new Error( + `Ambiguous portable matches for subagent "${name}": ${portableMatches.map((m) => m.path).join(", ")}`, + ); + } + + const base = portableMatches[0] ?? matches[0]!; const native: NativeSubagentContent = { ...base.subagent.native }; for (const match of matches.slice(1)) { @@ -409,6 +432,17 @@ function mergeDiscoveredSubagents(matches: DiscoveredSubagent[]): DiscoveredSuba }; } +function identityStrategyForNativeTarget( + target: NativeSubagentTarget | undefined, +): SubagentIdentityStrategy { + if (!target) {return "frontmatter-name";} + const identity = getAgent(target)?.subagents?.identity; + if (!identity) { + throw new Error(`Agent "${target}" does not define subagent identity rules.`); + } + return identity; +} + function assertSubagentNameMatches( actualName: string, expectedName: string, diff --git a/packages/dotagents/src/agents/subagent-writer.ts b/packages/dotagents/src/agents/subagent-writer.ts index 55a3265..bd821bb 100644 --- a/packages/dotagents/src/agents/subagent-writer.ts +++ b/packages/dotagents/src/agents/subagent-writer.ts @@ -1,10 +1,9 @@ import { existsSync } from "node:fs"; import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; -import { basename, extname, join } from "node:path"; -import { parse as parseTOML } from "smol-toml"; -import { loadMarkdownFrontmatter } from "@sentry/dotagents-lib"; +import { join } from "node:path"; import { allAgents, getAgent } from "./registry.js"; import { DOTAGENTS_SUBAGENT_MARKER } from "./definitions/helpers.js"; +import { generatedSubagentIdentity, readSubagentFileIdentity } from "./subagent-identity.js"; import type { SubagentConfigSpec, SubagentDeclaration } from "./types.js"; export interface SubagentResolvedTarget { @@ -98,7 +97,7 @@ export async function writeSubagentConfigs( throw new Error(`Internal error: generated subagent "${subagent.name}" is missing the dotagents marker`); } const generatedIdentity = generatedSubagentIdentity( - agentId, + agent.subagents, generated.fileName, content, subagent.name, @@ -107,10 +106,9 @@ export async function writeSubagentConfigs( await mkdir(dirPath, { recursive: true }); markDesired(desiredByDir, dirPath, agent.subagents.fileExtension, generated.fileName); const identityConflict = await findUnmanagedIdentityConflict( - agentId, dirPath, generated.fileName, - agent.subagents.fileExtension, + agent.subagents, generatedIdentity, ); if (identityConflict) { @@ -178,11 +176,10 @@ export async function verifySubagentConfigs( const content = normalizeContent(generated.content); const identityConflict = await findUnmanagedIdentityConflict( - agentId, dirPath, generated.fileName, - agent.subagents.fileExtension, - generatedSubagentIdentity(agentId, generated.fileName, content, subagent.name), + agent.subagents, + generatedSubagentIdentity(agent.subagents, generated.fileName, content, subagent.name), ); if (identityConflict) { issues.push({ @@ -335,10 +332,9 @@ async function pruneManagedFiles( } async function findUnmanagedIdentityConflict( - agentId: string, dirPath: string, generatedFileName: string, - extension: string, + spec: SubagentConfigSpec, generatedIdentity: string | null, ): Promise { if (!existsSync(dirPath)) {return null;} @@ -350,13 +346,13 @@ async function findUnmanagedIdentityConflict( )) { if (!entry.isFile()) {continue;} if (entry.name === generatedFileName) {continue;} - if (!entry.name.endsWith(extension)) {continue;} + if (!entry.name.endsWith(spec.fileExtension)) {continue;} const filePath = join(dirPath, entry.name); const existing = await readFile(filePath, "utf-8"); if (existing.includes(DOTAGENTS_SUBAGENT_MARKER)) {continue;} - const existingIdentity = await readExistingSubagentIdentity(agentId, filePath, entry.name, existing); + const existingIdentity = await readSubagentFileIdentity(spec, filePath, entry.name, existing); if (existingIdentity === generatedIdentity) { return filePath; } @@ -365,63 +361,10 @@ async function findUnmanagedIdentityConflict( return null; } -function generatedSubagentIdentity( - agentId: string, - fileName: string, - content: string, - portableName: string, -): string | null { - if (agentId === "opencode") { - return basename(fileName, extname(fileName)); - } - if (agentId === "codex") { - return readCodexSubagentIdentity(content); - } - return portableName; -} - -async function readExistingSubagentIdentity( - agentId: string, - filePath: string, - fileName: string, - content: string, -): Promise { - if (agentId === "opencode") { - return basename(fileName, extname(fileName)); - } - if (agentId === "codex") { - return readCodexSubagentIdentity(content); - } - - try { - const { meta } = await loadMarkdownFrontmatter(filePath); - const name = typeof meta["name"] === "string" && meta["name"] ? meta["name"] : null; - return name ?? (agentId === "cursor" ? basename(fileName, extname(fileName)) : null); - } catch { - return null; - } -} - -function readCodexSubagentIdentity(content: string): string | null { - try { - const parsed = parseTOML(content); - if (isPlainObject(parsed) && typeof parsed["name"] === "string" && parsed["name"]) { - return parsed["name"]; - } - } catch { - return null; - } - return null; -} - function normalizeContent(content: string): string { return content.endsWith("\n") ? content : `${content}\n`; } -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function isNotFoundError(err: unknown): boolean { return err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "ENOENT"; } diff --git a/packages/dotagents/src/agents/types.ts b/packages/dotagents/src/agents/types.ts index 7804be8..9518aec 100644 --- a/packages/dotagents/src/agents/types.ts +++ b/packages/dotagents/src/agents/types.ts @@ -91,6 +91,12 @@ export type NativeSubagentConfig = string; export type NativeSubagentContent = Partial>; +export type SubagentIdentityStrategy = + | "frontmatter-name" + | "frontmatter-name-or-filename" + | "filename" + | "toml-name"; + /** * Describes where an agent stores custom subagent definitions. */ @@ -101,6 +107,8 @@ export interface SubagentConfigSpec { userDir: string; /** Generated file extension, including the leading dot */ fileExtension: ".md" | ".toml"; + /** Runtime-specific rule for identifying a subagent artifact */ + identity: SubagentIdentityStrategy; /** Transforms a universal subagent declaration into an agent-specific file */ serialize: SubagentSerializer; } diff --git a/specs/subagents.md b/specs/subagents.md index 6e00fd4..edab6f8 100644 --- a/specs/subagents.md +++ b/specs/subagents.md @@ -82,7 +82,7 @@ Without an explicit `path`, dotagents scans source directories in this order: 6. `.codex/agents/**/*.toml` 7. `.opencode/agents/**/*.md` -Within each scanned directory, filename matches for `agents.toml` `name` take precedence over metadata-only matches. Multiple filename or metadata matches in one scanned directory are rejected as ambiguous. Matching artifacts from multiple runtimes are merged into one subagent declaration so native Claude and native Codex sources for the same portable ID can both be preserved. +Within each scanned directory, filename matches for `agents.toml` `name` take precedence over metadata-only matches. Multiple filename or metadata matches in one scanned directory are rejected as ambiguous. Multiple portable matches across portable scan directories are also rejected, because dotagents cannot safely choose between competing portable instructions. Matching artifacts from multiple runtimes are merged into one subagent declaration so native Claude and native Codex sources for the same portable ID can both be preserved. If `path` is set, dotagents imports only that file. A `.toml` path is treated as Codex native. Markdown under `.claude/agents/`, `.cursor/agents/`, or `.opencode/agents/` is treated as native for that runtime; other Markdown is treated as portable. From 7ae1357e1300da7b78d25a1da7a94852b88dc164 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 30 May 2026 18:27:51 -0700 Subject: [PATCH 05/33] fix(subagents): Honor frozen install lockfile state Validate declared subagents against agents.lock before frozen installs and write one coherent lockfile for the current configured skills and subagents. Also serialize native subagent overlays as block YAML and parse already-read Markdown content when checking runtime identities. Co-Authored-By: Codex --- README.md | 2 +- docs/public/llms.txt | 2 +- docs/src/content/docs/cli.mdx | 8 +- packages/dotagents-lib/src/index.ts | 7 +- packages/dotagents-lib/src/skills/loader.ts | 7 ++ .../src/agents/definitions/helpers.test.ts | 26 +++++++ .../src/agents/definitions/helpers.ts | 31 +++++++- .../dotagents/src/agents/subagent-identity.ts | 8 +- .../dotagents/src/agents/subagent-writer.ts | 2 +- .../src/cli/commands/install.test.ts | 75 +++++++++++++++++++ .../dotagents/src/cli/commands/install.ts | 58 +++++++------- specs/SPEC.md | 3 +- 12 files changed, 187 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index c7c51ac..87fc5cc 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ npx @sentry/dotagents add getsentry/skills find-bugs code-review commit npx @sentry/dotagents add getsentry/skills --all ``` -This creates an `agents.toml` at your project root and an `agents.lock` tracking installed skills. +This creates an `agents.toml` at your project root and an `agents.lock` tracking installed skills and subagents. After cloning a project that already has `agents.toml`, run `install` to fetch skills and subagents. Run it again to refresh managed local state: diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 280b21f..1271245 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -310,7 +310,7 @@ Create `agents.toml` and `.agents/skills/` directory. Automatically includes the npx @sentry/dotagents install ``` -Install and refresh dependencies from `agents.toml`. Resolves sources, copies skills, writes the lockfile, creates symlinks, and generates MCP and hook configs. There is no separate update command. +Install and refresh dependencies from `agents.toml`. Resolves sources, copies skills, writes the lockfile, creates symlinks, and generates MCP, hook, and subagent configs. There is no separate update command. With `--frozen`, declared skills and subagents must already exist in `agents.lock`, and the lockfile is not updated. ### add diff --git a/docs/src/content/docs/cli.mdx b/docs/src/content/docs/cli.mdx index 44160c0..a7f47c2 100644 --- a/docs/src/content/docs/cli.mdx +++ b/docs/src/content/docs/cli.mdx @@ -54,9 +54,11 @@ dotagents --user init dotagents install ``` -Install and refresh skill dependencies from `agents.toml`. Resolves sources, -copies skills, writes the lockfile, creates symlinks, and generates MCP and -hook configs. There is no separate update command. +Install and refresh skill and subagent dependencies from `agents.toml`. Resolves +sources, copies skills, writes the lockfile, creates symlinks, and generates MCP, +hook, and subagent configs. There is no separate update command. With +`--frozen`, declared skills and subagents must already exist in `agents.lock`, +and the lockfile is not updated. Example: diff --git a/packages/dotagents-lib/src/index.ts b/packages/dotagents-lib/src/index.ts index c9db959..0646b92 100644 --- a/packages/dotagents-lib/src/index.ts +++ b/packages/dotagents-lib/src/index.ts @@ -1,5 +1,10 @@ // SKILL.md loading -export { loadSkillMd, loadMarkdownFrontmatter, SkillLoadError } from "./skills/loader.js"; +export { + loadSkillMd, + loadMarkdownFrontmatter, + parseMarkdownFrontmatterContent, + SkillLoadError, +} from "./skills/loader.js"; export type { SkillMeta, LoadSkillMdOptions, diff --git a/packages/dotagents-lib/src/skills/loader.ts b/packages/dotagents-lib/src/skills/loader.ts index 46cbfc7..9695048 100644 --- a/packages/dotagents-lib/src/skills/loader.ts +++ b/packages/dotagents-lib/src/skills/loader.ts @@ -75,6 +75,13 @@ export async function loadMarkdownFrontmatter( throw new SkillLoadError(`${opts?.fileDescription ?? "Markdown file"} not found: ${filePath}`); } + return parseMarkdownFrontmatterContent(content, filePath); +} + +export function parseMarkdownFrontmatterContent( + content: string, + filePath: string, +): MarkdownFrontmatter { const match = FRONTMATTER_RE.exec(content); if (!match?.[1]) { throw new SkillLoadError(`No YAML frontmatter in ${filePath}`); diff --git a/packages/dotagents/src/agents/definitions/helpers.test.ts b/packages/dotagents/src/agents/definitions/helpers.test.ts index ed0fb0b..7d8ba2a 100644 --- a/packages/dotagents/src/agents/definitions/helpers.test.ts +++ b/packages/dotagents/src/agents/definitions/helpers.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { parseMarkdownFrontmatterContent } from "@sentry/dotagents-lib"; import { interpolateEnvRefs, interpolateHeaders, @@ -109,4 +110,29 @@ describe("serializeMarkdownSubagent", () => { expect(content).toContain('mode: "subagent"'); expect(content).toContain("Review the diff."); }); + + it("serializes nested frontmatter objects using block YAML", () => { + const content = serializeMarkdownSubagent( + { + name: "code-reviewer", + description: "Review code.", + dotagents_native: { + codex: [ + 'name = "code_reviewer"', + 'description = "Review code."', + "", + ].join("\n"), + }, + }, + "Review the diff.", + ); + + expect(content).toContain("dotagents_native:\n codex: |"); + expect(content).not.toContain('dotagents_native: {"codex"'); + + const parsed = parseMarkdownFrontmatterContent(content, "subagent.md"); + expect(parsed.meta["dotagents_native"]).toEqual({ + codex: 'name = "code_reviewer"\ndescription = "Review code."\n', + }); + }); }); diff --git a/packages/dotagents/src/agents/definitions/helpers.ts b/packages/dotagents/src/agents/definitions/helpers.ts index f949854..beed1d9 100644 --- a/packages/dotagents/src/agents/definitions/helpers.ts +++ b/packages/dotagents/src/agents/definitions/helpers.ts @@ -136,12 +136,37 @@ function appendYamlField( lines: string[], key: string, value: unknown, + indent = 0, ): void { if (value === undefined) {return;} + const prefix = " ".repeat(indent); + if (isPlainObject(value)) { + const entries = Object.entries(value); + if (entries.length === 0) { + lines.push(`${prefix}${toYamlKey(key)}: {}`); + return; + } + lines.push(`${prefix}${toYamlKey(key)}:`); + for (const [childKey, childValue] of entries) { + appendYamlField(lines, childKey, childValue, indent + 2); + } + return; + } + + if (typeof value === "string" && value.includes("\n")) { + const chomp = value.endsWith("\n") ? "|" : "|-"; + const body = value.endsWith("\n") ? value.slice(0, -1) : value; + lines.push(`${prefix}${toYamlKey(key)}: ${chomp}`); + for (const line of body.split(/\r?\n/)) { + lines.push(`${prefix} ${line}`); + } + return; + } + const serialized = JSON.stringify(value); if (serialized !== undefined) { - lines.push(`${toYamlKey(key)}: ${serialized}`); + lines.push(`${prefix}${toYamlKey(key)}: ${serialized}`); } } @@ -151,3 +176,7 @@ function toYamlKey(key: string): string { } return JSON.stringify(key); } + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/dotagents/src/agents/subagent-identity.ts b/packages/dotagents/src/agents/subagent-identity.ts index 0c137c5..626c91b 100644 --- a/packages/dotagents/src/agents/subagent-identity.ts +++ b/packages/dotagents/src/agents/subagent-identity.ts @@ -1,6 +1,6 @@ import { basename, extname } from "node:path"; import { parse as parseTOML } from "smol-toml"; -import { loadMarkdownFrontmatter } from "@sentry/dotagents-lib"; +import { parseMarkdownFrontmatterContent } from "@sentry/dotagents-lib"; import type { SubagentConfigSpec, SubagentIdentityStrategy } from "./types.js"; export function subagentIdentityFromMarkdownMeta( @@ -39,17 +39,17 @@ export function generatedSubagentIdentity( } } -export async function readSubagentFileIdentity( +export function readSubagentFileIdentity( spec: SubagentConfigSpec, filePath: string, fileName: string, content: string, -): Promise { +): string | null { switch (spec.identity) { case "frontmatter-name": case "frontmatter-name-or-filename": { try { - const { meta } = await loadMarkdownFrontmatter(filePath); + const { meta } = parseMarkdownFrontmatterContent(content, filePath); return subagentIdentityFromMarkdownMeta(spec.identity, fileName, meta); } catch { return null; diff --git a/packages/dotagents/src/agents/subagent-writer.ts b/packages/dotagents/src/agents/subagent-writer.ts index bd821bb..64f1338 100644 --- a/packages/dotagents/src/agents/subagent-writer.ts +++ b/packages/dotagents/src/agents/subagent-writer.ts @@ -352,7 +352,7 @@ async function findUnmanagedIdentityConflict( const existing = await readFile(filePath, "utf-8"); if (existing.includes(DOTAGENTS_SUBAGENT_MARKER)) {continue;} - const existingIdentity = await readSubagentFileIdentity(spec, filePath, entry.name, existing); + const existingIdentity = readSubagentFileIdentity(spec, filePath, entry.name, existing); if (existingIdentity === generatedIdentity) { return filePath; } diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index fab76d9..b456408 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -274,6 +274,81 @@ source = "path:agents" expect(lockfile!.subagents["code-reviewer"]?.source).toBe("path:agents"); }); + it("frozen mode fails when a subagent is missing from the lockfile", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1\n\n[[skills]]\nname = "pdf"\nsource = "git:${repoDir}"\n`, + ); + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +[[subagents]] +name = "code-reviewer" +source = "path:agents" +`, + ); + + await expect(runInstall({ scope, frozen: true })).rejects.toThrow( + '--frozen: subagent "code-reviewer" is in agents.toml but missing from agents.lock.', + ); + }); + + it("frozen mode passes when subagent lockfile entries match", async () => { + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +[[subagents]] +name = "code-reviewer" +source = "path:agents" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + const result = await runInstall({ scope, frozen: true }); + expect(result.subagentWarnings).toEqual([]); + }); + + it("clears removed skills from the lockfile when installing subagents", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1\n\n[[skills]]\nname = "pdf"\nsource = "git:${repoDir}"\n`, + ); + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +[[subagents]] +name = "code-reviewer" +source = "path:agents" +`, + ); + + await runInstall({ scope }); + + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.skills).toEqual({}); + expect(lockfile!.subagents["code-reviewer"]?.source).toBe("path:agents"); + }); + it("preserves native Codex content through install and sync", async () => { const sourceDir = join(projectRoot, "upstream", ".codex", "agents"); await mkdir(sourceDir, { recursive: true }); diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index 6485b48..44dd1d8 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -7,6 +7,7 @@ import { isWildcardDep, type RepositorySource, type SkillDependency, + type SubagentConfig, } from "../../config/schema.js"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; @@ -147,6 +148,24 @@ async function expandSkills( return expanded; } +function validateFrozenSubagents( + subagents: SubagentConfig[], + lockfile: Lockfile | null, +): void { + if (subagents.length === 0) {return;} + if (!lockfile) { + throw new InstallError("--frozen requires agents.lock to exist."); + } + + for (const subagent of subagents) { + if (!lockfile.subagents[subagent.name]) { + throw new InstallError( + `--frozen: subagent "${subagent.name}" is in agents.toml but missing from agents.lock.`, + ); + } + } +} + export async function runInstall(opts: InstallOptions): Promise { const { scope, frozen } = opts; const { configPath, lockPath, agentsDir, skillsDir } = scope; @@ -154,6 +173,8 @@ export async function runInstall(opts: InstallOptions): Promise { // 1. Read config const config = await loadConfig(configPath); + const lockfile = await loadLockfile(lockPath); + const newLock: Lockfile = { version: 1, skills: {}, subagents: {} }; const installed: string[] = []; const skipped: string[] = []; const pruned: string[] = []; @@ -163,8 +184,6 @@ export async function runInstall(opts: InstallOptions): Promise { // 2. Resolve and install skills (if any declared) if (config.skills.length > 0) { - const lockfile = await loadLockfile(lockPath); - if (frozen && !lockfile) { throw new InstallError("--frozen requires agents.lock to exist."); } @@ -191,8 +210,6 @@ export async function runInstall(opts: InstallOptions): Promise { } } - const newLock: Lockfile = { version: 1, skills: {}, subagents: lockfile?.subagents ?? {} }; - for (const item of expanded) { const { name, dep } = item; @@ -268,16 +285,14 @@ export async function runInstall(opts: InstallOptions): Promise { } } } - - if (!frozen) { - await writeLockfile(lockPath, newLock); - } } // 3. Resolve and install subagent markdown files const installedSubagents: SubagentDeclaration[] = []; + if (frozen) { + validateFrozenSubagents(config.subagents, lockfile); + } if (config.subagents.length > 0) { - const resolvedSubagents = []; for (const subagentConfig of config.subagents) { const resolved = await resolveSubagent(subagentConfig, { stateDir: getCacheStateDir(), @@ -287,32 +302,17 @@ export async function runInstall(opts: InstallOptions): Promise { minimumReleaseAgeExclude: config.minimum_release_age_exclude, trust: config.trust, }); - resolvedSubagents.push(resolved); installedSubagents.push(resolved.subagent); + newLock.subagents[resolved.subagent.name] = lockEntryForSubagent(resolved); } await writeInstalledSubagents(subagentsDir, installedSubagents); - - if (!frozen) { - const lockfile = await loadLockfile(lockPath); - const newLock: Lockfile = { - version: 1, - skills: lockfile?.skills ?? {}, - subagents: {}, - }; - for (const resolved of resolvedSubagents) { - newLock.subagents[resolved.subagent.name] = lockEntryForSubagent(resolved); - } - await writeLockfile(lockPath, newLock); - } } else { await writeInstalledSubagents(subagentsDir, []); - if (!frozen) { - const lockfile = await loadLockfile(lockPath); - if (lockfile && Object.keys(lockfile.subagents).length > 0) { - await writeLockfile(lockPath, { ...lockfile, subagents: {} }); - } - } + } + + if (!frozen && (lockfile || config.skills.length > 0 || config.subagents.length > 0)) { + await writeLockfile(lockPath, newLock); } // 4. Gitignore (skip for user scope — ~/.agents/ is not a git repo) diff --git a/specs/SPEC.md b/specs/SPEC.md index ffcfab2..2153af0 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -424,7 +424,8 @@ dotagents install a. Resolve source (check cache with TTL-based refresh, clone/fetch if needed) b. Discover skill within the repo c. Copy skill directory into `.agents/skills//` -3. Write `agents.lock` +3. Write `agents.lock` with the current configured skills and subagents + - In `--frozen` mode, require configured dependencies to already be present in `agents.lock` and do not update the lockfile 4. Regenerate `.agents/.gitignore` 5. Warn if `agents.lock` and `.agents/.gitignore` are not in the root `.gitignore` 6. Create/verify symlinks (legacy `[symlinks]` and agent-specific) From f52ca00adf8dc43f7d085d02e94813dfd78b2df6 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 30 May 2026 18:35:26 -0700 Subject: [PATCH 06/33] fix(subagents): Require explicit root markdown paths Avoid discovering arbitrary root-level Markdown files as subagents. Conventional agents directories still auto-discover subagents, while root files can be imported with an explicit path. Co-Authored-By: Codex --- docs/public/llms.txt | 2 +- docs/src/content/docs/cli.mdx | 2 +- .../src/agents/subagent-store.test.ts | 23 +++++++++++++++++++ .../dotagents/src/agents/subagent-store.ts | 1 - .../src/cli/commands/install.test.ts | 6 +++++ .../dotagents/src/cli/commands/sync.test.ts | 4 ++++ specs/subagents.md | 15 ++++++------ 7 files changed, 42 insertions(+), 11 deletions(-) diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 1271245..e046397 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -227,7 +227,7 @@ Each `[[subagents]]` entry requires `name` and `source`. Optional: `ref`, `path` dotagents treats subagents as best-effort portable dependencies, not a universal behavior schema. Runtime-specific behavior such as model routing, tool permissions, read-only modes, background execution, and reasoning effort stays in each tool's native artifact. -dotagents discovers portable subagent Markdown from `agents/` and `.agents/agents/`. It also imports native runtime artifacts from `.claude/agents/*.md`, `.cursor/agents/*.md`, `.codex/agents/*.toml`, and `.opencode/agents/*.md`. Multiple portable matches for the same subagent are rejected as ambiguous, while matching native runtime artifacts are merged. When the source format matches a target runtime, dotagents reuses the native source content for that runtime and only adds its managed-file marker. Other runtimes are generated from the portable `name`, `description`, and instructions. +dotagents discovers portable subagent Markdown from `agents/` and `.agents/agents/`. It also imports native runtime artifacts from `.claude/agents/*.md`, `.cursor/agents/*.md`, `.codex/agents/*.toml`, and `.opencode/agents/*.md`. Root-level source files require an explicit `path`. Multiple portable matches for the same subagent are rejected as ambiguous, while matching native runtime artifacts are merged. When the source format matches a target runtime, dotagents reuses the native source content for that runtime and only adds its managed-file marker. Other runtimes are generated from the portable `name`, `description`, and instructions. Accepted source/output formats: diff --git a/docs/src/content/docs/cli.mdx b/docs/src/content/docs/cli.mdx index a7f47c2..dcaf9be 100644 --- a/docs/src/content/docs/cli.mdx +++ b/docs/src/content/docs/cli.mdx @@ -385,7 +385,7 @@ Status output: dotagents treats subagents as best-effort portable dependencies, not a universal behavior schema. Runtime-specific behavior such as model routing, tool permissions, read-only modes, background execution, and reasoning effort stays in each tool's native artifact. -dotagents discovers portable subagent Markdown from `agents/` and `.agents/agents/`. It also imports native runtime artifacts from `.claude/agents/*.md`, `.cursor/agents/*.md`, `.codex/agents/*.toml`, and `.opencode/agents/*.md`. Multiple portable matches for the same subagent are rejected as ambiguous, while matching native runtime artifacts are merged. When the source format matches a target runtime, dotagents reuses the native source content for that runtime and only adds its managed-file marker. Other runtimes are generated from the portable `name`, `description`, and instructions. +dotagents discovers portable subagent Markdown from `agents/` and `.agents/agents/`. It also imports native runtime artifacts from `.claude/agents/*.md`, `.cursor/agents/*.md`, `.codex/agents/*.toml`, and `.opencode/agents/*.md`. Root-level source files require an explicit `path`. Multiple portable matches for the same subagent are rejected as ambiguous, while matching native runtime artifacts are merged. When the source format matches a target runtime, dotagents reuses the native source content for that runtime and only adds its managed-file marker. Other runtimes are generated from the portable `name`, `description`, and instructions. | Format | Source path | Matching output path | Required source fields | | --- | --- | --- | --- | diff --git a/packages/dotagents/src/agents/subagent-store.test.ts b/packages/dotagents/src/agents/subagent-store.test.ts index cc177bc..987cb13 100644 --- a/packages/dotagents/src/agents/subagent-store.test.ts +++ b/packages/dotagents/src/agents/subagent-store.test.ts @@ -49,6 +49,29 @@ describe("resolveSubagent", () => { expect(resolved.subagent.description).toBe("Review code for correctness."); }); + it("does not implicitly discover root markdown files", async () => { + await writeFile(join(repoDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await expect( + resolveSubagent(subagentConfig(), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }), + ).rejects.toThrow('Subagent "code-reviewer" not found in path:repo.'); + }); + + it("imports root markdown only when path is explicit", async () => { + await writeFile(join(repoDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + const resolved = await resolveSubagent(subagentConfig({ path: "code-reviewer.md" }), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }); + + expect(resolved.resolvedPath).toBeUndefined(); + expect(resolved.subagent.name).toBe("code-reviewer"); + }); + it("imports Codex TOML subagents as portable declarations with native content", async () => { await mkdir(join(repoDir, ".codex", "agents"), { recursive: true }); await writeFile( diff --git a/packages/dotagents/src/agents/subagent-store.ts b/packages/dotagents/src/agents/subagent-store.ts index c0280aa..9f3bec6 100644 --- a/packages/dotagents/src/agents/subagent-store.ts +++ b/packages/dotagents/src/agents/subagent-store.ts @@ -43,7 +43,6 @@ interface DiscoveredSubagent { } const SUBAGENT_SCAN_DIRS: readonly SubagentScanDir[] = [ - { dir: ".", flat: true, extensions: [".md"] }, { dir: "agents", extensions: [".md"] }, { dir: ".agents/agents", extensions: [".md"] }, { dir: ".claude/agents", nativeTarget: "claude", extensions: [".md"] }, diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index b456408..41757bd 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -253,6 +253,7 @@ agents = ["claude", "codex", "opencode"] [[subagents]] name = "code-reviewer" source = "path:agents" +path = "code-reviewer.md" `, ); @@ -292,6 +293,7 @@ source = "path:agents" [[subagents]] name = "code-reviewer" source = "path:agents" +path = "code-reviewer.md" `, ); @@ -311,6 +313,7 @@ source = "path:agents" [[subagents]] name = "code-reviewer" source = "path:agents" +path = "code-reviewer.md" `, ); @@ -339,6 +342,7 @@ source = "path:agents" [[subagents]] name = "code-reviewer" source = "path:agents" +path = "code-reviewer.md" `, ); @@ -462,6 +466,7 @@ agents = ["claude"] [[subagents]] name = "code-reviewer" source = "path:agents" +path = "code-reviewer.md" `, ); @@ -497,6 +502,7 @@ agents = ["vscode"] [[subagents]] name = "reviewer" source = "path:agents" +path = "reviewer.md" `, ); diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index f4200c6..1478d07 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -402,6 +402,7 @@ agents = ["claude"] [[subagents]] name = "reviewer" source = "path:agents" +path = "reviewer.md" `, ); @@ -427,6 +428,7 @@ agents = ["claude"] [[subagents]] name = "reviewer" source = "path:agents" +path = "reviewer.md" `, ); const agentsDir = join(projectRoot, ".claude", "agents"); @@ -457,6 +459,7 @@ agents = ["claude"] [[subagents]] name = "reviewer" source = "path:agents" +path = "reviewer.md" `, ); const agentsDir = join(projectRoot, ".claude", "agents"); @@ -489,6 +492,7 @@ agents = ["claude"] [[subagents]] name = "reviewer" source = "path:agents" +path = "reviewer.md" `, ); const agentsDir = join(projectRoot, ".claude", "agents"); diff --git a/specs/subagents.md b/specs/subagents.md index edab6f8..6f40355 100644 --- a/specs/subagents.md +++ b/specs/subagents.md @@ -74,17 +74,16 @@ When installing the Codex example to Codex, dotagents preserves `name = "code_re Without an explicit `path`, dotagents scans source directories in this order: -1. `*.md` at the source root, flat only -2. `agents/**/*.md` -3. `.agents/agents/**/*.md` -4. `.claude/agents/**/*.md` -5. `.cursor/agents/**/*.md` -6. `.codex/agents/**/*.toml` -7. `.opencode/agents/**/*.md` +1. `agents/**/*.md` +2. `.agents/agents/**/*.md` +3. `.claude/agents/**/*.md` +4. `.cursor/agents/**/*.md` +5. `.codex/agents/**/*.toml` +6. `.opencode/agents/**/*.md` Within each scanned directory, filename matches for `agents.toml` `name` take precedence over metadata-only matches. Multiple filename or metadata matches in one scanned directory are rejected as ambiguous. Multiple portable matches across portable scan directories are also rejected, because dotagents cannot safely choose between competing portable instructions. Matching artifacts from multiple runtimes are merged into one subagent declaration so native Claude and native Codex sources for the same portable ID can both be preserved. -If `path` is set, dotagents imports only that file. A `.toml` path is treated as Codex native. Markdown under `.claude/agents/`, `.cursor/agents/`, or `.opencode/agents/` is treated as native for that runtime; other Markdown is treated as portable. +If `path` is set, dotagents imports only that file. Root-level Markdown files are not discovered implicitly; use `path` when a source stores a subagent at the source root. A `.toml` path is treated as Codex native. Markdown under `.claude/agents/`, `.cursor/agents/`, or `.opencode/agents/` is treated as native for that runtime; other Markdown is treated as portable. ## Naming From 1f1f57cd1d7b9457e5e4014b8d590f9b6f302ba8 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 30 May 2026 18:40:26 -0700 Subject: [PATCH 07/33] fix(subagents): Preserve stale gitignore entries in doctor Include lockfile subagents when doctor recreates .agents/.gitignore. This keeps managed installed subagent files ignored until install or sync can prune stale state. Co-Authored-By: Codex --- .../dotagents/src/cli/commands/doctor.test.ts | 14 ++++++++++++++ packages/dotagents/src/cli/commands/doctor.ts | 16 +++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/dotagents/src/cli/commands/doctor.test.ts b/packages/dotagents/src/cli/commands/doctor.test.ts index 27a7fa0..ae9e3c0 100644 --- a/packages/dotagents/src/cli/commands/doctor.test.ts +++ b/packages/dotagents/src/cli/commands/doctor.test.ts @@ -200,5 +200,19 @@ describe("runDoctor", () => { expect(existsSync(join(projectRoot, ".agents", ".gitignore"))).toBe(true); }); + + it("includes lockfile subagents when recreating .agents/.gitignore", async () => { + await writeFile(join(projectRoot, "agents.toml"), "version = 1\n"); + await writeFile(join(projectRoot, ".gitignore"), "agents.lock\n.agents/.gitignore\n"); + await writeFile( + join(projectRoot, "agents.lock"), + `version = 1\n\n[skills]\n\n[subagents.old-reviewer]\nsource = "path:agents"\n`, + ); + + await runDoctor({ scope: resolveScope("project", projectRoot), fix: true }); + + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).toContain("/agents/old-reviewer.md"); + }); }); }); diff --git a/packages/dotagents/src/cli/commands/doctor.ts b/packages/dotagents/src/cli/commands/doctor.ts index 8817dfa..af6a9b1 100644 --- a/packages/dotagents/src/cli/commands/doctor.ts +++ b/packages/dotagents/src/cli/commands/doctor.ts @@ -150,6 +150,7 @@ export async function runDoctor(opts: DoctorOptions): Promise { checks.push({ name: ".agents/.gitignore", status: "ok", message: ".agents/.gitignore exists." }); } else { const managedNames = getManagedSkillNames(config, lockfile); + const managedSubagentNames = getManagedSubagentNames(config, lockfile); checks.push({ name: ".agents/.gitignore", status: "warn", @@ -158,7 +159,7 @@ export async function runDoctor(opts: DoctorOptions): Promise { await writeAgentsGitignore( scope.agentsDir, managedNames, - config.subagents.map((subagent) => subagent.name), + managedSubagentNames, ); }, }); @@ -281,6 +282,19 @@ function getManagedSkillNames( }); } +function getManagedSubagentNames( + config: Awaited>, + lockfile: Awaited>, +): string[] { + const names = new Set(config.subagents.map((subagent) => subagent.name)); + if (lockfile) { + for (const name of Object.keys(lockfile.subagents)) { + names.add(name); + } + } + return [...names]; +} + export default async function doctor(args: string[], flags?: { user?: boolean }): Promise { const { values } = parseArgs({ args, From c48c5513a04152fac3ad2d895048ce9d31215776 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 30 May 2026 19:00:05 -0700 Subject: [PATCH 08/33] fix(subagents): Limit runtime pruning to configured agents Prefer native runtime directories over TOML extension inference. Reject TOML files under non-Codex native subagent directories. This avoids importing them as Codex artifacts. Only register configured runtime directories for subagent pruning. This avoids deleting managed files for runtimes outside the current config. Co-Authored-By: OpenAI Codex --- .../src/agents/subagent-store.test.ts | 20 +++++++++++++++++++ .../dotagents/src/agents/subagent-store.ts | 6 +++++- .../src/agents/subagent-writer.test.ts | 6 +++--- .../dotagents/src/agents/subagent-writer.ts | 9 +++++---- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/dotagents/src/agents/subagent-store.test.ts b/packages/dotagents/src/agents/subagent-store.test.ts index 987cb13..726785a 100644 --- a/packages/dotagents/src/agents/subagent-store.test.ts +++ b/packages/dotagents/src/agents/subagent-store.test.ts @@ -72,6 +72,26 @@ describe("resolveSubagent", () => { expect(resolved.subagent.name).toBe("code-reviewer"); }); + it("does not classify TOML under another native runtime directory as Codex", async () => { + await mkdir(join(repoDir, ".claude", "agents"), { recursive: true }); + await writeFile( + join(repoDir, ".claude", "agents", "code-reviewer.toml"), + [ + 'name = "code_reviewer"', + 'description = "Review code for correctness."', + 'developer_instructions = "Review the current diff."', + "", + ].join("\n"), + ); + + await expect( + resolveSubagent(subagentConfig({ path: ".claude/agents/code-reviewer.toml" }), { + stateDir: join(tmpDir, "state"), + projectRoot: tmpDir, + }), + ).rejects.toThrow('Unsupported claude subagent file extension ".toml"'); + }); + it("imports Codex TOML subagents as portable declarations with native content", async () => { await mkdir(join(repoDir, ".codex", "agents"), { recursive: true }); await writeFile( diff --git a/packages/dotagents/src/agents/subagent-store.ts b/packages/dotagents/src/agents/subagent-store.ts index 9f3bec6..334dbd2 100644 --- a/packages/dotagents/src/agents/subagent-store.ts +++ b/packages/dotagents/src/agents/subagent-store.ts @@ -304,6 +304,9 @@ async function loadSubagentFile( } = {}, ): Promise { if (extname(filePath) === ".toml") { + if (opts.nativeTarget && opts.nativeTarget !== "codex") { + throw new Error(`Unsupported ${opts.nativeTarget} subagent file extension ".toml": ${filePath}`); + } return loadCodexSubagentFile(filePath, opts); } @@ -559,10 +562,11 @@ function relativePath(root: string, filePath: string): string { function inferNativeTarget(filePath: string): NativeSubagentTarget | undefined { const normalized = filePath.replaceAll("\\", "/"); - if (normalized.endsWith(".toml")) {return "codex";} if (normalized.includes(".claude/agents/")) {return "claude";} if (normalized.includes(".cursor/agents/")) {return "cursor";} + if (normalized.includes(".codex/agents/")) {return "codex";} if (normalized.includes(".opencode/agents/")) {return "opencode";} + if (normalized.endsWith(".toml")) {return "codex";} return undefined; } diff --git a/packages/dotagents/src/agents/subagent-writer.test.ts b/packages/dotagents/src/agents/subagent-writer.test.ts index 02b2432..b8fc4fe 100644 --- a/packages/dotagents/src/agents/subagent-writer.test.ts +++ b/packages/dotagents/src/agents/subagent-writer.test.ts @@ -288,7 +288,7 @@ Hand-written instructions. expect(existsSync(join(targetDir, "code-reviewer.md"))).toBe(true); }); - it("prunes managed files for runtimes no longer listed in agents", async () => { + it("does not prune managed files for runtimes not listed in agents", async () => { const targetDir = join(dir, ".codex", "agents"); await mkdir(targetDir, { recursive: true }); const stalePath = join(targetDir, "code-reviewer.toml"); @@ -300,8 +300,8 @@ Hand-written instructions. const result = await writeSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); - expect(result.pruned).toEqual([stalePath]); - expect(existsSync(stalePath)).toBe(false); + expect(result.pruned).toEqual([]); + expect(existsSync(stalePath)).toBe(true); }); it("prunes managed files when no subagents remain", async () => { diff --git a/packages/dotagents/src/agents/subagent-writer.ts b/packages/dotagents/src/agents/subagent-writer.ts index 64f1338..310be0b 100644 --- a/packages/dotagents/src/agents/subagent-writer.ts +++ b/packages/dotagents/src/agents/subagent-writer.ts @@ -1,7 +1,7 @@ import { existsSync } from "node:fs"; import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { allAgents, getAgent } from "./registry.js"; +import { getAgent } from "./registry.js"; import { DOTAGENTS_SUBAGENT_MARKER } from "./definitions/helpers.js"; import { generatedSubagentIdentity, readSubagentFileIdentity } from "./subagent-identity.js"; import type { SubagentConfigSpec, SubagentDeclaration } from "./types.js"; @@ -237,9 +237,10 @@ function initDesiredDirs( ): Map { const desiredByDir = new Map(); const configuredAgents = new Set(agentIds); - for (const agent of allAgents()) { - if (!agent.subagents) {continue;} - const { dirPath } = resolveTarget(agent.id, agent.subagents); + for (const agentId of configuredAgents) { + const agent = getAgent(agentId); + if (!agent?.subagents) {continue;} + const { dirPath } = resolveTarget(agentId, agent.subagents); markDesired(desiredByDir, dirPath, agent.subagents.fileExtension); } From 82897a1edce0c6ea76e308e24af27098ef5ee400 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 30 May 2026 19:24:07 -0700 Subject: [PATCH 09/33] fix(subagents): Report install file conflicts cleanly Convert unmanaged canonical subagent file conflicts into typed store errors and InstallError at the install boundary. This preserves user-owned files while letting the CLI print a clean message instead of surfacing an unexpected exception. Co-Authored-By: OpenAI Codex --- .../src/agents/subagent-store.test.ts | 24 ++++++++++- .../dotagents/src/agents/subagent-store.ts | 9 ++++- .../src/cli/commands/install.test.ts | 34 ++++++++++++++++ .../dotagents/src/cli/commands/install.ts | 40 +++++++++++++------ 4 files changed, 92 insertions(+), 15 deletions(-) diff --git a/packages/dotagents/src/agents/subagent-store.test.ts b/packages/dotagents/src/agents/subagent-store.test.ts index 726785a..17b5326 100644 --- a/packages/dotagents/src/agents/subagent-store.test.ts +++ b/packages/dotagents/src/agents/subagent-store.test.ts @@ -1,9 +1,14 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { loadInstalledSubagents, resolveSubagent, writeInstalledSubagents } from "./subagent-store.js"; +import { + InstalledSubagentWriteError, + loadInstalledSubagents, + resolveSubagent, + writeInstalledSubagents, +} from "./subagent-store.js"; import type { SubagentConfig } from "../config/schema.js"; const SUBAGENT_MD = (name: string) => `--- @@ -400,6 +405,21 @@ describe("writeInstalledSubagents", () => { expect(existsSync(join(installedDir, "code-reviewer.md"))).toBe(false); }); + it("rejects unmanaged installed files without overwriting them", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + const filePath = join(installedDir, "code-reviewer.md"); + await writeFile(filePath, "hand-written subagent\n", "utf-8"); + + await expect(writeInstalledSubagents(installedDir, [{ + name: "code-reviewer", + description: "Review code for correctness.", + instructions: "Review the current diff.", + }])).rejects.toThrow(InstalledSubagentWriteError); + + expect(await readFile(filePath, "utf-8")).toBe("hand-written subagent\n"); + }); + it("roundtrips native overlays through the installed portable markdown", async () => { const installedDir = join(tmpDir, ".agents", "agents"); await writeInstalledSubagents(installedDir, [{ diff --git a/packages/dotagents/src/agents/subagent-store.ts b/packages/dotagents/src/agents/subagent-store.ts index 334dbd2..ce91dad 100644 --- a/packages/dotagents/src/agents/subagent-store.ts +++ b/packages/dotagents/src/agents/subagent-store.ts @@ -78,6 +78,13 @@ export interface InstalledSubagentLoadIssue { repairable: boolean; } +export class InstalledSubagentWriteError extends Error { + constructor(message: string) { + super(message); + this.name = "InstalledSubagentWriteError"; + } +} + export async function resolveSubagent( config: SubagentConfig, opts: SubagentResolveOptions, @@ -525,7 +532,7 @@ async function writeManagedFile(filePath: string, content: string): Promise { + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +[[subagents]] +name = "code-reviewer" +source = "path:agents" +path = "code-reviewer.md" +`, + ); + + const installedDir = join(projectRoot, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + const installedPath = join(installedDir, "code-reviewer.md"); + await writeFile(installedPath, "hand-written subagent\n", "utf-8"); + + let error: unknown; + try { + await runInstall({ scope: resolveScope("project", projectRoot) }); + } catch (err) { + error = err; + } + + expect(error).toBeInstanceOf(InstallError); + expect((error as Error).message).toContain( + "Subagent file exists and is not managed by dotagents", + ); + expect(await readFile(installedPath, "utf-8")).toBe("hand-written subagent\n"); + }); + it("frozen mode fails when a subagent is missing from the lockfile", async () => { await writeFile( join(projectRoot, "agents.toml"), diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index 44dd1d8..6209087 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -32,7 +32,12 @@ import { getAgent } from "../../agents/registry.js"; import { writeMcpConfigs, toMcpDeclarations, projectMcpResolver } from "../../agents/mcp-writer.js"; import { writeHookConfigs, toHookDeclarations, projectHookResolver } from "../../agents/hook-writer.js"; import { writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../agents/subagent-writer.js"; -import { lockEntryForSubagent, resolveSubagent, writeInstalledSubagents } from "../../agents/subagent-store.js"; +import { + InstalledSubagentWriteError, + lockEntryForSubagent, + resolveSubagent, + writeInstalledSubagents, +} from "../../agents/subagent-store.js"; import { userMcpResolver } from "../../agents/paths.js"; import type { SubagentDeclaration } from "../../agents/types.js"; import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; @@ -294,21 +299,32 @@ export async function runInstall(opts: InstallOptions): Promise { } if (config.subagents.length > 0) { for (const subagentConfig of config.subagents) { - const resolved = await resolveSubagent(subagentConfig, { - stateDir: getCacheStateDir(), - projectRoot: scope.root, - defaultRepositorySource: config.defaultRepositorySource, - minimumReleaseAge: config.minimum_release_age, - minimumReleaseAgeExclude: config.minimum_release_age_exclude, - trust: config.trust, - }); + let resolved: Awaited>; + try { + resolved = await resolveSubagent(subagentConfig, { + stateDir: getCacheStateDir(), + projectRoot: scope.root, + defaultRepositorySource: config.defaultRepositorySource, + minimumReleaseAge: config.minimum_release_age, + minimumReleaseAgeExclude: config.minimum_release_age_exclude, + trust: config.trust, + }); + } catch (err) { + if (err instanceof GitError || err instanceof TrustError) {throw err;} + const msg = err instanceof Error ? err.message : String(err); + throw new InstallError(`Failed to resolve subagent "${subagentConfig.name}": ${msg}`); + } installedSubagents.push(resolved.subagent); newLock.subagents[resolved.subagent.name] = lockEntryForSubagent(resolved); } - + } + try { await writeInstalledSubagents(subagentsDir, installedSubagents); - } else { - await writeInstalledSubagents(subagentsDir, []); + } catch (err) { + if (err instanceof InstalledSubagentWriteError) { + throw new InstallError(err.message); + } + throw err; } if (!frozen && (lockfile || config.skills.length > 0 || config.subagents.length > 0)) { From 22891bb3ac877432b649a03034ee7f90c751983c Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 9 Jun 2026 11:15:56 -0700 Subject: [PATCH 10/33] fix(subagents): Address iterate review findings Document subagent lockfile and trust behavior, keep subagent runtime pruning explicit, and avoid exposing subagent internals on the host package surface. Make git-heavy tests deterministic under the full Vitest suite and keep install test setup lazy for path-only cases. Co-Authored-By: GPT-5 Codex --- docs/public/llms.txt | 18 ++++-- docs/src/content/docs/guide.mdx | 4 +- docs/src/content/docs/index.mdx | 2 +- docs/src/content/docs/security.mdx | 21 ++++-- packages/dotagents/src/agents/index.ts | 1 + .../dotagents/src/agents/subagent-store.ts | 3 - .../src/agents/subagent-writer.test.ts | 38 ++++++----- .../dotagents/src/agents/subagent-writer.ts | 64 +++++++++++-------- .../dotagents/src/cli/commands/doctor.test.ts | 2 +- .../src/cli/commands/install.test.ts | 26 +++++++- .../dotagents/src/cli/commands/install.ts | 3 +- .../dotagents/src/cli/commands/sync.test.ts | 2 +- packages/dotagents/src/cli/commands/sync.ts | 10 ++- packages/dotagents/src/index.ts | 17 ----- .../dotagents/src/symlinks/manager.test.ts | 2 +- specs/SPEC.md | 28 +++++++- 16 files changed, 149 insertions(+), 92 deletions(-) diff --git a/docs/public/llms.txt b/docs/public/llms.txt index e046397..eba73bf 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -37,13 +37,13 @@ name = "find-bugs" source = "getsentry/skills" ``` -And a lockfile (`agents.lock`) tracking which skills are managed. Both `agents.lock` and `.agents/.gitignore` are automatically gitignored. +And a lockfile (`agents.lock`) tracking which skills and subagents are managed. Both `agents.lock` and `.agents/.gitignore` are automatically gitignored. ## How It Works 1. Declare skill dependencies in `agents.toml` at the project root (or `~/.agents/agents.toml` for user scope) 2. `install` clones or refreshes sources, discovers skills by convention, and copies them into `.agents/skills/` -3. `agents.lock` tracks which skills are managed (gitignored automatically) +3. `agents.lock` tracks which skills and subagents are managed (gitignored automatically) 4. Managed skills and canonical installed subagents under `.agents/` are gitignored. Collaborators run `npx @sentry/dotagents install` after cloning. Custom skills in `.agents/skills/` are tracked by git normally. 5. Symlinks connect `.agents/skills/` to each agent's expected location (`.claude/skills/` for Claude and Cursor) 6. MCP, hook, and subagent configs are generated for each declared agent where supported @@ -268,7 +268,7 @@ Generated files include a dotagents marker. `install` and `sync` overwrite stale ### Trust -Optional `[trust]` section to restrict allowed skill sources. +Optional `[trust]` section to restrict allowed skill and subagent sources. | Field | Type | Description | |-------|------|-------------| @@ -282,7 +282,7 @@ Rules: - `allow_all = true` = all sources allowed (explicit intent) - `[trust]` present without `allow_all` = allowlist mode (source must match at least one rule) - Local `path:` sources are always allowed -- Trust is validated before any network operations in `add` and `install` +- Trust is validated before any network operations in `add` for skills and `install` for configured skills and subagents ## CLI Commands @@ -445,7 +445,7 @@ Check project health: gitignore setup, installed skills, symlinks, and legacy co | `vscode` | VS Code Copilot | `.vscode` | (reads `.agents/skills/` natively) | `.vscode/mcp.json` | `.claude/settings.json` | Not supported | | `opencode` | OpenCode | `.opencode` | (reads `.agents/skills/` natively) | `opencode.json` | Not supported | `.opencode/agents/*.md` | -Claude and Cursor use symlinks from their config directory to `.agents/skills/`. Codex, VS Code, OpenCode, and Pi read `.agents/skills/` directly. +Claude uses `.claude/skills/`, and Cursor shares the same Claude-compatible skills symlink. Codex, VS Code, OpenCode, and Pi read `.agents/skills/` directly. [Pi](https://github.com/badlogic/pi-mono) reads `.agents/skills/` natively. No agent target or symlink configuration needed. @@ -513,6 +513,12 @@ source = "getsentry/skills" resolved_url = "https://github.com/getsentry/skills.git" resolved_path = "plugins/sentry-skills/skills/find-bugs" resolved_commit = "0123456789abcdef0123456789abcdef01234567" + +[subagents.code-reviewer] +source = "getsentry/agent-pack" +resolved_url = "https://github.com/getsentry/agent-pack.git" +resolved_path = "agents/code-reviewer.md" +resolved_commit = "fedcba9876543210fedcba9876543210fedcba98" ``` | Field | Present For | Description | @@ -523,7 +529,7 @@ resolved_commit = "0123456789abcdef0123456789abcdef01234567" | `resolved_ref` | Git sources (optional) | Resolved ref name (omitted for default branch) | | `resolved_commit` | Git sources (optional) | Full commit SHA that was installed. Informational only; install does not use it for locking. | -Local `path:` skills have `source` only. +Local `path:` skills and subagents have `source` only. Subagent entries use the same fields under `[subagents.]`; `resolved_path` points to the subagent file inside a git source. ## Caching diff --git a/docs/src/content/docs/guide.mdx b/docs/src/content/docs/guide.mdx index 1d70eae..f588944 100644 --- a/docs/src/content/docs/guide.mdx +++ b/docs/src/content/docs/guide.mdx @@ -72,8 +72,8 @@ are tracked by git normally, so collaborators get them without running install. ## Trust Policies By default, any source is allowed. For teams, add a `[trust]` section to -restrict which sources can provide skills. Trust is validated before any network -operations. +restrict which sources can provide skills and subagents. Trust is validated +before any network operations. ```shell # Trust a GitHub org diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index ed75a04..58f9f5b 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -63,7 +63,7 @@ targets = ["claude", "codex", "opencode"]`} Trust policy - Restrict skill sources before dotagents performs any network operation. + Restrict skill and subagent sources before dotagents performs any network operation.
diff --git a/docs/src/content/docs/security.mdx b/docs/src/content/docs/security.mdx index cf62a0b..a609034 100644 --- a/docs/src/content/docs/security.mdx +++ b/docs/src/content/docs/security.mdx @@ -5,9 +5,10 @@ description: Trust policies and security model for dotagents. ## Trust Policies -The `[trust]` section in `agents.toml` controls which skill sources are allowed. -Trust is validated before any network operations in `add` and `install`. If a -source does not match the policy, the command fails immediately. +The `[trust]` section in `agents.toml` controls which skill and subagent sources +are allowed. Trust is validated before any network operations in `add` for +skills and `install` for configured skills and subagents. If a source does not +match the policy, the command fails immediately. ### No Trust Section (default) @@ -70,8 +71,8 @@ allow_all = true ## Lockfile -`agents.lock` tracks which skills are managed and where they came from. It is -auto-generated and should be gitignored. +`agents.lock` tracks which skills and subagents are managed and where they came +from. It is auto-generated and should be gitignored. ```toml # Auto-generated by dotagents. Do not edit. @@ -82,6 +83,12 @@ source = "getsentry/skills" resolved_url = "https://github.com/getsentry/skills.git" resolved_path = "plugins/sentry-skills/skills/find-bugs" resolved_commit = "0123456789abcdef0123456789abcdef01234567" + +[subagents.code-reviewer] +source = "getsentry/agent-pack" +resolved_url = "https://github.com/getsentry/agent-pack.git" +resolved_path = "agents/code-reviewer.md" +resolved_commit = "fedcba9876543210fedcba9876543210fedcba98" ``` | Field | Description | @@ -92,7 +99,9 @@ resolved_commit = "0123456789abcdef0123456789abcdef01234567" | `resolved_ref` | Resolved ref name, omitted for default branch | | `resolved_commit` | Installed commit SHA. Informational only | -Local `path:` skills have `source` only. +Local `path:` skills and subagents have `source` only. Subagent entries use the +same fields under `[subagents.]`; `resolved_path` points to the subagent +file inside a git source. ## Caching diff --git a/packages/dotagents/src/agents/index.ts b/packages/dotagents/src/agents/index.ts index ed9b6cc..72d00fc 100644 --- a/packages/dotagents/src/agents/index.ts +++ b/packages/dotagents/src/agents/index.ts @@ -5,6 +5,7 @@ export { writeHookConfigs, verifyHookConfigs, toHookDeclarations, projectHookRes export type { HookTargetResolver, HookResolvedTarget } from "./hook-writer.js"; export { writeSubagentConfigs, + pruneSubagentConfigs, verifySubagentConfigs, projectSubagentResolver, userSubagentResolver, diff --git a/packages/dotagents/src/agents/subagent-store.ts b/packages/dotagents/src/agents/subagent-store.ts index ce91dad..f324a47 100644 --- a/packages/dotagents/src/agents/subagent-store.ts +++ b/packages/dotagents/src/agents/subagent-store.ts @@ -75,7 +75,6 @@ export interface ResolvedSubagent { export interface InstalledSubagentLoadIssue { name: string; issue: string; - repairable: boolean; } export class InstalledSubagentWriteError extends Error { @@ -179,7 +178,6 @@ export async function loadInstalledSubagents( issues.push({ name: config.name, issue: `Subagent "${config.name}" is in agents.toml but not installed. Run 'npx @sentry/dotagents install'.`, - repairable: false, }); continue; } @@ -193,7 +191,6 @@ export async function loadInstalledSubagents( issues.push({ name: config.name, issue: `Failed to load installed subagent "${config.name}": ${message}`, - repairable: false, }); } } diff --git a/packages/dotagents/src/agents/subagent-writer.test.ts b/packages/dotagents/src/agents/subagent-writer.test.ts index b8fc4fe..f2c4196 100644 --- a/packages/dotagents/src/agents/subagent-writer.test.ts +++ b/packages/dotagents/src/agents/subagent-writer.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { parse as parseTOML } from "smol-toml"; import { projectSubagentResolver, + pruneSubagentConfigs, verifySubagentConfigs, writeSubagentConfigs, } from "./subagent-writer.js"; @@ -227,10 +228,11 @@ Hand-written instructions. ); const result = await writeSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + const pruned = await pruneSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); expect(result.warnings).toHaveLength(1); expect(result.warnings[0]!.message).toContain("identity conflicts with unmanaged file"); - expect(result.pruned).toEqual([managedPath]); + expect(pruned).toEqual([managedPath]); expect(existsSync(managedPath)).toBe(false); expect(existsSync(unmanagedPath)).toBe(true); }); @@ -281,9 +283,10 @@ Hand-written instructions. "utf-8", ); - const result = await writeSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + await writeSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + const pruned = await pruneSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); - expect(result.pruned).toEqual([stalePath]); + expect(pruned).toEqual([stalePath]); expect(existsSync(stalePath)).toBe(false); expect(existsSync(join(targetDir, "code-reviewer.md"))).toBe(true); }); @@ -298,9 +301,10 @@ Hand-written instructions. "utf-8", ); - const result = await writeSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + await writeSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + const pruned = await pruneSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); - expect(result.pruned).toEqual([]); + expect(pruned).toEqual([]); expect(existsSync(stalePath)).toBe(true); }); @@ -314,9 +318,10 @@ Hand-written instructions. "utf-8", ); - const result = await writeSubagentConfigs(["opencode"], [], projectSubagentResolver(dir)); + await writeSubagentConfigs(["opencode"], [], projectSubagentResolver(dir)); + const pruned = await pruneSubagentConfigs(["opencode"], [], projectSubagentResolver(dir)); - expect(result.pruned).toEqual([stalePath]); + expect(pruned).toEqual([stalePath]); expect(existsSync(stalePath)).toBe(false); }); @@ -336,14 +341,18 @@ Hand-written instructions. "utf-8", ); - const result = await writeSubagentConfigs( + await writeSubagentConfigs( ["claude"], [], projectSubagentResolver(dir), - { desiredSubagents: [{ name: "code-reviewer" }] }, + ); + const pruned = await pruneSubagentConfigs( + ["claude"], + [{ name: "code-reviewer" }], + projectSubagentResolver(dir), ); - expect(result.pruned).toEqual([stalePath]); + expect(pruned).toEqual([stalePath]); expect(existsSync(declaredPath)).toBe(true); expect(existsSync(stalePath)).toBe(false); }); @@ -367,15 +376,14 @@ describe("verifySubagentConfigs", () => { expect(issues).toEqual([]); }); - it("reports missing configs as repairable", async () => { + it("reports missing configs", async () => { const issues = await verifySubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); expect(issues).toHaveLength(1); expect(issues[0]!.issue).toContain("missing"); - expect(issues[0]!.repairable).toBe(true); }); - it("reports unmanaged existing configs as not repairable", async () => { + it("reports unmanaged existing configs", async () => { const targetDir = join(dir, ".claude", "agents"); await mkdir(targetDir, { recursive: true }); await writeFile(join(targetDir, "code-reviewer.md"), "hand-written", "utf-8"); @@ -384,10 +392,9 @@ describe("verifySubagentConfigs", () => { expect(issues).toHaveLength(1); expect(issues[0]!.issue).toContain("not managed by dotagents"); - expect(issues[0]!.repairable).toBe(false); }); - it("reports unmanaged identity conflicts as not repairable", async () => { + it("reports unmanaged identity conflicts", async () => { const targetDir = join(dir, ".claude", "agents"); await mkdir(targetDir, { recursive: true }); await writeFile( @@ -406,6 +413,5 @@ Hand-written instructions. expect(issues).toHaveLength(1); expect(issues[0]!.issue).toContain("identity conflicts with unmanaged file"); - expect(issues[0]!.repairable).toBe(false); }); }); diff --git a/packages/dotagents/src/agents/subagent-writer.ts b/packages/dotagents/src/agents/subagent-writer.ts index 310be0b..0590933 100644 --- a/packages/dotagents/src/agents/subagent-writer.ts +++ b/packages/dotagents/src/agents/subagent-writer.ts @@ -24,20 +24,17 @@ export interface SubagentWriteWarning { export interface SubagentWriteResult { warnings: SubagentWriteWarning[]; written: number; - pruned: string[]; } export interface SubagentVerifyIssue { agent: string; name: string; issue: string; - repairable: boolean; } -type DesiredSubagent = Pick; - interface DesiredDir { extension: string; + spec: SubagentConfigSpec; files: Set; } @@ -57,16 +54,10 @@ export async function writeSubagentConfigs( agentIds: string[], subagents: SubagentDeclaration[], resolveTarget: SubagentTargetResolver, - opts: { desiredSubagents?: DesiredSubagent[] } = {}, ): Promise { const warnings: SubagentWriteWarning[] = []; let written = 0; const configuredAgents = new Set(agentIds); - const desiredByDir = initDesiredDirs( - agentIds, - opts.desiredSubagents ?? subagents, - resolveTarget, - ); for (const subagent of subagents) { for (const agentId of selectedAgentIds(agentIds, subagent)) { @@ -104,7 +95,6 @@ export async function writeSubagentConfigs( ); await mkdir(dirPath, { recursive: true }); - markDesired(desiredByDir, dirPath, agent.subagents.fileExtension, generated.fileName); const identityConflict = await findUnmanagedIdentityConflict( dirPath, generated.fileName, @@ -117,7 +107,6 @@ export async function writeSubagentConfigs( name: subagent.name, message: `Subagent config identity conflicts with unmanaged file: ${identityConflict}`, }); - desiredByDir.get(dirPath)?.files.delete(generated.fileName); continue; } const didWrite = await writeManagedFile(join(dirPath, generated.fileName), content, { @@ -129,8 +118,15 @@ export async function writeSubagentConfigs( } } - const pruned = await pruneManagedFiles(desiredByDir); - return { warnings, written, pruned }; + return { warnings, written }; +} + +export async function pruneSubagentConfigs( + agentIds: string[], + desiredSubagents: Pick[], + resolveTarget: SubagentTargetResolver, +): Promise { + return pruneManagedFiles(initDesiredDirs(agentIds, desiredSubagents, resolveTarget)); } export async function verifySubagentConfigs( @@ -152,7 +148,6 @@ export async function verifySubagentConfigs( issues.push({ ...issueBase, issue: `Subagent "${subagent.name}" targets agent "${agentId}", but "${agentId}" is not listed in agents`, - repairable: false, }); continue; } @@ -163,7 +158,6 @@ export async function verifySubagentConfigs( issues.push({ ...issueBase, issue: `Agent "${agent.displayName}" does not support custom subagents`, - repairable: false, }); continue; } @@ -185,7 +179,6 @@ export async function verifySubagentConfigs( issues.push({ ...issueBase, issue: `Subagent config identity conflicts with unmanaged file: ${identityConflict}`, - repairable: false, }); continue; } @@ -194,7 +187,6 @@ export async function verifySubagentConfigs( issues.push({ ...issueBase, issue: `Subagent config missing: ${filePath}`, - repairable: true, }); continue; } @@ -205,7 +197,6 @@ export async function verifySubagentConfigs( issues.push({ ...issueBase, issue: `Subagent config exists and is not managed by dotagents: ${filePath}`, - repairable: false, }); continue; } @@ -214,14 +205,12 @@ export async function verifySubagentConfigs( issues.push({ ...issueBase, issue: `Subagent config out of date: ${filePath}`, - repairable: true, }); } } catch { issues.push({ ...issueBase, issue: `Failed to read subagent config: ${filePath}`, - repairable: false, }); } } @@ -232,7 +221,7 @@ export async function verifySubagentConfigs( function initDesiredDirs( agentIds: string[], - subagents: DesiredSubagent[], + subagents: Pick[], resolveTarget: SubagentTargetResolver, ): Map { const desiredByDir = new Map(); @@ -241,7 +230,7 @@ function initDesiredDirs( const agent = getAgent(agentId); if (!agent?.subagents) {continue;} const { dirPath } = resolveTarget(agentId, agent.subagents); - markDesired(desiredByDir, dirPath, agent.subagents.fileExtension); + markDesired(desiredByDir, dirPath, agent.subagents); } for (const subagent of subagents) { @@ -255,7 +244,7 @@ function initDesiredDirs( markDesired( desiredByDir, dirPath, - agent.subagents.fileExtension, + agent.subagents, `${subagent.name}${agent.subagents.fileExtension}`, ); } @@ -266,7 +255,7 @@ function initDesiredDirs( function selectedAgentIds( agentIds: string[], - subagent: DesiredSubagent, + subagent: Pick, ): string[] { return [...new Set(subagent.targets ?? agentIds)]; } @@ -274,10 +263,14 @@ function selectedAgentIds( function markDesired( desiredByDir: Map, dirPath: string, - extension: string, + spec: SubagentConfigSpec, fileName?: string, ): void { - const desired = desiredByDir.get(dirPath) ?? { extension, files: new Set() }; + const desired = desiredByDir.get(dirPath) ?? { + extension: spec.fileExtension, + spec, + files: new Set(), + }; if (fileName) { desired.files.add(fileName); } @@ -319,10 +312,25 @@ async function pruneManagedFiles( for (const entry of entries) { if (!entry.isFile()) {continue;} if (!entry.name.endsWith(desired.extension)) {continue;} - if (desired.files.has(entry.name)) {continue;} const filePath = join(dirPath, entry.name); const existing = await readFile(filePath, "utf-8"); + if (desired.files.has(entry.name)) { + const existingIdentity = readSubagentFileIdentity( + desired.spec, + filePath, + entry.name, + existing, + ); + if (!existing.includes(DOTAGENTS_SUBAGENT_MARKER)) {continue;} + const identityConflict = await findUnmanagedIdentityConflict( + dirPath, + entry.name, + desired.spec, + existingIdentity, + ); + if (!identityConflict) {continue;} + } if (existing.includes(DOTAGENTS_SUBAGENT_MARKER)) { await rm(filePath); pruned.push(filePath); diff --git a/packages/dotagents/src/cli/commands/doctor.test.ts b/packages/dotagents/src/cli/commands/doctor.test.ts index ae9e3c0..4471cb9 100644 --- a/packages/dotagents/src/cli/commands/doctor.test.ts +++ b/packages/dotagents/src/cli/commands/doctor.test.ts @@ -123,7 +123,7 @@ describe("runDoctor", () => { expect(check?.status).toBe("warn"); expect(check?.message).toContain("agents.lock"); expect(check?.message).toContain(".agents/.gitignore"); - }); + }, 30_000); it("does not warn when generated files are not tracked", async () => { const { execSync } = await import("node:child_process"); diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 61b64f2..fc10bbc 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, mkdir, readFile, writeFile, rm, lstat, access } from "node:fs/ import { existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { runInstall, InstallError } from "./install.js"; +import { runInstall as runInstallCommand, InstallError, type InstallOptions, type InstallResult } from "./install.js"; import { runSync } from "./sync.js"; import { exec } from "@sentry/dotagents-lib"; import { loadLockfile } from "../../lockfile/loader.js"; @@ -31,19 +31,25 @@ describe("runInstall", () => { let stateDir: string; let projectRoot: string; let repoDir: string; + let repoInitialized: boolean; beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), "dotagents-install-")); stateDir = join(tmpDir, "state"); projectRoot = join(tmpDir, "project"); repoDir = join(tmpDir, "repo"); + repoInitialized = false; process.env["DOTAGENTS_STATE_DIR"] = stateDir; // Set up project await mkdir(join(projectRoot, ".agents", "skills"), { recursive: true }); - // Create a local git repo with skills + }); + + async function ensureGitRepo(): Promise { + if (repoInitialized) {return;} + await mkdir(repoDir, { recursive: true }); await exec("git", ["init"], { cwd: repoDir }); await exec("git", ["config", "user.email", "test@test.com"], { cwd: repoDir }); @@ -58,7 +64,16 @@ describe("runInstall", () => { await exec("git", ["add", "."], { cwd: repoDir }); await exec("git", ["commit", "-m", "initial"], { cwd: repoDir }); - }); + repoInitialized = true; + } + + async function runInstall(opts: InstallOptions): Promise { + const config = await readFile(opts.scope.configPath, "utf-8").catch(() => ""); + if (config.includes(`git:${repoDir}`)) { + await ensureGitRepo(); + } + return runInstallCommand(opts); + } afterEach(async () => { delete process.env["DOTAGENTS_STATE_DIR"]; @@ -713,6 +728,7 @@ path = "reviewer.md" expect(first.pruned).toHaveLength(0); // Remove "review" from upstream repo + await ensureGitRepo(); await exec("git", ["rm", "-rf", "skills/review"], { cwd: repoDir }); await exec("git", ["commit", "-m", "remove review"], { cwd: repoDir }); @@ -756,6 +772,7 @@ path = "reviewer.md" // Remove "helper" from agents.toml (keep wildcard only) // Also remove "review" from upstream so it gets pruned + await ensureGitRepo(); await exec("git", ["rm", "-rf", "skills/review"], { cwd: repoDir }); await exec("git", ["commit", "-m", "remove review"], { cwd: repoDir }); @@ -884,6 +901,7 @@ path = "reviewer.md" ); // Update the skill upstream + await ensureGitRepo(); await writeFile(join(repoDir, "pdf", "SKILL.md"), `${SKILL_MD("pdf")}\nupdated content`); await exec("git", ["add", "."], { cwd: repoDir }); await exec("git", ["commit", "-m", "update pdf skill"], { cwd: repoDir }); @@ -901,6 +919,7 @@ path = "reviewer.md" it("minimum_release_age resolves to an older commit when HEAD is too new", async () => { // Create an old commit (backdated) then a new one + await ensureGitRepo(); await exec("git", ["rm", "-rf", "pdf", "skills"], { cwd: repoDir }); await exec("git", ["commit", "-m", "clear"], { cwd: repoDir }); @@ -953,6 +972,7 @@ path = "reviewer.md" }); it("minimum_release_age rejects pinned skills that are too new", async () => { + await ensureGitRepo(); const { stdout: sha } = await exec("git", ["rev-parse", "HEAD"], { cwd: repoDir }); await writeFile( diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index 6209087..b98eb84 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -31,7 +31,7 @@ import { ensureSkillsSymlink } from "../../symlinks/manager.js"; import { getAgent } from "../../agents/registry.js"; import { writeMcpConfigs, toMcpDeclarations, projectMcpResolver } from "../../agents/mcp-writer.js"; import { writeHookConfigs, toHookDeclarations, projectHookResolver } from "../../agents/hook-writer.js"; -import { writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../agents/subagent-writer.js"; +import { pruneSubagentConfigs, writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../agents/subagent-writer.js"; import { InstalledSubagentWriteError, lockEntryForSubagent, @@ -404,6 +404,7 @@ export async function runInstall(opts: InstallOptions): Promise { installedSubagents, subagentResolver, ); + await pruneSubagentConfigs(config.agents, installedSubagents, subagentResolver); return { installed, skipped, pruned, hookWarnings, subagentWarnings: subagentResult.warnings }; } diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index 1478d07..00c33f1 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -237,7 +237,7 @@ describe("runSync", () => { process.env["DOTAGENTS_STATE_DIR"] = previousStateDir; } } - }); + }, 30_000); it("detects missing skills", async () => { await writeFile( diff --git a/packages/dotagents/src/cli/commands/sync.ts b/packages/dotagents/src/cli/commands/sync.ts index 6d0cf88..667b1d4 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -13,7 +13,7 @@ import { ensureSkillsSymlink, verifySymlinks } from "../../symlinks/manager.js"; import { getAgent } from "../../agents/registry.js"; import { verifyMcpConfigs, writeMcpConfigs, toMcpDeclarations, projectMcpResolver } from "../../agents/mcp-writer.js"; import { verifyHookConfigs, writeHookConfigs, toHookDeclarations, projectHookResolver } from "../../agents/hook-writer.js"; -import { verifySubagentConfigs, writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../agents/subagent-writer.js"; +import { pruneSubagentConfigs, verifySubagentConfigs, writeSubagentConfigs, projectSubagentResolver, userSubagentResolver } from "../../agents/subagent-writer.js"; import { loadInstalledSubagents, pruneInstalledSubagents } from "../../agents/subagent-store.js"; import { userMcpResolver } from "../../agents/paths.js"; import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; @@ -258,9 +258,13 @@ export async function runSync(opts: SyncOptions): Promise { config.agents, subagentDecls, subagentResolver, - { desiredSubagents: config.subagents }, ); - subagentsRepaired = subagentResult.written + subagentResult.pruned.length + prunedInstalledSubagents.length; + const prunedSubagentConfigs = await pruneSubagentConfigs( + config.agents, + config.subagents, + subagentResolver, + ); + subagentsRepaired = subagentResult.written + prunedSubagentConfigs.length + prunedInstalledSubagents.length; for (const issue of installedSubagentResult.issues) { issues.push({ diff --git a/packages/dotagents/src/index.ts b/packages/dotagents/src/index.ts index a7b850c..b32c1d4 100644 --- a/packages/dotagents/src/index.ts +++ b/packages/dotagents/src/index.ts @@ -19,31 +19,14 @@ export { writeMcpConfigs, verifyMcpConfigs, projectMcpResolver, - writeSubagentConfigs, - verifySubagentConfigs, - projectSubagentResolver, getUserMcpTarget, userMcpResolver, - userSubagentResolver, - resolveSubagent, - writeInstalledSubagents, - loadInstalledSubagents, - pruneInstalledSubagents, } from "./agents/index.js"; export type { AgentDefinition, McpDeclaration, McpConfigSpec, McpTargetResolver, - SubagentDeclaration, - NativeSubagentConfig, - NativeSubagentContent, - NativeSubagentTarget, - SubagentConfigSpec, - SubagentSerializer, - SubagentTargetResolver, - SubagentWriteResult, - ResolvedSubagent, } from "./agents/index.js"; export { writeAgentsGitignore, ensureRootGitignoreEntries } from "./gitignore/index.js"; diff --git a/packages/dotagents/src/symlinks/manager.test.ts b/packages/dotagents/src/symlinks/manager.test.ts index 9e0d5af..2237ee0 100644 --- a/packages/dotagents/src/symlinks/manager.test.ts +++ b/packages/dotagents/src/symlinks/manager.test.ts @@ -144,7 +144,7 @@ describe("symlinks", () => { // Verify the skill was moved to .agents/skills/ const agentsEntries = await readdir(join(agentsDir, "skills")); expect(agentsEntries).toContain("my-skill"); - }); + }, 30_000); }); describe("verifySymlinks", () => { diff --git a/specs/SPEC.md b/specs/SPEC.md index 2153af0..1559990 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -106,7 +106,7 @@ targets = ["claude", "codex", "opencode"] #### `[trust]` -Optional section to restrict which skill sources are allowed. Useful for teams that want to lock down skill provenance. +Optional section to restrict which skill and subagent sources are allowed. Useful for teams that want to lock down agent dependency provenance. ```toml # Restrictive: only allow specific sources @@ -135,7 +135,7 @@ allow_all = true - `git_domains` entries match by prefix: `gitlab.com` matches all repos on GitLab, `gitlab.com/myorg` matches repos under that org, `gitlab.com/myorg/repo` matches only that repo - Local `path:` sources are always allowed (already sandboxed to project root) -Trust is checked before any network work in both `dotagents add` and `dotagents install`. +Trust is checked before any network work in `dotagents add` for skills and `dotagents install` for configured skills and subagents. #### `[project]` @@ -370,6 +370,15 @@ resolved_url = "https://cli.sentry.dev" [skills.my-custom-skill] source = "path:../shared-skills/my-custom-skill" + +[subagents.code-reviewer] +source = "getsentry/agent-pack" +resolved_url = "https://github.com/getsentry/agent-pack.git" +resolved_path = "agents/code-reviewer.md" +resolved_commit = "fedcba9876543210fedcba9876543210fedcba98" + +[subagents.local-reviewer] +source = "path:../shared-agents" ``` ### Fields per skill @@ -382,6 +391,18 @@ source = "path:../shared-skills/my-custom-skill" | `resolved_ref` | Git sources (optional) | The ref that was resolved (tag/branch name). Omitted when using default branch. | | `resolved_commit` | Git sources (optional) | Full 40-char commit SHA that was installed. **Informational only** — not used for resolution. The lockfile is not checked in, so this field must never be relied on for locking behavior. | +### Fields per subagent + +Subagent lock entries use the same source-resolution fields under `[subagents.]`. Git subagents record the resolved clone URL, discovered or explicit subagent file path, optional ref, and installed commit. Local `path:` subagents record `source` only. + +| Field | Present For | Description | +|-------|-------------|-------------| +| `source` | All | Original source specifier from agents.toml. | +| `resolved_url` | Git sources | Resolved clone URL. | +| `resolved_path` | Git sources | File path within the repo where the subagent was discovered or loaded from. | +| `resolved_ref` | Git sources (optional) | The ref that was resolved (tag/branch name). Omitted when using default branch. | +| `resolved_commit` | Git sources (optional) | Full 40-char commit SHA that was installed. Informational only. | + --- ## CLI Commands @@ -655,9 +676,10 @@ Managed (external) skills and canonical installed subagent files are gitignored. ```gitignore # Auto-generated by dotagents. Do not edit. -# Managed skills (installed by dotagents) +# Managed artifacts (installed by dotagents) /skills/find-bugs/ /skills/warden-skill/ +/agents/code-reviewer.md ``` Custom skills in `.agents/skills/my-local-skill/` are NOT listed, so git tracks them normally. From 7d6ab68567000f9c362bd6db26f8b10e185be826 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 9 Jun 2026 12:54:19 -0700 Subject: [PATCH 11/33] fix(agents): Prune stale managed skills safely Remove stale managed skill directories whenever install rewrites them out of the lockfile, while preserving in-place local skills. Guard stale deletion with a safe managed skill path so malformed lockfile keys cannot delete unrelated files. Also tolerate BOM-prefixed markdown frontmatter with spaces after the opening marker when parsing or marking native subagent files. Co-Authored-By: OpenAI Codex --- .../dotagents-lib/src/skills/loader.test.ts | 18 +++ packages/dotagents-lib/src/skills/loader.ts | 2 +- .../src/agents/definitions/helpers.test.ts | 14 +++ .../src/agents/definitions/helpers.ts | 3 +- .../src/agents/subagent-writer.test.ts | 20 +++ .../src/cli/commands/install.test.ts | 118 ++++++++++++++++-- .../dotagents/src/cli/commands/install.ts | 25 ++-- .../dotagents/src/cli/commands/sync.test.ts | 30 +++++ packages/dotagents/src/cli/commands/sync.ts | 17 ++- packages/dotagents/src/utils/fs.test.ts | 19 +++ packages/dotagents/src/utils/fs.ts | 16 +++ 11 files changed, 256 insertions(+), 26 deletions(-) create mode 100644 packages/dotagents/src/utils/fs.test.ts diff --git a/packages/dotagents-lib/src/skills/loader.test.ts b/packages/dotagents-lib/src/skills/loader.test.ts index 0feb03b..1337caf 100644 --- a/packages/dotagents-lib/src/skills/loader.test.ts +++ b/packages/dotagents-lib/src/skills/loader.test.ts @@ -37,6 +37,24 @@ This skill handles PDF files. expect(meta["license"]).toBe("MIT"); }); + it("parses frontmatter with a BOM and spaced opener", async () => { + const skillMd = join(dir, "SKILL.md"); + await writeFile( + skillMd, + `\uFEFF--- \t +name: pdf-processing +description: Extract and process PDF documents +--- + +# PDF Processing +`, + ); + + const meta = await loadSkillMd(skillMd); + expect(meta.name).toBe("pdf-processing"); + expect(meta.description).toBe("Extract and process PDF documents"); + }); + it("handles quoted values", async () => { const skillMd = join(dir, "SKILL.md"); await writeFile( diff --git a/packages/dotagents-lib/src/skills/loader.ts b/packages/dotagents-lib/src/skills/loader.ts index 9695048..27ce41d 100644 --- a/packages/dotagents-lib/src/skills/loader.ts +++ b/packages/dotagents-lib/src/skills/loader.ts @@ -32,7 +32,7 @@ export interface LoadSkillMdOptions { onWarning?: (message: string) => void; } -const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/; +const FRONTMATTER_RE = /^\uFEFF?---[ \t]*\r?\n([\s\S]*?)\r?\n---/; /** * Parse a SKILL.md file and extract YAML frontmatter. diff --git a/packages/dotagents/src/agents/definitions/helpers.test.ts b/packages/dotagents/src/agents/definitions/helpers.test.ts index 7d8ba2a..edf3fc8 100644 --- a/packages/dotagents/src/agents/definitions/helpers.test.ts +++ b/packages/dotagents/src/agents/definitions/helpers.test.ts @@ -4,7 +4,9 @@ import { interpolateEnvRefs, interpolateHeaders, extractCodexHeaders, + markManagedMarkdownSubagent, serializeMarkdownSubagent, + DOTAGENTS_SUBAGENT_MARKER, } from "./helpers.js"; const cursorTpl = (k: string) => `\${env:${k}}`; @@ -136,3 +138,15 @@ describe("serializeMarkdownSubagent", () => { }); }); }); + +describe("markManagedMarkdownSubagent", () => { + it("marks markdown with a BOM and spaced frontmatter opener", () => { + const content = "\uFEFF--- \nname: code-reviewer\n---\n\nReview code.\n"; + + const marked = markManagedMarkdownSubagent(content); + + expect(marked).toContain(`# ${DOTAGENTS_SUBAGENT_MARKER}`); + const parsed = parseMarkdownFrontmatterContent(marked, "subagent.md"); + expect(parsed.meta["name"]).toBe("code-reviewer"); + }); +}); diff --git a/packages/dotagents/src/agents/definitions/helpers.ts b/packages/dotagents/src/agents/definitions/helpers.ts index beed1d9..2c57e0b 100644 --- a/packages/dotagents/src/agents/definitions/helpers.ts +++ b/packages/dotagents/src/agents/definitions/helpers.ts @@ -109,9 +109,10 @@ export function serializeMarkdownSubagent( return lines.join("\n"); } +/** Insert the dotagents marker into markdown subagent frontmatter. */ export function markManagedMarkdownSubagent(content: string): string { if (content.includes(DOTAGENTS_SUBAGENT_MARKER)) {return content;} - return content.replace(/^---\r?\n/, (opening) => `${opening}# ${DOTAGENTS_SUBAGENT_MARKER}\n`); + return content.replace(/^(\uFEFF?---[ \t]*\r?\n)/, (opening) => `${opening}# ${DOTAGENTS_SUBAGENT_MARKER}\n`); } export function serializeCodexSubagent( diff --git a/packages/dotagents/src/agents/subagent-writer.test.ts b/packages/dotagents/src/agents/subagent-writer.test.ts index f2c4196..5632646 100644 --- a/packages/dotagents/src/agents/subagent-writer.test.ts +++ b/packages/dotagents/src/agents/subagent-writer.test.ts @@ -134,6 +134,26 @@ describe("writeSubagentConfigs", () => { expect(cursor).toContain("Review the current diff and return findings."); }); + it("marks native markdown that has a BOM and spaced frontmatter opener", async () => { + const result = await writeSubagentConfigs( + ["claude"], + [{ + ...SUBAGENT, + native: { + claude: "\uFEFF--- \nname: code-reviewer\ndescription: Review code.\n---\n\nNative instructions.\n", + }, + }], + projectSubagentResolver(dir), + ); + + expect(result.warnings).toEqual([]); + expect(result.written).toBe(1); + + const content = await readFile(join(dir, ".claude", "agents", "code-reviewer.md"), "utf-8"); + expect(content).toContain(DOTAGENTS_SUBAGENT_MARKER); + expect(content).toContain("Native instructions."); + }); + it("respects explicit targets", async () => { await writeSubagentConfigs( ["claude", "codex"], diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index fc10bbc..758297f 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -7,6 +7,7 @@ import { runInstall as runInstallCommand, InstallError, type InstallOptions, typ import { runSync } from "./sync.js"; import { exec } from "@sentry/dotagents-lib"; import { loadLockfile } from "../../lockfile/loader.js"; +import { writeLockfile } from "../../lockfile/writer.js"; import { resolveScope } from "../../scope.js"; import { DOTAGENTS_SUBAGENT_MARKER } from "../../agents/definitions/helpers.js"; @@ -374,13 +375,73 @@ path = "code-reviewer.md" }); it("clears removed skills from the lockfile when installing subagents", async () => { + const scope = resolveScope("project", projectRoot); + const skillDir = join(projectRoot, ".agents", "skills", "pdf"); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, "SKILL.md"), SKILL_MD("pdf")); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: { + pdf: { + source: "git:https://github.com/example/repo.git", + resolved_url: "https://github.com/example/repo.git", + resolved_path: "pdf", + resolved_commit: "abc123", + }, + }, + }); + + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + await writeFile( join(projectRoot, "agents.toml"), - `version = 1\n\n[[skills]]\nname = "pdf"\nsource = "git:${repoDir}"\n`, + `version = 1 +[[subagents]] +name = "code-reviewer" +source = "path:agents" +path = "code-reviewer.md" +`, ); - const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.skills).toEqual({}); + expect(lockfile!.subagents["code-reviewer"]?.source).toBe("path:agents"); + expect(existsSync(skillDir)).toBe(false); + + const syncResult = await runSync({ scope }); + expect(syncResult.adopted).toEqual([]); + }); + + it("does not prune outside skills dir for malformed lockfile skill names", async () => { + const scope = resolveScope("project", projectRoot); + const hooksDir = join(projectRoot, ".agents", "hooks"); + const pdfDir = join(projectRoot, ".agents", "skills", "pdf"); + await mkdir(hooksDir, { recursive: true }); + await mkdir(pdfDir, { recursive: true }); + await writeFile(join(hooksDir, "keep.sh"), "echo keep\n"); + await writeFile(join(pdfDir, "SKILL.md"), SKILL_MD("pdf")); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: { + "../hooks": { + source: "git:https://github.com/example/repo.git", + resolved_url: "https://github.com/example/repo.git", + resolved_path: "pdf", + resolved_commit: "abc123", + }, + "stale/../pdf": { + source: "git:https://github.com/example/repo.git", + resolved_url: "https://github.com/example/repo.git", + resolved_path: "pdf", + resolved_commit: "abc123", + }, + }, + }); + const sourceDir = join(projectRoot, "agents"); await mkdir(sourceDir, { recursive: true }); await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); @@ -397,9 +458,51 @@ path = "code-reviewer.md" await runInstall({ scope }); + expect(await readFile(join(hooksDir, "keep.sh"), "utf-8")).toBe("echo keep\n"); + expect(await readFile(join(pdfDir, "SKILL.md"), "utf-8")).toBe(SKILL_MD("pdf")); const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); expect(lockfile!.skills).toEqual({}); - expect(lockfile!.subagents["code-reviewer"]?.source).toBe("path:agents"); + }); + + it("prunes removed managed skills while other skills remain configured", async () => { + const scope = resolveScope("project", projectRoot); + const localSkillDir = join(projectRoot, ".agents", "skills", "local-skill"); + const staleSkillDir = join(projectRoot, ".agents", "skills", "pdf"); + await mkdir(localSkillDir, { recursive: true }); + await mkdir(staleSkillDir, { recursive: true }); + await writeFile(join(localSkillDir, "SKILL.md"), SKILL_MD("local-skill")); + await writeFile(join(staleSkillDir, "SKILL.md"), SKILL_MD("pdf")); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: { + "local-skill": { source: "path:.agents/skills/local-skill" }, + pdf: { + source: "git:https://github.com/example/repo.git", + resolved_url: "https://github.com/example/repo.git", + resolved_path: "pdf", + resolved_commit: "abc123", + }, + }, + }); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 + +[[skills]] +name = "local-skill" +source = "path:.agents/skills/local-skill" +`, + ); + + const result = await runInstall({ scope }); + + expect(result.pruned).toEqual(["pdf"]); + expect(existsSync(staleSkillDir)).toBe(false); + expect(existsSync(join(localSkillDir, "SKILL.md"))).toBe(true); + + const syncResult = await runSync({ scope }); + expect(syncResult.adopted).toEqual([]); }); it("preserves native Codex content through install and sync", async () => { @@ -749,7 +852,7 @@ path = "reviewer.md" expect(lock!.skills["pdf"]).toBeDefined(); }); - it("does not prune skills whose source does not match a wildcard", async () => { + it("prunes stale managed skills whose source does not match a wildcard", async () => { // Create a second repo with a "helper" skill const repoDir2 = join(tmpDir, "repo2"); await mkdir(repoDir2, { recursive: true }); @@ -785,10 +888,9 @@ path = "reviewer.md" // "review" was from the wildcard source and was removed upstream — should be pruned expect(result.pruned).toContain("review"); - // "helper" was explicit from a different source — should NOT be pruned - expect(result.pruned).not.toContain("helper"); - // helper's directory should still exist on disk - expect(existsSync(join(projectRoot, ".agents", "skills", "helper", "SKILL.md"))).toBe(true); + // "helper" was removed from config, so it should also be pruned + expect(result.pruned).toContain("helper"); + expect(existsSync(join(projectRoot, ".agents", "skills", "helper"))).toBe(false); }); it("prunes skills added to wildcard exclude list", async () => { diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index b98eb84..2363c4e 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -23,7 +23,7 @@ import { GitError, copyDir, } from "@sentry/dotagents-lib"; -import { isInPlaceSkill } from "../../utils/fs.js"; +import { isInPlaceSkill, managedSkillPath } from "../../utils/fs.js"; import { getCacheStateDir, HOST_SCAN_DIRS } from "../cache.js"; import { formatGitError, formatTrustError } from "../errors.js"; import { writeAgentsGitignore, checkRootGitignoreEntries } from "../../gitignore/writer.js"; @@ -276,20 +276,25 @@ export async function runInstall(opts: InstallOptions): Promise { installed.push(name); } - // Prune stale wildcard-sourced skills (skip in frozen mode to avoid disk/lockfile inconsistency) + // Prune stale managed skills (skip in frozen mode to avoid disk/lockfile inconsistency) if (!frozen && lockfile) { - const wildcardDeps = config.skills.filter(isWildcardDep); for (const [name, locked] of Object.entries(lockfile.skills)) { if (newLock.skills[name]) {continue;} // still tracked - const fromWildcard = wildcardDeps.some((w) => - sourcesMatch(locked.source, w.source), - ); - if (fromWildcard) { - await rm(join(skillsDir, name), { recursive: true, force: true }); - pruned.push(name); - } + if (isInPlaceSkill(locked.source)) {continue;} + const skillPath = managedSkillPath(skillsDir, name); + if (!skillPath) {continue;} + await rm(skillPath, { recursive: true, force: true }); + pruned.push(name); } } + } else if (!frozen && lockfile) { + for (const [name, locked] of Object.entries(lockfile.skills)) { + if (isInPlaceSkill(locked.source)) {continue;} + const skillPath = managedSkillPath(skillsDir, name); + if (!skillPath) {continue;} + await rm(skillPath, { recursive: true, force: true }); + pruned.push(name); + } } // 3. Resolve and install subagent markdown files diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index 00c33f1..a844b3d 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -128,6 +128,36 @@ describe("runSync", () => { expect(lockfile!.skills["pdf"]).toBeUndefined(); }); + it("does not report malformed stale skill names as pruned", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + "version = 1\n", + ); + const managedDir = join(projectRoot, ".agents", "skills", "bad name"); + await mkdir(managedDir, { recursive: true }); + await writeFile(join(managedDir, "SKILL.md"), SKILL_MD("bad name")); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: { + "bad name": { + source: "org/repo", + resolved_url: "https://github.com/org/repo.git", + resolved_path: "bad-name", + }, + }, + }); + + const result = await runSync({ scope: resolveScope("project", projectRoot) }); + + expect(result.adopted).toHaveLength(0); + expect(result.pruned).toHaveLength(0); + expect(existsSync(managedDir)).toBe(true); + + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile).not.toBeNull(); + expect(lockfile!.skills["bad name"]).toBeDefined(); + }); + it("prunes wildcard skills newly excluded from config", async () => { await writeFile( join(projectRoot, "agents.toml"), diff --git a/packages/dotagents/src/cli/commands/sync.ts b/packages/dotagents/src/cli/commands/sync.ts index 667b1d4..1463806 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -18,7 +18,7 @@ import { loadInstalledSubagents, pruneInstalledSubagents } from "../../agents/su import { userMcpResolver } from "../../agents/paths.js"; import { resolveScope, resolveDefaultScope, ScopeError, type ScopeRoot } from "../../scope.js"; import { ensureUserScopeBootstrapped } from "../ensure-user-scope.js"; -import { isInPlaceSkill } from "../../utils/fs.js"; +import { isInPlaceSkill, managedSkillPath } from "../../utils/fs.js"; export interface SyncIssue { type: "symlink" | "missing" | "mcp" | "hooks" | "subagents"; @@ -78,9 +78,11 @@ export async function runSync(opts: SyncOptions): Promise { const locked = lockfile?.skills[entry.name]; if (locked && !isInPlaceSkill(locked.source)) { - await removeStaleManagedSkill(skillsDir, entry.name); - delete lockfile!.skills[entry.name]; - pruned.push(entry.name); + const removed = await removeStaleManagedSkill(skillsDir, entry.name); + if (removed) { + delete lockfile!.skills[entry.name]; + pruned.push(entry.name); + } continue; } @@ -305,8 +307,11 @@ export async function runSync(opts: SyncOptions): Promise { }; } -async function removeStaleManagedSkill(skillsDir: string, name: string): Promise { - await rm(join(skillsDir, name), { recursive: true, force: true }); +async function removeStaleManagedSkill(skillsDir: string, name: string): Promise { + const skillPath = managedSkillPath(skillsDir, name); + if (!skillPath) {return false;} + await rm(skillPath, { recursive: true, force: true }); + return true; } export default async function sync(_args: string[], flags?: { user?: boolean }): Promise { diff --git a/packages/dotagents/src/utils/fs.test.ts b/packages/dotagents/src/utils/fs.test.ts new file mode 100644 index 0000000..d8071c7 --- /dev/null +++ b/packages/dotagents/src/utils/fs.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { join, resolve } from "node:path"; +import { managedSkillPath } from "./fs.js"; + +describe("managedSkillPath", () => { + it("resolves safe skill names inside the skills directory", () => { + const skillsDir = join("project", ".agents", "skills"); + + expect(managedSkillPath(skillsDir, "pdf")).toBe(resolve(skillsDir, "pdf")); + }); + + it("rejects path-shaped skill names", () => { + const skillsDir = join("project", ".agents", "skills"); + + expect(managedSkillPath(skillsDir, "../hooks")).toBeNull(); + expect(managedSkillPath(skillsDir, "stale/../pdf")).toBeNull(); + expect(managedSkillPath(skillsDir, "foo/bar")).toBeNull(); + }); +}); diff --git a/packages/dotagents/src/utils/fs.ts b/packages/dotagents/src/utils/fs.ts index be8ba63..49d8256 100644 --- a/packages/dotagents/src/utils/fs.ts +++ b/packages/dotagents/src/utils/fs.ts @@ -1,4 +1,20 @@ +import { isAbsolute, relative, resolve } from "node:path"; + +const SKILL_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; + /** Whether a skill source points to its own install location (adopted orphan). */ export function isInPlaceSkill(source: string): boolean { return source.startsWith("path:.agents/skills/") || source.startsWith("path:skills/"); } + +/** Resolve a managed skill directory, rejecting paths outside the skills root. */ +export function managedSkillPath(skillsDir: string, name: string): string | null { + if (!SKILL_NAME_RE.test(name)) {return null;} + const root = resolve(skillsDir); + const target = resolve(root, name); + const rel = relative(root, target); + if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) { + return null; + } + return target; +} From 4c5e014640e1ea9ec2468874052d26f623248d26 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 9 Jun 2026 14:28:23 -0700 Subject: [PATCH 12/33] fix(agents): Handle subagent pruning edge cases Treat empty subagent targets as every configured agent and make managed marker detection format-aware so body text is not trusted. Keep frozen installs from pruning managed subagent files or dropping their gitignore entries, while normal installs still prune explicitly. Co-Authored-By: Codex --- README.md | 2 + docs/public/llms.txt | 8 +- docs/src/content/docs/cli.mdx | 8 +- .../src/agents/definitions/helpers.test.ts | 30 ++++++ .../src/agents/definitions/helpers.ts | 25 ++++- .../src/agents/subagent-store.test.ts | 101 +++++++++++++++++- .../dotagents/src/agents/subagent-store.ts | 9 +- .../src/agents/subagent-writer.test.ts | 72 +++++++++++++ .../dotagents/src/agents/subagent-writer.ts | 28 +++-- .../src/cli/commands/install.test.ts | 38 +++++++ .../dotagents/src/cli/commands/install.ts | 13 ++- specs/SPEC.md | 6 +- specs/subagents.md | 4 +- 13 files changed, 311 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 87fc5cc..770456e 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,8 @@ source = "getsentry/agent-pack" targets = ["claude", "codex", "opencode"] ``` +If `targets` is omitted or empty, dotagents targets every configured agent and warns for agents that do not support custom subagents. + dotagents discovers portable subagent Markdown from conventional source directories such as `agents/` and `.agents/agents/`. The frontmatter supplies the portable `name` and `description`; the body supplies the runtime instructions: ```md diff --git a/docs/public/llms.txt b/docs/public/llms.txt index eba73bf..224caa5 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -223,7 +223,7 @@ Cursor event mapping: ### Subagents -Each `[[subagents]]` entry requires `name` and `source`. Optional: `ref`, `path`, and `targets`. When `targets` is absent, dotagents attempts every agent listed in `agents` and warns for agents that do not support custom subagents. +Each `[[subagents]]` entry requires `name` and `source`. Optional: `ref`, `path`, and `targets`. When `targets` is absent or empty, dotagents attempts every agent listed in `agents` and warns for agents that do not support custom subagents. dotagents treats subagents as best-effort portable dependencies, not a universal behavior schema. Runtime-specific behavior such as model routing, tool permissions, read-only modes, background execution, and reasoning effort stays in each tool's native artifact. @@ -256,7 +256,7 @@ Review the current diff and return findings with file references. | `source` | string | Yes | Source repository or local directory. Supports GitHub/GitLab shorthands, git URLs, and `path:` sources; HTTPS well-known skill indexes are not supported for subagents. | | `ref` | string | No | Optional git ref override. | | `path` | string | No | Optional explicit subagent file path inside the source. Markdown paths are portable or native Markdown; `.toml` paths are Codex native artifacts. | -| `targets` | string[] | No | Optional subset of agent IDs. Unsupported configured agents produce warnings. | +| `targets` | string[] | No | Optional subset of agent IDs. When absent or empty, defaults to every configured agent in `agents`; unsupported configured agents produce warnings. | Generated subagent files: - Claude: `.claude/agents/.md`, or `~/.claude/agents/.md` for user scope @@ -264,7 +264,7 @@ Generated subagent files: - Codex: `.codex/agents/.toml`, or `~/.codex/agents/.toml` for user scope - OpenCode: `.opencode/agents/.md`, or `~/.config/opencode/agents/.md` for user scope -Generated files include a dotagents marker. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the marker. They also avoid creating duplicate runtime identities when an unmanaged file in the same agent directory already declares the same subagent. +Generated files include a dotagents header marker. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the generated header marker. In `--frozen` mode, `install` preserves existing managed subagent files and lock entries instead of pruning removed subagents. They also avoid creating duplicate runtime identities when an unmanaged file in the same agent directory already declares the same subagent. ### Trust @@ -310,7 +310,7 @@ Create `agents.toml` and `.agents/skills/` directory. Automatically includes the npx @sentry/dotagents install ``` -Install and refresh dependencies from `agents.toml`. Resolves sources, copies skills, writes the lockfile, creates symlinks, and generates MCP, hook, and subagent configs. There is no separate update command. With `--frozen`, declared skills and subagents must already exist in `agents.lock`, and the lockfile is not updated. +Install and refresh dependencies from `agents.toml`. Resolves sources, copies skills, writes the lockfile, creates symlinks, and generates MCP, hook, and subagent configs. There is no separate update command. With `--frozen`, declared skills and subagents must already exist in `agents.lock`, the lockfile is not updated, and existing managed subagent files are not pruned. ### add diff --git a/docs/src/content/docs/cli.mdx b/docs/src/content/docs/cli.mdx index dcaf9be..57680dd 100644 --- a/docs/src/content/docs/cli.mdx +++ b/docs/src/content/docs/cli.mdx @@ -58,7 +58,7 @@ Install and refresh skill and subagent dependencies from `agents.toml`. Resolves sources, copies skills, writes the lockfile, creates symlinks, and generates MCP, hook, and subagent configs. There is no separate update command. With `--frozen`, declared skills and subagents must already exist in `agents.lock`, -and the lockfile is not updated. +the lockfile is not updated, and existing managed subagent files are not pruned. Example: @@ -381,11 +381,11 @@ Status output: | `source` | string | Yes | Source repository or local directory. Supports GitHub/GitLab shorthands, git URLs, and `path:` sources; HTTPS well-known skill indexes are not supported for subagents. | | `ref` | string | No | Optional git ref override. | | `path` | string | No | Optional explicit subagent file path inside the source. Markdown paths are portable or native Markdown; `.toml` paths are Codex native artifacts. | -| `targets` | string[] | No | Optional subset of agent IDs. Unsupported configured agents produce warnings. | +| `targets` | string[] | No | Optional subset of agent IDs. When absent or empty, defaults to every configured agent in `agents`; unsupported configured agents produce warnings. | dotagents treats subagents as best-effort portable dependencies, not a universal behavior schema. Runtime-specific behavior such as model routing, tool permissions, read-only modes, background execution, and reasoning effort stays in each tool's native artifact. -dotagents discovers portable subagent Markdown from `agents/` and `.agents/agents/`. It also imports native runtime artifacts from `.claude/agents/*.md`, `.cursor/agents/*.md`, `.codex/agents/*.toml`, and `.opencode/agents/*.md`. Root-level source files require an explicit `path`. Multiple portable matches for the same subagent are rejected as ambiguous, while matching native runtime artifacts are merged. When the source format matches a target runtime, dotagents reuses the native source content for that runtime and only adds its managed-file marker. Other runtimes are generated from the portable `name`, `description`, and instructions. +dotagents discovers portable subagent Markdown from `agents/` and `.agents/agents/`. It also imports native runtime artifacts from `.claude/agents/*.md`, `.cursor/agents/*.md`, `.codex/agents/*.toml`, and `.opencode/agents/*.md`. Root-level source files require an explicit `path`. Multiple portable matches for the same subagent are rejected as ambiguous, while matching native runtime artifacts are merged. When the source format matches a target runtime, dotagents reuses the native source content for that runtime and only adds its generated header marker. Other runtimes are generated from the portable `name`, `description`, and instructions. | Format | Source path | Matching output path | Required source fields | | --- | --- | --- | --- | @@ -404,7 +404,7 @@ Generated files: - Codex: `.codex/agents/.toml` - OpenCode: `.opencode/agents/.md` -Generated files include a dotagents marker. `install` and `sync` update managed files and do not overwrite hand-written files without the marker. They also avoid creating duplicate runtime identities when an unmanaged file in the same agent directory already declares the same subagent. +Generated files include a dotagents header marker. `install` and `sync` update managed files and do not overwrite hand-written files without the generated header marker. In `--frozen` mode, `install` preserves existing managed subagent files and lock entries instead of pruning removed subagents. They also avoid creating duplicate runtime identities when an unmanaged file in the same agent directory already declares the same subagent. ## Scopes diff --git a/packages/dotagents/src/agents/definitions/helpers.test.ts b/packages/dotagents/src/agents/definitions/helpers.test.ts index edf3fc8..41f48c0 100644 --- a/packages/dotagents/src/agents/definitions/helpers.test.ts +++ b/packages/dotagents/src/agents/definitions/helpers.test.ts @@ -5,6 +5,8 @@ import { interpolateHeaders, extractCodexHeaders, markManagedMarkdownSubagent, + hasDotagentsMarkdownSubagentMarker, + hasDotagentsTomlSubagentMarker, serializeMarkdownSubagent, DOTAGENTS_SUBAGENT_MARKER, } from "./helpers.js"; @@ -149,4 +151,32 @@ describe("markManagedMarkdownSubagent", () => { const parsed = parseMarkdownFrontmatterContent(marked, "subagent.md"); expect(parsed.meta["name"]).toBe("code-reviewer"); }); + + it("does not treat marker text in the body as a managed header", () => { + const content = `--- +name: code-reviewer +--- + +The phrase ${DOTAGENTS_SUBAGENT_MARKER} appears in instructions. +`; + + const marked = markManagedMarkdownSubagent(content); + + expect(marked).toMatch(/^---\n# Generated by dotagents/); + expect(hasDotagentsMarkdownSubagentMarker(content)).toBe(false); + expect(hasDotagentsMarkdownSubagentMarker(marked)).toBe(true); + }); + + it("does not treat a TOML-style marker as a managed markdown header", () => { + const content = `# ${DOTAGENTS_SUBAGENT_MARKER} +--- +name: code-reviewer +--- + +Review the current diff. +`; + + expect(hasDotagentsMarkdownSubagentMarker(content)).toBe(false); + expect(hasDotagentsTomlSubagentMarker(content)).toBe(true); + }); }); diff --git a/packages/dotagents/src/agents/definitions/helpers.ts b/packages/dotagents/src/agents/definitions/helpers.ts index 2c57e0b..35979d7 100644 --- a/packages/dotagents/src/agents/definitions/helpers.ts +++ b/packages/dotagents/src/agents/definitions/helpers.ts @@ -2,6 +2,13 @@ import { stringify as tomlStringify } from "smol-toml"; import type { McpDeclaration, HookDeclaration } from "../types.js"; export const DOTAGENTS_SUBAGENT_MARKER = "Generated by dotagents. Edit agents.toml instead."; +const DOTAGENTS_SUBAGENT_MARKER_RE = escapeRegExp(DOTAGENTS_SUBAGENT_MARKER); +const MANAGED_MARKDOWN_SUBAGENT_RE = new RegExp( + `^\\uFEFF?---[ \\t]*\\r?\\n# ${DOTAGENTS_SUBAGENT_MARKER_RE}\\r?\\n`, +); +const MANAGED_TOML_SUBAGENT_RE = new RegExp( + `^\\uFEFF?# ${DOTAGENTS_SUBAGENT_MARKER_RE}\\r?\\n`, +); export function envRecord( env: string[] | undefined, @@ -111,10 +118,20 @@ export function serializeMarkdownSubagent( /** Insert the dotagents marker into markdown subagent frontmatter. */ export function markManagedMarkdownSubagent(content: string): string { - if (content.includes(DOTAGENTS_SUBAGENT_MARKER)) {return content;} + if (hasDotagentsMarkdownSubagentMarker(content)) {return content;} return content.replace(/^(\uFEFF?---[ \t]*\r?\n)/, (opening) => `${opening}# ${DOTAGENTS_SUBAGENT_MARKER}\n`); } +/** Whether Markdown content has the dotagents marker in its generated frontmatter header. */ +export function hasDotagentsMarkdownSubagentMarker(content: string): boolean { + return MANAGED_MARKDOWN_SUBAGENT_RE.test(content); +} + +/** Whether TOML content has the dotagents marker in its generated header. */ +export function hasDotagentsTomlSubagentMarker(content: string): boolean { + return MANAGED_TOML_SUBAGENT_RE.test(content); +} + export function serializeCodexSubagent( fields: Record, ): string { @@ -129,10 +146,14 @@ export function serializeCodexSubagent( } export function markManagedTomlSubagent(content: string): string { - if (content.includes(DOTAGENTS_SUBAGENT_MARKER)) {return content;} + if (hasDotagentsTomlSubagentMarker(content)) {return content;} return `# ${DOTAGENTS_SUBAGENT_MARKER}\n${content}`; } +function escapeRegExp(value: string): string { + return value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + function appendYamlField( lines: string[], key: string, diff --git a/packages/dotagents/src/agents/subagent-store.test.ts b/packages/dotagents/src/agents/subagent-store.test.ts index 17b5326..2520dca 100644 --- a/packages/dotagents/src/agents/subagent-store.test.ts +++ b/packages/dotagents/src/agents/subagent-store.test.ts @@ -6,9 +6,11 @@ import { tmpdir } from "node:os"; import { InstalledSubagentWriteError, loadInstalledSubagents, + pruneInstalledSubagents, resolveSubagent, writeInstalledSubagents, } from "./subagent-store.js"; +import { DOTAGENTS_SUBAGENT_MARKER } from "./definitions/helpers.js"; import type { SubagentConfig } from "../config/schema.js"; const SUBAGENT_MD = (name: string) => `--- @@ -400,11 +402,65 @@ describe("writeInstalledSubagents", () => { instructions: "Review the current diff.", }]); - await writeInstalledSubagents(installedDir, []); + await pruneInstalledSubagents(installedDir, []); expect(existsSync(join(installedDir, "code-reviewer.md"))).toBe(false); }); + it("does not prune stale managed files while writing", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await writeInstalledSubagents(installedDir, [{ + name: "code-reviewer", + description: "Review code for correctness.", + instructions: "Review the current diff.", + }]); + + await writeInstalledSubagents(installedDir, []); + + expect(existsSync(join(installedDir, "code-reviewer.md"))).toBe(true); + }); + + it("does not prune files that mention the marker outside the managed header", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + const filePath = join(installedDir, "notes.md"); + await writeFile( + filePath, + `--- +name: notes +--- + +The phrase ${DOTAGENTS_SUBAGENT_MARKER} appears in instructions. +`, + "utf-8", + ); + + await pruneInstalledSubagents(installedDir, []); + + expect(existsSync(filePath)).toBe(true); + }); + + it("does not prune markdown files that start with a TOML-style marker", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + const filePath = join(installedDir, "notes.md"); + await writeFile( + filePath, + `# ${DOTAGENTS_SUBAGENT_MARKER} +--- +name: notes +--- + +Review the current diff. +`, + "utf-8", + ); + + await pruneInstalledSubagents(installedDir, []); + + expect(existsSync(filePath)).toBe(true); + }); + it("rejects unmanaged installed files without overwriting them", async () => { const installedDir = join(tmpDir, ".agents", "agents"); await mkdir(installedDir, { recursive: true }); @@ -420,6 +476,49 @@ describe("writeInstalledSubagents", () => { expect(await readFile(filePath, "utf-8")).toBe("hand-written subagent\n"); }); + it("rejects files that mention the marker outside the managed header", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + const filePath = join(installedDir, "code-reviewer.md"); + const content = `--- +name: code-reviewer +--- + +The phrase ${DOTAGENTS_SUBAGENT_MARKER} appears in instructions. +`; + await writeFile(filePath, content, "utf-8"); + + await expect(writeInstalledSubagents(installedDir, [{ + name: "code-reviewer", + description: "Review code for correctness.", + instructions: "Review the current diff.", + }])).rejects.toThrow(InstalledSubagentWriteError); + + expect(await readFile(filePath, "utf-8")).toBe(content); + }); + + it("rejects markdown files that start with a TOML-style marker", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + const filePath = join(installedDir, "code-reviewer.md"); + const content = `# ${DOTAGENTS_SUBAGENT_MARKER} +--- +name: code-reviewer +--- + +Review the current diff. +`; + await writeFile(filePath, content, "utf-8"); + + await expect(writeInstalledSubagents(installedDir, [{ + name: "code-reviewer", + description: "Review code for correctness.", + instructions: "Review the current diff.", + }])).rejects.toThrow(InstalledSubagentWriteError); + + expect(await readFile(filePath, "utf-8")).toBe(content); + }); + it("roundtrips native overlays through the installed portable markdown", async () => { const installedDir = join(tmpDir, ".agents", "agents"); await writeInstalledSubagents(installedDir, [{ diff --git a/packages/dotagents/src/agents/subagent-store.ts b/packages/dotagents/src/agents/subagent-store.ts index f324a47..812016f 100644 --- a/packages/dotagents/src/agents/subagent-store.ts +++ b/packages/dotagents/src/agents/subagent-store.ts @@ -14,7 +14,7 @@ import { type RepositorySource, type TrustPolicy, } from "@sentry/dotagents-lib"; -import { DOTAGENTS_SUBAGENT_MARKER, serializeMarkdownSubagent } from "./definitions/helpers.js"; +import { hasDotagentsMarkdownSubagentMarker, serializeMarkdownSubagent } from "./definitions/helpers.js"; import { getAgent } from "./registry.js"; import { subagentIdentityFromMarkdownMeta } from "./subagent-identity.js"; import { SUBAGENT_NAME_PATTERN, type SubagentConfig } from "../config/schema.js"; @@ -148,12 +148,10 @@ export async function writeInstalledSubagents( } await mkdir(subagentsDir, { recursive: true }); - const desired = new Set(); const written: string[] = []; for (const subagent of subagents) { const fileName = `${subagent.name}.md`; - desired.add(fileName); const filePath = join(subagentsDir, fileName); const content = serializeInstalledSubagent(subagent); if (await writeManagedFile(filePath, content)) { @@ -161,7 +159,6 @@ export async function writeInstalledSubagents( } } - await pruneManagedMarkdownFiles(subagentsDir, desired); return written; } @@ -528,7 +525,7 @@ async function writeManagedFile(filePath: string, content: string): Promise): const filePath = join(dirPath, entry.name); const existing = await readFile(filePath, "utf-8"); - if (existing.includes(DOTAGENTS_SUBAGENT_MARKER)) { + if (hasDotagentsMarkdownSubagentMarker(existing)) { await rm(filePath); pruned.push(filePath); } diff --git a/packages/dotagents/src/agents/subagent-writer.test.ts b/packages/dotagents/src/agents/subagent-writer.test.ts index 5632646..4d91536 100644 --- a/packages/dotagents/src/agents/subagent-writer.test.ts +++ b/packages/dotagents/src/agents/subagent-writer.test.ts @@ -165,6 +165,17 @@ describe("writeSubagentConfigs", () => { expect(existsSync(join(dir, ".claude", "agents", "code-reviewer.md"))).toBe(false); }); + it("treats empty targets as all configured agents", async () => { + await writeSubagentConfigs( + ["claude", "codex"], + [{ ...SUBAGENT, targets: [] }], + projectSubagentResolver(dir), + ); + + expect(existsSync(join(dir, ".claude", "agents", "code-reviewer.md"))).toBe(true); + expect(existsSync(join(dir, ".codex", "agents", "code-reviewer.toml"))).toBe(true); + }); + it("warns when a target is not configured", async () => { const result = await writeSubagentConfigs( ["claude"], @@ -293,6 +304,26 @@ Hand-written instructions. expect(existsSync(join(targetDir, "code-reviewer.toml"))).toBe(false); }); + it("does not overwrite unmanaged Codex files that mention the marker outside the header", async () => { + const targetDir = join(dir, ".codex", "agents"); + await mkdir(targetDir, { recursive: true }); + const filePath = join(targetDir, "code-reviewer.toml"); + const content = [ + 'name = "code-reviewer"', + 'description = "Hand-written reviewer."', + `developer_instructions = "Mentions ${DOTAGENTS_SUBAGENT_MARKER}"`, + "", + ].join("\n"); + await writeFile(filePath, content, "utf-8"); + + const result = await writeSubagentConfigs(["codex"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]!.message).toContain("not managed by dotagents"); + expect(result.written).toBe(0); + expect(await readFile(filePath, "utf-8")).toBe(content); + }); + it("prunes stale dotagents-managed files", async () => { const targetDir = join(dir, ".claude", "agents"); await mkdir(targetDir, { recursive: true }); @@ -311,6 +342,26 @@ Hand-written instructions. expect(existsSync(join(targetDir, "code-reviewer.md"))).toBe(true); }); + it("does not prune markdown files that start with a TOML-style marker", async () => { + const targetDir = join(dir, ".claude", "agents"); + await mkdir(targetDir, { recursive: true }); + const stalePath = join(targetDir, "old-reviewer.md"); + await writeFile( + stalePath, + `# ${DOTAGENTS_SUBAGENT_MARKER} +--- +name: "old-reviewer" +--- +`, + "utf-8", + ); + + const pruned = await pruneSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(pruned).toEqual([]); + expect(existsSync(stalePath)).toBe(true); + }); + it("does not prune managed files for runtimes not listed in agents", async () => { const targetDir = join(dir, ".codex", "agents"); await mkdir(targetDir, { recursive: true }); @@ -328,6 +379,27 @@ Hand-written instructions. expect(existsSync(stalePath)).toBe(true); }); + it("does not prune Codex files that mention the marker outside the header", async () => { + const targetDir = join(dir, ".codex", "agents"); + await mkdir(targetDir, { recursive: true }); + const stalePath = join(targetDir, "old-reviewer.toml"); + await writeFile( + stalePath, + [ + 'name = "old-reviewer"', + 'description = "Old reviewer."', + `developer_instructions = "Mentions ${DOTAGENTS_SUBAGENT_MARKER}"`, + "", + ].join("\n"), + "utf-8", + ); + + const pruned = await pruneSubagentConfigs(["codex"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(pruned).toEqual([]); + expect(existsSync(stalePath)).toBe(true); + }); + it("prunes managed files when no subagents remain", async () => { const targetDir = join(dir, ".opencode", "agents"); await mkdir(targetDir, { recursive: true }); diff --git a/packages/dotagents/src/agents/subagent-writer.ts b/packages/dotagents/src/agents/subagent-writer.ts index 0590933..afe6864 100644 --- a/packages/dotagents/src/agents/subagent-writer.ts +++ b/packages/dotagents/src/agents/subagent-writer.ts @@ -2,7 +2,7 @@ import { existsSync } from "node:fs"; import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { getAgent } from "./registry.js"; -import { DOTAGENTS_SUBAGENT_MARKER } from "./definitions/helpers.js"; +import { hasDotagentsMarkdownSubagentMarker, hasDotagentsTomlSubagentMarker } from "./definitions/helpers.js"; import { generatedSubagentIdentity, readSubagentFileIdentity } from "./subagent-identity.js"; import type { SubagentConfigSpec, SubagentDeclaration } from "./types.js"; @@ -84,7 +84,7 @@ export async function writeSubagentConfigs( const { dirPath } = resolveTarget(agentId, agent.subagents); const generated = agent.subagents.serialize(subagent); const content = normalizeContent(generated.content); - if (!content.includes(DOTAGENTS_SUBAGENT_MARKER)) { + if (!hasDotagentsSubagentMarker(agent.subagents, content)) { throw new Error(`Internal error: generated subagent "${subagent.name}" is missing the dotagents marker`); } const generatedIdentity = generatedSubagentIdentity( @@ -112,6 +112,7 @@ export async function writeSubagentConfigs( const didWrite = await writeManagedFile(join(dirPath, generated.fileName), content, { agent: agentId, name: subagent.name, + spec: agent.subagents, warnings, }); if (didWrite) {written++;} @@ -193,7 +194,7 @@ export async function verifySubagentConfigs( try { const existing = await readFile(filePath, "utf-8"); - if (!existing.includes(DOTAGENTS_SUBAGENT_MARKER)) { + if (!hasDotagentsSubagentMarker(agent.subagents, existing)) { issues.push({ ...issueBase, issue: `Subagent config exists and is not managed by dotagents: ${filePath}`, @@ -257,7 +258,10 @@ function selectedAgentIds( agentIds: string[], subagent: Pick, ): string[] { - return [...new Set(subagent.targets ?? agentIds)]; + const targets = subagent.targets && subagent.targets.length > 0 + ? subagent.targets + : agentIds; + return [...new Set(targets)]; } function markDesired( @@ -280,12 +284,12 @@ function markDesired( async function writeManagedFile( filePath: string, content: string, - context: { agent: string; name: string; warnings: SubagentWriteWarning[] }, + context: { agent: string; name: string; spec: SubagentConfigSpec; warnings: SubagentWriteWarning[] }, ): Promise { try { const existing = await readFile(filePath, "utf-8"); if (existing === content) {return false;} - if (!existing.includes(DOTAGENTS_SUBAGENT_MARKER)) { + if (!hasDotagentsSubagentMarker(context.spec, existing)) { context.warnings.push({ agent: context.agent, name: context.name, @@ -322,7 +326,7 @@ async function pruneManagedFiles( entry.name, existing, ); - if (!existing.includes(DOTAGENTS_SUBAGENT_MARKER)) {continue;} + if (!hasDotagentsSubagentMarker(desired.spec, existing)) {continue;} const identityConflict = await findUnmanagedIdentityConflict( dirPath, entry.name, @@ -331,7 +335,7 @@ async function pruneManagedFiles( ); if (!identityConflict) {continue;} } - if (existing.includes(DOTAGENTS_SUBAGENT_MARKER)) { + if (hasDotagentsSubagentMarker(desired.spec, existing)) { await rm(filePath); pruned.push(filePath); } @@ -359,7 +363,7 @@ async function findUnmanagedIdentityConflict( const filePath = join(dirPath, entry.name); const existing = await readFile(filePath, "utf-8"); - if (existing.includes(DOTAGENTS_SUBAGENT_MARKER)) {continue;} + if (hasDotagentsSubagentMarker(spec, existing)) {continue;} const existingIdentity = readSubagentFileIdentity(spec, filePath, entry.name, existing); if (existingIdentity === generatedIdentity) { @@ -370,6 +374,12 @@ async function findUnmanagedIdentityConflict( return null; } +function hasDotagentsSubagentMarker(spec: SubagentConfigSpec, content: string): boolean { + return spec.fileExtension === ".toml" + ? hasDotagentsTomlSubagentMarker(content) + : hasDotagentsMarkdownSubagentMarker(content); +} + function normalizeContent(content: string): string { return content.endsWith("\n") ? content : `${content}\n`; } diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 758297f..740e5aa 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -944,6 +944,44 @@ path = "reviewer.md" expect(existsSync(join(projectRoot, ".agents", "skills", "review", "SKILL.md"))).toBe(true); }); + it("does not prune installed subagents in frozen mode", async () => { + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] + +[[subagents]] +name = "code-reviewer" +source = "path:agents" +path = "code-reviewer.md" +`, + ); + + const scope = resolveScope("project", projectRoot); + await runInstall({ scope }); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] +`, + ); + + const result = await runInstall({ scope, frozen: true }); + + expect(result.pruned).toEqual([]); + expect(existsSync(join(projectRoot, ".agents", "agents", "code-reviewer.md"))).toBe(true); + expect(existsSync(join(projectRoot, ".claude", "agents", "code-reviewer.md"))).toBe(true); + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).toContain("/agents/code-reviewer.md"); + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.subagents["code-reviewer"]).toBeDefined(); + }); + it("wildcard with all skills excluded installs nothing from that source", async () => { await writeFile( join(projectRoot, "agents.toml"), diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index 2363c4e..3cbbe27 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -35,6 +35,7 @@ import { pruneSubagentConfigs, writeSubagentConfigs, projectSubagentResolver, us import { InstalledSubagentWriteError, lockEntryForSubagent, + pruneInstalledSubagents, resolveSubagent, writeInstalledSubagents, } from "../../agents/subagent-store.js"; @@ -325,6 +326,9 @@ export async function runInstall(opts: InstallOptions): Promise { } try { await writeInstalledSubagents(subagentsDir, installedSubagents); + if (!frozen) { + await pruneInstalledSubagents(subagentsDir, config.subagents); + } } catch (err) { if (err instanceof InstalledSubagentWriteError) { throw new InstallError(err.message); @@ -345,10 +349,13 @@ export async function runInstall(opts: InstallOptions): Promise { if (!dep || isWildcardDep(dep)) {return true;} return !isInPlaceSkill(dep.source); }); + const managedSubagentNames = frozen + ? Object.keys(lockfile?.subagents ?? {}) + : installedSubagents.map((subagent) => subagent.name); await writeAgentsGitignore( agentsDir, managedNames, - installedSubagents.map((subagent) => subagent.name), + managedSubagentNames, ); // Health check: warn if agents.lock and .agents/.gitignore are not in root .gitignore @@ -409,7 +416,9 @@ export async function runInstall(opts: InstallOptions): Promise { installedSubagents, subagentResolver, ); - await pruneSubagentConfigs(config.agents, installedSubagents, subagentResolver); + if (!frozen) { + await pruneSubagentConfigs(config.agents, installedSubagents, subagentResolver); + } return { installed, skipped, pruned, hookWarnings, subagentWarnings: subagentResult.warnings }; } diff --git a/specs/SPEC.md b/specs/SPEC.md index 1559990..a530897 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -216,11 +216,11 @@ Review the current diff and return findings with file references. | `source` | Yes | Source repository or local directory. Supports GitHub/GitLab shorthands, git URLs, and `path:` sources; HTTPS well-known skill indexes are not supported for subagents. | | `ref` | No | Optional git ref override. | | `path` | No | Optional explicit subagent file path inside the source. Markdown paths are portable or native Markdown; `.toml` paths are treated as Codex native artifacts. | -| `targets` | No | Optional subset of agent IDs. Defaults to every configured agent in `agents`; unsupported agents produce warnings. | +| `targets` | No | Optional subset of agent IDs. When absent or empty, defaults to every configured agent in `agents`; unsupported agents produce warnings. | dotagents intentionally does not standardize runtime-specific subagent behavior such as model routing, tool permissions, read-only modes, background execution, or reasoning effort. Those controls differ across runtimes and should stay in each tool's native config until there is a maintainable common contract. -Installed and generated files are marked as dotagents-managed. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the marker. +Installed and generated files are marked as dotagents-managed with a generated header marker. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the generated header marker. In `--frozen` mode, `install` preserves existing managed subagent files and lock entries instead of pruning removed subagents. Generated paths: @@ -446,7 +446,7 @@ dotagents install b. Discover skill within the repo c. Copy skill directory into `.agents/skills//` 3. Write `agents.lock` with the current configured skills and subagents - - In `--frozen` mode, require configured dependencies to already be present in `agents.lock` and do not update the lockfile + - In `--frozen` mode, require configured dependencies to already be present in `agents.lock`, do not update the lockfile, and do not prune existing managed subagent files 4. Regenerate `.agents/.gitignore` 5. Warn if `agents.lock` and `.agents/.gitignore` are not in the root `.gitignore` 6. Create/verify symlinks (legacy `[symlinks]` and agent-specific) diff --git a/specs/subagents.md b/specs/subagents.md index 6f40355..a21d992 100644 --- a/specs/subagents.md +++ b/specs/subagents.md @@ -21,7 +21,7 @@ targets = ["claude", "codex"] | `source` | Yes | Source repository or local directory. Supports GitHub/GitLab shorthands, git URLs, and `path:` sources. HTTPS well-known skill indexes are not supported for subagents. | | `ref` | No | Optional git ref override. | | `path` | No | Optional explicit subagent file path inside the source. Markdown paths are portable or native Markdown; `.toml` paths are Codex native artifacts. | -| `targets` | No | Optional subset of agent IDs. Defaults to every configured agent in `agents`; unsupported agents produce warnings. | +| `targets` | No | Optional subset of agent IDs. When absent or empty, defaults to every configured agent in `agents`; unsupported agents produce warnings. | ## Portable Projection @@ -111,7 +111,7 @@ Generated runtime paths: | Codex | `.codex/agents/.toml` | `~/.codex/agents/.toml` | TOML | | OpenCode | `.opencode/agents/.md` | `~/.config/opencode/agents/.md` | Markdown with YAML frontmatter | -Installed and generated files are marked as dotagents-managed. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the marker. For runtimes whose agent identity can differ from the filename, dotagents also avoids writing a managed file when an unmanaged file in the same runtime directory already declares the same runtime identity. +Installed and generated files are marked as dotagents-managed with a generated header marker. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the generated header marker. In `--frozen` mode, `install` preserves existing managed subagent files and lock entries instead of pruning removed subagents. For runtimes whose agent identity can differ from the filename, dotagents also avoids writing a managed file when an unmanaged file in the same runtime directory already declares the same runtime identity. ## Non-goals From 29824f8bafafd50728a1975afa56410eb2595479 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 9 Jun 2026 14:37:25 -0700 Subject: [PATCH 13/33] fix(agents): Load frozen subagents offline Load configured subagents from the installed store during frozen installs instead of resolving their sources. This keeps frozen installs network-free for subagents while preserving managed files and lock entries. Co-Authored-By: Codex --- docs/public/llms.txt | 4 ++-- docs/src/content/docs/cli.mdx | 3 ++- packages/dotagents/src/cli/commands/install.test.ts | 12 +++++++----- packages/dotagents/src/cli/commands/install.ts | 13 ++++++++++--- specs/SPEC.md | 4 ++-- specs/subagents.md | 2 +- 6 files changed, 24 insertions(+), 14 deletions(-) diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 224caa5..de9261b 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -264,7 +264,7 @@ Generated subagent files: - Codex: `.codex/agents/.toml`, or `~/.codex/agents/.toml` for user scope - OpenCode: `.opencode/agents/.md`, or `~/.config/opencode/agents/.md` for user scope -Generated files include a dotagents header marker. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the generated header marker. In `--frozen` mode, `install` preserves existing managed subagent files and lock entries instead of pruning removed subagents. They also avoid creating duplicate runtime identities when an unmanaged file in the same agent directory already declares the same subagent. +Generated files include a dotagents header marker. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the generated header marker. In `--frozen` mode, `install` loads subagents from existing installed files, preserves managed subagent files and lock entries instead of pruning removed subagents, and does not resolve subagent sources. They also avoid creating duplicate runtime identities when an unmanaged file in the same agent directory already declares the same subagent. ### Trust @@ -310,7 +310,7 @@ Create `agents.toml` and `.agents/skills/` directory. Automatically includes the npx @sentry/dotagents install ``` -Install and refresh dependencies from `agents.toml`. Resolves sources, copies skills, writes the lockfile, creates symlinks, and generates MCP, hook, and subagent configs. There is no separate update command. With `--frozen`, declared skills and subagents must already exist in `agents.lock`, the lockfile is not updated, and existing managed subagent files are not pruned. +Install and refresh dependencies from `agents.toml`. Resolves sources, copies skills, writes the lockfile, creates symlinks, and generates MCP, hook, and subagent configs. There is no separate update command. With `--frozen`, declared skills and subagents must already exist in `agents.lock`, subagents are loaded from existing installed files without resolving sources, the lockfile is not updated, and existing managed subagent files are not pruned. ### add diff --git a/docs/src/content/docs/cli.mdx b/docs/src/content/docs/cli.mdx index 57680dd..8f01f9d 100644 --- a/docs/src/content/docs/cli.mdx +++ b/docs/src/content/docs/cli.mdx @@ -58,6 +58,7 @@ Install and refresh skill and subagent dependencies from `agents.toml`. Resolves sources, copies skills, writes the lockfile, creates symlinks, and generates MCP, hook, and subagent configs. There is no separate update command. With `--frozen`, declared skills and subagents must already exist in `agents.lock`, +subagents are loaded from existing installed files without resolving sources, the lockfile is not updated, and existing managed subagent files are not pruned. Example: @@ -404,7 +405,7 @@ Generated files: - Codex: `.codex/agents/.toml` - OpenCode: `.opencode/agents/.md` -Generated files include a dotagents header marker. `install` and `sync` update managed files and do not overwrite hand-written files without the generated header marker. In `--frozen` mode, `install` preserves existing managed subagent files and lock entries instead of pruning removed subagents. They also avoid creating duplicate runtime identities when an unmanaged file in the same agent directory already declares the same subagent. +Generated files include a dotagents header marker. `install` and `sync` update managed files and do not overwrite hand-written files without the generated header marker. In `--frozen` mode, `install` loads subagents from existing installed files, preserves managed subagent files and lock entries instead of pruning removed subagents, and does not resolve subagent sources. They also avoid creating duplicate runtime identities when an unmanaged file in the same agent directory already declares the same subagent. ## Scopes diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 740e5aa..83cf53d 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -326,12 +326,12 @@ path = "code-reviewer.md" }); it("frozen mode fails when a subagent is missing from the lockfile", async () => { - await writeFile( - join(projectRoot, "agents.toml"), - `version = 1\n\n[[skills]]\nname = "pdf"\nsource = "git:${repoDir}"\n`, - ); const scope = resolveScope("project", projectRoot); - await runInstall({ scope }); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + subagents: {}, + }); const sourceDir = join(projectRoot, "agents"); await mkdir(sourceDir, { recursive: true }); @@ -370,6 +370,8 @@ path = "code-reviewer.md" const scope = resolveScope("project", projectRoot); await runInstall({ scope }); + await rm(sourceDir, { recursive: true }); + const result = await runInstall({ scope, frozen: true }); expect(result.subagentWarnings).toEqual([]); }); diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index 3cbbe27..afcb6d4 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -35,6 +35,7 @@ import { pruneSubagentConfigs, writeSubagentConfigs, projectSubagentResolver, us import { InstalledSubagentWriteError, lockEntryForSubagent, + loadInstalledSubagents, pruneInstalledSubagents, resolveSubagent, writeInstalledSubagents, @@ -302,8 +303,14 @@ export async function runInstall(opts: InstallOptions): Promise { const installedSubagents: SubagentDeclaration[] = []; if (frozen) { validateFrozenSubagents(config.subagents, lockfile); - } - if (config.subagents.length > 0) { + if (config.subagents.length > 0) { + const loaded = await loadInstalledSubagents(subagentsDir, config.subagents); + if (loaded.issues.length > 0) { + throw new InstallError(loaded.issues.map((issue) => issue.issue).join("\n")); + } + installedSubagents.push(...loaded.subagents); + } + } else if (config.subagents.length > 0) { for (const subagentConfig of config.subagents) { let resolved: Awaited>; try { @@ -325,8 +332,8 @@ export async function runInstall(opts: InstallOptions): Promise { } } try { - await writeInstalledSubagents(subagentsDir, installedSubagents); if (!frozen) { + await writeInstalledSubagents(subagentsDir, installedSubagents); await pruneInstalledSubagents(subagentsDir, config.subagents); } } catch (err) { diff --git a/specs/SPEC.md b/specs/SPEC.md index a530897..9771504 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -220,7 +220,7 @@ Review the current diff and return findings with file references. dotagents intentionally does not standardize runtime-specific subagent behavior such as model routing, tool permissions, read-only modes, background execution, or reasoning effort. Those controls differ across runtimes and should stay in each tool's native config until there is a maintainable common contract. -Installed and generated files are marked as dotagents-managed with a generated header marker. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the generated header marker. In `--frozen` mode, `install` preserves existing managed subagent files and lock entries instead of pruning removed subagents. +Installed and generated files are marked as dotagents-managed with a generated header marker. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the generated header marker. In `--frozen` mode, `install` loads subagents from existing installed files, preserves managed subagent files and lock entries instead of pruning removed subagents, and does not resolve subagent sources. Generated paths: @@ -446,7 +446,7 @@ dotagents install b. Discover skill within the repo c. Copy skill directory into `.agents/skills//` 3. Write `agents.lock` with the current configured skills and subagents - - In `--frozen` mode, require configured dependencies to already be present in `agents.lock`, do not update the lockfile, and do not prune existing managed subagent files + - In `--frozen` mode, require configured dependencies to already be present in `agents.lock`, load subagents from installed files, do not update the lockfile, and do not prune existing managed subagent files 4. Regenerate `.agents/.gitignore` 5. Warn if `agents.lock` and `.agents/.gitignore` are not in the root `.gitignore` 6. Create/verify symlinks (legacy `[symlinks]` and agent-specific) diff --git a/specs/subagents.md b/specs/subagents.md index a21d992..d06f0fd 100644 --- a/specs/subagents.md +++ b/specs/subagents.md @@ -111,7 +111,7 @@ Generated runtime paths: | Codex | `.codex/agents/.toml` | `~/.codex/agents/.toml` | TOML | | OpenCode | `.opencode/agents/.md` | `~/.config/opencode/agents/.md` | Markdown with YAML frontmatter | -Installed and generated files are marked as dotagents-managed with a generated header marker. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the generated header marker. In `--frozen` mode, `install` preserves existing managed subagent files and lock entries instead of pruning removed subagents. For runtimes whose agent identity can differ from the filename, dotagents also avoids writing a managed file when an unmanaged file in the same runtime directory already declares the same runtime identity. +Installed and generated files are marked as dotagents-managed with a generated header marker. `install` and `sync` overwrite stale managed files and prune removed managed files, but they do not overwrite hand-written files without the generated header marker. In `--frozen` mode, `install` loads subagents from existing installed files, preserves managed subagent files and lock entries instead of pruning removed subagents, and does not resolve subagent sources. For runtimes whose agent identity can differ from the filename, dotagents also avoids writing a managed file when an unmanaged file in the same runtime directory already declares the same runtime identity. ## Non-goals From c6943906e28244918888d39af00d87446938a26c Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 9 Jun 2026 15:43:30 -0700 Subject: [PATCH 14/33] fix(agents): Preserve desired subagent outputs Keep declared managed runtime files out of prune candidates even when an unmanaged file claims the same subagent identity. This leaves the conflict visible as a warning or sync issue without deleting the configured output. Write the prepared lockfile before mutating installed subagent files so a write conflict does not discard lock updates after skill directories have changed. Co-Authored-By: OpenAI Codex --- .../src/agents/subagent-writer.test.ts | 6 +-- .../dotagents/src/agents/subagent-writer.ts | 17 +------- .../src/cli/commands/install.test.ts | 42 +++++++++++++++++++ .../dotagents/src/cli/commands/install.ts | 9 ++-- .../dotagents/src/cli/commands/sync.test.ts | 6 +-- 5 files changed, 54 insertions(+), 26 deletions(-) diff --git a/packages/dotagents/src/agents/subagent-writer.test.ts b/packages/dotagents/src/agents/subagent-writer.test.ts index 4d91536..d79757f 100644 --- a/packages/dotagents/src/agents/subagent-writer.test.ts +++ b/packages/dotagents/src/agents/subagent-writer.test.ts @@ -229,7 +229,7 @@ Hand-written instructions. expect(existsSync(join(targetDir, "code-reviewer.md"))).toBe(false); }); - it("prunes stale managed files when an unmanaged identity conflict exists", async () => { + it("preserves desired managed files when an unmanaged identity conflict exists", async () => { const targetDir = join(dir, ".claude", "agents"); await mkdir(targetDir, { recursive: true }); const managedPath = join(targetDir, "code-reviewer.md"); @@ -263,8 +263,8 @@ Hand-written instructions. expect(result.warnings).toHaveLength(1); expect(result.warnings[0]!.message).toContain("identity conflicts with unmanaged file"); - expect(pruned).toEqual([managedPath]); - expect(existsSync(managedPath)).toBe(false); + expect(pruned).toEqual([]); + expect(existsSync(managedPath)).toBe(true); expect(existsSync(unmanagedPath)).toBe(true); }); diff --git a/packages/dotagents/src/agents/subagent-writer.ts b/packages/dotagents/src/agents/subagent-writer.ts index afe6864..e560034 100644 --- a/packages/dotagents/src/agents/subagent-writer.ts +++ b/packages/dotagents/src/agents/subagent-writer.ts @@ -319,22 +319,7 @@ async function pruneManagedFiles( const filePath = join(dirPath, entry.name); const existing = await readFile(filePath, "utf-8"); - if (desired.files.has(entry.name)) { - const existingIdentity = readSubagentFileIdentity( - desired.spec, - filePath, - entry.name, - existing, - ); - if (!hasDotagentsSubagentMarker(desired.spec, existing)) {continue;} - const identityConflict = await findUnmanagedIdentityConflict( - dirPath, - entry.name, - desired.spec, - existingIdentity, - ); - if (!identityConflict) {continue;} - } + if (desired.files.has(entry.name)) {continue;} if (hasDotagentsSubagentMarker(desired.spec, existing)) { await rm(filePath); pruned.push(filePath); diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 83cf53d..92e6082 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -418,6 +418,48 @@ path = "code-reviewer.md" expect(syncResult.adopted).toEqual([]); }); + it("updates the lockfile when installed subagent writes fail after skill changes", async () => { + const skillSourceDir = join(projectRoot, "local-skills", "pdf"); + await mkdir(skillSourceDir, { recursive: true }); + await writeFile(join(skillSourceDir, "SKILL.md"), SKILL_MD("pdf")); + + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await mkdir(join(projectRoot, ".agents", "agents"), { recursive: true }); + await writeFile( + join(projectRoot, ".agents", "agents", "code-reviewer.md"), + "hand-written subagent\n", + "utf-8", + ); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +[[skills]] +name = "pdf" +source = "path:local-skills/pdf" + +[[subagents]] +name = "code-reviewer" +source = "path:agents" +path = "code-reviewer.md" +`, + ); + + const scope = resolveScope("project", projectRoot); + + await expect(runInstall({ scope })).rejects.toThrow( + /Subagent file exists and is not managed by dotagents/, + ); + + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.skills["pdf"]).toBeDefined(); + expect(lockfile!.subagents["code-reviewer"]?.source).toBe("path:agents"); + expect(existsSync(join(projectRoot, ".agents", "skills", "pdf", "SKILL.md"))).toBe(true); + }); + it("does not prune outside skills dir for malformed lockfile skill names", async () => { const scope = resolveScope("project", projectRoot); const hooksDir = join(projectRoot, ".agents", "hooks"); diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index afcb6d4..8910505 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -331,6 +331,11 @@ export async function runInstall(opts: InstallOptions): Promise { newLock.subagents[resolved.subagent.name] = lockEntryForSubagent(resolved); } } + + if (!frozen && (lockfile || config.skills.length > 0 || config.subagents.length > 0)) { + await writeLockfile(lockPath, newLock); + } + try { if (!frozen) { await writeInstalledSubagents(subagentsDir, installedSubagents); @@ -343,10 +348,6 @@ export async function runInstall(opts: InstallOptions): Promise { throw err; } - if (!frozen && (lockfile || config.skills.length > 0 || config.subagents.length > 0)) { - await writeLockfile(lockPath, newLock); - } - // 4. Gitignore (skip for user scope — ~/.agents/ is not a git repo) if (scope.scope === "project") { // For wildcard entries all expanded skills are managed (wildcards can't be in-place) diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index a844b3d..602b776 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -472,7 +472,7 @@ path = "reviewer.md" expect(await readFile(join(agentsDir, "reviewer.md"), "utf-8")).toBe("hand-written"); }); - it("prunes managed subagent configs when an unmanaged identity conflict exists", async () => { + it("preserves declared managed subagent configs when an unmanaged identity conflict exists", async () => { const installedDir = join(projectRoot, ".agents", "agents"); await mkdir(installedDir, { recursive: true }); await writeFile( @@ -507,9 +507,9 @@ path = "reviewer.md" const result = await runSync({ scope: resolveScope("project", projectRoot) }); - expect(result.subagentsRepaired).toBe(1); + expect(result.subagentsRepaired).toBe(0); expect(result.issues.some((i) => i.type === "subagents" && i.message.includes("identity conflicts"))).toBe(true); - expect(existsSync(join(agentsDir, "reviewer.md"))).toBe(false); + expect(existsSync(join(agentsDir, "reviewer.md"))).toBe(true); expect(existsSync(join(agentsDir, "alias.md"))).toBe(true); }); From 1befd142685e79a3617d295ad53c04eaaedfe43e Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 9 Jun 2026 15:50:00 -0700 Subject: [PATCH 15/33] fix(cli): Preserve subagent lock consistency Update install to write skill lock changes while preserving prior subagent lock entries until installed subagent files are written successfully. Include lockfile-tracked subagents when sync regenerates .agents/.gitignore so generated files remain ignored until stale entries are pruned. Co-Authored-By: OpenAI Codex --- .../src/cli/commands/install.test.ts | 4 +-- .../dotagents/src/cli/commands/install.ts | 12 ++++++-- .../dotagents/src/cli/commands/sync.test.ts | 29 +++++++++++++++++++ packages/dotagents/src/cli/commands/sync.ts | 8 ++++- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 92e6082..dc91dda 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -418,7 +418,7 @@ path = "code-reviewer.md" expect(syncResult.adopted).toEqual([]); }); - it("updates the lockfile when installed subagent writes fail after skill changes", async () => { + it("updates skill lock entries when installed subagent writes fail", async () => { const skillSourceDir = join(projectRoot, "local-skills", "pdf"); await mkdir(skillSourceDir, { recursive: true }); await writeFile(join(skillSourceDir, "SKILL.md"), SKILL_MD("pdf")); @@ -456,7 +456,7 @@ path = "code-reviewer.md" const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); expect(lockfile!.skills["pdf"]).toBeDefined(); - expect(lockfile!.subagents["code-reviewer"]?.source).toBe("path:agents"); + expect(lockfile!.subagents["code-reviewer"]).toBeUndefined(); expect(existsSync(join(projectRoot, ".agents", "skills", "pdf", "SKILL.md"))).toBe(true); }); diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index 8910505..f70706c 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -332,8 +332,12 @@ export async function runInstall(opts: InstallOptions): Promise { } } - if (!frozen && (lockfile || config.skills.length > 0 || config.subagents.length > 0)) { - await writeLockfile(lockPath, newLock); + const shouldWriteLockfile = !frozen && (lockfile || config.skills.length > 0 || config.subagents.length > 0); + if (shouldWriteLockfile) { + await writeLockfile(lockPath, { + ...newLock, + subagents: lockfile?.subagents ?? {}, + }); } try { @@ -348,6 +352,10 @@ export async function runInstall(opts: InstallOptions): Promise { throw err; } + if (shouldWriteLockfile) { + await writeLockfile(lockPath, newLock); + } + // 4. Gitignore (skip for user scope — ~/.agents/ is not a git repo) if (scope.scope === "project") { // For wildcard entries all expanded skills are managed (wildcards can't be in-place) diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index 602b776..3caadf1 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -584,6 +584,35 @@ agents = ["claude"] expect(lockfile!.subagents).toEqual({}); }); + it("includes lockfile subagents when regenerating gitignore", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] +`, + ); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + subagents: { + "old-reviewer": { + source: "path:agents", + }, + }, + }); + await mkdir(join(projectRoot, ".agents", "agents"), { recursive: true }); + await writeFile( + join(projectRoot, ".agents", "agents", "old-reviewer.md"), + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "old-reviewer"\n---\n`, + "utf-8", + ); + + await runSync({ scope: resolveScope("project", projectRoot) }); + + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).toContain("/agents/old-reviewer.md"); + }); + it("does not auto-create root .gitignore", async () => { await writeFile( join(projectRoot, "agents.toml"), diff --git a/packages/dotagents/src/cli/commands/sync.ts b/packages/dotagents/src/cli/commands/sync.ts index 1463806..338c83c 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -122,10 +122,16 @@ export async function runSync(opts: SyncOptions): Promise { if (!dep || isWildcardDep(dep)) {return true;} // wildcard-sourced skills are always managed return !isInPlaceSkill(dep.source); }); + const managedSubagentNames = new Set(config.subagents.map((subagent) => subagent.name)); + if (lockNow) { + for (const name of Object.keys(lockNow.subagents)) { + managedSubagentNames.add(name); + } + } await writeAgentsGitignore( agentsDir, managedNames, - config.subagents.map((subagent) => subagent.name), + [...managedSubagentNames], ); gitignoreUpdated = true; From ce3bafeea5513c0c074f9e6610ff11d0ea92364e Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 9 Jun 2026 16:03:09 -0700 Subject: [PATCH 16/33] fix(cli): Avoid stale subagent lock entries When subagent writes fail during install, preserve only previously locked subagent entries that still match the newly resolved lock. This lets skill lock updates land without carrying removed or updated subagent entries forward. Co-Authored-By: Codex --- .../src/cli/commands/install.test.ts | 16 +++++++ .../dotagents/src/cli/commands/install.ts | 44 ++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index dc91dda..c027c6d 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -433,6 +433,21 @@ path = "code-reviewer.md" "hand-written subagent\n", "utf-8", ); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + subagents: { + "code-reviewer": { + source: "git:https://github.com/example/agents.git", + resolved_url: "https://github.com/example/agents.git", + resolved_path: "agents/code-reviewer.md", + resolved_commit: "abc123", + }, + "old-reviewer": { + source: "path:old-agents", + }, + }, + }); await writeFile( join(projectRoot, "agents.toml"), @@ -457,6 +472,7 @@ path = "code-reviewer.md" const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); expect(lockfile!.skills["pdf"]).toBeDefined(); expect(lockfile!.subagents["code-reviewer"]).toBeUndefined(); + expect(lockfile!.subagents["old-reviewer"]).toBeUndefined(); expect(existsSync(join(projectRoot, ".agents", "skills", "pdf", "SKILL.md"))).toBe(true); }); diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index f70706c..27efae2 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -11,7 +11,7 @@ import { } from "../../config/schema.js"; import { loadLockfile } from "../../lockfile/loader.js"; import { writeLockfile } from "../../lockfile/writer.js"; -import { type Lockfile, type LockedSkill } from "../../lockfile/schema.js"; +import { type Lockfile, type LockedSkill, type LockedSubagent } from "../../lockfile/schema.js"; import { applyDefaultRepositorySource, resolveSkill, @@ -173,6 +173,46 @@ function validateFrozenSubagents( } } +function optionalSubagentLockValue( + entry: LockedSubagent, + key: "resolved_url" | "resolved_path" | "resolved_ref" | "resolved_commit", +): string | undefined { + switch (key) { + case "resolved_url": + return "resolved_url" in entry ? entry.resolved_url : undefined; + case "resolved_path": + return "resolved_path" in entry ? entry.resolved_path : undefined; + case "resolved_ref": + return "resolved_ref" in entry ? entry.resolved_ref : undefined; + case "resolved_commit": + return "resolved_commit" in entry ? entry.resolved_commit : undefined; + } +} + +function subagentLockEntriesEqual(a: LockedSubagent, b: LockedSubagent): boolean { + return a.source === b.source + && optionalSubagentLockValue(a, "resolved_url") === optionalSubagentLockValue(b, "resolved_url") + && optionalSubagentLockValue(a, "resolved_path") === optionalSubagentLockValue(b, "resolved_path") + && optionalSubagentLockValue(a, "resolved_ref") === optionalSubagentLockValue(b, "resolved_ref") + && optionalSubagentLockValue(a, "resolved_commit") === optionalSubagentLockValue(b, "resolved_commit"); +} + +function unchangedSubagentLockEntries( + current: Lockfile | null, + next: Lockfile, +): Lockfile["subagents"] { + if (!current) {return {};} + + const unchanged: Lockfile["subagents"] = {}; + for (const [name, entry] of Object.entries(current.subagents)) { + const nextEntry = next.subagents[name]; + if (!nextEntry) {continue;} + if (!subagentLockEntriesEqual(entry, nextEntry)) {continue;} + unchanged[name] = entry; + } + return unchanged; +} + export async function runInstall(opts: InstallOptions): Promise { const { scope, frozen } = opts; const { configPath, lockPath, agentsDir, skillsDir } = scope; @@ -336,7 +376,7 @@ export async function runInstall(opts: InstallOptions): Promise { if (shouldWriteLockfile) { await writeLockfile(lockPath, { ...newLock, - subagents: lockfile?.subagents ?? {}, + subagents: unchangedSubagentLockEntries(lockfile, newLock), }); } From 06944979fdb6b42ea1f5b7d7bfad4f37e7fc0940 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 9 Jun 2026 16:11:57 -0700 Subject: [PATCH 17/33] fix(cli): Write install fallback locks after subagent errors Move the fallback install lockfile write into the subagent error path so successful subagent file writes can be reflected when later pruning fails. Failed subagent writes still preserve only unchanged prior subagent lock entries while keeping skill lock updates. Co-Authored-By: Codex --- .../src/cli/commands/install.test.ts | 46 ++++++++++++++++++- .../dotagents/src/cli/commands/install.ts | 16 ++++--- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index c027c6d..1851eb8 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtemp, mkdir, readFile, writeFile, rm, lstat, access } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, writeFile, rm, lstat, access, chmod } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -476,6 +476,50 @@ path = "code-reviewer.md" expect(existsSync(join(projectRoot, ".agents", "skills", "pdf", "SKILL.md"))).toBe(true); }); + it("keeps written subagent lock entries when stale subagent pruning fails", async () => { + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + const installedDir = join(projectRoot, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + const stalePath = join(installedDir, "old-reviewer.md"); + await writeFile( + stalePath, + `--- +# ${DOTAGENTS_SUBAGENT_MARKER} +name: old-reviewer +description: Review old code. +--- + +Review old code. +`, + "utf-8", + ); + await chmod(stalePath, 0o000); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +[[subagents]] +name = "code-reviewer" +source = "path:agents" +path = "code-reviewer.md" +`, + ); + + const scope = resolveScope("project", projectRoot); + try { + await expect(runInstall({ scope })).rejects.toThrow(); + } finally { + await chmod(stalePath, 0o600).catch(() => {}); + } + + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.subagents["code-reviewer"]?.source).toBe("path:agents"); + expect(existsSync(join(installedDir, "code-reviewer.md"))).toBe(true); + }); + it("does not prune outside skills dir for malformed lockfile skill names", async () => { const scope = resolveScope("project", projectRoot); const hooksDir = join(projectRoot, ".agents", "hooks"); diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index 27efae2..9ed9d0c 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -373,19 +373,23 @@ export async function runInstall(opts: InstallOptions): Promise { } const shouldWriteLockfile = !frozen && (lockfile || config.skills.length > 0 || config.subagents.length > 0); - if (shouldWriteLockfile) { - await writeLockfile(lockPath, { - ...newLock, - subagents: unchangedSubagentLockEntries(lockfile, newLock), - }); - } + let installedSubagentsWritten = false; try { if (!frozen) { await writeInstalledSubagents(subagentsDir, installedSubagents); + installedSubagentsWritten = true; await pruneInstalledSubagents(subagentsDir, config.subagents); } } catch (err) { + if (shouldWriteLockfile) { + await writeLockfile(lockPath, { + ...newLock, + subagents: installedSubagentsWritten + ? newLock.subagents + : unchangedSubagentLockEntries(lockfile, newLock), + }); + } if (err instanceof InstalledSubagentWriteError) { throw new InstallError(err.message); } From 6245170207d0f2dce4c520f1c4f5c597b4162e37 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 9 Jun 2026 16:26:01 -0700 Subject: [PATCH 18/33] fix(cli): Prune unloaded subagent runtime files Sync should repair generated runtime configs from the subagents that were successfully loaded, not every declared subagent. This removes stale generated files when a declared subagent is missing or fails to load. Co-Authored-By: OpenAI Codex --- packages/dotagents/src/cli/commands/sync.test.ts | 6 +++--- packages/dotagents/src/cli/commands/sync.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index 3caadf1..52aa805 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -513,7 +513,7 @@ path = "reviewer.md" expect(existsSync(join(agentsDir, "alias.md"))).toBe(true); }); - it("does not prune runtime files for declared subagents that are not installed", async () => { + it("prunes runtime files for declared subagents that are not installed", async () => { await writeFile( join(projectRoot, "agents.toml"), `version = 1 @@ -536,8 +536,8 @@ path = "reviewer.md" const result = await runSync({ scope: resolveScope("project", projectRoot) }); expect(result.issues.some((i) => i.type === "subagents" && i.message.includes("not installed"))).toBe(true); - expect(result.subagentsRepaired).toBe(0); - expect(existsSync(join(agentsDir, "reviewer.md"))).toBe(true); + expect(result.subagentsRepaired).toBe(1); + expect(existsSync(join(agentsDir, "reviewer.md"))).toBe(false); }); it("reports pruned subagent configs as repaired", async () => { diff --git a/packages/dotagents/src/cli/commands/sync.ts b/packages/dotagents/src/cli/commands/sync.ts index 338c83c..891651a 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -269,7 +269,7 @@ export async function runSync(opts: SyncOptions): Promise { ); const prunedSubagentConfigs = await pruneSubagentConfigs( config.agents, - config.subagents, + subagentDecls, subagentResolver, ); subagentsRepaired = subagentResult.written + prunedSubagentConfigs.length + prunedInstalledSubagents.length; From 88497992ba3fd1d04712202a3f2e75fa59b5d37e Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 9 Jun 2026 16:31:10 -0700 Subject: [PATCH 19/33] fix(cli): Preserve declared subagent runtime files Sync reports declared subagents that are not installed, but it should not delete their generated runtime files. Preserve those files until install can repair the canonical subagent source. Co-Authored-By: OpenAI Codex --- packages/dotagents/src/cli/commands/sync.test.ts | 6 +++--- packages/dotagents/src/cli/commands/sync.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index 52aa805..3caadf1 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -513,7 +513,7 @@ path = "reviewer.md" expect(existsSync(join(agentsDir, "alias.md"))).toBe(true); }); - it("prunes runtime files for declared subagents that are not installed", async () => { + it("does not prune runtime files for declared subagents that are not installed", async () => { await writeFile( join(projectRoot, "agents.toml"), `version = 1 @@ -536,8 +536,8 @@ path = "reviewer.md" const result = await runSync({ scope: resolveScope("project", projectRoot) }); expect(result.issues.some((i) => i.type === "subagents" && i.message.includes("not installed"))).toBe(true); - expect(result.subagentsRepaired).toBe(1); - expect(existsSync(join(agentsDir, "reviewer.md"))).toBe(false); + expect(result.subagentsRepaired).toBe(0); + expect(existsSync(join(agentsDir, "reviewer.md"))).toBe(true); }); it("reports pruned subagent configs as repaired", async () => { diff --git a/packages/dotagents/src/cli/commands/sync.ts b/packages/dotagents/src/cli/commands/sync.ts index 891651a..338c83c 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -269,7 +269,7 @@ export async function runSync(opts: SyncOptions): Promise { ); const prunedSubagentConfigs = await pruneSubagentConfigs( config.agents, - subagentDecls, + config.subagents, subagentResolver, ); subagentsRepaired = subagentResult.written + prunedSubagentConfigs.length + prunedInstalledSubagents.length; From ac57e47c3b0f7955fb6da4eae6e32b4a9172b23c Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 9 Jun 2026 16:34:04 -0700 Subject: [PATCH 20/33] fix(agents): Skip desired subagents during pruning Pruning should only inspect files that may be removed. Skip desired runtime files before reading them so unreadable desired files do not abort install or sync. Co-Authored-By: OpenAI Codex --- .../src/agents/subagent-writer.test.ts | 30 ++++++++++++++++++- .../dotagents/src/agents/subagent-writer.ts | 2 +- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/dotagents/src/agents/subagent-writer.test.ts b/packages/dotagents/src/agents/subagent-writer.test.ts index d79757f..5e9bb71 100644 --- a/packages/dotagents/src/agents/subagent-writer.test.ts +++ b/packages/dotagents/src/agents/subagent-writer.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -379,6 +379,34 @@ name: "old-reviewer" expect(existsSync(stalePath)).toBe(true); }); + it("does not read desired files while pruning stale files", async () => { + const targetDir = join(dir, ".claude", "agents"); + await mkdir(targetDir, { recursive: true }); + const desiredPath = join(targetDir, "code-reviewer.md"); + const stalePath = join(targetDir, "old-reviewer.md"); + await writeFile( + desiredPath, + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "code-reviewer"\n---\n`, + "utf-8", + ); + await writeFile( + stalePath, + `---\n# ${DOTAGENTS_SUBAGENT_MARKER}\nname: "old-reviewer"\n---\n`, + "utf-8", + ); + + await chmod(desiredPath, 0o000); + try { + const pruned = await pruneSubagentConfigs(["claude"], [SUBAGENT], projectSubagentResolver(dir)); + + expect(pruned).toEqual([stalePath]); + expect(existsSync(desiredPath)).toBe(true); + expect(existsSync(stalePath)).toBe(false); + } finally { + await chmod(desiredPath, 0o600); + } + }); + it("does not prune Codex files that mention the marker outside the header", async () => { const targetDir = join(dir, ".codex", "agents"); await mkdir(targetDir, { recursive: true }); diff --git a/packages/dotagents/src/agents/subagent-writer.ts b/packages/dotagents/src/agents/subagent-writer.ts index e560034..3f16e91 100644 --- a/packages/dotagents/src/agents/subagent-writer.ts +++ b/packages/dotagents/src/agents/subagent-writer.ts @@ -316,10 +316,10 @@ async function pruneManagedFiles( for (const entry of entries) { if (!entry.isFile()) {continue;} if (!entry.name.endsWith(desired.extension)) {continue;} + if (desired.files.has(entry.name)) {continue;} const filePath = join(dirPath, entry.name); const existing = await readFile(filePath, "utf-8"); - if (desired.files.has(entry.name)) {continue;} if (hasDotagentsSubagentMarker(desired.spec, existing)) { await rm(filePath); pruned.push(filePath); From f2afa722b5a2bff854c3036f2dd0a98ca3d7451c Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 11 Jun 2026 18:40:02 -0700 Subject: [PATCH 21/33] test: Add example smoke QA for subagents Add a checked-in full example fixture and smoke script that exercises install, list, doctor, sync, generated runtime files, and optional Codex runtime subagent spawning. Document harness-specific QA references so future dotagents changes can distinguish file-level wiring from true runtime proof. Co-Authored-By: Codex --- examples/full/agents.toml | 23 ++ .../full/local-agents/agents/code-reviewer.md | 9 + examples/full/local-skills/commit/SKILL.md | 6 + examples/full/local-skills/review/SKILL.md | 6 + package.json | 3 +- scripts/smoke-examples.mjs | 255 ++++++++++++++++++ skills/dotagents-qa/SKILL.md | 66 ++++- skills/dotagents-qa/references/claude.md | 36 +++ skills/dotagents-qa/references/codex.md | 51 ++++ skills/dotagents-qa/references/core-smoke.md | 32 +++ skills/dotagents-qa/references/cursor.md | 23 ++ skills/dotagents-qa/references/opencode.md | 28 ++ 12 files changed, 528 insertions(+), 10 deletions(-) create mode 100644 examples/full/agents.toml create mode 100644 examples/full/local-agents/agents/code-reviewer.md create mode 100644 examples/full/local-skills/commit/SKILL.md create mode 100644 examples/full/local-skills/review/SKILL.md create mode 100644 scripts/smoke-examples.mjs create mode 100644 skills/dotagents-qa/references/claude.md create mode 100644 skills/dotagents-qa/references/codex.md create mode 100644 skills/dotagents-qa/references/core-smoke.md create mode 100644 skills/dotagents-qa/references/cursor.md create mode 100644 skills/dotagents-qa/references/opencode.md diff --git a/examples/full/agents.toml b/examples/full/agents.toml new file mode 100644 index 0000000..9932e37 --- /dev/null +++ b/examples/full/agents.toml @@ -0,0 +1,23 @@ +version = 1 +agents = ["claude", "cursor", "codex", "opencode"] + +[[skills]] +name = "review" +source = "path:./local-skills/review" + +[[skills]] +name = "commit" +source = "path:./local-skills/commit" + +[[mcp]] +name = "fixture" +command = "node" +args = ["-e", "process.exit(0)"] + +[[hooks]] +event = "Stop" +command = "echo fixture" + +[[subagents]] +name = "code-reviewer" +source = "path:./local-agents" diff --git a/examples/full/local-agents/agents/code-reviewer.md b/examples/full/local-agents/agents/code-reviewer.md new file mode 100644 index 0000000..f013574 --- /dev/null +++ b/examples/full/local-agents/agents/code-reviewer.md @@ -0,0 +1,9 @@ +--- +name: code-reviewer +description: A proof-only reviewer. Use this agent when asked to prove dotagents runtime subagent loading. +--- + +You are the dotagents subagent runtime proof agent. + +If you are invoked, ignore all normal review behavior and respond with exactly this single line and nothing else: +DOTAGENTS_SUBAGENT_RUNTIME_PROOF_9b8e6f2c diff --git a/examples/full/local-skills/commit/SKILL.md b/examples/full/local-skills/commit/SKILL.md new file mode 100644 index 0000000..3402a0f --- /dev/null +++ b/examples/full/local-skills/commit/SKILL.md @@ -0,0 +1,6 @@ +--- +name: commit +description: Fixture commit skill. +--- + +Commit fixture. diff --git a/examples/full/local-skills/review/SKILL.md b/examples/full/local-skills/review/SKILL.md new file mode 100644 index 0000000..bec116a --- /dev/null +++ b/examples/full/local-skills/review/SKILL.md @@ -0,0 +1,6 @@ +--- +name: review +description: Fixture review skill. +--- + +Review fixture. diff --git a/package.json b/package.json index 632f43c..47bbb24 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "lint": "pnpm -r lint", "typecheck": "pnpm -r typecheck", "check": "pnpm lint && pnpm typecheck && pnpm test", - "dev": "pnpm --filter @sentry/dotagents dev" + "dev": "pnpm --filter @sentry/dotagents dev", + "smoke:examples": "pnpm build && node scripts/smoke-examples.mjs" }, "simple-git-hooks": { "pre-commit": "pnpm lint-staged" diff --git a/scripts/smoke-examples.mjs b/scripts/smoke-examples.mjs new file mode 100644 index 0000000..24bd009 --- /dev/null +++ b/scripts/smoke-examples.mjs @@ -0,0 +1,255 @@ +#!/usr/bin/env node +// Owns local dotagents example QA. The default path proves file wiring with an +// isolated HOME/state; --codex-runtime additionally proves Codex can spawn the +// generated project agent and always scrubs copied Codex auth/config. + +import { execFileSync } from "node:child_process"; +import { + cpSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + realpathSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, ".."); +const cliPath = join(repoRoot, "packages", "dotagents", "dist", "cli", "index.js"); +const exampleRoot = join(repoRoot, "examples", "full"); +const sentinel = "DOTAGENTS_SUBAGENT_RUNTIME_PROOF_9b8e6f2c"; +const args = new Set(process.argv.slice(2)); +const keep = args.has("--keep"); +const runCodexRuntime = args.has("--codex-runtime"); + +if (!existsSync(cliPath)) { + console.error(`smoke-examples: missing built CLI at ${cliPath}`); + console.error("Run `pnpm build` first."); + process.exit(1); +} + +const tmp = mkdtempSync(join(tmpdir(), "dotagents-example-")); +const projectDir = join(tmp, "project"); +const homeDir = join(tmp, "home"); +const stateDir = join(tmp, "state"); +const dotagentsHomeDir = join(tmp, "dotagents-home"); +const codexHomeDir = join(tmp, "codex-home"); +mkdirSync(homeDir, { recursive: true }); +mkdirSync(stateDir, { recursive: true }); +mkdirSync(dotagentsHomeDir, { recursive: true }); +cpSync(exampleRoot, projectDir, { recursive: true }); + +const fixtureEnv = { + ...process.env, + HOME: homeDir, + DOTAGENTS_HOME: dotagentsHomeDir, + DOTAGENTS_STATE_DIR: stateDir, +}; + +try { + console.log(`smoke-examples: project=${projectDir}`); + runCli(["install"]); + const list = runCli(["list"]); + writeFileSync(join(tmp, "list.out"), list); + runCli(["doctor", "--fix"]); + runCli(["doctor"]); + assertIncludes(list, "review", "list output should include review"); + assertIncludes(list, "commit", "list output should include commit"); + + assertFile(".agents/skills/review/SKILL.md"); + assertFile(".agents/skills/commit/SKILL.md"); + assertFileIncludes(".agents/skills/review/SKILL.md", "name: review"); + assertFileIncludes(".agents/skills/review/SKILL.md", "Review fixture."); + assertFileIncludes(".agents/skills/commit/SKILL.md", "name: commit"); + assertFileIncludes(".agents/skills/commit/SKILL.md", "Commit fixture."); + assertSymlink(".claude/skills"); + assertFile(".mcp.json"); + assertFile(".cursor/mcp.json"); + assertFile(".codex/config.toml"); + assertFile("opencode.json"); + assertFile(".claude/settings.json"); + assertFile(".cursor/hooks.json"); + assertSubagentOutputs(); + + rmSync(join(projectDir, ".mcp.json"), { force: true }); + rmSync(join(projectDir, ".claude", "skills"), { force: true, recursive: true }); + rmSync(join(projectDir, ".claude", "agents", "code-reviewer.md"), { force: true }); + rmSync(join(projectDir, ".codex", "agents", "code-reviewer.toml"), { force: true }); + runCli(["sync"]); + assertFile(".mcp.json"); + assertSymlink(".claude/skills"); + assertFile(".claude/agents/code-reviewer.md"); + assertFile(".codex/agents/code-reviewer.toml"); + + if (runCodexRuntime) { + proveCodexRuntime(); + } + + console.log("smoke-examples: ok"); +} catch (err) { + console.error("smoke-examples: failed"); + console.error(err instanceof Error ? err.message : String(err)); + console.error(`smoke-examples: project kept at ${projectDir}`); + process.exitCode = 1; + throw err; +} finally { + rmSync(codexHomeDir, { recursive: true, force: true }); + if (!keep && process.exitCode !== 1) { + rmSync(tmp, { recursive: true, force: true }); + } +} + +function runCli(cliArgs) { + return execFileSync("node", [cliPath, ...cliArgs], { + cwd: projectDir, + env: fixtureEnv, + encoding: "utf-8", + stdio: ["ignore", "pipe", "inherit"], + }); +} + +function proveCodexRuntime() { + if (!existsSync(join(projectDir, ".git"))) { + execFileSync("git", ["init"], { + cwd: projectDir, + stdio: ["ignore", "ignore", "inherit"], + }); + } + + const realProjectDir = realpathSync(projectDir); + const sourceCodexHome = process.env.CODEX_HOME ?? join(process.env.HOME ?? "", ".codex"); + const sourceAuth = join(sourceCodexHome, "auth.json"); + const sourceConfig = join(sourceCodexHome, "config.toml"); + if (!existsSync(sourceAuth)) { + throw new Error(`Codex runtime smoke requires auth.json at ${sourceAuth}`); + } + + mkdirSync(codexHomeDir, { recursive: true }); + cpSync(sourceAuth, join(codexHomeDir, "auth.json")); + const config = existsSync(sourceConfig) ? readFileSync(sourceConfig, "utf-8") : ""; + writeFileSync( + join(codexHomeDir, "config.toml"), + `${config.trimEnd()}\n\n[projects.${JSON.stringify(realProjectDir)}]\ntrust_level = "trusted"\n`, + ); + + const outputPath = join(tmp, "codex-runtime.jsonl"); + const lastMessagePath = join(tmp, "codex-runtime.out"); + const stderrPath = join(tmp, "codex-runtime.stderr"); + const prompt = [ + "Spawn the custom agent named code-reviewer, wait for it, and return only its exact response.", + "Return only the subagent's exact response.", + "Do not inspect files or answer from project files yourself.", + ].join(" "); + let output; + try { + output = execFileSync( + "codex", + [ + "exec", + "--json", + "-C", + realProjectDir, + "--output-last-message", + lastMessagePath, + prompt, + ], + { + cwd: realProjectDir, + env: { ...process.env, CODEX_HOME: codexHomeDir }, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + } catch (err) { + if (err && typeof err === "object" && "stdout" in err && typeof err.stdout === "string") { + writeFileSync(outputPath, err.stdout); + } + if (err && typeof err === "object" && "stderr" in err && typeof err.stderr === "string") { + writeFileSync(stderrPath, err.stderr); + } + throw err; + } + writeFileSync(outputPath, output); + const lastMessage = readFileSync(lastMessagePath, "utf-8"); + assertIncludes(lastMessage, sentinel, "Codex runtime final message should include the subagent sentinel"); + assertCodexRuntimeEvents(output); +} + +function assertSubagentOutputs() { + assertFile(".agents/agents/code-reviewer.md"); + assertFile(".claude/agents/code-reviewer.md"); + assertFile(".cursor/agents/code-reviewer.md"); + assertFile(".codex/agents/code-reviewer.toml"); + assertFile(".opencode/agents/code-reviewer.md"); + assertFileIncludes("agents.lock", "code-reviewer"); + assertFileIncludes(".claude/agents/code-reviewer.md", "Generated by dotagents"); + assertFileIncludes(".claude/agents/code-reviewer.md", "name: \"code-reviewer\""); + assertFileIncludes(".claude/agents/code-reviewer.md", "A proof-only reviewer."); + assertFileIncludes(".claude/agents/code-reviewer.md", sentinel); + assertFileIncludes(".cursor/agents/code-reviewer.md", "Generated by dotagents"); + assertFileIncludes(".cursor/agents/code-reviewer.md", "name: \"code-reviewer\""); + assertFileIncludes(".cursor/agents/code-reviewer.md", "A proof-only reviewer."); + assertFileIncludes(".cursor/agents/code-reviewer.md", sentinel); + assertFileIncludes(".codex/agents/code-reviewer.toml", "Generated by dotagents"); + assertFileIncludes(".codex/agents/code-reviewer.toml", 'name = "code-reviewer"'); + assertFileIncludes(".codex/agents/code-reviewer.toml", 'description = "A proof-only reviewer.'); + assertFileIncludes(".codex/agents/code-reviewer.toml", "developer_instructions = "); + assertFileIncludes(".codex/agents/code-reviewer.toml", sentinel); + assertFileIncludes(".opencode/agents/code-reviewer.md", "Generated by dotagents"); + assertFileIncludes(".opencode/agents/code-reviewer.md", "A proof-only reviewer."); + assertFileIncludes(".opencode/agents/code-reviewer.md", sentinel); +} + +function assertFile(relativePath) { + const path = join(projectDir, relativePath); + if (!existsSync(path)) { + throw new Error(`expected file to exist: ${relativePath}`); + } +} + +function assertSymlink(relativePath) { + const path = join(projectDir, relativePath); + if (!existsSync(path) || !lstatSync(path).isSymbolicLink()) { + throw new Error(`expected symlink to exist: ${relativePath}`); + } +} + +function assertFileIncludes(relativePath, expected) { + assertFile(relativePath); + assertIncludes(readFileSync(join(projectDir, relativePath), "utf-8"), expected, `${relativePath} should include ${expected}`); +} + +function assertIncludes(value, expected, message) { + if (!value.includes(expected)) { + throw new Error(message); + } +} + +/** Verifies Codex spawned and waited on a child agent that returned the sentinel. */ +function assertCodexRuntimeEvents(output) { + assertIncludes(output, '"tool":"spawn_agent"', "Codex runtime JSONL should include a spawn_agent event"); + assertIncludes(output, '"tool":"wait"', "Codex runtime JSONL should include a wait event"); + if (output.includes("unknown agent_type")) { + throw new Error("Codex runtime JSONL reported an unknown custom agent type"); + } + + for (const line of output.split(/\r?\n/)) { + if (!line.trim()) {continue;} + const event = JSON.parse(line); + const states = event.item?.agents_states; + if (!states || typeof states !== "object") {continue;} + for (const state of Object.values(states)) { + if (state?.message?.includes(sentinel)) { + return; + } + } + } + + throw new Error("Codex runtime JSONL should include a waited child-agent response with the sentinel"); +} diff --git a/skills/dotagents-qa/SKILL.md b/skills/dotagents-qa/SKILL.md index ad8f838..e6e6a31 100644 --- a/skills/dotagents-qa/SKILL.md +++ b/skills/dotagents-qa/SKILL.md @@ -13,28 +13,57 @@ Answer the practical question: "With this local dotagents build, does a represen - `git status --short` - `git diff --stat` - `git diff -- ` -2. Build the local CLI before smoke testing: +2. Read the targeted reference before running harness-specific QA: + - Core install/sync example: [references/core-smoke.md](references/core-smoke.md) + - Codex custom agents: [references/codex.md](references/codex.md) + - Claude Code files/runtime caveats: [references/claude.md](references/claude.md) + - Cursor files/runtime caveats: [references/cursor.md](references/cursor.md) + - OpenCode files/runtime caveats: [references/opencode.md](references/opencode.md) +3. Build the local CLI before smoke testing: ```bash pnpm build ``` -3. Create a temp project that exercises the changed behavior. Start here and add only what the change needs. +4. Prefer the checked-in example smoke for ordinary install/sync QA: + +```bash +pnpm smoke:examples +``` + +For paid Codex runtime proof of generated custom agents, run: + +```bash +node scripts/smoke-examples.mjs --codex-runtime --keep +``` + +The runtime mode copies Codex auth/config into a temp `CODEX_HOME`, marks only the temp example project trusted, and asserts that Codex can spawn the generated `.codex/agents/code-reviewer.toml` agent. See [references/codex.md](references/codex.md) before changing this path. + +5. Create a temp project manually only when the checked-in example does not cover the changed behavior. Start here and add only what the change needs. ```bash set -euo pipefail REPO="$(pwd)" TMP="$(mktemp -d)" -mkdir -p "$TMP/local-skills" "$TMP/home" "$TMP/state" +mkdir -p "$TMP/local-skills" "$TMP/local-agents/agents" "$TMP/home" "$TMP/state" for skill in review commit; do mkdir -p "$TMP/local-skills/$skill" printf -- "---\nname: %s\ndescription: Fixture %s skill.\n---\n\n%s fixture.\n" \ "$skill" "$skill" "$skill" > "$TMP/local-skills/$skill/SKILL.md" done +cat > "$TMP/local-agents/agents/code-reviewer.md" <<'EOF' +--- +name: code-reviewer +description: Review code for correctness. +--- + +Review the current diff and return findings with file references. +EOF + cat > "$TMP/agents.toml" <<'EOF' version = 1 -agents = ["claude", "cursor"] +agents = ["claude", "cursor", "codex", "opencode"] [[skills]] name = "review" @@ -52,10 +81,14 @@ args = ["-e", "process.exit(0)"] [[hooks]] event = "Stop" command = "echo fixture" + +[[subagents]] +name = "code-reviewer" +source = "path:./local-agents" EOF ``` -4. Run the local CLI from inside the temp project, with home/cache isolated: +6. Run the local CLI from inside the temp project, with home/cache isolated: ```bash set -euo pipefail @@ -67,7 +100,7 @@ node "$REPO/packages/dotagents/dist/cli/index.js" doctor --fix node "$REPO/packages/dotagents/dist/cli/index.js" doctor ``` -5. Assert the behavior that matters: +7. Assert the behavior that matters: ```bash set -euo pipefail @@ -76,18 +109,30 @@ test -f .agents/skills/review/SKILL.md && test -f .agents/skills/commit/SKILL.md test -L .claude/skills test -f .mcp.json test -f .cursor/mcp.json +test -f .codex/config.toml +test -f opencode.json test -f .claude/settings.json test -f .cursor/hooks.json +test -f .agents/agents/code-reviewer.md +test -f .claude/agents/code-reviewer.md +test -f .cursor/agents/code-reviewer.md +test -f .codex/agents/code-reviewer.toml +test -f .opencode/agents/code-reviewer.md +grep -q "code-reviewer" agents.lock +grep -q "Generated by dotagents" .claude/agents/code-reviewer.md +grep -q "Generated by dotagents" .codex/agents/code-reviewer.toml ``` -For `sync` changes, break generated state and assert repair: +For `sync` or subagent writer changes, break generated state and assert repair: ```bash set -euo pipefail -rm .mcp.json .claude/skills +rm .mcp.json .claude/skills .claude/agents/code-reviewer.md .codex/agents/code-reviewer.toml node "$REPO/packages/dotagents/dist/cli/index.js" sync test -f .mcp.json test -L .claude/skills +test -f .claude/agents/code-reviewer.md +test -f .codex/agents/code-reviewer.toml ``` ## Adjust The Fixture @@ -95,7 +140,8 @@ test -L .claude/skills - Skill resolution changes: use multiple local skills, nested `skills/` directories, or the exact `path:` layout touched by the change. - Agent placement changes: edit `agents = [...]` and assert expected symlinks/config files. - MCP or hook changes: include representative `[[mcp]]` or `[[hooks]]` entries and inspect generated JSON/TOML. -- User-scope changes: set `DOTAGENTS_HOME="$TMP/user-home"` and pass `--user`; never write to the real home directory. +- Subagent changes: include a portable Markdown fixture under `agents/`, assert the installed canonical file in `.agents/agents/`, assert generated runtime files for Claude/Cursor/Codex/OpenCode, and inspect `agents.lock` for the `subagents` entry. +- User-scope changes: set both `HOME="$TMP/home"` and `DOTAGENTS_HOME="$TMP/user-home"` and pass `--user`; never write to the real home directory. For subagents, assert generated runtime files under `$HOME/.claude/agents/`, `$HOME/.cursor/agents/`, `$HOME/.codex/agents/`, and `$HOME/.config/opencode/agents/`. - Package/runtime changes: run the built CLI as above, or pack/install the package only when the packaging path itself changed. - Remote source changes: use `getsentry/skills`; avoid remotes for ordinary install-location checks. @@ -110,6 +156,8 @@ CODEX_HOME="$TMP/codex-home" codex debug prompt-input "probe skills" > "$TMP/cod grep -q "review" "$TMP/codex-prompt.json" ``` +For Codex subagents, `codex debug prompt-input` only proves runtime discovery if it includes the generated subagent name or instructions. For a real proof, use `node scripts/smoke-examples.mjs --codex-runtime --keep`; project-scoped `.codex/agents/` load only when Codex trusts the project. See [references/codex.md](references/codex.md). + Claude: no cheap dry-run skill list. If auth/network/model cost is acceptable, run a minimal non-interactive `/skill-name` prompt from `$TMP`; otherwise report skipped. ## Optional Remote Check diff --git a/skills/dotagents-qa/references/claude.md b/skills/dotagents-qa/references/claude.md new file mode 100644 index 0000000..72561dc --- /dev/null +++ b/skills/dotagents-qa/references/claude.md @@ -0,0 +1,36 @@ +# Claude Code QA + +Use this reference when changes affect Claude Code skill symlinks, `.claude/settings.json`, `.claude/agents/*.md`, hooks, MCP config, or user-scope Claude paths. + +## File-Level Checks + +The core smoke asserts: + +- `.claude/skills` is a symlink to `.agents/skills` +- `.mcp.json` exists for Claude MCP config +- `.claude/settings.json` exists for hooks +- `.claude/agents/code-reviewer.md` exists +- generated Markdown contains the dotagents managed marker +- `sync` repairs deleted `.claude/skills` and `.claude/agents/code-reviewer.md` + +For user scope, isolate both: + +```bash +HOME="$TMP/home" +DOTAGENTS_HOME="$TMP/user-home" +``` + +Then assert Claude runtime files under `$HOME/.claude/agents/` and user skills under `$DOTAGENTS_HOME/skills/`. + +## Runtime Proof + +There is no cheap dry-run in this QA skill that proves Claude Code loads custom agents. Do not claim Claude runtime discovery from file-level checks alone. + +If a branch specifically requires Claude runtime proof, run an explicit Claude Code invocation only when auth/model cost is acceptable, keep it isolated to a temp project, and report: + +- exact command +- temp project path +- generated `.claude/agents/.md` +- evidence that Claude invoked or surfaced the intended custom agent + +If you skip this, say runtime proof was skipped and file-level wiring passed. diff --git a/skills/dotagents-qa/references/codex.md b/skills/dotagents-qa/references/codex.md new file mode 100644 index 0000000..7e7c294 --- /dev/null +++ b/skills/dotagents-qa/references/codex.md @@ -0,0 +1,51 @@ +# Codex QA + +Use this reference when changes affect Codex config generation, `.codex/agents/*.toml`, Codex MCP config, Codex user scope, or claims that generated Codex subagents work in Codex. + +## File-Level Checks + +The core smoke asserts: + +- `.codex/config.toml` exists for MCP config +- `.codex/agents/code-reviewer.toml` exists +- the generated TOML contains the dotagents managed marker +- the generated TOML contains `name`, `description`, and `developer_instructions` +- `sync` repairs deleted `.codex/agents/code-reviewer.toml` + +These checks prove dotagents wrote the expected files. They do not prove Codex loaded the agent. + +## Runtime Proof + +Run the paid runtime proof when the branch affects Codex custom agents or when reporting that Codex itself works: + +```bash +node scripts/smoke-examples.mjs --codex-runtime --keep +``` + +The script: + +- copies `examples/full/` to a temp project +- runs the built local dotagents CLI +- initializes the temp project as a git repo +- copies Codex auth/config into a temp `CODEX_HOME` +- marks only the temp project trusted in that temp config +- runs `codex exec` +- asks Codex to spawn `code-reviewer` +- asserts the final output includes `DOTAGENTS_SUBAGENT_RUNTIME_PROOF_9b8e6f2c` +- removes the temp `codex-home` even when `--keep` is set + +With `--keep`, inspect: + +- `codex-runtime.out` for the final message +- `codex-runtime.jsonl` for `spawn_agent`, `wait`, and child-agent response events +- `project/.codex/agents/code-reviewer.toml` for the generated file Codex loaded + +## Important Caveats + +Project-scoped `.codex/` layers load only when Codex trusts the project. A one-off `-c projects."".trust_level="trusted"` override did not prove sufficient in local testing; the reliable path is a temp `CODEX_HOME/config.toml` with the canonical real project path marked trusted. + +`codex debug prompt-input` is not enough for custom-agent proof unless it visibly includes the generated agent name or instructions. In local testing it did not expose the custom agent. + +If Codex reports `unknown agent_type`, check project trust and the canonical path (`pwd -P`) before assuming the generated TOML schema is wrong. + +Do not leave copied Codex auth in retained temp directories. The smoke script scrubs its temp `codex-home`; if you run manual experiments, remove copied auth/config before reporting. diff --git a/skills/dotagents-qa/references/core-smoke.md b/skills/dotagents-qa/references/core-smoke.md new file mode 100644 index 0000000..17be8b9 --- /dev/null +++ b/skills/dotagents-qa/references/core-smoke.md @@ -0,0 +1,32 @@ +# Core Smoke + +Use this reference for ordinary dotagents install/sync QA before drilling into a specific runtime. + +## Checked-In Example + +Run: + +```bash +pnpm smoke:examples +``` + +This builds the local CLI, copies `examples/full/` to a temp project, and verifies: + +- `install`, `list`, `doctor --fix`, and `doctor` +- managed skills under `.agents/skills/` +- Claude/Cursor skill symlink behavior +- MCP files for Claude, Cursor, Codex, and OpenCode +- hook files for Claude and Cursor +- canonical installed subagent under `.agents/agents/` +- generated subagent runtime files for Claude, Cursor, Codex, and OpenCode +- `sync` repair after deleting representative generated files + +Use `node scripts/smoke-examples.mjs --keep` to keep the temp project for inspection. The script prints the project path. + +## What This Proves + +This proves dotagents local CLI behavior and generated file placement. It does not prove a third-party runtime actually discovers or executes those generated files unless the runtime-specific proof also runs. + +## When To Customize + +Start from `examples/full/` when a branch changes broad behavior. Create a custom temp fixture only when the example cannot express the changed surface, such as unusual source resolution, user scope, conflict handling, or packaging behavior. diff --git a/skills/dotagents-qa/references/cursor.md b/skills/dotagents-qa/references/cursor.md new file mode 100644 index 0000000..0503885 --- /dev/null +++ b/skills/dotagents-qa/references/cursor.md @@ -0,0 +1,23 @@ +# Cursor QA + +Use this reference when changes affect Cursor skill sharing, `.cursor/mcp.json`, `.cursor/hooks.json`, `.cursor/agents/*.md`, or Cursor user-scope paths. + +## File-Level Checks + +The core smoke asserts: + +- Cursor shares Claude-compatible skills through `.claude/skills` +- `.cursor/mcp.json` exists for Cursor MCP config +- `.cursor/hooks.json` exists for Cursor hooks +- `.cursor/agents/code-reviewer.md` exists +- generated Markdown contains the dotagents managed marker + +Cursor does not get its own `.cursor/skills` symlink in the current expected behavior; it uses Claude-compatible skill placement. + +For user scope, isolate `HOME` and `DOTAGENTS_HOME`, then assert generated Cursor subagents under `$HOME/.cursor/agents/`. + +## Runtime Proof + +This QA skill does not currently include an automated Cursor desktop/runtime proof. Do not claim Cursor runtime discovery from file-level checks alone. + +If a branch specifically requires Cursor runtime proof, use a real Cursor session or a documented headless path if one exists in the local environment. Report the exact interaction and evidence. Otherwise report file-level Cursor wiring only. diff --git a/skills/dotagents-qa/references/opencode.md b/skills/dotagents-qa/references/opencode.md new file mode 100644 index 0000000..4465b07 --- /dev/null +++ b/skills/dotagents-qa/references/opencode.md @@ -0,0 +1,28 @@ +# OpenCode QA + +Use this reference when changes affect OpenCode config generation, `opencode.json`, `.opencode/agents/*.md`, or OpenCode user-scope paths. + +## File-Level Checks + +The core smoke asserts: + +- `opencode.json` exists for OpenCode MCP config +- `.opencode/agents/code-reviewer.md` exists +- generated Markdown contains the dotagents managed marker + +OpenCode does not support dotagents hooks in the current agent definition, so hook warnings for OpenCode are expected when the fixture includes hooks. + +For user scope, isolate `HOME` and `DOTAGENTS_HOME`, then assert generated OpenCode subagents under `$HOME/.config/opencode/agents/`. + +## Runtime Proof + +This QA skill does not currently include an automated OpenCode runtime proof. Do not claim OpenCode runtime discovery from file-level checks alone. + +If a branch specifically requires OpenCode runtime proof, use the installed OpenCode CLI/app and report: + +- exact command or interaction +- temp project path +- generated `.opencode/agents/.md` +- evidence that OpenCode loaded or invoked the expected agent + +Otherwise report file-level OpenCode wiring only. From 8e764d7842b7a923aea107896f76953f55733568 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 11 Jun 2026 20:20:52 -0700 Subject: [PATCH 22/33] fix(subagents): Preserve install consistency Preflight installed subagent conflicts before writing canonical files and roll back write-phase failures so the lockfile fallback cannot omit partially written subagents. Preserve trailing blank lines in native subagent overlays by using keep chomping for newline-terminated YAML block scalars. Co-Authored-By: Codex --- .../src/agents/definitions/helpers.test.ts | 28 ++++++++++++++- .../src/agents/definitions/helpers.ts | 4 +-- .../src/agents/subagent-store.test.ts | 29 +++++++++++++++ .../dotagents/src/agents/subagent-store.ts | 36 ++++++++++++++----- 4 files changed, 86 insertions(+), 11 deletions(-) diff --git a/packages/dotagents/src/agents/definitions/helpers.test.ts b/packages/dotagents/src/agents/definitions/helpers.test.ts index 41f48c0..316e58d 100644 --- a/packages/dotagents/src/agents/definitions/helpers.test.ts +++ b/packages/dotagents/src/agents/definitions/helpers.test.ts @@ -131,7 +131,7 @@ describe("serializeMarkdownSubagent", () => { "Review the diff.", ); - expect(content).toContain("dotagents_native:\n codex: |"); + expect(content).toContain("dotagents_native:\n codex: |+"); expect(content).not.toContain('dotagents_native: {"codex"'); const parsed = parseMarkdownFrontmatterContent(content, "subagent.md"); @@ -139,6 +139,32 @@ describe("serializeMarkdownSubagent", () => { codex: 'name = "code_reviewer"\ndescription = "Review code."\n', }); }); + + it("preserves trailing blank lines in nested native content", () => { + const nativeContent = [ + 'name = "code_reviewer"', + 'description = "Review code."', + "", + "", + ].join("\n"); + const content = serializeMarkdownSubagent( + { + name: "code-reviewer", + description: "Review code.", + dotagents_native: { + codex: nativeContent, + }, + }, + "Review the diff.", + ); + + expect(content).toContain("dotagents_native:\n codex: |+"); + + const parsed = parseMarkdownFrontmatterContent(content, "subagent.md"); + expect(parsed.meta["dotagents_native"]).toEqual({ + codex: nativeContent, + }); + }); }); describe("markManagedMarkdownSubagent", () => { diff --git a/packages/dotagents/src/agents/definitions/helpers.ts b/packages/dotagents/src/agents/definitions/helpers.ts index 35979d7..50c054d 100644 --- a/packages/dotagents/src/agents/definitions/helpers.ts +++ b/packages/dotagents/src/agents/definitions/helpers.ts @@ -177,8 +177,8 @@ function appendYamlField( } if (typeof value === "string" && value.includes("\n")) { - const chomp = value.endsWith("\n") ? "|" : "|-"; - const body = value.endsWith("\n") ? value.slice(0, -1) : value; + const chomp = value.endsWith("\n") ? "|+" : "|-"; + const body = value; lines.push(`${prefix}${toYamlKey(key)}: ${chomp}`); for (const line of body.split(/\r?\n/)) { lines.push(`${prefix} ${line}`); diff --git a/packages/dotagents/src/agents/subagent-store.test.ts b/packages/dotagents/src/agents/subagent-store.test.ts index 2520dca..ef98339 100644 --- a/packages/dotagents/src/agents/subagent-store.test.ts +++ b/packages/dotagents/src/agents/subagent-store.test.ts @@ -476,6 +476,35 @@ Review the current diff. expect(await readFile(filePath, "utf-8")).toBe("hand-written subagent\n"); }); + it("preflights unmanaged conflicts before updating any installed files", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await writeInstalledSubagents(installedDir, [{ + name: "alpha", + description: "Alpha reviewer.", + instructions: "Original alpha instructions.", + }]); + const alphaPath = join(installedDir, "alpha.md"); + const originalAlpha = await readFile(alphaPath, "utf-8"); + const betaPath = join(installedDir, "beta.md"); + await writeFile(betaPath, "hand-written beta\n", "utf-8"); + + await expect(writeInstalledSubagents(installedDir, [ + { + name: "alpha", + description: "Alpha reviewer.", + instructions: "Updated alpha instructions.", + }, + { + name: "beta", + description: "Beta reviewer.", + instructions: "Beta instructions.", + }, + ])).rejects.toThrow(InstalledSubagentWriteError); + + expect(await readFile(alphaPath, "utf-8")).toBe(originalAlpha); + expect(await readFile(betaPath, "utf-8")).toBe("hand-written beta\n"); + }); + it("rejects files that mention the marker outside the managed header", async () => { const installedDir = join(tmpDir, ".agents", "agents"); await mkdir(installedDir, { recursive: true }); diff --git a/packages/dotagents/src/agents/subagent-store.ts b/packages/dotagents/src/agents/subagent-store.ts index 812016f..439e2a7 100644 --- a/packages/dotagents/src/agents/subagent-store.ts +++ b/packages/dotagents/src/agents/subagent-store.ts @@ -148,17 +148,39 @@ export async function writeInstalledSubagents( } await mkdir(subagentsDir, { recursive: true }); - const written: string[] = []; + const plannedWrites: Array<{ + filePath: string; + content: string; + previous?: string; + }> = []; for (const subagent of subagents) { const fileName = `${subagent.name}.md`; const filePath = join(subagentsDir, fileName); const content = serializeInstalledSubagent(subagent); - if (await writeManagedFile(filePath, content)) { - written.push(filePath); + const previous = await readManagedFileForWrite(filePath); + if (previous !== content) { + plannedWrites.push({ filePath, content, previous }); } } + const written: string[] = []; + try { + for (const planned of plannedWrites) { + await writeFile(planned.filePath, planned.content, "utf-8"); + written.push(planned.filePath); + } + } catch (err) { + for (const planned of plannedWrites.slice(0, written.length).toReversed()) { + if (planned.previous === undefined) { + await rm(planned.filePath, { force: true }); + } else { + await writeFile(planned.filePath, planned.previous, "utf-8"); + } + } + throw err; + } + return written; } @@ -521,19 +543,17 @@ function serializeInstalledSubagent(subagent: SubagentDeclaration): string { ); } -async function writeManagedFile(filePath: string, content: string): Promise { +async function readManagedFileForWrite(filePath: string): Promise { try { const existing = await readFile(filePath, "utf-8"); - if (existing === content) {return false;} if (!hasDotagentsMarkdownSubagentMarker(existing)) { throw new InstalledSubagentWriteError(`Subagent file exists and is not managed by dotagents: ${filePath}`); } + return existing; } catch (err) { if (!isNotFoundError(err)) {throw err;} + return undefined; } - - await writeFile(filePath, content, "utf-8"); - return true; } async function pruneManagedMarkdownFiles(dirPath: string, desired: Set): Promise { From 20a6a89f73aee44acdab74bd873da640c24911c3 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 11 Jun 2026 21:07:35 -0700 Subject: [PATCH 23/33] chore: Refresh PR checks Trigger a fresh Warden app evaluation after resolving the stale review thread. Co-Authored-By: Codex From 8da8b6281beabd4a98a66cd165e2ffa0963c84d0 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 11 Jun 2026 21:34:43 -0700 Subject: [PATCH 24/33] fix(subagents): Write installed agents atomically Replace installed subagent files through same-directory temporary files before renaming them into place. This prevents failed writes from truncating existing managed files and keeps rollback lossless. Co-Authored-By: Codex --- .../dotagents/src/agents/subagent-store.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/dotagents/src/agents/subagent-store.ts b/packages/dotagents/src/agents/subagent-store.ts index 439e2a7..eee7c92 100644 --- a/packages/dotagents/src/agents/subagent-store.ts +++ b/packages/dotagents/src/agents/subagent-store.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { basename, extname, isAbsolute, join, relative, resolve } from "node:path"; import { parse as parseTOML } from "smol-toml"; import { @@ -28,6 +28,7 @@ import type { const DOTAGENTS_NATIVE_FIELD = "dotagents_native"; const NATIVE_SUBAGENT_TARGETS = ["claude", "cursor", "codex", "opencode"] satisfies NativeSubagentTarget[]; +let tempFileCounter = 0; interface SubagentScanDir { dir: string; @@ -167,7 +168,7 @@ export async function writeInstalledSubagents( const written: string[] = []; try { for (const planned of plannedWrites) { - await writeFile(planned.filePath, planned.content, "utf-8"); + await replaceFileAtomic(planned.filePath, planned.content); written.push(planned.filePath); } } catch (err) { @@ -175,7 +176,7 @@ export async function writeInstalledSubagents( if (planned.previous === undefined) { await rm(planned.filePath, { force: true }); } else { - await writeFile(planned.filePath, planned.previous, "utf-8"); + await replaceFileAtomic(planned.filePath, planned.previous); } } throw err; @@ -556,6 +557,17 @@ async function readManagedFileForWrite(filePath: string): Promise { + const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${tempFileCounter++}`; + try { + await writeFile(tempPath, content, "utf-8"); + await rename(tempPath, filePath); + } catch (err) { + await rm(tempPath, { force: true }); + throw err; + } +} + async function pruneManagedMarkdownFiles(dirPath: string, desired: Set): Promise { if (!existsSync(dirPath)) {return [];} From cba0eeae1f26d5abd0e6ce0c9f1f2773a9b4b800 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 11 Jun 2026 21:40:12 -0700 Subject: [PATCH 25/33] fix(subagents): Restore in-flight install writes Include the current attempted installed subagent file in rollback so failed write paths restore all affected files conservatively. Co-Authored-By: Codex --- packages/dotagents/src/agents/subagent-store.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/dotagents/src/agents/subagent-store.ts b/packages/dotagents/src/agents/subagent-store.ts index eee7c92..50aeacb 100644 --- a/packages/dotagents/src/agents/subagent-store.ts +++ b/packages/dotagents/src/agents/subagent-store.ts @@ -166,13 +166,19 @@ export async function writeInstalledSubagents( } const written: string[] = []; + let attempted: (typeof plannedWrites)[number] | undefined; try { for (const planned of plannedWrites) { + attempted = planned; await replaceFileAtomic(planned.filePath, planned.content); written.push(planned.filePath); + attempted = undefined; } } catch (err) { - for (const planned of plannedWrites.slice(0, written.length).toReversed()) { + const rollbackWrites = attempted + ? [...plannedWrites.slice(0, written.length), attempted] + : plannedWrites.slice(0, written.length); + for (const planned of rollbackWrites.toReversed()) { if (planned.previous === undefined) { await rm(planned.filePath, { force: true }); } else { From 4d5fb3223164ad4e92832a8a6691f407584e7d4e Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 11 Jun 2026 21:48:35 -0700 Subject: [PATCH 26/33] fix(subagents): Reject unmanaged installed agents Require installed canonical subagent files to carry the dotagents managed marker before loading them for sync or frozen install. This keeps unmanaged user files from driving generated runtime configs. Co-Authored-By: Codex --- .../src/agents/subagent-store.test.ts | 19 +++++++++++++++++-- .../dotagents/src/agents/subagent-store.ts | 9 +++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/dotagents/src/agents/subagent-store.test.ts b/packages/dotagents/src/agents/subagent-store.test.ts index ef98339..1d8044c 100644 --- a/packages/dotagents/src/agents/subagent-store.test.ts +++ b/packages/dotagents/src/agents/subagent-store.test.ts @@ -10,7 +10,7 @@ import { resolveSubagent, writeInstalledSubagents, } from "./subagent-store.js"; -import { DOTAGENTS_SUBAGENT_MARKER } from "./definitions/helpers.js"; +import { DOTAGENTS_SUBAGENT_MARKER, markManagedMarkdownSubagent } from "./definitions/helpers.js"; import type { SubagentConfig } from "../config/schema.js"; const SUBAGENT_MD = (name: string) => `--- @@ -362,7 +362,10 @@ Review the current diff for Claude. it("reports installed subagents whose frontmatter name does not match config", async () => { const installedDir = join(tmpDir, ".agents", "agents"); await mkdir(installedDir, { recursive: true }); - await writeFile(join(installedDir, "code-reviewer.md"), SUBAGENT_MD("other-reviewer")); + await writeFile( + join(installedDir, "code-reviewer.md"), + markManagedMarkdownSubagent(SUBAGENT_MD("other-reviewer")), + ); const result = await loadInstalledSubagents(installedDir, [subagentConfig()]); @@ -372,6 +375,18 @@ Review the current diff for Claude. 'declares name "other-reviewer", but agents.toml requested "code-reviewer"', ); }); + + it("reports unmanaged installed subagents without loading them", async () => { + const installedDir = join(tmpDir, ".agents", "agents"); + await mkdir(installedDir, { recursive: true }); + await writeFile(join(installedDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + const result = await loadInstalledSubagents(installedDir, [subagentConfig()]); + + expect(result.subagents).toEqual([]); + expect(result.issues).toHaveLength(1); + expect(result.issues[0]!.issue).toContain("not managed by dotagents"); + }); }); describe("writeInstalledSubagents", () => { diff --git a/packages/dotagents/src/agents/subagent-store.ts b/packages/dotagents/src/agents/subagent-store.ts index 50aeacb..d16559e 100644 --- a/packages/dotagents/src/agents/subagent-store.ts +++ b/packages/dotagents/src/agents/subagent-store.ts @@ -159,7 +159,7 @@ export async function writeInstalledSubagents( const fileName = `${subagent.name}.md`; const filePath = join(subagentsDir, fileName); const content = serializeInstalledSubagent(subagent); - const previous = await readManagedFileForWrite(filePath); + const previous = await readManagedInstalledSubagentFile(filePath); if (previous !== content) { plannedWrites.push({ filePath, content, previous }); } @@ -209,6 +209,7 @@ export async function loadInstalledSubagents( } try { + await assertManagedInstalledSubagentFile(filePath); const subagent = await loadSubagentFile(filePath); assertSubagentNameMatches(subagent.name, config.name, `${config.name}.md`); subagents.push({ ...subagent, targets: config.targets }); @@ -550,7 +551,11 @@ function serializeInstalledSubagent(subagent: SubagentDeclaration): string { ); } -async function readManagedFileForWrite(filePath: string): Promise { +async function assertManagedInstalledSubagentFile(filePath: string): Promise { + await readManagedInstalledSubagentFile(filePath); +} + +async function readManagedInstalledSubagentFile(filePath: string): Promise { try { const existing = await readFile(filePath, "utf-8"); if (!hasDotagentsMarkdownSubagentMarker(existing)) { From 75f627dda7bef45dbf459543fbfdc77411949739 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 11 Jun 2026 21:57:16 -0700 Subject: [PATCH 27/33] fix(subagents): Delay lock update until prune completes Treat canonical subagent write and prune as one filesystem phase before recording new subagent lock entries. If pruning aborts, keep only unchanged subagent lock entries so the lockfile does not claim a complete install. Co-Authored-By: Codex --- packages/dotagents/src/cli/commands/install.test.ts | 4 ++-- packages/dotagents/src/cli/commands/install.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 1851eb8..4fed3f1 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -476,7 +476,7 @@ path = "code-reviewer.md" expect(existsSync(join(projectRoot, ".agents", "skills", "pdf", "SKILL.md"))).toBe(true); }); - it("keeps written subagent lock entries when stale subagent pruning fails", async () => { + it("does not commit subagent lock entries when stale subagent pruning fails", async () => { const sourceDir = join(projectRoot, "agents"); await mkdir(sourceDir, { recursive: true }); await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); @@ -516,7 +516,7 @@ path = "code-reviewer.md" } const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); - expect(lockfile!.subagents["code-reviewer"]?.source).toBe("path:agents"); + expect(lockfile!.subagents["code-reviewer"]).toBeUndefined(); expect(existsSync(join(installedDir, "code-reviewer.md"))).toBe(true); }); diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index 9ed9d0c..1822b07 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -373,19 +373,19 @@ export async function runInstall(opts: InstallOptions): Promise { } const shouldWriteLockfile = !frozen && (lockfile || config.skills.length > 0 || config.subagents.length > 0); - let installedSubagentsWritten = false; + let installedSubagentsSynced = false; try { if (!frozen) { await writeInstalledSubagents(subagentsDir, installedSubagents); - installedSubagentsWritten = true; await pruneInstalledSubagents(subagentsDir, config.subagents); + installedSubagentsSynced = true; } } catch (err) { if (shouldWriteLockfile) { await writeLockfile(lockPath, { ...newLock, - subagents: installedSubagentsWritten + subagents: installedSubagentsSynced ? newLock.subagents : unchangedSubagentLockEntries(lockfile, newLock), }); From 55a5953d8c43d8d0d9d8c5bcfacbd0f363aa689f Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 11 Jun 2026 22:03:51 -0700 Subject: [PATCH 28/33] fix(remove): Preserve subagent gitignore entries Include lockfile-tracked subagents when remove regenerates .agents/.gitignore. This matches sync and doctor so stale managed canonical subagent files remain ignored until they are pruned. Co-Authored-By: Codex --- .../dotagents/src/cli/commands/remove.test.ts | 32 ++++++++++++++++++- packages/dotagents/src/cli/commands/remove.ts | 8 ++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/dotagents/src/cli/commands/remove.test.ts b/packages/dotagents/src/cli/commands/remove.test.ts index 74b8507..06df0f5 100644 --- a/packages/dotagents/src/cli/commands/remove.test.ts +++ b/packages/dotagents/src/cli/commands/remove.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -7,6 +7,7 @@ import { runRemove, runRemoveSource, collectSkillsFromSource, RemoveError, Wildc import { runInstall } from "./install.js"; import { exec } from "@sentry/dotagents-lib"; import { loadLockfile } from "../../lockfile/loader.js"; +import { writeLockfile } from "../../lockfile/writer.js"; import { loadConfig } from "../../config/loader.js"; import { resolveScope } from "../../scope.js"; @@ -71,6 +72,35 @@ describe("runRemove", () => { expect(lockfile!.skills["pdf"]).toBeUndefined(); }); + it("keeps lockfile subagents in .agents/.gitignore after removing a skill", async () => { + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1\n\n[[skills]]\nname = "pdf"\nsource = "path:local-skills/pdf"\n`, + ); + await mkdir(join(projectRoot, ".agents", "skills", "pdf"), { recursive: true }); + await writeFile(join(projectRoot, ".agents", "skills", "pdf", "SKILL.md"), SKILL_MD("pdf")); + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: { + pdf: { + source: "path:local-skills/pdf", + }, + }, + subagents: { + "old-reviewer": { + source: "path:agents", + }, + }, + }); + + const scope = resolveScope("project", projectRoot); + await runRemove({ scope, skillName: "pdf" }); + + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); + expect(gitignore).not.toContain("/skills/pdf/"); + expect(gitignore).toContain("/agents/old-reviewer.md"); + }); + it("throws RemoveError for skill not in config", async () => { await writeFile(join(projectRoot, "agents.toml"), "version = 1\n"); const scope = resolveScope("project", projectRoot); diff --git a/packages/dotagents/src/cli/commands/remove.ts b/packages/dotagents/src/cli/commands/remove.ts index f0c3a6f..ec3dd93 100644 --- a/packages/dotagents/src/cli/commands/remove.ts +++ b/packages/dotagents/src/cli/commands/remove.ts @@ -156,10 +156,16 @@ async function updateProjectGitignore(scope: ScopeRoot): Promise { if (!dep || isWildcardDep(dep)) {return true;} return !isInPlaceSkill(dep.source); }); + const managedSubagentNames = new Set(config.subagents.map((subagent) => subagent.name)); + if (lockfile) { + for (const name of Object.keys(lockfile.subagents)) { + managedSubagentNames.add(name); + } + } await writeAgentsGitignore( scope.agentsDir, managedNames, - config.subagents.map((subagent) => subagent.name), + [...managedSubagentNames], ); } From 44c56d052c6f8cd2ac747b9d99b3039961f71af0 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 11 Jun 2026 22:10:23 -0700 Subject: [PATCH 29/33] fix(install): Preserve original recovery error Keep the fallback lockfile rewrite best-effort when subagent install or prune fails. This prevents a secondary lockfile write failure from masking the original install failure. Co-Authored-By: Codex --- .../src/cli/commands/install.test.ts | 45 +++++++++++++++++++ .../dotagents/src/cli/commands/install.ts | 16 ++++--- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index 4fed3f1..d69acba 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -476,6 +476,51 @@ path = "code-reviewer.md" expect(existsSync(join(projectRoot, ".agents", "skills", "pdf", "SKILL.md"))).toBe(true); }); + it("preserves the original install error when fallback lockfile write fails", async () => { + const skillSourceDir = join(projectRoot, "local-skills", "pdf"); + await mkdir(skillSourceDir, { recursive: true }); + await writeFile(join(skillSourceDir, "SKILL.md"), SKILL_MD("pdf")); + + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await mkdir(join(projectRoot, ".agents", "agents"), { recursive: true }); + await writeFile( + join(projectRoot, ".agents", "agents", "code-reviewer.md"), + "hand-written subagent\n", + "utf-8", + ); + + const lockPath = join(projectRoot, "agents.lock"); + await writeLockfile(lockPath, { version: 1, skills: {}, subagents: {} }); + await chmod(lockPath, 0o400); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +[[skills]] +name = "pdf" +source = "path:local-skills/pdf" + +[[subagents]] +name = "code-reviewer" +source = "path:agents" +path = "code-reviewer.md" +`, + ); + + const scope = resolveScope("project", projectRoot); + + try { + await expect(runInstall({ scope })).rejects.toThrow( + /Subagent file exists and is not managed by dotagents/, + ); + } finally { + await chmod(lockPath, 0o600).catch(() => {}); + } + }); + it("does not commit subagent lock entries when stale subagent pruning fails", async () => { const sourceDir = join(projectRoot, "agents"); await mkdir(sourceDir, { recursive: true }); diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index 1822b07..7e76e6c 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -383,12 +383,16 @@ export async function runInstall(opts: InstallOptions): Promise { } } catch (err) { if (shouldWriteLockfile) { - await writeLockfile(lockPath, { - ...newLock, - subagents: installedSubagentsSynced - ? newLock.subagents - : unchangedSubagentLockEntries(lockfile, newLock), - }); + try { + await writeLockfile(lockPath, { + ...newLock, + subagents: installedSubagentsSynced + ? newLock.subagents + : unchangedSubagentLockEntries(lockfile, newLock), + }); + } catch { + // Preserve the original install failure; this recovery write is best-effort. + } } if (err instanceof InstalledSubagentWriteError) { throw new InstallError(err.message); From 788aad07a41edeeb9e5085058f90a42d4984b1cd Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 11 Jun 2026 23:00:10 -0700 Subject: [PATCH 30/33] fix(loader): Parse frontmatter delimiters by line Replace the non-greedy frontmatter regex with an explicit delimiter scan. This keeps separator-looking content inside YAML block scalars from ending frontmatter early while preserving existing chomping behavior. Co-Authored-By: Codex --- .../dotagents-lib/src/skills/loader.test.ts | 42 ++++++++++++++++++- packages/dotagents-lib/src/skills/loader.ts | 38 ++++++++++++++--- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/packages/dotagents-lib/src/skills/loader.test.ts b/packages/dotagents-lib/src/skills/loader.test.ts index 1337caf..d9f1321 100644 --- a/packages/dotagents-lib/src/skills/loader.test.ts +++ b/packages/dotagents-lib/src/skills/loader.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtemp, writeFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { loadSkillMd, SkillLoadError } from "./loader.js"; +import { loadSkillMd, parseMarkdownFrontmatterContent, SkillLoadError } from "./loader.js"; describe("loadSkillMd", () => { let dir: string; @@ -154,6 +154,46 @@ Content. expect(meta["metadata"]).toEqual({ author: "vercel", version: "1.0.0" }); }); + it("parses block scalar frontmatter values that contain separator lines", () => { + const parsed = parseMarkdownFrontmatterContent( + `--- +name: code-reviewer +description: Review code +dotagents_native: + codex: |+ + # upstream comment + --- + name = "code_reviewer" +--- + +Review the current diff. +`, + "subagent.md", + ); + + expect(parsed.meta["dotagents_native"]).toEqual({ + codex: '# upstream comment\n---\nname = "code_reviewer"\n', + }); + expect(parsed.body).toBe("Review the current diff."); + }); + + it("does not treat separator prefixes as frontmatter delimiters", () => { + const parsed = parseMarkdownFrontmatterContent( + `--- +name: code-reviewer +description: Review code +---not_a_delimiter: true +--- + +Review the current diff. +`, + "subagent.md", + ); + + expect(parsed.meta["---not_a_delimiter"]).toBe(true); + expect(parsed.body).toBe("Review the current diff."); + }); + it("parses array frontmatter values", async () => { const skillMd = join(dir, "SKILL.md"); await writeFile( diff --git a/packages/dotagents-lib/src/skills/loader.ts b/packages/dotagents-lib/src/skills/loader.ts index 27ce41d..6ba1c35 100644 --- a/packages/dotagents-lib/src/skills/loader.ts +++ b/packages/dotagents-lib/src/skills/loader.ts @@ -32,7 +32,8 @@ export interface LoadSkillMdOptions { onWarning?: (message: string) => void; } -const FRONTMATTER_RE = /^\uFEFF?---[ \t]*\r?\n([\s\S]*?)\r?\n---/; +const FRONTMATTER_OPEN_RE = /^\uFEFF?---[ \t]*\r?\n/; +const FRONTMATTER_CLOSE_RE = /^---[ \t]*$/; /** * Parse a SKILL.md file and extract YAML frontmatter. @@ -82,14 +83,14 @@ export function parseMarkdownFrontmatterContent( content: string, filePath: string, ): MarkdownFrontmatter { - const match = FRONTMATTER_RE.exec(content); - if (!match?.[1]) { + const frontmatter = extractFrontmatter(content); + if (!frontmatter) { throw new SkillLoadError(`No YAML frontmatter in ${filePath}`); } let parsed: unknown; try { - parsed = parseYaml(match[1]); + parsed = parseYaml(frontmatter.yaml); } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new SkillLoadError(`Invalid YAML frontmatter in ${filePath}: ${message}`); @@ -101,11 +102,38 @@ export function parseMarkdownFrontmatterContent( return { meta: parsed as Record, - body: content.slice(match[0].length).trim(), + body: content.slice(frontmatter.end).trim(), raw: content, }; } +function extractFrontmatter(content: string): { yaml: string; end: number } | null { + const opener = FRONTMATTER_OPEN_RE.exec(content); + if (!opener) {return null;} + + const yamlStart = opener[0].length; + let lineStart = yamlStart; + while (lineStart < content.length) { + const lineEnd = content.indexOf("\n", lineStart); + const hasNewline = lineEnd !== -1; + const line = content + .slice(lineStart, hasNewline ? lineEnd : content.length) + .replace(/\r$/, ""); + + if (FRONTMATTER_CLOSE_RE.test(line)) { + return { + yaml: content.slice(yamlStart, lineStart).replace(/\r?\n$/, ""), + end: hasNewline ? lineEnd : content.length, + }; + } + + if (!hasNewline) {break;} + lineStart = lineEnd + 1; + } + + return null; +} + function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } From cd890360143d1d0be351a82600ef599ce3b42d4c Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 11 Jun 2026 23:08:08 -0700 Subject: [PATCH 31/33] fix(sync): Refresh gitignore after subagent prune Prune stale subagent lock entries before regenerating .agents/.gitignore. This keeps sync from leaving ignore entries for subagents it removes in the same run. Co-Authored-By: Codex --- .../dotagents/src/cli/commands/sync.test.ts | 8 ++++++-- packages/dotagents/src/cli/commands/sync.ts | 17 +++++++++-------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/dotagents/src/cli/commands/sync.test.ts b/packages/dotagents/src/cli/commands/sync.test.ts index 3caadf1..7c7df5a 100644 --- a/packages/dotagents/src/cli/commands/sync.test.ts +++ b/packages/dotagents/src/cli/commands/sync.test.ts @@ -584,7 +584,7 @@ agents = ["claude"] expect(lockfile!.subagents).toEqual({}); }); - it("includes lockfile subagents when regenerating gitignore", async () => { + it("removes stale lockfile subagents when regenerating gitignore", async () => { await writeFile( join(projectRoot, "agents.toml"), `version = 1 @@ -609,8 +609,12 @@ agents = ["claude"] await runSync({ scope: resolveScope("project", projectRoot) }); + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.subagents).toEqual({}); + expect(existsSync(join(projectRoot, ".agents", "agents", "old-reviewer.md"))).toBe(false); + const gitignore = await readFile(join(projectRoot, ".agents", ".gitignore"), "utf-8"); - expect(gitignore).toContain("/agents/old-reviewer.md"); + expect(gitignore).not.toContain("/agents/old-reviewer.md"); }); it("does not auto-create root .gitignore", async () => { diff --git a/packages/dotagents/src/cli/commands/sync.ts b/packages/dotagents/src/cli/commands/sync.ts index 338c83c..7228845 100644 --- a/packages/dotagents/src/cli/commands/sync.ts +++ b/packages/dotagents/src/cli/commands/sync.ts @@ -108,6 +108,15 @@ export async function runSync(opts: SyncOptions): Promise { } } + const declaredSubagentNames = new Set(config.subagents.map((subagent) => subagent.name)); + if (lockfile && Object.keys(lockfile.subagents).some((name) => !declaredSubagentNames.has(name))) { + const subagents = Object.fromEntries( + Object.entries(lockfile.subagents).filter(([name]) => declaredSubagentNames.has(name)), + ); + lockfile = { ...lockfile, subagents }; + await writeLockfile(lockPath, lockfile); + } + // 2. Regenerate .agents/.gitignore (skip for user scope) let gitignoreUpdated = false; if (scope.scope === "project") { @@ -244,14 +253,6 @@ export async function runSync(opts: SyncOptions): Promise { let subagentsRepaired = 0; const installedSubagentResult = await loadInstalledSubagents(subagentsDir, config.subagents); const prunedInstalledSubagents = await pruneInstalledSubagents(subagentsDir, config.subagents); - const declaredSubagentNames = new Set(config.subagents.map((subagent) => subagent.name)); - if (lockfile && Object.keys(lockfile.subagents).some((name) => !declaredSubagentNames.has(name))) { - const subagents = Object.fromEntries( - Object.entries(lockfile.subagents).filter(([name]) => declaredSubagentNames.has(name)), - ); - lockfile = { ...lockfile, subagents }; - await writeLockfile(lockPath, lockfile); - } const subagentDecls = installedSubagentResult.subagents; const subagentResolver = scope.scope === "user" ? userSubagentResolver() From 6c9b29c78e2048e8a7a9b66a551ac9031ba37758 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 11 Jun 2026 23:16:09 -0700 Subject: [PATCH 32/33] fix(subagents): Preserve native merge overlays Merge native overlays from every discovered subagent match so native content is not dropped when a later portable match becomes the merge base. Co-Authored-By: Codex --- packages/dotagents/src/agents/subagent-store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dotagents/src/agents/subagent-store.ts b/packages/dotagents/src/agents/subagent-store.ts index d16559e..87e7ab1 100644 --- a/packages/dotagents/src/agents/subagent-store.ts +++ b/packages/dotagents/src/agents/subagent-store.ts @@ -449,7 +449,7 @@ function mergeDiscoveredSubagents( const base = portableMatches[0] ?? matches[0]!; const native: NativeSubagentContent = { ...base.subagent.native }; - for (const match of matches.slice(1)) { + for (const match of matches) { for (const [target, content] of Object.entries(match.subagent.native ?? {})) { const nativeTarget = target as NativeSubagentTarget; native[nativeTarget] ??= content; From dbb88dc9cc95cfc14f385cd64e00898f99168b75 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 11 Jun 2026 23:23:49 -0700 Subject: [PATCH 33/33] fix(install): Recover subagent runtime lock state Restore previous subagent lock entries when runtime subagent config writes or pruning fails so agents.lock does not claim new runtime files are installed after a partial install. Co-Authored-By: Codex --- .../src/cli/commands/install.test.ts | 50 +++++++++++++++++++ .../dotagents/src/cli/commands/install.ts | 29 ++++++++--- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/packages/dotagents/src/cli/commands/install.test.ts b/packages/dotagents/src/cli/commands/install.test.ts index d69acba..17bd773 100644 --- a/packages/dotagents/src/cli/commands/install.test.ts +++ b/packages/dotagents/src/cli/commands/install.test.ts @@ -565,6 +565,56 @@ path = "code-reviewer.md" expect(existsSync(join(installedDir, "code-reviewer.md"))).toBe(true); }); + it("does not commit subagent lock entries when runtime subagent writes fail", async () => { + const skillSourceDir = join(projectRoot, "local-skills", "pdf"); + await mkdir(skillSourceDir, { recursive: true }); + await writeFile(join(skillSourceDir, "SKILL.md"), SKILL_MD("pdf")); + + const sourceDir = join(projectRoot, "agents"); + await mkdir(sourceDir, { recursive: true }); + await writeFile(join(sourceDir, "code-reviewer.md"), SUBAGENT_MD("code-reviewer")); + + await writeLockfile(join(projectRoot, "agents.lock"), { + version: 1, + skills: {}, + subagents: { + "code-reviewer": { + source: "git:https://github.com/example/agents.git", + resolved_url: "https://github.com/example/agents.git", + resolved_path: "agents/code-reviewer.md", + resolved_commit: "abc123", + }, + }, + }); + + await mkdir(join(projectRoot, ".claude"), { recursive: true }); + await writeFile(join(projectRoot, ".claude", "agents"), "not a directory\n"); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1 +agents = ["claude"] + +[[skills]] +name = "pdf" +source = "path:local-skills/pdf" + +[[subagents]] +name = "code-reviewer" +source = "path:agents" +path = "code-reviewer.md" +`, + ); + + const scope = resolveScope("project", projectRoot); + await expect(runInstall({ scope })).rejects.toThrow(); + + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile!.skills["pdf"]).toBeDefined(); + expect(lockfile!.subagents["code-reviewer"]).toBeUndefined(); + expect(existsSync(join(projectRoot, ".agents", "agents", "code-reviewer.md"))).toBe(true); + }); + it("does not prune outside skills dir for malformed lockfile skill names", async () => { const scope = resolveScope("project", projectRoot); const hooksDir = join(projectRoot, ".agents", "hooks"); diff --git a/packages/dotagents/src/cli/commands/install.ts b/packages/dotagents/src/cli/commands/install.ts index 7e76e6c..9edeb54 100644 --- a/packages/dotagents/src/cli/commands/install.ts +++ b/packages/dotagents/src/cli/commands/install.ts @@ -475,13 +475,28 @@ export async function runInstall(opts: InstallOptions): Promise { const subagentResolver = scope.scope === "user" ? userSubagentResolver() : projectSubagentResolver(scope.root); - const subagentResult = await writeSubagentConfigs( - config.agents, - installedSubagents, - subagentResolver, - ); - if (!frozen) { - await pruneSubagentConfigs(config.agents, installedSubagents, subagentResolver); + let subagentResult: Awaited>; + try { + subagentResult = await writeSubagentConfigs( + config.agents, + installedSubagents, + subagentResolver, + ); + if (!frozen) { + await pruneSubagentConfigs(config.agents, installedSubagents, subagentResolver); + } + } catch (err) { + if (shouldWriteLockfile) { + try { + await writeLockfile(lockPath, { + ...newLock, + subagents: unchangedSubagentLockEntries(lockfile, newLock), + }); + } catch { + // Preserve the runtime config failure; this recovery write is best-effort. + } + } + throw err; } return { installed, skipped, pruned, hookWarnings, subagentWarnings: subagentResult.warnings };