Explorar o código

fix: harden v2 hook and planner state handling

Ma hai 7 meses
pai
achega
5910a4ded3

+ 61 - 1
packages/core/src/__tests__/architect.test.ts

@@ -206,8 +206,68 @@ describe("ArchitectAgent", () => {
 
     const result = await agent.generateFoundation(book);
 
-    expect(result.pendingHooks).toContain("| H01 | 1 | 主线 | 未开启 | 无 | 10章 | 主线核心钩子 |");
+    expect(result.pendingHooks).toContain("| H01 | 1 | 主线 | 未开启 | 0 | 10章 | 主线核心钩子 |");
     expect(result.pendingHooks).not.toContain("如果你愿意");
     expect(result.pendingHooks).not.toContain("前10章逐章细纲");
   });
+
+  it("normalizes architect pending hooks into runtime-compatible numeric progress columns", async () => {
+    const agent = new ArchitectAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const book: BookConfig = {
+      id: "zh-book",
+      title: "凌晨三点的证词",
+      platform: "tomato",
+      genre: "urban",
+      status: "active",
+      targetChapters: 80,
+      chapterWordCount: 2000,
+      language: "zh",
+      createdAt: "2026-03-25T00:00:00.000Z",
+      updatedAt: "2026-03-25T00:00:00.000Z",
+    };
+
+    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: [
+          "=== SECTION: story_bible ===",
+          "# 故事圣经",
+          "",
+          "=== SECTION: volume_outline ===",
+          "# 卷纲",
+          "",
+          "=== SECTION: book_rules ===",
+          "---",
+          "version: \"1.0\"",
+          "---",
+          "",
+          "=== SECTION: current_state ===",
+          "# 当前状态",
+          "",
+          "=== SECTION: pending_hooks ===",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| H13 | 22 | 舆情操盘 | 待推进 | 一家自媒体公司在多个旧案节点同步接单 | 51-60章 | 庄蔓出场后逐步揭露 |",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    const result = await agent.generateFoundation(book);
+
+    expect(result.pendingHooks).toContain("| H13 | 22 | 舆情操盘 | 待推进 | 0 | 51-60章 | 庄蔓出场后逐步揭露(初始线索:一家自媒体公司在多个旧案节点同步接单) |");
+  });
 });

+ 36 - 1
packages/core/src/__tests__/governed-working-set.test.ts

@@ -1,7 +1,42 @@
 import { describe, expect, it } from "vitest";
