<!-- STATUS: COMPLETE -->
# Phase 13: Campaign Orchestration and CLI Commands

## Goal

Wire together the existing analysis pipeline, LLM layer, and database into a complete campaign lifecycle — from `investigate` through mission completion — and implement all CLI commands.

## Research Notes

### Architecture: Campaign as Orchestration Layer

The `campaign/` package is the **glue layer** that connects three independent subsystems:
- **Analysis** (`analysis/`) — `analyze_repository()` → `AnalysisResult`
- **LLM** (`llm/`) — `generate_briefing()` → `BriefingResult`, `run_mission_pipeline()` → `PipelineResult`
- **Database** (`db/`) — `open_campaign_db()` → `CampaignDB` with all repositories

Currently `campaign/__init__.py` is an empty docstring placeholder. The campaign package needs to:
1. **Orchestrate creation** — run analysis → briefing → missions → persist to DB → set status
2. **Map LLM output to DB** — transform `PipelineResult` schemas into domain models and persist via repositories
3. **Manage mission lifecycle** — compute available missions, handle completion, unlock dependents
4. **Provide campaign discovery** — scan filesystem for existing campaigns across the `~/.unfamiliar/campaigns/` tree
5. **Compute progress** — category-level and overall stats from DB state

### Mapping LLM Output → DB Models

The most complex part is persisting `PipelineResult`:

- **Modules:** `BriefingResult.modules` (list of `ModuleDescription`) → `Module` models. These are created during briefing persistence and their IDs are needed for mission-module links.
- **Missions:** Each `MissionItemResponse` in `CategoryMissionsResponse.missions` → `Mission` model + `MissionFileRef` entries from `file_refs`.
- **Prerequisites:** `MissionItemResponse.prerequisite_titles` → resolve `title_plain` to persisted mission IDs → `MissionPrerequisite` junction entries. Must be resolved after all missions in a category are persisted.
- **Module links:** `MissionItemResponse.module_index` → index into the modules list → `ModuleMissionLink` with `is_primary=True`.
- **Chains:** `ChainResponse` → `MissionChain` model. `ChainResponse.mission_titles` → resolve to mission IDs → `ChainMissionLink` entries with position.

### Mission Status Engine

Per requirements:
- **On creation:** Root missions (no prerequisites) → `AVAILABLE`, all others → `LOCKED`
- **On complete:** Mark `COMPLETED` with `SELF_REPORT`, then check all dependents — if ALL their prerequisites are now `COMPLETED`, transition to `AVAILABLE`
- **On unlock (escape hatch):** Force any mission to `AVAILABLE` regardless of prerequisite state
- A completed mission counts across all chains (already handled by DB design — single mission ID)
- No skip status — user marks complete via self-report

### Campaign Discovery

Campaigns live in separate SQLite databases under `~/.unfamiliar/campaigns/<name>-<hash>/`. To list campaigns:
1. Scan `~/.unfamiliar/campaigns/*/` directories
2. For each: read `.repo_path` marker file
3. Open `campaign.db`, read campaign record (status, timestamps)
4. Check if `repo_path` still exists on filesystem (orphan detection)

### Progress Computation

Per requirements (US-4, Case Board):
- Progress per category: completed / total missions per `InvestigationCategory`
- Overall: total completed / total missions
- Available next missions count
- Locked mission count
- Security-tagged mission completion

### CLI → TUI Handoff

Phase 13 implements the **CLI commands and orchestration logic only**. The TUI launch is Phase 14-15. For now:
- `investigate` creates the campaign, prints progress, and exits with a message that TUI is coming
- `campaigns` lists campaigns to stdout
- `resume` prints campaign status and exits
- This ensures the full pipeline is testable end-to-end without TUI

### Deferred Items Addressed

- **CLEANUP-002 (`get_missions_for_module`)**: This method WILL be used by the TUI (Phase 14-15) to show missions grouped by module. Add a `# Used by: TUI Phase 14-15` comment. Do not remove.
- **Static mission data for UI iteration**: Not relevant to Phase 13. Will surface in Phase 14 planning.

## Interface Contracts

### discover_campaigns

