Pārlūkot izejas kodu

fix(state): close remaining chapter-number pollution paths

Post-merge regression: bootstrapStructuredStateFromMarkdown still
included currentState.chapter in lastAppliedChapter, allowing a
hallucinated chapter number in current_state.md to override the
contiguous durable progress.

Changes:
- state-bootstrap.ts: derivedProgress and lastAppliedChapter now
  come exclusively from durableStoryProgress (contiguous artifact
  chain), currentState.chapter is no longer trusted
- manager.ts: getNextChapterNumber returns durableChapter + 1
  without consulting manifest
- story-markdown.ts: hook startChapter/lastAdvancedChapter parsed
  with parseStrictChapterInteger (rejects "第141号文明" style prose)
- Tests updated to provide durable chapter artifacts matching the
  chapter numbers they expect
Ma 7 mēneši atpakaļ
vecāks
revīzija
54d090c351

+ 9 - 13
packages/core/src/__tests__/pipeline-agent.test.ts

@@ -151,8 +151,10 @@ describe("agent pipeline tools", () => {
   });
 
   it("blocks write_full_pipeline when runtime progress is ahead of the chapter index", async () => {
-    const stateDir = join(state.bookDir(bookId), "story", "state");
-    await mkdir(stateDir, { recursive: true });
+    const chaptersDir = join(state.bookDir(bookId), "chapters");
+    // Create durable chapter files for 1-3 but only index chapter 1.
+    // This produces durableChapter=3, nextNum=4 while lastIndexedChapter=1,
+    // triggering the sequential write guard.
     await state.saveChapterIndex(bookId, [{
       number: 1,
       title: "Existing Chapter",
@@ -163,17 +165,11 @@ describe("agent pipeline tools", () => {
       auditIssues: [],
       lengthWarnings: [],
     }]);
-    await writeFile(join(stateDir, "manifest.json"), JSON.stringify({
-      schemaVersion: 2,
-      language: "zh",
-      lastAppliedChapter: 3,
-      projectionVersion: 1,
-      migrationWarnings: [],
-    }, null, 2), "utf-8");
-    await writeFile(join(stateDir, "current_state.json"), JSON.stringify({
-      chapter: 3,
-      facts: [],
-    }, null, 2), "utf-8");
+    await Promise.all([
+      writeFile(join(chaptersDir, "0001_Existing.md"), "# Chapter 1\n", "utf-8"),
+      writeFile(join(chaptersDir, "0002_Second.md"), "# Chapter 2\n", "utf-8"),
+      writeFile(join(chaptersDir, "0003_Third.md"), "# Chapter 3\n", "utf-8"),
+    ]);
 
     const writeNextChapter = vi.spyOn(pipeline, "writeNextChapter").mockResolvedValue({
       bookId,

+ 10 - 0
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -604,7 +604,17 @@ describe("PipelineRunner", () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const storyDir = join(state.bookDir(bookId), "story");
     const stateDir = join(storyDir, "state");
+    const chaptersDir = join(state.bookDir(bookId), "chapters");
     await mkdir(stateDir, { recursive: true });
+    await writeFile(
+      join(chaptersDir, "index.json"),
+      JSON.stringify([
+        { number: 1, title: "Ch1", status: "approved" },
+        { number: 2, title: "Ch2", status: "approved" },
+        { number: 3, title: "Ch3", status: "approved" },
+      ]),
+      "utf-8",
+    );
 
     await Promise.all([
       writeFile(

+ 29 - 0
packages/core/src/__tests__/runtime-state-store.test.ts

@@ -24,7 +24,18 @@ describe("runtime-state-store memory helpers", () => {
     const bookDir = join(root, "book");
     const storyDir = join(bookDir, "story");
     const stateDir = join(storyDir, "state");
+    const chaptersDir = join(bookDir, "chapters");
     await mkdir(stateDir, { recursive: true });
+    await mkdir(chaptersDir, { recursive: true });
+    await writeFile(
+      join(chaptersDir, "index.json"),
+      JSON.stringify([
+        { number: 1, title: "Ch1", status: "approved" },
+        { number: 2, title: "Ch2", status: "approved" },
+        { number: 3, title: "Ch3", status: "approved" },
+      ]),
+      "utf-8",
+    );
 
     await Promise.all([
       writeFile(
@@ -168,7 +179,16 @@ describe("runtime-state-store memory helpers", () => {
     const bookDir = join(root, "book");
     const storyDir = join(bookDir, "story");
     const stateDir = join(storyDir, "state");
+    const chaptersDir = join(bookDir, "chapters");
     await mkdir(stateDir, { recursive: true });
+    await mkdir(chaptersDir, { recursive: true });
+    await writeFile(
+      join(chaptersDir, "index.json"),
+      JSON.stringify(
+        Array.from({ length: 12 }, (_, i) => ({ number: i + 1, title: `Ch${i + 1}`, status: "approved" })),
+      ),
+      "utf-8",
+    );
 
     await Promise.all([
       writeFile(join(stateDir, "manifest.json"), JSON.stringify({
@@ -222,7 +242,16 @@ describe("runtime-state-store memory helpers", () => {
     const bookDir = join(root, "book");
     const storyDir = join(bookDir, "story");
     const stateDir = join(storyDir, "state");
+    const chaptersDir = join(bookDir, "chapters");
     await mkdir(stateDir, { recursive: true });
+    await mkdir(chaptersDir, { recursive: true });
+    await writeFile(
+      join(chaptersDir, "index.json"),
+      JSON.stringify(
+        Array.from({ length: 11 }, (_, i) => ({ number: i + 1, title: `Ch${i + 1}`, status: "approved" })),
+      ),
+      "utf-8",
+    );
 
     await Promise.all([
       writeFile(join(stateDir, "manifest.json"), JSON.stringify({

+ 18 - 0
packages/core/src/__tests__/writer.test.ts

@@ -245,9 +245,15 @@ describe("WriterAgent", () => {
     const root = await mkdtemp(join(tmpdir(), "inkos-writer-runtime-state-test-"));
     const bookDir = join(root, "book");
     const storyDir = join(bookDir, "story");
+    const chaptersDir = join(bookDir, "chapters");
     await mkdir(storyDir, { recursive: true });
+    await mkdir(chaptersDir, { recursive: true });
 
     await Promise.all([
+      writeFile(join(chaptersDir, "index.json"), JSON.stringify([
+        { number: 1, title: "Ch1", status: "approved" },
+        { number: 2, title: "Ch2", status: "approved" },
+      ]), "utf-8"),
       writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
       writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 3\nTrace the debt through the river-port ledger.\n", "utf-8"),
       writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
@@ -388,9 +394,15 @@ describe("WriterAgent", () => {
     const root = await mkdtemp(join(tmpdir(), "inkos-writer-runtime-state-hallucinated-chapter-test-"));
     const bookDir = join(root, "book");
     const storyDir = join(bookDir, "story");
+    const chaptersDir = join(bookDir, "chapters");
     await mkdir(storyDir, { recursive: true });
+    await mkdir(chaptersDir, { recursive: true });
 
     await Promise.all([
+      writeFile(join(chaptersDir, "index.json"), JSON.stringify([
+        { number: 1, title: "Ch1", status: "approved" },
+        { number: 2, title: "Ch2", status: "approved" },
+      ]), "utf-8"),
       writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The city still remembers 1988.\n", "utf-8"),
       writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 3\nTrace the debt through the river-port ledger.\n", "utf-8"),
       writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
@@ -532,9 +544,15 @@ describe("WriterAgent", () => {
     const root = await mkdtemp(join(tmpdir(), "inkos-writer-arbiter-test-"));
     const bookDir = join(root, "book");
     const storyDir = join(bookDir, "story");
+    const chaptersDir = join(bookDir, "chapters");
     await mkdir(storyDir, { recursive: true });
+    await mkdir(chaptersDir, { recursive: true });
 
     await Promise.all([
+      writeFile(join(chaptersDir, "index.json"), JSON.stringify([
+        { number: 1, title: "Ch1", status: "approved" },
+        { number: 2, title: "Ch2", status: "approved" },
+      ]), "utf-8"),
       writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- Anonymous messages keep steering the debt trail.\n", "utf-8"),
       writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 3\nThe anonymous source widens from route to address.\n", "utf-8"),
       writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),

+ 5 - 2
packages/core/src/state/manager.ts

@@ -208,11 +208,14 @@ export class StateManager {
     const durableChapter = await resolveDurableStoryProgress({
       bookDir: this.bookDir(bookId),
     });
-    const runtimeState = await bootstrapStructuredStateFromMarkdown({
+    // Ensure structured state is bootstrapped (side-effect: creates missing
+    // JSON files), but do NOT trust its chapter number for progress — only
+    // the contiguous durable artifact chain is authoritative.
+    await bootstrapStructuredStateFromMarkdown({
       bookDir: this.bookDir(bookId),
       fallbackChapter: durableChapter,
     });
-    return Math.max(durableChapter, runtimeState.manifest.lastAppliedChapter) + 1;
+    return durableChapter + 1;
   }
 
   async getPersistedChapterCount(bookId: string): Promise<number> {

+ 6 - 11
packages/core/src/state/state-bootstrap.ts

@@ -88,10 +88,10 @@ export async function bootstrapStructuredStateFromMarkdown(params: {
     warnings,
     bootstrapState: markdownState.currentState,
   });
-  const derivedProgress = Math.max(
-    markdownState.durableStoryProgress,
-    currentState.chapter,
-  );
+  // Only trust durable artifact progress (chapter files + index).
+  // currentState.chapter comes from markdown which can contain
+  // hallucinated numbers (e.g. year 1988 parsed as chapter 1988).
+  const derivedProgress = markdownState.durableStoryProgress;
   if ((existingManifest?.lastAppliedChapter ?? 0) > derivedProgress) {
     appendWarning(
       warnings,
@@ -151,10 +151,7 @@ export async function rewriteStructuredStateFromMarkdown(params: {
   const manifest = StateManifestSchema.parse({
     schemaVersion: 2,
     language,
-    lastAppliedChapter: Math.max(
-      markdownState.durableStoryProgress,
-      currentState.chapter,
-    ),
+    lastAppliedChapter: markdownState.durableStoryProgress,
     projectionVersion: existingManifest?.projectionVersion ?? 1,
     migrationWarnings: uniqueStrings([
       ...(existingManifest?.migrationWarnings ?? []),
@@ -444,9 +441,7 @@ async function loadMarkdownBootstrapState(params: {
     summariesState,
     hooksState,
     currentState,
-    durableStoryProgress: authoritativeProgress > 0
-      ? authoritativeProgress
-      : currentState.chapter,
+    durableStoryProgress: authoritativeProgress,
   };
 }
 

+ 14 - 2
packages/core/src/utils/story-markdown.ts

@@ -195,6 +195,18 @@ export function parseInteger(value: string | undefined): number {
   return match ? parseInt(match[0], 10) : 0;
 }
 
+/**
+ * Strict integer parse — only accepts cells that are purely numeric
+ * (after stripping markdown formatting). Returns 0 for cells containing
+ * prose like "第141号文明" to prevent narrative numbers from being
+ * mistaken for chapter/progress values.
+ */
+function parseStrictChapterInteger(value: string | undefined): number {
+  if (!value) return 0;
+  const stripped = normalizeHookId(value);
+  return /^\d+$/.test(stripped) ? parseInt(stripped, 10) : 0;
+}
+
 export function normalizeHookId(value: string | undefined): string {
   let normalized = (value ?? "").trim();
   let previous = "";
@@ -220,10 +232,10 @@ function parsePendingHookRow(row: ReadonlyArray<string | undefined>): StoredHook
 
   return {
     hookId: normalizeHookId(row[0]),
-    startChapter: parseInteger(row[1]),
+    startChapter: parseStrictChapterInteger(row[1]),
     type: row[2] ?? "",
     status: row[3] ?? "open",
-    lastAdvancedChapter: parseInteger(row[4]),
+    lastAdvancedChapter: parseStrictChapterInteger(row[4]),
     expectedPayoff: row[5] ?? "",
     payoffTiming,
     notes,