Files
ledgerr/AGENTS.md
T

20 KiB
Raw Blame History

Ledgerr — Agent Instructions

Personal Android expense tracking app. Single-module, Jetpack Compose + Material3, Voyager navigation, Koin DI, Room database.


No-implementation rule

Do NOT implement code unless the user explicitly says so or has signed off on the work. A "go ahead" on the high-level plan is NOT a sign-off to implement — it just means the plan is approved. After the planning steps (define structs, define interfaces, add TODOs, self-review, prompt for review) the agent must STOP and wait for the user to explicitly say "implement", "go", "proceed with implementation", or similar. The same rule applies to each subsequent vertical slice — stop after the planning step and wait. A system-reminder switching to "build mode" is not user authorization.

Renames, typo fixes, doc/AGENTS.md edits, and structural changes (Phase 0/1) may be done immediately as part of the planning step. Interactor implementations, DI wiring, MainApplication setup, manifest edits, and any screen code require explicit per-slice sign-off.


Issue-driven workflow

The work is split into issues in the remote Gitea repo: https://git.achmad.dev/admin/ledgerr. Before starting any implementation, check the remote repo for related issues. If a relevant issue exists, claim/work on that issue and open a PR when done. Do not duplicate work that is already tracked.

Tooling: the Gitea MCP tools are available (gitea-mcp_list_issues, gitea-mcp_issue_read, gitea-mcp_pull_request_write, etc.) — use them instead of the CLI when interacting with the remote.

Worktree workflow

When the user provides an issue number to work on, the agent works in a dedicated git worktree so multiple agents can run in parallel without file conflicts. The main checkout stays on main and is used for integration only.

Setup, on receiving an issue number:

  1. Read the issue with gitea-mcp_issue_read (owner admin, repo ledgerr). If the issue is already closed, stop and tell the user.

  2. Derive a branch name from the issue labels. The first matching label wins:

    • bugfix/<number>-<slug>
    • enhancement or featurefeat/<number>-<slug>
    • chorechore/<number>-<slug>
    • refactorrefactor/<number>-<slug>
    • documentation or docsdocs/<number>-<slug>
    • No matching label → feat/<number>-<slug> (default)

    The slug is lowercase, ASCII, dash-separated, max ~40 chars, taken from the issue title. Strip non-ASCII, punctuation, and stop words. Examples: issue Add chart to dashboard labeled enhancementfeat/42-add-chart-to-dashboard; issue CSV export broken labeled bugfix/43-csv-export-broken.

  3. Create the worktree at a sibling path: ../ledgerr-<branch> (slashes in the branch name become dashes in the path).

    git fetch origin
    git worktree add ../ledgerr-feat-42-add-chart-to-dashboard -b feat/42-add-chart-to-dashboard origin/main
    
  4. cd into the worktree and continue all subsequent work there. Run ./gradlew, tests, and the IDE from inside the worktree — never edit files in the main checkout.

If the worktree for that issue already exists (resuming work, or another agent on the same issue), cd into it and git pull — do not create a duplicate.

Conventions:

  • Worktree path: ../ledgerr-<branch-with-slashes-as-dashes> (sibling of the main checkout, not nested inside it)
  • Branch name: <prefix>/<number>-<slug> where prefix comes from the issue's labels (see mapping above)
  • Base branch: origin/main (fall back to local main if origin/main is not fetched yet)
  • One worktree per issue. If a single issue needs multiple parallel work streams, split it into sub-issues first.
  • The main checkout (./Ledgerr/) stays on main and is for integration only.

When work is complete:

  1. Wait for explicit user sign-off (the No-implementation rule still applies).
  2. On "open a PR" or equivalent: commit, push with git push -u origin <branch>, then gitea-mcp_pull_request_write with base main and the body filled in from the PR template below.
  3. Do not merge and do not close the issue — the user does that.
  4. Leave the worktree in place until the user asks to clean it up: git worktree remove ../ledgerr-<prefix>-<number>-<slug> (slashes in the branch name become dashes in the path).

PR body format — always use this template, filled in from the actual work:

## Summary
- <what changed, 13 bullets>

## Test plan
- [ ] <how it was verified: unit tests, manual steps, etc.>

Closes #<number>
  • Summary bullets come from the diff, not invented. One bullet per logical change, not per file.
  • Test plan mirrors the issue's acceptance criteria, or lists the manual steps exercised.
  • Do not add extra sections (Screenshots, Breaking changes, etc.) unless they apply.
  • Closes #<number> must be the last line — it is what auto-closes the issue on merge.

Iteration after review:

