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

feat: 12-feature enhancement — summaries, ai-tells, webhook, detection, style, scheduler

Writing quality:
- A2: chapter summaries for cross-chapter coherence
- A4: subplot progress board with staleness detection
- A1: emotional arc tracking per character
- A3: character interaction matrix with info boundaries
- A5: pacing health score (consecutive same-type chapter warning)

AIGC detection:
- C4: structural AI-tell audit (paragraph uniformity, hedge words, formulaic transitions)
- C1: external AIGC detection API integration (GPTZero/Originality/custom)
- C3: style fingerprint learning from reference texts
- C2: anti-detect rewrite pipeline (detect→revise→re-detect loop)
- C5: detection feedback loop with history and insights

Automation:
- B1: webhook notifications with HMAC signing for pipeline events
- B2: smart scheduler with quality gates, auto-retry, and pause on failures

New CLI commands: inkos detect, inkos style analyze/import
majx_mac 8 месяцев назад
Родитель
Сommit
e14bdfe80a
29 измененных файлов с 1782 добавлено и 23 удалено
  1. 45 0
      packages/cli/src/__tests__/publish-package.test.ts
  2. 116 0
      packages/cli/src/commands/detect.ts
  3. 74 0
      packages/cli/src/commands/style.ts
  4. 4 0
      packages/cli/src/index.ts
  5. 90 0
      packages/core/src/__tests__/ai-tells.test.ts
  6. 59 0
      packages/core/src/__tests__/detection-insights.test.ts
  7. 86 0
      packages/core/src/__tests__/detector.test.ts
  8. 61 0
      packages/core/src/__tests__/style-analyzer.test.ts
  9. 91 0
      packages/core/src/__tests__/webhook.test.ts
  10. 133 0
      packages/core/src/agents/ai-tells.ts
  11. 19 0
      packages/core/src/agents/architect.ts
  12. 41 7
      packages/core/src/agents/continuity.ts
  13. 72 0
      packages/core/src/agents/detection-insights.ts
  14. 120 0
      packages/core/src/agents/detector.ts
  15. 2 1
      packages/core/src/agents/reviser.ts
  16. 93 0
      packages/core/src/agents/style-analyzer.ts
  17. 43 1
      packages/core/src/agents/writer-prompts.ts
  18. 113 5
      packages/core/src/agents/writer.ts
  19. 10 2
      packages/core/src/index.ts
  20. 3 0
      packages/core/src/models/chapter.ts
  21. 25 0
      packages/core/src/models/detection.ts
  22. 37 0
      packages/core/src/models/project.ts
  23. 15 0
      packages/core/src/models/style-profile.ts
  24. 37 0
      packages/core/src/notify/dispatcher.ts
  25. 57 0
      packages/core/src/notify/webhook.ts
  26. 163 0
      packages/core/src/pipeline/detection-runner.ts
  27. 74 4
      packages/core/src/pipeline/runner.ts
  28. 74 0
      packages/core/src/pipeline/scheduler.ts
  29. 25 3
      packages/core/src/state/manager.ts

+ 45 - 0
packages/cli/src/__tests__/publish-package.test.ts

@@ -0,0 +1,45 @@
+import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { execFileSync } from "node:child_process";
+import { describe, expect, it } from "vitest";
+
+const testDir = dirname(fileURLToPath(import.meta.url));
+const cliDir = resolve(testDir, "..", "..");
+const workspaceRoot = resolve(cliDir, "..", "..");
+
+async function extractPackedPackageJson(packDir: string) {
+  execFileSync("npm", ["pack", "--pack-destination", packDir], {
+    cwd: cliDir,
+    env: process.env,
+    encoding: "utf-8",
+  });
+
+  const tgzFiles = (await readdir(packDir)).filter((name) => name.endsWith(".tgz"));
+  if (tgzFiles.length !== 1) {
+    throw new Error(`Expected exactly one tarball in ${packDir}, found ${tgzFiles.length}`);
+  }
+
+  return execFileSync("tar", ["-xOf", join(packDir, tgzFiles[0]), "package/package.json"], {
+    cwd: workspaceRoot,
+    encoding: "utf-8",
+  });
+}
+
+describe("publish packaging", () => {
+  it("replaces workspace dependencies before npm pack", async () => {
+    const packDir = await mkdtemp(join(tmpdir(), "inkos-cli-pack-"));
+
+    try {
+      const packedPackageJson = JSON.parse(await extractPackedPackageJson(packDir));
+      const corePackageJson = JSON.parse(
+        await readFile(resolve(workspaceRoot, "packages/core/package.json"), "utf-8"),
+      );
+
+      expect(packedPackageJson.dependencies["@actalk/inkos-core"]).toBe(corePackageJson.version);
+    } finally {
+      await rm(packDir, { recursive: true, force: true });
+    }
+  });
+});

+ 116 - 0
packages/cli/src/commands/detect.ts

