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

feat: arbitrate new hook candidates before state apply

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

+ 124 - 0
packages/core/src/__tests__/hook-arbiter.test.ts

@@ -0,0 +1,124 @@
+import { describe, expect, it } from "vitest";
+import type { HookRecord, RuntimeStateDelta } from "../models/runtime-state.js";
+import { arbitrateRuntimeStateDeltaHooks } from "../utils/hook-arbiter.js";
+
+function createHook(overrides: Partial<HookRecord> = {}): HookRecord {
+  return {
+    hookId: overrides.hookId ?? "H001",
+    startChapter: overrides.startChapter ?? 1,
+    type: overrides.type ?? "mystery",
+    status: overrides.status ?? "open",
+    lastAdvancedChapter: overrides.lastAdvancedChapter ?? 1,
+    expectedPayoff: overrides.expectedPayoff ?? "Reveal the hidden ledger",
+    notes: overrides.notes ?? "Still unresolved",
+  };
+}
+
+function createDelta(overrides: Partial<RuntimeStateDelta> = {}): RuntimeStateDelta {
+  return {
+    chapter: overrides.chapter ?? 12,
+    hookOps: {
+      upsert: overrides.hookOps?.upsert ?? [],
+      mention: overrides.hookOps?.mention ?? [],
+      resolve: overrides.hookOps?.resolve ?? [],
+      defer: overrides.hookOps?.defer ?? [],
+    },
+    newHookCandidates: overrides.newHookCandidates ?? [],
+    subplotOps: [],
+    emotionalArcOps: [],
+    characterMatrixOps: [],
+    notes: [],
+  };
+}
+
+describe("arbitrateRuntimeStateDeltaHooks", () => {
+  it("maps a duplicate-family candidate back onto the matched existing hook", () => {
+    const result = arbitrateRuntimeStateDeltaHooks({
+      hooks: [
+        createHook({
+          hookId: "anonymous-source-scope",
+          type: "source-risk",
+          startChapter: 3,
+          lastAdvancedChapter: 8,
+          expectedPayoff: "Reveal how much the anonymous source already knew about the route.",
+          notes: "The source knowledge question remains unresolved.",
+        }),
+      ],
+      delta: createDelta({
+        newHookCandidates: [
+          {
+            type: "source-risk",
+            expectedPayoff: "Reveal how much the anonymous source already knew about the route and address.",
+            notes: "This chapter adds the address angle to the anonymous source question.",
+          },
+        ],
+      }),
+    });
+
+    expect(result.resolvedDelta.hookOps.upsert).toEqual([
+      expect.objectContaining({
+        hookId: "anonymous-source-scope",
+        lastAdvancedChapter: 12,
+      }),
+    ]);
+    expect(result.resolvedDelta.newHookCandidates).toEqual([]);
+  });
+
+  it("downgrades a pure restatement candidate into a mention instead of opening a new hook", () => {
+    const result = arbitrateRuntimeStateDeltaHooks({
+      hooks: [
+        createHook({
+          hookId: "mentor-debt",
+          type: "relationship",
+          expectedPayoff: "Reveal the real mentor debt.",
+          notes: "The mentor debt is still unresolved.",
+        }),
+      ],
+      delta: createDelta({
+        newHookCandidates: [
+          {
+            type: "relationship",
+            expectedPayoff: "Reveal the real mentor debt.",
+            notes: "The mentor debt is still unresolved.",
+          },
+        ],
+      }),
+    });
+
+    expect(result.resolvedDelta.hookOps.upsert).toEqual([]);
+    expect(result.resolvedDelta.hookOps.mention).toContain("mentor-debt");
+    expect(result.resolvedDelta.newHookCandidates).toEqual([]);
+  });
+
+  it("creates a canonical hook when the candidate is genuinely new", () => {
+    const result = arbitrateRuntimeStateDeltaHooks({
+      hooks: [
+        createHook({
+          hookId: "mentor-debt",
+          type: "relationship",
+          expectedPayoff: "Reveal the real mentor debt.",
+        }),
+      ],
+      delta: createDelta({
+        chapter: 15,
+        newHookCandidates: [
+          {
+            type: "artifact",
+            expectedPayoff: "Reveal why the seal answers only at midnight.",
+            notes: "A fresh unresolved rule around the seal appears in this chapter.",
+          },
+        ],
+      }),
+    });
+
+    expect(result.resolvedDelta.hookOps.upsert).toHaveLength(1);
+    expect(result.resolvedDelta.hookOps.upsert[0]).toEqual(expect.objectContaining({
+      startChapter: 15,
+      lastAdvancedChapter: 15,
+      type: "artifact",
+      status: "open",
+    }));
+    expect(result.resolvedDelta.hookOps.upsert[0]?.hookId).not.toBe("mentor-debt");
+    expect(result.resolvedDelta.newHookCandidates).toEqual([]);
+  });
+});

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

