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

feat(v13): restore persisted governed plan in memo frontmatter format

Saves planner output as story/runtime/chapter-NNNN.plan.md with YAML
frontmatter (memo + intent extensions + plannerInputs) plus the 7-section
memo body. Loader parses back through planner's strict parseMemo helper,
so any section drift falls through to re-planning rather than silent
degradation.
Ma 6 месяцев назад
Родитель
Сommit
460adb6300

+ 131 - 0
packages/core/src/__tests__/persisted-governed-plan.test.ts

@@ -0,0 +1,131 @@
+import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import {
+  loadPersistedPlan,
+  savePersistedPlan,
+} from "../pipeline/persisted-governed-plan.js";
+import type { PlanChapterOutput } from "../agents/planner.js";
+
+const MEMO_BODY = `## 当前任务
+林越必须取回母亲留下的账册并离开旧港。
+
+## 读者此刻在等什么
+读者想看账册内容会不会撕开上一章的悬念。
+
+## 该兑现的 / 暂不掀的
+- 兑现:账册现身
+- 压住:母亲真实身份
+
+## 日常/过渡承担什么任务
+- [开场茶馆段] → 建立旧港潮湿氛围与对手视线
+
+## 关键抉择过三连问
+林越拿起账册时必须过:为什么现在?符合人设吗?读者会觉得突兀吗?
+
+## 章尾必须发生的改变
+- 账册从母亲遗物变为主角私有
+- 主角与旧港江湖关系破裂
+
+## 不要做
+- 不要出现母亲真实姓名
+- 不要让账册被反派看到`;
+
+function buildPlan(chapter: number): PlanChapterOutput {
+  return {
+    intent: {
+      chapter,
+      goal: "取回账册离开旧港",
+      outlineNode: "Chapter 1: return",
+      arcContext: "卷纲节点:第一卷序章",
+      mustKeep: ["林越父亲已死", "母亲遗物"],
+      mustAvoid: ["提前揭露母亲身份"],
+      styleEmphasis: ["潮湿氛围", "短句交替"],
+    },
+    memo: {
+      chapter,
+      goal: "取回账册离开旧港",
+      isGoldenOpening: true,
+      threadRefs: ["H1"],
+      body: MEMO_BODY,
+    },
+    intentMarkdown: "# Chapter Intent\n\n## Goal\n取回账册离开旧港\n",
+    plannerInputs: ["story/volume_outline.md", "story/current_state.md"],
+    runtimePath: "unused",
+  };
+}
+
+describe("persisted-governed-plan round trip", () => {
+  it("savePersistedPlan + loadPersistedPlan returns equal memo", async () => {
+    const dir = await mkdtemp(join(tmpdir(), "inkos-plan-"));
+    await mkdir(join(dir, "story", "runtime"), { recursive: true });
+    // Write the sibling intent.md so loader reads it back.
+    await writeFile(
+      join(dir, "story", "runtime", "chapter-0001.intent.md"),
+      "# Chapter Intent\n\n## Goal\n取回账册离开旧港\n",
+      "utf-8",
+    );
+
+    const plan = buildPlan(1);
+    await savePersistedPlan(dir, plan);
+
+    const loaded = await loadPersistedPlan(dir, 1);
+    expect(loaded).not.toBeNull();
+    expect(loaded!.memo).toEqual(plan.memo);
+    expect(loaded!.intent.goal).toBe(plan.intent.goal);
+    expect(loaded!.intent.outlineNode).toBe(plan.intent.outlineNode);
+    expect(loaded!.intent.arcContext).toBe(plan.intent.arcContext);
+    expect(loaded!.intent.mustKeep).toEqual(plan.intent.mustKeep);
+    expect(loaded!.intent.mustAvoid).toEqual(plan.intent.mustAvoid);
+    expect(loaded!.intent.styleEmphasis).toEqual(plan.intent.styleEmphasis);
+    expect(loaded!.plannerInputs).toEqual(plan.plannerInputs);
+  });
+
+  it("returns null when plan file does not exist", async () => {
+    const dir = await mkdtemp(join(tmpdir(), "inkos-plan-"));
+    await mkdir(join(dir, "story", "runtime"), { recursive: true });
+    const loaded = await loadPersistedPlan(dir, 1);
+    expect(loaded).toBeNull();
+  });
+
+  it("returns null when memo body is missing required sections", async () => {
+    const dir = await mkdtemp(join(tmpdir(), "inkos-plan-"));
+    await mkdir(join(dir, "story", "runtime"), { recursive: true });
+
+    // Corrupt memo body: drop the 不要做 heading.
+    const corrupt = `---
+chapter: 1
+goal: 取回账册离开旧港
+isGoldenOpening: true
+threadRefs: []
+intent:
+  goal: 取回账册离开旧港
+  outlineNode: Chapter 1
+  mustKeep: []
+  mustAvoid: []
+  styleEmphasis: []
+plannerInputs: []
+---
+## 当前任务
+只有一段。
+`;
+    await writeFile(
+      join(dir, "story", "runtime", "chapter-0001.plan.md"),
+      corrupt,
+      "utf-8",
+    );
+
+    const loaded = await loadPersistedPlan(dir, 1);
+    expect(loaded).toBeNull();
+  });
+
+  it("returns null when chapter number does not match", async () => {
+    const dir = await mkdtemp(join(tmpdir(), "inkos-plan-"));
+    await mkdir(join(dir, "story", "runtime"), { recursive: true });
+    const plan = buildPlan(2);
+    await savePersistedPlan(dir, plan);
+    const loaded = await loadPersistedPlan(dir, 3);
+    expect(loaded).toBeNull();
+  });
+});

