Skip to content
Back to skills

Vibey Architecture

ASecurity

Onion layers for vibey (domain, application, infrastructure, cli, tui), import-linter, where new code belongs, the workspace tenants, and how to file a sub-doctrine. Use before adding a module under src/ or writing a standing rule.

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 19, 2026
ai-agentspythongobashsqlazuregitapidatabase

Works with

  • terminal
  • cli
  • api

Security analysis

A100/100

Scanned October 4, 2026

npx -y skills add the-vibey-project/vibey --skill vibey-architecture --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Vibey Architecture?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Vibey Architecture
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/the-vibey-project-vibey-architecture/badge)](https://www.skillsdirectory.com/skills/the-vibey-project-vibey-architecture)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: vibey-architecture
description: Onion layers for vibey (domain, application, infrastructure, cli, tui), import-linter, where new code belongs, the workspace tenants, and how to file a sub-doctrine. Use before adding a module under src/ or writing a standing rule.
allowed-tools: Read Grep Glob Bash(lint-imports)
---

# vibey architecture

Dependencies point inward. `import-linter` enforces this in CI via four
contracts: onion-layers, domain-independence, application-independence, and
interfaces-declare-only.

```
src/vibey/
├── domain/            # PURE. stdlib only. No I/O, no async, no third-party.
├── application/       # Protocol ports + use cases. Imports domain + stdlib.
├── infrastructure/    # Adapters. ONLY layer that may import asyncpg, httpx, etc.
├── cli/               # Typer. Calls application via bootstrap.
├── tui/               # Textual. Calls application via bootstrap.
└── bootstrap.py       # Composition root — the ONE module that sees every layer.
```

## Where does new code go?

1. **Touches FS, network, clock, database, or an SDK?** → `infrastructure/`,
   behind a `Protocol` in `application/interfaces/<area>.py` (`azure`, `build`,
   `design`, `engines`, `gates`, `ledger`, `observability`, `projects`, `queue`,
   `review`, `system`, `visual`). `application/ports.py` is a compatibility
   re-export shim of seven names — never add to it. Never `import asyncpg` or
   vendor SDKs elsewhere.
2. **Pure decision, zero I/O?** → `domain/`. Examples: `phase.py`,
   `rotation.py`, `noloss.py`, `circuit.py`, `effort.py`.
3. **Orchestration (port → domain → port)?** → `application/`.
4. **Argument parsing / terminal formatting?** → `cli/` or `tui/`.
5. **Wiring concrete implementations to Protocols?** → `bootstrap.py` ONLY.

When in doubt, push logic inward. `lint-imports` names the broken contract
when you violate the onion.

## Shape: classes, behind interfaces

Code lives in **class objects**. A module-level function is the method of last
resort — a package's `__all__` façade, a `__main__` entry point, a bare function
some library contract requires — and where you use one, write the reason at the
definition. "It is only a few lines" is not a reason.

**Every class has an interface beside it**, in a mirrored `interfaces/` directory
(illustrative paths — there is no `services/` package):

```
src/vibey/services/github_service.py
src/vibey/services/interfaces/github_service_interface.py
```

This is the target shape, not the current one. Today's interfaces packages
(`application/interfaces/`, `cli/interfaces/`, and the empty
`infrastructure/interfaces/`) are mostly organised by area; `cli/interfaces/early_signals_interface.py`
is the one module that already follows the `<module>_interface.py` mirror.

The mapping is mechanical: the directory gains an `interfaces/` child, the module
gains an `_interface` suffix. Interfaces **declare**; they never consume — an
interface module imports the standard library and other interfaces, nothing else
from its own tree. The rule applies to every `interfaces/` package; the
`interfaces-declare-only` contract in `.importlinter` enforces part of it today, for
`application/interfaces` only (it forbids imports of `vibey.infrastructure`,
`vibey.cli`, `vibey.tui` and `vibey.bootstrap`).

Why: a class behind an interface is substituted at its seam. The caller takes the
interface, the test passes a double, and nothing is patched — so the test depends
on the contract, which is meant to be stable, rather than on the import graph,
which is not. With a 100% branch floor that is not a style preference: a branch
reachable only by `monkeypatch.setattr` is a branch whose test breaks for reasons
unrelated to its subject.

This applies to **new and changed code**. The existing tree (289 module-level
functions, and 278 module-level classes outside an `interfaces/` package, 277 of
them with no mirrored `_interface` module — AST walk of `src/vibey/**/*.py`,
top-level definitions only, measured 2026-09-15) converges module by module,
and the absorbed subtrees under `src/vibey_runners/` and `src/vibey_tools/`
converge as they are touched — rewriting them on import would destroy the
property their import exists to create. `domain/` gets no exemption: a pure
function becomes a method on a stateless class, and purity is preserved, because
purity was never about the absence of a class.

See ADR-0016 and sub-doctrine 9.b (the declared seam).

## Dogfood the family first

If a capability exists inside this family, use it. Do not reimplement it, and do
not reach for a third-party equivalent. The bar is not "is ours better" — it is
"does ours do this at all".

Between `vibey_bootstrap`'s retry and a retry loop, use theirs. Between its
dead-letter routing and a hand-rolled failure path, use its. Between its
correlation scope and threading a request id by hand, use its. When the choice is
between using a family feature and not using it, **always prefer using it**.

The shape is the one this codebase already uses: a Protocol in
`application/interfaces/`, an adapter in `infrastructure/` that satisfies it with
the family package, and a line in `bootstrap.py`. Dogfooding is not a new pattern
here — it is the existing one, applied to ourselves.

A new implementation of something the family already ships needs a written reason
at the call site, and the reason must be a capability gap. If ours is missing
something, the fix is to add it to ours.

Measured 2026-09-15: `src/vibey` imports **zero** family packages. See ADR-0017
(and sub-doctrine 10.e) for the parity backlog.

## The workspace tenants

This repository is a uv workspace (`[tool.uv.workspace] members =
["src/vibey_runners/*", "src/vibey_tools/*"]`, ADR-0021). `vibey` is the root
package; the tenants are absorbed packages that ship inside the one `vibey`
distribution rather than under their own PyPI names (ADR-0037). Their former
GitHub repositories (`the-vibey-project/claudeloop`, `vibey-gh`, and the rest)
and their former PyPI projects no longer exist — never link either; link the
directory in this repository instead.

| Directory | Package | Python floor | Own checks |
|---|---|---|---|
| `src/vibey_runners/claude` | `claudeloop` | 3.12 | pytest, ruff, mypy, import-linter |
| `src/vibey_runners/codex` | `codexloop` | 3.12 | pytest, ruff, mypy, import-linter |
| `src/vibey_runners/qwen` | `qwenloop` | 3.12 | pytest, ruff, mypy, import-linter |
| `src/vibey_runners/common` | `vibey-runners-common` | 3.12 | ruff, mypy, import-linter (no test directory yet) |
| `src/vibey_tools/gh` | `vibey-gh` | 3.12 | pytest (100% branch), black, isort, mypy, bandit |
| `src/vibey_tools/skills` | `vibey-skills` | 3.12 | manifest/link validators, unittest |
| `src/vibey_tools/bootstrap` | `vibey-bootstrap` | 3.12 | pytest (100% line), black, isort, mypy, bandit |

Rules for working inside a tenant:

- **Its own configuration governs it.** Each tenant's `pyproject.toml` carries its
  own tool sections and test directory (`test/` for `gh` and `bootstrap`, `tests/`
  for the rest). Run its checks from its own directory; the root `pytest`
  (`testpaths = ["tests"]`) and the root `mypy`/`bandit`/coverage gates never
  reach it. Root `ruff check .` and `ruff format --check .` do (`include =
  ["src/**/*.py", "tests/**/*.py"]`). Absorbed packages keep the gates they
  arrived with (ADR-0022); see the `vibey-quality-gates` skill for the commands.
- **Converge as touched.** ADR-0016 and ADR-0017 apply to tenants only as their
  code is changed. Do not restyle or restructure a tenant on import.
- **The root layers do not import tenants,** except the dependency-free family
  packages `domain/` may use (below) and adapters in `infrastructure/`.

## Everything-as-code, and never less of it

If a thing can be declared in the repository and reconciled from it, that is how
it is done. Branch protection and the repository profile live in `.vibey-gh.toml`
(`[rulesets]`, `[repository_profile]`) and are reconciled by `vibey-gh`; pipelines
are rendered from templates; policy is `.importlinter` and the coverage gates. No
settings page, no one-off `gh api`, no runbook step that says "then set X".

And **everything that can be generic and configurable must be.** A hard-coded
value that could have been a key, a special case that could have been a rule, a
path that could have been discovered — each takes a decision away from whoever
adopts this next, silently. Never change anything to a state that is less generic
or less configurable. A default is fine; a default is configurability with an
opinion. A constant is not.

Worked example: `find_root` walked to `.git`, which broke the moment a project
lived inside a repository belonging to something else. The fix was not an
explicit root at the two call sites that noticed — it was that a directory
carrying its own `.vibey-gh.toml` stops the walk. Same work, aimed at the general
shape. See ADR-0018.

## Convergence-Driven Development is the delivery loop

Every change uses Specification-Driven Development (SDD) for intent and
acceptance criteria, Test-Driven Development (TDD) for executable checks, and
Convergence-Driven Development (CDD) above them to ground, implement, test,
review and repair against the actual tracked repository. At every iteration,
classify the trajectory as converging, neutral or diverging against the
remaining criteria and failing checks. A divergence is allowed only when it is
small, bounded and has a concrete path to reconvergence; large or pathless
divergence is abandoned. Never treat activity, a marker or a generated file as
completion evidence. See sub-doctrine 9.c and ADR-0039.
Project atoms can compose chemical structures, and a suite of suites can be
alive in the digital realm as a software organism when identity, feedback,
adaptation, repair and exchange are evidenced; inspect that higher-order
trajectory whenever the change participates in it.

## A standing rule goes in the canon

This project has law, not just decision records: the constitutional cluster in
`src/vibey_tools/gh/docs/` — the Constitution, the Twelve Doctrines, the Ten
Commandments, the Bill of Rights, SD-01 — indexed by `vibey-gh corpus-index` so
drift between published law and its index is one hash comparison.

The Twelve are **sealed**. Anything new files as a **sub-doctrine** under one of
them, in the established form, and is ratified by the operator's merge (Article
II.3). The ratchet applies: a refinement may strengthen, never weaken.