- **Signature:** `discover_campaigns(base_dir: Path | None = None) -> list[CampaignInfo]`
- **Purpose:** Scan the campaigns directory and return metadata for all found campaigns
- **Invariants:** Never raises — returns empty list on missing directory or scan errors. Each `CampaignInfo` has `campaign_dir`, `repo_path`, `status`, `is_orphaned`, `created_at`, `updated_at`, `mission_count`, `completed_count`.
- **Valid inputs:** `base_dir` is optional; defaults to `~/.unfamiliar`
- **Error conditions:** None — errors during individual campaign reads are logged and skipped
- **Edge cases to test:**
  - No campaigns directory exists → returns `[]`
  - Empty campaigns directory → returns `[]`
  - Campaign with missing `.repo_path` marker → `repo_path=None`, `is_orphaned=True`
  - Campaign with invalid/corrupt DB → skipped (logged), not in results
  - Campaign with repo path that no longer exists → `is_orphaned=True`
  - Multiple campaigns → sorted by `updated_at` descending (most recent first)

### persist_briefing_data

- **Signature:** `persist_briefing_data(db: CampaignDB, campaign_id: int, briefing_result: BriefingResult) -> list[Module]`
- **Purpose:** Persist the briefing and module descriptions to the database, returning persisted modules (with IDs) for mission linking
- **Invariants:** Returns exactly `len(briefing_result.modules)` Module objects, each with a non-None `id`. Creates exactly one Briefing row.
- **Valid inputs:** `campaign_id` must reference an existing campaign. `briefing_result` must have a valid `briefing` and `modules` list.
- **Error conditions:** `DatabaseError` if campaign doesn't exist or briefing already exists for this campaign
- **Edge cases to test:**
  - Zero modules in briefing → returns `[]`, briefing still created
  - Module with empty `category_relevance` → persisted with `{}`
  - Briefing with empty `tech_stack` → persisted with `[]`

### persist_pipeline_data

- **Signature:** `persist_pipeline_data(db: CampaignDB, campaign_id: int, pipeline_result: PipelineResult, modules: list[Module], repo_path: Path) -> PersistResult`
- **Purpose:** Transform and persist all mission pipeline output (missions, file refs, prerequisites, module links, chains) to the database
- **Invariants:** All persisted missions have non-None IDs. Root missions (no prerequisites) have status `AVAILABLE`. All other missions have status `LOCKED`. `PersistResult.mission_count` equals total missions persisted. Prerequisites reference only valid mission IDs.
- **Valid inputs:** `modules` must be persisted Module objects (with IDs). `pipeline_result` may have partial results (some categories failed). `repo_path` is used for file hash computation during file ref creation.
- **Error conditions:** `DatabaseError` on DB write failures. Logs warnings for unresolvable prerequisite titles or invalid module indices (does not fail).
- **Edge cases to test:**
  - Empty `pipeline_result` (no completed categories) → returns `PersistResult(mission_count=0, ...)`
  - Mission with `module_index` out of range → defaults to first module (index 0), logged warning
  - Mission with `prerequisite_titles` referencing non-existent title → prerequisite skipped, logged warning
  - Chain with `mission_titles` referencing non-existent title → mission skipped from chain, logged warning
  - Missions across categories with duplicate `title_plain` → each gets unique DB ID, prerequisite resolution uses intra-category matching first

### complete_mission

- **Signature:** `complete_mission(db: CampaignDB, mission_id: int) -> CompletionResult`
- **Purpose:** Mark a mission as completed via self-report and unlock any dependent missions whose prerequisites are now all met
- **Invariants:** The target mission transitions to `COMPLETED` with `SELF_REPORT`. Only dependents with ALL prerequisites completed transition to `AVAILABLE`. Returns list of newly unlocked mission IDs.
- **Valid inputs:** `mission_id` must reference an existing mission with status `AVAILABLE` or `IN_PROGRESS`
- **Error conditions:** `ValueError` if mission not found. `ValueError` if mission status is `LOCKED` or already `COMPLETED`.
- **Edge cases to test:**
  - Mission with no dependents → completes, `unlocked_ids=[]`
  - Mission with dependent that has 2 prerequisites, only 1 now met → dependent stays `LOCKED`
  - Mission with dependent that has 2 prerequisites, both now met → dependent becomes `AVAILABLE`
  - Mission already `COMPLETED` → raises `ValueError`
  - Mission in `LOCKED` status → raises `ValueError`
  - Mission in `IN_PROGRESS` status → completes normally

### unlock_mission

- **Signature:** `unlock_mission(db: CampaignDB, mission_id: int) -> None`
- **Purpose:** Force a locked mission to available status (escape hatch per requirements)
- **Invariants:** Mission transitions to `AVAILABLE` regardless of prerequisite state
- **Valid inputs:** `mission_id` must reference an existing mission
- **Error conditions:** `ValueError` if mission not found. `ValueError` if mission is already `COMPLETED`.
- **Edge cases to test:**
  - `LOCKED` mission → becomes `AVAILABLE`
  - `AVAILABLE` mission → no-op (stays `AVAILABLE`)
  - `IN_PROGRESS` mission → no-op (stays `IN_PROGRESS`)
  - `COMPLETED` mission → raises `ValueError`
  - Non-existent mission ID → raises `ValueError`

