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 }); } }), );