Compare commits

...

3 Commits

Author SHA1 Message Date
44b5a9426a docs: add BRIDGE-WP-0003 workplan — MCP server, skill, and cross-mode tests
Defines the FastMCP server, /bridge-status skill, capability registry,
and self-validating cross-access-mode test suite for ops-bridge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 09:36:19 +01:00
af2d419bf6 chore: mark BRIDGE-WP-0001 and BRIDGE-WP-0002 workplans as completed
All 39 tasks marked done; both workstreams updated to completed status
in the State Hub and workplan files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 03:37:32 +01:00
d248f14a9f docs: add README.txt with usage guide and configuration reference
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 03:24:56 +01:00
5 changed files with 1136 additions and 43 deletions

224
README.txt Normal file
View File

@@ -0,0 +1,224 @@
ops-bridge
==========
SSH reverse tunnel lifecycle manager. Keeps remote execution environments
(COULOMBCORE, Railiance nodes) connected to the local Custodian State Hub
so Claude Code sessions on those machines have full MCP connectivity.
WHAT IT DOES
------------
`bridge` is a CLI tool that manages named SSH reverse tunnels. Each tunnel:
- Is identified by a human-readable name (e.g. state-hub-coulombcore)
- Runs as an SSH reverse port-forward: ssh -R remote:127.0.0.1:local host
- Auto-reconnects on drop using exponential backoff
- Optionally runs an HTTP health check to confirm the forwarded service
is actually reachable (not just the SSH process alive)
- Records structured audit events (bridge_started, bridge_connected,
health_check_failed, etc.) to a JSON log per tunnel
Bridge states: stopped -> starting -> connected <-> degraded -> reconnecting
INSTALL
-------
Requires Python 3.11+ and uv (https://docs.astral.sh/uv/).
uv tool install /path/to/ops-bridge
This registers the `bridge` command globally. For development:
cd /path/to/ops-bridge
uv tool install -e .
Verify:
bridge --help
CONFIGURATION
-------------
Config file: ~/.config/bridge/tunnels.yaml
Override with: BRIDGE_CONFIG=/path/to/config.yaml
Minimal example:
tunnels:
state-hub-coulombcore:
host: coulombcore.local
remote_port: 18000
local_port: 8000
ssh_user: ubuntu
ssh_key: ~/.ssh/id_ops
actor: agent.claude-coulombcore
actors:
agent.claude-coulombcore:
class: automation
description: Claude Code agent on CoulombCore
With health check and reconnect policy:
tunnels:
state-hub-coulombcore:
host: coulombcore.local
remote_port: 18000
local_port: 8000
ssh_user: ubuntu
ssh_key: ~/.ssh/id_ops
actor: agent.claude-coulombcore
health_check:
url: http://127.0.0.1:18000/health # checked from the REMOTE host
interval_seconds: 30
timeout_seconds: 5
reconnect:
max_attempts: 0 # 0 = retry forever
backoff_initial: 5
backoff_max: 60
actors:
agent.claude-coulombcore:
class: automation # "human" or "automation"
description: Claude Code agent on CoulombCore
operator.bernd:
class: human
description: Bernd Worsch
Required tunnel fields: host, remote_port, local_port, ssh_user, ssh_key, actor
Required actor fields: class (must be "human" or "automation")
CLI COMMANDS
------------
Lifecycle:
bridge up [TUNNEL] Start one tunnel, or all if no name given
bridge down [TUNNEL] Stop one tunnel, or all
bridge restart [TUNNEL] Restart one tunnel, or all
Observation:
bridge status Show all tunnels: state, uptime, last event
bridge status --json Machine-readable JSON output
bridge logs TUNNEL Tail the audit log for a tunnel
bridge logs TUNNEL --lines 100 --follow
Examples:
bridge up state-hub-coulombcore
bridge status
bridge logs state-hub-coulombcore --follow
bridge down state-hub-coulombcore
OPSCATALOG EXTENSION (optional)
--------------------------------
If you maintain a Git-backed YAML catalog of your infrastructure, point
bridge at it in your config:
catalog_path: ~/ops-infra/opscatalog/
Catalog layout:
opscatalog/
domains/
<domain-id>/
domain.yaml
targets/
<target-id>.yaml
bridges/
<bridge-id>.yaml
Then you can use:
bridge targets [--domain DOMAIN] List all targets (optionally filtered)
bridge targets show TARGET_ID Show full target metadata
bridge catalog list List domains with counts
bridge catalog validate Check catalog for consistency errors
bridge catalog show BRIDGE_ID Show a catalog bridge's full metadata
Bridges defined in the catalog are resolved the same way as inline tunnels.
Inline tunnels (in tunnels.yaml) take precedence over catalog bridges when
both define the same name.
STATE FILES
-----------
Runtime state is stored in ~/.local/state/bridge/:
{name}.pid Manager process ID
{name}.state Current bridge state (e.g. "connected")
{name}.log Audit log, one JSON object per line
Override the state directory with: BRIDGE_STATE_DIR=/path/to/dir
AUDIT LOG FORMAT
----------------
Each event is one JSON object per line:
{
"ts": "2026-03-12T14:23:01.456789",
"tunnel": "state-hub-coulombcore",
"event": "bridge_connected",
"actor": "agent.claude-coulombcore",
"actor_class": "automation",
"detail": ""
}
Event types: bridge_started, bridge_connected, bridge_disconnected,
bridge_reconnecting, health_check_failed, health_check_recovered,
bridge_stopped
DEVELOPMENT
-----------
uv run pytest Run all tests
uv run pytest tests/test_cli.py -v Run a specific test file
uv run ruff check . Lint
Source layout:
src/bridge/
cli.py Typer CLI (entry point)
models.py Core dataclasses and enums
config.py Config loading from tunnels.yaml
manager.py Tunnel lifecycle (subprocess, reconnect loop)
state.py PID and state file management
audit.py Audit event logging
health.py HTTP health checker (async, httpx)
catalog/ OpsCatalog extension
DESIGN NOTES
------------
- No system daemons. Tunnel processes are managed as subprocesses; PIDs
are tracked in ~/.local/state/bridge/.
- Graceful shutdown: SIGTERM to the daemon allows a clean exit; SIGKILL
follows after 5 seconds if unresponsive.
- Actor attribution on every log event (human vs. automation) supports
audit traceability (FRS §5.7).
- SSH command invoked: ssh -N -R remote_port:127.0.0.1:local_port
-i ssh_key ssh_user@host
REPO STRUCTURE
--------------
src/bridge/ Main source
tests/ Test suite
wiki/ PRD, FRS, OpsCatalog specification
workplans/ Custodian State Hub workplan files (BRIDGE-WP-*)
pyproject.toml Build config and dependencies

343
uv.lock generated Normal file
View File

@@ -0,0 +1,343 @@
version = 1
requires-python = ">=3.11"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 },
]
[[package]]
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592 },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684 },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "platform_system == 'Windows'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
]
[[package]]
name = "ops-bridge"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "pyyaml" },
{ name = "typer" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.27" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "typer", specifier = ">=0.12" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-asyncio", specifier = ">=0.23" },
{ name = "ruff", specifier = ">=0.4" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075 },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 },
]
[[package]]
name = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458 },
]
[[package]]
name = "ruff"
version = "0.15.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185 },
{ url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201 },
{ url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752 },
{ url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857 },
{ url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120 },
{ url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428 },
{ url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251 },
{ url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801 },
{ url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821 },
{ url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326 },
{ url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820 },
{ url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395 },
{ url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069 },
{ url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315 },
{ url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676 },
{ url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972 },
{ url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572 },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 },
]
[[package]]
name = "typer"
version = "0.24.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085 },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 },
]

