ADR 0005: Adapter Contract Shape

Field Value
Status Accepted
Date Pre-alpha
Deciders Project maintainers
Supersedes
Superseded by
Related ADRs 0001 (Runtime+language), 0006 (Stack-pack shape), 0007 (MCP catalog)

Summary

The Adapter contract is a TypeScript interface with 12 distinct methods split by concern (emitAgentRulesFile, emitVerbs, emitRoles, emitSkills, emitWatchdog, installMCP, etc.) plus a static manifest declaring identity and capabilities. Methods return data (EmittedFile[], ShellCommand[]) rather than performing IO; the CLI orchestrates the actual writes. Adapters and stack packs are siblings that compose orthogonally — neither imports the other.

This ADR captures the shape; the content lives in docs/specs/adapter-contract.md.


Context

The Adapter contract is the most consequential abstraction in aidokit. Every CLI runtime (Claude Code, Codex, future tools) that wants to be supported must implement this contract. A poorly-shaped contract means:

We had to pick the contract shape before any adapter was implemented, because:

  1. The Claude Code adapter is the v0.1 deliverable, and it builds against this contract
  2. The Codex adapter (v1.0) is the second proof; the contract must accommodate two different CLI mechanics
  3. Third-party adapters (post-1.0) are an explicit roadmap item, and they can't be served by a contract that only fits Claude Code
  4. The @aidokit/cli orchestration code is written to this contract; changing the contract means rewriting the CLI

A contract redesign post-1.0 means a spec major bump (per docs/specs/adapter-contract.md §13) and migration scripts for every existing adapter. The cost is high enough that we need to commit to a shape before the first line of adapter code is written.


Decision

1. The contract is a TypeScript interface, not a YAML schema or declarative manifest

export interface Adapter {
  readonly manifest: AdapterManifest;
  emitAgentRulesFile(ctx: ProjectContext): EmittedFile;
  emitEngineConfig(ctx: ProjectContext): EmittedFile[];
  emitVerbs(ctx: ProjectContext): EmittedFile[];
  emitRoles(ctx: ProjectContext): EmittedFile[];
  emitSkills(ctx: ProjectContext, skills: SkillTemplate[]): EmittedFile[];
  emitWatchdog(ctx: ProjectContext): EmittedFile[];
  emitOutputStyles(ctx: ProjectContext): EmittedFile[];
  installMCP(mcp: MCPDef, roles: RoleName[], ctx: ProjectContext): ShellCommand[];
  removeMCP(mcpId: string, ctx: ProjectContext): ShellCommand[];
  listInstalledMCPs(ctx: ProjectContext): Promise<InstalledMCP[]>;
  postInstall(ctx: ProjectContext): ShellCommand[];
  preSync(ctx: ProjectContext): SyncPlan;
  doctor(ctx: ProjectContext): Promise<DoctorCheck[]>;
}

Adapter authors implement this interface in TypeScript (or JavaScript with TS types as type-only imports). The conformance harness verifies behavior at runtime.

2. Methods are split by concern, not consolidated

Each emission concern gets its own method:

Rather than a single emit(ctx): EmittedFile[] method that returns everything mixed together.

3. Manifest + behavior split

Adapters expose two things:

The CLI can inspect the manifest without invoking any method. This enables aidokit manifest, aidokit search, conformance verification, and capability audits without executing adapter code.

4. Methods return data; the CLI performs IO

Every emission method returns plain data objects (EmittedFile[], ShellCommand[]). The CLI is responsible for:

Adapters do not call fs.writeFile. They do not spawn child processes. They are pure providers of intent.

5. Sync where possible, async where IO-bound

This matches the actual nature of each operation and lets the conformance harness test sync methods without ceremony.

6. Adapters and stack packs are siblings, not nested

7. Capability profile mapping is the adapter's job

The spec defines three capability profiles (read-only, docs-only-write, scope-limited-write). The adapter maps each profile to its CLI's mechanism (permissionMode + hooks for Claude Code; sandbox + approval policy for Codex). The CLI does not know how the mapping works; it only knows that the adapter declares one and the manifest disclosure says how strong the enforcement is.

8. The manifest declares gaps honestly

When an adapter cannot satisfy a spec requirement (e.g., Codex has no hook mechanism), the manifest's gaps array documents it. Marketing prompt-level enforcement as deterministic is a conformance violation. Honest gaps > hidden gaps.


Consequences

Positive

Negative

Neutral


Alternatives considered

Alternative 1: Pure declarative manifest (YAML/JSON only, no code)

The adapter is just a static config file declaring "for verb X, emit file Y with template Z."

Pros

Cons

Reason rejected: the moment we need a single conditional branch, we'd reinvent a programming language. JSON/YAML is fine for data (manifest, MCP catalog); not for behavior (contract methods).

Alternative 2: Single emit(ctx): EmittedFile[] method

