Browse Source

feat(v12): redesign rhythm control from mood-toggle to objective cycles

Replace the reactive "detect N consecutive high-pressure chapters →
force cooldown" model with new.txt-aligned objective cycle tracking.

Core code changes:
- input-governance: add CyclePhase enum + optional cyclePhase to ChapterBrief
- chapter-cadence: add analyzeObjectiveCycle() alongside existing cadence
  analysis. Infers cycle phase (蓄压/升级/爆发/后效) from recent chapter
  types + hook debt state
- planner: buildCycleDirective replaces buildMoodDirective as primary rhythm
  signal. Phase-appropriate guidance instead of binary cooldown
- planner-prompts: principle 11 rewritten for objective cycles, cyclePhase
  added to output requirements, Cycle Context section in user prompt
- narrative-control: render cyclePhase in brief block
- writer: cadence directive label updated to "rhythm"

Prompt alignment (all modules vs new.txt):
- architect: replace "不得连续3章高压" with 3-5 chapter mini-goal cycle
  structure, add post-climax impact requirement, add "万物皆饵"
- writer-prompts: enhance WritingCraftCard with cycle awareness, post-climax
  impact, expectation management, information boundary. Add best anti-AI
  examples from dead code into CoreRules
- continuity (auditor): enhance pacing/payoff/expectation dimensions with
  cycle completion checks, post-climax aftermath detection, daily-as-bait
- reviser: add cycle-aware revision guidance to auto mode

Preserved: 黄金三章, 金手指, 去AI味铁律, 钩子链, 配角独立目标.
Ma 7 months ago
parent
commit
dfdeb4d220

+ 9 - 5
packages/core/src/__tests__/planner.test.ts

@@ -392,7 +392,7 @@ describe("PlannerAgent", () => {
     expect(result.brief.beatOutline.length).toBeGreaterThanOrEqual(1);
   });
 
