Преглед изворни кода

fix(planner): demote stale current focus below outline anchors

Ma пре 7 месеци
родитељ
комит
b9c05e1ad1

+ 33 - 0
packages/core/src/__tests__/pipeline-agent.test.ts

@@ -117,6 +117,39 @@ describe("agent pipeline tools", () => {
       .resolves.toContain("mentor fallout");
       .resolves.toContain("mentor fallout");
   });
   });
 
 
+  it("keeps update_current_focus usable for explicit local overrides through the tool surface", async () => {
+    await executeAgentTool(pipeline, state, config, "update_current_focus", {
+      bookId,
+      content: [
+        "# Current Focus",
+        "",
+        "## Active Focus",
+        "",
+        "Keep the merchant guild trail visible in the background.",
+        "",
+        "## Local Override",
+        "",
+        "Stay inside the mentor debt confrontation first and delay the guild chase by one chapter.",
+        "",
+      ].join("\n"),
+    });
+
+    const planResult = JSON.parse(await executeAgentTool(
+      pipeline,
+      state,
+      config,
+      "plan_chapter",
+      { bookId },
+    ));
+
+    const runtimePath = join(state.bookDir(bookId), planResult.intentPath);
+    const intentMarkdown = await readFile(runtimePath, "utf-8");
+    expect(intentMarkdown).toContain([
+      "## Goal",
+      "Stay inside the mentor debt confrontation first and delay the guild chase by one chapter.",
+    ].join("\n"));
+  });
+
   it("blocks write_full_pipeline when runtime progress is ahead of the chapter index", async () => {
   it("blocks write_full_pipeline when runtime progress is ahead of the chapter index", async () => {
     const stateDir = join(state.bookDir(bookId), "story", "state");
     const stateDir = join(state.bookDir(bookId), "story", "state");
     await mkdir(stateDir, { recursive: true });
     await mkdir(stateDir, { recursive: true });

+ 165 - 2
packages/core/src/__tests__/planner.test.ts

@@ -77,7 +77,13 @@ describe("PlannerAgent", () => {
     await rm(root, { recursive: true, force: true });
     await rm(root, { recursive: true, force: true });
   });
   });
 
 
-  it("uses current focus as the chapter goal and writes a chapter intent file", async () => {
+  it("uses current focus as the chapter goal when no outline node is available", async () => {
+    await writeFile(
+      join(storyDir, "volume_outline.md"),
+      "# Volume Outline\n",
+      "utf-8",
+    );
+
     const planner = new PlannerAgent({
     const planner = new PlannerAgent({
       client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
       client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
       model: "test-model",
       model: "test-model",
@@ -95,6 +101,158 @@ describe("PlannerAgent", () => {
     await expect(readFile(result.runtimePath, "utf-8")).resolves.toContain("mentor conflict");
     await expect(readFile(result.runtimePath, "utf-8")).resolves.toContain("mentor conflict");
   });
   });
 
 
+  it("prefers a matched outline node over ordinary current focus text", async () => {
+    await Promise.all([
+      writeFile(
+        join(storyDir, "current_focus.md"),
+        [
+          "# Current Focus",
+          "",
+          "## Active Focus",
+          "",
+          "Pull the next chapter back toward the mentor fallout instead of the guild route.",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "volume_outline.md"),
+        [
+          "# Volume Outline",
+          "",
+          "## Chapter 3",
+          "Track the merchant guild's escape route through the western canal.",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+    ]);
+
+    const planner = new PlannerAgent({
+      client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    const result = await planner.planChapter({
+      book,
+      bookDir,
+      chapterNumber: 3,
+    });
+
+    expect(result.intent.outlineNode).toContain("merchant guild's escape route");
+    expect(result.intent.goal).toContain("merchant guild's escape route");
+    expect(result.intent.goal).not.toContain("mentor fallout");
+    expect(result.intent.conflicts).toEqual([]);
+  });
+
+  it("lets explicit local override focus beat the matched outline node", async () => {
+    await Promise.all([
+      writeFile(
+        join(storyDir, "current_focus.md"),
+        [
+          "# Current Focus",
+          "",
+          "## Active Focus",
+          "",
+          "Keep pressure on the guild route in the background.",
+          "",
+          "## Local Override",
+          "",
+          "Stay inside the mentor debt confrontation first and delay the canal pursuit by one chapter.",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "volume_outline.md"),
+        [
+          "# Volume Outline",
+          "",
+          "## Chapter 3",
+          "Track the merchant guild's escape route through the western canal.",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+    ]);
+
+    const planner = new PlannerAgent({
+      client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    const result = await planner.planChapter({
+      book,
+      bookDir,
+      chapterNumber: 3,
+    });
+
+    expect(result.intent.outlineNode).toContain("merchant guild's escape route");
+    expect(result.intent.goal).toContain("mentor debt confrontation");
+    expect(result.intent.goal).not.toContain("merchant guild's escape route");
+    expect(result.intent.conflicts).toEqual(expect.arrayContaining([
+      expect.objectContaining({
+        type: "outline_vs_current_focus",
+        resolution: "allow explicit current focus override",
+      }),
+    ]));
+  });
+
+  it("keeps external context above both outline anchors and current focus", async () => {
+    await Promise.all([
+      writeFile(
+        join(storyDir, "current_focus.md"),
+        [
+          "# Current Focus",
+          "",
+          "## Active Focus",
+          "",
+          "Pull the next chapter back toward the mentor fallout instead of the guild route.",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+      writeFile(
+        join(storyDir, "volume_outline.md"),
+        [
+          "# Volume Outline",
+          "",
+          "## Chapter 3",
+          "Track the merchant guild's escape route through the western canal.",
+          "",
+        ].join("\n"),
+        "utf-8",
+      ),
+    ]);
+
+    const planner = new PlannerAgent({
+      client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId: book.id,
+    });
+
+    const result = await planner.planChapter({
+      book,
+      bookDir,
+      chapterNumber: 3,
+      externalContext: "Ignore the canal pursuit for now and force the next chapter into the mentor debt confrontation.",
+    });
+
+    expect(result.intent.goal).toContain("mentor debt confrontation");
+    expect(result.intent.goal).not.toContain("merchant guild's escape route");
+    expect(result.intent.conflicts).toEqual(expect.arrayContaining([
+      expect.objectContaining({
+        type: "outline_vs_request",
+        resolution: "allow local outline deferral",
+      }),
+    ]));
+  });
+
   it("ignores the default current_focus placeholder and falls back to author intent when no chapter outline is available", async () => {
   it("ignores the default current_focus placeholder and falls back to author intent when no chapter outline is available", async () => {
     await Promise.all([
     await Promise.all([
       writeFile(
       writeFile(
@@ -756,6 +914,11 @@ describe("PlannerAgent", () => {
         ].join("\n"),
         ].join("\n"),
         "utf-8",
         "utf-8",
       ),
       ),
+      writeFile(
+        join(storyDir, "volume_outline.md"),
+        "# Volume Outline\n",
+        "utf-8",
+      ),
     ]);
     ]);
 
 
     const planner = new PlannerAgent({
     const planner = new PlannerAgent({
@@ -768,7 +931,7 @@ describe("PlannerAgent", () => {
     const result = await planner.planChapter({
     const result = await planner.planChapter({
       book,
       book,
       bookDir,
       bookDir,
-      chapterNumber: 3,
+      chapterNumber: 2,
     });
     });
 
 
     expect(result.intent.goal).toContain("private confrontation");
     expect(result.intent.goal).toContain("private confrontation");

+ 48 - 8
packages/core/src/agents/planner.ts

@@ -66,7 +66,7 @@ export class PlannerAgent extends BaseAgent {
     const mustKeep = this.collectMustKeep(currentState, storyBible);
     const mustKeep = this.collectMustKeep(currentState, storyBible);
     const mustAvoid = this.collectMustAvoid(currentFocus, parsedRules.rules.prohibitions);
     const mustAvoid = this.collectMustAvoid(currentFocus, parsedRules.rules.prohibitions);
     const styleEmphasis = this.collectStyleEmphasis(authorIntent, currentFocus);
     const styleEmphasis = this.collectStyleEmphasis(authorIntent, currentFocus);
-    const conflicts = this.collectConflicts(input.externalContext, outlineNode, volumeOutline);
+    const conflicts = this.collectConflicts(input.externalContext, currentFocus, outlineNode, volumeOutline);
     const planningAnchor = conflicts.length > 0 ? undefined : outlineNode;
     const planningAnchor = conflicts.length > 0 ? undefined : outlineNode;
     const memorySelection = await retrieveMemorySelection({
     const memorySelection = await retrieveMemorySelection({
       bookDir: input.bookDir,
       bookDir: input.bookDir,
@@ -121,10 +121,12 @@ export class PlannerAgent extends BaseAgent {
   ): string {
   ): string {
     const first = this.extractFirstDirective(externalContext);
     const first = this.extractFirstDirective(externalContext);
     if (first) return first;
     if (first) return first;
-    const focus = this.extractFocusGoal(currentFocus);
-    if (focus) return focus;
+    const localOverride = this.extractLocalOverrideGoal(currentFocus);
+    if (localOverride) return localOverride;
     const outline = this.extractFirstDirective(outlineNode);
     const outline = this.extractFirstDirective(outlineNode);
     if (outline) return outline;
     if (outline) return outline;
+    const focus = this.extractFocusGoal(currentFocus);
+    if (focus) return focus;
     const author = this.extractFirstDirective(authorIntent);
     const author = this.extractFirstDirective(authorIntent);
     if (author) return author;
     if (author) return author;
     return `Advance chapter ${chapterNumber} with clear narrative focus.`;
     return `Advance chapter ${chapterNumber} with clear narrative focus.`;
@@ -169,19 +171,34 @@ export class PlannerAgent extends BaseAgent {
 
 
   private collectConflicts(
   private collectConflicts(
     externalContext: string | undefined,
     externalContext: string | undefined,
+    currentFocus: string,
     outlineNode: string | undefined,
     outlineNode: string | undefined,
     volumeOutline: string,
     volumeOutline: string,
   ): ChapterConflict[] {
   ): ChapterConflict[] {
-    if (!externalContext) return [];
     const outlineText = outlineNode ?? volumeOutline;
     const outlineText = outlineNode ?? volumeOutline;
     if (!outlineText || outlineText === "(文件尚未创建)") return [];
     if (!outlineText || outlineText === "(文件尚未创建)") return [];
-    const indicatesOverride = /ignore|skip|defer|instead|不要|别|先别|暂停/i.test(externalContext);
-    if (!indicatesOverride && this.hasKeywordOverlap(externalContext, outlineText)) return [];
+    if (externalContext) {
+      const indicatesOverride = /ignore|skip|defer|instead|不要|别|先别|暂停/i.test(externalContext);
+      if (!indicatesOverride && this.hasKeywordOverlap(externalContext, outlineText)) return [];
+
+      return [
+        {
+          type: "outline_vs_request",
+          resolution: "allow local outline deferral",
+        },
+      ];
+    }
+
+    const localOverride = this.extractLocalOverrideGoal(currentFocus);
+    if (!localOverride || !outlineNode) {
+      return [];
+    }
 
 
     return [
     return [
       {
       {
-        type: "outline_vs_request",
-        resolution: "allow local outline deferral",
+        type: "outline_vs_current_focus",
+        resolution: "allow explicit current focus override",
+        detail: localOverride,
       },
       },
     ];
     ];
   }
   }
@@ -224,6 +241,29 @@ export class PlannerAgent extends BaseAgent {
     return directives.join(this.containsChinese(focusSection) ? ";" : "; ");
     return directives.join(this.containsChinese(focusSection) ? ";" : "; ");
   }
   }
 
 
+  private extractLocalOverrideGoal(currentFocus: string): string | undefined {
+    const overrideSection = this.extractSection(currentFocus, [
+      "local override",
+      "explicit override",
+      "chapter override",
+      "local task override",
+      "局部覆盖",
+      "本章覆盖",
+      "临时覆盖",
+      "当前覆盖",
+    ]);
+    if (!overrideSection) {
+      return undefined;
+    }
+
+    const directives = this.extractListItems(overrideSection, 3);
+    if (directives.length > 0) {
+      return directives.join(this.containsChinese(overrideSection) ? ";" : "; ");
+    }
+
+    return this.extractFirstDirective(overrideSection);
+  }
+
   private extractFocusStyleItems(currentFocus: string, limit = 3): string[] {
   private extractFocusStyleItems(currentFocus: string, limit = 3): string[] {
     const focusSection = this.extractSection(currentFocus, [
     const focusSection = this.extractSection(currentFocus, [
       "active focus",
       "active focus",