Files
ai-development-scaffold/scripts/sync-agent-integrations.py
T
james.bland 3f0786f1e7 refactor: agents and skills move to a client-neutral source that renders per client
`.claude/agents/` was the source of truth, which made every role Claude-Code
shaped. Adding a second client meant rewriting each role in that client's syntax
and maintaining both copies — the drift this scaffold exists to prevent, one layer
up.

Roles and skills now live under `.agents/` and render into each registered
client. `.claude/agents/`, `.claude/skills/` and `.codex/agents/` are generated;
`scripts/sync-agent-integrations.py --check` fails on drift and belongs in CI.

The role metadata is portable rather than vendor-named: `reasoning_tier`
(deep/balanced/fast/vision), `capabilities`, `mutation`, `invocation`, and an
optional `preload_skills`. A client manifest maps those to native syntax and must
declare what it cannot express — `codex.yaml` declares `tier_policy: unsupported`
and its adapters say so in the file, rather than the tier silently evaporating and
leaving the repository to believe it was enforced.

The port is behaviour-preserving where it should be and a fix where it should not.
Every instruction body is byte-identical — the whole diff to `.claude/agents/` is
18 added lines and zero deletions. What changed is frontmatter that was missing:

- four agents (`code-reviewer`, `tdd-guardian`, `dependency-audit`, `pr-creator`)
  declared no `tools:` and therefore inherited the ENTIRE tool pool, so three
  review-only agents could edit and write the code they were reviewing. All eight
  now declare capabilities explicitly.
- the six read-only roles gain a non-editing permission mode, so the constraint is
  enforced by the client rather than by the prompt asking nicely.
- `mutation` is now explicit, which records the two roles that genuinely need to
  write: `pr-creator` (external-write — it pushes a branch and opens a PR) and
  `dependency-audit` (workspace-write — package managers rewrite lockfiles).

`pr-creator` keeps `shell` because opening a PR needs it, but it is now the only
agent here with a write mutation and a declared reason for it, instead of one of
four with unlimited access by omission.
2026-09-19 17:27:26 -04:00

