# repoman v0.1 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:** Ship a reef-lang implementation of `repoman new <name>` and `repoman sync [name]` with feature parity to the bash prototype at `~/.local/bin/repoman`, plus the v0.1-only differentiator: containers live under an Incus `project` namespace.

**Architecture:** Six modules under `src/`, no nesting: `main` (entry), `cli` (subcommand dispatch + flag parsing), `config` (TOML-backed registry + per-project overrides), `incus` (subprocess wrappers around the `incus` CLI), `sync` (NFS mount check + rsync invocation), `paths` (path helpers — pluralized to avoid colliding with stdlib's `io.path`). Pure logic lives in `config.merge_with_defaults`, `sync.build_rsync_args`, `incus.validate_name`, `paths.expand_home` — these get full unit-test coverage. Effectful wrappers (subprocess, file I/O, NFS check) are kept narrow and smoke-tested manually.

**Tech Stack:** reef-lang 0.5.10 (compiles to C → native binary), `encoding.toml` (TomlBuilder + TomlDoc), `core.result_generic` (`Result[T, string]` for errors), `sys.process.process_spawn` (argv-list subprocess — never the shell variant), `sys.flag.flag_parser_from` (sliced argv per subcommand), `test.framework.TestRunner` (active object with `assert_eq_*`, `assert_contains_string`).

---

## Reference: spec and source

- Design spec: `docs/superpowers/specs/2026-04-29-repoman-v0.1-design.md` (rev 3, locked)
- Bash prototype: `~/.local/bin/repoman` (behavioral source of truth for `new`/`sync`)
- Reef stdlib reference: `~/reef-lang-0.5.10-source/reef-stdlib/`
  - `test/framework.reef` — TestRunner API (note: `new framework.TestRunner()`, not `new TestRunner()`)
  - `encoding/toml.reef` — `toml_builder()`, `toml_set_*`, `toml_array_append_table`, `toml_render`, `toml_parse_doc`, `TomlDoc`
  - `io/path.reef` — `expand_home`, `join`, `dirname`, `basename`
  - `io/file.reef` — `readFile`, `writeFile`, `fileExists`, `rename`, `fsync`, `deleteFile`
  - `io/dir.reef` — `dir_exists`, `is_directory`, `create_dir_all`
  - `sys/flag.reef` — `flag_parser_from(args)`, `bool_flag`, `string_flag`, `parse`, `positional_args`
  - `sys/process.reef` — `process_spawn(prog, [args])`, `process_wait(pid)`, `process_exit_code()`
  - `sys/args.reef` — `count()`, `get(i)`, `program()`
  - `sys/env.reef` — `get_env(name)`, `get_env_or(name, default)`
  - `core/result_generic.reef` — `Result[T, E]`, construction `@Result[T, E].Ok(v)` / `@Result[T, E].Err(e)`, queries `is_ok(r)`, `is_err(r)`, `unwrap_ok(r)`, `unwrap_err(r)`
  - `core/str.reef` — `length`, `equals`, `contains`, `starts_with`, `ends_with`, `substring`, `split`, `join`

## File structure

```
~/repos/repoman/
├── reef.toml              # package manifest
├── Makefile               # install/uninstall wrapper for distro packagers
├── README.md              # quickstart + test loop + smoke recipe
├── VISION.md              # (already exists) design intent
├── .hgignore              # (already exists) build/, *.o, etc.
├── docs/                  # (already exists) specs + reef feedback
├── src/
│   ├── main.reef          # proc main() → cli.dispatch + exit
│   ├── cli.reef           # outer dispatch on argv[1]; per-subcommand parsers
│   ├── config.reef        # types + parse + serialize + load_or_init + atomic save
│   ├── incus.reef         # subprocess wrappers + validate_name (pure)
│   ├── sync.reef          # ensure_nfs_mounted + build_rsync_args (pure) + run
│   └── path.reef          # expand_home, join, exists, is_dir wrappers
└── tests/
    ├── test_path.reef
    ├── test_incus_validate.reef
    ├── test_config_parse.reef
    ├── test_config_serialize.reef
    ├── test_config_roundtrip.reef
    ├── test_config_merge.reef
    ├── test_sync_args.reef
    ├── test_config_io.reef         # uses temp dir
    └── test_config_save.reef       # uses temp dir
```

Module boundary rules:
- Each module file declares `module <name>` matching its filename.
- `main.reef` has no `module` declaration; it's the entry point.
- Tests are standalone reef programs (each has its own `proc main()`); they import production modules from `src/` (resolved automatically when run from project root).
- All `module <name>` blocks must end with `end module`.

---

## Task 1: Scaffold project

**Files:**
- Create: `reef.toml`, `src/main.reef`, `tests/.keep`
- Modify: `.hgignore` (add reef build artifacts)

- [ ] **Step 1: Initialize reef project in existing repo**

The repoman directory already exists with `VISION.md` and `docs/`. Run `reefc init` to add a `reef.toml` without disturbing existing files:

```bash
cd ~/repos/repoman
reefc init
```

Expected stdout: `Initialized Reef project in current directory` and `Created reef.toml`.

- [ ] **Step 2: Replace generated reef.toml with the repoman manifest**

The default `reef.toml` from `reefc init` is generic. Overwrite with this exact content:

```toml
[package]
name = "repoman"
version = "0.1.0"
author = "Chris Tusa <chris.tusa@leafscale.com>"
description = "Per-project Incus containers + opinionated NFS/ZFS backup"
license = "MIT"
url = ""

[build]
entry = "src/main.reef"
output = "repoman"
output_dir = "build"
source_dirs = ["src"]

[docs]
output = "docs/api"
include_private = false
```

Note: `tests/` is intentionally NOT in `source_dirs` — tests are standalone programs run via `reefc run tests/test_*.reef`, not bundled into the production binary.

- [ ] **Step 3: Create src/ and tests/ directories with placeholder main**

```bash
mkdir -p src tests
```

Create `src/main.reef` with this exact content (it will be replaced in Task 19, but we want a buildable skeleton now):

```reef
proc main()
    println("repoman 0.1.0 — not yet implemented")
end main
```

Create `tests/.keep` (empty file) so the directory is committed:

```bash
touch tests/.keep
```

- [ ] **Step 4: Update .hgignore for reef build artifacts**

Append to `.hgignore`:

```
build/
*.c
*.o
*.so
*.exe
docs/api/
```

(The `docs/api/` line covers reef's generated API docs.)

- [ ] **Step 5: Build and verify the skeleton runs**

Run from `~/repos/repoman/`:

```bash
reefc build
./build/repoman
```

Expected output: `repoman 0.1.0 — not yet implemented`. Build directory should appear at `~/repos/repoman/build/` and contain `repoman`.

- [ ] **Step 6: Commit**

```bash
hg add reef.toml src/main.reef tests/.keep
hg commit -m "scaffold: reef project skeleton + manifest"
```

---

## Task 2: paths module (`expand_home`, `join`, `exists`, `is_dir`)

> **Naming note:** the local module is named `paths` (plural), not `path`. Bare `path` collides with the stdlib's `io.path` and reef resolves to the stdlib module first, producing C-codegen errors at link time. Pluralizing avoids the collision; `paths.X` reads naturally at call sites.

**Files:**
- Create: `src/paths.reef`
- Test: `tests/test_paths.reef`

The `path` module is a thin facade over `io.path` and `io.dir`, named to keep call sites short. `expand_home` and `join` are direct re-exports of stdlib; `exists` and `is_dir` wrap `io.dir`. We test the re-exports too — they're trivial but anchor the test framework + module-import chain end-to-end before we write more.

- [ ] **Step 1: Write the failing test**

Create `tests/test_paths.reef`:

```reef
import paths
import test.framework
import sys.env

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

    // expand_home
    env.set_env("HOME", "/home/test")
    runner.assert_eq_string(paths.expand_home("~/foo"), "/home/test/foo", "~/foo expands")
    runner.assert_eq_string(paths.expand_home("~"), "/home/test", "bare ~ expands")
    runner.assert_eq_string(paths.expand_home("/abs/path"), "/abs/path", "abs path passthrough")
    runner.assert_eq_string(paths.expand_home("relative"), "relative", "relative passthrough")
    runner.assert_eq_string(paths.expand_home(""), "", "empty passthrough")

    // join
    runner.assert_eq_string(paths.join("/a", "b"), "/a/b", "join basic")
    runner.assert_eq_string(paths.join("/a/", "b"), "/a/b", "join trailing slash")
    runner.assert_eq_string(paths.join("/a", "/b"), "/a/b", "join leading slash")

    // exists / is_dir against a known dir
    runner.assert_eq_bool(paths.exists("/tmp"), true, "/tmp exists")
    runner.assert_eq_bool(paths.is_dir("/tmp"), true, "/tmp is dir")
    runner.assert_eq_bool(paths.exists("/this-does-not-exist-zzz"), false, "missing path → false")

    runner.report()
end main
```

- [ ] **Step 2: Run test to verify it fails**

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

Expected: compile error (module `path` not found).

- [ ] **Step 3: Write minimal implementation**

Create `src/paths.reef`:

```reef
module paths

import io.path as iopath
import io.dir as iodir
import io.file as iofile

export
    fn expand_home(p: string): string
    fn join(a: string, b: string): string
    fn exists(p: string): bool
    fn is_dir(p: string): bool
end export

fn expand_home(p: string): string
    return iopaths.expand_home(p)
end expand_home

fn join(a: string, b: string): string
    return iopaths.join(a, b)
end join

fn exists(p: string): bool
    if iofile.fileExists(p)
        return true
    end if
    return iodir.dir_exists(p)
end exists

fn is_dir(p: string): bool
    return iodir.is_directory(p)
end is_dir

end module
```

- [ ] **Step 4: Run test to verify it passes**

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

Expected: `Tests passed: 9`, `Tests failed: 0`, `All tests passed!`.

- [ ] **Step 5: Commit**

```bash
hg add src/paths.reef tests/test_paths.reef
hg commit -m "path: expand_home/join/exists/is_dir wrappers + tests"
```

---

## Task 3: incus.validate_name

**Files:**
- Create: `src/incus.reef`
- Test: `tests/test_incus_validate.reef`

`validate_name` is the pure half of the `incus` module. Container names follow Incus rules: lowercase alphanumeric + hyphens, must not start with a hyphen, ≤63 chars, must be non-empty. We populate the rest of the module (subprocess wrappers) in Task 13.

- [ ] **Step 1: Write the failing test**

Create `tests/test_incus_validate.reef`:

```reef
import incus
import test.framework

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

    runner.assert_eq_bool(incus.validate_name("foo"), true, "simple name")
    runner.assert_eq_bool(incus.validate_name("foo-bar"), true, "hyphenated")
    runner.assert_eq_bool(incus.validate_name("foo123"), true, "trailing digits")
    runner.assert_eq_bool(incus.validate_name("a"), true, "single char")

    runner.assert_eq_bool(incus.validate_name(""), false, "empty rejected")
    runner.assert_eq_bool(incus.validate_name("-foo"), false, "leading hyphen rejected")
    runner.assert_eq_bool(incus.validate_name("foo_bar"), false, "underscore rejected")
    runner.assert_eq_bool(incus.validate_name("foo.bar"), false, "dot rejected")
    runner.assert_eq_bool(incus.validate_name("Foo"), false, "uppercase rejected")
    runner.assert_eq_bool(incus.validate_name("foo bar"), false, "space rejected")

    // 63-char boundary (exactly 63 = ok, 64 = reject)
    let s63 = "abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabc"
    let s64 = "abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcd"
    runner.assert_eq_int(str.length(s63), 63, "s63 setup check")
    runner.assert_eq_int(str.length(s64), 64, "s64 setup check")
    runner.assert_eq_bool(incus.validate_name(s63), true, "63 chars accepted")
    runner.assert_eq_bool(incus.validate_name(s64), false, "64 chars rejected")

    runner.report()
end main
```

Note: this test imports `core.str` implicitly via `str.length` — add `import core.str` at the top:

```reef
import incus
import test.framework
import core.str
```

- [ ] **Step 2: Run test to verify it fails**

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

Expected: compile error (module `incus` not found).

- [ ] **Step 3: Write minimal implementation**

Create `src/incus.reef`:

```reef
module incus

import core.str

export
    fn validate_name(name: string): bool
end export

fn is_lower_alnum_or_hyphen(c: char): bool
    if c >= 'a' and c <= 'z'
        return true
    end if
    if c >= '0' and c <= '9'
        return true
    end if
    if c == '-'
        return true
    end if
    return false
end is_lower_alnum_or_hyphen

fn validate_name(name: string): bool
    let n: int = str.length(name)
    if n == 0
        return false
    end if
    if n > 63
        return false
    end if
    if name[0] == '-'
        return false
    end if
    mut i: int = 0
    while i < n
        if not is_lower_alnum_or_hyphen(name[i])
            return false
        end if
        i = i + 1
    end while
    return true
end validate_name

end module
```

- [ ] **Step 4: Run test to verify it passes**

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

Expected: all 14 assertions pass.

- [ ] **Step 5: Commit**

```bash
hg add src/incus.reef tests/test_incus_validate.reef
hg commit -m "incus: validate_name + boundary tests"
```

---

## Task 4: config types

**Files:**
- Create: `src/config.reef` (skeleton with type definitions only)

This task lays down the data types that subsequent tasks will populate. No functions yet — just the structs. We don't write a test for type definitions; the next task's test will exercise them.

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

Create `src/config.reef`:

```reef
module config

import core.str
import core.result_generic as rg
import encoding.toml as toml
import io.file as iofile
import io.dir as iodir
import io.path as iopath
import paths

export
    type Defaults
    type Project
    type Override
    type Mount
    type Registry
    type EffectiveConfig
end export

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

type Project = struct
    name: string
    repo: string
    image: string
    profiles: [string]
    created: string
    last_sync: string
    backup: bool
end Project

type Mount = struct
    source: string
    path: string
end Mount

type Override = struct
    image: string
    profiles: [string]
    has_profiles: bool
    mounts: [Mount]
    env_keys: [string]
    env_values: [string]
end Override

type Registry = struct
    schema: int
    defaults: Defaults
    projects: [Project]
end Registry

type EffectiveConfig = struct
    name: string
    repo: string
    repo_path: string
    image: string
    profiles: [string]
    mounts: [Mount]
    env_keys: [string]
    env_values: [string]
end EffectiveConfig

end module
```

Notes on the Override shape:
- `has_profiles` distinguishes "user authored an empty profiles list" from "user didn't author profiles" (the merge in Task 8 needs this to decide whether override replaces defaults).
- `env_keys` / `env_values` are parallel arrays. Reef doesn't have a native dict; this matches how TomlDoc surfaces parsed key-value entries.

- [ ] **Step 2: Verify it compiles**

```bash
reefc --check src/config.reef
```

Expected: no errors. (`--check` is reefc's type-check-only mode, useful for module-level smoke checks before there's a `proc main()`.)

If `--check` doesn't accept module files standalone, a quick alternative: run `reefc build` and confirm it still produces `build/repoman`. The new module is unused but should compile cleanly.

- [ ] **Step 3: Commit**

```bash
hg add src/config.reef
hg commit -m "config: type skeleton (Registry/Defaults/Project/Override/EffectiveConfig)"
```

---

## Task 5: config.parse_registry

**Files:**
- Modify: `src/config.reef` (add `parse_registry` function + helpers)
- Create: `tests/test_config_parse.reef`

Parses a TOML string into a `Registry`. Uses the new `TomlDoc` API from 0.5.10 — much cleaner than threading `(keys, values, count)`. Returns `Result[Registry, string]`.

- [ ] **Step 1: Write the failing test**

Create `tests/test_config_parse.reef`:

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

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

    let toml_input: string = "[repoman]\nschema = 1\n\n[defaults]\nrepos_root = \"/home/u/repos\"\nbackup_root = \"/nfs/repos\"\nincus_project = \"repoman\"\ndefault_image = \"images:ubuntu/26.04/cloud\"\nprofiles = [\"default\", \"claude-share\"]\n\n[[project]]\nname = \"isurus\"\nrepo = \"isurus-project\"\nimage = \"images:ubuntu/26.04/cloud\"\nprofiles = [\"default\"]\ncreated = \"2026-04-28T15:00:00Z\"\nlast_sync = \"\"\nbackup = true\n"

    let r = config.parse_registry(toml_input)
    runner.assert_eq_bool(rg.is_ok(r), true, "parse succeeds")
    if rg.is_ok(r)
        let reg = rg.unwrap_ok(r)
        runner.assert_eq_int(reg.schema, 1, "schema = 1")
        runner.assert_eq_string(reg.defaults.repos_root, "/home/u/repos", "repos_root")
        runner.assert_eq_string(reg.defaults.backup_root, "/nfs/repos", "backup_root")
        runner.assert_eq_string(reg.defaults.incus_project, "repoman", "incus_project")
        runner.assert_eq_string(reg.defaults.default_image, "images:ubuntu/26.04/cloud", "default_image")
        runner.assert_eq_int(reg.defaults.profiles.length(), 2, "defaults.profiles count")
        runner.assert_eq_string(reg.defaults.profiles[0], "default", "defaults.profiles[0]")
        runner.assert_eq_string(reg.defaults.profiles[1], "claude-share", "defaults.profiles[1]")
        runner.assert_eq_int(reg.projects.length(), 1, "1 project")
        let p = reg.projects[0]
        runner.assert_eq_string(p.name, "isurus", "project.name")
        runner.assert_eq_string(p.repo, "isurus-project", "project.repo")
        runner.assert_eq_string(p.image, "images:ubuntu/26.04/cloud", "project.image")
        runner.assert_eq_int(p.profiles.length(), 1, "project.profiles count")
        runner.assert_eq_string(p.profiles[0], "default", "project.profiles[0]")
        runner.assert_eq_string(p.last_sync, "", "project.last_sync")
        runner.assert_eq_bool(p.backup, true, "project.backup")
    end if

    // schema rejection
    let bad_schema: string = "[repoman]\nschema = 99\n[defaults]\nrepos_root = \"/r\"\nbackup_root = \"/b\"\nincus_project = \"x\"\ndefault_image = \"y\"\nprofiles = []\n"
    let r2 = config.parse_registry(bad_schema)
    runner.assert_eq_bool(rg.is_err(r2), true, "schema 99 rejected")

    runner.report()
end main
```

- [ ] **Step 2: Run test to verify it fails**

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

Expected: compile error (`parse_registry` not defined in `config`).

- [ ] **Step 3: Add parse_registry + helpers to src/config.reef**

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

```reef
    fn parse_registry(toml_text: string): rg.Result[Registry, string]
```

Add these functions before `end module`:

```reef
// Parse a comma-separated TOML inline array of strings: `["a", "b"]`.
// Tolerates whitespace and missing brackets; returns empty if input is empty.
fn parse_string_array(raw: string): [string]
    let n: int = str.length(raw)
    if n == 0
        return new [string](0)
    end if
    // Strip leading [ and trailing ]
    mut start: int = 0
    mut end_idx: int = n
    if n > 0 and raw[0] == '['
        start = 1
    end if
    if end_idx > start and raw[end_idx - 1] == ']'
        end_idx = end_idx - 1
    end if
    let inner: string = str.substring(raw, start, end_idx - start)

    // Split on commas, then trim quotes and whitespace from each element.
    let inner_len: int = str.length(inner)
    if inner_len == 0
        return new [string](0)
    end if

    // Reef doesn't have a dynamic split-and-collect; use str.split with a max.
    mut parts: [string] = new [string](64)
    let count: int = str.split(inner, ',', parts, 64)

    mut result: [string] = new [string](count)
    mut i: int = 0
    while i < count
        let p: string = str.trim_ws(parts[i])
        // Strip surrounding double quotes if present
        let pl: int = str.length(p)
        if pl >= 2 and p[0] == '"' and p[pl - 1] == '"'
            result[i] = str.substring(p, 1, pl - 2)
        else
            result[i] = p
        end if
        i = i + 1
    end while
    return result
end parse_string_array

fn parse_registry(toml_text: string): rg.Result[Registry, string]
    let doc: toml.TomlDoc = toml.toml_parse_doc(toml_text)
    if doc.truncated
        return @rg.Result[Registry, string].Err("registry too large (>1024 entries)")
    end if

    let schema: int = toml.toml_get_int_doc(doc, "repoman.schema")
    if schema != 1
        return @rg.Result[Registry, string].Err("unsupported schema (expected 1)")
    end if

    let defaults: Defaults = Defaults {
        repos_root:    toml.toml_get_doc(doc, "defaults.repos_root"),
        backup_root:   toml.toml_get_doc(doc, "defaults.backup_root"),
        incus_project: toml.toml_get_doc(doc, "defaults.incus_project"),
        default_image: toml.toml_get_doc(doc, "defaults.default_image"),
        profiles:      parse_string_array(toml.toml_get_doc(doc, "defaults.profiles"))
    }

    let project_count: int = toml.toml_array_count(doc.keys, doc.count, "project")
    mut projects: [Project] = new [Project](project_count)
    mut i: int = 0
    while i < project_count
        let p: Project = Project {
            name:      toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "name"),
            repo:      toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "repo"),
            image:     toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "image"),
            profiles:  parse_string_array(toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "profiles")),
            created:   toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "created"),
            last_sync: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "last_sync"),
            backup:    toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "backup") != "false"
        }
        projects[i] = p
        i = i + 1
    end while

    let reg: Registry = Registry {
        schema:   schema,
        defaults: defaults,
        projects: projects
    }
    return @rg.Result[Registry, string].Ok(reg)
