From eba4a54ae4e8d06a208137a70743785f75ef8396 Mon Sep 17 00:00:00 2001 From: Ma Date: Sat, 28 Mar 2026 21:29:38 +0800 Subject: [PATCH] feat: harden v0.6.3 safety guards and migration hints --- .../plans/2026-03-28-v0.6.3-hardening-plan.md | 171 ++++++++++++++++++ .../cli/src/__tests__/cli-integration.test.ts | 163 +++++++++++++++++ packages/cli/src/commands/status.ts | 7 +- packages/cli/src/commands/write.ts | 14 +- packages/cli/src/utils.ts | 21 ++- .../core/src/__tests__/pipeline-agent.test.ts | 66 ++++++- .../src/__tests__/pipeline-runner.test.ts | 145 +++++++++++++++ .../__tests__/post-write-validator.test.ts | 27 +++ .../core/src/__tests__/state-manager.test.ts | 46 +++++ .../core/src/agents/post-write-validator.ts | 45 ++++- packages/core/src/pipeline/agent.ts | 48 +++-- packages/core/src/pipeline/runner.ts | 73 +++++--- packages/core/src/state/manager.ts | 9 +- 13 files changed, 787 insertions(+), 48 deletions(-) create mode 100644 docs/plans/2026-03-28-v0.6.3-hardening-plan.md diff --git a/docs/plans/2026-03-28-v0.6.3-hardening-plan.md b/docs/plans/2026-03-28-v0.6.3-hardening-plan.md new file mode 100644 index 00000000..57cb4d07 --- /dev/null +++ b/docs/plans/2026-03-28-v0.6.3-hardening-plan.md @@ -0,0 +1,171 @@ +# v0.6.3 Hardening Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Close the remaining 0.6.3 safety gaps around agent tool misuse, final-content paragraph checks, duplicate chapter titles, migration hints, and rewrite regression coverage. + +**Architecture:** Keep the current v2 pipeline, but tighten the remaining soft spots at the edges. Add deterministic guards in the agent dispatch layer, run final-content validation at the last safe point before persist, surface migration status in user-facing CLI flows, and add targeted regressions for the exact user-visible failures. + +**Tech Stack:** TypeScript, Vitest, Commander CLI + +--- + +### Task 1: Guard the remaining agent path + +**Files:** +- Modify: `packages/core/src/__tests__/pipeline-agent.test.ts` +- Modify: `packages/core/src/pipeline/agent.ts` + +**Step 1: Write the failing tests** + +Add tests that prove: +- `write_full_pipeline` is rejected when the chapter sequence is inconsistent +- `write_truth_file` cannot use `current_state.md` to mutate progress + +**Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm vitest run src/__tests__/pipeline-agent.test.ts +``` + +**Step 3: Write minimal implementation** + +Reuse the same guard logic currently applied to `write_draft` and block `write_full_pipeline` before it calls `writeNextChapter()`. + +**Step 4: Run test to verify it passes** + +Run: + +```bash +pnpm vitest run src/__tests__/pipeline-agent.test.ts +``` + +**Step 5: Commit** + +```bash +git add packages/core/src/pipeline/agent.ts packages/core/src/__tests__/pipeline-agent.test.ts +git commit -m "fix: guard write_full_pipeline in agent dispatch" +``` + +### Task 2: Validate final persisted chapter shape + +**Files:** +- Modify: `packages/core/src/__tests__/post-write-validator.test.ts` +- Modify: `packages/core/src/__tests__/pipeline-runner.test.ts` +- Modify: `packages/core/src/agents/post-write-validator.ts` +- Modify: `packages/core/src/pipeline/runner.ts` + +**Step 1: Write the failing tests** + +Add tests that prove: +- final persisted content can trigger `段落过碎` / `连续短段` +- duplicate titles are handled as a real final-stage action, not a passive warning + +**Step 2: Run tests to verify they fail** + +Run: + +```bash +pnpm vitest run src/__tests__/post-write-validator.test.ts src/__tests__/pipeline-runner.test.ts +``` + +**Step 3: Write minimal implementation** + +Export a reusable final paragraph-shape detector and run it on `finalContent` before persist. Upgrade duplicate-title handling from a passive warning to an explicit final-stage resolution path. + +**Step 4: Run tests to verify they pass** + +Run: + +```bash +pnpm vitest run src/__tests__/post-write-validator.test.ts src/__tests__/pipeline-runner.test.ts +``` + +**Step 5: Commit** + +```bash +git add packages/core/src/agents/post-write-validator.ts packages/core/src/pipeline/runner.ts packages/core/src/__tests__/post-write-validator.test.ts packages/core/src/__tests__/pipeline-runner.test.ts +git commit -m "fix: harden final content validation and title handling" +``` + +### Task 3: Surface migration status in user-facing CLI flows + +**Files:** +- Modify: `packages/cli/src/__tests__/cli-integration.test.ts` +- Modify: `packages/cli/src/commands/status.ts` +- Modify: `packages/cli/src/commands/write.ts` +- Modify: `packages/cli/src/utils.ts` + +**Step 1: Write the failing tests** + +Add tests that prove: +- `inkos status` shows a migration hint for legacy books +- `inkos write next` warns before auto-migrating a legacy book + +**Step 2: Run tests to verify they fail** + +Run: + +```bash +pnpm vitest run src/__tests__/cli-integration.test.ts +``` + +**Step 3: Write minimal implementation** + +Add a shared CLI helper that detects legacy pre-v0.6 books and emits a clear hint in `status` and `write next`. + +**Step 4: Run tests to verify they pass** + +Run: + +```bash +pnpm vitest run src/__tests__/cli-integration.test.ts +``` + +**Step 5: Commit** + +```bash +git add packages/cli/src/utils.ts packages/cli/src/commands/status.ts packages/cli/src/commands/write.ts packages/cli/src/__tests__/cli-integration.test.ts +git commit -m "feat: show migration hints in status and write flows" +``` + +### Task 4: Verify rewrite from user path + +**Files:** +- Modify: `packages/cli/src/__tests__/cli-integration.test.ts` + +**Step 1: Write the failing test** + +Add a CLI-level regression covering: +- create 3 chapters +- rewrite chapter 2 +- assert next chapter number is 2 again + +**Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm vitest run src/__tests__/cli-integration.test.ts +``` + +**Step 3: Write minimal implementation** + +Prefer no production change unless the new CLI regression exposes a real gap. + +**Step 4: Run tests to verify it passes** + +Run: + +```bash +pnpm vitest run src/__tests__/cli-integration.test.ts +``` + +**Step 5: Commit** + +```bash +git add packages/cli/src/__tests__/cli-integration.test.ts +git commit -m "test: cover rewrite flow from CLI path" +``` diff --git a/packages/cli/src/__tests__/cli-integration.test.ts b/packages/cli/src/__tests__/cli-integration.test.ts index 603b7eb7..5266748b 100644 --- a/packages/cli/src/__tests__/cli-integration.test.ts +++ b/packages/cli/src/__tests__/cli-integration.test.ts @@ -4,6 +4,7 @@ import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { execFileSync } from "node:child_process"; import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { StateManager } from "@actalk/inkos-core"; const testDir = dirname(fileURLToPath(import.meta.url)); const cliDir = resolve(testDir, "..", ".."); @@ -40,6 +41,13 @@ function runStderr(args: string[], options?: { env?: Record }): } } +const failingLlmEnv = { + INKOS_LLM_PROVIDER: "openai", + INKOS_LLM_BASE_URL: "http://127.0.0.1:9/v1", + INKOS_LLM_MODEL: "test-model", + INKOS_LLM_API_KEY: "test-key", +}; + describe("CLI integration", () => { beforeAll(async () => { projectDir = await mkdtemp(join(tmpdir(), "inkos-cli-test-")); @@ -278,6 +286,34 @@ describe("CLI integration", () => { expect(output).toContain('Ch.1 "A Quiet Sky" | 7 words | ready-for-review'); expect(output).not.toContain("7字"); }); + + it("shows a migration hint for legacy pre-v0.6 books", async () => { + const bookDir = join(projectDir, "books", "legacy-status-hint"); + const storyDir = join(bookDir, "story"); + await mkdir(join(bookDir, "chapters"), { recursive: true }); + await mkdir(storyDir, { recursive: true }); + await writeFile( + join(bookDir, "book.json"), + JSON.stringify({ + id: "legacy-status-hint", + title: "Legacy Status Hint", + platform: "other", + genre: "other", + status: "active", + targetChapters: 10, + chapterWordCount: 2200, + createdAt: "2026-03-22T00:00:00.000Z", + updatedAt: "2026-03-22T00:00:00.000Z", + }, null, 2), + "utf-8", + ); + await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8"); + await writeFile(join(storyDir, "current_state.md"), "# Current State\n\nLegacy state.\n", "utf-8"); + await writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n", "utf-8"); + + const output = run(["status", "legacy-status-hint"]); + expect(output).toContain("legacy format"); + }); }); describe("inkos doctor", () => { @@ -345,6 +381,133 @@ describe("CLI integration", () => { await writeFile(envPath, originalEnv, "utf-8"); } }); + + it("reports legacy books in the version migration check", async () => { + const bookDir = join(projectDir, "books", "legacy-doctor-hint"); + const storyDir = join(bookDir, "story"); + await mkdir(join(bookDir, "chapters"), { recursive: true }); + await mkdir(storyDir, { recursive: true }); + await writeFile( + join(bookDir, "book.json"), + JSON.stringify({ + id: "legacy-doctor-hint", + title: "Legacy Doctor Hint", + platform: "other", + genre: "other", + status: "active", + targetChapters: 10, + chapterWordCount: 2200, + createdAt: "2026-03-22T00:00:00.000Z", + updatedAt: "2026-03-22T00:00:00.000Z", + }, null, 2), + "utf-8", + ); + await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8"); + await writeFile(join(storyDir, "current_state.md"), "# Current State\n\nLegacy state.\n", "utf-8"); + await writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n", "utf-8"); + + const { stdout } = runStderr(["doctor"]); + expect(stdout).toContain("Version Migration"); + expect(stdout).toContain("legacy format"); + }); + }); + + describe("inkos write", () => { + it("warns before writing when the target book still uses legacy format", async () => { + const bookDir = join(projectDir, "books", "legacy-write-hint"); + const storyDir = join(bookDir, "story"); + await mkdir(join(bookDir, "chapters"), { recursive: true }); + await mkdir(storyDir, { recursive: true }); + await writeFile( + join(bookDir, "book.json"), + JSON.stringify({ + id: "legacy-write-hint", + title: "Legacy Write Hint", + platform: "other", + genre: "other", + status: "active", + targetChapters: 10, + chapterWordCount: 2200, + createdAt: "2026-03-22T00:00:00.000Z", + updatedAt: "2026-03-22T00:00:00.000Z", + }, null, 2), + "utf-8", + ); + await writeFile(join(bookDir, "chapters", "index.json"), "[]", "utf-8"); + await writeFile(join(storyDir, "current_state.md"), "# Current State\n\nLegacy state.\n", "utf-8"); + await writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n", "utf-8"); + + const { stdout, stderr } = runStderr(["write", "next", "legacy-write-hint"], { + env: failingLlmEnv, + }); + expect(`${stdout}\n${stderr}`).toContain("legacy format"); + }); + + it("keeps next chapter at 2 after rewrite 2 trims later chapters, even if regeneration fails", async () => { + const state = new StateManager(projectDir); + const bookId = "rewrite-cli"; + const bookDir = join(projectDir, "books", bookId); + const storyDir = join(bookDir, "story"); + const chaptersDir = join(bookDir, "chapters"); + const stateDir = join(storyDir, "state"); + + await mkdir(chaptersDir, { recursive: true }); + await mkdir(stateDir, { recursive: true }); + await writeFile( + join(bookDir, "book.json"), + JSON.stringify({ + id: bookId, + title: "Rewrite CLI", + platform: "other", + genre: "other", + status: "active", + targetChapters: 10, + chapterWordCount: 2200, + createdAt: "2026-03-22T00:00:00.000Z", + updatedAt: "2026-03-22T00:00:00.000Z", + }, null, 2), + "utf-8", + ); + await writeFile(join(storyDir, "current_state.md"), "State at ch1", "utf-8"); + await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch1", "utf-8"); + await writeFile(join(chaptersDir, "0001_ch1.md"), "# Chapter 1\n\nContent 1", "utf-8"); + await writeFile(join(chaptersDir, "0002_ch2.md"), "# Chapter 2\n\nContent 2", "utf-8"); + await writeFile(join(chaptersDir, "0003_ch3.md"), "# Chapter 3\n\nContent 3", "utf-8"); + await writeFile( + join(chaptersDir, "index.json"), + JSON.stringify([ + { number: 1, title: "Ch1", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] }, + { number: 2, title: "Ch2", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] }, + { number: 3, title: "Ch3", status: "approved", wordCount: 100, createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [] }, + ], null, 2), + "utf-8", + ); + + await state.snapshotState(bookId, 1); + + await writeFile(join(storyDir, "current_state.md"), "State at ch3", "utf-8"); + await writeFile(join(stateDir, "manifest.json"), JSON.stringify({ + schemaVersion: 2, + language: "en", + lastAppliedChapter: 4, + projectionVersion: 1, + migrationWarnings: [], + }, null, 2), "utf-8"); + await writeFile(join(stateDir, "current_state.json"), JSON.stringify({ + chapter: 3, + facts: [], + }, null, 2), "utf-8"); + + const { exitCode, stdout, stderr } = runStderr(["write", "rewrite", bookId, "2", "--force"], { + env: failingLlmEnv, + }); + expect(exitCode).not.toBe(0); + expect(`${stdout}\n${stderr}`).toContain("Regenerating chapter 2"); + + const next = await state.getNextChapterNumber(bookId); + expect(next).toBe(2); + await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe("State at ch1"); + }); }); describe("inkos analytics", () => { diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 238edc67..ba853a41 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; import { StateManager, formatLengthCount, readGenreProfile, resolveLengthCountingMode } from "@actalk/inkos-core"; -import { findProjectRoot, log, logError } from "../utils.js"; +import { findProjectRoot, getLegacyMigrationHint, log, logError } from "../utils.js"; export const statusCommand = new Command("status") .description("Show project status") @@ -32,6 +32,7 @@ export const statusCommand = new Command("status") for (const id of bookIds) { const book = await state.loadBookConfig(id); const index = await state.loadChapterIndex(id); + const migrationHint = await getLegacyMigrationHint(root, id); const nextChapter = await state.getNextChapterNumber(id); const { profile: genreProfile } = await readGenreProfile(root, book.genre); const countingMode = resolveLengthCountingMode(book.language ?? genreProfile.language); @@ -59,6 +60,7 @@ export const statusCommand = new Command("status") approved, pending, failed, + ...(migrationHint ? { migrationHint } : {}), ...(opts.chapters ? { chapterList: index.map((ch) => ({ number: ch.number, @@ -77,6 +79,9 @@ export const statusCommand = new Command("status") log(` Chapters: ${nextChapter - 1} / ${book.targetChapters}`); log(` Words: ${totalWords.toLocaleString()} (avg ${avgWords}/ch)`); log(` Approved: ${approved} | Pending: ${pending} | Failed: ${failed}`); + if (migrationHint) { + log(` Migration: ${migrationHint}`); + } if (opts.chapters && index.length > 0) { log(""); diff --git a/packages/cli/src/commands/write.ts b/packages/cli/src/commands/write.ts index c755b9b6..83a77c1d 100644 --- a/packages/cli/src/commands/write.ts +++ b/packages/cli/src/commands/write.ts @@ -3,7 +3,7 @@ import { PipelineRunner, StateManager } from "@actalk/inkos-core"; import { readdir, unlink } from "node:fs/promises"; import { join } from "node:path"; import { createInterface } from "node:readline"; -import { loadConfig, buildPipelineConfig, findProjectRoot, resolveContext, resolveBookId, log, logError } from "../utils.js"; +import { loadConfig, buildPipelineConfig, findProjectRoot, getLegacyMigrationHint, resolveContext, resolveBookId, log, logError } from "../utils.js"; import { formatWriteNextComplete, formatWriteNextProgress, formatWriteNextResultLines, resolveCliLanguage } from "../localization.js"; export const writeCommand = new Command("write") @@ -21,13 +21,17 @@ writeCommand .option("-q, --quiet", "Suppress console output") .action(async (bookIdArg: string | undefined, opts) => { try { - const config = await loadConfig(); const root = findProjectRoot(); const bookId = await resolveBookId(bookIdArg, root); const context = await resolveContext(opts); const state = new StateManager(root); const book = await state.loadBookConfig(bookId); const language = resolveCliLanguage(book.language); + const migrationHint = await getLegacyMigrationHint(root, bookId); + if (migrationHint && !opts.json) { + log(`[migration] ${migrationHint}`); + } + const config = await loadConfig(); const pipeline = new PipelineRunner(buildPipelineConfig(config, root, { externalContext: context, quiet: opts.quiet })); @@ -81,7 +85,6 @@ writeCommand .option("--json", "Output JSON") .action(async (args: ReadonlyArray, opts) => { try { - const config = await loadConfig(); const root = findProjectRoot(); let bookId: string; @@ -113,6 +116,10 @@ writeCommand const state = new StateManager(root); const bookDir = state.bookDir(bookId); const chaptersDir = join(bookDir, "chapters"); + const migrationHint = await getLegacyMigrationHint(root, bookId); + if (migrationHint && !opts.json) { + log(`[migration] ${migrationHint}`); + } // Remove existing chapter file const files = await readdir(chaptersDir); @@ -151,6 +158,7 @@ writeCommand const wordCount = opts.words ? parseInt(opts.words, 10) : undefined; + const config = await loadConfig(); const pipeline = new PipelineRunner(buildPipelineConfig(config, root)); const result = await pipeline.writeNextChapter(bookId, wordCount); diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 34d90e42..a47f02c8 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -1,5 +1,5 @@ -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import { readFile, stat } from "node:fs/promises"; +import { join, resolve } from "node:path"; import { createLLMClient, StateManager, createLogger, createStderrSink, createJsonLineSink, loadProjectConfig, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH, type ProjectConfig, type PipelineConfig, type LogSink } from "@actalk/inkos-core"; import { formatSqliteMemorySupportWarning } from "./runtime-requirements.js"; @@ -132,3 +132,20 @@ export async function resolveBookId( `Multiple books found: ${books.join(", ")}\nPlease specify a book-id.`, ); } + +export async function getLegacyMigrationHint( + root: string, + bookId: string, +): Promise { + const state = new StateManager(root); + const stateDir = join(state.bookDir(bookId), "story", "state"); + try { + const info = await stat(stateDir); + if (info.isDirectory()) { + return null; + } + } catch { + return `Book "${bookId}" uses legacy format (pre-v0.6). The next write will auto-migrate its state files.`; + } + return `Book "${bookId}" uses legacy format (pre-v0.6). The next write will auto-migrate its state files.`; +} diff --git a/packages/core/src/__tests__/pipeline-agent.test.ts b/packages/core/src/__tests__/pipeline-agent.test.ts index aa48cd0a..dd38c3f4 100644 --- a/packages/core/src/__tests__/pipeline-agent.test.ts +++ b/packages/core/src/__tests__/pipeline-agent.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -116,4 +116,68 @@ describe("agent pipeline tools", () => { await expect(readFile(join(state.bookDir(bookId), "story", "current_focus.md"), "utf-8")) .resolves.toContain("mentor fallout"); }); + + it("blocks write_full_pipeline when runtime progress is ahead of the chapter index", async () => { + const stateDir = join(state.bookDir(bookId), "story", "state"); + await mkdir(stateDir, { recursive: true }); + await state.saveChapterIndex(bookId, [{ + number: 1, + title: "Existing Chapter", + status: "approved", + wordCount: 120, + createdAt: "2026-03-22T00:00:00.000Z", + updatedAt: "2026-03-22T00:00:00.000Z", + auditIssues: [], + lengthWarnings: [], + }]); + await writeFile(join(stateDir, "manifest.json"), JSON.stringify({ + schemaVersion: 2, + language: "zh", + lastAppliedChapter: 3, + projectionVersion: 1, + migrationWarnings: [], + }, null, 2), "utf-8"); + await writeFile(join(stateDir, "current_state.json"), JSON.stringify({ + chapter: 3, + facts: [], + }, null, 2), "utf-8"); + + const writeNextChapter = vi.spyOn(pipeline, "writeNextChapter").mockResolvedValue({ + bookId, + chapterNumber: 4, + title: "Should Not Run", + wordCount: 100, + filePath: "books/agent-book/chapters/0004_Should_Not_Run.md", + auditResult: { passed: true, issues: [], summary: "ok" }, + revised: false, + status: "ready-for-review", + } as Awaited>); + + const result = JSON.parse(await executeAgentTool( + pipeline, + state, + config, + "write_full_pipeline", + { bookId, count: 1 }, + )); + + expect(result.error).toContain("write_full_pipeline"); + expect(writeNextChapter).not.toHaveBeenCalled(); + }); + + it("blocks write_truth_file from hacking chapter progress inside current_state.md", async () => { + const result = JSON.parse(await executeAgentTool( + pipeline, + state, + config, + "write_truth_file", + { + bookId, + fileName: "current_state.md", + content: "# Current State\n\n| Current Chapter | 999 |\n", + }, + )); + + expect(result.error).toContain("章节进度"); + }); }); diff --git a/packages/core/src/__tests__/pipeline-runner.test.ts b/packages/core/src/__tests__/pipeline-runner.test.ts index a4ad8260..132123c3 100644 --- a/packages/core/src/__tests__/pipeline-runner.test.ts +++ b/packages/core/src/__tests__/pipeline-runner.test.ts @@ -3012,6 +3012,151 @@ describe("PipelineRunner", () => { } }); + it("adds final paragraph fragmentation warnings from revised content before persist", async () => { + const { root, runner, state, bookId } = await createRunnerFixture(); + const storyDir = join(state.bookDir(bookId), "story"); + const draftBody = "林越先把门推开一条缝,再侧耳去听墙后的动静。屋里的灯没有亮,但桌角还有没散的热气,说明人刚离开不久。"; + const revisedBody = [ + "门开了。", + "他没进去。", + "先听了一下。", + "里面没有声响。", + "他这才抬脚。", + "屋里很冷。", + ].join("\n\n"); + + await Promise.all([ + writeFile(join(storyDir, "current_state.md"), createStateCard({ + chapter: 0, + location: "Ashen ferry crossing", + protagonistState: "Lin Yue still hides the oath token.", + goal: "Find the vanished mentor.", + conflict: "The debt trail keeps narrowing.", + }), "utf-8"), + writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"), + ]); + + vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue( + createWriterOutput({ + chapterNumber: 1, + title: "雾线", + content: draftBody, + wordCount: draftBody.length, + }), + ); + vi.spyOn(ContinuityAuditor.prototype, "auditChapter") + .mockResolvedValueOnce( + createAuditResult({ + passed: false, + issues: [CRITICAL_ISSUE], + summary: "needs revision", + }), + ) + .mockResolvedValueOnce( + createAuditResult({ + passed: true, + issues: [], + summary: "clean", + }), + ); + vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue( + createReviseOutput({ + revisedContent: revisedBody, + wordCount: revisedBody.length, + updatedState: createStateCard({ + chapter: 1, + location: "Ashen ferry crossing", + protagonistState: "Lin Yue still hides the oath token.", + goal: "Find the vanished mentor.", + conflict: "He steps into the empty room.", + }), + updatedHooks: "# Pending Hooks\n", + }), + ); + vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue( + createAnalyzedOutput({ + title: "雾线", + content: revisedBody, + wordCount: revisedBody.length, + chapterSummary: "| 1 | 雾线 | 林越 | 进入空屋 | 状态推进 | 无 | 紧绷 | 过渡 |", + }), + ); + + try { + const result = await runner.writeNextChapter(bookId, 120); + + expect(result.auditResult.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + category: "paragraph-shape", + description: expect.stringContaining("段落被切得过碎"), + }), + expect.objectContaining({ + category: "paragraph-shape", + description: expect.stringContaining("连续出现"), + }), + ]), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("resolves duplicate chapter titles before persist", async () => { + const { root, runner, state, bookId } = await createRunnerFixture(); + const storyDir = join(state.bookDir(bookId), "story"); + const chaptersDir = join(state.bookDir(bookId), "chapters"); + const now = "2026-03-19T00:00:00.000Z"; + + await Promise.all([ + writeFile(join(chaptersDir, "0001_回声.md"), "# 第1章 回声\n\n旧章节。", "utf-8"), + writeFile(join(storyDir, "current_state.md"), createStateCard({ + chapter: 1, + location: "Ashen ferry crossing", + protagonistState: "Lin Yue still hides the oath token.", + goal: "Find the vanished mentor.", + conflict: "The debt trail keeps narrowing.", + }), "utf-8"), + writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"), + ]); + await state.saveChapterIndex(bookId, [{ + number: 1, + title: "回声", + status: "ready-for-review", + wordCount: 12, + createdAt: now, + updatedAt: now, + auditIssues: [], + lengthWarnings: [], + }]); + + vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue( + createWriterOutput({ + chapterNumber: 2, + title: "回声", + content: "这次的正文完全不同,只是标题碰巧重复了。", + wordCount: "这次的正文完全不同,只是标题碰巧重复了。".length, + }), + ); + vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue( + createAuditResult({ + passed: true, + issues: [], + summary: "clean", + }), + ); + + try { + const result = await runner.writeNextChapter(bookId, 120); + const index = await state.loadChapterIndex(bookId); + + expect(result.title).toBe("回声(2)"); + expect(index.at(-1)?.title).toBe("回声(2)"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("defaults manual reviseDraft to spot-fix when mode is omitted", async () => { const { root, runner, state, bookId } = await createRunnerFixture(); const storyDir = join(state.bookDir(bookId), "story"); diff --git a/packages/core/src/__tests__/post-write-validator.test.ts b/packages/core/src/__tests__/post-write-validator.test.ts index 688c0a34..8e82d11c 100644 --- a/packages/core/src/__tests__/post-write-validator.test.ts +++ b/packages/core/src/__tests__/post-write-validator.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from "vitest"; import { + detectDuplicateTitle, detectParagraphLengthDrift, + detectParagraphShapeWarnings, validatePostWrite, type PostWriteViolation, } from "../agents/post-write-validator.js"; @@ -217,4 +219,29 @@ describe("validatePostWrite", () => { expect(findRule(result, "段落密度漂移")).toBeDefined(); expect(findRule(result, "段落密度漂移")?.severity).toBe("warning"); }); + + it("exposes paragraph shape warnings for final-stage reuse", () => { + const current = [ + "他停下。", + "先看门。", + "又看窗。", + "没人说话。", + "他这才进去。", + "屋里很冷。", + ].join("\n\n"); + + const result = detectParagraphShapeWarnings(current, "zh"); + expect(findRule(result, "段落过碎")).toBeDefined(); + expect(findRule(result, "连续短段")).toBeDefined(); + }); + + it("detects duplicate chapter titles", () => { + const result = detectDuplicateTitle("回声", ["旧路", "回声"]); + expect(findRule(result, "duplicate-title")).toBeDefined(); + }); + + it("detects near-duplicate chapter titles", () => { + const result = detectDuplicateTitle("Echo-2", ["Echo 2"]); + expect(findRule(result, "near-duplicate-title")).toBeDefined(); + }); }); diff --git a/packages/core/src/__tests__/state-manager.test.ts b/packages/core/src/__tests__/state-manager.test.ts index 4a50ca87..4cebef14 100644 --- a/packages/core/src/__tests__/state-manager.test.ts +++ b/packages/core/src/__tests__/state-manager.test.ts @@ -454,6 +454,52 @@ describe("StateManager", () => { const next = await manager.getNextChapterNumber(rwBookId); expect(next).toBe(2); }); + + it("rewrite restore drops poisoned live structured state when the snapshot only has markdown truth files", async () => { + const rwBookId = "rewrite-book-markdown-only"; + const chapDir = join(manager.bookDir(rwBookId), "chapters"); + const storyDir = join(manager.bookDir(rwBookId), "story"); + const stateDir = join(storyDir, "state"); + await mkdir(chapDir, { recursive: true }); + await mkdir(storyDir, { recursive: true }); + + await writeFile(join(chapDir, "0001_ch1.md"), "# Chapter 1\nContent 1", "utf-8"); + await writeFile(join(chapDir, "0002_ch2.md"), "# Chapter 2\nContent 2", "utf-8"); + await writeFile(join(chapDir, "0003_ch3.md"), "# Chapter 3\nContent 3", "utf-8"); + const mkEntry = (n: number) => ({ + number: n, title: `Ch${n}`, status: "approved" as const, wordCount: 100, + createdAt: "", updatedAt: "", auditIssues: [] as string[], lengthWarnings: [] as string[], + }); + const fullIndex = [mkEntry(1), mkEntry(2), mkEntry(3)]; + await manager.saveChapterIndex(rwBookId, fullIndex); + + await writeFile(join(storyDir, "current_state.md"), "State at ch1", "utf-8"); + await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch1", "utf-8"); + await manager.snapshotState(rwBookId, 1); + + await mkdir(stateDir, { recursive: true }); + await writeFile(join(stateDir, "manifest.json"), JSON.stringify({ + schemaVersion: 2, + language: "en", + lastAppliedChapter: 4, + projectionVersion: 1, + migrationWarnings: [], + }, null, 2), "utf-8"); + await writeFile(join(stateDir, "current_state.json"), JSON.stringify({ + chapter: 3, + facts: [], + }, null, 2), "utf-8"); + + const trimmed = fullIndex.filter((ch) => ch.number < 2); + await manager.saveChapterIndex(rwBookId, trimmed); + const { rm } = await import("node:fs/promises"); + await rm(join(chapDir, "0002_ch2.md")); + await rm(join(chapDir, "0003_ch3.md")); + await manager.restoreState(rwBookId, 1); + + const next = await manager.getNextChapterNumber(rwBookId); + expect(next).toBe(2); + }); }); // ------------------------------------------------------------------------- diff --git a/packages/core/src/agents/post-write-validator.ts b/packages/core/src/agents/post-write-validator.ts index 94910dd6..05d5325f 100644 --- a/packages/core/src/agents/post-write-validator.ts +++ b/packages/core/src/agents/post-write-validator.ts @@ -235,7 +235,7 @@ export function validatePostWrite( }); } - appendParagraphShapeWarnings(violations, content, "zh"); + violations.push(...detectParagraphShapeWarnings(content, "zh")); // 11. Book-level prohibitions // Short prohibitions (2-30 chars): exact substring match @@ -398,7 +398,7 @@ function validatePostWriteEnglish( }); } - appendParagraphShapeWarnings(violations, content, "en"); + violations.push(...detectParagraphShapeWarnings(content, "en")); // 2.5. Multi-character scene with almost no direct exchange const quotedLines = content.match(/"[^"]+"/g) ?? []; @@ -494,6 +494,15 @@ function appendParagraphShapeWarnings( } } +export function detectParagraphShapeWarnings( + content: string, + language: "zh" | "en" = "zh", +): ReadonlyArray { + const violations: PostWriteViolation[] = []; + appendParagraphShapeWarnings(violations, content, language); + return violations; +} + function analyzeParagraphShape(content: string, language: "zh" | "en"): ParagraphShape { const paragraphs = extractParagraphs(content); const shortThreshold = language === "en" ? 120 : 35; @@ -588,3 +597,35 @@ export function detectDuplicateTitle( return violations; } + +export function resolveDuplicateTitle( + newTitle: string, + existingTitles: ReadonlyArray, + language: "zh" | "en" = "zh", +): { + readonly title: string; + readonly issues: ReadonlyArray; +} { + const trimmed = newTitle.trim(); + if (!trimmed) { + return { title: newTitle, issues: [] }; + } + + const issues = detectDuplicateTitle(trimmed, existingTitles); + if (issues.length === 0) { + return { title: trimmed, issues: [] }; + } + + let counter = 2; + while (counter < 100) { + const candidate = language === "en" + ? `${trimmed} (${counter})` + : `${trimmed}(${counter})`; + if (detectDuplicateTitle(candidate, existingTitles).length === 0) { + return { title: candidate, issues }; + } + counter++; + } + + return { title: trimmed, issues }; +} diff --git a/packages/core/src/pipeline/agent.ts b/packages/core/src/pipeline/agent.ts index 48578099..714f77ad 100644 --- a/packages/core/src/pipeline/agent.ts +++ b/packages/core/src/pipeline/agent.ts @@ -370,12 +370,10 @@ export async function executeAgentTool( } case "write_draft": { - // Guard: write_draft only writes the next sequential chapter const bookId = args.bookId as string; - const nextNum = await state.getNextChapterNumber(bookId); - const index = await state.loadChapterIndex(bookId); - if (index.length > 0 && index[index.length - 1]!.number !== nextNum - 1) { - return JSON.stringify({ error: `write_draft 只能续写下一章(第${nextNum}章)。如果中间有空缺,请先用 get_book_status 确认状态。` }); + const writeGuardError = await getSequentialWriteGuardError(state, bookId, "write_draft"); + if (writeGuardError) { + return JSON.stringify({ error: writeGuardError }); } const result = await pipeline.writeDraft( bookId, @@ -494,10 +492,15 @@ export async function executeAgentTool( } case "write_full_pipeline": { + const bookId = args.bookId as string; + const writeGuardError = await getSequentialWriteGuardError(state, bookId, "write_full_pipeline"); + if (writeGuardError) { + return JSON.stringify({ error: writeGuardError }); + } const count = (args.count as number) ?? 1; const results = []; for (let i = 0; i < count; i++) { - const result = await pipeline.writeNextChapter(args.bookId as string); + const result = await pipeline.writeNextChapter(bookId); results.push(result); } return JSON.stringify(results); @@ -573,11 +576,8 @@ export async function executeAgentTool( } // Guard: block chapter progress manipulation via current_state.md - if (fileName === "current_state.md") { - const progressPatterns = /(?:lastAppliedChapter|chapter.*:\s*\d+|当前章.*[::]\s*\d+|进度.*[::]\s*\d+)/i; - if (progressPatterns.test(content)) { - return JSON.stringify({ error: "不允许通过 write_truth_file 修改 current_state.md 中的章节进度。章节进度由系统自动管理。" }); - } + if (fileName === "current_state.md" && containsProgressManipulation(content)) { + return JSON.stringify({ error: "不允许通过 write_truth_file 修改 current_state.md 中的章节进度。章节进度由系统自动管理。" }); } const { writeFile, mkdir } = await import("node:fs/promises"); @@ -610,5 +610,31 @@ async function executeTool( return executeAgentTool(pipeline, state, config, name, args); } +async function getSequentialWriteGuardError( + state: import("../state/manager.js").StateManager, + bookId: string, + toolName: "write_draft" | "write_full_pipeline", +): Promise { + const nextNum = await state.getNextChapterNumber(bookId); + const index = await state.loadChapterIndex(bookId); + if (index.length === 0) return null; + const lastIndexedChapter = index[index.length - 1]!.number; + if (lastIndexedChapter === nextNum - 1) return null; + return `${toolName} 只能续写下一章(当前应写第${nextNum}章)。检测到章节索引与运行时进度不一致,请先用 get_book_status 确认状态。`; +} + +function containsProgressManipulation(content: string): boolean { + const patterns = [ + /\blastAppliedChapter\b/i, + /\|\s*Current Chapter\s*\|\s*\d+\s*\|/i, + /\|\s*当前章(?:节)?\s*\|\s*\d+\s*\|/, + /\bCurrent Chapter\b\s*[::]\s*\d+/i, + /当前章(?:节)?\s*[::]\s*\d+/, + /\bprogress\b\s*[::]\s*\d+/i, + /进度\s*[::]\s*\d+/, + ]; + return patterns.some((pattern) => pattern.test(content)); +} + /** Export tool definitions so external systems can reference them. */ export { TOOLS as AGENT_TOOLS }; diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index 32145fac..fcb99adc 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -1100,16 +1100,53 @@ export class PipelineRunner { // 4. Save the final chapter and truth files from a single persistence source this.logStage(stageLanguage, { zh: "落盘最终章节", en: "persisting final chapter" }); this.logStage(stageLanguage, { zh: "生成最终真相文件", en: "rebuilding final truth files" }); - const persistenceOutput = await this.buildPersistenceOutput( + const chapterIndexBeforePersist = await this.state.loadChapterIndex(bookId); + const { resolveDuplicateTitle } = await import("../agents/post-write-validator.js"); + const initialTitleResolution = resolveDuplicateTitle( + output.title, + chapterIndexBeforePersist.map((chapter) => chapter.title), + pipelineLang, + ); + let persistenceOutput = await this.buildPersistenceOutput( bookId, book, bookDir, chapterNumber, - output, + initialTitleResolution.title === output.title + ? output + : { ...output, title: initialTitleResolution.title }, finalContent, lengthSpec.countingMode, reducedControlInput, ); + const finalTitleResolution = resolveDuplicateTitle( + persistenceOutput.title, + chapterIndexBeforePersist.map((chapter) => chapter.title), + pipelineLang, + ); + if (finalTitleResolution.title !== persistenceOutput.title) { + persistenceOutput = { + ...persistenceOutput, + title: finalTitleResolution.title, + }; + } + if (persistenceOutput.title !== output.title) { + const description = pipelineLang === "en" + ? `Duplicate chapter title "${output.title}" was auto-renamed to "${persistenceOutput.title}".` + : `章节标题"${output.title}"与已有标题重复,已自动改为"${persistenceOutput.title}"。`; + this.config.logger?.warn(`[title] ${description}`); + auditResult = { + ...auditResult, + issues: [...auditResult.issues, { + severity: "warning", + category: "title-dedup", + description, + suggestion: pipelineLang === "en" + ? "If the auto-renamed title is weak, revise the chapter title manually." + : "如果自动改名不理想,可以在后续手动修订章节标题。", + }], + }; + } const longSpanFatigue = await analyzeLongSpanFatigue({ bookDir, chapterNumber, @@ -1178,7 +1215,10 @@ export class PipelineRunner { // 4.2 Final paragraph shape check on persisted content (post-normalize, post-revise) { - const { detectParagraphLengthDrift } = await import("../agents/post-write-validator.js"); + const { + detectParagraphLengthDrift, + detectParagraphShapeWarnings, + } = await import("../agents/post-write-validator.js"); const chapDir = join(bookDir, "chapters"); const recentFiles = (await readdir(chapDir).catch(() => [] as string[])) .filter((f) => f.endsWith(".md") && /^\d{4}/.test(f)) @@ -1187,7 +1227,10 @@ export class PipelineRunner { const recentContent = (await Promise.all( recentFiles.map((f) => readFile(join(chapDir, f), "utf-8").catch(() => "")), )).join("\n\n"); - const paragraphIssues = detectParagraphLengthDrift(finalContent, recentContent, pipelineLang); + const paragraphIssues = [ + ...detectParagraphShapeWarnings(finalContent, pipelineLang), + ...detectParagraphLengthDrift(finalContent, recentContent, pipelineLang), + ]; if (paragraphIssues.length > 0) { for (const issue of paragraphIssues) { this.config.logger?.warn(`[paragraph] ${issue.description}`); @@ -1198,28 +1241,6 @@ export class PipelineRunner { severity: v.severity as "warning", category: "paragraph-shape", description: v.description, - suggestion: v.description, - }))], - }; - } - } - - // 4.3 Title dedup check - { - const { detectDuplicateTitle } = await import("../agents/post-write-validator.js"); - const chapterIndex = await this.state.loadChapterIndex(bookId); - const existingTitles = chapterIndex.map((ch) => ch.title); - const titleIssues = detectDuplicateTitle(persistenceOutput.title, existingTitles); - if (titleIssues.length > 0) { - for (const issue of titleIssues) { - this.config.logger?.warn(`[title] ${issue.description}`); - } - auditResult = { - ...auditResult, - issues: [...auditResult.issues, ...titleIssues.map((v) => ({ - severity: v.severity as "warning", - category: "title-dedup", - description: v.description, suggestion: v.suggestion, }))], }; diff --git a/packages/core/src/state/manager.ts b/packages/core/src/state/manager.ts index c1938b87..4bb35ea4 100644 --- a/packages/core/src/state/manager.ts +++ b/packages/core/src/state/manager.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile, mkdir, readdir, stat, unlink, open } from "node:fs/promises"; +import { readFile, writeFile, mkdir, readdir, rm, stat, unlink, open } from "node:fs/promises"; import { join } from "node:path"; import type { BookConfig } from "../models/book.js"; import type { ChapterMeta } from "../models/chapter.js"; @@ -303,11 +303,13 @@ export class StateManager { }), ); + const stateDir = this.stateDir(bookId); + let restoredStructuredState = false; try { const snapshotStateDir = join(snapshotDir, "state"); const stateFiles = await readdir(snapshotStateDir); if (stateFiles.length > 0) { - const stateDir = this.stateDir(bookId); + restoredStructuredState = true; await mkdir(stateDir, { recursive: true }); await Promise.all( stateFiles.map(async (fileName) => { @@ -319,6 +321,9 @@ export class StateManager { } catch { // snapshot structured state missing — skip } + if (!restoredStructuredState) { + await rm(stateDir, { recursive: true, force: true }); + } return true; } catch {