---
title: Code Conventions
read_when:
  - "naming functions or variables"
  - "creating CLI commands"
  - "naming Claude artifacts"
  - "moving code between packages"
  - "creating imports"
  - "creating immutable classes or frozen dataclasses"
  - "implementing an ABC with abstract properties"
tripwires:
  - action: "writing `__all__` to a Python file"
    warning: "Re-export modules are forbidden. Import directly from where code is defined."
  - action: "adding --force flag to a CLI command"
    warning: 'Always include -f as the short form. Pattern: @click.option("-f", "--force", ...)'
  - action: "adding a function with 5+ parameters"
    warning: "Load `dignified-python` skill first. Use keyword-only arguments (add `*` after first param). Exception: ABC/Protocol method signatures and Click command callbacks."
  - action: "editing source files directly on master branch"
    warning: "Never edit source files on master. Even for one-line fixes, use plan-first workflow. Bypasses review, CI gates, and worktree isolation."
last_audited: "2026-02-16 14:20 PT"
audit_result: clean
---

# Code Conventions

This document defines conventions that supplement AGENTS.md. For basic naming (snake_case, PascalCase, kebab-case), Claude artifacts naming, brand names, worktree terminology, and CLI command organization, see AGENTS.md directly.

## Variable Naming by Type

| Type                  | Convention               | Example                          |
| --------------------- | ------------------------ | -------------------------------- |
| Issue numbers (`int`) | `_id` suffix             | `objective_id`, `plan_id`        |
| Issue objects         | No suffix or `_issue`    | `objective`, `plan_issue`        |
| String identifiers    | `_identifier` or `_name` | `plan_identifier`, `branch_name` |

**Rationale:** When a variable holds an integer ID (like a GitHub issue number), the `_id` suffix makes the type immediately clear. This distinguishes `objective_id: int` (an issue number) from `objective: ObjectiveInfo` (an object).

## CLI Flag Conventions

All `--force` flags must have `-f` as the short form. This provides consistent UX across all commands.

```python
@click.option("-f", "--force", is_flag=True, help="...")
```

## Import Conventions

### No Re-exports for Internal Code

**Never create re-export modules for backwards compatibility.** This is private, internal software — we can change imports freely.

When moving code between packages:

- Update all imports to point directly to the new location
- Don't create re-export files that import from the new location and re-export

**Example:** When moving `markers.py` from `erk/core/` to `erk_shared/scratch/`:

```python
# WRONG: Creating a re-export file at old location
from erk_shared.scratch.markers import PENDING_LEARN_MARKER, create_marker, delete_marker

# CORRECT: Update all consumers to import directly
from erk_shared.scratch.markers import PENDING_LEARN_MARKER, create_marker
```

### Import from Definition Site

Always import from where the code is defined, not through re-export layers:

- `from erk_shared.scratch.markers import create_marker`

## Speculative Feature Pattern

For features that may be removed, use this pattern for easy reversal:

### 1. Feature Constant at Module Top

```python
# SPECULATIVE: feature-name - set to False to disable
ENABLE_FEATURE_NAME = True
```

### 2. Guard Call Sites with the Constant

```python
# SPECULATIVE: feature-name - description
if ENABLE_FEATURE_NAME:
    do_speculative_thing()
```

### 3. Document in Module Docstring

```python
"""Module description.

SPECULATIVE: feature-name (objective #XXXX)
This feature is speculative. Set ENABLE_FEATURE_NAME to False to disable.
Grep for "SPECULATIVE: feature-name" to find all related code.
"""
```

| Action               | Command                                    |
| -------------------- | ------------------------------------------ |
| **To disable**       | Set constant to `False`                    |
| **To find all code** | `grep -r "SPECULATIVE: feature-name" src/` |
| **To remove**        | Delete the module and guarded blocks       |

## AI-Generated Commit Messages

### Forbidden Elements

| Element            | Example                      | Reason                |
| ------------------ | ---------------------------- | --------------------- |
| Claude attribution | `Generated with Claude Code` | Noise, not meaningful |
| Metadata headers   | `---\ntool: claude\n---`     | Not standard format   |
| Excessive emoji    | `Added feature`              | Distracting           |