end parse_registry
```

Note on the `backup` boolean parse: `toml_get_bool` exists, but the table-array helper `toml_array_get` returns a string. We default-true unless the literal string is `"false"`. Tighter handling can come later.

- [ ] **Step 4: Run test to verify it passes**

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

Expected: 19 assertions pass.

- [ ] **Step 5: Commit**

```bash
hg add tests/test_config_parse.reef
hg commit -m "config: parse_registry via TomlDoc + tests"
```

---

## Task 6: config.serialize_registry

**Files:**
- Modify: `src/config.reef` (add `serialize_registry`)
- Create: `tests/test_config_serialize.reef`

Renders a `Registry` to a TOML string using `TomlBuilder`. The output must round-trip cleanly through `parse_registry` (we test that explicitly in Task 7).

- [ ] **Step 1: Write the failing test**

Create `tests/test_config_serialize.reef`:

```reef
import config
import test.framework

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

    let defaults: config.Defaults = config.Defaults {
        repos_root:    "/home/u/repos",
        backup_root:   "/nfs/repos",
        incus_project: "repoman",
        default_image: "images:ubuntu/26.04/cloud",
        profiles:      ["default", "claude-share"]
    }
    mut projects: [config.Project] = new [config.Project](1)
    projects[0] = config.Project {
        name:      "isurus",
        repo:      "isurus-project",
        image:     "images:ubuntu/26.04/cloud",
        profiles:  ["default"],
        created:   "2026-04-28T15:00:00Z",
        last_sync: "",
        backup:    true
    }
    let reg: config.Registry = config.Registry {
        schema:   1,
        defaults: defaults,
        projects: projects
    }

    let out: string = config.serialize_registry(reg)
    runner.assert_contains_string(out, "[repoman]", "has [repoman] header")
    runner.assert_contains_string(out, "schema = 1", "has schema field")
    runner.assert_contains_string(out, "[defaults]", "has [defaults] header")
    runner.assert_contains_string(out, "repos_root = \"/home/u/repos\"", "has repos_root field")
    runner.assert_contains_string(out, "[[project]]", "has [[project]] header")
    runner.assert_contains_string(out, "name = \"isurus\"", "has project.name")
    runner.assert_contains_string(out, "backup = true", "has backup = true")
    runner.assert_contains_string(out, "profiles = [\"default\", \"claude-share\"]", "defaults.profiles array")

    runner.report()
