diff --git a/packages/core/src/__tests__/pipeline-runner.test.ts b/packages/core/src/__tests__/pipeline-runner.test.ts index ae03970c..1bfc7982 100644 --- a/packages/core/src/__tests__/pipeline-runner.test.ts +++ b/packages/core/src/__tests__/pipeline-runner.test.ts @@ -991,6 +991,88 @@ describe("PipelineRunner", () => { } }); + it("re-plans instead of reusing a persisted invalid intent artifact in v2 mode", async () => { + const { root, runner, state, bookId } = await createRunnerFixture({ + inputGovernanceMode: "v2", + }); + const storyDir = join(state.bookDir(bookId), "story"); + const runtimeDir = join(storyDir, "runtime"); + await mkdir(runtimeDir, { recursive: true }); + + await Promise.all([ + writeFile(join(storyDir, "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"), + writeFile( + join(storyDir, "volume_outline.md"), + [ + "# Volume Outline", + "", + "### Golden First Three Chapters Rule", + "", + "**Chapter 1:**", + "Track the merchant guild trail.", + "", + ].join("\n"), + "utf-8", + ), + writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"), + writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"), + writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"), + writeFile( + join(runtimeDir, "chapter-0001.intent.md"), + [ + "# Chapter Intent", + "", + "## Goal", + "**", + "", + "## Outline Node", + "**", + "", + "## Must Keep", + "- none", + "", + "## Must Avoid", + "- none", + "", + "## Style Emphasis", + "- none", + "", + "## Conflicts", + "- none", + "", + ].join("\n"), + "utf-8", + ), + ]); + + const planChapter = vi.spyOn(PlannerAgent.prototype, "planChapter"); + const writeChapter = vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue( + createWriterOutput({ + chapterNumber: 1, + content: "Governed pipeline draft.", + wordCount: "Governed pipeline draft.".length, + }), + ); + vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue( + createAuditResult({ + passed: true, + issues: [], + summary: "clean", + }), + ); + + try { + await runner.writeNextChapter(bookId, 220); + + expect(planChapter).toHaveBeenCalledTimes(1); + const writeInput = writeChapter.mock.calls[0]?.[0]; + expect(writeInput?.chapterIntent).toContain("Track the merchant guild trail."); + expect(writeInput?.chapterIntent).not.toContain("\n**\n"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("logs explicit stage messages during writeNextChapter", async () => { const { logger, infos } = createCaptureLogger(); const { root, runner, state, bookId } = await createRunnerFixture({ diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index a2653376..fa0b3d9d 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -2289,9 +2289,12 @@ ${matrix}`, const intentMarkdown = await readFile(runtimePath, "utf-8"); const sections = this.parseIntentSections(intentMarkdown); const goal = this.readIntentScalar(sections, "Goal"); - if (!goal) return null; + if (!goal || this.isInvalidPersistedIntentScalar(goal)) return null; const outlineNode = this.readIntentScalar(sections, "Outline Node"); + if (outlineNode && outlineNode !== "(not found)" && this.isInvalidPersistedIntentScalar(outlineNode)) { + return null; + } const conflicts = this.readIntentList(sections, "Conflicts") .map((line) => { const separator = line.indexOf(":"); @@ -2354,6 +2357,16 @@ ${matrix}`, .map((line) => line.replace(/^-\s*/, "")); } + private isInvalidPersistedIntentScalar(value: string): boolean { + const normalized = value.trim(); + if (!normalized) return true; + if (/^[*_`~::|.-]+$/.test(normalized)) return true; + return ( + /^\((describe|briefly describe|write)\b[\s\S]*\)$/i.test(normalized) + || /^((?:在这里描述|描述|填写|写下)[\s\S]*)$/u.test(normalized) + ); + } + private relativeToBookDir(bookDir: string, absolutePath: string): string { const prefix = `${bookDir}/`; return absolutePath.startsWith(prefix) ? absolutePath.slice(prefix.length) : absolutePath;