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

feat: add chapter plan and compose commands

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

+ 64 - 1
packages/cli/src/__tests__/cli-integration.test.ts

@@ -1,4 +1,4 @@
-import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
+import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { dirname, join, resolve } from "node:path";
 import { fileURLToPath } from "node:url";
@@ -188,4 +188,67 @@ describe("CLI integration", () => {
       expect(exitCode).not.toBe(0);
     });
   });
+
+  describe("inkos plan/compose", () => {
+    beforeAll(async () => {
+      const bookDir = join(projectDir, "books", "cli-book");
+      const storyDir = join(bookDir, "story");
+      await mkdir(join(storyDir, "runtime"), { recursive: true });
+
+      await writeFile(
+        join(bookDir, "book.json"),
+        JSON.stringify({
+          id: "cli-book",
+          title: "CLI Book",
+          platform: "tomato",
+          genre: "other",
+          status: "active",
+          targetChapters: 20,
+          chapterWordCount: 3000,
+          createdAt: "2026-03-22T00:00:00.000Z",
+          updatedAt: "2026-03-22T00:00:00.000Z",
+        }, null, 2),
+        "utf-8",
+      );
+      await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8").catch(async () => {
+        await mkdir(join(bookDir, "chapters"), { recursive: true });
+        await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8");
+      });
+
+      await Promise.all([
+        writeFile(join(storyDir, "author_intent.md"), "# Author Intent\n\nKeep the story centered on the mentor conflict.\n", "utf-8"),
+        writeFile(join(storyDir, "current_focus.md"), "# Current Focus\n\nBring 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 1\nTrack the merchant guild trail.\n", "utf-8"),
+        writeFile(join(storyDir, "book_rules.md"), "---\nprohibitions:\n  - Do not reveal the mastermind\n---\n\n# Book Rules\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"),
+      ]);
+    });
+
+    it("runs plan chapter and returns the generated intent path in JSON mode", async () => {
+      const output = run(["plan", "chapter", "cli-book", "--json", "--context", "Ignore the guild chase and focus on the mentor conflict."]);
+      const data = JSON.parse(output);
+
+      expect(data.bookId).toBe("cli-book");
+      expect(data.chapterNumber).toBe(1);
+      expect(data.intentPath).toContain("story/runtime/chapter-0001.intent.md");
+      await expect(stat(join(projectDir, "books", "cli-book", data.intentPath))).resolves.toBeTruthy();
+    });
+
+    it("runs compose chapter and returns runtime artifact paths in JSON mode", async () => {
+      const output = run(["compose", "chapter", "cli-book", "--json"]);
+      const data = JSON.parse(output);
+
+      expect(data.bookId).toBe("cli-book");
+      expect(data.chapterNumber).toBe(1);
+      expect(data.contextPath).toContain("story/runtime/chapter-0001.context.json");
+      expect(data.ruleStackPath).toContain("story/runtime/chapter-0001.rule-stack.yaml");
+      expect(data.tracePath).toContain("story/runtime/chapter-0001.trace.json");
+
+      await expect(stat(join(projectDir, "books", "cli-book", data.contextPath))).resolves.toBeTruthy();
+      await expect(stat(join(projectDir, "books", "cli-book", data.ruleStackPath))).resolves.toBeTruthy();
+      await expect(stat(join(projectDir, "books", "cli-book", data.tracePath))).resolves.toBeTruthy();
+    });
+  });
 });

+ 50 - 0
packages/cli/src/commands/compose.ts

