浏览代码

merge branch 'zhangjielandu'

Mochocyang 2 月之前
父节点
当前提交
c956fd0429

+ 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).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("只输出一个 JSON 对象")
     })
@@ -247,6 +260,36 @@ describe("ChapterPlanConfirmDialog 组件", () => {
     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 () => {
     let resolveSelfCheck!: (value: string) => void
     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)
   }, [editedContent, onModify])
 
+  const handleConfirm = useCallback(() => {
+    if (editing && onModify) {
+      onModify(editedContent)
+      setEditing(false)
+      return
+    }
+    onConfirm()
+  }, [editedContent, editing, onConfirm, onModify])
+
   const handleSelfCheck = useCallback(async () => {
     if (!onSelfCheck) return
     const requestId = selfCheckRequestRef.current + 1
@@ -230,7 +239,7 @@ export function ChapterPlanConfirmDialog({
               跳过计划,直接写
             </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"
             >
               <Check className="h-4 w-4" />

+ 1 - 1
src/components/chat/chat-message.tsx

@@ -703,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">
         <Bot className="h-4 w-4" />
       </div>
-      <div className="flex-1 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 ? (
           <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("ProcessDebugPanel")
+    expect(source).toContain("概览")
+    expect(source).toContain("ProcessOverviewPanel")
     expect(source).toContain("Blackboard")
     expect(source).toContain("可见事件")
   })

+ 158 - 75
src/components/skill-library/unified-skill-library-view.tsx

@@ -1,8 +1,12 @@
 import { useEffect, useMemo, useState } from "react"
 import { useWikiStore } from "@/stores/wiki-store"
-import { getAllDeAiSkills, loadDeAiSkillConfig, type DeAiSkill } from "@/lib/novel/de-ai-skill-library"
+import {
+  getAllDeAiSkills,
+  loadDeAiSkillConfig,
+  type DeAiSkill,
+} from "@/lib/novel/de-ai-skill-library"
 import { loadUserSkillConfig } from "@/lib/novel/user-skill-store"
-import type { UserSkill } from "@/lib/novel/skill-library"
+import type { SkillKind, UserSkill } from "@/lib/novel/skill-library"
 import { SkillLibraryView } from "./skill-library-view"
 import { WritingSkillLibraryView } from "./writing-skill-library-view"
 
@@ -11,6 +15,51 @@ const skillLibraryTabs = [
   { 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 }) {
   const activeView = useWikiStore((s) => s.activeView)
   const setActiveView = useWikiStore((s) => s.setActiveView)
@@ -55,112 +104,146 @@ export function UnifiedSkillLibrarySidebarPanel() {
   const project = useWikiStore((s) => s.project)
   const dataVersion = useWikiStore((s) => s.dataVersion)
   const setActiveView = useWikiStore((s) => s.setActiveView)
-  const setSelectedSkillLibrarySkillId = useWikiStore((s) => s.setSelectedSkillLibrarySkillId)
-  const setSelectedWritingSkillLibrarySkillId = useWikiStore((s) => s.setSelectedWritingSkillLibrarySkillId)
-  const [deAiSkills, setDeAiSkills] = useState<DeAiSkill[]>([])
-  const [writingSkills, setWritingSkills] = useState<UserSkill[]>([])
+  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("全部")
+  const [category, setCategory] = useState<UnifiedSkillCategory>("all")
+  const [loadError, setLoadError] = useState("")
 
   useEffect(() => {
     let cancelled = false
+    setLoadError("")
     Promise.all([
-      loadDeAiSkillConfig(project?.path).then((config) => getAllDeAiSkills(config)).catch(() => []),
-      loadUserSkillConfig(project?.path).then((config) => config.skills).catch(() => []),
-    ]).then(([nextDeAiSkills, nextWritingSkills]) => {
-      if (cancelled) return
-      setDeAiSkills(nextDeAiSkills)
-      setWritingSkills(nextWritingSkills)
-    })
+      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
     }
-  }, [project?.path, dataVersion])
-
-  const entries = useMemo(() => {
-    const writingEntries = writingSkills.map((skill) => ({
-      id: `writing:${skill.id}`,
-      source: "writing" as const,
-      skill,
-      name: skill.name,
-      description: skill.description,
-      categories: ["写作", ...skill.kind.map((kind) => kind === "review" ? "审稿" : kind === "output" ? "输出" : "知识")],
-    }))
-    const deAiEntries = deAiSkills.map((skill) => ({
-      id: `de-ai:${skill.id}`,
-      source: "de-ai" as const,
-      skill,
-      name: skill.name,
-      description: skill.description,
-      categories: ["去AI味"],
-    }))
-    return [...writingEntries, ...deAiEntries]
-  }, [deAiSkills, writingSkills])
-
-  const filteredEntries = entries.filter((entry) => {
-    const matchesCategory = category === "全部" || entry.categories.includes(category)
-    const text = `${entry.name} ${entry.description}`.toLowerCase()
-    const matchesQuery = !query.trim() || text.includes(query.trim().toLowerCase())
-    return matchesCategory && matchesQuery
-  })
-
-  function handleSelect(entry: (typeof entries)[number]) {
-    if (entry.source === "writing") {
+  }, [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")
-      setSelectedWritingSkillLibrarySkillId(entry.skill.id)
-    } else {
-      setActiveView("skillLibrary")
-      setSelectedSkillLibrarySkillId(entry.skill.id)
+      return
     }
+    setSelectedSkillId(entry.sourceId)
+    setActiveView("skillLibrary")
   }
 
   return (
     <div data-testid="unified-skill-library-sidebar" className="flex h-full flex-col overflow-hidden">
       <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>
+        <p className="mt-0.5 text-xs text-muted-foreground">统一检索写作 Skill 和去AI味技能。</p>
       </div>
-      <div className="shrink-0 space-y-2 border-b px-3 py-2">
+      <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="搜索技能"
+          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="flex flex-wrap gap-1">
-          {["全部", "写作", "去AI味", "审稿", "输出", "知识"].map((item) => (
+        <div className="mt-2 flex flex-wrap gap-1.5">
+          {unifiedSkillCategories.map((item) => (
             <button
-              key={item}
+              key={item.id}
               type="button"
-              onClick={() => setCategory(item)}
-              className={`rounded-full border px-2 py-0.5 text-xs ${
-                category === item ? "border-primary bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-accent"
+              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}
+              {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.map((entry) => (
-          <button
-            key={entry.id}
-            type="button"
-            data-testid={`unified-skill-entry-${entry.id}`}
-            onClick={() => handleSelect(entry)}
-            className="mb-2 w-full rounded-md border px-3 py-2 text-left hover:bg-accent"
-          >
-            <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.source === "writing" ? "写作" : "去AI味"}
-              </span>
+        {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 className="mt-1 truncate text-xs text-muted-foreground">{entry.description}</div>
-          </button>
-        ))}
+          )
+        })}
       </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", () => {
     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", () => {

+ 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: {} },
     })
 
-    expect(result.finalSystemPrompt).toContain("章节创作计划协议")
+    expect(result.finalSystemPrompt).toContain("章节主编策划协议")
     expect(result.finalSystemPrompt).toContain("chapter_plan")
-    expect(result.finalSystemPrompt).toContain("章节计划")
+    expect(result.finalSystemPrompt).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("目的、冲突、转折、输出结果、验收标准")
+    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 {
-  // 章节计划统一使用完整七维度分析,不再按 fast/standard/strict 裁剪维度。
   // mode 仅用于在协议头标注当前工作流强度,不改变计划结构。
   const modeLabel =
     mode === "fast" ? "快速" : mode === "strict" ? "严格" : "标准"
   return [
-    "## 章节创作计划协议(章节计划)",
+    "## 章节主编策划协议(本章策划案)",
     "",
-    `当前工作流强度:${modeLabel}模式,已开启 Plan Execute。写正文前先输出章节计划供用户确认。`,
-    "计划是可追溯、可执行的创作决策,不写正文片段或工具流程。",
+    `当前工作流强度:${modeLabel}模式,已开启 Plan Execute。写正文前先输出本章策划案供用户确认。`,
+    "策划案面向用户阅读,要像主编给作者的执行计划:清楚、有取舍、能落地,不写工具流程,不写正文片段。",
     "",
     "输出规范:",
     "1. 计划必须整体包裹在 `<!-- chapter_plan -->` 和 `<!-- /chapter_plan -->` 标记中。",
-    "2. 计划只供用户确认,不写正文、工具日志或旧工作流说明。",
+    "2. 计划只供用户确认,正文生成必须等用户确认后再开始。",
     "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 参数传入,再进入正文生成,不再重复输出计划。",
     "输出计划后暂停,等用户确认后再进入正文。",

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

@@ -318,4 +318,30 @@ describe("createRunChapterWorkflowTool", () => {
     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.taskBrief}`,
+        result.executionReport ? `执行报告:\n${result.executionReport}` : "",
         result.planCompliance ? `计划履约度:\n${result.planCompliance}` : "",
         "",
         "最终正文:",

+ 16 - 8
src/lib/changelog.ts

@@ -80,27 +80,34 @@ const TWO_POINT_TWO_ELEVEN_CHANGELOG: ChangelogEntry = {
 
 const TWO_POINT_TWO_THIRTY_THREE_CHANGELOG: ChangelogEntry = {
   version: "2.2.33",
-  date: "2026-07-05",
+  date: "2026-07-06",
   highlights: {
     en: [
-        "Added plot framework extraction for the dismantling library, centered on hook, buildup, payoff, and ending hook.",
-        "The visible Book Dismantling Library now has a Story Framework Extraction entry that lets selected chapters generate reusable plot frameworks.",
-        "Chapter outline generation can now receive the selected plot framework as a hard constraint instead of only writing a framework_id.",
+      "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.",
+      "Added plot framework extraction for the dismantling library, centered on hook, buildup, payoff, and ending hook.",
+      "The visible Book Dismantling Library now has a Story Framework Extraction entry that lets selected chapters generate reusable plot frameworks.",
+      "Chapter outline generation can now receive the selected plot framework as a hard constraint instead of only writing a framework_id.",
       "Dismantling runs automatically create a basic reusable plot framework when the four required beats are complete.",
       "Outline previews now highlight the author handcraft zone even when the heading includes explanatory parentheses.",
       "Cleaned build blockers caused by stale unused imports and variables.",
     ],
     zh: [
-        "拆书库新增剧情框架提取能力,围绕「钩子、铺垫、爽点、结尾钩子」形成可复用模板。",
-        "当前可见的拆书库顶部新增「故事框架提取」入口,可选择章节生成可复用剧情框架。",
-        "基于剧情框架创建章纲时,会把完整框架内容作为硬约束交给 AI,不再只是写入 framework_id。",
+      "优化 AI 会话章节计划执行:新增面向用户的本章策划案、隐藏执行契约和可查看的执行报告。",
+      "新增执行复核与定向修复,生成章节会按计划契约检查后再返回结果。",
+      "优化章节计划确认弹窗和执行动态展示,让计划、执行、复核过程更容易追踪。",
+      "修复计划执行优化后的 AI 会话相关 mock 测试回归,提升当前版本稳定性。",
+      "拆书库新增剧情框架提取能力,围绕「钩子、铺垫、爽点、结尾钩子」形成可复用模板。",
+      "当前可见的拆书库顶部新增「故事框架提取」入口,可选择章节生成可复用剧情框架。",
+      "基于剧情框架创建章纲时,会把完整框架内容作为硬约束交给 AI,不再只是写入 framework_id。",
       "拆文完成后,如果四段框架完整,会自动生成基础剧情框架并写入框架库。",
       "章纲预览支持高亮「作者手搓留白」区域,标题带括号说明时也能识别。",
       "清理阻塞构建的过期未使用导入和变量,恢复 typecheck 验证。",
     ],
   },
 };
-
 const TWO_POINT_TWO_THIRTY_TWO_CHANGELOG: ChangelogEntry = {
   version: "2.2.32",
   date: "2026-07-04",
@@ -885,3 +892,4 @@ export function allChangelog(): ChangelogEntry[] {
     ...CHANGELOG.filter((entry) => !isMergedOnePointRelease(entry.version)),
   ];
 }
+

+ 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("结尾钩子:")
   })
 
+  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", () => {
     const plan = [
       "维度一·输入校验:本章写第3章。",

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

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

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

@@ -24,20 +24,30 @@ const llmConfig: LlmConfig = {
 }
 
 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("维度四·场景序列编排:旧屋揭示")
 
     expect(prompt).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("缺失段落、缺失场景字段或不可验收的标准必须进入 issues")
     expect(prompt).toContain("对话目标")
     expect(prompt).toContain("水文")
-    expect(prompt).toContain("开头和结尾")
     expect(prompt).toContain("只输出一个 JSON 对象")
-    expect(prompt.length).toBeLessThan(760)
+    expect(prompt.length).toBeLessThan(1100)
   })
 
   it("includes compressed project context when provided", () => {
@@ -100,10 +110,12 @@ describe("chapter-plan-self-check", () => {
     expect(prompt).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.length).toBeLessThan(320)
+    expect(prompt.length).toBeLessThan(520)
   })
 
   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 代码块。",
     "2. 字段:status、summary、issues;status 只能是 pass、warning、error。",
     "3. summary 用一句中文概括;issues 最多 5 条,每条含 severity、problem、risk、suggestion。",
-    "4. 可确认通过时 status 为 pass,issues 为空数组。",
+    "4. 缺失段落、缺失场景字段或不可验收的标准必须进入 issues,并给出可直接修订计划的 suggestion。",
+    "5. 可确认通过时 status 为 pass,issues 为空数组。",
     contextSection,
     "",
     "待自检章节计划:",
@@ -182,9 +182,9 @@ export function buildChapterPlanRevisionPrompt(planContent: string, selfCheckRes
     "硬性要求:",
     "1. 只输出修订后的章节计划,不要输出解释、改动说明或正文。",
     "2. 保留原计划中合理的章节目标、场景序列、人物动机、伏笔动作和结尾钩子。",
-    "3. 只修复自检指出的问题:缺维度、转场不清、信息流矛盾、伏笔不明、边界不足。",
-    "4. 必须补足爽点/期待点、场景戏剧功能、对话目标、开头结尾和水文风险处理。",
-    "5. 修订后的计划仍必须保持七个维度结构。",
+    "3. 只修复自检指出的问题:缺固定段落、缺场景字段、信息流矛盾、伏笔不明、执行边界不足、验收标准不可检查。",
+    "4. 必须补足对话目标、人物行动结果、信息状态变化、章末钩子来源和水文风险兜底。",
+    "5. 修订后的计划必须保持 7 段结构:本章目标、已知依据、执行边界、分场景执行计划、信息流与伏笔、验收标准、风险与兜底。",
     "",
     "原章节计划:",
     planContent.trim(),

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

@@ -18,6 +18,7 @@ import {
   DEEP_CHAPTER_DRAFT_MAX_CHARS,
   DEEP_CHAPTER_MIN_CHARS,
 } from "./deep-chapter-prompts"
+import { contractToTaskBriefText, createEmptyContract, type ChapterExecutionContract } from "./chapter-execution-contract"
 
 const llmConfig = {
   provider: "custom",
@@ -52,6 +53,27 @@ const contextPack: ContextPack = {
   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 {
   const scenes = [
     "雨水沿着瓦檐落下,旧屋里的灯影忽明忽暗,主角先确认门缝后的动静。",
@@ -95,6 +117,15 @@ function createDeps(reviewResults: NovelReviewResult[] = []): DeepChapterGenerat
     buildContextPack: vi.fn(async () => contextPack),
     contextPackToPrompt: vi.fn(() => "上下文包内容"),
     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) => {
       const prompt = messagesPromptText(messages)
       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", () => {
   it("keeps the word-count target in planning and draft prompts without forcing later review stages", () => {
     const reviewResults: NovelReviewResult[] = [{
@@ -174,9 +212,262 @@ describe("runDeepChapterGeneration", () => {
     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 = {
       ...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 (
         _config: LlmConfig,
         _plan: string,
@@ -221,7 +512,7 @@ describe("runDeepChapterGeneration", () => {
   it("publishes final content before waiting for blueprint compliance", async () => {
     const order: string[] = []
     const deps = {
-      ...createDeps(),
+      ...createLegacyPlanComplianceDeps(),
       runChapterPlanComplianceCheck: vi.fn(async () => {
         order.push("compliance-start")
         await Promise.resolve()
@@ -247,7 +538,7 @@ describe("runDeepChapterGeneration", () => {
 
   it("forwards the stop signal into plan compliance", async () => {
     const deps = {
-      ...createDeps(),
+      ...createLegacyPlanComplianceDeps(),
       runChapterPlanComplianceCheck: vi.fn(async () => "履约度:基本符合"),
     }
     const controller = new AbortController()
@@ -278,7 +569,7 @@ describe("runDeepChapterGeneration", () => {
     const finalContents: string[] = []
     const activityEvents: AgentActivityEvent[] = []
     const deps = {
-      ...createDeps(),
+      ...createLegacyPlanComplianceDeps(),
       runChapterPlanComplianceCheck: vi.fn(async () => JSON.stringify({
         status: "partial_deviation",
         summary: "结尾钩子缺失。",
@@ -320,6 +611,8 @@ describe("runDeepChapterGeneration", () => {
     expect(deps.runChapterPlanDeviationRepair).toHaveBeenCalledOnce()
     expect(result.finalContent).toBe(repairedContent)
     expect(result.revised).toBe(true)
+    expect(result.planCompliance).toContain("计划偏离点返修:已完成")
+    expect(result.planCompliance).not.toContain("partial_deviation")
     expect(finalContents[0]).toContain("最终去AI味正文")
     expect(finalContents[finalContents.length - 1]).toBe(repairedContent)
     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 () => {
     const activityEvents: AgentActivityEvent[] = []
     const deps = {
-      ...createDeps(),
+      ...createLegacyPlanComplianceDeps(),
       runChapterPlanComplianceCheck: vi.fn(async () => "履约度:基本符合"),
       runChapterPlanDeviationRepair: vi.fn(async () => chapterText("不应出现的返修正文", 3000)),
     }
@@ -364,7 +657,7 @@ describe("runDeepChapterGeneration", () => {
     const finalContents: string[] = []
     const activityEvents: AgentActivityEvent[] = []
     const deps = {
-      ...createDeps(),
+      ...createLegacyPlanComplianceDeps(),
       runChapterPlanComplianceCheck: vi.fn(async () => JSON.stringify({
         status: "clear_deviation",
         summary: "章末钩子缺失。",
@@ -404,7 +697,7 @@ describe("runDeepChapterGeneration", () => {
   it("keeps the original final content when plan deviation repair becomes too long", async () => {
     const activityEvents: AgentActivityEvent[] = []
     const deps = {
-      ...createDeps(),
+      ...createLegacyPlanComplianceDeps(),
       runChapterPlanComplianceCheck: vi.fn(async () => JSON.stringify({
         status: "partial_deviation",
         summary: "缺少一个结尾动作。",
@@ -436,7 +729,7 @@ describe("runDeepChapterGeneration", () => {
       `全新段落${index}:这里改写成完全不同的事件、地点、人物和线索,绕开旧屋、钥匙、脚步声。`,
     ).join("\n")
     const deps = {
-      ...createDeps(),
+      ...createLegacyPlanComplianceDeps(),
       runChapterPlanComplianceCheck: vi.fn(async () => JSON.stringify({
         status: "partial_deviation",
         summary: "缺少一个结尾动作。",
@@ -465,7 +758,7 @@ describe("runDeepChapterGeneration", () => {
   it("explains why unknown plan compliance results do not trigger repair", async () => {
     const activityEvents: AgentActivityEvent[] = []
     const deps = {
-      ...createDeps(),
+      ...createLegacyPlanComplianceDeps(),
       runChapterPlanComplianceCheck: vi.fn(async () => "模型输出混乱,未给出履约度。"),
       runChapterPlanDeviationRepair: vi.fn(async () => chapterText("不应出现的返修正文", 3000)),
     }

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

@@ -30,6 +30,17 @@ import {
   shouldRepairChapterPlanDeviation,
 } from "./chapter-plan-compliance";
 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 {
   resolveChapterLengthSpec,
   type ChapterLengthSpec,
@@ -69,6 +80,7 @@ export interface DeepChapterGenerationResult {
   reviewResults: NovelReviewResult[];
   revised: boolean;
   planCompliance?: string;
+  executionReport?: string;
 }
 
 export type ChapterWorkflowEventType = "started" | "completed" | "error";
@@ -108,6 +120,8 @@ export interface DeepChapterGenerationDeps {
   reviewChapter: typeof reviewChapter;
   runChapterPlanComplianceCheck?: typeof runChapterPlanComplianceCheck;
   runChapterPlanDeviationRepair?: typeof runChapterPlanDeviationRepair;
+  runChapterExecutionContractBuild?: typeof runChapterExecutionContractBuild;
+  runChapterExecutionReportCheck?: typeof runChapterExecutionReportCheck;
   streamChat: (
     config: LlmConfig,
     messages: ChatMessage[],
@@ -134,6 +148,32 @@ const DEEP_CHAPTER_CONTEXT_TOKEN_BUDGET = 32000;
 /** chars/token approximation used to convert the token budget to characters
  *  for the outline cap (mirrors context-budget.ts / contextPackToPrompt). */
 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
  *  budget. The remainder is left for memory/settings/search context so the
  *  outline can never crowd the whole window out on its own. */
@@ -429,6 +469,23 @@ export async function runDeepChapterGeneration(
     chapterNumber: input.chapterNumber ?? null,
   };
   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 = {
     name: "chapter_context",
     title: "读取上下文",
@@ -672,6 +729,7 @@ export async function runDeepChapterGeneration(
                 input.goldenThreeChapter,
                 lengthSpec,
                 planExecutionSummary,
+                executionContractText,
               ),
             },
           ],
@@ -1188,8 +1246,102 @@ export async function runDeepChapterGeneration(
     content: `最终正文已生成,约 ${countChapterChars(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 = "";
-  if (planExecutionSummary.trim()) {
+  if (planExecutionSummary.trim() && !executionContract) {
     let complianceCheckFailed = false;
     const complianceStep = {
       name: "chapter_plan_compliance",
@@ -1256,6 +1408,7 @@ export async function runDeepChapterGeneration(
             finalContent = repairedContent.trim();
             revised = true;
             const afterChars = countChapterChars(finalContent);
+            planCompliance = buildPlanDeviationRepairReturnContent(parsedCompliance, beforeChars, afterChars);
             emitDeepChapterActivity(callbacks, {
               id: `deep_chapter:plan_deviation_repair:output:${Date.now()}`,
               stageId: "plan_deviation_repair",
@@ -1310,6 +1463,7 @@ export async function runDeepChapterGeneration(
     reviewResults,
     revised,
     planCompliance,
+    executionReport: executionReportSummary,
   };
 }
 
@@ -1544,6 +1698,33 @@ function chapterPlanComplianceStatusLabel(
   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(
   originalContent: string,
   repairedContent: string,

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

@@ -68,8 +68,19 @@ export function buildDeepChapterBriefPrompt(
   goldenThreeChapter?: GoldenThreeChapterRequest,
   lengthSpec: ChapterLengthSpec = DEFAULT_CHAPTER_LENGTH_SPEC,
   planBlueprint?: string,
+  executionContractText?: 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")
     : ""
 
-  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")
+    : defaultPlanningDirectives
 
   return [
     buildStableContextPrefix(outline, contextPrompt),
@@ -110,7 +137,7 @@ export function buildDeepChapterBriefPrompt(
     chapterNumber ? `目标章节:第${chapterNumber}章` : "目标章节:用户请求中的章节",
     `用户请求:${userRequest}`,
     goldenThreeChapterSection(goldenThreeChapter),
-    blueprintSection,
+    contractSection || blueprintSection,
   ].filter(Boolean).join("\n")
 }