Răsfoiți Sursa

feat(review): reject rolls back state and discards downstream chapters

Adds StateManager.rollbackToChapter(bookId, targetChapter) which:
- Restores the snapshot at targetChapter
- Deletes chapter files, snapshots, and runtime artifacts beyond it
- Updates the chapter index to remove discarded entries

review reject now calls rollbackToChapter(chapterNum - 1) by default,
undoing the rejected chapter's state mutations and any subsequent chapters
that depended on them.  --keep-subsequent preserves legacy behavior.

review approve now re-snapshots the approved chapter to ensure a clean
rollback target exists.

This prevents bad drafts from permanently polluting truth files — the
core "audit-then-commit" invariant that was missing.
Ma 7 luni în urmă
părinte
comite
5f187f957c

+ 43 - 13
packages/cli/src/commands/review.ts

@@ -106,7 +106,7 @@ function parseBookAndChapter(
 
 reviewCommand
   .command("approve")
-  .description("Approve a chapter: approve [book-id] <chapter>")
+  .description("Approve a chapter and commit its state: approve [book-id] <chapter>")
   .argument("<args...>", "Book ID (optional) and chapter number")
   .option("--json", "Output JSON")
   .action(async (args: ReadonlyArray<string>, opts) => {
@@ -129,10 +129,14 @@ reviewCommand
       };
       await state.saveChapterIndex(bookId, index);
 
+      // Ensure a snapshot exists at the approved chapter so reject can
+      // roll back to this committed state later.
+      await state.snapshotState(bookId, chapterNum);
+
       if (opts.json) {
         log(JSON.stringify({ bookId, chapter: chapterNum, status: "approved" }));
       } else {
-        log(`Chapter ${chapterNum} approved.`);
+        log(`Chapter ${chapterNum} approved (state committed).`);
       }
     } catch (e) {
       if (opts.json) {
@@ -186,9 +190,10 @@ reviewCommand
 
 reviewCommand
   .command("reject")
-  .description("Reject a chapter: reject [book-id] <chapter>")
+  .description("Reject a chapter and roll back state: reject [book-id] <chapter>")
   .argument("<args...>", "Book ID (optional) and chapter number")
   .option("--reason <reason>", "Rejection reason")
+  .option("--keep-subsequent", "Only reject this chapter, do not discard subsequent chapters")
   .option("--json", "Output JSON")
   .action(async (args: ReadonlyArray<string>, opts) => {
     try {
@@ -197,24 +202,49 @@ reviewCommand
       const bookId = await resolveBookId(bookIdArg, root);
 
       const state = new StateManager(root);
-      const index = [...(await state.loadChapterIndex(bookId))];
+      const index = await state.loadChapterIndex(bookId);
       const idx = index.findIndex((ch) => ch.number === chapterNum);
       if (idx === -1) {
         throw new Error(`Chapter ${chapterNum} not found in "${bookId}"`);
       }
 
-      index[idx] = {
-        ...index[idx]!,
-        status: "rejected",
-        reviewNote: opts.reason ?? "Rejected without reason",
-        updatedAt: new Date().toISOString(),
-      };
-      await state.saveChapterIndex(bookId, index);
+      if (opts.keepSubsequent) {
+        // Legacy behavior: only mark as rejected, no state rollback
+        const updated = [...index];
+        updated[idx] = {
+          ...updated[idx]!,
+          status: "rejected",
+          reviewNote: opts.reason ?? "Rejected without reason",
+          updatedAt: new Date().toISOString(),
+        };
+        await state.saveChapterIndex(bookId, updated);
+
+        if (opts.json) {
+          log(JSON.stringify({ bookId, chapter: chapterNum, status: "rejected", discarded: [] }));
+        } else {
+          log(`Chapter ${chapterNum} rejected (state not rolled back).`);
+        }
+        return;
+      }
+
+      // Default: roll back state to before the rejected chapter and discard
+      // it along with all subsequent chapters that depend on its state.
+      const rollbackTarget = chapterNum - 1;
+      const discarded = await state.rollbackToChapter(bookId, rollbackTarget);
 
       if (opts.json) {
-        log(JSON.stringify({ bookId, chapter: chapterNum, status: "rejected" }));
+        log(JSON.stringify({
+          bookId,
+          chapter: chapterNum,
+          status: "rejected",
+          rolledBackTo: rollbackTarget,
+          discarded,
+        }));
       } else {
-        log(`Chapter ${chapterNum} rejected.`);
+        log(`Chapter ${chapterNum} rejected. State rolled back to chapter ${rollbackTarget}.`);
+        if (discarded.length > 1) {
+          log(`  Also discarded ${discarded.length - 1} subsequent chapter(s): ${discarded.filter((n) => n !== chapterNum).join(", ")}`);
+        }
       }
     } catch (e) {
       if (opts.json) {

+ 122 - 0
packages/core/src/__tests__/state-manager.test.ts

@@ -963,4 +963,126 @@ describe("StateManager", () => {
       expect(hooks.hooks.map((hook) => hook.hookId)).toEqual(["H009"]);
     });
   });
+
+  // -------------------------------------------------------------------------
+  // rollbackToChapter — reject a chapter and discard downstream state
+  // -------------------------------------------------------------------------
+
+  describe("rollbackToChapter", () => {
+    const bookId = "rollback-book";
+
+    async function setupRollbackBook(): Promise<void> {
+      await manager.saveBookConfig(bookId, {
+        id: bookId,
+        title: "Rollback Test",
+        platform: "tomato",
+        genre: "xuanhuan",
+        status: "active",
+        targetChapters: 10,
+        chapterWordCount: 3000,
+        createdAt: "2026-03-31T00:00:00Z",
+        updatedAt: "2026-03-31T00:00:00Z",
+      });
+
+      const bookDir = manager.bookDir(bookId);
+      const storyDir = join(bookDir, "story");
+      const chaptersDir = join(bookDir, "chapters");
+      const runtimeDir = join(storyDir, "runtime");
+      await mkdir(runtimeDir, { recursive: true });
+      await mkdir(chaptersDir, { recursive: true });
+
+      // Write initial state (chapter 0 baseline)
+      await writeFile(join(storyDir, "current_state.md"), "# State\n\n- Initial state.\n", "utf-8");
+      await writeFile(join(storyDir, "pending_hooks.md"), "# Hooks\n\n- hook-1\n", "utf-8");
+      await writeFile(join(storyDir, "chapter_summaries.md"), "# Summaries\n", "utf-8");
+      await manager.snapshotState(bookId, 0);
+
+      // Write chapter 1 state + file
+      await writeFile(join(storyDir, "current_state.md"), "# State\n\n- After chapter 1.\n", "utf-8");
+      await writeFile(join(storyDir, "pending_hooks.md"), "# Hooks\n\n- hook-1\n- hook-2\n", "utf-8");
+      await writeFile(join(storyDir, "chapter_summaries.md"), "# Summaries\n\n| 1 | Title 1 |\n", "utf-8");
+      await writeFile(join(chaptersDir, "0001_Title_One.md"), "# Chapter 1\n\nContent 1.", "utf-8");
+      await manager.snapshotState(bookId, 1);
+
+      // Write chapter 2 state + file
+      await writeFile(join(storyDir, "current_state.md"), "# State\n\n- After chapter 2.\n", "utf-8");
+      await writeFile(join(storyDir, "pending_hooks.md"), "# Hooks\n\n- hook-1\n- hook-2\n- hook-3\n", "utf-8");
+      await writeFile(join(storyDir, "chapter_summaries.md"), "# Summaries\n\n| 1 | Title 1 |\n| 2 | Title 2 |\n", "utf-8");
+      await writeFile(join(chaptersDir, "0002_Title_Two.md"), "# Chapter 2\n\nContent 2.", "utf-8");
+      await writeFile(join(runtimeDir, "chapter-002.intent.md"), "intent 2", "utf-8");
+      await manager.snapshotState(bookId, 2);
+
+      // Write chapter 3 state + file
+      await writeFile(join(storyDir, "current_state.md"), "# State\n\n- After chapter 3.\n", "utf-8");
+      await writeFile(join(storyDir, "pending_hooks.md"), "# Hooks\n\n- hook-1\n- hook-2\n- hook-3\n- hook-4\n", "utf-8");
+      await writeFile(join(storyDir, "chapter_summaries.md"), "# Summaries\n\n| 1 | Title 1 |\n| 2 | Title 2 |\n| 3 | Title 3 |\n", "utf-8");
+      await writeFile(join(chaptersDir, "0003_Title_Three.md"), "# Chapter 3\n\nContent 3.", "utf-8");
+      await writeFile(join(runtimeDir, "chapter-003.intent.md"), "intent 3", "utf-8");
+      await manager.snapshotState(bookId, 3);
+
+      // Save index with all 3 chapters
+      const now = "2026-03-31T00:00:00Z";
+      await manager.saveChapterIndex(bookId, [
+        { number: 1, title: "Title One", status: "approved", wordCount: 100, createdAt: now, updatedAt: now, auditIssues: [], lengthWarnings: [] },
+        { number: 2, title: "Title Two", status: "ready-for-review", wordCount: 100, createdAt: now, updatedAt: now, auditIssues: [], lengthWarnings: [] },
+        { number: 3, title: "Title Three", status: "audit-failed", wordCount: 100, createdAt: now, updatedAt: now, auditIssues: ["pacing"], lengthWarnings: [] },
+      ]);
+    }
+
+    it("restores state to the target chapter and removes subsequent chapters", async () => {
+      await setupRollbackBook();
+
+      const discarded = await manager.rollbackToChapter(bookId, 1);
+
+      expect(discarded).toEqual([2, 3]);
+
+      // State should be restored to chapter 1 snapshot
+      const bookDir = manager.bookDir(bookId);
+      const state = await readFile(join(bookDir, "story", "current_state.md"), "utf-8");
+      expect(state).toContain("After chapter 1");
+      expect(state).not.toContain("After chapter 3");
+
+      const hooks = await readFile(join(bookDir, "story", "pending_hooks.md"), "utf-8");
+      expect(hooks).toContain("hook-2");
+      expect(hooks).not.toContain("hook-4");
+
+      // Chapter index should only have chapter 1
+      const index = await manager.loadChapterIndex(bookId);
+      expect(index).toHaveLength(1);
+      expect(index[0]!.number).toBe(1);
+      expect(index[0]!.status).toBe("approved");
+
+      // Chapter files for 2 and 3 should be deleted
+      const chaptersDir = join(bookDir, "chapters");
+      const { readdir: rd } = await import("node:fs/promises");
+      const remaining = (await rd(chaptersDir)).filter((f) => f.endsWith(".md"));
+      expect(remaining).toEqual(["0001_Title_One.md"]);
+
+      // Snapshots for 2 and 3 should be deleted
+      const snapshotsDir = join(bookDir, "story", "snapshots");
+      const snapshots = await rd(snapshotsDir);
+      expect(snapshots.sort()).toEqual(["0", "1"]);
+    });
+
+    it("rolls back to chapter 0 (initial state) when rejecting chapter 1", async () => {
+      await setupRollbackBook();
+
+      const discarded = await manager.rollbackToChapter(bookId, 0);
+
+      expect(discarded).toEqual([1, 2, 3]);
+
+      const bookDir = manager.bookDir(bookId);
+      const state = await readFile(join(bookDir, "story", "current_state.md"), "utf-8");
+      expect(state).toContain("Initial state");
+
+      const index = await manager.loadChapterIndex(bookId);
+      expect(index).toHaveLength(0);
+    });
+
+    it("throws when the target snapshot does not exist", async () => {
+      await setupRollbackBook();
+
+      await expect(manager.rollbackToChapter(bookId, 99)).rejects.toThrow("Cannot restore snapshot");
+    });
+  });
 });

+ 96 - 0
packages/core/src/state/manager.ts

@@ -393,6 +393,102 @@ export class StateManager {
     }
   }
 
+  /**
+   * Roll back state to the snapshot at `targetChapter`, removing all chapters
+   * after it and their associated files (chapter markdown, snapshots, runtime).
+   * Used by review reject to undo a bad chapter and everything that followed.
+   *
+   * Returns the list of chapter numbers that were discarded.
+   */
+  async rollbackToChapter(
+    bookId: string,
+    targetChapter: number,
+  ): Promise<ReadonlyArray<number>> {
+    const restored = await this.restoreState(bookId, targetChapter);
+    if (!restored) {
+      throw new Error(`Cannot restore snapshot for chapter ${targetChapter} in "${bookId}"`);
+    }
+
+    const bookDir = this.bookDir(bookId);
+    const chaptersDir = join(bookDir, "chapters");
+    const index = await this.loadChapterIndex(bookId);
+
+    const kept: ChapterMeta[] = [];
+    const discarded: number[] = [];
+
+    for (const entry of index) {
+      if (entry.number <= targetChapter) {
+        kept.push(entry);
+      } else {
+        discarded.push(entry.number);
+      }
+    }
+
+    // Delete chapter markdown files for discarded chapters
+    try {
+      const files = await readdir(chaptersDir);
+      for (const file of files) {
+        const match = file.match(/^(\d+)_.*\.md$/);
+        if (!match) continue;
+        const num = parseInt(match[1]!, 10);
+        if (num > targetChapter) {
+          await unlink(join(chaptersDir, file)).catch(() => {});
+        }
+      }
+    } catch {
+      // chapters directory missing
+    }
+
+    // Delete snapshots for discarded chapters
+    const snapshotsDir = join(bookDir, "story", "snapshots");
+    try {
+      const snapshots = await readdir(snapshotsDir);
+      for (const snap of snapshots) {
+        const num = parseInt(snap, 10);
+        if (Number.isFinite(num) && num > targetChapter) {
+          await rm(join(snapshotsDir, snap), { recursive: true, force: true });
+        }
+      }
+    } catch {
+      // snapshots directory missing
+    }
+
+    // Delete runtime artifacts for discarded chapters
+    const runtimeDir = join(bookDir, "story", "runtime");
+    try {
+      const runtimeFiles = await readdir(runtimeDir);
+      for (const file of runtimeFiles) {
+        const match = file.match(/^chapter-(\d+)\./);
+        if (!match) continue;
+        const num = parseInt(match[1]!, 10);
+        if (num > targetChapter) {
+          await unlink(join(runtimeDir, file)).catch(() => {});
+        }
+      }
+    } catch {
+      // runtime directory missing
+    }
+
+    // Also check story/drafts/ for discarded chapter files
+    const draftsDir = join(bookDir, "story", "drafts");
+    try {
+      const draftFiles = await readdir(draftsDir);
+      for (const file of draftFiles) {
+        const match = file.match(/^(\d+)_.*\.md$/);
+        if (!match) continue;
+        const num = parseInt(match[1]!, 10);
+        if (num > targetChapter) {
+          await unlink(join(draftsDir, file)).catch(() => {});
+        }
+      }
+    } catch {
+      // drafts directory missing
+    }
+
+    await this.saveChapterIndex(bookId, kept);
+    return discarded;
+  }
+
   private async writeIfMissing(path: string, content: string): Promise<void> {
     try {
       await stat(path);