### get_campaign_progress

- **Signature:** `get_campaign_progress(db: CampaignDB, campaign_id: int) -> CampaignProgress`
- **Purpose:** Compute progress stats for a campaign across all categories
- **Invariants:** `CampaignProgress.total_missions` equals sum of all per-category totals. Each category appears in `category_progress` even if it has 0 missions. Percentages are 0-100 integers.
- **Valid inputs:** `campaign_id` must reference an existing campaign
- **Error conditions:** `ValueError` if campaign not found
- **Edge cases to test:**
  - Campaign with no missions → all counts 0, all percentages 0
  - Campaign with all missions completed → `overall_percent == 100`
  - Campaign with missions in only 3 of 7 categories → 4 categories have `total=0, completed=0`
  - Security-tagged missions counted separately in `security_completed` / `security_total`

### create_campaign

- **Signature:** `create_campaign(repo_path: Path, config: UnfamiliarConfig, progress_callback: Callable[[str], None] | None = None) -> Campaign`
- **Purpose:** Orchestrate full campaign creation: analyze → briefing → missions → persist → set status to active
- **Invariants:** On success, campaign status is `ACTIVE` with all missions persisted. On partial failure (some categories failed), campaign status is `ACTIVE` with partial missions. On total failure (auth error), raises and campaign stays `INITIALIZING`.
- **Valid inputs:** `repo_path` must be an existing directory with `.git/`. `config` must have a resolvable API key.
- **Error conditions:** `AuthenticationError` on invalid API key (propagated, not caught). `AnalysisError` on analysis failure. `ConfigError` on missing config. `progress_callback` receives status strings like "Analyzing repository...", "Generating briefing...", "Generating missions (3/7)..."
- **Edge cases to test:**
  - Existing campaign for repo → loads existing campaign, resumes if initializing
  - Pipeline completes partially (4 of 7 categories) → campaign still set to ACTIVE with partial missions
  - Auth error during briefing → raises, campaign stays INITIALIZING
  - Empty repository (no files) → analysis produces empty result, briefing still generated

### resume_campaign

- **Signature:** `resume_campaign(repo_path: Path | None = None, base_dir: Path | None = None) -> Campaign | None`
- **Purpose:** Find and return the most recently active campaign, or a specific campaign by repo path
- **Invariants:** Returns None if no matching campaign found. If campaign is INITIALIZING, returns it (caller decides whether to continue initialization).
- **Valid inputs:** If `repo_path` provided, looks up that specific campaign. If None, finds the most recently updated ACTIVE or INITIALIZING campaign.
- **Error conditions:** None — returns None on not found
- **Edge cases to test:**
  - No campaigns exist → returns `None`
  - Only closed campaigns → returns `None`
  - Multiple active campaigns → returns most recently updated
  - Specific repo_path provided but not found → returns `None`

## Tasks

### Task 1: Campaign data types (`campaign/types.py`) `DONE`

**Files:** `src/unfamiliar/campaign/types.py` (new)
**Action:** Create dataclasses for campaign orchestration results:
- `CampaignInfo` — discovery result (campaign_dir, repo_path, status, is_orphaned, created_at, updated_at, mission_count, completed_count)
- `PersistResult` — persistence outcome (mission_count, chain_count, warning_messages)
- `CompletionResult` — mission completion outcome (mission_id, unlocked_ids)
- `CategoryProgress` — per-category stats (category, total, completed, available, locked, percent)
- `CampaignProgress` — overall progress (category_progress list, total_missions, completed_missions, available_missions, locked_missions, overall_percent, security_total, security_completed)

**Verify:** `python -c "from unfamiliar.campaign.types import CampaignInfo, PersistResult, CompletionResult, CampaignProgress"`
**Done when:** All dataclasses importable with correct fields

### Task 2: Campaign discovery (`campaign/discovery.py`) `DONE`

**Files:** `src/unfamiliar/campaign/discovery.py` (new)
**Action:** Implement `discover_campaigns(base_dir)` that:
1. Scans `{base_dir}/campaigns/*/` directories
2. For each: reads `.repo_path` marker, opens `campaign.db`, reads campaign + mission counts
3. Checks if repo_path exists on filesystem (orphan detection)
4. Returns `list[CampaignInfo]` sorted by `updated_at` desc
5. Handles errors gracefully — corrupt DBs, missing files → skip with logging

