AGENTS.md · diff

git:20260903.31363ab to git:20260904.475c9da

1 added, 1 removed. Audit A to A.

# AGENTS.md — Engineering contract for opencode-swarm
> **This file is the root engineering contract for opencode-swarm. It applies to every contributor — human, Claude Code, OpenCode agent, or other automated tool. Read it fully before making any code change. It is intentionally short and operational; the long-form rationale and historical failure map live in `docs/engineering-invariants.md`.**
## Required reading order
| When you are about to do… | Read these (in order) |
| --- | --- |
| Any code change | `AGENTS.md` (this file) → `docs/engineering-invariants.md` (skim, deep-dive on touched invariants) |
| Write or modify any test file | this file → `.opencode/skills/writing-tests/SKILL.md` (or `.claude/skills/writing-tests/SKILL.md` for Claude) |
| Commit / push / open a PR | this file → `.claude/skills/commit-pr/SKILL.md` |
| Trace, investigate, root-cause, fix, or resolve an issue or bug | this file → `.opencode/skills/issue-tracer/SKILL.md` (or `.claude/skills/issue-tracer/SKILL.md` for Claude) |
| Swarm-mode Claude work | this file → `CLAUDE.md` → `.claude/session/swarm-mode.md` (when present) |
| Architecture / plugin init / subprocess / tool-registration / plan-durability / .swarm storage / runtime-portability change | this file → `docs/engineering-invariants.md` → `.opencode/skills/engineering-conventions/SKILL.md` (or `.claude/skills/engineering-conventions/SKILL.md`) |
`AGENTS.md` and `docs/engineering-invariants.md` together are the single source of truth for repository invariants. When `CLAUDE.md`, `contributing.md`, `TESTING.md`, or any skill conflicts with this file, **this file wins**; that skill or doc is out of date and must be reconciled.
## Prime directive
Preserve the runtime contracts that keep the plugin **loadable, portable, bounded, recoverable, and safe** across Windows, macOS, Linux, GUI, TUI, Bun, and Node-hosted plugin contexts.
A plugin that loads on macOS but hangs Desktop Windows is a regression. A change that passes locally on one platform but corrupts state on another is a regression. Default to "what could go wrong on the host I am not testing on?"
## Non-negotiable invariants
Every PR that touches a relevant area must list which of these invariants it touched and how it verified them. See "Invariant audit required in PRs" below.
### 1. Plugin initialization is fast, bounded, fail-open, side-effect-minimal
- Plugin registration must complete in bounded time on every supported platform. The OpenCode plugin host silently drops a plugin whose entry never resolves; users see "no agents in TUI/GUI" with no error.
- **No unbounded** filesystem scans, Git commands, network calls, package-manager calls, cache repair, repo-graph construction, or large JSON repair before returning the plugin manifest.
- Any init-path environmental work must be wrapped in `withTimeout(...)` (or equivalent), log non-fatally, and continue. Compare `loadSnapshot(...)` in `src/index.ts` (already wrapped) and `ensureSwarmGitExcluded(...)` (post-fix wrapped).
- **Bounded is not free.** `withTimeout` only prevents an *unbounded* hang — the awaited work's latency still counts toward the cross-platform `repro-704` init deadline (~400 ms). If init-path work does non-trivial I/O and nothing downstream depends on its completion before `server()` resolves, register it with the wrapper-owned post-resolution task queue (the `repoGraphHook` precedent in `src/index.ts`) instead of `await`ing it or using `queueMicrotask` inside `initializeOpenCodeSwarm`. Only `await` init work that is genuinely fast (<~50 ms) **and** must complete before a later init step (e.g. `ensureSwarmGitExcluded` before `.swarm/` writes). Linux `repro-704` passing does **not** prove Windows; cold-FS latency is several× higher and the deadline is enforced on every platform in CI.
- Any init-path subprocess must use **explicit `cwd`**, **`stdin: 'ignore'`** (unless intentionally interactive), **`timeout`**, **bounded or ignored stdout/stderr**, and a **best-effort `proc.kill()` in `finally`**.
- GUI and TUI must still receive the plugin manifest even if optional startup work fails.
- Reference prior failures: v7.0.3 (`#704` repo-graph Desktop hang), v7.3.3 (`#732` Git-hygiene startup regression — the proximate cause of this AGENTS.md), and the PR #1920 merge-queue smoke failure where a `queueMicrotask` began cold-filesystem work during later init awaits. PR #1356 remains the **deferred-work exemplar**, now registered through the wrapper-owned post-resolution task queue: its bundled-skill sync stays off the `server()`-resolution path after an inline-`await` revision was corrected pre-merge (see the in-code comment near the bundled-skill registration and the "Bounded is not free" bullet above).
### 2. Runtime portability — Node-ESM-loadable + OpenCode v1 plugin shape
- The main plugin bundle (`dist/index.js`) must remain Node-ESM-loadable. **No top-level `bun:` imports** (see v6.86.8 / `bundle-portability.test.ts`).
- **No un-fenced `bun:`-scheme runtime module resolution.** A lazy `require('bun:sqlite')` / `createRequire(...)('bun:...')` / dynamic `import('bun:...')` passes the top-level-import check yet still throws under the OpenCode Desktop **Node** sidecar (`Cannot find module 'bun:sqlite'`). All `bun:` runtime resolution must route through a single Node-fallback-providing loader — `bun:sqlite` → `src/db/sqlite-loader.ts` (native `bun:sqlite` under Bun, a `node:sqlite` adapter under Node; issue #1873). Type-only `import type … from 'bun:…'` is fine (erased). Enforced **statically** by `tests/unit/build/bundle-portability.test.ts` (source scan + bundle-pairing, runs on every PR) and **functionally** by `scripts/repro-1873.mjs` (the merge-queue-gated CI `smoke` job, real Node — not on plain PR pushes). Note `node:sqlite` is stricter than `bun:sqlite`: it rejects a bound parameter when the SQL has no placeholder (`SQLITE_RANGE` / "column index out of range"), so keep every query's parameter count exact.
- **No direct `Bun.*` calls** outside `src/utils/bun-compat.ts`. CLI-only modules that intentionally `--target bun` are the only exception.
- The default export must remain the v1 plugin object shape `{ id, server }` (see v6.86.9 / `bundle-plugin-shape.test.ts`).
- Any change touching `src/index.ts`, package exports, `package.json#main`, `bun build` config, or plugin entry shape must run the bundle-portability and plugin-shape tests AND `node --input-type=module -e "await import('./dist/index.js')"`.
- `dist/` is **generated build output and is not committed** (#1047). Do not stage it. CI builds it (`unit`, `package-check`, `smoke` run `bun run build`) and `package-check` validates the packed npm tarball; release/publish builds from source.
### 3. Subprocesses — bounded, non-interactive, killable, portable
- **Array-form spawn only.** No shell-string commands unless the use case is independently justified and quoted.
- Set `cwd` explicitly, OR for Git CLI calls use an explicit `git -C <directory>` argument. Do not rely on inherited process cwd.
- `stdin: 'ignore'` unless the spawn is intentionally interactive. A never-closed stdin pipe under Bun on Windows can block the child from exiting (see v7.3.3 fix).
- `timeout: <ms>` is required for git, package managers, test runners, language tooling, and any external binary. There is no platform on which "git is always fast" is a safe assumption.
- Consume, bound, or ignore stdout/stderr. Never leave a piped stream unattended on a long-running child.
- Best-effort `proc.kill()` in `finally`. An outer `withTimeout` alone is not enough — it lets the awaiter proceed but does not abort the child.
- Windows is first-class. Handle `.cmd` extensions for npm/bun binaries, PATH differences, and the fact that `child_process.spawn('bin', ...)` does not behave identically to `cmd.exe`.
### 4. Working directory and `.swarm/` containment
- Runtime state lives **only** under the project root `.swarm/` directory. Tools must use `ctx.directory` injected via `createSwarmTool` (`src/tools/create-tool.ts`).
- `process.cwd()` is an explicitly documented **direct-CLI / test fallback only**. Do not introduce new `process.cwd()` callers in tools or hooks.
- User-supplied `working_directory` must resolve to a project root, never an ordinary subdirectory. A nested directory is an independent root only when it directly contains a `.git` file/directory (repository, linked worktree, or submodule declaration) or a `.opencode/` directory (#2127). These are explicit local declarations: direct malformed/empty `.git` markers still opt in, but marker symlinks/junctions and inaccessible markers do not. `save_plan`, the shared `resolveWorkingDirectory` helper, every duplicate root guard, and all low-level plan/scope/evidence/evaluation mutation sinks must apply the same policy; ambiguous ancestor `.swarm` or project-indicator state and ancestor-depth exhaustion fail closed. See also v6.82.2 (`#577`).
- No tool may create `.swarm/` under `src/`, `tests/`, `packages/*`, or any arbitrary `cwd`. New-directory checks must be explicit.
- `.swarm/` must not become Git pollution. The `ensureSwarmGitExcluded` flow (v7.3.3) keeps the project's `.git/info/exclude` honest; do not bypass it.
- **Bundled skill ownership:** plugin-shipped runtime protocols materialize only under `.swarm/bundled-skills/<slug>/`. Native skill roots (`.opencode/skills/`, `.claude/skills/`, `.agents/skills/`) are project-owned and must never be a plugin sync destination. A repository skill may reuse a bundled slug without overwrite; architect MODE stubs must load the private runtime copy.
- **Exception — developer skill-sync tool:** `drift:fix` (issue #1781 E3) is a developer-invoked, explicitly-confirmed (`SWARM_SKILL_SYNC_CONFIRM=1`) tool that reconciles native-skill mirror pairs before push. It is NOT a plugin-runtime sync and is never invoked at plugin init or under `DRIFT_CHECK_ENFORCE`. It is exempt from the runtime-sync prohibition above.
- **Exception — version-controlled deliverables:** documentation the docs agent authors into the project repo is *output*, not runtime state. The `docs` agent already writes README/CHANGELOG; the `docs_design` agent (issue #1080, opt-in) writes design docs under `design_docs.out_dir` (default `docs/`). These are intentionally committed and are exempt from `.swarm/` containment. Their *runtime drift signal* (`.swarm/doc-drift-phase-N.json`) stays under `.swarm/`.
### 5. Plan durability — ledger is authoritative
- `.swarm/plan-ledger.jsonl` is the authoritative source of plan state. `.swarm/plan.json` and `.swarm/plan.md` are derived projections.
- `SWARM_PLAN.{json,md}` files are checkpoint / export artifacts, scoped to `.swarm/plan-export/` (issue #852). Legacy reads from flat `.swarm/` and project root are supported with deprecation warnings; cleanup removes all three locations.
- Do not hand-edit `plan.md` as a source of truth. Do not write to `plan.json` outside the ledger replay path.
- Any plan-schema or status-shape change must update **all six** of: ledger replay, projection, checkpoint import/export, `get_approved_plan`, tests, and docs (`docs/plan-durability.md`).
### 6. Test execution — do not use broad `test_runner` for repo validation
- Do not use the OpenCode `test_runner` tool with `scope: 'all'` or broad `'graph'` / `'impact'` scope for **whole-repo validation**. `scope: 'all'` is gated behind the `SWARM_ALLOW_FULL_SUITE=1` env var (intended for opt-in CI mirrors, not interactive use; there is no `allow_full_suite` arg).
- `MAX_SAFE_TEST_FILES = 50` (`src/tools/test-runner.ts`). Resolutions exceeding this return `outcome: 'scope_exceeded'` with a SKIP instruction; broad scopes can stall or kill OpenCode.
- For repo validation, prefer **shell commands** (the per-file isolation loops in `contributing.md` / `TESTING.md`).
- For targeted agent validation, use `test_runner` with explicit `files: [...]` or small targeted scopes.
### 7. Test writing — bun:test, mock isolation, DI over `mock.module`
- All tests use `bun:test` only. No Jest, Vitest, etc. Bun's vitest-compat layer has known isolation bugs.
- Load the writing-tests skill (`.opencode/skills/writing-tests/SKILL.md` or `.claude/skills/writing-tests/SKILL.md`) before modifying tests.
- `mock.module(...)` leaks across test files in Bun's shared test-runner process. Spread the real module when mocking, or — preferred — use **dependency injection** via a small `_internals` seam (see `src/utils/gitignore-warning.ts:_internals` and `src/hooks/diff-scope.ts:_internals` introduced by the v7.3.4 fix). Restore the seam in `afterEach`. Current `_internals` seams include: `src/hooks/knowledge-migrator.ts:_internals` (writeSentinel, mkdir, writeFile for evidence-spoofing tests), `src/evidence/manager.ts:_internals` (validateEvidence, saveEvidence for evidence integrity tests — `src/tools/syntax-check.ts` calls `saveEvidence` through this seam rather than a local snapshot, since a plain object-literal snapshot of an imported binding is captured at module-init time and will not observe a sibling test file's later `mock.module()` replacement — issue #1248 item 16), `src/hooks/guardrails/index.ts:_internals`, `src/hooks/curator.ts:_internals`, and others listed in `docs/engineering-invariants.md`.
- **`_internals` is intentionally absent from `src/lang/backends/php.ts`** (removed in the #1145/#1194 hardening pass; siblings `go.ts`, `python.ts`, `typescript.ts` retain theirs). The PHP backend's tests use public-API testing (`backend.selectFramework?.(tmpDir)`) instead — `selectFramework` had no external consumer needing DI, so the seam was dead weight. Do not re-add it without a concrete test need.
- **FR-006/007/008/009/010/011/012 enforcement:** All new test files must be under 500 lines. `delegation-gate.test.ts` (2835 lines) was split into 45 focused files for FR-006 SC-006.1. SME tests (FR-008) use parameterization for coverage. Lean Turbo behavioral tests (FR-009) cover acquire-locks, plan-lanes, review, runner-status, generate-mutants, set-qa-gates, and get-qa-gate-profile. FR-010/FR-011/FR-012 (Phase 3–4 structural debt) added 11 new hook test files covering previously untested hooks (conflict-resolution, curator-types, delegate-ack-collector, delegate-directive-injection, knowledge-reinforcement, normalize-tool-name, phase-complete-directive-gate, phase-directives, semantic-diff-injection) and consolidated knowledge-curator tests with shared fixtures.
- **Spread-real-exports pattern for `node:*` mocks:** When mocking Node built-ins (`node:fs`, `node:child_process`, `node:fs/promises`, etc.), always spread the real module's exports into the mock return object. This prevents test pollution and ensures only the intended functions are overridden. CI enforces this via `bun run check:mock-cleanup` (Check 2).
```typescript
import * as realFs from 'node:fs';
mock.module('node:fs', () => ({
...realFs, // mandatory — preserves all other exports
readFileSync: mockFn, // override only what you need
}));
```
- **`mock.module` allowlist growth ratchet (issue #1666):** `scripts/mock-allowlist.txt` is closed against unapproved growth by `bun run check:invariants` Check 4. Adding a new `mock.module` target requires a matching standalone marker line `# APPROVED-NEW: <normalized-target>` in `scripts/mock-allowlist.txt` (preserved across regen by `scripts/generate-mock-allowlist.sh`). `MOCK_ALLOWLIST_ENFORCE=0` soft-warns for a deliberate growth PR. Prefer `_internals` DI for new code — the allowlist is a legacy-pattern debt surface, not a way to bypass the seam convention.
- Use `os.tmpdir()` + `path.join(...)` for temp paths. No hardcoded `/tmp` or `C:\` strings.
- `mkdtempSync` must be wrapped in `realpathSync` if the result is `chdir`'d on macOS.
### 8. Session and global state — keyed and bounded
- Anything session-scoped must be keyed by `sessionID` (see v6.80.2 `recentToolCallsBySession` and `lastSpiralTimestampBySession` fixes).
- Module-level global state must have an explicit eviction strategy (`MAX_TRACKED_SESSIONS`, FIFO eviction).
- Add cooldowns for repeated safety/advisory behavior (e.g. spiral-detection 60 s cooldown).
- No cross-session pollution. Global arrays and maps that mix session data are bugs.
### 9. Guardrails / retry semantics
- Distinguish transient infrastructure / provider errors (HTTP 429 / 503 / 529, timeouts, "temporarily unavailable") from real agent-logic failures (see v6.86.14).
- Transient errors use bounded retry (`max_transient_retries`, default 5) **before** counting toward `consecutiveErrors` / circuit-breaker accounting.
- `transientRetryCount` resets per invocation. Do not persist it across invocations.
- Transient retry and model fallback are independent — neither subsumes the other.
- Failure classification is source-aware and structured. Provider, shell/spawn, filesystem, Git, policy/gate, validation, cancellation, and deadline channels must retain distinct categories and retry/repair ownership; bounded display evidence is never reclassified by downstream consumers. Provider quota patterns never run over arbitrary tool output, and deterministic policy/containment failures never enter provider fallback.
- Retry and circuit state is invocation-owned, bounded, and **action-local**, keyed by the exact session, invocation, semantic action digest, and failure category. Parser, structured command-unavailable, and sandbox-wrapper failures may open immediately; repeated permanent failures use their category threshold. An open circuit blocks only the matching action. Read/diagnose/rescope/repair/handoff/abort/Full-Auto-exit controls remain reachable, while a sandbox-wrapper circuit never permits unsandboxed shell execution.
- A corrected successful execution clears only the matching action's circuits. Failures that require external repair use an explicit audited reset bound to the exact active session/invocation/action; stale or foreign resets fail closed. Late results from an older invocation/generation cannot clear current state. The false-to-true transition emits one bounded structured advisory/telemetry event without raw prompt, secret, URL, command, or output text.
- Write authorization requires an exact active v2 scope binding correlated to the current session and Task call. Empty scope, v1 scope, stale plan/task identity, and another session's declaration fail closed with `SCOPE_NOT_DECLARED`; no child state is published before this preflight passes. Exception: the coder write/edit/patch gate (`src/hooks/scope-guard.ts`) emits the more precise `SCOPE_WORKSPACE_MISMATCH` instead of `SCOPE_NOT_DECLARED` when it can positively identify the failure as a wrong-resolved-root case — an active, otherwise-plausible binding for this exact session/task exists but is rooted somewhere other than where the gate resolved (see `describeScopeWorkspaceMismatch`, `src/scope/scope-binding.ts`, issue #2002). This is diagnostic-only — it never widens or narrows what the preflight above would otherwise grant or deny, and does not fire for an unresolvable directory, a `pr_feedback` binding, or a stale/uncorrelated one. The shell-write path (`src/hooks/guardrails/tool-before.ts`) is unaffected and always emits `SCOPE_NOT_DECLARED`.
### 10. Chat / system-message hook contracts
- Preserve OpenCode's expected message shape end-to-end. Multiple `output.system` entries are materialized into multiple `{ role: 'system' }` messages.
- **A hook `output` property must be mutated IN PLACE for the two chat transform chains.** The host invokes each hook as `M(input, output)`, discards the return value, and afterwards reads its own local array — so `output.system = …` / `output.messages = …` inside `experimental.chat.system.transform` or `experimental.chat.messages.transform` is a silent no-op. Use `push` / `splice` / `length = 0` + refill. (`chat.params` / `chat.headers` *do* consume the returned object; do not over-generalize.) Two shipped fixes were dead for years because of this — see v6.85.1 below and `docs/engineering-invariants.md`. Enforced by `tests/unit/hooks/chat-transform-rebind-guard.test.ts`.
- **Model-only guidance rides USER-role guidance carriers — never `role:'system'` entries (issue #2526).** The pinned host (@opencode-ai 1.18.3) message→request converter branches only on `user` and `assistant` with no `else`, and dereferences `msg.parts` unconditionally: a `role:'system'` entry inside `experimental.chat.messages.transform` output is silently discarded before the request is built, and a flat entry without `parts` throws a TypeError in the host prompt build. Every plugin injection must go through `src/hooks/system-guidance-carrier.ts` — a carrier is `{ info: { id: 'swarm-guidance:<kind>', role: 'user' }, parts: [{ type: 'text', text: fenced }] }`, where `fenced` wraps the body in the `<swarm_system_directive source="opencode-swarm" kind="…">` provenance fence; carrier detection is by `info.id` prefix, never text sniffing. Delivery/telemetry predicates assert the host-render contract (`deliveredGuidanceDelta` — non-empty delta + renderable carrier shape), not presence in the plugin's own array. The final structure-mutating handler runs `materializeSystemGuidanceInPlace` (the boundary that converts any remaining system entry to a carrier in place, or drops tool-result/whitespace ones), so the transformed array contains ZERO system entries — a stronger form of the old "one system message at index 0" local-model guarantee (#608/#628), since the host's own system content arrives only via the separate `output.system` string surface. Pinned by `tests/unit/hooks/host-message-role-contract-2526.test.ts` (version tripwire: the installed `@opencode-ai/plugin` and `@opencode-ai/sdk` versions must equal the fixture's `PINNED_HOST_PACKAGE_VERSION`; on a lockfile bump, re-verify `tests/helpers/host-contract-v1_18_3.ts` against the new host source) and `tests/unit/hooks/system-splice-ratchet-2526.test.ts` (no `role:'system'` construction outside the role-filter system-string adapter and the materializer). The pre-#2526 `consolidateSystemMessages` merge and the v6.85.1/#628 `output.system` collapse are both gone.
- Do not emit diagnostic noise into chat-visible streams. Use the debug-gated logger (`src/utils/log.ts`) unless the message is an operational or security warning that must always be visible.
### 11. Tool registration + agent-map coherence
- A tool addition is **incomplete** until: (a) a `TOOL_METADATA` entry in `src/tools/tool-metadata.ts` (description + `agents: [...]` are required fields — a missing one is a compile error), (b) a `TOOL_MANIFEST` handler thunk in `src/tools/manifest.ts` (registered via `defineHandlers<T extends Record<ToolName, () => ToolDefinition>>`; the generic constraint makes the handler set exhaustive vs the metadata keys — a missing or stray handler is a compile error), (c) an export in the `src/tools/index.ts` barrel (still a required surface for wiring and registration tests — enforced by `scripts/check-tool-registration.ts`), (d) help/documentation surfaces, (e) tests covering the new entry. Everything else is **derived, not hand-maintained**: the plugin object is built by `buildPluginToolObject(...)` in `src/index.ts` (there is no manual `tool: {}` block to edit), and `TOOL_NAMES` / `TOOL_NAME_SET` / `AGENT_TOOL_MAP` invert automatically from `TOOL_METADATA.agents` (feature-gated tools instead declare an opt-in map such as `MEMORY_AGENT_TOOL_MAP` in `src/config/constants.ts`).
- Run `tests/unit/config/*.test.ts` and `/swarm doctor tools` after any tool, agent-map, command, or help change. See v6.48.0.
- - **CI drift check (issue #1497):** `.github/workflows/drift-check.yml` runs `scripts/drift-check.ts` on every PR and on `push: main`. It detects drift across skills (`.opencode`↔`.claude` mirror contracts in `src/config/skill-mirrors.ts`), bundled-skill completeness (the issue #1496 class: `.opencode/skills/` vs `BUNDLED_PROJECT_SKILLS` vs `package.json#files`), tool registration (reuses `scripts/check-tool-registration.ts`), commands, agents, public numeric docs claims, and — when `SWARM_DEP_FRESHNESS_CHECK=1` (set in CI) — dependency freshness (issue #1899: locked `@opencode-ai/*` resolution vs npm-latest, a fail-open advisory `notice` that never blocks even under enforce). It is **soft-warn by default** (annotations + a sticky PR comment, non-blocking); set the repo variable `DRIFT_CHECK_ENFORCE=1` for hard-fail. New cross-tree skill pairs must be classified in `src/config/skill-mirrors.ts` or the check warns. Run `bun run drift:check` locally before pushing skill/tool/command/agent/docs-claim changes.
+ - **CI drift check (issue #1497):** `.github/workflows/drift-check.yml` runs `scripts/drift-check.ts --enforce` on every PR and on `push: main`. It detects drift across skills (`.opencode`↔`.claude` mirror contracts in `src/config/skill-mirrors.ts`), bundled-skill completeness (the issue #1496 class: `.opencode/skills/` vs `BUNDLED_PROJECT_SKILLS` vs `package.json#files`), tool registration (reuses `scripts/check-tool-registration.ts`), commands, agents, public numeric docs claims, and — when `SWARM_DEP_FRESHNESS_CHECK=1` (set in CI) — dependency freshness (issue #1899: locked `@opencode-ai/*` resolution vs npm-latest, a fail-open advisory `notice` that never blocks even under enforce). Blocking drift findings fail the CI job while annotations and the sticky PR comment preserve the full report; local `bun run drift:check` remains soft-warn unless invoked with `--enforce` (or `DRIFT_CHECK_ENFORCE=1`). New cross-tree skill pairs must be classified in `src/config/skill-mirrors.ts` or the check warns. Run `bun run drift:check --enforce` locally before pushing skill/tool/command/agent/docs-claim changes.
- **Skill audience metadata:** every tracked static `SKILL.md` in the three native trees declares a top-level `audience`. `audience: swarm-plugin` identifies this repository's static plugin skills. Consumer repositories may use a domain tag and an optional `runner:opencode|claude|codex` constraint. Untagged runtime-generated skills remain backward-compatible match-all because the generator cannot invent a repository identity. Empty or malformed declarations fail closed, and explicit `SKILLS:` references are validated even when automatic propagation is disabled.
- Parity assertions across sibling map entries are intentional. If a parity test fails, mirror the change to siblings (most common) or update the invariant test if the design intent has actually changed.
- **Opt-in tool maps**: Some tools are intentionally gated behind feature flags and use separate opt-in maps (e.g., `MEMORY_AGENT_TOOL_MAP` for memory tools when `memory.enabled === true`). These tools must still have a `TOOL_METADATA` entry, a `TOOL_MANIFEST` handler, and a barrel export in `src/tools/index.ts`, but they are excluded from (or included in) the derived `TOOL_METADATA.agents` lists and merged into agent configs conditionally at build time. Opt-in maps must be validated by tests that verify: (a) tools do NOT appear when the feature is disabled, (b) tools DO appear when the feature is enabled, (c) the merged tool set is correct for each agent role. See the memory-tool merge path in `src/agents/index.ts` and `tests/unit/agents/memory-tool-gating.test.ts` for the current pattern.
- **Agent registration changes must test both legacy unprefixed AND multi-swarm prefixed agent names.** v7.3.x regression: a schema default on `default_agent` ("architect") combined with strict equality demoted every `*_architect` to subagent in multi-swarm configs, so OpenCode showed the plugin as loaded but no swarm architect agents appeared. Tests that only used legacy unprefixed `architect` missed the bug entirely. Any change to primary/subagent selection, `default_agent`, or `getAgentConfigs` MUST include a multi-swarm `swarms: { local: ..., mega: ... }` test that asserts at least one prefixed agent is `mode: 'primary'`.
### 12. Release / cache hygiene
- A plugin fix is incomplete if users stay pinned to a stale OpenCode plugin cache. Install / update / cache changes must cover **all known cache layouts**, including the macOS / Windows variants documented in v6.86.9 (Layout 1 `~/.cache/opencode/packages/opencode-swarm@latest`, Layout 2 `~/.config/opencode/node_modules/opencode-swarm`, Layout 3 `~/.cache/opencode/node_modules/opencode-swarm`).
- Cache-deletion code must use `isSafeCachePath` four-layer defense (depth, basename whitelist, recognized parent, canonical structure).
- **Never hand-edit** `package.json#version`, `CHANGELOG.md`, or `.release-please-manifest.json`. release-please owns those.
- Every user-visible PR ships a `docs/releases/pending/<unique-slug>.md` fragment (mandatory — see `contributing.md`). Do NOT compute a next version or create `docs/releases/vX.Y.Z.md`; release-please owns the version, and `scripts/release-notes-fragments.mjs` aggregates pending fragments into the release PR / GitHub Release body. The aggregated text is the only narrative future users will see.
## Invariant audit required in PRs
Every PR that touches a relevant area must include a `## Invariant audit` section in its description (see `.claude/skills/commit-pr/SKILL.md`). The required format is:
```
## Invariant audit
- 1 (plugin init): touched / not touched — <evidence>
- 2 (runtime portability): touched / not touched — <evidence>
- 3 (subprocesses): touched / not touched — <evidence>
- 4 (.swarm containment): touched / not touched — <evidence>
- 5 (plan durability): touched / not touched — <evidence>
- 6 (test_runner safety): touched / not touched — <evidence>
- 7 (test writing): touched / not touched — <evidence>
- 8 (session state): touched / not touched — <evidence>
- 9 (guardrails/retry): touched / not touched — <evidence>
- 10 (chat/system msg): touched / not touched — <evidence>
- 11 (tool registration): touched / not touched — <evidence>
- 12 (release/cache): touched / not touched — <evidence>
```
For every "touched" entry, the evidence must be a concrete artifact: a command run with its output, a test that proves the invariant, a grep showing no remaining anti-patterns, or a quoted spec citation. "Looks fine" is not evidence.
If you cannot prove a touched invariant from source and test output, **do not push**.
## When in doubt
- Ask, don't guess. The patterns in this file are the result of prior outages — every one has a release note in `docs/releases/`.
- Prefer the smallest patch that closes the issue without unwired functionality, untested branches, or hidden regressions.
- Defense in depth is cheap; an unbounded await is not.