Explorar o código

fix(state): anchor chapter progress to contiguous durable chapters

Ma hai 7 meses
pai
achega
2e64055927

+ 150 - 2
packages/core/src/__tests__/state-manager.test.ts

@@ -114,7 +114,7 @@ describe("StateManager", () => {
       expect(next).toBe(1);
       expect(next).toBe(1);
     });
     });
 
 
-    it("returns max+1 when chapters exist", async () => {
+    it("returns the first missing chapter when the chapter index has gaps", async () => {
       const chapters: ReadonlyArray<ChapterMeta> = [
       const chapters: ReadonlyArray<ChapterMeta> = [
         {
         {
           number: 1,
           number: 1,
@@ -149,7 +149,7 @@ describe("StateManager", () => {
       ];
       ];
       await manager.saveChapterIndex("book-x", chapters);
       await manager.saveChapterIndex("book-x", chapters);
       const next = await manager.getNextChapterNumber("book-x");
       const next = await manager.getNextChapterNumber("book-x");
-      expect(next).toBe(6);
+      expect(next).toBe(2);
     });
     });
 
 
     it("returns 2 when only chapter 1 exists", async () => {
     it("returns 2 when only chapter 1 exists", async () => {
@@ -224,6 +224,99 @@ describe("StateManager", () => {
 
 
       expect(next).toBe(4);
       expect(next).toBe(4);
     });
     });
+
+    it("ignores non-contiguous poisoned chapter numbers when calculating the next chapter", async () => {
+      const bookId = "poisoned-next-chapter-book";
+      const bookDir = manager.bookDir(bookId);
+      const chaptersDir = join(bookDir, "chapters");
+      const storyDir = join(bookDir, "story");
+      const stateDir = join(storyDir, "state");
+      await mkdir(chaptersDir, { recursive: true });
+      await mkdir(stateDir, { recursive: true });
+
+      const indexedChapters: ReadonlyArray<ChapterMeta> = [
+        ...Array.from({ length: 12 }, (_, index) => ({
+          number: index + 1,
+          title: `Ch${index + 1}`,
+          status: "ready-for-review" as const,
+          wordCount: 3000,
+          createdAt: "2026-01-01T00:00:00Z",
+          updatedAt: "2026-01-01T00:00:00Z",
+          auditIssues: [],
+          lengthWarnings: [],
+        })),
+        {
+          number: 142,
+          title: "Poisoned Ch142",
+          status: "audit-failed",
+          wordCount: 3200,
+          createdAt: "2026-01-13T00:00:00Z",
+          updatedAt: "2026-01-13T00:00:00Z",
+          auditIssues: [],
+          lengthWarnings: [],
+        },
+      ];
+
+      await manager.saveChapterIndex(bookId, indexedChapters);
+      await Promise.all([
+        ...Array.from({ length: 12 }, (_, index) => writeFile(
+          join(chaptersDir, `${String(index + 1).padStart(4, "0")}_Ch${index + 1}.md`),
+          `# Chapter ${index + 1}\n\nStable body.`,
+          "utf-8",
+        )),
+        writeFile(
+          join(chaptersDir, "0142_Poisoned.md"),
+          "# Chapter 142\n\nPoisoned body.",
+          "utf-8",
+        ),
+        writeFile(
+          join(storyDir, "current_state.md"),
+          [
+            "# Current State",
+            "",
+            "| Field | Value |",
+            "| --- | --- |",
+            "| Current Chapter | 12 |",
+            "| Current Goal | Enter the next true chapter cleanly |",
+            "",
+          ].join("\n"),
+          "utf-8",
+        ),
+        writeFile(
+          join(storyDir, "pending_hooks.md"),
+          [
+            "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
+            "| --- | --- | --- | --- | --- | --- | --- |",
+            "| H001 | 1 | mystery | progressing | 《三体》游戏内第141号文明继续展开 | Reveal the true enemy | Narrative text must not drive chapter progress |",
+            "",
+          ].join("\n"),
+          "utf-8",
+        ),
+        writeFile(
+          join(storyDir, "chapter_summaries.md"),
+          [
+            "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+            "| --- | --- | --- | --- | --- | --- | --- | --- |",
+            ...Array.from({ length: 12 }, (_, index) =>
+              `| ${index + 1} | Ch${index + 1} | Lin Yue | Event ${index + 1} | Shift ${index + 1} | Hook ${index + 1} | tense | mainline |`),
+            "| 142 | Poisoned Ch142 | Lin Yue | Poisoned event | Poisoned shift | Poisoned hook | tense | mainline |",
+            "",
+          ].join("\n"),
+          "utf-8",
+        ),
+        writeFile(join(stateDir, "manifest.json"), JSON.stringify({
+          schemaVersion: 2,
+          language: "en",
+          lastAppliedChapter: 141,
+          projectionVersion: 1,
+          migrationWarnings: [],
+        }, null, 2), "utf-8"),
+      ]);
+
+      const next = await manager.getNextChapterNumber(bookId);
+
+      expect(next).toBe(13);
+    });
   });
   });
 
 
   // -------------------------------------------------------------------------
   // -------------------------------------------------------------------------
@@ -792,6 +885,61 @@ describe("StateManager", () => {
       expect(manifest.lastAppliedChapter).toBe(1);
       expect(manifest.lastAppliedChapter).toBe(1);
     });
     });
 
 
