Просмотр исходного кода

feat(interaction): add stage telemetry and edit transactions

Ma 7 месяцев назад
Родитель
Сommit
55c4477f2f

+ 4 - 0
packages/cli/src/__tests__/interaction-tools.test.ts

@@ -45,6 +45,8 @@ describe("interaction tools adapter", () => {
     const state = {
       ensureControlDocuments: vi.fn(async () => {}),
       bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+      loadChapterIndex: vi.fn(async () => []),
+      saveChapterIndex: vi.fn(async () => undefined),
     };
 
     const tools = createInteractionToolsFromDeps(projectRoot, pipeline, state);
@@ -64,6 +66,8 @@ describe("interaction tools adapter", () => {
     const state = {
       ensureControlDocuments: vi.fn(async () => {}),
       bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+      loadChapterIndex: vi.fn(async () => []),
+      saveChapterIndex: vi.fn(async () => undefined),
     };
 
     const tools = createInteractionToolsFromDeps(projectRoot, pipeline, state);

+ 10 - 1
packages/cli/src/__tests__/tui-control-flow.test.ts

@@ -27,6 +27,8 @@ describe("tui control flow", () => {
     const tools = {
       writeNextChapter: vi.fn(async () => ({ ok: true })),
       reviseDraft: vi.fn(async () => ({ ok: true })),
+      patchChapterText: vi.fn(async () => ({ ok: true })),
+      renameEntity: vi.fn(async () => ({ ok: true })),
       updateCurrentFocus: vi.fn(async () => ({ ok: true })),
       updateAuthorIntent: vi.fn(async () => ({ ok: true })),
       writeTruthFile: vi.fn(async () => ({ ok: true })),
@@ -36,7 +38,8 @@ describe("tui control flow", () => {
 
     expect(tools.writeNextChapter).toHaveBeenCalledWith("harbor");
     expect(result.session.activeBookId).toBe("harbor");
-    expect(result.session.currentExecution?.status).toBe("completed");
+    expect(result.session.currentExecution?.status).toBe("waiting_human");
+    expect(result.session.pendingDecision?.kind).toBe("review-next-step");
     expect(result.session.messages.at(0)?.role).toBe("user");
     expect(result.session.messages.at(-1)?.role).toBe("assistant");
   });
@@ -50,6 +53,8 @@ describe("tui control flow", () => {
     const tools = {
       writeNextChapter: vi.fn(async () => ({ ok: true })),
       reviseDraft: vi.fn(async () => ({ ok: true })),
+      patchChapterText: vi.fn(async () => ({ ok: true })),
+      renameEntity: vi.fn(async () => ({ ok: true })),
       updateCurrentFocus: vi.fn(async () => ({ ok: true })),
       updateAuthorIntent: vi.fn(async () => ({ ok: true })),
       writeTruthFile: vi.fn(async () => ({ ok: true })),
@@ -69,6 +74,8 @@ describe("tui control flow", () => {
     const tools = {
       writeNextChapter: vi.fn(async () => ({ ok: true })),
       reviseDraft: vi.fn(async () => ({ ok: true })),
+      patchChapterText: vi.fn(async () => ({ ok: true })),
+      renameEntity: vi.fn(async () => ({ ok: true })),
       updateCurrentFocus: vi.fn(async () => ({ ok: true })),
       updateAuthorIntent: vi.fn(async () => ({ ok: true })),
       writeTruthFile: vi.fn(async () => ({ ok: true })),
@@ -88,6 +95,8 @@ describe("tui control flow", () => {
     const tools = {
       writeNextChapter: vi.fn(async () => ({ ok: true })),
       reviseDraft: vi.fn(async () => ({ ok: true })),
+      patchChapterText: vi.fn(async () => ({ ok: true })),
+      renameEntity: vi.fn(async () => ({ ok: true })),
       updateCurrentFocus: vi.fn(async () => ({ ok: true })),
       updateAuthorIntent: vi.fn(async () => ({ ok: true })),
       writeTruthFile: vi.fn(async () => ({ ok: true })),

+ 1 - 1
packages/cli/src/tui/tools.ts

@@ -7,7 +7,7 @@ import {
 import { buildPipelineConfig, loadConfig } from "../utils.js";
 
 type CliPipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft">;
-type CliStateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir">;
+type CliStateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir" | "loadChapterIndex" | "saveChapterIndex">;
 
 export function createCliInteractionToolsFromDeps(
   pipeline: CliPipelineLike,

+ 79 - 1
packages/core/src/__tests__/edit-controller.test.ts

@@ -1,13 +1,26 @@
-import { describe, expect, it } from "vitest";
+import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { beforeAll, describe, expect, it } from "vitest";
+import type { ChapterMeta } from "../models/chapter.js";
 import {
   classifyTruthAuthority,
   normalizeTruthFileName,
 } from "../interaction/truth-authority.js";
 import {
+  executeEditTransaction,
   planEditTransaction,
   type EditRequest,
 } from "../interaction/edit-controller.js";
 
+let projectRoot: string;
+
+beforeAll(async () => {
+  projectRoot = await mkdtemp(join(tmpdir(), "inkos-edit-controller-"));
+  await mkdir(join(projectRoot, "books", "harbor", "story", "runtime"), { recursive: true });
+  await mkdir(join(projectRoot, "books", "harbor", "chapters"), { recursive: true });
+});
+
 describe("truth authority", () => {
   it("normalizes supported truth files", () => {
     expect(normalizeTruthFileName("story_bible")).toBe("story_bible.md");
@@ -89,4 +102,69 @@ describe("edit controller", () => {
     expect(result.affectedScope).toBe("future");
     expect(result.requiresTruthRebuild).toBe(false);
   });
+
+  it("executes entity rename across truth files and chapters", async () => {
+    const bookDir = join(projectRoot, "books", "harbor");
+    await writeFile(join(bookDir, "story", "story_bible.md"), "主角陆尘住在港口。", "utf-8");
+    await writeFile(join(bookDir, "chapters", "0001_旧名字.md"), "# 第1章 旧名字\n\n陆尘走进港口。", "utf-8");
+
+    const result = await executeEditTransaction(
+      {
+        bookDir: (bookId) => join(projectRoot, "books", bookId),
+        loadChapterIndex: async () => [],
+        saveChapterIndex: async () => undefined,
+      },
+      {
+        kind: "entity-rename",
+        bookId: "harbor",
+        entityType: "protagonist",
+        oldValue: "陆尘",
+        newValue: "林砚",
+      },
+    );
+
+    await expect(readFile(join(bookDir, "story", "story_bible.md"), "utf-8")).resolves.toContain("林砚");
+    await expect(readFile(join(bookDir, "chapters", "0001_旧名字.md"), "utf-8")).resolves.toContain("林砚");
+    expect(result.touchedFiles.length).toBeGreaterThan(0);
+  });
+
+  it("executes chapter text patches and marks the chapter for review", async () => {
+    const bookDir = join(projectRoot, "books", "harbor");
+    await writeFile(join(bookDir, "chapters", "0003_灰墙榜下.md"), "# 第3章 灰墙榜下\n\n旧名字在这里。", "utf-8");
+    await writeFile(join(bookDir, "story", "runtime", "chapter-0003.intent.md"), "stale", "utf-8");
+    const chapterIndex = [{
+      number: 3,
+      title: "灰墙榜下",
+      status: "ready-for-review" as const,
+      wordCount: 12,
+      createdAt: new Date().toISOString(),
+      updatedAt: new Date().toISOString(),
+      auditIssues: [],
+      lengthWarnings: [],
+    }];
+
+    let savedIndex: ChapterMeta[] = [...chapterIndex];
+    const result = await executeEditTransaction(
+      {
+        bookDir: (bookId) => join(projectRoot, "books", bookId),
+        loadChapterIndex: async () => chapterIndex,
+        saveChapterIndex: async (_bookId, index) => {
+          savedIndex = [...index];
+        },
+      },
+      {
+        kind: "chapter-local-edit",
+        bookId: "harbor",
+        chapterNumber: 3,
+        instruction: "Replace old text",
+        targetText: "旧名字",
+        replacementText: "新名字",
+      },
+    );
+
+    await expect(readFile(join(bookDir, "chapters", "0003_灰墙榜下.md"), "utf-8")).resolves.toContain("新名字");
+    expect(savedIndex[0]?.status).toBe("audit-failed");
+    expect(savedIndex[0]?.auditIssues.at(-1)).toContain("Manual text edit requires review");
+    expect(result.reviewRequired).toBe(true);
+  });
 });

+ 28 - 0
packages/core/src/__tests__/interaction-nl-router.test.ts

@@ -98,5 +98,33 @@ describe("interaction natural-language router", () => {
       fileName: "current_focus.md",
       instruction: "Bring it back",
     });
+    expect(routeNaturalLanguageIntent("/rename 陆尘 => 林砚", { activeBookId: "harbor" })).toEqual({
+      intent: "rename_entity",
+      bookId: "harbor",
+      oldValue: "陆尘",
+      newValue: "林砚",
+    });
+    expect(routeNaturalLanguageIntent("/replace 3 旧名字 => 新名字", { activeBookId: "harbor" })).toEqual({
+      intent: "patch_chapter_text",
+      bookId: "harbor",
+      chapterNumber: 3,
+      targetText: "旧名字",
+      replacementText: "新名字",
+    });
+  });
+
+  it("maps rename and chapter patch requests from natural language", () => {
+    expect(routeNaturalLanguageIntent("把陆尘改成林砚", { activeBookId: "harbor" })).toEqual({
+      intent: "rename_entity",
+      bookId: "harbor",
+      oldValue: "陆尘",
+      newValue: "林砚",
+    });
+    expect(routeNaturalLanguageIntent("rename Lu Chen to Lin Yan", { activeBookId: "harbor" })).toEqual({
+      intent: "rename_entity",
+      bookId: "harbor",
+      oldValue: "Lu Chen",
+      newValue: "Lin Yan",
+    });
   });
 });

+ 166 - 4
packages/core/src/__tests__/interaction-runtime.test.ts

@@ -3,7 +3,7 @@ import { InteractionSessionSchema } from "../interaction/session.js";
 import { runInteractionRequest } from "../interaction/runtime.js";
 
 describe("interaction runtime", () => {
-  it("routes continue/write intents to writeNextChapter", async () => {
+  it("keeps write_next completed in auto mode", async () => {
     const writeNextChapter = vi.fn(async () => ({
       chapterNumber: 7,
       title: "Harbor Ledger",
@@ -20,20 +20,87 @@ describe("interaction runtime", () => {
       sessionId: "session-1",
       projectRoot: "/tmp/project",
       activeBookId: "harbor",
-      automationMode: "semi",
+      automationMode: "auto",
       messages: [],
+      events: [],
     });
 
     const result = await runInteractionRequest({
       session,
       request: { intent: "write_next", bookId: "harbor" },
-      tools: { writeNextChapter, reviseDraft, updateCurrentFocus, updateAuthorIntent, writeTruthFile },
+      tools: {
+        writeNextChapter,
+        reviseDraft,
+        patchChapterText: vi.fn(),
+        renameEntity: vi.fn(),
+        updateCurrentFocus,
+        updateAuthorIntent,
+        writeTruthFile,
+      },
     });
 
     expect(writeNextChapter).toHaveBeenCalledWith("harbor");
     expect(reviseDraft).not.toHaveBeenCalled();
     expect(result.session.activeBookId).toBe("harbor");
+    expect(result.session.activeChapterNumber).toBe(7);
     expect(result.session.currentExecution?.status).toBe("completed");
+    expect(result.session.pendingDecision).toBeUndefined();
+    expect(result.session.events.map((event) => event.kind)).toEqual([
+      "task.started",
+      "task.completed",
+    ]);
+  });
+
+  it("moves content-producing work into waiting_human in semi mode", async () => {
+    const writeNextChapter = vi.fn(async () => ({
+      chapterNumber: 7,
+      title: "Harbor Ledger",
+      wordCount: 3210,
+      revised: false,
+      status: "ready-for-review" as const,
+      auditResult: { passed: true, issues: [], summary: "ok" },
+      __interaction: {
+        events: [{
+          kind: "stage.changed",
+          timestamp: 1710000000000,
+          status: "writing" as const,
+          bookId: "harbor",
+          chapterNumber: 7,
+          detail: "writing chapter draft",
+        }],
+      },
+    }));
+
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-1b",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+      }),
+      request: { intent: "write_next", bookId: "harbor" },
+      tools: {
+        writeNextChapter,
+        reviseDraft: vi.fn(),
+        patchChapterText: vi.fn(),
+        renameEntity: vi.fn(),
+        updateCurrentFocus: vi.fn(),
+        updateAuthorIntent: vi.fn(),
+        writeTruthFile: vi.fn(),
+      },
+    });
+
+    expect(result.session.currentExecution?.status).toBe("waiting_human");
+    expect(result.session.pendingDecision?.kind).toBe("review-next-step");
+    expect(result.session.pendingDecision?.chapterNumber).toBe(7);
+    expect(result.session.events.map((event) => event.kind)).toEqual([
+      "task.started",
+      "stage.changed",
+      "task.completed",
+    ]);
+    expect(result.responseText).toContain("waiting for your next decision");
   });
 
   it("routes revise_chapter to reviseDraft with local-fix", async () => {
@@ -60,6 +127,8 @@ describe("interaction runtime", () => {
       tools: {
         writeNextChapter,
         reviseDraft,
+        patchChapterText: vi.fn(),
+        renameEntity: vi.fn(),
         updateCurrentFocus: vi.fn(),
         updateAuthorIntent: vi.fn(),
         writeTruthFile: vi.fn(),
@@ -90,6 +159,8 @@ describe("interaction runtime", () => {
       tools: {
         writeNextChapter: vi.fn(),
         reviseDraft,
+        patchChapterText: vi.fn(),
+        renameEntity: vi.fn(),
         updateCurrentFocus: vi.fn(),
         updateAuthorIntent: vi.fn(),
         writeTruthFile: vi.fn(),
@@ -118,6 +189,8 @@ describe("interaction runtime", () => {
       tools: {
         writeNextChapter: vi.fn(),
         reviseDraft: vi.fn(),
+        patchChapterText: vi.fn(),
+        renameEntity: vi.fn(),
         updateCurrentFocus,
         updateAuthorIntent: vi.fn(),
         writeTruthFile: vi.fn(),
@@ -151,6 +224,8 @@ describe("interaction runtime", () => {
       tools: {
         writeNextChapter: vi.fn(),
         reviseDraft: vi.fn(),
+        patchChapterText: vi.fn(),
+        renameEntity: vi.fn(),
         updateCurrentFocus: vi.fn(),
         updateAuthorIntent: vi.fn(),
         writeTruthFile,
@@ -164,6 +239,79 @@ describe("interaction runtime", () => {
     );
   });
 
+  it("routes rename_entity to the rename tool", async () => {
+    const renameEntity = vi.fn(async () => ({ ok: true }));
+
+    await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-4c",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "manual",
+        messages: [],
+        events: [],
+      }),
+      request: {
+        intent: "rename_entity",
+        bookId: "harbor",
+        oldValue: "陆尘",
+        newValue: "林砚",
+      },
+      tools: {
+        writeNextChapter: vi.fn(),
+        reviseDraft: vi.fn(),
+        updateCurrentFocus: vi.fn(),
+        updateAuthorIntent: vi.fn(),
+        writeTruthFile: vi.fn(),
+        renameEntity,
+        patchChapterText: vi.fn(),
+      },
+    });
+
+    expect(renameEntity).toHaveBeenCalledWith("harbor", "陆尘", "林砚");
+  });
+
+  it("routes patch_chapter_text to the chapter patch tool and waits for review", async () => {
+    const patchChapterText = vi.fn(async () => ({
+      chapterNumber: 3,
+      __interaction: {
+        responseText: "Patched chapter 3 and marked it for review.",
+      },
+    }));
+
+    const result = await runInteractionRequest({
+      session: InteractionSessionSchema.parse({
+        sessionId: "session-4d",
+        projectRoot: "/tmp/project",
+        activeBookId: "harbor",
+        automationMode: "semi",
+        messages: [],
+        events: [],
+      }),
+      request: {
+        intent: "patch_chapter_text",
+        bookId: "harbor",
+        chapterNumber: 3,
+        targetText: "旧名字",
+        replacementText: "新名字",
+      },
+      tools: {
+        writeNextChapter: vi.fn(),
+        reviseDraft: vi.fn(),
+        updateCurrentFocus: vi.fn(),
+        updateAuthorIntent: vi.fn(),
+        writeTruthFile: vi.fn(),
+        renameEntity: vi.fn(),
+        patchChapterText,
+      },
+    });
+
+    expect(patchChapterText).toHaveBeenCalledWith("harbor", 3, "旧名字", "新名字");
+    expect(result.session.currentExecution?.status).toBe("waiting_human");
+    expect(result.session.pendingDecision?.chapterNumber).toBe(3);
+    expect(result.responseText).toContain("marked it for review");
+  });
+
   it("updates automation mode without invoking pipeline tools", async () => {
     const writeNextChapter = vi.fn();
     const reviseDraft = vi.fn();
@@ -178,11 +326,14 @@ describe("interaction runtime", () => {
         activeBookId: "harbor",
         automationMode: "semi",
         messages: [],
+        events: [],
       }),
       request: { intent: "switch_mode", mode: "auto" },
       tools: {
         writeNextChapter,
         reviseDraft,
+        patchChapterText: vi.fn(),
+        renameEntity: vi.fn(),
         updateCurrentFocus,
         updateAuthorIntent,
         writeTruthFile,
@@ -194,6 +345,10 @@ describe("interaction runtime", () => {
     expect(reviseDraft).not.toHaveBeenCalled();
     expect(updateCurrentFocus).not.toHaveBeenCalled();
     expect(updateAuthorIntent).not.toHaveBeenCalled();
+    expect(result.session.events.map((event) => event.kind)).toEqual([
+      "task.started",
+      "task.completed",
+    ]);
   });
 
   it("pauses the active book without invoking pipeline tools", async () => {
@@ -210,6 +365,8 @@ describe("interaction runtime", () => {
       tools: {
         writeNextChapter: vi.fn(),
         reviseDraft: vi.fn(),
+        patchChapterText: vi.fn(),
+        renameEntity: vi.fn(),
         updateCurrentFocus: vi.fn(),
         updateAuthorIntent: vi.fn(),
         writeTruthFile: vi.fn(),
@@ -218,7 +375,10 @@ describe("interaction runtime", () => {
 
     expect(result.session.currentExecution?.status).toBe("blocked");
     expect(result.responseText).toContain("Paused");
-    expect(result.session.events.at(-1)?.kind).toBe("task.completed");
+    expect(result.session.events.map((event) => event.kind)).toEqual([
+      "task.started",
+      "task.completed",
+    ]);
   });
 
   it("returns a human-readable explanation for explain_status", async () => {
@@ -241,6 +401,8 @@ describe("interaction runtime", () => {
       tools: {
         writeNextChapter: vi.fn(),
         reviseDraft: vi.fn(),
+        patchChapterText: vi.fn(),
+        renameEntity: vi.fn(),
         updateCurrentFocus: vi.fn(),
         updateAuthorIntent: vi.fn(),
         writeTruthFile: vi.fn(),

+ 68 - 1
packages/core/src/__tests__/interaction-tools.test.ts

@@ -2,6 +2,7 @@ import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { beforeAll, describe, expect, it, vi } from "vitest";
+import { createLogger, type LogSink } from "../index.js";
 import { createInteractionToolsFromDeps } from "../interaction/project-tools.js";
 
 let projectRoot: string;
@@ -13,8 +14,18 @@ describe("interaction tools", () => {
   });
 
   it("delegates writeNextChapter and reviseDraft to the pipeline", async () => {
+    const events: string[] = [];
+    const sink: LogSink = {
+      write(entry) {
+        events.push(entry.message);
+      },
+    };
     const pipeline = {
+      config: {
+        logger: createLogger({ tag: "test", sinks: [sink] }),
+      },
       writeNextChapter: vi.fn(async () => ({
+        config: undefined,
         chapterNumber: 1,
         title: "Draft",
         wordCount: 1000,
@@ -33,15 +44,69 @@ describe("interaction tools", () => {
     const state = {
       ensureControlDocuments: vi.fn(async () => {}),
       bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+      loadChapterIndex: vi.fn(async () => []),
+      saveChapterIndex: vi.fn(async () => undefined),
     };
 
     const tools = createInteractionToolsFromDeps(pipeline, state);
 
-    await tools.writeNextChapter("harbor");
+    const writeResult = await tools.writeNextChapter("harbor");
     await tools.reviseDraft("harbor", 3, "rewrite");
 
     expect(pipeline.writeNextChapter).toHaveBeenCalledWith("harbor");
     expect(pipeline.reviseDraft).toHaveBeenCalledWith("harbor", 3, "rewrite");
+    expect((writeResult as { __interaction?: { activeChapterNumber?: number } }).__interaction?.activeChapterNumber).toBe(1);
+    expect(events).toEqual([]);
+  });
+
+  it("captures pipeline stage logs into interaction events", async () => {
+    const pipeline = {
+      config: {
+        logger: createLogger({
+          tag: "test",
+          sinks: [{
+            write() {},
+          }],
+        }),
+      },
+      writeNextChapter: vi.fn(async function (this: { config: { logger?: { info: (msg: string) => void } } }) {
+        this.config.logger?.info("Stage: preparing chapter inputs");
+        this.config.logger?.info("Stage: writing chapter draft");
+        return {
+          chapterNumber: 4,
+          title: "Draft",
+          wordCount: 1000,
+          revised: false,
+          status: "ready-for-review" as const,
+          auditResult: { passed: true, issues: [], summary: "ok" },
+        };
+      }),
+      reviseDraft: vi.fn(async () => ({
+        chapterNumber: 3,
+        wordCount: 1200,
+        fixedIssues: [],
+        applied: true,
+        status: "ready-for-review" as const,
+      })),
+    };
+    const state = {
+      ensureControlDocuments: vi.fn(async () => {}),
+      bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+      loadChapterIndex: vi.fn(async () => []),
+      saveChapterIndex: vi.fn(async () => undefined),
+    };
+
+    const tools = createInteractionToolsFromDeps(pipeline, state);
+    const result = await tools.writeNextChapter("harbor");
+
+    expect((result as {
+      __interaction?: {
+        events?: ReadonlyArray<{ kind: string; status: string; detail?: string }>;
+      };
+    }).__interaction?.events).toEqual([
+      expect.objectContaining({ kind: "stage.changed", status: "planning", detail: "preparing chapter inputs" }),
+      expect.objectContaining({ kind: "stage.changed", status: "writing", detail: "writing chapter draft" }),
+    ]);
   });
 
   it("writes current_focus and author_intent into canonical story paths", async () => {
@@ -66,6 +131,8 @@ describe("interaction tools", () => {
       {
         ensureControlDocuments: vi.fn(async () => {}),
         bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+        loadChapterIndex: vi.fn(async () => []),
+        saveChapterIndex: vi.fn(async () => undefined),
       },
     );
 

+ 41 - 0
packages/core/src/__tests__/project-interaction.test.ts

@@ -4,6 +4,7 @@ import { join } from "node:path";
 import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
 import {
   createProjectSession,
+  loadProjectSession,
   persistProjectSession,
 } from "../interaction/project-session-store.js";
 import { processProjectInteractionInput } from "../interaction/project-control.js";
@@ -30,6 +31,8 @@ describe("project interaction control", () => {
     const tools = {
       writeNextChapter: vi.fn(async () => ({ ok: true })),
       reviseDraft: vi.fn(async () => ({ ok: true })),
+      patchChapterText: vi.fn(async () => ({ ok: true })),
+      renameEntity: vi.fn(async () => ({ ok: true })),
       updateCurrentFocus: vi.fn(async () => ({ ok: true })),
       updateAuthorIntent: vi.fn(async () => ({ ok: true })),
       writeTruthFile: vi.fn(async () => ({ ok: true })),
@@ -44,6 +47,10 @@ describe("project interaction control", () => {
     expect(tools.writeNextChapter).toHaveBeenCalledWith("harbor");
     expect(result.session.activeBookId).toBe("harbor");
     expect(result.request.intent).toBe("write_next");
+    expect(result.session.events.map((event) => event.kind)).toEqual([
+      "task.started",
+      "task.completed",
+    ]);
   });
 
   it("persists mode switches in the project session", async () => {
@@ -58,6 +65,8 @@ describe("project interaction control", () => {
       tools: {
         writeNextChapter: vi.fn(async () => ({ ok: true })),
         reviseDraft: vi.fn(async () => ({ ok: true })),
+        patchChapterText: vi.fn(async () => ({ ok: true })),
+        renameEntity: vi.fn(async () => ({ ok: true })),
         updateCurrentFocus: vi.fn(async () => ({ ok: true })),
         updateAuthorIntent: vi.fn(async () => ({ ok: true })),
         writeTruthFile: vi.fn(async () => ({ ok: true })),
@@ -65,5 +74,37 @@ describe("project interaction control", () => {
     });
 
     expect(result.session.automationMode).toBe("auto");
+    expect(result.session.events.map((event) => event.kind)).toEqual([
+      "task.started",
+      "task.completed",
+    ]);
+  });
+
+  it("persists failed execution state when a routed action throws", async () => {
+    await persistProjectSession(projectRoot, {
+      ...createProjectSession(projectRoot),
+      activeBookId: "harbor",
+    });
+
+    await expect(processProjectInteractionInput({
+      projectRoot,
+      input: "continue",
+      tools: {
+        writeNextChapter: vi.fn(async () => {
+          throw new Error("boom");
+        }),
+        reviseDraft: vi.fn(async () => ({ ok: true })),
+        patchChapterText: vi.fn(async () => ({ ok: true })),
+        renameEntity: vi.fn(async () => ({ ok: true })),
+        updateCurrentFocus: vi.fn(async () => ({ ok: true })),
+        updateAuthorIntent: vi.fn(async () => ({ ok: true })),
+        writeTruthFile: vi.fn(async () => ({ ok: true })),
+      },
+    })).rejects.toThrow("boom");
+
+    const failedSession = await loadProjectSession(projectRoot);
+    expect(failedSession.currentExecution?.status).toBe("failed");
+    expect(failedSession.events.at(-1)?.kind).toBe("task.failed");
+    expect(failedSession.events.at(-1)?.detail).toContain("boom");
   });
 });

+ 3 - 0
packages/core/src/index.ts

@@ -121,8 +121,11 @@ export {
   type TruthAuthority,
 } from "./interaction/truth-authority.js";
 export {
+  executeEditTransaction,
   planEditTransaction,
   type EditRequest,
+  type EditExecutionDeps,
+  type ExecutedEditTransaction,
   type PlannedEditTransaction,
 } from "./interaction/edit-controller.js";
 export {

+ 156 - 0
packages/core/src/interaction/edit-controller.ts

@@ -1,3 +1,6 @@
+import { readdir, readFile, unlink, writeFile } from "node:fs/promises";
+import { join, relative } from "node:path";
+import type { ChapterMeta } from "../models/chapter.js";
 import { classifyTruthAuthority, normalizeTruthFileName, type TruthAuthority } from "./truth-authority.js";
 
 export type EditRequest =
@@ -19,6 +22,8 @@ export type EditRequest =
       readonly bookId: string;
       readonly chapterNumber: number;
       readonly instruction: string;
+      readonly targetText?: string;
+      readonly replacementText?: string;
     }
   | {
       readonly kind: "truth-file-edit";
@@ -42,6 +47,21 @@ export interface PlannedEditTransaction {
   readonly requiresTruthRebuild: boolean;
 }
 
+export interface EditExecutionDeps {
+  readonly bookDir: (bookId: string) => string;
+  readonly loadChapterIndex: (bookId: string) => Promise<ReadonlyArray<ChapterMeta>>;
+  readonly saveChapterIndex: (bookId: string, index: ReadonlyArray<ChapterMeta>) => Promise<void>;
+}
+
+export interface ExecutedEditTransaction {
+  readonly transactionType: EditRequest["kind"];
+  readonly bookId: string;
+  readonly chapterNumber?: number;
+  readonly touchedFiles: ReadonlyArray<string>;
+  readonly reviewRequired: boolean;
+  readonly summary: string;
+}
+
 export function planEditTransaction(request: EditRequest): PlannedEditTransaction {
   switch (request.kind) {
     case "entity-rename":
@@ -90,3 +110,139 @@ export function planEditTransaction(request: EditRequest): PlannedEditTransactio
       };
   }
 }
+
+function escapeRegExp(text: string): string {
+  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+async function collectEditableFiles(dir: string): Promise<ReadonlyArray<string>> {
+  const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
+  const files = await Promise.all(entries.map(async (entry) => {
+    const fullPath = join(dir, entry.name);
+    if (entry.isDirectory()) {
+      return collectEditableFiles(fullPath);
+    }
+    if (!/\.(md|json|ya?ml|txt)$/i.test(entry.name)) {
+      return [];
+    }
+    return [fullPath];
+  }));
+  return files.flat();
+}
+
+async function executeEntityRename(
+  deps: EditExecutionDeps,
+  request: Extract<EditRequest, { kind: "entity-rename" }>,
+): Promise<ExecutedEditTransaction> {
+  const root = deps.bookDir(request.bookId);
+  const files = await collectEditableFiles(root);
+  const touchedFiles: string[] = [];
+  const matcher = new RegExp(escapeRegExp(request.oldValue), "g");
+
+  for (const filePath of files) {
+    const content = await readFile(filePath, "utf-8");
+    const nextContent = content.replace(matcher, request.newValue);
+    if (nextContent === content) {
+      continue;
+    }
+    await writeFile(filePath, nextContent, "utf-8");
+    touchedFiles.push(relative(root, filePath));
+  }
+
+  if (touchedFiles.length === 0) {
+    throw new Error(`No occurrences of "${request.oldValue}" were found in "${request.bookId}".`);
+  }
+
+  return {
+    transactionType: request.kind,
+    bookId: request.bookId,
+    touchedFiles,
+    reviewRequired: false,
+    summary: `Renamed ${request.oldValue} to ${request.newValue} across ${touchedFiles.length} files.`,
+  };
+}
+
+async function executeChapterLocalEdit(
+  deps: EditExecutionDeps,
+  request: Extract<EditRequest, { kind: "chapter-local-edit" }>,
+): Promise<ExecutedEditTransaction> {
+  const root = deps.bookDir(request.bookId);
+  const chaptersDir = join(root, "chapters");
+  const paddedChapter = String(request.chapterNumber).padStart(4, "0");
+  const chapterFile = (await readdir(chaptersDir).catch(() => []))
+    .find((file) => file.startsWith(`${paddedChapter}_`) && file.endsWith(".md"));
+
+  if (!chapterFile) {
+    throw new Error(`Chapter ${request.chapterNumber} not found in "${request.bookId}".`);
+  }
+  if (!request.targetText || request.replacementText === undefined) {
+    throw new Error("Chapter-local edits require targetText and replacementText.");
+  }
+
+  const chapterPath = join(chaptersDir, chapterFile);
+  const content = await readFile(chapterPath, "utf-8");
+  const nextContent = content.split(request.targetText).join(request.replacementText);
+  if (nextContent === content) {
+    throw new Error(`Target text was not found in chapter ${request.chapterNumber}.`);
+  }
+  await writeFile(chapterPath, nextContent, "utf-8");
+
+  const runtimeDir = join(root, "story", "runtime");
+  const runtimeFiles = (await readdir(runtimeDir).catch(() => []))
+    .filter((file) => file.startsWith(`chapter-${paddedChapter}.`));
+  await Promise.all(runtimeFiles.map((file) => unlink(join(runtimeDir, file)).catch(() => undefined)));
+
+  const index = [...(await deps.loadChapterIndex(request.bookId))];
+  const updatedIndex = index.map((chapter) => chapter.number === request.chapterNumber
+    ? {
+        ...chapter,
+        status: "audit-failed" as const,
+        updatedAt: new Date().toISOString(),
+        auditIssues: [
+          ...chapter.auditIssues.filter((issue) => !issue.includes("Manual text edit requires review")),
+          "[warning] Manual text edit requires review before continuation.",
+        ],
+      }
+    : chapter);
+  await deps.saveChapterIndex(request.bookId, updatedIndex);
+
+  return {
+    transactionType: request.kind,
+    bookId: request.bookId,
+    chapterNumber: request.chapterNumber,
+    touchedFiles: [
+      relative(root, chapterPath),
+      ...runtimeFiles.map((file) => relative(root, join(runtimeDir, file))),
+      "chapters/index.json",
+    ],
+    reviewRequired: true,
+    summary: `Patched chapter ${request.chapterNumber} and marked it for review.`,
+  };
+}
+
+export async function executeEditTransaction(
+  deps: EditExecutionDeps,
+  request: EditRequest,
+): Promise<ExecutedEditTransaction> {
+  switch (request.kind) {
+    case "entity-rename":
+      return executeEntityRename(deps, request);
+    case "chapter-local-edit":
+      return executeChapterLocalEdit(deps, request);
+    case "truth-file-edit": {
+      const root = deps.bookDir(request.bookId);
+      const normalizedFileName = normalizeTruthFileName(request.fileName);
+      const filePath = join(root, "story", normalizedFileName);
+      await writeFile(filePath, request.instruction, "utf-8");
+      return {
+        transactionType: request.kind,
+        bookId: request.bookId,
+        touchedFiles: [relative(root, filePath)],
+        reviewRequired: false,
+        summary: `Updated ${normalizedFileName}.`,
+      };
+    }
+    default:
+      throw new Error(`Edit transaction "${request.kind}" is not executable yet.`);
+  }
+}

+ 6 - 0
packages/core/src/interaction/intents.ts

@@ -10,7 +10,9 @@ export const InteractionIntentTypeSchema = z.enum([
   "resume_book",
   "revise_chapter",
   "rewrite_chapter",
+  "patch_chapter_text",
   "edit_truth",
+  "rename_entity",
   "update_focus",
   "update_author_intent",
   "explain_status",
@@ -26,6 +28,10 @@ export const InteractionRequestSchema = z.object({
   bookId: z.string().min(1).optional(),
   chapterNumber: z.number().int().min(1).optional(),
   fileName: z.string().min(1).optional(),
+  oldValue: z.string().min(1).optional(),
+  newValue: z.string().min(1).optional(),
+  targetText: z.string().min(1).optional(),
+  replacementText: z.string().min(1).optional(),
   instruction: z.string().min(1).optional(),
   mode: AutomationModeSchema.optional(),
 });

+ 41 - 0
packages/core/src/interaction/nl-router.ts

@@ -90,6 +90,27 @@ export function routeNaturalLanguageIntent(
     };
   }
 
+  const slashRename = trimmed.match(/^\/rename\s+(.+?)\s*=>\s*(.+)$/i);
+  if (slashRename) {
+    return {
+      intent: "rename_entity",
+      ...(bookId ? { bookId } : {}),
+      oldValue: slashRename[1]!.trim(),
+      newValue: slashRename[2]!.trim(),
+    };
+  }
+
+  const slashReplace = trimmed.match(/^\/replace\s+(\d+)\s+(.+?)\s*=>\s*(.+)$/i);
+  if (slashReplace) {
+    return {
+      intent: "patch_chapter_text",
+      ...(bookId ? { bookId } : {}),
+      chapterNumber: parseInt(slashReplace[1]!, 10),
+      targetText: slashReplace[2]!.trim(),
+      replacementText: slashReplace[3]!.trim(),
+    };
+  }
+
   const rewriteMatch = trimmed.match(/(?:rewrite chapter|重写第)\s*(\d+)\s*(?:章)?/i);
   if (rewriteMatch) {
     return {
@@ -110,6 +131,26 @@ export function routeNaturalLanguageIntent(
     };
   }
 
+  const zhRenameMatch = trimmed.match(/^把(.+?)改成(.+)$/);
+  if (zhRenameMatch) {
+    return {
+      intent: "rename_entity",
+      ...(bookId ? { bookId } : {}),
+      oldValue: zhRenameMatch[1]!.trim(),
+      newValue: zhRenameMatch[2]!.trim(),
+    };
+  }
+
+  const enRenameMatch = trimmed.match(/^rename\s+(.+?)\s+to\s+(.+)$/i);
+  if (enRenameMatch) {
+    return {
+      intent: "rename_entity",
+      ...(bookId ? { bookId } : {}),
+      oldValue: enRenameMatch[1]!.trim(),
+      newValue: enRenameMatch[2]!.trim(),
+    };
+  }
+
   if (/(focus|聚焦|主线|旧案线)/i.test(trimmed)) {
     return {
       intent: "update_focus",

+ 33 - 11
packages/core/src/interaction/project-control.ts

@@ -1,4 +1,4 @@
-import { appendInteractionMessage } from "./session.js";
+import { appendInteractionEvent, appendInteractionMessage } from "./session.js";
 import { routeNaturalLanguageIntent } from "./nl-router.js";
 import type { InteractionRuntimeTools } from "./runtime.js";
 import { runInteractionRequest } from "./runtime.js";
@@ -28,14 +28,36 @@ export async function processProjectInteractionInput(params: {
   const request = routeNaturalLanguageIntent(params.input, {
     activeBookId: userSession.activeBookId,
   });
-  const result = await runInteractionRequest({
-    session: userSession,
-    request,
-    tools: params.tools,
-  });
-  await persistProjectSession(params.projectRoot, result.session);
-  return {
-    ...result,
-    request,
-  };
+  try {
+    const result = await runInteractionRequest({
+      session: userSession,
+      request,
+      tools: params.tools,
+    });
+    await persistProjectSession(params.projectRoot, result.session);
+    return {
+      ...result,
+      request,
+    };
+  } catch (error) {
+    const detail = error instanceof Error ? error.message : String(error);
+    const failedSession = appendInteractionEvent({
+      ...userSession,
+      currentExecution: {
+        status: "failed",
+        bookId: userSession.activeBookId,
+        chapterNumber: userSession.activeChapterNumber,
+        stageLabel: `failed ${request.intent}`,
+      },
+    }, {
+      kind: "task.failed",
+      timestamp: Date.now(),
+      status: "failed",
+      bookId: userSession.activeBookId,
+      chapterNumber: userSession.activeChapterNumber,
+      detail,
+    });
+    await persistProjectSession(params.projectRoot, failedSession);
+    throw error;
+  }
 }

+ 241 - 4
packages/core/src/interaction/project-tools.ts

@@ -1,18 +1,255 @@
 import { writeFile } from "node:fs/promises";
 import { join } from "node:path";
-import type { PipelineRunner, StateManager, ReviseMode } from "../index.js";
+import type {
+  InteractionEvent,
+  Logger,
+  PipelineRunner,
+  StateManager,
+  ReviseMode,
+} from "../index.js";
+import { executeEditTransaction } from "./edit-controller.js";
 import type { InteractionRuntimeTools } from "./runtime.js";
 
 type PipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft">;
-type StateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir">;
+type StateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir" | "loadChapterIndex" | "saveChapterIndex">;
+type InstrumentablePipelineLike = PipelineLike & {
+  readonly config?: {
+    logger?: Logger;
+  };
+};
+
+function mapStageMessageToStatus(message: string): InteractionEvent["status"] | undefined {
+  const lower = message.trim().toLowerCase();
+  if (
+    lower.includes("planning next chapter")
+    || lower.includes("generating foundation")
+    || lower.includes("reviewing foundation")
+    || lower.includes("preparing chapter inputs")
+    || message.includes("规划下一章意图")
+    || message.includes("生成基础设定")
+    || message.includes("审核基础设定")
+    || message.includes("准备章节输入")
+  ) {
+    return "planning";
+  }
+  if (
+    lower.includes("composing chapter runtime context")
+    || message.includes("组装章节运行时上下文")
+  ) {
+    return "composing";
+  }
+  if (
+    lower.includes("writing chapter draft")
+    || message.includes("撰写章节草稿")
+  ) {
+    return "writing";
+  }
+  if (
+    lower.includes("auditing draft")
+    || message.includes("审计草稿")
+  ) {
+    return "assessing";
+  }
+  if (
+    lower.includes("fixing")
+    || lower.includes("revising chapter")
+    || lower.includes("rewrite")
+    || lower.includes("repair")
+    || message.includes("自动修复")
+    || message.includes("整章改写")
+    || message.includes("修订第")
+  ) {
+    return "repairing";
+  }
+  if (
+    lower.includes("persist")
+    || lower.includes("saving")
+    || lower.includes("snapshot")
+    || lower.includes("rebuilding final truth files")
+    || lower.includes("validating truth file updates")
+    || lower.includes("syncing memory indexes")
+    || message.includes("落盘")
+    || message.includes("保存")
+    || message.includes("快照")
+    || message.includes("校验真相文件变更")
+    || message.includes("生成最终真相文件")
+    || message.includes("同步记忆索引")
+  ) {
+    return "persisting";
+  }
+  return undefined;
+}
+
+function extractStageDetail(message: string): string | undefined {
+  if (message.startsWith("Stage: ")) {
+    return message.slice("Stage: ".length).trim();
+  }
+  if (message.startsWith("阶段:")) {
+    return message.slice("阶段:".length).trim();
+  }
+  return undefined;
+}
+
+function createInteractionLogger(
+  original: Logger | undefined,
+  events: InteractionEvent[],
+  bookId: string,
+): Logger {
+  const emit = (level: "debug" | "info" | "warn" | "error", message: string): void => {
+    const stageDetail = extractStageDetail(message);
+    const stageStatus = stageDetail ? mapStageMessageToStatus(stageDetail) : undefined;
+
+    if (stageDetail && stageStatus) {
+      events.push({
+        kind: "stage.changed",
+        timestamp: Date.now(),
+        status: stageStatus,
+        bookId,
+        detail: stageDetail,
+      });
+      return;
+    }
+
+    if (level === "warn") {
+      events.push({
+        kind: "task.warning",
+        timestamp: Date.now(),
+        status: "blocked",
+        bookId,
+        detail: message,
+      });
+      return;
+    }
+
+    if (level === "error") {
+      events.push({
+        kind: "task.failed",
+        timestamp: Date.now(),
+        status: "failed",
+        bookId,
+        detail: message,
+      });
+    }
+  };
+
+  const wrap = (base: Logger | undefined): Logger => ({
+    debug: (msg, ctx) => {
+      emit("debug", msg);
+      base?.debug(msg, ctx);
+    },
+    info: (msg, ctx) => {
+      emit("info", msg);
+      base?.info(msg, ctx);
+    },
+    warn: (msg, ctx) => {
+      emit("warn", msg);
+      base?.warn(msg, ctx);
+    },
+    error: (msg, ctx) => {
+      emit("error", msg);
+      base?.error(msg, ctx);
+    },
+    child: (tag, extraCtx) => wrap(base?.child(tag, extraCtx)),
+  });
+
+  return wrap(original);
+}
+
+async function withPipelineInteractionTelemetry<T extends { chapterNumber?: number }>(
+  pipeline: InstrumentablePipelineLike,
+  bookId: string,
+  executor: () => Promise<T>,
+): Promise<T & {
+  __interaction: {
+    events: ReadonlyArray<InteractionEvent>;
+    activeChapterNumber?: number;
+  };
+}> {
+  const events: InteractionEvent[] = [];
+  const originalLogger = pipeline.config?.logger;
+  if (pipeline.config) {
+    pipeline.config.logger = createInteractionLogger(originalLogger, events, bookId);
+  }
+
+  try {
+    const result = await executor();
+    return {
+      ...result,
+      __interaction: {
+        events,
+        ...(typeof result.chapterNumber === "number"
+          ? { activeChapterNumber: result.chapterNumber }
+          : {}),
+      },
+    };
+  } finally {
+    if (pipeline.config) {
+      pipeline.config.logger = originalLogger;
+    }
+  }
+}
 
 export function createInteractionToolsFromDeps(
   pipeline: PipelineLike,
   state: StateLike,
 ): InteractionRuntimeTools {
+  const instrumentedPipeline = pipeline as InstrumentablePipelineLike;
+
   return {
-    writeNextChapter: (bookId) => pipeline.writeNextChapter(bookId),
-    reviseDraft: (bookId, chapterNumber, mode) => pipeline.reviseDraft(bookId, chapterNumber, mode as ReviseMode),
+    writeNextChapter: (bookId) => withPipelineInteractionTelemetry(
+      instrumentedPipeline,
+      bookId,
+      () => pipeline.writeNextChapter(bookId),
+    ),
+    reviseDraft: (bookId, chapterNumber, mode) => withPipelineInteractionTelemetry(
+      instrumentedPipeline,
+      bookId,
+      () => pipeline.reviseDraft(bookId, chapterNumber, mode as ReviseMode),
+    ),
+    patchChapterText: async (bookId, chapterNumber, targetText, replacementText) => {
+      const execution = await executeEditTransaction(
+        {
+          bookDir: (targetBookId) => state.bookDir(targetBookId),
+          loadChapterIndex: (targetBookId) => state.loadChapterIndex(targetBookId),
+          saveChapterIndex: (targetBookId, index) => state.saveChapterIndex(targetBookId, index),
+        },
+        {
+          kind: "chapter-local-edit",
+          bookId,
+          chapterNumber,
+          instruction: `Replace ${targetText} with ${replacementText}`,
+          targetText,
+          replacementText,
+        },
+      );
+      return {
+        __interaction: {
+          activeChapterNumber: chapterNumber,
+          responseText: execution.summary,
+        },
+      };
+    },
+    renameEntity: async (bookId, oldValue, newValue) => {
+      const execution = await executeEditTransaction(
+        {
+          bookDir: (targetBookId) => state.bookDir(targetBookId),
+          loadChapterIndex: (targetBookId) => state.loadChapterIndex(targetBookId),
+          saveChapterIndex: (targetBookId, index) => state.saveChapterIndex(targetBookId, index),
+        },
+        {
+          kind: "entity-rename",
+          bookId,
+          entityType: "character",
+          oldValue,
+          newValue,
+        },
+      );
+      return {
+        __interaction: {
+          responseText: execution.summary,
+        },
+      };
+    },
     updateCurrentFocus: async (bookId, content) => {
       await state.ensureControlDocuments(bookId);
       await writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), content, "utf-8");

+ 366 - 21
packages/core/src/interaction/runtime.ts

@@ -1,14 +1,31 @@
 import type { AutomationMode } from "./modes.js";
 import { routeInteractionRequest } from "./request-router.js";
 import type { InteractionRequest } from "./intents.js";
-import type { InteractionSession } from "./session.js";
-import { appendInteractionEvent, bindActiveBook, updateAutomationMode } from "./session.js";
+import type { ExecutionState, InteractionEvent } from "./events.js";
+import type { PendingDecision, InteractionSession } from "./session.js";
+import {
+  appendInteractionEvent,
+  bindActiveBook,
+  clearPendingDecision,
+  updateAutomationMode,
+} from "./session.js";
 
 type ReviseMode = "local-fix" | "rewrite";
 
 export interface InteractionRuntimeTools {
   readonly writeNextChapter: (bookId: string) => Promise<unknown>;
   readonly reviseDraft: (bookId: string, chapterNumber: number, mode: ReviseMode) => Promise<unknown>;
+  readonly patchChapterText: (
+    bookId: string,
+    chapterNumber: number,
+    targetText: string,
+    replacementText: string,
+  ) => Promise<unknown>;
+  readonly renameEntity: (
+    bookId: string,
+    oldValue: string,
+    newValue: string,
+  ) => Promise<unknown>;
   readonly updateCurrentFocus: (bookId: string, content: string) => Promise<unknown>;
   readonly updateAuthorIntent: (bookId: string, content: string) => Promise<unknown>;
   readonly writeTruthFile: (bookId: string, fileName: string, content: string) => Promise<unknown>;
@@ -19,6 +36,160 @@ export interface InteractionRuntimeResult {
   readonly responseText?: string;
 }
 
+interface InteractionToolMetadata {
+  readonly events?: ReadonlyArray<InteractionEvent>;
+  readonly activeChapterNumber?: number;
+  readonly currentExecution?: ExecutionState;
+  readonly pendingDecision?: PendingDecision;
+  readonly responseText?: string;
+}
+
+function extractToolMetadata(value: unknown): InteractionToolMetadata {
+  const chapterNumber = typeof value === "object" && value !== null && "chapterNumber" in value
+    && typeof (value as { chapterNumber?: unknown }).chapterNumber === "number"
+    ? (value as { chapterNumber: number }).chapterNumber
+    : undefined;
+
+  if (!value || typeof value !== "object" || !("__interaction" in value)) {
+    return {
+      ...(chapterNumber !== undefined ? { activeChapterNumber: chapterNumber } : {}),
+    };
+  }
+
+  const interaction = (value as {
+    readonly __interaction?: InteractionToolMetadata;
+  }).__interaction;
+
+  return {
+    ...interaction,
+    ...(interaction?.activeChapterNumber === undefined && chapterNumber !== undefined
+      ? { activeChapterNumber: chapterNumber }
+      : {}),
+  };
+}
+
+function buildTaskStartedState(
+  session: InteractionSession,
+  request: InteractionRequest,
+): ExecutionState {
+  switch (request.intent) {
+    case "write_next":
+    case "continue_book":
+      return {
+        status: "planning",
+        bookId: request.bookId ?? session.activeBookId,
+        chapterNumber: session.activeChapterNumber,
+        stageLabel: "preparing chapter inputs",
+      };
+    case "revise_chapter":
+    case "rewrite_chapter":
+      return {
+        status: "repairing",
+        bookId: request.bookId ?? session.activeBookId,
+        chapterNumber: request.chapterNumber ?? session.activeChapterNumber,
+        stageLabel: request.intent === "rewrite_chapter" ? "rewriting chapter" : "revising chapter",
+      };
+    case "update_focus":
+    case "update_author_intent":
+    case "edit_truth":
+      return {
+        status: "persisting",
+        bookId: request.bookId ?? session.activeBookId,
+        chapterNumber: session.activeChapterNumber,
+        stageLabel: "applying project edit",
+      };
+    case "pause_book":
+      return {
+        status: "blocked",
+        bookId: request.bookId ?? session.activeBookId,
+        chapterNumber: session.activeChapterNumber,
+        stageLabel: "paused by user",
+      };
+    default:
+      return {
+        status: "planning",
+        bookId: request.bookId ?? session.activeBookId,
+        chapterNumber: session.activeChapterNumber,
+        stageLabel: `handling ${request.intent}`,
+      };
+  }
+}
+
+function shouldWaitForHuman(
+  automationMode: AutomationMode,
+  request: InteractionRequest,
+): boolean {
+  const contentIntent = request.intent === "write_next"
+    || request.intent === "continue_book"
+    || request.intent === "revise_chapter"
+    || request.intent === "rewrite_chapter"
+    || request.intent === "patch_chapter_text";
+  const editIntent = request.intent === "update_focus"
+    || request.intent === "update_author_intent"
+    || request.intent === "edit_truth"
+    || request.intent === "rename_entity";
+
+  if (automationMode === "auto") {
+    return false;
+  }
+  if (automationMode === "semi") {
+    return contentIntent;
+  }
+  return contentIntent || editIntent;
+}
+
+function buildPendingDecision(
+  session: InteractionSession,
+  request: InteractionRequest,
+  chapterNumber?: number,
+): PendingDecision | undefined {
+  if (!shouldWaitForHuman(session.automationMode, request)) {
+    return undefined;
+  }
+
+  const bookId = request.bookId ?? session.activeBookId;
+  if (!bookId) {
+    return undefined;
+  }
+
+  return {
+    kind: "review-next-step",
+    bookId,
+    ...(chapterNumber !== undefined ? { chapterNumber } : {}),
+    summary: session.automationMode === "manual"
+      ? "Execution finished. Choose the next action explicitly."
+      : "Execution finished. Waiting for your next decision.",
+  };
+}
+
+function buildWaitingExecution(
+  session: InteractionSession,
+  request: InteractionRequest,
+  chapterNumber?: number,
+): ExecutionState {
+  return {
+    status: "waiting_human",
+    bookId: request.bookId ?? session.activeBookId,
+    ...(chapterNumber !== undefined ? { chapterNumber } : {}),
+    stageLabel: "waiting for your next decision",
+  };
+}
+
+function appendToolEvents(
+  session: InteractionSession,
+  events: ReadonlyArray<InteractionEvent> | undefined,
+): InteractionSession {
+  if (!events || events.length === 0) {
+    return session;
+  }
+
+  const baseTimestamp = Date.now();
+  return events.reduce((nextSession, event, index) => appendInteractionEvent(nextSession, {
+    ...event,
+    timestamp: baseTimestamp - events.length + index,
+  }), session);
+}
+
 export async function runInteractionRequest(params: {
   readonly session: InteractionSession;
   readonly request: InteractionRequest;
@@ -29,7 +200,7 @@ export async function runInteractionRequest(params: {
   const addEvent = (
     nextSession: InteractionSession,
     kind: string,
-    status: "completed" | "blocked",
+    status: InteractionEvent["status"],
     detail: string,
   ): InteractionSession => appendInteractionEvent(nextSession, {
     kind,
@@ -44,6 +215,12 @@ export async function runInteractionRequest(params: {
     session = updateAutomationMode(session, request.mode as AutomationMode);
   }
 
+  session = clearPendingDecision({
+    ...session,
+    currentExecution: buildTaskStartedState(session, request),
+  });
+  session = addEvent(session, "task.started", session.currentExecution!.status, `Started ${request.intent}.`);
+
   const markCompleted = (nextSession: InteractionSession): InteractionSession => ({
     ...nextSession,
     currentExecution: {
@@ -61,12 +238,32 @@ export async function runInteractionRequest(params: {
       if (!bookId) {
         throw new Error("No active book is bound to the interaction session.");
       }
-      await params.tools.writeNextChapter(bookId);
-      session = bindActiveBook(session, bookId);
-      const completed = markCompleted(session);
+      const toolResult = await params.tools.writeNextChapter(bookId);
+      const metadata = extractToolMetadata(toolResult);
+      session = bindActiveBook(session, bookId, metadata.activeChapterNumber);
+      session = appendToolEvents(session, metadata.events);
+      const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(
+        session,
+        request,
+        metadata.activeChapterNumber,
+      );
+      const completed = pendingDecision
+        ? {
+            ...session,
+            pendingDecision,
+            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, metadata.activeChapterNumber),
+          }
+        : {
+            ...markCompleted(session),
+            currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
+          };
       return {
         session: addEvent(completed, "task.completed", "completed", `Completed write_next for ${bookId}.`),
-        responseText: `Completed write_next for ${bookId}.`,
+        responseText: metadata.responseText ?? (
+          pendingDecision
+            ? `Completed write_next for ${bookId}; waiting for your next decision.`
+            : `Completed write_next for ${bookId}.`
+        ),
       };
     }
     case "revise_chapter":
@@ -79,12 +276,111 @@ export async function runInteractionRequest(params: {
         throw new Error("Chapter number is required for chapter revision.");
       }
       const mode: ReviseMode = request.intent === "rewrite_chapter" ? "rewrite" : "local-fix";
-      await params.tools.reviseDraft(bookId, request.chapterNumber, mode);
-      session = bindActiveBook(session, bookId, request.chapterNumber);
-      const completed = markCompleted(session);
+      const toolResult = await params.tools.reviseDraft(bookId, request.chapterNumber, mode);
+      const metadata = extractToolMetadata(toolResult);
+      const chapterNumber = metadata.activeChapterNumber ?? request.chapterNumber;
+      session = bindActiveBook(session, bookId, chapterNumber);
+      session = appendToolEvents(session, metadata.events);
+      const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(
+        session,
+        request,
+        chapterNumber,
+      );
+      const completed = pendingDecision
+        ? {
+            ...session,
+            pendingDecision,
+            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, chapterNumber),
+          }
+        : {
+            ...markCompleted(session),
+            currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
+          };
       return {
         session: addEvent(completed, "task.completed", "completed", `Completed ${request.intent} for ${bookId}.`),
-        responseText: `Completed ${request.intent} for ${bookId}.`,
+        responseText: metadata.responseText ?? (
+          pendingDecision
+            ? `Completed ${request.intent} for ${bookId}; waiting for your next decision.`
+            : `Completed ${request.intent} for ${bookId}.`
+        ),
+      };
+    }
+    case "patch_chapter_text": {
+      const bookId = request.bookId ?? session.activeBookId;
+      if (!bookId) {
+        throw new Error("No active book is bound to the interaction session.");
+      }
+      if (!request.chapterNumber || !request.targetText || !request.replacementText) {
+        throw new Error("Chapter patch requires chapter number, target text, and replacement text.");
+      }
+      const toolResult = await params.tools.patchChapterText(
+        bookId,
+        request.chapterNumber,
+        request.targetText,
+        request.replacementText,
+      );
+      const metadata = extractToolMetadata(toolResult);
+      const chapterNumber = metadata.activeChapterNumber ?? request.chapterNumber;
+      session = bindActiveBook(session, bookId, chapterNumber);
+      session = appendToolEvents(session, metadata.events);
+      const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(
+        session,
+        request,
+        chapterNumber,
+      );
+      const completed = pendingDecision
+        ? {
+            ...session,
+            pendingDecision,
+            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, chapterNumber),
+          }
+        : {
+            ...markCompleted(session),
+            currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
+          };
+      return {
+        session: addEvent(completed, "task.completed", "completed", `Patched chapter ${chapterNumber} for ${bookId}.`),
+        responseText: metadata.responseText ?? (
+          pendingDecision
+            ? `Patched chapter ${chapterNumber} for ${bookId}; waiting for your next decision.`
+            : `Patched chapter ${chapterNumber} for ${bookId}.`
+        ),
+      };
+    }
+    case "rename_entity": {
+      const bookId = request.bookId ?? session.activeBookId;
+      if (!bookId) {
+        throw new Error("No active book is bound to the interaction session.");
+      }
+      if (!request.oldValue || !request.newValue) {
+        throw new Error("Entity rename requires old and new values.");
+      }
+      const toolResult = await params.tools.renameEntity(bookId, request.oldValue, request.newValue);
+      const metadata = extractToolMetadata(toolResult);
+      session = bindActiveBook(session, bookId, metadata.activeChapterNumber);
+      session = appendToolEvents(session, metadata.events);
+      const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(
+        session,
+        request,
+        metadata.activeChapterNumber,
+      );
+      const completed = pendingDecision
+        ? {
+            ...session,
+            pendingDecision,
+            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, metadata.activeChapterNumber),
+          }
+        : {
+            ...markCompleted(session),
+            currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
+          };
+      return {
+        session: addEvent(completed, "task.completed", "completed", `Renamed ${request.oldValue} to ${request.newValue} in ${bookId}.`),
+        responseText: metadata.responseText ?? (
+          pendingDecision
+            ? `Renamed ${request.oldValue} to ${request.newValue} in ${bookId}; waiting for your next decision.`
+            : `Renamed ${request.oldValue} to ${request.newValue} in ${bookId}.`
+        ),
       };
     }
     case "update_focus": {
@@ -95,12 +391,28 @@ export async function runInteractionRequest(params: {
       if (!request.instruction) {
         throw new Error("Focus update requires instruction content.");
       }
-      await params.tools.updateCurrentFocus(bookId, request.instruction);
+      const toolResult = await params.tools.updateCurrentFocus(bookId, request.instruction);
+      const metadata = extractToolMetadata(toolResult);
       session = bindActiveBook(session, bookId);
-      const completed = markCompleted(session);
+      session = appendToolEvents(session, metadata.events);
+      const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(session, request);
+      const completed = pendingDecision
+        ? {
+            ...session,
+            pendingDecision,
+            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request),
+          }
+        : {
+            ...markCompleted(session),
+            currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
+          };
       return {
         session: addEvent(completed, "task.completed", "completed", `Updated current focus for ${bookId}.`),
-        responseText: `Updated current focus for ${bookId}.`,
+        responseText: metadata.responseText ?? (
+          pendingDecision
+            ? `Updated current focus for ${bookId}; waiting for your next decision.`
+            : `Updated current focus for ${bookId}.`
+        ),
       };
     }
     case "update_author_intent": {
@@ -111,12 +423,28 @@ export async function runInteractionRequest(params: {
       if (!request.instruction) {
         throw new Error("Author intent update requires instruction content.");
       }
-      await params.tools.updateAuthorIntent(bookId, request.instruction);
+      const toolResult = await params.tools.updateAuthorIntent(bookId, request.instruction);
+      const metadata = extractToolMetadata(toolResult);
       session = bindActiveBook(session, bookId);
-      const completed = markCompleted(session);
+      session = appendToolEvents(session, metadata.events);
+      const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(session, request);
+      const completed = pendingDecision
+        ? {
+            ...session,
+            pendingDecision,
+            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request),
+          }
+        : {
+            ...markCompleted(session),
+            currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
+          };
       return {
         session: addEvent(completed, "task.completed", "completed", `Updated author intent for ${bookId}.`),
-        responseText: `Updated author intent for ${bookId}.`,
+        responseText: metadata.responseText ?? (
+          pendingDecision
+            ? `Updated author intent for ${bookId}; waiting for your next decision.`
+            : `Updated author intent for ${bookId}.`
+        ),
       };
     }
     case "edit_truth": {
@@ -127,12 +455,28 @@ export async function runInteractionRequest(params: {
       if (!request.fileName || !request.instruction) {
         throw new Error("Truth-file edit requires a file name and content.");
       }
-      await params.tools.writeTruthFile(bookId, request.fileName, request.instruction);
+      const toolResult = await params.tools.writeTruthFile(bookId, request.fileName, request.instruction);
+      const metadata = extractToolMetadata(toolResult);
       session = bindActiveBook(session, bookId);
-      const completed = markCompleted(session);
+      session = appendToolEvents(session, metadata.events);
+      const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(session, request);
+      const completed = pendingDecision
+        ? {
+            ...session,
+            pendingDecision,
+            currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request),
+          }
+        : {
+            ...markCompleted(session),
+            currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
+          };
       return {
         session: addEvent(completed, "task.completed", "completed", `Updated ${request.fileName} for ${bookId}.`),
-        responseText: `Updated ${request.fileName} for ${bookId}.`,
+        responseText: metadata.responseText ?? (
+          pendingDecision
+            ? `Updated ${request.fileName} for ${bookId}; waiting for your next decision.`
+            : `Updated ${request.fileName} for ${bookId}.`
+        ),
       };
     }
     case "switch_mode":
@@ -176,7 +520,8 @@ export async function runInteractionRequest(params: {
     case "explain_status":
     case "explain_failure": {
       const bookId = request.bookId ?? session.activeBookId;
-      const stage = session.currentExecution?.stageLabel ?? session.currentExecution?.status ?? "idle";
+      const baselineExecution = params.session.currentExecution;
+      const stage = baselineExecution?.stageLabel ?? baselineExecution?.status ?? "idle";
       const summary = request.intent === "explain_failure"
         ? `Current failure context: ${bookId ?? "no active book"} is at ${stage}.`
         : `Current status: ${bookId ?? "no active book"} is at ${stage}.`;

+ 1 - 1
packages/core/src/interaction/session.ts

@@ -82,6 +82,6 @@ export function appendInteractionEvent(
 ): InteractionSession {
   return {
     ...session,
-    events: [...session.events, event].sort((left, right) => left.timestamp - right.timestamp),
+    events: [...session.events, event],
   };
 }

+ 42 - 1
packages/studio/src/components/ChatBar.tsx

@@ -44,6 +44,8 @@ interface ChatMessage {
 interface SharedSessionMeta {
   readonly activeBookId?: string;
   readonly automationMode?: string;
+  readonly currentStage?: string;
+  readonly pendingSummary?: string;
 }
 
 interface BookRef {
@@ -78,6 +80,14 @@ export function resolveDirectWriteTarget(
   return { bookId: null, reason: "ambiguous" };
 }
 
+export function formatSharedSessionContext(meta: SharedSessionMeta): string {
+  return [
+    meta.activeBookId ?? "no-book",
+    meta.automationMode ?? "semi",
+    meta.currentStage,
+  ].filter(Boolean).join(" · ");
+}
+
 // ── Sub-components ──
 
 function StatusIcon({ phase }: { readonly phase: string }) {
@@ -245,6 +255,13 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
       session?: {
         activeBookId?: string;
         automationMode?: string;
+        currentExecution?: {
+          status?: string;
+          stageLabel?: string;
+        };
+        pendingDecision?: {
+          summary?: string;
+        };
         messages?: ReadonlyArray<{ role: "user" | "assistant" | "system"; content: string; timestamp: number }>;
       };
       activeBookId?: string;
@@ -253,6 +270,8 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
       setSessionMeta({
         activeBookId: data.activeBookId ?? data.session?.activeBookId,
         automationMode: data.session?.automationMode,
+        currentStage: data.session?.currentExecution?.stageLabel ?? data.session?.currentExecution?.status,
+        pendingSummary: data.session?.pendingDecision?.summary,
       });
       const restored = coerceSharedSessionMessages(data.session?.messages ?? []);
       if (restored.length > 0) {
@@ -354,6 +373,13 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
         session?: {
           activeBookId?: string;
           automationMode?: string;
+          currentExecution?: {
+            status?: string;
+            stageLabel?: string;
+          };
+          pendingDecision?: {
+            summary?: string;
+          };
           messages?: ReadonlyArray<{ role: "user" | "assistant" | "system"; content: string; timestamp: number }>;
         };
       }>("/agent", {
@@ -366,6 +392,8 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
         setSessionMeta({
           activeBookId: data.session.activeBookId ?? activeBookId,
           automationMode: data.session.automationMode,
+          currentStage: data.session.currentExecution?.stageLabel ?? data.session.currentExecution?.status,
+          pendingSummary: data.session.pendingDecision?.summary,
         });
         const restored = coerceSharedSessionMessages(data.session.messages ?? []);
         if (restored.length > 0) {
@@ -440,7 +468,11 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
                   InkOS Assistant
                 </div>
                 <div className="text-[10px] text-muted-foreground/60 truncate">
-                  {(sessionMeta.activeBookId ?? activeBookId ?? "no-book")} · {sessionMeta.automationMode ?? "semi"}
+                  {formatSharedSessionContext({
+                    activeBookId: sessionMeta.activeBookId ?? activeBookId,
+                    automationMode: sessionMeta.automationMode,
+                    currentStage: sessionMeta.currentStage,
+                  })}
                 </div>
               </div>
             </div>
@@ -479,6 +511,15 @@ export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
             </div>
           )}
 
+          {!loading && sessionMeta.pendingSummary && (
+            <div className="shrink-0 px-4 py-2 border-b border-border/30 bg-amber-500/[0.06] fade-in">
+              <div className="flex items-center gap-2 text-[11px] text-amber-700 dark:text-amber-300">
+                <AlertTriangle size={12} />
+                <span className="truncate">{sessionMeta.pendingSummary}</span>
+              </div>
+            </div>
+          )}
+
           {/* ── Section 3: Messages ── */}
           <div
             ref={scrollRef}

+ 13 - 1
packages/studio/src/components/chatbar-state.test.ts

@@ -1,5 +1,9 @@
 import { describe, expect, it } from "vitest";
-import { coerceSharedSessionMessages, resolveDirectWriteTarget } from "./ChatBar";
+import {
+  coerceSharedSessionMessages,
+  formatSharedSessionContext,
+  resolveDirectWriteTarget,
+} from "./ChatBar";
 
 describe("resolveDirectWriteTarget", () => {
   it("prefers the active book when the user is already inside a book flow", () => {
@@ -46,4 +50,12 @@ describe("resolveDirectWriteTarget", () => {
       { role: "assistant", content: "Completed write_next for harbor.", timestamp: 2 },
     ]);
   });
+
+  it("formats shared session context with mode and stage", () => {
+    expect(formatSharedSessionContext({
+      activeBookId: "harbor",
+      automationMode: "semi",
+      currentStage: "waiting for your next decision",
+    })).toBe("harbor · semi · waiting for your next decision");
+  });
 });