end main
```

- [ ] **Step 2: Run test to verify it fails**

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

Expected: compile error (`serialize_registry` undefined).

- [ ] **Step 3: Add serialize_registry to src/config.reef**

Add to the export block:

```reef
    fn serialize_registry(reg: Registry): string
```

Add the implementation before `end module`:

```reef
fn serialize_registry(reg: Registry): string
    let b: toml.TomlBuilder = toml.toml_builder()

    toml.toml_begin_table(b, "repoman")
    toml.toml_set_int(b, "schema", reg.schema)

    toml.toml_begin_table(b, "defaults")
    toml.toml_set_string(b, "repos_root", reg.defaults.repos_root)
    toml.toml_set_string(b, "backup_root", reg.defaults.backup_root)
    toml.toml_set_string(b, "incus_project", reg.defaults.incus_project)
    toml.toml_set_string(b, "default_image", reg.defaults.default_image)
    toml.toml_set_string_array(b, "profiles", reg.defaults.profiles)

    let pn: int = reg.projects.length()
    mut i: int = 0
    while i < pn
        let p: Project = reg.projects[i]
        toml.toml_array_append_table(b, "project")
        toml.toml_set_string(b, "name", p.name)
        toml.toml_set_string(b, "repo", p.repo)
        toml.toml_set_string(b, "image", p.image)
        toml.toml_set_string_array(b, "profiles", p.profiles)
        toml.toml_set_string(b, "created", p.created)
        toml.toml_set_string(b, "last_sync", p.last_sync)
        toml.toml_set_bool(b, "backup", p.backup)
        i = i + 1
    end while

    return toml.toml_render(b)
end serialize_registry
```

- [ ] **Step 4: Run test to verify it passes**

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

Expected: 8 assertions pass.

- [ ] **Step 5: Commit**

```bash
hg add tests/test_config_serialize.reef
hg commit -m "config: serialize_registry via TomlBuilder + tests"
```

---

## Task 7: config round-trip test

**Files:**
- Create: `tests/test_config_roundtrip.reef`

Confirms that `parse(serialize(reg)) == reg`. This is the canonical test for any encoder pair — without it the two halves can drift.

- [ ] **Step 1: Write the failing test**

Create `tests/test_config_roundtrip.reef`:

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

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

    let defaults: config.Defaults = config.Defaults {
        repos_root:    "/home/u/repos",
        backup_root:   "/nfs/repos",
        incus_project: "repoman",
        default_image: "images:ubuntu/26.04/cloud",
        profiles:      ["default", "claude-share"]
    }
    mut projects: [config.Project] = new [config.Project](2)
    projects[0] = config.Project {
        name:      "isurus",
        repo:      "isurus-project",
        image:     "images:ubuntu/26.04/cloud",
        profiles:  ["default"],
        created:   "2026-04-28T15:00:00Z",
        last_sync: "",
        backup:    true
    }
    projects[1] = config.Project {
        name:      "tools",
        repo:      "tools",
        image:     "images:debian/12/cloud",
        profiles:  ["default", "claude-share"],
        created:   "2026-04-29T10:00:00Z",
        last_sync: "2026-04-29T11:00:00Z",
        backup:    false
    }
    let reg: config.Registry = config.Registry {
        schema: 1, defaults: defaults, projects: projects
    }

    let serialized: string = config.serialize_registry(reg)
    let parsed_r = config.parse_registry(serialized)
    runner.assert_eq_bool(rg.is_ok(parsed_r), true, "round-trip parse succeeds")

    if rg.is_ok(parsed_r)
        let reg2 = rg.unwrap_ok(parsed_r)
        runner.assert_eq_int(reg2.schema, 1, "schema preserved")
        runner.assert_eq_string(reg2.defaults.repos_root, reg.defaults.repos_root, "defaults.repos_root preserved")
        runner.assert_eq_int(reg2.defaults.profiles.length(), 2, "defaults.profiles len preserved")
        runner.assert_eq_int(reg2.projects.length(), 2, "project count preserved")
        runner.assert_eq_string(reg2.projects[0].name, "isurus", "project[0].name")
        runner.assert_eq_string(reg2.projects[1].name, "tools", "project[1].name")
        runner.assert_eq_bool(reg2.projects[0].backup, true, "project[0].backup")
        runner.assert_eq_bool(reg2.projects[1].backup, false, "project[1].backup")
        runner.assert_eq_string(reg2.projects[1].last_sync, "2026-04-29T11:00:00Z", "last_sync preserved")
    end if

    runner.report()
end main
```

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

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

Expected: 9 assertions pass. If any fail, the parse and serialize sides have drifted — fix whichever is wrong.

- [ ] **Step 3: Commit**

```bash
hg add tests/test_config_roundtrip.reef
hg commit -m "config: round-trip test (serialize → parse equals original)"
```

---

## Task 8: config.parse_override

**Files:**
- Modify: `src/config.reef` (add `parse_override`)
- Create: `tests/test_config_override.reef`

Parses a per-project override TOML (`[container]`, `[[mount]]`, `[env]` sections). Returns `Result[Override, string]`. Override files are read-only — repoman never writes them — so we only need parse, not serialize.

- [ ] **Step 1: Write the failing test**

Create `tests/test_config_override.reef`:

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

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

    let toml_input: string = "[container]\nimage = \"images:debian/12/cloud\"\nprofiles = [\"default\", \"claude-share\", \"node-dev\"]\n\n[[mount]]\nsource = \"~/.npm\"\npath = \"/home/ctusa/.npm\"\n\n[[mount]]\nsource = \"~/.cache/yarn\"\npath = \"/home/ctusa/.cache/yarn\"\n\n[env]\nNODE_ENV = \"development\"\nDEBUG = \"1\"\n"

    let r = config.parse_override(toml_input)
    runner.assert_eq_bool(rg.is_ok(r), true, "override parse succeeds")
    if rg.is_ok(r)
        let ov = rg.unwrap_ok(r)
        runner.assert_eq_string(ov.image, "images:debian/12/cloud", "override.image")
        runner.assert_eq_bool(ov.has_profiles, true, "has_profiles set")
        runner.assert_eq_int(ov.profiles.length(), 3, "profiles count")
        runner.assert_eq_string(ov.profiles[2], "node-dev", "profiles[2]")
        runner.assert_eq_int(ov.mounts.length(), 2, "mount count")
        runner.assert_eq_string(ov.mounts[0].source, "~/.npm", "mount[0].source")
        runner.assert_eq_string(ov.mounts[0].path, "/home/ctusa/.npm", "mount[0].path")
        runner.assert_eq_int(ov.env_keys.length(), 2, "env count")
        // env keys are not order-guaranteed by TOML; check both possibilities
        let k0: string = ov.env_keys[0]
        runner.assert_eq_bool(k0 == "NODE_ENV" or k0 == "DEBUG", true, "env_keys[0] is one of expected")
    end if

    // empty override
    let r2 = config.parse_override("")
    runner.assert_eq_bool(rg.is_ok(r2), true, "empty override is valid")
    if rg.is_ok(r2)
        let ov2 = rg.unwrap_ok(r2)
        runner.assert_eq_string(ov2.image, "", "empty override.image")
        runner.assert_eq_bool(ov2.has_profiles, false, "no profiles")
        runner.assert_eq_int(ov2.mounts.length(), 0, "no mounts")
        runner.assert_eq_int(ov2.env_keys.length(), 0, "no env")
    end if

    runner.report()
end main
```

- [ ] **Step 2: Run test to verify it fails**

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

Expected: compile error (`parse_override` undefined).

- [ ] **Step 3: Add parse_override to src/config.reef**

Add to the export block:

```reef
    fn parse_override(toml_text: string): rg.Result[Override, string]
```

Implementation (place before `end module`):

```reef
fn parse_override(toml_text: string): rg.Result[Override, string]
    let doc: toml.TomlDoc = toml.toml_parse_doc(toml_text)
    if doc.truncated
        return @rg.Result[Override, string].Err("override too large")
    end if

    let image: string = toml.toml_get_doc(doc, "container.image")
    let profiles_raw: string = toml.toml_get_doc(doc, "container.profiles")
    let has_profiles: bool = toml.toml_has_key_doc(doc, "container.profiles")
    let profiles: [string] = parse_string_array(profiles_raw)

    let mount_count: int = toml.toml_array_count(doc.keys, doc.count, "mount")
    mut mounts: [Mount] = new [Mount](mount_count)
    mut i: int = 0
    while i < mount_count
        mounts[i] = Mount {
            source: toml.toml_array_get(doc.keys, doc.values, doc.count, "mount", i, "source"),
            path:   toml.toml_array_get(doc.keys, doc.values, doc.count, "mount", i, "path")
        }
        i = i + 1
    end while

    // Walk doc.keys for entries starting with "env."
    mut env_keys_buf: [string] = new [string](128)
    mut env_vals_buf: [string] = new [string](128)
    mut env_count: int = 0
    mut k: int = 0
    while k < doc.count
        let key: string = doc.keys[k]
        if str.starts_with(key, "env.")
            if env_count < 128
                env_keys_buf[env_count] = str.substring(key, 4, str.length(key) - 4)
                env_vals_buf[env_count] = doc.values[k]
                env_count = env_count + 1
            end if
        end if
        k = k + 1
    end while

    mut env_keys: [string] = new [string](env_count)
    mut env_vals: [string] = new [string](env_count)
    mut j: int = 0
    while j < env_count
        env_keys[j] = env_keys_buf[j]
        env_vals[j] = env_vals_buf[j]
        j = j + 1
    end while

    let ov: Override = Override {
        image:        image,
        profiles:     profiles,
        has_profiles: has_profiles,
        mounts:       mounts,
        env_keys:     env_keys,
        env_values:   env_vals
    }
    return @rg.Result[Override, string].Ok(ov)
end parse_override
```

- [ ] **Step 4: Run test to verify it passes**

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

Expected: all assertions pass (12 in this test).

- [ ] **Step 5: Commit**

```bash
hg add tests/test_config_override.reef
hg commit -m "config: parse_override (container/mount/env) + tests"
```

---

## Task 9: config.merge_with_defaults

**Files:**
- Modify: `src/config.reef` (add `merge_with_defaults`)
- Create: `tests/test_config_merge.reef`

Pure function: takes a name, repo dirname, optional `--image` flag, optional Override, and Defaults. Returns an `EffectiveConfig`. Implements the merge table from spec §3.3:

| Field | Priority |
|---|---|
| `image` | flag → override.image → defaults.default_image |
| `profiles` | override.profiles (replace, when has_profiles) → defaults.profiles |
| `mounts` | always [auto repo bind] ++ override.mounts |
| `env` | override.env entries (or empty) |

- [ ] **Step 1: Write the failing test**

Create `tests/test_config_merge.reef`:

```reef
import config
import test.framework

fn make_defaults(): config.Defaults
    return config.Defaults {
        repos_root:    "/home/u/repos",
        backup_root:   "/nfs/repos",
        incus_project: "repoman",
        default_image: "images:ubuntu/26.04/cloud",
        profiles:      ["default", "claude-share"]
    }