View File

@@ -4,12 +4,12 @@ type: workplan
title: "OpsBridge Initial Implementation"
domain: custodian
repo: ops-bridge
status: active
status: completed
owner: Bernd
topic_slug: custodian
state_hub_workstream_id: 79112cff-9c0a-42ad-aa3d-916013001aee
created: "2026-03-11"
updated: "2026-03-11"
updated: "2026-03-12"
---
# BRIDGE-WP-0001 — OpsBridge Initial Implementation
@@ -100,7 +100,7 @@ actors:
```task
id: BRIDGE-WP-0001-T01
state_hub_task_id: 76c9ee58-10bf-4060-87bb-b73fa8cf25ea
status: todo
status: done
priority: high
```
@@ -111,7 +111,7 @@ Set up `[project]`, `[project.scripts]` (entry point `bridge = bridge.cli:app`),
```task
id: BRIDGE-WP-0001-T02
state_hub_task_id: b2be974c-6173-457d-9276-080ac551c105
status: todo
status: done
priority: high
```
@@ -122,7 +122,7 @@ Create `src/bridge/__init__.py` and empty module stubs: `cli.py`, `config.py`, `
```task
id: BRIDGE-WP-0001-T03
state_hub_task_id: 82f70483-91ae-4545-88af-44fe693ecb79
status: todo
status: done
priority: medium
```
@@ -139,7 +139,7 @@ Verify `uv tool install -e .` produces a working `bridge --help`.
```task
id: BRIDGE-WP-0001-T04
state_hub_task_id: 495e4257-40ad-4a1b-8a71-3a311476d41e
status: todo
status: done
priority: high
```
@@ -150,7 +150,7 @@ Define `TunnelConfig`, `ReconnectPolicy`, `HealthCheckConfig`, `ActorInfo` as da
```task
id: BRIDGE-WP-0001-T05
state_hub_task_id: b6782df4-e692-49e1-b3a3-d65d07826907
status: todo
status: done
priority: high
```
@@ -161,7 +161,7 @@ Load `~/.config/bridge/tunnels.yaml`, validate required fields, raise clear erro
```task
id: BRIDGE-WP-0001-T06
state_hub_task_id: 341c866f-8f4b-4165-9fa5-f10fe37c9252
status: todo
status: done
priority: medium
```
@@ -178,7 +178,7 @@ Test: valid config, missing required field, unknown tunnel name.
```task
id: BRIDGE-WP-0001-T07
state_hub_task_id: ae5e2566-a4b1-426f-9c32-4a2c025f2927
status: todo
status: done
priority: high
```
@@ -189,7 +189,7 @@ Read/write PID file and state file under `~/.local/state/bridge/`. Check if PID
```task
id: BRIDGE-WP-0001-T08
state_hub_task_id: 456a3cb5-50fa-4fed-9283-57e2d1c6fbb9
status: todo
status: done
priority: medium
```
@@ -200,7 +200,7 @@ States: `STOPPED`, `STARTING`, `CONNECTED`, `DEGRADED`, `RECONNECTING`, `FAILED`
```task
id: BRIDGE-WP-0001-T09
state_hub_task_id: 0accc0b7-d013-43ad-a810-3269e64fb096
status: todo
status: done
priority: medium
```
@@ -217,7 +217,7 @@ Test: write/read state round-trip, stale PID detection without error.
```task
id: BRIDGE-WP-0001-T10
state_hub_task_id: d0341e90-b48d-48ab-9e6d-82f4c365afec
status: todo
status: done
priority: high
```
@@ -228,7 +228,7 @@ SSH command: `ssh -N -R {remote_port}:127.0.0.1:{local_port} -i {key} -o ServerA
```task
id: BRIDGE-WP-0001-T11
state_hub_task_id: f5c91eff-fca3-4f66-b073-276a733b5a27
status: todo
status: done
priority: high
```
@@ -239,7 +239,7 @@ Exponential backoff between `backoff_initial` and `backoff_max`. Respect `max_at
```task
id: BRIDGE-WP-0001-T12
state_hub_task_id: 3f4df535-0d6a-49e8-9d3a-c3926d7f230c
status: todo
status: done
priority: medium
```
@@ -256,7 +256,7 @@ Catch SIGTERM/SIGINT, kill SSH subprocess, write `STOPPED` state.
```task
id: BRIDGE-WP-0001-T13
state_hub_task_id: 5aaa0e35-f32a-4c68-8707-1a1e037b76f4
status: todo
status: done
priority: medium
```
@@ -267,7 +267,7 @@ Async HTTP GET via `httpx` to configured health URL. Run health check loop insid
```task
id: BRIDGE-WP-0001-T14
state_hub_task_id: 599d4e28-88c8-4c2a-80ac-ca57824af467
status: todo
status: done
priority: low
```
@@ -284,7 +284,7 @@ Persist timestamp, status, HTTP code or error for display in `bridge status`.
```task
id: BRIDGE-WP-0001-T15
state_hub_task_id: 2f124b16-f1e7-4e9f-ad23-9f08543db3b7
status: todo
status: done
priority: medium
```
@@ -303,7 +303,7 @@ Status table columns: `TUNNEL`, `STATE`, `ACTOR`, `HOST`, `UPTIME`, `HEALTH`. Ex
```task
id: BRIDGE-WP-0001-T16
state_hub_task_id: 2c22b8fe-8a35-4887-89b2-f8fb7f43e0b6
status: todo
status: done
priority: high
```
@@ -314,7 +314,7 @@ Start named tunnel or all tunnels if name omitted.
```task
id: BRIDGE-WP-0001-T17
state_hub_task_id: 768e1a8b-fdf7-4718-b00e-bc2401f57657
status: todo
status: done
priority: high
```
@@ -325,7 +325,7 @@ Stop named tunnel or all tunnels if name omitted.
```task
id: BRIDGE-WP-0001-T18
state_hub_task_id: 8fd6486d-af4f-4295-a57a-a5fabbf25681
status: todo
status: done
priority: medium
```
@@ -336,7 +336,7 @@ Down then up for named tunnel or all.
```task
id: BRIDGE-WP-0001-T19
state_hub_task_id: 28f3f392-9e94-43e7-811a-fa036f588e10
status: todo
status: done
priority: high
```
@@ -347,7 +347,7 @@ Table output with `--json` flag for automation.
```task
id: BRIDGE-WP-0001-T20
state_hub_task_id: 43582657-b1b9-4113-88e1-2109b30f3732
status: todo
status: done
priority: medium
```
@@ -364,7 +364,7 @@ Tail log file. Defaults to last 50 lines. `--follow` for live tail. `--lines N`
```task
id: BRIDGE-WP-0001-T21
state_hub_task_id: 5e3c7ac6-03fd-45e9-af64-11bde1d03ab8
status: todo
status: done
priority: medium
```
@@ -375,7 +375,7 @@ Test fixture with minimal `tunnels.yaml` pointing to localhost. Test full `up
```task
id: BRIDGE-WP-0001-T22
state_hub_task_id: 8b6ac68e-d0ab-4826-8df5-ebdf30a1e23e
status: todo
status: done
priority: medium
```
@@ -386,7 +386,7 @@ Test reconnect loop with a subprocess that exits immediately.
```task
id: BRIDGE-WP-0001-T23
state_hub_task_id: c472bb1a-2fe2-4a88-aa6b-e18f732a3fde
status: todo
status: done
priority: medium
```

