# Implementation Plan: REQ-014 -- `ievo devinstall` -- Symlink-Based Dev Installation

## Domain Research

- **Problem type**: Filesystem operations (symlink creation, directory scaffolding), CLI command wiring. Not CPU-bound. All operations are `Path.symlink_to()`, `Path.mkdir()`, `Path.is_symlink()`. No algorithmic complexity.
- **Approaches evaluated**:
  1. Relative symlinks -- rejected. The spec explicitly mandates absolute paths. Relative symlinks break when the project directory is moved or when tools resolve paths differently.
  2. Symlink entire directories (`.claude/agents/` as one symlink) -- rejected by spec. Individual file symlinks allow user overrides of specific agents while keeping others linked.
  3. Reuse `sync_marketplace()` with a `symlink=True` parameter -- evaluated but rejected. The two functions have fundamentally different contracts: `sync_marketplace` is mtime-gated copy, `devinstall_marketplace` is unconditional symlink-or-skip. Merging them would add branching complexity to a function that is already working and tested. Cleaner to have a separate `devinstall_marketplace()` function.
- **Recommended approach**: New `devinstall_marketplace(root)` function in `src/ievo/core/agent_sync.py` alongside existing `sync_marketplace`. New `devinstall` command in `src/ievo/commands/devinstall.py`. Extract shared scaffold logic from `init.py` into a `scaffold_project()` function in the same file (or a new `src/ievo/core/scaffold.py`).
- **Libraries/tools**: None new. `pathlib.Path.symlink_to()` (stdlib), existing `ievo.marketplace` functions.
- **Known pitfalls**:
  - `Path.is_symlink()` returns True even for broken symlinks. `Path.exists()` returns False for broken symlinks. The idempotency check must use both: `dest.is_symlink() and dest.resolve() == src.resolve()` to detect correct symlinks.
  - `dest.unlink()` works on both regular files and symlinks. No need for `os.remove()`.
  - `bundled_agent_path(name)` returns `None` when the bundled file does not exist. Must handle this gracefully (skip + warn).
  - On macOS/Linux, `Path.symlink_to(target)` creates a symlink where `target` is stored as-is. Using `src.resolve()` ensures the target is an absolute canonical path.
  - The `scaffold_project()` extraction must be backward-compatible: `init` must continue to work identically after the refactor.
  - `ProjectManifest.load()` must handle YAML files without the `devinstall` key (default to `False`). `ProjectManifest.save()` must conditionally include it.

## Architecture Assessment

- **Fits existing architecture**: yes -- follows existing patterns (Typer command in `commands/`, marketplace functions in `core/agent_sync.py`, console helpers for output).
- **New patterns introduced**:
  - `scaffold_project()` shared helper -- first extracted function from `init.py`. Sets precedent for keeping scaffold logic reusable.
  - `devinstall: bool` field on `ProjectManifest` -- first boolean flag on the manifest.
- **Modules affected**:
  - `src/ievo/core/project.py` -- add `devinstall` field to `ProjectManifest`
  - `src/ievo/core/agent_sync.py` -- add `devinstall_marketplace()`, modify `sync_marketplace()` to skip symlinks
  - `src/ievo/commands/init.py` -- extract scaffold logic into `scaffold_project()`
  - `src/ievo/commands/devinstall.py` -- new command file
  - `src/ievo/cli.py` -- register new command
  - `src/ievo/commands/__init__.py` -- import new module (if needed, currently empty)
- **Risk level**: medium -- scaffold extraction touches `init.py` which has 30+ existing tests. Must not break them.
- **Estimated complexity**: M (day)

## Pre-conditions

- [x] REQ-000 must be implemented (dependency) -- confirmed implemented
- [x] Git workflow confirmed in DECISIONS.md (D-012: trunk-based, `feat/REQ-014-devinstall`)

## Sub-tasks

### TASK-014-1: ProjectManifest `devinstall` field
Scope: Add `devinstall: bool = False` to `ProjectManifest`, update `load()` and `save()`.
Tests: ~4
Files: `src/ievo/core/project.py`, `tests/test_project.py`

### TASK-014-2: Extract `scaffold_project()` from init
Scope: Extract directory creation, manifest handling, CLAUDE.md, .gitignore, SPEC_INDEX, hooks, settings.local.json into a shared function. `init()` calls it. All existing init tests must pass without changes.
Tests: ~3
Files: `src/ievo/commands/init.py`, `tests/test_init.py`

### TASK-014-3: `devinstall_marketplace()` core logic
Scope: New function in `agent_sync.py` that creates symlinks for agents, skills, and templates. Includes the `--check` diagnostic logic.
Tests: ~12
Files: `src/ievo/core/agent_sync.py`, `tests/test_devinstall.py`