end make_defaults

fn empty_override(): config.Override
    return config.Override {
        image:        "",
        profiles:     new [string](0),
        has_profiles: false,
        mounts:       new [config.Mount](0),
        env_keys:     new [string](0),
        env_values:   new [string](0)
    }
end empty_override

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

    // 1. flag wins over override and defaults
    mut ov1: config.Override = empty_override()
    ov1.image = "images:debian/12/cloud"
    let e1 = config.merge_with_defaults("isurus", "isurus-project", "images:custom/x", ov1, d)
    runner.assert_eq_string(e1.image, "images:custom/x", "flag wins")
    runner.assert_eq_string(e1.repo_path, "/home/u/repos/isurus-project", "repo_path computed")

    // 2. override.image wins over defaults when no flag
    let e2 = config.merge_with_defaults("isurus", "isurus-project", "", ov1, d)
    runner.assert_eq_string(e2.image, "images:debian/12/cloud", "override.image wins")

    // 3. defaults when no flag, no override.image
    let e3 = config.merge_with_defaults("isurus", "isurus-project", "", empty_override(), d)
    runner.assert_eq_string(e3.image, "images:ubuntu/26.04/cloud", "defaults.image wins")

    // 4. profiles: override replaces defaults when has_profiles
    mut ov2: config.Override = empty_override()
    ov2.profiles = ["default", "claude-share", "node-dev"]
    ov2.has_profiles = true
    let e4 = config.merge_with_defaults("isurus", "isurus-project", "", ov2, d)
    runner.assert_eq_int(e4.profiles.length(), 3, "override profiles count")
    runner.assert_eq_string(e4.profiles[2], "node-dev", "override profiles[2]")

    // 5. profiles fall back to defaults
    let e5 = config.merge_with_defaults("isurus", "isurus-project", "", empty_override(), d)
    runner.assert_eq_int(e5.profiles.length(), 2, "defaults profiles count")

    // 6. mounts: auto bind always present, override appended
    mut m1: [config.Mount] = new [config.Mount](1)
    m1[0] = config.Mount { source: "~/.npm", path: "/home/u/.npm" }
    mut ov3: config.Override = empty_override()
    ov3.mounts = m1
    let e6 = config.merge_with_defaults("isurus", "isurus-project", "", ov3, d)
    runner.assert_eq_int(e6.mounts.length(), 2, "auto bind + 1 override mount")
    runner.assert_eq_string(e6.mounts[0].source, "/home/u/repos/isurus-project", "auto bind source")
    runner.assert_eq_string(e6.mounts[0].path, "/home/u/repos/isurus-project", "auto bind dest")
    runner.assert_eq_string(e6.mounts[1].source, "~/.npm", "override mount preserved")

    // 7. env: passed through
    mut keys: [string] = ["NODE_ENV"]
    mut vals: [string] = ["development"]
    mut ov4: config.Override = empty_override()
    ov4.env_keys = keys
    ov4.env_values = vals
    let e7 = config.merge_with_defaults("isurus", "isurus-project", "", ov4, d)
    runner.assert_eq_int(e7.env_keys.length(), 1, "env_keys count")
    runner.assert_eq_string(e7.env_keys[0], "NODE_ENV", "env_keys[0]")
    runner.assert_eq_string(e7.env_values[0], "development", "env_values[0]")

    runner.report()
end main
```

- [ ] **Step 2: Run test to verify it fails**

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

Expected: compile error (`merge_with_defaults` undefined).

- [ ] **Step 3: Add merge_with_defaults to src/config.reef**

Add to export block:

```reef
    fn merge_with_defaults(name: string, repo: string, image_flag: string, ov: Override, d: Defaults): EffectiveConfig
```

Implementation:

```reef
fn merge_with_defaults(name: string, repo: string, image_flag: string, ov: Override, d: Defaults): EffectiveConfig
    let repos_root_expanded: string = paths.expand_home(d.repos_root)
    let repo_path: string = paths.join(repos_root_expanded, repo)

    // Image priority: flag → override → defaults
    mut image: string = d.default_image
    if str.length(ov.image) > 0
        image = ov.image
    end if
    if str.length(image_flag) > 0
        image = image_flag
    end if

    // Profiles: override replaces defaults when has_profiles, else defaults
    mut profiles: [string] = d.profiles
    if ov.has_profiles
        profiles = ov.profiles
    end if

    // Mounts: [auto repo bind] ++ override.mounts
    let ov_mount_count: int = ov.mounts.length()
    mut mounts: [Mount] = new [Mount](1 + ov_mount_count)
    mounts[0] = Mount { source: repo_path, path: repo_path }
    mut i: int = 0
    while i < ov_mount_count
        // Expand ~ in mount source for host paths
        let m: Mount = ov.mounts[i]
        mounts[i + 1] = Mount {
            source: paths.expand_home(m.source),
            path:   m.path
        }
        i = i + 1
    end while

    return EffectiveConfig {
        name:       name,
        repo:       repo,
        repo_path:  repo_path,
        image:      image,
        profiles:   profiles,
        mounts:     mounts,
        env_keys:   ov.env_keys,
        env_values: ov.env_values
    }
end merge_with_defaults
```

- [ ] **Step 4: Run test to verify it passes**

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

Expected: 14 assertions pass.

- [ ] **Step 5: Commit**

```bash
hg add tests/test_config_merge.reef
hg commit -m "config: merge_with_defaults + tests"
```

---

## Task 10: config.add_project + config.update_last_sync

**Files:**
- Modify: `src/config.reef` (add the two registry mutators)
- Modify: `tests/test_config_merge.reef` (add cases) — actually new file for clarity:
- Create: `tests/test_config_mutate.reef`

Two pure functions that build a new `Registry` from an existing one. They never write to disk — that's `save`'s job (Task 12).

- [ ] **Step 1: Write the failing test**

Create `tests/test_config_mutate.reef`:

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

fn empty_defaults(): config.Defaults
    return config.Defaults {
        repos_root: "/r", backup_root: "/b", incus_project: "p",
        default_image: "img", profiles: new [string](0)
    }
end empty_defaults

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

    let reg0: config.Registry = config.Registry {
        schema: 1, defaults: empty_defaults(), projects: new [config.Project](0)
    }

    let p1: config.Project = config.Project {
        name: "isurus", repo: "isurus", image: "img",
        profiles: new [string](0), created: "t", last_sync: "", backup: true
    }
    let r1 = config.add_project(reg0, p1)
    runner.assert_eq_bool(rg.is_ok(r1), true, "add new project ok")
    if rg.is_ok(r1)
        let reg1 = rg.unwrap_ok(r1)
        runner.assert_eq_int(reg1.projects.length(), 1, "1 project after add")
        runner.assert_eq_string(reg1.projects[0].name, "isurus", "project added")

        // duplicate add fails
        let r2 = config.add_project(reg1, p1)
        runner.assert_eq_bool(rg.is_err(r2), true, "duplicate name rejected")

        // update_last_sync
        let r3 = config.update_last_sync(reg1, "isurus", "2026-04-29T12:00:00Z")
        runner.assert_eq_bool(rg.is_ok(r3), true, "update existing ok")
        if rg.is_ok(r3)
            let reg3 = rg.unwrap_ok(r3)
            runner.assert_eq_string(reg3.projects[0].last_sync, "2026-04-29T12:00:00Z", "last_sync updated")
        end if

        // update unknown name fails
        let r4 = config.update_last_sync(reg1, "nope", "t")
        runner.assert_eq_bool(rg.is_err(r4), true, "unknown name rejected")
    end if

    runner.report()
end main
```

- [ ] **Step 2: Run test to verify it fails**

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

Expected: compile error.

- [ ] **Step 3: Add functions to src/config.reef**

Export:

```reef
    fn add_project(reg: Registry, p: Project): rg.Result[Registry, string]
    fn update_last_sync(reg: Registry, name: string, ts: string): rg.Result[Registry, string]
```

Implementations:

```reef
fn add_project(reg: Registry, p: Project): rg.Result[Registry, string]
    let n: int = reg.projects.length()
    mut i: int = 0
    while i < n
        if reg.projects[i].name == p.name
            return @rg.Result[Registry, string].Err("project already exists: " + p.name)
        end if
        i = i + 1
    end while

    mut new_projects: [Project] = new [Project](n + 1)
    mut k: int = 0
    while k < n
        new_projects[k] = reg.projects[k]
        k = k + 1
    end while
    new_projects[n] = p

    return @rg.Result[Registry, string].Ok(Registry {
        schema:   reg.schema,
        defaults: reg.defaults,
        projects: new_projects
    })
end add_project

fn update_last_sync(reg: Registry, name: string, ts: string): rg.Result[Registry, string]
    let n: int = reg.projects.length()
    mut found: int = -1
    mut i: int = 0
    while i < n
        if reg.projects[i].name == name
            found = i
        end if
        i = i + 1
    end while

    if found < 0
        return @rg.Result[Registry, string].Err("project not in registry: " + name)
    end if

    mut new_projects: [Project] = new [Project](n)
    mut k: int = 0
    while k < n
        if k == found
            let old: Project = reg.projects[k]
            new_projects[k] = Project {
                name: old.name, repo: old.repo, image: old.image,
                profiles: old.profiles, created: old.created,
                last_sync: ts, backup: old.backup
            }
        else
            new_projects[k] = reg.projects[k]
        end if
        k = k + 1
    end while

    return @rg.Result[Registry, string].Ok(Registry {
        schema:   reg.schema,
        defaults: reg.defaults,
        projects: new_projects
    })
end update_last_sync
```

- [ ] **Step 4: Run test to verify it passes**

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

Expected: 7 assertions pass.

- [ ] **Step 5: Commit**

```bash
hg add tests/test_config_mutate.reef
hg commit -m "config: add_project + update_last_sync + tests"
```

---

## Task 11: config.load_or_init (file I/O)

**Files:**
- Modify: `src/config.reef` (add `load_or_init`, `default_registry_for`, `registry_path`)
- Create: `tests/test_config_io.reef` (uses temp dir under `/tmp`)

`load_or_init(home_dir)` is the main entry point: returns the registry, creating an initial one if none exists. We use `home_dir` as a parameter (not implicit `$HOME`) so the test can drive it.

- [ ] **Step 1: Write the failing test**

Create `tests/test_config_io.reef`:

```reef
import config
import test.framework
import core.result_generic as rg
import io.dir as iodir
import io.file as iofile
import sys.process as proc

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

    // Set up a fresh temp dir as fake $HOME
    let pid_str: string = "12345"  // process id stand-in for uniqueness; reuse same dir is fine
    let tmp: string = "/tmp/repoman-test-load-init"
    // Wipe and recreate
    let _: int = proc.process_wait(proc.process_spawn("rm", ["-rf", tmp]))
    let _: bool = iodir.create_dir_all(tmp)

    // First call: no .config/repoman/repoman.toml exists → init writes default
    let r1 = config.load_or_init(tmp)
    runner.assert_eq_bool(rg.is_ok(r1), true, "load_or_init creates default")
    if rg.is_ok(r1)
        let reg = rg.unwrap_ok(r1)
        runner.assert_eq_int(reg.schema, 1, "default schema = 1")
        runner.assert_eq_int(reg.projects.length(), 0, "default has no projects")
        runner.assert_eq_string(reg.defaults.incus_project, "repoman", "default incus_project")
    end if

    // The file should now exist on disk.
    let cfg_path: string = tmp + "/.config/repoman/repoman.toml"
    runner.assert_eq_bool(iofile.fileExists(cfg_path), true, "registry file written")

    // Second call: should load the existing file.
    let r2 = config.load_or_init(tmp)
    runner.assert_eq_bool(rg.is_ok(r2), true, "second load reads existing")

    // Cleanup
    let _: int = proc.process_wait(proc.process_spawn("rm", ["-rf", tmp]))

    runner.report()
end main
```

