Skip to content

Latest commit

 

History

History
406 lines (292 loc) · 11.8 KB

File metadata and controls

406 lines (292 loc) · 11.8 KB

Beans — Agent Guide

This file describes the conventions and design principles for contributing to beans. Follow these closely — they are intentional and reflect the project's values.

Philosophy

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

Architecture

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, handles beans init.
  • cli.py — Thin wiring. No business logic. Display formatting lives here.

Code Style

No underscore prefixes

Everything is public. Don't use _ to signal "private" — if it's in the module, it's part of the module.

Functions over methods

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

Default arguments for configurability

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 dependency

**kwargs over dict for named fields

When 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"})

Constants for magic values

ID_BYTES = 4

Let Pydantic handle coercion

Don't manually convert what Pydantic validates and coerces automatically. For example, Pydantic coerces ISO strings to datetime — don't call fromisoformat() yourself.

Import organization

Use section comments and force-sort-within-sections:

# Python imports
import json
import sqlite3

# Pip imports
import typer

# Internal imports
from beans.models import Bean

Type annotations

Annotate return types. Don't annotate parameters when the default value tells the story.

Dependency Injection

Inject, don't construct

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:"))

Context manager protocol

Resource-holding classes implement __enter__/__exit__ to avoid explicit close().

with BeanStore.from_path("beans.db") as store:
    store.create(bean)

Model stays pure, display lives in CLI

Don't add __str__ or __format__ to models for display purposes. Display formatting is a CLI concern — use standalone functions like format_bean().

Testing

No mocks

BeanStore gets a real SQLite :memory: database. Test real behavior, not mock wiring.

Assert against the model, not individual fields

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",
        ...
    }

Module-level fixtures for shared infrastructure

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

One assertion purpose per test

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.

Readability over DRY

Allow repetition in tests. Each test should be self-contained and readable without jumping to shared helpers.

Workflow

Red-green-refactor TDD

  1. Write a failing test (RED)
  2. Make it pass minimally (GREEN)
  3. Commit: feat: <description>
  4. Refactor, verify green
  5. Commit: refactor: <description>

Small commits

Each commit does one thing. Use conventional commit messages:

  • feat: — new functionality
  • fix: — bug fix
  • refactor: — code improvement, no behavior change
  • chore: — tooling, config, deps
  • docs: — documentation
  • ci: — 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"

Verify before committing

uv run pytest
uv run ruff check src/ tests/

Review before committing

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.

Releasing

  1. Bump the version in pyproject.toml
  2. Generate changelog:
    uv run git-cliff --tag v<VERSION> -o CHANGELOG.md
  3. 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
  4. 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.

Task Tracking with 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.

Before starting work

uv run beans ready              # see what's unblocked
uv run beans show <id>          # read the full bean before starting

Self-contained beans

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

Working on a task

uv run beans claim <id> --actor <name>   # claim before starting
# ... do the work ...
uv run beans close <id>                  # close when done

Creating beans

Use --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>

Bean hygiene

  • 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

Bean-first rule

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.

Bean lifecycle

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.

Agent workflow modes

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.

Autonomous mode

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-5p after 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

Collaborative mode

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 ready and 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

Tools

  • 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