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

fix(hooks): promote stale debt and explicit hook agenda in writer

Ma 7 месяцев назад
Родитель
Сommit
db55742fce

+ 7 - 2
packages/core/src/__tests__/planner.test.ts

@@ -1109,9 +1109,10 @@ describe("PlannerAgent", () => {
       externalContext: "Keep the chapter on the mainline debt conflict.",
     });
 
-    expect(result.intent.hookAgenda.mustAdvance).toEqual(["recent-route", "ready-payoff"]);
+    expect(result.intent.hookAgenda.mustAdvance).toEqual(["stale-debt", "ready-payoff"]);
     expect(result.intent.hookAgenda.eligibleResolve).toEqual(["ready-payoff"]);
     expect(result.intent.hookAgenda.staleDebt).toEqual(["stale-debt"]);
+    expect(result.intent.hookAgenda.avoidNewHookFamilies).toContain("relationship");
 
     const intentMarkdown = await readFile(result.runtimePath, "utf-8");
     expect(intentMarkdown).toContain("## Hook Agenda");
@@ -1226,7 +1227,11 @@ describe("PlannerAgent", () => {
       externalContext: "Keep the chapter on the route pressure.",
     });
 
-    expect(result.intent.hookAgenda.mustAdvance).toEqual(["recent-route", "recent-guild"]);
+    expect(result.intent.hookAgenda.mustAdvance).toEqual(["stale-omega", "stale-sable"]);
     expect(result.intent.hookAgenda.staleDebt).toEqual(["stale-omega", "stale-sable"]);
+    expect(result.intent.hookAgenda.avoidNewHookFamilies).toEqual(expect.arrayContaining([
+      "relationship",
+      "mystery",
+    ]));
   });
 });

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

@@ -907,4 +907,152 @@ describe("WriterAgent", () => {
       await rm(root, { recursive: true, force: true });
     }
   });
