dsh-openwrite 0.2.23 → 0.2.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,12 +29,12 @@
29
29
 
30
30
  ## 快速安装
31
31
 
32
- **0.2.23 标准插件包**兼容精确 dsh 版本 **0.1.2-rc.1 / 0.1.5-alpha.1 / 0.2.0-rc.2 / 0.2.1-rc.2**。四个版本均已完成 Web 工作台渲染与 DoG 数据读取验证,Windows Electron 桌面端启动/恢复链路尚未实机复测。可下载版本、各平台验收结果和 SHA-256 以 [GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/latest) 附件为准;验收方法见 [发布验收](docs/RELEASE_ACCEPTANCE.md)。默认使用 npm `latest` 渠道;商城所需的精确预览版兼容性、验证范围和安装限制见 [DSH STORE 接入](docs/DSH_STORE.md)。
32
+ **0.2.24 标准插件包**兼容精确 dsh 版本 **0.1.2-rc.1 / 0.1.5-alpha.1 / 0.2.0-rc.2 / 0.2.1-rc.2**。四个版本均已完成 Web 工作台渲染与 DoG 数据读取验证,Windows Electron 桌面端启动/恢复链路尚未实机复测。可下载版本、各平台验收结果和 SHA-256 以 [GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/latest) 附件为准;验收方法见 [发布验收](docs/RELEASE_ACCEPTANCE.md)。商城所需的精确预览版兼容性、验证范围和安装限制见 [DSH STORE 接入](docs/DSH_STORE.md)。
33
33
 
34
- npm 包和对应 GitHub Release 使用同一份验收产物。也可安装精确版本的 Release 包:
34
+ npm `dsh-openwrite@0.2.24` 与对应 GitHub Release 均已发布,`latest` 已更新到 0.2.24,使用同一份验收产物。也可安装精确版本的 Release 包:
35
35
 
36
36
  ```sh
37
- dsh plugin --profile web add -w https://github.com/LiPu-jpg/Openwrite/releases/download/v0.2.23/dsh-openwrite-0.2.23.tgz
37
+ dsh plugin --profile web add -w https://github.com/LiPu-jpg/Openwrite/releases/download/v0.2.24/dsh-openwrite-0.2.24.tgz
38
38
  dsh web
39
39
  ```
40
40
 
@@ -64,7 +64,11 @@ dsh web
64
64
 
65
65
  ### dsh 0.2 桌面端升级
66
66
 