@@ -0,0 +1,50 @@
+import { Command } from "commander";
+import { PipelineRunner } from "@actalk/inkos-core";
+import { buildPipelineConfig, findProjectRoot, loadConfig, log, logError, resolveBookId, resolveContext } from "../utils.js";
+
+export const composeCommand = new Command("compose")
+  .description("Compose chapter runtime artifacts");
+
+composeCommand
+  .command("chapter")
+  .description("Generate context/rule-stack/trace artifacts for the next chapter")
+  .argument("[book-id]", "Book ID (auto-detected if only one book)")
+  .option("--context <text>", "Chapter steering guidance")
+  .option("--context-file <path>", "Read guidance from file")
+  .option("--json", "Output JSON")
+  .option("-q, --quiet", "Suppress console output")
+  .action(async (bookIdArg: string | undefined, opts) => {
+    try {
+      const config = await loadConfig({ requireApiKey: false });
+      const root = findProjectRoot();
+      const bookId = await resolveBookId(bookIdArg, root);
+      const context = await resolveContext(opts);
+
+      const pipeline = new PipelineRunner(
+        buildPipelineConfig(config, root, {
+          externalContext: context,
+          inputGovernanceMode: "v2",
+          quiet: opts.quiet,
+        }),
+      );
+
+      const result = await pipeline.composeChapter(bookId, context);
+
+      if (opts.json) {
+        log(JSON.stringify(result, null, 2));
+      } else {
+        log(`Composed chapter ${result.chapterNumber} for "${bookId}"`);
+        log(`  Intent: ${result.intentPath}`);
+        log(`  Context: ${result.contextPath}`);
+        log(`  Rule stack: ${result.ruleStackPath}`);
+        log(`  Trace: ${result.tracePath}`);
+      }
+    } catch (e) {
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Failed to compose chapter: ${e}`);
+      }
+      process.exit(1);
+    }
+  });

+ 54 - 0
packages/cli/src/commands/plan.ts

@@ -0,0 +1,54 @@
+import { Command } from "commander";
+import { PipelineRunner } from "@actalk/inkos-core";
+import { buildPipelineConfig, findProjectRoot, loadConfig, log, logError, resolveBookId, resolveContext } from "../utils.js";
+
+export const planCommand = new Command("plan")
+  .description("Plan chapter input artifacts");
+
+planCommand
+  .command("chapter")
+  .description("Generate chapter intent for the next chapter")
+  .argument("[book-id]", "Book ID (auto-detected if only one book)")
+  .option("--context <text>", "Chapter steering guidance")
+  .option("--context-file <path>", "Read guidance from file")
+  .option("--json", "Output JSON")
+  .option("-q, --quiet", "Suppress console output")
+  .action(async (bookIdArg: string | undefined, opts) => {
+    try {
+      const config = await loadConfig({ requireApiKey: false });
+      const root = findProjectRoot();
+      const bookId = await resolveBookId(bookIdArg, root);
+      const context = await resolveContext(opts);
+
+      const pipeline = new PipelineRunner(
+        buildPipelineConfig(config, root, {
+          externalContext: context,
+          inputGovernanceMode: "v2",
+          quiet: opts.quiet,
+        }),
+      );
+
+      const result = await pipeline.planChapter(bookId, context);
+
+      if (opts.json) {
+        log(JSON.stringify(result, null, 2));
+      } else {
+        log(`Planned chapter ${result.chapterNumber} for "${bookId}"`);
+        log(`  Goal: ${result.goal}`);
+        log(`  Intent: ${result.intentPath}`);
+        if (result.conflicts.length > 0) {
+          log("  Conflicts:");
+          for (const conflict of result.conflicts) {
+            log(`    - ${conflict}`);
+          }
+        }
+      }
+    } catch (e) {
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Failed to plan chapter: ${e}`);
+      }
+      process.exit(1);
+    }
+  });

+ 4 - 0
packages/cli/src/index.ts

@@ -16,6 +16,8 @@ import { draftCommand } from "./commands/draft.js";
 import { auditCommand } from "./commands/audit.js";
 import { reviseCommand } from "./commands/revise.js";
 import { agentCommand } from "./commands/agent.js";
+import { planCommand } from "./commands/plan.js";
+import { composeCommand } from "./commands/compose.js";
 import { genreCommand } from "./commands/genre.js";
 import { updateCommand } from "./commands/update.js";
 import { detectCommand } from "./commands/detect.js";
@@ -51,6 +53,8 @@ program.addCommand(draftCommand);
 program.addCommand(auditCommand);
 program.addCommand(reviseCommand);
 program.addCommand(agentCommand);
+program.addCommand(planCommand);
+program.addCommand(composeCommand);
 program.addCommand(genreCommand);
 program.addCommand(updateCommand);
 program.addCommand(detectCommand);

+ 4 - 3
packages/cli/src/utils.ts

@@ -28,8 +28,8 @@ export function findProjectRoot(): string {
   return process.cwd();
 }
 