- [ ] **Step 2: Run test to verify it fails**

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

Expected: compile error.

- [ ] **Step 3: Add load_or_init + helpers to src/config.reef**

Export:

```reef
    fn registry_path(home_dir: string): string
    fn default_registry(home_dir: string): Registry
    fn load_or_init(home_dir: string): rg.Result[Registry, string]
```

Implementation:

```reef
fn registry_path(home_dir: string): string
    let cfg_dir: string = paths.join(home_dir, ".config/repoman")
    return paths.join(cfg_dir, "repoman.toml")
end registry_path

fn default_registry(home_dir: string): Registry
    let repos_root: string = paths.join(home_dir, "repos")
    return Registry {
        schema: 1,
        defaults: Defaults {
            repos_root:    repos_root,
            backup_root:   "/nfs/repos",
            incus_project: "repoman",
            default_image: "images:ubuntu/26.04/cloud",
            profiles:      ["default", "claude-share"]
        },
        projects: new [Project](0)
    }
end default_registry

fn load_or_init(home_dir: string): rg.Result[Registry, string]
    let cfg_path: string = registry_path(home_dir)
    let cfg_dir: string = iopath.dirname(cfg_path)

    // Ensure ~/.config/repoman/ exists
    if not iodir.dir_exists(cfg_dir)
        if not iodir.create_dir_all(cfg_dir)
            return @rg.Result[Registry, string].Err("cannot create config dir: " + cfg_dir)
        end if
    end if

    if iofile.fileExists(cfg_path)
        let contents: string = iofile.readFile(cfg_path)
        return parse_registry(contents)
    end if

    // Init: write default registry.
    let reg: Registry = default_registry(home_dir)
    let saved_r = save(reg, cfg_path)
    if rg.is_err(saved_r)
        return @rg.Result[Registry, string].Err(rg.unwrap_err(saved_r))
    end if
    return @rg.Result[Registry, string].Ok(reg)
end load_or_init
```

Note: this references `save` which is the next task. Stub it out for now so this file compiles:

```reef
fn save(reg: Registry, cfg_path: string): rg.Result[bool, string]
    let _: bool = iofile.writeFile(cfg_path, serialize_registry(reg))
    return @rg.Result[bool, string].Ok(true)
end save
```

The stub will be replaced in Task 12 with the proper atomic write.

- [ ] **Step 4: Run test to verify it passes**

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

Expected: 5 assertions pass; `/tmp/repoman-test-load-init` is cleaned up at the end.

- [ ] **Step 5: Commit**

```bash
hg add tests/test_config_io.reef
hg commit -m "config: load_or_init with stub save (init flow + tests)"
```

---

## Task 12: config.save (atomic write with fsync + rename)

**Files:**
- Modify: `src/config.reef` (replace stub `save` with atomic version)
- Create: `tests/test_config_save.reef`

Atomic write recipe per spec §3.4: write to `<path>.tmp`, fsync the tmp, rename over the target. If any step fails, return Err with the failing step.

- [ ] **Step 1: Write the failing test**

Create `tests/test_config_save.reef`:

```reef
import config
import test.framework
import core.result_generic as rg
import io.dir as iodir
import io.file as iofile
import sys.process as proc

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

    let tmp: string = "/tmp/repoman-test-save"
    let _: int = proc.process_wait(proc.process_spawn("rm", ["-rf", tmp]))
    let _: bool = iodir.create_dir_all(tmp)
    let cfg_path: string = tmp + "/repoman.toml"

    let reg: config.Registry = config.default_registry("/home/u")
    let r1 = config.save(reg, cfg_path)
    runner.assert_eq_bool(rg.is_ok(r1), true, "save returns Ok")
    runner.assert_eq_bool(iofile.fileExists(cfg_path), true, "target file exists")
    runner.assert_eq_bool(iofile.fileExists(cfg_path + ".tmp"), false, "tmp removed after rename")

    // Round-trip: read what we wrote
    let contents: string = iofile.readFile(cfg_path)
    let r2 = config.parse_registry(contents)
    runner.assert_eq_bool(rg.is_ok(r2), true, "saved file parses")
    if rg.is_ok(r2)
        let reg2 = rg.unwrap_ok(r2)
        runner.assert_eq_int(reg2.schema, 1, "schema preserved on disk")
    end if

    let _: int = proc.process_wait(proc.process_spawn("rm", ["-rf", tmp]))
    runner.report()
end main
```

- [ ] **Step 2: Run test to verify the stub passes one assertion but isn't atomic-safe**

The stub `save` from Task 11 will pass the basic assertions (it does write the file), but doesn't go through `.tmp` and doesn't fsync. We're going to replace it.

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

Expected: passes (the stub is correct enough for these assertions; we replace it for crash safety, not behavior).

- [ ] **Step 3: Replace the stub save with the atomic version**

In `src/config.reef`, find the stub `save` from Task 11 and replace it with:

```reef
// Atomic write: writeFile(.tmp) → fsync(.tmp) → rename(.tmp, target).
// If any step fails, returns Err naming the failing step.
fn save(reg: Registry, cfg_path: string): rg.Result[bool, string]
    let serialized: string = serialize_registry(reg)
    let tmp_path: string = cfg_path + ".tmp"

    if not iofile.writeFile(tmp_path, serialized)
        return @rg.Result[bool, string].Err("write failed: " + tmp_path)
    end if

    if not iofile.fsync(tmp_path)
        // Best effort: clean up tmp
        let _: bool = iofile.deleteFile(tmp_path)
        return @rg.Result[bool, string].Err("fsync failed: " + tmp_path)
    end if

    if not iofile.rename(tmp_path, cfg_path)
        let _: bool = iofile.deleteFile(tmp_path)
        return @rg.Result[bool, string].Err("rename failed: " + tmp_path + " → " + cfg_path)
    end if

    return @rg.Result[bool, string].Ok(true)
end save
```

Add `save` to the export block:

```reef
    fn save(reg: Registry, cfg_path: string): rg.Result[bool, string]
```

- [ ] **Step 4: Run test to verify it passes**

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

Expected: 5 assertions pass; the `.tmp` file is gone after rename.

- [ ] **Step 5: Commit**

```bash
hg add tests/test_config_save.reef
hg commit -m "config: atomic save (writeFile→fsync→rename)"
```

---

## Task 13: incus subprocess wrappers

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

Subprocess wrappers around the `incus` CLI. These are NOT unit-tested — they need a live Incus daemon; the smoke recipe in Task 21 covers them. We do test that the argv-list construction is correct by inspection — read the bash prototype lines 105-115 for the canonical incantations.

Wrappers needed:
- `project_ensure(project: string)` — `incus project list --format csv -c name | grep -qx <p>`, else `incus project create <p>`
- `container_exists(project: string, name: string)` — `incus list --project <p> --format csv -c n | grep -qx <name>`
- `launch(project, name, image, profiles)` — `incus launch --project <p> [--profile P]+ <image> <name>`
- `device_add_disk(project, name, dev, source, dst)` — `incus config device add --project <p> <name> <dev> disk source=<src> path=<dst>`
- `set_env(project, name, key, val)` — `incus config set --project <p> <name> environment.<key>=<val>`
- `restart(project, name)` — `incus restart --project <p> <name>`

Each wrapper calls `process_spawn(prog, argv)` then `process_wait(pid)` and returns `Result[bool, string]` (Ok(true) on exit 0, Err with stderr-summary on non-zero). Stderr passes through to the user's terminal — we don't capture and reformat.

- [ ] **Step 1: Add wrappers to src/incus.reef**

Add to `src/incus.reef`:

```reef
import sys.process as p
import core.result_generic as rg
```

Add to the export block:

```reef
    fn project_ensure(project: string): rg.Result[bool, string]
    fn container_exists(project: string, name: string): rg.Result[bool, string]
    fn launch(project: string, name: string, image: string, profiles: [string]): rg.Result[bool, string]
    fn device_add_disk(project: string, name: string, dev: string, src: string, dst: string): rg.Result[bool, string]
    fn set_env_var(project: string, name: string, key: string, val: string): rg.Result[bool, string]
    fn restart(project: string, name: string): rg.Result[bool, string]
```

Implementations (place before `end module`):