### TASK-014-4: `sync_marketplace` devinstall-awareness
Scope: Modify `sync_marketplace()` to skip valid symlinks when manifest has `devinstall=True`.
Tests: ~3
Files: `src/ievo/core/agent_sync.py`, `tests/test_devinstall.py`

### TASK-014-5: `ievo devinstall` command + CLI registration
Scope: New command file, register in `cli.py`, wire scaffold + devinstall_marketplace + --check.
Tests: ~4
Files: `src/ievo/commands/devinstall.py`, `src/ievo/cli.py`, `tests/test_devinstall.py`

## Micro-Steps

### TASK-014-1: ProjectManifest `devinstall` field

#### Step 1: Add `devinstall` field to ProjectManifest

- **Tests to write** (`tests/test_project.py`):
  - `test_manifest_devinstall_field_defaults_false` -- `ProjectManifest(project="x")` has `devinstall == False`
  - `test_manifest_devinstall_field_round_trips` -- create manifest with `devinstall=True`, save, load, verify `devinstall == True`
  - `test_manifest_load_default_devinstall_false` -- YAML file without `devinstall` key -> `devinstall == False`
  - `test_manifest_save_omits_devinstall_when_false` -- save with `devinstall=False`, verify YAML output does not contain `devinstall` key
- **Criteria covered**: AC-5
- **Files**: modify `src/ievo/core/project.py`
- **Details**:
  - Add `devinstall: bool = False` to `ProjectManifest` dataclass
  - In `load()`: add `devinstall=data.get("devinstall", False)` or `bool(data.get("devinstall", False))`
  - In `save()`: add `if self.devinstall: data["devinstall"] = True` (only write when True, as spec says)
- **Commit**: `REQ-014: add devinstall field to ProjectManifest`

---

### TASK-014-2: Extract `scaffold_project()` from init

#### Step 2: Extract scaffold_project() helper

- **Tests to write** (`tests/test_init.py`):
  - `test_scaffold_project_creates_all_directories` -- call `scaffold_project()` directly, verify all expected dirs exist
  - `test_scaffold_project_creates_manifest` -- verify manifest.yaml is created
  - `test_init_scaffold_unchanged_after_extraction` -- run existing `init()`, verify same files created (regression guard)
- **Criteria covered**: AC-1 (scaffold creation shared between init and devinstall)
- **Files**: modify `src/ievo/commands/init.py`
- **Details**:
  - Extract into `scaffold_project(root: Path, project_name: str) -> None` in `src/ievo/commands/init.py`
  - The function handles: directory creation (all dirs from current `init`), manifest creation/update with `cli_version`, CLAUDE.md write-if-missing + patch, .gitignore write-if-missing, SPEC_INDEX write-if-missing, hooks write-if-missing, settings.local.json write-if-missing
  - `init()` becomes: resolve root/name, deprecated_command(), ensure_home(), github_auth_check, call `scaffold_project(root, project_name)`, call `sync_marketplace(root)`, print success
  - `scaffold_project()` must NOT call `deprecated_command()`, `ensure_home()`, or github auth -- those are init-specific
  - `scaffold_project()` must NOT call `sync_marketplace()` -- caller decides copy vs symlink
  - All existing `test_init.py` tests must pass unchanged -- this is the backward-compat gate
- **Commit**: `REQ-014: extract scaffold_project() from init`

---

### TASK-014-3: `devinstall_marketplace()` core logic

#### Step 3: devinstall_marketplace() -- symlink agents

- **Tests to write** (`tests/test_devinstall.py`):
  - `test_devinstall_agents_are_symlinks` -- after `devinstall_marketplace(root)`, each `.claude/agents/<name>.md` is a symlink
  - `test_devinstall_agent_symlink_target_is_bundled_path` -- symlink target equals `bundled_agent_path(name).resolve()`
  - `test_devinstall_symlinks_use_absolute_paths` -- `os.readlink(dest)` returns an absolute path
  - `test_devinstall_missing_bundled_agent_skips` -- mock one agent as missing, verify others installed, warning printed
- **Criteria covered**: AC-2, AC-10
- **Files**: modify `src/ievo/core/agent_sync.py`, create `tests/test_devinstall.py`
- **Details**:
  - New function `devinstall_marketplace(root: Path) -> list[str]` in `agent_sync.py`
  - Iterate `list_bundled_agents()`, for each: get `src = bundled_agent_path(name)`, skip if None with `warn()`, create symlink using the idempotency pattern from the spec
  - Use `src.resolve()` as the symlink target to guarantee absolute path
  - Return sorted list of names that were installed/updated