One method that returns every file the adapter wants to emit.

Pros

Cons

Reason rejected: split methods are more boilerplate but vastly more testable and granular. The conformance harness alone justifies the split.

Alternative 3: Adapter as plugin with arbitrary hooks

The CLI defines lifecycle phases (e.g., 50+ hooks: beforeDetect, afterDetect, beforePromptAdapter, afterPromptAdapter, …). Adapters subscribe.

Pros

Cons

Reason rejected: complexity explosion. The 12-method interface gives the same expressive power with vastly less surface area.

Alternative 4: Inheritance hierarchy (BaseAdapter class)

abstract class BaseAdapter {
  abstract emitVerbs(ctx): EmittedFile[];
  emitAgentRulesFile(ctx): EmittedFile {
    /* default impl */
  }
  // ...
}

class ClaudeCodeAdapter extends BaseAdapter {
  emitVerbs(ctx) {
    /* override */
  }
}

Pros

Cons

Reason rejected: interfaces force every author to make every decision explicitly. Defaults that hide gaps are anti-aligned with the "honest about gaps" principle.

Alternative 5: Interface segregation into many tiny interfaces

interface AgentRulesEmitter { emitAgentRulesFile(ctx): EmittedFile; }
interface VerbEmitter { emitVerbs(ctx): EmittedFile[]; }
interface RoleEmitter { emitRoles(ctx): EmittedFile[]; }
// ...

class ClaudeCodeAdapter implements AgentRulesEmitter, VerbEmitter, RoleEmitter, ... {}

Pros

Cons

Reason rejected: ISP solves a problem we don't have. One coherent interface with documented "MAY return empty array" for optional methods is simpler.

Alternative 6: Adapter writes files directly (no CLI orchestration of IO)

The adapter performs fs.writeFile calls internally; the CLI just invokes it.

Pros

Cons

Reason rejected: every advantage of direct writes is illusory; every disadvantage is severe.

Alternative 7: JSON Schema describing files to emit

The adapter declares "I will emit files matching this JSON Schema" and the CLI generates them from the schema + project context.

Pros

Cons

Reason rejected: Schemas describe shapes, not content. Wrong tool.


Implementation notes

How adapter authors implement the contract

// packages/adapter-claude-code/src/index.ts

import type { Adapter, AdapterManifest } from '@aidokit/core';

const manifest: AdapterManifest = {
  name: 'claude-code',
  // ... full manifest
};

export const adapter: Adapter = {
  manifest,
  emitAgentRulesFile(ctx) {
    /* ... */
  },
  emitEngineConfig(ctx) {
    /* ... */
  },
  // ... all 12 methods
};

The package exports adapter as a named export. @aidokit/cli does:

const adapterPkg = await import('@aidokit/adapter-claude-code');
const adapter: Adapter = adapterPkg.adapter;

Why a const object, not a class

// Preferred
export const adapter: Adapter = {
  /* ... */
};

// Not preferred
export class ClaudeCodeAdapter implements Adapter {
  /* ... */
}

A const object cannot be instantiated multiple times with different state — which is what we want, since adapters are stateless. Classes invite state via this, which we don't need and which complicates the conformance harness.

Type-only imports for non-TS authors

JavaScript-only adapter authors can use type-only imports:

// packages/aidokit-adapter-mytool/src/index.js

/** @type {import('@aidokit/core').Adapter} */
export const adapter = {
  manifest: {
    /* ... */
  },
  emitAgentRulesFile(ctx) {
    /* ... */
  },
  // ...
};

This gives JSDoc-based type checking without compiling TypeScript.

Conformance harness shape

import { runConformanceTests } from '@aidokit/core/conformance';

const results = await runConformanceTests(adapter, 'standard');
// returns ConformanceResult[] with per-requirement pass/fail

Internally, the harness:

  1. Builds a synthetic ProjectContext for each test scenario
  2. Invokes the relevant method
  3. Asserts the returned data matches the requirement
  4. Reports a pass/fail with the specific RFC clause violated

Capability profile mapping in practice

Each adapter declares how it maps capability profiles. For Claude Code:

const PROFILE_MAP = {
  'read-only': { permissionMode: 'plan' },
  'docs-only-write': { permissionMode: 'default', preToolUseHook: 'validate-docs-only.mjs' },
  'scope-limited-write': { permissionMode: 'default', preToolUseHook: 'validate-scope.mjs' },
};

For Codex (sketch):

const PROFILE_MAP = {
  'read-only': { sandbox: 'read-only', approvalPolicy: 'untrusted' },
  'docs-only-write': { sandbox: 'workspace-write' /* + role-prompt discipline */ },
  'scope-limited-write': { sandbox: 'workspace-write' /* + role-prompt discipline */ },
};

The adapter's emitRoles method uses its PROFILE_MAP to populate the right capability fields in each role's frontmatter / config.


Validation

This decision is validated when:


References

External