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

refactor: update CLI commands for bilingual output and v2 governance

- All CLI commands use localized strings
- write/revise commands pass v2 governance context
- config command supports new keys
- doctor command updated
- Tests updated for new features
Ma 7 месяцев назад
Родитель
Сommit
40cd8e9c54

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

@@ -1,6 +1,6 @@
 import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
-import { dirname, join, resolve } from "node:path";
+import { basename, dirname, join, resolve } from "node:path";
 import { fileURLToPath } from "node:url";
 import { execFileSync } from "node:child_process";
 import { describe, it, expect, beforeAll, afterAll } from "vitest";
@@ -92,6 +92,11 @@ describe("CLI integration", () => {
       expect(gitignore).toContain(".env");
     });
 
+    it("creates Node version hints for sqlite-backed memory features", async () => {
+      await expect(readFile(join(projectDir, ".nvmrc"), "utf-8")).resolves.toContain("22");
+      await expect(readFile(join(projectDir, ".node-version"), "utf-8")).resolves.toContain("22");
+    });
+
     it("creates books/ and radar/ directories", async () => {
       const booksStat = await stat(join(projectDir, "books"));
       expect(booksStat.isDirectory()).toBe(true);
@@ -111,6 +116,34 @@ describe("CLI integration", () => {
       const config = JSON.parse(raw);
       expect(config.name).toBe("subproject");
     });
+
+    it("supports absolute project paths instead of nesting them under cwd", async () => {
+      const absoluteDir = await mkdtemp(join(tmpdir(), "inkos-cli-abs-init-"));
+
+      try {
+        const output = run(["init", absoluteDir]);
+        expect(output).toContain(`Project initialized at ${absoluteDir}`);
+
+        const raw = await readFile(join(absoluteDir, "inkos.json"), "utf-8");
+        const config = JSON.parse(raw);
+        expect(config.name).toBe(basename(absoluteDir));
+      } finally {
+        await rm(absoluteDir, { recursive: true, force: true });
+      }
+    });
+
+    it("prints English next steps when initialized with --lang en", async () => {
+      const englishDir = await mkdtemp(join(tmpdir(), "inkos-cli-en-init-"));
+
+      try {
+        const output = run(["init", englishDir, "--lang", "en"]);
+        expect(output).toContain("Project initialized");
+        expect(output).toContain("inkos book create --title 'My Novel'");
+        expect(output).not.toContain("我的小说");
+      } finally {
+        await rm(englishDir, { recursive: true, force: true });
+      }
+    });
   });
 
   describe("inkos config set", () => {
@@ -131,6 +164,15 @@ describe("CLI integration", () => {
         run(["config", "set", "custom.nested.key", "value"]);
       }).toThrow();
     });
