ソースを参照

test(v13): cover Phase 7 hotfix edge cases [phase 7 hotfix 4]

New suite packages/core/src/__tests__/phase7-hotfix.test.ts with 11
tests covering:

hotfix 1: half_life roundtrip through render+parse (explicit value
preserved, empty cell → undefined), legacy 11-column compat, and
downstream effect on hook-stale-detection (explicit halfLife wins over
timing default).

hotfix 2: architect tags core_hook / depends_on / cross_volume seeds as
promoted=是 at seed time (and plain local seeds as promoted=否);
consolidator flips promoted=false→true when chapter_summaries mentions
the hook in ≥2 chapters; consolidator leaves the ledger byte-identical
when no hook crosses the threshold; reviewer prompt (zh and en) gates
critical severity on promoted and references the 已阻 N 章 /
blocked N chapters literal token.

hotfix 3: blocked distance uses max upstream distance (H01 ch 3, H02 ch
5, current ch 12, neither resolved → 已阻 9 章 on H02); upstream
missing entirely → blocked since hook's own planting; blockedDistance
is 0 when hook is not blocked.

Also extends consolidator.rerunAdvancedCountPromotion to derive
advancedCount from chapter_summaries.md hookActivity mentions when the
hook object itself lacks the field — this is the real runtime path, the
tests exercise it.
Ma 6 ヶ月 前
親
コミット
8c261175e6

+ 608 - 0
packages/core/src/__tests__/phase7-hotfix.test.ts

