dsh-novel-write-engine 0.2.7 → 0.2.8

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/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.8
4
+
5
+ 0.2.8 是边界与可信性版本,不改工具、阶段、门控、schema、导出或配置键。
6
+
7
+ ### Fixed
8
+
9
+ - **章节边界读模型**:归档第 N 章后 `pipeline.chapter` 指向 N+1、`stage` 仍为 `done`,旧读面把它拼成“当前第 N+1 章,阶段 done”。新增 `ChapterProgress` 单一投影,`/novel status`、`/novel resume`、`novel_status`、`novel_pipeline`、`novel_guide`、`novel_doctor`、面板摘要与复制续写指令统一显示“第 N 章已归档;下一章第 N+1 章从 P1 开始”。全书达到目标后 `finished=true`,引擎续写文本返回 `null`;`/novel resume` 改为显示“全书已完成”,不再把它当成故障态。
10
+ - **备份路径安全**:`backups/` 改用 `FsStore.ensureDir` + `safePath` 创建和解析,符号链接目录返回 `ENGINE_PATH_ESCAPE`,项目外零写入;备份 staging 写入前重复检查项目根,避免删除根后被 `mkdir` 重建;备份列表/retention 跳过 `backups/` 下的符号链接子项,不再因一个用户文件整体失败。
11
+ - **叙述者别名**:新增 `src/domain/narrator.ts`,PG-05、`state_delta` 的存在性与 locks 取证共用 `isNarratorAlias`;`narrator: "林砚(第一人称)"` 不再因为正文只写“我”而卡在 PG-05 或归档;只接受第一人称“我”,排除第三人称与无关括号引号,locks 优先非对白段落。
12
+ - **面板健康缓存**:`panelCacheKey` 纳入 journal 行数、末尾行与 `journalBlocking` 摘要,外部 ledger 改动或 journal 追加后不再返回旧 `health.ok`/旧 activity。
13
+ - **归档卡字段**:归档卡的 `goal/conflict/hook/notes` 与 `title/scene_type/target_words/cast` 一样不可改写;省略字段仍保留原值。`novel_amend` 在 `chapters.yaml` 不可读时也以旧大纲卡为证据,不能借损坏窗口改写。
14
+ - **created growth**:`state_delta` 的 `created` 角色不再丢弃已声明的 `growth`,按当前章写入 ledger。
15
+
16
+ ### Changed
17
+
18
+ - `stats()` 与 `/novel stats` 的 `totalWords` 统一为“归档 + 活动草稿”,并新增 `archivedWords`/`draftWords` 拆分;面板同步新增 `archivedWords`/`draftWords`,头部总字数与命令口径一致。
19
+ - 面板九阶段名称改为与 README/DESIGN 一致:P7 状态同步、P8 终审、P9 归档;教程注明首次章卡提交默认 strict、纯文本 `novel_word_count`/`novel_diff` 不绑定会话。
20
+
21
+ ### Tests
22
+
23
+ - 91 个测试文件 / 923 项测试全绿;覆盖率 93.55 / 89.23 / 98.14 / 93.55,逐文件门禁通过。新增归档边界、完结续写与命令文案、P8 brief 回归、面板缓存、字数拆分、备份符号链接、第一人称归档、created growth、归档字段保护、损坏 chapters 下的 amend 保护、空 legacy 卡重交、locks 对白取证、提交叙述者优先级回归。
24
+
25
+ ### Migration
26
+
27
+ - `progress`、`archivedWords`、`draftWords` 均为 additive 字段;`REMOTE_VERSION` 保持 6,旧标签页刷新即可。
28
+ - 无 schema、工具、阶段、门控、导出或配置键变更;项目文件不需要迁移。
29
+
3
30
  ## 0.2.7
4
31
 
5
32
  0.2.7 是通用性与发布边界版本,不改工具、阶段、门控、schema 或规则阈值。
package/README.md CHANGED
@@ -19,9 +19,9 @@
19
19
  </div>
20
20
 
21
21
  > [!IMPORTANT]
22
- > 当前版本 **0.2.7**。宿主范围:DSH `>=0.2.1-rc.2 <0.3.0-0`;Node `>=24.9.0`。
22
+ > 当前版本 **0.2.8**。宿主范围:DSH `>=0.2.1-rc.2 <0.3.0-0`;Node `>=24.9.0`。
23
23
  >
24
- > 0.2.7 是通用性与发布边界版本:没有改工具、阶段、门控、schema 或规则阈值。它把产品示例、任务书和测试夹具里的具体书稿痕迹清理干净,并补上三题材归档矩阵与发布面隐私守卫。历史版本的内部方案与验收稿留在对应发布 tag,不再复制进 `main`。
24
+ > 0.2.8 是边界与可信性版本:修正归档后“下一章被误标为 done”的读模型、备份路径符号链接逃逸、PG-05 与归档叙述者别名不一致、面板健康缓存假绿、完结书仍生成续写指令;同时保护归档章卡字段并修复 created growth 丢失。没有改工具、阶段、门控、schema 或配置键;改变的是人类读法、路径安全和少量状态契约,不迁移项目数据。历史版本的内部方案与验收稿留在对应发布 tag,不再复制进 `main`。
25
25
 
26
26
  ---
27
27
 
@@ -119,6 +119,8 @@ flowchart TB
119
119
 
120
120
  P7、P8、P9 在 `novel_archive` 一次事务里完成。单章归档后状态是 `done`;下一章仍从 P1 开始。只有项目配置的 `total_chapters` 全部归档,面板才显示“全书完成”。
121
121
 
122
+ `pipeline.chapter` + `pipeline.stage` 是协议对,不是人类读法:归档第 N 章后,管线指向 N+1,`stage` 仍是 `done`,因为 `done` 描述的是**刚刚归档的第 N 章**。所有读面统一使用 `progress` 投影:`/novel status`、`novel_status`、`novel_pipeline`、`novel_guide`、`novel_doctor`、面板和“复制续写指令”都会显示“第 N 章已归档;下一章第 N+1 章从 P1 开始”。全书完成时 `progress.finished=true`,引擎续写文本为 `null`,`/novel resume` 显示“全书已完成”;要继续写,先用 `novel_amend` 提高 `targets.total_chapters`。
123
+
122
124
  ### 十三道门控
123
125
 
124
126
  门控按固定顺序求值,并且**收集全部失败项**,不是遇到第一个就返回。这样模型可以一次改完,而不是来回撞墙。
@@ -201,7 +203,7 @@ pnpm --version # CLI 安装插件时需要
201
203
  corepack enable
202
204
  corepack prepare pnpm@11.28.9 --activate
203
205
 
204
- dsh plugin --profile web add dsh-novel-write-engine@0.2.7
206
+ dsh plugin --profile web add dsh-novel-write-engine@0.2.8
205
207
  ```
206
208
 
207
209
  安装后重启 DSH,或刷新 Web 页面。
@@ -249,10 +251,10 @@ corepack pnpm build
249
251
  corepack pnpm pack
250
252
  ```
251
253
 
252
- 得到 `dsh-novel-write-engine-0.2.7.tgz`,复制到目标机器后:
254
+ 得到 `dsh-novel-write-engine-0.2.8.tgz`,复制到目标机器后:
253
255
 
254
256
  ```bash
255
- dsh plugin --profile web add /absolute/path/to/dsh-novel-write-engine-0.2.7.tgz
257
+ dsh plugin --profile web add /absolute/path/to/dsh-novel-write-engine-0.2.8.tgz
256
258
  ```
257
259
 
258
260
  tarball 已包含 `lib/`、`rulepacks/`、`briefs/`、`skills/`、`presets/` 和两层 patch,不需要在目标机器重新构建。
@@ -264,7 +266,7 @@ tarball 已包含 `lib/`、`rulepacks/`、`briefs/`、`skills/`、`presets/` 和
264
266
 
265
267
  1. 左侧栏进入 **插件**;
266
268
  2. 点 **添加插件**;
267
- 3. 填 npm 包名 `dsh-novel-write-engine`(或精确版本 `dsh-novel-write-engine@0.2.7`);
269
+ 3. 填 npm 包名 `dsh-novel-write-engine`(或精确版本 `dsh-novel-write-engine@0.2.8`);
268
270
  4. 安装后启用,刷新页面,新开会话。
269
271
 
270
272
  插件页安装的是当前 profile 的依赖;Desktop 的插件页管理的是 `desktop` profile。
@@ -332,9 +334,9 @@ tarball 已包含 `lib/`、`rulepacks/`、`briefs/`、`skills/`、`presets/` 和
332
334
 
333
335
  归档后打开左侧 **网文工坊**:
334
336
 
335
- - 紧凑顶栏:书名、类型、平台、当前章、阶段;
336
- - 首屏“当前章 / 下一步”卡与“复制续写指令”;
337
- - 统计条:已归档章节、总字数、平均分、待处理门控;
337
+ - 紧凑顶栏:书名、类型、平台、当前章边界(例如“第 1 章已归档 · 下一章第 2 章待开始”);
338
+ - 首屏“当前章 / 下一步”卡与“复制续写指令”;归档后显示下一章 P1,全书完成时隐藏复制按钮;
339
+ - 统计条:已归档章节、总字数(归档 + 活动草稿)、平均分、待处理门控;
338
340
  - 双栏工作台:阶段轨、门控、提示、旧数据、全书规划、质量趋势、健康度、最近活动;
339
341
  - 底部章节卡。
340
342
 
@@ -439,9 +441,9 @@ novel_write_draft, novel_review, novel_revise, novel_check, novel_archive
439
441
  | `novel_outline` | 按 `num` upsert 章卡、按 `title` upsert 卷;省略不删,显式 `remove`;`dry_run` 预览 |
440
442
  | `novel_outline_import` | 从 Markdown / YAML / JSON 导入大纲;`dry_run` 先预览 |
441
443
  | `novel_worldbook` | 增量更新世界观设定集(设定/规则/势力/地点/角色/术语/前史) |
442
- | `novel_status` | 当前章、阶段、轮次、阻塞项、`state_errors`、`manuscript_hash`、下一步 |
444
+ | `novel_status` | `progress` 章节边界、轮次、阻塞项、`state_errors`、`manuscript_hash`、下一步 |
443
445
  | `novel_guide` | 上手引导:当前状态、已完成步骤、下一步工具;无项目也能用 |
444
- | `novel_pipeline` | “下一步该做什么”的建议 |
446
+ | `novel_pipeline` | “下一步该做什么”的建议,带同一份 `progress` |
445
447
 
446
448
  ### 单章流水线(8)
447
449
 
@@ -620,18 +622,18 @@ corepack pnpm test
620
622
 
621
623
  ## 升级与卸载
622
624
 
623
- ### 升级到 0.2.7
625
+ ### 升级到 0.2.8
624
626
 
625
- 0.2.7 没有 schema、工具、阶段、门控、导出或配置键变更,不需要迁移项目文件。
627
+ 0.2.8 没有 schema、工具、阶段、门控、导出或配置键变更,不需要迁移项目文件。
626
628
 
627
629
  ```bash
628
630
  dsh plugin --profile web remove dsh-novel-write-engine
629
- dsh plugin --profile web add dsh-novel-write-engine@0.2.7
631
+ dsh plugin --profile web add dsh-novel-write-engine@0.2.8
630
632
  ```
631
633
 
632
- 升级后刷新页面、新开会话。面板教程版本从 0.2.6 升到 0.2.7;旧标签页显示“需要刷新”时刷新即可。
634
+ 升级后刷新页面、新开会话。面板教程版本从 0.2.7 升到 0.2.8;旧标签页显示“需要刷新”时刷新即可。
633
635
 
634
- 历史版本的升级说明见 [CHANGELOG.md](CHANGELOG.md)。0.2.7 不再在 `main` 保留旧版方案与验收稿;需要溯源时使用对应发布 tag 或 Release 快照。
636
+ 历史版本的升级说明见 [CHANGELOG.md](CHANGELOG.md)。0.2.8 不再在 `main` 保留旧版方案与验收稿;需要溯源时使用对应发布 tag 或 Release 快照。
635
637
 
636
638
  ### 从源码目录升级
637
639
 
@@ -786,14 +788,14 @@ presets/ # 内置 Agent 预设 patch
786
788
  tests/ # 单测、集成、falsify、e2e