@@ -27,6 +27,7 @@ function createDelta(overrides: Partial<RuntimeStateDelta> = {}): RuntimeStateDe
       resolve: overrides.hookOps?.resolve ?? [],
       defer: overrides.hookOps?.defer ?? [],
     },
+    newHookCandidates: overrides.newHookCandidates ?? [],
     subplotOps: overrides.subplotOps ?? [],
     emotionalArcOps: overrides.emotionalArcOps ?? [],
     characterMatrixOps: overrides.characterMatrixOps ?? [],

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

@@ -23,6 +23,7 @@ function createDelta(overrides: Partial<RuntimeStateDelta> = {}): RuntimeStateDe
       resolve: overrides.hookOps?.resolve ?? [],
       defer: overrides.hookOps?.defer ?? [],
     },
+    newHookCandidates: overrides.newHookCandidates ?? [],
     subplotOps: [],
     emotionalArcOps: [],
     characterMatrixOps: [],

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

@@ -815,6 +815,13 @@ describe("Runtime state schemas", () => {
         resolve: [],
         defer: [],
       },
+      newHookCandidates: [
+        {
+          type: "source-risk",
+          expectedPayoff: "Reveal what the anonymous source already knew",
+          notes: "A new unresolved thread opens around the source's prior knowledge.",
+        },
+      ],
       chapterSummary: {
         chapter: 12,
         title: "River Ledger",
@@ -831,6 +838,7 @@ describe("Runtime state schemas", () => {
     expect(result.chapter).toBe(12);
     expect(result.hookOps.upsert[0]?.hookId).toBe("mentor-debt");
     expect(result.hookOps.mention).toEqual(["ledger-whisper"]);
+    expect(result.newHookCandidates[0]?.type).toBe("source-risk");
   });
 
   it("rejects natural-language numeric drift in runtime-state delta hooks", () => {

+ 1 - 0
packages/core/src/__tests__/pipeline-runner-memory-sync.test.ts

@@ -234,6 +234,7 @@ describe("PipelineRunner structured-state memory sync", () => {
           resolve: [],
           defer: [],
         },
+        newHookCandidates: [],
         chapterSummary: {
           chapter: 1,
           title: "Structured Summary",

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

@@ -1802,6 +1802,7 @@ describe("PipelineRunner", () => {
             resolve: [],
             defer: [],
           },
+          newHookCandidates: [],
           chapterSummary: {
             chapter: 1,
             title: "River Ledger",

+ 78 - 0
packages/core/src/__tests__/runtime-state-store.test.ts

@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { afterEach, describe, expect, it } from "vitest";
 import {
+  buildRuntimeStateArtifacts,
   loadNarrativeMemorySeed,
   loadRuntimeStateSnapshot,
   loadSnapshotCurrentStateFacts,
@@ -215,4 +216,81 @@ describe("runtime-state-store memory helpers", () => {
     expect(snapshot.chapterSummaries.rows).toHaveLength(1);
     expect(snapshot.chapterSummaries.rows[0]?.title).toBe("重复河埠对账");
   });
+
+  it("arbitrates new hook candidates before applying structured state updates", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-runtime-state-arbiter-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    const stateDir = join(storyDir, "state");
+    await mkdir(stateDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(join(stateDir, "manifest.json"), JSON.stringify({
+        schemaVersion: 2,
+        language: "en",
+        lastAppliedChapter: 11,
+        projectionVersion: 1,
+        migrationWarnings: [],
+      }, null, 2), "utf-8"),
+      writeFile(join(stateDir, "current_state.json"), JSON.stringify({
+        chapter: 11,
+        facts: [],
+      }, null, 2), "utf-8"),
+      writeFile(join(stateDir, "hooks.json"), JSON.stringify({
+        hooks: [
+          {
+            hookId: "anonymous-source-scope",
+            startChapter: 3,
+            type: "source-risk",
+            status: "open",
+            lastAdvancedChapter: 8,
+            expectedPayoff: "Reveal how much the anonymous source already knew about the route.",
+            notes: "The source knowledge question remains unresolved.",
+          },
+        ],
+      }, null, 2), "utf-8"),
+      writeFile(join(stateDir, "chapter_summaries.json"), JSON.stringify({
+        rows: [],
+      }, null, 2), "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "", "utf-8"),
+      writeFile(join(storyDir, "chapter_summaries.md"), "", "utf-8"),
+    ]);
+
+    const artifacts = await buildRuntimeStateArtifacts({
+      bookDir,
+      language: "en",
+      delta: {
+        chapter: 12,
+        hookOps: {
+          upsert: [],
+          mention: [],
+          resolve: [],
+          defer: [],
+        },
+        newHookCandidates: [
+          {
+            type: "source-risk",
+            expectedPayoff: "Reveal how much the anonymous source already knew about the route and address.",
+            notes: "This chapter adds the address angle to the anonymous source question.",
+          },
+        ],
+        notes: [],
+        subplotOps: [],
+        emotionalArcOps: [],
+        characterMatrixOps: [],
+      },
+    });
+
+    expect(artifacts.resolvedDelta.hookOps.upsert).toEqual([
+      expect.objectContaining({
+        hookId: "anonymous-source-scope",
+        lastAdvancedChapter: 12,
+      }),
+    ]);
+    expect(artifacts.snapshot.hooks.hooks).toHaveLength(1);
+    expect(artifacts.snapshot.hooks.hooks[0]).toEqual(expect.objectContaining({
+      hookId: "anonymous-source-scope",
+      lastAdvancedChapter: 12,
+    }));
+  });
 });

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

@@ -98,4 +98,36 @@ describe("parseSettlerDeltaOutput", () => {
     expect(result.runtimeStateDelta.hookOps.resolve).toEqual(["old-seal"]);
     expect(result.runtimeStateDelta.hookOps.defer).toEqual(["guild-route"]);
   });
+
+  it("parses new hook candidates separately from existing hook ops", () => {
+    const result = parseSettlerDeltaOutput([
+      "=== RUNTIME_STATE_DELTA ===",
+      "```json",
+      JSON.stringify({
+        chapter: 21,
+        hookOps: {
+          upsert: [],
+          mention: ["mentor-oath"],
+          resolve: [],
+          defer: [],
+        },
+        newHookCandidates: [
+          {
+            type: "source-risk",
+            expectedPayoff: "Reveal what the anonymous source already knew about the route and address",
+            notes: "This chapter opens a fresh unresolved question about source knowledge.",
+          },
+        ],
+        notes: [],
+      }),
+      "```",
+    ].join("\n"));
+
+    expect(result.runtimeStateDelta.hookOps.upsert).toEqual([]);
+    expect(result.runtimeStateDelta.newHookCandidates).toEqual([
+      expect.objectContaining({
+        type: "source-risk",
+      }),
+    ]);
+  });
 });

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

@@ -377,6 +377,145 @@ describe("WriterAgent", () => {
     }
   });
 
+  it("returns the arbiter-resolved delta instead of raw new-hook candidates", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-writer-arbiter-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- Anonymous messages keep steering the debt trail.\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 3\nThe anonymous source widens from route to address.\n", "utf-8"),
+      writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), [
+        "# Current State",
+        "",
+        "| Field | Value |",
+        "| --- | --- |",
+        "| Current Chapter | 2 |",
+        "| Current Goal | Find who fed the route to the anonymous source |",
+        "",
+      ].join("\n"), "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), [
+        "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
+        "| --- | --- | --- | --- | --- | --- | --- |",
+        "| anonymous-source-scope | 1 | source-risk | open | 2 | Reveal how much the anonymous source already knew about the route. | The source knowledge question remains unresolved. |",
+        "",
+      ].join("\n"), "utf-8"),
+      writeFile(join(storyDir, "chapter_summaries.md"), [
+        "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+        "| --- | --- | --- | --- | --- | --- | --- | --- |",
+        "| 2 | Route Leak | Lin Yue | An anonymous source already knew the route | Suspicion sharpens | anonymous-source-scope advanced | tense | mainline |",
+        "",
+      ].join("\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,
+    });
+
+    vi.spyOn(WriterAgent.prototype as never, "chat" as never)
+      .mockResolvedValueOnce({
+        content: [
+          "=== CHAPTER_TITLE ===",
+          "Address Leak",
+          "",
+          "=== CHAPTER_CONTENT ===",
+          "Lin Yue realizes the anonymous source knew the address, not just the route.",
+          "",
+          "=== PRE_WRITE_CHECK ===",
+          "- ok",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      })
+      .mockResolvedValueOnce({
+        content: "=== OBSERVATIONS ===\n- observed",
+        usage: ZERO_USAGE,
+      })
+      .mockResolvedValueOnce({
+        content: [
+          "=== POST_SETTLEMENT ===",
+          "- source scope widens",
+          "",
+          "=== RUNTIME_STATE_DELTA ===",
+          "```json",
+          JSON.stringify({
+            chapter: 3,
+            hookOps: {
+              upsert: [],
+              mention: [],
+              resolve: [],
+              defer: [],
+            },
+            newHookCandidates: [
+              {
+                type: "source-risk",
+                expectedPayoff: "Reveal how much the anonymous source already knew about the route and address.",
+                notes: "This chapter adds the address angle to the anonymous source question.",
+              },
+            ],
+            chapterSummary: {
+              chapter: 3,
+              title: "Address Leak",
+              characters: "Lin Yue",
+              events: "Lin Yue realizes the anonymous source knew the address.",
+              stateChanges: "The source knowledge question widens.",
+              hookActivity: "anonymous-source-scope advanced",
+              mood: "tight",
+              chapterType: "investigation",
+            },
+            notes: [],
+          }, null, 2),
+          "```",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    try {
+      const output = await agent.writeChapter({
+        book: {
+          id: "writer-book",
+          title: "Writer Book",
+          platform: "tomato",
+          genre: "other",
+          status: "active",
+          targetChapters: 20,
+          chapterWordCount: 2200,
+          language: "en",
+          createdAt: "2026-03-27T00:00:00.000Z",
+          updatedAt: "2026-03-27T00:00:00.000Z",
+        },
+        bookDir,
+        chapterNumber: 3,
+        lengthSpec: buildLengthSpec(2200, "en"),
+      });
+
+      expect(output.runtimeStateDelta?.hookOps.upsert).toEqual([
+        expect.objectContaining({
+          hookId: "anonymous-source-scope",
+          lastAdvancedChapter: 3,
+        }),
+      ]);
+      expect(output.runtimeStateDelta?.newHookCandidates).toEqual([]);
+      expect(output.updatedHooks).toContain("anonymous-source-scope");
+      expect(output.updatedHooks).toContain("| anonymous-source-scope | 1 | source-risk | progressing | 3 |");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("logs localized phase messages for Chinese books", async () => {
     const root = await mkdtemp(join(tmpdir(), "inkos-writer-test-"));
     const bookDir = join(root, "book");

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

@@ -23,6 +23,7 @@ export function buildSettlerSystemPrompt(
 - 推进伏笔:已有伏笔在本章出现了新的事实、证据、关系变化、风险升级或范围收缩 → **必须**更新"最近推进"列为当前章节号,更新状态和备注
 - 回收伏笔:伏笔在本章被明确揭示、解决、或不再成立 → 状态改为"已回收",备注回收方式
 - 延后伏笔:超过5章未推进 → 标注"延后",备注原因
+- brand-new unresolved thread:不要直接发明新的 hookId。把候选放进 newHookCandidates,由系统决定它是映射到旧 hook、变成真正新 hook,还是被拒绝为重述
 - **铁律**:不要把“再次提到”“换个说法重述”“抽象复盘”当成推进。只有状态真的变了,才更新最近推进。只是出现过的旧 hook,放进 mention 数组。`;
 
   const fullCastBlock = bookRules?.enableFullCastTracking
@@ -110,6 +111,13 @@ function buildSettlerOutputFormat(gp: GenreProfile): string {
     "resolve": ["已回收的 hookId"],
     "defer": ["需要标记延后的 hookId"]
   },
+  "newHookCandidates": [
+    {
+      "type": "mystery",
+      "expectedPayoff": "新伏笔未来要回收到哪里",
+      "notes": "本章为什么会形成新的未解问题"
+    }
+  ],
   "chapterSummary": {
     "chapter": 12,
     "title": "本章标题",
@@ -130,10 +138,12 @@ function buildSettlerOutputFormat(gp: GenreProfile): string {
 规则:
 1. 只输出增量,不要重写完整 truth files
 2. 所有章节号字段都必须是整数,不能写自然语言
-3. 如果旧 hook 只是被提到、没有真实状态变化,把它放进 mention,不要更新 lastAdvancedChapter
-4. 如果本章推进了旧 hook,lastAdvancedChapter 必须等于当前章号
-5. 如果回收或延后 hook,必须放在 resolve / defer 数组里
-6. chapterSummary.chapter 必须等于当前章节号`;
+3. hookOps.upsert 里只能写“当前伏笔池里已经存在”的 hookId,不允许发明新的 hookId
+4. brand-new unresolved thread 一律写进 newHookCandidates,不要自造 hookId
+5. 如果旧 hook 只是被提到、没有真实状态变化,把它放进 mention,不要更新 lastAdvancedChapter
+6. 如果本章推进了旧 hook,lastAdvancedChapter 必须等于当前章号
+7. 如果回收或延后 hook,必须放在 resolve / defer 数组里
+8. chapterSummary.chapter 必须等于当前章节号`;
 }
 
 export function buildSettlerUserPrompt(params: {

+ 7 - 5
packages/core/src/agents/writer.ts

@@ -307,14 +307,15 @@ export class WriterAgent extends BaseAgent {
       settlement.runtimeStateDelta,
       resolvedLanguage,
     );
+    const resolvedRuntimeStateDelta = runtimeStateArtifacts?.resolvedDelta ?? settlement.runtimeStateDelta;
     const priorHookIds = new Set(parsePendingHooksMarkdown(hooks).map((hook) => hook.hookId));
-    const hookHealthIssues = settlement.runtimeStateDelta
+    const hookHealthIssues = resolvedRuntimeStateDelta
       && (runtimeStateArtifacts?.snapshot ?? settlement.runtimeStateSnapshot)
       ? analyzeHookHealth({
           language: resolvedLanguage,
           chapterNumber,
           hooks: (runtimeStateArtifacts?.snapshot ?? settlement.runtimeStateSnapshot)!.hooks.hooks,
-          delta: settlement.runtimeStateDelta,
+          delta: resolvedRuntimeStateDelta,
           existingHookIds: [...priorHookIds],
         })
       : [];
@@ -371,13 +372,13 @@ export class WriterAgent extends BaseAgent {
       wordCount: creative.wordCount,
       preWriteCheck: creative.preWriteCheck,
       postSettlement: settlement.postSettlement,
-      runtimeStateDelta: settlement.runtimeStateDelta,
+      runtimeStateDelta: resolvedRuntimeStateDelta,
       runtimeStateSnapshot: runtimeStateArtifacts?.snapshot ?? settlement.runtimeStateSnapshot,
       updatedState: runtimeStateArtifacts?.currentStateMarkdown ?? settlement.updatedState,
       updatedLedger: settlement.updatedLedger,
       updatedHooks: runtimeStateArtifacts?.hooksMarkdown ?? settlement.updatedHooks,
-      chapterSummary: settlement.runtimeStateDelta
-        ? this.renderDeltaSummaryRow(settlement.runtimeStateDelta)
+      chapterSummary: resolvedRuntimeStateDelta
+        ? this.renderDeltaSummaryRow(resolvedRuntimeStateDelta)
         : settlement.chapterSummary,
       updatedChapterSummaries: runtimeStateArtifacts?.chapterSummariesMarkdown,
       updatedSubplots: settlement.updatedSubplots,
@@ -955,6 +956,7 @@ ${overrides}\n`;
     ) {
       return {
         snapshot: output.runtimeStateSnapshot,
+        resolvedDelta: output.runtimeStateDelta,
         currentStateMarkdown: output.updatedState,
         hooksMarkdown: output.updatedHooks,
         chapterSummariesMarkdown: output.updatedChapterSummaries,

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

@@ -20,6 +20,7 @@ export {
   type CurrentStateState,
   type CurrentStatePatch,
   type HookOps,
+  type NewHookCandidate,
   type RuntimeStateDelta,
   RuntimeStateLanguageSchema,
   StateManifestSchema,
@@ -32,6 +33,7 @@ export {
   CurrentStateStateSchema,
   CurrentStatePatchSchema,
   HookOpsSchema,
+  NewHookCandidateSchema,
   RuntimeStateDeltaSchema,
 } from "./models/runtime-state.js";
 export {
@@ -111,6 +113,7 @@ export {
   type HookAdmissionDecision,
   type HookDisposition,
 } from "./utils/hook-governance.js";
+export { arbitrateRuntimeStateDeltaHooks, type HookArbiterDecision } from "./utils/hook-arbiter.js";
 export { analyzeHookHealth } from "./utils/hook-health.js";
 
 // Pipeline

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

@@ -91,6 +91,14 @@ export const HookOpsSchema = z.object({
 
 export type HookOps = z.infer<typeof HookOpsSchema>;
 
+export const NewHookCandidateSchema = z.object({
+  type: z.string().min(1),
+  expectedPayoff: z.string().default(""),
+  notes: z.string().default(""),
+});
+
+export type NewHookCandidate = z.infer<typeof NewHookCandidateSchema>;
+
 const LooseOpSchema = z.record(z.string(), z.unknown());
 
 export const RuntimeStateDeltaSchema = z.object({
@@ -102,6 +110,7 @@ export const RuntimeStateDeltaSchema = z.object({
     resolve: [],
     defer: [],
   }),
+  newHookCandidates: z.array(NewHookCandidateSchema).default([]),
   chapterSummary: ChapterSummaryRowSchema.optional(),
   subplotOps: z.array(LooseOpSchema).default([]),
   emotionalArcOps: z.array(LooseOpSchema).default([]),

+ 8 - 1
packages/core/src/state/runtime-state-store.ts

@@ -12,9 +12,11 @@ import { bootstrapStructuredStateFromMarkdown, parseCurrentStateFacts } from "./
 import { renderChapterSummariesProjection, renderCurrentStateProjection, renderHooksProjection } from "./state-projections.js";
 import { applyRuntimeStateDelta, type RuntimeStateSnapshot } from "./state-reducer.js";
 import { validateRuntimeState } from "./state-validator.js";
+import { arbitrateRuntimeStateDeltaHooks } from "../utils/hook-arbiter.js";
 
 export interface RuntimeStateArtifacts {
   readonly snapshot: RuntimeStateSnapshot;
+  readonly resolvedDelta: RuntimeStateDelta;
   readonly currentStateMarkdown: string;
   readonly hooksMarkdown: string;
   readonly chapterSummariesMarkdown: string;
@@ -60,13 +62,18 @@ export async function buildRuntimeStateArtifacts(params: {
   readonly language: "zh" | "en";
 }): Promise<RuntimeStateArtifacts> {
   const snapshot = await loadRuntimeStateSnapshot(params.bookDir);
+  const { resolvedDelta } = arbitrateRuntimeStateDeltaHooks({
+    hooks: snapshot.hooks.hooks,
+    delta: params.delta,
+  });
   const next = applyRuntimeStateDelta({
     snapshot,
-    delta: params.delta,
+    delta: resolvedDelta,
   });
 
   return {
     snapshot: next,
+    resolvedDelta,
     currentStateMarkdown: renderCurrentStateProjection(next.currentState, params.language),
     hooksMarkdown: renderHooksProjection(next.hooks, params.language),
     chapterSummariesMarkdown: renderChapterSummariesProjection(next.chapterSummaries, params.language),

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

@@ -0,0 +1,324 @@
+import {
+  RuntimeStateDeltaSchema,
+  type HookRecord,
+  type NewHookCandidate,
+  type RuntimeStateDelta,
+} from "../models/runtime-state.js";
+import { evaluateHookAdmission } from "./hook-governance.js";
+
+export interface HookArbiterDecision {
+  readonly action: "created" | "mapped" | "mentioned" | "rejected";
+  readonly reason: string;
+  readonly hookId?: string;
+  readonly candidate: NewHookCandidate;
+}
+
+interface PendingHookCandidate extends NewHookCandidate {
+  readonly preferredHookId?: string;
+}
+
+export function arbitrateRuntimeStateDeltaHooks(params: {
+  readonly hooks: ReadonlyArray<HookRecord>;
+  readonly delta: RuntimeStateDelta;
+}): {
+  readonly resolvedDelta: RuntimeStateDelta;
+  readonly decisions: ReadonlyArray<HookArbiterDecision>;
+} {
+  const delta = RuntimeStateDeltaSchema.parse(params.delta);
+  const workingHooks = params.hooks.map((hook) => ({ ...hook }));
+  const knownHookIds = new Set(workingHooks.map((hook) => hook.hookId));
+  const upsertsById = new Map<string, HookRecord>();
+  const mentions = new Set(delta.hookOps.mention);
+  const resolves = uniqueStrings(delta.hookOps.resolve);
+  const defers = uniqueStrings(delta.hookOps.defer);
+  const fallbackCandidates: PendingHookCandidate[] = [];
+  const decisions: HookArbiterDecision[] = [];
+
+  for (const hook of delta.hookOps.upsert) {
+    if (knownHookIds.has(hook.hookId)) {
+      const normalized = { ...hook };
+      upsertsById.set(normalized.hookId, normalized);
+      replaceWorkingHook(workingHooks, normalized);
+      continue;
+    }
+
+    fallbackCandidates.push({
+      type: hook.type,
+      expectedPayoff: hook.expectedPayoff,
+      notes: hook.notes,
+      preferredHookId: hook.hookId,
+    });
+  }
+
+  for (const candidate of [...fallbackCandidates, ...delta.newHookCandidates]) {
+    const activeHooks = workingHooks.filter((hook) => hook.status !== "resolved");
+    const admission = evaluateHookAdmission({
+      candidate,
+      activeHooks,
+    });
+
+    if (!admission.admit) {
+      if (admission.reason === "duplicate_family" && admission.matchedHookId) {
+        const matched = workingHooks.find((hook) => hook.hookId === admission.matchedHookId);
+        if (!matched) {
+          decisions.push({
+            action: "rejected",
+            reason: "duplicate_family_without_match",
+            candidate,
+          });
+          continue;
+        }
+
+        if (isPureRestatement(candidate, matched)) {
+          if (!upsertsById.has(matched.hookId) && !resolves.includes(matched.hookId) && !defers.includes(matched.hookId)) {
+            mentions.add(matched.hookId);
+          }
+          decisions.push({
+            action: "mentioned",
+            reason: "restated_existing_family",
+            hookId: matched.hookId,
+            candidate,
+          });
+          continue;
+        }
+
+        const base = upsertsById.get(matched.hookId) ?? matched;
+        const mapped = mergeCandidateIntoExistingHook(base, candidate, delta.chapter);
+        upsertsById.set(mapped.hookId, mapped);
+        mentions.delete(mapped.hookId);
+        replaceWorkingHook(workingHooks, mapped);
+        decisions.push({
+          action: "mapped",
+          reason: "duplicate_family_with_novelty",
+          hookId: matched.hookId,
+          candidate,
+        });
+        continue;
+      }
+
+      decisions.push({
+        action: "rejected",
+        reason: admission.reason,
+        candidate,
+      });
+      continue;
+    }
+
+    const created = createCanonicalHook({
+      candidate,
+      chapter: delta.chapter,
+      existingIds: new Set([
+        ...workingHooks.map((hook) => hook.hookId),
+        ...upsertsById.keys(),
+      ]),
+    });
+    upsertsById.set(created.hookId, created);
+    workingHooks.push(created);
+    decisions.push({
+      action: "created",
+      reason: "admit",
+      hookId: created.hookId,
+      candidate,
+    });
+  }
+
+  const resolvedDelta = RuntimeStateDeltaSchema.parse({
+    ...delta,
+    hookOps: {
+      upsert: [...upsertsById.values()].sort(sortHooks),
+      mention: [...mentions]
+        .filter((hookId) => !upsertsById.has(hookId))
+        .filter((hookId) => !resolves.includes(hookId))
+        .filter((hookId) => !defers.includes(hookId))
+        .sort(),
+      resolve: resolves,
+      defer: defers,
+    },
+    newHookCandidates: [],
+  });
+
+  return {
+    resolvedDelta,
+    decisions,
+  };
+}
+
+function mergeCandidateIntoExistingHook(
+  existing: HookRecord,
+  candidate: NewHookCandidate,
+  chapter: number,
+): HookRecord {
+  return {
+    ...existing,
+    type: preferRicherText(existing.type, candidate.type),
+    status: existing.status === "resolved" ? "resolved" : "progressing",
+    lastAdvancedChapter: Math.max(existing.lastAdvancedChapter, chapter),
+    expectedPayoff: preferRicherText(existing.expectedPayoff, candidate.expectedPayoff),
+    notes: preferRicherText(existing.notes, candidate.notes),
+  };
+}
+
+function createCanonicalHook(params: {
+  readonly candidate: PendingHookCandidate;
+  readonly chapter: number;
+  readonly existingIds: ReadonlySet<string>;
+}): HookRecord {
+  return {
+    hookId: buildCanonicalHookId(params.candidate, params.existingIds),
+    startChapter: params.chapter,
+    type: params.candidate.type.trim(),
+    status: "open",
+    lastAdvancedChapter: params.chapter,
+    expectedPayoff: params.candidate.expectedPayoff.trim(),
+    notes: params.candidate.notes.trim(),
+  };
+}
+
+function buildCanonicalHookId(
+  candidate: PendingHookCandidate,
+  existingIds: ReadonlySet<string>,
+): string {
+  const preferred = candidate.preferredHookId?.trim();
+  if (preferred && !existingIds.has(preferred)) {
+    return preferred;
+  }
+
+  const base = slugifyHookStem([
+    candidate.type,
+    candidate.expectedPayoff,
+    candidate.notes,
+  ].join(" "));
+  let next = base;
+  let suffix = 2;
+
+  while (existingIds.has(next)) {
+    next = `${base}-${suffix}`;
+    suffix += 1;
+  }
+
+  return next;
+}
+
+function slugifyHookStem(value: string): string {
+  const normalized = value
+    .trim()
+    .toLowerCase()
+    .replace(/[^a-z0-9\u4e00-\u9fff]+/g, " ")
+    .replace(/\s+/g, " ")
+    .trim();
+  const englishTerms = (normalized.match(/[a-z0-9]{3,}/g) ?? [])
+    .filter((term) => !STOP_WORDS.has(term))
+    .slice(0, 5);
+  const chineseTerms = (normalized.match(/[\u4e00-\u9fff]{2,6}/g) ?? []).slice(0, 3);
+  const stem = [...englishTerms, ...chineseTerms].join("-").slice(0, 64).replace(/-+$/g, "");
+  return stem || "hook";
+}
+
+function isPureRestatement(candidate: NewHookCandidate, existing: HookRecord): boolean {
+  const candidateText = normalizeText([
+    candidate.type,
+    candidate.expectedPayoff,
+    candidate.notes,
+  ].join(" "));
+  const existingText = normalizeText([
+    existing.type,
+    existing.expectedPayoff,
+    existing.notes,
+  ].join(" "));
+
+  if (!candidateText) return true;
+  if (candidateText === existingText) return true;
+
+  const candidateTerms = extractTerms(candidateText);
+  const existingTerms = extractTerms(existingText);
+  const novelTerms = [...candidateTerms].filter((term) => !existingTerms.has(term));
+
+  const candidateChinese = extractChineseBigrams(candidateText);
+  const existingChinese = extractChineseBigrams(existingText);
+  const novelChinese = [...candidateChinese].filter((term) => !existingChinese.has(term));
+
+  return novelTerms.length === 0 && novelChinese.length < 2;
+}
+
+function replaceWorkingHook(workingHooks: HookRecord[], hook: HookRecord): void {
+  const index = workingHooks.findIndex((candidate) => candidate.hookId === hook.hookId);
+  if (index >= 0) {
+    workingHooks[index] = hook;
+    return;
+  }
+
+  workingHooks.push(hook);
+}
+
+function sortHooks(left: HookRecord, right: HookRecord): number {
+  return left.startChapter - right.startChapter
+    || left.lastAdvancedChapter - right.lastAdvancedChapter
+    || left.hookId.localeCompare(right.hookId);
+}
+
+function uniqueStrings(values: ReadonlyArray<string>): string[] {
+  return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
+}
+
+function preferRicherText(primary: string, fallback: string): string {
+  const left = primary.trim();
+  const right = fallback.trim();
+
+  if (!left) return right;
+  if (!right) return left;
+  if (left === right) return left;
+  return right.length > left.length ? right : left;
+}
+
+function normalizeText(value: string): string {
+  return value
+    .trim()
+    .toLowerCase()
+    .replace(/[^a-z0-9\u4e00-\u9fff]+/g, " ")
+    .replace(/\s+/g, " ")
+    .trim();
+}
+
+function extractTerms(value: string): Set<string> {
+  const english = value
+    .split(" ")
+    .map((term) => term.trim())
+    .filter((term) => term.length >= 4)
+    .filter((term) => !STOP_WORDS.has(term));
+  const chinese = value.match(/[\u4e00-\u9fff]{2,6}/g) ?? [];
+  return new Set([...english, ...chinese]);
+}
+
+function extractChineseBigrams(value: string): Set<string> {
+  const segments = value.match(/[\u4e00-\u9fff]+/g) ?? [];
+  const terms = new Set<string>();
+
+  for (const segment of segments) {
+    if (segment.length < 2) {
+      continue;
+    }
+
+    for (let index = 0; index <= segment.length - 2; index += 1) {
+      terms.add(segment.slice(index, index + 2));
+    }
+  }
+
+  return terms;
+}
+
+const STOP_WORDS = new Set([
+  "that",
+  "this",
+  "with",
+  "from",
+  "into",
+  "still",
+  "just",
+  "have",
+  "will",
+  "reveal",
+  "about",
+  "already",
+  "question",
+  "chapter",
+]);