dsh-novel-write-engine 0.2.6 → 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/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`);
@@ -5595,7 +5658,7 @@ function applyStateDelta(input) {
5595
5658
  "profile",
5596
5659
  "current_state",
5597
5660
  "growth"
5598
- ]) if (declared[field] !== void 0 && !isRecord(declared[field])) fail$1(`characters.${name}.${field} must be an object; expected ${field === "current_state" ? "{\"resources\":{\"港币\":3},\"status\":\"...\"}" : field === "profile" ? "{\"age\":31,\"occupation\":\"...\"}" : "{\"change\":\"...\",\"status_change\":\"...\"}"}`);
5661
+ ]) if (declared[field] !== void 0 && !isRecord(declared[field])) fail$1(`characters.${name}.${field} must be an object; expected ${field === "current_state" ? "{\"resources\":{\"资源名\":3},\"status\":\"...\"}" : field === "profile" ? "{\"age\":31,\"occupation\":\"...\"}" : "{\"change\":\"...\",\"status_change\":\"...\"}"}`);
5599
5662
  if (declared.growth !== void 0) {
5600
5663
  const growth = declared.growth;
5601
5664
  for (const field of ["change", "status_change"]) if (typeof growth[field] !== "string" || growth[field].trim() === "") fail$1(`characters.${name}.growth.${field} must be a non-empty string`);
@@ -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,
@@ -10650,7 +10786,7 @@ var EngineService = class {
10650
10786
  } catch {}
10651
10787
  if (oldChecks > 0) issues.push({
10652
10788
  code: "LEGACY_CHECK_FORMAT",
10653
- detail: `${oldChecks} 章 check 是 0.2.0 旧格式(advisory 混在 violations);重新跑 novel_check 或按 docs/0.2.3-plan.md 标记`
10789
+ detail: `${oldChecks} 章 check 是 0.2.0 旧格式(advisory 混在 violations);重新跑 novel_check 或按 0.2.3 迁移说明标记`
10654
10790
  });
10655
10791
  }
10656
10792
  const hints = /* @__PURE__ */ new Map();
@@ -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}`,
@@ -10943,7 +11084,7 @@ var EngineService = class {
10943
11084
  severity: "warning",
10944
11085
  title: "旧数据升级提示",
10945
11086
  detail: `${legacy.length} 类旧数据需要解释或人工补齐:${legacy.slice(0, 3).map((issue) => `${issue.code}(${issue.detail})`).join(";")}`,
10946
- hint: "这是只读审计,不会自动改历史;按 docs/0.2.3-plan.md WP0 处理,补 anchor/role 前先核对原文"
11087
+ hint: "这是只读审计,不会自动改历史;按 0.2.3 的 WP0 处理,补 anchor/role 前先核对原文"
10947
11088
  });
10948
11089
  const locked = await store.exists(LOCK_FILE);
10949
11090
  add({
@@ -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,
@@ -11235,7 +11383,7 @@ var EngineService = class {
11235
11383
  reason: "no_change 必填;created/updated 建议写",
11236
11384
  role: "protagonist | supporting | antagonist | extra(可选)",
11237
11385
  profile: "object(可选)",
11238
- current_state: "object(可选,如 {\"resources\":{\"港币\":-3}})"
11386
+ current_state: "object(可选,如 {\"resources\":{\"资源名\":-3}})"
11239
11387
  } },
11240
11388
  foreshadows: { "<summary.planted.key 或 FS-xxx>": {
11241
11389
  action: "created | updated | no_change",
@@ -11265,27 +11413,27 @@ var EngineService = class {
11265
11413
  },
11266
11414
  examples: [{
11267
11415
  summary_excerpt: {
11268
- characters_appeared: ["陈嘉树"],
11416
+ characters_appeared: ["角色甲"],
11269
11417
  foreshadows: {
11270
11418
  planted: [],
11271
11419
  advanced: ["FS-001"],
11272
11420
  resolved: []
11273
11421
  }
11274
11422
  },
11275
- characters: { 陈嘉树: {
11423
+ characters: { 角色甲: {
11276
11424
  action: "updated",
11277
- current_state: { resources: { 港币: -3 } }
11425
+ current_state: { resources: { 资源名: -3 } }
11278
11426
  } },
11279
11427
  foreshadows: { "FS-001": {
11280
11428
  action: "updated",
11281
- note: "欠条被收回",
11429
+ note: "债务已结清",
11282
11430
  new_info: true
11283
11431
  } }
11284
11432
  }, {
11285
11433
  summary_excerpt: {
11286
11434
  events: ["收到信"],
11287
11435
  new_elements: {
11288
- locations: ["旧码头"],
11436
+ locations: ["地点甲"],
11289
11437
  abilities: [],
11290
11438
  artifacts: []
11291
11439
  }
@@ -11295,31 +11443,31 @@ var EngineService = class {
11295
11443
  story_time: "Day 3 清晨",
11296
11444
  description: "收到信"
11297
11445
  } },
11298
- world: { 旧码头: {
11446
+ world: { 地点甲: {
11299
11447
  action: "created",
11300
11448
  kind: "location",
11301
- description: "退潮时露出石阶"
11449
+ description: "地点说明"
11302
11450
  } }
11303
11451
  }],
11304
11452
  linked_example: {
11305
11453
  summary: {
11306
- characters_appeared: ["陈嘉树"],
11454
+ characters_appeared: ["角色甲"],
11307
11455
  new_elements: {
11308
- locations: ["旧码头"],
11456
+ locations: ["地点甲"],
11309
11457
  abilities: [],
11310
11458
  artifacts: []
11311
11459
  },
11312
11460
  events: ["收到信"]
11313
11461
  },
11314
11462
  delta: {
11315
- characters: { 陈嘉树: {
11463
+ characters: { 角色甲: {
11316
11464
  action: "no_change",
11317
11465
  reason: "本章无变化"
11318
11466
  } },
11319
- world: { 旧码头: {
11467
+ world: { 地点甲: {
11320
11468
  action: "created",
11321
11469
  kind: "location",
11322
- description: "退潮时露出石阶"
11470
+ description: "地点说明"
11323
11471
  } },
11324
11472
  timeline: { "0": {
11325
11473
  action: "created",
@@ -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 };