Sfoglia il codice sorgente

feat(pipeline): add semantic hook lifecycle guidance

Ma 7 mesi fa
parent
commit
d6855e4e60

+ 63 - 0
packages/core/src/__tests__/composer.test.ts

@@ -458,4 +458,67 @@ describe("ComposerAgent", () => {
     expect(parentCanonEntry?.excerpt).toContain("archive fire");
     expect(fanficCanonEntry?.excerpt).toContain("oath debt logic");
   });
+
+  it("emits hook debt briefs for agenda-targeted hooks", async () => {
+    await Promise.all([
+      writeFile(
+        join(storyDir, "pending_hooks.md"),
+        [
+          "# Pending Hooks",
+          "",
+          "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
+          "| --- | --- | --- | --- | --- | --- | --- | --- |",
+          "| mentor-oath | 8 | relationship | progressing | 9 | 揭开师债为何断裂 | 慢烧 | 师债需要跨更大弧线回收 |",
+          "| guild-route | 1 | mystery | open | 2 | 查清商会路线背后的买家 | 近期 | 商会路线仍在旁支干扰 |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "# Chapter Summaries",
+          "",
+          "| 7 | Broken Letter | Lin Yue | A torn letter mentions the mentor | Lin Yue reopens the old oath | mentor-oath seeded | uneasy | mystery |",
+          "| 8 | River Camp | Lin Yue | Mentor debt becomes personal | Lin Yue cannot let go | mentor-oath advanced | raw | confrontation |",
+          "| 9 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+    ]);
+
+    const composer = new ComposerAgent({
+      client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    const result = await composer.composeChapter({
+      book,
+      bookDir,
+      chapterNumber: 10,
+      plan: {
+        ...plan,
+        intent: {
+          ...plan.intent,
+          chapter: 10,
+          goal: "Bring the focus back to the mentor oath conflict.",
+          hookAgenda: {
+            mustAdvance: ["mentor-oath"],
+            eligibleResolve: [],
+            staleDebt: [],
+            avoidNewHookFamilies: ["relationship"],
+          },
+        },
+      },
+    });
+
+    const hookDebtEntry = result.contextPackage.selectedContext.find((entry) => entry.source === "runtime/hook_debt#mentor-oath");
+    expect(hookDebtEntry).toBeDefined();
+    expect(hookDebtEntry?.excerpt).toContain("mentor-oath");
+    expect(hookDebtEntry?.excerpt).toContain("River Camp");
+    expect(hookDebtEntry?.excerpt).toContain("Trial Echo");
+  });
 });

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

@@ -1085,4 +1085,62 @@ describe("parsePendingHooksMarkdown", () => {
 
     expect(hooks.map((hook) => hook.hookId)).toEqual(["H009", "H010"]);
   });
+
+  it("parses semantic payoff timing from extended pending hooks tables", () => {
+    const hooks = memoryRetrieval.parsePendingHooksMarkdown([
+      "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| oath-debt | 8 | relationship | open | 12 | Reveal why the mentor broke the oath | slow-burn | Long-buried debt stays unresolved |",
+      "| kiln-key | 15 | mystery | open | 15 | Find out what the kiln key opens next chapter | immediate | Fresh key with a fast local payoff |",
+      "",
+    ].join("\n"));
+
+    expect(hooks).toEqual([
+      expect.objectContaining({
+        hookId: "oath-debt",
+        payoffTiming: "slow-burn",
+        notes: "Long-buried debt stays unresolved",
+      }),
+      expect.objectContaining({
+        hookId: "kiln-key",
+        payoffTiming: "immediate",
+        notes: "Fresh key with a fast local payoff",
+      }),
+    ]);
+  });
+
+  it("keeps slow-burn hooks out of early resolve slots while still advancing them", () => {
+    const agenda = memoryRetrieval.buildPlannerHookAgenda({
+      chapterNumber: 18,
+      hooks: [
+        {
+          hookId: "slow-oath",
+          startChapter: 10,
+          type: "relationship",
+          status: "progressing",
+          lastAdvancedChapter: 17,
+          expectedPayoff: "Reveal why the mentor buried the oath debt",
+          payoffTiming: "slow-burn",
+          notes: "The debt should simmer across the wider arc.",
+        },
+        {
+          hookId: "ready-packet",
+          startChapter: 14,
+          type: "mystery",
+          status: "progressing",
+          lastAdvancedChapter: 17,
+          expectedPayoff: "Open the missing packet and expose the inside hand",
+          payoffTiming: "near-term",
+          notes: "The local sequence is ready for a concrete payoff.",
+        },
+      ] as never,
+      maxMustAdvance: 2,
+      maxEligibleResolve: 2,
+      targetChapters: 40,
+    } as never);
+
+    expect(agenda.mustAdvance).toContain("slow-oath");
+    expect(agenda.eligibleResolve).toContain("ready-packet");
+    expect(agenda.eligibleResolve).not.toContain("slow-oath");
+  });
 });

+ 1 - 1
packages/core/src/__tests__/planner.test.ts

@@ -1041,7 +1041,7 @@ describe("PlannerAgent", () => {
     });
 
     const intentMarkdown = await readFile(result.runtimePath, "utf-8");
-    expect(intentMarkdown).toContain("| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |");
+    expect(intentMarkdown).toContain("| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |");
     expect(intentMarkdown).toContain("| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |");
     expect(intentMarkdown).not.toContain("| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |");
     expect(intentMarkdown).not.toContain("| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |");

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

@@ -205,6 +205,11 @@ describe("WriterAgent", () => {
               reason: "Carry forward unresolved hook.",
               excerpt: "relationship | open | 101 | Mentor oath debt with Lin Yue",
             },
+            {
+              source: "runtime/hook_debt#mentor-oath",
+              reason: "Explicit hook debt brief for the agenda target.",
+              excerpt: "mentor-oath | cadence: slow-burn | seed: ch8 River Camp - Mentor debt becomes personal | latest: ch99 Locked Gate - Lin Yue chooses the mentor line over the guild line | unpaid: reveal why the mentor broke the oath",
+            },
           ],
         },
         ruleStack: {
@@ -224,7 +229,9 @@ 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).toContain("## Hook Debt Briefs");
+      expect(settlePrompt).toContain("mentor-oath | cadence: slow-burn");
+      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");

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

@@ -195,18 +195,20 @@ enableFullCastTracking: false
 
     const pendingHooksPrompt = resolvedLanguage === "en"
       ? `Initial hook pool (Markdown table):
-| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |
+| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | 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
+- Column 7 must be one of: immediate / near-term / mid-arc / slow-burn / endgame
 - 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
+- 第7列必须填写:立即 / 近期 / 中程 / 慢烧 / 终局 之一
 - 如果要说明“初始线索/最初信号”,写进备注,不要写进第5列`;
 
     const finalRequirementsPrompt = resolvedLanguage === "en"
@@ -489,9 +491,9 @@ enableFullCastTracking: false
 
     const pendingHooksPrompt = resolvedLanguage === "en"
       ? `Identify all active hooks from the source text (Markdown table):
-| hook_id | start_chapter | type | status | latest_progress | expected_payoff | notes |`
+| hook_id | start_chapter | type | status | latest_progress | expected_payoff | payoff_timing | notes |`
       : `从正文中识别的所有伏笔(Markdown表格):
-| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |`;
+| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |`;
 
     const keyPrinciplesPrompt = resolvedLanguage === "en"
       ? `## Key Principles
@@ -727,7 +729,8 @@ prohibitions:
         status: row[3] ?? "open",
         lastAdvancedChapter: normalizedProgress,
         expectedPayoff: row[5] ?? "",
-        notes,
+        payoffTiming: row.length >= 8 ? row[6] ?? "" : "",
+        notes: row.length >= 8 ? this.mergeHookNotes(row[7] ?? "", seedNote, language) : notes,
       };
     });
 

+ 2 - 2
packages/core/src/agents/chapter-analyzer.ts

@@ -279,7 +279,7 @@ Updated state card as a Markdown table reflecting the end-of-chapter state:
 
 === UPDATED_HOOKS ===
 Updated hooks pool as a Markdown table with the latest status of every known hook:
-| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |
+| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | notes |
 
 === CHAPTER_SUMMARY ===
 Single Markdown table row:
@@ -372,7 +372,7 @@ ${bookRulesBody ? `## 本书规则\n\n${bookRulesBody}` : ""}
 
 === UPDATED_HOOKS ===
 更新后的伏笔池(Markdown表格),包含所有已知伏笔的最新状态:
-| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |
+| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |
 
 === CHAPTER_SUMMARY ===
 本章摘要(Markdown表格行):

+ 131 - 3
packages/core/src/agents/composer.ts

@@ -16,6 +16,10 @@ import {
   parseChapterSummariesMarkdown,
   retrieveMemorySelection,
 } from "../utils/memory-retrieval.js";
+import {
+  localizeHookPayoffTiming,
+  resolveHookPayoffTiming,
+} from "../utils/hook-lifecycle.js";
 
 export interface ComposeChapterInput {
   readonly book: BookConfig;
@@ -43,7 +47,11 @@ export class ComposerAgent extends BaseAgent {
     const runtimeDir = join(storyDir, "runtime");
     await mkdir(runtimeDir, { recursive: true });
 
-    const selectedContext = await this.collectSelectedContext(storyDir, input.plan);
+    const selectedContext = await this.collectSelectedContext(
+      storyDir,
+      input.plan,
+      input.book.language ?? "zh",
+    );
     const contextPackage = ContextPackageSchema.parse({
       chapter: input.chapterNumber,
       selectedContext,
@@ -103,7 +111,11 @@ export class ComposerAgent extends BaseAgent {
     };
   }
 
-  private async collectSelectedContext(storyDir: string, plan: PlanChapterOutput): Promise<ContextPackage["selectedContext"]> {
+  private async collectSelectedContext(
+    storyDir: string,
+    plan: PlanChapterOutput,
+    language: "zh" | "en",
+  ): Promise<ContextPackage["selectedContext"]> {
     const entries = await Promise.all([
       this.maybeContextSource(storyDir, "current_focus.md", "Current task focus for this chapter."),
       this.maybeContextSource(
@@ -145,6 +157,12 @@ export class ComposerAgent extends BaseAgent {
       outlineNode: planningAnchor,
       mustKeep: plan.intent.mustKeep,
     });
+    const hookDebtEntries = await this.buildHookDebtEntries(
+      storyDir,
+      plan,
+      memorySelection.activeHooks,
+      language,
+    );
 
     const summaryEntries = memorySelection.summaries.map((summary) => ({
       source: `story/chapter_summaries.md#${summary.chapter}`,
@@ -161,7 +179,7 @@ export class ComposerAgent extends BaseAgent {
     const hookEntries = memorySelection.hooks.map((hook) => ({
       source: `story/pending_hooks.md#${hook.hookId}`,
       reason: "Carry forward unresolved hooks that match the chapter focus.",
-      excerpt: [hook.type, hook.status, hook.expectedPayoff, hook.notes]
+      excerpt: [hook.type, hook.status, hook.expectedPayoff, hook.payoffTiming, hook.notes]
         .filter(Boolean)
         .join(" | "),
     }));
@@ -174,6 +192,7 @@ export class ComposerAgent extends BaseAgent {
     return [
       ...entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null),
       ...trailEntries,
+      ...hookDebtEntries,
       ...factEntries,
       ...summaryEntries,
       ...volumeSummaryEntries,
@@ -226,6 +245,64 @@ export class ComposerAgent extends BaseAgent {
     return entries;
   }
 
+  private async buildHookDebtEntries(
+    storyDir: string,
+    plan: PlanChapterOutput,
+    activeHooks: ReadonlyArray<{
+      readonly hookId: string;
+      readonly startChapter: number;
+      readonly type: string;
+      readonly status: string;
+      readonly lastAdvancedChapter: number;
+      readonly expectedPayoff: string;
+      readonly payoffTiming?: string;
+      readonly notes: string;
+    }>,
+    language: "zh" | "en",
+  ): Promise<ContextPackage["selectedContext"]> {
+    const targetHookIds = [...new Set([
+      ...plan.intent.hookAgenda.eligibleResolve,
+      ...plan.intent.hookAgenda.mustAdvance,
+      ...plan.intent.hookAgenda.staleDebt,
+    ])];
+    if (targetHookIds.length === 0) {
+      return [];
+    }
+
+    const summaries = parseChapterSummariesMarkdown(
+      await this.readFileOrDefault(join(storyDir, "chapter_summaries.md")),
+    );
+
+    return targetHookIds.flatMap((hookId) => {
+      const hook = activeHooks.find((entry) => entry.hookId === hookId);
+      if (!hook) {
+        return [];
+      }
+
+      const seedSummary = this.findHookSummary(summaries, hook.hookId, hook.startChapter, "seed");
+      const latestSummary = this.findHookSummary(summaries, hook.hookId, hook.lastAdvancedChapter, "latest");
+      const cadence = localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language);
+      const role = this.describeHookAgendaRole(plan, hook.hookId, language);
+      const promise = hook.expectedPayoff || (language === "en" ? "(unspecified)" : "(未写明)");
+      const seedBeat = seedSummary
+        ? this.renderHookDebtBeat(seedSummary)
+        : (hook.notes || promise);
+      const latestBeat = latestSummary
+        ? this.renderHookDebtBeat(latestSummary)
+        : (hook.notes || promise);
+
+      return [{
+        source: `runtime/hook_debt#${hook.hookId}`,
+        reason: language === "en"
+          ? "Narrative debt brief for an explicit hook agenda target."
+          : "显式 hook agenda 目标的叙事债务简报。",
+        excerpt: language === "en"
+          ? `${hook.hookId} | role: ${role} | cadence: ${cadence} | promise: ${promise} | seed: ${seedBeat} | latest: ${latestBeat}`
+          : `${hook.hookId} | 角色: ${role} | 节奏: ${cadence} | 承诺: ${promise} | 种子: ${seedBeat} | 最近推进: ${latestBeat}`,
+      }];
+    });
+  }
+
   private async maybeContextSource(
     storyDir: string,
     fileName: string,
@@ -270,4 +347,55 @@ export class ComposerAgent extends BaseAgent {
       return "(文件尚未创建)";
     }
   }
+
+  private describeHookAgendaRole(
+    plan: PlanChapterOutput,
+    hookId: string,
+    language: "zh" | "en",
+  ): string {
+    if (plan.intent.hookAgenda.eligibleResolve.includes(hookId)) {
+      return language === "en" ? "payoff candidate" : "本章优先兑现";
+    }
+    if (plan.intent.hookAgenda.staleDebt.includes(hookId)) {
+      return language === "en" ? "stale debt" : "高压旧债";
+    }
+    return language === "en" ? "must advance" : "本章必须推进";
+  }
+
+  private findHookSummary(
+    summaries: ReadonlyArray<ReturnType<typeof parseChapterSummariesMarkdown>[number]>,
+    hookId: string,
+    chapter: number,
+    mode: "seed" | "latest",
+  ) {
+    const directChapterHit = summaries.find((summary) => summary.chapter === chapter);
+    const hookMentions = summaries.filter((summary) => this.summaryMentionsHook(summary, hookId));
+    if (mode === "seed") {
+      return hookMentions.find((summary) => summary.chapter === chapter)
+        ?? hookMentions.at(0)
+        ?? directChapterHit;
+    }
+
+    return [...hookMentions].reverse().find((summary) => summary.chapter === chapter)
+      ?? hookMentions.at(-1)
+      ?? directChapterHit;
+  }
+
+  private summaryMentionsHook(
+    summary: ReturnType<typeof parseChapterSummariesMarkdown>[number],
+    hookId: string,
+  ): boolean {
+    return [
+      summary.title,
+      summary.events,
+      summary.stateChanges,
+      summary.hookActivity,
+    ].some((text) => text.includes(hookId));
+  }
+
+  private renderHookDebtBeat(
+    summary: ReturnType<typeof parseChapterSummariesMarkdown>[number],
+  ): string {
+    return `ch${summary.chapter} ${summary.title} - ${summary.events || summary.hookActivity || summary.stateChanges || "(none)"}`;
+  }
 }

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

@@ -83,6 +83,7 @@ export class PlannerAgent extends BaseAgent {
     const hookAgenda = buildPlannerHookAgenda({
       hooks: memorySelection.activeHooks,
       chapterNumber: input.chapterNumber,
+      targetChapters: input.book.targetChapters,
     });
     const directives = this.buildStructuredDirectives({
       chapterNumber: input.chapterNumber,

+ 4 - 1
packages/core/src/agents/settler-prompts.ts

@@ -22,8 +22,9 @@ export function buildSettlerSystemPrompt(
 - 提及伏笔:已有伏笔在本章被提到,但没有新增信息、没有改变读者或角色对该问题的理解 → 放入 mention 数组,不要更新最近推进
 - 推进伏笔:已有伏笔在本章出现了新的事实、证据、关系变化、风险升级或范围收缩 → **必须**更新"最近推进"列为当前章节号,更新状态和备注
 - 回收伏笔:伏笔在本章被明确揭示、解决、或不再成立 → 状态改为"已回收",备注回收方式
-- 延后伏笔:超过5章未推进 → 标注"延后",备注原因
+- 延后伏笔:只有当正文明确显示该线被主动搁置、转入后台、或被剧情压后时,才标注"延后";不要因为“已经过了几章”就机械延后
 - brand-new unresolved thread:不要直接发明新的 hookId。把候选放进 newHookCandidates,由系统决定它是映射到旧 hook、变成真正新 hook,还是被拒绝为重述
+- payoffTiming 使用语义节奏,不用硬写章节号:只允许 immediate / near-term / mid-arc / slow-burn / endgame
 - **铁律**:不要把“再次提到”“换个说法重述”“抽象复盘”当成推进。只有状态真的变了,才更新最近推进。只是出现过的旧 hook,放进 mention 数组。`;
 
   const fullCastBlock = bookRules?.enableFullCastTracking
@@ -114,6 +115,7 @@ function buildSettlerOutputFormat(gp: GenreProfile): string {
         "status": "progressing",
         "lastAdvancedChapter": 12,
         "expectedPayoff": "揭开师债真相",
+        "payoffTiming": "slow-burn",
         "notes": "本章为何推进/延后/回收"
       }
     ],
@@ -125,6 +127,7 @@ function buildSettlerOutputFormat(gp: GenreProfile): string {
     {
       "type": "mystery",
       "expectedPayoff": "新伏笔未来要回收到哪里",
+      "payoffTiming": "near-term",
       "notes": "本章为什么会形成新的未解问题"
     }
   ],

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

@@ -105,6 +105,9 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s
 - 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.
 - If an English Variance Brief is provided, obey it: avoid the listed phrase/opening/ending patterns and satisfy the scene obligation.
+- If Hook Debt Briefs are provided, treat them as the active memory of what the reader is still owed: preserve the original promise and change the on-page situation.
+- When the explicit hook agenda names an eligible resolve target, land a concrete payoff beat instead of merely mentioning the old thread.
+- When stale debt is present, do not open sibling hooks casually; clear pressure from old promises before minting fresh debt.
 - In multi-character scenes, include at least one resistance-bearing exchange instead of reducing the beat to summary or explanation.`;
   }
 
@@ -115,6 +118,9 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s
 - 当 runtime rule stack 明确记录了 L4 -> L3 的 active override 时,优先执行当前任务意图,再局部调整规划层。
 - 真正不能突破的只有硬护栏:世界设定、连续性事实、显式禁令。
 - 如果提供了 English Variance Brief,必须主动避开其中列出的高频短语、重复开头和重复结尾模式,并完成 scene obligation。
+- 如果提供了 Hook Debt 简报,把它当成读者仍在等待兑现的承诺记忆:保留原始 promise,并让本章在页上发生真实变化。
+- 如果显式 hook agenda 里出现了可回收目标,本章必须写出具体兑现片段,不能只是重新提一句旧线索。
+- 如果存在 stale debt,先消化旧承诺的压力,再决定是否开新坑;同类 sibling hook 不得随手再开。
 - 多角色场景里,至少给出一轮带阻力的直接交锋,不要把人物关系写成纯解释或纯总结。`;
 }
 

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

@@ -910,6 +910,7 @@ ${lengthRequirementBlock}
       blocks.titleHistoryBlock,
       blocks.moodTrailBlock,
       blocks.canonBlock,
+      blocks.hookDebtBlock,
       blocks.hooksBlock,
       blocks.summariesBlock,
       blocks.volumeSummariesBlock,

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

@@ -16,6 +16,15 @@ export type StateManifest = z.infer<typeof StateManifestSchema>;
 export const HookStatusSchema = z.enum(["open", "progressing", "deferred", "resolved"]);
 export type HookStatus = z.infer<typeof HookStatusSchema>;
 
+export const HookPayoffTimingSchema = z.enum([
+  "immediate",
+  "near-term",
+  "mid-arc",
+  "slow-burn",
+  "endgame",
+]);
+export type HookPayoffTiming = z.infer<typeof HookPayoffTimingSchema>;
+
 export const HookRecordSchema = z.object({
   hookId: z.string().min(1),
   startChapter: z.number().int().min(0),
@@ -23,6 +32,7 @@ export const HookRecordSchema = z.object({
   status: HookStatusSchema,
   lastAdvancedChapter: z.number().int().min(0),
   expectedPayoff: z.string().default(""),
+  payoffTiming: HookPayoffTimingSchema.optional(),
   notes: z.string().default(""),
 });
 
@@ -94,6 +104,7 @@ export type HookOps = z.infer<typeof HookOpsSchema>;
 export const NewHookCandidateSchema = z.object({
   type: z.string().min(1),
   expectedPayoff: z.string().default(""),
+  payoffTiming: HookPayoffTimingSchema.optional(),
   notes: z.string().default(""),
 });
 

+ 16 - 2
packages/core/src/state/memory-db.ts

@@ -52,6 +52,7 @@ export interface StoredHook {
   readonly status: string;
   readonly lastAdvancedChapter: number;
   readonly expectedPayoff: string;
+  readonly payoffTiming?: string;
   readonly notes: string;
 }
 
@@ -99,6 +100,7 @@ export class MemoryDB {
         status TEXT NOT NULL DEFAULT 'open',
         last_advanced_chapter INTEGER NOT NULL DEFAULT 0,
         expected_payoff TEXT NOT NULL DEFAULT '',
+        payoff_timing TEXT NOT NULL DEFAULT '',
         notes TEXT NOT NULL DEFAULT ''
       );
 
@@ -108,6 +110,16 @@ export class MemoryDB {
       CREATE INDEX IF NOT EXISTS idx_hooks_status ON hooks(status);
       CREATE INDEX IF NOT EXISTS idx_hooks_last_advanced ON hooks(last_advanced_chapter);
     `);
+
+    this.ensureColumn("hooks", "payoff_timing", "TEXT NOT NULL DEFAULT ''");
+  }
+
+  private ensureColumn(table: string, column: string, definition: string): void {
+    try {
+      this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
+    } catch {
+      // Column already exists on existing databases.
+    }
   }
 
   // ---------------------------------------------------------------------------
@@ -289,8 +301,8 @@ export class MemoryDB {
 
   upsertHook(hook: StoredHook): void {
     this.db.prepare(
-      `INSERT OR REPLACE INTO hooks (hook_id, start_chapter, type, status, last_advanced_chapter, expected_payoff, notes)
-       VALUES (?, ?, ?, ?, ?, ?, ?)`,
+      `INSERT OR REPLACE INTO hooks (hook_id, start_chapter, type, status, last_advanced_chapter, expected_payoff, payoff_timing, notes)
+       VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
     ).run(
       hook.hookId,
       hook.startChapter,
@@ -298,6 +310,7 @@ export class MemoryDB {
       hook.status,
       hook.lastAdvancedChapter,
       hook.expectedPayoff,
+      hook.payoffTiming ?? "",
       hook.notes,
     );
   }
@@ -318,6 +331,7 @@ export class MemoryDB {
          status,
          last_advanced_chapter AS lastAdvancedChapter,
          expected_payoff AS expectedPayoff,
+         payoff_timing AS payoffTiming,
          notes
        FROM hooks
        WHERE lower(status) NOT IN ('resolved', 'closed', '已回收', '已解决')

+ 10 - 9
packages/core/src/state/runtime-state-store.ts

@@ -109,15 +109,16 @@ export async function loadNarrativeMemorySeed(bookDir: string): Promise<Narrativ
       mood: row.mood,
       chapterType: row.chapterType,
     })),
-    hooks: snapshot.hooks.hooks.map((hook) => ({
-      hookId: hook.hookId,
-      startChapter: hook.startChapter,
-      type: hook.type,
-      status: hook.status,
-      lastAdvancedChapter: hook.lastAdvancedChapter,
-      expectedPayoff: hook.expectedPayoff,
-      notes: hook.notes,
-    })),
+      hooks: snapshot.hooks.hooks.map((hook) => ({
+        hookId: hook.hookId,
+        startChapter: hook.startChapter,
+        type: hook.type,
+        status: hook.status,
+        lastAdvancedChapter: hook.lastAdvancedChapter,
+        expectedPayoff: hook.expectedPayoff,
+        payoffTiming: hook.payoffTiming,
+        notes: hook.notes,
+      })),
   };
 }
 

+ 19 - 10
packages/core/src/state/state-bootstrap.ts

@@ -11,6 +11,7 @@ import {
   type StateManifest,
 } from "../models/runtime-state.js";
 import type { Fact, StoredHook, StoredSummary } from "./memory-db.js";
+import { normalizeHookPayoffTiming } from "../utils/hook-lifecycle.js";
 
 export interface BootstrapStructuredStateResult {
   readonly createdFiles: ReadonlyArray<string>;
@@ -186,15 +187,19 @@ export function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
   if (tableRows.length > 0) {
     return tableRows
       .filter((row) => normalizeHookId(row[0]).length > 0)
-      .map((row) => ({
-        hookId: normalizeHookId(row[0]),
-        startChapter: parseInteger(row[1]),
-        type: row[2] ?? "",
-        status: row[3] ?? "open",
-        lastAdvancedChapter: parseInteger(row[4]),
-        expectedPayoff: row[5] ?? "",
-        notes: row[6] ?? "",
-      }));
+      .map((row) => {
+        const legacyShape = row.length < 8;
+        return {
+          hookId: normalizeHookId(row[0]),
+          startChapter: parseInteger(row[1]),
+          type: row[2] ?? "",
+          status: row[3] ?? "open",
+          lastAdvancedChapter: parseInteger(row[4]),
+          expectedPayoff: row[5] ?? "",
+          payoffTiming: legacyShape ? undefined : normalizeHookPayoffTiming(row[6]),
+          notes: legacyShape ? (row[6] ?? "") : (row[7] ?? ""),
+        };
+      });
   }
 
   return markdown
@@ -210,6 +215,7 @@ export function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
       status: "open",
       lastAdvancedChapter: 0,
       expectedPayoff: "",
+      payoffTiming: undefined,
       notes: line,
     }));
 }
@@ -333,6 +339,7 @@ function parsePendingHooksStateMarkdown(markdown: string, warnings: string[]) {
         .filter((row) => normalizeHookId(row[0]).length > 0)
         .map((row) => {
           const hookId = normalizeHookId(row[0]);
+          const legacyShape = row.length < 8;
           return {
             hookId,
             startChapter: parseIntegerWithWarning(row[1], warnings, `${hookId}:startChapter`),
@@ -340,7 +347,8 @@ function parsePendingHooksStateMarkdown(markdown: string, warnings: string[]) {
             status: normalizeHookStatus(row[3], warnings, hookId),
             lastAdvancedChapter: parseIntegerWithWarning(row[4], warnings, `${hookId}:lastAdvancedChapter`),
             expectedPayoff: row[5] ?? "",
-            notes: row[6] ?? "",
+            payoffTiming: legacyShape ? undefined : normalizeHookPayoffTiming(row[6]),
+            notes: legacyShape ? (row[6] ?? "") : (row[7] ?? ""),
           };
         }),
     });
@@ -360,6 +368,7 @@ function parsePendingHooksStateMarkdown(markdown: string, warnings: string[]) {
         status: "open" as HookStatus,
         lastAdvancedChapter: 0,
         expectedPayoff: "",
+        payoffTiming: undefined,
         notes: line,
       })),
   });

+ 9 - 4
packages/core/src/state/state-projections.ts

@@ -3,6 +3,10 @@ import type {
   CurrentStateState,
   HooksState,
 } from "../models/runtime-state.js";
+import {
+  localizeHookPayoffTiming,
+  resolveHookPayoffTiming,
+} from "../utils/hook-lifecycle.js";
 
 export function renderHooksProjection(
   state: HooksState,
@@ -11,12 +15,12 @@ export function renderHooksProjection(
   const title = language === "en" ? "# Pending Hooks" : "# 伏笔池";
   const headers = language === "en"
     ? [
-      "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
-      "| --- | --- | --- | --- | --- | --- | --- |",
+      "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | payoff_timing | notes |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
     ]
     : [
-      "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
-      "| --- | --- | --- | --- | --- | --- | --- |",
+      "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
     ];
 
   const rows = [...state.hooks]
@@ -33,6 +37,7 @@ export function renderHooksProjection(
         hook.status,
         hook.lastAdvancedChapter,
         hook.expectedPayoff,
+        localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language),
         hook.notes,
       ].map(escapeTableCell).join(" | ")
     } |`);

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

@@ -12,6 +12,7 @@ import {
   type StateManifest,
 } from "../models/runtime-state.js";
 import { evaluateHookAdmission } from "../utils/hook-governance.js";
+import { resolveHookPayoffTiming } from "../utils/hook-lifecycle.js";
 import { validateRuntimeState } from "./state-validator.js";
 
 export interface RuntimeStateSnapshot {
@@ -153,6 +154,11 @@ function mergeDuplicateHookFamily(existing: HookRecord, incoming: HookRecord): H
         : existing.status,
     lastAdvancedChapter: advanced,
     expectedPayoff,
+    payoffTiming: resolveHookPayoffTiming({
+      payoffTiming: incoming.payoffTiming ?? existing.payoffTiming,
+      expectedPayoff,
+      notes,
+    }),
     notes,
   };
 }

+ 17 - 0
packages/core/src/utils/governed-context.ts

@@ -4,6 +4,7 @@ export function buildGovernedMemoryEvidenceBlocks(
   contextPackage: ContextPackage,
   language?: "zh" | "en",
 ): {
+  readonly hookDebtBlock?: string;
   readonly hooksBlock?: string;
   readonly summariesBlock?: string;
   readonly volumeSummariesBlock?: string;
@@ -15,6 +16,9 @@ export function buildGovernedMemoryEvidenceBlocks(
   const hookEntries = contextPackage.selectedContext.filter((entry) =>
     entry.source.startsWith("story/pending_hooks.md#"),
   );
+  const hookDebtEntries = contextPackage.selectedContext.filter((entry) =>
+    entry.source.startsWith("runtime/hook_debt#"),
+  );
   const summaryEntries = contextPackage.selectedContext.filter((entry) =>
     entry.source.startsWith("story/chapter_summaries.md#"),
   );
@@ -33,6 +37,12 @@ export function buildGovernedMemoryEvidenceBlocks(
   );
 
   return {
+    hookDebtBlock: hookDebtEntries.length > 0
+      ? renderHookDebtBlock(
+          resolvedLanguage === "en" ? "Hook Debt Briefs" : "Hook Debt Briefs",
+          hookDebtEntries,
+        )
+      : undefined,
     hooksBlock: hookEntries.length > 0
       ? renderEvidenceBlock(
           resolvedLanguage === "en" ? "Selected Hook Evidence" : "已选伏笔证据",
@@ -72,6 +82,13 @@ export function buildGovernedMemoryEvidenceBlocks(
   };
 }
 
+function renderHookDebtBlock(
+  heading: string,
+  entries: ContextPackage["selectedContext"],
+): string {
+  return `\n## ${heading}\n${entries.map((entry) => `- ${entry.excerpt ?? entry.reason}`).join("\n")}\n`;
+}
+
 function renderEvidenceBlock(
   heading: string,
   entries: ContextPackage["selectedContext"],

+ 7 - 0
packages/core/src/utils/hook-arbiter.ts

@@ -5,6 +5,7 @@ import {
   type RuntimeStateDelta,
 } from "../models/runtime-state.js";
 import { evaluateHookAdmission } from "./hook-governance.js";
+import { resolveHookPayoffTiming } from "./hook-lifecycle.js";
 
 export interface HookArbiterDecision {
   readonly action: "created" | "mapped" | "mentioned" | "rejected";
@@ -154,6 +155,11 @@ function mergeCandidateIntoExistingHook(
     status: existing.status === "resolved" ? "resolved" : "progressing",
     lastAdvancedChapter: Math.max(existing.lastAdvancedChapter, chapter),
     expectedPayoff: preferRicherText(existing.expectedPayoff, candidate.expectedPayoff),
+    payoffTiming: resolveHookPayoffTiming({
+      payoffTiming: candidate.payoffTiming ?? existing.payoffTiming,
+      expectedPayoff: preferRicherText(existing.expectedPayoff, candidate.expectedPayoff),
+      notes: preferRicherText(existing.notes, candidate.notes),
+    }),
     notes: preferRicherText(existing.notes, candidate.notes),
   };
 }
@@ -170,6 +176,7 @@ function createCanonicalHook(params: {
     status: "open",
     lastAdvancedChapter: params.chapter,
     expectedPayoff: params.candidate.expectedPayoff.trim(),
+    payoffTiming: resolveHookPayoffTiming(params.candidate),
     notes: params.candidate.notes.trim(),
   };
 }

+ 23 - 4
packages/core/src/utils/hook-governance.ts

@@ -1,10 +1,12 @@
 import type { HookRecord, RuntimeStateDelta } from "../models/runtime-state.js";
+import { describeHookLifecycle } from "./hook-lifecycle.js";
 
 export type HookDisposition = "none" | "mention" | "advance" | "resolve" | "defer";
 
 export interface HookAdmissionCandidate {
   readonly type: string;
   readonly expectedPayoff?: string;
+  readonly payoffTiming?: string;
   readonly notes?: string;
 }
 
@@ -17,15 +19,30 @@ export interface HookAdmissionDecision {
 export function collectStaleHookDebt(params: {
   readonly hooks: ReadonlyArray<HookRecord>;
   readonly chapterNumber: number;
+  readonly targetChapters?: 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)
+    .filter((hook) => {
+      const lifecycle = describeHookLifecycle({
+        payoffTiming: hook.payoffTiming,
+        expectedPayoff: hook.expectedPayoff,
+        notes: hook.notes,
+        startChapter: hook.startChapter,
+        lastAdvancedChapter: hook.lastAdvancedChapter,
+        status: hook.status,
+        chapterNumber: params.chapterNumber,
+        targetChapters: params.targetChapters,
+      });
+
+      if (params.staleAfterChapters !== undefined) {
+        return hook.lastAdvancedChapter <= params.chapterNumber - params.staleAfterChapters;
+      }
+
+      return lifecycle.stale || lifecycle.overdue;
+    })
     .sort((left, right) => (
       left.lastAdvancedChapter - right.lastAdvancedChapter
       || left.startChapter - right.startChapter
@@ -60,6 +77,7 @@ export function evaluateHookAdmission(params: {
   const candidateNormalized = normalizeText([
     params.candidate.type,
     params.candidate.expectedPayoff ?? "",
+    params.candidate.payoffTiming ?? "",
     params.candidate.notes ?? "",
   ].join(" "));
   const candidateTerms = extractTerms(candidateNormalized);
@@ -69,6 +87,7 @@ export function evaluateHookAdmission(params: {
     const activeNormalized = normalizeText([
       hook.type,
       hook.expectedPayoff,
+      hook.payoffTiming ?? "",
       hook.notes,
     ].join(" "));
 

+ 212 - 0
packages/core/src/utils/hook-lifecycle.ts

@@ -0,0 +1,212 @@
+import type { HookPayoffTiming } from "../models/runtime-state.js";
+
+type HookPhase = "opening" | "middle" | "late";
+
+interface LifecycleProfile {
+  readonly earliestResolveAge: number;
+  readonly staleDormancy: number;
+  readonly overdueAge: number;
+  readonly minimumPhase: HookPhase;
+  readonly resolveBias: number;
+}
+
+const TIMING_PROFILES: Record<HookPayoffTiming, LifecycleProfile> = {
+  immediate: {
+    earliestResolveAge: 1,
+    staleDormancy: 1,
+    overdueAge: 3,
+    minimumPhase: "opening",
+    resolveBias: 5,
+  },
+  "near-term": {
+    earliestResolveAge: 1,
+    staleDormancy: 2,
+    overdueAge: 5,
+    minimumPhase: "opening",
+    resolveBias: 4,
+  },
+  "mid-arc": {
+    earliestResolveAge: 2,
+    staleDormancy: 4,
+    overdueAge: 8,
+    minimumPhase: "opening",
+    resolveBias: 3,
+  },
+  "slow-burn": {
+    earliestResolveAge: 4,
+    staleDormancy: 5,
+    overdueAge: 12,
+    minimumPhase: "middle",
+    resolveBias: 2,
+  },
+  endgame: {
+    earliestResolveAge: 6,
+    staleDormancy: 6,
+    overdueAge: 16,
+    minimumPhase: "late",
+    resolveBias: 1,
+  },
+};
+
+const PHASE_WEIGHT: Record<HookPhase, number> = {
+  opening: 0,
+  middle: 1,
+  late: 2,
+};
+
+const LABELS: Record<"zh" | "en", Record<HookPayoffTiming, string>> = {
+  en: {
+    immediate: "immediate",
+    "near-term": "near-term",
+    "mid-arc": "mid-arc",
+    "slow-burn": "slow-burn",
+    endgame: "endgame",
+  },
+  zh: {
+    immediate: "立即",
+    "near-term": "近期",
+    "mid-arc": "中程",
+    "slow-burn": "慢烧",
+    endgame: "终局",
+  },
+};
+
+const TIMING_ALIASES: Array<[HookPayoffTiming, RegExp]> = [
+  ["immediate", /^(?:立即|马上|当章|本章|下一章|immediate|instant|next(?:\s+chapter|\s+beat)?|right\s+away)$/i],
+  ["near-term", /^(?:近期|近几章|短线|soon|short(?:\s+run)?|near(?:\s*-\s*|\s+)term|current\s+sequence)$/i],
+  ["mid-arc", /^(?:中程|中期|卷中|mid(?:\s*-\s*|\s+)arc|mid(?:\s*-\s*|\s+)book|middle)$/i],
+  ["slow-burn", /^(?:慢烧|长线|后续|later|late(?:r)?|long(?:\s*-\s*|\s+)arc|slow(?:\s*-\s*|\s+)burn)$/i],
+  ["endgame", /^(?:终局|终章|大结局|最终|climax|finale|endgame|late\s+book)$/i],
+];
+
+const SIGNAL_PATTERNS: Array<[HookPayoffTiming, RegExp]> = [
+  ["endgame", /(终局|终章|大结局|最终揭晓|最终摊牌|climax|finale|endgame|final reveal|last act)/i],
+  ["immediate", /(当章|本章|下一章|马上|立刻|即刻|immediate|next chapter|right away|at once)/i],
+  ["near-term", /(近期|近几章|很快|短线|soon|near-term|short run|current sequence)/i],
+  ["mid-arc", /(中期|卷中|本卷中段|mid-book|mid arc|middle of the arc)/i],
+  ["slow-burn", /(长线|慢烧|后续发酵|慢慢揭开|later|slow burn|long arc|long tail)/i],
+];
+
+export function normalizeHookPayoffTiming(value: string | undefined | null): HookPayoffTiming | undefined {
+  const normalized = value?.trim();
+  if (!normalized) return undefined;
+
+  for (const [timing, pattern] of TIMING_ALIASES) {
+    if (pattern.test(normalized)) {
+      return timing;
+    }
+  }
+
+  return undefined;
+}
+
+export function inferHookPayoffTiming(params: {
+  readonly expectedPayoff?: string;
+  readonly notes?: string;
+}): HookPayoffTiming {
+  const combined = [params.expectedPayoff, params.notes]
+    .filter((value): value is string => Boolean(value && value.trim()))
+    .join(" ")
+    .trim();
+  if (!combined) return "mid-arc";
+
+  for (const [timing, pattern] of SIGNAL_PATTERNS) {
+    if (pattern.test(combined)) {
+      return timing;
+    }
+  }
+
+  return "mid-arc";
+}
+
+export function resolveHookPayoffTiming(params: {
+  readonly payoffTiming?: string | null;
+  readonly expectedPayoff?: string;
+  readonly notes?: string;
+}): HookPayoffTiming {
+  return normalizeHookPayoffTiming(params.payoffTiming)
+    ?? inferHookPayoffTiming({
+      expectedPayoff: params.expectedPayoff,
+      notes: params.notes,
+    });
+}
+
+export function localizeHookPayoffTiming(
+  timing: HookPayoffTiming,
+  language: "zh" | "en",
+): string {
+  return LABELS[language][timing];
+}
+
+export function describeHookLifecycle(params: {
+  readonly payoffTiming?: string | null;
+  readonly expectedPayoff?: string;
+  readonly notes?: string;
+  readonly startChapter: number;
+  readonly lastAdvancedChapter: number;
+  readonly status: string;
+  readonly chapterNumber: number;
+  readonly targetChapters?: number;
+}): {
+  readonly timing: HookPayoffTiming;
+  readonly phase: HookPhase;
+  readonly age: number;
+  readonly dormancy: number;
+  readonly readyToResolve: boolean;
+  readonly stale: boolean;
+  readonly overdue: boolean;
+  readonly advancePressure: number;
+  readonly resolvePressure: number;
+} {
+  const timing = resolveHookPayoffTiming(params);
+  const profile = TIMING_PROFILES[timing];
+  const phase = resolveHookPhase(params.chapterNumber, params.targetChapters);
+  const age = Math.max(0, params.chapterNumber - Math.max(1, params.startChapter));
+  const lastTouchChapter = Math.max(params.startChapter, params.lastAdvancedChapter);
+  const dormancy = Math.max(0, params.chapterNumber - Math.max(1, lastTouchChapter));
+  const explicitProgressing = /^(progressing|advanced|重大推进|持续推进)$/i.test(params.status.trim());
+  const phaseReady = PHASE_WEIGHT[phase] >= PHASE_WEIGHT[profile.minimumPhase];
+  const recentlyTouched = dormancy <= 1;
+  const overdue = phaseReady && age >= profile.overdueAge;
+  const cadenceReady = timing === "slow-burn"
+    ? phase === "late" || overdue
+    : timing === "endgame"
+      ? phase === "late"
+      : true;
+  const momentum = explicitProgressing || recentlyTouched;
+  const stale = phaseReady && (
+    dormancy >= profile.staleDormancy
+    || (overdue && !momentum)
+  );
+  const readyToResolve = phaseReady
+    && cadenceReady
+    && age >= profile.earliestResolveAge
+    && (momentum || (overdue && explicitProgressing));
+
+  return {
+    timing,
+    phase,
+    age,
+    dormancy,
+    readyToResolve,
+    stale,
+    overdue,
+    advancePressure: age + dormancy + (stale ? 8 : 0) + (overdue ? 6 : 0),
+    resolvePressure: readyToResolve
+      ? profile.resolveBias * 10 + (explicitProgressing ? 5 : 0) + Math.min(12, dormancy * 2) + (overdue ? 10 : 0)
+      : 0,
+  };
+}
+
+function resolveHookPhase(chapterNumber: number, targetChapters?: number): HookPhase {
+  if (targetChapters && targetChapters > 0) {
+    const progress = chapterNumber / targetChapters;
+    if (progress >= 0.72) return "late";
+    if (progress >= 0.33) return "middle";
+    return "opening";
+  }
+
+  if (chapterNumber >= 24) return "late";
+  if (chapterNumber >= 8) return "middle";
+  return "opening";
+}

+ 81 - 35
packages/core/src/utils/memory-retrieval.ts

@@ -10,7 +10,12 @@ import {
 } from "../models/runtime-state.js";
 import { MemoryDB, type Fact, type StoredHook, type StoredSummary } from "../state/memory-db.js";
 import { bootstrapStructuredStateFromMarkdown, normalizeHookId } from "../state/state-bootstrap.js";
-import { collectStaleHookDebt } from "./hook-governance.js";
+import {
+  describeHookLifecycle,
+  localizeHookPayoffTiming,
+  resolveHookPayoffTiming,
+  normalizeHookPayoffTiming,
+} from "./hook-lifecycle.js";
 
 export interface MemorySelection {
   readonly summaries: ReadonlyArray<StoredSummary>;
@@ -191,12 +196,12 @@ export function renderHookSnapshot(
 
   const headers = language === "en"
     ? [
-      "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
-      "| --- | --- | --- | --- | --- | --- | --- |",
+      "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
     ]
     : [
-      "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
-      "| --- | --- | --- | --- | --- | --- | --- |",
+      "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
     ];
 
   return [
@@ -208,6 +213,7 @@ export function renderHookSnapshot(
       hook.status,
       hook.lastAdvancedChapter,
       hook.expectedPayoff,
+      localizeHookPayoffTiming(resolveHookPayoffTiming(hook), language),
       hook.notes,
     ].map((cell) => escapeTableCell(String(cell))).join(" | ")).map((row) => `| ${row} |`),
   ].join("\n");
@@ -216,6 +222,7 @@ export function renderHookSnapshot(
 export function buildPlannerHookAgenda(params: {
   readonly hooks: ReadonlyArray<StoredHook>;
   readonly chapterNumber: number;
+  readonly targetChapters?: number;
   readonly maxMustAdvance?: number;
   readonly maxEligibleResolve?: number;
   readonly maxStaleDebt?: number;
@@ -224,31 +231,55 @@ export function buildPlannerHookAgenda(params: {
     .map(normalizeStoredHook)
     .filter((hook) => !isFuturePlannedHook(hook, params.chapterNumber, 0))
     .filter((hook) => hook.status !== "resolved" && hook.status !== "deferred");
-  const mustAdvanceHooks = agendaHooks
+  const lifecycleEntries = agendaHooks.map((hook) => ({
+    hook,
+    lifecycle: describeHookLifecycle({
+      payoffTiming: hook.payoffTiming,
+      expectedPayoff: hook.expectedPayoff,
+      notes: hook.notes,
+      startChapter: hook.startChapter,
+      lastAdvancedChapter: hook.lastAdvancedChapter,
+      status: hook.status,
+      chapterNumber: params.chapterNumber,
+      targetChapters: params.targetChapters,
+    }),
+  }));
+  const staleDebtHooks = lifecycleEntries
+    .filter((entry) => entry.lifecycle.stale)
+    .sort((left, right) => (
+      Number(right.lifecycle.overdue) - Number(left.lifecycle.overdue)
+      || right.lifecycle.advancePressure - left.lifecycle.advancePressure
+      || left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter
+      || left.hook.startChapter - right.hook.startChapter
+      || left.hook.hookId.localeCompare(right.hook.hookId)
+    ))
+    .slice(0, params.maxStaleDebt ?? 2)
+    .map((entry) => entry.hook);
+  const mustAdvanceHooks = lifecycleEntries
     .slice()
     .sort((left, right) => (
-      left.lastAdvancedChapter - right.lastAdvancedChapter
-      || left.startChapter - right.startChapter
-      || left.hookId.localeCompare(right.hookId)
+      Number(right.lifecycle.stale) - Number(left.lifecycle.stale)
+      || right.lifecycle.advancePressure - left.lifecycle.advancePressure
+      || left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter
+      || left.hook.startChapter - right.hook.startChapter
+      || left.hook.hookId.localeCompare(right.hook.hookId)
     ))
-    .slice(0, params.maxMustAdvance ?? 2);
-  const staleDebtHooks = collectStaleHookDebt({
-    hooks: agendaHooks,
-    chapterNumber: params.chapterNumber,
-  })
-    .slice(0, params.maxStaleDebt ?? 2);
-  const eligibleResolveHooks = agendaHooks
-    .filter((hook) => hook.startChapter <= params.chapterNumber - 3)
-    .filter((hook) => hook.lastAdvancedChapter >= params.chapterNumber - 2)
+    .slice(0, params.maxMustAdvance ?? 2)
+    .map((entry) => entry.hook);
+  const eligibleResolveHooks = lifecycleEntries
+    .filter((entry) => entry.lifecycle.readyToResolve)
     .sort((left, right) => (
-      left.startChapter - right.startChapter
-      || right.lastAdvancedChapter - left.lastAdvancedChapter
-      || left.hookId.localeCompare(right.hookId)
+      right.lifecycle.resolvePressure - left.lifecycle.resolvePressure
+      || Number(right.lifecycle.stale) - Number(left.lifecycle.stale)
+      || left.hook.startChapter - right.hook.startChapter
+      || left.hook.hookId.localeCompare(right.hook.hookId)
     ))
-    .slice(0, params.maxEligibleResolve ?? 1);
+    .slice(0, params.maxEligibleResolve ?? 1)
+    .map((entry) => entry.hook);
   const avoidNewHookFamilies = [...new Set([
     ...staleDebtHooks.map((hook) => hook.type.trim()).filter(Boolean),
     ...mustAdvanceHooks.map((hook) => hook.type.trim()).filter(Boolean),
+    ...eligibleResolveHooks.map((hook) => hook.type.trim()).filter(Boolean),
   ])].slice(0, 3);
 
   return {
@@ -388,15 +419,7 @@ export function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
   if (tableRows.length > 0) {
     return tableRows
       .filter((row) => normalizeHookId(row[0]).length > 0)
-      .map((row) => ({
-        hookId: normalizeHookId(row[0]),
-        startChapter: parseInteger(row[1]),
-        type: row[2] ?? "",
-        status: row[3] ?? "open",
-        lastAdvancedChapter: parseInteger(row[4]),
-        expectedPayoff: row[5] ?? "",
-        notes: row[6] ?? "",
-      }));
+      .map((row) => parsePendingHookRow(row));
   }
 
   return markdown
@@ -412,10 +435,28 @@ export function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
       status: "open",
       lastAdvancedChapter: 0,
       expectedPayoff: "",
+      payoffTiming: undefined,
       notes: line,
     }));
 }
 
+function parsePendingHookRow(row: ReadonlyArray<string | undefined>): StoredHook {
+  const legacyShape = row.length < 8;
+  const payoffTiming = legacyShape ? undefined : normalizeHookPayoffTiming(row[6]);
+  const notes = legacyShape ? (row[6] ?? "") : (row[7] ?? "");
+
+  return {
+    hookId: normalizeHookId(row[0]),
+    startChapter: parseInteger(row[1]),
+    type: row[2] ?? "",
+    status: row[3] ?? "open",
+    lastAdvancedChapter: parseInteger(row[4]),
+    expectedPayoff: row[5] ?? "",
+    payoffTiming,
+    notes,
+  };
+}
+
 export function parseCurrentStateFacts(
   markdown: string,
   fallbackChapter: number,
@@ -553,9 +594,9 @@ function selectRelevantHooks(
   const ranked = hooks
     .map((hook) => ({
       hook,
-      score: scoreHook(hook, queryTerms),
+      score: scoreHook(hook, queryTerms, chapterNumber),
       matched: matchesAny(
-        [hook.hookId, hook.type, hook.expectedPayoff, hook.notes].join(" "),
+        [hook.hookId, hook.type, hook.expectedPayoff, hook.payoffTiming ?? "", hook.notes].join(" "),
         queryTerms,
       ),
     }))
@@ -665,8 +706,12 @@ function scoreSummary(summary: StoredSummary, chapterNumber: number, queryTerms:
   return recencyScore + termScore;
 }
 
-function scoreHook(hook: StoredHook, queryTerms: ReadonlyArray<string>): number {
-  const text = [hook.hookId, hook.type, hook.expectedPayoff, hook.notes].join(" ");
+function scoreHook(
+  hook: StoredHook,
+  queryTerms: ReadonlyArray<string>,
+  _chapterNumber: number,
+): number {
+  const text = [hook.hookId, hook.type, hook.expectedPayoff, hook.payoffTiming ?? "", hook.notes].join(" ");
   const freshness = Math.max(0, hook.lastAdvancedChapter);
   const termScore = queryTerms.reduce((score, term) => score + (includesTerm(text, term) ? Math.max(8, term.length * 2) : 0), 0);
   return termScore + freshness;
@@ -680,6 +725,7 @@ function normalizeStoredHook(hook: StoredHook): HookRecord {
     status: normalizeStoredHookStatus(hook.status),
     lastAdvancedChapter: Math.max(0, hook.lastAdvancedChapter),
     expectedPayoff: hook.expectedPayoff,
+    payoffTiming: resolveHookPayoffTiming(hook),
     notes: hook.notes,
   };
 }