|

docs: implementation plan for repoman rename subcommand

Author: Chris Tusa <chris.tusa@leafscale.com>
Date: Jul 15, 2026 13:43
Changeset: a0790dff2d8317e527b077f88416a284fad20909
Branch: default

Diff

diff -r 00a18db4a254 -r a0790dff2d83 docs/superpowers/plans/2026-07-15-repoman-rename.md
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/docs/superpowers/plans/2026-07-15-repoman-rename.md	Wed Jul 15 13:43:15 2026 -0500
@@ -0,0 +1,624 @@
+# repoman rename — 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:** Add a `repoman rename <old> <new>` subcommand that renames a project across every layer that encodes its name — incus container, `repo` bind device, host repo dir, registry entry, and (opt-in) Claude history slug — idempotently.
+
+**Architecture:** A pure registry mutation (`config.rename_project`) mirrors the existing `update_last_sync` helper and is unit-tested via TDD. Four thin `run_incus` wrappers add the incus verbs the command needs. `cli.cmd_rename` orchestrates the layers using the existing `cmd_remove` skeleton (load → resolve → guard → confirm → log → mutate → save), inspecting each layer's state and changing only what's still on the old name.
+
+**Tech Stack:** Reef 0.7.5 (reefc on PATH), incus CLI, Mercurial (`hg`). Build: `reefc build`. Test: `reefc run tests/<file>.reef` and `make test`.
+
+## Global Constraints
+
+- Reef **0.7.5** stdlib: fallible stdlib ops return `result.Result`/`option.Option`. Repoman keeps its own `rg.Result[T, string]` convention (`import core.result as rg`) and converts stdlib `error.Error` → string at boundaries via `error.error_message(e)`.
+- Result construction is `@Result[T, string].Ok(...)` / `.Err(...)` (bare `Result`, resolves to `core.result`).
+- Reef syntax: `fn name(...): T ... end name`; `proc name(...) ... end name`; `mut`/`let`; `while ... end while`; `if ... end if`; string concat with `+`; array alloc `new [T](n)`; `.length()`; index `a[i]`.
+- Every new source file starts with the header from `SRCHEADER.txt` (fill `Project: repoman`, `Filename:`, `Description:`).
+- Incus project for all containers: `reg.defaults.incus_project` (value `repoman`).
+- Author for commits: `Chris Tusa <chris.tusa@leafscale.com>`. VCS is **Mercurial** — commit with `hg commit -u "<author>" -m "<msg>" <files>`. Never `git`.
+- `container_state(project, name)` returns `"RUNNING"`, `"STOPPED"`, the raw state string, or `"MISSING"` when the container does not exist.
+
+---
+
+## File Structure
+
+- **`src/config.reef`** — add `rename_project` (registry mutation) + export. Owns registry logic.
+- **`src/incus.reef`** — add `stop`, `start`, `rename_container`, `device_set` wrappers + exports. Owns incus CLI shelling.
+- **`src/cli.reef`** — add `cmd_rename`, wire into `dispatch` + `print_usage` + the `export` block. Owns subcommand orchestration + user I/O.
+- **`tests/test_config_rename.reef`** — new. Unit tests for `rename_project`.
+- **`README.md`** — document the `rename` subcommand.
+
+---
+
+## Task 1: `config.rename_project` registry mutation (TDD)
+
+**Files:**
+- Create: `tests/test_config_rename.reef`
+- Modify: `src/config.reef` (add to `export` block near line 44; add fn after `remove_project`, ~line 490)
+
+**Interfaces:**
+- Consumes: `config.Registry`, `config.Project`, `config.Defaults`, `config.Host`, `config.with_projects` (existing).
+- Produces: `fn rename_project(reg: Registry, old: string, new: string): rg.Result[Registry, string]` — renames the project named `old` to `new`; sets `repo = new` **iff** the old `repo == old` (coupled dirname), else leaves `repo` unchanged; `Err` if `old` absent or `new` already used by a different project.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/test_config_rename.reef` (header from `SRCHEADER.txt`, Description: `Tests: rename_project registry mutation`):
+
+```reef
+import config
+import test.framework
+import core.result as rg
+
+fn empty_defaults(): config.Defaults
+    return config.Defaults {
+        repos_root: "/r", backup_root: "/b", logdir: "/l", incus_project: "p",
+        default_image: "img", profiles: new [string](0)
+    }
+end empty_defaults
+
+fn mk_project(name: string, repo: string): config.Project
+    return config.Project {
+        name: name, repo: repo, image: "img",
+        profiles: new [string](0), created: "t", last_sync: "", backup: true
+    }
+end mk_project
+
+proc main()
+    let runner = new framework.TestRunner()
+
+    // Two projects: a coupled one (name==repo) and a decoupled one (name!=repo).
+    mut projs: [config.Project] = new [config.Project](2)
+    projs[0] = mk_project("driftohm", "driftohm")
+    projs[1] = mk_project("portal", "leafscale-infrastructure/portal.leafscale.com")
+    let reg0: config.Registry = config.Registry {
+        schema: 3, host: config.Host { lan_ip: "" }, output: "quiet",
+        defaults: empty_defaults(), projects: projs
+    }
+
+    // Coupled rename: both name and repo move.
+    let r1 = config.rename_project(reg0, "driftohm", "mist")
+    runner.assert_eq_bool(rg.is_ok(r1), true, "coupled rename ok")
+    if rg.is_ok(r1)
+        let reg1 = rg.unwrap_ok(r1)
+        runner.assert_eq_string(reg1.projects[0].name, "mist", "coupled: name moved")
+        runner.assert_eq_string(reg1.projects[0].repo, "mist", "coupled: repo moved in lockstep")
+        runner.assert_eq_string(reg1.output, "quiet", "output preserved through rename_project")
+    end if
+
+    // Decoupled rename: only name moves, repo (nested path) preserved.
+    let r2 = config.rename_project(reg0, "portal", "gateway")
+    runner.assert_eq_bool(rg.is_ok(r2), true, "decoupled rename ok")
+    if rg.is_ok(r2)
+        let reg2 = rg.unwrap_ok(r2)
+        runner.assert_eq_string(reg2.projects[1].name, "gateway", "decoupled: name moved")
+        runner.assert_eq_string(reg2.projects[1].repo, "leafscale-infrastructure/portal.leafscale.com", "decoupled: repo dirname preserved")
+    end if
+
+    // Collision: rename onto an existing project name fails.
+    let r3 = config.rename_project(reg0, "driftohm", "portal")
+    runner.assert_eq_bool(rg.is_err(r3), true, "rename onto existing name rejected")
+
+    // Unknown source name fails.
+    let r4 = config.rename_project(reg0, "ghost", "whatever")
+    runner.assert_eq_bool(rg.is_err(r4), true, "unknown source name rejected")
+
+    runner.report()
+end main
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `reefc run tests/test_config_rename.reef`
+Expected: FAIL — compile/type error `rename_project` is not a member of `config` (function not defined yet).
+
+- [ ] **Step 3: Add the export declaration**
+
+In `src/config.reef`, inside the `export` block, add after the `remove_project` line (~line 44):
+
+```reef
+    fn rename_project(reg: Registry, old: string, new: string): rg.Result[Registry, string]
+```
+
+- [ ] **Step 4: Implement `rename_project`**
+
+In `src/config.reef`, add after `remove_project` (~line 490, before `end module`):
+
+```reef
+// Rename the project named `old` to `new`. The repo dirname moves in lockstep
+// only when it was coupled to the old name (repo == old); a deliberately
+// decoupled dirname (e.g. a nested monorepo path) is preserved. Errors if `old`
+// is absent or `new` is already used by a different project.
+fn rename_project(reg: Registry, old: string, new: 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 == old
+            found = i
+        end if
+        i = i + 1
+    end while
+
+    if found < 0
+        return @Result[Registry, string].Err("project not in registry: " + old)
+    end if
+
+    mut j: int = 0
+    while j < n
+        if j != found and reg.projects[j].name == new
+            return @Result[Registry, string].Err("project already exists: " + new)
+        end if
+        j = j + 1
+    end while
+
+    mut new_projects: [Project] = new [Project](n)
+    mut k: int = 0
+    while k < n
+        if k == found
+            let old_p: Project = reg.projects[k]
+            mut new_repo: string = old_p.repo
+            if old_p.repo == old
+                new_repo = new
+            end if
+            new_projects[k] = Project {
+                name: new, repo: new_repo, image: old_p.image,
+                profiles: old_p.profiles, created: old_p.created,
+                last_sync: old_p.last_sync, backup: old_p.backup
+            }
+        else
+            new_projects[k] = reg.projects[k]
+        end if
+        k = k + 1
+    end while
+
+    return @Result[Registry, string].Ok(with_projects(reg, new_projects))
+end rename_project
+```
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `reefc run tests/test_config_rename.reef`
+Expected: PASS — `All tests passed!`, Tests failed: 0.
+
+- [ ] **Step 6: Commit**
+
+```bash
+hg commit -u "Chris Tusa <chris.tusa@leafscale.com>" \
+  -m "config: add rename_project registry mutation" \
+  src/config.reef tests/test_config_rename.reef
+```
+
+---
+
+## Task 2: incus verb wrappers (`stop`, `start`, `rename_container`, `device_set`)
+
+**Files:**
+- Modify: `src/incus.reef` (add to `export` block ~line 45; add fns near `restart` ~line 364)
+
+**Interfaces:**
+- Consumes: `run_incus(args: [string]): rg.Result[bool, string]` (existing).
+- Produces:
+  - `fn stop(project: string, name: string): rg.Result[bool, string]`
+  - `fn start(project: string, name: string): rg.Result[bool, string]`
+  - `fn rename_container(project: string, old: string, new: string): rg.Result[bool, string]`
+  - `fn device_set(project: string, name: string, dev: string, keyvals: [string]): rg.Result[bool, string]`
+
+- [ ] **Step 1: Add the export declarations**
+
+In `src/incus.reef`, inside the `export` block (before `end export`, ~line 45), add:
+
+```reef
+    fn stop(project: string, name: string): rg.Result[bool, string]
+    fn start(project: string, name: string): rg.Result[bool, string]
+    fn rename_container(project: string, old: string, new: string): rg.Result[bool, string]
+    fn device_set(project: string, name: string, dev: string, keyvals: [string]): rg.Result[bool, string]
+```
+
+- [ ] **Step 2: Implement the wrappers**
+
+In `src/incus.reef`, add immediately after `restart` (~line 364):
+
+```reef
+fn stop(project: string, name: string): rg.Result[bool, string]
+    return run_incus(["stop", "--project", project, name])
+end stop
+
+fn start(project: string, name: string): rg.Result[bool, string]
+    return run_incus(["start", "--project", project, name])
+end start
+
+fn rename_container(project: string, old: string, new: string): rg.Result[bool, string]
+    return run_incus(["rename", "--project", project, old, new])
+end rename_container
+
+// incus config device set <container> <device> <key>=<val> ...
+fn device_set(project: string, name: string, dev: string, keyvals: [string]): rg.Result[bool, string]
+    let kn: int = keyvals.length()
+    mut args: [string] = new [string](7 + kn)
+    args[0] = "config"
+    args[1] = "device"
+    args[2] = "set"
+    args[3] = "--project"
+    args[4] = project
+    args[5] = name
+    args[6] = dev
+    mut i: int = 0
+    while i < kn
+        args[7 + i] = keyvals[i]
+        i = i + 1
+    end while
+    return run_incus(args)
+end device_set
+```
+
+- [ ] **Step 3: Build to verify it compiles**
+
+Run: `reefc build`
+Expected: `Build complete: build/repoman` (stdlib exhaustiveness warnings are pre-existing and fine).
+
+- [ ] **Step 4: Commit**
+
+```bash
+hg commit -u "Chris Tusa <chris.tusa@leafscale.com>" \
+  -m "incus: add stop/start/rename_container/device_set wrappers" \
+  src/incus.reef
+```
+
+---
+
+## Task 3: `cmd_rename` orchestration + dispatch/usage wiring
+
+**Files:**
+- Modify: `src/cli.reef` (add to `export` block ~line 48; add `cmd_rename` before `dispatch` ~line 1000; add `rename` branch in `dispatch` ~line 1049; add usage line in `print_usage` ~line 995)
+
+**Interfaces:**
+- Consumes: `config.load_or_init`, `config.registry_path`, `config.rename_project`, `config.save`, `config.Registry`, `config.Project` (existing + Task 1); `incus.validate_name`, `incus.container_state`, `incus.stop`, `incus.start`, `incus.rename_container`, `incus.device_set` (existing + Task 2); `paths.expand_home`, `paths.join`, `paths.is_dir`; `iofile.rename`; `error.error_message`; `log.open_log`, `log.write`; `flag.*`; `console.*`; `str.replace`.
+- Produces: `fn cmd_rename(argv: [string]): int` wired into `dispatch` on subcommand `"rename"`.
+
+- [ ] **Step 1: Add the export declaration**
+
+In `src/cli.reef`, inside the `export` block, add after the `cmd_remove` line (~line 46):
+
+```reef
+    fn cmd_rename(argv: [string]): int
+```
+
+- [ ] **Step 2: Implement `cmd_rename`**
+
+In `src/cli.reef`, add before `fn dispatch` (~line 1000):
+
+```reef
+// Build the Claude history slug directory for a repo path: the absolute
+// container-internal cwd with '/' replaced by '-' (e.g. /home/u/repos/x ->
+// -home-u-repos-x), under ~/.claude/projects/.
+fn claude_slug_dir(home: string, repo_path: string): string
+    let slug: string = str.replace(repo_path, "/", "-")
+    return paths.join(home, ".claude/projects/" + slug)
+end claude_slug_dir
+
+fn cmd_rename(argv: [string]): int
+    let parser: flag.FlagParser = flag.flag_parser_from(argv)
+    flag.application(parser, "repoman rename")
+    flag.description(parser, "Rename a project across container, registry, and repo dir")
+    let _y  = flag.bool_flag(parser, "yes", 'y', false, "skip confirmation prompt")
+    let _mh = flag.bool_flag(parser, "migrate-claude-history", '\0', false, "also move the Claude history slug dir")
+    if not flag.parse(parser)
+        console.printErr("repoman: error: " + flag.error(parser))
+        return 2
+    end if
+
+    let positionals: [string] = flag.positional_args(parser)
+    if positionals.length() != 2
+        console.printErr("repoman: error: 'rename' takes exactly two arguments: <old> <new>")
+        return 2
+    end if
+    let old: string = positionals[0]
+    let new: string = positionals[1]
+    let do_history: bool = flag.get_bool(parser, "migrate-claude-history")
+
+    if old == new
+        console.printErr("repoman: error: <old> and <new> are the same: " + old)
+        return 2
+    end if
+    if not incus.validate_name(new)
+        console.printErr("repoman: error: invalid new name: " + new)
+        console.printErr("hint: lowercase alphanumeric + hyphens, <=63 chars, no leading hyphen")
+        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")
+        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))
+        return 3
+    end if
+    let reg: config.Registry = rg.unwrap_ok(reg_r)
+
+    // Resolve the target project. Idempotent: accept either the old name
+    // (normal) or the new name (registry layer already renamed on a re-run).
+    let pn: int = reg.projects.length()
+    mut old_idx: int = -1
+    mut new_idx: int = -1
+    mut i: int = 0
+    while i < pn
+        if reg.projects[i].name == old
+            old_idx = i
+        end if
+        if reg.projects[i].name == new
+            new_idx = i
+        end if
+        i = i + 1
+    end while
+    if old_idx < 0 and new_idx < 0
+        console.printErr("repoman: error: no such project: " + old)
+        return 1
+    end if
+    if old_idx >= 0 and new_idx >= 0
+        console.printErr("repoman: error: both '" + old + "' and '" + new + "' exist in the registry; refusing to merge")
+        return 1
+    end if
+    mut base_idx: int = old_idx
+    if base_idx < 0
+        base_idx = new_idx
+    end if
+    let proj: config.Project = reg.projects[base_idx]
+
+    // The directory tier applies only when the repo dirname was coupled to the
+    // name (repo == old, or == new on a resumed run). A decoupled dirname is
+    // left alone.
+    let coupled: bool = proj.repo == old or proj.repo == new
+    let repos_root: string = paths.expand_home(reg.defaults.repos_root)
+    let old_dir: string = paths.join(repos_root, old)
+    let new_dir: string = paths.join(repos_root, new)
+    let inc_project: string = reg.defaults.incus_project
+
+    // Confirmation (case-aware).
+    let auto_confirmed: bool = flag.get_bool(parser, "yes")
+    if not auto_confirmed
+        println("repoman rename " + old + " " + new)
+        println("  will move:")
+        println("    - incus container   " + old + " -> " + new)
+        println("    - registry entry    " + old + " -> " + new)
+        if coupled
+            println("    - repo directory    " + old_dir + " -> " + new_dir)
+            if do_history
+                println("    - claude history    (slug for " + new_dir + ")")
+            end if
+            println("  your source is moved intact, never deleted.")
+        else
+            println("  repo directory unchanged (" + paths.join(repos_root, proj.repo) + ")")
+            println("  -- this project's dir name is decoupled from its container name.")
+        end if
+        let proceed: bool = console.confirm_default_no("continue?")
+        if not proceed
+            println("aborted")
+            return 4
+        end if
+    end if
+
+    let _ol: bool = log.open_log(reg.defaults.logdir, new, "rename")
+
+    // Inspect container run-state for both names.
+    mut state_old: string = "MISSING"
+    let so_r = incus.container_state(inc_project, old)
+    if rg.is_ok(so_r)
+        state_old = rg.unwrap_ok(so_r)
+    end if
+    mut state_new: string = "MISSING"
+    let sn_r = incus.container_state(inc_project, new)
+    if rg.is_ok(sn_r)
+        state_new = rg.unwrap_ok(sn_r)
+    end if
+    if state_old != "MISSING" and state_new != "MISSING"
+        log.write("repoman: error: containers '" + old + "' and '" + new + "' both exist; refusing to merge")
+        return 1
+    end if
+    let was_running: bool = state_old == "RUNNING" or state_new == "RUNNING"
+
+    // Step: stop the container (whichever name it currently has) if running.
+    if state_old == "RUNNING"
+        log.write("==> incus stop " + old)
+        let r = incus.stop(inc_project, old)
+        if rg.is_err(r)
+            log.write("repoman: error: " + rg.unwrap_err(r))
+            return 1
+        end if
+    elif state_new == "RUNNING"
+        log.write("==> incus stop " + new)
+        let r = incus.stop(inc_project, new)
+        if rg.is_err(r)
+            log.write("repoman: error: " + rg.unwrap_err(r))
+            return 1
+        end if
+    end if
+
+    // Step: host repo dir (coupled only).
+    if coupled
+        if paths.is_dir(old_dir) and not paths.is_dir(new_dir)
+            log.write("==> mv " + old_dir + " -> " + new_dir)
+            let mv_r = iofile.rename(old_dir, new_dir)
+            if rg.is_err(mv_r)
+                log.write("repoman: error: cannot move repo dir: " + error.error_message(rg.unwrap_err(mv_r)))
+                log.write("hint: move it manually then re-run 'repoman rename " + old + " " + new + "'")
+                return 1
+            end if
+        elif paths.is_dir(new_dir)
+            log.write("==> repo dir already at " + new_dir + " (skipping)")
+        else
+            log.write("repoman: error: repo directory not found: " + old_dir)
+            return 1
+        end if
+    end if
+
+    // Step: rename the container.
+    if state_old != "MISSING"
+        log.write("==> incus rename " + old + " -> " + new)
+        let rn_r = incus.rename_container(inc_project, old, new)
+        if rg.is_err(rn_r)
+            log.write("repoman: error: " + rg.unwrap_err(rn_r))
+            return 1
+        end if
+    elif state_new != "MISSING"
+        log.write("==> container already named " + new + " (skipping rename)")
+    else
+        log.write("==> no incus container for '" + old + "' (skipping rename)")
+    end if
+
+    // Step: repoint the repo device (coupled only), on the now-new container.
+    if coupled and (state_old != "MISSING" or state_new != "MISSING")
+        log.write("==> incus config device set " + new + " repo source/path -> " + new_dir)
+        let ds_r = incus.device_set(inc_project, new, "repo", ["source=" + new_dir, "path=" + new_dir])
+        if rg.is_err(ds_r)
+            log.write("repoman: error: " + rg.unwrap_err(ds_r))
+            return 1
+        end if
+    end if
+
+    // Step: registry (skip if already renamed on a resumed run).
+    if old_idx >= 0
+        let reg2_r = config.rename_project(reg, old, new)
+        if rg.is_err(reg2_r)
+            log.write("repoman: error: " + rg.unwrap_err(reg2_r))
+            return 1
+        end if
+        let saved = config.save(rg.unwrap_ok(reg2_r), cfg_path)
+        if rg.is_err(saved)
+            log.write("repoman: error: " + rg.unwrap_err(saved))
+            return 1
+        end if
+    end if
+
+    // Step: Claude history slug (coupled + opt-in).
+    if coupled and do_history
+        let old_slug: string = claude_slug_dir(home, old_dir)
+        let new_slug: string = claude_slug_dir(home, new_dir)
+        if paths.is_dir(old_slug) and not paths.is_dir(new_slug)
+            log.write("==> mv claude history " + old_slug + " -> " + new_slug)
+            let hs_r = iofile.rename(old_slug, new_slug)
+            if rg.is_err(hs_r)
+                log.write("repoman: warning: could not move claude history: " + error.error_message(rg.unwrap_err(hs_r)))
+            end if
+        elif paths.is_dir(new_slug)
+            log.write("==> claude history already at new slug (skipping)")
+        else
+            log.write("==> no claude history dir for old name (skipping)")
+        end if
+    end if
+
+    // Step: restart if it was running before.
+    if was_running
+        log.write("==> incus start " + new)
+        let st_r = incus.start(inc_project, new)
+        if rg.is_err(st_r)
+            log.write("repoman: error: " + rg.unwrap_err(st_r))
+            return 1
+        end if
+    end if
+
+    log.write("==> renamed '" + old + "' -> '" + new + "'")
+    return 0
+end cmd_rename
+```
+
+- [ ] **Step 3: Wire into `dispatch`**
+
+In `src/cli.reef` `fn dispatch`, add after the `remove` branch (~line 1040):
+
+```reef
+    if sub == "rename"
+        return cmd_rename(rest)
+    end if
+```
+
+- [ ] **Step 4: Add the usage line**
+
+In `src/cli.reef` `proc print_usage`, add after the `remove` block (~line 994, before the `shell` block):
+
+```reef
+    console.printErr("")
+    console.printErr("  rename <old> <new> [--yes | -y] [--migrate-claude-history]")
+    console.printErr("      Rename a project: incus container, registry, and (coupled) repo dir. Idempotent.")
+```
+
+- [ ] **Step 5: Build to verify it compiles**
+
+Run: `reefc build`
+Expected: `Build complete: build/repoman`.
+
+- [ ] **Step 6: Verify help + arg validation (no incus needed)**
+
+Run: `./build/repoman --help 2>&1 | grep rename`
+Expected: the `rename <old> <new> ...` usage line prints.
+
+Run: `./build/repoman rename onlyone 2>&1`
+Expected: `repoman: error: 'rename' takes exactly two arguments: <old> <new>` and exit 2.
+
+Run: `./build/repoman rename a 'bad/name' 2>&1`
+Expected: `repoman: error: invalid new name: bad/name` and exit 1.
+
+- [ ] **Step 7: Full unit-test suite still green**
+
+Run: `make test`
+Expected: exit 0; every suite reports `Tests failed: 0`; the claude-launcher shell test passes.
+
+- [ ] **Step 8: Manual integration recipe (documented; run against a scratch project)**
+
+On a host with incus, using a throwaway project:
+1. `./build/repoman new scratchone` — creates container + `~/repos/scratchone`.
+2. `./build/repoman rename scratchone scratchtwo` — confirm at the prompt.
+   - Assert: `incus list --project repoman` shows `scratchtwo`, not `scratchone`.
+   - Assert: `incus config device show scratchtwo --project repoman` → `repo` device `source`/`path` = `~/repos/scratchtwo`.
+   - Assert: `~/repos/scratchtwo` exists, `~/repos/scratchone` gone.
+   - Assert: `./build/repoman list` shows `scratchtwo`/`scratchtwo`.
+   - Assert: container run-state matches its pre-rename state.
+3. Re-run `./build/repoman rename scratchone scratchtwo --yes` — asserts idempotency: succeeds as a no-op (registry has `scratchtwo`, dir already moved), no errors.
+4. Clean up: `./build/repoman remove scratchtwo --yes`.
+
+- [ ] **Step 9: Commit**
+
+```bash
+hg commit -u "Chris Tusa <chris.tusa@leafscale.com>" \
+  -m "cli: add 'repoman rename' subcommand" \
+  src/cli.reef
+```
+
+---
+
+## Task 4: Document the `rename` subcommand in README
+
+**Files:**
+- Modify: `README.md` (subcommand list / usage section)
+
+**Interfaces:**
+- Consumes: nothing (docs).
+- Produces: user-facing docs for `rename`.
+
+- [ ] **Step 1: Add the rename row to README's subcommand table**
+
+`README.md` documents subcommands as a markdown table (`## Subcommands`, header `| Subcommand | Description |`, ~line 76). Insert a new row immediately after the `remove` row (line 85), matching the table format:
+
+```markdown
+| `rename <old> <new> [--yes | -y] [--migrate-claude-history]` | Rename a project across its incus container, registry entry, and — when the repo dirname is coupled to the name — the host repo directory and `repo` bind device. Idempotent (safe to re-run after a partial rename). `--migrate-claude-history` also moves the repo's Claude history directory. A project whose `repo` dirname is deliberately decoupled from its name has only its identity renamed; the repo directory is left untouched. |
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+hg commit -u "Chris Tusa <chris.tusa@leafscale.com>" \
+  -m "docs: document 'repoman rename' in README" \
+  README.md
+```
+
+---
+
+## Notes for the implementer
+
+- **No incus/filesystem mock layer exists** in repoman; existing suites (`test_config_*`, `test_paths`, `test_hermes_*`) are pure-logic. Task 1 is the only fully-automated test surface. Tasks 2–3 are build-verified plus the Task 3 Step 8 manual recipe — this matches the project's established test reality; do not invent a mock harness.
+- **Version bump is out of scope for this plan.** After merge, a release is cut separately via `./scripts/bump-version.sh --minor` (repoman's lockstep bumper), not as part of feature work.
+- If `iofile.rename` returns `EXDEV` (cross-filesystem) on the host-dir move, that surfaces as the `Err` in Task 3 Step 2's dir block, which already tells the user to move the dir manually and re-run — idempotency finishes the rest.