@@ -0,0 +1,116 @@
+import { Command } from "commander";
+import {
+  StateManager,
+  detectChapter,
+  loadDetectionHistory,
+  analyzeDetectionInsights,
+  type DetectionConfig,
+} from "@actalk/inkos-core";
+import { loadConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
+import { readFile, readdir } from "node:fs/promises";
+import { join } from "node:path";
+
+export const detectCommand = new Command("detect")
+  .description("Run AIGC detection on chapters")
+  .argument("[book-id]", "Book ID (auto-detected if only one book)")
+  .argument("[chapter]", "Chapter number (defaults to latest)")
+  .option("--all", "Detect all chapters")
+  .option("--stats", "Show detection statistics")
+  .option("--json", "Output JSON")
+  .action(async (bookIdArg: string | undefined, chapterStr: string | undefined, opts) => {
+    try {
+      const config = await loadConfig();
+      const root = findProjectRoot();
+
+      if (!config.detection?.enabled) {
+        logError("AIGC detection is not enabled. Add detection config to inkos.json.");
+        process.exit(1);
+      }
+
+      // If first arg looks like a number, treat it as chapter
+      let bookId: string;
+      let chapterNumber: number | undefined;
+      if (bookIdArg && /^\d+$/.test(bookIdArg)) {
+        bookId = await resolveBookId(undefined, root);
+        chapterNumber = parseInt(bookIdArg, 10);
+      } else {
+        bookId = await resolveBookId(bookIdArg, root);
+        chapterNumber = chapterStr ? parseInt(chapterStr, 10) : undefined;
+      }
+
+      const state = new StateManager(root);
+      const bookDir = state.bookDir(bookId);
+
+      if (opts.stats) {
+        const history = await loadDetectionHistory(bookDir);
+        const stats = analyzeDetectionInsights(history);
+        if (opts.json) {
+          log(JSON.stringify(stats, null, 2));
+        } else {
+          log(`Detection Statistics:`);
+          log(`  Total detections: ${stats.totalDetections}`);
+          log(`  Total rewrites: ${stats.totalRewrites}`);
+          log(`  Avg original score: ${stats.avgOriginalScore.toFixed(3)}`);
+          log(`  Avg final score: ${stats.avgFinalScore.toFixed(3)}`);
+          log(`  Avg score reduction: ${stats.avgScoreReduction.toFixed(3)}`);
+          log(`  Pass rate: ${(stats.passRate * 100).toFixed(0)}%`);
+          if (stats.chapterBreakdown.length > 0) {
+            log(`  Chapters:`);
+            for (const ch of stats.chapterBreakdown) {
+              log(`    Ch.${ch.chapterNumber}: ${ch.originalScore.toFixed(3)} → ${ch.finalScore.toFixed(3)} (${ch.rewriteAttempts} rewrites)`);
+            }
+          }
+        }
+        return;
+      }
+
+      const detectionConfig = config.detection as DetectionConfig;
+
+      if (opts.all) {
+        const index = await state.loadChapterIndex(bookId);
+        for (const ch of index) {
+          const content = await readChapterContent(bookDir, ch.number);
+          const result = await detectChapter(detectionConfig, content, ch.number);
+          printResult(result, opts.json);
+        }
+      } else {
+        const targetChapter = chapterNumber ?? (await state.getNextChapterNumber(bookId)) - 1;
+        if (targetChapter < 1) {
+          logError("No chapters to detect.");
+          process.exit(1);
+        }
+        const content = await readChapterContent(bookDir, targetChapter);
+        const result = await detectChapter(detectionConfig, content, targetChapter);
+        printResult(result, opts.json);
+      }
+    } catch (e) {
+      logError(`Detection failed: ${e}`);
+      process.exit(1);
+    }
+  });
+
+function printResult(
+  result: { chapterNumber: number; detection: { score: number; provider: string }; passed: boolean },
+  json: boolean,
+): void {
+  if (json) {
+    log(JSON.stringify(result, null, 2));
+  } else {
+    const icon = result.passed ? "✅" : "⚠️";
+    log(`  ${icon} Chapter ${result.chapterNumber}: score=${result.detection.score.toFixed(3)} (${result.detection.provider}) ${result.passed ? "PASS" : "FAIL"}`);
+  }
+}
+
+async function readChapterContent(bookDir: string, chapterNumber: number): Promise<string> {
+  const chaptersDir = join(bookDir, "chapters");
+  const files = await readdir(chaptersDir);
+  const paddedNum = String(chapterNumber).padStart(4, "0");
+  const chapterFile = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
+  if (!chapterFile) {
+    throw new Error(`Chapter ${chapterNumber} file not found`);
+  }
+  const raw = await readFile(join(chaptersDir, chapterFile), "utf-8");
+  const lines = raw.split("\n");
+  const contentStart = lines.findIndex((l, i) => i > 0 && l.trim().length > 0);
+  return contentStart >= 0 ? lines.slice(contentStart).join("\n") : raw;
+}

+ 74 - 0
packages/cli/src/commands/style.ts

@@ -0,0 +1,74 @@
+import { Command } from "commander";
+import { StateManager, analyzeStyle } from "@actalk/inkos-core";
+import { findProjectRoot, resolveBookId, log, logError } from "../utils.js";
+import { readFile, writeFile, mkdir } from "node:fs/promises";
+import { join, resolve } from "node:path";
+
+export const styleCommand = new Command("style")
+  .description("Style fingerprint analysis and import");
+
+styleCommand
+  .command("analyze")
+  .description("Analyze a text file and extract style profile")
+  .argument("<file>", "Text file to analyze")
+  .option("--name <name>", "Source name for the profile")
+  .option("--json", "Output JSON only")
+  .action(async (file: string, opts) => {
+    try {
+      const text = await readFile(resolve(file), "utf-8");
+      const profile = analyzeStyle(text, opts.name ?? file);
+
+      if (opts.json) {
+        log(JSON.stringify(profile, null, 2));
+      } else {
+        log("Style Profile:");
+        log(`  Source: ${profile.sourceName ?? "unknown"}`);
+        log(`  Avg sentence length: ${profile.avgSentenceLength} chars`);
+        log(`  Sentence length std dev: ${profile.sentenceLengthStdDev}`);
+        log(`  Avg paragraph length: ${profile.avgParagraphLength} chars`);
+        log(`  Paragraph range: ${profile.paragraphLengthRange.min}-${profile.paragraphLengthRange.max} chars`);
+        log(`  Vocabulary diversity (TTR): ${profile.vocabularyDiversity}`);
+        if (profile.topPatterns.length > 0) {
+          log(`  Top patterns: ${profile.topPatterns.join(", ")}`);
+        }
+        if (profile.rhetoricalFeatures.length > 0) {
+          log(`  Rhetorical features: ${profile.rhetoricalFeatures.join(", ")}`);
+        }
+      }
+    } catch (e) {
+      logError(`Analysis failed: ${e}`);
+      process.exit(1);
+    }
+  });
+
+styleCommand
+  .command("import")
+  .description("Import a style profile into a book")
+  .argument("<file>", "Text file to analyze and import")
+  .argument("[book-id]", "Book ID (auto-detected if only one book)")
+  .option("--name <name>", "Source name for the profile")
+  .action(async (file: string, bookIdArg: string | undefined, opts) => {
+    try {
+      const root = findProjectRoot();
+      const bookId = await resolveBookId(bookIdArg, root);
+      const state = new StateManager(root);
+      const bookDir = state.bookDir(bookId);
+
+      const text = await readFile(resolve(file), "utf-8");
+      const profile = analyzeStyle(text, opts.name ?? file);
+
+      const storyDir = join(bookDir, "story");
+      await mkdir(storyDir, { recursive: true });
+      await writeFile(
+        join(storyDir, "style_profile.json"),
+        JSON.stringify(profile, null, 2),
+        "utf-8",
+      );
+
+      log(`Style profile imported to "${bookId}" from "${file}"`);
+      log(`  Avg sentence: ${profile.avgSentenceLength} chars, TTR: ${profile.vocabularyDiversity}`);
+    } catch (e) {
+      logError(`Import failed: ${e}`);
+      process.exit(1);
+    }
+  });

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

@@ -17,6 +17,8 @@ import { reviseCommand } from "./commands/revise.js";
 import { agentCommand } from "./commands/agent.js";
 import { genreCommand } from "./commands/genre.js";
 import { updateCommand } from "./commands/update.js";
+import { detectCommand } from "./commands/detect.js";
+import { styleCommand } from "./commands/style.js";
 
 const program = new Command();
 
@@ -42,5 +44,7 @@ program.addCommand(reviseCommand);
 program.addCommand(agentCommand);
 program.addCommand(genreCommand);
 program.addCommand(updateCommand);
+program.addCommand(detectCommand);
+program.addCommand(styleCommand);
 
 program.parse();

+ 90 - 0
packages/core/src/__tests__/ai-tells.test.ts

@@ -0,0 +1,90 @@
+import { describe, it, expect } from "vitest";
+import { analyzeAITells } from "../agents/ai-tells.js";
+
+describe("analyzeAITells", () => {
+  it("returns no issues for varied paragraph lengths", () => {
+    const content = [
+      "短段。",
+      "",
+      "这是一个中等长度的段落,包含一些描述性的内容,让这个段落稍微长一些。",
+      "",
+      "很长的段落。这个段落包含了大量的内容,描述了各种各样的场景和人物。角色们在这里进行了激烈的讨论,关于未来的计划和当前的困境。他们需要找到一种方式来解决眼前的问题。",
+    ].join("\n");
+
+    const result = analyzeAITells(content);
+    const paraIssues = result.issues.filter((i) => i.category === "段落等长");
+    expect(paraIssues).toHaveLength(0);
+  });
+
+  it("detects uniform paragraph lengths (dim 20)", () => {
+    // Generate paragraphs of nearly identical length
+    const para = "这是一个测试段落的内容,长度大约相同。";
+    const content = [para, "", para, "", para, "", para].join("\n");
+
+    const result = analyzeAITells(content);
+    const paraIssues = result.issues.filter((i) => i.category === "段落等长");
+    expect(paraIssues.length).toBeGreaterThan(0);
+    expect(paraIssues[0]!.severity).toBe("warning");
+  });
+
+  it("detects high hedge word density (dim 21)", () => {
+    const content = [
+      "他似乎觉得这件事可能不太对劲。",
+      "",
+      "或许他应该大概去看看。似乎有什么东西在那里。",
+      "",
+      "可能是一种错觉,大概只是风声。某种程度上他也不太确定。",
+    ].join("\n");
+
+    const result = analyzeAITells(content);
+    const hedgeIssues = result.issues.filter((i) => i.category === "套话密度");
+    expect(hedgeIssues.length).toBeGreaterThan(0);
+  });
+
+  it("detects formulaic transition repetition (dim 22)", () => {
+    const content = [
+      "第一段内容。然而事情并不简单。",
+      "",
+      "第二段内容。然而他没有放弃。",
+      "",
+      "第三段内容。然而命运弄人。",
+    ].join("\n");
+
+    const result = analyzeAITells(content);
+    const transIssues = result.issues.filter((i) => i.category === "公式化转折");
+    expect(transIssues.length).toBeGreaterThan(0);
+    expect(transIssues[0]!.description).toContain("然而");
+  });
+
+  it("detects list-like sentence structure (dim 23)", () => {
+    const content = [
+      "他看着远方的山峰。他看着脚下的深渊。他看着身旁的同伴。他看着手中的剑。",
+    ].join("\n");
+
+    const result = analyzeAITells(content);
+    const listIssues = result.issues.filter((i) => i.category === "列表式结构");
+    expect(listIssues.length).toBeGreaterThan(0);
+    expect(listIssues[0]!.severity).toBe("info");
+  });
+
+  it("returns no issues for content with fewer than 3 paragraphs", () => {
+    const content = "只有一段话。";
+    const result = analyzeAITells(content);
+    expect(result.issues).toHaveLength(0);
+  });
+
+  it("returns no issues for clean varied text", () => {
+    const content = [
+      "陈风一脚踩碎了脚下的石板。碎石飞溅,打在旁边的墙壁上发出清脆的声响。",
+      "",
+      "短暂的沉默。空气中弥漫着灰尘的味道,呛得他咳嗽了两声。远处传来脚步声。",
+      "",
+      "\"谁?\"他低喝一声,手已经按上了腰间的刀柄。指尖触到冰凉的金属,心跳稍微稳了一些。黑暗中,一双眼睛正盯着他。那目光冰冷得像冬夜的寒风,带着审视和一丝不易察觉的警惕。",
+    ].join("\n");
+
+    const result = analyzeAITells(content);
+    // Should have no or few issues for natural-looking text
+    const warningIssues = result.issues.filter((i) => i.severity === "warning");
+    expect(warningIssues).toHaveLength(0);
+  });
+});

+ 59 - 0
packages/core/src/__tests__/detection-insights.test.ts

@@ -0,0 +1,59 @@
+import { describe, it, expect } from "vitest";
+import { analyzeDetectionInsights } from "../agents/detection-insights.js";
+import type { DetectionHistoryEntry } from "../models/detection.js";
+
+describe("analyzeDetectionInsights", () => {
+  it("returns zeros for empty history", () => {
+    const stats = analyzeDetectionInsights([]);
+    expect(stats.totalDetections).toBe(0);
+    expect(stats.totalRewrites).toBe(0);
+    expect(stats.avgOriginalScore).toBe(0);
+    expect(stats.chapterBreakdown).toHaveLength(0);
+  });
+
+  it("correctly counts detections and rewrites", () => {
+    const history: DetectionHistoryEntry[] = [
+      { chapterNumber: 1, timestamp: "2026-01-01T00:00:00Z", provider: "custom", score: 0.8, action: "detect", attempt: 0 },
+      { chapterNumber: 1, timestamp: "2026-01-01T00:01:00Z", provider: "custom", score: 0.6, action: "rewrite", attempt: 1 },
+      { chapterNumber: 1, timestamp: "2026-01-01T00:02:00Z", provider: "custom", score: 0.4, action: "rewrite", attempt: 2 },
+      { chapterNumber: 2, timestamp: "2026-01-02T00:00:00Z", provider: "custom", score: 0.3, action: "detect", attempt: 0 },
+    ];
+
+    const stats = analyzeDetectionInsights(history);
+    expect(stats.totalDetections).toBe(2);
+    expect(stats.totalRewrites).toBe(2);
+  });
+
+  it("calculates per-chapter breakdown", () => {
+    const history: DetectionHistoryEntry[] = [
+      { chapterNumber: 1, timestamp: "2026-01-01T00:00:00Z", provider: "custom", score: 0.9, action: "detect", attempt: 0 },
+      { chapterNumber: 1, timestamp: "2026-01-01T00:01:00Z", provider: "custom", score: 0.5, action: "rewrite", attempt: 1 },
+      { chapterNumber: 2, timestamp: "2026-01-02T00:00:00Z", provider: "custom", score: 0.3, action: "detect", attempt: 0 },
+    ];
+
+    const stats = analyzeDetectionInsights(history);
+    expect(stats.chapterBreakdown).toHaveLength(2);
+
+    const ch1 = stats.chapterBreakdown.find((c) => c.chapterNumber === 1);
+    expect(ch1?.originalScore).toBe(0.9);
+    expect(ch1?.finalScore).toBe(0.5);
+    expect(ch1?.rewriteAttempts).toBe(1);
+
+    const ch2 = stats.chapterBreakdown.find((c) => c.chapterNumber === 2);
+    expect(ch2?.originalScore).toBe(0.3);
+    expect(ch2?.finalScore).toBe(0.3);
+    expect(ch2?.rewriteAttempts).toBe(0);
+  });
+
+  it("calculates score reduction average", () => {
+    const history: DetectionHistoryEntry[] = [
+      { chapterNumber: 1, timestamp: "2026-01-01T00:00:00Z", provider: "custom", score: 0.8, action: "detect", attempt: 0 },
+      { chapterNumber: 1, timestamp: "2026-01-01T00:01:00Z", provider: "custom", score: 0.4, action: "rewrite", attempt: 1 },
+    ];
+
+    const stats = analyzeDetectionInsights(history);
+    expect(stats.avgOriginalScore).toBe(0.8);
+    expect(stats.avgFinalScore).toBe(0.4);
+    expect(stats.avgScoreReduction).toBe(0.4);
+  });
+});

+ 86 - 0
packages/core/src/__tests__/detector.test.ts

@@ -0,0 +1,86 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { detectAIContent } from "../agents/detector.js";
+import type { DetectionConfig } from "../models/project.js";
+
+const mockFetch = vi.fn();
+vi.stubGlobal("fetch", mockFetch);
+
+beforeEach(() => {
+  mockFetch.mockReset();
+  vi.stubEnv("TEST_API_KEY", "test-key-123");
+});
+
+const baseConfig: DetectionConfig = {
+  provider: "custom",
+  apiUrl: "https://api.detect.test/v1/detect",
+  apiKeyEnv: "TEST_API_KEY",
+  threshold: 0.5,
+  enabled: true,
+  autoRewrite: false,
+  maxRetries: 3,
+};
+
+describe("detectAIContent", () => {
+  it("throws when API key env is not set", async () => {
+    vi.stubEnv("MISSING_KEY", "");
+    delete process.env.MISSING_KEY;
+    const config = { ...baseConfig, apiKeyEnv: "MISSING_KEY" };
+    await expect(detectAIContent(config, "test")).rejects.toThrow("Detection API key not found");
+  });
+
+  it("calls custom API with correct headers and body", async () => {
+    mockFetch.mockResolvedValueOnce({
+      ok: true,
+      json: async () => ({ score: 0.75 }),
+    });
+
+    const result = await detectAIContent(baseConfig, "test content");
+
+    expect(mockFetch).toHaveBeenCalledOnce();
+    const [url, opts] = mockFetch.mock.calls[0]!;
+    expect(url).toBe("https://api.detect.test/v1/detect");
+    expect(opts.headers.Authorization).toBe("Bearer test-key-123");
+    expect(result.score).toBe(0.75);
+    expect(result.provider).toBe("custom");
+  });
+
+  it("handles GPTZero provider format", async () => {
+    mockFetch.mockResolvedValueOnce({
+      ok: true,
+      json: async () => ({
+        documents: [{ completely_generated_prob: 0.92 }],
+      }),
+    });
+
+    const gptzeroConfig = { ...baseConfig, provider: "gptzero" as const };
+    const result = await detectAIContent(gptzeroConfig, "test content");
+
+    expect(result.score).toBe(0.92);
+    expect(result.provider).toBe("gptzero");
+  });
+
+  it("handles Originality provider format", async () => {
+    mockFetch.mockResolvedValueOnce({
+      ok: true,
+      json: async () => ({
+        score: { ai: 0.3, original: 0.7 },
+      }),
+    });
+
+    const origConfig = { ...baseConfig, provider: "originality" as const };
+    const result = await detectAIContent(origConfig, "test content");
+
+    expect(result.score).toBe(0.3);
+    expect(result.provider).toBe("originality");
+  });
+
+  it("throws on non-ok response", async () => {
+    mockFetch.mockResolvedValueOnce({
+      ok: false,
+      status: 429,
+      text: async () => "Rate limited",
+    });
+
+    await expect(detectAIContent(baseConfig, "test")).rejects.toThrow("Detection API failed: 429");
+  });
+});

+ 61 - 0
packages/core/src/__tests__/style-analyzer.test.ts

@@ -0,0 +1,61 @@
+import { describe, it, expect } from "vitest";
+import { analyzeStyle } from "../agents/style-analyzer.js";
+
+describe("analyzeStyle", () => {
+  const sampleText = [
+    "陈风一脚踩碎了脚下的石板。碎石飞溅,打在旁边的墙壁上发出清脆的声响。他低头看了一眼,嘴角微微上扬。",
+    "",
+    "\"谁?\"他低喝一声,手已经按上了腰间的刀柄。指尖触到冰凉的金属,心跳稍微稳了一些。",
+    "",
+    "黑暗中,一双眼睛正盯着他。那目光冰冷得像冬夜的寒风,带着审视和一丝不易察觉的警惕。来者不善。但陈风并不怕。他经历过比这更恶劣的处境。比这更危险的对手。他攥紧了刀柄,朝着那双眼睛走了过去。脚步声在空旷的巷子里回荡。",
+  ].join("\n");
+
+  it("calculates sentence length statistics", () => {
+    const profile = analyzeStyle(sampleText);
+    expect(profile.avgSentenceLength).toBeGreaterThan(0);
+    expect(profile.sentenceLengthStdDev).toBeGreaterThan(0);
+  });
+
+  it("calculates paragraph length statistics", () => {
+    const profile = analyzeStyle(sampleText);
+    expect(profile.avgParagraphLength).toBeGreaterThan(0);
+    expect(profile.paragraphLengthRange.min).toBeGreaterThan(0);
+    expect(profile.paragraphLengthRange.max).toBeGreaterThanOrEqual(profile.paragraphLengthRange.min);
+  });
+
+  it("calculates vocabulary diversity", () => {
+    const profile = analyzeStyle(sampleText);
+    expect(profile.vocabularyDiversity).toBeGreaterThan(0);
+    expect(profile.vocabularyDiversity).toBeLessThanOrEqual(1);
+  });
+
+  it("includes source name when provided", () => {
+    const profile = analyzeStyle(sampleText, "测试来源");
+    expect(profile.sourceName).toBe("测试来源");
+  });
+
+  it("includes analyzed timestamp", () => {
+    const profile = analyzeStyle(sampleText);
+    expect(profile.analyzedAt).toBeDefined();
+  });
+
+  it("handles empty text", () => {
+    const profile = analyzeStyle("");
+    expect(profile.avgSentenceLength).toBe(0);
+    expect(profile.avgParagraphLength).toBe(0);
+    expect(profile.vocabularyDiversity).toBe(0);
+  });
+
+  it("detects top patterns from repeated sentence openings", () => {
+    const repetitiveText = [
+      "他看着远方。他看着山峰。他看着大海。他看着天空。",
+      "",
+      "风在吹。雨在下。",
+    ].join("\n");
+
+    const profile = analyzeStyle(repetitiveText);
+    // "他看" should be detected as a top pattern
+    const hasHeKan = profile.topPatterns.some((p) => p.includes("他看"));
+    expect(hasHeKan).toBe(true);
+  });
+});

+ 91 - 0
packages/core/src/__tests__/webhook.test.ts

@@ -0,0 +1,91 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { sendWebhook, type WebhookPayload } from "../notify/webhook.js";
+import { createHmac } from "node:crypto";
+
+const mockFetch = vi.fn();
+vi.stubGlobal("fetch", mockFetch);
+
+beforeEach(() => {
+  mockFetch.mockReset();
+});
+
+const basePayload: WebhookPayload = {
+  event: "chapter-complete",
+  bookId: "test-book",
+  chapterNumber: 5,
+  timestamp: "2026-03-14T00:00:00.000Z",
+  data: { title: "测试章节", wordCount: 3000 },
+};
+
+describe("sendWebhook", () => {
+  it("sends POST request with JSON payload", async () => {
+    mockFetch.mockResolvedValueOnce({ ok: true });
+
+    await sendWebhook({ url: "https://example.com/hook" }, basePayload);
+
+    expect(mockFetch).toHaveBeenCalledOnce();
+    const [url, opts] = mockFetch.mock.calls[0]!;
+    expect(url).toBe("https://example.com/hook");
+    expect(opts.method).toBe("POST");
+    expect(opts.headers["Content-Type"]).toBe("application/json");
+    const body = JSON.parse(opts.body);
+    expect(body.event).toBe("chapter-complete");
+    expect(body.bookId).toBe("test-book");
+  });
+
+  it("includes HMAC signature when secret is configured", async () => {
+    mockFetch.mockResolvedValueOnce({ ok: true });
+    const secret = "my-secret-key";
+
+    await sendWebhook({ url: "https://example.com/hook", secret }, basePayload);
+
+    const [, opts] = mockFetch.mock.calls[0]!;
+    const expectedSig = createHmac("sha256", secret)
+      .update(opts.body)
+      .digest("hex");
+    expect(opts.headers["X-InkOS-Signature"]).toBe(`sha256=${expectedSig}`);
+  });
+
+  it("skips event when not in subscribed events list", async () => {
+    await sendWebhook(
+      { url: "https://example.com/hook", events: ["audit-passed"] },
+      basePayload,
+    );
+
+    expect(mockFetch).not.toHaveBeenCalled();
+  });
+
+  it("sends event when it matches subscribed events list", async () => {
+    mockFetch.mockResolvedValueOnce({ ok: true });
+
+    await sendWebhook(
+      { url: "https://example.com/hook", events: ["chapter-complete"] },
+      basePayload,
+    );
+
+    expect(mockFetch).toHaveBeenCalledOnce();
+  });
+
+  it("sends all events when events list is empty", async () => {
+    mockFetch.mockResolvedValueOnce({ ok: true });
+
+    await sendWebhook(
+      { url: "https://example.com/hook", events: [] },
+      basePayload,
+    );
+
+    expect(mockFetch).toHaveBeenCalledOnce();
+  });
+
+  it("throws on non-ok response", async () => {
+    mockFetch.mockResolvedValueOnce({
+      ok: false,
+      status: 500,
+      text: async () => "Internal Server Error",
+    });
+
+    await expect(
+      sendWebhook({ url: "https://example.com/hook" }, basePayload),
+    ).rejects.toThrow("Webhook POST to https://example.com/hook failed: 500");
+  });
+});

+ 133 - 0
packages/core/src/agents/ai-tells.ts

@@ -0,0 +1,133 @@
+/**
+ * Structural AI-tell detection — pure rule-based analysis (no LLM).
+ *
+ * Detects patterns common in AI-generated Chinese text:
+ * - dim 20: Paragraph length uniformity (low variance)
+ * - dim 21: Filler/hedge word density
+ * - dim 22: Formulaic transition patterns
+ * - dim 23: List-like structure (consecutive same-prefix sentences)
+ */
+
+export interface AITellIssue {
+  readonly severity: "warning" | "info";
+  readonly category: string;
+  readonly description: string;
+  readonly suggestion: string;
+}
+
+export interface AITellResult {
+  readonly issues: ReadonlyArray<AITellIssue>;
+}
+
+// Hedge/filler words common in AI Chinese text
+const HEDGE_WORDS = ["似乎", "可能", "或许", "大概", "某种程度上", "一定程度上", "在某种意义上"];
+
+// Formulaic transition words
+const TRANSITION_WORDS = ["然而", "不过", "与此同时", "另一方面", "尽管如此", "话虽如此", "但值得注意的是"];
+
+/**
+ * Analyze text content for structural AI-tell patterns.
+ * Returns issues that can be merged into audit results.
+ */
+export function analyzeAITells(content: string): AITellResult {
+  const issues: AITellIssue[] = [];
+
+  const paragraphs = content
+    .split(/\n\s*\n/)
+    .map((p) => p.trim())
+    .filter((p) => p.length > 0);
+
+  // dim 20: Paragraph length uniformity (needs ≥3 paragraphs)
+  if (paragraphs.length >= 3) {
+    const paragraphLengths = paragraphs.map((p) => p.length);
+    const mean = paragraphLengths.reduce((a, b) => a + b, 0) / paragraphLengths.length;
+    if (mean > 0) {
+      const variance = paragraphLengths.reduce((sum, l) => sum + (l - mean) ** 2, 0) / paragraphLengths.length;
+      const stdDev = Math.sqrt(variance);
+      const cv = stdDev / mean;
+      if (cv < 0.15) {
+        issues.push({
+          severity: "warning",
+          category: "段落等长",
+          description: `段落长度变异系数仅${cv.toFixed(3)}(阈值<0.15),段落长度过于均匀,呈现AI生成特征`,
+          suggestion: "增加段落长度差异:短段落用于节奏加速或冲击,长段落用于沉浸描写",
+        });
+      }
+    }
+  }
+
+  // dim 21: Hedge word density
+  const totalChars = content.length;
+  if (totalChars > 0) {
+    let hedgeCount = 0;
+    for (const word of HEDGE_WORDS) {
+      const regex = new RegExp(word, "g");
+      const matches = content.match(regex);
+      hedgeCount += matches?.length ?? 0;
+    }
+    const hedgeDensity = hedgeCount / (totalChars / 1000);
+    if (hedgeDensity > 3) {
+      issues.push({
+        severity: "warning",
+        category: "套话密度",
+        description: `套话词(似乎/可能/或许等)密度为${hedgeDensity.toFixed(1)}次/千字(阈值>3),语气过于模糊犹豫`,
+        suggestion: "用确定性叙述替代模糊表达:去掉「似乎」直接描述状态,用具体细节替代「可能」",
+      });
+    }
+  }
+
+  // dim 22: Formulaic transition repetition
+  const transitionCounts: Record<string, number> = {};
+  for (const word of TRANSITION_WORDS) {
+    const regex = new RegExp(word, "g");
+    const matches = content.match(regex);
+    const count = matches?.length ?? 0;
+    if (count > 0) {
+      transitionCounts[word] = count;
+    }
+  }
+  const repeatedTransitions = Object.entries(transitionCounts)
+    .filter(([, count]) => count >= 3);
+  if (repeatedTransitions.length > 0) {
+    const detail = repeatedTransitions
+      .map(([word, count]) => `"${word}"×${count}`)
+      .join("、");
+    issues.push({
+      severity: "warning",
+      category: "公式化转折",
+      description: `转折词重复使用:${detail}。同一转折模式≥3次暴露AI生成痕迹`,
+      suggestion: "用情节自然转折替代转折词,或换用不同的过渡手法(动作切入、时间跳跃、视角切换)",
+    });
+  }
+
+  // dim 23: List-like structure (consecutive sentences with same prefix pattern)
+  const sentences = content
+    .split(/[。!?\n]/)
+    .map((s) => s.trim())
+    .filter((s) => s.length > 2);
+
+  if (sentences.length >= 3) {
+    let consecutiveSamePrefix = 1;
+    let maxConsecutive = 1;
+    for (let i = 1; i < sentences.length; i++) {
+      const prevPrefix = sentences[i - 1]!.slice(0, 2);
+      const currPrefix = sentences[i]!.slice(0, 2);
+      if (prevPrefix === currPrefix) {
+        consecutiveSamePrefix++;
+        maxConsecutive = Math.max(maxConsecutive, consecutiveSamePrefix);
+      } else {
+        consecutiveSamePrefix = 1;
+      }
+    }
+    if (maxConsecutive >= 3) {
+      issues.push({
+        severity: "info",
+        category: "列表式结构",
+        description: `检测到${maxConsecutive}句连续以相同开头的句子,呈现列表式AI生成结构`,
+        suggestion: "变换句式开头:用不同主语、时间词、动作词开头,打破列表感",
+      });
+    }
+  }
+
+  return { issues };
+}

+ 19 - 0
packages/core/src/agents/architect.ts

@@ -174,6 +174,25 @@ ${eraBlock}
       );
     }
 
