Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions docs/harness-configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Harness configuration projection

Canonfig can compile one project-local `.canonfig/` source into native
configuration for multiple AI development harnesses. This feature is separate
from Source/Follower Machine Profile synchronization: it does not change the
published profile contract, transport, synchronization planner, recovery, or
AgentResolution runtime.

## Canonical layout

```text
.canonfig/
harness.yaml
instructions/
AGENTS.md
rules/
skills/
hooks/
agents/
commands/
```

`harness.yaml`, `harness.yml`, and `harness.json` are accepted. Generated-file
ownership is stored in `.canonfig/.harness-state.json`; it contains hashes and
cleanup metadata, never credential values.

## Commands

```bash
canonfig harness init
canonfig harness validate
canonfig harness targets
canonfig harness plan
canonfig harness diff
canonfig harness apply
canonfig harness status
canonfig harness clean
canonfig harness doctor
```

Use repeatable `--target <id>` or comma-separated `--targets <ids>` to select
specific harnesses. `--strict` rejects mappings classified as `shim`, `lossy`,
or `unsupported`. `--force` is required to take ownership of an existing native
entry or externally edited generated file.

## Target identifiers

| Identifier | Harness |
| --- | --- |
| `codex` | OpenAI Codex |
| `claude-code` | Claude Code |
| `amp` | Amp |
| `oh-my-pi` | Oh My Pi |
| `pi` | Pi Coding Agent |
| `factory-droid` | Factory Droid CLI |
| `cursor` | Cursor Agent CLI |
| `devin` | Devin CLI / Devin Local |
| `opencode` | OpenCode |
| `grok-build` | Grok Build CLI |
| `antigravity` | Google Antigravity CLI |
| `copilot-cli` | GitHub Copilot CLI |

Each adapter declares a verification date, documentation references, executable
probes, feature support levels, and target-specific notes. Adapter code is pure:
it converts the canonical model into desired artifacts and diagnostics. A
shared planner owns collision detection, external-edit detection, path safety,
atomic writes, cleanup, and idempotence.

## Safety and ownership

- Native files and keys not owned by Canonfig are preserved.
- Existing conflicting keys become plan conflicts unless `--force` is explicit.
- A generated replacement edited outside Canonfig is never overwritten silently.
- Generated paths are constrained to the repository root, including symlink
resolution.
- MCP secrets remain symbolic environment references.
- Executable hook and plugin shims are shown in the plan before they are written.
- `clean` removes only artifacts represented in the ownership state.

## Canonical example

```yaml
version: 1
project:
name: example

targets:
codex:
enabled: true
options: {}
claude-code:
enabled: true
options: {}

instructions:
root: instructions/AGENTS.md
rules:
- id: frontend
file: rules/frontend.md
paths:
- apps/web/**
activation: path
description: Frontend rules

skills:
roots:
- skills

mcp:
servers:
docs:
transport: streamable-http
url: https://example.invalid/mcp
headers:
Authorization:
fromEnv: DOCS_MCP_TOKEN

hooks: []
agents: []
commands: []
permissions:
rules: []
extensions: {}
```

The canonical model intentionally carries semantic features rather than raw
native file shapes. When a harness changes, its adapter and fixtures can be
updated without changing the loader, planner, ownership model, or other
adapters.
115 changes: 115 additions & 0 deletions src/harness-configuration/adapters/amp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { DesiredArtifact, Diagnostic, HarnessAdapter } from "../core/types.ts";
import { descriptor } from "./descriptor.ts";
import {
agentDocuments,
commandSkillArtifacts,
enabledHooks,
hasEnabledMcpServers,
ruleDocuments,
ruleMarkdown,
skillArtifacts,
standardMcpMap,
standardMcpProjectionDiagnostics,
} from "./shared.ts";
import { nativeTools } from "./tools.ts";
import { AMP_PLUGIN_EVENT_MAP, ampPluginSource } from "../templates/runtime.ts";

const AMP_PLUGIN_EVENTS = new Set(Object.keys(AMP_PLUGIN_EVENT_MAP));