So if what you are writing binds future decisions, survives a rewrite, and is
about conduct rather than mechanism — it is a sub-doctrine, and writing it only as
an ADR leaves it with no ratification, no ratchet protection and no chunk in the
corpus. Write both: the sub-doctrine states the rule, the ADR argues it.

"PostgreSQL, not SQLite" is a mechanism and stays an ADR. "Dogfood the family" is
a rule and belongs in the canon. See ADR-0020 and sub-doctrine 12.b.

### Filing a sub-doctrine

1. **Pick the parent.** Find the one of the twelve `## N — title` headings in
   `src/vibey_tools/gh/docs/doctrines.md` the rule stands under. Use the next free
   letter (existing entries: 2.a–2.b, 4.a, 7.a, 8.a, 9.a–9.b, 10.a–10.e, 12.a–12.c).
2. **Write it in the established form** — one bold run-in paragraph under the parent
   heading:

   ```
   **9.c — short title** *(ratified by the merge that carried this entry)*: the rule,
   stated as conduct, in present tense.
   ```

   The ratification note becomes a date only after the fact; do not pre-date it.
3. **Write the ADR** that argues it and cite the sub-doctrine by number (ADR-0016
   through ADR-0020 pair with 9.b, 10.e, 12.c, 2.b and 12.b).
