# repoman v0.4 — Profile library + scope trim Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Introduce the `profile` subcommand family backed by a vendor/user profile library, trim the v0.3 LLM-stack-specific surface from `setup`, and migrate the registry to schema 3.

**Architecture:** New `src/profile.reef` module owns profile lifecycle (lookup → render → install/remove/diff/show) with vendor profiles at `/usr/local/share/repoman/profiles/` and user shadows at `~/.config/repoman/profiles.d/`. Schema-3 `[host].lan_ip` field replaces `[defaults].llm.ollama_url`; the `[defaults].llm` block is removed entirely. `setup` shrinks from 4 stages to 2 (incus_project + registry_defaults); `cmd_new` gains pre-launch profile validation.

**Tech Stack:** reef-lang 0.5.20 (no new stdlib requirements), `encoding.toml`, `core.result_generic`, `sys.process.process_spawn` / `process_run`, `io.dir.list_dir` (new caller for an existing API), `test.framework.TestRunner`.

---

## Reference: spec and source

- Design spec: `docs/superpowers/specs/2026-05-08-repoman-v0.4-profile-library.md` (locked)
- v0.3 plan (style/depth reference): `docs/superpowers/plans/2026-05-06-repoman-v0.3-llm-and-setup.md`
- Existing modules to mirror:
  - `src/setup.reef` — for module skeleton style + Environment/Stage struct pattern
  - `src/incus.reef` — for `process_run_capture`/`process_run_silent` patterns
  - `src/config.reef` — for parse/serialize/migrate idiom (T1–T6 of v0.3 plan are direct precedent for schema migrations)
- Reef stdlib API references already verified for v0.4:
  - `core.str.replace(s, old, new)` — global replace
  - `io.dir.list_dir(path: string, entries: [string], max: int): int` — populates buffer, returns count
  - `io.dir.dir_exists`, `io.dir.create_dir_all` — already in use
  - `io.file.fileExists`, `io.file.readFile`, `io.file.writeFile` — already in use
  - `core.str.starts_with`, `str.ends_with`, `str.substring`, `str.length`, `str.contains` — all already used
  - `sys.env.get_env_or` — already in use

## File structure

```
~/repos/repoman/
├── reef.toml                              # bump version 0.3.0 → 0.4.0
├── README.md                              # add ## Profile library + ## Migrating from v0.3 sections
├── VISION.md                              # update setup row (no --with-llm); add profile management row
├── Makefile                               # install profiles/*.yml to /usr/local/share/repoman/profiles/
├── profiles/                              # NEW vendor library directory
│   ├── claude-share.yml
│   ├── llm-share.yml
│   └── dotfiles.yml
├── src/
│   ├── cli.reef                           # +cmd_profile dispatch, +pre-launch validation, -setup --with-llm flags
│   ├── config.reef                        # +Host substruct, -LlmDefaults, schema 2→3 migration
│   ├── hermes.reef                        # (unchanged) library-only, kept from v0.3
│   ├── incus.reef                         # +profile_get (capture stdout)
│   ├── log.reef                           # (unchanged)
│   ├── main.reef                          # (unchanged)
│   ├── paths.reef                         # (unchanged)
│   ├── profile.reef                       # NEW
│   ├── setup.reef                         # trim Environment, drop llm helpers, shrink plan/apply
│   └── sync.reef                          # (unchanged)
└── tests/
    ├── test_config_schema_v3.reef         # NEW — schema 3 acceptance + serialize round-trip
    ├── test_config_migrate.reef           # NEW — v1→v3 and v2-with-llm→v3 migration
    ├── test_profile_paths.reef            # NEW — vendor_dir, user_dir
    ├── test_profile_render.reef           # NEW — ${VAR} substitution
    └── test_*.reef                        # existing tests; some updated, some deleted
```

Module-boundary rules (carried from v0.1):
- Each module file declares `module <name>` matching its filename, ends with `end module`.
- `main.reef` has no module declaration.
- Tests are standalone reef programs each with their own `proc main()`.
- `make test` runs every `tests/test_*.reef`, stops on first failure.

---

## Task 1: `Host` substruct + drop `LlmDefaults`

**Files:**
- Modify: `src/config.reef` (export block, type definitions, Defaults literal sites)

`Defaults` loses its `llm: LlmDefaults` field. Registry gains a `host: Host` field. `LlmDefaults` type is removed.

- [ ] **Step 1: Read the current Defaults / Registry / LlmDefaults declarations**

```bash
sed -n '11,80p' src/config.reef
```

Expected: `LlmDefaults` type definition and `llm: LlmDefaults` field in `Defaults`.

- [ ] **Step 2: Add Host type and Registry.host field; drop LlmDefaults type and Defaults.llm field**

In `src/config.reef`'s export block, **remove** the line `type LlmDefaults` and **add** `type Host`:

```reef
export
    type Defaults
    type Host
    type Project
    type Override
    type Mount
    type Registry
    type EffectiveConfig
    ...
end export
```

Add a new type definition above `Defaults`:

```reef
type Host = struct
    lan_ip: string
end Host
```

Remove the entire `type LlmDefaults = struct ... end LlmDefaults` block.

In `Defaults`, remove the `llm: LlmDefaults` field. The struct goes back to its v0.2 shape:

```reef
type Defaults = struct
    repos_root: string
    backup_root: string
    logdir: string
    incus_project: string
    default_image: string
    profiles: [string]
end Defaults
```

In `Registry`, add a `host: Host` field as the second field (after `schema`, before `output`):

```reef
type Registry = struct
    schema: int
    host: Host
    output: string
    defaults: Defaults
    projects: [Project]
end Registry
```

- [ ] **Step 3: Update existing Defaults/Registry construction sites — remove `llm: LlmDefaults {...}` blocks, add `host: Host {...}`**

Sites today (per v0.3 + revert state):
- `parse_registry` (around line 153): `Defaults { ..., llm: LlmDefaults {...} }`. Drop the `llm:` line.
- `default_registry` (around line 434): same.
- Any test that builds a `Defaults` literal: `tests/test_config_serialize.reef`, `tests/test_config_roundtrip.reef`, `tests/test_config_merge.reef`, `tests/test_config_mutate.reef` — drop `llm:` field.

Then add `host: Host { lan_ip: "" }` to every `Registry` literal:
- `parse_registry` (the final return literal)
- `default_registry`
- Tests that build a `Registry` literal directly

For `parse_registry`'s final literal, change:

```reef
    let reg: Registry = Registry {
        schema:   2,
        output:   output,
        defaults: defaults,
        projects: projects
    }
```

to:

```reef
    let host: Host = Host {
        lan_ip: ""    // populated below for schema 2 migration; left empty otherwise
    }
    let reg: Registry = Registry {
        schema:   2,
        host:     host,
        output:   output,
        defaults: defaults,
        projects: projects
    }
```

(The `host.lan_ip` value will be properly populated by the schema-2 migration logic in T2; this task just adds the field with a default of empty string.)

For `default_registry`:

```reef
    return Registry {
        schema:   2,
        host:     Host { lan_ip: "" },
        output:   "quiet",
        defaults: Defaults { ... },     // no llm field
        projects: new [Project](0)
    }
```

(Schema constant stays at 2 in this task; T5 bumps to 3.)

- [ ] **Step 4: Run existing tests; expect compile failures in test files that reference LlmDefaults or `defaults.llm`**

```bash
make test
```

Expected: compile errors on tests that use `LlmDefaults` or `reg.defaults.llm`. These tests are about to be deleted (T7) but for now patch them minimally to compile:
- `tests/test_config_serialize.reef`, `test_config_roundtrip.reef`, `test_config_merge.reef`, `test_config_mutate.reef` — drop `llm:` field from `Defaults` literals; add `host: Host { lan_ip: "" }` to `Registry` literals.

Run `grep -rln 'LlmDefaults\|defaults.llm' src/ tests/` to find every site.

- [ ] **Step 5: Build clean and re-run tests**

```bash
make build && make test
```

Expected: clean build. Some tests may still fail because parse/serialize haven't been updated yet (T2/T3); that's fine for now. The build must compile.

- [ ] **Step 6: Commit**

```bash
hg add src/config.reef tests/
hg commit -m "config: add Host substruct on Registry, drop LlmDefaults (schema unchanged)"
```

---

## Task 2: `parse_registry` reads `[host].lan_ip` and migrates from schema 2's `[defaults.llm.ollama_url]`

**Files:**
- Modify: `src/config.reef` (parse_registry function)

- [ ] **Step 1: Add a private helper `extract_lan_ip_from_url`**

Inside `src/config.reef`, before `parse_registry`, add:

```reef
// Extract the host portion of an `http://<host>:<port>` URL string.
// Returns "" if the input doesn't match that shape.
fn extract_lan_ip_from_url(url: string): string
    let n: int = str.length(url)
    if n == 0
        return ""
    end if
    let prefix: string = "http://"
    let prefix_len: int = 7
    if not str.starts_with(url, prefix)
        return ""
    end if
    // Find ':' after the prefix
    mut i: int = prefix_len
    while i < n and url[i] != ':'
        i = i + 1
    end while
    if i <= prefix_len
        return ""
    end if
    return str.substring(url, prefix_len, i - prefix_len)
