# nvrsion — a version manager for multi-agent orchestration (v0, Beta)

A per-file, per-edit version-control mode for Nucleic Control projects. Where git (and today's
Nucleic locking) is built for slow human coworkers who fork an isolated branch/worktree and merge a
big batch of work back hours later, **nvrsion** is built for fast agents: a session locks a *single
file* for the duration of *one edit*, the edit lands in a shared **trunk** the instant it completes,
and the lock releases immediately so the next agent can take the file and re-read it.

It is a *mode*, not a rewrite: it builds directly on the existing `LockManager` acquire path and the
shared control container. It is **opt-in per project, default off, Beta, and Nucleic-Control-only**.

**Status:** design draft (2026-06-25). Amends [[LOCKING.md]] (a control project with nvrsion on
takes the trunk path instead of the worktree-per-session path) and WORKTREE_MANAGER (nvrsion sessions
create *no* per-session worktree). Git plumbing is spelled out so it's reviewable; the Swift surface
(§12) is the stable contract.

---

## 0. Problem & motivation

### 0.1 The lock-hold window is too long

Today a session = an isolated worktree + branch forked at an immutable `baseSHA`. A file's lock is
acquired at the session's first edit and **held until the session's whole branch lands in its parent**
(autoship merge, or a mediated/agent merge detected by the git interceptor). See LOCKING §4.4. That is
correct and leak-free, but the *hold window is the entire session*: a second agent that needs the same
file waits for the first agent to finish **all** its edits and ship.

That window is sized for humans. A human holds a file for an afternoon, so isolation (a private
worktree) and batch merge (one review-able PR) are the right trade. Agents edit a file in seconds.
Making a second agent wait an entire session to touch one file — when the first agent touched it once,
early, and moved on — is the slowness this targets.

### 0.2 The git model carries weight nvrsion doesn't need

The per-session worktree exists to give each agent an isolated copy that can diverge and be merged.
But if the lock already guarantees **no two sessions edit a file at once** (LOCKING §2.2), and a granted
session is forced to **re-ground on the latest content before it edits** (the existing
`grantedNeedsReground` handshake, [LockManager.swift:262](../Sources/NucleicCore/LockManager.swift)),
then for the locked file *there is never any divergence to merge*. The isolated copy, the fork point,
the per-session branch, the nested-worktree cascade (LOCKING §5), and the conflict-resolution flow are
all machinery for a divergence that, under a per-file lock + re-ground, **cannot occur**.

### 0.3 The core bet

> Serialize writes per file (already done) + force re-ground before each edit (already done) ⇒
> textual merge conflicts within the trunk are **structurally impossible**, so "merge" collapses to
> "commit," and the lock need only be held for one edit.

The cost is the loss of the isolation buffer: a half-written or broken edit is visible to every other
agent the instant it lands (classic trunk-based development). That trade is accepted for v0; an
optional fast **pre-land validation hook** (§5) is the safety valve.

### 0.4 Design decisions (from review, 2026-06-25)

| Question | Decision |
| --- | --- |
| What is "trunk," and how does work reach the user's real branch? | **A dedicated `nucleic/trunk` branch** shared by all nvrsion sessions. Edits land there instantly; trunk is **promoted to the project's real base only at ship** (§6). A bad edit never touches the real branch directly. |
| Lock hold duration? | **Keep-warm within a turn** (§4). A file's edit lands in trunk immediately (others can *read* the latest), but the lock is *held* across the agent's consecutive edits to that file and released on turn-end / file-switch / a short idle — so a sibling can't interleave between two edits of one logical change. |
| Any gate before an edit lands in trunk? | **Optional fast pre-land hook** (§5), per project, default empty. Truly immediate when unset. |
| Opt-in surface? | **Per-project toggle, default OFF, Beta-labeled, Nucleic-Control-only**, and further requires the shared control container (`usesSharedControlContainer`). |
| Where is the shared trunk on disk? | One checkout at `<repo>/.nucleic/trunk`, on branch `nucleic/trunk`, **bind-mounted RW into the shared control container** that all the project's sessions already share. No per-session worktree. |
| Who performs the commit? | **The host**, via the `GitWorktreeManager` actor, scoped per edit (`git commit -- <paths>`), serialized. The agent writes the file (its container write *is* the host file, via virtiofs); Nucleic commits and releases. Host-mediated commit = a **certain, immediate** release signal — no detection poll needed. |
| Concurrent edits to the same file? | Impossible by the lock. Concurrent edits to *different* files share one trunk index, so commits are **path-scoped and serialized** through the actor; disjoint edits never collide. |

---

## 1. Invariants

1. **One trunk per project, shared by all nvrsion sessions.** Branch `nucleic/trunk`, one checkout,
   one index. The trunk is the only working copy — there is **no per-session worktree, no per-session
   branch, no fork-point (`baseSHA`)**.
