# RFE: public `isatty(fd: int): bool` binding **Filer:** repoman v0.1.1 implementation **Reef version:** 0.5.18 **Date:** 2026-05-03 **Severity:** Low — blocks one polish item; clean workaround possible by importing whole tty module if its internal C conflicts are fixed --- ## Summary Common need: detect whether a given file descriptor (typically `STDOUT`/`STDERR`) is a terminal, to switch between interactive and non-interactive output modes. Today there is no user-callable API for this: - `ui.backend.tty` declares `extern "C" fn reef_tty_is_tty(fd: int): int` privately and uses it internally (`if reef_tty_is_tty(STDOUT_FD) == 0`), but does not export a wrapper. - `sys.fd` exports `STDIN()`, `STDOUT()`, `STDERR()` and various read/write/dup primitives, but no `isatty`. - `sys.platform.libc` does not bind `isatty(3)`. Workarounds either fail at link time (`extern "C"` declaration in user code links against an undefined symbol) or fail at compile time (importing `ui.backend.tty` pulls in conflicting `extern int write(int fd, char* buf, int count);` that clashes with the runtime's own `write` declaration). ## Use case repoman v0.1.1 task A2 (TTY detection for rsync progress): when stdout is a terminal, repoman wants to pass `--info=stats2,progress2` to rsync (live progress); otherwise `--info=stats2` (cron-friendly). Currently hardcoded `is_tty: bool = false` for cron compatibility; missing the live-progress UX in interactive use. This is a generic CLI need — any tool that wants to render progress bars, ANSI colors, or interactive prompts needs to detect TTY context. ## Proposed API Either of: ```reef // In sys.fd: fn isatty(fd: int): bool // Or in ui.backend.tty (publicly exported): fn is_tty(fd: int): bool ``` Trivial wrapper: ```reef fn isatty(fd: int): bool return reef_tty_is_tty(fd) == 1 end isatty ``` `sys.fd` feels more correct since `isatty(3)` is a POSIX file-descriptor primitive, but `ui.backend.tty` is also reasonable since the implementation already lives there. Either works. ## Side issue If `ui.backend.tty` becomes the home, the existing `extern int write(int fd, char* buf, int count);` declaration inside that module needs to be removed or replaced — it conflicts with the runtime's own `write` declaration, which makes importing `ui.backend.tty` from a user program fail to compile (`error: conflicting types for 'write'`). If the goal is for user code to be able to import `ui.backend.tty` cleanly, that needs fixing regardless. ## Repoman impact A2 in our v0.1.1 polish list is parked until this lands. Trivial to wire in once the API exists (one-line replacement in `cli.cmd_sync`). --- *Filed by the repoman v0.1.1 build flow.* # Response: repoman v0.1 Feedback **Source feedback:** `bugs/reef-feedback.md` **Reef version evaluated:** 0.5.9 **Response date:** 2026-04-28 **Status:** All 8 items accepted — implementation plan in `bugs/reef-feedback-implementation-plan.md` --- ## Summary Thank you for the thorough, well-prioritized feedback. Each item is a real friction point and the proposed API shapes are largely on the mark. We accepted all 8 items; no rejections. Two corrections worth flagging up front: - **Item 6 is partially out of date.** `assert_true`, `assert_false`, `assert_not_eq_int`, `assert_not_eq_bool`, and `assert_not_eq_string` already exist on `TestRunner` in v0.5.9. Genuinely missing: `assert_contains` and Result-aware assertions (`assert_ok_*` / `assert_err_*`). We'll add those. - **Item 4 sketch references `str.substring_from` — that function does not exist.** The correct call in v0.5.9 is `str.substring(s, start, len)`. The implementation will use the real function; just flagging in case repoman has the workaround coded against a placeholder name. The rest is accurate as filed. --- ## Item-by-item disposition ### 1. TOML/JSON encoders missing — **HIGH** — accepted Going with the **builder API** (your option b), not the flat `toml_serialize(keys, values, count)`. Reason: the parser's parallel-array representation cannot losslessly round-trip `[[project]]` table-arrays — table-array indices are encoded into key names as a parser-internal detail, not a public contract. A flat serialize would be ambiguous on round-trip. The builder will be a plain `struct` (not an Active Object — no message-passing overhead needed for single-threaded doc building), modeled on `text.stringbuilder`. JSON gets a parallel `JsonBuilder` with the same surface plus `begin/end_object` / `begin/end_array` for JSON's mandatory structural nesting. YAML and INI extensions are out of scope for this batch — repoman doesn't need them, and YAML's nested-table indentation is non-trivial. **Scope:** medium (2-3 days). Sprint 4. ### 2. `io.file` missing rename and fsync — **HIGH** — accepted Both will be added to `io.file`. `rename` becomes a thin Reef wrapper delegating to the existing `runtime.reef_fs_rename` (same C function `fs.ops.rename` already calls — keeping `fs.ops.rename` in place because the modules have different audiences; doc cross-references on both will resolve the discoverability concern). `fsync` requires a small C runtime addition (`reef_fs_fsync(path)` in `reef_fs.c`) plus the FFI bridge plus the user-facing wrapper. Path-based, not handle-based — `io.file` has no handle type today and adding one would be a much larger redesign. The path-based fsync does an extra `open`/`close`, but for the atomic-write recipe that's nanoseconds; the actual disk flush dominates. **Scope:** small. Sprint 2. ### 3. No generic `Result[T, E]` — **MEDIUM** — accepted Will create `core/result_generic.reef` mirroring `core/option_generic.reef` exactly. Five functions: `is_ok[T,E]`, `is_err[T,E]`, `unwrap_ok[T,E]`, `unwrap_err[T,E]`, `unwrap_or[T,E]`. The existing `core.result` (type-specific monomorphizations) stays untouched for one release; deprecation notice and stdlib internal migration follow in a later release. **One thing to validate first:** that two-parameter generic enums (`enum Ok(T); Err(E)`) compile cleanly. `option_generic.reef` proves single-param works; we'll smoke-test the two-param case before writing function bodies. If we hit a compiler limit, fallback is `Result[T]` with `E` fixed to `string` — still satisfies the use case you described. The syntax reference's "Future stdlib" note about generic Result will be updated to "Available in `core.result_generic`" once shipped. **Scope:** small. Sprint 3. ### 4. `io.path.expand_home` missing — **MEDIUM** — accepted Pure-Reef addition to `io.path`. Behavior: `~` → `$HOME`, `~/foo` → `$HOME/foo`, anything else → passthrough. If `HOME` is unset, returns the path unchanged (substituting empty would silently corrupt `~/foo` to `/foo`). `expand_user("~user/...")` is **deferred** to a separate item — it requires a new C runtime helper (`getpwnam` resolution) and adds a `getpwnam_r` thread-safety footnote. Your feedback rated it as a "maybe" and we'd rather ship `expand_home` quickly than couple them. The current `expand_home` returns unchanged on `~user` form so adding `expand_user` later won't break anything. **Scope:** small. Sprint 1. ### 5. `sys.flag` no subcommand dispatch — **MEDIUM** — accepted in two phases **Phase 1 (Sprint 2):** `flag_parser_from(args: [string]): FlagParser` — caller slices argv and feeds it in. `parse()` reads from the override array when set, otherwise calls `sys.args` as today. Backward-compatible. **Phase 2 (Sprint 4):** Full `subcommand_parser` API matching cobra/clap conventions. Has open design questions worth resolving with you before implementation: - Should subcommand flags be defined before `dispatch` (each subcommand pre-registered with its own parser) or after (cobra-style, parser handed back from dispatch)? - How do global flags (e.g., `--verbose` before the subcommand token) coexist with per-subcommand flags? - Does `dispatch` intercept `--help` and route to subcommand-specific usage, or is that the caller's job? We'll spec Phase 2 against repoman's actual subcommand structure once you reach that part of the build. Phase 1 unblocks you immediately. **Scope:** Phase 1 small (~1 day), Phase 2 medium (2-3 days after design pass). ### 6. `test.framework` thin assertions — **MEDIUM** — accepted (with correction) **Already in v0.5.9** (you may have been reading older docs): - `assert_true(condition, message)` — line 130 - `assert_false(condition, message)` — line 135 - `assert_not_eq_int/bool/string` — lines 178-201 **Genuinely missing, will add (Sprint 1):** - `assert_contains_string(haystack, needle, message)` — substring; uses existing `str.contains` - `assert_ok_int/str/bool` — pass when `Result*_Ok(_)`, fail with diagnostic showing the wrapped error - `assert_err_int/str/bool` — pass when `Result*_Err(_)` Skipping `ResultFloat` variants for now (rare in error paths; add on demand). Skipping array-membership `assert_contains` until generic Result lands — a typed-array version per element type would be 4-5 procs and isn't worth it before generics. The string substring form covers your actual use case. A `test_framework.reef` test file (which currently doesn't exist) will be created alongside. **Scope:** small. Sprint 1. ### 7. TOML parser quirks — **LOW** — accepted in two phases **Phase 1 (Sprint 1) — silent truncation:** new `toml_parse_status(input, keys, values, max_capacity): int` returning `-1` when input remains unconsumed AND the cap was hit. Existing `toml_parse` and `toml_parse_sized` are untouched (zero breakage). Module docstring will be updated to recommend `toml_parse_status` for production callers. `json_parse` has the **identical silent-truncation flaw** at a 512-entry cap — we'll mirror the fix as `json_parse_status` in the same release, given the symmetry. **Phase 2 (Sprint 3) — `TomlDoc` struct:** new ergonomic API replacing the parallel-array threading. Adds `TomlDoc` struct + `toml_parse_doc()` + 4 wrapper getters (`toml_get_doc`, `toml_get_int_doc`, `toml_get_bool_doc`, `toml_has_key_doc`). Coexists with old API for ≥1 release. **Scope:** Phase 1 small, Phase 2 small-medium. ### 8. `join_path` naming — **LOW** — accepted (bundled) Adding `fn join(a, b)` as the canonical export, leaving `join_path` as a silent synonym. No deprecation ceremony — Reef has no precedent for one in the stdlib (verified by grep through CHANGELOG; all prior changes were additions or fixes, never renames). A docstring line on `join_path` notes that `join` is preferred. This is the only `_path` redundancy in `io.path` — `dirname`/`basename`/`extension` etc. don't repeat the module name. No coordinated naming pass needed elsewhere. **Scope:** ~15 minutes. Bundled with item 4 in Sprint 1. --- ## Sprint plan summary Detailed plan in `bugs/reef-feedback-implementation-plan.md`. Top-line sequencing: | Sprint | Items | Est. effort | Outcome | |---|---|---|---| | 1 | 4, 6, 7a, 8 (+JSON mirror) | ~1.5 days | Quick wins; unblocks ~half the friction points | | 2 | 2, 5a | ~2 days | Runtime additions; subcommand-friendly parser | | 3 | 3, 7b | ~1.5 days | Generic Result + ergonomic TomlDoc | | 4 | 1, 5b | ~5 days | Encoders + full subcommand API | **Defer indefinitely:** `expand_user` (item 4 follow-on, requires runtime work and only a "maybe" in the feedback). --- ## What you can do today While we land Sprint 1, you can unblock these items in repoman with minimal hand-rolled glue: - **`expand_home`** — your sketched workaround works; just substitute `str.substring(p, 2, str.length(p) - 2)` for the non-existent `substring_from`. - **`assert_contains`** — `runner.assert_eq_bool(str.contains(haystack, needle), true, message)` works as a stopgap. - **Atomic write** — without `fsync`, the write→rename sequence still gives crash-consistency in the common case (just not power-loss durability). Acceptable for v0.1. - **TOML truncation detection** — call `toml_parse_sized(input, keys, values, max_entries)` with your own cap and assert the returned count is less than the cap — if equal, you may be truncated. - **Subcommand dispatch** — hand-roll using `sys.args.get_args()` to extract argv[1] as the subcommand, then call `flag_parser()` with a different program name per subcommand. Migrate to `flag_parser_from(slice)` once Sprint 2 ships. --- ## Test plan summary Each sprint will add tests; the matrix below shows what coverage lands per item: | Item | Test file | Cases | |---|---|---| | 4 | `tests/integration/test_path_expand_home.reef` | bare `~`, `~/foo`, `/abs`, `relative`, empty, `~~double`, `HOME` unset | | 8 | (same as above) | `path.join(a, b)` produces same result as `path.join_path(a, b)` | | 6 | `reef-stdlib/test/test_framework.reef` (new) | pass + fail paths for every new assertion, verify diagnostic format | | 7a | `reef-stdlib/encoding/toml_test.reef` (new) | exact-cap parse returns count (not `-1`), over-cap returns `-1`, under-cap returns count | | 2 | extends `examples/test_file_io.reef` | rename existence check, atomic-write recipe (write→fsync→rename) | | 5a | new `tests/integration/test_flag_subcommand.reef` | flat parse unchanged, sliced parse, `tool sub --flag` end-to-end | | 3 | new `tests/result_generic_test.reef` | `Result[int,string]`, `Result[Point,string]` (struct payload — exercises BUG-034 territory) | | 7b | extends `toml_test.reef` | `TomlDoc` round-trip, `truncated` flag | | 1 | new `tests/encoding_toml_writer_test.reef`, `encoding_json_writer_test.reef` | round-trip parse→serialize→parse for all supported types incl. `[[project]]` | | 5b | extends `test_flag_subcommand.reef` | full `tool sub --flag` end-to-end via `subcommand_parser` | --- ## Open questions for repoman team 1. **Subcommand structure (item 5b):** what does repoman's actual subcommand layout look like? Knowing whether you need global flags before the subcommand token, and whether subcommands can have sub-subcommands (`tool group sub --flag`), shapes the Phase 2 design. 2. **Result error type (item 3):** if generic `Result[T, E]` two-param form hits a compiler limit, are you okay with `Result[T]` where `E = string`? Your feedback example used `string` errors throughout, so this should be a no-op. 3. **Encoder priority (item 1):** TOML or JSON first? You're using both per the feedback, but if one unblocks more of repoman's surface, we'll lead with that. --- *Reef team — Chris Tusal* # reef-lang Feedback — Surfaced While Planning repoman v0.1 **Source:** Spec'ing `repoman` (a real Linux CLI tool: Incus container management + rsync/NFS backup) against reef-lang 0.5.9 stdlib. Items below are gaps and quirks discovered while mapping the spec onto actual stdlib APIs. None are blockers — repoman v0.1 will work around them — but each is a friction point a future user will hit when writing a real production reef program. --- ## 1. Encoders missing for structured formats — **highest impact** Every config-driven program eventually needs to **write** its config, not just read it. Today: | Module | Reads | Writes | |---|---|---| | `encoding.toml` | yes (`toml_parse`, `toml_get*`, `toml_array_*`) | **no** | | `encoding.json` | yes (`json_parse`, `json_get*`) | **no** | | `encoding.yaml` | yes | only line-at-a-time `yaml_encode_string/int/bool` — no nested tables, no lists | | `encoding.ini` | yes (line helpers) | **no** | | `encoding.csv` | TBD | TBD | **What repoman needs:** a `toml_serialize(...)` (or equivalent for one of the structured formats) that handles strings, ints, bools, string arrays, tables, and table-arrays (`[[project]]`). **Suggested API shape** (TOML; same idea for JSON): ``` fn toml_serialize(keys: [string], values: [string], count: int): string // or a higher-level builder: type TomlBuilder fn toml_builder(): TomlBuilder proc toml_set_string(b: TomlBuilder, key: string, val: string) proc toml_set_int(b: TomlBuilder, key: string, val: int) proc toml_set_bool(b: TomlBuilder, key: string, val: bool) proc toml_set_string_array(b: TomlBuilder, key: string, vals: [string]) proc toml_array_append_table(b: TomlBuilder, name: string) // for [[project]] fn toml_render(b: TomlBuilder): string ``` Parser/serializer asymmetry is the single biggest gap stdlib has for "write a real config-driven program." ## 2. `io.file` is missing rename and fsync `io.file` exposes `readFile`, `writeFile`, `appendFile`, `deleteFile`, `fileExists`, `fileSize`, plus binary versions. **Missing:** - `rename(old, new)` — `fs.ops.rename` exists, so this is just a discoverability issue (people will look in `io.file` first). - `fsync(path)` — there is **no** way to force a file's contents to disk before a rename. Important for the standard atomic-write recipe (write tmp → fsync → rename). Today repoman has to skip the fsync and accept a brief power-loss inconsistency window. **Suggested:** add `fn fsync(path: string): bool` (or expose a file-handle-based API, then add `flush_to_disk(handle)`). ## 3. No generic `Result[T, E]` `core.result` only exports concrete `ResultInt`, `ResultStr`, `ResultBool`, `ResultFloat`. For functions that return structs (e.g., `load_registry() : Result`), every caller has to define a local enum: ```reef type RegistryLoad = enum Ok(Registry) Err(string) end RegistryLoad ``` `core.option_generic` already provides `Option[T]` — the same pattern would be a clean fit for `Result[T, E]`. The existing concrete `ResultInt`/`ResultStr`/etc. could be deprecated aliases or stay as ergonomic shortcuts. **Suggested:** add `core.result_generic` with `Result[T, E]`, `is_ok[T,E]`, `is_err[T,E]`, `unwrap_ok[T,E]`, `unwrap_err[T,E]`. ## 4. `io.path` is missing `expand_home` `~/foo` expansion is one of the most frequent path operations in any user-facing CLI. Today `io.path` has `join_path`, `dirname`, `basename`, `extension`, `normalize`, `is_absolute` — but no `expand_home` or `expand_user`. Every caller writes (corrected from v1: `str.substring_from` does not exist publicly; the real call is `str.substring(s, start, len)`): ```reef fn expand_home(p: string): string if str.starts_with(p, "~/") let len = str.length(p) return path.join_path(env.get_env_or("HOME", ""), str.substring(p, 2, len - 2)) end if return p end expand_home ``` **Suggested:** `fn expand_home(path: string): string` (and maybe `expand_user(path)` for `~user/...`) in `io.path`. ## 5. `sys.flag` doesn't support subcommand dispatch `sys.flag` is a V-style **flat** flag parser. It calls `sys.args` directly inside `parse()` — there's no way to feed it a subcommand-sliced argv. The common CLI shape `tool [subcommand-flags]` (git, kubectl, incus, podman, repoman) requires a hand-rolled outer parser to extract the subcommand, then either a hand-rolled per-subcommand parser or a way to give `sys.flag` a sub-slice of argv. **Suggested:** either - (a) `flag_parser_from(args: [string]): FlagParser` so callers can pass a sliced argv, or - (b) a `subcommand_parser` API at the same level as `flag_parser` that handles the dispatch. Even a doc note ("for subcommand tools, hand-roll using `sys.args.get(i)`") would help — today the example in `sys/flag.reef` makes it look like the answer. ## 6. `test.framework` thin assertion surface `TestRunner` (an active object — nice) already has `assert_eq_int/bool/string/float`, and **as of v0.5.9 also has** `assert_true`, `assert_false`, and `assert_not_eq_int/bool/string` (filer error in v1 of this report — caught reading only the first 100 lines of a 235-line file). **Genuinely missing:** - `assert_contains(haystack, needle, message)` for substring/array membership. - `assert_ok_*` / `assert_err_*` for Result types (uses pattern-match plus a diagnostic that surfaces the wrapped error/value on failure). ## 7. TOML parser quirks - **Hard 1024-entry cap** in `toml_alloc_keys()`. Adequate for repoman (~127 projects max), but the cap is invisible — `toml_parse` returns the entry count it processed; if the file had more, callers don't know. Suggest a return value indicating "truncated" (or accept a max-entries arg and error if exceeded — the `_sized` variants already do the latter, so making them the recommended path would help). - **Parallel-array repr** (`[string] keys`, `[string] values`, `int count`) forces every caller to thread three values around. A small `TomlDoc` struct (or `[Pair]` array) would be friendlier — the runtime cost is the same but the API surface is much smaller. ## 8. Naming nit `io.path.join_path(a, b)` — the `_path` suffix is redundant since the function is in `io.path`. Most languages call this `path.join`. Minor, but discoverability matters when learning a new stdlib. --- ## Summary table (ordered by impact on real-world reef programs) | # | Item | Severity | Type | |---|---|---|---| | 1 | TOML/JSON encoders missing | **High** | Feature gap | | 2 | `io.file` no rename/fsync | High | Feature gap | | 3 | No generic `Result[T,E]` | Medium | API design | | 4 | `io.path.expand_home` missing | Medium | Feature gap | | 5 | `sys.flag` no subcommand support | Medium | API design | | 6 | `test.framework` thin assertions | Medium | Feature gap | | 7 | TOML parser caps & repr | Low | API design | | 8 | `join_path` naming | Low | Naming | repoman v0.1 works around all of these and ships. But every one of them costs the project some hand-rolled glue code that *should* be in stdlib. # repoman v0.1 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ship a reef-lang implementation of `repoman new ` and `repoman sync [name]` with feature parity to the bash prototype at `~/.local/bin/repoman`, plus the v0.1-only differentiator: containers live under an Incus `project` namespace. **Architecture:** Six modules under `src/`, no nesting: `main` (entry), `cli` (subcommand dispatch + flag parsing), `config` (TOML-backed registry + per-project overrides), `incus` (subprocess wrappers around the `incus` CLI), `sync` (NFS mount check + rsync invocation), `paths` (path helpers — pluralized to avoid colliding with stdlib's `io.path`). Pure logic lives in `config.merge_with_defaults`, `sync.build_rsync_args`, `incus.validate_name`, `paths.expand_home` — these get full unit-test coverage. Effectful wrappers (subprocess, file I/O, NFS check) are kept narrow and smoke-tested manually. **Tech Stack:** reef-lang 0.5.10 (compiles to C → native binary), `encoding.toml` (TomlBuilder + TomlDoc), `core.result_generic` (`Result[T, string]` for errors), `sys.process.process_spawn` (argv-list subprocess — never the shell variant), `sys.flag.flag_parser_from` (sliced argv per subcommand), `test.framework.TestRunner` (active object with `assert_eq_*`, `assert_contains_string`). --- ## Reference: spec and source - Design spec: `docs/superpowers/specs/2026-04-29-repoman-v0.1-design.md` (rev 3, locked) - Bash prototype: `~/.local/bin/repoman` (behavioral source of truth for `new`/`sync`) - Reef stdlib reference: `~/reef-lang-0.5.10-source/reef-stdlib/` - `test/framework.reef` — TestRunner API (note: `new framework.TestRunner()`, not `new TestRunner()`) - `encoding/toml.reef` — `toml_builder()`, `toml_set_*`, `toml_array_append_table`, `toml_render`, `toml_parse_doc`, `TomlDoc` - `io/path.reef` — `expand_home`, `join`, `dirname`, `basename` - `io/file.reef` — `readFile`, `writeFile`, `fileExists`, `rename`, `fsync`, `deleteFile` - `io/dir.reef` — `dir_exists`, `is_directory`, `create_dir_all` - `sys/flag.reef` — `flag_parser_from(args)`, `bool_flag`, `string_flag`, `parse`, `positional_args` - `sys/process.reef` — `process_spawn(prog, [args])`, `process_wait(pid)`, `process_exit_code()` - `sys/args.reef` — `count()`, `get(i)`, `program()` - `sys/env.reef` — `get_env(name)`, `get_env_or(name, default)` - `core/result_generic.reef` — `Result[T, E]`, construction `@Result[T, E].Ok(v)` / `@Result[T, E].Err(e)`, queries `is_ok(r)`, `is_err(r)`, `unwrap_ok(r)`, `unwrap_err(r)` - `core/str.reef` — `length`, `equals`, `contains`, `starts_with`, `ends_with`, `substring`, `split`, `join` ## File structure ``` ~/repos/repoman/ ├── reef.toml # package manifest ├── Makefile # install/uninstall wrapper for distro packagers ├── README.md # quickstart + test loop + smoke recipe ├── VISION.md # (already exists) design intent ├── .hgignore # (already exists) build/, *.o, etc. ├── docs/ # (already exists) specs + reef feedback ├── src/ │ ├── main.reef # proc main() → cli.dispatch + exit │ ├── cli.reef # outer dispatch on argv[1]; per-subcommand parsers │ ├── config.reef # types + parse + serialize + load_or_init + atomic save │ ├── incus.reef # subprocess wrappers + validate_name (pure) │ ├── sync.reef # ensure_nfs_mounted + build_rsync_args (pure) + run │ └── path.reef # expand_home, join, exists, is_dir wrappers └── tests/ ├── test_path.reef ├── test_incus_validate.reef ├── test_config_parse.reef ├── test_config_serialize.reef ├── test_config_roundtrip.reef ├── test_config_merge.reef ├── test_sync_args.reef ├── test_config_io.reef # uses temp dir └── test_config_save.reef # uses temp dir ``` Module boundary rules: - Each module file declares `module ` matching its filename. - `main.reef` has no `module` declaration; it's the entry point. - Tests are standalone reef programs (each has its own `proc main()`); they import production modules from `src/` (resolved automatically when run from project root). - All `module ` blocks must end with `end module`. --- ## Task 1: Scaffold project **Files:** - Create: `reef.toml`, `src/main.reef`, `tests/.keep` - Modify: `.hgignore` (add reef build artifacts) - [ ] **Step 1: Initialize reef project in existing repo** The repoman directory already exists with `VISION.md` and `docs/`. Run `reefc init` to add a `reef.toml` without disturbing existing files: ```bash cd ~/repos/repoman reefc init ``` Expected stdout: `Initialized Reef project in current directory` and `Created reef.toml`. - [ ] **Step 2: Replace generated reef.toml with the repoman manifest** The default `reef.toml` from `reefc init` is generic. Overwrite with this exact content: ```toml [package] name = "repoman" version = "0.1.0" author = "Chris Tusa " description = "Per-project Incus containers + opinionated NFS/ZFS backup" license = "MIT" url = "" [build] entry = "src/main.reef" output = "repoman" output_dir = "build" source_dirs = ["src"] [docs] output = "docs/api" include_private = false ``` Note: `tests/` is intentionally NOT in `source_dirs` — tests are standalone programs run via `reefc run tests/test_*.reef`, not bundled into the production binary. - [ ] **Step 3: Create src/ and tests/ directories with placeholder main** ```bash mkdir -p src tests ``` Create `src/main.reef` with this exact content (it will be replaced in Task 19, but we want a buildable skeleton now): ```reef proc main() println("repoman 0.1.0 — not yet implemented") end main ``` Create `tests/.keep` (empty file) so the directory is committed: ```bash touch tests/.keep ``` - [ ] **Step 4: Update .hgignore for reef build artifacts** Append to `.hgignore`: ``` build/ *.c *.o *.so *.exe docs/api/ ``` (The `docs/api/` line covers reef's generated API docs.) - [ ] **Step 5: Build and verify the skeleton runs** Run from `~/repos/repoman/`: ```bash reefc build ./build/repoman ``` Expected output: `repoman 0.1.0 — not yet implemented`. Build directory should appear at `~/repos/repoman/build/` and contain `repoman`. - [ ] **Step 6: Commit** ```bash hg add reef.toml src/main.reef tests/.keep hg commit -m "scaffold: reef project skeleton + manifest" ``` --- ## Task 2: paths module (`expand_home`, `join`, `exists`, `is_dir`) > **Naming note:** the local module is named `paths` (plural), not `path`. Bare `path` collides with the stdlib's `io.path` and reef resolves to the stdlib module first, producing C-codegen errors at link time. Pluralizing avoids the collision; `paths.X` reads naturally at call sites. **Files:** - Create: `src/paths.reef` - Test: `tests/test_paths.reef` The `path` module is a thin facade over `io.path` and `io.dir`, named to keep call sites short. `expand_home` and `join` are direct re-exports of stdlib; `exists` and `is_dir` wrap `io.dir`. We test the re-exports too — they're trivial but anchor the test framework + module-import chain end-to-end before we write more. - [ ] **Step 1: Write the failing test** Create `tests/test_paths.reef`: ```reef import paths import test.framework import sys.env proc main() let runner = new framework.TestRunner() // expand_home env.set_env("HOME", "/home/test") runner.assert_eq_string(paths.expand_home("~/foo"), "/home/test/foo", "~/foo expands") runner.assert_eq_string(paths.expand_home("~"), "/home/test", "bare ~ expands") runner.assert_eq_string(paths.expand_home("/abs/path"), "/abs/path", "abs path passthrough") runner.assert_eq_string(paths.expand_home("relative"), "relative", "relative passthrough") runner.assert_eq_string(paths.expand_home(""), "", "empty passthrough") // join runner.assert_eq_string(paths.join("/a", "b"), "/a/b", "join basic") runner.assert_eq_string(paths.join("/a/", "b"), "/a/b", "join trailing slash") runner.assert_eq_string(paths.join("/a", "/b"), "/a/b", "join leading slash") // exists / is_dir against a known dir runner.assert_eq_bool(paths.exists("/tmp"), true, "/tmp exists") runner.assert_eq_bool(paths.is_dir("/tmp"), true, "/tmp is dir") runner.assert_eq_bool(paths.exists("/this-does-not-exist-zzz"), false, "missing path → false") runner.report() end main ``` - [ ] **Step 2: Run test to verify it fails** ```bash reefc run tests/test_paths.reef ``` Expected: compile error (module `path` not found). - [ ] **Step 3: Write minimal implementation** Create `src/paths.reef`: ```reef module paths import io.path as iopath import io.dir as iodir import io.file as iofile export fn expand_home(p: string): string fn join(a: string, b: string): string fn exists(p: string): bool fn is_dir(p: string): bool end export fn expand_home(p: string): string return iopaths.expand_home(p) end expand_home fn join(a: string, b: string): string return iopaths.join(a, b) end join fn exists(p: string): bool if iofile.fileExists(p) return true end if return iodir.dir_exists(p) end exists fn is_dir(p: string): bool return iodir.is_directory(p) end is_dir end module ``` - [ ] **Step 4: Run test to verify it passes** ```bash reefc run tests/test_paths.reef ``` Expected: `Tests passed: 9`, `Tests failed: 0`, `All tests passed!`. - [ ] **Step 5: Commit** ```bash hg add src/paths.reef tests/test_paths.reef hg commit -m "path: expand_home/join/exists/is_dir wrappers + tests" ``` --- ## Task 3: incus.validate_name **Files:** - Create: `src/incus.reef` - Test: `tests/test_incus_validate.reef` `validate_name` is the pure half of the `incus` module. Container names follow Incus rules: lowercase alphanumeric + hyphens, must not start with a hyphen, ≤63 chars, must be non-empty. We populate the rest of the module (subprocess wrappers) in Task 13. - [ ] **Step 1: Write the failing test** Create `tests/test_incus_validate.reef`: ```reef import incus import test.framework proc main() let runner = new framework.TestRunner() runner.assert_eq_bool(incus.validate_name("foo"), true, "simple name") runner.assert_eq_bool(incus.validate_name("foo-bar"), true, "hyphenated") runner.assert_eq_bool(incus.validate_name("foo123"), true, "trailing digits") runner.assert_eq_bool(incus.validate_name("a"), true, "single char") runner.assert_eq_bool(incus.validate_name(""), false, "empty rejected") runner.assert_eq_bool(incus.validate_name("-foo"), false, "leading hyphen rejected") runner.assert_eq_bool(incus.validate_name("foo_bar"), false, "underscore rejected") runner.assert_eq_bool(incus.validate_name("foo.bar"), false, "dot rejected") runner.assert_eq_bool(incus.validate_name("Foo"), false, "uppercase rejected") runner.assert_eq_bool(incus.validate_name("foo bar"), false, "space rejected") // 63-char boundary (exactly 63 = ok, 64 = reject) let s63 = "abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabc" let s64 = "abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcd" runner.assert_eq_int(str.length(s63), 63, "s63 setup check") runner.assert_eq_int(str.length(s64), 64, "s64 setup check") runner.assert_eq_bool(incus.validate_name(s63), true, "63 chars accepted") runner.assert_eq_bool(incus.validate_name(s64), false, "64 chars rejected") runner.report() end main ``` Note: this test imports `core.str` implicitly via `str.length` — add `import core.str` at the top: ```reef import incus import test.framework import core.str ``` - [ ] **Step 2: Run test to verify it fails** ```bash reefc run tests/test_incus_validate.reef ``` Expected: compile error (module `incus` not found). - [ ] **Step 3: Write minimal implementation** Create `src/incus.reef`: ```reef module incus import core.str export fn validate_name(name: string): bool end export fn is_lower_alnum_or_hyphen(c: char): bool if c >= 'a' and c <= 'z' return true end if if c >= '0' and c <= '9' return true end if if c == '-' return true end if return false end is_lower_alnum_or_hyphen fn validate_name(name: string): bool let n: int = str.length(name) if n == 0 return false end if if n > 63 return false end if if name[0] == '-' return false end if mut i: int = 0 while i < n if not is_lower_alnum_or_hyphen(name[i]) return false end if i = i + 1 end while return true end validate_name end module ``` - [ ] **Step 4: Run test to verify it passes** ```bash reefc run tests/test_incus_validate.reef ``` Expected: all 14 assertions pass. - [ ] **Step 5: Commit** ```bash hg add src/incus.reef tests/test_incus_validate.reef hg commit -m "incus: validate_name + boundary tests" ``` --- ## Task 4: config types **Files:** - Create: `src/config.reef` (skeleton with type definitions only) This task lays down the data types that subsequent tasks will populate. No functions yet — just the structs. We don't write a test for type definitions; the next task's test will exercise them. - [ ] **Step 1: Create the config module skeleton** Create `src/config.reef`: ```reef module config import core.str import core.result_generic as rg import encoding.toml as toml import io.file as iofile import io.dir as iodir import io.path as iopath import paths export type Defaults type Project type Override type Mount type Registry type EffectiveConfig end export type Defaults = struct repos_root: string backup_root: string incus_project: string default_image: string profiles: [string] end Defaults type Project = struct name: string repo: string image: string profiles: [string] created: string last_sync: string backup: bool end Project type Mount = struct source: string path: string end Mount type Override = struct image: string profiles: [string] has_profiles: bool mounts: [Mount] env_keys: [string] env_values: [string] end Override type Registry = struct schema: int defaults: Defaults projects: [Project] end Registry type EffectiveConfig = struct name: string repo: string repo_path: string image: string profiles: [string] mounts: [Mount] env_keys: [string] env_values: [string] end EffectiveConfig end module ``` Notes on the Override shape: - `has_profiles` distinguishes "user authored an empty profiles list" from "user didn't author profiles" (the merge in Task 8 needs this to decide whether override replaces defaults). - `env_keys` / `env_values` are parallel arrays. Reef doesn't have a native dict; this matches how TomlDoc surfaces parsed key-value entries. - [ ] **Step 2: Verify it compiles** ```bash reefc --check src/config.reef ``` Expected: no errors. (`--check` is reefc's type-check-only mode, useful for module-level smoke checks before there's a `proc main()`.) If `--check` doesn't accept module files standalone, a quick alternative: run `reefc build` and confirm it still produces `build/repoman`. The new module is unused but should compile cleanly. - [ ] **Step 3: Commit** ```bash hg add src/config.reef hg commit -m "config: type skeleton (Registry/Defaults/Project/Override/EffectiveConfig)" ``` --- ## Task 5: config.parse_registry **Files:** - Modify: `src/config.reef` (add `parse_registry` function + helpers) - Create: `tests/test_config_parse.reef` Parses a TOML string into a `Registry`. Uses the new `TomlDoc` API from 0.5.10 — much cleaner than threading `(keys, values, count)`. Returns `Result[Registry, string]`. - [ ] **Step 1: Write the failing test** Create `tests/test_config_parse.reef`: ```reef import config import test.framework import core.result_generic as rg proc main() let runner = new framework.TestRunner() let toml_input: string = "[repoman]\nschema = 1\n\n[defaults]\nrepos_root = \"/home/u/repos\"\nbackup_root = \"/nfs/repos\"\nincus_project = \"repoman\"\ndefault_image = \"images:ubuntu/26.04/cloud\"\nprofiles = [\"default\", \"claude-share\"]\n\n[[project]]\nname = \"isurus\"\nrepo = \"isurus-project\"\nimage = \"images:ubuntu/26.04/cloud\"\nprofiles = [\"default\"]\ncreated = \"2026-04-28T15:00:00Z\"\nlast_sync = \"\"\nbackup = true\n" let r = config.parse_registry(toml_input) runner.assert_eq_bool(rg.is_ok(r), true, "parse succeeds") if rg.is_ok(r) let reg = rg.unwrap_ok(r) runner.assert_eq_int(reg.schema, 1, "schema = 1") runner.assert_eq_string(reg.defaults.repos_root, "/home/u/repos", "repos_root") runner.assert_eq_string(reg.defaults.backup_root, "/nfs/repos", "backup_root") runner.assert_eq_string(reg.defaults.incus_project, "repoman", "incus_project") runner.assert_eq_string(reg.defaults.default_image, "images:ubuntu/26.04/cloud", "default_image") runner.assert_eq_int(reg.defaults.profiles.length(), 2, "defaults.profiles count") runner.assert_eq_string(reg.defaults.profiles[0], "default", "defaults.profiles[0]") runner.assert_eq_string(reg.defaults.profiles[1], "claude-share", "defaults.profiles[1]") runner.assert_eq_int(reg.projects.length(), 1, "1 project") let p = reg.projects[0] runner.assert_eq_string(p.name, "isurus", "project.name") runner.assert_eq_string(p.repo, "isurus-project", "project.repo") runner.assert_eq_string(p.image, "images:ubuntu/26.04/cloud", "project.image") runner.assert_eq_int(p.profiles.length(), 1, "project.profiles count") runner.assert_eq_string(p.profiles[0], "default", "project.profiles[0]") runner.assert_eq_string(p.last_sync, "", "project.last_sync") runner.assert_eq_bool(p.backup, true, "project.backup") end if // schema rejection let bad_schema: string = "[repoman]\nschema = 99\n[defaults]\nrepos_root = \"/r\"\nbackup_root = \"/b\"\nincus_project = \"x\"\ndefault_image = \"y\"\nprofiles = []\n" let r2 = config.parse_registry(bad_schema) runner.assert_eq_bool(rg.is_err(r2), true, "schema 99 rejected") runner.report() end main ``` - [ ] **Step 2: Run test to verify it fails** ```bash reefc run tests/test_config_parse.reef ``` Expected: compile error (`parse_registry` not defined in `config`). - [ ] **Step 3: Add parse_registry + helpers to src/config.reef** In `src/config.reef`, add to the `export` block: ```reef fn parse_registry(toml_text: string): rg.Result[Registry, string] ``` Add these functions before `end module`: ```reef // Parse a comma-separated TOML inline array of strings: `["a", "b"]`. // Tolerates whitespace and missing brackets; returns empty if input is empty. fn parse_string_array(raw: string): [string] let n: int = str.length(raw) if n == 0 return new [string](0) end if // Strip leading [ and trailing ] mut start: int = 0 mut end_idx: int = n if n > 0 and raw[0] == '[' start = 1 end if if end_idx > start and raw[end_idx - 1] == ']' end_idx = end_idx - 1 end if let inner: string = str.substring(raw, start, end_idx - start) // Split on commas, then trim quotes and whitespace from each element. let inner_len: int = str.length(inner) if inner_len == 0 return new [string](0) end if // Reef doesn't have a dynamic split-and-collect; use str.split with a max. mut parts: [string] = new [string](64) let count: int = str.split(inner, ',', parts, 64) mut result: [string] = new [string](count) mut i: int = 0 while i < count let p: string = str.trim_ws(parts[i]) // Strip surrounding double quotes if present let pl: int = str.length(p) if pl >= 2 and p[0] == '"' and p[pl - 1] == '"' result[i] = str.substring(p, 1, pl - 2) else result[i] = p end if i = i + 1 end while return result end parse_string_array fn parse_registry(toml_text: string): rg.Result[Registry, string] let doc: toml.TomlDoc = toml.toml_parse_doc(toml_text) if doc.truncated return @rg.Result[Registry, string].Err("registry too large (>1024 entries)") end if let schema: int = toml.toml_get_int_doc(doc, "repoman.schema") if schema != 1 return @rg.Result[Registry, string].Err("unsupported schema (expected 1)") end if let defaults: Defaults = Defaults { repos_root: toml.toml_get_doc(doc, "defaults.repos_root"), backup_root: toml.toml_get_doc(doc, "defaults.backup_root"), incus_project: toml.toml_get_doc(doc, "defaults.incus_project"), default_image: toml.toml_get_doc(doc, "defaults.default_image"), profiles: parse_string_array(toml.toml_get_doc(doc, "defaults.profiles")) } let project_count: int = toml.toml_array_count(doc.keys, doc.count, "project") mut projects: [Project] = new [Project](project_count) mut i: int = 0 while i < project_count let p: Project = Project { name: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "name"), repo: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "repo"), image: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "image"), profiles: parse_string_array(toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "profiles")), created: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "created"), last_sync: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "last_sync"), backup: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "backup") != "false" } projects[i] = p i = i + 1 end while let reg: Registry = Registry { schema: schema, defaults: defaults, projects: projects } return @rg.Result[Registry, string].Ok(reg) end parse_registry ``` Note on the `backup` boolean parse: `toml_get_bool` exists, but the table-array helper `toml_array_get` returns a string. We default-true unless the literal string is `"false"`. Tighter handling can come later. - [ ] **Step 4: Run test to verify it passes** ```bash reefc run tests/test_config_parse.reef ``` Expected: 19 assertions pass. - [ ] **Step 5: Commit** ```bash hg add tests/test_config_parse.reef hg commit -m "config: parse_registry via TomlDoc + tests" ``` --- ## Task 6: config.serialize_registry **Files:** - Modify: `src/config.reef` (add `serialize_registry`) - Create: `tests/test_config_serialize.reef` Renders a `Registry` to a TOML string using `TomlBuilder`. The output must round-trip cleanly through `parse_registry` (we test that explicitly in Task 7). - [ ] **Step 1: Write the failing test** Create `tests/test_config_serialize.reef`: ```reef import config import test.framework proc main() let runner = new framework.TestRunner() let defaults: config.Defaults = config.Defaults { repos_root: "/home/u/repos", backup_root: "/nfs/repos", incus_project: "repoman", default_image: "images:ubuntu/26.04/cloud", profiles: ["default", "claude-share"] } mut projects: [config.Project] = new [config.Project](1) projects[0] = config.Project { name: "isurus", repo: "isurus-project", image: "images:ubuntu/26.04/cloud", profiles: ["default"], created: "2026-04-28T15:00:00Z", last_sync: "", backup: true } let reg: config.Registry = config.Registry { schema: 1, defaults: defaults, projects: projects } let out: string = config.serialize_registry(reg) runner.assert_contains_string(out, "[repoman]", "has [repoman] header") runner.assert_contains_string(out, "schema = 1", "has schema field") runner.assert_contains_string(out, "[defaults]", "has [defaults] header") runner.assert_contains_string(out, "repos_root = \"/home/u/repos\"", "has repos_root field") runner.assert_contains_string(out, "[[project]]", "has [[project]] header") runner.assert_contains_string(out, "name = \"isurus\"", "has project.name") runner.assert_contains_string(out, "backup = true", "has backup = true") runner.assert_contains_string(out, "profiles = [\"default\", \"claude-share\"]", "defaults.profiles array") runner.report() end main ``` - [ ] **Step 2: Run test to verify it fails** ```bash reefc run tests/test_config_serialize.reef ``` Expected: compile error (`serialize_registry` undefined). - [ ] **Step 3: Add serialize_registry to src/config.reef** Add to the export block: ```reef fn serialize_registry(reg: Registry): string ``` Add the implementation before `end module`: ```reef fn serialize_registry(reg: Registry): string let b: toml.TomlBuilder = toml.toml_builder() toml.toml_begin_table(b, "repoman") toml.toml_set_int(b, "schema", reg.schema) toml.toml_begin_table(b, "defaults") toml.toml_set_string(b, "repos_root", reg.defaults.repos_root) toml.toml_set_string(b, "backup_root", reg.defaults.backup_root) toml.toml_set_string(b, "incus_project", reg.defaults.incus_project) toml.toml_set_string(b, "default_image", reg.defaults.default_image) toml.toml_set_string_array(b, "profiles", reg.defaults.profiles) let pn: int = reg.projects.length() mut i: int = 0 while i < pn let p: Project = reg.projects[i] toml.toml_array_append_table(b, "project") toml.toml_set_string(b, "name", p.name) toml.toml_set_string(b, "repo", p.repo) toml.toml_set_string(b, "image", p.image) toml.toml_set_string_array(b, "profiles", p.profiles) toml.toml_set_string(b, "created", p.created) toml.toml_set_string(b, "last_sync", p.last_sync) toml.toml_set_bool(b, "backup", p.backup) i = i + 1 end while return toml.toml_render(b) end serialize_registry ``` - [ ] **Step 4: Run test to verify it passes** ```bash reefc run tests/test_config_serialize.reef ``` Expected: 8 assertions pass. - [ ] **Step 5: Commit** ```bash hg add tests/test_config_serialize.reef hg commit -m "config: serialize_registry via TomlBuilder + tests" ``` --- ## Task 7: config round-trip test **Files:** - Create: `tests/test_config_roundtrip.reef` Confirms that `parse(serialize(reg)) == reg`. This is the canonical test for any encoder pair — without it the two halves can drift. - [ ] **Step 1: Write the failing test** Create `tests/test_config_roundtrip.reef`: ```reef import config import test.framework import core.result_generic as rg proc main() let runner = new framework.TestRunner() let defaults: config.Defaults = config.Defaults { repos_root: "/home/u/repos", backup_root: "/nfs/repos", incus_project: "repoman", default_image: "images:ubuntu/26.04/cloud", profiles: ["default", "claude-share"] } mut projects: [config.Project] = new [config.Project](2) projects[0] = config.Project { name: "isurus", repo: "isurus-project", image: "images:ubuntu/26.04/cloud", profiles: ["default"], created: "2026-04-28T15:00:00Z", last_sync: "", backup: true } projects[1] = config.Project { name: "tools", repo: "tools", image: "images:debian/12/cloud", profiles: ["default", "claude-share"], created: "2026-04-29T10:00:00Z", last_sync: "2026-04-29T11:00:00Z", backup: false } let reg: config.Registry = config.Registry { schema: 1, defaults: defaults, projects: projects } let serialized: string = config.serialize_registry(reg) let parsed_r = config.parse_registry(serialized) runner.assert_eq_bool(rg.is_ok(parsed_r), true, "round-trip parse succeeds") if rg.is_ok(parsed_r) let reg2 = rg.unwrap_ok(parsed_r) runner.assert_eq_int(reg2.schema, 1, "schema preserved") runner.assert_eq_string(reg2.defaults.repos_root, reg.defaults.repos_root, "defaults.repos_root preserved") runner.assert_eq_int(reg2.defaults.profiles.length(), 2, "defaults.profiles len preserved") runner.assert_eq_int(reg2.projects.length(), 2, "project count preserved") runner.assert_eq_string(reg2.projects[0].name, "isurus", "project[0].name") runner.assert_eq_string(reg2.projects[1].name, "tools", "project[1].name") runner.assert_eq_bool(reg2.projects[0].backup, true, "project[0].backup") runner.assert_eq_bool(reg2.projects[1].backup, false, "project[1].backup") runner.assert_eq_string(reg2.projects[1].last_sync, "2026-04-29T11:00:00Z", "last_sync preserved") end if runner.report() end main ``` - [ ] **Step 2: Run test** ```bash reefc run tests/test_config_roundtrip.reef ``` Expected: 9 assertions pass. If any fail, the parse and serialize sides have drifted — fix whichever is wrong. - [ ] **Step 3: Commit** ```bash hg add tests/test_config_roundtrip.reef hg commit -m "config: round-trip test (serialize → parse equals original)" ``` --- ## Task 8: config.parse_override **Files:** - Modify: `src/config.reef` (add `parse_override`) - Create: `tests/test_config_override.reef` Parses a per-project override TOML (`[container]`, `[[mount]]`, `[env]` sections). Returns `Result[Override, string]`. Override files are read-only — repoman never writes them — so we only need parse, not serialize. - [ ] **Step 1: Write the failing test** Create `tests/test_config_override.reef`: ```reef import config import test.framework import core.result_generic as rg proc main() let runner = new framework.TestRunner() let toml_input: string = "[container]\nimage = \"images:debian/12/cloud\"\nprofiles = [\"default\", \"claude-share\", \"node-dev\"]\n\n[[mount]]\nsource = \"~/.npm\"\npath = \"/home/ctusa/.npm\"\n\n[[mount]]\nsource = \"~/.cache/yarn\"\npath = \"/home/ctusa/.cache/yarn\"\n\n[env]\nNODE_ENV = \"development\"\nDEBUG = \"1\"\n" let r = config.parse_override(toml_input) runner.assert_eq_bool(rg.is_ok(r), true, "override parse succeeds") if rg.is_ok(r) let ov = rg.unwrap_ok(r) runner.assert_eq_string(ov.image, "images:debian/12/cloud", "override.image") runner.assert_eq_bool(ov.has_profiles, true, "has_profiles set") runner.assert_eq_int(ov.profiles.length(), 3, "profiles count") runner.assert_eq_string(ov.profiles[2], "node-dev", "profiles[2]") runner.assert_eq_int(ov.mounts.length(), 2, "mount count") runner.assert_eq_string(ov.mounts[0].source, "~/.npm", "mount[0].source") runner.assert_eq_string(ov.mounts[0].path, "/home/ctusa/.npm", "mount[0].path") runner.assert_eq_int(ov.env_keys.length(), 2, "env count") // env keys are not order-guaranteed by TOML; check both possibilities let k0: string = ov.env_keys[0] runner.assert_eq_bool(k0 == "NODE_ENV" or k0 == "DEBUG", true, "env_keys[0] is one of expected") end if // empty override let r2 = config.parse_override("") runner.assert_eq_bool(rg.is_ok(r2), true, "empty override is valid") if rg.is_ok(r2) let ov2 = rg.unwrap_ok(r2) runner.assert_eq_string(ov2.image, "", "empty override.image") runner.assert_eq_bool(ov2.has_profiles, false, "no profiles") runner.assert_eq_int(ov2.mounts.length(), 0, "no mounts") runner.assert_eq_int(ov2.env_keys.length(), 0, "no env") end if runner.report() end main ``` - [ ] **Step 2: Run test to verify it fails** ```bash reefc run tests/test_config_override.reef ``` Expected: compile error (`parse_override` undefined). - [ ] **Step 3: Add parse_override to src/config.reef** Add to the export block: ```reef fn parse_override(toml_text: string): rg.Result[Override, string] ``` Implementation (place before `end module`): ```reef fn parse_override(toml_text: string): rg.Result[Override, string] let doc: toml.TomlDoc = toml.toml_parse_doc(toml_text) if doc.truncated return @rg.Result[Override, string].Err("override too large") end if let image: string = toml.toml_get_doc(doc, "container.image") let profiles_raw: string = toml.toml_get_doc(doc, "container.profiles") let has_profiles: bool = toml.toml_has_key_doc(doc, "container.profiles") let profiles: [string] = parse_string_array(profiles_raw) let mount_count: int = toml.toml_array_count(doc.keys, doc.count, "mount") mut mounts: [Mount] = new [Mount](mount_count) mut i: int = 0 while i < mount_count mounts[i] = Mount { source: toml.toml_array_get(doc.keys, doc.values, doc.count, "mount", i, "source"), path: toml.toml_array_get(doc.keys, doc.values, doc.count, "mount", i, "path") } i = i + 1 end while // Walk doc.keys for entries starting with "env." mut env_keys_buf: [string] = new [string](128) mut env_vals_buf: [string] = new [string](128) mut env_count: int = 0 mut k: int = 0 while k < doc.count let key: string = doc.keys[k] if str.starts_with(key, "env.") if env_count < 128 env_keys_buf[env_count] = str.substring(key, 4, str.length(key) - 4) env_vals_buf[env_count] = doc.values[k] env_count = env_count + 1 end if end if k = k + 1 end while mut env_keys: [string] = new [string](env_count) mut env_vals: [string] = new [string](env_count) mut j: int = 0 while j < env_count env_keys[j] = env_keys_buf[j] env_vals[j] = env_vals_buf[j] j = j + 1 end while let ov: Override = Override { image: image, profiles: profiles, has_profiles: has_profiles, mounts: mounts, env_keys: env_keys, env_values: env_vals } return @rg.Result[Override, string].Ok(ov) end parse_override ``` - [ ] **Step 4: Run test to verify it passes** ```bash reefc run tests/test_config_override.reef ``` Expected: all assertions pass (12 in this test). - [ ] **Step 5: Commit** ```bash hg add tests/test_config_override.reef hg commit -m "config: parse_override (container/mount/env) + tests" ``` --- ## Task 9: config.merge_with_defaults **Files:** - Modify: `src/config.reef` (add `merge_with_defaults`) - Create: `tests/test_config_merge.reef` Pure function: takes a name, repo dirname, optional `--image` flag, optional Override, and Defaults. Returns an `EffectiveConfig`. Implements the merge table from spec §3.3: | Field | Priority | |---|---| | `image` | flag → override.image → defaults.default_image | | `profiles` | override.profiles (replace, when has_profiles) → defaults.profiles | | `mounts` | always [auto repo bind] ++ override.mounts | | `env` | override.env entries (or empty) | - [ ] **Step 1: Write the failing test** Create `tests/test_config_merge.reef`: ```reef import config import test.framework fn make_defaults(): config.Defaults return config.Defaults { repos_root: "/home/u/repos", backup_root: "/nfs/repos", incus_project: "repoman", default_image: "images:ubuntu/26.04/cloud", profiles: ["default", "claude-share"] } end make_defaults fn empty_override(): config.Override return config.Override { image: "", profiles: new [string](0), has_profiles: false, mounts: new [config.Mount](0), env_keys: new [string](0), env_values: new [string](0) } end empty_override proc main() let runner = new framework.TestRunner() let d = make_defaults() // 1. flag wins over override and defaults mut ov1: config.Override = empty_override() ov1.image = "images:debian/12/cloud" let e1 = config.merge_with_defaults("isurus", "isurus-project", "images:custom/x", ov1, d) runner.assert_eq_string(e1.image, "images:custom/x", "flag wins") runner.assert_eq_string(e1.repo_path, "/home/u/repos/isurus-project", "repo_path computed") // 2. override.image wins over defaults when no flag let e2 = config.merge_with_defaults("isurus", "isurus-project", "", ov1, d) runner.assert_eq_string(e2.image, "images:debian/12/cloud", "override.image wins") // 3. defaults when no flag, no override.image let e3 = config.merge_with_defaults("isurus", "isurus-project", "", empty_override(), d) runner.assert_eq_string(e3.image, "images:ubuntu/26.04/cloud", "defaults.image wins") // 4. profiles: override replaces defaults when has_profiles mut ov2: config.Override = empty_override() ov2.profiles = ["default", "claude-share", "node-dev"] ov2.has_profiles = true let e4 = config.merge_with_defaults("isurus", "isurus-project", "", ov2, d) runner.assert_eq_int(e4.profiles.length(), 3, "override profiles count") runner.assert_eq_string(e4.profiles[2], "node-dev", "override profiles[2]") // 5. profiles fall back to defaults let e5 = config.merge_with_defaults("isurus", "isurus-project", "", empty_override(), d) runner.assert_eq_int(e5.profiles.length(), 2, "defaults profiles count") // 6. mounts: auto bind always present, override appended mut m1: [config.Mount] = new [config.Mount](1) m1[0] = config.Mount { source: "~/.npm", path: "/home/u/.npm" } mut ov3: config.Override = empty_override() ov3.mounts = m1 let e6 = config.merge_with_defaults("isurus", "isurus-project", "", ov3, d) runner.assert_eq_int(e6.mounts.length(), 2, "auto bind + 1 override mount") runner.assert_eq_string(e6.mounts[0].source, "/home/u/repos/isurus-project", "auto bind source") runner.assert_eq_string(e6.mounts[0].path, "/home/u/repos/isurus-project", "auto bind dest") runner.assert_eq_string(e6.mounts[1].source, "~/.npm", "override mount preserved") // 7. env: passed through mut keys: [string] = ["NODE_ENV"] mut vals: [string] = ["development"] mut ov4: config.Override = empty_override() ov4.env_keys = keys ov4.env_values = vals let e7 = config.merge_with_defaults("isurus", "isurus-project", "", ov4, d) runner.assert_eq_int(e7.env_keys.length(), 1, "env_keys count") runner.assert_eq_string(e7.env_keys[0], "NODE_ENV", "env_keys[0]") runner.assert_eq_string(e7.env_values[0], "development", "env_values[0]") runner.report() end main ``` - [ ] **Step 2: Run test to verify it fails** ```bash reefc run tests/test_config_merge.reef ``` Expected: compile error (`merge_with_defaults` undefined). - [ ] **Step 3: Add merge_with_defaults to src/config.reef** Add to export block: ```reef fn merge_with_defaults(name: string, repo: string, image_flag: string, ov: Override, d: Defaults): EffectiveConfig ``` Implementation: ```reef fn merge_with_defaults(name: string, repo: string, image_flag: string, ov: Override, d: Defaults): EffectiveConfig let repos_root_expanded: string = paths.expand_home(d.repos_root) let repo_path: string = paths.join(repos_root_expanded, repo) // Image priority: flag → override → defaults mut image: string = d.default_image if str.length(ov.image) > 0 image = ov.image end if if str.length(image_flag) > 0 image = image_flag end if // Profiles: override replaces defaults when has_profiles, else defaults mut profiles: [string] = d.profiles if ov.has_profiles profiles = ov.profiles end if // Mounts: [auto repo bind] ++ override.mounts let ov_mount_count: int = ov.mounts.length() mut mounts: [Mount] = new [Mount](1 + ov_mount_count) mounts[0] = Mount { source: repo_path, path: repo_path } mut i: int = 0 while i < ov_mount_count // Expand ~ in mount source for host paths let m: Mount = ov.mounts[i] mounts[i + 1] = Mount { source: paths.expand_home(m.source), path: m.path } i = i + 1 end while return EffectiveConfig { name: name, repo: repo, repo_path: repo_path, image: image, profiles: profiles, mounts: mounts, env_keys: ov.env_keys, env_values: ov.env_values } end merge_with_defaults ``` - [ ] **Step 4: Run test to verify it passes** ```bash reefc run tests/test_config_merge.reef ``` Expected: 14 assertions pass. - [ ] **Step 5: Commit** ```bash hg add tests/test_config_merge.reef hg commit -m "config: merge_with_defaults + tests" ``` --- ## Task 10: config.add_project + config.update_last_sync **Files:** - Modify: `src/config.reef` (add the two registry mutators) - Modify: `tests/test_config_merge.reef` (add cases) — actually new file for clarity: - Create: `tests/test_config_mutate.reef` Two pure functions that build a new `Registry` from an existing one. They never write to disk — that's `save`'s job (Task 12). - [ ] **Step 1: Write the failing test** Create `tests/test_config_mutate.reef`: ```reef import config import test.framework import core.result_generic as rg fn empty_defaults(): config.Defaults return config.Defaults { repos_root: "/r", backup_root: "/b", incus_project: "p", default_image: "img", profiles: new [string](0) } end empty_defaults proc main() let runner = new framework.TestRunner() let reg0: config.Registry = config.Registry { schema: 1, defaults: empty_defaults(), projects: new [config.Project](0) } let p1: config.Project = config.Project { name: "isurus", repo: "isurus", image: "img", profiles: new [string](0), created: "t", last_sync: "", backup: true } let r1 = config.add_project(reg0, p1) runner.assert_eq_bool(rg.is_ok(r1), true, "add new project ok") if rg.is_ok(r1) let reg1 = rg.unwrap_ok(r1) runner.assert_eq_int(reg1.projects.length(), 1, "1 project after add") runner.assert_eq_string(reg1.projects[0].name, "isurus", "project added") // duplicate add fails let r2 = config.add_project(reg1, p1) runner.assert_eq_bool(rg.is_err(r2), true, "duplicate name rejected") // update_last_sync let r3 = config.update_last_sync(reg1, "isurus", "2026-04-29T12:00:00Z") runner.assert_eq_bool(rg.is_ok(r3), true, "update existing ok") if rg.is_ok(r3) let reg3 = rg.unwrap_ok(r3) runner.assert_eq_string(reg3.projects[0].last_sync, "2026-04-29T12:00:00Z", "last_sync updated") end if // update unknown name fails let r4 = config.update_last_sync(reg1, "nope", "t") runner.assert_eq_bool(rg.is_err(r4), true, "unknown name rejected") end if runner.report() end main ``` - [ ] **Step 2: Run test to verify it fails** ```bash reefc run tests/test_config_mutate.reef ``` Expected: compile error. - [ ] **Step 3: Add functions to src/config.reef** Export: ```reef fn add_project(reg: Registry, p: Project): rg.Result[Registry, string] fn update_last_sync(reg: Registry, name: string, ts: string): rg.Result[Registry, string] ``` Implementations: ```reef fn add_project(reg: Registry, p: Project): rg.Result[Registry, string] let n: int = reg.projects.length() mut i: int = 0 while i < n if reg.projects[i].name == p.name return @rg.Result[Registry, string].Err("project already exists: " + p.name) end if i = i + 1 end while mut new_projects: [Project] = new [Project](n + 1) mut k: int = 0 while k < n new_projects[k] = reg.projects[k] k = k + 1 end while new_projects[n] = p return @rg.Result[Registry, string].Ok(Registry { schema: reg.schema, defaults: reg.defaults, projects: new_projects }) end add_project fn update_last_sync(reg: Registry, name: string, ts: string): rg.Result[Registry, string] let n: int = reg.projects.length() mut found: int = -1 mut i: int = 0 while i < n if reg.projects[i].name == name found = i end if i = i + 1 end while if found < 0 return @rg.Result[Registry, string].Err("project not in registry: " + name) end if mut new_projects: [Project] = new [Project](n) mut k: int = 0 while k < n if k == found let old: Project = reg.projects[k] new_projects[k] = Project { name: old.name, repo: old.repo, image: old.image, profiles: old.profiles, created: old.created, last_sync: ts, backup: old.backup } else new_projects[k] = reg.projects[k] end if k = k + 1 end while return @rg.Result[Registry, string].Ok(Registry { schema: reg.schema, defaults: reg.defaults, projects: new_projects }) end update_last_sync ``` - [ ] **Step 4: Run test to verify it passes** ```bash reefc run tests/test_config_mutate.reef ``` Expected: 7 assertions pass. - [ ] **Step 5: Commit** ```bash hg add tests/test_config_mutate.reef hg commit -m "config: add_project + update_last_sync + tests" ``` --- ## Task 11: config.load_or_init (file I/O) **Files:** - Modify: `src/config.reef` (add `load_or_init`, `default_registry_for`, `registry_path`) - Create: `tests/test_config_io.reef` (uses temp dir under `/tmp`) `load_or_init(home_dir)` is the main entry point: returns the registry, creating an initial one if none exists. We use `home_dir` as a parameter (not implicit `$HOME`) so the test can drive it. - [ ] **Step 1: Write the failing test** Create `tests/test_config_io.reef`: ```reef import config import test.framework import core.result_generic as rg import io.dir as iodir import io.file as iofile import sys.process as proc proc main() let runner = new framework.TestRunner() // Set up a fresh temp dir as fake $HOME let pid_str: string = "12345" // process id stand-in for uniqueness; reuse same dir is fine let tmp: string = "/tmp/repoman-test-load-init" // Wipe and recreate let _: int = proc.process_wait(proc.process_spawn("rm", ["-rf", tmp])) let _: bool = iodir.create_dir_all(tmp) // First call: no .config/repoman/repoman.toml exists → init writes default let r1 = config.load_or_init(tmp) runner.assert_eq_bool(rg.is_ok(r1), true, "load_or_init creates default") if rg.is_ok(r1) let reg = rg.unwrap_ok(r1) runner.assert_eq_int(reg.schema, 1, "default schema = 1") runner.assert_eq_int(reg.projects.length(), 0, "default has no projects") runner.assert_eq_string(reg.defaults.incus_project, "repoman", "default incus_project") end if // The file should now exist on disk. let cfg_path: string = tmp + "/.config/repoman/repoman.toml" runner.assert_eq_bool(iofile.fileExists(cfg_path), true, "registry file written") // Second call: should load the existing file. let r2 = config.load_or_init(tmp) runner.assert_eq_bool(rg.is_ok(r2), true, "second load reads existing") // Cleanup let _: int = proc.process_wait(proc.process_spawn("rm", ["-rf", tmp])) runner.report() end main ``` - [ ] **Step 2: Run test to verify it fails** ```bash reefc run tests/test_config_io.reef ``` Expected: compile error. - [ ] **Step 3: Add load_or_init + helpers to src/config.reef** Export: ```reef fn registry_path(home_dir: string): string fn default_registry(home_dir: string): Registry fn load_or_init(home_dir: string): rg.Result[Registry, string] ``` Implementation: ```reef fn registry_path(home_dir: string): string let cfg_dir: string = paths.join(home_dir, ".config/repoman") return paths.join(cfg_dir, "repoman.toml") end registry_path fn default_registry(home_dir: string): Registry let repos_root: string = paths.join(home_dir, "repos") return Registry { schema: 1, defaults: Defaults { repos_root: repos_root, backup_root: "/nfs/repos", incus_project: "repoman", default_image: "images:ubuntu/26.04/cloud", profiles: ["default", "claude-share"] }, projects: new [Project](0) } end default_registry fn load_or_init(home_dir: string): rg.Result[Registry, string] let cfg_path: string = registry_path(home_dir) let cfg_dir: string = iopath.dirname(cfg_path) // Ensure ~/.config/repoman/ exists if not iodir.dir_exists(cfg_dir) if not iodir.create_dir_all(cfg_dir) return @rg.Result[Registry, string].Err("cannot create config dir: " + cfg_dir) end if end if if iofile.fileExists(cfg_path) let contents: string = iofile.readFile(cfg_path) return parse_registry(contents) end if // Init: write default registry. let reg: Registry = default_registry(home_dir) let saved_r = save(reg, cfg_path) if rg.is_err(saved_r) return @rg.Result[Registry, string].Err(rg.unwrap_err(saved_r)) end if return @rg.Result[Registry, string].Ok(reg) end load_or_init ``` Note: this references `save` which is the next task. Stub it out for now so this file compiles: ```reef fn save(reg: Registry, cfg_path: string): rg.Result[bool, string] let _: bool = iofile.writeFile(cfg_path, serialize_registry(reg)) return @rg.Result[bool, string].Ok(true) end save ``` The stub will be replaced in Task 12 with the proper atomic write. - [ ] **Step 4: Run test to verify it passes** ```bash reefc run tests/test_config_io.reef ``` Expected: 5 assertions pass; `/tmp/repoman-test-load-init` is cleaned up at the end. - [ ] **Step 5: Commit** ```bash hg add tests/test_config_io.reef hg commit -m "config: load_or_init with stub save (init flow + tests)" ``` --- ## Task 12: config.save (atomic write with fsync + rename) **Files:** - Modify: `src/config.reef` (replace stub `save` with atomic version) - Create: `tests/test_config_save.reef` Atomic write recipe per spec §3.4: write to `.tmp`, fsync the tmp, rename over the target. If any step fails, return Err with the failing step. - [ ] **Step 1: Write the failing test** Create `tests/test_config_save.reef`: ```reef import config import test.framework import core.result_generic as rg import io.dir as iodir import io.file as iofile import sys.process as proc proc main() let runner = new framework.TestRunner() let tmp: string = "/tmp/repoman-test-save" let _: int = proc.process_wait(proc.process_spawn("rm", ["-rf", tmp])) let _: bool = iodir.create_dir_all(tmp) let cfg_path: string = tmp + "/repoman.toml" let reg: config.Registry = config.default_registry("/home/u") let r1 = config.save(reg, cfg_path) runner.assert_eq_bool(rg.is_ok(r1), true, "save returns Ok") runner.assert_eq_bool(iofile.fileExists(cfg_path), true, "target file exists") runner.assert_eq_bool(iofile.fileExists(cfg_path + ".tmp"), false, "tmp removed after rename") // Round-trip: read what we wrote let contents: string = iofile.readFile(cfg_path) let r2 = config.parse_registry(contents) runner.assert_eq_bool(rg.is_ok(r2), true, "saved file parses") if rg.is_ok(r2) let reg2 = rg.unwrap_ok(r2) runner.assert_eq_int(reg2.schema, 1, "schema preserved on disk") end if let _: int = proc.process_wait(proc.process_spawn("rm", ["-rf", tmp])) runner.report() end main ``` - [ ] **Step 2: Run test to verify the stub passes one assertion but isn't atomic-safe** The stub `save` from Task 11 will pass the basic assertions (it does write the file), but doesn't go through `.tmp` and doesn't fsync. We're going to replace it. ```bash reefc run tests/test_config_save.reef ``` Expected: passes (the stub is correct enough for these assertions; we replace it for crash safety, not behavior). - [ ] **Step 3: Replace the stub save with the atomic version** In `src/config.reef`, find the stub `save` from Task 11 and replace it with: ```reef // Atomic write: writeFile(.tmp) → fsync(.tmp) → rename(.tmp, target). // If any step fails, returns Err naming the failing step. fn save(reg: Registry, cfg_path: string): rg.Result[bool, string] let serialized: string = serialize_registry(reg) let tmp_path: string = cfg_path + ".tmp" if not iofile.writeFile(tmp_path, serialized) return @rg.Result[bool, string].Err("write failed: " + tmp_path) end if if not iofile.fsync(tmp_path) // Best effort: clean up tmp let _: bool = iofile.deleteFile(tmp_path) return @rg.Result[bool, string].Err("fsync failed: " + tmp_path) end if if not iofile.rename(tmp_path, cfg_path) let _: bool = iofile.deleteFile(tmp_path) return @rg.Result[bool, string].Err("rename failed: " + tmp_path + " → " + cfg_path) end if return @rg.Result[bool, string].Ok(true) end save ``` Add `save` to the export block: ```reef fn save(reg: Registry, cfg_path: string): rg.Result[bool, string] ``` - [ ] **Step 4: Run test to verify it passes** ```bash reefc run tests/test_config_save.reef ``` Expected: 5 assertions pass; the `.tmp` file is gone after rename. - [ ] **Step 5: Commit** ```bash hg add tests/test_config_save.reef hg commit -m "config: atomic save (writeFile→fsync→rename)" ``` --- ## Task 13: incus subprocess wrappers **Files:** - Modify: `src/incus.reef` Subprocess wrappers around the `incus` CLI. These are NOT unit-tested — they need a live Incus daemon; the smoke recipe in Task 21 covers them. We do test that the argv-list construction is correct by inspection — read the bash prototype lines 105-115 for the canonical incantations. Wrappers needed: - `project_ensure(project: string)` — `incus project list --format csv -c name | grep -qx

