planner.test.ts 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412
  1. import { afterEach, beforeEach, describe, expect, it } from "vitest";
  2. import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
  3. import { tmpdir } from "node:os";
  4. import { join } from "node:path";
  5. import type { BookConfig } from "../models/book.js";
  6. import { PlannerAgent } from "../agents/planner.js";
  7. describe("PlannerAgent", () => {
  8. let root: string;
  9. let bookDir: string;
  10. let storyDir: string;
  11. let book: BookConfig;
  12. beforeEach(async () => {
  13. root = await mkdtemp(join(tmpdir(), "inkos-planner-test-"));
  14. bookDir = join(root, "books", "planner-book");
  15. storyDir = join(bookDir, "story");
  16. await mkdir(join(storyDir, "runtime"), { recursive: true });
  17. book = {
  18. id: "planner-book",
  19. title: "Planner Book",
  20. platform: "tomato",
  21. genre: "xuanhuan",
  22. status: "active",
  23. targetChapters: 20,
  24. chapterWordCount: 3000,
  25. createdAt: "2026-03-22T00:00:00.000Z",
  26. updatedAt: "2026-03-22T00:00:00.000Z",
  27. };
  28. await Promise.all([
  29. writeFile(
  30. join(storyDir, "author_intent.md"),
  31. "# Author Intent\n\nKeep the book emotionally centered on the mentor-student bond.\n",
  32. "utf-8",
  33. ),
  34. writeFile(
  35. join(storyDir, "current_focus.md"),
  36. "# Current Focus\n\nBring the focus back to the mentor conflict before opening new subplots.\n",
  37. "utf-8",
  38. ),
  39. writeFile(
  40. join(storyDir, "story_bible.md"),
  41. "# Story Bible\n\n- The jade seal cannot be destroyed.\n",
  42. "utf-8",
  43. ),
  44. writeFile(
  45. join(storyDir, "volume_outline.md"),
  46. "# Volume Outline\n\n## Chapter 3\nTrack the merchant guild's escape route.\n",
  47. "utf-8",
  48. ),
  49. writeFile(
  50. join(storyDir, "book_rules.md"),
  51. "---\nprohibitions:\n - Do not reveal the mastermind\n---\n\n# Book Rules\n",
  52. "utf-8",
  53. ),
  54. writeFile(
  55. join(storyDir, "current_state.md"),
  56. "# Current State\n\n- Lin Yue still hides the broken oath token.\n",
  57. "utf-8",
  58. ),
  59. writeFile(
  60. join(storyDir, "pending_hooks.md"),
  61. "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n",
  62. "utf-8",
  63. ),
  64. writeFile(
  65. join(storyDir, "chapter_summaries.md"),
  66. "# Chapter Summaries\n\n| 2 | Trial fallout | Mentor left without explanation |\n",
  67. "utf-8",
  68. ),
  69. ]);
  70. });
  71. afterEach(async () => {
  72. await rm(root, { recursive: true, force: true });
  73. });
  74. it("uses current focus as the chapter goal when no outline node is available", async () => {
  75. await writeFile(
  76. join(storyDir, "volume_outline.md"),
  77. "# Volume Outline\n",
  78. "utf-8",
  79. );
  80. const planner = new PlannerAgent({
  81. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  82. model: "test-model",
  83. projectRoot: root,
  84. bookId: book.id,
  85. });
  86. const result = await planner.planChapter({
  87. book,
  88. bookDir,
  89. chapterNumber: 3,
  90. });
  91. expect(result.intent.goal).toContain("mentor conflict");
  92. await expect(readFile(result.runtimePath, "utf-8")).resolves.toContain("mentor conflict");
  93. });
  94. it("prefers a matched outline node over ordinary current focus text", async () => {
  95. await Promise.all([
  96. writeFile(
  97. join(storyDir, "current_focus.md"),
  98. [
  99. "# Current Focus",
  100. "",
  101. "## Active Focus",
  102. "",
  103. "Pull the next chapter back toward the mentor fallout instead of the guild route.",
  104. "",
  105. ].join("\n"),
  106. "utf-8",
  107. ),
  108. writeFile(
  109. join(storyDir, "volume_outline.md"),
  110. [
  111. "# Volume Outline",
  112. "",
  113. "## Chapter 3",
  114. "Track the merchant guild's escape route through the western canal.",
  115. "",
  116. ].join("\n"),
  117. "utf-8",
  118. ),
  119. ]);
  120. const planner = new PlannerAgent({
  121. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  122. model: "test-model",
  123. projectRoot: root,
  124. bookId: book.id,
  125. });
  126. const result = await planner.planChapter({
  127. book,
  128. bookDir,
  129. chapterNumber: 3,
  130. });
  131. expect(result.intent.outlineNode).toContain("merchant guild's escape route");
  132. expect(result.intent.goal).toContain("merchant guild's escape route");
  133. expect(result.intent.goal).not.toContain("mentor fallout");
  134. expect(result.intent.conflicts).toEqual([]);
  135. });
  136. it("lets explicit local override focus beat the matched outline node", async () => {
  137. await Promise.all([
  138. writeFile(
  139. join(storyDir, "current_focus.md"),
  140. [
  141. "# Current Focus",
  142. "",
  143. "## Active Focus",
  144. "",
  145. "Keep pressure on the guild route in the background.",
  146. "",
  147. "## Local Override",
  148. "",
  149. "Stay inside the mentor debt confrontation first and delay the canal pursuit by one chapter.",
  150. "",
  151. ].join("\n"),
  152. "utf-8",
  153. ),
  154. writeFile(
  155. join(storyDir, "volume_outline.md"),
  156. [
  157. "# Volume Outline",
  158. "",
  159. "## Chapter 3",
  160. "Track the merchant guild's escape route through the western canal.",
  161. "",
  162. ].join("\n"),
  163. "utf-8",
  164. ),
  165. ]);
  166. const planner = new PlannerAgent({
  167. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  168. model: "test-model",
  169. projectRoot: root,
  170. bookId: book.id,
  171. });
  172. const result = await planner.planChapter({
  173. book,
  174. bookDir,
  175. chapterNumber: 3,
  176. });
  177. expect(result.intent.outlineNode).toContain("merchant guild's escape route");
  178. expect(result.intent.goal).toContain("mentor debt confrontation");
  179. expect(result.intent.goal).not.toContain("merchant guild's escape route");
  180. expect(result.intent.conflicts).toEqual(expect.arrayContaining([
  181. expect.objectContaining({
  182. type: "outline_vs_current_focus",
  183. resolution: "allow explicit current focus override",
  184. }),
  185. ]));
  186. });
  187. it("keeps external context above both outline anchors and current focus", async () => {
  188. await Promise.all([
  189. writeFile(
  190. join(storyDir, "current_focus.md"),
  191. [
  192. "# Current Focus",
  193. "",
  194. "## Active Focus",
  195. "",
  196. "Pull the next chapter back toward the mentor fallout instead of the guild route.",
  197. "",
  198. ].join("\n"),
  199. "utf-8",
  200. ),
  201. writeFile(
  202. join(storyDir, "volume_outline.md"),
  203. [
  204. "# Volume Outline",
  205. "",
  206. "## Chapter 3",
  207. "Track the merchant guild's escape route through the western canal.",
  208. "",
  209. ].join("\n"),
  210. "utf-8",
  211. ),
  212. ]);
  213. const planner = new PlannerAgent({
  214. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  215. model: "test-model",
  216. projectRoot: root,
  217. bookId: book.id,
  218. });
  219. const result = await planner.planChapter({
  220. book,
  221. bookDir,
  222. chapterNumber: 3,
  223. externalContext: "Ignore the canal pursuit for now and force the next chapter into the mentor debt confrontation.",
  224. });
  225. expect(result.intent.goal).toContain("mentor debt confrontation");
  226. expect(result.intent.goal).not.toContain("merchant guild's escape route");
  227. expect(result.intent.conflicts).toEqual(expect.arrayContaining([
  228. expect.objectContaining({
  229. type: "outline_vs_request",
  230. resolution: "allow local outline deferral",
  231. }),
  232. ]));
  233. });
  234. it("emits structured directives when fallback planning, chapter type repetition, and title collapse stack up", async () => {
  235. book = {
  236. ...book,
  237. genre: "other",
  238. language: "en",
  239. };
  240. await Promise.all([
  241. writeFile(
  242. join(storyDir, "author_intent.md"),
  243. "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
  244. "utf-8",
  245. ),
  246. writeFile(
  247. join(storyDir, "current_focus.md"),
  248. [
  249. "# Current Focus",
  250. "",
  251. "## Active Focus",
  252. "",
  253. "(Describe what the next 1-3 chapters should prioritize.)",
  254. "",
  255. ].join("\n"),
  256. "utf-8",
  257. ),
  258. writeFile(
  259. join(storyDir, "volume_outline.md"),
  260. [
  261. "# Volume Outline",
  262. "",
  263. "## Chapter 8",
  264. "Expose the registry clerk's hidden ledger in the floodgate archive.",
  265. "",
  266. ].join("\n"),
  267. "utf-8",
  268. ),
  269. writeFile(
  270. join(storyDir, "chapter_summaries.md"),
  271. [
  272. "# Chapter Summaries",
  273. "",
  274. "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
  275. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  276. "| 1 | Ledger in Rain | Taryn | Taryn checks the first false folio | None | hook advanced | tight | investigation |",
  277. "| 2 | Ledger at Dusk | Taryn | Taryn questions the dock clerk | None | hook advanced | tight | investigation |",
  278. "| 3 | Ledger Below | Taryn | Taryn searches the under-archive | None | hook advanced | tight | investigation |",
  279. "",
  280. ].join("\n"),
  281. "utf-8",
  282. ),
  283. ]);
  284. const planner = new PlannerAgent({
  285. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  286. model: "test-model",
  287. projectRoot: root,
  288. bookId: book.id,
  289. });
  290. const result = await planner.planChapter({
  291. book,
  292. bookDir,
  293. chapterNumber: 4,
  294. });
  295. expect(result.intent.arcDirective).toContain("fallback");
  296. expect(result.intent.sceneDirective).toContain("investigation");
  297. expect(result.intent.titleDirective?.toLowerCase()).toContain("ledger");
  298. expect(result.intent.moodDirective).toBeUndefined();
  299. });
  300. it("emits a mood directive when recent chapters are all high-tension", async () => {
  301. book = {
  302. ...book,
  303. genre: "other",
  304. language: "zh",
  305. };
  306. await Promise.all([
  307. writeFile(
  308. join(storyDir, "volume_outline.md"),
  309. "# Volume Outline\n\n## Chapter 5\n进入新的地点。\n",
  310. "utf-8",
  311. ),
  312. writeFile(
  313. join(storyDir, "chapter_summaries.md"),
  314. [
  315. "# Chapter Summaries",
  316. "",
  317. "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
  318. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  319. "| 1 | 暗巷追踪 | 周谨川 | 追踪目标 | None | none | 紧张、压抑 | 悬念验证章 |",
  320. "| 2 | 旧楼对峙 | 周谨川 | 对峙 | None | none | 冷硬、逼仄 | 冲突章 |",
  321. "| 3 | 夜色围堵 | 周谨川 | 围堵 | None | none | 肃杀、凝重 | 追击章 |",
  322. "| 4 | 地下通道 | 周谨川 | 逃脱 | None | none | 压迫、窒息 | 逃亡章 |",
  323. "",
  324. ].join("\n"),
  325. "utf-8",
  326. ),
  327. ]);
  328. const planner = new PlannerAgent({
  329. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  330. model: "test-model",
  331. projectRoot: root,
  332. bookId: book.id,
  333. });
  334. const result = await planner.planChapter({
  335. book,
  336. bookDir,
  337. chapterNumber: 5,
  338. });
  339. expect(result.intent.moodDirective).toBeDefined();
  340. expect(result.intent.moodDirective).toContain("降调");
  341. expect(result.intent.moodDirective).toContain("日常");
  342. });
  343. it("does not emit a mood directive when recent moods are varied", async () => {
  344. book = {
  345. ...book,
  346. genre: "other",
  347. language: "en",
  348. };
  349. await Promise.all([
  350. writeFile(
  351. join(storyDir, "volume_outline.md"),
  352. "# Volume Outline\n\n## Chapter 5\nMove to the harbor.\n",
  353. "utf-8",
  354. ),
  355. writeFile(
  356. join(storyDir, "chapter_summaries.md"),
  357. [
  358. "# Chapter Summaries",
  359. "",
  360. "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
  361. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  362. "| 1 | Morning Calm | Taryn | A quiet walk | None | none | warm, gentle | slice-of-life |",
  363. "| 2 | Sudden Rain | Taryn | Storm arrives | None | none | tense, ominous | tension |",
  364. "| 3 | Harbor Light | Taryn | Finds shelter | None | none | hopeful, light | transition |",
  365. "| 4 | The Letter | Taryn | Reads bad news | None | none | melancholy, reflective | introspection |",
  366. "",
  367. ].join("\n"),
  368. "utf-8",
  369. ),
  370. ]);
  371. const planner = new PlannerAgent({
  372. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  373. model: "test-model",
  374. projectRoot: root,
  375. bookId: book.id,
  376. });
  377. const result = await planner.planChapter({
  378. book,
  379. bookDir,
  380. chapterNumber: 5,
  381. });
  382. expect(result.intent.moodDirective).toBeUndefined();
  383. });
  384. it("ignores the default current_focus placeholder and falls back to author intent when no chapter outline is available", async () => {
  385. await Promise.all([
  386. writeFile(
  387. join(storyDir, "current_focus.md"),
  388. [
  389. "# Current Focus",
  390. "",
  391. "## Active Focus",
  392. "",
  393. "(Describe what the next 1-3 chapters should prioritize.)",
  394. "",
  395. ].join("\n"),
  396. "utf-8",
  397. ),
  398. writeFile(
  399. join(storyDir, "volume_outline.md"),
  400. "# Volume Outline\n",
  401. "utf-8",
  402. ),
  403. ]);
  404. const planner = new PlannerAgent({
  405. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  406. model: "test-model",
  407. projectRoot: root,
  408. bookId: book.id,
  409. });
  410. const result = await planner.planChapter({
  411. book,
  412. bookDir,
  413. chapterNumber: 3,
  414. });
  415. expect(result.intent.goal).toContain("mentor-student bond");
  416. expect(result.intent.goal).not.toContain("Describe what the next 1-3 chapters should prioritize");
  417. });
  418. it("uses bullet-style volume outline chapter nodes as the fallback goal when control docs are placeholders", async () => {
  419. await Promise.all([
  420. writeFile(
  421. join(storyDir, "author_intent.md"),
  422. "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
  423. "utf-8",
  424. ),
  425. writeFile(
  426. join(storyDir, "current_focus.md"),
  427. [
  428. "# Current Focus",
  429. "",
  430. "## Active Focus",
  431. "",
  432. "(Describe what the next 1-3 chapters should prioritize.)",
  433. "",
  434. ].join("\n"),
  435. "utf-8",
  436. ),
  437. writeFile(
  438. join(storyDir, "volume_outline.md"),
  439. [
  440. "## Volume 1",
  441. "**Chapter range:** 1-8",
  442. "",
  443. "**Key turning points:**",
  444. "- **Chapter 3:** Track the merchant guild's escape route through the western canal.",
  445. "",
  446. ].join("\n"),
  447. "utf-8",
  448. ),
  449. ]);
  450. const planner = new PlannerAgent({
  451. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  452. model: "test-model",
  453. projectRoot: root,
  454. bookId: book.id,
  455. });
  456. const result = await planner.planChapter({
  457. book,
  458. bookDir,
  459. chapterNumber: 3,
  460. });
  461. expect(result.intent.outlineNode).toContain("merchant guild's escape route");
  462. expect(result.intent.goal).toContain("merchant guild's escape route");
  463. expect(result.intent.goal).not.toContain("Advance chapter 3 with clear narrative focus.");
  464. });
  465. it("uses the next paragraph for bold standalone English chapter labels instead of capturing markdown markers", async () => {
  466. book = {
  467. ...book,
  468. genre: "other",
  469. language: "en",
  470. };
  471. await Promise.all([
  472. writeFile(
  473. join(storyDir, "current_focus.md"),
  474. [
  475. "# Current Focus",
  476. "",
  477. "## Active Focus",
  478. "",
  479. "(Describe what the next 1-3 chapters should prioritize.)",
  480. "",
  481. ].join("\n"),
  482. "utf-8",
  483. ),
  484. writeFile(
  485. join(storyDir, "volume_outline.md"),
  486. [
  487. "## Volume 1 - The Dead Examiner",
  488. "**Chapter Range:** 1-12",
  489. "",
  490. "**Key Turning Points:**",
  491. "- Ch1: Renn dies after summoning Taryn to review irregular treaty folios.",
  492. "",
  493. "### Golden First Three Chapters Rule",
  494. "",
  495. "**Chapter 2:**",
  496. "Show Taryn's edge through action, not exposition. He uses registry numbering logic to identify which folios are decoys and which conceal a ledger fragment.",
  497. "",
  498. ].join("\n"),
  499. "utf-8",
  500. ),
  501. ]);
  502. const planner = new PlannerAgent({
  503. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  504. model: "test-model",
  505. projectRoot: root,
  506. bookId: book.id,
  507. });
  508. const result = await planner.planChapter({
  509. book,
  510. bookDir,
  511. chapterNumber: 2,
  512. });
  513. expect(result.intent.outlineNode).toContain("Show Taryn's edge through action");
  514. expect(result.intent.outlineNode).not.toBe("**");
  515. expect(result.intent.goal).toContain("Show Taryn's edge through action");
  516. expect(result.intent.goal).not.toBe("**");
  517. });
  518. it("does not confuse Chapter 1 with Chapter 10 when matching exact English chapter labels", async () => {
  519. book = {
  520. ...book,
  521. genre: "other",
  522. language: "en",
  523. };
  524. await Promise.all([
  525. writeFile(
  526. join(storyDir, "current_focus.md"),
  527. [
  528. "# Current Focus",
  529. "",
  530. "## Active Focus",
  531. "",
  532. "(Describe what the next 1-3 chapters should prioritize.)",
  533. "",
  534. ].join("\n"),
  535. "utf-8",
  536. ),
  537. writeFile(
  538. join(storyDir, "volume_outline.md"),
  539. [
  540. "# Volume Outline",
  541. "",
  542. "### Chapter 10",
  543. "This late-volume node should not be selected for chapter one.",
  544. "",
  545. "### Chapter 1",
  546. "Open with the dead examiner and the sealed folio dispute.",
  547. "",
  548. ].join("\n"),
  549. "utf-8",
  550. ),
  551. ]);
  552. const planner = new PlannerAgent({
  553. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  554. model: "test-model",
  555. projectRoot: root,
  556. bookId: book.id,
  557. });
  558. const result = await planner.planChapter({
  559. book,
  560. bookDir,
  561. chapterNumber: 1,
  562. });
  563. expect(result.intent.outlineNode).toContain("dead examiner");
  564. expect(result.intent.outlineNode).not.toContain("late-volume");
  565. expect(result.intent.goal).toContain("dead examiner");
  566. });
  567. it("uses inline Chinese exact chapter labels with a title suffix", async () => {
  568. await Promise.all([
  569. writeFile(
  570. join(storyDir, "author_intent.md"),
  571. "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
  572. "utf-8",
  573. ),
  574. writeFile(
  575. join(storyDir, "current_focus.md"),
  576. [
  577. "# Current Focus",
  578. "",
  579. "## Active Focus",
  580. "",
  581. "(Describe what the next 1-3 chapters should prioritize.)",
  582. "",
  583. ].join("\n"),
  584. "utf-8",
  585. ),
  586. writeFile(
  587. join(storyDir, "volume_outline.md"),
  588. [
  589. "# Volume Outline",
  590. "",
  591. "第 7 章:在码头接头并截住逃跑账房。",
  592. "",
  593. ].join("\n"),
  594. "utf-8",
  595. ),
  596. ]);
  597. const planner = new PlannerAgent({
  598. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  599. model: "test-model",
  600. projectRoot: root,
  601. bookId: book.id,
  602. });
  603. const result = await planner.planChapter({
  604. book,
  605. bookDir,
  606. chapterNumber: 7,
  607. });
  608. expect(result.intent.outlineNode).toContain("在码头接头");
  609. expect(result.intent.goal).toContain("在码头接头");
  610. expect(result.intent.goal).not.toContain("Describe the long-horizon vision");
  611. });
  612. it("uses standalone Chinese chapter-range labels when the chapter falls inside the range", async () => {
  613. await Promise.all([
  614. writeFile(
  615. join(storyDir, "author_intent.md"),
  616. "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
  617. "utf-8",
  618. ),
  619. writeFile(
  620. join(storyDir, "current_focus.md"),
  621. [
  622. "# Current Focus",
  623. "",
  624. "## Active Focus",
  625. "",
  626. "(Describe what the next 1-3 chapters should prioritize.)",
  627. "",
  628. ].join("\n"),
  629. "utf-8",
  630. ),
  631. writeFile(
  632. join(storyDir, "volume_outline.md"),
  633. [
  634. "# Volume Outline",
  635. "",
  636. "第1-6章",
  637. "Stay with the early city setup and mentor fallout.",
  638. "",
  639. "第7-20章",
  640. "Track the merchant guild's escape route through the western canal.",
  641. "",
  642. ].join("\n"),
  643. "utf-8",
  644. ),
  645. ]);
  646. const planner = new PlannerAgent({
  647. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  648. model: "test-model",
  649. projectRoot: root,
  650. bookId: book.id,
  651. });
  652. const result = await planner.planChapter({
  653. book,
  654. bookDir,
  655. chapterNumber: 7,
  656. });
  657. expect(result.intent.outlineNode).toContain("merchant guild's escape route");
  658. expect(result.intent.goal).toContain("merchant guild's escape route");
  659. expect(result.intent.goal).not.toContain("Describe the long-horizon vision");
  660. });
  661. it("uses standalone English chapter-range labels at the start of the range", async () => {
  662. book = {
  663. ...book,
  664. genre: "other",
  665. language: "en",
  666. };
  667. await Promise.all([
  668. writeFile(
  669. join(storyDir, "author_intent.md"),
  670. "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
  671. "utf-8",
  672. ),
  673. writeFile(
  674. join(storyDir, "current_focus.md"),
  675. [
  676. "# Current Focus",
  677. "",
  678. "## Active Focus",
  679. "",
  680. "(Describe what the next 1-3 chapters should prioritize.)",
  681. "",
  682. ].join("\n"),
  683. "utf-8",
  684. ),
  685. writeFile(
  686. join(storyDir, "volume_outline.md"),
  687. [
  688. "# Volume Outline",
  689. "",
  690. "Chapter 1-3",
  691. "Keep the opening pressure on the first examiner.",
  692. "",
  693. "Chapter 4-6",
  694. "Recover the sealed ledger before dawn.",
  695. "",
  696. ].join("\n"),
  697. "utf-8",
  698. ),
  699. ]);
  700. const planner = new PlannerAgent({
  701. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  702. model: "test-model",
  703. projectRoot: root,
  704. bookId: book.id,
  705. });
  706. const result = await planner.planChapter({
  707. book,
  708. bookDir,
  709. chapterNumber: 4,
  710. });
  711. expect(result.intent.outlineNode).toContain("sealed ledger");
  712. expect(result.intent.goal).toContain("sealed ledger");
  713. expect(result.intent.outlineNode).not.toContain("6");
  714. expect(result.intent.goal).not.toContain("Describe the long-horizon vision");
  715. });
  716. it("uses the next paragraph for bold standalone English chapter-range labels instead of bleeding into the next range", async () => {
  717. book = {
  718. ...book,
  719. genre: "other",
  720. language: "en",
  721. };
  722. await Promise.all([
  723. writeFile(
  724. join(storyDir, "author_intent.md"),
  725. "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
  726. "utf-8",
  727. ),
  728. writeFile(
  729. join(storyDir, "current_focus.md"),
  730. [
  731. "# Current Focus",
  732. "",
  733. "## Active Focus",
  734. "",
  735. "(Describe what the next 1-3 chapters should prioritize.)",
  736. "",
  737. ].join("\n"),
  738. "utf-8",
  739. ),
  740. writeFile(
  741. join(storyDir, "volume_outline.md"),
  742. [
  743. "# Volume Outline",
  744. "",
  745. "**Chapter 1-3:**",
  746. "Keep the opening pressure on the first examiner.",
  747. "",
  748. "**Chapter 4-6:**",
  749. "Recover the sealed ledger before dawn.",
  750. "",
  751. "**Chapter 7-9:**",
  752. "Trigger the registry fire and expose the false witness.",
  753. "",
  754. ].join("\n"),
  755. "utf-8",
  756. ),
  757. ]);
  758. const planner = new PlannerAgent({
  759. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  760. model: "test-model",
  761. projectRoot: root,
  762. bookId: book.id,
  763. });
  764. const result = await planner.planChapter({
  765. book,
  766. bookDir,
  767. chapterNumber: 4,
  768. });
  769. expect(result.intent.outlineNode).toContain("sealed ledger");
  770. expect(result.intent.outlineNode).not.toContain("registry fire");
  771. expect(result.intent.goal).toContain("sealed ledger");
  772. });
  773. it("falls back to the first outline directive when no standalone range matches", async () => {
  774. await Promise.all([
  775. writeFile(
  776. join(storyDir, "author_intent.md"),
  777. "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n",
  778. "utf-8",
  779. ),
  780. writeFile(
  781. join(storyDir, "current_focus.md"),
  782. [
  783. "# Current Focus",
  784. "",
  785. "## Active Focus",
  786. "",
  787. "(Describe what the next 1-3 chapters should prioritize.)",
  788. "",
  789. ].join("\n"),
  790. "utf-8",
  791. ),
  792. writeFile(
  793. join(storyDir, "volume_outline.md"),
  794. [
  795. "# Volume Outline",
  796. "",
  797. "第1-6章",
  798. "Stay with the early city setup and mentor fallout.",
  799. "",
  800. "第7-20章",
  801. "Track the merchant guild's escape route through the western canal.",
  802. "",
  803. ].join("\n"),
  804. "utf-8",
  805. ),
  806. ]);
  807. const planner = new PlannerAgent({
  808. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  809. model: "test-model",
  810. projectRoot: root,
  811. bookId: book.id,
  812. });
  813. const result = await planner.planChapter({
  814. book,
  815. bookDir,
  816. chapterNumber: 25,
  817. });
  818. expect(result.intent.outlineNode).toContain("Stay with the early city setup");
  819. expect(result.intent.goal).toContain("Stay with the early city setup");
  820. expect(result.intent.outlineNode).not.toBe("第1-6章");
  821. expect(result.intent.goal).not.toContain("merchant guild's escape route");
  822. });
  823. it("preserves hard facts from state and canon in mustKeep", async () => {
  824. const planner = new PlannerAgent({
  825. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  826. model: "test-model",
  827. projectRoot: root,
  828. bookId: book.id,
  829. });
  830. const result = await planner.planChapter({
  831. book,
  832. bookDir,
  833. chapterNumber: 3,
  834. });
  835. expect(result.intent.mustKeep).toContain("Lin Yue still hides the broken oath token.");
  836. expect(result.intent.mustKeep).toContain("The jade seal cannot be destroyed.");
  837. });
  838. it("records conflicts when the external request diverges from the outline", async () => {
  839. const planner = new PlannerAgent({
  840. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  841. model: "test-model",
  842. projectRoot: root,
  843. bookId: book.id,
  844. });
  845. const result = await planner.planChapter({
  846. book,
  847. bookDir,
  848. chapterNumber: 3,
  849. externalContext: "Ignore the guild chase and bring the focus back to mentor conflict.",
  850. });
  851. expect(result.intent.conflicts).toHaveLength(1);
  852. expect(result.intent.conflicts[0]?.type).toBe("outline_vs_request");
  853. await expect(readFile(result.runtimePath, "utf-8")).resolves.toContain("outline_vs_request");
  854. });
  855. it("writes compact memory snapshots instead of inlining the full history", async () => {
  856. await Promise.all([
  857. writeFile(
  858. join(storyDir, "pending_hooks.md"),
  859. [
  860. "# Pending Hooks",
  861. "",
  862. "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
  863. "| --- | --- | --- | --- | --- | --- | --- |",
  864. "| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
  865. "| mentor-oath | 8 | relationship | open | 9 | 11 | Mentor oath debt with Lin Yue |",
  866. "| old-seal | 3 | artifact | resolved | 3 | 3 | Jade seal already recovered |",
  867. "",
  868. ].join("\n"),
  869. "utf-8",
  870. ),
  871. writeFile(
  872. join(storyDir, "chapter_summaries.md"),
  873. [
  874. "# Chapter Summaries",
  875. "",
  876. "| 1 | Guild Trail | Merchant guild flees west | Route clues only | None | guild-route seeded | tense | action |",
  877. "| 2 | City Watch | Patrols sweep the market | Search widens | None | guild-route advanced | urgent | investigation |",
  878. "| 3 | Seal Vault | Lin Yue finds the seal vault | The jade seal returns | Seal secured | old-seal resolved | solemn | reveal |",
  879. "| 4 | Empty Road | The group loses the convoy | Doubts grow | Travel fatigue | none | grim | travel |",
  880. "| 5 | Burned Shrine | Shrine clues point nowhere | Friction rises | Lin Yue distrusts allies | none | bitter | setback |",
  881. "| 6 | Quiet Ledger | Merchant records stay hidden | No breakthrough | Cash runs thin | none | weary | transition |",
  882. "| 7 | Broken Letter | A torn letter mentions the mentor | Suspicion returns | Lin Yue reopens the old oath | mentor-oath seeded | uneasy | mystery |",
  883. "| 8 | River Camp | Lin Yue meets old witnesses | Mentor debt becomes personal | Lin Yue cannot let go | mentor-oath advanced | raw | confrontation |",
  884. "| 9 | Trial Echo | The trial fallout resurfaces | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
  885. "| 10 | Locked Gate | Lin Yue chooses the mentor line over the guild line | Mentor conflict takes priority | Oath token is still hidden | mentor-oath advanced | focused | decision |",
  886. "",
  887. ].join("\n"),
  888. "utf-8",
  889. ),
  890. ]);
  891. const planner = new PlannerAgent({
  892. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  893. model: "test-model",
  894. projectRoot: root,
  895. bookId: book.id,
  896. });
  897. const result = await planner.planChapter({
  898. book,
  899. bookDir,
  900. chapterNumber: 11,
  901. externalContext: "Bring the focus back to the mentor oath conflict with Lin Yue.",
  902. });
  903. const intentMarkdown = await readFile(result.runtimePath, "utf-8");
  904. expect(intentMarkdown).toContain("mentor-oath");
  905. expect(intentMarkdown).toContain("| 10 | Locked Gate |");
  906. expect(intentMarkdown).not.toContain("| 1 | Guild Trail |");
  907. expect(intentMarkdown).not.toContain("| old-seal | 3 | artifact | resolved |");
  908. });
  909. it("renders English memory snapshot headers for English books", async () => {
  910. book = {
  911. ...book,
  912. genre: "other",
  913. language: "en",
  914. };
  915. await Promise.all([
  916. writeFile(
  917. join(storyDir, "pending_hooks.md"),
  918. [
  919. "# Pending Hooks",
  920. "",
  921. "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
  922. "| --- | --- | --- | --- | --- | --- | --- |",
  923. "| mentor-oath | 8 | relationship | open | 9 | 11 | Mentor oath debt with Lin Yue |",
  924. "",
  925. ].join("\n"),
  926. "utf-8",
  927. ),
  928. writeFile(
  929. join(storyDir, "chapter_summaries.md"),
  930. [
  931. "# Chapter Summaries",
  932. "",
  933. "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
  934. "| --- | --- | --- | --- | --- | --- | --- | --- |",
  935. "| 10 | Locked Gate | Lin Yue | Lin Yue chooses the mentor line over the guild line | Mentor conflict takes priority | mentor-oath advanced | focused | decision |",
  936. "",
  937. ].join("\n"),
  938. "utf-8",
  939. ),
  940. ]);
  941. const planner = new PlannerAgent({
  942. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  943. model: "test-model",
  944. projectRoot: root,
  945. bookId: book.id,
  946. });
  947. const result = await planner.planChapter({
  948. book,
  949. bookDir,
  950. chapterNumber: 11,
  951. externalContext: "Bring the focus back to the mentor oath conflict with Lin Yue.",
  952. });
  953. const intentMarkdown = await readFile(result.runtimePath, "utf-8");
  954. expect(intentMarkdown).toContain("| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |");
  955. expect(intentMarkdown).toContain("| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |");
  956. expect(intentMarkdown).not.toContain("| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |");
  957. expect(intentMarkdown).not.toContain("| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |");
  958. });
  959. it("derives structured current_focus markdown into goal, avoids, and style emphasis", async () => {
  960. await Promise.all([
  961. writeFile(
  962. join(storyDir, "current_focus.md"),
  963. [
  964. "# Current Focus",
  965. "",
  966. "## Active Focus",
  967. "",
  968. "- Bring the focus back to Lin Yue's private confrontation with the mentor debt.",
  969. "- Keep the chapter centered on a missing record, not a whole-conspiracy overview.",
  970. "- Surface one concrete evidence trail the next chapter can pursue.",
  971. "",
  972. "## Avoid",
  973. "",
  974. "- Do not turn this chapter into a citywide survey of every faction.",
  975. "- Do not use summary-heavy moralizing paragraphs.",
  976. "",
  977. ].join("\n"),
  978. "utf-8",
  979. ),
  980. writeFile(
  981. join(storyDir, "story_bible.md"),
  982. [
  983. "# Story Bible",
  984. "",
  985. "- --",
  986. "- The jade seal cannot be destroyed.",
  987. "",
  988. ].join("\n"),
  989. "utf-8",
  990. ),
  991. writeFile(
  992. join(storyDir, "volume_outline.md"),
  993. "# Volume Outline\n",
  994. "utf-8",
  995. ),
  996. ]);
  997. const planner = new PlannerAgent({
  998. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  999. model: "test-model",
  1000. projectRoot: root,
  1001. bookId: book.id,
  1002. });
  1003. const result = await planner.planChapter({
  1004. book,
  1005. bookDir,
  1006. chapterNumber: 2,
  1007. });
  1008. expect(result.intent.goal).toContain("private confrontation");
  1009. expect(result.intent.goal).toContain("missing record");
  1010. expect(result.intent.mustAvoid).toEqual(expect.arrayContaining([
  1011. "Do not turn this chapter into a citywide survey of every faction.",
  1012. "Do not use summary-heavy moralizing paragraphs.",
  1013. ]));
  1014. expect(result.intent.mustAvoid).not.toContain(
  1015. "Keep the chapter centered on a missing record, not a whole-conspiracy overview.",
  1016. );
  1017. expect(result.intent.styleEmphasis).toEqual(expect.arrayContaining([
  1018. "Bring the focus back to Lin Yue's private confrontation with the mentor debt.",
  1019. "Surface one concrete evidence trail the next chapter can pursue.",
  1020. ]));
  1021. expect(result.intent.mustKeep).not.toContain("--");
  1022. });
  1023. it("emits hook agenda into chapter intent and runtime markdown", async () => {
  1024. const stateDir = join(storyDir, "state");
  1025. await mkdir(stateDir, { recursive: true });
  1026. await Promise.all([
  1027. writeFile(
  1028. join(stateDir, "manifest.json"),
  1029. JSON.stringify({
  1030. schemaVersion: 2,
  1031. language: "en",
  1032. lastAppliedChapter: 25,
  1033. projectionVersion: 1,
  1034. migrationWarnings: [],
  1035. }, null, 2),
  1036. "utf-8",
  1037. ),
  1038. writeFile(
  1039. join(stateDir, "current_state.json"),
  1040. JSON.stringify({
  1041. chapter: 25,
  1042. facts: [],
  1043. }, null, 2),
  1044. "utf-8",
  1045. ),
  1046. writeFile(
  1047. join(stateDir, "chapter_summaries.json"),
  1048. JSON.stringify({
  1049. rows: [],
  1050. }, null, 2),
  1051. "utf-8",
  1052. ),
  1053. writeFile(
  1054. join(stateDir, "hooks.json"),
  1055. JSON.stringify({
  1056. hooks: [
  1057. {
  1058. hookId: "recent-route",
  1059. startChapter: 23,
  1060. type: "route",
  1061. status: "open",
  1062. lastAdvancedChapter: 25,
  1063. expectedPayoff: "Recent route payoff",
  1064. notes: "Recent route remains active.",
  1065. },
  1066. {
  1067. hookId: "ready-payoff",
  1068. startChapter: 12,
  1069. type: "mystery",
  1070. status: "progressing",
  1071. lastAdvancedChapter: 24,
  1072. expectedPayoff: "Reveal the hidden room mastermind",
  1073. notes: "The chapter is close to the reveal point.",
  1074. },
  1075. {
  1076. hookId: "stale-debt",
  1077. startChapter: 3,
  1078. type: "relationship",
  1079. status: "open",
  1080. lastAdvancedChapter: 8,
  1081. expectedPayoff: "Mentor debt payoff",
  1082. notes: "Long-stale but still unresolved.",
  1083. },
  1084. ],
  1085. }, null, 2),
  1086. "utf-8",
  1087. ),
  1088. ]);
  1089. book = {
  1090. ...book,
  1091. genre: "other",
  1092. language: "en",
  1093. };
  1094. const planner = new PlannerAgent({
  1095. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  1096. model: "test-model",
  1097. projectRoot: root,
  1098. bookId: book.id,
  1099. });
  1100. const result = await planner.planChapter({
  1101. book,
  1102. bookDir,
  1103. chapterNumber: 26,
  1104. externalContext: "Keep the chapter on the mainline debt conflict.",
  1105. });
  1106. // Lifecycle scheduling: ready-payoff (progressing, mid-arc, overdue) → eligibleResolve;
  1107. // stale-debt (dormant, stale+overdue) → staleDebt; recent-route (remainder) → mustAdvance.
  1108. expect(result.intent.hookAgenda.eligibleResolve).toEqual(["ready-payoff"]);
  1109. expect(result.intent.hookAgenda.staleDebt).toEqual(["stale-debt"]);
  1110. expect(result.intent.hookAgenda.mustAdvance).toEqual(["recent-route"]);
  1111. expect(result.intent.hookAgenda.avoidNewHookFamilies).toContain("relationship");
  1112. expect(result.intent.hookAgenda.pressureMap).toEqual([]);
  1113. const intentMarkdown = await readFile(result.runtimePath, "utf-8");
  1114. expect(intentMarkdown).toContain("## Hook Agenda");
  1115. expect(intentMarkdown).toContain("ready-payoff");
  1116. expect(intentMarkdown).toContain("stale-debt");
  1117. });
  1118. it("builds stale debt agenda from broader active hooks than the retrieval subset", async () => {
  1119. const stateDir = join(storyDir, "state");
  1120. await mkdir(stateDir, { recursive: true });
  1121. await Promise.all([
  1122. writeFile(
  1123. join(stateDir, "manifest.json"),
  1124. JSON.stringify({
  1125. schemaVersion: 2,
  1126. language: "en",
  1127. lastAppliedChapter: 25,
  1128. projectionVersion: 1,
  1129. migrationWarnings: [],
  1130. }, null, 2),
  1131. "utf-8",
  1132. ),
  1133. writeFile(
  1134. join(stateDir, "current_state.json"),
  1135. JSON.stringify({
  1136. chapter: 25,
  1137. facts: [],
  1138. }, null, 2),
  1139. "utf-8",
  1140. ),
  1141. writeFile(
  1142. join(stateDir, "chapter_summaries.json"),
  1143. JSON.stringify({
  1144. rows: [],
  1145. }, null, 2),
  1146. "utf-8",
  1147. ),
  1148. writeFile(
  1149. join(stateDir, "hooks.json"),
  1150. JSON.stringify({
  1151. hooks: [
  1152. {
  1153. hookId: "recent-route",
  1154. startChapter: 23,
  1155. type: "route",
  1156. status: "open",
  1157. lastAdvancedChapter: 25,
  1158. expectedPayoff: "Recent route payoff",
  1159. notes: "Keep the route central.",
  1160. },
  1161. {
  1162. hookId: "recent-guild",
  1163. startChapter: 22,
  1164. type: "politics",
  1165. status: "progressing",
  1166. lastAdvancedChapter: 24,
  1167. expectedPayoff: "Guild pressure payoff",
  1168. notes: "Guild pressure remains active.",
  1169. },
  1170. {
  1171. hookId: "recent-token",
  1172. startChapter: 21,
  1173. type: "artifact",
  1174. status: "open",
  1175. lastAdvancedChapter: 23,
  1176. expectedPayoff: "Token route payoff",
  1177. notes: "Token route remains active.",
  1178. },
  1179. {
  1180. hookId: "stale-omega",
  1181. startChapter: 3,
  1182. type: "relationship",
  1183. status: "open",
  1184. lastAdvancedChapter: 8,
  1185. expectedPayoff: "Old debt payoff",
  1186. notes: "Dormant unresolved line.",
  1187. },
  1188. {
  1189. hookId: "stale-sable",
  1190. startChapter: 4,
  1191. type: "mystery",
  1192. status: "open",
  1193. lastAdvancedChapter: 9,
  1194. expectedPayoff: "Archive payoff",
  1195. notes: "Another dormant unresolved line.",
  1196. },
  1197. ],
  1198. }, null, 2),
  1199. "utf-8",
  1200. ),
  1201. ]);
  1202. book = {
  1203. ...book,
  1204. genre: "other",
  1205. language: "en",
  1206. };
  1207. const planner = new PlannerAgent({
  1208. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  1209. model: "test-model",
  1210. projectRoot: root,
  1211. bookId: book.id,
  1212. });
  1213. const result = await planner.planChapter({
  1214. book,
  1215. bookDir,
  1216. chapterNumber: 26,
  1217. externalContext: "Keep the chapter on the route pressure.",
  1218. });
  1219. // Lifecycle scheduling: stale-omega and stale-sable are stale+overdue → staleDebt;
  1220. // recent-token and recent-route are not stale/overdue → mustAdvance (by advancePressure).
  1221. expect(result.intent.hookAgenda.staleDebt).toEqual(["stale-omega", "stale-sable"]);
  1222. expect(result.intent.hookAgenda.mustAdvance).toEqual(["recent-token", "recent-route"]);
  1223. expect(result.intent.hookAgenda.avoidNewHookFamilies).toEqual(expect.arrayContaining([
  1224. "relationship",
  1225. "mystery",
  1226. ]));
  1227. expect(result.intent.hookAgenda.pressureMap).toEqual([]);
  1228. });
  1229. it("renders hook budget from total active hooks instead of the selected hook snapshot", async () => {
  1230. const stateDir = join(storyDir, "state");
  1231. await mkdir(stateDir, { recursive: true });
  1232. const hooks = Array.from({ length: 12 }, (_, index) => ({
  1233. hookId: `hook-${index + 1}`,
  1234. startChapter: index + 1,
  1235. type: index < 6 ? "route" : "mystery",
  1236. status: "open",
  1237. lastAdvancedChapter: index < 6 ? 25 - index : 12 - index,
  1238. expectedPayoff: index < 6 ? "Route debt payoff" : "Dormant mystery payoff",
  1239. notes: index < 6
  1240. ? `Route pressure thread ${index + 1} stays relevant.`
  1241. : `Dormant thread ${index + 1} should not be selected into the primary context.`,
  1242. }));
  1243. await Promise.all([
  1244. writeFile(
  1245. join(stateDir, "manifest.json"),
  1246. JSON.stringify({
  1247. schemaVersion: 2,
  1248. language: "en",
  1249. lastAppliedChapter: 25,
  1250. projectionVersion: 1,
  1251. migrationWarnings: [],
  1252. }, null, 2),
  1253. "utf-8",
  1254. ),
  1255. writeFile(
  1256. join(stateDir, "current_state.json"),
  1257. JSON.stringify({
  1258. chapter: 25,
  1259. facts: [],
  1260. }, null, 2),
  1261. "utf-8",
  1262. ),
  1263. writeFile(
  1264. join(stateDir, "chapter_summaries.json"),
  1265. JSON.stringify({
  1266. rows: [],
  1267. }, null, 2),
  1268. "utf-8",
  1269. ),
  1270. writeFile(
  1271. join(stateDir, "hooks.json"),
  1272. JSON.stringify({ hooks }, null, 2),
  1273. "utf-8",
  1274. ),
  1275. ]);
  1276. book = {
  1277. ...book,
  1278. genre: "other",
  1279. language: "en",
  1280. targetChapters: 40,
  1281. };
  1282. const planner = new PlannerAgent({
  1283. client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
  1284. model: "test-model",
  1285. projectRoot: root,
  1286. bookId: book.id,
  1287. });
  1288. const result = await planner.planChapter({
  1289. book,
  1290. bookDir,
  1291. chapterNumber: 26,
  1292. externalContext: "Keep the chapter on the route pressure.",
  1293. });
  1294. const intentMarkdown = await readFile(result.runtimePath, "utf-8");
  1295. expect(intentMarkdown).toContain("12 active hooks");
  1296. expect(intentMarkdown).not.toContain("8 active hooks");
  1297. });
  1298. });