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

refactor(v13): current_state back to seed-only + skip empty 节奏原则.md [phase 5 consolidation revert]

Revert of 2a183eb's "restore narrow current_state" over-reach, plus a tiny
follow-up fix to stop writing 0-byte rhythm files.

## Why

Bench run on 3 books (urban / cultivation / system) showed the LLM emits an
empty current_state block on ALL 3 — parser falls back to the seed placeholder
every time. The motivating use case (seed "1985 年 7 月, 非典刚过"-style era
anchors separately from world-building) only matters for historical /
year-anchored genres (港综同人 / 年代文 / 都市重生); 修仙/玄幻/系统文 have no
real year and nothing to say in that section. A separate 6th section forces
empty duplication across the majority of books.

Era/setting context belongs naturally inside story_frame.世界观底色 when the
genre calls for it ("一个湿冷的沿海城市,2003 年 8 月,非典刚过...") and is
omitted entirely otherwise. No dedicated section needed.

## Fix 1 — architect output drops current_state

architect.ts
- Header doc block: 6 sections → 5 sections
- langPrefix (en): drop current_state from the output list, add era-anchor
  weaving guidance
- zh + en prompt body:
  - "6 个 SECTION" → "5 个 SECTION"
  - Dedup rule: drop "环境/时代/大局锚只写在 current_state" / "environment/
    era/macro initial state lives only in current_state"; replace with a
    clause reminding that era anchors (only for year-anchored genres) should
    be woven into story_frame.世界观底色, and omitted otherwise
  - Budget list: drop the "current_state 500-800 chars" entry
  - Remove the entire `=== SECTION: current_state === ...` block
  - Update roles.Current_State cross-reference to stop pointing at a
    non-existent section
  - pending_hooks note: drop "环境/时代/大局锚请写在独立的 current_state
    section" caveat
  - Final emphasis: "不要输出 rhythm_principles 独立 section +
    current_state 职能窄化" → "不要输出 rhythm_principles 或 current_state
    两个 section"
- Parser: stop requiring current_state in the missing-sections check; the
  section name is back to optional and the ArchitectOutput.currentState field
  falls through to "" when absent, exactly as it was pre-2a183eb
- Import + fanfic prompts: 6-section → 5-section, drop current_state reference

writeFoundationFiles (untouched by the revert): still writes current_state.md
with the "建书时占位" seed so isCompleteBookDirectory() sees the file and the
runtime consolidator has somewhere to append each chapter's state update.

## Fix 2 — skip writing empty 节奏原则.md

writeFoundationFiles
- Rhythm file write is now gated on rhythmPrinciples.trim() being non-empty.
  Post-consolidation the architect merges rhythm principles into volume_map's
  closing paragraph and never emits a standalone rhythm_principles block, so
  the dedicated file would otherwise be a 0-byte file that misleads the UI.
- Legacy outputs that DO carry an explicit rhythm_principles section keep
  writing the file exactly as before.

## Whitelist comments

pipeline/agent.ts + studio/server.ts
- Note that outline/节奏原则.md / outline/rhythm_principles.md are optional
  after consolidation (whitelisted for legacy / manual-edit paths, but the
  fresh-build architect no longer produces them)

## Tests

architect-phase5-consolidated.test.ts — revert to the dd5b017 baseline:
- Doc block: 7→5 sections, call out the bench-driven revert
- CONSOLIDATED_RESPONSE drops the narrow current_state block
- "Exactly 6 SECTION headers" → "Exactly 5 SECTION headers (NO current_state,
  NO rhythm_principles)" for zh + en
- "current_state budget marker 500-800 chars" → assert NOT present
- Dedup assertions now check 年代文 guidance instead of "只写在 current_state"
- "Rejects response missing current_state" → "Accepts response with no
  current_state and seeds placeholder on disk"
- Keep the rhythm-mix-universal-plus-concrete assertions (the rhythm half of
  2a183eb was the correct half)
- Keep the legacy 7-section back-compat assertion
- readCurrentStateWithFallback derive test now uses CONSOLIDATED_RESPONSE
  directly (no current_state section → seed placeholder on disk → fallback
  reader fires)

architect-phase5.test.ts
- "still requires book_rules / roles / current_state / pending_hooks" →
  "still requires book_rules / roles / pending_hooks (current_state optional)"
- System-prompt section-header assertion flipped:
  expect(system).toContain("=== SECTION: current_state ===") →
  expect(system).not.toContain("=== SECTION: current_state ===")
- New describe block — "writeFoundationFiles — rhythm file is skipped when
  rhythmPrinciples is empty":
  - Empty rhythm block → outline/节奏原则.md NOT written, but volume_map
    still carries the rhythm tail
  - Legacy non-empty rhythm_principles section → file still written
    (back-compat preserved)

665 tests pass (same count as pre-revert).
Ma 6 месяцев назад
Родитель
Сommit
0fe03c0f49

+ 46 - 77
packages/core/src/__tests__/architect-phase5-consolidated.test.ts

