Jelajahi Sumber

feat: optimize chapter plan execution

Mochocyang 2 bulan lalu
induk
melakukan
0a899aeb9b

+ 44 - 1
src/components/chat/chapter-plan-confirm-dialog.spec.tsx

@@ -77,7 +77,20 @@ describe("chapter-plan-confirm-dialog 纯函数", () => {
 
 
       expect(prompt).toContain("计划自检")
       expect(prompt).toContain("计划自检")
       expect(prompt).not.toContain("蓝图")
       expect(prompt).not.toContain("蓝图")
-      expect(prompt).toContain("七个维度")
+      expect(prompt).not.toContain("七个维度")
+      for (const section of [
+        "本章目标",
+        "已知依据",
+        "执行边界",
+        "分场景执行计划",
+        "信息流与伏笔",
+        "验收标准",
+        "风险与兜底",
+      ]) {
+        expect(prompt).toContain(section)
+      }
+      expect(prompt).toContain("目的、冲突、转折、输出结果、验收标准")
+      expect(prompt).toContain("缺失段落、缺失场景字段或不可验收的标准必须进入 issues")
       expect(prompt).toContain("维度四·场景序列编排:旧屋揭示")
       expect(prompt).toContain("维度四·场景序列编排:旧屋揭示")
       expect(prompt).toContain("只输出一个 JSON 对象")
       expect(prompt).toContain("只输出一个 JSON 对象")
     })
     })
@@ -247,6 +260,36 @@ describe("ChapterPlanConfirmDialog 组件", () => {
     expect(textarea.value).toBe("修订后计划:已补充篇幅分配")
     expect(textarea.value).toBe("修订后计划:已补充篇幅分配")
   })
   })
 
 
