# 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 `/.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). --- ## 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` 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.