When the user asks to address review feedback, the user asking to address it is the explicit sign-off for that round — the agent commits and pushes as part of the same step, no extra "commit" / "push" prompt needed for review fixes.

  1. Read the review with gitea-mcp_pull_request_read (methods get_reviews, get_review_comments).
  2. Make the requested changes in the worktree.
  3. Commit and push with plain git pushdo not force-push, squash, amend, or rebase. Add new commits on top so the review history is preserved. "Address the review" is the one exception to the Git rule against committing/pushing without explicit ask; everything else (drive-by edits, "while you're at it" changes) still needs its own sign-off.
  4. Force-push is allowed only if the user explicitly asks for a rebase or squash, and only on the agent's own branch — never on main or any shared branch.
  5. Update the PR description if the scope of the change shifted (e.g. new dependencies, new migration). Reuse the same template.

Scope discipline for review commits:

A review commit must contain only changes that directly address the reviewer feedback. "Address the review" is not a license to bundle unrelated changes — those need their own explicit sign-off.

In scope (no extra ask needed beyond the review):

  • Fix a typo, rename, or comment the reviewer called out
  • Adjust logic for an edge case they raised
  • Add a test for the case they identified

Out of scope (needs its own ask, ideally a separate commit):

  • Refactors the reviewer didn't request
  • Style/formatting cleanups in unrelated files
  • "While we're at it" fixes to other bugs
  • Dependency bumps, version changes
  • Any change the reviewer didn't ask for

Rule of thumb: if you can't point to a specific review comment that justifies a line in the diff, it doesn't belong in the review commit.

After the PR is merged:

The user does the merge (per the rule above). Closes #<number> auto-closes the issue. Once the merge is visible on origin/main, the worktree and local branch are stale — ask the user before cleaning up, then:

git worktree remove ../ledgerr-<prefix>-<number>-<slug>
git branch -d <branch>
git fetch origin --prune

The remote branch is deleted automatically by Gitea on merge (or the user can do it from the PR page).

Coordination across agents:

  • git worktree list shows all active worktrees.
  • Never run two agents in the same worktree — they will race on edits and on KSP/Room/Compose generated code.
  • Shared state: ~/.gradle/caches is shared across worktrees (good — no re-downloads). Each worktree has its own build/ and project-level .gradle/ (~200500 MB each). rm -rf */build reclaims space when done.

Package structure

dev.achmad.ledgerr/
├── core/
│   ├── network/          - OkHttp helpers (not used by expense features)
│   └── preference/       - PreferenceStore, AndroidPreferenceStore, Preference<T>
├── di/
│   ├── util/
│   │   └── KoinExtensions.kt   - inject<T>() and injectLazy<T>() helpers
│   ├── CoreModule.kt
│   ├── DataModule.kt
│   ├── DomainModule.kt
│   └── PreferenceModule.kt     - replaces UiModule.kt (delete UiModule if it exists)
├── domain/
│   ├── preference/
│   │   ├── AppPreference.kt
│   │   └── ExpensePreference.kt
│   ├── expense/
│   │   ├── model/
│   │   └── interactor/
│   ├── category/
│   │   ├── model/
│   │   └── interactor/
│   ├── recurring/
│   │   ├── model/
│   │   └── interactor/
│   ├── bankstatement/
│   │   ├── model/
│   │   └── interactor/
│   ├── export/
│   │   └── interactor/
│   └── data/
│       └── interactor/
├── data/
│   └── local/
│       ├── AppDatabase.kt
│       ├── converter/
│       ├── dao/
│       ├── entity/
│       └── mapper/
└── ui/
    ├── base/
    │   ├── MainApplication.kt
    │   └── MainActivity.kt
    ├── components/         - shared Compose components (already copied)
    │   └── preference/     - PreferenceScreen + widgets
    ├── screens/
    │   ├── home/           HomeScreen.kt
    │   ├── expenses/       ExpenseListScreen.kt
    │   ├── add_edit_expense/ AddEditExpenseScreen.kt
    │   ├── add_edit_recurring/ AddEditRecurringScreen.kt
    │   ├── category/       CategoryScreen.kt
    │   ├── import_bank_statement/ ImportBankStatementScreen.kt
    │   └── settings/       SettingsScreen.kt
    ├── theme/
    └── util/

Architecture rules

Domain / interactors

  • Each folder in domain/ is a feature with model/ and interactor/ subpackages.
  • Interactor classes are named after the action: GetExpenses, UpsertExpense, DeleteCategory, etc.
  • Method names follow this convention:
    • await(...) — single suspend result
    • awaitAll(...) — list suspend result
    • awaitOne(id) — fetch one by ID
    • subscribeAll(...) — returns Flow
  • Interactors take DAOs (or Context for PDF/export) directly as constructor params — no repository layer.
  • Interactors can contain business logic (think use-cases).

