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

feat(revision): add brief-aware revise/rewrite and chapter resync

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

+ 82 - 0
packages/cli/src/__tests__/revision-command.test.ts

@@ -0,0 +1,82 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const reviseDraftMock = vi.fn();
+const resyncChapterArtifactsMock = vi.fn();
+const buildPipelineConfigMock = vi.fn();
+const loadBookConfigMock = vi.fn();
+const logMock = vi.fn();
+const logErrorMock = vi.fn();
+
+vi.mock("@actalk/inkos-core", () => ({
+  DEFAULT_REVISE_MODE: "spot-fix",
+  PipelineRunner: class {
+    reviseDraft = reviseDraftMock;
+    resyncChapterArtifacts = resyncChapterArtifactsMock;
+  },
+  StateManager: class {
+    async loadBookConfig() {
+      return loadBookConfigMock();
+    }
+  },
+}));
+
+vi.mock("../utils.js", () => ({
+  loadConfig: vi.fn(async () => ({ llm: {} })),
+  buildPipelineConfig: buildPipelineConfigMock,
+  findProjectRoot: vi.fn(() => "/project"),
+  resolveBookId: vi.fn(async (bookId?: string) => bookId ?? "auto-book"),
+  getLegacyMigrationHint: vi.fn(async () => undefined),
+  resolveContext: vi.fn(),
+  log: logMock,
+  logError: logErrorMock,
+}));
+
+vi.mock("../localization.js", () => ({
+  formatWriteNextResultLines: vi.fn(() => ["ok"]),
+  resolveCliLanguage: vi.fn(() => "zh"),
+}));
+
+describe("revision-related CLI commands", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    reviseDraftMock.mockResolvedValue({
+      chapterNumber: 3,
+      wordCount: 1200,
+      fixedIssues: [],
+      applied: true,
+      status: "ready-for-review",
+    });
+    resyncChapterArtifactsMock.mockResolvedValue({
+      chapterNumber: 3,
+      title: "Synced Chapter",
+      wordCount: 1200,
+      revised: false,
+      status: "ready-for-review",
+      auditResult: { passed: true, issues: [], summary: "synced" },
+    });
+    buildPipelineConfigMock.mockReturnValue({});
+    loadBookConfigMock.mockResolvedValue({ language: "zh" });
+  });
+
+  it("passes one-off brief into revise command pipeline config", async () => {
+    const { reviseCommand } = await import("../commands/revise.js");
+
+    await reviseCommand.parseAsync(["node", "revise", "demo-book", "3", "--mode", "rewrite", "--brief", "把注意力拉回师债主线。"], { from: "node" });
+
+    expect(buildPipelineConfigMock).toHaveBeenCalledWith(expect.anything(), "/project", {
+      externalContext: "把注意力拉回师债主线。",
+    });
+    expect(reviseDraftMock).toHaveBeenCalledWith("demo-book", 3, "rewrite");
+  });
+
+  it("exposes write sync and passes brief into pipeline config", async () => {
+    const { writeCommand } = await import("../commands/write.js");
+
+    await writeCommand.parseAsync(["node", "write", "sync", "demo-book", "3", "--brief", "以师债线为准同步状态。"], { from: "node" });
+
+    expect(buildPipelineConfigMock).toHaveBeenCalledWith(expect.anything(), "/project", {
+      externalContext: "以师债线为准同步状态。",
+    });
+    expect(resyncChapterArtifactsMock).toHaveBeenCalledWith("demo-book", 3);
+  });
+});

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

@@ -7,6 +7,7 @@ export const reviseCommand = new Command("revise")
   .argument("[book-id]", "Book ID (auto-detected if only one book)")
   .argument("[book-id]", "Book ID (auto-detected if only one book)")
   .argument("[chapter]", "Chapter number (defaults to latest)")
   .argument("[chapter]", "Chapter number (defaults to latest)")
   .option("--mode <mode>", "Revise mode: spot-fix, polish, rewrite, rework, anti-detect", DEFAULT_REVISE_MODE)
   .option("--mode <mode>", "Revise mode: spot-fix, polish, rewrite, rework, anti-detect", DEFAULT_REVISE_MODE)
