Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/cli/src/__tests__/cli-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
20 changes: 14 additions & 6 deletions packages/cli/src/commands/write.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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}...`);
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/__tests__/state-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/state/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}),
);
Expand Down