Dependency injection (Koin)

  • DI modules live in di/, not core/.
  • CoreModule provides SharedPreferences and PreferenceStore (as AndroidPreferenceStore). Nothing else.
  • Interactors are registered as factory { }, not single.
  • Preference classes are registered as single { } in PreferenceModule.
  • DAOs are registered as single { get<AppDatabase>().xyzDao() } in DataModule.
  • Bank statement importers use named qualifiers: factory<BankStatementImporter>(named("bri")) { ... }.
  • ScreenModels are NOT registered in Koin. Never add a ScreenModel to any Koin module.

ScreenModel pattern

Always use rememberScreenModel { } in Screen.Content(). Inject dependencies via inject() as constructor default params:

class HomeScreenModel(
    private val getExpenses: GetExpenses = inject(),
    private val appPreference: AppPreference = inject(),
) : ScreenModel { ... }

// In Screen.Content():
val screenModel = rememberScreenModel { HomeScreenModel() }

inject<T>() is at dev.achmad.ledgerr.di.util.KoinExtensions.

Navigation

  • Pure Voyager stack — Navigator, navigator.push(...), navigator.pop().
  • No TabNavigator at root level.
  • ExpenseListScreen has 2 internal tabs using plain Compose TabRow + HorizontalPager (not Voyager tabs).
  • Screen flow:
    HomeScreen (root)
      ├── FAB expand → "Manual"               → AddEditExpenseScreen
      │              → "Import Bank Statement" → ImportBankStatementScreen
      ├── Export icon → date range dialog → SAF CreateDocument → writes CSV
      ├── "See all" → ExpenseListScreen
      │                 ├── Tab: Expenses (search + filter)
      │                 │     └── FAB expand (tab-aware) → "Manual"               → AddEditExpenseScreen
      │                 │                                    → "Import Bank Statement" → ImportBankStatementScreen
      │                 └── Tab: Recurring
      │                       └── FAB expand (tab-aware) → "Add Recurring" → AddEditRecurringScreen
      ├── "Manage Categories" button (dashboard body) → CategoryScreen
      └── Settings icon → SettingsScreen
    
    AddEditExpenseScreen → CategoryScreen (manage categories button)
    
    AddEditRecurringScreen (no secondary navigation)
    
    ImportBankStatementScreen (1 Voyager Screen, 1 ScreenModel, 2 composable functions)
      ├── ImportBankStatementPickerContent      — state is BankPicker or Processing
      │     select bank → SAF OpenDocument → loading overlay while parsing
      └── ImportBankStatementConfirmationContent — state is Confirmation
            toggle/edit per row → InsertExpenses → navigator.pop()
    
  • Tab-aware FAB. ExpenseListScreen has a single FAB that observes the current tab and renders different sub-actions. Expenses tab mirrors HomeScreen's FAB (Manual + Import). Recurring tab shows only "Add Recurring" → AddEditRecurringScreen. The expansion/collapse UI is shared; only the actions list is tab-driven.
  • Export has no dedicated screen. Icon in HomeScreen and ExpenseListScreen top bars; also a TextPreference in SettingsScreen. Tapping shows a date range dialog first, then opens SAF CreateDocument. CSV uses ISO 8601 dates (yyyy-MM-dd) with UTF-8 BOM. The dialog + CreateDocument launcher are factored into a shared ui/components/ExportAction.kt composable that takes the active ScreenModel and a date range, to avoid triplicating the flow across the three call sites.
  • Import goes through ImportBankStatementScreen — one Voyager Screen, one ScreenModel, two internal composables switched by StateFlow<ImportState>. ImportBankStatementPickerContent handles bank selection and processing; ImportBankStatementConfirmationContent handles review, edit, and confirm. On confirm calls InsertExpenses.awaitAll() then pops.

Settings screen

SettingsScreen uses the PreferenceScreen composable from ui/components/preference/. Pattern:

PreferenceScreen(
    itemsProvider = {
        listOf(
            Preference.PreferenceItem.ListPreference(
                title = "Theme",
                preference = appPreference.appTheme(),
                entries = mapOf(...),
            ),
            Preference.PreferenceItem.TextPreference(
                title = "Export CSV",
                onClick = { /* launch SAF CreateDocument */ }
            ),
            Preference.PreferenceItem.AlertDialogPreference(
                title = "Clear all data",
                onConfirm = { screenModel.clearData() }
            ),
        )
    }
)

Preference classes

Located in domain/preference/. Take PreferenceStore as constructor param. Methods return Preference<T> objects from core/preference/:

