Files
inkos/packages
hanjun fang 705555faf0 feat(core): v13 书籍创建流程迁移 — 段落式架构稿 + 旧书升级路径 + maxTokens bug fix (#207)
* chore(studio): remove dead BookFormCard create_book toolCall path

ChatPage 通过 /api/v1/agent 走的是 pi-agent loop,工具集是
sub_agent / write_truth_file / read / edit / grep / ls 等,从来不会
返回 toolCall.name === "create_book"。BookFormCard 的渲染条件因此
永远不成立,相关的 pendingBookArgs / bookCreating / handleCreateBook /
createProgress 一整套 store 状态也都是死代码。

实际建书走 BookCreate 独立表单页 → POST /api/v1/books/create,与
本次清理无关;CLI 仍在用的 develop_book / CREATE_BOOK_TOOL /
developBookDraft 全都在 core 端,未触动。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(studio): 侧边栏摘要卡改用 Streamdown 渲染 markdown

世界观/角色卡之前是 <p> 纯文本 + line-clamp,遇到 story_bible 里的
**加粗**、列表、小标题会原样显示为字面字符。现在接入 Streamdown
并带 cjk/code/math/mermaid 插件,同时用 Tailwind arbitrary variant
把标题/段落/列表的字号和间距压到卡片密度。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(studio): use-hash-route 在非 hash 页面切换时同步 React state

之前 setRoute 只在非 hash 页面分支里 setRouteState,hash 页面完全
依赖 hashchange 事件回调触发。当用户走 services → logs → services
这类路径时,中间的 logs 不写 URL,URL 一直停在 #/services;再次
赋值同一个 hash 不触发 hashchange,React state 就永远停在 logs,
表现为"点 services 没反应"。

现在进 setRoute 一律先 setRouteState,再按需写 URL,并在写 URL 前
判重避免多余 hashchange。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(core): add outline-paths helper for Phase 5 双路径 fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(core): 读架构稿硬路径统一改用 outline-paths helper

writer / continuity / reviser / chapter-analyzer / consolidator / planner /
runner / memory-retrieval 里读 story_bible.md / volume_outline.md /
character_matrix.md / current_state.md 的点都改成 readStoryFrame /
readVolumeMap / readCharacterContext / readCurrentStateWithFallback,
让 Phase 5 新书走 outline/ + roles/ 权威路径,旧书自动 fallback 到 legacy。

runner.ts 里做 before/after 对比的 old state 读取保留原样(需要磁盘原始内容);
book_rules.md 没有新路径替代,所有点也保留原 readFile 调用。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(core): architect 升级到段落式架构稿,剥离 Phase 7 伏笔扩展列

- ArchitectOutput 新增 Optional 字段 storyFrame/volumeMap/rhythmPrinciples/roles,
  并引入 ArchitectRole 类型。老字段保留让外部消费者向后兼容。
- buildChineseFoundationPrompt / buildEnglishFoundationPrompt 方法移植自 inkos-team
  的段落式 prompt(4+5 段结构 + 一人一卡 roles),剥离 Phase 7 扩展列相关要求,
  pending_hooks 保留 inkos 侧的 8 列基础版。
- parseSections 识别新 section(story_frame/volume_map/roles),legacy
  section(story_bible/volume_outline)仍被接受做 fallback;新增 parseRoles
  解析 ---ROLE--- 块。
- writeFoundationFiles 按 storyFrame 是否非空分两条路径:Phase 5 输出下产出
  outline/story_frame.md + outline/volume_map.md + roles/{主要,次要}角色/*.md +
  各个 legacy shim;legacy 输出下照老方式写扁平 md 文件。运行时 append log 文件
  (particle_ledger/subplot_board/emotional_arcs)保留初始化,兼容下游。
- extractYamlFrontmatter 顶层 helper 负责把 book_rules 的 YAML frontmatter
  拼到 story_frame.md 顶部,让读取点有单一权威位置。
- 文案:generateFoundationFromImport prompt 里 "叙事弧线" → "故事线"。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(core): state-manager 新建书籍时创建 outline/ 和 roles/ 目录

ensureControlDocumentsAt 在现有 story/ + runtime/ 之外,同时创建
story/outline/、story/roles/主要角色/、story/roles/次要角色/ 三个 Phase 5
目录。这样 initBook 完成后磁盘就有完整的 Phase 5 目录结构,
架构师产出的 outline/*.md 和 roles/**/*.md 有落脚处。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(core): 旧书架构稿升级路径 — architect revise + pipeline.reviseFoundation

Task 3.1:sub_agent 的 architect 参数新增 Optional revise + feedback 字段。
architect case 在 revise=true 时走 pipeline.reviseFoundation 分支,其他参数不变。

Task 3.2:
- architect.generateFoundation 新增第 4 个 options 参数,可带 reviseFrom 把
  老的条目式架构稿全文 + 用户反馈传进来。buildRevisePrompt 把这些拼成转换
  前缀注入到 system prompt 头部。原 3-参调用点(initBook / initFanficBook)
  保持不变。
- runner.reviseFoundation(bookId, feedback) 方法:先把 story_bible.md /
  volume_outline.md / book_rules.md / character_matrix.md 备份到
  story/.backup-phase4-<timestamp>/,读原内容喂给 architect(reviseFrom
  分支产出 Phase 5 结构),跑一次 foundation-reviewer(失败只 warn 不阻断),
  最后用 writeFoundationFiles 覆盖旧文件为 shim。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(core): context-transform 检测旧书时注入升级提示

createBookContextTransform 在读 truth files 之前先用 isNewLayoutBook(bookDir)
判断当前书的架构稿是否已经是 Phase 5 段落式。如果还是旧的条目式格式,
把一段 UPGRADE_HINT 拼到注入的 user message body 前面,提示 LLM 可以调
sub_agent(architect, { revise: true, ... }) 做一次升级——同时强调不要在
作者没明确同意前主动触发。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(test): 新测试里移除硬编码的 maxTokens / temperature 数字

此前 Phase 5 专项测试和 revise-foundation 测试按仓库历史惯例在 client.defaults
里写了 temperature: 0.7 / maxTokens: 4096 / thinkingBudget: 0 等数字。这些值:
- 对测试本身无意义——chat 方法被 vi.spyOn 拦截,defaults 运行时不被读取
- 会误导阅读者以为这是生产推荐配置,尤其 maxTokens: 4096 真被抄到生产会导致
  一章写到一半被截断(CLAUDE.md 明令禁止的 maxTokens 回归)

改成 `as unknown as LLMClient` 的 stub cast + 注释说明,保留类型满足度,
去掉所有具体数字。现有 architect.test.ts 其它测试里的老硬编码块不动,这次
只收敛新加的代码。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(core): 分离 LLMClient maxTokens (fallback) 与 maxTokensCap (cap) 语义

## Bug

createLLMClient 里 `maxTokensCap: config.maxTokens ?? null` 的实现意图是
"只有用户显式设 maxTokens 时才封顶 per-call"。但 LLMConfigSchema.maxTokens 有
zod default 8192,config.maxTokens 永远非 undefined,cap 永远等于 maxTokens。
结果 chatCompletion 里 `Math.min(perCallMax, cap)` 会把 architect 的
per-call 16384 裁到 config.maxTokens=8192,基础设定输出被截断——这正是
CLAUDE.md "防止 maxTokens 回归" 那条条款在防范的。

## 修复

把"fallback"和"cap"这两个语义彻底分到两个字段:

- `LLMConfig.maxTokens` → agent 没传 per-call 时的 fallback(不变)
- `LLMConfig.maxTokensCap` → per-call 硬上限,**新字段**,默认 undefined →
  `client.defaults.maxTokensCap = null` = 不封顶
- createLLMClient 不再从 maxTokens 推导 cap;严禁再把两者合并

## 测试

packages/core/src/__tests__/provider.test.ts 新增 "createLLMClient
maxTokensCap regression" describe:
- 只设 maxTokens → cap 为 null(不封顶)
- 显式设 maxTokensCap → cap 生效
- 什么都不设 → cap 为 null
- per-call 16384 原样到达下游,不被 config.maxTokens=8192 裁(回归核心)
- per-call 16384 被显式 maxTokensCap=4096 裁(正常 cap 语义)

## 文档

CLAUDE.md 的 "防止 maxTokens 回归" 条款改写,把两个字段的设计规则、错误
实现的样子、回归测试的路径都写清楚,让后续改动一眼看得到边界。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* revert(docs): CLAUDE.md 的 maxTokens 设计细节回退到单行索引

1f43f6a 里给 CLAUDE.md 的"防止 maxTokens 回归"条款加了 5 行
fallback/cap 字段设计细节,违反 CLAUDE.md 的渐进式披露原则
(CLAUDE.md 是 spec 索引,设计细节应该在代码注释 / 独立 spec 里)。

LLMClient 的 maxTokens / maxTokensCap 语义规则已经在
packages/core/src/llm/provider.ts 的 type definition 注释里写清楚;
回归测试在 provider.test.ts 的 "createLLMClient maxTokensCap
regression" describe block。后续改动者读到那两处即可。

CLAUDE.md 恢复到单行"防止 maxTokens 回归"的索引条款。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore(core): architect writeFoundationFiles 加布局分支 debug log

新建书或 reviseFoundation 落盘时,往 logger.info 输出一行记录本次走的是
phase5 还是 legacy 分支、storyFrame/volumeMap 字符数、roles 数量。

用途:当用户发现建书后磁盘只有 story_bible.md 没有 outline/ 时,看这行
能直接判断是 LLM 没按新 prompt 输出(走 legacy 分支)还是 writeFoundationFiles
本身的问题,不用读代码排查。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(core): reviseFoundation 正确处理运行时文件 / Phase 5 二次升级 / 删除角色清理

## 三个 Bug

**1 (high) writeFoundationFiles 无条件重置运行时状态文件**
之前在 reviseFoundation 场景下会把 current_state.md / pending_hooks.md /
particle_ledger.md / subplot_board.md / emotional_arcs.md 全部重置成初始模板。
对已经写过章节的书这是灾难——consolidator 累积的伏笔推进、角色位置、资源
账本、情感曲线都被清零,违反 context-transform 给 LLM 的 upgrade hint 承诺
"升级只改架构稿,不动已写的章节"。

修复:writeFoundationFiles 加 mode: "init" | "revise" 参数。init 模式下
初始化所有运行时文件(现有行为),revise 模式下**完全不动**运行时文件。
runner.reviseFoundation 调用时传 mode="revise"。initBook / initFanficBook
用默认 mode="init" 向后兼容。

**2 (high) reviseFoundation 第二次使用时喂 shim 给 architect**
Phase 5 书的 story_bible.md / character_matrix.md 是 shim(只有指针 + 摘录),
原 reviseFoundation 无条件读这些 flat 文件作为"原内容"。第一次升级后再用
sub_agent(architect, { revise: true, ... }) 做细节调整时,架构师拿到的
"原书内容"已经是丢信息的 shim——容易把角色线、世界观细节洗掉。

修复:reviseFoundation 用 isNewLayoutBook 检测当前书状态:
- Phase 5 书 → 从 outline/story_frame.md + outline/volume_map.md +
  roles/**/*.md(走 outline-paths helper)读权威全文
- legacy 书 → 从 story_bible.md 等 flat 文件读(现有行为)

**3 (medium) 删除/改名角色后旧 role 卡残留成幽灵**
writeFoundationFiles 只写本次 architect 输出的 role 文件,不清理
roles/**/*.md 里的旧文件。如果某次 revise 改名、删人、合并角色,旧卡会继续
被 readRoleCards 注入,planner / writer / auditor 看到 ghost 角色导致上下文
污染。

修复:writeFoundationFiles 在 mode="revise" 下,写 role 前先清空
roles/主要角色/ 和 roles/次要角色/ 两个目录。备份由 runner.reviseFoundation
在调用前完成(Phase 5 书会把 outline/ + roles/ 一并备份到 .backup-phase5-
<ts>/),清空安全。

## 测试

packages/core/src/__tests__/revise-foundation.test.ts 新增 4 个回归用例:

- revise 不重置运行时状态文件(构造已写 20 章的书,验证 5 个运行时文件
  内容保持不变)
- Phase 5 书二次 revise 从权威源读(验证传给 architect 的 reviseFrom
  是完整 outline + roles 内容,不含 shim 指针)
- revise 清空旧 role 文件(构造 5 个老角色,revise 后只输出 2 个新角色,
  验证另外 3 个旧文件被删除)
- Phase 5 revise 备份带 phase5 tag 且包含 outline/ + roles/

全仓测试 785 passed(core 785 / studio 138 / cli 169)。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(core): revise 模式 + legacy 输出时抛错,避免数据混乱

## Bug corner case

上一个 fix commit (81e6665) 里把 writeFoundationFiles 的 roles 清理逻辑放在
if (isPhase5Output) 分支内,漏掉了一个 corner case:revise 模式下,如果 LLM
意外回退 legacy 输出(storyFrame 为空),会走 else 分支把 LLM 产出的 legacy
内容写到 story_bible.md / volume_outline.md 等 flat 文件,但 outline/ 和
roles/ 保持不变——Phase 5 书立刻进入"outline 新 + shim 老内容 + roles 残留"
的混乱状态,读取端 fallback 读到互相矛盾的内容。

## 修法

两步:

1. 在 writeFoundationFiles 开头检查:mode === "revise" && !isPhase5Output
   → 立即抛错,不写任何文件。让用户感知 LLM 响应异常,避免数据默默被破坏。
2. roles 清理逻辑从 if (isPhase5Output) 内部移到外面,变成 mode 级行为。
   放在抛错检查之后,保证 revise 场景永远清空 roles(但抛错场景下不会执行
   到,因为先抛错了——旧 role 文件保留,加上 runner 里的 backup 双重保险)。

init 模式下 else 分支的 legacy 输出路径保持原样(老 prompt / 测试 stub 的
合法场景)。

## 测试

revise-foundation.test.ts 新增 "revise 模式 + LLM 意外输出 legacy 格式 →
抛错且不动架构稿文件" 用例:
- 构造 Phase 5 书(outline/ + roles/ 有原始内容、story_bible.md 是 shim)
- Stub architect 返回 legacy 格式输出(storyFrame 空)
- 期望 reviseFoundation 抛错含 "legacy-format output.*NOT been modified"
- 验证 outline/story_frame.md、outline/volume_map.md、story_bible.md 内容
  保持不变

全仓 786 passed / build 通过。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:10:30 -07:00
..