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

test(v13): cover hook causality, promotion, stale detection [phase 7/8 part 6]

Three new test files, 28 new cases, lifting the core suite from 573 to 601:

- hook-promotion.test.ts (13 cases)
  Covers each of the four promotion rules individually and in combination,
  verifies advancedCount context-fallback, and locks in defaultHalfLife /
  resolveHalfLifeChapters behavior.

- hook-stale-detection.test.ts (10 cases)
  Distance-vs-halfLife stale logic, explicit halfLifeChapters override,
  resolved-hook exclusion, pre-planting seeds not flagged, blocked detection
  for missing / unresolved upstream, marker rendering in zh + en.

- architect-phase7.test.ts (5 cases)
  Architect prompt documents all four new columns and the 3-7 core budget,
  parseSections round-trips extended rows into the runtime ledger shape,
  writeFoundationFiles writes the Phase 7 columns to disk, the parser
  reads back core_hook / dependsOn / paysOffInArc, and pre-Phase-7 8-column
  tables still parse without the new fields (backward compat).

All 601 core tests pass; tsc clean.
Ma 6 месяцев назад
Родитель
Сommit
12d5ad0e3a

+ 196 - 0
packages/core/src/__tests__/architect-phase7.test.ts

@@ -0,0 +1,196 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { mkdtemp, readFile, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { ArchitectAgent } from "../agents/architect.js";
+import type { BookConfig } from "../models/book.js";
+import { parsePendingHooksMarkdown } from "../utils/story-markdown.js";
+
+const ZERO_USAGE = {
+  promptTokens: 0,
+  completionTokens: 0,
+  totalTokens: 0,
+} as const;
+
+function buildAgent(): 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-book",
+    title: "Phase7测试书",
+    platform: "other",
+    genre: "urban",
+    status: "active",
+    targetChapters: 80,
+    chapterWordCount: 2200,
+    language: "zh",
+    createdAt: "2026-04-15T00:00:00.000Z",
+    updatedAt: "2026-04-15T00:00:00.000Z",
+  };
+}
+
+const PHASE7_RESPONSE = [
+  "=== SECTION: story_frame ===",
+  "## 主题与基调",
+  "这本书讲的是记忆与承诺。",
+  "=== SECTION: volume_map ===",
+  "## 各卷主题与情绪曲线",
+  "三卷结构,第1卷 1-20 章。",
+  "=== 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 | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 上游依赖 | 回收卷 | 核心 | 半衰期 | 备注 |",
+  "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
+  "| H01 | 1 | 主线 | 未开启 | 0 | 终章揭晓 | 终局 | 无 | 第3卷终章前 | 是 | 80 | 父亲笔记本 |",
+  "| H02 | 3 | 谜团 | 未开启 | 0 | 第2卷揭开 | 中程 | [H01] | 第2卷中段 | 否 | 30 | 码头名单碎片 |",
+  "| H03 | 7 | 小承诺 | 未开启 | 0 | 15章 | 近期 | 无 | 第1卷末 | 否 |  | 对妹妹的承诺 |",
+].join("\n");
+
+describe("ArchitectAgent — Phase 7 extended hook frontmatter", () => {
+  let bookDir: string;
+
+  beforeEach(async () => {
+    bookDir = await mkdtemp(join(tmpdir(), "inkos-phase7-arch-"));
+  });
+
+  afterEach(async () => {
+    await rm(bookDir, { recursive: true, force: true });
+    vi.restoreAllMocks();
+  });
+
+  it("architect prompt instructs depends_on / pays_off_in_arc / core_hook / half_life", async () => {
+    const agent = buildAgent();
+    const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({ content: PHASE7_RESPONSE, usage: ZERO_USAGE });
+
+    await agent.generateFoundation(baseBook());
+
+    const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
+    const system = messages[0]?.content ?? "";
+    // The zh prompt must document all four new columns with clear rules.
+    expect(system).toContain("上游依赖");
+    expect(system).toContain("回收卷");
+    expect(system).toContain("核心");
+    expect(system).toContain("半衰期");
+    // Core-hook budget guidance: 3-7 per book.
+    expect(system).toContain("3-7 条");
+    // The extended table header must appear in the prompt example.
+    expect(system).toContain("| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 上游依赖 | 回收卷 | 核心 | 半衰期 | 备注 |");
+  });
+
+  it("round-trips extended columns through parseSections into the ledger", async () => {
+    const agent = buildAgent();
+    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({ content: PHASE7_RESPONSE, usage: ZERO_USAGE });
+
+    const result = await agent.generateFoundation(baseBook());
+
+    // The rendered ledger drops half_life (architect-only input) but keeps
+    // depends_on / pays_off_in_arc / core_hook as visible columns.
+    expect(result.pendingHooks).toContain("| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 上游依赖 | 回收卷 | 核心 | 备注 |");
+    expect(result.pendingHooks).toContain("| H01 | 1 | 主线 | 未开启 | 0 | 终章揭晓 | 终局 | 无 | 第3卷终章前 | 是 | 父亲笔记本 |");
+    expect(result.pendingHooks).toContain("| H02 | 3 | 谜团 | 未开启 | 0 | 第2卷揭开 | 中程 | [H01] | 第2卷中段 | 否 | 码头名单碎片 |");
+  });
+
+  it("pending_hooks.md on disk carries the Phase 7 columns", async () => {
+    const agent = buildAgent();
+    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({ content: PHASE7_RESPONSE, usage: ZERO_USAGE });
+
+    const result = await agent.generateFoundation(baseBook());
+    await agent.writeFoundationFiles(bookDir, result, false, "zh");
+
+    const disk = await readFile(join(bookDir, "story", "pending_hooks.md"), "utf-8");
+    expect(disk).toContain("上游依赖");
+    expect(disk).toContain("回收卷");
+    expect(disk).toContain("核心");
+    // Second row's depends_on column should be [H01].
+    expect(disk).toMatch(/\| H02 \|.*\| \[H01\] \|/);
+  });
+
+  it("parsePendingHooksMarkdown reads the extended ledger shape", async () => {
+    const agent = buildAgent();
+    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({ content: PHASE7_RESPONSE, usage: ZERO_USAGE });
+
+    const result = await agent.generateFoundation(baseBook());
+
+    const hooks = parsePendingHooksMarkdown(result.pendingHooks);
+    expect(hooks).toHaveLength(3);
+
+    const h01 = hooks.find((h) => h.hookId === "H01")!;
+    expect(h01.coreHook).toBe(true);
+    expect(h01.paysOffInArc).toBe("第3卷终章前");
+    expect(h01.dependsOn ?? []).toEqual([]);
+
+    const h02 = hooks.find((h) => h.hookId === "H02")!;
+    expect(h02.coreHook).toBe(false);
+    expect(h02.dependsOn).toEqual(["H01"]);
+    expect(h02.paysOffInArc).toBe("第2卷中段");
+  });
+
+  it("legacy 8-column pending_hooks tables still parse without new fields (backward compat)", () => {
+    const legacy = [
+      "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| L01 | 1 | 主线 | 未开启 | 0 | 15章 | 中程 | 旧书 |",
+    ].join("\n");
+
+    const hooks = parsePendingHooksMarkdown(legacy);
+    expect(hooks).toHaveLength(1);
+    const hook = hooks[0]!;
+    expect(hook.hookId).toBe("L01");
+    expect(hook.coreHook).toBeUndefined();
+    expect(hook.dependsOn).toBeUndefined();
+    expect(hook.paysOffInArc).toBeUndefined();
+    expect(hook.halfLifeChapters).toBeUndefined();
+  });
+});