+  .option("--brief <text>", "One-off creative guidance for this revise/rewrite only")
   .option("--json", "Output JSON")
   .option("--json", "Output JSON")
   .action(async (bookIdArg: string | undefined, chapterStr: string | undefined, opts) => {
   .action(async (bookIdArg: string | undefined, chapterStr: string | undefined, opts) => {
     try {
     try {
@@ -23,7 +24,9 @@ export const reviseCommand = new Command("revise")
         chapterNumber = chapterStr ? parseInt(chapterStr, 10) : undefined;
         chapterNumber = chapterStr ? parseInt(chapterStr, 10) : undefined;
       }
       }
 
 
-      const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
+      const pipeline = new PipelineRunner(buildPipelineConfig(config, root, {
+        externalContext: opts.brief,
+      }));
 
 
       const mode = opts.mode as ReviseMode;
       const mode = opts.mode as ReviseMode;
       if (!opts.json) log(`Revising "${bookId}"${chapterNumber ? ` chapter ${chapterNumber}` : " (latest)"} [mode: ${mode}]...`);
       if (!opts.json) log(`Revising "${bookId}"${chapterNumber ? ` chapter ${chapterNumber}` : " (latest)"} [mode: ${mode}]...`);

+ 62 - 1
packages/cli/src/commands/write.ts

@@ -91,6 +91,7 @@ writeCommand
   .argument("<args...>", "Book ID (optional) and chapter number")
   .argument("<args...>", "Book ID (optional) and chapter number")
   .option("--force", "Skip confirmation prompt")
   .option("--force", "Skip confirmation prompt")
   .option("--words <n>", "Words per chapter (overrides book config)")
   .option("--words <n>", "Words per chapter (overrides book config)")
+  .option("--brief <text>", "One-off creative guidance for this rewrite only")
   .option("--json", "Output JSON")
   .option("--json", "Output JSON")
   .action(async (args: ReadonlyArray<string>, opts) => {
   .action(async (args: ReadonlyArray<string>, opts) => {
     try {
     try {
@@ -168,7 +169,9 @@ writeCommand
       const wordCount = opts.words ? parseInt(opts.words, 10) : undefined;
       const wordCount = opts.words ? parseInt(opts.words, 10) : undefined;
 
 
       const config = await loadConfig();
       const config = await loadConfig();
-      const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
+      const pipeline = new PipelineRunner(buildPipelineConfig(config, root, {
+        externalContext: opts.brief,
+      }));
 
 
       const result = await pipeline.writeNextChapter(bookId, wordCount);
       const result = await pipeline.writeNextChapter(bookId, wordCount);
       const book = await state.loadBookConfig(bookId);
       const book = await state.loadBookConfig(bookId);
@@ -199,6 +202,64 @@ writeCommand
     }
     }
   });
   });
 
 
+writeCommand
+  .command("sync")
+  .description("Rebuild truth files and SQLite indexes from the latest edited chapter body")
+  .argument("<args...>", "Book ID (optional) and chapter number")
+  .option("--brief <text>", "One-off guidance for how to interpret the edited chapter while syncing")
+  .option("--json", "Output JSON")
+  .action(async (args: ReadonlyArray<string>, opts) => {
+    try {
+      const root = findProjectRoot();
+
+      let bookId: string;
+      let chapter: number;
+      if (args.length === 1) {
+        chapter = parseInt(args[0]!, 10);
+        if (isNaN(chapter)) throw new Error(`Expected chapter number, got "${args[0]}"`);
+        bookId = await resolveBookId(undefined, root);
+      } else if (args.length === 2) {
+        chapter = parseInt(args[1]!, 10);
+        if (isNaN(chapter)) throw new Error(`Expected chapter number, got "${args[1]}"`);
+        bookId = await resolveBookId(args[0], root);
+      } else {
+        throw new Error("Usage: inkos write sync [book-id] <chapter>");
+      }
+
+      const state = new StateManager(root);
+      const book = await state.loadBookConfig(bookId);
+      const language = resolveCliLanguage(book.language);
+      const config = await loadConfig();
+      const pipeline = new PipelineRunner(buildPipelineConfig(config, root, {
+        externalContext: opts.brief,
+      }));
+      const result = await pipeline.resyncChapterArtifacts(bookId, chapter);
+
+      if (opts.json) {
+        log(JSON.stringify(result, null, 2));
+      } else {
+        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) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Failed to sync chapter artifacts: ${e}`);
+      }
+      process.exit(1);
+    }
+  });
+
 writeCommand
 writeCommand
   .command("repair-state")
   .command("repair-state")
   .description("Rebuild truth files for a persisted state-degraded chapter without rewriting body text")
   .description("Rebuild truth files for a persisted state-degraded chapter without rewriting body text")

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

@@ -2502,6 +2502,91 @@ describe("PipelineRunner", () => {
     await rm(root, { recursive: true, force: true });
     await rm(root, { recursive: true, force: true });
   });
   });
 
 
+  it("syncs the latest edited chapter body back into truth files without requiring state-degraded status", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "v2",
+      externalContext: "把注意力收回师债主线。",
+    });
+    const now = "2026-03-19T00:00:00.000Z";
+    const bookDir = state.bookDir(bookId);
+    const storyDir = join(bookDir, "story");
+
+    await Promise.all([
+      writeFile(join(storyDir, "current_focus.md"), "# 当前聚焦\n\n## 当前重点\n\n商会路线优先。\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# 卷纲\n\n## 第1章\n先处理商会路线噪音。\n", "utf-8"),
+      writeFile(join(storyDir, "story_bible.md"), "# 世界观设定\n\n- 誓令碎片不可伪造。\n", "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), "stable state", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "stable hooks", "utf-8"),
+      writeFile(join(storyDir, "particle_ledger.md"), "stable ledger", "utf-8"),
+      writeFile(join(storyDir, "chapter_summaries.md"), [
+        "# 章节摘要",
+        "",
+        "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+        "| --- | --- | --- | --- | --- | --- | --- | --- |",
+        "| 1 | 夜灯 | 林越 | 林越继续追查师债 | 追查意图更强 | 师债推进 | 压抑 | 主线推进 |",
+        "",
+      ].join("\n"), "utf-8"),
+      writeFile(
+        join(bookDir, "chapters", "0001_夜灯.md"),
+        "# 第1章 夜灯\n\n林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。",
+        "utf-8",
+      ),
+      state.saveChapterIndex(bookId, [{
+        number: 1,
+        title: "夜灯",
+        status: "approved" as ChapterMeta["status"],
+        wordCount: 55,
+        createdAt: now,
+        updatedAt: now,
+        auditIssues: [],
+        lengthWarnings: [],
+      }]),
+    ]);
+
+    const settleSpy = vi.spyOn(
+      WriterAgent.prototype as unknown as {
+        settleChapterState: (input: Record<string, unknown>) => Promise<WriteChapterOutput>;
+      },
+      "settleChapterState",
+    ).mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 1,
+        title: "夜灯",
+        content: "林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。",
+        wordCount: "林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。".length,
+        updatedState: "synced state",
+        updatedHooks: "synced hooks",
+        updatedLedger: "synced ledger",
+      }),
+    );
+    vi.spyOn(StateValidatorAgent.prototype, "validate").mockResolvedValue({
+      passed: true,
+      warnings: [],
+    });
+
+    const result = await (
+      runner as unknown as {
+        resyncChapterArtifacts: (bookId: string, chapterNumber?: number) => Promise<{
+          status: string;
+          chapterNumber: number;
+        }>;
+      }
+    ).resyncChapterArtifacts(bookId, 1);
+    const savedIndex = await state.loadChapterIndex(bookId);
+
+    expect(result.status).toBe("ready-for-review");
+    expect(result.chapterNumber).toBe(1);
+    expect(settleSpy).toHaveBeenCalledWith(expect.objectContaining({
+      allowReapply: true,
+      chapterIntent: expect.stringContaining("把注意力收回师债主线"),
+    }));
+    await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe("synced state");
+    await expect(readFile(join(storyDir, "pending_hooks.md"), "utf-8")).resolves.toBe("synced hooks");
+    expect(savedIndex[0]?.status).toBe("ready-for-review");
+
+    await rm(root, { recursive: true, force: true });
+  });
+
   it("still persists the chapter when the state validator appends markdown after a valid JSON verdict", async () => {
   it("still persists the chapter when the state validator appends markdown after a valid JSON verdict", async () => {
     vi.restoreAllMocks();
     vi.restoreAllMocks();
     vi.spyOn(LengthNormalizerAgent.prototype, "normalizeChapter").mockImplementation(
     vi.spyOn(LengthNormalizerAgent.prototype, "normalizeChapter").mockImplementation(
@@ -3988,6 +4073,85 @@ describe("PipelineRunner", () => {
     }
     }
   });
   });
 
 
+  it("passes one-off external brief into manual revise in v2 mode", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "v2",
+      externalContext: "把注意力收回师债主线,并强调柜台后的异常灯光。",
+    });
+    const storyDir = join(state.bookDir(bookId), "story");
+    const chaptersDir = join(state.bookDir(bookId), "chapters");
+    const originalBody = "林越推门进去,先看见柜台后那盏没关的灯。";
+
+    await Promise.all([
+      writeFile(join(storyDir, "current_focus.md"), "# 当前聚焦\n\n## 当前重点\n\n商会路线优先。\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# 卷纲\n\n## 第1章\n先处理商会路线噪音。\n", "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), createStateCard({
+        chapter: 1,
+        location: "旧港便利店",
+        protagonistState: "林越仍在追查师债。",
+        goal: "把注意力拉回师债线索。",
+        conflict: "商会路线仍在分散注意力。",
+      }), "utf-8"),
+      writeFile(join(storyDir, "story_bible.md"), "# 世界观设定\n\n- 誓令碎片不可伪造。\n", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# 伏笔池\n\n- 师债线索仍未回收。\n", "utf-8"),
+      writeFile(join(storyDir, "chapter_summaries.md"), [
+        "# 章节摘要",
+        "",
+        "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+        "| --- | --- | --- | --- | --- | --- | --- | --- |",
+        "| 1 | 夜灯 | 林越 | 林越继续追查师债 | 追查意图更强 | 师债推进 | 压抑 | 主线推进 |",
+        "",
+      ].join("\n"), "utf-8"),
+      writeFile(join(chaptersDir, "0001_夜灯.md"), `# 第1章 夜灯\n\n${originalBody}`, "utf-8"),
+    ]);
+    await state.saveChapterIndex(bookId, [{
+      number: 1,
+      title: "夜灯",
+      status: "audit-failed",
+      wordCount: originalBody.length,
+      createdAt: "2026-03-19T00:00:00.000Z",
+      updatedAt: "2026-03-19T00:00:00.000Z",
+      auditIssues: [],
+      lengthWarnings: [],
+    }]);
+
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: false,
+          issues: [CRITICAL_ISSUE],
+          summary: "needs revision",
+        }),
+      )
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: true,
+          issues: [],
+          summary: "clean",
+        }),
+      );
+    const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
+      createReviseOutput({
+        revisedContent: "林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。",
+        wordCount: "林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。".length,
+        fixedIssues: ["- 收紧了主线焦点。"],
+      }),
+    );
+
+    try {
+      await runner.reviseDraft(bookId, 1);
+
+      expect(reviseChapter.mock.calls[0]?.[6]).toMatchObject({
+        chapterIntent: expect.stringContaining("把注意力收回师债主线"),
+      });
+      expect(reviseChapter.mock.calls[0]?.[6]).not.toMatchObject({
+        chapterIntent: expect.stringContaining("商会路线优先"),
+      });
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("passes merged AI-tell issues into manual revise and rejects no-improvement revisions", async () => {
   it("passes merged AI-tell issues into manual revise and rejects no-improvement revisions", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const { root, runner, state, bookId } = await createRunnerFixture();
     const storyDir = join(state.bookDir(bookId), "story");
     const storyDir = join(state.bookDir(bookId), "story");

+ 159 - 1
packages/core/src/pipeline/runner.ts

@@ -853,7 +853,7 @@ export class PipelineRunner {
           book,
           book,
           bookDir,
           bookDir,
           targetChapter,
           targetChapter,
-          undefined,
+          this.config.externalContext,
           { reuseExistingIntentWhenContextMissing: true },
           { reuseExistingIntentWhenContextMissing: true },
         );
         );
       const preRevision = await this.evaluateMergedAudit({
       const preRevision = await this.evaluateMergedAudit({
@@ -1150,6 +1150,15 @@ export class PipelineRunner {
     }
     }
   }
   }
 
 
+  async resyncChapterArtifacts(bookId: string, chapterNumber?: number): Promise<ChapterPipelineResult> {
+    const releaseLock = await this.state.acquireBookLock(bookId);
+    try {
+      return await this._resyncChapterArtifactsLocked(bookId, chapterNumber);
+    } finally {
+      await releaseLock();
+    }
+  }
+
   private async _writeNextChapterLocked(bookId: string, wordCount?: number, temperatureOverride?: number): Promise<ChapterPipelineResult> {
   private async _writeNextChapterLocked(bookId: string, wordCount?: number, temperatureOverride?: number): Promise<ChapterPipelineResult> {
     await this.state.ensureControlDocuments(bookId);
     await this.state.ensureControlDocuments(bookId);
     const book = await this.state.loadBookConfig(bookId);
     const book = await this.state.loadBookConfig(bookId);
@@ -1578,6 +1587,155 @@ export class PipelineRunner {
     };
     };
   }
   }
 
 
+  private async _resyncChapterArtifactsLocked(bookId: string, chapterNumber?: number): Promise<ChapterPipelineResult> {
+    const book = await this.state.loadBookConfig(bookId);
+    const bookDir = this.state.bookDir(bookId);
+    const stageLanguage = await this.resolveBookLanguage(book);
+    const index = [...(await this.state.loadChapterIndex(bookId))];
+    if (index.length === 0) {
+      throw new Error(`Book "${bookId}" has no persisted chapters to sync.`);
+    }
+
+    const targetChapter = chapterNumber ?? index[index.length - 1]!.number;
+    const targetIndex = index.findIndex((chapter) => chapter.number === targetChapter);
+    if (targetIndex < 0) {
+      throw new Error(`Chapter ${targetChapter} not found in "${bookId}".`);
+    }
+
+    const targetMeta = index[targetIndex]!;
+    const latestChapter = Math.max(...index.map((chapter) => chapter.number));
+    if (targetChapter !== latestChapter) {
+      throw new Error(`Only the latest persisted chapter can be synced safely (latest is ${latestChapter}).`);
+    }
+
+    this.logStage(stageLanguage, { zh: "根据已编辑正文同步真相文件与索引", en: "syncing truth files and indexes from edited chapter body" });
+    const { profile: gp } = await this.loadGenreProfile(book.genre);
+    const pipelineLang = book.language ?? gp.language;
+    const content = await this.readChapterContent(bookDir, targetChapter);
+    const storyDir = join(bookDir, "story");
+    const [oldState, oldHooks] = await Promise.all([
+      readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
+      readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
+    ]);
+
+    const reducedControlInput = (this.config.inputGovernanceMode ?? "v2") === "legacy"
+      ? undefined
+      : await this.createGovernedArtifacts(
+        book,
+        bookDir,
+        targetChapter,
+        this.config.externalContext,
+        { reuseExistingIntentWhenContextMissing: true },
+      );
+
+    const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
+    let syncedOutput = await writer.settleChapterState({
+      book,
+      bookDir,
+      chapterNumber: targetChapter,
+      title: targetMeta.title,
+      content,
+      chapterIntent: reducedControlInput?.plan.intentMarkdown,
+      contextPackage: reducedControlInput?.composed.contextPackage,
+      ruleStack: reducedControlInput?.composed.ruleStack,
+      allowReapply: true,
+    });
+    const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId));
+    let validation = await validator.validate(
+      content,
+      targetChapter,
+      oldState,
+      syncedOutput.updatedState,
+      oldHooks,
+      syncedOutput.updatedHooks,
+      pipelineLang,
+    );
+
+    if (!validation.passed) {
+      const recovery = await retrySettlementAfterValidationFailure({
+        writer,
+        validator,
+        book,
+        bookDir,
+        chapterNumber: targetChapter,
+        title: targetMeta.title,
+        content,
+        reducedControlInput: reducedControlInput
+          ? {
+              chapterIntent: reducedControlInput.plan.intentMarkdown,
+              contextPackage: reducedControlInput.composed.contextPackage,
+              ruleStack: reducedControlInput.composed.ruleStack,
+            }
+          : undefined,
+        oldState,
+        oldHooks,
+        originalValidation: validation,
+        language: pipelineLang,
+        logWarn: (message) => this.logWarn(pipelineLang, message),
+        logger: this.config.logger,
+      });
+      if (recovery.kind !== "recovered") {
+        throw new Error(
+          recovery.issues[0]?.description
+            ?? `Chapter sync still failed for chapter ${targetChapter}.`,
+        );
+      }
+      syncedOutput = recovery.output;
+      validation = recovery.validation;
+    }
+
+    if (!validation.passed) {
+      throw new Error(`Chapter sync still failed for chapter ${targetChapter}.`);
+    }
+
+    await writer.saveChapter(bookDir, syncedOutput, gp.numericalSystem, pipelineLang);
+    await writer.saveNewTruthFiles(bookDir, syncedOutput, pipelineLang);
+    await this.syncLegacyStructuredStateFromMarkdown(bookDir, targetChapter, syncedOutput);
+    await this.syncNarrativeMemoryIndex(bookId);
+    await this.state.snapshotState(bookId, targetChapter);
+    await this.syncCurrentStateFactHistory(bookId, targetChapter);
+
+    const finalStatus: "ready-for-review" | "audit-failed" = targetMeta.status === "state-degraded"
+      ? resolveStateDegradedBaseStatus(targetMeta)
+      : "ready-for-review";
+
+    if (targetMeta.status === "state-degraded") {
+      const degradedMetadata = parseStateDegradedReviewNote(targetMeta.reviewNote);
+      const injectedIssues = new Set(degradedMetadata?.injectedIssues ?? []);
+      index[targetIndex] = {
+        ...targetMeta,
+        status: finalStatus,
+        updatedAt: new Date().toISOString(),
+        auditIssues: targetMeta.auditIssues.filter((issue) => !injectedIssues.has(issue)),
+        reviewNote: undefined,
+      };
+    } else {
+      index[targetIndex] = {
+        ...targetMeta,
+        status: "ready-for-review",
+        updatedAt: new Date().toISOString(),
+      };
+    }
+    await this.state.saveChapterIndex(bookId, index);
+    return {
+      chapterNumber: targetChapter,
+      title: targetMeta.title,
+      wordCount: targetMeta.wordCount,
+      auditResult: {
+        passed: finalStatus !== "audit-failed",
+        issues: [],
+        summary: finalStatus === "audit-failed"
+          ? "chapter truth/state resynced from edited body, but chapter still needs audit fixes"
+          : "chapter truth/state resynced from edited body",
+      },
+      revised: false,
+      status: finalStatus,
+      lengthWarnings: targetMeta.lengthWarnings,
+      lengthTelemetry: targetMeta.lengthTelemetry,
+      tokenUsage: targetMeta.tokenUsage,
+    };
+  }
+
   // ---------------------------------------------------------------------------
   // ---------------------------------------------------------------------------
   // Import operations (style imitation + canon for spinoff)
   // Import operations (style imitation + canon for spinoff)
   // ---------------------------------------------------------------------------
   // ---------------------------------------------------------------------------

+ 62 - 0
packages/studio/src/api/server.test.ts

@@ -6,6 +6,9 @@ import { tmpdir } from "node:os";
 const schedulerStartMock = vi.fn<() => Promise<void>>();
 const schedulerStartMock = vi.fn<() => Promise<void>>();
 const initBookMock = vi.fn();
 const initBookMock = vi.fn();
 const runRadarMock = vi.fn();
 const runRadarMock = vi.fn();
+const reviseDraftMock = vi.fn();
+const resyncChapterArtifactsMock = vi.fn();
+const writeNextChapterMock = vi.fn();
 const rollbackToChapterMock = vi.fn();
 const rollbackToChapterMock = vi.fn();
 const saveChapterIndexMock = vi.fn();
 const saveChapterIndexMock = vi.fn();
 const loadChapterIndexMock = vi.fn();
 const loadChapterIndexMock = vi.fn();
@@ -61,6 +64,9 @@ vi.mock("@actalk/inkos-core", () => {
 
 
     initBook = initBookMock;
     initBook = initBookMock;
     runRadar = runRadarMock;
     runRadar = runRadarMock;
+    reviseDraft = reviseDraftMock;
+    resyncChapterArtifacts = resyncChapterArtifactsMock;
+    writeNextChapter = writeNextChapterMock;
   }
   }
 
 
   class MockScheduler {
   class MockScheduler {
@@ -136,6 +142,9 @@ describe("createStudioServer daemon lifecycle", () => {
     schedulerStartMock.mockReset();
     schedulerStartMock.mockReset();
     initBookMock.mockReset();
     initBookMock.mockReset();
     runRadarMock.mockReset();
     runRadarMock.mockReset();
+    reviseDraftMock.mockReset();
+    resyncChapterArtifactsMock.mockReset();
+    writeNextChapterMock.mockReset();
     rollbackToChapterMock.mockReset();
     rollbackToChapterMock.mockReset();
     saveChapterIndexMock.mockReset();
     saveChapterIndexMock.mockReset();
     loadChapterIndexMock.mockReset();
     loadChapterIndexMock.mockReset();
@@ -143,6 +152,29 @@ describe("createStudioServer daemon lifecycle", () => {
       marketSummary: "Fresh market summary",
       marketSummary: "Fresh market summary",
       recommendations: [],
       recommendations: [],
     });
     });
+    reviseDraftMock.mockResolvedValue({
+      chapterNumber: 3,
+      wordCount: 1800,
+      fixedIssues: ["focus restored"],
+      applied: true,
+      status: "ready-for-review",
+    });
+    resyncChapterArtifactsMock.mockResolvedValue({
+      chapterNumber: 3,
+      title: "Synced Chapter",
+      wordCount: 1800,
+      revised: false,
+      status: "ready-for-review",
+      auditResult: { passed: true, issues: [], summary: "synced" },
+    });
+    writeNextChapterMock.mockResolvedValue({
+      chapterNumber: 3,
+      title: "Rewritten Chapter",
+      wordCount: 1800,
+      revised: false,
+      status: "ready-for-review",
+      auditResult: { passed: true, issues: [], summary: "rewritten" },
+    });
     createLLMClientMock.mockReset();
     createLLMClientMock.mockReset();
     createLLMClientMock.mockReturnValue({});
     createLLMClientMock.mockReturnValue({});
     chatCompletionMock.mockReset();
     chatCompletionMock.mockReset();
@@ -443,4 +475,34 @@ describe("createStudioServer daemon lifecycle", () => {
     expect(rollbackToChapterMock).toHaveBeenCalledWith("demo-book", 2);
     expect(rollbackToChapterMock).toHaveBeenCalledWith("demo-book", 2);
     expect(saveChapterIndexMock).not.toHaveBeenCalled();
     expect(saveChapterIndexMock).not.toHaveBeenCalled();
   });
   });
+
+  it("passes one-off brief into revise requests through pipeline config", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/books/demo-book/revise/3", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ mode: "rewrite", brief: "把注意力拉回师债主线。" }),
+    });
+
+    expect(response.status).toBe(200);
+    expect(pipelineConfigs.at(-1)).toMatchObject({ externalContext: "把注意力拉回师债主线。" });
+    expect(reviseDraftMock).toHaveBeenCalledWith("demo-book", 3, "rewrite");
+  });
+
+  it("exposes a resync endpoint for rebuilding latest chapter truth artifacts", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/books/demo-book/resync/3", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ brief: "以师债线为准同步状态。" }),
+    });
+
+    expect(response.status).toBe(200);
+    expect(pipelineConfigs.at(-1)).toMatchObject({ externalContext: "以师债线为准同步状态。" });
+    expect(resyncChapterArtifactsMock).toHaveBeenCalledWith("demo-book", 3);
+  });
 });
 });

