Просмотр исходного кода

feat: default interactive views to the active branch

Ma 7 месяцев назад
Родитель
Сommit
164af1c2bd

+ 6 - 5
README.en.md

@@ -364,7 +364,7 @@ inkos branch tree my-book
 inkos branch switch my-book <node-id>
 ```
 
-`interactive-tree` v1 is chapter-end branching only for now. Free-form player actions are out of scope, and `export` / `review` still operate on the active branch by default.
+`interactive-tree` v1 is chapter-end branching only for now. Free-form player actions are out of scope, and `status` / `review` / `export` / `eval` operate on the active branch by default. Use `--all-branches` when you need the full branch tree.
 
 | Command | Description |
 |---------|-------------|
@@ -382,14 +382,15 @@ inkos branch switch my-book <node-id>
 | `inkos audit [id] [n]` | Audit a specific chapter |
 | `inkos revise [id] [n]` | Revise a specific chapter |
 | `inkos agent <instruction>` | Natural language agent mode |
-| `inkos review list [id]` | Review drafts |
-| `inkos review approve-all [id]` | Batch approve |
-| `inkos status [id]` | Project status |
+| `inkos review list [id]` | Review drafts (interactive books default to the active branch; use `--all-branches` for the whole tree) |
+| `inkos review approve-all [id]` | Batch approve (interactive books default to the active branch; use `--all-branches` for the whole tree) |
+| `inkos status [id]` | Project status (interactive books default to the active branch; use `--all-branches` for the whole tree) |
 | `inkos branch tree [id]` | Show the interactive branch tree |
 | `inkos branch choices [id]` | Show pending choices on the active branch |
 | `inkos branch choose [id] <choice-id>` | Choose one pending branch option |
 | `inkos branch switch [id] <node-id>` | Switch to an existing branch and restore its snapshot |
-| `inkos export [id]` | Export book (`--format txt/md/epub`, `--output <path>`, `--approved-only`) |
+| `inkos export [id]` | Export book (`--format txt/md/epub`, `--output <path>`, `--approved-only`, `--all-branches`) |
+| `inkos eval [id]` | Evaluate book quality (interactive books default to the active branch; use `--all-branches` for the whole tree) |
 | `inkos fanfic init` | Create a fanfic book from source material (`--from`, `--mode canon/au/ooc/cp`) |
 | `inkos config set-global` | Set global LLM config (~/.inkos/.env) |
 | `inkos config set-model <agent> <model>` | Per-agent model override (`--base-url`, `--provider`, `--api-key-env`) |

+ 6 - 5
README.md

@@ -352,7 +352,7 @@ inkos branch tree my-book
 inkos branch switch my-book <node-id>
 ```
 
-`interactive-tree` v1 当前只做章节末分支,不支持自由输入动作;`export` / `review` 默认仍只看当前 active branch。
+`interactive-tree` v1 当前只做章节末分支,不支持自由输入动作;`status` / `review` / `export` / `eval` 默认只看当前 active branch,如需查看整棵分支树可加 `--all-branches`。
 
 | 命令 | 说明 |
 |------|------|
@@ -370,14 +370,15 @@ inkos branch switch my-book <node-id>
 | `inkos audit [id] [n]` | 审计指定章节 |
 | `inkos revise [id] [n]` | 修订指定章节 |
 | `inkos agent <instruction>` | 自然语言 Agent 模式 |
-| `inkos review list [id]` | 审阅草稿 |
-| `inkos review approve-all [id]` | 批量通过 |
-| `inkos status [id]` | 项目状态 |
+| `inkos review list [id]` | 审阅草稿(互动书默认只看当前分支,`--all-branches` 查看全部分支) |
+| `inkos review approve-all [id]` | 批量通过(互动书默认只通过当前分支,`--all-branches` 通过全部分支) |
+| `inkos status [id]` | 项目状态(互动书默认显示当前分支,`--all-branches` 查看全部分支) |
 | `inkos branch tree [id]` | 查看互动分支树 |
 | `inkos branch choices [id]` | 查看当前 active branch 的待选项 |
 | `inkos branch choose [id] <choice-id>` | 选择一个待选分支 |
 | `inkos branch switch [id] <node-id>` | 切换到已有分支并恢复对应快照 |
