Prechádzať zdrojové kódy

fix: make book creation fail closed

Ma 7 mesiacov pred
rodič
commit
eee8950989

+ 76 - 0
docs/plans/2026-03-29-book-create-atomic-init-design.md

@@ -0,0 +1,76 @@
+# Book Create Atomic Init Design
+
+**Goal:** Eliminate half-initialized books by making `inkos book create` fail-closed: foundation generation must either produce a complete book or leave no book directory behind.
+
+## Problem
+
+Current `book create` is not atomic:
+
+- `PipelineRunner.initBook()` writes `book.json` before foundation generation.
+- `ArchitectAgent.parseSections()` silently substitutes placeholder text when a required section is missing.
+- CLI treats an existing but incomplete directory as resumable state.
+
+This creates dirty `books/<bookId>/` directories with partial files. Re-running create on the same title can then behave unpredictably.
+
+## Design
+
+### 1. Stage to a temporary book directory
+
+`initBook()` should build the full book in a temporary directory under `books/`:
+
+- write `book.json`
+- generate foundation
+- write foundation files
+- initialize control docs
+- create chapter index
+- snapshot chapter 0
+
+Only after all of that succeeds should the temp directory be renamed to the final `books/<bookId>/`.
+
+If any step fails, the temp directory is deleted.
+
+### 2. Treat missing Architect sections as hard failure
+
+`ArchitectAgent.parseSections()` should no longer emit placeholder text like:
+
+- `[book_rules 生成失败,需要重新生成]`
+
+Missing required sections should throw an error immediately. This prevents malformed foundations from being persisted as if they were valid content.
+
+### 3. Remove resumable incomplete-init semantics
+
+CLI should no longer “resume incomplete book creation”.
+
+If `books/<bookId>/` exists:
+
+- if it looks complete, `book create` should fail with “already exists”
+- if it looks incomplete, it should be treated as stale partial state and removed before a fresh atomic create starts
+
+This keeps the behavior simple:
+
+- success => complete book exists
+- failure => no book directory remains
+
+## Scope
+
+In scope:
+
+- `book create`
+- `PipelineRunner.initBook()`
+- Architect foundation parsing
+- stale incomplete directory cleanup
+- regression tests
+
+Out of scope:
+
+- `initFanficBook()` atomic staging
+- state-validator robustness
+- interactive-fiction work
+
+## Validation
+
+Add regressions for:
+
+1. `ArchitectAgent` throws when a required section is missing.
+2. `PipelineRunner.initBook()` leaves no final book directory when foundation generation fails.
+3. CLI `book create` removes stale incomplete directories and does not keep a dirty directory when create fails downstream.

+ 116 - 0
docs/plans/2026-03-29-book-create-atomic-init-implementation-plan.md