View File

@@ -4,12 +4,12 @@ type: workplan
title: "OpsCatalog Extension"
domain: custodian
repo: ops-bridge
status: active
status: completed
owner: Bernd
topic_slug: custodian
state_hub_workstream_id: f38bfcdb-f115-4431-88b5-ce906a24199c
created: "2026-03-11"
updated: "2026-03-11"
updated: "2026-03-12"
---
# BRIDGE-WP-0002 — OpsCatalog Extension
@@ -146,7 +146,7 @@ description: Automated remediation agent
```task
id: BRIDGE-WP-0002-T01
state_hub_task_id: 21b90574-a27c-467c-8e9d-d4029a659171
status: todo
status: done
priority: high
```
@@ -166,7 +166,7 @@ directory tree; unknown `type:` values are skipped with a warning.
```task
id: BRIDGE-WP-0002-T02
state_hub_task_id: 782b5b4d-1f3f-4e5d-ad46-dc57b345bda3
status: todo
status: done
priority: high
```
@@ -178,7 +178,7 @@ field. Build in-memory index: domains, targets, bridges, actors.
```task
id: BRIDGE-WP-0002-T03
state_hub_task_id: 41fed4f8-7818-4ca1-bb48-6ac1089220e8
status: todo
status: done
priority: medium
```
@@ -197,7 +197,7 @@ when the catalog contains invalid entries.
```task
id: BRIDGE-WP-0002-T04
state_hub_task_id: 32946d15-5516-4599-8f27-8c653dec6786
status: todo
status: done
priority: medium
```
@@ -210,7 +210,7 @@ exist; target's `reachable_via` bridge IDs must exist; bridge's `target` and
```task
id: BRIDGE-WP-0002-T05
state_hub_task_id: 6061a6eb-9966-4be9-aa5e-ea7edf7fd085
status: todo
status: done
priority: medium
```
@@ -229,7 +229,7 @@ the catalog when no inline entry exists in `tunnels.yaml`.
```task
id: BRIDGE-WP-0002-T06
state_hub_task_id: a92d97c8-4eec-4dd5-9b90-d9c1cba813ac
status: todo
status: done
priority: high
```
@@ -243,7 +243,7 @@ has the name.
```task
id: BRIDGE-WP-0002-T07
state_hub_task_id: 23799377-64f2-4c13-aa72-364770d80f91
status: todo
status: done
priority: high
```
@@ -256,7 +256,7 @@ catalog-unaware.
```task
id: BRIDGE-WP-0002-T08
state_hub_task_id: d2313182-975f-409f-9d4f-ebabf66b44df
status: todo
status: done
priority: medium
```
@@ -275,7 +275,7 @@ bridges provide access to each target.
```task
id: BRIDGE-WP-0002-T09
state_hub_task_id: f9e508db-a19f-42be-9437-b4bdeb00a534
status: todo
status: done
priority: medium
```
@@ -288,7 +288,7 @@ Table columns: `DOMAIN`, `TARGET`, `KIND`, `BRIDGES`. `--domain <name>` filter.
```task
id: BRIDGE-WP-0002-T10
state_hub_task_id: e288a1d3-d676-404a-a3eb-25dbb241502d
status: todo
status: done
priority: low
```
@@ -306,7 +306,7 @@ bridges, and any operations notes from `docs/*.md` files in the domain directory
```task
id: BRIDGE-WP-0002-T11
state_hub_task_id: 73899b70-b0ac-4f48-b362-cc2455a66f41
status: todo
status: done
priority: medium
```
@@ -317,7 +317,7 @@ List all domains and a count of targets and bridges per domain.
```task
id: BRIDGE-WP-0002-T12
state_hub_task_id: e091daa2-7c20-4169-b634-1fcc469513ea
status: todo
status: done
priority: medium
```
@@ -329,7 +329,7 @@ found. Useful in CI pipelines for the catalog repo.
```task
id: BRIDGE-WP-0002-T13
state_hub_task_id: 9f5f4f30-bfe6-40fd-b178-2fbb396816ee
status: todo
status: done
priority: low
```
@@ -346,7 +346,7 @@ Print full resolved bridge metadata including target and domain context.
```task
id: BRIDGE-WP-0002-T14
state_hub_task_id: 5ccb2b4b-7ea5-4c38-8246-d59b8f7d4419
status: todo
status: done
priority: medium
```
@@ -358,7 +358,7 @@ Test `bridge up <catalog-bridge-name>` resolves and starts tunnel.
```task
id: BRIDGE-WP-0002-T15
state_hub_task_id: 72c9f686-c474-46c4-a759-bfd47e2d4211
status: todo
status: done
priority: medium
```
@@ -369,7 +369,7 @@ Test `bridge targets` output matches catalog fixture. Test `--json` flag.
```task
id: BRIDGE-WP-0002-T16
state_hub_task_id: 83c0734e-0dc2-49ce-8b6a-a4d5e26ff33a
status: todo
status: done
priority: medium
```

