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 toAGENTS.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.mdneeds@../AGENTS.md, and an import that names some other file (@AGENTS.mdthere 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 toAGENTS.mdif it is portable, or keep it as a host delta (after an@AGENTS.mdimport).
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
The drift finding kinds, in report order (see the module docstring). |
|
A skill rendered in a format that drops some of its fields (reported, not scored). |
Functions
|
Coverage and drift of host_files against the canonical |
|
render (the file at root-relative path) as its host reads it, import inlined. |
|
The |
|
|
|
The scanned CLAUDE.md as Claude Code reads it ( |
|
The scanned CLAUDE.md's path relative to the project root ( |
|
Whether an |
|
One |
|
Every host instruction file and rule folder present under root. |
|
Whether |
|
One diagnose suggestion per drifting render. |
Classes
|
One section that differs between |
|
One host instruction file (or rule folder) as found on disk. |
|
Where one agent host looks for project instructions (research table, opsward#15). |
|
Host coverage (host -> relation, |
- 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:
objectOne section that differs between
AGENTS.mdand a render.
- class opsward.portability.HostFile(path, content='', links_to_canonical=False, is_dir=False, compared=True)[source]#
Bases:
objectOne host instruction file (or rule folder) as found on disk.
- class opsward.portability.HostSpec(name, instruction_files=(), rule_dirs=(), reads_agents_md=False, compared=True, skill_target=None)[source]#
Bases:
objectWhere 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:
objectHost coverage (host -> relation,
nativeornone) and drift findings.
- 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.mdtext. Pure.A blank canonical (empty, whitespace or a BOM) counts as absent.
- Return type:
- 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.mdimport 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.mdin.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:
- opsward.portability.canonical_import(importer)[source]#
The
@…import that namesAGENTS.mdfrom the file importer (root-relative).>>> canonical_import("CLAUDE.md"), canonical_import(".claude/CLAUDE.md") ('@AGENTS.md', '@../AGENTS.md')
- Return type:
- 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]#
analyzeover what is on disk under root, pluslossyskill renders.The library entry point. Read-only.
- Return type:
- opsward.portability.claude_md_as_read(scan_result)[source]#
The scanned CLAUDE.md as Claude Code reads it (
as_readat its own path).- Return type:
- opsward.portability.claude_md_relpath(scan_result)[source]#
The scanned CLAUDE.md’s path relative to the project root (
CLAUDE.mdif unknown).- Return type:
- opsward.portability.has_canonical(canonical)[source]#
Whether an
AGENTS.mdtext says anything (a BOM or whitespace does not).- Return type:
- 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
lossyfinding 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
skillpackage installs it (or, for a shared file such asGEMINI.md, when the file holds the skill’s section). Yields nothing whenskillis not installed. Read-only.- Return type:
- 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:
- opsward.portability.resolves_to_canonical(import_path, *, importer)[source]#
Whether
@import_pathin the file importer (root-relative) namesAGENTS.md.Imports resolve against the importing file’s folder (Claude Code’s rule), so the same
@AGENTS.mdis 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: