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

feat: tighten v2 hook governance and english variance control

Ma 7 месяцев назад
Родитель
Сommit
b4c2684127
27 измененных файлов с 1804 добавлено и 14 удалено
  1. 46 0
      packages/core/src/__tests__/governed-working-set.test.ts
  2. 227 0
      packages/core/src/__tests__/hook-governance.test.ts
  3. 115 0
      packages/core/src/__tests__/hook-health.test.ts
  4. 43 1
      packages/core/src/__tests__/long-span-fatigue.test.ts
  5. 22 0
      packages/core/src/__tests__/models.test.ts
  6. 65 0
      packages/core/src/__tests__/pipeline-runner.test.ts
  7. 209 0
      packages/core/src/__tests__/planner.test.ts
  8. 12 0
      packages/core/src/__tests__/post-write-validator.test.ts
  9. 2 0
      packages/core/src/__tests__/settler-delta-parser.test.ts
  10. 108 0
      packages/core/src/__tests__/state-reducer.test.ts
  11. 27 0
      packages/core/src/__tests__/writer-prompts.test.ts
  12. 160 1
      packages/core/src/__tests__/writer.test.ts
  13. 35 1
      packages/core/src/agents/planner.ts
  14. 28 0
      packages/core/src/agents/post-write-validator.ts
  15. 9 6
      packages/core/src/agents/settler-prompts.ts
  16. 6 2
      packages/core/src/agents/writer-prompts.ts
  17. 44 0
      packages/core/src/agents/writer.ts
  18. 9 0
      packages/core/src/index.ts
  19. 15 0
      packages/core/src/models/input-governance.ts
  20. 2 0
      packages/core/src/models/runtime-state.ts
  21. 6 1
      packages/core/src/pipeline/runner.ts
  22. 16 0
      packages/core/src/state/state-reducer.ts
  23. 53 0
      packages/core/src/utils/governed-working-set.ts
  24. 180 0
      packages/core/src/utils/hook-governance.ts
  25. 112 0
      packages/core/src/utils/hook-health.ts
  26. 173 0
      packages/core/src/utils/long-span-fatigue.ts
  27. 80 2
      packages/core/src/utils/memory-retrieval.ts

+ 46 - 0
packages/core/src/__tests__/governed-working-set.test.ts

@@ -37,6 +37,52 @@ describe("governed-working-set", () => {
     expect(filtered).not.toContain("future-template");
   });
 
+  it("includes hook agenda debt items even when they are not selected or recent", () => {
+    const hooks = [
+      "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
+      "| --- | --- | --- | --- | --- | --- | --- |",
+      "| mentor-oath | 8 | relationship | open | 99 | 101 | Mentor oath debt with Lin Yue |",
+      "| stale-ledger | 14 | mystery | open | 70 | 120 | Old ledger debt is dormant but unresolved |",
+      "| future-pr-machine | 45 | system | open | 0 | 80 | Future hook should stay hidden |",
+    ].join("\n");
+
+    const filtered = buildGovernedHookWorkingSet({
+      hooksMarkdown: hooks,
+      contextPackage: {
+        chapter: 100,
+        selectedContext: [
+          {
+            source: "story/pending_hooks.md#mentor-oath",
+            reason: "Carry forward unresolved hook.",
+            excerpt: "relationship | open | 101 | Mentor oath debt with Lin Yue",
+          },
+        ],
+      },
+      chapterIntent: [
+        "# Chapter Intent",
+        "",
+        "## Hook Agenda",
+        "### Must Advance",
+        "- mentor-oath",
+        "",
+        "### Eligible Resolve",
+        "- none",
+        "",
+        "### Stale Debt",
+        "- stale-ledger",
+        "",
+        "### Avoid New Hook Families",
+        "- none",
+      ].join("\n"),
+      chapterNumber: 100,
+      language: "en",
+    });
+
+    expect(filtered).toContain("mentor-oath");
+    expect(filtered).toContain("stale-ledger");
+    expect(filtered).not.toContain("future-pr-machine");
+  });
+
   it("filters character matrix by exact governed character mentions instead of broad capitalized tokens", () => {
     const matrix = [
       "# Character Matrix",

+ 227 - 0
packages/core/src/__tests__/hook-governance.test.ts

@@ -0,0 +1,227 @@
+import { describe, expect, it } from "vitest";
+import type { HookRecord, RuntimeStateDelta } from "../models/runtime-state.js";
+import {
+  classifyHookDisposition,
+  collectStaleHookDebt,
+  evaluateHookAdmission,
+} from "../utils/hook-governance.js";
+
+function createHook(overrides: Partial<HookRecord> = {}): HookRecord {
+  return {
+    hookId: overrides.hookId ?? "H001",
+    startChapter: overrides.startChapter ?? 1,
+    type: overrides.type ?? "mystery",
+    status: overrides.status ?? "open",
+    lastAdvancedChapter: overrides.lastAdvancedChapter ?? 1,
+    expectedPayoff: overrides.expectedPayoff ?? "Reveal the hidden ledger",
+    notes: overrides.notes ?? "The hidden room is still sealed",
+  };
+}
+
+function createDelta(overrides: Partial<RuntimeStateDelta> = {}): RuntimeStateDelta {
+  return {
+    chapter: overrides.chapter ?? 12,
+    hookOps: {
+      upsert: overrides.hookOps?.upsert ?? [],
+      mention: overrides.hookOps?.mention ?? [],
+      resolve: overrides.hookOps?.resolve ?? [],
+      defer: overrides.hookOps?.defer ?? [],
+    },
+    subplotOps: overrides.subplotOps ?? [],
+    emotionalArcOps: overrides.emotionalArcOps ?? [],
+    characterMatrixOps: overrides.characterMatrixOps ?? [],
+    notes: overrides.notes ?? [],
+  };
+}
+
+describe("collectStaleHookDebt", () => {
+  it("returns unresolved stale hooks and excludes resolved or deferred hooks", () => {
+    const result = collectStaleHookDebt({
+      hooks: [
+        createHook({ hookId: "H001", status: "open", lastAdvancedChapter: 3 }),
+        createHook({ hookId: "H002", status: "progressing", lastAdvancedChapter: 8 }),
+        createHook({ hookId: "H003", status: "deferred", lastAdvancedChapter: 1 }),
+        createHook({ hookId: "H004", status: "resolved", lastAdvancedChapter: 2 }),
+      ],
+      chapterNumber: 20,
+      staleAfterChapters: 10,
+    });
+
+    expect(result.map((hook) => hook.hookId)).toEqual(["H001", "H002"]);
+  });
+});
+
+describe("evaluateHookAdmission", () => {
+  const activeHooks = [
+    createHook({
+      hookId: "H019",
+      type: "mystery",
+      expectedPayoff: "Reveal the hidden room behind the correction mark",
+      notes: "The hidden room converts public disputes into standing questions",
+    }),
+  ];
+
+  it("rejects hook candidates without payoff-bearing signal", () => {
+    const decision = evaluateHookAdmission({
+      candidate: {
+        type: "mystery",
+        expectedPayoff: "",
+        notes: "   ",
+      },
+      activeHooks,
+    });
+
+    expect(decision).toEqual({
+      admit: false,
+      reason: "missing_payoff_signal",
+    });
+  });
+
+  it("rejects hook candidates with blank type values", () => {
+    const decision = evaluateHookAdmission({
+      candidate: {
+        type: "   ",
+        expectedPayoff: "Reveal why the witness changed her statement",
+        notes: "A courtroom contradiction keeps widening",
+      },
+      activeHooks,
+    });
+
+    expect(decision).toEqual({
+      admit: false,
+      reason: "missing_type",
+    });
+  });
+
+  it("rejects duplicate or restated hook candidates", () => {
+    const decision = evaluateHookAdmission({
+      candidate: {
+        type: "mystery",
+        expectedPayoff: "Reveal the hidden room behind the correction mark",
+        notes: "The hidden room still reframes public disputes as standing questions",
+      },
+      activeHooks,
+    });
+
+    expect(decision).toEqual({
+      admit: false,
+      reason: "duplicate_family",
+      matchedHookId: "H019",
+    });
+  });
+
+  it("admits materially distinct hook candidates", () => {
+    const decision = evaluateHookAdmission({
+      candidate: {
+        type: "relationship",
+        expectedPayoff: "Expose why the mentor buried the oath",
+        notes: "A separate emotional debt keeps surfacing in private scenes",
+      },
+      activeHooks,
+    });
+
+    expect(decision).toEqual({
+      admit: true,
+      reason: "admit",
+    });
+  });
+
+  it("rejects Chinese paraphrase candidates from the same hook family", () => {
+    const decision = evaluateHookAdmission({
+      candidate: {
+        type: "神秘",
+        expectedPayoff: "弄明白雨夜匿名来电背后是谁",
+        notes: "一下雨就有陌生号码劝她远离旧码头",
+      },
+      activeHooks: [
+        createHook({
+          hookId: "H020",
+          type: "神秘",
+          expectedPayoff: "查出匿名号码为何总在雨夜响起",
+          notes: "每次雨夜都有人用匿名电话提醒她别去旧码头",
+        }),
+      ],
+    });
+
+    expect(decision).toEqual({
+      admit: false,
+      reason: "duplicate_family",
+      matchedHookId: "H020",
+    });
+  });
+});
+
+describe("classifyHookDisposition", () => {
+  it("classifies mention, advance, resolve, and defer with strict priority", () => {
+    expect(classifyHookDisposition({
+      hookId: "H001",
+      delta: createDelta({
+        hookOps: {
+          upsert: [],
+          mention: ["H001"],
+          resolve: [],
+          defer: [],
+        },
+      }),
+    })).toBe("mention");
+
+    expect(classifyHookDisposition({
+      hookId: "H002",
+      delta: createDelta({
+        chapter: 12,
+        hookOps: {
+          upsert: [createHook({ hookId: "H002", lastAdvancedChapter: 12 })],
+          mention: [],
+          resolve: [],
+          defer: [],
+        },
+      }),
+    })).toBe("advance");
+
+    expect(classifyHookDisposition({
+      hookId: "H003",
+      delta: createDelta({
+        hookOps: {
+          upsert: [createHook({ hookId: "H003", lastAdvancedChapter: 12 })],
+          mention: ["H003"],
+          resolve: ["H003"],
+          defer: [],
+        },
+      }),
+    })).toBe("resolve");
+
+    expect(classifyHookDisposition({
+      hookId: "H004",
+      delta: createDelta({
+        hookOps: {
+          upsert: [],
+          mention: ["H004"],
+          resolve: [],
+          defer: ["H004"],
+        },
+      }),
+    })).toBe("defer");
+  });
+
+  it("returns none when the hook is untouched in the chapter delta", () => {
+    expect(classifyHookDisposition({
+      hookId: "H099",
+      delta: createDelta(),
+    })).toBe("none");
+  });
+
+  it("reports defer when resolve and defer both target the same hook", () => {
+    expect(classifyHookDisposition({
+      hookId: "H021",
+      delta: createDelta({
+        chapter: 12,
+        hookOps: {
+          upsert: [createHook({ hookId: "H021", lastAdvancedChapter: 12 })],
+          mention: ["H021"],
+          resolve: ["H021"],
+          defer: ["H021"],
+        },
+      }),
+    })).toBe("defer");
+  });
+});

+ 115 - 0
packages/core/src/__tests__/hook-health.test.ts

@@ -0,0 +1,115 @@
+import { describe, expect, it } from "vitest";
+import type { HookRecord, RuntimeStateDelta } from "../models/runtime-state.js";
+import { analyzeHookHealth } from "../utils/hook-health.js";
+
+function createHook(overrides: Partial<HookRecord> = {}): HookRecord {
+  return {
+    hookId: overrides.hookId ?? "H001",
+    startChapter: overrides.startChapter ?? 1,
+    type: overrides.type ?? "mystery",
+    status: overrides.status ?? "open",
+    lastAdvancedChapter: overrides.lastAdvancedChapter ?? 1,
+    expectedPayoff: overrides.expectedPayoff ?? "Reveal the hidden ledger",
+    notes: overrides.notes ?? "Still unresolved",
+  };
+}
+
+function createDelta(overrides: Partial<RuntimeStateDelta> = {}): RuntimeStateDelta {
+  return {
+    chapter: overrides.chapter ?? 20,
+    hookOps: {
+      upsert: overrides.hookOps?.upsert ?? [],
+      mention: overrides.hookOps?.mention ?? [],
+      resolve: overrides.hookOps?.resolve ?? [],
+      defer: overrides.hookOps?.defer ?? [],
+    },
+    subplotOps: [],
+    emotionalArcOps: [],
+    characterMatrixOps: [],
+    notes: [],
+  };
+}
+
+describe("analyzeHookHealth", () => {
+  it("warns when active hook count exceeds the recommended cap", () => {
+    const issues = analyzeHookHealth({
+      language: "en",
+      chapterNumber: 20,
+      hooks: [
+        createHook({ hookId: "H001" }),
+        createHook({ hookId: "H002" }),
+        createHook({ hookId: "H003" }),
+        createHook({ hookId: "H004" }),
+        createHook({ hookId: "H005" }),
+      ],
+      maxActiveHooks: 4,
+    });
+
+    expect(issues.some((issue) => issue.category === "Hook Debt" && issue.description.includes("5 active hooks"))).toBe(true);
+  });
+
+  it("warns when no hook has materially advanced for several chapters", () => {
+    const issues = analyzeHookHealth({
+      language: "en",
+      chapterNumber: 20,
+      hooks: [
+        createHook({ hookId: "H001", lastAdvancedChapter: 12 }),
+        createHook({ hookId: "H002", lastAdvancedChapter: 11 }),
+      ],
+      noAdvanceWindow: 5,
+    });
+
+    expect(issues.some((issue) => issue.description.includes("No real hook advancement"))).toBe(true);
+  });
+
+  it("warns when stale hooks receive no disposition in the current chapter", () => {
+    const issues = analyzeHookHealth({
+      language: "en",
+      chapterNumber: 20,
+      hooks: [
+        createHook({ hookId: "H001", lastAdvancedChapter: 5 }),
+        createHook({ hookId: "H002", lastAdvancedChapter: 6 }),
+      ],
+      delta: createDelta({
+        chapter: 20,
+        hookOps: {
+          upsert: [],
+          mention: ["H001"],
+          resolve: [],
+          defer: [],
+        },
+      }),
+      staleAfterChapters: 10,
+    });
+
+    expect(issues.some((issue) => issue.description.includes("H001") || issue.description.includes("H002"))).toBe(true);
+  });
+
+  it("warns when multiple new hooks open without resolving older debt", () => {
+    const issues = analyzeHookHealth({
+      language: "en",
+      chapterNumber: 20,
+      hooks: [
+        createHook({ hookId: "old-debt", lastAdvancedChapter: 8 }),
+        createHook({ hookId: "new-a", startChapter: 20, lastAdvancedChapter: 20 }),
+        createHook({ hookId: "new-b", startChapter: 20, lastAdvancedChapter: 20 }),
+      ],
+      delta: createDelta({
+        chapter: 20,
+        hookOps: {
+          upsert: [
+            createHook({ hookId: "new-a", startChapter: 20, lastAdvancedChapter: 20 }),
+            createHook({ hookId: "new-b", startChapter: 20, lastAdvancedChapter: 20 }),
+          ],
+          mention: [],
+          resolve: [],
+          defer: [],
+        },
+      }),
+      existingHookIds: ["old-debt"],
+      newHookBurstThreshold: 2,
+    });
+
+    expect(issues.some((issue) => issue.description.includes("Opened 2 new hooks"))).toBe(true);
+  });
+});

+ 43 - 1
packages/core/src/__tests__/long-span-fatigue.test.ts

@@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest";
 import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
-import { analyzeLongSpanFatigue } from "../utils/long-span-fatigue.js";
+import {
+  analyzeLongSpanFatigue,
+  buildEnglishVarianceBrief,
+} from "../utils/long-span-fatigue.js";
 
 async function createBookDir(prefix: string): Promise<string> {
   const root = await mkdtemp(join(tmpdir(), prefix));
@@ -79,4 +82,43 @@ describe("analyzeLongSpanFatigue", () => {
       await rm(join(bookDir, ".."), { recursive: true, force: true });
     }
   });
+
+  it("builds an English variance brief with phrase, opening, ending, and scene guidance", async () => {
+    const bookDir = await createBookDir("inkos-variance-brief-test-");
+
+    await Promise.all([
+      writeChapter(bookDir, 1, "Ledger", "Mara kept the ledger close to her chest. The corridor stayed quiet after the bell. There it was again."),
+      writeChapter(bookDir, 2, "Ash", "Mara kept the ledger close to her chest while the ash fell. The corridor stayed quiet until Taryn stopped. There it was again."),
+      writeChapter(bookDir, 3, "Harbor", "Mara kept the ledger close to her chest near the harbor gate. The corridor stayed quiet while the guards changed. There it was again."),
+      writeFile(
+        join(bookDir, "story", "chapter_summaries.md"),
+        [
+          "# Chapter Summaries",
+          "",
+          "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+          "| --- | --- | --- | --- | --- | --- | --- | --- |",
+          "| 1 | Ledger | Mara | Mara hides the ledger | pressure tightens | none | tense | investigation |",
+          "| 2 | Ash | Mara,Taryn | Ash falls over the archive | pressure tightens | none | tense | investigation |",
+          "| 3 | Harbor | Mara,Taryn | The gate stays under watch | pressure tightens | none | tense | investigation |",
+        ].join("\n"),
+        "utf-8",
+      ),
+    ]);
+
+    try {
+      const brief = await buildEnglishVarianceBrief({
+        bookDir,
+        chapterNumber: 4,
+      });
+
+      expect(brief?.highFrequencyPhrases.length).toBeGreaterThan(0);
+      expect(brief?.repeatedOpeningPatterns.length).toBeGreaterThan(0);
+      expect(brief?.repeatedEndingShapes.length).toBeGreaterThan(0);
+      expect(brief?.sceneObligation).toBeTruthy();
+      expect(brief?.text).toContain("High-frequency phrases");
+      expect(brief?.text).toContain("Scene obligation");
+    } finally {
+      await rm(join(bookDir, ".."), { recursive: true, force: true });
+    }
+  });
 });

+ 22 - 0
packages/core/src/__tests__/models.test.ts

@@ -504,11 +504,27 @@ describe("ChapterIntentSchema", () => {
           resolution: "allow local outline deferral",
         },
       ],
+      hookAgenda: {
+        mustAdvance: ["H019"],
+        eligibleResolve: ["H045"],
+        staleDebt: ["H023", "H027"],
+        avoidNewHookFamilies: [
+          "anonymous-source-restatement",
+          "mechanism-restatement",
+        ],
+      },
     });
 
     expect(result.chapter).toBe(12);
     expect(result.goal).toContain("mentor conflict");
     expect(result.conflicts).toHaveLength(1);