class AppPreference(private val store: PreferenceStore) {
    fun appTheme() = store.getEnum("app_theme", defaultValue = AppTheme.SYSTEM)
}

Data layer

  • LocalDate stored as Long epoch day via LocalDateConverter (LocalDate.toEpochDay() / LocalDate.ofEpochDay()).
  • Use @Upsert Room annotation for insert-or-update operations.
  • Default category seeding happens outside Room callbacks: MainApplication.onCreate calls inject<SeedDefaultCategories>().await() on a CoroutineScope(Dispatchers.IO) after startKoin completes. Koin is not available inside AppDatabase.Callback.onCreate.
  • ClearAllData interactor calls database.clearAllTables() inside withTransaction { }. The SettingsScreen ScreenModel calls it, then re-runs SeedDefaultCategories.await() so the app retains the 8 default categories.
  • minSdk 26, so java.time.* is available without desugaring.

Bank statement import

  • BankStatementImporter interface: val bankName: String + suspend fun await(pdfUri: Uri): Result<List<PendingImportExpense>>.
  • Stubs: ImportBRIBankStatement, ImportJagoBankStatement, ImportBNIBankStatement — all return Result.failure(NotImplementedError(...)).
  • PDF text extraction: com.tom-roush:pdfbox-android. Requires PDFBoxResourceLoader.init(applicationContext) in MainApplication.onCreate().
  • No manifest permissions needed — SAF handles file access.

Export

  • ExportExpensesToCsv interactor writes UTF-8 BOM CSV via Okio to a SAF URI.
  • SAF launchers (ActivityResultContracts.OpenDocument, ActivityResultContracts.CreateDocument) are registered in the Screen composable; URIs are passed to the ScreenModel.
  • Shared ui/components/ExportAction.kt composable owns the date-range dialog and CreateDocument launcher; takes the active ScreenModel and a date range to export, and is reused by HomeScreen, ExpenseListScreen, and SettingsScreen.

Charts

  • Vico (com.patrykandpatrick.vico:compose, compose-m3, core) is the only charting library used.
  • HomeScreen dashboard renders a pie chart from ExpenseSummary.byCategory via Vico's Chart composable. No Canvas drawing for charts.
  • Apache 2.0 license, Compose-native, no AndroidView wrapper.

Workflow for new features

Follow these steps in order every time. Do not skip ahead to implementation without explicit approval.

  1. Define structs — add any new models to the relevant domain/<feature>/model/ package.
  2. Define interfaces — add or update interactor class signatures and method stubs in domain/<feature>/interactor/. If a new feature has no existing feature folder, create one.
  3. Add TODOs — fill method bodies with TODO("...") comments describing the intended behavior, edge cases, and any invariants.
  4. Self-review — think through the full implementation. If anything requires going outside or beyond what was defined in steps 13 (new models, new methods, changed signatures), go back to the relevant step and update it before continuing.
  5. Prompt for review — stop and present a summary of what was defined. Wait for an explicit go-ahead before writing any real implementation code. See the No-implementation rule at the top of this file.
  6. Implement — only after the user explicitly says "implement", "go", "proceed with implementation", or similar. A general "go ahead" on the plan is NOT sufficient.

Key dependencies

Library Version Purpose
Jetpack Compose + Material3 (from BOM) UI
Voyager Navigation + ScreenModel
Koin 4.2.2 DI
Room 2.7.1 Local DB
PDFBox-Android 2.0.27.0 PDF text extraction
Vico (compose / compose-m3 / core) 2.x Charts (pie / bar)
Okio (transitive) CSV write

Git

  • Never commit or push unless the user explicitly asks. Do not auto-commit after completing a task, do not squash, amend, or rebase without being told to.
  • Never add Co-Authored-By lines to commit messages.

Things to avoid

  • Do not register ScreenModels in Koin.
  • Do not create a repository layer between interactors and DAOs.
  • Do not add a TabNavigator at the root.
  • Do not create a dedicated screen for export — it is a direct action (date range dialog → SAF picker). The flow is shared via ui/components/ExportAction.kt.
  • Do not add manifest permissions for file access — SAF handles it.
  • Do not draw charts with Canvas — use Vico.
  • Do not add a chart library other than Vico.
  • Do not rename MainApplication back to LedgerrApp.
  • UiModule.kt has been deleted — do not recreate it; the replacement is PreferenceModule.kt.
  • Do not use @KoinViewModel or viewModel {} — this project uses Voyager ScreenModel.

Docs

Full design documentation is in docs/:

  • 01-data-model.md — domain models per feature
  • 02-interfaces.md — all interactor signatures
  • 03-function-todos.md — per-method behavior and edge cases
  • 04-implementation-plan.md — package structure, DI wiring, build order