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

feat(v13): hook account system — memo ledger + stale retrieval + draft gate [phase 9]

Closes the debt-accumulation loophole where planners kept opening hooks but
never resolved them. Three pieces, one cohesive mechanism:

- Phase 9-1: new required memo section "## 本章 hook 账" / "## Hook ledger
  for this chapter" with open / advance / resolve / defer sub-lists. Parser
  gates memos that skip it; system prompt adds principle #12 and format spec.
- Phase 9-2: computeRecyclableHooks surfaces hooks that are silent past
  their threshold (pressured/near_payoff ≥5ch, core ≥8ch, plain ≥10ch) so
  the planner prompt shows "MUST address" stale debt alongside fresh threads.
- Phase 9-3: hook-ledger-validator parses the memo's advance/resolve
  commitments and fails review as critical when the draft body does not
  echo each committed hook_id, forcing a reviser pass instead of silent
  drift.

All 684 vitest tests pass; typecheck clean.
Ma 6 месяцев назад
Родитель
Сommit
49e8ec661f

+ 16 - 2
packages/core/src/__tests__/chapter-memo-parser.test.ts

@@ -30,6 +30,14 @@ const SECTIONS = `
 - 信息改变:主角掌握实证
 - 关系改变:主角对阿泽产生戒心
 
+## 本章 hook 账
+advance:
+- H03 "七号门异常" → 从 pressured → near_payoff(主角拿到实证)
+resolve:
+- S004 "锁芯刮痕" → 本章核验完毕
+defer:
+- H07 "幕后主使" → 压到第 20 章,时机未到
+
 ## 不要做
 - 不要让对手突然降智
 - 不要直接点破幕后主使
@@ -99,6 +107,7 @@ describe("parseMemo", () => {
     "## 日常/过渡承担什么任务",
     "## 关键抉择过三连问",
     "## 章尾必须发生的改变",
+    "## 本章 hook 账",
     "## 不要做",
   ])("throws when body is missing section %s", (heading) => {
     const body = SECTIONS.replace(heading, "## SECTION-REMOVED");
@@ -154,6 +163,8 @@ describe("parseMemo", () => {
         "",
         "## 章尾必须发生的改变",
         "",
+        "## 本章 hook 账",
+        "",
         "## 不要做",
         "",
       ].join("\n");
@@ -172,8 +183,8 @@ describe("parseMemo", () => {
 
     it("rejects a memo where one section has 'TODO' (under 20 chars)", () => {
       const body = SECTIONS.replace(
-        /## 章尾必须发生的改变\n[\s\S]*?\n\n## 不要做/,
-        "## 章尾必须发生的改变\nTODO\n\n## 不要做",
+        /## 章尾必须发生的改变\n[\s\S]*?\n\n## 本章 hook 账/,
+        "## 章尾必须发生的改变\nTODO\n\n## 本章 hook 账",
       );
       expect(() => parseMemo(makeRaw({ body }), 12, false))
         .toThrow(/empty sections.*章尾必须发生的改变/);
@@ -201,6 +212,9 @@ describe("parseMemo", () => {
         "## 章尾必须发生的改变",
         "关系改变:主角和协作者从交易关系微微转向共担风险的同伴。",
         "",
+        "## 本章 hook 账",
+        "advance: H03 线索 → 从 planted 推到 pressured(本章让协作者第一次点头)。",
+        "",
         "## 不要做",
         "无",
       ].join("\n");

+ 3 - 0
packages/core/src/__tests__/continuity.test.ts

@@ -347,6 +347,9 @@ describe("ContinuityAuditor", () => {
       "## 章尾必须发生的改变",
       "陆焚拿回残刃,被人目击。",
       "",
+      "## 本章 hook 账",
+      "resolve: H11 残刃下落 → 本章找回。defer: H04 幕后主使 → 留到第 50 章。",
+      "",
       "## 不要做",
       "不要写成大段打斗。",
     ].join("\n");

+ 133 - 0
packages/core/src/__tests__/hook-ledger-validator.test.ts

@@ -0,0 +1,133 @@
+import { describe, it, expect } from "vitest";
+import {
+  parseHookLedger,
+  validateHookLedger,
+} from "../utils/hook-ledger-validator.js";
+
+const ZH_MEMO = `## 当前任务
+林秋潜入账房取回账册。
+
+## 本章 hook 账
+open:
+- [new] 旧港眼线盯梢 || 理由:留给下一卷
+
+advance:
+- H007 "胖虎借条" → planted → pressured
+- H012 "雷架焦痕" → pressured → near_payoff
+
+resolve:
+- H003 "杂役腰牌" → 林秋主动摘下
+
+defer:
+- H009 "守拙诀来历" → 本章不动
+
+## 不要做
+- 不要点破母亲身份`;
+
+const EN_MEMO = `## Current task
+Lin Qiu lifts the ledger from the Old Port accounting hall.
+
+## Hook ledger for this chapter
+open:
+- [new] Old Port tail || reason: save for later arc
+
+advance:
+- H007 "Huzi's IOU" → planted → pressured
+
+resolve:
+- H003 "errand badge" → Lin Qiu unpins it himself
+
+defer:
+- H009 "Shou-Zhuo Jue origin" → timing not right
+
+## Do not
+- Do not reveal the mother's name`;
+
+describe("parseHookLedger", () => {
+  it("extracts all four sub-lists from a zh memo", () => {
+    const ledger = parseHookLedger(ZH_MEMO);
+    expect(ledger.advance).toEqual(["H007", "H012"]);
+    expect(ledger.resolve).toEqual(["H003"]);
+    expect(ledger.defer).toEqual(["H009"]);
+    // open uses [new] so no hook_id is extracted
+    expect(ledger.open).toEqual([]);
+  });
+
+  it("extracts all four sub-lists from an en memo", () => {
+    const ledger = parseHookLedger(EN_MEMO);
+    expect(ledger.advance).toEqual(["H007"]);
+    expect(ledger.resolve).toEqual(["H003"]);
+    expect(ledger.defer).toEqual(["H009"]);
+  });
+
+  it("returns empty lists when no ledger section is present", () => {
+    const ledger = parseHookLedger("## 当前任务\n正文\n\n## 不要做\n- 无");
+    expect(ledger).toEqual({ open: [], advance: [], resolve: [], defer: [] });
+  });
+
+  it("stops at the next H2 heading and does not pollute across sections", () => {
+    const memo = `## 本章 hook 账
+advance:
+- H007 "xxx" → ...
+
+## 不要做
+- H999 looks-like-a-hook-but-its-under-do-not`;
+    const ledger = parseHookLedger(memo);
+    expect(ledger.advance).toEqual(["H007"]);
+    expect(ledger.defer).toEqual([]);
+  });
+});
+
+describe("validateHookLedger", () => {
+  it("passes when draft echoes every committed hook_id", () => {
+    const draft = "林秋摸出胖虎借条(H007),又被雷架焦痕 H012 刺到眼睛。随后他摘下 H003 腰牌。";
+    const violations = validateHookLedger(ZH_MEMO, draft);
+    expect(violations).toEqual([]);
+  });
+
+  it("flags a critical violation for each un-echoed advance/resolve id", () => {
+    const draft = "林秋只摸出 H007 借条,其他都没写。";
+    const violations = validateHookLedger(ZH_MEMO, draft);
+    // H012 advance and H003 resolve are missing
+    expect(violations).toHaveLength(2);
+    expect(violations.every((v) => v.severity === "critical")).toBe(true);
+    expect(violations.map((v) => v.description).join(" ")).toContain("H012");
+    expect(violations.map((v) => v.description).join(" ")).toContain("H003");
+  });
+
+  it("does NOT flag hooks that are only under defer", () => {
+    // H009 is deferred — if draft doesn't echo it, that's fine
+    const draft = "林秋翻出 H007、H012 推进情节。林秋摘下 H003 腰牌。";
+    const violations = validateHookLedger(ZH_MEMO, draft);
+    expect(violations).toEqual([]);
+  });
+
+  it("does NOT flag [new] open entries (they have no pre-existing id)", () => {
+    const memo = `## 本章 hook 账
+open:
+- [new] 新钩子 || 理由
+advance:
+- H001 "test" → x
+`;
+    const draft = "正文里有 H001。";
+    const violations = validateHookLedger(memo, draft);
+    expect(violations).toEqual([]);
+  });
+
+  it("returns empty array when memo has no ledger section at all", () => {
+    const violations = validateHookLedger("## 别的东西\n正文", "draft");
+    expect(violations).toEqual([]);
+  });
+
+  it("uses word boundary so H12 does not accidentally match H1 or H123", () => {
+    const memo = `## 本章 hook 账
+advance:
+- H1 "a" → x
+`;
+    // Draft contains H12, which must NOT satisfy the H1 commitment
+    const draft = "剧情涉及 H12 和 H123。";
+    const violations = validateHookLedger(memo, draft);
+    expect(violations).toHaveLength(1);
+    expect(violations[0]!.description).toContain("H1");
+  });
+});

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