2. **An edit lands the instant it completes.** On the edit-completion signal, Nucleic commits exactly
   the edited paths to `nucleic/trunk` (host-mediated, path-scoped). Trunk always reflects every
   *completed* edit.
3. **A lock is held for an edit, kept warm for a turn.** Acquired before an edit; retained across the
   agent's consecutive edits to that file; released on turn-end, file-switch, or short idle (§4) —
   never held to session-ship as today.
4. **No two sessions edit a file at once; the granted session always re-grounds.** The `LockManager`
   exclusion (LOCKING §2.2) and the `grantedNeedsReground` handshake are unchanged and are what make
   trunk conflicts structurally impossible (§0.3).
5. **Trunk reaches the real branch only by promotion.** The project's actual base branch changes only
   when trunk is **promoted** (§6) — an explicit action or autoship-on-completion — never as a side
   effect of an edit.
6. **Survivable.** At most one edit's worth of work is ever uncommitted (invariant 2). A small
   persisted `nvr_file_state` (§8) plus a launch reconcile rebuild lock state and never strand a file.
7. **Fail toward progress.** A failed pre-land hook (§5) rejects *that edit* back to the agent (it
   does not land, the lock stays warm, the agent fixes and re-issues) — it never wedges the trunk or
   the queue.

---

## 2. Topology — shared trunk in the shared container

A Nucleic Control project already runs **all its sessions in one shared container** with the whole
control base bind-mounted read-write
([Project.swift:494](../Sources/NucleicCore/Project.swift), `usesSharedControlContainer`;
[SessionController.swift:284](../Sources/NucleicCore/SessionController.swift)). nvrsion reuses exactly
this — it does **not** introduce a new container or mount model.

```
~/.nucleic/control/<repo>/                         (the control base, RW-mounted into the
  ├── .git/                                          shared "nucleic-control" container)
  ├── .nucleic/
  │     ├── worktrees/<slug>/   ← per-session worktrees  (NON-nvrsion sessions, unchanged)
  │     └── trunk/              ← THE shared nvrsion checkout, branch `nucleic/trunk`
  └── <project files>           ← the real base branch checkout (e.g. dev)

nvrsion session A ─┐
nvrsion session B ─┼─ all CWD = .nucleic/trunk, all editing one working copy on `nucleic/trunk`
nvrsion session C ─┘
```

- **`createSession` for an nvrsion project skips worktree creation** and points the session's
  `worktreePath` at the shared `.nucleic/trunk` (created lazily on the project's first nvrsion
  session via `git worktree add .nucleic/trunk -b nucleic/trunk <defaultBranch>`).
- The trunk dir is host-side under `.nucleic/` (git-excluded), so the **host runs git on it directly**
  — commits don't depend on the in-container interceptor (though the interceptor still reports any git
  the agent runs itself, as today).
- **Gating.** nvrsion is active for a session iff `project.nvrsionActive` (§10): controlled +
  `nvrsion.enabled` + `usesSharedControlContainer` + the container service is on. Per-session
  containers (`perSessionContainers`) are **excluded in v0** — a non-shared topology would need
  per-container checkouts synced to trunk (deferred, §15).

---

## 3. The edit → land → release loop

The whole feature is one loop layered onto the existing acquire path. The acquire half
([AppStore.arbitrate](../Sources/NucleicCore/AppStore.swift) → `LockManager.acquire`) is **unchanged**;
nvrsion adds the *land + release* half on a new edit-completion signal.

```
agent issues Edit/Write(file F)
  │
  ├─ arbitrate(S, files:[F]) → LockManager.acquire(S, domain, [F])        # UNCHANGED
  │     • domain = nvrsion trunk domain (§10), one per (project, trunk)
  │     • all-or-nothing; queues if F is held by another session
  │     • on grant: completeGrant → re-ground if trunk moved F under the
  │       agent's last Read (existing grantedNeedsReground / diff)        # UNCHANGED
  │
  ├─ agent's native Edit tool writes F  (its container write IS the host file via virtiofs)
  │
  └─ EDIT-COMPLETE signal (new seam, §3.1)
        │
        ├─ pre-land hook (§5)?  fail → reject this edit to the agent, KEEP lock warm, do not commit
        │
        ├─ landToTrunk(S, [F]):                                           # host-mediated, serialized
        │     git -C .nucleic/trunk add -- F
        │     git -C .nucleic/trunk commit -- F  -m "<title> (nvrsion: session <S>)"
        │        --author "<session author>"  --trailer "Nucleic-Session: <S>"
        │     # path-scoped: never sweeps in another session's in-flight file
        │
        └─ keep F warm (held) — release governed by §4, not here
```