```reef
// Run `incus <args>`. Returns Ok(true) on exit 0, Err with a brief diagnostic
// on non-zero. Stderr inherits the parent terminal — incus's own message
// reaches the user without us reformatting.
fn run_incus(args: [string]): rg.Result[bool, string]
    let pid: int = p.process_spawn("incus", args)
    if pid < 0
        return @rg.Result[bool, string].Err("failed to spawn 'incus' (is it installed?)")
    end if
    let exit: int = p.process_wait(pid)
    if exit == 0
        return @rg.Result[bool, string].Ok(true)
    end if
    return @rg.Result[bool, string].Err("incus exited with code " + int_to_str_simple(exit))
end run_incus

fn int_to_str_simple(n: int): string
    if n == 0
        return "0"
    end if
    mut value: int = n
    mut neg: bool = false
    if value < 0
        neg = true
        value = -value
    end if
    mut digits: string = ""
    while value > 0
        let d: int = value % 10
        let dc: char = '0'
        unsafe
            dc = d + 48
        end unsafe
        let one: string = ""
        let appended: string = append_char_local(one, dc)
        digits = appended + digits
        value = value / 10
    end while
    if neg
        return "-" + digits
    end if
    return digits
end int_to_str_simple

fn append_char_local(s: string, c: char): string
    let n: int = str.length(s)
    mut out: string = ""
    mut i: int = 0
    while i < n
        out = out + char_to_string(s[i])
        i = i + 1
    end while
    return out + char_to_string(c)
end append_char_local

fn char_to_string(c: char): string
    let buf: string = ""
    // Quick char-to-string via concatenation: rely on str.concat handling chars
    // (Reef strings are buffer-backed; if str.concat fails we'd need an FFI).
    mut tmp: string = " "
    tmp[0] = c
    return tmp
end char_to_string

// project_ensure: list, create if missing.
fn project_ensure(project: string): rg.Result[bool, string]
    // `incus project show <name>` exits 0 if it exists, non-0 otherwise.
    let pid: int = p.process_spawn("incus", ["project", "show", project])
    let exit: int = p.process_wait(pid)
    if exit == 0
        return @rg.Result[bool, string].Ok(true)
    end if
    // Create
    return run_incus(["project", "create", project])
end project_ensure

fn container_exists(project: string, name: string): rg.Result[bool, string]
    // `incus info --project <p> <name>` exits 0 if it exists.
    let pid: int = p.process_spawn("incus", ["info", "--project", project, name])
    let exit: int = p.process_wait(pid)
    if exit == 0
        return @rg.Result[bool, string].Ok(true)
    end if
    return @rg.Result[bool, string].Ok(false)
end container_exists

fn launch(project: string, name: string, image: string, profiles: [string]): rg.Result[bool, string]
    let pn: int = profiles.length()
    // Compute argv length: ["launch", "--project", project, ...profile args (2*pn), image, name]
    mut args: [string] = new [string](3 + 2 * pn + 2)
    args[0] = "launch"
    args[1] = "--project"
    args[2] = project
    mut i: int = 0
    while i < pn
        args[3 + i * 2] = "--profile"
        args[3 + i * 2 + 1] = profiles[i]
        i = i + 1
    end while
    args[3 + 2 * pn] = image
    args[3 + 2 * pn + 1] = name
    return run_incus(args)
end launch

fn device_add_disk(project: string, name: string, dev: string, src: string, dst: string): rg.Result[bool, string]
    return run_incus([
        "config", "device", "add",
        "--project", project,
        name,
        dev,
        "disk",
        "source=" + src,
        "path=" + dst
    ])
end device_add_disk

fn set_env_var(project: string, name: string, key: string, val: string): rg.Result[bool, string]
    return run_incus([
        "config", "set",
        "--project", project,
        name,
        "environment." + key + "=" + val
    ])
end set_env_var

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

Note on the int-to-string and char-to-string helpers: reef-stdlib does have these (`encoding.toml` has `int_to_str`; `core.str` has helpers) but they aren't all exported uniformly. To minimize surprise, the helpers above are local and self-contained. If a clean stdlib import works, prefer it — but verify with `reefc --check` before relying on it.

- [ ] **Step 2: Compile-check the module**

```bash
reefc build
```

Expected: builds cleanly. (No tests for this module — they require a live Incus.)

- [ ] **Step 3: Commit**

```bash
hg commit -m "incus: subprocess wrappers (project_ensure/launch/device_add/set_env/restart)"
```

---

## Task 14: sync.build_rsync_args (pure)

**Files:**
- Create: `src/sync.reef`
- Create: `tests/test_sync_args.reef`

Pure function that builds the rsync argv from `(src, dst, dry_run, no_delete, is_tty, excluded_repos)`. The standard exclude list is hardcoded to match the bash prototype line by line. `excluded_repos` is the per-project `backup = false` skip list, applied only in whole-tree mode.

- [ ] **Step 1: Write the failing test**

Create `tests/test_sync_args.reef`:

```reef
import sync
import test.framework

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

    // Basic: no flags
    let a1: [string] = sync.build_rsync_args("/src/", "/dst/", false, false, false, new [string](0))
    runner.assert_eq_bool(contains_str(a1, "-aHAX"), true, "has -aHAX")
    runner.assert_eq_bool(contains_str(a1, "--info=stats2"), true, "has --info=stats2")
    runner.assert_eq_bool(contains_str(a1, "--delete"), true, "delete on by default")
    runner.assert_eq_bool(contains_str(a1, "--exclude=node_modules/"), true, "node_modules excluded")
    runner.assert_eq_bool(contains_str(a1, "--exclude=.cache/"), true, ".cache excluded")
    runner.assert_eq_bool(last_two(a1, "/src/", "/dst/"), true, "src and dst end positional")

    // Dry run: --dry-run + --itemize-changes + --info=stats2 (NOT progress2)
    let a2: [string] = sync.build_rsync_args("/src/", "/dst/", true, false, true, new [string](0))
    runner.assert_eq_bool(contains_str(a2, "--dry-run"), true, "dry-run flag")
    runner.assert_eq_bool(contains_str(a2, "--itemize-changes"), true, "itemize-changes flag")
    runner.assert_eq_bool(contains_str(a2, "--info=stats2"), true, "info stats2")
    runner.assert_eq_bool(contains_str(a2, "--info=stats2,progress2"), false, "no progress in dry-run")

    // No delete
    let a3: [string] = sync.build_rsync_args("/src/", "/dst/", false, true, false, new [string](0))
    runner.assert_eq_bool(contains_str(a3, "--delete"), false, "no --delete with no_delete")

    // TTY interactive: stats2,progress2
    let a4: [string] = sync.build_rsync_args("/src/", "/dst/", false, false, true, new [string](0))
    runner.assert_eq_bool(contains_str(a4, "--info=stats2,progress2"), true, "tty progress")

    // Excluded repos in whole-tree mode
    let a5: [string] = sync.build_rsync_args("/src/", "/dst/", false, false, false, ["repo-A", "repo-B"])
    runner.assert_eq_bool(contains_str(a5, "--exclude=repo-A/"), true, "excluded repo A")
    runner.assert_eq_bool(contains_str(a5, "--exclude=repo-B/"), true, "excluded repo B")

    runner.report()
end main

fn contains_str(arr: [string], target: string): bool
    let n: int = arr.length()
    mut i: int = 0
    while i < n
        if arr[i] == target
            return true
        end if
        i = i + 1
    end while
    return false
end contains_str

fn last_two(arr: [string], a: string, b: string): bool
    let n: int = arr.length()
    if n < 2
        return false
    end if
    return arr[n - 2] == a and arr[n - 1] == b
end last_two
```

- [ ] **Step 2: Run test to verify it fails**

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

Expected: compile error (`sync` module not found).

- [ ] **Step 3: Create src/sync.reef**

```reef
module sync

import core.str
import core.result_generic as rg
import sys.process as p

export
    fn build_rsync_args(src: string, dst: string, dry_run: bool, no_delete: bool, is_tty: bool, excluded_repos: [string]): [string]
end export

// Hardcoded excludes matching bash prototype line 26-41.
fn standard_excludes(): [string]
    return [
        "node_modules/",
        "target/",
        "build/",
        "dist/",
        ".next/",
        "__pycache__/",
        "*.pyc",
        ".venv/",
        "venv/",
        ".cache/",
        ".tox/",
        ".pytest_cache/",
        ".mypy_cache/",
        ".ruff_cache/"
    ]
end standard_excludes

fn build_rsync_args(src: string, dst: string, dry_run: bool, no_delete: bool, is_tty: bool, excluded_repos: [string]): [string]
    let std: [string] = standard_excludes()
    let std_n: int = std.length()
    let ex_n: int = excluded_repos.length()

    // Estimate capacity: 1 (-aHAX) + up to 3 info flags + 1 (--delete) + std_n excludes + ex_n excludes + 2 positionals
    let cap: int = 1 + 3 + 1 + std_n + ex_n + 2
    mut buf: [string] = new [string](cap)
    mut k: int = 0

    buf[k] = "-aHAX"
    k = k + 1

    if dry_run
        buf[k] = "--dry-run"
        k = k + 1
        buf[k] = "--itemize-changes"
        k = k + 1
        buf[k] = "--info=stats2"
        k = k + 1
    elif is_tty
        buf[k] = "--info=stats2,progress2"
        k = k + 1
    else
        buf[k] = "--info=stats2"
        k = k + 1
    end if

    if not no_delete
        buf[k] = "--delete"
        k = k + 1
    end if

    mut i: int = 0
    while i < std_n
        buf[k] = "--exclude=" + std[i]
        k = k + 1
        i = i + 1
    end while

    mut j: int = 0
    while j < ex_n
        buf[k] = "--exclude=" + excluded_repos[j] + "/"
        k = k + 1
        j = j + 1
    end while

    buf[k] = src
    k = k + 1
    buf[k] = dst
    k = k + 1

    // Trim to actual size
    mut out: [string] = new [string](k)
    mut m: int = 0
    while m < k
        out[m] = buf[m]
        m = m + 1
    end while
    return out
end build_rsync_args

end module
```

- [ ] **Step 4: Run test to verify it passes**

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

Expected: 13 assertions pass.

- [ ] **Step 5: Commit**

```bash
hg add src/sync.reef tests/test_sync_args.reef
hg commit -m "sync: build_rsync_args (pure) + tests covering every branch"
```

---

## Task 15: sync.ensure_nfs_mounted (subprocess)

**Files:**
- Modify: `src/sync.reef` (add `ensure_nfs_mounted`)

Three-step check from bash prototype lines 60-71:
1. `stat <backup_root>` (triggers autofs)
2. `mountpoint -q <backup_root>` (confirms it's a mount)
3. `findmnt -t nfs4 <backup_root>` (confirms NFSv4)

Each fails fast with a different message. No unit test — needs a real NFS mount; smoke test will exercise it.

- [ ] **Step 1: Add ensure_nfs_mounted to src/sync.reef**

Add to export block:

```reef
    fn ensure_nfs_mounted(backup_root: string): rg.Result[bool, string]
```

Implementation:

```reef
fn ensure_nfs_mounted(backup_root: string): rg.Result[bool, string]
    // Step 1: stat triggers autofs
    let pid1: int = p.process_spawn("stat", [backup_root])
    if pid1 < 0
        return @rg.Result[bool, string].Err("cannot spawn stat")
    end if
    if p.process_wait(pid1) != 0
        return @rg.Result[bool, string].Err("cannot stat " + backup_root + " — autofs misconfigured or server unreachable")
    end if

    // Step 2: mountpoint
    let pid2: int = p.process_spawn("mountpoint", ["-q", backup_root])
    if pid2 < 0
        return @rg.Result[bool, string].Err("cannot spawn mountpoint")
    end if
    if p.process_wait(pid2) != 0
        return @rg.Result[bool, string].Err(backup_root + " exists but is not a mount — NFS server unreachable?")
    end if

    // Step 3: findmnt -t nfs4
    let pid3: int = p.process_spawn("findmnt", ["-t", "nfs4", backup_root])
    if pid3 < 0
        return @rg.Result[bool, string].Err("cannot spawn findmnt")
    end if
    if p.process_wait(pid3) != 0
        return @rg.Result[bool, string].Err(backup_root + " is mounted but not as nfs4 — check /etc/auto.nfs")
    end if

    return @rg.Result[bool, string].Ok(true)
end ensure_nfs_mounted
```

- [ ] **Step 2: Compile-check**

```bash
reefc build
```

Expected: builds cleanly.

- [ ] **Step 3: Commit**

```bash
hg commit -m "sync: ensure_nfs_mounted (stat → mountpoint → findmnt)"
```

---

## Task 16: sync.run

**Files:**
- Modify: `src/sync.reef` (add `run`)

Spawns rsync with the args from `build_rsync_args` and returns its exit code. Inherits parent stdio so the user sees rsync's progress live.

- [ ] **Step 1: Add run to src/sync.reef**

Add to export block:

```reef
    fn run(args: [string]): int
```

Implementation:

```reef
// Spawn rsync with the given argv. Inherits parent stdio (no capture).
// Returns rsync's exit code, or -1 if spawn failed.
fn run(args: [string]): int
    let pid: int = p.process_spawn("rsync", args)
    if pid < 0
        return -1
    end if
    return p.process_wait(pid)
end run
```

- [ ] **Step 2: Compile-check**

```bash
reefc build
```

Expected: builds cleanly.

- [ ] **Step 3: Commit**

```bash
hg commit -m "sync: run (spawn rsync, inherit stdio, return exit code)"
```

---

## Task 17: cli.cmd_new (orchestration)

**Files:**
- Create: `src/cli.reef` (with `cmd_new` function and supporting flag-parsing)

`cmd_new` walks through every step of spec §4.1: validate name, load registry, reject duplicates, resolve repo path, parse override, merge, ensure project, ensure no container conflict, launch, attach mounts, set env, restart, write registry, print ready hint. No unit test — orchestration is smoke-tested.

- [ ] **Step 1: Create src/cli.reef with cmd_new**

```reef
module cli

import core.str
import core.result_generic as rg
import io.console as console
import io.file as iofile
import sys.flag as flag
import sys.env as env
import sys.args as args
import config
import incus
import sync
import paths

export
    fn cmd_new(argv: [string]): int
    fn cmd_sync(argv: [string]): int
    fn dispatch(argv: [string]): int
end export

