Browse Source

feat: harden v0.6.3 safety guards and migration hints

Ma 7 months ago
parent
commit
eba4a54ae4

+ 171 - 0
docs/plans/2026-03-28-v0.6.3-hardening-plan.md

@@ -0,0 +1,171 @@
+# v0.6.3 Hardening Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Close the remaining 0.6.3 safety gaps around agent tool misuse, final-content paragraph checks, duplicate chapter titles, migration hints, and rewrite regression coverage.
+
+**Architecture:** Keep the current v2 pipeline, but tighten the remaining soft spots at the edges. Add deterministic guards in the agent dispatch layer, run final-content validation at the last safe point before persist, surface migration status in user-facing CLI flows, and add targeted regressions for the exact user-visible failures.
+
+**Tech Stack:** TypeScript, Vitest, Commander CLI
+
+---
+
+### Task 1: Guard the remaining agent path
+
+**Files:**
+- Modify: `packages/core/src/__tests__/pipeline-agent.test.ts`
+- Modify: `packages/core/src/pipeline/agent.ts`
+
+**Step 1: Write the failing tests**
+
+Add tests that prove:
+- `write_full_pipeline` is rejected when the chapter sequence is inconsistent
+- `write_truth_file` cannot use `current_state.md` to mutate progress
+
+**Step 2: Run test to verify it fails**
+
+Run:
+
+```bash
+pnpm vitest run src/__tests__/pipeline-agent.test.ts
+```
+
+**Step 3: Write minimal implementation**
+
+Reuse the same guard logic currently applied to `write_draft` and block `write_full_pipeline` before it calls `writeNextChapter()`.
+
+**Step 4: Run test to verify it passes**
+
+Run:
+
+```bash
+pnpm vitest run src/__tests__/pipeline-agent.test.ts
+```
+
+**Step 5: Commit**
+
+```bash
+git add packages/core/src/pipeline/agent.ts packages/core/src/__tests__/pipeline-agent.test.ts
+git commit -m "fix: guard write_full_pipeline in agent dispatch"
+```
+
+### Task 2: Validate final persisted chapter shape
+
+**Files:**
+- Modify: `packages/core/src/__tests__/post-write-validator.test.ts`
+- Modify: `packages/core/src/__tests__/pipeline-runner.test.ts`
+- Modify: `packages/core/src/agents/post-write-validator.ts`
+- Modify: `packages/core/src/pipeline/runner.ts`
+
+**Step 1: Write the failing tests**
+
+Add tests that prove:
+- final persisted content can trigger `段落过碎` / `连续短段`
+- duplicate titles are handled as a real final-stage action, not a passive warning
+
+**Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+pnpm vitest run src/__tests__/post-write-validator.test.ts src/__tests__/pipeline-runner.test.ts
+```
+
+**Step 3: Write minimal implementation**
+
+Export a reusable final paragraph-shape detector and run it on `finalContent` before persist. Upgrade duplicate-title handling from a passive warning to an explicit final-stage resolution path.
+
+**Step 4: Run tests to verify they pass**
+
+Run:
+
+```bash
+pnpm vitest run src/__tests__/post-write-validator.test.ts src/__tests__/pipeline-runner.test.ts
+```
+
+**Step 5: Commit**
+
+```bash
+git add packages/core/src/agents/post-write-validator.ts packages/core/src/pipeline/runner.ts packages/core/src/__tests__/post-write-validator.test.ts packages/core/src/__tests__/pipeline-runner.test.ts
+git commit -m "fix: harden final content validation and title handling"
+```
+
+### Task 3: Surface migration status in user-facing CLI flows
+
+**Files:**
+- Modify: `packages/cli/src/__tests__/cli-integration.test.ts`
+- Modify: `packages/cli/src/commands/status.ts`
+- Modify: `packages/cli/src/commands/write.ts`
+- Modify: `packages/cli/src/utils.ts`
+
+**Step 1: Write the failing tests**
+
+Add tests that prove:
+- `inkos status` shows a migration hint for legacy books
+- `inkos write next` warns before auto-migrating a legacy book
+
+**Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+pnpm vitest run src/__tests__/cli-integration.test.ts
+```
+
+**Step 3: Write minimal implementation**
+
+Add a shared CLI helper that detects legacy pre-v0.6 books and emits a clear hint in `status` and `write next`.
+
+**Step 4: Run tests to verify they pass**
+
+Run:
+
+```bash
+pnpm vitest run src/__tests__/cli-integration.test.ts
+```
+
+**Step 5: Commit**
+
+```bash
+git add packages/cli/src/utils.ts packages/cli/src/commands/status.ts packages/cli/src/commands/write.ts packages/cli/src/__tests__/cli-integration.test.ts
+git commit -m "feat: show migration hints in status and write flows"
+```
+
+### Task 4: Verify rewrite from user path
+
+**Files:**
+- Modify: `packages/cli/src/__tests__/cli-integration.test.ts`
+
+**Step 1: Write the failing test**
+
+Add a CLI-level regression covering:
+- create 3 chapters
+- rewrite chapter 2
+- assert next chapter number is 2 again
+
+**Step 2: Run test to verify it fails**
+
+Run:
+
+```bash
+pnpm vitest run src/__tests__/cli-integration.test.ts
+```
+
+**Step 3: Write minimal implementation**
+
+Prefer no production change unless the new CLI regression exposes a real gap.
+
+**Step 4: Run tests to verify it passes**
+
+Run:
+
+```bash
+pnpm vitest run src/__tests__/cli-integration.test.ts
+```
+
+**Step 5: Commit**
+
+```bash
+git add packages/cli/src/__tests__/cli-integration.test.ts
+git commit -m "test: cover rewrite flow from CLI path"
+```