-import { buildGovernedCharacterMatrixWorkingSet } from "../utils/governed-working-set.js";
+import {
+  buildGovernedCharacterMatrixWorkingSet,
+  buildGovernedHookWorkingSet,
+} from "../utils/governed-working-set.js";
 
 describe("governed-working-set", () => {
+  it("filters out far-future hooks from the governed hook working set", () => {
+    const hooks = [
+      "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+      "| --- | --- | --- | --- | --- | --- | --- |",
+      "| opening-call | 1 | mystery | open | 0 | 8 | 匿名来电开篇出现 |",
+      "| nearby-ledger | 4 | evidence | open | 0 | 12 | 近期开启的账本线 |",
+      "| future-pr-machine | 22 | conspiracy | open | 0 | 60 | 远期舆情操盘线 |",
+      "| future-template | 45 | system | open | 0 | 80 | 远期系统性话术线 |",
+    ].join("\n");
+
+    const filtered = buildGovernedHookWorkingSet({
+      hooksMarkdown: hooks,
+      contextPackage: {
+        chapter: 1,
+        selectedContext: [
+          {
+            source: "story/pending_hooks.md#opening-call",
+            reason: "Current chapter opening hook.",
+            excerpt: "mystery | open | 8 | 匿名来电开篇出现",
+          },
+        ],
+      },
+      chapterNumber: 1,
+      language: "zh",
+    });
+
+    expect(filtered).toContain("| opening-call | 1 | mystery | open | 0 | 8 | 匿名来电开篇出现 |");
+    expect(filtered).toContain("| nearby-ledger | 4 | evidence | open | 0 | 12 | 近期开启的账本线 |");
+    expect(filtered).not.toContain("future-pr-machine");
+    expect(filtered).not.toContain("future-template");
+  });
+
   it("filters character matrix by exact governed character mentions instead of broad capitalized tokens", () => {
     const matrix = [
       "# Character Matrix",

+ 102 - 0
packages/core/src/__tests__/memory-retrieval.test.ts

@@ -896,6 +896,108 @@ describe("retrieveMemorySelection", () => {
     expect(result.hooks.map((hook) => hook.hookId)).not.toContain("stale-resolved");
   });
 
+  it("does not surface far-future unstarted hooks in early chapter retrieval", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-future-hook-gate-test-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    const stateDir = join(storyDir, "state");
+    await mkdir(stateDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(
+        join(stateDir, "manifest.json"),
+        JSON.stringify({
+          schemaVersion: 2,
+          language: "zh",
+          lastAppliedChapter: 0,
+          projectionVersion: 1,
+          migrationWarnings: [],
+        }, null, 2),
+        "utf-8",
+      ),
+      writeFile(
+        join(stateDir, "current_state.json"),
+        JSON.stringify({
+          chapter: 0,
+          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: "future-gault",
+              startChapter: 54,
+              type: "threat",
+              status: "open",
+              lastAdvancedChapter: 0,
+              expectedPayoff: "Late assembly loss",
+              notes: "Far-future disruption only.",
+            },
+            {
+              hookId: "future-ledger-trial",
+              startChapter: 22,
+              type: "institutional",
+              status: "open",
+              lastAdvancedChapter: 0,
+              expectedPayoff: "Late court hearing",
+              notes: "Far-future institutional clash.",
+            },
+            {
+              hookId: "opening-call",
+              startChapter: 1,
+              type: "mystery",
+              status: "open",
+              lastAdvancedChapter: 0,
+              expectedPayoff: "Trace the anonymous caller",
+              notes: "Opening anonymous call.",
+            },
+            {
+              hookId: "nearby-ledger",
+              startChapter: 4,
+              type: "evidence",
+              status: "open",
+              lastAdvancedChapter: 0,
+              expectedPayoff: "Find the first ledger fragment",
+              notes: "Near-future evidence reveal.",
+            },
+            {
+              hookId: "future-final-choice",
+              startChapter: 71,
+              type: "climax",
+              status: "open",
+              lastAdvancedChapter: 0,
+              expectedPayoff: "Final disclosure choice",
+              notes: "Endgame only.",
+            },
+          ],
+        }, null, 2),
+        "utf-8",
+      ),
+    ]);
+
+    const result = await retrieveMemorySelection({
+      bookDir,
+      chapterNumber: 1,
+      goal: "稳住开篇压力,不提前展开远期线。",
+      mustKeep: ["匿名来电必须留在开篇。"],
+    });
+
+    expect(result.hooks.map((hook) => hook.hookId)).toEqual([
+      "opening-call",
+      "nearby-ledger",
+    ]);
+  });
+
   it("does not resurface a resolved hook just because mustKeep shares an artifact term", async () => {
     root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-resolved-artifact-test-"));
     const bookDir = join(root, "book");

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

@@ -2977,6 +2977,122 @@ describe("PipelineRunner", () => {
     }
   });
 
+  it("re-audits revisions against updated state overrides instead of stale on-disk truth files", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const storyDir = join(state.bookDir(bookId), "story");
+    const chaptersDir = join(state.bookDir(bookId), "chapters");
+    const originalBody = "Taryn kept one hand on the annexe key and listened at the door.";
+    const revisedBody = `${originalBody}\n\nHe checked the seal again before he moved.`;
+
+    await state.saveBookConfig(bookId, {
+      ...(await state.loadBookConfig(bookId)),
+      platform: "other",
+      genre: "progression",
+      language: "en",
+      chapterWordCount: 1800,
+    });
+
+    await Promise.all([
+      writeFile(join(chaptersDir, "0001_First.md"), `# Chapter 1: First\n\nOpening chapter.`, "utf-8"),
+      writeFile(join(chaptersDir, "0002_Test_Chapter.md"), `# Chapter 2: Test Chapter\n\n${originalBody}`, "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), createStateCard({
+        chapter: 1,
+        location: "Orsden archive lower hall",
+        protagonistState: "Taryn is still moving under Renn's first warning.",
+        goal: "Reach the annexe.",
+        conflict: "The archive is already compromised.",
+      }), "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+    ]);
+    await state.saveChapterIndex(bookId, [
+      {
+        number: 1,
+        title: "First",
+        status: "ready-for-review",
+        wordCount: countChapterLength("Opening chapter.", "en_words"),
+        createdAt: "2026-03-19T00:00:00.000Z",
+        updatedAt: "2026-03-19T00:00:00.000Z",
+        auditIssues: [],
+        lengthWarnings: [],
+      },
+      {
+        number: 2,
+        title: "Test Chapter",
+        status: "audit-failed",
+        wordCount: countChapterLength(originalBody, "en_words"),
+        createdAt: "2026-03-19T00:00:00.000Z",
+        updatedAt: "2026-03-19T00:00:00.000Z",
+        auditIssues: [],
+        lengthWarnings: [],
+      },
+    ]);
+
+    const auditChapter = vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: false,
+          issues: [{
+            severity: "warning",
+            category: "Pacing Check",
+            description: "The beat needs a firmer end stop.",
+            suggestion: "Tighten the closing move.",
+          }],
+          summary: "needs revision",
+        }),
+      )
+      .mockImplementationOnce(async (_bookDir, _chapterContent, chapterNumber, _genre, options) => {
+        const overrideState = (options as { truthFileOverrides?: { currentState?: string } } | undefined)
+          ?.truthFileOverrides?.currentState;
+        if (chapterNumber === 2 && overrideState?.includes("| Current Chapter | 2 |")) {
+          return createAuditResult({
+            passed: true,
+            issues: [],
+            summary: "clean",
+          });
+        }
+
+        return createAuditResult({
+          passed: false,
+          issues: [{
+            severity: "critical",
+            category: "Chronicle Drift Check",
+            description: "The chapter is presented as 'chapter 2', but the supplied Current State Card still lists 'Current Chapter | 1'.",
+            suggestion: "Sync the state card before re-audit.",
+          }],
+          summary: "stale state card",
+        });
+      });
+
+    vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
+      createReviseOutput({
+        revisedContent: revisedBody,
+        wordCount: countChapterLength(revisedBody, "en_words"),
+        fixedIssues: ["- Synced the annexe beat and tightened the ending."],
+        updatedState: createStateCard({
+          chapter: 2,
+          location: "East annexe corridor",
+          protagonistState: "Taryn is pressed against the annexe door with the true key in hand.",
+          goal: "Open the annexe before the cart clears the court.",
+          conflict: "A forged key and rival searchers have turned lawful access into a trap.",
+        }),
+        updatedHooks: "# Pending Hooks\n",
+      }),
+    );
+
+    try {
+      const result = await runner.reviseDraft(bookId, 2);
+      const savedIndex = await state.loadChapterIndex(bookId);
+
+      expect(auditChapter).toHaveBeenCalledTimes(2);
+      expect(result.applied).toBe(true);
+      expect(result.status).toBe("ready-for-review");
+      expect(savedIndex[1]?.status).toBe("ready-for-review");
+      expect(savedIndex[1]?.auditIssues).toEqual([]);
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("uses chapter length telemetry target for manual revise when available", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const storyDir = join(state.bookDir(bookId), "story");

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

@@ -184,6 +184,64 @@ describe("PlannerAgent", () => {
     expect(result.intent.goal).not.toContain("Advance chapter 3 with clear narrative focus.");
   });
 
+  it("uses the next paragraph for bold standalone English chapter labels instead of capturing markdown markers", async () => {
+    book = {
+      ...book,
+      genre: "other",
+      language: "en",
+    };
+
+    await Promise.all([
+      writeFile(
+        join(storyDir, "current_focus.md"),
+        [
+          "# Current Focus",
+          "",
+          "## Active Focus",
+          "",
+          "(Describe what the next 1-3 chapters should prioritize.)",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "volume_outline.md"),
+        [
+          "## Volume 1 - The Dead Examiner",
+          "**Chapter Range:** 1-12",
+          "",
+          "**Key Turning Points:**",
+          "- Ch1: Renn dies after summoning Taryn to review irregular treaty folios.",
+          "",
+          "### Golden First Three Chapters Rule",
+          "",
+          "**Chapter 2:**",
+          "Show Taryn's edge through action, not exposition. He uses registry numbering logic to identify which folios are decoys and which conceal a ledger fragment.",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+    ]);
+
+    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: 2,
+    });
+
+    expect(result.intent.outlineNode).toContain("Show Taryn's edge through action");
+    expect(result.intent.outlineNode).not.toBe("**");
+    expect(result.intent.goal).toContain("Show Taryn's edge through action");
+    expect(result.intent.goal).not.toBe("**");
+  });
+
   it("preserves hard facts from state and canon in mustKeep", async () => {
     const planner = new PlannerAgent({
       client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],

+ 84 - 5
packages/core/src/agents/architect.ts

@@ -4,6 +4,7 @@ import type { GenreProfile } from "../models/genre-profile.js";
 import { readGenreProfile } from "./rules-reader.js";
 import { writeFile, mkdir } from "node:fs/promises";
 import { join } from "node:path";
+import { renderHookSnapshot } from "../utils/memory-retrieval.js";
 
 export interface ArchitectOutput {
   readonly storyBible: string;
@@ -194,9 +195,19 @@ enableFullCastTracking: false
 
     const pendingHooksPrompt = resolvedLanguage === "en"
       ? `Initial hook pool (Markdown table):
-| hook_id | start_chapter | type | status | latest_progress | expected_payoff | notes |`
+| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |
+
+Rules for the hook table:
+- Column 5 must be a pure chapter number, never natural-language description
+- During book creation, all planned hooks are still unapplied, so last_advanced_chapter = 0
+- If you want to describe the initial clue/signal, put it in notes instead of column 5`
       : `初始伏笔池(Markdown表格):
-| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |`;
+| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |
+
+伏笔表规则:
+- 第5列必须是纯数字章节号,不能写自然语言描述
+- 建书阶段所有伏笔都还没正式推进,所以第5列统一填 0
+- 如果要说明“初始线索/最初信号”,写进备注,不要写进第5列`;
 
     const finalRequirementsPrompt = resolvedLanguage === "en"
       ? `Generated content must:
@@ -652,9 +663,10 @@ prohibitions:
       if (!section) {
         return `[${name} 生成失败,需要重新生成]`;
       }
-      return name === "pending_hooks"
-        ? this.stripTrailingAssistantCoda(section)
-        : section;
+      if (name !== "pending_hooks") {
+        return section;
+      }
+      return this.normalizePendingHooksSection(this.stripTrailingAssistantCoda(section));
     };
 
     return {
@@ -680,4 +692,71 @@ prohibitions:
 
     return lines.slice(0, cutoff).join("\n").trimEnd();
   }
+
+  private normalizePendingHooksSection(section: string): string {
+    const rows = section
+      .split("\n")
+      .map((line) => line.trim())
+      .filter((line) => line.startsWith("|"))
+      .filter((line) => !line.includes("---"))
+      .map((line) => line.split("|").slice(1, -1).map((cell) => cell.trim()))
+      .filter((cells) => cells.some(Boolean));
+
+    if (rows.length === 0) {
+      return section;
+    }
+
+    const dataRows = rows.filter((row) => (row[0] ?? "").toLowerCase() !== "hook_id");
+    if (dataRows.length === 0) {
+      return section;
+    }
+
+    const language: "zh" | "en" = /[\u4e00-\u9fff]/.test(section) ? "zh" : "en";
+    const normalizedHooks = dataRows.map((row, index) => {
+      const rawProgress = row[4] ?? "";
+      const normalizedProgress = this.parseHookChapterNumber(rawProgress);
+      const seedNote = normalizedProgress === 0 && this.hasNarrativeProgress(rawProgress)
+        ? (language === "zh" ? `初始线索:${rawProgress}` : `initial signal: ${rawProgress}`)
+        : "";
+      const notes = this.mergeHookNotes(row[6] ?? "", seedNote, language);
+
+      return {
+        hookId: row[0] || `hook-${index + 1}`,
+        startChapter: this.parseHookChapterNumber(row[1]),
+        type: row[2] ?? "",
+        status: row[3] ?? "open",
+        lastAdvancedChapter: normalizedProgress,
+        expectedPayoff: row[5] ?? "",
+        notes,
+      };
+    });
+
+    return renderHookSnapshot(normalizedHooks, language);
+  }
+
+  private parseHookChapterNumber(value: string | undefined): number {
+    if (!value) return 0;
+    const match = value.match(/\d+/);
+    return match ? parseInt(match[0], 10) : 0;
+  }
+
+  private hasNarrativeProgress(value: string | undefined): boolean {
+    const normalized = (value ?? "").trim().toLowerCase();
+    if (!normalized) return false;
+    return !["0", "none", "n/a", "na", "-", "无", "未推进"].includes(normalized);
+  }
+
+  private mergeHookNotes(notes: string, seedNote: string, language: "zh" | "en"): string {
+    const trimmedNotes = notes.trim();
+    const trimmedSeed = seedNote.trim();
+    if (!trimmedSeed) {
+      return trimmedNotes;
+    }
+    if (!trimmedNotes) {
+      return trimmedSeed;
+    }
+    return language === "zh"
+      ? `${trimmedNotes}(${trimmedSeed})`
+      : `${trimmedNotes} (${trimmedSeed})`;
+  }
 }

+ 9 - 1
packages/core/src/agents/continuity.ts

@@ -325,9 +325,14 @@ export class ContinuityAuditor extends BaseAgent {
       chapterIntent?: string;
       contextPackage?: ContextPackage;
       ruleStack?: RuleStack;
+      truthFileOverrides?: {
+        currentState?: string;
+        ledger?: string;
+        hooks?: string;
+      };
     },
   ): Promise<AuditResult> {
-    const [currentState, ledger, hooks, styleGuideRaw, subplotBoard, emotionalArcs, characterMatrix, chapterSummaries, parentCanon, fanficCanon, volumeOutline] =
+    const [diskCurrentState, diskLedger, diskHooks, styleGuideRaw, subplotBoard, emotionalArcs, characterMatrix, chapterSummaries, parentCanon, fanficCanon, volumeOutline] =
       await Promise.all([
         this.readFileSafe(join(bookDir, "story/current_state.md")),
         this.readFileSafe(join(bookDir, "story/particle_ledger.md")),
@@ -341,6 +346,9 @@ export class ContinuityAuditor extends BaseAgent {
         this.readFileSafe(join(bookDir, "story/fanfic_canon.md")),
         this.readFileSafe(join(bookDir, "story/volume_outline.md")),
       ]);
+    const currentState = options?.truthFileOverrides?.currentState ?? diskCurrentState;
+    const ledger = options?.truthFileOverrides?.ledger ?? diskLedger;
+    const hooks = options?.truthFileOverrides?.hooks ?? diskHooks;
 
     const hasParentCanon = parentCanon !== "(文件不存在)";
     const hasFanficCanon = fanficCanon !== "(文件不存在)";

+ 46 - 10
packages/core/src/agents/planner.ts

@@ -298,16 +298,22 @@ export class PlannerAgent extends BaseAgent {
       new RegExp(`^(?:[-*]\\s+)?(?:\\*\\*)?第\\s*${chapterNumber}\\s*章(?:[::-])?(?:\\*\\*)?\\s*(.+)$`),
     ];
 
-    const inlineMatch = lines
-      .map((line) => ({
-        line,
-        match: inlinePatterns
-          .map((pattern) => line.match(pattern))
-          .find((result): result is RegExpMatchArray => Boolean(result?.[1]?.trim())),
-      }))
-      .find((entry) => entry.match);
-    if (inlineMatch?.match?.[1]) {
-      return inlineMatch.match[1].trim();
+    for (let index = 0; index < lines.length; index += 1) {
+      const line = lines[index]!;
+      const match = inlinePatterns
+        .map((pattern) => line.match(pattern))
+        .find((result): result is RegExpMatchArray => Boolean(result));
+      if (!match) continue;
+
+      const inlineContent = this.cleanOutlineContent(match[1]);
+      if (inlineContent) {
+        return inlineContent;
+      }
+
+      const nextContent = this.findNextOutlineContent(lines, index + 1);
+      if (nextContent) {
+        return nextContent;
+      }
     }
 
     const heading = lines.find((line) => chapterPatterns.some((pattern) => pattern.test(line)));
@@ -318,6 +324,36 @@ export class PlannerAgent extends BaseAgent {
     return nextLine && !nextLine.startsWith("#") ? nextLine : heading.replace(/^#+\s*/, "");
   }
 
+  private cleanOutlineContent(content?: string): string | undefined {
+    const cleaned = content?.trim();
+    if (!cleaned) return undefined;
+    if (/^[*_`~::-]+$/.test(cleaned)) return undefined;
+    return cleaned;
+  }
+
+  private findNextOutlineContent(lines: ReadonlyArray<string>, startIndex: number): string | undefined {
+    for (let index = startIndex; index < lines.length; index += 1) {
+      const line = lines[index]!;
+      if (!line || line.startsWith("#")) {
+        continue;
+      }
+
+      if (
+        /^(?:[-*]\s+)?(?:\*\*)?Chapter\s*\d+(?:[::-])?(?:\*\*)?\s*$/i.test(line)
+        || /^(?:[-*]\s+)?(?:\*\*)?第\s*\d+\s*章(?:[::-])?(?:\*\*)?\s*$/.test(line)
+      ) {
+        return undefined;
+      }
+
+      const cleaned = this.cleanOutlineContent(line);
+      if (cleaned) {
+        return cleaned;
+      }
+    }
+
+    return undefined;
+  }
+
   private hasKeywordOverlap(left: string, right: string): boolean {
     const keywords = this.extractKeywords(left);
     if (keywords.length === 0) return false;

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

@@ -717,8 +717,20 @@ export class PipelineRunner {
               chapterIntent: reviseControlInput.plan.intentMarkdown,
               contextPackage: reviseControlInput.composed.contextPackage,
               ruleStack: reviseControlInput.composed.ruleStack,
+              truthFileOverrides: {
+                currentState: reviseOutput.updatedState !== "(状态卡未更新)" ? reviseOutput.updatedState : undefined,
+                ledger: reviseOutput.updatedLedger !== "(账本未更新)" ? reviseOutput.updatedLedger : undefined,
+                hooks: reviseOutput.updatedHooks !== "(伏笔池未更新)" ? reviseOutput.updatedHooks : undefined,
+              },
             }
-          : { temperature: 0 },
+          : {
+              temperature: 0,
+              truthFileOverrides: {
+                currentState: reviseOutput.updatedState !== "(状态卡未更新)" ? reviseOutput.updatedState : undefined,
+                ledger: reviseOutput.updatedLedger !== "(账本未更新)" ? reviseOutput.updatedLedger : undefined,
+                hooks: reviseOutput.updatedHooks !== "(伏笔池未更新)" ? reviseOutput.updatedHooks : undefined,
+              },
+            },
       });
       const effectivePostRevision = this.restoreActionableAuditIfLost(
         preRevision,
@@ -2155,6 +2167,11 @@ ${matrix}`,
       chapterIntent?: string;
       contextPackage?: ContextPackage;
       ruleStack?: RuleStack;
+      truthFileOverrides?: {
+        currentState?: string;
+        ledger?: string;
+        hooks?: string;
+      };
     };
   }): Promise<{
     auditResult: AuditResult;

+ 6 - 4
packages/core/src/utils/governed-working-set.ts

@@ -1,5 +1,9 @@
 import type { ContextPackage } from "../models/input-governance.js";
-import { parsePendingHooksMarkdown, renderHookSnapshot } from "./memory-retrieval.js";
+import {
+  isHookWithinChapterWindow,
+  parsePendingHooksMarkdown,
+  renderHookSnapshot,
+} from "./memory-retrieval.js";
 
 export function buildGovernedHookWorkingSet(params: {
   readonly hooksMarkdown: string;
@@ -24,11 +28,9 @@ export function buildGovernedHookWorkingSet(params: {
       .map((entry) => entry.source.slice("story/pending_hooks.md#".length))
       .filter(Boolean),
   );
-  const recentCutoff = Math.max(0, params.chapterNumber - (params.keepRecent ?? 5));
   const workingSet = hooks.filter((hook) =>
     selectedIds.has(hook.hookId)
-      || hook.lastAdvancedChapter >= recentCutoff
-      || hook.startChapter >= recentCutoff,
+      || isHookWithinChapterWindow(hook, params.chapterNumber, params.keepRecent ?? 5),
   );
 
   if (workingSet.length === 0 || workingSet.length >= hooks.length) {

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

@@ -22,6 +22,8 @@ export interface VolumeSummarySelection {
   readonly anchor: string;
 }
 
+export const DEFAULT_HOOK_LOOKAHEAD_CHAPTERS = 3;
+
 export async function retrieveMemorySelection(params: {
   readonly bookDir: string;
   readonly chapterNumber: number;
@@ -508,8 +510,7 @@ function selectRelevantHooks(
   const primary = ranked
     .filter((entry) => (
       entry.matched
-      || entry.hook.lastAdvancedChapter >= recentCutoff
-      || entry.hook.startChapter >= recentCutoff
+      || isHookWithinChapterWindow(entry.hook, chapterNumber, 5)
     ))
     .sort((left, right) => right.score - left.score || right.hook.lastAdvancedChapter - left.hook.lastAdvancedChapter)
     .slice(0, 3);
@@ -518,6 +519,7 @@ function selectRelevantHooks(
   const stale = ranked
     .filter((entry) => (
       !selectedIds.has(entry.hook.hookId)
+      && !isFuturePlannedHook(entry.hook, chapterNumber)
       && entry.hook.lastAdvancedChapter <= staleCutoff
       && isUnresolvedHook(entry.hook.status)
     ))
@@ -614,6 +616,41 @@ function scoreHook(hook: StoredHook, queryTerms: ReadonlyArray<string>): number
   return termScore + freshness;
 }
 
+export function isFuturePlannedHook(
+  hook: StoredHook,
+  chapterNumber: number,
+  lookahead: number = DEFAULT_HOOK_LOOKAHEAD_CHAPTERS,
+): boolean {
+  return hook.lastAdvancedChapter <= 0 && hook.startChapter > chapterNumber + lookahead;
+}
+
+export function isHookWithinChapterWindow(
+  hook: StoredHook,
+  chapterNumber: number,
+  recentWindow: number = 5,
+  lookahead: number = DEFAULT_HOOK_LOOKAHEAD_CHAPTERS,
+): boolean {
+  const recentCutoff = Math.max(0, chapterNumber - recentWindow);
+
+  if (hook.lastAdvancedChapter > 0 && hook.lastAdvancedChapter >= recentCutoff) {
+    return true;
+  }
+
+  if (hook.lastAdvancedChapter > 0) {
+    return false;
+  }
+
+  if (hook.startChapter <= 0) {
+    return true;
+  }
+
+  if (hook.startChapter >= recentCutoff && hook.startChapter <= chapterNumber) {
+    return true;
+  }
+
+  return hook.startChapter > chapterNumber && hook.startChapter <= chapterNumber + lookahead;
+}
+
 function matchesAny(text: string, queryTerms: ReadonlyArray<string>): boolean {
   return queryTerms.some((term) => includesTerm(text, term));
 }