Kaynağa Gözat

feat: add interactive branch CLI commands

Ma 7 ay önce
ebeveyn
işleme
6c92d2ea1d

+ 211 - 0
packages/cli/src/__tests__/cli-integration.test.ts

@@ -265,6 +265,217 @@ describe("CLI integration", () => {
     });
     });
   });
   });
 
 
+  describe("inkos branch", () => {
+    it("lists branch tree and pending choices for an interactive book", async () => {
+      const state = new StateManager(projectDir);
+      const bookId = "interactive-cli";
+      const bookDir = state.bookDir(bookId);
+      const storyDir = join(bookDir, "story");
+      await mkdir(join(bookDir, "chapters"), { recursive: true });
+      await mkdir(storyDir, { recursive: true });
+
+      await writeFile(
+        join(bookDir, "book.json"),
+        JSON.stringify({
+          id: bookId,
+          title: "Interactive CLI Book",
+          platform: "other",
+          genre: "other",
+          status: "active",
+          targetChapters: 10,
+          chapterWordCount: 2200,
+          narrativeMode: "interactive-tree",
+          createdAt: "2026-03-30T00:00:00.000Z",
+          updatedAt: "2026-03-30T00:00:00.000Z",
+        }, null, 2),
+        "utf-8",
+      );
+      await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8");
+      await state.saveBranchTree(bookId, {
+        version: 1,
+        rootNodeId: "root",
+        activeNodeId: "root",
+        nodes: [
+          {
+            nodeId: "root",
+            parentNodeId: null,
+            sourceChapterId: null,
+            sourceChapterNumber: 0,
+            branchDepth: 0,
+            branchLabel: "Main Route",
+            status: "awaiting-choice",
+            snapshotRef: { chapterNumber: 1 },
+            selectedChoiceId: null,
+            chapterIds: ["ch-0001"],
+            displayPath: "main",
+          },
+          {
+            nodeId: "node-a",
+            parentNodeId: "root",
+            sourceChapterId: "ch-0001",
+            sourceChapterNumber: 1,
+            branchDepth: 1,
+            branchLabel: "接受交易",
+            status: "dormant",
+            snapshotRef: { chapterNumber: 1 },
+            selectedChoiceId: null,
+            chapterIds: [],
+            displayPath: "main.a",
+          },
+        ],
+        choices: [
+          {
+            choiceId: "choice-root-a",
+            fromNodeId: "root",
+            toNodeId: "node-a",
+            label: "接受交易",
+            intent: "接受看守的交易。",
+            immediateGoal: "今晚进入档案室。",
+            expectedCost: "欠下一笔人情。",
+            expectedRisk: "会被持续监视。",
+            hookPressure: "看守线推进。",
+            characterPressure: "同伴信任下降。",
+            tone: "紧张",
+            selected: false,
+          },
+        ],
+      });
+
+      const treeOutput = run(["branch", "tree", bookId]);
+      expect(treeOutput).toContain("Main Route");
+      expect(treeOutput).toContain("node-a");
+
+      const choicesOutput = run(["branch", "choices", bookId, "--json"]);
+      const choices = JSON.parse(choicesOutput);
+      expect(choices.activeNodeId).toBe("root");
+      expect(choices.choices).toHaveLength(1);
+      expect(choices.choices[0].choiceId).toBe("choice-root-a");
+    });
+
+    it("chooses and switches interactive branches", async () => {
+      const state = new StateManager(projectDir);
+      const bookId = "interactive-switch";
+      const bookDir = state.bookDir(bookId);
+      const storyDir = join(bookDir, "story");
+      await mkdir(join(bookDir, "chapters"), { recursive: true });
+      await mkdir(storyDir, { recursive: true });
+      await mkdir(join(storyDir, "snapshots", "1"), { recursive: true });
+      await mkdir(join(storyDir, "snapshots", "2"), { recursive: true });
+
+      await writeFile(
+        join(bookDir, "book.json"),
+        JSON.stringify({
+          id: bookId,
+          title: "Interactive Switch Book",
+          platform: "other",
+          genre: "other",
+          status: "active",
+          targetChapters: 10,
+          chapterWordCount: 2200,
+          narrativeMode: "interactive-tree",
+          createdAt: "2026-03-30T00:00:00.000Z",
+          updatedAt: "2026-03-30T00:00:00.000Z",
+        }, null, 2),
+        "utf-8",
+      );
+      await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8");
+      await Promise.all([
+        writeFile(join(storyDir, "snapshots", "1", "current_state.md"), "# Current State\n\n- Root snapshot.\n", "utf-8"),
+        writeFile(join(storyDir, "snapshots", "1", "pending_hooks.md"), "# Pending Hooks\n\n- Root hook.\n", "utf-8"),
+        writeFile(join(storyDir, "snapshots", "2", "current_state.md"), "# Current State\n\n- Route A snapshot.\n", "utf-8"),
+        writeFile(join(storyDir, "snapshots", "2", "pending_hooks.md"), "# Pending Hooks\n\n- Route A hook.\n", "utf-8"),
+      ]);
+      await state.saveBranchTree(bookId, {
+        version: 1,
+        rootNodeId: "root",
+        activeNodeId: "root",
+        nodes: [
+          {
+            nodeId: "root",
+            parentNodeId: null,
+            sourceChapterId: null,
+            sourceChapterNumber: 0,
+            branchDepth: 0,
+            branchLabel: "Main Route",
+            status: "awaiting-choice",
+            snapshotRef: { chapterNumber: 1 },
+            selectedChoiceId: null,
+            chapterIds: ["ch-0001"],
+            displayPath: "main",
+          },
+          {
+            nodeId: "node-a",
+            parentNodeId: "root",
+            sourceChapterId: "ch-0001",
+            sourceChapterNumber: 1,
+            branchDepth: 1,
+            branchLabel: "A Route",
+            status: "dormant",
+            snapshotRef: { chapterNumber: 2 },
+            selectedChoiceId: null,
+            chapterIds: ["ch-0002"],
+            displayPath: "main.a",
+          },
+          {
+            nodeId: "node-b",
+            parentNodeId: "root",
+            sourceChapterId: "ch-0001",
+            sourceChapterNumber: 1,
+            branchDepth: 1,
+            branchLabel: "B Route",
+            status: "dormant",
+            snapshotRef: { chapterNumber: 1 },
+            selectedChoiceId: null,
+            chapterIds: [],
+            displayPath: "main.b",
+          },
+        ],
+        choices: [
+          {
+            choiceId: "choice-root-a",
+            fromNodeId: "root",
+            toNodeId: "node-a",
+            label: "A Route",
+            intent: "Take route A.",
+            immediateGoal: "Advance route A.",
+            expectedCost: "Leave B behind.",
+            expectedRisk: "Route A escalates.",
+            hookPressure: "A hook advances.",
+            characterPressure: "A pressure rises.",
+            tone: "tense",
+            selected: false,
+          },
+          {
+            choiceId: "choice-root-b",
+            fromNodeId: "root",
+            toNodeId: "node-b",
+            label: "B Route",
+            intent: "Take route B.",
+            immediateGoal: "Advance route B.",
+            expectedCost: "Lose A momentum.",
+            expectedRisk: "Route B resets pressure.",
+            hookPressure: "B hook revives.",
+            characterPressure: "B pressure returns.",
+            tone: "quiet",
+            selected: false,
+          },
+        ],
+      });
+
+      const chooseRun = runStderr(["branch", "choose", bookId, "choice-root-a", "--json"]);
+      expect(chooseRun.exitCode).toBe(0);
+      const chooseResult = JSON.parse(chooseRun.stdout);
+      expect(chooseResult.activeNodeId).toBe("node-a");
+
+      const switchRun = runStderr(["branch", "switch", bookId, "node-b", "--json"]);
+      expect(switchRun.exitCode).toBe(0);
+      const switchResult = JSON.parse(switchRun.stdout);
+      expect(switchResult.activeNodeId).toBe("node-b");
+      await expect(readFile(join(storyDir, "current_state.md"), "utf-8"))
+        .resolves.toContain("Root snapshot.");
+    });
+  });
+
   describe("inkos status", () => {
   describe("inkos status", () => {
     it("shows project status with zero books", () => {
     it("shows project status with zero books", () => {
       const output = run(["status"]);
       const output = run(["status"]);

+ 195 - 0
packages/cli/src/commands/branch.ts

@@ -0,0 +1,195 @@
+import { Command } from "commander";
+import { PipelineRunner, StateManager } from "@actalk/inkos-core";
+import { findProjectRoot, log, logError, resolveBookId } from "../utils.js";
+
+export const branchCommand = new Command("branch")
+  .description("Inspect and control interactive fiction branches");
+
+branchCommand
+  .command("tree")
+  .description("Show the interactive branch tree")
+  .argument("[book-id]", "Book ID (auto-detected if only one book)")
+  .option("--json", "Output JSON")
+  .action(async (bookIdArg: string | undefined, opts) => {
+    try {
+      const root = findProjectRoot();
+      const state = new StateManager(root);
+      const bookId = await resolveBookId(bookIdArg, root);
+      const tree = await loadInteractiveTreeOrThrow(state, bookId);
+
+      if (opts.json) {
+        log(JSON.stringify(tree, null, 2));
+        return;
+      }
+
+      log(`Interactive Branch Tree: ${bookId}`);
+      for (const node of tree.nodes) {
+        const marker = node.nodeId === tree.activeNodeId ? "*" : "-";
+        log(`${marker} ${node.nodeId} [${node.status}] ${node.branchLabel} (${node.displayPath})`);
+      }
+    } catch (error) {
+      if (opts.json) {
+        log(JSON.stringify({ error: String(error) }));
+      } else {
+        logError(`Failed to show branch tree: ${error}`);
+      }
+      process.exit(1);
+    }
+  });
+
+branchCommand
+  .command("choices")
+  .description("Show pending choices on the active branch")
+  .argument("[book-id]", "Book ID (auto-detected if only one book)")
+  .option("--json", "Output JSON")
+  .action(async (bookIdArg: string | undefined, opts) => {
+    try {
+      const root = findProjectRoot();
+      const state = new StateManager(root);
+      const bookId = await resolveBookId(bookIdArg, root);
+      const tree = await loadInteractiveTreeOrThrow(state, bookId);
+      const activeNode = tree.nodes.find((node) => node.nodeId === tree.activeNodeId);
+      if (!activeNode) {
+        throw new Error(`Interactive branch tree is missing active node "${tree.activeNodeId}".`);
+      }
+
+      const result = {
+        bookId,
+        activeNodeId: activeNode.nodeId,
+        activeNodeStatus: activeNode.status,
+        choices: tree.choices.filter((choice) => choice.fromNodeId === activeNode.nodeId),
+      };
+
+      if (opts.json) {
+        log(JSON.stringify(result, null, 2));
+        return;
+      }
+
+      log(`Active Branch: ${activeNode.nodeId} [${activeNode.status}]`);
+      if (result.choices.length === 0) {
+        log("No pending choices.");
+        return;
+      }
+      for (const choice of result.choices) {
+        log(`- ${choice.choiceId}: ${choice.label} -> ${choice.toNodeId}`);
+      }
+    } catch (error) {
+      if (opts.json) {
+        log(JSON.stringify({ error: String(error) }));
+      } else {
+        logError(`Failed to show branch choices: ${error}`);
+      }
+      process.exit(1);
+    }
+  });
+
+branchCommand
+  .command("choose")
+  .description("Choose one pending branch option")
+  .argument("<args...>", "Book ID (optional) and choice ID")
+  .option("--json", "Output JSON")
+  .action(async (args: ReadonlyArray<string>, opts) => {
+    try {
+      const root = findProjectRoot();
+      const { bookId, targetId: choiceId } = await resolveInteractiveTargetArgs(args, root, "choice");
+      const pipeline = createLocalBranchPipeline(root);
+      const result = await pipeline.chooseInteractiveBranch(bookId, choiceId);
+
+      if (opts.json) {
+        log(JSON.stringify(result, null, 2));
+        return;
+      }
+
+      log(`Selected ${choiceId}. Active branch is now ${result.activeNodeId}.`);
+    } catch (error) {
+      if (opts.json) {
+        log(JSON.stringify({ error: String(error) }));
+      } else {
+        logError(`Failed to choose branch: ${error}`);
+      }
+      process.exit(1);
+    }
+  });
+
+branchCommand
+  .command("switch")
+  .description("Switch to an existing branch node")
+  .argument("<args...>", "Book ID (optional) and node ID")
+  .option("--json", "Output JSON")
+  .action(async (args: ReadonlyArray<string>, opts) => {
+    try {
+      const root = findProjectRoot();
+      const { bookId, targetId: nodeId } = await resolveInteractiveTargetArgs(args, root, "node");
+      const pipeline = createLocalBranchPipeline(root);
+      const result = await pipeline.switchInteractiveBranch(bookId, nodeId);
+
+      if (opts.json) {
+        log(JSON.stringify(result, null, 2));
+        return;
+      }
+
+      log(`Switched active branch to ${result.activeNodeId} (snapshot ${result.restoredChapter}).`);
+    } catch (error) {
+      if (opts.json) {
+        log(JSON.stringify({ error: String(error) }));
+      } else {
+        logError(`Failed to switch branch: ${error}`);
+      }
+      process.exit(1);
+    }
+  });
+
+async function loadInteractiveTreeOrThrow(state: StateManager, bookId: string) {
+  const book = await state.loadBookConfig(bookId);
+  if (book.narrativeMode !== "interactive-tree") {
+    throw new Error(`Book "${bookId}" is not an interactive-tree book.`);
+  }
+
+  const tree = await state.loadBranchTree(bookId);
+  if (!tree) {
+    throw new Error(`Interactive branch tree is missing for "${bookId}".`);
+  }
+
+  return tree;
+}
+
+async function resolveInteractiveTargetArgs(
+  args: ReadonlyArray<string>,
+  root: string,
+  targetLabel: "choice" | "node",
+): Promise<{ bookId: string; targetId: string }> {
+  if (args.length === 1) {
+    return {
+      bookId: await resolveBookId(undefined, root),
+      targetId: args[0]!,
+    };
+  }
+
+  if (args.length === 2) {
+    return {
+      bookId: await resolveBookId(args[0], root),
+      targetId: args[1]!,
+    };
+  }
+
+  throw new Error(`Usage: inkos branch ${targetLabel === "choice" ? "choose" : "switch"} [book-id] <${targetLabel}-id>`);
+}
+
+function createLocalBranchPipeline(root: string): PipelineRunner {
+  return new PipelineRunner({
+    client: {
+      provider: "openai",
+      apiFormat: "chat",
+      stream: false,
+      defaults: {
+        temperature: 0,
+        maxTokens: 1,
+        thinkingBudget: 0,
+        maxTokensCap: null,
+        extra: {},
+      },
+    } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
+    model: "noop-model",
+    projectRoot: root,
+  });
+}

+ 2 - 0
packages/cli/src/index.ts

@@ -28,6 +28,7 @@ import { importCommand } from "./commands/import.js";
 import { fanficCommand } from "./commands/fanfic.js";
 import { fanficCommand } from "./commands/fanfic.js";
 import { studioCommand } from "./commands/studio.js";
 import { studioCommand } from "./commands/studio.js";
 import { consolidateCommand } from "./commands/consolidate.js";
 import { consolidateCommand } from "./commands/consolidate.js";
+import { branchCommand } from "./commands/branch.js";
 
 
 const require = createRequire(import.meta.url);
 const require = createRequire(import.meta.url);
 const { version } = require("../package.json") as { version: string };
 const { version } = require("../package.json") as { version: string };
@@ -66,5 +67,6 @@ program.addCommand(importCommand);
 program.addCommand(fanficCommand);
 program.addCommand(fanficCommand);
 program.addCommand(studioCommand);
 program.addCommand(studioCommand);
 program.addCommand(consolidateCommand);
 program.addCommand(consolidateCommand);
+program.addCommand(branchCommand);
 
 
 program.parse();
 program.parse();

+ 115 - 0
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -582,6 +582,121 @@ describe("PipelineRunner", () => {
     }
     }
   });
   });
 
 
+  it("restores the referenced snapshot when switching interactive branches", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const baseBook = await state.loadBookConfig(bookId);
+    await state.saveBookConfig(bookId, {
+      ...baseBook,
+      narrativeMode: "interactive-tree",
+    });
+
+    const storyDir = join(state.bookDir(bookId), "story");
+    await mkdir(storyDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Branch root aftermath.\n", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n- Root hook.\n", "utf-8"),
+    ]);
+    await state.snapshotState(bookId, 1);
+
+    await Promise.all([
+      writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Branch A later state.\n", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n- Branch A hook.\n", "utf-8"),
+    ]);
+    await state.snapshotState(bookId, 2);
+
+    await state.saveBranchTree(bookId, {
+      version: 1,
+      rootNodeId: "root",
+      activeNodeId: "node-a",
+      nodes: [
+        {
+          nodeId: "root",
+          parentNodeId: null,
+          sourceChapterId: null,
+          sourceChapterNumber: 0,
+          branchDepth: 0,
+          branchLabel: "Main Route",
+          status: "completed",
+          snapshotRef: { chapterNumber: 1 },
+          selectedChoiceId: "choice-root-a",
+          chapterIds: ["ch-0001"],
+          displayPath: "main",
+        },
+        {
+          nodeId: "node-a",
+          parentNodeId: "root",
+          sourceChapterId: "ch-0001",
+          sourceChapterNumber: 1,
+          branchDepth: 1,
+          branchLabel: "A Route",
+          status: "active",
+          snapshotRef: { chapterNumber: 2 },
+          selectedChoiceId: null,
+          chapterIds: ["ch-0002"],
+          displayPath: "main.a",
+        },
+        {
+          nodeId: "node-b",
+          parentNodeId: "root",
+          sourceChapterId: "ch-0001",
+          sourceChapterNumber: 1,
+          branchDepth: 1,
+          branchLabel: "B Route",
+          status: "dormant",
+          snapshotRef: { chapterNumber: 1 },
+          selectedChoiceId: null,
+          chapterIds: [],
+          displayPath: "main.b",
+        },
+      ],
+      choices: [
+        {
+          choiceId: "choice-root-a",
+          fromNodeId: "root",
+          toNodeId: "node-a",
+          label: "A Route",
+          intent: "Take route A.",
+          immediateGoal: "Advance route A.",
+          expectedCost: "Leave route B untouched.",
+          expectedRisk: "Route A escalates.",
+          hookPressure: "A hook moves.",
+          characterPressure: "A pressure rises.",
+          tone: "tense",
+          selected: true,
+        },
+        {
+          choiceId: "choice-root-b",
+          fromNodeId: "root",
+          toNodeId: "node-b",
+          label: "B Route",
+          intent: "Return to the earlier B route.",
+          immediateGoal: "Restore route B state.",
+          expectedCost: "Lose route A momentum.",
+          expectedRisk: "Need to rebuild pressure.",
+          hookPressure: "B hook wakes up.",
+          characterPressure: "B relationship strain returns.",
+          tone: "quiet",
+          selected: false,
+        },
+      ],
+    });
+
+    try {
+      const result = await runner.switchInteractiveBranch(bookId, "node-b");
+
+      expect(result.activeNodeId).toBe("node-b");
+      await expect(readFile(join(storyDir, "current_state.md"), "utf-8"))
+        .resolves.toContain("Branch root aftermath.");
+      const tree = await state.loadBranchTree(bookId);
+      expect(tree?.activeNodeId).toBe("node-b");
+      expect(tree?.nodes.find((node) => node.nodeId === "node-a")?.status).toBe("dormant");
+      expect(tree?.nodes.find((node) => node.nodeId === "node-b")?.status).toBe("active");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("bootstraps missing control documents for legacy books before writing", async () => {
   it("bootstraps missing control documents for legacy books before writing", async () => {
     const { root, runner, bookId } = await createRunnerFixture();
     const { root, runner, bookId } = await createRunnerFixture();
 
 

+ 161 - 0
packages/core/src/pipeline/runner.ts

@@ -3,6 +3,7 @@ import { chatCompletion, createLLMClient } from "../llm/provider.js";
 import type { Logger } from "../utils/logger.js";
 import type { Logger } from "../utils/logger.js";
 import type { BookConfig, FanficMode } from "../models/book.js";
 import type { BookConfig, FanficMode } from "../models/book.js";
 import type { ChapterMeta } from "../models/chapter.js";
 import type { ChapterMeta } from "../models/chapter.js";
+import type { InteractiveBranchTree } from "../models/interactive-fiction.js";
 import type { NotifyChannel, LLMConfig, AgentLLMOverride, InputGovernanceMode } from "../models/project.js";
 import type { NotifyChannel, LLMConfig, AgentLLMOverride, InputGovernanceMode } from "../models/project.js";
 import type { GenreProfile } from "../models/genre-profile.js";
 import type { GenreProfile } from "../models/genre-profile.js";
 import { ArchitectAgent } from "../agents/architect.js";
 import { ArchitectAgent } from "../agents/architect.js";
@@ -126,6 +127,20 @@ export interface BookStatusInfo {
   readonly chapters: ReadonlyArray<ChapterMeta>;
   readonly chapters: ReadonlyArray<ChapterMeta>;
 }
 }
 
 
+export interface InteractiveBranchChoicesResult {
+  readonly bookId: string;
+  readonly activeNodeId: string;
+  readonly activeNodeStatus: string;
+  readonly choices: ReadonlyArray<InteractiveBranchTree["choices"][number]>;
+}
+
+export interface InteractiveBranchActivationResult {
+  readonly bookId: string;
+  readonly activeNodeId: string;
+  readonly restoredChapter: number;
+  readonly selectedChoiceId?: string;
+}
+
 export interface ImportChaptersInput {
 export interface ImportChaptersInput {
   readonly bookId: string;
   readonly bookId: string;
   readonly chapters: ReadonlyArray<{ readonly title: string; readonly content: string }>;
   readonly chapters: ReadonlyArray<{ readonly title: string; readonly content: string }>;
@@ -935,6 +950,63 @@ export class PipelineRunner {
     };
     };
   }
   }
 
 
+  async getInteractiveBranchTree(bookId: string): Promise<InteractiveBranchTree> {
+    await this.assertInteractiveBook(bookId);
+    const tree = await this.state.loadBranchTree(bookId);
+    if (!tree) {
+      throw new Error(`Interactive branch tree is missing for "${bookId}".`);
+    }
+    return tree;
+  }
+
+  async listInteractiveChoices(bookId: string): Promise<InteractiveBranchChoicesResult> {
+    const tree = await this.getInteractiveBranchTree(bookId);
+    const activeNode = tree.nodes.find((node) => node.nodeId === tree.activeNodeId);
+    if (!activeNode) {
+      throw new Error(`Interactive branch tree is missing active node "${tree.activeNodeId}".`);
+    }
+
+    return {
+      bookId,
+      activeNodeId: activeNode.nodeId,
+      activeNodeStatus: activeNode.status,
+      choices: tree.choices.filter((choice) => choice.fromNodeId === activeNode.nodeId),
+    };
+  }
+
+  async chooseInteractiveBranch(bookId: string, choiceId: string): Promise<InteractiveBranchActivationResult> {
+    const releaseLock = await this.state.acquireBookLock(bookId);
+    try {
+      const tree = await this.getInteractiveBranchTree(bookId);
+      const activeNode = tree.nodes.find((node) => node.nodeId === tree.activeNodeId);
+      if (!activeNode) {
+        throw new Error(`Interactive branch tree is missing active node "${tree.activeNodeId}".`);
+      }
+      if (activeNode.status !== "awaiting-choice") {
+        throw new Error(`Branch ${activeNode.nodeId} is not waiting for a choice.`);
+      }
+
+      const choice = tree.choices.find((candidate) => candidate.choiceId === choiceId);
+      if (!choice || choice.fromNodeId !== activeNode.nodeId) {
+        throw new Error(`Choice "${choiceId}" is not available on the active branch.`);
+      }
+
+      return this.activateInteractiveBranch(bookId, tree, choice.toNodeId, choice.choiceId);
+    } finally {
+      await releaseLock();
+    }
+  }
+
+  async switchInteractiveBranch(bookId: string, nodeId: string): Promise<InteractiveBranchActivationResult> {
+    const releaseLock = await this.state.acquireBookLock(bookId);
+    try {
+      const tree = await this.getInteractiveBranchTree(bookId);
+      return this.activateInteractiveBranch(bookId, tree, nodeId);
+    } finally {
+      await releaseLock();
+    }
+  }
+
   // ---------------------------------------------------------------------------
   // ---------------------------------------------------------------------------
   // Full pipeline (convenience — runs draft + audit + revise in one shot)
   // Full pipeline (convenience — runs draft + audit + revise in one shot)
   // ---------------------------------------------------------------------------
   // ---------------------------------------------------------------------------
@@ -1866,6 +1938,13 @@ ${matrix}`,
     return `ch-${String(chapterNumber).padStart(4, "0")}`;
     return `ch-${String(chapterNumber).padStart(4, "0")}`;
   }
   }
 
 
+  private async assertInteractiveBook(bookId: string): Promise<void> {
+    const book = await this.state.loadBookConfig(bookId);
+    if (book.narrativeMode !== "interactive-tree") {
+      throw new Error(`Book "${bookId}" is not an interactive-tree book.`);
+    }
+  }
+
   private async assertInteractiveChoiceSelected(bookId: string, book: Pick<BookConfig, "narrativeMode">): Promise<void> {
   private async assertInteractiveChoiceSelected(bookId: string, book: Pick<BookConfig, "narrativeMode">): Promise<void> {
     if (book.narrativeMode !== "interactive-tree") {
     if (book.narrativeMode !== "interactive-tree") {
       return;
       return;
@@ -1882,6 +1961,88 @@ ${matrix}`,
     );
     );
   }
   }
 
 
+  private async activateInteractiveBranch(
+    bookId: string,
+    branchTree: InteractiveBranchTree,
+    targetNodeId: string,
+    selectedChoiceId?: string,
+  ): Promise<InteractiveBranchActivationResult> {
+    const targetNode = branchTree.nodes.find((node) => node.nodeId === targetNodeId);
+    if (!targetNode) {
+      throw new Error(`Interactive branch "${targetNodeId}" not found.`);
+    }
+
+    const restored = await this.state.restoreState(bookId, targetNode.snapshotRef.chapterNumber);
+    if (!restored) {
+      throw new Error(
+        `Failed to restore snapshot ${targetNode.snapshotRef.chapterNumber} for branch "${targetNodeId}".`,
+      );
+    }
+
+    const currentActiveNode = branchTree.nodes.find((node) => node.nodeId === branchTree.activeNodeId) ?? null;
+    const inboundChoice = selectedChoiceId
+      ? branchTree.choices.find((choice) => choice.choiceId === selectedChoiceId)
+      : branchTree.choices.find((choice) => choice.toNodeId === targetNodeId);
+    const shouldResolveParentChoice = Boolean(
+      inboundChoice
+      && currentActiveNode
+      && currentActiveNode.nodeId === inboundChoice.fromNodeId
+      && currentActiveNode.status === "awaiting-choice",
+    );
+
+    const updatedNodes = branchTree.nodes.map((node) => {
+      if (node.nodeId === targetNodeId) {
+        return {
+          ...node,
+          status: "active" as const,
+        };
+      }
+
+      if (shouldResolveParentChoice && inboundChoice && node.nodeId === inboundChoice.fromNodeId) {
+        return {
+          ...node,
+          status: "completed" as const,
+          selectedChoiceId: inboundChoice.choiceId,
+        };
+      }
+
+      if (currentActiveNode && node.nodeId === currentActiveNode.nodeId && node.nodeId !== targetNodeId) {
+        if (currentActiveNode.status === "active") {
+          return {
+            ...node,
+            status: currentActiveNode.selectedChoiceId ? "completed" as const : "dormant" as const,
+          };
+        }
+      }
+
+      return node;
+    });
+
+    const updatedChoices = branchTree.choices.map((choice) => {
+      if (shouldResolveParentChoice && inboundChoice && choice.fromNodeId === inboundChoice.fromNodeId) {
+        return {
+          ...choice,
+          selected: choice.choiceId === inboundChoice.choiceId,
+        };
+      }
+      return choice;
+    });
+
+    await this.state.saveBranchTree(bookId, {
+      ...branchTree,
+      activeNodeId: targetNodeId,
+      nodes: updatedNodes,
+      choices: updatedChoices,
+    });
+
+    return {
+      bookId,
+      activeNodeId: targetNodeId,
+      restoredChapter: targetNode.snapshotRef.chapterNumber,
+      ...(inboundChoice?.choiceId ? { selectedChoiceId: inboundChoice.choiceId } : {}),
+    };
+  }
+
   private async buildPersistenceOutput(
   private async buildPersistenceOutput(
     bookId: string,
     bookId: string,
     book: BookConfig,
     book: BookConfig,