@@ -10,32 +10,34 @@ import {
 import type { BookConfig } from "../models/book.js";
 
 // ---------------------------------------------------------------------------
-// Phase 5 consolidation invariants (7 sections → 6 sections after fix).
+// Phase 5 consolidation invariants (7 sections → 5 sections).
 //
-// Originally the consolidation dropped output from 7 sections to 5, but code
-// review found two small losses:
-//   - rhythm principles were forced 100% book-specific, universal rules had
-//     nowhere to live
-//   - current_state was deleted entirely, leaving non-character / non-hook
-//     env/era/macro anchors homeless
+// A brief side-trip restored current_state as a 6th "narrow env/era" section,
+// but the bench run showed the LLM emits an empty block for 3 out of 3 books
+// (修仙/玄幻/系统 genres have no real year; urban/period genres already weave
+// the era into world-tonal-ground naturally). The section is back to optional:
+// architect may omit it entirely, writeFoundationFiles still seeds
+// current_state.md with a placeholder so the consolidator has a file to
+// append to.
 //
-// Post-fix layout is 6 required sections. These tests lock in the contract so
-// future edits can't silently regress back to the 7-section layout that was
-// causing gpt-5.4 to drop tail sections, NOR over-collapse back to 5:
+// These tests lock in the 5-section contract so future edits can't silently
+// regress back to the 7-section layout that was causing gpt-5.4 to drop tail
+// sections:
 //
-//   1. The architect prompt advertises exactly 6 SECTION headers.
+//   1. The architect prompt advertises exactly 5 SECTION headers — no
+//      current_state, no rhythm_principles.
 //   2. The prompt FORBIDS duplication of protagonist-arc across story_frame
-//      and roles, and forbids re-emitting rhythm_principles.
-//   3. It carries explicit per-section budget markers, including
-//      current_state 500-800 chars.
-//   4. current_state section IS REQUIRED in architect output (narrow scope:
-//      env/era/macro prose only, not character status, not hooks).
+//      and roles, and forbids re-emitting rhythm_principles / current_state.
+//   3. It carries explicit per-section budget markers (NO current_state
+//      budget entry).
+//   4. current_state section is NOT required in architect output — legacy
+//      outputs that still carry it are accepted.
 //   5. book_rules prompt tells the LLM "YAML only, no prose".
 //   6. rhythm principles prompt allows mix of universal + concrete (≥3
 //      concretized, rest may stay universal).
 //   7. Legacy 7-section outputs still parse (backward compat).
 //   8. writeFoundationFiles seeds current_state.md with a marker placeholder
-//      when architect produced an empty current_state block.
+//      when architect produced no initial state.
 //   9. readCurrentStateWithFallback derives a substitute block from
 //      roles/*.Current_State + pending_hooks startChapter=0 rows when the
 //      seed placeholder is still on disk.
@@ -103,7 +105,7 @@ const CONSOLIDATED_RESPONSE = [
   "卷一末:主角决定留下。",
   "## 卷尾必须发生的改变",
   "身份暴露。",
-  "## 节奏原则与本书具体化",
+  "## 节奏原则(具体化 + 通用)",
   "1. 高潮间距:每 8-10 章一个大高潮。",
   "2. 喘息频率:3 章高压后插 1 章喘息。",
   "3. 钩子密度:每章章末 1 个主钩。",
@@ -166,9 +168,6 @@ const CONSOLIDATED_RESPONSE = [
   "enableFullCastTracking: false",
   "---",
   "",
-  "=== SECTION: current_state ===",
-  "2003 年 8 月。非典刚结束,城市街头还留着消毒水和口罩的气味。经济复苏前夜——楼市尚未起飞,出租屋月租不过两三百。码头正在沉默地复工,装卸工人重新排起长队;区政府大楼外墙仍挂着红幅,写字楼里已经有人悄悄讨论来年的楼盘认购。",
-  "",
   "=== SECTION: pending_hooks ===",
   "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 上游依赖 | 回收卷 | 核心 | 半衰期 | 备注 |",
   "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
@@ -176,8 +175,8 @@ const CONSOLIDATED_RESPONSE = [
   "| H02 | 0 | 初始世界 | 未开启 | 0 | 首卷中段 | 近期 | 无 | 第1卷中段 | 否 | 20 | 初始状态:体制已监视码头 |",
 ].join("\n");
 
-describe("Phase 5 consolidation — 7→6 sections, prompt contract", () => {
-  it("the zh prompt advertises exactly 6 SECTION headers (current_state restored, NO rhythm_principles)", async () => {
+describe("Phase 5 consolidation — 7→5 sections, prompt contract", () => {
+  it("the zh prompt advertises exactly 5 SECTION headers (NO current_state, NO rhythm_principles)", async () => {
     const agent = buildAgent();
     const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
       .mockResolvedValue({ content: CONSOLIDATED_RESPONSE, usage: ZERO_USAGE });
@@ -194,12 +193,14 @@ describe("Phase 5 consolidation — 7→6 sections, prompt contract", () => {
       "volume_map",
       "roles",
       "book_rules",
-      "current_state",
       "pending_hooks",
     ]);
     // rhythm_principles is explicitly NOT a standalone section — it still
     // lives inside the last paragraph of volume_map.
     expect(headers).not.toContain("rhythm_principles");
+    // current_state is no longer emitted by the architect either — era/setting
+    // context, when relevant, lives inside story_frame.世界观底色.
+    expect(headers).not.toContain("current_state");
   });
 
   it("the prompt forbids duplication across sections (dedup rule)", async () => {
@@ -216,9 +217,13 @@ describe("Phase 5 consolidation — 7→6 sections, prompt contract", () => {
     expect(system).toContain("世界铁律只写在 story_frame.世界观底色");
     // Rhythm principles: authoritative home is volume_map's closing paragraph
     expect(system).toContain("节奏原则只写在 volume_map 最后一段");
+    // Era/setting guidance: weave into story_frame.世界观底色 for year-anchored
+    // genres, omit entirely for others. NOT a separate current_state section.
+    expect(system).toContain("年代文");
+    expect(system).not.toContain("只写在 current_state");
   });
 
-  it("the prompt carries explicit per-section char budget markers (including current_state 500-800 chars)", async () => {
+  it("the prompt carries explicit per-section char budget markers (NO current_state budget)", async () => {
     const agent = buildAgent();
     const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
       .mockResolvedValue({ content: CONSOLIDATED_RESPONSE, usage: ZERO_USAGE });
@@ -230,8 +235,9 @@ describe("Phase 5 consolidation — 7→6 sections, prompt contract", () => {
     expect(system).toContain("volume_map ≤ 5000 chars");
     expect(system).toContain("roles 总 ≤ 8000 chars");
     expect(system).toContain("book_rules ≤ 500 chars");
-    expect(system).toContain("current_state 500-800 chars");
     expect(system).toContain("pending_hooks ≤ 2000 chars");
+    // current_state budget is gone — it's not a section any more.
+    expect(system).not.toContain("current_state 500-800 chars");
   });
 
   it("the rhythm principles prompt allows a mix of universal + concrete (≥3 concretized, rest may stay universal)", async () => {
@@ -252,7 +258,7 @@ describe("Phase 5 consolidation — 7→6 sections, prompt contract", () => {
     expect(system).toContain("具体化 + 通用混合是合法的");
   });
 
-  it("the English prompt also carries the 6-section / dedup / budget rules and rhythm universal allowance", async () => {
+  it("the English prompt also carries the 5-section / dedup / budget rules and rhythm universal allowance", async () => {
     const agent = buildAgent();
     const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
       .mockResolvedValue({ content: CONSOLIDATED_RESPONSE, usage: ZERO_USAGE });
@@ -268,11 +274,10 @@ describe("Phase 5 consolidation — 7→6 sections, prompt contract", () => {
       "volume_map",
       "roles",
       "book_rules",
-      "current_state",
       "pending_hooks",
     ]);
     expect(system).toContain("story_frame ≤ 3000 chars");
-    expect(system).toContain("current_state 500-800 chars");
+    expect(system).not.toContain("current_state 500-800 chars");
     expect(system).toContain("YAML only");
     // Rhythm universal allowance
     expect(system).toContain("At least 3 must be concretized for this book");
@@ -295,7 +300,7 @@ describe("Phase 5 consolidation — 7→6 sections, prompt contract", () => {
   });
 });
 
-describe("Phase 5 consolidation — parser requires 6-section output (current_state restored)", () => {
+describe("Phase 5 consolidation — parser accepts 5-section output (current_state is optional)", () => {
   let bookDir: string;
 
   beforeEach(async () => {
@@ -307,54 +312,23 @@ describe("Phase 5 consolidation — parser requires 6-section output (current_st
     vi.restoreAllMocks();
   });
 
-  it("rejects a response missing the current_state section (current_state is required again)", async () => {
-    const agent = buildAgent();
-    // Take the full consolidated response and drop the current_state section
-    const withoutCurrentState = CONSOLIDATED_RESPONSE.replace(
-      /=== SECTION: current_state ===[\s\S]*?(?==== SECTION: pending_hooks ===)/,
-      "",
-    );
-    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
-      .mockResolvedValue({ content: withoutCurrentState, usage: ZERO_USAGE });
-
-    await expect(agent.generateFoundation(baseBook())).rejects.toThrow(/current_state/i);
-  });
-
-  it("accepts a response with a narrow current_state block and writes it to disk", async () => {
+  it("accepts a response with no current_state section and seeds the placeholder on disk", async () => {
     const agent = buildAgent();
+    // CONSOLIDATED_RESPONSE already omits current_state — feed it straight in.
     vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
       .mockResolvedValue({ content: CONSOLIDATED_RESPONSE, usage: ZERO_USAGE });
 
     const out = await agent.generateFoundation(baseBook());
     await agent.writeFoundationFiles(bookDir, out, false, "zh");
 
-    // Architect output carries the narrow env/era prose
-    expect(out.currentState).toContain("2003 年 8 月");
-    expect(out.currentState).toContain("经济复苏前夜");
+    // Architect output has empty currentState — there's no section to parse.
+    expect(out.currentState.trim()).toBe("");
 
-    // current_state.md on disk is the LLM's prose, NOT the seed placeholder.
+    // writeFoundationFiles still writes current_state.md, but as a seed
+    // placeholder the fallback reader can detect.
     const onDisk = await readFile(join(bookDir, "story/current_state.md"), "utf-8");
-    expect(isCurrentStateSeedPlaceholder(onDisk)).toBe(false);
-    expect(onDisk).toContain("2003 年 8 月");
-  });
-
-  it("falls back to the seed placeholder when the LLM emits an empty current_state block (back-compat)", async () => {
-    const emptyCurrentState = CONSOLIDATED_RESPONSE.replace(
-      /(=== SECTION: current_state ===\n)[\s\S]*?(?=\n=== SECTION: pending_hooks ===)/,
-      "$1\n",
-    );
-    const agent = buildAgent();
-    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
-      .mockResolvedValue({ content: emptyCurrentState, usage: ZERO_USAGE });
-
-    const out = await agent.generateFoundation(baseBook());
-    await agent.writeFoundationFiles(bookDir, out, false, "zh");
-
-    // LLM emitted the header but no prose — parser accepts, writeFoundationFiles seeds.
-    expect(out.currentState.trim()).toBe("");
-    const seed = await readFile(join(bookDir, "story/current_state.md"), "utf-8");
-    expect(isCurrentStateSeedPlaceholder(seed)).toBe(true);
-    expect(seed).toContain("建书时占位");
+    expect(isCurrentStateSeedPlaceholder(onDisk)).toBe(true);
+    expect(onDisk).toContain("建书时占位");
   });
 
   it("preserves legacy 7-section input (current_state + rhythm_principles still present)", async () => {
@@ -428,16 +402,11 @@ describe("Phase 5 consolidation — readCurrentStateWithFallback derives initial
   });
 
   it("returns a derived block built from roles/*.Current_State + seed hooks when current_state.md is a seed placeholder", async () => {
-    // Simulate a pre-fix book (or an LLM that emitted an empty current_state
-    // block) — writeFoundationFiles falls back to the seed placeholder, which
-    // triggers the fallback reader.
-    const emptyCurrentState = CONSOLIDATED_RESPONSE.replace(
-      /(=== SECTION: current_state ===\n)[\s\S]*?(?=\n=== SECTION: pending_hooks ===)/,
-      "$1\n",
-    );
+    // CONSOLIDATED_RESPONSE emits no current_state section — writeFoundationFiles
+    // seeds the placeholder, which triggers the fallback reader.
     const agent = buildAgent();
     vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
-      .mockResolvedValue({ content: emptyCurrentState, usage: ZERO_USAGE });
+      .mockResolvedValue({ content: CONSOLIDATED_RESPONSE, usage: ZERO_USAGE });
 
     const out = await agent.generateFoundation(baseBook());
     await agent.writeFoundationFiles(bookDir, out, false, "zh");

+ 132 - 7
packages/core/src/__tests__/architect-phase5.test.ts

@@ -255,7 +255,7 @@ describe("ArchitectAgent — Phase 5 prose output", () => {
     await expect(readFile(join(storyDir, "volume_outline.md"), "utf-8")).rejects.toThrow();
   });
 
-  it("still requires book_rules / roles / current_state / pending_hooks to be present", async () => {
+  it("still requires book_rules / roles / pending_hooks to be present (current_state is optional after consolidation)", async () => {
     const agent = buildAgent();
     vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
       .mockResolvedValue({
@@ -270,10 +270,17 @@ describe("ArchitectAgent — Phase 5 prose output", () => {
         usage: ZERO_USAGE,
       });
 
-    // book_rules + roles + current_state all missing — the error lists them.
+    // book_rules + roles both missing — the error message lists them.
     await expect(agent.generateFoundation(baseBook())).rejects.toThrow(/book_rules/i);
     await expect(agent.generateFoundation(baseBook())).rejects.toThrow(/roles/i);
-    await expect(agent.generateFoundation(baseBook())).rejects.toThrow(/current_state/i);
+    // current_state is NOT in the missing list — it's optional now.
+    try {
+      await agent.generateFoundation(baseBook());
+      throw new Error("should have rejected");
+    } catch (error) {
+      const message = (error as Error).message;
+      expect(message).not.toMatch(/current_state/i);
+    }
   });
 
   it("requires at least one of story_frame or legacy story_bible", async () => {
@@ -317,13 +324,131 @@ describe("ArchitectAgent — Phase 5 prose output", () => {
     expect(system).toContain("=== SECTION: story_frame ===");
     expect(system).toContain("=== SECTION: volume_map ===");
     // Phase 5 consolidation: rhythm_principles is merged into volume_map's
-    // closing paragraph and remains NOT a standalone SECTION header.
+    // closing paragraph and is NOT a standalone SECTION header.
     expect(system).not.toContain("=== SECTION: rhythm_principles ===");
-    // Post-consolidation-fix: current_state restored as a narrow 6th section
-    // (500-800 chars, env/era/macro prose only).
-    expect(system).toContain("=== SECTION: current_state ===");
+    // current_state is also no longer produced by the architect — era/setting
+    // anchors (when the genre pins to a real year) are woven into
+    // story_frame.世界观底色; other genres omit them entirely.
+    expect(system).not.toContain("=== SECTION: current_state ===");
     expect(system).toContain("=== SECTION: roles ===");
     expect(system).toContain("=== SECTION: book_rules ===");
     expect(system).toContain("=== SECTION: pending_hooks ===");
   });
 });
+
+describe("writeFoundationFiles — rhythm file is skipped when rhythmPrinciples is empty", () => {
+  let bookDir: string;
+
+  beforeEach(async () => {
+    bookDir = await mkdtemp(join(tmpdir(), "inkos-phase5-rhythm-skip-"));
+  });
+
+  afterEach(async () => {
+    await rm(bookDir, { recursive: true, force: true });
+    vi.restoreAllMocks();
+  });
+
+  it("does not write outline/节奏原则.md when the architect output carries no rhythm block", async () => {
+    // CONSOLIDATED_RESPONSE (trimmed) has rhythm merged into volume_map tail
+    // and no standalone rhythm_principles section — rhythmPrinciples ends up
+    // an empty string.
+    const noRhythmResponse = [
+      "=== SECTION: story_frame ===",
+      "## 主题与基调",
+      "一段主题散文。",
+      "## 核心冲突",
+      "主角 vs 体制。",
+      "## 世界观底色",
+      "湿冷的沿海城市。",
+      "## 终局方向",
+      "最后一个镜头。",
+      "",
+      "=== SECTION: volume_map ===",
+      "## 各卷主题与情绪曲线",
+      "三卷结构。",
+      "## 关键节点章",
+      "第 17 章回家。",
+      "## 卷间钩子",
+      "钩子 H01。",
+      "## 角色阶段性目标",
+      "卷一末:定调。",
+      "## 卷尾必须发生的改变",
+      "身份暴露。",
+      "## 节奏原则(具体化 + 通用)",
+      "1-6. 节奏 merged into volume_map tail.",
+      "",
+      "=== SECTION: roles ===",
+      "---ROLE---",
+      "tier: major",
+      "name: 林辞",
+      "---CONTENT---",
+      "## 核心标签",
+      "沉默",
+      "## 反差细节",
+      "罐头",
+      "## 人物小传",
+      "过往。",
+      "## 当前现状",
+      "账房。",
+      "## 关系网络",
+      "与沈默。",
+      "## 内在驱动",
+      "查真相。",
+      "## 成长弧光",
+      "从独到托。",
+      "",
+      "=== SECTION: book_rules ===",
+      "---",
+      "version: \"1.0\"",
+      "---",
+      "",
+      "=== SECTION: pending_hooks ===",
+      "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| H01 | 1 | 主线 | 未开启 | 0 | 终章 | 终局 | 笔记本 |",
+    ].join("\n");
+
+    const agent = buildAgent();
+    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({ content: noRhythmResponse, usage: ZERO_USAGE });
+
+    const out = await agent.generateFoundation(baseBook());
+    expect((out.rhythmPrinciples ?? "").trim()).toBe("");
+
+    await agent.writeFoundationFiles(bookDir, out, false, "zh");
+
+    // No standalone rhythm file on disk — rhythm content lives in
+    // volume_map's closing paragraph.
+    await expect(
+      readFile(join(bookDir, "story/outline/节奏原则.md"), "utf-8"),
+    ).rejects.toThrow();
+
+    // But volume_map still exists and carries the rhythm tail.
+    const volumeMap = await readFile(
+      join(bookDir, "story/outline/volume_map.md"),
+      "utf-8",
+    );
+    expect(volumeMap).toContain("节奏原则(具体化 + 通用)");
+  });
+
+  it("still writes outline/rhythm_principles.md (en) when the architect emits a standalone block (legacy path)", async () => {
+    // Simulate a legacy-shaped output that DOES carry an explicit
+    // rhythm_principles section — writeFoundationFiles must still honour it
+    // for back-compat.
+    const withRhythmResponse = SAMPLE_RESPONSE;
+    const agent = buildAgent();
+    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({ content: withRhythmResponse, usage: ZERO_USAGE });
+
+    const out = await agent.generateFoundation(baseBook());
+    expect((out.rhythmPrinciples ?? "").trim().length).toBeGreaterThan(0);
+
+    await agent.writeFoundationFiles(bookDir, out, false, "zh");
+
+    const rhythm = await readFile(
+      join(bookDir, "story/outline/节奏原则.md"),
+      "utf-8",
+    );
+    expect(rhythm).toContain("高潮间距");
+  });
+});

+ 57 - 78
packages/core/src/agents/architect.ts

@@ -14,16 +14,15 @@ import type { StoredHook } from "../state/memory-db.js";
 
 // ---------------------------------------------------------------------------
 // Phase 5 (v13) — Static 骨架 layer collapse
-// Phase 5 consolidation — 7 sections → 6 sections (output shrinks ~20–35%).
+// Phase 5 consolidation — 7 sections → 5 sections (output shrinks ~25–40%).
 //
 // Architect now produces 2 prose outline files + one-file-per-character roles/
-// folder, plus compat pointer shims. The LLM output contract is now 6 blocks:
+// folder, plus compat pointer shims. The LLM output contract is 5 blocks:
 //
 //   === SECTION: story_frame ===   4 散文段(主题 / 冲突 / 世界铁律+质感 / 终局)
 //   === SECTION: volume_map ===    5 散文段 + 尾段「6 条节奏原则(具体化 + 通用)」
 //   === SECTION: roles ===         一人一卡;主角卡承载完整弧线(起点→终点→代价)
 //   === SECTION: book_rules ===    仅 YAML frontmatter,零散文
-//   === SECTION: current_state ===  500-800 chars,仅「非角色非钩子」的环境/时代锚
 //   === SECTION: pending_hooks ===  13-column 表;可含 startChapter=0 种子行
 //
 // Consolidation rules (MUST reflect in prompt):
@@ -32,23 +31,23 @@ import type { StoredHook } from "../state/memory-db.js";
 //   - 节奏原则只写在 volume_map 尾段,不作为独立 section
 //     (至少 3 条具体化,其余可为通用原则)
 //   - 初始状态拆分:角色当前现状 → roles.当前现状;初始钩子 → pending_hooks (startChapter=0);
-//     环境/时代/大局锚 → current_state(窄化后的 500-800 chars prose 段)
+//     环境/时代锚(仅历史/年代题材需要)→ 自然融入 story_frame.世界观底色
+//   - 独立的 current_state section 已删除。现状只在运行时写入 current_state.md
+//     (consolidator 每章追加),建书时架构师不产出结构化初始态。
 //
-// Budget table (8 content items — 6 LLM sections + 2 structural bounds):
+// Budget table (4 content items — LLM sections):
 //   story_frame ≤ 3000 chars / volume_map ≤ 5000 chars / roles 总 ≤ 8000 chars
-//   book_rules ≤ 500 chars (YAML only) / current_state 500-800 chars
-//   pending_hooks ≤ 2000 chars
+//   book_rules ≤ 500 chars (YAML only) / pending_hooks ≤ 2000 chars
 //
 // 输出落盘 contract(未变):
 //   outline/story_frame.md      ← 4 prose sections + YAML frontmatter
 //   outline/volume_map.md       ← 5 prose sections + 节奏原则尾段
-//   outline/节奏原则.md         ← 从 volume_map 尾段派生(或空占位)
 //   roles/主要角色/<name>.md    ← one file per major character
 //   roles/次要角色/<name>.md    ← one file per minor character
 //   story_bible.md              ← compat shim
 //   character_matrix.md         ← compat shim
 //   book_rules.md               ← compat shim
-//   current_state.md            ← seed 文件(空/最小),consolidator 运行时追加
+//   current_state.md            ← seed 占位文件(运行时 consolidator 每章追加)
 //   pending_hooks.md            ← 架构师初始伏笔池
 //   emotional_arcs.md           ← runtime state
 //
@@ -129,7 +128,7 @@ export class ArchitectAgent extends BaseAgent {
       : this.buildChineseFoundationPrompt(book, gp, genreBody, contextBlock, reviewFeedbackBlock, numericalBlock, powerBlock, eraBlock);
 
     const langPrefix = resolvedLanguage === "en"
-      ? `【LANGUAGE OVERRIDE】ALL output (story_frame, volume_map, roles, book_rules, current_state, pending_hooks) MUST be written in English. Character names, place names, and all prose must be in English. The === SECTION: === tags remain unchanged. Do NOT emit a rhythm_principles section — rhythm principles live inside the last paragraph of volume_map. current_state is narrowed to 500-800 chars of non-character, non-hook environment/era/macro prose.\n\n`
+      ? `【LANGUAGE OVERRIDE】ALL output (story_frame, volume_map, roles, book_rules, pending_hooks) MUST be written in English. Character names, place names, and all prose must be in English. The === SECTION: === tags remain unchanged. Do NOT emit rhythm_principles or current_state sections — rhythm principles live inside the last paragraph of volume_map; environment/era anchors (when relevant) are woven into story_frame's world-tonal-ground paragraph.\n\n`
       : "";
     const userMessage = resolvedLanguage === "en"
       ? `Generate the complete foundation for a ${gp.name} novel titled "${book.title}". Write everything in English.`
@@ -173,17 +172,16 @@ ${numericalBlock}
 ${powerBlock}
 ${eraBlock}
 
-## 输出结构(6 个 SECTION,严格按 === SECTION: === 分块,不要漏任何一块)
+## 输出结构(5 个 SECTION,严格按 === SECTION: === 分块,不要漏任何一块)
 
 ## 去重铁律(必读)
-禁止在多段里重复同一事实。主角弧线只写在 roles;世界铁律只写在 story_frame.世界观底色;节奏原则只写在 volume_map 最后一段;角色当前现状只写在 roles.当前现状;初始钩子只写在 pending_hooks(startChapter=0 行);环境/时代/大局锚只写在 current_state。如果一个段落写了另一段的内容,删掉。
+禁止在多段里重复同一事实。主角弧线只写在 roles;世界铁律只写在 story_frame.世界观底色;节奏原则只写在 volume_map 最后一段;角色当前现状只写在 roles.当前现状;初始钩子只写在 pending_hooks(startChapter=0 行)。**如果本书是年代文/历史同人/都市重生等需要年份、季节、重大历史事件作为锚点的题材**,把环境/时代锚自然织进 story_frame.世界观底色("1985 年 7 月,非典刚过"这类);**修仙/玄幻/系统等没有真实年份的题材直接省略**,不要硬凑。如果一个段落写了另一段的内容,删掉。
 
 ## 预算(超预算必删)
 - story_frame ≤ 3000 chars
 - volume_map ≤ 5000 chars
 - roles 总 ≤ 8000 chars
 - book_rules ≤ 500 chars(仅 YAML)
-- current_state 500-800 chars(窄化后的环境/时代/大局散文段)
 - pending_hooks ≤ 2000 chars
 
 === SECTION: story_frame ===
@@ -253,7 +251,7 @@ name: <角色名>
 **只有主角必须写本段;其他 major 角色如果弧线分量重也可以写,否则略过。**主角从哪里出发(身份、处境、核心缺陷、一开始最想要什么),到哪里落脚(最终变成什么样的人、拿到/失去什么),为了这个落脚他付出了什么不可逆的代价(关系、身体、信念、某段过去)。不要只写"变强"这种平面变化,要写**内在的位移**。本段是之前 story_frame.段 2 迁移过来的权威位置,写足写实。
 
 ## 当前现状(第 0 章初始状态)
-(第 0 章时他在哪、做什么、处境如何、最近最烦心的事。**只写角色个人处境**——环境/时代/大局锚写在独立的 current_state section;初始钩子写在 pending_hooks 的 startChapter=0 行。)
+(第 0 章时他在哪、做什么、处境如何、最近最烦心的事。**只写角色个人处境**——初始钩子写在 pending_hooks 的 startChapter=0 行;环境/时代锚(如果是需要年份的题材)织进 story_frame.世界观底色。不再有独立的 current_state section。)
 
 ## 关系网络
 (与主角、与其他重要角色的关系——一句话一条,关系不是标签是动态。)
@@ -305,16 +303,6 @@ enableFullCastTracking: false
 ---
 \`\`\`
 
-=== SECTION: current_state ===
-
-**职能窄化**:这一段只写**非角色、非钩子的环境/时代/大局初始态**。
-- 写什么:时代锚(年份 / 季节 / 重大历史事件坐标)、空间锚(主要场景的当下状态)、社会/经济/政治大环境
-- 不写:主角/配角的个人处境(→ roles.当前现状)、初始钩子/悬念(→ pending_hooks 起始行)
-- 预算:500-800 chars
-- 用 prose 段落,不要列表
-
-例:"2003 年 8 月。非典刚结束,城市街头还留着消毒水和口罩的气味。经济复苏前夜——楼市尚未起飞,出租屋月租不过两三百。码头正在沉默地复工,装卸工人重新排起长队;区政府大楼外墙仍挂着'打赢疫情攻坚战'的红幅,但写字楼里已经有人悄悄讨论来年的楼盘认购。"
-
 === SECTION: pending_hooks ===
 
 初始伏笔池(Markdown表格),Phase 7 扩展列:
@@ -329,7 +317,7 @@ enableFullCastTracking: false
 - 第10列「核心」:是否主线承重伏笔 true / false。主线承重伏笔一本书最多 3-7 条(主谜团、身世、核心承诺),其余次要伏笔填 false
 - 第11列「半衰期」:可选,整数章数。若不填自动按回收节奏推导(立即/近期 = 10、中程 = 30、慢烧/终局 = 80)
 - 初始线索放备注列,不放第5列
-- **初始世界状态 / 初始敌我关系** 如果有关键信息(例如"主角身上带着父亲的笔记本"、"体制已经开始监视码头"),可以作为 startChapter=0 的种子行录入,备注列说明其"初始状态"属性。——注:环境/时代/大局锚请写在独立的 current_state section,不要挤进这张表。
+- **初始世界状态 / 初始敌我关系** 如果有关键信息(例如"主角身上带着父亲的笔记本"、"体制已经开始监视码头"),可以作为 startChapter=0 的种子行录入,备注列说明其"初始状态"属性。
 
 ## 最后强调
 - 符合${book.platform}平台口味、${gp.name}题材特征
@@ -337,8 +325,7 @@ enableFullCastTracking: false
 - 伏笔前后呼应、配角有独立动机不是工具人
 - **story_frame / volume_map / roles 必须是散文密度,不要退化成 bullet**
 - **book_rules 只留 YAML,不要写散文**
-- **不要输出 rhythm_principles 独立 section**——节奏原则合并进 volume_map 尾段
-- **current_state 职能窄化**:只写非角色、非钩子的环境/时代/大局散文,500-800 chars;角色初始状态去 roles.当前现状,初始钩子去 pending_hooks
+- **不要输出 rhythm_principles 或 current_state 独立 section**——节奏原则合并进 volume_map 尾段;角色初始状态写在 roles.当前现状,初始钩子写在 pending_hooks(startChapter=0 行),环境/时代锚(仅历史/年代/都市重生等需要年份的题材)织进 story_frame.世界观底色,不要硬凑
 - **pending_hooks 表必须包含 Phase 7 扩展列——depends_on 标出因果链、pays_off_in_arc 锁定回收大致位置、core_hook 标记主线承重伏笔(3-7 条)、half_life 仅给重点伏笔设置**`;
   }
 
@@ -369,17 +356,16 @@ ${numericalBlock}
 ${powerBlock}
 ${eraBlock}
 
-## Output contract (6 === SECTION: === blocks)
+## Output contract (5 === SECTION: === blocks)
 
 ## Deduplication rule (MANDATORY)
-Do not duplicate the same fact across sections. The protagonist's arc lives only in roles; world hard-rules live only in story_frame; rhythm principles live only in the last paragraph of volume_map; character initial status lives only in roles.Current_State; initial hooks live only in pending_hooks (start_chapter=0 rows); environment/era/macro initial state lives only in current_state. If a section repeats content that belongs elsewhere, delete it.
+Do not duplicate the same fact across sections. The protagonist's arc lives only in roles; world hard-rules live only in story_frame; rhythm principles live only in the last paragraph of volume_map; character initial status lives only in roles.Current_State; initial hooks live only in pending_hooks (start_chapter=0 rows). **When the book is period fiction / historical fanfic / urban reincarnation** — anything pinned to a real year, season, or historic marker — weave the environment/era anchor into story_frame's world-tonal-ground paragraph (e.g. "July 1985, just after the SARS wave"). **For cultivation / high-fantasy / system genres that have no real-world year, skip it entirely** — do not fabricate an era anchor. If a section repeats content that belongs elsewhere, delete it.
 
 ## Output budget (over-budget means cut)
 - story_frame ≤ 3000 chars
 - volume_map ≤ 5000 chars
 - roles ≤ 8000 chars total
 - book_rules ≤ 500 chars (YAML only)
-- current_state 500-800 chars (narrowed: env/era/macro prose only)
 - pending_hooks ≤ 2000 chars
 
 === SECTION: story_frame ===
@@ -441,7 +427,7 @@ name: <character name>
 **Mandatory for the protagonist; optional for other majors with substantial arcs.** Where they start (identity, situation, core flaw, initial desire); where they land (who they become, what they gain or lose); the irreversible cost they pay for that landing. Show internal displacement, not just growth. This section absorbs what used to live in story_frame.02_Protagonist_Arc.
 
 ## Current_State (initial state at chapter 0)
-(Where they are at chapter 0, what's on their mind, most recent worry. **Character-only**: environment / era / macro anchors go in the separate current_state section; initial hooks go in pending_hooks start_chapter=0 rows.)
+(Where they are at chapter 0, what's on their mind, most recent worry. **Character-only**: initial hooks go in pending_hooks start_chapter=0 rows; environment / era anchors (when the genre has a real year) are woven into story_frame's world-tonal-ground paragraph. No separate current_state section is produced.)
 
 ## Relationship_Network
 (With protagonist, with other major characters. One line each. Relationships are dynamic, not labels.)
@@ -493,16 +479,6 @@ enableFullCastTracking: false
 ---
 \`\`\`
 
-=== SECTION: current_state ===
-
-**Narrow scope**: non-character, non-hook environment/era/macro initial state only.
-- Include: era anchors (year / season / historic markers), spatial anchors (key scenes' current status), social/economic/political macro
-- Exclude: protagonist or supporting character personal status (→ roles.当前现状), initial hooks/suspense (→ pending_hooks startChapter=0)
-- Budget: 500-800 chars
-- Prose paragraph; no lists
-
-Example: "August 2003. SARS has just ended; the city streets still smell of disinfectant and mask cloth. Pre-recovery — the housing market hasn't taken off yet, rent still runs two or three hundred a month. The harbor is quietly resuming work, dockhands queuing up again; the district office building still carries the red banner from the outbreak campaign, but inside the office towers people have started whispering about next year's property launches."
-
 === SECTION: pending_hooks ===
 
 Initial hook pool (Markdown table), Phase 7 extended columns:
@@ -517,7 +493,7 @@ Rules:
 - Column 10 (core_hook): true / false. Core hooks are main-line load-bearing (central mystery, identity, key promise). A book typically has 3-7 cores; everything else is false
 - Column 11 (half_life): optional integer chapters. If blank, derived from payoff_timing (immediate/near-term = 10, mid-arc = 30, slow-burn/endgame = 80)
 - Put initial signal text in notes, not column 5
-- **Initial world / alliance state**: any load-bearing initial condition ("protagonist carries the father's notebook", "the regime already watches the harbor") can be seeded as a start_chapter=0 row with a note-column tag indicating its initial-state nature. — Note: environment / era / macro anchors belong in the separate current_state section, not this table.
+- **Initial world / alliance state**: any load-bearing initial condition ("protagonist carries the father's notebook", "the regime already watches the harbor") can be seeded as a start_chapter=0 row with a note-column tag indicating its initial-state nature.
 
 ## Final emphasis
 - Fit ${book.platform} platform taste and ${gp.name} genre traits
@@ -525,8 +501,7 @@ Rules:
 - Hooks planted with payoff promises; supporting characters have independent motivation
 - **story_frame / volume_map / roles must be prose density — no bullet-list degradation**
 - **book_rules is YAML only — no prose body**
-- **Do NOT emit rhythm_principles as a separate section** — rhythm principles live in the last paragraph of volume_map
-- **current_state is narrow**: non-character, non-hook env/era/macro prose, 500-800 chars; character initial status goes in roles.Current_State, initial hooks go in pending_hooks
+- **Do NOT emit rhythm_principles or current_state as separate sections** — rhythm principles live in the last paragraph of volume_map; character initial status goes in roles.Current_State; initial hooks go in pending_hooks (start_chapter=0 rows); environment / era anchors (only when the genre has a real year) are woven into story_frame's world-tonal-ground paragraph
 - **pending_hooks table MUST carry Phase 7 extended columns — depends_on spells out the causal chain, pays_off_in_arc locks the approximate payoff location, core_hook marks main-line load-bearing hooks (3-7 per book), half_life only on priority hooks**`;
   }
 
@@ -558,18 +533,19 @@ Rules:
     const legacyStoryBible = parsedSections.get("story_bible") ?? "";
     const legacyVolumeOutline = parsedSections.get("volume_outline") ?? "";
     const bookRules = parsedSections.get("book_rules");
-    // Phase 5 consolidation fix: current_state restored as a required section,
-    // scope-narrowed to 500-800 chars of non-character, non-hook env/era/macro
-    // prose. Character initial status still lives in roles.Current_State;
-    // initial hooks still live in pending_hooks start_chapter=0 rows. The
-    // fallback reader (readCurrentStateWithFallback) stays in place for
-    // pre-fix books that happen to have empty current_state on disk.
-    const currentStateRaw = parsedSections.get("current_state");
+    // Phase 5 consolidation: current_state is no longer a required section.
+    // Legacy books (v12 / Phase 5 initial / pre-revert) and import/fanfic
+    // regenerations may still produce it — accept the value when present,
+    // fall through to empty seed when absent (consolidator will populate at
+    // runtime). Era/setting anchors that used to motivate a separate
+    // current_state block now live naturally inside story_frame.世界观底色
+    // for genres that have a real-world year anchor; other genres (修仙/玄幻/
+    // 系统文) omit them entirely.
+    const currentStateLegacy = parsedSections.get("current_state") ?? "";
     const pendingHooksRaw = parsedSections.get("pending_hooks");
 
-    // 6-section required contract: story_frame (or legacy story_bible),
-    // volume_map (or legacy volume_outline), roles, book_rules, current_state,
-    // pending_hooks.
+    // 5-section required contract: story_frame (or legacy story_bible),
+    // volume_map (or legacy volume_outline), roles, book_rules, pending_hooks.
     //
     // Backward compat: v12 outputs used story_bible/volume_outline and
     // embedded character data inside story_bible — they had no roles block.
@@ -586,13 +562,6 @@ Rules:
     if (!effectiveVolumeMap) missing.push("volume_map");
     if (!rolesRaw.trim() && !usingLegacyOutlineNames) missing.push("roles");
     if (!bookRules) missing.push("book_rules");
-    // current_state is required again post-fix. Legacy outputs that used the
-    // Phase 5 initial layout (no current_state) are rare — for those, the
-    // fallback reader derives a substitute block at runtime from roles +
-    // hooks, so we keep accepting an empty string as long as the section
-    // header is present (LLM may emit an empty block for a book that truly
-    // has nothing macro to say).
-    if (currentStateRaw === undefined) missing.push("current_state");
     if (!pendingHooksRaw) missing.push("pending_hooks");
     if (missing.length > 0) {
       throw new Error(
@@ -615,11 +584,10 @@ Rules:
       storyBible,
       volumeOutline,
       bookRules: bookRules!,
-      // current_state is required again post-consolidation-fix. The architect
-      // writes 500-800 chars of non-character, non-hook env/era/macro prose.
-      // When the LLM emits an empty section, writeFoundationFiles falls back
-      // to the seed placeholder so consumers still see a valid file on disk.
-      currentState: currentStateRaw ?? "",
+      // currentState: empty string when architect no longer emits the section;
+      // writeFoundationFiles seeds current_state.md with a placeholder so
+      // consolidator / state-bootstrap readers find a valid file on first boot.
+      currentState: currentStateLegacy,
       pendingHooks,
       storyFrame: effectiveStoryFrame,
       volumeMap: effectiveVolumeMap,
@@ -736,8 +704,17 @@ Rules:
     // Phase 5 primary prose files
     writes.push(writeFile(join(outlineDir, "story_frame.md"), storyFrame, "utf-8"));
     writes.push(writeFile(join(outlineDir, "volume_map.md"), volumeMap, "utf-8"));
-    const rhythmFileName = language === "en" ? "rhythm_principles.md" : "节奏原则.md";
-    writes.push(writeFile(join(outlineDir, rhythmFileName), rhythmPrinciples, "utf-8"));
+    // Phase 5 consolidation: rhythm principles live inside the last paragraph
+    // of volume_map. A separate 节奏原则.md / rhythm_principles.md file is only
+    // written when the architect happened to produce a standalone block (legacy
+    // 7-section output / foundation-reviewer round-trips that still split it
+    // out). Skipping the empty write avoids 0-byte files that mislead the UI
+    // and fight against the "no duplication" rule — readers who need the rhythm
+    // content already pull it from volume_map's closing paragraph.
+    if (rhythmPrinciples.trim()) {
+      const rhythmFileName = language === "en" ? "rhythm_principles.md" : "节奏原则.md";
+      writes.push(writeFile(join(outlineDir, rhythmFileName), rhythmPrinciples, "utf-8"));
+    }
 
     // Roles — one file per character
     for (const role of roles) {
@@ -774,13 +751,15 @@ Rules:
     ));
 
     // Runtime state files.
-    // Phase 5 consolidation fix: architect emits a narrow current_state
-    // section (500-800 chars, env/era/macro prose only). When the LLM's
-    // output is empty (rare — only happens for books with nothing macro to
-    // say), we fall back to the seed placeholder so isCompleteBookDirectory()
-    // still sees a valid file and the consolidator has somewhere to append.
-    // Per-character state still lives in roles/*.Current_State; initial hook
-    // rows still live in pending_hooks with start_chapter=0.
+    // Phase 5 consolidation: the architect no longer emits a current_state
+    // section (only 3 genres — 港综同人/年代文/都市重生 — benefit from a
+    // separate era anchor, and those fold naturally into story_frame.世界观底色).
+    // We still write current_state.md with a seed placeholder so
+    // isCompleteBookDirectory() sees it on first boot and the runtime
+    // consolidator has a file to append each chapter's state into.
+    // Per-character state lives in roles/*.Current_State; initial hook rows
+    // live in pending_hooks with start_chapter=0. Legacy books / imports that
+    // still produced the section keep their content as-is.
     const currentStateSeed = output.currentState?.trim()
       ? output.currentState
       : (language === "en"
@@ -869,7 +848,7 @@ ${numericalBlock}
 ${continuationDirective}
 
 ## Output contract
-Follow the consolidated 6-section === SECTION: === layout: story_frame, volume_map, roles, book_rules, current_state, pending_hooks. Do NOT emit rhythm_principles — rhythm principles live in the last paragraph of volume_map. current_state is narrow (500-800 chars of non-character, non-hook environment/era/macro prose). Character initial status lives in roles.Current_State; initial hooks live in pending_hooks start_chapter=0 rows.
+Follow the consolidated 5-section === SECTION: === layout: story_frame, volume_map, roles, book_rules, pending_hooks. Do NOT emit rhythm_principles or current_state — rhythm principles live in the last paragraph of volume_map; character initial status lives in roles.Current_State; initial hooks live in pending_hooks start_chapter=0 rows; era / setting anchors (only when the genre pins to a real year) are woven into story_frame's world-tonal-ground paragraph.
 
 All prose must be derived from the source text. Do not invent settings. For volume_map, treat existing chapters as "review" (one paragraph) and continuation as prose chapter-level planning. Hook extraction must be complete (every unresolved clue).
 
@@ -890,7 +869,7 @@ ${numericalBlock}
 ${continuationDirective}
 
 ## 输出契约
-合并后的 6 段 === SECTION: === 结构:story_frame / volume_map / roles / book_rules / current_state / pending_hooks。**不要输出 rhythm_principles**——节奏原则合并进 volume_map 尾段。current_state 职能窄化:500-800 chars 的非角色、非钩子环境/时代/大局散文。角色初始状态写在 roles.当前现状;初始钩子写在 pending_hooks startChapter=0 行。
+合并后的 5 段 === SECTION: === 结构:story_frame / volume_map / roles / book_rules / pending_hooks。**不要输出 rhythm_principles 或 current_state 两个 section**——节奏原则合并进 volume_map 尾段,角色初始状态合并进 roles.当前现状,初始钩子写在 pending_hooks startChapter=0 行;环境/时代锚(只有年代文 / 历史同人 / 都市重生等真实年份题材需要)织进 story_frame.世界观底色,其他题材直接省略。
 
 所有 prose 必须从正文中推导,不得臆造。volume_map 中,已有章节作为"回顾段"(一段散文),续写部分写到章级 prose。伏笔识别要完整(所有悬而未决的线索)。`;
 
@@ -943,7 +922,7 @@ ${fanficCanon}
 ${genreBody}
 
 ## 输出契约
-严格按合并后的 6 段 === SECTION: === 块输出:story_frame / volume_map / roles / book_rules / current_state / pending_hooks。**不要输出 rhythm_principles**(节奏原则合并进 volume_map 尾段)。current_state 职能窄化:500-800 chars 非角色、非钩子的环境/时代/大局散文;角色初始状态写在 roles.当前现状,初始钩子写在 pending_hooks startChapter=0 行。
+严格按合并后的 5 段 === SECTION: === 块输出:story_frame / volume_map / roles / book_rules / pending_hooks。**不要输出 rhythm_principles 或 current_state**:节奏原则合并进 volume_map 尾段;角色初始状态写在 roles.当前现状,初始钩子写在 pending_hooks startChapter=0 行;环境/时代锚(仅当同人的原作/本作锚定真实年份时)织进 story_frame.世界观底色,其他情况省略。
 
 - 主要角色必须来自原作正典
 - 可添加原创配角,标注"原创"

+ 6 - 1
packages/core/src/pipeline/agent.ts

@@ -210,7 +210,7 @@ const TOOLS: ReadonlyArray<ToolDefinition> = [
       type: "object",
       properties: {
         bookId: { type: "string", description: "书籍ID" },
-        fileName: { type: "string", description: "文件名(如 outline/story_frame.md、outline/volume_map.md、outline/节奏原则.md、roles/主要角色/<name>.md、roles/次要角色/<name>.md、current_state.md、pending_hooks.md)" },
+        fileName: { type: "string", description: "文件名(如 outline/story_frame.md、outline/volume_map.md、outline/节奏原则.md(可选,Phase 5 后节奏原则合并到 volume_map 尾段,仅 legacy / 人工写入时出现)、roles/主要角色/<name>.md、roles/次要角色/<name>.md、current_state.md、pending_hooks.md)" },
         content: { type: "string", description: "新的完整文件内容" },
       },
       required: ["bookId", "fileName", "content"],
@@ -576,6 +576,11 @@ export async function executeAgentTool(
         "chapter_summaries.md", "subplot_board.md", "emotional_arcs.md",
         "character_matrix.md", "style_guide.md",
       ];
+      // outline/节奏原则.md (zh) / outline/rhythm_principles.md (en) are
+      // optional after Phase 5 consolidation — rhythm principles normally live
+      // in the last paragraph of volume_map and writeFoundationFiles skips the
+      // dedicated file when the block is empty. They remain whitelisted so
+      // legacy books and manual overrides keep working.
       const ALLOWED_OUTLINE_FILES = [
         "outline/story_frame.md", "outline/volume_map.md",
         "outline/节奏原则.md", "outline/rhythm_principles.md",

+ 3 - 1
packages/studio/src/api/server.ts

@@ -264,7 +264,9 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
 
   // Authoritative Phase 5 paths — prose outline + role sheets live under
   // dedicated subdirectories of story/. The full path (relative to story/) is
-  // matched literally here.
+  // matched literally here. `节奏原则.md` / `rhythm_principles.md` is optional
+  // after Phase 5 consolidation (rhythm lives in volume_map's closing paragraph);
+  // the entries stay whitelisted for legacy books and manual overrides.
   const TRUTH_OUTLINE_FILES = [
     "outline/story_frame.md",
     "outline/volume_map.md",