# 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.

```pycon
>>> 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`](#opsward.portability.DRIFT_KINDS)   | The drift finding kinds, in report order (see the module docstring).               |
|----------------------------------------------------------------|------------------------------------------------------------------------------------|
| [`LOSSY`](#opsward.portability.LOSSY)         | A skill rendered in a format that drops some of its fields (reported, not scored). |

### Functions

| [`analyze`](#opsward.portability.analyze)(canonical, host_files, \*[, hosts])      | Coverage and drift of *host_files* against the *canonical* `AGENTS.md` text.         |
|---------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------|
| [`as_read`](#opsward.portability.as_read)(render, canonical, \*[, path])           | *render* (the file at root-relative *path*) as its host reads it, import inlined.    |
| [`canonical_import`](#opsward.portability.canonical_import)(importer)                       | The `@…` import that names `AGENTS.md` from the file *importer* (root-relative).     |
| [`check_portability`](#opsward.portability.check_portability)(root, \*[, hosts])             | `analyze` over what is on disk under *root*, plus `lossy` skill renders.             |
| [`claude_md_as_read`](#opsward.portability.claude_md_as_read)(scan_result)                   | The scanned CLAUDE.md as Claude Code reads it (`as_read` at its own path).           |
| [`claude_md_relpath`](#opsward.portability.claude_md_relpath)(scan_result)                   | The scanned CLAUDE.md's path relative to the project root (`CLAUDE.md` if unknown).  |
| [`has_canonical`](#opsward.portability.has_canonical)(canonical)                         | Whether an `AGENTS.md` text says anything (a BOM or whitespace does not).            |
| [`lossy_findings`](#opsward.portability.lossy_findings)(root, \*[, hosts])                | One `lossy` finding per project skill rendered for a host whose format drops fields. |
| [`read_host_files`](#opsward.portability.read_host_files)(root, \*[, hosts])               | Every host instruction file and rule folder present under *root*.                    |
| [`resolves_to_canonical`](#opsward.portability.resolves_to_canonical)(import_path, \*, importer) | Whether `@import_path` in the file *importer* (root-relative) names `AGENTS.md`.     |
| [`summarize`](#opsward.portability.summarize)(report)                                | One diagnose suggestion per drifting render.                                         |

### Classes

| [`DriftFinding`](#opsward.portability.DriftFinding)(kind, path, section[, lost])   | One section that differs between `AGENTS.md` and a render.                        |
|----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------|
| [`HostFile`](#opsward.portability.HostFile)(path[, content, ...])              | One host instruction file (or rule folder) as found on disk.                      |
| [`HostSpec`](#opsward.portability.HostSpec)(name[, instruction_files, ...])    | Where one agent host looks for project instructions (research table, opsward#15). |
| [`PortabilityReport`](#opsward.portability.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=())

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#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)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

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

#### *property* relation *: [str](https://docs.python.org/3/builtins/stdtypes.html#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)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#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())

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

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

#### *property* covered *: [list](https://docs.python.org/3/builtins/stdtypes.html#list)[[str](https://docs.python.org/3/builtins/stdtypes.html#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)})

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.PortabilityReport)

### opsward.portability.as_read(render, canonical, , path='CLAUDE.md')

*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.

```pycon
>>> 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`](https://docs.python.org/3/builtins/stdtypes.html#str)

### opsward.portability.canonical_import(importer)

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

```pycon
>>> canonical_import("CLAUDE.md"), canonical_import(".claude/CLAUDE.md")
('@AGENTS.md', '@../AGENTS.md')
```

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#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)})

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

The library entry point. Read-only.

* **Return type:**
  [`PortabilityReport`](#opsward.portability.PortabilityReport)

### opsward.portability.claude_md_as_read(scan_result)

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

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### opsward.portability.claude_md_relpath(scan_result)

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

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### opsward.portability.has_canonical(canonical)

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

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#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)})

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`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Iterator)[[`DriftFinding`](#opsward.portability.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)})

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

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### opsward.portability.resolves_to_canonical(import_path, , importer)

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/`.

```pycon
>>> 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`](https://docs.python.org/3/builtins/functions.html#bool)

### opsward.portability.summarize(report)

One diagnose suggestion per drifting render.

* **Return type:**
  [`Iterator`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Iterator)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]
