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

refactor: restore runtime-state v2 structured pipeline

Ma 7 месяцев назад
Родитель
Сommit
94e1968f3f
26 измененных файлов с 4071 добавлено и 116 удалено
  1. 2 0
      .gitignore
  2. 83 1
      packages/core/src/__tests__/composer.test.ts
  3. 491 3
      packages/core/src/__tests__/memory-retrieval.test.ts
  4. 130 0
      packages/core/src/__tests__/models.test.ts
  5. 309 0
      packages/core/src/__tests__/pipeline-runner-memory-sync.test.ts
  6. 422 5
      packages/core/src/__tests__/pipeline-runner.test.ts
  7. 215 0
      packages/core/src/__tests__/runtime-state-store.test.ts
  8. 99 0
      packages/core/src/__tests__/settler-delta-parser.test.ts
  9. 114 0
      packages/core/src/__tests__/state-manager.test.ts
  10. 130 0
      packages/core/src/__tests__/state-projections.test.ts
  11. 195 0
      packages/core/src/__tests__/state-reducer.test.ts
  12. 122 0
      packages/core/src/__tests__/state-validator.test.ts
  13. 143 0
      packages/core/src/__tests__/writer.test.ts
  14. 47 0
      packages/core/src/agents/settler-delta-parser.ts
  15. 58 56
      packages/core/src/agents/settler-prompts.ts
  16. 143 23
      packages/core/src/agents/writer.ts
  17. 32 0
      packages/core/src/index.ts
  18. 110 0
      packages/core/src/models/runtime-state.ts
  19. 7 13
      packages/core/src/pipeline/runner.ts
  20. 46 0
      packages/core/src/state/manager.ts
  21. 151 0
      packages/core/src/state/runtime-state-store.ts
  22. 443 0
      packages/core/src/state/state-bootstrap.ts
  23. 203 0
      packages/core/src/state/state-projections.ts
  24. 192 0
      packages/core/src/state/state-reducer.ts
  25. 107 0
      packages/core/src/state/state-validator.ts
  26. 77 15
      packages/core/src/utils/memory-retrieval.ts

+ 2 - 0
.gitignore

@@ -15,3 +15,5 @@ test-project/
 .worktrees/
 docs/
 novel-to-comic-research.docx
+.pnpm-store/
+tmp/

+ 83 - 1
packages/core/src/__tests__/composer.test.ts

@@ -1,4 +1,5 @@
 import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { createRequire } from "node:module";
 import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
@@ -6,6 +7,16 @@ import type { BookConfig } from "../models/book.js";
 import type { PlanChapterOutput } from "../agents/planner.js";
 import { ComposerAgent } from "../agents/composer.js";
 
+const require = createRequire(import.meta.url);
+const hasNodeSqlite = (() => {
+  try {
+    require("node:sqlite");
+    return true;
+  } catch {
+    return false;
+  }
+})();
+
 describe("ComposerAgent", () => {
   let root: string;
   let bookDir: string;
@@ -206,7 +217,78 @@ describe("ComposerAgent", () => {
     expect(selectedSources).toContain("story/pending_hooks.md#mentor-oath");
     expect(selectedSources).toContain("story/chapter_summaries.md#9");
     expect(selectedSources).not.toContain("story/pending_hooks.md");
-    await expect(stat(join(storyDir, "memory.db"))).resolves.toBeTruthy();
+    if (hasNodeSqlite) {
+      await expect(stat(join(storyDir, "memory.db"))).resolves.toBeTruthy();
+    }
+  });
+
+  it("surfaces stale unresolved hook evidence in governed context selection", async () => {
+    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: 25,
+        projectionVersion: 1,
+        migrationWarnings: [],
+      }, null, 2), "utf-8"),
+      writeFile(join(stateDir, "current_state.json"), JSON.stringify({
+        chapter: 25,
+        facts: [],
+      }, null, 2), "utf-8"),
+      writeFile(join(stateDir, "chapter_summaries.json"), JSON.stringify({
+        rows: [],
+      }, null, 2), "utf-8"),
+      writeFile(join(stateDir, "hooks.json"), JSON.stringify({
+        hooks: [
+          {
+            hookId: "recent-route",
+            startChapter: 22,
+            type: "route",
+            status: "open",
+            lastAdvancedChapter: 24,
+            expectedPayoff: "Recent route payoff",
+            notes: "Recent but not critical.",
+          },
+          {
+            hookId: "stale-debt",
+            startChapter: 3,
+            type: "relationship",
+            status: "open",
+            lastAdvancedChapter: 8,
+            expectedPayoff: "Mentor debt payoff",
+            notes: "Long-stale but still unresolved.",
+          },
+        ],
+      }, null, 2), "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: 26,
+      plan: {
+        ...plan,
+        intent: {
+          ...plan.intent,
+          chapter: 26,
+          goal: "Keep the chapter on the mainline debt conflict.",
+        },
+      },
+    });
+
+    const selectedSources = result.contextPackage.selectedContext.map((entry) => entry.source);
+    expect(selectedSources).toContain("story/pending_hooks.md#recent-route");
+    expect(selectedSources).toContain("story/pending_hooks.md#stale-debt");
   });
 
   it("adds current-state fact evidence retrieved from sqlite-backed memory", async () => {

+ 491 - 3
packages/core/src/__tests__/memory-retrieval.test.ts

@@ -1,11 +1,23 @@
 import { afterEach, describe, expect, it, vi } from "vitest";
-import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
+import { createRequire } from "node:module";
+import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
 import * as memoryRetrieval from "../utils/memory-retrieval.js";
 import { retrieveMemorySelection } from "../utils/memory-retrieval.js";
 import { MemoryDB } from "../state/memory-db.js";
 
+const require = createRequire(import.meta.url);
+const hasNodeSqlite = (() => {
+  try {
+    require("node:sqlite");
+    return true;
+  } catch {
+    return false;
+  }
+})();
+const sqliteIt = hasNodeSqlite ? it : it.skip;
+
 describe("retrieveMemorySelection", () => {
   let root = "";
 
@@ -63,7 +75,11 @@ describe("retrieveMemorySelection", () => {
         }),
       ]),
     );
-    expect(result.dbPath).toContain("memory.db");
+    if (hasNodeSqlite) {
+      expect(result.dbPath).toContain("memory.db");
+    } else {
+      expect(result.dbPath).toBeUndefined();
+    }
   });
 
   it("extracts mentor-focused query terms without pulling guild-route negatives into English retrieval", () => {
@@ -353,7 +369,7 @@ describe("retrieveMemorySelection", () => {
     expect(result.summaries.at(-1)?.chapter).toBe(50);
   });
 
-  it("uses existing sqlite summaries and hooks without requiring markdown truth files", async () => {
+  sqliteIt("uses existing sqlite summaries and hooks without requiring markdown truth files", async () => {
     root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-db-test-"));
     const bookDir = join(root, "book");
     const storyDir = join(bookDir, "story");
@@ -407,4 +423,476 @@ describe("retrieveMemorySelection", () => {
     expect(result.summaries.map((summary) => summary.chapter)).toContain(9);
     expect(result.hooks.map((hook) => hook.hookId)).toContain("mentor-debt");
   });
+
+  sqliteIt("backfills sqlite memory from structured state instead of stale markdown truth files", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-db-structured-test-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    const stateDir = join(storyDir, "state");
+    await mkdir(stateDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(
+        join(storyDir, "current_state.md"),
+        [
+          "| Field | Value |",
+          "| --- | --- |",
+          "| Current Chapter | 9 |",
+          "| Current Conflict | Old markdown conflict |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "pending_hooks.md"),
+        [
+          "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| markdown-hook | 1 | mystery | open | 9 | 12 | Old markdown hook |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+          "| --- | --- | --- | --- | --- | --- | --- | --- |",
+          "| 9 | Markdown Summary | Lin Yue | Old markdown events | Old markdown state | markdown-hook advanced | tense | fallback |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(stateDir, "manifest.json"),
+        JSON.stringify({
+          schemaVersion: 2,
+          language: "en",
+          lastAppliedChapter: 12,
+          projectionVersion: 1,
+          migrationWarnings: [],
+        }, null, 2),
+        "utf-8",
+      ),
+      writeFile(
+        join(stateDir, "current_state.json"),
+        JSON.stringify({
+          chapter: 12,
+          facts: [
+            {
+              subject: "protagonist",
+              predicate: "Current Conflict",
+              object: "Structured conflict should win.",
+              validFromChapter: 12,
+              validUntilChapter: null,
+              sourceChapter: 12,
+            },
+          ],
+        }, null, 2),
+        "utf-8",
+      ),
+      writeFile(
+        join(stateDir, "hooks.json"),
+        JSON.stringify({
+          hooks: [
+            {
+              hookId: "structured-hook",
+              startChapter: 10,
+              type: "relationship",
+              status: "progressing",
+              lastAdvancedChapter: 12,
+              expectedPayoff: "Structured payoff",
+              notes: "Structured hook should win.",
+            },
+          ],
+        }, null, 2),
+        "utf-8",
+      ),
+      writeFile(
+        join(stateDir, "chapter_summaries.json"),
+        JSON.stringify({
+          rows: [
+            {
+              chapter: 12,
+              title: "Structured Summary",
+              characters: "Lin Yue",
+              events: "Structured events should win.",
+              stateChanges: "Structured state should win.",
+              hookActivity: "structured-hook advanced",
+              mood: "tight",
+              chapterType: "mainline",
+            },
+          ],
+        }, null, 2),
+        "utf-8",
+      ),
+    ]);
+
+    const result = await retrieveMemorySelection({
+      bookDir,
+      chapterNumber: 13,
+      goal: "Bring the focus back to the structured hook.",
+      mustKeep: ["Structured conflict should win."],
+    });
+
+    expect(result.facts).toEqual(
+      expect.arrayContaining([
+        expect.objectContaining({
+          object: "Structured conflict should win.",
+          sourceChapter: 12,
+        }),
+      ]),
+    );
+    expect(result.hooks.map((hook) => hook.hookId)).toContain("structured-hook");
+    expect(result.hooks.map((hook) => hook.hookId)).not.toContain("markdown-hook");
+    expect(result.summaries.map((summary) => summary.chapter)).toContain(12);
+    expect(result.summaries.map((summary) => summary.title)).toContain("Structured Summary");
+  });
+
+  it("bootstraps structured runtime state from legacy markdown truth files during retrieval", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-bootstrap-test-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    const stateDir = join(storyDir, "state");
+    await mkdir(storyDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(
+        join(storyDir, "current_state.md"),
+        [
+          "| Field | Value |",
+          "| --- | --- |",
+          "| Current Chapter | 12 |",
+          "| Current Conflict | Mentor debt mainline vs guild safe route |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "pending_hooks.md"),
+        [
+          "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| mentor-debt | 1 | relationship | open | 12 | 16 | The mentor debt remains unresolved |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+          "| --- | --- | --- | --- | --- | --- | --- | --- |",
+          "| 12 | Mentor Debt Echo | Lin Yue | Lin Yue returns to the mentor debt trail | Commitment hardens | mentor-debt advanced | tense | mainline |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+    ]);
+
+    const result = await retrieveMemorySelection({
+      bookDir,
+      chapterNumber: 13,
+      goal: "Pull focus back to the mentor debt.",
+      mustKeep: ["Lin Yue does not abandon the mentor debt."],
+    });
+
+    const manifest = JSON.parse(await readFile(join(stateDir, "manifest.json"), "utf-8"));
+    const currentState = JSON.parse(await readFile(join(stateDir, "current_state.json"), "utf-8"));
+    const hooks = JSON.parse(await readFile(join(stateDir, "hooks.json"), "utf-8"));
+    const summaries = JSON.parse(await readFile(join(stateDir, "chapter_summaries.json"), "utf-8"));
+
+    expect(manifest.schemaVersion).toBe(2);
+    expect(currentState.chapter).toBe(12);
+    expect(hooks.hooks[0]?.hookId).toBe("mentor-debt");
+    expect(summaries.rows[0]?.title).toBe("Mentor Debt Echo");
+    expect(result.hooks.map((hook) => hook.hookId)).toContain("mentor-debt");
+  });
+
+  it("prefers structured state files over legacy markdown truth files when both exist", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-structured-preferred-test-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    const stateDir = join(storyDir, "state");
+    await mkdir(stateDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(
+        join(storyDir, "current_state.md"),
+        [
+          "| Field | Value |",
+          "| --- | --- |",
+          "| Current Chapter | 9 |",
+          "| Current Conflict | Old markdown conflict |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "pending_hooks.md"),
+        [
+          "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| markdown-hook | 1 | mystery | open | 9 | 12 | Old markdown hook |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+          "| --- | --- | --- | --- | --- | --- | --- | --- |",
+          "| 9 | Markdown Summary | Lin Yue | Old markdown event | Old markdown state | markdown-hook advanced | tense | fallback |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(join(stateDir, "manifest.json"), JSON.stringify({
+        schemaVersion: 2,
+        language: "en",
+        lastAppliedChapter: 12,
+        projectionVersion: 1,
+        migrationWarnings: [],
+      }, null, 2), "utf-8"),
+      writeFile(join(stateDir, "current_state.json"), JSON.stringify({
+        chapter: 12,
+        facts: [
+          {
+            subject: "protagonist",
+            predicate: "Current Conflict",
+            object: "Structured conflict should win.",
+            validFromChapter: 12,
+            validUntilChapter: null,
+            sourceChapter: 12,
+          },
+        ],
+      }, null, 2), "utf-8"),
+      writeFile(join(stateDir, "hooks.json"), JSON.stringify({
+        hooks: [
+          {
+            hookId: "structured-hook",
+            startChapter: 10,
+            type: "relationship",
+            status: "progressing",
+            lastAdvancedChapter: 12,
+            expectedPayoff: "Structured payoff",
+            notes: "Structured hook should win.",
+          },
+        ],
+      }, null, 2), "utf-8"),
+      writeFile(join(stateDir, "chapter_summaries.json"), JSON.stringify({
+        rows: [
+          {
+            chapter: 12,
+            title: "Structured Summary",
+            characters: "Lin Yue",
+            events: "Structured events should win.",
+            stateChanges: "Structured state should win.",
+            hookActivity: "structured-hook advanced",
+            mood: "tight",
+            chapterType: "mainline",
+          },
+        ],
+      }, null, 2), "utf-8"),
+    ]);
+
+    const result = await retrieveMemorySelection({
+      bookDir,
+      chapterNumber: 13,
+      goal: "Bring the focus back to the structured hook.",
+      mustKeep: ["Structured conflict should win."],
+    });
+
+    expect(result.facts).toEqual(
+      expect.arrayContaining([
+        expect.objectContaining({
+          object: "Structured conflict should win.",
+          sourceChapter: 12,
+        }),
+      ]),
+    );
+    expect(result.hooks.map((hook) => hook.hookId)).toContain("structured-hook");
+    expect(result.hooks.map((hook) => hook.hookId)).not.toContain("markdown-hook");
+    expect(result.summaries.map((summary) => summary.chapter)).toContain(12);
+    expect(result.summaries.map((summary) => summary.title)).toContain("Structured Summary");
+  });
+
+  it("recalls stale open hooks alongside recent governed memory selections", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-stale-hook-test-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    const stateDir = join(storyDir, "state");
+    await mkdir(stateDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(
+        join(stateDir, "manifest.json"),
+        JSON.stringify({
+          schemaVersion: 2,
+          language: "en",
+          lastAppliedChapter: 25,
+          projectionVersion: 1,
+          migrationWarnings: [],
+        }, null, 2),
+        "utf-8",
+      ),
+      writeFile(
+        join(stateDir, "current_state.json"),
+        JSON.stringify({
+          chapter: 25,
+          facts: [],
+        }, null, 2),
+        "utf-8",
+      ),
+      writeFile(
+        join(stateDir, "chapter_summaries.json"),
+        JSON.stringify({
+          rows: [],
+        }, null, 2),
+        "utf-8",
+      ),
+      writeFile(
+        join(stateDir, "hooks.json"),
+        JSON.stringify({
+          hooks: [
+            {
+              hookId: "recent-route",
+              startChapter: 22,
+              type: "route",
+              status: "open",
+              lastAdvancedChapter: 24,
+              expectedPayoff: "Recent route payoff",
+              notes: "Recent but not critical.",
+            },
+            {
+              hookId: "stale-debt",
+              startChapter: 3,
+              type: "relationship",
+              status: "open",
+              lastAdvancedChapter: 8,
+              expectedPayoff: "Mentor debt payoff",
+              notes: "Long-stale but still unresolved.",
+            },
+          ],
+        }, null, 2),
+        "utf-8",
+      ),
+    ]);
+
+    const result = await retrieveMemorySelection({
+      bookDir,
+      chapterNumber: 26,
+      goal: "Keep the chapter on the mainline debt conflict.",
+      mustKeep: ["The mentor debt is still unresolved."],
+    });
+
+    expect(result.hooks.map((hook) => hook.hookId)).toContain("recent-route");
+    expect(result.hooks.map((hook) => hook.hookId)).toContain("stale-debt");
+  });
+
+  it("surfaces one stale unresolved hook beyond the primary quota while excluding stale resolved hooks", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-stale-quota-test-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    const stateDir = join(storyDir, "state");
+    await mkdir(stateDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(
+        join(stateDir, "manifest.json"),
+        JSON.stringify({
+          schemaVersion: 2,
+          language: "en",
+          lastAppliedChapter: 40,
+          projectionVersion: 1,
+          migrationWarnings: [],
+        }, null, 2),
+        "utf-8",
+      ),
+      writeFile(
+        join(stateDir, "current_state.json"),
+        JSON.stringify({
+          chapter: 40,
+          facts: [],
+        }, null, 2),
+        "utf-8",
+      ),
+      writeFile(
+        join(stateDir, "chapter_summaries.json"),
+        JSON.stringify({
+          rows: [],
+        }, null, 2),
+        "utf-8",
+      ),
+      writeFile(
+        join(stateDir, "hooks.json"),
+        JSON.stringify({
+          hooks: [
+            {
+              hookId: "recent-route",
+              startChapter: 37,
+              type: "route",
+              status: "open",
+              lastAdvancedChapter: 39,
+              expectedPayoff: "Recent route payoff",
+              notes: "Recent route remains active.",
+            },
+            {
+              hookId: "recent-guild",
+              startChapter: 36,
+              type: "politics",
+              status: "progressing",
+              lastAdvancedChapter: 38,
+              expectedPayoff: "Guild payoff",
+              notes: "Recent guild pressure remains active.",
+            },
+            {
+              hookId: "recent-token",
+              startChapter: 35,
+              type: "artifact",
+              status: "open",
+              lastAdvancedChapter: 37,
+              expectedPayoff: "Token payoff",
+              notes: "Recent token route remains active.",
+            },
+            {
+              hookId: "stale-omega",
+              startChapter: 3,
+              type: "relationship",
+              status: "open",
+              lastAdvancedChapter: 8,
+              expectedPayoff: "Old relic payoff",
+              notes: "Dormant unresolved line.",
+            },
+            {
+              hookId: "stale-resolved",
+              startChapter: 2,
+              type: "mystery",
+              status: "resolved",
+              lastAdvancedChapter: 7,
+              expectedPayoff: "Already closed",
+              notes: "Should not be resurfaced.",
+            },
+          ],
+        }, null, 2),
+        "utf-8",
+      ),
+    ]);
+
+    const result = await retrieveMemorySelection({
+      bookDir,
+      chapterNumber: 41,
+      goal: "Keep the chapter on the harbor confrontation.",
+      mustKeep: ["The harbor confrontation must stay central."],
+    });
+
+    expect(result.hooks.map((hook) => hook.hookId)).toEqual([
+      "recent-route",
+      "recent-guild",
+      "recent-token",
+      "stale-omega",
+    ]);
+    expect(result.hooks.map((hook) => hook.hookId)).not.toContain("stale-resolved");
+  });
 });

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

@@ -23,6 +23,13 @@ import {
   LengthTelemetrySchema,
   LengthWarningSchema,
 } from "../models/length-governance.js";
+import {
+  RuntimeStateDeltaSchema,
+  StateManifestSchema,
+  HooksStateSchema,
+  ChapterSummariesStateSchema,
+  CurrentStateStateSchema,
+} from "../models/runtime-state.js";
 
 // ---------------------------------------------------------------------------
 // BookConfig
@@ -703,3 +710,126 @@ describe("Length governance schemas", () => {
     expect(result.actual).toBe(3100);
   });
 });