+
+    it("sets input governance mode", async () => {
+      const output = run(["config", "set", "inputGovernanceMode", "v2"]);
+      expect(output).toContain("Set inputGovernanceMode = v2");
+
+      const raw = await readFile(join(projectDir, "inkos.json"), "utf-8");
+      const config = JSON.parse(raw);
+      expect(config.inputGovernanceMode).toBe("v2");
+    });
   });
 
   describe("inkos config show", () => {
@@ -243,8 +285,31 @@ describe("CLI integration", () => {
       const { stdout } = runStderr(["doctor"]);
       expect(stdout).toContain("InkOS Doctor");
       expect(stdout).toContain("Node.js >= 20");
+      expect(stdout).toContain("SQLite memory index");
       expect(stdout).toContain("inkos.json");
     });
+
+    it("repairs missing node runtime pin files for old projects", async () => {
+      await stat(join(projectDir, "inkos.json")).catch(() => {
+        run(["init"]);
+      });
+
+      await rm(join(projectDir, ".nvmrc"), { force: true });
+      await rm(join(projectDir, ".node-version"), { force: true });
+
+      const before = runStderr(["doctor"]);
+      expect(before.stdout).toContain("Node runtime pin files");
+      expect(before.stdout).toContain(".nvmrc");
+      expect(before.stdout).toContain(".node-version");
+
+      const repaired = runStderr(["doctor", "--repair-node-runtime"]);
+      expect(repaired.stdout).toContain("Node runtime pin files repaired");
+      expect(repaired.stdout).toContain(".nvmrc");
+      expect(repaired.stdout).toContain(".node-version");
+
+      await expect(readFile(join(projectDir, ".nvmrc"), "utf-8")).resolves.toBe("22\n");
+      await expect(readFile(join(projectDir, ".node-version"), "utf-8")).resolves.toBe("22\n");
+    });
   });
 
   describe("inkos analytics", () => {
@@ -256,6 +321,10 @@ describe("CLI integration", () => {
 
   describe("inkos plan/compose", () => {
     beforeAll(async () => {
+      const configPath = join(projectDir, "inkos.json");
+      const initialized = await stat(configPath).then(() => true).catch(() => false);
+      if (!initialized) run(["init"]);
+
       const bookDir = join(projectDir, "books", "cli-book");
       const storyDir = join(bookDir, "story");
       await mkdir(join(storyDir, "runtime"), { recursive: true });
@@ -315,5 +384,74 @@ describe("CLI integration", () => {
       await expect(stat(join(projectDir, "books", "cli-book", data.ruleStackPath))).resolves.toBeTruthy();
       await expect(stat(join(projectDir, "books", "cli-book", data.tracePath))).resolves.toBeTruthy();
     });
+
+    it("reuses the planned intent when compose runs without a new context", async () => {
+      const plannedGoal = "Ignore the guild chase and focus on the mentor conflict.";
+      run(["plan", "chapter", "cli-book", "--context", plannedGoal]);
+
+      const output = run(["compose", "chapter", "cli-book", "--json"]);
+      const data = JSON.parse(output);
+      const intentMarkdown = await readFile(join(projectDir, "books", "cli-book", data.intentPath), "utf-8");
+
+      expect(data.goal).toBe(plannedGoal);
+      expect(intentMarkdown).toContain(plannedGoal);
+    });
+  });
+
+  describe("inkos export", () => {
+    beforeAll(async () => {
+      const configPath = join(projectDir, "inkos.json");
+      const initialized = await stat(configPath).then(() => true).catch(() => false);
+      if (!initialized) run(["init"]);
+
+      const bookDir = join(projectDir, "books", "export-book");
+      await mkdir(join(bookDir, "chapters"), { recursive: true });
+
+      await writeFile(
+        join(bookDir, "book.json"),
+        JSON.stringify({
+          id: "export-book",
+          title: "Export Book",
+          platform: "tomato",
+          genre: "xuanhuan",
+          status: "active",
+          targetChapters: 10,
+          chapterWordCount: 2000,
+          createdAt: "2026-03-23T00:00:00.000Z",
+          updatedAt: "2026-03-23T00:00:00.000Z",
+        }, null, 2),
+        "utf-8",
+      );
+      await writeFile(
+        join(bookDir, "chapters", "index.json"),
+        JSON.stringify([
+          {
+            number: 1,
+            title: "Dawn Ledger",
+            status: "ready-for-review",
+            wordCount: 1200,
+            createdAt: "2026-03-23T00:00:00.000Z",
+            updatedAt: "2026-03-23T00:00:00.000Z",
+            auditIssues: [],
+          },
+        ], null, 2),
+        "utf-8",
+      );
+      await writeFile(
+        join(bookDir, "chapters", "0001_Dawn_Ledger.md"),
+        "# 第1章 Dawn Ledger\n\n正文。\n",
+        "utf-8",
+      );
+    });
+
+    it("creates missing parent directories for custom output paths", async () => {
+      const outputPath = join(projectDir, "exports", "nested", "book.md");
+      const output = run(["export", "export-book", "--format", "md", "--output", outputPath, "--json"]);
+      const data = JSON.parse(output);
+
+      expect(data.outputPath).toBe(outputPath);
+      await expect(stat(outputPath)).resolves.toBeTruthy();
+      await expect(readFile(outputPath, "utf-8")).resolves.toContain("# Export Book");
+    });
   });
 });

+ 16 - 6
packages/cli/src/commands/book.ts

@@ -3,6 +3,15 @@ import { access, readFile, rm } from "node:fs/promises";
 import { createInterface } from "node:readline";
 import { join, resolve } from "node:path";
 import { PipelineRunner, StateManager, type BookConfig } from "@actalk/inkos-core";
+import {
+  formatBookCreateCreated,
+  formatBookCreateCreating,
+  formatBookCreateFoundationReady,
+  formatBookCreateLocation,
+  formatBookCreateNextStep,
+  formatBookCreateResume,
+  resolveCliLanguage,
+} from "../localization.js";
 import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 
 export const bookCommand = new Command("book")
@@ -43,6 +52,7 @@ bookCommand
         createdAt: now,
         updatedAt: now,
       };
+      const language = resolveCliLanguage(book.language);
 
       const bookDir = join(root, "books", bookId);
       try {
@@ -54,14 +64,14 @@ bookCommand
         } catch (inner) {
           if (inner instanceof Error && inner.message.includes("already exists")) throw inner;
           // story_bible.md missing → incomplete init, allow re-run
-          if (!opts.json) log(`Resuming incomplete book creation for "${bookId}"...`);
+          if (!opts.json) log(formatBookCreateResume(language, bookId));
         }
       } catch (e) {
         if (e instanceof Error && e.message.includes("already exists")) throw e;
         // Directory doesn't exist, good
       }
 
-      if (!opts.json) log(`Creating book "${book.title}" (${book.genre} / ${book.platform})...`);
+      if (!opts.json) log(formatBookCreateCreating(language, book.title, book.genre, book.platform));
 
       const brief = opts.brief
         ? await readFile(resolve(opts.brief), "utf-8")
@@ -81,11 +91,11 @@ bookCommand
           nextStep: `inkos write next ${bookId}`,
         }, null, 2));
       } else {
-        log(`Book created: ${bookId}`);
-        log(`  Location: books/${bookId}/`);
-        log(`  Story bible, outline, book rules generated.`);
+        log(formatBookCreateCreated(language, bookId));
+        log(formatBookCreateLocation(language, bookId));
+        log(formatBookCreateFoundationReady(language));
         log("");
-        log(`Next: inkos write next ${bookId}`);
+        log(formatBookCreateNextStep(language, bookId));
       }
     } catch (e) {
       if (opts.json) {

+ 1 - 0
packages/cli/src/commands/config.ts

@@ -24,6 +24,7 @@ configCommand
       const KNOWN_KEYS = new Set([
         "llm.provider", "llm.baseUrl", "llm.model", "llm.temperature",
         "llm.maxTokens", "llm.thinkingBudget", "llm.apiFormat", "llm.stream",
+        "inputGovernanceMode",
         "daemon.schedule.radarCron", "daemon.schedule.writeCron",
         "daemon.maxConcurrentBooks", "daemon.chaptersPerCycle",
         "daemon.retryDelayMs", "daemon.cooldownAfterChapterMs",

+ 26 - 1
packages/cli/src/commands/doctor.ts

@@ -2,13 +2,30 @@ import { Command } from "commander";
 import { readFile } from "node:fs/promises";
 import { join } from "node:path";
 import { findProjectRoot, log, logError, GLOBAL_ENV_PATH } from "../utils.js";
+import {
+  ensureNodeRuntimePinFiles,
+  evaluateSqliteMemorySupport,
+  inspectNodeRuntimePinFiles,
+} from "../runtime-requirements.js";
 
 export const doctorCommand = new Command("doctor")
   .description("Check environment and project health")
-  .action(async () => {
+  .option("--repair-node-runtime", "Write .nvmrc and .node-version pinned to Node 22 for this project")
+  .action(async (opts: { repairNodeRuntime?: boolean }) => {
     const checks: Array<{ name: string; ok: boolean; detail: string }> = [];
     const root = findProjectRoot();
 
+    if (opts.repairNodeRuntime) {
+      const repair = await ensureNodeRuntimePinFiles(root);
+      checks.push({
+        name: "Node runtime pin files repaired",
+        ok: true,
+        detail: repair.updated
+          ? `Wrote ${repair.written.join(", ")} -> Node 22`
+          : "Already pinned to Node 22",
+      });
+    }
+
     // 1. Check Node.js version
     const nodeVersion = process.version;
     const major = parseInt(nodeVersion.slice(1).split(".")[0]!, 10);
@@ -17,6 +34,14 @@ export const doctorCommand = new Command("doctor")
       ok: major >= 20,
       detail: nodeVersion,
     });
+    checks.push({
+      name: "SQLite memory index (Node 22+)",
+      ...evaluateSqliteMemorySupport({ nodeVersion }),
+    });
+    checks.push({
+      name: "Node runtime pin files",
+      ...await inspectNodeRuntimePinFiles(root),
+    });
 
     // 2. Check inkos.json exists
     try {

+ 4 - 2
packages/cli/src/commands/export.ts

@@ -1,7 +1,7 @@
 import { Command } from "commander";
 import { StateManager } from "@actalk/inkos-core";
-import { readFile, readdir, writeFile } from "node:fs/promises";
-import { join } from "node:path";
+import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
 import { findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 
 export const exportCommand = new Command("export")
@@ -59,6 +59,7 @@ export const exportCommand = new Command("export")
 
       const outputPath =
         opts.output ?? join(root, `${bookId}_export.${opts.format}`);
+      await mkdir(dirname(outputPath), { recursive: true });
       await writeFile(outputPath, parts.join("\n"), "utf-8");
 
       if (opts.json) {
@@ -119,6 +120,7 @@ async function exportEpub(
 
   const totalWords = chapters.reduce((sum, ch) => sum + ch.wordCount, 0);
   const outputPath = opts.output ?? join(root, `${bookId}.epub`);
+  await mkdir(dirname(outputPath), { recursive: true });
   await writeFile(outputPath, epubBuffer);
 
   if (opts.json) {

+ 28 - 11
packages/cli/src/commands/import.ts

@@ -3,6 +3,14 @@ import { PipelineRunner, StateManager, splitChapters } from "@actalk/inkos-core"
 import { readFile, readdir, stat } from "node:fs/promises";
 import { join, resolve } from "node:path";
 import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
+import {
+  formatImportCanonComplete,
+  formatImportCanonStart,
+  formatImportChaptersComplete,
+  formatImportChaptersDiscovery,
+  formatImportChaptersResume,
+  resolveCliLanguage,
+} from "../localization.js";
 
 export const importCommand = new Command("import")
   .description("Import external data into a book");
@@ -18,10 +26,13 @@ importCommand
       const root = findProjectRoot();
       const targetBookId = await resolveBookId(targetBookIdArg, root);
       const config = await loadConfig();
+      const state = new StateManager(root);
+      const targetBook = await state.loadBookConfig(targetBookId);
+      const language = resolveCliLanguage(targetBook.language);
 
       const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
 
-      if (!opts.json) log(`Importing canon from "${opts.from}" into "${targetBookId}"...`);
+      if (!opts.json) log(formatImportCanonStart(language, opts.from, targetBookId));
 
       await pipeline.importCanon(targetBookId, opts.from);
 
@@ -32,8 +43,9 @@ importCommand
           output: "story/parent_canon.md",
         }, null, 2));
       } else {
-        log(`Canon imported: story/parent_canon.md`);
-        log(`Writer and auditor will auto-detect this file for spinoff mode.`);
+        for (const line of formatImportCanonComplete(language)) {
+          log(line);
+        }
       }
     } catch (e) {
       if (opts.json) {
@@ -60,6 +72,8 @@ importCommand
       const config = await loadConfig();
 
       const state = new StateManager(root);
+      const book = await state.loadBookConfig(bookId);
+      const language = resolveCliLanguage(book.language);
       const existingChapterCount = (await state.getNextChapterNumber(bookId)) - 1;
       if (existingChapterCount > 0 && !opts.resumeFrom) {
         throw new Error(
@@ -99,15 +113,15 @@ importCommand
         if (chapters.length === 0) {
           throw new Error(
             `No chapters found in ${fromPath}. ` +
-            `Default pattern matches "第X章". Use --split to provide a custom regex.`,
+            `Default pattern matches "第X章" and "Chapter X". Use --split to provide a custom regex.`,
           );
         }
       }
 
       if (!opts.json) {
-        log(`Found ${chapters.length} chapters to import into "${bookId}".`);
+        log(formatImportChaptersDiscovery(language, chapters.length, bookId));
         if (opts.resumeFrom) {
-          log(`Resuming from chapter ${opts.resumeFrom}.`);
+          log(formatImportChaptersResume(language, opts.resumeFrom));
         }
       }
 
@@ -122,11 +136,14 @@ importCommand
       if (opts.json) {
         log(JSON.stringify(result, null, 2));
       } else {
-        log(`Import complete:`);
-        log(`  Chapters imported: ${result.importedCount}`);
-        log(`  Total characters: ${result.totalWords}`);
-        log(`  Next chapter number: ${result.nextChapter}`);
-        log(`\nRun "inkos write next ${bookId}" to continue writing.`);
+        for (const line of formatImportChaptersComplete(language, {
+          importedCount: result.importedCount,
+          totalWords: result.totalWords,
+          nextChapter: result.nextChapter,
+          continueBookId: bookId,
+        })) {
+          log(line);
+        }
       }
     } catch (e) {
       if (opts.json) {

+ 13 - 5
packages/cli/src/commands/init.ts

@@ -1,6 +1,6 @@
 import { Command } from "commander";
 import { access, readFile, writeFile, mkdir } from "node:fs/promises";
-import { join, basename } from "node:path";
+import { join, basename, resolve } from "node:path";
 import { log, logError, GLOBAL_ENV_PATH } from "../utils.js";
 
 async function hasGlobalConfig(): Promise<boolean> {
@@ -17,8 +17,8 @@ export const initCommand = new Command("init")
   .argument("[name]", "Project name (creates subdirectory). Omit to init current directory.")
   .option("--lang <language>", "Default writing language: zh (Chinese) or en (English)", "zh")
   .action(async (name: string | undefined, opts: { lang?: string }) => {
-    const projectDir = name ? join(process.cwd(), name) : process.cwd();
-    const projectName = name ?? basename(projectDir);
+    const projectDir = name ? resolve(process.cwd(), name) : process.cwd();
+    const projectName = basename(projectDir);
 
     try {
       await mkdir(projectDir, { recursive: true });
@@ -60,6 +60,10 @@ export const initCommand = new Command("init")
         JSON.stringify(config, null, 2),
         "utf-8",
       );
+      await Promise.all([
+        writeFile(join(projectDir, ".nvmrc"), "22\n", "utf-8"),
+        writeFile(join(projectDir, ".node-version"), "22\n", "utf-8"),
+      ]);
 
       const global = await hasGlobalConfig();
 
@@ -119,12 +123,16 @@ export const initCommand = new Command("init")
 
       log(`Project initialized at ${projectDir}`);
       log("");
+      const isEnglish = (opts.lang ?? "zh") === "en";
+      const exampleCreate = isEnglish
+        ? "  inkos book create --title 'My Novel' --genre progression --platform royalroad --lang en"
+        : "  inkos book create --title '我的小说' --genre xuanhuan --platform tomato";
       if (global) {
         log("Global LLM config detected. Ready to go!");
         log("");
         log("Next steps:");
         if (name) log(`  cd ${name}`);
-        log("  inkos book create --title '我的小说' --genre xuanhuan --platform tomato");
+        log(exampleCreate);
       } else {
         log("Next steps:");
         if (name) log(`  cd ${name}`);
@@ -132,7 +140,7 @@ export const initCommand = new Command("init")
         log("  inkos config set-global --provider openai --base-url <your-api-url> --api-key <your-key> --model <your-model>");
         log("  # Option 2: Edit .env for this project only");
         log("");
-        log("  inkos book create --title '我的小说' --genre xuanhuan --platform tomato");
+        log(exampleCreate);
       }
       log("  inkos write next <book-id>");
     } catch (e) {

+ 6 - 4
packages/cli/src/commands/revise.ts

@@ -1,12 +1,12 @@
 import { Command } from "commander";
-import { PipelineRunner, type ReviseMode } from "@actalk/inkos-core";
+import { DEFAULT_REVISE_MODE, PipelineRunner, type ReviseMode } from "@actalk/inkos-core";
 import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 
 export const reviseCommand = new Command("revise")
   .description("Revise a chapter based on audit issues")
   .argument("[book-id]", "Book ID (auto-detected if only one book)")
   .argument("[chapter]", "Chapter number (defaults to latest)")
-  .option("--mode <mode>", "Revise mode: polish, rewrite, rework, spot-fix", "rewrite")
+  .option("--mode <mode>", "Revise mode: spot-fix, polish, rewrite, rework, anti-detect", DEFAULT_REVISE_MODE)
   .option("--json", "Output JSON")
   .action(async (bookIdArg: string | undefined, chapterStr: string | undefined, opts) => {
     try {
@@ -32,11 +32,13 @@ export const reviseCommand = new Command("revise")
 
       if (opts.json) {
         log(JSON.stringify(result, null, 2));
-      } else if (result.fixedIssues.length === 0) {
-        log(`  Chapter ${result.chapterNumber}: no issues to fix (audit passed)`);
+      } else if (!result.applied) {
+        log(`  Chapter ${result.chapterNumber}: kept original draft`);
+        if (result.skippedReason) log(`  Reason: ${result.skippedReason}`);
       } else {
         log(`  Chapter ${result.chapterNumber} revised`);
         log(`  Words: ${result.wordCount}`);
+        log(`  Status: ${result.status}`);
         log("  Fixed:");
         for (const fix of result.fixedIssues) {
           log(`    - ${fix}`);

+ 29 - 20
packages/cli/src/commands/write.ts

@@ -4,6 +4,7 @@ import { readdir, unlink } from "node:fs/promises";
 import { join } from "node:path";
 import { createInterface } from "node:readline";
 import { loadConfig, buildPipelineConfig, findProjectRoot, resolveContext, resolveBookId, log, logError } from "../utils.js";
+import { formatWriteNextComplete, formatWriteNextProgress, formatWriteNextResultLines, resolveCliLanguage } from "../localization.js";
 
 export const writeCommand = new Command("write")
   .description("Write chapters");
@@ -24,6 +25,9 @@ writeCommand
       const root = findProjectRoot();
       const bookId = await resolveBookId(bookIdArg, root);
       const context = await resolveContext(opts);
+      const state = new StateManager(root);
+      const book = await state.loadBookConfig(bookId);
+      const language = resolveCliLanguage(book.language);
 
       const pipeline = new PipelineRunner(buildPipelineConfig(config, root, { externalContext: context, quiet: opts.quiet }));
 
@@ -32,27 +36,23 @@ writeCommand
 
       const results = [];
       for (let i = 0; i < count; i++) {
-        if (!opts.json) log(`[${i + 1}/${count}] Writing chapter for "${bookId}"...`);
+        if (!opts.json) log(formatWriteNextProgress(language, i + 1, count, bookId));
 
         const result = await pipeline.writeNextChapter(bookId, wordCount);
         results.push(result);
 
         if (!opts.json) {
-          log(`  Chapter ${result.chapterNumber}: ${result.title}`);
-          log(`  Words: ${result.wordCount}`);
-          log(`  Audit: ${result.auditResult.passed ? "PASSED" : "NEEDS REVIEW"}`);
-          if (result.revised) {
-            log("  Auto-revised: YES (critical issues were fixed)");
-          }
-          log(`  Status: ${result.status}`);
-
-          if (result.auditResult.issues.length > 0) {
-            log("  Issues:");
-            for (const issue of result.auditResult.issues) {
-              log(`    [${issue.severity}] ${issue.category}: ${issue.description}`);
-            }
+          for (const line of formatWriteNextResultLines(language, {
+            chapterNumber: result.chapterNumber,
+            title: result.title,
+            wordCount: result.wordCount,
+            auditPassed: result.auditResult.passed,
+            revised: result.revised,
+            status: result.status,
+            issues: result.auditResult.issues,
+          })) {
+            log(line);
           }
-
           log("");
         }
       }
@@ -60,7 +60,7 @@ writeCommand
       if (opts.json) {
         log(JSON.stringify(results, null, 2));
       } else {
-        log("Done.");
+        log(formatWriteNextComplete(language));
       }
     } catch (e) {
       if (opts.json) {
@@ -154,14 +154,23 @@ writeCommand
       const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
 
       const result = await pipeline.writeNextChapter(bookId, wordCount);
+      const book = await state.loadBookConfig(bookId);
+      const language = resolveCliLanguage(book.language);
 
       if (opts.json) {
         log(JSON.stringify(result, null, 2));
       } else {
-        log(`  Chapter ${result.chapterNumber}: ${result.title}`);
-        log(`  Words: ${result.wordCount}`);
-        log(`  Audit: ${result.auditResult.passed ? "PASSED" : "NEEDS REVIEW"}`);
-        log(`  Status: ${result.status}`);
+        for (const line of formatWriteNextResultLines(language, {
+          chapterNumber: result.chapterNumber,
+          title: result.title,
+          wordCount: result.wordCount,
+          auditPassed: result.auditResult.passed,
+          revised: result.revised,
+          status: result.status,
+          issues: result.auditResult.issues,
+        })) {
+          log(line);
+        }
       }
     } catch (e) {
       if (opts.json) {

+ 11 - 0
packages/cli/src/utils.ts

@@ -1,9 +1,12 @@
 import { readFile } from "node:fs/promises";
 import { resolve } from "node:path";
 import { createLLMClient, StateManager, createLogger, createStderrSink, createJsonLineSink, loadProjectConfig, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH, type ProjectConfig, type PipelineConfig, type LogSink } from "@actalk/inkos-core";
+import { formatSqliteMemorySupportWarning } from "./runtime-requirements.js";
 
 export { GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH };
 
+let sqliteMemorySupportWarned = false;
+
 export async function resolveContext(opts: {
   readonly context?: string;
   readonly contextFile?: string;
@@ -44,6 +47,14 @@ export function buildPipelineConfig(
     readonly logFile?: NodeJS.WritableStream;
   },
 ): PipelineConfig {
+  if (!extra?.quiet && !sqliteMemorySupportWarned) {
+    const warning = formatSqliteMemorySupportWarning();
+    if (warning) {
+      sqliteMemorySupportWarned = true;
+      process.stderr.write(`[WARN] ${warning}\n`);
+    }
+  }
+
   const sinks: LogSink[] = [];
   if (!extra?.quiet) {
     sinks.push(createStderrSink({ minLevel: "info" }));