+ 163 - 0
packages/cli/src/__tests__/cli-integration.test.ts

@@ -4,6 +4,7 @@ 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";
+import { StateManager } from "@actalk/inkos-core";
 
 const testDir = dirname(fileURLToPath(import.meta.url));
 const cliDir = resolve(testDir, "..", "..");
@@ -40,6 +41,13 @@ function runStderr(args: string[], options?: { env?: Record<string, string> }):
   }
 }
 
+const failingLlmEnv = {
+  INKOS_LLM_PROVIDER: "openai",
+  INKOS_LLM_BASE_URL: "http://127.0.0.1:9/v1",
+  INKOS_LLM_MODEL: "test-model",
+  INKOS_LLM_API_KEY: "test-key",
+};
+
 describe("CLI integration", () => {
   beforeAll(async () => {
     projectDir = await mkdtemp(join(tmpdir(), "inkos-cli-test-"));
@@ -278,6 +286,34 @@ describe("CLI integration", () => {
       expect(output).toContain('Ch.1 "A Quiet Sky" | 7 words | ready-for-review');
       expect(output).not.toContain("7字");
     });
+
+    it("shows a migration hint for legacy pre-v0.6 books", async () => {
+      const bookDir = join(projectDir, "books", "legacy-status-hint");
+      const storyDir = join(bookDir, "story");
+      await mkdir(join(bookDir, "chapters"), { recursive: true });
+      await mkdir(storyDir, { recursive: true });
+      await writeFile(
+        join(bookDir, "book.json"),
+        JSON.stringify({
+          id: "legacy-status-hint",
+          title: "Legacy Status Hint",
+          platform: "other",
+          genre: "other",
+          status: "active",
+          targetChapters: 10,
+          chapterWordCount: 2200,
+          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");
+      await writeFile(join(storyDir, "current_state.md"), "# Current State\n\nLegacy state.\n", "utf-8");
+      await writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n", "utf-8");
+
+      const output = run(["status", "legacy-status-hint"]);
+      expect(output).toContain("legacy format");
+    });
   });
 
   describe("inkos doctor", () => {
@@ -345,6 +381,133 @@ describe("CLI integration", () => {
         await writeFile(envPath, originalEnv, "utf-8");
       }
     });
+
+    it("reports legacy books in the version migration check", async () => {
+      const bookDir = join(projectDir, "books", "legacy-doctor-hint");
+      const storyDir = join(bookDir, "story");
+      await mkdir(join(bookDir, "chapters"), { recursive: true });
+      await mkdir(storyDir, { recursive: true });
+      await writeFile(
+        join(bookDir, "book.json"),
+        JSON.stringify({
+          id: "legacy-doctor-hint",
+          title: "Legacy Doctor Hint",
+          platform: "other",
+          genre: "other",
+          status: "active",
+          targetChapters: 10,
+          chapterWordCount: 2200,
+          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");
+      await writeFile(join(storyDir, "current_state.md"), "# Current State\n\nLegacy state.\n", "utf-8");
+      await writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n", "utf-8");
+
+      const { stdout } = runStderr(["doctor"]);
+      expect(stdout).toContain("Version Migration");
+      expect(stdout).toContain("legacy format");
+    });
+  });
+
+  describe("inkos write", () => {
+    it("warns before writing when the target book still uses legacy format", async () => {
+      const bookDir = join(projectDir, "books", "legacy-write-hint");
+      const storyDir = join(bookDir, "story");
+      await mkdir(join(bookDir, "chapters"), { recursive: true });
+      await mkdir(storyDir, { recursive: true });
+      await writeFile(
+        join(bookDir, "book.json"),
+        JSON.stringify({
+          id: "legacy-write-hint",
+          title: "Legacy Write Hint",
+          platform: "other",
+          genre: "other",
+          status: "active",
+          targetChapters: 10,
+          chapterWordCount: 2200,
+          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");
+      await writeFile(join(storyDir, "current_state.md"), "# Current State\n\nLegacy state.\n", "utf-8");
+      await writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n", "utf-8");
+
+      const { stdout, stderr } = runStderr(["write", "next", "legacy-write-hint"], {
+        env: failingLlmEnv,
+      });
+      expect(`${stdout}\n${stderr}`).toContain("legacy format");
+    });
+
+    it("keeps next chapter at 2 after rewrite 2 trims later chapters, even if regeneration fails", async () => {
+      const state = new StateManager(projectDir);
+      const bookId = "rewrite-cli";
+      const bookDir = join(projectDir, "books", bookId);
+      const storyDir = join(bookDir, "story");
+      const chaptersDir = join(bookDir, "chapters");
+      const stateDir = join(storyDir, "state");
+
+      await mkdir(chaptersDir, { recursive: true });
+      await mkdir(stateDir, { recursive: true });
+      await writeFile(
+        join(bookDir, "book.json"),
+        JSON.stringify({
+          id: bookId,
+          title: "Rewrite CLI",
+          platform: "other",
+          genre: "other",
+          status: "active",
+          targetChapters: 10,
+          chapterWordCount: 2200,
+          createdAt: "2026-03-22T00:00:00.000Z",
+          updatedAt: "2026-03-22T00:00:00.000Z",
+        }, null, 2),
+        "utf-8",
+      );
+      await writeFile(join(storyDir, "current_state.md"), "State at ch1", "utf-8");
+      await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch1", "utf-8");
+      await writeFile(join(chaptersDir, "0001_ch1.md"), "# Chapter 1\n\nContent 1", "utf-8");
+      await writeFile(join(chaptersDir, "0002_ch2.md"), "# Chapter 2\n\nContent 2", "utf-8");
+      await writeFile(join(chaptersDir, "0003_ch3.md"), "# Chapter 3\n\nContent 3", "utf-8");
+      await writeFile(
+        join(chaptersDir, "index.json"),
+        JSON.stringify([
+          { number: 1, title: "Ch1", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] },
+          { number: 2, title: "Ch2", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] },
+          { number: 3, title: "Ch3", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] },
+        ], null, 2),
+        "utf-8",
+      );
+
+      await state.snapshotState(bookId, 1);
+
+      await writeFile(join(storyDir, "current_state.md"), "State at ch3", "utf-8");
+      await writeFile(join(stateDir, "manifest.json"), JSON.stringify({
+        schemaVersion: 2,
+        language: "en",
+        lastAppliedChapter: 4,
+        projectionVersion: 1,
+        migrationWarnings: [],
+      }, null, 2), "utf-8");
+      await writeFile(join(stateDir, "current_state.json"), JSON.stringify({
+        chapter: 3,
+        facts: [],
+      }, null, 2), "utf-8");
+
+      const { exitCode, stdout, stderr } = runStderr(["write", "rewrite", bookId, "2", "--force"], {
+        env: failingLlmEnv,
+      });
+      expect(exitCode).not.toBe(0);
+      expect(`${stdout}\n${stderr}`).toContain("Regenerating chapter 2");
+
+      const next = await state.getNextChapterNumber(bookId);
+      expect(next).toBe(2);
+      await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe("State at ch1");
+    });
   });
 
   describe("inkos analytics", () => {

+ 6 - 1
packages/cli/src/commands/status.ts

@@ -1,6 +1,6 @@
 import { Command } from "commander";
 import { StateManager, formatLengthCount, readGenreProfile, resolveLengthCountingMode } from "@actalk/inkos-core";
-import { findProjectRoot, log, logError } from "../utils.js";
+import { findProjectRoot, getLegacyMigrationHint, log, logError } from "../utils.js";
 
 export const statusCommand = new Command("status")
   .description("Show project status")
@@ -32,6 +32,7 @@ export const statusCommand = new Command("status")
       for (const id of bookIds) {
         const book = await state.loadBookConfig(id);
         const index = await state.loadChapterIndex(id);
+        const migrationHint = await getLegacyMigrationHint(root, id);
         const nextChapter = await state.getNextChapterNumber(id);
         const { profile: genreProfile } = await readGenreProfile(root, book.genre);
         const countingMode = resolveLengthCountingMode(book.language ?? genreProfile.language);
@@ -59,6 +60,7 @@ export const statusCommand = new Command("status")
           approved,
           pending,
           failed,
+          ...(migrationHint ? { migrationHint } : {}),
           ...(opts.chapters ? {
             chapterList: index.map((ch) => ({
               number: ch.number,
@@ -77,6 +79,9 @@ export const statusCommand = new Command("status")
           log(`    Chapters: ${nextChapter - 1} / ${book.targetChapters}`);
           log(`    Words: ${totalWords.toLocaleString()} (avg ${avgWords}/ch)`);
           log(`    Approved: ${approved} | Pending: ${pending} | Failed: ${failed}`);
+          if (migrationHint) {
+            log(`    Migration: ${migrationHint}`);
+          }
 
           if (opts.chapters && index.length > 0) {
             log("");

+ 11 - 3
packages/cli/src/commands/write.ts

@@ -3,7 +3,7 @@ import { PipelineRunner, StateManager } from "@actalk/inkos-core";
 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 { loadConfig, buildPipelineConfig, findProjectRoot, getLegacyMigrationHint, resolveContext, resolveBookId, log, logError } from "../utils.js";
 import { formatWriteNextComplete, formatWriteNextProgress, formatWriteNextResultLines, resolveCliLanguage } from "../localization.js";
 
 export const writeCommand = new Command("write")
@@ -21,13 +21,17 @@ writeCommand
   .option("-q, --quiet", "Suppress console output")
   .action(async (bookIdArg: string | undefined, opts) => {
     try {
-      const config = await loadConfig();
       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 migrationHint = await getLegacyMigrationHint(root, bookId);
+      if (migrationHint && !opts.json) {
+        log(`[migration] ${migrationHint}`);
+      }
+      const config = await loadConfig();
 
       const pipeline = new PipelineRunner(buildPipelineConfig(config, root, { externalContext: context, quiet: opts.quiet }));
 
@@ -81,7 +85,6 @@ writeCommand
   .option("--json", "Output JSON")
   .action(async (args: ReadonlyArray<string>, opts) => {
     try {
-      const config = await loadConfig();
       const root = findProjectRoot();
 
       let bookId: string;
@@ -113,6 +116,10 @@ writeCommand
       const state = new StateManager(root);
       const bookDir = state.bookDir(bookId);
       const chaptersDir = join(bookDir, "chapters");
+      const migrationHint = await getLegacyMigrationHint(root, bookId);
+      if (migrationHint && !opts.json) {
+        log(`[migration] ${migrationHint}`);
+      }
 
       // Remove existing chapter file
       const files = await readdir(chaptersDir);
@@ -151,6 +158,7 @@ writeCommand
 
       const wordCount = opts.words ? parseInt(opts.words, 10) : undefined;
 
+      const config = await loadConfig();
       const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
 
       const result = await pipeline.writeNextChapter(bookId, wordCount);

+ 19 - 2
packages/cli/src/utils.ts

@@ -1,5 +1,5 @@
-import { readFile } from "node:fs/promises";
-import { resolve } from "node:path";
+import { readFile, stat } from "node:fs/promises";
+import { join, 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";
 
@@ -132,3 +132,20 @@ export async function resolveBookId(
     `Multiple books found: ${books.join(", ")}\nPlease specify a book-id.`,
   );
 }
+
+export async function getLegacyMigrationHint(
+  root: string,
+  bookId: string,
+): Promise<string | null> {
+  const state = new StateManager(root);
+  const stateDir = join(state.bookDir(bookId), "story", "state");
+  try {
+    const info = await stat(stateDir);
+    if (info.isDirectory()) {
+      return null;
+    }
+  } catch {
+    return `Book "${bookId}" uses legacy format (pre-v0.6). The next write will auto-migrate its state files.`;
+  }
+  return `Book "${bookId}" uses legacy format (pre-v0.6). The next write will auto-migrate its state files.`;
+}

+ 65 - 1
packages/core/src/__tests__/pipeline-agent.test.ts

@@ -1,4 +1,4 @@
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
@@ -116,4 +116,68 @@ describe("agent pipeline tools", () => {
     await expect(readFile(join(state.bookDir(bookId), "story", "current_focus.md"), "utf-8"))
       .resolves.toContain("mentor fallout");
   });
+
+  it("blocks write_full_pipeline when runtime progress is ahead of the chapter index", async () => {
+    const stateDir = join(state.bookDir(bookId), "story", "state");
+    await mkdir(stateDir, { recursive: true });
+    await state.saveChapterIndex(bookId, [{
+      number: 1,
+      title: "Existing Chapter",
+      status: "approved",
+      wordCount: 120,
+      createdAt: "2026-03-22T00:00:00.000Z",
+      updatedAt: "2026-03-22T00:00:00.000Z",
+      auditIssues: [],
+      lengthWarnings: [],
+    }]);
+    await writeFile(join(stateDir, "manifest.json"), JSON.stringify({
+      schemaVersion: 2,
+      language: "zh",
+      lastAppliedChapter: 3,
+      projectionVersion: 1,
+      migrationWarnings: [],
+    }, null, 2), "utf-8");
+    await writeFile(join(stateDir, "current_state.json"), JSON.stringify({
+      chapter: 3,
+      facts: [],
+    }, null, 2), "utf-8");
+
+    const writeNextChapter = vi.spyOn(pipeline, "writeNextChapter").mockResolvedValue({
+      bookId,
+      chapterNumber: 4,
+      title: "Should Not Run",
+      wordCount: 100,
+      filePath: "books/agent-book/chapters/0004_Should_Not_Run.md",
+      auditResult: { passed: true, issues: [], summary: "ok" },
+      revised: false,
+      status: "ready-for-review",
+    } as Awaited<ReturnType<typeof pipeline.writeNextChapter>>);
+
+    const result = JSON.parse(await executeAgentTool(
+      pipeline,
+      state,
+      config,
+      "write_full_pipeline",
+      { bookId, count: 1 },
+    ));
+
+    expect(result.error).toContain("write_full_pipeline");
+    expect(writeNextChapter).not.toHaveBeenCalled();
+  });
+
+  it("blocks write_truth_file from hacking chapter progress inside current_state.md", async () => {
+    const result = JSON.parse(await executeAgentTool(
+      pipeline,
+      state,
+      config,
+      "write_truth_file",
+      {
+        bookId,
+        fileName: "current_state.md",
+        content: "# Current State\n\n| Current Chapter | 999 |\n",
+      },
+    ));
+
+    expect(result.error).toContain("章节进度");
+  });
 });

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

@@ -3012,6 +3012,151 @@ describe("PipelineRunner", () => {
     }
   });
 
+  it("adds final paragraph fragmentation warnings from revised content before persist", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const storyDir = join(state.bookDir(bookId), "story");
+    const draftBody = "林越先把门推开一条缝,再侧耳去听墙后的动静。屋里的灯没有亮,但桌角还有没散的热气,说明人刚离开不久。";
+    const revisedBody = [
+      "门开了。",
+      "他没进去。",
+      "先听了一下。",
+      "里面没有声响。",
+      "他这才抬脚。",
+      "屋里很冷。",
+    ].join("\n\n");
+
+    await Promise.all([
+      writeFile(join(storyDir, "current_state.md"), createStateCard({
+        chapter: 0,
+        location: "Ashen ferry crossing",
+        protagonistState: "Lin Yue still hides the oath token.",
+        goal: "Find the vanished mentor.",
+        conflict: "The debt trail keeps narrowing.",
+      }), "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+    ]);
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 1,
+        title: "雾线",
+        content: draftBody,
+        wordCount: draftBody.length,
+      }),
+    );
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: false,
+          issues: [CRITICAL_ISSUE],
+          summary: "needs revision",
+        }),
+      )
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: true,
+          issues: [],
+          summary: "clean",
+        }),
+      );
+    vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
+      createReviseOutput({
+        revisedContent: revisedBody,
+        wordCount: revisedBody.length,
+        updatedState: createStateCard({
+          chapter: 1,
+          location: "Ashen ferry crossing",
+          protagonistState: "Lin Yue still hides the oath token.",
+          goal: "Find the vanished mentor.",
+          conflict: "He steps into the empty room.",
+        }),
+        updatedHooks: "# Pending Hooks\n",
+      }),
+    );
+    vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
+      createAnalyzedOutput({
+        title: "雾线",
+        content: revisedBody,
+        wordCount: revisedBody.length,
+        chapterSummary: "| 1 | 雾线 | 林越 | 进入空屋 | 状态推进 | 无 | 紧绷 | 过渡 |",
+      }),
+    );
+
+    try {
+      const result = await runner.writeNextChapter(bookId, 120);
+
+      expect(result.auditResult.issues).toEqual(
+        expect.arrayContaining([
+          expect.objectContaining({
+            category: "paragraph-shape",
+            description: expect.stringContaining("段落被切得过碎"),
+          }),
+          expect.objectContaining({
+            category: "paragraph-shape",
+            description: expect.stringContaining("连续出现"),
+          }),
+        ]),
+      );
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("resolves duplicate chapter titles before persist", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const storyDir = join(state.bookDir(bookId), "story");
+    const chaptersDir = join(state.bookDir(bookId), "chapters");
+    const now = "2026-03-19T00:00:00.000Z";
+
+    await Promise.all([
+      writeFile(join(chaptersDir, "0001_回声.md"), "# 第1章 回声\n\n旧章节。", "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), createStateCard({
+        chapter: 1,
+        location: "Ashen ferry crossing",
+        protagonistState: "Lin Yue still hides the oath token.",
+        goal: "Find the vanished mentor.",
+        conflict: "The debt trail keeps narrowing.",
+      }), "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+    ]);
+    await state.saveChapterIndex(bookId, [{
+      number: 1,
+      title: "回声",
+      status: "ready-for-review",
+      wordCount: 12,
+      createdAt: now,
+      updatedAt: now,
+      auditIssues: [],
+      lengthWarnings: [],
+    }]);
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 2,
+        title: "回声",
+        content: "这次的正文完全不同,只是标题碰巧重复了。",
+        wordCount: "这次的正文完全不同,只是标题碰巧重复了。".length,
+      }),
+    );
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
+      createAuditResult({
+        passed: true,
+        issues: [],
+        summary: "clean",
+      }),
+    );
+
+    try {
+      const result = await runner.writeNextChapter(bookId, 120);
+      const index = await state.loadChapterIndex(bookId);
+
+      expect(result.title).toBe("回声(2)");
+      expect(index.at(-1)?.title).toBe("回声(2)");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("defaults manual reviseDraft to spot-fix when mode is omitted", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const storyDir = join(state.bookDir(bookId), "story");

+ 27 - 0
packages/core/src/__tests__/post-write-validator.test.ts

@@ -1,6 +1,8 @@
 import { describe, it, expect } from "vitest";
 import {
+  detectDuplicateTitle,
   detectParagraphLengthDrift,
+  detectParagraphShapeWarnings,
   validatePostWrite,
   type PostWriteViolation,
 } from "../agents/post-write-validator.js";
@@ -217,4 +219,29 @@ describe("validatePostWrite", () => {
     expect(findRule(result, "段落密度漂移")).toBeDefined();
     expect(findRule(result, "段落密度漂移")?.severity).toBe("warning");
   });
+
+  it("exposes paragraph shape warnings for final-stage reuse", () => {
+    const current = [
+      "他停下。",
+      "先看门。",
+      "又看窗。",
+      "没人说话。",
+      "他这才进去。",
+      "屋里很冷。",
+    ].join("\n\n");
+
+    const result = detectParagraphShapeWarnings(current, "zh");
+    expect(findRule(result, "段落过碎")).toBeDefined();
+    expect(findRule(result, "连续短段")).toBeDefined();
+  });
+
+  it("detects duplicate chapter titles", () => {
+    const result = detectDuplicateTitle("回声", ["旧路", "回声"]);
+    expect(findRule(result, "duplicate-title")).toBeDefined();
+  });
+
+  it("detects near-duplicate chapter titles", () => {
+    const result = detectDuplicateTitle("Echo-2", ["Echo 2"]);
+    expect(findRule(result, "near-duplicate-title")).toBeDefined();
+  });
 });

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

@@ -454,6 +454,52 @@ describe("StateManager", () => {
       const next = await manager.getNextChapterNumber(rwBookId);
       expect(next).toBe(2);
     });
+
+    it("rewrite restore drops poisoned live structured state when the snapshot only has markdown truth files", async () => {
+      const rwBookId = "rewrite-book-markdown-only";
+      const chapDir = join(manager.bookDir(rwBookId), "chapters");
+      const storyDir = join(manager.bookDir(rwBookId), "story");
+      const stateDir = join(storyDir, "state");
+      await mkdir(chapDir, { recursive: true });
+      await mkdir(storyDir, { recursive: true });
+
+      await writeFile(join(chapDir, "0001_ch1.md"), "# Chapter 1\nContent 1", "utf-8");
+      await writeFile(join(chapDir, "0002_ch2.md"), "# Chapter 2\nContent 2", "utf-8");
+      await writeFile(join(chapDir, "0003_ch3.md"), "# Chapter 3\nContent 3", "utf-8");
+      const mkEntry = (n: number) => ({
+        number: n, title: `Ch${n}`, status: "approved" as const, wordCount: 100,
+        createdAt: "", updatedAt: "", auditIssues: [] as string[], lengthWarnings: [] as string[],
+      });
+      const fullIndex = [mkEntry(1), mkEntry(2), mkEntry(3)];
+      await manager.saveChapterIndex(rwBookId, fullIndex);
+
+      await writeFile(join(storyDir, "current_state.md"), "State at ch1", "utf-8");
+      await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch1", "utf-8");
+      await manager.snapshotState(rwBookId, 1);
+
+      await mkdir(stateDir, { recursive: true });
+      await writeFile(join(stateDir, "manifest.json"), JSON.stringify({
+        schemaVersion: 2,
+        language: "en",
+        lastAppliedChapter: 4,
+        projectionVersion: 1,
+        migrationWarnings: [],
+      }, null, 2), "utf-8");
+      await writeFile(join(stateDir, "current_state.json"), JSON.stringify({
+        chapter: 3,
+        facts: [],
+      }, null, 2), "utf-8");
+
+      const trimmed = fullIndex.filter((ch) => ch.number < 2);
+      await manager.saveChapterIndex(rwBookId, trimmed);
+      const { rm } = await import("node:fs/promises");
+      await rm(join(chapDir, "0002_ch2.md"));
+      await rm(join(chapDir, "0003_ch3.md"));
+      await manager.restoreState(rwBookId, 1);
+
+      const next = await manager.getNextChapterNumber(rwBookId);
+      expect(next).toBe(2);
+    });
   });
 
   // -------------------------------------------------------------------------

+ 43 - 2
packages/core/src/agents/post-write-validator.ts

@@ -235,7 +235,7 @@ export function validatePostWrite(
     });
   }
 
-  appendParagraphShapeWarnings(violations, content, "zh");
+  violations.push(...detectParagraphShapeWarnings(content, "zh"));
 
   // 11. Book-level prohibitions
   // Short prohibitions (2-30 chars): exact substring match
@@ -398,7 +398,7 @@ function validatePostWriteEnglish(
     });
   }
 
-  appendParagraphShapeWarnings(violations, content, "en");
+  violations.push(...detectParagraphShapeWarnings(content, "en"));
 
   // 2.5. Multi-character scene with almost no direct exchange
   const quotedLines = content.match(/"[^"]+"/g) ?? [];
@@ -494,6 +494,15 @@ function appendParagraphShapeWarnings(
   }
 }
 
+export function detectParagraphShapeWarnings(
+  content: string,
+  language: "zh" | "en" = "zh",
+): ReadonlyArray<PostWriteViolation> {
+  const violations: PostWriteViolation[] = [];
+  appendParagraphShapeWarnings(violations, content, language);
+  return violations;
+}
+
 function analyzeParagraphShape(content: string, language: "zh" | "en"): ParagraphShape {
   const paragraphs = extractParagraphs(content);
   const shortThreshold = language === "en" ? 120 : 35;
@@ -588,3 +597,35 @@ export function detectDuplicateTitle(
 
   return violations;
 }
+
+export function resolveDuplicateTitle(
+  newTitle: string,
+  existingTitles: ReadonlyArray<string>,
+  language: "zh" | "en" = "zh",
+): {
+  readonly title: string;
+  readonly issues: ReadonlyArray<PostWriteViolation>;
+} {
+  const trimmed = newTitle.trim();
+  if (!trimmed) {
+    return { title: newTitle, issues: [] };
+  }
+
+  const issues = detectDuplicateTitle(trimmed, existingTitles);
+  if (issues.length === 0) {
+    return { title: trimmed, issues: [] };
+  }
+
+  let counter = 2;
+  while (counter < 100) {
+    const candidate = language === "en"
+      ? `${trimmed} (${counter})`
+      : `${trimmed}(${counter})`;
+    if (detectDuplicateTitle(candidate, existingTitles).length === 0) {
+      return { title: candidate, issues };
+    }
+    counter++;
+  }
+
+  return { title: trimmed, issues };
+}

+ 37 - 11
packages/core/src/pipeline/agent.ts

@@ -370,12 +370,10 @@ export async function executeAgentTool(
     }
 
     case "write_draft": {
-      // Guard: write_draft only writes the next sequential chapter
       const bookId = args.bookId as string;
-      const nextNum = await state.getNextChapterNumber(bookId);
-      const index = await state.loadChapterIndex(bookId);
-      if (index.length > 0 && index[index.length - 1]!.number !== nextNum - 1) {
-        return JSON.stringify({ error: `write_draft 只能续写下一章(第${nextNum}章)。如果中间有空缺,请先用 get_book_status 确认状态。` });
+      const writeGuardError = await getSequentialWriteGuardError(state, bookId, "write_draft");
+      if (writeGuardError) {
+        return JSON.stringify({ error: writeGuardError });
       }
       const result = await pipeline.writeDraft(
         bookId,
@@ -494,10 +492,15 @@ export async function executeAgentTool(
     }
 
     case "write_full_pipeline": {
+      const bookId = args.bookId as string;
+      const writeGuardError = await getSequentialWriteGuardError(state, bookId, "write_full_pipeline");
+      if (writeGuardError) {
+        return JSON.stringify({ error: writeGuardError });
+      }
       const count = (args.count as number) ?? 1;
       const results = [];
       for (let i = 0; i < count; i++) {
-        const result = await pipeline.writeNextChapter(args.bookId as string);
+        const result = await pipeline.writeNextChapter(bookId);
         results.push(result);
       }
       return JSON.stringify(results);
@@ -573,11 +576,8 @@ export async function executeAgentTool(
       }
 
       // Guard: block chapter progress manipulation via current_state.md
-      if (fileName === "current_state.md") {
-        const progressPatterns = /(?:lastAppliedChapter|chapter.*:\s*\d+|当前章.*[::]\s*\d+|进度.*[::]\s*\d+)/i;
-        if (progressPatterns.test(content)) {
-          return JSON.stringify({ error: "不允许通过 write_truth_file 修改 current_state.md 中的章节进度。章节进度由系统自动管理。" });
-        }
+      if (fileName === "current_state.md" && containsProgressManipulation(content)) {
+        return JSON.stringify({ error: "不允许通过 write_truth_file 修改 current_state.md 中的章节进度。章节进度由系统自动管理。" });
       }
 
       const { writeFile, mkdir } = await import("node:fs/promises");
@@ -610,5 +610,31 @@ async function executeTool(
   return executeAgentTool(pipeline, state, config, name, args);
 }
 
+async function getSequentialWriteGuardError(
+  state: import("../state/manager.js").StateManager,
+  bookId: string,
+  toolName: "write_draft" | "write_full_pipeline",
+): Promise<string | null> {
+  const nextNum = await state.getNextChapterNumber(bookId);
+  const index = await state.loadChapterIndex(bookId);
+  if (index.length === 0) return null;
+  const lastIndexedChapter = index[index.length - 1]!.number;
+  if (lastIndexedChapter === nextNum - 1) return null;
+  return `${toolName} 只能续写下一章(当前应写第${nextNum}章)。检测到章节索引与运行时进度不一致,请先用 get_book_status 确认状态。`;
+}
+
+function containsProgressManipulation(content: string): boolean {
+  const patterns = [
+    /\blastAppliedChapter\b/i,
+    /\|\s*Current Chapter\s*\|\s*\d+\s*\|/i,
+    /\|\s*当前章(?:节)?\s*\|\s*\d+\s*\|/,
+    /\bCurrent Chapter\b\s*[::]\s*\d+/i,
+    /当前章(?:节)?\s*[::]\s*\d+/,
+    /\bprogress\b\s*[::]\s*\d+/i,
+    /进度\s*[::]\s*\d+/,
+  ];
+  return patterns.some((pattern) => pattern.test(content));
+}
+
 /** Export tool definitions so external systems can reference them. */
 export { TOOLS as AGENT_TOOLS };

+ 47 - 26
packages/core/src/pipeline/runner.ts

@@ -1100,16 +1100,53 @@ export class PipelineRunner {
     // 4. Save the final chapter and truth files from a single persistence source
     this.logStage(stageLanguage, { zh: "落盘最终章节", en: "persisting final chapter" });
     this.logStage(stageLanguage, { zh: "生成最终真相文件", en: "rebuilding final truth files" });
-    const persistenceOutput = await this.buildPersistenceOutput(
+    const chapterIndexBeforePersist = await this.state.loadChapterIndex(bookId);
+    const { resolveDuplicateTitle } = await import("../agents/post-write-validator.js");
+    const initialTitleResolution = resolveDuplicateTitle(
+      output.title,
+      chapterIndexBeforePersist.map((chapter) => chapter.title),
+      pipelineLang,
+    );
+    let persistenceOutput = await this.buildPersistenceOutput(
       bookId,
       book,
       bookDir,
       chapterNumber,
-      output,
+      initialTitleResolution.title === output.title
+        ? output
+        : { ...output, title: initialTitleResolution.title },
       finalContent,
       lengthSpec.countingMode,
       reducedControlInput,
     );
+    const finalTitleResolution = resolveDuplicateTitle(
+      persistenceOutput.title,
+      chapterIndexBeforePersist.map((chapter) => chapter.title),
+      pipelineLang,
+    );
+    if (finalTitleResolution.title !== persistenceOutput.title) {
+      persistenceOutput = {
+        ...persistenceOutput,
+        title: finalTitleResolution.title,
+      };
+    }
+    if (persistenceOutput.title !== output.title) {
+      const description = pipelineLang === "en"
+        ? `Duplicate chapter title "${output.title}" was auto-renamed to "${persistenceOutput.title}".`
+        : `章节标题"${output.title}"与已有标题重复,已自动改为"${persistenceOutput.title}"。`;
+      this.config.logger?.warn(`[title] ${description}`);
+      auditResult = {
+        ...auditResult,
+        issues: [...auditResult.issues, {
+          severity: "warning",
+          category: "title-dedup",
+          description,
+          suggestion: pipelineLang === "en"
+            ? "If the auto-renamed title is weak, revise the chapter title manually."
+            : "如果自动改名不理想,可以在后续手动修订章节标题。",
+        }],
+      };
+    }
     const longSpanFatigue = await analyzeLongSpanFatigue({
       bookDir,
       chapterNumber,
@@ -1178,7 +1215,10 @@ export class PipelineRunner {
 
     // 4.2 Final paragraph shape check on persisted content (post-normalize, post-revise)
     {
-      const { detectParagraphLengthDrift } = await import("../agents/post-write-validator.js");
+      const {
+        detectParagraphLengthDrift,
+        detectParagraphShapeWarnings,
+      } = await import("../agents/post-write-validator.js");
       const chapDir = join(bookDir, "chapters");
       const recentFiles = (await readdir(chapDir).catch(() => [] as string[]))
         .filter((f) => f.endsWith(".md") && /^\d{4}/.test(f))
@@ -1187,7 +1227,10 @@ export class PipelineRunner {
       const recentContent = (await Promise.all(
         recentFiles.map((f) => readFile(join(chapDir, f), "utf-8").catch(() => "")),
       )).join("\n\n");
-      const paragraphIssues = detectParagraphLengthDrift(finalContent, recentContent, pipelineLang);
+      const paragraphIssues = [
+        ...detectParagraphShapeWarnings(finalContent, pipelineLang),
+        ...detectParagraphLengthDrift(finalContent, recentContent, pipelineLang),
+      ];
       if (paragraphIssues.length > 0) {
         for (const issue of paragraphIssues) {
           this.config.logger?.warn(`[paragraph] ${issue.description}`);
@@ -1198,28 +1241,6 @@ export class PipelineRunner {
             severity: v.severity as "warning",
             category: "paragraph-shape",
             description: v.description,
-            suggestion: v.description,
-          }))],
-        };
-      }
-    }
-
-    // 4.3 Title dedup check
-    {
-      const { detectDuplicateTitle } = await import("../agents/post-write-validator.js");
-      const chapterIndex = await this.state.loadChapterIndex(bookId);
-      const existingTitles = chapterIndex.map((ch) => ch.title);
-      const titleIssues = detectDuplicateTitle(persistenceOutput.title, existingTitles);
-      if (titleIssues.length > 0) {
-        for (const issue of titleIssues) {
-          this.config.logger?.warn(`[title] ${issue.description}`);
-        }
-        auditResult = {
-          ...auditResult,
-          issues: [...auditResult.issues, ...titleIssues.map((v) => ({
-            severity: v.severity as "warning",
-            category: "title-dedup",
-            description: v.description,
             suggestion: v.suggestion,
           }))],
         };

+ 7 - 2
packages/core/src/state/manager.ts

@@ -1,4 +1,4 @@
-import { readFile, writeFile, mkdir, readdir, stat, unlink, open } from "node:fs/promises";
+import { readFile, writeFile, mkdir, readdir, rm, stat, unlink, open } from "node:fs/promises";
 import { join } from "node:path";
 import type { BookConfig } from "../models/book.js";
 import type { ChapterMeta } from "../models/chapter.js";
@@ -303,11 +303,13 @@ export class StateManager {
         }),
       );
 
+      const stateDir = this.stateDir(bookId);
+      let restoredStructuredState = false;
       try {
         const snapshotStateDir = join(snapshotDir, "state");
         const stateFiles = await readdir(snapshotStateDir);
         if (stateFiles.length > 0) {
-          const stateDir = this.stateDir(bookId);
+          restoredStructuredState = true;
           await mkdir(stateDir, { recursive: true });
           await Promise.all(
             stateFiles.map(async (fileName) => {
@@ -319,6 +321,9 @@ export class StateManager {
       } catch {
         // snapshot structured state missing — skip
       }
+      if (!restoredStructuredState) {
+        await rm(stateDir, { recursive: true, force: true });
+      }
 
       return true;
     } catch {