### Required Elements

| Element                 | Example                                  | Reason                  |
| ----------------------- | ---------------------------------------- | ----------------------- |
| Component-level summary | `Add user authentication to API`         | Clear scope             |
| Key changes (max 5)     | `- Add login endpoint\n- Add JWT tokens` | Scannable               |
| Closes reference        | `Closes #123`                            | Auto-close linked issue |

## Immutable Classes

### Frozen Dataclasses (Default)

For simple immutable data, use frozen dataclasses with plain field names:

```python
@dataclass(frozen=True)
class PRNotFound:
    pr_number: int | None = None
    branch: str | None = None
```

**Never use underscore-prefixed fields** like `_message` with pass-through properties. If a Protocol requires a `message` property, a frozen dataclass field named `message` satisfies it:

```python
# WRONG: Unnecessary underscore pattern
@dataclass(frozen=True)
class GitHubAPIFailed:
    _message: str

    @property
    def message(self) -> str:
        return self._message

# CORRECT: Plain field satisfies Protocol
@dataclass(frozen=True)
class GitHubAPIFailed:
    message: str
```

### Slots-Based Classes (For ABC with Abstract Properties)

When implementing an ABC that defines abstract **properties** (not methods), frozen dataclasses create a conflict: you can't have both a dataclass field and a property with the same name.

In this case, use a slots-based class with underscore-prefixed internal fields. See `LocalSessionSource` in `packages/erk-shared/src/erk_shared/learn/extraction/session_source.py` for the canonical example.

**Key points:**

- Constructor uses clean names (`session_id=`), not underscore-prefixed (`_session_id=`)
- Internal slots use underscores (`_session_id`) to avoid shadowing properties
- Immutability is by convention (underscore prefix signals "don't mutate"), not runtime enforcement

## LBYL Violations in Disguise

### `.get()` Chaining

Chained `.get()` calls are an EAFP violation. They silently return `None` on missing keys instead of failing explicitly:

```python
# WRONG: EAFP violation — silent None on missing key
value = config.get("section", {}).get("key")

# CORRECT: LBYL — check each level explicitly
if "section" in config and "key" in config["section"]:
    value = config["section"]["key"]
```

### `lambda` in `default_factory`

Using `lambda` in `default_factory` is forbidden. Use a named function instead:

```python
# WRONG: lambda in default_factory
field(default_factory=lambda: [])

# CORRECT: use list directly
field(default_factory=list)
```

## API Naming: Internal/External Alignment

Property names should match user-facing output labels. When the CLI displays a label like "Checkout:", the corresponding property should be named `checkout`, not an internal-only term.

**Example:** The `prepare` to `checkout` rename in `next_steps.py`:

- Properties were renamed from `prepare` to `checkout`, `checkout_and_implement`, `checkout_new_slot`, `checkout_new_slot_and_implement`
- The output label "Checkout:" matches the property name
- The constant `CHECKOUT_SLASH_COMMAND` value intentionally remains `"/erk:prepare"` because the slash command `/erk:prepare` still exists

**Source:** `packages/erk-shared/src/erk_shared/output/next_steps.py`

## String Method Preferences

Prefer `str.removesuffix()` and `str.removeprefix()` (Python 3.9+) over manual slicing:

```python
# WRONG: Manual slicing
if name.endswith("_cmd"):
    name = name[:-4]

# CORRECT: removesuffix
name = name.removesuffix("_cmd")
```

## Pydantic Field() Exception

Pydantic `Field()` with defaults is an exception to erk's "no default parameters" rule. This applies only to Pydantic config/settings classes (e.g., `BaseSettings` subclasses in `packages/erkbot/src/erkbot/config.py`), NOT to regular functions or dataclasses.

## Single-Use Locals Line-Length Exception

When a single-use local variable is introduced solely to avoid a line-length violation, prefer shortening the variable name rather than ignoring the single-use locals rule. A shorter but still descriptive name keeps the code compact without introducing an unnecessary intermediate binding:

```python
# Avoid: long variable name just to break line
validation_result = check_objective(issue_number)
print(validation_result)

# Prefer: shorter name still readable
result = check_objective(issue_number)
print(result)
```
