This file describes the conventions and design principles for contributing to beans. Follow these closely — they are intentional and reflect the project's values.
- Low cognitive load. Code reads top-to-bottom without jumping around.
- Pure by default. Push I/O to the boundary. Models and helpers are pure functions.
- No over-engineering. Don't build abstractions until you have two concrete uses.
- Defer what you don't need. If a feature isn't needed yet, don't add it.
src/beans/
├── models.py # Pydantic models, pure functions, no I/O
├── store.py # SQLite I/O boundary, BeanStore class
├── api.py # Command API, composes store calls, returns models
├── config.py # Global config (~/.config/beans/config.json)
├── project.py # Project discovery (find .beans/, init)
└── cli.py # Typer CLI, thin wiring layer (parse → call → format)
- models.py — Pure data and pure functions. No imports from store or cli.
- store.py — The only place that touches the database. Accepts injected connections.
- api.py — Command API. Each function is one use case, composes store calls.
- config.py — Global user config. Reads from XDG config directory.
- project.py — Project discovery. Finds
.beans/dir, handlesbeans init. - cli.py — Thin wiring. No business logic. Display formatting lives here.
Everything is public. Don't use _ to signal "private" — if it's in the module, it's
part of the module.
If it doesn't need self, it's a standalone function, not a method. Compose small
functions rather than building class hierarchies.
# Yes
def columns(cursor):
return [desc[0] for desc in cursor.description]
def row(cols, values):
return dict(zip(cols, values))
# No
class BeanStore:
def _get_columns(self, cursor): ...
def _row_to_dict(self, cols, values): ...Never reference module-level constants directly inside function bodies. Instead, pass them as default arguments. This makes functions testable and reusable without adding configuration infrastructure. For classes, use class attributes instead of reaching for the global.
# Yes — constant flows through the signature
def find_beans_dir(start=None, dirname=BEANS_DIR) -> Path: ...
# Yes — class owns its configuration
class BeanId(str):
pattern = ID_PATTERN
def __new__(cls, value="", **kwargs):
if not cls.pattern.match(value): ...
# No — function reaches for the global directly
def find_beans_dir(start=None) -> Path:
candidate = current / BEANS_DIR # hidden dependencyWhen a function accepts a set of named fields, use **kwargs instead of a dict parameter.
It reads better at the call site and lets the interpreter catch typos.
# Yes
def update(self, bean_id, **fields) -> int: ...
store.update(bean_id, title="New title", status="closed")
# No
def update(self, bean_id, fields: dict) -> int: ...
store.update(bean_id, {"title": "New title", "status": "closed"})ID_BYTES = 4Don't manually convert what Pydantic validates and coerces automatically. For example,
Pydantic coerces ISO strings to datetime — don't call fromisoformat() yourself.
Use section comments and force-sort-within-sections:
# Python imports
import json
import sqlite3
# Pip imports
import typer
# Internal imports
from beans.models import BeanAnnotate return types. Don't annotate parameters when the default value tells the story.
BeanStore takes a sqlite3.Connection, not a path. Factory classmethods handle
construction. This enables testing with :memory: databases.
# Production
store = BeanStore.from_path("beans.db")
# Testing
store = BeanStore(sqlite3.connect(":memory:"))Resource-holding classes implement __enter__/__exit__ to avoid explicit close().
with BeanStore.from_path("beans.db") as store:
store.create(bean)Don't add __str__ or __format__ to models for display purposes. Display formatting
is a CLI concern — use standalone functions like format_bean().
BeanStore gets a real SQLite :memory: database. Test real behavior, not mock wiring.
Leverage Pydantic equality to compare whole objects. Don't decompose into field-by-field checks when a single comparison says it all.
# Yes — one assertion, full structural check
assert store.get(bean.id) == bean
assert store.list() == [b1, b2]
# No — decomposing what equality already covers
result = store.get(bean.id)
assert result.id == bean.id
assert result.title == "Fix auth"For model defaults, use model_dump() against an expected dict with fixed id and
created_at to pin dynamic fields.
def test_defaults(self):
bean = Bean(id="bean-00000000", title="Fix auth", created_at=FIXED_TIME)
assert bean.model_dump() == {
"id": "bean-00000000",
"title": "Fix auth",
"type": "task",
...
}Extract common fixtures (like store) to module level. Keep test classes for grouping
related tests by behavior, not for fixture scoping.
@pytest.fixture()
def store():
with BeanStore(sqlite3.connect(":memory:")) as s:
yield s
class TestBeanStoreCreateAndList:
def test_create_and_list(self, store):
...A test method can have multiple assert statements if they verify the same thing (e.g.,
a dict comparison). Don't test unrelated behaviors in one method.
Allow repetition in tests. Each test should be self-contained and readable without jumping to shared helpers.
- Write a failing test (RED)
- Make it pass minimally (GREEN)
- Commit:
feat: <description> - Refactor, verify green
- Commit:
refactor: <description>
Each commit does one thing. Use conventional commit messages:
feat:— new functionalityfix:— bug fixrefactor:— code improvement, no behavior changechore:— tooling, config, depsdocs:— documentationci:— CI/CD changes
When a commit resolves a bean, append #closes <bean-id> to the commit message:
git commit -m "feat: add --body flag to create command #closes bean-69b4e720"uv run pytest
uv run ruff check src/ tests/After tests and lint pass, review the pending changes before committing:
inspect-5p
This runs a 5-pass parallel review of uncommitted changes, covering security, correctness, design, testing, and conventions. Fix any issues found, re-run tests, then commit.
- Bump the version in
pyproject.toml - Generate changelog:
uv run git-cliff --tag v<VERSION> -o CHANGELOG.md
- Commit and tag:
git add pyproject.toml CHANGELOG.md git commit -m "chore: bump version to <VERSION>" git tag v<VERSION> git push origin main --tags
- Create a GitHub Release:
gh release create v<VERSION> --generate-notes
The release workflow runs tests, publishes to PyPI, and updates the Homebrew formula.
The package is magic-beans on PyPI, but the CLI is beans and the import is import beans.
This project uses beans itself for task tracking. Beans is the primary tool for managing work — use it to create, track, and close all tasks, bugs, and epics.
uv run beans ready # see what's unblocked
uv run beans show <id> # read the full bean before startingEvery bean body must contain enough context for someone with no prior conversation to pick it up. Include: what needs to change, why, which files are involved, and what "done" looks like. Never rely on thread context or conversation history.
uv run beans claim <id> --actor <name> # claim before starting
# ... do the work ...
uv run beans close <id> # close when doneUse --body to describe the task fully. Use --parent for subtasks. Use beans dep add
to express ordering constraints. Choose meaningful types (task, bug, epic).
uv run beans create "Title" --body "Full description of what and why"
uv run beans create "Subtask" --parent <epic-id>
uv run beans dep add <blocker-id> <blocked-id>- One bean per deliverable change
- Close beans when done, don't leave them dangling
- If a bean is no longer needed, close it with an update explaining why
- If you discover new work while working on a bean, create a new bean for it
Every deliverable product or code task MUST become a bean before any code or research begins. If you discover work mid-task, create a bean for it before starting.
Exceptions:
- operational chores that do not change product behavior
- git/history cleanup
- release administration
- external repository maintenance (for example tap repos, packaging metadata, or CI fixes outside this repo)
- one-off local cleanup explicitly requested by the user
When a task is ambiguous, ask before creating a bean.
A bean can only be closed once tests pass and changes are committed:
beans create → beans claim → implement → lint → test → commit → beans close
When committing, append #closes <bean-id> to the commit message. When closing
the bean, include the commit SHA in --reason:
git commit -m "feat: add --body flag to create command #closes bean-69b4e720"
beans close bean-69b4e720 --reason "Implemented in abc1234"After implementation is complete, ALWAYS run linters and tests before closing the bean. This is part of the workflow, not an optional step.
When a project uses beans for task tracking, the agent operates in one of two modes. The user chooses the mode at the start of a session. Default is collaborative.
The agent drives the full loop without pausing:
beans ready → pick highest priority → beans claim →
read bean → implement (TDD) → lint → test → inspect-5p → commit →
beans close → next bean
- Auto-pick the highest priority unblocked bean from
beans ready - Auto-claim before starting
- Run
inspect-5pafter tests pass, fix any issues, then auto-commit - Auto-close the bean after commit
- If new work is discovered, create a bean for it and continue the current task
- Stop after completing all ready beans or when the user interrupts
The agent pauses at decision points and waits for the user:
beans ready → user picks → beans claim →
read bean → implement (TDD) → wait for commit approval →
user reviews → beans close
- Show
beans readyand wait for the user to pick a task - Claim after user confirms
- Implement (RED → GREEN), then pause — do not commit without user approval
- After commit, pause before closing the bean
- If new work is discovered, create a bean and ask the user what to do
- uv — package management, running, building
- beans — task tracking (
uv run beans ready,uv run beans list) - pytest — testing with coverage (
--cov=beans) - ruff — linting (line-length=120, select E,F,I,N,UP,RUF)
- git-cliff — changelog generation from conventional commits