end extract_lan_ip_from_url
```

- [ ] **Step 2: Modify `parse_registry` to read `[host].lan_ip` (schema 3) or extract from `[defaults.llm.ollama_url]` (schema 2 migration)**

In `parse_registry`, find the schema check (currently `if schema != 1 and schema != 2`). Update to accept schema 3:

```reef
    if schema != 1 and schema != 2 and schema != 3
        return @Result[Registry, string].Err("unsupported schema (expected 1, 2, or 3)")
    end if
```

Right before the final `Registry { ... }` literal (where Task 1 added `host: host`), populate the `host` value:

```reef
    // [host].lan_ip — schema 3 reads it directly; schema 2 migration extracts from
    // the now-removed [defaults.llm.ollama_url]; schema 1 has no such value (empty).
    mut lan_ip: string = toml.toml_get_doc(doc, "host.lan_ip")
    if str.length(lan_ip) == 0
        // Fall back: schema 2 migration via [defaults.llm.ollama_url]
        let old_url: string = toml.toml_get_doc(doc, "defaults.llm.ollama_url")
        if str.length(old_url) > 0
            lan_ip = extract_lan_ip_from_url(old_url)
        end if
    end if

    let host: Host = Host {
        lan_ip: lan_ip
    }
```

Replace Task 1's placeholder `let host: Host = Host { lan_ip: "" }` with this block.

- [ ] **Step 3: Build to verify**

```bash
make build
```

Expected: clean build.

- [ ] **Step 4: Commit**

```bash
hg add src/config.reef
hg commit -m "config: parse_registry accepts schema 3 + migrates lan_ip from schema 2 ollama_url"
```

---

## Task 3: `serialize_registry` writes `[host]` block; never writes `[defaults.llm]`

**Files:**
- Modify: `src/config.reef` (serialize_registry function)

The serializer is currently writing `[defaults.llm]` (per v0.3 T4). Remove that, add `[host]`.

- [ ] **Step 1: Read the current serialize_registry**

```bash
grep -n 'fn serialize_registry\|toml.toml_begin_table\|defaults.llm' src/config.reef
```

Identify the `toml_begin_table(b, "defaults.llm")` block and the surrounding section.

- [ ] **Step 2: Remove `[defaults.llm]` writes; add `[host]` write**

In `serialize_registry`, after `toml_set_string(b, "output", reg.output)` (the `[repoman]` block) and BEFORE `toml_begin_table(b, "defaults")`, insert the `[host]` block:

```reef
    toml.toml_begin_table(b, "host")
    toml.toml_set_string(b, "lan_ip", reg.host.lan_ip)
```

In the `[defaults]` section, **remove** the entire `[defaults.llm]` block:

```reef
    // DELETE these lines:
    toml.toml_begin_table(b, "defaults.llm")
    toml.toml_set_bool(b, "enabled", reg.defaults.llm.enabled)
    toml.toml_set_bool(b, "hermes_default", reg.defaults.llm.hermes_default)
    toml.toml_set_string(b, "ollama_url", reg.defaults.llm.ollama_url)
    toml.toml_set_string_array(b, "hermes_seed", reg.defaults.llm.hermes_seed)
```

The `[defaults]` body retains all other field writes (repos_root, backup_root, logdir, incus_project, default_image, profiles).

- [ ] **Step 3: Build to verify**

```bash
make build
```

Expected: clean build.

- [ ] **Step 4: Commit**

```bash
hg add src/config.reef
hg commit -m "config: serialize_registry emits [host] block, drops [defaults.llm]"
```

---

## Task 4: Force in-memory schema to 3 in `parse_registry`

**Files:**
- Modify: `src/config.reef` (parse_registry's final Registry literal)

Same pattern as v0.3's T5: any registry, regardless of disk format, becomes schema 3 in memory.

- [ ] **Step 1: Update parse_registry's final literal**

In `parse_registry`, find the final Registry literal (currently `schema: 2,`):

```reef
    let reg: Registry = Registry {
        schema:   2,
        host:     host,
        output:   output,
        defaults: defaults,
        projects: projects
    }
```

Change `schema: 2,` to `schema: 3,`.

- [ ] **Step 2: Update existing test assertions about post-parse schema**

`grep -rn 'reg.schema, 2\|.schema, 2\|reg2.schema, 2' tests/` to find tests asserting schema=2 after parse. Update to `3`.

Likely sites: `tests/test_config_parse.reef`, `tests/test_config_roundtrip.reef`, `tests/test_config_save.reef`, `tests/test_config_migrate_v1.reef`. Also possibly `test_config_io.reef` for the parse-not-init path.

For each, change assertions like:

```reef
runner.assert_eq_int(reg.schema, 2, "schema after parse = 2")
```

to:

```reef
runner.assert_eq_int(reg.schema, 3, "schema after parse = 3 (migrated)")
```

Don't change tests that build a `Registry` literal directly with `schema: 2` and serialize without parsing (those are testing serializer fidelity at the input schema, and still pass).

- [ ] **Step 3: Run all tests**

```bash
make test
```

Expected: all suites pass. Some tests will need additional updates due to `LlmDefaults` removal (already done in T1's step 4). If any still fail, they'll likely be in `test_config_llm_parse.reef` or `test_config_llm_serialize.reef` — both will be deleted in T7. For now, comment out their failing assertions or skip the test.

- [ ] **Step 4: Commit**

```bash
hg add src/config.reef tests/
hg commit -m "config: in-memory schema always 3 after parse (v1/v2 migrate implicitly)"
```

---

## Task 5: `default_registry` returns schema 3

**Files:**
- Modify: `src/config.reef` (default_registry function)
- Tests: `tests/test_config_io.reef` (init-path schema assertion)

- [ ] **Step 1: Update default_registry**

In `src/config.reef`, find:

```reef
fn default_registry(home_dir: string): Registry
    ...
    return Registry {
        schema: 2,
        host:     Host { lan_ip: "" },
        ...
    }
end default_registry
```

Change `schema: 2,` to `schema: 3,`.

- [ ] **Step 2: Update test_config_io.reef's init-path assertion**

`grep -n 'schema' tests/test_config_io.reef`. The init path (where `load_or_init` is called against an empty temp dir) currently asserts `reg.schema == 2`. Change to `3`.

- [ ] **Step 3: Run tests**

```bash
make test
```

Expected: all green (modulo the to-be-deleted tests).

- [ ] **Step 4: Commit**

```bash
hg add src/config.reef tests/test_config_io.reef
hg commit -m "config: default_registry writes schema 3"
```

---

## Task 6: Schema 3 acceptance test + migration test

**Files:**
- Test: `tests/test_config_schema_v3.reef` (new)
- Test: `tests/test_config_migrate.reef` (new)

- [ ] **Step 1: Write test_config_schema_v3.reef — schema 3 round-trip**

Create `tests/test_config_schema_v3.reef`:

```reef
import config
import test.framework
import core.result_generic as rg

proc main()
    let runner = new framework.TestRunner()

    // Build a schema-3 registry, serialize, parse round-trip
    let reg = config.Registry {
        schema:   3,
        host:     config.Host { lan_ip: "192.168.168.124" },
        output:   "quiet",
        defaults: config.Defaults {
            repos_root:    "~/repos",
            backup_root:   "/nfs/repos",
            logdir:        "~/.local/state/repoman",
            incus_project: "repoman",
            default_image: "images:ubuntu/26.04/cloud",
            profiles:      ["default"]
        },
        projects: new [config.Project](0)
    }

    let s = config.serialize_registry(reg)
    runner.assert_contains_string(s, "schema = 3", "writes schema = 3")
    runner.assert_contains_string(s, "[host]", "writes [host] block")
    runner.assert_contains_string(s, "lan_ip = \"192.168.168.124\"", "writes lan_ip")
    let neg = str.contains(s, "[defaults.llm]")
    runner.assert_eq_bool(neg, false, "does NOT write [defaults.llm]")

    let r2 = config.parse_registry(s)
    runner.assert_eq_bool(rg.is_ok(r2), true, "round-trip parses ok")
    if rg.is_ok(r2)
        let reg2 = rg.unwrap_ok(r2)
        runner.assert_eq_int(reg2.schema, 3, "round-trip schema = 3")
        runner.assert_eq_string(reg2.host.lan_ip, "192.168.168.124", "round-trip host.lan_ip")
    end if

    runner.report()
end main
```

(Note: `import core.str` for `str.contains` if needed — check `tests/test_config_llm_serialize.reef` for the import pattern.)

- [ ] **Step 2: Write test_config_migrate.reef — v1→v3 and v2→v3 migration**

Create `tests/test_config_migrate.reef`:

```reef
import config
import test.framework
import core.result_generic as rg