787
789
  ```
788
790
 
789
- ### 本次 0.2.7 验证
791
+ ### 本次 0.2.8 验证
790
792
 
791
793
  以本次发布实际跑出的结果为准:
792
794
 
793
- - `corepack pnpm test`:**91 个测试文件 / 910 项全部通过**;
794
- - 覆盖率:**语句 93.51% / 分支 89.13% / 函数 98.12% / 行 93.51%**;逐文件门禁通过;
795
+ - `corepack pnpm test`:**91 个测试文件 / 923 项全部通过**;
796
+ - 覆盖率:**语句 93.55% / 分支 89.23% / 函数 98.14% / 行 93.55%**;逐文件门禁通过;
795
797
  - `typecheck`、`lint`、`build`:通过;
796
- - `check:hygiene`(156 files,0 v8 ignore)、`test:isolated`(62 源文件)、`validate:rulepack`、`check:pack`(26 files)、`check:artifacts`、`check:install`(26 packages)、`check:preset`(13 packages)、`check:host`、`check:crash`:通过;
798
+ - `check:hygiene`(157 files,0 v8 ignore)、`test:isolated`(63 源文件)、`validate:rulepack`、`check:pack`(26 files)、`check:artifacts`、`check:install`(26 packages)、`check:preset`(13 packages)、`check:host`、`check:crash`:通过;
797
799
  - 三题材矩阵:玄幻修仙、都市现代、西方奇幻各跑一遍 `init → archive`;
798
800
  - 隐私守卫:`src / briefs / skills / presets / rulepacks / docs` 与根文档均无具体项目身份。
799
801
 
package/lib/client.js CHANGED
@@ -50,9 +50,9 @@ window.__ModuleLoader__.load({
50
50
  P4: "评审",
51
51
  P5: "修订",
52
52
  P6: "一致性",
53
- P7: "终审",
54
- P8: "归档",
55
- P9: "完成"
53
+ P7: "状态同步",
54
+ P8: "终审",
55
+ P9: "归档"
56
56
  };
57
57
  const STAGE_LABELS = {
58
58
  idle: "待开始",
@@ -332,8 +332,10 @@ window.__ModuleLoader__.load({
332
332
  const rowAverage = scored.length === 0 ? 0 : scored.reduce((sum, row) => sum + row.scoreValue, 0) / scored.length;
333
333
  const average = summary.averageScore !== void 0 && Number.isFinite(summary.averageScore) ? summary.averageScore : rowAverage;
334
334
  const next = summary.next;
335
- const finished = summary.finished === true;
335
+ const progress = summary.progress;
336
+ const finished = progress?.finished ?? summary.finished === true;
336
337
  const doneLabel = summary.stage === "done" ? finished ? "全书完成" : "本章已归档" : stageLabel(summary.stage);
338
+ const progressLine = progress?.label ?? (summary.stage === "done" ? finished ? `共 ${summary.totalChapters} 章` : `下一章 第 ${summary.chapter} 章` : `第 ${summary.chapter} 章`);
337
339
  const selected = base.projects.find((project) => project.selected);
338
340
  return {
339
341
  ...base,
@@ -343,7 +345,7 @@ window.__ModuleLoader__.load({
343
345
  damaged: selected?.state === "damaged",
344
346
  resumeText: payload.resume?.text ?? "",
345
347
  volumeNumber: summary.volume,
346
- subtitle: summary.stage === "done" ? `${summary.genre} · ${platformLabel(summary.platform)} · ${finished ? `共 ${summary.totalChapters} 章` : `下一章 第 ${summary.chapter} 章`}${volumeLabel(summary)}` : `${summary.genre} · ${platformLabel(summary.platform)} · 第 ${summary.chapter} 章${volumeLabel(summary)}`,
348
+ subtitle: `${summary.genre} · ${platformLabel(summary.platform)} · ${progressLine}${volumeLabel(summary)}`,
347
349
  stageLabel: doneLabel,
348
350
  finished,
349
351
  revisionLabel: `第 ${summary.bookData?.current.rounds.revise ?? summary.revision} 次修订`,
@@ -357,6 +359,8 @@ window.__ModuleLoader__.load({
357
359
  stats: {
358
360
  chapters: summary.totalChapters,
359
361
  words: summary.totalWords,
362
+ ...summary.archivedWords === void 0 ? {} : { archivedWords: summary.archivedWords },
363
+ ...summary.draftWords === void 0 ? {} : { draftWords: summary.draftWords },
360
364
  averageScore: Math.round(average * 10) / 10,
361
365
  blocking: blocking.length
362
366
  },
@@ -1146,7 +1150,7 @@ window.__ModuleLoader__.load({
1146
1150
  title: "写书级蓝图与卷结构",
1147
1151
  tool: "novel_blueprint",
1148
1152
  body: "让模型先定 premise(故事前提)、logline(一句话卖点)、themes(主题)、tone(基调)、pov(视角)、audience(读者)、selling_points(卖点)、ending(结局方向)和 volumes(每卷起止章、目标、弧线、高潮)。",
1149
- note: "蓝图不是正文,可以后补。严格大纲是可选开关:strict:true 时每章必须先有章卡;不开启也能先写,但章卡一旦存在,标题、场景、目标字数和 cast 仍会校验。"
1153
+ note: "蓝图不是正文,可以后补。首次提交章卡时 strict 默认开启;要宽松写作可在蓝图里显式 strict:false 关闭。strict 开启后每章必须先有章卡;章卡一旦存在,标题、场景、目标字数和 cast 仍会校验。"
1150
1154
  },
1151
1155
  {
1152
1156
  title: "排章卡,或导入现成大纲",
@@ -1219,7 +1223,7 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1219
1223
  每次提交前先领取当前阶段任务书,失败时按返回的 code 和 blocking 一次改完。`;
