## Codebase Patterns
- Resource modules live in `src/kanboard/resources/` — one file per API category.
- CLI command groups live in `src/kanboard_cli/commands/` — one file per resource.
- Each resource class follows `__init__(self, client: KanboardClient)` pattern with `self._client`.
- All public SDK methods are fully type-hinted; `_int()`, `_float()`, `_parse_date()` helpers in `models.py`.
- `KanboardNotFoundError` raised when API returns `False` or `None` for a single-resource fetch.
- `KanboardAPIError` raised when a mutating call returns `False` or `0`.
- List methods return `[]` (never raise) when API returns `False`/`None`/`[]`.
- CLI uses `format_output(data, app.output, columns=_LIST_COLUMNS)` and `format_success(msg, app.output)`.
- Destructive CLI commands require `--yes`; use `click.confirm(..., abort=True)`.
- `download` commands use `[--output PATH]` (named `output_path` to avoid shadowing Click's `output`).
- Rich table may truncate long cell values — test assertions should check for a prefix, not the full value.
- Pre-existing `scripts/bump_version.py` had a ruff `F541` error — fixed in US-001 commit.
- For metadata-style APIs returning `dict` or `str` (no dataclass): format as `[{"key": k, "value": v}]` dicts for `format_output`.
- `get_*_by_name` methods returning strings: use `result is None or result is False or result == ""` guard — `not result` alone fails because `False == 0` in Python, and `str(False)` gives `"False"`.
- Ruff `RUF002` flags en-dash (`–`) in docstrings — always use plain hyphen-minus (`-`).
- Ruff `D301` requires `r"""` prefix for docstrings containing backslashes (e.g. `\\TaskClose`).
---

## PRD Run Context
PRD Branch: ralph/milestone-3-extended
Started: 2026-03-22T06:06:13.299Z
---

## 2026-03-22 - US-001
Session: N/A
Review Outcome: PASS
Trajectory: ON_TRACK
Trajectory Notes: N/A
Corrective Plan: N/A
- Implemented `src/kanboard/resources/project_files.py` with `ProjectFilesResource` (6 methods: create, get_all, get, download, remove, remove_all).
- Implemented `src/kanboard_cli/commands/project_file.py` with 6 subcommands (list, get, upload, download, remove, remove-all).
- Wired `ProjectFilesResource` into `KanboardClient.project_files`.
- Exported `ProjectFilesResource` from `kanboard` package `__init__.py`.
- Replaced stub `project_file` group in `main.py` with real import.
- Added 31 unit tests for SDK resource and 27 CLI tests (58 total); all pass.
- Fixed pre-existing ruff `F541` in `scripts/bump_version.py`.
- Files changed: `src/kanboard/resources/project_files.py`, `src/kanboard_cli/commands/project_file.py`, `src/kanboard/__init__.py`, `src/kanboard/client.py`, `src/kanboard_cli/main.py`, `scripts/bump_version.py`, `tests/unit/resources/test_project_files.py`, `tests/cli/test_project_file.py`.
- **Learnings for future iterations:**
  - Rich table truncates long cell values in narrow terminals — use prefix checks (e.g. `"rep"`) in table output assertions, not full string matches.
  - `--output` is a reserved param name for Click in the `download` command; use `output_path` as the Python variable name.
  - `ProjectFile` and `TaskFile` dataclasses already pre-defined in `models.py` — no model work needed for these stories.
  - The `__all__` list in `kanboard/__init__.py` must stay sorted (RUF022 enforced by ruff).
---

## 2026-03-22 - US-002
Session: N/A
Review Outcome: PASS
Trajectory: ON_TRACK
Trajectory Notes: N/A
Corrective Plan: N/A
- Implemented `src/kanboard/resources/task_files.py` with `TaskFilesResource` (6 methods: create, get_all, get, download, remove, remove_all).
- Implemented `src/kanboard_cli/commands/task_file.py` with 6 subcommands (list, get, upload, download, remove, remove-all).
- Wired `TaskFilesResource` into `KanboardClient.task_files`.
- Exported `TaskFilesResource` from `kanboard` package `__init__.py`.
- Replaced stub `task_file` group in `main.py` with real import.
- Added 31 unit tests for SDK resource and 27 CLI tests (58 total); all pass.
- Files changed: `src/kanboard/resources/task_files.py`, `src/kanboard_cli/commands/task_file.py`, `src/kanboard/__init__.py`, `src/kanboard/client.py`, `src/kanboard_cli/main.py`, `tests/unit/resources/test_task_files.py`, `tests/cli/test_task_file.py`.
- **Learnings for future iterations:**
  - Task file API signatures differ from project files: `get_task_file(file_id)` and `download_task_file(file_id)` take only `file_id` (no project_id), while `create_task_file` takes both `project_id` and `task_id`.
  - Import ordering in `client.py` must be alphabetical by module path — `task_files` goes after `tags` and before `task_links`.
  - The `ruff format` pre-commit hook may reformat newly created files — watch for that in the commit output.
---

## 2026-03-22 - US-003
Session: N/A
Review Outcome: PASS
Trajectory: ON_TRACK
Trajectory Notes: N/A
Corrective Plan: N/A
- Implemented `src/kanboard/resources/project_metadata.py` with `ProjectMetadataResource` (4 methods: get_project_metadata, get_project_metadata_by_name, save_project_metadata, remove_project_metadata).
- Implemented `src/kanboard_cli/commands/project_meta.py` with 4 subcommands (list, get, set, remove).
- Wired `ProjectMetadataResource` into `KanboardClient.project_metadata`.
- Exported `ProjectMetadataResource` from `kanboard` package `__init__.py`.
- Replaced stub `project_meta` group in `main.py` with real import.
- Added 24 unit tests for SDK resource and 20 CLI tests (44 total); all pass.
- Files changed: `src/kanboard/resources/project_metadata.py`, `src/kanboard_cli/commands/project_meta.py`, `src/kanboard/__init__.py`, `src/kanboard/client.py`, `src/kanboard_cli/main.py`, `tests/unit/resources/test_project_metadata.py`, `tests/cli/test_project_meta.py`.
- **Learnings for future iterations:**
  - Metadata APIs return plain dicts/strings, not dataclass models — format list output as `[{"key": k, "value": v}]` dicts.
  - `get_*_by_name` methods: `not result` fails to catch `False` because `False == 0` in Python — use explicit `result is None or result is False` guard.
  - Ruff `RUF002` flags en-dash characters in docstrings — always use plain hyphen-minus.
  - Ruff line-length `E501` applies to docstring lines too — keep class cross-references short when embedded in docstrings.
  - `getTaskMetadata` returns `[]` (empty list) instead of `{}` on empty — check `isinstance(result, list)` explicitly.
---

## 2026-03-22 - US-004
Session: N/A
Review Outcome: PASS
Trajectory: ON_TRACK
Trajectory Notes: N/A
Corrective Plan: N/A
- Implemented `src/kanboard/resources/task_metadata.py` with `TaskMetadataResource` (4 methods: get_task_metadata, get_task_metadata_by_name, save_task_metadata, remove_task_metadata).
- Implemented `src/kanboard_cli/commands/task_meta.py` with 4 subcommands (list, get, set, remove).
- Wired `TaskMetadataResource` into `KanboardClient.task_metadata`.
- Exported `TaskMetadataResource` from `kanboard` package `__init__.py`.
- Replaced stub `task_meta` group in `main.py` with real import.
- Added 26 unit tests for SDK resource and 20 CLI tests (46 total); all pass.
- Handles Kanboard quirk where `getTaskMetadata` returns `[]` instead of `{}` on empty.
- Files changed: `src/kanboard/resources/task_metadata.py`, `src/kanboard_cli/commands/task_meta.py`, `src/kanboard/__init__.py`, `src/kanboard/client.py`, `src/kanboard_cli/main.py`, `tests/unit/resources/test_task_metadata.py`, `tests/cli/test_task_meta.py`.
- **Learnings for future iterations:**
  - Task metadata follows identical pattern to project metadata — only differs in parameter names (`task_id` vs `project_id`) and Kanboard API quirk (returns `[]` on empty).
  - Added `isinstance(result, list)` guard in `get_task_metadata()` to handle the `[]` case — this is unique to this endpoint.
---

## 2026-03-22 - US-005
Session: N/A
Review Outcome: PASS
Trajectory: ON_TRACK
Trajectory Notes: N/A
Corrective Plan: N/A
- Implemented `src/kanboard/resources/project_permissions.py` with `ProjectPermissionsResource` (9 methods: get_project_users, get_assignable_users, get_project_user_role, add_project_user, remove_project_user, change_project_user_role, add_project_group, remove_project_group, change_project_group_role).
- Wired `ProjectPermissionsResource` into `KanboardClient.project_permissions`.
- Exported `ProjectPermissionsResource` from `kanboard` package `__init__.py`.
- Added 47 unit tests covering happy paths, error paths (KanboardAPIError), empty/falsy returns, kwargs forwarding, network failures (KanboardConnectionError), client wiring, and package importability; all pass.
- Files changed: `src/kanboard/resources/project_permissions.py`, `src/kanboard/__init__.py`, `src/kanboard/client.py`, `tests/unit/resources/test_project_permissions.py`.
- **Learnings for future iterations:**
  - Permission APIs return dicts mapping user/group IDs (as strings) to usernames - no dataclass models needed (same pattern as metadata).
  - `get_project_user_role` returns a role string - uses same `result is None or result is False or result == ""` guard as `get_*_metadata_by_name`.
  - The `add_project_user` and `add_project_group` methods accept `**kwargs` for optional `role` parameter - follow same pattern for any API method with optional params.
  - This is the largest single resource module (9 methods) - splitting SDK from CLI was a good decision for maintainability.
---

## 2026-03-22 - US-006
Session: N/A
Review Outcome: PASS
Trajectory: ON_TRACK
Trajectory Notes: N/A
Corrective Plan: N/A
- Implemented `src/kanboard_cli/commands/project_access.py` with 9 subcommands: list, assignable, add-user, add-group, remove-user, remove-group, set-user-role, set-group-role, user-role.
- Replaced stub `project_access` group in `main.py` with real import from commands module.
- Added 45 CLI tests covering all 9 subcommands with table/json/csv/quiet formats, error paths, --yes confirmation flows, empty results, and help output.
- Files changed: `src/kanboard_cli/commands/project_access.py`, `src/kanboard_cli/main.py`, `tests/cli/test_project_access.py`.
- **Learnings for future iterations:**
  - Permission APIs returning dicts (user_id -> username) use the same formatting pattern as metadata: convert to `[{"user_id": k, "username": v}]` list of dicts for `format_output`.
  - For `add-user`/`add-group` with optional `--role`, build kwargs dict conditionally rather than always passing `role=None` - the API may not accept explicit `None`.
  - The `user-role` query command returns a single string role - format as `{"user_id": str(user_id), "role": role}` for consistent output across formats.
---

## 2026-03-22 - US-007
Session: N/A
Review Outcome: PASS
Trajectory: ON_TRACK
Trajectory Notes: N/A
Corrective Plan: N/A
- Implemented `src/kanboard/resources/groups.py` with `GroupsResource` (5 methods: create_group, get_group, get_all_groups, update_group, remove_group).
- Implemented `src/kanboard_cli/commands/group.py` with 5 subcommands (list, get, create, update, remove).
- Wired `GroupsResource` into `KanboardClient.groups`.
- Exported `GroupsResource` from `kanboard` package `__init__.py`.
- Replaced stub `group` group in `main.py` with real import from commands module.
- Added 32 unit tests for SDK resource and 24 CLI tests (56 total); all pass.
- Files changed: `src/kanboard/resources/groups.py`, `src/kanboard_cli/commands/group.py`, `src/kanboard/__init__.py`, `src/kanboard/client.py`, `src/kanboard_cli/main.py`, `tests/unit/resources/test_groups.py`, `tests/cli/test_group.py`.
- **Learnings for future iterations:**
  - Groups resource is a clean, simple model with only 3 fields (id, name, external_id) - straightforward dataclass mapping.
  - `update_group` raises KanboardAPIError on False (mutation), while `remove_group` just returns bool (consistent with project_files pattern for non-critical removals).
  - Single-item JSON output from `format_output` returns a plain dict, not a list wrapping one element - test assertions should use `data["key"]` not `data[0]["key"]`.
  - US-008 (Group members) depends on US-007 and will add sub-commands under `kanboard group member ...` - need to plan for nested command groups.
---

## 2026-03-22 - US-008
Session: N/A
Review Outcome: PASS
Trajectory: ON_TRACK
Trajectory Notes: N/A
Corrective Plan: N/A
- Implemented `src/kanboard/resources/group_members.py` with `GroupMembersResource` (5 methods: get_member_groups, get_group_members, add_group_member, remove_group_member, is_group_member).
- Added `member` sub-group to `src/kanboard_cli/commands/group.py` with 5 subcommands (list, groups, add, remove, check).
- Wired `GroupMembersResource` into `KanboardClient.group_members`.
- Exported `GroupMembersResource` from `kanboard` package `__init__.py`.
- Added 28 unit tests for SDK resource and 25 CLI tests (53 total); all pass.
- Files changed: `src/kanboard/resources/group_members.py`, `src/kanboard_cli/commands/group.py`, `src/kanboard/__init__.py`, `src/kanboard/client.py`, `tests/unit/resources/test_group_members.py`, `tests/cli/test_group_member.py`.
- **Learnings for future iterations:**
  - Group members is a sub-group of the group CLI command - use `@group.group("member")` to nest a Click group under an existing group.
  - `get_group_members` returns `list[User]` while `get_member_groups` returns `list[Group]` - need both model types imported.
  - `remove_group_member` follows the non-raising `bool` return pattern (like `remove_group`), while `add_group_member` raises `KanboardAPIError` on False (consistent with mutation pattern).
  - The `is_group_member` check returns a simple bool - format as a dict `{"group_id": ..., "user_id": ..., "is_member": ...}` for consistent output across all formats.
---

## 2026-03-22 - US-009
Session: N/A
Review Outcome: PASS
Trajectory: ON_TRACK
Trajectory Notes: N/A
Corrective Plan: N/A
- Implemented `src/kanboard/resources/external_task_links.py` with `ExternalTaskLinksResource` (7 methods: get_external_task_link_types, get_external_task_link_provider_dependencies, create_external_task_link, update_external_task_link, get_external_task_link_by_id, get_all_external_task_links, remove_external_task_link).
- Implemented `src/kanboard_cli/commands/external_link.py` with 7 subcommands (types, dependencies, list, get, create, update, remove).
- Wired `ExternalTaskLinksResource` into `KanboardClient.external_task_links`.
- Exported `ExternalTaskLinksResource` from `kanboard` package `__init__.py`.
- Replaced stub `external_link` group in `main.py` with real import from commands module.
- Added 34 unit tests for SDK resource and 33 CLI tests (67 total); all pass.
- Files changed: `src/kanboard/resources/external_task_links.py`, `src/kanboard_cli/commands/external_link.py`, `src/kanboard/__init__.py`, `src/kanboard/client.py`, `src/kanboard_cli/main.py`, `tests/unit/resources/test_external_task_links.py`, `tests/cli/test_external_link.py`.
- **Learnings for future iterations:**
  - External task link APIs use `providerName` (camelCase) as the param name for `getExternalTaskLinkProviderDependencies` - not `provider_name` (the SDK translates this via kwargs).
  - `get_external_task_link_types` and `get_external_task_link_provider_dependencies` return plain dicts (provider -> label, dependency -> label) - format as list of `{"key": k, "label": v}` dicts for table output.
  - `KanboardNotFoundError.__str__` auto-formats as `"Not found: {resource} '{identifier}' does not exist"` - test assertions must match this format, not the message passed to the constructor.
  - Network failure tests must use `httpx_mock.add_exception(httpx_lib.ConnectError("refused"))` pattern, NOT an unreachable URL - pytest-httpx asserts all requests are expected by default.
---

## 2026-03-22 - US-010
Session: N/A
Review Outcome: PASS
Trajectory: ON_TRACK
Trajectory Notes: N/A
Corrective Plan: N/A
- Implemented `src/kanboard/resources/actions.py` with `ActionsResource` (6 methods: get_available_actions, get_available_action_events, get_compatible_action_events, get_actions, create_action, remove_action).
- Implemented `src/kanboard_cli/commands/action.py` with 6 subcommands (list, available, events, compatible-events, create, remove).
- Wired `ActionsResource` into `KanboardClient.actions`.
- Exported `ActionsResource` from `kanboard` package `__init__.py`.
- Replaced stub `action` group in `main.py` with real import from commands module.
- Added 30 unit tests for SDK resource and 28 CLI tests (58 total); all pass.
- Files changed: `src/kanboard/resources/actions.py`, `src/kanboard_cli/commands/action.py`, `src/kanboard/__init__.py`, `src/kanboard/client.py`, `src/kanboard_cli/main.py`, `tests/unit/resources/test_actions.py`, `tests/cli/test_action.py`.
- **Learnings for future iterations:**
  - Actions resource has a mix of return types: `get_available_actions` and `get_available_action_events` return dicts, `get_compatible_action_events` returns a list, `get_actions` returns list[Action], `create_action` returns int, `remove_action` returns bool.
  - `get_compatible_action_events` may return either a list or a dict from the API - use `list()` to handle both (dict yields keys).
  - Ruff `D301` requires `r"""` prefix for docstrings containing backslashes (e.g. `\\TaskClose` in examples).
  - The `--param`/`-p` repeatable option with `multiple=True` is a good pattern for key=value pairs - parse with `split("=", 1)`.
  - Ruff `N817` flags CamelCase imported as acronym - use full name aliases like `Imported` instead of abbreviations like `AR`.
---

## 2026-03-22 - US-011
Session: N/A
Review Outcome: PASS
Trajectory: ON_TRACK
Trajectory Notes: N/A
Corrective Plan: N/A
- Implemented `src/kanboard/resources/subtask_time_tracking.py` with `SubtaskTimeTrackingResource` (4 methods: has_subtask_timer, set_subtask_start_time, set_subtask_end_time, get_subtask_time_spent).
- Implemented `src/kanboard_cli/commands/timer.py` with 4 subcommands (status, start, stop, spent).
- Wired `SubtaskTimeTrackingResource` into `KanboardClient.subtask_time_tracking`.
- Exported `SubtaskTimeTrackingResource` from `kanboard` package `__init__.py`.
- Replaced stub `timer` group in `main.py` with real import from commands module.
- Added 26 unit tests for SDK resource and 23 CLI tests (49 total); all pass.
- Files changed: `src/kanboard/resources/subtask_time_tracking.py`, `src/kanboard_cli/commands/timer.py`, `src/kanboard/__init__.py`, `src/kanboard/client.py`, `src/kanboard_cli/main.py`, `tests/unit/resources/test_subtask_time_tracking.py`, `tests/cli/test_timer.py`.
- **Learnings for future iterations:**
  - Time tracking resource is simple (4 methods, no dataclass models) - returns bool and float primitives.
  - `set_subtask_start_time` and `set_subtask_end_time` raise KanboardAPIError on False (mutation pattern), while `has_subtask_timer` and `get_subtask_time_spent` are query methods that return falsy defaults.
  - `get_subtask_time_spent` returns 0.0 (not 0) to maintain consistent float typing - use `float(result)` conversion.
  - CLI commands outputting simple status/query data format as `[{"key": ..., "value": ...}]` list-of-dicts for `format_output` consistency across all output formats.
  - Import ordering in `main.py` is strictly alphabetical by module path - `timer` goes after `task_meta` and before `user`.
---

## 2026-03-22 - US-012
Session: N/A
Review Outcome: PASS
Trajectory: ON_TRACK
Trajectory Notes: N/A
Corrective Plan: N/A
- Implemented `src/kanboard/resources/me.py` with `MeResource` (7 methods: get_me, get_my_dashboard, get_my_activity_stream, create_my_private_project, get_my_projects_list, get_my_overdue_tasks, get_my_projects).
- All 7 methods raise `KanboardAuthError` with a clear message explaining User API auth is required (not yet implemented; planned for Milestone 4).
- Implemented `src/kanboard_cli/commands/me.py` with 6 subcommands (default show, dashboard, activity, projects, overdue, create-project). Uses `invoke_without_command=True` so bare `kanboard me` shows current user info.
- Wired `MeResource` into `KanboardClient.me`.
- Exported `MeResource` from `kanboard` package `__init__.py`.
- Replaced stub `me` group in `main.py` with real import from commands module.
- Added 12 unit tests for SDK resource and 12 CLI tests (24 total); all pass.
- Files changed: `src/kanboard/resources/me.py`, `src/kanboard_cli/commands/me.py`, `src/kanboard/__init__.py`, `src/kanboard/client.py`, `src/kanboard_cli/main.py`, `tests/unit/resources/test_me.py`, `tests/cli/test_me.py`.
- **Learnings for future iterations:**
  - `invoke_without_command=True` on a Click group allows the group itself to act as a command when no subcommand is given - useful for "me" which should show the current user by default.
  - Resource methods that unconditionally raise exceptions don't need pytest-httpx mocking - no HTTP calls are made.
  - The `KanboardAuthError` class supports an optional `http_status` parameter, but for pre-emptive auth errors (before any HTTP call), omit it - the `__str__` format changes accordingly.
  - When all SDK methods raise errors, CLI tests can use `MagicMock` with `side_effect=KanboardAuthError(...)` on each method rather than patching the actual resource class.
---

## 2026-03-22 - US-013
Session: N/A
Review Outcome: PASS
Trajectory: ON_TRACK
Trajectory Notes: N/A
Corrective Plan: N/A
- Implemented `src/kanboard/resources/application.py` with `ApplicationResource` (7 methods: get_version, get_timezone, get_default_task_colors, get_default_task_color, get_color_list, get_application_roles, get_project_roles).
- Implemented `src/kanboard_cli/commands/app_info.py` with 5 subcommands (version, timezone, colors, default-color, roles).
- Wired `ApplicationResource` into `KanboardClient.application`.
- Exported `ApplicationResource` from `kanboard` package `__init__.py`.
- Replaced stub `app` group in `main.py` with real import from commands module.
- Added 32 unit tests for SDK resource and 28 CLI tests (60 total); all pass.
- Files changed: `src/kanboard/resources/application.py`, `src/kanboard_cli/commands/app_info.py`, `src/kanboard/__init__.py`, `src/kanboard/client.py`, `src/kanboard_cli/main.py`, `tests/unit/resources/test_application.py`, `tests/cli/test_app_info.py`.
- **Learnings for future iterations:**
  - Application info resource is entirely read-only (no mutations) - all methods return str or dict with falsy-default guards, no KanboardAPIError raises needed in the SDK layer.
  - The `roles` CLI command combines two API calls (getApplicationRoles + getProjectRoles) into one table with a `scope` column to differentiate application vs project roles.
  - The `colors` command formats dict values using `str(v)` since colour definitions can be nested dicts - keeps the definition column as a string representation.
  - Stub groups in main.py must be removed when importing the real command group, otherwise Click raises a duplicate command name error.
---

## 2026-03-22 - US-014
Session: N/A
Review Outcome: PASS
Trajectory: ON_TRACK
Trajectory Notes: N/A
Corrective Plan: N/A
- Implemented `src/kanboard_cli/workflows/base.py` with `BaseWorkflow` ABC (abstract properties `name` and `description`, abstract method `register_commands`, concrete `get_config` method).
- Implemented `src/kanboard_cli/workflow_loader.py` with `discover_workflows()` function scanning `~/.config/kanboard/workflows/` for `.py` files and packages using `importlib.util`.
- Updated `src/kanboard_cli/workflows/__init__.py` to export `BaseWorkflow`.
- Replaced stub `workflow` group in `main.py` with real workflow list command and plugin registration loop.
- `kanboard workflow list` shows name and description of all discovered workflows.
- Graceful handling: missing directory returns empty list, broken files log warning and skip.
- Added 14 unit tests for workflow loader, 5 unit tests for BaseWorkflow ABC, and 7 CLI tests for workflow list (26 total); all pass.
- Files changed: `src/kanboard_cli/workflows/base.py`, `src/kanboard_cli/workflow_loader.py`, `src/kanboard_cli/workflows/__init__.py`, `src/kanboard_cli/main.py`, `tests/unit/test_workflow_loader.py`, `tests/unit/test_base_workflow.py`, `tests/cli/test_workflow.py`.
- **Learnings for future iterations:**
  - `importlib.util.spec_from_file_location` can return `None` for invalid paths - always check before calling `exec_module`.
  - `inspect.isabstract(obj)` reliably filters out ABC subclasses that haven't implemented all abstract methods - no need for manual checking.
  - Workflow loader must register modules in `sys.modules` before `exec_module` to avoid circular import issues.
  - Sorting discovered workflows by `wf.name` ensures deterministic ordering across platforms.
  - The `workflow list` command uses deferred `discover_workflows()` import in the command handler (not at module load time) to avoid issues with test patching.
---

## 2026-03-22 - US-015
Session: N/A
Review Outcome: PASS
Trajectory: ON_TRACK
Trajectory Notes: N/A
Corrective Plan: N/A
- Filled test coverage gaps across all Milestone 3 CLI and SDK test suites.
- Added 8 output format tests for `me` CLI commands (`--output json/csv/quiet`) verifying auth error output consistency across all formats.
- Added 7 additional timer CLI tests: "not running" status across json/csv/quiet formats, zero-spent across json/csv/quiet formats.
- Added 3 empty-result multi-format tests for `project-file list` (json/csv/quiet empty array rendering).
- Added 3 empty-result multi-format tests for `group list` (json/csv/quiet empty array rendering).
- Added 3 empty-result multi-format tests for `action list` (json/csv/quiet empty array rendering).
- Added 2 empty-result multi-format tests for `external-link list` (json/quiet empty rendering).
- Final test count: 1753 tests, all passing.
- Coverage: 100% on src/kanboard/resources/ (852 statements, 0 missed).
- All acceptance criteria verified:
  - ≥90% coverage (100% achieved)
  - Error path tests for every M3 resource (NotFoundError, APIError, ConnectionError, AuthError where applicable)
  - All 4 output formats tested for at least one command per new CLI group
  - --yes confirmation tested for all 11 M3 destructive commands
  - Empty result rendering tested for all list commands
  - Network failure tests for all HTTP-calling M3 SDK resources
  - All test files follow existing conventions
  - ruff check passes, pytest passes
- Files changed: `tests/cli/test_me.py`, `tests/cli/test_timer.py`, `tests/cli/test_project_file.py`, `tests/cli/test_group.py`, `tests/cli/test_action.py`, `tests/cli/test_external_link.py`, `.ralphi/prd.json`.
- **Learnings for future iterations:**
  - The `me` resource is a special case — all methods raise KanboardAuthError before making HTTP calls, so ConnectionError tests are N/A and output format tests verify error (not data) display.
  - Timer commands (status, start, stop, spent) are non-destructive so no --yes tests needed.
  - Timer and me have no list commands, so empty result tests don't apply.
  - When ClickException is raised, output format (--output json/csv/quiet) does not change the error display — Click handles error output independently.
  - This milestone (15 stories) is now fully complete with 100% resource test coverage.
---
