import Foundation
import Testing

@testable import NucleicCore

private struct FakeIntelligence: IntelligenceProviding {
    func summarize(session: Session, events: [AgentEvent]) async -> String { "FAKE SUMMARY" }
    func sessionName(fromFirstMessage message: String) async -> String? { "Title: \(message)" }
}

private final class CountingIntelligence: IntelligenceProviding, @unchecked Sendable {
    let calls = LockedBox(0)
    func summarize(session: Session, events: [AgentEvent]) async -> String {
        calls.set(calls.get() + 1)
        return "SUMMARY \(calls.get())"
    }
    func sessionName(fromFirstMessage message: String) async -> String? { nil }
}

/// Exercises the Bash map→reduce path: each command is gisted (map), then the gist list is
/// merged into one line (reduce). The distinctive shapes let a test prove the per-command
/// gists flow through the merge as a list rather than the raw commands being re-summarized.
private struct MapReduceIntelligence: IntelligenceProviding {
    func summarize(session: Session, events: [AgentEvent]) async -> String { "" }
    func sessionName(fromFirstMessage message: String) async -> String? { nil }
    func summarizeBashCommand(_ command: String) async -> String { "gist(\(command))" }
    func mergeBashSummaries(_ summaries: [String]) async -> String {
        "MERGED[" + summaries.joined(separator: " | ") + "]"
    }
}

/// A one-shot gate: `wait()` suspends until `open()` (and returns immediately once open).
private actor TestGate {
    private var opened = false
    private var waiters: [CheckedContinuation<Void, Never>] = []
    func wait() async {
        if opened { return }
        await withCheckedContinuation { waiters.append($0) }
    }
    func open() {
        opened = true
        for waiter in waiters { waiter.resume() }
        waiters.removeAll()
    }
}

/// Counts group-gist passes STARTED, per group, and holds each in flight on a gate — so a
/// test can keep a pass running while it triggers more regenerations, the window in which the
/// duplicate-enqueue bug fired.
private final class GatedGistIntelligence: IntelligenceProviding, @unchecked Sendable {
    private let starts = LockedBox([String: Int]())
    private let gate = TestGate()
    func summarize(session: Session, events: [AgentEvent]) async -> String { "" }
    func sessionName(fromFirstMessage message: String) async -> String? { nil }
    func summarizeTodos(_ items: [String], group: String) async -> String {
        var counts = starts.get(); counts[group, default: 0] += 1; starts.set(counts)
        await gate.wait()
        return "GIST(\(group))"
    }
    func startCount(_ group: String) -> Int { starts.get()[group, default: 0] }
    func release() async { await gate.open() }
}

/// Counts group-gist passes per group (completing each immediately) and triages every group
/// into REVERSE capture order — so a group of ≥2 ideas flips its display order the moment
/// triage lands. Lets a test prove the gist's de-dup fingerprint ignores a pure reorder of an
/// otherwise-unchanged idea set.
private final class ReorderingGistIntelligence: IntelligenceProviding, @unchecked Sendable {
    private let starts = LockedBox([String: Int]())
    func summarize(session: Session, events: [AgentEvent]) async -> String { "" }
    func sessionName(fromFirstMessage message: String) async -> String? { nil }
    func summarizeTodos(_ items: [String], group: String) async -> String {
        var counts = starts.get(); counts[group, default: 0] += 1; starts.set(counts)
        return "GIST(\(group))"
    }
    func triageTodos(_ items: [TriageInput], group: String) async -> [TriageItem] {
        // Rank last-captured first (rank 0 = top) so a ≥2-idea group's display order flips.
        let n = items.count
        return items.enumerated().map {
            TriageItem(id: $0.element.id, level: .medium, rank: n - 1 - $0.offset, reason: nil)
        }
    }
    func startCount(_ group: String) -> Int { starts.get()[group, default: 0] }
}

/// Returns a fixed trunk-change summary regardless of input, so a test can prove the whole-trunk
/// promote commits the running summary as its message (rather than the legacy fixed line).
private struct FixedTrunkSummaryIntelligence: IntelligenceProviding {
    func summarize(session: Session, events: [AgentEvent]) async -> String { "" }
    func sessionName(fromFirstMessage message: String) async -> String? { nil }
    func updateTrunkSummary(previous: String, with digest: String) async -> String {
        "Add the made.txt fixture"
    }
}

@MainActor
// Serialized: every test here drives an @MainActor AppStore and polls async state via
// `waitFor`, so they can't truly run in parallel anyway — parallel scheduling only adds
// main-actor contention that stretches turn pipelines past the poll timeouts and flakes
// the timing-based assertions.
@Suite("AppStore — object-graph root", .serialized)
struct AppStoreTests {
    private func makeStore(
        repo: GitTestRepo,
        now: @escaping @Sendable () -> Date = { Date(timeIntervalSince1970: 1_700_000_000) }
    ) -> AppStore {
        let database = try! GRDBMetadataStore(path: nil)
        let worktrees = GitWorktreeManager(now: now)
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        return AppStore(
            database: database, worktrees: worktrees, transcriptsDir: transcriptsDir,
            now: now
        ) { session in
            // A scripted agent that writes a file into its worktree, then finishes.
            let wtPath = session.worktreePath ?? ""
            return ScriptedBackend { e, _ in
                e.emit(.sessionStarted(SessionStarted(
                    backendSessionID: "be-app", model: "m", cwd: wtPath, toolNames: ["Write"])))
                let file = (wtPath as NSString).appendingPathComponent("made.txt")
                try? "by agent\n".write(toFile: file, atomically: true, encoding: .utf8)
                e.emit(.fileChange(FileChange(path: "made.txt", kind: .add)))
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            }
        }
    }

    private func waitFor(_ condition: @escaping () -> Bool, timeoutMs: Int = 3000) async {
        var elapsed = 0
        while !condition() && elapsed < timeoutMs {
            try? await Task.sleep(for: .milliseconds(10))
            elapsed += 10
        }
    }

    /// Async analogue of `waitFor`, for conditions that must hop to an actor (e.g. the
    /// `lockQueueSnapshot()` accessor) and so can't run in `waitFor`'s synchronous closure.
    private func waitForAsync(_ condition: @escaping () async -> Bool, timeoutMs: Int = 3000) async {
        var elapsed = 0
        while await !condition() && elapsed < timeoutMs {
            try? await Task.sleep(for: .milliseconds(10))
            elapsed += 10
        }
    }

    /// Autoship transcript notes (e.g. "merging into main…", "merged into main · abc1234")
    /// in event order — the `Autoship:`-prefixed notes `handleShipUpdate` logs.
    private func autoshipNotes(_ events: [AgentEvent]) -> [String] {
        events.compactMap {
            if case .note(let n) = $0.kind, n.isAutoship { return n.text } else { return nil }
        }
    }