proc main()
    let runner = new framework.TestRunner()

    // v1 (no llm block, no host block) → v3 with empty lan_ip
    let v1: string = "[repoman]\nschema = 1\noutput = \"quiet\"\n\n[defaults]\nrepos_root = \"~/repos\"\nbackup_root = \"/nfs/repos\"\nlogdir = \"~/.local/state/repoman\"\nincus_project = \"repoman\"\ndefault_image = \"images:ubuntu/26.04/cloud\"\nprofiles = [\"default\", \"claude-share\"]\n"
    let r1 = config.parse_registry(v1)
    runner.assert_eq_bool(rg.is_ok(r1), true, "v1 parses ok")
    if rg.is_ok(r1)
        let reg = rg.unwrap_ok(r1)
        runner.assert_eq_int(reg.schema, 3, "v1 schema migrates to 3")
        runner.assert_eq_string(reg.host.lan_ip, "", "v1 has no lan_ip")
    end if

    // v2 with [defaults.llm.ollama_url] → v3 with extracted lan_ip
    let v2: string = "[repoman]\nschema = 2\noutput = \"quiet\"\n\n[defaults]\nrepos_root = \"~/repos\"\nbackup_root = \"/nfs/repos\"\nlogdir = \"~/.local/state/repoman\"\nincus_project = \"repoman\"\ndefault_image = \"images:ubuntu/26.04/cloud\"\nprofiles = [\"default\"]\n\n[defaults.llm]\nenabled = true\nhermes_default = false\nollama_url = \"http://192.168.168.124:11434\"\nhermes_seed = []\n"
    let r2 = config.parse_registry(v2)
    runner.assert_eq_bool(rg.is_ok(r2), true, "v2 parses ok")
    if rg.is_ok(r2)
        let reg = rg.unwrap_ok(r2)
        runner.assert_eq_int(reg.schema, 3, "v2 schema migrates to 3")
        runner.assert_eq_string(reg.host.lan_ip, "192.168.168.124", "v2 lan_ip extracted from ollama_url")
    end if

    // v2 without [defaults.llm.ollama_url] (LLM was disabled) → v3 with empty lan_ip
    let v2_no_llm: string = "[repoman]\nschema = 2\noutput = \"quiet\"\n\n[defaults]\nrepos_root = \"~/repos\"\nbackup_root = \"/nfs/repos\"\nlogdir = \"~/.local/state/repoman\"\nincus_project = \"repoman\"\ndefault_image = \"images:ubuntu/26.04/cloud\"\nprofiles = [\"default\"]\n"
    let r3 = config.parse_registry(v2_no_llm)
    runner.assert_eq_bool(rg.is_ok(r3), true, "v2 without llm block parses ok")
    if rg.is_ok(r3)
        let reg = rg.unwrap_ok(r3)
        runner.assert_eq_string(reg.host.lan_ip, "", "v2 without ollama_url has empty lan_ip")
    end if

    // v3 native (with [host].lan_ip) round-trip
    let v3: string = "[repoman]\nschema = 3\noutput = \"quiet\"\n\n[host]\nlan_ip = \"10.0.0.5\"\n\n[defaults]\nrepos_root = \"~/repos\"\nbackup_root = \"/nfs/repos\"\nlogdir = \"~/.local/state/repoman\"\nincus_project = \"repoman\"\ndefault_image = \"images:ubuntu/26.04/cloud\"\nprofiles = [\"default\"]\n"
    let r4 = config.parse_registry(v3)
    runner.assert_eq_bool(rg.is_ok(r4), true, "v3 parses ok")
    if rg.is_ok(r4)
        let reg = rg.unwrap_ok(r4)
        runner.assert_eq_string(reg.host.lan_ip, "10.0.0.5", "v3 native lan_ip read directly")
    end if

    runner.report()
end main
```

- [ ] **Step 3: Run the new tests**

```bash
reefc run tests/test_config_schema_v3.reef
reefc run tests/test_config_migrate.reef
```

Expected: all assertions pass.

- [ ] **Step 4: Run full test suite for regression check**

```bash
make test
```

Expected: all green (modulo the to-be-deleted tests in T7).

- [ ] **Step 5: Commit**

```bash
hg add tests/test_config_schema_v3.reef tests/test_config_migrate.reef
hg commit -m "config: tests for schema 3 round-trip and v1/v2 migration"
```

---

## Task 7: Delete v0.3-only LLM-block tests

**Files:**
- Delete: `tests/test_config_llm_parse.reef`
- Delete: `tests/test_config_llm_serialize.reef`
- Delete: `tests/test_config_migrate_v1.reef`

After T6's new tests, these v0.3-era tests are superseded:
- `test_config_llm_parse.reef` — tested parsing of `[defaults.llm]` block which no longer exists
- `test_config_llm_serialize.reef` — tested serializing the same
- `test_config_migrate_v1.reef` — tested v1→v2 migration; T6's `test_config_migrate.reef` covers v1→v3 and v2→v3

- [ ] **Step 1: Delete the three test files**

```bash
hg rm tests/test_config_llm_parse.reef
hg rm tests/test_config_llm_serialize.reef
hg rm tests/test_config_migrate_v1.reef
```

- [ ] **Step 2: Run all tests to confirm clean state**

```bash
make test
```

Expected: clean. The remaining test count is lower than v0.3.

- [ ] **Step 3: Commit**

```bash
hg commit -m "tests: drop v0.3 [defaults.llm] tests; superseded by schema_v3 + migrate tests"
```

---

## Task 8: `incus.profile_get` — capture-mode wrapper for `incus profile show`

**Files:**
- Modify: `src/incus.reef` (export block + new function)

- [ ] **Step 1: Add to export block**

In `src/incus.reef`'s export block, add:

```reef
    fn profile_get(project: string, name: string): rg.Result[string, string]
```

- [ ] **Step 2: Implement profile_get**

After the existing `profile_create_or_edit` function, add:

```reef
// Returns the YAML body of the named profile (the same output as
// `incus profile show <project> <name>`). Errors if the profile doesn't
// exist or the subprocess fails.
fn profile_get(project: string, name: string): rg.Result[string, string]
    let cap: CaptureResult = process_run_capture("incus", [
        "profile", "show", "--project", project, name
    ])
    if cap.exit_code != 0
        return @Result[string, string].Err("incus profile show exited " + convert.to_string(cap.exit_code))
    end if
    return @Result[string, string].Ok(cap.stdout)
end profile_get
```

- [ ] **Step 3: Build**

```bash
make build
```

Expected: clean build.

- [ ] **Step 4: Commit**

```bash
hg add src/incus.reef
hg commit -m "incus: add profile_get (capture incus profile show stdout)"
```

---

## Task 9: `profile.reef` skeleton + types

**Files:**
- Create: `src/profile.reef`

- [ ] **Step 1: Create the module skeleton**

Create `src/profile.reef`:

```reef
module profile

import core.str
import core.result_generic as rg
import core.convert as convert
import io.file as iofile
import io.dir as iodir
import sys.env
import paths
import incus

export
    type ProfileEntry
    type HostFacts
    fn vendor_dir(): string
    fn user_dir(home_dir: string): string
    fn render(yaml: string, host: HostFacts): string
    fn lookup(name: string, home_dir: string): rg.Result[string, string]
    fn list_all(home_dir: string): [ProfileEntry]
    fn install(name: string, home_dir: string, host: HostFacts): rg.Result[bool, string]
    fn remove_profile(name: string): rg.Result[bool, string]
    fn show(name: string, home_dir: string, host: HostFacts): rg.Result[string, string]
    fn diff(name: string, home_dir: string, host: HostFacts): rg.Result[string, string]
end export

// One entry from the profile library, populated by list_all.
type ProfileEntry = struct
    name:      string         // e.g., "claude-share"
    source:    string         // "user", "vendor", or "user (shadows vendor)"
    file_path: string         // resolved file path
    installed: bool           // present in incus state
end ProfileEntry

// Substitution context for templated profile YAML.
type HostFacts = struct
    lan_ip: string
    user:   string
    home:   string
end HostFacts

// (function bodies follow in subsequent tasks)

fn vendor_dir(): string
    return ""
end vendor_dir

fn user_dir(home_dir: string): string
    return ""
end user_dir

fn render(yaml: string, host: HostFacts): string
    return yaml
end render

fn lookup(name: string, home_dir: string): rg.Result[string, string]
    return @Result[string, string].Err("not implemented")
end lookup

fn list_all(home_dir: string): [ProfileEntry]
    return new [ProfileEntry](0)
end list_all

fn install(name: string, home_dir: string, host: HostFacts): rg.Result[bool, string]
    return @Result[bool, string].Err("not implemented")
end install

fn remove_profile(name: string): rg.Result[bool, string]
    return @Result[bool, string].Err("not implemented")
end remove_profile

fn show(name: string, home_dir: string, host: HostFacts): rg.Result[string, string]
    return @Result[string, string].Err("not implemented")
end show

fn diff(name: string, home_dir: string, host: HostFacts): rg.Result[string, string]
    return @Result[string, string].Err("not implemented")
end diff

end module
```

(Note: function name is `remove_profile` not `remove` — `remove` would conflict with the existing reef stdlib. Caller in `cli.reef` uses `profile.remove_profile`.)

- [ ] **Step 2: Build**

```bash
make build
```

Expected: clean build (stubs compile).

- [ ] **Step 3: Commit**

```bash
hg add src/profile.reef
hg commit -m "profile: module skeleton with types and stubbed entry points"
```

---

## Task 10: `profile.vendor_dir`, `profile.user_dir`, `profile.render` + tests

**Files:**
- Modify: `src/profile.reef`
- Test: `tests/test_profile_paths.reef`
- Test: `tests/test_profile_render.reef`

- [ ] **Step 1: Write failing test for vendor_dir / user_dir**

Create `tests/test_profile_paths.reef`:

```reef
import profile
import test.framework