+    expect(result.hookAgenda.mustAdvance).toEqual(["H019"]);
+    expect(result.hookAgenda.eligibleResolve).toEqual(["H045"]);
+    expect(result.hookAgenda.staleDebt).toEqual(["H023", "H027"]);
+    expect(result.hookAgenda.avoidNewHookFamilies).toEqual([
+      "anonymous-source-restatement",
+      "mechanism-restatement",
+    ]);
   });
 
   it("defaults optional arrays to empty", () => {
@@ -521,6 +537,10 @@ describe("ChapterIntentSchema", () => {
     expect(result.mustAvoid).toEqual([]);
     expect(result.styleEmphasis).toEqual([]);
     expect(result.conflicts).toEqual([]);
+    expect(result.hookAgenda.mustAdvance).toEqual([]);
+    expect(result.hookAgenda.eligibleResolve).toEqual([]);
+    expect(result.hookAgenda.staleDebt).toEqual([]);
+    expect(result.hookAgenda.avoidNewHookFamilies).toEqual([]);
   });
 
   it("rejects invalid chapter numbers", () => {
@@ -791,6 +811,7 @@ describe("Runtime state schemas", () => {
             notes: "Ledger clue sharpens the line",
           },
         ],
+        mention: ["ledger-whisper"],
         resolve: [],
         defer: [],
       },
@@ -809,6 +830,7 @@ describe("Runtime state schemas", () => {
 
     expect(result.chapter).toBe(12);
     expect(result.hookOps.upsert[0]?.hookId).toBe("mentor-debt");
+    expect(result.hookOps.mention).toEqual(["ledger-whisper"]);
   });
 
   it("rejects natural-language numeric drift in runtime-state delta hooks", () => {

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

@@ -2734,6 +2734,71 @@ describe("PipelineRunner", () => {
     }
   });
 
+  it("feeds hook health warnings back into pipeline audit and drift correction", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const storyDir = join(state.bookDir(bookId), "story");
+
+    await Promise.all([
+      writeFile(join(storyDir, "current_state.md"), createStateCard({
+        chapter: 2,
+        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"),
+      writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
+    ]);
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 3,
+        title: "回声",
+        content: "夜色慢慢压低了屋檐。林越先停在门外,随后才抬手去碰那道旧债印。",
+        wordCount: "夜色慢慢压低了屋檐。林越先停在门外,随后才抬手去碰那道旧债印。".length,
+        updatedState: createStateCard({
+          chapter: 3,
+          location: "Ashen ferry crossing",
+          protagonistState: "Lin Yue still hides the oath token.",
+          goal: "Find the vanished mentor.",
+          conflict: "The debt trail keeps narrowing.",
+        }),
+        updatedLedger: "",
+        updatedHooks: "# Pending Hooks\n",
+        chapterSummary: "| 3 | 回声 | 林越 | 继续潜伏 | 目标未变 | 债印未解 | 克制 | 布局 |",
+        hookHealthIssues: [{
+          severity: "warning",
+          category: "伏笔债务",
+          description: "活跃伏笔过多,且本章没有处理陈旧债务。",
+          suggestion: "下一章优先推进或延后至少一个僵死伏笔。",
+        }],
+      }),
+    );
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
+      createAuditResult({
+        passed: true,
+        issues: [],
+        summary: "ok",
+      }),
+    );
+
+    try {
+      const result = await runner.writeNextChapter(bookId);
+      const currentState = await readFile(join(storyDir, "current_state.md"), "utf-8");
+      const savedIndex = await state.loadChapterIndex(bookId);
+
+      expect(result.auditResult.issues.some((issue) => issue.category === "伏笔债务")).toBe(true);
+      expect(currentState).toContain("伏笔债务");
+      expect(savedIndex[0]?.auditIssues).toEqual(
+        expect.arrayContaining([
+          expect.stringContaining("活跃伏笔过多"),
+        ]),
+      );
+    } 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");

+ 209 - 0
packages/core/src/__tests__/planner.test.ts

@@ -454,4 +454,213 @@ describe("PlannerAgent", () => {
     ]));
     expect(result.intent.mustKeep).not.toContain("--");
   });
+
+  it("emits hook agenda into chapter intent and runtime markdown", async () => {
+    const stateDir = join(storyDir, "state");
+    await mkdir(stateDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(
+        join(stateDir, "manifest.json"),
+        JSON.stringify({
+          schemaVersion: 2,
+          language: "en",
+          lastAppliedChapter: 25,
+          projectionVersion: 1,
+          migrationWarnings: [],
+        }, null, 2),
+        "utf-8",
+      ),
+      writeFile(
+        join(stateDir, "current_state.json"),
+        JSON.stringify({
+          chapter: 25,
+          facts: [],
+        }, null, 2),
+        "utf-8",
+      ),
+      writeFile(
+        join(stateDir, "chapter_summaries.json"),
+        JSON.stringify({
+          rows: [],
+        }, null, 2),
+        "utf-8",
+      ),
+      writeFile(
+        join(stateDir, "hooks.json"),
+        JSON.stringify({
+          hooks: [
+            {
+              hookId: "recent-route",
+              startChapter: 23,
+              type: "route",
+              status: "open",
+              lastAdvancedChapter: 25,
+              expectedPayoff: "Recent route payoff",
+              notes: "Recent route remains active.",
+            },
+            {
+              hookId: "ready-payoff",
+              startChapter: 12,
+              type: "mystery",
+              status: "progressing",
+              lastAdvancedChapter: 24,
+              expectedPayoff: "Reveal the hidden room mastermind",
+              notes: "The chapter is close to the reveal point.",
+            },
+            {
+              hookId: "stale-debt",
+              startChapter: 3,
+              type: "relationship",
+              status: "open",
+              lastAdvancedChapter: 8,
+              expectedPayoff: "Mentor debt payoff",
+              notes: "Long-stale but still unresolved.",
+            },
+          ],
+        }, null, 2),
+        "utf-8",
+      ),
+    ]);
+
+    book = {
+      ...book,
+      genre: "other",
+      language: "en",
+    };
+
+    const planner = new PlannerAgent({
+      client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    const result = await planner.planChapter({
+      book,
+      bookDir,
+      chapterNumber: 26,
+      externalContext: "Keep the chapter on the mainline debt conflict.",
+    });
+
+    expect(result.intent.hookAgenda.mustAdvance).toEqual(["recent-route", "ready-payoff"]);
+    expect(result.intent.hookAgenda.eligibleResolve).toEqual(["ready-payoff"]);
+    expect(result.intent.hookAgenda.staleDebt).toEqual(["stale-debt"]);
+
+    const intentMarkdown = await readFile(result.runtimePath, "utf-8");
+    expect(intentMarkdown).toContain("## Hook Agenda");
+    expect(intentMarkdown).toContain("recent-route");
+    expect(intentMarkdown).toContain("ready-payoff");
+    expect(intentMarkdown).toContain("stale-debt");
+  });
+
+  it("builds stale debt agenda from broader active hooks than the retrieval subset", async () => {
+    const stateDir = join(storyDir, "state");
+    await mkdir(stateDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(
+        join(stateDir, "manifest.json"),
+        JSON.stringify({
+          schemaVersion: 2,
+          language: "en",
+          lastAppliedChapter: 25,
+          projectionVersion: 1,
+          migrationWarnings: [],
+        }, null, 2),
+        "utf-8",
+      ),
+      writeFile(
+        join(stateDir, "current_state.json"),
+        JSON.stringify({
+          chapter: 25,
+          facts: [],
+        }, null, 2),
+        "utf-8",
+      ),
+      writeFile(
+        join(stateDir, "chapter_summaries.json"),
+        JSON.stringify({
+          rows: [],
+        }, null, 2),
+        "utf-8",
+      ),
+      writeFile(
+        join(stateDir, "hooks.json"),
+        JSON.stringify({
+          hooks: [
+            {
+              hookId: "recent-route",
+              startChapter: 23,
+              type: "route",
+              status: "open",
+              lastAdvancedChapter: 25,
+              expectedPayoff: "Recent route payoff",
+              notes: "Keep the route central.",
+            },
+            {
+              hookId: "recent-guild",
+              startChapter: 22,
+              type: "politics",
+              status: "progressing",
+              lastAdvancedChapter: 24,
+              expectedPayoff: "Guild pressure payoff",
+              notes: "Guild pressure remains active.",
+            },
+            {
+              hookId: "recent-token",
+              startChapter: 21,
+              type: "artifact",
+              status: "open",
+              lastAdvancedChapter: 23,
+              expectedPayoff: "Token route payoff",
+              notes: "Token route remains active.",
+            },
+            {
+              hookId: "stale-omega",
+              startChapter: 3,
+              type: "relationship",
+              status: "open",
+              lastAdvancedChapter: 8,
+              expectedPayoff: "Old debt payoff",
+              notes: "Dormant unresolved line.",
+            },
+            {
+              hookId: "stale-sable",
+              startChapter: 4,
+              type: "mystery",
+              status: "open",
+              lastAdvancedChapter: 9,
+              expectedPayoff: "Archive payoff",
+              notes: "Another dormant unresolved line.",
+            },
+          ],
+        }, null, 2),
+        "utf-8",
+      ),
+    ]);
+
+    book = {
+      ...book,
+      genre: "other",
+      language: "en",
+    };
+
+    const planner = new PlannerAgent({
+      client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    const result = await planner.planChapter({
+      book,
+      bookDir,
+      chapterNumber: 26,
+      externalContext: "Keep the chapter on the route pressure.",
+    });
+
+    expect(result.intent.hookAgenda.mustAdvance).toEqual(["recent-route", "recent-guild"]);
+    expect(result.intent.hookAgenda.staleDebt).toEqual(["stale-omega", "stale-sable"]);
+  });
 });

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

@@ -146,4 +146,16 @@ describe("validatePostWrite", () => {
     const result = validatePostWrite(content, baseProfile, null);
     expect(result).toHaveLength(0);
   });
+
+  it("warns when an English multi-character scene has almost no direct exchange", () => {
+    const content = [
+      "Mara cornered Taryn in the archive and kept the ledger between them.",
+      "Mara demanded a clear answer about the missing page while Taryn refused to meet her eyes.",
+      "Taryn stepped back toward the window and Mara followed without letting the pressure break.",
+    ].join(" ");
+
+    const result = validatePostWrite(content, baseProfile, null, "en");
+    expect(findRule(result, "Dialogue pressure")).toBeDefined();
+    expect(findRule(result, "Dialogue pressure")?.severity).toBe("warning");
+  });
 });

+ 2 - 0
packages/core/src/__tests__/settler-delta-parser.test.ts

@@ -85,6 +85,7 @@ describe("parseSettlerDeltaOutput", () => {
         chapter: 20,
         hookOps: {
           upsert: [],
+          mention: ["mentor-oath"],
           resolve: ["old-seal"],
           defer: ["guild-route"],
         },
@@ -93,6 +94,7 @@ describe("parseSettlerDeltaOutput", () => {
       "```",
     ].join("\n"));
 
+    expect(result.runtimeStateDelta.hookOps.mention).toEqual(["mentor-oath"]);
     expect(result.runtimeStateDelta.hookOps.resolve).toEqual(["old-seal"]);
     expect(result.runtimeStateDelta.hookOps.defer).toEqual(["guild-route"]);
   });

+ 108 - 0
packages/core/src/__tests__/state-reducer.test.ts

@@ -192,4 +192,112 @@ describe("applyRuntimeStateDelta", () => {
       }),
     ).toThrow(/unknown hook/i);
   });
+
+  it("keeps mention-only hooks from mutating lastAdvancedChapter", () => {
+    const result = applyRuntimeStateDelta({
+      snapshot: {
+        manifest: {
+          schemaVersion: 2,
+          language: "en",
+          lastAppliedChapter: 11,
+          projectionVersion: 1,
+          migrationWarnings: [],
+        },
+        currentState: {
+          chapter: 11,
+          facts: [],
+        },
+        hooks: {
+          hooks: [
+            {
+              hookId: "mentor-debt",
+              startChapter: 1,
+              type: "relationship",
+              status: "open",
+              lastAdvancedChapter: 8,
+              expectedPayoff: "Reveal the debt.",
+              notes: "Still unresolved.",
+            },
+          ],
+        },
+        chapterSummaries: {
+          rows: [],
+        },
+      },
+      delta: RuntimeStateDeltaSchema.parse({
+        chapter: 12,
+        hookOps: {
+          upsert: [],
+          mention: ["mentor-debt"],
+          resolve: [],
+          defer: [],
+        },
+        notes: [],
+      }),
+    });
+
+    expect(result.hooks.hooks).toEqual([
+      expect.objectContaining({
+        hookId: "mentor-debt",
+        lastAdvancedChapter: 8,
+        status: "open",
+      }),
+    ]);
+  });
+
+  it("rejects duplicate restated hook families when a new hook upsert overlaps an active hook", () => {
+    expect(() =>
+      applyRuntimeStateDelta({
+        snapshot: {
+          manifest: {
+            schemaVersion: 2,
+            language: "en",
+            lastAppliedChapter: 11,
+            projectionVersion: 1,
+            migrationWarnings: [],
+          },
+          currentState: {
+            chapter: 11,
+            facts: [],
+          },
+          hooks: {
+            hooks: [
+              {
+                hookId: "anonymous-source-scope",
+                startChapter: 3,
+                type: "source-risk",
+                status: "open",
+                lastAdvancedChapter: 8,
+                expectedPayoff: "Reveal how much the anonymous source already knew about the route and address.",
+                notes: "Still unresolved anonymous source knowledge question.",
+              },
+            ],
+          },
+          chapterSummaries: {
+            rows: [],
+          },
+        },
+        delta: RuntimeStateDeltaSchema.parse({
+          chapter: 12,
+          hookOps: {
+            upsert: [
+              {
+                hookId: "anonymous-source-restated",
+                startChapter: 12,
+                type: "source-risk",
+                status: "open",
+                lastAdvancedChapter: 12,
+                expectedPayoff: "Reveal how much the anonymous source already knew about the route.",
+                notes: "Anonymous source knowledge question restated with slightly different wording.",
+              },
+            ],
+            mention: [],
+            resolve: [],
+            defer: [],
+          },
+          notes: [],
+        }),
+      }),
+    ).toThrow(/duplicate active hook/i);
+  });
 });

+ 27 - 0
packages/core/src/__tests__/writer-prompts.test.ts