+
+describe("Runtime state schemas", () => {
+  it("accepts a valid state manifest", () => {
+    const result = StateManifestSchema.parse({
+      schemaVersion: 2,
+      language: "en",
+      lastAppliedChapter: 12,
+      projectionVersion: 1,
+      migrationWarnings: [],
+    });
+
+    expect(result.language).toBe("en");
+    expect(result.lastAppliedChapter).toBe(12);
+  });
+
+  it("accepts hook, summary, and current-state payloads", () => {
+    const hooks = HooksStateSchema.parse({
+      hooks: [
+        {
+          hookId: "mentor-debt",
+          startChapter: 1,
+          type: "relationship",
+          status: "open",
+          lastAdvancedChapter: 12,
+          expectedPayoff: "Reveal the debt",
+          notes: "Still unresolved",
+        },
+      ],
+    });
+    const summaries = ChapterSummariesStateSchema.parse({
+      rows: [
+        {
+          chapter: 12,
+          title: "River Ledger",
+          characters: "Lin Yue",
+          events: "Lin Yue checks the old ledger",
+          stateChanges: "Debt sharpens",
+          hookActivity: "mentor-debt advanced",
+          mood: "tense",
+          chapterType: "mainline",
+        },
+      ],
+    });
+    const currentState = CurrentStateStateSchema.parse({
+      chapter: 12,
+      facts: [
+        {
+          subject: "protagonist",
+          predicate: "Current Goal",
+          object: "Trace the mentor debt",
+          validFromChapter: 12,
+          validUntilChapter: null,
+          sourceChapter: 12,
+        },
+      ],
+    });
+
+    expect(hooks.hooks[0]?.hookId).toBe("mentor-debt");
+    expect(summaries.rows[0]?.title).toBe("River Ledger");
+    expect(currentState.chapter).toBe(12);
+  });
+
+  it("accepts a valid runtime-state delta", () => {
+    const result = RuntimeStateDeltaSchema.parse({
+      chapter: 12,
+      currentStatePatch: {
+        currentGoal: "Trace the mentor debt",
+        currentConflict: "Guild pressure keeps colliding with the debt trail",
+      },
+      hookOps: {
+        upsert: [
+          {
+            hookId: "mentor-debt",
+            startChapter: 1,
+            type: "relationship",
+            status: "progressing",
+            lastAdvancedChapter: 12,
+            expectedPayoff: "Reveal the debt",
+            notes: "Ledger clue sharpens the line",
+          },
+        ],
+        resolve: [],
+        defer: [],
+      },
+      chapterSummary: {
+        chapter: 12,
+        title: "River Ledger",
+        characters: "Lin Yue",
+        events: "Lin Yue checks the old ledger",
+        stateChanges: "Debt sharpens",
+        hookActivity: "mentor-debt advanced",
+        mood: "tense",
+        chapterType: "mainline",
+      },
+      notes: [],
+    });
+
+    expect(result.chapter).toBe(12);
+    expect(result.hookOps.upsert[0]?.hookId).toBe("mentor-debt");
+  });
+
+  it("rejects natural-language numeric drift in runtime-state delta hooks", () => {
+    expect(() =>
+      RuntimeStateDeltaSchema.parse({
+        chapter: 12,
+        hookOps: {
+          upsert: [
+            {
+              hookId: "mentor-debt",
+              startChapter: 1,
+              type: "relationship",
+              status: "open",
+              lastAdvancedChapter: "chapter twelve",
+            },
+          ],
+          resolve: [],
+          defer: [],
+        },
+        notes: [],
+      }),
+    ).toThrow();
+  });
+});

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

@@ -0,0 +1,309 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import type { BookConfig } from "../models/book.js";
+
+const ZERO_USAGE = {
+  promptTokens: 0,
+  completionTokens: 0,
+  totalTokens: 0,
+} as const;
+
+function createStateCard(params: {
+  readonly chapter: number;
+  readonly location: string;
+  readonly protagonistState: string;
+  readonly goal: string;
+  readonly conflict: string;
+}): string {
+  return [
+    "# Current State",
+    "",
+    "| Field | Value |",
+    "| --- | --- |",
+    `| Current Chapter | ${params.chapter} |`,
+    `| Current Location | ${params.location} |`,
+    `| Protagonist State | ${params.protagonistState} |`,
+    `| Current Goal | ${params.goal} |`,
+    "| Current Constraint | The city gates are watched. |",
+    "| Current Alliances | Mentor allies are scattered. |",
+    `| Current Conflict | ${params.conflict} |`,
+    "",
+  ].join("\n");
+}
+
+interface FakeStore {
+  facts: Array<{
+    id: number;
+    subject: string;
+    predicate: string;
+    object: string;
+    validFromChapter: number;
+    validUntilChapter: number | null;
+    sourceChapter: number;
+  }>;
+  summaries: Array<{
+    chapter: number;
+    title: string;
+    characters: string;
+    events: string;
+    stateChanges: string;
+    hookActivity: string;
+    mood: string;
+    chapterType: string;
+  }>;
+  hooks: Array<{
+    hookId: string;
+    startChapter: number;
+    type: string;
+    status: string;
+    lastAdvancedChapter: number;
+    expectedPayoff: string;
+    notes: string;
+  }>;
+  nextFactId: number;
+}
+
+class FakeMemoryDB {
+  static stores = new Map<string, FakeStore>();
+
+  private readonly store: FakeStore;
+
+  constructor(private readonly bookDir: string) {
+    const existing = FakeMemoryDB.stores.get(bookDir);
+    if (existing) {
+      this.store = existing;
+      return;
+    }
+
+    const created: FakeStore = {
+      facts: [],
+      summaries: [],
+      hooks: [],
+      nextFactId: 1,
+    };
+    FakeMemoryDB.stores.set(bookDir, created);
+    this.store = created;
+  }
+
+  close(): void {}
+
+  replaceSummaries(summaries: FakeStore["summaries"]): void {
+    this.store.summaries = summaries.map((summary) => ({ ...summary }));
+  }
+
+  replaceHooks(hooks: FakeStore["hooks"]): void {
+    this.store.hooks = hooks.map((hook) => ({ ...hook }));
+  }
+
+  resetFacts(): void {
+    this.store.facts = [];
+    this.store.nextFactId = 1;
+  }
+
+  addFact(fact: Omit<FakeStore["facts"][number], "id">): number {
+    const id = this.store.nextFactId++;
+    this.store.facts.push({ id, ...fact });
+    return id;
+  }
+
+  invalidateFact(id: number, untilChapter: number): void {
+    const index = this.store.facts.findIndex((fact) => fact.id === id);
+    if (index >= 0) {
+      this.store.facts[index] = {
+        ...this.store.facts[index]!,
+        validUntilChapter: untilChapter,
+      };
+    }
+  }
+}
+
+describe("PipelineRunner structured-state memory sync", () => {
+  let root = "";
+
+  afterEach(async () => {
+    vi.restoreAllMocks();
+    vi.resetModules();
+    vi.doUnmock("../state/memory-db.js");
+    FakeMemoryDB.stores.clear();
+    if (root) {
+      await rm(root, { recursive: true, force: true });
+      root = "";
+    }
+  });
+
+  it("uses structured runtime state for narrative memory during writeNextChapter even when markdown projections drift after persistence", async () => {
+    vi.doMock("../state/memory-db.js", () => ({
+      MemoryDB: FakeMemoryDB,
+    }));
+
+    const { PipelineRunner } = await import("../pipeline/runner.js");
+    const { StateManager } = await import("../state/manager.js");
+    const { WriterAgent } = await import("../agents/writer.js");
+    const { ContinuityAuditor } = await import("../agents/continuity.js");
+
+    root = await mkdtemp(join(tmpdir(), "inkos-runner-memory-sync-"));
+    const state = new StateManager(root);
+    const bookId = "memory-sync-book";
+    const now = "2026-03-25T00:00:00.000Z";
+    const book: BookConfig = {
+      id: bookId,
+      title: "Memory Sync Book",
+      platform: "tomato",
+      genre: "xuanhuan",
+      status: "active",
+      language: "en",
+      targetChapters: 10,
+      chapterWordCount: 10,
+      createdAt: now,
+      updatedAt: now,
+    };
+
+    await state.saveBookConfig(bookId, book);
+    const bookDir = state.bookDir(bookId);
+    const storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+    await mkdir(join(bookDir, "chapters"), { recursive: true });
+    await Promise.all([
+      writeFile(join(storyDir, "current_state.md"), createStateCard({
+        chapter: 0,
+        location: "Shrine outskirts",
+        protagonistState: "Lin Yue begins with the oath token hidden.",
+        goal: "Reach the trial city.",
+        conflict: "The trial deadline is closing in.",
+      }), "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+      writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
+    ]);
+
+    const runner = new PipelineRunner({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0,
+          maxTokensCap: null,
+        },
+      } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      inputGovernanceMode: "legacy",
+    });
+
+    const originalSaveChapter = WriterAgent.prototype.saveChapter;
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue({
+      chapterNumber: 1,
+      title: "Structured Chapter",
+      content: "Lin Yue follows the debt into the watchtower archive.",
+      wordCount: 9,
+      preWriteCheck: "check",
+      postSettlement: "settled",
+      updatedState: "unused legacy state",
+      updatedLedger: "unused legacy ledger",
+      updatedHooks: "unused legacy hooks",
+      chapterSummary: "| 1 | unused summary |",
+      updatedSubplots: "",
+      updatedEmotionalArcs: "",
+      updatedCharacterMatrix: "",
+      postWriteErrors: [],
+      postWriteWarnings: [],
+      tokenUsage: ZERO_USAGE,
+      runtimeStateDelta: {
+        chapter: 1,
+        currentStatePatch: {
+          currentGoal: "Trace the debt through the watchtower archive.",
+          currentConflict: "Guild pressure keeps colliding with the debt trail.",
+        },
+        hookOps: {
+          upsert: [
+            {
+              hookId: "structured-hook",
+              startChapter: 1,
+              type: "relationship",
+              status: "open",
+              lastAdvancedChapter: 1,
+              expectedPayoff: "Reveal why the mentor vanished.",
+              notes: "Structured hook should win.",
+            },
+          ],
+          resolve: [],
+          defer: [],
+        },
+        chapterSummary: {
+          chapter: 1,
+          title: "Structured Summary",
+          characters: "Lin Yue",
+          events: "Lin Yue follows the debt into the watchtower archive.",
+          stateChanges: "The debt trail sharpens.",
+          hookActivity: "structured-hook advanced",
+          mood: "tense",
+          chapterType: "investigation",
+        },
+        subplotOps: [],
+        emotionalArcOps: [],
+        characterMatrixOps: [],
+        notes: [],
+      },
+    });
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue({
+      passed: true,
+      issues: [],
+      summary: "clean",
+      tokenUsage: ZERO_USAGE,
+    });
+    vi.spyOn(WriterAgent.prototype, "saveChapter").mockImplementation(async function (
+      this: InstanceType<typeof WriterAgent>,
+      bookDirArg,
+      output,
+      numericalSystem,
+      language,
+    ) {
+      await originalSaveChapter.call(this, bookDirArg, output, numericalSystem, language);
+      await Promise.all([
+        writeFile(
+          join(bookDirArg, "story", "pending_hooks.md"),
+          [
+            "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
+            "| --- | --- | --- | --- | --- | --- | --- |",
+            "| markdown-drift-hook | 1 | mystery | open | 1 | 5 | Drifted markdown hook |",
+            "",
+          ].join("\n"),
+          "utf-8",
+        ),
+        writeFile(
+          join(bookDirArg, "story", "chapter_summaries.md"),
+          [
+            "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+            "| --- | --- | --- | --- | --- | --- | --- | --- |",
+            "| 1 | Markdown Drift Summary | Lin Yue | Drifted markdown event | Drifted markdown state | markdown-drift-hook advanced | flat | fallback |",
+            "",
+          ].join("\n"),
+          "utf-8",
+        ),
+      ]);
+    });
+
+    await runner.writeNextChapter(bookId);
+
+    const narrativeStore = FakeMemoryDB.stores.get(bookDir);
+    expect(await readFile(join(storyDir, "pending_hooks.md"), "utf-8")).toContain("markdown-drift-hook");
+    expect(await readFile(join(storyDir, "chapter_summaries.md"), "utf-8")).toContain("Markdown Drift Summary");
+    expect(narrativeStore?.hooks).toEqual([
+      expect.objectContaining({
+        hookId: "structured-hook",
+        notes: "Structured hook should win.",
+      }),
+    ]);
+    expect(narrativeStore?.summaries).toEqual([
+      expect.objectContaining({
+        chapter: 1,
+        title: "Structured Summary",
+        events: "Lin Yue follows the debt into the watchtower archive.",
+      }),
+    ]);
+  });
+});

+ 422 - 5
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -1,4 +1,5 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { createRequire } from "node:module";
 import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
@@ -18,6 +19,18 @@ import { MemoryDB } from "../state/memory-db.js";
 import * as memoryDbModule from "../state/memory-db.js";
 import { countChapterLength } from "../utils/length-metrics.js";
 
+const require = createRequire(import.meta.url);
+const hasNodeSqlite = (() => {
+  try {
+    require("node:sqlite");
+    return true;
+  } catch {
+    return false;
+  }
+})();
+
+const sqliteIt = hasNodeSqlite ? it : it.skip;
+
 const ZERO_USAGE = {
   promptTokens: 0,
   completionTokens: 0,
@@ -461,7 +474,7 @@ describe("PipelineRunner", () => {
     }
   });
 