**Verify:** Ruff lint passes. `python -c "from unfamiliar.campaign.discovery import discover_campaigns"`
**Done when:** Function handles all edge cases (empty dir, missing marker, corrupt DB, orphaned repos)

### Task 3: Pipeline data persister (`campaign/persister.py`) `DONE`

**Files:** `src/unfamiliar/campaign/persister.py` (new)
**Action:** Implement two functions:

`persist_briefing_data(db, campaign_id, briefing_result) -> list[Module]`:
1. Create Briefing from `briefing_result.briefing`
2. Create Module for each `briefing_result.modules` with category_relevance mapping
3. Return persisted modules with IDs

`persist_pipeline_data(db, campaign_id, pipeline_result, modules, repo_path) -> PersistResult`:
1. For each completed category in `pipeline_result.category_missions`:
   a. For each `MissionItemResponse` → create `Mission` (map difficulty, category)
   b. Create `MissionFileRef` entries from `file_refs`
   c. Resolve `prerequisite_titles` to mission IDs (intra-category by title_plain)
   d. Create `MissionPrerequisite` entries
   e. Map `module_index` to module ID → create `ModuleMissionLink`
2. Set root missions (no prerequisites) to `AVAILABLE`, rest to `LOCKED`
3. For each `ChainResponse` → create `MissionChain`, resolve `mission_titles` → `ChainMissionLink`
4. Log warnings for unresolvable titles or out-of-range indices
5. Return `PersistResult` with counts

**Verify:** Ruff lint passes. `python -c "from unfamiliar.campaign.persister import persist_briefing_data, persist_pipeline_data"`
**Done when:** Both functions handle all mapping logic including edge cases (missing titles, out-of-range indices)

### Task 4: Mission status engine (`campaign/missions.py`) `DONE`

**Files:** `src/unfamiliar/campaign/missions.py` (new)
**Action:** Implement mission lifecycle functions:

`complete_mission(db, mission_id) -> CompletionResult`:
1. Load mission, validate status is AVAILABLE or IN_PROGRESS
2. Update status to COMPLETED with SELF_REPORT
3. Find all dependent missions via `get_dependents()`
4. For each dependent: check if ALL prerequisites are now COMPLETED
5. If yes, update dependent to AVAILABLE
6. Return CompletionResult with unlocked IDs

`unlock_mission(db, mission_id) -> None`:
1. Load mission, validate exists and not COMPLETED
2. Set status to AVAILABLE

`start_mission(db, mission_id) -> None`:
1. Load mission, validate status is AVAILABLE
2. Set status to IN_PROGRESS

**Verify:** Ruff lint passes. `python -c "from unfamiliar.campaign.missions import complete_mission, unlock_mission, start_mission"`
**Done when:** All status transitions enforced per requirements. Cascade unlocking works correctly.

### Task 5: Campaign progress tracker (`campaign/progress.py`) `DONE`

**Files:** `src/unfamiliar/campaign/progress.py` (new)
**Action:** Implement `get_campaign_progress(db, campaign_id) -> CampaignProgress`:
1. Load all missions for campaign
2. Group by category, compute per-category stats (total, completed, available, locked)
3. Ensure all 7 categories appear in results even if they have 0 missions
4. Compute security-tagged totals
5. Compute overall percentages

**Verify:** Ruff lint passes. `python -c "from unfamiliar.campaign.progress import get_campaign_progress"`
**Done when:** Returns correct stats for all categories including empty ones

### Task 6: Campaign orchestrator (`campaign/orchestrator.py`) `DONE`

**Files:** `src/unfamiliar/campaign/orchestrator.py` (new)
**Action:** Implement the main orchestration functions:

`create_campaign(repo_path, config, progress_callback) -> Campaign`:
1. Compute campaign_dir via `get_campaign_dir()`
2. Create campaign_dir if it doesn't exist
3. Open campaign DB
4. Check for existing campaign — if INITIALIZING, resume; if ACTIVE, return it
5. Create Campaign record (status=INITIALIZING)
6. Run `analyze_repository(repo_path)`
7. Create LLM model via `create_chat_model(config)`
8. Generate briefing via `generate_briefing()`
9. Persist briefing data
10. Run mission pipeline via `run_mission_pipeline()`
11. Persist pipeline data
12. Update campaign status to ACTIVE
13. Return campaign

`resume_campaign(repo_path, base_dir) -> Campaign | None`:
1. If repo_path provided: look up via `get_campaign_dir()`, open DB, return campaign
2. If no repo_path: scan campaigns via `discover_campaigns()`, return most recent active/initializing