### 3.1 The edit-completion seam (the one genuinely new wiring)

Today there is no "edit done" callback — `finalize()` (stage+commit) lives only at the ship boundary
inside `SessionController.integrate()`
([SessionController.swift:1055](../Sources/NucleicCore/SessionController.swift)). nvrsion needs the
commit to happen *per edit*. The signal already latent in the system: the backend **synthesizes
file-change events from Edit/Write/MultiEdit tool calls** ([ClaudeCodeBackend](../Sources/NucleicCore/Claude/ClaudeCodeBackend.swift),
`emitsFileChangeEvents=false`). nvrsion subscribes to *that* completion, post-approval and
post-tool-result.

**Design rule:** the backend must **not** call git. It emits a structured "edit completed: session S,
paths P" event; `AppStore` (which already owns the `LockManager` and the git collaborators) owns an
`NvrsionTrunk` coordinator that performs `landToTrunk`. This keeps the backend↔git decoupling the
codebase already enforces (the same shape as `LockManager`'s injected collaborators).

### 3.2 Why landing is conflict-free

The lock guaranteed no other session touched F during the edit, and re-ground guaranteed the agent
edited against trunk's current F. So `git add F; git commit -- F` on trunk is always a clean
fast-forward of F's content — there is nothing to merge. Files the agent *read but did not lock* may
have moved; that surfaces through the existing re-ground/diff path on the agent's next edit, as a
*notice*, never a hard trunk conflict.

---

## 4. Lock duration — keep-warm within a turn

Two independent clocks, deliberately decoupled:

| Clock | Fires when | Effect |
| --- | --- | --- |
| **Land** | each edit completes (§3) | commit F to trunk — others can **read** the latest immediately |
| **Release** | turn-end ∨ file-switch ∨ idle > `keepWarmIdle` | drop the lock — others can **write** F |

So trunk is always current, but the *lock* is held a little longer than a single edit, to protect a
multi-edit logical change from a sibling slipping in between two of its edits.

```
releaseGovernor(session S):
    on EDIT-COMPLETE(F):     mark F warm, stamp lastTouched[F] = now      # keep holding F
    on TURN-END(S):          releaseAll-nvrsion(S)                        # drop every warm file
    on EDIT-COMPLETE(G≠F):   if S no longer intends F → release(S, [F])   # file-switch eviction
    idle sweep (timer):      for F in warm(S) where now-lastTouched[F] > keepWarmIdle: release(S,[F])
```

- **Released ⇒ re-read on return.** If S releases F then edits it again, it re-acquires; if another
  session changed F meanwhile, the existing re-ground hands S the diff. This is the per-file re-read
  contract, made explicit.
- **`keepWarmIdle`** is a project config (default 4s). Lower = faster handoff, more re-reads; higher =
  fewer re-reads, longer waits. Turn-end always releases regardless.
- **Release is host-certain.** Because the host performs the commit and the host performs the release,
  there is no "did it land?" detection lag and no leak surface — the central failure mode LOCKING was
  built to prevent simply doesn't exist on the nvrsion path (nothing to detect; we *did* the merge).

---

## 5. Pre-land validation hook *(Phase D)*

Optional per-project command (`ProjectNvrsion.prelandHook`), default empty. It runs **after the edit is
written, before the trunk commit** (`NvrsionTrunk.land`), so a broken edit never poisons the trunk —
and therefore never reaches the real branch (promotion, §6, only ships *committed* trunk work).

```
prelandHook (e.g. "swift -frontend -parse $NUCLEIC_NVR_PATHS" or a project script):
   • runs via `/bin/sh -c` on the HOST in the trunk dir, with the edited paths in NUCLEIC_NVR_PATHS,
   • OUTSIDE the index gate (it only reads files) — a slow hook can't wedge other sessions' lands,
   • with a 10s timeout (NvrsionTrunk.prelandTimeoutSeconds): overrun ⇒ killed ⇒ rejected.
   exit 0 → land.   non-zero / timeout / spawn-fail → DON'T land (`NvrLandResult.rejected`):
        • the edit's content stays on disk (uncommitted) — the agent's work isn't lost,
        • the file's lock stays warm (released at turn-end / idle like any held file),
        • a transcript note carries the hook's output so the agent (or user) can fix and re-edit;
          the next edit to that file re-runs the hook and lands once it passes.
```

Keep it fast — it is on the per-edit hot path. Intended for syntax/format/typecheck of *just the
edited files*, not a full build/test (that belongs at promotion, §6). Unset ⇒ landing is immediate.

> **v0 limitation (honest):** the hook runs on the **host** (not in the agent's container) and the
> rejection is surfaced as a *note*, not folded into the agent's edit tool-result — because the edit's
> result is already sent by the time `.fileChange` fires (§3.1). The safety property still holds (a
> rejected edit never lands/promotes); tighter in-container execution + agent re-prompt is future work.

---

## 6. Trunk → base promotion (ship) *(Phase D)*

Trunk accumulates per-edit, session-attributed commits. The project's **real base branch changes only
by promotion**, which keeps the user in command (NUCLEIC_CONCEPT) and the real history clean despite
per-edit churn.

- **Whole-trunk promotion** *(implemented)* — `AppStore.promoteNvrsionTrunk` / `NvrsionTrunk.promote`,
  surfaced as the **"Integrate trunk → `<base>`"** button. `git merge --squash nucleic/trunk` into a
  checkout of `base` → **one clean commit** on the real branch (trailer `Nucleic-Promote: 1`), then a
  best-effort merge of `base` back into the trunk so the *next* promotion squashes only new work.
  The commit **message is the trunk's running change summary** — `nvrsion: <summary of changes>`,
  not a fixed "promoted trunk to `<base>`". The summary is **built incrementally**: each time a
  session finishes a turn that landed work, `AppStore` folds a small per-turn digest (the chat's
  goal + the files it landed) into the running summary via `IntelligenceProviding.updateTrunkSummary`
  (the on-device model when available, else a deterministic accumulating fold). Folding small
  increments — rather than summarizing the whole accumulated diff at promote time — is what keeps a
  small **on-device** model accurate. The summary is persisted in a sidecar
  (`<repo>/.nucleic/trunk-summary`, beside the trunk/promote worktrees), so it survives restarts;
  folds are serialized per project and run outside the trunk's index gate, so a slow fold never
  stalls a sibling's land. A successful promotion clears it (the work shipped), so the next message
  describes only work landed afterward; if Intelligence is off or nothing has folded yet, the message
  falls back to `nvrsion: promote trunk to <base>`. (Per-session promotion keeps its own
  chat-named message — it ships one chat, not the whole accumulated trunk.)
  Conflicts (e.g. `base` edited outside the trunk) reset cleanly and are reported; an unchanged trunk
  returns *nothing-to-promote*. It waits for the project to fall quiet (no chat mid-turn), and an
  auto-integrate countdown ships it on its own once it does. **Where it runs (`PromoteCheckout`):**
  promotion must commit onto `base`, which once required the user to have parked the project's primary
  checkout *on `base`* — if it sat on a feature branch (or the trunk, or detached) the squash built on
  the wrong branch and promotion failed. It now uses the primary checkout **only when it's already on
  `base`** (the commit advances `base` itself) and otherwise stands up a private worktree
  `<repo>/.nucleic/promote` **detached at `base`**, squashes there, and advances the real `base` ref
  with `git update-ref`. Detached so it never *holds* the `base` branch (the user can still
  `git checkout <base>` in the primary) and so moving the ref can't desync any working tree — promotion
  is fully independent of where the primary checkout sits.
- **Per-session promotion** *(implemented)* — `AppStore.promoteNvrsionSession` / `NvrsionTrunk.promoteSession`,
  surfaced as the **"Integrate this chat → `<base>`"** button in a chat's menu. Ships **one finished
  chat's work without waiting** for a long-running sibling — the slowness the whole-trunk gate imposes
  when several chats are done but one is mid-marathon. Because the trunk is always conflict-free
  composed content (§0.3), this is *not* a fragile replay of a session's interleaved commits; it
  **lifts the current trunk content of the files the chat is the latest author of** onto base as one
  commit (trailers `Nucleic-Promote: 1` + `Nucleic-Session: <S>`). Concretely, in the same
  `PromoteCheckout` of `base` the whole-trunk promote uses (the primary checkout if it's on `base`,
  else the detached `<repo>/.nucleic/promote` worktree): of the files where trunk differs from base,
  take those whose most-recent trunk commit carries this
  session's trailer, and `git checkout nucleic/trunk -- <those>` (a direct content overwrite — no
  3-way merge, so **no conflict and idempotent**: a re-promote finds them already in base and ships
  nothing). A file a *later* sibling re-touched is that sibling's to ship, not this chat's; a file
  composed of this chat's edit over an earlier sibling's already-landed edit ships whole (safe — the
  lock invariant means every earlier edit to it was complete before this chat took the file). The lone
  refusal (`.conflicted`) is a file whose `base` side was edited **outside** the trunk since the fork,
  which an overwrite would clobber — the same guard the whole-trunk promote has. Crucially it runs
  **only against `base` and the trunk branch ref — it never touches the trunk working copy**, so every
  other session's locks, warm files, and re-ground state are left completely intact (§4, §7): siblings
  stay aware of each other's work exactly as before. Gated on *this* chat (not the project) being past
  its turn, so a half-written change is never shipped.
- **Autoship-on-completion** *(deferred — see §15)*: the per-session action above is user-initiated;
  *automatically* firing it the instant a chat completes its turn is the remaining refinement. The
  hard part — separating a session's net contribution from interleaved shared history — is resolved by
  the latest-author model above, so the deferral is now just about the auto-trigger policy.
- Promotion is the *only* place a real-branch mutation happens, so it stays the single audit gate.

---

## 7. Coexistence — what the nvrsion path bypasses

When `project.nvrsionActive`, a session takes the trunk path; everything else is unchanged. The two
models never run for the same project at once (gated whole-project).

| Subsystem | nvrsion session |
| --- | --- |
| Per-session worktree / branch / `baseSHA` ([WorktreeManager](../Sources/NucleicCore/Git/WorktreeManager.swift)) | **Not created.** `worktreePath` = shared `.nucleic/trunk`. |
| `LockManager.acquire` + re-ground (LOCKING §4.2, §4.5) | **Reused as-is.** Domain = trunk (§10). |
| Lock *release* (LOCKING §4.4: detect landing) | **Replaced** by host-certain release on commit (§4). No `hasLanded` poll on the nvrsion path. |
| `SessionController.integrate` / `finalize` at ship | **Repurposed** to *trunk→base promotion* only (§6), not per-session merge. |
| Nested worktrees / cascade / `parentRef` / `rootRef` (LOCKING §5) | **Unused** — flat trunk, no tree. |
| Git interceptor → `observeGitOp` | **Still on** (reports agent-run git), but not required for release. |
| Autoship `MergeQueue` | **Reused** only at trunk→base promotion (§6). |

**A session's versioning mode is fixed at creation, not re-derived from the project's current toggle**
(`AppStore.isNvrsionSession` = "its working dir is the trunk"). So a project can hold both a worktree
chat and an nvrsion chat without them mixing: each keeps the mode it was born with, and they sit in
different lock domains. Toggling nvrsion therefore changes only how *new* chats are created — it never
disturbs an in-flight or idle chat — so it is always safe and needs **no flip guard** (§10).

### 7.1 Telling the agent the model is different

The agent inside an nvrsion chat would otherwise reason in the ordinary per-branch git model and get
it wrong — "your changes are only in the working tree", "you still need to commit / open a PR to
ship" — or take actively harmful actions (`git reset`/`commit`/`branch` on a tree **other agents
share**). So nvrsion chats get a version-control guidance paragraph appended to the system prompt
(`SessionController.nvrsionVersionControlGuidance`, threaded through `appendedSystemPrompt` beside the
sandbox build guidance and the Orchestra consent, and re-sent every turn since the CLI doesn't persist
`--append-system-prompt`). It teaches the one structural difference — a *shared* trunk where every
edit is committed the instant it lands — and the behavior that follows: don't hand-run history-mutating
git on the shared tree (read-only git is fine), expect to re-read files a sibling moved, and reach the
real branch only via the user's promotion (§6), never an agent merge/push/PR. It is gated on
`isNvrsionSession` (working dir *is* the trunk), the same creation-fixed signal as everything else
here — so a worktree chat in an nvrsion project never gets it, and an nvrsion chat whose project was
later toggled off still does.

