From 6a9678d8d2b9cdaa8ec0496bbb4719bfe716215b Mon Sep 17 00:00:00 2001 From: chenkang Date: Thu, 2 Apr 2026 10:48:54 +0800 Subject: [PATCH] fix(cli): keep rewrite pinned to requested chapter Fail rewrite early when the rollback snapshot is missing or resolves to the wrong next chapter, and clear stale optional truth files during state restore so rewrite cannot silently advance to the next chapter. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/src/__tests__/cli-integration.test.ts | 41 +++++++++++++++++++ packages/cli/src/commands/write.ts | 20 ++++++--- .../core/src/__tests__/state-manager.test.ts | 16 ++++++++ packages/core/src/state/manager.ts | 5 ++- 4 files changed, 74 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/__tests__/cli-integration.test.ts b/packages/cli/src/__tests__/cli-integration.test.ts index b6731a4b..0f301b04 100644 --- a/packages/cli/src/__tests__/cli-integration.test.ts +++ b/packages/cli/src/__tests__/cli-integration.test.ts @@ -546,6 +546,46 @@ describe("CLI integration", () => { expect(`${stdout}\n${stderr}`).toContain("legacy format"); }); + it("fails rewrite before deleting chapters when the rollback snapshot is missing", async () => { + const bookId = "rewrite-missing-snapshot"; + const bookDir = join(projectDir, "books", bookId); + const storyDir = join(bookDir, "story"); + const chaptersDir = join(bookDir, "chapters"); + + await mkdir(chaptersDir, { recursive: true }); + await mkdir(storyDir, { recursive: true }); + await writeFile( + join(bookDir, "book.json"), + JSON.stringify({ + id: bookId, + title: "Rewrite Missing Snapshot", + 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, "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: [] }, + ], 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("missing snapshot for chapter 1"); + await expect(readFile(join(chaptersDir, "0002_ch2.md"), "utf-8")).resolves.toContain("Content 2"); + }); + 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"; @@ -606,6 +646,7 @@ describe("CLI integration", () => { }); expect(exitCode).not.toBe(0); expect(`${stdout}\n${stderr}`).toContain("Regenerating chapter 2"); + expect(`${stdout}\n${stderr}`).not.toContain("resolved to 3"); const next = await state.getNextChapterNumber(bookId); expect(next).toBe(2); diff --git a/packages/cli/src/commands/write.ts b/packages/cli/src/commands/write.ts index 83a77c1d..6aa9eae9 100644 --- a/packages/cli/src/commands/write.ts +++ b/packages/cli/src/commands/write.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; import { PipelineRunner, StateManager } from "@actalk/inkos-core"; -import { readdir, unlink } from "node:fs/promises"; +import { readdir, stat, unlink } from "node:fs/promises"; import { join } from "node:path"; import { createInterface } from "node:readline"; import { loadConfig, buildPipelineConfig, findProjectRoot, getLegacyMigrationHint, resolveContext, resolveBookId, log, logError } from "../utils.js"; @@ -116,6 +116,11 @@ writeCommand const state = new StateManager(root); const bookDir = state.bookDir(bookId); const chaptersDir = join(bookDir, "chapters"); + const restoreFrom = chapter - 1; + const restoreSnapshotDir = join(bookDir, "story", "snapshots", String(restoreFrom)); + await stat(restoreSnapshotDir).catch(() => { + throw new Error(`Cannot rewrite chapter ${chapter}: missing snapshot for chapter ${restoreFrom}`); + }); const migrationHint = await getLegacyMigrationHint(root, bookId); if (migrationHint && !opts.json) { log(`[migration] ${migrationHint}`); @@ -146,12 +151,15 @@ writeCommand } // Restore state to previous chapter's end-state (chapter 1 uses snapshot-0 from initBook) - const restoreFrom = chapter - 1; const restored = await state.restoreState(bookId, restoreFrom); - if (restored) { - if (!opts.json) log(`State restored from chapter ${restoreFrom} snapshot.`); - } else { - if (!opts.json) log(`Warning: no snapshot for chapter ${restoreFrom}. Using current state.`); + if (!restored) { + throw new Error(`Cannot rewrite chapter ${chapter}: failed to restore snapshot for chapter ${restoreFrom}`); + } + if (!opts.json) log(`State restored from chapter ${restoreFrom} snapshot.`); + + const nextChapter = await state.getNextChapterNumber(bookId); + if (nextChapter !== chapter) { + throw new Error(`Cannot rewrite chapter ${chapter}: expected next chapter to be ${chapter}, but resolved to ${nextChapter}`); } if (!opts.json) log(`Regenerating chapter ${chapter}...`); diff --git a/packages/core/src/__tests__/state-manager.test.ts b/packages/core/src/__tests__/state-manager.test.ts index 4cebef14..a66849fc 100644 --- a/packages/core/src/__tests__/state-manager.test.ts +++ b/packages/core/src/__tests__/state-manager.test.ts @@ -379,6 +379,22 @@ describe("StateManager", () => { expect(ledger).toBe("# Ledger at ch1"); }); + it("removes live optional truth files that are absent from the snapshot", async () => { + const storyDir = join(manager.bookDir(bookId), "story"); + await rm(join(storyDir, "particle_ledger.md")); + await manager.snapshotState(bookId, 1); + + await writeFile( + join(storyDir, "particle_ledger.md"), + "# Ledger added after snapshot", + "utf-8", + ); + + const restored = await manager.restoreState(bookId, 1); + expect(restored).toBe(true); + await expect(stat(join(storyDir, "particle_ledger.md"))).rejects.toThrow(); + }); + it("restores structured runtime state files from snapshot/state", async () => { const stateDir = manager.stateDir(bookId); await mkdir(stateDir, { recursive: true }); diff --git a/packages/core/src/state/manager.ts b/packages/core/src/state/manager.ts index 702f3ef5..2085ba73 100644 --- a/packages/core/src/state/manager.ts +++ b/packages/core/src/state/manager.ts @@ -356,11 +356,12 @@ export class StateManager { await Promise.all( optionalFiles.map(async (f) => { + const targetPath = join(storyDir, f); try { const content = await readFile(join(snapshotDir, f), "utf-8"); - await writeFile(join(storyDir, f), content, "utf-8"); + await writeFile(targetPath, content, "utf-8"); } catch { - // Optional file missing — skip + await rm(targetPath, { force: true }); } }), );