proc main()
    let runner = new framework.TestRunner()

    runner.assert_eq_string(
        profile.vendor_dir(),
        "/usr/local/share/repoman/profiles",
        "vendor_dir is the install layout"
    )
    runner.assert_eq_string(
        profile.user_dir("/home/ctusa"),
        "/home/ctusa/.config/repoman/profiles.d",
        "user_dir under XDG config"
    )
    runner.assert_eq_string(
        profile.user_dir(""),
        "/.config/repoman/profiles.d",
        "user_dir with empty home (still composes path)"
    )

    runner.report()
end main
```

- [ ] **Step 2: Run test, see failure**

```bash
reefc run tests/test_profile_paths.reef
```

Expected: 3 assertion failures (stub returns "").

- [ ] **Step 3: Implement vendor_dir + user_dir**

In `src/profile.reef`, replace the stubs:

```reef
fn vendor_dir(): string
    return "/usr/local/share/repoman/profiles"
end vendor_dir

fn user_dir(home_dir: string): string
    return paths.join(home_dir, ".config/repoman/profiles.d")
end user_dir
```

- [ ] **Step 4: Run test, expect pass**

```bash
reefc run tests/test_profile_paths.reef
```

Expected: 3/3 pass.

- [ ] **Step 5: Write failing test for render**

Create `tests/test_profile_render.reef`:

```reef
import profile
import test.framework

proc main()
    let runner = new framework.TestRunner()

    let host = profile.HostFacts {
        lan_ip: "192.168.168.124",
        user:   "ctusa",
        home:   "/home/ctusa"
    }

    // All three substitutions
    let yaml: string = "config:\n  environment.OLLAMA_HOST: \"http://${HOST_LAN_IP}:11434\"\ndevices:\n  state:\n    source: ${HOME}/.ollama\n    user: ${USER}\n"
    let out = profile.render(yaml, host)
    runner.assert_contains_string(out, "http://192.168.168.124:11434", "${HOST_LAN_IP} substituted")
    runner.assert_contains_string(out, "/home/ctusa/.ollama", "${HOME} substituted")
    runner.assert_contains_string(out, "user: ctusa", "${USER} substituted")
    runner.assert_eq_bool(false, str.contains(out, "${HOST_LAN_IP}"), "no leftover ${HOST_LAN_IP}")
    runner.assert_eq_bool(false, str.contains(out, "${USER}"), "no leftover ${USER}")
    runner.assert_eq_bool(false, str.contains(out, "${HOME}"), "no leftover ${HOME}")

    // No substitutions present — input is returned unchanged
    let plain: string = "name: foo\nconfig: {}\n"
    runner.assert_eq_string(profile.render(plain, host), plain, "no-substitution input passes through")

    // Empty input
    runner.assert_eq_string(profile.render("", host), "", "empty input")

    runner.report()
end main
```

(Add `import core.str` at the top — `str.contains` requires it.)

- [ ] **Step 6: Run, see failure**

```bash
reefc run tests/test_profile_render.reef
```

Expected: assertion failures (stub returns input unchanged, but the substitution-required assertions fail).

- [ ] **Step 7: Implement render**

Replace the stub:

```reef
fn render(yaml: string, host: HostFacts): string
    let s1: string = str.replace(yaml, "${HOST_LAN_IP}", host.lan_ip)
    let s2: string = str.replace(s1,   "${USER}",        host.user)
    let s3: string = str.replace(s2,   "${HOME}",        host.home)
    return s3
end render
```

- [ ] **Step 8: Run tests**

```bash
reefc run tests/test_profile_render.reef
make test
```

Expected: render test 9/9, full suite green.

- [ ] **Step 9: Commit**

```bash
hg add src/profile.reef tests/test_profile_paths.reef tests/test_profile_render.reef
hg commit -m "profile: vendor_dir, user_dir, render — pure helpers + tests"
```

---

## Task 11: `profile.lookup` — search user dir then vendor dir

**Files:**
- Modify: `src/profile.reef` (replace lookup stub)

- [ ] **Step 1: Implement lookup**

Replace the stub `fn lookup(...)`:

```reef
// Search user dir first, then vendor dir. Returns the path to the YAML file.
// User shadows vendor: a file in user dir wins.
fn lookup(name: string, home_dir: string): rg.Result[string, string]
    let user_path: string = paths.join(user_dir(home_dir), name + ".yml")
    if iofile.fileExists(user_path)
        return @Result[string, string].Ok(user_path)
    end if
    let vendor_path: string = paths.join(vendor_dir(), name + ".yml")
    if iofile.fileExists(vendor_path)
        return @Result[string, string].Ok(vendor_path)
    end if
    return @Result[string, string].Err("profile not found: " + name + " (looked in " + user_path + " and " + vendor_path + ")")
end lookup
```

- [ ] **Step 2: Build**

```bash
make build
```

Expected: clean build.

- [ ] **Step 3: Smoke-test by inspection**

Create a temp dir + file:

```bash
mkdir -p /tmp/repoman-lookup-test/.config/repoman/profiles.d
echo 'name: test' > /tmp/repoman-lookup-test/.config/repoman/profiles.d/test.yml
cat > /tmp/test_lookup.reef <<'EOF'
import profile
import core.result_generic as rg
import io.console as console

proc main()
    let r = profile.lookup("test", "/tmp/repoman-lookup-test")
    if rg.is_ok(r)
        console.print("found: " + rg.unwrap_ok(r) + "\n")
    else
        console.print("err: " + rg.unwrap_err(r) + "\n")
    end if
EOF
reefc run /tmp/test_lookup.reef
```

Expected: `found: /tmp/repoman-lookup-test/.config/repoman/profiles.d/test.yml`.

(If `console.print` doesn't compile, use `println` instead — same pattern as the v0.3 setup smoke test.)

- [ ] **Step 4: Commit**

```bash
hg add src/profile.reef
hg commit -m "profile: lookup — user-shadows-vendor file resolution"
```

---

## Task 12: `profile.list_all` — enumerate both dirs with shadow + install status

**Files:**
- Modify: `src/profile.reef` (replace list_all stub)

- [ ] **Step 1: Implement list_all**

Replace the stub. The function enumerates user dir + vendor dir, builds a name → ProfileEntry map (user shadows vendor), and queries incus for install status.

```reef
fn list_all(home_dir: string): [ProfileEntry]
    // Buffers for filename lists. Cap at 256 per dir; profile libraries
    // are not expected to be enormous.
    let max_files: int = 256
    mut user_buf: [string] = new [string](max_files)
    mut vendor_buf: [string] = new [string](max_files)

    let u_dir: string = user_dir(home_dir)
    mut user_count: int = 0
    if iodir.dir_exists(u_dir)
        user_count = iodir.list_dir(u_dir, user_buf, max_files)
    end if

    let v_dir: string = vendor_dir()
    mut vendor_count: int = 0
    if iodir.dir_exists(v_dir)
        vendor_count = iodir.list_dir(v_dir, vendor_buf, max_files)
    end if

    // Pre-allocate result buffer at worst-case size.
    let cap: int = user_count + vendor_count
    mut entries_buf: [ProfileEntry] = new [ProfileEntry](cap)
    mut e_count: int = 0

    // Pass 1: add all user-dir *.yml files
    mut i: int = 0
    while i < user_count
        let fname: string = user_buf[i]
        if str.ends_with(fname, ".yml")
            let name: string = str.substring(fname, 0, str.length(fname) - 4)
            let path: string = paths.join(u_dir, fname)
            let installed_r = incus.profile_exists("default", name)
            mut installed: bool = false
            if rg.is_ok(installed_r)
                installed = rg.unwrap_ok(installed_r)
            end if
            entries_buf[e_count] = ProfileEntry {
                name:      name,
                source:    "user",
                file_path: path,
                installed: installed
            }
            e_count = e_count + 1
        end if
        i = i + 1
    end while

    // Pass 2: add vendor-dir *.yml files NOT already present (so user wins)
    mut j: int = 0
    while j < vendor_count
        let fname: string = vendor_buf[j]
        if str.ends_with(fname, ".yml")
            let name: string = str.substring(fname, 0, str.length(fname) - 4)
            // Check if name already in entries_buf
            mut shadowed: bool = false
            mut k: int = 0
            while k < e_count
                if entries_buf[k].name == name
                    shadowed = true
                    // Update the existing user entry's source label to indicate shadow
                    entries_buf[k] = ProfileEntry {
                        name:      entries_buf[k].name,
                        source:    "user (shadows vendor)",
                        file_path: entries_buf[k].file_path,
                        installed: entries_buf[k].installed
                    }
                end if
                k = k + 1
            end while
            if not shadowed
                let path: string = paths.join(v_dir, fname)
                let installed_r = incus.profile_exists("default", name)
                mut installed: bool = false
                if rg.is_ok(installed_r)
                    installed = rg.unwrap_ok(installed_r)
                end if
                entries_buf[e_count] = ProfileEntry {
                    name:      name,
                    source:    "vendor",
                    file_path: path,
                    installed: installed
                }
                e_count = e_count + 1
            end if
        end if
        j = j + 1
    end while

    // Compact to actual size
    mut entries: [ProfileEntry] = new [ProfileEntry](e_count)
    mut m: int = 0
    while m < e_count
        entries[m] = entries_buf[m]
        m = m + 1
    end while
    return entries