@@ -0,0 +1,116 @@
+# Book Create Atomic Init Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Make `book create` atomic so malformed Architect output or failed initialization never leaves a half-built book directory behind.
+
+**Architecture:** Stage new books in a temporary directory, require Architect to return all mandatory sections, and delete stale incomplete directories instead of resuming them. Promote the staged directory into place only after all initialization steps succeed.
+
+**Tech Stack:** TypeScript, Vitest, Commander CLI, Node `fs/promises`
+
+---
+
+### Task 1: Lock down Architect missing-section failure
+
+**Files:**
+- Modify: `packages/core/src/__tests__/architect.test.ts`
+- Modify: `packages/core/src/agents/architect.ts`
+
+**Step 1: Write the failing test**
+
+Add a test showing `generateFoundation()` rejects when `book_rules` is missing from the LLM response.
+
+**Step 2: Run test to verify it fails**
+
+Run: `pnpm vitest run src/__tests__/architect.test.ts -t "throws when a required foundation section is missing"`
+
+**Step 3: Write minimal implementation**
+
+Change `parseSections()` to throw on missing required sections instead of returning placeholder content.
+
+**Step 4: Run test to verify it passes**
+
+Run the same Vitest command and confirm it passes.
+
+### Task 2: Lock down atomic init rollback
+
+**Files:**
+- Modify: `packages/core/src/__tests__/pipeline-runner.test.ts`
+- Modify: `packages/core/src/state/manager.ts`
+- Modify: `packages/core/src/pipeline/runner.ts`
+
+**Step 1: Write the failing test**
+
+Add a regression showing `initBook()` leaves no final `books/<bookId>/` when foundation generation fails.
+
+**Step 2: Run test to verify it fails**
+
+Run: `pnpm vitest run src/__tests__/pipeline-runner.test.ts -t "cleans staged files when initBook fails before foundation is complete"`
+
+**Step 3: Write minimal implementation**
+
+Implement staged initialization in a temporary directory, then rename into place only after success. Add helper methods in `StateManager` as needed for path-based control-doc/index/snapshot writes.
+
+**Step 4: Run test to verify it passes**
+
+Run the same Vitest command and confirm it passes.
+
+### Task 3: Lock down stale incomplete-directory cleanup at CLI level
+
+**Files:**
+- Modify: `packages/cli/src/__tests__/cli-integration.test.ts`
+- Modify: `packages/cli/src/commands/book.ts`
+
+**Step 1: Write the failing test**
+
+Add a CLI regression where a stale incomplete `books/<bookId>/` exists before `book create`, and verify the command does not preserve that stale directory when create later fails.
+
+**Step 2: Run test to verify it fails**
+
+Run: `pnpm vitest run src/__tests__/cli-integration.test.ts -t "removes stale incomplete book directories before retrying create"`
+
+**Step 3: Write minimal implementation**
+
+Replace “resume incomplete book creation” behavior with stale-directory cleanup plus fresh atomic create.
+
+**Step 4: Run test to verify it passes**
+
+Run the same Vitest command and confirm it passes.
+
+### Task 4: Run focused verification
+
+**Files:**
+- No production changes expected
+
+**Step 1: Run targeted suite**
+
+Run:
+
+```bash
+pnpm vitest run src/__tests__/architect.test.ts src/__tests__/pipeline-runner.test.ts
+```
+
+in `packages/core`, and:
+
+```bash
+pnpm vitest run src/__tests__/cli-integration.test.ts
+```
+
+in `packages/cli`.
+
+**Step 2: Run typecheck**
+
+Run:
+
+```bash
+pnpm typecheck
+```
+
+in `packages/core` and `packages/cli`.
+
+**Step 3: Commit**
+
+```bash
+git add docs/plans/2026-03-29-book-create-atomic-init-design.md docs/plans/2026-03-29-book-create-atomic-init-implementation-plan.md packages/core/src/agents/architect.ts packages/core/src/pipeline/runner.ts packages/core/src/state/manager.ts packages/core/src/__tests__/architect.test.ts packages/core/src/__tests__/pipeline-runner.test.ts packages/cli/src/commands/book.ts packages/cli/src/__tests__/cli-integration.test.ts
+git commit -m "fix: make book creation fail closed"
+```

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

@@ -228,6 +228,37 @@ describe("CLI integration", () => {
     });
     });
   });
   });
 
 
+  describe("inkos book create", () => {
+    it("removes stale incomplete book directories before retrying create", async () => {
+      try {
+        await stat(join(projectDir, "inkos.json"));
+      } catch {
+        run(["init"]);
+      }
+      const bookId = "stale-book";
+      const staleDir = join(projectDir, "books", bookId);
+      await mkdir(join(staleDir, "story"), { recursive: true });
+      await writeFile(join(staleDir, "book.json"), JSON.stringify({
+        id: bookId,
+        title: "Stale Book",
+      }, null, 2));
+      await writeFile(join(staleDir, "story", "current_state.md"), "# stale\n", "utf-8");
+
+      const { exitCode, stderr } = runStderr([
+        "book",
+        "create",
+        "--title",
+        "stale book",
+      ], {
+        env: failingLlmEnv,
+      });
+
+      expect(exitCode).not.toBe(0);
+      expect(stderr).toContain("Failed to create book");
+      await expect(stat(staleDir)).rejects.toThrow();
+    });
+  });
+
   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"]);

+ 14 - 19
packages/cli/src/commands/book.ts

@@ -9,7 +9,6 @@ import {
   formatBookCreateFoundationReady,
   formatBookCreateFoundationReady,
   formatBookCreateLocation,
   formatBookCreateLocation,
   formatBookCreateNextStep,
   formatBookCreateNextStep,
-  formatBookCreateResume,
   resolveCliLanguage,
   resolveCliLanguage,
 } from "../localization.js";
 } from "../localization.js";
 import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