**Verify:** Ruff lint passes. `python -c "from unfamiliar.campaign.orchestrator import create_campaign, resume_campaign"`
**Done when:** Full pipeline wired end-to-end. Error handling for auth failures, partial completions.

### Task 7: Package exports (`campaign/__init__.py`) `DONE`

**Files:** `src/unfamiliar/campaign/__init__.py` (update)
**Action:** Update the `__init__.py` to export the public API:
- `create_campaign`, `resume_campaign` from orchestrator
- `discover_campaigns` from discovery
- `complete_mission`, `unlock_mission`, `start_mission` from missions
- `get_campaign_progress` from progress
- `CampaignInfo`, `PersistResult`, `CompletionResult`, `CampaignProgress`, `CategoryProgress` from types

**Verify:** `python -c "from unfamiliar.campaign import create_campaign, discover_campaigns, complete_mission"`
**Done when:** All public API symbols importable from `unfamiliar.campaign`

### Task 8: CLI command implementations (`cli.py`) `DONE`

**Files:** `src/unfamiliar/cli.py` (update)
**Action:** Replace all `[placeholder]` stubs with real implementations:

`investigate`:
1. Check for existing campaign (via `get_campaign_dir` + DB lookup)
2. If active campaign exists: print "Existing campaign found" + status summary, exit with note about `resume`
3. If no campaign: run `create_campaign()` with progress callback printing to stderr
4. Print campaign summary (missions generated, categories covered)
5. Print "TUI coming in Phase 14 — use `unfamiliar resume` to check status"

`campaigns`:
1. Call `discover_campaigns()`
2. Print table: repo name, status, missions completed/total, orphaned warning, last activity

`resume`:
1. Call `resume_campaign()`
2. If found: print campaign status and progress summary
3. If not found: print "No active campaigns"

`reconnect`:
1. Find campaign by name/ID in campaigns directory
2. Update `.repo_path` marker file
3. Validate new path is a git repo

`upgrade`:
1. Print "Schema upgrade not needed for v1.0 — all campaigns use schema 1.0"

**Verify:** `python -m unfamiliar --help` shows all commands. `python -m unfamiliar campaigns` runs without error.
**Done when:** All CLI commands functional (investigate requires API key; campaigns/resume/reconnect work offline)

### Task 9: Tech debt cleanup `DONE`

**Files:** `src/unfamiliar/db/repositories/mission_link.py`
**Action:**
- Add comment `# Used by: TUI Phase 14-15 (missions grouped by module)` to `get_missions_for_module()` method (resolves CLEANUP-002 from tech debt doc)
- Update `_planning/technical_debt.md` to mark CLEANUP-002 as resolved

**Verify:** Ruff lint passes
**Done when:** Comment added, tech debt updated

## Dependencies

- **Phase 12** — test suite passing (890 tests)
- **Analysis package** — `analyze_repository()` returns `AnalysisResult`
- **LLM package** — `generate_briefing()`, `run_mission_pipeline()`, `create_chat_model()`
- **DB package** — `open_campaign_db()` returns `CampaignDB` with all repositories
- **Config** — `load_config()`, `get_campaign_dir()`, `resolve_api_key()`

## Security Checklist

- [x] No API keys logged or stored in campaign data — config passed through, not persisted
- [x] File paths from LLM output validated before storage (existing validation in `validation.py`)
- [x] Campaign directories created with default permissions (no chmod — per decisions)
- [x] No user input passed to shell commands or SQL without parameterization — all DB operations use repositories with parameterized queries
- [x] Progress callback receives sanitized strings only (no user-controlled content) — only static status messages passed

## Issues Discovered During Verification Stage

### Issue 1: CLI calling get_campaign_progress with wrong signature

**Description:** `investigate` and `resume` commands were calling `get_campaign_progress(repo_path)` but the function signature is `get_campaign_progress(db: CampaignDB, campaign_id: int)`.
**Fix:** Updated CLI to:
1. Get `campaign_dir` via `get_campaign_dir(repo_path)`
2. Open database with `open_campaign_db(campaign_dir)`
3. Call `get_campaign_progress(db, campaign.id)` with correct arguments
**Resolution:** Fixed by adding imports for `get_campaign_dir` and `open_campaign_db` and using context manager to open DB.

### Issue 2: Test missing CampaignStatus import

**Description:** `test_investigate_accepts_git_directory` used `CampaignStatus.ACTIVE` without importing it.
**Fix:** Added `from unfamiliar.constants import CampaignStatus` inside the test function.
**Resolution:** Import added, test now passes.
