opsward.portability#

Multi-host portability: which agent hosts a repo satisfies, and canonical↔render drift.

The canonical project brief is AGENTS.md (opsward#15). Every other host’s instruction file (CLAUDE.md, GEMINI.md, .github/copilot-instructions.md, …) is a render of it, related to it in one of three ways:

  • symlink — the file is a symlink to AGENTS.md: byte-identical, cannot drift;

  • import — the file imports it (@AGENTS.md) and adds host-only deltas: the recommended shape for hosts with an import syntax (Claude Code, Gemini CLI). The import path resolves against the importing file’s folder, as Claude Code resolves it: .claude/CLAUDE.md needs @../AGENTS.md, and an import that names some other file (@AGENTS.md there means .claude/AGENTS.md) is no import of the canonical;

  • copy — a separate file: everything in it can drift from the canonical.

The check is a linter: the canonical is authoritative and drift in a render is reported, never merged back. Three finding kinds, matched section by section (by normalised heading):

  • stale — a canonical section is missing from a copy;

  • diverged — a section is in both, with different bodies (a stale render or a hand edit in the render: the files alone cannot say which);

  • host_only — a section only the render has: promote it to AGENTS.md if it is portable, or keep it as a host delta (after an @AGENTS.md import).

Scoped-rule folders (.cursor/rules/, .windsurf/rules/, .clinerules/) count towards coverage but are not compared: they are not renders of one brief.

A fourth kind, lossy, is about skills rather than the brief: a project skill rendered for a host whose format drops some of its fields (allowed-tools, license, bundled resources). Where each host installs skills and what its format drops is the skill package’s registry (skill.install.agent_targets, skill.translate.lost_fields); HostSpec.skill_target points into it, so the two registries are joined, not copied. check_portability reports it when skill is installed (pip install 'opsward[lint]'); it is never scored.

Files that are only coverage signals (CONVENTIONS.md is often a contributor doc, not a render) are compared only when they import or link the canonical.

read_host_files is read-only (it backs scan); analyze is pure.

>>> report = analyze(
...     "# P\n\n## Build\n\npytest\n",
...     {"CLAUDE.md": HostFile("CLAUDE.md", "## Build\n\npytest -x\n## Claude\n\nx\n")},
... )
>>> report.coverage["claude"], report.coverage["codex"]
('copy', 'native')
>>> sorted((f.kind, f.section) for f in report.findings)
[('diverged', 'build'), ('host_only', 'claude')]

Module Attributes

DRIFT_KINDS

The drift finding kinds, in report order (see the module docstring).

LOSSY

A skill rendered in a format that drops some of its fields (reported, not scored).

Functions

analyze(canonical, host_files, *[, hosts])

Coverage and drift of host_files against the canonical AGENTS.md text.

as_read(render, canonical, *[, path])

render (the file at root-relative path) as its host reads it, import inlined.

canonical_import(importer)

The @… import that names AGENTS.md from the file importer (root-relative).

check_portability(root, *[, hosts])

analyze over what is on disk under root, plus lossy skill renders.

claude_md_as_read(scan_result)

The scanned CLAUDE.md as Claude Code reads it (as_read at its own path).

claude_md_relpath(scan_result)

The scanned CLAUDE.md's path relative to the project root (CLAUDE.md if unknown).

has_canonical(canonical)

Whether an AGENTS.md text says anything (a BOM or whitespace does not).

lossy_findings(root, *[, hosts])

One lossy finding per project skill rendered for a host whose format drops fields.

read_host_files(root, *[, hosts])

Every host instruction file and rule folder present under root.

resolves_to_canonical(import_path, *, importer)

Whether @import_path in the file importer (root-relative) names AGENTS.md.

summarize(report)

One diagnose suggestion per drifting render.

Classes

DriftFinding(kind, path, section[, lost])

One section that differs between AGENTS.md and a render.

HostFile(path[, content, ...])

One host instruction file (or rule folder) as found on disk.

HostSpec(name[, instruction_files, ...])

Where one agent host looks for project instructions (research table, opsward#15).

PortabilityReport([coverage, findings])

Host coverage (host -> relation, native or none) and drift findings.

opsward.portability.DRIFT_KINDS = ('stale', 'diverged', 'host_only')#

The drift finding kinds, in report order (see the module docstring).

class opsward.portability.DriftFinding(kind, path, section, lost=())[source]#

Bases: object

One section that differs between AGENTS.md and a render.

class opsward.portability.HostFile(path, content='', links_to_canonical=False, is_dir=False, compared=True)[source]#

Bases: object

One host instruction file (or rule folder) as found on disk.

property relation: str#

symlink, import, rules or copy (see the module docstring).

class opsward.portability.HostSpec(name, instruction_files=(), rule_dirs=(), reads_agents_md=False, compared=True, skill_target=None)[source]#

Bases: object

Where one agent host looks for project instructions (research table, opsward#15).

opsward.portability.LOSSY = 'lossy'#

A skill rendered in a format that drops some of its fields (reported, not scored).

class opsward.portability.PortabilityReport(coverage=dict(), findings=list())[source]#

Bases: object

Host coverage (host -> relation, native or none) and drift findings.

property covered: list[str]#

Hosts that find project instructions, in AGENT_HOSTS order.

opsward.portability.analyze(canonical, host_files, *, hosts={'aider': HostSpec(name='aider', instruction_files=('CONVENTIONS.md',), rule_dirs=(), reads_agents_md=False, compared=False, skill_target=None), 'claude': HostSpec(name='claude', instruction_files=('CLAUDE.md', '.claude/CLAUDE.md'), rule_dirs=('.claude/rules',), reads_agents_md=False, compared=True, skill_target='claude-code'), 'cline': HostSpec(name='cline', instruction_files=('.clinerules',), rule_dirs=('.clinerules',), reads_agents_md=True, compared=True, skill_target='cline'), 'codex': HostSpec(name='codex', instruction_files=(), rule_dirs=(), reads_agents_md=True, compared=True, skill_target=None), 'copilot': HostSpec(name='copilot', instruction_files=('.github/copilot-instructions.md',), rule_dirs=('.github/instructions',), reads_agents_md=True, compared=True, skill_target='copilot'), 'cursor': HostSpec(name='cursor', instruction_files=('.cursorrules',), rule_dirs=('.cursor/rules',), reads_agents_md=True, compared=True, skill_target='cursor'), 'gemini': HostSpec(name='gemini', instruction_files=('GEMINI.md',), rule_dirs=(), reads_agents_md=False, compared=True, skill_target='gemini'), 'windsurf': HostSpec(name='windsurf', instruction_files=('.windsurfrules', '.windsurfrules.md'), rule_dirs=('.windsurf/rules',), reads_agents_md=False, compared=True, skill_target='windsurf'), 'zed': HostSpec(name='zed', instruction_files=('.rules',), rule_dirs=(), reads_agents_md=True, compared=True, skill_target=None)})[source]#

Coverage and drift of host_files against the canonical AGENTS.md text. Pure.

A blank canonical (empty, whitespace or a BOM) counts as absent.

Return type:

PortabilityReport

opsward.portability.as_read(render, canonical, *, path='CLAUDE.md')[source]#

render (the file at root-relative path) as its host reads it, import inlined.

An @…AGENTS.md import that resolves to the canonical from path’s folder is replaced by canonical: a line that is only the import is replaced in place; an import inside a sentence appends the canonical at the end. A copy, or an import that does not resolve (@AGENTS.md in .claude/CLAUDE.md), comes back unchanged, so scoring a shim judges what the agent actually sees, not the three-line file.

>>> as_read("@AGENTS.md\n\n## Claude\n\nx", "## Build\n\npytest\n")
'## Build\n\npytest\n\n## Claude\n\nx'
>>> as_read("Read @AGENTS.md first.", "## Build")
'Read @AGENTS.md first.\n\n## Build'
>>> as_read("## Build\n\npytest", "## Other")
'## Build\n\npytest'
>>> as_read("@AGENTS.md", "## Build", path=".claude/CLAUDE.md")
'@AGENTS.md'
>>> as_read("@../AGENTS.md", "## Build", path=".claude/CLAUDE.md")
'## Build'
Return type:

str

opsward.portability.canonical_import(importer)[source]#

The @… import that names AGENTS.md from the file importer (root-relative).

>>> canonical_import("CLAUDE.md"), canonical_import(".claude/CLAUDE.md")
('@AGENTS.md', '@../AGENTS.md')
Return type:

str

opsward.portability.check_portability(root, *, hosts={'aider': HostSpec(name='aider', instruction_files=('CONVENTIONS.md',), rule_dirs=(), reads_agents_md=False, compared=False, skill_target=None), 'claude': HostSpec(name='claude', instruction_files=('CLAUDE.md', '.claude/CLAUDE.md'), rule_dirs=('.claude/rules',), reads_agents_md=False, compared=True, skill_target='claude-code'), 'cline': HostSpec(name='cline', instruction_files=('.clinerules',), rule_dirs=('.clinerules',), reads_agents_md=True, compared=True, skill_target='cline'), 'codex': HostSpec(name='codex', instruction_files=(), rule_dirs=(), reads_agents_md=True, compared=True, skill_target=None), 'copilot': HostSpec(name='copilot', instruction_files=('.github/copilot-instructions.md',), rule_dirs=('.github/instructions',), reads_agents_md=True, compared=True, skill_target='copilot'), 'cursor': HostSpec(name='cursor', instruction_files=('.cursorrules',), rule_dirs=('.cursor/rules',), reads_agents_md=True, compared=True, skill_target='cursor'), 'gemini': HostSpec(name='gemini', instruction_files=('GEMINI.md',), rule_dirs=(), reads_agents_md=False, compared=True, skill_target='gemini'), 'windsurf': HostSpec(name='windsurf', instruction_files=('.windsurfrules', '.windsurfrules.md'), rule_dirs=('.windsurf/rules',), reads_agents_md=False, compared=True, skill_target='windsurf'), 'zed': HostSpec(name='zed', instruction_files=('.rules',), rule_dirs=(), reads_agents_md=True, compared=True, skill_target=None)})[source]#

analyze over what is on disk under root, plus lossy skill renders.

The library entry point. Read-only.

Return type:

PortabilityReport

opsward.portability.claude_md_as_read(scan_result)[source]#

The scanned CLAUDE.md as Claude Code reads it (as_read at its own path).

Return type:

str

opsward.portability.claude_md_relpath(scan_result)[source]#

The scanned CLAUDE.md’s path relative to the project root (CLAUDE.md if unknown).

Return type:

str

opsward.portability.has_canonical(canonical)[source]#

Whether an AGENTS.md text says anything (a BOM or whitespace does not).

Return type:

bool

opsward.portability.lossy_findings(root, *, hosts={'aider': HostSpec(name='aider', instruction_files=('CONVENTIONS.md',), rule_dirs=(), reads_agents_md=False, compared=False, skill_target=None), 'claude': HostSpec(name='claude', instruction_files=('CLAUDE.md', '.claude/CLAUDE.md'), rule_dirs=('.claude/rules',), reads_agents_md=False, compared=True, skill_target='claude-code'), 'cline': HostSpec(name='cline', instruction_files=('.clinerules',), rule_dirs=('.clinerules',), reads_agents_md=True, compared=True, skill_target='cline'), 'codex': HostSpec(name='codex', instruction_files=(), rule_dirs=(), reads_agents_md=True, compared=True, skill_target=None), 'copilot': HostSpec(name='copilot', instruction_files=('.github/copilot-instructions.md',), rule_dirs=('.github/instructions',), reads_agents_md=True, compared=True, skill_target='copilot'), 'cursor': HostSpec(name='cursor', instruction_files=('.cursorrules',), rule_dirs=('.cursor/rules',), reads_agents_md=True, compared=True, skill_target='cursor'), 'gemini': HostSpec(name='gemini', instruction_files=('GEMINI.md',), rule_dirs=(), reads_agents_md=False, compared=True, skill_target='gemini'), 'windsurf': HostSpec(name='windsurf', instruction_files=('.windsurfrules', '.windsurfrules.md'), rule_dirs=('.windsurf/rules',), reads_agents_md=False, compared=True, skill_target='windsurf'), 'zed': HostSpec(name='zed', instruction_files=('.rules',), rule_dirs=(), reads_agents_md=True, compared=True, skill_target=None)})[source]#

One lossy finding per project skill rendered for a host whose format drops fields.

A skill counts as rendered for a host when its translated file exists where the skill package installs it (or, for a shared file such as GEMINI.md, when the file holds the skill’s section). Yields nothing when skill is not installed. Read-only.

Return type:

Iterator[DriftFinding]

opsward.portability.read_host_files(root, *, hosts={'aider': HostSpec(name='aider', instruction_files=('CONVENTIONS.md',), rule_dirs=(), reads_agents_md=False, compared=False, skill_target=None), 'claude': HostSpec(name='claude', instruction_files=('CLAUDE.md', '.claude/CLAUDE.md'), rule_dirs=('.claude/rules',), reads_agents_md=False, compared=True, skill_target='claude-code'), 'cline': HostSpec(name='cline', instruction_files=('.clinerules',), rule_dirs=('.clinerules',), reads_agents_md=True, compared=True, skill_target='cline'), 'codex': HostSpec(name='codex', instruction_files=(), rule_dirs=(), reads_agents_md=True, compared=True, skill_target=None), 'copilot': HostSpec(name='copilot', instruction_files=('.github/copilot-instructions.md',), rule_dirs=('.github/instructions',), reads_agents_md=True, compared=True, skill_target='copilot'), 'cursor': HostSpec(name='cursor', instruction_files=('.cursorrules',), rule_dirs=('.cursor/rules',), reads_agents_md=True, compared=True, skill_target='cursor'), 'gemini': HostSpec(name='gemini', instruction_files=('GEMINI.md',), rule_dirs=(), reads_agents_md=False, compared=True, skill_target='gemini'), 'windsurf': HostSpec(name='windsurf', instruction_files=('.windsurfrules', '.windsurfrules.md'), rule_dirs=('.windsurf/rules',), reads_agents_md=False, compared=True, skill_target='windsurf'), 'zed': HostSpec(name='zed', instruction_files=('.rules',), rule_dirs=(), reads_agents_md=True, compared=True, skill_target=None)})[source]#

Every host instruction file and rule folder present under root. Read-only.

Return type:

dict

opsward.portability.resolves_to_canonical(import_path, *, importer)[source]#

Whether @import_path in the file importer (root-relative) names AGENTS.md.

Imports resolve against the importing file’s folder (Claude Code’s rule), so the same @AGENTS.md is the canonical from the root and a missing file from .claude/.

>>> resolves_to_canonical("AGENTS.md", importer="CLAUDE.md")
True
>>> resolves_to_canonical("AGENTS.md", importer=".claude/CLAUDE.md")
False
>>> resolves_to_canonical("../AGENTS.md", importer=".claude/CLAUDE.md")
True
Return type:

bool

opsward.portability.summarize(report)[source]#

One diagnose suggestion per drifting render.

Return type:

Iterator[str]