-  it("syncs current-state facts into memory.db after drafting a chapter", async () => {
+  sqliteIt("syncs current-state facts into memory.db after drafting a chapter", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const chapterOneState = createStateCard({
       chapter: 1,
@@ -539,6 +552,101 @@ describe("PipelineRunner", () => {
     }
   });
 
+  sqliteIt("syncs narrative memory from structured runtime state instead of stale markdown projections", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const storyDir = join(state.bookDir(bookId), "story");
+    const stateDir = join(storyDir, "state");
+    await mkdir(stateDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+          "| --- | --- | --- | --- | --- | --- | --- | --- |",
+          "| 1 | Markdown Summary | Lin Yue | Old markdown event | Old markdown state | markdown-hook advanced | tense | fallback |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "pending_hooks.md"),
+        [
+          "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| markdown-hook | 1 | mystery | open | 1 | 4 | Old markdown hook |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(join(stateDir, "manifest.json"), JSON.stringify({
+        schemaVersion: 2,
+        language: "en",
+        lastAppliedChapter: 3,
+        projectionVersion: 1,
+        migrationWarnings: [],
+      }, null, 2), "utf-8"),
+      writeFile(join(stateDir, "current_state.json"), JSON.stringify({
+        chapter: 3,
+        facts: [],
+      }, null, 2), "utf-8"),
+      writeFile(join(stateDir, "hooks.json"), JSON.stringify({
+        hooks: [
+          {
+            hookId: "structured-hook",
+            startChapter: 2,
+            type: "relationship",
+            status: "progressing",
+            lastAdvancedChapter: 3,
+            expectedPayoff: "Reveal the mentor ledger.",
+            notes: "Structured hook should win.",
+          },
+        ],
+      }, null, 2), "utf-8"),
+      writeFile(join(stateDir, "chapter_summaries.json"), JSON.stringify({
+        rows: [
+          {
+            chapter: 3,
+            title: "Structured Summary",
+            characters: "Lin Yue",
+            events: "Structured runtime state event.",
+            stateChanges: "Structured runtime state shift.",
+            hookActivity: "structured-hook advanced",
+            mood: "grim",
+            chapterType: "mainline",
+          },
+        ],
+      }, null, 2), "utf-8"),
+    ]);
+
+    try {
+      await (runner as unknown as {
+        syncNarrativeMemoryIndex: (targetBookId: string) => Promise<void>;
+      }).syncNarrativeMemoryIndex(bookId);
+
+      const memoryDb = new MemoryDB(state.bookDir(bookId));
+      try {
+        expect(memoryDb.getSummaries(1, 10)).toEqual([
+          expect.objectContaining({
+            chapter: 3,
+            title: "Structured Summary",
+            events: "Structured runtime state event.",
+          }),
+        ]);
+        expect(memoryDb.getActiveHooks()).toEqual([
+          expect.objectContaining({
+            hookId: "structured-hook",
+            status: "progressing",
+          }),
+        ]);
+      } finally {
+        memoryDb.close();
+      }
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("uses a friendly fallback warning when sqlite memory indexing is unavailable", async () => {
     const { logger, warnings } = createCaptureLogger();
     const { root, runner, state, bookId } = await createRunnerFixture({
@@ -631,7 +739,7 @@ describe("PipelineRunner", () => {
     }
   });
 
-  it("recovers when sqlite-unavailable signature is transient and probe succeeds", async () => {
+  sqliteIt("recovers when sqlite-unavailable signature is transient and probe succeeds", async () => {
     const { logger, warnings } = createCaptureLogger();
     const { root, runner, state, bookId } = await createRunnerFixture({
       logger,
@@ -707,7 +815,7 @@ describe("PipelineRunner", () => {
     }
   });
 
-  it("retries transient sqlite busy errors during narrative memory sync", async () => {
+  sqliteIt("retries transient sqlite busy errors during narrative memory sync", async () => {
     const { logger, warnings } = createCaptureLogger();
     const { root, runner, state, bookId } = await createRunnerFixture({
       logger,
@@ -1579,6 +1687,234 @@ describe("PipelineRunner", () => {
     await rm(root, { recursive: true, force: true });
   });
 
+  it("persists structured runtime state and rendered projections from writer delta output", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "legacy",
+    });
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        content: "Lin Yue follows the debt into the river-port ledger.",
+        wordCount: countChapterLength("Lin Yue follows the debt into the river-port ledger.", "en_words"),
+        postWriteErrors: [],
+        postWriteWarnings: [],
+        runtimeStateDelta: {
+          chapter: 1,
+          currentStatePatch: {
+            currentGoal: "Follow the debt through the river-port ledger.",
+            currentConflict: "Guild pressure keeps pulling against the debt trail.",
+          },
+          hookOps: {
+            upsert: [
+              {
+                hookId: "mentor-debt",
+                startChapter: 1,
+                type: "relationship",
+                status: "open",
+                lastAdvancedChapter: 1,
+                expectedPayoff: "Reveal why the mentor vanished.",
+                notes: "The river-port ledger sharpens the debt line.",
+              },
+            ],
+            resolve: [],
+            defer: [],
+          },
+          chapterSummary: {
+            chapter: 1,
+            title: "River Ledger",
+            characters: "Lin Yue",
+            events: "Lin Yue follows the debt into the river-port ledger.",
+            stateChanges: "The debt line sharpens.",
+            hookActivity: "mentor-debt advanced",
+            mood: "tense",
+            chapterType: "investigation",
+          },
+          notes: [],
+        },
+      }),
+    );
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
+      createAuditResult({
+        passed: true,
+        issues: [],
+        summary: "clean",
+      }),
+    );
+
+    await runner.writeNextChapter(bookId);
+
+    const storyDir = join(state.bookDir(bookId), "story");
+    const currentState = await readFile(join(storyDir, "current_state.md"), "utf-8");
+    const hooks = await readFile(join(storyDir, "pending_hooks.md"), "utf-8");
+    const summaries = await readFile(join(storyDir, "chapter_summaries.md"), "utf-8");
+    const manifest = JSON.parse(await readFile(join(storyDir, "state", "manifest.json"), "utf-8"));
+    const stateCurrent = JSON.parse(await readFile(join(storyDir, "state", "current_state.json"), "utf-8"));
+    const stateHooks = JSON.parse(await readFile(join(storyDir, "state", "hooks.json"), "utf-8"));
+    const stateSummaries = JSON.parse(await readFile(join(storyDir, "state", "chapter_summaries.json"), "utf-8"));
+
+    expect(currentState).toContain("Follow the debt through the river-port ledger.");
+    expect(hooks).toContain("mentor-debt");
+    expect(summaries).toContain("River Ledger");
+    expect(manifest.lastAppliedChapter).toBe(1);
+    expect(stateCurrent.chapter).toBe(1);
+    expect(stateHooks.hooks[0]?.hookId).toBe("mentor-debt");
+    expect(stateSummaries.rows[0]?.title).toBe("River Ledger");
+
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("does not corrupt persisted runtime state when writer delta is invalid", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "legacy",
+    });
+    const storyDir = join(state.bookDir(bookId), "story");
+    await mkdir(join(storyDir, "state"), { recursive: true });
+    await Promise.all([
+      writeFile(join(storyDir, "current_state.md"), createStateCard({
+        chapter: 0,
+        location: "Ashen ferry crossing",
+        protagonistState: "Lin Yue still hides the oath token.",
+        goal: "Find the vanished mentor.",
+        conflict: "The mentor debt is still personal.",
+      }), "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+      writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
+      writeFile(join(storyDir, "state", "manifest.json"), JSON.stringify({
+        schemaVersion: 2,
+        language: "en",
+        lastAppliedChapter: 0,
+        projectionVersion: 1,
+        migrationWarnings: [],
+      }, null, 2), "utf-8"),
+      writeFile(join(storyDir, "state", "current_state.json"), JSON.stringify({
+        chapter: 0,
+        facts: [],
+      }, null, 2), "utf-8"),
+      writeFile(join(storyDir, "state", "hooks.json"), JSON.stringify({
+        hooks: [],
+      }, null, 2), "utf-8"),
+      writeFile(join(storyDir, "state", "chapter_summaries.json"), JSON.stringify({
+        rows: [],
+      }, null, 2), "utf-8"),
+    ]);
+
+    const beforeState = await readFile(join(storyDir, "current_state.md"), "utf-8");
+    const beforeManifest = await readFile(join(storyDir, "state", "manifest.json"), "utf-8");
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        content: "Broken chapter body.",
+        wordCount: countChapterLength("Broken chapter body.", "en_words"),
+        postWriteErrors: [],
+        postWriteWarnings: [],
+        runtimeStateDelta: {
+          chapter: 0,
+          hookOps: {
+            upsert: [],
+            resolve: [],
+            defer: [],
+          },
+          notes: [],
+        } as unknown as NonNullable<ReturnType<typeof createWriterOutput>["runtimeStateDelta"]>,
+      }),
+    );
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
+      createAuditResult({
+        passed: true,
+        issues: [],
+        summary: "clean",
+      }),
+    );
+
+    await expect(runner.writeNextChapter(bookId)).rejects.toThrow();
+
+    await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe(beforeState);
+    await expect(readFile(join(storyDir, "state", "manifest.json"), "utf-8")).resolves.toBe(beforeManifest);
+
+    await rm(root, { recursive: true, force: true });
+  });
+
+  it("rolls back persisted runtime state when writer delta contains natural-language numeric drift", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "legacy",
+    });
+    const storyDir = join(state.bookDir(bookId), "story");
+    await mkdir(join(storyDir, "state"), { recursive: true });
+    await Promise.all([
+      writeFile(join(storyDir, "current_state.md"), createStateCard({
+        chapter: 0,
+        location: "Ashen ferry crossing",
+        protagonistState: "Lin Yue still hides the oath token.",
+        goal: "Find the vanished mentor.",
+        conflict: "The mentor debt is still personal.",
+      }), "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
+      writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
+      writeFile(join(storyDir, "state", "manifest.json"), JSON.stringify({
+        schemaVersion: 2,
+        language: "en",
+        lastAppliedChapter: 0,
+        projectionVersion: 1,
+        migrationWarnings: [],
+      }, null, 2), "utf-8"),
+      writeFile(join(storyDir, "state", "current_state.json"), JSON.stringify({
+        chapter: 0,
+        facts: [],
+      }, null, 2), "utf-8"),
+      writeFile(join(storyDir, "state", "hooks.json"), JSON.stringify({
+        hooks: [],
+      }, null, 2), "utf-8"),
+      writeFile(join(storyDir, "state", "chapter_summaries.json"), JSON.stringify({
+        rows: [],
+      }, null, 2), "utf-8"),
+    ]);
+
+    const beforeState = await readFile(join(storyDir, "current_state.md"), "utf-8");
+    const beforeManifest = await readFile(join(storyDir, "state", "manifest.json"), "utf-8");
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        content: "Broken chapter body.",
+        wordCount: countChapterLength("Broken chapter body.", "en_words"),
+        postWriteErrors: [],
+        postWriteWarnings: [],
+        runtimeStateDelta: {
+          chapter: 1,
+          hookOps: {
+            upsert: [
+              {
+                hookId: "mentor-debt",
+                startChapter: 1,
+                type: "relationship",
+                status: "open",
+                lastAdvancedChapter: "chapter one",
+                expectedPayoff: "Reveal the debt.",
+                notes: "Bad numeric drift.",
+              },
+            ],
+            resolve: [],
+            defer: [],
+          },
+          notes: [],
+        } as unknown as NonNullable<ReturnType<typeof createWriterOutput>["runtimeStateDelta"]>,
+      }),
+    );
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
+      createAuditResult({
+        passed: true,
+        issues: [],
+        summary: "clean",
+      }),
+    );
+
+    await expect(runner.writeNextChapter(bookId)).rejects.toThrow();
+
+    await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe(beforeState);
+    await expect(readFile(join(storyDir, "state", "manifest.json"), "utf-8")).resolves.toBe(beforeManifest);
+
+    await rm(root, { recursive: true, force: true });
+  });
+
   it("reports only resumed chapters in import results", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const now = "2026-03-19T00:00:00.000Z";
@@ -1635,7 +1971,7 @@ describe("PipelineRunner", () => {
     await rm(root, { recursive: true, force: true });
   });
 
-  it("rebuilds fact history from imported chapter snapshots", async () => {
+  sqliteIt("rebuilds fact history from imported chapter snapshots", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
 
     vi.spyOn(ArchitectAgent.prototype, "generateFoundationFromImport").mockResolvedValue({
@@ -1722,6 +2058,87 @@ describe("PipelineRunner", () => {
     }
   });
 
+  sqliteIt("rebuilds fact history from structured snapshot state instead of stale markdown snapshots", async () => {
+    const { root, runner, state, bookId } = await createRunnerFixture();
+    const storyDir = join(state.bookDir(bookId), "story");
+    const snapshotOneDir = join(storyDir, "snapshots", "1");
+    const snapshotOneStateDir = join(snapshotOneDir, "state");
+    await mkdir(snapshotOneStateDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(
+        join(snapshotOneDir, "current_state.md"),
+        createStateCard({
+          chapter: 1,
+          location: "Old markdown ferry crossing",
+          protagonistState: "Markdown state still hides the oath token.",
+          goal: "Follow the markdown trail.",
+          conflict: "Old markdown conflict.",
+        }),
+        "utf-8",
+      ),
+      writeFile(join(snapshotOneStateDir, "current_state.json"), JSON.stringify({
+        chapter: 1,
+        facts: [
+          {
+            subject: "current",
+            predicate: "Current Location",
+            object: "Structured watchtower",
+            validFromChapter: 1,
+            validUntilChapter: null,
+            sourceChapter: 1,
+          },
+          {
+            subject: "protagonist",
+            predicate: "Current Conflict",
+            object: "Structured conflict replaces markdown drift.",
+            validFromChapter: 1,
+            validUntilChapter: null,
+            sourceChapter: 1,
+          },
+        ],
+      }, null, 2), "utf-8"),
+    ]);
+
+    try {
+      await (runner as unknown as {
+        syncCurrentStateFactHistory: (targetBookId: string, uptoChapter: number) => Promise<void>;
+      }).syncCurrentStateFactHistory(bookId, 1);
+
+      const memoryDb = new MemoryDB(state.bookDir(bookId));
+      try {
+        expect(memoryDb.getCurrentFacts()).toEqual(
+          expect.arrayContaining([
+            expect.objectContaining({
+              predicate: "Current Location",
+              object: "Structured watchtower",
+              validFromChapter: 1,
+            }),
+            expect.objectContaining({
+              predicate: "Current Conflict",
+              object: "Structured conflict replaces markdown drift.",
+              validFromChapter: 1,
+            }),
+          ]),
+        );
+        expect(memoryDb.getCurrentFacts()).not.toEqual(
+          expect.arrayContaining([
+            expect.objectContaining({
+              object: "Old markdown ferry crossing",
+            }),
+            expect.objectContaining({
+              object: "Old markdown conflict.",
+            }),
+          ]),
+        );
+      } finally {
+        memoryDb.close();
+      }
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("tracks imported English chapters using word counts instead of characters", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const englishBook = {
@@ -2060,7 +2477,7 @@ describe("PipelineRunner", () => {
     }
   });
 
-  it("rebuilds current facts from the revised chapter snapshot", async () => {
+  sqliteIt("rebuilds current facts from the revised chapter snapshot", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const storyDir = join(state.bookDir(bookId), "story");
     const chaptersDir = join(state.bookDir(bookId), "chapters");

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

@@ -0,0 +1,215 @@
+import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import {
+  loadNarrativeMemorySeed,
+  loadRuntimeStateSnapshot,
+  loadSnapshotCurrentStateFacts,
+} from "../state/runtime-state-store.js";
+
+describe("runtime-state-store memory helpers", () => {
+  let root = "";
+
+  afterEach(async () => {
+    if (root) {
+      await rm(root, { recursive: true, force: true });
+      root = "";
+    }
+  });
+
+  it("prefers structured runtime state over stale markdown projections for narrative memory", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-runtime-state-store-"));
+    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(storyDir, "pending_hooks.md"),
+        [
+          "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
+          "| --- | --- | --- | --- | --- | --- | --- |",
+          "| markdown-hook | 1 | mystery | open | 1 | 4 | Old markdown hook |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        [
+          "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+          "| --- | --- | --- | --- | --- | --- | --- | --- |",
+          "| 1 | Markdown Summary | Lin Yue | Old markdown event | Old markdown state | markdown-hook advanced | tense | fallback |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(join(stateDir, "manifest.json"), JSON.stringify({
+        schemaVersion: 2,
+        language: "en",
+        lastAppliedChapter: 3,
+        projectionVersion: 1,
+        migrationWarnings: [],
+      }, null, 2), "utf-8"),
+      writeFile(join(stateDir, "current_state.json"), JSON.stringify({
+        chapter: 3,
+        facts: [],
+      }, null, 2), "utf-8"),
+      writeFile(join(stateDir, "hooks.json"), JSON.stringify({
+        hooks: [
+          {
+            hookId: "structured-hook",
+            startChapter: 2,
+            type: "relationship",
+            status: "progressing",
+            lastAdvancedChapter: 3,
+            expectedPayoff: "Reveal the mentor ledger.",
+            notes: "Structured hook should win.",
+          },
+        ],
+      }, null, 2), "utf-8"),
+      writeFile(join(stateDir, "chapter_summaries.json"), JSON.stringify({
+        rows: [
+          {
+            chapter: 3,
+            title: "Structured Summary",
+            characters: "Lin Yue",
+            events: "Structured runtime state event.",
+            stateChanges: "Structured runtime state shift.",
+            hookActivity: "structured-hook advanced",
+            mood: "grim",
+            chapterType: "mainline",
+          },
+        ],
+      }, null, 2), "utf-8"),
+    ]);
+
+    const seed = await loadNarrativeMemorySeed(bookDir);
+
+    expect(seed.hooks).toEqual([
+      expect.objectContaining({
+        hookId: "structured-hook",
+        status: "progressing",
+      }),
+    ]);
+    expect(seed.summaries).toEqual([
+      expect.objectContaining({
+        chapter: 3,
+        title: "Structured Summary",
+        events: "Structured runtime state event.",
+      }),
+    ]);
+  });
+
+  it("prefers structured snapshot state over stale markdown snapshots for fact history rebuild", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-runtime-state-snapshot-"));
+    const bookDir = join(root, "book");
+    const snapshotDir = join(bookDir, "story", "snapshots", "5");
+    const snapshotStateDir = join(snapshotDir, "state");
+    await mkdir(snapshotStateDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(
+        join(snapshotDir, "current_state.md"),
+        [
+          "# Current State",
+          "",
+          "| Field | Value |",
+          "| --- | --- |",
+          "| Current Chapter | 5 |",
+          "| Current Location | Markdown harbor |",
+          "| Current Conflict | Old markdown conflict |",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(join(snapshotStateDir, "current_state.json"), JSON.stringify({
+        chapter: 5,
+        facts: [
+          {
+            subject: "current",
+            predicate: "Current Location",
+            object: "Structured watchtower",
+            validFromChapter: 5,
+            validUntilChapter: null,
+            sourceChapter: 5,
+          },
+          {
+            subject: "protagonist",
+            predicate: "Current Conflict",
+            object: "Structured conflict replaces markdown drift.",
+            validFromChapter: 5,
+            validUntilChapter: null,
+            sourceChapter: 5,
+          },
+        ],
+      }, null, 2), "utf-8"),
+    ]);
+
+    const facts = await loadSnapshotCurrentStateFacts(bookDir, 5);
+
+    expect(facts).toEqual([
+      expect.objectContaining({
+        predicate: "Current Location",
+        object: "Structured watchtower",
+      }),
+      expect.objectContaining({
+        predicate: "Current Conflict",
+        object: "Structured conflict replaces markdown drift.",
+      }),
+    ]);
+  });
+
+  it("rejects persisted duplicate summary chapters in structured runtime state", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-runtime-state-invalid-"));
+    const bookDir = join(root, "book");
+    const storyDir = join(bookDir, "story");
+    const stateDir = join(storyDir, "state");
+    await mkdir(stateDir, { recursive: true });
+
+    await Promise.all([
+      writeFile(join(stateDir, "manifest.json"), JSON.stringify({
+        schemaVersion: 2,
+        language: "zh",
+        lastAppliedChapter: 12,
+        projectionVersion: 1,
+        migrationWarnings: [],
+      }, null, 2), "utf-8"),
+      writeFile(join(stateDir, "current_state.json"), JSON.stringify({
+        chapter: 12,
+        facts: [],
+      }, null, 2), "utf-8"),
+      writeFile(join(stateDir, "hooks.json"), JSON.stringify({
+        hooks: [],
+      }, null, 2), "utf-8"),
+      writeFile(join(stateDir, "chapter_summaries.json"), JSON.stringify({
+        rows: [
+          {
+            chapter: 12,
+            title: "河埠对账",
+            characters: "林月",
+            events: "第一次写入。",
+            stateChanges: "第一次写入。",
+            hookActivity: "mentor-debt 推进",
+            mood: "紧绷",
+            chapterType: "主线推进",
+          },
+          {
+            chapter: 12,
+            title: "重复河埠对账",
+            characters: "林月",
+            events: "第二次写入。",
+            stateChanges: "第二次写入。",
+            hookActivity: "mentor-debt 推进",
+            mood: "紧绷",
+            chapterType: "主线推进",
+          },
+        ],
+      }, null, 2), "utf-8"),
+    ]);
+
+    await expect(loadRuntimeStateSnapshot(bookDir)).rejects.toThrow(/duplicate_summary_chapter/i);
+  });
+});

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

@@ -0,0 +1,99 @@
+import { describe, expect, it } from "vitest";
+import { parseSettlerDeltaOutput } from "../agents/settler-delta-parser.js";
+
+describe("parseSettlerDeltaOutput", () => {
+  it("parses a valid runtime-state delta block", () => {
+    const result = parseSettlerDeltaOutput([
+      "=== POST_SETTLEMENT ===",
+      "| 伏笔变动 | mentor-oath 推进 | 同步更新 |",
+      "",
+      "=== RUNTIME_STATE_DELTA ===",
+      "```json",
+      JSON.stringify({
+        chapter: 12,
+        currentStatePatch: {
+          currentGoal: "追到河埠旧账的尽头",
+          currentConflict: "商会噪音仍在干扰师债主线",
+        },
+        hookOps: {
+          upsert: [
+            {
+              hookId: "mentor-oath",
+              startChapter: 8,
+              type: "relationship",
+              status: "progressing",
+              lastAdvancedChapter: 12,
+              expectedPayoff: "揭开师债真相",
+              notes: "河埠旧账把师债再往前推了一格",
+            },
+          ],
+          resolve: [],
+          defer: [],
+        },
+        chapterSummary: {
+          chapter: 12,
+          title: "河埠对账",
+          characters: "林月",
+          events: "林月核对河埠旧账",
+          stateChanges: "师债线索进一步收束",
+          hookActivity: "mentor-oath advanced",
+          mood: "紧绷",
+          chapterType: "主线推进",
+        },
+        notes: ["保留商会噪音,但不盖过主线"],
+      }, null, 2),
+      "```",
+    ].join("\n"));
+
+    expect(result.postSettlement).toContain("mentor-oath");
+    expect(result.runtimeStateDelta.chapter).toBe(12);
+    expect(result.runtimeStateDelta.hookOps.upsert[0]?.hookId).toBe("mentor-oath");
+    expect(result.runtimeStateDelta.chapterSummary?.title).toBe("河埠对账");
+  });
+
+  it("rejects invalid runtime-state delta payloads", () => {
+    expect(() =>
+      parseSettlerDeltaOutput([
+        "=== RUNTIME_STATE_DELTA ===",
+        "```json",
+        JSON.stringify({
+          chapter: 12,
+          hookOps: {
+            upsert: [
+              {
+                hookId: "mentor-oath",
+                startChapter: 8,
+                type: "relationship",
+                status: "open",
+                lastAdvancedChapter: "chapter twelve",
+              },
+            ],
+            resolve: [],
+            defer: [],
+          },
+        }),
+        "```",
+      ].join("\n")),
+    ).toThrow(/runtime state delta/i);
+  });
+
+  it("parses hook resolve and defer operations", () => {
+    const result = parseSettlerDeltaOutput([
+      "=== RUNTIME_STATE_DELTA ===",
+      "```json",
+      JSON.stringify({
+        chapter: 20,
+        hookOps: {
+          upsert: [],
+          resolve: ["old-seal"],
+          defer: ["guild-route"],
+        },
+        notes: [],
+      }),
+      "```",
+    ].join("\n"));
+
+    expect(result.runtimeStateDelta.hookOps.resolve).toEqual(["old-seal"]);
+    expect(result.runtimeStateDelta.hookOps.defer).toEqual(["guild-route"]);
+  });
+});

+ 114 - 0
packages/core/src/__tests__/state-manager.test.ts

@@ -262,6 +262,30 @@ describe("StateManager", () => {
       expect(hooks).toBe("# Hooks at ch1");
     });
 
+    it("copies structured runtime state into snapshot/state when present", async () => {
+      const stateDir = manager.stateDir(bookId);
+      await mkdir(stateDir, { recursive: true });
+      await writeFile(
+        join(stateDir, "manifest.json"),
+        JSON.stringify({
+          schemaVersion: 2,
+          language: "en",
+          lastAppliedChapter: 1,
+          projectionVersion: 1,
+          migrationWarnings: [],
+        }, null, 2),
+        "utf-8",
+      );
+
+      await manager.snapshotState(bookId, 1);
+
+      const snapshotManifest = await readFile(
+        join(manager.bookDir(bookId), "story", "snapshots", "1", "state", "manifest.json"),
+        "utf-8",
+      );
+      expect(snapshotManifest).toContain("\"schemaVersion\": 2");
+    });
+
     it("restores state from a previous snapshot", async () => {
       await manager.snapshotState(bookId, 1);
 
@@ -300,6 +324,41 @@ describe("StateManager", () => {
       expect(ledger).toBe("# Ledger at ch1");
     });
 
+    it("restores structured runtime state files from snapshot/state", async () => {
+      const stateDir = manager.stateDir(bookId);
+      await mkdir(stateDir, { recursive: true });
+      await writeFile(
+        join(stateDir, "manifest.json"),
+        JSON.stringify({
+          schemaVersion: 2,
+          language: "en",
+          lastAppliedChapter: 1,
+          projectionVersion: 1,
+          migrationWarnings: [],
+        }, null, 2),
+        "utf-8",
+      );
+
+      await manager.snapshotState(bookId, 1);
+      await writeFile(
+        join(stateDir, "manifest.json"),
+        JSON.stringify({
+          schemaVersion: 2,
+          language: "en",
+          lastAppliedChapter: 9,
+          projectionVersion: 1,
+          migrationWarnings: [],
+        }, null, 2),
+        "utf-8",
+      );
+
+      const restored = await manager.restoreState(bookId, 1);
+      expect(restored).toBe(true);
+
+      const manifest = await readFile(join(stateDir, "manifest.json"), "utf-8");
+      expect(manifest).toContain("\"lastAppliedChapter\": 1");
+    });
+
     it("returns false when restoring from non-existent snapshot", async () => {
       const restored = await manager.restoreState(bookId, 999);
       expect(restored).toBe(false);
@@ -415,6 +474,12 @@ describe("StateManager", () => {
         join(tempDir, "books", "my-book"),
       );
     });
+
+    it("stateDir returns <bookDir>/story/state", () => {
+      expect(manager.stateDir("my-book")).toBe(
+        join(tempDir, "books", "my-book", "story", "state"),
+      );
+    });
   });
 
   // -------------------------------------------------------------------------
@@ -490,5 +555,54 @@ describe("StateManager", () => {
       expect(currentFocus).toContain("# 当前聚焦");
       expect(currentFocus).not.toContain("# Current Focus");
     });
+
+    it("bootstraps structured runtime state from legacy markdown truth files", async () => {
+      const bookId = "runtime-state-book";
+      const storyDir = join(manager.bookDir(bookId), "story");
+      await mkdir(storyDir, { recursive: true });
+      await Promise.all([
+        writeFile(
+          join(storyDir, "current_state.md"),
+          [
+            "# Current State",
+            "",
+            "| Field | Value |",
+            "| --- | --- |",
+            "| Current Chapter | 3 |",
+            "| Current Goal | Trace the mentor debt |",
+            "",
+          ].join("\n"),
+          "utf-8",
+        ),
+        writeFile(
+          join(storyDir, "pending_hooks.md"),
+          [
+            "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
+            "| --- | --- | --- | --- | --- | --- | --- |",
+            "| mentor-debt | 1 | relationship | open | 3 | 10 | Still unresolved |",
+            "",
+          ].join("\n"),
+          "utf-8",
+        ),
+        writeFile(
+          join(storyDir, "chapter_summaries.md"),
+          [
+            "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+            "| --- | --- | --- | --- | --- | --- | --- | --- |",
+            "| 3 | River Ledger | Lin Yue | He checks the old ledger | Debt sharpens | mentor-debt advanced | tense | mainline |",
+            "",
+          ].join("\n"),
+          "utf-8",
+        ),
+      ]);
+
+      await manager.ensureRuntimeState(bookId, 3);
+
+      const manifest = await readFile(join(manager.stateDir(bookId), "manifest.json"), "utf-8");
+      const currentState = await readFile(join(manager.stateDir(bookId), "current_state.json"), "utf-8");
+
+      expect(manifest).toContain("\"schemaVersion\": 2");
+      expect(currentState).toContain("\"chapter\": 3");
+    });
   });
 });

+ 130 - 0
packages/core/src/__tests__/state-projections.test.ts

@@ -0,0 +1,130 @@
+import { describe, expect, it } from "vitest";
+import {
+  renderChapterSummariesProjection,
+  renderCurrentStateProjection,
+  renderHooksProjection,
+} from "../state/state-projections.js";
+
+describe("state projections", () => {
+  it("renders pending hooks projection with deterministic English ordering", () => {
+    const markdown = renderHooksProjection({
+      hooks: [
+        {
+          hookId: "b-courier",
+          startChapter: 12,
+          type: "mystery",
+          status: "open",
+          lastAdvancedChapter: 13,
+          expectedPayoff: "Identify the courier.",
+          notes: "The seal is still broken.",
+        },
+        {
+          hookId: "a-debt",
+          startChapter: 4,
+          type: "relationship",
+          status: "progressing",
+          lastAdvancedChapter: 11,
+          expectedPayoff: "Reveal the debt.",
+          notes: "Old oath token resurfaces.",
+        },
+      ],
+    }, "en");
+
+    expect(markdown).toBe([
+      "# Pending Hooks",
+      "",
+      "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
+      "| --- | --- | --- | --- | --- | --- | --- |",
+      "| a-debt | 4 | relationship | progressing | 11 | Reveal the debt. | Old oath token resurfaces. |",
+      "| b-courier | 12 | mystery | open | 13 | Identify the courier. | The seal is still broken. |",
+      "",
+    ].join("\n"));
+  });
+
+  it("renders chapter summaries projection with deterministic Chinese ordering", () => {
+    const markdown = renderChapterSummariesProjection({
+      rows: [
+        {
+          chapter: 12,
+          title: "河埠对账",
+          characters: "林月",
+          events: "林月核对货单与誓令碎片",
+          stateChanges: "师债线索进一步收束",
+          hookActivity: "mentor-debt 推进",
+          mood: "紧绷",
+          chapterType: "主线推进",
+        },
+        {
+          chapter: 11,
+          title: "雨巷旧账",
+          characters: "林月",
+          events: "林月查到旧账册断页",
+          stateChanges: "师债线被重新钉牢",
+          hookActivity: "mentor-debt 推进",
+          mood: "压抑",
+          chapterType: "主线推进",
+        },
+      ],
+    }, "zh");
+
+    expect(markdown).toBe([
+      "# 章节摘要",
+      "",
+      "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| 11 | 雨巷旧账 | 林月 | 林月查到旧账册断页 | 师债线被重新钉牢 | mentor-debt 推进 | 压抑 | 主线推进 |",
+      "| 12 | 河埠对账 | 林月 | 林月核对货单与誓令碎片 | 师债线索进一步收束 | mentor-debt 推进 | 紧绷 | 主线推进 |",
+      "",
+    ].join("\n"));
+  });
+
+  it("renders current state projection with placeholders and additional notes", () => {
+    const markdown = renderCurrentStateProjection({
+      chapter: 12,
+      facts: [
+        {
+          subject: "protagonist",
+          predicate: "Current Goal",
+          object: "Track the mentor debt through the river-port ledger.",
+          validFromChapter: 12,
+          validUntilChapter: null,
+          sourceChapter: 12,
+        },
+        {
+          subject: "protagonist",
+          predicate: "Current Conflict",
+          object: "Guild pressure keeps pulling against the debt trail.",
+          validFromChapter: 12,
+          validUntilChapter: null,
+          sourceChapter: 12,
+        },
+        {
+          subject: "current_state",
+          predicate: "note_1",
+          object: "Lin Yue still hides the broken oath token.",
+          validFromChapter: 12,
+          validUntilChapter: null,
+          sourceChapter: 12,
+        },
+      ],
+    }, "en");
+
+    expect(markdown).toBe([
+      "# Current State",
+      "",
+      "| Field | Value |",
+      "| --- | --- |",
+      "| Current Chapter | 12 |",
+      "| Current Location | (not set) |",
+      "| Protagonist State | (not set) |",
+      "| Current Goal | Track the mentor debt through the river-port ledger. |",
+      "| Current Constraint | (not set) |",
+      "| Current Alliances | (not set) |",
+      "| Current Conflict | Guild pressure keeps pulling against the debt trail. |",
+      "",
+      "## Additional State",
+      "- Lin Yue still hides the broken oath token.",
+      "",
+    ].join("\n"));
+  });
+});

+ 195 - 0
packages/core/src/__tests__/state-reducer.test.ts

@@ -0,0 +1,195 @@
+import { describe, expect, it } from "vitest";
+import { applyRuntimeStateDelta } from "../state/state-reducer.js";
+import { RuntimeStateDeltaSchema } from "../models/runtime-state.js";
+
+describe("applyRuntimeStateDelta", () => {
+  it("applies a chapter-local delta into structured state", () => {
+    const result = applyRuntimeStateDelta({
+      snapshot: {
+        manifest: {
+          schemaVersion: 2,
+          language: "en",
+          lastAppliedChapter: 11,
+          projectionVersion: 1,
+          migrationWarnings: [],
+        },
+        currentState: {
+          chapter: 11,
+          facts: [],
+        },
+        hooks: {
+          hooks: [
+            {
+              hookId: "mentor-debt",
+              startChapter: 1,
+              type: "relationship",
+              status: "open",
+              lastAdvancedChapter: 11,
+              expectedPayoff: "Reveal the debt.",
+              notes: "Still unresolved.",
+            },
+          ],
+        },
+        chapterSummaries: {
+          rows: [
+            {
+              chapter: 11,
+              title: "Old Ledger",
+              characters: "Lin Yue",
+              events: "Lin Yue finds the old ledger.",
+              stateChanges: "The debt trail tightens.",
+              hookActivity: "mentor-debt advanced",
+              mood: "tense",
+              chapterType: "mainline",
+            },
+          ],
+        },
+      },
+      delta: RuntimeStateDeltaSchema.parse({
+        chapter: 12,
+        currentStatePatch: {
+          currentGoal: "Trace the debt through the river-port ledger.",
+        },
+        hookOps: {
+          upsert: [
+            {
+              hookId: "mentor-debt",
+              startChapter: 1,
+              type: "relationship",
+              status: "progressing",
+              lastAdvancedChapter: 12,
+              expectedPayoff: "Reveal the debt.",
+              notes: "The river-port ledger sharpens the clue.",
+            },
+          ],
+          resolve: [],
+          defer: [],
+        },
+        chapterSummary: {
+          chapter: 12,
+          title: "River-Port Ledger",
+          characters: "Lin Yue",
+          events: "Lin Yue cross-checks the river-port ledger.",
+          stateChanges: "The debt trail narrows.",
+          hookActivity: "mentor-debt advanced",
+          mood: "tight",
+          chapterType: "investigation",
+        },
+        notes: [],
+      }),
+    });
+
+    expect(result.manifest.lastAppliedChapter).toBe(12);
+    expect(result.currentState.chapter).toBe(12);
+    expect(result.currentState.facts).toEqual(
+      expect.arrayContaining([
+        expect.objectContaining({
+          predicate: "Current Goal",
+          object: "Trace the debt through the river-port ledger.",
+          sourceChapter: 12,
+        }),
+      ]),
+    );
+    expect(result.hooks.hooks).toEqual(
+      expect.arrayContaining([
+        expect.objectContaining({
+          hookId: "mentor-debt",
+          status: "progressing",
+          lastAdvancedChapter: 12,
+        }),
+      ]),
+    );
+    expect(result.chapterSummaries.rows.map((row) => row.chapter)).toEqual([11, 12]);
+  });
+
+  it("rejects duplicate summary rows for the same chapter", () => {
+    expect(() =>
+      applyRuntimeStateDelta({
+        snapshot: {
+          manifest: {
+            schemaVersion: 2,
+            language: "zh",
+            lastAppliedChapter: 11,
+            projectionVersion: 1,
+            migrationWarnings: [],
+          },
+          currentState: {
+            chapter: 11,
+            facts: [],
+          },
+          hooks: {
+            hooks: [],
+          },
+          chapterSummaries: {
+            rows: [
+              {
+                chapter: 12,
+                title: "河埠对账",
+                characters: "林月",
+                events: "林月核对货单。",
+                stateChanges: "师债线索收束。",
+                hookActivity: "mentor-debt 推进",
+                mood: "紧绷",
+                chapterType: "主线推进",
+              },
+            ],
+          },
+        },
+        delta: RuntimeStateDeltaSchema.parse({
+          chapter: 12,
+          hookOps: {
+            upsert: [],
+            resolve: [],
+            defer: [],
+          },
+          chapterSummary: {
+            chapter: 12,
+            title: "再写一版河埠对账",
+            characters: "林月",
+            events: "重复写入。",
+            stateChanges: "重复写入。",
+            hookActivity: "mentor-debt 推进",
+            mood: "紧绷",
+            chapterType: "主线推进",
+          },
+          notes: [],
+        }),
+      }),
+    ).toThrow(/duplicate summary/i);
+  });
+
+  it("rejects resolve operations for unknown hooks", () => {
+    expect(() =>
+      applyRuntimeStateDelta({
+        snapshot: {
+          manifest: {
+            schemaVersion: 2,
+            language: "en",
+            lastAppliedChapter: 11,
+            projectionVersion: 1,
+            migrationWarnings: [],
+          },
+          currentState: {
+            chapter: 11,
+            facts: [],
+          },
+          hooks: {
+            hooks: [],
+          },
+          chapterSummaries: {
+            rows: [],
+          },
+        },
+        delta: RuntimeStateDeltaSchema.parse({
+          chapter: 12,
+          hookOps: {
+            upsert: [],
+            resolve: ["mentor-debt"],
+            defer: [],
+          },
+          notes: [],
+        }),
+      }),
+    ).toThrow(/unknown hook/i);
+  });
+});

+ 122 - 0
packages/core/src/__tests__/state-validator.test.ts

@@ -0,0 +1,122 @@
+import { describe, expect, it } from "vitest";
+import { validateRuntimeState } from "../state/state-validator.js";
+
+describe("validateRuntimeState", () => {
+  it("rejects hook rows with non-integer numeric fields", () => {
+    const issues = validateRuntimeState({
+      manifest: {
+        schemaVersion: 2,
+        language: "en",
+        lastAppliedChapter: 12,
+        projectionVersion: 1,
+        migrationWarnings: [],
+      },
+      currentState: {
+        chapter: 12,
+        facts: [],
+      },
+      hooks: {
+        hooks: [
+          {
+            hookId: "mentor-debt",
+            startChapter: 1,
+            type: "relationship",
+            status: "open",
+            lastAdvancedChapter: "chapter twelve",
+            expectedPayoff: "Reveal the debt.",
+            notes: "Bad numeric field.",
+          },
+        ],
+      },
+      chapterSummaries: {
+        rows: [],
+      },
+    });
+
+    expect(issues.map((issue) => issue.code)).toContain("invalid_hooks_state");
+  });
+
+  it("rejects duplicate hook ids", () => {
+    const issues = validateRuntimeState({
+      manifest: {
+        schemaVersion: 2,
+        language: "en",
+        lastAppliedChapter: 12,
+        projectionVersion: 1,
+        migrationWarnings: [],
+      },
+      currentState: {
+        chapter: 12,
+        facts: [],
+      },
+      hooks: {
+        hooks: [
+          {
+            hookId: "mentor-debt",
+            startChapter: 1,
+            type: "relationship",
+            status: "open",
+            lastAdvancedChapter: 10,
+            expectedPayoff: "Reveal the debt.",
+            notes: "",
+          },
+          {
+            hookId: "mentor-debt",
+            startChapter: 4,
+            type: "mystery",
+            status: "progressing",
+            lastAdvancedChapter: 12,
+            expectedPayoff: "Identify the courier.",
+            notes: "",
+          },
+        ],
+      },
+      chapterSummaries: {
+        rows: [],
+      },
+    });
+
+    expect(issues).toEqual(
+      expect.arrayContaining([
+        expect.objectContaining({
+          code: "duplicate_hook_id",
+          path: "hooks.mentor-debt",
+        }),
+      ]),
+    );
+  });
+
+  it("accepts stale open hooks as valid runtime state", () => {
+    const issues = validateRuntimeState({
+      manifest: {
+        schemaVersion: 2,
+        language: "zh",
+        lastAppliedChapter: 30,
+        projectionVersion: 1,
+        migrationWarnings: [],
+      },
+      currentState: {
+        chapter: 30,
+        facts: [],
+      },
+      hooks: {
+        hooks: [
+          {
+            hookId: "mentor-oath",
+            startChapter: 2,
+            type: "relationship",
+            status: "open",
+            lastAdvancedChapter: 8,
+            expectedPayoff: "揭开师债真相",
+            notes: "已经很多章没推进,但仍然有效。",
+          },
+        ],
+      },
+      chapterSummaries: {
+        rows: [],
+      },
+    });
+
+    expect(issues).toEqual([]);
+  });
+});

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

@@ -214,6 +214,149 @@ describe("WriterAgent", () => {
     }
   });
 
+  it("builds structured runtime-state artifacts when settler returns a delta", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-writer-runtime-state-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- The jade seal cannot be destroyed.\n", "utf-8"),
+      writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 3\nTrace the debt through the river-port ledger.\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 the vanished mentor |",
+        "| Current Conflict | Guild pressure keeps colliding with the debt trail |",
+        "",
+      ].join("\n"), "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), [
+        "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
+        "| --- | --- | --- | --- | --- | --- | --- |",
+        "| mentor-debt | 1 | relationship | open | 2 | 6 | Still unresolved |",
+        "",
+      ].join("\n"), "utf-8"),
+      writeFile(join(storyDir, "chapter_summaries.md"), [
+        "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+        "| --- | --- | --- | --- | --- | --- | --- | --- |",
+        "| 2 | Old Ledger | Lin Yue | Lin Yue finds the old ledger | Debt sharpens | mentor-debt 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 ===",
+          "River Ledger",
+          "",
+          "=== CHAPTER_CONTENT ===",
+          "Lin Yue follows the debt into the river-port ledger.",
+          "",
+          "=== PRE_WRITE_CHECK ===",
+          "- ok",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      })
+      .mockResolvedValueOnce({
+        content: "=== OBSERVATIONS ===\n- observed",
+        usage: ZERO_USAGE,
+      })
+      .mockResolvedValueOnce({
+        content: [
+          "=== POST_SETTLEMENT ===",
+          "- mentor-debt advanced",
+          "",
+          "=== RUNTIME_STATE_DELTA ===",
+          "```json",
+          JSON.stringify({
+            chapter: 3,
+            currentStatePatch: {
+              currentGoal: "Trace the debt through the river-port ledger.",
+              currentConflict: "Guild pressure keeps colliding with the debt trail.",
+            },
+            hookOps: {
+              upsert: [
+                {
+                  hookId: "mentor-debt",
+                  startChapter: 1,
+                  type: "relationship",
+                  status: "progressing",
+                  lastAdvancedChapter: 3,
+                  expectedPayoff: "Reveal the debt.",
+                  notes: "The ledger clue sharpens the line.",
+                },
+              ],
+              resolve: [],
+              defer: [],
+            },
+            chapterSummary: {
+              chapter: 3,
+              title: "River Ledger",
+              characters: "Lin Yue",
+              events: "Lin Yue follows the debt into the river-port ledger.",
+              stateChanges: "The debt line sharpens.",
+              hookActivity: "mentor-debt advanced",
+              mood: "tense",
+              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: "xuanhuan",
+          status: "active",
+          targetChapters: 20,
+          chapterWordCount: 2200,
+          language: "en",
+          createdAt: "2026-03-25T00:00:00.000Z",
+          updatedAt: "2026-03-25T00:00:00.000Z",
+        },
+        bookDir,
+        chapterNumber: 3,
+        lengthSpec: buildLengthSpec(2200, "en"),
+      });
+
+      expect(output.runtimeStateDelta?.chapter).toBe(3);
+      expect(output.runtimeStateSnapshot?.manifest.lastAppliedChapter).toBe(3);
+      expect(output.updatedState).toContain("Trace the debt through the river-port ledger.");
+      expect(output.updatedHooks).toContain("mentor-debt");
+      expect(output.updatedChapterSummaries).toContain("River Ledger");
+      expect(output.chapterSummary).toContain("| 3 | River Ledger |");
+    } 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");

+ 47 - 0
packages/core/src/agents/settler-delta-parser.ts

@@ -0,0 +1,47 @@
+import {
+  RuntimeStateDeltaSchema,
+  type RuntimeStateDelta,
+} from "../models/runtime-state.js";
+
+export interface SettlerDeltaOutput {
+  readonly postSettlement: string;
+  readonly runtimeStateDelta: RuntimeStateDelta;
+}
+
+export function parseSettlerDeltaOutput(content: string): SettlerDeltaOutput {
+  const extract = (tag: string): string => {
+    const regex = new RegExp(
+      `=== ${tag} ===\\s*([\\s\\S]*?)(?==== [A-Z_]+ ===|$)`,
+    );
+    const match = content.match(regex);
+    return match?.[1]?.trim() ?? "";
+  };
+
+  const rawDelta = extract("RUNTIME_STATE_DELTA");
+  if (!rawDelta) {
+    throw new Error("runtime state delta block is missing");
+  }
+
+  const jsonPayload = stripCodeFence(rawDelta);
+  let parsed: unknown;
+  try {
+    parsed = JSON.parse(jsonPayload);
+  } catch (error) {
+    throw new Error(`runtime state delta is not valid JSON: ${String(error)}`);
+  }
+
+  try {
+    return {
+      postSettlement: extract("POST_SETTLEMENT"),
+      runtimeStateDelta: RuntimeStateDeltaSchema.parse(parsed),
+    };
+  } catch (error) {
+    throw new Error(`runtime state delta failed schema validation: ${String(error)}`);
+  }
+}
+
+function stripCodeFence(value: string): string {
+  const trimmed = value.trim();
+  const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
+  return fenced?.[1]?.trim() ?? trimmed;
+}

+ 58 - 56
packages/core/src/agents/settler-prompts.ts

@@ -73,62 +73,64 @@ ${buildSettlerOutputFormat(genreProfile)}
 }
 
 function buildSettlerOutputFormat(gp: GenreProfile): string {
-  const postSettlement = gp.numericalSystem
-    ? `=== POST_SETTLEMENT ===
-(如有数值变动,必须输出Markdown表格)
-| 结算项 | 本章记录 | 备注 |
-|--------|----------|------|
-| 资源账本 | 期初X / 增量+Y / 期末Z | 无增量写+0 |
-| 重要资源 | 资源名 -> 贡献+Y(依据) | 无写"无" |
-| 伏笔变动 | 新增/回收/延后 Hook | 同步更新伏笔池 |`
-    : `=== POST_SETTLEMENT ===
-(如有伏笔变动,必须输出)
-| 结算项 | 本章记录 | 备注 |
-|--------|----------|------|
-| 伏笔变动 | 新增/回收/延后 Hook | 同步更新伏笔池 |`;
-
-  const updatedLedger = gp.numericalSystem
-    ? `\n=== UPDATED_LEDGER ===\n(更新后的完整资源账本,Markdown表格格式)`
-    : "";
-
-  return `${postSettlement}
-
-=== UPDATED_STATE ===
-(更新后的完整状态卡,Markdown表格格式)
-${updatedLedger}
-=== UPDATED_HOOKS ===
-(更新后的完整伏笔池,Markdown表格格式)
-
-=== CHAPTER_SUMMARY ===
-(本章摘要,Markdown表格格式,必须包含以下列)
-| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |
-|------|------|----------|----------|----------|----------|----------|----------|
-| N | 本章标题 | 角色1,角色2 | 一句话概括 | 关键变化 | H01埋设/H02推进 | 情绪走向 | ${gp.chapterTypes.length > 0 ? gp.chapterTypes.join("/") : "过渡/冲突/高潮/收束"} |
-
-=== UPDATED_SUBPLOTS ===
-(更新后的完整支线进度板,Markdown表格格式)
-| 支线ID | 支线名 | 相关角色 | 起始章 | 最近活跃章 | 距今章数 | 状态 | 进度概述 | 回收ETA |
-|--------|--------|----------|--------|------------|----------|------|----------|---------|
-
-=== UPDATED_EMOTIONAL_ARCS ===
-(更新后的完整情感弧线,Markdown表格格式)
-| 角色 | 章节 | 情绪状态 | 触发事件 | 强度(1-10) | 弧线方向 |
-|------|------|----------|----------|------------|----------|
-
-=== UPDATED_CHARACTER_MATRIX ===
-(更新后的角色交互矩阵,分三个子表)
-
-### 角色档案
-| 角色 | 核心标签 | 反差细节 | 说话风格 | 性格底色 | 与主角关系 | 核心动机 | 当前目标 |
-|------|----------|----------|----------|----------|------------|----------|----------|
-
-### 相遇记录
-| 角色A | 角色B | 首次相遇章 | 最近交互章 | 关系性质 | 关系变化 |
-|-------|-------|------------|------------|----------|----------|
-
-### 信息边界
-| 角色 | 已知信息 | 未知信息 | 信息来源章 |
-|------|----------|----------|------------|`;
+  const chapterTypeExample = gp.chapterTypes.length > 0
+    ? gp.chapterTypes[0]
+    : "主线推进";
+
+  return `=== POST_SETTLEMENT ===
+(简要说明本章有哪些状态变动、伏笔推进、结算注意事项;允许 Markdown 表格或要点)
+
+=== RUNTIME_STATE_DELTA ===
+(必须输出 JSON,不要输出 Markdown,不要加解释)
+\`\`\`json
+{
+  "chapter": 12,
+  "currentStatePatch": {
+    "currentLocation": "可选",
+    "protagonistState": "可选",
+    "currentGoal": "可选",
+    "currentConstraint": "可选",
+    "currentAlliances": "可选",
+    "currentConflict": "可选"
+  },
+  "hookOps": {
+    "upsert": [
+      {
+        "hookId": "mentor-oath",
+        "startChapter": 8,
+        "type": "relationship",
+        "status": "progressing",
+        "lastAdvancedChapter": 12,
+        "expectedPayoff": "揭开师债真相",
+        "notes": "本章为何推进/延后/回收"
+      }
+    ],
+    "resolve": ["已回收的 hookId"],
+    "defer": ["需要标记延后的 hookId"]
+  },
+  "chapterSummary": {
+    "chapter": 12,
+    "title": "本章标题",
+    "characters": "角色1,角色2",
+    "events": "一句话概括关键事件",
+    "stateChanges": "一句话概括状态变化",
+    "hookActivity": "mentor-oath advanced",
+    "mood": "紧绷",
+    "chapterType": "${chapterTypeExample}"
+  },
+  "subplotOps": [],
+  "emotionalArcOps": [],
+  "characterMatrixOps": [],
+  "notes": []
+}
+\`\`\`
+
+规则:
+1. 只输出增量,不要重写完整 truth files
+2. 所有章节号字段都必须是整数,不能写自然语言
+3. 如果本章推进了旧 hook,lastAdvancedChapter 必须等于当前章号
+4. 如果回收或延后 hook,必须放在 resolve / defer 数组里
+5. chapterSummary.chapter 必须等于当前章节号`;
 }
 
 export function buildSettlerUserPrompt(params: {

+ 143 - 23
packages/core/src/agents/writer.ts

@@ -5,12 +5,14 @@ import type { BookRules } from "../models/book-rules.js";
 import { buildWriterSystemPrompt, type FanficContext } from "./writer-prompts.js";
 import { buildSettlerSystemPrompt, buildSettlerUserPrompt } from "./settler-prompts.js";
 import { buildObserverSystemPrompt, buildObserverUserPrompt } from "./observer-prompts.js";
+import { parseSettlerDeltaOutput } from "./settler-delta-parser.js";
 import { parseSettlementOutput } from "./settler-parser.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import { validatePostWrite, detectCrossChapterRepetition, type PostWriteViolation } from "./post-write-validator.js";
 import { analyzeAITells } from "./ai-tells.js";
 import type { ChapterTrace, ContextPackage, RuleStack } from "../models/input-governance.js";
 import type { LengthSpec } from "../models/length-governance.js";
+import type { RuntimeStateDelta } from "../models/runtime-state.js";
 import { buildLengthSpec } from "../utils/length-metrics.js";
 import { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "../utils/context-filter.js";
 import { buildGovernedMemoryEvidenceBlocks } from "../utils/governed-context.js";
@@ -22,6 +24,8 @@ import {
 } from "../utils/governed-working-set.js";
 import { extractPOVFromOutline, filterMatrixByPOV, filterHooksByPOV } from "../utils/pov-filter.js";
 import { parseCreativeOutput } from "./writer-parser.js";
+import { buildRuntimeStateArtifacts, saveRuntimeStateSnapshot, type RuntimeStateArtifacts } from "../state/runtime-state-store.js";
+import type { RuntimeStateSnapshot } from "../state/state-reducer.js";
 import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
 import { join } from "node:path";
 
@@ -52,10 +56,13 @@ export interface WriteChapterOutput {
   readonly wordCount: number;
   readonly preWriteCheck: string;
   readonly postSettlement: string;
+  readonly runtimeStateDelta?: RuntimeStateDelta;
+  readonly runtimeStateSnapshot?: RuntimeStateSnapshot;
   readonly updatedState: string;
   readonly updatedLedger: string;
   readonly updatedHooks: string;
   readonly chapterSummary: string;
+  readonly updatedChapterSummaries?: string;
   readonly updatedSubplots: string;
   readonly updatedEmotionalArcs: string;
   readonly updatedCharacterMatrix: string;
@@ -278,6 +285,11 @@ export class WriterAgent extends BaseAgent {
     });
     const settlement = settleResult.settlement;
     const settleUsage = settleResult.usage;
+    const runtimeStateArtifacts = await this.buildRuntimeStateArtifactsIfPresent(
+      bookDir,
+      settlement.runtimeStateDelta,
+      resolvedLanguage,
+    );
 
     // ── Post-write validation (regex + rule-based, zero LLM cost) ──
     const ruleViolations = [
@@ -322,10 +334,15 @@ export class WriterAgent extends BaseAgent {
       wordCount: creative.wordCount,
       preWriteCheck: creative.preWriteCheck,
       postSettlement: settlement.postSettlement,
-      updatedState: settlement.updatedState,
+      runtimeStateDelta: settlement.runtimeStateDelta,
+      runtimeStateSnapshot: runtimeStateArtifacts?.snapshot ?? settlement.runtimeStateSnapshot,
+      updatedState: runtimeStateArtifacts?.currentStateMarkdown ?? settlement.updatedState,
       updatedLedger: settlement.updatedLedger,
-      updatedHooks: settlement.updatedHooks,
-      chapterSummary: settlement.chapterSummary,
+      updatedHooks: runtimeStateArtifacts?.hooksMarkdown ?? settlement.updatedHooks,
+      chapterSummary: settlement.runtimeStateDelta
+        ? this.renderDeltaSummaryRow(settlement.runtimeStateDelta)
+        : settlement.chapterSummary,
+      updatedChapterSummaries: runtimeStateArtifacts?.chapterSummariesMarkdown,
       updatedSubplots: settlement.updatedSubplots,
       updatedEmotionalArcs: settlement.updatedEmotionalArcs,
       updatedCharacterMatrix: settlement.updatedCharacterMatrix,
@@ -358,7 +375,13 @@ export class WriterAgent extends BaseAgent {
     readonly originalSubplots: string;
     readonly originalEmotionalArcs: string;
     readonly originalCharacterMatrix: string;
-  }): Promise<{ settlement: ReturnType<typeof parseSettlementOutput>; usage: TokenUsage }> {
+  }): Promise<{
+    settlement: ReturnType<typeof parseSettlementOutput> & {
+      runtimeStateDelta?: RuntimeStateDelta;
+      runtimeStateSnapshot?: RuntimeStateSnapshot;
+    };
+    usage: TokenUsage;
+  }> {
     // Phase 2a: Observer — extract all facts from the chapter
     const resolvedLang = params.book.language ?? params.genreProfile.language;
     const observerSystem = buildObserverSystemPrompt(params.book, params.genreProfile, resolvedLang);
@@ -422,22 +445,41 @@ export class WriterAgent extends BaseAgent {
       { maxTokens: settlerMaxTokens, temperature: 0.3 },
     );
 
-    const settlement = parseSettlementOutput(response.content, params.genreProfile);
-    const mergedSettlement = governedControlBlock
-      ? {
-          ...settlement,
-          updatedHooks: mergeTableMarkdownByKey(params.originalHooks, settlement.updatedHooks, [0]),
-          updatedSubplots: settlement.updatedSubplots
-            ? mergeTableMarkdownByKey(params.originalSubplots, settlement.updatedSubplots, [0])
-            : settlement.updatedSubplots,
-          updatedEmotionalArcs: settlement.updatedEmotionalArcs
-            ? mergeTableMarkdownByKey(params.originalEmotionalArcs, settlement.updatedEmotionalArcs, [0, 1])
-            : settlement.updatedEmotionalArcs,
-          updatedCharacterMatrix: settlement.updatedCharacterMatrix
-            ? mergeCharacterMatrixMarkdown(params.originalCharacterMatrix, settlement.updatedCharacterMatrix)
-            : settlement.updatedCharacterMatrix,
-        }
-      : settlement;
+    let mergedSettlement: ReturnType<typeof parseSettlementOutput> & {
+      runtimeStateDelta?: RuntimeStateDelta;
+      runtimeStateSnapshot?: RuntimeStateSnapshot;
+    };
+    try {
+      const deltaOutput = parseSettlerDeltaOutput(response.content);
+      mergedSettlement = {
+        postSettlement: deltaOutput.postSettlement,
+        runtimeStateDelta: deltaOutput.runtimeStateDelta,
+        updatedState: "",
+        updatedLedger: "",
+        updatedHooks: "",
+        chapterSummary: "",
+        updatedSubplots: "",
+        updatedEmotionalArcs: "",
+        updatedCharacterMatrix: "",
+      };
+    } catch {
+      const settlement = parseSettlementOutput(response.content, params.genreProfile);
+      mergedSettlement = governedControlBlock
+        ? {
+            ...settlement,
+            updatedHooks: mergeTableMarkdownByKey(params.originalHooks, settlement.updatedHooks, [0]),
+            updatedSubplots: settlement.updatedSubplots
+              ? mergeTableMarkdownByKey(params.originalSubplots, settlement.updatedSubplots, [0])
+              : settlement.updatedSubplots,
+            updatedEmotionalArcs: settlement.updatedEmotionalArcs
+              ? mergeTableMarkdownByKey(params.originalEmotionalArcs, settlement.updatedEmotionalArcs, [0, 1])
+              : settlement.updatedEmotionalArcs,
+            updatedCharacterMatrix: settlement.updatedCharacterMatrix
+              ? mergeCharacterMatrixMarkdown(params.originalCharacterMatrix, settlement.updatedCharacterMatrix)
+              : settlement.updatedCharacterMatrix,
+          }
+        : settlement;
+    }
 
     return {
       settlement: mergedSettlement,
@@ -466,13 +508,28 @@ export class WriterAgent extends BaseAgent {
       "",
       output.content,
     ].join("\n");
+    const runtimeStateArtifacts = await this.resolveRuntimeStateArtifactsForOutput(
+      bookDir,
+      output,
+      language,
+    );
 
     const writes: Array<Promise<void>> = [
       writeFile(join(chaptersDir, filename), chapterContent, "utf-8"),
-      writeFile(join(storyDir, "current_state.md"), output.updatedState, "utf-8"),
-      writeFile(join(storyDir, "pending_hooks.md"), output.updatedHooks, "utf-8"),
+      writeFile(join(storyDir, "current_state.md"), runtimeStateArtifacts?.currentStateMarkdown ?? output.updatedState, "utf-8"),
+      writeFile(join(storyDir, "pending_hooks.md"), runtimeStateArtifacts?.hooksMarkdown ?? output.updatedHooks, "utf-8"),
     ];
 
+    if (runtimeStateArtifacts?.chapterSummariesMarkdown) {
+      writes.push(
+        writeFile(join(storyDir, "chapter_summaries.md"), runtimeStateArtifacts.chapterSummariesMarkdown, "utf-8"),
+      );
+    }
+
+    if (runtimeStateArtifacts?.snapshot ?? output.runtimeStateSnapshot) {
+      writes.push(saveRuntimeStateSnapshot(bookDir, runtimeStateArtifacts?.snapshot ?? output.runtimeStateSnapshot!));
+    }
+
     if (numericalSystem) {
       writes.push(
         writeFile(join(storyDir, "particle_ledger.md"), output.updatedLedger, "utf-8"),
@@ -782,7 +839,13 @@ ${overrides}\n`;
     const writes: Array<Promise<void>> = [];
 
     // Append chapter summary to chapter_summaries.md
-    if (output.chapterSummary) {
+    if (!output.runtimeStateDelta && output.updatedChapterSummaries) {
+      writes.push(writeFile(
+        join(storyDir, "chapter_summaries.md"),
+        output.updatedChapterSummaries,
+        "utf-8",
+      ));
+    } else if (!output.runtimeStateDelta && output.chapterSummary) {
       writes.push(this.appendChapterSummary(storyDir, output.chapterSummary, language));
     }
 
@@ -804,6 +867,63 @@ ${overrides}\n`;
     await Promise.all(writes);
   }
 
+  private renderDeltaSummaryRow(delta: RuntimeStateDelta): string {
+    if (!delta.chapterSummary) return "";
+    const summary = delta.chapterSummary;
+    const row = [
+      summary.chapter,
+      summary.title,
+      summary.characters,
+      summary.events,
+      summary.stateChanges,
+      summary.hookActivity,
+      summary.mood,
+      summary.chapterType,
+    ].map((value) => String(value).replace(/\|/g, "\\|").trim()).join(" | ");
+
+    return `| ${row} |`;
+  }
+
+  private async buildRuntimeStateArtifactsIfPresent(
+    bookDir: string,
+    delta: RuntimeStateDelta | undefined,
+    language: "zh" | "en",
+  ): Promise<RuntimeStateArtifacts | null> {
+    if (!delta) return null;
+    return buildRuntimeStateArtifacts({
+      bookDir,
+      delta,
+      language,
+    });
+  }
+
+  private async resolveRuntimeStateArtifactsForOutput(
+    bookDir: string,
+    output: WriteChapterOutput,
+    language: "zh" | "en",
+  ): Promise<RuntimeStateArtifacts | null> {
+    if (!output.runtimeStateDelta) return null;
+    if (
+      output.runtimeStateSnapshot
+      && output.updatedChapterSummaries
+      && output.updatedState
+      && output.updatedHooks
+    ) {
+      return {
+        snapshot: output.runtimeStateSnapshot,
+        currentStateMarkdown: output.updatedState,
+        hooksMarkdown: output.updatedHooks,
+        chapterSummariesMarkdown: output.updatedChapterSummaries,
+      };
+    }
+
+    return buildRuntimeStateArtifacts({
+      bookDir,
+      delta: output.runtimeStateDelta,
+      language,
+    });
+  }
+
   private async appendChapterSummary(
     storyDir: string,
     summary: string,

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

@@ -8,6 +8,32 @@ export { type BookRules, type ParsedBookRules, BookRulesSchema, parseBookRules }
 export { type DetectionHistoryEntry, type DetectionStats } from "./models/detection.js";
 export { type StyleProfile } from "./models/style-profile.js";
 export { type LengthCountingMode, type LengthNormalizeMode, type LengthSpec, type LengthTelemetry, type LengthWarning, LengthCountingModeSchema, LengthNormalizeModeSchema, LengthSpecSchema, LengthTelemetrySchema, LengthWarningSchema } from "./models/length-governance.js";
+export {
+  type RuntimeStateLanguage,
+  type StateManifest,
+  type HookStatus,
+  type HookRecord,
+  type HooksState,
+  type ChapterSummaryRow,
+  type ChapterSummariesState,
+  type CurrentStateFact,
+  type CurrentStateState,
+  type CurrentStatePatch,
+  type HookOps,
+  type RuntimeStateDelta,
+  RuntimeStateLanguageSchema,
+  StateManifestSchema,
+  HookStatusSchema,
+  HookRecordSchema,
+  HooksStateSchema,
+  ChapterSummaryRowSchema,
+  ChapterSummariesStateSchema,
+  CurrentStateFactSchema,
+  CurrentStateStateSchema,
+  CurrentStatePatchSchema,
+  HookOpsSchema,
+  RuntimeStateDeltaSchema,
+} from "./models/runtime-state.js";
 export {
   type ChapterConflict,
   type ChapterIntent,
@@ -59,6 +85,7 @@ export { ChapterAnalyzerAgent, type AnalyzeChapterInput, type AnalyzeChapterOutp
 export { parseWriterOutput, parseCreativeOutput, type ParsedWriterOutput, type CreativeOutput } from "./agents/writer-parser.js";
 export { buildSettlerSystemPrompt, buildSettlerUserPrompt } from "./agents/settler-prompts.js";
 export { parseSettlementOutput, type SettlementOutput } from "./agents/settler-parser.js";
+export { parseSettlerDeltaOutput, type SettlerDeltaOutput } from "./agents/settler-delta-parser.js";
 export { FanficCanonImporter, type FanficCanonOutput } from "./agents/fanfic-canon-importer.js";
 export { getFanficDimensionConfig, FANFIC_DIMENSIONS, type FanficDimensionConfig } from "./agents/fanfic-dimensions.js";
 export { buildFanficCanonSection, buildCharacterVoiceProfiles, buildFanficModeInstructions } from "./agents/fanfic-prompt-sections.js";
@@ -70,6 +97,7 @@ export { extractPOVFromOutline, filterMatrixByPOV, filterHooksByPOV } from "./ut
 export { ConsolidatorAgent } from "./agents/consolidator.js";
 export { MemoryDB, type Fact, type StoredSummary } from "./state/memory-db.js";
 export { StateValidatorAgent } from "./agents/state-validator.js";
+export { loadRuntimeStateSnapshot, buildRuntimeStateArtifacts, saveRuntimeStateSnapshot, loadNarrativeMemorySeed, loadSnapshotCurrentStateFacts, type RuntimeStateArtifacts, type NarrativeMemorySeed } from "./state/runtime-state-store.js";
 export { splitChapters, type SplitChapter } from "./utils/chapter-splitter.js";
 export { countChapterLength, resolveLengthCountingMode, formatLengthCount, buildLengthSpec, isOutsideSoftRange, isOutsideHardRange, chooseNormalizeMode, type LengthLanguage } from "./utils/length-metrics.js";
 export { createLogger, createStderrSink, createJsonLineSink, nullSink, type Logger, type LogSink, type LogLevel, type LogEntry } from "./utils/logger.js";
@@ -84,6 +112,10 @@ export { detectChapter, detectAndRewrite, loadDetectionHistory, type DetectChapt
 
 // State
 export { StateManager } from "./state/manager.js";
+export { bootstrapStructuredStateFromMarkdown } from "./state/state-bootstrap.js";
+export { renderCurrentStateProjection, renderHooksProjection, renderChapterSummariesProjection } from "./state/state-projections.js";
+export { applyRuntimeStateDelta, type RuntimeStateSnapshot } from "./state/state-reducer.js";
+export { validateRuntimeState, type RuntimeStateValidationIssue } from "./state/state-validator.js";
 
 // Notify
 export { dispatchNotification, dispatchWebhookEvent, type NotifyMessage } from "./notify/dispatcher.js";

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

@@ -0,0 +1,110 @@
+import { z } from "zod";
+
+export const RuntimeStateLanguageSchema = z.enum(["zh", "en"]);
+export type RuntimeStateLanguage = z.infer<typeof RuntimeStateLanguageSchema>;
+
+export const StateManifestSchema = z.object({
+  schemaVersion: z.literal(2),
+  language: RuntimeStateLanguageSchema,
+  lastAppliedChapter: z.number().int().min(0),
+  projectionVersion: z.number().int().min(1),
+  migrationWarnings: z.array(z.string()).default([]),
+});
+
+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 HookRecordSchema = z.object({
+  hookId: z.string().min(1),
+  startChapter: z.number().int().min(0),
+  type: z.string().min(1),
+  status: HookStatusSchema,
+  lastAdvancedChapter: z.number().int().min(0),
+  expectedPayoff: z.string().default(""),
+  notes: z.string().default(""),
+});
+
+export type HookRecord = z.infer<typeof HookRecordSchema>;
+
+export const HooksStateSchema = z.object({
+  hooks: z.array(HookRecordSchema).default([]),
+});
+
+export type HooksState = z.infer<typeof HooksStateSchema>;
+
+export const ChapterSummaryRowSchema = z.object({
+  chapter: z.number().int().min(1),
+  title: z.string().min(1),
+  characters: z.string().default(""),
+  events: z.string().default(""),
+  stateChanges: z.string().default(""),
+  hookActivity: z.string().default(""),
+  mood: z.string().default(""),
+  chapterType: z.string().default(""),
+});
+
+export type ChapterSummaryRow = z.infer<typeof ChapterSummaryRowSchema>;
+
+export const ChapterSummariesStateSchema = z.object({
+  rows: z.array(ChapterSummaryRowSchema).default([]),
+});
+
+export type ChapterSummariesState = z.infer<typeof ChapterSummariesStateSchema>;
+
+export const CurrentStateFactSchema = z.object({
+  subject: z.string().min(1),
+  predicate: z.string().min(1),
+  object: z.string().min(1),
+  validFromChapter: z.number().int().min(0),
+  validUntilChapter: z.number().int().min(0).nullable(),
+  sourceChapter: z.number().int().min(0),
+});
+
+export type CurrentStateFact = z.infer<typeof CurrentStateFactSchema>;
+
+export const CurrentStateStateSchema = z.object({
+  chapter: z.number().int().min(0),
+  facts: z.array(CurrentStateFactSchema).default([]),
+});
+
+export type CurrentStateState = z.infer<typeof CurrentStateStateSchema>;
+
+export const CurrentStatePatchSchema = z.object({
+  currentLocation: z.string().optional(),
+  protagonistState: z.string().optional(),
+  currentGoal: z.string().optional(),
+  currentConstraint: z.string().optional(),
+  currentAlliances: z.string().optional(),
+  currentConflict: z.string().optional(),
+});
+
+export type CurrentStatePatch = z.infer<typeof CurrentStatePatchSchema>;
+
+export const HookOpsSchema = z.object({
+  upsert: z.array(HookRecordSchema).default([]),
+  resolve: z.array(z.string().min(1)).default([]),
+  defer: z.array(z.string().min(1)).default([]),
+});
+
+export type HookOps = z.infer<typeof HookOpsSchema>;
+
+const LooseOpSchema = z.record(z.string(), z.unknown());
+
+export const RuntimeStateDeltaSchema = z.object({
+  chapter: z.number().int().min(1),
+  currentStatePatch: CurrentStatePatchSchema.optional(),
+  hookOps: HookOpsSchema.default({
+    upsert: [],
+    resolve: [],
+    defer: [],
+  }),
+  chapterSummary: ChapterSummaryRowSchema.optional(),
+  subplotOps: z.array(LooseOpSchema).default([]),
+  emotionalArcOps: z.array(LooseOpSchema).default([]),
+  characterMatrixOps: z.array(LooseOpSchema).default([]),
+  notes: z.array(z.string()).default([]),
+});
+
+export type RuntimeStateDelta = z.infer<typeof RuntimeStateDeltaSchema>;

+ 7 - 13
packages/core/src/pipeline/runner.ts

@@ -30,7 +30,7 @@ import type { LengthSpec, LengthTelemetry } from "../models/length-governance.js
 import { ChapterIntentSchema, type ContextPackage, type RuleStack } from "../models/input-governance.js";
 import { buildLengthSpec, countChapterLength, formatLengthCount, isOutsideHardRange, isOutsideSoftRange, resolveLengthCountingMode, type LengthLanguage } from "../utils/length-metrics.js";
 import { analyzeLongSpanFatigue } from "../utils/long-span-fatigue.js";
-import { parseChapterSummariesMarkdown, parseCurrentStateFacts, parsePendingHooksMarkdown } from "../utils/memory-retrieval.js";
+import { loadNarrativeMemorySeed, loadSnapshotCurrentStateFacts } from "../state/runtime-state-store.js";
 import { readFile, readdir, writeFile, mkdir, rm } from "node:fs/promises";
 import { join } from "node:path";
 
@@ -1698,6 +1698,7 @@ ${matrix}`,
       rm(join(storyDir, "memory.db"), { force: true }),
       rm(join(storyDir, "memory.db-shm"), { force: true }),
       rm(join(storyDir, "memory.db-wal"), { force: true }),
+      rm(join(storyDir, "state"), { recursive: true, force: true }),
       rm(join(storyDir, "snapshots"), { recursive: true, force: true }),
     ]);
   }
@@ -1875,11 +1876,8 @@ ${matrix}`,
         const activeFacts = new Map<string, { id: number; object: string }>();
 
         for (let chapter = 0; chapter <= uptoChapter; chapter++) {
-          const snapshotPath = join(bookDir, "story", "snapshots", String(chapter), "current_state.md");
-          const markdown = await readFile(snapshotPath, "utf-8").catch(() => "");
-          if (!markdown) continue;
-
-          const snapshotFacts = parseCurrentStateFacts(markdown, chapter);
+          const snapshotFacts = await loadSnapshotCurrentStateFacts(bookDir, chapter);
+          if (snapshotFacts.length === 0) continue;
           const nextFacts = new Map<string, Omit<Fact, "id">>();
 
           for (const fact of snapshotFacts) {
@@ -1923,17 +1921,13 @@ ${matrix}`,
   }
 
   private async rebuildNarrativeMemoryIndex(bookDir: string): Promise<void> {
-    const storyDir = join(bookDir, "story");
-    const [chapterSummaries, pendingHooks] = await Promise.all([
-      readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => ""),
-      readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
-    ]);
+    const memorySeed = await loadNarrativeMemorySeed(bookDir);
 
     const memoryDb = await this.withMemoryIndexRetry(() => {
       const db = new MemoryDB(bookDir);
       try {
-        db.replaceSummaries(parseChapterSummariesMarkdown(chapterSummaries));
-        db.replaceHooks(parsePendingHooksMarkdown(pendingHooks));
+        db.replaceSummaries(memorySeed.summaries);
+        db.replaceHooks(memorySeed.hooks);
         return db;
       } catch (error) {
         db.close();

+ 46 - 0
packages/core/src/state/manager.ts

@@ -2,6 +2,7 @@ import { readFile, writeFile, mkdir, readdir, stat, unlink, open } from "node:fs
 import { join } from "node:path";
 import type { BookConfig } from "../models/book.js";
 import type { ChapterMeta } from "../models/chapter.js";
+import { bootstrapStructuredStateFromMarkdown } from "./state-bootstrap.js";
 
 export class StateManager {
   constructor(private readonly projectRoot: string) {}
@@ -132,6 +133,10 @@ export class StateManager {
     return join(this.booksDir, bookId);
   }
 
+  stateDir(bookId: string): string {
+    return join(this.bookDir(bookId), "story", "state");
+  }
+
   async loadProjectConfig(): Promise<Record<string, unknown>> {
     const configPath = join(this.projectRoot, "inkos.json");
     const raw = await readFile(configPath, "utf-8");
@@ -162,6 +167,13 @@ export class StateManager {
     );
   }
 
+  async ensureRuntimeState(bookId: string, fallbackChapter = 0): Promise<void> {
+    await bootstrapStructuredStateFromMarkdown({
+      bookDir: this.bookDir(bookId),
+      fallbackChapter,
+    });
+  }
+
   async listBooks(): Promise<ReadonlyArray<string>> {
     try {
       const entries = await readdir(this.booksDir);
@@ -230,6 +242,23 @@ export class StateManager {
         }
       }),
     );
+
+    const stateDir = this.stateDir(bookId);
+    const snapshotStateDir = join(snapshotDir, "state");
+    try {
+      const stateFiles = await readdir(stateDir);
+      if (stateFiles.length > 0) {
+        await mkdir(snapshotStateDir, { recursive: true });
+        await Promise.all(
+          stateFiles.map(async (fileName) => {
+            const content = await readFile(join(stateDir, fileName), "utf-8");
+            await writeFile(join(snapshotStateDir, fileName), content, "utf-8");
+          }),
+        );
+      }
+    } catch {
+      // state directory missing — skip
+    }
   }
 
   async restoreState(bookId: string, chapterNumber: number): Promise<boolean> {
@@ -265,6 +294,23 @@ export class StateManager {
         }),
       );
 
+      try {
+        const snapshotStateDir = join(snapshotDir, "state");
+        const stateFiles = await readdir(snapshotStateDir);
+        if (stateFiles.length > 0) {
+          const stateDir = this.stateDir(bookId);
+          await mkdir(stateDir, { recursive: true });
+          await Promise.all(
+            stateFiles.map(async (fileName) => {
+              const content = await readFile(join(snapshotStateDir, fileName), "utf-8");
+              await writeFile(join(stateDir, fileName), content, "utf-8");
+            }),
+          );
+        }
+      } catch {
+        // snapshot structured state missing — skip
+      }
+
       return true;
     } catch {
       return false;

+ 151 - 0
packages/core/src/state/runtime-state-store.ts

@@ -0,0 +1,151 @@
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import {
+  ChapterSummariesStateSchema,
+  CurrentStateStateSchema,
+  HooksStateSchema,
+  StateManifestSchema,
+  type RuntimeStateDelta,
+} from "../models/runtime-state.js";
+import type { Fact, StoredHook, StoredSummary } from "./memory-db.js";
+import { bootstrapStructuredStateFromMarkdown, parseCurrentStateFacts } from "./state-bootstrap.js";
+import { renderChapterSummariesProjection, renderCurrentStateProjection, renderHooksProjection } from "./state-projections.js";
+import { applyRuntimeStateDelta, type RuntimeStateSnapshot } from "./state-reducer.js";
+import { validateRuntimeState } from "./state-validator.js";
+
+export interface RuntimeStateArtifacts {
+  readonly snapshot: RuntimeStateSnapshot;
+  readonly currentStateMarkdown: string;
+  readonly hooksMarkdown: string;
+  readonly chapterSummariesMarkdown: string;
+}
+
+export interface NarrativeMemorySeed {
+  readonly summaries: ReadonlyArray<StoredSummary>;
+  readonly hooks: ReadonlyArray<StoredHook>;
+}
+
+export async function loadRuntimeStateSnapshot(bookDir: string): Promise<RuntimeStateSnapshot> {
+  await bootstrapStructuredStateFromMarkdown({ bookDir });
+  const stateDir = join(bookDir, "story", "state");
+
+  const [manifest, currentState, hooks, chapterSummaries] = await Promise.all([
+    readJson(join(stateDir, "manifest.json"), StateManifestSchema),
+    readJson(join(stateDir, "current_state.json"), CurrentStateStateSchema),
+    readJson(join(stateDir, "hooks.json"), HooksStateSchema),
+    readJson(join(stateDir, "chapter_summaries.json"), ChapterSummariesStateSchema),
+  ]);
+
+  const snapshot = {
+    manifest,
+    currentState,
+    hooks,
+    chapterSummaries,
+  };
+
+  const issues = validateRuntimeState(snapshot);
+  if (issues.length > 0) {
+    const summary = issues
+      .map((issue) => `${issue.code}${issue.path ? `@${issue.path}` : ""}`)
+      .join(", ");
+    throw new Error(`Invalid persisted runtime state: ${summary}`);
+  }
+
+  return snapshot;
+}
+
+export async function buildRuntimeStateArtifacts(params: {
+  readonly bookDir: string;
+  readonly delta: RuntimeStateDelta;
+  readonly language: "zh" | "en";
+}): Promise<RuntimeStateArtifacts> {
+  const snapshot = await loadRuntimeStateSnapshot(params.bookDir);
+  const next = applyRuntimeStateDelta({
+    snapshot,
+    delta: params.delta,
+  });
+
+  return {
+    snapshot: next,
+    currentStateMarkdown: renderCurrentStateProjection(next.currentState, params.language),
+    hooksMarkdown: renderHooksProjection(next.hooks, params.language),
+    chapterSummariesMarkdown: renderChapterSummariesProjection(next.chapterSummaries, params.language),
+  };
+}
+
+export async function saveRuntimeStateSnapshot(
+  bookDir: string,
+  snapshot: RuntimeStateSnapshot,
+): Promise<void> {
+  const stateDir = join(bookDir, "story", "state");
+  await mkdir(stateDir, { recursive: true });
+
+  await Promise.all([
+    writeFile(join(stateDir, "manifest.json"), JSON.stringify(snapshot.manifest, null, 2), "utf-8"),
+    writeFile(join(stateDir, "current_state.json"), JSON.stringify(snapshot.currentState, null, 2), "utf-8"),
+    writeFile(join(stateDir, "hooks.json"), JSON.stringify(snapshot.hooks, null, 2), "utf-8"),
+    writeFile(join(stateDir, "chapter_summaries.json"), JSON.stringify(snapshot.chapterSummaries, null, 2), "utf-8"),
+  ]);
+}
+
+export async function loadNarrativeMemorySeed(bookDir: string): Promise<NarrativeMemorySeed> {
+  const snapshot = await loadRuntimeStateSnapshot(bookDir);
+
+  return {
+    summaries: snapshot.chapterSummaries.rows.map((row) => ({
+      chapter: row.chapter,
+      title: row.title,
+      characters: row.characters,
+      events: row.events,
+      stateChanges: row.stateChanges,
+      hookActivity: row.hookActivity,
+      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,
+    })),
+  };
+}
+
+export async function loadSnapshotCurrentStateFacts(
+  bookDir: string,
+  chapterNumber: number,
+): Promise<ReadonlyArray<Fact>> {
+  const snapshotDir = join(bookDir, "story", "snapshots", String(chapterNumber));
+  const structuredState = await readJsonOrNull(
+    join(snapshotDir, "state", "current_state.json"),
+    CurrentStateStateSchema,
+  );
+  if (structuredState) {
+    return structuredState.facts;
+  }
+
+  const markdown = await readFile(join(snapshotDir, "current_state.md"), "utf-8").catch(() => "");
+  return parseCurrentStateFacts(markdown, chapterNumber);
+}
+
+async function readJson<T>(
+  path: string,
+  schema: { parse(value: unknown): T },
+): Promise<T> {
+  const raw = await readFile(path, "utf-8");
+  return schema.parse(JSON.parse(raw));
+}
+
+async function readJsonOrNull<T>(
+  path: string,
+  schema: { parse(value: unknown): T },
+): Promise<T | null> {
+  try {
+    return await readJson(path, schema);
+  } catch {
+    return null;
+  }
+}

+ 443 - 0
packages/core/src/state/state-bootstrap.ts

@@ -0,0 +1,443 @@
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import {
+  ChapterSummariesStateSchema,
+  CurrentStateStateSchema,
+  HooksStateSchema,
+  StateManifestSchema,
+  type ChapterSummariesState,
+  type CurrentStateState,
+  type HookStatus,
+  type StateManifest,
+} from "../models/runtime-state.js";
+import type { Fact, StoredHook, StoredSummary } from "./memory-db.js";
+
+export interface BootstrapStructuredStateResult {
+  readonly createdFiles: ReadonlyArray<string>;
+  readonly warnings: ReadonlyArray<string>;
+  readonly manifest: StateManifest;
+}
+
+export async function bootstrapStructuredStateFromMarkdown(params: {
+  readonly bookDir: string;
+  readonly fallbackChapter?: number;
+}): Promise<BootstrapStructuredStateResult> {
+  const storyDir = join(params.bookDir, "story");
+  const stateDir = join(storyDir, "state");
+  const manifestPath = join(stateDir, "manifest.json");
+  const currentStatePath = join(stateDir, "current_state.json");
+  const hooksPath = join(stateDir, "hooks.json");
+  const summariesPath = join(stateDir, "chapter_summaries.json");
+
+  await mkdir(stateDir, { recursive: true });
+
+  const createdFiles: string[] = [];
+  const warnings: string[] = [];
+  const existingManifest = await loadJsonIfValid(manifestPath, StateManifestSchema, warnings, "manifest.json");
+  const language = existingManifest?.language ?? await resolveRuntimeLanguage(params.bookDir);
+
+  const summariesState = await loadOrBootstrapSummaries({
+    storyDir,
+    statePath: summariesPath,
+    createdFiles,
+    warnings,
+  });
+  const hooksState = await loadOrBootstrapHooks({
+    storyDir,
+    statePath: hooksPath,
+    createdFiles,
+    warnings,
+  });
+  const inferredFallbackChapter = Math.max(
+    params.fallbackChapter ?? 0,
+    maxSummaryChapter(summariesState),
+    maxHookChapter(hooksState.hooks),
+  );
+  const currentState = await loadOrBootstrapCurrentState({
+    storyDir,
+    statePath: currentStatePath,
+    fallbackChapter: inferredFallbackChapter,
+    createdFiles,
+    warnings,
+  });
+
+  const manifest = StateManifestSchema.parse({
+    schemaVersion: 2,
+    language,
+    lastAppliedChapter: Math.max(
+      existingManifest?.lastAppliedChapter ?? 0,
+      currentState.chapter,
+      maxSummaryChapter(summariesState),
+      maxHookChapter(hooksState.hooks),
+    ),
+    projectionVersion: existingManifest?.projectionVersion ?? 1,
+    migrationWarnings: uniqueStrings([
+      ...(existingManifest?.migrationWarnings ?? []),
+      ...warnings,
+    ]),
+  });
+
+  await writeFile(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
+  if (!existingManifest) {
+    createdFiles.push("manifest.json");
+  }
+
+  return {
+    createdFiles,
+    warnings: manifest.migrationWarnings,
+    manifest,
+  };
+}
+
+export function parseChapterSummariesMarkdown(markdown: string): StoredSummary[] {
+  const rows = parseMarkdownTableRows(markdown)
+    .filter((row) => /^\d+$/.test(row[0] ?? ""));
+
+  return rows.map((row) => ({
+    chapter: parseInt(row[0]!, 10),
+    title: row[1] ?? "",
+    characters: row[2] ?? "",
+    events: row[3] ?? "",
+    stateChanges: row[4] ?? "",
+    hookActivity: row[5] ?? "",
+    mood: row[6] ?? "",
+    chapterType: row[7] ?? "",
+  }));
+}
+
+export function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
+  const tableRows = parseMarkdownTableRows(markdown)
+    .filter((row) => (row[0] ?? "").toLowerCase() !== "hook_id");
+
+  if (tableRows.length > 0) {
+    return tableRows
+      .filter((row) => (row[0] ?? "").length > 0)
+      .map((row) => ({
+        hookId: row[0] ?? "",
+        startChapter: parseInteger(row[1]),
+        type: row[2] ?? "",
+        status: row[3] ?? "open",
+        lastAdvancedChapter: parseInteger(row[4]),
+        expectedPayoff: row[5] ?? "",
+        notes: row[6] ?? "",
+      }));
+  }
+
+  return markdown
+    .split("\n")
+    .map((line) => line.trim())
+    .filter((line) => line.startsWith("-"))
+    .map((line) => line.replace(/^-\s*/, ""))
+    .filter(Boolean)
+    .map((line, index) => ({
+      hookId: `hook-${index + 1}`,
+      startChapter: 0,
+      type: "unspecified",
+      status: "open",
+      lastAdvancedChapter: 0,
+      expectedPayoff: "",
+      notes: line,
+    }));
+}
+
+export function parseCurrentStateFacts(
+  markdown: string,
+  fallbackChapter: number,
+): Fact[] {
+  return parseCurrentStateStateMarkdown(markdown, fallbackChapter, []).facts;
+}
+
+async function loadOrBootstrapCurrentState(params: {
+  readonly storyDir: string;
+  readonly statePath: string;
+  readonly fallbackChapter: number;
+  readonly createdFiles: string[];
+  readonly warnings: string[];
+}): Promise<CurrentStateState> {
+  const existing = await loadJsonIfValid(
+    params.statePath,
+    CurrentStateStateSchema,
+    params.warnings,
+    "current_state.json",
+  );
+  if (existing) {
+    return existing;
+  }
+
+  const markdown = await readFile(join(params.storyDir, "current_state.md"), "utf-8").catch(() => "");
+  const currentState = parseCurrentStateStateMarkdown(markdown, params.fallbackChapter, params.warnings);
+  await writeFile(params.statePath, JSON.stringify(currentState, null, 2), "utf-8");
+  params.createdFiles.push("current_state.json");
+  return currentState;
+}
+
+async function loadOrBootstrapHooks(params: {
+  readonly storyDir: string;
+  readonly statePath: string;
+  readonly createdFiles: string[];
+  readonly warnings: string[];
+}) {
+  const existing = await loadJsonIfValid(
+    params.statePath,
+    HooksStateSchema,
+    params.warnings,
+    "hooks.json",
+  );
+  if (existing) {
+    return existing;
+  }
+
+  const markdown = await readFile(join(params.storyDir, "pending_hooks.md"), "utf-8").catch(() => "");
+  const hooksState = parsePendingHooksStateMarkdown(markdown, params.warnings);
+  await writeFile(params.statePath, JSON.stringify(hooksState, null, 2), "utf-8");
+  params.createdFiles.push("hooks.json");
+  return hooksState;
+}
+
+async function loadOrBootstrapSummaries(params: {
+  readonly storyDir: string;
+  readonly statePath: string;
+  readonly createdFiles: string[];
+  readonly warnings: string[];
+}): Promise<ChapterSummariesState> {
+  const existing = await loadJsonIfValid(
+    params.statePath,
+    ChapterSummariesStateSchema,
+    params.warnings,
+    "chapter_summaries.json",
+  );
+  if (existing) {
+    return existing;
+  }
+
+  const markdown = await readFile(join(params.storyDir, "chapter_summaries.md"), "utf-8").catch(() => "");
+  const summariesState = ChapterSummariesStateSchema.parse({
+    rows: parseChapterSummariesMarkdown(markdown),
+  });
+  await writeFile(params.statePath, JSON.stringify(summariesState, null, 2), "utf-8");
+  params.createdFiles.push("chapter_summaries.json");
+  return summariesState;
+}
+
+function parsePendingHooksStateMarkdown(markdown: string, warnings: string[]) {
+  const tableRows = parseMarkdownTableRows(markdown)
+    .filter((row) => (row[0] ?? "").toLowerCase() !== "hook_id");
+
+  if (tableRows.length > 0) {
+    return HooksStateSchema.parse({
+      hooks: tableRows
+        .filter((row) => (row[0] ?? "").length > 0)
+        .map((row) => {
+          const hookId = row[0] ?? "";
+          return {
+            hookId,
+            startChapter: parseIntegerWithWarning(row[1], warnings, `${hookId}:startChapter`),
+            type: row[2] ?? "unspecified",
+            status: normalizeHookStatus(row[3], warnings, hookId),
+            lastAdvancedChapter: parseIntegerWithWarning(row[4], warnings, `${hookId}:lastAdvancedChapter`),
+            expectedPayoff: row[5] ?? "",
+            notes: row[6] ?? "",
+          };
+        }),
+    });
+  }
+
+  return HooksStateSchema.parse({
+    hooks: markdown
+      .split("\n")
+      .map((line) => line.trim())
+      .filter((line) => line.startsWith("-"))
+      .map((line) => line.replace(/^-\s*/, ""))
+      .filter(Boolean)
+      .map((line, index) => ({
+        hookId: `hook-${index + 1}`,
+        startChapter: 0,
+        type: "unspecified",
+        status: "open" as HookStatus,
+        lastAdvancedChapter: 0,
+        expectedPayoff: "",
+        notes: line,
+      })),
+  });
+}
+
+function parseCurrentStateStateMarkdown(
+  markdown: string,
+  fallbackChapter: number,
+  warnings: string[],
+): CurrentStateState {
+  const tableRows = parseMarkdownTableRows(markdown);
+  const fieldValueRows = tableRows
+    .filter((row) => row.length >= 2)
+    .filter((row) => !isStateTableHeaderRow(row));
+
+  if (fieldValueRows.length > 0) {
+    const chapterFromTable = fieldValueRows.find((row) => isCurrentChapterLabel(row[0] ?? ""));
+    const stateChapter = parseIntegerWithFallback(
+      chapterFromTable?.[1],
+      fallbackChapter,
+      warnings,
+      "current_state:chapter",
+    );
+
+    return CurrentStateStateSchema.parse({
+      chapter: stateChapter,
+      facts: fieldValueRows
+        .filter((row) => !isCurrentChapterLabel(row[0] ?? ""))
+        .flatMap((row): Fact[] => {
+          const label = (row[0] ?? "").trim();
+          const value = (row[1] ?? "").trim();
+          if (!label || !value) return [];
+
+          return [{
+            subject: inferFactSubject(label),
+            predicate: label,
+            object: value,
+            validFromChapter: stateChapter,
+            validUntilChapter: null,
+            sourceChapter: stateChapter,
+          }];
+        }),
+    });
+  }
+
+  const bulletFacts = markdown
+    .split("\n")
+    .map((line) => line.trim())
+    .filter((line) => line.startsWith("-"))
+    .map((line) => line.replace(/^-\s*/, ""))
+    .filter(Boolean);
+
+  return CurrentStateStateSchema.parse({
+    chapter: Math.max(0, fallbackChapter),
+    facts: bulletFacts.map((line, index) => ({
+      subject: "current_state",
+      predicate: `note_${index + 1}`,
+      object: line,
+      validFromChapter: Math.max(0, fallbackChapter),
+      validUntilChapter: null,
+      sourceChapter: Math.max(0, fallbackChapter),
+    })),
+  });
+}
+
+async function resolveRuntimeLanguage(bookDir: string): Promise<"zh" | "en"> {
+  try {
+    const raw = await readFile(join(bookDir, "book.json"), "utf-8");
+    const parsed = JSON.parse(raw) as { language?: unknown };
+    return parsed.language === "zh" ? "zh" : "en";
+  } catch {
+    return "en";
+  }
+}
+
+async function loadJsonIfValid<T>(
+  path: string,
+  schema: { parse(value: unknown): T },
+  warnings: string[],
+  fileLabel: string,
+): Promise<T | null> {
+  try {
+    const raw = await readFile(path, "utf-8");
+    return schema.parse(JSON.parse(raw));
+  } catch (error) {
+    const message = String(error);
+    if (!/ENOENT/.test(message)) {
+      appendWarning(warnings, `${fileLabel} invalid, rebuilt from markdown`);
+    }
+    return null;
+  }
+}
+
+function maxSummaryChapter(state: ChapterSummariesState): number {
+  return state.rows.reduce((max, row) => Math.max(max, row.chapter), 0);
+}
+
+function maxHookChapter(hooks: ReadonlyArray<StoredHook>): number {
+  return hooks.reduce(
+    (max, hook) => Math.max(max, hook.startChapter, hook.lastAdvancedChapter),
+    0,
+  );
+}
+
+function normalizeHookStatus(value: string | undefined, warnings: string[], hookId: string): HookStatus {
+  const normalized = (value ?? "").trim().toLowerCase();
+  if (!normalized) return "open";
+  if (/(resolved|closed|done|已回收|回收|完成)/i.test(normalized)) return "resolved";
+  if (/(deferred|paused|hold|搁置|延后|延期)/i.test(normalized)) return "deferred";
+  if (/(progress|active|推进|进行中)/i.test(normalized)) return "progressing";
+  if (/(open|pending|待定|未回收)/i.test(normalized)) return "open";
+  appendWarning(warnings, `${hookId}:status normalized from "${value ?? ""}" to "open"`);
+  return "open";
+}
+
+function parseIntegerWithWarning(value: string | undefined, warnings: string[], fieldLabel: string): number {
+  if (!value) return 0;
+  const match = value.match(/\d+/);
+  if (!match) {
+    appendWarning(warnings, `${fieldLabel} normalized from "${value}" to 0`);
+    return 0;
+  }
+  return parseInt(match[0], 10);
+}
+
+function parseIntegerWithFallback(
+  value: string | undefined,
+  fallback: number,
+  warnings: string[],
+  fieldLabel: string,
+): number {
+  if (!value) return Math.max(0, fallback);
+  const match = value.match(/\d+/);
+  if (!match) {
+    appendWarning(warnings, `${fieldLabel} normalized from "${value}" to ${Math.max(0, fallback)}`);
+    return Math.max(0, fallback);
+  }
+  return parseInt(match[0], 10);
+}
+
+function parseMarkdownTableRows(markdown: string): string[][] {
+  return markdown
+    .split("\n")
+    .map((line) => line.trim())
+    .filter((line) => line.startsWith("|"))
+    .filter((line) => !line.includes("---"))
+    .map((line) => line.split("|").slice(1, -1).map((cell) => cell.trim()))
+    .filter((cells) => cells.some(Boolean));
+}
+
+function isStateTableHeaderRow(row: ReadonlyArray<string>): boolean {
+  const first = (row[0] ?? "").trim().toLowerCase();
+  const second = (row[1] ?? "").trim().toLowerCase();
+  return (first === "字段" && second === "值") || (first === "field" && second === "value");
+}
+
+function isCurrentChapterLabel(label: string): boolean {
+  return /^(当前章节|current chapter)$/i.test(label.trim());
+}
+
+function inferFactSubject(label: string): string {
+  if (/^(当前位置|current location)$/i.test(label)) return "protagonist";
+  if (/^(主角状态|protagonist state)$/i.test(label)) return "protagonist";
+  if (/^(当前目标|current goal)$/i.test(label)) return "protagonist";
+  if (/^(当前限制|current constraint)$/i.test(label)) return "protagonist";
+  if (/^(当前敌我|current alliances|current relationships)$/i.test(label)) return "protagonist";
+  if (/^(当前冲突|current conflict)$/i.test(label)) return "protagonist";
+  return "current_state";
+}
+
+function parseInteger(value: string | undefined): number {
+  if (!value) return 0;
+  const match = value.match(/\d+/);
+  return match ? parseInt(match[0], 10) : 0;
+}
+
+function appendWarning(warnings: string[], warning: string): void {
+  if (!warnings.includes(warning)) {
+    warnings.push(warning);
+  }
+}
+
+function uniqueStrings(values: ReadonlyArray<string>): string[] {
+  return [...new Set(values.filter((value) => value.trim().length > 0))];
+}

+ 203 - 0
packages/core/src/state/state-projections.ts

@@ -0,0 +1,203 @@
+import type {
+  ChapterSummariesState,
+  CurrentStateState,
+  HooksState,
+} from "../models/runtime-state.js";
+
+export function renderHooksProjection(
+  state: HooksState,
+  language: "zh" | "en" = "zh",
+): string {
+  const title = language === "en" ? "# Pending Hooks" : "# 伏笔池";
+  const headers = language === "en"
+    ? [
+      "| hook_id | start_chapter | type | status | last_advanced_chapter | expected_payoff | notes |",
+      "| --- | --- | --- | --- | --- | --- | --- |",
+    ]
+    : [
+      "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
+      "| --- | --- | --- | --- | --- | --- | --- |",
+    ];
+
+  const rows = [...state.hooks]
+    .sort((left, right) => (
+      left.startChapter - right.startChapter
+      || left.lastAdvancedChapter - right.lastAdvancedChapter
+      || left.hookId.localeCompare(right.hookId)
+    ))
+    .map((hook) => `| ${
+      [
+        hook.hookId,
+        hook.startChapter,
+        hook.type,
+        hook.status,
+        hook.lastAdvancedChapter,
+        hook.expectedPayoff,
+        hook.notes,
+      ].map(escapeTableCell).join(" | ")
+    } |`);
+
+  return [title, "", ...headers, ...rows, ""].join("\n");
+}
+
+export function renderChapterSummariesProjection(
+  state: ChapterSummariesState,
+  language: "zh" | "en" = "zh",
+): string {
+  const title = language === "en" ? "# Chapter Summaries" : "# 章节摘要";
+  const headers = language === "en"
+    ? [
+      "| Chapter | Title | Characters | Key Events | State Changes | Hook Activity | Mood | Chapter Type |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+    ]
+    : [
+      "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+    ];
+
+  const rows = [...state.rows]
+    .sort((left, right) => left.chapter - right.chapter)
+    .map((summary) => `| ${
+      [
+        summary.chapter,
+        summary.title,
+        summary.characters,
+        summary.events,
+        summary.stateChanges,
+        summary.hookActivity,
+        summary.mood,
+        summary.chapterType,
+      ].map(escapeTableCell).join(" | ")
+    } |`);
+
+  return [title, "", ...headers, ...rows, ""].join("\n");
+}
+
+export function renderCurrentStateProjection(
+  state: CurrentStateState,
+  language: "zh" | "en" = "zh",
+): string {
+  const layout = language === "en"
+    ? {
+      title: "# Current State",
+      tableHeader: "| Field | Value |",
+      labels: {
+        chapter: "Current Chapter",
+        location: "Current Location",
+        protagonistState: "Protagonist State",
+        goal: "Current Goal",
+        constraint: "Current Constraint",
+        alliances: "Current Alliances",
+        conflict: "Current Conflict",
+      },
+      placeholders: "(not set)",
+      additionalTitle: "## Additional State",
+    }
+    : {
+      title: "# 当前状态",
+      tableHeader: "| 字段 | 值 |",
+      labels: {
+        chapter: "当前章节",
+        location: "当前位置",
+        protagonistState: "主角状态",
+        goal: "当前目标",
+        constraint: "当前限制",
+        alliances: "当前敌我",
+        conflict: "当前冲突",
+      },
+      placeholders: "(未设定)",
+      additionalTitle: "## 其他状态",
+    };
+
+  const slots = [
+    {
+      label: layout.labels.location,
+      aliases: ["Current Location", "当前位置"],
+    },
+    {
+      label: layout.labels.protagonistState,
+      aliases: ["Protagonist State", "主角状态"],
+    },
+    {
+      label: layout.labels.goal,
+      aliases: ["Current Goal", "当前目标"],
+    },
+    {
+      label: layout.labels.constraint,
+      aliases: ["Current Constraint", "当前限制"],
+    },
+    {
+      label: layout.labels.alliances,
+      aliases: ["Current Alliances", "Current Relationships", "当前敌我"],
+    },
+    {
+      label: layout.labels.conflict,
+      aliases: ["Current Conflict", "当前冲突"],
+    },
+  ] as const;
+
+  const knownPredicates = new Set(
+    slots.flatMap((slot) => slot.aliases.map(normalizePredicate)),
+  );
+  const lines = [
+    layout.title,
+    "",
+    layout.tableHeader,
+    "| --- | --- |",
+    `| ${layout.labels.chapter} | ${escapeTableCell(state.chapter)} |`,
+    ...slots.map((slot) => {
+      const value = findFactValue(state, slot.aliases) ?? layout.placeholders;
+      return `| ${slot.label} | ${escapeTableCell(value)} |`;
+    }),
+  ];
+
+  const additionalFacts = [...state.facts]
+    .filter((fact) => !knownPredicates.has(normalizePredicate(fact.predicate)))
+    .sort((left, right) => compareAdditionalFacts(left.predicate, right.predicate));
+
+  if (additionalFacts.length === 0) {
+    return [...lines, ""].join("\n");
+  }
+
+  return [
+    ...lines,
+    "",
+    layout.additionalTitle,
+    ...additionalFacts.map((fact) => renderAdditionalFact(fact.predicate, fact.object)),
+    "",
+  ].join("\n");
+}
+
+function findFactValue(
+  state: CurrentStateState,
+  aliases: ReadonlyArray<string>,
+): string | undefined {
+  const aliasSet = new Set(aliases.map(normalizePredicate));
+  return state.facts.find((fact) => aliasSet.has(normalizePredicate(fact.predicate)))?.object;
+}
+
+function renderAdditionalFact(predicate: string, object: string): string {
+  if (/^note_\d+$/i.test(predicate)) {
+    return `- ${object}`;
+  }
+  return `- ${predicate}: ${object}`;
+}
+
+function compareAdditionalFacts(left: string, right: string): number {
+  const leftNote = left.match(/^note_(\d+)$/i);
+  const rightNote = right.match(/^note_(\d+)$/i);
+  if (leftNote && rightNote) {
+    return Number.parseInt(leftNote[1] ?? "0", 10) - Number.parseInt(rightNote[1] ?? "0", 10);
+  }
+  if (leftNote) return -1;
+  if (rightNote) return 1;
+  return left.localeCompare(right);
+}
+
+function normalizePredicate(value: string): string {
+  return value.trim().toLowerCase();
+}
+
+function escapeTableCell(value: string | number): string {
+  return String(value).replace(/\|/g, "\\|").trim();
+}

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

@@ -0,0 +1,192 @@
+import {
+  ChapterSummariesStateSchema,
+  CurrentStateStateSchema,
+  HooksStateSchema,
+  RuntimeStateDeltaSchema,
+  StateManifestSchema,
+  type ChapterSummariesState,
+  type CurrentStateState,
+  type HooksState,
+  type RuntimeStateDelta,
+  type StateManifest,
+} from "../models/runtime-state.js";
+import { validateRuntimeState } from "./state-validator.js";
+
+export interface RuntimeStateSnapshot {
+  readonly manifest: StateManifest;
+  readonly currentState: CurrentStateState;
+  readonly hooks: HooksState;
+  readonly chapterSummaries: ChapterSummariesState;
+}
+
+export function applyRuntimeStateDelta(params: {
+  readonly snapshot: RuntimeStateSnapshot;
+  readonly delta: RuntimeStateDelta;
+}): RuntimeStateSnapshot {
+  const snapshot = {
+    manifest: StateManifestSchema.parse(params.snapshot.manifest),
+    currentState: CurrentStateStateSchema.parse(params.snapshot.currentState),
+    hooks: HooksStateSchema.parse(params.snapshot.hooks),
+    chapterSummaries: ChapterSummariesStateSchema.parse(params.snapshot.chapterSummaries),
+  };
+  const delta = RuntimeStateDeltaSchema.parse(params.delta);
+
+  if (delta.chapter <= snapshot.manifest.lastAppliedChapter) {
+    throw new Error(`delta chapter ${delta.chapter} goes backwards`);
+  }
+
+  if (delta.chapterSummary && delta.chapterSummary.chapter !== delta.chapter) {
+    throw new Error(`chapter summary ${delta.chapterSummary.chapter} does not match delta chapter ${delta.chapter}`);
+  }
+
+  if (
+    delta.chapterSummary
+    && snapshot.chapterSummaries.rows.some((row) => row.chapter === delta.chapterSummary?.chapter)
+  ) {
+    throw new Error(`duplicate summary row for chapter ${delta.chapterSummary.chapter}`);
+  }
+
+  const hooks = applyHookOps(snapshot.hooks, delta);
+  const currentState = applyCurrentStatePatch(
+    snapshot.currentState,
+    snapshot.manifest.language,
+    delta,
+  );
+  const chapterSummaries = applySummaryDelta(snapshot.chapterSummaries, delta);
+
+  const next: RuntimeStateSnapshot = {
+    manifest: {
+      ...snapshot.manifest,
+      lastAppliedChapter: delta.chapter,
+    },
+    currentState,
+    hooks,
+    chapterSummaries,
+  };
+
+  const issues = validateRuntimeState(next);
+  if (issues.length > 0) {
+    throw new Error(issues.map((issue) => `${issue.code}: ${issue.message}`).join("; "));
+  }
+
+  return next;
+}
+
+function applyHookOps(hooksState: HooksState, delta: RuntimeStateDelta): HooksState {
+  const hooksById = new Map(hooksState.hooks.map((hook) => [hook.hookId, { ...hook }]));
+
+  for (const hook of delta.hookOps.upsert) {
+    hooksById.set(hook.hookId, { ...hook });
+  }
+
+  for (const hookId of delta.hookOps.resolve) {
+    const existing = hooksById.get(hookId);
+    if (!existing) {
+      throw new Error(`unknown hook: ${hookId}`);
+    }
+    hooksById.set(hookId, {
+      ...existing,
+      status: "resolved",
+      lastAdvancedChapter: Math.max(existing.lastAdvancedChapter, delta.chapter),
+    });
+  }
+
+  for (const hookId of delta.hookOps.defer) {
+    const existing = hooksById.get(hookId);
+    if (!existing) {
+      throw new Error(`unknown hook: ${hookId}`);
+    }
+    hooksById.set(hookId, {
+      ...existing,
+      status: "deferred",
+      lastAdvancedChapter: Math.max(existing.lastAdvancedChapter, delta.chapter),
+    });
+  }
+
+  return {
+    hooks: [...hooksById.values()].sort((left, right) => (
+      left.startChapter - right.startChapter
+      || left.lastAdvancedChapter - right.lastAdvancedChapter
+      || left.hookId.localeCompare(right.hookId)
+    )),
+  };
+}
+
+function applyCurrentStatePatch(
+  currentState: CurrentStateState,
+  language: "zh" | "en",
+  delta: RuntimeStateDelta,
+): CurrentStateState {
+  if (!delta.currentStatePatch) {
+    return {
+      chapter: delta.chapter,
+      facts: [...currentState.facts],
+    };
+  }
+
+  const nextFacts = [...currentState.facts];
+  const labels = language === "en"
+    ? {
+      currentLocation: ["Current Location", "当前位置"],
+      protagonistState: ["Protagonist State", "主角状态"],
+      currentGoal: ["Current Goal", "当前目标"],
+      currentConstraint: ["Current Constraint", "当前限制"],
+      currentAlliances: ["Current Alliances", "Current Relationships", "当前敌我"],
+      currentConflict: ["Current Conflict", "当前冲突"],
+    }
+    : {
+      currentLocation: ["当前位置", "Current Location"],
+      protagonistState: ["主角状态", "Protagonist State"],
+      currentGoal: ["当前目标", "Current Goal"],
+      currentConstraint: ["当前限制", "Current Constraint"],
+      currentAlliances: ["当前敌我", "Current Alliances", "Current Relationships"],
+      currentConflict: ["当前冲突", "Current Conflict"],
+    };
+
+  for (const [patchKey, aliases] of Object.entries(labels) as Array<[
+    keyof typeof labels,
+    string[],
+  ]>) {
+    const value = delta.currentStatePatch[patchKey];
+    if (value === undefined) continue;
+
+    for (let index = nextFacts.length - 1; index >= 0; index -= 1) {
+      const predicate = nextFacts[index]?.predicate ?? "";
+      if (aliases.some((alias) => alias.toLowerCase() === predicate.toLowerCase())) {
+        nextFacts.splice(index, 1);
+      }
+    }
+
+    nextFacts.push({
+      subject: "protagonist",
+      predicate: aliases[0]!,
+      object: value,
+      validFromChapter: delta.chapter,
+      validUntilChapter: null,
+      sourceChapter: delta.chapter,
+    });
+  }
+
+  return {
+    chapter: delta.chapter,
+    facts: nextFacts.sort((left, right) => (
+      left.predicate.localeCompare(right.predicate)
+      || left.object.localeCompare(right.object)
+    )),
+  };
+}
+
+function applySummaryDelta(
+  state: ChapterSummariesState,
+  delta: RuntimeStateDelta,
+): ChapterSummariesState {
+  if (!delta.chapterSummary) {
+    return {
+      rows: [...state.rows].sort((left, right) => left.chapter - right.chapter),
+    };
+  }
+
+  return {
+    rows: [...state.rows, delta.chapterSummary].sort((left, right) => left.chapter - right.chapter),
+  };
+}

+ 107 - 0
packages/core/src/state/state-validator.ts

@@ -0,0 +1,107 @@
+import {
+  ChapterSummariesStateSchema,
+  CurrentStateStateSchema,
+  HooksStateSchema,
+  StateManifestSchema,
+} from "../models/runtime-state.js";
+
+export interface RuntimeStateValidationIssue {
+  readonly code: string;
+  readonly message: string;
+  readonly path?: string;
+}
+
+export function validateRuntimeState(input: {
+  readonly manifest: unknown;
+  readonly currentState: unknown;
+  readonly hooks: unknown;
+  readonly chapterSummaries: unknown;
+}): RuntimeStateValidationIssue[] {
+  const issues: RuntimeStateValidationIssue[] = [];
+
+  const manifest = parseOrIssue(
+    StateManifestSchema,
+    input.manifest,
+    issues,
+    "invalid_manifest",
+    "manifest",
+  );
+  const currentState = parseOrIssue(
+    CurrentStateStateSchema,
+    input.currentState,
+    issues,
+    "invalid_current_state",
+    "currentState",
+  );
+  const hooks = parseOrIssue(
+    HooksStateSchema,
+    input.hooks,
+    issues,
+    "invalid_hooks_state",
+    "hooks",
+  );
+  const chapterSummaries = parseOrIssue(
+    ChapterSummariesStateSchema,
+    input.chapterSummaries,
+    issues,
+    "invalid_chapter_summaries_state",
+    "chapterSummaries",
+  );
+
+  if (hooks) {
+    const seen = new Set<string>();
+    for (const hook of hooks.hooks) {
+      if (seen.has(hook.hookId)) {
+        issues.push({
+          code: "duplicate_hook_id",
+          message: `duplicate hook id: ${hook.hookId}`,
+          path: `hooks.${hook.hookId}`,
+        });
+      }
+      seen.add(hook.hookId);
+    }
+  }
+
+  if (chapterSummaries) {
+    const seen = new Set<number>();
+    for (const row of chapterSummaries.rows) {
+      if (seen.has(row.chapter)) {
+        issues.push({
+          code: "duplicate_summary_chapter",
+          message: `duplicate summary chapter: ${row.chapter}`,
+          path: `chapterSummaries.${row.chapter}`,
+        });
+      }
+      seen.add(row.chapter);
+    }
+  }
+
+  if (manifest && currentState && currentState.chapter > manifest.lastAppliedChapter) {
+    issues.push({
+      code: "current_state_ahead_of_manifest",
+      message: `current state chapter ${currentState.chapter} exceeds manifest ${manifest.lastAppliedChapter}`,
+      path: "currentState.chapter",
+    });
+  }
+
+  return issues;
+}
+
+function parseOrIssue<T>(
+  schema: { parse(value: unknown): T },
+  value: unknown,
+  issues: RuntimeStateValidationIssue[],
+  code: string,
+  path: string,
+): T | undefined {
+  try {
+    return schema.parse(value);
+  } catch (error) {
+    issues.push({
+      code,
+      message: String(error),
+      path,
+    });
+    return undefined;
+  }
+}

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

@@ -1,6 +1,12 @@
 import { readFile } from "node:fs/promises";
 import { join } from "node:path";
+import {
+  ChapterSummariesStateSchema,
+  CurrentStateStateSchema,
+  HooksStateSchema,
+} from "../models/runtime-state.js";
 import { MemoryDB, type Fact, type StoredHook, type StoredSummary } from "../state/memory-db.js";
+import { bootstrapStructuredStateFromMarkdown } from "../state/state-bootstrap.js";
 
 export interface MemorySelection {
   readonly summaries: ReadonlyArray<StoredSummary>;
@@ -24,13 +30,30 @@ export async function retrieveMemorySelection(params: {
   readonly mustKeep?: ReadonlyArray<string>;
 }): Promise<MemorySelection> {
   const storyDir = join(params.bookDir, "story");
-  const [currentStateMarkdown, volumeSummariesMarkdown] = await Promise.all([
+  const stateDir = join(storyDir, "state");
+  const fallbackChapter = Math.max(0, params.chapterNumber - 1);
+
+  await bootstrapStructuredStateFromMarkdown({
+    bookDir: params.bookDir,
+    fallbackChapter,
+  }).catch(() => undefined);
+
+  const [
+    currentStateMarkdown,
+    volumeSummariesMarkdown,
+    structuredCurrentState,
+    structuredHooks,
+    structuredSummaries,
+  ] = await Promise.all([
     readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
     readFile(join(storyDir, "volume_summaries.md"), "utf-8").catch(() => ""),
+    readStructuredState(join(stateDir, "current_state.json"), CurrentStateStateSchema),
+    readStructuredState(join(stateDir, "hooks.json"), HooksStateSchema),
+    readStructuredState(join(stateDir, "chapter_summaries.json"), ChapterSummariesStateSchema),
   ]);
-  const facts = parseCurrentStateFacts(
+  const facts = structuredCurrentState?.facts ?? parseCurrentStateFacts(
     currentStateMarkdown,
-    Math.max(0, params.chapterNumber - 1),
+    fallbackChapter,
   );
   const queryTerms = extractQueryTerms(
     params.goal,
@@ -46,15 +69,17 @@ export async function retrieveMemorySelection(params: {
   if (memoryDb) {
     try {
       if (memoryDb.getChapterCount() === 0) {
-        const summariesMarkdown = await readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => "");
-        const summaries = parseChapterSummariesMarkdown(summariesMarkdown);
+        const summaries = structuredSummaries?.rows ?? parseChapterSummariesMarkdown(
+          await readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => ""),
+        );
         if (summaries.length > 0) {
           memoryDb.replaceSummaries(summaries);
         }
       }
       if (memoryDb.getActiveHooks().length === 0) {
-        const hooksMarkdown = await readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => "");
-        const hooks = parsePendingHooksMarkdown(hooksMarkdown);
+        const hooks = structuredHooks?.hooks ?? parsePendingHooksMarkdown(
+          await readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
+        );
         if (hooks.length > 0) {
           memoryDb.replaceHooks(hooks);
         }
@@ -69,7 +94,7 @@ export async function retrieveMemorySelection(params: {
           params.chapterNumber,
           queryTerms,
         ),
-        hooks: selectRelevantHooks(memoryDb.getActiveHooks(), queryTerms),
+        hooks: selectRelevantHooks(memoryDb.getActiveHooks(), queryTerms, params.chapterNumber),
         facts: selectRelevantFacts(memoryDb.getCurrentFacts(), queryTerms),
         volumeSummaries,
         dbPath: join(storyDir, "memory.db"),
@@ -83,12 +108,12 @@ export async function retrieveMemorySelection(params: {
     readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => ""),
     readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
   ]);
-  const summaries = parseChapterSummariesMarkdown(summariesMarkdown);
-  const hooks = parsePendingHooksMarkdown(hooksMarkdown);
+  const summaries = structuredSummaries?.rows ?? parseChapterSummariesMarkdown(summariesMarkdown);
+  const hooks = structuredHooks?.hooks ?? parsePendingHooksMarkdown(hooksMarkdown);
 
   return {
     summaries: selectRelevantSummaries(summaries, params.chapterNumber, queryTerms),
-    hooks: selectRelevantHooks(hooks, queryTerms),
+    hooks: selectRelevantHooks(hooks, queryTerms, params.chapterNumber),
     facts: selectRelevantFacts(facts, queryTerms),
     volumeSummaries,
   };
@@ -179,6 +204,18 @@ function openMemoryDB(bookDir: string): MemoryDB | null {
   }
 }
 
+async function readStructuredState<T>(
+  path: string,
+  schema: { parse(value: unknown): T },
+): Promise<T | null> {
+  try {
+    const raw = await readFile(path, "utf-8");
+    return schema.parse(JSON.parse(raw));
+  } catch {
+    return null;
+  }
+}
+
 function buildLegacyQueryTerms(goal: string, outlineNode: string | undefined, mustKeep: ReadonlyArray<string>): string[] {
   const stopWords = new Set([
     "bring", "focus", "back", "chapter", "clear", "narrative", "before", "opening",
@@ -415,6 +452,10 @@ function inferFactSubject(label: string): string {
   return "current_state";
 }
 
+function isUnresolvedHook(status: string): boolean {
+  return status.trim().length === 0 || /open|待定|推进|active|progressing/i.test(status);
+}
+
 function selectRelevantSummaries(
   summaries: ReadonlyArray<StoredSummary>,
   chapterNumber: number,
@@ -444,8 +485,9 @@ function selectRelevantSummaries(
 function selectRelevantHooks(
   hooks: ReadonlyArray<StoredHook>,
   queryTerms: ReadonlyArray<string>,
+  chapterNumber: number,
 ): StoredHook[] {
-  return hooks
+  const ranked = hooks
     .map((hook) => ({
       hook,
       score: scoreHook(hook, queryTerms),
@@ -454,10 +496,30 @@ function selectRelevantHooks(
         queryTerms,
       ),
     }))
-    .filter((entry) => entry.matched || entry.hook.status.trim().length === 0 || /open|待定|推进|active/i.test(entry.hook.status))
+    .filter((entry) => entry.matched || isUnresolvedHook(entry.hook.status));
+
+  const recentCutoff = Math.max(0, chapterNumber - 5);
+  const staleCutoff = Math.max(0, chapterNumber - 10);
+  const primary = ranked
+    .filter((entry) => (
+      entry.matched
+      || entry.hook.lastAdvancedChapter >= recentCutoff
+      || entry.hook.startChapter >= recentCutoff
+    ))
     .sort((left, right) => right.score - left.score || right.hook.lastAdvancedChapter - left.hook.lastAdvancedChapter)
-    .slice(0, 3)
-    .map((entry) => entry.hook);
+    .slice(0, 3);
+
+  const selectedIds = new Set(primary.map((entry) => entry.hook.hookId));
+  const stale = ranked
+    .filter((entry) => (
+      !selectedIds.has(entry.hook.hookId)
+      && entry.hook.lastAdvancedChapter <= staleCutoff
+      && isUnresolvedHook(entry.hook.status)
+    ))
+    .sort((left, right) => left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter || right.score - left.score)
+    .slice(0, 1);
+
+  return [...primary, ...stale].map((entry) => entry.hook);
 }
 
 function selectRelevantFacts(