Răsfoiți Sursa

feat(cli): expose degraded-state recovery flow

Ma 7 luni în urmă
părinte
comite
e689a8391f

+ 15 - 0
packages/cli/src/__tests__/analytics.test.ts

@@ -40,6 +40,21 @@ describe("computeAnalytics", () => {
     expect(result.auditPassRate).toBe(67);
     expect(result.auditPassRate).toBe(67);
   });
   });
 
 
+  it("counts state-degraded chapters as audited but not passed", () => {
+    const chapters = [
+      { number: 1, status: "approved", wordCount: 3000, auditIssues: [] },
+      { number: 2, status: "state-degraded", wordCount: 2800, auditIssues: ["[warning] state validation drift"] },
+      { number: 3, status: "drafted", wordCount: 2600, auditIssues: [] },
+    ];
+    const result = computeAnalytics("book-state-degraded", chapters);
+    expect(result.auditPassRate).toBe(50);
+    expect(result.statusDistribution).toEqual({
+      approved: 1,
+      "state-degraded": 1,
+      drafted: 1,
+    });
+  });
+
   it("extracts issue categories from formatted strings", () => {
   it("extracts issue categories from formatted strings", () => {
     const chapters = [
     const chapters = [
       {
       {

+ 44 - 0
packages/cli/src/__tests__/cli-integration.test.ts

@@ -318,6 +318,50 @@ describe("CLI integration", () => {
       expect(output).not.toContain("7字");
       expect(output).not.toContain("7字");
     });
     });
 
 
+    it("shows degraded chapter counts and issues explicitly", async () => {
+      const bookDir = join(projectDir, "books", "degraded-status");
+      await mkdir(join(bookDir, "chapters"), { recursive: true });
+      await writeFile(
+        join(bookDir, "book.json"),
+        JSON.stringify({
+          id: "degraded-status",
+          title: "Degraded Status Book",
+          platform: "other",
+          genre: "other",
+          status: "active",
+          targetChapters: 10,
+          chapterWordCount: 2200,
+          createdAt: "2026-04-01T00:00:00.000Z",
+          updatedAt: "2026-04-01T00:00:00.000Z",
+        }, null, 2),
+        "utf-8",
+      );
+      await writeFile(
+        join(bookDir, "chapters", "index.json"),
+        JSON.stringify([
+          {
+            number: 1,
+            title: "Broken State",
+            status: "state-degraded",
+            wordCount: 1800,
+            createdAt: "2026-04-01T00:00:00.000Z",
+            updatedAt: "2026-04-01T00:00:00.000Z",
+            auditIssues: ["[warning] state validation still failed after retry"],
+            lengthWarnings: [],
+          },
+        ], null, 2),
+        "utf-8",
+      );
+
+      const output = run(["status", "degraded-status", "--chapters"]);
+      expect(output).toContain("Degraded: 1");
+      expect(output).toContain('Ch.1 "Broken State" | 1800字 | state-degraded');
+      expect(output).toContain("[warning] state validation still failed after retry");
+
+      const json = JSON.parse(run(["status", "degraded-status", "--json"]));
+      expect(json.books[0]?.degraded).toBe(1);
+    });
+
     it("shows a migration hint for legacy pre-v0.6 books", async () => {
     it("shows a migration hint for legacy pre-v0.6 books", async () => {
       const bookDir = join(projectDir, "books", "legacy-status-hint");
       const bookDir = join(projectDir, "books", "legacy-status-hint");
       const storyDir = join(bookDir, "story");
       const storyDir = join(bookDir, "story");

+ 5 - 1
packages/cli/src/commands/daemon.ts

@@ -50,7 +50,11 @@ export const upCommand = new Command("up")
         cooldownAfterChapterMs: config.daemon.cooldownAfterChapterMs,
         cooldownAfterChapterMs: config.daemon.cooldownAfterChapterMs,
         maxChaptersPerDay: config.daemon.maxChaptersPerDay,
         maxChaptersPerDay: config.daemon.maxChaptersPerDay,
         onChapterComplete: (bookId, chapter, status) => {
         onChapterComplete: (bookId, chapter, status) => {
-          const icon = status === "ready-for-review" ? "+" : "!";
+          const icon = status === "ready-for-review"
+            ? "+"
+            : status === "state-degraded"
+              ? "x"
+              : "!";
           log(`  [${icon}] ${bookId} Ch.${chapter} — ${status}`);
           log(`  [${icon}] ${bookId} Ch.${chapter} — ${status}`);
         },
         },
         onError: (bookId, error) => {
         onError: (bookId, error) => {

+ 23 - 5
packages/cli/src/commands/status.ts

@@ -44,6 +44,9 @@ export const statusCommand = new Command("status")
         const failed = index.filter(
         const failed = index.filter(
           (ch) => ch.status === "audit-failed",
           (ch) => ch.status === "audit-failed",
         ).length;
         ).length;
+        const degraded = index.filter(
+          (ch) => ch.status === "state-degraded",
+        ).length;
         const totalWords = index.reduce((sum, ch) => sum + ch.wordCount, 0);
         const totalWords = index.reduce((sum, ch) => sum + ch.wordCount, 0);
         const avgWords = index.length > 0 ? Math.round(totalWords / index.length) : 0;
         const avgWords = index.length > 0 ? Math.round(totalWords / index.length) : 0;
 
 
@@ -60,6 +63,7 @@ export const statusCommand = new Command("status")
           approved,
           approved,
           pending,
           pending,
           failed,
           failed,
+          degraded,
           ...(migrationHint ? { migrationHint } : {}),
           ...(migrationHint ? { migrationHint } : {}),
           ...(opts.chapters ? {
           ...(opts.chapters ? {
             chapterList: index.map((ch) => ({
             chapterList: index.map((ch) => ({
@@ -67,7 +71,9 @@ export const statusCommand = new Command("status")
               title: ch.title,
               title: ch.title,
               status: ch.status,
               status: ch.status,
               wordCount: ch.wordCount,
               wordCount: ch.wordCount,
-              ...(ch.status === "audit-failed" ? { issues: ch.auditIssues } : {}),
+              ...(ch.status === "audit-failed" || ch.status === "state-degraded"
+                ? { issues: ch.auditIssues }
+                : {}),
             })),
             })),
           } : {}),
           } : {}),
         });
         });
@@ -78,7 +84,7 @@ export const statusCommand = new Command("status")
           log(`    Platform: ${book.platform} | Genre: ${book.genre}`);
           log(`    Platform: ${book.platform} | Genre: ${book.genre}`);
           log(`    Chapters: ${persistedChapterCount} / ${book.targetChapters}`);
           log(`    Chapters: ${persistedChapterCount} / ${book.targetChapters}`);
           log(`    Words: ${totalWords.toLocaleString()} (avg ${avgWords}/ch)`);
           log(`    Words: ${totalWords.toLocaleString()} (avg ${avgWords}/ch)`);
-          log(`    Approved: ${approved} | Pending: ${pending} | Failed: ${failed}`);
+          log(`    Approved: ${approved} | Pending: ${pending} | Failed: ${failed} | Degraded: ${degraded}`);
           if (migrationHint) {
           if (migrationHint) {
             log(`    Migration: ${migrationHint}`);
             log(`    Migration: ${migrationHint}`);
           }
           }
@@ -86,9 +92,15 @@ export const statusCommand = new Command("status")
           if (opts.chapters && index.length > 0) {
           if (opts.chapters && index.length > 0) {
             log("");
             log("");
             for (const ch of index) {
             for (const ch of index) {
-              const icon = ch.status === "approved" ? "+" : ch.status === "audit-failed" ? "!" : "~";
+              const icon = ch.status === "approved"
+                ? "+"
+                : ch.status === "audit-failed"
+                  ? "!"
+                  : ch.status === "state-degraded"
+                    ? "x"
+                    : "~";
               log(`    [${icon}] Ch.${ch.number} "${ch.title}" | ${formatLengthCount(ch.wordCount, countingMode)} | ${ch.status}`);
               log(`    [${icon}] Ch.${ch.number} "${ch.title}" | ${formatLengthCount(ch.wordCount, countingMode)} | ${ch.status}`);
-              if (ch.status === "audit-failed" && ch.auditIssues.length > 0) {
+              if ((ch.status === "audit-failed" || ch.status === "state-degraded") && ch.auditIssues.length > 0) {
                 const criticals = ch.auditIssues.filter((i: string) => i.startsWith("[critical]"));
                 const criticals = ch.auditIssues.filter((i: string) => i.startsWith("[critical]"));
                 const warnings = ch.auditIssues.filter((i: string) => i.startsWith("[warning]"));
                 const warnings = ch.auditIssues.filter((i: string) => i.startsWith("[warning]"));
                 if (criticals.length > 0) {
                 if (criticals.length > 0) {
@@ -97,7 +109,13 @@ export const statusCommand = new Command("status")
                   }
                   }
                 }
                 }
                 if (warnings.length > 0) {
                 if (warnings.length > 0) {
-                  log(`        + ${warnings.length} warning(s)`);
+                  if (ch.status === "state-degraded") {
+                    for (const issue of warnings) {
+                      log(`        ${issue}`);
+                    }
+                  } else {
+                    log(`        + ${warnings.length} warning(s)`);
+                  }
                 }
                 }
               }
               }
             }
             }

+ 64 - 0
packages/cli/src/commands/write.ts

@@ -59,6 +59,15 @@ writeCommand
           }
           }
           log("");
           log("");
         }
         }
+
+        if (result.status === "state-degraded") {
+          if (!opts.json) {
+            log(language === "en"
+              ? "State repair required before continuing. Stopping batch."
+              : "需要先修复 state,已停止后续连写。");
+          }
+          break;
+        }
       }
       }
 
 
       if (opts.json) {
       if (opts.json) {
@@ -189,3 +198,58 @@ writeCommand
       process.exit(1);
       process.exit(1);
     }
     }
   });
   });
+
+writeCommand
+  .command("repair-state")
+  .description("Rebuild truth files for a persisted state-degraded chapter without rewriting body text")
+  .argument("<args...>", "Book ID (optional) and chapter number")
+  .option("--json", "Output JSON")
+  .action(async (args: ReadonlyArray<string>, opts) => {
+    try {
+      const root = findProjectRoot();
+
+      let bookId: string;
+      let chapter: number;
+      if (args.length === 1) {
+        chapter = parseInt(args[0]!, 10);
+        if (isNaN(chapter)) throw new Error(`Expected chapter number, got "${args[0]}"`);
+        bookId = await resolveBookId(undefined, root);
+      } else if (args.length === 2) {
+        chapter = parseInt(args[1]!, 10);
+        if (isNaN(chapter)) throw new Error(`Expected chapter number, got "${args[1]}"`);
+        bookId = await resolveBookId(args[0], root);
+      } else {
+        throw new Error("Usage: inkos write repair-state [book-id] <chapter>");
+      }
+
+      const state = new StateManager(root);
+      const book = await state.loadBookConfig(bookId);
+      const language = resolveCliLanguage(book.language);
+      const config = await loadConfig();
+      const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
+      const result = await pipeline.repairChapterState(bookId, chapter);
+
+      if (opts.json) {
+        log(JSON.stringify(result, null, 2));
+      } else {
+        for (const line of formatWriteNextResultLines(language, {
+          chapterNumber: result.chapterNumber,
+          title: result.title,
+          wordCount: result.wordCount,
+          auditPassed: result.auditResult.passed,
+          revised: result.revised,
+          status: result.status,
+          issues: result.auditResult.issues,
+        })) {
+          log(line);
+        }
+      }
+    } catch (e) {
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Failed to repair chapter state: ${e}`);
+      }
+      process.exit(1);
+    }
+  });