---

## 8. Persistence & recovery

Today lock state is rebuilt at launch purely from `unmergedFiles` (LOCKING §6,
[AppStore.reconstructLocks](../Sources/NucleicCore/AppStore.swift)). On the nvrsion path edits land
immediately, so there are no unmerged files to reconstruct from — the recovery source disappears. But
that loss is **correct**, not a problem to solve: a restart kills every agent process, so any
in-flight nvrsion lock has no agent behind it and *should* vanish. Worktree-less nvrsion sessions are
already skipped by `reconstructLocks`, so no stale lock survives a restart. The only thing that can
carry across a crash is the **trunk's working tree** — hence:

- **`nvrsion_config`** — a JSON column on `project` (migration `v20-nvrsion`), holding the
  `ProjectNvrsion` struct (`enabled`, `trunkBranch`, `prelandHook`, `keepWarmIdleSeconds`).
  JSON-encoded like `sandbox_config`, so future fields need no new migration. *(Phase A.)*
- **Launch trunk-recovery** (`NvrsionTrunk.recover`, driven by `AppStore.reconcileNvrsionTrunks` after
  `reconstructLocks`). Invariant 2 bounds loss to a single edit: a crash can leave a file
  written-but-uncommitted on the trunk (the agent wrote it; the app died before `.fileChange` landed
  it). On launch, for each nvrsion project, `recover` ensures the trunk exists and **commits any
  uncommitted residue** as a `Nucleic-Recovery: 1` commit, so the trunk starts every run clean and no
  work is silently lost. The trunk's git history is the durable record. *(Phase C.)*