    @Test func createSessionRunsAndSummarizes() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)

        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")
        let projectID = try #require(project?.id)

        let sessionID = try await store.createSession(
            in: store.project(projectID)!, title: "make a file", prompt: "go")
        store.openSessionID = sessionID

        // Wait for the run to be fully observed (terminal event ingested into the
        // transcript), then settle the live diffstat deterministically (the in-stream
        // refresh is best-effort and can be skipped under heavy parallel git load).
        await store.awaitOpenSessionSettled()
        await store.refreshOpenStatus()

        let summary = try #require(store.summaries.first)
        #expect(summary.title == "make a file")
        #expect(summary.status == .awaitingInput)  // conversational: ready for next message
        #expect((summary.diffStat?.filesChanged ?? 0) >= 1)
        #expect(store.summaries(for: projectID).count == 1)

        // Open transcript was populated for the selected session.
        #expect(!store.openTranscript.isEmpty)
        #expect(store.openTranscript.contains {
            if case .runFinished = $0.kind { return true } else { return false }
        })
        // The agent's file is in the worktree.
        #expect(FileManager.default.fileExists(
            atPath: (repo.root as NSString)
                .appendingPathComponent(".nucleic/worktrees/make-a-file/made.txt")))
    }

    // MARK: - nvrsion (NVRSION) — shared-trunk mode, end to end

    /// Turn a control project into an nvrsion project and return the refreshed `Project`.
    private func enableNvrsion(_ store: AppStore, _ project: Project) async -> Project {
        var p = project
        p.nvrsion = ProjectNvrsion(enabled: true)
        await store.updateProject(p)
        return store.project(project.id) ?? p
    }

    @Test func nvrsionLandsEditOnTrunkAndMakesNoPerSessionWorktree() async throws {
        let repo = try await GitTestRepo(controlled: true)  // nvrsion is Control-only (NVRSION §10)
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
        let project = await enableNvrsion(store, created)
        #expect(project.nvrsionActive)

        let session = try await store.createSession(in: project, title: "make a file", prompt: "go")
        store.openSessionID = session
        await store.awaitOpenSessionSettled()

        let trunkPath = project.resolvedTrunkPath
        // The scripted edit (made.txt) landed on `nucleic/trunk` as a path-scoped, attributed commit.
        let names = try await repo.run(["show", "--name-only", "--format=", "HEAD"], in: trunkPath).stdout
        #expect(names.contains("made.txt"))
        // The land is logged into the session's chat (nvrsion's in-chat ops log, the analog of
        // autoship's commit/merge notes), tagged with the shared nvrsion prefix.
        #expect(store.openTranscript.contains {
            if case .note(let n) = $0.kind { return n.isNvrsion && n.text.contains("landed made.txt") }
            return false
        })
        let body = try await repo.run(["log", "-1", "--format=%B", "HEAD"], in: trunkPath).stdout
        #expect(body.contains("Nucleic-Session: \(session.rawValue)"))
        // No isolated per-session worktree/branch — only the shared trunk branch exists.
        let branches = try await repo.run(["branch", "--list"]).stdout
        #expect(branches.contains("nucleic/trunk"))
        #expect(!branches.contains("make-a-file"))
        // And no per-session worktree directory was created under .nucleic/worktrees.
        #expect(!FileManager.default.fileExists(
            atPath: (repo.root as NSString).appendingPathComponent(".nucleic/worktrees/make-a-file")))
    }

    @Test func nvrsionSessionsShareTheOneTrunkLockDomain() async throws {
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.lockReconcileInterval = .milliseconds(20)
        let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
        let project = await enableNvrsion(store, created)

        let a = try await store.createSession(in: project, title: "alpha", prompt: "go")
        let b = try await store.createSession(in: project, title: "bravo", prompt: "go")
        await waitFor {
            store.summaries.first { $0.id == a }?.status == .awaitingInput
                && store.summaries.first { $0.id == b }?.status == .awaitingInput
        }

        // A acquires shared.txt. Because every nvrsion session shares ONE lock domain (the trunk),
        // B asking for the same file must queue — not proceed independently.
        #expect(await store.arbitrate(sessionID: a, task: "edit", files: ["shared.txt"]).resolution == .proceed)
        let arbitration = Task { await store.arbitrate(sessionID: b, task: "edit", files: ["shared.txt"]) }
        await waitFor { store.sessionsWaitingForAccess.contains(b) }
        #expect(store.sessionsWaitingForAccess.contains(b))

        // Releasing A grants B — proving they contended in the same domain.
        await store.forceReleaseLocks(a)
        #expect(await arbitration.value.resolution == .proceed)
    }

    @Test func nvrsionKeepWarmSweepReleasesAnIdleHeldLock() async throws {
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        let clock = MutableClock(Date(timeIntervalSince1970: 1_700_000_000))
        let store = makeStore(repo: repo, now: clock.now)
        let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
        let project = await enableNvrsion(store, created)

        // A session that doesn't run (empty prompt) — so no turn-end clears the warm set under us.
        let a = try await store.createSession(in: project, title: "alpha", prompt: "")
        // It holds shared.txt (acquired via arbitrate); mark it warm with a 1s window, as a land would.
        #expect(await store.arbitrate(sessionID: a, task: "edit", files: ["shared.txt"]).resolution == .proceed)
        await store.nvrsionGovernor.warmed(a, "shared.txt", idleSeconds: 1)

        // Before the window elapses, the sweep keeps the lock held (keep-warm).
        #expect(await store.runNvrsionSweep() == false)
        let held = await store.lockQueueSnapshot()
        #expect(held.files.contains { $0.path == "shared.txt" && $0.holders.contains { $0.sessionID == a } })

        // Past the window, the sweep releases it without waiting for the turn to end (NVRSION §4).
        clock.advance(2)
        #expect(await store.runNvrsionSweep() == true)
        let freed = await store.lockQueueSnapshot()
        #expect(!freed.files.contains { $0.path == "shared.txt" })
    }

    @Test func nvrsionPromoteShipsTrunkWorkToTheRealBranch() async throws {
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
        let project = await enableNvrsion(store, created)

        // Nothing landed yet → the sidebar Integrate button rests at its checkmark.
        #expect(store.nvrsionHasPendingIntegration(project.id) == false)

        // A scripted session lands made.txt on the trunk.
        let session = try await store.createSession(in: project, title: "alpha", prompt: "go")
        store.openSessionID = session
        await store.awaitOpenSessionSettled()

        // The landed edit flips the button to its up-arrow — there's work to integrate.
        #expect(store.nvrsionHasPendingIntegration(project.id) == true)

        let mainBefore = try await repo.revParse("refs/heads/main")
        // Promote → the real branch (main) gets the trunk's work as one squashed commit.
        #expect(await store.promoteNvrsionTrunk(project.id) == true)
        #expect(try await repo.revParse("refs/heads/main") != mainBefore)
        #expect(repo.read("made.txt", in: repo.root) == "by agent\n")
        // And once promoted, it settles back to the checkmark — nothing left to integrate.
        #expect(store.nvrsionHasPendingIntegration(project.id) == false)

        // Nothing new on the trunk now → a second promote reports nothing-to-promote.
        #expect(await store.promoteNvrsionTrunk(project.id) == false)
        #expect(store.lastError?.contains("Nothing on the nvrsion trunk") == true)
    }

    @Test func nvrsionPromoteCommitMessageIsTheRunningChangeSummary() async throws {
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.intelligence = FixedTrunkSummaryIntelligence()  // deterministic per-turn fold
        let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
        let project = await enableNvrsion(store, created)

        // A scripted session lands made.txt; at turn-end its contribution folds into the trunk's
        // running change summary (here the stub returns a fixed line).
        let session = try await store.createSession(in: project, title: "alpha", prompt: "go")
        store.openSessionID = session
        await store.awaitOpenSessionSettled()

        // Promote captions the squashed commit with the running summary as `nvrsion: <summary>` —
        // not the legacy fixed "promote trunk to main" line.
        #expect(await store.promoteNvrsionTrunk(project.id) == true)
        let body = try await repo.run(["log", "-1", "--format=%B", "main"]).stdout
        #expect(body.contains("nvrsion: Add the made.txt fixture"))
        #expect(!body.contains("promote trunk to main"))
    }

    @Test func nvrsionPromoteMessageFormatsSummaryAndFallsBack() {
        // A summary becomes the `nvrsion: <summary>` subject, with internal newlines collapsed.
        #expect(AppStore.nvrsionPromoteMessage(summary: "Add a\n  Fix b", base: "main")
            == "nvrsion: Add a Fix b")
        // An empty/blank summary falls back to the legacy fixed line.
        #expect(AppStore.nvrsionPromoteMessage(summary: "   ", base: "main")
            == "nvrsion: promote trunk to main")
    }

    @Test func nvrsionTrunkAutoIntegratesOnceTheProjectFallsQuiet() async throws {
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
        let project = await enableNvrsion(store, created)

        // A scripted session lands made.txt on the trunk and then finishes its turn.
        let session = try await store.createSession(in: project, title: "alpha", prompt: "go")
        store.openSessionID = session
        await store.awaitOpenSessionSettled()

        // There's work to integrate and no chat is mid-turn → integration is available, so the
        // auto-integrate countdown is armed (we don't wait out the real 3 s — we fire it directly).
        #expect(store.nvrsionHasPendingIntegration(project.id) == true)
        #expect(store.nvrsionIntegrationAvailable(project.id) == true)
        #expect(store.nvrsionAutoIntegrationArmed(project.id) == true)

        // The countdown elapses → it promotes on its own, exactly as the manual button would.
        let mainBefore = try await repo.revParse("refs/heads/main")
        await store.fireNvrsionAutoIntegration(project.id)
        #expect(try await repo.revParse("refs/heads/main") != mainBefore)
        #expect(repo.read("made.txt", in: repo.root) == "by agent\n")
        // Promoted → nothing left to integrate and the countdown is stood down.
        #expect(store.nvrsionHasPendingIntegration(project.id) == false)
        #expect(store.nvrsionIntegrationAvailable(project.id) == false)
        #expect(store.nvrsionAutoIntegrationArmed(project.id) == false)
    }

    @Test func nvrsionAutoIntegrateFlagsAConflictAndStandsDown() async throws {
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
        let project = await enableNvrsion(store, created)

        // A scripted session lands made.txt="by agent\n" on the trunk and finishes.
        let session = try await store.createSession(in: project, title: "alpha", prompt: "go")
        store.openSessionID = session
        await store.awaitOpenSessionSettled()
        #expect(store.nvrsionHasPendingIntegration(project.id) == true)

        // Meanwhile the real branch grows a *different* made.txt — so squashing the trunk into main
        // will conflict add/add.
        try repo.write("made.txt", "hand edit\n")
        try await repo.run(["add", "made.txt"])
        try await repo.run(["-c", "commit.gpgsign=false", "commit", "-m", "hand edit"])
        let mainConflicted = try await repo.revParse("refs/heads/main")

        // The countdown fires → the promote conflicts. Integration couldn't run automatically, so the
        // project is flagged (sidebar shows a red ✗), main is untouched, and the work still pends.
        await store.fireNvrsionAutoIntegration(project.id)
        #expect(store.nvrsionIntegrationFailure(project.id)?.contains("conflicted") == true)
        #expect(try await repo.revParse("refs/heads/main") == mainConflicted)  // promote rolled back
        #expect(store.nvrsionHasPendingIntegration(project.id) == true)

        // While flagged the countdown stands down — it must not auto-retry a wedged promote.
        await store.refreshNvrsionPending(project.id)
        #expect(store.nvrsionAutoIntegrationArmed(project.id) == false)

        // Resolve the conflict on the base branch (drop the hand edit), then a manual retry succeeds
        // and clears the flag — the sidebar drops the red ✗.
        try await repo.run(["reset", "--hard", "HEAD~1"])
        #expect(await store.promoteNvrsionTrunk(project.id) == true)
        #expect(store.nvrsionIntegrationFailure(project.id) == nil)
        #expect(store.nvrsionHasPendingIntegration(project.id) == false)
        #expect(repo.read("made.txt", in: repo.root) == "by agent\n")
    }

    @Test func nvrsionAutoIntegrateStaysDownWithNothingToIntegrate() async throws {
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
        let project = await enableNvrsion(store, created)

        // A quiet project with nothing landed on its trunk is not integratable, so the countdown
        // never arms and firing it is a harmless no-op (the gate the running-chat case also relies on).
        #expect(store.nvrsionHasPendingIntegration(project.id) == false)
        #expect(store.nvrsionIntegrationAvailable(project.id) == false)
        #expect(store.nvrsionAutoIntegrationArmed(project.id) == false)

        let mainBefore = try await repo.revParse("refs/heads/main")
        await store.fireNvrsionAutoIntegration(project.id)
        #expect(try await repo.revParse("refs/heads/main") == mainBefore)
    }

    @Test func nvrsionPrelandHookRejectsAnEditFromLanding() async throws {
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
        // Enable nvrsion with a pre-land hook that always fails.
        var p = created
        p.nvrsion = ProjectNvrsion(enabled: true, prelandHook: "exit 1")
        await store.updateProject(p)
        let project = try #require(store.project(created.id))
        #expect(project.nvrsionActive)

        let session = try await store.createSession(in: project, title: "alpha", prompt: "go")
        store.openSessionID = session
        await store.awaitOpenSessionSettled()

        // The scripted edit was rejected by the hook, so it never landed on the trunk.
        let trunkPath = project.resolvedTrunkPath
        let inTrunk = try await repo.run(["cat-file", "-e", "HEAD:made.txt"], in: trunkPath)
        #expect(inTrunk.status != 0)  // made.txt is not in the trunk's committed tree
        // And a rejection note was posted.
        #expect(store.openTranscript.contains {
            if case .note(let n) = $0.kind { return n.text.contains("pre-land check rejected") }
            return false
        })
    }

    /// A store whose scripted agent writes a file named after the chat's title (so two chats land
    /// *disjoint* files on the shared trunk), and — for any title in `slowTitles` — stays mid-turn
    /// (lands its file, then blocks without finishing) so a sibling can be promoted while it "works".
    private func makeStorePerSessionFileStore(
        repo: GitTestRepo, slowTitles: Set<String> = []
    ) -> AppStore {
        let database = try! GRDBMetadataStore(path: nil)
        let now: @Sendable () -> Date = { Date(timeIntervalSince1970: 1_700_000_000) }
        let worktrees = GitWorktreeManager(now: now)
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        return AppStore(
            database: database, worktrees: worktrees, transcriptsDir: transcriptsDir, now: now
        ) { session in
            let wtPath = session.worktreePath ?? ""
            let title = session.title
            let isSlow = slowTitles.contains(title)
            return ScriptedBackend { e, _ in
                e.emit(.sessionStarted(SessionStarted(
                    backendSessionID: "be-\(title)", model: "m", cwd: wtPath, toolNames: ["Write"])))
                let rel = "\(title).txt"
                try? "by \(title)\n".write(
                    toFile: (wtPath as NSString).appendingPathComponent(rel),
                    atomically: true, encoding: .utf8)
                e.emit(.fileChange(FileChange(path: rel, kind: .add)))
                if isSlow {
                    // Stay mid-turn: never emit turnCompleted/runFinished. Cancellation-aware sleep
                    // so shutdown unblocks it cleanly at teardown.
                    try? await Task.sleep(for: .seconds(3600))
                    return
                }
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            }
        }
    }

    @Test func nvrsionPromotesOneChatWithoutWaitingOnALongRunningSibling() async throws {
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        // bravo never finishes its turn — it stands in for the "very long session" the user shouldn't
        // have to wait on before integrating alpha's finished work.
        let store = makeStorePerSessionFileStore(repo: repo, slowTitles: ["bravo"])
        let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
        let project = await enableNvrsion(store, created)
        let trunkPath = project.resolvedTrunkPath

        // alpha lands alpha.txt and finishes its turn.
        let alpha = try await store.createSession(in: project, title: "alpha", prompt: "go")
        store.openSessionID = alpha
        await store.awaitOpenSessionSettled()

        // bravo lands bravo.txt but keeps working (mid-turn) — wait until its file is on the trunk.
        let bravo = try await store.createSession(in: project, title: "bravo", prompt: "go")
        await waitForAsync {
            ((try? await repo.run(["cat-file", "-e", "HEAD:bravo.txt"], in: trunkPath))?.status ?? 1) == 0
        }
        // A long-running sibling means the *whole-trunk* integrate is unavailable (it would squash
        // bravo's half-written work) — this is exactly the wait the per-chat promote removes.
        #expect(store.isAnySessionRunning(in: project.id) == true)
        #expect(store.nvrsionIntegrationAvailable(project.id) == false)

        let mainBefore = try await repo.revParse("refs/heads/main")
        // Promote ONLY alpha — it ships even though bravo is still working.
        #expect(await store.promoteNvrsionSession(alpha) == true)
        #expect(try await repo.revParse("refs/heads/main") != mainBefore)
        #expect(repo.read("alpha.txt", in: repo.root) == "by alpha\n")
        #expect(repo.read("bravo.txt", in: repo.root) == nil)   // bravo's work did NOT ship
        // bravo's landed work is untouched on the trunk, and its lock/keep-warm state is intact —
        // the per-chat promote never reset or rewrote the trunk branch.
        #expect(repo.read("bravo.txt", in: trunkPath) == "by bravo\n")
        #expect(store.isAnySessionRunning(in: project.id) == true)
        #expect(store.nvrsionHasPendingIntegration(project.id) == true)  // bravo still pending

        // Trying to promote bravo while it's mid-turn is refused (don't ship a half-written change),
        // without disturbing anything — the gate is on *this* chat's turn, not the project's.
        #expect(await store.promoteNvrsionSession(bravo) == false)
        #expect(store.lastError?.contains("still working") == true)
    }

    @Test func nvrsionPerChatPromoteReportsNothingWhenTheChatHasNoTrunkWork() async throws {
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        let store = makeStorePerSessionFileStore(repo: repo)
        let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
        let project = await enableNvrsion(store, created)

        // alpha lands work; a second, never-prompted chat lands nothing.
        let alpha = try await store.createSession(in: project, title: "alpha", prompt: "go")
        store.openSessionID = alpha
        await store.awaitOpenSessionSettled()
        let idle = try await store.createSession(in: project, title: "idle", prompt: "")

        // Promoting the idle chat ships nothing and says so; alpha's work stays pending.
        #expect(await store.promoteNvrsionSession(idle) == false)
        #expect(store.lastError?.contains("nothing on the nvrsion trunk") == true)
        #expect(store.nvrsionHasPendingIntegration(project.id) == true)
    }

    /// Regression: a session that writes a file *outside* the trunk worktree — e.g. a plan or
    /// memory file under the app-support `claude-home` dir — must NOT be landed. Agents report
    /// absolute paths, and one outside the trunk can't reduce to a repo-relative pathspec; handing
    /// it to `git add` drew `pathspec '…' did not match any file(s) known to git` and surfaced a
    /// spurious "nvrsion could not land" note. The out-of-trunk edit is now ignored, while a real
    /// in-trunk edit in the same turn still lands cleanly.
    @Test func nvrsionIgnoresEditsOutsideTheTrunkWorktree() async throws {
        let repo = try await GitTestRepo(controlled: true)  // nvrsion is Control-only (NVRSION §10)
        defer { repo.cleanup() }
        let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        // A claude-home-style plan path under app-support — absolute, but NOT under the trunk.
        let outsidePlan = (repo.container as NSString)
            .appendingPathComponent("claude-home/plans/tidy-plotting-yeti.md")
        let store = AppStore(
            database: try GRDBMetadataStore(path: nil), worktrees: worktrees,
            transcriptsDir: transcriptsDir,
            now: { Date(timeIntervalSince1970: 1_700_000_000) }
        ) { session in
            let wtPath = session.worktreePath ?? ""
            return ScriptedBackend { e, _ in
                e.emit(.sessionStarted(SessionStarted(
                    backendSessionID: "be", model: "m", cwd: wtPath, toolNames: ["Write"])))
                // A real in-trunk edit — lands.
                try? "by agent\n".write(
                    toFile: (wtPath as NSString).appendingPathComponent("made.txt"),
                    atomically: true, encoding: .utf8)
                e.emit(.fileChange(FileChange(path: "made.txt", kind: .add)))
                // The agent also writes a plan under claude-home (outside the trunk) — must be ignored.
                e.emit(.fileChange(FileChange(path: outsidePlan, kind: .add)))
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            }
        }
        let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
        let project = await enableNvrsion(store, created)
        #expect(project.nvrsionActive)

        let session = try await store.createSession(in: project, title: "make a file", prompt: "go")
        store.openSessionID = session
        await store.awaitOpenSessionSettled()

        let trunkPath = project.resolvedTrunkPath
        // The in-trunk edit landed as a path-scoped commit; the out-of-trunk plan did not.
        let names = try await repo.run(["show", "--name-only", "--format=", "HEAD"], in: trunkPath).stdout
        #expect(names.contains("made.txt"))
        #expect(!names.contains("tidy-plotting-yeti.md"))
        // And no spurious land-failure note was surfaced for the out-of-trunk write.
        #expect(!store.openTranscript.contains {
            if case .note(let n) = $0.kind { return n.text.contains("could not land") }
            return false
        })
    }

    @Test func nvrsionCanBeEnabledWithIdleChatsAndExistingSessionsKeepTheirMode() async throws {
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project0 = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))

        // A worktree-mode chat (nvrsion off) finishes its turn and rests at awaitingInput
        // ("complete"). It made its own isolated per-session worktree branch.
        let legacy = try await store.createSession(in: project0, title: "legacy", prompt: "go")
        await waitFor { store.summaries.first { $0.id == legacy }?.status == .awaitingInput }
        #expect(try await repo.run(["branch", "--list"]).stdout.contains("nucleic/legacy"))

        // Enabling nvrsion is ALLOWED despite the idle chat — there is no flip-safety block, because
        // a session's mode is fixed at creation (the user's reported bug: "complete" chats blocked it).
        let project = await enableNvrsion(store, project0)
        #expect(project.nvrsionActive)

        // A NEW chat uses the shared trunk; the existing chat keeps its worktree, so the two models
        // never mix on live work.
        let fresh = try await store.createSession(in: project, title: "fresh", prompt: "go")
        await waitFor { store.summaries.first { $0.id == fresh }?.status == .awaitingInput }
        let branches = try await repo.run(["branch", "--list"]).stdout
        #expect(branches.contains("nucleic/legacy"))   // legacy still on its own worktree branch
        #expect(branches.contains("nucleic/trunk"))    // fresh's edit landed on the shared trunk
        #expect(!branches.contains("nucleic/fresh"))   // fresh made NO per-session worktree
    }

    @Test func archivedWorktreeCleanupReclaimsPastThresholdThenRestoresOnUnarchive() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let t0 = Date(timeIntervalSince1970: 1_700_000_000)
        let clock = LockedBox(t0)
        let store = makeStore(repo: repo, now: { clock.get() })

        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")
        let projectID = try #require(project?.id)
        let sessionID = try await store.createSession(
            in: store.project(projectID)!, title: "make a file", prompt: "go")
        store.openSessionID = sessionID
        await store.awaitOpenSessionSettled()

        let wtPath = (repo.root as NSString).appendingPathComponent(".nucleic/worktrees/make-a-file")
        #expect(FileManager.default.fileExists(atPath: wtPath))

        store.archivedWorktreeCleanupInterval = 86_400  // one day
        await store.setSessionArchived(sessionID, true)

        // Just archived (archivedAt == now): under the threshold, so the worktree stays.
        await store.sweepArchivedWorktrees()
        #expect(FileManager.default.fileExists(atPath: wtPath))

        // Two days later it's past the threshold — the checkout is reclaimed, the branch kept.
        clock.set(t0.addingTimeInterval(2 * 86_400))
        await store.sweepArchivedWorktrees()
        #expect(!FileManager.default.fileExists(atPath: wtPath))
        let branch = try await repo.run(["rev-parse", "--verify", "--quiet", "refs/heads/nucleic/make-a-file"])
        #expect(branch.ok)

        // Unarchiving re-creates the worktree, with the agent's (WIP-committed) file restored.
        await store.setSessionArchived(sessionID, false)
        #expect(FileManager.default.fileExists(atPath: wtPath))
        #expect(FileManager.default.fileExists(
            atPath: (wtPath as NSString).appendingPathComponent("made.txt")))
    }

    @Test func archivedWorktreeCleanupDisabledByNeverSetting() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")
        let projectID = try #require(project?.id)
        let sessionID = try await store.createSession(
            in: store.project(projectID)!, title: "keep me", prompt: "go")
        store.openSessionID = sessionID
        await store.awaitOpenSessionSettled()

        let wtPath = (repo.root as NSString).appendingPathComponent(".nucleic/worktrees/keep-me")
        store.archivedWorktreeCleanupInterval = nil  // "Never"
        await store.setSessionArchived(sessionID, true)
        await store.sweepArchivedWorktrees()

        #expect(FileManager.default.fileExists(atPath: wtPath))  // nothing reclaimed
    }

    /// Builds a store whose scripted agent emits one finished tool call per `(id, name,
    /// input)`, all in a row, so they coalesce into a single group.
    private func storeEmitting(
        _ calls: [(id: String, name: String, input: JSONValue)],
        repo: GitTestRepo
    ) -> AppStore {
        let database = try! GRDBMetadataStore(path: nil)
        let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        // Default intelligence is HeuristicIntelligence — deterministic merged lines.
        return AppStore(
            database: database, worktrees: worktrees, transcriptsDir: transcriptsDir,
            now: { Date(timeIntervalSince1970: 1_700_000_000) }
        ) { session in
            let wtPath = session.worktreePath ?? ""
            return ScriptedBackend { e, _ in
                e.emit(.sessionStarted(SessionStarted(
                    backendSessionID: "be", model: "m", cwd: wtPath, toolNames: [])))
                for spec in calls {
                    let call = ToolCall(toolCallID: spec.id, name: spec.name, input: spec.input)
                    e.emit(.toolCallStarted(call))
                    e.emit(.toolCallCompleted(call))
                    e.emit(.toolResult(ToolResult(toolCallID: spec.id, content: "ok", isError: false)))
                }
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            }
        }
    }

    private func openSettled(_ store: AppStore, repo: GitTestRepo, title: String) async throws {
        let project = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
        let sessionID = try await store.createSession(
            in: store.project(project.id)!, title: title, prompt: "go")
        store.openSessionID = sessionID
        await store.awaitOpenSessionSettled()
    }

    @Test func consecutiveReadsCoalesceButAreNeverAISummarized() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = storeEmitting([
            ("t1", "Read", ["file_path": .string("A.swift")]),
            ("t2", "Read", ["file_path": .string("B.swift")]),
            ("t3", "Read", ["file_path": .string("C.swift")]),
        ], repo: repo)
        try await openSettled(store, repo: repo, title: "reads")

        // The three reads coalesce into one group, but Read is never AI-summarized, so no
        // summary is cached — the block renders the deterministic heuristic merge directly.
        let group = try #require(TranscriptProjection.toolGroups(store.openTranscript).first)
        #expect(group.calls.count == 3)
        #expect(store.bashBlockLine("t1") == nil)  // not a Bash block
        let familySignature = TranscriptProjection.toolGroupSignature(group.calls)
        #expect(store.toolFamilyLines(familySignature) == nil)  // Read is never AI-summarized
        #expect(HeuristicSummary.toolGroupLines(group.calls) == ["Read A.swift, B.swift, C.swift"])
    }

    @Test func consecutiveBashCallsGetACachedMergedSummary() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = storeEmitting([
            ("t1", "Bash", ["command": .string("swift build")]),
            ("t2", "Bash", ["command": .string("swift test")]),
        ], repo: repo)
        try await openSettled(store, repo: repo, title: "builds")

        // A Bash run IS AI-eligible: each command is gisted (map) and the block's gist list
        // is merged into one live line (reduce), keyed by the block's stable id (the first
        // call's). HeuristicIntelligence backs it here, producing the deterministic line.
        let group = try #require(TranscriptProjection.toolGroups(store.openTranscript).first)
        #expect(group.calls.count == 2)
        let line = try #require(store.bashBlockLine("t1"))
        #expect(line == "Ran swift build, swift test")
    }

    @Test func loneBashCallGetsACachedSummaryJustLikeAGroup() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        // A single Bash call doesn't coalesce into a group (it stays a lone `.tool`), but it
        // is summarized exactly as a one-call group would be — keyed by its own id — so the
        // standalone row shows the model line, not a raw "Bash <command>".
        let store = storeEmitting([
            ("t1", "Bash", ["command": .string("swift build")]),
        ], repo: repo)
        try await openSettled(store, repo: repo, title: "build")

        // It is NOT a group (no run of ≥2)…
        #expect(TranscriptProjection.toolGroups(store.openTranscript).isEmpty)
        // …yet it IS a summarizable unit, and gets a cached line keyed by its id.
        #expect(TranscriptProjection.summarizableUnits(store.openTranscript).count == 1)
        #expect(store.bashBlockLine("t1") == "Ran swift build")
    }

    @Test func bashBlockLineIsTheReduceOverPerCommandGists() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = storeEmitting([
            ("t1", "Bash", ["command": .string("swift build")]),
            ("t2", "Bash", ["command": .string("swift test")]),
        ], repo: repo)
        store.intelligence = MapReduceIntelligence()
        try await openSettled(store, repo: repo, title: "builds")

        // The block's line is the reduce over the per-command gists (map), in order — proving
        // the additive list-of-summaries is what gets merged, not the raw commands.
        #expect(store.bashBlockLine("t1") == "MERGED[gist(swift build) | gist(swift test)]")
    }

    @Test func bashBlockLineGrowsAdditivelyAsCommandsLand() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = storeEmitting([
            ("t1", "Bash", ["command": .string("git add -A")]),
            ("t2", "Bash", ["command": .string("git commit -m x")]),
            ("t3", "Bash", ["command": .string("git push")]),
        ], repo: repo)
        store.intelligence = MapReduceIntelligence()
        try await openSettled(store, repo: repo, title: "ship")

        // Every command that landed is folded into the one block line, in run order.
        #expect(store.bashBlockLine("t1")
            == "MERGED[gist(git add -A) | gist(git commit -m x) | gist(git push)]")
    }

    @Test func openingAChatFromAPriorRunSkipsAISummaryOfItsHistory() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let dbPath = (repo.container as NSString).appendingPathComponent("nucleic.sqlite")
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        let makeBackend: @Sendable (Session) -> any AgentBackend = { session in
            let wtPath = session.worktreePath ?? ""
            return ScriptedBackend { e, _ in
                e.emit(.sessionStarted(SessionStarted(
                    backendSessionID: "be", model: "m", cwd: wtPath, toolNames: [])))
                for (id, cmd) in [("t1", "swift build"), ("t2", "swift test")] {
                    let call = ToolCall(toolCallID: id, name: "Bash", input: ["command": .string(cmd)])
                    e.emit(.toolCallStarted(call))
                    e.emit(.toolCallCompleted(call))
                    e.emit(.toolResult(ToolResult(toolCallID: id, content: "ok", isError: false)))
                }
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            }
        }

        // First launch: a chat created + run this session has only live activity, so its
        // Bash group IS summarized.
        let sessionID: SessionID
        do {
            let db = try GRDBMetadataStore(path: dbPath)
            let store = AppStore(
                database: db, worktrees: worktrees, transcriptsDir: transcriptsDir,
                now: { Date(timeIntervalSince1970: 1_700_000_000) }, backendFactory: makeBackend)
            let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
            sessionID = try await store.createSession(in: project, title: "builds", prompt: "go")
            store.openSessionID = sessionID
            await store.awaitOpenSessionSettled()
            _ = try #require(TranscriptProjection.toolGroups(store.openTranscript).first)
            #expect(store.bashBlockLine("t1") != nil)  // live activity → summarized
        }

        // Second launch: a fresh store over the same DB + transcripts. The chat is now
        // pre-existing history, so opening it must NOT AI-summarize its tool groups.
        let db2 = try GRDBMetadataStore(path: dbPath)
        let store2 = AppStore(
            database: db2, worktrees: worktrees, transcriptsDir: transcriptsDir,
            now: { Date(timeIntervalSince1970: 1_700_000_000) }, backendFactory: makeBackend)
        // A distinguishable AI backend: had history been AI-summarized, the line would be
        // "MERGED[…]" rather than the deterministic heuristic.
        store2.intelligence = MapReduceIntelligence()
        await store2.loadProjects()
        await store2.loadSessions()
        store2.openSessionID = sessionID
        await waitFor { !store2.openTranscript.isEmpty }
        let group = try #require(TranscriptProjection.toolGroups(store2.openTranscript).first)
        #expect(group.calls.count == 2)
        // History fires no AI work, but it IS seeded with its heuristic line up front, so the
        // view shows a finished line immediately instead of a placeholder that would otherwise
        // spin forever waiting on an AI summary that's never generated.
        #expect(store2.bashBlockLine("t1") == "Ran swift build, swift test")
    }

    @Test func todoDispatchSpawnsAndOpensSession() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        // Capture an unassigned idea.
        let todo = try #require(await store.addTodo("make a file"))
        #expect(store.openTodos.count == 1)
        #expect(todo.projectID == nil)

        // Dispatch it into the project.
        let sessionID = try #require(await store.dispatchTodo(todo.id, in: project))
        #expect(store.openSessionID == sessionID)

        // The idea leaves the open list, now tagged + linked to the run.
        #expect(store.openTodos.isEmpty)
        let dispatched = try #require(store.todos.first { $0.id == todo.id })
        #expect(dispatched.status == .dispatched)
        #expect(dispatched.projectID == project.id)
        #expect(dispatched.dispatchedSessionID == sessionID)

        // The agent actually ran for the dispatched session.
        await waitFor { store.summaries.first { $0.id == sessionID }?.status == .awaitingInput }
        #expect(store.summaries.contains { $0.id == sessionID })

        // Persisted across a reload.
        await store.loadTodos()
        #expect(store.todos.first { $0.id == todo.id }?.status == .dispatched)
    }

    @Test func todoEditDeleteAndComplete() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        let a = try #require(await store.addTodo("first"))
        let b = try #require(await store.addTodo("second"))
        #expect(store.openTodos.count == 2)

        // Blank text is ignored.
        #expect(await store.addTodo("   ") == nil)
        #expect(store.openTodos.count == 2)

        await store.updateTodo(a.id, text: "edited", projectID: project.id)
        #expect(store.todos.first { $0.id == a.id }?.text == "edited")
        #expect(store.todos.first { $0.id == a.id }?.projectID == project.id)

        await store.setTodoStatus(b.id, .done)
        #expect(store.openTodos.map(\.id) == [a.id])  // done drops from the open list

        await store.deleteTodo(a.id)
        #expect(store.todos.contains { $0.id == a.id } == false)
    }

    @Test func longTodoGetsItemSummaryShortDoesNot() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)

        let long = try #require(await store.addTodo(
            "refactor the authentication module so it issues short-lived tokens and refreshes them automatically before they expire"))
        let short = try #require(await store.addTodo("fix typo"))

        // Per-item summaries are generated asynchronously (heuristic, no model here).
        await waitFor {
            store.todos.first { $0.id == long.id }?.summary != nil
                && store.todos.first { $0.id == short.id }?.summary != nil
        }
        let longTodo = try #require(store.todos.first { $0.id == long.id })
        let shortTodo = try #require(store.todos.first { $0.id == short.id })
        #expect(longTodo.summary?.isEmpty == false)      // long → a distinct summary
        #expect(longTodo.summary != longTodo.text)
        #expect(shortTodo.summary == "")                 // short → no distinct summary

        // Persisted: a reload keeps the generated summary (no re-summarize needed).
        await store.loadTodos()
        #expect(store.todos.first { $0.id == long.id }?.summary?.isEmpty == false)
        #expect(store.todos.first { $0.id == short.id }?.summary == "")
    }

    @Test func openTodosSortedOldestFirst() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let clock = LockedBox(Date(timeIntervalSince1970: 1_700_000_000))
        let store = AppStore(
            database: try! GRDBMetadataStore(path: nil),
            worktrees: GitWorktreeManager(),
            transcriptsDir: URL(fileURLWithPath: (repo.container as NSString).appendingPathComponent("t")),
            now: { let date = clock.get(); clock.set(date.addingTimeInterval(1)); return date }
        ) { _ in ScriptedBackend { _, _ in } }

        let first = try #require(await store.addTodo("first in"))
        let second = try #require(await store.addTodo("second in"))
        let third = try #require(await store.addTodo("third in"))
        // First-in at the top.
        #expect(store.openTodos.map(\.id) == [first.id, second.id, third.id])

        // Grouping preserves oldest-first within a group.
        let project = await store.addProject(name: "p", rootPath: repo.root, defaultBranch: "main")!
        await store.updateTodo(third.id, projectID: project.id)
        await store.updateTodo(first.id, projectID: project.id)
        #expect(store.todoGroups.first?.todos.map(\.id) == [first.id, third.id])
    }

    @Test func triageRanksEachProjectIndependently() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let alpha = try #require(await store.addProject(
            name: "alpha", rootPath: repo.root, defaultBranch: "main"))

        // Two buckets: a project, and the unassigned group. Each gets a clearly-urgent and a
        // clearly-calm idea.
        let urgentA = try #require(await store.addTodo(
            "production outage: users hit a crash, data loss", projectID: alpha.id))
        let calmA = try #require(await store.addTodo("rename a variable", projectID: alpha.id))
        let urgentU = try #require(await store.addTodo(
            "production outage: users hit a crash, data loss"))  // unassigned
        let calmU = try #require(await store.addTodo("rename a variable"))  // unassigned

        // Each bucket is triaged on its own, so each has its OWN rank-0 top idea — proof the
        // passes are per-project, not one global ranking (which would assign four unique
        // ranks 0…3 with a single rank-0 across the whole backlog).
        await waitFor {
            !store.triaging
                && store.triageItem(for: urgentA.id)?.rank == 0
                && store.triageItem(for: urgentU.id)?.rank == 0
        }
        #expect(store.triageItem(for: calmA.id)?.rank == 1)
        #expect(store.triageItem(for: calmU.id)?.rank == 1)
        #expect(store.triageLevel(for: urgentA.id) == .critical)
        #expect(store.triageLevel(for: urgentU.id) == .critical)
    }

    @Test func groupGistIsNotReEnqueuedWhileInFlight() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let gist = GatedGistIntelligence()
        store.intelligence = gist
        let alpha = try #require(await store.addProject(
            name: "alpha", rootPath: repo.root, defaultBranch: "main"))

        // Add an idea to project alpha: its group-gist pass starts and blocks in flight (the
        // gate is never opened until the end), so its summary hasn't landed yet.
        _ = try #require(await store.addTodo("refactor the auth module to use tokens", projectID: alpha.id))
        await waitFor { gist.startCount(alpha.id.rawValue) == 1 }

        // Add an idea to a DIFFERENT group (unassigned). This re-runs regenerateTodoSummaries
        // while alpha's gist is still in flight. alpha's ideas are unchanged, so it must NOT
        // enqueue a second identical pass; the unassigned group legitimately starts its own.
        _ = try #require(await store.addTodo("an unfiled stray idea"))
        await waitFor { gist.startCount("") == 1 }
        // Let any (buggy) duplicate alpha pass start before asserting it didn't.
        try? await Task.sleep(for: .milliseconds(50))

        // The fix: alpha's gist ran exactly once despite the second regeneration. Before the
        // fix, the in-flight window re-enqueued an identical pass and this was 2 — wasted
        // on-device model work and the duplicate history entries the user saw.
        #expect(gist.startCount(alpha.id.rawValue) == 1)

        await gist.release()  // let the in-flight passes finish so nothing dangles
    }

    @Test func groupGistIgnoresPureTriageReorder() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        // Distinct, increasing timestamps so capture order is deterministic ([a, b]) and the
        // triage reorder ([b, a]) is a real flip — not a sort tie on equal `createdAt`.
        let tick = LockedBox(0)
        let store = makeStore(repo: repo) {
            let v = tick.get(); tick.set(v + 1)
            return Date(timeIntervalSince1970: 1_700_000_000 + Double(v))
        }
        let intel = ReorderingGistIntelligence()
        store.intelligence = intel
        store.enableTriageSort()  // display order follows triage rank, so a reorder bites

        let alpha = try #require(await store.addProject(
            name: "alpha", rootPath: repo.root, defaultBranch: "main"))

        // Two ideas in alpha. Each add re-gists the (genuinely changed) set, so alpha's gist
        // runs exactly twice — that's the baseline the reorder must not push past.
        let a = try #require(await store.addTodo("refactor the auth module", projectID: alpha.id))
        let b = try #require(await store.addTodo("rename the session token", projectID: alpha.id))
        await waitFor { intel.startCount(alpha.id.rawValue) == 2 }

        // Let triage flip alpha's display order to [b, a] (last-captured ranked top).
        await waitFor {
            store.triageItem(for: b.id)?.rank == 0 && store.triageItem(for: a.id)?.rank == 1
        }

        // Touch a DIFFERENT group (unassigned). This regenerates every group's gist — alpha's
        // ideas are UNCHANGED, only reordered, so it must NOT re-gist.
        _ = try #require(await store.addTodo("an unfiled stray idea"))
        await waitFor { intel.startCount("") == 1 }
        try? await Task.sleep(for: .milliseconds(50))  // let any (buggy) reorder re-gist start

        // The fix: the de-dup fingerprint is order-independent, so the reorder didn't re-fire
        // alpha's gist. Before the fix, hashing the reordered texts made this 3 — wasted
        // on-device model work on an idea set that never changed.
        #expect(intel.startCount(alpha.id.rawValue) == 2)
    }

    @Test func triageReordersOpenTodosByImpactAndTogglesBack() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let clock = LockedBox(Date(timeIntervalSince1970: 1_700_000_000))
        let store = AppStore(
            database: try! GRDBMetadataStore(path: nil),
            worktrees: GitWorktreeManager(),
            transcriptsDir: URL(fileURLWithPath: (repo.container as NSString).appendingPathComponent("t")),
            now: { let date = clock.get(); clock.set(date.addingTimeInterval(1)); return date }
        ) { _ in ScriptedBackend { _, _ in } }

        let calm = try #require(await store.addTodo("rename a variable"))
        let urgent = try #require(await store.addTodo("production outage: users hit a crash, data loss"))
        let impactful = try #require(await store.addTodo("refactor the billing architecture for scale"))

        // FIFO and unsorted until the user flips on impact order.
        #expect(store.openTodos.map(\.id) == [calm.id, urgent.id, impactful.id])
        #expect(store.triageSortEnabled == false)

        // Triage runs in the background on add (default HeuristicIntelligence) and is
        // stored on each idea — no pass on the button click. Wait for the final pass
        // (over all three) to settle before asserting the steady-state ranking.
        await waitFor { !store.triaging && store.triageLevel(for: calm.id) == .low }

        // Flipping on impact order just re-sorts using the stored verdicts.
        store.enableTriageSort()
        #expect(store.triageSortEnabled)
        // Critical/urgent floats to the top; the calm rename sinks to the bottom.
        #expect(store.openTodos.first?.id == urgent.id)
        #expect(store.openTodos.last?.id == calm.id)
        #expect(store.triageLevel(for: urgent.id) == .critical)
        #expect(store.triageLevel(for: calm.id) == .low)

        // Toggling triage off restores FIFO (verdicts are kept around).
        store.clearTriageSort()
        #expect(store.openTodos.map(\.id) == [calm.id, urgent.id, impactful.id])
        #expect(store.triageLevel(for: urgent.id) == .critical)

        // The stored verdicts survive a reload.
        await store.loadTodos()
        #expect(store.triageLevel(for: urgent.id) == .critical)
        #expect(store.triageLevel(for: calm.id) == .low)
    }

    @Test func editingTodoTextRegeneratesSummary() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)

        let todo = try #require(await store.addTodo(
            "draft a long detailed plan for migrating the database to the new schema safely"))
        await waitFor { store.todo(todo.id)?.summary?.isEmpty == false }
        let original = try #require(store.todo(todo.id)?.summary)

        // Editing the text updates it and regenerates the summary from the new text.
        await store.updateTodo(todo.id, text:
            "investigate and fix the flaky integration tests that fail intermittently on CI")
        #expect(store.todo(todo.id)?.text.contains("flaky") == true)
        await waitFor { store.todo(todo.id)?.summary?.isEmpty == false }
        #expect(store.todo(todo.id)?.summary != original)
    }

    @Test func quickTodoSeedsCurrentProjectContext() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "alpha", rootPath: repo.root, defaultBranch: "main")!

        // On the home dashboard there is no context — a plain global capture.
        store.presentQuickTodo()
        #expect(store.quickTodoPresented)
        #expect(store.quickTodoProjectID == nil)

        // Inside a session, Cmd-T pre-tags that session's project.
        let sessionID = try await store.createSession(in: project, title: "work", prompt: "go")
        store.openSessionID = sessionID
        await waitFor { store.openSession?.projectID == project.id }
        store.presentQuickTodo()
        #expect(store.quickTodoProjectID == project.id)

        // On the project overview page, the same project is seeded.
        store.openProject(project.id)
        store.presentQuickTodo()
        #expect(store.quickTodoProjectID == project.id)

        // An explicit unfiled capture still wins.
        store.presentQuickTodo(projectID: nil)
        #expect(store.quickTodoProjectID == nil)
    }

    @Test func moveTodoReassignsProject() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "alpha", rootPath: repo.root, defaultBranch: "main")!

        let todo = try #require(await store.addTodo("misfiled idea"))  // unassigned
        #expect(store.todoGroups.first?.projectID == nil)

        // Unassigned → project.
        await store.moveTodo(todo.id, toProject: project.id)
        #expect(store.todos.first { $0.id == todo.id }?.projectID == project.id)
        #expect(store.todoGroups.count == 1)
        #expect(store.todoGroups.first?.projectID == project.id)

        // Project → unassigned (unfiled).
        await store.moveTodo(todo.id, toProject: nil)
        #expect(store.todos.first { $0.id == todo.id }?.projectID == nil)
        #expect(store.todoGroups.first?.projectID == nil)

        // Persisted across a reload.
        await store.loadTodos()
        #expect(store.todos.first { $0.id == todo.id }?.projectID == nil)
    }

    @Test func todosGroupByProjectWithSummaries() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "alpha", rootPath: repo.root, defaultBranch: "main")!

        await store.addTodo("refactor the auth module to use tokens", projectID: project.id)
        await store.addTodo("add a dark mode toggle to settings", projectID: project.id)
        await store.addTodo("an unfiled stray idea")  // unassigned

        let groups = store.todoGroups
        #expect(groups.count == 2)
        // Project group first, unassigned bucket last.
        #expect(groups.first?.projectID == project.id)
        #expect(groups.first?.projectName == "alpha")
        #expect(groups.first?.todos.count == 2)
        #expect(groups.last?.projectID == nil)
        #expect(groups.last?.projectName == "Unassigned")
        #expect(groups.last?.todos.count == 1)

        // Summaries land asynchronously (heuristic gist when no model is configured).
        await waitFor { store.todoGroups.allSatisfy { !$0.summary.isEmpty } }
        #expect(store.todoGroups.first?.summary.contains("2 ideas") == true)
        // A single-idea group shows the idea itself, not an "N ideas:" rollup.
        #expect(store.todoGroups.last?.summary.contains("ideas:") == false)
    }

    @Test func deletingProjectUnassignsItsTodos() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "alpha", rootPath: repo.root, defaultBranch: "main")!
        let todo = try #require(await store.addTodo("keep this idea", projectID: project.id))
        #expect(store.todoGroups.first?.projectID == project.id)

        await store.deleteProject(project.id)

        // The idea survives the project's deletion, now unassigned.
        #expect(store.todos.first { $0.id == todo.id }?.projectID == nil)
        #expect(store.todoGroups.count == 1)
        #expect(store.todoGroups.first?.projectID == nil)
        #expect(store.todoGroups.first?.projectName == "Unassigned")
    }

    @Test func integrateOpenSessionLandsToMain() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        let sessionID = try await store.createSession(in: project, title: "ship it", prompt: "go")
        store.openSessionID = sessionID
        await waitFor { store.summaries.first?.status == .awaitingInput }

        let result = await store.integrateOpenSession(strategy: .squash)
        guard case .clean = result else {
            Issue.record("expected clean integration, got \(String(describing: result))")
            return
        }
        #expect(repo.read("made.txt") == "by agent\n")
    }

    @Test func waitForAccessGrantsOnceConflictingWorkMerges() async throws {
        let repo = try await GitTestRepo(controlled: true)  // file locking applies only to Nucleic Control
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.lockReconcileInterval = .milliseconds(20)  // detect landed/lifecycle promptly
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        // `doer` writes made.txt and acquires its lock; `asker` wants the same file.
        let doer = try await store.createSession(in: project, title: "doer", prompt: "go")
        let asker = try await store.createSession(in: project, title: "asker", prompt: "go")
        await waitFor {
            store.summaries.first { $0.id == doer }?.status == .awaitingInput
                && store.summaries.first { $0.id == asker }?.status == .awaitingInput
        }
        #expect(await store.arbitrate(sessionID: doer, task: "edit made", files: ["made.txt"]).resolution == .proceed)

        // The asker's check_conflict auto-queues: made.txt is held by the doer.
        let arbitration = Task { await store.arbitrate(sessionID: asker, task: "edit made", files: ["made.txt"]) }
        await waitFor { store.sessionsWaitingForAccess.contains(asker) }
        #expect(store.sessionsWaitingForAccess.contains(asker))

        // The doer merges into its parent, releasing its lock; the waiter is then granted.
        store.openSessionID = doer
        guard case .clean = await store.integrateOpenSession(strategy: .squash) else {
            Issue.record("expected the doer to integrate cleanly")
            return
        }

        #expect(await arbitration.value.resolution == .proceed)
        await waitFor { store.sessionsWaitingForAccess.isEmpty }
        #expect(store.sessionsWaitingForAccess.isEmpty)
    }

    @Test func forceReleaseGrantsWaiterWithoutMerging() async throws {
        let repo = try await GitTestRepo(controlled: true)  // file locking applies only to Nucleic Control
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.lockReconcileInterval = .milliseconds(20)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        // `doer` writes made.txt and acquires its lock; `asker` wants the same file.
        let doer = try await store.createSession(in: project, title: "doer", prompt: "go")
        let asker = try await store.createSession(in: project, title: "asker", prompt: "go")
        await waitFor {
            store.summaries.first { $0.id == doer }?.status == .awaitingInput
                && store.summaries.first { $0.id == asker }?.status == .awaitingInput
        }
        #expect(await store.arbitrate(sessionID: doer, task: "edit made", files: ["made.txt"]).resolution == .proceed)

        // The asker's check_conflict auto-queues behind the held lock.
        let arbitration = Task { await store.arbitrate(sessionID: asker, task: "edit made", files: ["made.txt"]) }
        await waitFor { store.sessionsWaitingForAccess.contains(asker) }

        // The doer's lock persists (no merge). Force-releasing it grants the waiter.
        await store.forceReleaseLocks(doer)

        #expect(await arbitration.value.resolution == .proceed)
        await waitFor { store.sessionsWaitingForAccess.isEmpty }
        #expect(store.sessionsWaitingForAccess.isEmpty)
    }

    @Test func lockQueueSnapshotReportsHoldersAndWaiters() async throws {
        let repo = try await GitTestRepo(controlled: true)  // file locking applies only to Nucleic Control
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.lockReconcileInterval = .milliseconds(20)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        let doer = try await store.createSession(in: project, title: "doer", prompt: "go")
        let asker = try await store.createSession(in: project, title: "asker", prompt: "go")
        await waitFor {
            store.summaries.first { $0.id == doer }?.status == .awaitingInput
                && store.summaries.first { $0.id == asker }?.status == .awaitingInput
        }

        // The doer acquires made.txt; the snapshot shows it as the holder, no waiters yet.
        #expect(await store.arbitrate(sessionID: doer, task: "edit made", files: ["made.txt"]).resolution == .proceed)
        let held = await store.lockQueueSnapshot()
        let madeHeld = held.files.first { $0.path == "made.txt" }
        #expect(madeHeld?.holders.contains { $0.sessionID == doer } == true)
        #expect(madeHeld?.waiters.isEmpty == true)
        #expect(held.waiters.isEmpty)

        // The asker auto-queues behind the held lock.
        let arbitration = Task { await store.arbitrate(sessionID: asker, task: "edit made", files: ["made.txt"]) }
        await waitFor { store.sessionsWaitingForAccess.contains(asker) }

        // Now the snapshot shows the doer holding made.txt and the asker waiting on it.
        let queued = await store.lockQueueSnapshot()
        let madeQueued = try #require(queued.files.first { $0.path == "made.txt" })
        #expect(madeQueued.holders.contains { $0.sessionID == doer })
        #expect(madeQueued.waiters.contains { $0.sessionID == asker })
        let waiter = try #require(queued.waiters.first { $0.sessionID == asker })
        #expect(waiter.wantedFiles.contains("made.txt"))
        #expect(waiter.blockedBy.contains { $0.sessionID == doer })

        // Clean up the blocked arbitration so the task doesn't outlive the test.
        await store.forceReleaseLocks(doer)
        _ = await arbitration.value
    }

    @Test func archivingReleasesLocksAndGrantsWaiter() async throws {
        let repo = try await GitTestRepo(controlled: true)  // file locking applies only to Nucleic Control
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.lockReconcileInterval = .milliseconds(20)  // the reconcile poll releases archived holders
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        let doer = try await store.createSession(in: project, title: "doer", prompt: "go")
        let asker = try await store.createSession(in: project, title: "asker", prompt: "go")
        await waitFor {
            store.summaries.first { $0.id == doer }?.status == .awaitingInput
                && store.summaries.first { $0.id == asker }?.status == .awaitingInput
        }
        #expect(await store.arbitrate(sessionID: doer, task: "edit made", files: ["made.txt"]).resolution == .proceed)

        // The asker's check_conflict auto-queues behind the held lock.
        let arbitration = Task { await store.arbitrate(sessionID: asker, task: "edit made", files: ["made.txt"]) }
        await waitFor { store.sessionsWaitingForAccess.contains(asker) }

        // Archiving the doer releases its lock (via the reconcile poll), granting the waiter.
        await store.setSessionArchived(doer, true)

        #expect(await arbitration.value.resolution == .proceed)
        await waitFor { store.sessionsWaitingForAccess.isEmpty }
        #expect(store.sessionsWaitingForAccess.isEmpty)
    }

    /// The headline fix: a lock releases when its file lands in the parent by ANY means — not
    /// just a Nucleic-mediated merge. Here the doer's change stops differing from the parent (as
    /// an agent's own `git merge` would), and the reconcile poll detects it and grants the waiter
    /// without Nucleic performing any merge.
    @Test func reconcilePollReleasesWhenWorkLandsOutsideNucleic() async throws {
        let repo = try await GitTestRepo(controlled: true)  // file locking applies only to Nucleic Control
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.lockReconcileInterval = .milliseconds(20)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        let doer = try await store.createSession(in: project, title: "doer", prompt: "go")
        let asker = try await store.createSession(in: project, title: "asker", prompt: "go")
        await waitFor {
            store.summaries.first { $0.id == doer }?.status == .awaitingInput
                && store.summaries.first { $0.id == asker }?.status == .awaitingInput
        }
        #expect(await store.arbitrate(sessionID: doer, task: "edit made", files: ["made.txt"]).resolution == .proceed)

        let arbitration = Task { await store.arbitrate(sessionID: asker, task: "edit made", files: ["made.txt"]) }
        await waitFor { store.sessionsWaitingForAccess.contains(asker) }

        // The doer's made.txt stops differing from the parent (its work "landed") — no Nucleic merge.
        store.openSessionID = doer
        await waitFor { store.openSession?.id == doer }
        let worktree = try #require(store.openSession?.worktreePath)
        try FileManager.default.removeItem(atPath: (worktree as NSString).appendingPathComponent("made.txt"))

        // The reconcile poll detects the file landed and grants the waiter — no mediated merge ran.
        #expect(await arbitration.value.resolution == .proceed)
        await waitFor { store.sessionsWaitingForAccess.isEmpty }
        #expect(store.sessionsWaitingForAccess.isEmpty)
    }

    @Test func lockingIsScopedToNucleicControl() async throws {
        // File locking now applies ONLY to Nucleic Control projects (that's the only place we
        // can reliably intercept `git merge` to unlock). A non-control project's arbitration
        // fails open and acquires nothing.
        let repo = try await GitTestRepo()  // NOT controlled
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "plain", rootPath: repo.root, defaultBranch: "main")!
        #expect(!project.isNucleicControlled)

        let s = try await store.createSession(in: project, title: "s", prompt: "go")
        await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }
        #expect(await store.arbitrate(sessionID: s, task: "edit made", files: ["made.txt"]).resolution == .noConflict)
        #expect(await store.lockQueueSnapshot().files.isEmpty)
    }

    @Test func observeGitOpReleasesLockOnObservedMerge() async throws {
        // The interceptor's certain `git merge` signal (`observeGitOp`) releases a holder's
        // landed locks immediately, without the background poll. The poll is parked far out, so
        // this isolates the interceptor-driven release.
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.lockReconcileInterval = .seconds(3600)  // effectively disable the periodic poll
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        let doer = try await store.createSession(in: project, title: "doer", prompt: "go")
        let asker = try await store.createSession(in: project, title: "asker", prompt: "go")
        await waitFor {
            store.summaries.first { $0.id == doer }?.status == .awaitingInput
                && store.summaries.first { $0.id == asker }?.status == .awaitingInput
        }
        #expect(await store.arbitrate(sessionID: doer, task: "edit made", files: ["made.txt"]).resolution == .proceed)
        let arbitration = Task { await store.arbitrate(sessionID: asker, task: "edit made", files: ["made.txt"]) }
        await waitFor { store.sessionsWaitingForAccess.contains(asker) }

        // doer's made.txt stops differing from the parent (its work "landed", agent-merge style).
        store.openSessionID = doer
        await waitFor { store.openSession?.id == doer }
        let worktree = try #require(store.openSession?.worktreePath)
        try FileManager.default.removeItem(atPath: (worktree as NSString).appendingPathComponent("made.txt"))

        // The interceptor reports the agent's merge → the held-but-landed lock releases at once,
        // granting the waiter — the poll never fired.
        await store.observeGitOp(sessionID: doer, argv: ["merge", "made"], exitCode: 0)
        #expect(await arbitration.value.resolution == .proceed)
        #expect(store.sessionsWaitingForAccess.isEmpty)
        // `doer` never armed autoship, so an ordinary agent `git merge` must NOT conjure an
        // autoship ship status (which would put it in the Control panel's autoship list as
        // "Shipped"). The recovery write only applies to sessions participating in autoship.
        #expect(store.shipStatuses[doer] == nil)
    }

    @Test func agentMergeDoesNotDoublePostShippedNote() async throws {
        // Once a session's work is shipped (here by the autoship merge queue), a follow-up
        // agent sync-merge observed in-container must NOT re-post a duplicate "merged" note —
        // the queue ship and the interceptor share one dedup latch, cleared only by new edits.
        let repo = try await GitTestRepo(controlled: true)  // autoship + locking need Control
        defer { repo.cleanup() }
        let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        let store = AppStore(
            database: try GRDBMetadataStore(path: nil), worktrees: worktrees,
            transcriptsDir: transcriptsDir,
            now: { Date(timeIntervalSince1970: 1_700_000_000) }
        ) { session in
            let wtPath = session.worktreePath ?? ""
            let name = session.title.replacingOccurrences(of: " ", with: "-") + ".txt"
            return ScriptedBackend { e, _ in
                e.emit(.sessionStarted(SessionStarted(
                    backendSessionID: "be", model: "m", cwd: wtPath, toolNames: ["Write"])))
                try? "by agent\n".write(
                    toFile: (wtPath as NSString).appendingPathComponent(name),
                    atomically: true, encoding: .utf8)
                e.emit(.fileChange(FileChange(path: name, kind: .add)))
                e.emit(.assistantText(TextChunk(messageID: "a",
                    text: "Fixed the bug and added a test. All tests pass.", isPartial: false)))
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            }
        }
        await store.activateConflictArbitration()  // wires the autoship merge queue
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        let landed = try await store.createSession(
            in: project, title: "landed", prompt: "go", useWorktree: true, autoShip: true)
        store.openSessionID = landed
        await store.awaitOpenSessionSettled()
        await waitFor({
            self.autoshipNotes(store.openTranscript).contains { $0.contains("merged into main") }
        }, timeoutMs: 8000)
        let mergedNotes = { self.autoshipNotes(store.openTranscript).filter { $0.contains("merged into") }.count }
        #expect(mergedNotes() == 1)

        // A follow-up agent sync-merge (no new edits) must not add a second shipped note.
        await store.observeGitOp(sessionID: landed, argv: ["merge", "main"], exitCode: 0)
        await store.observeGitOp(sessionID: landed, argv: ["merge", "main"], exitCode: 0)
        #expect(mergedNotes() == 1)
        // The ship status stays `.merged` for this armed session — the agent-merge recovery
        // reflects a landing without clobbering the queue's existing `.merged`.
        guard case .merged = store.shipStatuses[landed] else {
            Issue.record("expected shipStatuses[landed] == .merged, got \(String(describing: store.shipStatuses[landed]))")
            return
        }
    }

    @Test func autoshipRowRevertsToArmedOnNewWorkThenRetiresAfterTTL() async throws {
        // The Control panel's Autoship row is event-driven: it shows the recorded ship status
        // directly. After a ship the row reads "Shipped"; it reverts to "Armed" only when the agent
        // starts genuinely new, not-yet-landed work (an `arbitrate` grant clears the settled
        // status), then returns to "Shipped" when that work lands. Finally, a settled "Shipped" row
        // retires from the section once it has lingered past `autoshipShippedTTL`, so the list can't
        // grow without bound. (This replaces the old poll-derived demotion off hasTurnInFlight/held
        // locks, whose volatile sampling left rows inconsistently stuck between "Armed" and
        // "Shipped" — the reported bug.)
        let repo = try await GitTestRepo(controlled: true)  // autoship + locking need Control
        defer { repo.cleanup() }
        let clock = LockedBox(Date(timeIntervalSince1970: 1_700_000_000))  // drives the retire TTL
        let worktrees = GitWorktreeManager(now: { clock.get() })
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        let store = AppStore(
            database: try GRDBMetadataStore(path: nil), worktrees: worktrees,
            transcriptsDir: transcriptsDir, now: { clock.get() }
        ) { session in
            let wtPath = session.worktreePath ?? ""
            return ScriptedBackend(multiTurn: { e, _, _ in
                // One shipping turn: write work and let the merge queue ship it → row "Shipped".
                e.emit(.sessionStarted(SessionStarted(
                    backendSessionID: "be", model: "m", cwd: wtPath, toolNames: ["Write"])))
                try? "by agent\n".write(
                    toFile: (wtPath as NSString).appendingPathComponent("made.txt"),
                    atomically: true, encoding: .utf8)
                e.emit(.fileChange(FileChange(path: "made.txt", kind: .add)))
                e.emit(.assistantText(TextChunk(messageID: "a",
                    text: "Fixed the bug and added a test. All tests pass.", isPartial: false)))
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            })
        }
        store.autoshipShippedTTL = 300  // advance the test clock past this to retire the row
        await store.activateConflictArbitration()  // wires the autoship merge queue
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
        let landed = try await store.createSession(
            in: project, title: "landed", prompt: "go", useWorktree: true, autoShip: true)
        store.openSessionID = landed
        await store.awaitOpenSessionSettled()

        func row() async -> AutoshipEntry? {
            await store.controlSnapshot().autoship.first { $0.id == landed }
        }
        func isShipped() async -> Bool {
            if case .merged = (await row())?.status { return true } else { return false }
        }
        // "Armed" = the session is still in the section (autoship armed) but with no live status.
        func isArmed() async -> Bool {
            guard let r = await row() else { return false }
            return r.status == nil
        }

        // Turn 1 ships → the row settles on "Shipped".
        await waitForAsync({ await isShipped() }, timeoutMs: 8000)
        #expect(await isShipped())

        // The agent starts genuinely new work: it declares a file via `arbitrate`, which clears the
        // settled ship status. The row reverts to "Armed" (and the new file's lock is held).
        let worktree = try #require(store.openSession?.worktreePath)
        try "more\n".write(
            toFile: (worktree as NSString).appendingPathComponent("made2.txt"),
            atomically: true, encoding: .utf8)
        #expect(await store.arbitrate(
            sessionID: landed, task: "more", files: ["made2.txt"]).resolution == .proceed)
        #expect(await isArmed())
        #expect(!(await store.lockQueueSnapshot().files.isEmpty))  // armed because new work is locked

        // The new work lands (footprint empties, lock releases) → the row returns to "Shipped".
        try FileManager.default.removeItem(
            atPath: (worktree as NSString).appendingPathComponent("made2.txt"))
        await store.observeGitOp(sessionID: landed, argv: ["merge", "main"], exitCode: 0)
        await waitForAsync({ await isShipped() }, timeoutMs: 4000)
        #expect(await isShipped())

        // Past the TTL, the settled "Shipped" row retires from the section so it can't accumulate.
        clock.set(clock.get().addingTimeInterval(store.autoshipShippedTTL + 1))
        #expect(await row() == nil)
    }

    @Test func observeGitOpRecordsInterceptorEventForControlSessions() async throws {
        // Every mutating op the interceptor reports for a Control session lands in the activity
        // feed, newest-first, including non-zero exits (a failed op is itself worth surfacing).
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
        let s = try await store.createSession(in: project, title: "doer", prompt: "go")
        await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }

        await store.observeGitOp(sessionID: s, argv: ["commit", "-m", "Fix the bug"], exitCode: 0)
        #expect(store.gitInterceptorEvents.count == 1)
        let first = try #require(store.gitInterceptorEvents.first)
        #expect(first.kind == "commit")
        #expect(first.sessionID == s)
        #expect(first.sessionTitle == "doer")
        #expect(first.succeeded)

        // A failed op is recorded too, and ends up newest-first ahead of the commit.
        await store.observeGitOp(sessionID: s, argv: ["merge", "feature"], exitCode: 1)
        #expect(store.gitInterceptorEvents.count == 2)
        #expect(store.gitInterceptorEvents.first?.kind == "merge")
        #expect(store.gitInterceptorEvents.first?.succeeded == false)

        // A read-only op the shim wouldn't normally report is not fed even if it arrives.
        await store.observeGitOp(sessionID: s, argv: ["status"], exitCode: 0)
        #expect(store.gitInterceptorEvents.count == 2)
    }

    @Test func observeGitOpIgnoresNonControlSessions() async throws {
        // Locking + the activity feed are Control-only: a non-control session's git ops are
        // dropped entirely (the interceptor isn't even installed in non-control containers).
        let repo = try await GitTestRepo()  // not a Nucleic Control repo
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
        let s = try await store.createSession(in: project, title: "doer", prompt: "go")
        await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }

        await store.observeGitOp(sessionID: s, argv: ["commit", "-m", "x"], exitCode: 0)
        #expect(store.gitInterceptorEvents.isEmpty)
    }

    @Test func observeGhOpRecordsAgenticActionsForControlSessions() async throws {
        // The `gh` interceptor feeds the same Control activity feed as `git`, tagged `.gh`:
        // agentic GitHub actions (open/merge a PR) are recorded; reads (pr view) are dropped.
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
        let s = try await store.createSession(in: project, title: "doer", prompt: "go")
        await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }

        await store.observeGhOp(
            sessionID: s, argv: ["pr", "create", "--title", "Fix the bug"], exitCode: 0)
        #expect(store.gitInterceptorEvents.count == 1)
        let first = try #require(store.gitInterceptorEvents.first)
        #expect(first.tool == .gh)
        #expect(first.kind == "pr.create")
        #expect(first.label == "Open PR: Fix the bug")
        #expect(first.succeeded)

        // A merge is flagged as a merge landing and is newest-first.
        await store.observeGhOp(sessionID: s, argv: ["pr", "merge", "42"], exitCode: 0)
        #expect(store.gitInterceptorEvents.first?.kind == "pr.merge")
        #expect(store.gitInterceptorEvents.first?.isMerge == true)
        #expect(store.gitInterceptorEvents.count == 2)

        // A read (pr view) classifies to nil and never enters the feed.
        await store.observeGhOp(sessionID: s, argv: ["pr", "view", "42"], exitCode: 0)
        #expect(store.gitInterceptorEvents.count == 2)
    }

    @Test func observeCommandRecordsClassifiedEventsForControlSessions() async throws {
        // Every non-git command the interceptor reports for a Control session lands in the command
        // feed, classified (label/category/symbol), newest-first, with output preview + duration.
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
        let s = try await store.createSession(in: project, title: "doer", prompt: "go")
        await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }

        // A rich shim report (argv + captured output).
        await store.observeCommand(sessionID: s, call: Self.shimCall(
            command: "grep", argv: ["grep", "-rn", "TODO", "src"], exitCode: 0,
            durationMs: 12, stdout: "src/a.swift:3:// TODO\nsrc/b.swift:9:// TODO\n"))
        #expect(store.commandInterceptorEvents.count == 1)
        let first = try #require(store.commandInterceptorEvents.first)
        #expect(first.command == "grep")
        #expect(first.category == "search")
        #expect(first.label == #"Search for "TODO""#)
        #expect(first.sessionID == s)
        #expect(first.sessionTitle == "doer")
        #expect(first.durationMs == 12)
        #expect(first.outputPreview == "src/a.swift:3:// TODO")
        #expect(first.source == .shim)
        #expect(first.succeeded)

        // A failing tracer report (metadata only) ends up newest-first.
        await store.observeCommand(sessionID: s, call: Self.tracerCall(
            commandLine: "make build", cwd: "/repo", exitCode: 2, durationMs: 1500))
        #expect(store.commandInterceptorEvents.count == 2)
        #expect(store.commandInterceptorEvents.first?.command == "make")
        #expect(store.commandInterceptorEvents.first?.category == "build")
        #expect(store.commandInterceptorEvents.first?.succeeded == false)
        #expect(store.commandInterceptorEvents.first?.source == .tracer)
    }

    @Test func observeGhOpIgnoresNonControlSessions() async throws {
        let repo = try await GitTestRepo()  // not a Nucleic Control repo
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
        let s = try await store.createSession(in: project, title: "doer", prompt: "go")
        await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }

        await store.observeGhOp(sessionID: s, argv: ["pr", "create", "--title", "x"], exitCode: 0)
        #expect(store.gitInterceptorEvents.isEmpty)
    }

    @Test func observeCommandIgnoresNonControlSessions() async throws {
        // The command feed is Control-only, like the git feed.
        let repo = try await GitTestRepo()  // not a Nucleic Control repo
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
        let s = try await store.createSession(in: project, title: "doer", prompt: "go")
        await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }

        await store.observeCommand(sessionID: s, call: Self.shimCall(
            command: "cat", argv: ["cat", "f.txt"], exitCode: 0, durationMs: 1, stdout: "hi"))
        #expect(store.commandInterceptorEvents.isEmpty)
    }

    private static func shimCall(
        command: String, argv: [String], exitCode: Int, durationMs: Int, stdout: String
    ) -> MCPApprovalServer.CommandReportCall {
        MCPApprovalServer.CommandReportCall(
            command: command, argv: argv, commandLine: nil, cwd: "/repo", exitCode: exitCode,
            durationMs: durationMs, stdout: stdout, stderr: "", truncated: false,
            source: .shim, sessionID: nil)
    }

    private static func tracerCall(
        commandLine: String, cwd: String, exitCode: Int, durationMs: Int
    ) -> MCPApprovalServer.CommandReportCall {
        MCPApprovalServer.CommandReportCall(
            command: nil, argv: [], commandLine: commandLine, cwd: cwd, exitCode: exitCode,
            durationMs: durationMs, stdout: "", stderr: "", truncated: false,
            source: .tracer, sessionID: nil)
    }

    @Test func controlSnapshotAggregatesControlState() async throws {
        // The Control panel's one aggregator call gathers container counts, autoship entries, and
        // the activity feed for the Nucleic Control subsystem.
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
        let s = try await store.createSession(
            in: project, title: "ship me", prompt: "go", useWorktree: true, autoShip: true)
        await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }

        await store.observeGitOp(sessionID: s, argv: ["commit", "-m", "work"], exitCode: 0)

        let snap = await store.controlSnapshot()
        // No container manager is wired in tests, so the runtime probe is skipped (all false),
        // but the session/project counts come from the store's own state.
        #expect(snap.container.controlProjects == 1)
        #expect(snap.container.activeSessions >= 1)
        #expect(!snap.container.running)
        // The autoship-armed session appears (membership holds whether armed or already acted on).
        #expect(snap.autoship.contains { $0.id == s })
        // The observed commit is in the activity feed.
        #expect(snap.activity.contains { $0.kind == "commit" && $0.sessionID == s })
    }

    /// Liveness regression (LOCKING §4.4): the release-reconcile driver must keep detecting
    /// landings across the lock system going fully idle. A lone holder (no waiter forcing the loop
    /// alive) acquires, its work lands by an agent-style merge, and the driver releases it with an
    /// empty queue — then, *after* the driver has parked idle, a second session repeats the cycle
    /// and is released too. The previous self-stopping loop decided to stop from a stale snapshot
    /// and could terminate holding a lock no one was watching for its landing, stranding it until a
    /// mediated merge or manual release; this proves the driver re-wakes on the next acquire.
    @Test func detectedReleaseSurvivesTheLockSystemGoingIdle() async throws {
        let repo = try await GitTestRepo(controlled: true)  // file locking applies only to Nucleic Control
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.lockReconcileInterval = .milliseconds(20)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        // One full acquire → land → detected-release cycle for a lone holder (no contention).
        func landAndExpectRelease(_ title: String) async throws {
            let s = try await store.createSession(in: project, title: title, prompt: "go")
            await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }
            #expect(await store.arbitrate(sessionID: s, task: "edit made", files: ["made.txt"]).resolution == .proceed)
            // It holds made.txt and nobody is queued behind it.
            await waitForAsync { await store.lockQueueSnapshot().files.contains { $0.path == "made.txt" } }
            // made.txt stops differing from the parent (its work "landed", agent-merge style).
            store.openSessionID = s
            await waitFor { store.openSession?.id == s }
            let worktree = try #require(store.openSession?.worktreePath)
            try FileManager.default.removeItem(
                atPath: (worktree as NSString).appendingPathComponent("made.txt"))
            // The driver detects the landing and releases — with an empty queue — then parks idle.
            await waitForAsync { await store.lockQueueSnapshot().files.isEmpty }
            #expect(await store.lockQueueSnapshot().files.isEmpty)
        }

        try await landAndExpectRelease("first")   // round 1: driver starts, releases, parks idle
        try await landAndExpectRelease("second")  // round 2 (post-idle): driver must re-wake & release
    }

    /// The reported leak (LOCKING §4.4): a *completed* chat (awaitingInput + disposition
    /// `.completed`, so not terminal/archived) kept locks on files it declared via `check_conflict`
    /// but whose work had already landed — they released only via the background poll or a manual
    /// click. Completion now releases the landed ones immediately, while in-flight unmerged work
    /// stays locked until it merges. The poll is disabled here so this exercises *only* the
    /// completion-release path.
    @Test func completedChatReleasesLandedLocksButKeepsUnmergedWork() async throws {
        let repo = try await GitTestRepo(controlled: true)  // file locking applies only to Nucleic Control
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.lockReconcileInterval = .seconds(3600)  // isolate completion-release from the poll
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        let s = try await store.createSession(in: project, title: "doer", prompt: "go")
        await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }

        // The chat declared two files: made.txt (which the scripted agent actually wrote → differs
        // from the parent → unmerged) and phantom.txt (never created → matches the parent → landed).
        #expect(await store.arbitrate(
            sessionID: s, task: "edit", files: ["made.txt", "phantom.txt"]).resolution == .proceed)
        await waitForAsync { await store.lockQueueSnapshot().files.count == 2 }

        // Completion releases the landed lock immediately; the unmerged in-flight file is retained
        // (it will release when it merges — never on a bare commit, LOCKING invariant 4).
        await store.releaseCompletedSessionLocks(s)

        await waitForAsync { await store.lockQueueSnapshot().files.map(\.path) == ["made.txt"] }
        let snap = await store.lockQueueSnapshot()
        #expect(snap.files.map(\.path) == ["made.txt"])                  // unmerged work kept
        #expect(!snap.files.contains { $0.path == "phantom.txt" })       // landed lock released
    }

    /// Regression: completion-release must fail CLOSED. The landed set is `held − unmerged`, so a
    /// git failure computing `unmergedFiles` (here: the parent ref it diffs against is gone) would,
    /// under a `try? … ?? []`, read as "nothing unmerged → everything landed" and dump every lock
    /// on work that never merged — defeating the lock. It must instead keep all locks held.
    @Test func completionKeepsAllLocksWhenLandedCheckFails() async throws {
        let repo = try await GitTestRepo(controlled: true)  // file locking applies only to Nucleic Control
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.lockReconcileInterval = .seconds(3600)  // isolate completion-release from the poll
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        let s = try await store.createSession(in: project, title: "doer", prompt: "go")
        await waitFor { store.summaries.first { $0.id == s }?.status == .awaitingInput }

        #expect(await store.arbitrate(
            sessionID: s, task: "edit", files: ["made.txt", "phantom.txt"]).resolution == .proceed)
        await waitForAsync { await store.lockQueueSnapshot().files.count == 2 }

        // Break the parent ref the landed-check diffs against (parentRef = "main") so
        // `unmergedFiles()` throws. Free `main` from the main checkout first, then delete it.
        try await repo.run(["checkout", "-b", "parking"], in: repo.root)
        try await repo.run(["branch", "-D", "main"], in: repo.root)

        // Can't verify what landed → keep ALL locks (fail closed), release nothing.
        await store.releaseCompletedSessionLocks(s)

        let snap = await store.lockQueueSnapshot()
        #expect(Set(snap.files.map(\.path)) == ["made.txt", "phantom.txt"])
    }

    /// Persistence (LOCKING §6): on launch, held locks are rebuilt from git — a session that
    /// edited a file but never merged it still holds it, so a fresh session contends.
    @Test func reconstructLocksRestoresHeldFromGitOnLaunch() async throws {
        let repo = try await GitTestRepo(controlled: true)  // file locking applies only to Nucleic Control
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.lockReconcileInterval = .milliseconds(20)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        // The doer writes made.txt (unmerged) but never went through arbitrate.
        let doer = try await store.createSession(in: project, title: "doer", prompt: "go")
        await waitFor { store.summaries.first { $0.id == doer }?.status == .awaitingInput }

        // A launch reconstruction recognizes the doer as holding made.txt (its unmerged file).
        await store.reconstructLocks()
        let snap = await store.lockQueueSnapshot()
        #expect(snap.files.first { $0.path == "made.txt" }?.holders.contains { $0.sessionID == doer } == true)

        // A fresh session now contends and queues behind the reconstructed lock.
        let asker = try await store.createSession(in: project, title: "asker", prompt: "go")
        await waitFor { store.summaries.first { $0.id == asker }?.status == .awaitingInput }
        let arbitration = Task { await store.arbitrate(sessionID: asker, task: "edit made", files: ["made.txt"]) }
        await waitFor { store.sessionsWaitingForAccess.contains(asker) }
        #expect(store.sessionsWaitingForAccess.contains(asker))

        await store.forceReleaseLocks(doer)  // cleanup the blocked arbitration
        _ = await arbitration.value
    }

    /// Cascade (LOCKING §5.2): deleting a parent session re-targets its children to the
    /// grandparent (the parent's own parent); the lock domain `rootRef` is unchanged.
    @Test func cascadeRetargetsChildrenToGrandparentOnParentDelete() async throws {
        let repo = try await GitTestRepo(controlled: true)  // file locking applies only to Nucleic Control
        defer { repo.cleanup() }
        let db = try GRDBMetadataStore(path: nil)
        let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        let backend: @Sendable (Session) -> any AgentBackend = { _ in
            ScriptedBackend { e, _ in e.emit(.runFinished(RunFinished(outcome: .completed))) }
        }
        let store = AppStore(
            database: db, worktrees: worktrees, transcriptsDir: transcriptsDir,
            now: { Date(timeIntervalSince1970: 1_700_000_000) }, backendFactory: backend)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        let parent = try await store.createSession(in: project, title: "parent")
        let parentBranch = try #require(await db.loadSession(id: parent)?.branch)
        let child = try await store.createSession(in: project, title: "child", base: GitRef(parentBranch))
        let before = try #require(await db.loadSession(id: child))
        #expect(before.parentSessionID == parent)
        #expect(before.parentRef == parentBranch)
        #expect(before.rootRef == "main")

        // Delete the parent → the child re-targets to the grandparent (parent was top-level off "main").
        await store.deleteSession(parent)
        let after = try #require(await db.loadSession(id: child))
        #expect(after.parentRef == "main")        // grandparent ref
        #expect(after.parentSessionID == nil)     // grandparent is a project branch, no session
        #expect(after.rootRef == "main")          // lock domain unchanged
    }

    @Test func rejectsDuplicateProject() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)

        let first = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")
        #expect(first != nil)
        let dup = await store.addProject(name: "demo-again", rootPath: repo.root, defaultBranch: "main")
        #expect(dup == nil)
        #expect(store.projects.count == 1)
        #expect(store.lastError != nil)
    }

    @Test func changingLastErrorClearsDeepLink() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)

        // An error that names a responsible chat carries a deep-link to it…
        store.lastError = "Autoship failed."
        store.lastErrorSessionID = SessionID(rawValue: "s1")
        #expect(store.lastErrorSessionID != nil)

        // …but a subsequent, unrelated error must not inherit that stale target.
        store.lastError = "Something else broke."
        #expect(store.lastErrorSessionID == nil)
    }

    @Test func renameSessionAndProject() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        let sessionID = try await store.newSession(in: project)!
        await store.renameOpenSession(to: "my-cool-chat")
        #expect(store.openSession?.title == "my-cool-chat")
        #expect(store.summaries.first { $0.id == sessionID }?.title == "my-cool-chat")

        await store.renameProject(project.id, to: "Renamed Project")
        #expect(store.projects.first?.name == "Renamed Project")
    }

    @Test func modelAndEffortPersist() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
        let sessionID = try await store.newSession(in: project)!

        await store.setOpenSessionModel("opus")
        await store.setOpenSessionEffort("high")
        #expect(store.openSession?.model == "opus")
        #expect(store.openSession?.effort == "high")
        #expect(store.openSession?.id == sessionID)
    }

    @Test func intelligenceSummarizesAndAutoNamesFromFirstMessage() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.intelligence = FakeIntelligence()
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        let sessionID = try await store.createSession(in: project, title: "amber-quiet-otter", prompt: "build a login screen")
        store.openSessionID = sessionID

        await waitFor { store.openSession?.title == "Title: build a login screen" }
        #expect(store.openSession?.title == "Title: build a login screen")
        #expect(store.summaries.first { $0.id == sessionID }?.title == "Title: build a login screen")

        await waitFor { store.openSummary == "FAKE SUMMARY" }
        #expect(store.openSummary == "FAKE SUMMARY")
    }

    /// Phase A (LOCKING §3): a session records its parent (release target) and root (lock
    /// domain). A top-level chat forks from the default branch, so both are that branch and it
    /// has no parent session. A nested child (forked from another session's branch) takes that
    /// branch as its parent, **inherits the tree's root** (not the immediate parent), links to
    /// the parent session — and all three survive a relaunch (migration v14 columns round-trip).
    @Test func parentAndRootRefsTrackTheTreeAndPersist() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let dbPath = (repo.container as NSString).appendingPathComponent("nucleic.sqlite")
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        let backend: @Sendable (Session) -> any AgentBackend = { _ in
            ScriptedBackend { e, _ in e.emit(.runFinished(RunFinished(outcome: .completed))) }
        }

        let parentID: SessionID
        let childID: SessionID
        let parentBranch: String
        do {
            let db = try GRDBMetadataStore(path: dbPath)
            let store = AppStore(
                database: db, worktrees: worktrees, transcriptsDir: transcriptsDir,
                now: { Date(timeIntervalSince1970: 1_700_000_000) }, backendFactory: backend)
            let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

            // Top-level: forks from the default branch ⇒ parentRef = rootRef = "main", no parent.
            parentID = try await store.createSession(in: project, title: "parent")
            let parent = try #require(await db.loadSession(id: parentID))
            #expect(parent.parentRef == "main")
            #expect(parent.rootRef == "main")
            #expect(parent.parentSessionID == nil)
            parentBranch = try #require(parent.branch)

            // Nested child: forks from the parent's branch ⇒ parentRef = that branch,
            // rootRef inherited ("main", NOT the immediate parent), parentSessionID = parent.
            childID = try await store.createSession(in: project, title: "child", base: GitRef(parentBranch))
            let child = try #require(await db.loadSession(id: childID))
            #expect(child.parentRef == parentBranch)
            #expect(child.rootRef == "main")
            #expect(child.parentSessionID == parentID)
        }

        // Relaunch on the same DB: the three columns round-trip (migration v14).
        let db2 = try GRDBMetadataStore(path: dbPath)
        let reloaded = try #require(await db2.loadSession(id: childID))
        #expect(reloaded.parentRef == parentBranch)
        #expect(reloaded.rootRef == "main")
        #expect(reloaded.parentSessionID == parentID)
    }

    @Test func sessionsReloadIntoSidebarAfterRelaunch() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        // On-disk DB so a second store sees the first store's writes (a "relaunch").
        let dbPath = (repo.container as NSString).appendingPathComponent("nucleic.sqlite")
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })

        let makeBackend: @Sendable (Session) -> any AgentBackend = { session in
            let wtPath = session.worktreePath ?? ""
            return ScriptedBackend { e, _ in
                e.emit(.sessionStarted(SessionStarted(
                    backendSessionID: "be", model: "m", cwd: wtPath, toolNames: [])))
                e.emit(.assistantText(TextChunk(messageID: "a", text: "hello", isPartial: false)))
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            }
        }

        // First "launch": create a project + a session that runs a turn.
        let sessionID: SessionID
        do {
            let db = try GRDBMetadataStore(path: dbPath)
            let store = AppStore(
                database: db, worktrees: worktrees, transcriptsDir: transcriptsDir,
                now: { Date(timeIntervalSince1970: 1_700_000_000) }, backendFactory: makeBackend)
            let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
            sessionID = try await store.createSession(in: project, title: "kept-chat", prompt: "hi")
            store.openSessionID = sessionID
            await waitFor { store.summaries.first?.status == .awaitingInput }
        }

        // Second "launch": a fresh store on the same DB + transcripts.
        let db2 = try GRDBMetadataStore(path: dbPath)
        let store2 = AppStore(
            database: db2, worktrees: worktrees, transcriptsDir: transcriptsDir,
            now: { Date(timeIntervalSince1970: 1_700_000_000) }, backendFactory: makeBackend)
        await store2.loadProjects()
        await store2.loadSessions()

        // The chat is back in the sidebar…
        let project = try #require(store2.projects.first)
        #expect(store2.summaries(for: project.id).map(\.id) == [sessionID])
        #expect(store2.summaries.first?.title == "kept-chat")

        // …and opening it replays the prior transcript.
        store2.openSessionID = sessionID
        await waitFor { !store2.openTranscript.isEmpty }
        #expect(store2.openTranscript.contains {
            if case .assistantText(let c) = $0.kind { return c.text == "hello" } else { return false }
        })
    }

    @Test func backfillsDispositionForUnclassifiedSessionOnLoad() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let dbPath = (repo.container as NSString).appendingPathComponent("nucleic.sqlite")
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        let makeBackend: @Sendable (Session) -> any AgentBackend = { session in
            let wtPath = session.worktreePath ?? ""
            return ScriptedBackend { e, _ in
                e.emit(.sessionStarted(SessionStarted(
                    backendSessionID: "be", model: "m", cwd: wtPath, toolNames: [])))
                e.emit(.assistantText(TextChunk(messageID: "a",
                    text: "The worktree is quiet. Nothing left to push.", isPartial: false)))
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            }
        }

        // First launch: run a turn, then simulate a pre-feature session by clearing the
        // persisted disposition (as if it had finished before classification existed).
        let sessionID: SessionID
        let db = try GRDBMetadataStore(path: dbPath)
        do {
            let store = AppStore(
                database: db, worktrees: worktrees, transcriptsDir: transcriptsDir,
                now: { Date(timeIntervalSince1970: 1_700_000_000) }, backendFactory: makeBackend)
            let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
            sessionID = try await store.createSession(in: project, title: "done-chat", prompt: "hi")
            await waitFor { store.summaries.first?.status == .awaitingInput }
        }
        var stored = try #require(await db.loadSession(id: sessionID))
        stored.lastTurnDisposition = nil
        try await db.saveSession(stored)

        // Relaunch: loadSessions backfills the disposition from the transcript's last reply.
        let store2 = AppStore(
            database: try GRDBMetadataStore(path: dbPath), worktrees: worktrees,
            transcriptsDir: transcriptsDir,
            now: { Date(timeIntervalSince1970: 1_700_000_000) }, backendFactory: makeBackend)
        await store2.loadProjects()
        await store2.loadSessions()
        #expect(store2.summaries.first { $0.id == sessionID }?.disposition == .completed)
    }

    @Test func unseenCompletionFlagsCompletedChatsUntilOpened() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        // Every chat ends by reporting it finished the work, so the heuristic classifier
        // lands the turn on `.completed` (an "unread, done" chat for the sidebar).
        let store = AppStore(
            database: try GRDBMetadataStore(path: nil), worktrees: worktrees,
            transcriptsDir: transcriptsDir,
            now: { Date(timeIntervalSince1970: 1_700_000_000) }
        ) { session in
            ScriptedBackend { e, _ in
                e.emit(.sessionStarted(SessionStarted(
                    backendSessionID: "be", model: "m", cwd: session.worktreePath ?? "", toolNames: [])))
                e.emit(.assistantText(TextChunk(messageID: "a",
                    text: "Fixed the bug and added a test. All tests pass.", isPartial: false)))
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            }
        }
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        // A chat that completes while the user is looking elsewhere becomes "unread".
        let background = try await store.createSession(in: project, title: "bg", prompt: "go")
        await waitFor({
            store.summaries.first { $0.id == background }?.unseenCompletion == true
        }, timeoutMs: 8000)
        #expect(store.summaries.first { $0.id == background }?.disposition == .completed)
        #expect(store.summaries.first { $0.id == background }?.unseenCompletion == true)

        // Opening it marks it seen — the dot clears.
        store.openSessionID = background
        await store.awaitOpenSessionSettled()
        await waitFor({
            store.summaries.first { $0.id == background }?.unseenCompletion == false
        }, timeoutMs: 8000)
        #expect(store.summaries.first { $0.id == background }?.unseenCompletion == false)

        // A chat that completes while it's the open one was never unread.
        let foreground = try await store.createSession(in: project, title: "fg", prompt: "go")
        store.openSessionID = foreground
        await store.awaitOpenSessionSettled()
        await waitFor({
            store.summaries.first { $0.id == foreground }?.disposition == .completed
        }, timeoutMs: 8000)
        #expect(store.summaries.first { $0.id == foreground }?.unseenCompletion == false)
    }

    @Test func classifiesTurnDispositionForSidebar() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        // The agent's final reply varies by the session title, so one chat ends by
        // asking and the other by reporting done — the heuristic classifier (default
        // intelligence) then drives the sidebar disposition.
        let store = AppStore(
            database: try GRDBMetadataStore(path: nil), worktrees: worktrees,
            transcriptsDir: transcriptsDir,
            now: { Date(timeIntervalSince1970: 1_700_000_000) }
        ) { session in
            let asking = session.title.contains("ask")
            return ScriptedBackend { e, _ in
                e.emit(.sessionStarted(SessionStarted(
                    backendSessionID: "be", model: "m", cwd: session.worktreePath ?? "", toolNames: [])))
                e.emit(.assistantText(TextChunk(messageID: "a",
                    text: asking ? "I found two options. Which would you prefer?"
                                 : "Fixed the bug and added a test. All tests pass.",
                    isPartial: false)))
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            }
        }
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        let asker = try await store.createSession(in: project, title: "ask-chat", prompt: "go")
        let doer = try await store.createSession(in: project, title: "done-chat", prompt: "go")

        // Status settles to awaitingInput for both; the disposition refines which kind.
        // (Both the turn pipeline and classification are async, so wait generously.)
        await waitFor({
            store.summaries.first { $0.id == asker }?.disposition == .awaitingInput
                && store.summaries.first { $0.id == doer }?.disposition == .completed
        }, timeoutMs: 8000)
        #expect(store.summaries.first { $0.id == asker }?.status == .awaitingInput)
        #expect(store.summaries.first { $0.id == asker }?.disposition == .awaitingInput)
        #expect(store.summaries.first { $0.id == doer }?.status == .awaitingInput)
        #expect(store.summaries.first { $0.id == doer }?.disposition == .completed)
    }

    @Test func markSessionDoneFlipsAwaitingInputToCompleted() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        // Every chat ends by asking the user something, so the heuristic classifier lands the
        // turn on `.awaitingInput` — the "stuck on Awaiting Input" state the sidebar's Mark Done
        // action is there to clear.
        let store = AppStore(
            database: try GRDBMetadataStore(path: nil), worktrees: worktrees,
            transcriptsDir: transcriptsDir,
            now: { Date(timeIntervalSince1970: 1_700_000_000) }
        ) { session in
            ScriptedBackend { e, _ in
                e.emit(.sessionStarted(SessionStarted(
                    backendSessionID: "be", model: "m", cwd: session.worktreePath ?? "", toolNames: [])))
                e.emit(.assistantText(TextChunk(messageID: "a",
                    text: "I found two options. Which would you prefer?", isPartial: false)))
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            }
        }
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        let chat = try await store.createSession(in: project, title: "ask-chat", prompt: "go")
        await waitFor({
            store.summaries.first { $0.id == chat }?.disposition == .awaitingInput
        }, timeoutMs: 8000)

        // Marking it done by hand flips the disposition to `.completed` — the row now reads "Done"
        // and drops out of "needs attention" — without leaving `.awaitingInput`.
        await store.markSessionDone(chat)
        #expect(store.summaries.first { $0.id == chat }?.status == .awaitingInput)
        #expect(store.summaries.first { $0.id == chat }?.disposition == .completed)
        #expect(store.summaries.first { $0.id == chat }?.isCompleted == true)
        #expect(store.summaries.first { $0.id == chat }?.needsAttention == false)

        // Re-marking an already-done chat is a harmless no-op.
        await store.markSessionDone(chat)
        #expect(store.summaries.first { $0.id == chat }?.disposition == .completed)
    }

    @Test func dashboardCountsProjectsChatsAndMessages() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        // One chat that runs a turn (the scripted backend injects "go" as a user turn).
        let sessionID = try await store.createSession(in: project, title: "with-msgs", prompt: "go")
        store.openSessionID = sessionID
        await waitFor { store.summaries.first?.status == .awaitingInput }

        await store.loadDashboard()
        #expect(store.dashboard.projects == 1)
        #expect(store.dashboard.chats == 1)
        #expect(store.dashboard.messages >= 1)         // the injected user turn
        #expect(store.dashboard.activeChats == 1)      // awaitingInput is non-terminal
        #expect(!store.activityByDay.isEmpty)          // at least today has activity

        // Archived chats drop out of both the chats and active counts.
        await store.setSessionArchived(sessionID, true)
        await store.loadDashboard()
        #expect(store.dashboard.chats == 0)
        #expect(store.dashboard.activeChats == 0)
    }

    @Test func activityContributionTalliesMessagesAndTokensByDay() {
        var cal = Calendar(identifier: .gregorian)
        cal.timeZone = TimeZone(identifier: "UTC")!
        let dayA = Date(timeIntervalSince1970: 1_750_000_000)         // some day
        let dayB = cal.date(byAdding: .day, value: 1, to: dayA)!      // the next day
        func event(_ at: Date, _ kind: AgentEvent.Kind) -> AgentEvent {
            AgentEvent(sessionID: SessionID(rawValue: "s"), seq: 0, at: at,
                       backend: .claudeCode, nativeType: nil, kind: kind)
        }
        let chunk = TextChunk(messageID: "m", text: "hi", isPartial: false)
        let usageA = Usage(inputTokens: 10, cachedInputTokens: 90, outputTokens: 20)  // 120
        let usageB = Usage(inputTokens: 5, outputTokens: 5)                            // 10
        let events = [
            event(dayA, .userText(chunk)),
            event(dayA, .userText(chunk)),
            event(dayA, .usage(usageA)),
            // The matching turnCompleted carries a *copy* of the same usage — it must NOT be
            // tallied again, or the day's tokens would double.
            event(dayA, .turnCompleted(TurnCompleted(stopReason: "end_turn", usage: usageA))),
            event(dayB, .userText(chunk)),
            event(dayB, .usage(usageB)),
        ]

        let c = AppStore.activityContribution(events: events, calendar: cal)
        #expect(c.messages == 3)
        #expect(c.tokens == 130)                                  // 120 + 10, no double-count
        let startA = cal.startOfDay(for: dayA)
        let startB = cal.startOfDay(for: dayB)
        #expect(c.messagesByDay[startA] == 2)
        #expect(c.messagesByDay[startB] == 1)
        #expect(c.tokensByDay[startA] == 120)
        #expect(c.tokensByDay[startB] == 10)
    }

    @Test func newChatsInheritDefaults() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.defaultModel = "claude-sonnet-4-6"
        store.defaultEffort = "low"
        store.defaultAuto = true
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        _ = try await store.newSession(in: project)
        await waitFor { store.openSession != nil }
        #expect(store.openSession?.model == "claude-sonnet-4-6")
        #expect(store.openSession?.effort == "low")
        #expect(store.openSession?.auto == true)
    }

    @Test func worktreelessSessionRunsInRepoRootAndDeletesCleanly() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        let id = try await store.createSession(in: project, title: "c", prompt: "", useWorktree: false)
        store.openSessionID = id
        await waitFor { store.openSession != nil }
        // Runs in the main checkout on the chosen branch — no isolated worktree.
        #expect(store.openSession?.worktreePath == repo.root)
        #expect(store.openSession?.branch == "main")

        // Nothing to remove ⇒ deletes without surfacing an error.
        await store.deleteSession(id)
        #expect(store.lastError == nil)
        #expect(store.summaries.contains { $0.id == id } == false)
    }

    @Test func worktreelessSessionDoesNotAutoship() async throws {
        let repo = try await GitTestRepo(controlled: true)  // autoship requires Nucleic Control
        defer { repo.cleanup() }
        let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        // Each chat writes a file named after its title (distinct files, no merge collision)
        // and reports done, so the heuristic classifier lands the turn on `.completed` with a
        // non-empty footprint — the precondition for autoship to fire.
        let store = AppStore(
            database: try GRDBMetadataStore(path: nil), worktrees: worktrees,
            transcriptsDir: transcriptsDir,
            now: { Date(timeIntervalSince1970: 1_700_000_000) }
        ) { session in
            let wtPath = session.worktreePath ?? ""
            let name = session.title.replacingOccurrences(of: " ", with: "-") + ".txt"
            return ScriptedBackend { e, _ in
                e.emit(.sessionStarted(SessionStarted(
                    backendSessionID: "be", model: "m", cwd: wtPath, toolNames: ["Write"])))
                try? "by agent\n".write(
                    toFile: (wtPath as NSString).appendingPathComponent(name),
                    atomically: true, encoding: .utf8)
                e.emit(.fileChange(FileChange(path: name, kind: .add)))
                e.emit(.assistantText(TextChunk(messageID: "a",
                    text: "Fixed the bug and added a test. All tests pass.", isPartial: false)))
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            }
        }
        await store.activateConflictArbitration()  // wires the autoship merge queue
        // Autoship is Control-gated, so the project must be Nucleic-controlled to ship.
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
        #expect(project.isNucleicControlled)

        // The session under test runs directly in the main checkout on `main` — there is no
        // isolated branch to merge (a branch can't merge into itself). Autoship must skip it
        // entirely: no "merging into…" note, no surfaced failure — even though it has a real
        // file footprint that would otherwise be shippable.
        let inPlace = try await store.createSession(
            in: project, title: "in place", prompt: "go", useWorktree: false, autoShip: true)
        store.openSessionID = inPlace
        await store.awaitOpenSessionSettled()
        // Wait until the turn is classified done (so shipIfCompleted has made its decision),
        // then give any erroneous enqueue a grace window to surface its `.merging` note.
        await waitFor({
            store.summaries.first { $0.id == inPlace }?.disposition == .completed
        }, timeoutMs: 8000)
        await waitFor({ self.autoshipNotes(store.openTranscript).isEmpty == false }, timeoutMs: 1000)
        #expect(autoshipNotes(store.openTranscript).isEmpty)
        #expect(store.lastError == nil)

        // Sanity: a worktree session with autoship DOES land and logs a merge note — proof
        // the queue is actually wired in this store, so the skip above is real and not just
        // an unwired no-op.
        let landed = try await store.createSession(
            in: project, title: "landed", prompt: "go", useWorktree: true, autoShip: true)
        store.openSessionID = landed
        await store.awaitOpenSessionSettled()
        await waitFor({
            self.autoshipNotes(store.openTranscript).contains { $0.contains("merged into main") }
        }, timeoutMs: 8000)
        #expect(autoshipNotes(store.openTranscript).contains { $0.contains("merged into main") })
    }

    @Test func erroredRunDoesNotAutoship() async throws {
        let repo = try await GitTestRepo(controlled: true)  // autoship requires Nucleic Control
        defer { repo.cleanup() }
        let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        // Each chat writes a file and signs off with done-looking prose (so the heuristic
        // classifier lands the turn on `.completed`) — but the run's *outcome* is driven by
        // the title: a chat titled "broken" ends `.errored`, everything else `.completed`.
        let store = AppStore(
            database: try GRDBMetadataStore(path: nil), worktrees: worktrees,
            transcriptsDir: transcriptsDir,
            now: { Date(timeIntervalSince1970: 1_700_000_000) }
        ) { session in
            let wtPath = session.worktreePath ?? ""
            let name = session.title.replacingOccurrences(of: " ", with: "-") + ".txt"
            let errored = session.title.contains("broken")
            return ScriptedBackend { e, _ in
                e.emit(.sessionStarted(SessionStarted(
                    backendSessionID: "be", model: "m", cwd: wtPath, toolNames: ["Write"])))
                try? "by agent\n".write(
                    toFile: (wtPath as NSString).appendingPathComponent(name),
                    atomically: true, encoding: .utf8)
                e.emit(.fileChange(FileChange(path: name, kind: .add)))
                e.emit(.assistantText(TextChunk(messageID: "a",
                    text: "Fixed the bug and added a test. All tests pass.", isPartial: false)))
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
                e.emit(.runFinished(RunFinished(outcome: errored ? .errored : .completed)))
            }
        }
        await store.activateConflictArbitration()  // wires the autoship merge queue
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        // The run errored mid-flight even though it left done-looking prose and a real file
        // footprint. Autoship must not fire: half-finished work never lands. The classifier
        // still runs (so the turn is marked done for the sidebar), giving us a settle point;
        // we then allow any erroneous enqueue a grace window to surface its "merging…" note.
        let broken = try await store.createSession(
            in: project, title: "broken", prompt: "go", useWorktree: true, autoShip: true)
        store.openSessionID = broken
        await store.awaitOpenSessionSettled()
        await waitFor({
            store.summaries.first { $0.id == broken }?.disposition == .completed
        }, timeoutMs: 8000)
        await waitFor({ self.autoshipNotes(store.openTranscript).isEmpty == false }, timeoutMs: 1000)
        #expect(autoshipNotes(store.openTranscript).isEmpty)
        #expect(store.lastError == nil)

        // Sanity: an identical chat whose run completes cleanly DOES land — proof the queue is
        // wired in this store, so the skip above is the outcome gate, not an unwired no-op.
        let landed = try await store.createSession(
            in: project, title: "landed", prompt: "go", useWorktree: true, autoShip: true)
        store.openSessionID = landed
        await store.awaitOpenSessionSettled()
        await waitFor({
            self.autoshipNotes(store.openTranscript).contains { $0.contains("merged into main") }
        }, timeoutMs: 8000)
        #expect(autoshipNotes(store.openTranscript).contains { $0.contains("merged into main") })
    }

    @Test func summaryIsCachedAndNotRegeneratedOnReopen() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let counter = CountingIntelligence()
        store.intelligence = counter
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        let id = try await store.createSession(in: project, title: "c", prompt: "go")
        store.openSessionID = id
        await waitFor { store.summaries.first?.status == .awaitingInput }
        // Drain all in-flight intelligence work (classification → summary) so the count
        // is stable, then confirm the summary was generated and cached on the session.
        await store.awaitOpenSessionSettled()
        #expect(store.openSession?.summary?.isEmpty == false)  // cached on the session
        let callsAfterRun = counter.calls.get()
        #expect(callsAfterRun >= 1)

        // Switch away and back — the cache is used, no new generation.
        store.openSessionID = nil
        await waitFor { store.openSession == nil }
        store.openSessionID = id
        await store.awaitOpenSessionSettled()
        #expect(!store.openSummary.isEmpty)
        #expect(counter.calls.get() == callsAfterRun)
    }

    @Test func favoriteArchiveDeleteChat() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
        let id = try await store.createSession(in: project, title: "chat", prompt: "go")
        store.openSessionID = id
        await waitFor { store.summaries.first?.status == .awaitingInput }

        await store.setSessionFavorite(id, true)
        #expect(store.summaries(for: project.id).first?.favorite == true)

        await store.setSessionArchived(id, true)
        #expect(store.summaries(for: project.id).isEmpty)                  // hidden from main list
        #expect(store.archivedSummaries(for: project.id).map(\.id) == [id])
        #expect(store.openSessionID == nil)                               // closed on archive

        await store.setSessionArchived(id, false)
        #expect(store.summaries(for: project.id).map(\.id) == [id])

        await store.deleteSession(id)
        #expect(store.summaries.isEmpty)
    }

    @Test func deleteProjectCascadesSessionsAndWorktrees() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        let sessionID = try await store.createSession(in: project, title: "doomed", prompt: "go")
        store.openSessionID = sessionID
        await waitFor { store.summaries.first?.status == .awaitingInput }
        let wtPath = (repo.root as NSString).appendingPathComponent(".nucleic/worktrees/doomed")
        #expect(FileManager.default.fileExists(atPath: wtPath))

        await store.deleteProject(project.id)

        #expect(store.projects.isEmpty)
        #expect(store.summaries.isEmpty)
        #expect(store.openSessionID == nil)
        #expect(!FileManager.default.fileExists(atPath: wtPath))  // worktree removed
        // Branch gone too.
        let branch = try await repo.run(["rev-parse", "--verify", "--quiet", "refs/heads/nucleic/doomed"])
        #expect(!branch.ok)
    }

    @Test func discardRemovesSessionAndWorktree() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!

        let sessionID = try await store.createSession(in: project, title: "scrap", prompt: "go")
        store.openSessionID = sessionID
        await waitFor { store.summaries.first?.status == .awaitingInput }
        let wtPath = (repo.root as NSString).appendingPathComponent(".nucleic/worktrees/scrap")
        #expect(FileManager.default.fileExists(atPath: wtPath))

        await store.discardOpenSession()

        #expect(store.summaries.isEmpty)
        #expect(store.openSessionID == nil)
        #expect(!FileManager.default.fileExists(atPath: wtPath))
    }

    // MARK: - Auto-archive

    /// A store whose clock can be advanced, and whose scripted agent ends each turn
    /// with a reply that the heuristic classifier reads as "done" — unless the chat's
    /// title starts with "asking", in which case the reply ends on a question (so its
    /// disposition lands on `.awaitingInput`).
    private func makeArchiveStore(repo: GitTestRepo, clock: LockedBox<Date>) -> AppStore {
        let database = try! GRDBMetadataStore(path: nil)
        let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        return AppStore(
            database: database, worktrees: worktrees, transcriptsDir: transcriptsDir,
            now: { clock.get() }
        ) { session in
            let asking = session.title.hasPrefix("asking")
            return ScriptedBackend { e, _ in
                e.emit(.sessionStarted(SessionStarted(
                    backendSessionID: "be", model: "m",
                    cwd: session.worktreePath ?? "", toolNames: [])))
                e.emit(.assistantText(TextChunk(
                    messageID: "a",
                    text: asking ? "Want me to proceed?" : "All done.", isPartial: false)))
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            }
        }
    }

    @Test func isCompletedDistinguishesDoneFromAwaiting() {
        func summary(_ status: SessionStatus, _ disposition: TurnDisposition? = nil) -> SessionSummary {
            let session = Session(
                id: .generate(), projectID: .generate(), backend: .claudeCode,
                title: "t", status: status, transcriptPath: "/tmp/t",
                lastTurnDisposition: disposition,
                createdAt: Date(timeIntervalSince1970: 0), updatedAt: Date(timeIntervalSince1970: 0))
            return SessionSummary(session, pendingApprovalCount: 0)
        }
        #expect(summary(.finished).isCompleted)
        #expect(summary(.awaitingInput, .completed).isCompleted)
        // Awaiting the user (asking, or not yet classified) is NOT "done".
        #expect(!summary(.awaitingInput, .awaitingInput).isCompleted)
        #expect(!summary(.awaitingInput, nil).isCompleted)
        #expect(!summary(.running).isCompleted)
        #expect(!summary(.awaitingApproval).isCompleted)
        #expect(!summary(.interrupted).isCompleted)
        #expect(!summary(.error).isCompleted)
        #expect(!summary(.idle).isCompleted)
        #expect(!summary(.provisioning).isCompleted)
    }

    @Test func needsAttentionFlagsChatsBlockedOnTheUser() {
        func summary(_ status: SessionStatus, disposition: TurnDisposition? = nil,
                     approvals: Int = 0, autoShipFailed: Bool = false,
                     autoShipConflict: Bool = false) -> SessionSummary {
            let session = Session(
                id: .generate(), projectID: .generate(), backend: .claudeCode,
                title: "t", status: status, transcriptPath: "/tmp/t",
                autoShipFailed: autoShipFailed, autoShipConflict: autoShipConflict,
                lastTurnDisposition: disposition,
                createdAt: Date(timeIntervalSince1970: 0), updatedAt: Date(timeIntervalSince1970: 0))
            return SessionSummary(session, pendingApprovalCount: approvals)
        }
        // Blocked on a decision, or asking the user something (incl. not-yet-classified).
        #expect(summary(.awaitingApproval).needsAttention)
        #expect(summary(.awaitingInput, disposition: .awaitingInput).needsAttention)
        #expect(summary(.awaitingInput, disposition: nil).needsAttention)
        #expect(summary(.idle, approvals: 1).needsAttention)
        // Autoship stalled (hard failure, or armed-but-conflicted) needs a hand.
        #expect(summary(.running, autoShipFailed: true).needsAttention)
        #expect(summary(.running, autoShipConflict: true).needsAttention)
        // A chat that simply finished its work is NOT "needs attention" (unread, not blocked).
        #expect(!summary(.awaitingInput, disposition: .completed).needsAttention)
        #expect(!summary(.finished).needsAttention)
        #expect(!summary(.running).needsAttention)
        #expect(!summary(.idle).needsAttention)
        #expect(!summary(.provisioning).needsAttention)
    }

    @Test func autoArchiveSweepArchivesCompletedIdleChats() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let clock = LockedBox(Date(timeIntervalSince1970: 1_700_000_000))
        let store = makeArchiveStore(repo: repo, clock: clock)
        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
        let pid = project.id

        let done = try await store.createSession(in: project, title: "done", prompt: "go")
        let open = try await store.createSession(in: project, title: "done-open", prompt: "go")
        let fav = try await store.createSession(in: project, title: "done-fav", prompt: "go")
        let asking = try await store.createSession(in: project, title: "asking", prompt: "go")

        func disposition(_ id: SessionID) -> TurnDisposition? {
            store.summaries.first { $0.id == id }?.disposition
        }
        // Let every turn finish and be classified before we judge idleness.
        await waitFor {
            disposition(done) == .completed && disposition(open) == .completed
                && disposition(fav) == .completed && disposition(asking) == .awaitingInput
        }
        #expect(disposition(asking) == .awaitingInput)

        await store.setSessionFavorite(fav, true)
        store.openSessionID = open

        // "Never" (nil interval) archives nothing, even after a long idle.
        store.autoArchiveIdleInterval = nil
        clock.set(clock.get().addingTimeInterval(60 * 60))
        await store.sweepAutoArchive()
        #expect(store.summaries(for: pid).count == 4)
        #expect(store.archivedSummaries(for: pid).isEmpty)

        // 30-minute policy: only the plain completed-and-idle chat is archived; the
        // favorite, the open chat, and the one still asking a question are spared.
        store.autoArchiveIdleInterval = 30 * 60
        await store.sweepAutoArchive()

        #expect(store.archivedSummaries(for: pid).map(\.id) == [done])
        #expect(Set(store.summaries(for: pid).map(\.id)) == Set([open, fav, asking]))
    }
}