@@ -1212,3 +1212,92 @@ describe("parsePendingHooksMarkdown", () => {
   });
 
 });
+
+// ---------------------------------------------------------------------------
+// Phase 9-2 — computeRecyclableHooks unit tests
+// ---------------------------------------------------------------------------
+
+import { computeRecyclableHooks } from "../utils/memory-retrieval.js";
+import type { StoredHook } from "../state/memory-db.js";
+
+function makeHook(overrides: Partial<StoredHook> & Pick<StoredHook, "hookId">): StoredHook {
+  return {
+    startChapter: 1,
+    type: "foreshadow",
+    status: "open",
+    lastAdvancedChapter: 0,
+    expectedPayoff: "",
+    notes: "",
+    ...overrides,
+  };
+}
+
+describe("computeRecyclableHooks", () => {
+  it("returns empty array when no hooks are stale", () => {
+    const hooks = [
+      makeHook({ hookId: "H1", startChapter: 8, lastAdvancedChapter: 9, status: "pressured" }),
+      makeHook({ hookId: "H2", startChapter: 9, lastAdvancedChapter: 0, status: "open" }),
+    ];
+    expect(computeRecyclableHooks(hooks, 10)).toEqual([]);
+  });
+
+  it("flags pressured hooks silent ≥ 5 chapters", () => {
+    const hooks = [
+      makeHook({ hookId: "H1", startChapter: 3, lastAdvancedChapter: 4, status: "pressured" }),
+      makeHook({ hookId: "H2", startChapter: 9, lastAdvancedChapter: 9, status: "pressured" }),
+    ];
+    const result = computeRecyclableHooks(hooks, 10);
+    expect(result.map((h) => h.hookId)).toEqual(["H1"]);
+  });
+
+  it("flags near_payoff hooks silent ≥ 5 chapters", () => {
+    const hooks = [
+      makeHook({ hookId: "H1", startChapter: 3, lastAdvancedChapter: 4, status: "near_payoff" }),
+    ];
+    const result = computeRecyclableHooks(hooks, 10);
+    expect(result.map((h) => h.hookId)).toEqual(["H1"]);
+  });
+
+  it("flags core hooks silent ≥ 8 chapters (not 10)", () => {
+    const hooks = [
+      makeHook({ hookId: "H-core", startChapter: 2, lastAdvancedChapter: 2, status: "open", coreHook: true }),
+      makeHook({ hookId: "H-regular", startChapter: 2, lastAdvancedChapter: 2, status: "open" }),
+    ];
+    // silence = 10 - 2 = 8. core: qualifies (>=8). regular: does not (<10).
+    const result = computeRecyclableHooks(hooks, 10);
+    expect(result.map((h) => h.hookId)).toEqual(["H-core"]);
+  });
+
+  it("flags plain open hooks only when silent ≥ 10 chapters", () => {
+    const hooks = [
+      makeHook({ hookId: "H1", startChapter: 1, lastAdvancedChapter: 0, status: "open" }),
+    ];
+    expect(computeRecyclableHooks(hooks, 10).map((h) => h.hookId)).toEqual([]);
+    expect(computeRecyclableHooks(hooks, 11).map((h) => h.hookId)).toEqual(["H1"]);
+  });
+
+  it("excludes resolved / deferred hooks regardless of silence", () => {
+    const hooks = [
+      makeHook({ hookId: "H1", startChapter: 1, lastAdvancedChapter: 1, status: "resolved" }),
+      makeHook({ hookId: "H2", startChapter: 1, lastAdvancedChapter: 1, status: "deferred" }),
+    ];
+    expect(computeRecyclableHooks(hooks, 20)).toEqual([]);
+  });
+
+  it("excludes future-planted hooks that have not yet landed", () => {
+    const hooks = [
+      makeHook({ hookId: "H1", startChapter: 30, lastAdvancedChapter: 0, status: "open" }),
+    ];
+    expect(computeRecyclableHooks(hooks, 10)).toEqual([]);
+  });
+
+  it("sorts by silence DESC — most overdue hook first", () => {
+    const hooks = [
+      makeHook({ hookId: "H-mid", startChapter: 2, lastAdvancedChapter: 4, status: "pressured" }),
+      makeHook({ hookId: "H-worst", startChapter: 1, lastAdvancedChapter: 1, status: "pressured" }),
+      makeHook({ hookId: "H-mild", startChapter: 3, lastAdvancedChapter: 5, status: "pressured" }),
+    ];
+    const result = computeRecyclableHooks(hooks, 10);
+    expect(result.map((h) => h.hookId)).toEqual(["H-worst", "H-mid", "H-mild"]);
+  });
+});