end list_all
```

- [ ] **Step 2: Build**

```bash
make build
```

Expected: clean build.

- [ ] **Step 3: Commit**

```bash
hg add src/profile.reef
hg commit -m "profile: list_all — enumerate user + vendor dirs with shadow handling"
```

---

## Task 13: `profile.install` — render + apply via incus.profile_create_or_edit

**Files:**
- Modify: `src/profile.reef` (replace install stub)

- [ ] **Step 1: Implement install**

Replace the stub:

```reef
fn install(name: string, home_dir: string, host: HostFacts): rg.Result[bool, string]
    // Resolve the file (user or vendor)
    let path_r = lookup(name, home_dir)
    if rg.is_err(path_r)
        return @Result[bool, string].Err(rg.unwrap_err(path_r))
    end if
    let path: string = rg.unwrap_ok(path_r)

    // Read the file
    let yaml: string = iofile.readFile(path)
    if str.length(yaml) == 0
        return @Result[bool, string].Err("empty or unreadable profile file: " + path)
    end if

    // Render — but if HOST_LAN_IP is needed and host.lan_ip is empty, fail with a hint
    if str.contains(yaml, "${HOST_LAN_IP}") and str.length(host.lan_ip) == 0
        return @Result[bool, string].Err("profile " + name + " requires ${HOST_LAN_IP} but [host].lan_ip is empty in registry. Run 'repoman setup' to detect and store the host LAN IP.")
    end if
    let rendered: string = render(yaml, host)

    // Apply via incus
    return incus.profile_create_or_edit("default", name, rendered)
end install
```

- [ ] **Step 2: Build**

```bash
make build
```

Expected: clean build.

- [ ] **Step 3: Commit**

```bash
hg add src/profile.reef
hg commit -m "profile: install — resolve, render, apply via incus profile_create_or_edit"
```

---

## Task 14: `profile.remove_profile` and `profile.show`

**Files:**
- Modify: `src/profile.reef`

- [ ] **Step 1: Implement remove_profile and show**

Replace the stubs:

```reef
fn remove_profile(name: string): rg.Result[bool, string]
    let pid: int = process_run_silent_via_incus(name)  // helper below
    // Note: we use incus.run_incus directly for clarity here.
    return incus_delete_profile(name)
end remove_profile
```

Actually, simpler: avoid the indirection. Replace with a direct call:

```reef
fn remove_profile(name: string): rg.Result[bool, string]
    return incus_run_remove(name)
end remove_profile
```

Hmm, neither is cleanest. Use the existing incus public surface or extend it.

Let me reconsider — `src/incus.reef` already has `delete_container`. Add a sibling `delete_profile`:

(This step requires a small addition in `src/incus.reef`. Take this detour now.)

In `src/incus.reef`, add to the export block:

```reef
    fn delete_profile(project: string, name: string): rg.Result[bool, string]
```

And add the function near `delete_container`:

```reef
fn delete_profile(project: string, name: string): rg.Result[bool, string]
    return run_incus(["profile", "delete", "--project", project, name])
end delete_profile
```

Now back to `src/profile.reef`:

```reef
fn remove_profile(name: string): rg.Result[bool, string]
    return incus.delete_profile("default", name)
end remove_profile

fn show(name: string, home_dir: string, host: HostFacts): rg.Result[string, string]
    let path_r = lookup(name, home_dir)
    if rg.is_err(path_r)
        return @Result[string, string].Err(rg.unwrap_err(path_r))
    end if
    let path: string = rg.unwrap_ok(path_r)
    let yaml: string = iofile.readFile(path)
    let rendered: string = render(yaml, host)
    return @Result[string, string].Ok(rendered)
end show
```

- [ ] **Step 2: Build**

```bash
make build
```

Expected: clean build.

- [ ] **Step 3: Commit**

```bash
hg add src/profile.reef src/incus.reef
hg commit -m "profile+incus: remove_profile, show; add incus.delete_profile wrapper"
```

---

## Task 15: `profile.diff` — render file vs incus state

**Files:**
- Modify: `src/profile.reef`

- [ ] **Step 1: Implement diff**

Replace the stub:

```reef
// Compute and return a string describing differences between the rendered
// file and the live incus profile state. v0.4 uses a simple line-by-line
// presentation: "  same line" / "- removed line" / "+ added line". A more
// sophisticated unified diff is out of scope; the use case is "show me
// what's drifted" not "produce a patch."
fn diff(name: string, home_dir: string, host: HostFacts): rg.Result[string, string]
    // Render the file
    let path_r = lookup(name, home_dir)
    if rg.is_err(path_r)
        return @Result[string, string].Err(rg.unwrap_err(path_r))
    end if
    let path: string = rg.unwrap_ok(path_r)
    let yaml: string = iofile.readFile(path)
    let rendered: string = render(yaml, host)

    // Fetch incus state
    let incus_r = incus.profile_get("default", name)
    if rg.is_err(incus_r)
        // Profile not installed — diff shows the rendered file as "would install"
        let msg: string = "profile " + name + " is not installed. Rendered would-be content:\n\n" + rendered
        return @Result[string, string].Ok(msg)
    end if
    let live: string = rg.unwrap_ok(incus_r)

    if rendered == live
        return @Result[string, string].Ok("(no drift)")
    end if

    // Simple line-mark diff: file lines marked '-', incus lines marked '+'.
    // This is not a unified diff; it's a side-by-side hint.
    let header: string = "--- profile file (rendered): " + path + "\n+++ incus state\n"
    let body: string = "-- file --\n" + rendered + "\n-- incus --\n" + live
    return @Result[string, string].Ok(header + body)
end diff
```

- [ ] **Step 2: Build**

```bash
make build
```

Expected: clean build.

- [ ] **Step 3: Commit**

```bash
hg add src/profile.reef
hg commit -m "profile: diff — show rendered file vs live incus state"
```

---

## Task 16: Trim `setup.reef` part 1 — Environment struct, detect_environment, drop helpers

**Files:**
- Modify: `src/setup.reef`
- Delete: `tests/test_setup_template.reef`

- [ ] **Step 1: Trim Environment struct**

In `src/setup.reef`, change:

```reef
type Environment = struct
    home_dir:             string
    user:                 string
    host_lan_ip:          string
    incus_reachable:      bool
    repoman_project_present: bool
    claude_share_present: bool
    ollama_binary:        string
    ollama_lan_ok:        bool
    hermes_binary:        string
    hermes_data_present:  bool
end Environment
```

to:

```reef
type Environment = struct
    home_dir:                 string
    user:                     string
    host_lan_ip:              string
    incus_reachable:          bool
    repoman_project_present:  bool
end Environment
```

- [ ] **Step 2: Trim detect_environment**

Replace the body to only populate the 5 fields:

```reef
fn detect_environment(home_dir: string): Environment
    let user: string = env.get_env_or("USER", "")
    let lan_ip: string = detect_host_lan_ip()

    // incus reachable?
    let incus_pid: int = incus.process_run_silent("incus", ["version"])
    mut incus_ok: bool = false
    if incus_pid >= 0
        incus_ok = p.process_wait(incus_pid) == 0
    end if

    // repoman project (only if incus is up)
    mut project_ok: bool = false
    if incus_ok
        let pe = incus.project_present("repoman")
        if rg.is_ok(pe)
            project_ok = rg.unwrap_ok(pe)
        end if
    end if

    return Environment {
        home_dir:                 home_dir,
        user:                     user,
        host_lan_ip:              lan_ip,
        incus_reachable:          incus_ok,
        repoman_project_present:  project_ok
    }
end detect_environment
```

- [ ] **Step 3: Remove helper functions which_binary, ollama_listening_at**

Both helpers are no longer used. Delete them entirely from `src/setup.reef`.

- [ ] **Step 4: Remove render_llm_share_template and template_contains_placeholder**

Delete both functions and remove them from the export block.

- [ ] **Step 5: Delete the test that exercised the now-removed render**

```bash
hg rm tests/test_setup_template.reef
```

- [ ] **Step 6: Build and test**

```bash
make build
make test
```

Expected: clean build. The setup_planner test will likely still pass (its 3 fixtures construct minimal Environments). If any test references the removed Environment fields, fix the test.

- [ ] **Step 7: Commit**

```bash
hg add src/setup.reef tests/
hg commit -m "setup: trim Environment to 5 fields; drop render_llm_share_template + which_binary + ollama_listening_at"
```

---

## Task 17: Trim `setup.reef` part 2 — plan_stages, apply_stage, planner test

**Files:**
- Modify: `src/setup.reef`
- Test: `tests/test_setup_planner.reef` (update)

- [ ] **Step 1: Update test_setup_planner.reef to expect 2 stages**

The test currently asserts 3-or-4 stages. Replace with 2 stages always:

```reef
import setup
import test.framework