1220
1224
  const TUTORIAL_NEXT_PROMPT = `继续写当前项目的下一章。先调用 novel_pipeline(或 novel_status)确认当前阶段、项目根目录和下一步工具,不要猜阶段。
1221
1225
  如果当前会话还没绑定或不确定是哪本书:先用 /novel current 看会话绑定,再调用 novel_status 看 selection;需要切换时用 /novel use <路径|书名|.>,不要靠 active 猜。
1222
- 第一次调用带 project_root 时,它会成为当前会话的持久绑定;后续无 root 调用会继续这本书。
1226
+ 第一次带 project_root 的项目调用会成为当前会话的持久绑定;后续无 root 调用会继续这本书。纯文本的 novel_word_count 和 novel_diff 不读取项目,也不会建立绑定。
1223
1227
 
1224
1228
  如果下一章还没有章卡:用 novel_outline 只提交下一章的卡(按 num upsert),省略的已有卡会保持不动;不要重交全量,也不要用空数组清空。要删卡必须显式传 remove。
1225
1229
  如果 targets.total_chapters 已经写满:先用 novel_amend 把 total_chapters 调到至少包含下一章,不能低于已归档章数。
@@ -1515,7 +1519,7 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1515
1519
  ref: closeRef,
1516
1520
  onClick: onClose,
1517
1521
  "aria-label": "关闭使用教程"
1518
- }, (0, react.createElement)("span", { "aria-hidden": "true" }, "×"))), (0, react.createElement)("div", { className: "nwe-modal-body" }, (0, react.createElement)("div", { className: "nwe-tutorial-intro" }, (0, react.createElement)("p", null, "网文工坊不是一个“帮你想剧情”的按钮,而是一台有状态的写作引擎:你提供内容,引擎负责阶段、字数、门控、评审、归档和文件。"), (0, react.createElement)("p", null, "每一步都先领取当前阶段的任务书,再提交;提交后看引擎返回的 blocking 与下一步。面板只读,真正的写操作都在对话里的工具调用中。")), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "开始前:记住三件事"), (0, react.createElement)("div", { className: "nwe-tutorial-cards" }, (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "只用引擎入口写"), (0, react.createElement)("span", null, "正文只走 novel_write_draft / novel_revise;状态只走工具。手改 chapters/、state/、outline/ 会破坏事务与校验。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "每一步先领 brief"), (0, react.createElement)("span", null, "任务书携带当前上下文和 digest;跳过它会撞 PG-01。任务书过期就重新领取,不要复用旧上下文。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "失败先读 code"), (0, react.createElement)("span", null, "参数错改参数,门控没过改正文,系统故障查目录/稍后重试。blocking 会一次列全,一次改完再交。"))), (0, react.createElement)("p", { className: "nwe-tutorial-note" }, (0, react.createElement)("strong", null, "用哪个会话:"), " 想获得完整的引擎保证,请新开一个「网文工坊」预设会话;普通会话里模型可能还挂着文件工具,能绕过引擎直接改稿。")), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "一本书的完整流程"), (0, react.createElement)("p", null, "前四步是开书准备,后面是每章循环。第一次先按顺序走完一章,熟悉后再批量推进整本。"), (0, react.createElement)("ol", { className: "nwe-steps" }, ...TUTORIAL_STEPS.map((step, index) => tutorialStep(step, index)))), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "面板怎么看"), (0, react.createElement)("div", { className: "nwe-tutorial-cards" }, (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "顶部统计"), (0, react.createElement)("span", null, "已归档章、总字数、平均分、待处理门控。数字来自引擎,不是正文估算。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "九阶段轨与下一步"), (0, react.createElement)("span", null, "亮起的阶段是当前进度,“下一步”卡会直接给出该调用的工具名。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "章节与门控"), (0, react.createElement)("span", null, "章节卡展开可看字数、评分与状态;门控区按 code/detail/hint 列出所有阻塞。"))), (0, react.createElement)("p", { className: "nwe-tutorial-note" }, (0, react.createElement)("strong", null, "刷新:"), " 面板每 15 秒静默同步一次,也可以点右上角刷新。它是只读投影;不会替你改稿、归档或修状态。"), (0, react.createElement)("p", { className: "nwe-tutorial-note" }, (0, react.createElement)("strong", null, "项目状态:"), "选择器里 choose 表示发现多本书、还没选;项目不在了表示目录缺失,绑定仍会保留;不是项目表示目录还在但没有 novel.yaml 或项目状态;项目不可读表示权限或 IO 错误,先修权限或换一本。面板选择只影响查看,不改变任何会话的写作绑定。")), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "卡住时先看这里"), (0, react.createElement)("ul", { className: "nwe-faq" }, (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "为什么一直让我先领 brief?"), (0, react.createElement)("p", null, "这是正常门控。任务书决定当前阶段可用的字段与上下文 digest;先按返回值调用 novel_brief,再继续。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "门控失败,改一条还是全改?"), (0, react.createElement)("p", null, "一次看完所有 blocking,再一起改。逐条试会反复烧掉阶段轮次;错误里的 code 会告诉你属于参数、门控还是系统故障。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "字数和评分为什么对不上?"), (0, react.createElement)("p", null, "字数以 engine 返回为准,可用 novel_word_count 核对;review 的 total 必须等于 150 减全部扣分,不能另算一套。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "P4/P6 过不去怎么办?"), (0, react.createElement)("p", null, "P4 有 blocker 就领 P5 任务书修订,再回 P4 重评;P6 失败同样回修订。不要 waive blocker,也不要只跑半个 scope 就归档。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "想改已经归档的章节?"), (0, react.createElement)("p", null, "先 novel_reopen,带非空 reason。它会从归档备份恢复,重开后重新走 P3→P8;不要直接编辑归档卡或正文文件。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "状态文档坏了能手工修吗?"), (0, react.createElement)("p", null, "先用 novel_state 看 raw 和 next;需要修复时用 novel_amend,并在 reason 写清原因。手改 YAML 会丢失事务保护。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "有多个项目,面板看的是哪本?"), (0, react.createElement)("p", null, "面板用顶部项目选择器切换查看对象;选择只改 ?root= 和浏览器 localStorage,不调用 activate、不写会话绑定。写作项目请用 /novel use 或显式 project_root。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "选错书、想清掉绑定怎么办?"), (0, react.createElement)("p", null, "先用 /novel current 看当前会话绑定;要换书用 /novel use <路径|书名|.>,要清掉只影响当前会话的绑定用 /novel unbind。不要靠 active 猜,也不要手改 state/session-bindings.yaml。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "扫描到了,为什么还没切过去?"), (0, react.createElement)("p", null, "scan 只读发现;scan --register 只登记项目,不选择、不改 active、不绑会话。真正切换写作项目要用 /novel use,或第一次调用显式传 project_root。")))), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "可以直接复制给模型的起手话术"), (0, react.createElement)("pre", { className: "nwe-prompt" }, TUTORIAL_PROMPT)), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "续写下一章时,直接复制这段给 Agent"), (0, react.createElement)("p", null, "这段会先确认状态,再处理缺章卡和目标章数已满,最后按 P1→P8 推进;归档前会要求你确认 state_delta。"), (0, react.createElement)("pre", { className: "nwe-prompt" }, TUTORIAL_NEXT_PROMPT)), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "一章完成检查清单"), (0, react.createElement)("ul", { className: "nwe-checklist" }, (0, react.createElement)("li", null, "本章 P1/P2 计划已提交,P3 正文已通过格式与字数门控。"), (0, react.createElement)("li", null, "P4 评审 total 对账无误,没有未修 blocker。"), (0, react.createElement)("li", null, "P6 五个语义项都有不同句子的正文引用,完整 scope 通过。"), (0, react.createElement)("li", null, "P8 任务书当前,novel_archive 带完整 state_delta 成功。"), (0, react.createElement)("li", null, "面板出现已归档章节,门控区没有新的阻塞。")))), (0, react.createElement)("div", { className: "nwe-modal-foot" }, "教程基于 0.2.7 · 具体字段以当前任务书和工具返回为准 · 面板只读")));
1522
+ }, (0, react.createElement)("span", { "aria-hidden": "true" }, "×"))), (0, react.createElement)("div", { className: "nwe-modal-body" }, (0, react.createElement)("div", { className: "nwe-tutorial-intro" }, (0, react.createElement)("p", null, "网文工坊不是一个“帮你想剧情”的按钮,而是一台有状态的写作引擎:你提供内容,引擎负责阶段、字数、门控、评审、归档和文件。"), (0, react.createElement)("p", null, "每一步都先领取当前阶段的任务书,再提交;提交后看引擎返回的 blocking 与下一步。面板只读,真正的写操作都在对话里的工具调用中。")), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "开始前:记住三件事"), (0, react.createElement)("div", { className: "nwe-tutorial-cards" }, (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "只用引擎入口写"), (0, react.createElement)("span", null, "正文只走 novel_write_draft / novel_revise;状态只走工具。手改 chapters/、state/、outline/ 会破坏事务与校验。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "每一步先领 brief"), (0, react.createElement)("span", null, "任务书携带当前上下文和 digest;跳过它会撞 PG-01。任务书过期就重新领取,不要复用旧上下文。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "失败先读 code"), (0, react.createElement)("span", null, "参数错改参数,门控没过改正文,系统故障查目录/稍后重试。blocking 会一次列全,一次改完再交。"))), (0, react.createElement)("p", { className: "nwe-tutorial-note" }, (0, react.createElement)("strong", null, "用哪个会话:"), " 想获得完整的引擎保证,请新开一个「网文工坊」预设会话;普通会话里模型可能还挂着文件工具,能绕过引擎直接改稿。")), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "一本书的完整流程"), (0, react.createElement)("p", null, "前四步是开书准备,后面是每章循环。第一次先按顺序走完一章,熟悉后再批量推进整本。"), (0, react.createElement)("ol", { className: "nwe-steps" }, ...TUTORIAL_STEPS.map((step, index) => tutorialStep(step, index)))), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "面板怎么看"), (0, react.createElement)("div", { className: "nwe-tutorial-cards" }, (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "顶部统计"), (0, react.createElement)("span", null, "已归档章、总字数、平均分、待处理门控。数字来自引擎,不是正文估算。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "九阶段轨与下一步"), (0, react.createElement)("span", null, "亮起的阶段是当前进度,“下一步”卡会直接给出该调用的工具名。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "章节与门控"), (0, react.createElement)("span", null, "章节卡展开可看字数、评分与状态;门控区按 code/detail/hint 列出所有阻塞。"))), (0, react.createElement)("p", { className: "nwe-tutorial-note" }, (0, react.createElement)("strong", null, "刷新:"), " 面板每 15 秒静默同步一次,也可以点右上角刷新。它是只读投影;不会替你改稿、归档或修状态。"), (0, react.createElement)("p", { className: "nwe-tutorial-note" }, (0, react.createElement)("strong", null, "项目状态:"), "选择器里 choose 表示发现多本书、还没选;项目不在了表示目录缺失,绑定仍会保留;不是项目表示目录还在但没有 novel.yaml 或项目状态;项目不可读表示权限或 IO 错误,先修权限或换一本。面板选择只影响查看,不改变任何会话的写作绑定。")), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "卡住时先看这里"), (0, react.createElement)("ul", { className: "nwe-faq" }, (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "为什么一直让我先领 brief?"), (0, react.createElement)("p", null, "这是正常门控。任务书决定当前阶段可用的字段与上下文 digest;先按返回值调用 novel_brief,再继续。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "门控失败,改一条还是全改?"), (0, react.createElement)("p", null, "一次看完所有 blocking,再一起改。逐条试会反复烧掉阶段轮次;错误里的 code 会告诉你属于参数、门控还是系统故障。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "字数和评分为什么对不上?"), (0, react.createElement)("p", null, "字数以 engine 返回为准,可用 novel_word_count 核对;review 的 total 必须等于 150 减全部扣分,不能另算一套。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "P4/P6 过不去怎么办?"), (0, react.createElement)("p", null, "P4 有 blocker 就领 P5 任务书修订,再回 P4 重评;P6 失败同样回修订。不要 waive blocker,也不要只跑半个 scope 就归档。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "想改已经归档的章节?"), (0, react.createElement)("p", null, "先 novel_reopen,带非空 reason。它会从归档备份恢复,重开后重新走 P3→P8;不要直接编辑归档卡或正文文件。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "状态文档坏了能手工修吗?"), (0, react.createElement)("p", null, "先用 novel_state 看 raw 和 next;需要修复时用 novel_amend,并在 reason 写清原因。手改 YAML 会丢失事务保护。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "有多个项目,面板看的是哪本?"), (0, react.createElement)("p", null, "面板用顶部项目选择器切换查看对象;选择只改 ?root= 和浏览器 localStorage,不调用 activate、不写会话绑定。写作项目请用 /novel use 或显式 project_root。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "选错书、想清掉绑定怎么办?"), (0, react.createElement)("p", null, "先用 /novel current 看当前会话绑定;要换书用 /novel use <路径|书名|.>,要清掉只影响当前会话的绑定用 /novel unbind。不要靠 active 猜,也不要手改 state/session-bindings.yaml。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "扫描到了,为什么还没切过去?"), (0, react.createElement)("p", null, "scan 只读发现;scan --register 只登记项目,不选择、不改 active、不绑会话。真正切换写作项目要用 /novel use,或第一次调用显式传 project_root。")))), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "可以直接复制给模型的起手话术"), (0, react.createElement)("pre", { className: "nwe-prompt" }, TUTORIAL_PROMPT)), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "续写下一章时,直接复制这段给 Agent"), (0, react.createElement)("p", null, "这段会先确认状态,再处理缺章卡和目标章数已满,最后按 P1→P8 推进;归档前会要求你确认 state_delta。"), (0, react.createElement)("pre", { className: "nwe-prompt" }, TUTORIAL_NEXT_PROMPT)), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "一章完成检查清单"), (0, react.createElement)("ul", { className: "nwe-checklist" }, (0, react.createElement)("li", null, "本章 P1/P2 计划已提交,P3 正文已通过格式与字数门控。"), (0, react.createElement)("li", null, "P4 评审 total 对账无误,没有未修 blocker。"), (0, react.createElement)("li", null, "P6 五个语义项都有不同句子的正文引用,完整 scope 通过。"), (0, react.createElement)("li", null, "P8 任务书当前,novel_archive 带完整 state_delta 成功。"), (0, react.createElement)("li", null, "面板出现已归档章节,门控区没有新的阻塞。")))), (0, react.createElement)("div", { className: "nwe-modal-foot" }, "教程基于 0.2.8 · 具体字段以当前任务书和工具返回为准 · 面板只读")));
1519
1523
  const NovelHelp = () => {
1520
1524
  const [open, setOpen] = (0, react.useState)(false);
1521
1525
  const triggerRef = (0, react.useRef)(null);
@@ -1592,6 +1596,7 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1592
1596
  const bookDataSection = (view) => {
1593
1597
  const data = view.bookData;
1594
1598
  if (data === null) return null;
1599
+ const currentLabel = data.current.stage === "done" ? data.progress.target !== null && data.progress.archived >= data.progress.target ? "全书完成" : `下一章 ${data.current.chapter} · 未开始` : `${data.current.chapter} · ${data.current.stage}`;
1595
1600
  const dims = data.quality.dimensions.map((dimension) => (0, react.createElement)("div", {
1596
1601
  className: "nwe-v6-bar",
1597
1602
  key: dimension.id
@@ -1613,7 +1618,7 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1613
1618
  ["已归档", `${data.progress.archived} / ${data.progress.target ?? "不限"} 章`],
1614
1619
  ["字数", `归档 ${data.progress.words_archived} · 含草稿 ${data.progress.words_with_draft}`],
1615
1620
  ["剩余", data.progress.remaining_words === null ? "—" : `${data.progress.remaining_words} 字(${data.progress.remaining_words_basis})`],
1616
- ["当前章", `${data.current.chapter} · ${data.current.stage} · ${data.current.card?.title ?? "无章卡"}`],
1621
+ ["当前章", `${currentLabel} · ${data.current.card?.title ?? "无章卡"}`],
1617
1622
  ["轮次", `评 ${data.current.rounds.review} / 修 ${data.current.rounds.revise} / 检 ${data.current.rounds.check}`],
1618
1623
  ["正文 hash", data.current.manuscript_hash ?? "—"],
1619
1624
  ["当前字数", data.current.metrics === null ? "—" : String(data.current.metrics.total_word_count)]
@@ -1685,7 +1690,7 @@ brief P6 → check → brief P8 → archive 的顺序推进。
1685
1690
  className: "nwe-stats",
1686
1691
  role: "group",
1687
1692
  "aria-label": "项目统计"
1688
- }, stat("已归档", String(view.stats.chapters), "章"), stat("总字数", view.stats.words.toLocaleString("zh-CN"), "字"), stat("全书均分", view.stats.chapters === 0 ? "—" : String(view.stats.averageScore), "/150"), stat("待处理", String(view.stats.blocking), "项")) : null, ready ? bookDataSection(view) : null, view.state === "loading" ? loadingState() : null, view.state === "choose" ? choosePanel(view, onSelectProject) : null, view.state === "missing" ? missingPanel(view) : null, view.state === "error" || view.state === "empty" || view.state === "outdated" ? statePanel(view.state, view, loading, onRefresh) : null, ready ? (0, react.createElement)("div", { className: "nwe-desk" }, (0, react.createElement)("div", { className: "nwe-desk-main" }, (0, react.createElement)("div", { className: "nwe-desk-heading" }, (0, react.createElement)("span", { className: "nwe-desk-kicker" }, "当前一章"), (0, react.createElement)("span", { className: "nwe-desk-note" }, view.stageLabel)), phases(view), blockingSection(view), advisoriesSection(view), pendingSection(view), legacySection(view), chaptersSection(view)), (0, react.createElement)("aside", {
1693
+ }, stat("已归档", String(view.stats.chapters), "章"), stat("总字数", view.stats.words.toLocaleString("zh-CN"), (view.stats.draftWords ?? 0) > 0 ? `字(归档 ${(view.stats.archivedWords ?? 0).toLocaleString("zh-CN")} + 草稿 ${view.stats.draftWords.toLocaleString("zh-CN")})` : "字"), stat("全书均分", view.stats.chapters === 0 ? "—" : String(view.stats.averageScore), "/150"), stat("待处理", String(view.stats.blocking), "项")) : null, ready ? bookDataSection(view) : null, view.state === "loading" ? loadingState() : null, view.state === "choose" ? choosePanel(view, onSelectProject) : null, view.state === "missing" ? missingPanel(view) : null, view.state === "error" || view.state === "empty" || view.state === "outdated" ? statePanel(view.state, view, loading, onRefresh) : null, ready ? (0, react.createElement)("div", { className: "nwe-desk" }, (0, react.createElement)("div", { className: "nwe-desk-main" }, (0, react.createElement)("div", { className: "nwe-desk-heading" }, (0, react.createElement)("span", { className: "nwe-desk-kicker" }, "当前一章"), (0, react.createElement)("span", { className: "nwe-desk-note" }, view.stageLabel)), phases(view), blockingSection(view), advisoriesSection(view), pendingSection(view), legacySection(view), chaptersSection(view)), (0, react.createElement)("aside", {
1689
1694
  className: "nwe-desk-side",
1690
1695
  "aria-label": "全书与活动"
1691
1696
  }, bookSection(view), activitySection(view))) : null, footer());
package/lib/index.d.ts CHANGED
@@ -301,8 +301,8 @@ interface CharacterDelta {
301
301
  role?: string;
302
302
  profile?: Record<string, unknown>;
303
303
  current_state?: Record<string, unknown>;
304
+ /** Chapter is engine-assigned from the archive transaction, not caller-supplied. */
304
305
  growth?: {
305
- chapter: number;
306
306
  change: string;
307
307
  status_change: string;
308
308
  };
@@ -972,6 +972,8 @@ interface GateInput {
972
972
  briefStage: BriefStage | null;
973
973
  draftTitle: string | null;
974
974
  sceneType: SceneType | null;
975
+ /** The submitted/active plan narrator; PG-05 uses it before a draft exists. */
976
+ narrator?: string | null;
975
977
  summary: ChapterSummary | null;
976
978
  /** P5 may omit `summary`; when omitted, the previous plan summary is reused. */
977
979
  summaryProvided?: boolean;
@@ -1034,6 +1036,36 @@ interface Selection {
1034
1036
  title?: string;
1035
1037
  created_at?: string;
1036
1038
  }
1039
+ /**
1040
+ * One human-readable projection of the pipeline's chapter boundary.
1041
+ *
1042
+ * `pipeline.chapter`/`stage` are protocol values: after archiving chapter N the
1043
+ * pipeline points at N+1 and still says `done` because `done` describes the
1044
+ * chapter that was just archived. This projection is the only place that turns
1045
+ * those values into "chapter N archived; chapter N+1 starts at P1".
1046
+ */
1047
+ interface ChapterProgress {
1048
+ active_chapter: number;
1049
+ stage: Stage;
1050
+ /** The chapter currently being written; null while `stage === 'done'`. */
1051
+ current: {
1052
+ chapter: number;
1053
+ stage: Stage;
1054
+ } | null;
1055
+ /** Highest archived chapter, or null before the first archive. */
1056
+ last_archived: number | null;
1057
+ /** Next chapter to start at P1; null while writing or after the book is finished. */
1058
+ next: {
1059
+ chapter: number;
1060
+ stage: 'P1';
1061
+ } | null;
1062
+ /** True only when the configured target has been reached at `stage === 'done'`. */
1063
+ finished: boolean;
1064
+ /** One line for status cards, commands and the panel. */
1065
+ label: string;
1066
+ /** Sentence used by the resume prompt instead of "当前第 N 章,阶段 done". */
1067
+ resume: string;
1068
+ }
1037
1069
  /** The `getStatus` payload. */
1038
1070
  interface StatusReport {
1039
1071
  project: {
@@ -1054,6 +1086,8 @@ interface StatusReport {
1054
1086
  /** SHA-256 of the active manuscript, or null when the stage has no draft. */
1055
1087
  manuscript_hash: string | null;
1056
1088
  stage: Stage;
1089
+ /** Chapter-boundary projection shared by every human-readable surface. */
1090
+ progress?: ChapterProgress;
1057
1091
  rounds: RoundCounters;
1058
1092
  blocking: BlockingItem[];
1059
1093
  /** Non-blocking recovery notes, e.g. a recoverable pending transaction. */
@@ -1106,6 +1140,8 @@ interface StatusReport {
1106
1140
  interface AdviceReport {
1107
1141
  stage: Stage;
1108
1142
  chapter: number;
1143
+ /** Same chapter-boundary projection as getStatus. */
1144
+ progress?: ChapterProgress;
1109
1145
  tool: string;
1110
1146
  args: Record<string, unknown>;
1111
1147
  reason: string;
@@ -1577,6 +1613,8 @@ interface PanelSnapshot {
1577
1613
  platform: string;
1578
1614
  chapter: number;
1579
1615
  stage: Stage;
1616
+ /** Chapter-boundary projection; `chapter`/`stage` stay as the protocol pair. */
1617
+ progress?: ChapterProgress;
1580
1618
  revision: number;
1581
1619
  rounds: {
1582
1620
  review: number;
@@ -1595,8 +1633,12 @@ interface PanelSnapshot {
1595
1633
  chapters: ChapterRecord[];
1596
1634
  /** The active, not-yet-archived chapter; archived rows come from `chapters`. */
1597
1635
  draft?: PanelDraft | null;
1598
- /** Total words across the index, for the header. */
1636
+ /** Archived words plus the active draft; the header shows this total. */
1599
1637
  totalWords: number;
1638
+ /** Words in archived chapters only. */
1639
+ archivedWords?: number;
1640
+ /** Active draft words, zero when no draft exists. */
1641
+ draftWords?: number;
1600
1642
  /** Book-wide archived average, not just the panel's capped rows. */
1601
1643
  averageScore?: number;
1602
1644
  /** The nine-phase strip state, derived from the pipeline stage. */
@@ -1694,8 +1736,14 @@ interface PanelSummaryDto {
1694
1736
  platform: string;
1695
1737
  chapter: number;
1696
1738
  stage: Stage;
1739
+ /** Chapter-boundary projection; absent on older payloads. */
1740
+ progress?: ChapterProgress;
1697
1741
  revision: number;
1698
1742
  totalWords: number;
1743
+ /** Words in archived chapters only; `totalWords` also counts the active draft. */
1744
+ archivedWords?: number;
1745
+ /** Active draft words, zero when no draft exists. */
1746
+ draftWords?: number;
1699
1747
  totalChapters: number;
1700
1748
  /** Book-wide archived average; absent on older payloads. */
1701
1749
  averageScore?: number;
@@ -1825,12 +1873,27 @@ interface PanelBundle {
1825
1873
  empty?: true;
1826
1874
  error?: string;
1827
1875
  }
1876
+ interface ChapterProgressInput {
1877
+ activeChapter: number;
1878
+ stage: Stage;
1879
+ archived: readonly number[];
1880
+ target: number | null;
1881
+ }
1882
+ /**
1883
+ * Turn the protocol pair (`chapter` + `stage`) into the only human-readable
1884
+ * chapter boundary. After archiving chapter N the protocol intentionally keeps
1885
+ * `stage: 'done'` and advances `chapter` to N+1; `done` describes chapter N,
1886
+ * not the chapter the pipeline now points at.
1887
+ */
1888
+ declare function projectChapterProgress(input: ChapterProgressInput): ChapterProgress;
1828
1889
  interface ResumePromptInput {
1829
1890
  title: string;
1830
1891
  root: string;
1831
1892
  chapter: number | string;
1832
1893
  stage: string;
1833
1894
  nextTool: string;
1895
+ /** When supplied, replaces the protocol pair with the chapter boundary text. */
1896
+ progress?: ChapterProgress;
1834
1897
  }
1835
1898
  /** One shared prompt template for `/novel resume` and the panel copy button. */
1836
1899
  declare function formatResumePrompt(input: ResumePromptInput): string;
@@ -2298,6 +2361,7 @@ declare class EngineService {
2298
2361
  }): Promise<OpResult<{
2299
2362
  stage: Stage;
2300
2363
  chapter: number;
2364
+ progress: ChapterProgress;
2301
2365
  tool: string;
2302
2366
  args: Record<string, unknown>;
2303
2367
  reason: string;
@@ -2419,6 +2483,8 @@ declare class EngineService {
2419
2483
  root?: string;
2420
2484
  }): Promise<{
2421
2485
  totalWords: number;
2486
+ archivedWords: number;
2487
+ draftWords: number;
2422
2488
  chapters: number;
2423
2489
  revision: number;
2424
2490
  rulepack: string;
@@ -2805,4 +2871,4 @@ declare const Config: Schema<Config>;
2805
2871
  * created lazily per project root.
2806
2872
  */
2807
2873
  declare function apply(ctx: Context, config: Config): void;
2808
- export { ARCHIVE_DIGESTS_FILE, AdviceReport, ArchiveHandoff, AuditRow, BACKUP_DIR, BRIEF_RECEIPTS_FILE, BlockingItem, BookPlan, BookReport, BriefDocument, BriefLibrary, BriefReceipt, BriefReceiptsDocument, BriefStage, CHAPTERS_DIR, CHECK_DIR, CORE_TOOLS, ChapterCastMember, ChapterPlanInput, ChapterRecord, ChapterStatusRow, ChapterSummary, ChaptersDocument, CharacterDelta, CharactersDocument, CheckDocument, Config, DEFAULT_GENRE, DEFAULT_PLATFORM, Deduction, DeltaAction, DiffReport, DoctorCheck, DoctorReport, DraftInput, DraftState, ERROR_CODES, EXPORT_DIR, EngineBlockedError, EngineError, EngineFaultError, EngineInputError, EngineService, ErrorCode, ExportChapter, ExportKind, ForeshadowDelta, ForeshadowsDocument, GENRES, GateInput, GateOptions, GateResult, Genre, GuideReport, GuideStep, HISTORY_DIR, HISTORY_KINDS, HandoffDocument, HistoryKind, INITIAL_STAGE, LIMITS, LOCK_FILE, LocksDocument, METADATA_LOCK_FILE, MachineMetrics, NovelProject, OPERATIONS, OUTLINE_DEPRECATED_FILE, OUTLINE_DIR, OUTLINE_FILE, OpResult, Operation, OutlineChapter, OutlineDocument, OutlineProgress, OutlineVolume, PANEL_CHAPTER_LIMIT, PANEL_ID, PANEL_ROUTE, PHASE_STRIP, PLAN_DIR, PLATFORMS, PLUGIN_NAME, PROJECT_FILE, PanelBook, PanelBundle, PanelChapterRow, PanelChaptersDto, PanelDraft, PanelGatesDto, PanelMissing, PanelProjectOption, PanelSelection, PanelSnapshot, PanelSummaryDto, PipelineState, Platform, REGISTRY_FILE, REMOTE_VERSION, REVIEW_DIR, ReadingProof, ReadingProofReference, RelationshipDelta, RelationshipsDocument, ResumePromptInput, ReviewDocument, ReviewInput, ReviewIssue, RevisionEdit, RevisionInput, RevisionPatch, RoundCounters, SCENE_TYPES, SCENE_TYPE_DEFAULT, SCHEMA_VERSION, SESSION_BINDINGS_FILE, SKILL_NAME, STAGING_DIR, STATE_DIR, STATE_FILES, SceneType, ScoreDimension, SearchReport, SearchResultItem, Selection, SelectionSource, SemanticItem, Severity, Stage, StateDelta, StateError, StateErrorCode, StatusReport, TOOL_NAMES, TimelineDelta, TimelineDocument, ToolName, Violation, WORLDBOOK_FILE, Warning, WordMetrics, WorldDelta, WorldDocument, WorldPlanInput, WorldbookBackstory, WorldbookCharacter, WorldbookDocument, WorldbookFact, WorldbookFaction, WorldbookGlossary, WorldbookLocation, WorldbookRule, applicableGates, apply, blockedError, briefStageFor, chapterFile, chapterHeading, chapterHistoryFile, checkFile, checkHistoryFile, deprecatedChapterFile, deprecatedHistoryFile, evaluateGates, faultError, formatResumePrompt, gateName, inject, inputError, loadRulepack, name, parseParagraphLocation, phaseStrip, planChapterFile, planHistoryFile, planWorldFile, projectChapter, projectChapters, projectGates, projectSummary, renderBlurb, renderCover, renderEpubChapter, renderMarkdown, renderPlainText, renderProposal, reviewFile, reviewHistoryFile, verifyReference };
2874
+ export { ARCHIVE_DIGESTS_FILE, AdviceReport, ArchiveHandoff, AuditRow, BACKUP_DIR, BRIEF_RECEIPTS_FILE, BlockingItem, BookPlan, BookReport, BriefDocument, BriefLibrary, BriefReceipt, BriefReceiptsDocument, BriefStage, CHAPTERS_DIR, CHECK_DIR, CORE_TOOLS, ChapterCastMember, ChapterPlanInput, ChapterProgress, ChapterProgressInput, ChapterRecord, ChapterStatusRow, ChapterSummary, ChaptersDocument, CharacterDelta, CharactersDocument, CheckDocument, Config, DEFAULT_GENRE, DEFAULT_PLATFORM, Deduction, DeltaAction, DiffReport, DoctorCheck, DoctorReport, DraftInput, DraftState, ERROR_CODES, EXPORT_DIR, EngineBlockedError, EngineError, EngineFaultError, EngineInputError, EngineService, ErrorCode, ExportChapter, ExportKind, ForeshadowDelta, ForeshadowsDocument, GENRES, GateInput, GateOptions, GateResult, Genre, GuideReport, GuideStep, HISTORY_DIR, HISTORY_KINDS, HandoffDocument, HistoryKind, INITIAL_STAGE, LIMITS, LOCK_FILE, LocksDocument, METADATA_LOCK_FILE, MachineMetrics, NovelProject, OPERATIONS, OUTLINE_DEPRECATED_FILE, OUTLINE_DIR, OUTLINE_FILE, OpResult, Operation, OutlineChapter, OutlineDocument, OutlineProgress, OutlineVolume, PANEL_CHAPTER_LIMIT, PANEL_ID, PANEL_ROUTE, PHASE_STRIP, PLAN_DIR, PLATFORMS, PLUGIN_NAME, PROJECT_FILE, PanelBook, PanelBundle, PanelChapterRow, PanelChaptersDto, PanelDraft, PanelGatesDto, PanelMissing, PanelProjectOption, PanelSelection, PanelSnapshot, PanelSummaryDto, PipelineState, Platform, REGISTRY_FILE, REMOTE_VERSION, REVIEW_DIR, ReadingProof, ReadingProofReference, RelationshipDelta, RelationshipsDocument, ResumePromptInput, ReviewDocument, ReviewInput, ReviewIssue, RevisionEdit, RevisionInput, RevisionPatch, RoundCounters, SCENE_TYPES, SCENE_TYPE_DEFAULT, SCHEMA_VERSION, SESSION_BINDINGS_FILE, SKILL_NAME, STAGING_DIR, STATE_DIR, STATE_FILES, SceneType, ScoreDimension, SearchReport, SearchResultItem, Selection, SelectionSource, SemanticItem, Severity, Stage, StateDelta, StateError, StateErrorCode, StatusReport, TOOL_NAMES, TimelineDelta, TimelineDocument, ToolName, Violation, WORLDBOOK_FILE, Warning, WordMetrics, WorldDelta, WorldDocument, WorldPlanInput, WorldbookBackstory, WorldbookCharacter, WorldbookDocument, WorldbookFact, WorldbookFaction, WorldbookGlossary, WorldbookLocation, WorldbookRule, applicableGates, apply, blockedError, briefStageFor, chapterFile, chapterHeading, chapterHistoryFile, checkFile, checkHistoryFile, deprecatedChapterFile, deprecatedHistoryFile, evaluateGates, faultError, formatResumePrompt, gateName, inject, inputError, loadRulepack, name, parseParagraphLocation, phaseStrip, planChapterFile, planHistoryFile, planWorldFile, projectChapter, projectChapterProgress, projectChapters, projectGates, projectSummary, renderBlurb, renderCover, renderEpubChapter, renderMarkdown, renderPlainText, renderProposal, reviewFile, reviewHistoryFile, verifyReference };
package/lib/index.js CHANGED
@@ -308,6 +308,33 @@ function allocateIds(prefix, nextSeq, count) {
308
308
  nextSeq: nextSeq + count
309
309
  };
310
310
  }
311
+ // src/domain/narrator.ts
312
+ /**
313
+ * One narrator-alias contract shared by PG-05, state_delta and lock capture.
314
+ *
315
+ * The plan format is `角色名(第一人称)` or `角色名(第一人称「我」)`. A
316
+ * first-person manuscript says 「我」 instead of repeating the character name;
317
+ * every consumer must accept that alias, and none may infer it from a bare
318
+ * substring such as "林" inside "林砚".
319
+ */
320
+ const narratorName = (narrator) => narrator.split(/[((]/, 1)[0].trim();
321
+ /** The only documented first-person alias is the pronoun itself. */
322
+ const narratorAliases = (narrator) => isFirstPersonNarrator(narrator) ? ["我"] : [];
323
+ /** Whether the plan declares first-person narration. */
324
+ const isFirstPersonNarrator = (narrator) => narrator.includes("第一人称") && !narrator.includes("第三人称");
325
+ /**
326
+ * True when `name` is the declared narrator and the manuscript uses the
327
+ * declared first-person alias. A name that merely contains the narrator name
328
+ * is not an alias.
329
+ */
330
+ const isNarratorAlias = (name, narrator, text) => {
331
+ if (text.includes(name)) return true;
332
+ if (narratorName(narrator) !== name) return false;
333
+ if (!isFirstPersonNarrator(narrator)) return false;
334
+ return narratorAliases(narrator).some((alias) => text.includes(alias));
335
+ };
336
+ /** Paragraph used for a newly locked character; prefers the named/aliased one. */
337
+ const narratorAliasesFor = (name, narrator) => name === narratorName(narrator) ? narratorAliases(narrator) : [];
311
338
  // src/domain/scoring.ts
312
339
  /** The fifteen dimensions, grouped as the commercial weights demand. */
313
340
  const SCORE_DIMENSIONS = [
@@ -2528,7 +2555,13 @@ var FsStore = class {
2528
2555
  /** Absolute path of an internal file outside the project root (locks live at root). */
2529
2556
  async ensureDir(relativePath) {
2530
2557
  const absolute = await this.safePath(relativePath);
2531
- await promises.mkdir(absolute, { recursive: true });
2558
+ await this.ensureParent(absolute);
2559
+ try {
2560
+ await promises.mkdir(absolute);
2561
+ } catch (error) {
2562
+ if (error.code !== "EEXIST") throw ioFailure(error);
2563
+ if (!(await promises.lstat(absolute)).isDirectory()) throw new EngineFaultError(ERROR_CODES.IO_PERMISSION, `refusing to use "${relativePath}": it exists but is not a directory`, "remove or rename the conflicting file, then retry");
2564
+ }
2532
2565
  return absolute;
2533
2566
  }
2534
2567
  async exists(relativePath) {
@@ -3218,7 +3251,9 @@ const BACKUP_MANIFEST = "manifest.json";
3218
3251
  /** Backup transaction directories, newest first by filesystem mtime. */
3219
3252
  async function listBackups(store) {
3220
3253
  const entries = [];
3221
- for (const name of await store.list(BACKUP_DIR)) {
3254
+ for (const entry of await store.entries(BACKUP_DIR)) {
3255
+ if (!entry.directory || entry.symbolicLink) continue;
3256
+ const name = entry.name;
3222
3257
  if (name === "migrations") continue;
3223
3258
  const info = await store.stat(joinRel(BACKUP_DIR, name));
3224
3259
  if (info === null || !info.isDirectory) continue;
@@ -3379,6 +3414,7 @@ async function runTransaction(store, input) {
3379
3414
  for (const file of input.files) {
3380
3415
  const existing = before.get(file.path);
3381
3416
  if (existing === null || existing === void 0) continue;
3417
+ await assertRootPresent();
3382
3418
  await store.writeBytes(joinRel(staging, "before", file.path), existing);
3383
3419
  }
3384
3420
  if (input.backup) {
@@ -3386,8 +3422,10 @@ async function runTransaction(store, input) {
3386
3422
  for (const file of input.files) {
3387
3423
  const existing = before.get(file.path);
3388
3424
  if (existing === null || existing === void 0) continue;
3425
+ await assertRootPresent();
3389
3426
  await store.writeBytes(joinRel(stagingBackup, file.path), existing);
3390
3427
  }
3428
+ await assertRootPresent();
3391
3429
  await store.writeText(joinRel(stagingBackup, BACKUP_MARKER), "1");
3392
3430
  await store.writeText(joinRel(stagingBackup, BACKUP_MANIFEST), `${JSON.stringify({
3393
3431
  schema_version: 1,
@@ -3399,9 +3437,11 @@ async function runTransaction(store, input) {
3399
3437
  ...input.reason === void 0 ? {} : { reason: input.reason },
3400
3438
  files: input.files.filter((file) => before.get(file.path) !== null && before.get(file.path) !== void 0).map((file) => file.path)
3401
3439
  }, null, 2)}\n`);
3402
- const targetBackup = store.path(backupDir(input.txid));
3403
- await promises.mkdir(dirname(targetBackup), { recursive: true });
3404
- await promises.rename(store.path(stagingBackup), targetBackup);
3440
+ await assertRootPresent();
3441
+ await store.ensureDir(BACKUP_DIR);
3442
+ await assertRootPresent();
3443
+ const targetBackup = await store.safePath(backupDir(input.txid));
3444
+ await promises.rename(await store.safePath(stagingBackup), targetBackup);
3405
3445
  await syncDirectory(dirname(targetBackup));
3406
3446
  backupMarked = true;
3407
3447
  backupComplete = true;
@@ -4305,12 +4345,38 @@ function normalizeOutlineChapters(input, options) {
4305
4345
  if (target[0] !== base.target_words[0] || target[1] !== base.target_words[1]) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} is archived; its target_words cannot change`, "call novel_reopen first if the manuscript must be revised");