- **Commit**: `REQ-014: devinstall_marketplace agents as symlinks`

#### Step 4: devinstall_marketplace() -- symlink skills and templates

- **Tests to write** (`tests/test_devinstall.py`):
  - `test_devinstall_skills_are_symlinks` -- `.claude/skills/<name>/SKILL.md` is a symlink
  - `test_devinstall_skill_parent_dir_is_real_dir` -- `.claude/skills/<name>/` is a real directory
  - `test_devinstall_templates_are_symlinks` -- `.ievo/iEVO.md` and all TEMPLATE_MAP entries are symlinks
- **Criteria covered**: AC-3, AC-4
- **Files**: modify `src/ievo/core/agent_sync.py`, update `tests/test_devinstall.py`
- **Details**:
  - Extend `devinstall_marketplace()` to handle skills (iterate `list_bundled_skills()`, create parent dir, symlink SKILL.md) and templates (iterate `TEMPLATE_MAP`, symlink each)
  - Same idempotency pattern as agents
- **Commit**: `REQ-014: devinstall skills and templates as symlinks`

#### Step 5: Idempotency -- upgrade copies to symlinks, fix broken symlinks

- **Tests to write** (`tests/test_devinstall.py`):
  - `test_devinstall_upgrades_copy_to_symlink` -- create regular file first, run devinstall, verify it becomes a symlink
  - `test_devinstall_replaces_broken_symlink` -- create symlink to nonexistent target, run devinstall, verify new valid symlink
  - `test_devinstall_idempotent_on_symlinked_project` -- run devinstall twice, verify symlinks unchanged (same target)
- **Criteria covered**: AC-6
- **Files**: update `tests/test_devinstall.py`
- **Details**: These tests exercise the idempotency logic already implemented in Steps 3-4. No new production code needed -- this step is purely test coverage for the edge cases.
- **Commit**: `REQ-014: idempotency tests for devinstall`

#### Step 6: `check_devinstall_status()` diagnostic function

- **Tests to write** (`tests/test_devinstall.py`):
  - `test_devinstall_check_reports_symlink` -- devinstalled file -> `[symlink]`
  - `test_devinstall_check_reports_file` -- regular file -> `[file]`
  - `test_devinstall_check_reports_broken_symlink` -- broken symlink -> `[broken symlink]`
  - `test_devinstall_check_reports_missing` -- missing file -> `[missing]`
  - `test_devinstall_check_does_not_modify_files` -- snapshot dir contents before and after, assert identical
- **Criteria covered**: AC-7
- **Files**: modify `src/ievo/core/agent_sync.py`, update `tests/test_devinstall.py`
- **Details**:
  - New function `check_devinstall_status(root: Path) -> list[tuple[str, str]]` in `agent_sync.py`
  - Returns list of `(relative_path, status)` tuples where status is one of `"[symlink]"`, `"[file]"`, `"[broken symlink]"`, `"[missing]"`
  - Iterates same items as `devinstall_marketplace()`: agents, skills, templates
  - Pure read-only -- no filesystem modifications
- **Commit**: `REQ-014: check_devinstall_status diagnostic function`

---

### TASK-014-4: sync_marketplace devinstall-awareness

#### Step 7: Modify sync_marketplace to skip valid symlinks

- **Tests to write** (`tests/test_devinstall.py`):
  - `test_sync_marketplace_skips_valid_symlinks` -- set up devinstalled project (symlinks + `devinstall=True` manifest), run `sync_marketplace`, verify symlinks not replaced with copies
  - `test_sync_marketplace_updates_regular_files_in_devinstall` -- in devinstall project, one file is a regular file (not symlink), verify `sync_marketplace` still updates it via mtime
  - `test_sync_marketplace_still_runs_for_regular_install` -- `devinstall=False` manifest, verify existing behavior unchanged