67
- `0.2.23` 修复 [#83](https://github.com/LiPu-jpg/Openwrite/issues/83)/[#84](https://github.com/LiPu-jpg/Openwrite/issues/84)/[#85](https://github.com/LiPu-jpg/Openwrite/issues/85):根包与全部内嵌子包的兼容声明加入精确宿主 `0.2.1-rc.2`,安装和激活不再被宿主版本闸门拒绝,无需版本豁免或手工编辑 `compatibility.json`。已在 `0.2.1-rc.2` 完成 Web 客户端验收(启动、OpenWrite 入口、工作台三页签、DoG 面板与其他插件共存),`0.1.2-rc.1`、`0.2.0-rc.2` 回归通过;Windows Electron 桌面端未实机复测。升级后停止并重新启动 dsh,再刷新浏览器页面。
67
+ `0.2.25`(Core 5.8.12)为本分支的未发布候选,新增连载体检、分段续写、共享术语/范本、章组审计和漂移裁决,并修复验收与任务可观测性。范围、离线升级工具及宿主侧限制见 [#98–#100 实现说明](docs/ISSUES_98_100.md)。不要把候选版本当作已上架版本。
68
+
69
+ `0.2.24`(Core 5.8.11)修复 [#88–#95](docs/ISSUES_88_95.md):删章验收账本、结算降级、长文本流式生成、后台写章进度、统一字数门限、草稿发现与恢复及诊断/路由语义。[GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/tag/v0.2.24) 与 [npm](https://www.npmjs.com/package/dsh-openwrite/v/0.2.24) 均已发布,完整验收见 [发布验收](docs/RELEASE_ACCEPTANCE.md#024)。升级后停止并重新启动 dsh,再刷新浏览器页面。
70
+
71
+ `0.2.23` 修复 [#83](https://github.com/LiPu-jpg/Openwrite/issues/83)/[#84](https://github.com/LiPu-jpg/Openwrite/issues/84)/[#85](https://github.com/LiPu-jpg/Openwrite/issues/85):根包与全部内嵌子包的兼容声明加入精确宿主 `0.2.1-rc.2`,安装和激活不再被宿主版本闸门拒绝,无需版本豁免或手工编辑 `compatibility.json`。已在 `0.2.1-rc.2` 完成 Web 客户端验收(启动、OpenWrite 入口、工作台三页签、DoG 面板与其他插件共存),`0.1.2-rc.1`、`0.2.0-rc.2` 回归通过;Windows Electron 桌面端未实机复测。该版本完整验收见 [发布验收](docs/RELEASE_ACCEPTANCE.md#023)。升级后停止并重新启动 dsh,再刷新浏览器页面。
68
72
 
69
73
  `0.2.21` 修复 [#79](https://github.com/LiPu-jpg/Openwrite/issues/79) 的 Windows Desktop 目录选择:宿主接口失败后使用桌面提供的系统文件夹对话框,覆盖侧栏进入与工作台内目录选择。Core 保持 5.8.10。排查与手填路径方式见 [安装指南](docs/INSTALL.md#windows-desktop-目录选择)。
70
74
 
@@ -11,7 +11,7 @@ npm --prefix packages/openwrite-bridge run build
11
11
  node --test packages/openwrite-bridge/scripts/tool-policy.test.mjs
12
12
  ```
13
13
 
14
- `real dsh tool registry isolates 90 tools` 会打印两行 JSON:
14
+ `real dsh tool registry isolates 95 tools` 会打印两行 JSON:
15
15
 
16
16
  | 字段 | 含义 | 不是什么 |
17
17
  |---|---|---|
@@ -20,7 +20,7 @@ node --test packages/openwrite-bridge/scripts/tool-policy.test.mjs
20
20
  | `sdkBytes` | 注入到 system prompt 的 SDK 文本字节 | 不是费用 |
21
21
  | `tokenizer: not-measured` | 本仓库不内置某家 tokenizer | 不得改写成已测 token |
22
22
 
23
- 同一套检查还断言:普通预设 `novel_*` 为 0;创作预设为 90 且不随 PTC 另一 scope 改变。
23
+ 同一套检查还断言:普通预设 `novel_*` 为 0;创作预设为 95 且不随 PTC 另一 scope 改变。
24
24
 
25
25
  要对比「请求大小」,把一次真实 `tools.schemas(agent)` 或 PTC `wireSchemas` 的 JSON 写入文件后对体积做 `wc -c` / SHA-256。这仍是请求体字节,不是 token。
26
26
 
@@ -18,7 +18,7 @@
18
18
 
19
19
  | 路径 | 说明 |
20
20
  |---|---|
21
- | `packages/openwrite-bridge/` | dsh 插件:90 个 `novel_*` 工具,覆盖 Studio HTTP 动作面并提供隔离模型横评与脱敏 trace |
21
+ | `packages/openwrite-bridge/` | dsh 插件:95 个 `novel_*` 工具,覆盖 Studio HTTP 动作面并提供隔离模型横评与脱敏 trace |
22
22
  | `packages/studio-panel/` | dsh web 原生工作台:创作 / 资料 / 任务,含规范章节导航、连续审读、场景双序、正文编辑器、双 DAG、模型测试台与工具卡 |
23
23
  | `presets/openwrite/` | OpenWrite 全流程 agent 预设(规划/资产/写章/评审/修订) |
24
24
  | `scripts/install.sh` | 构建插件、安装预设到 `~/.dsh/.agent-presets/`、插件装进 dsh profile |
package/docs/DSH_STORE.md CHANGED
@@ -23,7 +23,7 @@ OpenWrite 是一个完整的小说创作插件套件。商城安装目标是仓
23
23
 
24
24
  alpha 浏览器验收进一步发现 persona 配置由 `text` 改成了必填 `prefix`;预设使用 YAML 锚点将同一份身份文本提供给两个字段,并以两版实际 Config / apply 验证身份只注册一次。
25
25
 
26
- 构建基线仍是 `0.1.2-rc.1`。兼容声明覆盖精确版本 `0.1.2-rc.1`、`0.1.5-alpha.1`、`0.2.0-rc.2`、`0.2.1-rc.2`,并分别保留已完成的 Web 客户端验收;`0.1.3-alpha.1`、`0.1.3-alpha.2` 保持未知。这不自动升级用户宿主,也不承诺整个 alpha 或 0.2 范围都兼容。`0.2.23` 对 `0.2.1-rc.2` 的本机验证覆盖安装闸门、两个客户端激活、预设选择、工作台入口与三页签、DoG 面板及其他插件共存;完整平台矩阵和发布产物仍以下述 CI 报告为准。
26
+ 构建基线仍是 `0.1.2-rc.1`。兼容声明覆盖精确版本 `0.1.2-rc.1`、`0.1.5-alpha.1`、`0.2.0-rc.2`、`0.2.1-rc.2`,并分别保留已完成的 Web 客户端验收;`0.1.3-alpha.1`、`0.1.3-alpha.2` 保持未知。这不自动升级用户宿主,也不承诺整个 alpha 或 0.2 范围都兼容。`0.2.23` 已发布到 npm `latest` 与 GitHub Release,完整平台矩阵、源码安装和四份宿主客户端报告见[发布验收](RELEASE_ACCEPTANCE.md#023);本机另复验了 `0.2.1-rc.2` 的安装闸门、客户端激活、预设选择、工作台入口与三页签、DoG 面板及其他插件共存。
27
27
 
28
28
  2026-09-09,从官方 npm Registry 直接安装 `@deepseek-ai/dsh@0.1.5-alpha.1` 时,上游范围依赖选中了正在发布的 alpha.2,其中 `@deepseek-ai/dsh-tool-pwsh-persistent@^0.1.5-alpha.2` 当时缺失,安装失败(ETARGET)。兼容测试使用 npm 的 `--before=2026-09-09T00:00:00Z` 解析 alpha.1 发布时的依赖,并保留解析锁。没有修改官方包源码或用 replacements/overrides 冒充官方组件。此证据仅适用于报告中的宿主依赖图,不代表后续浮动依赖安装也通过。
29
29
 
@@ -0,0 +1,40 @@
1
+ # Core 5.8.11:写作与恢复修复
2
+
3
+ 插件 0.2.24 / Core 5.8.11 修复 #88–#95,已发布到 [GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/tag/v0.2.24) 与 [npm](https://www.npmjs.com/package/dsh-openwrite/v/0.2.24);分发状态和完整验收见 [发布验收](RELEASE_ACCEPTANCE.md#024)。测试使用假模型和隔离目录,不调用付费模型。
4
+
5
+ | Issue | 行为 |
6
+ | --- | --- |
7
+ | #88 | 删章在项目写锁内清除验收 head、递增 revision、保存删除 operation;未完成旧 operation 不再允许 resume。 |
8
+ | #89 | 账本有 head 而文件不存在时返回 `missing` 和 `CHAPTER_FILE_MISSING` warning,不阻断事实门禁;原文恢复后自动回到 current。 |
9
+ | #90 | 结算格式错误最多重试两次,收紧提示且保留全部正文。耗尽后保存带降级标记的摘要,reconcile task 返回 `completed`、`degraded: true`。网络、认证等异常仍返回实际失败。 |
10
+ | #91 | 写作 Agent、修订、语料分析/打包与结算使用 `OW_AGENT_STREAM`(默认 1);流式请求使用读空闲超时,保留已接收的短片段。`OW_LLM_TIMEOUT` 可覆盖超时,默认 300 秒,默认 SDK 重试一次。 |
11
+ | #92 | HTTP `POST /api/write` 提交 `chapter_write` 后立即返回 `task_id`。任务详情保留原有 `phase`,另提供 context/plan/draft/fact_extract/settle/validate/commit 的 `stage`、`run_id_v2`。后台任务保留原始工具调用追踪。锁每 15 秒更新心跳,死进程锁在下一次获取时回收。 |
12
+ | #93 | 上下文、writer 和 reviewer 共用 `chapter_word_bounds`(目标 -20%/+50%)和 `count_writing_units`。4200/5000 单位无需补写;真正超限先尝试两次唯一文本锚定修改,再允许一次整章调整。 |
13
+ | #94 | 删章派生数据移入 `data/chapter_deletions/<operation_id>/`,备份地址指向归档。未提交草稿有列表和显式恢复接口;恢复使用原 run id、逐字节复制,不生成新正文。 |
14
+ | #95 | 任务写入 `finished_at`,读端兼容旧 `completed_at` 并返回 `duration_ms`。导出预检使用阅读顺序的缺章判定;诊断默认只显示近 24 小时的历史事件,历史数量单独返回,成功任务重置失败连续计数。未知 API 路由返回 404 `ROUTE_NOT_FOUND`,方法错误返回 405 `METHOD_NOT_ALLOWED` 和 `Allow`。 |
15
+
16
+ ## 草稿恢复
17
+
18
+ 1. `GET /api/manuscript/drafts` 返回 chapter_id、run_id_v2、draft_path、draft_artifact、word_count、created_at、draft_revision 和 restorable。
19
+ 2. `POST /api/manuscript/drafts/restore` 传入 `run_id_v2`、`confirm: true`。覆盖已有正文还必须传入当前正文的 `expected_revision`;缺失正文传空字符串。
20
+ 3. 响应返回 `operation` 和 `requires_reconcile: true`。原始草稿已经写回正文;运行 acceptance reconcile 后才会宣告事实 current。恢复正文这一步不调用模型。
21
+
22
+ 同步 Python 应用方法 `write_next_chapter()` 保持可用。HTTP 写作的结果通过 `/api/tasks/<task_id>` 读取,错误中的 `details` 包含已落盘草稿路径。取消或流中断也保留可发现的正文片段。
23
+
24
+ 诊断支持 GET/POST `/api/diagnostics` 的 `since`(ISO 时间)与 `window_hours`;CLI 为 `openwrite diagnose --window 24 --since <ISO>`。缺章、当前设定冲突等仍存在的状态问题持续报告;时间窗口只淘汰已发生的任务、运行和传输事件。
25
+
26
+ ## 验证范围
27
+
28
+ 回归覆盖五轮删章/重建、缺失文件恢复、三次非 mapping 降级、原字节/CRLF 草稿恢复、取消与流中断保稿、后台进度、错误恢复路径、HTTP 404/405、过期诊断及统一字数门限。没有将真实服务商连续 20 次请求或 Windows Electron 实机验收标记为通过。
29
+
30
+ ## dsh 工具入口
31
+
32
+ `novel_manuscript_edit_action(action="drafts")` 列出未提交草稿;`action="restore_draft"` 传 `run_id_v2`、`confirm: true`,覆盖已有正文时用 `revision` 传当前正文 SHA。工具将其映射到 Core 的 `expected_revision`。恢复属于本地写入操作;不会按生成任务执行。随后使用现有验收调和工具处理返回的 operation。
33
+
34
+ #86、#87 的精确宿主 `0.2.1-rc.2` 声明已经包含在 0.2.23,本版继续保留该兼容范围。
35
+
36
+ 插件验证:`npm run check:plugin` 通过(构建、doctor、36 项生命周期检查、预设与双 profile、bridge/panel smoke、14 项 epoch、298 项组件);`npm run test:managed` 23 项通过,跨仓库 contracts 通过。
37
+
38
+ 核心全量 pytest:1409 passed、32 skipped;新 wheel 另做打包内容与针对性回归检查。发布版本为插件 0.2.24 / Core 5.8.11。
39
+
40
+ 新 wheel 解包后隔离导入并执行针对性回归:11 passed。已合并核心 [PR #96](https://github.com/LiPu-jpg/Openwrite/pull/96) 与插件 [PR #97](https://github.com/LiPu-jpg/Openwrite/pull/97)。
@@ -0,0 +1,59 @@
1
+ # #98–#100:连载写作与运行可靠性
2
+
3
+ 本分支为 dsh-openwrite 0.2.25 / Core 5.8.12 候选,尚未发布。Core 代码位于同仓库 native-core 分支;安装包将捆绑对应 wheel。完整数据契约见 Core `docs/SERIAL_WRITING.md`。
4
+
5
+ ## 覆盖范围
6
+
7
+ | Issue | 实现 |
8
+ | --- | --- |
9
+ | #98.1 插件升级破坏 bundle | 新增停机离线旁路升级工具,复制整个 profile 后安装、校验、切换,保留旧目录和日志;支持修复不完整 OpenWrite bundle 与中断恢复。原生 dsh 安装器和宿主启动降级未修改,见下方限制。 |
10
+ | #98.2 外部验收失败 | `no_model=true` / `mode=fast` 承认正文版本,派生事实明确 stale,可稍后完整对账。 |
11
+ | #98.3 误连实例 | status/响应头标识 root、instance、port;Bridge 校验实际根,不一致返回 409。 |
12
+ | #98.4、#99.1 长章 | 按 beats 分段,携带 1,500 字尾文续写,拼接去重;不足时续写,预算耗尽保留草稿。 |
13
+ | #98.5 已定稿章横评 | 显式选择历史隔离改写,产物不写正式正文/事实账本;标注当前正典上下文限制。 |
14
+ | #98.6 自留存档 | `personal_archive` 档位将质量门禁降为警告并写 hash 回执,结构错误仍阻断。 |
15
+ | #98.7 心跳与超时 | 活动、worker 心跳、当前模型调用时长分别显示;流式总期限有界,超时保留部分输出。 |
16
+ | #98.8 读写互斥 | GET 与纯读诊断不被写任务统一阻断;修改仍持项目写锁。 |
17
+ | #98.9 章级体检 | 汉字/段落、句长和段长 P50/P90、对白占比、项目词典名词密度,写后自动保存。 |
18
+ | #99.2–#99.6 写作资产 | anchor 范本、共享术语表及例句、最低次数、review 开关、12 字窗口重合/重复、角色属性分组轮换和明确已用标记。 |
19
+ | #99.7 用量 | reasoning_tokens 从上游嵌套 usage 提升,真实 0 与未知分别保留。 |
20
+ | #100 章节组审计 | 章纲四字段、只读计数与缺失报告、声明伏笔连边、漂移预算与冻结、吸收/回收/冻结裁决、不可变基线和事务恢复。 |
21
+
22
+ 工作台「操作 → 连载体检」提供资产编辑、范本导入、章节体检、章纲元数据、漂移记账与裁决。相关工具为 `novel_writing_assets`、`novel_manuscript_metrics`、`novel_outline_audit`、`novel_drift_entry`、`novel_arc_reconcile`;当前预设共 95 个领域工具。大纲吸收要求真实版本化更新;界面里的承诺计数是声明检查,不能当作已验证的故事推进。
23
+
24
+ ## 离线升级和损坏修复
25
+
26
+ 先完整退出 DeepSeek Harness / DSH Desktop 并停止 dsh、OpenWrite managed runtime。整个升级过程中保持停机。工具会在安装前及切换前检查进程;未知进程状态也拒绝修改。宿主原生命令不受此检查保护。
27
+
28
+ 从解压的候选/发行包运行(或已安装版本提供的 `openwrite-safe-upgrade`):
29
+
30
+ ```sh
31
+ node /path/to/package/scripts/safe-upgrade.mjs --profile desktop --package /absolute/path/dsh-openwrite-0.2.25.tgz
32
+ ```
33
+
34
+ 候选版本未上架前不要使用 `dsh-openwrite@0.2.25` 下载命令。`DSH_HOME` 必须指向实际宿主的 home;默认 `~/.dsh`。Windows 另设 `DSH_BIN` 为已验收宿主运行时的绝对 `lib/bin.js` 路径,使用 Node 直接执行而非 `.cmd` shell。其余平台可通过 DSH_BIN 指定同一受信任 CLI。
35
+
36
+ 安装在旁路 profile 中进行;整个旧 profile(包括其他插件、锁文件及设置)在切换前保持可用。只有 OpenWrite 的必要文件和 manifest 校验成功才进入目录切换。切换为两次同盘 rename,**不是整个目录树的单次原子交换**;停机状态避免读取窗口,中断由持久事务记录恢复。锁覆盖本工具的并发操作,不能阻止未经协调的原生安装器或用户重新启动宿主。
37
+
38
+ 记录保存在 `<DSH_HOME>/openwrite/upgrades/upgrade_*/`:`operation.json`、`pnpm.log`、成功切换后的 `previous-profile/`。stdout/stderr 实时落盘;安装 180 秒超时终止子进程。保留目录可能占用较多空间,确认升级稳定后再人工清理。
39
+
40
+ ```sh
41
+ # 中断恢复,只恢复同一批准操作;若后来修改过 profile 则拒绝覆盖
42
+ node /path/to/package/scripts/safe-upgrade.mjs --recover upgrade_<实际操作ID>
43
+
44
+ # 损坏 bundle 修复:先检查,再明确应用。仅禁用不完整的 OpenWrite bundle。
45
+ node /path/to/package/scripts/safe-upgrade.mjs --profile desktop --repair
46
+ node /path/to/package/scripts/safe-upgrade.mjs --profile desktop --repair --apply
47
+ ```
48
+
49
+ 修复会备份 profile manifest,不删除正文、其他插件或设置;不会假装重装已经成功。完整 OpenWrite bundle 不会被该修复入口禁用。之后使用旁路升级重新安装。
50
+
51
+ ## 验收与边界
52
+
53
+ 自动化覆盖分段续写/去重、版本冲突、术语与 Unicode 位置、快验收及后续完整对账、持双重写锁时纯读、心跳不伪造进度、流式期限、漂移超预算及中断恢复、存档回执、升级失败时旧 profile 不变和非空日志。前端测试覆盖连载体检读取/资产保存/版本化追认;手工浏览器验收仅使用 `~/my_novel`。
54
+
55
+ 这些测试不调用付费模型。真实模型达到 10,000 字的稳定率仍由模型决定;系统保证有限续写与草稿留存,不保证模型质量。具象名词密度依赖作者词典;承诺是否兑现未做语义判断。历史横评使用当前正典,不声称复原当时章前状态。伏笔声明边与 DAG 已确认状态分别报告,不自动篡改已收状态。
56
+
57
+ Windows Desktop 的文件句柄冲突与恢复需 Windows 实机补验;本次在 macOS 完成实现和本地回归。原生 `dsh plugin add/update` 与「损坏 bundle 自动跳过并提供修复入口」属于 deepseek-harness 宿主代码,未在这个仓库修复,因此 #98 不应标为全部解决。现有宿主用户必须使用本工具的停机路径,或等宿主更新后再走原生更新。
58
+
59
+ 本地回归记录(2026-10-23,macOS arm64):Core 全量 1,424 passed / 32 skipped;后续超时配置校验等定向回归 38 passed、规划转换回归 36 passed;前端 301 passed;managed runtime/策略 24 passed;离线升级 5 passed;跨端契约检查通过。Core wheel 已从独立解压目录验证版本、体检模块及 HTTP 路由。真实服务商调用、Windows 实机和发布操作均未执行。
@@ -6,13 +6,23 @@
6
6
 
7
7
  新建作品的验收请求最多等待 60 秒(原为 15 秒),保留成功耗时和失败诊断;不重试写请求,超时仍失败。此调整容纳慢速运行器,不代表初始化耗时问题已被修复。
8
8
 
9
- ## 0.2.23(候选)
9
+ ## 0.2.24
10
10
 
11
- 修复 [#83](https://github.com/LiPu-jpg/Openwrite/issues/83)/[#84](https://github.com/LiPu-jpg/Openwrite/issues/84)/[#85](https://github.com/LiPu-jpg/Openwrite/issues/85):根包与全部内嵌子包的 `peerDependencies`、`dsh.compatibility.dsh` 和 `dshReleases` 加入精确宿主 `0.2.1-rc.2`。宿主版本闸门读取的是 dsh peer 范围,因此只修改展示用的 `dsh.compatibility` 不能让旧包通过;本版四个包同步声明后,安装与激活均不需要版本豁免。
11
+ [GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/tag/v0.2.24) 与 [npm `dsh-openwrite@0.2.24`](https://www.npmjs.com/package/dsh-openwrite/v/0.2.24) 均已发布,npm `latest` 已更新到 0.2.24。直接使用[完整发布 CI](https://github.com/LiPu-jpg/Openwrite/actions/runs/39716542888) 验收通过的原始产物,未重新打包。来源提交 `d16623c6b0d2a84d45dffe23c24e641df790175f`,源码 tree `e0b2fd0647114e46e6fd188f4fb32b13fadc6b3d` 与主分支合并提交 `32166de6a3f66d8ebb2e3b5bd3912c1c1236f07b` 一致。npm 包 SHA-256 为 `a9fb766e8277b0f7dfecd9ea2284720512583e766e82515b70a529c967aa0170`;从官方 registry 无认证重新下载后,包字节、SHA-1、SHA-512 integrity 与 CI 产物和 registry 元数据均一致。
12
12
 
13
- 本机 macOS arm64 / Node v24.15 已使用官方 registry 的精确 `@deepseek-ai/dsh@0.2.1-rc.2` 完成候选包验证:tarball 经 pnpm 安装进隔离 profile,宿主启动不再出现 `incompatible with dsh` / `skipping profile bundle`;浏览器中两个客户端入口激活,OpenWrite 入口和作品启动器可用。`client-boot-smoke` 在 `0.2.1-rc.2` 上通过版本闸门、预设选择、工作台三页签、DoG 面板与其他插件共存检查;`0.1.2-rc.1`、`0.2.0-rc.2` 回归通过。真实模型调用为 0。
13
+ 8 份原生安装/源码报告与 4 份客户端契约报告全部通过,覆盖 Linux、macOS arm64/x64、Windows、Node 22.19/24/26、源码安装、alpha 兼容与精确宿主 `0.1.2-rc.1` / `0.1.5-alpha.1` / `0.2.0-rc.2` / `0.2.1-rc.2`。GitHub 四份公开附件重新下载后均与 CI 原文件逐字节一致;合并后的[主分支完整 CI](https://github.com/LiPu-jpg/Openwrite/actions/runs/39717124199) 也已通过。源码安装命令的超时从两分钟调整为五分钟,保留原有成功条件,并补充退出状态、信号和错误码诊断。
14
14
 
15
- 完整六平台/Node、源码安装、CI 产物及 npm 发布尚未由本提交的 `Release artifact validation` 运行确认。在这些报告和同字节产物通过前,0.2.23 只作为候选版本,不计为已发布;Windows Electron 桌面端原生启动与恢复仍须对应平台实机验收。
15
+ 修复 #88–#95,合并核心 [PR #96](https://github.com/LiPu-jpg/Openwrite/pull/96) 与插件 [PR #97](https://github.com/LiPu-jpg/Openwrite/pull/97)。捆绑 Core 5.8.11,wheel 来源提交 `596709e3f4bbe4eab7ce59513c9f36c4390ad367` 已包含在 `native-core` 分支,wheel SHA-256 为 `7937914eee6b99cd64fee2ead96a5e6d63e807acae0a610c1c4abf863e814188`。行为与草稿恢复步骤见[修复说明](ISSUES_88_95.md)。
16
+
17
+ 核心全量 pytest:1409 passed、32 skipped;wheel 解包后隔离回归:11 passed。插件构建、doctor、生命周期、双 profile、预设、bridge/panel smoke、14 项 epoch、298 项组件、23 项受管理运行时检查及跨仓库 contracts 通过。真实服务商连续请求与 Windows Electron 实机验收未运行,不计入通过。
18
+
19
+ ## 0.2.23
20
+
21
+ [GitHub Release](https://github.com/LiPu-jpg/Openwrite/releases/tag/v0.2.23) 与 npm `dsh-openwrite@0.2.23` 已发布,npm `latest` 已更新到 0.2.23。使用[主分支完整 CI](https://github.com/LiPu-jpg/Openwrite/actions/runs/39219394324) 验收的原始产物,来源提交 `341408b80075fe4cc429f30a3327bcbdf2e69903`,npm 包 SHA-256 为 `8eb13e3de31b2144f1d5a57094abff38951c0329058520c96331aed03c735470`。8 份安装/源码报告与 4 份宿主客户端报告(含精确 `0.2.1-rc.2`)全部通过;GitHub 四份附件逐一与 CI 原文件比对一致,官方 npm registry 无认证下载包的字节与 SHA-256 也一致。
22
+
23
+ 修复 [#83](https://github.com/LiPu-jpg/Openwrite/issues/83)/[#84](https://github.com/LiPu-jpg/Openwrite/issues/84)/[#85](https://github.com/LiPu-jpg/Openwrite/issues/85):根包与全部内嵌子包的 `peerDependencies`、`dsh.compatibility.dsh` 和 `dshReleases` 加入精确宿主 `0.2.1-rc.2`。宿主版本闸门读取的是 dsh peer 范围,因此只修改展示用的 `dsh.compatibility` 不能让旧包通过;本版四个包同步声明后,安装与激活均不需要版本豁免。发布矩阵新增 `0.2.1-rc.2` 客户端契约,并与 `0.1.2-rc.1`、`0.1.5-alpha.1`、`0.2.0-rc.2` 一起通过。
24
+
25
+ 本机 macOS arm64 / Node v24.15 另使用官方 registry 的精确 `@deepseek-ai/dsh@0.2.1-rc.2` 完成安装级复验:tarball 经 pnpm 安装进隔离 profile,宿主启动不再出现 `incompatible with dsh` / `skipping profile bundle`;浏览器中两个客户端入口激活,OpenWrite 入口和作品启动器可用。真实模型调用为 0。Windows Electron 桌面端原生启动与恢复仍须对应平台实机验收,本版未计为通过。
16
26
 
17
27
  ## 0.2.22
18
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-openwrite",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
4
4
  "description": "OpenWrite 长篇小说领域后端接入 DeepSeek Harness:统一创作 Agent 预设 + 原生创作工作台",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -14,7 +14,7 @@
14
14
  "install-dsh": "bash scripts/install.sh",
15
15
  "dev": "bash scripts/dev.sh",
16
16
  "doctor": "node scripts/plugin-doctor.mjs",
17
- "test:maintenance": "node --test scripts/plugin-doctor.test.mjs scripts/lifecycle.test.mjs packages/openwrite-bridge/scripts/runtime-lifecycle.test.mjs scripts/maintenance.test.mjs scripts/preset-lifecycle.test.mjs",
17
+ "test:maintenance": "node --test scripts/plugin-doctor.test.mjs scripts/lifecycle.test.mjs packages/openwrite-bridge/scripts/runtime-lifecycle.test.mjs scripts/maintenance.test.mjs scripts/preset-lifecycle.test.mjs scripts/safe-upgrade.test.mjs",
18
18
  "check": "npm run check:plugin && npm run test:contracts",
19
19
  "test:lifecycle": "node --test scripts/lifecycle.test.mjs",
20
20
  "test:runtime": "node --test packages/openwrite-bridge/scripts/runtime-lifecycle.test.mjs",
@@ -95,6 +95,7 @@
95
95
  "packages/studio-panel/package.json",
96
96
  "scripts/dog",
97
97
  "scripts/maintenance.mjs",
98
+ "scripts/safe-upgrade.mjs",
98
99
  "packages/openwrite-bridge/LICENSE",
99
100
  "packages/studio-panel/LICENSE",
100
101
  "release/licenses",
@@ -119,7 +120,8 @@
119
120
  "@deepseek-ai/dsh-tools": "0.1.2-rc.1 || 0.1.5-alpha.1 || 0.2.0-rc.2 || 0.2.1-rc.2"
120
121
  },
121
122
  "bin": {
122
- "openwrite-maintenance": "scripts/maintenance.mjs"
123
+ "openwrite-maintenance": "scripts/maintenance.mjs",
124
+ "openwrite-safe-upgrade": "scripts/safe-upgrade.mjs"
123
125
  },
124
126
  "repository": {
125
127
  "type": "git",
@@ -1,4 +1,6 @@
1
1
  import type { BackendConnection } from './managed-runtime.js';
2
+ /** Windows roots are case-insensitive and may use either separator. */
3
+ export declare function workspaceRootsEqual(left: string, right: string, platform?: NodeJS.Platform): boolean;
2
4
  /**
3
5
  * Shared HTTP client for the OpenWrite Studio action surface.
4
6
  *
@@ -1,6 +1,14 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
2
+ import { join, resolve, win32 } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { realpathSync } from 'node:fs';
5
+ /** Windows roots are case-insensitive and may use either separator. */
6
+ export function workspaceRootsEqual(left, right, platform = process.platform) {
7
+ const normalize = platform === 'win32'
8
+ ? (value) => win32.normalize(value).replace(/[\\/]+$/, '').toLowerCase()
9
+ : (value) => resolve(value);
10
+ return normalize(left) === normalize(right);
11
+ }
4
12
  /** Header OpenWrite requires on every write request (POST/PUT). */
5
13
  const WRITE_HEADER = 'X-OpenWrite-Studio';
6
14
  /** HTTP header values are bytes, so Unicode paths need explicit wire encoding.
@@ -183,9 +191,21 @@ export class StudioClient {
183
191
  const connection = await this.connect?.();
184
192
  const target = connection ? new URL(url.pathname + url.search, connection.baseUrl) : url;
185
193
  const headers = connection?.token ? { ...init.headers, Authorization: `Bearer ${connection.token}` } : init.headers;
186
- return await fetch(target, { ...init, headers, signal: AbortSignal.any(signals) });
194
+ const response = await fetch(target, { ...init, headers, signal: AbortSignal.any(signals) });
195
+ const actual = response.headers.get('X-OpenWrite-Project-Path');
196
+ if (response.ok && actual && this.context?.workspaceRoot) {
197
+ const expected = realpathSync(this.context.workspaceRoot);
198
+ const received = decodeURIComponent(actual);
199
+ if (!workspaceRootsEqual(received, expected)) {
200
+ await response.body?.cancel();
201
+ throw new StudioError('Connected workspace differs from requested project', 409, 'WORKSPACE_CONTEXT_MISMATCH', { requested_path: expected, project_path: received });
202
+ }
203
+ }
204
+ return response;
187
205
  }
188
206
  catch (error) {
207
+ if (error instanceof StudioError)
208
+ throw error;
189
209
  if (this.context?.workspaceRoot) {
190
210
  try {
191
211
  const root = join(this.context.workspaceRoot, '.openwrite', 'transport-errors');
@@ -1,5 +1,5 @@
1
1
  import { Service } from '@deepseek-ai/cordis';
2
- import { StudioClient, workspaceRootHeaders, mutationAffectsState } from './client.js';
2
+ import { StudioClient, workspaceRootHeaders, workspaceRootsEqual, mutationAffectsState } from './client.js';
3
3
  import { workspaceContextFromExec } from './tools.js';
4
4
  export const CONFIG_ROUTE = '/studio-panel/config.json';
5
5
  export const API_PROXY_ROUTE = '/studio-panel/api';
@@ -9,6 +9,8 @@ const PROXY_TIMEOUT_MS = 60_000;
9
9
  /** Cap for the best-effort upstream context_epoch read; never blocks a snapshot longer. */
10
10
  const CONTEXT_EPOCH_TIMEOUT_MS = 5_000;
11
11
  const WRITABLE_PATHS = new Set([
12
+ 'writing-assets',
13
+ 'arc-reconcile',
12
14
  'assets',
13
15
  'assets/update',
14
16
  'assets/package/import',
@@ -37,6 +39,7 @@ const WRITABLE_PATHS = new Set([
37
39
  'document/change-plan',
38
40
  'structured/change-plan',
39
41
  'manuscript-editing',
42
+ 'manuscript/drafts/restore',
40
43
  'manuscript/acceptance/baseline',
41
44
  'manuscript/acceptance/external',
42
45
  'manuscript/acceptance/reconcile',
@@ -84,7 +87,7 @@ function resourceForPath(path) {
84
87
  return 'tasks';
85
88
  if (path.startsWith('/api/project-archives'))
86
89
  return 'workspace';
87
- if (path.startsWith('/api/document') || path.startsWith('/api/write') || path.startsWith('/api/manuscript/acceptance'))
90
+ if (path.startsWith('/api/document') || path.startsWith('/api/write') || path.startsWith('/api/manuscript/'))
88
91
  return 'manuscript';
89
92
  if (path.startsWith('/api/outline'))
90
93
  return 'outline';
@@ -473,6 +476,12 @@ export class NovelDomainService extends Service {
473
476
  });
474
477
  if (lifetime.aborted)
475
478
  return;
479
+ const actualRoot = upstream.headers.get('X-OpenWrite-Project-Path');
480
+ if (upstream.ok && actualRoot && !workspaceRootsEqual(decodeURIComponent(actualRoot), resolved.root)) {
481
+ await upstream.body?.cancel();
482
+ sendJson(res, 409, { error: 'Connected workspace differs from requested project', code: 'WORKSPACE_CONTEXT_MISMATCH', details: { requested_path: resolved.root, project_path: decodeURIComponent(actualRoot) } });
483
+ return;
484
+ }
476
485
  const upstreamBytes = Buffer.from(await upstream.arrayBuffer());
477
486
  const bytes = compactTaskList(upstreamBytes, pathPart, method);
478
487
  const responseHeaders = {
@@ -481,6 +490,11 @@ export class NovelDomainService extends Service {
481
490
  'content-length': String(bytes.length),
482
491
  };
483
492
  const disposition = upstream.headers.get('content-disposition');
493
+ const instanceId = upstream.headers.get('X-OpenWrite-Instance-Id');
494
+ if (instanceId)
495
+ responseHeaders['X-OpenWrite-Instance-Id'] = instanceId;
496
+ if (actualRoot)
497
+ responseHeaders['X-OpenWrite-Project-Path'] = actualRoot;
484
498
  if (disposition !== null)
485
499
  responseHeaders['content-disposition'] = disposition;
486
500
  res.writeHead(upstream.status, responseHeaders);
@@ -6,6 +6,7 @@ const reads = new Set([
6
6
  'novel_revision_get', 'novel_tasks_list', 'novel_continuity', 'novel_diagnostics',
7
7
  'novel_research_status', 'novel_research_report', 'novel_model_profiles',
8
8
  'novel_manuscript_acceptance', 'novel_export_preflight',
9
+ 'novel_manuscript_metrics', 'novel_outline_audit',
9
10
  ]);
10
11
  const generates = new Set([
11
12
  'novel_write_chapter', 'novel_review_chapter', 'novel_multi_write', 'novel_task_create',
@@ -14,11 +15,12 @@ const generates = new Set([
14
15
  'novel_model_test', 'novel_model_embedding_test',
15
16
  ]);
16
17
  const readActions = {
18
+ novel_writing_assets: ['get'],
17
19
  novel_model_benchmark: ['list', 'get', 'options'],
18
20
  novel_chapter_run_action: ['list', 'get'],
19
21
  novel_rolling_plan_action: ['list', 'get'],
20
22
  novel_narrative_forecast_action: ['list', 'get'],
21
- novel_manuscript_edit_action: ['versions', 'version', 'compare', 'annotations'],
23
+ novel_manuscript_edit_action: ['drafts', 'versions', 'version', 'compare', 'annotations'],
22
24
  novel_source_action: ['status_v2', 'profile_v2'],
23
25
  novel_reference_library_action: ['list', 'status', 'profile'],
24
26
  novel_runtime_skill_action: ['list', 'diagnose', 'resolve'],
@@ -178,6 +178,11 @@ function resolveReviewPath(args) {
178
178
  function objectValue(value) {
179
179
  return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
180
180
  }
181
+ function requireRecord(value, name) {
182
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
183
+ throw new Error(`${name} must be a JSON object`);
184
+ return value;
185
+ }
181
186
  async function deliveryForResponse(client, response, chapterId, signal, threshold) {
182
187
  const embedded = objectValue(response)['workspace'];
183
188
  const workspace = embedded !== null && typeof embedded === 'object' && !Array.isArray(embedded)
@@ -979,7 +984,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
979
984
  'so the downloaded file is bound to the manuscript state that was inspected.',
980
985
  parameters: {
981
986
  format: { type: 'string', enum: ['md', 'txt', 'epub'], description: 'Export format (default "md").' },
982
- purpose: { type: 'string', enum: ['backup', 'delivery'], description: 'Export purpose (default "delivery"). Delivery applies publication gates; backup preserves the current work.' },
987
+ purpose: { type: 'string', enum: ['backup', 'delivery', 'personal_archive'], description: 'Export purpose (default "delivery"). personal_archive preserves author copies with gate findings recorded as warnings.' },
983
988
  },
984
989
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
985
990
  timeoutMs,
@@ -998,7 +1003,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
998
1003
  'preflight_revision unchanged to bind the download to that inspected manuscript state. Returns the saved file path.',
999
1004
  parameters: {
1000
1005
  format: { type: 'string', enum: ['md', 'txt', 'epub'], description: 'Export format (default "md").' },
1001
- purpose: { type: 'string', enum: ['backup', 'delivery'], description: 'Export purpose (default "delivery").' },
1006
+ purpose: { type: 'string', enum: ['backup', 'delivery', 'personal_archive'], description: 'Export purpose (default "delivery").' },
1002
1007
  preflight_revision: { type: 'string', description: 'Revision returned by novel_export_preflight for the same format and purpose.' },
1003
1008
  },
1004
1009
  output: {
@@ -2117,7 +2122,8 @@ export function registerNovelTools(ctx, clientFactory, options) {
2117
2122
  }));
2118
2123
  ctx.tools.register(defineTool({
2119
2124
  name: 'novel_manuscript_edit_action',
2120
- description: 'Chapter version snapshots and annotations. Actions: "versions" (chapter_id), "version" (chapter_id + ' +
2125
+ description: 'Chapter snapshots, annotations and saved drafts. Actions: "drafts" (list recoverable uncommitted prose), ' +
2126
+ '"restore_draft" (run_id_v2 + revision + confirm: true; copy the exact saved bytes without generation, then reconcile facts). Other actions: "versions" (chapter_id), "version" (chapter_id + ' +
2121
2127
  'version_id — returns the snapshot content), "checkpoint" (chapter_id, optional label — save a snapshot), ' +
2122
2128
  '"compare" (chapter_id + version_id — preview current-to-snapshot differences without writing), ' +
2123
2129
  '"restore" (chapter_id + version_id + revision — REVERTS the chapter to a snapshot; revision-gated and ' +
@@ -2125,8 +2131,9 @@ export function registerNovelTools(ctx, clientFactory, options) {
2125
2131
  'revision + quote + note, optional start_hint/end_hint and color amber|rose|sky|lime|violet), ' +
2126
2132
  '"resolve_annotation" (chapter_id + annotation_id).',
2127
2133
  parameters: {
2128
- action: { type: 'string', required: true, enum: ['versions', 'version', 'compare', 'checkpoint', 'restore', 'annotations', 'annotate', 'resolve_annotation'], description: 'The manuscript-editing operation.' },
2129
- chapter_id: { type: 'string', required: true, description: 'Chapter id (ch_<digits>).' },
2134
+ action: { type: 'string', required: true, enum: ['drafts', 'restore_draft', 'versions', 'version', 'compare', 'checkpoint', 'restore', 'annotations', 'annotate', 'resolve_annotation'], description: 'The manuscript-editing operation.' },
2135
+ chapter_id: { type: 'string', description: 'Chapter id (ch_<digits>), required except for drafts/restore_draft.' },
2136
+ run_id_v2: { type: 'string', description: 'Saved run id from drafts (restore_draft).' },
2130
2137
  version_id: { type: 'string', description: 'Snapshot id (version/restore).' },
2131
2138
  label: { type: 'string', description: 'Snapshot label (checkpoint).' },
2132
2139
  revision: { type: 'string', description: 'Current chapter revision (restore/annotate).' },
@@ -2140,10 +2147,19 @@ export function registerNovelTools(ctx, clientFactory, options) {
2140
2147
  },
2141
2148
  output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2142
2149
  timeoutMs,
2143
- isConcurrencySafe: (args) => args.action === 'versions' || args.action === 'version' || args.action === 'compare' || args.action === 'annotations',
2150
+ isConcurrencySafe: (args) => args.action === 'drafts' || args.action === 'versions' || args.action === 'version' || args.action === 'compare' || args.action === 'annotations',
2144
2151
  async execute(args, exec) {
2145
2152
  const client = clientFor(exec);
2146
- if (!CHAPTER_ID_PATTERN.test(args.chapter_id))
2153
+ if (args.action === 'drafts')
2154
+ return await client.getJson('/api/manuscript/drafts', {}, exec.signal);
2155
+ if (args.action === 'restore_draft') {
2156
+ if (!args.run_id_v2)
2157
+ throw new Error('run_id_v2 is required; list drafts first');
2158
+ return await client.postJson('/api/manuscript/drafts/restore', {
2159
+ run_id_v2: args.run_id_v2, expected_revision: args.revision ?? '', confirm: args.confirm === true,
2160
+ }, exec.signal);
2161
+ }
2162
+ if (!args.chapter_id || !CHAPTER_ID_PATTERN.test(args.chapter_id))
2147
2163
  throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
2148
2164
  const body = { action: args.action, chapter_id: args.chapter_id };
2149
2165
  for (const key of ['version_id', 'label', 'revision', 'confirm', 'quote', 'note', 'start_hint', 'end_hint', 'color', 'annotation_id']) {
@@ -2163,6 +2179,8 @@ export function registerNovelTools(ctx, clientFactory, options) {
2163
2179
  'acknowledge are confirmation-gated by the server.',
2164
2180
  parameters: {
2165
2181
  action: { type: 'string', required: true, enum: ['baseline', 'external', 'resume', 'acknowledge', 'abandon'], description: 'The reconciliation action.' },
2182
+ mode: { type: 'string', enum: ['full', 'fast'], description: 'resume: fast accepts frozen manuscript without calling a model; derived facts remain stale and can be reconciled later.' },
2183
+ no_model: { type: 'boolean', description: 'external: accept manuscript bytes without extracting facts; does not clear the facts guard.' },
2166
2184
  chapter_id: { type: 'string', description: 'Optional chapter id (ch_<digits>); omit for the project-level operation.' },
2167
2185
  operation_id: { type: 'string', description: 'Durable operation id to resume or acknowledge.' },
2168
2186
  domains: { type: 'array', items: { type: 'string' }, description: 'Affected domains to acknowledge or retry; acknowledge defaults to outline and foreshadowing.' },
@@ -2175,7 +2193,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
2175
2193
  throw new Error(`chapter_id must match ch_<digits>, got "${args.chapter_id}"`);
2176
2194
  }
2177
2195
  const body = {};
2178
- for (const key of ['chapter_id', 'operation_id', 'domains', 'confirm']) {
2196
+ for (const key of ['chapter_id', 'operation_id', 'domains', 'confirm', 'mode', 'no_model']) {
2179
2197
  const value = args[key];
2180
2198
  if (value !== undefined)
2181
2199
  body[key] = value;
@@ -2186,6 +2204,89 @@ export function registerNovelTools(ctx, clientFactory, options) {
2186
2204
  return await clientFor(exec).postJson(`/api/manuscript/acceptance/${route}`, body, exec.signal);
2187
2205
  },
2188
2206
  }));
2207
+ ctx.tools.register(defineTool({
2208
+ name: 'novel_writing_assets',
2209
+ description: 'Read or save shared project terminology, review_terms toggle, concrete noun lexicon, overlap_threshold and character_groups. Anchors are Markdown files in the novel anchor/ directory. Save requires the revision returned by get. Character groups are optional descriptive material; retain identity and canonical state in character cards.',
2210
+ parameters: {
2211
+ action: { type: 'string', required: true, enum: ['get', 'save', 'save_anchor', 'mark_usage'], description: 'Asset operation.' },
2212
+ assets: { type: 'json', description: 'Object: terms [{term, forbidden?, replacement?, min_occurrences?, example?}], character_groups {name: [{id,text}]}, review_terms?, concrete_nouns?, overlap_threshold?.' },
2213
+ expected_revision: { type: 'string', description: 'Revision from get; empty for a new asset table.' },
2214
+ name: { type: 'string', description: 'save_anchor: simple .md filename.' },
2215
+ content: { type: 'string', description: 'save_anchor: Markdown reference prose.' },
2216
+ chapter_id: { type: 'string', description: 'mark_usage: chapter whose optional character details were used.' },
2217
+ groups: { type: 'json', description: 'mark_usage: character name to group IDs used in this chapter.' },
2218
+ },
2219
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2220
+ timeoutMs,
2221
+ isConcurrencySafe: args => args.action === 'get',
2222
+ async execute(args, exec) {
2223
+ const client = clientFor(exec);
2224
+ if (args.action === 'get')
2225
+ return await client.getJson('/api/writing-assets', {}, exec.signal);
2226
+ if (args.action === 'save_anchor')
2227
+ return await client.postJson('/api/writing-assets', { action: args.action, name: args.name ?? '', content: args.content ?? '', expected_revision: args.expected_revision ?? '' }, exec.signal);
2228
+ if (args.action === 'mark_usage')
2229
+ return await client.postJson('/api/writing-assets', { action: args.action, chapter_id: args.chapter_id ?? '', groups: requireRecord(args.groups, 'groups') }, exec.signal);
2230
+ const assets = requireRecord(args.assets, 'assets');
2231
+ return await client.postJson('/api/writing-assets', { assets: assets, expected_revision: args.expected_revision ?? '' }, exec.signal);
2232
+ },
2233
+ }));
2234
+ ctx.tools.register(defineTool({
2235
+ name: 'novel_manuscript_metrics',
2236
+ description: 'Read deterministic chapter metrics, terminology findings, overlap against anchors and the previous three chapters, duplicate paragraphs and selected character groups. Overlap spans use Unicode codepoint offsets. No model call.',
2237
+ parameters: { chapter_id: { type: 'string', required: true, description: 'Canonical chapter ID.' } },
2238
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2239
+ timeoutMs, isConcurrencySafe: () => true,
2240
+ async execute(args, exec) {
2241
+ if (!CHAPTER_ID_PATTERN.test(args.chapter_id))
2242
+ throw new Error('Invalid chapter_id');
2243
+ return await clientFor(exec).getJson(`/api/manuscript/${encodeURIComponent(args.chapter_id)}/metrics`, {}, exec.signal);
2244
+ },
2245
+ }));
2246
+ ctx.tools.register(defineTool({
2247
+ name: 'novel_outline_audit',
2248
+ description: 'Read countable chapter-group facts: budgets/actual words, declared promises and function streaks, declared/confirmed payoffs, overdue hooks and drift ledger. Never generates a score or claims prose fulfilled a promise. No model call.',
2249
+ parameters: {
2250
+ group_id: { type: 'string', description: 'Optional sec_* or arc_* group.' },
2251
+ start: { type: 'string', description: 'Optional first chapter ID.' },
2252
+ end: { type: 'string', description: 'Optional last chapter ID.' },
2253
+ },
2254
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2255
+ timeoutMs, isConcurrencySafe: () => true,
2256
+ async execute(args, exec) {
2257
+ return await clientFor(exec).getJson('/api/outline/audit', { group_id: args.group_id ?? '', start: args.start ?? '', end: args.end ?? '' }, exec.signal);
2258
+ },
2259
+ }));
2260
+ ctx.tools.register(defineTool({
2261
+ name: 'novel_drift_entry',
2262
+ description: 'Record author-observed chapter drift. Saves an immutable outline/manuscript baseline first. More than two unresolved rewrite drifts in a section freezes generation until explicit reconciliation and replanning.',
2263
+ parameters: {
2264
+ group_id: { type: 'string', required: true, description: 'Section ID sec_*.' },
2265
+ chapter_id: { type: 'string', required: true, description: 'Chapter in this section.' },
2266
+ kind: { type: 'string', required: true, enum: ['growth', 'escape', 'dilution', 'snowball'], description: 'Drift kind.' },
2267
+ impact: { type: 'string', required: true, enum: ['local', 'section', 'arc'], description: 'Impact scope.' },
2268
+ rewrite: { type: 'boolean', description: 'Counts toward rewrite budget; default true.' },
2269
+ note: { type: 'string', required: true, description: 'Observed drift and supporting evidence.' },
2270
+ expected_revision: { type: 'integer', required: true, description: 'ledger.revision from outline audit.' },
2271
+ },
2272
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2273
+ timeoutMs,
2274
+ async execute(args, exec) {
2275
+ return await clientFor(exec).postJson('/api/arc-reconcile', { ...args, action: 'drift_entry' }, exec.signal);
2276
+ },
2277
+ }));
2278
+ ctx.tools.register(defineTool({
2279
+ name: 'novel_arc_reconcile',
2280
+ description: 'Resolve selected drift entries by absorbing authored outline metadata, scheduling recovery in a later chapter, or freezing. Absorption requires concrete outline_updates and outline_revision; baseline is saved before edits. resume=true explicitly resumes a replanned group within budget. Does not rewrite manuscript.',
2281
+ parameters: {
2282
+ data: { type: 'json', required: true, description: 'Object: group_id, expected_revision, entry_ids[], decision absorb/recover/freeze, confirm:true. absorb: outline_revision, outline_updates[{node_id,metadata}], resume?. recover: plan, target_chapter_id.' },
2283
+ },
2284
+ output: { schema: JSON_OUTPUT_SCHEMA, render: (_args, value) => renderJson(value) },
2285
+ timeoutMs,
2286
+ async execute(args, exec) {
2287
+ return await clientFor(exec).postJson('/api/arc-reconcile', { ...requireRecord(args.data, 'data'), action: 'arc_reconcile' }, exec.signal);
2288
+ },
2289
+ }));
2189
2290
  // ── style sources, reference library, runtime skills, rules ──────────────
2190
2291
  ctx.tools.register(defineTool({
2191
2292
  name: 'novel_source_action',
@@ -2382,13 +2483,14 @@ export function registerNovelTools(ctx, clientFactory, options) {
2382
2483
  run_id: { type: 'string', description: 'Benchmark run id for get.' },
2383
2484
  task_type: { type: 'string', enum: ['chapter', 'outline'], description: 'Run target: chapter (default) or outline. Outline supports framework mode only.' },
2384
2485
  chapter_id: { type: 'string', description: 'Chapter task target: next (default) or an available canonical chapter_id from options, e.g. ch_003. Unavailable targets are rejected without fallback; inspect availability/reason first.' },
2486
+ allow_historical: { type: 'boolean', description: 'Explicit isolated rewrite of an already written chapter using current canon. This is not a reconstructed pre-chapter baseline. Source manuscript and ledgers remain unchanged.' },
2385
2487
  outline_start_chapter: { type: 'integer', description: 'Outline task first chapter number, inclusive, 1-100000. Omit to continue after the last outline chapter.' },
2386
2488
  outline_chapter_count: { type: 'integer', description: 'Outline task range length including its starting chapter, 1-20 (default 3).' },
2387
2489
  writer_profile_ids: { type: 'array', items: { type: 'string' }, description: 'Writing profiles to compare (run).' },
2388
2490
  reviewer_profile_ids: { type: 'array', items: { type: 'string' }, description: 'Independent blind-review profiles (run).' },
2389
2491
  execution_mode: { type: 'string', enum: ['framework', 'creative'], description: 'Execution path: full framework (default); creative diagnostic is available only for chapter tasks. Read task pipelines with options.' },
2390
2492
  repeats: { type: 'integer', description: 'Repeats per writing profile, 1-5.' },
2391
- target_words: { type: 'integer', description: 'Chapter candidate target length, 200-12000; use outline_chapter_count for outline range.' },
2493
+ target_words: { type: 'integer', description: 'Chapter candidate target length, 200-50000; use outline_chapter_count for outline range.' },
2392
2494
  concurrency: { type: 'integer', description: 'Concurrent model jobs, 1-4.' },
2393
2495
  limit: { type: 'integer', description: 'List limit, default 20.' },
2394
2496
  },
@@ -2418,7 +2520,7 @@ export function registerNovelTools(ctx, clientFactory, options) {
2418
2520
  writer_profile_ids: args.writer_profile_ids,
2419
2521
  reviewer_profile_ids: args.reviewer_profile_ids,
2420
2522
  };
2421
- for (const key of ['task_type', 'chapter_id', 'outline_start_chapter', 'outline_chapter_count', 'execution_mode', 'repeats', 'target_words', 'concurrency']) {
2523
+ for (const key of ['task_type', 'chapter_id', 'allow_historical', 'outline_start_chapter', 'outline_chapter_count', 'execution_mode', 'repeats', 'target_words', 'concurrency']) {
2422
2524
  const value = args[key];
2423
2525
  if (value !== undefined)
2424
2526
  body[key] = value;