proc main()
    let runner = new framework.TestRunner()

    // Fresh host
    let fresh = setup.Environment {
        home_dir: "/home/ctusa", user: "ctusa",
        host_lan_ip: "192.168.168.42",
        incus_reachable: true,
        repoman_project_present: false
    }
    let stages = setup.plan_stages(fresh)
    runner.assert_eq_int(stages.length(), 2, "always 2 stages: incus_project + registry_defaults")
    runner.assert_eq_string(stages[0].id, "incus_project", "stage 0 = incus_project")
    runner.assert_eq_bool(stages[0].is_change, true, "incus_project will change on fresh host")
    runner.assert_eq_string(stages[1].id, "registry_defaults", "stage 1 = registry_defaults")
    runner.assert_eq_bool(stages[1].is_change, true, "registry_defaults always writes")

    // Already-set-up host
    let done = setup.Environment {
        home_dir: "/home/ctusa", user: "ctusa",
        host_lan_ip: "192.168.168.42",
        incus_reachable: true,
        repoman_project_present: true
    }
    let s2 = setup.plan_stages(done)
    runner.assert_eq_int(s2.length(), 2, "still 2 stages")
    runner.assert_eq_bool(s2[0].is_change, false, "incus_project no-op when present")

    runner.report()
end main
```

- [ ] **Step 2: Update plan_stages signature and body**

`plan_stages` no longer takes `with_llm` (the flag is gone). Trim:

```reef
fn plan_stages(env: Environment): [Stage]
    mut stages: [Stage] = new [Stage](2)
    stages[0] = Stage {
        id:          "incus_project",
        description: "ensure Incus project 'repoman' exists",
        is_change:   not env.repoman_project_present
    }
    stages[1] = Stage {
        id:          "registry_defaults",
        description: "write registry defaults (schema 3, [host].lan_ip)",
        is_change:   true
    }
    return stages
end plan_stages
```

Update the export-block declaration to match:

```reef
    fn plan_stages(env: Environment): [Stage]
```

- [ ] **Step 3: Trim apply_stage to handle only incus_project and registry_defaults**

In `src/setup.reef`, replace `apply_stage`'s body:

```reef
fn apply_stage(stage: Stage, env: Environment, reg: config.Registry): rg.Result[config.Registry, string]
    if stage.id == "incus_project"
        if env.repoman_project_present
            return @Result[config.Registry, string].Ok(reg)
        end if
        let r = incus.project_ensure("repoman", false)
        if rg.is_err(r)
            return @Result[config.Registry, string].Err(rg.unwrap_err(r))
        end if
        return @Result[config.Registry, string].Ok(reg)
    end if

    if stage.id == "registry_defaults"
        // Update [host].lan_ip from detected env
        let new_host = config.Host {
            lan_ip: env.host_lan_ip
        }
        let new_reg = config.Registry {
            schema:   3,
            host:     new_host,
            output:   reg.output,
            defaults: reg.defaults,
            projects: reg.projects
        }
        return @Result[config.Registry, string].Ok(new_reg)
    end if

    return @Result[config.Registry, string].Err("unknown stage id: " + stage.id)
end apply_stage
```

The previous llm_share_profile and claude_share_check stages are gone.

- [ ] **Step 4: Build and test**

```bash
make build
make test
```

Expected: clean. test_setup_planner now passes its updated assertions.

- [ ] **Step 5: Commit**

```bash
hg add src/setup.reef tests/test_setup_planner.reef
hg commit -m "setup: plan_stages always emits 2 stages; apply_stage handles only incus_project + registry_defaults"
```

---

## Task 18: Trim `setup.reef` part 3 — cmd_setup flags and prompt

**Files:**
- Modify: `src/setup.reef` (cmd_setup function)

- [ ] **Step 1: Drop --with-llm and --without-llm flags from cmd_setup**

In `src/setup.reef`, find `cmd_setup`. Remove these lines:

```reef
    let _f2 = flag.bool_flag(parser, "with-llm",        '\0', false, "include the LLM stack ...")
    let _f3 = flag.bool_flag(parser, "without-llm",     '\0', false, "skip the LLM stack")
```

And remove the LLM-decision block:

```reef
    let with_llm_flag: bool = flag.get_bool(parser, "with-llm")
    let without_llm_flag: bool = flag.get_bool(parser, "without-llm")
    if with_llm_flag and without_llm_flag
        ...
    end if
    mut with_llm: bool = false
    if with_llm_flag
        ...
    end if
    ...prompt for LLM...
```

- [ ] **Step 2: Update calls to plan_stages and the success message**

Wherever cmd_setup currently calls `plan_stages(env_snap, with_llm)`, change to `plan_stages(env_snap)` (no second arg).

Remove the conditional success-hint about `--hermes`:

```reef
    // DELETE this block:
    if reg.defaults.llm.enabled
        println("        repoman new <name> --hermes")
    end if
```

(Note: `reg.defaults.llm` doesn't exist anymore; this would be a compile error after T1 anyway.)

The final success message becomes:

```reef
    println("")
    println("setup complete.")
    println("")
    println("  next: repoman profile install --all   (install vendor profile library)")
    println("        repoman new <name>")
    println("        repoman list")
```

- [ ] **Step 3: Update cmd_setup's flag.description and the print_usage entry in cli.reef**

In `src/setup.reef` cmd_setup, update:

```reef
    flag.description(parser, "First-time host bootstrap: incus project, registry, host LAN IP detection")
```

(`cli.reef` print_usage update is in T20.)

- [ ] **Step 4: Build and test**

```bash
make build
make test
```

Expected: clean.

- [ ] **Step 5: Commit**

```bash
hg add src/setup.reef
hg commit -m "setup: cmd_setup drops --with-llm/--without-llm flags; success hint points to profile install"
```

---

## Task 19: `cmd_new` pre-launch profile validation

**Files:**
- Modify: `src/cli.reef` (cmd_new)

- [ ] **Step 1: Insert validation block before `incus.launch`**

In `cmd_new`, find the line:

```reef
    log.write("==> incus launch " + eff.image + " " + name)
    let lr = incus.launch(reg.defaults.incus_project, name, eff.image, eff.profiles)
```

Immediately before `log.write("==> incus launch ...")`, add:

```reef
    // Pre-launch profile validation: every name in eff.profiles must either be
    // the magic incus 'default' profile, or installed in the 'default' project.
    // (Repoman-managed profiles all live in 'default' per the v0.4 architecture.)
    let pn2: int = eff.profiles.length()
    mut pi: int = 0
    while pi < pn2
        let pname: string = eff.profiles[pi]
        if pname != "default"
            let exists_r = incus.profile_exists("default", pname)
            if rg.is_ok(exists_r) and not rg.unwrap_ok(exists_r)
                log.write("repoman: error: container references profile '" + pname + "' but it's not installed in incus.")
                log.write("hint: repoman profile install " + pname)
                log.write("hint: repoman profile install --all   (to install the vendor library)")
                return 4
            end if
        end if
        pi = pi + 1
    end while
```

- [ ] **Step 2: Build**

```bash
make build
```

Expected: clean build.

- [ ] **Step 3: Test by inspection**

Set up a fixture: registry with `[defaults].profiles = ["default", "missing-profile"]`. Run `repoman new test`. Expect exit 4 with the hint.

(Manual smoke; no unit test for cmd_new because it's effectful.)

- [ ] **Step 4: Commit**

```bash
hg add src/cli.reef
hg commit -m "cli: cmd_new pre-launch validation — error early if profile not installed"
```

---

## Task 20: `cli.cmd_profile` dispatch for 5 verbs

**Files:**
- Modify: `src/cli.reef`

- [ ] **Step 1: Add cmd_profile to imports and exports**

In `src/cli.reef`, add to imports near the top:

```reef
import profile
```

Add `cmd_profile` to the export block:

```reef
    fn cmd_profile(argv: [string]): int
```

- [ ] **Step 2: Implement cmd_profile (dispatches on verb)**

After `cmd_setup`, add `cmd_profile`. Each verb is its own helper:

```reef
fn cmd_profile(argv: [string]): int
    if argv.length() == 0
        console.printErr("repoman: error: 'profile' requires a subcommand: list | install | diff | remove | show")
        return 2
    end if
    let verb: string = argv[0]
    // Slice argv[1..] for the verb's own parser
    let n: int = argv.length()
    mut rest: [string] = new [string](n - 1)
    mut i: int = 0
    while i < n - 1
        rest[i] = argv[i + 1]
        i = i + 1
    end while

    if verb == "list"
        return cmd_profile_list(rest)
    end if
    if verb == "install"
        return cmd_profile_install(rest)
    end if
    if verb == "diff"
        return cmd_profile_diff(rest)
    end if
    if verb == "remove"
        return cmd_profile_remove(rest)
    end if
    if verb == "show"
        return cmd_profile_show(rest)
    end if
    console.printErr("repoman: error: unknown profile subcommand: " + verb)
    return 2
end cmd_profile

fn build_host_facts(): profile.HostFacts
    let home: string = env.get_env_or("HOME", "")
    let user: string = env.get_env_or("USER", "")
    let reg_r = config.load_or_init(home)
    mut lan_ip: string = ""
    if rg.is_ok(reg_r)
        lan_ip = rg.unwrap_ok(reg_r).host.lan_ip
    end if
    return profile.HostFacts {
        lan_ip: lan_ip,
        user:   user,
        home:   home
    }