export const ampAdapter: HarnessAdapter = {
descriptor: descriptor(
"amp",
"Amp",
["amp"],
[
"https://ampcode.com/manual",
"https://ampcode.com/manual#mcp",
"https://ampcode.com/manual#agent-skills",
"https://ampcode.com/manual#plugins",
],
{
instructions: "portable",
rules: "translated",
skills: "portable",
mcp: "native",
hooks: "shim",
agents: "translated",
commands: "translated",
},
[
"Hooks and custom subagents compile into an Amp TypeScript plugin.",
"Commands compile to Agent Skills so they remain invokable without relying on an unstable command manifest.",
],
),
async build(context) {
const artifacts: DesiredArtifact[] = [];
const diagnostics: Diagnostic[] = [];

if (hasEnabledMcpServers(context)) {
diagnostics.push(...standardMcpProjectionDiagnostics(context, "amp", false));
artifacts.push({
kind: "json",
path: ".amp/settings.json",
owner: "amp",
operations: [{
kind: "managed-map",
path: ["amp.mcpServers"],
entries: standardMcpMap(context, false),
collision: "error",
}],
});
}

const agents = await agentDocuments(context);
const hooks = enabledHooks(context);
const supportedHooks = hooks.filter((hook) => AMP_PLUGIN_EVENTS.has(hook.event));
for (const hook of hooks) {
if (!AMP_PLUGIN_EVENTS.has(hook.event)) {
diagnostics.push({
level: "warning",
code: "HOOK_EVENT_UNSUPPORTED",
target: "amp",
message: `Amp's generated plugin cannot map hook event ${hook.event}; it was skipped.`,
});
}
}
if (supportedHooks.length > 0 || agents.length > 0) {
artifacts.push({
kind: "replace",
path: ".amp/plugins/canonfig.ts",
owner: "amp",
content: ampPluginSource(
supportedHooks,
agents.map(({ agent, content }) => ({ agent, content, tools: nativeTools("amp", agent) })),
),
});
}

for (const { rule, content } of await ruleDocuments(context)) {
artifacts.push({
kind: "replace",
path: `.amp/rules/${rule.id}.md`,
owner: "amp",
content: ruleMarkdown(rule, content),
});
}

const commonSkillPaths = new Set(
(await skillArtifacts(context, ".agents/skills", "common")).map((artifact) => artifact.path),
);
for (const artifact of await commandSkillArtifacts(context, ".agents/skills", "amp")) {
if (commonSkillPaths.has(artifact.path)) {
diagnostics.push({
level: "error",
code: "TRANSLATED_SKILL_COLLISION",
target: "amp",
path: artifact.path,
message: `Amp command output collides with a canonical skill at ${artifact.path}; rename the skill or command.`,
});
} else {
artifacts.push(artifact);
}
}
return { artifacts, diagnostics };
},
};
70 changes: 70 additions & 0 deletions src/harness-configuration/adapters/antigravity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { DesiredArtifact, Diagnostic, HarnessAdapter } from "../core/types.ts";
import { descriptor } from "./descriptor.ts";
import {
agentSkillArtifacts,
antigravityHooks,
antigravityMcpMap,
commandSkillArtifacts,
enabledHooks,
hasEnabledMcpServers,
ruleDocuments,
ruleMarkdown,
skillArtifacts,
} from "./shared.ts";