-export async function loadConfig(): Promise<ProjectConfig> {
-  return loadProjectConfig(findProjectRoot());
+export async function loadConfig(options?: { readonly requireApiKey?: boolean }): Promise<ProjectConfig> {
+  return loadProjectConfig(findProjectRoot(), options);
 }
 
 export function createClient(config: ProjectConfig) {
@@ -39,7 +39,7 @@ export function createClient(config: ProjectConfig) {
 export function buildPipelineConfig(
   config: ProjectConfig,
   root: string,
-  extra?: Partial<Pick<PipelineConfig, "notifyChannels" | "radarSources" | "externalContext">> & {
+  extra?: Partial<Pick<PipelineConfig, "notifyChannels" | "radarSources" | "externalContext" | "inputGovernanceMode">> & {
     readonly quiet?: boolean;
     readonly logFile?: NodeJS.WritableStream;
   },
@@ -71,6 +71,7 @@ export function buildPipelineConfig(
     projectRoot: root,
     defaultLLMConfig: config.llm,
     modelOverrides: config.modelOverrides,
+    inputGovernanceMode: extra?.inputGovernanceMode ?? config.inputGovernanceMode,
     notifyChannels: extra?.notifyChannels ?? config.notify,
     radarSources: extra?.radarSources,
     externalContext: extra?.externalContext,

+ 1 - 1
packages/core/src/index.ts

@@ -74,7 +74,7 @@ export { loadProjectConfig, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH } from "./utils/c
 export { computeAnalytics, type AnalyticsData, type TokenStats } from "./utils/analytics.js";
 
 // Pipeline
-export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type DraftResult, type ReviseResult, type TruthFiles, type BookStatusInfo, type ImportChaptersInput, type ImportChaptersResult, type TokenUsageSummary } from "./pipeline/runner.js";
+export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type DraftResult, type PlanChapterResult, type ComposeChapterResult, type ReviseResult, type TruthFiles, type BookStatusInfo, type ImportChaptersInput, type ImportChaptersResult, type TokenUsageSummary } from "./pipeline/runner.js";
 export { Scheduler, type SchedulerConfig } from "./pipeline/scheduler.js";
 export { runAgentLoop, AGENT_TOOLS as AGENT_TOOLS, type AgentLoopOptions } from "./pipeline/agent.js";
 export { detectChapter, detectAndRewrite, loadDetectionHistory, type DetectChapterResult, type DetectAndRewriteResult } from "./pipeline/detection-runner.js";

+ 89 - 6
packages/core/src/pipeline/runner.ts

@@ -66,6 +66,20 @@ export interface DraftResult {
   readonly tokenUsage?: TokenUsageSummary;
 }
 
+export interface PlanChapterResult {
+  readonly bookId: string;
+  readonly chapterNumber: number;
+  readonly intentPath: string;
+  readonly goal: string;
+  readonly conflicts: ReadonlyArray<string>;
+}
+
+export interface ComposeChapterResult extends PlanChapterResult {
+  readonly contextPath: string;
+  readonly ruleStackPath: string;
+  readonly tracePath: string;
+}
+
 export interface ReviseResult {
   readonly chapterNumber: number;
   readonly wordCount: number;
@@ -337,6 +351,51 @@ export class PipelineRunner {
     }
   }
 
+  async planChapter(bookId: string, context?: string): Promise<PlanChapterResult> {
+    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);
+    const { plan } = await this.createGovernedArtifacts(
+      book,
+      bookDir,
+      chapterNumber,
+      context ?? this.config.externalContext,
+    );
+
+    return {
+      bookId,
+      chapterNumber,
+      intentPath: this.relativeToBookDir(bookDir, plan.runtimePath),
+      goal: plan.intent.goal,
+      conflicts: plan.intent.conflicts.map((conflict) => `${conflict.type}: ${conflict.resolution}`),
+    };
+  }
+
+  async composeChapter(bookId: string, context?: string): Promise<ComposeChapterResult> {
+    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);
+    const { plan, composed } = await this.createGovernedArtifacts(
+      book,
+      bookDir,
+      chapterNumber,
+      context ?? this.config.externalContext,
+    );
+
+    return {
+      bookId,
+      chapterNumber,
+      intentPath: this.relativeToBookDir(bookDir, plan.runtimePath),
+      goal: plan.intent.goal,
+      conflicts: plan.intent.conflicts.map((conflict) => `${conflict.type}: ${conflict.resolution}`),
+      contextPath: this.relativeToBookDir(bookDir, composed.contextPath),
+      ruleStackPath: this.relativeToBookDir(bookDir, composed.ruleStackPath),
+      tracePath: this.relativeToBookDir(bookDir, composed.tracePath),
+    };
+  }
+
   /** Audit the latest (or specified) chapter. Read-only, no lock needed. */
   async auditDraft(bookId: string, chapterNumber?: number): Promise<AuditResult & { readonly chapterNumber: number }> {
     const book = await this.state.loadBookConfig(bookId);
@@ -1169,6 +1228,30 @@ ${matrix}`,
       return { externalContext };
     }
 
+    const { plan, composed } = await this.createGovernedArtifacts(
+      book,
+      bookDir,
+      chapterNumber,
+      externalContext,
+    );
+
+    return {
+      chapterIntent: plan.intentMarkdown,
+      contextPackage: composed.contextPackage,
+      ruleStack: composed.ruleStack,
+      trace: composed.trace,
+    };
+  }
+
+  private async createGovernedArtifacts(
+    book: BookConfig,
+    bookDir: string,
+    chapterNumber: number,
+    externalContext?: string,
+  ): Promise<{
+    plan: Awaited<ReturnType<PlannerAgent["planChapter"]>>;
+    composed: Awaited<ReturnType<ComposerAgent["composeChapter"]>>;
+  }> {
     const planner = new PlannerAgent(this.agentCtxFor("planner", book.id));
     const plan = await planner.planChapter({
       book,
@@ -1185,12 +1268,12 @@ ${matrix}`,
       plan,
     });
 
-    return {
-      chapterIntent: plan.intentMarkdown,
-      contextPackage: composed.contextPackage,
-      ruleStack: composed.ruleStack,
-      trace: composed.trace,
-    };
+    return { plan, composed };
+  }
+
+  private relativeToBookDir(bookDir: string, absolutePath: string): string {
+    const prefix = `${bookDir}/`;
+    return absolutePath.startsWith(prefix) ? absolutePath.slice(prefix.length) : absolutePath;
   }
 
   private async emitWebhook(

+ 17 - 3
packages/core/src/utils/config-loader.ts

@@ -10,7 +10,10 @@ export const GLOBAL_ENV_PATH = join(GLOBAL_CONFIG_DIR, ".env");
  * Load project config from inkos.json with .env overrides.
  * Shared by CLI and Studio — single source of truth for config loading.
  */
-export async function loadProjectConfig(root: string): Promise<ProjectConfig> {
+export async function loadProjectConfig(
+  root: string,
+  options?: { readonly requireApiKey?: boolean },
+): Promise<ProjectConfig> {
   // Load global ~/.inkos/.env first, then project .env overrides
   const { config: loadEnv } = await import("dotenv");
   loadEnv({ path: GLOBAL_ENV_PATH });
@@ -70,12 +73,23 @@ export async function loadProjectConfig(root: string): Promise<ProjectConfig> {
 
   // API key ONLY from env — never stored in inkos.json
   const apiKey = env.INKOS_LLM_API_KEY;
-  if (!apiKey) {
+  if (!apiKey && options?.requireApiKey !== false) {
     throw new Error(
       "INKOS_LLM_API_KEY not set. Run 'inkos config set-global' or add it to project .env file.",
     );
   }
-  llm.apiKey = apiKey;
+  if (options?.requireApiKey === false) {
+    llm.provider = typeof llm.provider === "string" && llm.provider.length > 0
+      ? llm.provider
+      : "openai";
+    llm.baseUrl = typeof llm.baseUrl === "string" && llm.baseUrl.length > 0
+      ? llm.baseUrl
+      : "https://example.invalid/v1";
+    llm.model = typeof llm.model === "string" && llm.model.length > 0
+      ? llm.model
+      : "noop-model";
+  }
+  llm.apiKey = apiKey ?? "";
 
   return ProjectConfigSchema.parse(config);
 }