+ 40 - 39
packages/studio/src/api/server.ts

@@ -83,7 +83,9 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     return freshConfig;
     return freshConfig;
   }
   }
 
 
-  async function buildPipelineConfig(): Promise<PipelineConfig> {
+  async function buildPipelineConfig(
+    overrides?: Partial<Pick<PipelineConfig, "externalContext">>,
+  ): Promise<PipelineConfig> {
     const currentConfig = await loadCurrentProjectConfig();
     const currentConfig = await loadCurrentProjectConfig();
     const logger = createLogger({ tag: "studio", sinks: [sseSink] });
     const logger = createLogger({ tag: "studio", sinks: [sseSink] });
     return {
     return {
@@ -103,6 +105,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
           });
           });
         }
         }
       },
       },
+      externalContext: overrides?.externalContext,
     };
     };
   }
   }
 
 
@@ -620,42 +623,19 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
   app.post("/api/books/:id/revise/:chapter", async (c) => {
   app.post("/api/books/:id/revise/:chapter", async (c) => {
     const id = c.req.param("id");
     const id = c.req.param("id");
     const chapterNum = parseInt(c.req.param("chapter"), 10);
     const chapterNum = parseInt(c.req.param("chapter"), 10);
-    const bookDir = state.bookDir(id);
-    const body = await c.req.json<{ mode?: string }>().catch(() => ({ mode: "spot-fix" }));
+    const body: { mode?: string; brief?: string } = await c.req
+      .json<{ mode?: string; brief?: string }>()
+      .catch(() => ({ mode: "spot-fix" }));
 
 
     broadcast("revise:start", { bookId: id, chapter: chapterNum });
     broadcast("revise:start", { bookId: id, chapter: chapterNum });
     try {
     try {
-      const book = await state.loadBookConfig(id);
-      const chaptersDir = join(bookDir, "chapters");
-      const files = await readdir(chaptersDir);
-      const paddedNum = String(chapterNum).padStart(4, "0");
-      const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
-      if (!match) return c.json({ error: "Chapter not found" }, 404);
-
-      const content = await readFile(join(chaptersDir, match), "utf-8");
-
-      // Get audit issues first
-      const index = await state.loadChapterIndex(id);
-      const chapterMeta = index.find((ch) => ch.number === chapterNum);
-      const issues = (chapterMeta?.auditIssues ?? []).map((desc) => ({
-        severity: "warning" as const,
-        category: "general",
-        description: desc,
-        suggestion: "",
+      const pipeline = new PipelineRunner(await buildPipelineConfig({
+        externalContext: body.brief,
       }));
       }));
-
-      const currentConfig = await loadCurrentProjectConfig();
-      const { ReviserAgent } = await import("@actalk/inkos-core");
-      const reviser = new ReviserAgent({
-        client: createLLMClient(currentConfig.llm),
-        model: currentConfig.llm.model,
-        projectRoot: root,
-        bookId: id,
-      });
-      const result = await reviser.reviseChapter(
-        bookDir, content, chapterNum, issues,
-        (body.mode ?? "spot-fix") as "spot-fix",
-        book.genre,
+      const result = await pipeline.reviseDraft(
+        id,
+        chapterNum,
+        (body.mode ?? "spot-fix") as "spot-fix" | "polish" | "rewrite" | "rework" | "anti-detect",
       );
       );
       broadcast("revise:complete", { bookId: id, chapter: chapterNum });
       broadcast("revise:complete", { bookId: id, chapter: chapterNum });
       return c.json(result);
       return c.json(result);
@@ -940,25 +920,46 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
   app.post("/api/books/:id/rewrite/:chapter", async (c) => {
   app.post("/api/books/:id/rewrite/:chapter", async (c) => {
     const id = c.req.param("id");
     const id = c.req.param("id");
     const chapterNum = parseInt(c.req.param("chapter"), 10);
     const chapterNum = parseInt(c.req.param("chapter"), 10);
+    const body: { brief?: string } = await c.req
+      .json<{ brief?: string }>()
+      .catch(() => ({}));
 
 
     broadcast("rewrite:start", { bookId: id, chapter: chapterNum });
     broadcast("rewrite:start", { bookId: id, chapter: chapterNum });
     try {
     try {
-      const restored = await state.restoreState(id, chapterNum);
-      if (!restored) {
-        return c.json({ error: `Cannot restore state to chapter ${chapterNum}` }, 400);
-      }
-      const pipeline = new PipelineRunner(await buildPipelineConfig());
+      const rollbackTarget = chapterNum - 1;
+      const discarded = await state.rollbackToChapter(id, rollbackTarget);
+      const pipeline = new PipelineRunner(await buildPipelineConfig({
+        externalContext: body.brief,
+      }));
       pipeline.writeNextChapter(id).then(
       pipeline.writeNextChapter(id).then(
         (result) => broadcast("rewrite:complete", { bookId: id, chapterNumber: result.chapterNumber, title: result.title, wordCount: result.wordCount }),
         (result) => broadcast("rewrite:complete", { bookId: id, chapterNumber: result.chapterNumber, title: result.title, wordCount: result.wordCount }),
         (e) => broadcast("rewrite:error", { bookId: id, error: e instanceof Error ? e.message : String(e) }),
         (e) => broadcast("rewrite:error", { bookId: id, error: e instanceof Error ? e.message : String(e) }),
       );
       );
-      return c.json({ status: "rewriting", bookId: id, chapter: chapterNum });
+      return c.json({ status: "rewriting", bookId: id, chapter: chapterNum, rolledBackTo: rollbackTarget, discarded });
     } catch (e) {
     } catch (e) {
       broadcast("rewrite:error", { bookId: id, error: String(e) });
       broadcast("rewrite:error", { bookId: id, error: String(e) });
       return c.json({ error: String(e) }, 500);
       return c.json({ error: String(e) }, 500);
     }
     }
   });
   });
 
 
+  app.post("/api/books/:id/resync/:chapter", async (c) => {
+    const id = c.req.param("id");
+    const chapterNum = parseInt(c.req.param("chapter"), 10);
+    const body: { brief?: string } = await c.req
+      .json<{ brief?: string }>()
+      .catch(() => ({}));
+
+    try {
+      const pipeline = new PipelineRunner(await buildPipelineConfig({
+        externalContext: body.brief,
+      }));
+      const result = await pipeline.resyncChapterArtifacts(id, chapterNum);
+      return c.json(result);
+    } catch (e) {
+      return c.json({ error: String(e) }, 500);
+    }
+  });
+
   // --- Detect All chapters ---
   // --- Detect All chapters ---
 
 
   app.post("/api/books/:id/detect-all", async (c) => {
   app.post("/api/books/:id/detect-all", async (c) => {

+ 56 - 2
packages/studio/src/pages/BookDetail.tsx

@@ -21,6 +21,7 @@ import {
   X,
   X,
   ShieldCheck,
   ShieldCheck,
   RotateCcw,
   RotateCcw,
+  RefreshCw,
   Sparkles,
   Sparkles,
   Trash2,
   Trash2,
   Save
   Save
@@ -99,6 +100,7 @@ export function BookDetail({
   const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
   const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
   const [rewritingChapters, setRewritingChapters] = useState<ReadonlyArray<number>>([]);
   const [rewritingChapters, setRewritingChapters] = useState<ReadonlyArray<number>>([]);
   const [revisingChapters, setRevisingChapters] = useState<ReadonlyArray<number>>([]);
   const [revisingChapters, setRevisingChapters] = useState<ReadonlyArray<number>>([]);
+  const [syncingChapters, setSyncingChapters] = useState<ReadonlyArray<number>>([]);
   const [savingSettings, setSavingSettings] = useState(false);
   const [savingSettings, setSavingSettings] = useState(false);
   const [settingsWordCount, setSettingsWordCount] = useState<number | null>(null);
   const [settingsWordCount, setSettingsWordCount] = useState<number | null>(null);
   const [settingsTargetChapters, setSettingsTargetChapters] = useState<number | null>(null);
   const [settingsTargetChapters, setSettingsTargetChapters] = useState<number | null>(null);
@@ -108,6 +110,7 @@ export function BookDetail({
   const activity = useMemo(() => deriveBookActivity(sse.messages, bookId), [bookId, sse.messages]);
   const activity = useMemo(() => deriveBookActivity(sse.messages, bookId), [bookId, sse.messages]);
   const writing = writeRequestPending || activity.writing;
   const writing = writeRequestPending || activity.writing;
   const drafting = draftRequestPending || activity.drafting;
   const drafting = draftRequestPending || activity.drafting;
+  const latestPersistedChapter = data ? data.nextChapter - 1 : 0;
 
 
   useEffect(() => {
   useEffect(() => {
     const recent = sse.messages.at(-1);
     const recent = sse.messages.at(-1);
@@ -171,9 +174,20 @@ export function BookDetail({
   };
   };
 
 
   const handleRewrite = async (chapterNum: number) => {
   const handleRewrite = async (chapterNum: number) => {
+    const brief = window.prompt(
+      data?.book.language === "en"
+        ? "Optional rewrite brief for this run only. Leave blank to use existing focus."
+        : "可选:输入这次重写要遵循的补充想法。留空则沿用现有 focus。",
+      "",
+    );
+    if (brief === null) return;
     setRewritingChapters((prev) => [...prev, chapterNum]);
     setRewritingChapters((prev) => [...prev, chapterNum]);
     try {
     try {
-      await postApi(`/books/${bookId}/rewrite/${chapterNum}`);
+      await fetchJson(`/books/${bookId}/rewrite/${chapterNum}`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ brief: brief.trim() || undefined }),
+      });
       refetch();
       refetch();
     } catch (e) {
     } catch (e) {
       alert(e instanceof Error ? e.message : "Rewrite failed");
       alert(e instanceof Error ? e.message : "Rewrite failed");
@@ -183,12 +197,19 @@ export function BookDetail({
   };
   };
 
 
   const handleRevise = async (chapterNum: number, mode: ReviseMode) => {
   const handleRevise = async (chapterNum: number, mode: ReviseMode) => {
+    const brief = window.prompt(
+      data?.book.language === "en"
+        ? "Optional revise brief for this run only. Leave blank to use existing focus."
+        : "可选:输入这次修订要遵循的补充想法。留空则沿用现有 focus。",
+      "",
+    );
+    if (brief === null) return;
     setRevisingChapters((prev) => [...prev, chapterNum]);
     setRevisingChapters((prev) => [...prev, chapterNum]);
     try {
     try {
       await fetchJson(`/books/${bookId}/revise/${chapterNum}`, {
       await fetchJson(`/books/${bookId}/revise/${chapterNum}`, {
         method: "POST",
         method: "POST",
         headers: { "Content-Type": "application/json" },
         headers: { "Content-Type": "application/json" },
-        body: JSON.stringify({ mode }),
+        body: JSON.stringify({ mode, brief: brief.trim() || undefined }),
       });
       });
       refetch();
       refetch();
     } catch (e) {
     } catch (e) {
@@ -198,6 +219,29 @@ export function BookDetail({
     }
     }
   };
   };
 
 
+  const handleSync = async (chapterNum: number) => {
+    const brief = window.prompt(
+      data?.book.language === "en"
+        ? "Optional sync brief for interpreting the edited chapter body. Leave blank to sync directly from the text."
+        : "可选:输入这次同步时要遵循的补充说明。留空则直接按正文同步。",
+      "",
+    );
+    if (brief === null) return;
+    setSyncingChapters((prev) => [...prev, chapterNum]);
+    try {
+      await fetchJson(`/books/${bookId}/resync/${chapterNum}`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ brief: brief.trim() || undefined }),
+      });
+      refetch();
+    } catch (e) {
+      alert(e instanceof Error ? e.message : "Sync failed");
+    } finally {
+      setSyncingChapters((prev) => prev.filter((n) => n !== chapterNum));
+    }
+  };
+
   const handleSaveSettings = async () => {
   const handleSaveSettings = async () => {
     if (!data) return;
     if (!data) return;
     setSavingSettings(true);
     setSavingSettings(true);
@@ -526,6 +570,16 @@ export function BookDetail({
                           ? <div className="w-3.5 h-3.5 border-2 border-muted-foreground/20 border-t-muted-foreground rounded-full animate-spin" />
                           ? <div className="w-3.5 h-3.5 border-2 border-muted-foreground/20 border-t-muted-foreground rounded-full animate-spin" />
                           : <RotateCcw size={14} />}
                           : <RotateCcw size={14} />}
                       </button>
                       </button>
+                      <button
+                        onClick={() => handleSync(ch.number)}
+                        disabled={syncingChapters.includes(ch.number) || ch.number !== latestPersistedChapter}
+                        className="p-2 rounded-lg bg-secondary text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all shadow-sm disabled:opacity-50"
+                        title={data?.book.language === "en" ? "Sync truth/state from edited chapter" : "根据已编辑章节同步 truth/state"}
+                      >
+                        {syncingChapters.includes(ch.number)
+                          ? <div className="w-3.5 h-3.5 border-2 border-muted-foreground/20 border-t-muted-foreground rounded-full animate-spin" />
+                          : <RefreshCw size={14} />}
+                      </button>
                       <select
                       <select
                         disabled={revisingChapters.includes(ch.number)}
                         disabled={revisingChapters.includes(ch.number)}
                         value=""
                         value=""