|
root / docs / superpowers / plans / 2026-05-06-repoman-v0.3-llm-and-setup.md
2026-05-06-repoman-v0.3-llm-and-setup.md markdown 2657 lines 86.5 KB

repoman v0.3 — Setup wizard + LLM stack Implementation Plan

Scope reduction (2026-05-08)

The --hermes/--no-hermes/--purge-hermes flag-based provisioning was removed
during smoke testing
and does not ship in v0.3. Smoke testing exposed fundamental
problems with the bind-mount-the-host-runtime architecture: hermes' Python venv pins
to a uv-vendored host-only path, and uid-mapping for file binds does not generalize.
v0.4 will revisit via pre-built incus images.

v0.3 ships: setup wizard, llm-share profile (ollama wiring), schema-2 migration,
hermes module helpers as a library for v0.4.


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 repoman setup (idempotent host-bootstrap wizard) plus per-container hermes data-dir provisioning (repoman new --hermes, repoman remove --purge-hermes), with a repoman-managed llm-share Incus profile that wires containers to the host's ollama daemon over LAN.

Architecture: Two new modules under src/: setup.reef (the wizard — environment detection → stage planner → applier) and hermes.reef (per-container data-dir lifecycle: pure helpers for path resolution and seed-list classification, effectful seed_data_dir / purge_data_dir). Targeted edits to config.reef (schema bump 1→2 with migration; new LlmDefaults substruct; new hermes field on Project), incus.reef (capture-mode profile_exists, stdin-based profile_create_or_edit, options-aware device_add_disk_opts), and cli.reef (cmd_setup dispatch; --hermes/--no-hermes on new; --purge-hermes on remove; hermes: yes/no on list/status). The wizard composes incus.* and hermes.* — it adds no new abstractions over them.

Tech Stack: reef-lang 0.5.20 (no new stdlib requirements vs v0.2; targets every API documented since 0.5.10), encoding.toml (TomlBuilder + TomlDoc), core.result_generic, sys.process.process_spawn (argv-list — never shell), sys.flag.flag_parser_from, io.console (interactive prompts), io.file/io.dir (seed copy + dir creation), test.framework.TestRunner.


Reference: spec and source

  • Design spec: docs/superpowers/specs/2026-05-06-repoman-v0.3-llm-and-setup.md (this plan implements it)
  • v0.1 plan (style/depth reference): docs/superpowers/plans/2026-04-29-repoman-v0.1.md
  • Hermes Docker docs (cited in spec §4.1): https://hermes-agent.nousresearch.com/docs/user-guide/docker
  • Existing modules whose patterns to mirror:
    • src/config.reef — parse/serialize/migrate idiom; with_projects/add_project invariant pattern
    • src/incus.reefprocess_run_capture for stdout-capturing wrappers; run_incus for fire-and-forget
    • src/cli.reefcmd_* shape: parse → validate → load registry → effects → save → exit code
    • tests/test_config_*.reef — temp-dir + fixture-driven tests; framework.TestRunner API

File structure

~/repos/repoman/
├── reef.toml                              # bump version to 0.3.0
├── README.md                              # add: setup, --hermes, --purge-hermes
├── VISION.md                              # check off setup, document llm-share
├── src/
│   ├── cli.reef                           # +cmd_setup, --hermes/--no-hermes, --purge-hermes
│   ├── config.reef                        # +LlmDefaults, +Project.hermes, schema 1→2 migration
│   ├── hermes.reef                        # NEW — data-dir lifecycle
│   ├── incus.reef                         # +profile_exists, +profile_create_or_edit, +device_add_disk_opts
│   ├── log.reef                           # (unchanged)
│   ├── main.reef                          # (unchanged)
│   ├── paths.reef                         # (unchanged)
│   ├── setup.reef                         # NEW — host bootstrap wizard
│   └── sync.reef                          # (unchanged)
└── tests/
    ├── test_config_llm_parse.reef         # NEW — parse llm block + hermes field
    ├── test_config_llm_serialize.reef     # NEW — serialize llm block + hermes field
    ├── test_config_migrate_v1.reef        # NEW — schema 1 → 2 migration
    ├── test_hermes_paths.reef             # NEW — state_dir_for, default_seed_list
    ├── test_hermes_classify.reef          # NEW — classify_seed_entry partition logic
    ├── test_setup_template.reef           # NEW — render_llm_share_template golden
    ├── test_setup_planner.reef            # NEW — plan_stages from a fixture environment
    └── test_*.reef                        # existing v0.1/v0.2 tests untouched

Module-boundary rules from v0.1 still apply:

  • Each module file declares module <name> matching its filename, ends with end module.
  • main.reef has no module declaration.
  • Tests are standalone reef programs; each has its own proc main().
  • make test runs every tests/test_*.reef and stops on first failure.

Task 1: Add LlmDefaults type + extend Defaults struct

Files:

  • Modify: src/config.reef:32-39 (Defaults struct), add new type after it.

Defaults gains an llm: LlmDefaults field. LlmDefaults carries the four fields from spec §6.1: enabled, hermes_default, ollama_url, hermes_seed.

  • Step 1: Read the current Defaults declaration
sed -n '32,40p' src/config.reef

Expected: a struct with repos_root, backup_root, logdir, incus_project, default_image, profiles.

  • Step 2: Add LlmDefaults type and extend Defaults

In src/config.reef, modify the export block (lines 11-30) to add the new type to the export list (insert after type Defaults):

    type LlmDefaults

Then add the new type definition immediately above the existing type Defaults = struct block (around line 32):

type LlmDefaults = struct
    enabled:        bool
    hermes_default: bool
    ollama_url:     string
    hermes_seed:    [string]
end LlmDefaults

And extend the existing Defaults struct to add the field as the last entry, before end Defaults:

type Defaults = struct
    repos_root: string
    backup_root: string
    logdir: string
    incus_project: string
    default_image: string
    profiles: [string]
    llm: LlmDefaults
end Defaults
  • Step 3: Build to verify the types compile
make build

Expected: clean build, no errors. Existing tests will fail to compile in next steps because the Defaults literal sites need updating — that's expected and addressed in Task 2.

  • Step 4: Update existing Defaults construction sites

Three sites today construct a Defaults literal: parse_registry (line ~153), default_registry (line ~434), and any test that builds one. Pin the llm field on each to a default-disabled value so this task doesn't break the build.

In parse_registry (around line 153), update the literal to add the field at the end (just before the closing }):

        llm: LlmDefaults {
            enabled:        false,
            hermes_default: false,
            ollama_url:     "",
            hermes_seed:    new [string](0)
        }

In default_registry (around line 434), do the same:

        llm: LlmDefaults {
            enabled:        false,
            hermes_default: false,
            ollama_url:     "",
            hermes_seed:    new [string](0)
        }
  • Step 5: Build and run all existing tests
make build && make test

Expected: clean build; every existing test passes (we haven't changed parse/serialize semantics yet — the new field defaults out).

  • Step 6: Commit
hg add src/config.reef
hg commit -m "config: add LlmDefaults substruct on Defaults (schema unchanged)"

Task 2: Add hermes field to Project struct

Files:

  • Modify: src/config.reef:41-49 (Project struct)

  • Step 1: Extend Project struct

In src/config.reef, modify the Project struct:

type Project = struct
    name: string
    repo: string
    image: string
    profiles: [string]
    created: string
    last_sync: string
    backup: bool
    hermes: bool
end Project
  • Step 2: Update existing Project construction sites

Find every Project { literal and add hermes: false as the last field. Sites today:

  • parse_registry (around line 167)
  • update_last_sync (around line 367) — preserves old.hermes
  • cmd_new in cli.reef (around line 200)
  • Tests (e.g., tests/test_config_serialize.reef, tests/test_config_mutate.reef)

For parse_registry, add at the end of the literal:

            hermes:    toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "hermes") == "true"

For update_last_sync (line ~367), preserve old.hermes:

            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,
                hermes: old.hermes
            }

For cli.cmd_new (line ~200), add:

        hermes:    false

For tests: open each test file that builds a Project literal and add hermes: false to the last position. Run grep -rln 'Project {' tests/ to find them.

  • Step 3: Build and run all tests
make build && make test

Expected: clean build, all existing tests pass.

  • Step 4: Commit
hg add src/config.reef src/cli.reef tests/
hg commit -m "config: add Project.hermes flag (defaults false; schema unchanged)"

Task 3: Schema-2 acceptance in parse_registry (no migration yet)

The existing parser rejects anything but schema = 1. We change it to accept 1 or 2, populate LlmDefaults from [defaults].llm only when present, and otherwise leave it default-disabled.

Files:

  • Modify: src/config.reef:127-186 (parse_registry)

  • Test: tests/test_config_llm_parse.reef

  • Step 1: Write the failing test for schema 2 + llm block