-| `inkos export [id]` | 导出书籍(`--format txt/md/epub`、`--output <path>`、`--approved-only`) |
+| `inkos export [id]` | 导出书籍(`--format txt/md/epub`、`--output <path>`、`--approved-only`、`--all-branches`) |
+| `inkos eval [id]` | 评估书籍质量(互动书默认只看当前分支,`--all-branches` 查看全部分支) |
 | `inkos radar scan` | 扫描平台趋势 |
 | `inkos fanfic init` | 从原作素材创建同人书(`--from`、`--mode canon/au/ooc/cp`) |
 | `inkos config set-global` | 设置全局 LLM 配置(~/.inkos/.env) |

+ 220 - 14
packages/cli/src/__tests__/cli-integration.test.ts

@@ -12,26 +12,29 @@ const cliEntry = resolve(cliDir, "dist", "index.js");
 
 let projectDir: string;
 
-function run(args: string[], options?: { env?: Record<string, string> }): string {
+function run(args: string[], options?: { env?: Record<string, string>; cwd?: string }): string {
   return execFileSync("node", [cliEntry, ...args], {
-    cwd: projectDir,
+    cwd: options?.cwd ?? projectDir,
     encoding: "utf-8",
     env: {
       ...process.env,
       // Prevent global config from leaking into tests
-      HOME: projectDir,
+      HOME: options?.cwd ?? projectDir,
       ...options?.env,
     },
     timeout: 10_000,
   });
 }
 
-function runStderr(args: string[], options?: { env?: Record<string, string> }): { stdout: string; stderr: string; exitCode: number } {
+function runStderr(
+  args: string[],
+  options?: { env?: Record<string, string>; cwd?: string },
+): { stdout: string; stderr: string; exitCode: number } {
   try {
     const stdout = execFileSync("node", [cliEntry, ...args], {
-      cwd: projectDir,
+      cwd: options?.cwd ?? projectDir,
       encoding: "utf-8",
-      env: { ...process.env, HOME: projectDir, ...options?.env },
+      env: { ...process.env, HOME: options?.cwd ?? projectDir, ...options?.env },
       timeout: 10_000,
     });
     return { stdout, stderr: "", exitCode: 0 };
@@ -57,6 +60,12 @@ describe("CLI integration", () => {
     await rm(projectDir, { recursive: true, force: true });
   });
 
+  async function createIsolatedProjectDir(prefix: string): Promise<string> {
+    const dir = await mkdtemp(join(tmpdir(), prefix));
+    run(["init"], { cwd: dir });
+    return dir;
+  }
+
   describe("inkos --version", () => {
     it("prints version number", () => {
       const output = run(["--version"]);
@@ -503,16 +512,26 @@ describe("CLI integration", () => {
   });
 
   describe("inkos status", () => {
-    it("shows project status with zero books", () => {
-      const output = run(["status"]);
-      expect(output).toContain("Books: 0");
+    it("shows project status with zero books", async () => {
+      const isolatedDir = await createIsolatedProjectDir("inkos-cli-status-empty-");
+      try {
+        const output = run(["status"], { cwd: isolatedDir });
+        expect(output).toContain("Books: 0");
+      } finally {
+        await rm(isolatedDir, { recursive: true, force: true });
+      }
     });
 
-    it("returns JSON with --json flag", () => {
-      const output = run(["status", "--json"]);
-      const data = JSON.parse(output);
-      expect(data.project).toBeDefined();
-      expect(data.books).toEqual([]);
+    it("returns JSON with --json flag", async () => {
+      const isolatedDir = await createIsolatedProjectDir("inkos-cli-status-json-");
+      try {
+        const output = run(["status", "--json"], { cwd: isolatedDir });
+        const data = JSON.parse(output);
+        expect(data.project).toBeDefined();
+        expect(data.books).toEqual([]);
+      } finally {
+        await rm(isolatedDir, { recursive: true, force: true });
+      }
     });
 
     it("errors for nonexistent book", () => {
@@ -660,6 +679,193 @@ describe("CLI integration", () => {
       const json = JSON.parse(run(["status", bookId, "--json"]));
       expect(json.books[0]?.chapters).toBe(1);
     });
+
+    it("defaults interactive status, review, export, and eval to the active branch", async () => {
+      const state = new StateManager(projectDir);
+      const bookId = "interactive-visible-cli";
+      const bookDir = state.bookDir(bookId);
+      const chaptersDir = join(bookDir, "chapters");
+      await mkdir(chaptersDir, { recursive: true });
+      await writeFile(
+        join(bookDir, "book.json"),
+        JSON.stringify({
+          id: bookId,
+          title: "Interactive Visible CLI",
+          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(chaptersDir, "index.json"),
+        JSON.stringify([
+          {
+            number: 1,
+            title: "Seed",
+            status: "ready-for-review",
+            wordCount: 100,
+            createdAt: "2026-03-30T00:00:00.000Z",
+            updatedAt: "2026-03-30T00:00:00.000Z",
+            auditIssues: [],
+            lengthWarnings: [],
+          },
+          {
+            number: 2,
+            title: "Route A",
+            status: "ready-for-review",
+            wordCount: 120,
+            createdAt: "2026-03-30T00:00:00.000Z",
+            updatedAt: "2026-03-30T00:00:00.000Z",
+            auditIssues: [],
+            lengthWarnings: [],
+          },
+          {
+            number: 3,
+            title: "Route B",
+            status: "ready-for-review",
+            wordCount: 140,
+            createdAt: "2026-03-30T00:00:00.000Z",
+            updatedAt: "2026-03-30T00:00:00.000Z",
+            auditIssues: [],
+            lengthWarnings: [],
+          },
+        ], null, 2),
+        "utf-8",
+      );
+      await Promise.all([
+        writeFile(join(chaptersDir, "0001_Seed.md"), "# Seed\n\nMain route chapter.\n", "utf-8"),
+        writeFile(join(chaptersDir, "0002_Route_A.md"), "# Route A\n\nActive branch chapter.\n", "utf-8"),
+        writeFile(join(chaptersDir, "0003_Route_B.md"), "# Route B\n\nDormant branch chapter.\n", "utf-8"),
+      ]);
+      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: "Route A",
+            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: "Route B",
+            status: "dormant",
+            snapshotRef: { chapterNumber: 1 },
+            selectedChoiceId: null,
+            chapterIds: ["ch-0003"],
+            displayPath: "main.b",
+          },
+        ],
+        choices: [
+          {
+            choiceId: "choice-root-a",
+            fromNodeId: "root",
+            toNodeId: "node-a",
+            label: "Route A",
+            intent: "Take route A.",
+            immediateGoal: "Advance route A.",
+            expectedCost: "Lose route B.",
+            expectedRisk: "Conflict rises.",
+            hookPressure: "A hook advances.",
+            characterPressure: "A pressure rises.",
+            tone: "tense",
+            selected: true,
+          },
+          {
+            choiceId: "choice-root-b",
+            fromNodeId: "root",
+            toNodeId: "node-b",
+            label: "Route B",
+            intent: "Take route B.",
+            immediateGoal: "Advance route B.",
+            expectedCost: "Lose route A.",
+            expectedRisk: "Momentum drops.",
+            hookPressure: "B hook advances.",
+            characterPressure: "B pressure rises.",
+            tone: "quiet",
+            selected: false,
+          },
+        ],
+      });
+
+      const statusText = run(["status", bookId, "--chapters"]);
+      expect(statusText).toContain("Chapters: 2 / 10");
+      expect(statusText).toContain("Active branch: main.a (node-a) | active");
+      expect(statusText).toContain('Ch.1 "Seed"');
+      expect(statusText).toContain('Ch.2 "Route A"');
+      expect(statusText).not.toContain('Ch.3 "Route B"');
+
+      const statusJson = JSON.parse(run(["status", bookId, "--json"]));
+      expect(statusJson.books[0]?.chapters).toBe(2);
+      expect(statusJson.books[0]?.activeBranch).toEqual({
+        activeNodeId: "node-a",
+        displayPath: "main.a",
+        status: "active",
+        visibleChapterNumbers: [1, 2],
+        pendingChoiceCount: 0,
+      });
+
+      const reviewJson = JSON.parse(run(["review", "list", bookId, "--json"]));
+      expect(reviewJson.pending.map((row: { chapter: number }) => row.chapter)).toEqual([1, 2]);
+
+      const exportDefault = join(projectDir, "interactive-default.txt");
+      run(["export", bookId, "--format", "txt", "--output", exportDefault]);
+      await expect(readFile(exportDefault, "utf-8")).resolves.toContain("Active branch chapter.");
+      await expect(readFile(exportDefault, "utf-8")).resolves.not.toContain("Dormant branch chapter.");
+
+      const evalJson = JSON.parse(run(["eval", bookId, "--json"]));
+      expect(evalJson.totalChapters).toBe(2);
+    });
+
+    it("supports --all-branches for interactive status, review, export, and eval", async () => {
+      const bookId = "interactive-visible-cli";
+
+      const statusText = run(["status", bookId, "--chapters", "--all-branches"]);
+      expect(statusText).toContain("Chapters: 3 / 10");
+      expect(statusText).toContain('Ch.3 "Route B"');
+
+      const reviewJson = JSON.parse(run(["review", "list", bookId, "--json", "--all-branches"]));
+      expect(reviewJson.pending.map((row: { chapter: number }) => row.chapter)).toEqual([1, 2, 3]);
+
+      const exportAll = join(projectDir, "interactive-all.txt");
+      run(["export", bookId, "--format", "txt", "--output", exportAll, "--all-branches"]);
+      await expect(readFile(exportAll, "utf-8")).resolves.toContain("Dormant branch chapter.");
+
+      const evalJson = JSON.parse(run(["eval", bookId, "--json", "--all-branches"]));
+      expect(evalJson.totalChapters).toBe(3);
+    });
   });
 
   describe("inkos doctor", () => {

+ 3 - 2
packages/cli/src/commands/eval.ts

@@ -48,12 +48,13 @@ export const evalCommand = new Command("eval")
   .argument("[book-id]", "Book ID (auto-detected if only one book)")
   .option("--json", "Output JSON only")
   .option("--chapters <range>", "Chapter range (e.g. 1-10, 5-20)")
-  .action(async (bookIdArg: string | undefined, opts: { json?: boolean; chapters?: string }) => {
+  .option("--all-branches", "For interactive books, include every branch instead of only the active branch")
+  .action(async (bookIdArg: string | undefined, opts: { json?: boolean; chapters?: string; allBranches?: boolean }) => {
     try {
       const root = findProjectRoot();
       const bookId = await resolveBookId(bookIdArg, root);
       const state = new StateManager(root);
-      const index = await state.loadChapterIndex(bookId);
+      const index = await state.loadVisibleChapterIndex(bookId, { allBranches: opts.allBranches });
       const bookDir = state.bookDir(bookId);
       const chaptersDir = join(bookDir, "chapters");
 

+ 2 - 1
packages/cli/src/commands/export.ts

@@ -10,6 +10,7 @@ export const exportCommand = new Command("export")
   .option("--format <format>", "Output format (txt, md, epub)", "txt")
   .option("--output <path>", "Output file path")
   .option("--approved-only", "Only export approved chapters")
+  .option("--all-branches", "For interactive books, include every branch instead of only the active branch")
   .option("--json", "Output JSON metadata")
   .action(async (bookIdArg: string | undefined, opts) => {
     try {
@@ -18,7 +19,7 @@ export const exportCommand = new Command("export")
       const state = new StateManager(root);
 
       const book = await state.loadBookConfig(bookId);
-      const index = await state.loadChapterIndex(bookId);
+      const index = await state.loadVisibleChapterIndex(bookId, { allBranches: opts.allBranches });
       const bookDir = state.bookDir(bookId);
       const chaptersDir = join(bookDir, "chapters");
 

+ 9 - 2
packages/cli/src/commands/review.ts

@@ -9,6 +9,7 @@ reviewCommand
   .command("list")
   .description("List chapters pending review")
   .argument("[book-id]", "Book ID (optional, lists all books if omitted)")
+  .option("--all-branches", "For interactive books, include every branch instead of only the active branch")
   .option("--json", "Output JSON")
   .action(async (bookId: string | undefined, opts) => {
     try {
@@ -27,7 +28,7 @@ reviewCommand
       }> = [];
 
       for (const id of bookIds) {
-        const index = await state.loadChapterIndex(id);
+        const index = await state.loadVisibleChapterIndex(id, { allBranches: opts.allBranches });
         const pending = index.filter(
           (ch) =>
             ch.status === "ready-for-review" || ch.status === "audit-failed",
@@ -148,6 +149,7 @@ reviewCommand
   .command("approve-all")
   .description("Approve all pending chapters for a book")
   .argument("[book-id]", "Book ID (auto-detected if only one book)")
+  .option("--all-branches", "For interactive books, include every branch instead of only the active branch")
   .option("--json", "Output JSON")
   .action(async (bookIdArg: string | undefined, opts) => {
     try {
@@ -156,11 +158,16 @@ reviewCommand
       const state = new StateManager(root);
 
       const index = [...(await state.loadChapterIndex(bookId))];
+      const visibleIndex = await state.loadVisibleChapterIndex(bookId, { allBranches: opts.allBranches });
+      const visibleNumbers = new Set(visibleIndex.map((chapter) => chapter.number));
       let count = 0;
       const now = new Date().toISOString();
 
       const updated = index.map((ch) => {
-        if (ch.status === "ready-for-review" || ch.status === "audit-failed") {
+        if (
+          visibleNumbers.has(ch.number)
+          && (ch.status === "ready-for-review" || ch.status === "audit-failed")
+        ) {
           count++;
           return { ...ch, status: "approved" as const, updatedAt: now };
         }

+ 8 - 2
packages/cli/src/commands/status.ts

@@ -6,6 +6,7 @@ export const statusCommand = new Command("status")
   .description("Show project status")
   .argument("[book-id]", "Book ID (optional, shows all if omitted)")
   .option("--chapters", "Show per-chapter status and issues")
+  .option("--all-branches", "For interactive books, include every branch instead of only the active branch")
   .option("--json", "Output JSON")
   .action(async (bookIdArg: string | undefined, opts) => {
     try {
@@ -31,9 +32,10 @@ export const statusCommand = new Command("status")
 
       for (const id of bookIds) {
         const book = await state.loadBookConfig(id);
-        const index = await state.loadChapterIndex(id);
+        const index = await state.loadVisibleChapterIndex(id, { allBranches: opts.allBranches });
         const migrationHint = await getLegacyMigrationHint(root, id);
-        const persistedChapterCount = await state.getPersistedChapterCount(id);
+        const persistedChapterCount = await state.getPersistedChapterCount(id, { allBranches: opts.allBranches });
+        const activeBranch = opts.allBranches ? null : await state.loadActiveBranchView(id);
         const { profile: genreProfile } = await readGenreProfile(root, book.genre);
         const countingMode = resolveLengthCountingMode(book.language ?? genreProfile.language);
 
@@ -60,6 +62,7 @@ export const statusCommand = new Command("status")
           approved,
           pending,
           failed,
+          ...(activeBranch ? { activeBranch } : {}),
           ...(migrationHint ? { migrationHint } : {}),
           ...(opts.chapters ? {
             chapterList: index.map((ch) => ({
@@ -79,6 +82,9 @@ export const statusCommand = new Command("status")
           log(`    Chapters: ${persistedChapterCount} / ${book.targetChapters}`);
           log(`    Words: ${totalWords.toLocaleString()} (avg ${avgWords}/ch)`);
           log(`    Approved: ${approved} | Pending: ${pending} | Failed: ${failed}`);
+          if (activeBranch) {
+            log(`    Active branch: ${activeBranch.displayPath} (${activeBranch.activeNodeId}) | ${activeBranch.status}`);
+          }
           if (migrationHint) {
             log(`    Migration: ${migrationHint}`);
           }

+ 141 - 0
packages/core/src/__tests__/state-manager.test.ts

@@ -688,6 +688,147 @@ describe("StateManager", () => {
         readFile(join(manager.interactiveDir("interactive-book"), "branch-tree.json"), "utf-8"),
       ).resolves.toContain("\"rootNodeId\": \"root\"");
     });
+
+    it("derives the active branch view and visible chapter index for interactive books", async () => {
+      const bookId = "interactive-visible";
+      await manager.saveBookConfig(bookId, {
+        id: bookId,
+        title: "Interactive Visible Book",
+        platform: "tomato",
+        genre: "other",
+        status: "active",
+        targetChapters: 10,
+        chapterWordCount: 2200,
+        narrativeMode: "interactive-tree",
+        createdAt: "2026-03-30T00:00:00Z",
+        updatedAt: "2026-03-30T00:00:00Z",
+      });
+      await manager.saveChapterIndex(bookId, [
+        {
+          number: 1,
+          title: "Chapter 1",
+          status: "ready-for-review",
+          wordCount: 2100,
+          createdAt: "2026-03-30T00:00:00Z",
+          updatedAt: "2026-03-30T00:00:00Z",
+          auditIssues: [],
+          lengthWarnings: [],
+        },
+        {
+          number: 2,
+          title: "Chapter 2A",
+          status: "ready-for-review",
+          wordCount: 2200,
+          createdAt: "2026-03-30T00:00:00Z",
+          updatedAt: "2026-03-30T00:00:00Z",
+          auditIssues: [],
+          lengthWarnings: [],
+        },
+        {
+          number: 3,
+          title: "Chapter 2B",
+          status: "ready-for-review",
+          wordCount: 2300,
+          createdAt: "2026-03-30T00:00:00Z",
+          updatedAt: "2026-03-30T00:00:00Z",
+          auditIssues: [],
+          lengthWarnings: [],
+        },
+      ]);
+      await manager.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: "Route A",
+            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: "Route B",
+            status: "dormant",
+            snapshotRef: { chapterNumber: 1 },
+            selectedChoiceId: null,
+            chapterIds: ["ch-0003"],
+            displayPath: "main.b",
+          },
+        ],
+        choices: [
+          {
+            choiceId: "choice-root-a",
+            fromNodeId: "root",
+            toNodeId: "node-a",
+            label: "Route A",
+            intent: "Take route A.",
+            immediateGoal: "Advance route A.",
+            expectedCost: "Lose route B.",
+            expectedRisk: "Escalate conflict.",
+            hookPressure: "A hook advances.",
+            characterPressure: "A pressure rises.",
+            tone: "tense",
+            selected: true,
+          },
+          {
+            choiceId: "choice-root-b",
+            fromNodeId: "root",
+            toNodeId: "node-b",
+            label: "Route B",
+            intent: "Take route B.",
+            immediateGoal: "Advance route B.",
+            expectedCost: "Lose route A.",
+            expectedRisk: "Reset momentum.",
+            hookPressure: "B hook advances.",
+            characterPressure: "B pressure rises.",
+            tone: "quiet",
+            selected: false,
+          },
+        ],
+      });
+
+      await expect(manager.loadActiveBranchView(bookId)).resolves.toEqual({
+        activeNodeId: "node-a",
+        displayPath: "main.a",
+        status: "active",
+        visibleChapterNumbers: [1, 2],
+        pendingChoiceCount: 0,
+      });
+      await expect(manager.loadVisibleChapterIndex(bookId)).resolves.toEqual([
+        expect.objectContaining({ number: 1 }),
+        expect.objectContaining({ number: 2 }),
+      ]);
+      await expect(manager.loadVisibleChapterIndex(bookId, { allBranches: true })).resolves.toEqual([
+        expect.objectContaining({ number: 1 }),
+        expect.objectContaining({ number: 2 }),
+        expect.objectContaining({ number: 3 }),
+      ]);
+    });
   });
 
   // -------------------------------------------------------------------------

+ 131 - 18
packages/core/src/state/manager.ts

@@ -232,24 +232,6 @@ export class StateManager {
     return Math.max(indexedChapter, durableChapter, runtimeState.manifest.lastAppliedChapter) + 1;
   }
 
-  async getPersistedChapterCount(bookId: string): Promise<number> {
-    const chaptersDir = join(this.bookDir(bookId), "chapters");
-    const chapterNumbers = new Set<number>();
-
-    try {
-      const files = await readdir(chaptersDir);
-      for (const file of files) {
-        const match = file.match(/^(\d+)_.*\.md$/);
-        if (!match) continue;
-        chapterNumbers.add(parseInt(match[1]!, 10));
-      }
-    } catch {
-      return 0;
-    }
-
-    return chapterNumbers.size;
-  }
-
   async loadChapterIndex(bookId: string): Promise<ReadonlyArray<ChapterMeta>> {
     const indexPath = join(this.bookDir(bookId), "chapters", "index.json");
     try {
@@ -339,6 +321,80 @@ export class StateManager {
     };
   }
 
+  async loadActiveBranchView(bookId: string): Promise<{
+    activeNodeId: string;
+    displayPath: string;
+    status: "active" | "awaiting-choice" | "dormant" | "completed";
+    visibleChapterNumbers: ReadonlyArray<number>;
+    pendingChoiceCount: number;
+  } | null> {
+    const book = await this.loadBookConfig(bookId).catch(() => null);
+    if (!book || book.narrativeMode !== "interactive-tree") {
+      return null;
+    }
+
+    const tree = await this.loadBranchTree(bookId);
+    if (!tree) {
+      return null;
+    }
+
+    const nodeMap = new Map(tree.nodes.map((node) => [node.nodeId, node]));
+    const activeNode = nodeMap.get(tree.activeNodeId);
+    if (!activeNode) {
+      return null;
+    }
+
+    const lineage = this.resolveInteractiveLineage(nodeMap, activeNode.nodeId);
+    const visibleChapterNumbers = this.resolveInteractiveChapterNumbers(lineage);
+    const pendingChoiceCount = tree.choices.filter(
+      (choice) => choice.fromNodeId === activeNode.nodeId && !choice.selected,
+    ).length;
+
+    return {
+      activeNodeId: activeNode.nodeId,
+      displayPath: activeNode.displayPath,
+      status: activeNode.status,
+      visibleChapterNumbers,
+      pendingChoiceCount,
+    };
+  }
+
+  async loadVisibleChapterIndex(
+    bookId: string,
+    options?: { readonly allBranches?: boolean },
+  ): Promise<ReadonlyArray<ChapterMeta>> {
+    const index = await this.loadChapterIndex(bookId);
+    if (options?.allBranches) {
+      return index;
+    }
+
+    const view = await this.loadActiveBranchView(bookId);
+    if (!view) {
+      return index;
+    }
+
+    const visibleNumbers = new Set(view.visibleChapterNumbers);
+    return index.filter((chapter) => visibleNumbers.has(chapter.number));
+  }
+
+  async getPersistedChapterCount(
+    bookId: string,
+    options?: { readonly allBranches?: boolean },
+  ): Promise<number> {
+    const persistedNumbers = await this.getPersistedChapterNumbers(bookId);
+    if (options?.allBranches) {
+      return persistedNumbers.length;
+    }
+
+    const view = await this.loadActiveBranchView(bookId);
+    if (!view) {
+      return persistedNumbers.length;
+    }
+
+    const visibleNumbers = new Set(view.visibleChapterNumbers);
+    return persistedNumbers.filter((chapterNumber) => visibleNumbers.has(chapterNumber)).length;
+  }
+
   async ensureInteractiveTreeAt(bookDir: string): Promise<InteractiveBranchTree> {
     const existing = await this.loadBranchTreeAt(bookDir);
     if (existing) {
@@ -377,6 +433,63 @@ export class StateManager {
     await this.snapshotStateAt(this.bookDir(bookId), chapterNumber);
   }
 
+  private resolveInteractiveLineage(
+    nodeMap: ReadonlyMap<string, InteractiveBranchTree["nodes"][number]>,
+    startNodeId: string,
+  ): ReadonlyArray<InteractiveBranchTree["nodes"][number]> {
+    const lineage: InteractiveBranchTree["nodes"] = [];
+    const visited = new Set<string>();
+    let cursor = nodeMap.get(startNodeId) ?? null;
+
+    while (cursor && !visited.has(cursor.nodeId)) {
+      lineage.push(cursor);
+      visited.add(cursor.nodeId);
+      cursor = cursor.parentNodeId ? nodeMap.get(cursor.parentNodeId) ?? null : null;
+    }
+
+    return lineage.reverse();
+  }
+
+  private resolveInteractiveChapterNumbers(
+    lineage: ReadonlyArray<InteractiveBranchTree["nodes"][number]>,
+  ): ReadonlyArray<number> {
+    const numbers: number[] = [];
+    const seen = new Set<number>();
+
+    for (const node of lineage) {
+      for (const chapterId of node.chapterIds) {
+        const match = chapterId.match(/^ch-(\d+)$/);
+        if (!match) continue;
+        const chapterNumber = Number.parseInt(match[1] ?? "", 10);
+        if (!Number.isInteger(chapterNumber) || seen.has(chapterNumber)) {
+          continue;
+        }
+        seen.add(chapterNumber);
+        numbers.push(chapterNumber);
+      }
+    }
+
+    return numbers;
+  }
+
+  private async getPersistedChapterNumbers(bookId: string): Promise<ReadonlyArray<number>> {
+    const chaptersDir = join(this.bookDir(bookId), "chapters");
+    const chapterNumbers = new Set<number>();
+
+    try {
+      const files = await readdir(chaptersDir);
+      for (const file of files) {
+        const match = file.match(/^(\d+)_.*\.md$/);
+        if (!match) continue;
+        chapterNumbers.add(parseInt(match[1]!, 10));
+      }
+    } catch {
+      return [];
+    }
+
+    return [...chapterNumbers].sort((a, b) => a - b);
+  }
+
   async snapshotStateAt(bookDir: string, chapterNumber: number): Promise<void> {
     const storyDir = join(bookDir, "story");
     const snapshotDir = join(storyDir, "snapshots", String(chapterNumber));