> **Design note (revised in Phase C):** the earlier sketch proposed an `nvr_file_state` table to
> re-seed warm locks across a restart. It was dropped — re-seeding is pointless because the agents
> that held those locks are gone, and crash safety is fully covered by committing the trunk's dirty
> residue (above). One less table, one less migration, same guarantees.

---

## 9. Failure semantics (truth table)

| Event | Trunk | Lock |
| --- | --- | --- |
| Edit completes, no hook | committed (path-scoped) | kept warm (§4) |
| Pre-land hook fails | **not** committed; edit stays on disk | kept warm; edit re-issued |
| Turn ends | (already committed per edit) | released (all warm) |
| Idle > `keepWarmIdle` | — | that file released |
| Agent runs `git` itself in trunk | its commit recorded; interceptor reports it | reconciled like any commit |
| App/container crash mid-edit | ≤1 edit uncommitted on trunk | re-seeded from `nvr_file_state` at launch |
| Two sessions, same file | — | impossible (lock); second queues |
| Two sessions, different files | both commit (path-scoped, serialized) | independent |
| Promotion to base conflicts | trunk unchanged | n/a (heavier flow, §6, surfaced like autoship conflict) |

---

## 10. Opt-in surface & Beta gating

- **`Project.nvrsion: ProjectNvrsion?`** — `nil`/`enabled:false` ⇒ off (default). Persisted as
  `nvrsion_config` JSON (migration `v20-nvrsion`), mirroring `sandbox`.