`, else `incus project create

` - `container_exists(project: string, name: string)` — `incus list --project

--format csv -c n | grep -qx ` - `launch(project, name, image, profiles)` — `incus launch --project

[--profile P]+ ` - `device_add_disk(project, name, dev, source, dst)` — `incus config device add --project

disk source= path=` - `set_env(project, name, key, val)` — `incus config set --project

environment.=` - `restart(project, name)` — `incus restart --project

` Each wrapper calls `process_spawn(prog, argv)` then `process_wait(pid)` and returns `Result[bool, string]` (Ok(true) on exit 0, Err with stderr-summary on non-zero). Stderr passes through to the user's terminal — we don't capture and reformat. - [ ] **Step 1: Add wrappers to src/incus.reef** Add to `src/incus.reef`: ```reef import sys.process as p import core.result_generic as rg ``` Add to the export block: ```reef fn project_ensure(project: string): rg.Result[bool, string] fn container_exists(project: string, name: string): rg.Result[bool, string] fn launch(project: string, name: string, image: string, profiles: [string]): rg.Result[bool, string] fn device_add_disk(project: string, name: string, dev: string, src: string, dst: string): rg.Result[bool, string] fn set_env_var(project: string, name: string, key: string, val: string): rg.Result[bool, string] fn restart(project: string, name: string): rg.Result[bool, string] ``` Implementations (place before `end module`): ```reef // Run `incus `. Returns Ok(true) on exit 0, Err with a brief diagnostic // on non-zero. Stderr inherits the parent terminal — incus's own message // reaches the user without us reformatting. fn run_incus(args: [string]): rg.Result[bool, string] let pid: int = p.process_spawn("incus", args) if pid < 0 return @rg.Result[bool, string].Err("failed to spawn 'incus' (is it installed?)") end if let exit: int = p.process_wait(pid) if exit == 0 return @rg.Result[bool, string].Ok(true) end if return @rg.Result[bool, string].Err("incus exited with code " + int_to_str_simple(exit)) end run_incus fn int_to_str_simple(n: int): string if n == 0 return "0" end if mut value: int = n mut neg: bool = false if value < 0 neg = true value = -value end if mut digits: string = "" while value > 0 let d: int = value % 10 let dc: char = '0' unsafe dc = d + 48 end unsafe let one: string = "" let appended: string = append_char_local(one, dc) digits = appended + digits value = value / 10 end while if neg return "-" + digits end if return digits end int_to_str_simple fn append_char_local(s: string, c: char): string let n: int = str.length(s) mut out: string = "" mut i: int = 0 while i < n out = out + char_to_string(s[i]) i = i + 1 end while return out + char_to_string(c) end append_char_local fn char_to_string(c: char): string let buf: string = "" // Quick char-to-string via concatenation: rely on str.concat handling chars // (Reef strings are buffer-backed; if str.concat fails we'd need an FFI). mut tmp: string = " " tmp[0] = c return tmp end char_to_string // project_ensure: list, create if missing. fn project_ensure(project: string): rg.Result[bool, string] // `incus project show ` exits 0 if it exists, non-0 otherwise. let pid: int = p.process_spawn("incus", ["project", "show", project]) let exit: int = p.process_wait(pid) if exit == 0 return @rg.Result[bool, string].Ok(true) end if // Create return run_incus(["project", "create", project]) end project_ensure fn container_exists(project: string, name: string): rg.Result[bool, string] // `incus info --project