Create tests/test_config_llm_parse.reef:

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

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

    // Schema 2 with [defaults.llm] populated
    let toml: string =
        "[repoman]\n" ++
        "schema = 2\n" ++
        "output = \"quiet\"\n\n" ++
        "[defaults]\n" ++
        "repos_root = \"~/repos\"\n" ++
        "backup_root = \"/nfs/repos\"\n" ++
        "logdir = \"~/.local/state/repoman\"\n" ++
        "incus_project = \"repoman\"\n" ++
        "default_image = \"images:ubuntu/26.04/cloud\"\n" ++
        "profiles = [\"default\", \"claude-share\", \"llm-share\"]\n\n" ++
        "[defaults.llm]\n" ++
        "enabled = true\n" ++
        "hermes_default = false\n" ++
        "ollama_url = \"http://192.168.168.42:11434\"\n" ++
        "hermes_seed = [\".env\", \"config.yaml\", \"skills/\"]\n\n" ++
        "[[project]]\n" ++
        "name = \"isurus\"\n" ++
        "repo = \"isurus\"\n" ++
        "image = \"images:ubuntu/26.04/cloud\"\n" ++
        "profiles = [\"default\", \"claude-share\", \"llm-share\"]\n" ++
        "created = \"2026-05-06T00:00:00Z\"\n" ++
        "last_sync = \"\"\n" ++
        "backup = true\n" ++
        "hermes = true\n"

    let r = config.parse_registry(toml)
    runner.assert_eq_bool(rg.is_ok(r), true, "schema 2 parses ok")
    if rg.is_ok(r)
        let reg = rg.unwrap_ok(r)
        runner.assert_eq_int(reg.schema, 2, "schema = 2")
        runner.assert_eq_bool(reg.defaults.llm.enabled, true, "llm.enabled = true")
        runner.assert_eq_bool(reg.defaults.llm.hermes_default, false, "llm.hermes_default = false")
        runner.assert_eq_string(reg.defaults.llm.ollama_url, "http://192.168.168.42:11434", "llm.ollama_url")
        runner.assert_eq_int(reg.defaults.llm.hermes_seed.length(), 3, "llm.hermes_seed has 3 entries")
        runner.assert_eq_int(reg.projects.length(), 1, "one project")
        runner.assert_eq_bool(reg.projects[0].hermes, true, "project.hermes = true")
    end if

    // Schema 2 with [defaults.llm] missing — should default to disabled
    let toml_no_llm: string =
        "[repoman]\n" ++
        "schema = 2\n" ++
        "output = \"quiet\"\n\n" ++
        "[defaults]\n" ++
        "repos_root = \"~/repos\"\n" ++
        "backup_root = \"/nfs/repos\"\n" ++
        "logdir = \"~/.local/state/repoman\"\n" ++
        "incus_project = \"repoman\"\n" ++
        "default_image = \"images:ubuntu/26.04/cloud\"\n" ++
        "profiles = [\"default\", \"claude-share\"]\n"

    let r2 = config.parse_registry(toml_no_llm)
    runner.assert_eq_bool(rg.is_ok(r2), true, "schema 2 without llm block parses ok")
    if rg.is_ok(r2)
        let reg2 = rg.unwrap_ok(r2)
        runner.assert_eq_bool(reg2.defaults.llm.enabled, false, "llm.enabled defaults false")
        runner.assert_eq_int(reg2.defaults.llm.hermes_seed.length(), 0, "llm.hermes_seed empty")
    end if

    // Schema 99 still rejected
    let toml_bad: string = "[repoman]\nschema = 99\n"
    let r3 = config.parse_registry(toml_bad)
    runner.assert_eq_bool(rg.is_err(r3), true, "schema 99 rejected")

    runner.report()
end main
  • Step 2: Run test to verify it fails
reefc run tests/test_config_llm_parse.reef

Expected: failure — current parser rejects schema 2.

  • Step 3: Modify parse_registry to accept schema 1 or 2

In src/config.reef, find the schema check (line ~134):

    if schema != 1
        return @Result[Registry, string].Err("unsupported schema (expected 1)")
    end if

Replace with:

    if schema != 1 and schema != 2
        return @Result[Registry, string].Err("unsupported schema (expected 1 or 2)")
    end if
  • Step 4: Read [defaults.llm.*] after the existing defaults literal

Immediately after the Defaults literal is constructed (line ~160, before the project loop), replace:

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

With:

    let llm: LlmDefaults = LlmDefaults {
        enabled:        toml.toml_get_doc(doc, "defaults.llm.enabled") == "true",
        hermes_default: toml.toml_get_doc(doc, "defaults.llm.hermes_default") == "true",
        ollama_url:     toml.toml_get_doc(doc, "defaults.llm.ollama_url"),
        hermes_seed:    parse_string_array(toml.toml_get_doc(doc, "defaults.llm.hermes_seed"))
    }

    let defaults: Defaults = Defaults {
        repos_root:    toml.toml_get_doc(doc, "defaults.repos_root"),
        backup_root:   toml.toml_get_doc(doc, "defaults.backup_root"),
        logdir:        logdir,
        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")),
        llm:           llm
    }