+ 130 - 9
packages/core/src/pipeline/persisted-governed-plan.ts

@@ -1,19 +1,140 @@
-import { relative } from "node:path";
+import { readFile, writeFile } from "node:fs/promises";
+import { join, relative } from "node:path";
+import YAML from "js-yaml";
 import type { PlanChapterOutput } from "../agents/planner.js";
+import {
+  ChapterIntentSchema,
+  type ChapterIntent,
+} from "../models/input-governance.js";
+import { parseMemo, PlannerParseError } from "../utils/chapter-memo-parser.js";
 
 /**
- * Phase 1: persisted governed plans are disabled.
+ * Phase 4: persisted governed plans are stored as a single markdown file
+ * containing YAML frontmatter (memo + intent extensions + plannerInputs)
+ * followed by the memo body (7 required sections).
  *
- * The previous implementation serialized ChapterBrief/HookAgenda into the
- * intent markdown, but those schemas no longer exist. Rather than write a
- * migration, we return null so the pipeline regenerates the plan from scratch.
- * Phase 3 will introduce a new memo-based persistence format if needed.
+ * File path: `story/runtime/chapter-NNNN.plan.md`
+ *
+ * The sibling `chapter-NNNN.intent.md` file stays as a human-readable
+ * render — it is not parsed back. We keep it in sync by regenerating
+ * downstream, but only this `.plan.md` is authoritative for restore.
+ *
+ * If parse fails for any reason we return null and let the runner re-invoke
+ * the planner. We never try to partially reconstruct — silent degradation
+ * is worse than re-planning.
  */