end build_host_facts

fn cmd_profile_list(argv: [string]): int
    let home: string = env.get_env_or("HOME", "")
    let entries = profile.list_all(home)
    println("NAME              SOURCE                  INSTALLED")
    let n: int = entries.length()
    mut i: int = 0
    while i < n
        let e = entries[i]
        mut inst: string = "no"
        if e.installed
            inst = "yes"
        end if
        println(e.name + "    " + e.source + "    " + inst)
        i = i + 1
    end while
    return 0
end cmd_profile_list

fn cmd_profile_install(argv: [string]): int
    let host = build_host_facts()
    let home: string = host.home
    if argv.length() == 0
        console.printErr("repoman: error: 'profile install' requires <name> or --all")
        return 2
    end if
    if argv[0] == "--all"
        let entries = profile.list_all(home)
        let n: int = entries.length()
        mut i: int = 0
        mut errs: int = 0
        while i < n
            let e = entries[i]
            println("==> install " + e.name + " (source: " + e.source + ")")
            let r = profile.install(e.name, home, host)
            if rg.is_err(r)
                console.printErr("  error: " + rg.unwrap_err(r))
                errs = errs + 1
            end if
            i = i + 1
        end while
        if errs > 0
            return 1
        end if
        return 0
    end if
    let name: string = argv[0]
    let r = profile.install(name, home, host)
    if rg.is_err(r)
        console.printErr("repoman: error: " + rg.unwrap_err(r))
        return 1
    end if
    println("==> installed " + name)
    return 0
end cmd_profile_install

fn cmd_profile_diff(argv: [string]): int
    if argv.length() == 0
        console.printErr("repoman: error: 'profile diff' requires <name>")
        return 2
    end if
    let host = build_host_facts()
    let r = profile.diff(argv[0], host.home, host)
    if rg.is_err(r)
        console.printErr("repoman: error: " + rg.unwrap_err(r))
        return 3
    end if
    println(rg.unwrap_ok(r))
    return 0
end cmd_profile_diff

fn cmd_profile_remove(argv: [string]): int
    if argv.length() == 0
        console.printErr("repoman: error: 'profile remove' requires <name>")
        return 2
    end if
    let r = profile.remove_profile(argv[0])
    if rg.is_err(r)
        console.printErr("repoman: error: " + rg.unwrap_err(r))
        return 1
    end if
    println("==> removed " + argv[0] + " from incus")
    return 0
end cmd_profile_remove

fn cmd_profile_show(argv: [string]): int
    if argv.length() == 0
        console.printErr("repoman: error: 'profile show' requires <name>")
        return 2
    end if
    let host = build_host_facts()
    let r = profile.show(argv[0], host.home, host)
    if rg.is_err(r)
        console.printErr("repoman: error: " + rg.unwrap_err(r))
        return 3
    end if
    println(rg.unwrap_ok(r))
    return 0
end cmd_profile_show
```

- [ ] **Step 3: Wire into the dispatcher**

Find the `dispatch` function. Add a `profile` arm in alphabetical position (between `new` and `remove`, or wherever fits):

```reef
    if cmd == "profile"
        return cmd_profile(rest)
    end if
```

- [ ] **Step 4: Update print_usage**

Add `profile` to the help text:

```reef
    console.printErr("  profile {list|install|diff|remove|show} [<name>] [--all]")
    console.printErr("      Manage Incus profiles from the repoman library.")
    console.printErr("")
```

Place it after `new`'s entry, before `remove`'s entry (alphabetical).

Also update setup's help text to drop the `--with-llm` mention:

```reef
    console.printErr("  setup [--non-interactive]")
    console.printErr("      First-time host bootstrap: incus project + registry.")
```

- [ ] **Step 5: Build and verify**

```bash
make build
./build/repoman --help
./build/repoman profile
```

Expected: `--help` shows the `profile` line; `profile` (no verb) prints the error message.

- [ ] **Step 6: Commit**

```bash
hg add src/cli.reef
hg commit -m "cli: cmd_profile dispatch for list/install/diff/remove/show; update print_usage"
```

---

## Task 21: Author the three vendor profile YAML files

**Files:**
- Create: `profiles/claude-share.yml`
- Create: `profiles/llm-share.yml`
- Create: `profiles/dotfiles.yml`

- [ ] **Step 1: Create profiles directory and the three YAML files**

```bash
mkdir -p profiles
```

Create `profiles/claude-share.yml`:

```yaml
name: claude-share
description: Share host's Claude CLI state (auth, history, plugins) into containers.
config: {}
devices:
  claude-state:
    type: disk
    source: ${HOME}/.claude
    path: ${HOME}/.claude
    shift: "true"
  claude-bin:
    type: disk
    source: ${HOME}/.local/bin/claude
    path: /usr/local/bin/claude
    readonly: "true"
    shift: "true"
```

Create `profiles/llm-share.yml`:

```yaml
name: llm-share
description: Wire containers to the host ollama daemon over LAN.
config:
  environment.OLLAMA_HOST: "http://${HOST_LAN_IP}:11434"
devices:
  ollama-bin:
    type: disk
    source: /usr/local/bin/ollama
    path: /usr/local/bin/ollama
    readonly: "true"
  ollama-state:
    type: disk
    source: ${HOME}/.ollama
    path: ${HOME}/.ollama
    shift: "true"
```

Create `profiles/dotfiles.yml`:

```yaml
name: dotfiles
description: Bind common host dotfiles (.gitconfig, .hgrc) into containers.
config: {}
devices:
  gitconfig:
    type: disk
    source: ${HOME}/.gitconfig
    path: ${HOME}/.gitconfig
    readonly: "true"
    shift: "true"
  hgrc:
    type: disk
    source: ${HOME}/.hgrc
    path: ${HOME}/.hgrc
    readonly: "true"
    shift: "true"
```

- [ ] **Step 2: Commit**

```bash
hg add profiles/claude-share.yml profiles/llm-share.yml profiles/dotfiles.yml
hg commit -m "profiles: vendor library — claude-share, llm-share, dotfiles"
```

---

## Task 22: `Makefile` — install profiles to `/usr/local/share/repoman/profiles/`

**Files:**
- Modify: `Makefile`

- [ ] **Step 1: Update install/uninstall targets**

Replace the existing `install:` and `uninstall:` targets:

```makefile
PREFIX ?= /usr/local
BINDIR  = $(PREFIX)/bin
SHAREDIR = $(PREFIX)/share/repoman
PROFILES_DIR = $(SHAREDIR)/profiles
DESTDIR ?=

.PHONY: all build test clean install uninstall

all: build

build:
	reefc build

test:
	@for t in tests/test_*.reef; do \
		echo "== $$t =="; \
		reefc run "$$t" || exit 1; \
	done

clean:
	reefc clean