540 lines
20 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate native agent-client adapters from client-neutral repository sources."""
from __future__ import annotations
import argparse
import json
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
_DEFAULT_ROOT = Path(__file__).resolve().parents[1]
_ROLE_MARKER = "Generated from .agents/roles/"
_SKILL_MARKER = ".generated-from-agents"
_MUTATION_POLICIES = frozenset({"read-only", "workspace-write", "external-write"})
_FORMATS = frozenset({"claude-markdown", "codex-toml"})
#: Tier-handling policies a client manifest may declare. `unsupported` is a
#: deliberate, visible position rather than an omission — the alternative
#: considered was emitting a model key the client ignores, which would leave the
#: repository believing tiers were enforced where nothing read them.
TIER_MAPPED = "mapped"
TIER_UNSUPPORTED = "unsupported"
_TIER_POLICIES = frozenset({TIER_MAPPED, TIER_UNSUPPORTED})
#: Printed where a role declares `preload_skills` and the client cannot honour
#: them. Names the skills so the reader knows which standard the agent will NOT
#: arrive holding, rather than the declaration disappearing between layers.
def _preload_notice(skills: tuple[str, ...], comment: str) -> str:
named = ", ".join(skills)
return (
f"{comment} This role declares preload_skills ({named}) and this client\n"
f"{comment} cannot preload: the standard is NOT in context at launch. It\n"
f"{comment} remains discoverable under .agents/skills/ and must be read.\n"
)
#: Printed into an adapter generated for a client that cannot express tiers, so
#: the gap is legible in the artifact a reader actually opens.
_TIER_NOTICE = (
"# The role's reasoning tier is NOT enforced here: this client declares\n"
"# tier_policy = unsupported, so the session default applies. The tier is\n"
"# still authoritative in .agents/roles/ and enforced for clients that map it.\n"
)
@dataclass(frozen=True)
class RoleDefinition:
source: Path
name: str
description: str
reasoning_tier: str
capabilities: tuple[str, ...]
mutation: str
instructions: str
#: Skills whose content this role is launched with already in context.
#: Portable: a client that cannot preload records that it dropped them.
preload_skills: tuple[str, ...] = ()
@dataclass(frozen=True)
class ClientDefinition:
name: str
agent_format: str
agents_path: Path
skills_path: Path | None
reasoning_tiers: dict[str, str]
capabilities: dict[str, str]
#: How this client handles a role's portable reasoning tier. `mapped` means
#: every tier a role uses must resolve through `reasoning_tiers` or the sync
#: refuses; `unsupported` means the client cannot express one, no tier is
#: rendered, and the generated adapter says so where a reader will see it.
#: There is no default: a client that does not declare is refused, because
#: silently dropping the tier is how `codex.yaml` came to run every
#: merge-blocking reviewer on the session default (HF-2026-001173).
tier_policy: str
#: Whether this client can launch an agent with named skills already
#: loaded. False means the role's `preload_skills` cannot be honoured, and
#: the generated adapter says so rather than dropping it silently.
preload_support: bool = False
def _read_json_compatible_yaml(path: Path) -> dict[str, Any]:
text = path.read_text(encoding="utf-8")
payload = json.loads(
"\n".join(
line for line in text.splitlines() if not line.lstrip().startswith("#")
)
)
if not isinstance(payload, dict):
raise ValueError(f"{path}: client definition must be an object")
return payload
def _required_string(payload: dict[str, Any], field: str, source: Path) -> str:
value = payload.get(field)
if not isinstance(value, str) or not value:
raise ValueError(f"{source}: {field} must be a non-empty string")
return value
def _string_map(payload: dict[str, Any], field: str, source: Path) -> dict[str, str]:
value = payload.get(field, {})
if not isinstance(value, dict) or not all(
isinstance(key, str) and key and isinstance(mapped, str) and mapped
for key, mapped in value.items()
):
raise ValueError(f"{source}: {field} must map strings to strings")
return dict(value)
def _destination(root: Path, value: Any, field: str, source: Path) -> Path | None:
if value is None and field == "skills_path":
return None
if not isinstance(value, str) or not value:
raise ValueError(f"{source}: {field} must be a non-empty relative path")
relative = Path(value)
if relative.is_absolute() or ".." in relative.parts:
raise ValueError(f"{source}: {field} must stay inside the repository")
return root / relative
def load_client(source: Path, root: Path) -> ClientDefinition:
"""Load and validate one declarative client adapter definition."""
payload = _read_json_compatible_yaml(source)
agent_format = _required_string(payload, "agent_format", source)
if agent_format not in _FORMATS:
raise ValueError(f"{source}: unsupported agent_format {agent_format!r}")
agents_path = _destination(root, payload.get("agents_path"), "agents_path", source)
assert agents_path is not None
tiers = _string_map(payload, "reasoning_tiers", source)
tier_policy = _required_string(payload, "tier_policy", source)
if tier_policy not in _TIER_POLICIES:
raise ValueError(
f"{source}: unsupported tier_policy {tier_policy!r} — expected one of "
f"{', '.join(sorted(_TIER_POLICIES))}"
)
if tier_policy == TIER_UNSUPPORTED and tiers:
raise ValueError(
f"{source}: declares tier_policy {TIER_UNSUPPORTED!r} but also maps "
f"{sorted(tiers)} — one of the two is wrong, and a contradictory "
f"manifest misleads the next reader about what is enforced"
)
return ClientDefinition(
name=_required_string(payload, "name", source),
agent_format=agent_format,
agents_path=agents_path,
skills_path=_destination(
root, payload.get("skills_path"), "skills_path", source
),
reasoning_tiers=tiers,
capabilities=_string_map(payload, "capabilities", source),
tier_policy=tier_policy,
preload_support=bool(payload.get("preload_support", False)),
)
def validate_preloads(
preloads: tuple[str, ...], root: Path, source: Path
) -> tuple[str, ...]:
"""Refuse a preload naming a skill that does not exist.
A preload pointing at a renamed or deleted skill loads nothing, and the role
goes back to grading against whatever it already knew with no indication
anything is missing. Validated when the role is read, so the failure lands on
the person editing it rather than in a review session weeks later.
"""
for skill in preloads:
if not (root / ".agents" / "skills" / skill / "SKILL.md").is_file():
raise ValueError(
f"{source}: preload_skills names {skill!r}, which has no "
f".agents/skills/{skill}/SKILL.md"
)
return preloads
def parse_role(source: Path) -> RoleDefinition:
"""Parse one portable role definition and validate its common contract."""
text = source.read_text(encoding="utf-8")
if not text.startswith("---\n"):
raise ValueError(f"{source}: missing YAML frontmatter")
try:
frontmatter, body = text[4:].split("\n---\n", maxsplit=1)
except ValueError as error:
raise ValueError(f"{source}: unclosed YAML frontmatter") from error
metadata: dict[str, str] = {}
for line in frontmatter.splitlines():
if not line.strip():
continue
key, separator, value = line.partition(":")
if not separator:
raise ValueError(f"{source}: unsupported frontmatter line {line!r}")
metadata[key.strip()] = value.strip()
required = {"name", "description", "reasoning_tier", "capabilities", "mutation"}
missing = required - metadata.keys()
if missing:
raise ValueError(
f"{source}: missing required frontmatter: {', '.join(sorted(missing))}"
)
mutation = metadata["mutation"]
if mutation not in _MUTATION_POLICIES:
raise ValueError(f"{source}: unsupported mutation policy {mutation!r}")
capabilities = tuple(
item.strip() for item in metadata["capabilities"].split(",") if item.strip()
)
if not capabilities:
raise ValueError(f"{source}: capabilities must not be empty")
preloads = tuple(
item.strip()
for item in metadata.get("preload_skills", "").split(",")
if item.strip()
)
validate_preloads(preloads, _DEFAULT_ROOT, source)
return RoleDefinition(
source=source,
name=metadata["name"],
description=metadata["description"],
reasoning_tier=metadata["reasoning_tier"],
capabilities=capabilities,
mutation=mutation,
instructions=body.strip() + "\n",
preload_skills=preloads,
)
def _mapped_capabilities(role: RoleDefinition, client: ClientDefinition) -> list[str]:
missing = set(role.capabilities) - client.capabilities.keys()
if missing:
raise ValueError(
f"{client.name}: no capability mapping for {', '.join(sorted(missing))}"
)
return [client.capabilities[name] for name in role.capabilities]
def _render_claude(role: RoleDefinition, client: ClientDefinition) -> str:
tier = _resolve_tier(role, client)
tools = _mapped_capabilities(role, client)
lines = [
"---",
f"name: {role.name}",
f"description: {role.description}",
]
if tier is None:
# A markdown client declaring tier_policy: unsupported. Not reachable
# from claude-code.yaml, which maps every tier; written so a future
# manifest cannot render the literal string "None" as a model.
lines.append(f"<!-- {_TIER_NOTICE.strip()} -->")
elif tier != "inherit":
lines.append(f"model: {tier}")
lines.append(f"tools: {', '.join(tools)}")
if role.preload_skills:
if client.preload_support:
lines.append(f"skills: {', '.join(role.preload_skills)}")
else:
lines.append(f"<!-- {_preload_notice(role.preload_skills, '').strip()} -->")
if role.mutation == "read-only":
lines.append("permissionMode: plan")
lines.extend(
[
"---",
f"<!-- {_ROLE_MARKER}{role.source.name}; edit the neutral source. -->",
"",
role.instructions.rstrip(),
"",
]
)
return "\n".join(lines)
def _toml_string(value: str) -> str:
return json.dumps(value, ensure_ascii=False)
def _toml_instructions(value: str) -> str:
if "'''" in value:
return _toml_string(value)
return "'''\n" + value + "'''"
def _resolve_tier(role: RoleDefinition, client: ClientDefinition) -> str | None:
"""The client's model for this role's tier, or None where it has no concept.
Shared by both renderers so "what does this client do with a tier?" has one
answer. Previously `_render_codex` opened with `del client`, which made the
question unanswerable for Codex and silently dropped every tier.
"""
if client.tier_policy == TIER_UNSUPPORTED:
return None
tier = client.reasoning_tiers.get(role.reasoning_tier)
if tier is None:
raise ValueError(
f"{client.name}: no reasoning-tier mapping for {role.reasoning_tier!r}"
)
return tier
def _render_codex(role: RoleDefinition, client: ClientDefinition) -> str:
sandbox = 'sandbox_mode = "read-only"\n' if role.mutation == "read-only" else ""
tier = _resolve_tier(role, client)
if tier is None:
notice, model = _TIER_NOTICE, ""
else:
notice = ""
model = "" if tier == "inherit" else f"model = {_toml_string(tier)}\n"
if role.preload_skills:
if client.preload_support:
listed = ", ".join(_toml_string(s) for s in role.preload_skills)
model += f"skills = [{listed}]\n"
else:
notice += _preload_notice(role.preload_skills, "#")
return (
f"# {_ROLE_MARKER}{role.source.name} by scripts/sync-agent-integrations.py.\n"
"# Edit the client-neutral role, then rerun the sync script.\n"
f"{notice}"
f"name = {_toml_string(role.name)}\n"
f"description = {_toml_string(role.description)}\n"
f"{model}"
f"{sandbox}"
f"developer_instructions = {_toml_instructions(role.instructions)}\n"
)
def render_agent(role: RoleDefinition, client: ClientDefinition) -> str:
"""Render a portable role into one client's native agent format."""
if client.agent_format == "claude-markdown":
return _render_claude(role, client)
if client.agent_format == "codex-toml":
return _render_codex(role, client)
raise ValueError(f"{client.name}: unsupported agent format {client.agent_format!r}")
def _extension(client: ClientDefinition) -> str:
return ".md" if client.agent_format == "claude-markdown" else ".toml"
def _expected_agents(
roles: list[RoleDefinition], client: ClientDefinition
) -> dict[str, str]:
return {
f"{role.name}{_extension(client)}": render_agent(role, client) for role in roles
}
def _is_generated_agent(path: Path) -> bool:
try:
return _ROLE_MARKER in path.read_text(encoding="utf-8")[:2048]
except OSError:
return False
def _skill_files(root: Path) -> dict[Path, bytes]:
source = root / ".agents" / "skills"
expected: dict[Path, bytes] = {}
if not source.exists():
return expected
for path in sorted(source.rglob("*")):
if (
path.is_file()
and path.name != _SKILL_MARKER
and "__pycache__" not in path.parts
):
expected[path.relative_to(source)] = path.read_bytes()
return expected
def _check_client(
client: ClientDefinition,
expected_agents: dict[str, str],
expected_skills: dict[Path, bytes],
) -> list[str]:
findings: list[str] = []
for name, rendered in expected_agents.items():
target = client.agents_path / name
if not target.exists():
findings.append(f"missing: {target}")
elif target.read_text(encoding="utf-8") != rendered:
findings.append(f"stale: {target}")
if client.agents_path.exists():
expected_names = set(expected_agents)
for target in client.agents_path.iterdir():
if (
target.is_file()
and _is_generated_agent(target)
and target.name not in expected_names
):
findings.append(f"orphaned generated agent: {target}")
if client.skills_path is not None:
for relative, content in expected_skills.items():
target = client.skills_path / relative
if not target.exists():
findings.append(f"missing: {target}")
elif target.read_bytes() != content:
findings.append(f"stale: {target}")
if client.skills_path.exists():
expected_paths = set(expected_skills)
expected_roots = {path.parts[0] for path in expected_paths if path.parts}
for child in client.skills_path.iterdir():
if not child.is_dir() or not child.joinpath(_SKILL_MARKER).is_file():
continue
if child.name not in expected_roots:
findings.append(f"orphaned generated skill: {child}")
continue
for target in child.rglob("*"):
if (
target.is_file()
and target.name != _SKILL_MARKER
and target.relative_to(client.skills_path) not in expected_paths
):
findings.append(f"orphaned generated skill file: {target}")
return findings
def _sync_client(
client: ClientDefinition,
expected_agents: dict[str, str],
expected_skills: dict[Path, bytes],
) -> list[str]:
changes: list[str] = []
client.agents_path.mkdir(parents=True, exist_ok=True)
for name, rendered in expected_agents.items():
target = client.agents_path / name
if not target.exists() or target.read_text(encoding="utf-8") != rendered:
target.write_text(rendered, encoding="utf-8")
changes.append(f"wrote: {target}")
expected_names = set(expected_agents)
for target in client.agents_path.iterdir():
if (
target.is_file()
and _is_generated_agent(target)
and target.name not in expected_names
):
target.unlink()
changes.append(f"removed: {target}")
if client.skills_path is not None:
for relative, content in expected_skills.items():
target = client.skills_path / relative
target.parent.mkdir(parents=True, exist_ok=True)
if not target.exists() or target.read_bytes() != content:
target.write_bytes(content)
changes.append(f"wrote: {target}")
for skill_dir in {path.parts[0] for path in expected_skills if path.parts}:
marker = client.skills_path / skill_dir / _SKILL_MARKER
marker.write_text(
"Generated from .agents/skills by scripts/sync-agent-integrations.py.\n",
encoding="utf-8",
)
if client.skills_path.exists():
expected_paths = set(expected_skills)
expected_roots = {path.parts[0] for path in expected_skills if path.parts}
for child in client.skills_path.iterdir():
if not child.is_dir() or not child.joinpath(_SKILL_MARKER).is_file():
continue
if child.name not in expected_roots:
shutil.rmtree(child)
changes.append(f"removed: {child}")
continue
for target in sorted(child.rglob("*"), reverse=True):
if (
target.is_file()
and target.name != _SKILL_MARKER
and target.relative_to(client.skills_path) not in expected_paths
):
target.unlink()
changes.append(f"removed: {target}")
elif target.is_dir() and not any(target.iterdir()):
target.rmdir()
return changes
def _load_repository(root: Path) -> tuple[list[RoleDefinition], list[ClientDefinition]]:
roles = [
parse_role(path) for path in sorted((root / ".agents" / "roles").glob("*.md"))
]
clients = [
load_client(path, root)
for path in sorted((root / ".agents" / "clients").glob("*.yaml"))
]
if not roles:
raise ValueError(f"{root / '.agents/roles'}: no role definitions found")
if not clients:
raise ValueError(f"{root / '.agents/clients'}: no client definitions found")
return roles, clients
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=_DEFAULT_ROOT)
parser.add_argument(
"--check",
action="store_true",
help="report adapter drift and exit non-zero instead of updating files",
)
return parser
def main(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv)
root = args.root.resolve()
try:
roles, clients = _load_repository(root)
skills = _skill_files(root)
if args.check:
findings: list[str] = []
for client in clients:
findings.extend(
_check_client(client, _expected_agents(roles, client), skills)
)
if findings:
print("\n".join(findings))
return 1
print(
f"Agent client adapters are current ({len(roles)} roles, "
f"{len(clients)} clients)"
)
return 0
changes: list[str] = []
for client in clients:
changes.extend(
_sync_client(client, _expected_agents(roles, client), skills)
)
print(
"\n".join(changes) if changes else "Agent client adapters already current"
)
return 0
except (OSError, ValueError, json.JSONDecodeError) as error:
print(f"error: {error}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())