-  it("emits a mood directive when recent chapters are all high-tension", async () => {
+  it("emits a cycle directive when recent chapters are all high-tension", async () => {
     book = {
       ...book,
       genre: "other",
@@ -435,12 +435,13 @@ describe("PlannerAgent", () => {
       chapterNumber: 5,
     });
 
+    // With objective cycle analysis, the moodDirective now contains
+    // cycle-phase guidance instead of the old mood-based cooldown
     expect(result.intent.moodDirective).toBeDefined();
-    expect(result.intent.moodDirective).toContain("降调");
-    expect(result.intent.moodDirective).toContain("日常");
+    expect(result.intent.moodDirective).toContain("本章");
   });
 
-  it("does not emit a mood directive when recent moods are varied", async () => {
+  it("emits a cycle directive for varied moods based on chapter type patterns", async () => {
     book = {
       ...book,
       genre: "other",
@@ -483,7 +484,10 @@ describe("PlannerAgent", () => {
       chapterNumber: 5,
     });
 
-    expect(result.intent.moodDirective).toBeUndefined();
+    // With the new cycle system, a cycle directive is always emitted
+    // based on chapter type patterns rather than mood analysis
+    expect(result.intent.moodDirective).toBeDefined();
+    expect(result.intent.moodDirective).toContain("chapter");
   });
 
   it("ignores the default current_focus placeholder and falls back to author intent when no chapter outline is available", async () => {

+ 6 - 8
packages/core/src/agents/architect.ts

@@ -115,10 +115,9 @@ Core blurb principle:
 - Chapter 5: raise the stakes before paywall — strongest cliffhanger yet
 
 ### Emotional Wave Design
-- Each volume must have a tension curve: build → peak → release → bigger build
-- Never plan 3+ consecutive high-tension chapters without a breathing chapter
-- Never plan 3+ consecutive quiet chapters without a complication or revelation
-- Daily/transition chapters must serve the main plot (plant hooks, advance relationships)`
+- Organize each volume in 3-5 chapter mini-goal cycles: build-up → escalation → climax → aftermath. Not mechanical rotation — each cycle has a clear short-term goal and payoff point.
+- After a climax, the next 1-2 chapters MUST show change: relationships shifted, status changed, costs paid. Never jump straight from climax to new build-up.
+- Daily/transition chapters must carry a mission: plant a hook, advance a relationship, set up contrast, prepare the next cycle. No filler allowed — everything is "bait".`
       : `卷纲规划,每卷包含:卷名、章节范围、核心冲突、关键转折、收益目标
 
 ### 黄金三章法则(前三章必须遵循)
@@ -127,10 +126,9 @@ Core blurb principle:
 - 第3章:明确短期目标(主角确立第一个具体可达成的目标),给读者追读理由
 
 ### 情绪波形设计
-- 每卷必须有张力曲线:蓄压→爆发→释放→更大蓄压
-- 不得连续规划3章以上的高压章节而没有喘息章
-- 不得连续规划3章以上的平淡章节而没有冲突或揭秘
-- 日常/过渡章必须为主线服务(埋伏笔、推关系、建反差),禁止纯填充`;
+- 每卷以 3-5 章的小目标周期为单位组织:蓄压→升级→爆发→后效。不是机械轮换,而是每个周期有明确的短期目标和兑现点。
+- 高潮后 1-2 章必须写改变:关系变了、地位变了、代价显现了。不要高潮后直接跳到下一个蓄压。
+- 日常/过渡段落必须承担伏笔或关系推进任务,不准水。万物皆为"饵"。`;
 
     const bookRulesPrompt = resolvedLanguage === "en"
       ? `Generate book_rules.md as YAML frontmatter plus narrative guidance:

+ 6 - 6
packages/core/src/agents/continuity.ts

@@ -168,8 +168,8 @@ function buildDimensionNote(
   // v10: Enhanced dimension notes with writing methodology awareness
   if (id === 7) {
     return language === "en"
-      ? "Check pacing rhythm: Is there emotional wave variation (tension → release → bigger tension)? Are daily/quiet scenes serving the main plot (planting hooks, advancing relationships) or just filler? Does the chapter have at least one scene that changes the status quo?"
-      : "检查节奏波形:本章情绪是否与前2章形成波动变化(压制→释放→更大压制)?日常/过渡段是否为主线服务(埋伏笔/推关系/建反差)还是纯填充?本章是否有至少一个改变现状的场景?";
+      ? "Check pacing rhythm: Do the recent 3-5 chapters form a complete mini-goal cycle (build-up → escalation → climax → aftermath)? If 5+ consecutive chapters pass without a climax (payoff/reward/reversal), flag as pacing stagnation. If the previous chapter was a climax/big reversal, does this chapter show change (relationships shifted, status changed, costs paid)? If it jumps straight to new build-up without showing impact, flag as 'post-climax impact missing'. Daily/transition scenes must carry at least one task: plant a hook, advance a relationship, set up contrast, or prepare the next cycle."
+      : "检查节奏波形:最近 3-5 章是否形成了完整的「蓄压→升级→爆发→后效」周期?如果连续 5 章没有爆发(兑现/回报/翻转),标记为节奏停滞。如果上一章是爆发/高潮/大反转,本章是否写出了改变?如果直接跳到新蓄压而没有展示前一波爆发的影响,标记为「高潮后影响缺失」。非冲突章节中的日常/过渡/对话段落,是否至少承担了一项任务:埋伏笔、推关系、建立反差、准备下一轮蓄压。纯水日常标记为流水账风险。";
   }
 
   if (id === 15) {
@@ -177,8 +177,8 @@ function buildDimensionNote(
       ? (language === "en" ? `Payoff types: ${gp.satisfactionTypes.join(", ")}. ` : `爽点类型:${gp.satisfactionTypes.join("、")}。`)
       : "";
     return language === "en"
-      ? `${base}Check desire engine: Has the chapter created an emotional gap (reader wants release) OR delivered a payoff that exceeds expectations? A payoff that only satisfies 70% of built-up anticipation counts as diluted.`
-      : `${base}检查欲望驱动:本章是否制造了情绪缺口(读者渴望释放)或完成了超出预期的兑现?只满足读者70%期待的兑现等于爽点虚化。`;
+      ? `${base}Check desire engine: Has the chapter created an emotional gap (reader wants release) OR delivered a payoff that exceeds expectations? A payoff that only satisfies 70% of built-up anticipation counts as diluted. If this chapter is in the aftermath phase of a mini-goal cycle, verify that consequences are shown — not just emotional reactions, but concrete changes to status, relationships, or resources.`
+      : `${base}检查欲望驱动:本章是否制造了情绪缺口(读者渴望释放)或完成了超出预期的兑现?只满足读者70%期待的兑现等于爽点虚化。如果本章处于小目标周期的后效阶段,检查是否展示了具体改变——不只是情绪反应,而是地位、关系或资源的实际变化。`;
   }
 
   if (id === 25) {
@@ -222,8 +222,8 @@ function buildDimensionNote(
         : "检查番外是否越权回收正传伏笔(warning级别)";
     case 32:
       return language === "en"
-        ? "Check whether the ending renews curiosity, whether promised payoffs are landing on the cadence their hooks imply, whether pressure gets any release, and whether reader expectation gaps are accumulating faster than they are being satisfied."
-        : "检查:章尾是否重新点燃好奇心,已经承诺的回收是否按伏笔自身节奏落地,压力是否得到释放,读者期待缺口是在持续累积还是在被满足。";
+        ? "Check whether the ending renews curiosity, whether promised payoffs are landing on the cadence their hooks imply, whether pressure gets any release, and whether reader expectation gaps are accumulating faster than they are being satisfied. If a climax just occurred, check whether the aftermath chapters show concrete change before starting a new cycle."
+        : "检查:章尾是否重新点燃好奇心,已经承诺的回收是否按伏笔自身节奏落地,压力是否得到释放,读者期待缺口是在持续累积还是在被满足。如果刚经历高潮,检查后效章节是否在开启新周期前展示了具体改变。";
     case 33:
       return language === "en"
         ? "Cross-check volume_outline: does this chapter match the planned beat for the current chapter range? Did it skip planned nodes or consume later nodes too early? Does actual pacing match the planned chapter span? If a beat planned for N chapters is consumed in 1-2 chapters -> critical."

+ 33 - 2
packages/core/src/agents/planner-prompts.ts

@@ -1,4 +1,5 @@
 import type { PlanningMaterials } from "../utils/planning-materials.js";
+import type { ObjectiveCycleAnalysis } from "../utils/chapter-cadence.js";
 
 export interface PlannerPromptInput {
   readonly chapterNumber: number;
@@ -6,6 +7,7 @@ export interface PlannerPromptInput {
   readonly genreName: string;
   readonly language: "zh" | "en";
   readonly materials: PlanningMaterials;
+  readonly cycleAnalysis?: ObjectiveCycleAnalysis;
 }
 
 export function buildPlannerSystemPrompt(language: "zh" | "en"): string {
@@ -41,7 +43,9 @@ const ZH_SYSTEM_PROMPT = `你是 InkOS 的章节规划器。
 ### 关于配角和情绪
 9. 配角有目的:推动剧情,不是充场面。B 面只在需要时露。
 10. 爽点有代价,刀子有余温。
-11. 和上一章有呼吸差:不重复情绪,也不割裂。是对比或递进。
+11. 节奏由目标周期驱动,不由情绪档位决定。每 3-5 章是一个小周期:
+    蓄压(铺新阻力、新信息)→ 升级(冲突加码、缺口扩大)→ 爆发(爽点落地、承诺兑现、释放超预期)→ 后效(写改变:关系变了、地位变了、代价显现了)。
+    不要机械轮换——如果故事需要连续蓄压就连续蓄压,但 5 章内必须有一次爆发。
 
 ## 开场打开方式(工具箱,非强制)
 
@@ -111,7 +115,9 @@ but to decide that this chapter is written *right* — making it a proper micro-
 ### Supporting cast and emotion
 9. Supporting characters have purpose: they drive plot, not fill scenes. Show their B-side only when needed.
 10. Payoffs have costs; knife-twists leave warmth.
-11. Breathing difference from the prior chapter: contrast or progression, not repetition.
+11. Rhythm is driven by objective cycles, not mood buckets. Every 3-5 chapters form a mini-arc:
+    蓄压 (build pressure — new obstacles, new info) → 升级 (escalate — raise stakes, widen gaps) → 爆发 (payoff — deliver on promises, exceed expectations) → 后效 (aftermath — write change: relationships shifted, status changed, costs visible).
+    Don't rotate mechanically — if the story needs consecutive pressure-building, do it. But there must be at least one payoff within every 5 chapters.
 
 ## Opening types (toolbox, not mandatory)
 
@@ -182,6 +188,26 @@ function renderActiveHooks(materials: PlanningMaterials): string {
     .join("\n");
 }
 
+function renderCycleContext(
+  cycleAnalysis: ObjectiveCycleAnalysis | undefined,
+  language: "zh" | "en",
+): string {
+  if (!cycleAnalysis) return "(not available)";
+  const isEn = language === "en";
+  const phaseLabel = isEn ? "Suggested Phase" : "建议阶段";
+  const reasonLabel = isEn ? "Reasoning" : "依据";
+  const debtLabel = isEn ? "Hook Debt" : "伏笔债务";
+  const payoffLabel = isEn ? "Payoff Due" : "需要兑现";
+  const streakLabel = isEn ? "Chapters in Current Phase" : "当前阶段连续章数";
+  return [
+    `- ${phaseLabel}: ${cycleAnalysis.phase}`,
+    `- ${reasonLabel}: ${cycleAnalysis.reasoning}`,
+    `- ${streakLabel}: ${cycleAnalysis.chaptersInCurrentPhase}`,
+    `- ${payoffLabel}: ${cycleAnalysis.payoffDue ? (isEn ? "yes" : "是") : (isEn ? "no" : "否")}`,
+    `- ${debtLabel}: ${cycleAnalysis.hookDebtLevel}`,
+  ].join("\n");
+}
+
 export function buildPlannerUserPrompt(input: PlannerPromptInput): string {
   const { materials } = input;
 
@@ -219,6 +245,9 @@ export function buildPlannerUserPrompt(input: PlannerPromptInput): string {
     "## Active Hooks",
     renderActiveHooks(materials),
     "",
+    "## Cycle Context",
+    renderCycleContext(input.cycleAnalysis, input.language),
+    "",
     "## Output Requirements",
     input.language === "zh"
       ? ZH_OUTPUT_REQUIREMENTS
@@ -237,6 +266,7 @@ const ZH_OUTPUT_REQUIREMENTS = `按核心原则产出 ChapterBrief JSON。严格
   每个对象必须有 hookId (string)、movement (string)、targetEffect (string) 三个字段。
   其他 active hook 省略,用 dormantReason 解释为什么按兵不动。
 - propsAndSetting (string[]): 本章出现在页面上的人/物/地,平铺为字符串数组。
+- cyclePhase (string, 可选): 本章在小目标周期中的位置——"蓄压" / "升级" / "爆发" / "后效"
 - dormantReason (string, 可选): 其他 hook 按兵不动的原因。
 - isGoldenOpening (boolean): 前 3 章设 true,其他 false。`;
 
@@ -251,5 +281,6 @@ const EN_OUTPUT_REQUIREMENTS = `Produce ChapterBrief JSON following the core pri
   Each object must have hookId (string), movement (string), targetEffect (string).
   Omit other active hooks; explain in dormantReason why they stay dormant.
 - propsAndSetting (string[]): People/items/locations on-page, as a flat string array.
+- cyclePhase (string, optional): This chapter's position in the mini objective cycle — "蓄压" / "升级" / "爆发" / "后效"
 - dormantReason (string, optional): Why other hooks stay dormant.
 - isGoldenOpening (boolean): true for first 5 chapters, false otherwise.`;

+ 63 - 11
packages/core/src/agents/planner.ts

@@ -15,7 +15,7 @@ import {
   renderHookSnapshot,
   renderSummarySnapshot,
 } from "../utils/memory-retrieval.js";
-import { analyzeChapterCadence } from "../utils/chapter-cadence.js";
+import { analyzeChapterCadence, analyzeObjectiveCycle, type ObjectiveCycleAnalysis } from "../utils/chapter-cadence.js";
 import { buildPlannerHookAgenda } from "../utils/hook-agenda.js";
 import {
   gatherPlanningMaterials,
@@ -25,6 +25,7 @@ import {
 import {
   buildPlannerSystemPrompt,
   buildPlannerUserPrompt,
+  type PlannerPromptInput,
 } from "./planner-prompts.js";
 
 export interface PlanChapterInput {
@@ -89,19 +90,24 @@ export class PlannerAgent extends BaseAgent {
       targetChapters: input.book.targetChapters,
       language: input.book.language ?? "zh",
     });
-    const directives = this.buildStructuredDirectives({
+    const { cycleAnalysis, ...directives } = this.buildStructuredDirectives({
       chapterNumber: input.chapterNumber,
       language: input.book.language,
       volumeOutline: seedMaterials.volumeOutline,
       outlineNode,
       matchedOutlineAnchor,
       chapterSummaries: materials.chapterSummariesRaw,
+      hookAgenda: {
+        eligibleResolve: hookAgenda.eligibleResolve,
+        staleDebt: hookAgenda.staleDebt,
+      },
     });
 
     const brief = await this.planChapterBrief({
       input,
       outlineNode: planningAnchor,
       materials,
+      cycleAnalysis,
     });
 
     const intent = ChapterIntentSchema.parse({
@@ -142,6 +148,7 @@ export class PlannerAgent extends BaseAgent {
     readonly input: PlanChapterInput;
     readonly outlineNode: string | undefined;
     readonly materials: PlanningMaterials;
+    readonly cycleAnalysis?: ObjectiveCycleAnalysis;
   }): Promise<ChapterBrief> {
     const language = this.isChineseLanguage(params.input.book.language) ? "zh" : "en";
     const response = await this.chat([
@@ -160,6 +167,7 @@ export class PlannerAgent extends BaseAgent {
             ...params.materials,
             outlineNode: params.outlineNode,
           },
+          cycleAnalysis: params.cycleAnalysis,
         }),
       },
     ], {
@@ -266,21 +274,37 @@ export class PlannerAgent extends BaseAgent {
     readonly outlineNode: string | undefined;
     readonly matchedOutlineAnchor: boolean;
     readonly chapterSummaries: string;
-  }): Pick<ChapterIntent, "sceneDirective" | "arcDirective" | "moodDirective" | "titleDirective"> {
+    readonly hookAgenda?: {
+      readonly eligibleResolve: ReadonlyArray<string>;
+      readonly staleDebt: ReadonlyArray<string>;
+    };
+  }): Pick<ChapterIntent, "sceneDirective" | "arcDirective" | "moodDirective" | "titleDirective"> & {
+    readonly cycleAnalysis?: ObjectiveCycleAnalysis;
+  } {
+    const language = this.isChineseLanguage(input.language) ? "zh" as const : "en" as const;
     const recentSummaries = parseChapterSummariesMarkdown(input.chapterSummaries)
       .filter((summary) => summary.chapter < input.chapterNumber)
       .sort((left, right) => left.chapter - right.chapter)
       .slice(-4);
+    const cadenceRows = recentSummaries.map((summary) => ({
+      chapter: summary.chapter,
+      title: summary.title,
+      mood: summary.mood,
+      chapterType: summary.chapterType,
+    }));
     const cadence = analyzeChapterCadence({
-      language: this.isChineseLanguage(input.language) ? "zh" : "en",
-      rows: recentSummaries.map((summary) => ({
-        chapter: summary.chapter,
-        title: summary.title,
-        mood: summary.mood,
-        chapterType: summary.chapterType,
-      })),
+      language,
+      rows: cadenceRows,
     });
 
+    const cycleAnalysis = input.hookAgenda
+      ? analyzeObjectiveCycle({
+          rows: cadenceRows,
+          hookAgenda: input.hookAgenda,
+          language,
+        })
+      : undefined;
+
     return {
       arcDirective: this.buildArcDirective(
         input.language,
@@ -289,8 +313,11 @@ export class PlannerAgent extends BaseAgent {
         input.matchedOutlineAnchor,
       ),
       sceneDirective: this.buildSceneDirective(input.language, cadence),
-      moodDirective: this.buildMoodDirective(input.language, cadence),
+      moodDirective: cycleAnalysis
+        ? this.buildCycleDirective(input.language, cycleAnalysis)
+        : this.buildMoodDirective(input.language, cadence),
       titleDirective: this.buildTitleDirective(input.language, cadence),
+      cycleAnalysis,
     };
   }
 
@@ -500,6 +527,31 @@ export class PlannerAgent extends BaseAgent {
       : `The last ${moods.length} chapters have been relentlessly tense (${moods.slice(0, 3).join(", ")}). This chapter must downshift — write a quieter scene with warmth, humor, or breathing room.`;
   }
 
+  private buildCycleDirective(
+    language: string | undefined,
+    cycle: ObjectiveCycleAnalysis,
+  ): string | undefined {
+    const isZh = this.isChineseLanguage(language);
+    switch (cycle.phase) {
+      case "蓄压":
+        return isZh
+          ? "本章铺压制,新阻力或新信息——不急着爆发。"
+          : "This chapter builds pressure — introduce new obstacles or information. Don't rush to climax.";
+      case "升级":
+        return isZh
+          ? "本章升级冲突,已有缺口还没释放——加码不松手。"
+          : "This chapter escalates conflict — existing gaps are unresolved. Raise the stakes, don't relent.";
+      case "爆发":
+        return isZh
+          ? "本章有一个承诺必须兑现——让积累的压力落地,给读者超预期的释放。"
+          : "This chapter must deliver on a promise — release accumulated pressure with a payoff that exceeds expectations.";
+      case "后效":
+        return isZh
+          ? "上一章刚爆发,写改变:关系变了、地位变了、代价显现了。"
+          : "Previous chapter just climaxed — write the aftermath: changed relationships, shifted status, visible costs.";
+    }
+  }
+
   private buildTitleDirective(
     language: string | undefined,
     cadence: ReturnType<typeof analyzeChapterCadence>,

+ 10 - 0
packages/core/src/agents/reviser.ts

@@ -409,6 +409,11 @@ Revision principles:
 6. Different characters speak differently. No "everyone gasped in unison"
 7. Escalate: bad things stack, each worse than the last
 
+Cycle-aware revision:
+- If this chapter should be "aftermath" but is still escalating tension, rewrite the densest conflict passage into a change-showing passage — who lost what, whose attitude shifted, what the new normal is
+- If this chapter should be "climax" but has no clear payoff, find the closest scene to a reward and amplify it — make the promised release exceed reader expectations
+- Daily passages that don't serve the main line: rewrite as "bait" — add a detail pointing to the future, a hint, a character reaction that seeds curiosity
+
 Output format:
 
 === FIXED_ISSUES ===
@@ -452,6 +457,11 @@ REVISED_CONTENT——处理全章级问题(字数压缩、结构重组、节
 6. 不同角色说话方式必须不同。禁止"众人齐声惊呼"
 7. 坏事叠坏事,每层比上一层过分
 
+小目标周期修稿指引:
+- 如果本章应该是"后效"阶段但仍在加压,把最密集的冲突段落改写为展示改变的段落——谁失去了什么、谁的态度变了、新的常态是什么
+- 如果本章应该是"爆发"阶段但没有明确兑现,找到最接近回报的场景并放大它——让承诺的释放超过读者预期
+- 日常段落如果不服务主线,改写为"饵":加入一个指向未来的细节、一句暗示、一个角色反应
+
 输出格式:
 
 === FIXED_ISSUES ===

+ 11 - 2
packages/core/src/agents/writer-prompts.ts

@@ -187,6 +187,7 @@ function buildCoreRules(lengthSpec: LengthSpec): string {
 - 【铁律】转折/惊讶标记词(仿佛、忽然、竟、竟然、猛地、猛然、不禁、宛如)全篇总数不超过每3000字1次。超出时改用具体动作或感官描写传递突然性
 - 【铁律】同一体感/意象禁止连续渲染超过两轮。第三次出现相同意象域(如"火在体内流动")时必须切换到新信息或新动作,避免原地打转
 - 【铁律】六步走心理分析是写作推导工具,其中的术语("当前处境""核心动机""信息边界""性格过滤"等)只用于PRE_WRITE_CHECK内部推理,绝不可出现在正文叙事中
+- 反例→正例速查:✗"虽然他很强,但是他还是输了"→✓"他确实强,可对面那个老东西更脏";✗"然而事情并没有那么简单"→✓"哪有那么便宜的事";✗"这一刻他终于明白了什么是力量"→✓删掉,让读者自己感受
 
 ## 硬性禁令
 
@@ -372,7 +373,11 @@ function buildWritingCraftCard(language: "zh" | "en"): string {
 - **Character check**: Before every character action ask: Why? Does it match their profile? Would the reader find it jarring?
 - **Dialogue**: Different characters speak differently — vocabulary, sentence length, verbal tics, dialect traces
 - **Forbidden**: Info-dump character introductions / introducing 3+ new characters at once / "everyone gasped in unison"
-- **Escalation**: Bad things stack — each layer worse than the last. Not one setback, but setback → worse setback → even worse`;
+- **Escalation**: Bad things stack — each layer worse than the last. Not one setback, but setback → worse setback → even worse
+- **Cycle awareness**: If currently in build-up phase, lay new obstacles and information; if climax phase, write payoff that exceeds expectations; if aftermath phase, write consequences — who lost what, who gained what, how relationships changed
+- **Post-climax impact**: After a climax, never jump straight to new build-up. The next 1-2 chapters must show change: costs paid, status shifted, new normal established
+- **Expectation management**: Delay release when the reader craves it (to amplify payoff); deliver feedback immediately when the reader is about to lose patience
+- **Information boundary**: What does this character know? What don't they know? What are they wrong about? Characters must act only on information they possess`;
   }
 
   return `## 写作铁律
@@ -387,7 +392,11 @@ function buildWritingCraftCard(language: "zh" | "en"): string {
 - **人设三问**:为什么这么做?符合人设吗?读者会觉得突兀吗?
 - **对话**:不同角色说话方式不同——用词习惯、句子长短、口头禅、方言痕迹
 - **禁止**:资料卡式介绍角色 / 一次引入超3个新角色 / 众人齐声惊呼
-- **升级**:坏事叠坏事,每层比上一层过分——被骂→手机掉了→直播课结束了→包子噎住了`;
+- **升级**:坏事叠坏事,每层比上一层过分——被骂→手机掉了→直播课结束了→包子噎住了
+- **小目标周期意识**:如果当前处于蓄压阶段,铺新阻力新信息;如果是爆发阶段,写兑现超预期;如果是后效阶段,写改变和代价
+- **高潮后影响**:爆发后不能直接跳到下一个蓄压。紧接着的 1-2 章必须写出改变——谁失去了什么、谁得到了什么、关系怎么变了
+- **期待管理**:读者期待释放时适当延迟以增强快感;读者即将失去耐心时立即给反馈
+- **信息边界**:角色此刻知道什么?不知道什么?对局势有什么误判?角色只能基于已掌握的信息行动`;
 }
 
 // ---------------------------------------------------------------------------

+ 1 - 1
packages/core/src/agents/writer.ts

@@ -938,7 +938,7 @@ ${lengthRequirementBlock}
     const isEn = language === "en";
     const lines: string[] = [];
     if (moodDirective) {
-      lines.push(`- ${isEn ? "mood" : "情绪节奏"}: ${moodDirective}`);
+      lines.push(`- ${isEn ? "rhythm" : "节奏"}: ${moodDirective}`);
     }
     if (titleDirective) {
       lines.push(`- ${isEn ? "title" : "标题提示"}: ${titleDirective}`);

+ 4 - 0
packages/core/src/models/input-governance.ts

@@ -79,10 +79,14 @@ export const ChapterHookMoveSchema = z.object({
 });
 export type ChapterHookMove = z.infer<typeof ChapterHookMoveSchema>;
 
+export const CyclePhaseSchema = z.enum(["蓄压", "升级", "爆发", "后效"]);
+export type CyclePhase = z.infer<typeof CyclePhaseSchema>;
+
 export const ChapterBriefSchema = z.object({
   chapter: z.number().int().min(1),
   goal: z.string().min(1),
   chapterType: z.string().min(1),
+  cyclePhase: CyclePhaseSchema.optional(),
   isGoldenOpening: z.boolean().default(false),
   beatOutline: z.array(ChapterBeatSchema).min(1),
   hookPlan: z.array(ChapterHookMoveSchema).default([]),

+ 171 - 0
packages/core/src/utils/chapter-cadence.ts

@@ -202,6 +202,177 @@ function extractTitleTokens(title: string, language: "zh" | "en"): string[] {
   return [...tokens];
 }
 
+// ── Objective Cycle Analysis ────────────────────────────────────
+
+export interface ObjectiveCycleAnalysis {
+  readonly phase: "蓄压" | "升级" | "爆发" | "后效";
+  readonly reasoning: string;
+  readonly chaptersInCurrentPhase: number;
+  readonly payoffDue: boolean;
+  readonly hookDebtLevel: "low" | "medium" | "high";
+}
+
+const CLIMAX_TYPES = new Set([
+  "爽点", "爆发", "payoff", "climax", "knife-twist", "刀子",
+]);
+
+const SETUP_TYPES = new Set([
+  "调查", "铺垫", "推进", "过渡", "investigation", "setup",
+  "progression", "transition",
+]);
+
+const ESCALATION_TYPES = new Set([
+  "冲突", "反转", "conflict", "reversal", "reveal", "揭露",
+]);
+
+const AFTERMATH_TYPES = new Set([
+  "后效", "收束", "aftermath", "resolution", "denouement",
+]);
+
+function classifyChapterType(chapterType: string): "setup" | "escalation" | "climax" | "aftermath" | "unknown" {
+  const lower = chapterType.toLowerCase().trim();
+  if (CLIMAX_TYPES.has(lower)) return "climax";
+  if (AFTERMATH_TYPES.has(lower)) return "aftermath";
+  if (ESCALATION_TYPES.has(lower)) return "escalation";
+  if (SETUP_TYPES.has(lower)) return "setup";
+  // Partial matching for compound types like "调查章"
+  for (const keyword of CLIMAX_TYPES) {
+    if (lower.includes(keyword)) return "climax";
+  }
+  for (const keyword of AFTERMATH_TYPES) {
+    if (lower.includes(keyword)) return "aftermath";
+  }
+  for (const keyword of ESCALATION_TYPES) {
+    if (lower.includes(keyword)) return "escalation";
+  }
+  for (const keyword of SETUP_TYPES) {
+    if (lower.includes(keyword)) return "setup";
+  }
+  return "unknown";
+}
+
+function mapClassToPhase(cls: "setup" | "escalation" | "climax" | "aftermath" | "unknown"): "蓄压" | "升级" | "爆发" | "后效" {
+  switch (cls) {
+    case "setup": return "蓄压";
+    case "escalation": return "升级";
+    case "climax": return "爆发";
+    case "aftermath": return "后效";
+    case "unknown": return "蓄压";
+  }
+}
+
+function computeHookDebtLevel(staleDebt: ReadonlyArray<string>): "low" | "medium" | "high" {
+  if (staleDebt.length >= 3) return "high";
+  if (staleDebt.length >= 1) return "medium";
+  return "low";
+}
+
+export function analyzeObjectiveCycle(params: {
+  readonly rows: ReadonlyArray<CadenceSummaryRow>;
+  readonly hookAgenda: {
+    readonly eligibleResolve: ReadonlyArray<string>;
+    readonly staleDebt: ReadonlyArray<string>;
+  };
+  readonly language: "zh" | "en";
+}): ObjectiveCycleAnalysis {
+  const recentRows = [...params.rows]
+    .sort((left, right) => left.chapter - right.chapter)
+    .slice(-5);
+
+  const payoffDue = params.hookAgenda.eligibleResolve.length > 0;
+  const hookDebtLevel = computeHookDebtLevel(params.hookAgenda.staleDebt);
+
+  if (recentRows.length === 0) {
+    return {
+      phase: "蓄压",
+      reasoning: params.language === "en"
+        ? "No prior chapters — starting with pressure-building."
+        : "无前章记录——从蓄压开始。",
+      chaptersInCurrentPhase: 0,
+      payoffDue,
+      hookDebtLevel,
+    };
+  }
+
+  const classifications = recentRows.map((row) => classifyChapterType(row.chapterType));
+  const lastClassification = classifications.at(-1) ?? "unknown";
+
+  // Count consecutive chapters in the same classification (from the end)
+  let consecutiveCount = 0;
+  for (let i = classifications.length - 1; i >= 0; i--) {
+    if (classifications[i] === lastClassification) {
+      consecutiveCount += 1;
+    } else {
+      break;
+    }
+  }
+
+  // Determine the phase for the NEXT chapter
+  let phase: "蓄压" | "升级" | "爆发" | "后效";
+  let reasoning: string;
+  const isEn = params.language === "en";
+
+  // Rule 1: If last chapter was climax → aftermath
+  if (lastClassification === "climax") {
+    phase = "后效";
+    reasoning = isEn
+      ? "Previous chapter was a climax — write the aftermath: changed relationships, costs, new status."
+      : "上一章是爆发——本章写后效:关系变了、代价显现、新状态。";
+  }
+  // Rule 2: If stale debt is high → force toward climax
+  else if (hookDebtLevel === "high") {
+    phase = "爆发";
+    reasoning = isEn
+      ? `High hook debt (${params.hookAgenda.staleDebt.length} stale) — force a payoff chapter.`
+      : `伏笔积压严重(${params.hookAgenda.staleDebt.length}条逾期)——强制推向爆发。`;
+  }
+  // Rule 3: If eligible hooks are ripe → lean toward climax
+  else if (payoffDue && consecutiveCount >= 2 && (lastClassification === "setup" || lastClassification === "escalation")) {
+    phase = "爆发";
+    reasoning = isEn
+      ? `Hooks are ripe for payoff and ${consecutiveCount} chapters of buildup — time to deliver.`
+      : `伏笔已成熟且已蓄压${consecutiveCount}章——该爆发了。`;
+  }
+  // Rule 4: If last chapter was aftermath → start fresh cycle
+  else if (lastClassification === "aftermath") {
+    phase = "蓄压";
+    reasoning = isEn
+      ? "Previous chapter was aftermath — start a new mini-arc with fresh pressure."
+      : "上一章是后效——开始新小周期,铺新阻力。";
+  }
+  // Rule 5: Natural progression based on buildup length
+  else if (lastClassification === "setup" && consecutiveCount >= 2) {
+    phase = payoffDue ? "爆发" : "升级";
+    reasoning = isEn
+      ? `${consecutiveCount} consecutive setup chapters — time to ${payoffDue ? "deliver payoff" : "escalate conflict"}.`
+      : `已连续${consecutiveCount}章蓄压——该${payoffDue ? "爆发兑现" : "升级冲突"}了。`;
+  }
+  else if (lastClassification === "escalation") {
+    phase = payoffDue ? "爆发" : "升级";
+    reasoning = isEn
+      ? `Escalation in progress${payoffDue ? " with ripe hooks — push to payoff" : " — continue building or escalate further"}.`
+      : `正在升级中${payoffDue ? "且伏笔成熟——推向爆发" : "——继续加码或进一步升级"}。`;
+  }
+  // Default: derive from the last chapter's natural next step
+  else {
+    const naturalNext = mapClassToPhase(lastClassification);
+    phase = naturalNext;
+    reasoning = isEn
+      ? `Continuing natural rhythm progression from ${lastClassification}.`
+      : `根据上章类型(${recentRows.at(-1)?.chapterType ?? "未知"})自然推进。`;
+  }
+
+  return {
+    phase,
+    reasoning,
+    chaptersInCurrentPhase: consecutiveCount,
+    payoffDue,
+    hookDebtLevel,
+  };
+}
+
+// ── Helpers ────────────────────────────────────────────────────
+
 function isMeaningfulValue(value: string): boolean {
   const normalized = value.trim().toLowerCase();
   if (!normalized) return false;

+ 7 - 0
packages/core/src/utils/narrative-control.ts

@@ -93,6 +93,13 @@ export function renderBriefAsNarrativeBlock(
     );
   }
 
+  // Cycle phase
+  if (brief.cyclePhase) {
+    sections.push(
+      `## ${isEn ? "Cycle Phase" : "周期阶段"}\n- ${brief.cyclePhase}`,
+    );
+  }
+
   // Golden opening flag
   if (brief.isGoldenOpening) {
     sections.push(