// argv passed in is the slice past argv[1] (i.e., excludes program + subcommand).
fn cmd_new(argv: [string]): int
    let parser: flag.FlagParser = flag.flag_parser_from(argv)
    flag.application(parser, "repoman new")
    flag.description(parser, "Create a new container + repo bind")
    let _ = flag.string_flag(parser, "repo", '\0', "", "repo dirname (defaults to <name>)")
    let _ = flag.string_flag(parser, "image", '\0', "", "container image (overrides default)")

    if not flag.parse(parser)
        console.printErr("repoman: error: " + flag.error(parser) + "\n")
        return 2
    end if

    let positionals: [string] = flag.positional_args(parser)
    if positionals.length() != 1
        console.printErr("repoman: error: 'new' takes exactly one positional argument: <name>\n")
        return 2
    end if

    let name: string = positionals[0]
    let repo_flag: string = flag.get_string(parser, "repo")
    let image_flag: string = flag.get_string(parser, "image")

    if not incus.validate_name(name)
        console.printErr("repoman: error: invalid container name: " + name + "\n")
        console.printErr("hint: lowercase alphanumeric + hyphens, ≤63 chars, no leading hyphen\n")
        return 1
    end if

    let home: string = env.get_env_or("HOME", "")
    if str.length(home) == 0
        console.printErr("repoman: error: HOME is not set\n")
        return 3
    end if

    let cfg_path: string = config.registry_path(home)
    let reg_r = config.load_or_init(home)
    if rg.is_err(reg_r)
        console.printErr("repoman: error: " + rg.unwrap_err(reg_r) + "\n")
        return 3
    end if
    let reg: config.Registry = rg.unwrap_ok(reg_r)

    // Reject duplicate name
    let pn: int = reg.projects.length()
    mut i: int = 0
    while i < pn
        if reg.projects[i].name == name
            console.printErr("repoman: error: project '" + name + "' already in registry\n")
            console.printErr("hint: incus delete --project " + reg.defaults.incus_project + " " + name + " ; then remove from " + cfg_path + "\n")
            return 4
        end if
        i = i + 1
    end while

    // Resolve repo path
    mut repo: string = repo_flag
    if str.length(repo) == 0
        repo = name
    end if
    let repos_root: string = paths.expand_home(reg.defaults.repos_root)
    let repo_path: string = paths.join(repos_root, repo)
    if not paths.is_dir(repo_path)
        console.printErr("repoman: error: no repo at " + repo_path + "\n")
        return 3
    end if

    // Read override (optional)
    let override_path: string = paths.join(home, ".config/repoman/repos.d/" + name + ".toml")
    mut override: config.Override = config.Override {
        image: "", profiles: new [string](0), has_profiles: false,
        mounts: new [config.Mount](0),
        env_keys: new [string](0), env_values: new [string](0)
    }
    if iofile.fileExists(override_path)
        let ov_r = config.parse_override(iofile.readFile(override_path))
        if rg.is_err(ov_r)
            console.printErr("repoman: error: bad override " + override_path + ": " + rg.unwrap_err(ov_r) + "\n")
            return 3
        end if
        override = rg.unwrap_ok(ov_r)
    end if

    let eff: config.EffectiveConfig = config.merge_with_defaults(name, repo, image_flag, override, reg.defaults)

    // Ensure incus project
    console.printErr("==> incus project ensure " + reg.defaults.incus_project + "\n")
    let pe = incus.project_ensure(reg.defaults.incus_project)
    if rg.is_err(pe)
        console.printErr("repoman: error: " + rg.unwrap_err(pe) + "\n")
        return 1
    end if

    // Reject if container exists already
    let ce = incus.container_exists(reg.defaults.incus_project, name)
    if rg.is_err(ce)
        console.printErr("repoman: error: " + rg.unwrap_err(ce) + "\n")
        return 1
    end if
    if rg.unwrap_ok(ce)
        console.printErr("repoman: error: container '" + name + "' already exists in project '" + reg.defaults.incus_project + "'\n")
        console.printErr("hint: incus delete --project " + reg.defaults.incus_project + " " + name + "\n")
        return 4
    end if

    // Launch
    console.printErr("==> incus launch " + eff.image + " " + name + "\n")
    let lr = incus.launch(reg.defaults.incus_project, name, eff.image, eff.profiles)
    if rg.is_err(lr)
        console.printErr("repoman: error: " + rg.unwrap_err(lr) + "\n")
        return 1
    end if

    // Mounts: device names "repo" for the auto bind, "mount-1", "mount-2", ...
    let mn: int = eff.mounts.length()
    mut k: int = 0
    while k < mn
        let m: config.Mount = eff.mounts[k]
        let dev_name: string = "repo"
        if k > 0
            dev_name = "mount-" + int_to_str_simple(k)
        end if
        console.printErr("==> incus device add " + name + " " + dev_name + " " + m.source + ":" + m.path + "\n")
        let dr = incus.device_add_disk(reg.defaults.incus_project, name, dev_name, m.source, m.path)
        if rg.is_err(dr)
            console.printErr("repoman: error: " + rg.unwrap_err(dr) + "\n")
            console.printErr("hint: incus delete --project " + reg.defaults.incus_project + " " + name + "\n")
            return 1
        end if
        k = k + 1
    end while

    // Env
    let en: int = eff.env_keys.length()
    mut e: int = 0
    while e < en
        let er = incus.set_env_var(reg.defaults.incus_project, name, eff.env_keys[e], eff.env_values[e])
        if rg.is_err(er)
            console.printErr("repoman: error: " + rg.unwrap_err(er) + "\n")
            return 1
        end if
        e = e + 1
    end while

    // Restart so binds + env take effect
    console.printErr("==> incus restart " + name + "\n")
    let rr = incus.restart(reg.defaults.incus_project, name)
    if rg.is_err(rr)
        console.printErr("repoman: error: " + rg.unwrap_err(rr) + "\n")
        return 1
    end if

    // Build new project entry and write registry
    let new_p: config.Project = config.Project {
        name:      name,
        repo:      repo,
        image:     eff.image,
        profiles:  eff.profiles,
        created:   "",            // v0.1: leave timestamp blank (no time stdlib used yet)
        last_sync: "",
        backup:    true
    }
    let reg2_r = config.add_project(reg, new_p)
    if rg.is_err(reg2_r)
        console.printErr("repoman: error: " + rg.unwrap_err(reg2_r) + "\n")
        return 1
    end if
    let saved = config.save(rg.unwrap_ok(reg2_r), cfg_path)
    if rg.is_err(saved)
        console.printErr("repoman: error: " + rg.unwrap_err(saved) + "\n")
        return 1
    end if

    // Ready hint — use $UID and $HOME for shell expansion (correct on any host)
    console.printErr("==> ready\n")
    console.printErr("\n")
    console.printErr("  shell in:   incus exec --project " + reg.defaults.incus_project + " --user $UID --cwd " + repo_path + " --env HOME=$HOME " + name + " -- bash -l\n")
    console.printErr("  run claude: incus exec --project " + reg.defaults.incus_project + " " + name + " -- claude\n")
    return 0
end cmd_new

// Local int-to-str helper (decimal, non-negative integers expected)
fn int_to_str_simple(n: int): string
    if n == 0
        return "0"
    end if
    mut value: int = n
    mut digits: string = ""
    while value > 0
        let d: int = value % 10
        let dc: char = '0'
        unsafe
            dc = d + 48
        end unsafe
        let one_buf: string = " "
        one_buf[0] = dc
        digits = one_buf + digits
        value = value / 10
    end while
    return digits
end int_to_str_simple

end module
```

- [ ] **Step 2: Compile-check**

```bash
reefc build
```

Expected: builds cleanly.

- [ ] **Step 3: Commit**

```bash
hg add src/cli.reef
hg commit -m "cli: cmd_new orchestration (validate→load→merge→incus→save)"
```

---

## Task 18: cli.cmd_sync (orchestration)

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

`cmd_sync` walks spec §4.2: parse flags, load registry, ensure NFS mount, resolve target (single project vs whole tree), build args, run rsync, on success update last_sync. Exit code is rsync's, with pre-rsync errors using 1-4.

- [ ] **Step 1: Add cmd_sync to src/cli.reef**

Add the implementation (anywhere before `end module`):

```reef
fn cmd_sync(argv: [string]): int
    let parser: flag.FlagParser = flag.flag_parser_from(argv)
    flag.application(parser, "repoman sync")
    flag.description(parser, "rsync local repos → NFS backup")
    let _ = flag.bool_flag(parser, "no-delete", '\0', false, "additive only — no deletions on the destination")
    let _ = flag.bool_flag(parser, "dry-run", '\0', false, "preview changes without writing")

    if not flag.parse(parser)
        console.printErr("repoman: error: " + flag.error(parser) + "\n")
        return 2
    end if

    let positionals: [string] = flag.positional_args(parser)
    if positionals.length() > 1
        console.printErr("repoman: error: 'sync' takes at most one positional argument: [name]\n")
        return 2
    end if

    let no_delete: bool = flag.get_bool(parser, "no-delete")
    let dry_run: bool = flag.get_bool(parser, "dry-run")

    let home: string = env.get_env_or("HOME", "")
    if str.length(home) == 0
        console.printErr("repoman: error: HOME is not set\n")
        return 3
    end if

    let reg_r = config.load_or_init(home)
    if rg.is_err(reg_r)
        console.printErr("repoman: error: " + rg.unwrap_err(reg_r) + "\n")
        return 3
    end if
    let reg: config.Registry = rg.unwrap_ok(reg_r)
    let cfg_path: string = config.registry_path(home)

    let backup_root: string = paths.expand_home(reg.defaults.backup_root)
    let repos_root:  string = paths.expand_home(reg.defaults.repos_root)

    // ensure_nfs_mounted
    let mr = sync.ensure_nfs_mounted(backup_root)
    if rg.is_err(mr)
        console.printErr("repoman: error: " + rg.unwrap_err(mr) + "\n")
        return 3
    end if

    // Resolve target
    mut src: string = ""
    mut dst: string = ""
    mut excluded: [string] = new [string](0)
    mut single_target: string = ""

    if positionals.length() == 1
        let name: string = positionals[0]
        // Find in registry
        let pn: int = reg.projects.length()
        mut found: int = -1
        mut i: int = 0
        while i < pn
            if reg.projects[i].name == name
                found = i
            end if
            i = i + 1
        end while
        if found < 0
            console.printErr("repoman: error: '" + name + "' not in registry\n")
            console.printErr("hint: repoman new " + name + "\n")
            return 1
        end if
        let proj: config.Project = reg.projects[found]
        if not proj.backup
            console.printErr("repoman: error: '" + name + "' has backup = false; refusing single-target sync\n")
            return 1
        end if
        src = paths.join(repos_root, proj.repo) + "/"
        dst = paths.join(backup_root, proj.repo) + "/"
        single_target = name
    else
        // whole tree
        src = repos_root + "/"
        dst = backup_root + "/"
        // Build excludes for backup=false projects
        let pn: int = reg.projects.length()
        mut buf: [string] = new [string](pn)
        mut count: int = 0
        mut i: int = 0
        while i < pn
            if not reg.projects[i].backup
                buf[count] = reg.projects[i].repo
                count = count + 1
            end if
            i = i + 1
        end while
        mut tight: [string] = new [string](count)
        mut j: int = 0
        while j < count
            tight[j] = buf[j]
            j = j + 1
        end while
        excluded = tight
    end if

    // Build args + log + run
    let is_tty: bool = false  // v0.1: assume non-TTY (cron-friendly defaults).
                              // TTY detection via ui.backend.tty.is_tty(STDOUT_FD) is a v0.2 niceness.
    let rsync_args: [string] = sync.build_rsync_args(src, dst, dry_run, no_delete, is_tty, excluded)

    mut tags: string = ""
    if dry_run
        tags = tags + "(dry-run) "
    end if
    if no_delete
        tags = tags + "(additive) "
    end if
    console.printErr("==> rsync " + tags + src + " → " + dst + "\n")

    let exit: int = sync.run_rsync(rsync_args)
    if exit < 0
        console.printErr("repoman: error: failed to spawn rsync\n")
        return 1
    end if
    if exit != 0
        return exit
    end if

    // Success: update last_sync. Skip in dry-run mode (nothing changed).
    if not dry_run
        let now: string = ""  // v0.1: timestamp blank; time stdlib integration is a follow-on
        if str.length(single_target) > 0
            let upd = config.update_last_sync(reg, single_target, now)
            if rg.is_ok(upd)
                let _ = config.save(rg.unwrap_ok(upd), cfg_path)
            end if
        else
            mut cur: config.Registry = reg
            let pn: int = cur.projects.length()
            mut i: int = 0
            while i < pn
                if cur.projects[i].backup
                    let upd = config.update_last_sync(cur, cur.projects[i].name, now)
                    if rg.is_ok(upd)
                        cur = rg.unwrap_ok(upd)
                    end if
                end if
                i = i + 1
            end while
            let _ = config.save(cur, cfg_path)
        end if
    end if

    return 0
