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_projectinvariant patternsrc/incus.reef—process_run_capturefor stdout-capturing wrappers;run_incusfor fire-and-forgetsrc/cli.reef—cmd_*shape: parse → validate → load registry → effects → save → exit codetests/test_config_*.reef— temp-dir + fixture-driven tests;framework.TestRunnerAPI
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 withend module. main.reefhas no module declaration.- Tests are standalone reef programs; each has its own
proc main(). make testruns everytests/test_*.reefand 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
LlmDefaultstype and extendDefaults
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
Defaultsconstruction 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
Projectconstruction 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) — preservesold.hermescmd_newin 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 existingdefaultsliteral
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.reefwill need itsschema, 1assertion updated toschema, 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"
Task 12: hermes.seed_data_dir (effectful copy + symlink)
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 setup → new --hermes → shell into container and run hermes → remove --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].llmblock: 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-shareprofile — 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].enabledfield is acknowledged in T21 as deferred to v0.4, with an explicit guard incmd_newthat prevents--hermeswhen LLM stack disabled. - The exported-status of
incus.process_run_captureandprocess_run_silentwas 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_overridereading[hermes].enabledfromrepos.d/<name>.toml(spec §6.2; gated in T21 by hard error).repoman setupauthoring/editingclaude-share(spec §1 explicit out-of-scope; spec O-5).--purgeumbrella unifying--purge-hermesand future per-tool purges (spec O-1).HERMES_HOMEenv-based redirection if hermes supports it (spec O-2).- Adopting an existing
~/.hermesinstall as one of the per-container dirs. - Multi-host registry awareness (spec O-6).