@@ -0,0 +1,608 @@
+/**
+ * Phase 7 hotfix 4 — edge-case coverage for hotfixes 1/2/3.
+ *
+ * These tests pin down the hotfix-specific invariants that the pre-existing
+ * Phase 7 suites did not cover:
+ *
+ *   hotfix 1: half_life roundtrips through render/parse (12-col), empty cell
+ *             falls back to undefined, legacy 11-col still parses.
+ *   hotfix 2: architect tags core_hook seeds as promoted=true at seed time;
+ *             consolidator re-promotes seeds whose advancedCount>=2 at volume
+ *             boundary; reviewer prompt gates critical severity on promoted.
+ *   hotfix 3: blocked-distance computation embeds the 已阻 N 章 token.
+ */
+
+import { afterEach, beforeEach, 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 { StoredHook } from "../state/memory-db.js";
+import {
+  parsePendingHooksMarkdown,
+  renderHookSnapshot,
+} from "../utils/story-markdown.js";
+import { computeHookDiagnostics, renderHookDiagnosticMarker } from "../utils/hook-stale-detection.js";
+import { ArchitectAgent } from "../agents/architect.js";
+import { ConsolidatorAgent } from "../agents/consolidator.js";
+import type { BookConfig } from "../models/book.js";
+
+const ZERO_USAGE = {
+  promptTokens: 0,
+  completionTokens: 0,
+  totalTokens: 0,
+} as const;
+
+// ---------------------------------------------------------------------------
+// Hotfix 1: half_life roundtrip
+// ---------------------------------------------------------------------------
+
+describe("Phase 7 hotfix 1 — half_life roundtrip", () => {
+  it("renders the 半衰期 column and parses it back with the original value", () => {
+    const hooks: StoredHook[] = [
+      {
+        hookId: "H-explicit",
+        startChapter: 5,
+        type: "主线",
+        status: "open",
+        lastAdvancedChapter: 0,
+        expectedPayoff: "终章揭晓",
+        notes: "带半衰期的钩子",
+        payoffTiming: "endgame",
+        halfLifeChapters: 45,
+      },
+      {
+        hookId: "H-implicit",
+        startChapter: 7,
+        type: "谜团",
+        status: "open",
+        lastAdvancedChapter: 0,
+        expectedPayoff: "15章",
+        notes: "不带半衰期",
+        payoffTiming: "near-term",
+      },
+    ];
+
+    const rendered = renderHookSnapshot(hooks, "zh");
+    expect(rendered).toContain("| 半衰期 |");
+    expect(rendered).toContain("| H-explicit | 5 | 主线 | open | 0 | 终章揭晓 | 终局 | 无 |  | 否 | 45 |  | 带半衰期的钩子 |");
+    expect(rendered).toContain("| H-implicit | 7 | 谜团 | open | 0 | 15章 | 近期 | 无 |  | 否 |  |  | 不带半衰期 |");
+
+    const parsed = parsePendingHooksMarkdown(rendered);
+    const hExplicit = parsed.find((h) => h.hookId === "H-explicit")!;
+    expect(hExplicit.halfLifeChapters).toBe(45);
+    const hImplicit = parsed.find((h) => h.hookId === "H-implicit")!;
+    expect(hImplicit.halfLifeChapters).toBeUndefined();
+  });
+
+  it("legacy 11-column pending_hooks.md still parses (half_life stays undefined)", () => {
+    const legacy11 = [
+      "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 上游依赖 | 回收卷 | 核心 | 备注 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| L01 | 1 | 主线 | 未开启 | 0 | 终章揭晓 | 终局 | 无 | 第3卷 | 是 | 早期书目 |",
+    ].join("\n");
+
+    const parsed = parsePendingHooksMarkdown(legacy11);
+    expect(parsed).toHaveLength(1);
+    const h = parsed[0]!;
+    expect(h.hookId).toBe("L01");
+    expect(h.coreHook).toBe(true);
+    expect(h.halfLifeChapters).toBeUndefined();
+    // promoted should also be undefined on legacy 11-col data.
+    expect(h.promoted).toBeUndefined();
+  });
+
+  it("hook-stale-detection honors explicit halfLifeChapters after roundtrip", () => {
+    const hooks: StoredHook[] = [
+      {
+        hookId: "H-late",
+        startChapter: 5,
+        type: "主线",
+        status: "open",
+        lastAdvancedChapter: 0,
+        expectedPayoff: "terminal",
+        notes: "",
+        payoffTiming: "near-term", // default would be 10
+        halfLifeChapters: 60,
+      },
+    ];
+
+    const rendered = renderHookSnapshot(hooks, "zh");
+    const parsed = parsePendingHooksMarkdown(rendered);
+
+    // distance 40 vs halfLife 60 → not stale (would have been stale with the
+    // near-term default of 10).
+    const diag = computeHookDiagnostics({ hooks: parsed, currentChapter: 45 }).get("H-late")!;
+    expect(diag.halfLife).toBe(60);
+    expect(diag.stale).toBe(false);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Hotfix 2: promotion wiring
+// ---------------------------------------------------------------------------
+
+function buildPhase7Response(language: "zh" = "zh"): string {
+  // zh-only for brevity — we care about the promoted flag logic, not language.
+  void language;
+  return [
+    "=== SECTION: story_frame ===",
+    "## 主题与基调",
+    "这本书讲的是记忆与承诺。",
+    "=== SECTION: volume_map ===",
+    "## 各卷主题与情绪曲线",
+    "### 第一卷:起航 (1-20章)",
+    "导言。",
+    "### 第二卷:中盘 (21-40章)",
+    "转折。",
+    "### 第三卷:终局 (41-60章)",
+    "收束。",
+    "=== SECTION: rhythm_principles ===",
+    "## 原则 1",
+    "每 10 章一个高潮。",
+    "=== SECTION: roles ===",
+    "---ROLE---",
+    "tier: major",
+    "name: 林辞",
+    "---CONTENT---",
+    "## 核心标签",
+    "沉默",
+    "## 反差细节",
+    "会给流浪狗留罐头",
+    "## 人物小传",
+    "过往。",
+    "## 当前现状",
+    "码头边做账房。",
+    "## 关系网络",
+    "与沈默是旧友。",
+    "## 内在驱动",
+    "查清真相。",
+    "## 成长弧光",
+    "从独行到托付。",
+    "=== SECTION: book_rules ===",
+    "---",
+    "version: \"1.0\"",
+    "---",
+    "## 叙事视角",
+    "第三人称。",
+    "=== SECTION: current_state ===",
+    "| 字段 | 值 |",
+    "| --- | --- |",
+    "| 当前章节 | 0 |",
+    "",
+    "=== SECTION: pending_hooks ===",
+    "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 上游依赖 | 回收卷 | 核心 | 半衰期 | 备注 |",
+    "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
+    // core_hook=是 → promoted 是
+    "| H-core | 1 | 主线 | 未开启 | 0 | 终章揭晓 | 终局 | 无 | 第3卷终章前 | 是 | 80 | 主承重 |",
+    // depends_on non-empty → promoted 是
+    "| H-dep | 5 | 谜团 | 未开启 | 0 | 第2卷揭开 | 中程 | [H-core] | 第2卷中段 | 否 | 30 | 因果下游 |",
+    // cross_volume via slow-burn in vol 1 → promoted 是
+    "| H-slow | 3 | 伏笔 | 未开启 | 0 | 终卷前 | 慢烧 | 无 | 第3卷 | 否 |  | 慢烧 |",
+    // local, no rule firing → promoted 否
+    "| H-local | 8 | 小承诺 | 未开启 | 0 | 15章 | 近期 | 无 | 第1卷末 | 否 |  | 对妹妹的承诺 |",
+  ].join("\n");
+}
+
+function buildArchitectAgent(): ArchitectAgent {
+  return new ArchitectAgent({
+    client: {
+      provider: "openai",
+      apiFormat: "chat",
+      stream: false,
+      defaults: {
+        temperature: 0.7,
+        maxTokens: 4096,
+        thinkingBudget: 0,
+        maxTokensCap: null,
+        extra: {},
+      },
+    },
+    model: "test-model",
+    projectRoot: process.cwd(),
+  });
+}
+
+function baseBook(): BookConfig {
+  return {
+    id: "phase7-hotfix-book",
+    title: "hotfix测试书",
+    platform: "other",
+    genre: "urban",
+    status: "active",
+    targetChapters: 60,
+    chapterWordCount: 2000,
+    language: "zh",
+    createdAt: "2026-04-15T00:00:00.000Z",
+    updatedAt: "2026-04-15T00:00:00.000Z",
+  };
+}
+
+describe("Phase 7 hotfix 2 — architect pre-promotes structural seeds", () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it("tags core_hook / depends_on / cross_volume seeds as promoted=是, others as promoted=否", async () => {
+    const agent = buildArchitectAgent();
+    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({ content: buildPhase7Response(), usage: ZERO_USAGE });
+
+    const result = await agent.generateFoundation(baseBook());
+
+    // H-core: core_hook=是 → 升级=是
+    expect(result.pendingHooks).toMatch(/\| H-core \|.*\| 是 \| 主承重 \|/);
+    // H-dep: depends_on=[H-core] → 升级=是
+    expect(result.pendingHooks).toMatch(/\| H-dep \|.*\| 是 \| 因果下游 \|/);
+    // H-slow: slow-burn in vol 1 → 升级=是 (cross_volume)
+    expect(result.pendingHooks).toMatch(/\| H-slow \|.*\| 是 \| 慢烧 \|/);
+    // H-local: no rule firing → 升级=否
+    expect(result.pendingHooks).toMatch(/\| H-local \|.*\| 否 \| 对妹妹的承诺 \|/);
+
+    const parsed = parsePendingHooksMarkdown(result.pendingHooks);
+    expect(parsed.find((h) => h.hookId === "H-core")!.promoted).toBe(true);
+    expect(parsed.find((h) => h.hookId === "H-dep")!.promoted).toBe(true);
+    expect(parsed.find((h) => h.hookId === "H-slow")!.promoted).toBe(true);
+    expect(parsed.find((h) => h.hookId === "H-local")!.promoted).toBe(false);
+  });
+});
+
+describe("Phase 7 hotfix 2 — consolidator re-promotes advancedCount>=2", () => {
+  let bookDir: string;
+
+  beforeEach(async () => {
+    bookDir = await mkdtemp(join(tmpdir(), "inkos-phase7-hf-consolid-"));
+    await mkdir(join(bookDir, "story"), { recursive: true });
+  });
+
+  afterEach(async () => {
+    await rm(bookDir, { recursive: true, force: true });
+    vi.restoreAllMocks();
+  });
+
+  it("flips promoted=false → true when chapter_summaries mentions the hook in 2+ chapters", async () => {
+    // Seed ledger: H-slept was architect-emitted as 否 (no rule firing at
+    // seed time), but subsequent chapter summaries mention it 3 times.
+    // Consolidator must re-promote via the derived advancedCount path.
+    const seededHooks: StoredHook[] = [
+      {
+        hookId: "H-slept",
+        startChapter: 3,
+        type: "小承诺",
+        status: "open",
+        lastAdvancedChapter: 9,
+        expectedPayoff: "15章",
+        payoffTiming: "near-term",
+        notes: "对妹妹的承诺",
+        dependsOn: [],
+        paysOffInArc: "第1卷末",
+        coreHook: false,
+        promoted: false,
+      },
+      {
+        hookId: "H-cold",
+        startChapter: 4,
+        type: "小线索",
+        status: "open",
+        lastAdvancedChapter: 0,
+        expectedPayoff: "",
+        payoffTiming: "mid-arc",
+        notes: "",
+        dependsOn: [],
+        paysOffInArc: "",
+        coreHook: false,
+        promoted: false,
+      },
+    ];
+    const ledgerPath = join(bookDir, "story", "pending_hooks.md");
+    await writeFile(ledgerPath, renderHookSnapshot(seededHooks, "zh"), "utf-8");
+
+    // chapter_summaries mentions H-slept in 3 chapters (>=2 → promote).
+    // H-cold mentioned in only 1 chapter (below threshold).
+    await writeFile(
+      join(bookDir, "story", "chapter_summaries.md"),
+      [
+        "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
+        "| --- | --- | --- | --- | --- | --- | --- | --- |",
+        "| 4 | 旧信 | 林辞 | 收到家书 | 回忆浮现 | H-slept 推进 | 压抑 | 铺垫 |",
+        "| 5 | 湿巷 | 林辞 | 遇到熟人 | 警戒上升 | H-cold 提及 | 紧绷 | 铺垫 |",
+        "| 7 | 码头 | 林辞 | 查账 | 线索汇合 | H-slept 推进 | 紧绷 | 推进 |",
+        "| 9 | 雨夜 | 林辞 | 回到住所 | 情绪积压 | H-slept 推进 | 压抑 | 内省 |",
+      ].join("\n"),
+      "utf-8",
+    );
+
+    const agent = new ConsolidatorAgent({
+      client: {} as ConstructorParameters<typeof ConsolidatorAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: bookDir,
+    });
+
+    const result = await agent.consolidate(bookDir);
+    expect(result.promotedHookCount).toBe(1);
+
+    const next = await readFile(ledgerPath, "utf-8");
+    const parsed = parsePendingHooksMarkdown(next);
+    expect(parsed.find((h) => h.hookId === "H-slept")!.promoted).toBe(true);
+    expect(parsed.find((h) => h.hookId === "H-cold")!.promoted).toBe(false);
+  });
+
+  it("leaves ledger untouched when no hook crosses the threshold", async () => {
+    const hooks: StoredHook[] = [
+      {
+        hookId: "H-still",
+        startChapter: 3,
+        type: "小",
+        status: "open",
+        lastAdvancedChapter: 0,
+        expectedPayoff: "",
+        payoffTiming: "mid-arc",
+        notes: "",
+        promoted: false,
+      },
+    ];
+    const ledgerPath = join(bookDir, "story", "pending_hooks.md");
+    const before = renderHookSnapshot(hooks, "zh");
+    await writeFile(ledgerPath, before, "utf-8");
+
+    const agent = new ConsolidatorAgent({
+      client: {} as ConstructorParameters<typeof ConsolidatorAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: bookDir,
+    });
+
+    const result = await agent.consolidate(bookDir);
+    expect(result.promotedHookCount).toBe(0);
+    const after = await readFile(ledgerPath, "utf-8");
+    expect(after).toBe(before);
+  });
+});
+
+describe("Phase 7 hotfix 2 — reviewer gates critical severity on promoted", () => {
+  it("zh reviewer prompt references 升级=是 as critical gate and the 已阻 N 章 token", async () => {
+    // We drive the reviewer end-to-end against a minimal book fixture and
+    // assert the system prompt carries the hotfix language. This mirrors the
+    // continuity.test.ts style so we're exercising the actual prompt builder
+    // rather than a decoupled unit.
+    const { ContinuityAuditor } = await import("../agents/continuity.js");
+    const root = await mkdtemp(join(tmpdir(), "inkos-hf-reviewer-zh-"));
+    const bookDirLocal = join(root, "book");
+    const storyDir = join(bookDirLocal, "story");
+    await mkdir(storyDir, { recursive: true });
+
+    try {
+      await writeFile(
+        join(bookDirLocal, "book.json"),
+        JSON.stringify({
+          id: "hf-zh",
+          title: "hotfix-zh",
+          genre: "urban",
+          platform: "other",
+          chapterWordCount: 2000,
+          targetChapters: 60,
+          status: "active",
+          language: "zh",
+          createdAt: "2026-04-15T00:00:00.000Z",
+          updatedAt: "2026-04-15T00:00:00.000Z",
+        }, null, 2),
+        "utf-8",
+      );
+      await Promise.all([
+        writeFile(join(storyDir, "current_state.md"), "# 当前状态\n", "utf-8"),
+        writeFile(join(storyDir, "pending_hooks.md"), "# 伏笔池\n", "utf-8"),
+        writeFile(join(storyDir, "chapter_summaries.md"), "# 章节摘要\n", "utf-8"),
+        writeFile(join(storyDir, "subplot_board.md"), "# 支线进度板\n", "utf-8"),
+        writeFile(join(storyDir, "emotional_arcs.md"), "# 情感弧线\n", "utf-8"),
+        writeFile(join(storyDir, "character_matrix.md"), "# 角色交互矩阵\n", "utf-8"),
+        writeFile(join(storyDir, "volume_outline.md"), "# 卷纲\n", "utf-8"),
+        writeFile(join(storyDir, "style_guide.md"), "# 文风\n", "utf-8"),
+      ]);
+
+      const auditor = new ContinuityAuditor({
+        client: {
+          provider: "openai",
+          apiFormat: "chat",
+          stream: false,
+          defaults: {
+            temperature: 0.7,
+            maxTokens: 4096,
+            thinkingBudget: 0,
+            maxTokensCap: null,
+            extra: {},
+          },
+        },
+        model: "test-model",
+        projectRoot: root,
+      });
+
+      // Mock both possible paths — eraResearch genres go through chatWithSearch,
+      // others use chat. The two spies share a capture so whichever runs wins.
+      const stub = vi.fn().mockResolvedValue({
+        content: JSON.stringify({ passed: true, issues: [], summary: "ok" }),
+        usage: ZERO_USAGE,
+      });
+      vi.spyOn(ContinuityAuditor.prototype as never, "chatWithSearch" as never).mockImplementation(stub as never);
+      vi.spyOn(ContinuityAuditor.prototype as never, "chat" as never).mockImplementation(stub as never);
+
+      await auditor.auditChapter(bookDirLocal, "章节正文。", 1, "urban");
+
+      const messages = stub.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined;
+      const systemPrompt = messages?.[0]?.content ?? "";
+
+      // Critical severity gated on 升级=是.
+      expect(systemPrompt).toContain("升级");
+      expect(systemPrompt).toContain("升级=是");
+      // The 已阻 N 章 literal token reviewer reads verbatim (from hotfix 3).
+      expect(systemPrompt).toContain("已阻");
+      // Non-promoted stale hooks stay at info.
+      expect(systemPrompt).toMatch(/升级=否.*info|非升级.*info/);
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("en reviewer prompt gates on promoted=true and references 'blocked N chapters'", async () => {
+    const { ContinuityAuditor } = await import("../agents/continuity.js");
+    const root = await mkdtemp(join(tmpdir(), "inkos-hf-reviewer-en-"));
+    const bookDirLocal = join(root, "book");
+    const storyDir = join(bookDirLocal, "story");
+    await mkdir(storyDir, { recursive: true });
+
+    try {
+      await writeFile(
+        join(bookDirLocal, "book.json"),
+        JSON.stringify({
+          id: "hf-en",
+          title: "hotfix-en",
+          genre: "other",
+          platform: "royalroad",
+          chapterWordCount: 800,
+          targetChapters: 60,
+          status: "active",
+          language: "en",
+          createdAt: "2026-04-15T00:00:00.000Z",
+          updatedAt: "2026-04-15T00:00:00.000Z",
+        }, null, 2),
+        "utf-8",
+      );
+      await Promise.all([
+        writeFile(join(storyDir, "current_state.md"), "# Current State\n", "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, "subplot_board.md"), "# Subplot Board\n", "utf-8"),
+        writeFile(join(storyDir, "emotional_arcs.md"), "# Emotional Arcs\n", "utf-8"),
+        writeFile(join(storyDir, "character_matrix.md"), "# Character Matrix\n", "utf-8"),
+        writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n", "utf-8"),
+        writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n", "utf-8"),
+      ]);
+
+      const auditor = new ContinuityAuditor({
+        client: {
+          provider: "openai",
+          apiFormat: "chat",
+          stream: false,
+          defaults: {
+            temperature: 0.7,
+            maxTokens: 4096,
+            thinkingBudget: 0,
+            maxTokensCap: null,
+            extra: {},
+          },
+        },
+        model: "test-model",
+        projectRoot: root,
+      });
+
+      // Mock both possible paths — eraResearch genres go through chatWithSearch,
+      // others use chat. The two spies share a capture so whichever runs wins.
+      const stub = vi.fn().mockResolvedValue({
+        content: JSON.stringify({ passed: true, issues: [], summary: "ok" }),
+        usage: ZERO_USAGE,
+      });
+      vi.spyOn(ContinuityAuditor.prototype as never, "chatWithSearch" as never).mockImplementation(stub as never);
+      vi.spyOn(ContinuityAuditor.prototype as never, "chat" as never).mockImplementation(stub as never);
+
+      await auditor.auditChapter(bookDirLocal, "Chapter body.", 1, "other");
+
+      const messages = stub.mock.calls[0]?.[0] as ReadonlyArray<{ content: string }> | undefined;
+      const systemPrompt = messages?.[0]?.content ?? "";
+
+      expect(systemPrompt).toContain("promoted=true");
+      expect(systemPrompt).toContain("blocked ");
+      // info-only for non-promoted.
+      expect(systemPrompt).toMatch(/non-promoted.*info/i);
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Hotfix 3: blocked distance
+// ---------------------------------------------------------------------------
+
+describe("Phase 7 hotfix 3 — blocked distance embeds 已阻 N 章 token", () => {
+  it("reports blocked distance = currentChapter - upstream.startChapter when upstream is planted but unresolved", () => {
+    // H01 planted ch 3 (open, unresolved).
+    // H02 depends on H01, planted ch 5.
+    // current ch 12 → H02 has been blocked since upstream planting (ch 3).
+    // Expected: 已阻 9 章 (12 - 3).
+    const upstream: StoredHook = {
+      hookId: "H01",
+      startChapter: 3,
+      type: "主线",
+      status: "open",
+      lastAdvancedChapter: 0,
+      expectedPayoff: "",
+      notes: "",
+    };
+    const downstream: StoredHook = {
+      hookId: "H02",
+      startChapter: 5,
+      type: "下游",
+      status: "open",
+      lastAdvancedChapter: 0,
+      expectedPayoff: "",
+      notes: "",
+      dependsOn: ["H01"],
+    };
+
+    const diag = computeHookDiagnostics({
+      hooks: [upstream, downstream],
+      currentChapter: 12,
+    }).get("H02")!;
+
+    expect(diag.blocked).toBe(true);
+    expect(diag.blockedDistance).toBe(9);
+    expect(renderHookDiagnosticMarker(diag, "zh")).toContain("已阻 9 章");
+    expect(renderHookDiagnosticMarker(diag, "en")).toContain("blocked 9 chapters");
+  });
+
+  it("uses hook.startChapter as the reference when upstream is missing from the ledger entirely", () => {
+    // Upstream ghost id → blocked since hook's own planting.
+    const hook: StoredHook = {
+      hookId: "H-orphan",
+      startChapter: 4,
+      type: "",
+      status: "open",
+      lastAdvancedChapter: 0,
+      expectedPayoff: "",
+      notes: "",
+      dependsOn: ["H-ghost"],
+    };
+
+    const diag = computeHookDiagnostics({
+      hooks: [hook],
+      currentChapter: 11,
+    }).get("H-orphan")!;
+
+    expect(diag.blocked).toBe(true);
+    expect(diag.blockedDistance).toBe(7); // 11 - 4
+    expect(renderHookDiagnosticMarker(diag, "zh")).toContain("已阻 7 章");
+  });
+
+  it("blockedDistance is 0 when hook is not blocked", () => {
+    const upstream: StoredHook = {
+      hookId: "U",
+      startChapter: 2,
+      type: "",
+      status: "resolved",
+      lastAdvancedChapter: 5,
+      expectedPayoff: "",
+      notes: "",
+    };
+    const downstream: StoredHook = {
+      hookId: "D",
+      startChapter: 4,
+      type: "",
+      status: "open",
+      lastAdvancedChapter: 0,
+      expectedPayoff: "",
+      notes: "",
+      dependsOn: ["U"],
+    };
+    const diag = computeHookDiagnostics({ hooks: [upstream, downstream], currentChapter: 10 }).get("D")!;
+    expect(diag.blocked).toBe(false);
+    expect(diag.blockedDistance).toBe(0);
+    expect(renderHookDiagnosticMarker(diag, "zh")).toBe("");
+  });
+});

+ 43 - 1
packages/core/src/agents/consolidator.ts

@@ -155,6 +155,12 @@ export class ConsolidatorAgent extends BaseAgent {
    * rules (core_hook / depends_on / cross_volume) are already applied at
    * architect time and stay sticky (once promoted, always promoted).
    *
+   * advancedCount sources (first-win):
+   *   1. Explicit hook.advancedCount (test fixture path — our runtime doesn't
+   *      yet persist this).
+   *   2. Derived from chapter_summaries.md: count chapters whose hookActivity
+   *      cell mentions the hook id.
+   *
    * Returns the number of hooks that flipped from promoted=false (or
    * undefined) to promoted=true this run.
    */
@@ -168,10 +174,16 @@ export class ConsolidatorAgent extends BaseAgent {
 
     const language: "zh" | "en" = /[\u4e00-\u9fff]/.test(raw) ? "zh" : "en";
 
+    const summariesRaw = await readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => "");
+    const derivedCounts = this.deriveAdvancedCountsFromSummaries(
+      summariesRaw,
+      hooks.map((h) => h.hookId),
+    );
+
     let flipped = 0;
     const nextHooks: StoredHook[] = hooks.map((hook) => {
       if (hook.promoted === true) return hook;
-      const advanced = hook.advancedCount ?? 0;
+      const advanced = hook.advancedCount ?? derivedCounts.get(hook.hookId) ?? 0;
       if (advanced >= 2) {
         flipped += 1;
         return { ...hook, promoted: true };
@@ -189,6 +201,36 @@ export class ConsolidatorAgent extends BaseAgent {
     return flipped;
   }
 
+  /**
+   * Count how many chapter summaries mention each hook id in the hookActivity
+   * column. Crude but sufficient: chapter_summaries is the consolidator's
+   * source of truth for "what happened per chapter" and every reducer writes
+   * hook activity into it.
+   */
+  private deriveAdvancedCountsFromSummaries(
+    summariesRaw: string,
+    hookIds: ReadonlyArray<string>,
+  ): Map<string, number> {
+    const counts = new Map<string, number>();
+    if (!summariesRaw.trim() || hookIds.length === 0) return counts;
+
+    const lines = summariesRaw.split("\n");
+    for (const hookId of hookIds) {
+      // Escape regex meta characters in the hook id defensively.
+      const escaped = hookId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+      const pattern = new RegExp(escaped, "i");
+      let count = 0;
+      for (const line of lines) {
+        if (!line.startsWith("|")) continue;
+        // Skip header / separator rows.
+        if (line.includes("---") || /\|\s*(章节|Chapter)\s*\|/i.test(line)) continue;
+        if (pattern.test(line)) count += 1;
+      }
+      if (count > 0) counts.set(hookId, count);
+    }
+    return counts;
+  }
+
   private parseVolumeBoundaries(outline: string): Array<{ name: string; startCh: number; endCh: number }> {
     const volumes: Array<{ name: string; startCh: number; endCh: number }> = [];
     const lines = outline.split("\n");