+ 4 - 0
packages/core/src/__tests__/persisted-governed-plan.test.ts

@@ -27,6 +27,10 @@ const MEMO_BODY = `## 当前任务
 ## 章尾必须发生的改变
 - 账册从母亲遗物变为主角私有,主角与旧港江湖关系出现实质裂痕
 
+## 本章 hook 账
+advance: H1 账册下落 → planted → pressured(本章被主角拿到)
+defer: H4 母亲真实身份 → 第 6 章再揭
+
 ## 不要做
 - 不要出现母亲真实姓名
 - 不要让账册被反派看到`;

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

@@ -32,6 +32,7 @@ describe("PLANNER_MEMO_USER_TEMPLATE", () => {
       "{{opponent_rows}}",
       "{{collaborator_rows}}",
       "{{relevant_threads}}",
+      "{{recyclable_hooks}}",
       "{{isGoldenOpening}}",
       "{{book_rules_relevant}}",
     ];
@@ -52,6 +53,7 @@ describe("buildPlannerUserMessage", () => {
       opponentRows: "| 老李 | 对手 | ... |",
       collaboratorRows: "| 小白 | 盟友 | ... |",
       relevantThreads: "- H03: 未解码信\n- S004: 七号门异常",
+      recyclableHooks: "(暂无陈旧 hook——账本干净)",
       isGoldenOpening: false,
       bookRulesRelevant: "- 禁止主角降智",
     });
@@ -79,6 +81,7 @@ describe("buildPlannerUserMessage", () => {
       opponentRows: "",
       collaboratorRows: "",
       relevantThreads: "",
+      recyclableHooks: "",
       isGoldenOpening: true,
       bookRulesRelevant: "",
     });
@@ -153,6 +156,7 @@ describe("buildGoldenOpeningGuidance", () => {
       opponentRows: "",
       collaboratorRows: "",
       relevantThreads: "",
+      recyclableHooks: "",
       isGoldenOpening: false,
       bookRulesRelevant: "",
     };

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

@@ -36,6 +36,14 @@ const VALID_BODY = `
 ## 章尾必须发生的改变
 - 信息改变:主角掌握实证,可以面对幕后主使前先压住对手的退路
 
+## 本章 hook 账
+advance:
+- H03 "七号门异常" → 从 pressured → near_payoff(本章钉成实证)
+resolve:
+- S004 "锁芯刮痕" → 核验完毕,本章结清
+defer:
+- H07 "幕后主使" → 第 20 章再动
+
 ## 不要做
 - 不要让对手突然降智
 - 不要直接点破幕后主使
@@ -199,6 +207,12 @@ n/a — pressure chapter, no transitional beats.
 ## Required end-of-chapter change
 - Information change: protagonist holds live evidence.
 
+## Hook ledger for this chapter
+advance:
+- H03 "Door 7 anomaly" → pressured → near_payoff (pinned as live evidence this chapter)
+defer:
+- H07 "the mastermind" → hold until chapter 20
+
 ## Do not
 - Do not let the antagonist suddenly turn dumb.
 - Do not directly name the mastermind.

+ 37 - 0
packages/core/src/agents/planner-context.ts

@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
 import { parseMarkdownTableRows } from "../utils/story-markdown.js";
 import { readCharacterContext } from "../utils/outline-paths.js";
 import { readBookRules as readStructuredBookRules } from "./rules-reader.js";
+import type { StoredHook } from "../state/memory-db.js";
 
 async function readOrEmpty(path: string): Promise<string> {
   try {
@@ -258,3 +259,39 @@ export function extractRelevantThreads(pendingHooksRaw: string, subplotBoardRaw:
   }
   return lines.join("\n");
 }
+
+/**
+ * Phase 9-2: render stale hooks that the planner MUST dispose of in this
+ * chapter's memo ("## 本章 hook 账"). These are already filtered by
+ * computeRecyclableHooks; here we just format them for the prompt.
+ *
+ * Language switch mirrors the rest of the planner prompt: zh by default,
+ * en for English books.
+ */
+export function formatRecyclableHooks(
+  hooks: ReadonlyArray<StoredHook>,
+  chapterNumber: number,
+  language: "zh" | "en" = "zh",
+): string {
+  if (hooks.length === 0) {
+    return language === "en"
+      ? "(no stale hooks — the ledger is clean)"
+      : "(暂无陈旧 hook——账本干净)";
+  }
+
+  const topSlice = hooks.slice(0, 6);
+  const lines = topSlice.map((hook) => {
+    const lastTouch = Math.max(hook.startChapter, hook.lastAdvancedChapter);
+    const silence = lastTouch <= 0 ? chapterNumber : Math.max(0, chapterNumber - lastTouch);
+    const payoff = hook.expectedPayoff?.trim() || hook.notes?.trim() || "";
+    const core = hook.coreHook === true ? (language === "en" ? " [core]" : " [核心]") : "";
+    return language === "en"
+      ? `- ${hook.hookId} "${payoff}" — status=${hook.status}, silent ${silence} ch${core}`
+      : `- ${hook.hookId} "${payoff}" — 状态=${hook.status},已沉默 ${silence} 章${core}`;
+  });
+
+  const header = language === "en"
+    ? "The planner MUST place each of these under advance / resolve / defer in the hook ledger (deferring requires an explicit reason):"
+    : "规划时必须把以下每个 hook 放入 advance / resolve / defer(若 defer,必须写出理由):";
+  return [header, ...lines].join("\n");
+}

+ 54 - 0
packages/core/src/agents/planner-prompts.ts

@@ -21,6 +21,7 @@ export const PLANNER_MEMO_SYSTEM_PROMPT = `你是这本小说的创作总编,
 9. 人物立体化:核心标签 + 反差细节 = 活人
 10. 五感具体化:场景描写必须有具体可视化感官细节
 11. 钩子承接:每章章尾留钩
+12. 钩子账本必须结账:每章对活跃 hook 做明确动作(open/advance/resolve/defer),不允许"新开一堆不回收"
 
 ## 输出格式(严格遵守)
 
@@ -66,6 +67,28 @@ threadRefs:
 ## 章尾必须发生的改变
 <1-3 条,从以下维度选:信息改变 / 关系改变 / 物理改变 / 权力改变>
 
+## 本章 hook 账
+**这是本章对活跃伏笔的账本,写手必须按这份账动作。格式如下(每个分类下用 - 列表):**
+
+open:
+- [new] 新钩子描述(<=30字)|| 理由:为什么是现在开,不在本章点破(要求:本章新开钩子 ≤ 2 个)
+
+advance:
+- H007 "胖虎借条" → 林秋第一次想撕,被阻止(planted → pressured)
+- H012 "雷架焦痕" → 师兄偷看留下印子(pressured → near_payoff)
+
+resolve:
+- H003 "杂役腰牌" → 林秋主动摘下(clear)
+
+defer:
+- H009 "守拙诀来历" → 本章不动,理由:时机不到,等到第 N 章
+
+**硬规则**:
+- 输入的 pending_hooks 里如果有任何 hook 状态已是 "pressured" 或 "near_payoff" 且距上次推进 ≥ 5 章,**必须**放到 advance 或 resolve,不允许 defer
+- advance/resolve 里写的 hook_id 必须真实存在于 pending_hooks 输入中(不要编造 ID)
+- 如果这章是纯高压/战斗章节没有伏笔处理空间,至少也要有 1 条 advance 或 defer 声明
+- 本章"## 当前任务"如果天然对应某个 hook 的兑现动作,必须在 resolve 里显式声明对应 hook_id
+
 ## 不要做
 <2-4 条硬约束>
 
@@ -100,6 +123,7 @@ Your working principles (internalize them — do not cite by number in the memo)
 9. Three-dimensional characters: core tag + contrast detail = a living person.
 10. Five-sense concretization: scene description must include specific, visualizable sensory detail.
 11. Hook-passing: every chapter ends with a hook for the next.
+12. Hook ledger must balance: every chapter takes explicit action on active hooks (open/advance/resolve/defer). "Open a pile of hooks and never resolve any" is forbidden.
 
 ## Output format (strict)
 
@@ -145,6 +169,28 @@ if this is a pressure / conflict chapter, write "n/a — pressure chapter, no tr
 ## Required end-of-chapter change
 <1-3 items, choose from: information change / relationship change / physical change / power change>
 
+## Hook ledger for this chapter
+**The per-chapter accounting of active foreshadows. The writer must act on this ledger. Format (use "-" bullets under each subsection):**
+
+open:
+- [new] new hook description (<=30 chars) || reason: why open it now, do not pay it off this chapter (limit: ≤ 2 new hooks per chapter)
+
+advance:
+- H007 "Huzi's IOU" → Lin Qiu tries to tear it, gets stopped (planted → pressured)
+- H012 "thunder rack scar" → a senior brother sneaks a look, leaves a mark (pressured → near_payoff)
+
+resolve:
+- H003 "errand badge" → Lin Qiu unpins it himself (clear)
+
+defer:
+- H009 "origin of Shou-Zhuo Jue" → not touched this chapter, reason: timing not right, save until chapter N
+
+**Hard rules**:
+- If any hook in input pending_hooks is already "pressured" or "near_payoff" AND has not advanced in ≥ 5 chapters, it **must** go into advance or resolve — deferring is not allowed.
+- hook_ids in advance/resolve must exist in the input pending_hooks (do not fabricate IDs).
+- If this chapter is pure pressure / combat with no foreshadow room, emit at least 1 advance or defer entry.
+- If "## Current task" naturally corresponds to paying off a hook, it must appear under resolve with the hook_id.
+
 ## Do not
 <2-4 hard prohibitions>
 
@@ -182,6 +228,9 @@ export const PLANNER_MEMO_USER_TEMPLATE_EN = `# Chapter {{chapterNumber}} memo r
 ## Threads that may be touched (foreshadows + subplots)
 {{relevant_threads}}
 
+## Stale hooks — MUST be advanced / resolved / explicitly deferred this chapter
+{{recyclable_hooks}}
+
 ## Out-of-volume constraints for this chapter
 - Golden opening chapter: {{isGoldenOpening}}
 - Hard rules (excerpt of items this chapter may touch):
@@ -227,6 +276,9 @@ export const PLANNER_MEMO_USER_TEMPLATE = `# 第 {{chapterNumber}} 章 memo 请
 ## 可能被牵动的 thread(伏笔 + 支线)
 {{relevant_threads}}
 
+## 必须回收的陈旧 hook(本章必须 advance / resolve / 显式 defer)
+{{recyclable_hooks}}
+
 ## 本章卷外约束
 - 是否黄金三章:{{isGoldenOpening}}
 - 硬约束(摘取本章可能触碰的条目):
@@ -243,6 +295,7 @@ export interface PlannerUserMessageInput {
   readonly opponentRows: string;
   readonly collaboratorRows: string;
   readonly relevantThreads: string;
+  readonly recyclableHooks: string;
   readonly isGoldenOpening: boolean;
   readonly bookRulesRelevant: string;
   readonly brief?: string;
@@ -267,6 +320,7 @@ export function buildPlannerUserMessage(input: PlannerUserMessageInput): string
     .replaceAll("{{opponent_rows}}", input.opponentRows)
     .replaceAll("{{collaborator_rows}}", input.collaboratorRows)
     .replaceAll("{{relevant_threads}}", input.relevantThreads)
+    .replaceAll("{{recyclable_hooks}}", input.recyclableHooks)
     .replaceAll("{{isGoldenOpening}}", input.isGoldenOpening ? yesText : noText)
     .replaceAll("{{book_rules_relevant}}", input.bookRulesRelevant);
 

+ 9 - 0
packages/core/src/agents/planner.ts

@@ -28,12 +28,14 @@ import {
   extractProtagonistRow,
   extractRelevantThreads,
   formatRecentSummaries,
+  formatRecyclableHooks,
   readBookRules,
   readCharacterMatrix,
   readEmotionalArcs,
   readPendingHooks,
   readSubplotBoard,
 } from "./planner-context.js";
+import type { StoredHook } from "../state/memory-db.js";
 
 export interface PlanChapterInput {
   readonly book: BookConfig;
@@ -137,6 +139,7 @@ export class PlannerAgent extends BaseAgent {
       chapterSummariesRaw: seedMaterials.chapterSummariesRaw,
       previousEndingExcerpt: seedMaterials.previousEndingExcerpt,
       brief: seedMaterials.brief,
+      recyclableHooks: memorySelection.recyclableHooks,
       // Phase hotfix 4: thread book language through so the planner uses
       // English prompts (system + user template + golden opening guidance)
       // for English books instead of always-Chinese.
@@ -182,6 +185,7 @@ export class PlannerAgent extends BaseAgent {
     readonly chapterSummariesRaw: string;
     readonly previousEndingExcerpt?: string;
     readonly brief?: string;
+    readonly recyclableHooks?: ReadonlyArray<StoredHook>;
     readonly language?: "zh" | "en";
   }): Promise<ChapterMemo> {
     const [characterMatrix, subplotBoard, emotionalArcs, pendingHooks, bookRulesRaw] = await Promise.all([
@@ -217,6 +221,11 @@ export class PlannerAgent extends BaseAgent {
       opponentRows: extractOpponentRows(characterMatrix, 3),
       collaboratorRows: extractCollaboratorRows(characterMatrix, 3),
       relevantThreads: extractRelevantThreads(pendingHooks, subplotBoard),
+      recyclableHooks: formatRecyclableHooks(
+        input.recyclableHooks ?? [],
+        input.chapterNumber,
+        language,
+      ),
       isGoldenOpening: input.isGoldenOpening,
       bookRulesRelevant: bookRulesRaw.trim().length > 0 ? bookRulesRaw.trim() : noBookRules,
       brief: input.brief ?? "",

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

@@ -1198,6 +1198,7 @@ export class PipelineRunner {
       pipelineLang,
     );
     const { validatePostWrite: postWriteValidate } = await import("../agents/post-write-validator.js");
+    const { validateHookLedger } = await import("../utils/hook-ledger-validator.js");
     const { readBookRules } = await import("../agents/rules-reader.js");
     const parsedBookRules = (await readBookRules(bookDir))?.rules ?? null;
 
@@ -1241,7 +1242,7 @@ export class PipelineRunner {
       analyzeAITells: (content) => analyzeAITells(content, pipelineLang),
       analyzeSensitiveWords: (content) => analyzeSensitiveWords(content, undefined, pipelineLang),
       runPostWriteChecks: (content) => {
-        return postWriteValidate(content, gp, parsedBookRules, pipelineLang)
+        const baseIssues = postWriteValidate(content, gp, parsedBookRules, pipelineLang)
           .filter((v) => v.severity === "error")
           .map((v) => ({
             severity: "critical" as const,
@@ -1249,6 +1250,12 @@ export class PipelineRunner {
             description: v.description,
             suggestion: v.suggestion,
           }));
+        // Phase 9-3: verify the draft acts on every hook the memo committed to.
+        const memoBody = writeInput.chapterMemo?.body ?? "";
+        const ledgerIssues = memoBody
+          ? validateHookLedger(memoBody, content)
+          : [];
+        return [...baseIssues, ...ledgerIssues];
       },
       logWarn: (message) => this.logWarn(pipelineLang, message),
       logStage: (message) => this.logStage(stageLanguage, message),

+ 1 - 0
packages/core/src/utils/chapter-memo-parser.ts

@@ -37,6 +37,7 @@ const REQUIRED_SECTIONS: ReadonlyArray<RequiredSection> = [
   { zh: "## 日常/过渡承担什么任务", en: "## What the slow / transitional beats carry", minContentChars: 20 },
   { zh: "## 关键抉择过三连问", en: "## Three-question check on the key choice", minContentChars: 20 },
   { zh: "## 章尾必须发生的改变", en: "## Required end-of-chapter change", minContentChars: 20 },
+  { zh: "## 本章 hook 账", en: "## Hook ledger for this chapter", minContentChars: 20 },
   { zh: "## 不要做", en: "## Do not", minContentChars: 1 },
 ];
 

+ 165 - 0
packages/core/src/utils/hook-ledger-validator.ts

@@ -0,0 +1,165 @@
+/**
+ * Phase 9-3: hard gate that a chapter draft actually acts on the hook ledger
+ * the planner declared in the memo's "## 本章 hook 账" / "## Hook ledger for
+ * this chapter" section.
+ *
+ * The planner commits, per chapter, to:
+ *   - advance: <hook_id> "name" → state-change
+ *   - resolve: <hook_id> "name" → action
+ *
+ * This validator parses those two lists and checks that each committed
+ * hook_id is echoed somewhere in the draft body. A silent failure would
+ * let the planner promise advancement the writer never delivered — exactly
+ * the "debt keeps piling up" failure mode Phase 9 is meant to prevent.
+ *
+ * A missing echo is a "critical" severity violation so the review cycle
+ * forces a reviser pass instead of shipping.
+ */
+
+export interface HookLedgerViolation {
+  readonly severity: "critical";
+  readonly category: string;
+  readonly description: string;
+  readonly suggestion: string;
+}
+
+export interface HookLedger {
+  readonly open: ReadonlyArray<string>;
+  readonly advance: ReadonlyArray<string>;
+  readonly resolve: ReadonlyArray<string>;
+  readonly defer: ReadonlyArray<string>;
+}
+
+const LEDGER_HEADING_PATTERNS = [
+  /^#{2,3}\s*本章\s*hook\s*账\s*$/im,
+  /^#{2,3}\s*Hook\s+ledger\s+for\s+this\s+chapter\s*$/im,
+];
+
+const SUBSECTION_KEYS: ReadonlyArray<keyof HookLedger> = ["open", "advance", "resolve", "defer"];
+
+/**
+ * Extract the hook ledger's four sub-lists from a memo body. Hook IDs follow
+ * the pending_hooks convention — typically H###, S###, or an alphanumeric
+ * token. We accept any token matching [A-Za-z][A-Za-z0-9_-]{0,15} so the
+ * validator is robust to ID style differences between books.
+ */
+export function parseHookLedger(memoBody: string): HookLedger {
+  const section = extractLedgerSection(memoBody);
+  if (!section) {
+    return { open: [], advance: [], resolve: [], defer: [] };
+  }
+
+  const result: Record<keyof HookLedger, string[]> = {
+    open: [],
+    advance: [],
+    resolve: [],
+    defer: [],
+  };
+
+  let current: keyof HookLedger | null = null;
+  for (const rawLine of section.split(/\r?\n/)) {
+    const line = rawLine.trim();
+    if (line.length === 0) continue;
+
+    const subHeadingMatch = line.match(/^(open|advance|resolve|defer)\s*[::]?\s*$/i);
+    if (subHeadingMatch) {
+      current = subHeadingMatch[1]!.toLowerCase() as keyof HookLedger;
+      continue;
+    }
+
+    if (!current) continue;
+    if (!line.startsWith("-")) continue;
+
+    const id = extractHookIdFromLine(line);
+    if (id) result[current].push(id);
+  }
+
+  return result;
+}
+
+/**
+ * Enforce: every hook_id declared under advance / resolve must appear in the
+ * draft text. We do NOT validate `open` (new hooks by definition don't have
+ * a pre-existing ID to echo) or `defer` (deferred = deliberately not touched).
+ */
+export function validateHookLedger(
+  memoBody: string,
+  draftContent: string,
+): ReadonlyArray<HookLedgerViolation> {
+  const ledger = parseHookLedger(memoBody);
+  const committedIds = dedupe([...ledger.advance, ...ledger.resolve]);
+  if (committedIds.length === 0) return [];
+
+  const violations: HookLedgerViolation[] = [];
+  for (const id of committedIds) {
+    if (!draftEchoesHookId(draftContent, id)) {
+      violations.push({
+        severity: "critical",
+        category: "hook 账未兑现",
+        description: `memo 在 advance/resolve 里声明要处理 ${id},但正文没有对应的落地动作`,
+        suggestion: `在正文中加入对 ${id} 的具体情节推进(动作、对话、环境变化),或把它从 hook 账里移到 defer 并给出理由`,
+      });
+    }
+  }
+  return violations;
+}
+
+function extractLedgerSection(memoBody: string): string | undefined {
+  for (const pattern of LEDGER_HEADING_PATTERNS) {
+    const match = memoBody.match(pattern);
+    if (!match || match.index === undefined) continue;
+    const start = match.index + match[0].length;
+    const rest = memoBody.slice(start);
+    // Stop at the next H2/H3 heading.
+    const nextHeading = rest.match(/\n#{2,3}\s/);
+    const end = nextHeading ? nextHeading.index ?? rest.length : rest.length;
+    return rest.slice(0, end);
+  }
+  return undefined;
+}
+
+function extractHookIdFromLine(line: string): string | undefined {
+  // Strip leading "-" bullet and any trailing punctuation before checking.
+  const cleaned = line.replace(/^-+\s*/, "").trim();
+  if (cleaned.startsWith("[new]") || cleaned.startsWith("[NEW]")) return undefined;
+
+  // Hook IDs: H007, S004, hook_12, 主线-01, etc. Match a token that starts
+  // with a letter/CJK and contains [A-Za-z0-9_-]. We stop at whitespace or
+  // quote so "H007 \"xxx\"" cleanly yields "H007".
+  const idMatch = cleaned.match(/^([A-Za-z\u4e00-\u9fff][A-Za-z0-9_\-\u4e00-\u9fff]{0,19})/);
+  if (!idMatch) return undefined;
+
+  const candidate = idMatch[1]!;
+  // Reject obvious non-IDs like "open", "advance" that slipped through.
+  if (/^(open|advance|resolve|defer|new)$/i.test(candidate)) return undefined;
+  return candidate;
+}
+
+function draftEchoesHookId(draftContent: string, id: string): boolean {
+  // Exact token match — word boundary for ASCII, simple substring for CJK.
+  if (/^[A-Za-z0-9_-]+$/.test(id)) {
+    const pattern = new RegExp(`\\b${escapeRegex(id)}\\b`);
+    if (pattern.test(draftContent)) return true;
+  } else {
+    if (draftContent.includes(id)) return true;
+  }
+
+  // Also accept the hook's name quoted in the ledger line — but we only have
+  // the ID here. Name-based matching is the responsibility of the caller if
+  // it wants softer gating. This validator stays strict on IDs.
+  return false;
+}
+
+function dedupe(values: ReadonlyArray<string>): string[] {
+  return [...new Set(values)];
+}
+
+function escapeRegex(value: string): string {
+  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+export const INTERNAL = {
+  SUBSECTION_KEYS,
+  extractLedgerSection,
+  extractHookIdFromLine,
+};

+ 53 - 0
packages/core/src/utils/memory-retrieval.ts

@@ -35,6 +35,12 @@ export interface MemorySelection {
   readonly summaries: ReadonlyArray<StoredSummary>;
   readonly hooks: ReadonlyArray<StoredHook>;
   readonly activeHooks: ReadonlyArray<StoredHook>;
+  /**
+   * Hooks with recycling pressure — stale hooks that the planner must
+   * advance/resolve/defer (and if deferred, justify). Sorted by staleness DESC
+   * (most overdue first). See computeRecyclableHooks for the selection rule.
+   */
+  readonly recyclableHooks: ReadonlyArray<StoredHook>;
   readonly facts: ReadonlyArray<Fact>;
   readonly volumeSummaries: ReadonlyArray<VolumeSummarySelection>;
   readonly dbPath?: string;
@@ -127,6 +133,7 @@ export async function retrieveMemorySelection(params: {
         ),
         hooks: selectRelevantHooks(activeHooks, narrativeQueryTerms, params.chapterNumber),
         activeHooks,
+        recyclableHooks: computeRecyclableHooks(activeHooks, params.chapterNumber),
         facts: selectRelevantFacts(memoryDb.getCurrentFacts(), factQueryTerms),
         volumeSummaries,
         dbPath: join(storyDir, "memory.db"),
@@ -148,11 +155,57 @@ export async function retrieveMemorySelection(params: {
     summaries: selectRelevantSummaries(summaries, params.chapterNumber, narrativeQueryTerms),
     hooks: selectRelevantHooks(activeHooks, narrativeQueryTerms, params.chapterNumber),
     activeHooks,
+    recyclableHooks: computeRecyclableHooks(activeHooks, params.chapterNumber),
     facts: selectRelevantFacts(facts, factQueryTerms),
     volumeSummaries,
   };
 }
 
+/**
+ * Phase 9-2: Hooks that the planner MUST address this chapter.
+ *
+ * An active hook is "recyclable" (i.e., stale enough to force an
+ * advance/resolve/defer decision) when any of the following holds:
+ *
+ *   - pressured / near_payoff / progressing: silent for ≥ 5 chapters
+ *   - planted / open: silent for ≥ 10 chapters
+ *   - coreHook === true:                      silent for ≥ 8 chapters
+ *
+ * "Silent" = (chapterNumber − max(startChapter, lastAdvancedChapter)).
+ * Future-planted hooks are excluded (they aren't overdue yet).
+ * Sorted by silence DESC — most overdue first — so the planner sees the
+ * worst debt at the top of its prompt slice.
+ */
+export function computeRecyclableHooks(
+  hooks: ReadonlyArray<StoredHook>,
+  chapterNumber: number,
+): StoredHook[] {
+  return hooks
+    .filter((hook) => !isRecycleTerminalStatus(hook.status))
+    .filter((hook) => !isFuturePlannedHook(hook, chapterNumber))
+    .map((hook) => ({ hook, silence: hookSilence(hook, chapterNumber) }))
+    .filter(({ hook, silence }) => silence >= recycleThreshold(hook))
+    .sort((a, b) => b.silence - a.silence || a.hook.startChapter - b.hook.startChapter)
+    .map(({ hook }) => hook);
+}
+
+function isRecycleTerminalStatus(status: string): boolean {
+  return /^(resolved|closed|done|已回收|已解决|deferred|paused|hold|延后|延期|搁置|暂缓)$/i.test(status.trim());
+}
+
+function hookSilence(hook: StoredHook, chapterNumber: number): number {
+  const lastTouch = Math.max(hook.startChapter, hook.lastAdvancedChapter);
+  if (lastTouch <= 0) return chapterNumber;
+  return Math.max(0, chapterNumber - lastTouch);
+}
+
+function recycleThreshold(hook: StoredHook): number {
+  const status = hook.status.trim().toLowerCase();
+  if (/pressured|near[_\s-]?payoff|progressing|重大推进|持续推进/.test(status)) return 5;
+  if (hook.coreHook === true) return 8;
+  return 10;
+}
+
 export function extractQueryTerms(goal: string, outlineNode: string | undefined, mustKeep: ReadonlyArray<string>): string[] {
   const primaryTerms = uniqueTerms([
     ...extractTermsFromText(stripNegativeGuidance(goal)),