38 KiB
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),
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
- 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). - 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. - 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.
- No two sessions edit a file at once; the granted session always re-grounds. The
LockManagerexclusion (LOCKING §2.2) and thegrantedNeedsRegroundhandshake are unchanged and are what make trunk conflicts structurally impossible (§0.3). - 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.
- 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. - 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, usesSharedControlContainer;
SessionController.swift:284). 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 ─┘
createSessionfor an nvrsion project skips worktree creation and points the session'sworktreePathat the shared.nucleic/trunk(created lazily on the project's first nvrsion session viagit 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 → 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). 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,
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.
keepWarmIdleis 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
.fileChangefires (§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/trunkinto a checkout ofbase→ one clean commit on the real branch (trailerNucleic-Promote: 1), then a best-effort merge ofbaseback 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,AppStorefolds a small per-turn digest (the chat's goal + the files it landed) into the running summary viaIntelligenceProviding.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 tonvrsion: 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.baseedited 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 ontobase, which once required the user to have parked the project's primary checkout onbase— 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 onbase(the commit advancesbaseitself) and otherwise stands up a private worktree<repo>/.nucleic/promotedetached atbase, squashes there, and advances the realbaseref withgit update-ref. Detached so it never holds thebasebranch (the user can stillgit 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 (trailersNucleic-Promote: 1+Nucleic-Session: <S>). Concretely, in the samePromoteCheckoutofbasethe whole-trunk promote uses (the primary checkout if it's onbase, else the detached<repo>/.nucleic/promoteworktree): of the files where trunk differs from base, take those whose most-recent trunk commit carries this session's trailer, andgit 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 whosebaseside 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 againstbaseand 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) |
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). 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 onproject(migrationv20-nvrsion), holding theProjectNvrsionstruct (enabled,trunkBranch,prelandHook,keepWarmIdleSeconds). JSON-encoded likesandbox_config, so future fields need no new migration. (Phase A.)- Launch trunk-recovery (
NvrsionTrunk.recover, driven byAppStore.reconcileNvrsionTrunksafterreconstructLocks). 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.fileChangelanded it). On launch, for each nvrsion project,recoverensures the trunk exists and commits any uncommitted residue as aNucleic-Recovery: 1commit, 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_statetable 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 asnvrsion_configJSON (migrationv20-nvrsion), mirroringsandbox.Project.nvrsionActive: Bool— the single gate both spawn and teardown read:(and, likeisNucleicControlled && (nvrsion?.enabled ?? false) && usesSharedControlContainereffectiveSandbox, subordinate to the app-wide container-service master switch).- UI: a Beta-labeled toggle in
ProjectSettingsSheet(Sheets.swift), shown only for control projects, disabled (with an explanatory caption) whenperSessionContainersis on. OptionalkeepWarmIdle/prelandHookfields and a Promote trunk button. Plus an app-widenvrsionByDefaulttoggle in Settings → Control (SettingsView.swift) that seeds new Control projects with nvrsion on (mirrorscontrolByDefault/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 — soupdateProjectflips 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)
/// 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(theNvrsionReleaseGovernorsweep, 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
- Per-session containers. v0 requires the shared container. Supporting
perSessionContainersneeds per-container trunk checkouts that sync to a host trunk per edit (the explore's "Option B"); deferred. - 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.
- 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/trunkreset after promotion is a candidate. - 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.
- 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.
- iOS parity. Surface trunk state + per-file warm-locks read-only over the sync protocol (additive wire fields), as the lock viewer does today.
- 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.