+
+function planPath(bookDir: string, chapterNumber: number): string {
+  const runtimeDir = join(bookDir, "story", "runtime");
+  const padded = String(chapterNumber).padStart(4, "0");
+  return join(runtimeDir, `chapter-${padded}.plan.md`);
+}
+
+function intentPath(bookDir: string, chapterNumber: number): string {
+  const runtimeDir = join(bookDir, "story", "runtime");
+  const padded = String(chapterNumber).padStart(4, "0");
+  return join(runtimeDir, `chapter-${padded}.intent.md`);
+}
+
+export async function savePersistedPlan(
+  bookDir: string,
+  plan: PlanChapterOutput,
+): Promise<void> {
+  const { intent, memo, plannerInputs } = plan;
+  const frontmatter = {
+    chapter: memo.chapter,
+    goal: memo.goal,
+    isGoldenOpening: memo.isGoldenOpening,
+    threadRefs: memo.threadRefs,
+    intent: {
+      goal: intent.goal,
+      outlineNode: intent.outlineNode,
+      arcContext: intent.arcContext,
+      mustKeep: intent.mustKeep,
+      mustAvoid: intent.mustAvoid,
+      styleEmphasis: intent.styleEmphasis,
+    },
+    plannerInputs: [...plannerInputs],
+  };
+  const yaml = YAML.dump(frontmatter, { lineWidth: -1 });
+  const content = `---\n${yaml}---\n${memo.body}\n`;
+  await writeFile(planPath(bookDir, memo.chapter), content, "utf-8");
+}
+
 export async function loadPersistedPlan(
-  _bookDir: string,
-  _chapterNumber: number,
+  bookDir: string,
+  chapterNumber: number,
 ): Promise<PlanChapterOutput | null> {
-  return null;
+  let raw: string;
+  try {
+    raw = await readFile(planPath(bookDir, chapterNumber), "utf-8");
+  } catch {
+    return null;
+  }
+
+  const match = raw.trim().match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
+  if (!match) return null;
+
+  let fm: unknown;
+  try {
+    fm = YAML.load(match[1]!);
+  } catch {
+    return null;
+  }
+  if (!fm || typeof fm !== "object" || Array.isArray(fm)) return null;
+  const f = fm as Record<string, unknown>;
+
+  if (typeof f.chapter !== "number" || f.chapter !== chapterNumber) return null;
+  if (typeof f.isGoldenOpening !== "boolean") return null;
+  if (!f.intent || typeof f.intent !== "object") return null;
+
+  // Reconstruct memo via the same strict parser planner uses. This guarantees
+  // the 7 required section headings are still present — any drift triggers
+  // re-planning (null return).
+  let memo;
+  try {
+    const reconstructed = `---\n${YAML.dump({
+      chapter: f.chapter,
+      goal: f.goal,
+      threadRefs: f.threadRefs,
+    })}---\n${match[2]!}`;
+    memo = parseMemo(reconstructed, chapterNumber, f.isGoldenOpening);
+  } catch (error) {
+    if (error instanceof PlannerParseError) return null;
+    throw error;
+  }
+
+  let intent: ChapterIntent;
+  try {
+    intent = ChapterIntentSchema.parse({
+      chapter: chapterNumber,
+      ...(f.intent as Record<string, unknown>),
+    });
+  } catch {
+    return null;
+  }
+
+  const plannerInputs = Array.isArray(f.plannerInputs)
+    ? f.plannerInputs.filter((value): value is string => typeof value === "string")
+    : [];
+
+  // intentMarkdown is a display artifact — read the sibling .intent.md so we
+  // surface the same content downstream consumers expect. If it's missing we
+  // fall back to the memo body, which is usable but less rich.
+  let intentMarkdown = memo.body;
+  try {
+    intentMarkdown = await readFile(intentPath(bookDir, chapterNumber), "utf-8");
+  } catch {
+    // fall through — memo body is a safe default.
+  }
+
+  return {
+    intent,
+    memo,
+    intentMarkdown,
+    plannerInputs,
+    runtimePath: intentPath(bookDir, chapterNumber),
+  };
 }
 
 export function relativeToBookDir(bookDir: string, absolutePath: string): string {

+ 6 - 2
packages/core/src/pipeline/runner.ts

@@ -44,7 +44,7 @@ import {
 import { persistChapterArtifacts } from "./chapter-persistence.js";
 import { runChapterReviewCycle } from "./chapter-review-cycle.js";
 import { validateChapterTruthPersistence } from "./chapter-truth-validation.js";
-import { loadPersistedPlan, relativeToBookDir } from "./persisted-governed-plan.js";
+import { loadPersistedPlan, relativeToBookDir, savePersistedPlan } from "./persisted-governed-plan.js";
 
 const SEQUENCE_LEVEL_CATEGORIES = new Set([
   "Pacing Monotony", "节奏单调",
@@ -2757,12 +2757,16 @@ ${matrix}`,
     }
 
     const planner = new PlannerAgent(this.agentCtxFor("planner", book.id));
-    return planner.planChapter({
+    const plan = await planner.planChapter({
       book,
       bookDir,
       chapterNumber,
       externalContext,
     });
+    // Persist in the new memo format so subsequent compose/write phases can
+    // skip the planner LLM call when no new context is supplied.
+    await savePersistedPlan(bookDir, plan);
+    return plan;
   }
 
   private async emitWebhook(