Răsfoiți Sursa

feat: add control document helpers

Ma 7 luni în urmă
părinte
comite
59da8afde8

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

@@ -1,9 +1,10 @@
 import { afterEach, describe, expect, it, vi } from "vitest";
-import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises";
+import { mkdtemp, mkdir, readFile, rm, stat } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { PipelineRunner } from "../pipeline/runner.js";
 import { StateManager } from "../state/manager.js";
+import { ArchitectAgent } from "../agents/architect.js";
 import { WriterAgent, type WriteChapterOutput } from "../agents/writer.js";
 import { ContinuityAuditor, type AuditIssue, type AuditResult } from "../agents/continuity.js";
 import { ReviserAgent, type ReviseOutput } from "../agents/reviser.js";
@@ -199,6 +200,63 @@ describe("PipelineRunner", () => {
     }
   });
 
+  it("initializes control documents during book creation", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-init-book-test-"));
+    const bookId = "bootstrap-book";
+    const brief = "# Author Intent\n\nKeep the narrative centered on mentor conflict.\n";
+    const now = "2026-03-22T00:00:00.000Z";
+    const book: BookConfig = {
+      id: bookId,
+      title: "Bootstrap Book",
+      platform: "tomato",
+      genre: "xuanhuan",
+      status: "outlining",
+      targetChapters: 10,
+      chapterWordCount: 3000,
+      createdAt: now,
+      updatedAt: now,
+    };
+
+    const runner = new PipelineRunner({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0,
+        },
+      } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      externalContext: brief,
+    });
+
+    vi.spyOn(ArchitectAgent.prototype, "generateFoundation").mockResolvedValue({
+      storyBible: "# Story Bible\n",
+      volumeOutline: "# Volume Outline\n",
+      bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
+      currentState: "# Current State\n",
+      pendingHooks: "# Pending Hooks\n",
+    });
+
+    try {
+      await runner.initBook(book);
+
+      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("mentor conflict");
+      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();
 

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

@@ -383,4 +383,32 @@ describe("StateManager", () => {
       );
     });
   });
+
+  // -------------------------------------------------------------------------
+  // Input governance control docs
+  // -------------------------------------------------------------------------
+
+  describe("ensureControlDocuments", () => {
+    it("creates author intent, current focus, and runtime directory", async () => {
+      await manager.ensureControlDocuments(
+        "control-book",
+        "# Initial Brief\n\nKeep the focus on mentor conflict.\n",
+      );
+
+      const storyDir = join(manager.bookDir("control-book"), "story");
+      const authorIntent = await readFile(
+        join(storyDir, "author_intent.md"),
+        "utf-8",
+      );
+      const currentFocus = await readFile(
+        join(storyDir, "current_focus.md"),
+        "utf-8",
+      );
+      const runtimeStat = await stat(join(storyDir, "runtime"));
+
+      expect(authorIntent).toContain("mentor conflict");
+      expect(currentFocus).toContain("Current Focus");
+      expect(runtimeStat.isDirectory()).toBe(true);
+    });
+  });
 });

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

@@ -206,6 +206,7 @@ export class PipelineRunner {
     const { profile: gp } = await this.loadGenreProfile(book.genre);
     const foundation = await architect.generateFoundation(book, this.config.externalContext);
     await architect.writeFoundationFiles(bookDir, foundation, gp.numericalSystem);
+    await this.state.ensureControlDocuments(book.id, this.config.externalContext);
 
     // Ensure chapters directory exists (prevents ENOENT if init was previously interrupted)
     await mkdir(join(bookDir, "chapters"), { recursive: true });
@@ -253,6 +254,7 @@ export class PipelineRunner {
     const { profile: gp } = await this.loadGenreProfile(book.genre);
     const foundation = await architect.generateFanficFoundation(book, fanficCanon, fanficMode);
     await architect.writeFoundationFiles(bookDir, foundation, gp.numericalSystem);
+    await this.state.ensureControlDocuments(book.id, this.config.externalContext);
 
     // Step 3: Initialize chapters directory + snapshot
     await mkdir(join(bookDir, "chapters"), { recursive: true });

+ 28 - 0
packages/core/src/state/manager.ts

@@ -6,6 +6,26 @@ import type { ChapterMeta } from "../models/chapter.js";
 export class StateManager {
   constructor(private readonly projectRoot: string) {}
 
+  async ensureControlDocuments(bookId: string, authorIntent?: string): Promise<void> {
+    const storyDir = join(this.bookDir(bookId), "story");
+    const runtimeDir = join(storyDir, "runtime");
+
+    await mkdir(storyDir, { recursive: true });
+    await mkdir(runtimeDir, { recursive: true });
+
+    await this.writeIfMissing(
+      join(storyDir, "author_intent.md"),
+      authorIntent?.trim()
+        ? authorIntent.trimEnd() + "\n"
+        : "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
+    );
+
+    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",
+    );
+  }
+
   async acquireBookLock(bookId: string): Promise<() => Promise<void>> {
     await mkdir(this.bookDir(bookId), { recursive: true });
     const lockPath = join(this.bookDir(bookId), ".write.lock");
@@ -185,4 +205,12 @@ export class StateManager {
       return false;
     }
   }
+
+  private async writeIfMissing(path: string, content: string): Promise<void> {
+    try {
+      await stat(path);
+    } catch {
+      await writeFile(path, content, "utf-8");
+    }
+  }
 }