- **`Project.nvrsionActive: Bool`** — the single gate both spawn and teardown read:
  ```
  isNucleicControlled && (nvrsion?.enabled ?? false) && usesSharedControlContainer
  ```
  (and, like `effectiveSandbox`, subordinate to the app-wide container-service master switch).
- **UI:** a **Beta**-labeled toggle in `ProjectSettingsSheet`
  ([Sheets.swift](../Sources/NucleicApp/Sheets.swift)), shown only for control projects, disabled
  (with an explanatory caption) when `perSessionContainers` is on. Optional `keepWarmIdle` /
  `prelandHook` fields and a **Promote trunk** button. Plus an app-wide **`nvrsionByDefault`** toggle
  in **Settings → Control** ([SettingsView.swift](../Sources/NucleicApp/SettingsView.swift)) that seeds
  new Control projects with nvrsion on (mirrors `controlByDefault`/`sandboxByDefault`).
- **No flip guard needed.** Because a session's mode is fixed at creation (`isNvrsionSession`, §7),
  toggling nvrsion only affects *new* chats — existing chats keep their mode and never mix — so
  `updateProject` flips it freely, mid-flight or not. (An earlier draft blocked the flip while any
  non-terminal chat existed; that wrongly caught idle "complete" chats and was removed.)

---

## 11. Reuse vs replace (summary)