4306
4346
  const cast = raw.cast === void 0 ? base.cast : stringArray$1(raw.cast, `chapters[${index}].cast`, 8);
4307
4347
  if (cast.length !== base.cast.length || cast.some((name) => !base.cast.includes(name))) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} is archived; its cast cannot change`, "call novel_reopen first if the manuscript must be revised");
4348
+ const goal = raw.goal === void 0 || raw.goal === base.goal ? base.goal : text$2(raw.goal, `chapter ${num} goal`);
4349
+ const conflict = raw.conflict === void 0 || raw.conflict === base.conflict ? base.conflict : text$2(raw.conflict, `chapter ${num} conflict`);
4350
+ const hook = raw.hook === void 0 || raw.hook === base.hook ? base.hook : text$2(raw.hook, `chapter ${num} hook`);
4351
+ const notes = raw.notes === void 0 || raw.notes === base.notes ? base.notes : optionalText(raw.notes, `chapter ${num} notes`, LIMITS.maxOutlineNotesChars);
4352
+ for (const [field, value, previous] of [
4353
+ [
4354
+ "goal",
4355
+ goal,
4356
+ base.goal
4357
+ ],
4358
+ [
4359
+ "conflict",
4360
+ conflict,
4361
+ base.conflict
4362
+ ],
4363
+ [
4364
+ "hook",
4365
+ hook,
4366
+ base.hook
4367
+ ],
4368
+ [
4369
+ "notes",
4370
+ notes,
4371
+ base.notes
4372
+ ]
4373
+ ]) if (value !== previous) throw new EngineInputError(ERROR_CODES.SCHEMA_INVALID, `chapter ${num} is archived; its ${field} cannot change`, "call novel_reopen first if the manuscript must be revised");
4308
4374
  normalized.set(num, {
4309
4375
  ...base,
4310
- goal: raw.goal === void 0 || raw.goal === base.goal ? base.goal : text$2(raw.goal, `chapter ${num} goal`),
4311
- conflict: raw.conflict === void 0 || raw.conflict === base.conflict ? base.conflict : text$2(raw.conflict, `chapter ${num} conflict`),
4312
- hook: raw.hook === void 0 || raw.hook === base.hook ? base.hook : text$2(raw.hook, `chapter ${num} hook`),
4313
- notes: raw.notes === void 0 || raw.notes === base.notes ? base.notes : optionalText(raw.notes, `chapter ${num} notes`, LIMITS.maxOutlineNotesChars),
4376
+ goal,
4377
+ conflict,
4378
+ hook,
4379
+ notes,
4314
4380
  volume: volumeForChapter(options.volumes, num, raw.volume === void 0 ? base.volume : raw.volume),
4315
4381
  status: "archived",
4316
4382
  archived_at: base.archived_at ?? record.archived_at ?? null,
@@ -5485,9 +5551,10 @@ const requireForeshadowFields = (value, where) => {
5485
5551
  if (value.note !== void 0 && typeof value.note !== "string") fail$1(`${where}.note must be a string`);
5486
5552
  if (value.new_info !== void 0 && typeof value.new_info !== "boolean") fail$1(`${where}.new_info must be a boolean`);
5487
5553
  };
5554
+ const withoutQuotedSpeech = (text) => text.replace(/"[^"]*"|“[^”]*”|「[^」]*」|『[^』]*』/g, "");
5488
5555
  const appearanceOf = (text, name, aliases = []) => {
5489
5556
  const paragraphs = splitParagraphs(text);
5490
- const source = paragraphs.find((paragraph) => paragraph.includes(name) || aliases.some((alias) => alias !== "" && paragraph.includes(alias))) ?? paragraphs[0] ?? "";
5557
+ const source = paragraphs.find((paragraph) => withoutQuotedSpeech(paragraph).includes(name)) ?? paragraphs.find((paragraph) => paragraph.includes(name)) ?? paragraphs.find((paragraph) => aliases.some((alias) => alias !== "" && withoutQuotedSpeech(paragraph).includes(alias))) ?? paragraphs[0] ?? "";
5491
5558
  return Array.from(source).slice(0, 120).join("");
5492
5559
  };
5493
5560
  const named = (value) => isRecord(value) && typeof value.name === "string";
@@ -5578,12 +5645,8 @@ function applyStateDelta(input) {
5578
5645
  const appeared = [...new Set(summary.characters_appeared)];
5579
5646
  const byName = new Map(characters.characters.map((character) => [character.name, character]));
5580
5647
  const narrator = input.narrator ?? "";
5581
- const narratorName = narrator.split(/[((]/, 1)[0].trim();
5582
- const narratorAliases = [...narrator.matchAll(/[「『"]([^」』"]+)[」』"]/g)].map((match) => match[1].trim());
5583
- const appearsInManuscript = (name) => {
5584
- if (input.text.includes(name)) return true;
5585
- return name === narratorName && narratorAliases.some((alias) => alias !== "" && input.text.includes(alias));
5586
- };
5648
+ const narratorName$1 = narratorName(narrator);
5649
+ const appearsInManuscript = (name) => isNarratorAlias(name, narrator, input.text);
5587
5650
  for (const name of appeared) {
5588
5651
  if (!appearsInManuscript(name)) fail$1(`character "${name}" is listed in characters_appeared but does not appear in the manuscript`);
5589
5652
  if (!Object.hasOwn(delta.characters, name)) fail$1(`character "${name}" appeared in the chapter but is not declared in characters`);
@@ -5605,16 +5668,20 @@ function applyStateDelta(input) {
5605
5668
  if (existing !== void 0) fail$1(`character "${name}" already exists; use updated or no_change`);
5606
5669
  if (!appeared.includes(name)) fail$1(`character "${name}" is created but does not appear in characters_appeared`);
5607
5670
  const rawHint = input.roleHints?.[name];
5608
- const hinted = canonicalRole(rawHint) ?? (rawHint === void 0 && (name === narratorName || name === narrator) ? "protagonist" : "extra");
5671
+ const hinted = canonicalRole(rawHint) ?? (rawHint === void 0 && (name === narratorName$1 || name === narrator) ? "protagonist" : "extra");
5609
5672
  const currentState = declared.current_state ?? {};
5610
5673
  validateResources(currentState, `characters.${name}`);
5674
+ const growth = declared.growth === void 0 ? [] : [{
5675
+ ...declared.growth,
5676
+ chapter
5677
+ }];
5611
5678
  const created = {
5612
5679
  name,
5613
5680
  role: normalizeRole(declared.role, hinted),
5614
5681
  first_appearance: chapter,
5615
5682
  profile: declared.profile ?? {},
5616
5683
  current_state: currentState,
5617
- growth: [],
5684
+ growth,
5618
5685
  updated_at: chapter
5619
5686
  };
5620
5687
  characters.characters.push(created);
@@ -5626,7 +5693,7 @@ function applyStateDelta(input) {
5626
5693
  const rawHint = input.roleHints?.[name];
5627
5694
  const hinted = canonicalRole(rawHint);
5628
5695
  if (hinted !== null && hinted !== "extra") existing.role = hinted;
5629
- else if (rawHint === void 0 && (name === narratorName || name === narrator)) existing.role = "protagonist";
5696
+ else if (rawHint === void 0 && (name === narratorName$1 || name === narrator)) existing.role = "protagonist";
5630
5697
  }
5631
5698
  if (declared.role !== void 0) existing.role = normalizeRole(declared.role, existing.role);
5632
5699
  if (declared.action === "updated") {
@@ -5809,7 +5876,7 @@ function applyStateDelta(input) {
5809
5876
  setOwn(locks.characters, name, {
5810
5877
  first_appearance: character.first_appearance,
5811
5878
  locked_at: chapter,
5812
- appearance: appearanceOf(input.text, name, name === narratorName ? narratorAliases : [])
5879
+ appearance: appearanceOf(input.text, name, narratorAliasesFor(name, narrator))
5813
5880
  });
5814
5881
  }
5815
5882
  for (const location of world.locations) if (!Object.hasOwn(locks.locations, location.name)) setOwn(locks.locations, location.name, {
@@ -6330,8 +6397,8 @@ function buildGuide(input) {
6330
6397
  },
6331
6398
  {
6332
6399
  id: "write",
6333
- title: input.finished ? "全书章节已写完" : `写第 ${input.chapter} 章`,
6334
- detail: input.finished ? `已完成 ${input.archived} 章;下一步是导出成书。` : `当前阶段 ${input.stage};${input.blocking === 0 ? "没有阻塞" : `${input.blocking} 条阻塞`}。`,
6400
+ title: input.finished ? "全书章节已写完" : input.progress?.current !== null && input.progress?.current !== void 0 ? `写第 ${input.progress.current.chapter} 章` : input.progress?.next !== null && input.progress?.next !== void 0 ? `准备第 ${input.progress.next.chapter} 章(P1)` : `写第 ${input.chapter} 章`,
6401
+ detail: input.finished ? `已完成 ${input.archived} 章;下一步是导出成书。` : `${input.progress?.label ?? `当前阶段 ${input.stage}`};${input.blocking === 0 ? "没有阻塞" : `${input.blocking} 条阻塞`}。`,
6335
6402
  done: input.finished,
6336
6403
  tool: input.next.tool
6337
6404
  },
@@ -6345,7 +6412,7 @@ function buildGuide(input) {
6345
6412
  ];
6346
6413
  return {
6347
6414
  state: input.finished ? "finished" : input.stateErrors > 0 || input.blocking > 0 ? "blocked" : input.stage === "idle" && input.archived === 0 && !outlineDone && !blueprintDone && !worldbookDone ? "new" : input.stage === "idle" && input.archived === 0 ? "planning" : "writing",
6348
- title: input.finished ? "全书已完成" : `第 ${input.chapter} 章 · ${input.stage}`,
6415
+ title: input.finished ? "全书已完成" : input.progress?.label ?? `第 ${input.chapter} 章 · ${input.stage}`,
6349
6416
  steps,
6350
6417
  next: input.next,
6351
6418
  commands: [
@@ -8015,11 +8082,8 @@ const summarySchema = (input) => {
8015
8082
  }
8016
8083
  }
8017
8084
  if (textArray(summary.characters_appeared)) {
8018
- const narrator = input.pipeline.draft?.narrator ?? "";
8019
- for (const name of summary.characters_appeared) {
8020
- const alias = narrator.includes(name) && (narrator.includes("第一人称") || narrator.includes("「我」")) && input.text.includes("我");
8021
- if (!input.text.includes(name) && !alias) problems.push(`characters_appeared lists "${name}", but the manuscript never mentions it`);
8022
- }
8085
+ const narrator = input.narrator ?? input.pipeline.draft?.narrator ?? "";
8086
+ for (const name of summary.characters_appeared) if (!isNarratorAlias(name, narrator, input.text)) problems.push(`characters_appeared lists "${name}", but the manuscript never mentions it`);
8023
8087
  }
8024
8088
  const resources = summary.resource_changes;
8025
8089
  if (!Array.isArray(resources) || !resources.every((item) => typeof item === "object" && item !== null && isText(item.character) && isText(item.resource) && Number.isInteger(item.delta) && item.delta !== 0 && isText(item.reason))) problems.push("resource_changes must contain character/resource/non-zero delta/reason");
@@ -8363,12 +8427,67 @@ function briefStageFor(operation, stage) {
8363
8427
  }
8364
8428
  }
8365
8429
  // src/engine/projections.ts
8430
+ /**
8431
+ * Turn the protocol pair (`chapter` + `stage`) into the only human-readable
8432
+ * chapter boundary. After archiving chapter N the protocol intentionally keeps
8433
+ * `stage: 'done'` and advances `chapter` to N+1; `done` describes chapter N,
8434
+ * not the chapter the pipeline now points at.
8435
+ */
8436
+ function projectChapterProgress(input) {
8437
+ const archived = [...new Set(input.archived)].filter((num) => Number.isInteger(num) && num > 0).sort((left, right) => left - right);
8438
+ const lastArchived = archived.length === 0 ? null : archived[archived.length - 1];
8439
+ const finished = input.stage === "done" && input.target !== null && archived.length >= input.target;
8440
+ const base = {
8441
+ active_chapter: input.activeChapter,
8442
+ stage: input.stage,
8443
+ last_archived: lastArchived,
8444
+ finished
8445
+ };
8446
+ if (finished) return {
8447
+ ...base,
8448
+ current: null,
8449
+ next: null,
8450
+ label: `全书完成:已归档 ${archived.length} 章`,
8451
+ resume: `全书已完成(已归档 ${archived.length} 章);如需续写,先用 novel_amend 提高 targets.total_chapters。`
8452
+ };
8453
+ if (input.stage === "done") {
8454
+ if (lastArchived === null) return {
8455
+ ...base,
8456
+ current: null,
8457
+ next: null,
8458
+ label: `当前第 ${input.activeChapter} 章 · 阶段 done(尚无归档章)`,
8459
+ resume: `引擎处于 done,但章节索引里还没有已归档章;先核对项目状态。`
8460
+ };
8461
+ const nextChapter = input.activeChapter > lastArchived ? input.activeChapter : lastArchived + 1;
8462
+ return {
8463
+ ...base,
8464
+ current: null,
8465
+ next: {
8466
+ chapter: nextChapter,
8467
+ stage: "P1"
8468
+ },
8469
+ label: `第 ${lastArchived} 章已归档 · 下一章第 ${nextChapter} 章待开始(P1)`,
8470
+ resume: `第 ${lastArchived} 章已归档;当前不处于写作阶段,下一章第 ${nextChapter} 章从 P1 开始。`
8471
+ };
8472
+ }
8473
+ return {
8474
+ ...base,
8475
+ current: {
8476
+ chapter: input.activeChapter,
8477
+ stage: input.stage
8478
+ },
8479
+ next: null,
8480
+ label: `第 ${input.activeChapter} 章 · 阶段 ${input.stage}`,
8481
+ resume: `当前第 ${input.activeChapter} 章,阶段 ${input.stage}。`
8482
+ };
8483
+ }
8366
8484
  /** One shared prompt template for `/novel resume` and the panel copy button. */
8367
8485
  function formatResumePrompt(input) {
8486
+ if (input.progress?.finished === true) return "";
8368
8487
  return [
8369
8488
  `请继续《${input.title}》。`,
8370
8489
  `项目根:${input.root}(第一次工具调用必须显式传 project_root=${input.root})。`,
8371
- `当前第 ${input.chapter} 章,阶段 ${input.stage}。`,
8490
+ input.progress?.resume ?? `当前第 ${input.chapter} 章,阶段 ${input.stage}。`,
8372
8491
  `下一步:${input.nextTool}。`,
8373
8492
  "先调用 novel_status 核对项目根与书名,再按 novel_pipeline 的下一步执行;",
8374
8493
  "只操作这个项目,不要读取或写入其他书。"
@@ -8434,8 +8553,11 @@ function projectSummary(snapshot) {
8434
8553
  platform: snapshot.platform,
8435
8554
  chapter: snapshot.chapter,
8436
8555
  stage: snapshot.stage,
8556
+ ...snapshot.progress === void 0 ? {} : { progress: snapshot.progress },
8437
8557
  revision: snapshot.revision,
8438
8558
  totalWords: snapshot.totalWords,
8559
+ ...snapshot.archivedWords === void 0 ? {} : { archivedWords: snapshot.archivedWords },
8560
+ ...snapshot.draftWords === void 0 ? {} : { draftWords: snapshot.draftWords },
8439
8561
  totalChapters: snapshot.chapters.length,
8440
8562
  averageScore: snapshot.averageScore ?? (snapshot.chapters.length === 0 ? 0 : snapshot.chapters.reduce((sum, chapter) => sum + chapter.score, 0) / snapshot.chapters.length),
8441
8563
  ...snapshot.volume === void 0 ? {} : { volume: snapshot.volume },
@@ -10206,6 +10328,12 @@ var EngineService = class {
10206
10328
  */
10207
10329
  async statusFor(snapshot, chapter) {
10208
10330
  const metrics = await this.metricsFor(snapshot, chapter);
10331
+ const progress = projectChapterProgress({
10332
+ activeChapter: snapshot.pipeline.chapter,
10333
+ stage: snapshot.pipeline.stage,
10334
+ archived: snapshot.chapters.chapters.map((record) => record.num),
10335
+ target: snapshot.project.targets.total_chapters
10336
+ });
10209
10337
  const journal = await this.journalBlocking(snapshot);
10210
10338
  const pending = await inspectPending(this.storeFor(snapshot.root));
10211
10339
  const pendingWarnings = pending.state === "complete" || pending.state === "untouched" ? [{
@@ -10229,6 +10357,7 @@ var EngineService = class {
10229
10357
  active_chapter: snapshot.pipeline.chapter,
10230
10358
  manuscript_hash: snapshot.pipeline.draft !== void 0 && snapshot.text !== null ? sha256(snapshot.text) : null,
10231
10359
  stage: snapshot.pipeline.stage,
10360
+ progress,
10232
10361
  rounds: { ...snapshot.pipeline.rounds },
10233
10362
  blocking: [...this.effectiveBlocking(snapshot), ...journal],
10234
10363
  ...pendingWarnings.length === 0 ? {} : { warnings: pendingWarnings },
@@ -10540,6 +10669,12 @@ var EngineService = class {
10540
10669
  }
10541
10670
  async advise(input = {}) {
10542
10671
  const snapshot = await this.load(input.root);
10672
+ const progress = projectChapterProgress({
10673
+ activeChapter: snapshot.pipeline.chapter,
10674
+ stage: snapshot.pipeline.stage,
10675
+ archived: snapshot.chapters.chapters.map((record) => record.num),
10676
+ target: snapshot.project.targets.total_chapters
10677
+ });
10543
10678
  const journal = await this.journalBlocking(snapshot);
10544
10679
  const next = journal.length > 0 && this.stateErrors(snapshot).length === 0 ? {
10545
10680
  tool: "novel_audit",
@@ -10550,6 +10685,7 @@ var EngineService = class {
10550
10685
  data: {
10551
10686
  stage: snapshot.pipeline.stage,
10552
10687
  chapter: snapshot.pipeline.chapter,
10688
+ progress,
10553
10689
  tool: next.tool,
10554
10690
  args: next.args,
10555
10691
  reason: next.reason,
@@ -10751,7 +10887,12 @@ var EngineService = class {
10751
10887
  id: "pipeline",
10752
10888
  severity: "ok",
10753
10889
  title: "流水线",
10754
- detail: `revision ${snapshot.pipeline.revision} · 第 ${snapshot.pipeline.chapter} 章 · ${snapshot.pipeline.stage}`
10890
+ detail: `revision ${snapshot.pipeline.revision} · ${projectChapterProgress({
10891
+ activeChapter: snapshot.pipeline.chapter,
10892
+ stage: snapshot.pipeline.stage,
10893
+ archived: snapshot.chapters.chapters.map((record) => record.num),
10894
+ target: snapshot.project.targets.total_chapters
10895
+ }).label}`
10755
10896
  });
10756
10897
  for (const error of this.stateErrors(snapshot)) add({
10757
10898
  id: `state:${error.path}`,
@@ -11124,7 +11265,13 @@ var EngineService = class {
11124
11265
  const snapshot = await this.loadAt(selection);
11125
11266
  await this.recordSelectionBestEffort(selection, snapshot);
11126
11267
  const target = snapshot.project.targets.total_chapters;
11127
- const finished = snapshot.pipeline.stage === "done" && target !== null && snapshot.chapters.chapters.length >= target;
11268
+ const progress = projectChapterProgress({
11269
+ activeChapter: snapshot.pipeline.chapter,
11270
+ stage: snapshot.pipeline.stage,
11271
+ archived: snapshot.chapters.chapters.map((record) => record.num),
11272
+ target
11273
+ });
11274
+ const finished = progress.finished;
11128
11275
  const journalBlocking = await this.journalBlocking(snapshot);
11129
11276
  const next = journalBlocking.length > 0 && this.stateErrors(snapshot).length === 0 ? {
11130
11277
  tool: "novel_audit",
@@ -11136,6 +11283,7 @@ var EngineService = class {
11136
11283
  projectPresent: true,
11137
11284
  chapter: snapshot.pipeline.chapter,
11138
11285
  stage: snapshot.pipeline.stage,
11286
+ progress,
11139
11287
  archived: snapshot.chapters.chapters.length,
11140
11288
  target,
11141
11289
  finished,
@@ -11535,6 +11683,7 @@ var EngineService = class {
11535
11683
  summary: input.summary,
11536
11684
  draftTitle: input.chapter_title,
11537
11685
  sceneType: input.scene_type,
11686
+ narrator: input.narrator,
11538
11687
  planTargetWords: snapshot.plan?.target_words ?? null,
11539
11688
  planCast: snapshot.plan?.cast === void 0 ? null : snapshot.plan.cast.map((member) => member.name)
11540
11689
  });
@@ -12863,12 +13012,13 @@ var EngineService = class {
12863
13012
  }
12864
13013
  const summary = projectSummary(snapshot);
12865
13014
  if (option.state !== "ok" && option.title !== "") summary.title = option.title;
12866
- const resume = summary.next === void 0 ? void 0 : { text: formatResumePrompt({
13015
+ const resume = summary.next === void 0 || summary.progress?.finished === true ? void 0 : { text: formatResumePrompt({
12867
13016
  title: summary.title,
12868
13017
  root: summary.root,
12869
13018
  chapter: summary.chapter,
12870
13019
  stage: summary.stage,
12871
- nextTool: summary.next.tool
13020
+ nextTool: summary.next.tool,
13021
+ ...summary.progress === void 0 ? {} : { progress: summary.progress }
12872
13022
  }) };
12873
13023
  return {
12874
13024
  projects,
@@ -12888,12 +13038,13 @@ var EngineService = class {
12888
13038
  const nextTool = status.data.next?.tool;
12889
13039
  return {
12890
13040
  data: {
12891
- text: nextTool === void 0 ? null : formatResumePrompt({
13041
+ text: nextTool === void 0 || status.data.progress?.finished === true ? null : formatResumePrompt({
12892
13042
  title: status.data.project.title,
12893
13043
  root: status.data.project.root,
12894
13044
  chapter: status.data.chapter,
12895
13045
  stage: status.data.stage,
12896
- nextTool
13046
+ nextTool,
13047
+ ...status.data.progress === void 0 ? {} : { progress: status.data.progress }
12897
13048
  }),
12898
13049
  selection: status.data.selection
12899
13050
  },
@@ -12903,12 +13054,17 @@ var EngineService = class {
12903
13054
  }
12904
13055
  async stats(input = {}) {
12905
13056
  const snapshot = await this.load(input.root);
12906
- const totalWords = snapshot.chapters.chapters.reduce((total, chapter) => total + chapter.word_count, 0);
13057
+ const archivedWords = snapshot.chapters.chapters.reduce((total, chapter) => total + chapter.word_count, 0);
13058
+ const activeIndexed = snapshot.chapters.chapters.some((chapter) => chapter.num === snapshot.pipeline.chapter);
13059
+ const draftWords = snapshot.text === null || activeIndexed || snapshot.pipeline.stage === "done" ? 0 : countWordMetrics(snapshot.text).total_word_count;
13060
+ const totalWords = archivedWords + draftWords;
12907
13061
  const scores = snapshot.chapters.chapters.map((chapter) => chapter.score);
12908
13062
  const outline = outlineSummary(snapshot.outline, snapshot.chapters.chapters.length, snapshot.project.targets.total_chapters, snapshot.chapters.chapters.map((record) => record.num));
12909
13063
  const counts = worldbookCounts(snapshot.worldbook);
12910
13064
  return {
12911
13065
  totalWords,
13066
+ archivedWords,
13067
+ draftWords,
12912
13068
  chapters: snapshot.chapters.chapters.length,
12913
13069
  revision: snapshot.pipeline.revision,
12914
13070
  rulepack: `${this.rulepack.name}@${this.rulepack.version}`,
@@ -13406,15 +13562,32 @@ var EngineService = class {
13406
13562
  const archived = snapshot.chapters.chapters;
13407
13563
  const archivedCurrent = archived.some((chapter) => chapter.num === snapshot.pipeline.chapter);
13408
13564
  const target = snapshot.project.targets.total_chapters;
13409
- const finished = snapshot.pipeline.stage === "done" && target !== null && archived.length >= target;
13410
- const draftWords = snapshot.text === null ? 0 : countWordMetrics(snapshot.text).total_word_count;
13565
+ const progress = projectChapterProgress({
13566
+ activeChapter: snapshot.pipeline.chapter,
13567
+ stage: snapshot.pipeline.stage,
13568
+ archived: archived.map((record) => record.num),
13569
+ target
13570
+ });
13571
+ const finished = progress.finished;
13572
+ const draftWords = snapshot.text === null || archivedCurrent || snapshot.pipeline.stage === "done" ? 0 : countWordMetrics(snapshot.text).total_word_count;
13573
+ const archivedWords = archived.reduce((total, chapter) => total + chapter.word_count, 0);
13411
13574
  const pending = await inspectPending(this.storeFor(snapshot.root));
13412
13575
  const stateErrorCount = this.stateErrors(snapshot).length;
13413
- const panelCacheKey = `${snapshot.root}:${snapshot.pipeline.revision}:${pending.state}:${stateErrorCount}:${snapshot.outlineError ?? ""}:${snapshot.worldbookError ?? ""}:${JSON.stringify(snapshot.briefs)}`;
13414
- const cachedPanel = this.panelCache.get(snapshot.root);
13415
- if (cachedPanel?.key === panelCacheKey) return cachedPanel.snapshot;
13416
13576
  const journal = await readJournal(this.storeFor(snapshot.root));
13417
13577
  const journalBlocking = await this.journalBlocking(snapshot);
13578
+ const journalTail = journal.rows[journal.rows.length - 1];
13579
+ const journalKey = sha256(stableJson({
13580
+ rows: journal.rows.length,
13581
+ tail: journalTail === void 0 ? null : [
13582
+ journalTail.seq,
13583
+ journalTail.at,
13584
+ journalTail.status
13585
+ ],
13586
+ blocking: journalBlocking
13587
+ }));
13588
+ const panelCacheKey = `${snapshot.root}:${snapshot.pipeline.revision}:${pending.state}:${stateErrorCount}:${snapshot.outlineError ?? ""}:${snapshot.worldbookError ?? ""}:${JSON.stringify(snapshot.briefs)}:${journalKey}`;
13589
+ const cachedPanel = this.panelCache.get(snapshot.root);
13590
+ if (cachedPanel?.key === panelCacheKey) return cachedPanel.snapshot;
13418
13591
  const outline = outlineSummary(snapshot.outline, archived.length, target, archived.map((record) => record.num));
13419
13592
  const counts = worldbookCounts(snapshot.worldbook);
13420
13593
  const scores = archived.slice(-10).map((chapter) => chapter.score);
@@ -13569,6 +13742,7 @@ var EngineService = class {
13569
13742
  platform: snapshot.project.platform,
13570
13743
  chapter: snapshot.pipeline.chapter,
13571
13744
  stage: snapshot.pipeline.stage,
13745
+ progress,
13572
13746
  revision: snapshot.pipeline.revision,
13573
13747
  rounds: snapshot.pipeline.rounds,
13574
13748
  blocking,
@@ -13589,7 +13763,9 @@ var EngineService = class {
13589
13763
  finished,
13590
13764
  chapters: archived,
13591
13765
  draft,
13592
- totalWords: archived.reduce((total, chapter) => total + chapter.word_count, 0) + (draft === null ? 0 : draft.wordCount),
13766
+ totalWords: archivedWords + draftWords,
13767
+ archivedWords,
13768
+ draftWords,
13593
13769
  averageScore: archived.length === 0 ? 0 : archived.reduce((total, chapter) => total + chapter.score, 0) / archived.length,
13594
13770
  phases: phaseStrip(snapshot.pipeline.stage, finished),
13595
13771
  book,
@@ -13697,6 +13873,12 @@ var EngineService = class {
13697
13873
  nextStep(snapshot) {
13698
13874
  const stage = snapshot.pipeline.stage;
13699
13875
  const chapter = snapshot.pipeline.chapter;
13876
+ const progress = projectChapterProgress({
13877
+ activeChapter: chapter,
13878
+ stage,
13879
+ archived: snapshot.chapters.chapters.map((record) => record.num),
13880
+ target: snapshot.project.targets.total_chapters
13881
+ });
13700
13882
  if (snapshot.chapters.chapters.some((record) => record.num === chapter)) return {
13701
13883
  tool: "novel_amend",
13702
13884
  args: { path: joinRel(STATE_DIR, STATE_FILES.chapters) },
@@ -13747,8 +13929,7 @@ var EngineService = class {
13747
13929
  };
13748
13930
  }
13749
13931
  const step = STAGE_NEXT[stage];
13750
- const target = snapshot.project.targets.total_chapters;
13751
- if (stage === "done" && target !== null && snapshot.chapters.chapters.length >= target) return {
13932
+ if (progress.finished) return {
13752
13933
  tool: "novel_export",
13753
13934
  args: { kind: "epub" },
13754
13935
  reason: "全书已完成;可导出成书,或结束本书"
@@ -14356,6 +14537,7 @@ var EngineService = class {
14356
14537
  briefStage: extras.briefStage ?? briefStageFor(operation, snapshot.pipeline.stage),
14357
14538
  draftTitle: composeTitle(snapshot.pipeline.chapter, extras.draftTitle ?? snapshot.pipeline.draft?.title ?? snapshot.plan?.title ?? null, this.rulepack.format.title.pattern),
14358
14539
  sceneType: extras.sceneType ?? snapshot.pipeline.draft?.scene_type ?? snapshot.plan?.scene_type ?? null,
14540
+ narrator: extras.narrator ?? snapshot.pipeline.draft?.narrator ?? snapshot.plan?.narrator ?? null,
14359
14541
  summary: extras.summary ?? snapshot.pipeline.draft?.summary ?? snapshot.plan?.summary ?? null,
14360
14542
  summaryProvided: extras.summaryProvided,
14361
14543
  summaryOnly: extras.summaryOnly,
@@ -15446,6 +15628,12 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
15446
15628
  problems.push(`archived chapter ${record.num} has no previous outline card; restore its archive backup before repairing target_words/cast/archived_at`);
15447
15629
  continue;
15448
15630
  }
15631
+ for (const field of [
15632
+ "goal",
15633
+ "conflict",
15634
+ "hook",
15635
+ "notes"
15636
+ ]) if (stableJson(before[field]) !== stableJson(card[field])) problems.push(`archived chapter ${record.num} cannot change its ${field}`);
15449
15637
  if (JSON.stringify(before.target_words) !== JSON.stringify(card.target_words)) problems.push(`archived chapter ${record.num} cannot change its target_words`);
15450
15638
  const beforeCast = Array.isArray(before.cast) ? before.cast : [];
15451
15639
  const afterCast = Array.isArray(card.cast) ? card.cast : [];
@@ -15457,6 +15645,27 @@ const amendProblems = (kind, oldDocument, nextDocument, snapshot, path, archived
15457
15645
  for (const [num, card] of oldCards) if (card.status === "archived") expected.add(num);
15458
15646
  const missing = [...expected].filter((num) => byNum.get(num)?.status !== "archived");
15459
15647
  if (missing.length > 0) problems.push(`cannot verify archived cards while chapters.yaml is unreadable; keep archived cards for ${missing.join(", ")}`);
15648
+ for (const [num, before] of oldCards) {
15649
+ if (before.status !== "archived") continue;
15650
+ const card = byNum.get(num);
15651
+ if (card === void 0) continue;
15652
+ if (card.status !== "archived") problems.push(`archived chapter ${num} cannot be changed back to planned`);
15653
+ for (const field of [
15654
+ "title",
15655
+ "scene_type",
15656
+ "word_count",
15657
+ "score",
15658
+ "goal",
15659
+ "conflict",
15660
+ "hook",
15661
+ "notes"
15662
+ ]) if (stableJson(before[field]) !== stableJson(card[field])) problems.push(`archived chapter ${num} cannot change its ${field}`);
15663
+ if (JSON.stringify(before.target_words) !== JSON.stringify(card.target_words)) problems.push(`archived chapter ${num} cannot change its target_words`);
15664
+ const beforeCast = Array.isArray(before.cast) ? before.cast : [];
15665
+ const afterCast = Array.isArray(card.cast) ? card.cast : [];
15666
+ if (beforeCast.length !== afterCast.length || afterCast.some((name) => !beforeCast.includes(name))) problems.push(`archived chapter ${num} cannot change its cast`);
15667
+ if (before.archived_at !== card.archived_at && !(before.archived_at === null && typeof card.archived_at === "string")) problems.push(`archived chapter ${num} cannot change archived_at`);
15668
+ }
15460
15669
  }
15461
15670
  return problems;
15462
15671
  }
@@ -15847,9 +16056,11 @@ function createNovelTools(engine, options = {}) {
15847
16056
  render: (_args, raw) => {
15848
16057
  const value = payload(raw);
15849
16058
  const active = value.active_chapter ?? value.chapter;
15850
- const where = active === value.chapter ? `第 ${value.chapter} 章` : `当前第 ${active} 章 · 查询第 ${value.chapter} 章字数`;
16059
+ const progressLabel = value.progress?.label ?? `第 ${value.chapter} 章 · 阶段 ${value.stage}`;
15851
16060
  return text([
15852
- `《${value.project.title}》 ${where} · ${value.stage}`,
16061
+ `《${value.project.title}》`,
16062
+ `进度:${progressLabel}`,
16063
+ ...active === value.chapter ? [] : [`字数查询:第 ${value.chapter} 章(活动章第 ${active} 章)`],
15853
16064
  ...value.selection === void 0 ? [] : [`项目根 ${value.selection.root} · 选择来源 ${value.selection.source}`],
15854
16065
  ...value.rounds === void 0 ? [] : [`轮次:评审 ${value.rounds.review} / 修订 ${value.rounds.revise} / 校验 ${value.rounds.check}`],
15855
16066
  value.blocking.length === 0 ? "门控:全部通过" : `门控:${value.blocking.length} 条阻塞`,
@@ -16803,9 +17014,11 @@ const failure = (error) => {
16803
17014
  const formatPercent = (value) => value === null || value === void 0 ? "—" : `${Math.round(value * 1e3) / 10}%`;
16804
17015
  const statusText = async (engine) => {
16805
17016
  const { data } = await engine.getStatus({});
17017
+ const progressLabel = data.progress?.label ?? `第 ${data.chapter} 章 · 阶段 ${data.stage}`;
16806
17018
  return [
16807
17019
  `《${data.project.title}》 ${data.project.genre} · ${data.project.platform}`,
16808
- `第 ${data.chapter} 章 · 阶段 ${data.stage}`,
17020
+ `进度:${progressLabel}`,
17021
+ ...data.chapter === data.active_chapter ? [] : [`字数查询:第 ${data.chapter} 章(活动章第 ${data.active_chapter} 章)`],
16809
17022
  ...data.selection === void 0 ? [] : [`项目根 ${data.selection.root} · 选择来源 ${data.selection.source}`],
16810
17023
  `轮次 评审 ${data.rounds.review} / 修订 ${data.rounds.revise} / 校验 ${data.rounds.check}`,
16811
17024
  data.metrics === null ? "字数 —" : `字数 ${data.metrics.total_word_count}(汉字 ${data.metrics.cjk_chars} + 标点 ${data.metrics.punctuation_chars},含标点)`,
@@ -16852,11 +17065,11 @@ function createCommands(engine, host = {}) {
16852
17065
  case "next": {
16853
17066
  const { data } = await engine.advise({});
16854
17067
  const reason = NEXT_DETAILS[data.tool] ?? data.reason;
16855
- return ok(`下一步:${data.tool}\n理由:${reason}${data.blocking.length === 0 ? "" : `\n阻塞 ${data.blocking.length} 条`}`);
17068
+ return ok(`进度:${data.progress?.label ?? `第 ${data.chapter} 章 · 阶段 ${data.stage}`}\n下一步:${data.tool}\n理由:${reason}${data.blocking.length === 0 ? "" : `\n阻塞 ${data.blocking.length} 条`}`);
16856
17069
  }
16857
17070
  case "stats": {
16858
17071
  const stats = await engine.stats({});
16859
- return ok(`章节 ${stats.chapters} 章 · 总字数 ${stats.totalWords} · 平均分 ${stats.averageScore} · 未回收伏笔 ${stats.openForeshadows} · 大纲 ${stats.totalCards} 卡(规划覆盖 ${formatPercent(stats.outlineCoverage)} · 归档覆盖 ${formatPercent(stats.archivedCoverage)}${stats.missingCards > 0 ? ` · 缺卡 ${stats.missingCards}` : ""})· 蓝图 ${stats.blueprintReady ? "就绪" : "未就绪"} · 严格 ${stats.strictOutline ? "开" : "关"} · 目标 ${stats.targetChapters ?? "未设"} 章 · 世界观 ${stats.worldbookEntries} 条 · revision ${stats.revision} · 规则包 ${stats.rulepack}`);
17072
+ return ok(`章节 ${stats.chapters} 章 · 总字数 ${stats.totalWords}(已归档 ${stats.archivedWords} + 草稿 ${stats.draftWords}) · 平均分 ${stats.averageScore} · 未回收伏笔 ${stats.openForeshadows} · 大纲 ${stats.totalCards} 卡(规划覆盖 ${formatPercent(stats.outlineCoverage)} · 归档覆盖 ${formatPercent(stats.archivedCoverage)}${stats.missingCards > 0 ? ` · 缺卡 ${stats.missingCards}` : ""})· 蓝图 ${stats.blueprintReady ? "就绪" : "未就绪"} · 严格 ${stats.strictOutline ? "开" : "关"} · 目标 ${stats.targetChapters ?? "未设"} 章 · 世界观 ${stats.worldbookEntries} 条 · revision ${stats.revision} · 规则包 ${stats.rulepack}`);
16860
17073
  }
16861
17074
  case "outline": {
16862
17075
  const status = await engine.getStatus({});
@@ -17000,8 +17213,10 @@ function createCommands(engine, host = {}) {
17000
17213
  }
17001
17214
  case "resume": {
17002
17215
  const { data } = await engine.resumePrompt({});
17003
- if (data.text === null) return bad("当前项目没有可续写的下一步;先用 /novel status 查看项目状态。");
17004
- return ok(data.text);
17216
+ if (data.text !== null) return ok(data.text);
17217
+ const status = await engine.getStatus({});
17218
+ if (status.data.progress?.finished === true) return ok(status.data.progress.resume);
17219
+ return bad("当前项目没有可续写的下一步;先用 /novel status 查看项目状态。");
17005
17220
  }
17006
17221
  case "admin": {
17007
17222
  if (rest.length === 0) return ok(adminHelp());
@@ -17061,7 +17276,7 @@ function createCommands(engine, host = {}) {
17061
17276
  if (invocation.signal.aborted) return bad("已取消");
17062
17277
  try {
17063
17278
  const stats = await engine.stats({});
17064
- return ok(`章节 ${stats.chapters} 章 · 总字数 ${stats.totalWords} · 平均分 ${stats.averageScore} · 未回收伏笔 ${stats.openForeshadows} · 大纲 ${stats.totalCards} 卡(规划覆盖 ${formatPercent(stats.outlineCoverage)} · 归档覆盖 ${formatPercent(stats.archivedCoverage)}${stats.missingCards > 0 ? ` · 缺卡 ${stats.missingCards}` : ""})· 蓝图 ${stats.blueprintReady ? "就绪" : "未就绪"} · 严格 ${stats.strictOutline ? "开" : "关"} · 目标 ${stats.targetChapters ?? "未设"} 章 · 世界观 ${stats.worldbookEntries} 条 · revision ${stats.revision} · 规则包 ${stats.rulepack}`);
17279
+ return ok(`章节 ${stats.chapters} 章 · 总字数 ${stats.totalWords}(已归档 ${stats.archivedWords} + 草稿 ${stats.draftWords}) · 平均分 ${stats.averageScore} · 未回收伏笔 ${stats.openForeshadows} · 大纲 ${stats.totalCards} 卡(规划覆盖 ${formatPercent(stats.outlineCoverage)} · 归档覆盖 ${formatPercent(stats.archivedCoverage)}${stats.missingCards > 0 ? ` · 缺卡 ${stats.missingCards}` : ""})· 蓝图 ${stats.blueprintReady ? "就绪" : "未就绪"} · 严格 ${stats.strictOutline ? "开" : "关"} · 目标 ${stats.targetChapters ?? "未设"} 章 · 世界观 ${stats.worldbookEntries} 条 · revision ${stats.revision} · 规则包 ${stats.rulepack}`);
17065
17280
  } catch (error) {
17066
17281
  return failure(error);
17067
17282
  }
@@ -17837,4 +18052,4 @@ function connectionFence(scope) {
17837
18052
  }
17838
18053
  if (Object.prototype.hasOwnProperty.call(candidate, "connection")) return candidate.connection;
17839
18054
  }
17840
- export { ARCHIVE_DIGESTS_FILE, BACKUP_DIR, BRIEF_RECEIPTS_FILE, BriefLibrary, CHAPTERS_DIR, CHECK_DIR, CORE_TOOLS, Config, DEFAULT_GENRE, DEFAULT_PLATFORM, ERROR_CODES, EXPORT_DIR, EngineBlockedError, EngineError, EngineFaultError, EngineInputError, EngineService, GENRES, HISTORY_DIR, HISTORY_KINDS, INITIAL_STAGE, LIMITS, LOCK_FILE, METADATA_LOCK_FILE, OPERATIONS, OUTLINE_DEPRECATED_FILE, OUTLINE_DIR, OUTLINE_FILE, PANEL_CHAPTER_LIMIT, PANEL_ID, PANEL_ROUTE, PHASE_STRIP, PLAN_DIR, PLATFORMS, PLUGIN_NAME, PROJECT_FILE, REGISTRY_FILE, REMOTE_VERSION, REVIEW_DIR, SCENE_TYPES, SCENE_TYPE_DEFAULT, SCHEMA_VERSION, SESSION_BINDINGS_FILE, SKILL_NAME, STAGING_DIR, STATE_DIR, STATE_FILES, TOOL_NAMES, WORLDBOOK_FILE, applicableGates, apply, blockedError, briefStageFor, chapterFile, chapterHeading, chapterHistoryFile, checkFile, checkHistoryFile, deprecatedChapterFile, deprecatedHistoryFile, evaluateGates, faultError, formatResumePrompt, gateName, inject, inputError, loadRulepack, name, parseParagraphLocation, phaseStrip, planChapterFile, planHistoryFile, planWorldFile, projectChapter, projectChapters, projectGates, projectSummary, renderBlurb, renderCover, renderEpubChapter, renderMarkdown, renderPlainText, renderProposal, reviewFile, reviewHistoryFile, verifyReference };
18055
+ export { ARCHIVE_DIGESTS_FILE, BACKUP_DIR, BRIEF_RECEIPTS_FILE, BriefLibrary, CHAPTERS_DIR, CHECK_DIR, CORE_TOOLS, Config, DEFAULT_GENRE, DEFAULT_PLATFORM, ERROR_CODES, EXPORT_DIR, EngineBlockedError, EngineError, EngineFaultError, EngineInputError, EngineService, GENRES, HISTORY_DIR, HISTORY_KINDS, INITIAL_STAGE, LIMITS, LOCK_FILE, METADATA_LOCK_FILE, OPERATIONS, OUTLINE_DEPRECATED_FILE, OUTLINE_DIR, OUTLINE_FILE, PANEL_CHAPTER_LIMIT, PANEL_ID, PANEL_ROUTE, PHASE_STRIP, PLAN_DIR, PLATFORMS, PLUGIN_NAME, PROJECT_FILE, REGISTRY_FILE, REMOTE_VERSION, REVIEW_DIR, SCENE_TYPES, SCENE_TYPE_DEFAULT, SCHEMA_VERSION, SESSION_BINDINGS_FILE, SKILL_NAME, STAGING_DIR, STATE_DIR, STATE_FILES, TOOL_NAMES, WORLDBOOK_FILE, applicableGates, apply, blockedError, briefStageFor, chapterFile, chapterHeading, chapterHistoryFile, checkFile, checkHistoryFile, deprecatedChapterFile, deprecatedHistoryFile, evaluateGates, faultError, formatResumePrompt, gateName, inject, inputError, loadRulepack, name, parseParagraphLocation, phaseStrip, planChapterFile, planHistoryFile, planWorldFile, projectChapter, projectChapterProgress, projectChapters, projectGates, projectSummary, renderBlurb, renderCover, renderEpubChapter, renderMarkdown, renderPlainText, renderProposal, reviewFile, reviewHistoryFile, verifyReference };
@@ -11,6 +11,23 @@ export interface BlockingDto {
11
11
  detail: string;
12
12
  hint?: string;
13
13
  }
14
+ /** Chapter-boundary projection; absent on older payloads. */
15
+ export interface ProgressDto {
16
+ active_chapter: number;
17
+ stage: string;
18
+ current: {
19
+ chapter: number;
20
+ stage: string;
21
+ } | null;
22
+ last_archived: number | null;
23
+ next: {
24
+ chapter: number;
25
+ stage: 'P1';
26
+ } | null;
27
+ finished: boolean;
28
+ label: string;
29
+ resume: string;
30
+ }
14
31
  export interface SummaryDto {
15
32
  schemaVersion: number;
16
33
  panelId: string;
@@ -21,8 +38,11 @@ export interface SummaryDto {
21
38
  platform: string;
22
39
  chapter: number;
23
40
  stage: string;
41
+ progress?: ProgressDto;
24
42
  revision: number;
25
43
  totalWords: number;
44
+ archivedWords?: number;
45
+ draftWords?: number;
26
46
  totalChapters: number;
27
47
  /** Book-wide archived average; absent on older payloads. */
28
48
  averageScore?: number;
@@ -82,6 +82,8 @@ export interface PanelView {
82
82
  stats: {
83
83
  chapters: number;
84
84
  words: number;
85
+ archivedWords?: number;
86
+ draftWords?: number;
85
87
  averageScore: number;
86
88
  blocking: number;
87
89
  };
@@ -301,8 +301,8 @@ export interface CharacterDelta {
301
301
  role?: string;
302
302
  profile?: Record<string, unknown>;
303
303
  current_state?: Record<string, unknown>;
304
+ /** Chapter is engine-assigned from the archive transaction, not caller-supplied. */
304
305
  growth?: {
305
- chapter: number;
306
306
  change: string;
307
307
  status_change: string;
308
308
  };
@@ -972,6 +972,8 @@ export interface GateInput {
972
972
  briefStage: BriefStage | null;
973
973
  draftTitle: string | null;
974
974
  sceneType: SceneType | null;
975
+ /** The submitted/active plan narrator; PG-05 uses it before a draft exists. */
976
+ narrator?: string | null;
975
977
  summary: ChapterSummary | null;
976
978
  /** P5 may omit `summary`; when omitted, the previous plan summary is reused. */
977
979
  summaryProvided?: boolean;
@@ -1034,6 +1036,36 @@ export interface Selection {
1034
1036
  title?: string;
1035
1037
  created_at?: string;
1036
1038
  }
1039
+ /**
1040
+ * One human-readable projection of the pipeline's chapter boundary.
1041
+ *
1042
+ * `pipeline.chapter`/`stage` are protocol values: after archiving chapter N the
1043
+ * pipeline points at N+1 and still says `done` because `done` describes the
1044
+ * chapter that was just archived. This projection is the only place that turns
1045
+ * those values into "chapter N archived; chapter N+1 starts at P1".
1046
+ */
1047
+ export interface ChapterProgress {
1048
+ active_chapter: number;
1049
+ stage: Stage;
1050
+ /** The chapter currently being written; null while `stage === 'done'`. */
1051
+ current: {
1052
+ chapter: number;
1053
+ stage: Stage;
1054
+ } | null;
1055
+ /** Highest archived chapter, or null before the first archive. */
1056
+ last_archived: number | null;
1057
+ /** Next chapter to start at P1; null while writing or after the book is finished. */
1058
+ next: {
1059
+ chapter: number;
1060
+ stage: 'P1';
1061
+ } | null;
1062
+ /** True only when the configured target has been reached at `stage === 'done'`. */
1063
+ finished: boolean;
1064
+ /** One line for status cards, commands and the panel. */
1065
+ label: string;
1066
+ /** Sentence used by the resume prompt instead of "当前第 N 章,阶段 done". */
1067
+ resume: string;
1068
+ }
1037
1069
  /** The `getStatus` payload. */
1038
1070
  export interface StatusReport {
1039
1071
  project: {
@@ -1054,6 +1086,8 @@ export interface StatusReport {
1054
1086
  /** SHA-256 of the active manuscript, or null when the stage has no draft. */
1055
1087
  manuscript_hash: string | null;
1056
1088
  stage: Stage;
1089
+ /** Chapter-boundary projection shared by every human-readable surface. */
1090
+ progress?: ChapterProgress;
1057
1091
  rounds: RoundCounters;
1058
1092
  blocking: BlockingItem[];
1059
1093
  /** Non-blocking recovery notes, e.g. a recoverable pending transaction. */
@@ -1106,6 +1140,8 @@ export interface StatusReport {
1106
1140
  export interface AdviceReport {
1107
1141
  stage: Stage;
1108
1142
  chapter: number;
1143
+ /** Same chapter-boundary projection as getStatus. */
1144
+ progress?: ChapterProgress;
1109
1145
  tool: string;
1110
1146
  args: Record<string, unknown>;
1111
1147
  reason: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-write-engine",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Novel writing engine for DeepSeek Harness: state machine, gates, rulepacks and ledgers moved from prompts into code.",
5
5
  "keywords": [
6
6
  "dsh-plugin",