install: build
	install -d $(DESTDIR)$(BINDIR)
	install -m 0755 build/repoman $(DESTDIR)$(BINDIR)/repoman
	install -d $(DESTDIR)$(PROFILES_DIR)
	install -m 0644 profiles/*.yml $(DESTDIR)$(PROFILES_DIR)/

uninstall:
	rm -f $(DESTDIR)$(BINDIR)/repoman
	rm -rf $(DESTDIR)$(PROFILES_DIR)
```

- [ ] **Step 2: Test the install path locally**

```bash
sudo make install
ls /usr/local/share/repoman/profiles/
```

Expected: `claude-share.yml`, `dotfiles.yml`, `llm-share.yml`.

- [ ] **Step 3: Commit**

```bash
hg add Makefile
hg commit -m "Makefile: install profiles/*.yml to /usr/local/share/repoman/profiles/"
```

---

## Task 23: README + VISION + reef.toml + version_string updates

**Files:**
- Modify: `reef.toml`
- Modify: `src/cli.reef` (version_string)
- Modify: `README.md`
- Modify: `VISION.md`

- [ ] **Step 1: Bump version**

In `reef.toml`, change `version = "0.3.0"` to `version = "0.4.0"`.

In `src/cli.reef`, find `version_string()` and change `"repoman 0.3.0"` to `"repoman 0.4.0"`.

- [ ] **Step 2: README — add profile library section, drop --with-llm**

In `README.md`, find the `## Setup wizard` and `## Local LLM stack` sections from v0.3. Replace them with:

```markdown
## Setup wizard

First-time host bootstrap (idempotent — safe to re-run):

    repoman setup                     # interactive
    repoman setup --non-interactive   # accept defaults

The wizard creates the Incus project `repoman`, detects your host LAN IP (used by
profiles that wire containers to host services), and writes the initial registry.

After setup, install the vendor profile library:

    repoman profile install --all

## Profile library

repoman ships a vendor profile library at `/usr/local/share/repoman/profiles/`.
v0.4 includes three profiles:

- **`claude-share`** — bind `~/.claude` and `~/.local/bin/claude` into containers
  so they share the host's Claude CLI auth, history, and plugins.
- **`llm-share`** — bind `/usr/local/bin/ollama` and `~/.ollama` into containers,
  set `OLLAMA_HOST=http://<host-lan-ip>:11434` so containers reach the host's
  ollama daemon over LAN.
- **`dotfiles`** — bind `~/.gitconfig` and `~/.hgrc` (extend with your own).

Manage profiles via `repoman profile`:

    repoman profile list
    repoman profile install <name>
    repoman profile install --all
    repoman profile diff <name>            # show drift between file and incus state
    repoman profile show <name>            # print rendered YAML
    repoman profile remove <name>          # remove from incus (file untouched)

### Customizing profiles

To override a vendor profile, copy it to your user dir and edit:

    cp /usr/local/share/repoman/profiles/dotfiles.yml ~/.config/repoman/profiles.d/
    $EDITOR ~/.config/repoman/profiles.d/dotfiles.yml
    repoman profile install dotfiles       # applies the user version (shadows vendor)

User profiles in `~/.config/repoman/profiles.d/` always win over vendor profiles
of the same name. `repoman profile list` shows the active source for each.

## Migrating from v0.3

If you ran `repoman setup --with-llm` on v0.3, your registry is at schema 2 and
includes a `[defaults.llm]` block. v0.4 reads that block on first load, extracts
the ollama_url's host portion into `[host].lan_ip`, and writes the registry as
schema 3 on the next save. No action required — the migration is automatic and
lossless for the one fact that's still useful.

The `--hermes` / `--no-hermes` / `--purge-hermes` flags are gone. Hermes (and
similar agents that need per-container installs) is now the user's responsibility:
shell into the container with `repoman shell <name>` and run the install yourself.
v0.5+ may revisit this if real demand surfaces.
```

- [ ] **Step 3: VISION.md — update setup row, add profile management**

In `VISION.md`, find the subcommand table. Update the `repoman setup` row description and add a `repoman profile` row:

```markdown
| `repoman setup` | First-time host setup. Creates Incus project `repoman`, detects host LAN IP, writes initial registry. Idempotent. **Shipped in v0.3, trimmed in v0.4 (no longer touches profiles or LLM stack).** |
| `repoman profile {list, install, diff, remove, show}` | Manage Incus profiles from the vendor library at `/usr/local/share/repoman/profiles/` and the user override dir at `~/.config/repoman/profiles.d/`. **Shipped in v0.4.** |
```

- [ ] **Step 4: Build and test**

```bash
make build
./build/repoman --version    # repoman 0.4.0
./build/repoman --help       # shows setup (no --with-llm), profile, etc.
make test
```

Expected: clean.

- [ ] **Step 5: Commit**

```bash
hg add reef.toml src/cli.reef README.md VISION.md
hg commit -m "docs: v0.4 — profile library section, migration guide; bump version"
```

---

## Task 24: End-to-end smoke test

Manual gate before tagging v0.4.0.

**Files:** (no code changes — manual verification)

- [ ] **Step 1: Install and verify version**

```bash
cd ~/repos/repoman
sudo make install
repoman --version              # repoman 0.4.0
ls /usr/local/share/repoman/profiles/   # three YAML files
```

- [ ] **Step 2: Run setup**

```bash
repoman setup --non-interactive
cat ~/.config/repoman/repoman.toml | grep -E 'schema|host|lan_ip'
```

Expected: `schema = 3`, `[host]` block, `lan_ip = "<your br0 IP>"`.

- [ ] **Step 3: Install vendor profile library**

```bash
repoman profile install --all
incus profile list --project default | grep -E 'claude-share|llm-share|dotfiles'
```

Expected: all three profiles present in incus.

- [ ] **Step 4: Verify rendered profiles**

```bash
incus profile show --project default llm-share | grep OLLAMA_HOST
# expect: environment.OLLAMA_HOST: http://<your-lan-ip>:11434

incus profile show --project default claude-share | grep '/home/'
# expect: /home/<your-user>/.claude paths
```

- [ ] **Step 5: Test profile diff and show**

```bash
repoman profile show claude-share | head -20
repoman profile diff claude-share
# expect "(no drift)" since we just installed it
```

- [ ] **Step 6: Test pre-launch validation**

Edit `~/.config/repoman/repoman.toml` to add `"missing-profile"` to `[defaults].profiles`. Then:

```bash
mkdir -p ~/repos/smoke-test
echo "smoke" > ~/repos/smoke-test/README.md
repoman new smoke-test
```

Expected: error with hint `repoman profile install missing-profile`. Container is NOT launched.

Revert the registry change.

- [ ] **Step 7: Successful container creation with profiles**

```bash
# Edit registry to use ["default", "claude-share", "llm-share", "dotfiles"] in [defaults].profiles
# OR write an override at ~/.config/repoman/repos.d/smoke-test.toml
repoman new smoke-test
repoman shell smoke-test
# Inside container:
ls -la ~/.claude        # bind-mounted from host
ls -la ~/.gitconfig     # readable
ollama list             # talks to host daemon
exit
```

Expected: all three profiles working.

- [ ] **Step 8: Test user-shadow customization**

```bash
mkdir -p ~/.config/repoman/profiles.d
cp /usr/local/share/repoman/profiles/dotfiles.yml ~/.config/repoman/profiles.d/
# Edit to add a custom bind, e.g., ~/.zshrc
repoman profile install dotfiles
repoman profile list | grep dotfiles
# expect: source = "user (shadows vendor)"
```

- [ ] **Step 9: Cleanup and tag**

```bash
repoman remove smoke-test --yes
rm -rf ~/repos/smoke-test
hg tag v0.4.0
hg log -r tip --template "{node|short} {desc|firstline}\n"
```

If everything in steps 1-8 worked, **v0.4.0 ships.**

---

## Self-review

### Spec coverage check

- §1 In-scope items:
  - Profile library layout (vendor + user) → T9, T10, T11, T12
  - `repoman profile {list, install, diff, remove, show}` → T13, T14, T15, T20
  - Templated YAML with `${VAR}` → T10
  - `[host].lan_ip` field → T1, T2, T17
  - Schema 2 → 3 migration → T2, T4, T5, T6
  - Three vendor profiles → T21
  - Pre-launch validation → T19
- §1 Trims:
  - `--with-llm` / `--without-llm` flags removed → T18
  - ollama LAN-listening check removed → T16
  - `[defaults].llm` block removed → T1, T3
  - String template embedded in setup.reef removed → T16
  - apply_stage llm_share_profile and registry_defaults trim → T17
- §2 Architecture:
  - `src/profile.reef` new module → T9–T15
  - `setup.reef` edits → T16, T17, T18
  - `cli.reef` edits → T19, T20
  - `config.reef` edits → T1, T2, T3, T4, T5
  - `incus.reef` `profile_get` and `delete_profile` → T8, T14
- §3 Data shapes — all covered (Schema 3, profile YAMLs, ProfileEntry, HostFacts)
- §4 Subcommand flows — all covered
- §5 Testing — pure tests in T6, T10; smoke in T24
- §7 Decisions — locked into spec, not implementation tasks

### Placeholder scan

No "TBD"/"TODO" in the plan. Each step has either runnable shell or complete reef code.

### Type-consistency check

- `Host` struct: `lan_ip: string` consistently across T1, T2, T6, T17.
- `Registry` field order: `schema, host, output, defaults, projects` — used consistently in T1, T6, T17.
- `ProfileEntry` fields: name, source, file_path, installed — consistent T9, T12, T20.
- `HostFacts` fields: lan_ip, user, home — consistent T9, T10, T13, T14, T15, T20.
- `profile.remove_profile` (NOT `profile.remove`) — consistent T9, T14, T20.
- `incus.delete_profile` (new in T14) used in T14.
- `incus.profile_get` (new in T8) used in T15.
- `plan_stages(env)` (no `with_llm` arg after T17) — used in T17, T18.
- `apply_stage(stage, env, reg)` signature — unchanged, used in T17.

### Task ordering / dependency check

T1–T7 (config schema): linear sequence; each builds on the prior. ✓
T8 (incus.profile_get): independent; can land anywhere before T15. ✓
T9–T15 (profile module): T9 stub first, T10 paths/render, T11 lookup uses paths, T12 list_all uses lookup, T13 install uses lookup+render, T14 remove (small) + show (uses lookup+render), T15 diff (uses everything). ✓
T16–T18 (setup trim): each builds on the prior — T16 first (struct + helpers), T17 next (planner), T18 last (CLI integration). ✓
T19 (cmd_new validation): independent; needs T8 indirectly via incus.profile_exists which already exists. ✓
T20 (cmd_profile dispatch): depends on profile module (T9–T15) being complete. ✓
T21 (vendor YAMLs): independent file authoring. ✓
T22 (Makefile): depends on T21 (files must exist). ✓
T23 (docs + version): can land anywhere; placed near end as polish. ✓
T24 (smoke): final gate. ✓

No circular deps.

---

## Deferred (v0.5+ candidates)

- `repoman profile diff --vendor` (shadow-vs-vendor diff): noted in spec §7 as resolved decision; implementation deferred.
- `optional: true` on bind-mount sources for dotfiles (so missing files don't break launch): blocked on incus version support; reassess at v0.5.
- Smarter unified diff in `profile diff` instead of side-by-side: low value for v0.4 use cases.
- Setup wizard configuring ollama (installer + systemd override): explicitly rejected as out of mission scope.
- Pre-built incus images (`repoman image {build, refresh, list, prune}`): rejected as maintenance overhead.
- Per-project `[install]` block in override files: rejected — shell scripts in user repos serve this need.