@@ -30,7 +29,6 @@ bookCommand
   .option("--json", "Output JSON")
   .option("--json", "Output JSON")
   .action(async (opts) => {
   .action(async (opts) => {
     try {
     try {
-      const config = await loadConfig();
       const root = findProjectRoot();
       const root = findProjectRoot();
 
 
       const bookId = opts.title
       const bookId = opts.title
@@ -39,6 +37,20 @@ bookCommand
         .replace(/-+/g, "-")
         .replace(/-+/g, "-")
         .slice(0, 30);
         .slice(0, 30);
 
 
+      const bookDir = join(root, "books", bookId);
+      try {
+        await access(bookDir);
+        const state = new StateManager(root);
+        if (await state.isCompleteBookDirectory(bookDir)) {
+          throw new Error(`Book "${bookId}" already exists at books/${bookId}/. Use a different title or delete the existing book first.`);
+        }
+        await rm(bookDir, { recursive: true, force: true });
+      } catch (e) {
+        if (e instanceof Error && e.message.includes("already exists")) throw e;
+        // Directory doesn't exist, good
+      }
+
+      const config = await loadConfig();
       const now = new Date().toISOString();
       const now = new Date().toISOString();
       const book: BookConfig = {
       const book: BookConfig = {
         id: bookId,
         id: bookId,
@@ -54,23 +66,6 @@ bookCommand
       };
       };
       const language = resolveCliLanguage(book.language);
       const language = resolveCliLanguage(book.language);
 
 
-      const bookDir = join(root, "books", bookId);
-      try {
-        await access(bookDir);
-        // Directory exists — check if it's a complete book or an interrupted init
-        try {
-          await access(join(bookDir, "story", "story_bible.md"));
-          throw new Error(`Book "${bookId}" already exists at books/${bookId}/. Use a different title or delete the existing book first.`);
-        } catch (inner) {
-          if (inner instanceof Error && inner.message.includes("already exists")) throw inner;
-          // story_bible.md missing → incomplete init, allow re-run
-          if (!opts.json) log(formatBookCreateResume(language, bookId));
-        }
-      } catch (e) {
-        if (e instanceof Error && e.message.includes("already exists")) throw e;
-        // Directory doesn't exist, good
-      }
-
       if (!opts.json) log(formatBookCreateCreating(language, book.title, book.genre, book.platform));
       if (!opts.json) log(formatBookCreateCreating(language, book.title, book.genre, book.platform));
 
 
       const brief = opts.brief
       const brief = opts.brief

+ 51 - 0
packages/core/src/__tests__/architect.test.ts

@@ -270,4 +270,55 @@ describe("ArchitectAgent", () => {
 
 
     expect(result.pendingHooks).toContain("| H13 | 22 | 舆情操盘 | 待推进 | 0 | 51-60章 | 庄蔓出场后逐步揭露(初始线索:一家自媒体公司在多个旧案节点同步接单) |");
     expect(result.pendingHooks).toContain("| H13 | 22 | 舆情操盘 | 待推进 | 0 | 51-60章 | 庄蔓出场后逐步揭露(初始线索:一家自媒体公司在多个旧案节点同步接单) |");
   });
   });
+
+  it("throws when a required foundation section is missing", async () => {
+    const agent = new ArchitectAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const book: BookConfig = {
+      id: "broken-book",
+      title: "Broken Book",
+      platform: "other",
+      genre: "other",
+      status: "active",
+      targetChapters: 20,
+      chapterWordCount: 2200,
+      language: "zh",
+      createdAt: "2026-03-29T00:00:00.000Z",
+      updatedAt: "2026-03-29T00:00:00.000Z",
+    };
+
+    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== SECTION: story_bible ===",
+          "# 故事圣经",
+          "",
+          "=== SECTION: volume_outline ===",
+          "# 卷纲",
+          "",
+          "=== SECTION: current_state ===",
+          "# 当前状态",
+          "",
+          "=== SECTION: pending_hooks ===",
+          "# 伏笔池",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    await expect(agent.generateFoundation(book)).rejects.toThrow(/book_rules/i);
+  });
 });
 });