+ 192 - 0
packages/core/src/__tests__/hook-promotion.test.ts

@@ -0,0 +1,192 @@
+import { describe, expect, it } from "vitest";
+import type { StoredHook } from "../state/memory-db.js";
+import {
+  defaultHalfLifeChapters,
+  resolveHalfLifeChapters,
+  shouldPromoteHook,
+  type PromotionContext,
+  type VolumeBoundary,
+} from "../utils/hook-promotion.js";
+
+function makeHook(overrides: Partial<StoredHook>): StoredHook {
+  return {
+    hookId: "H01",
+    startChapter: 1,
+    type: "主线",
+    status: "open",
+    lastAdvancedChapter: 0,
+    expectedPayoff: "回收",
+    notes: "",
+    ...overrides,
+  };
+}
+
+function makeContext(overrides: Partial<PromotionContext>): PromotionContext {
+  return {
+    volumeBoundaries: [],
+    currentChapter: 10,
+    advancedCounts: new Map(),
+    allSeedStartChapters: new Map(),
+    ...overrides,
+  };
+}
+
+const VOL_MAP: ReadonlyArray<VolumeBoundary> = [
+  { name: "第一卷", startCh: 1, endCh: 20 },
+  { name: "第二卷", startCh: 21, endCh: 40 },
+  { name: "第三卷", startCh: 41, endCh: 60 },
+];
+
+describe("shouldPromoteHook — Phase 7 four-rule promotion", () => {
+  it("promotes a core_hook even when nothing else fires", () => {
+    const hook = makeHook({ coreHook: true, startChapter: 5 });
+    const decision = shouldPromoteHook(hook, makeContext({ volumeBoundaries: VOL_MAP }));
+    expect(decision.promote).toBe(true);
+    expect(decision.reasons).toContain("core_hook");
+  });
+
+  it("promotes a hook with any depends_on entries", () => {
+    const hook = makeHook({ dependsOn: ["H02"], startChapter: 5 });
+    const decision = shouldPromoteHook(hook, makeContext({ volumeBoundaries: VOL_MAP }));
+    expect(decision.promote).toBe(true);
+    expect(decision.reasons).toContain("depends_on");
+  });
+
+  it("promotes a hook that has been advanced in two or more chapters", () => {
+    const hook = makeHook({ hookId: "H07", startChapter: 3, advancedCount: 2 });
+    const decision = shouldPromoteHook(hook, makeContext({ volumeBoundaries: VOL_MAP }));
+    expect(decision.promote).toBe(true);
+    expect(decision.reasons).toContain("advanced_count");
+  });
+
+  it("reads advancedCount from context when hook itself has none", () => {
+    const hook = makeHook({ hookId: "H07", startChapter: 3 });
+    const decision = shouldPromoteHook(
+      hook,
+      makeContext({
+        volumeBoundaries: VOL_MAP,
+        advancedCounts: new Map([["H07", 3]]),
+      }),
+    );
+    expect(decision.promote).toBe(true);
+    expect(decision.reasons).toContain("advanced_count");
+  });
+
+  it("promotes when depends_on upstream lives in a later volume (cross_volume)", () => {
+    // Planted in vol 1 (ch 5), depends on H_future which is planted in vol 2 (ch 25).
+    const hook = makeHook({
+      startChapter: 5,
+      dependsOn: ["H_FUTURE"],
+    });
+    const decision = shouldPromoteHook(
+      hook,
+      makeContext({
+        volumeBoundaries: VOL_MAP,
+        allSeedStartChapters: new Map([["H_FUTURE", 25]]),
+      }),
+    );
+    expect(decision.promote).toBe(true);
+    // Both depends_on and cross_volume should fire.
+    expect(decision.reasons).toContain("depends_on");
+    expect(decision.reasons).toContain("cross_volume");
+  });
+
+  it("promotes a slow-burn hook planted in volume 1 as cross_volume", () => {
+    const hook = makeHook({
+      startChapter: 3,
+      payoffTiming: "slow-burn",
+    });
+    const decision = shouldPromoteHook(
+      hook,
+      makeContext({ volumeBoundaries: VOL_MAP }),
+    );
+    expect(decision.promote).toBe(true);
+    expect(decision.reasons).toContain("cross_volume");
+  });
+
+  it("promotes when pays_off_in_arc mentions a different volume in Chinese", () => {
+    const hook = makeHook({
+      startChapter: 5,
+      paysOffInArc: "第二卷中段揭晓",
+    });
+    const decision = shouldPromoteHook(
+      hook,
+      makeContext({ volumeBoundaries: VOL_MAP }),
+    );
+    expect(decision.promote).toBe(true);
+    expect(decision.reasons).toContain("cross_volume");
+  });
+
+  it("promotes when pays_off_in_arc mentions a different volume in English", () => {
+    const hook = makeHook({
+      startChapter: 5,
+      paysOffInArc: "mid of volume 3",
+    });
+    const decision = shouldPromoteHook(
+      hook,
+      makeContext({ volumeBoundaries: VOL_MAP }),
+    );
+    expect(decision.promote).toBe(true);
+    expect(decision.reasons).toContain("cross_volume");
+  });
+
+  it("does NOT promote a plain local seed with no rules firing", () => {
+    const hook = makeHook({
+      hookId: "H99",
+      startChapter: 5,
+      payoffTiming: "near-term",
+      coreHook: false,
+      dependsOn: [],
+    });
+    const decision = shouldPromoteHook(
+      hook,
+      makeContext({ volumeBoundaries: VOL_MAP }),
+    );
+    expect(decision.promote).toBe(false);
+    expect(decision.reasons).toEqual([]);
+  });
+
+  it("does NOT promote when advancedCount is 1 (below threshold)", () => {
+    const hook = makeHook({ hookId: "H1", startChapter: 5, advancedCount: 1 });
+    const decision = shouldPromoteHook(
+      hook,
+      makeContext({ volumeBoundaries: VOL_MAP }),
+    );
+    expect(decision.promote).toBe(false);
+  });
+
+  it("combines multiple reasons when several conditions hold", () => {
+    const hook = makeHook({
+      startChapter: 3,
+      coreHook: true,
+      dependsOn: ["H03"],
+      advancedCount: 4,
+      payoffTiming: "endgame",
+    });
+    const decision = shouldPromoteHook(
+      hook,
+      makeContext({ volumeBoundaries: VOL_MAP }),
+    );
+    expect(decision.promote).toBe(true);
+    expect(new Set(decision.reasons)).toEqual(
+      new Set(["core_hook", "depends_on", "advanced_count", "cross_volume"]),
+    );
+  });
+
+  it("defaultHalfLifeChapters maps timing to the prompt defaults", () => {
+    expect(defaultHalfLifeChapters("immediate")).toBe(10);
+    expect(defaultHalfLifeChapters("near-term")).toBe(10);
+    expect(defaultHalfLifeChapters("mid-arc")).toBe(30);
+    expect(defaultHalfLifeChapters("slow-burn")).toBe(80);
+    expect(defaultHalfLifeChapters("endgame")).toBe(80);
+    expect(defaultHalfLifeChapters(undefined)).toBe(30);
+  });
+
+  it("resolveHalfLifeChapters prefers explicit halfLifeChapters over derived default", () => {
+    const explicit = makeHook({ payoffTiming: "mid-arc", halfLifeChapters: 50 });
+    expect(resolveHalfLifeChapters(explicit)).toBe(50);
+
+    const derived = makeHook({ payoffTiming: "mid-arc" });
+    expect(resolveHalfLifeChapters(derived)).toBe(30);
+  });
+});