` exits 0 if it exists. let pid: int = p.process_spawn("incus", ["info", "--project", project, name]) let exit: int = p.process_wait(pid) if exit == 0 return @rg.Result[bool, string].Ok(true) end if return @rg.Result[bool, string].Ok(false) end container_exists fn launch(project: string, name: string, image: string, profiles: [string]): rg.Result[bool, string] let pn: int = profiles.length() // Compute argv length: ["launch", "--project", project, ...profile args (2*pn), image, name] mut args: [string] = new [string](3 + 2 * pn + 2) args[0] = "launch" args[1] = "--project" args[2] = project mut i: int = 0 while i < pn args[3 + i * 2] = "--profile" args[3 + i * 2 + 1] = profiles[i] i = i + 1 end while args[3 + 2 * pn] = image args[3 + 2 * pn + 1] = name return run_incus(args) end launch fn device_add_disk(project: string, name: string, dev: string, src: string, dst: string): rg.Result[bool, string] return run_incus([ "config", "device", "add", "--project", project, name, dev, "disk", "source=" + src, "path=" + dst ]) end device_add_disk fn set_env_var(project: string, name: string, key: string, val: string): rg.Result[bool, string] return run_incus([ "config", "set", "--project", project, name, "environment." + key + "=" + val ]) end set_env_var fn restart(project: string, name: string): rg.Result[bool, string] return run_incus(["restart", "--project", project, name]) end restart ``` Note on the int-to-string and char-to-string helpers: reef-stdlib does have these (`encoding.toml` has `int_to_str`; `core.str` has helpers) but they aren't all exported uniformly. To minimize surprise, the helpers above are local and self-contained. If a clean stdlib import works, prefer it — but verify with `reefc --check` before relying on it. - [ ] **Step 2: Compile-check the module** ```bash reefc build ``` Expected: builds cleanly. (No tests for this module — they require a live Incus.) - [ ] **Step 3: Commit** ```bash hg commit -m "incus: subprocess wrappers (project_ensure/launch/device_add/set_env/restart)" ``` --- ## Task 14: sync.build_rsync_args (pure) **Files:** - Create: `src/sync.reef` - Create: `tests/test_sync_args.reef` Pure function that builds the rsync argv from `(src, dst, dry_run, no_delete, is_tty, excluded_repos)`. The standard exclude list is hardcoded to match the bash prototype line by line. `excluded_repos` is the per-project `backup = false` skip list, applied only in whole-tree mode. - [ ] **Step 1: Write the failing test** Create `tests/test_sync_args.reef`: ```reef import sync import test.framework proc main() let runner = new framework.TestRunner() // Basic: no flags let a1: [string] = sync.build_rsync_args("/src/", "/dst/", false, false, false, new [string](0)) runner.assert_eq_bool(contains_str(a1, "-aHAX"), true, "has -aHAX") runner.assert_eq_bool(contains_str(a1, "--info=stats2"), true, "has --info=stats2") runner.assert_eq_bool(contains_str(a1, "--delete"), true, "delete on by default") runner.assert_eq_bool(contains_str(a1, "--exclude=node_modules/"), true, "node_modules excluded") runner.assert_eq_bool(contains_str(a1, "--exclude=.cache/"), true, ".cache excluded") runner.assert_eq_bool(last_two(a1, "/src/", "/dst/"), true, "src and dst end positional") // Dry run: --dry-run + --itemize-changes + --info=stats2 (NOT progress2) let a2: [string] = sync.build_rsync_args("/src/", "/dst/", true, false, true, new [string](0)) runner.assert_eq_bool(contains_str(a2, "--dry-run"), true, "dry-run flag") runner.assert_eq_bool(contains_str(a2, "--itemize-changes"), true, "itemize-changes flag") runner.assert_eq_bool(contains_str(a2, "--info=stats2"), true, "info stats2") runner.assert_eq_bool(contains_str(a2, "--info=stats2,progress2"), false, "no progress in dry-run") // No delete let a3: [string] = sync.build_rsync_args("/src/", "/dst/", false, true, false, new [string](0)) runner.assert_eq_bool(contains_str(a3, "--delete"), false, "no --delete with no_delete") // TTY interactive: stats2,progress2 let a4: [string] = sync.build_rsync_args("/src/", "/dst/", false, false, true, new [string](0)) runner.assert_eq_bool(contains_str(a4, "--info=stats2,progress2"), true, "tty progress") // Excluded repos in whole-tree mode let a5: [string] = sync.build_rsync_args("/src/", "/dst/", false, false, false, ["repo-A", "repo-B"]) runner.assert_eq_bool(contains_str(a5, "--exclude=repo-A/"), true, "excluded repo A") runner.assert_eq_bool(contains_str(a5, "--exclude=repo-B/"), true, "excluded repo B") runner.report() end main fn contains_str(arr: [string], target: string): bool let n: int = arr.length() mut i: int = 0 while i < n if arr[i] == target return true end if i = i + 1 end while return false end contains_str fn last_two(arr: [string], a: string, b: string): bool let n: int = arr.length() if n < 2 return false end if return arr[n - 2] == a and arr[n - 1] == b end last_two ``` - [ ] **Step 2: Run test to verify it fails** ```bash reefc run tests/test_sync_args.reef ``` Expected: compile error (`sync` module not found). - [ ] **Step 3: Create src/sync.reef** ```reef module sync import core.str import core.result_generic as rg import sys.process as p export fn build_rsync_args(src: string, dst: string, dry_run: bool, no_delete: bool, is_tty: bool, excluded_repos: [string]): [string] end export // Hardcoded excludes matching bash prototype line 26-41. fn standard_excludes(): [string] return [ "node_modules/", "target/", "build/", "dist/", ".next/", "__pycache__/", "*.pyc", ".venv/", "venv/", ".cache/", ".tox/", ".pytest_cache/", ".mypy_cache/", ".ruff_cache/" ] end standard_excludes fn build_rsync_args(src: string, dst: string, dry_run: bool, no_delete: bool, is_tty: bool, excluded_repos: [string]): [string] let std: [string] = standard_excludes() let std_n: int = std.length() let ex_n: int = excluded_repos.length() // Estimate capacity: 1 (-aHAX) + up to 3 info flags + 1 (--delete) + std_n excludes + ex_n excludes + 2 positionals let cap: int = 1 + 3 + 1 + std_n + ex_n + 2 mut buf: [string] = new [string](cap) mut k: int = 0 buf[k] = "-aHAX" k = k + 1 if dry_run buf[k] = "--dry-run" k = k + 1 buf[k] = "--itemize-changes" k = k + 1 buf[k] = "--info=stats2" k = k + 1 elif is_tty buf[k] = "--info=stats2,progress2" k = k + 1 else buf[k] = "--info=stats2" k = k + 1 end if if not no_delete buf[k] = "--delete" k = k + 1 end if mut i: int = 0 while i < std_n buf[k] = "--exclude=" + std[i] k = k + 1 i = i + 1 end while mut j: int = 0 while j < ex_n buf[k] = "--exclude=" + excluded_repos[j] + "/" k = k + 1 j = j + 1 end while buf[k] = src k = k + 1 buf[k] = dst k = k + 1 // Trim to actual size mut out: [string] = new [string](k) mut m: int = 0 while m < k out[m] = buf[m] m = m + 1 end while return out end build_rsync_args end module ``` - [ ] **Step 4: Run test to verify it passes** ```bash reefc run tests/test_sync_args.reef ``` Expected: 13 assertions pass. - [ ] **Step 5: Commit** ```bash hg add src/sync.reef tests/test_sync_args.reef hg commit -m "sync: build_rsync_args (pure) + tests covering every branch" ``` --- ## Task 15: sync.ensure_nfs_mounted (subprocess) **Files:** - Modify: `src/sync.reef` (add `ensure_nfs_mounted`) Three-step check from bash prototype lines 60-71: 1. `stat ` (triggers autofs) 2. `mountpoint -q ` (confirms it's a mount) 3. `findmnt -t nfs4 ` (confirms NFSv4) Each fails fast with a different message. No unit test — needs a real NFS mount; smoke test will exercise it. - [ ] **Step 1: Add ensure_nfs_mounted to src/sync.reef** Add to export block: ```reef fn ensure_nfs_mounted(backup_root: string): rg.Result[bool, string] ``` Implementation: ```reef fn ensure_nfs_mounted(backup_root: string): rg.Result[bool, string] // Step 1: stat triggers autofs let pid1: int = p.process_spawn("stat", [backup_root]) if pid1 < 0 return @rg.Result[bool, string].Err("cannot spawn stat") end if if p.process_wait(pid1) != 0 return @rg.Result[bool, string].Err("cannot stat " + backup_root + " — autofs misconfigured or server unreachable") end if // Step 2: mountpoint let pid2: int = p.process_spawn("mountpoint", ["-q", backup_root]) if pid2 < 0 return @rg.Result[bool, string].Err("cannot spawn mountpoint") end if if p.process_wait(pid2) != 0 return @rg.Result[bool, string].Err(backup_root + " exists but is not a mount — NFS server unreachable?") end if // Step 3: findmnt -t nfs4 let pid3: int = p.process_spawn("findmnt", ["-t", "nfs4", backup_root]) if pid3 < 0 return @rg.Result[bool, string].Err("cannot spawn findmnt") end if if p.process_wait(pid3) != 0 return @rg.Result[bool, string].Err(backup_root + " is mounted but not as nfs4 — check /etc/auto.nfs") end if return @rg.Result[bool, string].Ok(true) end ensure_nfs_mounted ``` - [ ] **Step 2: Compile-check** ```bash reefc build ``` Expected: builds cleanly. - [ ] **Step 3: Commit** ```bash hg commit -m "sync: ensure_nfs_mounted (stat → mountpoint → findmnt)" ``` --- ## Task 16: sync.run **Files:** - Modify: `src/sync.reef` (add `run`) Spawns rsync with the args from `build_rsync_args` and returns its exit code. Inherits parent stdio so the user sees rsync's progress live. - [ ] **Step 1: Add run to src/sync.reef** Add to export block: ```reef fn run(args: [string]): int ``` Implementation: ```reef // Spawn rsync with the given argv. Inherits parent stdio (no capture). // Returns rsync's exit code, or -1 if spawn failed. fn run(args: [string]): int let pid: int = p.process_spawn("rsync", args) if pid < 0 return -1 end if return p.process_wait(pid) end run ``` - [ ] **Step 2: Compile-check** ```bash reefc build ``` Expected: builds cleanly. - [ ] **Step 3: Commit** ```bash hg commit -m "sync: run (spawn rsync, inherit stdio, return exit code)" ``` --- ## Task 17: cli.cmd_new (orchestration) **Files:** - Create: `src/cli.reef` (with `cmd_new` function and supporting flag-parsing) `cmd_new` walks through every step of spec §4.1: validate name, load registry, reject duplicates, resolve repo path, parse override, merge, ensure project, ensure no container conflict, launch, attach mounts, set env, restart, write registry, print ready hint. No unit test — orchestration is smoke-tested. - [ ] **Step 1: Create src/cli.reef with cmd_new** ```reef module cli import core.str import core.result_generic as rg import io.console as console import io.file as iofile import sys.flag as flag import sys.env as env import sys.args as args import config import incus import sync import paths export fn cmd_new(argv: [string]): int fn cmd_sync(argv: [string]): int fn dispatch(argv: [string]): int end export // argv passed in is the slice past argv[1] (i.e., excludes program + subcommand). fn cmd_new(argv: [string]): int let parser: flag.FlagParser = flag.flag_parser_from(argv) flag.application(parser, "repoman new") flag.description(parser, "Create a new container + repo bind") let _ = flag.string_flag(parser, "repo", '\0', "", "repo dirname (defaults to )") let _ = flag.string_flag(parser, "image", '\0', "", "container image (overrides default)") if not flag.parse(parser) console.printErr("repoman: error: " + flag.error(parser) + "\n") return 2 end if let positionals: [string] = flag.positional_args(parser) if positionals.length() != 1 console.printErr("repoman: error: 'new' takes exactly one positional argument: \n") return 2 end if let name: string = positionals[0] let repo_flag: string = flag.get_string(parser, "repo") let image_flag: string = flag.get_string(parser, "image") if not incus.validate_name(name) console.printErr("repoman: error: invalid container name: " + name + "\n") console.printErr("hint: lowercase alphanumeric + hyphens, ≤63 chars, no leading hyphen\n") return 1 end if let home: string = env.get_env_or("HOME", "") if str.length(home) == 0 console.printErr("repoman: error: HOME is not set\n") return 3 end if let cfg_path: string = config.registry_path(home) let reg_r = config.load_or_init(home) if rg.is_err(reg_r) console.printErr("repoman: error: " + rg.unwrap_err(reg_r) + "\n") return 3 end if let reg: config.Registry = rg.unwrap_ok(reg_r) // Reject duplicate name let pn: int = reg.projects.length() mut i: int = 0 while i < pn if reg.projects[i].name == name console.printErr("repoman: error: project '" + name + "' already in registry\n") console.printErr("hint: incus delete --project " + reg.defaults.incus_project + " " + name + " ; then remove from " + cfg_path + "\n") return 4 end if i = i + 1 end while // Resolve repo path mut repo: string = repo_flag if str.length(repo) == 0 repo = name end if let repos_root: string = paths.expand_home(reg.defaults.repos_root) let repo_path: string = paths.join(repos_root, repo) if not paths.is_dir(repo_path) console.printErr("repoman: error: no repo at " + repo_path + "\n") return 3 end if // Read override (optional) let override_path: string = paths.join(home, ".config/repoman/repos.d/" + name + ".toml") mut override: config.Override = config.Override { image: "", profiles: new [string](0), has_profiles: false, mounts: new [config.Mount](0), env_keys: new [string](0), env_values: new [string](0) } if iofile.fileExists(override_path) let ov_r = config.parse_override(iofile.readFile(override_path)) if rg.is_err(ov_r) console.printErr("repoman: error: bad override " + override_path + ": " + rg.unwrap_err(ov_r) + "\n") return 3 end if override = rg.unwrap_ok(ov_r) end if let eff: config.EffectiveConfig = config.merge_with_defaults(name, repo, image_flag, override, reg.defaults) // Ensure incus project console.printErr("==> incus project ensure " + reg.defaults.incus_project + "\n") let pe = incus.project_ensure(reg.defaults.incus_project) if rg.is_err(pe) console.printErr("repoman: error: " + rg.unwrap_err(pe) + "\n") return 1 end if // Reject if container exists already let ce = incus.container_exists(reg.defaults.incus_project, name) if rg.is_err(ce) console.printErr("repoman: error: " + rg.unwrap_err(ce) + "\n") return 1 end if if rg.unwrap_ok(ce) console.printErr("repoman: error: container '" + name + "' already exists in project '" + reg.defaults.incus_project + "'\n") console.printErr("hint: incus delete --project " + reg.defaults.incus_project + " " + name + "\n") return 4 end if // Launch console.printErr("==> incus launch " + eff.image + " " + name + "\n") let lr = incus.launch(reg.defaults.incus_project, name, eff.image, eff.profiles) if rg.is_err(lr) console.printErr("repoman: error: " + rg.unwrap_err(lr) + "\n") return 1 end if // Mounts: device names "repo" for the auto bind, "mount-1", "mount-2", ... let mn: int = eff.mounts.length() mut k: int = 0 while k < mn let m: config.Mount = eff.mounts[k] let dev_name: string = "repo" if k > 0 dev_name = "mount-" + int_to_str_simple(k) end if console.printErr("==> incus device add " + name + " " + dev_name + " " + m.source + ":" + m.path + "\n") let dr = incus.device_add_disk(reg.defaults.incus_project, name, dev_name, m.source, m.path) if rg.is_err(dr) console.printErr("repoman: error: " + rg.unwrap_err(dr) + "\n") console.printErr("hint: incus delete --project " + reg.defaults.incus_project + " " + name + "\n") return 1 end if k = k + 1 end while // Env let en: int = eff.env_keys.length() mut e: int = 0 while e < en let er = incus.set_env_var(reg.defaults.incus_project, name, eff.env_keys[e], eff.env_values[e]) if rg.is_err(er) console.printErr("repoman: error: " + rg.unwrap_err(er) + "\n") return 1 end if e = e + 1 end while // Restart so binds + env take effect console.printErr("==> incus restart " + name + "\n") let rr = incus.restart(reg.defaults.incus_project, name) if rg.is_err(rr) console.printErr("repoman: error: " + rg.unwrap_err(rr) + "\n") return 1 end if // Build new project entry and write registry let new_p: config.Project = config.Project { name: name, repo: repo, image: eff.image, profiles: eff.profiles, created: "", // v0.1: leave timestamp blank (no time stdlib used yet) last_sync: "", backup: true } let reg2_r = config.add_project(reg, new_p) if rg.is_err(reg2_r) console.printErr("repoman: error: " + rg.unwrap_err(reg2_r) + "\n") return 1 end if let saved = config.save(rg.unwrap_ok(reg2_r), cfg_path) if rg.is_err(saved) console.printErr("repoman: error: " + rg.unwrap_err(saved) + "\n") return 1 end if // Ready hint — use $UID and $HOME for shell expansion (correct on any host) console.printErr("==> ready\n") console.printErr("\n") console.printErr(" shell in: incus exec --project " + reg.defaults.incus_project + " --user $UID --cwd " + repo_path + " --env HOME=$HOME " + name + " -- bash -l\n") console.printErr(" run claude: incus exec --project " + reg.defaults.incus_project + " " + name + " -- claude\n") return 0 end cmd_new // Local int-to-str helper (decimal, non-negative integers expected) fn int_to_str_simple(n: int): string if n == 0 return "0" end if mut value: int = n mut digits: string = "" while value > 0 let d: int = value % 10 let dc: char = '0' unsafe dc = d + 48 end unsafe let one_buf: string = " " one_buf[0] = dc digits = one_buf + digits value = value / 10 end while return digits end int_to_str_simple end module ``` - [ ] **Step 2: Compile-check** ```bash reefc build ``` Expected: builds cleanly. - [ ] **Step 3: Commit** ```bash hg add src/cli.reef hg commit -m "cli: cmd_new orchestration (validate→load→merge→incus→save)" ``` --- ## Task 18: cli.cmd_sync (orchestration) **Files:** - Modify: `src/cli.reef` (add `cmd_sync`) `cmd_sync` walks spec §4.2: parse flags, load registry, ensure NFS mount, resolve target (single project vs whole tree), build args, run rsync, on success update last_sync. Exit code is rsync's, with pre-rsync errors using 1-4. - [ ] **Step 1: Add cmd_sync to src/cli.reef** Add the implementation (anywhere before `end module`): ```reef fn cmd_sync(argv: [string]): int let parser: flag.FlagParser = flag.flag_parser_from(argv) flag.application(parser, "repoman sync") flag.description(parser, "rsync local repos → NFS backup") let _ = flag.bool_flag(parser, "no-delete", '\0', false, "additive only — no deletions on the destination") let _ = flag.bool_flag(parser, "dry-run", '\0', false, "preview changes without writing") if not flag.parse(parser) console.printErr("repoman: error: " + flag.error(parser) + "\n") return 2 end if let positionals: [string] = flag.positional_args(parser) if positionals.length() > 1 console.printErr("repoman: error: 'sync' takes at most one positional argument: [name]\n") return 2 end if let no_delete: bool = flag.get_bool(parser, "no-delete") let dry_run: bool = flag.get_bool(parser, "dry-run") let home: string = env.get_env_or("HOME", "") if str.length(home) == 0 console.printErr("repoman: error: HOME is not set\n") return 3 end if let reg_r = config.load_or_init(home) if rg.is_err(reg_r) console.printErr("repoman: error: " + rg.unwrap_err(reg_r) + "\n") return 3 end if let reg: config.Registry = rg.unwrap_ok(reg_r) let cfg_path: string = config.registry_path(home) let backup_root: string = paths.expand_home(reg.defaults.backup_root) let repos_root: string = paths.expand_home(reg.defaults.repos_root) // ensure_nfs_mounted let mr = sync.ensure_nfs_mounted(backup_root) if rg.is_err(mr) console.printErr("repoman: error: " + rg.unwrap_err(mr) + "\n") return 3 end if // Resolve target mut src: string = "" mut dst: string = "" mut excluded: [string] = new [string](0) mut single_target: string = "" if positionals.length() == 1 let name: string = positionals[0] // Find in registry let pn: int = reg.projects.length() mut found: int = -1 mut i: int = 0 while i < pn if reg.projects[i].name == name found = i end if i = i + 1 end while if found < 0 console.printErr("repoman: error: '" + name + "' not in registry\n") console.printErr("hint: repoman new " + name + "\n") return 1 end if let proj: config.Project = reg.projects[found] if not proj.backup console.printErr("repoman: error: '" + name + "' has backup = false; refusing single-target sync\n") return 1 end if src = paths.join(repos_root, proj.repo) + "/" dst = paths.join(backup_root, proj.repo) + "/" single_target = name else // whole tree src = repos_root + "/" dst = backup_root + "/" // Build excludes for backup=false projects let pn: int = reg.projects.length() mut buf: [string] = new [string](pn) mut count: int = 0 mut i: int = 0 while i < pn if not reg.projects[i].backup buf[count] = reg.projects[i].repo count = count + 1 end if i = i + 1 end while mut tight: [string] = new [string](count) mut j: int = 0 while j < count tight[j] = buf[j] j = j + 1 end while excluded = tight end if // Build args + log + run let is_tty: bool = false // v0.1: assume non-TTY (cron-friendly defaults). // TTY detection via ui.backend.tty.is_tty(STDOUT_FD) is a v0.2 niceness. let rsync_args: [string] = sync.build_rsync_args(src, dst, dry_run, no_delete, is_tty, excluded) mut tags: string = "" if dry_run tags = tags + "(dry-run) " end if if no_delete tags = tags + "(additive) " end if console.printErr("==> rsync " + tags + src + " → " + dst + "\n") let exit: int = sync.run_rsync(rsync_args) if exit < 0 console.printErr("repoman: error: failed to spawn rsync\n") return 1 end if if exit != 0 return exit end if // Success: update last_sync. Skip in dry-run mode (nothing changed). if not dry_run let now: string = "" // v0.1: timestamp blank; time stdlib integration is a follow-on if str.length(single_target) > 0 let upd = config.update_last_sync(reg, single_target, now) if rg.is_ok(upd) let _ = config.save(rg.unwrap_ok(upd), cfg_path) end if else mut cur: config.Registry = reg let pn: int = cur.projects.length() mut i: int = 0 while i < pn if cur.projects[i].backup let upd = config.update_last_sync(cur, cur.projects[i].name, now) if rg.is_ok(upd) cur = rg.unwrap_ok(upd) end if end if i = i + 1 end while let _ = config.save(cur, cfg_path) end if end if return 0 end cmd_sync ``` - [ ] **Step 2: Compile-check** ```bash reefc build ``` Expected: builds cleanly. - [ ] **Step 3: Commit** ```bash hg commit -m "cli: cmd_sync orchestration (NFS check → rsync → last_sync update)" ``` --- ## Task 19: cli.dispatch (outer router) **Files:** - Modify: `src/cli.reef` (add `dispatch`, `usage`) Outer dispatch on `argv[1]` per spec §5. Handles `new`, `sync`, `--version`/`-V`, `--help`/`-h`/`help`, no args. - [ ] **Step 1: Add dispatch + usage to src/cli.reef** Add to the top of the implementations (before `cmd_new`): ```reef fn version_string(): string return "repoman 0.1.0" end version_string proc print_usage() console.printErr("Usage: repoman [args]\n") console.printErr("\n") console.printErr("Subcommands\n") console.printErr(" new [--repo ] [--image ]\n") console.printErr(" Launch a container in the 'repoman' Incus project; bind ~/repos/.\n") console.printErr("\n") console.printErr(" sync [name] [--no-delete] [--dry-run]\n") console.printErr(" Mirror local repos to NFS backup (rsync --delete by default).\n") console.printErr("\n") console.printErr(" --version | -V\n") console.printErr(" --help | -h | help\n") end print_usage ``` Add `dispatch` (place after `cmd_sync`): ```reef fn dispatch(argv: [string]): int // argv is the full process argv: [program, subcommand, ...] let n: int = argv.length() if n < 2 print_usage() return 0 end if let sub: string = argv[1] if sub == "--version" or sub == "-V" console.printErr(version_string() + "\n") return 0 end if if sub == "--help" or sub == "-h" or sub == "help" print_usage() return 0 end if // Slice argv[2..] for the subcommand parser mut rest: [string] = new [string](n - 2) mut i: int = 0 while i < n - 2 rest[i] = argv[i + 2] i = i + 1 end while if sub == "new" return cmd_new(rest) end if if sub == "sync" return cmd_sync(rest) end if console.printErr("repoman: error: unknown subcommand: " + sub + "\n") console.printErr("hint: try 'repoman --help'\n") return 2 end dispatch ``` - [ ] **Step 2: Compile-check** ```bash reefc build ``` Expected: builds cleanly. - [ ] **Step 3: Commit** ```bash hg commit -m "cli: dispatch + usage (subcommand routing, --version, --help)" ``` --- ## Task 20: main.reef entry point **Files:** - Modify: `src/main.reef` Tiny entry that collects argv, calls `cli.dispatch`, exits with the returned code. - [ ] **Step 1: Replace src/main.reef** Overwrite `src/main.reef` with: ```reef import cli import sys.args as args import sys.process as p proc main() let n: int = args.count() mut argv: [string] = new [string](n) mut i: int = 0 while i < n argv[i] = args.get(i) i = i + 1 end while let code: int = cli.dispatch(argv) p.exit_now(code) end main ``` - [ ] **Step 2: Build and smoke-test the help output** ```bash reefc build ./build/repoman --version ./build/repoman --help ./build/repoman ./build/repoman bogus-subcommand ``` Expected: - `--version` → `repoman 0.1.0`, exit 0. - `--help` → usage, exit 0. - no args → usage, exit 0. - bogus → error message, exit 2. (Run `echo $?` after each to check exit codes.) - [ ] **Step 3: Commit** ```bash hg commit -m "main: argv collection + dispatch" ``` --- ## Task 21: README + Makefile **Files:** - Create: `README.md` - Create: `Makefile` README documents quickstart, build, test loop, smoke recipe, install. Makefile provides `make`/`make install`/`make uninstall`/`make clean`/`make test` for distro packagers — `reefc` does the actual building. - [ ] **Step 1: Create README.md** ```markdown # repoman Per-project Incus containers + opinionated NFS/ZFS backup. v0.1. ## Build ```bash reefc build ``` Produces `./build/repoman`. ## Test ```bash for t in tests/test_*.reef; do echo "== $t ==" reefc run "$t" || exit 1 done ``` ## Install System-wide via Makefile (uses `reefc build` under the hood): ```bash make sudo make install # installs to /usr/local/bin/repoman ``` ## Quickstart ```bash # First run creates ~/.config/repoman/repoman.toml with sane defaults. repoman --help repoman new isurus --repo isurus-project repoman sync --dry-run ``` ## Smoke test (requires Incus + NFS) ```bash # In an existing repo dir under ~/repos: repoman new test-foo repoman sync test-foo --dry-run incus delete --project repoman test-foo ``` ## Configuration Central registry: `~/.config/repoman/repoman.toml` (managed; do not edit while repoman is running). Per-project overrides: `~/.config/repoman/repos.d/.toml` (user-authored). Example: ```toml [container] image = "images:debian/12/cloud" profiles = ["default", "claude-share", "node-dev"] [[mount]] source = "~/.npm" path = "/home/ctusa/.npm" [env] NODE_ENV = "development" ``` ## Recommended Incus profile: `claude-share` For the agent-friendly setup repoman is built around, create a shared profile that exposes the user's Claude state: ```bash # (one-time) incus profile create claude-share incus profile edit claude-share # add your bind-mounts for ~/.claude, etc. ``` repoman uses profiles `default` and `claude-share` by default; override per-project in `repos.d/.toml`. ``` - [ ] **Step 2: Create Makefile** ```makefile PREFIX ?= /usr/local BINDIR = $(PREFIX)/bin DESTDIR ?= .PHONY: all build test clean install uninstall all: build build: reefc build test: @for t in tests/test_*.reef; do \ echo "== $$t =="; \ reefc run "$$t" || exit 1; \ done clean: reefc clean install: build install -d $(DESTDIR)$(BINDIR) install -m 0755 build/repoman $(DESTDIR)$(BINDIR)/repoman uninstall: rm -f $(DESTDIR)$(BINDIR)/repoman ``` - [ ] **Step 3: Verify make targets work** ```bash make clean make build ls build/repoman make test ``` Expected: `make build` produces the binary, `make test` runs every `tests/test_*.reef` and reports pass. - [ ] **Step 4: Commit** ```bash hg add README.md Makefile hg commit -m "docs: README + Makefile (quickstart, install, test loop)" ``` --- ## Task 22: End-to-end smoke test **Files:** none — operational verification Run the smoke recipe from README against a real Incus + NFS environment. This is the v0.1 acceptance gate. - [ ] **Step 1: Pre-flight** Confirm: - `incus version` works, daemon is running - `/nfs/repos` is autofs-mounted (autofs config covers it) - A dir exists under `~/repos/` for the smoke (e.g., `mkdir ~/repos/repoman-smoke; cd ~/repos/repoman-smoke; hg init`) - [ ] **Step 2: First-run init** ```bash rm -rf ~/.config/repoman # clean slate; you may want to back up first if you've been using it ./build/repoman --help ls ~/.config/repoman/repoman.toml # should NOT exist yet (help doesn't init) ./build/repoman sync --dry-run # this triggers init cat ~/.config/repoman/repoman.toml # should show schema=1, no projects ``` Expected: registry created on first command that touches it. - [ ] **Step 3: `new` flow** ```bash ./build/repoman new repoman-smoke ``` Expected: - Stderr shows `==> incus project ensure repoman`, `==> incus launch ...`, `==> incus device add ... repo ...`, `==> incus restart ...`, `==> ready`. - `incus list --project repoman` shows `repoman-smoke` running. - `~/.config/repoman/repoman.toml` now has a `[[project]]` entry for `repoman-smoke`. - The shell-in hint is printed. - [ ] **Step 4: Verify the bind mount** ```bash incus exec --project repoman repoman-smoke -- ls /home/ctusa/repos/repoman-smoke ``` Expected: contents of the host repo are visible inside the container. - [ ] **Step 5: `sync` dry-run** ```bash ./build/repoman sync repoman-smoke --dry-run ``` Expected: rsync prints itemize-changes lines (mostly `cd+++++++++` for new dirs), exits 0, the registry's `last_sync` for `repoman-smoke` is NOT updated (because dry-run). - [ ] **Step 6: `sync` real run** ```bash ./build/repoman sync repoman-smoke ``` Expected: rsync runs, exits 0, registry's `last_sync` for `repoman-smoke` is updated to a non-empty value (or stays empty if you haven't wired the timestamp yet — that's a v0.2 task per spec). - [ ] **Step 7: Cleanup** ```bash incus delete --project repoman --force repoman-smoke # Manually remove the entry from ~/.config/repoman/repoman.toml (no `repoman remove` in v0.1) ``` - [ ] **Step 8: Tag the release** ```bash hg tag -m "v0.1.0 ships" v0.1.0 ``` If the smoke test surfaced bugs, fix them before tagging — open follow-up commits as needed. --- ## Self-review checklist (run after writing all tasks) After completing the plan, verify: 1. **Spec coverage:** - §1 scope: `new`, `sync`, registry, override, project namespace ✓ (Tasks 17, 18, 11, 8, 17) - §2 architecture: 6 modules ✓ (Tasks 2, 3, 4-12, 13, 14-16, 17-19) - §3 data shapes: registry, override, merge, atomic write, validation ✓ (Tasks 4-9, 11, 12) - §4 flows: `new`, `sync` ✓ (Tasks 17, 18) - §5 CLI: subcommands, exit codes, error UX ✓ (Tasks 17-19) - §6 testing: pure-logic units, smoke recipe ✓ (Tasks 2, 3, 5-12, 14, 22) - §7 build/install: reef.toml, Makefile, README ✓ (Tasks 1, 21) 2. **Type consistency:** function names and signatures match between definitions and call sites — `parse_registry`, `serialize_registry`, `parse_override`, `merge_with_defaults`, `add_project`, `update_last_sync`, `load_or_init`, `save`, `registry_path`, `default_registry`. The `incus.*` and `sync.*` symbol shapes are consistent across `cli.reef`'s call sites and the module exports. 3. **Placeholder scan:** no TODO, no "implement later", no "similar to Task N". Code blocks are concrete. --- ## Known v0.1 simplifications (intentional, not gaps) These are tracked for v0.2, deliberately omitted from the plan: - **Timestamps blank.** `created` and `last_sync` are written as `""`. A `time.now_iso8601()` integration is straightforward but the time stdlib hasn't been audited yet; a dedicated follow-up task can add it without breaking the schema. - **TTY detection always false.** `cmd_sync` hardcodes `is_tty = false`, giving cron-friendly `--info=stats2`. To get progress bars in interactive use, wire `ui.backend.tty.is_tty(STDOUT_FD)`. Trivial to add. - **No rollback on partial `new` failure.** Per spec §4.1: if launch succeeds but a downstream step fails, the user sees the error and the manual cleanup hint. v0.2 candidate: `--rollback-on-error`. - **No CI.** Per spec §6.5. Test loop runs locally via `make test`. # 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.reef` — `process_run_capture` for stdout-capturing wrappers; `run_incus` for fire-and-forget - `src/cli.reef` — `cmd_*` 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 ` 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** ```bash 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`): ```reef type LlmDefaults ``` Then add the new type definition immediately above the existing `type Defaults = struct` block (around line 32): ```reef 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`: ```reef 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** ```bash 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 `}`): ```reef llm: LlmDefaults { enabled: false, hermes_default: false, ollama_url: "", hermes_seed: new [string](0) } ``` In `default_registry` (around line 434), do the same: ```reef llm: LlmDefaults { enabled: false, hermes_default: false, ollama_url: "", hermes_seed: new [string](0) } ``` - [ ] **Step 5: Build and run all existing tests** ```bash 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** ```bash 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: ```reef 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: ```reef hermes: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "hermes") == "true" ``` For `update_last_sync` (line ~367), preserve old.hermes: ```reef 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: ```reef 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** ```bash make build && make test ``` Expected: clean build, all existing tests pass. - [ ] **Step 4: Commit** ```bash 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`: ```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** ```bash 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): ```reef if schema != 1 return @Result[Registry, string].Err("unsupported schema (expected 1)") end if ``` Replace with: ```reef 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: ```reef 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: ```reef 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** ```bash reefc run tests/test_config_llm_parse.reef ``` Expected: all 9 assertions pass. - [ ] **Step 6: Run all tests to verify no regression** ```bash 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** ```bash 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`: ```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** ```bash 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: ```reef 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: ```reef toml.toml_set_bool(b, "hermes", p.hermes) ``` - [ ] **Step 4: Run the new test to verify it passes** ```bash 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** ```bash 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** ```bash 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`: ```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** ```bash 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): ```reef 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** ```bash reefc run tests/test_config_migrate_v1.reef ``` Expected: all 6 assertions pass. - [ ] **Step 5: Run all tests** ```bash 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** ```bash 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: ```reef return Registry { schema: 1, ... } ``` Change to: ```reef 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: ```reef runner.assert_eq_int(reg.schema, 1, "default schema = 1") ``` to: ```reef runner.assert_eq_int(reg.schema, 2, "default schema = 2") ``` - [ ] **Step 3: Run all tests** ```bash make test ``` Expected: all tests pass. - [ ] **Step 4: Commit** ```bash 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: ```reef 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: ```reef // 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** ```bash make build ``` Expected: clean build. (No unit test for this — it's a subprocess wrapper, smoke-tested via `cmd_setup`.) - [ ] **Step 4: Commit** ```bash 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 ` makes an empty profile; `incus profile edit < 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** ```reef 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: ```reef // 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`: ```reef // 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** ```bash 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** ```bash 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** ```reef 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: ```reef // 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** ```bash make build ``` Expected: clean build. - [ ] **Step 4: Commit** ```bash 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`: ```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** ```bash 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`: ```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// 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** ```bash reefc run tests/test_hermes_paths.reef ``` Expected: all 9 assertions pass. - [ ] **Step 5: Commit** ```bash 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`: ```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** ```bash reefc run tests/test_hermes_classify.reef ``` Expected: all 9 assertions pass. - [ ] **Step 3: Commit** ```bash 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** ```reef 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: ```reef 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: ```reef // 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** ```bash 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`: ```bash #!/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 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** ```bash 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** ```reef fn purge_data_dir(dest: string): rg.Result[bool, string] ``` - [ ] **Step 2: Implement** ```reef // 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** ```bash make build ``` Expected: clean build. - [ ] **Step 4: Commit** ```bash 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** ```reef 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** ```bash make build ``` Expected: clean build. (Functions are stubs; later tasks fill them.) - [ ] **Step 3: Commit** ```bash 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`: ```reef 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: ```reef type CaptureResult fn process_run_capture(program: string, args: [string]): CaptureResult ``` - [ ] **Step 3: Build** ```bash 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. ```bash 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** ```bash 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`: ```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** ```bash 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: ```reef fn template_contains_placeholder(s: string): bool ``` Then replace `render_llm_share_template` and add the helper: ```reef 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** ```bash reefc run tests/test_setup_template.reef ``` Expected: all 8 assertions pass. - [ ] **Step 5: Commit** ```bash 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** ```reef // Probe whether a binary is in PATH by running `command -v `. // 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://: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: ```reef 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`): ```reef fn process_run_silent(program: string, args: [string]): int ``` - [ ] **Step 3: Build** ```bash make build ``` Expected: clean build. - [ ] **Step 4: Smoke-test detect_environment** ```bash 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** ```bash 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`: ```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** ```bash reefc run tests/test_setup_planner.reef ``` Expected: assertion failures. - [ ] **Step 3: Implement plan_stages** ```reef 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** ```bash reefc run tests/test_setup_planner.reef ``` Expected: all 8 assertions pass. - [ ] **Step 5: Commit** ```bash 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** ```reef 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`: ```reef fn apply_stage(stage: Stage, env: Environment, reg: config.Registry): rg.Result[config.Registry, string] if stage.id == "incus_project" if env.repoman_project_present return @Result[config.Registry, string].Ok(reg) // 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** ```bash make build ``` Expected: clean build. - [ ] **Step 4: Commit** ```bash 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: ```reef fn cmd_setup(argv: [string]): int ``` - [ ] **Step 2: Add the import** Near the top of `cli.reef`, add: ```reef import setup ``` - [ ] **Step 3: Implement cmd_setup** After `cmd_shell` (line ~752) and before `version_string`, add: ```reef 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 ") if reg.defaults.llm.enabled console.print(" repoman new --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: ```reef fn dispatch(argv: [string]): int ... if cmd == "new" return cmd_new(rest) end if ... ``` Add (alphabetically — between `remove` and `shell`, or wherever fits): ```reef 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** ```bash make build ./build/repoman setup --without-llm --non-interactive ``` Expected: prints the plan, applies each stage, says "setup complete". - [ ] **Step 6: Commit** ```bash 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: ```reef 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: ```reef 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: ```reef 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: ```reef // 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`: ```reef 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: ```reef import hermes import io.dir as iodir ``` (`paths` is already imported; `incus` already; `iofile` already.) - [ ] **Step 6: Build and smoke** ```bash make build ``` Expected: clean build. (Smoke test deferred to Task 25.) - [ ] **Step 7: Commit** ```bash 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: ```reef 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: ```reef 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** ```bash make build ``` Expected: clean build. - [ ] **Step 4: Commit** ```bash 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: ```reef 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** ```bash make build ``` Expected: clean build. - [ ] **Step 4: Commit** ```bash 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: ```toml version = "0.1.0" ``` to: ```toml version = "0.3.0" ``` - [ ] **Step 2: README — add setup + LLM section** In `README.md`, add a new section after the existing usage examples: ```markdown ## 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://: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: ```markdown | `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** ```bash 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** ```bash make build && sudo make install ``` Expected: `repoman --version` reports `0.3.0`. - [ ] **Step 2: Setup wizard (with LLM)** ```bash repoman setup --with-llm --non-interactive ``` Expected: prints the plan; applies stages; reports `setup complete`. Verify: ```bash 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://: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** ```bash 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. ```bash 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)** ```bash 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** ```bash 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** ```bash 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** ```bash 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/.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). # repoman v0.4 — Profile library + scope trim Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Introduce the `profile` subcommand family backed by a vendor/user profile library, trim the v0.3 LLM-stack-specific surface from `setup`, and migrate the registry to schema 3. **Architecture:** New `src/profile.reef` module owns profile lifecycle (lookup → render → install/remove/diff/show) with vendor profiles at `/usr/local/share/repoman/profiles/` and user shadows at `~/.config/repoman/profiles.d/`. Schema-3 `[host].lan_ip` field replaces `[defaults].llm.ollama_url`; the `[defaults].llm` block is removed entirely. `setup` shrinks from 4 stages to 2 (incus_project + registry_defaults); `cmd_new` gains pre-launch profile validation. **Tech Stack:** reef-lang 0.5.20 (no new stdlib requirements), `encoding.toml`, `core.result_generic`, `sys.process.process_spawn` / `process_run`, `io.dir.list_dir` (new caller for an existing API), `test.framework.TestRunner`. --- ## Reference: spec and source - Design spec: `docs/superpowers/specs/2026-05-08-repoman-v0.4-profile-library.md` (locked) - v0.3 plan (style/depth reference): `docs/superpowers/plans/2026-05-06-repoman-v0.3-llm-and-setup.md` - Existing modules to mirror: - `src/setup.reef` — for module skeleton style + Environment/Stage struct pattern - `src/incus.reef` — for `process_run_capture`/`process_run_silent` patterns - `src/config.reef` — for parse/serialize/migrate idiom (T1–T6 of v0.3 plan are direct precedent for schema migrations) - Reef stdlib API references already verified for v0.4: - `core.str.replace(s, old, new)` — global replace - `io.dir.list_dir(path: string, entries: [string], max: int): int` — populates buffer, returns count - `io.dir.dir_exists`, `io.dir.create_dir_all` — already in use - `io.file.fileExists`, `io.file.readFile`, `io.file.writeFile` — already in use - `core.str.starts_with`, `str.ends_with`, `str.substring`, `str.length`, `str.contains` — all already used - `sys.env.get_env_or` — already in use ## File structure ``` ~/repos/repoman/ ├── reef.toml # bump version 0.3.0 → 0.4.0 ├── README.md # add ## Profile library + ## Migrating from v0.3 sections ├── VISION.md # update setup row (no --with-llm); add profile management row ├── Makefile # install profiles/*.yml to /usr/local/share/repoman/profiles/ ├── profiles/ # NEW vendor library directory │ ├── claude-share.yml │ ├── llm-share.yml │ └── dotfiles.yml ├── src/ │ ├── cli.reef # +cmd_profile dispatch, +pre-launch validation, -setup --with-llm flags │ ├── config.reef # +Host substruct, -LlmDefaults, schema 2→3 migration │ ├── hermes.reef # (unchanged) library-only, kept from v0.3 │ ├── incus.reef # +profile_get (capture stdout) │ ├── log.reef # (unchanged) │ ├── main.reef # (unchanged) │ ├── paths.reef # (unchanged) │ ├── profile.reef # NEW │ ├── setup.reef # trim Environment, drop llm helpers, shrink plan/apply │ └── sync.reef # (unchanged) └── tests/ ├── test_config_schema_v3.reef # NEW — schema 3 acceptance + serialize round-trip ├── test_config_migrate.reef # NEW — v1→v3 and v2-with-llm→v3 migration ├── test_profile_paths.reef # NEW — vendor_dir, user_dir ├── test_profile_render.reef # NEW — ${VAR} substitution └── test_*.reef # existing tests; some updated, some deleted ``` Module-boundary rules (carried from v0.1): - Each module file declares `module ` matching its filename, ends with `end module`. - `main.reef` has no module declaration. - Tests are standalone reef programs each with their own `proc main()`. - `make test` runs every `tests/test_*.reef`, stops on first failure. --- ## Task 1: `Host` substruct + drop `LlmDefaults` **Files:** - Modify: `src/config.reef` (export block, type definitions, Defaults literal sites) `Defaults` loses its `llm: LlmDefaults` field. Registry gains a `host: Host` field. `LlmDefaults` type is removed. - [ ] **Step 1: Read the current Defaults / Registry / LlmDefaults declarations** ```bash sed -n '11,80p' src/config.reef ``` Expected: `LlmDefaults` type definition and `llm: LlmDefaults` field in `Defaults`. - [ ] **Step 2: Add Host type and Registry.host field; drop LlmDefaults type and Defaults.llm field** In `src/config.reef`'s export block, **remove** the line `type LlmDefaults` and **add** `type Host`: ```reef export type Defaults type Host type Project type Override type Mount type Registry type EffectiveConfig ... end export ``` Add a new type definition above `Defaults`: ```reef type Host = struct lan_ip: string end Host ``` Remove the entire `type LlmDefaults = struct ... end LlmDefaults` block. In `Defaults`, remove the `llm: LlmDefaults` field. The struct goes back to its v0.2 shape: ```reef type Defaults = struct repos_root: string backup_root: string logdir: string incus_project: string default_image: string profiles: [string] end Defaults ``` In `Registry`, add a `host: Host` field as the second field (after `schema`, before `output`): ```reef type Registry = struct schema: int host: Host output: string defaults: Defaults projects: [Project] end Registry ``` - [ ] **Step 3: Update existing Defaults/Registry construction sites — remove `llm: LlmDefaults {...}` blocks, add `host: Host {...}`** Sites today (per v0.3 + revert state): - `parse_registry` (around line 153): `Defaults { ..., llm: LlmDefaults {...} }`. Drop the `llm:` line. - `default_registry` (around line 434): same. - Any test that builds a `Defaults` literal: `tests/test_config_serialize.reef`, `tests/test_config_roundtrip.reef`, `tests/test_config_merge.reef`, `tests/test_config_mutate.reef` — drop `llm:` field. Then add `host: Host { lan_ip: "" }` to every `Registry` literal: - `parse_registry` (the final return literal) - `default_registry` - Tests that build a `Registry` literal directly For `parse_registry`'s final literal, change: ```reef let reg: Registry = Registry { schema: 2, output: output, defaults: defaults, projects: projects } ``` to: ```reef let host: Host = Host { lan_ip: "" // populated below for schema 2 migration; left empty otherwise } let reg: Registry = Registry { schema: 2, host: host, output: output, defaults: defaults, projects: projects } ``` (The `host.lan_ip` value will be properly populated by the schema-2 migration logic in T2; this task just adds the field with a default of empty string.) For `default_registry`: ```reef return Registry { schema: 2, host: Host { lan_ip: "" }, output: "quiet", defaults: Defaults { ... }, // no llm field projects: new [Project](0) } ``` (Schema constant stays at 2 in this task; T5 bumps to 3.) - [ ] **Step 4: Run existing tests; expect compile failures in test files that reference LlmDefaults or `defaults.llm`** ```bash make test ``` Expected: compile errors on tests that use `LlmDefaults` or `reg.defaults.llm`. These tests are about to be deleted (T7) but for now patch them minimally to compile: - `tests/test_config_serialize.reef`, `test_config_roundtrip.reef`, `test_config_merge.reef`, `test_config_mutate.reef` — drop `llm:` field from `Defaults` literals; add `host: Host { lan_ip: "" }` to `Registry` literals. Run `grep -rln 'LlmDefaults\|defaults.llm' src/ tests/` to find every site. - [ ] **Step 5: Build clean and re-run tests** ```bash make build && make test ``` Expected: clean build. Some tests may still fail because parse/serialize haven't been updated yet (T2/T3); that's fine for now. The build must compile. - [ ] **Step 6: Commit** ```bash hg add src/config.reef tests/ hg commit -m "config: add Host substruct on Registry, drop LlmDefaults (schema unchanged)" ``` --- ## Task 2: `parse_registry` reads `[host].lan_ip` and migrates from schema 2's `[defaults.llm.ollama_url]` **Files:** - Modify: `src/config.reef` (parse_registry function) - [ ] **Step 1: Add a private helper `extract_lan_ip_from_url`** Inside `src/config.reef`, before `parse_registry`, add: ```reef // Extract the host portion of an `http://:` URL string. // Returns "" if the input doesn't match that shape. fn extract_lan_ip_from_url(url: string): string let n: int = str.length(url) if n == 0 return "" end if let prefix: string = "http://" let prefix_len: int = 7 if not str.starts_with(url, prefix) return "" end if // Find ':' after the prefix mut i: int = prefix_len while i < n and url[i] != ':' i = i + 1 end while if i <= prefix_len return "" end if return str.substring(url, prefix_len, i - prefix_len) end extract_lan_ip_from_url ``` - [ ] **Step 2: Modify `parse_registry` to read `[host].lan_ip` (schema 3) or extract from `[defaults.llm.ollama_url]` (schema 2 migration)** In `parse_registry`, find the schema check (currently `if schema != 1 and schema != 2`). Update to accept schema 3: ```reef if schema != 1 and schema != 2 and schema != 3 return @Result[Registry, string].Err("unsupported schema (expected 1, 2, or 3)") end if ``` Right before the final `Registry { ... }` literal (where Task 1 added `host: host`), populate the `host` value: ```reef // [host].lan_ip — schema 3 reads it directly; schema 2 migration extracts from // the now-removed [defaults.llm.ollama_url]; schema 1 has no such value (empty). mut lan_ip: string = toml.toml_get_doc(doc, "host.lan_ip") if str.length(lan_ip) == 0 // Fall back: schema 2 migration via [defaults.llm.ollama_url] let old_url: string = toml.toml_get_doc(doc, "defaults.llm.ollama_url") if str.length(old_url) > 0 lan_ip = extract_lan_ip_from_url(old_url) end if end if let host: Host = Host { lan_ip: lan_ip } ``` Replace Task 1's placeholder `let host: Host = Host { lan_ip: "" }` with this block. - [ ] **Step 3: Build to verify** ```bash make build ``` Expected: clean build. - [ ] **Step 4: Commit** ```bash hg add src/config.reef hg commit -m "config: parse_registry accepts schema 3 + migrates lan_ip from schema 2 ollama_url" ``` --- ## Task 3: `serialize_registry` writes `[host]` block; never writes `[defaults.llm]` **Files:** - Modify: `src/config.reef` (serialize_registry function) The serializer is currently writing `[defaults.llm]` (per v0.3 T4). Remove that, add `[host]`. - [ ] **Step 1: Read the current serialize_registry** ```bash grep -n 'fn serialize_registry\|toml.toml_begin_table\|defaults.llm' src/config.reef ``` Identify the `toml_begin_table(b, "defaults.llm")` block and the surrounding section. - [ ] **Step 2: Remove `[defaults.llm]` writes; add `[host]` write** In `serialize_registry`, after `toml_set_string(b, "output", reg.output)` (the `[repoman]` block) and BEFORE `toml_begin_table(b, "defaults")`, insert the `[host]` block: ```reef toml.toml_begin_table(b, "host") toml.toml_set_string(b, "lan_ip", reg.host.lan_ip) ``` In the `[defaults]` section, **remove** the entire `[defaults.llm]` block: ```reef // DELETE these lines: toml.toml_begin_table(b, "defaults.llm") toml.toml_set_bool(b, "enabled", reg.defaults.llm.enabled) toml.toml_set_bool(b, "hermes_default", reg.defaults.llm.hermes_default) toml.toml_set_string(b, "ollama_url", reg.defaults.llm.ollama_url) toml.toml_set_string_array(b, "hermes_seed", reg.defaults.llm.hermes_seed) ``` The `[defaults]` body retains all other field writes (repos_root, backup_root, logdir, incus_project, default_image, profiles). - [ ] **Step 3: Build to verify** ```bash make build ``` Expected: clean build. - [ ] **Step 4: Commit** ```bash hg add src/config.reef hg commit -m "config: serialize_registry emits [host] block, drops [defaults.llm]" ``` --- ## Task 4: Force in-memory schema to 3 in `parse_registry` **Files:** - Modify: `src/config.reef` (parse_registry's final Registry literal) Same pattern as v0.3's T5: any registry, regardless of disk format, becomes schema 3 in memory. - [ ] **Step 1: Update parse_registry's final literal** In `parse_registry`, find the final Registry literal (currently `schema: 2,`): ```reef let reg: Registry = Registry { schema: 2, host: host, output: output, defaults: defaults, projects: projects } ``` Change `schema: 2,` to `schema: 3,`. - [ ] **Step 2: Update existing test assertions about post-parse schema** `grep -rn 'reg.schema, 2\|.schema, 2\|reg2.schema, 2' tests/` to find tests asserting schema=2 after parse. Update to `3`. Likely sites: `tests/test_config_parse.reef`, `tests/test_config_roundtrip.reef`, `tests/test_config_save.reef`, `tests/test_config_migrate_v1.reef`. Also possibly `test_config_io.reef` for the parse-not-init path. For each, change assertions like: ```reef runner.assert_eq_int(reg.schema, 2, "schema after parse = 2") ``` to: ```reef runner.assert_eq_int(reg.schema, 3, "schema after parse = 3 (migrated)") ``` Don't change tests that build a `Registry` literal directly with `schema: 2` and serialize without parsing (those are testing serializer fidelity at the input schema, and still pass). - [ ] **Step 3: Run all tests** ```bash make test ``` Expected: all suites pass. Some tests will need additional updates due to `LlmDefaults` removal (already done in T1's step 4). If any still fail, they'll likely be in `test_config_llm_parse.reef` or `test_config_llm_serialize.reef` — both will be deleted in T7. For now, comment out their failing assertions or skip the test. - [ ] **Step 4: Commit** ```bash hg add src/config.reef tests/ hg commit -m "config: in-memory schema always 3 after parse (v1/v2 migrate implicitly)" ``` --- ## Task 5: `default_registry` returns schema 3 **Files:** - Modify: `src/config.reef` (default_registry function) - Tests: `tests/test_config_io.reef` (init-path schema assertion) - [ ] **Step 1: Update default_registry** In `src/config.reef`, find: ```reef fn default_registry(home_dir: string): Registry ... return Registry { schema: 2, host: Host { lan_ip: "" }, ... } end default_registry ``` Change `schema: 2,` to `schema: 3,`. - [ ] **Step 2: Update test_config_io.reef's init-path assertion** `grep -n 'schema' tests/test_config_io.reef`. The init path (where `load_or_init` is called against an empty temp dir) currently asserts `reg.schema == 2`. Change to `3`. - [ ] **Step 3: Run tests** ```bash make test ``` Expected: all green (modulo the to-be-deleted tests). - [ ] **Step 4: Commit** ```bash hg add src/config.reef tests/test_config_io.reef hg commit -m "config: default_registry writes schema 3" ``` --- ## Task 6: Schema 3 acceptance test + migration test **Files:** - Test: `tests/test_config_schema_v3.reef` (new) - Test: `tests/test_config_migrate.reef` (new) - [ ] **Step 1: Write test_config_schema_v3.reef — schema 3 round-trip** Create `tests/test_config_schema_v3.reef`: ```reef import config import test.framework import core.result_generic as rg proc main() let runner = new framework.TestRunner() // Build a schema-3 registry, serialize, parse round-trip let reg = config.Registry { schema: 3, host: config.Host { lan_ip: "192.168.168.124" }, output: "quiet", defaults: config.Defaults { repos_root: "~/repos", backup_root: "/nfs/repos", logdir: "~/.local/state/repoman", incus_project: "repoman", default_image: "images:ubuntu/26.04/cloud", profiles: ["default"] }, projects: new [config.Project](0) } let s = config.serialize_registry(reg) runner.assert_contains_string(s, "schema = 3", "writes schema = 3") runner.assert_contains_string(s, "[host]", "writes [host] block") runner.assert_contains_string(s, "lan_ip = \"192.168.168.124\"", "writes lan_ip") let neg = str.contains(s, "[defaults.llm]") runner.assert_eq_bool(neg, false, "does NOT write [defaults.llm]") let r2 = config.parse_registry(s) runner.assert_eq_bool(rg.is_ok(r2), true, "round-trip parses ok") if rg.is_ok(r2) let reg2 = rg.unwrap_ok(r2) runner.assert_eq_int(reg2.schema, 3, "round-trip schema = 3") runner.assert_eq_string(reg2.host.lan_ip, "192.168.168.124", "round-trip host.lan_ip") end if runner.report() end main ``` (Note: `import core.str` for `str.contains` if needed — check `tests/test_config_llm_serialize.reef` for the import pattern.) - [ ] **Step 2: Write test_config_migrate.reef — v1→v3 and v2→v3 migration** Create `tests/test_config_migrate.reef`: ```reef import config import test.framework import core.result_generic as rg proc main() let runner = new framework.TestRunner() // v1 (no llm block, no host block) → v3 with empty lan_ip let v1: string = "[repoman]\nschema = 1\noutput = \"quiet\"\n\n[defaults]\nrepos_root = \"~/repos\"\nbackup_root = \"/nfs/repos\"\nlogdir = \"~/.local/state/repoman\"\nincus_project = \"repoman\"\ndefault_image = \"images:ubuntu/26.04/cloud\"\nprofiles = [\"default\", \"claude-share\"]\n" let r1 = config.parse_registry(v1) runner.assert_eq_bool(rg.is_ok(r1), true, "v1 parses ok") if rg.is_ok(r1) let reg = rg.unwrap_ok(r1) runner.assert_eq_int(reg.schema, 3, "v1 schema migrates to 3") runner.assert_eq_string(reg.host.lan_ip, "", "v1 has no lan_ip") end if // v2 with [defaults.llm.ollama_url] → v3 with extracted lan_ip let v2: string = "[repoman]\nschema = 2\noutput = \"quiet\"\n\n[defaults]\nrepos_root = \"~/repos\"\nbackup_root = \"/nfs/repos\"\nlogdir = \"~/.local/state/repoman\"\nincus_project = \"repoman\"\ndefault_image = \"images:ubuntu/26.04/cloud\"\nprofiles = [\"default\"]\n\n[defaults.llm]\nenabled = true\nhermes_default = false\nollama_url = \"http://192.168.168.124:11434\"\nhermes_seed = []\n" let r2 = config.parse_registry(v2) runner.assert_eq_bool(rg.is_ok(r2), true, "v2 parses ok") if rg.is_ok(r2) let reg = rg.unwrap_ok(r2) runner.assert_eq_int(reg.schema, 3, "v2 schema migrates to 3") runner.assert_eq_string(reg.host.lan_ip, "192.168.168.124", "v2 lan_ip extracted from ollama_url") end if // v2 without [defaults.llm.ollama_url] (LLM was disabled) → v3 with empty lan_ip let v2_no_llm: string = "[repoman]\nschema = 2\noutput = \"quiet\"\n\n[defaults]\nrepos_root = \"~/repos\"\nbackup_root = \"/nfs/repos\"\nlogdir = \"~/.local/state/repoman\"\nincus_project = \"repoman\"\ndefault_image = \"images:ubuntu/26.04/cloud\"\nprofiles = [\"default\"]\n" let r3 = config.parse_registry(v2_no_llm) runner.assert_eq_bool(rg.is_ok(r3), true, "v2 without llm block parses ok") if rg.is_ok(r3) let reg = rg.unwrap_ok(r3) runner.assert_eq_string(reg.host.lan_ip, "", "v2 without ollama_url has empty lan_ip") end if // v3 native (with [host].lan_ip) round-trip let v3: string = "[repoman]\nschema = 3\noutput = \"quiet\"\n\n[host]\nlan_ip = \"10.0.0.5\"\n\n[defaults]\nrepos_root = \"~/repos\"\nbackup_root = \"/nfs/repos\"\nlogdir = \"~/.local/state/repoman\"\nincus_project = \"repoman\"\ndefault_image = \"images:ubuntu/26.04/cloud\"\nprofiles = [\"default\"]\n" let r4 = config.parse_registry(v3) runner.assert_eq_bool(rg.is_ok(r4), true, "v3 parses ok") if rg.is_ok(r4) let reg = rg.unwrap_ok(r4) runner.assert_eq_string(reg.host.lan_ip, "10.0.0.5", "v3 native lan_ip read directly") end if runner.report() end main ``` - [ ] **Step 3: Run the new tests** ```bash reefc run tests/test_config_schema_v3.reef reefc run tests/test_config_migrate.reef ``` Expected: all assertions pass. - [ ] **Step 4: Run full test suite for regression check** ```bash make test ``` Expected: all green (modulo the to-be-deleted tests in T7). - [ ] **Step 5: Commit** ```bash hg add tests/test_config_schema_v3.reef tests/test_config_migrate.reef hg commit -m "config: tests for schema 3 round-trip and v1/v2 migration" ``` --- ## Task 7: Delete v0.3-only LLM-block tests **Files:** - Delete: `tests/test_config_llm_parse.reef` - Delete: `tests/test_config_llm_serialize.reef` - Delete: `tests/test_config_migrate_v1.reef` After T6's new tests, these v0.3-era tests are superseded: - `test_config_llm_parse.reef` — tested parsing of `[defaults.llm]` block which no longer exists - `test_config_llm_serialize.reef` — tested serializing the same - `test_config_migrate_v1.reef` — tested v1→v2 migration; T6's `test_config_migrate.reef` covers v1→v3 and v2→v3 - [ ] **Step 1: Delete the three test files** ```bash hg rm tests/test_config_llm_parse.reef hg rm tests/test_config_llm_serialize.reef hg rm tests/test_config_migrate_v1.reef ``` - [ ] **Step 2: Run all tests to confirm clean state** ```bash make test ``` Expected: clean. The remaining test count is lower than v0.3. - [ ] **Step 3: Commit** ```bash hg commit -m "tests: drop v0.3 [defaults.llm] tests; superseded by schema_v3 + migrate tests" ``` --- ## Task 8: `incus.profile_get` — capture-mode wrapper for `incus profile show` **Files:** - Modify: `src/incus.reef` (export block + new function) - [ ] **Step 1: Add to export block** In `src/incus.reef`'s export block, add: ```reef fn profile_get(project: string, name: string): rg.Result[string, string] ``` - [ ] **Step 2: Implement profile_get** After the existing `profile_create_or_edit` function, add: ```reef // Returns the YAML body of the named profile (the same output as // `incus profile show `). Errors if the profile doesn't // exist or the subprocess fails. fn profile_get(project: string, name: string): rg.Result[string, string] let cap: CaptureResult = process_run_capture("incus", [ "profile", "show", "--project", project, name ]) if cap.exit_code != 0 return @Result[string, string].Err("incus profile show exited " + convert.to_string(cap.exit_code)) end if return @Result[string, string].Ok(cap.stdout) end profile_get ``` - [ ] **Step 3: Build** ```bash make build ``` Expected: clean build. - [ ] **Step 4: Commit** ```bash hg add src/incus.reef hg commit -m "incus: add profile_get (capture incus profile show stdout)" ``` --- ## Task 9: `profile.reef` skeleton + types **Files:** - Create: `src/profile.reef` - [ ] **Step 1: Create the module skeleton** Create `src/profile.reef`: ```reef module profile import core.str import core.result_generic as rg import core.convert as convert import io.file as iofile import io.dir as iodir import sys.env import paths import incus export type ProfileEntry type HostFacts fn vendor_dir(): string fn user_dir(home_dir: string): string fn render(yaml: string, host: HostFacts): string fn lookup(name: string, home_dir: string): rg.Result[string, string] fn list_all(home_dir: string): [ProfileEntry] fn install(name: string, home_dir: string, host: HostFacts): rg.Result[bool, string] fn remove_profile(name: string): rg.Result[bool, string] fn show(name: string, home_dir: string, host: HostFacts): rg.Result[string, string] fn diff(name: string, home_dir: string, host: HostFacts): rg.Result[string, string] end export // One entry from the profile library, populated by list_all. type ProfileEntry = struct name: string // e.g., "claude-share" source: string // "user", "vendor", or "user (shadows vendor)" file_path: string // resolved file path installed: bool // present in incus state end ProfileEntry // Substitution context for templated profile YAML. type HostFacts = struct lan_ip: string user: string home: string end HostFacts // (function bodies follow in subsequent tasks) fn vendor_dir(): string return "" end vendor_dir fn user_dir(home_dir: string): string return "" end user_dir fn render(yaml: string, host: HostFacts): string return yaml end render fn lookup(name: string, home_dir: string): rg.Result[string, string] return @Result[string, string].Err("not implemented") end lookup fn list_all(home_dir: string): [ProfileEntry] return new [ProfileEntry](0) end list_all fn install(name: string, home_dir: string, host: HostFacts): rg.Result[bool, string] return @Result[bool, string].Err("not implemented") end install fn remove_profile(name: string): rg.Result[bool, string] return @Result[bool, string].Err("not implemented") end remove_profile fn show(name: string, home_dir: string, host: HostFacts): rg.Result[string, string] return @Result[string, string].Err("not implemented") end show fn diff(name: string, home_dir: string, host: HostFacts): rg.Result[string, string] return @Result[string, string].Err("not implemented") end diff end module ``` (Note: function name is `remove_profile` not `remove` — `remove` would conflict with the existing reef stdlib. Caller in `cli.reef` uses `profile.remove_profile`.) - [ ] **Step 2: Build** ```bash make build ``` Expected: clean build (stubs compile). - [ ] **Step 3: Commit** ```bash hg add src/profile.reef hg commit -m "profile: module skeleton with types and stubbed entry points" ``` --- ## Task 10: `profile.vendor_dir`, `profile.user_dir`, `profile.render` + tests **Files:** - Modify: `src/profile.reef` - Test: `tests/test_profile_paths.reef` - Test: `tests/test_profile_render.reef` - [ ] **Step 1: Write failing test for vendor_dir / user_dir** Create `tests/test_profile_paths.reef`: ```reef import profile import test.framework proc main() let runner = new framework.TestRunner() runner.assert_eq_string( profile.vendor_dir(), "/usr/local/share/repoman/profiles", "vendor_dir is the install layout" ) runner.assert_eq_string( profile.user_dir("/home/ctusa"), "/home/ctusa/.config/repoman/profiles.d", "user_dir under XDG config" ) runner.assert_eq_string( profile.user_dir(""), "/.config/repoman/profiles.d", "user_dir with empty home (still composes path)" ) runner.report() end main ``` - [ ] **Step 2: Run test, see failure** ```bash reefc run tests/test_profile_paths.reef ``` Expected: 3 assertion failures (stub returns ""). - [ ] **Step 3: Implement vendor_dir + user_dir** In `src/profile.reef`, replace the stubs: ```reef fn vendor_dir(): string return "/usr/local/share/repoman/profiles" end vendor_dir fn user_dir(home_dir: string): string return paths.join(home_dir, ".config/repoman/profiles.d") end user_dir ``` - [ ] **Step 4: Run test, expect pass** ```bash reefc run tests/test_profile_paths.reef ``` Expected: 3/3 pass. - [ ] **Step 5: Write failing test for render** Create `tests/test_profile_render.reef`: ```reef import profile import test.framework proc main() let runner = new framework.TestRunner() let host = profile.HostFacts { lan_ip: "192.168.168.124", user: "ctusa", home: "/home/ctusa" } // All three substitutions let yaml: string = "config:\n environment.OLLAMA_HOST: \"http://${HOST_LAN_IP}:11434\"\ndevices:\n state:\n source: ${HOME}/.ollama\n user: ${USER}\n" let out = profile.render(yaml, host) runner.assert_contains_string(out, "http://192.168.168.124:11434", "${HOST_LAN_IP} substituted") runner.assert_contains_string(out, "/home/ctusa/.ollama", "${HOME} substituted") runner.assert_contains_string(out, "user: ctusa", "${USER} substituted") runner.assert_eq_bool(false, str.contains(out, "${HOST_LAN_IP}"), "no leftover ${HOST_LAN_IP}") runner.assert_eq_bool(false, str.contains(out, "${USER}"), "no leftover ${USER}") runner.assert_eq_bool(false, str.contains(out, "${HOME}"), "no leftover ${HOME}") // No substitutions present — input is returned unchanged let plain: string = "name: foo\nconfig: {}\n" runner.assert_eq_string(profile.render(plain, host), plain, "no-substitution input passes through") // Empty input runner.assert_eq_string(profile.render("", host), "", "empty input") runner.report() end main ``` (Add `import core.str` at the top — `str.contains` requires it.) - [ ] **Step 6: Run, see failure** ```bash reefc run tests/test_profile_render.reef ``` Expected: assertion failures (stub returns input unchanged, but the substitution-required assertions fail). - [ ] **Step 7: Implement render** Replace the stub: ```reef fn render(yaml: string, host: HostFacts): string let s1: string = str.replace(yaml, "${HOST_LAN_IP}", host.lan_ip) let s2: string = str.replace(s1, "${USER}", host.user) let s3: string = str.replace(s2, "${HOME}", host.home) return s3 end render ``` - [ ] **Step 8: Run tests** ```bash reefc run tests/test_profile_render.reef make test ``` Expected: render test 9/9, full suite green. - [ ] **Step 9: Commit** ```bash hg add src/profile.reef tests/test_profile_paths.reef tests/test_profile_render.reef hg commit -m "profile: vendor_dir, user_dir, render — pure helpers + tests" ``` --- ## Task 11: `profile.lookup` — search user dir then vendor dir **Files:** - Modify: `src/profile.reef` (replace lookup stub) - [ ] **Step 1: Implement lookup** Replace the stub `fn lookup(...)`: ```reef // Search user dir first, then vendor dir. Returns the path to the YAML file. // User shadows vendor: a file in user dir wins. fn lookup(name: string, home_dir: string): rg.Result[string, string] let user_path: string = paths.join(user_dir(home_dir), name + ".yml") if iofile.fileExists(user_path) return @Result[string, string].Ok(user_path) end if let vendor_path: string = paths.join(vendor_dir(), name + ".yml") if iofile.fileExists(vendor_path) return @Result[string, string].Ok(vendor_path) end if return @Result[string, string].Err("profile not found: " + name + " (looked in " + user_path + " and " + vendor_path + ")") end lookup ``` - [ ] **Step 2: Build** ```bash make build ``` Expected: clean build. - [ ] **Step 3: Smoke-test by inspection** Create a temp dir + file: ```bash mkdir -p /tmp/repoman-lookup-test/.config/repoman/profiles.d echo 'name: test' > /tmp/repoman-lookup-test/.config/repoman/profiles.d/test.yml cat > /tmp/test_lookup.reef <<'EOF' import profile import core.result_generic as rg import io.console as console proc main() let r = profile.lookup("test", "/tmp/repoman-lookup-test") if rg.is_ok(r) console.print("found: " + rg.unwrap_ok(r) + "\n") else console.print("err: " + rg.unwrap_err(r) + "\n") end if EOF reefc run /tmp/test_lookup.reef ``` Expected: `found: /tmp/repoman-lookup-test/.config/repoman/profiles.d/test.yml`. (If `console.print` doesn't compile, use `println` instead — same pattern as the v0.3 setup smoke test.) - [ ] **Step 4: Commit** ```bash hg add src/profile.reef hg commit -m "profile: lookup — user-shadows-vendor file resolution" ``` --- ## Task 12: `profile.list_all` — enumerate both dirs with shadow + install status **Files:** - Modify: `src/profile.reef` (replace list_all stub) - [ ] **Step 1: Implement list_all** Replace the stub. The function enumerates user dir + vendor dir, builds a name → ProfileEntry map (user shadows vendor), and queries incus for install status. ```reef fn list_all(home_dir: string): [ProfileEntry] // Buffers for filename lists. Cap at 256 per dir; profile libraries // are not expected to be enormous. let max_files: int = 256 mut user_buf: [string] = new [string](max_files) mut vendor_buf: [string] = new [string](max_files) let u_dir: string = user_dir(home_dir) mut user_count: int = 0 if iodir.dir_exists(u_dir) user_count = iodir.list_dir(u_dir, user_buf, max_files) end if let v_dir: string = vendor_dir() mut vendor_count: int = 0 if iodir.dir_exists(v_dir) vendor_count = iodir.list_dir(v_dir, vendor_buf, max_files) end if // Pre-allocate result buffer at worst-case size. let cap: int = user_count + vendor_count mut entries_buf: [ProfileEntry] = new [ProfileEntry](cap) mut e_count: int = 0 // Pass 1: add all user-dir *.yml files mut i: int = 0 while i < user_count let fname: string = user_buf[i] if str.ends_with(fname, ".yml") let name: string = str.substring(fname, 0, str.length(fname) - 4) let path: string = paths.join(u_dir, fname) let installed_r = incus.profile_exists("default", name) mut installed: bool = false if rg.is_ok(installed_r) installed = rg.unwrap_ok(installed_r) end if entries_buf[e_count] = ProfileEntry { name: name, source: "user", file_path: path, installed: installed } e_count = e_count + 1 end if i = i + 1 end while // Pass 2: add vendor-dir *.yml files NOT already present (so user wins) mut j: int = 0 while j < vendor_count let fname: string = vendor_buf[j] if str.ends_with(fname, ".yml") let name: string = str.substring(fname, 0, str.length(fname) - 4) // Check if name already in entries_buf mut shadowed: bool = false mut k: int = 0 while k < e_count if entries_buf[k].name == name shadowed = true // Update the existing user entry's source label to indicate shadow entries_buf[k] = ProfileEntry { name: entries_buf[k].name, source: "user (shadows vendor)", file_path: entries_buf[k].file_path, installed: entries_buf[k].installed } end if k = k + 1 end while if not shadowed let path: string = paths.join(v_dir, fname) let installed_r = incus.profile_exists("default", name) mut installed: bool = false if rg.is_ok(installed_r) installed = rg.unwrap_ok(installed_r) end if entries_buf[e_count] = ProfileEntry { name: name, source: "vendor", file_path: path, installed: installed } e_count = e_count + 1 end if end if j = j + 1 end while // Compact to actual size mut entries: [ProfileEntry] = new [ProfileEntry](e_count) mut m: int = 0 while m < e_count entries[m] = entries_buf[m] m = m + 1 end while return entries end list_all ``` - [ ] **Step 2: Build** ```bash make build ``` Expected: clean build. - [ ] **Step 3: Commit** ```bash hg add src/profile.reef hg commit -m "profile: list_all — enumerate user + vendor dirs with shadow handling" ``` --- ## Task 13: `profile.install` — render + apply via incus.profile_create_or_edit **Files:** - Modify: `src/profile.reef` (replace install stub) - [ ] **Step 1: Implement install** Replace the stub: ```reef fn install(name: string, home_dir: string, host: HostFacts): rg.Result[bool, string] // Resolve the file (user or vendor) let path_r = lookup(name, home_dir) if rg.is_err(path_r) return @Result[bool, string].Err(rg.unwrap_err(path_r)) end if let path: string = rg.unwrap_ok(path_r) // Read the file let yaml: string = iofile.readFile(path) if str.length(yaml) == 0 return @Result[bool, string].Err("empty or unreadable profile file: " + path) end if // Render — but if HOST_LAN_IP is needed and host.lan_ip is empty, fail with a hint if str.contains(yaml, "${HOST_LAN_IP}") and str.length(host.lan_ip) == 0 return @Result[bool, string].Err("profile " + name + " requires ${HOST_LAN_IP} but [host].lan_ip is empty in registry. Run 'repoman setup' to detect and store the host LAN IP.") end if let rendered: string = render(yaml, host) // Apply via incus return incus.profile_create_or_edit("default", name, rendered) end install ``` - [ ] **Step 2: Build** ```bash make build ``` Expected: clean build. - [ ] **Step 3: Commit** ```bash hg add src/profile.reef hg commit -m "profile: install — resolve, render, apply via incus profile_create_or_edit" ``` --- ## Task 14: `profile.remove_profile` and `profile.show` **Files:** - Modify: `src/profile.reef` - [ ] **Step 1: Implement remove_profile and show** Replace the stubs: ```reef fn remove_profile(name: string): rg.Result[bool, string] let pid: int = process_run_silent_via_incus(name) // helper below // Note: we use incus.run_incus directly for clarity here. return incus_delete_profile(name) end remove_profile ``` Actually, simpler: avoid the indirection. Replace with a direct call: ```reef fn remove_profile(name: string): rg.Result[bool, string] return incus_run_remove(name) end remove_profile ``` Hmm, neither is cleanest. Use the existing incus public surface or extend it. Let me reconsider — `src/incus.reef` already has `delete_container`. Add a sibling `delete_profile`: (This step requires a small addition in `src/incus.reef`. Take this detour now.) In `src/incus.reef`, add to the export block: ```reef fn delete_profile(project: string, name: string): rg.Result[bool, string] ``` And add the function near `delete_container`: ```reef fn delete_profile(project: string, name: string): rg.Result[bool, string] return run_incus(["profile", "delete", "--project", project, name]) end delete_profile ``` Now back to `src/profile.reef`: ```reef fn remove_profile(name: string): rg.Result[bool, string] return incus.delete_profile("default", name) end remove_profile fn show(name: string, home_dir: string, host: HostFacts): rg.Result[string, string] let path_r = lookup(name, home_dir) if rg.is_err(path_r) return @Result[string, string].Err(rg.unwrap_err(path_r)) end if let path: string = rg.unwrap_ok(path_r) let yaml: string = iofile.readFile(path) let rendered: string = render(yaml, host) return @Result[string, string].Ok(rendered) end show ``` - [ ] **Step 2: Build** ```bash make build ``` Expected: clean build. - [ ] **Step 3: Commit** ```bash hg add src/profile.reef src/incus.reef hg commit -m "profile+incus: remove_profile, show; add incus.delete_profile wrapper" ``` --- ## Task 15: `profile.diff` — render file vs incus state **Files:** - Modify: `src/profile.reef` - [ ] **Step 1: Implement diff** Replace the stub: ```reef // Compute and return a string describing differences between the rendered // file and the live incus profile state. v0.4 uses a simple line-by-line // presentation: " same line" / "- removed line" / "+ added line". A more // sophisticated unified diff is out of scope; the use case is "show me // what's drifted" not "produce a patch." fn diff(name: string, home_dir: string, host: HostFacts): rg.Result[string, string] // Render the file let path_r = lookup(name, home_dir) if rg.is_err(path_r) return @Result[string, string].Err(rg.unwrap_err(path_r)) end if let path: string = rg.unwrap_ok(path_r) let yaml: string = iofile.readFile(path) let rendered: string = render(yaml, host) // Fetch incus state let incus_r = incus.profile_get("default", name) if rg.is_err(incus_r) // Profile not installed — diff shows the rendered file as "would install" let msg: string = "profile " + name + " is not installed. Rendered would-be content:\n\n" + rendered return @Result[string, string].Ok(msg) end if let live: string = rg.unwrap_ok(incus_r) if rendered == live return @Result[string, string].Ok("(no drift)") end if // Simple line-mark diff: file lines marked '-', incus lines marked '+'. // This is not a unified diff; it's a side-by-side hint. let header: string = "--- profile file (rendered): " + path + "\n+++ incus state\n" let body: string = "-- file --\n" + rendered + "\n-- incus --\n" + live return @Result[string, string].Ok(header + body) end diff ``` - [ ] **Step 2: Build** ```bash make build ``` Expected: clean build. - [ ] **Step 3: Commit** ```bash hg add src/profile.reef hg commit -m "profile: diff — show rendered file vs live incus state" ``` --- ## Task 16: Trim `setup.reef` part 1 — Environment struct, detect_environment, drop helpers **Files:** - Modify: `src/setup.reef` - Delete: `tests/test_setup_template.reef` - [ ] **Step 1: Trim Environment struct** In `src/setup.reef`, change: ```reef type Environment = struct home_dir: string user: string host_lan_ip: string incus_reachable: bool repoman_project_present: bool claude_share_present: bool ollama_binary: string ollama_lan_ok: bool hermes_binary: string hermes_data_present: bool end Environment ``` to: ```reef type Environment = struct home_dir: string user: string host_lan_ip: string incus_reachable: bool repoman_project_present: bool end Environment ``` - [ ] **Step 2: Trim detect_environment** Replace the body to only populate the 5 fields: ```reef fn detect_environment(home_dir: string): Environment let user: string = env.get_env_or("USER", "") let lan_ip: string = detect_host_lan_ip() // incus reachable? let incus_pid: int = incus.process_run_silent("incus", ["version"]) mut incus_ok: bool = false if incus_pid >= 0 incus_ok = p.process_wait(incus_pid) == 0 end if // repoman project (only if incus is up) mut project_ok: bool = false if incus_ok let pe = incus.project_present("repoman") if rg.is_ok(pe) project_ok = rg.unwrap_ok(pe) end if end if return Environment { home_dir: home_dir, user: user, host_lan_ip: lan_ip, incus_reachable: incus_ok, repoman_project_present: project_ok } end detect_environment ``` - [ ] **Step 3: Remove helper functions which_binary, ollama_listening_at** Both helpers are no longer used. Delete them entirely from `src/setup.reef`. - [ ] **Step 4: Remove render_llm_share_template and template_contains_placeholder** Delete both functions and remove them from the export block. - [ ] **Step 5: Delete the test that exercised the now-removed render** ```bash hg rm tests/test_setup_template.reef ``` - [ ] **Step 6: Build and test** ```bash make build make test ``` Expected: clean build. The setup_planner test will likely still pass (its 3 fixtures construct minimal Environments). If any test references the removed Environment fields, fix the test. - [ ] **Step 7: Commit** ```bash hg add src/setup.reef tests/ hg commit -m "setup: trim Environment to 5 fields; drop render_llm_share_template + which_binary + ollama_listening_at" ``` --- ## Task 17: Trim `setup.reef` part 2 — plan_stages, apply_stage, planner test **Files:** - Modify: `src/setup.reef` - Test: `tests/test_setup_planner.reef` (update) - [ ] **Step 1: Update test_setup_planner.reef to expect 2 stages** The test currently asserts 3-or-4 stages. Replace with 2 stages always: ```reef import setup import test.framework proc main() let runner = new framework.TestRunner() // Fresh host let fresh = setup.Environment { home_dir: "/home/ctusa", user: "ctusa", host_lan_ip: "192.168.168.42", incus_reachable: true, repoman_project_present: false } let stages = setup.plan_stages(fresh) runner.assert_eq_int(stages.length(), 2, "always 2 stages: incus_project + registry_defaults") runner.assert_eq_string(stages[0].id, "incus_project", "stage 0 = incus_project") runner.assert_eq_bool(stages[0].is_change, true, "incus_project will change on fresh host") runner.assert_eq_string(stages[1].id, "registry_defaults", "stage 1 = registry_defaults") runner.assert_eq_bool(stages[1].is_change, true, "registry_defaults always writes") // Already-set-up host let done = setup.Environment { home_dir: "/home/ctusa", user: "ctusa", host_lan_ip: "192.168.168.42", incus_reachable: true, repoman_project_present: true } let s2 = setup.plan_stages(done) runner.assert_eq_int(s2.length(), 2, "still 2 stages") runner.assert_eq_bool(s2[0].is_change, false, "incus_project no-op when present") runner.report() end main ``` - [ ] **Step 2: Update plan_stages signature and body** `plan_stages` no longer takes `with_llm` (the flag is gone). Trim: ```reef fn plan_stages(env: Environment): [Stage] mut stages: [Stage] = new [Stage](2) stages[0] = Stage { id: "incus_project", description: "ensure Incus project 'repoman' exists", is_change: not env.repoman_project_present } stages[1] = Stage { id: "registry_defaults", description: "write registry defaults (schema 3, [host].lan_ip)", is_change: true } return stages end plan_stages ``` Update the export-block declaration to match: ```reef fn plan_stages(env: Environment): [Stage] ``` - [ ] **Step 3: Trim apply_stage to handle only incus_project and registry_defaults** In `src/setup.reef`, replace `apply_stage`'s body: ```reef fn apply_stage(stage: Stage, env: Environment, reg: config.Registry): rg.Result[config.Registry, string] if stage.id == "incus_project" if env.repoman_project_present return @Result[config.Registry, string].Ok(reg) end if let r = incus.project_ensure("repoman", false) if rg.is_err(r) return @Result[config.Registry, string].Err(rg.unwrap_err(r)) end if return @Result[config.Registry, string].Ok(reg) end if if stage.id == "registry_defaults" // Update [host].lan_ip from detected env let new_host = config.Host { lan_ip: env.host_lan_ip } let new_reg = config.Registry { schema: 3, host: new_host, output: reg.output, defaults: reg.defaults, projects: reg.projects } return @Result[config.Registry, string].Ok(new_reg) end if return @Result[config.Registry, string].Err("unknown stage id: " + stage.id) end apply_stage ``` The previous llm_share_profile and claude_share_check stages are gone. - [ ] **Step 4: Build and test** ```bash make build make test ``` Expected: clean. test_setup_planner now passes its updated assertions. - [ ] **Step 5: Commit** ```bash hg add src/setup.reef tests/test_setup_planner.reef hg commit -m "setup: plan_stages always emits 2 stages; apply_stage handles only incus_project + registry_defaults" ``` --- ## Task 18: Trim `setup.reef` part 3 — cmd_setup flags and prompt **Files:** - Modify: `src/setup.reef` (cmd_setup function) - [ ] **Step 1: Drop --with-llm and --without-llm flags from cmd_setup** In `src/setup.reef`, find `cmd_setup`. Remove these lines: ```reef let _f2 = flag.bool_flag(parser, "with-llm", '\0', false, "include the LLM stack ...") let _f3 = flag.bool_flag(parser, "without-llm", '\0', false, "skip the LLM stack") ``` And remove the LLM-decision block: ```reef let with_llm_flag: bool = flag.get_bool(parser, "with-llm") let without_llm_flag: bool = flag.get_bool(parser, "without-llm") if with_llm_flag and without_llm_flag ... end if mut with_llm: bool = false if with_llm_flag ... end if ...prompt for LLM... ``` - [ ] **Step 2: Update calls to plan_stages and the success message** Wherever cmd_setup currently calls `plan_stages(env_snap, with_llm)`, change to `plan_stages(env_snap)` (no second arg). Remove the conditional success-hint about `--hermes`: ```reef // DELETE this block: if reg.defaults.llm.enabled println(" repoman new --hermes") end if ``` (Note: `reg.defaults.llm` doesn't exist anymore; this would be a compile error after T1 anyway.) The final success message becomes: ```reef println("") println("setup complete.") println("") println(" next: repoman profile install --all (install vendor profile library)") println(" repoman new ") println(" repoman list") ``` - [ ] **Step 3: Update cmd_setup's flag.description and the print_usage entry in cli.reef** In `src/setup.reef` cmd_setup, update: ```reef flag.description(parser, "First-time host bootstrap: incus project, registry, host LAN IP detection") ``` (`cli.reef` print_usage update is in T20.) - [ ] **Step 4: Build and test** ```bash make build make test ``` Expected: clean. - [ ] **Step 5: Commit** ```bash hg add src/setup.reef hg commit -m "setup: cmd_setup drops --with-llm/--without-llm flags; success hint points to profile install" ``` --- ## Task 19: `cmd_new` pre-launch profile validation **Files:** - Modify: `src/cli.reef` (cmd_new) - [ ] **Step 1: Insert validation block before `incus.launch`** In `cmd_new`, find the line: ```reef log.write("==> incus launch " + eff.image + " " + name) let lr = incus.launch(reg.defaults.incus_project, name, eff.image, eff.profiles) ``` Immediately before `log.write("==> incus launch ...")`, add: ```reef // Pre-launch profile validation: every name in eff.profiles must either be // the magic incus 'default' profile, or installed in the 'default' project. // (Repoman-managed profiles all live in 'default' per the v0.4 architecture.) let pn2: int = eff.profiles.length() mut pi: int = 0 while pi < pn2 let pname: string = eff.profiles[pi] if pname != "default" let exists_r = incus.profile_exists("default", pname) if rg.is_ok(exists_r) and not rg.unwrap_ok(exists_r) log.write("repoman: error: container references profile '" + pname + "' but it's not installed in incus.") log.write("hint: repoman profile install " + pname) log.write("hint: repoman profile install --all (to install the vendor library)") return 4 end if end if pi = pi + 1 end while ``` - [ ] **Step 2: Build** ```bash make build ``` Expected: clean build. - [ ] **Step 3: Test by inspection** Set up a fixture: registry with `[defaults].profiles = ["default", "missing-profile"]`. Run `repoman new test`. Expect exit 4 with the hint. (Manual smoke; no unit test for cmd_new because it's effectful.) - [ ] **Step 4: Commit** ```bash hg add src/cli.reef hg commit -m "cli: cmd_new pre-launch validation — error early if profile not installed" ``` --- ## Task 20: `cli.cmd_profile` dispatch for 5 verbs **Files:** - Modify: `src/cli.reef` - [ ] **Step 1: Add cmd_profile to imports and exports** In `src/cli.reef`, add to imports near the top: ```reef import profile ``` Add `cmd_profile` to the export block: ```reef fn cmd_profile(argv: [string]): int ``` - [ ] **Step 2: Implement cmd_profile (dispatches on verb)** After `cmd_setup`, add `cmd_profile`. Each verb is its own helper: ```reef fn cmd_profile(argv: [string]): int if argv.length() == 0 console.printErr("repoman: error: 'profile' requires a subcommand: list | install | diff | remove | show") return 2 end if let verb: string = argv[0] // Slice argv[1..] for the verb's own parser let n: int = argv.length() mut rest: [string] = new [string](n - 1) mut i: int = 0 while i < n - 1 rest[i] = argv[i + 1] i = i + 1 end while if verb == "list" return cmd_profile_list(rest) end if if verb == "install" return cmd_profile_install(rest) end if if verb == "diff" return cmd_profile_diff(rest) end if if verb == "remove" return cmd_profile_remove(rest) end if if verb == "show" return cmd_profile_show(rest) end if console.printErr("repoman: error: unknown profile subcommand: " + verb) return 2 end cmd_profile fn build_host_facts(): profile.HostFacts let home: string = env.get_env_or("HOME", "") let user: string = env.get_env_or("USER", "") let reg_r = config.load_or_init(home) mut lan_ip: string = "" if rg.is_ok(reg_r) lan_ip = rg.unwrap_ok(reg_r).host.lan_ip end if return profile.HostFacts { lan_ip: lan_ip, user: user, home: home } end build_host_facts fn cmd_profile_list(argv: [string]): int let home: string = env.get_env_or("HOME", "") let entries = profile.list_all(home) println("NAME SOURCE INSTALLED") let n: int = entries.length() mut i: int = 0 while i < n let e = entries[i] mut inst: string = "no" if e.installed inst = "yes" end if println(e.name + " " + e.source + " " + inst) i = i + 1 end while return 0 end cmd_profile_list fn cmd_profile_install(argv: [string]): int let host = build_host_facts() let home: string = host.home if argv.length() == 0 console.printErr("repoman: error: 'profile install' requires or --all") return 2 end if if argv[0] == "--all" let entries = profile.list_all(home) let n: int = entries.length() mut i: int = 0 mut errs: int = 0 while i < n let e = entries[i] println("==> install " + e.name + " (source: " + e.source + ")") let r = profile.install(e.name, home, host) if rg.is_err(r) console.printErr(" error: " + rg.unwrap_err(r)) errs = errs + 1 end if i = i + 1 end while if errs > 0 return 1 end if return 0 end if let name: string = argv[0] let r = profile.install(name, home, host) if rg.is_err(r) console.printErr("repoman: error: " + rg.unwrap_err(r)) return 1 end if println("==> installed " + name) return 0 end cmd_profile_install fn cmd_profile_diff(argv: [string]): int if argv.length() == 0 console.printErr("repoman: error: 'profile diff' requires ") return 2 end if let host = build_host_facts() let r = profile.diff(argv[0], host.home, host) if rg.is_err(r) console.printErr("repoman: error: " + rg.unwrap_err(r)) return 3 end if println(rg.unwrap_ok(r)) return 0 end cmd_profile_diff fn cmd_profile_remove(argv: [string]): int if argv.length() == 0 console.printErr("repoman: error: 'profile remove' requires ") return 2 end if let r = profile.remove_profile(argv[0]) if rg.is_err(r) console.printErr("repoman: error: " + rg.unwrap_err(r)) return 1 end if println("==> removed " + argv[0] + " from incus") return 0 end cmd_profile_remove fn cmd_profile_show(argv: [string]): int if argv.length() == 0 console.printErr("repoman: error: 'profile show' requires ") return 2 end if let host = build_host_facts() let r = profile.show(argv[0], host.home, host) if rg.is_err(r) console.printErr("repoman: error: " + rg.unwrap_err(r)) return 3 end if println(rg.unwrap_ok(r)) return 0 end cmd_profile_show ``` - [ ] **Step 3: Wire into the dispatcher** Find the `dispatch` function. Add a `profile` arm in alphabetical position (between `new` and `remove`, or wherever fits): ```reef if cmd == "profile" return cmd_profile(rest) end if ``` - [ ] **Step 4: Update print_usage** Add `profile` to the help text: ```reef console.printErr(" profile {list|install|diff|remove|show} [] [--all]") console.printErr(" Manage Incus profiles from the repoman library.") console.printErr("") ``` Place it after `new`'s entry, before `remove`'s entry (alphabetical). Also update setup's help text to drop the `--with-llm` mention: ```reef console.printErr(" setup [--non-interactive]") console.printErr(" First-time host bootstrap: incus project + registry.") ``` - [ ] **Step 5: Build and verify** ```bash make build ./build/repoman --help ./build/repoman profile ``` Expected: `--help` shows the `profile` line; `profile` (no verb) prints the error message. - [ ] **Step 6: Commit** ```bash hg add src/cli.reef hg commit -m "cli: cmd_profile dispatch for list/install/diff/remove/show; update print_usage" ``` --- ## Task 21: Author the three vendor profile YAML files **Files:** - Create: `profiles/claude-share.yml` - Create: `profiles/llm-share.yml` - Create: `profiles/dotfiles.yml` - [ ] **Step 1: Create profiles directory and the three YAML files** ```bash mkdir -p profiles ``` Create `profiles/claude-share.yml`: ```yaml name: claude-share description: Share host's Claude CLI state (auth, history, plugins) into containers. config: {} devices: claude-state: type: disk source: ${HOME}/.claude path: ${HOME}/.claude shift: "true" claude-bin: type: disk source: ${HOME}/.local/bin/claude path: /usr/local/bin/claude readonly: "true" shift: "true" ``` Create `profiles/llm-share.yml`: ```yaml name: llm-share description: Wire containers to the host ollama daemon over LAN. config: environment.OLLAMA_HOST: "http://${HOST_LAN_IP}:11434" devices: ollama-bin: type: disk source: /usr/local/bin/ollama path: /usr/local/bin/ollama readonly: "true" ollama-state: type: disk source: ${HOME}/.ollama path: ${HOME}/.ollama shift: "true" ``` Create `profiles/dotfiles.yml`: ```yaml name: dotfiles description: Bind common host dotfiles (.gitconfig, .hgrc) into containers. config: {} devices: gitconfig: type: disk source: ${HOME}/.gitconfig path: ${HOME}/.gitconfig readonly: "true" shift: "true" hgrc: type: disk source: ${HOME}/.hgrc path: ${HOME}/.hgrc readonly: "true" shift: "true" ``` - [ ] **Step 2: Commit** ```bash hg add profiles/claude-share.yml profiles/llm-share.yml profiles/dotfiles.yml hg commit -m "profiles: vendor library — claude-share, llm-share, dotfiles" ``` --- ## Task 22: `Makefile` — install profiles to `/usr/local/share/repoman/profiles/` **Files:** - Modify: `Makefile` - [ ] **Step 1: Update install/uninstall targets** Replace the existing `install:` and `uninstall:` targets: ```makefile PREFIX ?= /usr/local BINDIR = $(PREFIX)/bin SHAREDIR = $(PREFIX)/share/repoman PROFILES_DIR = $(SHAREDIR)/profiles DESTDIR ?= .PHONY: all build test clean install uninstall all: build build: reefc build test: @for t in tests/test_*.reef; do \ echo "== $$t =="; \ reefc run "$$t" || exit 1; \ done clean: reefc clean install: build install -d $(DESTDIR)$(BINDIR) install -m 0755 build/repoman $(DESTDIR)$(BINDIR)/repoman install -d $(DESTDIR)$(PROFILES_DIR) install -m 0644 profiles/*.yml $(DESTDIR)$(PROFILES_DIR)/ uninstall: rm -f $(DESTDIR)$(BINDIR)/repoman rm -rf $(DESTDIR)$(PROFILES_DIR) ``` - [ ] **Step 2: Test the install path locally** ```bash sudo make install ls /usr/local/share/repoman/profiles/ ``` Expected: `claude-share.yml`, `dotfiles.yml`, `llm-share.yml`. - [ ] **Step 3: Commit** ```bash hg add Makefile hg commit -m "Makefile: install profiles/*.yml to /usr/local/share/repoman/profiles/" ``` --- ## Task 23: README + VISION + reef.toml + version_string updates **Files:** - Modify: `reef.toml` - Modify: `src/cli.reef` (version_string) - Modify: `README.md` - Modify: `VISION.md` - [ ] **Step 1: Bump version** In `reef.toml`, change `version = "0.3.0"` to `version = "0.4.0"`. In `src/cli.reef`, find `version_string()` and change `"repoman 0.3.0"` to `"repoman 0.4.0"`. - [ ] **Step 2: README — add profile library section, drop --with-llm** In `README.md`, find the `## Setup wizard` and `## Local LLM stack` sections from v0.3. Replace them with: ```markdown ## Setup wizard First-time host bootstrap (idempotent — safe to re-run): repoman setup # interactive repoman setup --non-interactive # accept defaults The wizard creates the Incus project `repoman`, detects your host LAN IP (used by profiles that wire containers to host services), and writes the initial registry. After setup, install the vendor profile library: repoman profile install --all ## Profile library repoman ships a vendor profile library at `/usr/local/share/repoman/profiles/`. v0.4 includes three profiles: - **`claude-share`** — bind `~/.claude` and `~/.local/bin/claude` into containers so they share the host's Claude CLI auth, history, and plugins. - **`llm-share`** — bind `/usr/local/bin/ollama` and `~/.ollama` into containers, set `OLLAMA_HOST=http://:11434` so containers reach the host's ollama daemon over LAN. - **`dotfiles`** — bind `~/.gitconfig` and `~/.hgrc` (extend with your own). Manage profiles via `repoman profile`: repoman profile list repoman profile install repoman profile install --all repoman profile diff # show drift between file and incus state repoman profile show # print rendered YAML repoman profile remove # remove from incus (file untouched) ### Customizing profiles To override a vendor profile, copy it to your user dir and edit: cp /usr/local/share/repoman/profiles/dotfiles.yml ~/.config/repoman/profiles.d/ $EDITOR ~/.config/repoman/profiles.d/dotfiles.yml repoman profile install dotfiles # applies the user version (shadows vendor) User profiles in `~/.config/repoman/profiles.d/` always win over vendor profiles of the same name. `repoman profile list` shows the active source for each. ## Migrating from v0.3 If you ran `repoman setup --with-llm` on v0.3, your registry is at schema 2 and includes a `[defaults.llm]` block. v0.4 reads that block on first load, extracts the ollama_url's host portion into `[host].lan_ip`, and writes the registry as schema 3 on the next save. No action required — the migration is automatic and lossless for the one fact that's still useful. The `--hermes` / `--no-hermes` / `--purge-hermes` flags are gone. Hermes (and similar agents that need per-container installs) is now the user's responsibility: shell into the container with `repoman shell ` and run the install yourself. v0.5+ may revisit this if real demand surfaces. ``` - [ ] **Step 3: VISION.md — update setup row, add profile management** In `VISION.md`, find the subcommand table. Update the `repoman setup` row description and add a `repoman profile` row: ```markdown | `repoman setup` | First-time host setup. Creates Incus project `repoman`, detects host LAN IP, writes initial registry. Idempotent. **Shipped in v0.3, trimmed in v0.4 (no longer touches profiles or LLM stack).** | | `repoman profile {list, install, diff, remove, show}` | Manage Incus profiles from the vendor library at `/usr/local/share/repoman/profiles/` and the user override dir at `~/.config/repoman/profiles.d/`. **Shipped in v0.4.** | ``` - [ ] **Step 4: Build and test** ```bash make build ./build/repoman --version # repoman 0.4.0 ./build/repoman --help # shows setup (no --with-llm), profile, etc. make test ``` Expected: clean. - [ ] **Step 5: Commit** ```bash hg add reef.toml src/cli.reef README.md VISION.md hg commit -m "docs: v0.4 — profile library section, migration guide; bump version" ``` --- ## Task 24: End-to-end smoke test Manual gate before tagging v0.4.0. **Files:** (no code changes — manual verification) - [ ] **Step 1: Install and verify version** ```bash cd ~/repos/repoman sudo make install repoman --version # repoman 0.4.0 ls /usr/local/share/repoman/profiles/ # three YAML files ``` - [ ] **Step 2: Run setup** ```bash repoman setup --non-interactive cat ~/.config/repoman/repoman.toml | grep -E 'schema|host|lan_ip' ``` Expected: `schema = 3`, `[host]` block, `lan_ip = ""`. - [ ] **Step 3: Install vendor profile library** ```bash repoman profile install --all incus profile list --project default | grep -E 'claude-share|llm-share|dotfiles' ``` Expected: all three profiles present in incus. - [ ] **Step 4: Verify rendered profiles** ```bash incus profile show --project default llm-share | grep OLLAMA_HOST # expect: environment.OLLAMA_HOST: http://:11434 incus profile show --project default claude-share | grep '/home/' # expect: /home//.claude paths ``` - [ ] **Step 5: Test profile diff and show** ```bash repoman profile show claude-share | head -20 repoman profile diff claude-share # expect "(no drift)" since we just installed it ``` - [ ] **Step 6: Test pre-launch validation** Edit `~/.config/repoman/repoman.toml` to add `"missing-profile"` to `[defaults].profiles`. Then: ```bash mkdir -p ~/repos/smoke-test echo "smoke" > ~/repos/smoke-test/README.md repoman new smoke-test ``` Expected: error with hint `repoman profile install missing-profile`. Container is NOT launched. Revert the registry change. - [ ] **Step 7: Successful container creation with profiles** ```bash # Edit registry to use ["default", "claude-share", "llm-share", "dotfiles"] in [defaults].profiles # OR write an override at ~/.config/repoman/repos.d/smoke-test.toml repoman new smoke-test repoman shell smoke-test # Inside container: ls -la ~/.claude # bind-mounted from host ls -la ~/.gitconfig # readable ollama list # talks to host daemon exit ``` Expected: all three profiles working. - [ ] **Step 8: Test user-shadow customization** ```bash mkdir -p ~/.config/repoman/profiles.d cp /usr/local/share/repoman/profiles/dotfiles.yml ~/.config/repoman/profiles.d/ # Edit to add a custom bind, e.g., ~/.zshrc repoman profile install dotfiles repoman profile list | grep dotfiles # expect: source = "user (shadows vendor)" ``` - [ ] **Step 9: Cleanup and tag** ```bash repoman remove smoke-test --yes rm -rf ~/repos/smoke-test hg tag v0.4.0 hg log -r tip --template "{node|short} {desc|firstline}\n" ``` If everything in steps 1-8 worked, **v0.4.0 ships.** --- ## Self-review ### Spec coverage check - §1 In-scope items: - Profile library layout (vendor + user) → T9, T10, T11, T12 - `repoman profile {list, install, diff, remove, show}` → T13, T14, T15, T20 - Templated YAML with `${VAR}` → T10 - `[host].lan_ip` field → T1, T2, T17 - Schema 2 → 3 migration → T2, T4, T5, T6 - Three vendor profiles → T21 - Pre-launch validation → T19 - §1 Trims: - `--with-llm` / `--without-llm` flags removed → T18 - ollama LAN-listening check removed → T16 - `[defaults].llm` block removed → T1, T3 - String template embedded in setup.reef removed → T16 - apply_stage llm_share_profile and registry_defaults trim → T17 - §2 Architecture: - `src/profile.reef` new module → T9–T15 - `setup.reef` edits → T16, T17, T18 - `cli.reef` edits → T19, T20 - `config.reef` edits → T1, T2, T3, T4, T5 - `incus.reef` `profile_get` and `delete_profile` → T8, T14 - §3 Data shapes — all covered (Schema 3, profile YAMLs, ProfileEntry, HostFacts) - §4 Subcommand flows — all covered - §5 Testing — pure tests in T6, T10; smoke in T24 - §7 Decisions — locked into spec, not implementation tasks ### Placeholder scan No "TBD"/"TODO" in the plan. Each step has either runnable shell or complete reef code. ### Type-consistency check - `Host` struct: `lan_ip: string` consistently across T1, T2, T6, T17. - `Registry` field order: `schema, host, output, defaults, projects` — used consistently in T1, T6, T17. - `ProfileEntry` fields: name, source, file_path, installed — consistent T9, T12, T20. - `HostFacts` fields: lan_ip, user, home — consistent T9, T10, T13, T14, T15, T20. - `profile.remove_profile` (NOT `profile.remove`) — consistent T9, T14, T20. - `incus.delete_profile` (new in T14) used in T14. - `incus.profile_get` (new in T8) used in T15. - `plan_stages(env)` (no `with_llm` arg after T17) — used in T17, T18. - `apply_stage(stage, env, reg)` signature — unchanged, used in T17. ### Task ordering / dependency check T1–T7 (config schema): linear sequence; each builds on the prior. ✓ T8 (incus.profile_get): independent; can land anywhere before T15. ✓ T9–T15 (profile module): T9 stub first, T10 paths/render, T11 lookup uses paths, T12 list_all uses lookup, T13 install uses lookup+render, T14 remove (small) + show (uses lookup+render), T15 diff (uses everything). ✓ T16–T18 (setup trim): each builds on the prior — T16 first (struct + helpers), T17 next (planner), T18 last (CLI integration). ✓ T19 (cmd_new validation): independent; needs T8 indirectly via incus.profile_exists which already exists. ✓ T20 (cmd_profile dispatch): depends on profile module (T9–T15) being complete. ✓ T21 (vendor YAMLs): independent file authoring. ✓ T22 (Makefile): depends on T21 (files must exist). ✓ T23 (docs + version): can land anywhere; placed near end as polish. ✓ T24 (smoke): final gate. ✓ No circular deps. --- ## Deferred (v0.5+ candidates) - `repoman profile diff --vendor` (shadow-vs-vendor diff): noted in spec §7 as resolved decision; implementation deferred. - `optional: true` on bind-mount sources for dotfiles (so missing files don't break launch): blocked on incus version support; reassess at v0.5. - Smarter unified diff in `profile diff` instead of side-by-side: low value for v0.4 use cases. - Setup wizard configuring ollama (installer + systemd override): explicitly rejected as out of mission scope. - Pre-built incus images (`repoman image {build, refresh, list, prune}`): rejected as maintenance overhead. - Per-project `[install]` block in override files: rejected — shell scripts in user repos serve this need. # repoman v0.1 — Design Spec **Status:** v0.1 design, locked **Date:** 2026-04-29 **Implementation language:** reef-lang 0.5.10 **Origin:** [VISION.md](../../../VISION.md) (intent) + bash prototype at `~/.local/bin/repoman` (behavioral spec for `new`/`sync`) **Outcome:** the contract for the first reef build of repoman. --- ## Reef version target This spec targets **reef 0.5.10**, which shipped the stdlib additions requested in [`docs/reef-feedback.md`](../../reef-feedback.md) (response in [`docs/reef-feedback-response.md`](../../reef-feedback-response.md)). All four feedback sprints landed in a single 0.5.10 release on 2026-04-29: - `io.path.expand_home`, `io.path.join` (canonical synonym for `join_path`). - `test.framework.assert_contains_string`, `assert_ok_int/_str/_bool`, `assert_err_int/_str/_bool`. - `encoding.toml.toml_parse_status`, `encoding.json.json_parse_status` (truncation-aware parse). - `io.file.rename`, `io.file.fsync` (atomic-write primitives; parent-directory fsync still pending). - `sys.flag.flag_parser_from(args)` for sliced argv (subcommand dispatch). - `core.result_generic` — `Result[T, E]` with `is_ok`, `is_err`, `unwrap_ok`, `unwrap_err`, `unwrap_or`. Construction uses explicit type args: `@Result[int, string].Ok(42)`. Cross-module use unblocked by BUG-037 fix in the same release. - `encoding.toml.TomlDoc` + `toml_parse_doc` (struct-bundled parse output, replacing parallel-array threading). - `encoding.toml.TomlBuilder` and `encoding.json.JsonBuilder` (streaming serializers — closes the reader-without-writer asymmetry). **Deferred to 0.5.11** (does not affect repoman v0.1): - Cobra-style declarative `subcommand_parser` API. Phase 1 (`flag_parser_from`) is sufficient for repoman's subcommand dispatch — we hand-roll the outer dispatch on `argv[1]`, then feed each subcommand a sliced argv. - `expand_user("~user/...")`. Current `expand_home` passes `~user/...` through unchanged. §2 (architecture's CLI parser hedge) and §3.4 (atomic-write fsync hedge) were originally written against 0.5.9 workarounds. They have been retargeted to the 0.5.10 APIs in rev 3. --- ## 1. Scope v0.1 is "match-then-surpass": ship feature-parity with the bash prototype's two subcommands plus the one v0.1-only differentiator that's cheap to add now and expensive to retrofit (the Incus `project` namespace). Everything else from VISION (the remaining subcommands, REST integration, interactive wizard, JSON output mode) is deferred. **In scope:** - `repoman new [--repo ] [--image ]` - `repoman sync [name] [--no-delete] [--dry-run]` - `repoman --version` / `repoman --help` / `repoman` (no args → full help) - Central registry at `~/.config/repoman/repoman.toml` - Per-project overrides at `~/.config/repoman/repos.d/.toml` - Incus `project repoman` as the namespace for every container repoman creates **Out of scope (deferred to v0.2 or later):** - Subcommands: `setup`, `list`, `shell`, `remove`, `status`, `update`, `config`, `adopt` - Incus REST-over-unix-socket integration (v0.1 shells out to the `incus` CLI) - Interactive wizard / line-prompt mode for missing args - Auto-rollback when a `new` step fails midway - JSON output mode for automation - CI / public-forge release artifacts (prebuilt binaries) - Migration from the bash prototype's containers (which live in Incus's `default` project) - `repoman` config-by-env-var compatibility (`LOCAL_REPOS`/`NFS_REPOS`) - Incus profile management (e.g., `repoman profile new dotfiles` interactive create/edit). v0.1 expects users to author profiles directly via `incus profile create/edit` and reference them in `[defaults].profiles`. README documents the recommended `dotfiles` profile pattern (bind-mount `~/.hgrc`/`~/.gitconfig`/etc.) for shared host config. - `[defaults].seed_files` (copy-at-create semantics). Bind-mount via Incus profile is sufficient for the v0.1 dotfiles use case; revisit if a real copy-on-create need surfaces. --- ## 2. Architecture Six modules under `src/`, no nesting: | File | Module | Responsibility | Pure? | |---|---|---|---| | `src/main.reef` | (entry) | `proc main()` → `cli.dispatch(sys.args.argv())` and exit. | — | | `src/cli.reef` | `cli` | Subcommand routing, flag parsing, usage/version output. Returns exit codes. Hand-rolls outer dispatch on `argv[1]`, then feeds each subcommand a sliced argv via `sys.flag.flag_parser_from(args)`. Migrates to the declarative `subcommand_parser` API in 0.5.11+. | mostly | | `src/config.reef` | `config` | Registry types (`Defaults`, `Project`, `Registry`), per-project override (`Override`), `EffectiveConfig`. Load-or-init from disk (atomic write); merge defaults+override. | yes | | `src/incus.reef` | `incus` | Thin wrappers over the `incus` CLI via `process_spawn(prog, argv)` — `project_ensure`, `container_exists`, `launch`, `bind_repo`, `set_env`, `restart`. Plus pure `validate_name`. | wrappers thin; `validate_name` pure | | `src/sync.reef` | `sync` | `ensure_nfs_mounted` (stat → mountpoint → findmnt -t nfs4 chain), pure `build_rsync_args(opts)`, effectful `run(opts)` that spawns rsync and inherits stdio. | arg builder pure | | `src/path.reef` | `path` | `expand_home`, `join`, `exists`, `is_dir`. Thin wrappers over `io.path` / `io.dir`. | yes | **Boundaries.** Pure logic (TOML schemas, name validation, rsync arg construction, path expansion, defaults merging) is unit-tested. Effectful wrappers (subprocess invocation, NFS mount checks, file write) are kept narrow so they can be smoke-tested via integration but don't need fakes. `core.result_generic` (`Result[T, string]`) carries errors out of every fallible function; `main` translates `Result.Err` into a stderr message + non-zero exit code. Construction syntax is `@Result[T, string].Ok(value)` / `@Result[T, string].Err(msg)` per 0.5.10 generics. **Subprocess discipline.** Every external invocation goes through `sys.process.process_spawn(program, argv)` — never `process_spawn_shell`. This is non-negotiable: user-derived names (container, repo, paths) must not pass through a shell's word-splitting or globbing. **Dependency graph.** `main → cli → {config, incus, sync, path}`. `incus` and `sync` are independent of each other. `config` depends only on `encoding.toml`, `io.file`, `path`. No cycles. **Concurrency.** v0.1 is fully sequential. No Active Objects, no parallel sync. VISION's parallel-sync-across-N-projects idea is a v0.2 candidate. --- ## 3. Data shapes ### 3.1 Central registry — `~/.config/repoman/repoman.toml` ```toml [repoman] schema = 1 [defaults] repos_root = "~/repos" backup_root = "/nfs/repos" incus_project = "repoman" default_image = "images:ubuntu/26.04/cloud" profiles = ["default", "claude-share"] [[project]] name = "isurus" repo = "isurus-project" image = "images:ubuntu/26.04/cloud" profiles = ["default", "claude-share"] created = "2026-04-28T15:00:00Z" last_sync = "" # "" = never synced backup = true # false → sync skips ``` **Fields:** - `[repoman].schema` — registry schema version. v0.1 writes `1`. Loader rejects any other value with a clear error and a hint to upgrade repoman. - `[defaults].repos_root` / `backup_root` — `~` expanded by `path.expand_home` at load time. - `[[project]].repo` — repo dirname relative to `repos_root`. Defaults to `name` if unspecified. - `[[project]].image` and `[[project]].profiles` — **effective** (post-merge) values used at container-create time, snapshotted for the registry. If override `repos.d/.toml` had `[container].profiles`, those (not defaults) get stored here. Stored explicitly so v0.2 `list`/`status` doesn't need to query Incus to display them. - `[[project]].created` / `last_sync` — ISO 8601 UTC strings. `""` for never. - `[[project]].backup` — opt-out flag. Default `true`. ### 3.2 Per-project override — `~/.config/repoman/repos.d/.toml` User-authored before `repoman new`. Optional. Read by `new` only. ```toml [container] image = "images:debian/12/cloud" profiles = ["default", "claude-share", "node-dev"] [[mount]] source = "~/.npm" # host path, ~ expanded path = "/home/ctusa/.npm" # container path [env] NODE_ENV = "development" ``` Filename keys off the **container name** (``), not the repo dirname. So `repoman new isurus --repo isurus-project` reads `repos.d/isurus.toml`. ### 3.3 Merge semantics (`new` only) Effective config priority: | Field | Priority | |---|---| | `image` | `--image` flag → `override.container.image` → `defaults.default_image` | | `profiles` | `override.container.profiles` (replace, not merge) → `defaults.profiles` | | `mounts` | `[auto repo bind]` ++ `override.[[mount]]` (additive; the auto repo bind is always present) | | `env` | `override.[env]` (key-by-key; empty if absent) | Replace semantics for `profiles` is deliberate: VISION's example shows the override containing the full profiles list (including the defaults), and predictable replace beats subtle additive surprises. ### 3.4 Atomic write Central registry is rewritten atomically on every change: 1. `io.file.writeFile("repoman.toml.tmp", contents)` in the same directory as the target. 2. `io.file.fsync("repoman.toml.tmp")` to push contents to disk before the rename. 3. `io.file.rename("repoman.toml.tmp", "repoman.toml")`. Parent-directory fsync is not yet exposed (per the 0.5.10 changelog), so a power-loss window for the rename itself remains. Acceptable for a homelab dev tool; revisit when the runtime adds it. TOML serialization is via `encoding.toml.TomlBuilder` from 0.5.10 — explicit `toml_set_string` / `toml_set_string_array` / `toml_array_append_table` for `[[project]]` entries, then `toml_render` to a string. The override file is never written by repoman in v0.1. User authors it. ### 3.5 Validation on registry load - `[repoman].schema == 1` — error otherwise. - All `[[project]].name` values pass `incus.validate_name`. - No duplicate `name` across `[[project]]` entries. - `repos_root` and `backup_root` resolve to non-empty strings after `~` expansion. Failure → `Result.Err` with a clear message including the offending field. Exit code 3 (environment). --- ## 4. Subcommand flows ### 4.1 `repoman new [--repo ] [--image ]` 1. Parse args. `name` required; bad usage → exit 2. 2. `incus.validate_name(name)` — pure check (alnum + hyphen, ≤63, no leading hyphen). Fail → exit 1. 3. `config.load_or_init()` — read or create the registry with default `[repoman]`/`[defaults]`. 4. Reject if `name` already exists in `[[project]]` → exit 4 with hint. 5. Resolve repo path: `/`. Error if directory doesn't exist (matches bash) → exit 3. 6. Read `~/.config/repoman/repos.d/.toml` if present; parse into `Override`. 7. Compute `EffectiveConfig` per the merge table in §3.3. 8. `incus.project_ensure(defaults.incus_project)` — list, create if missing. Idempotent. 9. `incus.container_exists(project, name)` — error if already present → exit 4 with `incus delete --project