4. **Regenerate and check the corpus index** from the tenant directory:

   ```bash
   cd src/vibey_tools/gh
   uv run vibey-gh corpus-index          # rewrites corpus-index.json
   uv run vibey-gh corpus-index --check  # "corpus intact: N chunk(s)"
   ```

   The index lives at `src/vibey_tools/gh/corpus-index.json` (32 chunks on
   2026-09-15); there is no root copy. Commit it with the doctrine change.
5. **Ratify by human merge.** Article II.3: the doctrines' sub-entries are ratified
   by humans through reviewed pull requests — a machine may draft, a human
   ratifies. Article IV.2 (the ratchet): a change may clarify, strengthen or
   extend; it may never weaken.
6. **Know the blast radius.** Article V.4: a ratified change to the canon yanks
   every prior release of the code. A sub-doctrine merge is a release-level event.

A standing subdoctrine carried verbatim in agent files (such as SD-01 in
`CLAUDE.md`) is amended only by the operator, with a version bump — never edited
in passing.

## The forbidden imports

`domain/` must never import:
- Any layer: `vibey.application`, `vibey.infrastructure`, `vibey.cli`, `vibey.tui`
- Third-party: `asyncpg`, `psycopg`, `httpx`, `typer`, `structlog`, `pydantic`, `textual`
- `vibey_bootstrap` — named rather than categorised: it carries the Azure SDK and
  OpenTelemetry, so importing it here pulls a third-party graph in transitively
- I/O, async and wall-clock from stdlib: `tests/domain/test_domain_purity.py` walks
  the AST and rejects imports of `asyncio`, `asyncpg`, `subprocess`, `socket` and
  `pathlib` (plus the third-party names above), calls to `open()`, `os.environ`,
  `os.getenv`, `datetime.now()`/`today()`, `time.time()`, and any `async def`,
  `await`, `async with` or `async for`

`domain/` **may** import a family package that is itself dependency-free —
`vibey_gh`, `vibey_skills`, `vibey_runners.common` all declare `dependencies = []`,
so they add nothing to the graph that stdlib-only did not already allow. This is the
ADR-0017 caveat to the stdlib-only rule: a first-party package is not third-party.

`application/` must never import:
- `vibey.infrastructure`, `vibey.cli`, `vibey.tui`

## Verify before committing

```bash
uv run lint-imports
```

If it fails, the error names the contract and the violating import. Fix it by
moving the offending code to a layer that can legally import the dependency,
or by abstracting the dependency behind a Protocol in
`application/interfaces/<area>.py` (`ports.py` is a compatibility re-export shim —
never add to it).

The onion layering has no ADR of its own: its authority is `.importlinter` and
`docs/plans/architecture-and-roadmap.md`. ADR-0016 governs the class/interface
shape; ADR-0021 records the monorepo layout.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…