+    it("does not treat narrative digits inside hook markdown as runtime chapter progress during bootstrap", async () => {
+      const bookId = "runtime-state-narrative-digit-book";
+      const storyDir = join(manager.bookDir(bookId), "story");
+      await mkdir(storyDir, { recursive: true });
+      await Promise.all([
+        writeFile(
+          join(storyDir, "current_state.md"),
+          [
+            "# Current State",
+            "",
+            "| Field | Value |",
+            "| --- | --- |",
+            "| Current Chapter | 12 |",
+            "| Current Goal | Continue after the imported twelfth chapter |",
+            "",
+          ].join("\n"),
+          "utf-8",
+        ),
+        writeFile(
+          join(storyDir, "pending_hooks.md"),
+          [
+            "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
+            "| --- | --- | --- | --- | --- | --- | --- |",
+            "| H001 | 1 | mystery | progressing | 《三体》游戏内第141号文明展开到墨子时代 | Reveal the threat | Narrative prose, not chapter metadata |",
+            "",
+          ].join("\n"),
+          "utf-8",
+        ),
+        writeFile(
+          join(storyDir, "chapter_summaries.md"),
+          [
+            "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
+            "| --- | --- | --- | --- | --- | --- | --- | --- |",
+            ...Array.from({ length: 12 }, (_, index) =>
+              `| ${index + 1} | Ch${index + 1} | Lin Yue | Event ${index + 1} | Shift ${index + 1} | Hook ${index + 1} | tense | mainline |`),
+            "",
+          ].join("\n"),
+          "utf-8",
+        ),
+      ]);
+
+      await manager.ensureRuntimeState(bookId, 12);
+
+      const manifest = JSON.parse(
+        await readFile(join(manager.stateDir(bookId), "manifest.json"), "utf-8"),
+      ) as { lastAppliedChapter: number };
+      const hooks = JSON.parse(
+        await readFile(join(manager.stateDir(bookId), "hooks.json"), "utf-8"),
+      ) as { hooks: Array<{ hookId: string; lastAdvancedChapter: number }> };
+
+      expect(manifest.lastAppliedChapter).toBe(12);
+      expect(hooks.hooks[0]?.hookId).toBe("H001");
+      expect(hooks.hooks[0]?.lastAdvancedChapter).toBe(0);
+    });
+
     it("repairs poisoned manifest chapter when it runs ahead of persisted runtime state", async () => {
     it("repairs poisoned manifest chapter when it runs ahead of persisted runtime state", async () => {
       const bookId = "runtime-state-poisoned-book";
       const bookId = "runtime-state-poisoned-book";
       const storyDir = join(manager.bookDir(bookId), "story");
       const storyDir = join(manager.bookDir(bookId), "story");

+ 4 - 9
packages/core/src/state/manager.ts

@@ -205,19 +205,14 @@ export class StateManager {
   }
   }
 
 
   async getNextChapterNumber(bookId: string): Promise<number> {
   async getNextChapterNumber(bookId: string): Promise<number> {
-    const index = await this.loadChapterIndex(bookId);
-    const indexedChapter = index.length > 0
-      ? Math.max(...index.map((ch) => ch.number))
-      : 0;
-    const runtimeState = await bootstrapStructuredStateFromMarkdown({
+    const durableChapter = await resolveDurableStoryProgress({
       bookDir: this.bookDir(bookId),
       bookDir: this.bookDir(bookId),
-      fallbackChapter: indexedChapter,
     });
     });
-    const durableChapter = await resolveDurableStoryProgress({
+    await bootstrapStructuredStateFromMarkdown({
       bookDir: this.bookDir(bookId),
       bookDir: this.bookDir(bookId),
-      fallbackChapter: indexedChapter,
+      fallbackChapter: durableChapter,
     });
     });
-    return Math.max(indexedChapter, durableChapter, runtimeState.manifest.lastAppliedChapter) + 1;
+    return durableChapter + 1;
   }
   }
 
 
   async getPersistedChapterCount(bookId: string): Promise<number> {
   async getPersistedChapterCount(bookId: string): Promise<number> {

+ 66 - 65
packages/core/src/state/state-bootstrap.ts

@@ -71,12 +71,7 @@ export async function bootstrapStructuredStateFromMarkdown(params: {
     warnings,
     warnings,
     bootstrapState: markdownState.currentState,
     bootstrapState: markdownState.currentState,
   });
   });
-  const derivedProgress = Math.max(
-    markdownState.durableStoryProgress,
-    currentState.chapter,
-    maxSummaryChapter(summariesState),
-    maxHookChapter(hooksState.hooks),
-  );
+  const derivedProgress = markdownState.durableStoryProgress;
   if ((existingManifest?.lastAppliedChapter ?? 0) > derivedProgress) {
   if ((existingManifest?.lastAppliedChapter ?? 0) > derivedProgress) {
     appendWarning(
     appendWarning(
       warnings,
       warnings,
@@ -136,12 +131,7 @@ export async function rewriteStructuredStateFromMarkdown(params: {
   const manifest = StateManifestSchema.parse({
   const manifest = StateManifestSchema.parse({
     schemaVersion: 2,
     schemaVersion: 2,
     language,
     language,
-    lastAppliedChapter: Math.max(
-      markdownState.durableStoryProgress,
-      currentState.chapter,
-      maxSummaryChapter(summariesState),
-      maxHookChapter(hooksState.hooks),
-    ),
+    lastAppliedChapter: markdownState.durableStoryProgress,
     projectionVersion: existingManifest?.projectionVersion ?? 1,
     projectionVersion: existingManifest?.projectionVersion ?? 1,
     migrationWarnings: uniqueStrings([
     migrationWarnings: uniqueStrings([
       ...(existingManifest?.migrationWarnings ?? []),
       ...(existingManifest?.migrationWarnings ?? []),
@@ -188,10 +178,10 @@ export function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
       .filter((row) => normalizeHookId(row[0]).length > 0)
       .filter((row) => normalizeHookId(row[0]).length > 0)
       .map((row) => ({
       .map((row) => ({
         hookId: normalizeHookId(row[0]),
         hookId: normalizeHookId(row[0]),
-        startChapter: parseInteger(row[1]),
+        startChapter: parseStrictInteger(row[1]),
         type: row[2] ?? "",
         type: row[2] ?? "",
         status: row[3] ?? "open",
         status: row[3] ?? "open",
-        lastAdvancedChapter: parseInteger(row[4]),
+        lastAdvancedChapter: parseStrictInteger(row[4]),
         expectedPayoff: row[5] ?? "",
         expectedPayoff: row[5] ?? "",
         notes: row[6] ?? "",
         notes: row[6] ?? "",
       }));
       }));
@@ -335,10 +325,10 @@ function parsePendingHooksStateMarkdown(markdown: string, warnings: string[]) {
           const hookId = normalizeHookId(row[0]);
           const hookId = normalizeHookId(row[0]);
           return {
           return {
             hookId,
             hookId,
-            startChapter: parseIntegerWithWarning(row[1], warnings, `${hookId}:startChapter`),
+            startChapter: parseStrictIntegerWithWarning(row[1], warnings, `${hookId}:startChapter`),
             type: row[2] ?? "unspecified",
             type: row[2] ?? "unspecified",
             status: normalizeHookStatus(row[3], warnings, hookId),
             status: normalizeHookStatus(row[3], warnings, hookId),
-            lastAdvancedChapter: parseIntegerWithWarning(row[4], warnings, `${hookId}:lastAdvancedChapter`),
+            lastAdvancedChapter: parseStrictIntegerWithWarning(row[4], warnings, `${hookId}:lastAdvancedChapter`),
             expectedPayoff: row[5] ?? "",
             expectedPayoff: row[5] ?? "",
             notes: row[6] ?? "",
             notes: row[6] ?? "",
           };
           };
@@ -439,14 +429,9 @@ export async function resolveDurableStoryProgress(params: {
   readonly bookDir: string;
   readonly bookDir: string;
   readonly fallbackChapter?: number;
   readonly fallbackChapter?: number;
 }): Promise<number> {
 }): Promise<number> {
-  const storyDir = join(params.bookDir, "story");
-  const state = await loadMarkdownBootstrapState({
-    bookDir: params.bookDir,
-    storyDir,
-    fallbackChapter: params.fallbackChapter ?? 0,
-    warnings: [],
-  });
-  return state.durableStoryProgress;
+  const explicitFallback = normalizeExplicitChapter(params.fallbackChapter);
+  const durableArtifactProgress = await resolveContiguousArtifactChapterProgress(params.bookDir);
+  return Math.max(durableArtifactProgress, explicitFallback);
 }
 }
 
 
 async function loadJsonIfValid<T>(
 async function loadJsonIfValid<T>(
@@ -478,16 +463,12 @@ async function loadMarkdownBootstrapState(params: {
     storyDir: params.storyDir,
     storyDir: params.storyDir,
     warnings: params.warnings,
     warnings: params.warnings,
   });
   });
-  const durableArtifactProgress = await maxDurableArtifactChapter(params.bookDir);
-  const inferredFallbackChapter = Math.max(
-    params.fallbackChapter,
-    durableArtifactProgress,
-    maxSummaryChapter(summariesState),
-    maxHookChapter(hooksState.hooks),
-  );
+  const explicitFallback = normalizeExplicitChapter(params.fallbackChapter);
+  const durableArtifactProgress = await resolveContiguousArtifactChapterProgress(params.bookDir);
+  const authoritativeProgress = Math.max(explicitFallback, durableArtifactProgress);
   const currentState = await loadMarkdownCurrentState({
   const currentState = await loadMarkdownCurrentState({
     storyDir: params.storyDir,
     storyDir: params.storyDir,
-    fallbackChapter: inferredFallbackChapter,
+    fallbackChapter: authoritativeProgress,
     warnings: params.warnings,
     warnings: params.warnings,
   });
   });
 
 
@@ -495,10 +476,9 @@ async function loadMarkdownBootstrapState(params: {
     summariesState,
     summariesState,
     hooksState,
     hooksState,
     currentState,
     currentState,
-    durableStoryProgress: Math.max(
-      inferredFallbackChapter,
-      currentState.chapter,
-    ),
+    durableStoryProgress: authoritativeProgress > 0
+      ? authoritativeProgress
+      : currentState.chapter,
   };
   };
 }
 }
 
 
@@ -527,28 +507,31 @@ async function loadMarkdownCurrentState(params: {
   return parseCurrentStateStateMarkdown(markdown, params.fallbackChapter, params.warnings);
   return parseCurrentStateStateMarkdown(markdown, params.fallbackChapter, params.warnings);
 }
 }
 
 
-async function maxDurableArtifactChapter(bookDir: string): Promise<number> {
+async function resolveContiguousArtifactChapterProgress(bookDir: string): Promise<number> {
+  const chapterNumbers = await loadDurableArtifactChapterNumbers(bookDir);
+  return resolveContiguousChapterPrefix(chapterNumbers);
+}
+
+async function loadDurableArtifactChapterNumbers(bookDir: string): Promise<number[]> {
   const chaptersDir = join(bookDir, "chapters");
   const chaptersDir = join(bookDir, "chapters");
   const indexPath = join(chaptersDir, "index.json");
   const indexPath = join(chaptersDir, "index.json");
-  const [indexChapter, fileChapter] = await Promise.all([
+  const [indexChapters, fileChapters] = await Promise.all([
     readFile(indexPath, "utf-8")
     readFile(indexPath, "utf-8")
       .then((raw) => {
       .then((raw) => {
         const parsed = JSON.parse(raw) as Array<{ number?: unknown }>;
         const parsed = JSON.parse(raw) as Array<{ number?: unknown }>;
-        return parsed.reduce((max, entry) => (
-          typeof entry?.number === "number"
-            ? Math.max(max, entry.number)
-            : max
-        ), 0);
+        return parsed
+          .map((entry) => entry?.number)
+          .filter((entry): entry is number => typeof entry === "number" && Number.isInteger(entry) && entry > 0);
       })
       })
-      .catch(() => 0),
+      .catch(() => [] as number[]),
     readdir(chaptersDir)
     readdir(chaptersDir)
-      .then((entries) => entries.reduce((max, entry) => {
+      .then((entries) => entries.flatMap((entry) => {
         const match = entry.match(/^(\d+)_/);
         const match = entry.match(/^(\d+)_/);
-        return match ? Math.max(max, parseInt(match[1]!, 10)) : max;
-      }, 0))
-      .catch(() => 0),
+        return match ? [parseInt(match[1]!, 10)] : [];
+      }))
+      .catch(() => [] as number[]),
   ]);
   ]);
-  return Math.max(indexChapter, fileChapter);
+  return [...indexChapters, ...fileChapters];
 }
 }
 
 
 async function pathExists(path: string): Promise<boolean> {
 async function pathExists(path: string): Promise<boolean> {
@@ -568,17 +551,15 @@ function deduplicateSummaryRows<T extends { chapter: number }>(rows: ReadonlyArr
   return [...byChapter.values()].sort((a, b) => a.chapter - b.chapter);
   return [...byChapter.values()].sort((a, b) => a.chapter - b.chapter);
 }
 }
 
 
-function maxSummaryChapter(state: ChapterSummariesState): number {
-  return state.rows.reduce((max, row) => Math.max(max, row.chapter), 0);
-}
-
-function maxHookChapter(hooks: ReadonlyArray<StoredHook>): number {
-  // Only count lastAdvancedChapter — startChapter is a future plan marker,
-  // not an indication that state has been applied up to that chapter.
-  return hooks.reduce(
-    (max, hook) => Math.max(max, hook.lastAdvancedChapter),
-    0,
+export function resolveContiguousChapterPrefix(chapterNumbers: ReadonlyArray<number>): number {
+  const chapters = new Set(
+    chapterNumbers.filter((chapter): chapter is number => Number.isInteger(chapter) && chapter > 0),
   );
   );
+  let contiguousChapter = 0;
+  while (chapters.has(contiguousChapter + 1)) {
+    contiguousChapter += 1;
+  }
+  return contiguousChapter;
 }
 }
 
 
 export function normalizeHookId(value: string | undefined): string {
 export function normalizeHookId(value: string | undefined): string {
@@ -610,14 +591,14 @@ function normalizeHookStatus(value: string | undefined, warnings: string[], hook
   return "open";
   return "open";
 }
 }
 
 
-function parseIntegerWithWarning(value: string | undefined, warnings: string[], fieldLabel: string): number {
+function parseStrictIntegerWithWarning(value: string | undefined, warnings: string[], fieldLabel: string): number {
   if (!value) return 0;
   if (!value) return 0;
-  const match = value.match(/\d+/);
-  if (!match) {
-    appendWarning(warnings, `${fieldLabel} normalized from "${value}" to 0`);
-    return 0;
+  const parsed = parseStrictIntegerCell(value);
+  if (parsed !== null) {
+    return parsed;
   }
   }
-  return parseInt(match[0], 10);
+  appendWarning(warnings, `${fieldLabel} normalized from "${value}" to 0`);
+  return 0;
 }
 }
 
 
 function parseIntegerWithFallback(
 function parseIntegerWithFallback(
@@ -671,6 +652,26 @@ function parseInteger(value: string | undefined): number {
   return match ? parseInt(match[0], 10) : 0;
   return match ? parseInt(match[0], 10) : 0;
 }
 }
 
 
+function parseStrictInteger(value: string | undefined): number {
+  return parseStrictIntegerCell(value) ?? 0;
+}
+
+function parseStrictIntegerCell(value: string | undefined): number | null {
+  if (!value) return null;
+  const normalized = normalizeHookId(value);
+  if (!/^\d+$/.test(normalized)) {
+    return null;
+  }
+  return parseInt(normalized, 10);
+}
+
+function normalizeExplicitChapter(value: number | undefined): number {
+  if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
+    return 0;
+  }
+  return value;
+}
+
 function appendWarning(warnings: string[], warning: string): void {
 function appendWarning(warnings: string[], warning: string): void {
   if (!warnings.includes(warning)) {
   if (!warnings.includes(warning)) {
     warnings.push(warning);
     warnings.push(warning);

+ 5 - 33
packages/core/src/utils/memory-retrieval.ts

@@ -9,7 +9,10 @@ import {
   type HookStatus,
   type HookStatus,
 } from "../models/runtime-state.js";
 } from "../models/runtime-state.js";
 import { MemoryDB, type Fact, type StoredHook, type StoredSummary } from "../state/memory-db.js";
 import { MemoryDB, type Fact, type StoredHook, type StoredSummary } from "../state/memory-db.js";
-import { bootstrapStructuredStateFromMarkdown, normalizeHookId } from "../state/state-bootstrap.js";
+import {
+  bootstrapStructuredStateFromMarkdown,
+  parsePendingHooksMarkdown as parseStatePendingHooksMarkdown,
+} from "../state/state-bootstrap.js";
 import { collectStaleHookDebt } from "./hook-governance.js";
 import { collectStaleHookDebt } from "./hook-governance.js";
 
 
 export interface MemorySelection {
 export interface MemorySelection {
@@ -381,38 +384,7 @@ export function parseChapterSummariesMarkdown(markdown: string): StoredSummary[]
 }
 }
 
 
 export function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
 export function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
-  const tableRows = parseMarkdownTableRows(markdown)
-    .filter((row) => (row[0] ?? "").toLowerCase() !== "hook_id");
-
-  if (tableRows.length > 0) {
-    return tableRows
-      .filter((row) => normalizeHookId(row[0]).length > 0)
-      .map((row) => ({
-        hookId: normalizeHookId(row[0]),
-        startChapter: parseInteger(row[1]),
-        type: row[2] ?? "",
-        status: row[3] ?? "open",
-        lastAdvancedChapter: parseInteger(row[4]),
-        expectedPayoff: row[5] ?? "",
-        notes: row[6] ?? "",
-      }));
-  }
-
-  return markdown
-    .split("\n")
-    .map((line) => line.trim())
-    .filter((line) => line.startsWith("-"))
-    .map((line) => line.replace(/^-\s*/, ""))
-    .filter(Boolean)
-    .map((line, index) => ({
-      hookId: `hook-${index + 1}`,
-      startChapter: 0,
-      type: "unspecified",
-      status: "open",
-      lastAdvancedChapter: 0,
-      expectedPayoff: "",
-      notes: line,
-    }));
+  return parseStatePendingHooksMarkdown(markdown);
 }
 }
 
 
 export function parseCurrentStateFacts(
 export function parseCurrentStateFacts(