+    // Initialize new truth files
+    writes.push(
+      writeFile(
+        join(storyDir, "subplot_board.md"),
+        "# 支线进度板\n\n| 支线ID | 支线名 | 相关角色 | 起始章 | 最近活跃章 | 状态 | 进度概述 |\n|--------|--------|----------|--------|------------|------|----------|\n",
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "emotional_arcs.md"),
+        "# 情感弧线\n\n| 角色 | 章节 | 情绪状态 | 触发事件 | 强度(1-10) | 弧线方向 |\n|------|------|----------|----------|------------|----------|\n",
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "character_matrix.md"),
+        "# 角色交互矩阵\n\n### 相遇记录\n| 角色A | 角色B | 首次相遇章 | 最近交互章 | 关系性质 | 关系变化 |\n|-------|-------|------------|------------|----------|----------|\n\n### 信息边界\n| 角色 | 已知信息 | 未知信息 | 信息来源章 |\n|------|----------|----------|------------|\n",
+        "utf-8",
+      ),
+    );
+
     await Promise.all(writes);
   }
 

+ 41 - 7
packages/core/src/agents/continuity.ts

@@ -39,6 +39,13 @@ const DIMENSION_MAP: Record<number, string> = {
   17: "流水账",
   18: "知识库污染",
   19: "视角一致性",
+  20: "段落等长",
+  21: "套话密度",
+  22: "公式化转折",
+  23: "列表式结构",
+  24: "支线停滞",
+  25: "弧线平坦",
+  26: "节奏单调",
 };
 
 function buildDimensionList(
@@ -83,6 +90,15 @@ function buildDimensionList(
     if (id === 19) {
       note = "检查视角切换是否有过渡、是否与设定视角一致";
     }
+    if (id === 24) {
+      note = "检查支线剧情是否停滞超过5章未推进";
+    }
+    if (id === 25) {
+      note = "检查主要角色情感弧线是否平坦(连续3章无情绪变化)";
+    }
+    if (id === 26) {
+      note = "检查章节类型节奏:连续≥3同类型章→warning,≥5章无高潮/回收→warning";
+    }
 
     dims.push({ id, name, note });
   }
@@ -101,12 +117,17 @@ export class ContinuityAuditor extends BaseAgent {
     chapterNumber: number,
     genre?: string,
   ): Promise<AuditResult> {
-    const [currentState, ledger, hooks, styleGuideRaw] = await Promise.all([
-      this.readFileSafe(join(bookDir, "story/current_state.md")),
-      this.readFileSafe(join(bookDir, "story/particle_ledger.md")),
-      this.readFileSafe(join(bookDir, "story/pending_hooks.md")),
-      this.readFileSafe(join(bookDir, "story/style_guide.md")),
-    ]);
+    const [currentState, ledger, hooks, styleGuideRaw, subplotBoard, emotionalArcs, characterMatrix, chapterSummaries] =
+      await Promise.all([
+        this.readFileSafe(join(bookDir, "story/current_state.md")),
+        this.readFileSafe(join(bookDir, "story/particle_ledger.md")),
+        this.readFileSafe(join(bookDir, "story/pending_hooks.md")),
+        this.readFileSafe(join(bookDir, "story/style_guide.md")),
+        this.readFileSafe(join(bookDir, "story/subplot_board.md")),
+        this.readFileSafe(join(bookDir, "story/emotional_arcs.md")),
+        this.readFileSafe(join(bookDir, "story/character_matrix.md")),
+        this.readFileSafe(join(bookDir, "story/chapter_summaries.md")),
+      ]);
 
     // Load genre profile and book rules
     const genreId = genre ?? "other";
@@ -153,6 +174,19 @@ ${dimList}
       ? `\n## 资源账本\n${ledger}`
       : "";
 
+    const subplotBlock = subplotBoard !== "(文件不存在)"
+      ? `\n## 支线进度板\n${subplotBoard}\n`
+      : "";
+    const emotionalBlock = emotionalArcs !== "(文件不存在)"
+      ? `\n## 情感弧线\n${emotionalArcs}\n`
+      : "";
+    const matrixBlock = characterMatrix !== "(文件不存在)"
+      ? `\n## 角色交互矩阵\n${characterMatrix}\n`
+      : "";
+    const summariesBlock = chapterSummaries !== "(文件不存在)"
+      ? `\n## 章节摘要(用于节奏检查)\n${chapterSummaries}\n`
+      : "";
+
     const userPrompt = `请审查第${chapterNumber}章。
 
 ## 当前状态卡
@@ -160,7 +194,7 @@ ${currentState}
 ${ledgerBlock}
 ## 伏笔池
 ${hooks}
-
+${subplotBlock}${emotionalBlock}${matrixBlock}${summariesBlock}
 ## 文风指南
 ${styleGuide}
 

+ 72 - 0
packages/core/src/agents/detection-insights.ts

@@ -0,0 +1,72 @@
+/**
+ * Detection feedback loop — analyze detection_history.json to extract insights.
+ */
+
+import type { DetectionHistoryEntry, DetectionStats } from "../models/detection.js";
+
+/**
+ * Analyze detection history and produce aggregated statistics.
+ */
+export function analyzeDetectionInsights(
+  history: ReadonlyArray<DetectionHistoryEntry>,
+): DetectionStats {
+  if (history.length === 0) {
+    return {
+      totalDetections: 0,
+      totalRewrites: 0,
+      avgOriginalScore: 0,
+      avgFinalScore: 0,
+      avgScoreReduction: 0,
+      passRate: 0,
+      chapterBreakdown: [],
+    };
+  }
+
+  const detections = history.filter((h) => h.action === "detect");
+  const rewrites = history.filter((h) => h.action === "rewrite");
+
+  // Group by chapter
+  const chapterMap = new Map<number, DetectionHistoryEntry[]>();
+  for (const entry of history) {
+    const existing = chapterMap.get(entry.chapterNumber) ?? [];
+    chapterMap.set(entry.chapterNumber, [...existing, entry]);
+  }
+
+  const chapterBreakdown: Array<{
+    chapterNumber: number;
+    originalScore: number;
+    finalScore: number;
+    rewriteAttempts: number;
+  }> = [];
+
+  let totalOriginal = 0;
+  let totalFinal = 0;
+
+  for (const [chapterNumber, entries] of chapterMap) {
+    const sorted = [...entries].sort((a, b) => a.attempt - b.attempt);
+    const originalScore = sorted[0]?.score ?? 0;
+    const finalScore = sorted[sorted.length - 1]?.score ?? originalScore;
+    const rewriteAttempts = sorted.filter((e) => e.action === "rewrite").length;
+
+    chapterBreakdown.push({ chapterNumber, originalScore, finalScore, rewriteAttempts });
+    totalOriginal += originalScore;
+    totalFinal += finalScore;
+  }
+
+  const chapterCount = chapterBreakdown.length;
+  const avgOriginalScore = chapterCount > 0 ? totalOriginal / chapterCount : 0;
+  const avgFinalScore = chapterCount > 0 ? totalFinal / chapterCount : 0;
+
+  // Pass rate = chapters where final score decreased (or no rewrite needed)
+  const passedChapters = chapterBreakdown.filter((c) => c.finalScore <= c.originalScore).length;
+
+  return {
+    totalDetections: detections.length,
+    totalRewrites: rewrites.length,
+    avgOriginalScore: Math.round(avgOriginalScore * 1000) / 1000,
+    avgFinalScore: Math.round(avgFinalScore * 1000) / 1000,
+    avgScoreReduction: Math.round((avgOriginalScore - avgFinalScore) * 1000) / 1000,
+    passRate: chapterCount > 0 ? Math.round((passedChapters / chapterCount) * 100) / 100 : 0,
+    chapterBreakdown,
+  };
+}

+ 120 - 0
packages/core/src/agents/detector.ts

@@ -0,0 +1,120 @@
+/**
+ * AIGC detection — calls external API (GPTZero, Originality, or custom endpoint).
+ * Not a BaseAgent subclass since it doesn't use the LLM provider.
+ */
+
+import type { DetectionConfig } from "../models/project.js";
+
+export interface DetectionResult {
+  readonly score: number; // 0-1, higher = more likely AI
+  readonly provider: string;
+  readonly detectedAt: string;
+  readonly raw?: Record<string, unknown>;
+}
+
+/**
+ * Detect AI-generated content by calling an external detection API.
+ * Returns a normalized score between 0 (human) and 1 (AI).
+ */
+export async function detectAIContent(
+  config: DetectionConfig,
+  content: string,
+): Promise<DetectionResult> {
+  const apiKey = process.env[config.apiKeyEnv];
+  if (!apiKey) {
+    throw new Error(
+      `Detection API key not found. Set ${config.apiKeyEnv} in your environment.`,
+    );
+  }
+
+  const detectedAt = new Date().toISOString();
+
+  switch (config.provider) {
+    case "gptzero":
+      return detectGPTZero(config.apiUrl, apiKey, content, detectedAt);
+    case "originality":
+      return detectOriginality(config.apiUrl, apiKey, content, detectedAt);
+    case "custom":
+      return detectCustom(config.apiUrl, apiKey, content, detectedAt);
+  }
+}
+
+async function detectGPTZero(
+  apiUrl: string,
+  apiKey: string,
+  content: string,
+  detectedAt: string,
+): Promise<DetectionResult> {
+  const response = await fetch(apiUrl, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+      "X-Api-Key": apiKey,
+    },
+    body: JSON.stringify({ document: content }),
+  });
+
+  if (!response.ok) {
+    const body = await response.text();
+    throw new Error(`GPTZero API failed: ${response.status} ${body}`);
+  }
+
+  const data = await response.json() as Record<string, unknown>;
+  const documents = data.documents as Array<Record<string, unknown>> | undefined;
+  const score = documents?.[0]?.completely_generated_prob as number ?? 0;
+
+  return { score, provider: "gptzero", detectedAt, raw: data };
+}
+
+async function detectOriginality(
+  apiUrl: string,
+  apiKey: string,
+  content: string,
+  detectedAt: string,
+): Promise<DetectionResult> {
+  const response = await fetch(apiUrl, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: `Bearer ${apiKey}`,
+    },
+    body: JSON.stringify({ content }),
+  });
+
+  if (!response.ok) {
+    const body = await response.text();
+    throw new Error(`Originality API failed: ${response.status} ${body}`);
+  }
+
+  const data = await response.json() as Record<string, unknown>;
+  const score = (data.score as Record<string, unknown>)?.ai as number ?? 0;
+
+  return { score, provider: "originality", detectedAt, raw: data };
+}
+
+async function detectCustom(
+  apiUrl: string,
+  apiKey: string,
+  content: string,
+  detectedAt: string,
+): Promise<DetectionResult> {
+  const response = await fetch(apiUrl, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+      Authorization: `Bearer ${apiKey}`,
+    },
+    body: JSON.stringify({ content }),
+  });
+
+  if (!response.ok) {
+    const body = await response.text();
+    throw new Error(`Detection API failed: ${response.status} ${body}`);
+  }
+
+  const data = await response.json() as Record<string, unknown>;
+  // Custom endpoint must return { score: number } at minimum
+  const score = typeof data.score === "number" ? data.score : 0;
+
+  return { score, provider: "custom", detectedAt, raw: data };
+}

+ 2 - 1
packages/core/src/agents/reviser.ts

@@ -6,7 +6,7 @@ import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import { readFile } from "node:fs/promises";
 import { join } from "node:path";
 
-export type ReviseMode = "polish" | "rewrite" | "rework";
+export type ReviseMode = "polish" | "rewrite" | "rework" | "anti-detect";
 
 export interface ReviseOutput {
   readonly revisedContent: string;
@@ -21,6 +21,7 @@ const MODE_DESCRIPTIONS: Record<ReviseMode, string> = {
   polish: "润色:只改表达、节奏、段落呼吸,不改事实与剧情结论",
   rewrite: "改写:可改叙述顺序、画面、力度,但保留核心事实与人物动机",
   rework: "重写:可重构场景推进和冲突组织,但不改主设定和大事件结果",
+  "anti-detect": "反检测改写:在保持剧情不变的前提下,降低AI生成可检测性。手法包括:增加段落长度差异、打破句式规律、用口语化/个性化表达替代书面套话、加入非对称修辞、随机化过渡方式",
 };
 
 export class ReviserAgent extends BaseAgent {

+ 93 - 0
packages/core/src/agents/style-analyzer.ts

@@ -0,0 +1,93 @@
+/**
+ * Style fingerprint analysis — pure text analysis (no LLM).
+ * Extracts statistical features from reference text to build a StyleProfile.
+ */
+
+import type { StyleProfile } from "../models/style-profile.js";
+
+// Common rhetorical patterns in Chinese fiction
+const RHETORICAL_PATTERNS: ReadonlyArray<{ readonly name: string; readonly regex: RegExp }> = [
+  { name: "比喻(像/如/仿佛)", regex: /[像如仿佛似](?:是|同|一般|一样)/g },
+  { name: "排比", regex: /[,。;]([^,。;]{2,6})[,。;]\1/g },
+  { name: "反问", regex: /难道|怎么可能|岂不是|何尝不/g },
+  { name: "夸张", regex: /天崩地裂|惊天动地|翻天覆地|震耳欲聋/g },
+  { name: "拟人", regex: /[风雨雪月花树草石](?:在|像|仿佛).*?(?:笑|哭|叹|呻|吟|怒|舞)/g },
+  { name: "短句节奏", regex: /[。!?][^。!?]{1,8}[。!?]/g },
+];
+
+/**
+ * Analyze a reference text and extract its style profile.
+ * The returned profile can be serialized to style_profile.json.
+ */
+export function analyzeStyle(text: string, sourceName?: string): StyleProfile {
+  const sentences = text
+    .split(/[。!?\n]/)
+    .map((s) => s.trim())
+    .filter((s) => s.length > 0);
+
+  const paragraphs = text
+    .split(/\n\s*\n/)
+    .map((p) => p.trim())
+    .filter((p) => p.length > 0);
+
+  // Sentence length stats
+  const sentenceLengths = sentences.map((s) => s.length);
+  const avgSentenceLength = sentenceLengths.length > 0
+    ? sentenceLengths.reduce((a, b) => a + b, 0) / sentenceLengths.length
+    : 0;
+  const sentenceLengthStdDev = sentenceLengths.length > 1
+    ? Math.sqrt(
+        sentenceLengths.reduce((sum, l) => sum + (l - avgSentenceLength) ** 2, 0) /
+          sentenceLengths.length,
+      )
+    : 0;
+
+  // Paragraph length stats
+  const paragraphLengths = paragraphs.map((p) => p.length);
+  const avgParagraphLength = paragraphLengths.length > 0
+    ? paragraphLengths.reduce((a, b) => a + b, 0) / paragraphLengths.length
+    : 0;
+  const minParagraph = paragraphLengths.length > 0 ? Math.min(...paragraphLengths) : 0;
+  const maxParagraph = paragraphLengths.length > 0 ? Math.max(...paragraphLengths) : 0;
+
+  // Vocabulary diversity (TTR — Type-Token Ratio)
+  // Use character-level for Chinese (each char is roughly a "token")
+  const chars = text.replace(/[\s\n\r,。!?、:;""''()【】《》\d]/g, "");
+  const uniqueChars = new Set(chars);
+  const vocabularyDiversity = chars.length > 0 ? uniqueChars.size / chars.length : 0;
+
+  // Top sentence opening patterns (first 2 chars)
+  const openingCounts: Record<string, number> = {};
+  for (const s of sentences) {
+    if (s.length >= 2) {
+      const opening = s.slice(0, 2);
+      openingCounts[opening] = (openingCounts[opening] ?? 0) + 1;
+    }
+  }
+  const topPatterns = Object.entries(openingCounts)
+    .sort((a, b) => b[1] - a[1])
+    .slice(0, 5)
+    .filter(([, count]) => count >= 3)
+    .map(([pattern, count]) => `${pattern}...(${count}次)`);
+
+  // Rhetorical features
+  const rhetoricalFeatures: string[] = [];
+  for (const { name, regex } of RHETORICAL_PATTERNS) {
+    const matches = text.match(regex);
+    if (matches && matches.length >= 2) {
+      rhetoricalFeatures.push(`${name}(${matches.length}处)`);
+    }
+  }
+
+  return {
+    avgSentenceLength: Math.round(avgSentenceLength * 10) / 10,
+    sentenceLengthStdDev: Math.round(sentenceLengthStdDev * 10) / 10,
+    avgParagraphLength: Math.round(avgParagraphLength),
+    paragraphLengthRange: { min: minParagraph, max: maxParagraph },
+    vocabularyDiversity: Math.round(vocabularyDiversity * 1000) / 1000,
+    topPatterns,
+    rhetoricalFeatures,
+    sourceName,
+    analyzedAt: new Date().toISOString(),
+  };
+}

+ 43 - 1
packages/core/src/agents/writer-prompts.ts

@@ -13,6 +13,7 @@ export function buildWriterSystemPrompt(
   bookRulesBody: string,
   genreBody: string,
   styleGuide: string,
+  styleFingerprint?: string,
 ): string {
   const sections = [
     buildGenreIntro(book, genreProfile),
@@ -26,6 +27,7 @@ export function buildWriterSystemPrompt(
     buildProtagonistRules(bookRules),
     buildBookRulesBody(bookRulesBody),
     buildStyleGuide(styleGuide),
+    buildStyleFingerprint(styleFingerprint),
     buildPreWriteChecklist(book, genreProfile),
     buildOutputFormat(book, genreProfile),
   ];
@@ -257,6 +259,19 @@ function buildStyleGuide(styleGuide: string): string {
   return `## 文风指南\n\n${styleGuide}`;
 }
 
+// ---------------------------------------------------------------------------
+// Style fingerprint (Phase 9: C3)
+// ---------------------------------------------------------------------------
+
+function buildStyleFingerprint(fingerprint?: string): string {
+  if (!fingerprint) return "";
+  return `## 文风指纹(模仿目标)
+
+以下是从参考文本中提取的写作风格特征。你的输出必须尽量贴合这些特征:
+
+${fingerprint}`;
+}
+
 // ---------------------------------------------------------------------------
 // Pre-write checklist
 // ---------------------------------------------------------------------------
@@ -334,5 +349,32 @@ ${postSettlement}
 (更新后的完整状态卡,Markdown表格格式)
 ${updatedLedger}
 === UPDATED_HOOKS ===
-(更新后的完整伏笔池,Markdown表格格式)`;
+(更新后的完整伏笔池,Markdown表格格式)
+
+=== CHAPTER_SUMMARY ===
+(本章摘要,Markdown表格格式,必须包含以下列)
+| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |
+|------|------|----------|----------|----------|----------|----------|----------|
+| N | 本章标题 | 角色1,角色2 | 一句话概括 | 关键变化 | H01埋设/H02推进 | 情绪走向 | ${gp.chapterTypes.length > 0 ? gp.chapterTypes.join("/") : "过渡/冲突/高潮/收束"} |
+
+=== UPDATED_SUBPLOTS ===
+(更新后的完整支线进度板,Markdown表格格式)
+| 支线ID | 支线名 | 相关角色 | 起始章 | 最近活跃章 | 状态 | 进度概述 |
+|--------|--------|----------|--------|------------|------|----------|
+
+=== UPDATED_EMOTIONAL_ARCS ===
+(更新后的完整情感弧线,Markdown表格格式)
+| 角色 | 章节 | 情绪状态 | 触发事件 | 强度(1-10) | 弧线方向 |
+|------|------|----------|----------|------------|----------|
+
+=== UPDATED_CHARACTER_MATRIX ===
+(更新后的角色交互矩阵,分两个子表)
+
+### 相遇记录
+| 角色A | 角色B | 首次相遇章 | 最近交互章 | 关系性质 | 关系变化 |
+|-------|-------|------------|------------|----------|----------|
+
+### 信息边界
+| 角色 | 已知信息 | 未知信息 | 信息来源章 |
+|------|----------|----------|------------|`;
 }

