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

test(v13): cover old-book editing, per-chapter promotion, regex precision [hotfix 4]

Core tests (v13-hotfix-round4.test.ts):
- isNewLayoutBook: legacy book false, new-layout book true, missing dir false
- rerunPromotionPass: promotes at 2+ mentions, skips already-promoted
- deriveAdvancedCountsFromSummaries: H01 vs H010, column-scoped matching
- escapeRegex utility

Studio tests (v13-hotfix-round4.test.ts):
- Old book: GET/PUT/list treat story_bible.md as normal editable file
- New book: GET legacy:true, PUT 400

Fix phase5-hotfix.test.ts to provide isNewLayoutBook mock and create
outline/story_frame.md in tests that expect shim detection.
Ma 6 месяцев назад
Родитель
Сommit
2fe063459e

+ 343 - 0
packages/core/src/__tests__/v13-hotfix-round4.test.ts

@@ -0,0 +1,343 @@
+/**
+ * v13 hotfix round 4 — tests for:
+ *   Issue 1: old-book editing (no outline/story_frame.md) — isNewLayoutBook
+ *   Issue 2: per-chapter promotion pass
+ *   Issue 3: word-boundary + column-scoped regex for advanced_count
+ *
+ * Studio server tests for Issue 1 live in packages/studio/src/api/v13-hotfix-round4.test.ts.
+ */
+
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+  isNewLayoutBook,
+} from "../utils/outline-paths.js";
+import {
+  deriveAdvancedCountsFromSummaries,
+  escapeRegex,
+  rerunPromotionPass,
+} from "../utils/hook-promotion.js";
+import {
+  parsePendingHooksMarkdown,
+  renderHookSnapshot,
+} from "../utils/story-markdown.js";
+import type { StoredHook } from "../state/memory-db.js";
+
+// ---------------------------------------------------------------------------
+// Issue 1: isNewLayoutBook — old vs new book detection
+// ---------------------------------------------------------------------------
+
+describe("Issue 1 — isNewLayoutBook detection", () => {
+  let bookDir: string;
+
+  beforeEach(async () => {
+    bookDir = await mkdtemp(join(tmpdir(), "inkos-layout-detect-"));
+    await mkdir(join(bookDir, "story"), { recursive: true });
+  });
+
+  afterEach(async () => {
+    await rm(bookDir, { recursive: true, force: true });
+  });
+
+  it("returns false when outline/story_frame.md does not exist (legacy book)", async () => {
+    await writeFile(join(bookDir, "story", "story_bible.md"), "# Story Bible", "utf-8");
+    expect(await isNewLayoutBook(bookDir)).toBe(false);
+  });
+
+  it("returns true when outline/story_frame.md exists (new layout book)", async () => {
+    await mkdir(join(bookDir, "story", "outline"), { recursive: true });
+    await writeFile(join(bookDir, "story", "outline", "story_frame.md"), "# Frame", "utf-8");
+    expect(await isNewLayoutBook(bookDir)).toBe(true);
+  });
+
+  it("returns false when story/ dir does not exist at all", async () => {
+    const emptyDir = await mkdtemp(join(tmpdir(), "inkos-empty-"));
+    try {
+      expect(await isNewLayoutBook(emptyDir)).toBe(false);
+    } finally {
+      await rm(emptyDir, { recursive: true, force: true });
+    }
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Issue 1: write_truth_file — old book allows story_bible.md writes
+// ---------------------------------------------------------------------------
+
+describe("Issue 1 — write_truth_file per-book shim detection", () => {
+  let bookDir: string;
+
+  beforeEach(async () => {
+    bookDir = await mkdtemp(join(tmpdir(), "inkos-wt-shim-"));
+    await mkdir(join(bookDir, "story"), { recursive: true });
+  });
+
+  afterEach(async () => {
+    await rm(bookDir, { recursive: true, force: true });
+  });
+
+  it("old book: isNewLayoutBook returns false so story_bible.md is writable", async () => {
+    await writeFile(join(bookDir, "story", "story_bible.md"), "# Old", "utf-8");
+    expect(await isNewLayoutBook(bookDir)).toBe(false);
+  });
+
+  it("new book: isNewLayoutBook returns true so story_bible.md is a shim", async () => {
+    await mkdir(join(bookDir, "story", "outline"), { recursive: true });
+    await writeFile(join(bookDir, "story", "outline", "story_frame.md"), "# Frame", "utf-8");
+    await writeFile(join(bookDir, "story", "story_bible.md"), "# Shim", "utf-8");
+    expect(await isNewLayoutBook(bookDir)).toBe(true);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Issue 2: per-chapter promotion pass
+// ---------------------------------------------------------------------------
+
+describe("Issue 2 — rerunPromotionPass", () => {
+  it("promotes hook when mentioned in 2+ chapters of chapter_summaries", () => {
+    const hooks: StoredHook[] = [
+      {
+        hookId: "H05",
+        startChapter: 3,
+        type: "小承诺",
+        status: "open",
+        lastAdvancedChapter: 7,
+        expectedPayoff: "15章",
+        payoffTiming: "near-term",
+        notes: "",
+        promoted: false,
+      },
+    ];
+    const summaries = [
+      "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| 4 | A | X | event | change | H05 推进 | 压抑 | 铺垫 |",
+      "| 7 | B | X | event2 | change2 | H05 推进 | 紧绷 | 推进 |",
+    ].join("\n");
+
+    const result = rerunPromotionPass(hooks, summaries);
+    expect(result.updated).toBe(true);
+    expect(result.flippedCount).toBe(1);
+    expect(result.hooks[0]!.promoted).toBe(true);
+  });
+
+  it("does not promote when mentioned in only 1 chapter", () => {
+    const hooks: StoredHook[] = [
+      {
+        hookId: "H06",
+        startChapter: 5,
+        type: "小",
+        status: "open",
+        lastAdvancedChapter: 0,
+        expectedPayoff: "",
+        notes: "",
+        promoted: false,
+      },
+    ];
+    const summaries = [
+      "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| 5 | A | X | event | change | H06 推进 | 压抑 | 铺垫 |",
+    ].join("\n");
+
+    const result = rerunPromotionPass(hooks, summaries);
+    expect(result.updated).toBe(false);
+    expect(result.flippedCount).toBe(0);
+    expect(result.hooks[0]!.promoted).toBe(false);
+  });
+
+  it("skips already-promoted hooks", () => {
+    const hooks: StoredHook[] = [
+      {
+        hookId: "H07",
+        startChapter: 1,
+        type: "主线",
+        status: "open",
+        lastAdvancedChapter: 5,
+        expectedPayoff: "",
+        notes: "",
+        promoted: true,
+      },
+    ];
+    const summaries = [
+      "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| 3 | A | X | event | change | H07 推进 | 压抑 | 铺垫 |",
+      "| 5 | B | X | event2 | change2 | H07 推进 | 紧绷 | 推进 |",
+    ].join("\n");
+
+    const result = rerunPromotionPass(hooks, summaries);
+    expect(result.updated).toBe(false);
+    expect(result.flippedCount).toBe(0);
+  });
+});
+
+describe("Issue 2 — per-chapter promotion persists to pending_hooks.md", () => {
+  let bookDir: string;
+
+  beforeEach(async () => {
+    bookDir = await mkdtemp(join(tmpdir(), "inkos-perchapt-promo-"));
+    await mkdir(join(bookDir, "story"), { recursive: true });
+  });
+
+  afterEach(async () => {
+    await rm(bookDir, { recursive: true, force: true });
+  });
+
+  it("after writing chapter where H05 is mentioned 2nd time, pending_hooks.md shows H05 promoted=true", async () => {
+    const hooks: StoredHook[] = [
+      {
+        hookId: "H05",
+        startChapter: 3,
+        type: "小承诺",
+        status: "open",
+        lastAdvancedChapter: 7,
+        expectedPayoff: "15章",
+        payoffTiming: "near-term",
+        notes: "sister promise",
+        promoted: false,
+      },
+    ];
+    const storyDir = join(bookDir, "story");
+    const ledgerPath = join(storyDir, "pending_hooks.md");
+    await writeFile(ledgerPath, renderHookSnapshot(hooks, "zh"), "utf-8");
+
+    // Simulate chapter_summaries with H05 mentioned in 2 chapters
+    await writeFile(join(storyDir, "chapter_summaries.md"), [
+      "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| 4 | 旧信 | 林辞 | 收到家书 | 回忆 | H05 推进 | 压抑 | 铺垫 |",
+      "| 7 | 码头 | 林辞 | 查账 | 汇合 | H05 推进 | 紧绷 | 推进 |",
+    ].join("\n"), "utf-8");
+
+    // Run the promotion pass (same logic as runner.ts step 3c)
+    const ledgerRaw = await readFile(ledgerPath, "utf-8");
+    const parsedHooks = parsePendingHooksMarkdown(ledgerRaw);
+    const summariesRaw = await readFile(join(storyDir, "chapter_summaries.md"), "utf-8");
+    const result = rerunPromotionPass(parsedHooks, summariesRaw);
+
+    expect(result.updated).toBe(true);
+    // Write back
+    await writeFile(ledgerPath, renderHookSnapshot([...result.hooks], "zh"), "utf-8");
+
+    // Verify the file on disk
+    const updated = parsePendingHooksMarkdown(await readFile(ledgerPath, "utf-8"));
+    expect(updated.find((h) => h.hookId === "H05")!.promoted).toBe(true);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Issue 3: word-boundary + column-scoped regex
+// ---------------------------------------------------------------------------
+
+describe("Issue 3 — escapeRegex", () => {
+  it("escapes special characters", () => {
+    // `-` is not a regex metachar outside character classes, so not escaped
+    expect(escapeRegex("H-01")).toBe("H-01");
+    expect(escapeRegex("H.01")).toBe("H\\.01");
+    expect(escapeRegex("test(1)")).toBe("test\\(1\\)");
+    expect(escapeRegex("hook[0]")).toBe("hook\\[0\\]");
+  });
+});
+
+describe("Issue 3 — deriveAdvancedCountsFromSummaries", () => {
+  it("H01 does NOT match H010 in the same row", () => {
+    const summaries = [
+      "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| 5 | title | char | event | change | H010 推进 | mood | type |",
+    ].join("\n");
+
+    const counts = deriveAdvancedCountsFromSummaries(summaries, ["H01"]);
+    expect(counts.get("H01")).toBeUndefined();
+  });
+
+  it("H010 matches H010 correctly", () => {
+    const summaries = [
+      "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| 5 | title | char | event | change | H010 推进 | mood | type |",
+    ].join("\n");
+
+    const counts = deriveAdvancedCountsFromSummaries(summaries, ["H010"]);
+    expect(counts.get("H010")).toBe(1);
+  });
+
+  it("hook_id in title column does NOT count as advancement", () => {
+    // H01 appears in the title column (index 1), not hookActivity (index 5)
+    const summaries = [
+      "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| 5 | H01的故事 | char | event | change | 其他线索 | mood | type |",
+    ].join("\n");
+
+    const counts = deriveAdvancedCountsFromSummaries(summaries, ["H01"]);
+    expect(counts.get("H01")).toBeUndefined();
+  });
+
+  it("hook_id in hookActivity column DOES count", () => {
+    const summaries = [
+      "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| 5 | 普通标题 | char | event | change | H01 推进 | mood | type |",
+      "| 6 | 另一个 | char | event | change | H01 推进 | mood | type |",
+    ].join("\n");
+
+    const counts = deriveAdvancedCountsFromSummaries(summaries, ["H01"]);
+    expect(counts.get("H01")).toBe(2);
+  });
+
+  it("works with en-locale headers", () => {
+    const summaries = [
+      "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| 5 | old letter | Lin | found letter | memory | H01 advanced | tense | setup |",
+      "| 6 | dock | Lin | check | converge | H01 advanced | tense | progress |",
+    ].join("\n");
+
+    const counts = deriveAdvancedCountsFromSummaries(summaries, ["H01"]);
+    expect(counts.get("H01")).toBe(2);
+  });
+
+  it("hook_id in events column (not hookActivity) does not count", () => {
+    const summaries = [
+      "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| 5 | title | char | H01 discovered | change | other stuff | mood | type |",
+    ].join("\n");
+
+    const counts = deriveAdvancedCountsFromSummaries(summaries, ["H01"]);
+    expect(counts.get("H01")).toBeUndefined();
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Issue 1: write_truth_file — old book allows story_bible.md writes
+// ---------------------------------------------------------------------------
+
+describe("Issue 1 — write_truth_file per-book shim detection", () => {
+  let bookDir: string;
+
+  beforeEach(async () => {
+    bookDir = await mkdtemp(join(tmpdir(), "inkos-wt-shim-"));
+    await mkdir(join(bookDir, "story"), { recursive: true });
+  });
+
+  afterEach(async () => {
+    await rm(bookDir, { recursive: true, force: true });
+  });
+
+  it("old book: isNewLayoutBook returns false so story_bible.md is writable", async () => {
+    await writeFile(join(bookDir, "story", "story_bible.md"), "# Old", "utf-8");
+    expect(await isNewLayoutBook(bookDir)).toBe(false);
+  });
+
+  it("new book: isNewLayoutBook returns true so story_bible.md is a shim", async () => {
+    await mkdir(join(bookDir, "story", "outline"), { recursive: true });
+    await writeFile(join(bookDir, "story", "outline", "story_frame.md"), "# Frame", "utf-8");
+    await writeFile(join(bookDir, "story", "story_bible.md"), "# Shim", "utf-8");
+    expect(await isNewLayoutBook(bookDir)).toBe(true);
+  });
+});

+ 19 - 2
packages/studio/src/api/phase5-hotfix.test.ts

@@ -52,10 +52,23 @@ vi.mock("@actalk/inkos-core", () => {
     get isRunning(): boolean { return false; }
   }
 
+  // Real isNewLayoutBook — needs filesystem access for per-book detection
+  async function isNewLayoutBook(bookDir: string): Promise<boolean> {
+    const { access: accessFs } = await import("node:fs/promises");
+    const { join: joinPath } = await import("node:path");
+    try {
+      await accessFs(joinPath(bookDir, "story", "outline", "story_frame.md"));
+      return true;
+    } catch {
+      return false;
+    }
+  }
+
   return {
     StateManager: MockStateManager,
     PipelineRunner: MockPipelineRunner,
     Scheduler: MockScheduler,
+    isNewLayoutBook,
     createLLMClient: createLLMClientMock,
     createLogger: vi.fn(() => logger),
     computeAnalytics: vi.fn(() => ({})),
@@ -151,7 +164,9 @@ describe("Phase 5 hotfix 1 — Studio truth file endpoints", () => {
     expect(body.content).toContain("核心标签");
   });
 
-  it("tags legacy shim files (story_bible.md, book_rules.md) with legacy: true on GET", async () => {
+  it("tags legacy shim files (story_bible.md, book_rules.md) with legacy: true on GET for new-layout book", async () => {
+    // New-layout book: outline/story_frame.md must exist for shims to be tagged
+    await writeFile(join(storyDir, "outline/story_frame.md"), "# Frame", "utf-8");
     await writeFile(join(storyDir, "story_bible.md"), "# Legacy bible shim", "utf-8");
     await writeFile(join(storyDir, "book_rules.md"), "# Legacy rules shim", "utf-8");
     const { createStudioServer } = await import("./server.js");
@@ -201,7 +216,9 @@ describe("Phase 5 hotfix 1 — Studio truth file endpoints", () => {
     expect(response3.status).toBe(400);
   });
 
-  it("refuses to PUT legacy shim files (story_bible.md / book_rules.md)", async () => {
+  it("refuses to PUT legacy shim files (story_bible.md / book_rules.md) for new-layout book", async () => {
+    // New-layout book: outline/story_frame.md must exist for PUT rejection
+    await writeFile(join(storyDir, "outline/story_frame.md"), "# Frame", "utf-8");
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 

+ 223 - 0
packages/studio/src/api/v13-hotfix-round4.test.ts

@@ -0,0 +1,223 @@
+/**
+ * v13 hotfix round 4 — Studio server tests for Issue 1:
+ *   Old book (no outline/story_frame.md): GET/PUT/list treat story_bible.md
+ *   and book_rules.md as normal editable files (no legacy tag).
+ *   New book (has outline/story_frame.md): GET returns legacy:true, PUT returns 400.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { access } from "node:fs/promises";
+
+// ---------------------------------------------------------------------------
+// Mock — we need isNewLayoutBook to actually work (it stats the filesystem),
+// so we re-export the real implementation while mocking the rest of core.
+// ---------------------------------------------------------------------------
+
+const loadProjectConfigMock = vi.fn();
+const logger = {
+  child: () => logger,
+  info: vi.fn(),
+  warn: vi.fn(),
+  error: vi.fn(),
+};
+
+vi.mock("@actalk/inkos-core", () => {
+  class MockStateManager {
+    constructor(private readonly root: string) {}
+    async listBooks(): Promise<string[]> { return []; }
+    async loadBookConfig(): Promise<never> { throw new Error("not implemented"); }
+    async loadChapterIndex(): Promise<[]> { return []; }
+    async getNextChapterNumber(): Promise<number> { return 1; }
+    bookDir(id: string): string { return join(this.root, "books", id); }
+  }
+  class MockPipelineRunner {
+    constructor(_config: unknown) {}
+    initBook = vi.fn();
+    runRadar = vi.fn();
+  }
+  class MockScheduler {
+    constructor(_config: unknown) {}
+    async start(): Promise<void> {}
+    stop(): void {}
+    get isRunning(): boolean { return false; }
+  }
+
+  // Real isNewLayoutBook — needs filesystem access
+  async function isNewLayoutBook(bookDir: string): Promise<boolean> {
+    const { access: accessFs } = await import("node:fs/promises");
+    const { join: joinPath } = await import("node:path");
+    try {
+      await accessFs(joinPath(bookDir, "story", "outline", "story_frame.md"));
+      return true;
+    } catch {
+      return false;
+    }
+  }
+
+  return {
+    StateManager: MockStateManager,
+    PipelineRunner: MockPipelineRunner,
+    Scheduler: MockScheduler,
+    isNewLayoutBook,
+    createLLMClient: vi.fn(() => ({})),
+    createLogger: vi.fn(() => logger),
+    computeAnalytics: vi.fn(() => ({})),
+    chatCompletion: vi.fn(),
+    loadProjectConfig: loadProjectConfigMock,
+    GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"),
+  };
+});
+
+const projectConfig = {
+  name: "studio-v13-hf4",
+  version: "0.1.0",
+  language: "zh" as const,
+  llm: {
+    provider: "openai",
+    baseUrl: "https://api.example.com/v1",
+    apiKey: "sk-test",
+    model: "gpt-5.4",
+    temperature: 0.7,
+    maxTokens: 4096,
+    stream: false,
+  },
+  daemon: {
+    schedule: { radarCron: "0 */6 * * *", writeCron: "*/15 * * * *" },
+    maxConcurrentBooks: 1,
+    chaptersPerCycle: 1,
+    retryDelayMs: 30000,
+    cooldownAfterChapterMs: 0,
+    maxChaptersPerDay: 50,
+  },
+  modelOverrides: {},
+  notify: [],
+};
+
+function cloneProjectConfig(): typeof projectConfig {
+  return structuredClone(projectConfig);
+}
+
+// ---------------------------------------------------------------------------
+// Old book (no outline/story_frame.md)
+// ---------------------------------------------------------------------------
+
+describe("Issue 1 — Studio: old book (no outline/story_frame.md)", () => {
+  let root: string;
+  let storyDir: string;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "studio-old-book-"));
+    await writeFile(join(root, "inkos.json"), JSON.stringify(projectConfig, null, 2), "utf-8");
+    storyDir = join(root, "books", "old-book", "story");
+    await mkdir(storyDir, { recursive: true });
+    await writeFile(join(storyDir, "story_bible.md"), "# Old Bible\nAuthoritative content", "utf-8");
+    await writeFile(join(storyDir, "book_rules.md"), "# Old Rules\nAuthoritative rules", "utf-8");
+    loadProjectConfigMock.mockReset();
+    loadProjectConfigMock.mockResolvedValue(cloneProjectConfig());
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("GET story_bible.md has no legacy flag", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const res = await app.request("http://localhost/api/books/old-book/truth/story_bible.md");
+    expect(res.status).toBe(200);
+    const body = await res.json() as { file: string; content: string; legacy?: boolean };
+    expect(body.content).toContain("Authoritative content");
+    expect(body.legacy).toBeUndefined();
+  });
+
+  it("PUT story_bible.md succeeds", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const res = await app.request("http://localhost/api/books/old-book/truth/story_bible.md", {
+      method: "PUT",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ content: "# Updated Bible" }),
+    });
+    expect(res.status).toBe(200);
+    const saved = await readFile(join(storyDir, "story_bible.md"), "utf-8");
+    expect(saved).toBe("# Updated Bible");
+  });
+
+  it("list endpoint shows story_bible.md without legacy tag", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const res = await app.request("http://localhost/api/books/old-book/truth");
+    expect(res.status).toBe(200);
+    const body = await res.json() as { files: ReadonlyArray<{ name: string; legacy?: true }> };
+    const bibleEntry = body.files.find((f) => f.name === "story_bible.md");
+    expect(bibleEntry).toBeDefined();
+    expect(bibleEntry!.legacy).toBeUndefined();
+  });
+});
+
+// ---------------------------------------------------------------------------
+// New book (has outline/story_frame.md)
+// ---------------------------------------------------------------------------
+
+describe("Issue 1 — Studio: new book (has outline/story_frame.md)", () => {
+  let root: string;
+  let storyDir: string;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "studio-new-book-"));
+    await writeFile(join(root, "inkos.json"), JSON.stringify(projectConfig, null, 2), "utf-8");
+    storyDir = join(root, "books", "new-book", "story");
+    await mkdir(join(storyDir, "outline"), { recursive: true });
+    await writeFile(join(storyDir, "outline", "story_frame.md"), "# Frame", "utf-8");
+    await writeFile(join(storyDir, "story_bible.md"), "# Shim bible", "utf-8");
+    await writeFile(join(storyDir, "book_rules.md"), "# Shim rules", "utf-8");
+    loadProjectConfigMock.mockReset();
+    loadProjectConfigMock.mockResolvedValue(cloneProjectConfig());
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("GET story_bible.md has legacy:true", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const res = await app.request("http://localhost/api/books/new-book/truth/story_bible.md");
+    expect(res.status).toBe(200);
+    const body = await res.json() as { legacy?: boolean };
+    expect(body.legacy).toBe(true);
+  });
+
+  it("PUT story_bible.md returns 400", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const res = await app.request("http://localhost/api/books/new-book/truth/story_bible.md", {
+      method: "PUT",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ content: "# Attempt" }),
+    });
+    expect(res.status).toBe(400);
+    const body = await res.json() as { error: string };
+    expect(body.error).toMatch(/Legacy compat shim/);
+  });
+
+  it("list endpoint shows story_bible.md with legacy tag", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const res = await app.request("http://localhost/api/books/new-book/truth");
+    expect(res.status).toBe(200);
+    const body = await res.json() as { files: ReadonlyArray<{ name: string; legacy?: true }> };
+    const bibleEntry = body.files.find((f) => f.name === "story_bible.md");
+    expect(bibleEntry).toBeDefined();
+    expect(bibleEntry!.legacy).toBe(true);
+  });
+});