View File

@@ -0,0 +1,526 @@
---
id: BRIDGE-WP-0003
type: workplan
title: "OpsBridge MCP Server, Skill, and Cross-Mode Test Coverage"
domain: custodian
repo: ops-bridge
status: active
owner: Bernd
topic_slug: custodian
state_hub_workstream_id: 97009d3f-fd92-4fd9-a308-6c2445b4d623
created: "2026-03-12"
updated: "2026-03-12"
---
# BRIDGE-WP-0003 — OpsBridge MCP Server, Skill, and Cross-Mode Test Coverage
**Scope:** Expose OpsBridge and OpsCatalog functionality as a FastMCP server
and a Claude Code skill. Introduce a capability registry and cross-access-mode
test suite that enforces test coverage parity across CLI, MCP, and skill for
every operation — including a meta-test that validates the test suite itself is
complete.
**Depends on:** BRIDGE-WP-0001 and BRIDGE-WP-0002 complete.
**Out of scope:** Identity provider integration (FR-2729, deferred indefinitely).
---
## Goal
After this workplan:
1. Any Claude Code agent can call `bridge_up()`, `bridge_status()`,
`catalog_list_targets()` etc. as first-class MCP tools — no Bash
required, structured JSON in/out.
2. Human operators can invoke `/bridge-status` as a skill to get an
immediate, natural-language summary of tunnel health.
3. Adding any new capability (CLI command, MCP tool) without writing tests
for all required access modes causes `uv run pytest` to fail with a
clear capability × mode gap report.
4. The gap-detection mechanism is itself tested: a synthetic missing-mode
fixture asserts the meta-test catches it.
---
## Reference Documents
| Document | Location |
|---|---|
| Architecture note | `CLAUDE.md` — Architecture section |
| OpsBridge FRS | `wiki/OpsBridgeFrs.md` |
| State Hub MCP server (reference impl) | `~/the-custodian/state-hub/mcp_server/server.py` |
---
## Architecture Summary
```
src/bridge/
capabilities.py # canonical capability registry
mcp_server/
__init__.py
server.py # FastMCP app, stdio entry point
.mcp.json # project-scope MCP registration
scripts/
register_mcp.py # user-scope registration helper
~/.claude/plugins/
ops-bridge/
bridge-status.md # /bridge-status skill
tests/
conftest.py # capability + access_mode marks, collector helper
test_cli.py # existing — annotated with marks (T09)
test_mcp.py # new — FastMCP in-process client tests
test_skill.py # new — static skill coverage lint
test_coverage_completeness.py # new — cross-mode meta-test
```
### Capability Registry
```python
# src/bridge/capabilities.py
from dataclasses import dataclass
ACCESS_MODES = {"cli", "mcp", "skill"}
@dataclass
class Capability:
name: str
description: str
required_access_modes: frozenset[str]
CAPABILITIES: list[Capability] = [
Capability("bridge_up", "Start one or all tunnels", frozenset({"cli", "mcp"})),
Capability("bridge_down", "Stop one or all tunnels", frozenset({"cli", "mcp"})),
Capability("bridge_restart", "Restart one or all tunnels", frozenset({"cli", "mcp"})),
Capability("bridge_status", "Show tunnel status", frozenset({"cli", "mcp", "skill"})),
Capability("bridge_logs", "Tail tunnel audit log", frozenset({"cli", "mcp"})),
Capability("catalog_list_targets", "List catalog targets", frozenset({"cli", "mcp"})),
Capability("catalog_show_target", "Show target metadata", frozenset({"cli", "mcp"})),
Capability("catalog_list_domains", "List catalog domains", frozenset({"cli", "mcp"})),
Capability("catalog_validate", "Validate catalog consistency", frozenset({"cli", "mcp"})),
Capability("catalog_show_bridge", "Show bridge metadata", frozenset({"cli", "mcp"})),
]
```
### Cross-Mode Test Marks
Every test that exercises a capability against an access mode carries two marks:
```python
@pytest.mark.capability("bridge_up")
@pytest.mark.access_mode("cli")
def test_bridge_up_cli(runner, config_file):
result = runner.invoke(app, ["up", "my-tunnel"])
assert result.exit_code == 0
@pytest.mark.capability("bridge_up")
@pytest.mark.access_mode("mcp")
async def test_bridge_up_mcp(mcp_client):
result = await mcp_client.call_tool("bridge_up", {"tunnel": "my-tunnel"})
assert result["started"] == ["my-tunnel"]
```
### Meta-Test Mechanism
`test_coverage_completeness.py` uses a pytest plugin hook to collect all
test items, read their marks, and assert the coverage matrix is complete:
```
capability cli mcp skill
bridge_up ✓ ✓ — (not required for skill)
bridge_status ✓ ✓ ✓
catalog_list_targets ✓ ✓ —
...
```
Fails with a table of gaps. The meta-test is itself validated by a fixture
that injects a synthetic `Capability("test_sentinel", frozenset({"cli","mcp"}))`,
deliberately omits the `mcp` test, and asserts the checker raises.
---
## Phase 1 — Capability Registry
**Acceptance:** `from bridge.capabilities import CAPABILITIES` works; every
existing CLI command and the planned MCP tool set appears in the registry.
### T01 — Define capability registry module (src/bridge/capabilities.py)
```task
id: BRIDGE-WP-0003-T01
state_hub_task_id: 1397a838-b225-4452-ad53-29ad65388060
status: todo
priority: high
```
`Capability` dataclass with `name`, `description`, `required_access_modes`.
List all 10 capabilities as shown in the architecture above. No external
dependencies — pure stdlib.
### T02 — Meta-test: registry completeness against CLI commands and MCP tools
```task
id: BRIDGE-WP-0003-T02
state_hub_task_id: 97467243-9237-4e63-a860-cc49587546ad
status: todo
priority: high
```
Introspect `app.registered_commands` (Typer) and `mcp.list_tools()` (FastMCP).
Assert every name appears in `{c.name for c in CAPABILITIES}`. Fails fast if
a developer adds a CLI command or MCP tool without updating the registry.
---
## Phase 2 — MCP Server
**Acceptance:** `uv run python src/bridge/mcp_server/server.py` starts without
error; `bridge_status()` returns a list of tunnel dicts; `bridge_up("x")`
returns `{"started": ["x"]}` or `{"already_running": ["x"]}`.
### T03 — Add fastmcp dependency and mcp_server package skeleton
```task
id: BRIDGE-WP-0003-T03
state_hub_task_id: f2fd64f5-31c6-493b-b48b-d13980467cca
status: todo
priority: high
```
Add `fastmcp>=2.0.0` to `[project.dependencies]` in `pyproject.toml`. Create
`src/bridge/mcp_server/__init__.py` (empty) and `server.py` with:
```python
from fastmcp import FastMCP
mcp = FastMCP(name="ops-bridge", instructions="...")
if __name__ == "__main__":
mcp.run(transport="stdio")
```
### T04 — Implement bridge lifecycle MCP tools (up, down, restart, status, logs)
```task
id: BRIDGE-WP-0003-T04
state_hub_task_id: 1bfc9b36-2be3-4606-a6e9-d611d1ac33ab
status: todo
priority: high
```
`@mcp.tool()` wrappers that import and call the Python library directly (no
subprocess). Signatures:
```python
def bridge_up(tunnel: str | None = None) -> dict
def bridge_down(tunnel: str | None = None) -> dict
def bridge_restart(tunnel: str | None = None) -> dict
def bridge_status() -> list[dict]
def bridge_logs(tunnel: str, lines: int = 50) -> list[dict]
```
All return JSON-serialisable dicts/lists. `tunnel=None` means all tunnels.
### T05 — Implement catalog MCP tools
```task
id: BRIDGE-WP-0003-T05
state_hub_task_id: ef7fa23c-d2e1-4fe0-9e26-994c1a6ce1fb
status: todo
priority: high
```
```python
def catalog_list_targets(domain: str | None = None) -> list[dict]
def catalog_show_target(target_id: str) -> dict | None
def catalog_list_domains() -> list[dict]
def catalog_validate() -> dict # {"valid": bool, "errors": list[str]}
def catalog_show_bridge(bridge_id: str) -> dict | None
```
When `catalog_path` is not configured in `tunnels.yaml`, return
`{"error": "catalog_path not configured"}` rather than raising.
### T06 — Implement bridge:// and catalog:// MCP resources
```task
id: BRIDGE-WP-0003-T06
state_hub_task_id: 71c9ee45-6928-416c-b4f3-dfb785a0ec8f
status: todo
priority: medium
```
```python
@mcp.resource("bridge://status")
def resource_bridge_status() -> str:
"""Live snapshot of all tunnel states."""
@mcp.resource("catalog://domains")
def resource_catalog_domains() -> str: ...
@mcp.resource("catalog://targets")
def resource_catalog_targets() -> str: ...
```
Resources are for cheap orientation reads; tools are for actions and
parameterised queries. Both are needed.
### T07 — Add .mcp.json project-scope registration config
```task
id: BRIDGE-WP-0003-T07
state_hub_task_id: 618c011d-bd1b-4c8f-8750-f3d2f9fcaf88
status: todo
priority: medium
```
```json
{
"mcpServers": {
"ops-bridge": {
"type": "stdio",
"command": "uv",
"args": ["run", "python", "src/bridge/mcp_server/server.py"],
"cwd": "/home/worsch/ops-bridge"
}
}
}
```
Project-scope: Claude Code sessions inside `ops-bridge/` get the tools
automatically. See T14 for user-scope (machine-global) registration.
---
## Phase 3 — Skill
**Acceptance:** `/bridge-status` invoked in Claude Code runs the skill,
calls `bridge_status` MCP tool, and returns a natural-language health summary.
### T08 — Implement /bridge-status skill for human operators
```task
id: BRIDGE-WP-0003-T08
state_hub_task_id: 2c070f34-12b5-4dd9-ab24-bb7b6836773c
status: todo
priority: medium
```
Skill file at `~/.claude/plugins/ops-bridge/bridge-status.md`. Prompt instructs
Claude to:
1. Call `bridge_status` MCP tool
2. Report each tunnel: name, state (with colour hint), host, uptime
3. Flag any `degraded` or `failed` tunnels and suggest `bridge restart <name>`
4. If catalog is configured, offer `catalog_list_targets` for discovery context
Skill prompt **must** reference the canonical capability names (`bridge_status`,
`catalog_list_targets`) so `test_skill.py` can assert coverage statically.
---
## Phase 4 — Cross-Access-Mode Test Suite
**Acceptance:** `uv run pytest` fails if any capability is missing a test for
any of its required access modes. The failure message is a capability × mode
gap matrix. The meta-test is itself verified by a synthetic failing fixture.
### T09 — CLI test layer: annotate existing tests with capability/access_mode marks
```task
id: BRIDGE-WP-0003-T09
state_hub_task_id: a8f3f5fb-fcd6-47e9-aad5-85dc803f796d
status: todo
priority: high
```
Retrofit `tests/test_cli.py` (and other CLI test files) with:
```python
@pytest.mark.capability("bridge_up")
@pytest.mark.access_mode("cli")
def test_bridge_up_starts_tunnel(...): ...
```
Every capability whose `required_access_modes` includes `"cli"` must have at
least one marked test in the CLI layer.
### T10 — MCP test layer: tests/test_mcp.py with FastMCP in-process test client
```task
id: BRIDGE-WP-0003-T10
state_hub_task_id: acb7ada6-111d-4b8d-b201-45748c394c43
status: todo
priority: high
```
Use FastMCP's `Client(mcp_app)` context manager (in-process, no network):
```python
@pytest.mark.capability("bridge_up")
@pytest.mark.access_mode("mcp")
async def test_bridge_up_mcp(mcp_client, mock_tunnel_manager):
result = await mcp_client.call_tool("bridge_up", {"tunnel": "t1"})
assert result["started"] == ["t1"]
```
Cover: correct return schema, missing tunnel name handled, catalog tools
graceful when `catalog_path` unset, resource URIs return valid JSON.
### T11 — Skill test layer: tests/test_skill.py — static skill coverage lint
```task
id: BRIDGE-WP-0003-T11
state_hub_task_id: 071adfa4-2ccb-466b-b298-35130876267f
status: todo
priority: medium
```
Parse the skill markdown file. Assert:
- File is syntactically valid (frontmatter parseable)
- Each capability with `"skill"` in `required_access_modes` has its `name`
appearing in the skill body text
This is a static lint, not an LLM invocation — fast and deterministic.
```python
@pytest.mark.access_mode("skill")
def test_skill_covers_required_capabilities():
skill_text = Path("~/.claude/plugins/ops-bridge/bridge-status.md").read_text()
for cap in CAPABILITIES:
if "skill" in cap.required_access_modes:
assert cap.name in skill_text, f"Skill missing capability: {cap.name}"
```
### T12 — Cross-mode completeness meta-test: tests/test_coverage_completeness.py
```task
id: BRIDGE-WP-0003-T12
state_hub_task_id: f1277a48-1790-42bd-8c70-8ba10c68312b
status: todo
priority: critical
```
The centrepiece. Uses a pytest plugin (conftest hook or `pytest.ini`
`collect_ignore`) to collect all test items, read their marks, build the
coverage matrix, and assert completeness:
```python
def test_all_capabilities_have_all_required_mode_tests(pytestconfig):
covered = collect_capability_coverage(pytestconfig)
gaps = []
for cap in CAPABILITIES:
for mode in cap.required_access_modes:
if (cap.name, mode) not in covered:
gaps.append(f" {cap.name:<30} {mode}")
if gaps:
pytest.fail("Missing capability × mode coverage:\n" + "\n".join(gaps))
```
**Self-validation fixture:** a separate test injects a synthetic capability
`Capability("_test_sentinel", frozenset({"cli","mcp"}))` into a copy of
`CAPABILITIES`, provides only a `cli`-marked test for it, and asserts that
calling `collect_capability_coverage` on this patched set reports the `mcp`
gap.
### T13 — conftest.py: pytest marks registration and coverage collector helper
```task
id: BRIDGE-WP-0003-T13
state_hub_task_id: c518662a-9a5b-40de-86f5-582a16489cd3
status: todo
priority: medium
```
Register custom marks to silence `PytestUnknownMarkWarning`:
```toml
# pyproject.toml
[tool.pytest.ini_options]
markers = [
"capability(name): the bridge capability under test",
"access_mode(mode): access mode being tested (cli, mcp, skill)",
]
```
Implement `collect_capability_coverage(session_or_items)` in `conftest.py`
that walks collected items and returns `set[tuple[str, str]]` of
`(capability_name, access_mode)` pairs.
---
## Phase 5 — Registration and Documentation
**Acceptance:** `python scripts/register_mcp.py` registers ops-bridge MCP at
user scope; `bridge --help` still works; `uv run pytest` passes.
### T14 — User-scope registration guide and patch script
```task
id: BRIDGE-WP-0003-T14
state_hub_task_id: b86916ba-59f3-44c1-b874-8af92d30e470
status: todo
priority: medium
```
`scripts/register_mcp.py` modelled on `state-hub/scripts/patch_mcp_cwd.py`:
reads `.mcp.json`, registers at user scope via `claude mcp add-json -s user`,
then patches `cwd` directly in `~/.claude.json`. Update `README.txt` with:
```
MCP INTEGRATION
---------------
Project-scope (auto, inside ops-bridge/):
Already configured in .mcp.json.
User-scope (machine-global, any repo):
python scripts/register_mcp.py
```
### T15 — Integration test: agent workflow (bridge_status → bridge_up → bridge_status)
```task
id: BRIDGE-WP-0003-T15
state_hub_task_id: d826764f-e2f1-4f6a-842c-a1852a88b209
status: todo
priority: medium
```
End-to-end MCP flow with mocked `TunnelManager`:
1. `bridge_status()` → all tunnels `stopped`
2. `bridge_up("test-tunnel")``{"started": ["test-tunnel"]}`
3. `bridge_status()``test-tunnel` now `connected`
Verifies the MCP layer correctly delegates to the library and state is
reflected. Marked `@pytest.mark.capability("bridge_up") @pytest.mark.access_mode("mcp")`.
---
## Capability × Mode Coverage Target
| Capability | CLI | MCP | Skill |
|-------------------------|-----|-----|-------|
| bridge_up | ✓ | ✓ | |
| bridge_down | ✓ | ✓ | |
| bridge_restart | ✓ | ✓ | |
| bridge_status | ✓ | ✓ | ✓ |
| bridge_logs | ✓ | ✓ | |
| catalog_list_targets | ✓ | ✓ | |
| catalog_show_target | ✓ | ✓ | |
| catalog_list_domains | ✓ | ✓ | |
| catalog_validate | ✓ | ✓ | |
| catalog_show_bridge | ✓ | ✓ | |
The skill only requires `bridge_status` and `catalog_list_targets` — the
two capabilities needed for a health summary. All others are CLI+MCP only.
---
## Deferred
- **FR-2729** — Identity provider integration — separate workplan.
- **Skill coverage for lifecycle operations** — `/bridge-up`, `/bridge-down`
skills for human operators are low value; agents use MCP tools directly.
- **Remote MCP transport (SSE/HTTP)** — stdio is sufficient for local use;
remote transport is a future concern when ops-bridge runs on a headless node.