composer.test.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660
  1. import { afterEach, beforeEach, describe, expect, it } from "vitest";
  2. import { createRequire } from "node:module";
  3. import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
  4. import { tmpdir } from "node:os";
  5. import { join } from "node:path";
  6. import type { BookConfig } from "../models/book.js";
  7. import type { PlanChapterOutput } from "../agents/planner.js";
  8. import { ComposerAgent } from "../agents/composer.js";
  9. const require = createRequire(import.meta.url);
  10. const hasNodeSqlite = (() => {
  11. try {
  12. require("node:sqlite");
  13. return true;
  14. } catch {
  15. return false;
  16. }
  17. })();
  18. describe("ComposerAgent", () => {
  19. let root: string;
  20. let bookDir: string;
  21. let storyDir: string;
  22. let book: BookConfig;
  23. let plan: PlanChapterOutput;
  24. beforeEach(async () => {
  25. root = await mkdtemp(join(tmpdir(), "inkos-composer-test-"));
  26. bookDir = join(root, "books", "composer-book");
  27. storyDir = join(bookDir, "story");
  28. await mkdir(join(storyDir, "runtime"), { recursive: true });
  29. book = {
  30. id: "composer-book",
  31. title: "Composer Book",
  32. platform: "tomato",
  33. genre: "xuanhuan",
  34. status: "active",
  35. targetChapters: 20,
  36. chapterWordCount: 3000,
  37. createdAt: "2026-03-22T00:00:00.000Z",
  38. updatedAt: "2026-03-22T00:00:00.000Z",
  39. };
  40. await Promise.all([
  41. writeFile(join(storyDir, "author_intent.md"), "# Author Intent\n\nKeep the pressure on the mentor conflict.\n", "utf-8"),
  42. writeFile(join(storyDir, "current_focus.md"), "# Current Focus\n\nBring the focus back to the mentor conflict.\n", "utf-8"),
  43. writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
  44. writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 4\nTrack the merchant guild trail.\n", "utf-8"),
  45. writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
  46. writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
  47. writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
  48. ]);
  49. const runtimePath = join(storyDir, "runtime", "chapter-0004.intent.md");
  50. await writeFile(runtimePath, "# Chapter Intent\n\n## Goal\nBring the focus back to the mentor conflict.\n", "utf-8");
  51. plan = {
  52. intent: {
  53. chapter: 4,
  54. goal: "Bring the focus back to the mentor conflict.",
  55. outlineNode: "Track the merchant guild trail.",
  56. mustKeep: [
  57. "Lin Yue still hides the broken oath token.",
  58. "The jade seal cannot be destroyed.",
  59. ],
  60. mustAvoid: ["Do not reveal the mastermind."],
  61. styleEmphasis: ["character conflict", "tight POV"],
  62. conflicts: [
  63. {
  64. type: "outline_vs_request",
  65. resolution: "allow local outline deferral",
  66. },
  67. ],
  68. hookAgenda: {
  69. pressureMap: [],
  70. mustAdvance: [],
  71. eligibleResolve: [],
  72. staleDebt: [],
  73. avoidNewHookFamilies: [],
  74. },
  75. },
  76. brief: {
  77. chapter: 4,
  78. goal: "Bring the focus back to the mentor conflict.",
  79. chapterType: "confrontation",
  80. isGoldenOpening: false,
  81. beatOutline: [
  82. { phase: "opening", instruction: "Open inside the mentor argument." },
  83. { phase: "development", instruction: "Bring the oath token back into the scene." },
  84. ],
  85. hookPlan: [],
  86. propsAndSetting: ["broken oath token"],
  87. },
  88. intentMarkdown: "# Chapter Intent\n",
  89. plannerInputs: [
  90. join(storyDir, "author_intent.md"),
  91. join(storyDir, "current_focus.md"),
  92. join(storyDir, "story_bible.md"),
  93. join(storyDir, "volume_outline.md"),
  94. join(storyDir, "current_state.md"),
  95. ],
  96. runtimePath,
  97. };
  98. });
  99. afterEach(async () => {
  100. await rm(root, { recursive: true, force: true });
  101. });
  102. it("selects only the relevant context and writes a context package", async () => {
  103. const composer = new ComposerAgent({
  104. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  105. model: "test-model",
  106. projectRoot: root,
  107. bookId: book.id,
  108. });
  109. const result = await composer.composeChapter({
  110. book,
  111. bookDir,
  112. chapterNumber: 4,
  113. plan,
  114. });
  115. const selectedSources = result.contextPackage.selectedContext.map((entry) => entry.source);
  116. expect(selectedSources.slice(0, 5)).toEqual([
  117. "runtime/chapter_brief",
  118. "story/current_focus.md",
  119. "story/current_state.md",
  120. "story/story_bible.md",
  121. "story/volume_outline.md",
  122. ]);
  123. expect(selectedSources.some((source) => source.startsWith("story/pending_hooks.md"))).toBe(true);
  124. expect(selectedSources).not.toContain("story/style_guide.md");
  125. await expect(readFile(result.contextPath, "utf-8")).resolves.toContain("current_focus.md");
  126. });
  127. it("emits a rule stack with hard, soft, and diagnostic sections", async () => {
  128. const composer = new ComposerAgent({
  129. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  130. model: "test-model",
  131. projectRoot: root,
  132. bookId: book.id,
  133. });
  134. const result = await composer.composeChapter({
  135. book,
  136. bookDir,
  137. chapterNumber: 4,
  138. plan,
  139. });
  140. expect(result.ruleStack.sections.hard).toContain("story_bible");
  141. expect(result.ruleStack.sections.soft).toContain("author_intent");
  142. expect(result.ruleStack.sections.diagnostic).toContain("anti_ai_checks");
  143. expect(result.ruleStack.activeOverrides).toHaveLength(1);
  144. });
  145. it("writes trace output describing planner inputs and selected sources", async () => {
  146. const composer = new ComposerAgent({
  147. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  148. model: "test-model",
  149. projectRoot: root,
  150. bookId: book.id,
  151. });
  152. const result = await composer.composeChapter({
  153. book,
  154. bookDir,
  155. chapterNumber: 4,
  156. plan,
  157. });
  158. expect(result.trace.plannerInputs).toEqual(plan.plannerInputs);
  159. expect(result.trace.selectedSources).toContain("story/current_focus.md");
  160. expect(result.trace.notes).toContain("allow local outline deferral");
  161. await expect(readFile(result.tracePath, "utf-8")).resolves.toContain("allow local outline deferral");
  162. });
  163. it("retrieves summary and hook evidence chunks instead of whole long memory files", async () => {
  164. await Promise.all([
  165. writeFile(
  166. join(storyDir, "pending_hooks.md"),
  167. [
  168. "# Pending Hooks",
  169. "",
  170. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
  171. "| --- | --- | --- | --- | --- | --- | --- |",
  172. "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
  173. "| mentor-oath | 8 | relationship | open | 9 | 11 | Mentor oath debt with Lin Yue |",
  174. "| old-seal | 3 | artifact | resolved | 3 | 3 | Jade seal already recovered |",
  175. "",
  176. ].join("\n"),
  177. "utf-8",
  178. ),
  179. writeFile(
  180. join(storyDir, "chapter_summaries.md"),
  181. [
  182. "# Chapter Summaries",
  183. "",
  184. "| 1 | Guild Trail | Merchant guild flees west | Route clues only | None | guild-route seeded | tense | action |",
  185. "| 7 | Broken Letter | Lin Yue | A torn letter mentions the mentor | Lin Yue reopens the old oath | mentor-oath seeded | uneasy | mystery |",
  186. "| 8 | River Camp | Lin Yue, Mentor Witness | Mentor debt becomes personal | Lin Yue cannot let go | mentor-oath advanced | raw | confrontation |",
  187. "| 9 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
  188. "",
  189. ].join("\n"),
  190. "utf-8",
  191. ),
  192. ]);
  193. const longRangePlan: PlanChapterOutput = {
  194. ...plan,
  195. intent: {
  196. ...plan.intent,
  197. chapter: 10,
  198. goal: "Bring the focus back to the mentor oath conflict.",
  199. outlineNode: "Track the merchant guild trail.",
  200. },
  201. };
  202. const composer = new ComposerAgent({
  203. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  204. model: "test-model",
  205. projectRoot: root,
  206. bookId: book.id,
  207. });
  208. const result = await composer.composeChapter({
  209. book,
  210. bookDir,
  211. chapterNumber: 10,
  212. plan: longRangePlan,
  213. });
  214. const selectedSources = result.contextPackage.selectedContext.map((entry) => entry.source);
  215. expect(selectedSources).toContain("story/pending_hooks.md#mentor-oath");
  216. expect(selectedSources).toContain("story/chapter_summaries.md#9");
  217. expect(selectedSources).not.toContain("story/pending_hooks.md");
  218. if (hasNodeSqlite) {
  219. await expect(stat(join(storyDir, "memory.db"))).resolves.toBeTruthy();
  220. }
  221. });
  222. it("surfaces stale unresolved hook evidence in governed context selection", async () => {
  223. const stateDir = join(storyDir, "state");
  224. await mkdir(stateDir, { recursive: true });
  225. await Promise.all([
  226. writeFile(join(stateDir, "manifest.json"), JSON.stringify({
  227. schemaVersion: 2,
  228. language: "en",
  229. lastAppliedChapter: 25,
  230. projectionVersion: 1,
  231. migrationWarnings: [],
  232. }, null, 2), "utf-8"),
  233. writeFile(join(stateDir, "current_state.json"), JSON.stringify({
  234. chapter: 25,
  235. facts: [],
  236. }, null, 2), "utf-8"),
  237. writeFile(join(stateDir, "chapter_summaries.json"), JSON.stringify({
  238. rows: [],
  239. }, null, 2), "utf-8"),
  240. writeFile(join(stateDir, "hooks.json"), JSON.stringify({
  241. hooks: [
  242. {
  243. hookId: "recent-route",
  244. startChapter: 22,
  245. type: "route",
  246. status: "open",
  247. lastAdvancedChapter: 24,
  248. expectedPayoff: "Recent route payoff",
  249. notes: "Recent but not critical.",
  250. },
  251. {
  252. hookId: "stale-debt",
  253. startChapter: 3,
  254. type: "relationship",
  255. status: "open",
  256. lastAdvancedChapter: 8,
  257. expectedPayoff: "Mentor debt payoff",
  258. notes: "Long-stale but still unresolved.",
  259. },
  260. ],
  261. }, null, 2), "utf-8"),
  262. ]);
  263. const composer = new ComposerAgent({
  264. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  265. model: "test-model",
  266. projectRoot: root,
  267. bookId: book.id,
  268. });
  269. const result = await composer.composeChapter({
  270. book,
  271. bookDir,
  272. chapterNumber: 26,
  273. plan: {
  274. ...plan,
  275. intent: {
  276. ...plan.intent,
  277. chapter: 26,
  278. goal: "Keep the chapter on the mainline debt conflict.",
  279. },
  280. },
  281. });
  282. const selectedSources = result.contextPackage.selectedContext.map((entry) => entry.source);
  283. expect(selectedSources).toContain("story/pending_hooks.md#recent-route");
  284. expect(selectedSources).toContain("story/pending_hooks.md#stale-debt");
  285. });
  286. it("adds current-state fact evidence retrieved from sqlite-backed memory", async () => {
  287. await writeFile(
  288. join(storyDir, "current_state.md"),
  289. [
  290. "# Current State",
  291. "",
  292. "| Field | Value |",
  293. "| --- | --- |",
  294. "| Current Chapter | 9 |",
  295. "| Current Location | Ashen ferry crossing |",
  296. "| Protagonist State | Lin Yue hides the broken oath token and the old wound has reopened. |",
  297. "| Current Goal | Find the vanished mentor before the guild covers its tracks. |",
  298. "| Current Conflict | Mentor debt with the vanished teacher blocks every choice. |",
  299. "",
  300. ].join("\n"),
  301. "utf-8",
  302. );
  303. const composer = new ComposerAgent({
  304. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  305. model: "test-model",
  306. projectRoot: root,
  307. bookId: book.id,
  308. });
  309. const result = await composer.composeChapter({
  310. book,
  311. bookDir,
  312. chapterNumber: 10,
  313. plan: {
  314. ...plan,
  315. intent: {
  316. ...plan.intent,
  317. chapter: 10,
  318. goal: "Bring the focus back to the vanished mentor conflict.",
  319. },
  320. },
  321. });
  322. const factEntry = result.contextPackage.selectedContext.find((entry) =>
  323. entry.source === "story/current_state.md#current-conflict",
  324. );
  325. expect(factEntry).toBeDefined();
  326. expect(factEntry?.excerpt).toContain("Current Conflict");
  327. expect(factEntry?.excerpt).toContain("Mentor debt with the vanished teacher");
  328. });
  329. it("adds relevant volume-summary evidence for long-span retrieval after consolidation", async () => {
  330. await writeFile(
  331. join(storyDir, "volume_summaries.md"),
  332. [
  333. "# Volume Summaries",
  334. "",
  335. "## Volume 1 (Ch.1-40)",
  336. "",
  337. "Lin Yue's mentor oath becomes the core unresolved debt, while the guild route keeps trying to pull him away from the mainline.",
  338. "",
  339. "## Volume 2 (Ch.41-80)",
  340. "",
  341. "The guild route dominates logistics noise, but the mentor debt recedes into the background.",
  342. "",
  343. ].join("\n"),
  344. "utf-8",
  345. );
  346. const composer = new ComposerAgent({
  347. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  348. model: "test-model",
  349. projectRoot: root,
  350. bookId: book.id,
  351. });
  352. const result = await composer.composeChapter({
  353. book,
  354. bookDir,
  355. chapterNumber: 81,
  356. plan: {
  357. ...plan,
  358. intent: {
  359. ...plan.intent,
  360. chapter: 81,
  361. goal: "Bring the focus back to the mentor oath conflict.",
  362. },
  363. },
  364. });
  365. const volumeEntry = result.contextPackage.selectedContext.find((entry) =>
  366. entry.source.startsWith("story/volume_summaries.md#"),
  367. );
  368. expect(volumeEntry).toBeDefined();
  369. expect(volumeEntry?.excerpt).toContain("mentor oath");
  370. });
  371. it("adds explicit title history, mood trail, and canon evidence for governed writing", async () => {
  372. await Promise.all([
  373. writeFile(
  374. join(storyDir, "chapter_summaries.md"),
  375. [
  376. "# Chapter Summaries",
  377. "",
  378. "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
  379. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  380. "| 1 | Ledger in Rain | Lin Yue | First ledger clue appears | None | none | tight | investigation |",
  381. "| 2 | Ledger at Dusk | Lin Yue | Second ledger clue appears | None | none | tight | investigation |",
  382. "| 3 | Harbor Ledger | Lin Yue | Third ledger clue appears | None | none | tight | investigation |",
  383. "",
  384. ].join("\n"),
  385. "utf-8",
  386. ),
  387. writeFile(
  388. join(storyDir, "parent_canon.md"),
  389. "# Parent Canon\n\nThe mentor does not learn about the archive fire until volume two.\n",
  390. "utf-8",
  391. ),
  392. writeFile(
  393. join(storyDir, "fanfic_canon.md"),
  394. "# Fanfic Canon\n\nMara may diverge from the archive route, but the oath debt logic must stay intact.\n",
  395. "utf-8",
  396. ),
  397. ]);
  398. const composer = new ComposerAgent({
  399. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  400. model: "test-model",
  401. projectRoot: root,
  402. bookId: book.id,
  403. });
  404. const result = await composer.composeChapter({
  405. book,
  406. bookDir,
  407. chapterNumber: 4,
  408. plan,
  409. });
  410. const selectedSources = result.contextPackage.selectedContext.map((entry) => entry.source);
  411. expect(selectedSources).toContain("story/chapter_summaries.md#recent_titles");
  412. expect(selectedSources).toContain("story/chapter_summaries.md#recent_mood_type_trail");
  413. expect(selectedSources).toContain("story/parent_canon.md");
  414. expect(selectedSources).toContain("story/fanfic_canon.md");
  415. const titleEntry = result.contextPackage.selectedContext.find((entry) =>
  416. entry.source === "story/chapter_summaries.md#recent_titles",
  417. );
  418. const moodEntry = result.contextPackage.selectedContext.find((entry) =>
  419. entry.source === "story/chapter_summaries.md#recent_mood_type_trail",
  420. );
  421. const parentCanonEntry = result.contextPackage.selectedContext.find((entry) =>
  422. entry.source === "story/parent_canon.md",
  423. );
  424. const fanficCanonEntry = result.contextPackage.selectedContext.find((entry) =>
  425. entry.source === "story/fanfic_canon.md",
  426. );
  427. expect(titleEntry?.excerpt).toContain("Ledger in Rain");
  428. expect(moodEntry?.excerpt).toContain("tight / investigation");
  429. expect(parentCanonEntry?.excerpt).toContain("archive fire");
  430. expect(fanficCanonEntry?.excerpt).toContain("oath debt logic");
  431. });
  432. it("includes dedicated audit drift guidance instead of relying on current_state pollution", async () => {
  433. await writeFile(
  434. join(storyDir, "audit_drift.md"),
  435. [
  436. "# Audit Drift",
  437. "",
  438. "## 审计纠偏(自动生成,下一章写作前参照)",
  439. "",
  440. "> - [warning] 节奏单调: 最近4章章节类型持续停留在“调查章”。",
  441. ].join("\n"),
  442. "utf-8",
  443. );
  444. const composer = new ComposerAgent({
  445. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  446. model: "test-model",
  447. projectRoot: root,
  448. bookId: book.id,
  449. });
  450. const result = await composer.composeChapter({
  451. book,
  452. bookDir,
  453. chapterNumber: 4,
  454. plan,
  455. });
  456. const driftEntry = result.contextPackage.selectedContext.find((entry) =>
  457. entry.source === "story/audit_drift.md",
  458. );
  459. expect(driftEntry).toBeDefined();
  460. expect(driftEntry?.excerpt).toContain("节奏单调");
  461. });
  462. it("emits hook debt briefs for agenda-targeted hooks", async () => {
  463. await Promise.all([
  464. writeFile(
  465. join(storyDir, "pending_hooks.md"),
  466. [
  467. "# Pending Hooks",
  468. "",
  469. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
  470. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  471. "| mentor-oath | 8 | relationship | progressing | 9 | 揭开师债为何断裂 | 慢烧 | 师债需要跨更大弧线回收 |",
  472. "| guild-route | 1 | mystery | open | 2 | 查清商会路线背后的买家 | 近期 | 商会路线仍在旁支干扰 |",
  473. "",
  474. ].join("\n"),
  475. "utf-8",
  476. ),
  477. writeFile(
  478. join(storyDir, "chapter_summaries.md"),
  479. [
  480. "# Chapter Summaries",
  481. "",
  482. "| 7 | Broken Letter | Lin Yue | A torn letter mentions the mentor | Lin Yue reopens the old oath | mentor-oath seeded | uneasy | mystery |",
  483. "| 8 | River Camp | Lin Yue | Mentor debt becomes personal | Lin Yue cannot let go | mentor-oath advanced | raw | confrontation |",
  484. "| 9 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
  485. "",
  486. ].join("\n"),
  487. "utf-8",
  488. ),
  489. ]);
  490. const composer = new ComposerAgent({
  491. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  492. model: "test-model",
  493. projectRoot: root,
  494. bookId: book.id,
  495. });
  496. const result = await composer.composeChapter({
  497. book,
  498. bookDir,
  499. chapterNumber: 10,
  500. plan: {
  501. ...plan,
  502. intent: {
  503. ...plan.intent,
  504. chapter: 10,
  505. goal: "Bring the focus back to the mentor oath conflict.",
  506. hookAgenda: {
  507. pressureMap: [{
  508. hookId: "mentor-oath",
  509. type: "relationship",
  510. payoffTiming: "slow-burn",
  511. phase: "middle",
  512. pressure: "high",
  513. movement: "partial-payoff",
  514. reason: "stale-promise",
  515. blockSiblingHooks: true,
  516. }],
  517. mustAdvance: ["mentor-oath"],
  518. eligibleResolve: [],
  519. staleDebt: [],
  520. avoidNewHookFamilies: ["relationship"],
  521. },
  522. },
  523. },
  524. });
  525. const hookDebtEntry = result.contextPackage.selectedContext.find((entry) => entry.source === "runtime/hook_debt#mentor-oath");
  526. expect(hookDebtEntry).toBeDefined();
  527. expect(hookDebtEntry?.excerpt).toContain("mentor-oath");
  528. expect(hookDebtEntry?.excerpt).toContain("主要旧债");
  529. expect(hookDebtEntry?.excerpt).toContain("读者承诺");
  530. expect(hookDebtEntry?.excerpt).toContain("River Camp");
  531. expect(hookDebtEntry?.excerpt).toContain("Trial Echo");
  532. });
  533. it("includes brief-selected hook ids in hook debt retrieval even before hookAgenda is updated", async () => {
  534. await Promise.all([
  535. writeFile(
  536. join(storyDir, "pending_hooks.md"),
  537. [
  538. "# Pending Hooks",
  539. "",
  540. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
  541. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  542. "| black-ring | 6 | mystery | open | 7 | 揭开黑戒来源 | 中程 | 这一根还没进旧 hookAgenda |",
  543. "",
  544. ].join("\n"),
  545. "utf-8",
  546. ),
  547. writeFile(
  548. join(storyDir, "chapter_summaries.md"),
  549. [
  550. "# Chapter Summaries",
  551. "",
  552. "| 6 | Black Ring | Lin Yue | Black ring first surfaces | Pressure rises | black-ring seeded | uneasy | mystery |",
  553. "| 7 | Wet Dock | Lin Yue | Ring clue points to the dock | Stakes rise | black-ring advanced | tense | pursuit |",
  554. "",
  555. ].join("\n"),
  556. "utf-8",
  557. ),
  558. ]);
  559. const composer = new ComposerAgent({
  560. client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
  561. model: "test-model",
  562. projectRoot: root,
  563. bookId: book.id,
  564. });
  565. const result = await composer.composeChapter({
  566. book,
  567. bookDir,
  568. chapterNumber: 8,
  569. plan: {
  570. ...plan,
  571. intent: {
  572. ...plan.intent,
  573. chapter: 8,
  574. goal: "Follow the black ring pressure.",
  575. mustKeep: [],
  576. hookAgenda: {
  577. pressureMap: [],
  578. mustAdvance: [],
  579. eligibleResolve: [],
  580. staleDebt: [],
  581. avoidNewHookFamilies: [],
  582. },
  583. },
  584. brief: {
  585. chapter: 8,
  586. goal: "Follow the black ring pressure.",
  587. chapterType: "pursuit",
  588. isGoldenOpening: false,
  589. beatOutline: [
  590. { phase: "opening", instruction: "Open with the ring clue already in motion." },
  591. ],
  592. hookPlan: [
  593. {
  594. hookId: "black-ring",
  595. movement: "advance",
  596. targetEffect: "Push the ring clue onto the dock in a way the reader can feel.",
  597. },
  598. ],
  599. propsAndSetting: ["wet dock", "black ring"],
  600. },
  601. },
  602. });
  603. expect(result.contextPackage.selectedContext.map((entry) => entry.source)).toContain("runtime/hook_debt#black-ring");
  604. });
  605. });