export const antigravityAdapter: HarnessAdapter = {
descriptor: descriptor(
"antigravity",
"Google Antigravity CLI",
["agy"],
["https://antigravity.google/docs/mcp", "https://antigravity.google/docs/hooks", "https://antigravity.google/docs/gcli-migration"],
{ instructions: "portable", rules: "native", skills: "portable", mcp: "native", hooks: "native", agents: "translated", commands: "translated" },
["Canonical agents and commands compile to Agent Skills; Antigravity exposes subagents separately from portable agent manifests."],
),
async build(context) {
const artifacts: DesiredArtifact[] = [];
const diagnostics: Diagnostic[] = [];
if (hasEnabledMcpServers(context)) {
artifacts.push({
kind: "json", path: ".agents/mcp_config.json", owner: "antigravity",
operations: [{ kind: "managed-map", path: ["mcpServers"], entries: antigravityMcpMap(context), collision: "error" }],
});
}
if (enabledHooks(context).length > 0) {
const compiled = antigravityHooks(context);
diagnostics.push(...compiled.diagnostics);
if (Object.keys(compiled.entries).length > 0) {
artifacts.push({
kind: "json", path: ".agents/hooks.json", owner: "antigravity",
operations: [{ kind: "managed-map", path: [], entries: compiled.entries, collision: "error" }],
});
}
}
for (const { rule, content } of await ruleDocuments(context)) {
artifacts.push({ kind: "replace", path: `.agents/rules/${rule.id}.md`, owner: "antigravity", content: ruleMarkdown(rule, content, { trigger: rule.paths.length ? "glob" : "always" }) });
}

const commonSkills = await skillArtifacts(context, ".agents/skills", "common");
artifacts.push(...commonSkills);
const occupiedSkillPaths = new Set(commonSkills.map((artifact) => artifact.path));
const translatedSkills = [
...await agentSkillArtifacts(context, ".agents/skills", "antigravity"),
...await commandSkillArtifacts(context, ".agents/skills", "antigravity"),
];
for (const artifact of translatedSkills) {
if (occupiedSkillPaths.has(artifact.path)) {
diagnostics.push({
level: "error",
code: "TRANSLATED_SKILL_COLLISION",
target: "antigravity",
path: artifact.path,
message: `Antigravity translated skill output collides at ${artifact.path}; rename the canonical skill, agent, or command.`,
});
continue;
}
occupiedSkillPaths.add(artifact.path);
artifacts.push(artifact);
}
return { artifacts, diagnostics };
},
};
65 changes: 65 additions & 0 deletions src/harness-configuration/adapters/claude.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { DesiredArtifact, Diagnostic, HarnessAdapter } from "../core/types.ts";
import { descriptor } from "./descriptor.ts";
import {
agentDocuments,
agentMarkdown,
claudeStyleHooks,
commandDocuments,
commandMarkdown,
enabledHooks,
enabledMcpServerEntries,
ruleDocuments,
ruleMarkdown,
skillArtifacts,
standardMcpProjectionDiagnostics,
} from "./shared.ts";
import { nativeTools } from "./tools.ts";

export const claudeAdapter: HarnessAdapter = {
descriptor: descriptor(
"claude-code",
"Claude Code",
["claude"],
["https://docs.anthropic.com/en/docs/claude-code/settings", "https://docs.anthropic.com/en/docs/claude-code/hooks"],
{ instructions: "translated", rules: "native", skills: "native", mcp: "native", hooks: "native", agents: "native", commands: "native" },
["CLAUDE.md is generated as a small bridge to the canonical AGENTS.md."],
),
async build(context) {
const artifacts: DesiredArtifact[] = [{
kind: "managed-text", path: "CLAUDE.md", owner: "claude-code", marker: "instructions-import",
comments: "html", placement: "start", content: "@AGENTS.md",
}];
const diagnostics: Diagnostic[] = standardMcpProjectionDiagnostics(context, "claude-code");

artifacts.push(...await skillArtifacts(context, ".claude/skills", "claude-code"));
for (const { rule, content } of await ruleDocuments(context)) {
artifacts.push({ kind: "replace", path: `.claude/rules/${rule.id}.md`, owner: "claude-code", content: ruleMarkdown(rule, content, rule.paths.length ? { paths: rule.paths } : {}) });
}
if (enabledHooks(context).length > 0) {
const compiled = claudeStyleHooks(context);
diagnostics.push(...compiled.diagnostics);
if (Object.keys(compiled.hooks).length > 0) {
artifacts.push({
kind: "json", path: ".claude/settings.json", owner: "claude-code",
operations: [{ kind: "managed-hooks", path: ["hooks"], hooks: compiled.hooks, marker: ".canonfig/.runtime/hook-runner.mjs" }],
});
}
}
for (const { agent, content } of await agentDocuments(context)) {
const tools = nativeTools("claude-code", agent);
if (agent.tools.includes("mcp")) {
tools.push(...enabledMcpServerEntries(context).map(([name]) => `mcp__${name}`));
}
artifacts.push({
kind: "replace",
path: `.claude/agents/${agent.id}.md`,
owner: "claude-code",
content: agentMarkdown(agent, content, [...new Set(tools)]),
});
}
for (const { command, content } of await commandDocuments(context)) {
artifacts.push({ kind: "replace", path: `.claude/commands/${command.id}.md`, owner: "claude-code", content: commandMarkdown(command, content) });
}
return { artifacts, diagnostics };
},
};
Loading
Loading