@@ -107,4 +107,31 @@ describe("buildWriterSystemPrompt", () => {
     expect(prompt).toContain("Do not reveal the mastermind");
     expect(prompt).toContain("Keep the prose restrained");
   });
+
+  it("tells governed English prompts to obey variance briefs and include resistance-bearing exchanges", () => {
+    const prompt = buildWriterSystemPrompt(
+      {
+        ...BOOK,
+        language: "en",
+      },
+      {
+        ...GENRE,
+        language: "en",
+        name: "General",
+      },
+      null,
+      "# Book Rules",
+      "# Genre Body",
+      "# Style Guide\n\nKeep the prose restrained.",
+      undefined,
+      3,
+      "creative",
+      undefined,
+      "en",
+      "governed",
+    );
+
+    expect(prompt).toContain("English Variance Brief");
+    expect(prompt).toContain("resistance-bearing exchange");
+  });
 });

+ 160 - 1
packages/core/src/__tests__/writer.test.ts

@@ -55,6 +55,7 @@ describe("WriterAgent", () => {
         "| --- | --- | --- | --- | --- | --- | --- |",
         "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
         "| old-seal | 3 | artifact | open | 12 | 40 | Old seal detour |",
+        "| stale-ledger | 14 | mystery | open | 70 | 120 | Old ledger debt is dormant but unresolved |",
         "| mentor-oath | 8 | relationship | open | 99 | 101 | Mentor oath debt with Lin Yue |",
       ].join("\n"), "utf-8"),
       writeFile(join(storyDir, "chapter_summaries.md"), [
@@ -167,7 +168,25 @@ describe("WriterAgent", () => {
         },
         bookDir,
         chapterNumber: 100,
-        chapterIntent: "# Chapter Intent\n\n## Goal\nBring the focus back to the mentor oath conflict.\n",
+        chapterIntent: [
+          "# Chapter Intent",
+          "",
+          "## Goal",
+          "Bring the focus back to the mentor oath conflict.",
+          "",
+          "## Hook Agenda",
+          "### Must Advance",
+          "- mentor-oath",
+          "",
+          "### Eligible Resolve",
+          "- none",
+          "",
+          "### Stale Debt",
+          "- stale-ledger",
+          "",
+          "### Avoid New Hook Families",
+          "- none",
+        ].join("\n"),
         contextPackage: {
           chapter: 100,
           selectedContext: [
@@ -205,6 +224,7 @@ describe("WriterAgent", () => {
       expect(settlePrompt).toContain("## 本章控制输入");
       expect(settlePrompt).toContain("story/chapter_summaries.md#99");
       expect(settlePrompt).toContain("| 99 | Locked Gate |");
+      expect(settlePrompt).toContain("| stale-ledger | 14 | mystery | open | 70 | 120 | Old ledger debt is dormant but unresolved |");
       expect(settlePrompt).not.toContain("| 1 | Guild Trail |");
       expect(settlePrompt).not.toContain("old-seal");
       expect(settlePrompt).not.toContain("Guildmaster Ren");
@@ -466,4 +486,143 @@ describe("WriterAgent", () => {
       await rm(root, { recursive: true, force: true });
     }
   });
+
+  it("injects an English variance brief into governed creative prompts", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-writer-variance-test-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    const chaptersDir = join(bookDir, "chapters");
+    await mkdir(storyDir, { recursive: true });
+    await mkdir(chaptersDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The registry seals matter.\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 4\nForce Mara back toward the ledger trail.\n", "utf-8"),
+      writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose lean.\n", "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Mara still hides the ledger fragment.\n", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), [
+        "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
+        "| --- | --- | --- | --- | --- | --- | --- |",
+        "| ledger-fragment | 1 | mystery | open | 3 | 8 | Mara still hides the ledger fragment |",
+      ].join("\n"), "utf-8"),
+      writeFile(join(storyDir, "chapter_summaries.md"), [
+        "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+        "| --- | --- | --- | --- | --- | --- | --- | --- |",
+        "| 1 | Ledger | Mara | Mara hides the ledger | pressure tightens | none | tense | investigation |",
+        "| 2 | Ash | Mara,Taryn | Ash falls over the archive | pressure tightens | none | tense | investigation |",
+        "| 3 | Harbor | Mara,Taryn | The gate stays under watch | pressure tightens | none | tense | investigation |",
+      ].join("\n"), "utf-8"),
+      writeFile(join(chaptersDir, "0001_Ledger.md"), "# Chapter 1 Ledger\n\nMara kept the ledger close to her chest. The corridor stayed quiet after the bell. There it was again.\n", "utf-8"),
+      writeFile(join(chaptersDir, "0002_Ash.md"), "# Chapter 2 Ash\n\nMara kept the ledger close to her chest while the ash fell. The corridor stayed quiet until Taryn stopped. There it was again.\n", "utf-8"),
+      writeFile(join(chaptersDir, "0003_Harbor.md"), "# Chapter 3 Harbor\n\nMara kept the ledger close to her chest near the harbor gate. The corridor stayed quiet while the guards changed. There it was again.\n", "utf-8"),
+    ]);
+
+    const agent = new WriterAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    const chatSpy = vi.spyOn(WriterAgent.prototype as never, "chat" as never)
+      .mockResolvedValueOnce({
+        content: [
+          "=== CHAPTER_TITLE ===",
+          "Pressure Ledger",
+          "",
+          "=== CHAPTER_CONTENT ===",
+          "Mara forced Taryn to answer beside the archive window.",
+          "",
+          "=== PRE_WRITE_CHECK ===",
+          "- ok",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      })
+      .mockResolvedValueOnce({
+        content: "=== OBSERVATIONS ===\n- observed",
+        usage: ZERO_USAGE,
+      })
+      .mockResolvedValueOnce({
+        content: [
+          "=== POST_SETTLEMENT ===",
+          "- ledger-fragment advanced",
+          "",
+          "=== UPDATED_STATE ===",
+          "state",
+          "",
+          "=== UPDATED_HOOKS ===",
+          "hooks",
+          "",
+          "=== CHAPTER_SUMMARY ===",
+          "| 4 | Pressure Ledger | Mara,Taryn | Pressure rises | Trail narrows | ledger-fragment advanced | tense | confrontation |",
+          "",
+          "=== UPDATED_SUBPLOTS ===",
+          "subplots",
+          "",
+          "=== UPDATED_EMOTIONAL_ARCS ===",
+          "arcs",
+          "",
+          "=== UPDATED_CHARACTER_MATRIX ===",
+          "matrix",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    try {
+      await agent.writeChapter({
+        book: {
+          id: "writer-book",
+          title: "Writer Book",
+          platform: "other",
+          genre: "other",
+          status: "active",
+          targetChapters: 20,
+          chapterWordCount: 2200,
+          language: "en",
+          createdAt: "2026-03-26T00:00:00.000Z",
+          updatedAt: "2026-03-26T00:00:00.000Z",
+        },
+        bookDir,
+        chapterNumber: 4,
+        chapterIntent: "# Chapter Intent\n\n## Goal\nForce Mara back toward the ledger trail.\n",
+        contextPackage: {
+          chapter: 4,
+          selectedContext: [
+            {
+              source: "story/chapter_summaries.md#3",
+              reason: "Carry recent pressure into the next chapter.",
+              excerpt: "The gate stays under watch.",
+            },
+          ],
+        },
+        ruleStack: {
+          layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
+          sections: {
+            hard: ["current_state"],
+            soft: ["current_focus"],
+            diagnostic: ["continuity_audit"],
+          },
+          overrideEdges: [],
+          activeOverrides: [],
+        },
+        lengthSpec: buildLengthSpec(2200, "en"),
+      });
+
+      const creativePrompt = (chatSpy.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? "";
+      expect(creativePrompt).toContain("## English Variance Brief");
+      expect(creativePrompt).toContain("High-frequency phrases");
+      expect(creativePrompt).toContain("Scene obligation");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
 });

+ 35 - 1
packages/core/src/agents/planner.ts

@@ -4,7 +4,12 @@ import { BaseAgent } from "./base.js";
 import type { BookConfig } from "../models/book.js";
 import { parseBookRules } from "../models/book-rules.js";
 import { ChapterIntentSchema, type ChapterConflict, type ChapterIntent } from "../models/input-governance.js";
-import { renderHookSnapshot, renderSummarySnapshot, retrieveMemorySelection } from "../utils/memory-retrieval.js";
+import {
+  buildPlannerHookAgenda,
+  renderHookSnapshot,
+  renderSummarySnapshot,
+  retrieveMemorySelection,
+} from "../utils/memory-retrieval.js";
 
 export interface PlanChapterInput {
   readonly book: BookConfig;
@@ -70,6 +75,10 @@ export class PlannerAgent extends BaseAgent {
       outlineNode: planningAnchor,
       mustKeep,
     });
+    const hookAgenda = buildPlannerHookAgenda({
+      hooks: memorySelection.activeHooks,
+      chapterNumber: input.chapterNumber,
+    });
 
     const intent = ChapterIntentSchema.parse({
       chapter: input.chapterNumber,
@@ -79,6 +88,7 @@ export class PlannerAgent extends BaseAgent {
       mustAvoid,
       styleEmphasis,
       conflicts,
+      hookAgenda,
     });
 
     const runtimePath = join(runtimeDir, `chapter-${String(input.chapterNumber).padStart(4, "0")}.intent.md`);
@@ -387,6 +397,27 @@ export class PlannerAgent extends BaseAgent {
     const styleEmphasis = intent.styleEmphasis.length > 0
       ? intent.styleEmphasis.map((item) => `- ${item}`).join("\n")
       : "- none";
+    const hookAgenda = [
+      "### Must Advance",
+      intent.hookAgenda.mustAdvance.length > 0
+        ? intent.hookAgenda.mustAdvance.map((item) => `- ${item}`).join("\n")
+        : "- none",
+      "",
+      "### Eligible Resolve",
+      intent.hookAgenda.eligibleResolve.length > 0
+        ? intent.hookAgenda.eligibleResolve.map((item) => `- ${item}`).join("\n")
+        : "- none",
+      "",
+      "### Stale Debt",
+      intent.hookAgenda.staleDebt.length > 0
+        ? intent.hookAgenda.staleDebt.map((item) => `- ${item}`).join("\n")
+        : "- none",
+      "",
+      "### Avoid New Hook Families",
+      intent.hookAgenda.avoidNewHookFamilies.length > 0
+        ? intent.hookAgenda.avoidNewHookFamilies.map((item) => `- ${item}`).join("\n")
+        : "- none",
+    ].join("\n");
 
     return [
       "# Chapter Intent",
@@ -406,6 +437,9 @@ export class PlannerAgent extends BaseAgent {
       "## Style Emphasis",
       styleEmphasis,
       "",
+      "## Hook Agenda",
+      hookAgenda,
+      "",
       "## Conflicts",
       conflictLines,
       "",

+ 28 - 0
packages/core/src/agents/post-write-validator.ts

@@ -348,6 +348,21 @@ function validatePostWriteEnglish(
     });
   }
 
+  // 2.5. Multi-character scene with almost no direct exchange
+  const quotedLines = content.match(/"[^"]+"/g) ?? [];
+  const englishNames = [...new Set(
+    (content.match(/\b[A-Z][a-z]{2,}\b/g) ?? [])
+      .filter((name) => !ENGLISH_NAME_STOP_WORDS.has(name)),
+  )];
+  if (englishNames.length >= 2 && quotedLines.length < 2 && content.length >= 120) {
+    violations.push({
+      rule: "Dialogue pressure",
+      severity: "warning",
+      description: `Multi-character scene appears to rely on narration with almost no direct exchange (${englishNames.slice(0, 3).join(", ")}).`,
+      suggestion: "Add at least one resistance-bearing exchange so characters push back, withhold, or pressure each other directly.",
+    });
+  }
+
   // 3. Book-specific prohibitions
   if (bookRules?.prohibitions) {
     for (const prohibition of bookRules.prohibitions) {
@@ -381,3 +396,16 @@ function validatePostWriteEnglish(
 
   return violations;
 }
+
+const ENGLISH_NAME_STOP_WORDS = new Set([
+  "The",
+  "And",
+  "But",
+  "When",
+  "While",
+  "After",
+  "Before",
+  "Even",
+  "Then",
+  "They",
+]);

+ 9 - 6
packages/core/src/agents/settler-prompts.ts

@@ -18,11 +18,12 @@ export function buildSettlerSystemPrompt(
   const hookRules = `
 ## 伏笔追踪规则(严格执行)
 
-- 新伏笔:正文中出现的暗示、悬念、未解之谜 → 新增 hook_id,标注起始章=${isEnglish ? "current chapter" : "当前章"}、类型、状态=待定
-- 推进伏笔:已有伏笔在本章有新进展 → **必须**更新"最近推进"列为当前章节号,更新状态和备注
+- 新伏笔:只有当正文中出现一个会延续到后续章节、且有具体回收方向的未解问题时,才新增 hook_id。不要为旧 hook 的换说法、重述、抽象总结再开新 hook
+- 提及伏笔:已有伏笔在本章被提到,但没有新增信息、没有改变读者或角色对该问题的理解 → 放入 mention 数组,不要更新最近推进
+- 推进伏笔:已有伏笔在本章出现了新的事实、证据、关系变化、风险升级或范围收缩 → **必须**更新"最近推进"列为当前章节号,更新状态和备注
 - 回收伏笔:伏笔在本章被明确揭示、解决、或不再成立 → 状态改为"已回收",备注回收方式
 - 延后伏笔:超过5章未推进 → 标注"延后",备注原因
-- **铁律**:每次输出 UPDATED_HOOKS 时,逐条检查所有伏笔的"最近推进"列。如果某伏笔在本章正文中有任何相关内容(哪怕只是间接提及),必须更新其"最近推进"为当前章节号。不要让伏笔僵死。`;
+- **铁律**:不要把“再次提到”“换个说法重述”“抽象复盘”当成推进。只有状态真的变了,才更新最近推进。只是出现过的旧 hook,放进 mention 数组。`;
 
   const fullCastBlock = bookRules?.enableFullCastTracking
     ? `\n## 全员追踪\nPOST_SETTLEMENT 必须额外包含:本章出场角色清单、角色间关系变动、未出场但被提及的角色。`
@@ -105,6 +106,7 @@ function buildSettlerOutputFormat(gp: GenreProfile): string {
         "notes": "本章为何推进/延后/回收"
       }
     ],
+    "mention": ["本章只是被提到、没有真实推进的 hookId"],
     "resolve": ["已回收的 hookId"],
     "defer": ["需要标记延后的 hookId"]
   },
@@ -128,9 +130,10 @@ function buildSettlerOutputFormat(gp: GenreProfile): string {
 规则:
 1. 只输出增量,不要重写完整 truth files
 2. 所有章节号字段都必须是整数,不能写自然语言
-3. 如果本章推进了旧 hook,lastAdvancedChapter 必须等于当前章号
-4. 如果回收或延后 hook,必须放在 resolve / defer 数组里
-5. chapterSummary.chapter 必须等于当前章节号`;
+3. 如果旧 hook 只是被提到、没有真实状态变化,把它放进 mention,不要更新 lastAdvancedChapter
+4. 如果本章推进了旧 hook,lastAdvancedChapter 必须等于当前章号
+5. 如果回收或延后 hook,必须放在 resolve / defer 数组里
+6. chapterSummary.chapter 必须等于当前章节号`;
 }
 
 export function buildSettlerUserPrompt(params: {

+ 6 - 2
packages/core/src/agents/writer-prompts.ts

@@ -103,7 +103,9 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s
 - Chapter-specific steering comes from the provided chapter intent and composed context package.
 - The outline is the default plan, not unconditional global supremacy.
 - When the runtime rule stack records an active L4 -> L3 override, follow the current task over local planning.
-- Keep hard guardrails compact: canon, continuity facts, and explicit prohibitions still win.`;
+- Keep hard guardrails compact: canon, continuity facts, and explicit prohibitions still win.
+- If an English Variance Brief is provided, obey it: avoid the listed phrase/opening/ending patterns and satisfy the scene obligation.
+- In multi-character scenes, include at least one resistance-bearing exchange instead of reducing the beat to summary or explanation.`;
   }
 
   return `## 输入治理契约
@@ -111,7 +113,9 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s
 - 本章具体写什么,以提供给你的 chapter intent 和 composed context package 为准。
 - 卷纲是默认规划,不是全局最高规则。
 - 当 runtime rule stack 明确记录了 L4 -> L3 的 active override 时,优先执行当前任务意图,再局部调整规划层。
-- 真正不能突破的只有硬护栏:世界设定、连续性事实、显式禁令。`;
+- 真正不能突破的只有硬护栏:世界设定、连续性事实、显式禁令。
+- 如果提供了 English Variance Brief,必须主动避开其中列出的高频短语、重复开头和重复结尾模式,并完成 scene obligation。
+- 多角色场景里,至少给出一轮带阻力的直接交锋,不要把人物关系写成纯解释或纯总结。`;
 }
 
 function buildLengthGuidance(lengthSpec: LengthSpec, language: "zh" | "en"): string {

+ 44 - 0
packages/core/src/agents/writer.ts

@@ -26,6 +26,9 @@ import { extractPOVFromOutline, filterMatrixByPOV, filterHooksByPOV } from "../u
 import { parseCreativeOutput } from "./writer-parser.js";
 import { buildRuntimeStateArtifacts, saveRuntimeStateSnapshot, type RuntimeStateArtifacts } from "../state/runtime-state-store.js";
 import type { RuntimeStateSnapshot } from "../state/state-reducer.js";
+import { parsePendingHooksMarkdown } from "../utils/memory-retrieval.js";
+import { analyzeHookHealth } from "../utils/hook-health.js";
+import { buildEnglishVarianceBrief } from "../utils/long-span-fatigue.js";
 import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
 import { join } from "node:path";
 
@@ -68,6 +71,12 @@ export interface WriteChapterOutput {
   readonly updatedCharacterMatrix: string;
   readonly postWriteErrors: ReadonlyArray<PostWriteViolation>;
   readonly postWriteWarnings: ReadonlyArray<PostWriteViolation>;
+  readonly hookHealthIssues?: ReadonlyArray<{
+    readonly severity: "critical" | "warning" | "info";
+    readonly category: string;
+    readonly description: string;
+    readonly suggestion: string;
+  }>;
   readonly tokenUsage?: TokenUsage;
 }
 
@@ -135,6 +144,12 @@ export class WriterAgent extends BaseAgent {
     const governedMemoryBlocks = input.contextPackage
       ? buildGovernedMemoryEvidenceBlocks(input.contextPackage)
       : undefined;
+    const englishVarianceBrief = resolvedLanguage === "en"
+      ? await buildEnglishVarianceBrief({
+          bookDir,
+          chapterNumber,
+        })
+      : null;
 
     // Build fanfic context if fanfic_canon.md exists
     const fanficContext: FanficContext | undefined = hasFanficCanon && bookRules?.fanficMode
@@ -162,6 +177,7 @@ export class WriterAgent extends BaseAgent {
           trace: input.trace,
           lengthSpec: resolvedLengthSpec,
           language: book.language ?? genreProfile.language,
+          varianceBrief: englishVarianceBrief?.text,
         })
       : (() => {
           // Smart context filtering: inject only relevant parts of truth files
@@ -232,6 +248,7 @@ export class WriterAgent extends BaseAgent {
       ? buildGovernedHookWorkingSet({
           hooksMarkdown: hooks,
           contextPackage: input.contextPackage,
+          chapterIntent: input.chapterIntent,
           chapterNumber,
           language: resolvedLanguage,
         })
@@ -290,6 +307,17 @@ export class WriterAgent extends BaseAgent {
       settlement.runtimeStateDelta,
       resolvedLanguage,
     );
+    const priorHookIds = new Set(parsePendingHooksMarkdown(hooks).map((hook) => hook.hookId));
+    const hookHealthIssues = settlement.runtimeStateDelta
+      && (runtimeStateArtifacts?.snapshot ?? settlement.runtimeStateSnapshot)
+      ? analyzeHookHealth({
+          language: resolvedLanguage,
+          chapterNumber,
+          hooks: (runtimeStateArtifacts?.snapshot ?? settlement.runtimeStateSnapshot)!.hooks.hooks,
+          delta: settlement.runtimeStateDelta,
+          existingHookIds: [...priorHookIds],
+        })
+      : [];
 
     // ── Post-write validation (regex + rule-based, zero LLM cost) ──
     const ruleViolations = [
@@ -319,6 +347,15 @@ export class WriterAgent extends BaseAgent {
         this.ctx.logger?.warn(`[${issue.severity}] ${issue.category}: ${issue.description}`);
       }
     }
+    if (hookHealthIssues.length > 0) {
+      this.logWarn(resolvedLanguage, {
+        zh: `伏笔健康:第${chapterNumber}章发现 ${hookHealthIssues.length} 条警告`,
+        en: `Hook health: ${hookHealthIssues.length} warning(s) in chapter ${chapterNumber}`,
+      });
+      for (const issue of hookHealthIssues) {
+        this.ctx.logger?.warn(`[${issue.severity}] ${issue.category}: ${issue.description}`);
+      }
+    }
 
     // ── Merge into WriteChapterOutput ──
     const tokenUsage: TokenUsage = {
@@ -348,6 +385,7 @@ export class WriterAgent extends BaseAgent {
       updatedCharacterMatrix: settlement.updatedCharacterMatrix,
       postWriteErrors,
       postWriteWarnings,
+      hookHealthIssues,
       tokenUsage,
     };
   }
@@ -662,6 +700,7 @@ ${lengthRequirementBlock}
     readonly trace?: ChapterTrace;
     readonly lengthSpec: LengthSpec;
     readonly language?: "zh" | "en";
+    readonly varianceBrief?: string;
   }): string {
     const contextSections = params.contextPackage.selectedContext
       .map((entry) => [
@@ -685,6 +724,9 @@ ${lengthRequirementBlock}
       ? params.trace.notes.map((note) => `- ${note}`).join("\n")
       : "- none";
     const lengthRequirementBlock = this.buildLengthRequirementBlock(params.lengthSpec, params.language ?? "zh");
+    const varianceBlock = params.varianceBrief
+      ? `\n${params.varianceBrief}\n`
+      : "";
 
     if (params.language === "en") {
       return `Write chapter ${params.chapterNumber}.
@@ -706,6 +748,7 @@ ${overrideLines}
 ## Trace Notes
 ${traceNotes}
 
+${varianceBlock}
 ${lengthRequirementBlock}
 - Output PRE_WRITE_CHECK first, then the chapter
 - Output only PRE_WRITE_CHECK, CHAPTER_TITLE, and CHAPTER_CONTENT blocks`;
@@ -730,6 +773,7 @@ ${overrideLines}
 ## 追踪说明
 ${traceNotes}
 
+${varianceBlock}
 ${lengthRequirementBlock}
 - 先输出写作自检表,再写正文
 - 只需输出 PRE_WRITE_CHECK、CHAPTER_TITLE、CHAPTER_CONTENT 三个区块`;

+ 9 - 0
packages/core/src/index.ts

@@ -103,6 +103,15 @@ export { countChapterLength, resolveLengthCountingMode, formatLengthCount, build
 export { createLogger, createStderrSink, createJsonLineSink, nullSink, type Logger, type LogSink, type LogLevel, type LogEntry } from "./utils/logger.js";
 export { loadProjectConfig, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH } from "./utils/config-loader.js";
 export { computeAnalytics, type AnalyticsData, type TokenStats } from "./utils/analytics.js";
+export {
+  collectStaleHookDebt,
+  evaluateHookAdmission,
+  classifyHookDisposition,
+  type HookAdmissionCandidate,
+  type HookAdmissionDecision,
+  type HookDisposition,
+} from "./utils/hook-governance.js";
+export { analyzeHookHealth } from "./utils/hook-health.js";
 
 // Pipeline
 export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type DraftResult, type PlanChapterResult, type ComposeChapterResult, type ReviseResult, type TruthFiles, type BookStatusInfo, type ImportChaptersInput, type ImportChaptersResult, type TokenUsageSummary } from "./pipeline/runner.js";

+ 15 - 0
packages/core/src/models/input-governance.ts

@@ -8,6 +8,15 @@ export const ChapterConflictSchema = z.object({
 
 export type ChapterConflict = z.infer<typeof ChapterConflictSchema>;
 
+export const HookAgendaSchema = z.object({
+  mustAdvance: z.array(z.string().min(1)).default([]),
+  eligibleResolve: z.array(z.string().min(1)).default([]),
+  staleDebt: z.array(z.string().min(1)).default([]),
+  avoidNewHookFamilies: z.array(z.string().min(1)).default([]),
+});
+
+export type HookAgenda = z.infer<typeof HookAgendaSchema>;
+
 export const ChapterIntentSchema = z.object({
   chapter: z.number().int().min(1),
   goal: z.string().min(1),
@@ -16,6 +25,12 @@ export const ChapterIntentSchema = z.object({
   mustAvoid: z.array(z.string()).default([]),
   styleEmphasis: z.array(z.string()).default([]),
   conflicts: z.array(ChapterConflictSchema).default([]),
+  hookAgenda: HookAgendaSchema.default({
+    mustAdvance: [],
+    eligibleResolve: [],
+    staleDebt: [],
+    avoidNewHookFamilies: [],
+  }),
 });
 
 export type ChapterIntent = z.infer<typeof ChapterIntentSchema>;

+ 2 - 0
packages/core/src/models/runtime-state.ts

@@ -84,6 +84,7 @@ export type CurrentStatePatch = z.infer<typeof CurrentStatePatchSchema>;
 
 export const HookOpsSchema = z.object({
   upsert: z.array(HookRecordSchema).default([]),
+  mention: z.array(z.string().min(1)).default([]),
   resolve: z.array(z.string().min(1)).default([]),
   defer: z.array(z.string().min(1)).default([]),
 });
@@ -97,6 +98,7 @@ export const RuntimeStateDeltaSchema = z.object({
   currentStatePatch: CurrentStatePatchSchema.optional(),
   hookOps: HookOpsSchema.default({
     upsert: [],
+    mention: [],
     resolve: [],
     defer: [],
   }),

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

@@ -1115,7 +1115,11 @@ export class PipelineRunner {
     });
     auditResult = {
       ...auditResult,
-      issues: [...auditResult.issues, ...longSpanFatigue.issues],
+      issues: [
+        ...auditResult.issues,
+        ...longSpanFatigue.issues,
+        ...(persistenceOutput.hookHealthIssues ?? []),
+      ],
     };
     finalWordCount = persistenceOutput.wordCount;
     const lengthWarnings = this.buildLengthWarnings(
@@ -1655,6 +1659,7 @@ ${matrix}`,
       ...analyzed,
       postWriteErrors: [],
       postWriteWarnings: [],
+      hookHealthIssues: output.hookHealthIssues,
       tokenUsage: output.tokenUsage,
     };
   }

+ 16 - 0
packages/core/src/state/state-reducer.ts

@@ -10,6 +10,7 @@ import {
   type RuntimeStateDelta,
   type StateManifest,
 } from "../models/runtime-state.js";
+import { evaluateHookAdmission } from "../utils/hook-governance.js";
 import { validateRuntimeState } from "./state-validator.js";
 
 export interface RuntimeStateSnapshot {
@@ -76,6 +77,21 @@ function applyHookOps(hooksState: HooksState, delta: RuntimeStateDelta): HooksSt
   const hooksById = new Map(hooksState.hooks.map((hook) => [hook.hookId, { ...hook }]));
 
   for (const hook of delta.hookOps.upsert) {
+    if (!hooksById.has(hook.hookId)) {
+      const admission = evaluateHookAdmission({
+        candidate: {
+          type: hook.type,
+          expectedPayoff: hook.expectedPayoff,
+          notes: hook.notes,
+        },
+        activeHooks: [...hooksById.values()].filter((candidate) => candidate.status !== "resolved"),
+      });
+
+      if (!admission.admit && admission.reason === "duplicate_family") {
+        throw new Error(`duplicate active hook family: ${hook.hookId} overlaps ${admission.matchedHookId}`);
+      }
+    }
+
     hooksById.set(hook.hookId, { ...hook });
   }
 

+ 53 - 0
packages/core/src/utils/governed-working-set.ts

@@ -8,6 +8,7 @@ import {
 export function buildGovernedHookWorkingSet(params: {
   readonly hooksMarkdown: string;
   readonly contextPackage: ContextPackage;
+  readonly chapterIntent?: string;
   readonly chapterNumber: number;
   readonly language: "zh" | "en";
   readonly keepRecent?: number;
@@ -28,8 +29,10 @@ export function buildGovernedHookWorkingSet(params: {
       .map((entry) => entry.source.slice("story/pending_hooks.md#".length))
       .filter(Boolean),
   );
+  const agendaIds = collectHookAgendaIds(params.chapterIntent);
   const workingSet = hooks.filter((hook) =>
     selectedIds.has(hook.hookId)
+      || agendaIds.has(hook.hookId)
       || isHookWithinChapterWindow(hook, params.chapterNumber, params.keepRecent ?? 5),
   );
 
@@ -40,6 +43,56 @@ export function buildGovernedHookWorkingSet(params: {
   return renderHookSnapshot(workingSet, params.language);
 }
 
+function collectHookAgendaIds(chapterIntent?: string): Set<string> {
+  if (!chapterIntent || chapterIntent.trim().length === 0) {
+    return new Set();
+  }
+
+  const ids = new Set<string>();
+  const lines = chapterIntent.split("\n");
+  let inHookAgenda = false;
+  let captureIds = false;
+
+  for (const rawLine of lines) {
+    const line = rawLine.trim();
+
+    if (line === "## Hook Agenda") {
+      inHookAgenda = true;
+      captureIds = false;
+      continue;
+    }
+
+    if (!inHookAgenda) {
+      continue;
+    }
+
+    if (line.startsWith("## ") && line !== "## Hook Agenda") {
+      break;
+    }
+
+    if (line === "### Must Advance" || line === "### Eligible Resolve" || line === "### Stale Debt") {
+      captureIds = true;
+      continue;
+    }
+
+    if (line.startsWith("### ")) {
+      captureIds = false;
+      continue;
+    }
+
+    if (!captureIds || !line.startsWith("- ")) {
+      continue;
+    }
+
+    const value = line.slice(2).trim();
+    if (value && value.toLowerCase() !== "none") {
+      ids.add(value);
+    }
+  }
+
+  return ids;
+}
+
 export function mergeTableMarkdownByKey(
   original: string,
   updated: string,

+ 180 - 0
packages/core/src/utils/hook-governance.ts

@@ -0,0 +1,180 @@
+import type { HookRecord, RuntimeStateDelta } from "../models/runtime-state.js";
+
+export type HookDisposition = "none" | "mention" | "advance" | "resolve" | "defer";
+
+export interface HookAdmissionCandidate {
+  readonly type: string;
+  readonly expectedPayoff?: string;
+  readonly notes?: string;
+}
+
+export interface HookAdmissionDecision {
+  readonly admit: boolean;
+  readonly reason: "admit" | "missing_type" | "missing_payoff_signal" | "duplicate_family";
+  readonly matchedHookId?: string;
+}
+
+export function collectStaleHookDebt(params: {
+  readonly hooks: ReadonlyArray<HookRecord>;
+  readonly chapterNumber: number;
+  readonly staleAfterChapters?: number;
+}): HookRecord[] {
+  const staleAfterChapters = params.staleAfterChapters ?? 10;
+  const staleCutoff = params.chapterNumber - staleAfterChapters;
+
+  return params.hooks
+    .filter((hook) => hook.status !== "resolved" && hook.status !== "deferred")
+    .filter((hook) => hook.startChapter <= params.chapterNumber)
+    .filter((hook) => hook.lastAdvancedChapter <= staleCutoff)
+    .sort((left, right) => (
+      left.lastAdvancedChapter - right.lastAdvancedChapter
+      || left.startChapter - right.startChapter
+      || left.hookId.localeCompare(right.hookId)
+    ));
+}
+
+export function evaluateHookAdmission(params: {
+  readonly candidate: HookAdmissionCandidate;
+  readonly activeHooks: ReadonlyArray<HookRecord>;
+}): HookAdmissionDecision {
+  const candidateType = normalizeText(params.candidate.type);
+  if (!candidateType) {
+    return {
+      admit: false,
+      reason: "missing_type",
+    };
+  }
+
+  const payoffSignal = [params.candidate.expectedPayoff, params.candidate.notes]
+    .filter((value): value is string => Boolean(value && value.trim()))
+    .join(" ")
+    .trim();
+
+  if (!payoffSignal) {
+    return {
+      admit: false,
+      reason: "missing_payoff_signal",
+    };
+  }
+
+  const candidateNormalized = normalizeText([
+    params.candidate.type,
+    params.candidate.expectedPayoff ?? "",
+    params.candidate.notes ?? "",
+  ].join(" "));
+  const candidateTerms = extractTerms(candidateNormalized);
+  const candidateChineseBigrams = extractChineseBigrams(candidateNormalized);
+
+  for (const hook of params.activeHooks) {
+    const activeNormalized = normalizeText([
+      hook.type,
+      hook.expectedPayoff,
+      hook.notes,
+    ].join(" "));
+
+    if (candidateNormalized === activeNormalized) {
+      return {
+        admit: false,
+        reason: "duplicate_family",
+        matchedHookId: hook.hookId,
+      };
+    }
+
+    if (candidateType !== normalizeText(hook.type)) {
+      continue;
+    }
+
+    const activeTerms = extractTerms(activeNormalized);
+    const overlap = [...candidateTerms].filter((term) => activeTerms.has(term));
+    const activeChineseBigrams = extractChineseBigrams(activeNormalized);
+    const chineseOverlap = [...candidateChineseBigrams].filter((term) =>
+      activeChineseBigrams.has(term),
+    );
+    if (overlap.length >= 2 || chineseOverlap.length >= 3) {
+      return {
+        admit: false,
+        reason: "duplicate_family",
+        matchedHookId: hook.hookId,
+      };
+    }
+  }
+
+  return {
+    admit: true,
+    reason: "admit",
+  };
+}
+
+export function classifyHookDisposition(params: {
+  readonly hookId: string;
+  readonly delta: Pick<RuntimeStateDelta, "chapter" | "hookOps">;
+}): HookDisposition {
+  const { hookId, delta } = params;
+
+  if (delta.hookOps.defer.includes(hookId)) {
+    return "defer";
+  }
+
+  if (delta.hookOps.resolve.includes(hookId)) {
+    return "resolve";
+  }
+
+  if (delta.hookOps.upsert.some((hook) => hook.hookId === hookId && hook.lastAdvancedChapter === delta.chapter)) {
+    return "advance";
+  }
+
+  if (delta.hookOps.mention.includes(hookId)) {
+    return "mention";
+  }
+
+  return "none";
+}
+
+function normalizeText(value: string): string {
+  return value
+    .trim()
+    .toLowerCase()
+    .replace(/[^a-z0-9\u4e00-\u9fff]+/g, " ")
+    .replace(/\s+/g, " ")
+    .trim();
+}
+
+function extractTerms(value: string): Set<string> {
+  const english = value
+    .split(" ")
+    .map((term) => term.trim())
+    .filter((term) => term.length >= 4)
+    .filter((term) => !STOP_WORDS.has(term));
+  const chinese = value.match(/[\u4e00-\u9fff]{2,6}/g) ?? [];
+  return new Set([...english, ...chinese]);
+}
+
+function extractChineseBigrams(value: string): Set<string> {
+  const segments = value.match(/[\u4e00-\u9fff]+/g) ?? [];
+  const terms = new Set<string>();
+
+  for (const segment of segments) {
+    if (segment.length < 2) {
+      continue;
+    }
+
+    for (let index = 0; index <= segment.length - 2; index += 1) {
+      terms.add(segment.slice(index, index + 2));
+    }
+  }
+
+  return terms;
+}
+
+const STOP_WORDS = new Set([
+  "that",
+  "this",
+  "with",
+  "from",
+  "into",
+  "still",
+  "just",
+  "have",
+  "will",
+  "reveal",
+]);

+ 112 - 0
packages/core/src/utils/hook-health.ts

@@ -0,0 +1,112 @@
+import type { AuditIssue } from "../agents/continuity.js";
+import type { HookRecord, RuntimeStateDelta } from "../models/runtime-state.js";
+import { classifyHookDisposition, collectStaleHookDebt } from "./hook-governance.js";
+
+export function analyzeHookHealth(params: {
+  readonly language: "zh" | "en";
+  readonly chapterNumber: number;
+  readonly hooks: ReadonlyArray<HookRecord>;
+  readonly delta?: Pick<RuntimeStateDelta, "chapter" | "hookOps">;
+  readonly existingHookIds?: ReadonlyArray<string>;
+  readonly maxActiveHooks?: number;
+  readonly staleAfterChapters?: number;
+  readonly noAdvanceWindow?: number;
+  readonly newHookBurstThreshold?: number;
+}): AuditIssue[] {
+  const maxActiveHooks = params.maxActiveHooks ?? 12;
+  const staleAfterChapters = params.staleAfterChapters ?? 10;
+  const noAdvanceWindow = params.noAdvanceWindow ?? 5;
+  const newHookBurstThreshold = params.newHookBurstThreshold ?? 2;
+  const issues: AuditIssue[] = [];
+
+  const activeHooks = params.hooks.filter((hook) => hook.status !== "resolved");
+
+  if (activeHooks.length > maxActiveHooks) {
+    issues.push(warning(
+      params.language,
+      params.language === "en"
+        ? `There are ${activeHooks.length} active hooks, above the recommended cap of ${maxActiveHooks}.`
+        : `当前有 ${activeHooks.length} 个活跃伏笔,已经高于建议上限 ${maxActiveHooks} 个。`,
+      params.language === "en"
+        ? "Prefer advancing, resolving, or deferring existing debt before opening more hooks."
+        : "优先推进、回收或延后已有伏笔,再继续开新伏笔。",
+    ));
+  }
+
+  const latestRealAdvance = activeHooks.reduce(
+    (max, hook) => Math.max(max, hook.lastAdvancedChapter),
+    0,
+  );
+  if (activeHooks.length > 0 && params.chapterNumber - latestRealAdvance >= noAdvanceWindow) {
+    issues.push(warning(
+      params.language,
+      params.language === "en"
+        ? `No real hook advancement has landed for ${params.chapterNumber - latestRealAdvance} chapters.`
+        : `已经连续 ${params.chapterNumber - latestRealAdvance} 章没有真实伏笔推进。`,
+      params.language === "en"
+        ? "Schedule one old hook for real movement instead of opening parallel restatements."
+        : "下一章优先让一个旧伏笔发生真实推进,而不是继续平行重述。",
+    ));
+  }
+
+  const staleHooks = collectStaleHookDebt({
+    hooks: activeHooks,
+    chapterNumber: params.chapterNumber,
+    staleAfterChapters,
+  });
+  if (params.delta && staleHooks.length > 0) {
+    const untouchedStale = staleHooks.filter((hook) => {
+      const disposition = classifyHookDisposition({
+        hookId: hook.hookId,
+        delta: params.delta!,
+      });
+      return disposition === "none" || disposition === "mention";
+    });
+
+    if (untouchedStale.length > 0) {
+      issues.push(warning(
+        params.language,
+        params.language === "en"
+          ? `Stale hooks received no real disposition this chapter: ${untouchedStale.map((hook) => hook.hookId).join(", ")}.`
+          : `本章没有真正处理这些陈旧伏笔:${untouchedStale.map((hook) => hook.hookId).join("、")}。`,
+        params.language === "en"
+          ? "Advance, resolve, or explicitly defer at least one stale hook."
+          : "至少推进、回收或明确延后一个陈旧伏笔。",
+      ));
+    }
+  }
+
+  if (params.delta) {
+    const existingHookIds = new Set(params.existingHookIds ?? []);
+    const newHookIds = params.delta.hookOps.upsert
+      .map((hook) => hook.hookId)
+      .filter((hookId) => !existingHookIds.has(hookId));
+
+    if (newHookIds.length >= newHookBurstThreshold && params.delta.hookOps.resolve.length === 0) {
+      issues.push(warning(
+        params.language,
+        params.language === "en"
+          ? `Opened ${newHookIds.length} new hooks without resolving any older debt.`
+          : `本章新开了 ${newHookIds.length} 个伏笔,但没有回收任何旧债。`,
+        params.language === "en"
+          ? "Keep the hook table from ballooning by pairing new openings with old payoffs."
+          : "控制伏笔膨胀,新开伏笔时尽量配套回收旧伏笔。",
+      ));
+    }
+  }
+
+  return issues;
+}
+
+function warning(
+  language: "zh" | "en",
+  description: string,
+  suggestion: string,
+): AuditIssue {
+  return {
+    severity: "warning",
+    category: language === "en" ? "Hook Debt" : "伏笔债务",
+    description,
+    suggestion,
+  };
+}

+ 173 - 0
packages/core/src/utils/long-span-fatigue.ts

@@ -16,6 +16,14 @@ export interface AnalyzeLongSpanFatigueInput {
   readonly language?: "zh" | "en";
 }
 
+export interface EnglishVarianceBrief {
+  readonly highFrequencyPhrases: ReadonlyArray<string>;
+  readonly repeatedOpeningPatterns: ReadonlyArray<string>;
+  readonly repeatedEndingShapes: ReadonlyArray<string>;
+  readonly sceneObligation: string;
+  readonly text: string;
+}
+
 interface SummaryRow {
   readonly chapter: number;
   readonly title: string;
@@ -27,6 +35,44 @@ const SENTENCE_SIMILARITY_THRESHOLD = 0.72;
 const CHINESE_PUNCTUATION = /[,。!?;:“”‘’()《》、\s\-—…·]/g;
 const ENGLISH_PUNCTUATION = /[^a-z0-9]+/gi;
 
+export async function buildEnglishVarianceBrief(params: {
+  readonly bookDir: string;
+  readonly chapterNumber: number;
+}): Promise<EnglishVarianceBrief | null> {
+  const chapterBodies = await loadPreviousChapterBodies(params.bookDir, params.chapterNumber, 24);
+  if (chapterBodies.length < 2) {
+    return null;
+  }
+
+  const summaryRows = await loadSummaryRows(join(params.bookDir, "story", "chapter_summaries.md"));
+  const recentRows = summaryRows
+    .filter((row) => row.chapter < params.chapterNumber)
+    .sort((left, right) => left.chapter - right.chapter)
+    .slice(-3);
+
+  const highFrequencyPhrases = collectRepeatedEnglishPhrases(chapterBodies);
+  const repeatedOpeningPatterns = collectRepeatedBoundaryPatterns(chapterBodies, "opening");
+  const repeatedEndingShapes = collectRepeatedBoundaryPatterns(chapterBodies, "ending");
+  const sceneObligation = chooseSceneObligation(recentRows, repeatedOpeningPatterns, repeatedEndingShapes);
+
+  const lines = [
+    "## English Variance Brief",
+    "",
+    `- High-frequency phrases to avoid: ${formatEnglishList(highFrequencyPhrases)}`,
+    `- Repeated opening patterns to avoid: ${formatEnglishList(repeatedOpeningPatterns)}`,
+    `- Repeated ending patterns to avoid: ${formatEnglishList(repeatedEndingShapes)}`,
+    `- Scene obligation: ${sceneObligation}`,
+  ];
+
+  return {
+    highFrequencyPhrases,
+    repeatedOpeningPatterns,
+    repeatedEndingShapes,
+    sceneObligation,
+    text: lines.join("\n"),
+  };
+}
+
 export async function analyzeLongSpanFatigue(
   input: AnalyzeLongSpanFatigueInput,
 ): Promise<{ readonly issues: ReadonlyArray<LongSpanFatigueIssue> }> {
@@ -76,6 +122,28 @@ async function loadSummaryRows(path: string): Promise<SummaryRow[]> {
   }
 }
 
+async function loadPreviousChapterBodies(
+  bookDir: string,
+  currentChapter: number,
+  limit: number,
+): Promise<string[]> {
+  const chaptersDir = join(bookDir, "chapters");
+  try {
+    const files = await readdir(chaptersDir);
+    const previousFiles = files
+      .map((file) => ({ file, chapter: Number.parseInt(file.slice(0, 4), 10) }))
+      .filter((entry) => Number.isFinite(entry.chapter) && entry.chapter < currentChapter && entry.file.endsWith(".md"))
+      .sort((left, right) => left.chapter - right.chapter)
+      .slice(-limit);
+
+    return Promise.all(
+      previousFiles.map((entry) => readFile(join(chaptersDir, entry.file), "utf-8")),
+    );
+  } catch {
+    return [];
+  }
+}
+
 function mergeCurrentSummary(rows: ReadonlyArray<SummaryRow>, currentSummary?: string): SummaryRow[] {
   const parsedCurrent = currentSummary ? parseSummaryRow(currentSummary) : null;
   if (!parsedCurrent) return [...rows];
@@ -225,6 +293,85 @@ function buildSentencePatternIssue(
   };
 }
 
+function collectRepeatedEnglishPhrases(chapterBodies: ReadonlyArray<string>): string[] {
+  const counts = new Map<string, number>();
+
+  for (const body of chapterBodies) {
+    const tokens = body
+      .toLowerCase()
+      .replace(/[^a-z0-9\s]+/gi, " ")
+      .split(/\s+/)
+      .filter((token) => token.length >= 3)
+      .filter((token) => !ENGLISH_STOP_WORDS.has(token));
+    const seen = new Set<string>();
+
+    for (let index = 0; index <= tokens.length - 3; index += 1) {
+      const phrase = `${tokens[index]} ${tokens[index + 1]} ${tokens[index + 2]}`;
+      seen.add(phrase);
+    }
+
+    for (const phrase of seen) {
+      counts.set(phrase, (counts.get(phrase) ?? 0) + 1);
+    }
+  }
+
+  return [...counts.entries()]
+    .filter(([, count]) => count >= 2)
+    .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
+    .slice(0, 3)
+    .map(([phrase]) => phrase);
+}
+
+function collectRepeatedBoundaryPatterns(
+  chapterBodies: ReadonlyArray<string>,
+  boundary: "opening" | "ending",
+): string[] {
+  const counts = new Map<string, number>();
+
+  for (const body of chapterBodies) {
+    const sentence = extractBoundarySentence(body, boundary);
+    if (!sentence) continue;
+
+    const tokens = sentence
+      .toLowerCase()
+      .replace(/[^a-z0-9\s]+/gi, " ")
+      .split(/\s+/)
+      .filter(Boolean)
+      .slice(0, 4);
+    if (tokens.length < 2) continue;
+
+    const pattern = tokens.join(" ");
+    counts.set(pattern, (counts.get(pattern) ?? 0) + 1);
+  }
+
+  return [...counts.entries()]
+    .filter(([, count]) => count >= 2)
+    .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
+    .slice(0, 3)
+    .map(([pattern]) => pattern);
+}
+
+function chooseSceneObligation(
+  rows: ReadonlyArray<SummaryRow>,
+  repeatedOpenings: ReadonlyArray<string>,
+  repeatedEndings: ReadonlyArray<string>,
+): string {
+  const recentTypes = rows
+    .map((row) => row.chapterType.trim().toLowerCase())
+    .filter((type) => type.length > 0);
+
+  if (recentTypes.length >= 3 && recentTypes.every((type) => type === recentTypes[0])) {
+    return "confrontation under pressure";
+  }
+  if (repeatedEndings.length > 0) {
+    return "discovery under pressure";
+  }
+  if (repeatedOpenings.length > 0) {
+    return "negotiation with withholding";
+  }
+  return "concealment with active pushback";
+}
+
 function extractBoundarySentence(content: string, boundary: "opening" | "ending"): string | null {
   const flattened = content
     .split("\n")
@@ -273,6 +420,10 @@ function summarizeSentence(sentence: string, language: "zh" | "en"): string {
   return collapsed.slice(0, 12);
 }
 
+function formatEnglishList(values: ReadonlyArray<string>): string {
+  return values.length > 0 ? values.join(", ") : "none";
+}
+
 function diceCoefficient(left: string, right: string): number {
   if (left === right) return 1;
   if (left.length < 2 || right.length < 2) return 0;
@@ -303,3 +454,25 @@ function isMeaningfulValue(value: string): boolean {
   const normalized = value.trim().toLowerCase();
   return normalized.length > 0 && normalized !== "none" && normalized !== "(none)" && normalized !== "无";
 }
+
+const ENGLISH_STOP_WORDS = new Set([
+  "the",
+  "and",
+  "but",
+  "with",
+  "from",
+  "into",
+  "that",
+  "this",
+  "there",
+  "again",
+  "while",
+  "after",
+  "before",
+  "were",
+  "was",
+  "had",
+  "has",
+  "have",
+  "kept",
+]);

+ 80 - 2
packages/core/src/utils/memory-retrieval.ts

@@ -1,16 +1,21 @@
 import { readFile } from "node:fs/promises";
 import { join } from "node:path";
+import type { HookAgenda } from "../models/input-governance.js";
 import {
   ChapterSummariesStateSchema,
   CurrentStateStateSchema,
   HooksStateSchema,
+  type HookRecord,
+  type HookStatus,
 } from "../models/runtime-state.js";
 import { MemoryDB, type Fact, type StoredHook, type StoredSummary } from "../state/memory-db.js";
 import { bootstrapStructuredStateFromMarkdown } from "../state/state-bootstrap.js";
+import { collectStaleHookDebt } from "./hook-governance.js";
 
 export interface MemorySelection {
   readonly summaries: ReadonlyArray<StoredSummary>;
   readonly hooks: ReadonlyArray<StoredHook>;
+  readonly activeHooks: ReadonlyArray<StoredHook>;
   readonly facts: ReadonlyArray<Fact>;
   readonly volumeSummaries: ReadonlyArray<VolumeSummarySelection>;
   readonly dbPath?: string;
@@ -95,13 +100,16 @@ export async function retrieveMemorySelection(params: {
         memoryDb.replaceCurrentFacts(facts);
       }
 
+      const activeHooks = memoryDb.getActiveHooks();
+
       return {
         summaries: selectRelevantSummaries(
           memoryDb.getSummaries(1, Math.max(1, params.chapterNumber - 1)),
           params.chapterNumber,
           narrativeQueryTerms,
         ),
-        hooks: selectRelevantHooks(memoryDb.getActiveHooks(), narrativeQueryTerms, params.chapterNumber),
+        hooks: selectRelevantHooks(activeHooks, narrativeQueryTerms, params.chapterNumber),
+        activeHooks,
         facts: selectRelevantFacts(memoryDb.getCurrentFacts(), factQueryTerms),
         volumeSummaries,
         dbPath: join(storyDir, "memory.db"),
@@ -117,10 +125,12 @@ export async function retrieveMemorySelection(params: {
   ]);
   const summaries = structuredSummaries?.rows ?? parseChapterSummariesMarkdown(summariesMarkdown);
   const hooks = structuredHooks?.hooks ?? parsePendingHooksMarkdown(hooksMarkdown);
+  const activeHooks = filterActiveHooks(hooks);
 
   return {
     summaries: selectRelevantSummaries(summaries, params.chapterNumber, narrativeQueryTerms),
-    hooks: selectRelevantHooks(hooks, narrativeQueryTerms, params.chapterNumber),
+    hooks: selectRelevantHooks(activeHooks, narrativeQueryTerms, params.chapterNumber),
+    activeHooks,
     facts: selectRelevantFacts(facts, factQueryTerms),
     volumeSummaries,
   };
@@ -203,6 +213,51 @@ export function renderHookSnapshot(
   ].join("\n");
 }
 
+export function buildPlannerHookAgenda(params: {
+  readonly hooks: ReadonlyArray<StoredHook>;
+  readonly chapterNumber: number;
+  readonly maxMustAdvance?: number;
+  readonly maxEligibleResolve?: number;
+  readonly maxStaleDebt?: number;
+}): HookAgenda {
+  const agendaHooks = params.hooks
+    .map(normalizeStoredHook)
+    .filter((hook) => !isFuturePlannedHook(hook, params.chapterNumber, 0))
+    .filter((hook) => hook.status !== "resolved" && hook.status !== "deferred");
+  const mustAdvance = agendaHooks
+    .slice()
+    .sort((left, right) => (
+      right.lastAdvancedChapter - left.lastAdvancedChapter
+      || left.startChapter - right.startChapter
+      || left.hookId.localeCompare(right.hookId)
+    ))
+    .slice(0, params.maxMustAdvance ?? 2)
+    .map((hook) => hook.hookId);
+  const staleDebt = collectStaleHookDebt({
+    hooks: agendaHooks,
+    chapterNumber: params.chapterNumber,
+  })
+    .slice(0, params.maxStaleDebt ?? 2)
+    .map((hook) => hook.hookId);
+  const eligibleResolve = agendaHooks
+    .filter((hook) => hook.startChapter <= params.chapterNumber - 3)
+    .filter((hook) => hook.lastAdvancedChapter >= params.chapterNumber - 2)
+    .sort((left, right) => (
+      left.startChapter - right.startChapter
+      || right.lastAdvancedChapter - left.lastAdvancedChapter
+      || left.hookId.localeCompare(right.hookId)
+    ))
+    .slice(0, params.maxEligibleResolve ?? 1)
+    .map((hook) => hook.hookId);
+
+  return {
+    mustAdvance,
+    eligibleResolve,
+    staleDebt,
+    avoidNewHookFamilies: [],
+  };
+}
+
 function openMemoryDB(bookDir: string): MemoryDB | null {
   try {
     return new MemoryDB(bookDir);
@@ -616,6 +671,29 @@ function scoreHook(hook: StoredHook, queryTerms: ReadonlyArray<string>): number
   return termScore + freshness;
 }
 
+function normalizeStoredHook(hook: StoredHook): HookRecord {
+  return {
+    hookId: hook.hookId,
+    startChapter: Math.max(0, hook.startChapter),
+    type: hook.type,
+    status: normalizeStoredHookStatus(hook.status),
+    lastAdvancedChapter: Math.max(0, hook.lastAdvancedChapter),
+    expectedPayoff: hook.expectedPayoff,
+    notes: hook.notes,
+  };
+}
+
+function normalizeStoredHookStatus(status: string): HookStatus {
+  if (/^(resolved|closed|done|已回收|已解决)$/i.test(status.trim())) return "resolved";
+  if (/^(deferred|paused|hold|延后|延期|搁置|暂缓)$/i.test(status.trim())) return "deferred";
+  if (/^(progressing|advanced|重大推进|持续推进)$/i.test(status.trim())) return "progressing";
+  return "open";
+}
+
+function filterActiveHooks(hooks: ReadonlyArray<StoredHook>): StoredHook[] {
+  return hooks.filter((hook) => normalizeStoredHookStatus(hook.status) !== "resolved");
+}
+
 export function isFuturePlannedHook(
   hook: StoredHook,
   chapterNumber: number,