` hint. 10. `incus launch --project

--profile P1 --profile P2 ... ` (one `process_spawn`). 11. For each effective mount (always: the auto repo bind at `:`; then any override mounts), `incus config device add --project

disk source= path=`. Device names: `repo` for the auto bind, `mount-N` (N=1..) for overrides. 12. For each `[env]` entry, `incus config set --project

environment.KEY=VALUE`. 13. `incus restart --project

` so binds and env take effect. 14. Append a `[[project]]` entry with all snapshot fields; `config.save(reg)` atomically. 15. Print "ready" + a correct shell-in hint using `getuid()` and `$HOME` (not hardcoded `1000`/`/home/ctusa` like bash). **Rollback policy.** v0.1 does **not** auto-destroy on partial-failure. If launch succeeds but a downstream step fails (device-add, env-set, restart, registry write), surface the error and the exact `incus delete --project

` command for manual cleanup. v0.2 candidate: `--rollback-on-error`. ### 4.2 `repoman sync [name] [--no-delete] [--dry-run]` 1. Parse args. 2. `config.load_or_init()`. 3. `sync.ensure_nfs_mounted(defaults.backup_root)` — three calls: `stat ` (triggers autofs), `mountpoint -q `, `findmnt -t nfs4 `. Each via `process_spawn`. Any failure → exit 3 with a clear message identifying which step failed. 4. Resolve sync target: - **With `name`:** find in registry. Missing → exit 1 with hint. `backup = false` → exit 1 (user explicitly asked, refusing is more honest than silent skip). `src = //`, `dst = //`. - **Without `name`:** `src = /`, `dst = /`. Build excludes from the standard list (see §4.3) plus `/` for every project where `backup = false`. 5. Pure `sync.build_rsync_args(opts)` produces argv. See §4.3 for the full set. 6. Print one-line tag to stderr: `==> rsync (dry-run) (additive) ` (tags conditional on flags). 7. `process_spawn("rsync", argv)`. Inherit parent stdout/stderr — user sees rsync's progress live; we don't capture or reformat. 8. If rsync exit 0: update `last_sync` to current ISO 8601 UTC and `config.save(reg)` atomically. "Affected" = the named project (single-project mode), or every project where `backup != false` (whole-tree mode). 9. Exit code = rsync's exit code (passthrough). 0 = success; 23 = partial transfer; 24 = vanished files; etc. ### 4.3 rsync invocation Base flags: `-aHAX`. Adaptive info flags: - Dry-run: `--dry-run --itemize-changes --info=stats2` - Interactive (TTY on stdout): `--info=stats2,progress2` - Otherwise (cron / piped): `--info=stats2` Delete: `--delete` unless `--no-delete`. Excludes (hardcoded for v0.1 — matches bash prototype): ``` node_modules/ target/ build/ dist/ .next/ __pycache__/ *.pyc .venv/ venv/ .cache/ .tox/ .pytest_cache/ .mypy_cache/ .ruff_cache/ ``` Whole-tree mode appends `--exclude=/` for every project where `backup = false`. **`backup = false` semantics:** - Whole-tree sync: that repo is excluded by rsync; not synced; its `last_sync` is not touched. - Single-project sync: errors. The user explicitly named it. --- ## 5. CLI surface and error UX ### 5.1 Subcommands ``` repoman new [--repo ] [--image ] repoman sync [name] [--no-delete] [--dry-run] repoman --version | -V repoman --help | -h | help repoman # no args → print full help ``` ### 5.2 Output discipline - All informational chatter (`==> incus launch ...`, `==> rsync (dry-run) ...`) goes to **stderr**. - **stdout is reserved** for future machine-readable output. v0.1 produces no stdout (matches bash prototype, keeps cron logs clean). - Errors prefixed `repoman: error: ` to stderr. Append a `hint:` line where a clear next step exists. ### 5.3 Exit codes | Code | Meaning | |---|---| | `0` | success | | `1` | generic / unhandled error | | `2` | bad usage (unknown subcommand, missing required arg, bad flag) | | `3` | environment problem (NFS unreachable, repo dir missing, registry corrupt) | | `4` | state conflict (container already exists, name already in registry) | | (rsync codes) | for `sync`: rsync's own exit code passes through when rsync itself fails. Pre-rsync errors use 1–4. | The rsync passthrough is deliberate: cron consumers can distinguish "couldn't even start" (3/4) from "rsync hit a problem" (rsync's own codes). ### 5.4 Error UX matrix | Class | Example | Code | What the user sees | |---|---|---|---| | Bad usage | `repoman new` | 2 | error msg + short usage hint | | Validation | invalid container name | 1 | error msg + offending value | | State conflict | name already in registry | 4 | error msg + cleanup hint | | Environment | NFS not mounted | 3 | "backup_root /nfs/repos is not mounted (autofs/server unreachable)" | | Subprocess (incus) | `incus launch` failed | 1 | our error msg + incus's stderr passed through | | Subprocess (rsync) | rsync mid-transfer error | rsync's | rsync's own stderr (we don't capture) | ### 5.5 No interactive mode in v0.1 Missing required args produce a usage error, not a prompt. Wizard mode is v0.2. --- ## 6. Testing strategy ### 6.1 Unit-tested (pure logic) — full coverage expected | Module | What's tested | |---|---| | `path` | `expand_home` (`~/`, `~`, no-op cases), `join` (trailing-slash handling) | | `incus` | `validate_name` — valid, leading-hyphen, too-long, dot/underscore, empty, exactly-63-char boundary | | `config` | TOML round-trip (parse → serialize → reparse equals original); schema rejection (unknown `schema`); `load_or_init` against a temp `XDG_CONFIG_HOME`; `merge_with_defaults` for image/profiles/mounts/env priority cases; `add_project` rejects duplicates; `update_last_sync` on known/unknown name | | `sync` | `build_rsync_args` covers every branch: dry-run on/off, delete on/off, TTY/non-TTY info flags, `backup=false` exclude generation, single-project vs whole-tree, every standard exclude present | ### 6.2 Smoke-tested (effectful) — manual, not automated - `incus.project_ensure`, `launch`, `bind_repo`, `restart` — need a real Incus daemon. - `sync.ensure_nfs_mounted`, `sync.run` — need a real NFS mount and rsync. Smoke test recipe (in README): ```bash # In an existing repo dir under ~/repos: repoman new test-foo repoman sync test-foo --dry-run incus delete --project repoman test-foo ``` Three commands, ~30 seconds, catches integration regressions before each release tag. ### 6.3 Test runner Reef idiom: `reefc run tests/test_.reef` per file, each a standalone program using `test.framework`'s `TestRunner`. Documented loop in README: ```bash for t in tests/test_*.reef; do echo "== $t ==" reefc run "$t" || exit 1 done ``` ### 6.4 Not tested in v0.1 - `main.reef` — 5-line dispatcher, exercised by every invocation in dev. - CLI flag-parsing fine grain — exercised by manual usage during dogfooding; revisit in v0.2. - Exact rsync exit-code passthrough — trusts rsync. ### 6.5 No CI in v0.1 Public-forge + CI is post-v0.1 work. Bar for v0.1: the test loop runs cleanly locally. --- ## 7. Build, install, distribution ### 7.1 `reef.toml` manifest ```toml [package] name = "repoman" version = "0.1.0" author = "Chris Tusa " description = "Per-project Incus containers + opinionated NFS/ZFS backup" license = "MIT" # placeholder — open product question url = "" # forge TBD [build] entry = "src/main.reef" output = "repoman" output_dir = "build" source_dirs = ["src"] [docs] output = "docs/api" include_private = false ``` ### 7.2 Repo layout ``` ~/repos/repoman/ ├── reef.toml ├── Makefile # 10-line install/uninstall wrapper for packagers ├── README.md # quickstart + test loop + smoke-test recipe + recommended `dotfiles` Incus profile pattern ├── VISION.md # design intent (stays at root) ├── .hgignore # build/, *.o, etc. ├── docs/ │ ├── superpowers/specs/2026-04-29-repoman-v0.1-design.md # this file │ └── api/ # generated by `reefc doc` ├── src/ │ ├── main.reef │ ├── cli.reef │ ├── config.reef │ ├── incus.reef │ ├── sync.reef │ └── path.reef ├── tests/ │ ├── test_path.reef │ ├── test_config.reef │ ├── test_incus.reef │ └── test_sync.reef └── build/ # generated; hg-ignored ``` ### 7.3 Build / clean / docs Reef-native: - `reefc build` → `build/repoman` - `reefc clean` - `reefc doc` → `docs/api/` ### 7.4 Tests Documented shell loop in README (§6.3); no wrapper. ### 7.5 Install 10-line `Makefile`, install/uninstall only (packagers expect `DESTDIR`/`PREFIX`): ```make PREFIX ?= /usr/local DESTDIR ?= build/repoman: reefc build install: build/repoman install -d $(DESTDIR)$(PREFIX)/bin install -m 755 build/repoman $(DESTDIR)$(PREFIX)/bin/repoman uninstall: rm -f $(DESTDIR)$(PREFIX)/bin/repoman .PHONY: install uninstall ``` `make install PREFIX=$HOME/.local` and `sudo make install` both work; `DESTDIR` honored for fakeroot staging. ### 7.6 Single-binary distribution Reef compiles to C → native. `build/repoman` statically links `libreef_runtime.a`. v0.1 doesn't use TLS (no REST yet) → `libreef_tls.a` not pulled in. Runtime deps: libc + the external `incus`, `rsync`, `findmnt`, `mountpoint`, `stat` binaries. ### 7.7 Release artifacts (post-v0.1) Source tarball minimum; prebuilt binaries per platform once forge is chosen. Out of scope for v0.1. ### 7.8 v0.1 verification checklist 1. `reefc build` succeeds clean. 2. The test loop passes for every file in `tests/`. 3. `make install PREFIX=$HOME/.local` (or `sudo make install`) puts `repoman` on PATH. 4. Smoke test: `repoman new test-foo` (against an existing test repo) → `repoman sync test-foo --dry-run` → manual `incus delete --project repoman test-foo`. All exit 0. 5. `repoman --version` prints `0.1.0`. --- ## 8. Open product questions (do not block v0.1 dev) 1. **License.** MIT placeholder in `reef.toml`. Final choice (MIT vs Apache-2.0) before public release. 2. **Forge.** GitHub / Codeberg / self-hosted — affects release artifact pipeline. 3. **Env-var compatibility.** Bash prototype reads `LOCAL_REPOS` / `NFS_REPOS`. Reef v0.1 does not — TOML registry is canonical. Decide before the bash version is retired whether to honor `REPOMAN_REPOS_ROOT` / `REPOMAN_BACKUP_ROOT` as overrides. 4. **Logging / observability.** v0.1 = stderr only. JSON-lines mode is a v0.2 candidate. --- ## 9. Implementation pinned at scaffold-time (not now) These are decisions deliberately deferred to the moment the code lands, because a 10-minute look at the actual reef API resolves them better than abstract debate: 1. **CLI parser.** Hand-roll an `argv[1]` switch in `cli.reef`, then call `sys.flag.flag_parser_from(argv[2..])` per subcommand. The surface to the rest of the code is `cli.dispatch(argv: [string]): int`. Migrate to `subcommand_parser` when 0.5.11 ships it. 2. **`incus config show` output parsing.** Anywhere v0.1 needs to query Incus state (e.g., `container_exists`), prefer `incus list --format csv` or `--format json` over text scraping. Lock the format choice when implementing each call. --- ## 10. References - [VISION.md](../../../VISION.md) — design intent, audience, differentiators, open product questions. - `~/.local/bin/repoman` — bash prototype (behavioral spec for `new` ≈ `create` and `sync`). - `~/reef-lang-0.5.10-source/docs/language/reference/031_PROJECT_STRUCTURE.md` — reef project conventions (`reefc init`, `reef.toml`, module-to-file mapping). - `~/reef-lang-0.5.10-source/reef-stdlib/sys/process.reef` — `process_spawn` / `process_exec` / wait/kill API used throughout. - `~/reef-lang-0.5.10-source/reef-stdlib/encoding/toml.reef` — TOML codec for the registry. # repoman v0.3 — Setup wizard + LLM stack integration ## Scope reduction (2026-05-08) The `--hermes`/`--no-hermes`/`--purge-hermes` flag-based provisioning described in this spec was **removed during smoke testing** and will not ship in v0.3. Root cause: the bind-mount-the-host-runtime architecture does not survive Python venv portability constraints. Hermes' venv pins to a uv-vendored host-only path; bind- mounting it into a container where that path doesn't exist fails at import time. Uid mapping for the bind also does not generalize cleanly. Copying the venv breaks shebang paths. v0.4 will revisit per-container hermes provisioning via pre-built incus images that embed a self-contained hermes install rather than sharing the host runtime. **v0.3 still ships:** `repoman setup` wizard, `llm-share` profile (ollama client wiring), schema-2 migration, and the `hermes` module helpers as a library for v0.4. --- **Status:** v0.3 design, under review **Date:** 2026-05-06 **Implementation language:** reef-lang 0.5.20 (no new stdlib requirements vs v0.2) **Origin:** [VISION.md §4 (`repoman setup`)](../../../VISION.md), [v0.1 spec](2026-04-29-repoman-v0.1-design.md), conversation 2026-05-06 with hermes Docker docs at `https://hermes-agent.nousresearch.com/docs/user-guide/docker` **Outcome:** the contract for v0.3 — the first version of repoman that productizes the host-side bootstrap and bundles local-LLM tooling. --- ## 0. What's new vs v0.2 v0.2 shipped `new`/`sync`/`list`/`status`/`remove`/`shell`. It assumes the host is already prepared (Incus project exists, `claude-share` profile authored, ZFS/NFS available). v0.3 closes that gap by introducing the host-bootstrap subcommand and adds first-class support for local LLM tooling (ollama + hermes) since that's the most common reason a fresh host needs more than just `claude-share`. Two threads, one release: 1. **`repoman setup`** — idempotent host bootstrap. Replaces the README's manual incus-project-create / profile-edit walkthrough with a guided wizard. 2. **LLM stack integration** — `llm-share` profile, ollama client wiring, per-container hermes data dirs with selective seeding. The two are coupled because the wizard is the natural place to *offer* LLM-stack setup as an option, and the per-container hermes seeding adds new behavior to `repoman new`. --- ## 1. Scope **In scope:** - `repoman setup` — interactive + flag-driven (`--non-interactive`, `--with-llm`, `--without-llm`). - `llm-share` Incus profile, repoman-managed (created/refreshed by `setup`). - `repoman new --hermes` — opt-in flag that provisions a per-container hermes data dir. - `repoman new --no-hermes` (and `--llm/--no-llm` umbrella) for explicit opt-out when defaults change. - Selective hermes seeding from host's `~/.hermes/` into `~/.local/share/repoman/hermes//`. - `repoman remove --purge-hermes` — delete the per-container hermes data dir (default: leave it for safety). - Host LAN-IP detection for `OLLAMA_HOST` (read once at `setup`, written into profile). - `[defaults].llm = { enabled, hermes_default, ollama_url, hermes_seed }` block in `repoman.toml`. **Out of scope (deferred to v0.4 or later):** - Adopting an existing host hermes install whose `~/.hermes` should *be* one of the project dirs (no migration tool yet — user can `mv` manually). - Re-keying / rotating `.env` API keys across N seeded containers. - Bind-mounting `/opt/ollama/imports` into containers for in-container `ollama create`. Will be added if real demand surfaces; for v0.3, model imports stay a host operation. - `claude-share` lifecycle. v0.3 *checks* its existence in `setup` and tells the user how to create it if missing, but does not author or edit it. (Same boundary as v0.2.) - Hermes server-side gateway (port 8642) exposure to the LAN as a shared service. The hermes docs explicitly reject the daemon model; we don't fight it. - YAML rewriting of seeded `config.yaml`. v0.3 issues a warning if `localhost:11434` is detected in the seed source and asks the user to fix once on the host; we do not parse YAML. --- ## 2. Architecture additions Two new modules, plus targeted edits to `cli.reef`, `config.reef`, `incus.reef`: | File | Module | Responsibility | Pure? | |---|---|---|---| | `src/setup.reef` | `setup` | The wizard: detect state (incus project, profiles, ollama, hermes binary, host LAN IP), print summary, prompt yes/no per stage, apply changes. Composes `incus.*` and `hermes.*`. | wrappers thin | | `src/hermes.reef` | `hermes` | Per-container data-dir management: `seed_data_dir(name, source, dest, seed_list)`, `purge_data_dir(dest)`. Pure helpers `default_seed_list()` (returns the allow-list), `state_dir_for(name)` (resolves `~/.local/share/repoman/hermes//`). | helpers pure; copy effectful | Edits: - `cli.reef` — add `cmd_setup` dispatch, extend `cmd_new` to honor `--hermes` / `--no-hermes`, extend `cmd_remove` to honor `--purge-hermes`. - `config.reef` — add `[defaults].llm` substructure, schema bump to `2`, migration path for `schema = 1` registries. - `incus.reef` — add `profile_exists(name, project)`, `profile_create_or_edit(name, project, yaml)`, `container_add_disk_device(name, device, source, path, opts)`. The disk-device add is needed because `hermes-state` is a *per-container* device, not a profile-level one. **Dependency graph addition:** `cli → setup → {incus, hermes, config, path}`. `hermes → {path, io.file, io.dir}`. No cycles. **Non-goal: a new abstraction over Incus profiles.** `setup` constructs `llm-share` from a string-template embedded in the binary — we don't build a profile-modeling layer in reef just yet. --- ## 3. The `llm-share` profile Created and maintained by `repoman setup`, in the `repoman` Incus project: ```yaml name: llm-share description: | Local LLM client tools (ollama client + hermes runtime) and host-daemon wiring. Created by repoman setup; do not hand-edit (changes will be overwritten). config: environment.OLLAMA_HOST: "http://:11434" devices: ollama-bin: type: disk source: /usr/local/bin/ollama path: /usr/local/bin/ollama readonly: "true" ollama-state: type: disk source: /home//.ollama path: /home//.ollama shift: "true" ``` `` is the address bound to `br0` on the host, resolved at `setup` time by parsing `ip -4 addr show br0`. `` is the invoking user. Both are baked into the YAML at write time — `repoman setup` is the single source of truth. **Notably absent:** any hermes bind-mount. Hermes data is per-container (see §4). **Refresh policy.** Re-running `repoman setup` rewrites `llm-share` from template if and only if the on-disk content differs. `setup` tells the user what it changed and why. --- ## 4. Per-container hermes data dirs Host hermes' `~/.hermes/` is left untouched. For each container that opts into hermes, repoman provisions: - A host directory at `~/.local/share/repoman/hermes//`, owned by the invoking user. - Selective seed from `~/.hermes/` into that directory (see §4.2). - An Incus disk device on the container itself (not a profile) bind-mounting that host dir to `/home//.hermes` inside. The hermes binary at `~/.local/bin/hermes` is reachable inside the container via the existing `claude-share` profile's bind on `~/.local/bin/`. (We **verify** this assumption in §6.1; if `claude-share` doesn't share `~/.local/bin`, the wizard tells the user to add it.) ### 4.1 Why per-container, not shared Per the [hermes Docker guide](https://hermes-agent.nousresearch.com/docs/user-guide/docker): > Never run two Hermes gateway containers against the same data directory simultaneously — session files and memory stores are not designed for concurrent write access. `state.db` is SQLite + WAL. Sharing the data dir between host and N containers risks corruption. Per-container dirs eliminate the risk entirely and align with the hermes team's recommended pattern (one data dir per profile/container). ### 4.2 Seed list Default `[defaults].llm.hermes_seed`: ```toml hermes_seed = [ ".env", # API keys "config.yaml", # model defaults, daemon URL "SOUL.md", # persona "skills/", # user-authored skills (recursive) "hooks/", # user hooks (recursive) "hermes-agent/", # vendored runtime — symlink (see §4.3) "node/", # vendored node — symlink "bin/", # extra binaries — symlink ] ``` **Not seeded** (per-instance state, must be fresh): `sessions/`, `memories/`, `logs/`, `state.db`, `state.db-shm`, `state.db-wal`, `audio_cache/`, `image_cache/`, `sandboxes/`, `cron/`, `pairing/`, `models_dev_cache.json`, `ollama_cloud_models_cache.json`, `context_length_cache.yaml`, `.skills_prompt_snapshot.json`, `.update_check`, `.hermes_history`, `auth.lock`. The seed list is in `[defaults].llm.hermes_seed` so users can adjust it without rebuilding. ### 4.3 Symlink vs copy for runtime dirs `hermes-agent/`, `node/`, `bin/` are *runtime*, not user data. By default we **symlink** them from the host's `~/.hermes/` (so a `hermes` upgrade on the host applies to every container with no rebuild), and **copy** the credential/config files. A future `--hermes-isolate-runtime` flag can flip everything to copy for users who want hermes versions to diverge per container. Symlinks must point at the host path *as visible from inside the container* — i.e., the symlink target must already be reachable through some other bind. Since `~/` is bind-mounted in or mappable, this works as long as the user paths align. **Open question O-3 (§9)** owns the cross-mount-namespace symlink correctness check. ### 4.4 Storage location: why `~/.local/share/repoman/hermes//` Considered: `~/.hermes-/` (parallel to `~/.hermes`). Chose `~/.local/share/repoman/hermes//` because: - All repoman-owned per-project state ends up under one tree (`~/.local/share/repoman/`), which matters for backups and for users who want to know "what does repoman own?" - `~/.hermes-*` pollutes `$HOME` and risks collision with hypothetical future hermes profile features. - XDG Base Directory convention. --- ## 5. Subcommand flows ### 5.1 `repoman setup [--non-interactive] [--with-llm | --without-llm]` Stages, run sequentially. Each stage prints what it found, what it'd change, and (interactive mode) waits for `[Y/n]`. `--non-interactive` accepts every default; `--with-llm`/`--without-llm` non-interactively pin the LLM stage. 1. **Detect environment.** incus reachable, current user, host LAN IP via `br0`, ollama binary, hermes binary, `~/.hermes/` presence, ZFS/NFS roots from registry defaults. 2. **Incus project `repoman`.** Create if missing. (No-op if v0.1 already created it.) 3. **`claude-share` profile.** Verify it exists in the `repoman` project and bind-mounts `~/.local/bin/`. If missing or doesn't bind that path, print the recommended `incus profile edit` snippet and exit non-zero with a clear message — we do not author `claude-share`. 4. **LLM stack (gated on `--with-llm` or interactive yes).** 1. Verify ollama daemon is reachable on `:11434`. If only on loopback, print the systemd-override snippet to make it LAN-listen and exit non-zero. 2. Write/refresh the `llm-share` profile in the `repoman` project from template (§3). 3. Verify hermes binary at `~/.local/bin/hermes` and host data dir at `~/.hermes/`. If absent, print install pointer (link to hermes user guide) and skip per-container hermes seeding default. 5. **Registry defaults.** Write `[defaults].profiles = ["default", "claude-share", "llm-share"]` if user said yes to LLM stack; write `[defaults].llm.{enabled = true, hermes_default = false, ollama_url, hermes_seed = [...]}` block. Schema bumps to `2`. 6. **Summary.** Print `setup complete` summary with three follow-on hints: `repoman new `, `repoman new --hermes`, `repoman list`. Exit codes: `0` success, `2` bad usage, `3` environment (incus unreachable, ollama not LAN-bound, br0 missing), `4` user said no to a required stage in non-interactive mode. **Idempotency:** every stage is rerunnable. Re-running `setup` after a hermes upgrade refreshes the `llm-share` profile if its content changed and is otherwise a no-op. ### 5.2 `repoman new [...] [--hermes | --no-hermes]` Existing v0.2 flow plus: - After the container launch, **if** `--hermes` (explicit) or `[defaults].llm.hermes_default = true` and not `--no-hermes`: 1. Compute `dest = ~/.local/share/repoman/hermes//`. 2. Refuse if `dest` already exists and is non-empty (exit 4 with hint to `repoman remove --purge-hermes ` first). 3. Run the seed (§4.2): copy the credential/config files, symlink the runtime dirs. 4. `incus.container_add_disk_device(, "hermes-state", source=dest, path=/home//.hermes, shift=true)`. 5. Restart the container so the device takes effect. - The registry's `[[project]]` entry gains a `hermes = true|false` field so `list`/`status` can show it. If the LLM stack wasn't enabled at `setup` time, `--hermes` errors out with a hint to `repoman setup --with-llm`. ### 5.3 `repoman remove [--purge-hermes]` Existing v0.2 flow plus: - Container removal proceeds as today. - If the project had `hermes = true`, the per-container data dir is **left in place by default**. The user's reauthorized `.env` and skills survive the container teardown. - `--purge-hermes` (or `--purge` umbrella, see open question O-1) deletes `~/.local/share/repoman/hermes//`. Logged loudly because this destroys session/memory state. ### 5.4 `repoman status ` / `repoman list` Show `hermes: yes/no` per project. No new flags. --- ## 6. Data shapes ### 6.1 Registry schema bump (1 → 2) ```toml [repoman] schema = 2 [defaults] repos_root = "~/repos" backup_root = "/nfs/repos" incus_project = "repoman" default_image = "images:ubuntu/26.04/cloud" profiles = ["default", "claude-share", "llm-share"] # llm-share added if user opted in [defaults.llm] enabled = true hermes_default = false # false → opt-in via --hermes ollama_url = "http://192.168.168.42:11434" # LAN IP captured at setup hermes_seed = [ ".env", "config.yaml", "SOUL.md", "skills/", "hooks/", "hermes-agent/", "node/", "bin/", ] [[project]] name = "isurus" repo = "isurus-project" image = "images:ubuntu/26.04/cloud" profiles = ["default", "claude-share", "llm-share"] created = "2026-04-28T15:00:00Z" last_sync = "" backup = true hermes = true # NEW; defaults false ``` **Migration from schema 1:** `config.load_or_init` recognizes `schema = 1`, prints a one-line note, populates `[defaults].llm` with `enabled = false` (i.e., user must opt in via `setup`), sets `hermes = false` on every existing `[[project]]`, writes back as `schema = 2`. Idempotent. No data loss. ### 6.2 Per-project override addition Override files (`~/.config/repoman/repos.d/.toml`) gain an optional field: ```toml [hermes] enabled = true # equivalent to passing --hermes; flag wins if both specified ``` Unknown to v0.2; harmless to v0.2 since override parser ignores unknown sections. ### 6.3 Profile YAML template Embedded as a `string` constant in `setup.reef`: ```reef let LLM_SHARE_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" ``` Substitutions are literal `{HOST_LAN_IP}` / `{USER}` replacement — no template engine. Validated with a roundtrip test (substitution → YAML parse via the bundled toolchain → assert structure). --- ## 7. Testing Mirrors the v0.1 boundary: pure logic gets unit tests; effectful wrappers get smoke-tested via integration. Specifically: **Pure tests** (run on every build): - `hermes.default_seed_list()` returns the documented allow-list. - `hermes.state_dir_for("foo")` resolves to `/.local/share/repoman/hermes/foo/`. - Profile template substitution: given known `HOST_LAN_IP`/`USER`, the rendered YAML matches a golden string. - Schema migration: load `schema = 1` toml, get back `schema = 2` toml with expected defaults populated and existing `[[project]]` entries unchanged except for `hermes = false`. - Seed-list partition: given the documented hermes dir contents (test fixture), the seeded vs not-seeded sets match §4.2. - `setup` stage planner: given a fixture environment description (profile present, ollama on LAN, hermes installed), the planner returns the expected list of "would change" actions and "no-op" actions. **Smoke tests** (require an Incus host, gated on `REPOMAN_SMOKE=1`): - `repoman setup --non-interactive --with-llm` on a fresh-ish host produces a working baseline. - `repoman new foo --hermes` then `incus exec foo -- ls /home/$USER/.hermes` shows the seeded layout, and `incus exec foo -- hermes --version` runs. - `repoman remove foo` leaves the data dir; `repoman remove foo --purge-hermes` removes it. - Symlink correctness: from inside the container, `readlink ~/.hermes/hermes-agent` resolves to a real path (no broken link). --- ## 8. Risks / mitigations | Risk | Mitigation | |---|---| | Symlinks for runtime dirs break across mount namespaces. | Smoke test 4 above. If broken, fall back to copy for runtime dirs and surface as O-3. | | Host LAN IP changes (DHCP renewal). | `setup --refresh` re-detects and rewrites `llm-share`. Documented as the recovery step in `repoman status` when ollama health-check fails. | | Hermes upgrades change the on-disk layout (`hermes-agent/` schema, etc.). | Symlinking the runtime dirs means upgrades flow through automatically. Seed list is config (`[defaults].llm.hermes_seed`) so users can adjust without recompiling. | | `.env` shared across N containers means a leaked container key is the user's main key. | Same trust model as `claude-share`. Documented in README. Future v0.4 work on rotation is explicitly out of scope here. | | User has hermes installed in a non-default location (not `~/.hermes/`). | `setup` reads `HERMES_HOME`-equivalent if set; otherwise hardcoded default. Open question O-2. | | `setup` partial-fails midway (e.g., wrote `llm-share` but couldn't restart a container). | Each stage is independently idempotent. `setup` is safe to rerun. No transactional rollback in v0.3 — same posture as v0.1's container-create. | --- ## 9. Open questions - **O-1: `--purge-hermes` vs `--purge`.** Should `repoman remove` have a single `--purge` flag that removes both the hermes data dir and any future per-container repoman state, or stay tool-specific (`--purge-hermes`, `--purge-claude`, …)? Defaulting to `--purge-hermes` for v0.3. - **O-2: Hermes home env var.** Does the hermes CLI honor a `HERMES_HOME` (or similar) env var to redirect from `~/.hermes/`? If yes, the per-container path could be set via env rather than bind-mount-overlay. Needs probe. - **O-3: Symlink correctness across the bind boundary.** Validate empirically that `~/.local/share/repoman/hermes//hermes-agent → /home//.hermes/hermes-agent` resolves correctly inside the container when only the per-container dir is bind-mounted. If not, fall back to copy and document. - **O-4: `claude-share` baseline check.** v0.3 currently *checks* that `claude-share` bind-mounts `~/.local/bin/`. If that's a fragile assumption (the user may name their profile differently), should `setup` accept a `--claude-share-profile=` override? - **O-5: Authoring `claude-share` itself.** Out of scope for v0.3 (§1), but eventually repoman should manage `claude-share` the same way it manages `llm-share`. v0.4 candidate. - **O-6: Multi-host repoman.** If a user runs repoman on two hosts and their LAN IPs differ, the registry's `ollama_url` is host-specific. v0.3 treats `repoman.toml` as host-local; document. --- ## 10. Build sequence Suggested order so each step produces a working binary: 1. **Schema bump.** `config.reef` adds `[defaults].llm` parsing + schema 1→2 migration. Tests pass; v0.2 behavior unchanged. 2. **`incus.profile_*` and `container_add_disk_device` wrappers.** Pure plumbing; no caller yet. 3. **`hermes.reef` module.** `default_seed_list`, `state_dir_for`, `seed_data_dir`, `purge_data_dir`. Unit-tested. 4. **`setup.reef` module.** Detection + planner + applier. The interactive prompt scaffolding is `io.console`-based; matches the wizard pattern named in VISION. 5. **`cli.cmd_setup`** — wire it up. 6. **`cmd_new` extensions** — `--hermes`/`--no-hermes`, registry write of `hermes` field, post-launch seed + device-add + restart. 7. **`cmd_remove` extensions** — `--purge-hermes`. 8. **`list`/`status` display** — show `hermes: yes/no`. 9. **README + VISION updates** — document `setup`, document `--hermes`, link to hermes Docker docs as the source for the per-container-data-dir decision. 10. **Smoke run on a fresh host** — fold findings back as bug fixes, then tag v0.3.0. # repoman v0.4 — Profile library + scope trim **Status:** v0.4 design, under review **Date:** 2026-05-08 **Implementation language:** reef-lang 0.5.20 (no new stdlib requirements vs v0.3) **Origin:** brainstorm 2026-05-08 — re-anchoring on repoman's actual mission after v0.3 LLM-stack work overshot scope. Builds on [v0.3 spec](2026-05-06-repoman-v0.3-llm-and-setup.md) (which was scope-reduced before shipping; see addendum at top of that file). **Outcome:** the contract for v0.4 — repoman becomes a profile-library-driven container provisioner, with the v0.3 LLM-specific surface either generalized into the profile system or removed. --- ## 0. Mission re-anchor Repoman's mission is narrowly: 1. Provision per-project Incus containers that bind-mount the user's git/hg repos (`cmd_new`) 2. Backup `~/repos/` to NFS via rsync (`cmd_sync`) 3. Manage the Incus profiles that share host-side resources (configs, agents, bind-mountable runtimes) into containers 4. Quality-of-life subcommands over the above (`list`, `status`, `remove`, `shell`, `setup`) **What repoman is NOT:** a host-configuration tool. It does not install or configure host services (ollama, hermes runtime, kernel modules). It does not build or maintain container images. It does not run arbitrary install scripts on the user's behalf. **Where v0.3 drifted:** `setup --with-llm` checks whether the host's ollama daemon listens on the LAN IP and tells the user how to configure it if not. This was halfway across the host-config line. v0.4 pulls back to the line. **The unifying insight from this brainstorm:** `claude-share` (user-managed since v0.1) and `llm-share` (repoman-managed since v0.3) are the same kind of thing — incus profiles that bind something host-side into containers. The split was incidental, not principled. v0.4 unifies them under a single profile library. --- ## 1. Scope **In scope (additions):** - Profile library layout: vendor profiles at `/usr/local/share/repoman/profiles/`, user profiles at `~/.config/repoman/profiles.d/`, user shadows vendor. - `repoman profile` subcommand family: `list`, `install`, `diff`, `remove`, `show`. - Templated YAML profile files. Substitution syntax: `${VAR}` (shell-style). Variables: `${HOST_LAN_IP}`, `${USER}`, `${HOME}`. - `[host].lan_ip` field in the registry, populated by `repoman setup`'s detection. - Registry schema 2 → schema 3 migration (drops `[defaults].llm` entirely, extracts `lan_ip` from the old `ollama_url` if present). - Three vendor profiles ship in v0.4: `claude-share.yml`, `llm-share.yml`, `dotfiles.yml`. - Pre-launch validation in `repoman new`: error early with actionable hint if a referenced profile isn't installed in incus. **In scope (trims/removals):** - `repoman setup --with-llm` and `--without-llm` flags — removed. Setup no longer touches profiles. - `setup`'s ollama LAN-listening check — removed. Reading host LAN IP from `ip -4 addr show br0` stays (read-only host introspection, not configuration). - `[defaults].llm` registry block (enabled, hermes_default, ollama_url, hermes_seed) — removed entirely. - The string-template-embedded-in-binary approach for `llm-share` in `setup.reef` — removed. `llm-share` becomes a vendor profile YAML file. - `setup`'s `apply_stage` for `llm_share_profile` and `registry_defaults` — those stages move to `repoman profile install` and registry init respectively. **Out of scope (deferred or rejected):** - Image management (`repoman image build/refresh/etc.`) — rejected during brainstorm. Maintenance burden too high for a homelab tool with low container churn. v0.5+ may revisit if real demand surfaces. - `[install]` block in override files — rejected during brainstorm. Per-project install commands are shell-script territory; not repoman's job. - Configuring host ollama (installer, systemd override, model pulls) — rejected as scope creep across the host-config boundary. - Remote profile registries / fetching profiles over HTTP — out of scope. Library is local files only. - Profile dependencies (`profile A includes profile B`) — YAGNI. Each profile is independent. - Profile YAML schema validation — trust incus. v0.4 just substitutes templates; incus rejects malformed YAML at apply time with clear errors. - Per-host portability automation — registry is per-host (lives in `~/.config/repoman/`); document that fact rather than automate it. --- ## 2. Architecture ### 2.1 New module: `src/profile.reef` Single new module covering the profile library. Composes `incus`, `path`, `config`, and stdlib I/O. | Responsibility | Pure? | |---|---| | `vendor_dir(): string` — `/usr/local/share/repoman/profiles` | yes | | `user_dir(home: string): string` — `/.config/repoman/profiles.d` | yes | | `lookup(name: string, home: string): Result[string, string]` — search user dir then vendor dir, return path to YAML file or Err if not found | side-effecting (filesystem reads) | | `list_all(home: string): [ProfileEntry]` — enumerate both dirs, return list with source (user/vendor) tags | side-effecting | | `render(yaml: string, host: HostFacts): string` — substitute `${HOST_LAN_IP}`, `${USER}`, `${HOME}` | yes | | `install(name: string, home: string, host: HostFacts): Result[bool, string]` — lookup → render → `incus.profile_create_or_edit` | side-effecting | | `diff(name: string, home: string, host: HostFacts): Result[string, string]` — render the file, fetch the live incus profile, return unified diff | side-effecting | | `remove(name: string): Result[bool, string]` — `incus profile delete` (no-op if not installed) | side-effecting | | `show(name: string, home: string, host: HostFacts): Result[string, string]` — return rendered YAML for inspection | side-effecting | Pure helpers (`render`, `vendor_dir`, `user_dir`) get unit tests. Effectful wrappers are smoke-tested via `cmd_*`. ### 2.2 Edits to existing modules - `src/setup.reef`: drop `render_llm_share_template`, `template_contains_placeholder`, the `apply_stage` cases for `llm_share_profile` and `registry_defaults`, and the `--with-llm`/`--without-llm` flag plumbing in `cmd_setup`. Setup becomes: detect environment → print plan → ensure incus project → write registry with `[host].lan_ip` populated. The wizard still has `--non-interactive`. Two stages remain (down from up to four): `incus_project`, `registry_defaults`. - `src/cli.reef`: add `cmd_profile` dispatch covering 5 subcommands. Add pre-launch validation in `cmd_new`: before `incus.launch`, iterate `eff.profiles` and call `incus.profile_exists("default", name)` for each — error early with `hint: repoman profile install ` if any are missing. Update `print_usage()` help text. - `src/config.reef`: add `Host` substruct (`lan_ip: string`) and `Registry.host: Host` field. Drop `LlmDefaults` and `Defaults.llm`. Bump schema constant to 3. Add migration: if loading schema 2, extract `[defaults.llm.ollama_url]`, parse the host portion of the URL into `[host].lan_ip`, drop the rest. - `src/incus.reef`: add `profile_get(name): Result[string, string]` (`incus profile show ` capturing stdout) — needed for `profile diff`. - `src/main.reef`: dispatch already routes through `cli.dispatch`; no changes. ### 2.3 Build/install changes `Makefile`: - New target dependency: `install` copies `profiles/*.yml` into `/usr/local/share/repoman/profiles/`. - New target: `uninstall` removes them. ```makefile PROFILES_DIR = $(DESTDIR)$(PREFIX)/share/repoman/profiles install: build install -d $(DESTDIR)$(BINDIR) install -m 0755 build/repoman $(DESTDIR)$(BINDIR)/repoman install -d $(PROFILES_DIR) install -m 0644 profiles/*.yml $(PROFILES_DIR)/ uninstall: rm -f $(DESTDIR)$(BINDIR)/repoman rm -rf $(PROFILES_DIR) ``` ### 2.4 Repository layout addition ``` ~/repos/repoman/ ├── profiles/ │ ├── claude-share.yml # bind ${HOME}/.claude │ ├── llm-share.yml # bind /usr/local/bin/ollama, ${HOME}/.ollama; OLLAMA_HOST env │ └── dotfiles.yml # bind ${HOME}/.gitconfig, ${HOME}/.hgrc ├── src/ │ ├── profile.reef # NEW │ ├── ... └── ... ``` `profiles/` is a tracked directory in the source repo (versioned alongside code). The files are templates; substitutions happen at install time. --- ## 3. Data shapes ### 3.1 Vendor profile templates (v0.4 starter library) **`profiles/claude-share.yml`:** ```yaml name: claude-share description: Share host's Claude CLI state (auth, history, plugins) into containers. config: {} devices: claude-state: type: disk source: ${HOME}/.claude path: ${HOME}/.claude shift: "true" claude-bin: type: disk source: ${HOME}/.local/bin/claude path: /usr/local/bin/claude readonly: "true" shift: "true" ``` (Note: `claude-bin` assumes claude is installed via npm/pipx into `~/.local/bin/`. Users with system-installed claude shadow this profile.) **`profiles/llm-share.yml`:** ```yaml name: llm-share description: Wire containers to the host ollama daemon over LAN. config: environment.OLLAMA_HOST: "http://${HOST_LAN_IP}:11434" devices: ollama-bin: type: disk source: /usr/local/bin/ollama path: /usr/local/bin/ollama readonly: "true" ollama-state: type: disk source: ${HOME}/.ollama path: ${HOME}/.ollama shift: "true" ``` **`profiles/dotfiles.yml`:** ```yaml name: dotfiles description: Bind common host dotfiles (.gitconfig, .hgrc) into containers. config: {} devices: gitconfig: type: disk source: ${HOME}/.gitconfig path: ${HOME}/.gitconfig readonly: "true" shift: "true" hgrc: type: disk source: ${HOME}/.hgrc path: ${HOME}/.hgrc readonly: "true" shift: "true" ``` Users add their own dotfiles by shadowing — copy `dotfiles.yml` into `~/.config/repoman/profiles.d/` and add `.zshrc`, `.tmux.conf`, etc. ### 3.2 Registry schema 3 ```toml [repoman] schema = 3 output = "quiet" [host] lan_ip = "192.168.168.124" # populated by `repoman setup` from `ip -4 addr show br0` [defaults] repos_root = "~/repos" backup_root = "/nfs/repos" logdir = "~/.local/state/repoman" incus_project = "repoman" default_image = "images:ubuntu/26.04/cloud" profiles = ["default"] # safe default; users add others by editing or via override files # [defaults.llm] block removed (was in schema 2). [[project]] name = "isurus" repo = "isurus-project" image = "images:ubuntu/26.04/cloud" profiles = ["default", "claude-share", "llm-share"] created = "2026-04-28T15:00:00Z" last_sync = "" backup = true ``` ### 3.3 Schema 2 → 3 migration Implicit (no migration function — same pattern as v0.3's 1→2): - `parse_registry` accepts schemas 1, 2, or 3. - When loading schema 2: read `[defaults.llm.ollama_url]` (e.g., `"http://192.168.168.124:11434"`), strip `http://` prefix and `:11434` suffix, store remainder in `[host].lan_ip`. If parse fails or the field is missing, leave `lan_ip` empty (will be re-detected on next `setup` run). - When loading schema 1: same behavior as schema 2 except `[host].lan_ip = ""` (no llm block to extract from). - Final `Registry` literal in `parse_registry` always returns `schema: 3`. - `default_registry` returns `schema: 3` with `[host].lan_ip = ""` (populated by `setup`). - `serialize_registry` writes schema 3 with `[host]` block; never writes `[defaults.llm]`. ### 3.4 ProfileEntry struct (for `list_all`) ```reef type ProfileEntry = struct name: string // e.g., "claude-share" source: string // "user" or "vendor" file_path: string // resolved path installed: bool // present in incus state drift: bool // installed-and-rendered-file-differs (computed lazily; false if not installed) end ProfileEntry ``` ### 3.5 HostFacts (substitution context) ```reef type HostFacts = struct lan_ip: string user: string home: string end HostFacts ``` Constructed at command entry (read from registry + env), passed to `profile.render`/`install`/etc. --- ## 4. Subcommand flows ### 4.1 `repoman profile list` 1. Resolve user dir + vendor dir. 2. Enumerate `*.yml` in each. Build a name → source map; user wins on collision (note as "user (shadows vendor)" in source column). 3. For each, query `incus profile show --project repoman ` to determine `installed`. 4. For installed entries, render the file and compute `drift = (rendered != incus_show_output)`. 5. Print a table: ``` NAME SOURCE INSTALLED DRIFT claude-share vendor yes no llm-share user (shadows vendor) yes yes dotfiles vendor no n/a my-experiment user no n/a ``` Exit 0 always (informational). ### 4.2 `repoman profile install ` (or `--all`) 1. Resolve `name` to a file path via shadow lookup. 2. Read file → render with `HostFacts` (registry `[host].lan_ip`, env `USER`, env `HOME`). If `${HOST_LAN_IP}` is in the file but registry has empty `lan_ip`, fail with hint to run `repoman setup`. 3. Call `incus.profile_create_or_edit("repoman", name, rendered_yaml)`. 4. Print `==> installed (source: user|vendor)`. `--all`: enumerate the union of user and vendor profile names; install each (user shadows resolve correctly). Exit 0 success, 1 install failure, 3 missing host facts. ### 4.3 `repoman profile diff ` 1. Resolve and render the file. 2. Fetch `incus profile show --project repoman ` stdout. If not installed, print "not installed; would install " and the rendered content. 3. Compute and print a unified diff between rendered file and live incus state. Exit 0 if no diff, 1 if diff exists, 3 on resolution failure. (Useful for "what will `profile install` change?" before running it.) ### 4.4 `repoman profile remove ` 1. `incus profile delete --project default `. If it doesn't exist in incus, that's an error from incus — surface it to the user with exit 1 and a hint that they may not need to remove it. 2. Does NOT delete the file from `~/.config/repoman/profiles.d/.yml`. User's files are user's. Print: `==> removed from incus (file at untouched)`. 3. If any project in the registry references the just-removed profile in its `profiles` list, print a warning naming the projects that will fail to relaunch — but exit 0; the removal succeeded. ### 4.5 `repoman profile show ` 1. Resolve and render. Print to stdout. Exit 0. Useful for debugging templating, piping into other tools, or sanity-checking before install. ### 4.6 `repoman setup` (revised — much smaller) Stages reduce from four to two: 1. **`incus_project`** — ensure `repoman` project exists (unchanged). 2. **`registry_defaults`** — write registry with `schema = 3`, `[host].lan_ip` populated from `detect_host_lan_ip()`, default `[defaults].profiles = ["default", "claude-share"]`. The `claude_share_check` and `llm_share_profile` stages from v0.3 are gone. Profile installation is now `repoman profile install --all`. Flags: `--non-interactive` only. No `--with-llm`/`--without-llm`. Help text: ``` setup [--non-interactive] First-time host bootstrap: ensures Incus project 'repoman' exists, detects host LAN IP, writes initial registry. Run `repoman profile install --all` afterwards to install the vendor profile library. ``` ### 4.7 `repoman new` (small addition: pre-launch profile validation) Just before `incus.launch`, iterate `eff.profiles`. For each name except the magic incus default `"default"`, call `incus.profile_exists("default", name)`. (All repoman-managed profiles install into the incus `default` project — see §6 below — and our `features.profiles=false` setting on the `repoman` project means containers in it inherit profiles from `default`.) If any check returns false: ``` repoman: error: container references profile 'foo' but it's not installed in incus. hint: repoman profile install foo hint: repoman profile install --all (to install the vendor library) ``` Exit 4 (resource-conflict class). --- ## 5. Testing Mirrors the v0.3 testing posture: pure logic gets unit tests; effectful wrappers are smoke-tested. **Pure tests** (run on every build): - `profile.render` — given a YAML string with `${HOST_LAN_IP}`, `${USER}`, `${HOME}` and a `HostFacts`, returns the substituted output. Test cases: all three present, only some present, none present (no-op), unknown variable left as literal. - `profile.vendor_dir` and `profile.user_dir` — given a `home`, return the documented paths. - Schema 2 → 3 migration: load a fixture toml with `[defaults.llm.ollama_url]` set, verify `[host].lan_ip` is populated correctly; load one with the field missing, verify empty string; load schema 1, verify both work. - Registry schema-3 round-trip serialize → parse. **Smoke tests** (require an Incus host, gated on `REPOMAN_SMOKE=1`): - `repoman profile install claude-share` against a fresh host produces a working profile. - `repoman profile diff` correctly identifies drift between an edited file and the installed profile. - `repoman profile remove` removes from incus but leaves the file. - `repoman new ` against a registry with `profiles = [..., "missing-profile"]` errors early with the expected hint, before any incus mutation. - Schema 2 registry on disk loads cleanly and re-saves as schema 3, with `[host].lan_ip` extracted from the old `ollama_url`. --- ## 6. Risks / mitigations | Risk | Mitigation | |---|---| | Existing users have hand-authored `claude-share` profiles in incus that differ from the vendor `claude-share.yml`. `repoman profile install claude-share` would overwrite. | Document migration: before `install`, run `repoman profile diff claude-share` to see what would change; user can `cp /usr/local/share/repoman/profiles/claude-share.yml ~/.config/repoman/profiles.d/`, edit to match their hand-authored version, then `install` (user file wins). README has a "migrating to v0.4" section. | | Vendor `dotfiles.yml` binds `~/.gitconfig` but a user doesn't have one. Container fails to launch. | Minimal initial set (.gitconfig, .hgrc) reduces this surface. README documents shadowing as the way to tailor. Future v0.5 could explore `optional: true` semantics if incus supports it. | | `${HOST_LAN_IP}` can't be substituted if registry's `[host].lan_ip` is empty (e.g., `setup` couldn't detect br0). | `profile install` errors with a clear "run `repoman setup` first" hint. Detection happens in `setup`; it's the documented path. | | User edits vendor profile file directly at `/usr/local/share/repoman/profiles/.yml`. Next `make install` overwrites. | Document: vendor dir is owned by the package; user changes go in `~/.config/repoman/profiles.d/`. | | `repoman profile install --all` order dependencies (e.g., what if profile A references profile B?). | YAGNI for v0.4 — no dependencies model. `--all` installs in alphabetical order; user re-installs if needed. Document. | | `incus profile show` output format differs from what we render — diff always shows noise. | The render → install → show roundtrip should be stable for the limited template surface we use. If incus rewrites/normalizes the YAML, `diff` will show normalization noise; we accept this for v0.4 and document. v0.5 could add a smarter diff. | --- ## 7. Decisions and open questions ### Resolved decisions (locked into spec) - **All repoman-managed profiles install in the incus `default` project.** v0.1/v0.2 already placed `claude-share` there; v0.3's `llm-share` was inconsistent (placed in `repoman` project). v0.4 unifies: every vendor and user profile installed via `repoman profile install` lands in `default`. The `repoman` project's `features.profiles=false` setting means containers in it inherit profiles from `default`, so this works without any per-profile project metadata. Users who want a different project can `incus profile copy` manually after install. - **`repoman profile remove` does not touch the registry.** It only deletes from incus; if `[defaults].profiles` or any project's `profiles` list still names the removed profile, those references fail at next `new` (caught by pre-launch validation). `remove` prints a warning if removal would orphan a reference, but doesn't auto-edit the registry. - **`profile diff` supports `--vendor` for shadow-vs-vendor comparison.** Default is rendered-file-vs-incus (most useful day-to-day). `--vendor` shows the user shadow vs the vendor file (useful when upgrading repoman). If the named profile isn't shadowed (no user file), `--vendor` errors with a clear hint. - **Initial `[defaults].profiles = ["default"]`.** Just the magic incus `default` profile. Users add others (claude-share, llm-share, etc.) explicitly — via override files in `repos.d/` or by editing `repoman.toml` — after they've run `profile install` for whatever they want. ### Open questions - **O-1: Per-host portability of `[host].lan_ip`.** A user moving `repoman.toml` between hosts would carry the old IP. Out of scope to automate — registry is per-host. Document. - **O-2: Vendor `dotfiles.yml` failure mode.** If a user lacks `~/.gitconfig` or `~/.hgrc` on the host, the bind fails at container start. The minimal initial set reduces but doesn't eliminate this risk. Investigate whether incus supports `optional: true` on disk devices (newer incus versions may); if yes, add to the vendor profile. If no, document and let users shadow. - **O-3: `incus profile show` output format stability.** If incus normalizes/reorders YAML on write, `profile diff` shows formatting noise rather than semantic drift. Acceptable for v0.4; revisit if it's painful in practice. - **O-4: What if vendor library grows in v0.5+?** Users who shadowed an existing profile keep their shadow (good). Users who didn't get the new ones automatically (good). Risk: vendor adds a name that collides with a user's custom. Shadowing handles this — user wins. Documented in §6's risks. --- ## 8. Build sequence (suggested order) 1. Schema 3 plumbing in `config.reef`: add `[host]` substruct, drop `[defaults].llm`, schema 2→3 migration, schema constant bump in `default_registry`. Round-trip + migration tests. 2. `incus.profile_get(name)` wrapper. Small, used by `profile diff`/`list`/`show`. 3. `profile.reef` module skeleton + pure helpers (`vendor_dir`, `user_dir`, `render`). Unit tests. 4. `profile.lookup` and `profile.list_all` (filesystem-effectful). Smoke test by inspection. 5. `profile.install`, `profile.remove`, `profile.show`. Smoke-tested via subcommand wiring in step 8. 6. `profile.diff`. Smoke-tested via subcommand wiring. 7. Trim `setup.reef`: remove llm-stage, remove flag plumbing for `--with-llm`/`--without-llm`, remove `render_llm_share_template` + `template_contains_placeholder`. Update `apply_stage` to handle only `incus_project` and `registry_defaults` (with `[host].lan_ip` writing). 8. Wire `cmd_profile` dispatch in `cli.reef` covering all 5 verbs. Update `print_usage`. Add pre-launch validation in `cmd_new`. 9. `Makefile`: install profiles into `/usr/local/share/repoman/profiles/`. 10. Author the three vendor profile YAML files in `profiles/`. 11. README + VISION updates: profile library section, migration guide for users coming from v0.3, document the new subcommands. 12. Smoke run on a fresh host: `setup` → `profile install --all` → `new myproj` (with profiles) → `shell` → verify everything works → `profile remove` → `profile diff` to verify drift detection. Tag v0.4.0.