end cmd_sync
```

- [ ] **Step 2: Compile-check**

```bash
reefc build
```

Expected: builds cleanly.

- [ ] **Step 3: Commit**

```bash
hg commit -m "cli: cmd_sync orchestration (NFS check → rsync → last_sync update)"
```

---

## Task 19: cli.dispatch (outer router)

**Files:**
- Modify: `src/cli.reef` (add `dispatch`, `usage`)

Outer dispatch on `argv[1]` per spec §5. Handles `new`, `sync`, `--version`/`-V`, `--help`/`-h`/`help`, no args.

- [ ] **Step 1: Add dispatch + usage to src/cli.reef**

Add to the top of the implementations (before `cmd_new`):

```reef
fn version_string(): string
    return "repoman 0.1.0"
end version_string

proc print_usage()
    console.printErr("Usage: repoman <subcommand> [args]\n")
    console.printErr("\n")
    console.printErr("Subcommands\n")
    console.printErr("  new <name> [--repo <dirname>] [--image <image>]\n")
    console.printErr("      Launch a container in the 'repoman' Incus project; bind ~/repos/<dirname>.\n")
    console.printErr("\n")
    console.printErr("  sync [name] [--no-delete] [--dry-run]\n")
    console.printErr("      Mirror local repos to NFS backup (rsync --delete by default).\n")
    console.printErr("\n")
    console.printErr("  --version | -V\n")
    console.printErr("  --help    | -h | help\n")
end print_usage
```

Add `dispatch` (place after `cmd_sync`):

```reef
fn dispatch(argv: [string]): int
    // argv is the full process argv: [program, subcommand, ...]
    let n: int = argv.length()
    if n < 2
        print_usage()
        return 0
    end if

    let sub: string = argv[1]

    if sub == "--version" or sub == "-V"
        console.printErr(version_string() + "\n")
        return 0
    end if
    if sub == "--help" or sub == "-h" or sub == "help"
        print_usage()
        return 0
    end if

    // Slice argv[2..] for the subcommand parser
    mut rest: [string] = new [string](n - 2)
    mut i: int = 0
    while i < n - 2
        rest[i] = argv[i + 2]
        i = i + 1
    end while

    if sub == "new"
        return cmd_new(rest)
    end if
    if sub == "sync"
        return cmd_sync(rest)
    end if

    console.printErr("repoman: error: unknown subcommand: " + sub + "\n")
    console.printErr("hint: try 'repoman --help'\n")
    return 2
end dispatch
```

- [ ] **Step 2: Compile-check**

```bash
reefc build
```

Expected: builds cleanly.

- [ ] **Step 3: Commit**

```bash
hg commit -m "cli: dispatch + usage (subcommand routing, --version, --help)"
```

---

## Task 20: main.reef entry point

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

Tiny entry that collects argv, calls `cli.dispatch`, exits with the returned code.

- [ ] **Step 1: Replace src/main.reef**

Overwrite `src/main.reef` with:

```reef
import cli
import sys.args as args
import sys.process as p

proc main()
    let n: int = args.count()
    mut argv: [string] = new [string](n)
    mut i: int = 0
    while i < n
        argv[i] = args.get(i)
        i = i + 1
    end while
    let code: int = cli.dispatch(argv)
    p.exit_now(code)
end main
```

- [ ] **Step 2: Build and smoke-test the help output**

```bash
reefc build
./build/repoman --version
./build/repoman --help
./build/repoman
./build/repoman bogus-subcommand
```

Expected:
- `--version` → `repoman 0.1.0`, exit 0.
- `--help` → usage, exit 0.
- no args → usage, exit 0.
- bogus → error message, exit 2.

(Run `echo $?` after each to check exit codes.)

- [ ] **Step 3: Commit**

```bash
hg commit -m "main: argv collection + dispatch"
```

---

## Task 21: README + Makefile

**Files:**
- Create: `README.md`
- Create: `Makefile`

README documents quickstart, build, test loop, smoke recipe, install. Makefile provides `make`/`make install`/`make uninstall`/`make clean`/`make test` for distro packagers — `reefc` does the actual building.

- [ ] **Step 1: Create README.md**

```markdown
# repoman

Per-project Incus containers + opinionated NFS/ZFS backup. v0.1.

## Build

```bash
reefc build
```

Produces `./build/repoman`.

## Test

```bash
for t in tests/test_*.reef; do
    echo "== $t =="
    reefc run "$t" || exit 1
done
```

## Install

System-wide via Makefile (uses `reefc build` under the hood):

```bash
make
sudo make install        # installs to /usr/local/bin/repoman
```

## Quickstart

```bash
# First run creates ~/.config/repoman/repoman.toml with sane defaults.
repoman --help
repoman new isurus --repo isurus-project
repoman sync --dry-run
```

## Smoke test (requires Incus + NFS)

```bash
# In an existing repo dir under ~/repos:
repoman new test-foo
repoman sync test-foo --dry-run
incus delete --project repoman test-foo
```

## Configuration

Central registry: `~/.config/repoman/repoman.toml` (managed; do not edit while repoman is running).

Per-project overrides: `~/.config/repoman/repos.d/<container-name>.toml` (user-authored). Example:

```toml
[container]
image    = "images:debian/12/cloud"
profiles = ["default", "claude-share", "node-dev"]

[[mount]]
source = "~/.npm"
path   = "/home/ctusa/.npm"

[env]
NODE_ENV = "development"
```

## Recommended Incus profile: `claude-share`

For the agent-friendly setup repoman is built around, create a shared profile that exposes the user's Claude state:

```bash
# (one-time)
incus profile create claude-share
incus profile edit claude-share  # add your bind-mounts for ~/.claude, etc.
```

repoman uses profiles `default` and `claude-share` by default; override per-project in `repos.d/<name>.toml`.
```

- [ ] **Step 2: Create Makefile**

```makefile
PREFIX ?= /usr/local
BINDIR  = $(PREFIX)/bin
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

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

- [ ] **Step 3: Verify make targets work**

```bash
make clean
make build
ls build/repoman
make test
```

Expected: `make build` produces the binary, `make test` runs every `tests/test_*.reef` and reports pass.

- [ ] **Step 4: Commit**

```bash
hg add README.md Makefile
hg commit -m "docs: README + Makefile (quickstart, install, test loop)"
```

---

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

**Files:** none — operational verification

Run the smoke recipe from README against a real Incus + NFS environment. This is the v0.1 acceptance gate.

- [ ] **Step 1: Pre-flight**

Confirm:
- `incus version` works, daemon is running
- `/nfs/repos` is autofs-mounted (autofs config covers it)
- A dir exists under `~/repos/<some-name>` for the smoke (e.g., `mkdir ~/repos/repoman-smoke; cd ~/repos/repoman-smoke; hg init`)

- [ ] **Step 2: First-run init**

```bash
rm -rf ~/.config/repoman   # clean slate; you may want to back up first if you've been using it
./build/repoman --help
ls ~/.config/repoman/repoman.toml   # should NOT exist yet (help doesn't init)
./build/repoman sync --dry-run      # this triggers init
cat ~/.config/repoman/repoman.toml  # should show schema=1, no projects
```

Expected: registry created on first command that touches it.

- [ ] **Step 3: `new` flow**

```bash
./build/repoman new repoman-smoke
```

Expected:
- Stderr shows `==> incus project ensure repoman`, `==> incus launch ...`, `==> incus device add ... repo ...`, `==> incus restart ...`, `==> ready`.
- `incus list --project repoman` shows `repoman-smoke` running.
- `~/.config/repoman/repoman.toml` now has a `[[project]]` entry for `repoman-smoke`.
- The shell-in hint is printed.

- [ ] **Step 4: Verify the bind mount**

```bash
incus exec --project repoman repoman-smoke -- ls /home/ctusa/repos/repoman-smoke
```

Expected: contents of the host repo are visible inside the container.

- [ ] **Step 5: `sync` dry-run**

```bash
./build/repoman sync repoman-smoke --dry-run
```

Expected: rsync prints itemize-changes lines (mostly `cd+++++++++` for new dirs), exits 0, the registry's `last_sync` for `repoman-smoke` is NOT updated (because dry-run).

- [ ] **Step 6: `sync` real run**

```bash
./build/repoman sync repoman-smoke
```

Expected: rsync runs, exits 0, registry's `last_sync` for `repoman-smoke` is updated to a non-empty value (or stays empty if you haven't wired the timestamp yet — that's a v0.2 task per spec).

- [ ] **Step 7: Cleanup**

```bash
incus delete --project repoman --force repoman-smoke
# Manually remove the entry from ~/.config/repoman/repoman.toml (no `repoman remove` in v0.1)
```

- [ ] **Step 8: Tag the release**

```bash
hg tag -m "v0.1.0 ships" v0.1.0
```

If the smoke test surfaced bugs, fix them before tagging — open follow-up commits as needed.

---

## Self-review checklist (run after writing all tasks)

After completing the plan, verify:

1. **Spec coverage:**
   - §1 scope: `new`, `sync`, registry, override, project namespace ✓ (Tasks 17, 18, 11, 8, 17)
   - §2 architecture: 6 modules ✓ (Tasks 2, 3, 4-12, 13, 14-16, 17-19)
   - §3 data shapes: registry, override, merge, atomic write, validation ✓ (Tasks 4-9, 11, 12)
   - §4 flows: `new`, `sync` ✓ (Tasks 17, 18)
   - §5 CLI: subcommands, exit codes, error UX ✓ (Tasks 17-19)
   - §6 testing: pure-logic units, smoke recipe ✓ (Tasks 2, 3, 5-12, 14, 22)
   - §7 build/install: reef.toml, Makefile, README ✓ (Tasks 1, 21)

2. **Type consistency:** function names and signatures match between definitions and call sites — `parse_registry`, `serialize_registry`, `parse_override`, `merge_with_defaults`, `add_project`, `update_last_sync`, `load_or_init`, `save`, `registry_path`, `default_registry`. The `incus.*` and `sync.*` symbol shapes are consistent across `cli.reef`'s call sites and the module exports.

3. **Placeholder scan:** no TODO, no "implement later", no "similar to Task N". Code blocks are concrete.

---

## Known v0.1 simplifications (intentional, not gaps)

These are tracked for v0.2, deliberately omitted from the plan:
- **Timestamps blank.** `created` and `last_sync` are written as `""`. A `time.now_iso8601()` integration is straightforward but the time stdlib hasn't been audited yet; a dedicated follow-up task can add it without breaking the schema.
- **TTY detection always false.** `cmd_sync` hardcodes `is_tty = false`, giving cron-friendly `--info=stats2`. To get progress bars in interactive use, wire `ui.backend.tty.is_tty(STDOUT_FD)`. Trivial to add.
- **No rollback on partial `new` failure.** Per spec §4.1: if launch succeeds but a downstream step fails, the user sees the error and the manual cleanup hint. v0.2 candidate: `--rollback-on-error`.
- **No CI.** Per spec §6.5. Test loop runs locally via `make test`.