+  it("修订计划后点击普通确认也按修订后的计划执行", async () => {
+    const onConfirm = vi.fn()
+    const onModify = vi.fn()
+    const onSelfCheck = vi.fn(async () => "状态:warning\n建议:补充篇幅分配")
+    const onRevisePlan = vi.fn(async () => "修订后计划:已补充篇幅分配")
+    await act(async () => {
+      root.render(
+        <ChapterPlanConfirmDialog
+          {...baseProps}
+          onConfirm={onConfirm}
+          onModify={onModify}
+          onSelfCheck={onSelfCheck}
+          onRevisePlan={onRevisePlan}
+        />,
+      )
+    })
+    await act(async () => {
+      ;(queryByText("自检计划") as HTMLButtonElement).click()
+    })
+    await act(async () => {
+      ;(queryByText("按自检建议修正") as HTMLButtonElement).click()
+    })
+    await act(async () => {
+      ;(queryByText("确认,按此计划写正文") as HTMLButtonElement).click()
+    })
+
+    expect(onModify).toHaveBeenCalledWith("修订后计划:已补充篇幅分配")
+    expect(onConfirm).not.toHaveBeenCalled()
+  })
+
   it("自检期间关闭弹窗后不会把旧结果带到下次打开", async () => {
   it("自检期间关闭弹窗后不会把旧结果带到下次打开", async () => {
     let resolveSelfCheck!: (value: string) => void
     let resolveSelfCheck!: (value: string) => void
     const onSelfCheck = vi.fn(() => new Promise<string>((resolve) => {
     const onSelfCheck = vi.fn(() => new Promise<string>((resolve) => {

+ 10 - 1
src/components/chat/chapter-plan-confirm-dialog.tsx

@@ -93,6 +93,15 @@ export function ChapterPlanConfirmDialog({
     setEditing(false)
     setEditing(false)
   }, [editedContent, onModify])
   }, [editedContent, onModify])
 
 
+  const handleConfirm = useCallback(() => {
+    if (editing && onModify) {
+      onModify(editedContent)
+      setEditing(false)
+      return
+    }
+    onConfirm()
+  }, [editedContent, editing, onConfirm, onModify])
+
   const handleSelfCheck = useCallback(async () => {
   const handleSelfCheck = useCallback(async () => {
     if (!onSelfCheck) return
     if (!onSelfCheck) return
     const requestId = selfCheckRequestRef.current + 1
     const requestId = selfCheckRequestRef.current + 1
@@ -230,7 +239,7 @@ export function ChapterPlanConfirmDialog({
               跳过计划,直接写
               跳过计划,直接写
             </button>
             </button>
             <button
             <button
-              onClick={onConfirm}
+              onClick={handleConfirm}
               className="flex items-center gap-1.5 rounded-md bg-green-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-green-700"
               className="flex items-center gap-1.5 rounded-md bg-green-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-green-700"
             >
             >
               <Check className="h-4 w-4" />
               <Check className="h-4 w-4" />

+ 2 - 3
src/components/chat/chat-message.tsx

@@ -191,8 +191,7 @@ export function ChatMessage({
           <div className="flex items-center gap-1 flex-wrap">
           <div className="flex items-center gap-1 flex-wrap">
             {canResumeUnfinished && (
             {canResumeUnfinished && (
               <div className="basis-full rounded-md border border-amber-500/30 bg-amber-50/60 px-2 py-1.5 text-[11px] leading-5 text-amber-800 dark:bg-amber-950/20 dark:text-amber-300">
               <div className="basis-full rounded-md border border-amber-500/30 bg-amber-50/60 px-2 py-1.5 text-[11px] leading-5 text-amber-800 dark:bg-amber-950/20 dark:text-amber-300">
-                这次深度生成已经完成了部分思考过程。点击“继续未完成”会基于上方已有阶段继续往后生成,通常比“重新生成”更节省
-                token;如果前面的思考方向本身不对,再使用“重新生成”。
+                这次深度生成已经完成了部分思考过程。点击“继续未完成”会基于上方已有阶段继续往后生成,通常比“重新生成”更节省 token;如果前面的思考方向本身不对,再使用“重新生成”。
               </div>
               </div>
             )}
             )}
             {novelMode && isLastAssistant && onSaveAsChapter && (
             {novelMode && isLastAssistant && onSaveAsChapter && (
@@ -704,7 +703,7 @@ export function StreamingMessage({ content }: StreamingMessageProps) {
       <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
       <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
         <Bot className="h-4 w-4" />
         <Bot className="h-4 w-4" />
       </div>
       </div>
-      <div className="max-w-[80%] rounded-lg border bg-background px-3 py-2 text-sm text-foreground">
+      <div className="w-fit max-w-full lg:max-w-[50vw] rounded-lg border bg-background px-3 py-2 text-sm text-foreground">
         {isThinking ? (
         {isThinking ? (
           <StreamingWorkflowBlock content={thinking} />
           <StreamingWorkflowBlock content={thinking} />
         ) : (
         ) : (

+ 2 - 2
src/components/novel/story-simulation/story-simulation-view.debug.spec.ts

@@ -29,8 +29,8 @@ describe("story simulation debug process view wiring", () => {
     )
     )
 
 
     expect(source).toContain("时间线")
     expect(source).toContain("时间线")
-    expect(source).toContain("过程观察")
-    expect(source).toContain("ProcessDebugPanel")
+    expect(source).toContain("概览")
+    expect(source).toContain("ProcessOverviewPanel")
     expect(source).toContain("Blackboard")
     expect(source).toContain("Blackboard")
     expect(source).toContain("可见事件")
     expect(source).toContain("可见事件")
   })
   })

+ 195 - 6
src/components/skill-library/unified-skill-library-view.tsx

@@ -1,12 +1,65 @@
+import { useEffect, useMemo, useState } from "react"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
-import { SkillLibrarySidebarPanel, SkillLibraryView } from "./skill-library-view"
-import { WritingSkillLibrarySidebarPanel, WritingSkillLibraryView } from "./writing-skill-library-view"
+import {
+  getAllDeAiSkills,
+  loadDeAiSkillConfig,
+  type DeAiSkill,
+} from "@/lib/novel/de-ai-skill-library"
+import { loadUserSkillConfig } from "@/lib/novel/user-skill-store"
+import type { SkillKind, UserSkill } from "@/lib/novel/skill-library"
+import { SkillLibraryView } from "./skill-library-view"
+import { WritingSkillLibraryView } from "./writing-skill-library-view"
 
 
 const skillLibraryTabs = [
 const skillLibraryTabs = [
   { view: "skillLibrary" as const, label: "去AI味技能" },
   { view: "skillLibrary" as const, label: "去AI味技能" },
   { view: "writingSkillLibrary" as const, label: "写作 Skill" },
   { view: "writingSkillLibrary" as const, label: "写作 Skill" },
 ]
 ]
 
 
+type UnifiedSkillCategory = "all" | "writing" | "de-ai" | SkillKind
+
+interface UnifiedSkillEntry {
+  id: string
+  sourceId: string
+  type: "writing" | "de-ai"
+  name: string
+  description: string
+  content: string
+  kinds: SkillKind[]
+}
+
+const unifiedSkillCategories: { id: UnifiedSkillCategory; label: string }[] = [
+  { id: "all", label: "全部" },
+  { id: "writing", label: "写作" },
+  { id: "de-ai", label: "去AI味" },
+  { id: "review", label: "审稿" },
+  { id: "output", label: "输出" },
+  { id: "knowledge", label: "知识" },
+]
+
+function deAiSkillToEntry(skill: DeAiSkill): UnifiedSkillEntry {
+  return {
+    id: `de-ai:${skill.id}`,
+    sourceId: skill.id,
+    type: "de-ai",
+    name: skill.name,
+    description: skill.description,
+    content: skill.content,
+    kinds: ["rewrite", "style"],
+  }
+}
+
+function writingSkillToEntry(skill: UserSkill): UnifiedSkillEntry {
+  return {
+    id: `writing:${skill.id}`,
+    sourceId: skill.id,
+    type: "writing",
+    name: skill.name,
+    description: skill.description,
+    content: skill.content,
+    kinds: skill.kind,
+  }
+}
+
 function SkillLibraryTabs({ compact = false }: { compact?: boolean }) {
 function SkillLibraryTabs({ compact = false }: { compact?: boolean }) {
   const activeView = useWikiStore((s) => s.activeView)
   const activeView = useWikiStore((s) => s.activeView)
   const setActiveView = useWikiStore((s) => s.setActiveView)
   const setActiveView = useWikiStore((s) => s.setActiveView)
@@ -48,13 +101,149 @@ export function UnifiedSkillLibraryView() {
 }
 }
 
 
 export function UnifiedSkillLibrarySidebarPanel() {
 export function UnifiedSkillLibrarySidebarPanel() {
-  const activeView = useWikiStore((s) => s.activeView)
-  const showWritingSkill = activeView === "writingSkillLibrary"
+  const project = useWikiStore((s) => s.project)
+  const dataVersion = useWikiStore((s) => s.dataVersion)
+  const setActiveView = useWikiStore((s) => s.setActiveView)
+  const selectedSkillId = useWikiStore((s) => s.selectedSkillLibrarySkillId)
+  const selectedWritingSkillId = useWikiStore((s) => s.selectedWritingSkillLibrarySkillId)
+  const setSelectedSkillId = useWikiStore((s) => s.setSelectedSkillLibrarySkillId)
+  const setSelectedWritingSkillId = useWikiStore((s) => s.setSelectedWritingSkillLibrarySkillId)
+  const [entries, setEntries] = useState<UnifiedSkillEntry[]>([])
+  const [query, setQuery] = useState("")
+  const [category, setCategory] = useState<UnifiedSkillCategory>("all")
+  const [loadError, setLoadError] = useState("")
+
+  useEffect(() => {
+    let cancelled = false
+    setLoadError("")
+    Promise.all([
+      loadUserSkillConfig(project?.path),
+      loadDeAiSkillConfig(project?.path),
+    ])
+      .then(([writingConfig, deAiConfig]) => {
+        if (cancelled) return
+        setEntries([
+          ...writingConfig.skills.map(writingSkillToEntry),
+          ...getAllDeAiSkills(deAiConfig).map(deAiSkillToEntry),
+        ])
+      })
+      .catch(() => {
+        if (cancelled) return
+        setEntries([])
+        setLoadError("技能库加载失败")
+      })
+    return () => {
+      cancelled = true
+    }
+  }, [dataVersion, project?.path])
+
+  const activeEntryId = useMemo(() => {
+    if (selectedWritingSkillId) return `writing:${selectedWritingSkillId}`
+    if (selectedSkillId) return `de-ai:${selectedSkillId}`
+    return ""
+  }, [selectedSkillId, selectedWritingSkillId])
+
+  const filteredEntries = useMemo(() => {
+    const keyword = query.trim().toLowerCase()
+    return entries.filter((entry) => {
+      if (category === "writing" && entry.type !== "writing") return false
+      if (category === "de-ai" && entry.type !== "de-ai") return false
+      if (
+        category !== "all" &&
+        category !== "writing" &&
+        category !== "de-ai" &&
+        !entry.kinds.includes(category)
+      ) {
+        return false
+      }
+      if (!keyword) return true
+      return [entry.name, entry.description, entry.content]
+        .some((value) => value.toLowerCase().includes(keyword))
+    })
+  }, [category, entries, query])
+
+  function handleSelectEntry(entry: UnifiedSkillEntry) {
+    if (entry.type === "writing") {
+      setSelectedWritingSkillId(entry.sourceId)
+      setActiveView("writingSkillLibrary")
+      return
+    }
+    setSelectedSkillId(entry.sourceId)
+    setActiveView("skillLibrary")
+  }
 
 
   return (
   return (
     <div data-testid="unified-skill-library-sidebar" className="flex h-full flex-col overflow-hidden">
     <div data-testid="unified-skill-library-sidebar" className="flex h-full flex-col overflow-hidden">
-      <div className="min-h-0 flex-1 overflow-hidden">
-        {showWritingSkill ? <WritingSkillLibrarySidebarPanel /> : <SkillLibrarySidebarPanel />}
+      <div className="shrink-0 border-b px-3 py-2">
+        <h1 className="text-sm font-semibold">技能库</h1>
+        <p className="mt-0.5 text-xs text-muted-foreground">统一检索写作 Skill 和去AI味技能。</p>
+      </div>
+      <div className="shrink-0 border-b px-3 py-2">
+        <input
+          data-testid="unified-skill-search-input"
+          value={query}
+          onChange={(event) => setQuery(event.target.value)}
+          placeholder="搜索技能名称、说明或规则"
+          className="w-full rounded-md border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-ring"
+        />
+        <div className="mt-2 flex flex-wrap gap-1.5">
+          {unifiedSkillCategories.map((item) => (
+            <button
+              key={item.id}
+              type="button"
+              aria-pressed={category === item.id}
+              onClick={() => setCategory(item.id)}
+              className={`rounded-md border px-2 py-1 text-xs transition-colors ${
+                category === item.id
+                  ? "border-primary bg-primary text-primary-foreground"
+                  : "border-border text-muted-foreground hover:bg-accent hover:text-foreground"
+              }`}
+            >
+              {item.label}
+            </button>
+          ))}
+        </div>
+      </div>
+      {loadError ? (
+        <div className="border-b px-3 py-2 text-xs text-muted-foreground">{loadError}</div>
+      ) : null}
+      <div className="min-h-0 flex-1 overflow-y-auto p-2">
+        {filteredEntries.length === 0 ? (
+          <div className="rounded-md border border-dashed p-3 text-xs leading-5 text-muted-foreground">
+            没有匹配的技能。
+          </div>
+        ) : null}
+        {filteredEntries.map((entry) => {
+          const active = entry.id === activeEntryId
+          return (
+            <div
+              key={entry.id}
+              data-testid={`unified-skill-entry-${entry.id}`}
+              role="button"
+              tabIndex={0}
+              onClick={() => handleSelectEntry(entry)}
+              onKeyDown={(event) => {
+                if (event.key === "Enter" || event.key === " ") {
+                  event.preventDefault()
+                  handleSelectEntry(entry)
+                }
+              }}
+              className={`mb-2 rounded-md border px-3 py-2 text-left transition-colors hover:bg-accent ${
+                active ? "border-primary bg-accent/60" : "border-border"
+              }`}
+            >
+              <div className="flex items-center gap-2">
+                <span className="min-w-0 flex-1 truncate text-sm font-medium">{entry.name}</span>
+                <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
+                  {entry.type === "writing" ? "写作" : "去AI味"}
+                </span>
+              </div>
+              <div className="mt-1 truncate text-xs text-muted-foreground">
+                {entry.description || "未填写说明"}
+              </div>
+            </div>
+          )
+        })}
       </div>
       </div>
     </div>
     </div>
   )
   )

+ 2 - 2
src/components/sources/outline-chat-panel.spec.tsx

@@ -69,8 +69,8 @@ describe("OutlineChatPanel controls", () => {
 
 
   it("settles running outline tool calls when generation finishes", () => {
   it("settles running outline tool calls when generation finishes", () => {
     expect(source).toContain("settleRunningAgentToolCalls")
     expect(source).toContain("settleRunningAgentToolCalls")
-    expect(source).toContain("settleRunningAgentToolCalls(record.toolCalls.length ? record.toolCalls : message.agentToolCalls")
-    expect(source).toContain('settleRunningAgentToolCalls(message.agentToolCalls, "error"')
+    expect(source).toMatch(/settleRunningAgentToolCalls\(\s*record\.toolCalls\.length\s*\?\s*record\.toolCalls\s*:\s*message\.agentToolCalls/s)
+    expect(source).toMatch(/settleRunningAgentToolCalls\(\s*message\.agentToolCalls,\s*"error"/s)
   })
   })
 
 
   it("uses an outline-only tool set that cannot write chapters or memory", () => {
   it("uses an outline-only tool set that cannot write chapters or memory", () => {

+ 19 - 9
src/lib/agent/plugins/build-system-prompt-plugin.spec.ts

@@ -73,18 +73,28 @@ describe("BuildSystemPromptPlugin selected skills", () => {
       taskRoute: { intent: "write_chapter", confidence: 0.9, extractedParams: {} },
       taskRoute: { intent: "write_chapter", confidence: 0.9, extractedParams: {} },
     })
     })
 
 
-    expect(result.finalSystemPrompt).toContain("章节创作计划协议")
+    expect(result.finalSystemPrompt).toContain("章节主编策划协议")
     expect(result.finalSystemPrompt).toContain("chapter_plan")
     expect(result.finalSystemPrompt).toContain("chapter_plan")
-    expect(result.finalSystemPrompt).toContain("章节计划")
+    expect(result.finalSystemPrompt).toContain("本章策划案")
     expect(result.finalSystemPrompt).not.toContain("章节蓝图")
     expect(result.finalSystemPrompt).not.toContain("章节蓝图")
-    expect(result.finalSystemPrompt).toContain("维度一·输入校验")
-    expect(result.finalSystemPrompt).toContain("维度四·场景序列编排(计划核心)")
-    expect(result.finalSystemPrompt).toContain("维度七·节奏、字数与结尾钩子")
-    expect(result.finalSystemPrompt).toContain("爽点/期待点设计")
-    expect(result.finalSystemPrompt).toContain("场景戏剧功能")
-    expect(result.finalSystemPrompt).toContain("对话目标")
-    expect(result.finalSystemPrompt).toContain("开头与结尾")
+    expect(result.finalSystemPrompt).not.toContain("维度一")
+    expect(result.finalSystemPrompt).not.toContain("维度四")
+    expect(result.finalSystemPrompt).toContain("### 1. 本章目标")
+    expect(result.finalSystemPrompt).toContain("### 2. 已知依据")
+    expect(result.finalSystemPrompt).toContain("### 3. 执行边界")
+    expect(result.finalSystemPrompt).toContain("### 4. 分场景执行计划")
+    expect(result.finalSystemPrompt).toContain("### 5. 信息流与伏笔")
+    expect(result.finalSystemPrompt).toContain("### 6. 验收标准")
+    expect(result.finalSystemPrompt).toContain("### 7. 风险与兜底")
+    expect(result.finalSystemPrompt).not.toContain("承接与边界")
+    expect(result.finalSystemPrompt).not.toContain("戏剧问题与信息流")
+    expect(result.finalSystemPrompt).not.toContain("执行分层")
+    expect(result.finalSystemPrompt).not.toContain("结尾钩子")
+    expect(result.finalSystemPrompt).toContain("1200-1800字")
     expect(result.finalSystemPrompt).toContain("S1/S2/S3")
     expect(result.finalSystemPrompt).toContain("S1/S2/S3")
+    expect(result.finalSystemPrompt).toContain("目的、冲突、转折、输出结果、验收标准")
+    expect(result.finalSystemPrompt).toContain("输出结果")
+    expect(result.finalSystemPrompt).toContain("验收标准")
     expect(result.finalSystemPrompt).toContain("必须执行")
     expect(result.finalSystemPrompt).toContain("必须执行")
     expect(result.finalSystemPrompt).toContain("禁止违背")
     expect(result.finalSystemPrompt).toContain("禁止违背")
     expect(result.finalSystemPrompt).toContain("可自由发挥")
     expect(result.finalSystemPrompt).toContain("可自由发挥")

+ 35 - 34
src/lib/agent/plugins/build-system-prompt-plugin.ts

@@ -63,56 +63,57 @@ export function createBuildSystemPromptPlugin(deps: BuildSystemPromptPluginDeps
 }
 }
 
 
 function buildChapterPlanProtocol(mode: AiWorkflowMode): string {
 function buildChapterPlanProtocol(mode: AiWorkflowMode): string {
-  // 章节计划统一使用完整七维度分析,不再按 fast/standard/strict 裁剪维度。
   // mode 仅用于在协议头标注当前工作流强度,不改变计划结构。
   // mode 仅用于在协议头标注当前工作流强度,不改变计划结构。
   const modeLabel =
   const modeLabel =
     mode === "fast" ? "快速" : mode === "strict" ? "严格" : "标准"
     mode === "fast" ? "快速" : mode === "strict" ? "严格" : "标准"
   return [
   return [
-    "## 章节创作计划协议(章节计划)",
+    "## 章节主编策划协议(本章策划案)",
     "",
     "",
-    `当前工作流强度:${modeLabel}模式,已开启 Plan Execute。写正文前先输出章节计划供用户确认。`,
-    "计划是可追溯、可执行的创作决策,不写正文片段或工具流程。",
+    `当前工作流强度:${modeLabel}模式,已开启 Plan Execute。写正文前先输出本章策划案供用户确认。`,
+    "策划案面向用户阅读,要像主编给作者的执行计划:清楚、有取舍、能落地,不写工具流程,不写正文片段。",
     "",
     "",
     "输出规范:",
     "输出规范:",
     "1. 计划必须整体包裹在 `<!-- chapter_plan -->` 和 `<!-- /chapter_plan -->` 标记中。",
     "1. 计划必须整体包裹在 `<!-- chapter_plan -->` 和 `<!-- /chapter_plan -->` 标记中。",
-    "2. 计划只供用户确认,不写正文、工具日志或旧工作流说明。",
+    "2. 计划只供用户确认,正文生成必须等用户确认后再开始。",
     "3. 计划必须基于会话上下文包;读取资料前先用 list_chapters、list_outlines、list_memories 确认可用文件名,绝不编造资料名称。",
     "3. 计划必须基于会话上下文包;读取资料前先用 list_chapters、list_outlines、list_memories 确认可用文件名,绝不编造资料名称。",
-    "4. 计划必须按以下七个维度组织,每个维度都不可省略;场景用 S1/S2/S3 编号,后续正文必须按编号执行。",
-    "5. 计划末尾必须列出执行分层:必须执行 / 禁止违背 / 可自由发挥,避免正文阶段把所有内容混成同等优先级。",
+    "4. 计划总长控制在 1200-1800字,避免堆砌分析维度;用结论和执行项表达。",
+    "5. 场景必须用 S1/S2/S3 编号,后续正文会按编号执行。",
+    "6. 不要使用旧版分析报告式编号标题。",
     "",
     "",
-    "维度一·输入校验",
-    "- 核对 chapterGoal、outline、previousChapterEnding、recentSummaries、characterStates、cognitionStates、foreshadowingStates、timeline、canonRules、mustDo、mustAvoid、nextChapterAdvice。",
-    "- 字段缺失则写明原因和最小补全方向,不得伪装成已读取。",
+    "必须使用以下结构:",
     "",
     "",
-    "维度二·章节定位分析",
-    "- 卷/段落阶段:开篇、发展、转折、高潮或收束。",
-    "- 承接 previousChapterEnding 的待解问题、情绪或未完成动作。",
-    "- 写明本章把哪条主线推进到哪个节点,并为后文哪条伏笔铺垫。",
+    "### 1. 本章目标",
+    "- 用 2-4 句话说明本章要完成的剧情推进、人物状态变化、读者期待和章末状态。",
+    "- 写清这一章的核心问题:本章必须解决、推进或反转什么。",
     "",
     "",
-    "维度三·戏剧问题与信息流",
-    "- 本章核心戏剧问题:这一章要回答“……?”",
-    "- 章首悬念 → 章末新悬念或新问题。",
-    "- 信息差:依据 cognitionStates 写清揭示、隐藏、误导,禁止提前泄露角色未知信息。",
-    "- 伏笔动作:依据 foreshadowingStates 标明埋设/推进/回收及程度。",
-    "- 爽点/期待点设计:写清满足哪个期待、制造哪个新期待,必须有情绪、冲突或悬念回报。",
+    "### 2. 已知依据",
+    "- 列出计划依据:上一章结尾、近期剧情、章节目标、人物状态、认知状态、时间线、canonRules、已读取大纲/记忆/章节。",
+    "- 如果上下文缺失,明确写“缺失项”和“最小写作假设”,不得伪装成已读取。",
     "",
     "",
-    "维度四·场景序列编排(计划核心)",
-    "- 列出 2-4 个场景,统一写成 S1/S2/S3;每场写明:场景戏剧功能(制造/升级/反转/暂解/引出新问题)、情绪目标、地点、在场人物及目标、进入状态 → 出场状态、转场方式。",
-    "- 场景序列必须连成“起—承—转—合(或钩)”,不得只列一个场景。",
+    "### 3. 执行边界",
+    "- 必须执行:写漏即偏离的剧情结果、伏笔动作、人物变化和信息释放。",
+    "- 禁止违背:写了即错误的设定、认知越界、时间线冲突、提前泄密和不符合角色动机的行为。",
+    "- 可自由发挥:只允许补环境、动作、心理、过渡、对话细节和局部节奏。",
     "",
     "",
-    "维度五·冲突与人物引擎",
-    "- 核心冲突链:谁要什么 → 谁阻拦 → 结果如何 → 导向下一章什么问题。",
-    "- 人物动机必须溯源到 characterStates,禁止凭空给动机。",
-    "- 对话目标:写清角色想得到什么、不愿说什么、如何试探/隐瞒/压迫/诱导;对话后关系或信息状态必须变化。",
-    "- 章末人物变化:认知、关系、能力或处境。",
+    "### 4. 分场景执行计划",
+    "- 列出 2-4 个场景,统一写成 S1/S2/S3。",
+    "- 每个场景必须包含:目的、冲突、转折、输出结果、验收标准。",
+    "- 输出结果必须是正文完成后可检查的状态变化;验收标准必须能判断正文是否写漏。",
+    "- 场景序列必须连成起承转合或起承转钩,不得只列一个场景。",
     "",
     "",
-    "维度六·边界与禁忌",
-    "- 列出不得违背的 canonRules、不得超越的 timeline、不得破坏的 cognitionStates、不得提前回收的伏笔。",
+    "### 5. 信息流与伏笔",
+    "- 信息流分成:本章揭示、继续隐藏、允许误导、埋设/推进/回收伏笔。",
+    "- 写清读者会得到什么回报、留下什么新期待,以及哪些信息不能提前给出。",
+    "- 对话后必须带来关系、认知、处境或信息状态变化。",
     "",
     "",
-    "维度七·节奏、字数与结尾钩子",
-    "- 开头与结尾:开头承接上一章并立刻给当前问题;结尾完成阶段结果,并留下下一章必须解决的问题。",
-    "- 写明情绪/张力曲线、场景篇幅预算和章末钩子(悬念/反转/未决动作/新威胁)。",
-    "- 执行分层:必须执行写漏即偏离;禁止违背写了即错误;可自由发挥只允许补环境、动作、心理、过渡和细节。",
+    "### 6. 验收标准",
+    "- 列出 4-8 条可检查标准,覆盖:章节目标、每个 S 场景输出结果、信息流/伏笔、人物关系变化、章末状态。",
+    "- 标准必须能用“正文里是否出现/是否完成/是否违背”判断,禁止写成空泛审美要求。",
+    "",
+    "### 7. 风险与兜底",
+    "- 列出本章最容易写偏的风险:水文、解释腔、人物认知越界、提前泄密、场景断裂、钩子凭空出现。",
+    "- 为每个风险给出兜底处理:正文写作时应该如何避免或如何轻量补救。",
+    "- 写清章末必须出现的悬念、反转、未决动作或新威胁;钩子必须来自本章推进结果,不能凭空另起一条线。",
     "",
     "",
     "确认后动作:用户点击确认后,把整份计划作为 run_chapter_workflow 的 planBlueprint 参数传入,再进入正文生成,不再重复输出计划。",
     "确认后动作:用户点击确认后,把整份计划作为 run_chapter_workflow 的 planBlueprint 参数传入,再进入正文生成,不再重复输出计划。",
     "输出计划后暂停,等用户确认后再进入正文。",
     "输出计划后暂停,等用户确认后再进入正文。",

+ 26 - 0
src/lib/agent/tools/run-chapter-workflow.spec.ts

@@ -318,4 +318,30 @@ describe("createRunChapterWorkflowTool", () => {
     expect(result).toContain("计划履约度")
     expect(result).toContain("计划履约度")
     expect(result).toContain("履约度:基本符合")
     expect(result).toContain("履约度:基本符合")
   })
   })
+
+  it("includes execution report summary in the tool result when available", async () => {
+    const runDeepChapterGeneration = vi.fn(async () => ({
+      finalContent: "最终正文",
+      taskBrief: "任务书",
+      draftContent: "初稿",
+      reviewResults: [],
+      revised: true,
+      executionReport: "执行状态:已返修\n完成场景:S1/S2\n待处理偏离项:无",
+    }))
+    const tool = createRunChapterWorkflowTool({
+      projectPath: "E:/Novel",
+      llmConfig,
+      aiWorkflowMode: "standard",
+      runDeepChapterGeneration,
+    })
+
+    const result = await tool.execute({
+      intent: "write_chapter",
+      userRequest: "生成第3章",
+    })
+
+    expect(result).toContain("执行报告")
+    expect(result).toContain("执行状态:已返修")
+    expect(result).toContain("最终正文")
+  })
 })
 })

+ 1 - 0
src/lib/agent/tools/run-chapter-workflow.ts

@@ -154,6 +154,7 @@ export function createRunChapterWorkflowTool(options: RunChapterWorkflowToolOpti
         "章节工作流完成。",
         "章节工作流完成。",
         `是否返修:${result.revised ? "是" : "否"}`,
         `是否返修:${result.revised ? "是" : "否"}`,
         `任务书:${result.taskBrief}`,
         `任务书:${result.taskBrief}`,
+        result.executionReport ? `执行报告:\n${result.executionReport}` : "",
         result.planCompliance ? `计划履约度:\n${result.planCompliance}` : "",
         result.planCompliance ? `计划履约度:\n${result.planCompliance}` : "",
         "",
         "",
         "最终正文:",
         "最终正文:",

+ 3 - 2
src/lib/changelog.spec.ts

@@ -6,7 +6,8 @@ describe("changelog", () => {
     const entries = allChangelog()
     const entries = allChangelog()
     const versions = entries.map((entry) => entry.version)
     const versions = entries.map((entry) => entry.version)
 
 
-    expect(versions.slice(0, 26)).toEqual([
+    expect(versions.slice(0, 27)).toEqual([
+      "2.2.33",
       "2.2.32",
       "2.2.32",
       "2.2.31",
       "2.2.31",
       "2.2.30",
       "2.2.30",
@@ -34,7 +35,7 @@ describe("changelog", () => {
       "2.2.0",
       "2.2.0",
       "2.1.0",
       "2.1.0",
     ])
     ])
-    expect(versions[26]).toBe("2.0.0")
+    expect(versions[27]).toBe("2.0.0")
 
 
     for (let patch = 1; patch <= 6; patch += 1) {
     for (let patch = 1; patch <= 6; patch += 1) {
       expect(versions).not.toContain(`2.2.${patch}`)
       expect(versions).not.toContain(`2.2.${patch}`)

+ 22 - 0
src/lib/changelog.ts

@@ -78,6 +78,25 @@ const TWO_POINT_TWO_ELEVEN_CHANGELOG: ChangelogEntry = {
   },
   },
 };
 };
 
 
+const TWO_POINT_TWO_THIRTY_THREE_CHANGELOG: ChangelogEntry = {
+  version: "2.2.33",
+  date: "2026-07-06",
+  highlights: {
+    en: [
+      "Optimized AI chapter plan execution with a user-facing chapter plan, hidden execution contract, and visible execution report.",
+      "Added execution recheck and targeted repair so generated chapters are validated against the plan before returning results.",
+      "Improved chapter plan confirmation and activity output so planning, execution, and verification are easier to trace.",
+      "Stabilized AI chat and related mock coverage after the plan execution workflow changes.",
+    ],
+    zh: [
+      "优化 AI 会话章节计划执行:新增面向用户的本章策划案、隐藏执行契约和可查看的执行报告。",
+      "新增执行复核与定向修复,生成章节会按计划契约检查后再返回结果。",
+      "优化章节计划确认弹窗和执行动态展示,让计划、执行、复核过程更容易追踪。",
+      "修复计划执行优化后的 AI 会话相关 mock 测试回归,提升当前版本稳定性。",
+    ],
+  },
+};
+
 const TWO_POINT_TWO_THIRTY_TWO_CHANGELOG: ChangelogEntry = {
 const TWO_POINT_TWO_THIRTY_TWO_CHANGELOG: ChangelogEntry = {
   version: "2.2.32",
   version: "2.2.32",
   date: "2026-07-04",
   date: "2026-07-04",
@@ -766,6 +785,8 @@ export const CHANGELOG: ChangelogEntry[] = [
 ];
 ];
 
 
 export function currentVersionChangelog(version: string): ChangelogEntry[] {
 export function currentVersionChangelog(version: string): ChangelogEntry[] {
+  if (version === TWO_POINT_TWO_THIRTY_THREE_CHANGELOG.version)
+    return [TWO_POINT_TWO_THIRTY_THREE_CHANGELOG];
   if (version === TWO_POINT_TWO_THIRTY_TWO_CHANGELOG.version)
   if (version === TWO_POINT_TWO_THIRTY_TWO_CHANGELOG.version)
     return [TWO_POINT_TWO_THIRTY_TWO_CHANGELOG];
     return [TWO_POINT_TWO_THIRTY_TWO_CHANGELOG];
   if (version === TWO_POINT_TWO_THIRTY_ONE_CHANGELOG.version)
   if (version === TWO_POINT_TWO_THIRTY_ONE_CHANGELOG.version)
@@ -829,6 +850,7 @@ export function currentVersionChangelog(version: string): ChangelogEntry[] {
 
 
 export function allChangelog(): ChangelogEntry[] {
 export function allChangelog(): ChangelogEntry[] {
   return [
   return [
+    TWO_POINT_TWO_THIRTY_THREE_CHANGELOG,
     TWO_POINT_TWO_THIRTY_TWO_CHANGELOG,
     TWO_POINT_TWO_THIRTY_TWO_CHANGELOG,
     TWO_POINT_TWO_THIRTY_ONE_CHANGELOG,
     TWO_POINT_TWO_THIRTY_ONE_CHANGELOG,
     TWO_POINT_TWO_THIRTY_CHANGELOG,
     TWO_POINT_TWO_THIRTY_CHANGELOG,

+ 220 - 0
src/lib/novel/chapter-execution-contract.spec.ts

@@ -0,0 +1,220 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import type { LlmConfig } from "@/stores/wiki-store"
+import { streamChat } from "@/lib/llm-client"
+import {
+  buildChapterExecutionContractPrompt,
+  contractToTaskBriefText,
+  fallbackParseChapterExecutionContract,
+  parseChapterExecutionContractJson,
+  runChapterExecutionContractBuild,
+} from "./chapter-execution-contract"
+
+vi.mock("@/lib/llm-client", () => ({
+  streamChat: vi.fn(),
+}))
+
+const mockLlmConfig: LlmConfig = {
+  provider: "openai",
+  apiKey: "test-key",
+  model: "test-model",
+  ollamaUrl: "",
+  customEndpoint: "",
+  maxContextSize: 8192,
+}
+
+describe("chapter execution contract", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  it("buildChapterExecutionContractPrompt 要求输出完整 ChapterExecutionContract JSON", () => {
+    const prompt = buildChapterExecutionContractPrompt("## 本章策划案\nS1:旧屋门口\n- 输出结果:主角进入旧屋")
+
+    expect(prompt).toContain("ChapterExecutionContract")
+    expect(prompt).toContain("只输出 JSON")
+    expect(prompt).toContain("sceneSteps")
+    expect(prompt).toContain("purpose")
+    expect(prompt).toContain("conflict")
+    expect(prompt).toContain("turn")
+    expect(prompt).toContain("requiredOutcome")
+    expect(prompt).toContain("acceptanceCriteria")
+    expect(prompt).toContain("reveal/hide/mislead/foreshadow")
+    expect(prompt).toContain("## 本章策划案")
+    expect(prompt).not.toContain("维度一")
+  })
+
+  it("parseChapterExecutionContractJson 能解析 fenced 结构化 JSON", () => {
+    const contract = parseChapterExecutionContractJson([
+      "```json",
+      JSON.stringify({
+        objective: "推进旧屋线索",
+        knownContext: ["上一章听见门缝声"],
+        assumptions: ["主角不知道屋主身份"],
+        sceneSteps: [
+          {
+            id: "S1",
+            title: "旧屋门口",
+            purpose: "承接悬念",
+            conflict: "主角想进去,小晴阻止",
+            turn: "门内传来第二声响",
+            requiredOutcome: "主角进入旧屋",
+            acceptanceCriteria: ["出现门缝声承接", "主角进入旧屋"],
+          },
+        ],
+        mustDo: ["推进锈钥匙"],
+        mustAvoid: ["不要提前揭露屋主身份"],
+        dialogueGoals: ["主角试探小晴"],
+        informationFlow: {
+          reveal: ["旧屋有新脚印"],
+          hide: ["屋主身份"],
+          mislead: ["小晴误导主角"],
+          foreshadow: ["锈钥匙发热"],
+        },
+        finalHook: "门外第二串脚步",
+        freeSpace: ["可补足环境细节"],
+      }),
+      "```",
+    ].join("\n"))
+
+    expect(contract.objective).toBe("推进旧屋线索")
+    expect(contract.knownContext).toEqual(["上一章听见门缝声"])
+    expect(contract.assumptions).toEqual(["主角不知道屋主身份"])
+    expect(contract.sceneSteps[0]).toMatchObject({
+      id: "S1",
+      title: "旧屋门口",
+      purpose: "承接悬念",
+      conflict: "主角想进去,小晴阻止",
+      turn: "门内传来第二声响",
+      requiredOutcome: "主角进入旧屋",
+      acceptanceCriteria: ["出现门缝声承接", "主角进入旧屋"],
+    })
+    expect(contract.mustDo).toEqual(["推进锈钥匙"])
+    expect(contract.mustAvoid).toEqual(["不要提前揭露屋主身份"])
+    expect(contract.dialogueGoals).toEqual(["主角试探小晴"])
+    expect(contract.informationFlow.reveal).toEqual(["旧屋有新脚印"])
+    expect(contract.informationFlow.hide).toEqual(["屋主身份"])
+    expect(contract.informationFlow.mislead).toEqual(["小晴误导主角"])
+    expect(contract.informationFlow.foreshadow).toEqual(["锈钥匙发热"])
+    expect(contract.finalHook).toBe("门外第二串脚步")
+    expect(contract.freeSpace).toEqual(["可补足环境细节"])
+  })
+
+  it("fallbackParseChapterExecutionContract 能从主编策划案提取场景、风险和结尾钩子", () => {
+    const contract = fallbackParseChapterExecutionContract([
+      "## 主编策划案",
+      "### 1. 本章目标",
+      "推进旧屋线索。",
+      "S1:旧屋门口",
+      "- 目的:承接门缝声。",
+      "- 冲突:主角要进屋,小晴阻止。",
+      "- 转折:门内传来第二声响。",
+      "- 输出结果:主角进入旧屋。",
+      "- 验收标准:出现门缝声承接;主角进入旧屋。",
+      "S2:堂屋信纸",
+      "- 输出结果:发现锈钥匙线索。",
+      "- 验收标准:主角必须拿到信纸。",
+      "### 7. 结尾钩子",
+      "门外传来第二串脚步。",
+      "### 8. 风险提醒",
+      "- 不要提前揭露屋主身份。",
+      "- 禁止只解释设定不行动。",
+    ].join("\n"))
+
+    expect(contract.objective).toBe("推进旧屋线索。")
+    expect(contract.sceneSteps).toHaveLength(2)
+    expect(contract.sceneSteps[0]).toMatchObject({
+      id: "S1",
+      title: "旧屋门口",
+      purpose: "承接门缝声。",
+      conflict: "主角要进屋,小晴阻止。",
+      turn: "门内传来第二声响。",
+      requiredOutcome: "主角进入旧屋。",
+      acceptanceCriteria: ["出现门缝声承接", "主角进入旧屋。"],
+    })
+    expect(contract.sceneSteps[1]).toMatchObject({
+      id: "S2",
+      title: "堂屋信纸",
+      requiredOutcome: "发现锈钥匙线索。",
+      acceptanceCriteria: ["主角必须拿到信纸。"],
+    })
+    expect(contract.mustAvoid).toContain("不要提前揭露屋主身份。")
+    expect(contract.mustAvoid).toContain("禁止只解释设定不行动。")
+    expect(contract.finalHook).toBe("门外传来第二串脚步。")
+  })
+
+  it("contractToTaskBriefText 输出 Codex 风格中文写作任务书", () => {
+    const brief = contractToTaskBriefText({
+      objective: "推进旧屋线索",
+      knownContext: ["上一章听见门缝声"],
+      assumptions: [],
+      sceneSteps: [
+        {
+          id: "S1",
+          title: "旧屋门口",
+          purpose: "承接悬念",
+          conflict: "主角想进去,小晴阻止",
+          turn: "门内传来第二声响",
+          requiredOutcome: "主角进入旧屋",
+          acceptanceCriteria: ["出现门缝声承接", "主角进入旧屋"],
+        },
+      ],
+      mustDo: ["推进锈钥匙"],
+      mustAvoid: ["不要提前揭露屋主身份"],
+      dialogueGoals: ["主角试探小晴"],
+      informationFlow: {
+        reveal: ["旧屋有新脚印"],
+        hide: ["屋主身份"],
+        mislead: ["小晴误导主角"],
+        foreshadow: ["锈钥匙发热"],
+      },
+      finalHook: "门外传来第二串脚步。",
+      freeSpace: ["可补足环境和心理细节"],
+    })
+
+    expect(brief).toContain("## 写作任务书")
+    expect(brief).toContain("执行顺序")
+    expect(brief).toContain("S1:旧屋门口")
+    expect(brief).toContain("完成后状态:主角进入旧屋")
+    expect(brief).toContain("必须完成:出现门缝声承接;主角进入旧屋")
+    expect(brief).toContain("禁止违背:不要提前揭露屋主身份")
+    expect(brief).toContain("章末钩子")
+    expect(brief).not.toContain("正文")
+  })
+
+  it("runChapterExecutionContractBuild 使用 streamChat 聚合 token 并解析 JSON", async () => {
+    const aiResponse = JSON.stringify({
+      objective: "推进旧屋线索",
+      knownContext: ["上一章听见门缝声"],
+      assumptions: [],
+      sceneSteps: [
+        {
+          id: "S1",
+          title: "旧屋门口",
+          purpose: "承接悬念",
+          conflict: "主角想进去,小晴阻止",
+          turn: "门内传来第二声响",
+          requiredOutcome: "主角进入旧屋",
+          acceptanceCriteria: ["完成进入动作"],
+        },
+      ],
+      mustDo: ["推进锈钥匙"],
+      mustAvoid: [],
+      dialogueGoals: [],
+      informationFlow: { reveal: [], hide: [], mislead: [], foreshadow: [] },
+      finalHook: "门外第二串脚步",
+      freeSpace: ["可补足环境细节"],
+    })
+    vi.mocked(streamChat).mockImplementationOnce(async (_config, messages, callbacks) => {
+      expect(messages.map((message) => message.content).join("\n")).toContain("ChapterExecutionContract")
+      callbacks.onToken(aiResponse.slice(0, 20))
+      callbacks.onToken(aiResponse.slice(20))
+      callbacks.onDone()
+    })
+
+    const contract = await runChapterExecutionContractBuild(mockLlmConfig, "## 本章策划案\nS1:旧屋门口")
+
+    expect(streamChat).toHaveBeenCalledOnce()
+    expect(contract.objective).toBe("推进旧屋线索")
+    expect(contract.sceneSteps[0].title).toBe("旧屋门口")
+  })
+})

+ 397 - 0
src/lib/novel/chapter-execution-contract.ts

@@ -0,0 +1,397 @@
+import { streamChat } from "@/lib/llm-client"
+import type { LlmConfig } from "@/stores/wiki-store"
+
+export interface ChapterExecutionSceneStep {
+  id: string
+  title: string
+  purpose: string
+  conflict: string
+  turn: string
+  requiredOutcome: string
+  acceptanceCriteria: string[]
+}
+
+export interface ChapterExecutionContract {
+  objective: string
+  knownContext: string[]
+  assumptions: string[]
+  sceneSteps: ChapterExecutionSceneStep[]
+  mustDo: string[]
+  mustAvoid: string[]
+  dialogueGoals: string[]
+  informationFlow: {
+    reveal: string[]
+    hide: string[]
+    mislead: string[]
+    foreshadow: string[]
+  }
+  finalHook: string
+  freeSpace: string[]
+}
+
+export function createEmptyContract(): ChapterExecutionContract {
+  return {
+    objective: "",
+    knownContext: [],
+    assumptions: [],
+    sceneSteps: [],
+    mustDo: [],
+    mustAvoid: [],
+    dialogueGoals: [],
+    informationFlow: {
+      reveal: [],
+      hide: [],
+      mislead: [],
+      foreshadow: [],
+    },
+    finalHook: "",
+    freeSpace: [],
+  }
+}
+
+export function buildChapterExecutionContractPrompt(planContent: string): string {
+  return [
+    "你是章节计划执行清单转换器。",
+    "请把用户确认的主编策划案转换为 ChapterExecutionContract JSON。",
+    "只输出 JSON,不输出 markdown 代码块、不解释、不写正文。",
+    "",
+    "JSON 字段要求:",
+    "{",
+    '  "objective": "本章执行目标",',
+    '  "knownContext": ["已知上下文要点"],',
+    '  "assumptions": ["缺失信息下的最小写作假设"],',
+    '  "sceneSteps": [',
+    '    {',
+    '      "id": "S1",',
+    '      "title": "场景名",',
+    '      "purpose": "本场景目的",',
+    '      "conflict": "本场景冲突",',
+    '      "turn": "本场景转折",',
+    '      "requiredOutcome": "完成后状态",',
+    '      "acceptanceCriteria": ["可检查的验收标准"]',
+    "    }",
+    "  ],",
+    '  "mustDo": ["必须执行项"],',
+    '  "mustAvoid": ["禁止违背项"],',
+    '  "dialogueGoals": ["对话攻防目标"],',
+    '  "informationFlow": {',
+    '    "reveal": ["本章释放的信息"],',
+    '    "hide": ["本章继续隐藏的信息"],',
+    '    "mislead": ["允许误导读者的信息"],',
+    '    "foreshadow": ["需要埋下的伏笔"]',
+    "  },",
+    '  "finalHook": "章末钩子",',
+    '  "freeSpace": ["可自由发挥范围"]',
+    "}",
+    "",
+    "要求:",
+    "1. sceneSteps 必须保留 S1/S2/S3 等执行顺序,不得合并、跳过或调换。",
+    "2. acceptanceCriteria 必须写成可检查的完成标准。",
+    "3. informationFlow 必须使用 reveal/hide/mislead/foreshadow 四组数组。",
+    "4. 不要写章节成稿,不要复述无关解释。",
+    "",
+    "用户确认的主编策划案:",
+    planContent.trim(),
+  ].join("\n")
+}
+
+export async function runChapterExecutionContractBuild(
+  llmConfig: LlmConfig,
+  planContent: string,
+  signal?: AbortSignal,
+): Promise<ChapterExecutionContract> {
+  if (!planContent.trim()) return createEmptyContract()
+
+  let responseText = ""
+  let streamError: Error | null = null
+
+  await streamChat(
+    llmConfig,
+    [{ role: "user", content: buildChapterExecutionContractPrompt(planContent) }],
+    {
+      onToken: (token: string) => {
+        responseText += token
+      },
+      onDone: () => {},
+      onError: (error: Error) => {
+        streamError = error
+      },
+    },
+    signal,
+  )
+
+  if (streamError) throw streamError
+
+  try {
+    return parseChapterExecutionContractJson(responseText)
+  } catch {
+    return fallbackParseChapterExecutionContract(planContent)
+  }
+}
+
+export function parseChapterExecutionContractJson(text: string): ChapterExecutionContract {
+  const jsonText = extractJsonText(text)
+  const parsed = JSON.parse(jsonText) as Record<string, unknown>
+  return normalizeContract(parsed)
+}
+
+export function fallbackParseChapterExecutionContract(planContent: string): ChapterExecutionContract {
+  const lines = planContent
+    .split(/\r?\n/)
+    .map((line) => line.trim())
+    .filter(Boolean)
+  const contract = createEmptyContract()
+
+  contract.objective = collectSectionText(lines, /本章目标|章节目标|目标/, /读者期待|章节推进|分场景策划|人物动机|信息流|结尾钩子|章末钩子|风险提醒/) || firstPlainLine(lines)
+  contract.sceneSteps = parseSceneSteps(lines)
+  contract.finalHook = collectSectionText(lines, /结尾钩子|章末钩子/, /风险提醒|必须避免|禁止/) || collectInlineValue(lines, /^(?:结尾钩子|章末钩子|钩子)[::]\s*(.+)$/)
+  contract.mustAvoid = unique([
+    ...collectBulletSection(lines, /风险提醒|必须避免|禁止/),
+    ...collectInlineValues(lines, /^(?:风险提醒|风险|必须避免|禁止|不得|不要)[::]\s*(.+)$/),
+  ])
+  contract.mustDo = unique([
+    ...collectBulletSection(lines, /必须完成|必须执行/),
+    ...collectInlineValues(lines, /^(?:必须完成|必须执行|要做)[::]\s*(.+)$/),
+    ...contract.sceneSteps.map((step) => step.requiredOutcome).filter(Boolean),
+    contract.finalHook,
+  ].filter(Boolean))
+  contract.freeSpace = ["可补足环境、动作、心理、过渡和细节,但不得改变必须执行、禁止违背和章末钩子。"]
+
+  return contract
+}
+
+export function contractToTaskBriefText(contract: ChapterExecutionContract): string {
+  const lines = [
+    "## 写作任务书",
+    "",
+    `本章目标:${contract.objective || "按用户确认的章节策划案生成完整章节。"}`,
+  ]
+
+  if (contract.knownContext.length > 0) lines.push("", ...formatList("已知上下文", contract.knownContext))
+  if (contract.assumptions.length > 0) lines.push("", ...formatList("执行假设", contract.assumptions))
+
+  lines.push("", "## 执行顺序")
+  for (const step of contract.sceneSteps) {
+    lines.push(`### ${step.id || "S?"}:${step.title || "未命名场景"}`)
+    if (step.purpose) lines.push(`目的:${step.purpose}`)
+    if (step.conflict) lines.push(`冲突:${step.conflict}`)
+    if (step.turn) lines.push(`转折:${step.turn}`)
+    lines.push(`必须完成:${joinOrFallback(step.acceptanceCriteria, step.requiredOutcome || "完成本场景输出结果")}`)
+    lines.push(`禁止违背:${joinOrFallback(contract.mustAvoid, "不得违背已知设定、人物认知和时间线")}`)
+    lines.push(`完成后状态:${step.requiredOutcome || "场景状态发生明确变化"}`)
+    lines.push("")
+  }
+
+  lines.push("## 全章硬约束")
+  lines.push(`必须执行:${joinOrFallback(contract.mustDo, "完成本章目标和所有场景输出结果")}`)
+  lines.push(`禁止违背:${joinOrFallback(contract.mustAvoid, "不得提前泄密、不得人物认知越界、不得只解释不行动")}`)
+  if (contract.dialogueGoals.length > 0) lines.push(`对话目标:${contract.dialogueGoals.join(";")}`)
+
+  const flowLines = formatInformationFlow(contract.informationFlow)
+  if (flowLines.length > 0) lines.push("", "## 信息流", ...flowLines)
+
+  lines.push("", "## 章末钩子")
+  lines.push(contract.finalHook || "留下自然导向下一章的未解决问题。")
+  lines.push("", "## 可自由发挥")
+  lines.push(joinOrFallback(contract.freeSpace, "环境、动作、心理和过渡细节。"))
+
+  return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()
+}
+
+function normalizeContract(parsed: Record<string, unknown>): ChapterExecutionContract {
+  return {
+    objective: asString(parsed.objective),
+    knownContext: asStringArray(parsed.knownContext),
+    assumptions: asStringArray(parsed.assumptions),
+    sceneSteps: normalizeSceneSteps(parsed.sceneSteps),
+    mustDo: asStringArray(parsed.mustDo),
+    mustAvoid: asStringArray(parsed.mustAvoid),
+    dialogueGoals: asStringArray(parsed.dialogueGoals),
+    informationFlow: normalizeInformationFlow(parsed.informationFlow),
+    finalHook: asString(parsed.finalHook),
+    freeSpace: asStringArray(parsed.freeSpace),
+  }
+}
+
+function normalizeSceneSteps(value: unknown): ChapterExecutionSceneStep[] {
+  if (!Array.isArray(value)) return []
+  return value
+    .map((item, index) => {
+      const record = isRecord(item) ? item : {}
+      const requiredOutcome = asString(record.requiredOutcome) || asString(record.outputResult)
+      return {
+        id: asString(record.id) || `S${index + 1}`,
+        title: asString(record.title),
+        purpose: asString(record.purpose),
+        conflict: asString(record.conflict),
+        turn: asString(record.turn),
+        requiredOutcome,
+        acceptanceCriteria: asStringArray(record.acceptanceCriteria),
+      }
+    })
+    .filter((step) => step.title || step.requiredOutcome || step.acceptanceCriteria.length > 0)
+}
+
+function normalizeInformationFlow(value: unknown): ChapterExecutionContract["informationFlow"] {
+  if (isRecord(value)) {
+    return {
+      reveal: asStringArray(value.reveal),
+      hide: asStringArray(value.hide),
+      mislead: asStringArray(value.mislead),
+      foreshadow: asStringArray(value.foreshadow),
+    }
+  }
+  return {
+    reveal: asStringArray(value),
+    hide: [],
+    mislead: [],
+    foreshadow: [],
+  }
+}
+
+function parseSceneSteps(lines: string[]): ChapterExecutionSceneStep[] {
+  const scenes: ChapterExecutionSceneStep[] = []
+  let current: ChapterExecutionSceneStep | null = null
+
+  for (const line of lines) {
+    const cleaned = cleanLine(line)
+    const sceneMatch = /^(S\d+)[\s::.、.-]*(.+)$/i.exec(cleaned)
+    if (sceneMatch) {
+      current = {
+        id: sceneMatch[1].toUpperCase(),
+        title: sceneMatch[2].trim(),
+        purpose: "",
+        conflict: "",
+        turn: "",
+        requiredOutcome: "",
+        acceptanceCriteria: [],
+      }
+      scenes.push(current)
+      continue
+    }
+
+    if (!current) continue
+    assignSceneField(current, cleaned)
+  }
+
+  return scenes.map((scene) => ({
+    ...scene,
+    acceptanceCriteria: scene.acceptanceCriteria.length > 0
+      ? scene.acceptanceCriteria
+      : [scene.requiredOutcome].filter(Boolean),
+  }))
+}
+
+function assignSceneField(scene: ChapterExecutionSceneStep, line: string): void {
+  const field = /^(目的|冲突|转折|输出结果|验收标准)[::]\s*(.+)$/.exec(line)
+  if (!field) return
+  const content = field[2].trim()
+  if (field[1] === "目的") scene.purpose = content
+  if (field[1] === "冲突") scene.conflict = content
+  if (field[1] === "转折") scene.turn = content
+  if (field[1] === "输出结果") scene.requiredOutcome = content
+  if (field[1] === "验收标准") scene.acceptanceCriteria = splitList(content)
+}
+
+function extractJsonText(text: string): string {
+  const trimmed = text.trim()
+  const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(trimmed)
+  if (fenced) return fenced[1].trim()
+
+  const start = trimmed.indexOf("{")
+  const end = trimmed.lastIndexOf("}")
+  if (start >= 0 && end > start) return trimmed.slice(start, end + 1)
+  return trimmed
+}
+
+function collectSectionText(lines: string[], startPattern: RegExp, endPattern: RegExp): string {
+  const start = lines.findIndex((line) => startPattern.test(line))
+  if (start < 0) return ""
+  const inline = lineValueAfterColon(lines[start])
+  if (inline) return inline
+
+  const values: string[] = []
+  for (const line of lines.slice(start + 1)) {
+    if (/^#{1,6}\s+/.test(line)) {
+      if (endPattern.test(line)) break
+      break
+    }
+    if (/^S\d+[\s::.、.-]/i.test(cleanLine(line))) break
+    values.push(cleanLine(line))
+  }
+  return values.join(" ").trim()
+}
+
+function collectBulletSection(lines: string[], startPattern: RegExp): string[] {
+  const start = lines.findIndex((line) => startPattern.test(line))
+  if (start < 0) return []
+  const values: string[] = []
+  for (const line of lines.slice(start + 1)) {
+    if (/^#{1,6}\s+/.test(line)) break
+    if (/^[-*]\s+/.test(line)) values.push(cleanLine(line))
+  }
+  return unique(values)
+}
+
+function collectInlineValue(lines: string[], pattern: RegExp): string {
+  return collectInlineValues(lines, pattern)[0] ?? ""
+}
+
+function collectInlineValues(lines: string[], pattern: RegExp): string[] {
+  return unique(lines.map((line) => pattern.exec(cleanLine(line))?.[1]?.trim() ?? "").filter(Boolean))
+}
+
+function firstPlainLine(lines: string[]): string {
+  return lines.find((line) => !/^#{1,6}\s+/.test(line) && !/^S\d+[\s::.、.-]/i.test(cleanLine(line)) && !/^[-*]\s+/.test(line)) ?? ""
+}
+
+function lineValueAfterColon(line: string): string {
+  return /^[^::]+[::]\s*(.+)$/.exec(cleanLine(line))?.[1]?.trim() ?? ""
+}
+
+function asString(value: unknown): string {
+  return typeof value === "string" ? value.trim() : ""
+}
+
+function asStringArray(value: unknown): string[] {
+  if (Array.isArray(value)) {
+    return unique(value.map(asString).filter(Boolean))
+  }
+  if (typeof value === "string") {
+    return splitList(value)
+  }
+  return []
+}
+
+function splitList(value: string): string[] {
+  return unique(value.split(/\r?\n|[;;]/).map((item) => item.trim()).filter(Boolean))
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === "object" && value !== null
+}
+
+function cleanLine(line: string): string {
+  return line.replace(/^[-*]\s*/, "").trim()
+}
+
+function unique(values: string[]): string[] {
+  return Array.from(new Set(values))
+}
+
+function joinOrFallback(values: string[], fallback: string): string {
+  return values.filter(Boolean).join(";") || fallback
+}
+
+function formatList(title: string, values: string[]): string[] {
+  return [`${title}:`, ...values.map((value) => `- ${value}`)]
+}
+
+function formatInformationFlow(flow: ChapterExecutionContract["informationFlow"]): string[] {
+  return [
+    flow.reveal.length > 0 ? `释放:${flow.reveal.join(";")}` : "",
+    flow.hide.length > 0 ? `隐藏:${flow.hide.join(";")}` : "",
+    flow.mislead.length > 0 ? `误导:${flow.mislead.join(";")}` : "",
+    flow.foreshadow.length > 0 ? `伏笔:${flow.foreshadow.join(";")}` : "",
+  ].filter(Boolean)
+}

+ 108 - 0
src/lib/novel/chapter-execution-report.spec.ts

@@ -0,0 +1,108 @@
+import { describe, expect, it, vi } from "vitest"
+import type { LlmConfig } from "@/stores/wiki-store"
+import { streamChat } from "@/lib/llm-client"
+import { createEmptyContract } from "./chapter-execution-contract"
+import {
+  buildChapterExecutionReportPrompt,
+  executionReportToToolSummary,
+  extractExecutionRepairItems,
+  parseChapterExecutionReportJson,
+  runChapterExecutionReportCheck,
+} from "./chapter-execution-report"
+
+vi.mock("@/lib/llm-client", () => ({
+  streamChat: vi.fn(),
+}))
+
+const llmConfig: LlmConfig = {
+  provider: "custom",
+  apiKey: "test-key",
+  model: "test-model",
+  ollamaUrl: "",
+  customEndpoint: "https://example.test/v1",
+  maxContextSize: 120000,
+}
+
+describe("chapter-execution-report", () => {
+  it("builds a checklist report prompt from contract and final content", () => {
+    const contract = {
+      ...createEmptyContract(),
+      objective: "推进旧屋线索",
+      sceneSteps: [{
+        id: "S1",
+        title: "旧屋门口",
+        purpose: "承接悬念",
+        conflict: "阻止进入",
+        turn: "门内响动",
+        requiredOutcome: "主角进入旧屋",
+        acceptanceCriteria: ["主角进入旧屋"],
+      }],
+      finalHook: "门外出现第二个人影",
+    }
+
+    const prompt = buildChapterExecutionReportPrompt(contract, "最终正文")
+
+    expect(prompt).toContain("逐项验收")
+    expect(prompt).toContain("sceneResults")
+    expect(prompt).toContain("S1")
+    expect(prompt).toContain("主角进入旧屋")
+    expect(prompt).toContain("最终正文")
+  })
+
+  it("parses report JSON and extracts repair items", () => {
+    const report = parseChapterExecutionReportJson(JSON.stringify({
+      status: "fail",
+      sceneResults: [{
+        id: "S2",
+        passed: false,
+        missing: ["半张族谱误导"],
+        evidence: "正文只写信纸,没有误导。",
+        repairInstruction: "在 S2 补入族谱误导。",
+      }],
+      mustDoResults: [],
+      mustAvoidResults: [],
+      finalHookPassed: false,
+      repairItems: ["S2 缺少半张族谱误导", "章末钩子缺失"],
+    }))
+
+    expect(report.status).toBe("fail")
+    expect(extractExecutionRepairItems(report)).toEqual(["S2 缺少半张族谱误导", "章末钩子缺失"])
+  })
+
+  it("formats a short tool summary", () => {
+    const summary = executionReportToToolSummary({
+      status: "warning",
+      sceneResults: [
+        { id: "S1", passed: true, missing: [], evidence: "已进入旧屋", repairInstruction: "" },
+        { id: "S2", passed: false, missing: ["误导不足"], evidence: "", repairInstruction: "补误导" },
+      ],
+      mustDoResults: [],
+      mustAvoidResults: [],
+      finalHookPassed: true,
+      repairItems: ["S2 误导不足"],
+    })
+
+    expect(summary).toContain("执行状态:有警告")
+    expect(summary).toContain("完成场景:S1")
+    expect(summary).toContain("待处理偏离项:S2 误导不足")
+  })
+
+  it("runs report model call and returns parsed report", async () => {
+    vi.mocked(streamChat).mockImplementationOnce(async (_config, messages, callbacks) => {
+      expect(messages[0].content).toContain("逐项验收")
+      callbacks.onToken(JSON.stringify({
+        status: "pass",
+        sceneResults: [],
+        mustDoResults: [],
+        mustAvoidResults: [],
+        finalHookPassed: true,
+        repairItems: [],
+      }))
+      callbacks.onDone()
+    })
+
+    const report = await runChapterExecutionReportCheck(llmConfig, createEmptyContract(), "最终正文")
+
+    expect(report.status).toBe("pass")
+  })
+})

+ 202 - 0
src/lib/novel/chapter-execution-report.ts

@@ -0,0 +1,202 @@
+import { streamChat } from "@/lib/llm-client"
+import type { LlmConfig } from "@/stores/wiki-store"
+import type { ChapterExecutionContract } from "./chapter-execution-contract"
+import { contractToTaskBriefText } from "./chapter-execution-contract"
+
+export type ChapterExecutionReportStatus = "pass" | "warning" | "fail" | "unknown"
+
+export interface ChapterExecutionSceneResult {
+  id: string
+  passed: boolean
+  missing: string[]
+  evidence: string
+  repairInstruction: string
+}
+
+export interface ChapterExecutionReport {
+  status: ChapterExecutionReportStatus
+  sceneResults: ChapterExecutionSceneResult[]
+  mustDoResults: Array<{ item: string; passed: boolean; evidence: string }>
+  mustAvoidResults: Array<{ item: string; violated: boolean; evidence: string }>
+  finalHookPassed: boolean
+  repairItems: string[]
+}
+
+export function buildChapterExecutionReportPrompt(
+  contract: ChapterExecutionContract,
+  finalContent: string,
+): string {
+  return [
+    "你是章节执行报告检查助手。",
+    "请根据 ChapterExecutionContract 对最终正文逐项验收。",
+    "只输出 JSON,不输出 markdown 代码块,不改写正文。",
+    "",
+    "JSON 字段:status、sceneResults、mustDoResults、mustAvoidResults、finalHookPassed、repairItems。",
+    "status 只能是 pass / warning / fail。",
+    "sceneResults 每项包含 id、passed、missing、evidence、repairInstruction。",
+    "mustDoResults 每项包含 item、passed、evidence。",
+    "mustAvoidResults 每项包含 item、violated、evidence。",
+    "repairItems 只列需要返修的具体失败项;不要写笼统建议。",
+    "",
+    "ChapterExecutionContract:",
+    contractToTaskBriefText(contract),
+    "",
+    "最终正文:",
+    finalContent.trim(),
+  ].join("\n")
+}
+
+export async function runChapterExecutionReportCheck(
+  llmConfig: LlmConfig,
+  contract: ChapterExecutionContract,
+  finalContent: string,
+  signal?: AbortSignal,
+): Promise<ChapterExecutionReport> {
+  let responseText = ""
+  let streamError: Error | null = null
+
+  await streamChat(
+    llmConfig,
+    [{ role: "user", content: buildChapterExecutionReportPrompt(contract, finalContent) }],
+    {
+      onToken: (token: string) => {
+        responseText += token
+      },
+      onDone: () => {},
+      onError: (error: Error) => {
+        streamError = error
+      },
+    },
+    signal,
+  )
+
+  if (streamError) throw streamError
+  return parseChapterExecutionReportJson(responseText)
+}
+
+export function parseChapterExecutionReportJson(text: string): ChapterExecutionReport {
+  const jsonText = extractJsonObject(text)
+  if (!jsonText) return createUnknownReport()
+  try {
+    const parsed = JSON.parse(jsonText) as Record<string, unknown>
+    return {
+      status: normalizeStatus(parsed.status),
+      sceneResults: normalizeSceneResults(parsed.sceneResults),
+      mustDoResults: normalizeMustDoResults(parsed.mustDoResults),
+      mustAvoidResults: normalizeMustAvoidResults(parsed.mustAvoidResults),
+      finalHookPassed: parsed.finalHookPassed === true,
+      repairItems: stringArray(parsed.repairItems),
+    }
+  } catch {
+    return createUnknownReport()
+  }
+}
+
+export function extractExecutionRepairItems(report: ChapterExecutionReport): string[] {
+  if (report.repairItems.length > 0) return report.repairItems
+  return report.sceneResults
+    .filter((result) => !result.passed)
+    .map((result) => result.repairInstruction || `${result.id} 未完成:${result.missing.join(";")}`)
+    .filter(Boolean)
+}
+
+export function executionReportToToolSummary(report: ChapterExecutionReport, repaired = false): string {
+  const statusLabel = repaired
+    ? "已返修"
+    : report.status === "pass"
+      ? "通过"
+      : report.status === "warning"
+        ? "有警告"
+        : report.status === "fail"
+          ? "需返修"
+          : "检查失败"
+  const completedScenes = report.sceneResults
+    .filter((item) => item.passed)
+    .map((item) => item.id)
+    .join("/") || "无"
+  const repairItems = extractExecutionRepairItems(report)
+  return [
+    `执行状态:${statusLabel}`,
+    `完成场景:${completedScenes}`,
+    `待处理偏离项:${repairItems.length > 0 ? repairItems.join(";") : "无"}`,
+  ].join("\n")
+}
+
+function createUnknownReport(): ChapterExecutionReport {
+  return {
+    status: "unknown",
+    sceneResults: [],
+    mustDoResults: [],
+    mustAvoidResults: [],
+    finalHookPassed: false,
+    repairItems: [],
+  }
+}
+
+function normalizeStatus(value: unknown): ChapterExecutionReportStatus {
+  return value === "pass" || value === "warning" || value === "fail" ? value : "unknown"
+}
+
+function normalizeSceneResults(value: unknown): ChapterExecutionSceneResult[] {
+  if (!Array.isArray(value)) return []
+  return value.map((item) => {
+    const record = isRecord(item) ? item : {}
+    return {
+      id: stringValue(record.id),
+      passed: record.passed === true,
+      missing: stringArray(record.missing),
+      evidence: stringValue(record.evidence),
+      repairInstruction: stringValue(record.repairInstruction),
+    }
+  }).filter((item) => item.id)
+}
+
+function normalizeMustDoResults(value: unknown): ChapterExecutionReport["mustDoResults"] {
+  if (!Array.isArray(value)) return []
+  return value.map((item) => {
+    const record = isRecord(item) ? item : {}
+    return {
+      item: stringValue(record.item),
+      passed: record.passed === true,
+      evidence: stringValue(record.evidence),
+    }
+  }).filter((item) => item.item)
+}
+
+function normalizeMustAvoidResults(value: unknown): ChapterExecutionReport["mustAvoidResults"] {
+  if (!Array.isArray(value)) return []
+  return value.map((item) => {
+    const record = isRecord(item) ? item : {}
+    return {
+      item: stringValue(record.item),
+      violated: record.violated === true,
+      evidence: stringValue(record.evidence),
+    }
+  }).filter((item) => item.item)
+}
+
+function extractJsonObject(text: string): string | null {
+  const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]?.trim() ?? text
+  const start = fenced.indexOf("{")
+  const end = fenced.lastIndexOf("}")
+  if (start < 0 || end <= start) return null
+  return fenced.slice(start, end + 1)
+}
+
+function stringValue(value: unknown): string {
+  return typeof value === "string" ? value.trim() : ""
+}
+
+function stringArray(value: unknown): string[] {
+  if (Array.isArray(value)) {
+    return Array.from(new Set(value.map(stringValue).filter(Boolean)))
+  }
+  if (typeof value === "string") {
+    return value.split(/\r?\n|[;;]/).map((item) => item.trim()).filter(Boolean)
+  }
+  return []
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === "object" && value !== null
+}

+ 18 - 0
src/lib/novel/chapter-plan-execution-summary.spec.ts

@@ -26,6 +26,24 @@ describe("buildChapterPlanExecutionSummary", () => {
     expect(summary).toContain("结尾钩子:")
     expect(summary).toContain("结尾钩子:")
   })
   })
 
 
+  it("keeps multiline S scene items under the scene-sequence dimension", () => {
+    const plan = [
+      "维度二·章节定位分析:本章目标是承接门缝声,推进锈钥匙线索。",
+      "维度四·场景序列编排:",
+      "S1 旧屋门口承接上一章门缝声,功能:制造当前问题。",
+      "S2 堂屋信纸揭示锈钥匙线索,功能:升级信息差。",
+      "S3 屋外第二个人影贴近门口,功能:引出新威胁。",
+      "维度六·边界与禁忌:禁止提前揭露旧屋主人身份。",
+      "维度七·节奏、字数与结尾钩子:结尾钩子是第二个人影贴近门口。",
+    ].join("\n")
+
+    const summary = buildChapterPlanExecutionSummary(plan)
+
+    expect(summary).toContain("S1 旧屋门口承接上一章门缝声")
+    expect(summary).toContain("S2 堂屋信纸揭示锈钥匙线索")
+    expect(summary).toContain("S3 屋外第二个人影贴近门口")
+  })
+
   it("keeps chapter execution constraints and removes low-value filler", () => {
   it("keeps chapter execution constraints and removes low-value filler", () => {
     const plan = [
     const plan = [
       "维度一·输入校验:本章写第3章。",
       "维度一·输入校验:本章写第3章。",

+ 23 - 5
src/lib/novel/chapter-plan-execution-summary.ts

@@ -1,7 +1,7 @@
 const DEFAULT_PLAN_EXECUTION_SUMMARY_MAX_CHARS = 1800
 const DEFAULT_PLAN_EXECUTION_SUMMARY_MAX_CHARS = 1800
 
 
 const EXECUTION_KEYWORD_PATTERN =
 const EXECUTION_KEYWORD_PATTERN =
-  /维度[一二三四五六七]|本章目标|章节目标|场景|戏剧功能|信息流|伏笔|边界|禁忌|对话目标|爽点|期待点|开头|结尾|钩子|水文|冲突|人物|必须|禁止|不得|可自由|自由发挥|mustDo|mustAvoid|canon|timeline|cognition/i
+  /维度[一二三四五六七]|^S\d+[\s::.、.-]|本章目标|章节目标|场景|戏剧功能|信息流|伏笔|边界|禁忌|对话目标|爽点|期待点|开头|结尾|钩子|水文|冲突|人物|必须|禁止|不得|可自由|自由发挥|mustDo|mustAvoid|canon|timeline|cognition/i
 
 
 const LOW_VALUE_LINE_PATTERN =
 const LOW_VALUE_LINE_PATTERN =
   /感谢确认|下面开始|下面才会|工具流程|计划来源|帮助模型理解|不属于正文|补充说明/i
   /感谢确认|下面开始|下面才会|工具流程|计划来源|帮助模型理解|不属于正文|补充说明/i
@@ -56,16 +56,34 @@ function extractFirstValue(lines: string[], pattern: RegExp): string {
 }
 }
 
 
 function extractSceneItems(lines: string[]): string[] {
 function extractSceneItems(lines: string[]): string[] {
-  const sceneLine = lines.find((line) => /场景序列|维度四/.test(line))
-  if (!sceneLine) return []
-  const content = cleanPlanLine(sceneLine)
+  const sceneIndex = lines.findIndex((line) => /场景序列|维度四/.test(line))
+  if (sceneIndex < 0) return []
+
+  const sceneContents: string[] = []
+  const headerContent = cleanPlanLine(lines[sceneIndex])
+  if (headerContent) sceneContents.push(headerContent)
+
+  for (const line of lines.slice(sceneIndex + 1)) {
+    if (/^维度[一二三五六七]/.test(line)) break
+    if (/^S\d+[\s::.、.-]/i.test(line)) {
+      sceneContents.push(line)
+    }
+  }
+
+  return unique(sceneContents.flatMap(parseSceneLine))
+}
+
+function parseSceneLine(line: string): string[] {
+  const content = cleanPlanLine(line)
   const numbered = content
   const numbered = content
     .replace(/(?:^|[;;]\s*)(?:S)?(\d+)[.、.]\s*/gi, "\n")
     .replace(/(?:^|[;;]\s*)(?:S)?(\d+)[.、.]\s*/gi, "\n")
     .split(/\n|[;;]/)
     .split(/\n|[;;]/)
     .map((item) => item.trim())
     .map((item) => item.trim())
     .filter(Boolean)
     .filter(Boolean)
   const items = numbered.length > 0 ? numbered : [content]
   const items = numbered.length > 0 ? numbered : [content]
-  return items.map((item) => item.replace(/^S\d+\s*/i, "").trim()).filter(Boolean)
+  return items
+    .map((item) => item.replace(/^S\d+[\s::.、.-]*/i, "").trim())
+    .filter(Boolean)
 }
 }
 
 
 function cleanPlanLine(line: string): string {
 function cleanPlanLine(line: string): string {

+ 20 - 8
src/lib/novel/chapter-plan-self-check.spec.ts

@@ -24,20 +24,30 @@ const llmConfig: LlmConfig = {
 }
 }
 
 
 describe("chapter-plan-self-check", () => {
 describe("chapter-plan-self-check", () => {
-  it("builds a prompt that checks blueprint completeness and includes the source plan", () => {
+  it("builds a prompt that checks Codex-style plan completeness and includes the source plan", () => {
     const prompt = buildChapterPlanSelfCheckPrompt("维度四·场景序列编排:旧屋揭示")
     const prompt = buildChapterPlanSelfCheckPrompt("维度四·场景序列编排:旧屋揭示")
 
 
     expect(prompt).toContain("计划自检")
     expect(prompt).toContain("计划自检")
     expect(prompt).not.toContain("蓝图")
     expect(prompt).not.toContain("蓝图")
-    expect(prompt).toContain("七个维度")
+    expect(prompt).not.toContain("七个维度")
+    for (const section of [
+      "本章目标",
+      "已知依据",
+      "执行边界",
+      "分场景执行计划",
+      "信息流与伏笔",
+      "验收标准",
+      "风险与兜底",
+    ]) {
+      expect(prompt).toContain(section)
+    }
     expect(prompt).toContain("维度四·场景序列编排:旧屋揭示")
     expect(prompt).toContain("维度四·场景序列编排:旧屋揭示")
-    expect(prompt).toContain("爽点/期待点")
-    expect(prompt).toContain("场景戏剧功能")
+    expect(prompt).toContain("目的、冲突、转折、输出结果、验收标准")
+    expect(prompt).toContain("缺失段落、缺失场景字段或不可验收的标准必须进入 issues")
     expect(prompt).toContain("对话目标")
     expect(prompt).toContain("对话目标")
     expect(prompt).toContain("水文")
     expect(prompt).toContain("水文")
-    expect(prompt).toContain("开头和结尾")
     expect(prompt).toContain("只输出一个 JSON 对象")
     expect(prompt).toContain("只输出一个 JSON 对象")
-    expect(prompt.length).toBeLessThan(760)
+    expect(prompt.length).toBeLessThan(1100)
   })
   })
 
 
   it("includes compressed project context when provided", () => {
   it("includes compressed project context when provided", () => {
@@ -100,10 +110,12 @@ describe("chapter-plan-self-check", () => {
     expect(prompt).toContain("原计划")
     expect(prompt).toContain("原计划")
     expect(prompt).not.toContain("蓝图")
     expect(prompt).not.toContain("蓝图")
     expect(prompt).toContain("缺少字数预算")
     expect(prompt).toContain("缺少字数预算")
-    expect(prompt).toContain("爽点/期待点")
+    expect(prompt).toContain("分场景执行计划")
+    expect(prompt).toContain("验收标准")
+    expect(prompt).toContain("风险与兜底")
     expect(prompt).toContain("对话目标")
     expect(prompt).toContain("对话目标")
     expect(prompt).toContain("只输出修订后的章节计划")
     expect(prompt).toContain("只输出修订后的章节计划")
-    expect(prompt.length).toBeLessThan(320)
+    expect(prompt.length).toBeLessThan(520)
   })
   })
 
 
   it("runs plan revision and returns the revised plan", async () => {
   it("runs plan revision and returns the revised plan", async () => {

+ 16 - 16
src/lib/novel/chapter-plan-self-check.ts

@@ -57,24 +57,24 @@ export function buildChapterPlanSelfCheckPrompt(
     "你是小说章节计划自检助手。",
     "你是小说章节计划自检助手。",
     "请轻量检查这份章节计划是否足以指导后续正文生成。",
     "请轻量检查这份章节计划是否足以指导后续正文生成。",
     "",
     "",
-    "计划自检维度:",
-    "1. 七个维度是否完整:输入校验、章节定位、戏剧问题与信息流、场景序列编排、冲突与人物引擎、边界与禁忌、节奏字数与结尾钩子。",
-    "2. 场景序列能否连成起承转合/钩,是否单场景或缺转场。",
-    "3. 信息流是否写清揭示、隐藏、误导,是否提前泄露角色未知信息。",
-    "4. 伏笔动作是否清楚:埋设、推进、回收分别是什么。",
-    "5. 边界与禁忌能否约束大纲、时间线、角色认知和正史规则。",
-    "6. 结尾钩子是否具体,并自然导向下一章。",
-    "7. 爽点/期待点是否明确:满足什么期待、制造什么新期待。",
-    "8. 场景戏剧功能是否明确:制造/升级/反转/暂解/引出新问题。",
-    "9. 对话目标是否明确:想得到什么、不愿说什么、如何试探/隐瞒/压迫/诱导。",
-    "10. 是否有水文风险:只写气氛/解释/字数,不推动剧情/人物关系/信息差/伏笔/危机。",
-    "11. 开头和结尾是否成立:开头承接上一章并给当前问题;结尾完成阶段结果并留下一章问题。",
+    "计划自检清单:",
+    "1. 固定段落是否完整:本章目标、已知依据、执行边界、分场景执行计划、信息流与伏笔、验收标准、风险与兜底。",
+    "2. 本章目标是否写清剧情推进、人物状态变化、读者期待、章末状态和核心问题。",
+    "3. 已知依据是否区分已读取资料、缺失项和最小写作假设;不得把未读取资料写成事实。",
+    "4. 执行边界是否明确必须执行、禁止违背、可自由发挥三层约束。",
+    "5. 分场景执行计划是否包含 2-4 个 S1/S2/S3 场景,且每个场景都有目的、冲突、转折、输出结果、验收标准。",
+    "6. 信息流与伏笔是否写清揭示、隐藏、误导、埋设/推进/回收伏笔,以及不能提前给出的信息。",
+    "7. 验收标准是否可检查,能用正文里是否出现、是否完成、是否违背来判断。",
+    "8. 风险与兜底是否覆盖水文、解释腔、人物认知越界、提前泄密、场景断裂、钩子凭空出现。",
+    "9. 章末钩子是否来自本章推进结果,并自然导向下一章。",
+    "10. 对话目标和人物行动是否明确,并会带来关系、认知、处境或信息状态变化。",
     "",
     "",
     "输出要求:",
     "输出要求:",
     "1. 只输出一个 JSON 对象,不改计划、不写正文、不输出 markdown 代码块。",
     "1. 只输出一个 JSON 对象,不改计划、不写正文、不输出 markdown 代码块。",
     "2. 字段:status、summary、issues;status 只能是 pass、warning、error。",
     "2. 字段:status、summary、issues;status 只能是 pass、warning、error。",
     "3. summary 用一句中文概括;issues 最多 5 条,每条含 severity、problem、risk、suggestion。",
     "3. summary 用一句中文概括;issues 最多 5 条,每条含 severity、problem、risk、suggestion。",
-    "4. 可确认通过时 status 为 pass,issues 为空数组。",
+    "4. 缺失段落、缺失场景字段或不可验收的标准必须进入 issues,并给出可直接修订计划的 suggestion。",
+    "5. 可确认通过时 status 为 pass,issues 为空数组。",
     contextSection,
     contextSection,
     "",
     "",
     "待自检章节计划:",
     "待自检章节计划:",
@@ -182,9 +182,9 @@ export function buildChapterPlanRevisionPrompt(planContent: string, selfCheckRes
     "硬性要求:",
     "硬性要求:",
     "1. 只输出修订后的章节计划,不要输出解释、改动说明或正文。",
     "1. 只输出修订后的章节计划,不要输出解释、改动说明或正文。",
     "2. 保留原计划中合理的章节目标、场景序列、人物动机、伏笔动作和结尾钩子。",
     "2. 保留原计划中合理的章节目标、场景序列、人物动机、伏笔动作和结尾钩子。",
-    "3. 只修复自检指出的问题:缺维度、转场不清、信息流矛盾、伏笔不明、边界不足。",
-    "4. 必须补足爽点/期待点、场景戏剧功能、对话目标、开头结尾和水文风险处理。",
-    "5. 修订后的计划仍必须保持七个维度结构。",
+    "3. 只修复自检指出的问题:缺固定段落、缺场景字段、信息流矛盾、伏笔不明、执行边界不足、验收标准不可检查。",
+    "4. 必须补足对话目标、人物行动结果、信息状态变化、章末钩子来源和水文风险兜底。",
+    "5. 修订后的计划必须保持 7 段结构:本章目标、已知依据、执行边界、分场景执行计划、信息流与伏笔、验收标准、风险与兜底。",
     "",
     "",
     "原章节计划:",
     "原章节计划:",
     planContent.trim(),
     planContent.trim(),

+ 302 - 9
src/lib/novel/deep-chapter-generation.spec.ts

@@ -18,6 +18,7 @@ import {
   DEEP_CHAPTER_DRAFT_MAX_CHARS,
   DEEP_CHAPTER_DRAFT_MAX_CHARS,
   DEEP_CHAPTER_MIN_CHARS,
   DEEP_CHAPTER_MIN_CHARS,
 } from "./deep-chapter-prompts"
 } from "./deep-chapter-prompts"
+import { contractToTaskBriefText, createEmptyContract, type ChapterExecutionContract } from "./chapter-execution-contract"
 
 
 const llmConfig = {
 const llmConfig = {
   provider: "custom",
   provider: "custom",
@@ -52,6 +53,27 @@ const contextPack: ContextPack = {
   revisionDirectives: "",
   revisionDirectives: "",
 }
 }
 
 
+const executionContract: ChapterExecutionContract = {
+  objective: "推进旧屋线索",
+  knownContext: ["上一章门缝声未解"],
+  assumptions: [],
+  sceneSteps: [{
+    id: "S1",
+    title: "旧屋门口",
+    purpose: "承接悬念",
+    conflict: "主角要进屋,小晴阻止",
+    turn: "门内传来第二声响",
+    requiredOutcome: "主角进入旧屋",
+    acceptanceCriteria: ["出现门缝声承接", "主角进入旧屋"],
+  }],
+  mustDo: ["推进锈钥匙"],
+  mustAvoid: ["不得提前揭露旧屋主人"],
+  dialogueGoals: ["主角试探小晴"],
+  informationFlow: { reveal: [], hide: [], mislead: [], foreshadow: [] },
+  finalHook: "门外出现第二个人影",
+  freeSpace: ["环境细节可自由补足"],
+}
+
 function chapterText(prefix: string, count = 3000): string {
 function chapterText(prefix: string, count = 3000): string {
   const scenes = [
   const scenes = [
     "雨水沿着瓦檐落下,旧屋里的灯影忽明忽暗,主角先确认门缝后的动静。",
     "雨水沿着瓦檐落下,旧屋里的灯影忽明忽暗,主角先确认门缝后的动静。",
@@ -95,6 +117,15 @@ function createDeps(reviewResults: NovelReviewResult[] = []): DeepChapterGenerat
     buildContextPack: vi.fn(async () => contextPack),
     buildContextPack: vi.fn(async () => contextPack),
     contextPackToPrompt: vi.fn(() => "上下文包内容"),
     contextPackToPrompt: vi.fn(() => "上下文包内容"),
     reviewChapter: vi.fn(async () => reviewResults),
     reviewChapter: vi.fn(async () => reviewResults),
+    runChapterExecutionContractBuild: vi.fn(async () => executionContract),
+    runChapterExecutionReportCheck: vi.fn(async () => ({
+      status: "pass" as const,
+      sceneResults: [{ id: "S1", passed: true, missing: [], evidence: "已进入旧屋", repairInstruction: "" }],
+      mustDoResults: [],
+      mustAvoidResults: [],
+      finalHookPassed: true,
+      repairItems: [],
+    })),
     streamChat: vi.fn(async (_config: LlmConfig, messages: ChatMessage[], callbacks: StreamCallbacks) => {
     streamChat: vi.fn(async (_config: LlmConfig, messages: ChatMessage[], callbacks: StreamCallbacks) => {
       const prompt = messagesPromptText(messages)
       const prompt = messagesPromptText(messages)
       const content = prompt.includes("简单审查") || prompt.includes("去AI味")
       const content = prompt.includes("简单审查") || prompt.includes("去AI味")
@@ -110,6 +141,13 @@ function createDeps(reviewResults: NovelReviewResult[] = []): DeepChapterGenerat
   }
   }
 }
 }
 
 
+function createLegacyPlanComplianceDeps(reviewResults: NovelReviewResult[] = []): DeepChapterGenerationDeps {
+  return {
+    ...createDeps(reviewResults),
+    runChapterExecutionContractBuild: vi.fn(async () => createEmptyContract()),
+  }
+}
+
 describe("runDeepChapterGeneration", () => {
 describe("runDeepChapterGeneration", () => {
   it("keeps the word-count target in planning and draft prompts without forcing later review stages", () => {
   it("keeps the word-count target in planning and draft prompts without forcing later review stages", () => {
     const reviewResults: NovelReviewResult[] = [{
     const reviewResults: NovelReviewResult[] = [{
@@ -174,9 +212,262 @@ describe("runDeepChapterGeneration", () => {
     expect(promptWithoutPlan).not.toContain("用户已确认的章节计划")
     expect(promptWithoutPlan).not.toContain("用户已确认的章节计划")
   })
   })
 
 
-  it("runs a final plan compliance check with the compact execution summary when a confirmed plan is provided", async () => {
+  it("prefers the execution contract task brief over the old plan summary", () => {
+    const taskBrief = contractToTaskBriefText(executionContract)
+    const prompt = buildDeepChapterBriefPrompt(
+      "",
+      "上下文包内容",
+      "生成第3章",
+      3,
+      undefined,
+      undefined,
+      "旧计划摘要",
+      taskBrief,
+    )
+
+    expect(prompt).toContain("用户确认计划的执行清单")
+    expect(prompt).toContain("以下执行清单是本阶段写作任务书的权威依据")
+    expect(prompt).toContain("S1:旧屋门口")
+    expect(prompt).toContain("必须完成:出现门缝声承接;主角进入旧屋")
+    expect(prompt).toContain("不得重新设计剧情")
+    expect(prompt).not.toContain("用户已确认的章节计划执行摘要")
+  })
+
+  it("generates an execution contract from confirmed plan and uses it for task brief", async () => {
+    const deps = {
+      ...createDeps(),
+      runChapterExecutionContractBuild: vi.fn(async () => executionContract),
+      runChapterPlanComplianceCheck: vi.fn(async () => "履约度:基本符合"),
+    }
+
+    await runDeepChapterGeneration(
+      {
+        projectPath: "E:/Novel",
+        userRequest: "生成第3章",
+        chapterNumber: 3,
+        llmConfig,
+        planBlueprint: "## 本章策划案\nS1:旧屋门口\n- 输出结果:主角进入旧屋。",
+      },
+      {},
+      deps,
+    )
+
+    expect(deps.runChapterExecutionContractBuild).toHaveBeenCalledWith(
+      expect.any(Object),
+      expect.stringContaining("## 本章策划案"),
+      undefined,
+    )
+    const streamPrompts = vi.mocked(deps.streamChat).mock.calls.map((call) => messagesPromptText(call[1]))
+    expect(streamPrompts.join("\n")).toContain("用户确认计划的执行清单")
+    expect(streamPrompts.join("\n")).toContain("S1:旧屋门口")
+  })
+
+  it("falls back to local execution contract parsing when contract generation fails", async () => {
     const deps = {
     const deps = {
       ...createDeps(),
       ...createDeps(),
+      runChapterExecutionContractBuild: vi.fn(async () => {
+        throw new Error("contract failed")
+      }),
+      runChapterPlanComplianceCheck: vi.fn(async () => "履约度:基本符合"),
+    }
+
+    const result = await runDeepChapterGeneration(
+      {
+        projectPath: "E:/Novel",
+        userRequest: "生成第3章",
+        chapterNumber: 3,
+        llmConfig,
+        planBlueprint: "## 本章策划案\nS1:旧屋门口\n- 输出结果:主角进入旧屋。\n- 验收标准:主角进入旧屋。",
+      },
+      {},
+      deps,
+    )
+
+    expect(result.finalContent).toContain("最终去AI味正文")
+    const streamPrompts = vi.mocked(deps.streamChat).mock.calls.map((call) => messagesPromptText(call[1]))
+    expect(streamPrompts.join("\n")).toContain("用户确认计划的执行清单")
+    expect(streamPrompts.join("\n")).toContain("完成后状态:主角进入旧屋。")
+  })
+
+  it("uses execution report repair items for targeted plan repair", async () => {
+    const repairedContent = chapterText("执行报告返修后正文", 3000)
+    const deps = {
+      ...createDeps(),
+      runChapterExecutionContractBuild: vi.fn(async () => executionContract),
+      runChapterExecutionReportCheck: vi.fn(async () => ({
+        status: "fail" as const,
+        sceneResults: [{
+          id: "S1",
+          passed: false,
+          missing: ["主角进入旧屋"],
+          evidence: "正文停在门外。",
+          repairInstruction: "补写主角进入旧屋。",
+        }],
+        mustDoResults: [],
+        mustAvoidResults: [],
+        finalHookPassed: true,
+        repairItems: ["S1 缺少主角进入旧屋"],
+      })),
+      runChapterPlanComplianceCheck: vi.fn(async () => "不应调用旧履约检查"),
+      runChapterPlanDeviationRepair: vi.fn(async (_config, _plan, _content, compliance) => {
+        expect(String(compliance)).toContain("S1 缺少主角进入旧屋")
+        return repairedContent
+      }),
+    }
+
+    const result = await runDeepChapterGeneration(
+      {
+        projectPath: "E:/Novel",
+        userRequest: "生成第3章",
+        chapterNumber: 3,
+        llmConfig,
+        planBlueprint: "## 本章策划案\nS1:旧屋门口\n- 输出结果:主角进入旧屋。",
+      },
+      {},
+      deps,
+    )
+
+    expect(result.finalContent).toBe(repairedContent)
+    expect(result.executionReport).toContain("执行状态:已返修")
+    expect(result.planCompliance).toBe("")
+    expect(deps.runChapterPlanComplianceCheck).not.toHaveBeenCalled()
+  })
+
+  it("emits the execution report as a visible activity result", async () => {
+    const activityEvents: AgentActivityEvent[] = []
+    const deps = {
+      ...createDeps(),
+      runChapterExecutionContractBuild: vi.fn(async () => executionContract),
+      runChapterExecutionReportCheck: vi.fn(async () => ({
+        status: "warning" as const,
+        sceneResults: [
+          { id: "S1", passed: true, missing: [], evidence: "已进入旧屋", repairInstruction: "" },
+          { id: "S2", passed: false, missing: ["误导不足"], evidence: "", repairInstruction: "补误导" },
+        ],
+        mustDoResults: [],
+        mustAvoidResults: [],
+        finalHookPassed: true,
+        repairItems: [],
+      })),
+      runChapterPlanDeviationRepair: vi.fn(async () => chapterText("执行报告可见性返修后正文", 3000)),
+      runChapterPlanComplianceCheck: vi.fn(async () => "履约度:基本符合"),
+    }
+
+    const result = await runDeepChapterGeneration(
+      {
+        projectPath: "E:/Novel",
+        userRequest: "生成第3章",
+        chapterNumber: 3,
+        llmConfig,
+        planBlueprint: "## 本章策划案\nS1:旧屋门口\n- 输出结果:主角进入旧屋。",
+      },
+      { onActivityEvent: (event) => activityEvents.push(event) },
+      deps,
+    )
+
+    const reportEvent = activityEvents.find((event) => event.stageId === "execution_report")
+    expect(reportEvent?.title).toBe("执行报告")
+    expect(reportEvent?.content).toContain("执行状态:有警告")
+    expect(reportEvent?.content).toContain("完成场景:S1")
+    expect(reportEvent?.content).toContain("待处理偏离项:补误导")
+    expect(result.planCompliance).toBe("")
+    expect(deps.runChapterPlanComplianceCheck).not.toHaveBeenCalled()
+    expect(activityEvents.some((event) => event.stageId === "plan_compliance")).toBe(false)
+  })
+
+  it("rechecks the execution report after accepted targeted repair", async () => {
+    const repairedContent = chapterText("执行报告返修后正文", 3000)
+    const activityEvents: AgentActivityEvent[] = []
+    const deps = {
+      ...createDeps(),
+      runChapterExecutionContractBuild: vi.fn(async () => executionContract),
+      runChapterExecutionReportCheck: vi
+        .fn()
+        .mockResolvedValueOnce({
+          status: "fail" as const,
+          sceneResults: [{
+            id: "S1",
+            passed: false,
+            missing: ["主角进入旧屋"],
+            evidence: "正文停在门外。",
+            repairInstruction: "补写主角进入旧屋。",
+          }],
+          mustDoResults: [],
+          mustAvoidResults: [],
+          finalHookPassed: true,
+          repairItems: ["S1 缺少主角进入旧屋"],
+        })
+        .mockResolvedValueOnce({
+          status: "pass" as const,
+          sceneResults: [{ id: "S1", passed: true, missing: [], evidence: "返修后已进入旧屋。", repairInstruction: "" }],
+          mustDoResults: [],
+          mustAvoidResults: [],
+          finalHookPassed: true,
+          repairItems: [],
+        }),
+      runChapterPlanDeviationRepair: vi.fn(async () => repairedContent),
+      runChapterPlanComplianceCheck: vi.fn(async () => "履约度:基本符合"),
+    }
+
+    const result = await runDeepChapterGeneration(
+      {
+        projectPath: "E:/Novel",
+        userRequest: "生成第3章",
+        chapterNumber: 3,
+        llmConfig,
+        planBlueprint: "## 本章策划案\nS1:旧屋门口\n- 输出结果:主角进入旧屋。",
+      },
+      { onActivityEvent: (event) => activityEvents.push(event) },
+      deps,
+    )
+
+    expect(deps.runChapterExecutionReportCheck).toHaveBeenCalledTimes(2)
+    expect(deps.runChapterExecutionReportCheck).toHaveBeenLastCalledWith(
+      expect.any(Object),
+      executionContract,
+      repairedContent,
+      undefined,
+    )
+    expect(result.executionReport).toContain("执行状态:已返修")
+    expect(result.executionReport).toContain("完成场景:S1")
+    expect(result.executionReport).toContain("待处理偏离项:无")
+    expect(result.planCompliance).toBe("")
+    expect(deps.runChapterPlanComplianceCheck).not.toHaveBeenCalled()
+    expect(activityEvents.some((event) => event.stageId === "execution_recheck" && event.content.includes("执行状态:已返修"))).toBe(true)
+    expect(activityEvents.some((event) => event.stageId === "plan_compliance")).toBe(false)
+  })
+
+  it("keeps final content when execution report generation fails", async () => {
+    const deps = {
+      ...createDeps(),
+      runChapterExecutionContractBuild: vi.fn(async () => executionContract),
+      runChapterExecutionReportCheck: vi.fn(async () => {
+        throw new Error("report failed")
+      }),
+      runChapterPlanComplianceCheck: vi.fn(async () => "不应调用旧履约检查"),
+    }
+
+    const result = await runDeepChapterGeneration(
+      {
+        projectPath: "E:/Novel",
+        userRequest: "生成第3章",
+        chapterNumber: 3,
+        llmConfig,
+        planBlueprint: "## 本章策划案\nS1:旧屋门口\n- 输出结果:主角进入旧屋。",
+      },
+      {},
+      deps,
+    )
+
+    expect(result.finalContent).toContain("最终去AI味正文")
+    expect(result.executionReport).toContain("执行报告生成失败")
+    expect(result.planCompliance).toBe("")
+    expect(deps.runChapterPlanComplianceCheck).not.toHaveBeenCalled()
+  })
+
+  it("runs a final plan compliance check with the compact execution summary when a confirmed plan is provided", async () => {
+    const deps = {
+      ...createLegacyPlanComplianceDeps(),
       runChapterPlanComplianceCheck: vi.fn(async (
       runChapterPlanComplianceCheck: vi.fn(async (
         _config: LlmConfig,
         _config: LlmConfig,
         _plan: string,
         _plan: string,
@@ -221,7 +512,7 @@ describe("runDeepChapterGeneration", () => {
   it("publishes final content before waiting for blueprint compliance", async () => {
   it("publishes final content before waiting for blueprint compliance", async () => {
     const order: string[] = []
     const order: string[] = []
     const deps = {
     const deps = {
-      ...createDeps(),
+      ...createLegacyPlanComplianceDeps(),
       runChapterPlanComplianceCheck: vi.fn(async () => {
       runChapterPlanComplianceCheck: vi.fn(async () => {
         order.push("compliance-start")
         order.push("compliance-start")
         await Promise.resolve()
         await Promise.resolve()
@@ -247,7 +538,7 @@ describe("runDeepChapterGeneration", () => {
 
 
   it("forwards the stop signal into plan compliance", async () => {
   it("forwards the stop signal into plan compliance", async () => {
     const deps = {
     const deps = {
-      ...createDeps(),
+      ...createLegacyPlanComplianceDeps(),
       runChapterPlanComplianceCheck: vi.fn(async () => "履约度:基本符合"),
       runChapterPlanComplianceCheck: vi.fn(async () => "履约度:基本符合"),
     }
     }
     const controller = new AbortController()
     const controller = new AbortController()
@@ -278,7 +569,7 @@ describe("runDeepChapterGeneration", () => {
     const finalContents: string[] = []
     const finalContents: string[] = []
     const activityEvents: AgentActivityEvent[] = []
     const activityEvents: AgentActivityEvent[] = []
     const deps = {
     const deps = {
-      ...createDeps(),
+      ...createLegacyPlanComplianceDeps(),
       runChapterPlanComplianceCheck: vi.fn(async () => JSON.stringify({
       runChapterPlanComplianceCheck: vi.fn(async () => JSON.stringify({
         status: "partial_deviation",
         status: "partial_deviation",
         summary: "结尾钩子缺失。",
         summary: "结尾钩子缺失。",
@@ -320,6 +611,8 @@ describe("runDeepChapterGeneration", () => {
     expect(deps.runChapterPlanDeviationRepair).toHaveBeenCalledOnce()
     expect(deps.runChapterPlanDeviationRepair).toHaveBeenCalledOnce()
     expect(result.finalContent).toBe(repairedContent)
     expect(result.finalContent).toBe(repairedContent)
     expect(result.revised).toBe(true)
     expect(result.revised).toBe(true)
+    expect(result.planCompliance).toContain("计划偏离点返修:已完成")
+    expect(result.planCompliance).not.toContain("partial_deviation")
     expect(finalContents[0]).toContain("最终去AI味正文")
     expect(finalContents[0]).toContain("最终去AI味正文")
     expect(finalContents[finalContents.length - 1]).toBe(repairedContent)
     expect(finalContents[finalContents.length - 1]).toBe(repairedContent)
     const complianceEvent = activityEvents.find((event) => event.stageId === "plan_compliance")
     const complianceEvent = activityEvents.find((event) => event.stageId === "plan_compliance")
@@ -336,7 +629,7 @@ describe("runDeepChapterGeneration", () => {
   it("does not repair final content when plan compliance is mostly compliant", async () => {
   it("does not repair final content when plan compliance is mostly compliant", async () => {
     const activityEvents: AgentActivityEvent[] = []
     const activityEvents: AgentActivityEvent[] = []
     const deps = {
     const deps = {
-      ...createDeps(),
+      ...createLegacyPlanComplianceDeps(),
       runChapterPlanComplianceCheck: vi.fn(async () => "履约度:基本符合"),
       runChapterPlanComplianceCheck: vi.fn(async () => "履约度:基本符合"),
       runChapterPlanDeviationRepair: vi.fn(async () => chapterText("不应出现的返修正文", 3000)),
       runChapterPlanDeviationRepair: vi.fn(async () => chapterText("不应出现的返修正文", 3000)),
     }
     }
@@ -364,7 +657,7 @@ describe("runDeepChapterGeneration", () => {
     const finalContents: string[] = []
     const finalContents: string[] = []
     const activityEvents: AgentActivityEvent[] = []
     const activityEvents: AgentActivityEvent[] = []
     const deps = {
     const deps = {
-      ...createDeps(),
+      ...createLegacyPlanComplianceDeps(),
       runChapterPlanComplianceCheck: vi.fn(async () => JSON.stringify({
       runChapterPlanComplianceCheck: vi.fn(async () => JSON.stringify({
         status: "clear_deviation",
         status: "clear_deviation",
         summary: "章末钩子缺失。",
         summary: "章末钩子缺失。",
@@ -404,7 +697,7 @@ describe("runDeepChapterGeneration", () => {
   it("keeps the original final content when plan deviation repair becomes too long", async () => {
   it("keeps the original final content when plan deviation repair becomes too long", async () => {
     const activityEvents: AgentActivityEvent[] = []
     const activityEvents: AgentActivityEvent[] = []
     const deps = {
     const deps = {
-      ...createDeps(),
+      ...createLegacyPlanComplianceDeps(),
       runChapterPlanComplianceCheck: vi.fn(async () => JSON.stringify({
       runChapterPlanComplianceCheck: vi.fn(async () => JSON.stringify({
         status: "partial_deviation",
         status: "partial_deviation",
         summary: "缺少一个结尾动作。",
         summary: "缺少一个结尾动作。",
@@ -436,7 +729,7 @@ describe("runDeepChapterGeneration", () => {
       `全新段落${index}:这里改写成完全不同的事件、地点、人物和线索,绕开旧屋、钥匙、脚步声。`,
       `全新段落${index}:这里改写成完全不同的事件、地点、人物和线索,绕开旧屋、钥匙、脚步声。`,
     ).join("\n")
     ).join("\n")
     const deps = {
     const deps = {
-      ...createDeps(),
+      ...createLegacyPlanComplianceDeps(),
       runChapterPlanComplianceCheck: vi.fn(async () => JSON.stringify({
       runChapterPlanComplianceCheck: vi.fn(async () => JSON.stringify({
         status: "partial_deviation",
         status: "partial_deviation",
         summary: "缺少一个结尾动作。",
         summary: "缺少一个结尾动作。",
@@ -465,7 +758,7 @@ describe("runDeepChapterGeneration", () => {
   it("explains why unknown plan compliance results do not trigger repair", async () => {
   it("explains why unknown plan compliance results do not trigger repair", async () => {
     const activityEvents: AgentActivityEvent[] = []
     const activityEvents: AgentActivityEvent[] = []
     const deps = {
     const deps = {
-      ...createDeps(),
+      ...createLegacyPlanComplianceDeps(),
       runChapterPlanComplianceCheck: vi.fn(async () => "模型输出混乱,未给出履约度。"),
       runChapterPlanComplianceCheck: vi.fn(async () => "模型输出混乱,未给出履约度。"),
       runChapterPlanDeviationRepair: vi.fn(async () => chapterText("不应出现的返修正文", 3000)),
       runChapterPlanDeviationRepair: vi.fn(async () => chapterText("不应出现的返修正文", 3000)),
     }
     }

+ 182 - 1
src/lib/novel/deep-chapter-generation.ts

@@ -30,6 +30,17 @@ import {
   shouldRepairChapterPlanDeviation,
   shouldRepairChapterPlanDeviation,
 } from "./chapter-plan-compliance";
 } from "./chapter-plan-compliance";
 import { buildChapterPlanExecutionSummary } from "./chapter-plan-execution-summary";
 import { buildChapterPlanExecutionSummary } from "./chapter-plan-execution-summary";
+import {
+  contractToTaskBriefText,
+  fallbackParseChapterExecutionContract,
+  runChapterExecutionContractBuild,
+  type ChapterExecutionContract,
+} from "./chapter-execution-contract";
+import {
+  executionReportToToolSummary,
+  extractExecutionRepairItems,
+  runChapterExecutionReportCheck,
+} from "./chapter-execution-report";
 import {
 import {
   resolveChapterLengthSpec,
   resolveChapterLengthSpec,
   type ChapterLengthSpec,
   type ChapterLengthSpec,
@@ -69,6 +80,7 @@ export interface DeepChapterGenerationResult {
   reviewResults: NovelReviewResult[];
   reviewResults: NovelReviewResult[];
   revised: boolean;
   revised: boolean;
   planCompliance?: string;
   planCompliance?: string;
+  executionReport?: string;
 }
 }
 
 
 export type ChapterWorkflowEventType = "started" | "completed" | "error";
 export type ChapterWorkflowEventType = "started" | "completed" | "error";
@@ -108,6 +120,8 @@ export interface DeepChapterGenerationDeps {
   reviewChapter: typeof reviewChapter;
   reviewChapter: typeof reviewChapter;
   runChapterPlanComplianceCheck?: typeof runChapterPlanComplianceCheck;
   runChapterPlanComplianceCheck?: typeof runChapterPlanComplianceCheck;
   runChapterPlanDeviationRepair?: typeof runChapterPlanDeviationRepair;
   runChapterPlanDeviationRepair?: typeof runChapterPlanDeviationRepair;
+  runChapterExecutionContractBuild?: typeof runChapterExecutionContractBuild;
+  runChapterExecutionReportCheck?: typeof runChapterExecutionReportCheck;
   streamChat: (
   streamChat: (
     config: LlmConfig,
     config: LlmConfig,
     messages: ChatMessage[],
     messages: ChatMessage[],
@@ -134,6 +148,32 @@ const DEEP_CHAPTER_CONTEXT_TOKEN_BUDGET = 32000;
 /** chars/token approximation used to convert the token budget to characters
 /** chars/token approximation used to convert the token budget to characters
  *  for the outline cap (mirrors context-budget.ts / contextPackToPrompt). */
  *  for the outline cap (mirrors context-budget.ts / contextPackToPrompt). */
 const DEEP_CHAPTER_CHARS_PER_TOKEN = 4;
 const DEEP_CHAPTER_CHARS_PER_TOKEN = 4;
+
+function hasUsableChapterExecutionContract(contract: ChapterExecutionContract | null): contract is ChapterExecutionContract {
+  if (!contract) return false;
+  const hasSceneChecks = contract.sceneSteps.some((step) =>
+    step.title.trim() ||
+    step.purpose.trim() ||
+    step.conflict.trim() ||
+    step.turn.trim() ||
+    step.requiredOutcome.trim() ||
+    step.acceptanceCriteria.some((item) => item.trim()),
+  );
+  const hasInformationFlow =
+    contract.informationFlow.reveal.some((item) => item.trim()) ||
+    contract.informationFlow.hide.some((item) => item.trim()) ||
+    contract.informationFlow.mislead.some((item) => item.trim()) ||
+    contract.informationFlow.foreshadow.some((item) => item.trim());
+
+  return (
+    hasSceneChecks ||
+    contract.mustDo.some((item) => item.trim()) ||
+    contract.mustAvoid.some((item) => item.trim()) ||
+    contract.dialogueGoals.some((item) => item.trim()) ||
+    hasInformationFlow ||
+    Boolean(contract.finalHook.trim())
+  );
+}
 /** The mandatory outline may consume at most this share of the total context
 /** The mandatory outline may consume at most this share of the total context
  *  budget. The remainder is left for memory/settings/search context so the
  *  budget. The remainder is left for memory/settings/search context so the
  *  outline can never crowd the whole window out on its own. */
  *  outline can never crowd the whole window out on its own. */
@@ -429,6 +469,23 @@ export async function runDeepChapterGeneration(
     chapterNumber: input.chapterNumber ?? null,
     chapterNumber: input.chapterNumber ?? null,
   };
   };
   const planExecutionSummary = buildChapterPlanExecutionSummary(input.planBlueprint ?? "");
   const planExecutionSummary = buildChapterPlanExecutionSummary(input.planBlueprint ?? "");
+  let executionContract: ChapterExecutionContract | null = null;
+  let executionContractText = "";
+  if (input.planBlueprint?.trim()) {
+    try {
+      const buildContract = deps.runChapterExecutionContractBuild || runChapterExecutionContractBuild;
+      executionContract = await buildContract(writingConfig, input.planBlueprint, signal);
+    } catch (error) {
+      console.warn("[Deep Chapter] 执行清单生成失败,使用本地兜底解析:", error);
+      executionContract = fallbackParseChapterExecutionContract(input.planBlueprint);
+    }
+    if (hasUsableChapterExecutionContract(executionContract)) {
+      executionContractText = contractToTaskBriefText(executionContract);
+    } else {
+      executionContract = null;
+      executionContractText = "";
+    }
+  }
   const contextWorkflowStep: ChapterWorkflowStepSpec = {
   const contextWorkflowStep: ChapterWorkflowStepSpec = {
     name: "chapter_context",
     name: "chapter_context",
     title: "读取上下文",
     title: "读取上下文",
@@ -672,6 +729,7 @@ export async function runDeepChapterGeneration(
                 input.goldenThreeChapter,
                 input.goldenThreeChapter,
                 lengthSpec,
                 lengthSpec,
                 planExecutionSummary,
                 planExecutionSummary,
+                executionContractText,
               ),
               ),
             },
             },
           ],
           ],
@@ -1188,8 +1246,102 @@ export async function runDeepChapterGeneration(
     content: `最终正文已生成,约 ${countChapterChars(finalContent)} 字。`,
     content: `最终正文已生成,约 ${countChapterChars(finalContent)} 字。`,
   });
   });
   callbacks.onFinalContent?.(finalContent);
   callbacks.onFinalContent?.(finalContent);
+  let executionReportSummary = "";
+  if (executionContract) {
+    let repairedByExecutionReport = false;
+    try {
+      const runReport = deps.runChapterExecutionReportCheck || runChapterExecutionReportCheck;
+      const report = await runChapterWorkflowStep(
+        callbacks,
+        {
+          name: "chapter_execution_report",
+          title: "检查执行清单",
+          detail: "按场景验收标准逐项检查最终正文。",
+          params: workflowBaseParams,
+        },
+        () => runReport(writingConfig, executionContract!, finalContent, signal),
+        (value) => executionReportToToolSummary(value),
+        (value) => ({
+          status: value.status,
+          repairItemCount: extractExecutionRepairItems(value).length,
+        }),
+      );
+      const repairItems = extractExecutionRepairItems(report);
+      executionReportSummary = executionReportToToolSummary(report);
+      emitDeepChapterActivity(callbacks, {
+        id: `deep_chapter:execution_report:output:${Date.now()}`,
+        stageId: "execution_report",
+        kind: "stage_output",
+        title: "执行报告",
+        content: executionReportSummary,
+      });
+      if (repairItems.length > 0) {
+        const runRepair = deps.runChapterPlanDeviationRepair || runChapterPlanDeviationRepair;
+        const repairedContent = await runChapterWorkflowStep(
+          callbacks,
+          {
+            name: "chapter_execution_repair",
+            title: "返修执行失败项",
+            detail: "只修复执行报告中的失败项,不重写全章。",
+            params: workflowBaseParams,
+          },
+          () => runRepair(
+            writingConfig,
+            contractToTaskBriefText(executionContract!),
+            finalContent,
+            repairItems.join("\n"),
+            signal,
+          ),
+          (value) => `执行失败项返修完成,正文约 ${countChapterChars(value)} 字。`,
+          (value) => ({ chars: countChapterChars(value) }),
+        );
+        const validation = validateChapterPlanRepairResult(finalContent, repairedContent);
+        if (validation.accepted) {
+          finalContent = repairedContent.trim();
+          revised = true;
+          repairedByExecutionReport = true;
+          callbacks.onFinalContent?.(finalContent);
+          const recheckReport = await runChapterWorkflowStep(
+            callbacks,
+            {
+              name: "chapter_execution_recheck",
+              title: "复检执行清单",
+              detail: "返修后再次检查执行清单失败项是否消除。",
+              params: workflowBaseParams,
+            },
+            () => runReport(writingConfig, executionContract!, finalContent, signal),
+            (value) => executionReportToToolSummary(value, true),
+            (value) => ({
+              status: value.status,
+              repairItemCount: extractExecutionRepairItems(value).length,
+            }),
+          );
+          executionReportSummary = executionReportToToolSummary(recheckReport, true);
+          emitDeepChapterActivity(callbacks, {
+            id: `deep_chapter:execution_recheck:output:${Date.now()}`,
+            stageId: "execution_recheck",
+            kind: "stage_output",
+            title: "执行复检",
+            content: executionReportSummary,
+          });
+        }
+      }
+      if (!repairedByExecutionReport) {
+        executionReportSummary = executionReportToToolSummary(report);
+      }
+    } catch (error) {
+      executionReportSummary = `执行报告生成失败,已保留正文:${error instanceof Error ? error.message : String(error)}`;
+      emitDeepChapterActivity(callbacks, {
+        id: `deep_chapter:execution_report:error:${Date.now()}`,
+        stageId: "execution_report",
+        kind: "stage_output",
+        title: "执行报告",
+        content: executionReportSummary,
+      });
+    }
+  }
   let planCompliance = "";
   let planCompliance = "";
-  if (planExecutionSummary.trim()) {
+  if (planExecutionSummary.trim() && !executionContract) {
     let complianceCheckFailed = false;
     let complianceCheckFailed = false;
     const complianceStep = {
     const complianceStep = {
       name: "chapter_plan_compliance",
       name: "chapter_plan_compliance",
@@ -1256,6 +1408,7 @@ export async function runDeepChapterGeneration(
             finalContent = repairedContent.trim();
             finalContent = repairedContent.trim();
             revised = true;
             revised = true;
             const afterChars = countChapterChars(finalContent);
             const afterChars = countChapterChars(finalContent);
+            planCompliance = buildPlanDeviationRepairReturnContent(parsedCompliance, beforeChars, afterChars);
             emitDeepChapterActivity(callbacks, {
             emitDeepChapterActivity(callbacks, {
               id: `deep_chapter:plan_deviation_repair:output:${Date.now()}`,
               id: `deep_chapter:plan_deviation_repair:output:${Date.now()}`,
               stageId: "plan_deviation_repair",
               stageId: "plan_deviation_repair",
@@ -1310,6 +1463,7 @@ export async function runDeepChapterGeneration(
     reviewResults,
     reviewResults,
     revised,
     revised,
     planCompliance,
     planCompliance,
+    executionReport: executionReportSummary,
   };
   };
 }
 }
 
 
@@ -1544,6 +1698,33 @@ function chapterPlanComplianceStatusLabel(
   return "未知";
   return "未知";
 }
 }
 
 
+function buildPlanDeviationRepairReturnContent(
+  result: ParsedChapterPlanComplianceResult,
+  beforeChars: number,
+  afterChars: number,
+): string {
+  const lines = [
+    "计划偏离点返修:已完成",
+    `返修前履约状态:${chapterPlanComplianceStatusLabel(result.status)}`,
+    `已处理偏离点数量:${result.deviations.length}`,
+    `返修前:约 ${beforeChars} 字。`,
+    `返修后:约 ${afterChars} 字。`,
+    "最终正文已按计划偏离点轻量返修结果更新。",
+  ];
+  const handledPoints = result.deviations
+    .map((item) => item.point.trim())
+    .filter(Boolean)
+    .slice(0, 5);
+  if (handledPoints.length > 0) {
+    lines.push("");
+    lines.push("已处理偏离点:");
+    handledPoints.forEach((point, index) => {
+      lines.push(`${index + 1}. ${point}`);
+    });
+  }
+  return lines.join("\n");
+}
+
 function validateChapterPlanRepairResult(
 function validateChapterPlanRepairResult(
   originalContent: string,
   originalContent: string,
   repairedContent: string,
   repairedContent: string,

+ 45 - 18
src/lib/novel/deep-chapter-prompts.ts

@@ -68,8 +68,19 @@ export function buildDeepChapterBriefPrompt(
   goldenThreeChapter?: GoldenThreeChapterRequest,
   goldenThreeChapter?: GoldenThreeChapterRequest,
   lengthSpec: ChapterLengthSpec = DEFAULT_CHAPTER_LENGTH_SPEC,
   lengthSpec: ChapterLengthSpec = DEFAULT_CHAPTER_LENGTH_SPEC,
   planBlueprint?: string,
   planBlueprint?: string,
+  executionContractText?: string,
 ): string {
 ): string {
-  const blueprintSection = planBlueprint && planBlueprint.trim()
+  const contractSection = executionContractText && executionContractText.trim()
+    ? [
+        "",
+        "## 用户确认计划的执行清单",
+        "以下执行清单是本阶段写作任务书的权威依据。不得重新设计剧情,只能把清单落实到正文。",
+        "",
+        executionContractText.trim(),
+      ].join("\n")
+    : ""
+
+  const blueprintSection = !contractSection && planBlueprint && planBlueprint.trim()
     ? [
     ? [
         "",
         "",
         "## 用户已确认的章节计划执行摘要",
         "## 用户已确认的章节计划执行摘要",
@@ -80,24 +91,40 @@ export function buildDeepChapterBriefPrompt(
       ].join("\n")
       ].join("\n")
     : ""
     : ""
 
 
-  const planningDirectives = planBlueprint && planBlueprint.trim()
+  const contractPlanningDirectives = [
+    "硬性要求:",
+    "1. 只输出任务书,不要写故事片段。",
+    "2. 任务书必须逐项承接执行清单中的 S 场景、必须执行、禁止违背和章末钩子。",
+    "3. 不得重新设计剧情,不得合并、跳过或调换 S 场景顺序。",
+    `4. 后续正文必须按完整章节规划,${chapterLengthBoundary(lengthSpec)}`,
+  ].join("\n")
+
+  const planBlueprintDirectives = [
+    "硬性要求:",
+    "1. 只输出任务书,不要写故事片段。",
+    "2. 以用户确认的计划为骨架逐场景落地:必须完成、禁止违背、角色状态、伏笔推进、结尾钩子都与计划一致。",
+    "3. 若计划摘要含 S1/S2/S3,任务书必须逐条展开 S1/S2/S3,不得合并、跳过或调换顺序。",
+    "4. 不新增计划未涵盖的主线推进、伏笔动作或人物变化;计划有缺失时只给最小补全方向。",
+    `5. 后续正文必须按完整章节规划,${chapterLengthBoundary(lengthSpec)}`,
+    "6. 任务书必须覆盖场景推进、冲突升级、人物互动、细节描写、章节节奏曲线、爽点/期待点、对话目标和开头/结尾执行要求。",
+  ].join("\n")
+
+  const defaultPlanningDirectives = [
+    "硬性要求:",
+    "1. 只输出任务书,不要写故事片段。",
+    "2. 必须列出本章必须完成、禁止违背、角色状态、伏笔推进、结尾钩子。",
+    "3. 如果上下文不足,写明缺失项,并给出最小补全方向。",
+    `4. 后续正文必须按完整章节规划,${chapterLengthBoundary(lengthSpec)}`,
+    "5. 任务书必须覆盖场景推进、冲突升级、人物互动、细节描写、章节节奏曲线、爽点/期待点、主要对话目标、开头承接方式和结尾钩子执行方式。",
+  ].join("\n")
+
+  const planningDirectives = contractSection
+    ? contractPlanningDirectives
+    : planBlueprint && planBlueprint.trim()
     ? [
     ? [
-        "硬性要求:",
-        "1. 只输出任务书,不要写故事片段。",
-        "2. 以用户确认的计划为骨架逐场景落地:必须完成、禁止违背、角色状态、伏笔推进、结尾钩子都与计划一致。",
-        "3. 若计划摘要含 S1/S2/S3,任务书必须逐条展开 S1/S2/S3,不得合并、跳过或调换顺序。",
-        "4. 不新增计划未涵盖的主线推进、伏笔动作或人物变化;计划有缺失时只给最小补全方向。",
-        `5. 后续正文必须按完整章节规划,${chapterLengthBoundary(lengthSpec)}`,
-        "6. 任务书必须覆盖场景推进、冲突升级、人物互动、细节描写、章节节奏曲线、爽点/期待点、对话目标和开头/结尾执行要求。",
-      ].join("\n")
-    : [
-        "硬性要求:",
-        "1. 只输出任务书,不要写故事片段。",
-        "2. 必须列出本章必须完成、禁止违背、角色状态、伏笔推进、结尾钩子。",
-        "3. 如果上下文不足,写明缺失项,并给出最小补全方向。",
-        `4. 后续正文必须按完整章节规划,${chapterLengthBoundary(lengthSpec)}`,
-        "5. 任务书必须覆盖场景推进、冲突升级、人物互动、细节描写、章节节奏曲线、爽点/期待点、主要对话目标、开头承接方式和结尾钩子执行方式。",
+        planBlueprintDirectives,
       ].join("\n")
       ].join("\n")
+    : defaultPlanningDirectives
 
 
   return [
   return [
     buildStableContextPrefix(outline, contextPrompt),
     buildStableContextPrefix(outline, contextPrompt),
@@ -110,7 +137,7 @@ export function buildDeepChapterBriefPrompt(
     chapterNumber ? `目标章节:第${chapterNumber}章` : "目标章节:用户请求中的章节",
     chapterNumber ? `目标章节:第${chapterNumber}章` : "目标章节:用户请求中的章节",
     `用户请求:${userRequest}`,
     `用户请求:${userRequest}`,
     goldenThreeChapterSection(goldenThreeChapter),
     goldenThreeChapterSection(goldenThreeChapter),
-    blueprintSection,
+    contractSection || blueprintSection,
   ].filter(Boolean).join("\n")
   ].filter(Boolean).join("\n")
 }
 }