+ 132 - 0
packages/core/src/__tests__/hook-stale-detection.test.ts

@@ -0,0 +1,132 @@
+import { describe, expect, it } from "vitest";
+import type { StoredHook } from "../state/memory-db.js";
+import {
+  computeHookDiagnostics,
+  renderHookDiagnosticMarker,
+} from "../utils/hook-stale-detection.js";
+
+function hook(overrides: Partial<StoredHook>): StoredHook {
+  return {
+    hookId: "H01",
+    startChapter: 1,
+    type: "主线",
+    status: "open",
+    lastAdvancedChapter: 0,
+    expectedPayoff: "",
+    notes: "",
+    ...overrides,
+  };
+}
+
+describe("computeHookDiagnostics — Phase 7 stale / blocked detection", () => {
+  it("flags a hook as stale when distance exceeds half-life and hook is unresolved", () => {
+    // near-term default half-life is 10, plant at ch 5, current ch 25 => distance 20 > 10.
+    const h = hook({ startChapter: 5, payoffTiming: "near-term" });
+    const diag = computeHookDiagnostics({ hooks: [h], currentChapter: 25 }).get("H01")!;
+    expect(diag.stale).toBe(true);
+    expect(diag.distance).toBe(20);
+    expect(diag.halfLife).toBe(10);
+  });
+
+  it("does NOT flag a hook as stale when distance is within half-life", () => {
+    const h = hook({ startChapter: 5, payoffTiming: "mid-arc" });
+    const diag = computeHookDiagnostics({ hooks: [h], currentChapter: 25 }).get("H01")!;
+    expect(diag.stale).toBe(false);
+  });
+
+  it("honors explicit halfLifeChapters over the timing default", () => {
+    const h = hook({
+      startChapter: 5,
+      payoffTiming: "near-term", // default 10
+      halfLifeChapters: 50,
+    });
+    const diag = computeHookDiagnostics({ hooks: [h], currentChapter: 30 }).get("H01")!;
+    expect(diag.stale).toBe(false); // distance 25 < explicit 50
+    expect(diag.halfLife).toBe(50);
+  });
+
+  it("never flags a resolved hook as stale", () => {
+    const h = hook({ startChapter: 5, status: "resolved", payoffTiming: "immediate" });
+    const diag = computeHookDiagnostics({ hooks: [h], currentChapter: 90 }).get("H01")!;
+    expect(diag.stale).toBe(false);
+  });
+
+  it("flags a hook as blocked when its depends_on upstream is unplanted", () => {
+    const downstream = hook({
+      hookId: "H-down",
+      startChapter: 10,
+      dependsOn: ["H-up"],
+    });
+    // upstream missing entirely
+    const diag = computeHookDiagnostics({
+      hooks: [downstream],
+      currentChapter: 20,
+    }).get("H-down")!;
+    expect(diag.blocked).toBe(true);
+    expect(diag.missingUpstream).toEqual(["H-up"]);
+  });
+
+  it("flags a hook as blocked when the upstream is planted but unresolved", () => {
+    const upstream = hook({ hookId: "H-up", startChapter: 3, status: "open" });
+    const downstream = hook({
+      hookId: "H-down",
+      startChapter: 10,
+      dependsOn: ["H-up"],
+    });
+    const result = computeHookDiagnostics({
+      hooks: [upstream, downstream],
+      currentChapter: 20,
+    });
+    expect(result.get("H-down")!.blocked).toBe(true);
+    expect(result.get("H-up")!.blocked).toBe(false);
+  });
+
+  it("clears the blocked flag once every upstream is resolved", () => {
+    const upstream = hook({ hookId: "H-up", startChapter: 3, status: "resolved" });
+    const downstream = hook({
+      hookId: "H-down",
+      startChapter: 10,
+      dependsOn: ["H-up"],
+    });
+    const result = computeHookDiagnostics({
+      hooks: [upstream, downstream],
+      currentChapter: 20,
+    });
+    expect(result.get("H-down")!.blocked).toBe(false);
+    expect(result.get("H-down")!.missingUpstream).toEqual([]);
+  });
+
+  it("pre-planting seeds (startChapter 0) are not marked stale", () => {
+    const h = hook({ startChapter: 0, payoffTiming: "immediate" });
+    const diag = computeHookDiagnostics({ hooks: [h], currentChapter: 50 }).get("H01")!;
+    expect(diag.stale).toBe(false);
+  });
+
+  it("renderHookDiagnosticMarker formats zh / en markers correctly", () => {
+    const diag = {
+      stale: true,
+      blocked: true,
+      missingUpstream: ["H-up"],
+      distance: 20,
+      halfLife: 10,
+    } as const;
+    expect(renderHookDiagnosticMarker(diag, "zh")).toBe(
+      "过期 (距=20/半衰=10); 受阻于 H-up",
+    );
+    expect(renderHookDiagnosticMarker(diag, "en")).toBe(
+      "stale (d=20/half=10); blocked on H-up",
+    );
+  });
+
+  it("renders empty marker when nothing is flagged", () => {
+    const diag = {
+      stale: false,
+      blocked: false,
+      missingUpstream: [],
+      distance: 1,
+      halfLife: 10,
+    } as const;
+    expect(renderHookDiagnosticMarker(diag, "zh")).toBe("");
+    expect(renderHookDiagnosticMarker(diag, "en")).toBe("");
+  });
+});