소스 검색

feat: bootstrap missing control docs for legacy books

Ma 7 달 전
부모
커밋
51e1459969

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

@@ -257,6 +257,33 @@ describe("PipelineRunner", () => {
     }
   });
 
+  it("bootstraps missing control documents for legacy books before writing", async () => {
+    const { root, runner, bookId } = await createRunnerFixture();
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 1,
+        content: "Legacy chapter body.",
+        wordCount: "Legacy chapter body.".length,
+      }),
+    );
+
+    try {
+      await runner.writeDraft(bookId);
+
+      const storyDir = join(root, "books", bookId, "story");
+      const authorIntent = await readFile(join(storyDir, "author_intent.md"), "utf-8");
+      const currentFocus = await readFile(join(storyDir, "current_focus.md"), "utf-8");
+      const runtimeDir = await stat(join(storyDir, "runtime"));
+
+      expect(authorIntent).toContain("Author Intent");
+      expect(currentFocus).toContain("Current Focus");
+      expect(runtimeDir.isDirectory()).toBe(true);
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("uses the latest revised content as the input for follow-up spot-fix revisions", async () => {
     const { root, runner, bookId } = await createRunnerFixture();
 

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

@@ -410,5 +410,21 @@ describe("StateManager", () => {
       expect(currentFocus).toContain("Current Focus");
       expect(runtimeStat.isDirectory()).toBe(true);
     });
+
+    it("bootstraps and returns safe defaults for legacy books", async () => {
+      const storyDir = join(manager.bookDir("legacy-book"), "story");
+      await mkdir(storyDir, { recursive: true });
+      await writeFile(
+        join(storyDir, "story_bible.md"),
+        "# Story Bible\n\nLegacy books may not have control docs yet.\n",
+        "utf-8",
+      );
+
+      const controlDocs = await manager.loadControlDocuments("legacy-book");
+
+      expect(controlDocs.authorIntent).toContain("# Author Intent");
+      expect(controlDocs.currentFocus).toContain("# Current Focus");
+      expect(controlDocs.runtimeDir).toBe(join(storyDir, "runtime"));
+    });
   });
 });

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

@@ -266,6 +266,7 @@ export class PipelineRunner {
   async writeDraft(bookId: string, context?: string, wordCount?: number): Promise<DraftResult> {
     const releaseLock = await this.state.acquireBookLock(bookId);
     try {
+      await this.state.ensureControlDocuments(bookId);
       const book = await this.state.loadBookConfig(bookId);
       const bookDir = this.state.bookDir(bookId);
       const chapterNumber = await this.state.getNextChapterNumber(bookId);
@@ -538,6 +539,7 @@ export class PipelineRunner {
   }
 
   private async _writeNextChapterLocked(bookId: string, wordCount?: number, temperatureOverride?: number): Promise<ChapterPipelineResult> {
+    await this.state.ensureControlDocuments(bookId);
     const book = await this.state.loadBookConfig(bookId);
     const bookDir = this.state.bookDir(bookId);
     const chapterNumber = await this.state.getNextChapterNumber(bookId);

+ 25 - 2
packages/core/src/state/manager.ts

@@ -6,6 +6,12 @@ import type { ChapterMeta } from "../models/chapter.js";
 export class StateManager {
   constructor(private readonly projectRoot: string) {}
 
+  private static readonly defaultAuthorIntent =
+    "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n";
+
+  private static readonly defaultCurrentFocus =
+    "# Current Focus\n\n## Active Focus\n\n(Describe what the next 1-3 chapters should prioritize.)\n";
+
   async ensureControlDocuments(bookId: string, authorIntent?: string): Promise<void> {
     const storyDir = join(this.bookDir(bookId), "story");
     const runtimeDir = join(storyDir, "runtime");
@@ -17,15 +23,32 @@ export class StateManager {
       join(storyDir, "author_intent.md"),
       authorIntent?.trim()
         ? authorIntent.trimEnd() + "\n"
-        : "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
+        : StateManager.defaultAuthorIntent,
     );
 
     await this.writeIfMissing(
       join(storyDir, "current_focus.md"),
-      "# Current Focus\n\n## Active Focus\n\n(Describe what the next 1-3 chapters should prioritize.)\n",
+      StateManager.defaultCurrentFocus,
     );
   }
 
+  async loadControlDocuments(bookId: string): Promise<{
+    authorIntent: string;
+    currentFocus: string;
+    runtimeDir: string;
+  }> {
+    await this.ensureControlDocuments(bookId);
+
+    const storyDir = join(this.bookDir(bookId), "story");
+    const runtimeDir = join(storyDir, "runtime");
+    const [authorIntent, currentFocus] = await Promise.all([
+      readFile(join(storyDir, "author_intent.md"), "utf-8"),
+      readFile(join(storyDir, "current_focus.md"), "utf-8"),
+    ]);
+
+    return { authorIntent, currentFocus, runtimeDir };
+  }
+
   async acquireBookLock(bookId: string): Promise<() => Promise<void>> {
     await mkdir(this.bookDir(bookId), { recursive: true });
     const lockPath = join(this.bookDir(bookId), ".write.lock");