- **Criteria covered**: AC-9
- **Files**: modify `src/ievo/core/agent_sync.py`, update `tests/test_devinstall.py`
- **Details**:
  - `sync_marketplace(root)` must load manifest to check `devinstall` flag
  - Before each `shutil.copy2()`, check: if `manifest.devinstall` and `dest.is_symlink() and dest.exists()` -> skip (symlink is valid, no copy needed)
  - If `dest.is_symlink()` but `not dest.exists()` -> broken symlink, still skip (don't replace symlink with copy -- that would be wrong in a devinstall project; broken symlinks are fixed by re-running `devinstall`)
  - If manifest is not loadable (no `.ievo/manifest.yaml`), behave as before (first-time init)
- **Commit**: `REQ-014: sync_marketplace skips symlinks in devinstall projects`

---

### TASK-014-5: `ievo devinstall` command + CLI registration

#### Step 8: Create devinstall command and register in CLI

- **Tests to write** (`tests/test_devinstall.py`):
  - `test_devinstall_creates_scaffold` -- full integration: run `devinstall()`, verify all dirs, manifest, CLAUDE.md, SPEC_INDEX, hooks, settings.local.json exist
  - `test_devinstall_manifest_has_devinstall_true` -- after `devinstall()`, manifest has `devinstall=True`
  - `test_devinstall_registered_as_active_command` -- `ievo --help` output contains `devinstall` without `[DEPRECATED]`
  - `test_devinstall_check_exits_0` -- `devinstall(check=True)` on a devinstalled project exits normally (exit code 0)
- **Criteria covered**: AC-1, AC-5, AC-7, AC-8
- **Files**: create `src/ievo/commands/devinstall.py`, modify `src/ievo/cli.py`, update `tests/test_devinstall.py`
- **Details**:
  - `src/ievo/commands/devinstall.py`:
    ```
    def devinstall(
        check: bool = typer.Option(False, "--check", help="Show symlink status without modifying files."),
        name: str = typer.Argument(default=".", help="Project name or path."),
    ) -> None:
    ```
  - If `--check`: load project root via `find_project_root()`, call `check_devinstall_status(root)`, print status lines, return
  - Otherwise: resolve root/project_name (same pattern as `init()`), call `scaffold_project(root, project_name)`, call `devinstall_marketplace(root)`, load manifest + set `devinstall=True` + save, print success
  - `src/ievo/cli.py`: add import, register as active command:
    ```
    app.command(name="devinstall", help="Dev-install marketplace files as symlinks.")(devinstall.devinstall)
    ```
  - No deprecation warning -- this is a new active command
- **Commit**: `REQ-014: add ievo devinstall command`

## Traceability

| Acceptance Criterion | Covered in Step |
|---------------------|-----------------|
| AC-1: scaffold creation | Step 2, Step 8 |
| AC-2: agents as symlinks | Step 3 |
| AC-3: skills as symlinks | Step 4 |
| AC-4: templates as symlinks | Step 4 |
| AC-5: manifest devinstall=true | Step 1, Step 8 |
| AC-6: idempotency | Step 5 |
| AC-7: --check diagnostic | Step 6, Step 8 |
| AC-8: registered as active command | Step 8 |
| AC-9: sync_marketplace skips symlinks | Step 7 |
| AC-10: graceful skip on missing source | Step 3 |

## Scope Guard

- **Considered but excluded**: Windows symlink support (requires admin privileges on older Windows; this project targets macOS/Linux per dev environment). If Windows support is needed later, a new REQ should address it.
- **Considered but excluded**: symlink validation on startup (checking if all symlinks are still valid when TUI launches). The `--check` flag covers this on demand. Startup validation can be a future enhancement.
- **Considered but excluded**: migration command (`ievo init --upgrade-to-devinstall`). The spec says `ievo devinstall` handles this natively via idempotency.

## Architecture Notes for Coder

- Use `src.resolve()` as symlink target to guarantee absolute canonical path. Do NOT use `str(src)` -- it might be relative in edge cases.
- `_write_if_missing()` stays in `init.py` -- it is used by `scaffold_project()` which remains in `init.py`.
- `scaffold_project()` stays in `init.py` rather than moving to a new module. It avoids a new file and keeps the extraction minimal. `devinstall.py` imports it: `from ievo.commands.init import scaffold_project`.
- The `devinstall` command does NOT call `ensure_home()` or github auth -- those are `init`-specific (global config setup). `devinstall` is for developers who already have a working ievo installation.
- For `--check`, use `find_project_root()` and error if no project found (same pattern as `uninstall`).
- Reuse `list_bundled_agents()`, `list_bundled_skills()`, `bundled_agent_path()`, `bundled_skill_path()`, `bundled_template_path()` from `src/ievo/marketplace/__init__.py`.
- Reuse `TEMPLATE_MAP` from `src/ievo/core/templates.py` for template symlink targets.
- In `devinstall_marketplace()`, the return value is a list of names that were created/updated (not "already correct" symlinks). This matches `sync_marketplace()` semantics for consistent output.
- Test fixtures: use `tmp_path` for all filesystem tests. Create a minimal scaffold with `scaffold_project()` first, then test devinstall on top. Mock `ensure_home` and `get_config` in the same pattern as `test_init.py` uses.
- The `devinstall` Typer command should NOT be wrapped in `deprecated_command()`. It is an active command.