+ 43 - 1
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -333,7 +333,7 @@ describe("PipelineRunner", () => {
       const runtimeDir = await stat(join(storyDir, "runtime"));
       const runtimeDir = await stat(join(storyDir, "runtime"));
 
 
       expect(authorIntent).toContain("mentor conflict");
       expect(authorIntent).toContain("mentor conflict");
-      expect(currentFocus).toContain("Current Focus");
+      expect(currentFocus).toContain("当前聚焦");
       expect(runtimeDir.isDirectory()).toBe(true);
       expect(runtimeDir.isDirectory()).toBe(true);
     } finally {
     } finally {
       await rm(root, { recursive: true, force: true });
       await rm(root, { recursive: true, force: true });
@@ -367,6 +367,48 @@ describe("PipelineRunner", () => {
     }
     }
   });
   });
 
 
+  it("cleans staged files when initBook fails before foundation is complete", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-init-rollback-"));
+    const runner = new PipelineRunner({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+        },
+      } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    const now = "2026-03-29T00:00:00.000Z";
+    const book: BookConfig = {
+      id: "atomic-book",
+      title: "Atomic Book",
+      platform: "tomato",
+      genre: "xuanhuan",
+      status: "outlining",
+      targetChapters: 12,
+      chapterWordCount: 2200,
+      createdAt: now,
+      updatedAt: now,
+    };
+
+    vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockRejectedValue(
+      new Error("missing book_rules section"),
+    );
+
+    try {
+      await expect(runner.initBook(book)).rejects.toThrow("missing book_rules section");
+      await expect(stat(join(root, "books", "atomic-book"))).rejects.toThrow();
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("routes writeDraft through planner and composer in v2 mode", async () => {
   it("routes writeDraft through planner and composer in v2 mode", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture({
     const { root, runner, state, bookId } = await createRunnerFixture({
       inputGovernanceMode: "v2",
       inputGovernanceMode: "v2",

+ 1 - 1
packages/core/src/agents/architect.ts

@@ -661,7 +661,7 @@ prohibitions:
       const match = content.match(regex);
       const match = content.match(regex);
       const section = match?.[1]?.trim();
       const section = match?.[1]?.trim();
       if (!section) {
       if (!section) {
-        return `[${name} 生成失败,需要重新生成]`;
+        throw new Error(`Architect output missing required section: ${name}`);
       }
       }
       if (name !== "pending_hooks") {
       if (name !== "pending_hooks") {
         return section;
         return section;

+ 48 - 19
packages/core/src/pipeline/runner.ts

@@ -32,7 +32,7 @@ import { buildLengthSpec, countChapterLength, formatLengthCount, isOutsideHardRa
 import { analyzeLongSpanFatigue } from "../utils/long-span-fatigue.js";
 import { analyzeLongSpanFatigue } from "../utils/long-span-fatigue.js";
 import { loadNarrativeMemorySeed, loadSnapshotCurrentStateFacts } from "../state/runtime-state-store.js";
 import { loadNarrativeMemorySeed, loadSnapshotCurrentStateFacts } from "../state/runtime-state-store.js";
 import { rewriteStructuredStateFromMarkdown } from "../state/state-bootstrap.js";
 import { rewriteStructuredStateFromMarkdown } from "../state/state-bootstrap.js";
-import { readFile, readdir, writeFile, mkdir, rm } from "node:fs/promises";
+import { readFile, readdir, writeFile, mkdir, rename, rm, stat } from "node:fs/promises";
 import { join, relative } from "node:path";
 import { join, relative } from "node:path";
 
 
 export interface PipelineConfig {
 export interface PipelineConfig {
@@ -264,6 +264,15 @@ export class PipelineRunner {
     };
     };
   }
   }
 
 
+  private async pathExists(path: string): Promise<boolean> {
+    try {
+      await stat(path);
+      return true;
+    } catch {
+      return false;
+    }
+  }
+
   private async loadGenreProfile(genre: string): Promise<{ profile: GenreProfile }> {
   private async loadGenreProfile(genre: string): Promise<{ profile: GenreProfile }> {
     const parsed = await readGenreProfile(this.config.projectRoot, genre);
     const parsed = await readGenreProfile(this.config.projectRoot, genre);
     return { profile: parsed.profile };
     return { profile: parsed.profile };
@@ -281,31 +290,51 @@ export class PipelineRunner {
   async initBook(book: BookConfig): Promise<void> {
   async initBook(book: BookConfig): Promise<void> {
     const architect = new ArchitectAgent(this.agentCtxFor("architect", book.id));
     const architect = new ArchitectAgent(this.agentCtxFor("architect", book.id));
     const bookDir = this.state.bookDir(book.id);
     const bookDir = this.state.bookDir(book.id);
+    const stagingBookDir = join(
+      this.state.booksDir,
+      `.tmp-book-create-${book.id}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
+    );
     const stageLanguage = await this.resolveBookLanguage(book);
     const stageLanguage = await this.resolveBookLanguage(book);
 
 
-    this.logStage(stageLanguage, { zh: "保存书籍配置", en: "saving book config" });
-    await this.state.saveBookConfig(book.id, book);
-
     this.logStage(stageLanguage, { zh: "生成基础设定", en: "generating foundation" });
     this.logStage(stageLanguage, { zh: "生成基础设定", en: "generating foundation" });
     const { profile: gp } = await this.loadGenreProfile(book.genre);
     const { profile: gp } = await this.loadGenreProfile(book.genre);
     const foundation = await architect.generateFoundation(book, this.config.externalContext);
     const foundation = await architect.generateFoundation(book, this.config.externalContext);
-    this.logStage(stageLanguage, { zh: "写入基础设定文件", en: "writing foundation files" });
-    await architect.writeFoundationFiles(
-      bookDir,
-      foundation,
-      gp.numericalSystem,
-      book.language ?? gp.language,
-    );
-    this.logStage(stageLanguage, { zh: "初始化控制文档", en: "initializing control documents" });
-    await this.state.ensureControlDocuments(book.id, this.config.externalContext);
+    try {
+      this.logStage(stageLanguage, { zh: "保存书籍配置", en: "saving book config" });
+      await this.state.saveBookConfigAt(stagingBookDir, book);
+
+      this.logStage(stageLanguage, { zh: "写入基础设定文件", en: "writing foundation files" });
+      await architect.writeFoundationFiles(
+        stagingBookDir,
+        foundation,
+        gp.numericalSystem,
+        book.language ?? gp.language,
+      );
 
 
-    // Ensure chapters directory exists (prevents ENOENT if init was previously interrupted)
-    await mkdir(join(bookDir, "chapters"), { recursive: true });
-    await this.state.saveChapterIndex(book.id, []);
+      this.logStage(stageLanguage, { zh: "初始化控制文档", en: "initializing control documents" });
+      await this.state.ensureControlDocumentsAt(
+        stagingBookDir,
+        book.language ?? gp.language,
+        this.config.externalContext,
+      );
 
 
-    // Snapshot initial state so rewrite of chapter 1 can restore to pre-chapter state
-    this.logStage(stageLanguage, { zh: "创建初始快照", en: "creating initial snapshot" });
-    await this.state.snapshotState(book.id, 0);
+      await this.state.saveChapterIndexAt(stagingBookDir, []);
+
+      this.logStage(stageLanguage, { zh: "创建初始快照", en: "creating initial snapshot" });
+      await this.state.snapshotStateAt(stagingBookDir, 0);
+
+      if (await this.pathExists(bookDir)) {
+        if (await this.state.isCompleteBookDirectory(bookDir)) {
+          throw new Error(`Book "${book.id}" already exists at books/${book.id}/. Use a different title or delete the existing book first.`);
+        }
+        await rm(bookDir, { recursive: true, force: true });
+      }
+
+      await rename(stagingBookDir, bookDir);
+    } catch (error) {
+      await rm(stagingBookDir, { recursive: true, force: true }).catch(() => undefined);
+      throw error;
+    }
   }
   }
 
 
   /** Import external source material and generate fanfic_canon.md */
   /** Import external source material and generate fanfic_canon.md */

+ 52 - 8
packages/core/src/state/manager.ts

@@ -20,9 +20,17 @@ export class StateManager {
   }
   }
 
 
   async ensureControlDocuments(bookId: string, authorIntent?: string): Promise<void> {
   async ensureControlDocuments(bookId: string, authorIntent?: string): Promise<void> {
-    const storyDir = join(this.bookDir(bookId), "story");
-    const runtimeDir = join(storyDir, "runtime");
     const language = await this.resolveControlDocumentLanguage(bookId);
     const language = await this.resolveControlDocumentLanguage(bookId);
+    await this.ensureControlDocumentsAt(this.bookDir(bookId), language, authorIntent);
+  }
+
+  async ensureControlDocumentsAt(
+    bookDir: string,
+    language: "zh" | "en",
+    authorIntent?: string,
+  ): Promise<void> {
+    const storyDir = join(bookDir, "story");
+    const runtimeDir = join(storyDir, "runtime");
 
 
     await mkdir(storyDir, { recursive: true });
     await mkdir(storyDir, { recursive: true });
     await mkdir(runtimeDir, { recursive: true });
     await mkdir(runtimeDir, { recursive: true });
@@ -158,10 +166,13 @@ export class StateManager {
   }
   }
 
 
   async saveBookConfig(bookId: string, config: BookConfig): Promise<void> {
   async saveBookConfig(bookId: string, config: BookConfig): Promise<void> {
-    const dir = this.bookDir(bookId);
-    await mkdir(dir, { recursive: true });
+    await this.saveBookConfigAt(this.bookDir(bookId), config);
+  }
+
+  async saveBookConfigAt(bookDir: string, config: BookConfig): Promise<void> {
+    await mkdir(bookDir, { recursive: true });
     await writeFile(
     await writeFile(
-      join(dir, "book.json"),
+      join(bookDir, "book.json"),
       JSON.stringify(config, null, 2),
       JSON.stringify(config, null, 2),
       "utf-8",
       "utf-8",
     );
     );
@@ -223,7 +234,14 @@ export class StateManager {
     bookId: string,
     bookId: string,
     index: ReadonlyArray<ChapterMeta>,
     index: ReadonlyArray<ChapterMeta>,
   ): Promise<void> {
   ): Promise<void> {
-    const chaptersDir = join(this.bookDir(bookId), "chapters");
+    await this.saveChapterIndexAt(this.bookDir(bookId), index);
+  }
+
+  async saveChapterIndexAt(
+    bookDir: string,
+    index: ReadonlyArray<ChapterMeta>,
+  ): Promise<void> {
+    const chaptersDir = join(bookDir, "chapters");
     await mkdir(chaptersDir, { recursive: true });
     await mkdir(chaptersDir, { recursive: true });
     await writeFile(
     await writeFile(
       join(chaptersDir, "index.json"),
       join(chaptersDir, "index.json"),
@@ -233,7 +251,11 @@ export class StateManager {
   }
   }
 
 
   async snapshotState(bookId: string, chapterNumber: number): Promise<void> {
   async snapshotState(bookId: string, chapterNumber: number): Promise<void> {
-    const storyDir = join(this.bookDir(bookId), "story");
+    await this.snapshotStateAt(this.bookDir(bookId), chapterNumber);
+  }
+
+  async snapshotStateAt(bookDir: string, chapterNumber: number): Promise<void> {
+    const storyDir = join(bookDir, "story");
     const snapshotDir = join(storyDir, "snapshots", String(chapterNumber));
     const snapshotDir = join(storyDir, "snapshots", String(chapterNumber));
     await mkdir(snapshotDir, { recursive: true });
     await mkdir(snapshotDir, { recursive: true });
 
 
@@ -252,7 +274,7 @@ export class StateManager {
       }),
       }),
     );
     );
 
 
-    const stateDir = this.stateDir(bookId);
+    const stateDir = join(bookDir, "story", "state");
     const snapshotStateDir = join(snapshotDir, "state");
     const snapshotStateDir = join(snapshotDir, "state");
     try {
     try {
       const stateFiles = await readdir(stateDir);
       const stateFiles = await readdir(stateDir);
@@ -270,6 +292,28 @@ export class StateManager {
     }
     }
   }
   }
 
 
+  async isCompleteBookDirectory(bookDir: string): Promise<boolean> {
+    const requiredPaths = [
+      join(bookDir, "book.json"),
+      join(bookDir, "story", "story_bible.md"),
+      join(bookDir, "story", "volume_outline.md"),
+      join(bookDir, "story", "book_rules.md"),
+      join(bookDir, "story", "current_state.md"),
+      join(bookDir, "story", "pending_hooks.md"),
+      join(bookDir, "chapters", "index.json"),
+    ];
+
+    for (const requiredPath of requiredPaths) {
+      try {
+        await stat(requiredPath);
+      } catch {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
   async restoreState(bookId: string, chapterNumber: number): Promise<boolean> {
   async restoreState(bookId: string, chapterNumber: number): Promise<boolean> {
     const storyDir = join(this.bookDir(bookId), "story");
     const storyDir = join(this.bookDir(bookId), "story");
     const snapshotDir = join(storyDir, "snapshots", String(chapterNumber));
     const snapshotDir = join(storyDir, "snapshots", String(chapterNumber));