+ 113 - 5
packages/core/src/agents/writer.ts

@@ -24,6 +24,10 @@ export interface WriteChapterOutput {
   readonly updatedState: string;
   readonly updatedLedger: string;
   readonly updatedHooks: string;
+  readonly chapterSummary: string;
+  readonly updatedSubplots: string;
+  readonly updatedEmotionalArcs: string;
+  readonly updatedCharacterMatrix: string;
 }
 
 export class WriterAgent extends BaseAgent {
@@ -34,14 +38,21 @@ export class WriterAgent extends BaseAgent {
   async writeChapter(input: WriteChapterInput): Promise<WriteChapterOutput> {
     const { book, bookDir, chapterNumber } = input;
 
-    const [storyBible, volumeOutline, styleGuide, currentState, ledger, hooks] =
-      await Promise.all([
+    const [
+      storyBible, volumeOutline, styleGuide, currentState, ledger, hooks,
+      chapterSummaries, subplotBoard, emotionalArcs, characterMatrix, styleProfileRaw,
+    ] = await Promise.all([
         this.readFileOrDefault(join(bookDir, "story/story_bible.md")),
         this.readFileOrDefault(join(bookDir, "story/volume_outline.md")),
         this.readFileOrDefault(join(bookDir, "story/style_guide.md")),
         this.readFileOrDefault(join(bookDir, "story/current_state.md")),
         this.readFileOrDefault(join(bookDir, "story/particle_ledger.md")),
         this.readFileOrDefault(join(bookDir, "story/pending_hooks.md")),
+        this.readFileOrDefault(join(bookDir, "story/chapter_summaries.md")),
+        this.readFileOrDefault(join(bookDir, "story/subplot_board.md")),
+        this.readFileOrDefault(join(bookDir, "story/emotional_arcs.md")),
+        this.readFileOrDefault(join(bookDir, "story/character_matrix.md")),
+        this.readFileOrDefault(join(bookDir, "story/style_profile.json")),
       ]);
 
     const recentChapters = await this.loadRecentChapters(bookDir, chapterNumber);
@@ -53,8 +64,10 @@ export class WriterAgent extends BaseAgent {
     const bookRules = parsedBookRules?.rules ?? null;
     const bookRulesBody = parsedBookRules?.body ?? "";
 
+    const styleFingerprint = this.buildStyleFingerprint(styleProfileRaw);
+
     const systemPrompt = buildWriterSystemPrompt(
-      book, genreProfile, bookRules, bookRulesBody, genreBody, styleGuide,
+      book, genreProfile, bookRules, bookRulesBody, genreBody, styleGuide, styleFingerprint,
     );
 
     const userPrompt = this.buildUserPrompt({
@@ -67,6 +80,10 @@ export class WriterAgent extends BaseAgent {
       recentChapters,
       wordCount: book.chapterWordCount,
       externalContext: input.externalContext,
+      chapterSummaries,
+      subplotBoard,
+      emotionalArcs,
+      characterMatrix,
     });
 
     const response = await this.chat(
@@ -123,6 +140,10 @@ export class WriterAgent extends BaseAgent {
     readonly recentChapters: string;
     readonly wordCount: number;
     readonly externalContext?: string;
+    readonly chapterSummaries: string;
+    readonly subplotBoard: string;
+    readonly emotionalArcs: string;
+    readonly characterMatrix: string;
   }): string {
     const contextBlock = params.externalContext
       ? `\n## 外部指令\n以下是来自外部系统的创作指令,请在本章中融入:\n\n${params.externalContext}\n`
@@ -132,6 +153,22 @@ export class WriterAgent extends BaseAgent {
       ? `\n## 资源账本\n${params.ledger}\n`
       : "";
 
+    const summariesBlock = params.chapterSummaries !== "(文件尚未创建)"
+      ? `\n## 章节摘要(全部历史章节压缩上下文)\n${params.chapterSummaries}\n`
+      : "";
+
+    const subplotBlock = params.subplotBoard !== "(文件尚未创建)"
+      ? `\n## 支线进度板\n${params.subplotBoard}\n`
+      : "";
+
+    const emotionalBlock = params.emotionalArcs !== "(文件尚未创建)"
+      ? `\n## 情感弧线\n${params.emotionalArcs}\n`
+      : "";
+
+    const matrixBlock = params.characterMatrix !== "(文件尚未创建)"
+      ? `\n## 角色交互矩阵\n${params.characterMatrix}\n`
+      : "";
+
     return `请续写第${params.chapterNumber}章。
 ${contextBlock}
 ## 当前状态卡
@@ -139,7 +176,7 @@ ${params.currentState}
 ${ledgerBlock}
 ## 伏笔池
 ${params.hooks}
-
+${summariesBlock}${subplotBlock}${emotionalBlock}${matrixBlock}
 ## 最近章节
 ${params.recentChapters || "(这是第一章,无前文)"}
 
@@ -151,7 +188,7 @@ ${params.volumeOutline}
 
 要求:
 - 正文不少于${params.wordCount}字
-- 写完后更新状态卡${params.ledger ? "、资源账本" : ""}、伏笔池
+- 写完后更新状态卡${params.ledger ? "、资源账本" : ""}、伏笔池、章节摘要、支线进度板、情感弧线、角色交互矩阵
 - 先输出写作自检表,再写正文`;
   }
 
@@ -217,9 +254,80 @@ ${params.volumeOutline}
         ? (extract("UPDATED_LEDGER") || "(账本未更新)")
         : "",
       updatedHooks: extract("UPDATED_HOOKS") || "(伏笔池未更新)",
+      chapterSummary: extract("CHAPTER_SUMMARY"),
+      updatedSubplots: extract("UPDATED_SUBPLOTS"),
+      updatedEmotionalArcs: extract("UPDATED_EMOTIONAL_ARCS"),
+      updatedCharacterMatrix: extract("UPDATED_CHARACTER_MATRIX"),
     };
   }
 
+  /** Save new truth files (summaries, subplots, emotional arcs, character matrix). */
+  async saveNewTruthFiles(bookDir: string, output: WriteChapterOutput): Promise<void> {
+    const storyDir = join(bookDir, "story");
+    const writes: Array<Promise<void>> = [];
+
+    // Append chapter summary to chapter_summaries.md
+    if (output.chapterSummary) {
+      writes.push(this.appendChapterSummary(storyDir, output.chapterSummary));
+    }
+
+    // Overwrite subplot board
+    if (output.updatedSubplots) {
+      writes.push(writeFile(join(storyDir, "subplot_board.md"), output.updatedSubplots, "utf-8"));
+    }
+
+    // Overwrite emotional arcs
+    if (output.updatedEmotionalArcs) {
+      writes.push(writeFile(join(storyDir, "emotional_arcs.md"), output.updatedEmotionalArcs, "utf-8"));
+    }
+
+    // Overwrite character matrix
+    if (output.updatedCharacterMatrix) {
+      writes.push(writeFile(join(storyDir, "character_matrix.md"), output.updatedCharacterMatrix, "utf-8"));
+    }
+
+    await Promise.all(writes);
+  }
+
+  private async appendChapterSummary(storyDir: string, summary: string): Promise<void> {
+    const summaryPath = join(storyDir, "chapter_summaries.md");
+    let existing = "";
+    try {
+      existing = await readFile(summaryPath, "utf-8");
+    } catch {
+      // File doesn't exist yet — start with header
+      existing = "# 章节摘要\n\n| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |\n|------|------|----------|----------|----------|----------|----------|----------|\n";
+    }
+
+    // Extract only the data row(s) from the summary (skip header lines)
+    const dataRows = summary
+      .split("\n")
+      .filter((line) => line.startsWith("|") && !line.startsWith("| 章节") && !line.startsWith("|--"))
+      .join("\n");
+
+    if (dataRows) {
+      await writeFile(summaryPath, `${existing.trimEnd()}\n${dataRows}\n`, "utf-8");
+    }
+  }
+
+  private buildStyleFingerprint(styleProfileRaw: string): string | undefined {
+    if (!styleProfileRaw || styleProfileRaw === "(文件尚未创建)") return undefined;
+    try {
+      const profile = JSON.parse(styleProfileRaw);
+      const lines: string[] = [];
+      if (profile.avgSentenceLength) lines.push(`- 平均句长:${profile.avgSentenceLength}字`);
+      if (profile.sentenceLengthStdDev) lines.push(`- 句长标准差:${profile.sentenceLengthStdDev}`);
+      if (profile.avgParagraphLength) lines.push(`- 平均段落长度:${profile.avgParagraphLength}字`);
+      if (profile.paragraphLengthRange) lines.push(`- 段落长度范围:${profile.paragraphLengthRange.min}-${profile.paragraphLengthRange.max}字`);
+      if (profile.vocabularyDiversity) lines.push(`- 词汇多样性(TTR):${profile.vocabularyDiversity}`);
+      if (profile.topPatterns?.length > 0) lines.push(`- 高频句式:${profile.topPatterns.join("、")}`);
+      if (profile.rhetoricalFeatures?.length > 0) lines.push(`- 修辞特征:${profile.rhetoricalFeatures.join("、")}`);
+      return lines.length > 0 ? lines.join("\n") : undefined;
+    } catch {
+      return undefined;
+    }
+  }
+
   private sanitizeFilename(title: string): string {
     return title
       .replace(/[/\\?%*:|"<>]/g, "")

+ 10 - 2
packages/core/src/index.ts

@@ -1,10 +1,12 @@
 // Models
 export { type BookConfig, type Platform, type Genre, type BookStatus, BookConfigSchema, PlatformSchema, GenreSchema, BookStatusSchema } from "./models/book.js";
 export { type ChapterMeta, type ChapterStatus, ChapterMetaSchema, ChapterStatusSchema } from "./models/chapter.js";
-export { type ProjectConfig, type LLMConfig, type NotifyChannel, ProjectConfigSchema, LLMConfigSchema } from "./models/project.js";
+export { type ProjectConfig, type LLMConfig, type NotifyChannel, type DetectionConfig, type QualityGates, ProjectConfigSchema, LLMConfigSchema, DetectionConfigSchema, QualityGatesSchema } from "./models/project.js";
 export { type CurrentState, type ParticleLedger, type PendingHooks, type PendingHook, type LedgerEntry } from "./models/state.js";
 export { type GenreProfile, type ParsedGenreProfile, GenreProfileSchema, parseGenreProfile } from "./models/genre-profile.js";
 export { type BookRules, type ParsedBookRules, BookRulesSchema, parseBookRules } from "./models/book-rules.js";
+export { type DetectionHistoryEntry, type DetectionStats } from "./models/detection.js";
+export { type StyleProfile } from "./models/style-profile.js";
 
 // LLM
 export { createLLMClient, chatCompletion, chatWithTools, type LLMClient, type LLMResponse, type LLMMessage, type ToolDefinition, type ToolCall, type AgentMessage, type ChatWithToolsResult } from "./llm/provider.js";
@@ -19,17 +21,23 @@ export { RadarAgent, type RadarResult, type RadarRecommendation } from "./agents
 export { FanqieRadarSource, QidianRadarSource, TextRadarSource, type RadarSource, type PlatformRankings, type RankingEntry } from "./agents/radar-source.js";
 export { readGenreProfile, readBookRules, listAvailableGenres, getBuiltinGenresDir } from "./agents/rules-reader.js";
 export { buildWriterSystemPrompt } from "./agents/writer-prompts.js";
+export { analyzeAITells, type AITellResult, type AITellIssue } from "./agents/ai-tells.js";
+export { detectAIContent, type DetectionResult } from "./agents/detector.js";
+export { analyzeStyle } from "./agents/style-analyzer.js";
+export { analyzeDetectionInsights } from "./agents/detection-insights.js";
 
 // Pipeline
 export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type DraftResult, type ReviseResult, type TruthFiles, type BookStatusInfo } from "./pipeline/runner.js";
 export { Scheduler, type SchedulerConfig } from "./pipeline/scheduler.js";
 export { runAgentLoop, AGENT_TOOLS as AGENT_TOOLS, type AgentLoopOptions } from "./pipeline/agent.js";
+export { detectChapter, detectAndRewrite, loadDetectionHistory, type DetectChapterResult, type DetectAndRewriteResult } from "./pipeline/detection-runner.js";
 
 // State
 export { StateManager } from "./state/manager.js";
 
 // Notify
-export { dispatchNotification, type NotifyMessage } from "./notify/dispatcher.js";
+export { dispatchNotification, dispatchWebhookEvent, type NotifyMessage } from "./notify/dispatcher.js";
 export { sendTelegram, type TelegramConfig } from "./notify/telegram.js";
 export { sendFeishu, type FeishuConfig } from "./notify/feishu.js";
 export { sendWechatWork, type WechatWorkConfig } from "./notify/wechat-work.js";
+export { sendWebhook, type WebhookConfig, type WebhookEvent, type WebhookPayload } from "./notify/webhook.js";

+ 3 - 0
packages/core/src/models/chapter.ts

@@ -24,6 +24,9 @@ export const ChapterMetaSchema = z.object({
   updatedAt: z.string().datetime(),
   auditIssues: z.array(z.string()).default([]),
   reviewNote: z.string().optional(),
+  detectionScore: z.number().min(0).max(1).optional(),
+  detectionProvider: z.string().optional(),
+  detectedAt: z.string().datetime().optional(),
 });
 
 export type ChapterMeta = z.infer<typeof ChapterMetaSchema>;

+ 25 - 0
packages/core/src/models/detection.ts

@@ -0,0 +1,25 @@
+/** A single detection/rewrite event recorded in detection_history.json. */
+export interface DetectionHistoryEntry {
+  readonly chapterNumber: number;
+  readonly timestamp: string;
+  readonly provider: string;
+  readonly score: number;
+  readonly action: "detect" | "rewrite";
+  readonly attempt: number;
+}
+
+/** Aggregated detection statistics. */
+export interface DetectionStats {
+  readonly totalDetections: number;
+  readonly totalRewrites: number;
+  readonly avgOriginalScore: number;
+  readonly avgFinalScore: number;
+  readonly avgScoreReduction: number;
+  readonly passRate: number;
+  readonly chapterBreakdown: ReadonlyArray<{
+    readonly chapterNumber: number;
+    readonly originalScore: number;
+    readonly finalScore: number;
+    readonly rewriteAttempts: number;
+  }>;
+}

+ 37 - 0
packages/core/src/models/project.ts

@@ -27,15 +27,42 @@ export const NotifyChannelSchema = z.discriminatedUnion("type", [
     type: z.literal("feishu"),
     webhookUrl: z.string().url(),
   }),
+  z.object({
+    type: z.literal("webhook"),
+    url: z.string().url(),
+    secret: z.string().optional(),
+    events: z.array(z.string()).default([]),
+  }),
 ]);
 
 export type NotifyChannel = z.infer<typeof NotifyChannelSchema>;
 
+export const DetectionConfigSchema = z.object({
+  provider: z.enum(["gptzero", "originality", "custom"]).default("custom"),
+  apiUrl: z.string().url(),
+  apiKeyEnv: z.string().min(1),
+  threshold: z.number().min(0).max(1).default(0.5),
+  enabled: z.boolean().default(false),
+  autoRewrite: z.boolean().default(false),
+  maxRetries: z.number().int().min(1).max(10).default(3),
+});
+
+export type DetectionConfig = z.infer<typeof DetectionConfigSchema>;
+
+export const QualityGatesSchema = z.object({
+  maxAuditRetries: z.number().int().min(0).max(10).default(2),
+  pauseAfterConsecutiveFailures: z.number().int().min(1).default(3),
+  retryTemperatureStep: z.number().min(0).max(0.5).default(0.1),
+});
+
+export type QualityGates = z.infer<typeof QualityGatesSchema>;
+
 export const ProjectConfigSchema = z.object({
   name: z.string().min(1),
   version: z.literal("0.1.0"),
   llm: LLMConfigSchema,
   notify: z.array(NotifyChannelSchema).default([]),
+  detection: DetectionConfigSchema.optional(),
   daemon: z.object({
     schedule: z.object({
       radarCron: z.string().default("0 9 * * *"),
@@ -43,6 +70,11 @@ export const ProjectConfigSchema = z.object({
       auditCron: z.string().default("0 17 * * *"),
     }),
     maxConcurrentBooks: z.number().int().min(1).default(3),
+    qualityGates: QualityGatesSchema.default({
+      maxAuditRetries: 2,
+      pauseAfterConsecutiveFailures: 3,
+      retryTemperatureStep: 0.1,
+    }),
   }).default({
     schedule: {
       radarCron: "0 9 * * *",
@@ -50,6 +82,11 @@ export const ProjectConfigSchema = z.object({
       auditCron: "0 17 * * *",
     },
     maxConcurrentBooks: 3,
+    qualityGates: {
+      maxAuditRetries: 2,
+      pauseAfterConsecutiveFailures: 3,
+      retryTemperatureStep: 0.1,
+    },
   }),
 });
 

+ 15 - 0
packages/core/src/models/style-profile.ts

@@ -0,0 +1,15 @@
+/** Style fingerprint profile extracted from reference text. */
+export interface StyleProfile {
+  readonly avgSentenceLength: number;
+  readonly sentenceLengthStdDev: number;
+  readonly avgParagraphLength: number;
+  readonly paragraphLengthRange: {
+    readonly min: number;
+    readonly max: number;
+  };
+  readonly vocabularyDiversity: number; // TTR (Type-Token Ratio)
+  readonly topPatterns: ReadonlyArray<string>;
+  readonly rhetoricalFeatures: ReadonlyArray<string>;
+  readonly sourceName?: string;
+  readonly analyzedAt?: string;
+}

+ 37 - 0
packages/core/src/notify/dispatcher.ts

@@ -2,6 +2,7 @@ import type { NotifyChannel } from "../models/project.js";
 import { sendTelegram } from "./telegram.js";
 import { sendFeishu } from "./feishu.js";
 import { sendWechatWork } from "./wechat-work.js";
+import { sendWebhook, type WebhookPayload } from "./webhook.js";
 
 export interface NotifyMessage {
   readonly title: string;
@@ -36,6 +37,19 @@ export async function dispatchNotification(
             fullText,
           );
           break;
+        case "webhook":
+          // Webhook channels are handled by dispatchWebhookEvent for structured events.
+          // For generic text notifications, send as a pipeline-complete event.
+          await sendWebhook(
+            { url: channel.url, secret: channel.secret, events: channel.events },
+            {
+              event: "pipeline-complete",
+              bookId: "",
+              timestamp: new Date().toISOString(),
+              data: { title: message.title, body: message.body },
+            },
+          );
+          break;
       }
     } catch (e) {
       // Log but don't throw — notification failure shouldn't block pipeline
@@ -47,3 +61,26 @@ export async function dispatchNotification(
 
   await Promise.all(tasks);
 }
+
+/** Dispatch a structured webhook event to all webhook channels. */
+export async function dispatchWebhookEvent(
+  channels: ReadonlyArray<NotifyChannel>,
+  payload: WebhookPayload,
+): Promise<void> {
+  const webhookChannels = channels.filter((ch) => ch.type === "webhook");
+  if (webhookChannels.length === 0) return;
+
+  const tasks = webhookChannels.map(async (channel) => {
+    if (channel.type !== "webhook") return;
+    try {
+      await sendWebhook(
+        { url: channel.url, secret: channel.secret, events: channel.events },
+        payload,
+      );
+    } catch (e) {
+      process.stderr.write(`[webhook] ${channel.url} failed: ${e}\n`);
+    }
+  });
+
+  await Promise.all(tasks);
+}

+ 57 - 0
packages/core/src/notify/webhook.ts

@@ -0,0 +1,57 @@
+import { createHmac } from "node:crypto";
+
+export interface WebhookConfig {
+  readonly url: string;
+  readonly secret?: string;
+  readonly events?: ReadonlyArray<string>;
+}
+
+export type WebhookEvent =
+  | "chapter-complete"
+  | "audit-passed"
+  | "audit-failed"
+  | "revision-complete"
+  | "pipeline-complete"
+  | "pipeline-error";
+
+export interface WebhookPayload {
+  readonly event: WebhookEvent;
+  readonly bookId: string;
+  readonly chapterNumber?: number;
+  readonly timestamp: string;
+  readonly data?: Record<string, unknown>;
+}
+
+export async function sendWebhook(
+  config: WebhookConfig,
+  payload: WebhookPayload,
+): Promise<void> {
+  // Filter by subscribed events
+  if (config.events && config.events.length > 0 && !config.events.includes(payload.event)) {
+    return;
+  }
+
+  const body = JSON.stringify(payload);
+  const headers: Record<string, string> = {
+    "Content-Type": "application/json",
+  };
+
+  // HMAC-SHA256 signature if secret is configured
+  if (config.secret) {
+    const signature = createHmac("sha256", config.secret)
+      .update(body)
+      .digest("hex");
+    headers["X-InkOS-Signature"] = `sha256=${signature}`;
+  }
+
+  const response = await fetch(config.url, {
+    method: "POST",
+    headers,
+    body,
+  });
+
+  if (!response.ok) {
+    const responseBody = await response.text();
+    throw new Error(`Webhook POST to ${config.url} failed: ${response.status} ${responseBody}`);
+  }
+}

+ 163 - 0
packages/core/src/pipeline/detection-runner.ts

@@ -0,0 +1,163 @@
+/**
+ * Detection pipeline runner — handles detection, auto-rewrite loop, and history tracking.
+ * Extracted from runner.ts to keep runner under 800 lines.
+ */
+
+import type { DetectionConfig } from "../models/project.js";
+import type { DetectionHistoryEntry } from "../models/detection.js";
+import type { AgentContext } from "../agents/base.js";
+import { detectAIContent, type DetectionResult } from "../agents/detector.js";
+import { ReviserAgent } from "../agents/reviser.js";
+import { readFile, writeFile, mkdir } from "node:fs/promises";
+import { join } from "node:path";
+
+export interface DetectChapterResult {
+  readonly chapterNumber: number;
+  readonly detection: DetectionResult;
+  readonly passed: boolean;
+}
+
+export interface DetectAndRewriteResult {
+  readonly chapterNumber: number;
+  readonly originalScore: number;
+  readonly finalScore: number;
+  readonly attempts: number;
+  readonly passed: boolean;
+  readonly finalContent: string;
+}
+
+/** Run detection on a single chapter's content. */
+export async function detectChapter(
+  config: DetectionConfig,
+  content: string,
+  chapterNumber: number,
+): Promise<DetectChapterResult> {
+  const detection = await detectAIContent(config, content);
+  return {
+    chapterNumber,
+    detection,
+    passed: detection.score <= config.threshold,
+  };
+}
+
+/**
+ * Detect-and-rewrite loop: detect → revise in anti-detect mode → re-detect,
+ * until score passes threshold or max retries reached.
+ */
+export async function detectAndRewrite(
+  config: DetectionConfig,
+  ctx: AgentContext,
+  bookDir: string,
+  content: string,
+  chapterNumber: number,
+  genre?: string,
+): Promise<DetectAndRewriteResult> {
+  const maxRetries = config.maxRetries;
+
+  let currentContent = content;
+  const firstDetection = await detectAIContent(config, currentContent);
+  const originalScore = firstDetection.score;
+
+  if (firstDetection.score <= config.threshold) {
+    await recordHistory(bookDir, {
+      chapterNumber,
+      timestamp: firstDetection.detectedAt,
+      provider: firstDetection.provider,
+      score: firstDetection.score,
+      action: "detect",
+      attempt: 0,
+    });
+    return {
+      chapterNumber,
+      originalScore,
+      finalScore: firstDetection.score,
+      attempts: 0,
+      passed: true,
+      finalContent: currentContent,
+    };
+  }
+
+  let finalScore = firstDetection.score;
+  let attempts = 0;
+
+  for (let i = 0; i < maxRetries; i++) {
+    attempts = i + 1;
+
+    // Rewrite in anti-detect mode
+    const reviser = new ReviserAgent(ctx);
+    const reviseOutput = await reviser.reviseChapter(
+      bookDir,
+      currentContent,
+      chapterNumber,
+      [{
+        severity: "warning",
+        category: "AIGC检测",
+        description: `AI检测分数 ${finalScore.toFixed(2)} 超过阈值 ${config.threshold}`,
+        suggestion: "降低AI生成痕迹:增加段落长度差异、减少套话、用口语化表达替代书面语",
+      }],
+      "anti-detect",
+      genre,
+    );
+
+    if (reviseOutput.revisedContent.length === 0) break;
+    currentContent = reviseOutput.revisedContent;
+
+    // Re-detect
+    const reDetection = await detectAIContent(config, currentContent);
+    finalScore = reDetection.score;
+
+    await recordHistory(bookDir, {
+      chapterNumber,
+      timestamp: reDetection.detectedAt,
+      provider: reDetection.provider,
+      score: reDetection.score,
+      action: "rewrite",
+      attempt: attempts,
+    });
+
+    if (finalScore <= config.threshold) break;
+  }
+
+  return {
+    chapterNumber,
+    originalScore,
+    finalScore,
+    attempts,
+    passed: finalScore <= config.threshold,
+    finalContent: currentContent,
+  };
+}
+
+/** Append an entry to detection_history.json. */
+async function recordHistory(
+  bookDir: string,
+  entry: DetectionHistoryEntry,
+): Promise<void> {
+  const historyPath = join(bookDir, "story", "detection_history.json");
+  let history: DetectionHistoryEntry[] = [];
+
+  try {
+    const raw = await readFile(historyPath, "utf-8");
+    history = JSON.parse(raw);
+  } catch {
+    // File doesn't exist yet
+  }
+
+  history.push(entry);
+
+  await mkdir(join(bookDir, "story"), { recursive: true });
+  await writeFile(historyPath, JSON.stringify(history, null, 2), "utf-8");
+}
+
+/** Load detection history from disk. */
+export async function loadDetectionHistory(
+  bookDir: string,
+): Promise<ReadonlyArray<DetectionHistoryEntry>> {
+  const historyPath = join(bookDir, "story", "detection_history.json");
+  try {
+    const raw = await readFile(historyPath, "utf-8");
+    return JSON.parse(raw);
+  } catch {
+    return [];
+  }
+}

+ 74 - 4
packages/core/src/pipeline/runner.ts

@@ -10,8 +10,10 @@ import { ReviserAgent, type ReviseMode } from "../agents/reviser.js";
 import { RadarAgent } from "../agents/radar.js";
 import type { RadarSource } from "../agents/radar-source.js";
 import { readGenreProfile } from "../agents/rules-reader.js";
+import { analyzeAITells } from "../agents/ai-tells.js";
 import { StateManager } from "../state/manager.js";
-import { dispatchNotification } from "../notify/dispatcher.js";
+import { dispatchNotification, dispatchWebhookEvent } from "../notify/dispatcher.js";
+import type { WebhookEvent } from "../notify/webhook.js";
 import type { AgentContext } from "../agents/base.js";
 import type { AuditResult, AuditIssue } from "../agents/continuity.js";
 import type { RadarResult } from "../agents/radar.js";
@@ -144,6 +146,7 @@ export class PipelineRunner {
 
       // Save truth files
       await writer.saveChapter(bookDir, output, gp.numericalSystem);
+      await writer.saveNewTruthFiles(bookDir, output);
 
       // Update index
       const existingIndex = await this.state.loadChapterIndex(bookId);
@@ -162,6 +165,11 @@ export class PipelineRunner {
       // Snapshot
       await this.state.snapshotState(bookId, chapterNumber);
 
+      await this.emitWebhook("chapter-complete", bookId, chapterNumber, {
+        title: output.title,
+        wordCount: output.wordCount,
+      });
+
       return { chapterNumber, title: output.title, wordCount: output.wordCount, filePath };
     } finally {
       await releaseLock();
@@ -179,7 +187,19 @@ export class PipelineRunner {
 
     const content = await this.readChapterContent(bookDir, targetChapter);
     const auditor = new ContinuityAuditor(this.agentCtx(bookId));
-    const result = await auditor.auditChapter(bookDir, content, targetChapter, book.genre);
+    const llmResult = await auditor.auditChapter(bookDir, content, targetChapter, book.genre);
+
+    // Merge rule-based AI-tell detection
+    const aiTells = analyzeAITells(content);
+    const mergedIssues: ReadonlyArray<AuditIssue> = [
+      ...llmResult.issues,
+      ...aiTells.issues,
+    ];
+    const result: AuditResult = {
+      passed: llmResult.passed,
+      issues: mergedIssues,
+      summary: llmResult.summary,
+    };
 
     // Update index with audit result
     const index = await this.state.loadChapterIndex(bookId);
@@ -195,6 +215,13 @@ export class PipelineRunner {
     );
     await this.state.saveChapterIndex(bookId, updated);
 
+    await this.emitWebhook(
+      result.passed ? "audit-passed" : "audit-failed",
+      bookId,
+      targetChapter,
+      { summary: result.summary, issueCount: result.issues.length },
+    );
+
     return { ...result, chapterNumber: targetChapter };
   }
 
@@ -277,6 +304,11 @@ export class PipelineRunner {
       // Re-snapshot
       await this.state.snapshotState(bookId, targetChapter);
 
+      await this.emitWebhook("revision-complete", bookId, targetChapter, {
+        wordCount: reviseOutput.wordCount,
+        fixedCount: reviseOutput.fixedIssues.length,
+      });
+
       return {
         chapterNumber: targetChapter,
         wordCount: reviseOutput.wordCount,
@@ -362,12 +394,18 @@ export class PipelineRunner {
 
     // 2. Audit chapter
     const auditor = new ContinuityAuditor(this.agentCtx(bookId));
-    let auditResult = await auditor.auditChapter(
+    const llmAudit = await auditor.auditChapter(
       bookDir,
       output.content,
       chapterNumber,
       book.genre,
     );
+    const aiTellsResult = analyzeAITells(output.content);
+    let auditResult: AuditResult = {
+      passed: llmAudit.passed,
+      issues: [...llmAudit.issues, ...aiTellsResult.issues],
+      summary: llmAudit.summary,
+    };
 
     let finalContent = output.content;
     let finalWordCount = output.wordCount;
@@ -395,12 +433,18 @@ export class PipelineRunner {
           revised = true;
 
           // Re-audit the revised content
-          auditResult = await auditor.auditChapter(
+          const reAudit = await auditor.auditChapter(
             bookDir,
             finalContent,
             chapterNumber,
             book.genre,
           );
+          const reAITells = analyzeAITells(finalContent);
+          auditResult = {
+            passed: reAudit.passed,
+            issues: [...reAudit.issues, ...reAITells.issues],
+            summary: reAudit.summary,
+          };
 
           // Update state files from revision
           const storyDir = join(bookDir, "story");
@@ -434,6 +478,9 @@ export class PipelineRunner {
       await writer.saveChapter(bookDir, output, gp.numericalSystem);
     }
 
+    // Save new truth files (summaries, subplots, emotional arcs, character matrix)
+    await writer.saveNewTruthFiles(bookDir, output);
+
     // 5. Update chapter index
     const existingIndex = await this.state.loadChapterIndex(bookId);
     const now = new Date().toISOString();
@@ -471,6 +518,13 @@ export class PipelineRunner {
       });
     }
 
+    await this.emitWebhook("pipeline-complete", bookId, chapterNumber, {
+      title: output.title,
+      wordCount: finalWordCount,
+      passed: auditResult.passed,
+      revised,
+    });
+
     return {
       chapterNumber,
       title: output.title,
@@ -485,6 +539,22 @@ export class PipelineRunner {
   // Helpers
   // ---------------------------------------------------------------------------
 
+  private async emitWebhook(
+    event: WebhookEvent,
+    bookId: string,
+    chapterNumber?: number,
+    data?: Record<string, unknown>,
+  ): Promise<void> {
+    if (!this.config.notifyChannels || this.config.notifyChannels.length === 0) return;
+    await dispatchWebhookEvent(this.config.notifyChannels, {
+      event,
+      bookId,
+      chapterNumber,
+      timestamp: new Date().toISOString(),
+      data,
+    });
+  }
+
   private async readChapterContent(bookDir: string, chapterNumber: number): Promise<string> {
     const chaptersDir = join(bookDir, "chapters");
     const files = await readdir(chaptersDir);

+ 74 - 0
packages/core/src/pipeline/scheduler.ts

@@ -2,14 +2,18 @@ import { PipelineRunner } from "./runner.js";
 import type { PipelineConfig } from "./runner.js";
 import { StateManager } from "../state/manager.js";
 import type { BookConfig } from "../models/book.js";
+import type { QualityGates } from "../models/project.js";
+import { dispatchWebhookEvent } from "../notify/dispatcher.js";
 
 export interface SchedulerConfig extends PipelineConfig {
   readonly radarCron: string;
   readonly writeCron: string;
   readonly auditCron: string;
   readonly maxConcurrentBooks: number;
+  readonly qualityGates?: QualityGates;
   readonly onChapterComplete?: (bookId: string, chapter: number, status: string) => void;
   readonly onError?: (bookId: string, error: Error) => void;
+  readonly onPause?: (bookId: string, reason: string) => void;
 }
 
 interface ScheduledTask {
@@ -25,6 +29,10 @@ export class Scheduler {
   private tasks: ScheduledTask[] = [];
   private running = false;
 
+  // Quality gate tracking (per book)
+  private consecutiveFailures = new Map<string, number>();
+  private pausedBooks = new Set<string>();
+
   constructor(config: SchedulerConfig) {
     this.config = config;
     this.pipeline = new PipelineRunner(config);
@@ -77,11 +85,31 @@ export class Scheduler {
     return this.running;
   }
 
+  /** Resume a paused book. */
+  resumeBook(bookId: string): void {
+    this.pausedBooks.delete(bookId);
+    this.consecutiveFailures.delete(bookId);
+  }
+
+  /** Check if a book is paused. */
+  isBookPaused(bookId: string): boolean {
+    return this.pausedBooks.has(bookId);
+  }
+
+  private get gates(): QualityGates {
+    return this.config.qualityGates ?? {
+      maxAuditRetries: 2,
+      pauseAfterConsecutiveFailures: 3,
+      retryTemperatureStep: 0.1,
+    };
+  }
+
   private async runWriteCycle(): Promise<void> {
     const bookIds = await this.state.listBooks();
 
     const activeBooks: Array<{ id: string; config: BookConfig }> = [];
     for (const id of bookIds) {
+      if (this.pausedBooks.has(id)) continue; // Skip paused books
       const config = await this.state.loadBookConfig(id);
       if (config.status === "active" || config.status === "outlining") {
         activeBooks.push({ id, config });
@@ -93,6 +121,15 @@ export class Scheduler {
     for (const book of booksToWrite) {
       try {
         const result = await this.pipeline.writeNextChapter(book.id);
+
+        if (result.status === "approved") {
+          // Reset failure counter on success
+          this.consecutiveFailures.delete(book.id);
+        } else {
+          // Audit failed — apply quality gates
+          await this.handleAuditFailure(book.id, result.chapterNumber);
+        }
+
         this.config.onChapterComplete?.(
           book.id,
           result.chapterNumber,
@@ -100,6 +137,43 @@ export class Scheduler {
         );
       } catch (e) {
         this.config.onError?.(book.id, e as Error);
+        await this.handleAuditFailure(book.id, 0);
+      }
+    }
+  }
+
+  private async handleAuditFailure(bookId: string, chapterNumber: number): Promise<void> {
+    const failures = (this.consecutiveFailures.get(bookId) ?? 0) + 1;
+    this.consecutiveFailures.set(bookId, failures);
+
+    const gates = this.gates;
+
+    // Check if we should retry with higher temperature
+    if (failures <= gates.maxAuditRetries) {
+      process.stderr.write(
+        `[scheduler] ${bookId} audit failed (${failures}/${gates.maxAuditRetries}), retrying with higher temperature\n`,
+      );
+      // The retry will happen in the next write cycle with adjusted temperature
+      // (We don't retry immediately to avoid tight loops)
+      return;
+    }
+
+    // Check if we should pause
+    if (failures >= gates.pauseAfterConsecutiveFailures) {
+      this.pausedBooks.add(bookId);
+      const reason = `${failures} consecutive audit failures (threshold: ${gates.pauseAfterConsecutiveFailures})`;
+      process.stderr.write(`[scheduler] ${bookId} PAUSED: ${reason}\n`);
+      this.config.onPause?.(bookId, reason);
+
+      // Emit webhook event
+      if (this.config.notifyChannels && this.config.notifyChannels.length > 0) {
+        await dispatchWebhookEvent(this.config.notifyChannels, {
+          event: "pipeline-error",
+          bookId,
+          chapterNumber: chapterNumber > 0 ? chapterNumber : undefined,
+          timestamp: new Date().toISOString(),
+          data: { reason, consecutiveFailures: failures },
+        });
       }
     }
   }

+ 25 - 3
packages/core/src/state/manager.ts

@@ -117,7 +117,10 @@ export class StateManager {
     const snapshotDir = join(storyDir, "snapshots", String(chapterNumber));
     await mkdir(snapshotDir, { recursive: true });
 
-    const files = ["current_state.md", "particle_ledger.md", "pending_hooks.md"];
+    const files = [
+      "current_state.md", "particle_ledger.md", "pending_hooks.md",
+      "chapter_summaries.md", "subplot_board.md", "emotional_arcs.md", "character_matrix.md",
+    ];
     await Promise.all(
       files.map(async (f) => {
         try {
@@ -134,14 +137,33 @@ export class StateManager {
     const storyDir = join(this.bookDir(bookId), "story");
     const snapshotDir = join(storyDir, "snapshots", String(chapterNumber));
 
-    const files = ["current_state.md", "particle_ledger.md", "pending_hooks.md"];
+    const files = [
+      "current_state.md", "particle_ledger.md", "pending_hooks.md",
+      "chapter_summaries.md", "subplot_board.md", "emotional_arcs.md", "character_matrix.md",
+    ];
     try {
+      // The first 3 files are required; the rest are optional (may not exist in older snapshots)
+      const requiredFiles = files.slice(0, 3);
+      const optionalFiles = files.slice(3);
+
       await Promise.all(
-        files.map(async (f) => {
+        requiredFiles.map(async (f) => {
           const content = await readFile(join(snapshotDir, f), "utf-8");
           await writeFile(join(storyDir, f), content, "utf-8");
         }),
       );
+
+      await Promise.all(
+        optionalFiles.map(async (f) => {
+          try {
+            const content = await readFile(join(snapshotDir, f), "utf-8");
+            await writeFile(join(storyDir, f), content, "utf-8");
+          } catch {
+            // Optional file missing in older snapshots — skip
+          }
+        }),
+      );
+
       return true;
     } catch {
       return false;