(Replace the placeholder default LlmDefaults from Task 1's edit with this real read.)

  • Step 5: Run the new test to verify it passes
reefc run tests/test_config_llm_parse.reef

Expected: all 9 assertions pass.

  • Step 6: Run all tests to verify no regression
make test

Expected: every test passes (existing v0.1/v0.2 tests use schema = 1, which is still accepted; new test exercises schema = 2).

  • Step 7: Commit
hg add src/config.reef tests/test_config_llm_parse.reef
hg commit -m "config: parse_registry accepts schema 2 + [defaults.llm] block"

Task 4: Schema-2 emission in serialize_registry

The serializer always writes the current Registry shape. After this task, every save writes schema = 2 with the [defaults.llm] block and per-project hermes field.

Files:

  • Modify: src/config.reef:188-219 (serialize_registry)

  • Test: tests/test_config_llm_serialize.reef

  • Step 1: Write the failing test

Create tests/test_config_llm_serialize.reef:

import config
import test.framework
import core.result_generic as rg
import core.str

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

    let llm = config.LlmDefaults {
        enabled:        true,
        hermes_default: false,
        ollama_url:     "http://192.168.168.42:11434",
        hermes_seed:    [".env", "config.yaml", "skills/"]
    }
    let defaults = config.Defaults {
        repos_root:    "~/repos",
        backup_root:   "/nfs/repos",
        logdir:        "~/.local/state/repoman",
        incus_project: "repoman",
        default_image: "images:ubuntu/26.04/cloud",
        profiles:      ["default", "claude-share", "llm-share"],
        llm:           llm
    }
    let p = config.Project {
        name:      "isurus",
        repo:      "isurus",
        image:     "images:ubuntu/26.04/cloud",
        profiles:  ["default", "claude-share", "llm-share"],
        created:   "2026-05-06T00:00:00Z",
        last_sync: "",
        backup:    true,
        hermes:    true
    }
    let reg = config.Registry {
        schema:   2,
        output:   "quiet",
        defaults: defaults,
        projects: [p]
    }

    let s = config.serialize_registry(reg)

    runner.assert_contains_string(s, "schema = 2",                  "writes schema = 2")
    runner.assert_contains_string(s, "[defaults.llm]",              "writes [defaults.llm] table header")
    runner.assert_contains_string(s, "enabled = true",              "writes llm.enabled")
    runner.assert_contains_string(s, "hermes_default = false",      "writes llm.hermes_default")
    runner.assert_contains_string(s, "ollama_url = \"http://192.168.168.42:11434\"", "writes llm.ollama_url")
    runner.assert_contains_string(s, "hermes_seed = [",             "writes llm.hermes_seed array")
    runner.assert_contains_string(s, "\".env\"",                    "hermes_seed contains .env")
    runner.assert_contains_string(s, "hermes = true",               "writes project.hermes")

    // Round-trip check
    let r2 = config.parse_registry(s)
    runner.assert_eq_bool(rg.is_ok(r2), true, "round-trip parses")
    if rg.is_ok(r2)
        let reg2 = rg.unwrap_ok(r2)
        runner.assert_eq_int(reg2.schema, 2, "round-trip schema")
        runner.assert_eq_bool(reg2.defaults.llm.enabled, true, "round-trip llm.enabled")
        runner.assert_eq_int(reg2.defaults.llm.hermes_seed.length(), 3, "round-trip hermes_seed length")
        runner.assert_eq_bool(reg2.projects[0].hermes, true, "round-trip project.hermes")
    end if

    runner.report()
end main
  • Step 2: Run test to verify it fails
reefc run tests/test_config_llm_serialize.reef

Expected: assertion failures — serializer doesn't yet emit the new fields.

  • Step 3: Extend serialize_registry

In src/config.reef, find the serialize_registry function (line ~188). After the toml_set_string_array(b, "profiles", reg.defaults.profiles) line (~201), add the LLM table:

    toml.toml_begin_table(b, "defaults.llm")
    toml.toml_set_bool(b, "enabled", reg.defaults.llm.enabled)
    toml.toml_set_bool(b, "hermes_default", reg.defaults.llm.hermes_default)
    toml.toml_set_string(b, "ollama_url", reg.defaults.llm.ollama_url)
    toml.toml_set_string_array(b, "hermes_seed", reg.defaults.llm.hermes_seed)

Inside the per-project loop (line ~205-216), after the existing toml_set_bool(b, "backup", p.backup) line, add:

        toml.toml_set_bool(b, "hermes", p.hermes)
  • Step 4: Run the new test to verify it passes
reefc run tests/test_config_llm_serialize.reef

Expected: all 13 assertions pass.

  • Step 5: Run all tests to verify the existing round-trip test still passes
make test

Note: tests/test_config_roundtrip.reef likely now sees schema = 2 in the output even when it constructed schema = 1 input. If it asserts schema = 1 post-round-trip, update its expectation to schema = 2 (this is intentional: the serializer always writes the current shape). Inspect with cat tests/test_config_roundtrip.reef.

  • Step 6: Commit
hg add src/config.reef tests/test_config_llm_serialize.reef
hg commit -m "config: serialize_registry writes schema 2 with [defaults.llm] + project.hermes"

Task 5: Schema 1 → 2 migration in load_or_init

When repoman reads a schema = 1 registry from disk, populate the new fields with safe defaults and treat the in-memory Registry as schema = 2. The next save (for any reason) writes schema = 2. Lossless and idempotent.

Files:

  • Modify: src/config.reef:446-469 (load_or_init), src/config.reef:127-186 (parse_registry — set in-memory schema to 2 even if input is 1)

  • Test: tests/test_config_migrate_v1.reef

  • Step 1: Write the failing test

Create tests/test_config_migrate_v1.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 pr

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

    let tmp: string = "/tmp/repoman-test-migrate-v1"
    let _w: int = pr.process_wait(pr.process_spawn("rm", ["-rf", tmp]))
    let _c: bool = iodir.create_dir_all(tmp)
    let _c2: bool = iodir.create_dir_all(tmp ++ "/.config/repoman")

    // Write a v1 registry on disk
    let v1: string =
        "[repoman]\n" ++
        "schema = 1\n" ++
        "output = \"quiet\"\n\n" ++
        "[defaults]\n" ++
        "repos_root = \"~/repos\"\n" ++
        "backup_root = \"/nfs/repos\"\n" ++
        "logdir = \"~/.local/state/repoman\"\n" ++
        "incus_project = \"repoman\"\n" ++
        "default_image = \"images:ubuntu/26.04/cloud\"\n" ++
        "profiles = [\"default\", \"claude-share\"]\n\n" ++
        "[[project]]\n" ++
        "name = \"isurus\"\n" ++
        "repo = \"isurus\"\n" ++
        "image = \"images:ubuntu/26.04/cloud\"\n" ++
        "profiles = [\"default\", \"claude-share\"]\n" ++
        "created = \"2026-04-28T15:00:00Z\"\n" ++
        "last_sync = \"\"\n" ++
        "backup = true\n"

    let _w2: bool = iofile.writeFile(tmp ++ "/.config/repoman/repoman.toml", v1)

    // load_or_init reads v1 and migrates
    let r = config.load_or_init(tmp)
    runner.assert_eq_bool(rg.is_ok(r), true, "v1 registry loads")
    if rg.is_ok(r)
        let reg = rg.unwrap_ok(r)
        runner.assert_eq_int(reg.schema, 2, "in-memory schema bumped to 2")
        runner.assert_eq_bool(reg.defaults.llm.enabled, false, "migrated llm.enabled = false")
        runner.assert_eq_int(reg.defaults.llm.hermes_seed.length(), 0, "migrated hermes_seed empty")
        runner.assert_eq_int(reg.projects.length(), 1, "project preserved")
        runner.assert_eq_bool(reg.projects[0].hermes, false, "migrated project.hermes = false")
        runner.assert_eq_string(reg.projects[0].name, "isurus", "project name preserved")
    end if

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

    runner.report()
end main
  • Step 2: Run test to verify it fails
reefc run tests/test_config_migrate_v1.reef

Expected: assertion failure — reg.schema will be 1, not 2.

  • Step 3: Force in-memory schema to 2 in parse_registry

In src/config.reef, find the final Registry literal at the end of parse_registry (line ~179):

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

Change schema: schema, to schema: 2, — every successfully parsed registry is exposed as schema 2 in memory regardless of disk format. Migration is implicit: parse fills new fields with safe defaults (the array/string/bool gets default-empty values when the keys aren't present), and the in-memory schema reflects what we'll write next.

  • Step 4: Run the migration test to verify it passes
reefc run tests/test_config_migrate_v1.reef

Expected: all 6 assertions pass.

  • Step 5: Run all tests
make test

Expected: every test passes. Any v0.1/v0.2 test that asserted reg.schema == 1 after a parse needs its assertion updated to 2 — find and update with grep -rn 'schema, 1' tests/ if the existing tests fail.

  • Step 6: Commit
hg add src/config.reef tests/test_config_migrate_v1.reef
hg commit -m "config: implicit v1 → v2 migration on load (in-memory schema always 2)"

Task 6: Update default_registry to schema 2

Fresh installs should write schema 2 from the start.

Files:

  • Modify: src/config.reef:428-444 (default_registry)

  • Test: existing tests/test_config_io.reef will need its schema, 1 assertion updated to schema, 2.

  • Step 1: Update default_registry

In src/config.reef, find:

    return Registry {
        schema: 1,
        ...
    }

Change to:

    return Registry {
        schema: 2,
        ...
    }
  • Step 2: Update affected tests

Run grep -rn 'schema, 1' tests/ and update each match to schema, 2. Likely candidates: tests/test_config_io.reef, tests/test_config_serialize.reef. Each should change:

runner.assert_eq_int(reg.schema, 1, "default schema = 1")

to:

runner.assert_eq_int(reg.schema, 2, "default schema = 2")
  • Step 3: Run all tests
make test

Expected: all tests pass.

  • Step 4: Commit
hg add src/config.reef tests/
hg commit -m "config: default_registry writes schema 2"

Task 7: incus.profile_exists (capture-mode wrapper)

Files:

  • Modify: src/incus.reef (export block + new function)

  • Step 1: Add to the export block

In src/incus.reef, extend the export block (lines 9-20) to add:

    fn profile_exists(project: string, name: string): rg.Result[bool, string]
  • Step 2: Implement profile_exists

After the existing delete_container function (line ~272), add:

// Returns Ok(true) if the named profile exists in the given project,
// Ok(false) otherwise. Errors only on subprocess failure.
fn profile_exists(project: string, name: string): rg.Result[bool, string]
    let pid: int = process_run_silent("incus", [
        "profile", "show", "--project", project, name
    ])
    if pid < 0
        return @Result[bool, string].Err("failed to spawn 'incus profile show'")
    end if
    let exit: int = p.process_wait(pid)
    if exit == 0
        return @Result[bool, string].Ok(true)
    end if
    return @Result[bool, string].Ok(false)
end profile_exists
  • Step 3: Build to verify it compiles
make build

Expected: clean build. (No unit test for this — it's a subprocess wrapper, smoke-tested via cmd_setup.)

  • Step 4: Commit
hg add src/incus.reef
hg commit -m "incus: add profile_exists (capture-mode probe)"

Task 8: incus.profile_create_or_edit (apply YAML via stdin)

incus profile create <name> makes an empty profile; incus profile edit <name> < file.yaml applies a YAML body. Idempotent flow: try create (ignore failure if exists), then edit. Edit takes YAML on stdin.

Files:

  • Modify: src/incus.reef

  • Step 1: Add to export block

    fn profile_create_or_edit(project: string, name: string, yaml: string): rg.Result[bool, string]
  • Step 2: Add a stdin-feeding helper near process_run_capture

After process_run_capture (line ~96), add:

// Spawn `program args` with the given string written to the child's stdin.
// stdout/stderr inherit the parent terminal. Returns exit code.
// On any setup failure (fork, pipe, exec), returns non-zero.
fn process_run_with_stdin(program: string, args: [string], input: string): int
    let pipe_fds: [int] = fd.fd_pipe()
    if pipe_fds.length() != 2
        return -1
    end if
    let read_fd: int = pipe_fds[0]
    let write_fd: int = pipe_fds[1]

    let pid: int = p.process_fork()
    if pid < 0
        let _r: int = fd.fd_close(read_fd)
        let _w: int = fd.fd_close(write_fd)
        return -1
    end if
    if pid == 0
        // Child: dup read end of pipe over stdin, close both ends, exec.
        let _i: int = fd.fd_dup2(read_fd, fd.STDIN())
        let _c1: int = fd.fd_close(read_fd)
        let _c2: int = fd.fd_close(write_fd)
        let _x: int = p.process_run_exec(program, args)
        p.exit_now(127)
    end if

    // Parent: close read end, write input, close write, wait.
    let _cr: int = fd.fd_close(read_fd)
    let _wn: int = fd.fd_write(write_fd, input)
    let _cw: int = fd.fd_close(write_fd)
    return p.process_wait(pid)
end process_run_with_stdin

(If fd.STDIN() and fd.fd_write aren't already imported / exported by sys.fd, this requires a quick reef-stdlib check; both are expected to exist alongside STDOUT()/STDERR()/fd_dup2.)

  • Step 3: Implement profile_create_or_edit

After delete_container:

// Idempotent: ensure the profile exists with the given YAML body.
// Step 1: try `incus profile create` (no-op error if exists).
// Step 2: `incus profile edit` reads stdin and replaces the profile body.
fn profile_create_or_edit(project: string, name: string, yaml: string): rg.Result[bool, string]
    // Step 1: create (silent on failure — profile may already exist)
    let create_pid: int = process_run_silent("incus", [
        "profile", "create", "--project", project, name
    ])
    let _ce: int = p.process_wait(create_pid)

    // Step 2: edit with YAML on stdin
    let edit_exit: int = process_run_with_stdin("incus", [
        "profile", "edit", "--project", project, name
    ], yaml)
    if edit_exit == 0
        return @Result[bool, string].Ok(true)
    end if
    return @Result[bool, string].Err("incus profile edit exited with code " ++ convert.to_string(edit_exit))
end profile_create_or_edit
  • Step 4: Build
make build

Expected: clean build. If fd.STDIN() or fd.fd_write are missing, surface as an open question and hand-roll the equivalent from already-exported primitives.

  • Step 5: Commit
hg add src/incus.reef
hg commit -m "incus: add profile_create_or_edit (stdin-feed YAML to incus profile edit)"

Task 9: incus.device_add_disk_opts — extended disk-device add

The existing device_add_disk(project, name, dev, src, dst) doesn't support shift=true or readonly=true, both of which are required by the llm-share profile. Add a new function that takes an opts list (each "key=value" string is appended to the argv as a separate arg).

Files:

  • Modify: src/incus.reef

  • Step 1: Add to export block

    fn device_add_disk_opts(project: string, name: string, dev: string, src: string, dst: string, opts: [string]): rg.Result[bool, string]
  • Step 2: Implement device_add_disk_opts

After the existing device_add_disk (line ~245), add:

// Like device_add_disk but with extra options like ["shift=true",
// "readonly=true"] appended to the incus argv.
fn device_add_disk_opts(project: string, name: string, dev: string, src: string, dst: string, opts: [string]): rg.Result[bool, string]
    let on: int = opts.length()
    mut args: [string] = new [string](7 + on)
    args[0] = "config"
    args[1] = "device"
    args[2] = "add"
    args[3] = "--project"
    args[4] = project
    args[5] = name
    args[6] = dev
    // (note: no "disk" type arg here — appended after as the 8th, with src/dst/opts)
    return device_add_disk_opts_inner(project, name, dev, src, dst, opts)
end device_add_disk_opts

fn device_add_disk_opts_inner(project: string, name: string, dev: string, src: string, dst: string, opts: [string]): rg.Result[bool, string]
    let on: int = opts.length()
    // Final argv: ["config", "device", "add", "--project", P, NAME, DEV, "disk",
    //             "source=...", "path=...", opts...]
    mut args: [string] = new [string](10 + on)
    args[0] = "config"
    args[1] = "device"
    args[2] = "add"
    args[3] = "--project"
    args[4] = project
    args[5] = name
    args[6] = dev
    args[7] = "disk"
    args[8] = "source=" ++ src
    args[9] = "path=" ++ dst
    mut i: int = 0
    while i < on
        args[10 + i] = opts[i]
        i = i + 1
    end while
    return run_incus(args)
end device_add_disk_opts_inner

(Two functions because reef doesn't allow shadowing — the inner does the work, the outer is the public surface.)

  • Step 3: Build
make build

Expected: clean build.

  • Step 4: Commit
hg add src/incus.reef
hg commit -m "incus: add device_add_disk_opts for shift/readonly disk devices"

Task 10: hermes module skeleton + pure helpers

Files:

  • Create: src/hermes.reef

  • Test: tests/test_hermes_paths.reef

  • Step 1: Write the failing test

Create tests/test_hermes_paths.reef:

import hermes
import test.framework

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

    runner.assert_eq_string(
        hermes.state_dir_for("/home/ctusa", "isurus"),
        "/home/ctusa/.local/share/repoman/hermes/isurus",
        "state_dir_for layout"
    )

    let seed = hermes.default_seed_list()
    runner.assert_eq_int(seed.length(), 8, "default seed list size = 8")
    runner.assert_eq_string(seed[0], ".env",          "first entry .env")
    runner.assert_eq_string(seed[1], "config.yaml",   "second config.yaml")
    runner.assert_eq_string(seed[2], "SOUL.md",       "third SOUL.md")
    runner.assert_eq_string(seed[3], "skills/",       "fourth skills/")
    runner.assert_eq_string(seed[4], "hooks/",        "fifth hooks/")
    runner.assert_eq_string(seed[5], "hermes-agent/", "sixth hermes-agent/")
    runner.assert_eq_string(seed[6], "node/",         "seventh node/")
    runner.assert_eq_string(seed[7], "bin/",          "eighth bin/")

    runner.report()
end main
  • Step 2: Run test to verify it fails
reefc run tests/test_hermes_paths.reef

Expected: compile error — module hermes not found.

  • Step 3: Create the hermes module skeleton

Create src/hermes.reef:

module hermes

import core.str
import paths

export
    fn state_dir_for(home_dir: string, container_name: string): string
    fn default_seed_list(): [string]
    fn classify_seed_entry(name: string): int
end export

// Layout under ~/.local/share/repoman/hermes/<container-name>/
fn state_dir_for(home_dir: string, container_name: string): string
    let base: string = paths.join(home_dir, ".local/share/repoman/hermes")
    return paths.join(base, container_name)
end state_dir_for

// Default selective-seed list: what to copy/symlink from the host's
// ~/.hermes/ into a per-container data dir. Per-instance state
// (sessions/, memories/, state.db, etc.) is *not* in this list.
fn default_seed_list(): [string]
    return [
        ".env",
        "config.yaml",
        "SOUL.md",
        "skills/",
        "hooks/",
        "hermes-agent/",
        "node/",
        "bin/"
    ]
end default_seed_list

// Constants exposed to the applier so it knows whether to copy or symlink
// each seed entry.
export
    fn SEED_KIND_COPY(): int
    fn SEED_KIND_SYMLINK(): int
end export

fn SEED_KIND_COPY(): int
    return 1
end SEED_KIND_COPY

fn SEED_KIND_SYMLINK(): int
    return 2
end SEED_KIND_SYMLINK

// Classify a seed entry: runtime dirs (hermes-agent/, node/, bin/) are
// symlinks; everything else is a copy. Trailing-slash convention from
// default_seed_list() is honored.
fn classify_seed_entry(name: string): int
    if name == "hermes-agent/" or name == "node/" or name == "bin/"
        return SEED_KIND_SYMLINK()
    end if
    return SEED_KIND_COPY()
end classify_seed_entry

end module
  • Step 4: Run the test
reefc run tests/test_hermes_paths.reef

Expected: all 9 assertions pass.

  • Step 5: Commit
hg add src/hermes.reef tests/test_hermes_paths.reef
hg commit -m "hermes: module skeleton — state_dir_for, default_seed_list, classify_seed_entry"

Task 11: hermes.classify_seed_entry test

Already implemented in Task 10; this task adds the dedicated unit test for the partition logic.

Files:

  • Test: tests/test_hermes_classify.reef

  • Step 1: Write the test

Create tests/test_hermes_classify.reef:

import hermes
import test.framework

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

    let copy = hermes.SEED_KIND_COPY()
    let link = hermes.SEED_KIND_SYMLINK()

    // Runtime directories → symlink
    runner.assert_eq_int(hermes.classify_seed_entry("hermes-agent/"), link, "hermes-agent/ symlink")
    runner.assert_eq_int(hermes.classify_seed_entry("node/"),         link, "node/ symlink")
    runner.assert_eq_int(hermes.classify_seed_entry("bin/"),          link, "bin/ symlink")

    // Credentials/config/customizations → copy
    runner.assert_eq_int(hermes.classify_seed_entry(".env"),          copy, ".env copy")
    runner.assert_eq_int(hermes.classify_seed_entry("config.yaml"),   copy, "config.yaml copy")
    runner.assert_eq_int(hermes.classify_seed_entry("SOUL.md"),       copy, "SOUL.md copy")
    runner.assert_eq_int(hermes.classify_seed_entry("skills/"),       copy, "skills/ copy (user data, not runtime)")
    runner.assert_eq_int(hermes.classify_seed_entry("hooks/"),        copy, "hooks/ copy")

    // Unknown entries → conservative default (copy)
    runner.assert_eq_int(hermes.classify_seed_entry("custom_thing"),  copy, "unknown defaults to copy")

    runner.report()
end main
  • Step 2: Run the test
reefc run tests/test_hermes_classify.reef

Expected: all 9 assertions pass.

  • Step 3: Commit
hg add tests/test_hermes_classify.reef
hg commit -m "hermes: classify_seed_entry — explicit copy/symlink partition test"

Given a source dir (e.g., /home/ctusa/.hermes), a destination dir (e.g., /home/ctusa/.local/share/repoman/hermes/isurus), and a seed list, create the destination if missing, then for each entry: if classify_seed_entry is COPY, recursively copy; if SYMLINK, create a symlink pointing back to the source path.

Files:

  • Modify: src/hermes.reef

  • Step 1: Add to export block

    fn seed_data_dir(source: string, dest: string, seed: [string]): rg.Result[bool, string]

(Re-add after the SEED_KIND_* exports.)

  • Step 2: Add imports needed

At the top of src/hermes.reef, add:

import core.result_generic as rg
import core.convert as convert
import io.dir as iodir
import sys.process as p
  • Step 3: Implement seed_data_dir

Just before end module, add:

// Strip a trailing slash from a string ("hermes-agent/" → "hermes-agent").
// Used to normalize seed-list entries before paths.join.
fn strip_trailing_slash(s: string): string
    let n: int = str.length(s)
    if n > 0 and s[n - 1] == '/'
        return str.substring(s, 0, n - 1)
    end if
    return s
end strip_trailing_slash

// Recursively copy `src` to `dst`. We don't have a built-in recursive copy
// in the reef stdlib, so we shell out to `cp -a`. argv-list spawn — never
// shell — so user-supplied paths can't escape.
fn cp_recursive(src: string, dst: string): bool
    let pid: int = p.process_spawn("cp", ["-a", src, dst])
    if pid < 0
        return false
    end if
    return p.process_wait(pid) == 0
end cp_recursive

// Create a symlink at `link` pointing to `target` (absolute path).
fn make_symlink(target: string, link: string): bool
    let pid: int = p.process_spawn("ln", ["-sfn", target, link])
    if pid < 0
        return false
    end if
    return p.process_wait(pid) == 0
end make_symlink

// Selectively seed `source` into `dest` per the seed list.
// Idempotent for COPY entries (cp -a overwrites); idempotent for
// SYMLINK entries (ln -sfn replaces existing links).
// Returns Err on the first failure with a message naming the offending entry.
fn seed_data_dir(source: string, dest: string, seed: [string]): rg.Result[bool, string]
    if not iodir.dir_exists(source)
        return @Result[bool, string].Err("hermes source dir not found: " ++ source)
    end if
    if not iodir.create_dir_all(dest)
        return @Result[bool, string].Err("cannot create dest dir: " ++ dest)
    end if

    let n: int = seed.length()
    mut i: int = 0
    while i < n
        let entry: string = seed[i]
        let normalized: string = strip_trailing_slash(entry)
        let src_path: string = paths.join(source, normalized)
        let dst_path: string = paths.join(dest, normalized)
        let kind: int = classify_seed_entry(entry)

        if kind == SEED_KIND_COPY()
            if not cp_recursive(src_path, dst_path)
                return @Result[bool, string].Err("copy failed: " ++ entry)
            end if
        else
            if not make_symlink(src_path, dst_path)
                return @Result[bool, string].Err("symlink failed: " ++ entry)
            end if
        end if
        i = i + 1
    end while
    return @Result[bool, string].Ok(true)
end seed_data_dir
  • Step 4: Build to verify it compiles
make build

Expected: clean build.

  • Step 5: Smoke-test seed_data_dir

This function is effectful (touches the filesystem and forks cp/ln), so we exercise it via a manual smoke run rather than a unit test. Create /tmp/hermes-smoke.sh:

#!/bin/bash
set -e
SRC=/tmp/hermes-smoke-src
DST=/tmp/hermes-smoke-dst
rm -rf $SRC $DST
mkdir -p $SRC/skills $SRC/hooks $SRC/hermes-agent
echo 'KEY=secret' > $SRC/.env
echo 'model: foo' > $SRC/config.yaml
echo 'agent runtime' > $SRC/hermes-agent/index.js
echo 'a skill' > $SRC/skills/skill1.md

# Use a tiny reef wrapper to call hermes.seed_data_dir; or hand-trace by
# running the equivalent ops:
mkdir -p $DST
cp -a $SRC/.env $DST/.env
cp -a $SRC/config.yaml $DST/config.yaml
cp -a $SRC/skills $DST/skills
cp -a $SRC/hooks $DST/hooks 2>/dev/null || true
ln -sfn $SRC/hermes-agent $DST/hermes-agent
ln -sfn $SRC/node $DST/node 2>/dev/null || true  # may not exist
ln -sfn $SRC/bin $DST/bin 2>/dev/null || true

ls -la $DST
[[ -L $DST/hermes-agent ]] && echo "OK: hermes-agent is a symlink"
[[ -f $DST/.env ]] && echo "OK: .env is a regular file"
echo "smoke OK"
bash /tmp/hermes-smoke.sh

Expected: shows OK: hermes-agent is a symlink, OK: .env is a regular file. Confirms the model.

  • Step 6: Commit
hg add src/hermes.reef
hg commit -m "hermes: seed_data_dir — selective copy + symlink from host ~/.hermes"

Task 13: hermes.purge_data_dir

Files:

  • Modify: src/hermes.reef

  • Step 1: Add to export block

    fn purge_data_dir(dest: string): rg.Result[bool, string]
  • Step 2: Implement
// Recursively delete the per-container hermes data dir. Loud; only called
// from `repoman remove --purge-hermes`.
fn purge_data_dir(dest: string): rg.Result[bool, string]
    if not iodir.dir_exists(dest)
        return @Result[bool, string].Ok(true)  // nothing to do
    end if
    let pid: int = p.process_spawn("rm", ["-rf", dest])
    if pid < 0
        return @Result[bool, string].Err("failed to spawn rm")
    end if
    let exit: int = p.process_wait(pid)
    if exit == 0
        return @Result[bool, string].Ok(true)
    end if
    return @Result[bool, string].Err("rm -rf exited " ++ convert.to_string(exit))
end purge_data_dir
  • Step 3: Build
make build

Expected: clean build.

  • Step 4: Commit
hg add src/hermes.reef
hg commit -m "hermes: purge_data_dir for --purge-hermes flow"

Task 14: setup module skeleton + Environment struct

Files:

  • Create: src/setup.reef

  • Step 1: Create the module skeleton

module setup

import core.str
import core.result_generic as rg
import core.convert as convert
import io.console as console
import io.file as iofile
import io.dir as iodir
import sys.env
import sys.process as p
import sys.fd as fd
import paths
import incus
import hermes
import config

export
    type Environment
    fn detect_environment(home_dir: string): Environment
    fn detect_host_lan_ip(): string
    fn render_llm_share_template(host_lan_ip: string, user: string): string
    type Stage
    fn plan_stages(env: Environment, with_llm: bool): [Stage]
    fn cmd_setup(argv: [string]): int
end export

// Snapshot of the host state that `setup` cares about.
type Environment = struct
    home_dir:             string
    user:                 string
    host_lan_ip:          string         // empty if br0 not found
    incus_reachable:      bool
    repoman_project_present: bool
    claude_share_present: bool
    ollama_binary:        string         // path; empty if not installed
    ollama_lan_ok:        bool           // listening on LAN, not just loopback
    hermes_binary:        string         // path; empty if not installed
    hermes_data_present:  bool           // ~/.hermes exists
end Environment

// Stage is a planned action with a description for the user.
type Stage = struct
    id:          string                  // "incus_project", "llm_share_profile", etc.
    description: string                  // user-facing
    is_change:   bool                    // false → no-op, just inform
end Stage

// (function bodies follow in subsequent tasks)

fn detect_environment(home_dir: string): Environment
    return Environment {
        home_dir:             home_dir,
        user:                 "",
        host_lan_ip:          "",
        incus_reachable:      false,
        repoman_project_present: false,
        claude_share_present: false,
        ollama_binary:        "",
        ollama_lan_ok:        false,
        hermes_binary:        "",
        hermes_data_present:  false
    }
end detect_environment

fn detect_host_lan_ip(): string
    return ""
end detect_host_lan_ip

fn render_llm_share_template(host_lan_ip: string, user: string): string
    return ""
end render_llm_share_template

fn plan_stages(env: Environment, with_llm: bool): [Stage]
    return new [Stage](0)
end plan_stages

fn cmd_setup(argv: [string]): int
    return 0
end cmd_setup

end module
  • Step 2: Build to verify the skeleton compiles
make build

Expected: clean build. (Functions are stubs; later tasks fill them.)

  • Step 3: Commit
hg add src/setup.reef
hg commit -m "setup: module skeleton — Environment, Stage, stubbed entry points"

Task 15: setup.detect_host_lan_ip (parse ip -4 addr show br0)

Run ip -4 addr show br0, parse the first inet X.Y.Z.W/N line, return X.Y.Z.W. Empty string if anything fails.

Files:

  • Modify: src/setup.reef

  • Step 1: Replace the stub with a real implementation

In src/setup.reef, replace the body of detect_host_lan_ip:

fn detect_host_lan_ip(): string
    let cap = incus.process_run_capture("ip", ["-4", "addr", "show", "br0"])
    if cap.exit_code != 0
        return ""
    end if
    // Look for the first occurrence of "inet " and grab the IPv4 token after it
    // until the next slash. `cap.stdout` is small, hand-roll the scan.
    let s: string = cap.stdout
    let n: int = str.length(s)
    let needle: string = "inet "
    let needle_len: int = 5
    mut i: int = 0
    while i + needle_len <= n
        if str.substring(s, i, needle_len) == needle
            let start: int = i + needle_len
            mut end_idx: int = start
            while end_idx < n and s[end_idx] != '/' and s[end_idx] != ' '
                end_idx = end_idx + 1
            end while
            return str.substring(s, start, end_idx - start)
        end if
        i = i + 1
    end while
    return ""
end detect_host_lan_ip

(Note: incus.process_run_capture is currently private to that module. To call it from setup, either export it from incus (preferred — small, no leakage of intent) or replicate the capture helper here. Choose: export — change src/incus.reef to add process_run_capture to its export block, with the existing CaptureResult type also exported.)

  • Step 2: Export from incus

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

    type CaptureResult
    fn process_run_capture(program: string, args: [string]): CaptureResult
  • Step 3: Build
make build

Expected: clean build.

  • Step 4: Smoke-test detect_host_lan_ip

There's no clean unit test for this (depends on the host's network), so smoke-test from a tiny throwaway program.

cat > /tmp/test_lanip.reef <<'EOF'
import setup
import io.console as console
proc main()
    let ip = setup.detect_host_lan_ip()
    console.print("br0 IP: [" ++ ip ++ "]\n")
end main
EOF
reefc run /tmp/test_lanip.reef

Expected: prints something like br0 IP: [192.168.168.42]. If your host has no br0, prints br0 IP: [] — note this for the spec's open question O-4.

  • Step 5: Commit
hg add src/setup.reef src/incus.reef
hg commit -m "setup: detect_host_lan_ip via ip -4 addr show br0; export incus.process_run_capture"

Task 16: setup.render_llm_share_template

Pure function: substitute {HOST_LAN_IP} and {USER} in the embedded template. Test with a golden string.

Files:

  • Modify: src/setup.reef

  • Test: tests/test_setup_template.reef

  • Step 1: Write the failing test

Create tests/test_setup_template.reef:

import setup
import test.framework

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

    let yaml = setup.render_llm_share_template("192.168.168.42", "ctusa")

    runner.assert_contains_string(yaml, "name: llm-share",                "name line")
    runner.assert_contains_string(yaml, "OLLAMA_HOST",                    "ollama env key")
    runner.assert_contains_string(yaml, "http://192.168.168.42:11434",    "lan ip substitution")
    runner.assert_contains_string(yaml, "/usr/local/bin/ollama",          "ollama bin path")
    runner.assert_contains_string(yaml, "/home/ctusa/.ollama",            "user path substitution")
    runner.assert_contains_string(yaml, "shift: \"true\"",                "shift opt set")
    runner.assert_contains_string(yaml, "readonly: \"true\"",             "readonly opt on bin")

    // No placeholders should remain
    runner.assert_eq_bool(false, setup.template_contains_placeholder(yaml), "no {HOST_LAN_IP} or {USER} left")

    runner.report()
end main
  • Step 2: Run to verify it fails
reefc run tests/test_setup_template.reef

Expected: fails — function returns empty string.

  • Step 3: Implement

In src/setup.reef, add a constant template + replace the stub body. Add to the export block:

    fn template_contains_placeholder(s: string): bool

Then replace render_llm_share_template and add the helper:

fn render_llm_share_template(host_lan_ip: string, user: string): string
    let template: string =
        "name: llm-share\n" ++
        "description: |\n" ++
        "  Local LLM client tools (ollama client + hermes runtime) and host-daemon wiring.\n" ++
        "  Created by repoman setup; do not hand-edit (changes will be overwritten).\n" ++
        "config:\n" ++
        "  environment.OLLAMA_HOST: \"http://{HOST_LAN_IP}:11434\"\n" ++
        "devices:\n" ++
        "  ollama-bin:\n" ++
        "    type: disk\n" ++
        "    source: /usr/local/bin/ollama\n" ++
        "    path: /usr/local/bin/ollama\n" ++
        "    readonly: \"true\"\n" ++
        "  ollama-state:\n" ++
        "    type: disk\n" ++
        "    source: /home/{USER}/.ollama\n" ++
        "    path: /home/{USER}/.ollama\n" ++
        "    shift: \"true\"\n"
    let s1: string = str.replace(template, "{HOST_LAN_IP}", host_lan_ip)
    let s2: string = str.replace(s1,       "{USER}",        user)
    return s2
end render_llm_share_template

fn template_contains_placeholder(s: string): bool
    return str.contains(s, "{HOST_LAN_IP}") or str.contains(s, "{USER}")
end template_contains_placeholder

(core.str.replace(s, old, new) is the global-replace function in the reef stdlib — verified at ~/reef-lang-0.5.20-source/reef-stdlib/core/str.reef:65.)

  • Step 4: Run the test to verify it passes
reefc run tests/test_setup_template.reef

Expected: all 8 assertions pass.

  • Step 5: Commit
hg add src/setup.reef tests/test_setup_template.reef
hg commit -m "setup: render_llm_share_template with {HOST_LAN_IP}/{USER} substitution"

Task 17: setup.detect_environment (full env probe)

Populate every field of Environment: HOME, USER, br0 IP, incus reachability, project/profile presence, ollama/hermes binaries, ollama LAN listening status.

Files:

  • Modify: src/setup.reef

  • Step 1: Replace the stub

// Probe whether a binary is in PATH by running `command -v <name>`.
// Returns the path on success, empty string on failure.
fn which_binary(name: string): string
    let cap = incus.process_run_capture("sh", ["-c", "command -v " ++ name])
    if cap.exit_code != 0
        return ""
    end if
    return str.trim_ws(cap.stdout)
end which_binary

// Returns true if anything is listening on the given TCP host:port.
// Uses /bin/sh + getent + bash's TCP redirection? Simpler: try
// `curl -sS -o /dev/null --connect-timeout 1 http://<ip>:11434`
// and check the exit. curl is in claude-share's image baseline.
fn ollama_listening_at(host: string): bool
    let url: string = "http://" ++ host ++ ":11434"
    let pid: int = p.process_spawn("curl", [
        "-sS", "-o", "/dev/null", "--connect-timeout", "1", url
    ])
    if pid < 0
        return false
    end if
    return p.process_wait(pid) == 0
end ollama_listening_at

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

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

    // repoman project + claude-share profile (only if incus is up)
    mut project_ok: bool = false
    mut claude_share_ok: bool = false
    if incus_ok
        let pe = incus.project_ensure_check_only("repoman")  // helper added below if needed
        project_ok = rg.is_ok(pe) and rg.unwrap_ok(pe)
        let cs = incus.profile_exists("default", "claude-share")
        claude_share_ok = rg.is_ok(cs) and rg.unwrap_ok(cs)
    end if

    let ollama_path: string = which_binary("ollama")
    let hermes_path: string = which_binary("hermes")
    let hermes_data_dir: string = paths.join(home_dir, ".hermes")
    let hermes_data_ok: bool = iodir.dir_exists(hermes_data_dir)

    mut ollama_lan_ok: bool = false
    if str.length(lan_ip) > 0
        ollama_lan_ok = ollama_listening_at(lan_ip)
    end if

    return Environment {
        home_dir:             home_dir,
        user:                 user,
        host_lan_ip:          lan_ip,
        incus_reachable:      incus_ok,
        repoman_project_present: project_ok,
        claude_share_present: claude_share_ok,
        ollama_binary:        ollama_path,
        ollama_lan_ok:        ollama_lan_ok,
        hermes_binary:        hermes_path,
        hermes_data_present:  hermes_data_ok
    }
end detect_environment

(incus.project_ensure_check_only doesn't yet exist — that's what the helper-or-existing-function comment is about. Take the existing project_ensure(project, verbose) and split out a check-only path: rename or add a sibling fn project_present(project): rg.Result[bool, string] that runs incus project show silently and reports existence without creating. Add this small helper in src/incus.reef and export it; keep project_ensure for the create path.)

  • Step 2: Add incus.project_present

In src/incus.reef, after project_ensure, add:

fn project_present(project: string): rg.Result[bool, string]
    let pid: int = process_run_silent("incus", ["project", "show", project])
    if pid < 0
        return @Result[bool, string].Err("failed to spawn 'incus project show'")
    end if
    let exit: int = p.process_wait(pid)
    if exit == 0
        return @Result[bool, string].Ok(true)
    end if
    return @Result[bool, string].Ok(false)
end project_present

And export it. Update the detect_environment body to call incus.project_present("repoman") instead of the placeholder project_ensure_check_only.

Also export incus.process_run_silent for use in setup (same pattern as process_run_capture):

    fn process_run_silent(program: string, args: [string]): int
  • Step 3: Build
make build

Expected: clean build.

  • Step 4: Smoke-test detect_environment
cat > /tmp/test_detect.reef <<'EOF'
import setup
import sys.env
import io.console as console
proc main()
    let h = env.get_env_or("HOME", "")
    let e = setup.detect_environment(h)
    console.print("home: " ++ e.home_dir ++ "\n")
    console.print("user: " ++ e.user ++ "\n")
    console.print("br0 IP: [" ++ e.host_lan_ip ++ "]\n")
    console.print("incus reachable: " ++ (if e.incus_reachable then "yes" else "no") ++ "\n")
    console.print("repoman project: " ++ (if e.repoman_project_present then "yes" else "no") ++ "\n")
    console.print("claude-share: " ++ (if e.claude_share_present then "yes" else "no") ++ "\n")
    console.print("ollama: [" ++ e.ollama_binary ++ "] lan_ok=" ++ (if e.ollama_lan_ok then "y" else "n") ++ "\n")
    console.print("hermes: [" ++ e.hermes_binary ++ "] data=" ++ (if e.hermes_data_present then "y" else "n") ++ "\n")
end main
EOF
reefc run /tmp/test_detect.reef

Expected: each field printed with a sensible value matching the host's actual state.

  • Step 5: Commit
hg add src/setup.reef src/incus.reef
hg commit -m "setup: detect_environment + incus.project_present + exported probes"

Task 18: setup.plan_stages (pure)

Given an Environment and with_llm: bool, return the ordered list of stages with their is_change/no-op status.

Files:

  • Modify: src/setup.reef

  • Test: tests/test_setup_planner.reef

  • Step 1: Write the failing test

Create tests/test_setup_planner.reef:

import setup
import test.framework

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

    // Fully fresh host: nothing exists, with_llm = true
    let fresh = setup.Environment {
        home_dir: "/home/ctusa", user: "ctusa",
        host_lan_ip: "192.168.168.42",
        incus_reachable: true,
        repoman_project_present: false, claude_share_present: false,
        ollama_binary: "/usr/local/bin/ollama", ollama_lan_ok: true,
        hermes_binary: "/home/ctusa/.local/bin/hermes", hermes_data_present: true
    }
    let stages_fresh = setup.plan_stages(fresh, true)

    // Expect 4 actionable stages: incus_project, claude_share_check, llm_share_profile, registry_defaults
    runner.assert_eq_int(stages_fresh.length(), 4, "fresh + with_llm = 4 stages")
    runner.assert_eq_string(stages_fresh[0].id, "incus_project", "stage 0 = incus_project")
    runner.assert_eq_bool(stages_fresh[0].is_change, true, "incus_project will change")
    runner.assert_eq_string(stages_fresh[2].id, "llm_share_profile", "stage 2 = llm_share_profile")

    // Already set up host, no LLM: just verify
    let setup_done = setup.Environment {
        home_dir: "/home/ctusa", user: "ctusa",
        host_lan_ip: "192.168.168.42",
        incus_reachable: true,
        repoman_project_present: true, claude_share_present: true,
        ollama_binary: "", ollama_lan_ok: false,
        hermes_binary: "", hermes_data_present: false
    }
    let stages_done = setup.plan_stages(setup_done, false)
    runner.assert_eq_int(stages_done.length(), 3, "done host + no_llm = 3 stages (no llm_share)")
    let any_change: bool = stages_done[0].is_change or stages_done[1].is_change or stages_done[2].is_change
    runner.assert_eq_bool(any_change, false, "all stages no-op when host is set up")

    // Want LLM but no LAN IP detected → llm_share stage flagged is_change=false with explanatory description
    let no_br0 = setup.Environment {
        home_dir: "/home/ctusa", user: "ctusa",
        host_lan_ip: "",
        incus_reachable: true,
        repoman_project_present: true, claude_share_present: true,
        ollama_binary: "/usr/local/bin/ollama", ollama_lan_ok: false,
        hermes_binary: "/home/ctusa/.local/bin/hermes", hermes_data_present: true
    }
    let stages_no_br0 = setup.plan_stages(no_br0, true)
    runner.assert_eq_int(stages_no_br0.length(), 4, "with_llm but no br0 still plans 4 stages")
    // The llm_share stage is index 2; it should NOT mark itself is_change=true without an IP
    runner.assert_eq_bool(stages_no_br0[2].is_change, false, "llm_share is_change=false with no br0 IP")

    runner.report()
end main
  • Step 2: Run to verify it fails
reefc run tests/test_setup_planner.reef

Expected: assertion failures.

  • Step 3: Implement plan_stages
fn plan_stages(env: Environment, with_llm: bool): [Stage]
    mut count: int = 3                      // incus_project, claude_share_check, registry_defaults
    if with_llm
        count = 4                            // + llm_share_profile (between claude_share and registry)
    end if

    mut stages: [Stage] = new [Stage](count)

    stages[0] = Stage {
        id:          "incus_project",
        description: "ensure Incus project 'repoman' exists",
        is_change:   not env.repoman_project_present
    }
    stages[1] = Stage {
        id:          "claude_share_check",
        description: "verify 'claude-share' profile exists in default project",
        is_change:   false                  // we never modify claude-share; failure→exit
    }
    if with_llm
        let llm_change: bool = (str.length(env.host_lan_ip) > 0) and env.ollama_lan_ok
        stages[2] = Stage {
            id:          "llm_share_profile",
            description: "create/refresh 'llm-share' profile in repoman project",
            is_change:   llm_change
        }
        stages[3] = Stage {
            id:          "registry_defaults",
            description: "write registry defaults (schema 2, llm.enabled, profiles list)",
            is_change:   true
        }
    else
        stages[2] = Stage {
            id:          "registry_defaults",
            description: "write registry defaults (schema 2, llm.enabled=false)",
            is_change:   true
        }
    end if

    return stages
end plan_stages
  • Step 4: Run the test to verify it passes
reefc run tests/test_setup_planner.reef

Expected: all 8 assertions pass.

  • Step 5: Commit
hg add src/setup.reef tests/test_setup_planner.reef
hg commit -m "setup: plan_stages — pure stage planner with idempotency awareness"

Task 19: setup.apply_stage (per-stage applier)

Wire the planned stages to actual effects.

Files:

  • Modify: src/setup.reef

  • Step 1: Add to export block

    fn apply_stage(stage: Stage, env: Environment, reg: config.Registry): rg.Result[config.Registry, string]
  • Step 2: Implement

Add at the bottom of the module before end module:

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

    if stage.id == "claude_share_check"
        if env.claude_share_present
            return @Result[config.Registry, string].Ok(reg)
        end if
        return @Result[config.Registry, string].Err(
            "claude-share profile not found.\n" ++
            "Create it with:\n" ++
            "  incus profile create claude-share\n" ++
            "  incus profile edit claude-share  # add your bind-mounts (~/.claude, ~/.local/bin, ...)"
        )
    end if

    if stage.id == "llm_share_profile"
        if str.length(env.host_lan_ip) == 0
            return @Result[config.Registry, string].Err(
                "no br0 IP detected — cannot wire llm-share without a stable LAN address.\n" ++
                "Set up br0 first, then re-run 'repoman setup --with-llm'."
            )
        end if
        if not env.ollama_lan_ok
            return @Result[config.Registry, string].Err(
                "ollama daemon not reachable on " ++ env.host_lan_ip ++ ":11434.\n" ++
                "Add OLLAMA_HOST=" ++ env.host_lan_ip ++ ":11434 to your systemd unit and restart ollama."
            )
        end if
        let yaml: string = render_llm_share_template(env.host_lan_ip, env.user)
        let r = incus.profile_create_or_edit("repoman", "llm-share", yaml)
        if rg.is_err(r)
            return @Result[config.Registry, string].Err(rg.unwrap_err(r))
        end if
        return @Result[config.Registry, string].Ok(reg)
    end if

    if stage.id == "registry_defaults"
        // Compute desired LlmDefaults
        let want_enabled: bool = (str.length(env.host_lan_ip) > 0) and env.ollama_lan_ok and (str.length(env.hermes_binary) > 0)
        let new_llm = config.LlmDefaults {
            enabled:        want_enabled,
            hermes_default: false,
            ollama_url:     "http://" ++ env.host_lan_ip ++ ":11434",
            hermes_seed:    hermes.default_seed_list()
        }
        // Compute desired profiles list
        mut new_profiles: [string] = ["default", "claude-share"]
        if want_enabled
            new_profiles = ["default", "claude-share", "llm-share"]
        end if
        let new_defaults = config.Defaults {
            repos_root:    reg.defaults.repos_root,
            backup_root:   reg.defaults.backup_root,
            logdir:        reg.defaults.logdir,
            incus_project: reg.defaults.incus_project,
            default_image: reg.defaults.default_image,
            profiles:      new_profiles,
            llm:           new_llm
        }
        let new_reg = config.Registry {
            schema:   2,
            output:   reg.output,
            defaults: new_defaults,
            projects: reg.projects
        }
        return @Result[config.Registry, string].Ok(new_reg)
    end if

    return @Result[config.Registry, string].Err("unknown stage id: " ++ stage.id)
end apply_stage
  • Step 3: Build
make build

Expected: clean build.

  • Step 4: Commit
hg add src/setup.reef
hg commit -m "setup: apply_stage — per-stage applier composing incus + config writes"

Task 20: cli.cmd_setup wiring

Files:

  • Modify: src/cli.reef

  • Step 1: Add cmd_setup to the export block

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

    fn cmd_setup(argv: [string]): int
  • Step 2: Add the import

Near the top of cli.reef, add:

import setup
  • Step 3: Implement cmd_setup

After cmd_shell (line ~752) and before version_string, add:

fn cmd_setup(argv: [string]): int
    let parser: flag.FlagParser = flag.flag_parser_from(argv)
    flag.application(parser, "repoman setup")
    flag.description(parser, "First-time host bootstrap: incus project, profiles, registry")
    let _f1 = flag.bool_flag(parser, "non-interactive", '\0', false, "accept all defaults without prompting")
    let _f2 = flag.bool_flag(parser, "with-llm",        '\0', false, "include the LLM stack (llm-share profile, ollama wiring)")
    let _f3 = flag.bool_flag(parser, "without-llm",     '\0', false, "skip the LLM stack")

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

    let non_interactive: bool = flag.get_bool(parser, "non-interactive")
    let with_llm_flag: bool = flag.get_bool(parser, "with-llm")
    let without_llm_flag: bool = flag.get_bool(parser, "without-llm")
    if with_llm_flag and without_llm_flag
        console.printErr("repoman: error: --with-llm and --without-llm are mutually exclusive")
        return 2
    end if

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

    let env_snap = setup.detect_environment(home)
    if not env_snap.incus_reachable
        console.printErr("repoman: error: 'incus' is not installed or not on PATH")
        return 3
    end if

    // Decide LLM inclusion
    mut with_llm: bool = false
    if with_llm_flag
        with_llm = true
    else if without_llm_flag
        with_llm = false
    else if non_interactive
        with_llm = false                // safe default in non-interactive mode
    else
        // Interactive prompt
        console.print("Include local LLM stack (ollama + hermes wiring)? [y/N] ")
        let answer: string = console.readLine()
        let trimmed: string = str.trim_ws(answer)
        with_llm = trimmed == "y" or trimmed == "Y" or trimmed == "yes"
    end if

    // Plan & display
    let stages = setup.plan_stages(env_snap, with_llm)
    console.print("\nrepoman setup plan:\n")
    let n: int = stages.length()
    mut i: int = 0
    while i < n
        let st = stages[i]
        let marker: string = if st.is_change then " * " else "   "
        console.print(marker ++ "[" ++ st.id ++ "] " ++ st.description ++ "\n")
        i = i + 1
    end while
    console.print("\n* = will change; otherwise no-op\n\n")

    if not non_interactive
        console.print("proceed? [Y/n] ")
        let answer: string = console.readLine()
        let trimmed: string = str.trim_ws(answer)
        if trimmed == "n" or trimmed == "N" or trimmed == "no"
            console.print("aborted\n")
            return 4
        end if
    end if

    // Load registry (or init)
    let reg_r = config.load_or_init(home)
    if rg.is_err(reg_r)
        console.printErr("repoman: error: " ++ rg.unwrap_err(reg_r))
        return 3
    end if
    mut reg: config.Registry = rg.unwrap_ok(reg_r)

    // Apply stages
    mut k: int = 0
    while k < n
        let st = stages[k]
        console.print("==> [" ++ st.id ++ "] " ++ st.description ++ "\n")
        let r = setup.apply_stage(st, env_snap, reg)
        if rg.is_err(r)
            console.printErr("repoman: error: " ++ rg.unwrap_err(r))
            return 1
        end if
        reg = rg.unwrap_ok(r)
        k = k + 1
    end while

    // Persist registry
    let cfg_path: string = config.registry_path(home)
    let saved = config.save(reg, cfg_path)
    if rg.is_err(saved)
        console.printErr("repoman: error: " ++ rg.unwrap_err(saved))
        return 1
    end if

    console.print("\nsetup complete.\n")
    console.print("\n  next: repoman new <name>")
    if reg.defaults.llm.enabled
        console.print("        repoman new <name> --hermes")
    end if
    console.print("        repoman list\n")
    return 0
end cmd_setup
  • Step 4: Wire into the dispatcher

Find the dispatch function (around dispatch near the end of cli.reef) and add a setup arm. It looks like:

fn dispatch(argv: [string]): int
    ...
    if cmd == "new"
        return cmd_new(rest)
    end if
    ...

Add (alphabetically — between remove and shell, or wherever fits):

    if cmd == "setup"
        return cmd_setup(rest)
    end if

Also update the help text in the same function to add setup to the list of commands.

  • Step 5: Build and smoke-test
make build
./build/repoman setup --without-llm --non-interactive

Expected: prints the plan, applies each stage, says "setup complete".

  • Step 6: Commit
hg add src/cli.reef
hg commit -m "cli: cmd_setup — interactive host bootstrap with --with-llm/--without-llm"

Task 21: cmd_new --hermes / --no-hermes

Files:

  • Modify: src/cli.reef:30-226 (cmd_new)

  • Step 1: Add the flags

In cmd_new, after the existing flag definitions (line ~37), add:

    let _f3 = flag.bool_flag(parser, "hermes",     '\0', false, "provision a per-container hermes data dir (overrides default)")
    let _f4 = flag.bool_flag(parser, "no-hermes",  '\0', false, "skip per-container hermes data dir (overrides default)")
  • Step 2: Resolve want_hermes

After loading the registry (around line ~72) and resolving verbose, add:

    let cli_hermes: bool = flag.get_bool(parser, "hermes")
    let cli_no_hermes: bool = flag.get_bool(parser, "no-hermes")
    if cli_hermes and cli_no_hermes
        console.printErr("repoman: error: --hermes and --no-hermes are mutually exclusive")
        return 2
    end if
    let override_hermes: bool = override_has_hermes_field(override)  // helper added below
    mut want_hermes: bool = reg.defaults.llm.hermes_default
    if cli_hermes
        want_hermes = true
    else if cli_no_hermes
        want_hermes = false
    else if override_hermes
        want_hermes = override_hermes_value(override)
    end if
    if want_hermes and not reg.defaults.llm.enabled
        console.printErr("repoman: error: --hermes requires LLM stack enabled. Run 'repoman setup --with-llm' first.")
        return 3
    end if

(Note: this assumes Override carries an optional hermes field; that's deferred — for v0.3 the override-file [hermes].enabled is not yet read by parse_override. To keep this task self-contained, simplify: drop the override branch:

    let cli_hermes: bool = flag.get_bool(parser, "hermes")
    let cli_no_hermes: bool = flag.get_bool(parser, "no-hermes")
    if cli_hermes and cli_no_hermes
        console.printErr("repoman: error: --hermes and --no-hermes are mutually exclusive")
        return 2
    end if
    mut want_hermes: bool = reg.defaults.llm.hermes_default
    if cli_hermes
        want_hermes = true
    else if cli_no_hermes
        want_hermes = false
    end if
    if want_hermes and not reg.defaults.llm.enabled
        console.printErr("repoman: error: --hermes requires LLM stack enabled. Run 'repoman setup --with-llm' first.")
        return 3
    end if

The override-file integration is a v0.4 task. Update the spec's §6.2 "open question" annotation accordingly when committing.)

  • Step 3: After the existing container restart, seed the hermes dir

After the incus.restart call (around line ~196), before the registry write, add:

    // Seed per-container hermes data dir + add disk device (opt-in)
    if want_hermes
        let dest: string = hermes.state_dir_for(home, name)
        if iodir.dir_exists(dest)
            log.write("repoman: error: hermes data dir already exists at " ++ dest)
            log.write("hint: repoman remove --purge-hermes " ++ name ++ "  (then retry)")
            return 4
        end if
        let source: string = paths.join(home, ".hermes")
        log.write("==> hermes seed " ++ source ++ " → " ++ dest)
        let sr = hermes.seed_data_dir(source, dest, reg.defaults.llm.hermes_seed)
        if rg.is_err(sr)
            log.write("repoman: error: " ++ rg.unwrap_err(sr))
            return 1
        end if
        let in_path: string = "/home/" ++ env.get_env_or("USER", "") ++ "/.hermes"
        log.write("==> incus device add " ++ name ++ " hermes-state " ++ dest ++ " → " ++ in_path)
        let dr = incus.device_add_disk_opts(reg.defaults.incus_project, name, "hermes-state", dest, in_path, ["shift=true"])
        if rg.is_err(dr)
            log.write("repoman: error: " ++ rg.unwrap_err(dr))
            log.write("hint: repoman remove --purge-hermes " ++ name)
            return 1
        end if
        // Restart again so the new device is mounted
        let rr2 = incus.restart(reg.defaults.incus_project, name)
        if rg.is_err(rr2)
            log.write("repoman: error: " ++ rg.unwrap_err(rr2))
            return 1
        end if
    end if
  • Step 4: Update the new Project literal

The new_p literal (line ~200) needs hermes: want_hermes:

    let new_p: config.Project = config.Project {
        name:      name,
        repo:      repo,
        image:     eff.image,
        profiles:  eff.profiles,
        created:   now,
        last_sync: "",
        backup:    true,
        hermes:    want_hermes
    }
  • Step 5: Add the imports needed by cmd_new

At the top of cli.reef, ensure these imports exist:

import hermes
import io.dir as iodir

(paths is already imported; incus already; iofile already.)

  • Step 6: Build and smoke
make build

Expected: clean build. (Smoke test deferred to Task 25.)

  • Step 7: Commit
hg add src/cli.reef
hg commit -m "cli: cmd_new --hermes/--no-hermes — seed data dir + add disk device"

Task 22: cmd_remove --purge-hermes

Files:

  • Modify: src/cli.reef:577-669 (cmd_remove)

  • Step 1: Add the flag

In cmd_remove, alongside the existing flag definitions, add:

    let _fph = flag.bool_flag(parser, "purge-hermes", '\0', false, "also delete the per-container hermes data dir")
  • Step 2: After successful container delete + registry write, optionally purge the data dir

Find the block where cmd_remove writes the registry after successful container removal. Immediately after the config.save returns Ok, add:

    let purge: bool = flag.get_bool(parser, "purge-hermes")
    if purge
        let dest: string = hermes.state_dir_for(home, name)
        log.write("==> purge hermes data dir: " ++ dest)
        let pr = hermes.purge_data_dir(dest)
        if rg.is_err(pr)
            log.write("repoman: warning: " ++ rg.unwrap_err(pr))
            // Don't fail the overall remove on purge failure — container is gone.
        end if
    end if

(If cmd_remove doesn't currently expose home/name at the right scope, hoist them — they are loaded earlier in the function.)

  • Step 3: Build
make build

Expected: clean build.

  • Step 4: Commit
hg add src/cli.reef
hg commit -m "cli: cmd_remove --purge-hermes — also delete per-container data dir"

Task 23: cmd_list and cmd_status show hermes flag

Files:

  • Modify: src/cli.reef:400-575 (cmd_list, cmd_status_one, cmd_status_all)

  • Step 1: cmd_list output column

Find the loop in cmd_list that prints each project (around line 440). It currently prints something like:

NAME           REPO                 IMAGE                                 PROFILES
isurus         isurus               images:ubuntu/26.04/cloud             default,claude-share

Add a HERMES column. The exact format depends on the existing print pattern — match it. For example, if rows are produced by console.print(p.name + "\t" + p.repo + "\t" + ...), add + "\t" + (if p.hermes then "yes" else "no"). Read the function to see the exact pattern, then mirror it.

  • Step 2: cmd_status_one — show hermes line

In cmd_status_one, add a line after the existing project info:

    console.print("hermes: " ++ (if p.hermes then "yes" else "no") ++ "\n")
    if p.hermes
        let dest: string = hermes.state_dir_for(home, name)
        console.print("  data dir: " ++ dest ++ "\n")
    end if
  • Step 3: Build
make build

Expected: clean build.

  • Step 4: Commit
hg add src/cli.reef
hg commit -m "cli: list/status show hermes column + data dir"

Task 24: README + VISION updates + reef.toml version bump

Files:

  • Modify: reef.toml, README.md, VISION.md

  • Step 1: Bump version

In reef.toml, change:

version = "0.1.0"

to:

version = "0.3.0"
  • Step 2: README — add setup + LLM section

In README.md, add a new section after the existing usage examples:

## Setup wizard

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

    repoman setup                     # interactive
    repoman setup --non-interactive   # accept defaults
    repoman setup --with-llm          # include local LLM stack (ollama + hermes)

The wizard creates the Incus project `repoman`, verifies your `claude-share` profile
exists, and (with `--with-llm`) creates an `llm-share` profile that wires containers
to your host's ollama daemon over LAN.

## Local LLM stack

`repoman setup --with-llm` provisions:

- An Incus profile `llm-share` that bind-mounts `/usr/local/bin/ollama` (read-only)
  and `~/.ollama/`, and sets `OLLAMA_HOST=http://<host-lan-ip>:11434` in the
  container environment.
- Registry default `[defaults].llm.enabled = true`.

Per-project hermes data directories (opt-in):

    repoman new myapp --hermes

This selectively seeds your host's `~/.hermes/` (credentials, config, skills, hooks,
runtime symlinks) into `~/.local/share/repoman/hermes/myapp/` and bind-mounts that
into the container as `~/.hermes`. Per-container sessions, memories, and SQLite state
stay isolated — never share a hermes data dir between two running instances.

To delete the data dir alongside the container:

    repoman remove myapp --purge-hermes
  • Step 3: VISION updates

In VISION.md, find the line in the subcommand table for repoman setup and update its description:

| `repoman setup` | First-time host setup. Creates Incus project `repoman`, ensures profiles exist (`claude-share` user-managed, `llm-share` repoman-managed), validates LAN ollama reachability if --with-llm. Idempotent. **Shipped in v0.3.** |
  • Step 4: Commit
hg add reef.toml README.md VISION.md
hg commit -m "docs: v0.3 — README sections for setup + LLM stack; bump version"

Task 25: End-to-end smoke test

Verify a complete setupnew --hermesshell into container and run hermesremove --purge-hermes cycle on the actual host. This is the manual gate before tagging v0.3.0.

Files:

  • (none — manual verification)

  • Step 1: Build

make build && sudo make install

Expected: repoman --version reports 0.3.0.

  • Step 2: Setup wizard (with LLM)
repoman setup --with-llm --non-interactive

Expected: prints the plan; applies stages; reports setup complete. Verify:

incus profile list --project repoman
# llm-share should be listed
incus profile show --project repoman llm-share | grep OLLAMA_HOST
# should show OLLAMA_HOST: "http://<your-lan-ip>:11434"
cat ~/.config/repoman/repoman.toml | grep -E 'schema|llm'
# schema = 2; [defaults.llm] block with enabled = true
  • Step 3: Create a hermes-enabled container
mkdir -p ~/repos/smoke-test
echo "smoke" > ~/repos/smoke-test/README.md
repoman new smoke-test --hermes

Expected: container launched, hermes data dir seeded, device added, container restarted, registry updated.

ls ~/.local/share/repoman/hermes/smoke-test/
# should show .env, config.yaml, skills/, hooks/, hermes-agent (symlink), node (symlink), bin (symlink), SOUL.md
incus exec --project repoman smoke-test -- ls -la /home/$USER/.hermes
# inside the container, the same layout should appear
  • Step 4: Validate symlinks across the bind boundary (spec O-3)
incus exec --project repoman smoke-test -- readlink /home/$USER/.hermes/hermes-agent
incus exec --project repoman smoke-test -- ls /home/$USER/.hermes/hermes-agent/

Expected: readlink shows the host path; the listing succeeds. If the listing fails (broken symlink across mount namespace), this validates spec open question O-3 in the negative — escalate to a follow-up task: change classify_seed_entry to return SEED_KIND_COPY() for the runtime dirs and accept the upgrade-coordination cost.

  • Step 5: Run hermes from inside the container
repoman shell smoke-test
# inside the container:
hermes --version       # should print hermes version, identical to host
ollama list             # should hit the host daemon and list models
exit

Expected: both commands succeed. If hermes fails because some library/path is unresolved, surface the exact error — likely a sub-fix for the symlink set or an additional bind-mount needed in the claude-share profile (e.g., ~/.local/lib/python*/site-packages/).

  • Step 6: Cleanup with --purge-hermes
repoman remove smoke-test --purge-hermes
ls ~/.local/share/repoman/hermes/smoke-test/ 2>&1
# should report "No such file or directory"
  • Step 7: Tag v0.3.0
hg tag v0.3.0
hg commit -m "v0.3.0 ships (setup wizard + llm-share profile + per-container hermes seeding)"

(Or, if your project tags via release notes only: skip the formal tag and ensure the commit message above is the release marker.)


Self-review

Spec coverage check (against 2026-05-06-repoman-v0.3-llm-and-setup.md):

  • §0 New vs v0.2 — covered by the entire plan.
  • §1 In-scope items — repoman setup: T14–T20. llm-share: T8, T15–T20. --hermes/--no-hermes: T21. --purge-hermes: T22. Selective seeding: T10–T12. Host LAN-IP detection: T15. [defaults].llm block: T1, T3, T4, T5, T6.
  • §2 New modules — setup.reef: T14–T19. hermes.reef: T10–T13.
  • §2 Edits to cli.reef/config.reef/incus.reef — T7–T9 (incus), T1–T6 (config), T20–T23 (cli).
  • §3 llm-share profile — T16 (template), T19 (apply), T20 (cmd_setup wiring).
  • §4 Per-container hermes data dirs — T10 (paths), T11 (classify), T12 (seed), T13 (purge), T21 (cmd_new wiring), T22 (cmd_remove wiring).
  • §6.1 Schema bump — T1 (LlmDefaults), T2 (Project.hermes), T3 (parse), T4 (serialize), T5 (migration), T6 (default_registry).
  • §6.2 Per-project override addition — deferred, noted in T21 commit message and recorded as v0.4 work.
  • §6.3 Profile YAML template — T16.
  • §7 Testing — pure tests at T1–T6, T10, T11, T16, T18; smoke at T15, T17, T25.
  • §10 Build sequence — followed step-for-step (T1–T6 = §10.1, T7–T9 = §10.2, T10–T13 = §10.3, T14–T19 = §10.4, T20 = §10.5, T21 = §10.6, T22 = §10.7, T23 = §10.8, T24 = §10.9, T25 = §10.10).

Spec gaps closed during planning:

  • The §6.2 override-file [hermes].enabled field is acknowledged in T21 as deferred to v0.4, with an explicit guard in cmd_new that prevents --hermes when LLM stack disabled.
  • The exported-status of incus.process_run_capture and process_run_silent was a hidden dependency surfaced in T15 and T17 — handled by exporting them.

Type-consistency check: LlmDefaults.hermes_seed: [string] consistently across T1, T3, T4, T19. Project.hermes: bool across T2, T4, T21. Stage.id: string across T18, T19, T20. seed_data_dir(source, dest, seed: [string]) signature matches T12 def and T21 call site.

No-placeholder check: every step contains either runnable shell, complete reef code, or a directive to read/find/grep a known location. Reviewed once; clean.


Deferred (v0.4 candidates)

  • parse_override reading [hermes].enabled from repos.d/<name>.toml (spec §6.2; gated in T21 by hard error).
  • repoman setup authoring/editing claude-share (spec §1 explicit out-of-scope; spec O-5).
  • --purge umbrella unifying --purge-hermes and future per-tool purges (spec O-1).
  • HERMES_HOME env-based redirection if hermes supports it (spec O-2).
  • Adopting an existing ~/.hermes install as one of the per-container dirs.
  • Multi-host registry awareness (spec O-6).