| Component | Verdict |
| --- | --- |
| `LockManager` acquire + queue + re-ground | **Reuse unchanged** (it's the heart; nvrsion only changes *when release fires*). |
| `arbitrate` / `handleApprovalCall` acquire path | **Reuse**; add the edit-completion subscription. |
| Per-session `WorktreeManager` worktree/branch/`baseSHA` | **Bypass** on the nvrsion path. |
| `WorktreeManager.finalize`/`integrate` | **Repurpose** for trunk→base promotion only. |
| Lock-release detection (`hasLanded`, reconcile poll) | **Replace** with host-certain release. |
| Nested worktree / cascade / `parentRef`·`rootRef` | **Unused** on the nvrsion path. |
| Git interceptor `observeGitOp` | **Keep** (agent-run git), not required for release. |
| `reconstructLocks` from `unmergedFiles` | **Replace** with `nvr_file_state` re-seed (§8). |
| `MergeQueue` | **Reuse** at promotion only. |
| `lockDomain` / `isNucleicControlled` gate | **Reuse** as the opt-in gate carrier. |

---

## 12. Swift surface (the stable contract)

```swift
/// Per-project nvrsion config (persisted as `nvrsion_config` JSON, like ProjectSandbox).
public struct ProjectNvrsion: Sendable, Codable, Equatable {
    public var enabled: Bool                 // default false
    public var trunkBranch: String           // default "nucleic/trunk"
    public var prelandHook: String?          // default nil → land immediately
    public var keepWarmIdleSeconds: Int      // default 4
}

extension Project {
    /// nvrsion governs this project's sessions (the single spawn/teardown gate).
    public var nvrsionActive: Bool { /* §10 */ }
    /// Host path of the shared trunk checkout: <repo>/.nucleic/trunk.
    public var resolvedTrunkPath: String { /* … */ }
}

/// Host-side coordinator owned by AppStore (mirrors LockManager's injected-collaborator shape).
/// The backend NEVER calls this directly — it emits edit-completion events AppStore routes here.
public actor NvrsionTrunk {
    /// Ensure `.nucleic/trunk` exists on `nucleic/trunk`, forked from the base. Idempotent.
    public func ensureTrunk(_ project: Project) async throws
    /// Land exactly `paths` for `session` as one path-scoped commit (serialized). Runs the
    /// pre-land hook first; returns .rejected(stderr) if it fails (lock kept warm by the caller).
    public func land(_ session: SessionID, paths: [String]) async -> NvrLandResult
    /// Promote the whole trunk → base (squash; §6). `message` is the running change summary
    /// (`nvrsion: <summary>`); a successful promote clears the sidecar summary.
    public func promote(..., message: String) async -> NvrPromoteResult
    /// Promote one chat → base (§6): lift the current trunk content of the files `session` is the
    /// latest author of onto base as one commit. Idempotent; never touches the trunk working copy.
    public func promoteSession(..., session: SessionID, message: String) async -> NvrPromoteResult
    /// The trunk's running change summary, maintained incrementally as sessions land work (§6),
    /// and recorded back after each per-turn fold. Persisted in `<repo>/.nucleic/trunk-summary`.
    public func changeSummary(trunkPath: String) -> String
    public func recordChangeSummary(trunkPath: String, _ summary: String)
}

// AppStore surfaces both as user actions:
//   promoteNvrsionTrunk(_ projectID:)   — whole trunk; gated on the whole project being quiet.
//   promoteNvrsionSession(_ sessionID:) — one chat; gated only on *that* chat being past its turn.

public enum NvrLandResult: Sendable, Equatable { case landed(sha: String); case rejected(String); case failed(String) }
```

Release/keep-warm is driven through the existing `LockManager.release` / `releaseAll`; nvrsion adds a
small `ReleaseGovernor` (§4) in `AppStore` that owns the warm-set + idle timer and calls them.

---

## 13. Phased rollout

Each phase builds + `swift test --build-system native` green before the next.

| Phase | Scope | Status |
| --- | --- | --- |
| **A — Opt-in scaffolding** (§10) | `ProjectNvrsion`, migration `v20-nvrsion`, `ProjectRow` column, `Project.nvrsionActive`/`resolvedTrunkPath`, Beta toggle in `ProjectSettingsSheet`. No behavior change (gate is read nowhere yet). | ✅ **Landed** — `Project.swift`, `GRDBMetadataStore.swift`, `Sheets.swift`; tests in `GRDBMetadataStoreTests`. |
| **B — Trunk topology + land loop** (§2, §3) | `NvrsionTrunk` actor (`ensureTrunk`, `land`, `regroundOnGrant`); skip worktree creation for nvrsion sessions (CWD = shared trunk); `lockDomain` = trunk; the edit-completion seam (§3.1, `.fileChange` → host-mediated path-scoped commit); **release at turn-end** (keep-warm's conservative form — idle/file-switch eviction is C). | ✅ **Landed** — `NvrsionTrunk.swift` + `AppStore` wiring; `NvrsionTrunkTests` + 2 `AppStore` integration tests. |
| **C — Keep-warm eviction + crash-recovery** (§4, §8) | `NvrsionReleaseGovernor` idle eviction on top of B's turn-end release (sweep loop in `AppStore`); launch trunk-recovery (`NvrsionTrunk.recover`); session mode fixed at creation (`isNvrsionSession`) so flipping is always safe. (`nvr_file_state` dropped — see §8.) | ✅ **Landed** — `NvrsionReleaseGovernor.swift`, `NvrsionTrunk.recover`, `AppStore` wiring; `NvrsionReleaseGovernorTests` + trunk-recover + 2 `AppStore` tests. |
| **D — Pre-land hook + promotion** (§5, §6) | Per-edit `prelandHook` (host-side, 10s timeout, `.rejected` → not committed); explicit whole-trunk → base promotion (`promoteNvrsionTrunk`, squash + resync) + a "Promote trunk" button. Per-session autoship-on-completion deferred (§6). | ✅ **Landed** — `NvrsionTrunk.land(prelandHook:)`/`.promote`, `AppStore.promoteNvrsionTrunk`, `Sheets.swift` button; `NvrsionTrunkTests` (hook + promote) + 2 `AppStore` tests. |

**All four phases are landed.** An nvrsion session edits the shared trunk, lands each completed edit
as a path-scoped commit (optionally gated by a pre-land hook), holds a file warm until idle past
`keepWarmIdle` or turn-end, recovers cleanly from a crash, can be toggled on/off freely (existing
chats keep their mode), and
the user promotes the trunk's accumulated work into the real branch as one squashed commit on demand —
either the **whole trunk** at once, or **a single finished chat** (`promoteNvrsionSession`) without
waiting for a long-running sibling to complete (§6 per-session promotion).
Everything is behind the per-project Beta opt-in (default off, Control-only).

> **Keep-warm has two tiers now:** a landed file is held until it goes idle past the project's
> `keepWarmIdleSeconds` (the `NvrsionReleaseGovernor` sweep, default 4s) **or** the turn ends
> (`.runFinished` → `releaseAll`), whichever first. Active editing refreshes the idle clock, so a
> multi-edit change to one file is never interrupted; a file the agent finished with is handed off
> within ~`keepWarmIdle`. Explicit file-switch eviction is subsumed by the idle sweep.

---

## 14. Test matrix

Built on the hermetic git-temp-repo harness (`AppStoreTests`, `LockManagerTests`, `MergeQueueTests`).
New: `NvrsionTrunkTests`, `NvrsionReleaseGovernorTests`.

| # | Scenario | Asserts |
| --- | --- | --- |
| 1 | Single edit lands | After edit-complete, F is committed to `nucleic/trunk` (path-scoped); lock warm. |
| 2 | Two sessions, different files | Both land; commits are independent and serialized; no index clobber. |
| 3 | Two sessions, same file | Second queues; granted after first releases; granted session re-grounds to first's content. |
| 4 | Keep-warm protects a multi-edit change | Sibling can't acquire F between two of holder's edits within a turn; gets it at turn-end. |
| 5 | Idle eviction | A warm, untouched file releases after `keepWarmIdle`; sibling acquires. |
| 6 | Re-read on return | Holder releases F (idle), sibling edits F, holder re-acquires → re-ground diff delivered. |
| 7 | Pre-land hook fails | No commit; edit stays on disk; lock warm; agent re-issues; pass → lands. |
| 8 | Crash mid-edit | Launch reconcile: dirty trunk path + `nvr_file_state` row → committed or re-seeded; no strand. |
| 9 | Promotion squashes | Trunk's session-attributed commits squash into base as one commit; trunk preserved. |
| 10 | Gate off-paths | Non-control / per-session-container / disabled → classic worktree path; no trunk created. |
| 11 | Flip is always safe | Toggling nvrsion is allowed even with idle chats present; existing chats keep their mode (worktree vs trunk), new chats follow the new setting. |
| 12 | No conflict possible | Concurrent disjoint edits + serialized commits never produce a git conflict on trunk. |
| 13 | Per-chat promote, disjoint | Promote chat A while sibling B works: A's files reach base; B's stay on the trunk, pending; re-promoting A ships nothing (idempotent); B promotes independently after. |
| 14 | Per-chat promote, shared file | A edits a file after B: A (latest author) ships the file whole; B then has nothing left to promote. |
| 15 | Per-chat promote, base diverged | A file edited on `base` outside the trunk → `.conflicted`; base's edit survives untouched. |
| 16 | Per-chat promote doesn't wait | With a long-running sibling, whole-trunk integrate is unavailable, yet a finished chat promotes; a mid-turn chat's own promote is refused. |
| 17 | Running summary as promote message | Per-turn folds accumulate a change summary; the whole-trunk promote commits `nvrsion: <summary>`; a successful promote clears the sidecar so the next message starts fresh; empty summary falls back to `nvrsion: promote trunk to <base>`. |

---

## 15. Open questions / future work

1. **Per-session containers.** v0 requires the shared container. Supporting `perSessionContainers`
   needs per-container trunk checkouts that sync to a host trunk per edit (the explore's "Option B");
   deferred.
2. **Read staleness of unlocked context.** Files an agent read for context (not locked) can move
   silently; only *edited* files re-ground. Acceptable for v0; a "context moved" advisory is possible
   later.
3. **Trunk history volume.** Commit-per-edit makes trunk history noisy; promotion squashes it for the
   real branch, but the trunk branch itself grows. Periodic `nucleic/trunk` reset after promotion is a
   candidate.
4. **Heavier pre-land gates.** Build/test before *landing* (not just promotion) is intentionally out
   of scope (latency); revisit if broken edits poisoning trunk proves painful in practice.
5. **Cross-trunk domains.** A project shipping to multiple bases could want multiple trunks; v0 is one
   trunk per project. Domain keying (§10) already allows generalizing later.
6. **iOS parity.** Surface trunk state + per-file warm-locks read-only over the sync protocol
   (additive wire fields), as the lock viewer does today.
7. **Auto per-session promotion.** Per-session promotion is now implemented as a user action (§6);
   *automatically* firing it the moment a chat finishes its turn (so finished chats ship hands-free
   while siblings keep working) is the remaining policy question — when to trigger, and how it
   interacts with the whole-trunk auto-integrate countdown.
