# 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. > **Amended 2026-07-21:** lock state moves into Carbon and is mesh-wide > ([CARBON_SHARDING.md](CARBON_SHARDING.md) §17). The nvrsion release path stays host-certain > *on the trunk host*; mesh-wide, each release is published to the Carbon lock ledger carrying > the trunk `history` head as its **release fence** — a remote device does not see a file as > released until it holds the landed trunk content via Carbon (§4 note; CARBON_SHARDING > §17.4). Carbon and Covalence are now mandatory features, so this interacts with nothing in > nvrsion's own Beta opt-in (§10), which is unchanged. --- ## 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 `/.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 -- `), 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// (the control base, RW-mounted into the ├── .git/ shared "nucleic-control" container) ├── .nucleic/ │ ├── worktrees// ← per-session worktrees (NON-nvrsion sessions, unchanged) │ └── trunk/ ← THE shared nvrsion checkout, branch `nucleic/trunk` └── ← 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 `). - 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 " (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). - **Mesh visibility is Carbon-fenced (2026-07-21).** Host-certainty above is about the *trunk host* — commit and release are one host-mediated act, with no detection lag. Mesh-wide, every land also advances the project's trunk `history` chain in Carbon (CARBON_SHARDING §3.2, emitted per land), and each release is published to the domain's lock ledger with that head as `requiredHeads` (CARBON_SHARDING §17.4): a remote device observes the file as released — and a session there could acquire it — only once it holds the landed trunk content via Carbon. On the trunk host the fence is trivially satisfied (it produced the head), so nothing in this section's timing changes locally. --- ## 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. Release *publication* + per-device mesh observation ride the Carbon lock ledger with the trunk `history` head as the fence (CARBON_SHARDING §17.4). | | `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 | | Remote device hasn't synced the landed trunk content | (its Carbon store lags) | that device still renders F held (`releasing — syncing files`); observes release once it holds the trunk `history` head (CARBON_SHARDING §17.4) | | 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*). Now Carbon-resident as `CarbonLockManager` (CARBON_SHARDING §17.7). | | `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` 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.