Skip to content
Draft
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
45 changes: 45 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Tests

on:
push:
branches: [main]
pull_request:

permissions: {}

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# One entry per tool. Add a directory here when you add a package.
package: [agents-md]
python-version: ['3.10', '3.12', '3.14']
defaults:
run:
working-directory: ${{ matrix.package }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
python-version: ${{ matrix.python-version }}
# --locked, so that a lockfile which no longer matches its pyproject.toml
# fails here rather than silently resolving to something else.
- run: uv sync --locked --group unit
- run: uv run pytest

lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
# Ruff is pinned in the root dependency group, and its configuration
# lives there too, so both run once across every package rather than
# per matrix entry.
- run: uv run --group lint ruff check .
- run: uv run --group lint ruff format --check .
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# charm-tech-code

Shared tooling for the Charm Tech repositories (`operator`, `charmlibs`, `jubilant`, `pebble`, `concierge`, and the rest of the estate).

Each tool is its own package in its own top-level directory, with its own `pyproject.toml`, `src/`, `tests/` and lockfile - the same shape `canonical/charmlibs` uses. Adding a tool means adding a directory, not adding to an existing package, so a tool's dependencies are paid only by the workflows that run that tool.

| directory | what it does |
|---|---|
| [`agents-md`](agents-md) | Checks that a repository's `AGENTS.md` is current and load-bearing. |

Code here is consumed by workflow YAML in the repository that runs it, pinned by commit SHA:

```yaml
run: uvx --from "git+https://github.com/canonical/charm-tech-code@<40-char-sha>#subdirectory=agents-md" agents-md check
```

The point is that the code lives in one place. A tool used by eleven repositories should be fixed once, not eleven times, and the workflow YAML that differs per repository stays in that repository.

There is no release process and nothing is published. The SHA in the `uvx` line is the version, which is the same trust decision every pinned `uses: actions/checkout@<sha>` line in those repositories already makes.

## Configuration

Ruff's configuration lives in the root `pyproject.toml` and is copied from `canonical/operator`, so that a file can move between the two repositories without being reformatted. Packages deliberately do not carry their own `[tool.ruff]` block: ruff uses the closest configuration it finds rather than merging, so a local one would silently override the shared one.

`preview` is set in configuration rather than passed as `--preview` on the command line, which is how operator's `tox.ini` does it. That way an editor, a hook and CI agree without anyone having to remember the flag. It is load-bearing rather than cosmetic - the preview style hugs brackets inside calls, and without it a good deal of existing code reformats.

## Developing

```shell
cd <package>
uv sync --group unit
uv run pytest
```

Lint and format run from the root, across every package at once:

```shell
uv run --group lint ruff check .
uv run --group lint ruff format --check .
```
27 changes: 27 additions & 0 deletions agents-md/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# agents-md

Keeps the `AGENTS.md` files across the Charm Tech estate current and load-bearing. It implements the deterministic half of the scheme for doing that: a line in `AGENTS.md` earns its place either as an *override* (the agent would confidently do the wrong thing without it) or as a *cache* (the agent would get there eventually, by reading the Makefile, tox config and CI every session). A stale line is worse than a missing one, because agents trust the file over the repo.

## Checks

| ID | What it does |
|---|---|
| `agents-md` | The file exists, and is a pointer rather than an encyclopaedia. |
| `agents-md-content` | Staleness. Extracts every command, path, symbol and tool version, runs or resolves each against the repo, and reports what no longer exists. |
| `agents-md-battery` | Runs the repo's question battery: question, checkable answer, source line. Tests whether the file changes what an agent does, rather than whether it conforms to a style. |

`agents-md fix add-agents-md` writes the template into a repo that has none.

## Use

```
uvx --from charm-tech-code-agents-md agents-md check
uvx --from charm-tech-code-agents-md agents-md check --only=agents-md-content --format=markdown
uvx --from charm-tech-code-agents-md agents-md list
```

Every check applies to every repository. A well-maintained `AGENTS.md` is worth the same in a personal fork as in a product repository, so there is no tier system here and nothing to configure per repo beyond the battery.

## Question batteries

`assets/question-batteries/*.yaml`, one per repo, keyed by upstream name. They live here rather than in the skill so that the check and the data it reads ship together. Each entry carries the question, the answer that counts as correct, and the line of `AGENTS.md` it came from, so a battery failure points at the line to fix.
34 changes: 34 additions & 0 deletions agents-md/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
[project]
name = "charm-tech-code-agents-md"
version = "0.1.0"
description = "Check that a repository's AGENTS.md is current and load-bearing."
readme = "README.md"
requires-python = ">=3.10"
authors = [
{name = "The Charm Tech team at Canonical Ltd."},
]
license = "Apache-2.0"
# PyYAML only, for the question batteries. Everything else is stdlib, and
# `gh` and `git` are called as subprocesses rather than through a library.
dependencies = ["pyyaml"]

[project.scripts]
agents-md = "charm_tech_code.agents_md._cli:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/charm_tech_code"]

[dependency-groups]
unit = ["pytest"]

[tool.pytest.ini_options]
testpaths = ["tests"]

# The real ruff configuration is at the root of the monorepo; extending it
# means a setting added here overrides one key rather than the whole config.
[tool.ruff]
extend = "../pyproject.toml"
23 changes: 23 additions & 0 deletions agents-md/src/charm_tech_code/agents_md/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Copyright 2026 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Keep the estate's AGENTS.md files honest.

Three checks and one fix, plus the per-repo question batteries they read. A
line in AGENTS.md earns its place either as an override (the agent would
confidently do the wrong thing without it) or as a cache (the agent would get
there eventually, by reading the Makefile, tox config and CI every session).
The checks here test both, and the agent-facing half lives in the
`charm-tech-baseline` skill in `canonical/charm-tech`.
"""
13 changes: 13 additions & 0 deletions agents-md/src/charm_tech_code/agents_md/_checks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copyright 2026 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
76 changes: 76 additions & 0 deletions agents-md/src/charm_tech_code/agents_md/_checks/agents_md.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Copyright 2026 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Check: AGENTS.md present (best-of-class; agent-onboarding entry point).

Convention: keep it minimal — a short pointer file, not an encyclopaedia.
"""

from __future__ import annotations

import pathlib
import sys

from .. import _common

CHECK_ID = 'agents-md'


def main() -> int:
_common.cd_repo_root()

p = pathlib.Path('AGENTS.md')
if p.is_file():
lines = p.read_text().count('\n')
if lines > 200:
_common.emit_check(
CHECK_ID,
'fail',
f"AGENTS.md present but at {lines} lines is well past the 'keep it minimal' "
f'convention.',
{'path': 'AGENTS.md', 'lines': lines},
{
'kind': 'judgement',
'human_review': (
'Trim AGENTS.md down — point at HACKING/CONTRIBUTING for depth; keep '
'AGENTS.md to setup commands and conventions only.'
),
},
)
return _common.EXIT_FAIL
_common.emit_check(
CHECK_ID,
'pass',
f'AGENTS.md present ({lines} lines).',
{'path': 'AGENTS.md', 'lines': lines},
)
return _common.EXIT_PASS

_common.emit_check(
CHECK_ID,
'fail',
'No AGENTS.md found.',
{},
{
'kind': 'mechanical',
'script': 'scripts/fixes/add-agents-md.py',
'human_review': 'Customise the dev-setup commands for this repo (uv / go / make / '
'just).',
},
)
return _common.EXIT_FAIL


if __name__ == '__main__':
sys.exit(main())
Loading