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

+ 148 - 0
packages/core/src/__tests__/composer.test.ts

@@ -0,0 +1,148 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import type { BookConfig } from "../models/book.js";
+import type { PlanChapterOutput } from "../agents/planner.js";
+import { ComposerAgent } from "../agents/composer.js";
+
+describe("ComposerAgent", () => {
+  let root: string;
+  let bookDir: string;
+  let storyDir: string;
+  let book: BookConfig;
+  let plan: PlanChapterOutput;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-composer-test-"));
+    bookDir = join(root, "books", "composer-book");
+    storyDir = join(bookDir, "story");
+    await mkdir(join(storyDir, "runtime"), { recursive: true });
+
+    book = {
+      id: "composer-book",
+      title: "Composer Book",
+      platform: "tomato",
+      genre: "xuanhuan",
+      status: "active",
+      targetChapters: 20,
+      chapterWordCount: 3000,
+      createdAt: "2026-03-22T00:00:00.000Z",
+      updatedAt: "2026-03-22T00:00:00.000Z",
+    };
+
+    await Promise.all([
+      writeFile(join(storyDir, "author_intent.md"), "# Author Intent\n\nKeep the pressure on the mentor conflict.\n", "utf-8"),
+      writeFile(join(storyDir, "current_focus.md"), "# Current Focus\n\nBring the focus back to the mentor conflict.\n", "utf-8"),
+      writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 4\nTrack the merchant guild trail.\n", "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
+      writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
+    ]);
+
+    const runtimePath = join(storyDir, "runtime", "chapter-0004.intent.md");
+    await writeFile(runtimePath, "# Chapter Intent\n\n## Goal\nBring the focus back to the mentor conflict.\n", "utf-8");
+
+    plan = {
+      intent: {
+        chapter: 4,
+        goal: "Bring the focus back to the mentor conflict.",
+        outlineNode: "Track the merchant guild trail.",
+        mustKeep: [
+          "Lin Yue still hides the broken oath token.",
+          "The jade seal cannot be destroyed.",
+        ],
+        mustAvoid: ["Do not reveal the mastermind."],
+        styleEmphasis: ["character conflict", "tight POV"],
+        conflicts: [
+          {
+            type: "outline_vs_request",
+            resolution: "allow local outline deferral",
+          },
+        ],
+      },
+      intentMarkdown: "# Chapter Intent\n",
+      plannerInputs: [
+        join(storyDir, "author_intent.md"),
+        join(storyDir, "current_focus.md"),
+        join(storyDir, "story_bible.md"),
+        join(storyDir, "volume_outline.md"),
+        join(storyDir, "current_state.md"),
+      ],
+      runtimePath,
+    };
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("selects only the relevant context and writes a context package", async () => {
+    const composer = new ComposerAgent({
+      client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    const result = await composer.composeChapter({
+      book,
+      bookDir,
+      chapterNumber: 4,
+      plan,
+    });
+
+    expect(result.contextPackage.selectedContext.map((entry) => entry.source)).toEqual([
+      "story/current_focus.md",
+      "story/current_state.md",
+      "story/story_bible.md",
+      "story/volume_outline.md",
+      "story/pending_hooks.md",
+    ]);
+    expect(result.contextPackage.selectedContext.map((entry) => entry.source)).not.toContain("story/style_guide.md");
+    await expect(readFile(result.contextPath, "utf-8")).resolves.toContain("current_focus.md");
+  });
+
+  it("emits a rule stack with hard, soft, and diagnostic sections", async () => {
+    const composer = new ComposerAgent({
+      client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    const result = await composer.composeChapter({
+      book,
+      bookDir,
+      chapterNumber: 4,
+      plan,
+    });
+
+    expect(result.ruleStack.sections.hard).toContain("story_bible");
+    expect(result.ruleStack.sections.soft).toContain("author_intent");
+    expect(result.ruleStack.sections.diagnostic).toContain("anti_ai_checks");
+    expect(result.ruleStack.activeOverrides).toHaveLength(1);
+  });
+
+  it("writes trace output describing planner inputs and selected sources", async () => {
+    const composer = new ComposerAgent({
+      client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    const result = await composer.composeChapter({
+      book,
+      bookDir,
+      chapterNumber: 4,
+      plan,
+    });
+
+    expect(result.trace.plannerInputs).toEqual(plan.plannerInputs);
+    expect(result.trace.selectedSources).toContain("story/current_focus.md");
+    expect(result.trace.notes).toContain("allow local outline deferral");
+    await expect(readFile(result.tracePath, "utf-8")).resolves.toContain("allow local outline deferral");
+  });
+});

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

@@ -10,6 +10,7 @@ import {
   ProjectConfigSchema,
   LLMConfigSchema,
   NotifyChannelSchema,
+  InputGovernanceModeSchema,
 } from "../models/project.js";
 import {
   ChapterIntentSchema,
@@ -318,6 +319,11 @@ describe("ProjectConfigSchema", () => {
     expect(result.notify).toEqual([]);
   });
 
+  it("defaults input governance mode to legacy", () => {
+    const result = ProjectConfigSchema.parse(validProject);
+    expect(result.inputGovernanceMode).toBe("legacy");
+  });
+
   it("rejects wrong version", () => {
     expect(() =>
       ProjectConfigSchema.parse({ ...validProject, version: "1.0.0" }),
@@ -337,6 +343,16 @@ describe("ProjectConfigSchema", () => {
   });
 });
 
+describe("InputGovernanceModeSchema", () => {
+  it.each(["legacy", "v2"] as const)("accepts '%s'", (value) => {
+    expect(InputGovernanceModeSchema.parse(value)).toBe(value);
+  });
+
+  it("rejects unknown input governance modes", () => {
+    expect(() => InputGovernanceModeSchema.parse("planner")).toThrow();
+  });
+});
+
 describe("LLMConfigSchema", () => {
   it("accepts valid LLM config", () => {
     const result = LLMConfigSchema.parse({
@@ -537,6 +553,11 @@ describe("RuleStackSchema", () => {
         { id: "L3", name: "planning", precedence: 60, scope: "arc" },
         { id: "L4", name: "current_task", precedence: 70, scope: "local" },
       ],
+      sections: {
+        hard: ["story_bible"],
+        soft: ["author_intent", "current_focus"],
+        diagnostic: ["anti_ai_checks"],
+      },
       overrideEdges: [
         { from: "L4", to: "L3", allowed: true, scope: "current_chapter" },
         { from: "L4", to: "L2", allowed: false, scope: "current_chapter" },
@@ -552,6 +573,7 @@ describe("RuleStackSchema", () => {
     });
 
     expect(result.layers[0]?.id).toBe("L1");
+    expect(result.sections.hard).toContain("story_bible");
     expect(result.activeOverrides).toHaveLength(1);
   });
 
@@ -562,6 +584,11 @@ describe("RuleStackSchema", () => {
       ],
     });
 
+    expect(result.sections).toEqual({
+      hard: [],
+      soft: [],
+      diagnostic: [],
+    });
     expect(result.overrideEdges).toEqual([]);
     expect(result.activeOverrides).toEqual([]);
   });

+ 168 - 0
packages/core/src/agents/composer.ts

@@ -0,0 +1,168 @@
+import { readFile, writeFile, mkdir } from "node:fs/promises";
+import { join } from "node:path";
+import yaml from "js-yaml";
+import { BaseAgent } from "./base.js";
+import type { BookConfig } from "../models/book.js";
+import {
+  ChapterTraceSchema,
+  ContextPackageSchema,
+  RuleStackSchema,
+  type ChapterTrace,
+  type ContextPackage,
+  type RuleStack,
+} from "../models/input-governance.js";
+import type { PlanChapterOutput } from "./planner.js";
+
+export interface ComposeChapterInput {
+  readonly book: BookConfig;
+  readonly bookDir: string;
+  readonly chapterNumber: number;
+  readonly plan: PlanChapterOutput;
+}
+
+export interface ComposeChapterOutput {
+  readonly contextPackage: ContextPackage;
+  readonly ruleStack: RuleStack;
+  readonly trace: ChapterTrace;
+  readonly contextPath: string;
+  readonly ruleStackPath: string;
+  readonly tracePath: string;
+}
+
+export class ComposerAgent extends BaseAgent {
+  get name(): string {
+    return "composer";
+  }
+
+  async composeChapter(input: ComposeChapterInput): Promise<ComposeChapterOutput> {
+    const storyDir = join(input.bookDir, "story");
+    const runtimeDir = join(storyDir, "runtime");
+    await mkdir(runtimeDir, { recursive: true });
+
+    const selectedContext = await this.collectSelectedContext(storyDir, input.plan);
+    const contextPackage = ContextPackageSchema.parse({
+      chapter: input.chapterNumber,
+      selectedContext,
+    });
+
+    const ruleStack = RuleStackSchema.parse({
+      layers: [
+        { id: "L1", name: "hard_facts", precedence: 100, scope: "global" },
+        { id: "L2", name: "author_intent", precedence: 80, scope: "book" },
+        { id: "L3", name: "planning", precedence: 60, scope: "arc" },
+        { id: "L4", name: "current_task", precedence: 70, scope: "local" },
+      ],
+      sections: {
+        hard: ["story_bible", "current_state", "book_rules"],
+        soft: ["author_intent", "current_focus", "volume_outline"],
+        diagnostic: ["anti_ai_checks", "continuity_audit", "style_regression_checks"],
+      },
+      overrideEdges: [
+        { from: "L4", to: "L3", allowed: true, scope: "current_chapter" },
+        { from: "L4", to: "L2", allowed: false, scope: "current_chapter" },
+        { from: "L4", to: "L1", allowed: false, scope: "current_chapter" },
+      ],
+      activeOverrides: input.plan.intent.conflicts.map((conflict) => ({
+        from: "L4",
+        to: "L3",
+        target: input.plan.intent.outlineNode ?? `chapter_${input.chapterNumber}`,
+        reason: conflict.resolution,
+      })),
+    });
+
+    const trace = ChapterTraceSchema.parse({
+      chapter: input.chapterNumber,
+      plannerInputs: input.plan.plannerInputs,
+      composerInputs: [input.plan.runtimePath],
+      selectedSources: contextPackage.selectedContext.map((entry) => entry.source),
+      notes: input.plan.intent.conflicts.map((conflict) => conflict.resolution),
+    });
+
+    const chapterSlug = `chapter-${String(input.chapterNumber).padStart(4, "0")}`;
+    const contextPath = join(runtimeDir, `${chapterSlug}.context.json`);
+    const ruleStackPath = join(runtimeDir, `${chapterSlug}.rule-stack.yaml`);
+    const tracePath = join(runtimeDir, `${chapterSlug}.trace.json`);
+
+    await Promise.all([
+      writeFile(contextPath, JSON.stringify(contextPackage, null, 2), "utf-8"),
+      writeFile(ruleStackPath, yaml.dump(ruleStack, { lineWidth: 120 }), "utf-8"),
+      writeFile(tracePath, JSON.stringify(trace, null, 2), "utf-8"),
+    ]);
+
+    return {
+      contextPackage,
+      ruleStack,
+      trace,
+      contextPath,
+      ruleStackPath,
+      tracePath,
+    };
+  }
+
+  private async collectSelectedContext(storyDir: string, plan: PlanChapterOutput): Promise<ContextPackage["selectedContext"]> {
+    const entries = await Promise.all([
+      this.maybeContextSource(storyDir, "current_focus.md", "Current task focus for this chapter."),
+      this.maybeContextSource(
+        storyDir,
+        "current_state.md",
+        "Preserve hard state facts referenced by mustKeep.",
+        plan.intent.mustKeep,
+      ),
+      this.maybeContextSource(
+        storyDir,
+        "story_bible.md",
+        "Preserve canon constraints referenced by mustKeep.",
+        plan.intent.mustKeep,
+      ),
+      this.maybeContextSource(
+        storyDir,
+        "volume_outline.md",
+        "Anchor the default planning node for this chapter.",
+        plan.intent.outlineNode ? [plan.intent.outlineNode] : [],
+      ),
+      this.maybeContextSource(
+        storyDir,
+        "pending_hooks.md",
+        "Carry forward unresolved hooks that match the chapter focus.",
+      ),
+    ]);
+
+    return entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
+  }
+
+  private async maybeContextSource(
+    storyDir: string,
+    fileName: string,
+    reason: string,
+    preferredExcerpts: ReadonlyArray<string> = [],
+  ): Promise<ContextPackage["selectedContext"][number] | null> {
+    const path = join(storyDir, fileName);
+    const content = await this.readFileOrDefault(path);
+    if (!content || content === "(文件尚未创建)") return null;
+
+    return {
+      source: `story/${fileName}`,
+      reason,
+      excerpt: this.pickExcerpt(content, preferredExcerpts),
+    };
+  }
+
+  private pickExcerpt(content: string, preferredExcerpts: ReadonlyArray<string>): string | undefined {
+    for (const preferred of preferredExcerpts) {
+      if (preferred && content.includes(preferred)) return preferred;
+    }
+
+    return content
+      .split("\n")
+      .map((line) => line.trim())
+      .find((line) => line.length > 0 && !line.startsWith("#"));
+  }
+
+  private async readFileOrDefault(path: string): Promise<string> {
+    try {
+      return await readFile(path, "utf-8");
+    } catch {
+      return "(文件尚未创建)";
+    }
+  }
+}

+ 13 - 0
packages/core/src/models/input-governance.ts

@@ -65,8 +65,21 @@ export const ActiveOverrideSchema = z.object({
 
 export type ActiveOverride = z.infer<typeof ActiveOverrideSchema>;
 
+export const RuleStackSectionsSchema = z.object({
+  hard: z.array(z.string()).default([]),
+  soft: z.array(z.string()).default([]),
+  diagnostic: z.array(z.string()).default([]),
+});
+
+export type RuleStackSections = z.infer<typeof RuleStackSectionsSchema>;
+
 export const RuleStackSchema = z.object({
   layers: z.array(RuleLayerSchema).min(1),
+  sections: RuleStackSectionsSchema.default({
+    hard: [],
+    soft: [],
+    diagnostic: [],
+  }),
   overrideEdges: z.array(OverrideEdgeSchema).default([]),
   activeOverrides: z.array(ActiveOverrideSchema).default([]),
 });