+
+  it("renders an explicit hook agenda block and removes placeholder hook ids from the governed write contract", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-writer-hook-agenda-test-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- Registry seals still matter.\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 4\nPush Mara back toward the archive ledger.\n", "utf-8"),
+      writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose lean.\n", "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Mara still hides the ledger fragment.\n", "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n- ledger-fragment\n", "utf-8"),
+      writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
+    ]);
+
+    const agent = new WriterAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0, maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    const chatSpy = vi.spyOn(WriterAgent.prototype as never, "chat" as never)
+      .mockResolvedValueOnce({
+        content: [
+          "=== CHAPTER_TITLE ===",
+          "Archive Pressure",
+          "",
+          "=== CHAPTER_CONTENT ===",
+          "Mara corners Taryn beside the archive ledger.",
+          "",
+          "=== PRE_WRITE_CHECK ===",
+          "- ok",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      })
+      .mockResolvedValueOnce({
+        content: "=== OBSERVATIONS ===\n- observed",
+        usage: ZERO_USAGE,
+      })
+      .mockResolvedValueOnce({
+        content: [
+          "=== POST_SETTLEMENT ===",
+          "- ledger-fragment advanced",
+          "",
+          "=== UPDATED_STATE ===",
+          "state",
+          "",
+          "=== UPDATED_HOOKS ===",
+          "hooks",
+          "",
+          "=== CHAPTER_SUMMARY ===",
+          "| 4 | Archive Pressure | Mara,Taryn | Pressure rises | Trail narrows | ledger-fragment advanced | tense | confrontation |",
+          "",
+          "=== UPDATED_SUBPLOTS ===",
+          "subplots",
+          "",
+          "=== UPDATED_EMOTIONAL_ARCS ===",
+          "arcs",
+          "",
+          "=== UPDATED_CHARACTER_MATRIX ===",
+          "matrix",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    try {
+      await agent.writeChapter({
+        book: {
+          id: "writer-book",
+          title: "Writer Book",
+          platform: "other",
+          genre: "other",
+          status: "active",
+          targetChapters: 20,
+          chapterWordCount: 2200,
+          language: "en",
+          createdAt: "2026-03-26T00:00:00.000Z",
+          updatedAt: "2026-03-26T00:00:00.000Z",
+        },
+        bookDir,
+        chapterNumber: 4,
+        chapterIntent: [
+          "# Chapter Intent",
+          "",
+          "## Goal",
+          "Push Mara back toward the archive ledger.",
+          "",
+          "## Hook Agenda",
+          "### Must Advance",
+          "- mentor-oath",
+          "",
+          "### Eligible Resolve",
+          "- ledger-fragment",
+          "",
+          "### Stale Debt",
+          "- stale-ledger",
+          "",
+          "### Avoid New Hook Families",
+          "- relationship",
+        ].join("\n"),
+        contextPackage: {
+          chapter: 4,
+          selectedContext: [
+            {
+              source: "story/pending_hooks.md#mentor-oath",
+              reason: "Carry the unresolved oath line.",
+              excerpt: "relationship | open | old oath debt",
+            },
+          ],
+        },
+        ruleStack: {
+          layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
+          sections: {
+            hard: ["current_state"],
+            soft: ["current_focus"],
+            diagnostic: ["continuity_audit"],
+          },
+          overrideEdges: [],
+          activeOverrides: [],
+        },
+        lengthSpec: buildLengthSpec(2200, "en"),
+      });
+
+      const systemPrompt = (chatSpy.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[0]?.content ?? "";
+      const creativePrompt = (chatSpy.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? "";
+
+      expect(systemPrompt).not.toContain("Hook-A / Hook-B");
+      expect(systemPrompt).toContain("真实 hook_id");
+      expect(creativePrompt).toContain("## Explicit Hook Agenda");
+      expect(creativePrompt).toContain("mentor-oath");
+      expect(creativePrompt).toContain("ledger-fragment");
+      expect(creativePrompt).toContain("stale-ledger");
+      expect(creativePrompt).toContain("relationship");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
 });

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

@@ -525,7 +525,7 @@ function buildCreativeOutputFormat(book: BookConfig, gp: GenreProfile, lengthSpe
 | 大纲锚定 | 当前卷名/阶段 + 本章应推进的具体节点 | 严禁跳过节点或提前消耗后续剧情 |
 | 上下文范围 | 第X章至第Y章 / 状态卡 / 设定文件 | |
 | 当前锚点 | 地点 / 对手 / 收益目标 | 锚点必须具体 |
-${resourceRow}| 待回收伏笔 | Hook-A / Hook-B | 与伏笔池一致 |
+${resourceRow}| 待回收伏笔 | 用真实 hook_id 填写(无则写 none) | 与伏笔池一致 |
 | 本章冲突 | 一句话概括 | |
 | 章节类型 | ${gp.chapterTypes.join("/")} | |
 | 风险扫描 | OOC/信息越界/设定冲突${gp.powerScaling ? "/战力崩坏" : ""}/节奏/词汇疲劳 | |`;
@@ -560,7 +560,7 @@ function buildOutputFormat(book: BookConfig, gp: GenreProfile, lengthSpec: Lengt
 | 大纲锚定 | 当前卷名/阶段 + 本章应推进的具体节点 | 严禁跳过节点或提前消耗后续剧情 |
 | 上下文范围 | 第X章至第Y章 / 状态卡 / 设定文件 | |
 | 当前锚点 | 地点 / 对手 / 收益目标 | 锚点必须具体 |
-${resourceRow}| 待回收伏笔 | Hook-A / Hook-B | 与伏笔池一致 |
+${resourceRow}| 待回收伏笔 | 用真实 hook_id 填写(无则写 none) | 与伏笔池一致 |
 | 本章冲突 | 一句话概括 | |
 | 章节类型 | ${gp.chapterTypes.join("/")} | |
 | 风险扫描 | OOC/信息越界/设定冲突${gp.powerScaling ? "/战力崩坏" : ""}/节奏/词汇疲劳 | |`;

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

@@ -733,6 +733,12 @@ ${lengthRequirementBlock}
     const selectedEvidenceBlock = params.selectedEvidenceBlock
       ? `\n${params.selectedEvidenceBlock}\n`
       : "";
+    const explicitHookAgenda = this.extractMarkdownSection(params.chapterIntent, "## Hook Agenda");
+    const hookAgendaBlock = explicitHookAgenda
+      ? params.language === "en"
+        ? `\n## Explicit Hook Agenda\n${explicitHookAgenda}\n`
+        : `\n## 显式 Hook Agenda\n${explicitHookAgenda}\n`
+      : "";
 
     if (params.language === "en") {
       return `Write chapter ${params.chapterNumber}.
@@ -743,6 +749,7 @@ ${params.chapterIntent}
 ## Selected Context
 ${contextSections || "(none)"}
 ${selectedEvidenceBlock}
+${hookAgendaBlock}
 
 ## Rule Stack
 - Hard: ${params.ruleStack.sections.hard.join(", ") || "(none)"}
@@ -769,6 +776,7 @@ ${params.chapterIntent}
 ## 已选上下文
 ${contextSections || "(无)"}
 ${selectedEvidenceBlock}
+${hookAgendaBlock}
 
 ## 规则栈
 - 硬护栏:${params.ruleStack.sections.hard.join("、") || "(无)"}
@@ -806,6 +814,29 @@ ${lengthRequirementBlock}
     return joined || undefined;
   }
 
+  private extractMarkdownSection(content: string, heading: string): string | undefined {
+    const lines = content.split("\n");
+    let buffer: string[] | null = null;
+
+    for (const line of lines) {
+      if (line.trim() === heading) {
+        buffer = [];
+        continue;
+      }
+
+      if (buffer && line.startsWith("## ") && line.trim() !== heading) {
+        break;
+      }
+
+      if (buffer) {
+        buffer.push(line);
+      }
+    }
+
+    const section = buffer?.join("\n").trim();
+    return section && section.length > 0 ? section : undefined;
+  }
+
   private buildSettlerGovernedControlBlock(
     chapterIntent: string,
     contextPackage: ContextPackage,

+ 15 - 14
packages/core/src/utils/memory-retrieval.ts

@@ -224,22 +224,20 @@ export function buildPlannerHookAgenda(params: {
     .map(normalizeStoredHook)
     .filter((hook) => !isFuturePlannedHook(hook, params.chapterNumber, 0))
     .filter((hook) => hook.status !== "resolved" && hook.status !== "deferred");
-  const mustAdvance = agendaHooks
+  const mustAdvanceHooks = agendaHooks
     .slice()
     .sort((left, right) => (
-      right.lastAdvancedChapter - left.lastAdvancedChapter
+      left.lastAdvancedChapter - right.lastAdvancedChapter
       || left.startChapter - right.startChapter
       || left.hookId.localeCompare(right.hookId)
     ))
-    .slice(0, params.maxMustAdvance ?? 2)
-    .map((hook) => hook.hookId);
-  const staleDebt = collectStaleHookDebt({
+    .slice(0, params.maxMustAdvance ?? 2);
+  const staleDebtHooks = collectStaleHookDebt({
     hooks: agendaHooks,
     chapterNumber: params.chapterNumber,
   })
-    .slice(0, params.maxStaleDebt ?? 2)
-    .map((hook) => hook.hookId);
-  const eligibleResolve = agendaHooks
+    .slice(0, params.maxStaleDebt ?? 2);
+  const eligibleResolveHooks = agendaHooks
     .filter((hook) => hook.startChapter <= params.chapterNumber - 3)
     .filter((hook) => hook.lastAdvancedChapter >= params.chapterNumber - 2)
     .sort((left, right) => (
@@ -247,14 +245,17 @@ export function buildPlannerHookAgenda(params: {
       || right.lastAdvancedChapter - left.lastAdvancedChapter
       || left.hookId.localeCompare(right.hookId)
     ))
-    .slice(0, params.maxEligibleResolve ?? 1)
-    .map((hook) => hook.hookId);
+    .slice(0, params.maxEligibleResolve ?? 1);
+  const avoidNewHookFamilies = [...new Set([
+    ...staleDebtHooks.map((hook) => hook.type.trim()).filter(Boolean),
+    ...mustAdvanceHooks.map((hook) => hook.type.trim()).filter(Boolean),
+  ])].slice(0, 3);
 
   return {
-    mustAdvance,
-    eligibleResolve,
-    staleDebt,
-    avoidNewHookFamilies: [],
+    mustAdvance: mustAdvanceHooks.map((hook) => hook.hookId),
+    eligibleResolve: eligibleResolveHooks.map((hook) => hook.hookId),
+    staleDebt: staleDebtHooks.map((hook) => hook.hookId),
+    avoidNewHookFamilies,
   };
 }